V2 alpha - initial version (#172)

Initial alpha codebase for version 2 of WireGuard Portal.
This version is considered unstable and incomplete (for example, no public REST API)! 
Use with care!


Fixes/Implements the following issues:
 - OAuth support #154, #1 
 - New Web UI with internationalisation support #98, #107, #89, #62
 - Postgres Support #49 
 - Improved Email handling #47, #119 
 - DNS Search Domain support #46 
 - Bugfixes #94, #48 

---------

Co-authored-by: Fabian Wechselberger <wechselbergerf@hotmail.com>
This commit is contained in:
h44z 2023-08-04 13:34:18 +02:00 committed by GitHub
parent b3a5f2ac60
commit 8b820a5adf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
788 changed files with 46139 additions and 11281 deletions

View File

@ -7,6 +7,10 @@ jobs:
- restore_cache:
keys:
- go-mod-latest-v4-{{ checksum "go.sum" }}
- run:
name: Build Frontend
command: |
make frontend
- run:
name: Install Dependencies
command: |
@ -51,42 +55,13 @@ jobs:
fi
working_directory: ~/repo
docker:
- image: cimg/go:1.19
build-118: # just to validate compatibility with minimum go version
steps:
- checkout
- restore_cache:
keys:
- go-mod-118-v4-{{ checksum "go.sum" }}
- run:
name: Install Dependencies
command: |
make build-dependencies
- save_cache:
key: go-mod-118-v4-{{ checksum "go.sum" }}
paths:
- "~/go/pkg/mod"
- run:
name: Build
command: |
VERSION=$CIRCLE_BRANCH
if [ ! -z "${CIRCLE_TAG}" ]; then VERSION=$CIRCLE_TAG; fi
make ENV_BUILD_IDENTIFIER=$VERSION ENV_BUILD_VERSION=$(echo $CIRCLE_SHA1 | cut -c1-7) build
working_directory: ~/repo118
docker:
- image: cimg/go:1.18
- image: cimg/go:1.20-node
workflows:
build-and-release:
jobs:
#--------------- BUILD ---------------#
- build-latest:
filters:
tags:
only: /^v.*/
- build-118:
requires:
- build-latest
filters:
tags:
only: /^v.*/

7
.gitignore vendored
View File

@ -28,12 +28,11 @@
out/
dist/
data/
docker_images/
ssh.key
.testCoverage.txt
wg_portal.db
sqlite.db
go.sum
swagger.json
swagger.yaml
/config.yml
sqlite.db
node_modules/
/config.yml

View File

@ -0,0 +1,11 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="swag_build_tool" type="GoApplicationRunConfiguration" factoryName="Go Application">
<module name="wg-portal" />
<working_directory value="$PROJECT_DIR$" />
<kind value="PACKAGE" />
<package value="github.com/h44z/wg-portal/cmd/api_build_tool" />
<directory value="$PROJECT_DIR$" />
<filePath value="$PROJECT_DIR$/internal/ports/api/build_tool/main.go" />
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,17 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="wg-portal-migrate" type="GoApplicationRunConfiguration" factoryName="Go Application">
<module name="wg-portal" />
<working_directory value="$PROJECT_DIR$" />
<parameters value="-migrateFrom=wg_portal.db" />
<envs>
<env name="SESSION_SECRET" value="extremlybad" />
<env name="LOG_LEVEL" value="trace" />
</envs>
<sudo value="true" />
<kind value="PACKAGE" />
<package value="github.com/h44z/wg-portal/cmd/wg-portal" />
<directory value="$PROJECT_DIR$" />
<filePath value="$PROJECT_DIR$/cmd/wg-portal/main.go" />
<method v="2" />
</configuration>
</component>

16
.run/wg-portal.run.xml Normal file
View File

@ -0,0 +1,16 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="wg-portal" type="GoApplicationRunConfiguration" factoryName="Go Application">
<module name="wg-portal" />
<working_directory value="$PROJECT_DIR$" />
<envs>
<env name="SESSION_SECRET" value="extremlybad" />
<env name="LOG_LEVEL" value="trace" />
</envs>
<sudo value="true" />
<kind value="PACKAGE" />
<package value="github.com/h44z/wg-portal/cmd/wg-portal" />
<directory value="$PROJECT_DIR$" />
<filePath value="$PROJECT_DIR$/cmd/wg-portal/main.go" />
<method v="2" />
</configuration>
</component>

View File

@ -4,7 +4,7 @@
######-
# Start from the latest golang base image as builder image (only used to compile the code)
######-
FROM golang:1.18 as builder
FROM golang:1.20 as builder
ARG BUILD_IDENTIFIER
ENV ENV_BUILD_IDENTIFIER=$BUILD_IDENTIFIER
@ -42,12 +42,9 @@ COPY --from=builder /etc/group /etc/group
# Copy binaries
COPY --from=builder /build/dist/wg-portal /app/wg-portal
COPY --from=builder /build/dist/hc /app/hc
# Set the Current Working Directory inside the container
WORKDIR /app
# Command to run the executable
CMD [ "/app/wg-portal" ]
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 CMD [ "/app/hc", "http://localhost:11223/health" ]
CMD [ "/app/wg-portal" ]

View File

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

View File

@ -5,6 +5,7 @@ GOFILES:=$(shell go list ./... | grep -v /vendor/)
BUILDDIR=dist
BINARIES=$(subst cmd/,,$(wildcard cmd/*))
IMAGE=h44z/wg-portal
NPMCMD=npm
all: help
@ -79,10 +80,6 @@ build: build-dependencies
-tags netgo \
cmd/wg-portal/main.go
CGO_ENABLED=0 $(GOCMD) build -o $(BUILDDIR)/hc \
-ldflags "-w -s -extldflags \"-static\"" \
cmd/hc/main.go
#< build-amd64: Build all executables for AMD64
.PHONY: build-amd64
build-amd64: build-dependencies
@ -91,10 +88,6 @@ build-amd64: build-dependencies
-tags netgo \
cmd/wg-portal/main.go
CGO_ENABLED=0 $(GOCMD) build -o $(BUILDDIR)/hc-amd64 \
-ldflags "-w -s -extldflags \"-static\"" \
cmd/hc/main.go
#< build-arm64: Build all executables for ARM64
.PHONY: build-arm64
build-arm64: build-dependencies
@ -103,10 +96,6 @@ build-arm64: build-dependencies
-tags netgo \
cmd/wg-portal/main.go
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GOCMD) build -o $(BUILDDIR)/hc-arm64 \
-ldflags "-w -s -extldflags \"-static\"" \
cmd/hc/main.go
#< build-arm: Build all executables for ARM32
.PHONY: build-arm
build-arm: build-dependencies
@ -115,10 +104,6 @@ build-arm: build-dependencies
-tags netgo \
cmd/wg-portal/main.go
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 $(GOCMD) build -o $(BUILDDIR)/hc-arm \
-ldflags "-w -s -extldflags \"-static\"" \
cmd/hc/main.go
#< build-dependencies: Generate the output directory for compiled executables and download dependencies
.PHONY: build-dependencies
build-dependencies:
@ -126,3 +111,13 @@ build-dependencies:
@mkdir -p $(BUILDDIR)
cp scripts/wg-portal.service $(BUILDDIR)
cp scripts/wg-portal.env $(BUILDDIR)
#< frontend: Build Vue.js frontend
frontend: frontend-dependencies
cd frontend; $(NPMCMD) run build
#< frontend-dependencies: Generate the output directory for compiled executables and download frontend dependencies
.PHONY: frontend-dependencies
frontend-dependencies:
@mkdir -p $(BUILDDIR)
cd frontend; $(NPMCMD) install

View File

@ -1,51 +0,0 @@
# WireGuard Portal on Raspberry Pi
This readme only contains a detailed explanation of how to set up the WireGuard Portal service on a raspberry pi (>= 3).
## Setup
You can either download prebuild binaries from the [release page](https://github.com/h44z/wg-portal/releases) or use Docker images for ARM.
If you want to build the binary yourself, use the following building instructions.
### Building
This section describes how to build the WireGuard Portal code.
To compile the final binary, use the Makefile provided in the repository.
As WireGuard Portal is written in Go, **golang >= 1.18** 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`

365
README.md
View File

@ -1,4 +1,4 @@
# WireGuard Portal
# WireGuard Portal (V2 - alpha testing)
[![Build Status](https://travis-ci.com/h44z/wg-portal.svg?token=q4pSqaqT58Jzpxdx62xk&branch=master)](https://travis-ci.com/h44z/wg-portal)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT)
@ -8,249 +8,196 @@
![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/h44z/wg-portal)
[![Docker Pulls](https://img.shields.io/docker/pulls/h44z/wg-portal.svg)](https://hub.docker.com/r/h44z/wg-portal/)
> :warning: **IMPORTANT** Version 2 is currently under development and may contain bugs. It is currently not advised to use this version
in production. Use version [1.0.18](https://github.com/h44z/wg-portal/releases) instead.
A simple, web based configuration portal for [WireGuard](https://wireguard.com).
The portal uses the WireGuard [wgctrl](https://github.com/WireGuard/wgctrl-go) library to manage existing VPN
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
* Self-hosted - the whole application is a single binary
* Responsive web UI written in Vue.JS
* Automatically select IP from the network pool assigned to client
* 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 wg-quick configuration file (`wgX.conf`) if required
* User authentication (database, OAuth or LDAP)
* IPv6 ready
* User authentication (SQLite/MySQL and LDAP)
* Dockerized
* Responsive template
* One single binary
* Docker ready
* Can be used with existing WireGuard setups
* Support for multiple WireGuard interfaces
* REST API for management and client deployment
* Peer Expiry Feature
* Handle route and DNS settings like wg-quick does
* ~~REST API for management and client deployment~~ (coming soon)
![Screenshot](screenshot.png)
## Setup
Make sure that your host system has at least one WireGuard interface (for example wg0) available.
If you did not start up a WireGuard interface yet, take a look at [wg-quick](https://manpages.debian.org/unstable/wireguard-tools/wg-quick.8.en.html) in order to get started.
### Docker
The easiest way to run WireGuard Portal is to use the Docker image provided.
HINT: the *latest* tag always refers to the master branch and might contain unstable or incompatible code!
Docker Compose snippet with some sample configuration values:
```
version: '3.6'
services:
wg-portal:
image: h44z/wg-portal:latest
container_name: wg-portal
restart: unless-stopped
cap_add:
- NET_ADMIN
network_mode: "host"
volumes:
- /etc/wireguard:/etc/wireguard
- ./data:/app/data
ports:
- '8123:8123'
environment:
# WireGuard Settings
- WG_DEVICES=wg0
- WG_DEFAULT_DEVICE=wg0
- WG_CONFIG_PATH=/etc/wireguard
# Core Settings
- EXTERNAL_URL=https://vpn.company.com
- WEBSITE_TITLE=WireGuard VPN
- COMPANY_NAME=Your Company Name
- ADMIN_USER=admin@domain.com
- ADMIN_PASS=supersecret
# Mail Settings
- MAIL_FROM=WireGuard VPN <noreply+wireguard@company.com>
- EMAIL_HOST=10.10.10.10
- EMAIL_PORT=25
# LDAP Settings
- LDAP_ENABLED=true
- LDAP_URL=ldap://srv-ad01.company.local:389
- LDAP_BASEDN=DC=COMPANY,DC=LOCAL
- LDAP_USER=ldap_wireguard@company.local
- LDAP_PASSWORD=supersecretldappassword
- LDAP_ADMIN_GROUP=CN=WireGuardAdmins,OU=Users,DC=COMPANY,DC=LOCAL
```
Please note that mapping ```/etc/wireguard``` to ```/etc/wireguard``` inside the docker, will erase your host's current configuration.
If needed, please make sure to back up your files from ```/etc/wireguard```.
For a full list of configuration options take a look at the source file [internal/server/configuration.go](internal/server/configuration.go#L58).
### Standalone
For a standalone application, use the Makefile provided in the repository to build the application. Go version 1.18 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. |
| 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 | The time interval after which users will be synchronized from LDAP. |
| start_listen_port | advanced | 51820 | The first port number that will be used as listening port for new interfaces. |
| start_cidr_v4 | advanced | 10.11.12.0/24 | The first IPv4 subnet that will be used for new interfaces. |
| start_cidr_v6 | advanced | fdfd:d3ad:c0de:1234::0/64 | The first IPv6 subnet that will be used for new interfaces. |
| use_ip_v6 | advanced | true | Enable IPv6 support. |
| config_storage_path | advanced | | If a wg-quick style configuration should be stored to the filesystem, specify a storage directory. |
| expiry_check_interval | advanced | 15m | The interval after which existing peers will be checked if they expired. |
| rule_prio_offset | advanced | 20000 | The default offset for ip route rule priorities. |
| route_table_offset | advanced | 20000 | The default offset for ip route table id's. |
| use_ping_checks | statistics | true | If enabled, peers will be pinged periodically to check if they are still connected. |
| ping_check_workers | statistics | 10 | Number of parallel ping checks that will be executed. |
| ping_unprivileged | statistics | false | If set to false, the ping checks will run without root permissions (BETA). |
| ping_check_interval | statistics | 1m | The interval time between two ping check runs. |
| data_collection_interval | statistics | 10m | The interval between the data collection cycles. |
| collect_interface_data | statistics | true | A flag to enable interface data collection like bytes sent and received. |
| collect_peer_data | statistics | true | A flag to enable peer data collection like bytes sent and received, last handshake and remote endpoint address. |
| collect_audit_data | statistics | true | If enabled, some events, like portal logins, will be logged to the database. |
| host | mail | 127.0.0.1 | The mail-server address. |
| port | mail | 25 | The mail-server SMTP port. |
| encryption | mail | none | SMTP encryption type, allowed values: none, tls, starttls. |
| cert_validation | mail | false | Validate the mail server certificate (if encryption tls is used). |
| username | mail | | The SMTP user name. |
| password | mail | | The SMTP password. |
| auth_type | mail | plain | SMTP authentication type, allowed values: plain, login, crammd5. |
| from | mail | Wireguard Portal <noreply@wireguard.local> | The address that is used to send mails. |
| link_only | mail | false | Only send links to WireGuard Portal instead of the full configuration. |
| callback_url_prefix | auth | /api/v0 | OAuth callback URL prefix. The full callback URL will look like: https://wg.portal.local/callback_url_prefix/provider_name/callback |
| oidc | auth | Empty Array - no providers configured | A list of OpenID Connect providers. See auth/oidc properties to setup a new provider. |
| oauth | auth | Empty Array - no providers configured | A list of plain OAuth providers. See auth/oauth properties to setup a new provider. |
| ldap | auth | Empty Array - no providers configured | A list of LDAP providers. See auth/ldap properties to setup a new provider. |
| provider_name | auth/oidc | | A unique provider name. This name must be unique throughout all authentication providers (even other types). |
| display_name | auth/oidc | | The display name is shown at the login page (the login button). |
| base_url | auth/oidc | | The base_url is the URL identifier for the service. For example: "https://accounts.google.com". |
| client_id | auth/oidc | | The OAuth client id. |
| client_secret | auth/oidc | | The OAuth client secret. |
| extra_scopes | auth/oidc | | Extra scopes that should be used in the OpenID Connect authentication flow. |
| field_map | auth/oidc | | Mapping of user fields. Internal fields: user_identifier, email, firstname, lastname, phone, department and is_admin. |
| registration_enabled | auth/oidc | | If registration is enabled, new user accounts will created in WireGuard Portal. |
| provider_name | auth/oauth | | A unique provider name. This name must be unique throughout all authentication providers (even other types). |
| display_name | auth/oauth | | The display name is shown at the login page (the login button). |
| base_url | auth/oauth | | The base_url is the URL identifier for the service. For example: "https://accounts.google.com". |
| client_id | auth/oauth | | The OAuth client id. |
| client_secret | auth/oauth | | The OAuth client secret. |
| auth_url | auth/oauth | | The URL for the authentication endpoint. |
| token_url | auth/oauth | | The URL for the token endpoint. |
| redirect_url | auth/oauth | | The redirect URL. |
| user_info_url | auth/oauth | | The URL for the user information endpoint. |
| scopes | auth/oauth | | OAuth scopes. |
| field_map | auth/oauth | | Mapping of user fields. Internal fields: user_identifier, email, firstname, lastname, phone, department and is_admin. |
| registration_enabled | auth/oauth | | If registration is enabled, new user accounts will created in WireGuard Portal. |
| url | auth/ldap | | The LDAP server url. For example: ldap://srv-ad01.company.local:389 |
| start_tls | auth/ldap | | Use STARTTLS to encrypt LDAP requests. |
| cert_validation | auth/ldap | | Validate the LDAP server certificate. |
| tls_certificate_path | auth/ldap | | A path to the TLS certificate. |
| tls_key_path | auth/ldap | | A path to the TLS key. |
| base_dn | auth/ldap | | The base DN for searching users. For example: DC=COMPANY,DC=LOCAL |
| bind_user | auth/ldap | | The bind user. For example: company\\ldap_wireguard |
| bind_pass | auth/ldap | | The bind password. |
| field_map | auth/ldap | | Mapping of user fields. Internal fields: user_identifier, email, firstname, lastname, phone, department and memberof. |
| login_filter | auth/ldap | | LDAP filters for users that should be allowed to log in. {{login_identifier}} will be replaced with the login username. |
| admin_group | auth/ldap | | Users in this group are marked as administrators. |
| synchronize | auth/ldap | | Periodically synchronize users (name, department, phone, status, ...) to the WireGuard Portal database. |
| disable_missing | auth/ldap | | If synchronization is enabled, missing LDAP users will be disabled in WireGuard Portal. |
| sync_filter | auth/ldap | | LDAP filters for users that should be synchronized to WireGuard Portal. |
| registration_enabled | auth/ldap | | If registration is enabled, new user accounts will created in WireGuard Portal. |
| debug | database | false | Debug database statements (log each statement). |
| slow_query_threshold | database | | A threshold for slow database queries. If the threshold is exceeded, a warning message will be logged. |
| type | database | sqlite | The database type. Allowed values: sqlite, mssql, mysql or postgres. |
| dsn | database | sqlite.db | The database DSN. For example: user:pass@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local |
| request_logging | web | false | Log all HTTP requests. |
| external_url | web | http://localhost:8888 | The URL where a client can access WireGuard Portal. |
| listening_address | web | :8888 | The listening port of the web server. |
| session_identifier | web | wgPortalSession | The session identifier for the web frontend. |
| session_secret | web | very_secret | The session secret for the web frontend. |
| csrf_secret | web | extremely_secret | The CSRF secret. |
| site_title | web | WireGuard Portal | The title that is shown in the web frontend. |
| site_company_name | web | WireGuard Portal | The company name that is shown at the bottom of the web frontend. |
### 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
## Upgrading from V1
> :warning: Before upgrading from V1, make sure that you have a backup of your currently working configuration files and database!
To start the upgrade process, start the wg-portal binary with the **-migrateFrom** parameter.
The configuration (config.yml) for WireGuard Portal must be updated and valid before starting the upgrade.
To upgrade from a previous SQLite database, start wg-portal like:
```shell
./wg-portal-amd64 -migrateFrom=old_wg_portal.db
```
### 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`.
You can also specify the database type using the parameter **-migrateFromType**, supported types: mysql, mssql, postgres or sqlite.
For example:
The [API's unittesting](tests/test_API.py) may serve as an example how to make use of the API with python3 & pyswagger.
```shell
./wg-portal-amd64 -migrateFromType=mysql -migrateFrom=user:pass@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
```
The upgrade will transform the old, existing database and store the values in the new database specified in config.yml.
Ensure that the new database does not contain any data!
## V2 TODOs
* Public REST API
* Translations
* Documentation
* Audit UI
## Building
To build a standalone application, use the Makefile provided in the repository.
Go version 1.20 or higher has to be installed to build WireGuard Portal.
If you want to re-compile the frontend, NodeJS 18 and NPM >= 9 is required.
```shell
# build the frontend (optional)
make frontend
# build the binary
make build
```
## 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.
* Automatic generation or application of any `iptables` or `nftables` rules.
* Support for operating systems other than linux.
* Automatic import of private keys of an existing WireGuard setup.
## 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/)
* [wgctrl-go](https://github.com/WireGuard/wgctrl-go) and [netlink](https://github.com/vishvananda/netlink) for interface handling
* [Gin](https://github.com/gin-gonic/gin), HTTP web framework written in Go
* [Bootstrap](https://getbootstrap.com/), for the HTML templates
* [Vue.JS](https://vuejs.org/), for the frontend
## License
* MIT License. [MIT](LICENSE.txt) or https://opensource.org/licenses/MIT
This project was inspired by [wg-gen-web](https://github.com/vx3r/wg-gen-web).

View File

@ -1,190 +0,0 @@
// Lux 4.5.3
// Bootswatch
// Variables ===================================================================
$web-font-path: "https://fonts.googleapis.com/css2?family=Nunito+Sans:wght@400;600&display=swap" !default;
@import url($web-font-path);
// Navbar ======================================================================
.navbar {
font-size: $font-size-sm;
text-transform: uppercase;
font-weight: 600;
&-nav {
.nav-link {
padding-top: .715rem;
padding-bottom: .715rem;
}
}
&-brand {
margin-right: 2rem;
}
}
.bg-primary {
background-color: theme-color("primary") !important;
}
.bg-light {
border: 1px solid rgba(0, 0, 0, .1);
&.navbar-fixed-top {
border-width: 0 0 1px;
}
&.navbar-bottom-top {
border-width: 1px 0 0;
}
}
.nav-item {
margin-right: 2rem;
}
// Buttons =====================================================================
.btn {
font-size: $font-size-sm;
text-transform: uppercase;
&-sm {
font-size: 10px;
}
&-warning {
&,
&:hover,
&:not([disabled]):not(.disabled):active,
&:focus {
color: $white;
}
}
}
.btn-outline-secondary {
border-color: $gray-600;
color: $gray-600;
&:not([disabled]):not(.disabled):hover,
&:not([disabled]):not(.disabled):focus,
&:not([disabled]):not(.disabled):active {
background-color: $gray-400;
border-color: $gray-400;
color: $white;
}
&:not([disabled]):not(.disabled):focus {
box-shadow: 0 0 0 .2rem rgba($gray-400, .5);
}
}
[class*="btn-outline-"] {
border-width: 2px;
}
.border-secondary {
border: 1px solid $gray-400 !important;
}
// Typography ==================================================================
body {
font-weight: 200;
letter-spacing: 1px;
}
h1,
h2,
h3,
h4,
h5,
h6 {
text-transform: uppercase;
letter-spacing: 3px;
}
.text-secondary {
color: $body-color !important;
}
// Tables ======================================================================
th {
font-size: $font-size-sm;
text-transform: uppercase;
}
.table {
th,
td {
padding: 1.5rem;
}
&-sm {
th,
td {
padding: .75rem;
}
}
}
// Forms =======================================================================
.custom-switch {
.custom-control-label {
&::after {
top: add(.15625rem, 2px);
left: add(-2.25rem, 2px);
width: subtract(1rem, 4px);
height: subtract(1rem, 4px);
}
}
}
// Navs ========================================================================
.dropdown-menu {
font-size: $font-size-sm;
text-transform: none;
}
// Indicators ==================================================================
.badge {
padding-top: .28rem;
&-pill {
border-radius: 10rem;
}
}
// Containers ==================================================================
.list-group-item {
h1,
h2,
h3,
h4,
h5,
h6,
.h1,
.h2,
.h3,
.h4,
.h5,
.h6 {
color: inherit;
}
}
.card {
&-title,
&-header {
color: inherit;
}
}

View File

@ -1,106 +0,0 @@
// Lux 4.5.3
// Bootswatch
//
// Color system
//
$white: #fff !default;
$gray-100: #f8f9fa !default;
$gray-200: #f7f7f9 !default;
$gray-300: #eceeef !default;
$gray-400: #ced4da !default;
$gray-500: #adb5bd !default;
$gray-600: #919aa1 !default;
$gray-700: #55595c !default;
$gray-800: #343a40 !default;
$gray-900: #1a1a1a !default;
$black: #000 !default;
$blue: #007bff !default;
$indigo: #6610f2 !default;
$purple: #6f42c1 !default;
$pink: #e83e8c !default;
$red: #d9534f !default;
$orange: #fd7e14 !default;
$yellow: #f0ad4e !default;
$green: #4bbf73 !default;
$teal: #20c997 !default;
$cyan: #1f9bcf !default;
$primary: $gray-900 !default;
$secondary: $white !default;
$success: $green !default;
$info: $cyan !default;
$warning: $yellow !default;
$danger: $red !default;
$light: $white !default;
$dark: $gray-800 !default;
$yiq-contrasted-threshold: 185 !default;
// Options
$enable-rounded: false !default;
// Body
$body-color: $gray-700 !default;
// Fonts
// stylelint-disable-next-line value-keyword-case
$font-family-sans-serif: "Nunito Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol" !default;
$font-size-base: .875rem !default;
$h1-font-size: 2rem !default;
$h2-font-size: 1.75rem !default;
$h3-font-size: 1.5rem !default;
$h4-font-size: 1.25rem !default;
$h5-font-size: 1rem !default;
$h6-font-size: .75rem !default;
$headings-font-weight: 600 !default;
$headings-color: $gray-900 !default;
// Tables
$table-border-color: rgba(0, 0, 0, .05) !default;
// Buttons + Forms
$input-btn-border-width: 0 !default;
// Buttons
$btn-line-height: 1.5rem !default;
$input-btn-padding-y: .75rem !default;
$input-btn-padding-x: 1.5rem !default;
$input-btn-padding-y-sm: .5rem !default;
$input-btn-padding-x-sm: 1rem !default;
$input-btn-padding-y-lg: 2rem !default;
$input-btn-padding-x-lg: 2rem !default;
$btn-font-weight: 600 !default;
// Forms
$input-line-height: 1.5 !default;
$input-bg: $gray-200 !default;
$input-disabled-bg: $gray-300 !default;
$input-group-addon-bg: $gray-300 !default;
// Navbar
$navbar-padding-y: 1.5rem !default;
$navbar-dark-hover-color: $white !default;
$navbar-light-color: rgba($black, .3) !default;
$navbar-light-hover-color: $gray-900 !default;
$navbar-light-active-color: $gray-900 !default;
// Pagination
$pagination-border-color: transparent !default;
$pagination-hover-border-color: $pagination-border-color !default;
$pagination-disabled-border-color: $pagination-border-color !default;
// Breadcrumbs
$breadcrumb-bg: transparent !default;

View File

@ -1,5 +0,0 @@
/*!
* bootstrap-tokenfield
* https://github.com/sliptree/bootstrap-tokenfield
* Copyright 2013-2014 Sliptree and other contributors; Licensed MIT
*/@-webkit-keyframes blink{0%{border-color:#ededed}100%{border-color:#b94a48}}@-moz-keyframes blink{0%{border-color:#ededed}100%{border-color:#b94a48}}@keyframes blink{0%{border-color:#ededed}100%{border-color:#b94a48}}.tokenfield{height:auto;min-height:34px;padding-bottom:0}.tokenfield.focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.tokenfield .token{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;display:inline-block;border:1px solid #d9d9d9;background-color:#ededed;white-space:nowrap;margin:-1px 5px 5px 0;height:22px;vertical-align:top;cursor:default}.tokenfield .token:hover{border-color:#b9b9b9}.tokenfield .token.active{border-color:#52a8ec;border-color:rgba(82,168,236,.8)}.tokenfield .token.duplicate{border-color:#ebccd1;-webkit-animation-name:blink;animation-name:blink;-webkit-animation-duration:.1s;animation-duration:.1s;-webkit-animation-direction:normal;animation-direction:normal;-webkit-animation-timing-function:ease;animation-timing-function:ease;-webkit-animation-iteration-count:infinite;animation-iteration-count:infinite}.tokenfield .token.invalid{background:0 0;border:1px solid transparent;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;border-bottom:1px dotted #d9534f}.tokenfield .token.invalid.active{background:#ededed;border:1px solid #ededed;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px}.tokenfield .token .token-label{display:inline-block;overflow:hidden;text-overflow:ellipsis;padding-left:4px;vertical-align:top}.tokenfield .token .close{font-family:Arial;display:inline-block;line-height:100%;font-size:1.1em;line-height:1.49em;margin-left:5px;float:none;height:100%;vertical-align:top;padding-right:4px}.tokenfield .token-input{background:0 0;width:60px;min-width:60px;border:0;height:20px;padding:0;margin-bottom:6px;-webkit-box-shadow:none;box-shadow:none}.tokenfield .token-input:focus{border-color:transparent;outline:0;-webkit-box-shadow:none;box-shadow:none}.tokenfield.disabled{cursor:not-allowed;background-color:#eee}.tokenfield.disabled .token-input{cursor:not-allowed}.tokenfield.disabled .token:hover{cursor:not-allowed;border-color:#d9d9d9}.tokenfield.disabled .token:hover .close{cursor:not-allowed;opacity:.2;filter:alpha(opacity=20)}.has-warning .tokenfield.focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b}.has-error .tokenfield.focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.has-success .tokenfield.focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.tokenfield.input-sm,.input-group-sm .tokenfield{min-height:30px;padding-bottom:0}.input-group-sm .token,.tokenfield.input-sm .token{height:20px;margin-bottom:4px}.input-group-sm .token-input,.tokenfield.input-sm .token-input{height:18px;margin-bottom:5px}.tokenfield.input-lg,.input-group-lg .tokenfield{height:auto;min-height:45px;padding-bottom:4px}.input-group-lg .token,.tokenfield.input-lg .token{height:25px}.input-group-lg .token-label,.tokenfield.input-lg .token-label{line-height:23px}.input-group-lg .token .close,.tokenfield.input-lg .token .close{line-height:1.3em}.input-group-lg .token-input,.tokenfield.input-lg .token-input{height:23px;line-height:23px;margin-bottom:6px;vertical-align:top}.tokenfield.rtl{direction:rtl;text-align:right}.tokenfield.rtl .token{margin:-1px 0 5px 5px}.tokenfield.rtl .token .token-label{padding-left:0;padding-right:4px}

View File

@ -1,118 +0,0 @@
/* THEME STYLE */
pre{background:#f7f7f9}iframe{overflow:hidden;border:none}@media (min-width: 768px){body>.navbar-transparent{box-shadow:none}body>.navbar-transparent .navbar-nav>.open>a{box-shadow:none}}#home,#help{font-size:.9rem}#home .navbar,#help .navbar{background:#349aed;background:linear-gradient(145deg, #349aed 50%, #34d8ed 100%);transition:box-shadow 200ms ease-in}#home .navbar-transparent,#help .navbar-transparent{background:none !important;box-shadow:none}#home .navbar-brand .nav-link,#help .navbar-brand .nav-link{display:inline-block;margin-right:-30px}#home .nav-link,#help .nav-link{text-transform:uppercase;font-weight:500;color:#fff}#home{padding-top:0}#home .btn{padding:.6rem .55rem .5rem;box-shadow:none;font-size:.7rem;font-weight:500}.bs-docs-section{margin-top:4em}.bs-docs-section .page-header h1{padding:2rem 0;font-size:3rem}.dropdown-menu.show[aria-labelledby="themes"]{display:-ms-flexbox;display:flex;width:420px;-ms-flex-wrap:wrap;flex-wrap:wrap}.dropdown-menu.show[aria-labelledby="themes"] .dropdown-item{width:33.333%}.dropdown-menu.show[aria-labelledby="themes"] .dropdown-item:first-child{width:100%}.bs-component{position:relative}.bs-component+.bs-component{margin-top:1rem}.bs-component .card{margin-bottom:1rem}.bs-component .modal{position:relative;top:auto;right:auto;left:auto;bottom:auto;z-index:1;display:block}.bs-component .modal-dialog{width:90%}.bs-component .popover{position:relative;display:inline-block;width:220px;margin:20px}.source-button{display:none;position:absolute;top:0;right:0;z-index:100;font-weight:700}.source-button:hover{cursor:pointer}.bs-component:hover .source-button{display:block}#source-modal pre{max-height:calc(100vh - 11rem);background-color:rgba(0,0,0,0.7);color:rgba(255,255,255,0.7)}.nav-tabs{margin-bottom:15px}.progress{margin-bottom:10px}#footer{margin:5em 0}#footer li{float:left;margin-right:1.5em;margin-bottom:1.5em}#footer p{clear:left;margin-bottom:0}.splash{padding:12em 0 6em;background:#349aed;background:linear-gradient(145deg, #349aed 50%, #34d8ed 100%);color:#fff;text-align:center}.splash .logo{width:160px}.splash h1{font-size:3em;color:#fff}.splash #social{margin:2em 0 3em}.splash .alert{margin:2em 0;border:none}.splash .sponsor a{color:#fff}.section-tout{padding:6em 0 1em;border-bottom:1px solid rgba(0,0,0,0.05);background-color:#eaf1f1;text-align:center}.section-tout .icon{display:-ms-flexbox;display:flex;-ms-flex-pack:center;justify-content:center;-ms-flex-align:center;align-items:center;width:80px;height:80px;margin:0 auto 1rem;background:#349aed;background:linear-gradient(145deg, #3b9cea 50%, #3db8eb 100%);border-radius:50%;font-size:2rem;color:rgba(0,0,0,0.5)}.section-tout p{margin-bottom:5em}.section-preview{padding:4em 0}.section-preview .preview{margin-bottom:4em;background-color:#eaf1f1}.section-preview .preview .image{position:relative}.section-preview .preview .image::before{box-shadow:inset 0 0 0 1px rgba(0,0,0,0.1);position:absolute;top:0;left:0;width:100%;height:100%;content:"";pointer-events:none}.section-preview .preview .options{padding:2em;border:1px solid rgba(0,0,0,0.05);border-top:none;text-align:center}.section-preview .preview .options p{margin-bottom:2em}.section-preview .dropdown-menu{text-align:left}.section-preview .lead{margin-bottom:2em}.sponsor #carbonads{max-width:240px;margin:0 auto}.sponsor .carbon-text{display:block;margin-top:1em;font-size:12px}.sponsor .carbon-poweredby{float:right;margin-top:1em;font-size:10px}@media (max-width: 767px){.splash{padding-top:8em}.splash .logo{width:100px}.splash h1{font-size:2em}#banner{margin-bottom:2em;text-align:center}}
/* CUSTOM STYLE */
/* Start collapsable table
-------------------------------------------------- */
.hiddenRow, .hiddenCell {
padding: 0px!important;
border-top: 0px!important;
}
.collapsedRow .col-md-6{
display:inline-block;
}
.collapsedRow {
padding: 10px 0px;
border-top: 1px solid lightgray;
margin-left: 0px;
margin-right: 0px;
}
.collapse-indicator {
text-decoration: none;
}
.collapse-indicator:after {
font-weight: 900;
font-family: "Font Awesome 5 Free";
content: "\f056";
}
.collapse-indicator.collapsed:after {
font-weight: 900;
font-family: "Font Awesome 5 Free";
content: "\f055";
}
/* --------------------------------------------------
End collapsable table*/
.jumbotron-home {
padding: 1rem 1rem;
}
@media (min-width: 576px) {
.jumbotron-home {
padding: 2rem 2rem;
}
}
@media (min-width: 1440px) {
.container, .container-lg, .container-md, .container-sm, .container-xl {
max-width: 1400px;
}
}
.device-status-table {
font-size: small;
}
.navbar {
padding: 0.5rem 1rem;
}
.navbar-brand > img {
height: 2rem;
width: auto;
}
.disabled-peer {
color: #d03131;
}
.expiring-peer {
color: #d09d12;
}
.tokenfield .token {
border-radius: 0px;
border: 1px solid #1a1a1a;
color: #1a1a1a;
background-color: #f7f7f9;
margin: -4px 5px 5px 0;
height: 22px;
}
.form-group.required label:after {
content:"*";
color:red;
}
a.advanced-settings:before {
content: "Hide";
}
a.advanced-settings.collapsed:before {
content: "Show";
}
.form-group.global-config label:after, .custom-control.global-config label:after {
content: "g";
color: #0057bb;
font-size: xx-small;
top: -5px;
position: absolute;
}
.text-blue {
color: #0057bb;
}
@media (min-width: 992px) {
.pull-right-lg {
float: right;
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,8 +0,0 @@
.navbar {
padding: 0.5rem 1rem;
}
.navbar-brand > img {
height: 2rem;
width: auto;
}

View File

@ -1,5 +0,0 @@
/*!
* bootstrap-tokenfield
* https://github.com/sliptree/bootstrap-tokenfield
* Copyright 2013-2014 Sliptree and other contributors; Licensed MIT
*/.twitter-typeahead{width:100%;position:relative;vertical-align:top}.twitter-typeahead .tt-input,.twitter-typeahead .tt-hint{margin:0;width:100%;vertical-align:middle;background-color:#fff}.twitter-typeahead .tt-hint{color:#999;z-index:1;border:1px solid transparent}.twitter-typeahead .tt-input{color:#555;z-index:2}.twitter-typeahead .tt-input,.twitter-typeahead .tt-hint{height:34px;padding:6px 12px;font-size:14px;line-height:1.428571429}.twitter-typeahead .input-sm.tt-input,.twitter-typeahead .hint-sm.tt-hint{border-radius:3px}.twitter-typeahead .input-lg.tt-input,.twitter-typeahead .hint-lg.tt-hint{border-radius:6px}.input-group .twitter-typeahead:first-child .tt-input,.input-group .twitter-typeahead:first-child .tt-hint{border-radius:4px 0 0 4px!important}.input-group .twitter-typeahead:last-child .tt-input,.input-group .twitter-typeahead:last-child .tt-hint{border-radius:0 4px 4px 0!important}.input-group.input-group-sm .twitter-typeahead:first-child .tt-input,.input-group.input-group-sm .twitter-typeahead:first-child .tt-hint{border-radius:3px 0 0 3px!important}.input-group.input-group-sm .twitter-typeahead:last-child .tt-input,.input-group.input-group-sm .twitter-typeahead:last-child .tt-hint{border-radius:0 3px 3px 0!important}.input-sm.tt-input,.hint-sm.tt-hint,.input-group.input-group-sm .tt-input,.input-group.input-group-sm .tt-hint{height:30px;padding:5px 10px;font-size:12px;line-height:1.5}.input-group.input-group-lg .twitter-typeahead:first-child .tt-input,.input-group.input-group-lg .twitter-typeahead:first-child .tt-hint{border-radius:6px 0 0 6px!important}.input-group.input-group-lg .twitter-typeahead:last-child .tt-input,.input-group.input-group-lg .twitter-typeahead:last-child .tt-hint{border-radius:0 6px 6px 0!important}.input-lg.tt-input,.hint-lg.tt-hint,.input-group.input-group-lg .tt-input,.input-group.input-group-lg .tt-hint{height:45px;padding:10px 16px;font-size:18px;line-height:1.33}.tt-dropdown-menu{width:100%;min-width:160px;margin-top:2px;padding:5px 0;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);*border-right-width:2px;*border-bottom-width:2px;border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2);-webkit-background-clip:padding-box;-moz-background-clip:padding;background-clip:padding-box}.tt-suggestion{display:block;padding:3px 20px}.tt-suggestion.tt-cursor{color:#262626;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0)}.tt-suggestion.tt-cursor a{color:#fff}.tt-suggestion p{margin:0}.tokenfield .twitter-typeahead{width:auto}.tokenfield .twitter-typeahead .tt-hint{padding:0;height:20px}.tokenfield.input-sm .twitter-typeahead .tt-input,.tokenfield.input-sm .twitter-typeahead .tt-hint{height:18px;font-size:12px;line-height:1.5}.tokenfield.input-lg .twitter-typeahead .tt-input,.tokenfield.input-lg .twitter-typeahead .tt-hint{height:23px;font-size:18px;line-height:1.33}.tokenfield .twitter-typeahead .tt-suggestions{font-size:14px}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,39 +0,0 @@
(function($) {
"use strict"; // Start of use strict
// Smooth scrolling using jQuery easing
$(document).on('click', 'a.scroll-to-top', function(e) {
var $anchor = $(this);
$('html, body').stop().animate({
scrollTop: ($($anchor.attr('href')).offset().top)
}, 1000, 'easeInOutExpo');
e.preventDefault();
});
$(function () {
$('[data-toggle="tooltip"]').tooltip()
});
$(".online-status").each(function(){
const onlineStatusID = "#" + $(this).attr('id');
$.get( "/user/status?pkey=" + encodeURIComponent($(this).attr('data-pkey')), function( data ) {
console.log(onlineStatusID + " " + data)
if(data === true) {
$(onlineStatusID).html('<i class="fas fa-link text-success"></i>');
} else {
$(onlineStatusID).html('<i class="fas fa-unlink"></i>');
}
});
});
$(function() {
$('select.device-selector').change(function() {
this.form.submit();
});
});
$('[data-toggle=confirmation]').confirmation({
rootSelector: '[data-toggle=confirmation]',
// other options
});
})(jQuery); // End of use strict

File diff suppressed because one or more lines are too long

View File

@ -1,73 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>{{ .Static.WebsiteTitle }} - Admin</title>
<meta name="description" content="{{ .Static.WebsiteTitle }}">
<link rel="stylesheet" href="/css/jquery-ui.min.css">
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
<link rel="stylesheet" href="/css/bootstrap-tokenfield.min.css">
<link rel="stylesheet" href="/css/tokenfield-typeahead.min.css">
<link rel="stylesheet" href="/css/custom.css">
</head>
<body id="page-top" class="d-flex flex-column min-vh-100">
{{template "prt_nav.html" .}}
<div class="container mt-5">
<h1>Create new clients</h1>
<h2>Enter valid user email addresses to quickly create new accounts.</h2>
{{template "prt_flashes.html" .}}
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{.Csrf}}">
<div class="form-row">
<div class="form-group required col-md-12">
<label for="inputEmail">Email Addresses</label>
<input type="text" name="email" class="form-control" id="inputEmail" value="{{.FormData.Emails}}" required>
</div>
</div>
<div class="form-row">
<div class="form-group required col-md-12">
<label for="inputIdentifier">Client Friendly Name (will be added as suffix to the name of the user)</label>
<input type="text" name="identifier" class="form-control" id="inputIdentifier" value="{{.FormData.Identifier}}" required>
</div>
</div>
<button type="submit" class="btn btn-primary">Create</button>
<a href="/admin" class="btn btn-secondary">Cancel</a>
</form>
</div>
{{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script>
<script src="/js/jquery.easing.js"></script>
<script src="/js/popper.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/bootstrap-confirmation.min.js"></script>
<script src="/js/bootstrap-tokenfield.min.js"></script>
<script src="/js/custom.js"></script>
<script>$('#inputEmail').on('tokenfield:createdtoken', function (e) {
// Über-simplistic e-mail validation
var re = /\S+@\S+\.\S+/
var valid = re.test(e.attrs.value)
if (!valid) {
$(e.relatedTarget).addClass('invalid')
}
}).on('tokenfield:createtoken', function (e) {
var existingTokens = $(this).tokenfield('getTokens');
$.each(existingTokens, function(index, token) {
if (token.value === e.attrs.value)
e.preventDefault();
});
}).tokenfield({
autocomplete: {
source: [{{range $i, $u :=.Users}}{{if ne $i 0}},{{end}}'{{$u.Email}}'{{end}}],
delay: 100
},
inputType: 'email',
createTokensOnBlur: true,
showAutocompleteOnFocus: false
})</script>
</body>
</html>

View File

@ -1,221 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>{{ .Static.WebsiteTitle }} - Admin</title>
<meta name="description" content="{{ .Static.WebsiteTitle }}">
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
<link rel="stylesheet" href="/css/custom.css">
</head>
<body id="page-top" class="d-flex flex-column min-vh-100">
{{template "prt_nav.html" .}}
<div class="container mt-5">
{{template "prt_flashes.html" .}}
<!-- server mode -->
{{if eq .Device.Type "server"}}
{{if .Peer.IsNew}}
<h1>Create a new client</h1>
{{else}}
<h1>Edit client: <strong>{{.Peer.Identifier}}</strong></h1>
{{end}}
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{.Csrf}}">
<input type="hidden" name="uid" value="{{.Peer.UID}}">
<input type="hidden" name="devicetype" value="{{.Device.Type}}">
<input type="hidden" name="device" value="{{.Device.DeviceName}}">
<input type="hidden" name="endpoint" value="{{.Peer.Endpoint}}">
{{if .EditableKeys}}
<div class="form-row">
<div class="form-group col-md-12">
<label for="server_PrivateKey">Private Key</label>
<input type="text" name="privkey" class="form-control" id="server_PrivateKey" value="{{.Peer.PrivateKey}}">
</div>
</div>
<div class="form-row">
<div class="form-group required col-md-12">
<label for="server_PublicKey">Public Key</label>
<input type="text" name="pubkey" class="form-control" id="server_PublicKey" value="{{.Peer.PublicKey}}" required>
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="server_PresharedKey">Preshared Key</label>
<input type="text" name="presharedkey" class="form-control" id="server_PresharedKey" value="{{.Peer.PresharedKey}}">
</div>
</div>
{{else}}
<input type="hidden" name="privkey" value="{{.Peer.PrivateKey}}">
<input type="hidden" name="presharedkey" value="{{.Peer.PresharedKey}}">
<div class="form-row">
<div class="form-group col-md-12">
<label for="server_ro_PublicKey">Public Key</label>
<input type="text" name="pubkey" readonly class="form-control" id="server_ro_PublicKey" value="{{.Peer.PublicKey}}">
</div>
</div>
{{end}}
<div class="form-row">
<div class="form-group required col-md-12">
<label for="server_Identifier">Client Friendly Name</label>
<input type="text" name="identifier" class="form-control" id="server_Identifier" value="{{.Peer.Identifier}}" required>
</div>
</div>
<div class="form-row">
<div class="form-group required col-md-12">
<label for="server_Email">Client Email Address</label>
<input type="email" name="mail" class="form-control" id="server_Email" value="{{.Peer.Email}}" required>
</div>
</div>
<div class="form-row">
<div class="form-group required col-md-12">
<label for="server_IP">Client IP Address</label>
<input type="text" name="ip" class="form-control" id="server_IP" value="{{.Peer.IPsStr}}" required>
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12 global-config">
<label for="server_AllowedIP">Allowed IPs</label>
<input type="text" name="allowedip" class="form-control" id="server_AllowedIP" value="{{.Peer.AllowedIPsStr}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="server_AllowedIPSrv">Extra Allowed IPs (Server sided)</label>
<input type="text" name="allowedipSrv" class="form-control" id="server_AllowedIPSrv" value="{{.Peer.AllowedIPsSrvStr}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12 global-config">
<label for="server_DNS">Client DNS Servers</label>
<input type="text" name="dns" class="form-control" id="server_DNS" value="{{.Peer.DNSStr}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-6 global-config">
<label for="server_PersistentKeepalive">Persistent Keepalive (0 = off)</label>
<input type="number" name="keepalive" class="form-control" id="server_PersistentKeepalive" placeholder="16" value="{{.Peer.PersistentKeepalive}}">
</div>
<div class="form-group col-md-6 global-config">
<label for="server_MTU">Client MTU (0 = default)</label>
<input type="number" name="mtu" class="form-control" id="server_MTU" placeholder="" value="{{.Peer.Mtu}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-6">
<div class="custom-control custom-switch">
<input class="custom-control-input" name="isdisabled" type="checkbox" value="true" id="server_Disabled" {{if .Peer.DeactivatedAt}}checked{{end}}>
<label class="custom-control-label" for="server_Disabled">
Disabled
</label>
</div>
<div class="custom-control custom-switch">
<input class="custom-control-input" name="ignoreglobalsettings" type="checkbox" value="true" id="server_IgnoreGlobalSettings" {{if .Peer.IgnoreGlobalSettings}}checked{{end}}>
<label class="custom-control-label" for="server_IgnoreGlobalSettings">
Ignore global settings (<span class="text-blue">g</span>)
</label>
</div>
</div>
<div class="form-group col-md-6">
<label for="expires_at">Expires At</label>
<input type="date" name="expires_at" pattern="\d{4}-\d{2}-\d{2}" class="form-control" id="expires_at" placeholder="" value="{{formatDate .Peer.ExpiresAt}}" min="2022-01-01">
</div>
</div>
<button type="submit" class="btn btn-primary">Save</button>
<a href="/admin" class="btn btn-secondary">Cancel</a>
</form>
{{end}}
<!-- client mode -->
{{if eq .Device.Type "client"}}
{{if .Peer.IsNew}}
<h1>Create a new remote endpoint</h1>
{{else}}
<h1>Edit remote endpoint: <strong>{{.Peer.Identifier}}</strong></h1>
{{end}}
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{.Csrf}}">
<input type="hidden" name="uid" value="{{.Peer.UID}}">
<input type="hidden" name="mail" value="{{.AdminEmail}}">
<input type="hidden" name="devicetype" value="{{.Device.Type}}">
<input type="hidden" name="device" value="{{.Device.DeviceName}}">
<input type="hidden" name="privkey" value="{{.Peer.PrivateKey}}">
<div class="form-row">
<div class="form-group required col-md-12">
<label for="client_Identifier">Endpoint Friendly Name</label>
<input type="text" name="identifier" class="form-control" id="client_Identifier" value="{{.Peer.Identifier}}" required>
</div>
</div>
<div class="form-row">
<div class="form-group required col-md-12">
<label for="client_Endpoint">Endpoint Address</label>
<input type="text" name="endpoint" class="form-control" id="client_Endpoint" value="{{.Peer.Endpoint}}" required>
</div>
</div>
<div class="form-row">
<div class="form-group required col-md-12">
<label for="client_PublicKey">Endpoint Public Key</label>
<input type="text" name="pubkey" class="form-control" id="client_PublicKey" value="{{.Peer.PublicKey}}" required>
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="client_PresharedKey">Preshared Key</label>
<input type="text" name="presharedkey" class="form-control" id="client_PresharedKey" value="{{.Peer.PresharedKey}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="client_AllowedIP">Allowed IPs</label>
<input type="text" name="allowedip" class="form-control" id="client_AllowedIP" value="{{.Peer.AllowedIPsStr}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-6">
<label for="client_PersistentKeepalive">Persistent Keepalive (0 = off)</label>
<input type="number" name="keepalive" class="form-control" id="client_PersistentKeepalive" placeholder="16" value="{{.Peer.PersistentKeepalive}}">
</div>
<div class="form-group col-md-6">
<label for="client_IP">Ping-Check IP Address</label>
<input type="text" name="ip" class="form-control" id="client_IP" value="{{.Peer.IPsStr}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-6">
<div class="custom-control custom-switch">
<input class="custom-control-input" name="isdisabled" type="checkbox" value="true" id="client_Disabled" {{if .Peer.DeactivatedAt}}checked{{end}}>
<label class="custom-control-label" for="client_Disabled">
Disabled
</label>
</div>
</div>
<div class="form-group col-md-6">
<label for="expires_at">Expires At</label>
<input type="date" name="expires_at" pattern="\d{4}-\d{2}-\d{2}" class="form-control" id="expires_at" placeholder="" value="{{formatDate .Peer.ExpiresAt}}" min="2022-01-01">
</div>
</div>
<button type="submit" class="btn btn-primary">Save</button>
<a href="/admin" class="btn btn-secondary">Cancel</a>
</form>
{{end}}
</div>
{{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script>
<script src="/js/jquery.easing.js"></script>
<script src="/js/popper.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/bootstrap-confirmation.min.js"></script>
<script src="/js/custom.js"></script>
</body>
</html>

View File

@ -1,263 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>{{ .Static.WebsiteTitle }} - Admin</title>
<meta name="description" content="{{ .Static.WebsiteTitle }}">
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
<link rel="stylesheet" href="/css/custom.css">
</head>
<body id="page-top" class="d-flex flex-column min-vh-100">
{{template "prt_nav.html" .}}
<div class="container mt-5 main-app">
<h1>Edit interface <strong>{{.Device.DeviceName}}</strong></h1>
{{template "prt_flashes.html" .}}
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link {{if eq .Device.Type "server"}}active{{end}}" data-toggle="tab" href="#server">Server Mode</a>
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Device.Type "client"}}active{{end}}" data-toggle="tab" href="#client">Client Mode</a>
</li>
</ul>
<div id="configContent" class="tab-content">
<!-- server mode -->
<div class="tab-pane fade {{if eq .Device.Type "server"}}active show{{end}}" id="server">
<form method="post" enctype="multipart/form-data" name="server">
<input type="hidden" name="_csrf" value="{{.Csrf}}">
<input type="hidden" name="device" value="{{.Device.DeviceName}}">
<input type="hidden" name="devicetype" value="server">
<h3>Server's interface configuration</h3>
<div class="form-row">
<div class="form-group col-md-12">
<label for="server_DisplayName">Display Name</label>
<input type="text" name="displayname" class="form-control" id="server_DisplayName" value="{{.Device.DisplayName}}">
</div>
</div>
{{if .EditableKeys}}
<div class="form-row">
<div class="form-group required col-md-12">
<label for="server_PrivateKey">Private Key</label>
<input type="text" name="privkey" class="form-control" id="server_PrivateKey" value="{{.Device.PrivateKey}}" required>
</div>
</div>
<div class="form-row">
<div class="form-group required col-md-12">
<label for="server_PublicKey">Public Key</label>
<input type="text" name="pubkey" class="form-control" id="server_PublicKey" value="{{.Device.PublicKey}}" required>
</div>
</div>
{{else}}
<input type="hidden" name="privkey" value="{{.Device.PrivateKey}}">
<div class="form-row">
<div class="form-group col-md-12">
<label for="server_ro_PublicKey">Public Key</label>
<input type="text" name="pubkey" readonly class="form-control" id="server_ro_PublicKey" value="{{.Device.PublicKey}}">
</div>
</div>
{{end}}
<div class="form-row">
<div class="form-group required col-md-6">
<label for="server_ListenPort">Listen port</label>
<input type="number" name="port" class="form-control" id="server_ListenPort" placeholder="51820" value="{{.Device.ListenPort}}" required>
</div>
<div class="form-group required col-md-6">
<label for="server_IPs">Server IP address</label>
<input type="text" name="ip" class="form-control" id="server_IPs" placeholder="10.6.6.1/24" value="{{.Device.IPsStr}}" required>
</div>
</div>
<h3>Client's global configuration (<span class="text-blue">g</span>)</h3>
<div class="form-row">
<div class="form-group required col-md-12">
<label for="server_PublicEndpoint">Public Endpoint for Clients</label>
<input type="text" name="endpoint" class="form-control" id="server_PublicEndpoint" placeholder="vpn.company.com:51820" value="{{.Device.DefaultEndpoint}}" required>
</div>
</div>
<div class="form-row">
<div class="form-group col-md-6">
<label for="server_DNS">DNS Servers</label>
<input type="text" name="dns" class="form-control" id="server_DNS" placeholder="1.1.1.1" value="{{.Device.DNSStr}}">
</div>
<div class="form-group col-md-6">
<label for="server_AllowedIP">Default allowed IPs</label>
<input type="text" name="allowedip" class="form-control" id="server_AllowedIP" placeholder="10.6.6.0/24" value="{{.Device.DefaultAllowedIPsStr}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-6">
<label for="server_MTU">MTU (also used for the server interface, 0 = default)</label>
<input type="number" name="mtu" class="form-control" id="server_MTU" placeholder="" value="{{.Device.Mtu}}">
</div>
<div class="form-group col-md-6">
<label for="server_PersistentKeepalive">Persistent Keepalive (0 = off)</label>
<input type="number" name="keepalive" class="form-control" id="server_PersistentKeepalive" placeholder="16" value="{{.Device.DefaultPersistentKeepalive}}">
</div>
</div>
<h3>Interface configuration hooks</h3>
<div class="form-row">
<div class="form-group col-md-12">
<label for="server_PreUp">Pre Up</label>
<input type="text" name="preup" class="form-control" id="server_PreUp" value="{{.Device.PreUp}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="server_PostUp">Post Up</label>
<input type="text" name="postup" class="form-control" id="server_PostUp" value="{{.Device.PostUp}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="server_PreDown">Pre Down</label>
<input type="text" name="predown" class="form-control" id="server_PreDown" value="{{.Device.PreDown}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="server_PostDown">Post Down</label>
<input type="text" name="postdown" class="form-control" id="server_PostDown" value="{{.Device.PostDown}}">
</div>
</div>
<div class="form-row">
<div class="d-flex align-items-center">
<a href="#" class="advanced-settings btn btn-link collapsed" data-toggle="collapse" data-target="#collapseAdvancedServer" aria-expanded="false" aria-controls="collapseAdvancedServer">
Advanced Settings
</a>
</div>
</div>
<div id="collapseAdvancedServer" class="collapse" aria-labelledby="collapseAdvancedServer">
<div class="form-row">
<div class="form-group col-md-6">
<label for="server_FirewallMark">Firewall Mark (0 = default or off)</label>
<input type="number" name="firewallmark" class="form-control" id="server_FirewallMark" placeholder="" value="{{.Device.FirewallMark}}">
</div>
<div class="form-group col-md-6">
<label for="server_RoutingTable">Routing Table (empty = default or auto)</label>
<input type="text" name="routingtable" class="form-control" id="server_RoutingTable" placeholder="auto" value="{{.Device.RoutingTable}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<div class="custom-control custom-switch">
<input class="custom-control-input" name="saveconfig" type="checkbox" value="true" id="server_SaveConfig" {{if .Peer.SaveConfig}}checked{{end}}>
<label class="custom-control-label" for="server_SaveConfig">
Save Configuration (if interface was edited via WireGuard configuration tool)
</label>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Save</button>
<a href="/admin" class="btn btn-secondary">Cancel</a>
<a href="/admin/device/applyglobals" class="btn btn-dark float-right">Apply Global Settings (<span class="text-blue">g</span>) to clients</a>
</form>
</div>
<!-- client mode -->
<div class="tab-pane fade {{if eq .Device.Type "client"}}active show{{end}}" id="client">
<form method="post" enctype="multipart/form-data" name="client">
<input type="hidden" name="_csrf" value="{{.Csrf}}">
<input type="hidden" name="device" value="{{.Device.DeviceName}}">
<input type="hidden" name="devicetype" value="client">
<h3>Client's interface configuration</h3>
<div class="form-row">
<div class="form-group col-md-12">
<label for="client_DisplayName">Display Name</label>
<input type="text" name="displayname" class="form-control" id="client_DisplayName" value="{{.Device.DisplayName}}">
</div>
</div>
{{if .EditableKeys}}
<div class="form-row">
<div class="form-group required col-md-12">
<label for="client_PrivateKey">Private Key</label>
<input type="text" name="privkey" class="form-control" id="client_PrivateKey" value="{{.Device.PrivateKey}}" required>
</div>
</div>
<div class="form-row">
<div class="form-group required col-md-12">
<label for="client_PublicKey">Public Key</label>
<input type="text" name="pubkey" class="form-control" id="client_PublicKey" value="{{.Device.PublicKey}}" required>
</div>
</div>
{{else}}
<input type="hidden" name="privkey" value="{{.Device.PrivateKey}}">
<div class="form-row">
<div class="form-group col-md-12">
<label for="client_ro_PublicKey">Public Key</label>
<input type="text" name="pubkey" readonly class="form-control" id="client_ro_PublicKey" value="{{.Device.PublicKey}}">
</div>
</div>
{{end}}
<div class="form-row">
<div class="form-group required col-md-6">
<label for="client_IPs">Client IP address</label>
<input type="text" name="ip" class="form-control" id="client_IPs" placeholder="10.6.6.1/24" value="{{.Device.IPsStr}}" required>
</div>
<div class="form-group col-md-6">
<label for="client_DNS">DNS Servers</label>
<input type="text" name="dns" class="form-control" id="client_DNS" placeholder="1.1.1.1" value="{{.Device.DNSStr}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-4">
<label for="client_MTU">MTU (0 = default)</label>
<input type="number" name="mtu" class="form-control" id="client_MTU" placeholder="" value="{{.Device.Mtu}}">
</div>
<div class="form-group col-md-4">
<label for="client_FirewallMark">Firewall Mark (0 = default or off)</label>
<input type="number" name="firewallmark" class="form-control" id="client_FirewallMark" placeholder="" value="{{.Device.FirewallMark}}">
</div>
<div class="form-group col-md-4">
<label for="client_RoutingTable">Routing Table (empty = default or auto)</label>
<input type="text" name="routingtable" class="form-control" id="client_RoutingTable" placeholder="auto" value="{{.Device.RoutingTable}}">
</div>
</div>
<h3>Interface configuration hooks</h3>
<div class="form-row">
<div class="form-group col-md-12">
<label for="client_PreUp">Pre Up</label>
<input type="text" name="preup" class="form-control" id="client_PreUp" value="{{.Device.PreUp}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="client_PostUp">Post Up</label>
<input type="text" name="postup" class="form-control" id="client_PostUp" value="{{.Device.PostUp}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="client_PreDown">Pre Down</label>
<input type="text" name="predown" class="form-control" id="client_PreDown" value="{{.Device.PreDown}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="client_PostDown">Post Down</label>
<input type="text" name="postdown" class="form-control" id="client_PostDown" value="{{.Device.PostDown}}">
</div>
</div>
<button type="submit" class="btn btn-primary">Save</button>
<a href="/admin" class="btn btn-secondary">Cancel</a>
</form>
</div>
</div>
</div>
{{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script>
<script src="/js/jquery.easing.js"></script>
<script src="/js/popper.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/bootstrap-confirmation.min.js"></script>
<script src="/js/custom.js"></script>
</body>
</html>

View File

@ -1,95 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>{{ .Static.WebsiteTitle }} - Users</title>
<meta name="description" content="{{ .Static.WebsiteTitle }}">
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
<link rel="stylesheet" href="/css/custom.css">
</head>
<body id="page-top" class="d-flex flex-column min-vh-100">
{{template "prt_nav.html" .}}
<div class="container mt-5">
{{if eq .User.CreatedAt .Epoch}}
<h1>Create a new user</h1>
{{else}}
<h1>Edit user <strong>{{.User.Email}}</strong></h1>
{{end}}
{{template "prt_flashes.html" .}}
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{.Csrf}}">
{{if eq .User.CreatedAt .Epoch}}
<div class="form-row">
<div class="form-group required col-md-12">
<label for="inputEmail">Email</label>
<input type="text" name="email" class="form-control" id="inputEmail" value="{{.User.Email}}" required>
</div>
</div>
{{else}}
<input type="hidden" name="email" value="{{.User.Email}}">
{{end}}
<div class="form-row">
<div class="form-group required col-md-12">
<label for="inputFirstname">Firstname</label>
<input type="text" name="firstname" class="form-control" id="inputFirstname" value="{{.User.Firstname}}" required>
</div>
</div>
<div class="form-row">
<div class="form-group required col-md-12">
<label for="inputLastname">Lastname</label>
<input type="text" name="lastname" class="form-control" id="inputLastname" value="{{.User.Lastname}}" required>
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="inputPhone">Phone</label>
<input type="text" name="phone" class="form-control" id="inputPhone" value="{{.User.Phone}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12 {{if eq .User.CreatedAt .Epoch}}required{{end}}">
<label for="inputPassword">Password</label>
<input type="password" name="password" class="form-control" id="inputPassword" {{if eq .User.CreatedAt .Epoch}}required{{end}}>
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<div class="custom-control custom-switch">
<input class="custom-control-input" name="isadmin" type="checkbox" value="true" id="inputAdmin" {{if .User.IsAdmin}}checked{{end}}>
<label class="custom-control-label" for="inputAdmin">
Administrator
</label>
</div>
<div class="custom-control custom-switch">
<input class="custom-control-input" name="isdisabled" type="checkbox" value="true" id="inputDisabled" {{if .User.DeletedAt.Valid}}checked{{end}}>
<label class="custom-control-label" for="inputDisabled">
Disabled
</label>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Save</button>
<a href="/admin/users/" class="btn btn-secondary">Cancel</a>
{{if eq $.Session.IsAdmin true}}
{{if eq .User.Source "db"}}
<a href="/admin/users/delete?pkey={{.User.Email}}" data-toggle="confirmation" data-title="Really delete user and associated peers?" title="Delete user and associated peers" class="btn btn-danger float-right">Delete</a>
{{end}}
{{end}}
</form>
</div>
{{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script>
<script src="/js/jquery.easing.js"></script>
<script src="/js/popper.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/bootstrap-confirmation.min.js"></script>
<script src="/js/custom.js"></script>
</body>
</html>

View File

@ -1,278 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>{{ .Static.WebsiteTitle }} - Admin</title>
<meta name="description" content="{{ .Static.WebsiteTitle }}">
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
<link rel="stylesheet" href="/css/custom.css">
</head>
<body id="page-top" class="d-flex flex-column min-vh-100">
{{template "prt_nav.html" .}}
<div class="container mt-5">
<h1>WireGuard VPN Administration</h1>
{{template "prt_flashes.html" .}}
<div class="card">
<div class="card-header">
<div class="d-flex align-items-center">
<span class="mr-auto">Interface status for <strong>{{.Device.DeviceName}}</strong> {{if eq $.Device.Type "server"}}(server mode){{end}}{{if eq $.Device.Type "client"}}(client mode){{end}}</span>
<a href="/admin/device/write?dev={{.Device.DeviceName}}" title="Write interface configuration"><i class="fas fa-save"></i></a>
&nbsp;&nbsp;&nbsp;
<a href="/admin/device/download?dev={{.Device.DeviceName}}" title="Download interface configuration"><i class="fas fa-download"></i></a>
&nbsp;&nbsp;&nbsp;
<a href="/admin/device/edit?dev={{.Device.DeviceName}}" title="Edit interface settings"><i class="fas fa-cog"></i></a>
</div>
</div>
<div class="card-body">
<div class="row">
{{if eq $.Device.Type "server"}}
<div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>Public Key:</td>
<td>{{.Device.PublicKey}}</td>
</tr>
<tr>
<td>Public Endpoint:</td>
<td>{{.Device.DefaultEndpoint}}</td>
</tr>
<tr>
<td>Listening Port:</td>
<td>{{.Device.ListenPort}}</td>
</tr>
<tr>
<td>Enabled Peers:</td>
<td>{{len .Device.Interface.Peers}}</td>
</tr>
<tr>
<td>Total Peers:</td>
<td>{{.TotalPeers}}</td>
</tr>
</tbody>
</table>
</div>
<div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>IP Address:</td>
<td>{{.Device.IPsStr}}</td>
</tr>
<tr>
<td>Default allowed IP's:</td>
<td>{{.Device.DefaultAllowedIPsStr}}</td>
</tr>
<tr>
<td>Default DNS servers:</td>
<td>{{.Device.DNSStr}}</td>
</tr>
<tr>
<td>Default MTU:</td>
<td>{{.Device.Mtu}}</td>
</tr>
<tr>
<td>Default Keepalive Interval:</td>
<td>{{.Device.DefaultPersistentKeepalive}}</td>
</tr>
</tbody>
</table>
</div>
{{end}}
{{if eq $.Device.Type "client"}}
<div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>Public Key:</td>
<td>{{.Device.PublicKey}}</td>
</tr>
<tr>
<td>Enabled Endpoints:</td>
<td>{{len .Device.Interface.Peers}}</td>
</tr>
<tr>
<td>Total Endpoints:</td>
<td>{{.TotalPeers}}</td>
</tr>
</tbody>
</table>
</div>
<div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>IP Address:</td>
<td>{{.Device.IPsStr}}</td>
</tr>
<tr>
<td>DNS servers:</td>
<td>{{.Device.DNSStr}}</td>
</tr>
<tr>
<td>Default MTU:</td>
<td>{{.Device.Mtu}}</td>
</tr>
</tbody>
</table>
</div>
{{end}}
</div>
</div>
</div>
<div class="mt-4 row">
<div class="col-sm-8 col-12">
{{if eq $.Device.Type "server"}}
<h2 class="mt-2">Current VPN Peers</h2>
{{end}}
{{if eq $.Device.Type "client"}}
<h2 class="mt-2">Current VPN Endpoints</h2>
{{end}}
</div>
<div class="col-sm-4 col-12 text-right">
<a href="/admin/peer/emailall" data-toggle="confirmation" data-title="Send mail to all peers?" title="Send mail to all peers" class="btn btn-light"><i class="fa fa-fw fa-paper-plane"></i></a>
{{if eq $.Device.Type "server"}}
<a href="/admin/peer/createldap" title="Add multiple peers" class="btn btn-primary"><i class="fa fa-fw fa-plus"></i><i class="fa fa-fw fa-users"></i></a>
{{end}}
<a href="/admin/peer/create" title="Add a peer" class="btn btn-primary"><i class="fa fa-fw fa-plus"></i><i class="fa fa-fw fa-user"></i></a>
</div>
</div>
<div class="mt-2 table-responsive">
<table class="table table-sm" id="userTable">
<thead>
<tr>
<th scope="col" class="list-image-cell"></th><!-- Status and expand -->
<th scope="col"><a href="?sort=id">Identifier <i class="fa fa-fw {{.Session.GetSortIcon "peers" "id"}}"></i></a></th>
<th scope="col"><a href="?sort=pubKey">Public Key <i class="fa fa-fw {{.Session.GetSortIcon "peers" "pubKey"}}"></i></a></th>
{{if eq $.Device.Type "server"}}
<th scope="col"><a href="?sort=mail">E-Mail <i class="fa fa-fw {{.Session.GetSortIcon "peers" "mail"}}"></i></a></th>
{{end}}
{{if eq $.Device.Type "server"}}
<th scope="col"><a href="?sort=ip">IP's <i class="fa fa-fw {{.Session.GetSortIcon "peers" "ip"}}"></i></a></th>
{{end}}
{{if eq $.Device.Type "client"}}
<th scope="col"><a href="?sort=endpoint">Endpoint <i class="fa fa-fw {{.Session.GetSortIcon "peers" "endpoint"}}"></i></a></th>
{{end}}
<th scope="col"><a href="?sort=handshake">Handshake <i class="fa fa-fw {{.Session.GetSortIcon "peers" "handshake"}}"></i></a></th>
<th scope="col"></th><!-- Actions -->
</tr>
</thead>
<tbody>
{{range $i, $p :=.Peers}}
{{$peerUser:=(userForEmail $.Users $p.Email)}}
<tr id="user-pos-{{$i}}" {{if $p.DeactivatedAt}}class="disabled-peer"{{end}}>
<th scope="row" class="list-image-cell">
<a href="#{{$p.UID}}" data-toggle="collapse" class="collapse-indicator collapsed"></a>
<!-- online check -->
<span title="Online status" class="online-status" id="online-{{$p.UID}}" data-pkey="{{$p.PublicKey}}"><i class="fas fa-unlink"></i></span>
</th>
<td>{{$p.Identifier}}{{if $p.WillExpire}} <i class="fas fa-hourglass-end expiring-peer" data-toggle="tooltip" data-placement="right" title="" data-original-title="Expires at: {{formatDate $p.ExpiresAt}}"></i>{{end}}</td>
<td>{{$p.PublicKey}}</td>
{{if eq $.Device.Type "server"}}
<td>{{$p.Email}}</td>
{{end}}
{{if eq $.Device.Type "server"}}
<td>{{$p.IPsStr}}</td>
{{end}}
{{if eq $.Device.Type "client"}}
<td>{{$p.Endpoint}}</td>
{{end}}
<td><span data-toggle="tooltip" data-placement="left" title="" data-original-title="{{$p.LastHandshakeTime}}">{{$p.LastHandshake}}</span></td>
<td>
{{if eq $.Session.IsAdmin true}}
<a href="/admin/peer/edit?pkey={{$p.PublicKey}}" title="Edit peer"><i class="fas fa-cog"></i></a>
{{end}}
</td>
</tr>
<tr class="hiddenRow">
<td colspan="7" class="hiddenCell" style="white-space:nowrap">
<div class="collapse" id="{{$p.UID}}" data-parent="#userTable">
<div class="row collapsedRow">
<div class="col-md-6 leftBorder">
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link active" data-toggle="tab" href="#t1{{$p.UID}}">Personal</a>
</li>
{{if eq $.Device.Type "server"}}
<li class="nav-item">
<a class="nav-link" data-toggle="tab" href="#t2{{$p.UID}}">Configuration</a>
</li>
{{end}}
<li class="nav-item">
<a class="nav-link" data-toggle="tab" href="#t3{{$p.UID}}">Danger Zone</a>
</li>
</ul>
<div class="tab-content" id="tabContent{{$p.UID}}">
<div id="t1{{$p.UID}}" class="tab-pane fade active show">
<h4>User details</h4>
{{if not $peerUser}}
<p>No user information available...</p>
{{else}}
<ul>
<li>Firstname: {{$peerUser.Firstname}}</li>
<li>Lastname: {{$peerUser.Lastname}}</li>
<li>Phone: {{$peerUser.Phone}}</li>
<li>Mail: {{$peerUser.Email}}</li>
</ul>
{{end}}
<h4>Connection / Traffic</h4>
{{if not $p.Peer}}
<p>No Traffic data available...</p>
{{else}}
<p class="ml-4">{{if $p.DeactivatedAt}}-{{else}}<i class="fas fa-network-wired" title="Last Endpoint"></i> {{$p.Peer.Endpoint}}{{end}}</p>
<p class="ml-4">{{if $p.DeactivatedAt}}-{{else}}<i class="fas fa-long-arrow-alt-down" title="Download"></i> {{formatBytes $p.Peer.ReceiveBytes}} / <i class="fas fa-long-arrow-alt-up" title="Upload"></i> {{formatBytes $p.Peer.TransmitBytes}}{{end}}</p>
{{end}}
</div>
{{if eq $.Device.Type "server"}}
<div id="t2{{$p.UID}}" class="tab-pane fade">
<pre>{{$p.Config}}</pre>
</div>
{{end}}
<div id="t3{{$p.UID}}" class="tab-pane fade">
<a href="/admin/peer/delete?pkey={{$p.PublicKey}}" class="btn btn-danger" title="Delete peer">Delete</a>
</div>
</div>
</div>
<div class="col-md-3">
{{if eq $.Device.Type "server"}}
<img class="list-image-large" loading="lazy" alt="Configuration QR Code" src="/user/qrcode?pkey={{$p.PublicKey}}"/>
{{end}}
</div>
<div class="col-md-3">
{{if $p.DeactivatedAt}}
<div class="pull-right-lg mt-lg-5 disabled-peer">Peer is disabled! <i class="fas fa-comment-dots" data-toggle="tooltip" data-placement="left" title="" data-original-title="Reason: {{$p.DeactivatedReason}}"></i></div>
{{end}}
{{if $p.WillExpire}}
<div class="pull-right-lg mt-lg-5 expiring-peer"><i class="fas fa-exclamation-triangle"></i> Peer will expire on {{ formatDate $p.ExpiresAt}}</div>
{{end}}
{{if eq $.Device.Type "server"}}
<div class="pull-right-lg mt-lg-5 mt-md-3">
<a href="/admin/peer/download?pkey={{$p.PublicKey}}" class="btn btn-primary" title="Download configuration">Download</a>
<a href="/admin/peer/email?pkey={{$p.PublicKey}}" class="btn btn-primary" title="Send configuration via Email">Email</a>
</div>
{{end}}
</div>
</div>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
<p>Currently listed peers: <strong>{{len .Peers}}</strong></p>
</div>
</div>
{{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script>
<script src="/js/jquery.easing.js"></script>
<script src="/js/popper.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/bootstrap-confirmation.min.js"></script>
<script src="/js/custom.js"></script>
</body>
</html>

View File

@ -1,69 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>{{ .Static.WebsiteTitle }} - Users</title>
<meta name="description" content="{{ .Static.WebsiteTitle }}">
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
<link rel="stylesheet" href="/css/custom.css">
</head>
<body id="page-top" class="d-flex flex-column min-vh-100">
{{template "prt_nav.html" .}}
<div class="container mt-5">
<h1>WireGuard VPN Users</h1>
{{template "prt_flashes.html" .}}
<div class="mt-4 row">
<div class="col-sm-10 col-12">
<h2 class="mt-2">All Users</h2>
</div>
<div class="col-sm-2 col-12 text-right">
<a href="/admin/users/create" title="Add a user" class="btn btn-primary"><i class="fa fa-fw fa-plus"></i>M</a>
</div>
</div>
<div class="mt-2 table-responsive">
<table class="table table-sm" id="userTable">
<thead>
<tr>
<th scope="col"><a href="?sort=email">E-Mail <i class="fa fa-fw {{.Session.GetSortIcon "users" "email"}}"></i></a></th>
<th scope="col"><a href="?sort=lastname">Lastname <i class="fa fa-fw {{.Session.GetSortIcon "users" "lastname"}}"></i></a></th>
<th scope="col"><a href="?sort=firstname">Firstname <i class="fa fa-fw {{.Session.GetSortIcon "users" "firstname"}}"></i></a></th>
<th scope="col"><a href="?sort=source">Source <i class="fa fa-fw {{.Session.GetSortIcon "users" "source"}}"></i></a></th>
<th scope="col"><a href="?sort=admin">Is Admin <i class="fa fa-fw {{.Session.GetSortIcon "users" "admin"}}"></i></a></th>
<th scope="col"></th><!-- Actions -->
</tr>
</thead>
<tbody>
{{range $i, $u :=.Users}}
<tr id="user-pos-{{$i}}" {{if $u.DeletedAt.Valid}}class="disabled-peer"{{end}}>
<td>{{$u.Email}}</td>
<td>{{$u.Lastname}}</td>
<td>{{$u.Firstname}}</td>
<td>{{$u.Source}}</td>
<td>{{if $u.IsAdmin}}True{{else}}False{{end}}</td>
<td>
{{if eq $.Session.IsAdmin true}}
{{if eq $u.Source "db"}}
<a href="/admin/users/edit?pkey={{$u.Email}}" title="Edit user"><i class="fas fa-cog"></i></a>
{{end}}
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<p>Currently listed users: <strong>{{len .Users}}</strong></p>
</div>
</div>
{{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script>
<script src="/js/jquery.easing.js"></script>
<script src="/js/popper.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/bootstrap-confirmation.min.js"></script>
<script src="/js/custom.js"></script>
</body>
</html>

View File

@ -1,33 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>{{ .Static.WebsiteTitle }} - Error</title>
<meta name="description" content="{{ .Static.WebsiteTitle }}">
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
<link rel="stylesheet" href="/css/custom.css">
</head>
<body id="page-top">
{{template "prt_nav.html" .}}
<div class="container">
<div class="text-center mt-5">
<div class="error mx-auto" data-text="{{.Data.Code}}">
<p class="m-0">{{.Data.Code}}</p>
</div>
<p class="text-dark mb-5 lead">{{.Data.Message}}</p>
<p class="text-black-50 mb-0">{{.Data.Details}}</p><a href="/">← Back to Dashboard</a>
</div>
</div>
{{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script>
<script src="/js/jquery.easing.js"></script>
<script src="/js/popper.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/bootstrap-confirmation.min.js"></script>
<script src="/js/custom.js"></script>
</body>
</html>

View File

@ -1,89 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<!-- Theme: https://bootswatch.com/lux/ -->
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>{{ .Static.WebsiteTitle }}</title>
<meta name="description" content="{{ .Static.WebsiteTitle }}">
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
<link rel="stylesheet" href="/css/custom.css">
</head>
<body id="page-top" class="d-flex flex-column min-vh-100">
{{template "prt_nav.html" .}}
<div class="container mt-2">
<div class="page-header">
<h1>{{ .Static.WebsiteTitle }}</h1>
</div>
{{template "prt_flashes.html" .}}
<p class="lead">WireGuard® is an extremely simple yet fast and modern VPN that utilizes state-of-the-art cryptography. It aims to be faster, simpler, leaner, and more useful than IPsec, while avoiding the massive headache. It intends to be considerably more performant than OpenVPN. </p>
<h3 class="mt-3">More Information</h3>
<div class="row">
<div class="col-lg-4">
<div class="card border-secondary mb-4" style="min-height: 15rem;">
<div class="card-header">WireGuard Installation</div>
<div class="card-body">
<h4 class="card-title">Installation</h4>
<p class="card-text">Installation instructions for client software can be found on the official WireGuard website.</p>
<a href="https://www.wireguard.com/install/" title="WireGuard Installation" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Instructions</a>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-secondary mb-4" style="min-height: 15rem;">
<div class="card-header">About WireGuard</div>
<div class="card-body">
<h4 class="card-title">About</h4>
<p class="card-text">WireGuard® is an extremely simple yet fast and modern VPN that utilizes state-of-the-art cryptography.</p>
<a href="https://www.wireguard.com/" title="WireGuard" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">More details</a>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-secondary mb-4" style="min-height: 15rem;">
<div class="card-header">About WireGuard Portal</div>
<div class="card-body">
<h4 class="card-title">WireGuard Portal</h4>
<p class="card-text">WireGuard Portal is a simple, web based configuration portal for WireGuard.</p>
<a href="https://github.com/h44z/wg-portal/" title="WireGuard Portal" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">More details</a>
</div>
</div>
</div>
</div>
<div class="jumbotron jumbotron-home">
<h2 class="display-5">VPN Profiles</h2>
<p class="lead">You can access and download your personal VPN configurations via your Userprofile.</p>
<hr class="my-4">
<p>To find all your configured profiles click on the button below.</p>
<p class="lead">
<a href="/user/profile" class="btn btn-primary btn-lg" title="User-Profile">Open My Profile</a>
</p>
</div>
{{with eq $.Session.LoggedIn true}}{{with eq $.Session.IsAdmin true}}
<div class="jumbotron jumbotron-home">
<h2 class="display-5">Administration Area</h2>
<p class="lead">In the administration area you can manage WireGuard peers and the server interface as well as users that are allowed to log in to the WireGuard Portal.</p>
<hr class="my-4">
<p>To find all your configured profiles click on the button below.</p>
<p class="lead">
<a href="/admin/" class="btn btn-primary btn-lg" title="WireGuard Administration">Open WireGuard Administration</a>
<a href="/admin/users/" class="btn btn-primary btn-lg" title="User Administration">Open User Administration</a>
</p>
</div>
{{end}}{{end}}
</div>
{{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script>
<script src="/js/jquery.easing.js"></script>
<script src="/js/popper.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/bootstrap-confirmation.min.js"></script>
<script src="/js/custom.js"></script>
</body>
</html>

View File

@ -1,66 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>{{ .static.WebsiteTitle }} - Login</title>
<meta name="description" content="{{ .static.WebsiteTitle }}">
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
<link rel="stylesheet" href="/fonts/font-awesome.min.css">
<link rel="stylesheet" href="/fonts/fontawesome5-overrides.min.css">
<link rel="stylesheet" href="/css/signin.css">
</head>
<body id="page-top" class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#topNavbar" aria-controls="topNavbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<a class="navbar-brand" href="/"><img src="{{$.static.WebsiteLogo}}" alt="{{$.static.CompanyName}}"/></a>
<div id="topNavbar" class="navbar-collapse collapse">
</div><!--/.navbar-collapse -->
</nav>
<div class="container mt-1">
<div class="card mt-5">
<div class="card-header">Please sign in</div>
<div class="card-body">
<form class="form-signin" method="post" name="login">
<input type="hidden" name="_csrf" value="{{.Csrf}}">
<div class="form-group">
<label for="inputUsername">Username</label>
<input type="text" name="username" class="form-control" id="inputUsername" aria-describedby="usernameHelp" placeholder="Enter username or email">
</div>
<div class="form-group">
<label for="inputPassword">Password</label>
<input type="password" name="password" class="form-control" id="inputPassword" placeholder="Password">
</div>
<button class="btn btn-lg btn-primary btn-block mt-5" type="submit">Sign in</button>
{{ if eq .error true }}
<div class="alert alert-danger mt-3" role="alert">
{{.message}}
</div>
{{end}}
</form>
<div class="card o-hidden border-0 my-5">
<div class="card-body p-0">
<a href="/" class="btn btn-white btn-block text-primary btn-user">Go Home</a>
</div>
</div>
</div>
</div>
{{template "prt_flashes.html" .}}
</div>
<script src="/js/jquery.min.js"></script>
<script src="/js/jquery.easing.js"></script>
<script src="/js/popper.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/bootstrap-confirmation.min.js"></script>
<script src="/js/custom.js"></script>
</body>
</html>

View File

@ -1,5 +0,0 @@
{{range $flash := $.Alerts}}
<div class="alert alert-{{$flash.Type}}" role="alert">
{{$flash.Message}}
</div>
{{end}}

View File

@ -1,5 +0,0 @@
<footer class="page-footer mt-auto">
<div class="container mt-3">
<p class="text-muted">Copyright © {{ $.Static.CompanyName }} {{$.Static.Year}}, version {{$.Static.Version}} <a class="float-right scroll-to-top" href="#page-top"><i class="fas fa-angle-up"></i></a></p>
</div>
</footer>

View File

@ -1,61 +0,0 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#topNavbar" aria-controls="topNavbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<a class="navbar-brand" href="/"><img src="{{$.Static.WebsiteLogo}}" alt="{{$.Static.CompanyName}}"/></a>
<div id="topNavbar" class="navbar-collapse collapse">
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
<li class="nav-spacer"></li>
{{with eq $.Session.LoggedIn true}}{{with eq $.Session.IsAdmin true}}
{{with eq $.Route "/admin/"}}
<form class="form-inline my-2 my-lg-0" method="get">
<input class="form-control mr-sm-2" name="search" type="search" placeholder="Search" aria-label="Search" value="{{index $.Session.Search "peers"}}">
<button class="btn btn-outline-success my-2 my-sm-0" type="submit"><i class="fa fa-search"></i></button>
</form>
{{end}}
{{with eq $.Route "/admin/users/"}}
<form class="form-inline my-2 my-lg-0" method="get">
<input class="form-control mr-sm-2" name="search" type="search" placeholder="Search" aria-label="Search" value="{{index $.Session.Search "users"}}">
<button class="btn btn-outline-success my-2 my-sm-0" type="submit"><i class="fa fa-search"></i></button>
</form>
{{end}}
{{end}}{{end}}
</ul>
{{with eq $.Session.LoggedIn true}}{{with eq $.Session.IsAdmin true}}
{{with startsWith $.Route "/admin/"}}
<form class="form-inline my-2 my-lg-0" method="get">
<div class="form-group mr-sm-2">
<select name="device" id="inputDevice" class="form-control device-selector">
{{range $d, $dn := $.DeviceNames}}
<option value="{{$d}}" {{if eq $d $.Session.DeviceName}}selected{{end}}>{{$d}} {{if and (ne $dn "") (ne $d $dn)}}({{$dn}}){{end}}</option>
{{end}}
</select>
</div>
</form>
{{end}}
{{end}}{{end}}
{{if eq $.Session.LoggedIn true}}
<div class="nav-item dropdown">
<a href="#" class="navbar-text dropdown-toggle" data-toggle="dropdown">{{$.Session.Firstname}} {{$.Session.Lastname}} <span class="caret"></span></a>
<div class="dropdown-menu">
{{with eq $.Session.LoggedIn true}}{{with eq $.Session.IsAdmin true}}
<a class="dropdown-item" href="/admin/"><i class="fas fa-cogs"></i> Administration</a>
<a class="dropdown-item" href="/admin/users/"><i class="fas fa-users-cog"></i> User Management</a>
<div class="dropdown-divider"></div>
{{end}}{{end}}
<a class="dropdown-item" href="/user/profile"><i class="fas fa-user"></i> Profile</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="/auth/logout"><i class="fas fa-sign-out-alt"></i> Logout</a>
</div>
</div>
{{else}}
<a href="/auth/login" class="navbar-text"><i class="fas fa-sign-in-alt fa-sm fa-fw mr-2 text-gray-400"></i> Login</a></li>
{{end}}
</div><!--/.navbar-collapse -->
</nav>
{{if not $.Device.IsValid}}
<div class="container">
<div class="alert alert-danger">Warning: WireGuard Interface {{$.Device.DeviceName}} is not fully configured! Configurations may be incomplete and non functional!</div>
</div>
{{end}}

View File

@ -1,44 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>{{ .Static.WebsiteTitle }} - Admin</title>
<meta name="description" content="{{ .Static.WebsiteTitle }}">
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
<link rel="stylesheet" href="/css/custom.css">
</head>
<body id="page-top" class="d-flex flex-column min-vh-100">
{{template "prt_nav.html" .}}
<div class="container mt-5">
{{template "prt_flashes.html" .}}
<!-- server mode -->
<h1>Create a new client</h1>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{.Csrf}}">
<input type="hidden" name="uid" value="{{.Peer.UID}}">
<div class="form-row">
<div class="form-group required col-md-12">
<label for="server_PublicKey">Public Key</label>
<input type="text" name="pubkey" class="form-control" id="server_PublicKey" value="{{.Peer.PublicKey}}" required>
</div>
</div>
<button type="submit" class="btn btn-primary">Save</button>
<a href="/user/profile" class="btn btn-secondary">Cancel</a>
</form>
</div>
{{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script>
<script src="/js/jquery.easing.js"></script>
<script src="/js/popper.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/bootstrap-confirmation.min.js"></script>
<script src="/js/custom.js"></script>
</body>
</html>

View File

@ -1,54 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>{{ .Static.WebsiteTitle }} - Admin</title>
<meta name="description" content="{{ .Static.WebsiteTitle }}">
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
<link rel="stylesheet" href="/css/custom.css">
</head>
<body id="page-top" class="d-flex flex-column min-vh-100">
{{template "prt_nav.html" .}}
<div class="container mt-5">
{{template "prt_flashes.html" .}}
<!-- server mode -->
<h1>Edit client: <strong>{{.Peer.Identifier}}</strong></h1>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{.Csrf}}">
<input type="hidden" name="uid" value="{{.Peer.UID}}">
<div class="form-row">
<div class="form-group required col-md-12">
<label for="server_PublicKey">Public Key</label>
<input type="text" name="pubkey" class="form-control" id="server_PublicKey" value="{{.Peer.PublicKey}}" required disabled="disabled">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<div class="custom-control custom-switch">
<input class="custom-control-input" name="isdisabled" type="checkbox" value="true" id="server_Disabled" {{if .Peer.DeactivatedAt}}disabled="disabled"{{end}} {{if .Peer.DeactivatedAt}}checked{{end}}>
<label class="custom-control-label" for="server_Disabled">
Disabled
</label>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Save</button>
<a href="/user/profile" class="btn btn-secondary">Cancel</a>
</form>
</div>
{{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script>
<script src="/js/jquery.easing.js"></script>
<script src="/js/popper.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/bootstrap-confirmation.min.js"></script>
<script src="/js/custom.js"></script>
</body>
</html>

View File

@ -1,137 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>{{ .Static.WebsiteTitle }} - Profile</title>
<meta name="description" content="{{ .Static.WebsiteTitle }}">
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
<link rel="stylesheet" href="/css/custom.css">
</head>
<body id="page-top" class="d-flex flex-column min-vh-100">
{{template "prt_nav.html" .}}
<div class="container mt-5">
<h1>WireGuard VPN User-Portal</h1>
<div class="mt-4 row">
<div class="col-sm-8 col-12">
<h2 class="mt-2">Your VPN Profiles</h2>
</div>
<div class="col-sm-4 col-12 text-right">
{{if eq $.UserManagePeers true}}
<a href="/user/peer/create" title="Add a peer" class="btn btn-primary"><i class="fa fa-fw fa-plus"></i><i class="fa fa-fw fa-user"></i></a>
{{end}}
</div>
</div>
<div class="mt-2 table-responsive">
<table class="table table-sm" id="userTable">
<thead>
<tr>
<th scope="col" class="list-image-cell"></th><!-- Status and expand -->
<th scope="col"><a href="?sort=id">Identifier <i class="fa fa-fw {{.Session.GetSortIcon "userpeers" "id"}}"></i></a></th>
<th scope="col"><a href="?sort=pubKey">Public Key <i class="fa fa-fw {{.Session.GetSortIcon "userpeers" "pubKey"}}"></i></a></th>
<th scope="col"><a href="?sort=mail">E-Mail <i class="fa fa-fw {{.Session.GetSortIcon "userpeers" "mail"}}"></i></a></th>
<th scope="col"><a href="?sort=ip">IP's <i class="fa fa-fw {{.Session.GetSortIcon "userpeers" "ip"}}"></i></a></th>
<th scope="col"><a href="?sort=device">Interface <i class="fa fa-fw {{.Session.GetSortIcon "userpeers" "device"}}"></i></a></th>
<th scope="col"><a href="?sort=handshake">Handshake <i class="fa fa-fw {{.Session.GetSortIcon "userpeers" "handshake"}}"></i></a></th>
{{if eq $.UserManagePeers true}}
<th scope="col"></th>
{{end}}
</tr>
</thead>
<tbody>
{{range $i, $p :=.Peers}}
{{$peerUser:=(userForEmail $.Users $p.Email)}}
<tr id="user-pos-{{$i}}" {{if $p.DeactivatedAt}}class="disabled-peer"{{end}}>
<th scope="row" class="list-image-cell">
<a href="#{{$p.UID}}" data-toggle="collapse" class="collapse-indicator collapsed"></a>
<!-- online check -->
<span class="online-status" id="online-{{$p.UID}}" data-pkey="{{$p.PublicKey}}"><i class="fas fa-unlink"></i></span>
</th>
<td>{{$p.Identifier}}{{if $p.WillExpire}} <i class="fas fa-hourglass-end expiring-peer" data-toggle="tooltip" data-placement="right" title="" data-original-title="Expires at: {{formatDate $p.ExpiresAt}}"></i>{{end}}</td>
<td>{{$p.PublicKey}}</td>
<td>{{$p.Email}}</td>
<td>{{$p.IPsStr}}</td>
<td>{{$p.DeviceName}}</td>
<td><span data-toggle="tooltip" data-placement="left" title="" data-original-title="{{$p.LastHandshakeTime}}">{{$p.LastHandshake}}</span></td>
{{if eq $.UserManagePeers true}}
<td>
<a href="/user/peer/edit?pkey={{$p.PublicKey}}" title="Edit peer"><i class="fas fa-cog"></i></a>
</td>
{{end}}
</tr>
<tr class="hiddenRow">
<td colspan="6" class="hiddenCell" style="white-space:nowrap">
<div class="collapse" id="{{$p.UID}}" data-parent="#userTable">
<div class="row collapsedRow">
<div class="col-md-6 leftBorder">
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link active" data-toggle="tab" href="#t1{{$p.UID}}">Personal</a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="tab" href="#t2{{$p.UID}}">Configuration</a>
</li>
</ul>
<div class="tab-content" id="tabContent{{$p.UID}}">
<div id="t1{{$p.UID}}" class="tab-pane fade active show">
<h4>User details</h4>
{{if not $peerUser}}
<p>No user information available...</p>
{{else}}
<ul>
<li>Firstname: {{$peerUser.Firstname}}</li>
<li>Lastname: {{$peerUser.Lastname}}</li>
<li>Phone: {{$peerUser.Phone}}</li>
<li>Mail: {{$peerUser.Email}}</li>
</ul>
{{end}}
<h4>Traffic</h4>
{{if not $p.Peer}}
<p>No Traffic data available...</p>
{{else}}
<p>{{if $p.DeactivatedAt}}-{{else}}<i class="fas fa-long-arrow-alt-down"></i></i> {{formatBytes $p.Peer.ReceiveBytes}} / <i class="fas fa-long-arrow-alt-up"></i> {{formatBytes $p.Peer.TransmitBytes}}{{end}}</p>
{{end}}
</div>
<div id="t2{{$p.UID}}" class="tab-pane fade">
<pre>{{$p.Config}}</pre>
</div>
</div>
</div>
<div class="col-md-3">
<img class="list-image-large" src="/user/qrcode?pkey={{$p.PublicKey}}"/>
</div>
<div class="col-md-3">
{{if $p.DeactivatedAt}}
<div class="pull-right-lg mt-lg-5 disabled-peer">Peer is disabled! <i class="fas fa-comment-dots" data-toggle="tooltip" data-placement="left" title="" data-original-title="Reason: {{$p.DeactivatedReason}}"></i></div>
{{end}}
{{if $p.WillExpire}}
<div class="pull-right-lg mt-lg-5 expiring-peer"><i class="fas fa-exclamation-triangle"></i> Profile expires on {{ formatDate $p.ExpiresAt}}</div>
{{end}}
<div class="pull-right-lg mt-lg-5 mt-md-3">
<a href="/user/download?pkey={{$p.PublicKey}}" class="btn btn-primary" title="Download configuration">Download</a>
<a href="/user/email?pkey={{$p.PublicKey}}" class="btn btn-primary" title="Send configuration via Email">Email</a>
</div>
</div>
</div>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
<p>Currently listed peers: <strong>{{len .Peers}}</strong></p>
</div>
</div>
{{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script>
<script src="/js/jquery.easing.js"></script>
<script src="/js/popper.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/bootstrap-confirmation.min.js"></script>
<script src="/js/custom.js"></script>
</body>
</html>

View File

@ -0,0 +1,70 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/sirupsen/logrus"
"github.com/swaggo/swag"
"github.com/swaggo/swag/gen"
)
// this replaces the call to: swag init --propertyStrategy pascalcase --parseDependency --parseInternal --generalInfo base.go
func main() {
wd, err := os.Getwd() // should be the project root
if err != nil {
panic(err)
}
apiBasePath := filepath.Join(wd, "/internal/app/api")
apis := []string{"v0"}
hasError := false
for _, apiVersion := range apis {
apiPath := filepath.Join(apiBasePath, apiVersion, "handlers")
apiVersion = strings.TrimLeft(apiVersion, "api-")
log.Println("")
log.Println("Generate swagger docs for API", apiVersion)
log.Println("Api path:", apiPath)
err := generateApi(apiBasePath, apiPath, apiVersion)
if err != nil {
hasError = true
logrus.Errorf("failed to generate API docs for %s: %v", apiVersion, err)
}
log.Println("Generated swagger docs for API", apiVersion)
}
if hasError {
os.Exit(1)
}
}
func generateApi(basePath, apiPath, version string) error {
err := gen.New().Build(&gen.Config{
SearchDir: apiPath,
Excludes: "",
MainAPIFile: "base.go",
PropNamingStrategy: swag.PascalCase,
OutputDir: filepath.Join(basePath, "core/assets/doc"),
OutputTypes: []string{"json", "yaml"},
ParseVendor: false,
ParseDependency: true,
MarkdownFilesDir: "",
ParseInternal: true,
GeneratedTime: false,
CodeExampleFilesDir: "",
ParseDepth: 3,
InstanceName: version,
})
if err != nil {
return fmt.Errorf("swag failed: %w", err)
}
return nil
}

View File

@ -1,35 +0,0 @@
// source taken from https://git.prolicht.digital/golib/healthcheck/-/blob/master/cmd/hc/main.go
package main
import (
"net/http"
"os"
"time"
)
// main checks the given URL, if the response is not 200, it will return with exit code 1
// on success, exit code 0 will be returned
func main() {
os.Exit(checkWebEndpointFromArgs())
}
func checkWebEndpointFromArgs() int {
if len(os.Args) < 2 {
return 1
}
if status := checkWebEndpoint(os.Args[1]); !status {
return 1
}
return 0
}
func checkWebEndpoint(url string) bool {
client := &http.Client{
Timeout: time.Second * 2,
}
if resp, err := client.Get(url); err != nil || resp.StatusCode < 200 || resp.StatusCode > 299 {
return false
}
return true
}

View File

@ -2,103 +2,144 @@ package main
import (
"context"
"io"
"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"
"github.com/h44z/wg-portal/internal/app/route"
"github.com/h44z/wg-portal/internal/app/users"
"github.com/h44z/wg-portal/internal/app/wireguard"
"os"
"os/signal"
"runtime"
"strings"
"syscall"
"time"
"git.prolicht.digital/golib/healthcheck"
"github.com/h44z/wg-portal/internal/server"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/adapters"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/config"
"github.com/sirupsen/logrus"
evbus "github.com/vardius/message-bus"
)
// main entry point for WireGuard Portal
func main() {
_ = setupLogger(logrus.StandardLogger())
ctx := internal.SignalAwareContext(context.Background(), syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM)
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
logrus.Infof("Starting WireGuard Portal V2...")
logrus.Infof("WireGuard Portal version: %s", internal.Version)
logrus.Infof("sysinfo: os=%s, arch=%s", runtime.GOOS, runtime.GOARCH)
logrus.Infof("starting WireGuard Portal Server [%s]...", server.Version)
cfg, err := config.GetConfig()
internal.AssertNoError(err)
setupLogging(cfg)
// Context for clean shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cfg.LogStartupValues()
// start health check service on port 11223
healthcheck.New(healthcheck.ListenOn("127.0.0.1:11223")).StartWithContext(ctx)
rawDb, err := adapters.NewDatabase(cfg.Database)
internal.AssertNoError(err)
service := server.Server{}
if err := service.Setup(ctx); err != nil {
logrus.Fatalf("setup failed: %v", err)
database, err := adapters.NewSqlRepository(rawDb)
internal.AssertNoError(err)
wireGuard := adapters.NewWireGuardRepository()
wgQuick := adapters.NewWgQuickRepo()
mailer := adapters.NewSmtpMailRepo(cfg.Mail)
cfgFileSystem, err := adapters.NewFileSystemRepository(cfg.Advanced.ConfigStoragePath)
internal.AssertNoError(err)
shouldExit, err := app.HandleProgramArgs(cfg, rawDb)
switch {
case shouldExit && err == nil:
return
case shouldExit && err != nil:
logrus.Errorf("Failed to process program args: %v", err)
os.Exit(1)
case !shouldExit:
internal.AssertNoError(err)
}
// Attach signal handlers to context
go func() {
osCall := <-c
logrus.Tracef("received system call: %v", osCall)
cancel() // cancel the context
}()
queueSize := 100
eventBus := evbus.New(queueSize)
// Start main process in background
go service.Run()
userManager, err := users.NewUserManager(cfg, eventBus, database, database)
internal.AssertNoError(err)
<-ctx.Done() // Wait until the context gets canceled
authenticator, err := auth.NewAuthenticator(&cfg.Auth, eventBus, userManager)
internal.AssertNoError(err)
// Give goroutines some time to stop gracefully
logrus.Info("stopping WireGuard Portal Server...")
time.Sleep(2 * time.Second)
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database)
internal.AssertNoError(err)
logrus.Infof("stopped WireGuard Portal Server...")
logrus.Exit(0)
statisticsCollector, err := wireguard.NewStatisticsCollector(cfg, database, wireGuard)
internal.AssertNoError(err)
cfgFileManager, err := configfile.NewConfigFileManager(cfg, eventBus, database, database, cfgFileSystem)
internal.AssertNoError(err)
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)
routeManager, err := route.NewRouteManager(cfg, eventBus, database)
internal.AssertNoError(err)
routeManager.StartBackgroundJobs(ctx)
backend, err := app.New(cfg, eventBus, authenticator, userManager, wireGuardManager,
statisticsCollector, cfgFileManager, mailManager)
internal.AssertNoError(err)
err = backend.Startup(ctx)
internal.AssertNoError(err)
apiFrontend := handlersV0.NewRestApi(cfg, backend)
webSrv, err := core.NewServer(cfg, apiFrontend)
internal.AssertNoError(err)
go webSrv.Run(ctx, cfg.Web.ListeningAddress)
// wait until context gets cancelled
<-ctx.Done()
logrus.Infof("Stopping WireGuard Portal")
time.Sleep(5 * time.Second) // wait for (most) goroutines to finish gracefully
logrus.Infof("Stopped WireGuard Portal")
}
func setupLogger(logger *logrus.Logger) error {
// Check environment variables for logrus settings
level, ok := os.LookupEnv("LOG_LEVEL")
if !ok {
level = "debug" // Default logrus level
}
useJSON, ok := os.LookupEnv("LOG_JSON")
if !ok {
useJSON = "false" // Default use human readable logging
}
useColor, ok := os.LookupEnv("LOG_COLOR")
if !ok {
useColor = "true"
}
switch level {
case "off":
logger.SetOutput(io.Discard)
case "info":
logger.SetLevel(logrus.InfoLevel)
case "debug":
logger.SetLevel(logrus.DebugLevel)
func setupLogging(cfg *config.Config) {
switch strings.ToLower(cfg.Advanced.LogLevel) {
case "trace":
logger.SetLevel(logrus.TraceLevel)
logrus.SetLevel(logrus.TraceLevel)
case "debug":
logrus.SetLevel(logrus.DebugLevel)
case "info", "information":
logrus.SetLevel(logrus.InfoLevel)
case "warn", "warning":
logrus.SetLevel(logrus.WarnLevel)
case "error":
logrus.SetLevel(logrus.ErrorLevel)
default:
logrus.SetLevel(logrus.WarnLevel)
}
var formatter logrus.Formatter
if useJSON == "false" {
f := new(logrus.TextFormatter)
f.TimestampFormat = "2006-01-02 15:04:05"
f.FullTimestamp = true
if useColor == "true" {
f.ForceColors = true
}
formatter = f
} else {
f := new(logrus.JSONFormatter)
f.TimestampFormat = "2006-01-02 15:04:05"
formatter = f
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,
})
}
logger.SetFormatter(formatter)
return nil
}

47
config.yml.sample Normal file
View File

@ -0,0 +1,47 @@
advanced:
log_level: trace
core:
admin_user: test@test.de
admin_password: secret
web:
external_url: http://localhost:8888
request_logging: true
auth:
callback_url_prefix: http://localhost:8888/api/v0
ldap:
- id: ldap1
provider_name: company ldap
display_name: Login with</br>LDAP
url: ldap://ldap.yourcompany.local:389
bind_user: ldap_wireguard@yourcompany.local
bind_pass: super_Secret_PASSWORD
base_dn: DC=YOURCOMPANY,DC=LOCAL
login_filter: (&(objectClass=organizationalPerson)(mail={{login_identifier}})(!userAccountControl:1.2.840.113556.1.4.803:=2))
admin_group: CN=WireGuardAdmins,OU=it,DC=YOURCOMPANY,DC=LOCAL
synchronize: false
sync_filter: (&(objectClass=organizationalPerson)(!userAccountControl:1.2.840.113556.1.4.803:=2)(mail=*))
registration_enabled: true
oidc:
- id: oidc1
provider_name: google
display_name: Login with</br>Google
base_url: https://accounts.google.com
client_id: the-client-id-1234.apps.googleusercontent.com
client_secret: A_CLIENT_SECRET
extra_scopes:
- https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile
registration_enabled: true
- id: oidc2
provider_name: google2
display_name: Login with</br>Google2
base_url: https://accounts.google.com
client_id: another-client-id-1234.apps.googleusercontent.com
client_secret: A_CLIENT_SECRET
extra_scopes:
- https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile
registration_enabled: true

View File

@ -2,7 +2,7 @@
version: '3.6'
services:
wg-portal:
image: h44z/wg-portal:1.0.17
image: h44z/wg-portal:2.0.0-alpha1
container_name: wg-portal
restart: unless-stopped
logging:
@ -16,4 +16,4 @@ services:
- /etc/wireguard:/etc/wireguard
- ./data:/app/data
environment:
- EXTERNAL_URL=http://localhost:8123
- EXTERNAL_URL=http://localhost:8888

12
efs.go
View File

@ -1,12 +0,0 @@
package wg_portal
import "embed"
//go:embed assets/tpl/*
var Templates embed.FS
//go:embed assets/css/*
//go:embed assets/fonts/*
//go:embed assets/img/*
//go:embed assets/js/*
var Statics embed.FS

View File

@ -0,0 +1 @@
VITE_SOME_EXAMPLE_VAR=http://localhost:5000 (can be used internally like: import.meta.env.VITE_SOME_EXAMPLE_VAR)

1
frontend/.env.production Normal file
View File

@ -0,0 +1 @@
VITE_API_BASE_URL=https://wgportal.server.com

28
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/extensions.json
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["johnsoncodehk.volar", "johnsoncodehk.vscode-typescript-vue-plugin"]
}

29
frontend/README.md Normal file
View File

@ -0,0 +1,29 @@
# frontend
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.vscode-typescript-vue-plugin).
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```

35
frontend/index.html Normal file
View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link href="/favicon.ico" rel="icon" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>WireGuard Portal</title>
<meta content="WireGuard VPN Management Portal" name="description">
<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>
<body class="d-flex flex-column min-vh-100">
<noscript>
<strong>We're sorry but this site doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<!-- vue teleport will add toasts here -->
<div id="toasts"></div>
<!-- main application -->
<div id="app"></div>
<!-- vue teleport will add modals and dialogs here -->
<div id="modals"></div>
<div id="dialogs"></div>
<script src="/src/main.js" type="module"></script>
</body>
</html>

1684
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
frontend/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "frontend",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build-dev": "vite build --mode development --base=/app/",
"build": "vite build --base=/app/",
"preview": "vite preview --port 5050"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.4.0",
"@kyvg/vue3-notification": "^2.9.1",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.0",
"bootswatch": "^5.3.0",
"flag-icons": "^6.7.0",
"is-cidr": "^5.0.3",
"is-ip": "^5.0.0",
"pinia": "^2.1.4",
"prismjs": "^1.29.0",
"vue": "^3.3.4",
"vue-i18n": "^9.2.2",
"vue-prism-component": "github:h44z/vue-prism-component",
"vue-router": "^4.2.2",
"vue3-tags-input": "^1.0.12"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"vite": "^4.3.9"
}
}

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

128
frontend/src/App.vue Normal file
View File

@ -0,0 +1,128 @@
<script setup>
import { RouterLink, RouterView } from 'vue-router';
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...");
await sec.LoadSecurityProperties();
await auth.LoadProviders();
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();
}
}
console.log("WireGuard Portal ready!");
})
const switchLanguage = function (lang) {
if (appGlobal.$i18n.locale !== lang) {
localStorage.setItem('wgLang', lang);
appGlobal.$i18n.locale = lang;
}
}
const languageFlag = computed(() => {
// `this` points to the component instance
let lang = appGlobal.$i18n.locale.toLowerCase();
if (lang === "en") {
lang = "us";
}
return "fi-" + lang;
})
const companyName = ref(WGPORTAL_SITE_COMPANY_NAME);
const wgVersion = ref(WGPORTAL_VERSION);
const currentYear = ref(new Date().getFullYear())
</script>
<template>
<notifications :duration="3000" :ignore-duplicates="true" position="top right" />
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="/"><img alt="WireGuard Portal" src="/img/header-logo.png" /></a>
<button aria-controls="navbarColor01" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler"
data-bs-target="#navbarTop" data-bs-toggle="collapse" type="button">
<span class="navbar-toggler-icon"></span>
</button>
<div id="navbarTop" class="collapse navbar-collapse">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<RouterLink :to="{ name: 'home' }" class="nav-link">{{ $t('menu.home') }}</RouterLink>
</li>
<li v-if="auth.IsAuthenticated && auth.IsAdmin" class="nav-item">
<RouterLink :to="{ name: 'interfaces' }" class="nav-link">{{ $t('menu.interfaces') }}</RouterLink>
</li>
<li v-if="auth.IsAuthenticated && auth.IsAdmin" class="nav-item">
<RouterLink :to="{ name: 'users' }" class="nav-link">{{ $t('menu.users') }}</RouterLink>
</li>
</ul>
<div class="navbar-nav d-flex justify-content-end">
<div v-if="auth.IsAuthenticated" class="nav-item dropdown">
<a aria-expanded="false" aria-haspopup="true" class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#"
role="button">{{ auth.User.Firstname }} {{ auth.User.Lastname }}</a>
<div class="dropdown-menu">
<RouterLink :to="{ name: 'profile' }" class="dropdown-item"><i class="fas fa-user"></i> {{ $t('menu.profile') }}</RouterLink>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" @click.prevent="auth.Logout">
<i class="fas fa-sign-out-alt"></i> {{ $t('menu.logout') }}
</a>
</div>
</div>
<div v-if="!auth.IsAuthenticated" class="nav-item">
<RouterLink :to="{ name: 'login' }" class="nav-link">
<i class="fas fa-sign-in-alt fa-sm fa-fw me-2"></i>{{ $t('menu.login') }}
</RouterLink>
</div>
</div>
</div>
</div>
</nav>
<div class="container mt-5 flex-shrink-0">
<RouterView />
</div>
<footer class="page-footer mt-auto">
<div class="container mt-5">
<div class="row align-items-center">
<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">
<button aria-expanded="false" aria-haspopup="true" class="btn btn btn-secondary pe-0" data-bs-toggle="dropdown" type="button"><span :class="languageFlag" class="fi"></span></button>
<div aria-labelledby="btnGroupDrop3" class="dropdown-menu" style="">
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('en')"><span class="fi fi-us"></span> English</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('de')"><span class="fi fi-de"></span> Deutsch</a>
</div>
</div>
</div>
</div>
</div>
</div>
</footer>
</template>
<style>
</style>

View File

@ -0,0 +1,5 @@
a.disabled {
pointer-events: none;
cursor: default;
color: #888888;
}

View File

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 308 B

View File

@ -0,0 +1,54 @@
<script setup>
import {ref} from "vue";
import {useI18n} from "vue-i18n";
const { t } = useI18n()
const title = ref("Default Title")
const question = ref("Default Question")
const visible = ref(true)
const emit = defineEmits(['no', 'yes'])
function showDialog(titleStr, questionStr) {
visible.value = true
title.value = titleStr
question.value = questionStr
}
function yes() {
visible.value = false
emit('yes')
}
function no() {
visible.value = false
emit('no')
}
</script>
<template>
<Teleport to="#dialogs">
<div v-if="visible" class="modal-backdrop fade show">
<div class="modal fade show" tabindex="-1">
<div class="modal-dialog modal-dialog-scrollable" @click.stop="">
<div class="modal-content" ref="body">
<div class="modal-header">
<h5 class="modal-title">{{ title }}</h5>
</div>
<div class="modal-body">
{{ question }}
</div>
<div class="modal-footer pt-0 border-top-0">
<button type="button" class="btn btn-primary" @click="no">{{ $t('general.no') }}</button>
<button type="button" class="btn btn-success" @click="yes">{{ $t('general.yes') }}</button>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style>
</style>

View File

@ -0,0 +1,513 @@
<script setup>
import Modal from "./Modal.vue";
import {interfaceStore} from "@/stores/interfaces";
import {computed, ref, watch} from "vue";
import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification";
import Vue3TagsInput from 'vue3-tags-input';
import { validateCIDR, validateIP, validateDomain } from '@/helpers/validators';
import isCidr from "is-cidr";
import {isIP} from 'is-ip';
import { freshInterface } from '@/helpers/models';
import {peerStore} from "@/stores/peers";
const { t } = useI18n()
const interfaces = interfaceStore()
const peers = peerStore()
const props = defineProps({
interfaceId: String,
visible: Boolean,
})
const emit = defineEmits(['close'])
const selectedInterface = computed(() => {
return interfaces.Find(props.interfaceId)
})
const title = computed(() => {
if (!props.visible) {
return "" // otherwise interfaces.GetSelected will die...
}
if (selectedInterface.value) {
return t("modals.interface-edit.headline-edit") + " " + selectedInterface.value.Identifier
}
return t("modals.interface-edit.headline-new")
})
const formData = ref(freshInterface())
// functions
watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown
console.log(selectedInterface.value)
if (!selectedInterface.value) {
await interfaces.PrepareInterface()
// fill form data
formData.value.Identifier = interfaces.Prepared.Identifier
formData.value.DisplayName = interfaces.Prepared.DisplayName
formData.value.Mode = interfaces.Prepared.Mode
formData.value.PublicKey = interfaces.Prepared.PublicKey
formData.value.PrivateKey = interfaces.Prepared.PrivateKey
formData.value.ListenPort = interfaces.Prepared.ListenPort
formData.value.Addresses = interfaces.Prepared.Addresses
formData.value.Dns = interfaces.Prepared.Dns
formData.value.DnsSearch = interfaces.Prepared.DnsSearch
formData.value.Mtu = interfaces.Prepared.Mtu
formData.value.FirewallMark = interfaces.Prepared.FirewallMark
formData.value.RoutingTable = interfaces.Prepared.RoutingTable
formData.value.PreUp = interfaces.Prepared.PreUp
formData.value.PostUp = interfaces.Prepared.PostUp
formData.value.PreDown = interfaces.Prepared.PreDown
formData.value.PostDown = interfaces.Prepared.PostDown
formData.value.SaveConfig = interfaces.Prepared.SaveConfig
formData.value.PeerDefNetwork = interfaces.Prepared.PeerDefNetwork
formData.value.PeerDefDns = interfaces.Prepared.PeerDefDns
formData.value.PeerDefDnsSearch = interfaces.Prepared.PeerDefDnsSearch
formData.value.PeerDefEndpoint = interfaces.Prepared.PeerDefEndpoint
formData.value.PeerDefAllowedIPs = interfaces.Prepared.PeerDefAllowedIPs
formData.value.PeerDefMtu = interfaces.Prepared.PeerDefMtu
formData.value.PeerDefPersistentKeepalive = interfaces.Prepared.PeerDefPersistentKeepalive
formData.value.PeerDefFirewallMark = interfaces.Prepared.PeerDefFirewallMark
formData.value.PeerDefRoutingTable = interfaces.Prepared.PeerDefRoutingTable
formData.value.PeerDefPreUp = interfaces.Prepared.PeerDefPreUp
formData.value.PeerDefPostUp = interfaces.Prepared.PeerDefPostUp
formData.value.PeerDefPreDown = interfaces.Prepared.PeerDefPreDown
formData.value.PeerDefPostDown = interfaces.Prepared.PeerDefPostDown
} else { // fill existing userdata
formData.value.Disabled = selectedInterface.value.Disabled
formData.value.Identifier = selectedInterface.value.Identifier
formData.value.DisplayName = selectedInterface.value.DisplayName
formData.value.Mode = selectedInterface.value.Mode
formData.value.PublicKey = selectedInterface.value.PublicKey
formData.value.PrivateKey = selectedInterface.value.PrivateKey
formData.value.ListenPort = selectedInterface.value.ListenPort
formData.value.Addresses = selectedInterface.value.Addresses
formData.value.Dns = selectedInterface.value.Dns
formData.value.DnsSearch = selectedInterface.value.DnsSearch
formData.value.Mtu = selectedInterface.value.Mtu
formData.value.FirewallMark = selectedInterface.value.FirewallMark
formData.value.RoutingTable = selectedInterface.value.RoutingTable
formData.value.PreUp = selectedInterface.value.PreUp
formData.value.PostUp = selectedInterface.value.PostUp
formData.value.PreDown = selectedInterface.value.PreDown
formData.value.PostDown = selectedInterface.value.PostDown
formData.value.SaveConfig = selectedInterface.value.SaveConfig
formData.value.PeerDefNetwork = selectedInterface.value.PeerDefNetwork
formData.value.PeerDefDns = selectedInterface.value.PeerDefDns
formData.value.PeerDefDnsSearch = selectedInterface.value.PeerDefDnsSearch
formData.value.PeerDefEndpoint = selectedInterface.value.PeerDefEndpoint
formData.value.PeerDefAllowedIPs = selectedInterface.value.PeerDefAllowedIPs
formData.value.PeerDefMtu = selectedInterface.value.PeerDefMtu
formData.value.PeerDefPersistentKeepalive = selectedInterface.value.PeerDefPersistentKeepalive
formData.value.PeerDefFirewallMark = selectedInterface.value.PeerDefFirewallMark
formData.value.PeerDefRoutingTable = selectedInterface.value.PeerDefRoutingTable
formData.value.PeerDefPreUp = selectedInterface.value.PeerDefPreUp
formData.value.PeerDefPostUp = selectedInterface.value.PeerDefPostUp
formData.value.PeerDefPreDown = selectedInterface.value.PeerDefPreDown
formData.value.PeerDefPostDown = selectedInterface.value.PeerDefPostDown
}
}
}
)
function close() {
formData.value = freshInterface()
emit('close')
}
function handleChangeAddresses(tags) {
let validInput = true
tags.forEach(tag => {
if(isCidr(tag) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.Addresses = tags
}
}
function handleChangeDns(tags) {
let validInput = true
tags.forEach(tag => {
if(!isIP(tag)) {
validInput = false
notify({
title: "Invalid IP",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.Dns = tags
}
}
function handleChangeDnsSearch(tags) {
formData.value.DnsSearch = tags
}
function handleChangePeerDefNetwork(tags) {
let validInput = true
tags.forEach(tag => {
if(isCidr(tag) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.PeerDefNetwork = tags
}
}
function handleChangePeerDefAllowedIPs(tags) {
let validInput = true
tags.forEach(tag => {
if(isCidr(tag) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.PeerDefAllowedIPs = tags
}
}
function handleChangePeerDefDns(tags) {
let validInput = true
tags.forEach(tag => {
if(!isIP(tag)) {
validInput = false
notify({
title: "Invalid IP",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.PeerDefDns = tags
}
}
function handleChangePeerDefDnsSearch(tags) {
formData.value.PeerDefDnsSearch = tags
}
async function save() {
try {
if (props.interfaceId!=='#NEW#') {
await interfaces.UpdateInterface(selectedInterface.value.Identifier, formData.value)
} else {
await interfaces.CreateInterface(formData.value)
}
close()
} catch (e) {
console.log(e)
notify({
title: "Failed to save interface!",
text: e.toString(),
type: 'error',
})
}
}
async function applyPeerDefaults() {
if (props.interfaceId==='#NEW#') {
return; // do nothing for new interfaces
}
try {
await interfaces.ApplyPeerDefaults(selectedInterface.value.Identifier, formData.value)
notify({
title: "Peer Defaults Applied",
text: "Applied current peer defaults to all available peers.",
type: 'success',
})
await peers.LoadPeers(selectedInterface.value.Identifier) // reload all peers after applying the defaults
} catch (e) {
console.log(e)
notify({
title: "Failed to apply peer defaults!",
text: e.toString(),
type: 'error',
})
}
}
async function del() {
try {
await interfaces.DeleteInterface(selectedInterface.value.Identifier)
close()
} catch (e) {
console.log(e)
notify({
title: "Failed to delete interface!",
text: e.toString(),
type: 'error',
})
}
}
</script>
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" href="#interface">{{ $t('modals.interface-edit.tab-interface') }}</a>
</li>
<li v-if="formData.Mode==='server'" class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#peerdefaults">{{ $t('modals.interface-edit.tab-peerdef') }}</a>
</li>
</ul>
<div id="interfaceTabs" class="tab-content">
<div id="interface" class="tab-pane fade active show">
<fieldset>
<legend class="mt-4">{{ $t('modals.interface-edit.header-general') }}</legend>
<div v-if="props.interfaceId==='#NEW#'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.identifier.label') }}</label>
<input v-model="formData.Identifier" class="form-control" :placeholder="$t('modals.interface-edit.identifier.placeholder')" type="text">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.mode.label') }}</label>
<select v-model="formData.Mode" class="form-select">
<option value="server">{{ $t('modals.interface-edit.mode.server') }}</option>
<option value="client">{{ $t('modals.interface-edit.mode.client') }}</option>
<option value="any">{{ $t('modals.interface-edit.mode.any') }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.display-name.label') }}</label>
<input v-model="formData.DisplayName" class="form-control" :placeholder="$t('modals.interface-edit.display-name.placeholder')" type="text">
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.interface-edit.header-crypto') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.private-key.label') }}</label>
<input v-model="formData.PrivateKey" class="form-control" :placeholder="$t('modals.interface-edit.private-key.placeholder')" required type="email">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.public-key.label') }}</label>
<input v-model="formData.PublicKey" class="form-control" :placeholder="$t('modals.interface-edit.public-key.placeholder')" required type="email">
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.interface-edit.header-network') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.ip.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.Addresses"
:placeholder="$t('modals.interface-edit.ip.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR"
@on-tags-changed="handleChangeAddresses"/>
</div>
<div v-if="formData.Mode==='server'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.listen-port.label') }}</label>
<input v-model="formData.ListenPort" class="form-control" :placeholder="$t('modals.interface-edit.listen-port.placeholder')" type="number">
</div>
<div v-if="formData.Mode!=='server'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.dns.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.Dns"
:placeholder="$t('modals.interface-edit.dns.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateIP"
@on-tags-changed="handleChangeDns"/>
</div>
<div v-if="formData.Mode!=='server'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.dns-search.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.DnsSearch"
:placeholder="$t('modals.interface-edit.dns-search.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateDomain"
@on-tags-changed="handleChangeDnsSearch"/>
</div>
<div class="row">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.interface-edit.mtu.label') }}</label>
<input v-model="formData.Mtu" class="form-control" :placeholder="$t('modals.interface-edit.mtu.placeholder')" type="number">
</div>
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.interface-edit.firewall-mark.label') }}</label>
<input v-model="formData.FirewallMark" class="form-control" :placeholder="$t('modals.interface-edit.firewall-mark.placeholder')" type="number">
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.interface-edit.routing-table.label') }}</label>
<input v-model="formData.RoutingTable" aria-describedby="routingTableHelp" class="form-control" :placeholder="$t('modals.interface-edit.routing-table.placeholder')" type="text">
<small id="routingTableHelp" class="form-text text-muted">{{ $t('modals.interface-edit.routing-table.description') }}</small>
</div>
<div class="form-group col-md-6">
</div>
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.interface-edit.header-hooks') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.pre-up.label') }}</label>
<textarea v-model="formData.PreUp" class="form-control" rows="2" :placeholder="$t('modals.interface-edit.pre-up.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.post-up.label') }}</label>
<textarea v-model="formData.PostUp" class="form-control" rows="2" :placeholder="$t('modals.interface-edit.post-up.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.pre-down.label') }}</label>
<textarea v-model="formData.PreDown" class="form-control" rows="2" :placeholder="$t('modals.interface-edit.pre-down.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.post-down.label') }}</label>
<textarea v-model="formData.PostDown" class="form-control" rows="2" :placeholder="$t('modals.interface-edit.post-down.placeholder')"></textarea>
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.interface-edit.header-state') }}</legend>
<div class="form-check form-switch">
<input v-model="formData.Disabled" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.disabled.label') }}</label>
</div>
<div class="form-check form-switch">
<input v-model="formData.SaveConfig" checked="" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.save-config.label') }}</label>
</div>
</fieldset>
</div>
<div id="peerdefaults" class="tab-pane fade">
<fieldset>
<legend class="mt-4">{{ $t('modals.interface-edit.header-network') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.defaults.endpoint.label') }}</label>
<input v-model="formData.PeerDefEndpoint" class="form-control" :placeholder="$t('modals.interface-edit.defaults.endpoint.placeholder')" type="text">
<small class="form-text text-muted">{{ $t('modals.interface-edit.defaults.endpoint.description') }}</small>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.defaults.networks.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.PeerDefNetwork"
:placeholder="$t('modals.interface-edit.defaults.networks.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR"
@on-tags-changed="handleChangePeerDefNetwork"/>
<small class="form-text text-muted">{{ $t('modals.interface-edit.defaults.networks.description') }}</small>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.defaults.allowed-ip.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.PeerDefAllowedIPs"
:placeholder="$t('modals.interface-edit.defaults.allowed-ip.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR"
@on-tags-changed="handleChangePeerDefAllowedIPs"/>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.dns.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.PeerDefDns"
:placeholder="$t('modals.interface-edit.dns.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateIP"
@on-tags-changed="handleChangePeerDefDns"/>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.dns-search.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.PeerDefDnsSearch"
:placeholder="$t('modals.interface-edit.dns-search.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateDomain"
@on-tags-changed="handleChangePeerDefDnsSearch"/>
</div>
<div class="row">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.interface-edit.defaults.mtu.label') }}</label>
<input v-model="formData.PeerDefMtu" class="form-control" :placeholder="$t('modals.interface-edit.defaults.mtu.placeholder')" type="number">
</div>
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.interface-edit.firewall-mark.label') }}</label>
<input v-model="formData.PeerDefFirewallMark" class="form-control" :placeholder="$t('modals.interface-edit.firewall-mark.placeholder')" type="number">
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.interface-edit.routing-table.label') }}</label>
<input v-model="formData.PeerDefRoutingTable" class="form-control" :placeholder="$t('modals.interface-edit.routing-table.placeholder')" type="number">
</div>
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.interface-edit.defaults.keep-alive.label') }}</label>
<input v-model="formData.PeerDefPersistentKeepalive" class="form-control" :placeholder="$t('modals.interface-edit.defaults.keep-alive.placeholder')" type="number">
</div>
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.interface-edit.header-peer-hooks') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.pre-up.label') }}</label>
<textarea v-model="formData.PeerDefPreUp" class="form-control" rows="2" :placeholder="$t('modals.interface-edit.pre-up.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.post-up.label') }}</label>
<textarea v-model="formData.PeerDefPostUp" class="form-control" rows="2" :placeholder="$t('modals.interface-edit.post-up.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.pre-down.label') }}</label>
<textarea v-model="formData.PeerDefPreDown" class="form-control" rows="2" :placeholder="$t('modals.interface-edit.pre-down.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.post-down.label') }}</label>
<textarea v-model="formData.PeerDefPostDown" class="form-control" rows="2" :placeholder="$t('modals.interface-edit.post-down.placeholder')"></textarea>
</div>
</fieldset>
<fieldset v-if="props.interfaceId!=='#NEW#'" class="text-end">
<hr class="mt-4">
<button class="btn btn-primary me-1" type="button" @click.prevent="applyPeerDefaults">{{ $t('modals.interface-edit.button-apply-defaults') }}</button>
</fieldset>
</div>
</div>
</template>
<template #footer>
<div class="flex-fill text-start">
<button v-if="props.interfaceId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button>
</div>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>
</template>
<style>
</style>

View File

@ -0,0 +1,60 @@
<script setup>
import Modal from "./Modal.vue";
import {computed, ref, watch} from "vue";
import { useI18n } from 'vue-i18n';
import {interfaceStore} from "@/stores/interfaces";
import Prism from 'vue-prism-component'
import 'prismjs/components/prism-ini'
const { t } = useI18n()
const interfaces = interfaceStore()
const props = defineProps({
interfaceId: String,
visible: Boolean,
})
const configString = ref("")
const emit = defineEmits(['close'])
const selectedInterface = computed(() => {
return interfaces.Find(props.interfaceId)
})
const title = computed(() => {
if (!props.visible) {
return "" // otherwise interfaces.GetSelected will die...
}
return t("modals.interface-view.headline") + " " + selectedInterface.value.Identifier
})
// functions
watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown
console.log(selectedInterface.value)
await interfaces.LoadInterfaceConfig(selectedInterface.value.Identifier)
configString.value = interfaces.configuration
}
}
)
function close() {
emit('close')
}
</script>
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<Prism language="ini" :code="configString"></Prism>
</template>
<template #footer>
<button class="btn btn-primary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>
</template>

View File

@ -0,0 +1,59 @@
<template>
<Teleport to="#modals">
<div v-show="visible" class="modal-backdrop fade show" @click="closeBackdrop">
<div class="modal fade show" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" @click.stop="">
<div class="modal-content" ref="body">
<div class="modal-header">
<h5 class="modal-title">{{ title }}</h5>
<button @click="closeModal" class="btn-close" aria-label="Close"></button>
</div>
<div class="modal-body col-md-12">
<slot></slot>
</div>
<div class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style>
.modal.show {
display:block;
}
.modal.show {
opacity: 1;
}
.modal-backdrop {
background-color: rgba(0,0,0,0.6) !important;
}
.modal-backdrop.show {
opacity: 1 !important;
}
</style>
<script setup>
const props = defineProps({
title: String,
visible: Boolean,
closeOnBackdrop: Boolean,
})
const emit = defineEmits(['close'])
function closeBackdrop() {
if(props.closeOnBackdrop) {
console.log("CLOSING BD")
emit('close')
}
}
function closeModal() {
console.log("CLOSING")
emit('close')
}
</script>

View File

@ -0,0 +1,431 @@
<script setup>
import Modal from "./Modal.vue";
import {peerStore} from "@/stores/peers";
import {interfaceStore} from "@/stores/interfaces";
import {computed, ref, watch} from "vue";
import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification";
import Vue3TagsInput from "vue3-tags-input";
import { validateCIDR, validateIP, validateDomain } from '@/helpers/validators';
import isCidr from "is-cidr";
import {isIP} from 'is-ip';
import { freshPeer, freshInterface } from '@/helpers/models';
const { t } = useI18n()
const peers = peerStore()
const interfaces = interfaceStore()
const props = defineProps({
peerId: String,
visible: Boolean,
})
const emit = defineEmits(['close'])
const selectedPeer = computed(() => {
return peers.Find(props.peerId)
})
const selectedInterface = computed(() => {
let i = interfaces.GetSelected;
if (!i) {
i = freshInterface() // dummy interface to avoid 'undefined' exceptions
}
return i
})
const title = computed(() => {
if (!props.visible) {
return "" // otherwise interfaces.GetSelected will die...
}
if (selectedInterface.value.Mode === "server") {
if (selectedPeer.value) {
return t("modals.peer-edit.headline-edit-peer") + " " + selectedPeer.value.Identifier
}
return t("modals.peer-edit.headline-new-peer")
} else {
if (selectedPeer.value) {
return t("modals.peer-edit.headline-edit-endpoint") + " " + selectedPeer.value.Identifier
}
return t("modals.peer-edit.headline-new-endpoint")
}
})
const formData = ref(freshPeer())
// functions
watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown
console.log(selectedInterface.value)
console.log(selectedPeer.value)
if (!selectedPeer.value) {
await peers.PreparePeer(selectedInterface.value.Identifier)
formData.value.Identifier = peers.Prepared.Identifier
formData.value.DisplayName = peers.Prepared.DisplayName
formData.value.UserIdentifier = peers.Prepared.UserIdentifier
formData.value.InterfaceIdentifier = peers.Prepared.InterfaceIdentifier
formData.value.Disabled = peers.Prepared.Disabled
formData.value.ExpiresAt = peers.Prepared.ExpiresAt
formData.value.Notes = peers.Prepared.Notes
formData.value.Endpoint = peers.Prepared.Endpoint
formData.value.EndpointPublicKey = peers.Prepared.EndpointPublicKey
formData.value.AllowedIPs = peers.Prepared.AllowedIPs
formData.value.ExtraAllowedIPs = peers.Prepared.ExtraAllowedIPs
formData.value.PresharedKey = peers.Prepared.PresharedKey
formData.value.PersistentKeepalive = peers.Prepared.PersistentKeepalive
formData.value.PrivateKey = peers.Prepared.PrivateKey
formData.value.PublicKey = peers.Prepared.PublicKey
formData.value.Mode = peers.Prepared.Mode
formData.value.Addresses = peers.Prepared.Addresses
formData.value.CheckAliveAddress = peers.Prepared.CheckAliveAddress
formData.value.Dns = peers.Prepared.Dns
formData.value.DnsSearch = peers.Prepared.DnsSearch
formData.value.Mtu = peers.Prepared.Mtu
formData.value.FirewallMark = peers.Prepared.FirewallMark
formData.value.RoutingTable = peers.Prepared.RoutingTable
formData.value.PreUp = peers.Prepared.PreUp
formData.value.PostUp = peers.Prepared.PostUp
formData.value.PreDown = peers.Prepared.PreDown
formData.value.PostDown = peers.Prepared.PostDown
} else { // fill existing data
formData.value.Identifier = selectedPeer.value.Identifier
formData.value.DisplayName = selectedPeer.value.DisplayName
formData.value.UserIdentifier = selectedPeer.value.UserIdentifier
formData.value.InterfaceIdentifier = selectedPeer.value.InterfaceIdentifier
formData.value.Disabled = selectedPeer.value.Disabled
formData.value.ExpiresAt = selectedPeer.value.ExpiresAt
formData.value.Notes = selectedPeer.value.Notes
formData.value.Endpoint = selectedPeer.value.Endpoint
formData.value.EndpointPublicKey = selectedPeer.value.EndpointPublicKey
formData.value.AllowedIPs = selectedPeer.value.AllowedIPs
formData.value.ExtraAllowedIPs = selectedPeer.value.ExtraAllowedIPs
formData.value.PresharedKey = selectedPeer.value.PresharedKey
formData.value.PersistentKeepalive = selectedPeer.value.PersistentKeepalive
formData.value.PrivateKey = selectedPeer.value.PrivateKey
formData.value.PublicKey = selectedPeer.value.PublicKey
formData.value.Mode = selectedPeer.value.Mode
formData.value.Addresses = selectedPeer.value.Addresses
formData.value.CheckAliveAddress = selectedPeer.value.CheckAliveAddress
formData.value.Dns = selectedPeer.value.Dns
formData.value.DnsSearch = selectedPeer.value.DnsSearch
formData.value.Mtu = selectedPeer.value.Mtu
formData.value.FirewallMark = selectedPeer.value.FirewallMark
formData.value.RoutingTable = selectedPeer.value.RoutingTable
formData.value.PreUp = selectedPeer.value.PreUp
formData.value.PostUp = selectedPeer.value.PostUp
formData.value.PreDown = selectedPeer.value.PreDown
formData.value.PostDown = selectedPeer.value.PostDown
if (!formData.value.Endpoint.Overridable ||
!formData.value.EndpointPublicKey.Overridable ||
!formData.value.AllowedIPs.Overridable ||
!formData.value.PersistentKeepalive.Overridable ||
!formData.value.Dns.Overridable ||
!formData.value.DnsSearch.Overridable ||
!formData.value.Mtu.Overridable ||
!formData.value.FirewallMark.Overridable ||
!formData.value.RoutingTable.Overridable ||
!formData.value.PreUp.Overridable ||
!formData.value.PostUp.Overridable ||
!formData.value.PreDown.Overridable ||
!formData.value.PostDown.Overridable) {
formData.value.IgnoreGlobalSettings = true
}
}
}
}
)
watch(() => formData.value.IgnoreGlobalSettings, async (newValue, oldValue) => {
formData.value.Endpoint.Overridable = !newValue
formData.value.EndpointPublicKey.Overridable = !newValue
formData.value.AllowedIPs.Overridable = !newValue
formData.value.PersistentKeepalive.Overridable = !newValue
formData.value.Dns.Overridable = !newValue
formData.value.DnsSearch.Overridable = !newValue
formData.value.Mtu.Overridable = !newValue
formData.value.FirewallMark.Overridable = !newValue
formData.value.RoutingTable.Overridable = !newValue
formData.value.PreUp.Overridable = !newValue
formData.value.PostUp.Overridable = !newValue
formData.value.PreDown.Overridable = !newValue
formData.value.PostDown.Overridable = !newValue
}
)
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')
}
function handleChangeAddresses(tags) {
let validInput = true
tags.forEach(tag => {
if(isCidr(tag) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.Addresses = tags
}
}
function handleChangeAllowedIPs(tags) {
let validInput = true
tags.forEach(tag => {
if(isCidr(tag) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.AllowedIPs.Value = tags
}
}
function handleChangeExtraAllowedIPs(tags) {
let validInput = true
tags.forEach(tag => {
if(isCidr(tag) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.ExtraAllowedIPs = tags
}
}
function handleChangeDns(tags) {
let validInput = true
tags.forEach(tag => {
if(!isIP(tag)) {
validInput = false
notify({
title: "Invalid IP",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.Dns = tags
}
}
function handleChangeDnsSearch(tags) {
formData.value.DnsSearch = tags
}
async function save() {
try {
if (props.peerId!=='#NEW#') {
await peers.UpdatePeer(selectedPeer.value.Identifier, formData.value)
} else {
await peers.CreatePeer(selectedInterface.value.Identifier, formData.value)
}
close()
} catch (e) {
console.log(e)
notify({
title: "Failed to save peer!",
text: e.toString(),
type: 'error',
})
}
}
async function del() {
try {
await peers.DeletePeer(selectedPeer.value.Identifier)
close()
} catch (e) {
console.log(e)
notify({
title: "Failed to delete peer!",
text: e.toString(),
type: 'error',
})
}
}
</script>
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<fieldset>
<legend class="mt-4">{{ $t('modals.peer-edit.header-general') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.display-name.label') }}</label>
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.display-name.placeholder')" v-model="formData.DisplayName">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.linked-user.label') }}</label>
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.linked-user.placeholder')" v-model="formData.UserIdentifier">
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.peer-edit.header-crypto') }}</legend>
<div class="form-group" v-if="selectedInterface.Mode==='server'">
<label class="form-label mt-4">{{ $t('modals.peer-edit.private-key.label') }}</label>
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required v-model="formData.PrivateKey">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.public-key.label') }}</label>
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required v-model="formData.PublicKey">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.preshared-key.label') }}</label>
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')" v-model="formData.PresharedKey">
</div>
<div class="form-group" v-if="formData.Mode==='client'">
<label class="form-label mt-4">{{ $t('modals.peer-edit.endpoint-public-key.label') }}</label>
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.endpoint-public-key.placeholder')" v-model="formData.EndpointPublicKey.Value">
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.peer-edit.header-network') }}</legend>
<div class="form-group" v-if="selectedInterface.Mode==='client'">
<label class="form-label mt-4">{{ $t('modals.peer-edit.endpoint.label') }}</label>
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.endpoint.placeholder')" v-model="formData.Endpoint.Value">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.ip.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.Addresses"
:placeholder="$t('modals.peer-edit.ip.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR"
@on-tags-changed="handleChangeAddresses"/>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.allowed-ip.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.AllowedIPs.Value"
:placeholder="$t('modals.peer-edit.allowed-ip.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR"
@on-tags-changed="handleChangeAllowedIPs"/>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.extra-allowed-ip.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.ExtraAllowedIPs"
:placeholder="$t('modals.peer-edit.extra-allowed-ip.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR"
@on-tags-changed="handleChangeExtraAllowedIPs"/>
<small class="form-text text-muted">{{ $t('modals.peer-edit.extra-allowed-ip.description') }}</small>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.dns.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.Dns.Value"
:placeholder="$t('modals.peer-edit.dns.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateIP"
@on-tags-changed="handleChangeDns"/>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.dns-search.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.DnsSearch.Value"
:placeholder="$t('modals.peer-edit.dns-search.label')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateDomain"
@on-tags-changed="handleChangeDnsSearch"/>
</div>
<div class="row">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.peer-edit.keep-alive.label') }}</label>
<input type="number" class="form-control" :placeholder="$t('modals.peer-edit.keep-alive.label')" v-model="formData.PersistentKeepalive.Value">
</div>
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.peer-edit.mtu.label') }}</label>
<input type="number" class="form-control" :placeholder="$t('modals.peer-edit.mtu.label')" v-model="formData.Mtu.Value">
</div>
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.peer-edit.header-hooks') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.pre-up.label') }}</label>
<textarea v-model="formData.PreUp.Value" class="form-control" rows="2" :placeholder="$t('modals.peer-edit.pre-up.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.post-up.label') }}</label>
<textarea v-model="formData.PostUp.Value" class="form-control" rows="2" :placeholder="$t('modals.peer-edit.post-up.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.pre-down.label') }}</label>
<textarea v-model="formData.PreDown.Value" class="form-control" rows="2" :placeholder="$t('modals.peer-edit.pre-down.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.post-down.label') }}</label>
<textarea v-model="formData.PostDown.Value" class="form-control" rows="2" :placeholder="$t('modals.peer-edit.post-down.placeholder')"></textarea>
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.peer-edit.header-state') }}</legend>
<div class="row">
<div class="form-group col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" v-model="formData.Disabled">
<label class="form-check-label" >{{ $t('modals.peer-edit.disabled.label') }}</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" v-model="formData.IgnoreGlobalSettings">
<label class="form-check-label">{{ $t('modals.peer-edit.ignore-global.label') }}</label>
</div>
</div>
<div class="form-group col-md-6">
<label class="form-label">{{ $t('modals.peer-edit.expires-at.label') }}</label>
<input type="date" pattern="\d{4}-\d{2}-\d{2}" class="form-control" min="2023-01-01" v-model="formData.ExpiresAt">
</div>
</div>
</fieldset>
</template>
<template #footer>
<div class="flex-fill text-start">
<button v-if="props.peerId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button>
</div>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>
</template>
<style>
</style>

View File

@ -0,0 +1,110 @@
<script setup>
import Modal from "./Modal.vue";
import {peerStore} from "@/stores/peers";
import {interfaceStore} from "@/stores/interfaces";
import {computed, ref} from "vue";
import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification";
import Vue3TagsInput from "vue3-tags-input";
import { freshInterface } from '@/helpers/models';
const { t } = useI18n()
const peers = peerStore()
const interfaces = interfaceStore()
const props = defineProps({
visible: Boolean,
})
const emit = defineEmits(['close'])
const selectedInterface = computed(() => {
let i = interfaces.GetSelected;
if (!i) {
i = freshInterface() // dummy interface to avoid 'undefined' exceptions
}
return i
})
function freshForm() {
return {
Identifiers: [],
Suffix: "",
}
}
const formData = ref(freshForm())
const title = computed(() => {
if (!props.visible) {
return "" // otherwise interfaces.GetSelected will die...
}
if (selectedInterface.value.Mode === "server") {
return t("modals.peer-multi-create.headline-peer")
} else {
return t("modals.peer-multi-create.headline-endpoint")
}
})
function close() {
formData.value = freshForm()
emit('close')
}
function handleChangeUserIdentifiers(tags) {
formData.value.Identifiers = tags
}
async function save() {
if (formData.value.Identifiers.length === 0) {
notify({
title: "Missing Identifiers",
text: "At least one identifier is required to create a new peer.",
type: 'error',
})
return
}
try {
await peers.CreateMultiplePeers(selectedInterface.value.Identifier, formData.value)
close()
} catch (e) {
console.log(e)
notify({
title: "Failed to create peers!",
text: e.toString(),
type: 'error',
})
}
}
</script>
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<fieldset>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-multi-create.identifiers.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.Identifiers"
:placeholder="$t('modals.peer-multi-create.identifiers.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
@on-tags-changed="handleChangeUserIdentifiers"/>
<small class="form-text text-muted">{{ $t('modals.peer-multi-create.identifiers.description') }}</small>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-multi-create.prefix.label') }}</label>
<input type="text" class="form-control" :placeholder="$t('modals.peer-multi-create.prefix.placeholder')" v-model="formData.Suffix">
<small class="form-text text-muted">{{ $t('modals.peer-multi-create.prefix.description') }}</small>
</div>
</fieldset>
</template>
<template #footer>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>
</template>

View File

@ -0,0 +1,199 @@
<script setup>
import Modal from "./Modal.vue";
import {peerStore} from "@/stores/peers";
import {interfaceStore} from "@/stores/interfaces";
import {computed, ref, watch} from "vue";
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()
const props = defineProps({
peerId: String,
visible: Boolean,
})
const emit = defineEmits(['close'])
function close() {
emit('close')
}
const configString = ref("")
const selectedPeer = computed(() => {
let p = peers.Find(props.peerId)
if (!p) {
p = freshPeer() // dummy peer to avoid 'undefined' exceptions
}
return p
})
const selectedStats = computed(() => {
let s = peers.Statistics(props.peerId)
if (!s) {
s = freshStats() // dummy peer to avoid 'undefined' exceptions
}
return s
})
const selectedInterface = computed(() => {
let i = interfaces.GetSelected;
if (!i) {
i = freshInterface() // dummy interface to avoid 'undefined' exceptions
}
return i
})
const title = computed(() => {
if (!props.visible) {
return "" // otherwise interfaces.GetSelected will die...
}
if (selectedInterface.value.Mode === "server") {
return t("modals.peer-view.headline-peer") + " " + selectedPeer.value.DisplayName
} else {
return t("modals.peer-view.headline-endpoint") + " " + selectedPeer.value.DisplayName
}
})
watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown
await peers.LoadPeerConfig(selectedPeer.value.Identifier)
configString.value = peers.configuration
}
}
)
function download() {
// credit: https://www.bitdegree.org/learn/javascript-download
let filename = 'WireGuard-Tunnel.conf'
if (selectedPeer.value.DisplayName) {
filename = selectedPeer.value.DisplayName
.replace(/ /g,"_")
.replace(/[^a-zA-Z0-9-_]/g,"")
.substring(0, 16)
+ ".conf"
}
let text = configString.value
let element = document.createElement('a')
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text))
element.setAttribute('download', filename)
element.style.display = 'none'
document.body.appendChild(element)
element.click()
document.body.removeChild(element)
}
function email() {
peers.MailPeerConfig(settings.Setting("MailLinkOnly"), [selectedPeer.value.Identifier]).catch(e => {
notify({
title: "Failed to send mail with peer configuration!",
text: e.toString(),
type: 'error',
})
})
}
</script>
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<div class="accordion" id="peerInformation">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseDetails" aria-expanded="true" aria-controls="collapseDetails">
{{ $t('modals.peer-view.section-info') }}
</button>
</h2>
<div id="collapseDetails" class="accordion-collapse collapse show" aria-labelledby="headingDetails" data-bs-parent="#peerInformation" style="">
<div class="accordion-body">
<div class="row">
<div class="col-md-8">
<ul>
<li>{{ $t('modals.peer-view.identifier') }}: {{ selectedPeer.PublicKey }}</li>
<li>{{ $t('modals.peer-view.ip') }}: <span v-for="ip in selectedPeer.Addresses" :key="ip" class="badge rounded-pill bg-light">{{ ip }}</span></li>
<li>{{ $t('modals.peer-view.user') }}: {{ selectedPeer.UserIdentifier }}</li>
<li v-if="selectedPeer.Notes">{{ $t('modals.peer-view.notes') }}: {{ selectedPeer.Notes }}</li>
<li v-if="selectedPeer.ExpiresAt">{{ $t('modals.peer-view.expiry-status') }}: {{ selectedPeer.ExpiresAt }}</li>
<li v-if="selectedPeer.Disabled">{{ $t('modals.peer-view.disabled-status') }}: {{ selectedPeer.DisabledReason }}</li>
</ul>
</div>
<div class="col-md-4">
<img class="config-qr-img" :src="peers.ConfigQrUrl(props.peerId)" loading="lazy" alt="Configuration QR Code">
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingStatus">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseStatus" aria-expanded="false" aria-controls="collapseStatus">
{{ $t('modals.peer-view.section-status') }}
</button>
</h2>
<div id="collapseStatus" class="accordion-collapse collapse" aria-labelledby="headingStatus" data-bs-parent="#peerInformation" style="">
<div class="accordion-body">
<div class="row">
<div class="col-md-12">
<h4>{{ $t('modals.peer-view.traffic') }}</h4>
<p><i class="fas fa-long-arrow-alt-down" :title="$t('modals.peer-view.download')"></i> {{ selectedStats.BytesReceived }} Bytes / <i class="fas fa-long-arrow-alt-up" :title="$t('modals.peer-view.upload')"></i> {{ selectedStats.BytesTransmitted }} Bytes</p>
<h4>{{ $t('modals.peer-view.connection-status') }}</h4>
<ul>
<li>{{ $t('modals.peer-view.pingable') }}: {{ selectedStats.IsPingable }}</li>
<li>{{ $t('modals.peer-view.handshake') }}: {{ selectedStats.LastHandshake }}</li>
<li>{{ $t('modals.peer-view.connected-since') }}: {{ selectedStats.LastSessionStart }}</li>
<li>{{ $t('modals.peer-view.endpoint') }}: {{ selectedStats.EndpointAddress }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div v-if="selectedInterface.Mode==='server'" class="accordion-item">
<h2 class="accordion-header" id="headingConfig">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseConfig" aria-expanded="false" aria-controls="collapseConfig">
{{ $t('modals.peer-view.section-config') }}
</button>
</h2>
<div id="collapseConfig" class="accordion-collapse collapse" aria-labelledby="headingConfig" data-bs-parent="#peerInformation" style="">
<div class="accordion-body">
<Prism language="ini" :code="configString"></Prism>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex-fill text-start">
<button @click.prevent="download" type="button" class="btn btn-primary me-1">{{ $t('modals.peer-view.button-download') }}</button>
<button @click.prevent="email" type="button" class="btn btn-primary me-1">{{ $t('modals.peer-view.button-email') }}</button>
</div>
<button @click.prevent="close" type="button" class="btn btn-secondary">{{ $t('general.close') }}</button>
</template>
</Modal>
</template>
<style>
.config-qr-img {
max-width: 100%;
}
</style>

View File

@ -0,0 +1,174 @@
<script setup>
import Modal from "./Modal.vue";
import {userStore} from "@/stores/users";
import {computed, ref, watch} from "vue";
import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification";
import {freshUser} from "@/helpers/models";
const { t } = useI18n()
const users = userStore()
const props = defineProps({
userId: String,
visible: Boolean,
})
const emit = defineEmits(['close'])
const selectedUser = computed(() => {
return users.Find(props.userId)
})
const title = computed(() => {
if (!props.visible) {
return "" // otherwise interfaces.GetSelected will die...
}
if (selectedUser.value) {
return t("modals.user-edit.headline-edit") + " " + selectedUser.value.Identifier
}
return t("modals.user-edit.headline-new")
})
const formData = ref(freshUser())
// functions
watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown
if (!selectedUser.value) {
formData.value = freshUser()
} else { // fill existing userdata
formData.value.Identifier = selectedUser.value.Identifier
formData.value.Email = selectedUser.value.Email
formData.value.Source = selectedUser.value.Source
formData.value.IsAdmin = selectedUser.value.IsAdmin
formData.value.Firstname = selectedUser.value.Firstname
formData.value.Lastname = selectedUser.value.Lastname
formData.value.Phone = selectedUser.value.Phone
formData.value.Department = selectedUser.value.Department
formData.value.Notes = selectedUser.value.Notes
formData.value.Password = ""
formData.value.Disabled = selectedUser.value.Disabled
}
}
}
)
function close() {
formData.value = freshUser()
emit('close')
}
async function save() {
try {
if (props.userId!=='#NEW#') {
await users.UpdateUser(selectedUser.value.Identifier, formData.value)
} else {
await users.CreateUser(formData.value)
}
close()
} catch (e) {
notify({
title: "Failed to save user!",
text: e.toString(),
type: 'error',
})
}
}
async function del() {
try {
await users.DeleteUser(selectedUser.value.Identifier)
close()
} catch (e) {
notify({
title: "Failed to delete user!",
text: e.toString(),
type: 'error',
})
}
}
</script>
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<fieldset v-if="formData.Source==='db'">
<legend class="mt-4">{{ $t('modals.user-edit.header-general') }}</legend>
<div v-if="props.userId==='#NEW#'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.identifier.label') }}</label>
<input v-model="formData.Identifier" class="form-control" :placeholder="$t('modals.user-edit.identifier.placeholder')" type="text">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.source.label') }}</label>
<input v-model="formData.Source" class="form-control" disabled="disabled" :placeholder="$t('modals.user-edit.source.placeholder')" type="text">
</div>
<div v-if="formData.Source==='db'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.password.label') }}</label>
<input v-model="formData.Password" aria-describedby="passwordHelp" class="form-control" :placeholder="$t('modals.user-edit.password.placeholder')" type="text">
<small v-if="props.userId!=='#NEW#'" id="passwordHelp" class="form-text text-muted">{{ $t('modals.user-edit.password.description') }}</small>
</div>
</fieldset>
<fieldset v-if="formData.Source==='db'">
<legend class="mt-4">{{ $t('modals.user-edit.header-personal') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.email.label') }}</label>
<input v-model="formData.Email" class="form-control" :placeholder="$t('modals.user-edit.email.placeholder')" type="email">
</div>
<div class="row">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.user-edit.firstname.label') }}</label>
<input v-model="formData.Firstname" class="form-control" :placeholder="$t('modals.user-edit.firstname.placeholder')" type="text">
</div>
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.user-edit.lastname.label') }}</label>
<input v-model="formData.Lastname" class="form-control" :placeholder="$t('modals.user-edit.lastname.placeholder')" type="text">
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.user-edit.phone.label') }}</label>
<input v-model="formData.Phone" class="form-control" :placeholder="$t('modals.user-edit.phone.placeholder')" type="text">
</div>
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.user-edit.department.label') }}</label>
<input v-model="formData.Department" class="form-control" :placeholder="$t('modals.user-edit.department.placeholder')" type="text">
</div>
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.user-edit.header-notes') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.notes.label') }}</label>
<textarea v-model="formData.Notes" class="form-control" rows="2"></textarea>
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.user-edit.header-state') }}</legend>
<div class="form-check form-switch">
<input v-model="formData.Disabled" class="form-check-input" type="checkbox">
<label class="form-check-label" >{{ $t('modals.user-edit.disabled.label') }}</label>
</div>
<div class="form-check form-switch">
<input v-model="formData.Locked" class="form-check-input" type="checkbox">
<label class="form-check-label" >{{ $t('modals.user-edit.locked.label') }}</label>
</div>
<div class="form-check form-switch" v-if="formData.Source==='db'">
<input v-model="formData.IsAdmin" checked="" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.user-edit.admin.label') }}</label>
</div>
</fieldset>
</template>
<template #footer>
<div class="flex-fill text-start">
<button v-if="props.userId!=='#NEW#'&&formData.Source==='db'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button>
</div>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>
</template>

View File

@ -0,0 +1,143 @@
<script setup>
import Modal from "./Modal.vue";
import {userStore} from "../stores/users";
import {computed, ref, watch} from "vue";
import { useI18n } from 'vue-i18n';
const { t } = useI18n()
const users = userStore()
const props = defineProps({
userId: String,
visible: Boolean,
})
const emit = defineEmits(['close'])
const selectedUser = computed(() => {
let user = users.Find(props.userId)
if (user) {
return user
}
return {} // return empty object to avoid "undefined" access problems
})
const title = computed(() => {
if (!props.visible) {
return "" // otherwise interfaces.GetSelected will die...
}
return t("modals.user-view.headline") + " " + selectedUser.value.Identifier
})
const userPeers = computed(() => {
return users.Peers
})
// functions
watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown
await users.LoadUserPeers(selectedUser.value.Identifier)
}
}
)
function close() {
emit('close')
}
</script>
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" href="#user">{{ $t('modals.user-view.tab-user') }}</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#peers">{{ $t('modals.user-view.tab-peers') }}</a>
</li>
</ul>
<div id="interfaceTabs" class="tab-content">
<div id="user" class="tab-pane fade active show">
<ul class="list-group list-group-flush">
<li class="list-group-item">
<h4>{{ $t('modals.user-view.headline-info') }}</h4>
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>{{ $t('modals.user-view.email') }}:</td>
<td>{{selectedUser.Email}}</td>
</tr>
<tr>
<td>{{ $t('modals.user-view.firstname') }}:</td>
<td>{{selectedUser.Firstname}}</td>
</tr>
<tr>
<td>{{ $t('modals.user-view.lastname') }}:</td>
<td>{{selectedUser.Lastname}}</td>
</tr>
<tr>
<td>{{ $t('modals.user-view.phone') }}:</td>
<td>{{selectedUser.Phone}}</td>
</tr>
<tr>
<td>{{ $t('modals.user-view.department') }}:</td>
<td>{{selectedUser.Department}}</td>
</tr>
<tr v-if="selectedUser.Disabled">
<td>{{ $t('modals.user-view.disabled') }}:</td>
<td>{{selectedUser.DisabledReason}}</td>
</tr>
<tr v-if="selectedUser.Locked">
<td>{{ $t('modals.user-view.locked') }}:</td>
<td>{{selectedUser.LockedReason}}</td>
</tr>
</tbody>
</table>
</li>
<li class="list-group-item" v-if="selectedUser.Notes">
<h4>{{ $t('modals.user-view.headline-notes') }}</h4>
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr><td>{{selectedUser.Notes}}</td></tr>
</tbody>
</table>
</li>
</ul>
</div>
<div id="peers" class="tab-pane fade">
<ul v-if="userPeers.length===0" class="list-group list-group-flush">
<li class="list-group-item">{{ $t('modals.user-view.no-peers') }}</li>
</ul>
<table v-if="userPeers.length!==0" id="peerTable" class="table table-sm">
<thead>
<tr>
<th scope="col">{{ $t('modals.user-view.peers.name') }}</th>
<th scope="col">{{ $t('modals.user-view.peers.interface') }}</th>
<th scope="col">{{ $t('modals.user-view.peers.ip') }}</th>
<th scope="col"></th><!-- Actions -->
</tr>
</thead>
<tbody>
<tr v-for="peer in userPeers" :key="peer.Identifier">
<td>{{peer.DisplayName}}</td>
<td>{{peer.InterfaceIdentifier}}</td>
<td>
<span v-for="ip in peer.Addresses" :key="ip" class="badge pill bg-light">{{ ip }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<template #footer>
<button class="btn btn-primary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

View File

@ -0,0 +1,7 @@
export function base64_url_encode(input) {
let output = btoa(input)
output = output.replace('+', '.')
output = output.replace('/', '_')
output = output.replace('=', '-')
return output
}

View File

@ -0,0 +1,95 @@
import { authStore } from '@/stores/auth';
import { securityStore } from '@/stores/security';
export const fetchWrapper = {
url: apiUrl(),
get: request('GET'),
post: request('POST'),
put: request('PUT'),
delete: request('DELETE')
};
export const apiWrapper = {
url: apiUrl(),
get: apiRequest('GET'),
post: apiRequest('POST'),
put: apiRequest('PUT'),
delete: apiRequest('DELETE')
};
// request can be used to query arbitrary URLs
function request(method) {
return (url, body = undefined) => {
const requestOptions = {
method,
headers: getHeaders(url)
};
if (body) {
requestOptions.headers['Content-Type'] = 'application/json';
requestOptions.body = JSON.stringify(body);
}
return fetch(url, requestOptions).then(handleResponse);
}
}
// apiRequest uses WGPORTAL_BACKEND_BASE_URL as base URL
function apiRequest(method) {
return (path, body = undefined) => {
const url = WGPORTAL_BACKEND_BASE_URL + path
const requestOptions = {
method,
headers: getHeaders(method, url)
};
if (body) {
requestOptions.headers['Content-Type'] = 'application/json';
requestOptions.body = JSON.stringify(body);
}
return fetch(url, requestOptions).then(handleResponse);
}
}
// apiUrl uses WGPORTAL_BACKEND_BASE_URL as base URL
function apiUrl() {
return (path) => {
return WGPORTAL_BACKEND_BASE_URL + path
}
}
// helper functions
function getHeaders(method, url) {
// return auth header with jwt if user is logged in and request is to the api url
const auth = authStore();
const sec = securityStore();
const isApiUrl = url.startsWith(WGPORTAL_BACKEND_BASE_URL);
let headers = {};
if (isApiUrl && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
headers["X-CSRF-TOKEN"] = sec.CsrfToken;
}
if (isApiUrl && auth.IsAuthenticated) {
headers["X-FRONTEND-UID"] = auth.UserIdentifier;
}
return headers;
}
function handleResponse(response) {
return response.text().then(text => {
const data = text && JSON.parse(text);
if (!response.ok) {
const auth = authStore();
if ([401, 403].includes(response.status) && auth.IsAuthenticated) {
console.log("automatic logout initiated...");
// auto logout if 401 Unauthorized or 403 Forbidden response returned from api
auth.Logout();
}
const error = (data && data.Message) || response.statusText;
return Promise.reject(error);
}
return data;
});
}

View File

@ -0,0 +1,164 @@
export function freshInterface() {
return {
Disabled: false,
DisplayName: "",
Identifier: "",
Mode: "server",
PublicKey: "",
PrivateKey: "",
ListenPort: 51820,
Addresses: [],
DnsStr: [],
DnsSearch: [],
Mtu: 0,
FirewallMark: 0,
RoutingTable: "",
PreUp: "",
PostUp: "",
PreDown: "",
PostDown: "",
SaveConfig: false,
// Peer defaults
PeerDefNetwork: [],
PeerDefDns: [],
PeerDefDnsSearch: [],
PeerDefEndpoint: "",
PeerDefAllowedIPs: [],
PeerDefMtu: 0,
PeerDefPersistentKeepalive: 0,
PeerDefFirewallMark: 0,
PeerDefRoutingTable: "",
PeerDefPreUp: "",
PeerDefPostUp: "",
PeerDefPreDown: "",
PeerDefPostDown: "",
TotalPeers: 0,
EnabledPeers: 0
}
}
export function freshPeer() {
return {
Identifier: "",
DisplayName: "",
UserIdentifier: "",
InterfaceIdentifier: "",
Disabled: false,
ExpiresAt: null,
Notes: "",
Endpoint: {
Value: "",
Overridable: true,
},
EndpointPublicKey: {
Value: "",
Overridable: true,
},
AllowedIPs: {
Value: [],
Overridable: true,
},
ExtraAllowedIPs: [],
PresharedKey: "",
PersistentKeepalive: {
Value: 0,
Overridable: true,
},
PrivateKey: "",
PublicKey: "",
Mode: "client",
Addresses: [],
CheckAliveAddress: "",
Dns: {
Value: [],
Overridable: true,
},
DnsSearch: {
Value: [],
Overridable: true,
},
Mtu: {
Value: 0,
Overridable: true,
},
FirewallMark: {
Value: 0,
Overridable: true,
},
RoutingTable: {
Value: "",
Overridable: true,
},
PreUp: {
Value: "",
Overridable: true,
},
PostUp: {
Value: "",
Overridable: true,
},
PreDown: {
Value: "",
Overridable: true,
},
PostDown: {
Value: "",
Overridable: true,
},
// Internal value
IgnoreGlobalSettings: false
}
}
export function freshUser() {
return {
Identifier: "",
Email: "",
Source: "db",
IsAdmin: false,
Firstname: "",
Lastname: "",
Phone: "",
Department: "",
Notes: "",
Password: "",
Disabled: false,
DisabledReason: "",
Locked: false,
LockedReason: "",
PeerCount: 0
}
}
export function freshStats() {
return {
IsConnected: false,
IsPingable: false,
LastHandshake: null,
LastPing: null,
LastSessionStart: null,
BytesTransmitted: 0,
BytesReceived: 0,
EndpointAddress: ""
}
}

View File

@ -0,0 +1,14 @@
import isCidr from "is-cidr";
import {isIP} from 'is-ip';
export function validateCIDR(value) {
return isCidr(value) !== 0
}
export function validateIP(value) {
return isIP(value)
}
export function validateDomain(value) {
return true
}

View File

@ -0,0 +1,27 @@
// src/lang/index.js
import de from './translations/de.json';
import en from './translations/en.json';
import {createI18n} from "vue-i18n";
function getStoredLanguage() {
let initialLang = localStorage.getItem('wgLang');
if (!initialLang) {
initialLang = "en"
}
return initialLang
}
// Create i18n instance with options
const i18n = createI18n({
legacy: false,
globalInjection: true,
allowComposition: true,
locale: getStoredLanguage(), // set locale
fallbackLocale: "en", // set fallback locale
messages: {
"de": de,
"en": en
}
});
export default i18n

View File

@ -0,0 +1,489 @@
{
"general": {
"pagination": {
"size": "Anzahl an Elementen",
"all": "Alle (langsam)"
},
"search": {
"placeholder": "Suche...",
"button": "Suchen"
},
"select-all": "Alle auswählen",
"yes": "Ja",
"no": "Nein",
"cancel": "Abbrechen",
"close": "Schließen",
"save": "Speichern",
"delete": "Löschen"
},
"login": {
"headline": "Bitte melden Sie sich an",
"username": {
"label": "Benutzername",
"placeholder": "Bitte geben Sie Ihren Benutzernamen ein"
},
"password": {
"label": "Kennwort",
"placeholder": "Bitte geben Sie Ihr Passwort ein"
},
"button": "Anmelden"
},
"menu": {
"home": "Home",
"interfaces": "Schnittstellen",
"users": "Benutzer",
"lang": "Sprache ändern",
"profile": "Mein Profil",
"login": "Anmelden",
"logout": "Abmelden"
},
"home": {
"headline": "WireGuard® VPN Portal",
"info-headline": "Mehr Informationen",
"abstract": "WireGuard® ist ein extrem einfaches, aber dennoch schnelles und modernes VPN, das modernste Kryptographie nutzt. Es zielt darauf ab, schneller, einfacher, schlanker und nützlicher als IPsec zu sein, während es die massiven Kopfschmerzen vermeidet. Es soll wesentlich leistungsfähiger sein als OpenVPN.",
"installation": {
"box-header": "WireGuard Installation",
"headline": "Installation",
"content": "Die Installationsanweisungen für die Client-Software finden Sie auf der offiziellen WireGuard-Website.",
"btn": "Anleitung öffnen"
},
"about-wg": {
"box-header": "Über WireGuard",
"headline": "Über",
"content": "WireGuard® ist ein extrem einfaches, aber schnelles und modernes VPN, das modernste Kryptographie verwendet.",
"button": "Details"
},
"about-portal": {
"box-header": "Über WireGuard Portal",
"headline": "WireGuard Portal",
"content": "WireGuard Portal ist ein einfaches, webbasiertes Konfigurationsportal für WireGuard.",
"button": "Details"
},
"profiles": {
"headline": "VPN Profile",
"abstract": "Über Ihr Benutzerprofil können Sie auf Ihre persönlichen VPN-Konfigurationen zugreifen und diese herunterladen.",
"content": "Um alle Ihre konfigurierten Profile zu finden, klicken Sie auf die Schaltfläche unten.",
"button": "Mein Profil öffnen"
},
"admin": {
"headline": "Verwaltungsbereich",
"abstract": "Im Administrationsbereich können Sie VPN-Zugänge und die Serverschnittstelle sowie die Benutzer, die sich am VPN-Portal anmelden dürfen, verwalten.",
"content": "",
"button-admin": "Schnittstellenverwaltung",
"button-user": "Benutzerverwaltung"
}
},
"interfaces": {
"headline": "Schnittstellenverwaltung",
"headline-peers": "Current VPN Peers",
"headline-endpoints": "Current Endpoints",
"no-interface": {
"default-selection": "No Interface available",
"headline": "No interfaces found...",
"abstract": "Click the plus button above to create a new WireGuard interface."
},
"no-peer": {
"headline": "No peers available",
"abstract": "Currently, there are no peers available for the selected WireGuard interface."
},
"table-heading": {
"name": "Name",
"user": "User",
"ip": "IP's",
"endpoint": "Endpoint",
"status": "Status"
},
"interface": {
"headline": "Interface status for",
"mode": "mode",
"key": "Public Key",
"endpoint": "Public Endpoint",
"port": "Listening Port",
"peers": "Enabled Peers",
"total-peers": "Total Peers",
"endpoints": "Enabled Endpoints",
"total-endpoints": "Total Endpoints",
"ip": "IP Address",
"default-allowed-ip": "Default allowed IPs",
"dns": "DNS Servers",
"mtu": "MTU",
"default-keep-alive": "Default Keepalive Interval",
"button-show-config": "Show configuration",
"button-download-config": "Download configuration",
"button-store-config": "Store configuration for wg-quick",
"button-edit": "Edit interface"
},
"button-add-interface": "Add Interface",
"button-add-peer": "Add Peer",
"button-add-peers": "Add Multiple Peers",
"button-show-peer": "Show Peer",
"button-edit-peer": "Edit Peer",
"peer-disabled": "Peer is disabled, reason:",
"peer-expiring": "Peer is expiring at",
"peer-connected": "Connected",
"peer-not-connected": "Not Connected",
"peer-handshake": "Last handshake:"
},
"users": {
"headline": "Benutzerverwaltung",
"table-heading": {
"id": "ID",
"email": "E-Mail",
"firstname": "Firstname",
"lastname": "Lastname",
"source": "Source",
"peers": "Peers",
"admin": "Admin"
},
"no-user": {
"headline": "No users available",
"abstract": "Currently, there are no users registered with WireGuard Portal."
},
"button-add-user": "Add User",
"button-show-user": "Show User",
"button-edit-user": "Edit User",
"user-disabled": "User is disabled, reason:",
"user-locked": "Account is locked, reason:",
"admin": "User has administrator privileges",
"no-admin": "User has no administrator privileges"
},
"profile": {
"headline": "Meine VPN-Konfigurationen",
"table-heading": {
"name": "Name",
"ip": "IP's",
"stats": "Status",
"interface": "Server Interface"
},
"no-peer": {
"headline": "No peers available",
"abstract": "Currently, there are no peers associated with your user profile."
},
"peer-connected": "Connected",
"button-add-peer": "Add Peer",
"button-show-peer": "Show Peer",
"button-edit-peer": "Edit Peer"
},
"modals": {
"user-view": {
"headline": "User Account:",
"tab-user": "Information",
"tab-peers": "Peers",
"headline-info": "User Information:",
"headline-notes": "Notes:",
"email": "E-Mail",
"firstname": "Firstname",
"lastname": "Lastname",
"phone": "Phone number",
"department": "Department",
"disabled": "Account Disabled",
"locked": "Account Locked",
"no-peers": "User has no associated peers.",
"peers": {
"name": "Name",
"interface": "Interface",
"ip": "IP's"
}
},
"user-edit": {
"headline-edit": "Edit user:",
"headline-new": "New user",
"header-general": "General",
"header-personal": "User Information",
"header-notes": "Notes",
"header-state": "State",
"identifier": {
"label": "Identifier",
"placeholder": "The unique user identifier"
},
"source": {
"label": "Source",
"placeholder": "The user source"
},
"password": {
"label": "Password",
"placeholder": "A super secret password",
"description": "Leave this field blank to keep current password."
},
"email": {
"label": "Email",
"placeholder": "The email address"
},
"phone": {
"label": "Phone",
"placeholder": "The phone number"
},
"department": {
"label": "Department",
"placeholder": "The department"
},
"firstname": {
"label": "Firstname",
"placeholder": "Firstname"
},
"lastname": {
"label": "Lastname",
"placeholder": "Lastname"
},
"notes": {
"label": "Notes",
"placeholder": ""
},
"disabled": {
"label": "Disabled (no WireGuard connection and no login possible)"
},
"locked": {
"label": "Locked (no login possible, WireGuard connections still work)"
},
"admin": {
"label": "Is Admin"
}
},
"interface-view": {
"headline": "Config for Interface:"
},
"interface-edit": {
"headline-edit": "Edit Interface:",
"headline-new": "New Interface",
"tab-interface": "Interface",
"tab-peerdef": "Peer Defaults",
"header-general": "General",
"header-network": "Network",
"header-crypto": "Cryptography",
"header-hooks": "Interface Hooks",
"header-peer-hooks": "Hooks",
"header-state": "State",
"identifier": {
"label": "Identifier",
"placeholder": "The unique interface identifier"
},
"mode": {
"label": "Interface Mode",
"server": "Server Mode",
"client": "Client Mode",
"any": "Unknown Mode"
},
"display-name": {
"label": "Display Name",
"placeholder": "The descriptive name for the interface"
},
"private-key": {
"label": "Private Key",
"placeholder": "The private key"
},
"public-key": {
"label": "Public Key",
"placeholder": "The public key"
},
"ip": {
"label": "IP Addresses",
"placeholder": "IP Addresses (CIDR format)"
},
"listen-port": {
"label": "Listen Port",
"placeholder": "The listening port"
},
"dns": {
"label": "DNS Server",
"placeholder": "The DNS servers that should be used"
},
"dns-search": {
"label": "DNS Search Domains",
"placeholder": "DNS search prefixes"
},
"mtu": {
"label": "MTU",
"placeholder": "The interface MTU (0 = keep default)"
},
"firewall-mark": {
"label": "Firewall Mark",
"placeholder": "Firewall mark that is applied to outgoing traffic. (0 = automatic)"
},
"routing-table": {
"label": "Routing Table",
"placeholder": "The routing table ID",
"description": "Special cases: off = do not manage routes, 0 = automatic"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "One or multiple bash commands separated by ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "One or multiple bash commands separated by ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "One or multiple bash commands separated by ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "One or multiple bash commands separated by ;"
},
"disabled": {
"label": "Interface Disabled"
},
"save-config": {
"label": "Automatically save wg-quick config"
},
"defaults": {
"endpoint": {
"label": "Endpoint Address",
"placeholder": "Endpoint Address",
"description": "The endpoint address that peers will connect to."
},
"networks": {
"label": "IP Networks",
"placeholder": "Network Addresses",
"description": "Peers will get IP addresses from those subnets."
},
"allowed-ip": {
"label": "Allowed IP Addresses",
"placeholder": "Default Allowed IP Addresses"
},
"mtu": {
"label": "MTU",
"placeholder": "The client MTU (0 = keep default)"
},
"keep-alive": {
"label": "Keep Alive Interval",
"placeholder": "Persistent Keepalive (0 = default)"
}
},
"button-apply-defaults": "Apply Peer Defaults"
},
"peer-view": {
"headline-peer": "Peer:",
"headline-endpoint": "Endpoint:",
"section-info": "Peer Information",
"section-status": "Current Status",
"section-config": "Configuration",
"identifier": "Identifier",
"ip": "IP Addresses",
"user": "Associated User",
"notes": "Notes",
"expiry-status": "Expires At",
"disabled-status": "Disabled At",
"traffic": "Traffic",
"connection-status": "Connection Stats",
"upload": "Uploaded Bytes (from Server to Peer)",
"download": "Downloaded Bytes (from Peer to Server)",
"pingable": "Is Pingable",
"handshake": "Last Handshake",
"connected-since": "Connected since",
"endpoint": "Endpoint",
"button-download": "Download configuration",
"button-email": "Send configuration via E-Mail"
},
"peer-edit": {
"headline-edit-peer": "Edit peer:",
"headline-edit-endpoint": "Edit endpoint:",
"headline-new-peer": "Create peer",
"headline-new-endpoint": "Create endpoint",
"header-general": "General",
"header-network": "Network",
"header-crypto": "Cryptography",
"header-hooks": "Hooks (Executed on Peer)",
"header-state": "State",
"display-name": {
"label": "Display Name",
"placeholder": "The descriptive name for the peer"
},
"linked-user": {
"label": "Linked User",
"placeholder": "The user account which owns this peer"
},
"private-key": {
"label": "Private Key",
"placeholder": "The private key"
},
"public-key": {
"label": "Public Key",
"placeholder": "The public key"
},
"preshared-key": {
"label": "Preshared Key",
"placeholder": "Optional pre-shared key"
},
"endpoint-public-key": {
"label": "Endpoint public Key",
"placeholder": "The public key of the remote endpoint"
},
"endpoint": {
"label": "Endpoint Address",
"placeholder": "The address of the remote endpoint"
},
"ip": {
"label": "IP Addresses",
"placeholder": "IP Addresses (CIDR format)"
},
"allowed-ip": {
"label": "Allowed IP Addresses",
"placeholder": "Allowed IP Addresses (CIDR format)"
},
"extra-allowed-ip": {
"label": "Extra allowed IP Addresses",
"placeholder": "Extra allowed IP's (Server Sided)",
"description": "Those IP's will be added on the remote WireGuard interface as allowed IP's."
},
"dns": {
"label": "DNS Server",
"placeholder": "The DNS servers that should be used"
},
"dns-search": {
"label": "DNS Search Domains",
"placeholder": "DNS search prefixes"
},
"keep-alive": {
"label": "Keep Alive Interval",
"placeholder": "Persistent Keepalive (0 = default)"
},
"mtu": {
"label": "MTU",
"placeholder": "The client MTU (0 = keep default)"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "One or multiple bash commands separated by ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "One or multiple bash commands separated by ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "One or multiple bash commands separated by ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "One or multiple bash commands separated by ;"
},
"disabled": {
"label": "Peer Disabled"
},
"ignore-global": {
"label": "Ignore global settings"
},
"expires-at": {
"label": "Expiry date"
}
},
"peer-multi-create": {
"headline-peer": "Create multiple peers",
"headline-endpoint": "Create multiple endpoints",
"identifiers": {
"label": "User Identifiers",
"placeholder": "User Identifiers",
"description": "A user identifier (the username) for which a peer should be created."
},
"prefix": {
"headline-peer": "Peer:",
"headline-endpoint": "Endpoint:",
"label": "Display Name Prefix",
"placeholder": "The prefix",
"description": "A prefix that is added to the peers display name."
}
}
}
}

View File

@ -0,0 +1,489 @@
{
"general": {
"pagination": {
"size": "Number of Elements",
"all": "All (slow)"
},
"search": {
"placeholder": "Search...",
"button": "Search"
},
"select-all": "Select all",
"yes": "Yes",
"no": "No",
"cancel": "Cancel",
"close": "Close",
"save": "Save",
"delete": "Delete"
},
"login": {
"headline": "Please sign in",
"username": {
"label": "Username",
"placeholder": "Please enter your username"
},
"password": {
"label": "Password",
"placeholder": "Please enter your password"
},
"button": "Sign in"
},
"menu": {
"home": "Home",
"interfaces": "Interfaces",
"users": "Users",
"lang": "Toggle Language",
"profile": "My Profile",
"login": "Login",
"logout": "Logout"
},
"home": {
"headline": "WireGuard® VPN Portal",
"info-headline": "More Information",
"abstract": "WireGuard® is an extremely simple yet fast and modern VPN that utilizes state-of-the-art cryptography. It aims to be faster, simpler, leaner, and more useful than IPsec, while avoiding the massive headache. It intends to be considerably more performant than OpenVPN.",
"installation": {
"box-header": "WireGuard Installation",
"headline": "Installation",
"content": "Installation instructions for client software can be found on the official WireGuard website.",
"btn": "Open Instructions"
},
"about-wg": {
"box-header": "About WireGuard",
"headline": "About",
"content": "WireGuard® is an extremely simple yet fast and modern VPN that utilizes state-of-the-art cryptography.",
"button": "More"
},
"about-portal": {
"box-header": "About WireGuard Portal",
"headline": "WireGuard Portal",
"content": "WireGuard Portal is a simple, web based configuration portal for WireGuard.",
"button": "More"
},
"profiles": {
"headline": "VPN Profiles",
"abstract": "You can access and download your personal VPN configurations via your Userprofile.",
"content": "To find all your configured profiles click on the button below.",
"button": "Open my profile"
},
"admin": {
"headline": "Administration Area",
"abstract": "In the administration area you can manage WireGuard peers and the server interface as well as users that are allowed to log in to the WireGuard Portal.",
"content": "",
"button-admin": "Open Server Administration",
"button-user": "Open User Administration"
}
},
"interfaces": {
"headline": "Interface Administration",
"headline-peers": "Current VPN Peers",
"headline-endpoints": "Current Endpoints",
"no-interface": {
"default-selection": "No Interface available",
"headline": "No interfaces found...",
"abstract": "Click the plus button above to create a new WireGuard interface."
},
"no-peer": {
"headline": "No peers available",
"abstract": "Currently, there are no peers available for the selected WireGuard interface."
},
"table-heading": {
"name": "Name",
"user": "User",
"ip": "IP's",
"endpoint": "Endpoint",
"status": "Status"
},
"interface": {
"headline": "Interface status for",
"mode": "mode",
"key": "Public Key",
"endpoint": "Public Endpoint",
"port": "Listening Port",
"peers": "Enabled Peers",
"total-peers": "Total Peers",
"endpoints": "Enabled Endpoints",
"total-endpoints": "Total Endpoints",
"ip": "IP Address",
"default-allowed-ip": "Default allowed IPs",
"dns": "DNS Servers",
"mtu": "MTU",
"default-keep-alive": "Default Keepalive Interval",
"button-show-config": "Show configuration",
"button-download-config": "Download configuration",
"button-store-config": "Store configuration for wg-quick",
"button-edit": "Edit interface"
},
"button-add-interface": "Add Interface",
"button-add-peer": "Add Peer",
"button-add-peers": "Add Multiple Peers",
"button-show-peer": "Show Peer",
"button-edit-peer": "Edit Peer",
"peer-disabled": "Peer is disabled, reason:",
"peer-expiring": "Peer is expiring at",
"peer-connected": "Connected",
"peer-not-connected": "Not Connected",
"peer-handshake": "Last handshake:"
},
"users": {
"headline": "User Administration",
"table-heading": {
"id": "ID",
"email": "E-Mail",
"firstname": "Firstname",
"lastname": "Lastname",
"source": "Source",
"peers": "Peers",
"admin": "Admin"
},
"no-user": {
"headline": "No users available",
"abstract": "Currently, there are no users registered with WireGuard Portal."
},
"button-add-user": "Add User",
"button-show-user": "Show User",
"button-edit-user": "Edit User",
"user-disabled": "User is disabled, reason:",
"user-locked": "Account is locked, reason:",
"admin": "User has administrator privileges",
"no-admin": "User has no administrator privileges"
},
"profile": {
"headline": "My VPN Peers",
"table-heading": {
"name": "Name",
"ip": "IP's",
"stats": "Status",
"interface": "Server Interface"
},
"no-peer": {
"headline": "No peers available",
"abstract": "Currently, there are no peers associated with your user profile."
},
"peer-connected": "Connected",
"button-add-peer": "Add Peer",
"button-show-peer": "Show Peer",
"button-edit-peer": "Edit Peer"
},
"modals": {
"user-view": {
"headline": "User Account:",
"tab-user": "Information",
"tab-peers": "Peers",
"headline-info": "User Information:",
"headline-notes": "Notes:",
"email": "E-Mail",
"firstname": "Firstname",
"lastname": "Lastname",
"phone": "Phone number",
"department": "Department",
"disabled": "Account Disabled",
"locked": "Account Locked",
"no-peers": "User has no associated peers.",
"peers": {
"name": "Name",
"interface": "Interface",
"ip": "IP's"
}
},
"user-edit": {
"headline-edit": "Edit user:",
"headline-new": "New user",
"header-general": "General",
"header-personal": "User Information",
"header-notes": "Notes",
"header-state": "State",
"identifier": {
"label": "Identifier",
"placeholder": "The unique user identifier"
},
"source": {
"label": "Source",
"placeholder": "The user source"
},
"password": {
"label": "Password",
"placeholder": "A super secret password",
"description": "Leave this field blank to keep current password."
},
"email": {
"label": "Email",
"placeholder": "The email address"
},
"phone": {
"label": "Phone",
"placeholder": "The phone number"
},
"department": {
"label": "Department",
"placeholder": "The department"
},
"firstname": {
"label": "Firstname",
"placeholder": "Firstname"
},
"lastname": {
"label": "Lastname",
"placeholder": "Lastname"
},
"notes": {
"label": "Notes",
"placeholder": ""
},
"disabled": {
"label": "Disabled (no WireGuard connection and no login possible)"
},
"locked": {
"label": "Locked (no login possible, WireGuard connections still work)"
},
"admin": {
"label": "Is Admin"
}
},
"interface-view": {
"headline": "Config for Interface:"
},
"interface-edit": {
"headline-edit": "Edit Interface:",
"headline-new": "New Interface",
"tab-interface": "Interface",
"tab-peerdef": "Peer Defaults",
"header-general": "General",
"header-network": "Network",
"header-crypto": "Cryptography",
"header-hooks": "Interface Hooks",
"header-peer-hooks": "Hooks",
"header-state": "State",
"identifier": {
"label": "Identifier",
"placeholder": "The unique interface identifier"
},
"mode": {
"label": "Interface Mode",
"server": "Server Mode",
"client": "Client Mode",
"any": "Unknown Mode"
},
"display-name": {
"label": "Display Name",
"placeholder": "The descriptive name for the interface"
},
"private-key": {
"label": "Private Key",
"placeholder": "The private key"
},
"public-key": {
"label": "Public Key",
"placeholder": "The public key"
},
"ip": {
"label": "IP Addresses",
"placeholder": "IP Addresses (CIDR format)"
},
"listen-port": {
"label": "Listen Port",
"placeholder": "The listening port"
},
"dns": {
"label": "DNS Server",
"placeholder": "The DNS servers that should be used"
},
"dns-search": {
"label": "DNS Search Domains",
"placeholder": "DNS search prefixes"
},
"mtu": {
"label": "MTU",
"placeholder": "The interface MTU (0 = keep default)"
},
"firewall-mark": {
"label": "Firewall Mark",
"placeholder": "Firewall mark that is applied to outgoing traffic. (0 = automatic)"
},
"routing-table": {
"label": "Routing Table",
"placeholder": "The routing table ID",
"description": "Special cases: off = do not manage routes, 0 = automatic"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "One or multiple bash commands separated by ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "One or multiple bash commands separated by ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "One or multiple bash commands separated by ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "One or multiple bash commands separated by ;"
},
"disabled": {
"label": "Interface Disabled"
},
"save-config": {
"label": "Automatically save wg-quick config"
},
"defaults": {
"endpoint": {
"label": "Endpoint Address",
"placeholder": "Endpoint Address",
"description": "The endpoint address that peers will connect to."
},
"networks": {
"label": "IP Networks",
"placeholder": "Network Addresses",
"description": "Peers will get IP addresses from those subnets."
},
"allowed-ip": {
"label": "Allowed IP Addresses",
"placeholder": "Default Allowed IP Addresses"
},
"mtu": {
"label": "MTU",
"placeholder": "The client MTU (0 = keep default)"
},
"keep-alive": {
"label": "Keep Alive Interval",
"placeholder": "Persistent Keepalive (0 = default)"
}
},
"button-apply-defaults": "Apply Peer Defaults"
},
"peer-view": {
"headline-peer": "Peer:",
"headline-endpoint": "Endpoint:",
"section-info": "Peer Information",
"section-status": "Current Status",
"section-config": "Configuration",
"identifier": "Identifier",
"ip": "IP Addresses",
"user": "Associated User",
"notes": "Notes",
"expiry-status": "Expires At",
"disabled-status": "Disabled At",
"traffic": "Traffic",
"connection-status": "Connection Stats",
"upload": "Uploaded Bytes (from Server to Peer)",
"download": "Downloaded Bytes (from Peer to Server)",
"pingable": "Is Pingable",
"handshake": "Last Handshake",
"connected-since": "Connected since",
"endpoint": "Endpoint",
"button-download": "Download configuration",
"button-email": "Send configuration via E-Mail"
},
"peer-edit": {
"headline-edit-peer": "Edit peer:",
"headline-edit-endpoint": "Edit endpoint:",
"headline-new-peer": "Create peer",
"headline-new-endpoint": "Create endpoint",
"header-general": "General",
"header-network": "Network",
"header-crypto": "Cryptography",
"header-hooks": "Hooks (Executed on Peer)",
"header-state": "State",
"display-name": {
"label": "Display Name",
"placeholder": "The descriptive name for the peer"
},
"linked-user": {
"label": "Linked User",
"placeholder": "The user account which owns this peer"
},
"private-key": {
"label": "Private Key",
"placeholder": "The private key"
},
"public-key": {
"label": "Public Key",
"placeholder": "The public key"
},
"preshared-key": {
"label": "Preshared Key",
"placeholder": "Optional pre-shared key"
},
"endpoint-public-key": {
"label": "Endpoint public Key",
"placeholder": "The public key of the remote endpoint"
},
"endpoint": {
"label": "Endpoint Address",
"placeholder": "The address of the remote endpoint"
},
"ip": {
"label": "IP Addresses",
"placeholder": "IP Addresses (CIDR format)"
},
"allowed-ip": {
"label": "Allowed IP Addresses",
"placeholder": "Allowed IP Addresses (CIDR format)"
},
"extra-allowed-ip": {
"label": "Extra allowed IP Addresses",
"placeholder": "Extra allowed IP's (Server Sided)",
"description": "Those IP's will be added on the remote WireGuard interface as allowed IP's."
},
"dns": {
"label": "DNS Server",
"placeholder": "The DNS servers that should be used"
},
"dns-search": {
"label": "DNS Search Domains",
"placeholder": "DNS search prefixes"
},
"keep-alive": {
"label": "Keep Alive Interval",
"placeholder": "Persistent Keepalive (0 = default)"
},
"mtu": {
"label": "MTU",
"placeholder": "The client MTU (0 = keep default)"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "One or multiple bash commands separated by ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "One or multiple bash commands separated by ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "One or multiple bash commands separated by ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "One or multiple bash commands separated by ;"
},
"disabled": {
"label": "Peer Disabled"
},
"ignore-global": {
"label": "Ignore global settings"
},
"expires-at": {
"label": "Expiry date"
}
},
"peer-multi-create": {
"headline-peer": "Create multiple peers",
"headline-endpoint": "Create multiple endpoints",
"identifiers": {
"label": "User Identifiers",
"placeholder": "User Identifiers",
"description": "A user identifier (the username) for which a peer should be created."
},
"prefix": {
"headline-peer": "Peer:",
"headline-endpoint": "Endpoint:",
"label": "Display Name Prefix",
"placeholder": "The prefix",
"description": "A prefix that is added to the peers display name."
}
}
}
}

45
frontend/src/main.js Normal file
View File

@ -0,0 +1,45 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import i18n from "./lang";
import Notifications from '@kyvg/vue3-notification'
// Bootstrap (and theme)
//import "bootstrap/dist/css/bootstrap.min.css"
import "bootswatch/dist/lux/bootstrap.min.css";
import "bootstrap";
import "./assets/base.css";
// Fontawesome
import "@fortawesome/fontawesome-free/js/all.js"
// Flags
import "flag-icons/css/flag-icons.min.css"
// Syntax Highlighting
import 'prismjs'
import 'prismjs/themes/prism-okaidia.css'
const app = createApp(App);
app.use(i18n)
app.use(createPinia());
app.use(router);
app.use(Notifications);
app.config.globalProperties.$filters = {
truncate(value, maxLength, suffix) {
suffix = suffix || '...'
if (value.length > maxLength) {
return value.substring(0, maxLength) + suffix;
} else {
return value;
}
}
}
app.mount("#app");

View File

@ -0,0 +1,109 @@
import {createRouter, createWebHashHistory} from 'vue-router'
import HomeView from '../views/HomeView.vue'
import LoginView from '../views/LoginView.vue'
import InterfaceView from '../views/InterfaceView.vue'
import {authStore} from '@/stores/auth'
import {notify} from "@kyvg/vue3-notification";
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/login',
name: 'login',
component: LoginView
},
{
path: '/interface',
name: 'interface',
component: InterfaceView
},
{
path: '/interfaces',
name: 'interfaces',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/InterfaceView.vue')
},
{
path: '/users',
name: 'users',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/UserView.vue')
},
{
path: '/profile',
name: 'profile',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/ProfileView.vue')
}
],
linkActiveClass: "active",
linkExactActiveClass: "exact-active",
})
router.beforeEach(async (to) => {
const auth = authStore()
// check if the request was a successful oauth login
if ('wgLoginState' in to.query && !auth.IsAuthenticated) {
const state = to.query['wgLoginState']
const returnUrl = auth.ReturnUrl
console.log("Oauth login callback:", state)
if (state === "success") {
try {
const uid = await auth.LoadSession()
console.log("Oauth login completed for UID:", uid)
console.log("Continuing to:", returnUrl)
notify({
title: "Logged in",
text: "Authentication suceeded!",
type: 'success',
})
auth.ResetReturnUrl()
return returnUrl
} catch (e) {
notify({
title: "Login failed!",
text: "Oauth session is invalid!",
type: 'error',
})
return '/login'
}
} else {
notify({
title: "Login failed!",
text: "Authentication via Oauth failed!",
type: 'error',
})
return '/login'
}
}
// redirect to login page if not logged in and trying to access a restricted page
const publicPages = ['/', '/login']
const authRequired = !publicPages.includes(to.path)
if (authRequired && !auth.IsAuthenticated) {
auth.SetReturnUrl(to.fullPath) // store original destination before starting the auth process
return '/login'
}
})
export default router

125
frontend/src/stores/auth.js Normal file
View File

@ -0,0 +1,125 @@
import { defineStore } from 'pinia'
import { notify } from "@kyvg/vue3-notification";
import { apiWrapper } from '@/helpers/fetch-wrapper'
import router from '../router'
export const authStore = defineStore({
id: 'auth',
state: () => ({
// initialize state from local storage to enable user to stay logged in
user: JSON.parse(localStorage.getItem('user')),
providers: [],
returnUrl: localStorage.getItem('returnUrl')
}),
getters: {
UserIdentifier: (state) => state.user?.Identifier || 'unknown',
User: (state) => state.user,
LoginProviders: (state) => state.providers,
IsAuthenticated: (state) => state.user != null,
IsAdmin: (state) => state.user?.IsAdmin || false,
ReturnUrl: (state) => state.returnUrl || '/',
},
actions: {
SetReturnUrl(link) {
this.returnUrl = link
localStorage.setItem('returnUrl', link)
},
ResetReturnUrl() {
this.returnUrl = null
localStorage.removeItem('returnUrl')
},
// LoadProviders always returns a fulfilled promise, even if the request failed.
async LoadProviders() {
apiWrapper.get(`/auth/providers`)
.then(providers => this.providers = providers)
.catch(error => {
this.providers = []
console.log("Failed to load auth providers: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load external authentication providers!",
})
})
},
// LoadSession returns promise that might have been rejected if the session was not authenticated.
async LoadSession() {
return apiWrapper.get(`/auth/session`)
.then(session => {
if (session.LoggedIn === true) {
this.ResetReturnUrl()
this.setUserInfo(session)
return session.UserIdentifier
} else {
this.setUserInfo(null)
return Promise.reject(new Error('session not authenticated'))
}
})
.catch(err => {
this.setUserInfo(null)
return Promise.reject(err)
})
},
// Login returns promise that might have been rejected if the login attempt was not successful.
async Login(username, password) {
return apiWrapper.post(`/auth/login`, { username, password })
.then(user => {
this.ResetReturnUrl()
this.setUserInfo(user)
return user.Identifier
})
.catch(err => {
console.log("Login failed:", err)
this.setUserInfo(null)
return Promise.reject(new Error("login failed"))
})
},
async Logout() {
this.setUserInfo(null)
this.ResetReturnUrl() // just to be sure^^
try {
await apiWrapper.post(`/auth/logout`)
} catch (e) {
console.log("Logout request failed:", e)
}
notify({
title: "Logged Out",
text: "Logout successful!",
type: "warn",
})
await router.push('/login')
},
// -- internal setters
setUserInfo(userInfo) {
// store user details and jwt in local storage to keep user logged in between page refreshes
if (userInfo) {
if ('UserIdentifier' in userInfo) { // session object
this.user = {
Identifier: userInfo['UserIdentifier'],
Firstname: userInfo['UserFirstname'],
Lastname: userInfo['UserLastname'],
Email: userInfo['UserEmail'],
IsAdmin: userInfo['IsAdmin']
}
} else { // user object
this.user = {
Identifier: userInfo['Identifier'],
Firstname: userInfo['Firstname'],
Lastname: userInfo['Lastname'],
Email: userInfo['Email'],
IsAdmin: userInfo['IsAdmin']
}
}
localStorage.setItem('user', JSON.stringify(this.user))
} else {
this.user = null
localStorage.removeItem('user')
}
},
}
});

View File

@ -0,0 +1,152 @@
import { defineStore } from 'pinia'
import {apiWrapper} from '@/helpers/fetch-wrapper'
import {notify} from "@kyvg/vue3-notification";
import { freshInterface } from '@/helpers/models';
import { base64_url_encode } from '@/helpers/encoding';
const baseUrl = `/interface`
export const interfaceStore = defineStore({
id: 'interfaces',
state: () => ({
interfaces: [],
prepared: freshInterface(),
configuration: "",
selected: "",
fetching: false,
}),
getters: {
Count: (state) => state.interfaces.length,
Prepared: (state) => {console.log("STATE:", state.prepared); return state.prepared},
All: (state) => state.interfaces,
Find: (state) => {
return (id) => state.interfaces.find((p) => p.Identifier === id)
},
GetSelected: (state) => state.interfaces.find((i) => i.Identifier === state.selected) || state.interfaces[0],
isFetching: (state) => state.fetching,
},
actions: {
setInterfaces(interfaces) {
this.interfaces = interfaces
if (this.interfaces.length > 0) {
this.selected = this.interfaces[0].Identifier
} else {
this.selected = ""
}
this.fetching = false
},
async LoadInterfaces() {
this.fetching = true
return apiWrapper.get(`${baseUrl}/all`)
.then(this.setInterfaces)
.catch(error => {
this.setInterfaces([])
console.log("Failed to load interfaces: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load interfaces!",
})
})
},
setPreparedInterface(iface) {
this.prepared = iface;
},
setInterfaceConfig(ifaceConfig) {
this.configuration = ifaceConfig;
},
async PrepareInterface() {
return apiWrapper.get(`${baseUrl}/prepare`)
.then(this.setPreparedInterface)
.catch(error => {
this.prepared = freshInterface()
console.log("Failed to load prepared interface: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load prepared interface!",
})
})
},
async LoadInterfaceConfig(id) {
return apiWrapper.get(`${baseUrl}/config/${base64_url_encode(id)}`)
.then(this.setInterfaceConfig)
.catch(error => {
this.configuration = ""
console.log("Failed to load interface configuration: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load interface configuration!",
})
})
},
async DeleteInterface(id) {
this.fetching = true
return apiWrapper.delete(`${baseUrl}/${base64_url_encode(id)}`)
.then(() => {
this.interfaces = this.interfaces.filter(i => i.Identifier !== id)
if (this.interfaces.length > 0) {
this.selected = this.interfaces[0].Identifier
} else {
this.selected = ""
}
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async UpdateInterface(id, formData) {
this.fetching = true
return apiWrapper.put(`${baseUrl}/${base64_url_encode(id)}`, formData)
.then(iface => {
let idx = this.interfaces.findIndex((i) => i.Identifier === id)
this.interfaces[idx] = iface
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async CreateInterface(formData) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/new`, formData)
.then(iface => {
this.interfaces.push(iface)
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async ApplyPeerDefaults(id, formData) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/${base64_url_encode(id)}/apply-peer-defaults`, formData)
.then(() => {
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async SaveConfiguration(id) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/${base64_url_encode(id)}/save-config`)
.then(() => {
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
}
}
})

View File

@ -0,0 +1,258 @@
import { defineStore } from 'pinia'
import {apiWrapper} from "@/helpers/fetch-wrapper";
import {notify} from "@kyvg/vue3-notification";
import {interfaceStore} from "./interfaces";
import {freshPeer, freshStats} from '@/helpers/models';
import { base64_url_encode } from '@/helpers/encoding';
const baseUrl = `/peer`
export const peerStore = defineStore({
id: 'peers',
state: () => ({
peers: [],
stats: {},
statsEnabled: false,
peer: freshPeer(),
prepared: freshPeer(),
configuration: "",
filter: "",
pageSize: 10,
pageOffset: 0,
pages: [],
fetching: false,
}),
getters: {
Find: (state) => {
return (id) => state.peers.find((p) => p.Identifier === id)
},
Count: (state) => state.peers.length,
Prepared: (state) => {console.log("STATE:", state.prepared); return state.prepared},
FilteredCount: (state) => state.Filtered.length,
All: (state) => state.peers,
Filtered: (state) => {
if (!state.filter) {
return state.peers
}
return state.peers.filter((p) => {
return p.DisplayName.includes(state.filter) || p.Identifier.includes(state.filter)
})
},
FilteredAndPaged: (state) => {
return state.Filtered.slice(state.pageOffset, state.pageOffset + state.pageSize)
},
ConfigQrUrl: (state) => {
return (id) => state.peers.find((p) => p.Identifier === id) ? apiWrapper.url(`${baseUrl}/config-qr/${base64_url_encode(id)}`) : ''
},
isFetching: (state) => state.fetching,
hasNextPage: (state) => state.pageOffset < (state.FilteredCount - state.pageSize),
hasPrevPage: (state) => state.pageOffset > 0,
currentPage: (state) => (state.pageOffset / state.pageSize)+1,
Statistics: (state) => {
return (id) => state.statsEnabled && (id in state.stats) ? state.stats[id] : freshStats()
},
hasStatistics: (state) => state.statsEnabled,
},
actions: {
afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
},
gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
},
nextPage() {
this.pageOffset += this.pageSize
this.calculatePages()
},
previousPage() {
this.pageOffset -= this.pageSize
this.calculatePages()
},
setPeers(peers) {
this.peers = peers
this.calculatePages()
this.fetching = false
},
setPeer(peer) {
this.peer = peer
this.fetching = false
},
setPreparedPeer(peer) {
this.prepared = peer;
},
setPeerConfig(config) {
this.configuration = config;
},
setStats(statsResponse) {
if (!statsResponse) {
this.stats = {}
this.statsEnabled = false
}
this.stats = statsResponse.Stats
this.statsEnabled = statsResponse.Enabled
},
async PreparePeer(interfaceId) {
return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/prepare`)
.then(this.setPreparedPeer)
.catch(error => {
this.prepared = freshPeer()
console.log("Failed to load prepared peer: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load prepared peer!",
})
})
},
async MailPeerConfig(linkOnly, ids) {
return apiWrapper.post(`${baseUrl}/config-mail`, {
Identifiers: ids,
LinkOnly: linkOnly
})
.then(() => {
notify({
title: "Peer Configuration sent",
text: "Email sent to linked user!",
})
})
.catch(error => {
console.log("Failed to send peer configuration: ", error)
throw new Error(error)
})
},
async LoadPeerConfig(id) {
return apiWrapper.get(`${baseUrl}/config/${base64_url_encode(id)}`)
.then(this.setPeerConfig)
.catch(error => {
this.configuration = ""
console.log("Failed to load peer configuration: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load peer configuration!",
})
})
},
async LoadPeer(id) {
this.fetching = true
return apiWrapper.get(`${baseUrl}/${base64_url_encode(id)}`)
.then(this.setPeer)
.catch(error => {
this.setPeers([])
console.log("Failed to load peer: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load peer!",
})
})
},
async LoadStats(interfaceId) {
// if no interfaceId is given, use the currently selected interface
if (!interfaceId) {
interfaceId = interfaceStore().GetSelected.Identifier
if (!interfaceId) {
return // no interface, nothing to load
}
}
this.fetching = true
return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/stats`)
.then(this.setStats)
.catch(error => {
this.setStats(undefined)
console.log("Failed to load peer stats: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load peer stats!",
})
})
},
async DeletePeer(id) {
this.fetching = true
return apiWrapper.delete(`${baseUrl}/${base64_url_encode(id)}`)
.then(() => {
this.peers = this.peers.filter(p => p.Identifier !== id)
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async UpdatePeer(id, formData) {
this.fetching = true
return apiWrapper.put(`${baseUrl}/${base64_url_encode(id)}`, formData)
.then(peer => {
let idx = this.peers.findIndex((p) => p.Identifier === id)
this.peers[idx] = peer
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async CreatePeer(interfaceId, formData) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/new`, formData)
.then(peer => {
this.peers.push(peer)
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async CreateMultiplePeers(interfaceId, formData) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/multiplenew`, formData)
.then(peers => {
this.peers.push(...peers)
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async LoadPeers(interfaceId) {
// if no interfaceId is given, use the currently selected interface
if (!interfaceId) {
interfaceId = interfaceStore().GetSelected.Identifier
if (!interfaceId) {
return // no interface, nothing to load
}
}
this.fetching = true
return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/all`)
.then(this.setPeers)
.catch(error => {
this.setPeers([])
console.log("Failed to load peers: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load peers!",
})
})
}
}
})

View File

@ -0,0 +1,137 @@
import { defineStore } from 'pinia'
import {apiWrapper} from "@/helpers/fetch-wrapper";
import {notify} from "@kyvg/vue3-notification";
import {authStore} from "@/stores/auth";
import { base64_url_encode } from '@/helpers/encoding';
import {freshStats} from "@/helpers/models";
const baseUrl = `/user`
export const profileStore = defineStore({
id: 'profile',
state: () => ({
peers: [],
stats: {},
statsEnabled: false,
user: {},
filter: "",
pageSize: 10,
pageOffset: 0,
pages: [],
fetching: false,
}),
getters: {
FindPeers: (state) => {
return (id) => state.peers.find((p) => p.Identifier === id)
},
CountPeers: (state) => state.peers.length,
FilteredPeerCount: (state) => state.FilteredPeers.length,
Peers: (state) => state.peers,
FilteredPeers: (state) => {
if (!state.filter) {
return state.peers
}
return state.peers.filter((p) => {
return p.DisplayName.includes(state.filter) || p.Identifier.includes(state.filter)
})
},
FilteredAndPagedPeers: (state) => {
return state.FilteredPeers.slice(state.pageOffset, state.pageOffset + state.pageSize)
},
isFetching: (state) => state.fetching,
hasNextPage: (state) => state.pageOffset < (state.FilteredPeerCount - state.pageSize),
hasPrevPage: (state) => state.pageOffset > 0,
currentPage: (state) => (state.pageOffset / state.pageSize)+1,
Statistics: (state) => {
return (id) => state.statsEnabled && (id in state.stats) ? state.stats[id] : freshStats()
},
hasStatistics: (state) => state.statsEnabled,
},
actions: {
afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredPeerCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
},
gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
},
nextPage() {
this.pageOffset += this.pageSize
this.calculatePages()
},
previousPage() {
this.pageOffset -= this.pageSize
this.calculatePages()
},
setPeers(peers) {
this.peers = peers
this.fetching = false
},
setUser(user) {
this.user = user
this.fetching = false
},
setStats(statsResponse) {
if (!statsResponse) {
this.stats = {}
this.statsEnabled = false
}
this.stats = statsResponse.Stats
this.statsEnabled = statsResponse.Enabled
},
async LoadPeers() {
this.fetching = true
let currentUser = authStore().user.Identifier
return apiWrapper.get(`${baseUrl}/${base64_url_encode(currentUser)}/peers`)
.then(this.setPeers)
.catch(error => {
this.setPeers([])
console.log("Failed to load user peers for ", currentUser, ": ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load user peers!",
})
})
},
async LoadStats() {
this.fetching = true
let currentUser = authStore().user.Identifier
return apiWrapper.get(`${baseUrl}/${base64_url_encode(currentUser)}/stats`)
.then(this.setStats)
.catch(error => {
this.setStats(undefined)
console.log("Failed to load peer stats: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load peer stats!",
})
})
},
async LoadUser() {
this.fetching = true
let currentUser = authStore().user.Identifier
return apiWrapper.get(`${baseUrl}/${base64_url_encode(currentUser)}`)
.then(this.setUser)
.catch(error => {
this.setUser({})
console.log("Failed to load user for ", currentUser, ": ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load user!",
})
})
},
}
})

View File

@ -0,0 +1,32 @@
import { defineStore } from 'pinia'
import { notify } from "@kyvg/vue3-notification";
import { apiWrapper } from '@/helpers/fetch-wrapper'
export const securityStore = defineStore({
id: 'security',
state: () => ({
csrfToken: "",
}),
getters: {
CsrfToken: (state) => state.csrfToken,
},
actions: {
SetCsrfToken(token) {
this.csrfToken = token
},
// LoadSecurityProperties always returns a fulfilled promise, even if the request failed.
async LoadSecurityProperties() {
await apiWrapper.get(`/csrf`)
.then(token => this.SetCsrfToken(token))
.catch(error => {
this.SetCsrfToken("");
console.log("Failed to load csrf token: ", error);
notify({
title: "Backend Connection Failure",
text: "Failed to load csrf token!",
});
})
}
}
});

View File

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

View File

@ -0,0 +1,147 @@
import { defineStore } from 'pinia'
import {apiWrapper} from "@/helpers/fetch-wrapper";
import {notify} from "@kyvg/vue3-notification";
import { base64_url_encode } from '@/helpers/encoding';
const baseUrl = `/user`
export const userStore = defineStore({
id: 'users',
state: () => ({
userPeers: [],
users: [],
filter: "",
pageSize: 10,
pageOffset: 0,
pages: [],
fetching: false,
}),
getters: {
Find: (state) => {
return (id) => state.users.find((p) => p.Identifier === id)
},
Count: (state) => state.users.length,
FilteredCount: (state) => state.Filtered.length,
All: (state) => state.users,
Peers: (state) => state.userPeers,
Filtered: (state) => {
if (!state.filter) {
return state.users
}
return state.users.filter((u) => {
return u.Firstname.includes(state.filter) || u.Lastname.includes(state.filter) || u.Email.includes(state.filter) || u.Identifier.includes(state.filter)
})
},
FilteredAndPaged: (state) => {
return state.Filtered.slice(state.pageOffset, state.pageOffset + state.pageSize)
},
isFetching: (state) => state.fetching,
hasNextPage: (state) => state.pageOffset < (state.FilteredCount - state.pageSize),
hasPrevPage: (state) => state.pageOffset > 0,
currentPage: (state) => (state.pageOffset / state.pageSize)+1,
},
actions: {
afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
},
gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
},
nextPage() {
this.pageOffset += this.pageSize
this.calculatePages()
},
previousPage() {
this.pageOffset -= this.pageSize
this.calculatePages()
},
setUsers(users) {
this.users = users
this.calculatePages()
this.fetching = false
},
setUserPeers(peers) {
this.userPeers = peers
this.fetching = false
},
async LoadUsers() {
this.fetching = true
return apiWrapper.get(`${baseUrl}/all`)
.then(this.setUsers)
.catch(error => {
this.setUsers([])
console.log("Failed to load users: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load users!",
})
})
},
async DeleteUser(id) {
this.fetching = true
return apiWrapper.delete(`${baseUrl}/${base64_url_encode(id)}`)
.then(() => {
this.users = this.users.filter(u => u.Identifier !== id)
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async UpdateUser(id, formData) {
this.fetching = true
return apiWrapper.put(`${baseUrl}/${base64_url_encode(id)}`, formData)
.then(user => {
let idx = this.users.findIndex((u) => u.Identifier === id)
this.users[idx] = user
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async CreateUser(formData) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/new`, formData)
.then(user => {
this.users.push(user)
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async LoadUserPeers(id) {
this.fetching = true
return apiWrapper.get(`${baseUrl}/${base64_url_encode(id)}/peers`)
.then(this.setUserPeers)
.catch(error => {
this.setUserPeers([])
console.log("Failed to load user peers for ",id ,": ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load user peers!",
})
})
},
}
})

View File

@ -0,0 +1,73 @@
<script setup>
import {authStore} from "@/stores/auth";
import {RouterLink} from "vue-router";
const auth = authStore()
</script>
<template>
<div class="page-header">
<h1>{{ $t('home.headline') }}</h1>
</div>
<p class="lead">{{ $t('home.abstract') }}</p>
<div class="bg-light p-5" v-if="auth.IsAuthenticated">
<h2 class="display-5">{{ $t('home.profiles.headline') }}</h2>
<p class="lead">{{ $t('home.profiles.abstract') }}</p>
<hr class="my-4">
<p>{{ $t('home.profiles.content') }}</p>
<p class="lead">
<RouterLink :to="{ name: 'profile' }" class="btn btn-primary btn-lg">{{ $t('home.profiles.button') }}</RouterLink>
</p>
</div>
<div class="bg-light p-5 mt-4" v-if="auth.IsAuthenticated && auth.IsAdmin">
<h2 class="display-5">{{ $t('home.admin.headline') }}</h2>
<p class="lead">{{ $t('home.admin.abstract') }}</p>
<hr class="my-4">
<p>{{ $t('home.admin.content') }}</p>
<p class="lead">
<RouterLink :to="{ name: 'interfaces' }" class="btn btn-primary btn-lg me-2">{{ $t('home.admin.button-admin') }}</RouterLink>
<RouterLink :to="{ name: 'users' }" class="btn btn-primary btn-lg">{{ $t('home.admin.button-user') }}</RouterLink>
</p>
</div>
<h3 class="mt-5">{{ $t('home.info-headline') }}</h3>
<div class="row">
<div class="col-lg-4">
<div class="card border-secondary mb-4" style="min-height: 15rem;">
<div class="card-header">{{ $t('home.installation.box-header') }}</div>
<div class="card-body d-flex flex-column">
<h4 class="card-title">{{ $t('home.installation.headline') }}</h4>
<p class="card-text">{{ $t('home.installation.content') }}</p>
<a href="https://www.wireguard.com/install/" title="WireGuard Installation" target="_blank"
rel="noopener noreferrer" class="mt-auto btn btn-primary btn-sm">{{ $t('home.installation.button') }}</a>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-secondary mb-4" style="min-height: 15rem;">
<div class="card-header">{{ $t('home.about-wg.box-header') }}</div>
<div class="card-body d-flex flex-column">
<h4 class="card-title">{{ $t('home.about-wg.headline') }}</h4>
<p class="card-text">{{ $t('home.about-wg.content') }}</p>
<a href="https://www.wireguard.com/" title="WireGuard" target="_blank" rel="noopener noreferrer"
class="mt-auto btn btn-primary btn-sm">{{ $t('home.about-wg.button') }}</a>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-secondary mb-4" style="min-height: 15rem;">
<div class="card-header">{{ $t('home.about-portal.box-header') }}</div>
<div class="card-body d-flex flex-column">
<h4 class="card-title">{{ $t('home.about-portal.headline') }}</h4>
<p class="card-text">{{ $t('home.about-portal.content') }}</p>
<a href="https://github.com/h44z/wg-portal/" title="WireGuard Portal" target="_blank"
rel="noopener noreferrer" class="mt-auto btn btn-primary btn-sm">{{ $t('home.about-portal.button') }}</a>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,390 @@
<script setup>
import PeerViewModal from "../components/PeerViewModal.vue";
import PeerEditModal from "../components/PeerEditModal.vue";
import PeerMultiCreateModal from "../components/PeerMultiCreateModal.vue";
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 {notify} from "@kyvg/vue3-notification";
import {settingsStore} from "@/stores/settings";
const settings = settingsStore()
const interfaces = interfaceStore()
const peers = peerStore()
const viewedPeerId = ref("")
const editPeerId = ref("")
const multiCreatePeerId = ref("")
const editInterfaceId = ref("")
const viewedInterfaceId = ref("")
function calculateInterfaceName(id, name) {
let result = id
if (name) {
result += ' (' + name + ')'
}
return result
}
async function download() {
await interfaces.LoadInterfaceConfig(interfaces.GetSelected.Identifier)
// credit: https://www.bitdegree.org/learn/javascript-download
let filename = interfaces.GetSelected.Identifier + ".conf"
let text = interfaces.configuration
let element = document.createElement('a')
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text))
element.setAttribute('download', filename)
element.style.display = 'none'
document.body.appendChild(element)
element.click()
document.body.removeChild(element)
}
async function saveConfig() {
try {
await interfaces.SaveConfiguration(interfaces.GetSelected.Identifier)
notify({
title: "Interface configuration persisted to file",
text: "The interface configuration has been written to the wg-quick configuration file.",
type: 'success',
})
} catch (e) {
console.log(e)
notify({
title: "Failed to persist interface configuration file!",
text: e.toString(),
type: 'error',
})
}
}
onMounted(async () => {
await interfaces.LoadInterfaces()
await peers.LoadPeers(undefined) // use default interface
await peers.LoadStats(undefined) // use default interface
})
</script>
<template>
<PeerViewModal :peerId="viewedPeerId" :visible="viewedPeerId!==''" @close="viewedPeerId=''"></PeerViewModal>
<PeerEditModal :peerId="editPeerId" :visible="editPeerId!==''" @close="editPeerId=''"></PeerEditModal>
<PeerMultiCreateModal :visible="multiCreatePeerId!==''" @close="multiCreatePeerId=''"></PeerMultiCreateModal>
<InterfaceEditModal :interfaceId="editInterfaceId" :visible="editInterfaceId!==''" @close="editInterfaceId=''"></InterfaceEditModal>
<InterfaceViewModal :interfaceId="viewedInterfaceId" :visible="viewedInterfaceId!==''" @close="viewedInterfaceId=''"></InterfaceViewModal>
<!-- Headline and interface selector -->
<div class="page-header row">
<div class="col-12 col-lg-8">
<h1>{{ $t('interfaces.headline') }}</h1>
</div>
<div class="col-12 col-lg-4 text-end">
<div class="form-group">
</div>
<div class="form-group">
<div class="input-group mb-3">
<button class="input-group-text btn btn-primary" :title="$t('interfaces.button-add-interface')" @click.prevent="editInterfaceId='#NEW#'">
<i class="fa-solid fa-plus-circle"></i>
</button>
<select v-model="interfaces.selected" :disabled="interfaces.Count===0" class="form-select" @change="peers.LoadPeers()">
<option v-if="interfaces.Count===0" value="nothing">{{ $t('interfaces.no-interface.default-selection') }}</option>
<option v-for="iface in interfaces.All" :key="iface.Identifier" :value="iface.Identifier">{{ calculateInterfaceName(iface.Identifier,iface.DisplayName) }}</option>
</select>
</div>
</div>
</div>
</div>
<!-- No interfaces information -->
<div v-if="interfaces.Count===0" class="row">
<div class="col-lg-12">
<div class="mt-5">
<h4>{{ $t('interfaces.no-interface.headline') }}</h4>
<p>{{ $t('interfaces.no-interface.abstract') }}</p>
</div>
</div>
</div>
<!-- Interface overview -->
<div v-if="interfaces.Count!==0" class="row">
<div class="col-lg-12">
<div class="card border-secondary mb-4" style="min-height: 15rem;">
<div class="card-header">
<div class="row">
<div class="col-12 col-lg-8">
{{ $t('interfaces.interface.headline') }} <strong>{{interfaces.GetSelected.Identifier}}</strong> ({{interfaces.GetSelected.Mode}} {{ $t('interfaces.interface.mode') }})
<span v-if="interfaces.GetSelected.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="interfaces.GetSelected.DisabledReason"></i></span>
</div>
<div class="col-12 col-lg-4 text-lg-end">
<a class="btn-link" href="#" :title="$t('interfaces.interface.button-show-config')" @click.prevent="viewedInterfaceId=interfaces.GetSelected.Identifier"><i class="fas fa-eye"></i></a>
<a class="ms-5 btn-link" href="#" :title="$t('interfaces.interface.button-download-config')" @click.prevent="download"><i class="fas fa-download"></i></a>
<a v-if="settings.Setting('PersistentConfigSupported')" class="ms-5 btn-link" href="#" :title="$t('interfaces.interface.button-store-config')" @click.prevent="saveConfig"><i class="fas fa-save"></i></a>
<a class="ms-5 btn-link" href="#" :title="$t('interfaces.interface.button-edit')" @click.prevent="editInterfaceId=interfaces.GetSelected.Identifier"><i class="fas fa-cog"></i></a>
</div>
</div>
</div>
<div class="card-body d-flex flex-column">
<div v-if="interfaces.GetSelected.Mode==='server'" class="row">
<div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>{{ $t('interfaces.interface.key') }}:</td>
<td>{{interfaces.GetSelected.PublicKey}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.endpoint') }}:</td>
<td>{{interfaces.GetSelected.PeerDefEndpoint}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.port') }}:</td>
<td>{{interfaces.GetSelected.ListenPort}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.peers') }}:</td>
<td>{{interfaces.GetSelected.EnabledPeers}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.total-peers') }}:</td>
<td>{{interfaces.GetSelected.TotalPeers}}</td>
</tr>
</tbody>
</table>
</div>
<div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>{{ $t('interfaces.interface.ip') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Addresses" :key="addr">{{addr}}</span></td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.dns') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Dns" :key="addr">{{addr}}</span></td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.mtu') }}:</td>
<td>{{interfaces.GetSelected.Mtu}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.default-keep-alive') }}:</td>
<td>{{interfaces.GetSelected.PeerDefPersistentKeepalive}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.default-allowed-ip') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.PeerDefAllowedIPs" :key="addr">{{addr}}</span></td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="interfaces.GetSelected.Mode==='client'" class="row">
<div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>{{ $t('interfaces.interface.key') }}:</td>
<td>{{interfaces.GetSelected.PublicKey}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.endpoints') }}:</td>
<td>{{interfaces.GetSelected.EnabledPeers}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.total-endpoints') }}:</td>
<td>{{interfaces.GetSelected.TotalPeers}}</td>
</tr>
</tbody>
</table>
</div>
<div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>{{ $t('interfaces.interface.ip') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Addresses" :key="addr">{{addr}}</span></td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.dns') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Dns" :key="addr">{{addr}}</span></td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.mtu') }}:</td>
<td>{{interfaces.GetSelected.Mtu}}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="interfaces.GetSelected.Mode==='any'" class="row">
<div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>{{ $t('interfaces.interface.key') }}:</td>
<td>{{interfaces.GetSelected.PublicKey}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.endpoint') }}:</td>
<td>{{interfaces.GetSelected.PeerDefEndpoint}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.port') }}:</td>
<td>{{interfaces.GetSelected.ListenPort}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.peers') }}:</td>
<td>{{interfaces.GetSelected.EnabledPeers}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.total-peers') }}:</td>
<td>{{interfaces.GetSelected.TotalPeers}}</td>
</tr>
</tbody>
</table>
</div>
<div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>{{ $t('interfaces.interface.ip') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Addresses" :key="addr">{{addr}}</span></td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.default-allowed-ip') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.PeerDefAllowedIPs" :key="addr">{{addr}}</span></td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.dns') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.PeerDefDns" :key="addr">{{addr}}</span></td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.mtu') }}:</td>
<td>{{interfaces.GetSelected.Mtu}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.default-keep-alive') }}:</td>
<td>{{interfaces.GetSelected.PeerDefPersistentKeepalive}}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Peer list -->
<div v-if="interfaces.Count!==0" class="mt-4 row">
<div class="col-12 col-lg-5">
<h2 v-if="interfaces.GetSelected.Mode==='server'" class="mt-2">{{ $t('interfaces.headline-peers') }}</h2>
<h2 v-else class="mt-2">{{ $t('interfaces.headline-endpoints') }}</h2>
</div>
<div class="col-12 col-lg-4 text-lg-end">
<div class="form-group d-inline">
<div class="input-group mb-3">
<input v-model="peers.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="peers.afterPageSizeChange">
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
</div>
</div>
</div>
<div class="col-12 col-lg-3 text-lg-end">
<a class="btn btn-primary ms-2" href="#" :title="$t('interfaces.button-add-peers')" @click.prevent="multiCreatePeerId='#NEW#'"><i class="fa fa-plus me-1"></i><i class="fa fa-users"></i></a>
<a class="btn btn-primary ms-2" href="#" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId='#NEW#'"><i class="fa fa-plus me-1"></i><i class="fa fa-user"></i></a>
</div>
</div>
<div v-if="interfaces.Count!==0" class="mt-2 table-responsive">
<div v-if="peers.Count===0">
<h4>{{ $t('interfaces.no-peer.headline') }}</h4>
<p>{{ $t('interfaces.no-peer.abstract') }}</p>
</div>
<table v-if="peers.Count!==0" id="peerTable" class="table table-sm">
<thead>
<tr>
<th scope="col">
<input id="flexCheckDefault" class="form-check-input" :title="$t('general.select-all')" type="checkbox" value="">
</th><!-- select -->
<th scope="col"></th><!-- status -->
<th scope="col">{{ $t('interfaces.table-heading.name') }}</th>
<th scope="col">{{ $t('interfaces.table-heading.user') }}</th>
<th scope="col">{{ $t('interfaces.table-heading.ip') }}</th>
<th v-if="interfaces.GetSelected.Mode==='client'" scope="col">{{ $t('interfaces.table-heading.endpoint') }}</th>
<th v-if="peers.hasStatistics" scope="col">{{ $t('interfaces.table-heading.status') }}</th>
<th scope="col"></th><!-- Actions -->
</tr>
</thead>
<tbody>
<tr v-for="peer in peers.FilteredAndPaged" :key="peer.Identifier">
<th scope="row">
<input id="flexCheckDefault" class="form-check-input" type="checkbox" value="">
</th>
<td class="text-center">
<span v-if="peer.Disabled" class="text-danger" :title="$t('interfaces.peer-disabled') + ' ' + peer.DisabledReason"><i class="fa fa-circle-xmark"></i></span>
<span v-if="!peer.Disabled && peer.ExpiresAt" class="text-warning" :title="$t('interfaces.peer-expiring') + ' ' + peer.ExpiresAt"><i class="fas fa-hourglass-end expiring-peer"></i></span>
</td>
<td><span v-if="peer.DisplayName" :title="peer.Identifier">{{peer.DisplayName}}</span><span v-else :title="peer.Identifier">{{ $filters.truncate(peer.Identifier, 10)}}</span></td>
<td>{{peer.UserIdentifier}}</td>
<td>
<span v-for="ip in peer.Addresses" :key="ip" class="badge bg-light me-1">{{ ip }}</span>
</td>
<td v-if="interfaces.GetSelected.Mode==='client'">{{peer.Endpoint.Value}}</td>
<td v-if="peers.hasStatistics">
<div v-if="peers.Statistics(peer.Identifier).IsConnected">
<span class="badge rounded-pill bg-success" :title="$t('interfaces.peer-connected')"><i class="fa-solid fa-link"></i></span> <span :title="$t('interfaces.peer-handshake') + ' ' + peers.Statistics(peer.Identifier).LastHandshake">{{ $t('interfaces.peer-connected') }}</span>
</div>
<div v-else>
<span class="badge rounded-pill bg-light" :title="$t('interfaces.peer-not-connected')"><i class="fa-solid fa-link-slash"></i></span>
</div>
</td>
<td class="text-center">
<a href="#" :title="$t('interfaces.button-show-peer')" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a>
<a href="#" :title="$t('interfaces.button-edit-peer')" @click.prevent="editPeerId=peer.Identifier"><i class="fas fa-cog"></i></a>
</td>
</tr>
</tbody>
</table>
</div>
<hr v-if="interfaces.Count!==0">
<div v-if="interfaces.Count!==0" class="mt-3">
<div class="row">
<div class="col-6">
<ul class="pagination pagination-sm">
<li :class="{disabled:peers.pageOffset===0}" class="page-item">
<a class="page-link" @click="peers.previousPage">&laquo;</a>
</li>
<li v-for="page in peers.pages" :key="page" :class="{active:peers.currentPage===page}" class="page-item">
<a class="page-link" @click="peers.gotoPage(page)">{{page}}</a>
</li>
<li :class="{disabled:!peers.hasNextPage}" class="page-item">
<a class="page-link" @click="peers.nextPage">&raquo;</a>
</li>
</ul>
</div>
<div class="col-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select v-model.number="peers.pageSize" class="form-select" @click="peers.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="999999999">{{ $t('general.pagination.all') }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,113 @@
<script setup>
import {computed, ref} from "vue";
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("")
const password = ref("")
const usernameInvalid = computed(() => username.value === "")
const passwordInvalid = computed(() => password.value === "")
const disableLoginBtn = computed(() => username.value === "" || password.value === "" || loggingIn.value)
const login = async function () {
console.log("Performing login for user:", username.value);
loggingIn.value = true;
auth.Login(username.value, password.value)
.then(uid => {
notify({
title: "Logged in",
text: "Authentication succeeded!",
type: 'success',
});
loggingIn.value = false;
settings.LoadSettings(); // only logs errors, does not throw
router.push(auth.ReturnUrl);
})
.catch(error => {
notify({
title: "Login failed!",
text: "Authentication failed!",
type: 'error',
});
//loggingIn.value = false;
// delay the user from logging in for a short amount of time
setTimeout(() => loggingIn.value = false, 1000);
});
}
const externalLogin = function (provider) {
console.log("Performing external login for provider", provider.Identifier);
loggingIn.value = true;
console.log(router.currentRoute.value);
let currentUri = window.location.origin + "/#" + router.currentRoute.value.fullPath;
let redirectUrl = `${WGPORTAL_BACKEND_BASE_URL}${provider.ProviderUrl}`;
redirectUrl += "?redirect=true";
redirectUrl += "&return=" + encodeURIComponent(currentUri);
window.location.href = redirectUrl;
}
</script>
<template>
<div class="row">
<div class="col-lg-3"></div><!-- left spacer -->
<div class="col-lg-6">
<div class="card mt-5">
<div class="card-header">{{ $t('login.headline') }}<div class="float-end">
<RouterLink :to="{ name: 'home' }" class="nav-link" :title="$t('menu.home')"><i class="fas fa-times-circle"></i></RouterLink>
</div></div>
<div class="card-body">
<form method="post">
<fieldset>
<div class="form-group">
<label class="form-label" for="inputUsername">{{ $t('login.username.label') }}</label>
<div class="input-group mb-3">
<span class="input-group-text"><span class="far fa-user p-2"></span></span>
<input id="inputUsername" v-model="username" :class="{'is-invalid':usernameInvalid, 'is-valid':!usernameInvalid}" :placeholder="$t('login.username.placeholder')" aria-describedby="usernameHelp"
class="form-control"
name="username" type="text">
</div>
</div>
<div class="form-group">
<label class="form-label" for="inputPassword">{{ $t('login.password.label') }}</label>
<div class="input-group mb-3">
<span class="input-group-text"><span class="fas fa-lock p-2"></span></span>
<input id="inputPassword" v-model="password" :class="{'is-invalid':passwordInvalid, 'is-valid':!passwordInvalid}" :placeholder="$t('login.password.placeholder')" class="form-control"
name="password" type="password">
</div>
</div>
<div class="row mt-5 d-flex">
<div :class="{'col-lg-4':auth.LoginProviders.length < 3, 'col-lg-12':auth.LoginProviders.length >= 3}" class="d-flex mb-2">
<button :disabled="disableLoginBtn" class="btn btn-primary flex-fill" type="submit" @click.prevent="login">
{{ $t('login.button') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div>
</button>
</div>
<div :class="{'col-lg-8':auth.LoginProviders.length < 3, 'col-lg-12':auth.LoginProviders.length >= 3}" class="d-flex mb-2">
<!-- OpenIdConnect / OAUTH providers -->
<button v-for="(provider, idx) in auth.LoginProviders" :key="provider.Identifier" :class="{'ms-1':idx > 0}"
:disabled="loggingIn" :title="provider.Name" class="btn btn-outline-primary flex-fill"
v-html="provider.Name" @click.prevent="externalLogin(provider)"></button>
</div>
</div>
<div class="mt-3">
</div>
</fieldset>
</form>
</div>
</div>
</div>
<div class="col-lg-3"></div><!-- right spacer -->
</div>
</template>

View File

@ -0,0 +1,126 @@
<script setup>
import PeerViewModal from "../components/PeerViewModal.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("")
const editPeerId = ref("")
onMounted(async () => {
await profile.LoadUser()
await profile.LoadPeers()
await profile.LoadStats()
})
</script>
<template>
<PeerViewModal :peerId="viewedPeerId" :visible="viewedPeerId!==''" @close="viewedPeerId=''"></PeerViewModal>
<PeerEditModal :peerId="editPeerId" :visible="editPeerId!==''" @close="editPeerId=''"></PeerEditModal>
<!-- Peer list -->
<div class="mt-4 row">
<div class="col-12 col-lg-5">
<h2 class="mt-2">{{ $t('profile.headline') }}</h2>
</div>
<div class="col-12 col-lg-4 text-lg-end">
<div class="form-group d-inline">
<div class="input-group mb-3">
<input v-model="profile.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="profile.afterPageSizeChange">
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
</div>
</div>
</div>
<div class="col-12 col-lg-3 text-lg-end">
<a v-if="settings.Setting('SelfProvisioning')" class="btn btn-primary ms-2" href="#" :title="$t('general.search.button-add-peer')" @click.prevent="editPeerId='#NEW#'"><i class="fa fa-plus me-1"></i><i class="fa fa-user"></i></a>
</div>
</div>
<div class="mt-2 table-responsive">
<div v-if="profile.CountPeers===0">
<h4>{{ $t('profile.no-peer.headline') }}</h4>
<p>{{ $t('profile.no-peer.abstract') }}</p>
</div>
<table v-if="profile.CountPeers!==0" id="peerTable" class="table table-sm">
<thead>
<tr>
<th scope="col">
<input id="flexCheckDefault" class="form-check-input" :title="$t('general.select-all')" type="checkbox" value="">
</th><!-- select -->
<th scope="col"></th><!-- status -->
<th scope="col">{{ $t('profile.table-heading.name') }}</th>
<th scope="col">{{ $t('profile.table-heading.ip') }}</th>
<th v-if="profile.hasStatistics" scope="col">{{ $t('profile.table-heading.stats') }}</th>
<th scope="col">{{ $t('profile.table-heading.interface') }}</th>
<th scope="col"></th><!-- Actions -->
</tr>
</thead>
<tbody>
<tr v-for="peer in profile.FilteredAndPagedPeers" :key="peer.Identifier">
<th scope="row">
<input id="flexCheckDefault" class="form-check-input" type="checkbox" value="">
</th>
<td class="text-center">
<span v-if="peer.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="peer.DisabledReason"></i></span>
<span v-if="!peer.Disabled && peer.ExpiresAt" class="text-warning"><i class="fas fa-hourglass-end" :title="peer.ExpiresAt"></i></span>
</td>
<td><span v-if="peer.DisplayName" :title="peer.Identifier">{{peer.DisplayName}}</span><span v-else :title="peer.Identifier">{{$filters.truncate(peer.Identifier, 10)}}</span></td>
<td>
<span v-for="ip in peer.Addresses" :key="ip" class="badge rounded-pill bg-light">{{ ip }}</span>
</td>
<td v-if="profile.hasStatistics">
<div v-if="profile.Statistics(peer.Identifier).IsConnected">
<span class="badge rounded-pill bg-success"><i class="fa-solid fa-link"></i></span> <span :title="peers.Statistics(peer.Identifier).LastHandshake">{{ $t('profile.peer-connected') }}</span>
</div>
<div v-else>
<span class="badge rounded-pill bg-light"><i class="fa-solid fa-link-slash"></i></span>
</div>
</td>
<td>{{peer.InterfaceIdentifier}}</td>
<td class="text-center">
<a href="#" :title="$t('profile.button-show-peer')" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a>
<a href="#" :title="$t('profile.button-edit-peer')" @click.prevent="editPeerId=peer.Identifier"><i class="fas fa-cog"></i></a>
</td>
</tr>
</tbody>
</table>
</div>
<hr>
<div class="mt-3">
<div class="row">
<div class="col-6">
<ul class="pagination pagination-sm">
<li :class="{disabled:profile.pageOffset===0}" class="page-item">
<a class="page-link" @click="profile.previousPage">&laquo;</a>
</li>
<li v-for="page in profile.pages" :key="page" :class="{active:profile.currentPage===page}" class="page-item">
<a class="page-link" @click="profile.gotoPage(page)">{{page}}</a>
</li>
<li :class="{disabled:!profile.hasNextPage}" class="page-item">
<a class="page-link" @click="profile.nextPage">&raquo;</a>
</li>
</ul>
</div>
<div class="col-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select v-model.number="profile.pageSize" class="form-select" @click="profile.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="999999999">{{ $t('general.pagination.all') }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,126 @@
<script setup>
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("")
const viewedUserId = ref("")
onMounted(() => {
users.LoadUsers()
})
</script>
<template>
<UserEditModal :userId="editUserId" :visible="editUserId!==''" @close="editUserId=''"></UserEditModal>
<UserViewModal :userId="viewedUserId" :visible="viewedUserId!==''" @close="viewedUserId=''"></UserViewModal>
<!-- User list -->
<div class="mt-4 row">
<div class="col-12 col-lg-5">
<h1>{{ $t('users.headline') }}</h1>
</div>
<div class="col-12 col-lg-4 text-lg-end">
<div class="form-group d-inline">
<div class="input-group mb-3">
<input v-model="users.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="users.afterPageSizeChange">
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
</div>
</div>
</div>
<div class="col-12 col-lg-3 text-lg-end">
<a class="btn btn-primary ms-2" href="#" :title="$t('users.button-add-user')" @click.prevent="editUserId='#NEW#'">
<i class="fa fa-plus me-1"></i><i class="fa fa-user"></i>
</a>
</div>
</div>
<div class="mt-2 table-responsive">
<div v-if="users.Count===0">
<h4>{{ $t('users.no-user.headline') }}</h4>
<p>{{ $t('users.no-user.abstract') }}</p>
</div>
<table v-if="users.Count!==0" id="userTable" class="table table-sm">
<thead>
<tr>
<th scope="col">
<input id="flexCheckDefault" class="form-check-input" :title="$t('general.select-all')" type="checkbox" value="">
</th><!-- select -->
<th scope="col"></th><!-- status -->
<th scope="col">{{ $t('users.table-heading.id') }}</th>
<th scope="col">{{ $t('users.table-heading.email') }}</th>
<th scope="col">{{ $t('users.table-heading.firstname') }}</th>
<th scope="col">{{ $t('users.table-heading.lastname') }}</th>
<th class="text-center" scope="col">{{ $t('users.table-heading.source') }}</th>
<th class="text-center" scope="col">{{ $t('users.table-heading.peers') }}</th>
<th class="text-center" scope="col">{{ $t('users.table-heading.admin') }}</th>
<th scope="col"></th><!-- Actions -->
</tr>
</thead>
<tbody>
<tr v-for="user in users.FilteredAndPaged" :key="user.Identifier">
<th scope="row">
<input id="flexCheckDefault" class="form-check-input" type="checkbox" value="">
</th>
<td class="text-center">
<span v-if="user.Disabled" class="text-danger" :title="$t('users.user-disabled') + ' ' + user.DisabledReason"><i class="fa fa-circle-xmark"></i></span>
<span v-if="user.Locked" class="text-danger" :title="$t('users.user-locked') + ' ' + user.LockedReason"><i class="fas fa-lock"></i></span>
</td>
<td>{{user.Identifier}}</td>
<td>{{user.Email}}</td>
<td>{{user.Firstname}}</td>
<td>{{user.Lastname}}</td>
<td class="text-center"><span class="badge rounded-pill bg-light">{{user.Source}}</span></td>
<td class="text-center">{{user.PeerCount}}</td>
<td class="text-center">
<span v-if="user.IsAdmin" class="text-danger" :title="$t('users.admin')"><i class="fa fa-check-circle"></i></span>
<span v-else><i class="fa fa-circle-xmark" :title="$t('users.no-admin')"></i></span>
</td>
<td class="text-center">
<a href="#" :title="$t('users.button-show-user')" @click.prevent="viewedUserId=user.Identifier"><i class="fas fa-eye me-2"></i></a>
<a href="#" :title="$t('users.button-edit-user')" @click.prevent="editUserId=user.Identifier"><i class="fas fa-cog me-2"></i></a>
</td>
</tr>
</tbody>
</table>
</div>
<hr>
<div class="mt-3">
<div class="row">
<div class="col-6">
<ul class="pagination pagination-sm">
<li :class="{disabled:users.pageOffset===0}" class="page-item">
<a class="page-link" @click="users.previousPage">&laquo;</a>
</li>
<li v-for="page in users.pages" :key="page" :class="{active:users.currentPage===page}" class="page-item">
<a class="page-link" @click="users.gotoPage(page)">{{page}}</a>
</li>
<li :class="{disabled:!users.hasNextPage}" class="page-item">
<a class="page-link" @click="users.nextPage">&raquo;</a>
</li>
</ul>
</div>
<div class="col-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select v-model.number="users.pageSize" class="form-select" @click="users.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="999999999">{{ $t('general.pagination.all') }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
</template>

34
frontend/vite.config.js Normal file
View File

@ -0,0 +1,34 @@
import { fileURLToPath, URL } from 'url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
build: {
outDir: '../internal/app/api/core/frontend-dist',
emptyOutDir: true
},
// local dev api (proxy to avoid cors problems)
server: {
port: 5000,
proxy: {
"/api/v0": {
target: "http://localhost:8888",
changeOrigin: true,
secure: false,
withCredentials: true,
headers: {
"x-wg-dev": true,
},
rewrite: (path) => path,
},
},
},
})

114
go.mod
View File

@ -1,78 +1,106 @@
module github.com/h44z/wg-portal
go 1.18
go 1.20
require (
git.prolicht.digital/golib/healthcheck v1.1.1
github.com/evanphx/json-patch v5.6.0+incompatible
github.com/coreos/go-oidc/v3 v3.6.0
github.com/gin-contrib/cors v1.4.0
github.com/gin-contrib/sessions v0.0.5
github.com/gin-gonic/gin v1.8.2
github.com/go-ldap/ldap/v3 v3.4.4
github.com/go-playground/validator/v10 v10.11.2
github.com/kelseyhightower/envconfig v1.4.0
github.com/milosgajdos/tenus v0.0.3
github.com/pkg/errors v0.9.1
github.com/sirupsen/logrus v1.9.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/swaggo/files v1.0.0
github.com/swaggo/gin-swagger v1.5.3
github.com/swaggo/swag v1.8.10
github.com/tatsushid/go-fastping v0.0.0-20160109021039-d7bb493dee3e
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.9.0
github.com/go-ldap/ldap/v3 v3.4.5
github.com/prometheus-community/pro-bing v0.3.0
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.8.4
github.com/swaggo/swag v1.16.1
github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca
github.com/xhit/go-simple-mail/v2 v2.13.0
golang.org/x/crypto v0.6.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230215201556-9c5414ab4bde
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.4.7
gorm.io/driver/sqlite v1.4.4
gorm.io/gorm v1.24.5
github.com/vardius/message-bus v1.1.5
github.com/vishvananda/netlink v1.1.0
github.com/xhit/go-simple-mail/v2 v2.15.0
github.com/yeqown/go-qrcode/v2 v2.2.2
golang.org/x/crypto v0.11.0
golang.org/x/oauth2 v0.10.0
golang.org/x/sys v0.10.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
gopkg.in/yaml.v2 v2.4.0
gorm.io/driver/mysql v1.5.1
gorm.io/driver/postgres v1.5.2
gorm.io/driver/sqlserver v1.5.1
gorm.io/gorm v1.25.2
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dchest/uniuri v1.2.0 // indirect
github.com/docker/libcontainer v2.2.1+incompatible // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
github.com/go-jose/go-jose/v3 v3.0.0 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/spec v0.20.8 // indirect
github.com/go-openapi/swag v0.22.3 // indirect
github.com/go-openapi/spec v0.20.9 // indirect
github.com/go-openapi/swag v0.22.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/go-playground/validator/v10 v10.14.1 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/go-test/deep v1.0.8 // indirect
github.com/goccy/go-json v0.10.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.4.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/mdlayher/genetlink v1.3.1 // indirect
github.com/mdlayher/netlink v1.7.1 // indirect
github.com/mdlayher/socket v0.4.0 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.4.1 // indirect
github.com/microsoft/go-mssqldb v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
github.com/ugorji/go/codec v1.2.9 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/tools v0.6.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20230216153314-c7b76d3d9ecd // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/net v0.12.0 // indirect
golang.org/x/sync v0.3.0 // indirect
golang.org/x/text v0.11.0 // indirect
golang.org/x/tools v0.9.3 // indirect
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)

319
go.sum
View File

@ -1,18 +1,24 @@
git.prolicht.digital/golib/healthcheck v1.1.1 h1:bdx0MuGqAq0PCooPpiuPXsr4/Ok+yfJwq8P9ITq2eLI=
git.prolicht.digital/golib/healthcheck v1.1.1/go.mod h1:wEqVrqHJ8NsSx5qlFGUlw74wJ/wDSKaA34QoyvsEkdc=
github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0/go.mod h1:OQeznEEkTZ9OrhHJoDD8ZDq51FHgXjqtP9z6bEwBq9U=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDmCTgBzIEPFElEF+FK0SdjAor06dRq2Go927dnQ6o=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/coreos/go-oidc/v3 v3.6.0 h1:AKVxfYw1Gmkn/w96z0DbT/B/xFnzTd3MkZvWLjF4n/o=
github.com/coreos/go-oidc/v3 v3.6.0/go.mod h1:ZpHUsHBucTUj6WOkrP4E20UPynbLZzhTQ1XKCXkxyPc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -20,14 +26,15 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g=
github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY=
github.com/docker/libcontainer v2.2.1+incompatible h1:++SbbkCw+X8vAd4j2gOCzZ2Nn7s2xFALTf7LZKmM1/0=
github.com/docker/libcontainer v2.2.1+incompatible/go.mod h1:osvj61pYsqhNCMLGX31xr7klUBhHb/ZBuXS0o1Fvwbw=
github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U=
github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/garyburd/redigo v1.6.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs=
github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963/go.mod h1:4lkInX8nHSR62NSmhXM3xtPeMSyfiR58NaEz+om1lHM=
github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE=
github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY=
@ -36,28 +43,33 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
github.com/gin-gonic/gin v1.8.2 h1:UzKToD9/PoFj/V4rvlKqTRKnQYyz8Sc1MJlv4JHPtvY=
github.com/gin-gonic/gin v1.8.2/go.mod h1:qw5AYuDrzRTnhvusDsrov+fDIxp9Dleuu12h8nfB398=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs=
github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw=
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A=
github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.4 h1:qPjipEpt+qDa6SI/h1fzuGWoRUY+qqQ9sOZq67/PYUs=
github.com/go-ldap/ldap/v3 v3.4.4/go.mod h1:fe1MsuN5eJJ1FeLT/LEBVdWfNWKh459R7aXgXtJC+aI=
github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-ldap/ldap/v3 v3.4.5 h1:ekEKmaDrpvR2yf5Nc/DClsGG9lAmdDixe44mLzlW5r8=
github.com/go-ldap/ldap/v3 v3.4.5/go.mod h1:bMGIq3AGbytbaMwf8wdv5Phdxz0FWHTIYMSzyrYgnQs=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
github.com/go-openapi/spec v0.20.8 h1:ubHmXNY3FCIOinT8RNrrPfGc9t7I1qhPtdOGoG2AxRU=
github.com/go-openapi/spec v0.20.8/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/spec v0.20.9 h1:xnlYNQAwKd2VQRRfwTEI0DcK+2cbuvI/0c7jx3gA8/8=
github.com/go-openapi/spec v0.20.9/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
@ -67,23 +79,36 @@ github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU=
github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k=
github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA=
github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
@ -92,9 +117,22 @@ 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/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=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.0 h1:BSr+GCm4N6QcgIwv0DyTFHK9ugfEFF9DzSbbzxOiXU0=
github.com/jackc/pgx/v5 v5.4.0/go.mod h1:q6iHT8uDNXWiFNOlRqJzBTaSH3+2xCXkokxHZC5qWFY=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@ -104,10 +142,10 @@ github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2C
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/kidstuff/mongostore v0.0.0-20181113001930-e650cd85ee4b/go.mod h1:g2nVr8KZVXJSS97Jo8pJ0jgq29P6H7dG0oplUA86MQw=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
@ -116,8 +154,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
@ -125,169 +165,179 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mdlayher/genetlink v1.3.1 h1:roBiPnual+eqtRkKX2Jb8UQN5ZPWnhDCGj/wR6Jlz2w=
github.com/mdlayher/genetlink v1.3.1/go.mod h1:uaIPxkWmGk753VVIzDtROxQ8+T+dkHqOI0vB1NA9S/Q=
github.com/mdlayher/netlink v1.7.1 h1:FdUaT/e33HjEXagwELR8R3/KL1Fq5x3G5jgHLp/BTmg=
github.com/mdlayher/netlink v1.7.1/go.mod h1:nKO5CSjE/DJjVhk/TNp6vCE1ktVxEA8VEh8drhZzxsQ=
github.com/mdlayher/socket v0.4.0 h1:280wsy40IC9M9q1uPGcLBwXpcTQDtoGwVt+BNoITxIw=
github.com/mdlayher/socket v0.4.0/go.mod h1:xxFqz5GRCUN3UEOm9CZqEJsAbe1C8OwSK46NlmWuVoc=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
github.com/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc=
github.com/microsoft/go-mssqldb v1.1.0 h1:jsV+tpvcPTbNNKW0o3kiCD69kOHICsfjZ2VcVu2lKYc=
github.com/microsoft/go-mssqldb v1.1.0/go.mod h1:LzkFdl4z2Ck+Hi+ycGOTbL56VEfgoyA2DvYejrNGbRk=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/milosgajdos/tenus v0.0.3 h1:jmaJzwaY1DUyYVD0lM4U+uvP2kkEg1VahDqRFxIkVBE=
github.com/milosgajdos/tenus v0.0.3/go.mod h1:eIjx29vNeDOYWJuCnaHY2r4fq5egetV26ry3on7p8qY=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U=
github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.3.0 h1:SFT6gHqXwbItEDJhTkzPWVqU6CLEtqEfNAPp47RUON4=
github.com/prometheus-community/pro-bing v0.3.0/go.mod h1:p9dLb9zdmv+eLxWfCT6jESWuDrS+YzpPkQBgysQF8a0=
github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg=
github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b h1:aUNXCGgukb4gtY99imuIeoh8Vr0GSwAlYxPAhqZrpFc=
github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
github.com/swaggo/files v1.0.0 h1:1gGXVIeUFCS/dta17rnP0iOpr6CXFwKD7EO5ID233e4=
github.com/swaggo/files v1.0.0/go.mod h1:N59U6URJLyU1PQgFqPM7wXLMhJx7QAolnvfQkqO13kc=
github.com/swaggo/gin-swagger v1.5.3 h1:8mWmHLolIbrhJJTflsaFoZzRBYVmEE7JZGIq08EiC0Q=
github.com/swaggo/gin-swagger v1.5.3/go.mod h1:3XJKSfHjDMB5dBo/0rrTXidPmgLeqsX89Yp4uA50HpI=
github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ=
github.com/swaggo/swag v1.8.10 h1:eExW4bFa52WOjqRzRD58bgWsWfdFJso50lpbeTcmTfo=
github.com/swaggo/swag v1.8.10/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
github.com/tatsushid/go-fastping v0.0.0-20160109021039-d7bb493dee3e h1:nt2877sKfojlHCTOBXbpWjBkuWKritFaGIfgQwbQUls=
github.com/tatsushid/go-fastping v0.0.0-20160109021039-d7bb493dee3e/go.mod h1:B4+Kq1u5FlULTjFSM707Q6e/cOHFv0z/6QRoxubDIQ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/swaggo/swag v1.16.1 h1:fTNRhKstPKxcnoKsytm4sahr8FaYzUcT7i1/3nd/fBg=
github.com/swaggo/swag v1.16.1/go.mod h1:9/LMvHycG3NFHfR6LwvikHv5iFvmPADQ359cKikGxto=
github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f h1:oqdnd6OGlOUu1InG37hWcCB3a+Jy3fwjylyVboaNMwY=
github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f/go.mod h1:X3Dd1SB8Gt1V968NTzpKFjMM6O8ccta2NPC6MprOxZQ=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.9 h1:rmenucSohSTiyL09Y+l2OCk+FrMxGMzho2+tjr5ticU=
github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca h1:lpvAjPK+PcxnbcB8H7axIb4fMNwjX9bE4DzwPjGg8aE=
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca/go.mod h1:XXKxNbpoLihvvT7orUZbs/iZayg1n4ip7iJakJPAwA8=
github.com/xhit/go-simple-mail/v2 v2.13.0 h1:OANWU9jHZrVfBkNkvLf8Ww0fexwpQVF/v/5f96fFTLI=
github.com/xhit/go-simple-mail/v2 v2.13.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/vardius/message-bus v1.1.5 h1:YSAC2WB4HRlwc4neFPTmT88kzzoiQ+9WRRbej/E/LZc=
github.com/vardius/message-bus v1.1.5/go.mod h1:6xladCV2lMkUAE4bzzS85qKOiB5miV7aBVRafiTJGqw=
github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0=
github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/xhit/go-simple-mail/v2 v2.15.0 h1:qMXeqcZErUW/Dw6EXxmPuxHzVI8MdxWnEnu2xcisohU=
github.com/xhit/go-simple-mail/v2 v2.15.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
github.com/yeqown/go-qrcode/v2 v2.2.2 h1:0comk6jEwi0oWNhKEmzx4JI+Q7XIneAApmFSMKWmSVc=
github.com/yeqown/go-qrcode/v2 v2.2.2/go.mod h1:2Qsk2APUCPne0TsRo40DIkI5MYnbzYKCnKGEFWrxd24=
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8=
golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM=
golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wireguard v0.0.0-20230216153314-c7b76d3d9ecd h1:thMXEWXMWIiGlp5T/V+CoetkzBJi4INNaglxdvyfK0c=
golang.zx2c4.com/wireguard v0.0.0-20230216153314-c7b76d3d9ecd/go.mod h1:whfbyDBt09xhCYQWtO2+3UVjlaq6/9hDZrjg2ZE6SyA=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230215201556-9c5414ab4bde h1:ybF7AMzIUikL9x4LgwEmzhXtzRpKNqngme1VGDWz+Nk=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230215201556-9c5414ab4bde/go.mod h1:mQqgjkW8GQQcJQsbBvK890TKqUK1DfKWkuBGbOkuMHQ=
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b h1:J1CaxgLerRR5lgx3wnr6L04cJFbWoceSK9JWBdglINo=
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b/go.mod h1:tqur9LnfstdR9ep2LaJT4lFUl0EjlHtge+gAjmsHUG4=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvYQH2OU3/TnxLx97WDSUDRABfT18pCOYwc2GE=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -297,8 +347,11 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@ -306,11 +359,23 @@ gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.4.7 h1:rY46lkCspzGHn7+IYsNpSfEv9tA+SU4SkkB+GFX125Y=
gorm.io/driver/mysql v1.4.7/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8oc=
gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc=
gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.5 h1:g6OPREKqqlWq4kh/3MCQbZKImeB9e6Xgc4zD+JgNZGE=
gorm.io/gorm v1.24.5/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw=
gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o=
gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0=
gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8=
gorm.io/driver/sqlserver v1.5.1 h1:wpyW/pR26U94uaujltiFGXY7fd2Jw5hC9PB1ZF/Y5s4=
gorm.io/driver/sqlserver v1.5.1/go.mod h1:AYHzzte2msKTmYBYsSIq8ZUsznLJwBdkB2wpI+kt0nM=
gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho=
gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=

View File

@ -0,0 +1,887 @@
package adapters
import (
"context"
"errors"
"fmt"
"github.com/sirupsen/logrus"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
"gorm.io/gorm/utils"
"os"
"path/filepath"
"strings"
"time"
"github.com/glebarez/sqlite"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
gormMySQL "gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlserver"
"gorm.io/gorm"
)
// SchemaVersion describes the current database schema version. It must be incremented if a manual migration is needed.
var SchemaVersion uint64 = 1
// SysStat stores the current database schema version and the timestamp when it was applied.
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
SourceField string
IgnoreErrRecordNotFound bool
Debug bool
Silent bool
}
func NewLogger(slowThreshold time.Duration, debug bool) *GormLogger {
return &GormLogger{
SlowThreshold: slowThreshold,
Debug: debug,
IgnoreErrRecordNotFound: true,
Silent: false,
SourceField: "src",
}
}
func (l *GormLogger) LogMode(level logger.LogLevel) logger.Interface {
if level == logger.Silent {
l.Silent = true
} else {
l.Silent = false
}
return l
}
func (l *GormLogger) Info(ctx context.Context, s string, args ...interface{}) {
if l.Silent {
return
}
logrus.WithContext(ctx).Infof(s, args...)
}
func (l *GormLogger) Warn(ctx context.Context, s string, args ...interface{}) {
if l.Silent {
return
}
logrus.WithContext(ctx).Warnf(s, args...)
}
func (l *GormLogger) Error(ctx context.Context, s string, args ...interface{}) {
if l.Silent {
return
}
logrus.WithContext(ctx).Errorf(s, args...)
}
func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
if l.Silent {
return
}
elapsed := time.Since(begin)
sql, rows := fc()
fields := logrus.Fields{
"rows": rows,
"duration": elapsed,
}
if l.SourceField != "" {
fields[l.SourceField] = utils.FileWithLineNum()
}
if err != nil && !(errors.Is(err, gorm.ErrRecordNotFound) && l.IgnoreErrRecordNotFound) {
fields[logrus.ErrorKey] = err
logrus.WithContext(ctx).WithFields(fields).Errorf("%s", sql)
return
}
if l.SlowThreshold != 0 && elapsed > l.SlowThreshold {
logrus.WithContext(ctx).WithFields(fields).Warnf("%s", sql)
return
}
if l.Debug {
logrus.WithContext(ctx).WithFields(fields).Tracef("%s", sql)
}
}
func NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
var gormDb *gorm.DB
var err error
switch cfg.Type {
case config.DatabaseMySQL:
gormDb, err = gorm.Open(gormMySQL.Open(cfg.DSN), &gorm.Config{
Logger: NewLogger(cfg.SlowQueryThreshold, cfg.Debug),
})
if err != nil {
return nil, fmt.Errorf("failed to open MySQL database: %w", err)
}
sqlDB, _ := gormDb.DB()
sqlDB.SetConnMaxLifetime(time.Minute * 5)
sqlDB.SetMaxIdleConns(2)
sqlDB.SetMaxOpenConns(10)
err = sqlDB.Ping() // This DOES open a connection if necessary. This makes sure the database is accessible
if err != nil {
return nil, fmt.Errorf("failed to ping MySQL database: %w", err)
}
case config.DatabaseMsSQL:
gormDb, err = gorm.Open(sqlserver.Open(cfg.DSN), &gorm.Config{
Logger: NewLogger(cfg.SlowQueryThreshold, cfg.Debug),
})
if err != nil {
return nil, fmt.Errorf("failed to open sqlserver database: %w", err)
}
case config.DatabasePostgres:
gormDb, err = gorm.Open(postgres.Open(cfg.DSN), &gorm.Config{
Logger: NewLogger(cfg.SlowQueryThreshold, cfg.Debug),
})
if err != nil {
return nil, fmt.Errorf("failed to open Postgres database: %w", err)
}
case config.DatabaseSQLite:
if _, err = os.Stat(filepath.Dir(cfg.DSN)); os.IsNotExist(err) {
if err = os.MkdirAll(filepath.Dir(cfg.DSN), 0700); err != nil {
return nil, fmt.Errorf("failed to create database base directory: %w", err)
}
}
gormDb, err = gorm.Open(sqlite.Open(cfg.DSN), &gorm.Config{
Logger: NewLogger(cfg.SlowQueryThreshold, cfg.Debug),
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
}
sqlDB, _ := gormDb.DB()
sqlDB.SetMaxOpenConns(1)
}
return gormDb, nil
}
// SqlRepo is a SQL database repository implementation.
// Currently, it supports MySQL, SQLite, Microsoft SQL and Postgresql database systems.
type SqlRepo struct {
db *gorm.DB
}
func NewSqlRepository(db *gorm.DB) (*SqlRepo, error) {
repo := &SqlRepo{
db: db,
}
if err := repo.preCheck(); err != nil {
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
if err := repo.migrate(); err != nil {
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
return repo, nil
}
func (r *SqlRepo) preCheck() error {
// WireGuard Portal v1 database migration table
type DatabaseMigrationInfo struct {
Version string `gorm:"primaryKey"`
Applied time.Time
}
// temporarily disable logger as the next request might fail (intentionally)
r.db.Logger.LogMode(logger.Silent)
defer func() { r.db.Logger.LogMode(logger.Info) }()
lastVersion := DatabaseMigrationInfo{}
err := r.db.Order("applied desc, version desc").FirstOrInit(&lastVersion).Error
if err != nil {
return nil // we probably don't have a V1 database =)
}
return fmt.Errorf("detected a WireGuard Portal V1 database (version: %s) - please migrate first", lastVersion.Version)
}
func (r *SqlRepo) migrate() error {
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{}))
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
}
// region interfaces
func (r *SqlRepo) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) {
var in domain.Interface
err := r.db.WithContext(ctx).Preload("Addresses").First(&in, id).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, err
}
return &in, nil
}
func (r *SqlRepo) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error) {
in, err := r.GetInterface(ctx, id)
if err != nil {
return nil, nil, fmt.Errorf("failed to load interface: %w", err)
}
peers, err := r.GetInterfacePeers(ctx, id)
if err != nil {
return nil, nil, fmt.Errorf("failed to load peers: %w", err)
}
return in, peers, nil
}
func (r *SqlRepo) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier) ([]domain.PeerStatus, error) {
if len(ids) == 0 {
return nil, nil
}
var stats []domain.PeerStatus
err := r.db.WithContext(ctx).Where("identifier IN ?", ids).Find(&stats).Error
if err != nil {
return nil, err
}
return stats, nil
}
func (r *SqlRepo) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
var interfaces []domain.Interface
err := r.db.WithContext(ctx).Preload("Addresses").Find(&interfaces).Error
if err != nil {
return nil, err
}
return interfaces, nil
}
func (r *SqlRepo) FindInterfaces(ctx context.Context, search string) ([]domain.Interface, error) {
var users []domain.Interface
searchValue := "%" + strings.ToLower(search) + "%"
err := r.db.WithContext(ctx).
Where("identifier LIKE ?", searchValue).
Or("display_name LIKE ?", searchValue).
Preload("Addresses").
Find(&users).Error
if err != nil {
return nil, err
}
return users, nil
}
func (r *SqlRepo) SaveInterface(ctx context.Context, id domain.InterfaceIdentifier, updateFunc func(in *domain.Interface) (*domain.Interface, error)) error {
userInfo := domain.GetUserInfo(ctx)
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
in, err := r.getOrCreateInterface(userInfo, tx, id)
if err != nil {
return err // return any error will roll back
}
in, err = updateFunc(in)
if err != nil {
return err
}
err = r.upsertInterface(userInfo, tx, in)
if err != nil {
return err
}
// return nil will commit the whole transaction
return nil
})
if err != nil {
return err
}
return nil
}
func (r *SqlRepo) getOrCreateInterface(ui *domain.ContextUserInfo, tx *gorm.DB, id domain.InterfaceIdentifier) (*domain.Interface, error) {
var in domain.Interface
// interfaceDefaults will be applied to newly created interface records
interfaceDefaults := domain.Interface{
BaseModel: domain.BaseModel{
CreatedBy: ui.UserId(),
UpdatedBy: ui.UserId(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Identifier: id,
}
err := tx.Attrs(interfaceDefaults).FirstOrCreate(&in, id).Error
if err != nil {
return nil, err
}
return &in, nil
}
func (r *SqlRepo) upsertInterface(ui *domain.ContextUserInfo, tx *gorm.DB, in *domain.Interface) error {
in.UpdatedBy = ui.UserId()
in.UpdatedAt = time.Now()
err := tx.Save(in).Error
if err != nil {
return err
}
err = tx.Model(in).Association("Addresses").Replace(in.Addresses)
if err != nil {
return fmt.Errorf("failed to update interface addresses: %w", err)
}
return nil
}
func (r *SqlRepo) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Where("interface_identifier = ?", id).Delete(&domain.Peer{}).Error
if err != nil {
return err
}
err = tx.Delete(&domain.InterfaceStatus{InterfaceId: id}).Error
if err != nil {
return err
}
err = tx.Select(clause.Associations).Delete(&domain.Interface{Identifier: id}).Error
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
return nil
}
func (r *SqlRepo) GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIdentifier][]domain.Cidr, error) {
var ips []struct {
domain.Cidr
InterfaceId domain.InterfaceIdentifier `gorm:"column:interface_identifier"`
}
err := r.db.WithContext(ctx).
Table("interface_addresses").
Joins("LEFT JOIN cidrs ON interface_addresses.cidr_cidr = cidrs.cidr").
Scan(&ips).Error
if err != nil {
return nil, err
}
result := make(map[domain.InterfaceIdentifier][]domain.Cidr)
for _, ip := range ips {
result[ip.InterfaceId] = append(result[ip.InterfaceId], ip.Cidr)
}
return result, nil
}
// endregion interfaces
// region peers
func (r *SqlRepo) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) {
var peer domain.Peer
err := r.db.WithContext(ctx).Preload("Addresses").First(&peer, id).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, err
}
return &peer, nil
}
func (r *SqlRepo) GetInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.Peer, error) {
var peers []domain.Peer
err := r.db.WithContext(ctx).Preload("Addresses").Where("interface_identifier = ?", id).Find(&peers).Error
if err != nil {
return nil, err
}
return peers, nil
}
func (r *SqlRepo) FindInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier, search string) ([]domain.Peer, error) {
var peers []domain.Peer
searchValue := "%" + strings.ToLower(search) + "%"
err := r.db.WithContext(ctx).Where("interface_identifier = ?", id).
Where("identifier LIKE ?", searchValue).
Or("display_name LIKE ?", searchValue).
Or("iface_address_str_v LIKE ?", searchValue).
Find(&peers).Error
if err != nil {
return nil, err
}
return peers, nil
}
func (r *SqlRepo) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) {
var peers []domain.Peer
err := r.db.WithContext(ctx).Preload("Addresses").Where("user_identifier = ?", id).Find(&peers).Error
if err != nil {
return nil, err
}
return peers, nil
}
func (r *SqlRepo) FindUserPeers(ctx context.Context, id domain.UserIdentifier, search string) ([]domain.Peer, error) {
var peers []domain.Peer
searchValue := "%" + strings.ToLower(search) + "%"
err := r.db.WithContext(ctx).Where("user_identifier = ?", id).
Where("identifier LIKE ?", searchValue).
Or("display_name LIKE ?", searchValue).
Or("iface_address_str_v LIKE ?", searchValue).
Find(&peers).Error
if err != nil {
return nil, err
}
return peers, nil
}
func (r *SqlRepo) SavePeer(ctx context.Context, id domain.PeerIdentifier, updateFunc func(in *domain.Peer) (*domain.Peer, error)) error {
userInfo := domain.GetUserInfo(ctx)
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
peer, err := r.getOrCreatePeer(userInfo, tx, id)
if err != nil {
return err // return any error will roll back
}
peer, err = updateFunc(peer)
if err != nil {
return err
}
err = r.upsertPeer(userInfo, tx, peer)
if err != nil {
return err
}
// return nil will commit the whole transaction
return nil
})
if err != nil {
return err
}
return nil
}
func (r *SqlRepo) getOrCreatePeer(ui *domain.ContextUserInfo, tx *gorm.DB, id domain.PeerIdentifier) (*domain.Peer, error) {
var peer domain.Peer
// interfaceDefaults will be applied to newly created interface records
interfaceDefaults := domain.Peer{
BaseModel: domain.BaseModel{
CreatedBy: ui.UserId(),
UpdatedBy: ui.UserId(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Identifier: id,
}
err := tx.Attrs(interfaceDefaults).FirstOrCreate(&peer, id).Error
if err != nil {
return nil, err
}
return &peer, nil
}
func (r *SqlRepo) upsertPeer(ui *domain.ContextUserInfo, tx *gorm.DB, peer *domain.Peer) error {
peer.UpdatedBy = ui.UserId()
peer.UpdatedAt = time.Now()
err := tx.Save(peer).Error
if err != nil {
return err
}
err = tx.Model(peer).Association("Addresses").Replace(peer.Interface.Addresses)
if err != nil {
return fmt.Errorf("failed to update peer addresses: %w", err)
}
return nil
}
func (r *SqlRepo) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Delete(&domain.PeerStatus{PeerId: id}).Error
if err != nil {
return err
}
err = tx.Select(clause.Associations).Delete(&domain.Peer{Identifier: id}).Error
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
return nil
}
func (r *SqlRepo) GetPeerIps(ctx context.Context) (map[domain.PeerIdentifier][]domain.Cidr, error) {
var ips []struct {
domain.Cidr
PeerId domain.PeerIdentifier `gorm:"column:peer_identifier"`
}
err := r.db.WithContext(ctx).
Table("peer_addresses").
Joins("LEFT JOIN cidrs ON peer_addresses.cidr_cidr = cidrs.cidr").
Scan(&ips).Error
if err != nil {
return nil, err
}
result := make(map[domain.PeerIdentifier][]domain.Cidr)
for _, ip := range ips {
result[ip.PeerId] = append(result[ip.PeerId], ip.Cidr)
}
return result, nil
}
func (r *SqlRepo) GetUsedIpsPerSubnet(ctx context.Context) (map[domain.Cidr][]domain.Cidr, error) {
var peerIps []struct {
domain.Cidr
PeerId domain.PeerIdentifier `gorm:"column:peer_identifier"`
}
err := r.db.WithContext(ctx).
Table("peer_addresses").
Joins("LEFT JOIN cidrs ON peer_addresses.cidr_cidr = cidrs.cidr").
Scan(&peerIps).Error
if err != nil {
return nil, fmt.Errorf("failed to fetch peer IP's: %w", err)
}
var interfaceIps []struct {
domain.Cidr
InterfaceId domain.InterfaceIdentifier `gorm:"column:interface_identifier"`
}
err = r.db.WithContext(ctx).
Table("interface_addresses").
Joins("LEFT JOIN cidrs ON interface_addresses.cidr_cidr = cidrs.cidr").
Scan(&interfaceIps).Error
if err != nil {
return nil, fmt.Errorf("failed to fetch interface IP's: %w", err)
}
result := make(map[domain.Cidr][]domain.Cidr)
for _, ip := range interfaceIps {
networkAddr := ip.Cidr.NetworkAddr()
result[networkAddr] = append(result[networkAddr], ip.Cidr)
}
for _, ip := range peerIps {
networkAddr := ip.Cidr.NetworkAddr()
result[networkAddr] = append(result[networkAddr], ip.Cidr)
}
return result, nil
}
// endregion peers
// region users
func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
var user domain.User
err := r.db.WithContext(ctx).First(&user, id).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, err
}
return &user, nil
}
func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
var users []domain.User
err := r.db.WithContext(ctx).Find(&users).Error
if err != nil {
return nil, err
}
return users, nil
}
func (r *SqlRepo) FindUsers(ctx context.Context, search string) ([]domain.User, error) {
var users []domain.User
searchValue := "%" + strings.ToLower(search) + "%"
err := r.db.WithContext(ctx).
Where("identifier LIKE ?", searchValue).
Or("firstname LIKE ?", searchValue).
Or("lastname LIKE ?", searchValue).
Or("email LIKE ?", searchValue).
Find(&users).Error
if err != nil {
return nil, err
}
return users, nil
}
func (r *SqlRepo) SaveUser(ctx context.Context, id domain.UserIdentifier, updateFunc func(u *domain.User) (*domain.User, error)) error {
userInfo := domain.GetUserInfo(ctx)
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
user, err := r.getOrCreateUser(userInfo, tx, id)
if err != nil {
return err // return any error will roll back
}
user, err = updateFunc(user)
if err != nil {
return err
}
err = r.upsertUser(userInfo, tx, user)
if err != nil {
return err
}
// return nil will commit the whole transaction
return nil
})
if err != nil {
return err
}
return nil
}
func (r *SqlRepo) DeleteUser(ctx context.Context, id domain.UserIdentifier) error {
err := r.db.WithContext(ctx).Delete(&domain.User{}, id).Error
if err != nil {
return err
}
return nil
}
func (r *SqlRepo) getOrCreateUser(ui *domain.ContextUserInfo, tx *gorm.DB, id domain.UserIdentifier) (*domain.User, error) {
var user domain.User
// userDefaults will be applied to newly created user records
userDefaults := domain.User{
BaseModel: domain.BaseModel{
CreatedBy: ui.UserId(),
UpdatedBy: ui.UserId(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Identifier: id,
Source: domain.UserSourceDatabase,
IsAdmin: false,
}
err := tx.Attrs(userDefaults).FirstOrCreate(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *domain.User) error {
user.UpdatedBy = ui.UserId()
user.UpdatedAt = time.Now()
err := tx.Save(user).Error
if err != nil {
return err
}
return nil
}
// endregion users
// region statistics
func (r *SqlRepo) UpdateInterfaceStatus(ctx context.Context, id domain.InterfaceIdentifier, updateFunc func(in *domain.InterfaceStatus) (*domain.InterfaceStatus, error)) error {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
in, err := r.getOrCreateInterfaceStatus(tx, id)
if err != nil {
return err // return any error will roll back
}
in, err = updateFunc(in)
if err != nil {
return err
}
err = r.upsertInterfaceStatus(tx, in)
if err != nil {
return err
}
// return nil will commit the whole transaction
return nil
})
if err != nil {
return err
}
return nil
}
func (r *SqlRepo) getOrCreateInterfaceStatus(tx *gorm.DB, id domain.InterfaceIdentifier) (*domain.InterfaceStatus, error) {
var in domain.InterfaceStatus
// defaults will be applied to newly created record
defaults := domain.InterfaceStatus{
InterfaceId: id,
UpdatedAt: time.Now(),
}
err := tx.Attrs(defaults).FirstOrCreate(&in, id).Error
if err != nil {
return nil, err
}
return &in, nil
}
func (r *SqlRepo) upsertInterfaceStatus(tx *gorm.DB, in *domain.InterfaceStatus) error {
err := tx.Save(in).Error
if err != nil {
return err
}
return nil
}
func (r *SqlRepo) UpdatePeerStatus(ctx context.Context, id domain.PeerIdentifier, updateFunc func(in *domain.PeerStatus) (*domain.PeerStatus, error)) error {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
in, err := r.getOrCreatePeerStatus(tx, id)
if err != nil {
return err // return any error will roll back
}
in, err = updateFunc(in)
if err != nil {
return err
}
err = r.upsertPeerStatus(tx, in)
if err != nil {
return err
}
// return nil will commit the whole transaction
return nil
})
if err != nil {
return err
}
return nil
}
func (r *SqlRepo) getOrCreatePeerStatus(tx *gorm.DB, id domain.PeerIdentifier) (*domain.PeerStatus, error) {
var in domain.PeerStatus
// defaults will be applied to newly created record
defaults := domain.PeerStatus{
PeerId: id,
UpdatedAt: time.Now(),
}
err := tx.Attrs(defaults).FirstOrCreate(&in, id).Error
if err != nil {
return nil, err
}
return &in, nil
}
func (r *SqlRepo) upsertPeerStatus(tx *gorm.DB, in *domain.PeerStatus) error {
err := tx.Save(in).Error
if err != nil {
return err
}
return nil
}
// 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

View File

@ -0,0 +1,43 @@
//go:build integration
package adapters
import (
"database/sql"
"fmt"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
"testing"
)
func tempSqliteDb(t *testing.T) *gorm.DB {
// github.com/mattn/go-sqlite3
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
return db
}
func Test_sqlRepo_migrate(t *testing.T) {
db := tempSqliteDb(t)
r := SqlRepo{db: db}
err := r.migrate()
assert.NoError(t, err)
// check result
var sqlStatement []sql.NullString
db.Raw("SELECT sql FROM sqlite_master").Find(&sqlStatement)
fmt.Println("Table Schemas:")
for _, stm := range sqlStatement {
if stm.Valid {
fmt.Println(stm.String)
}
}
}

View File

@ -0,0 +1,54 @@
package adapters
import (
"fmt"
"github.com/sirupsen/logrus"
"io"
"os"
"path/filepath"
)
type FilesystemRepo struct {
basePath string
}
func NewFileSystemRepository(basePath string) (*FilesystemRepo, error) {
if basePath == "" {
return nil, nil // no path, return empty repository
}
r := &FilesystemRepo{basePath: basePath}
if err := os.MkdirAll(r.basePath, os.ModePerm); err != nil {
return nil, fmt.Errorf("failed to create base directory %s: %w", basePath, err)
}
return r, nil
}
func (r *FilesystemRepo) WriteFile(path string, contents io.Reader) error {
filePath := filepath.Join(r.basePath, path)
parentDirectory := filepath.Dir(filePath)
if err := os.MkdirAll(parentDirectory, os.ModePerm); err != nil {
return fmt.Errorf("failed to create parent directory %s: %w", parentDirectory, err)
}
file, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.ModePerm)
if err != nil {
return fmt.Errorf("failed to open file %s: %w", file.Name(), err)
}
defer func(file *os.File) {
if err := file.Close(); err != nil {
logrus.Errorf("failed to close file %s: %v", file.Name(), err)
}
}(file)
_, err = io.Copy(file, contents)
if err != nil {
return fmt.Errorf("failed to write file contents: %w", err)
}
return nil
}

138
internal/adapters/mailer.go Normal file
View File

@ -0,0 +1,138 @@
package adapters
import (
"context"
"crypto/tls"
"errors"
"fmt"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
mail "github.com/xhit/go-simple-mail/v2"
"io"
"time"
)
type MailRepo struct {
cfg *config.MailConfig
}
func NewSmtpMailRepo(cfg config.MailConfig) MailRepo {
return MailRepo{cfg: &cfg}
}
// Send sends a mail.
func (r MailRepo) Send(_ context.Context, subject, body string, to []string, options *domain.MailOptions) error {
if options == nil {
options = &domain.MailOptions{}
}
r.setDefaultOptions(r.cfg.From, options)
if len(to) == 0 {
return errors.New("missing email recipient")
}
uniqueTo := internal.UniqueStringSlice(to)
email := mail.NewMSG()
email.SetFrom(r.cfg.From).
AddTo(uniqueTo...).
SetReplyTo(options.ReplyTo).
SetSubject(subject).
SetBody(mail.TextPlain, body)
if len(options.Cc) > 0 {
// the underlying mail library does not allow the same address to appear in TO and CC... so filter entries that are already included
// in the TO addresses
cc := RemoveDuplicates(internal.UniqueStringSlice(options.Cc), uniqueTo)
email.AddCc(cc...)
}
if len(options.Bcc) > 0 {
// the underlying mail library does not allow the same address to appear in TO or CC and BCC... so filter entries that are already
// included in the TO and CC addresses
bcc := RemoveDuplicates(internal.UniqueStringSlice(options.Bcc), uniqueTo)
bcc = RemoveDuplicates(bcc, options.Cc)
email.AddCc(internal.UniqueStringSlice(options.Bcc)...)
}
if options.HtmlBody != "" {
email.AddAlternative(mail.TextHTML, options.HtmlBody)
}
for _, attachment := range options.Attachments {
attachmentData, err := io.ReadAll(attachment.Data)
if err != nil {
return fmt.Errorf("failed to read attachment data for %s: %w", attachment.Name, err)
}
if attachment.Embedded {
email.AddInlineData(attachmentData, attachment.Name, attachment.ContentType)
} else {
email.AddAttachmentData(attachmentData, attachment.Name, attachment.ContentType)
}
}
// Call Send and pass the client
srv := r.getMailServer()
client, err := srv.Connect()
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
err = email.Send(client)
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
return nil
}
func (r MailRepo) setDefaultOptions(sender string, options *domain.MailOptions) {
if options.ReplyTo == "" {
options.ReplyTo = sender
}
}
func (r MailRepo) getMailServer() *mail.SMTPServer {
srv := mail.NewSMTPClient()
srv.ConnectTimeout = 30 * time.Second
srv.SendTimeout = 30 * time.Second
srv.Host = r.cfg.Host
srv.Port = r.cfg.Port
srv.Username = r.cfg.Username
srv.Password = r.cfg.Password
switch r.cfg.Encryption {
case config.MailEncryptionTLS:
srv.Encryption = mail.EncryptionSSLTLS
case config.MailEncryptionStartTLS:
srv.Encryption = mail.EncryptionSTARTTLS
default: // MailEncryptionNone
srv.Encryption = mail.EncryptionNone
}
srv.TLSConfig = &tls.Config{ServerName: srv.Host, InsecureSkipVerify: !r.cfg.CertValidation}
switch r.cfg.AuthType {
case config.MailAuthPlain:
srv.Authentication = mail.AuthPlain
case config.MailAuthLogin:
srv.Authentication = mail.AuthLogin
case config.MailAuthCramMD5:
srv.Authentication = mail.AuthCRAMMD5
}
return srv
}
// RemoveDuplicates removes addresses from the given string slice which are contained in the remove slice.
func RemoveDuplicates(slice []string, remove []string) []string {
uniqueSlice := make([]string, 0, len(slice))
for _, i := range remove {
for _, j := range slice {
if i != j {
uniqueSlice = append(uniqueSlice, j)
}
}
}
return uniqueSlice
}

Some files were not shown because too many files have changed in this diff Show More