Compare commits

...

155 Commits

Author SHA1 Message Date
Christoph Haas
8de4da8984 Fix circle-ci config 2023-02-19 00:10:49 +01:00
Christoph Haas
4b5e63c44b Update Go version to 1.18, prepare release 2023-02-19 00:07:38 +01:00
Christoph Haas
c5c6135793 Update dependencies 2023-02-18 23:55:32 +01:00
Christoph Haas
105fa8a880 Use Go DNS resolver (#149) 2023-02-13 11:02:06 +01:00
Christoph Haas
3c2c7f325b keep original admin group behaviour 2023-01-06 00:03:37 +01:00
Christoph Haas
1c97ff8d27 remove log entries that contain user input (#140) 2023-01-05 23:21:22 +01:00
Dmitriy
c0879a379f Healthcheck listen on localhost (#138) 2022-12-28 23:39:30 +01:00
Konstantin
112433e87a Fix: "host" network_mode is incompatible with port_bindings (#137)
Removed port bindings.
2022-12-28 11:57:02 +01:00
Christoph Haas
53a6602a64 cleanup recursive ldap group sync 2022-12-27 13:36:25 +01:00
Christoph Haas
f2afd4a21c Merge branch 'asterix11-master' 2022-12-27 12:36:23 +01:00
Fabian Schultis
a2ab5c9301 Fix nested LDAP group resolution 2022-12-07 05:01:06 +01:00
Fabian Schultis
6f463ac9a5 Add nested group admin state resolution 2022-12-06 02:59:29 +01:00
Christoph Haas
fda3e7b2be fix makefile (#132) 2022-12-04 21:06:41 +01:00
Christoph Haas
dab1e13c54 fix circle ci config 2022-11-11 19:18:10 +01:00
Christoph Haas
51fb9b4139 cleanup code warnings, update RaspberryPi readme 2022-11-11 18:17:38 +01:00
Christoph Haas
bda8c9a3d1 fix migration issue for mysql/mariadb (#128) 2022-11-11 18:07:48 +01:00
h44z
54716f7f53 Multiarch Docker Build (#104) (#129)
* Improved Makefile
* Multiarch Docker build (amd64, arm64 and armv7)
* closes #104
2022-11-11 17:10:41 +01:00
Christoph Haas
e97fb38bd5 fix issue where newly created peers expire 2022-11-08 18:02:00 +01:00
Christoph Haas
2796433973 expiry feature: automatically re-enable peers if date is in the future 2022-11-01 10:51:17 +01:00
Christoph Haas
3e2208c8f6 ensure that db index is re-created (avoids invalid DDL errors), update gorm 2022-10-29 15:24:13 +02:00
Christoph Haas
09a9af245c prepare new release 2022-10-29 14:27:56 +02:00
h44z
979cec7d83 Merge pull request #127 from h44z/feat_exp
Expiry Date for Peers
2022-10-29 14:20:36 +02:00
Christoph Haas
0f33871850 peer expiry feature: update api docs and readme 2022-10-29 13:18:32 +02:00
Christoph Haas
c43e8d7ca2 peer expiry feature: re-activate expired peers 2022-10-29 13:03:05 +02:00
Christoph Haas
4a0e773d96 peer expiry feature: expiration check 2022-10-29 11:21:04 +02:00
Christoph Haas
6f4af97024 peer expiry feature: frontend updates 2022-10-29 10:12:42 +02:00
Christoph Haas
0d5b895174 lazy load qr code (if browser supports it) 2022-10-29 10:06:58 +02:00
Christoph Haas
fe3247bdc1 peer expiry feature: database model, frontend updates 2022-10-28 23:21:37 +02:00
Christoph Haas
e4b927bc45 use go-playground/validator instead of asaskevich/govalidator (#46) 2022-10-28 21:48:44 +02:00
philippderdiedas
383fc8cb58 Merge branch 'h44z:master' into master 2022-10-28 20:42:35 +02:00
Christoph Haas
ab7f19bb55 only remove private key if a custom public key was specified (#112) 2022-10-28 18:40:06 +02:00
Philipp Harms
49c7109c61 Fix DNSStr validator 2022-10-28 18:31:20 +02:00
Fabian Schultis
352c689623 Remove as in https://github.com/h44z/wg-portal/issues/112 2022-10-28 17:54:13 +02:00
skodapilot
e6a8e2f2cf Fixed possibility to save clients without preshared key (#114) 2022-09-19 22:39:34 +02:00
dada513
12717987a6 Add config option to make everyone admin (#106) 2022-09-19 22:26:11 +02:00
Christoph Haas
e4c641f78f update dependencies 2022-05-26 23:29:07 +02:00
Alexander Beck
2f194884d3 user can manage own peers on default device (#82)
Co-authored-by: GitHubActionRunner <knm@knm.io>
2022-05-26 23:10:17 +02:00
Christoph Haas
b34d2e1174 fix status code number (related to #95) 2022-04-30 19:43:39 +02:00
lowid
a46e3724bf fix delete peer status code (#95)
http.StatusNoContent == 204
2022-04-30 19:42:46 +02:00
Christoph Haas
83271b5d34 fix user edit bug, allow to delete users from the database (#40) 2022-03-15 23:34:55 +01:00
Alexis
cc50fcf8e6 Feat/ldap certificate connexion (#92)
* Give the way to connect against LDAP server with certificate and key

* fix(ldap) Update cert variable name

In order to be more explicit

Co-authored-by: Alexis Aurin <alexis@so6.pw>
2022-03-15 22:46:00 +01:00
Christoph Haas
5d4d06db81 fix invalid interface public key (#74) 2021-12-16 19:51:45 +01:00
ultram4rine
e581b3a69f Wireguard exporter friendly tags (#81)
* add friendly name

* add friendly name as option to configuration

* add friendly name configuration to readme
2021-12-16 19:35:15 +01:00
Alexander Beck
acb629f672 do not overwrite preshared key in CreatePeer (#77) 2021-12-10 16:52:44 +01:00
Christoph Haas
b5cb967e09 improve ldap logging (#67) 2021-11-07 13:20:16 +01:00
commonism
5a9918e00d docker-compose - use logging limits (#66)
- ldap sync is very noisy, limits/rotation required
 - can be verified with
   docker inspect -f '{{.HostConfig.LogConfig}}' 88…de
   {json-file map[max-file:3 max-size:10m]}

Co-authored-by: Markus Koetter <koetter@cispa.de>
2021-11-04 22:52:14 +01:00
Christoph Haas
897a2bacf0 circle-ci fix 2021-10-14 21:37:10 +02:00
Christoph Haas
759cf3a0bc build for debian stretch (legacy) and with latest golang version (#61) 2021-10-14 21:25:19 +02:00
Christoph Haas
a07457b41f build for debian stretch (legacy) and with latest golang version (#61) 2021-10-14 21:21:06 +02:00
commonism
d7b52eba1c ldap - compare DNs using DN.Equal (#60)
* ldap - compare DNs using DN.Equal

* ldap/isAdmin- restructure & remove code duplication

Co-authored-by: Markus Koetter <koetter@cispa.de>
2021-10-14 08:57:03 +02:00
commonism
04bc0b7a81 UI unit tests (#59)
* tests - add pytests for the UI

* tests/api - fix NotImplemented

* tests - add README

Co-authored-by: Markus Koetter <koetter@cispa.de>
2021-09-30 22:58:24 +02:00
commonism
19c58fb5af Fixes & API unit testing (#58)
* api - add OperationID

  helps when using pyswagger and is visible via
  http://localhost:8123/swagger/index.html?displayOperationId=true
  gin-swagger can not set displayOperationId yet

* api - match paramters to their property equivalents

  pascalcase & sometimes replacing the name (e.g. device -> DeviceName)

* api - use ShouldBindJSON instead of BindJSON

 BindJSON sets the content-type text/plain

* api - we renamed, we regenerated

* device - allow - in DeviceName wg-example0.conf etc

* api - more pascalcase & argument renames

* api - marshal DeletedAt as string

  gorm.DeletedAt is of type sql.NullTime
  NullTime declares Time & Valid as properties
  DeletedAt marshals as time.Time
  swaggertype allows only basic types
  -> string

* Peer - export UID/DeviceType in json
 UID/DeviceType is required, skipping in json, skips it in marshalling,
 next unmarshalling fails

* assets - name forms for use with mechanize

* api - match error message

* add python3/pyswagger based unittesting
 - initializes a clean install by configuration via web service
 - tests the rest api

* tests - test address exhaustion

* tests - test network expansion

Co-authored-by: Markus Koetter <koetter@cispa.de>
2021-09-29 18:41:13 +02:00
commonism
93db475eee swag - use pascalcase for properties (#54)
Co-authored-by: Markus Koetter <koetter@cispa.de>
2021-09-27 20:28:03 +02:00
The one with the braid (she/her) | Dфҿ mit dem Zopf (sie/ihr)
9147fe33cb Added some more customization options (#43)
* Added some more customization options

* Fixed inconsistent height of custom logos

* Extended navbar style to login page
2021-09-12 10:17:13 +02:00
Christoph Haas
f27909a6ce update dependencies 2021-08-24 21:31:31 +02:00
Christoph Haas
b4bd2b35e2 add HttpOnly and Secure flag to cookie store (#39) 2021-08-24 21:26:16 +02:00
Christoph Haas
929c95f9ae fix version in docker builds 2021-08-24 21:00:13 +02:00
Christoph Haas
7b348888d7 fix version in docker builds 2021-08-24 20:18:13 +02:00
Christoph Haas
5aa777f08d update docker tag names 2021-08-23 23:27:42 +02:00
Christoph Haas
c0abce15d6 also use circleci for tags 2021-08-23 23:03:55 +02:00
h44z
e9369b0afd Circleci project setup (#37) (#38)
* Add .circleci/config.yml

* add go sum, remove travis file

* store artifacts

* github release upload

* github release upload
2021-08-23 22:48:05 +02:00
Christoph Haas
becb35d65e Use Github Actions to build Docker image for hub.docker.com (#26) 2021-08-23 21:47:05 +02:00
Christoph Haas
c0c41bdf2a Use Github Actions to build Docker image for hub.docker.com (#26) 2021-08-23 21:21:05 +02:00
Christoph Haas
57b57931b2 validate user in session (#32) 2021-07-30 13:56:21 +02:00
Christoph Haas
fbc0b26631 sendall button for mails, update icons for peer creation buttons (#35) 2021-07-30 13:43:39 +02:00
Christoph Haas
e6ad82ec6e changed headline to avoid confusion (#33) 2021-07-30 12:32:10 +02:00
Christoph Haas
c3c0971aa0 update dependencies 2021-07-30 12:27:21 +02:00
h44z
16a373f1eb Fix typo 2021-07-20 11:02:17 +02:00
h44z
91b83d7882 Log number of ldap users (#36) 2021-07-20 11:01:03 +02:00
h44z
1e35fb2538 Use Github Container Registry
Also publish docker images on ghcr.io
2021-06-30 17:57:41 +02:00
Christoph Haas
400259a0be convert input to email-token on focus loss (#28) 2021-06-30 17:36:39 +02:00
Christoph Haas
96c713a513 update bootstrap-tokenfield lib, fix enter bug (#27)
related: https://github.com/sliptree/bootstrap-tokenfield/issues/308
2021-06-30 17:28:25 +02:00
Christoph Haas
3645d75d8d fix auto-creation of peers on login (#30) 2021-06-30 17:03:16 +02:00
h44z
a017775f8a Add minimum Go version to Readme (#29) 2021-06-25 17:01:16 +02:00
Christoph Haas
e0968b3239 support AllowIPs for peers in server config (#24) 2021-06-18 14:13:44 +02:00
Christoph Haas
e1db939a18 update readme to clarify some things 2021-06-18 14:12:22 +02:00
Christoph Haas
92d09535bc fix foreign key problem (#23) 2021-06-08 16:17:30 +02:00
Christoph Haas
d165fc0658 ensure that email attribute is set 2021-05-17 09:06:26 +02:00
Christoph Haas
cadbe4a090 fix migrations for fresh databases 2021-05-16 23:55:59 +02:00
Christoph Haas
d516d74d3f ldap filter: skip empty emails by default, fix ldap sync (case insensitive email comparison, user source difference) 2021-05-16 23:43:47 +02:00
Christoph Haas
c9e7145a5b add docker latest tag warning to README.md 2021-05-16 23:14:18 +02:00
h44z
88278bf677 Merge pull request #21 from h44z/ldap_filter
use LDAP filter strings
2021-05-16 23:11:55 +02:00
Christoph Haas
1c4d47293c skip migrations for fresh databases 2021-05-16 23:11:03 +02:00
Christoph Haas
27de6e8b8c use LDAP filter strings 2021-05-10 10:31:56 +02:00
Christoph Haas
3ecb0925d6 use low error correction if qr-code content is too long (#18) 2021-05-10 09:26:36 +02:00
Christoph Haas
edfecd536a use query params throughout the whole rest api (#11) 2021-05-03 11:40:06 +02:00
Christoph Haas
d794f807ad use query param for public key in api requests (#11) 2021-05-03 10:44:27 +02:00
h44z
84e5359977 Merge pull request #16 from xhit/patch-1
Fix email encryption type SSL/TLS
2021-04-30 11:00:25 +02:00
Santiago De la Cruz
5ac45b7a4f Fix email encryption type SSL/TLS
mail.EncryptionTLS is deprecated and is the same like mail.EncryptionSTARTTLS

The correct here is mail.EncryptionSSLTLS
2021-04-29 12:53:03 -04:00
Christoph Haas
ab02f656be add ServerName to TLS config 2021-04-29 18:19:41 +02:00
Christoph Haas
0d4e12a6c1 increase smtp timeout to 30 seconds 2021-04-29 17:04:26 +02:00
Christoph Haas
9a420d26e1 use html email body by default, add alternative text only body 2021-04-29 16:54:01 +02:00
Christoph Haas
19e6fa2a1a switch to another email lib to support more AUTH types 2021-04-29 16:45:28 +02:00
Christoph Haas
7b1f59d86a deployment api completed (#11) 2021-04-29 11:23:32 +02:00
Christoph Haas
9c8a1df01f Set server name in TLS config (#13) 2021-04-29 10:59:00 +02:00
Christoph Haas
87964f8ec4 RESTful API for WireGuard Portal (#11) 2021-04-26 22:00:50 +02:00
Christoph Haas
35513ae994 WIP: RESTful API for WireGuard Portal, user endpoint (#11) 2021-04-26 20:02:40 +02:00
Christoph Haas
b6d9814021 use lowercase email addresses for filtering (#14) 2021-04-22 20:46:03 +02:00
Christoph Haas
97edd103be transform email addresses to lower case in ldap sync (#14) 2021-04-22 20:41:30 +02:00
Christoph Haas
e052f400aa convert all email addresses to lower case (#14) 2021-04-22 20:29:37 +02:00
Christoph Haas
926733dea4 add ssl/tls option for email encryption (#13) 2021-04-22 14:11:54 +02:00
Christoph Haas
7042523c54 configurable cert-check for the ldap auth provider (#12) 2021-04-21 11:07:16 +02:00
Christoph Haas
e65a4a8148 disable cert-check should also work for ldap via ssl (#12) 2021-04-21 10:04:10 +02:00
Christoph Haas
28c2494d88 cleanup import statements 2021-04-09 23:17:44 +02:00
Christoph Haas
11b9a567d1 include tag version in travis builds 2021-04-08 21:30:16 +02:00
Christoph Haas
f34594f8d2 fix allowed ip's for peers in server-mode 2021-04-08 19:10:38 +02:00
Christoph Haas
46dc6dc2ad remove endpoint from peer in server-mode 2021-04-08 18:39:52 +02:00
Christoph Haas
2ca1226d50 fix .local DNS lookup (https://github.com/golang/go/issues/35067) 2021-04-08 18:37:49 +02:00
Christoph Haas
066f939294 fix version display 2021-04-08 18:10:53 +02:00
Christoph Haas
17bc297d77 WIP: smaller docker image, fix docker build 2021-04-08 17:58:25 +02:00
Christoph Haas
79e4513edb WIP: smaller docker image, sqlite needs cgo 2021-04-08 09:38:32 +02:00
Christoph Haas
f793ece922 WIP: smaller docker image 2021-04-08 09:23:48 +02:00
Christoph Haas
96215c4f0e version 1.0.6, show version in footer 2021-04-07 20:07:40 +02:00
Christoph Haas
5199c8674d add configuration options and sample yaml to readme (#6) 2021-04-06 23:51:57 +02:00
Christoph Haas
2caa64571b fix duplicate configuration tag (#6) 2021-04-06 23:01:50 +02:00
Christoph Haas
afbe36d289 fix client edit layout 2021-04-06 00:33:51 +02:00
h44z
e84a43cd2a Merge pull request #7 from h44z/interface_types
Interface types and more...
2021-04-06 00:15:51 +02:00
Christoph Haas
7981a3c437 update dependencies 2021-04-06 00:15:24 +02:00
Christoph Haas
8f21c12c3d simple database migration versioning, todo: implement migrations 2021-04-06 00:07:05 +02:00
Christoph Haas
b4f3228bec more ui improvements, fix peer template for clients 2021-04-05 23:18:02 +02:00
Christoph Haas
ba768dd2c3 improve client mode, todo: migrate peers (new db schema) 2021-04-05 20:00:11 +02:00
Christoph Haas
39166250ea fix some bugs in client mode, improve login ui 2021-04-05 19:12:27 +02:00
Christoph Haas
94ca177884 support different interface types: client and server mode 2021-04-05 18:38:38 +02:00
Christoph Haas
39903922dd WIP: support different interface types: improve placeholder values, allow bulk user creation for external email addresses 2021-04-04 00:04:59 +02:00
Christoph Haas
647fe92a03 WIP: support different interface types: many fixes and improvements... 2021-04-03 23:54:35 +02:00
Christoph Haas
3bfcbe0209 WIP: support different interface types: update config templates 2021-04-03 22:38:22 +02:00
Christoph Haas
aa17303cec WIP: support different interface types: server, client and custom. Show different UI for each type. 2021-04-03 19:11:05 +02:00
Christoph Haas
116a86c5e7 WIP: support different interface types: server, client and custom. Show different UI for each type. 2021-04-02 23:48:30 +02:00
Christoph Haas
5017fb5759 update readme, fix default env file 2021-03-22 23:05:20 +01:00
Christoph Haas
29cd73aa46 fix TLS for email sending 2021-03-22 22:53:59 +01:00
Christoph Haas
6ece6e5be9 make ldap cert check configurable, fix CodeQL warnings 2021-03-22 22:52:08 +01:00
Christoph Haas
588f8c7c70 add csrf 2021-03-22 22:51:37 +01:00
Christoph Haas
68507c3bcd fix redirect after sending the peer email 2021-03-22 13:45:35 +01:00
Christoph Haas
1e9f845457 fix user_edit template 2021-03-22 13:42:28 +01:00
Christoph Haas
f95c692aed migrate peer database 2021-03-22 13:00:02 +01:00
Christoph Haas
f4edc55851 fix mail template (#3) and rename some variables, also change default ordering (latest handshake first) 2021-03-22 12:39:50 +01:00
Christoph Haas
6ab00ef567 WIP: support for multiple WireGuard devices (#2) 2021-03-21 12:36:11 +01:00
Christoph Haas
5f4c041ee7 fix potentially unsafe external link 2021-02-27 00:19:47 +01:00
Christoph Haas
9ef4200be0 fix ldap provider, disable gin logs by default 2021-02-27 00:13:48 +01:00
Christoph Haas
e1c7a43496 fix ldap sync for disabled users, check if admin username is an email address, rename username to email 2021-02-26 23:43:52 +01:00
h44z
5bc3aa0036 Create codeql-analysis.yml 2021-02-26 23:19:42 +01:00
Christoph Haas
2b77148b81 migrate old database format correctly, fix typo, update readme 2021-02-26 23:13:11 +01:00
Christoph Haas
9bd80dbd33 fix docker build 2021-02-26 22:27:48 +01:00
Christoph Haas
9faa459c44 cleanup 2021-02-26 22:17:04 +01:00
Christoph Haas
8ea82c1916 add travis token 2021-02-25 09:46:19 +01:00
Christoph Haas
ca83caf357 fix travis 2021-02-24 23:01:13 +01:00
Christoph Haas
14339e72d4 WIP: dont use gox 2021-02-24 22:52:19 +01:00
Christoph Haas
6d4fcba00c WIP: use gox for cross platform compiling, try to enable cross platform cgo builds 2021-02-24 22:40:35 +01:00
Christoph Haas
4fe4d93e0d WIP: use gox for cross platform compiling 2021-02-24 22:16:03 +01:00
Christoph Haas
9b10d099b6 WIP: new user management and authentication system, use go 1.16 embed 2021-02-24 21:24:45 +01:00
Christoph Haas
43bab58f0a WIP: context for clean shutdown 2021-02-22 22:25:08 +01:00
Christoph Haas
984f744548 rename user to peer 2021-02-21 23:23:58 +01:00
Christoph Haas
53814dbc27 cleanup, typos, ... 2021-02-08 22:56:02 +01:00
Christoph Haas
dd47f84c3d use logrus for gin's log output, fix nil pointer bug 2021-01-13 17:49:52 +01:00
Christoph Haas
ec752f8b08 use logrus for gin's log output 2021-01-13 17:27:01 +01:00
89 changed files with 10061 additions and 2764 deletions

92
.circleci/config.yml Normal file
View File

@@ -0,0 +1,92 @@
version: 2.1
jobs:
build-latest:
steps:
- checkout
- restore_cache:
keys:
- go-mod-latest-v4-{{ checksum "go.sum" }}
- run:
name: Install Dependencies
command: |
make build-dependencies
- save_cache:
key: go-mod-latest-v4-{{ checksum "go.sum" }}
paths:
- "~/go/pkg/mod"
- run:
name: Build AMD64
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-amd64
- run:
name: Install Cross-Platform Dependencies
command: |
sudo apt-get update
sudo -E apt-get -yq --no-install-suggests --no-install-recommends --force-yes install gcc-aarch64-linux-gnu libc6-dev-arm64-cross
sudo -E apt-get -yq --no-install-suggests --no-install-recommends --force-yes install gcc-arm-linux-gnueabi libc6-dev-armel-cross gcc-arm-linux-gnueabihf libc6-dev-armhf-cross
sudo ln -s /usr/include/asm-generic /usr/include/asm
- run:
name: Build ARM64
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-arm64
- run:
name: Build ARM
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-arm
- store_artifacts:
path: ~/repo/dist
- run:
name: "Publish Release on GitHub"
command: |
if [ ! -z "${CIRCLE_TAG}" ]; then
go install github.com/tcnksm/ghr@latest
ghr -t ${GITHUB_TOKEN} -u ${CIRCLE_PROJECT_USERNAME} -r ${CIRCLE_PROJECT_REPONAME} -c ${CIRCLE_SHA1} -replace $CIRCLE_TAG ~/repo/dist
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
workflows:
build-and-release:
jobs:
#--------------- BUILD ---------------#
- build-latest:
filters:
tags:
only: /^v.*/
- build-118:
requires:
- build-latest
filters:
tags:
only: /^v.*/

67
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '35 15 * * 4'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

137
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,137 @@
name: Docker
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
push:
branches: [ master ]
# Publish vX.X.X tags as releases.
tags: [ 'v*.*.*' ]
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build-dockerhub:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Get Version
shell: bash
run: |
echo "::set-output name=identifier::$(echo ${GITHUB_REF##*/})"
echo "::set-output name=hash::$(echo ${GITHUB_SHA} | cut -c1-7)"
id: get_version
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: h44z/wg-portal
flavor: |
latest=true
prefix=
suffix=
tags: |
type=ref,event=branch
type=ref,event=tag
type=semver,pattern={{version}}
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
build-args: |
BUILD_IDENTIFIER=${{ steps.get_version.outputs.identifier }}
BUILD_VERSION=${{ steps.get_version.outputs.hash }}
build-github:
name: Push Docker image to Github Container Registry
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Get Version
shell: bash
run: |
echo "::set-output name=identifier::$(echo ${GITHUB_REF##*/})"
echo "::set-output name=hash::$(echo ${GITHUB_SHA} | cut -c1-7)"
id: get_version
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=true
prefix=
suffix=
tags: |
type=ref,event=branch
type=ref,event=tag
type=semver,pattern={{version}}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
build-args: |
BUILD_IDENTIFIER=${{ steps.get_version.outputs.identifier }}
BUILD_VERSION=${{ steps.get_version.outputs.hash }}

5
.gitignore vendored
View File

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

View File

@@ -1,4 +0,0 @@
language: go
go:
- 1.15.x

View File

@@ -4,7 +4,17 @@
######- ######-
# Start from the latest golang base image as builder image (only used to compile the code) # Start from the latest golang base image as builder image (only used to compile the code)
######- ######-
FROM golang:1.15 as builder FROM golang:1.18 as builder
ARG BUILD_IDENTIFIER
ENV ENV_BUILD_IDENTIFIER=$BUILD_IDENTIFIER
ARG BUILD_VERSION
ENV ENV_BUILD_VERSION=$BUILD_VERSION
# populated by BuildKit
ARG TARGETPLATFORM
ENV ENV_TARGETPLATFORM=$TARGETPLATFORM
RUN mkdir /build RUN mkdir /build
@@ -14,38 +24,30 @@ ADD . /build/
# Set the Current Working Directory inside the container # Set the Current Working Directory inside the container
WORKDIR /build WORKDIR /build
# Workaround for failing travis-ci builds
RUN rm -rf ~/go; rm -rf go.sum
# Build the Go app # Build the Go app
RUN go clean -modcache; go mod tidy; make build RUN echo "Building version '$ENV_BUILD_IDENTIFIER-$ENV_BUILD_VERSION' for platform $ENV_TARGETPLATFORM"; make build
######- ######-
# Here starts the main image # Here starts the main image
######- ######-
FROM debian:buster FROM scratch
# Setup timezone # Setup timezone
ENV TZ=Europe/Vienna ENV TZ=Europe/Vienna
# GOSS for container health checks # Import linux stuff from builder.
ENV GOSS_VERSION v0.3.14 COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
RUN apt-get update && apt-get upgrade -y && \ COPY --from=builder /etc/passwd /etc/passwd
apt-get install --no-install-recommends -y moreutils ca-certificates curl && \ COPY --from=builder /etc/group /etc/group
rm -rf /var/cache/apt /var/lib/apt/lists/*; \
curl -L https://github.com/aelsabbahy/goss/releases/download/$GOSS_VERSION/goss-linux-amd64 -o /usr/local/bin/goss && \
chmod +rx /usr/local/bin/goss && \
goss --version
COPY --from=builder /build/dist/wg-portal-amd64 /app/wgportal # Copy binaries
COPY --from=builder /build/dist/assets /app/assets COPY --from=builder /build/dist/wg-portal /app/wg-portal
COPY --from=builder /build/scripts /app/ COPY --from=builder /build/dist/hc /app/hc
# Set the Current Working Directory inside the container # Set the Current Working Directory inside the container
WORKDIR /app WORKDIR /app
# Command to run the executable # Command to run the executable
CMD [ "/app/wgportal" ] CMD [ "/app/wg-portal" ]
HEALTHCHECK --interval=1m --timeout=10s \ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 CMD [ "/app/hc", "http://localhost:11223/health" ]
CMD /app/docker-healthcheck.sh

144
Makefile
View File

@@ -6,57 +6,123 @@ BUILDDIR=dist
BINARIES=$(subst cmd/,,$(wildcard cmd/*)) BINARIES=$(subst cmd/,,$(wildcard cmd/*))
IMAGE=h44z/wg-portal IMAGE=h44z/wg-portal
.PHONY: all test clean phony all: help
all: dep build .PHONY: help
help:
@echo "Usage:"
@sed -n 's/^#>//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' # user commands (#>)
@echo ""
@echo "Advanced commands:"
@sed -n 's/^#<//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' # internal commands (#<)
build: dep $(addsuffix -amd64,$(addprefix $(BUILDDIR)/,$(BINARIES))) ########################################################################################
cp -r assets $(BUILDDIR) ##
cp scripts/wg-portal.service $(BUILDDIR) ## DEVELOPER / USER TARGETS
cp scripts/wg-portal.env $(BUILDDIR) ##
########################################################################################
build-cross-plat: dep build $(addsuffix -arm,$(addprefix $(BUILDDIR)/,$(BINARIES))) $(addsuffix -arm64,$(addprefix $(BUILDDIR)/,$(BINARIES))) #> codegen: Re-generate autogenerated files (like API docs)
cp -r assets $(BUILDDIR) .PHONY: codegen
cp scripts/wg-portal.service $(BUILDDIR) codegen: $(SUBDIRS)
cp scripts/wg-portal.env $(BUILDDIR) cd internal; swag init --propertyStrategy pascalcase --parseInternal --generalInfo server/api.go --output server/docs/
$(GOCMD) fmt internal/server/docs/docs.go
dep: #> update: Update all dependencies
$(GOCMD) mod download .PHONY: update
update:
@ $(GOCMD) get -u ./...
@ $(GOCMD) mod tidy
validate: dep #> format: Re-format the code
$(GOCMD) fmt $(GOFILES) .PHONY: format
$(GOCMD) vet $(GOFILES) format:
$(GOCMD) test -race $(GOFILES) @echo "Formatting code..."
@ $(GOCMD) fmt $(GOFILES)
coverage: dep ########################################################################################
$(GOCMD) fmt $(GOFILES) ##
$(GOCMD) test $(GOFILES) -v -coverprofile .testCoverage.txt ## TESTING / CODE QUALITY TARGETS
$(GOCMD) tool cover -func=.testCoverage.txt # use total:\s+\(statements\)\s+(\d+.\d+\%) as Gitlab CI regextotal:\s+\(statements\)\s+(\d+.\d+\%) ##
########################################################################################
coverage-html: coverage #> test: Run all kinds of tests, except for integration tests
$(GOCMD) tool cover -html=.testCoverage.txt .PHONY: test
test: test-vet test-race
test: dep #< test-vet: Static code analysis
$(GOCMD) test $(MODULENAME)/... -v -count=1 .PHONY: test-vet
test-vet: build-dependencies
@$(GOCMD) vet $(GOFILES)
#< test-race: Race condition test
.PHONY: test-race
test-race: build-dependencies
@$(GOCMD) test -race -short $(GOFILES)
########################################################################################
##
## CI TARGETS
##
########################################################################################
#< clean: Delete all generated executables and test files
.PHONY: clean
clean: clean:
$(GOCMD) clean $(GOFILES) @rm -rf $(BUILDDIR)
rm -rf .testCoverage.txt
rm -rf $(BUILDDIR)
docker-build: #< build: Build all executables (architecture depends on build system)
docker build -t $(IMAGE) . .PHONY: build
build: build-dependencies
CGO_ENABLED=1 $(GOCMD) build -o $(BUILDDIR)/wg-portal \
-ldflags "-w -s -extldflags \"-static\" -X 'github.com/h44z/wg-portal/internal/server.Version=${ENV_BUILD_IDENTIFIER}-${ENV_BUILD_VERSION}'" \
-tags netgo \
cmd/wg-portal/main.go
docker-push: CGO_ENABLED=0 $(GOCMD) build -o $(BUILDDIR)/hc \
docker push $(IMAGE) -ldflags "-w -s -extldflags \"-static\"" \
cmd/hc/main.go
$(BUILDDIR)/%-amd64: cmd/%/main.go dep phony #< build-amd64: Build all executables for AMD64
GOOS=linux GOARCH=amd64 $(GOCMD) build -o $@ $< .PHONY: build-amd64
build-amd64: build-dependencies
CGO_ENABLED=1 $(GOCMD) build -o $(BUILDDIR)/wg-portal-amd64 \
-ldflags "-w -s -extldflags \"-static\" -X 'github.com/h44z/wg-portal/internal/server.Version=${ENV_BUILD_IDENTIFIER}-${ENV_BUILD_VERSION}'" \
-tags netgo \
cmd/wg-portal/main.go
# On arch-linux install aarch64-linux-gnu-gcc to crosscompile for arm64 CGO_ENABLED=0 $(GOCMD) build -o $(BUILDDIR)/hc-amd64 \
$(BUILDDIR)/%-arm64: cmd/%/main.go dep phony -ldflags "-w -s -extldflags \"-static\"" \
CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc GOOS=linux GOARCH=arm64 $(GOCMD) build -ldflags "-linkmode external -extldflags -static" -o $@ $< cmd/hc/main.go
# On arch-linux install arm-linux-gnueabihf-gcc to crosscompile for arm #< build-arm64: Build all executables for ARM64
$(BUILDDIR)/%-arm: cmd/%/main.go dep phony .PHONY: build-arm64
CGO_ENABLED=1 CC=arm-linux-gnueabihf-gcc GOOS=linux GOARCH=arm GOARM=7 $(GOCMD) build -ldflags "-linkmode external -extldflags -static" -o $@ $< build-arm64: build-dependencies
CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc GOOS=linux GOARCH=arm64 $(GOCMD) build -o $(BUILDDIR)/wg-portal-arm64 \
-ldflags "-w -s -extldflags \"-static\" -X 'github.com/h44z/wg-portal/internal/server.Version=${ENV_BUILD_IDENTIFIER}-${ENV_BUILD_VERSION}'" \
-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
CGO_ENABLED=1 CC=arm-linux-gnueabi-gcc GOOS=linux GOARCH=arm GOARM=7 $(GOCMD) build -o $(BUILDDIR)/wg-portal-arm \
-ldflags "-w -s -extldflags \"-static\" -X 'github.com/h44z/wg-portal/internal/server.Version=${ENV_BUILD_IDENTIFIER}-${ENV_BUILD_VERSION}'" \
-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:
@$(GOCMD) mod download -x
@mkdir -p $(BUILDDIR)
cp scripts/wg-portal.service $(BUILDDIR)
cp scripts/wg-portal.env $(BUILDDIR)

View File

@@ -1,19 +1,24 @@
# WireGuard Portal on Raspberry Pi # WireGuard Portal on Raspberry Pi
This readme only contains a detailed explanation of how to setup the WireGuard Portal service on a raspberry pi (>= 3). This readme only contains a detailed explanation of how to set up the WireGuard Portal service on a raspberry pi (>= 3).
## Setup ## Setup
You can download prebuild binaries from the [release page](https://github.com/h44z/wg-portal/releases). If you want to build the binary yourself, You can either download prebuild binaries from the [release page](https://github.com/h44z/wg-portal/releases) or use Docker images for ARM.
use the following instructions: If you want to build the binary yourself, use the following building instructions.
### Building ### Building
This section describes how to build the WireGuard Portal code. This section describes how to build the WireGuard Portal code.
To compile the final binary, use the Makefile provided in the repository. To compile the final binary, use the Makefile provided in the repository.
As WireGuard Portal is written in Go, **golang >= 1.14** must be installed prior to building. 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).
``` ```
make build-cross-plat # 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. The compiled binary and all necessary assets will be located in the dist folder.

198
README.md
View File

@@ -3,17 +3,18 @@
[![Build Status](https://travis-ci.com/h44z/wg-portal.svg?token=q4pSqaqT58Jzpxdx62xk&branch=master)](https://travis-ci.com/h44z/wg-portal) [![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) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT)
![GitHub last commit](https://img.shields.io/github/last-commit/h44z/wg-portal) ![GitHub last commit](https://img.shields.io/github/last-commit/h44z/wg-portal)
[![Go Report Card](https://goreportcard.com/badge/github.com/h44z/wg-portal)](https://goreportcard.com/report/github.com/h44z/wg-portal)
![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/h44z/wg-portal) ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/h44z/wg-portal)
![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/h44z/wg-portal) ![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/)
A simple web base configuration portal for [WireGuard](https://wireguard.com). 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 the VPN The portal uses the WireGuard [wgctrl](https://github.com/WireGuard/wgctrl-go) library to manage existing VPN
interface. This allows for seamless activation or deactivation of new users, without disturbing existing VPN interfaces. This allows for seamless activation or deactivation of new users, without disturbing existing VPN
connections. connections.
The configuration portal is designed to use LDAP (Active Directory) as a user source for authentication and profile data. The configuration portal currently supports using SQLite and MySQL as a user source for authentication and profile data.
It still can be used without LDAP by using a predefined administrator account. Some features like mass creation of accounts It also supports LDAP (Active Directory or OpenLDAP) as authentication provider.
will only be available in combination with LDAP.
## Features ## Features
* Self-hosted and web based * Self-hosted and web based
@@ -23,18 +24,27 @@ will only be available in combination with LDAP.
* Enable / Disable clients seamlessly * Enable / Disable clients seamlessly
* Generation of `wgX.conf` after any modification * Generation of `wgX.conf` after any modification
* IPv6 ready * IPv6 ready
* User authentication (LDAP and/or predefined admin account) * User authentication (SQLite/MySQL and LDAP)
* Dockerized * Dockerized
* Responsive template * Responsive template
* One single binary
* Can be used with existing WireGuard setups
* Support for multiple WireGuard interfaces
* REST API for management and client deployment
* Peer Expiry Feature
![Screenshot](screenshot.png) ![Screenshot](screenshot.png)
## Setup ## 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 ### Docker
The easiest way to run WireGuard Portal is using the provided docker image. The easiest way to run WireGuard Portal is to use the Docker image provided.
Docker compose snippet with sample values: 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' version: '3.6'
services: services:
@@ -51,41 +61,185 @@ services:
ports: ports:
- '8123:8123' - '8123:8123'
environment: environment:
# WireGuard Settings
- WG_DEVICES=wg0
- WG_DEFAULT_DEVICE=wg0
- WG_CONFIG_PATH=/etc/wireguard
# Core Settings
- EXTERNAL_URL=https://vpn.company.com - EXTERNAL_URL=https://vpn.company.com
- WEBSITE_TITLE=WireGuard VPN - WEBSITE_TITLE=WireGuard VPN
- COMPANY_NAME=Your Company Name - COMPANY_NAME=Your Company Name
- MAIL_FROM=WireGuard VPN <noreply+wireguard@company.com> - ADMIN_USER=admin@domain.com
- ADMIN_USER=admin # optional admin user
- ADMIN_PASS=supersecret - ADMIN_PASS=supersecret
- ADMIN_LDAP_GROUP=CN=WireGuardAdmins,OU=Users,DC=COMPANY,DC=LOCAL # Mail Settings
- MAIL_FROM=WireGuard VPN <noreply+wireguard@company.com>
- EMAIL_HOST=10.10.10.10 - EMAIL_HOST=10.10.10.10
- EMAIL_PORT=25 - EMAIL_PORT=25
# LDAP Settings
- LDAP_ENABLED=true
- LDAP_URL=ldap://srv-ad01.company.local:389 - LDAP_URL=ldap://srv-ad01.company.local:389
- LDAP_BASEDN=DC=COMPANY,DC=LOCAL - LDAP_BASEDN=DC=COMPANY,DC=LOCAL
- LDAP_USER=ldap_wireguard@company.local - LDAP_USER=ldap_wireguard@company.local
- LDAP_PASSWORD=supersecretldappassword - 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. 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 backup your files from ```/etc/wireguard```. 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/common/configuration.go](internal/common/configuration.go). For a full list of configuration options take a look at the source file [internal/server/configuration.go](internal/server/configuration.go#L58).
### Standalone ### Standalone
For a standalone application, use the Makefile provided in the repository to build the application. 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 make
# To build for arm architecture as well use: # build wg-portal for current system architecture
make build-cross-plat make build
``` ```
The compiled binary and all necessary assets will be located in the dist folder. 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). A detailed description for using this software with a raspberry pi can be found in the [README-RASPBERRYPI.md](README-RASPBERRYPI.md).
## What is out of scope 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`.
* Generation or application of any `iptables` or `nftables` rules Once the Docker setup is completed, create a new buildx builder:
* Setting up or changing IP-addresses of the WireGuard interface on operating systems other than linux ```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.
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`.
### Configuration Options
The following configuration options are available:
| environment | yaml | yaml_parent | default_value | description |
|----------------------------|-------------------------|-------------|-----------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|
| LISTENING_ADDRESS | listeningAddress | core | :8123 | The address on which the web server is listening. Optional IP address and port, e.g.: 127.0.0.1:8080. |
| EXTERNAL_URL | externalUrl | core | http://localhost:8123 | The external URL where the web server is reachable. This link is used in emails that are created by the WireGuard Portal. |
| WEBSITE_TITLE | title | core | WireGuard VPN | The website title. |
| COMPANY_NAME | company | core | WireGuard Portal | The company name (for branding). |
| MAIL_FROM | mailFrom | core | WireGuard VPN <noreply@company.com> | The email address from which emails are sent. |
| LOGO_URL | logoUrl | core | /img/header-logo.png | The logo displayed in the page's header. |
| ADMIN_USER | adminUser | core | admin@wgportal.local | The administrator user. Must be a valid email address. |
| ADMIN_PASS | adminPass | core | wgportal | The administrator password. If unchanged, a random password will be set on first startup. |
| EDITABLE_KEYS | editableKeys | core | true | Allow to edit key-pairs in the UI. |
| CREATE_DEFAULT_PEER | createDefaultPeer | core | false | If an LDAP user logs in for the first time, a new WireGuard peer will be created on the WG_DEFAULT_DEVICE if this option is enabled. |
| SELF_PROVISIONING | selfProvisioning | core | false | Allow registered users to automatically create peers via the RESTful API. |
| WG_EXPORTER_FRIENDLY_NAMES | wgExporterFriendlyNames | core | false | Enable integration with [prometheus_wireguard_exporter friendly name](https://github.com/MindFlavor/prometheus_wireguard_exporter#friendly-tags). |
| LDAP_ENABLED | ldapEnabled | core | false | Enable or disable the LDAP backend. |
| SESSION_SECRET | sessionSecret | core | secret | Use a custom secret to encrypt session data. |
| BACKGROUND_TASK_INTERVAL | backgroundTaskInterval | core | 900 | The interval (in seconds) for the background tasks (like peer expiry check). |
| EXPIRY_REENABLE | expiryReEnable | core | false | Reactivate expired peers if the expiration date is in the future. |
| DATABASE_TYPE | typ | database | sqlite | Either mysql or sqlite. |
| DATABASE_HOST | host | database | | The mysql server address. |
| DATABASE_PORT | port | database | | The mysql server port. |
| DATABASE_NAME | database | database | data/wg_portal.db | For sqlite database: the database file-path, otherwise the database name. |
| DATABASE_USERNAME | user | database | | The mysql user. |
| DATABASE_PASSWORD | password | database | | The mysql password. |
| EMAIL_HOST | host | email | 127.0.0.1 | The email server address. |
| EMAIL_PORT | port | email | 25 | The email server port. |
| EMAIL_TLS | tls | email | false | Use STARTTLS. DEPRECATED: use EMAIL_ENCRYPTION instead. |
| EMAIL_ENCRYPTION | encryption | email | none | Either none, tls or starttls. |
| EMAIL_CERT_VALIDATION | certcheck | email | false | Validate the email server certificate. |
| EMAIL_USERNAME | user | email | | An optional username for SMTP authentication. |
| EMAIL_PASSWORD | pass | email | | An optional password for SMTP authentication. |
| EMAIL_AUTHTYPE | auth | email | plain | Either plain, login or crammd5. If username and password are empty, this value is ignored. |
| WG_DEVICES | devices | wg | wg0 | A comma separated list of WireGuard devices. |
| WG_DEFAULT_DEVICE | defaultDevice | wg | wg0 | This device is used for auto-created peers (if CREATE_DEFAULT_PEER is enabled). |
| WG_CONFIG_PATH | configDirectory | wg | /etc/wireguard | If set, interface configuration updates will be written to this path, filename: <devicename>.conf. |
| MANAGE_IPS | manageIPAddresses | wg | true | Handle IP address setup of interface, only available on linux. |
| USER_MANAGE_PEERS | userManagePeers | wg | false | Logged in user can create or update peers (partially). |
| LDAP_URL | url | ldap | ldap://srv-ad01.company.local:389 | The LDAP server url. |
| LDAP_STARTTLS | startTLS | ldap | true | Use STARTTLS. |
| LDAP_CERT_VALIDATION | certcheck | ldap | false | Validate the LDAP server certificate. |
| LDAP_BASEDN | dn | ldap | DC=COMPANY,DC=LOCAL | The base DN for searching users. |
| LDAP_USER | user | ldap | company\\\\ldap_wireguard | The bind user. |
| LDAP_PASSWORD | pass | ldap | SuperSecret | The bind password. |
| LDAP_LOGIN_FILTER | loginFilter | ldap | (&(objectClass=organizationalPerson)(mail={{login_identifier}})(!userAccountControl:1.2.840.113556.1.4.803:=2)) | {{login_identifier}} will be replaced with the login email address. |
| LDAP_SYNC_FILTER | syncFilter | ldap | (&(objectClass=organizationalPerson)(!userAccountControl:1.2.840.113556.1.4.803:=2)(mail=*)) | The filter string for the LDAP synchronization service. Users matching this filter will be synchronized with the WireGuard Portal database. |
| LDAP_SYNC_GROUP_FILTER | syncGroupFilter | ldap | | The filter string for the LDAP groups, for example: (objectClass=group). The groups are used to recursively check for admin group member ship of users. |
| LDAP_ADMIN_GROUP | adminGroup | ldap | CN=WireGuardAdmins,OU=_O_IT,DC=COMPANY,DC=LOCAL | Users in this group are marked as administrators. |
| LDAP_ATTR_EMAIL | attrEmail | ldap | mail | User email attribute. |
| LDAP_ATTR_FIRSTNAME | attrFirstname | ldap | givenName | User firstname attribute. |
| LDAP_ATTR_LASTNAME | attrLastname | ldap | sn | User lastname attribute. |
| LDAP_ATTR_PHONE | attrPhone | ldap | telephoneNumber | User phone number attribute. |
| LDAP_ATTR_GROUPS | attrGroups | ldap | memberOf | User groups attribute. |
| LDAP_CERT_CONN | ldapCertConn | ldap | false | Allow connection with certificate against LDAP server without user/password |
| LDAPTLS_CERT | ldapTlsCert | ldap | | The LDAP cert's path |
| LDAPTLS_KEY | ldapTlsKey | ldap | | The LDAP key's path |
| LOG_LEVEL | | | debug | Specify log level, one of: trace, debug, info, off. |
| LOG_JSON | | | false | Format log output as JSON. |
| LOG_COLOR | | | true | Colorize log output. |
| CONFIG_FILE | | | config.yml | The config file path. |
### Sample yaml configuration
config.yml:
```yaml
core:
listeningAddress: :8123
externalUrl: https://wg-test.test.com
adminUser: test@test.com
adminPass: test
editableKeys: true
createDefaultPeer: false
ldapEnabled: true
mailFrom: WireGuard VPN <noreply@test.com>
ldap:
url: ldap://10.10.10.10:389
dn: DC=test,DC=test
startTLS: false
user: wireguard@test.test
pass: test
adminGroup: CN=WireGuardAdmins,CN=Users,DC=test,DC=test
database:
typ: sqlite
database: data/wg_portal.db
email:
host: smtp.gmail.com
port: 587
tls: true
user: test@gmail.com
pass: topsecret
wg:
devices:
- wg0
- wg1
defaultDevice: wg0
configDirectory: /etc/wireguard
manageIPAddresses: true
```
### RESTful API
WireGuard Portal offers a RESTful API to interact with.
The API is documented using OpenAPI 2.0, the Swagger UI can be found
under the URL `http://<your wg-portal ip/domain>/swagger/index.html?displayOperationId=true`.
The [API's unittesting](tests/test_API.py) may serve as an example how to make use of the API with python3 & pyswagger.
## 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.
## Application stack ## Application stack

View File

@@ -2,4 +2,4 @@
* bootstrap-tokenfield * bootstrap-tokenfield
* https://github.com/sliptree/bootstrap-tokenfield * https://github.com/sliptree/bootstrap-tokenfield
* Copyright 2013-2014 Sliptree and other contributors; Licensed MIT * 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{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} */@-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

@@ -40,6 +40,16 @@ pre{background:#f7f7f9}iframe{overflow:hidden;border:none}@media (min-width: 768
/* -------------------------------------------------- /* --------------------------------------------------
End collapsable table*/ End collapsable table*/
.jumbotron-home {
padding: 1rem 1rem;
}
@media (min-width: 576px) {
.jumbotron-home {
padding: 2rem 2rem;
}
}
@media (min-width: 1440px) { @media (min-width: 1440px) {
.container, .container-lg, .container-md, .container-sm, .container-xl { .container, .container-lg, .container-md, .container-sm, .container-xl {
max-width: 1400px; max-width: 1400px;
@@ -54,10 +64,19 @@ pre{background:#f7f7f9}iframe{overflow:hidden;border:none}@media (min-width: 768
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
} }
.navbar-brand > img {
height: 2rem;
width: auto;
}
.disabled-peer { .disabled-peer {
color: #d03131; color: #d03131;
} }
.expiring-peer {
color: #d09d12;
}
.tokenfield .token { .tokenfield .token {
border-radius: 0px; border-radius: 0px;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
@@ -71,3 +90,29 @@ pre{background:#f7f7f9}iframe{overflow:hidden;border:none}@media (min-width: 768
content:"*"; content:"*";
color:red; 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;
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
assets/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
assets/img/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

13
assets/js/bootstrap-confirmation.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -25,6 +25,15 @@
} }
}); });
}); });
$(function() {
$('select.device-selector').change(function() {
this.form.submit();
});
});
$('[data-toggle=confirmation]').confirmation({
rootSelector: '[data-toggle=confirmation]',
// other options
});
})(jQuery); // End of use strict })(jQuery); // End of use strict

5
assets/js/popper.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -17,19 +17,20 @@
{{template "prt_nav.html" .}} {{template "prt_nav.html" .}}
<div class="container mt-5"> <div class="container mt-5">
<h1>Create new clients</h1> <h1>Create new clients</h1>
<h2>Enter valid LDAP user email addresses to quickly create new accounts.</h2> <h2>Enter valid user email addresses to quickly create new accounts.</h2>
{{template "prt_flashes.html" .}} {{template "prt_flashes.html" .}}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{.Csrf}}">
<div class="form-row"> <div class="form-row">
<div class="form-group required col-md-12"> <div class="form-group required col-md-12">
<label for="inputEmail">Email Addresses</label> <label for="inputEmail">Email Addresses</label>
<input type="text" name="email" class="form-control" id="inputEmail" value="{{.FormData.Emails}}"> <input type="text" name="email" class="form-control" id="inputEmail" value="{{.FormData.Emails}}" required>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group required col-md-12"> <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> <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}}"> <input type="text" name="identifier" class="form-control" id="inputIdentifier" value="{{.FormData.Identifier}}" required>
</div> </div>
</div> </div>
@@ -39,9 +40,10 @@
</div> </div>
{{template "prt_footer.html" .}} {{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script> <script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/jquery.easing.js"></script> <script src="/js/jquery.easing.js"></script>
<script src="/js/jquery-ui.min.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/bootstrap-tokenfield.min.js"></script>
<script src="/js/custom.js"></script> <script src="/js/custom.js"></script>
<script>$('#inputEmail').on('tokenfield:createdtoken', function (e) { <script>$('#inputEmail').on('tokenfield:createdtoken', function (e) {
@@ -51,11 +53,19 @@
if (!valid) { if (!valid) {
$(e.relatedTarget).addClass('invalid') $(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({ }).tokenfield({
autocomplete: { autocomplete: {
source: [{{range $i, $u :=.Users}}{{$u.Mail}},{{end}}], source: [{{range $i, $u :=.Users}}{{if ne $i 0}},{{end}}'{{$u.Email}}'{{end}}],
delay: 100 delay: 100
}, },
inputType: 'email',
createTokensOnBlur: true,
showAutocompleteOnFocus: false showAutocompleteOnFocus: false
})</script> })</script>
</body> </body>

View File

@@ -13,33 +13,39 @@
<body id="page-top" class="d-flex flex-column min-vh-100"> <body id="page-top" class="d-flex flex-column min-vh-100">
{{template "prt_nav.html" .}} {{template "prt_nav.html" .}}
<div class="container mt-5"> <div class="container mt-5">
{{template "prt_flashes.html" .}}
<!-- server mode -->
{{if eq .Device.Type "server"}}
{{if .Peer.IsNew}} {{if .Peer.IsNew}}
<h1>Create a new client</h1> <h1>Create a new client</h1>
{{else}} {{else}}
<h1>Edit client <strong>{{.Peer.Identifier}}</strong></h1> <h1>Edit client: <strong>{{.Peer.Identifier}}</strong></h1>
{{end}} {{end}}
{{template "prt_flashes.html" .}}
<form method="post" enctype="multipart/form-data"> <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="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}} {{if .EditableKeys}}
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-12"> <div class="form-group col-md-12">
<label for="inputServerPrivateKey">Private Key</label> <label for="server_PrivateKey">Private Key</label>
<input type="text" name="privkey" class="form-control" id="inputServerPrivateKey" value="{{.Peer.PrivateKey}}"> <input type="text" name="privkey" class="form-control" id="server_PrivateKey" value="{{.Peer.PrivateKey}}">
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group required col-md-12"> <div class="form-group required col-md-12">
<label for="inputServerPublicKey">Public Key</label> <label for="server_PublicKey">Public Key</label>
<input type="text" name="pubkey" class="form-control" id="inputServerPublicKey" value="{{.Peer.PublicKey}}"> <input type="text" name="pubkey" class="form-control" id="server_PublicKey" value="{{.Peer.PublicKey}}" required>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-12"> <div class="form-group col-md-12">
<label for="inputServerPresharedKey">Preshared Key</label> <label for="server_PresharedKey">Preshared Key</label>
<input type="text" name="presharedkey" class="form-control" id="inputServerPresharedKey" value="{{.Peer.PresharedKey}}"> <input type="text" name="presharedkey" class="form-control" id="server_PresharedKey" value="{{.Peer.PresharedKey}}">
</div> </div>
</div> </div>
{{else}} {{else}}
@@ -47,62 +53,168 @@
<input type="hidden" name="presharedkey" value="{{.Peer.PresharedKey}}"> <input type="hidden" name="presharedkey" value="{{.Peer.PresharedKey}}">
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-12"> <div class="form-group col-md-12">
<label for="inputServerPublicKey">Public Key</label> <label for="server_ro_PublicKey">Public Key</label>
<input type="text" name="pubkey" readonly class="form-control" id="inputServerPublicKey" value="{{.Peer.PublicKey}}"> <input type="text" name="pubkey" readonly class="form-control" id="server_ro_PublicKey" value="{{.Peer.PublicKey}}">
</div> </div>
</div> </div>
{{end}} {{end}}
<div class="form-row"> <div class="form-row">
<div class="form-group required col-md-12"> <div class="form-group required col-md-12">
<label for="inputIdentifier">Client Friendly Name</label> <label for="server_Identifier">Client Friendly Name</label>
<input type="text" name="identifier" class="form-control" id="inputIdentifier" value="{{.Peer.Identifier}}"> <input type="text" name="identifier" class="form-control" id="server_Identifier" value="{{.Peer.Identifier}}" required>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group required col-md-12"> <div class="form-group required col-md-12">
<label for="inputEmail">Client Email Address</label> <label for="server_Email">Client Email Address</label>
<input type="email" name="mail" class="form-control" id="inputEmail" value="{{.Peer.Email}}"> <input type="email" name="mail" class="form-control" id="server_Email" value="{{.Peer.Email}}" required>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group required col-md-12"> <div class="form-group required col-md-12">
<label for="inputIP">Client IP Address</label> <label for="server_IP">Client IP Address</label>
<input type="text" name="ip" class="form-control" id="inputIP" value="{{.Peer.IPsStr}}"> <input type="text" name="ip" class="form-control" id="server_IP" value="{{.Peer.IPsStr}}" required>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group required col-md-12"> <div class="form-group col-md-12 global-config">
<label for="inputAllowedIP">Allowed IPs</label> <label for="server_AllowedIP">Allowed IPs</label>
<input type="text" name="allowedip" class="form-control" id="inputAllowedIP" value="{{.Peer.AllowedIPsStr}}"> <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> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-12"> <div class="form-group col-md-6">
<div class="custom-control custom-switch"> <div class="custom-control custom-switch">
<input class="custom-control-input" name="isdisabled" type="checkbox" value="true" id="inputDisabled" {{if .Peer.DeactivatedAt}}checked{{end}}> <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="inputDisabled"> <label class="custom-control-label" for="server_Disabled">
Disabled Disabled
</label> </label>
</div> </div>
<div class="custom-control custom-switch"> <div class="custom-control custom-switch">
<input class="custom-control-input" name="ignorekeepalive" type="checkbox" value="true" id="inputIgnoreKeepalive" {{if .Peer.IgnorePersistentKeepalive}}checked{{end}}> <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="inputIgnoreKeepalive"> <label class="custom-control-label" for="server_IgnoreGlobalSettings">
Ignore persistent keepalive Ignore global settings (<span class="text-blue">g</span>)
</label> </label>
</div> </div>
</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> </div>
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>
<a href="/admin" class="btn btn-secondary">Cancel</a> <a href="/admin" class="btn btn-secondary">Cancel</a>
</form> </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> </div>
{{template "prt_footer.html" .}} {{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script> <script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/jquery.easing.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> <script src="/js/custom.js"></script>
</body> </body>

View File

@@ -16,103 +16,247 @@
<h1>Edit interface <strong>{{.Device.DeviceName}}</strong></h1> <h1>Edit interface <strong>{{.Device.DeviceName}}</strong></h1>
{{template "prt_flashes.html" .}} {{template "prt_flashes.html" .}}
<form method="post" enctype="multipart/form-data"> <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="device" value="{{.Device.DeviceName}}">
<input type="hidden" name="devicetype" value="server">
<h3>Server's interface configuration</h3> <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}} {{if .EditableKeys}}
<div class="form-row"> <div class="form-row">
<div class="form-group required col-md-12"> <div class="form-group required col-md-12">
<label for="inputServerPrivateKey">Private Key</label> <label for="server_PrivateKey">Private Key</label>
<input type="text" name="privkey" class="form-control" id="inputServerPrivateKey" value="{{.Device.PrivateKey}}"> <input type="text" name="privkey" class="form-control" id="server_PrivateKey" value="{{.Device.PrivateKey}}" required>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group required col-md-12"> <div class="form-group required col-md-12">
<label for="inputServerPublicKey">Public Key</label> <label for="server_PublicKey">Public Key</label>
<input type="text" name="pubkey" class="form-control" id="inputServerPublicKey" value="{{.Device.PublicKey}}"> <input type="text" name="pubkey" class="form-control" id="server_PublicKey" value="{{.Device.PublicKey}}" required>
</div> </div>
</div> </div>
{{else}} {{else}}
<input type="hidden" name="privkey" value="{{.Device.PrivateKey}}"> <input type="hidden" name="privkey" value="{{.Device.PrivateKey}}">
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-12"> <div class="form-group col-md-12">
<label for="inputServerPublicKey">Public Key</label> <label for="server_ro_PublicKey">Public Key</label>
<input type="text" name="pubkey" readonly class="form-control" id="inputServerPublicKey" value="{{.Device.PublicKey}}"> <input type="text" name="pubkey" readonly class="form-control" id="server_ro_PublicKey" value="{{.Device.PublicKey}}">
</div> </div>
</div> </div>
{{end}} {{end}}
<div class="form-row"> <div class="form-row">
<div class="form-group required col-md-6"> <div class="form-group required col-md-6">
<label for="inputListenPort">Listen port</label> <label for="server_ListenPort">Listen port</label>
<input type="number" name="port" class="form-control" id="inputListenPort" placeholder="51820" value="{{.Device.ListenPort}}"> <input type="number" name="port" class="form-control" id="server_ListenPort" placeholder="51820" value="{{.Device.ListenPort}}" required>
</div> </div>
<div class="form-group required col-md-6"> <div class="form-group required col-md-6">
<label for="inputIPs">Server IP address</label> <label for="server_IPs">Server IP address</label>
<input type="text" name="ip" class="form-control" id="inputIPs" placeholder="10.6.6.1/24" value="{{.Device.IPsStr}}"> <input type="text" name="ip" class="form-control" id="server_IPs" placeholder="10.6.6.1/24" value="{{.Device.IPsStr}}" required>
</div> </div>
</div> </div>
<h3>Client's global configuration</h3> <h3>Client's global configuration (<span class="text-blue">g</span>)</h3>
<div class="form-row"> <div class="form-row">
<div class="form-group required col-md-12"> <div class="form-group required col-md-12">
<label for="inputPublicEndpoint">Public Enpoint for Clients</label> <label for="server_PublicEndpoint">Public Endpoint for Clients</label>
<input type="text" name="endpoint" class="form-control" id="inputPublicEndpoint" placeholder="vpn.company.com:51820" value="{{.Device.Endpoint}}"> <input type="text" name="endpoint" class="form-control" id="server_PublicEndpoint" placeholder="vpn.company.com:51820" value="{{.Device.DefaultEndpoint}}" required>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-6"> <div class="form-group col-md-6">
<label for="inputDNS">DNS Servers</label> <label for="server_DNS">DNS Servers</label>
<input type="text" name="dns" class="form-control" id="inputDNS" placeholder="1.1.1.1" value="{{.Device.DNSStr}}"> <input type="text" name="dns" class="form-control" id="server_DNS" placeholder="1.1.1.1" value="{{.Device.DNSStr}}">
</div> </div>
<div class="form-group col-md-6"> <div class="form-group col-md-6">
<label for="inputAllowedIP">Default allowed IPs</label> <label for="server_AllowedIP">Default allowed IPs</label>
<input type="text" name="allowedip" class="form-control" id="inputAllowedIP" placeholder="10.6.6.0/24" value="{{.Device.AllowedIPsStr}}"> <input type="text" name="allowedip" class="form-control" id="server_AllowedIP" placeholder="10.6.6.0/24" value="{{.Device.DefaultAllowedIPsStr}}">
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-6"> <div class="form-group col-md-6">
<label for="inputMTU">Global MTU</label> <label for="server_MTU">MTU (also used for the server interface, 0 = default)</label>
<input type="number" name="mtu" class="form-control" id="inputMTU" placeholder="0" value="{{.Device.Mtu}}"> <input type="number" name="mtu" class="form-control" id="server_MTU" placeholder="" value="{{.Device.Mtu}}">
</div> </div>
<div class="form-group col-md-6"> <div class="form-group col-md-6">
<label for="inputPersistentKeepalive">Persistent Keepalive</label> <label for="server_PersistentKeepalive">Persistent Keepalive (0 = off)</label>
<input type="number" name="keepalive" class="form-control" id="inputPersistentKeepalive" placeholder="16" value="{{.Device.PersistentKeepalive}}"> <input type="number" name="keepalive" class="form-control" id="server_PersistentKeepalive" placeholder="16" value="{{.Device.DefaultPersistentKeepalive}}">
</div> </div>
</div> </div>
<h3>Interface configuration hooks</h3> <h3>Interface configuration hooks</h3>
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-12"> <div class="form-group col-md-12">
<label for="inputPreUp">Pre Up</label> <label for="server_PreUp">Pre Up</label>
<input type="text" name="preup" class="form-control" id="inputPreUp" value="{{.Device.PreUp}}"> <input type="text" name="preup" class="form-control" id="server_PreUp" value="{{.Device.PreUp}}">
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-12"> <div class="form-group col-md-12">
<label for="inputPostUp">Post Up</label> <label for="server_PostUp">Post Up</label>
<input type="text" name="postup" class="form-control" id="inputPostUp" value="{{.Device.PostUp}}"> <input type="text" name="postup" class="form-control" id="server_PostUp" value="{{.Device.PostUp}}">
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-12"> <div class="form-group col-md-12">
<label for="inputPreDown">Pre Down</label> <label for="server_PreDown">Pre Down</label>
<input type="text" name="predown" class="form-control" id="inputPreDown" value="{{.Device.PreDown}}"> <input type="text" name="predown" class="form-control" id="server_PreDown" value="{{.Device.PreDown}}">
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-12"> <div class="form-group col-md-12">
<label for="inputPostDown">Post Down</label> <label for="server_PostDown">Post Down</label>
<input type="text" name="postdown" class="form-control" id="inputPostDown" value="{{.Device.PostDown}}"> <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>
</div> </div>
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>
<a href="/admin" class="btn btn-secondary">Cancel</a> <a href="/admin" class="btn btn-secondary">Cancel</a>
<a href="/admin/device/applyglobals" class="btn btn-dark float-right">Apply Allowed IP's to clients</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> </form>
</div> </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" .}} {{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script> <script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/jquery.easing.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> <script src="/js/custom.js"></script>
</body> </body>

View File

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

@@ -18,7 +18,9 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span class="mr-auto">Interface status for <strong>{{.Device.DeviceName}}</strong></span> <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> <a href="/admin/device/download?dev={{.Device.DeviceName}}" title="Download interface configuration"><i class="fas fa-download"></i></a>
&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;
<a href="/admin/device/edit?dev={{.Device.DeviceName}}" title="Edit interface settings"><i class="fas fa-cog"></i></a> <a href="/admin/device/edit?dev={{.Device.DeviceName}}" title="Edit interface settings"><i class="fas fa-cog"></i></a>
@@ -26,6 +28,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
{{if eq $.Device.Type "server"}}
<div class="col-sm-6"> <div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table"> <table class="table table-sm table-borderless device-status-table">
<tbody> <tbody>
@@ -35,7 +38,7 @@
</tr> </tr>
<tr> <tr>
<td>Public Endpoint:</td> <td>Public Endpoint:</td>
<td>{{.Device.Endpoint}}</td> <td>{{.Device.DefaultEndpoint}}</td>
</tr> </tr>
<tr> <tr>
<td>Listening Port:</td> <td>Listening Port:</td>
@@ -61,7 +64,7 @@
</tr> </tr>
<tr> <tr>
<td>Default allowed IP's:</td> <td>Default allowed IP's:</td>
<td>{{.Device.AllowedIPsStr}}</td> <td>{{.Device.DefaultAllowedIPsStr}}</td>
</tr> </tr>
<tr> <tr>
<td>Default DNS servers:</td> <td>Default DNS servers:</td>
@@ -73,24 +76,69 @@
</tr> </tr>
<tr> <tr>
<td>Default Keepalive Interval:</td> <td>Default Keepalive Interval:</td>
<td>{{.Device.PersistentKeepalive}}</td> <td>{{.Device.DefaultPersistentKeepalive}}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </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>
</div> </div>
<div class="mt-4 row"> <div class="mt-4 row">
<div class="col-sm-10 col-12"> <div class="col-sm-8 col-12">
<h2 class="mt-2">Current VPN Users</h2> {{if eq $.Device.Type "server"}}
</div> <h2 class="mt-2">Current VPN Peers</h2>
<div class="col-sm-2 col-12 text-right">
{{if not .Static.LdapDisabled}}
<a href="/admin/peer/createldap" title="Add LDAP users" class="btn btn-primary"><i class="fa fa-fw fa-user-plus"></i></a>
{{end}} {{end}}
<a href="/admin/peer/create" title="Manually add a user" class="btn btn-primary"><i class="fa fa-fw fa-plus"></i>M</a> {{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> </div>
<div class="mt-2 table-responsive"> <div class="mt-2 table-responsive">
@@ -98,26 +146,41 @@
<thead> <thead>
<tr> <tr>
<th scope="col" class="list-image-cell"></th><!-- Status and expand --> <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 "id"}}"></i></a></th> <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 "pubKey"}}"></i></a></th> <th scope="col"><a href="?sort=pubKey">Public Key <i class="fa fa-fw {{.Session.GetSortIcon "peers" "pubKey"}}"></i></a></th>
<th scope="col"><a href="?sort=mail">E-Mail <i class="fa fa-fw {{.Session.GetSortIcon "mail"}}"></i></a></th> {{if eq $.Device.Type "server"}}
<th scope="col"><a href="?sort=ip">IP's <i class="fa fa-fw {{.Session.GetSortIcon "ip"}}"></i></a></th> <th scope="col"><a href="?sort=mail">E-Mail <i class="fa fa-fw {{.Session.GetSortIcon "peers" "mail"}}"></i></a></th>
<th scope="col"><a href="?sort=handshake">Handshake <i class="fa fa-fw {{.Session.GetSortIcon "handshake"}}"></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 --> <th scope="col"></th><!-- Actions -->
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{range $i, $p :=.Peers}} {{range $i, $p :=.Peers}}
{{$peerUser:=(userForEmail $.Users $p.Email)}}
<tr id="user-pos-{{$i}}" {{if $p.DeactivatedAt}}class="disabled-peer"{{end}}> <tr id="user-pos-{{$i}}" {{if $p.DeactivatedAt}}class="disabled-peer"{{end}}>
<th scope="row" class="list-image-cell"> <th scope="row" class="list-image-cell">
<a href="#{{$p.UID}}" data-toggle="collapse" class="collapse-indicator collapsed"></a> <a href="#{{$p.UID}}" data-toggle="collapse" class="collapse-indicator collapsed"></a>
<!-- online check --> <!-- online check -->
<span title="Online status" class="online-status" id="online-{{$p.UID}}" data-pkey="{{$p.PublicKey}}"><i class="fas fa-unlink"></i></span> <span title="Online status" class="online-status" id="online-{{$p.UID}}" data-pkey="{{$p.PublicKey}}"><i class="fas fa-unlink"></i></span>
</th> </th>
<td>{{$p.Identifier}}</td> <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.PublicKey}}</td>
{{if eq $.Device.Type "server"}}
<td>{{$p.Email}}</td> <td>{{$p.Email}}</td>
{{end}}
{{if eq $.Device.Type "server"}}
<td>{{$p.IPsStr}}</td> <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><span data-toggle="tooltip" data-placement="left" title="" data-original-title="{{$p.LastHandshakeTime}}">{{$p.LastHandshake}}</span></td>
<td> <td>
{{if eq $.Session.IsAdmin true}} {{if eq $.Session.IsAdmin true}}
@@ -134,9 +197,11 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" data-toggle="tab" href="#t1{{$p.UID}}">Personal</a> <a class="nav-link active" data-toggle="tab" href="#t1{{$p.UID}}">Personal</a>
</li> </li>
{{if eq $.Device.Type "server"}}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-toggle="tab" href="#t2{{$p.UID}}">Configuration</a> <a class="nav-link" data-toggle="tab" href="#t2{{$p.UID}}">Configuration</a>
</li> </li>
{{end}}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-toggle="tab" href="#t3{{$p.UID}}">Danger Zone</a> <a class="nav-link" data-toggle="tab" href="#t3{{$p.UID}}">Danger Zone</a>
</li> </li>
@@ -144,15 +209,14 @@
<div class="tab-content" id="tabContent{{$p.UID}}"> <div class="tab-content" id="tabContent{{$p.UID}}">
<div id="t1{{$p.UID}}" class="tab-pane fade active show"> <div id="t1{{$p.UID}}" class="tab-pane fade active show">
<h4>User details</h4> <h4>User details</h4>
{{if not $p.LdapUser}} {{if not $peerUser}}
<p>No LDAP user-information available...</p> <p>No user information available...</p>
{{else}} {{else}}
<ul> <ul>
<li>Firstname: {{$p.LdapUser.Firstname}}</li> <li>Firstname: {{$peerUser.Firstname}}</li>
<li>Lastname: {{$p.LdapUser.Lastname}}</li> <li>Lastname: {{$peerUser.Lastname}}</li>
<li>Phone: {{index $p.LdapUser.RawLdapData.Attributes "telephoneNumber"}}</li> <li>Phone: {{$peerUser.Phone}}</li>
<li>Mail: {{$p.LdapUser.Mail}}</li> <li>Mail: {{$peerUser.Email}}</li>
<li>Department: {{index $p.LdapUser.RawLdapData.Attributes "department"}}</li>
</ul> </ul>
{{end}} {{end}}
<h4>Connection / Traffic</h4> <h4>Connection / Traffic</h4>
@@ -163,22 +227,34 @@
<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> <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}} {{end}}
</div> </div>
{{if eq $.Device.Type "server"}}
<div id="t2{{$p.UID}}" class="tab-pane fade"> <div id="t2{{$p.UID}}" class="tab-pane fade">
<pre>{{$p.Config}}</pre> <pre>{{$p.Config}}</pre>
</div> </div>
{{end}}
<div id="t3{{$p.UID}}" class="tab-pane fade"> <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> <a href="/admin/peer/delete?pkey={{$p.PublicKey}}" class="btn btn-danger" title="Delete peer">Delete</a>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<img class="list-image-large" src="/user/qrcode?pkey={{$p.PublicKey}}"/> {{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>
<div class="col-md-3"> <div class="col-md-3">
<div class="float-right mt-5"> {{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/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> <a href="/admin/peer/email?pkey={{$p.PublicKey}}" class="btn btn-primary" title="Send configuration via Email">Email</a>
</div> </div>
{{end}}
</div> </div>
</div> </div>
</div> </div>
@@ -192,8 +268,10 @@
</div> </div>
{{template "prt_footer.html" .}} {{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script> <script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/jquery.easing.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> <script src="/js/custom.js"></script>
</body> </body>

View File

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

@@ -92,7 +92,7 @@
<th class="column-top" width="210" style="font-size:0pt; line-height:0pt; padding:0; margin:0; font-weight:normal; vertical-align:top;"> <th class="column-top" width="210" style="font-size:0pt; line-height:0pt; padding:0; margin:0; font-weight:normal; vertical-align:top;">
<table width="100%" border="0" cellspacing="0" cellpadding="0"> <table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr> <tr>
<td class="fluid-img" style="font-size:0pt; line-height:0pt; text-align:left;"><img src="cid:{{.QrcodePngName}}" width="210" height="210" border="0" alt="" /></td> <td class="fluid-img" style="font-size:0pt; line-height:0pt; text-align:left;"><img src="cid:{{$.QrcodePngName}}" width="210" height="210" border="0" alt="" /></td>
</tr> </tr>
</table> </table>
</th> </th>
@@ -100,14 +100,14 @@
<th class="column-top" width="280" style="font-size:0pt; line-height:0pt; padding:0; margin:0; font-weight:normal; vertical-align:top;"> <th class="column-top" width="280" style="font-size:0pt; line-height:0pt; padding:0; margin:0; font-weight:normal; vertical-align:top;">
<table width="100%" border="0" cellspacing="0" cellpadding="0"> <table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr> <tr>
{{if .Client.LdapUser}} {{if $.User}}
<td class="h4 pb20" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:20px; line-height:28px; text-align:left; padding-bottom:20px;">Hello {{.Client.LdapUser.Firstname}} {{.Client.LdapUser.Lastname}}</td> <td class="h4 pb20" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:20px; line-height:28px; text-align:left; padding-bottom:20px;">Hello {{$.User.Firstname}} {{$.User.Lastname}}</td>
{{else}} {{else}}
<td class="h4 pb20" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:20px; line-height:28px; text-align:left; padding-bottom:20px;">Hello</td> <td class="h4 pb20" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:20px; line-height:28px; text-align:left; padding-bottom:20px;">Hello</td>
{{end}} {{end}}
</tr> </tr>
<tr> <tr>
<td class="text pb20" style="color:#000000; font-family:Arial,sans-serif; font-size:14px; line-height:26px; text-align:left; padding-bottom:20px;">You or your administrator probably requested this VPN configuration. Scan the Qrcode or open the attached configuration file ({{.Client.GetConfigFileName}}) in the WireGuard VPN client to establish a secure VPN connection.</td> <td class="text pb20" style="color:#000000; font-family:Arial,sans-serif; font-size:14px; line-height:26px; text-align:left; padding-bottom:20px;">You or your administrator probably requested this VPN configuration. Scan the Qrcode or open the attached configuration file ({{$.Peer.GetConfigFileName}}) in the WireGuard VPN client to establish a secure VPN connection.</td>
</tr> </tr>
</table> </table>
</th> </th>
@@ -170,7 +170,7 @@
<td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated using WireGuard Portal.</td> <td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated using WireGuard Portal.</td>
</tr> </tr>
<tr> <tr>
<td class="text-footer2" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="{{.PortalUrl}}" target="_blank" class="link" style="color:#000000; text-decoration:none;"><span class="link" style="color:#000000; text-decoration:none;">Visit WireGuard Portal</span></a></td> <td class="text-footer2" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="{{$.PortalUrl}}" target="_blank" rel="noopener noreferrer" class="link" style="color:#000000; text-decoration:none;"><span class="link" style="color:#000000; text-decoration:none;">Visit WireGuard Portal</span></a></td>
</tr> </tr>
</table> </table>
</td> </td>

View File

@@ -11,8 +11,8 @@
</head> </head>
<body id="page-top"> <body id="page-top">
{{template "prt_nav.html" .}} {{template "prt_nav.html" .}}
<div class="container"> <div class="container">
<div class="text-center mt-5"> <div class="text-center mt-5">
<div class="error mx-auto" data-text="{{.Data.Code}}"> <div class="error mx-auto" data-text="{{.Data.Code}}">
<p class="m-0">{{.Data.Code}}</p> <p class="m-0">{{.Data.Code}}</p>
@@ -20,12 +20,14 @@
<p class="text-dark mb-5 lead">{{.Data.Message}}</p> <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> <p class="text-black-50 mb-0">{{.Data.Details}}</p><a href="/">← Back to Dashboard</a>
</div> </div>
</div> </div>
{{template "prt_footer.html" .}} {{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script> <script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script> <script src="/js/jquery.easing.js"></script>
<script src="/js/jquery.easing.js"></script> <script src="/js/popper.min.js"></script>
<script src="/js/custom.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> </body>
</html> </html>

View File

@@ -13,23 +13,76 @@
<body id="page-top" class="d-flex flex-column min-vh-100"> <body id="page-top" class="d-flex flex-column min-vh-100">
{{template "prt_nav.html" .}} {{template "prt_nav.html" .}}
<div class="container mt-5"> <div class="container mt-2">
<div class="page-header"> <div class="page-header">
<h1>WireGuard VPN Portal</h1> <h1>{{ .Static.WebsiteTitle }}</h1>
</div> </div>
{{template "prt_flashes.html" .}} {{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> <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>
<h3>VPN Profiles and configuration</h3> <div class="jumbotron jumbotron-home">
<p>You can access your personal VPN configurations via your Userprofile: <a href="/user/profile" class="btn btn-primary" title="User-Profile">Open Userprofile</a></p> <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}}
<h3>Client Software</h3>
<p>Installation instructions for client software can be found on the official WireGuard website: <a href="https://www.wireguard.com/install/" title="WireGuard" target="_blank">https://www.wireguard.com/</a> </p>
</div> </div>
{{template "prt_footer.html" .}} {{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script> <script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/jquery.easing.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> <script src="/js/custom.js"></script>
</body> </body>

View File

@@ -13,30 +13,40 @@
<link rel="stylesheet" href="/css/signin.css"> <link rel="stylesheet" href="/css/signin.css">
</head> </head>
<body class="bg-gradient-primary"> <body id="page-top" class="d-flex flex-column min-vh-100">
<div class="container"> <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 mt-5">
<div class="card-header">Please sign in</div> <div class="card-header">Please sign in</div>
<div class="card-body"> <div class="card-body">
<form class="form-signin" method="post"> <form class="form-signin" method="post" name="login">
<input type="hidden" name="_csrf" value="{{.Csrf}}">
<div class="form-group"> <div class="form-group">
<label for="inputUsername">Username</label> <label for="inputUsername">Username</label>
<input type="text" name="username" class="form-control" id="inputUsername" aria-describedby="usernameHelp" placeholder="Enter username"> <input type="text" name="username" class="form-control" id="inputUsername" aria-describedby="usernameHelp" placeholder="Enter username or email">
<small id="usernameHelp" class="form-text text-muted">Please enter your LDAP username, not the email address.</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="inputPassword">Password</label> <label for="inputPassword">Password</label>
<input type="password" name="password" class="form-control" id="inputPassword" placeholder="Password"> <input type="password" name="password" class="form-control" id="inputPassword" placeholder="Password">
</div> </div>
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button> <button class="btn btn-lg btn-primary btn-block mt-5" type="submit">Sign in</button>
{{ if eq .error true }} {{ if eq .error true }}
<hr> <div class="alert alert-danger mt-3" role="alert">
<span class="text-danger">{{.message}}</span> {{.message}}
</div>
{{end}} {{end}}
</form> </form>
<div class="card shadow-lg o-hidden border-0 my-5"> <div class="card o-hidden border-0 my-5">
<div class="card-body p-0"> <div class="card-body p-0">
<a href="/" class="btn btn-white btn-block text-primary btn-user">Go Home</a> <a href="/" class="btn btn-white btn-block text-primary btn-user">Go Home</a>
</div> </div>
@@ -46,8 +56,10 @@
{{template "prt_flashes.html" .}} {{template "prt_flashes.html" .}}
</div> </div>
<script src="/js/jquery.min.js"></script> <script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/jquery.easing.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> <script src="/js/custom.js"></script>
</body> </body>

View File

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

View File

@@ -1,5 +1,5 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-primary"> <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#topNavbar" aria-controls="navbarTogglerDemo03" aria-expanded="false" aria-label="Toggle navigation"> <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> <span class="navbar-toggler-icon"></span>
</button> </button>
@@ -7,19 +7,41 @@
<div id="topNavbar" class="navbar-collapse collapse"> <div id="topNavbar" class="navbar-collapse collapse">
<ul class="navbar-nav mr-auto mt-2 mt-lg-0"> <ul class="navbar-nav mr-auto mt-2 mt-lg-0">
<li class="nav-spacer"></li> <li class="nav-spacer"></li>
{{with eq $.Session.LoggedIn true}}{{with eq $.Session.IsAdmin true}}{{with eq $.Route "/admin/"}} {{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"> <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="{{$.Session.Search}}"> <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> <button class="btn btn-outline-success my-2 my-sm-0" type="submit"><i class="fa fa-search"></i></button>
</form> </form>
{{end}}{{end}}{{end}} {{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> </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}} {{if eq $.Session.LoggedIn true}}
<div class="nav-item dropdown"> <div class="nav-item dropdown">
<a href="#" class="navbar-text dropdown-toggle" data-toggle="dropdown">{{$.Session.Firstname}} {{$.Session.Lastname}} <span class="caret"></span></a> <a href="#" class="navbar-text dropdown-toggle" data-toggle="dropdown">{{$.Session.Firstname}} {{$.Session.Lastname}} <span class="caret"></span></a>
<div class="dropdown-menu"> <div class="dropdown-menu">
{{with eq $.Session.LoggedIn true}}{{with eq $.Session.IsAdmin true}} {{with eq $.Session.LoggedIn true}}{{with eq $.Session.IsAdmin true}}
<a class="dropdown-item" href="/admin/"><i class="fas fa-file-export"></i> Administration</a> <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> <div class="dropdown-divider"></div>
{{end}}{{end}} {{end}}{{end}}
<a class="dropdown-item" href="/user/profile"><i class="fas fa-user"></i> Profile</a> <a class="dropdown-item" href="/user/profile"><i class="fas fa-user"></i> Profile</a>
@@ -34,6 +56,6 @@
</nav> </nav>
{{if not $.Device.IsValid}} {{if not $.Device.IsValid}}
<div class="container"> <div class="container">
<div class="alert alert-danger">Warning: WireGuard Interface is not fully configured! Configurations may be incomplete and non functional!</div> <div class="alert alert-danger">Warning: WireGuard Interface {{$.Device.DeviceName}} is not fully configured! Configurations may be incomplete and non functional!</div>
</div> </div>
{{end}} {{end}}

View File

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

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

@@ -15,32 +15,50 @@
<div class="container mt-5"> <div class="container mt-5">
<h1>WireGuard VPN User-Portal</h1> <h1>WireGuard VPN User-Portal</h1>
<h2 class="mt-4">Your VPN Profiles</h2> <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"> <div class="mt-2 table-responsive">
<table class="table table-sm" id="userTable"> <table class="table table-sm" id="userTable">
<thead> <thead>
<tr> <tr>
<th scope="col" class="list-image-cell"></th><!-- Status and expand --> <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 "id"}}"></i></a></th> <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 "pubKey"}}"></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 "mail"}}"></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 "ip"}}"></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=handshake">Handshake <i class="fa fa-fw {{.Session.GetSortIcon "handshake"}}"></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> </tr>
</thead> </thead>
<tbody> <tbody>
{{range $i, $p :=.Peers}} {{range $i, $p :=.Peers}}
{{$peerUser:=(userForEmail $.Users $p.Email)}}
<tr id="user-pos-{{$i}}" {{if $p.DeactivatedAt}}class="disabled-peer"{{end}}> <tr id="user-pos-{{$i}}" {{if $p.DeactivatedAt}}class="disabled-peer"{{end}}>
<th scope="row" class="list-image-cell"> <th scope="row" class="list-image-cell">
<a href="#{{$p.UID}}" data-toggle="collapse" class="collapse-indicator collapsed"></a> <a href="#{{$p.UID}}" data-toggle="collapse" class="collapse-indicator collapsed"></a>
<!-- online check --> <!-- online check -->
<span class="online-status" id="online-{{$p.UID}}" data-pkey="{{$p.PublicKey}}"><i class="fas fa-unlink"></i></span> <span class="online-status" id="online-{{$p.UID}}" data-pkey="{{$p.PublicKey}}"><i class="fas fa-unlink"></i></span>
</th> </th>
<td>{{$p.Identifier}}</td> <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.PublicKey}}</td>
<td>{{$p.Email}}</td> <td>{{$p.Email}}</td>
<td>{{$p.IPsStr}}</td> <td>{{$p.IPsStr}}</td>
<td><span data-toggle="tooltip" data-placement="left" title="" data-original-title="{{$p.LastHandshakeTime}}">{{$p.LastHandshake}}</span></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>
<tr class="hiddenRow"> <tr class="hiddenRow">
<td colspan="6" class="hiddenCell" style="white-space:nowrap"> <td colspan="6" class="hiddenCell" style="white-space:nowrap">
@@ -58,15 +76,14 @@
<div class="tab-content" id="tabContent{{$p.UID}}"> <div class="tab-content" id="tabContent{{$p.UID}}">
<div id="t1{{$p.UID}}" class="tab-pane fade active show"> <div id="t1{{$p.UID}}" class="tab-pane fade active show">
<h4>User details</h4> <h4>User details</h4>
{{if not $p.LdapUser}} {{if not $peerUser}}
<p>No LDAP user-information available...</p> <p>No user information available...</p>
{{else}} {{else}}
<ul> <ul>
<li>Firstname: {{$p.LdapUser.Firstname}}</li> <li>Firstname: {{$peerUser.Firstname}}</li>
<li>Lastname: {{$p.LdapUser.Lastname}}</li> <li>Lastname: {{$peerUser.Lastname}}</li>
<li>Phone: {{$p.UID}}</li> <li>Phone: {{$peerUser.Phone}}</li>
<li>Mail: {{$p.LdapUser.Mail}}</li> <li>Mail: {{$peerUser.Email}}</li>
<li>Department: {{$p.UID}}</li>
</ul> </ul>
{{end}} {{end}}
<h4>Traffic</h4> <h4>Traffic</h4>
@@ -85,7 +102,13 @@
<img class="list-image-large" src="/user/qrcode?pkey={{$p.PublicKey}}"/> <img class="list-image-large" src="/user/qrcode?pkey={{$p.PublicKey}}"/>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="float-right mt-5"> {{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/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> <a href="/user/email?pkey={{$p.PublicKey}}" class="btn btn-primary" title="Send configuration via Email">Email</a>
</div> </div>
@@ -102,8 +125,10 @@
</div> </div>
{{template "prt_footer.html" .}} {{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script> <script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/jquery.easing.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> <script src="/js/custom.js"></script>
</body> </body>

35
cmd/hc/main.go Normal file
View File

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

@@ -1,19 +1,104 @@
package main package main
import ( import (
"context"
"io"
"os"
"os/signal"
"runtime"
"syscall"
"time"
"git.prolicht.digital/golib/healthcheck"
"github.com/h44z/wg-portal/internal/server" "github.com/h44z/wg-portal/internal/server"
log "github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
func main() { func main() {
log.Infof("Starting WireGuard Portal Server...") _ = setupLogger(logrus.StandardLogger())
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
logrus.Infof("sysinfo: os=%s, arch=%s", runtime.GOOS, runtime.GOARCH)
logrus.Infof("starting WireGuard Portal Server [%s]...", server.Version)
// Context for clean shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// start health check service on port 11223
healthcheck.New(healthcheck.ListenOn("127.0.0.1:11223")).StartWithContext(ctx)
service := server.Server{} service := server.Server{}
if err := service.Setup(); err != nil { if err := service.Setup(ctx); err != nil {
log.Fatalf("Setup failed: %v", err) logrus.Fatalf("setup failed: %v", err)
} }
service.Run() // Attach signal handlers to context
go func() {
osCall := <-c
logrus.Tracef("received system call: %v", osCall)
cancel() // cancel the context
}()
log.Infof("Stopped WireGuard Portal Server...") // Start main process in background
go service.Run()
<-ctx.Done() // Wait until the context gets canceled
// Give goroutines some time to stop gracefully
logrus.Info("stopping WireGuard Portal Server...")
time.Sleep(2 * time.Second)
logrus.Infof("stopped WireGuard Portal Server...")
logrus.Exit(0)
}
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)
case "trace":
logger.SetLevel(logrus.TraceLevel)
}
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
}
logger.SetFormatter(formatter)
return nil
} }

View File

@@ -1,16 +1,19 @@
---
version: '3.6' version: '3.6'
services: services:
wg-portal: wg-portal:
image: h44z/wg-portal:latest image: h44z/wg-portal:1.0.17
container_name: wg-portal container_name: wg-portal
restart: unless-stopped restart: unless-stopped
logging:
options:
max-size: "10m"
max-file: "3"
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
network_mode: "host" network_mode: "host"
volumes: volumes:
- /etc/wireguard:/etc/wireguard - /etc/wireguard:/etc/wireguard
- ./data:/app/data - ./data:/app/data
ports:
- '8123:8123'
environment: environment:
- EXTERNAL_URL=http://localhost:8123 - EXTERNAL_URL=http://localhost:8123

12
efs.go Normal file
View File

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

81
go.mod
View File

@@ -1,21 +1,78 @@
module github.com/h44z/wg-portal module github.com/h44z/wg-portal
go 1.14 go 1.18
require ( require (
github.com/gin-contrib/sessions v0.0.3 git.prolicht.digital/golib/healthcheck v1.1.1
github.com/gin-gonic/gin v1.6.3 github.com/evanphx/json-patch v5.6.0+incompatible
github.com/go-ldap/ldap/v3 v3.2.4 github.com/gin-contrib/sessions v0.0.5
github.com/go-playground/validator/v10 v10.2.0 github.com/gin-gonic/gin v1.8.2
github.com/gorilla/sessions v1.2.1 // indirect github.com/go-ldap/ldap/v3 v3.4.4
github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible github.com/go-playground/validator/v10 v10.11.2
github.com/kelseyhightower/envconfig v1.4.0 github.com/kelseyhightower/envconfig v1.4.0
github.com/milosgajdos/tenus v0.0.3 github.com/milosgajdos/tenus v0.0.3
github.com/sirupsen/logrus v1.7.0 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/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/tatsushid/go-fastping v0.0.0-20160109021039-d7bb493dee3e
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20200609130330-bd2cb7843e1b github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca
gorm.io/driver/sqlite v1.1.3 github.com/xhit/go-simple-mail/v2 v2.13.0
gorm.io/gorm v1.20.5 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
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/dchest/uniuri v1.2.0 // indirect
github.com/docker/libcontainer v2.2.1+incompatible // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.4 // 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-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-test/deep v1.0.8 // indirect
github.com/goccy/go-json v0.10.0 // indirect
github.com/google/go-cmp v0.5.9 // 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/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/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/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/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b // 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
) )

316
go.sum Normal file
View File

@@ -0,0 +1,316 @@
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/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/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/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/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=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/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/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=
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
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/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-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/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-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=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
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-sql-driver/mysql v1.7.0/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/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
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/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=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
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/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=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
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/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=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
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/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
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=
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/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc=
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/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/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/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/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/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/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.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/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/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/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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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-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/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/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-20190620200207-3b0461eec859/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.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
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/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-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-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/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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
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/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/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=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
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=
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=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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/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.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=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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=

10
hooks/build Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
# File needs to be called /hooks/build relative to the Dockerfile.
# Some environment variables are injected into the build hook, see: https://docs.docker.com/docker-hub/builds/advanced/.
GIT_SHORT_HASH=$(echo $SOURCE_COMMIT | cut -c1-7)
echo "Build hook running for git hash $GIT_SHORT_HASH"
docker build --build-arg BUILD_IDENTIFIER=$DOCKER_TAG \
--build-arg BUILD_VERSION=$GIT_SHORT_HASH \
-t $IMAGE_NAME .

View File

@@ -0,0 +1,32 @@
package authentication
import (
"github.com/gin-gonic/gin"
)
// AuthContext contains all information that the AuthProvider needs to perform the authentication.
type AuthContext struct {
Username string // email or username
Password string
Callback string // callback for OIDC
}
type AuthProviderType string
const (
AuthProviderTypePassword AuthProviderType = "password"
AuthProviderTypeOauth AuthProviderType = "oauth"
)
// AuthProvider is a interface that can be implemented by different authentication providers like LDAP, OAUTH, ...
type AuthProvider interface {
GetName() string
GetType() AuthProviderType
GetPriority() int // lower number = higher priority
Login(*AuthContext) (string, error)
Logout(*AuthContext) error
GetUserModel(*AuthContext) (*User, error)
SetupRoutes(routes *gin.RouterGroup)
}

View File

@@ -0,0 +1,210 @@
package ldap
import (
"crypto/tls"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
"github.com/h44z/wg-portal/internal/authentication"
ldapconfig "github.com/h44z/wg-portal/internal/ldap"
"github.com/h44z/wg-portal/internal/users"
"github.com/pkg/errors"
)
// Provider implements a password login method for an LDAP backend.
type Provider struct {
config *ldapconfig.Config
}
func New(cfg *ldapconfig.Config) (*Provider, error) {
p := &Provider{
config: cfg,
}
// test ldap connectivity
client, err := p.open()
if err != nil {
return nil, errors.Wrap(err, "unable to open ldap connection")
}
defer p.close(client)
return p, nil
}
// GetName return provider name
func (Provider) GetName() string {
return string(users.UserSourceLdap)
}
// GetType return provider type
func (Provider) GetType() authentication.AuthProviderType {
return authentication.AuthProviderTypePassword
}
// GetPriority return provider priority
func (Provider) GetPriority() int {
return 1 // LDAP password provider
}
func (provider Provider) SetupRoutes(_ *gin.RouterGroup) {
// nothing here
}
func (provider Provider) Login(ctx *authentication.AuthContext) (string, error) {
username := strings.ToLower(ctx.Username)
password := ctx.Password
// Validate input
if strings.Trim(username, " ") == "" || strings.Trim(password, " ") == "" {
return "", errors.New("empty username or password")
}
client, err := provider.open()
if err != nil {
return "", errors.Wrap(err, "unable to open ldap connection")
}
defer provider.close(client)
// Search for the given username
attrs := []string{"dn", provider.config.EmailAttribute}
loginFilter := strings.Replace(provider.config.LoginFilter, "{{login_identifier}}", username, -1)
searchRequest := ldap.NewSearchRequest(
provider.config.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
loginFilter,
attrs,
nil,
)
sr, err := client.Search(searchRequest)
if err != nil {
return "", errors.Wrap(err, "unable to find user in ldap")
}
if len(sr.Entries) != 1 {
return "", errors.Errorf("invalid amount of ldap entries (%d)", len(sr.Entries))
}
// Bind as the user to verify their password
userDN := sr.Entries[0].DN
err = client.Bind(userDN, password)
if err != nil {
return "", errors.Wrapf(err, "invalid credentials")
}
return sr.Entries[0].GetAttributeValue(provider.config.EmailAttribute), nil
}
func (provider Provider) Logout(_ *authentication.AuthContext) error {
return nil // nothing here
}
func (provider Provider) GetUserModel(ctx *authentication.AuthContext) (*authentication.User, error) {
username := strings.ToLower(ctx.Username)
// Validate input
if strings.Trim(username, " ") == "" {
return nil, errors.New("empty username")
}
client, err := provider.open()
if err != nil {
return nil, errors.Wrap(err, "unable to open ldap connection")
}
defer provider.close(client)
// Search for the given username
attrs := []string{"dn", provider.config.EmailAttribute, provider.config.FirstNameAttribute, provider.config.LastNameAttribute,
provider.config.PhoneAttribute, provider.config.GroupMemberAttribute}
loginFilter := strings.Replace(provider.config.LoginFilter, "{{login_identifier}}", username, -1)
searchRequest := ldap.NewSearchRequest(
provider.config.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
loginFilter,
attrs,
nil,
)
sr, err := client.Search(searchRequest)
if err != nil {
return nil, errors.Wrap(err, "unable to find user in ldap")
}
if len(sr.Entries) != 1 {
return nil, errors.Wrapf(err, "invalid amount of ldap entries (%d)", len(sr.Entries))
}
user := &authentication.User{
Firstname: sr.Entries[0].GetAttributeValue(provider.config.FirstNameAttribute),
Lastname: sr.Entries[0].GetAttributeValue(provider.config.LastNameAttribute),
Email: sr.Entries[0].GetAttributeValue(provider.config.EmailAttribute),
Phone: sr.Entries[0].GetAttributeValue(provider.config.PhoneAttribute),
IsAdmin: false,
}
for _, group := range sr.Entries[0].GetAttributeValues(provider.config.GroupMemberAttribute) {
if group == provider.config.AdminLdapGroup {
user.IsAdmin = true
break
}
}
return user, nil
}
func (provider Provider) open() (*ldap.Conn, error) {
var tlsConfig *tls.Config
if provider.config.LdapCertConn {
certPlain, err := os.ReadFile(provider.config.LdapTlsCert)
if err != nil {
return nil, errors.WithMessage(err, "failed to load the certificate")
}
key, err := os.ReadFile(provider.config.LdapTlsKey)
if err != nil {
return nil, errors.WithMessage(err, "failed to load the key")
}
certX509, err := tls.X509KeyPair(certPlain, key)
if err != nil {
return nil, errors.WithMessage(err, "failed X509")
}
tlsConfig = &tls.Config{Certificates: []tls.Certificate{certX509}}
} else {
tlsConfig = &tls.Config{InsecureSkipVerify: !provider.config.CertValidation}
}
conn, err := ldap.DialURL(provider.config.URL, ldap.DialWithTLSConfig(tlsConfig))
if err != nil {
return nil, errors.WithMessage(err, "failed to connect to LDAP")
}
if provider.config.StartTLS {
// Reconnect with TLS
err = conn.StartTLS(tlsConfig)
if err != nil {
return nil, errors.WithMessage(err, "failed to start TLS session")
}
}
err = conn.Bind(provider.config.BindUser, provider.config.BindPass)
if err != nil {
return nil, errors.WithMessage(err, "failed to bind user")
}
return conn, nil
}
func (provider Provider) close(conn *ldap.Conn) {
if conn != nil {
conn.Close()
}
}

View File

@@ -0,0 +1,195 @@
package password
import (
"fmt"
"math/rand"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/authentication"
"github.com/h44z/wg-portal/internal/common"
"github.com/h44z/wg-portal/internal/users"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
// Provider implements a password login method for a database backend.
type Provider struct {
db *gorm.DB
}
func New(cfg *common.DatabaseConfig) (*Provider, error) {
p := &Provider{}
var err error
p.db, err = common.GetDatabaseForConfig(cfg)
if err != nil {
return nil, errors.Wrapf(err, "failed to setup authentication database %s", cfg.Database)
}
return p, nil
}
// GetName return provider name
func (Provider) GetName() string {
return string(users.UserSourceDatabase)
}
// GetType return provider type
func (Provider) GetType() authentication.AuthProviderType {
return authentication.AuthProviderTypePassword
}
// GetPriority return provider priority
func (Provider) GetPriority() int {
return 0 // DB password provider = highest prio
}
func (provider Provider) SetupRoutes(_ *gin.RouterGroup) {
// nothing here
}
func (provider Provider) Login(ctx *authentication.AuthContext) (string, error) {
username := strings.ToLower(ctx.Username)
password := ctx.Password
// Validate input
if strings.Trim(username, " ") == "" || strings.Trim(password, " ") == "" {
return "", errors.New("empty username or password")
}
// Authenticate against the users database
user := users.User{}
provider.db.Where("email = ?", username).First(&user)
if user.Email == "" {
return "", errors.New("invalid username")
}
// Compare the stored hashed password, with the hashed version of the password that was received
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return "", errors.New("invalid password")
}
return user.Email, nil
}
func (provider Provider) Logout(_ *authentication.AuthContext) error {
return nil // nothing here
}
func (provider Provider) GetUserModel(ctx *authentication.AuthContext) (*authentication.User, error) {
username := strings.ToLower(ctx.Username)
// Validate input
if strings.Trim(username, " ") == "" {
return nil, errors.New("empty username")
}
// Fetch usermodel from users database
user := users.User{}
provider.db.Where("email = ?", username).First(&user)
if user.Email != username {
return nil, errors.New("invalid or disabled username")
}
return &authentication.User{
Email: user.Email,
IsAdmin: user.IsAdmin,
Firstname: user.Firstname,
Lastname: user.Lastname,
Phone: user.Phone,
}, nil
}
func (provider Provider) InitializeAdmin(email, password string) error {
email = strings.ToLower(email)
if !emailRegex.MatchString(email) {
return errors.New("admin username must be an email address")
}
admin := users.User{}
provider.db.Unscoped().Where("email = ?", email).FirstOrInit(&admin)
// newly created admin
if admin.Email != email {
// For security reasons a random admin password will be generated if the default one is still in use!
if password == "wgportal" {
password = generateRandomPassword()
fmt.Println("#############################################")
fmt.Println("Administrator credentials:")
fmt.Println(" Email: ", email)
fmt.Println(" Password: ", password)
fmt.Println()
fmt.Println("This information will only be displayed once!")
fmt.Println("#############################################")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return errors.Wrap(err, "failed to hash admin password")
}
admin.Email = email
admin.Password = users.PrivateString(hashedPassword)
admin.Firstname = "WireGuard"
admin.Lastname = "Administrator"
admin.CreatedAt = time.Now()
admin.UpdatedAt = time.Now()
admin.IsAdmin = true
admin.Source = users.UserSourceDatabase
res := provider.db.Create(admin)
if res.Error != nil {
return errors.Wrapf(res.Error, "failed to create admin %s", admin.Email)
}
}
// update/reactivate
if !admin.IsAdmin || admin.DeletedAt.Valid {
// For security reasons a random admin password will be generated if the default one is still in use!
if password == "wgportal" {
password = generateRandomPassword()
fmt.Println("#############################################")
fmt.Println("Administrator credentials:")
fmt.Println(" Email: ", email)
fmt.Println(" Password: ", password)
fmt.Println()
fmt.Println("This information will only be displayed once!")
fmt.Println("#############################################")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return errors.Wrap(err, "failed to hash admin password")
}
admin.Password = users.PrivateString(hashedPassword)
admin.IsAdmin = true
admin.UpdatedAt = time.Now()
res := provider.db.Save(admin)
if res.Error != nil {
return errors.Wrapf(res.Error, "failed to update admin %s", admin.Email)
}
}
return nil
}
func generateRandomPassword() string {
rand.Seed(time.Now().Unix())
var randPassword strings.Builder
charSet := "abcdedfghijklmnopqrstABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$"
for i := 0; i < 12; i++ {
random := rand.Intn(len(charSet))
randPassword.WriteString(string(charSet[random]))
}
return randPassword.String()
}

View File

@@ -0,0 +1,12 @@
package authentication
// User represents the data that can be retrieved from authentication backends.
type User struct {
Email string
IsAdmin bool
// optional fields
Firstname string
Lastname string
Phone string
}

View File

@@ -1,120 +0,0 @@
package common
import (
"errors"
"os"
"reflect"
"runtime"
"github.com/h44z/wg-portal/internal/wireguard"
"github.com/h44z/wg-portal/internal/ldap"
"github.com/kelseyhightower/envconfig"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
var ErrInvalidSpecification = errors.New("specification must be a struct pointer")
// LoadConfigFile parses yaml files. It uses to yaml annotation to store the data in a struct.
func loadConfigFile(cfg interface{}, filename string) error {
s := reflect.ValueOf(cfg)
if s.Kind() != reflect.Ptr {
return ErrInvalidSpecification
}
s = s.Elem()
if s.Kind() != reflect.Struct {
return ErrInvalidSpecification
}
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
decoder := yaml.NewDecoder(f)
err = decoder.Decode(cfg)
if err != nil {
return err
}
return nil
}
// LoadConfigEnv processes envconfig annotations and loads environment variables to the given configuration struct.
func loadConfigEnv(cfg interface{}) error {
err := envconfig.Process("", cfg)
if err != nil {
return err
}
return nil
}
type Config struct {
Core struct {
ListeningAddress string `yaml:"listeningAddress" envconfig:"LISTENING_ADDRESS"`
ExternalUrl string `yaml:"externalUrl" envconfig:"EXTERNAL_URL"`
Title string `yaml:"title" envconfig:"WEBSITE_TITLE"`
CompanyName string `yaml:"company" envconfig:"COMPANY_NAME"`
MailFrom string `yaml:"mailfrom" envconfig:"MAIL_FROM"`
AdminUser string `yaml:"adminUser" envconfig:"ADMIN_USER"` // optional, non LDAP admin user
AdminPassword string `yaml:"adminPass" envconfig:"ADMIN_PASS"`
DatabasePath string `yaml:"database" envconfig:"DATABASE_PATH"`
EditableKeys bool `yaml:"editableKeys" envconfig:"EDITABLE_KEYS"`
CreateInterfaceOnLogin bool `yaml:"createOnLogin" envconfig:"CREATE_INTERFACE_ON_LOGIN"`
SyncLdapStatus bool `yaml:"syncLdapStatus" envconfig:"SYNC_LDAP_STATUS"` // disable account if disabled in ldap
} `yaml:"core"`
Email MailConfig `yaml:"email"`
LDAP ldap.Config `yaml:"ldap"`
WG wireguard.Config `yaml:"wg"`
AdminLdapGroup string `yaml:"adminLdapGroup" envconfig:"ADMIN_LDAP_GROUP"`
}
func NewConfig() *Config {
cfg := &Config{}
// Default config
cfg.Core.ListeningAddress = ":8123"
cfg.Core.Title = "WireGuard VPN"
cfg.Core.CompanyName = "WireGuard Portal"
cfg.Core.ExternalUrl = "http://localhost:8123"
cfg.Core.MailFrom = "WireGuard VPN <noreply@company.com>"
cfg.Core.AdminUser = "" // non-ldap admin access is disabled by default
cfg.Core.AdminPassword = ""
cfg.Core.DatabasePath = "data/wg_portal.db"
cfg.LDAP.URL = "ldap://srv-ad01.company.local:389"
cfg.LDAP.BaseDN = "DC=COMPANY,DC=LOCAL"
cfg.LDAP.StartTLS = true
cfg.LDAP.BindUser = "company\\\\ldap_wireguard"
cfg.LDAP.BindPass = "SuperSecret"
cfg.WG.DeviceName = "wg0"
cfg.WG.WireGuardConfig = "/etc/wireguard/wg0.conf"
cfg.WG.ManageIPAddresses = true
cfg.AdminLdapGroup = "CN=WireGuardAdmins,OU=_O_IT,DC=COMPANY,DC=LOCAL"
cfg.Email.Host = "127.0.0.1"
cfg.Email.Port = 25
// Load config from file and environment
cfgFile, ok := os.LookupEnv("CONFIG_FILE")
if !ok {
cfgFile = "config.yml" // Default config file
}
err := loadConfigFile(cfg, cfgFile)
if err != nil {
log.Warnf("unable to load config.yml file: %v, using default configuration...", err)
}
err = loadConfigEnv(cfg)
if err != nil {
log.Warnf("unable to load environment config: %v", err)
}
if cfg.WG.ManageIPAddresses && runtime.GOOS != "linux" {
log.Warnf("Managing IP addresses only works on linux! Feature disabled.")
cfg.WG.ManageIPAddresses = false
}
return cfg
}

195
internal/common/db.go Normal file
View File

@@ -0,0 +1,195 @@
package common
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func init() {
migrations = append(migrations, Migration{
version: "1.0.7",
migrateFn: func(db *gorm.DB) error {
if err := db.Exec("UPDATE users SET email = LOWER(email)").Error; err != nil {
return errors.Wrap(err, "failed to convert user emails to lower case")
}
if err := db.Exec("UPDATE peers SET email = LOWER(email)").Error; err != nil {
return errors.Wrap(err, "failed to convert peer emails to lower case")
}
logrus.Infof("upgraded database format to version 1.0.7")
return nil
},
})
migrations = append(migrations, Migration{
version: "1.0.8",
migrateFn: func(db *gorm.DB) error {
logrus.Infof("upgraded database format to version 1.0.8")
return nil
},
})
migrations = append(migrations, Migration{
version: "1.0.9",
migrateFn: func(db *gorm.DB) error {
if db.Dialector.Name() != (sqlite.Dialector{}).Name() {
logrus.Infof("upgraded database format to version 1.0.9")
return nil // only perform migration for sqlite
}
type sqlIndex struct {
Name string `gorm:"column:name"`
Table string `gorm:"column:tbl_name"`
}
var indices []sqlIndex
if err := db.Raw("SELECT name, tbl_name FROM sqlite_master WHERE type == 'index'").Scan(&indices).Error; err != nil {
return errors.Wrap(err, "failed to fetch indices")
}
for _, index := range indices {
if index.Table != "devices" && index.Table != "peers" && index.Table != "users" {
continue
}
if strings.Contains(index.Name, "autoindex") {
continue
}
if err := db.Exec("DROP INDEX " + index.Name).Error; err != nil {
return errors.Wrap(err, "failed to drop index "+index.Name)
}
}
logrus.Infof("upgraded database format to version 1.0.9")
return nil
},
})
}
type SupportedDatabase string
const (
SupportedDatabaseMySQL SupportedDatabase = "mysql"
SupportedDatabaseSQLite SupportedDatabase = "sqlite"
)
type DatabaseConfig struct {
Typ SupportedDatabase `yaml:"typ" envconfig:"DATABASE_TYPE"` //mysql or sqlite
Host string `yaml:"host" envconfig:"DATABASE_HOST"`
Port int `yaml:"port" envconfig:"DATABASE_PORT"`
Database string `yaml:"database" envconfig:"DATABASE_NAME"` // On SQLite: the database file-path, otherwise the database name
User string `yaml:"user" envconfig:"DATABASE_USERNAME"`
Password string `yaml:"password" envconfig:"DATABASE_PASSWORD"`
}
func GetDatabaseForConfig(cfg *DatabaseConfig) (db *gorm.DB, err error) {
switch cfg.Typ {
case SupportedDatabaseSQLite:
if _, err = os.Stat(filepath.Dir(cfg.Database)); os.IsNotExist(err) {
if err = os.MkdirAll(filepath.Dir(cfg.Database), 0700); err != nil {
return
}
}
db, err = gorm.Open(sqlite.Open(cfg.Database), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
if err != nil {
return
}
case SupportedDatabaseMySQL:
connectionString := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database)
db, err = gorm.Open(mysql.Open(connectionString), &gorm.Config{})
if err != nil {
return
}
sqlDB, _ := db.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, errors.Wrap(err, "failed to ping mysql authentication database")
}
}
// Enable Logger (logrus)
logCfg := logger.Config{
SlowThreshold: time.Second, // all slower than one second
Colorful: false,
LogLevel: logger.Silent, // default: log nothing
}
if logrus.StandardLogger().GetLevel() == logrus.TraceLevel {
logCfg.LogLevel = logger.Info
logCfg.SlowThreshold = 500 * time.Millisecond // all slower than half a second
}
db.Config.Logger = logger.New(logrus.StandardLogger(), logCfg)
return
}
type DatabaseMigrationInfo struct {
Version string `gorm:"primaryKey"`
Applied time.Time
}
type Migration struct {
version string
migrateFn func(db *gorm.DB) error
}
var migrations []Migration
func MigrateDatabase(db *gorm.DB, version string) error {
if err := db.AutoMigrate(&DatabaseMigrationInfo{}); err != nil {
return errors.Wrap(err, "failed to migrate version database")
}
existingMigration := DatabaseMigrationInfo{}
db.Where("version = ?", version).FirstOrInit(&existingMigration)
if existingMigration.Version == "" {
lastVersion := DatabaseMigrationInfo{}
db.Order("applied desc, version desc").FirstOrInit(&lastVersion)
if lastVersion.Version == "" {
// fresh database, no migrations to apply
res := db.Create(&DatabaseMigrationInfo{
Version: version,
Applied: time.Now(),
})
if res.Error != nil {
return errors.Wrapf(res.Error, "failed to write version %s to database", version)
}
return nil
}
sort.Slice(migrations, func(i, j int) bool {
return migrations[i].version < migrations[j].version
})
for _, migration := range migrations {
if migration.version > lastVersion.Version {
if err := migration.migrateFn(db); err != nil {
return errors.Wrapf(err, "failed to migrate to version %s", migration.version)
}
res := db.Create(&DatabaseMigrationInfo{
Version: migration.version,
Applied: time.Now(),
})
if res.Error != nil {
return errors.Wrapf(res.Error, "failed to write version %s to database", migration.version)
}
}
}
}
return nil
}

View File

@@ -3,20 +3,37 @@ package common
import ( import (
"crypto/tls" "crypto/tls"
"io" "io"
"net/smtp" "time"
"strconv"
"strings"
"github.com/jordan-wright/email" "github.com/pkg/errors"
mail "github.com/xhit/go-simple-mail/v2"
)
type MailEncryption string
const (
MailEncryptionNone MailEncryption = "none"
MailEncryptionTLS MailEncryption = "tls"
MailEncryptionStartTLS MailEncryption = "starttls"
)
type MailAuthType string
const (
MailAuthPlain MailAuthType = "plain"
MailAuthLogin MailAuthType = "login"
MailAuthCramMD5 MailAuthType = "crammd5"
) )
type MailConfig struct { type MailConfig struct {
Host string `yaml:"host" envconfig:"EMAIL_HOST"` Host string `yaml:"host" envconfig:"EMAIL_HOST"`
Port int `yaml:"port" envconfig:"EMAIL_PORT"` Port int `yaml:"port" envconfig:"EMAIL_PORT"`
TLS bool `yaml:"tls" envconfig:"EMAIL_TLS"` TLS bool `yaml:"tls" envconfig:"EMAIL_TLS"` // Deprecated, use MailConfig.Encryption instead.
Encryption MailEncryption `yaml:"encryption" envconfig:"EMAIL_ENCRYPTION"`
CertValidation bool `yaml:"certcheck" envconfig:"EMAIL_CERT_VALIDATION"` CertValidation bool `yaml:"certcheck" envconfig:"EMAIL_CERT_VALIDATION"`
Username string `yaml:"user" envconfig:"EMAIL_USERNAME"` Username string `yaml:"user" envconfig:"EMAIL_USERNAME"`
Password string `yaml:"pass" envconfig:"EMAIL_PASSWORD"` Password string `yaml:"pass" envconfig:"EMAIL_PASSWORD"`
AuthType MailAuthType `yaml:"auth" envconfig:"EMAIL_AUTHTYPE"`
} }
type MailAttachment struct { type MailAttachment struct {
@@ -26,54 +43,74 @@ type MailAttachment struct {
Embedded bool Embedded bool
} }
// SendEmailWithAttachments sends a mail with attachments. // SendEmailWithAttachments sends a mail with optional attachments.
func SendEmailWithAttachments(cfg MailConfig, sender, replyTo, subject, body string, htmlBody string, receivers []string, attachments []MailAttachment) error { func SendEmailWithAttachments(cfg MailConfig, sender, replyTo, subject, body, htmlBody string, receivers []string, attachments []MailAttachment) error {
e := email.NewEmail() srv := mail.NewSMTPClient()
srv.ConnectTimeout = 30 * time.Second
srv.SendTimeout = 30 * time.Second
srv.Host = cfg.Host
srv.Port = cfg.Port
srv.Username = cfg.Username
srv.Password = cfg.Password
// TODO: remove this once the deprecated MailConfig.TLS config option has been removed
if cfg.TLS {
cfg.Encryption = MailEncryptionStartTLS
}
switch cfg.Encryption {
case MailEncryptionTLS:
srv.Encryption = mail.EncryptionSSLTLS
case MailEncryptionStartTLS:
srv.Encryption = mail.EncryptionSTARTTLS
default: // MailEncryptionNone
srv.Encryption = mail.EncryptionNone
}
srv.TLSConfig = &tls.Config{ServerName: srv.Host, InsecureSkipVerify: !cfg.CertValidation}
switch cfg.AuthType {
case MailAuthPlain:
srv.Authentication = mail.AuthPlain
case MailAuthLogin:
srv.Authentication = mail.AuthLogin
case MailAuthCramMD5:
srv.Authentication = mail.AuthCRAMMD5
}
client, err := srv.Connect()
if err != nil {
return errors.Wrap(err, "failed to connect via SMTP")
}
hostname := cfg.Host + ":" + strconv.Itoa(cfg.Port)
subject = strings.Trim(subject, "\n\r\t")
sender = strings.Trim(sender, "\n\r\t")
replyTo = strings.Trim(replyTo, "\n\r\t")
if replyTo == "" { if replyTo == "" {
replyTo = sender replyTo = sender
} }
var auth smtp.Auth email := mail.NewMSG()
if cfg.Username == "" { email.SetFrom(sender).
auth = nil AddTo(receivers...).
} else { SetReplyTo(replyTo).
// Set up authentication information. SetSubject(subject)
auth = smtp.PlainAuth(
"",
cfg.Username,
cfg.Password,
cfg.Host,
)
}
// Set email data. email.SetBody(mail.TextHTML, htmlBody)
e.From = sender email.AddAlternative(mail.TextPlain, body)
e.To = receivers
e.ReplyTo = []string{replyTo}
e.Subject = subject
e.Text = []byte(body)
if htmlBody != "" {
e.HTML = []byte(htmlBody)
}
for _, attachment := range attachments { for _, attachment := range attachments {
a, err := e.Attach(attachment.Data, attachment.Name, attachment.ContentType) attachmentData, err := io.ReadAll(attachment.Data)
if err != nil { if err != nil {
return err return errors.Wrapf(err, "failed to read attachment data for %s", attachment.Name)
} }
if attachment.Embedded { if attachment.Embedded {
a.HTMLRelated = true email.AddInlineData(attachmentData, attachment.Name, attachment.ContentType)
} else {
email.AddAttachmentData(attachmentData, attachment.Name, attachment.ContentType)
} }
} }
if cfg.CertValidation { // Call Send and pass the client
return e.Send(hostname, auth) err = email.Send(client)
} else { if err != nil {
return e.SendWithStartTLS(hostname, auth, &tls.Config{InsecureSkipVerify: true}) return errors.Wrapf(err, "failed to send email")
} }
return nil
} }

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net" "net"
"strings" "strings"
"time"
) )
// BroadcastAddr returns the last address in the given network, or the broadcast address. // BroadcastAddr returns the last address in the given network, or the broadcast address.
@@ -40,6 +41,8 @@ func IsIPv6(address string) bool {
return ip.To4() == nil return ip.To4() == nil
} }
// ParseStringList converts a comma separated string into a list of strings.
// It also trims spaces from each element of the list.
func ParseStringList(lst string) []string { func ParseStringList(lst string) []string {
tokens := strings.Split(lst, ",") tokens := strings.Split(lst, ",")
validatedTokens := make([]string, 0, len(tokens)) validatedTokens := make([]string, 0, len(tokens))
@@ -53,10 +56,21 @@ func ParseStringList(lst string) []string {
return validatedTokens return validatedTokens
} }
// ListToString converts a list of strings into a comma separated string.
func ListToString(lst []string) string { func ListToString(lst []string) string {
return strings.Join(lst, ", ") return strings.Join(lst, ", ")
} }
// ListContains checks if a needle exists in the given list.
func ListContains(lst []string, needle string) bool {
for _, entry := range lst {
if entry == needle {
return true
}
}
return false
}
// https://yourbasic.org/golang/formatting-byte-size-to-human-readable-format/ // https://yourbasic.org/golang/formatting-byte-size-to-human-readable-format/
func ByteCountSI(b int64) string { func ByteCountSI(b int64) string {
const unit = 1000 const unit = 1000
@@ -71,3 +85,11 @@ func ByteCountSI(b int64) string {
return fmt.Sprintf("%.1f %cB", return fmt.Sprintf("%.1f %cB",
float64(b)/float64(div), "kMGTPE"[exp]) float64(b)/float64(div), "kMGTPE"[exp])
} }
func FormatDateHTML(t *time.Time) string {
if t == nil {
return ""
}
return t.Format("2006-01-02")
}

View File

@@ -1,94 +0,0 @@
package ldap
import (
"crypto/tls"
"fmt"
"github.com/go-ldap/ldap/v3"
)
type Authentication struct {
Cfg *Config
}
func NewAuthentication(config Config) Authentication {
a := Authentication{
Cfg: &config,
}
return a
}
func (a Authentication) open() (*ldap.Conn, error) {
conn, err := ldap.DialURL(a.Cfg.URL)
if err != nil {
return nil, err
}
if a.Cfg.StartTLS {
// Reconnect with TLS
err = conn.StartTLS(&tls.Config{InsecureSkipVerify: true})
if err != nil {
return nil, err
}
}
err = conn.Bind(a.Cfg.BindUser, a.Cfg.BindPass)
if err != nil {
return nil, err
}
return conn, nil
}
func (a Authentication) close(conn *ldap.Conn) {
if conn != nil {
conn.Close()
}
}
func (a Authentication) CheckLogin(username, password string) bool {
return a.CheckCustomLogin("sAMAccountName", username, password)
}
func (a Authentication) CheckCustomLogin(userIdentifier, username, password string) bool {
client, err := a.open()
if err != nil {
return false
}
defer a.close(client)
// Search for the given username
searchRequest := ldap.NewSearchRequest(
a.Cfg.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&(objectClass=organizationalPerson)(%s=%s))", userIdentifier, username),
[]string{"dn", "userAccountControl"},
nil,
)
sr, err := client.Search(searchRequest)
if err != nil {
return false
}
if len(sr.Entries) != 1 {
return false
}
userDN := sr.Entries[0].DN
// Check if user is disabled, if so deny login
uac := sr.Entries[0].GetAttributeValue("userAccountControl")
if uac != "" && IsLdapUserDisabled(uac) {
return false
}
// Bind as the user to verify their password
err = client.Bind(userDN, password)
if err != nil {
return false
}
return true
}

37
internal/ldap/config.go Normal file
View File

@@ -0,0 +1,37 @@
package ldap
import (
gldap "github.com/go-ldap/ldap/v3"
)
type Type string
const (
TypeActiveDirectory Type = "AD"
TypeOpenLDAP Type = "OpenLDAP"
)
type Config struct {
URL string `yaml:"url" envconfig:"LDAP_URL"`
StartTLS bool `yaml:"startTLS" envconfig:"LDAP_STARTTLS"`
CertValidation bool `yaml:"certcheck" envconfig:"LDAP_CERT_VALIDATION"`
BaseDN string `yaml:"dn" envconfig:"LDAP_BASEDN"`
BindUser string `yaml:"user" envconfig:"LDAP_USER"`
BindPass string `yaml:"pass" envconfig:"LDAP_PASSWORD"`
EmailAttribute string `yaml:"attrEmail" envconfig:"LDAP_ATTR_EMAIL"`
FirstNameAttribute string `yaml:"attrFirstname" envconfig:"LDAP_ATTR_FIRSTNAME"`
LastNameAttribute string `yaml:"attrLastname" envconfig:"LDAP_ATTR_LASTNAME"`
PhoneAttribute string `yaml:"attrPhone" envconfig:"LDAP_ATTR_PHONE"`
GroupMemberAttribute string `yaml:"attrGroups" envconfig:"LDAP_ATTR_GROUPS"`
LoginFilter string `yaml:"loginFilter" envconfig:"LDAP_LOGIN_FILTER"` // {{login_identifier}} gets replaced with the login email address
SyncFilter string `yaml:"syncFilter" envconfig:"LDAP_SYNC_FILTER"`
SyncGroupFilter string `yaml:"syncGroupFilter" envconfig:"LDAP_SYNC_GROUP_FILTER"`
AdminLdapGroup string `yaml:"adminGroup" envconfig:"LDAP_ADMIN_GROUP"` // Members of this group receive admin rights in WG-Portal
AdminLdapGroup_ *gldap.DN `yaml:"-"`
EveryoneAdmin bool `yaml:"everyoneAdmin" envconfig:"LDAP_EVERYONE_ADMIN"`
LdapCertConn bool `yaml:"ldapCertConn" envconfig:"LDAP_CERT_CONN"`
LdapTlsCert string `yaml:"ldapTlsCert" envconfig:"LDAPTLS_CERT"`
LdapTlsKey string `yaml:"ldapTlsKey" envconfig:"LDAPTLS_KEY"`
}

View File

@@ -1,9 +1,137 @@
package ldap package ldap
type Config struct { import (
URL string `yaml:"url" envconfig:"LDAP_URL"` "crypto/tls"
StartTLS bool `yaml:"startTLS" envconfig:"LDAP_STARTTLS"` "os"
BaseDN string `yaml:"dn" envconfig:"LDAP_BASEDN"`
BindUser string `yaml:"user" envconfig:"LDAP_USER"` "github.com/go-ldap/ldap/v3"
BindPass string `yaml:"pass" envconfig:"LDAP_PASSWORD"` "github.com/pkg/errors"
)
type ObjectType int
const (
Users ObjectType = iota
Groups
)
type RawLdapData struct {
DN string
Attributes map[string]string
RawAttributes map[string][][]byte
}
func Open(cfg *Config) (*ldap.Conn, error) {
var tlsConfig *tls.Config
if cfg.LdapCertConn {
certPlain, err := os.ReadFile(cfg.LdapTlsCert)
if err != nil {
return nil, errors.WithMessage(err, "failed to load the certificate")
}
key, err := os.ReadFile(cfg.LdapTlsKey)
if err != nil {
return nil, errors.WithMessage(err, "failed to load the key")
}
certX509, err := tls.X509KeyPair(certPlain, key)
if err != nil {
return nil, errors.WithMessage(err, "failed X509")
}
tlsConfig = &tls.Config{Certificates: []tls.Certificate{certX509}}
} else {
tlsConfig = &tls.Config{InsecureSkipVerify: !cfg.CertValidation}
}
conn, err := ldap.DialURL(cfg.URL, ldap.DialWithTLSConfig(tlsConfig))
if err != nil {
return nil, errors.Wrap(err, "failed to connect to LDAP")
}
if cfg.StartTLS {
// Reconnect with TLS
err = conn.StartTLS(tlsConfig)
if err != nil {
return nil, errors.Wrap(err, "failed to star TLS on connection")
}
}
err = conn.Bind(cfg.BindUser, cfg.BindPass)
if err != nil {
return nil, errors.Wrap(err, "failed to bind to LDAP")
}
return conn, nil
}
func Close(conn *ldap.Conn) {
if conn != nil {
conn.Close()
}
}
func FindAllObjects(cfg *Config, objType ObjectType) ([]RawLdapData, error) {
client, err := Open(cfg)
if err != nil {
return nil, errors.WithMessage(err, "failed to open ldap connection")
}
defer Close(client)
var searchRequest *ldap.SearchRequest
var attrs []string
switch objType {
case Users:
// Search all users
attrs = []string{"dn", cfg.EmailAttribute, cfg.EmailAttribute, cfg.FirstNameAttribute, cfg.LastNameAttribute,
cfg.PhoneAttribute, cfg.GroupMemberAttribute}
searchRequest = ldap.NewSearchRequest(
cfg.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
cfg.SyncFilter, attrs, nil,
)
case Groups:
if cfg.SyncGroupFilter == "" {
return nil, nil // no groups
}
// Search all groups
attrs = []string{"dn", cfg.GroupMemberAttribute}
searchRequest = ldap.NewSearchRequest(
cfg.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
cfg.SyncGroupFilter, attrs, nil,
)
default:
panic("invalid object type")
}
sr, err := client.Search(searchRequest)
if err != nil {
return nil, errors.Wrapf(err, "failed to search in ldap")
}
tmpData := make([]RawLdapData, 0, len(sr.Entries))
for _, entry := range sr.Entries {
tmp := RawLdapData{
DN: entry.DN,
Attributes: make(map[string]string, len(attrs)),
RawAttributes: make(map[string][][]byte, len(attrs)),
}
for _, field := range attrs {
tmp.Attributes[field] = entry.GetAttributeValue(field)
tmp.RawAttributes[field] = entry.GetRawAttributeValues(field)
}
tmpData = append(tmpData, tmp)
}
return tmpData, nil
} }

View File

@@ -1,338 +0,0 @@
package ldap
import (
"crypto/md5"
"crypto/tls"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/go-ldap/ldap/v3"
log "github.com/sirupsen/logrus"
)
var Fields = []string{"givenName", "sn", "mail", "department", "memberOf", "sAMAccountName", "telephoneNumber",
"mobile", "displayName", "cn", "title", "company", "manager", "streetAddress", "employeeID", "memberOf", "l",
"st", "postalCode", "co", "facsimileTelephoneNumber", "pager", "thumbnailPhoto", "otherMobile",
"extensionAttribute2", "distinguishedName", "userAccountControl"}
// --------------------------------------------------------------------------------------------------------------------
// Cache Data Store
// --------------------------------------------------------------------------------------------------------------------
type UserCacheHolder interface {
Clear()
SetAllUsers(users []RawLdapData)
GetUser(dn string) *RawLdapData
GetUsers() []*RawLdapData
}
type RawLdapData struct {
DN string
Attributes map[string]string
RawAttributes map[string][][]byte
}
// --------------------------------------------------------------------------------------------------------------------
// Sample Cache Data store
// --------------------------------------------------------------------------------------------------------------------
type UserCacheHolderEntry struct {
RawLdapData
Username string
Mail string
Firstname string
Lastname string
Groups []string
}
func (e *UserCacheHolderEntry) CalcFieldsFromAttributes() {
e.Username = strings.ToLower(e.Attributes["sAMAccountName"])
e.Mail = e.Attributes["mail"]
e.Firstname = e.Attributes["givenName"]
e.Lastname = e.Attributes["sn"]
e.Groups = make([]string, len(e.RawAttributes["memberOf"]))
for i, group := range e.RawAttributes["memberOf"] {
e.Groups[i] = string(group)
}
}
func (e *UserCacheHolderEntry) GetUID() string {
return fmt.Sprintf("u%x", md5.Sum([]byte(e.Attributes["distinguishedName"])))
}
type SynchronizedUserCacheHolder struct {
users map[string]*UserCacheHolderEntry
mux sync.RWMutex
}
func (h *SynchronizedUserCacheHolder) Init() {
h.users = make(map[string]*UserCacheHolderEntry)
}
func (h *SynchronizedUserCacheHolder) Clear() {
h.mux.Lock()
defer h.mux.Unlock()
h.users = make(map[string]*UserCacheHolderEntry)
}
func (h *SynchronizedUserCacheHolder) SetAllUsers(users []RawLdapData) {
h.mux.Lock()
defer h.mux.Unlock()
h.users = make(map[string]*UserCacheHolderEntry)
for i := range users {
h.users[users[i].DN] = &UserCacheHolderEntry{RawLdapData: users[i]}
h.users[users[i].DN].CalcFieldsFromAttributes()
}
}
func (h *SynchronizedUserCacheHolder) GetUser(dn string) *RawLdapData {
h.mux.RLock()
defer h.mux.RUnlock()
return &h.users[dn].RawLdapData
}
func (h *SynchronizedUserCacheHolder) GetUserData(dn string) *UserCacheHolderEntry {
h.mux.RLock()
defer h.mux.RUnlock()
return h.users[dn]
}
func (h *SynchronizedUserCacheHolder) GetUsers() []*RawLdapData {
h.mux.RLock()
defer h.mux.RUnlock()
users := make([]*RawLdapData, 0, len(h.users))
for _, user := range h.users {
users = append(users, &user.RawLdapData)
}
return users
}
func (h *SynchronizedUserCacheHolder) GetSortedUsers(sortKey string, sortDirection string) []*UserCacheHolderEntry {
h.mux.RLock()
defer h.mux.RUnlock()
sortedUsers := make([]*UserCacheHolderEntry, 0, len(h.users))
for _, user := range h.users {
sortedUsers = append(sortedUsers, user)
}
sort.Slice(sortedUsers, func(i, j int) bool {
if sortDirection == "asc" {
return sortedUsers[i].Attributes[sortKey] < sortedUsers[j].Attributes[sortKey]
} else {
return sortedUsers[i].Attributes[sortKey] > sortedUsers[j].Attributes[sortKey]
}
})
return sortedUsers
}
func (h *SynchronizedUserCacheHolder) IsInGroup(username, gid string) bool {
userDN := h.GetUserDN(username)
if userDN == "" {
return false // user not found -> not in group
}
user := h.GetUserData(userDN)
if user == nil {
return false
}
for _, group := range user.Groups {
if group == gid {
return true
}
}
return false
}
func (h *SynchronizedUserCacheHolder) UserExists(username string) bool {
userDN := h.GetUserDN(username)
if userDN == "" {
return false // user not found
}
return true
}
func (h *SynchronizedUserCacheHolder) GetUserDN(username string) string {
userDN := ""
for dn, user := range h.users {
accName := strings.ToLower(user.Attributes["sAMAccountName"])
if accName == username {
userDN = dn
break
}
}
return userDN
}
func (h *SynchronizedUserCacheHolder) GetUserDNByMail(mail string) string {
userDN := ""
for dn, user := range h.users {
accMail := strings.ToLower(user.Attributes["mail"])
if accMail == mail {
userDN = dn
break
}
}
return userDN
}
// --------------------------------------------------------------------------------------------------------------------
// Cache Handler, LDAP interaction
// --------------------------------------------------------------------------------------------------------------------
type UserCache struct {
Cfg *Config
LastError error
UpdatedAt time.Time
userData UserCacheHolder
}
func NewUserCache(config Config, store UserCacheHolder) *UserCache {
uc := &UserCache{
Cfg: &config,
UpdatedAt: time.Now(),
userData: store,
}
log.Infof("Filling user cache...")
err := uc.Update(true, true)
log.Infof("User cache filled!")
uc.LastError = err
return uc
}
func (u UserCache) open() (*ldap.Conn, error) {
conn, err := ldap.DialURL(u.Cfg.URL)
if err != nil {
return nil, err
}
if u.Cfg.StartTLS {
// Reconnect with TLS
err = conn.StartTLS(&tls.Config{InsecureSkipVerify: true})
if err != nil {
return nil, err
}
}
err = conn.Bind(u.Cfg.BindUser, u.Cfg.BindPass)
if err != nil {
return nil, err
}
return conn, nil
}
func (u UserCache) close(conn *ldap.Conn) {
if conn != nil {
conn.Close()
}
}
// Update updates the user cache in background, minimal locking will happen
func (u *UserCache) Update(filter, withDisabledUsers bool) error {
log.Debugf("Updating ldap cache...")
client, err := u.open()
if err != nil {
u.LastError = err
return err
}
defer u.close(client)
// Search for the given username
searchRequest := ldap.NewSearchRequest(
u.Cfg.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
"(objectClass=organizationalPerson)",
Fields,
nil,
)
sr, err := client.Search(searchRequest)
if err != nil {
u.LastError = err
return err
}
tmpData := make([]RawLdapData, 0, len(sr.Entries))
for _, entry := range sr.Entries {
if filter {
usernameAttr := strings.ToLower(entry.GetAttributeValue("sAMAccountName"))
firstNameAttr := entry.GetAttributeValue("givenName")
lastNameAttr := entry.GetAttributeValue("sn")
mailAttr := entry.GetAttributeValue("mail")
userAccountControl := entry.GetAttributeValue("userAccountControl")
employeeID := entry.GetAttributeValue("employeeID")
dn := entry.GetAttributeValue("distinguishedName")
if usernameAttr == "" || firstNameAttr == "" || lastNameAttr == "" || mailAttr == "" || employeeID == "" {
continue // prefilter...
}
if !withDisabledUsers && userAccountControl != "" && IsLdapUserDisabled(userAccountControl) {
continue
}
if entry.DN != dn {
log.Errorf("LDAP inconsistent: '%s' != '%s'", entry.DN, dn)
continue
}
}
tmp := RawLdapData{
DN: entry.DN,
Attributes: make(map[string]string, len(Fields)),
RawAttributes: make(map[string][][]byte, len(Fields)),
}
for _, field := range Fields {
tmp.Attributes[field] = entry.GetAttributeValue(field)
tmp.RawAttributes[field] = entry.GetRawAttributeValues(field)
}
tmpData = append(tmpData, tmp)
}
// Copy to userdata
u.userData.SetAllUsers(tmpData)
u.UpdatedAt = time.Now()
u.LastError = nil
log.Debug("Ldap cache updated...")
return nil
}
func IsLdapUserDisabled(userAccountControl string) bool {
uacInt, err := strconv.Atoi(userAccountControl)
if err != nil {
return true
}
if int32(uacInt)&0x2 != 0 {
return true // bit 2 set means account is disabled
}
return false
}

947
internal/server/api.go Normal file
View File

@@ -0,0 +1,947 @@
package server
// go get -u github.com/swaggo/swag/cmd/swag
// run: swag init --parseDependency --parseInternal --generalInfo api.go
// in the internal/server folder
import (
"encoding/json"
"net/http"
"strings"
"time"
jsonpatch "github.com/evanphx/json-patch"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/common"
"github.com/h44z/wg-portal/internal/users"
"github.com/h44z/wg-portal/internal/wireguard"
)
// @title WireGuard Portal API
// @version 1.0
// @description WireGuard Portal API for managing users and peers.
// @license.name MIT
// @license.url https://github.com/h44z/wg-portal/blob/master/LICENSE.txt
// @contact.name WireGuard Portal Project
// @contact.url https://github.com/h44z/wg-portal
// @securityDefinitions.basic ApiBasicAuth
// @in header
// @name Authorization
// @scope.admin Admin access required
// @securityDefinitions.basic GeneralBasicAuth
// @in header
// @name Authorization
// @scope.user User access required
// @BasePath /api/v1
// ApiServer is a simple wrapper struct so that we can have fresh member function names.
type ApiServer struct {
s *Server
}
type ApiError struct {
Message string
}
// GetUsers godoc
// @Tags Users
// @Summary Retrieves all users
// @ID GetUsers
// @Produce json
// @Success 200 {object} []users.User
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Router /backend/users [get]
// @Security ApiBasicAuth
func (s *ApiServer) GetUsers(c *gin.Context) {
allUsers := s.s.users.GetUsersUnscoped()
c.JSON(http.StatusOK, allUsers)
}
// GetUser godoc
// @Tags Users
// @Summary Retrieves user based on given Email
// @ID GetUser
// @Produce json
// @Param Email query string true "User Email"
// @Success 200 {object} users.User
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Router /backend/user [get]
// @Security ApiBasicAuth
func (s *ApiServer) GetUser(c *gin.Context) {
email := strings.ToLower(strings.TrimSpace(c.Query("Email")))
if email == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "Email parameter must be specified"})
return
}
user := s.s.users.GetUserUnscoped(email)
if user == nil {
c.JSON(http.StatusNotFound, ApiError{Message: "user not found"})
return
}
c.JSON(http.StatusOK, user)
}
// PostUser godoc
// @Tags Users
// @Summary Creates a new user based on the given user model
// @ID PostUser
// @Accept json
// @Produce json
// @Param User body users.User true "User Model"
// @Success 200 {object} users.User
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Failure 500 {object} ApiError
// @Router /backend/users [post]
// @Security ApiBasicAuth
func (s *ApiServer) PostUser(c *gin.Context) {
newUser := users.User{}
if err := c.ShouldBindJSON(&newUser); err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}
if user := s.s.users.GetUserUnscoped(newUser.Email); user != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: "user already exists"})
return
}
if err := s.s.CreateUser(newUser, s.s.wg.Cfg.GetDefaultDeviceName()); err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
user := s.s.users.GetUserUnscoped(newUser.Email)
if user == nil {
c.JSON(http.StatusNotFound, ApiError{Message: "user not found"})
return
}
c.JSON(http.StatusOK, user)
}
// PutUser godoc
// @Tags Users
// @Summary Updates a user based on the given user model
// @ID PutUser
// @Accept json
// @Produce json
// @Param Email query string true "User Email"
// @Param User body users.User true "User Model"
// @Success 200 {object} users.User
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Failure 500 {object} ApiError
// @Router /backend/user [put]
// @Security ApiBasicAuth
func (s *ApiServer) PutUser(c *gin.Context) {
email := strings.ToLower(strings.TrimSpace(c.Query("Email")))
if email == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "Email parameter must be specified"})
return
}
updateUser := users.User{}
if err := c.ShouldBindJSON(&updateUser); err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}
// Changing email address is not allowed
if email != updateUser.Email {
c.JSON(http.StatusBadRequest, ApiError{Message: "Email parameter must match the model email address"})
return
}
if user := s.s.users.GetUserUnscoped(email); user == nil {
c.JSON(http.StatusNotFound, ApiError{Message: "user does not exist"})
return
}
if err := s.s.UpdateUser(updateUser); err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
user := s.s.users.GetUserUnscoped(email)
if user == nil {
c.JSON(http.StatusNotFound, ApiError{Message: "user not found"})
return
}
c.JSON(http.StatusOK, user)
}
// PatchUser godoc
// @Tags Users
// @Summary Updates a user based on the given partial user model
// @ID PatchUser
// @Accept json
// @Produce json
// @Param Email query string true "User Email"
// @Param User body users.User true "User Model"
// @Success 200 {object} users.User
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Failure 500 {object} ApiError
// @Router /backend/user [patch]
// @Security ApiBasicAuth
func (s *ApiServer) PatchUser(c *gin.Context) {
email := strings.ToLower(strings.TrimSpace(c.Query("Email")))
if email == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "email parameter must be specified"})
return
}
patch, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}
user := s.s.users.GetUserUnscoped(email)
if user == nil {
c.JSON(http.StatusNotFound, ApiError{Message: "user does not exist"})
return
}
userData, err := json.Marshal(user)
if err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
mergedUserData, err := jsonpatch.MergePatch(userData, patch)
var mergedUser users.User
err = json.Unmarshal(mergedUserData, &mergedUser)
if err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
// CHanging email address is not allowed
if email != mergedUser.Email {
c.JSON(http.StatusBadRequest, ApiError{Message: "email parameter must match the model email address"})
return
}
if err := s.s.UpdateUser(mergedUser); err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
user = s.s.users.GetUserUnscoped(email)
if user == nil {
c.JSON(http.StatusNotFound, ApiError{Message: "user not found"})
return
}
c.JSON(http.StatusOK, user)
}
// DeleteUser godoc
// @Tags Users
// @Summary Deletes the specified user
// @ID DeleteUser
// @Produce json
// @Param Email query string true "User Email"
// @Success 204 "No content"
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Failure 500 {object} ApiError
// @Router /backend/user [delete]
// @Security ApiBasicAuth
func (s *ApiServer) DeleteUser(c *gin.Context) {
email := strings.ToLower(strings.TrimSpace(c.Query("Email")))
if email == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "email parameter must be specified"})
return
}
var user *users.User
if user = s.s.users.GetUserUnscoped(email); user == nil {
c.JSON(http.StatusNotFound, ApiError{Message: "user does not exist"})
return
}
if err := s.s.DeleteUser(*user); err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
c.Status(http.StatusNoContent)
}
// GetPeers godoc
// @Tags Peers
// @Summary Retrieves all peers for the given interface
// @ID GetPeers
// @Produce json
// @Param DeviceName query string true "Device Name"
// @Success 200 {object} []wireguard.Peer
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Router /backend/peers [get]
// @Security ApiBasicAuth
func (s *ApiServer) GetPeers(c *gin.Context) {
deviceName := strings.ToLower(strings.TrimSpace(c.Query("DeviceName")))
if deviceName == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "DeviceName parameter must be specified"})
return
}
// validate device name
if !common.ListContains(s.s.config.WG.DeviceNames, deviceName) {
c.JSON(http.StatusNotFound, ApiError{Message: "unknown device"})
return
}
peers := s.s.peers.GetAllPeers(deviceName)
c.JSON(http.StatusOK, peers)
}
// GetPeer godoc
// @Tags Peers
// @Summary Retrieves the peer for the given public key
// @ID GetPeer
// @Produce json
// @Param PublicKey query string true "Public Key (Base 64)"
// @Success 200 {object} wireguard.Peer
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Router /backend/peer [get]
// @Security ApiBasicAuth
func (s *ApiServer) GetPeer(c *gin.Context) {
pkey := c.Query("PublicKey")
if pkey == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "PublicKey parameter must be specified"})
return
}
peer := s.s.peers.GetPeerByKey(pkey)
if !peer.IsValid() {
c.JSON(http.StatusNotFound, ApiError{Message: "peer does not exist"})
return
}
c.JSON(http.StatusOK, peer)
}
// PostPeer godoc
// @Tags Peers
// @Summary Creates a new peer based on the given peer model
// @ID PostPeer
// @Accept json
// @Produce json
// @Param DeviceName query string true "Device Name"
// @Param Peer body wireguard.Peer true "Peer Model"
// @Success 200 {object} wireguard.Peer
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Failure 500 {object} ApiError
// @Router /backend/peers [post]
// @Security ApiBasicAuth
func (s *ApiServer) PostPeer(c *gin.Context) {
deviceName := strings.ToLower(strings.TrimSpace(c.Query("DeviceName")))
if deviceName == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "DeviceName parameter must be specified"})
return
}
// validate device name
if !common.ListContains(s.s.config.WG.DeviceNames, deviceName) {
c.JSON(http.StatusNotFound, ApiError{Message: "unknown device"})
return
}
newPeer := wireguard.Peer{}
if err := c.ShouldBindJSON(&newPeer); err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}
if peer := s.s.peers.GetPeerByKey(newPeer.PublicKey); peer.IsValid() {
c.JSON(http.StatusBadRequest, ApiError{Message: "peer already exists"})
return
}
if err := s.s.CreatePeer(deviceName, newPeer); err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
peer := s.s.peers.GetPeerByKey(newPeer.PublicKey)
if !peer.IsValid() {
c.JSON(http.StatusNotFound, ApiError{Message: "peer not found"})
return
}
c.JSON(http.StatusOK, peer)
}
// PutPeer godoc
// @Tags Peers
// @Summary Updates the given peer based on the given peer model
// @ID PutPeer
// @Accept json
// @Produce json
// @Param PublicKey query string true "Public Key"
// @Param Peer body wireguard.Peer true "Peer Model"
// @Success 200 {object} wireguard.Peer
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Failure 500 {object} ApiError
// @Router /backend/peer [put]
// @Security ApiBasicAuth
func (s *ApiServer) PutPeer(c *gin.Context) {
updatePeer := wireguard.Peer{}
if err := c.ShouldBindJSON(&updatePeer); err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}
pkey := c.Query("PublicKey")
if pkey == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "PublicKey parameter must be specified"})
return
}
if peer := s.s.peers.GetPeerByKey(pkey); !peer.IsValid() {
c.JSON(http.StatusNotFound, ApiError{Message: "peer does not exist"})
return
}
// Changing public key is not allowed
if pkey != updatePeer.PublicKey {
c.JSON(http.StatusBadRequest, ApiError{Message: "PublicKey parameter must match the model public key"})
return
}
now := time.Now()
if updatePeer.DeactivatedAt != nil {
updatePeer.DeactivatedAt = &now
updatePeer.DeactivatedReason = wireguard.DeactivatedReasonApiEdit
}
if err := s.s.UpdatePeer(updatePeer, now); err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
peer := s.s.peers.GetPeerByKey(updatePeer.PublicKey)
if !peer.IsValid() {
c.JSON(http.StatusNotFound, ApiError{Message: "peer not found"})
return
}
c.JSON(http.StatusOK, peer)
}
// PatchPeer godoc
// @Tags Peers
// @Summary Updates the given peer based on the given partial peer model
// @ID PatchPeer
// @Accept json
// @Produce json
// @Param PublicKey query string true "Public Key"
// @Param Peer body wireguard.Peer true "Peer Model"
// @Success 200 {object} wireguard.Peer
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Failure 500 {object} ApiError
// @Router /backend/peer [patch]
// @Security ApiBasicAuth
func (s *ApiServer) PatchPeer(c *gin.Context) {
patch, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}
pkey := c.Query("PublicKey")
if pkey == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "pkey parameter must be specified"})
return
}
peer := s.s.peers.GetPeerByKey(pkey)
if !peer.IsValid() {
c.JSON(http.StatusNotFound, ApiError{Message: "peer does not exist"})
return
}
peerData, err := json.Marshal(peer)
if err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
mergedPeerData, err := jsonpatch.MergePatch(peerData, patch)
var mergedPeer wireguard.Peer
err = json.Unmarshal(mergedPeerData, &mergedPeer)
if err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
if !mergedPeer.IsValid() {
c.JSON(http.StatusBadRequest, ApiError{Message: "invalid peer model"})
return
}
// Changing public key is not allowed
if pkey != mergedPeer.PublicKey {
c.JSON(http.StatusBadRequest, ApiError{Message: "PublicKey parameter must match the model public key"})
return
}
now := time.Now()
if mergedPeer.DeactivatedAt != nil {
mergedPeer.DeactivatedAt = &now
mergedPeer.DeactivatedReason = wireguard.DeactivatedReasonApiEdit
}
if err := s.s.UpdatePeer(mergedPeer, now); err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
peer = s.s.peers.GetPeerByKey(mergedPeer.PublicKey)
if !peer.IsValid() {
c.JSON(http.StatusNotFound, ApiError{Message: "peer not found"})
return
}
c.JSON(http.StatusOK, peer)
}
// DeletePeer godoc
// @Tags Peers
// @Summary Updates the given peer based on the given partial peer model
// @ID DeletePeer
// @Produce json
// @Param PublicKey query string true "Public Key"
// @Success 204 "No Content"
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Failure 500 {object} ApiError
// @Router /backend/peer [delete]
// @Security ApiBasicAuth
func (s *ApiServer) DeletePeer(c *gin.Context) {
pkey := c.Query("PublicKey")
if pkey == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "PublicKey parameter must be specified"})
return
}
peer := s.s.peers.GetPeerByKey(pkey)
if peer.PublicKey == "" {
c.JSON(http.StatusNotFound, ApiError{Message: "peer does not exist"})
return
}
if err := s.s.DeletePeer(peer); err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
c.Status(http.StatusNoContent)
}
// GetDevices godoc
// @Tags Interface
// @Summary Get all devices
// @ID GetDevices
// @Produce json
// @Success 200 {object} []wireguard.Device
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Router /backend/devices [get]
// @Security ApiBasicAuth
func (s *ApiServer) GetDevices(c *gin.Context) {
var devices []wireguard.Device
for _, deviceName := range s.s.config.WG.DeviceNames {
device := s.s.peers.GetDevice(deviceName)
if !device.IsValid() {
continue
}
devices = append(devices, device)
}
c.JSON(http.StatusOK, devices)
}
// GetDevice godoc
// @Tags Interface
// @Summary Get the given device
// @ID GetDevice
// @Produce json
// @Param DeviceName query string true "Device Name"
// @Success 200 {object} wireguard.Device
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Router /backend/device [get]
// @Security ApiBasicAuth
func (s *ApiServer) GetDevice(c *gin.Context) {
deviceName := strings.ToLower(strings.TrimSpace(c.Query("DeviceName")))
if deviceName == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "DeviceName parameter must be specified"})
return
}
// validate device name
if !common.ListContains(s.s.config.WG.DeviceNames, deviceName) {
c.JSON(http.StatusNotFound, ApiError{Message: "unknown device"})
return
}
device := s.s.peers.GetDevice(deviceName)
if !device.IsValid() {
c.JSON(http.StatusNotFound, ApiError{Message: "device not found"})
return
}
c.JSON(http.StatusOK, device)
}
// PutDevice godoc
// @Tags Interface
// @Summary Updates the given device based on the given device model (UNIMPLEMENTED)
// @ID PutDevice
// @Accept json
// @Produce json
// @Param DeviceName query string true "Device Name"
// @Param Device body wireguard.Device true "Device Model"
// @Success 200 {object} wireguard.Device
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Failure 500 {object} ApiError
// @Router /backend/device [put]
// @Security ApiBasicAuth
func (s *ApiServer) PutDevice(c *gin.Context) {
updateDevice := wireguard.Device{}
if err := c.ShouldBindJSON(&updateDevice); err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}
deviceName := strings.ToLower(strings.TrimSpace(c.Query("DeviceName")))
if deviceName == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "DeviceName parameter must be specified"})
return
}
// validate device name
if !common.ListContains(s.s.config.WG.DeviceNames, deviceName) {
c.JSON(http.StatusNotFound, ApiError{Message: "unknown device"})
return
}
device := s.s.peers.GetDevice(deviceName)
if !device.IsValid() {
c.JSON(http.StatusNotFound, ApiError{Message: "peer not found"})
return
}
// Changing device name is not allowed
if deviceName != updateDevice.DeviceName {
c.JSON(http.StatusBadRequest, ApiError{Message: "DeviceName parameter must match the model device name"})
return
}
// TODO: implement
c.JSON(http.StatusNotImplemented, device)
}
// PatchDevice godoc
// @Tags Interface
// @Summary Updates the given device based on the given partial device model (UNIMPLEMENTED)
// @ID PatchDevice
// @Accept json
// @Produce json
// @Param DeviceName query string true "Device Name"
// @Param Device body wireguard.Device true "Device Model"
// @Success 200 {object} wireguard.Device
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Failure 500 {object} ApiError
// @Router /backend/device [patch]
// @Security ApiBasicAuth
func (s *ApiServer) PatchDevice(c *gin.Context) {
patch, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}
deviceName := strings.ToLower(strings.TrimSpace(c.Query("DeviceName")))
if deviceName == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "DeviceName parameter must be specified"})
return
}
// validate device name
if !common.ListContains(s.s.config.WG.DeviceNames, deviceName) {
c.JSON(http.StatusNotFound, ApiError{Message: "unknown device"})
return
}
device := s.s.peers.GetDevice(deviceName)
if !device.IsValid() {
c.JSON(http.StatusNotFound, ApiError{Message: "peer not found"})
return
}
deviceData, err := json.Marshal(device)
if err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
mergedDeviceData, err := jsonpatch.MergePatch(deviceData, patch)
var mergedDevice wireguard.Device
err = json.Unmarshal(mergedDeviceData, &mergedDevice)
if err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
if !mergedDevice.IsValid() {
c.JSON(http.StatusBadRequest, ApiError{Message: "invalid device model"})
return
}
// Changing device name is not allowed
if deviceName != mergedDevice.DeviceName {
c.JSON(http.StatusBadRequest, ApiError{Message: "DeviceName parameter must match the model device name"})
return
}
// TODO: implement
c.JSON(http.StatusNotImplemented, device)
}
type PeerDeploymentInformation struct {
PublicKey string
Identifier string
Device string
DeviceIdentifier string
}
// GetPeerDeploymentInformation godoc
// @Tags Provisioning
// @Summary Retrieves all active peers for the given email address
// @ID GetPeerDeploymentInformation
// @Produce json
// @Param Email query string true "Email Address"
// @Success 200 {object} []PeerDeploymentInformation "All active WireGuard peers"
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Router /provisioning/peers [get]
// @Security GeneralBasicAuth
func (s *ApiServer) GetPeerDeploymentInformation(c *gin.Context) {
email := c.Query("Email")
if email == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "Email parameter must be specified"})
return
}
// Get authenticated user to check permissions
username, _, _ := c.Request.BasicAuth()
user := s.s.users.GetUser(username)
if !user.IsAdmin && user.Email != email {
c.JSON(http.StatusForbidden, ApiError{Message: "not enough permissions to access this resource"})
return
}
peers := s.s.peers.GetPeersByMail(email)
result := make([]PeerDeploymentInformation, 0, len(peers))
for i := range peers {
if peers[i].DeactivatedAt != nil {
continue // skip deactivated peers
}
device := s.s.peers.GetDevice(peers[i].DeviceName)
if device.Type != wireguard.DeviceTypeServer {
continue // Skip peers on non-server devices
}
result = append(result, PeerDeploymentInformation{
PublicKey: peers[i].PublicKey,
Identifier: peers[i].Identifier,
Device: device.DeviceName,
DeviceIdentifier: device.DisplayName,
})
}
c.JSON(http.StatusOK, result)
}
// GetPeerDeploymentConfig godoc
// @Tags Provisioning
// @Summary Retrieves the peer config for the given public key
// @ID GetPeerDeploymentConfig
// @Produce plain
// @Param PublicKey query string true "Public Key (Base 64)"
// @Success 200 {object} string "The WireGuard configuration file"
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Router /provisioning/peer [get]
// @Security GeneralBasicAuth
func (s *ApiServer) GetPeerDeploymentConfig(c *gin.Context) {
pkey := c.Query("PublicKey")
if pkey == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "PublicKey parameter must be specified"})
return
}
peer := s.s.peers.GetPeerByKey(pkey)
if !peer.IsValid() {
c.JSON(http.StatusNotFound, ApiError{Message: "peer does not exist"})
return
}
// Get authenticated user to check permissions
username, _, _ := c.Request.BasicAuth()
user := s.s.users.GetUser(username)
if !user.IsAdmin && user.Email != peer.Email {
c.JSON(http.StatusForbidden, ApiError{Message: "not enough permissions to access this resource"})
return
}
device := s.s.peers.GetDevice(peer.DeviceName)
config, err := peer.GetConfigFile(device)
if err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
c.Data(http.StatusOK, "text/plain", config)
}
type ProvisioningRequest struct {
// DeviceName is optional, if not specified, the configured default device will be used.
DeviceName string `json:",omitempty"`
Identifier string `binding:"required"`
Email string `binding:"required"`
// Client specific and optional settings
AllowedIPsStr string `binding:"cidrlist" json:",omitempty"`
PersistentKeepalive int `binding:"gte=0" json:",omitempty"`
DNSStr string `binding:"iplist" json:",omitempty"`
Mtu int `binding:"gte=0,lte=1500" json:",omitempty"`
}
// PostPeerDeploymentConfig godoc
// @Tags Provisioning
// @Summary Creates the requested peer config and returns the config file
// @ID PostPeerDeploymentConfig
// @Accept json
// @Produce plain
// @Param ProvisioningRequest body ProvisioningRequest true "Provisioning Request Model"
// @Success 200 {object} string "The WireGuard configuration file"
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
// @Failure 404 {object} ApiError
// @Router /provisioning/peers [post]
// @Security GeneralBasicAuth
func (s *ApiServer) PostPeerDeploymentConfig(c *gin.Context) {
req := ProvisioningRequest{}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}
// Get authenticated user to check permissions
username, _, _ := c.Request.BasicAuth()
user := s.s.users.GetUser(username)
if !user.IsAdmin && !s.s.config.Core.SelfProvisioningAllowed {
c.JSON(http.StatusForbidden, ApiError{Message: "peer provisioning service disabled"})
return
}
if !user.IsAdmin && user.Email != req.Email {
c.JSON(http.StatusForbidden, ApiError{Message: "not enough permissions to access this resource"})
return
}
deviceName := req.DeviceName
if deviceName == "" || !common.ListContains(s.s.config.WG.DeviceNames, deviceName) {
deviceName = s.s.config.WG.GetDefaultDeviceName()
}
device := s.s.peers.GetDevice(deviceName)
if device.Type != wireguard.DeviceTypeServer {
c.JSON(http.StatusForbidden, ApiError{Message: "invalid device, provisioning disabled"})
return
}
// check if private/public keys are set, if so check database for existing entries
peer, err := s.s.PrepareNewPeer(deviceName)
if err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
peer.Email = req.Email
peer.Identifier = req.Identifier
if req.AllowedIPsStr != "" {
peer.AllowedIPsStr = req.AllowedIPsStr
}
if req.PersistentKeepalive != 0 {
peer.PersistentKeepalive = req.PersistentKeepalive
}
if req.DNSStr != "" {
peer.DNSStr = req.DNSStr
}
if req.Mtu != 0 {
peer.Mtu = req.Mtu
}
if err := s.s.CreatePeer(deviceName, peer); err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
config, err := peer.GetConfigFile(device)
if err != nil {
c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()})
return
}
c.Data(http.StatusOK, "text/plain", config)
}

90
internal/server/auth.go Normal file
View File

@@ -0,0 +1,90 @@
package server
import (
"sort"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/authentication"
"github.com/h44z/wg-portal/internal/users"
"github.com/sirupsen/logrus"
)
// AuthManager keeps track of available authentication providers.
type AuthManager struct {
Server *Server
Group *gin.RouterGroup // basic group for all providers (/auth)
providers []authentication.AuthProvider
UserManager *users.Manager
}
// RegisterProvider register auth provider
func (auth *AuthManager) RegisterProvider(provider authentication.AuthProvider) {
name := provider.GetName()
if auth.GetProvider(name) != nil {
logrus.Warnf("auth provider %v already registered", name)
}
provider.SetupRoutes(auth.Group)
auth.providers = append(auth.providers, provider)
}
// RegisterProviderWithoutError register auth provider if err is nil
func (auth *AuthManager) RegisterProviderWithoutError(provider authentication.AuthProvider, err error) {
if err != nil {
logrus.Errorf("skipping provider registration: %v", err)
return
}
auth.RegisterProvider(provider)
}
// GetProvider get provider by name
func (auth *AuthManager) GetProvider(name string) authentication.AuthProvider {
for _, provider := range auth.providers {
if provider.GetName() == name {
return provider
}
}
return nil
}
// GetProviders return registered providers.
// Returned providers are ordered by provider priority.
func (auth *AuthManager) GetProviders() (providers []authentication.AuthProvider) {
for _, provider := range auth.providers {
providers = append(providers, provider)
}
// order by priority
sort.SliceStable(providers, func(i, j int) bool {
return providers[i].GetPriority() < providers[j].GetPriority()
})
return
}
// GetProvidersForType return registered providers for the given type.
// Returned providers are ordered by provider priority.
func (auth *AuthManager) GetProvidersForType(typ authentication.AuthProviderType) (providers []authentication.AuthProvider) {
for _, provider := range auth.providers {
if provider.GetType() == typ {
providers = append(providers, provider)
}
}
// order by priority
sort.SliceStable(providers, func(i, j int) bool {
return providers[i].GetPriority() < providers[j].GetPriority()
})
return
}
func NewAuthManager(server *Server) *AuthManager {
m := &AuthManager{
Server: server,
}
m.Group = m.Server.server.Group("/auth")
return m
}

View File

@@ -0,0 +1,153 @@
package server
import (
"os"
"reflect"
"runtime"
"github.com/h44z/wg-portal/internal/common"
"github.com/h44z/wg-portal/internal/ldap"
"github.com/h44z/wg-portal/internal/wireguard"
"github.com/kelseyhightower/envconfig"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
gldap "github.com/go-ldap/ldap/v3"
)
var ErrInvalidSpecification = errors.New("specification must be a struct pointer")
// loadConfigFile parses yaml files. It uses yaml annotation to store the data in a struct.
func loadConfigFile(cfg interface{}, filename string) error {
s := reflect.ValueOf(cfg)
if s.Kind() != reflect.Ptr {
return ErrInvalidSpecification
}
s = s.Elem()
if s.Kind() != reflect.Struct {
return ErrInvalidSpecification
}
f, err := os.Open(filename)
if err != nil {
return errors.Wrapf(err, "failed to open config file %s", filename)
}
defer f.Close()
decoder := yaml.NewDecoder(f)
err = decoder.Decode(cfg)
if err != nil {
return errors.Wrapf(err, "failed to decode config file %s", filename)
}
return nil
}
// loadConfigEnv processes envconfig annotations and loads environment variables to the given configuration struct.
func loadConfigEnv(cfg interface{}) error {
err := envconfig.Process("", cfg)
if err != nil {
return errors.Wrap(err, "failed to process environment config")
}
return nil
}
type Config struct {
Core struct {
ListeningAddress string `yaml:"listeningAddress" envconfig:"LISTENING_ADDRESS"`
ExternalUrl string `yaml:"externalUrl" envconfig:"EXTERNAL_URL"`
Title string `yaml:"title" envconfig:"WEBSITE_TITLE"`
CompanyName string `yaml:"company" envconfig:"COMPANY_NAME"`
MailFrom string `yaml:"mailFrom" envconfig:"MAIL_FROM"`
AdminUser string `yaml:"adminUser" envconfig:"ADMIN_USER"` // must be an email address
AdminPassword string `yaml:"adminPass" envconfig:"ADMIN_PASS"`
EditableKeys bool `yaml:"editableKeys" envconfig:"EDITABLE_KEYS"`
CreateDefaultPeer bool `yaml:"createDefaultPeer" envconfig:"CREATE_DEFAULT_PEER"`
SelfProvisioningAllowed bool `yaml:"selfProvisioning" envconfig:"SELF_PROVISIONING"`
WGExporterFriendlyNames bool `yaml:"wgExporterFriendlyNames" envconfig:"WG_EXPORTER_FRIENDLY_NAMES"`
LdapEnabled bool `yaml:"ldapEnabled" envconfig:"LDAP_ENABLED"`
SessionSecret string `yaml:"sessionSecret" envconfig:"SESSION_SECRET"`
LogoUrl string `yaml:"logoUrl" envconfig:"LOGO_URL"`
BackgroundTaskInterval int `yaml:"backgroundTaskInterval" envconfig:"BACKGROUND_TASK_INTERVAL"` // in seconds
ExpiryReEnable bool `yaml:"expiryReEnable" envconfig:"EXPIRY_REENABLE"`
} `yaml:"core"`
Database common.DatabaseConfig `yaml:"database"`
Email common.MailConfig `yaml:"email"`
LDAP ldap.Config `yaml:"ldap"`
WG wireguard.Config `yaml:"wg"`
}
func NewConfig() *Config {
cfg := &Config{}
// Default config
cfg.Core.ListeningAddress = ":8123"
cfg.Core.Title = "WireGuard VPN"
cfg.Core.CompanyName = "WireGuard Portal"
cfg.Core.LogoUrl = "/img/header-logo.png"
cfg.Core.ExternalUrl = "http://localhost:8123"
cfg.Core.MailFrom = "WireGuard VPN <noreply@company.com>"
cfg.Core.AdminUser = "admin@wgportal.local"
cfg.Core.AdminPassword = "wgportal"
cfg.Core.LdapEnabled = false
cfg.Core.EditableKeys = true
cfg.Core.WGExporterFriendlyNames = false
cfg.Core.SessionSecret = "secret"
cfg.Core.BackgroundTaskInterval = 15 * 60 // 15 minutes
cfg.Database.Typ = "sqlite"
cfg.Database.Database = "data/wg_portal.db"
cfg.LDAP.URL = "ldap://srv-ad01.company.local:389"
cfg.LDAP.BaseDN = "DC=COMPANY,DC=LOCAL"
cfg.LDAP.StartTLS = true
cfg.LDAP.BindUser = "company\\\\ldap_wireguard"
cfg.LDAP.BindPass = "SuperSecret"
cfg.LDAP.EmailAttribute = "mail"
cfg.LDAP.FirstNameAttribute = "givenName"
cfg.LDAP.LastNameAttribute = "sn"
cfg.LDAP.PhoneAttribute = "telephoneNumber"
cfg.LDAP.GroupMemberAttribute = "memberOf"
cfg.LDAP.AdminLdapGroup = "CN=WireGuardAdmins,OU=_O_IT,DC=COMPANY,DC=LOCAL"
cfg.LDAP.LoginFilter = "(&(objectClass=organizationalPerson)(mail={{login_identifier}})(!userAccountControl:1.2.840.113556.1.4.803:=2))"
cfg.LDAP.SyncFilter = "(&(objectClass=organizationalPerson)(!userAccountControl:1.2.840.113556.1.4.803:=2)(mail=*))"
cfg.LDAP.SyncGroupFilter = ""
cfg.WG.DeviceNames = []string{"wg0"}
cfg.WG.DefaultDeviceName = "wg0"
cfg.WG.ConfigDirectoryPath = "/etc/wireguard"
cfg.WG.ManageIPAddresses = true
cfg.WG.UserManagePeers = false
cfg.Email.Host = "127.0.0.1"
cfg.Email.Port = 25
cfg.Email.Encryption = common.MailEncryptionNone
cfg.Email.AuthType = common.MailAuthPlain
// Load config from file and environment
cfgFile, ok := os.LookupEnv("CONFIG_FILE")
if !ok {
cfgFile = "config.yml" // Default config file
}
err := loadConfigFile(cfg, cfgFile)
if err != nil {
logrus.Warnf("unable to load config.yml file: %v, using default configuration...", err)
}
err = loadConfigEnv(cfg)
if err != nil {
logrus.Warnf("unable to load environment config: %v", err)
}
cfg.LDAP.AdminLdapGroup_, err = gldap.ParseDN(cfg.LDAP.AdminLdapGroup)
if err != nil {
logrus.Warnf("Parsing AdminLDAPGroup failed: %v", err)
}
if cfg.WG.ManageIPAddresses && runtime.GOOS != "linux" {
logrus.Warnf("managing IP addresses only works on linux, feature disabled...")
cfg.WG.ManageIPAddresses = false
}
return cfg
}

View File

@@ -1,291 +0,0 @@
package server
import (
"encoding/gob"
"errors"
"html/template"
"math/rand"
"net/url"
"os"
"path/filepath"
"time"
"github.com/h44z/wg-portal/internal/wireguard"
"github.com/h44z/wg-portal/internal/common"
"github.com/h44z/wg-portal/internal/ldap"
log "github.com/sirupsen/logrus"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/memstore"
"github.com/gin-gonic/gin"
)
const SessionIdentifier = "wgPortalSession"
const CacheRefreshDuration = 5 * time.Minute
func init() {
gob.Register(SessionData{})
gob.Register(FlashData{})
gob.Register(User{})
gob.Register(Device{})
gob.Register(LdapCreateForm{})
}
type SessionData struct {
LoggedIn bool
IsAdmin bool
UID string
UserName string
Firstname string
Lastname string
Email string
SortedBy string
SortDirection string
Search string
AlertData string
AlertType string
FormData interface{}
}
type FlashData struct {
HasAlert bool
Message string
Type string
}
type StaticData struct {
WebsiteTitle string
WebsiteLogo string
CompanyName string
Year int
LdapDisabled bool
}
type Server struct {
// Core components
config *common.Config
server *gin.Engine
users *UserManager
mailTpl *template.Template
// WireGuard stuff
wg *wireguard.Manager
// LDAP stuff
ldapDisabled bool
ldapAuth ldap.Authentication
ldapUsers *ldap.SynchronizedUserCacheHolder
ldapCacheUpdater *ldap.UserCache
}
func (s *Server) Setup() error {
dir := s.getExecutableDirectory()
rDir, _ := filepath.Abs(filepath.Dir(os.Args[0]))
log.Infof("Real working directory: %s", rDir)
log.Infof("Current working directory: %s", dir)
// Init rand
rand.Seed(time.Now().UnixNano())
s.config = common.NewConfig()
// Setup LDAP stuff
s.ldapAuth = ldap.NewAuthentication(s.config.LDAP)
s.ldapUsers = &ldap.SynchronizedUserCacheHolder{}
s.ldapUsers.Init()
s.ldapCacheUpdater = ldap.NewUserCache(s.config.LDAP, s.ldapUsers)
if s.ldapCacheUpdater.LastError != nil {
log.Warnf("LDAP error: %v", s.ldapCacheUpdater.LastError)
log.Warnf("LDAP features disabled!")
s.ldapDisabled = true
}
// Setup WireGuard stuff
s.wg = &wireguard.Manager{Cfg: &s.config.WG}
if err := s.wg.Init(); err != nil {
return err
}
// Setup user manager
if s.users = NewUserManager(filepath.Join(dir, s.config.Core.DatabasePath), s.wg, s.ldapUsers); s.users == nil {
return errors.New("unable to setup user manager")
}
if err := s.users.InitFromCurrentInterface(); err != nil {
return errors.New("unable to initialize user manager")
}
if err := s.RestoreWireGuardInterface(); err != nil {
return errors.New("unable to restore wirguard state")
}
// Setup mail template
var err error
s.mailTpl, err = template.New("email.html").ParseFiles(filepath.Join(dir, "/assets/tpl/email.html"))
if err != nil {
return errors.New("unable to pare mail template")
}
// Setup http server
s.server = gin.Default()
s.server.SetFuncMap(template.FuncMap{
"formatBytes": common.ByteCountSI,
"urlEncode": url.QueryEscape,
})
// Setup templates
log.Infof("Loading templates from: %s", filepath.Join(dir, "/assets/tpl/*.html"))
s.server.LoadHTMLGlob(filepath.Join(dir, "/assets/tpl/*.html"))
s.server.Use(sessions.Sessions("authsession", memstore.NewStore([]byte("secret")))) // TODO: change key?
// Serve static files
s.server.Static("/css", filepath.Join(dir, "/assets/css"))
s.server.Static("/js", filepath.Join(dir, "/assets/js"))
s.server.Static("/img", filepath.Join(dir, "/assets/img"))
s.server.Static("/fonts", filepath.Join(dir, "/assets/fonts"))
// Setup all routes
SetupRoutes(s)
log.Infof("Setup of service completed!")
return nil
}
func (s *Server) Run() {
// Start ldap group watcher
if !s.ldapDisabled {
go func(s *Server) {
for {
time.Sleep(CacheRefreshDuration)
if err := s.ldapCacheUpdater.Update(true, true); err != nil {
log.Warnf("Failed to update ldap group cache: %v", err)
}
log.Debugf("Refreshed LDAP permissions!")
}
}(s)
}
if !s.ldapDisabled && s.config.Core.SyncLdapStatus {
go func(s *Server) {
for {
time.Sleep(CacheRefreshDuration)
if err := s.SyncLdapAttributesWithWireGuard(); err != nil {
log.Warnf("Failed to synchronize ldap attributes: %v", err)
}
log.Debugf("Synced LDAP attributes!")
}
}(s)
}
// Run web service
err := s.server.Run(s.config.Core.ListeningAddress)
if err != nil {
log.Errorf("Failed to listen and serve on %s: %v", s.config.Core.ListeningAddress, err)
}
}
func (s *Server) getExecutableDirectory() string {
dir, err := filepath.Abs(filepath.Dir(os.Args[0]))
if err != nil {
log.Errorf("Failed to get executable directory: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "assets")); os.IsNotExist(err) {
return "." // assets directory not found -> we are developing in goland =)
}
return dir
}
func (s *Server) getSessionData(c *gin.Context) SessionData {
session := sessions.Default(c)
rawSessionData := session.Get(SessionIdentifier)
var sessionData SessionData
if rawSessionData != nil {
sessionData = rawSessionData.(SessionData)
} else {
sessionData = SessionData{
SortedBy: "mail",
SortDirection: "asc",
Email: "",
Firstname: "",
Lastname: "",
IsAdmin: false,
LoggedIn: false,
}
session.Set(SessionIdentifier, sessionData)
if err := session.Save(); err != nil {
log.Errorf("Failed to store session: %v", err)
}
}
return sessionData
}
func (s *Server) getFlashes(c *gin.Context) []FlashData {
session := sessions.Default(c)
flashes := session.Flashes()
if err := session.Save(); err != nil {
log.Errorf("Failed to store session after setting flash: %v", err)
}
flashData := make([]FlashData, len(flashes))
for i := range flashes {
flashData[i] = flashes[i].(FlashData)
}
return flashData
}
func (s *Server) updateSessionData(c *gin.Context, data SessionData) error {
session := sessions.Default(c)
session.Set(SessionIdentifier, data)
if err := session.Save(); err != nil {
log.Errorf("Failed to store session: %v", err)
return err
}
return nil
}
func (s *Server) destroySessionData(c *gin.Context) error {
session := sessions.Default(c)
session.Delete(SessionIdentifier)
if err := session.Save(); err != nil {
log.Errorf("Failed to destroy session: %v", err)
return err
}
return nil
}
func (s *Server) getStaticData() StaticData {
return StaticData{
WebsiteTitle: s.config.Core.Title,
WebsiteLogo: "/img/header-logo.png",
CompanyName: s.config.Core.CompanyName,
LdapDisabled: s.ldapDisabled,
Year: time.Now().Year(),
}
}
func (s *Server) setFlashMessage(c *gin.Context, message, typ string) {
session := sessions.Default(c)
session.AddFlash(FlashData{
Message: message,
Type: typ,
})
if err := session.Save(); err != nil {
log.Errorf("Failed to store session after setting flash: %v", err)
}
}
func (s SessionData) GetSortIcon(field string) string {
if s.SortedBy != field {
return "fa-sort"
}
if s.SortDirection == "asc" {
return "fa-sort-alpha-down"
} else {
return "fa-sort-alpha-up"
}
}

1538
internal/server/docs/docs.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,13 +4,17 @@ import (
"net/http" "net/http"
"strings" "strings"
log "github.com/sirupsen/logrus" "github.com/pkg/errors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/authentication"
"github.com/h44z/wg-portal/internal/users"
"github.com/sirupsen/logrus"
csrf "github.com/utrack/gin-csrf"
) )
func (s *Server) GetLogin(c *gin.Context) { func (s *Server) GetLogin(c *gin.Context) {
currentSession := s.getSessionData(c) currentSession := GetSessionData(c)
if currentSession.LoggedIn { if currentSession.LoggedIn {
c.Redirect(http.StatusSeeOther, "/") // already logged in c.Redirect(http.StatusSeeOther, "/") // already logged in
} }
@@ -30,11 +34,12 @@ func (s *Server) GetLogin(c *gin.Context) {
"error": authError != "", "error": authError != "",
"message": errMsg, "message": errMsg,
"static": s.getStaticData(), "static": s.getStaticData(),
"Csrf": csrf.GetToken(c),
}) })
} }
func (s *Server) PostLogin(c *gin.Context) { func (s *Server) PostLogin(c *gin.Context) {
currentSession := s.getSessionData(c) currentSession := GetSessionData(c)
if currentSession.LoggedIn { if currentSession.LoggedIn {
// already logged in // already logged in
c.Redirect(http.StatusSeeOther, "/") c.Redirect(http.StatusSeeOther, "/")
@@ -50,69 +55,35 @@ func (s *Server) PostLogin(c *gin.Context) {
return return
} }
adminAuthenticated := false // Check all available auth backends
if s.config.Core.AdminUser != "" && username == s.config.Core.AdminUser && password == s.config.Core.AdminPassword { user, err := s.checkAuthentication(username, password)
adminAuthenticated = true if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "login error", err.Error())
return
} }
// Check if user is in cache, avoid unnecessary ldap requests // Check if user is authenticated
if !adminAuthenticated && !s.ldapUsers.UserExists(username) { if user == nil {
c.Redirect(http.StatusSeeOther, "/auth/login?err=authfail")
}
// Check if username and password match
if !adminAuthenticated && !s.ldapAuth.CheckLogin(username, password) {
c.Redirect(http.StatusSeeOther, "/auth/login?err=authfail") c.Redirect(http.StatusSeeOther, "/auth/login?err=authfail")
return return
} }
var sessionData SessionData // Set authenticated session
if adminAuthenticated { sessionData := GetSessionData(c)
sessionData = SessionData{ sessionData.LoggedIn = true
LoggedIn: true, sessionData.IsAdmin = user.IsAdmin
IsAdmin: true, sessionData.Email = user.Email
Email: "autodetected@example.com", sessionData.Firstname = user.Firstname
UID: "adminuid", sessionData.Lastname = user.Lastname
UserName: username, sessionData.DeviceName = s.wg.Cfg.DeviceNames[0]
Firstname: "System",
Lastname: "Administrator",
SortedBy: "mail",
SortDirection: "asc",
Search: "",
}
} else {
dn := s.ldapUsers.GetUserDN(username)
userData := s.ldapUsers.GetUserData(dn)
sessionData = SessionData{
LoggedIn: true,
IsAdmin: s.ldapUsers.IsInGroup(username, s.config.AdminLdapGroup),
UID: userData.GetUID(),
UserName: username,
Email: userData.Mail,
Firstname: userData.Firstname,
Lastname: userData.Lastname,
SortedBy: "mail",
SortDirection: "asc",
Search: "",
}
}
// Check if user already has a peer setup, if not create one // Check if user already has a peer setup, if not create one
if s.config.Core.CreateInterfaceOnLogin && !adminAuthenticated { if err := s.CreateUserDefaultPeer(user.Email, s.wg.Cfg.GetDefaultDeviceName()); err != nil {
users := s.users.GetUsersByMail(sessionData.Email) // Not a fatal error, just log it...
logrus.Errorf("failed to automatically create vpn peer for %s: %v", sessionData.Email, err)
if len(users) == 0 { // Create vpn peer
err := s.CreateUser(User{
Identifier: sessionData.Firstname + " " + sessionData.Lastname + " (Default)",
Email: sessionData.Email,
CreatedBy: sessionData.Email,
UpdatedBy: sessionData.Email,
})
log.Errorf("Failed to automatically create vpn peer for %s: %v", sessionData.Email, err)
}
} }
if err := s.updateSessionData(c, sessionData); err != nil { if err := UpdateSessionData(c, sessionData); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "login error", "failed to save session") s.GetHandleError(c, http.StatusInternalServerError, "login error", "failed to save session")
return return
} }
@@ -120,16 +91,61 @@ func (s *Server) PostLogin(c *gin.Context) {
} }
func (s *Server) GetLogout(c *gin.Context) { func (s *Server) GetLogout(c *gin.Context) {
currentSession := s.getSessionData(c) currentSession := GetSessionData(c)
if !currentSession.LoggedIn { // Not logged in if !currentSession.LoggedIn { // Not logged in
c.Redirect(http.StatusSeeOther, "/") c.Redirect(http.StatusSeeOther, "/")
return return
} }
if err := s.destroySessionData(c); err != nil { if err := DestroySessionData(c); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "logout error", "failed to destroy session") s.GetHandleError(c, http.StatusInternalServerError, "logout error", "failed to destroy session")
return return
} }
c.Redirect(http.StatusSeeOther, "/") c.Redirect(http.StatusSeeOther, "/")
} }
func (s *Server) checkAuthentication(username, password string) (*users.User, error) {
var user *users.User
// Check all available auth backends
for _, provider := range s.auth.GetProvidersForType(authentication.AuthProviderTypePassword) {
// try to log in to the given provider
authEmail, err := provider.Login(&authentication.AuthContext{
Username: username,
Password: password,
})
if err != nil {
continue
}
// Login succeeded
user = s.users.GetUser(authEmail)
if user != nil {
break // user exists, nothing more to do...
}
// create new user in the database (or reactivate him)
userData, err := provider.GetUserModel(&authentication.AuthContext{
Username: username,
})
if err != nil {
return nil, errors.Wrap(err, "failed to get user model")
}
if err := s.CreateUser(users.User{
Email: userData.Email,
Source: users.UserSource(provider.GetName()),
IsAdmin: userData.IsAdmin,
Firstname: userData.Firstname,
Lastname: userData.Lastname,
Phone: userData.Phone,
}, s.wg.Cfg.GetDefaultDeviceName()); err != nil {
return nil, errors.Wrap(err, "failed to update user data")
}
user = s.users.GetUser(authEmail)
break
}
return user, nil
}

View File

@@ -5,9 +5,14 @@ import (
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/common"
"github.com/h44z/wg-portal/internal/users"
"github.com/pkg/errors"
) )
func (s *Server) GetHandleError(c *gin.Context, code int, message, details string) { func (s *Server) GetHandleError(c *gin.Context, code int, message, details string) {
currentSession := GetSessionData(c)
c.HTML(code, "error.html", gin.H{ c.HTML(code, "error.html", gin.H{
"Data": gin.H{ "Data": gin.H{
"Code": strconv.Itoa(code), "Code": strconv.Itoa(code),
@@ -15,104 +20,111 @@ func (s *Server) GetHandleError(c *gin.Context, code int, message, details strin
"Details": details, "Details": details,
}, },
"Route": c.Request.URL.Path, "Route": c.Request.URL.Path,
"Session": s.getSessionData(c), "Session": GetSessionData(c),
"Static": s.getStaticData(), "Static": s.getStaticData(),
"Device": s.peers.GetDevice(currentSession.DeviceName),
"DeviceNames": s.GetDeviceNames(),
}) })
} }
func (s *Server) GetIndex(c *gin.Context) { func (s *Server) GetIndex(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", struct { currentSession := GetSessionData(c)
Route string
Alerts []FlashData c.HTML(http.StatusOK, "index.html", gin.H{
Session SessionData "Route": c.Request.URL.Path,
Static StaticData "Alerts": GetFlashes(c),
Device Device "Session": currentSession,
}{ "Static": s.getStaticData(),
Route: c.Request.URL.Path, "Device": s.peers.GetDevice(currentSession.DeviceName),
Alerts: s.getFlashes(c), "DeviceNames": s.GetDeviceNames(),
Session: s.getSessionData(c),
Static: s.getStaticData(),
Device: s.users.GetDevice(),
}) })
} }
func (s *Server) GetAdminIndex(c *gin.Context) { func (s *Server) GetAdminIndex(c *gin.Context) {
currentSession := s.getSessionData(c) currentSession := GetSessionData(c)
sort := c.Query("sort") sort := c.Query("sort")
if sort != "" { if sort != "" {
if currentSession.SortedBy != sort { if currentSession.SortedBy["peers"] != sort {
currentSession.SortedBy = sort currentSession.SortedBy["peers"] = sort
currentSession.SortDirection = "asc" currentSession.SortDirection["peers"] = "asc"
} else { } else {
if currentSession.SortDirection == "asc" { if currentSession.SortDirection["peers"] == "asc" {
currentSession.SortDirection = "desc" currentSession.SortDirection["peers"] = "desc"
} else { } else {
currentSession.SortDirection = "asc" currentSession.SortDirection["peers"] = "asc"
} }
} }
if err := s.updateSessionData(c, currentSession); err != nil { if err := UpdateSessionData(c, currentSession); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "sort error", "failed to save session") s.GetHandleError(c, http.StatusInternalServerError, "sort error", "failed to save session")
return return
} }
c.Redirect(http.StatusSeeOther, "/admin") c.Redirect(http.StatusSeeOther, "/admin/")
return return
} }
search, searching := c.GetQuery("search") search, searching := c.GetQuery("search")
if searching { if searching {
currentSession.Search = search currentSession.Search["peers"] = search
if err := s.updateSessionData(c, currentSession); err != nil { if err := UpdateSessionData(c, currentSession); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "search error", "failed to save session") s.GetHandleError(c, http.StatusInternalServerError, "search error", "failed to save session")
return return
} }
c.Redirect(http.StatusSeeOther, "/admin") c.Redirect(http.StatusSeeOther, "/admin/")
return return
} }
device := s.users.GetDevice() deviceName := c.Query("device")
users := s.users.GetFilteredAndSortedUsers(currentSession.SortedBy, currentSession.SortDirection, currentSession.Search) if deviceName != "" {
if !common.ListContains(s.wg.Cfg.DeviceNames, deviceName) {
s.GetHandleError(c, http.StatusInternalServerError, "device selection error", "no such device")
return
}
currentSession.DeviceName = deviceName
c.HTML(http.StatusOK, "admin_index.html", struct { if err := UpdateSessionData(c, currentSession); err != nil {
Route string s.GetHandleError(c, http.StatusInternalServerError, "device selection error", "failed to save session")
Alerts []FlashData return
Session SessionData }
Static StaticData c.Redirect(http.StatusSeeOther, "/admin/")
Peers []User return
TotalPeers int }
Device Device
LdapDisabled bool device := s.peers.GetDevice(currentSession.DeviceName)
}{ users := s.peers.GetFilteredAndSortedPeers(currentSession.DeviceName, currentSession.SortedBy["peers"], currentSession.SortDirection["peers"], currentSession.Search["peers"])
Route: c.Request.URL.Path,
Alerts: s.getFlashes(c), c.HTML(http.StatusOK, "admin_index.html", gin.H{
Session: currentSession, "Route": c.Request.URL.Path,
Static: s.getStaticData(), "Alerts": GetFlashes(c),
Peers: users, "Session": currentSession,
TotalPeers: len(s.users.GetAllUsers()), "Static": s.getStaticData(),
Device: device, "Peers": users,
LdapDisabled: s.ldapDisabled, "TotalPeers": len(s.peers.GetAllPeers(currentSession.DeviceName)),
"Users": s.users.GetUsers(),
"Device": device,
"DeviceNames": s.GetDeviceNames(),
}) })
} }
func (s *Server) GetUserIndex(c *gin.Context) { func (s *Server) GetUserIndex(c *gin.Context) {
currentSession := s.getSessionData(c) currentSession := GetSessionData(c)
sort := c.Query("sort") sort := c.Query("sort")
if sort != "" { if sort != "" {
if currentSession.SortedBy != sort { if currentSession.SortedBy["userpeers"] != sort {
currentSession.SortedBy = sort currentSession.SortedBy["userpeers"] = sort
currentSession.SortDirection = "asc" currentSession.SortDirection["userpeers"] = "asc"
} else { } else {
if currentSession.SortDirection == "asc" { if currentSession.SortDirection["userpeers"] == "asc" {
currentSession.SortDirection = "desc" currentSession.SortDirection["userpeers"] = "desc"
} else { } else {
currentSession.SortDirection = "asc" currentSession.SortDirection["userpeers"] = "asc"
} }
} }
if err := s.updateSessionData(c, currentSession); err != nil { if err := UpdateSessionData(c, currentSession); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "sort error", "failed to save session") s.GetHandleError(c, http.StatusInternalServerError, "sort error", "failed to save session")
return return
} }
@@ -120,69 +132,71 @@ func (s *Server) GetUserIndex(c *gin.Context) {
return return
} }
device := s.users.GetDevice() peers := s.peers.GetSortedPeersForEmail(currentSession.SortedBy["userpeers"], currentSession.SortDirection["userpeers"], currentSession.Email)
users := s.users.GetSortedUsersForEmail(currentSession.SortedBy, currentSession.SortDirection, currentSession.Email)
c.HTML(http.StatusOK, "user_index.html", struct { c.HTML(http.StatusOK, "user_index.html", gin.H{
Route string "Route": c.Request.URL.Path,
Alerts []FlashData "Alerts": GetFlashes(c),
Session SessionData "Session": currentSession,
Static StaticData "Static": s.getStaticData(),
Peers []User "Peers": peers,
TotalPeers int "TotalPeers": len(peers),
Device Device "Users": []users.User{*s.users.GetUser(currentSession.Email)},
}{ "Device": s.peers.GetDevice(currentSession.DeviceName),
Route: c.Request.URL.Path, "DeviceNames": s.GetDeviceNames(),
Alerts: s.getFlashes(c), "UserManagePeers": s.config.WG.UserManagePeers,
Session: currentSession,
Static: s.getStaticData(),
Peers: users,
TotalPeers: len(users),
Device: device,
}) })
} }
func (s *Server) updateFormInSession(c *gin.Context, formData interface{}) error { func (s *Server) updateFormInSession(c *gin.Context, formData interface{}) error {
currentSession := s.getSessionData(c) currentSession := GetSessionData(c)
currentSession.FormData = formData currentSession.FormData = formData
if err := s.updateSessionData(c, currentSession); err != nil { if err := UpdateSessionData(c, currentSession); err != nil {
return err return errors.WithMessage(err, "failed to update form in session")
} }
return nil return nil
} }
func (s *Server) setNewUserFormInSession(c *gin.Context) (SessionData, error) { func (s *Server) setNewPeerFormInSession(c *gin.Context) (SessionData, error) {
currentSession := s.getSessionData(c) currentSession := GetSessionData(c)
// If session does not contain a user form ignore update
// If session does not contain a peer form ignore update
// If url contains a formerr parameter reset the form // If url contains a formerr parameter reset the form
if currentSession.FormData == nil || c.Query("formerr") == "" { if currentSession.FormData == nil || c.Query("formerr") == "" {
user, err := s.PrepareNewUser() user, err := s.PrepareNewPeer(currentSession.DeviceName)
if err != nil { if err != nil {
return currentSession, err return currentSession, errors.WithMessage(err, "failed to prepare new peer")
} }
currentSession.FormData = user currentSession.FormData = user
} }
if err := s.updateSessionData(c, currentSession); err != nil { if err := UpdateSessionData(c, currentSession); err != nil {
return currentSession, err return currentSession, errors.WithMessage(err, "failed to update peer form in session")
} }
return currentSession, nil return currentSession, nil
} }
func (s *Server) setFormInSession(c *gin.Context, formData interface{}) (SessionData, error) { func (s *Server) setFormInSession(c *gin.Context, formData interface{}) (SessionData, error) {
currentSession := s.getSessionData(c) currentSession := GetSessionData(c)
// If session does not contain a form ignore update // If session does not contain a form ignore update
// If url contains a formerr parameter reset the form // If url contains a formerr parameter reset the form
if currentSession.FormData == nil || c.Query("formerr") == "" { if currentSession.FormData == nil || c.Query("formerr") == "" {
currentSession.FormData = formData currentSession.FormData = formData
} }
if err := s.updateSessionData(c, currentSession); err != nil { if err := UpdateSessionData(c, currentSession); err != nil {
return currentSession, err return currentSession, errors.WithMessage(err, "failed to set form in session")
} }
return currentSession, nil return currentSession, nil
} }
func (s *Server) isUserStillValid(email string) bool {
if s.users.GetUser(email) == nil {
return false
}
return true
}

View File

@@ -1,114 +1,118 @@
package server package server
import ( import (
"fmt"
"net/http" "net/http"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/common" "github.com/h44z/wg-portal/internal/common"
"github.com/h44z/wg-portal/internal/wireguard"
csrf "github.com/utrack/gin-csrf"
) )
func (s *Server) GetAdminEditInterface(c *gin.Context) { func (s *Server) GetAdminEditInterface(c *gin.Context) {
device := s.users.GetDevice() currentSession := GetSessionData(c)
users := s.users.GetAllUsers() device := s.peers.GetDevice(currentSession.DeviceName)
currentSession, err := s.setFormInSession(c, device) currentSession, err := s.setFormInSession(c, device)
if err != nil { if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "Session error", err.Error()) s.GetHandleError(c, http.StatusInternalServerError, "Session error", err.Error())
return return
} }
c.HTML(http.StatusOK, "admin_edit_interface.html", struct { c.HTML(http.StatusOK, "admin_edit_interface.html", gin.H{
Route string "Route": c.Request.URL.Path,
Alerts []FlashData "Alerts": GetFlashes(c),
Session SessionData "Session": currentSession,
Static StaticData "Static": s.getStaticData(),
Peers []User "Device": currentSession.FormData.(wireguard.Device),
Device Device "EditableKeys": s.config.Core.EditableKeys,
EditableKeys bool "DeviceNames": s.GetDeviceNames(),
}{ "Csrf": csrf.GetToken(c),
Route: c.Request.URL.Path,
Alerts: s.getFlashes(c),
Session: currentSession,
Static: s.getStaticData(),
Peers: users,
Device: currentSession.FormData.(Device),
EditableKeys: s.config.Core.EditableKeys,
}) })
} }
func (s *Server) PostAdminEditInterface(c *gin.Context) { func (s *Server) PostAdminEditInterface(c *gin.Context) {
currentSession := s.getSessionData(c) currentSession := GetSessionData(c)
var formDevice Device var formDevice wireguard.Device
if currentSession.FormData != nil { if currentSession.FormData != nil {
formDevice = currentSession.FormData.(Device) formDevice = currentSession.FormData.(wireguard.Device)
} }
if err := c.ShouldBind(&formDevice); err != nil { if err := c.ShouldBind(&formDevice); err != nil {
_ = s.updateFormInSession(c, formDevice) _ = s.updateFormInSession(c, formDevice)
s.setFlashMessage(c, err.Error(), "danger") SetFlashMessage(c, err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=bind") c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=bind")
return return
} }
// Clean list input // Clean list input
formDevice.IPs = common.ParseStringList(formDevice.IPsStr) formDevice.IPsStr = common.ListToString(common.ParseStringList(formDevice.IPsStr))
formDevice.AllowedIPs = common.ParseStringList(formDevice.AllowedIPsStr) formDevice.DefaultAllowedIPsStr = common.ListToString(common.ParseStringList(formDevice.DefaultAllowedIPsStr))
formDevice.DNS = common.ParseStringList(formDevice.DNSStr) formDevice.DNSStr = common.ListToString(common.ParseStringList(formDevice.DNSStr))
formDevice.IPsStr = common.ListToString(formDevice.IPs)
formDevice.AllowedIPsStr = common.ListToString(formDevice.AllowedIPs) // Clean interface parameters based on interface type
formDevice.DNSStr = common.ListToString(formDevice.DNS) switch formDevice.Type {
case wireguard.DeviceTypeClient:
formDevice.ListenPort = 0
formDevice.DefaultEndpoint = ""
formDevice.DefaultAllowedIPsStr = ""
formDevice.DefaultPersistentKeepalive = 0
formDevice.SaveConfig = false
case wireguard.DeviceTypeServer:
}
// Update WireGuard device // Update WireGuard device
err := s.wg.UpdateDevice(formDevice.DeviceName, formDevice.GetDeviceConfig()) err := s.wg.UpdateDevice(formDevice.DeviceName, formDevice.GetConfig())
if err != nil { if err != nil {
_ = s.updateFormInSession(c, formDevice) _ = s.updateFormInSession(c, formDevice)
s.setFlashMessage(c, "Failed to update device in WireGuard: "+err.Error(), "danger") SetFlashMessage(c, "Failed to update device in WireGuard: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=wg") c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=wg")
return return
} }
// Update in database // Update in database
err = s.users.UpdateDevice(formDevice) err = s.peers.UpdateDevice(formDevice)
if err != nil { if err != nil {
_ = s.updateFormInSession(c, formDevice) _ = s.updateFormInSession(c, formDevice)
s.setFlashMessage(c, "Failed to update device in database: "+err.Error(), "danger") SetFlashMessage(c, "Failed to update device in database: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=update") c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=update")
return return
} }
// Update WireGuard config file // Update WireGuard config file
err = s.WriteWireGuardConfigFile() err = s.WriteWireGuardConfigFile(currentSession.DeviceName)
if err != nil { if err != nil {
_ = s.updateFormInSession(c, formDevice) _ = s.updateFormInSession(c, formDevice)
s.setFlashMessage(c, "Failed to update wireguard config-file: "+err.Error(), "danger") SetFlashMessage(c, "Failed to update WireGuard config-file: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=update") c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=update")
return return
} }
// Update interface IP address // Update interface IP address
if s.config.WG.ManageIPAddresses { if s.config.WG.ManageIPAddresses {
if err := s.wg.SetIPAddress(formDevice.IPs); err != nil { if err := s.wg.SetIPAddress(currentSession.DeviceName, formDevice.GetIPAddresses()); err != nil {
_ = s.updateFormInSession(c, formDevice) _ = s.updateFormInSession(c, formDevice)
s.setFlashMessage(c, "Failed to update ip address: "+err.Error(), "danger") SetFlashMessage(c, "Failed to update ip address: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=update") c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=update")
} }
if err := s.wg.SetMTU(formDevice.Mtu); err != nil { if err := s.wg.SetMTU(currentSession.DeviceName, formDevice.Mtu); err != nil {
_ = s.updateFormInSession(c, formDevice) _ = s.updateFormInSession(c, formDevice)
s.setFlashMessage(c, "Failed to update MTU: "+err.Error(), "danger") SetFlashMessage(c, "Failed to update MTU: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=update") c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=update")
} }
} }
s.setFlashMessage(c, "Changes applied successfully!", "success") SetFlashMessage(c, "Changes applied successfully!", "success")
if !s.config.WG.ManageIPAddresses { if !s.config.WG.ManageIPAddresses {
s.setFlashMessage(c, "WireGuard must be restarted to apply ip changes.", "warning") SetFlashMessage(c, "WireGuard must be restarted to apply ip changes.", "warning")
} }
c.Redirect(http.StatusSeeOther, "/admin/device/edit") c.Redirect(http.StatusSeeOther, "/admin/device/edit")
} }
func (s *Server) GetInterfaceConfig(c *gin.Context) { func (s *Server) GetInterfaceConfig(c *gin.Context) {
device := s.users.GetDevice() currentSession := GetSessionData(c)
users := s.users.GetActiveUsers() device := s.peers.GetDevice(currentSession.DeviceName)
cfg, err := device.GetDeviceConfigFile(users) peers := s.peers.GetActivePeers(device.DeviceName)
cfg, err := device.GetConfigFile(peers, s.config.Core.WGExporterFriendlyNames)
if err != nil { if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "ConfigFile error", err.Error()) s.GetHandleError(c, http.StatusInternalServerError, "ConfigFile error", err.Error())
return return
@@ -121,20 +125,53 @@ func (s *Server) GetInterfaceConfig(c *gin.Context) {
return return
} }
func (s *Server) GetSaveConfig(c *gin.Context) {
currentSession := GetSessionData(c)
err := s.WriteWireGuardConfigFile(currentSession.DeviceName)
if err != nil {
SetFlashMessage(c, "Failed to save WireGuard config-file: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/")
return
}
SetFlashMessage(c, "Updated WireGuard config-file", "success")
c.Redirect(http.StatusSeeOther, "/admin/")
return
}
func (s *Server) GetApplyGlobalConfig(c *gin.Context) { func (s *Server) GetApplyGlobalConfig(c *gin.Context) {
device := s.users.GetDevice() currentSession := GetSessionData(c)
users := s.users.GetAllUsers() device := s.peers.GetDevice(currentSession.DeviceName)
peers := s.peers.GetAllPeers(device.DeviceName)
for _, user := range users { if device.Type == wireguard.DeviceTypeClient {
user.AllowedIPs = device.AllowedIPs SetFlashMessage(c, "Cannot apply global configuration while interface is in client mode.", "danger")
user.AllowedIPsStr = device.AllowedIPsStr
if err := s.users.UpdateUser(user); err != nil {
s.setFlashMessage(c, err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/device/edit") c.Redirect(http.StatusSeeOther, "/admin/device/edit")
} return
} }
s.setFlashMessage(c, "Allowed ip's updated for all clients.", "success") updateCounter := 0
for _, peer := range peers {
if peer.IgnoreGlobalSettings {
continue
}
peer.AllowedIPsStr = device.DefaultAllowedIPsStr
peer.Endpoint = device.DefaultEndpoint
peer.PersistentKeepalive = device.DefaultPersistentKeepalive
peer.DNSStr = device.DNSStr
peer.Mtu = device.Mtu
if err := s.peers.UpdatePeer(peer); err != nil {
SetFlashMessage(c, err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/device/edit")
return
}
updateCounter++
}
SetFlashMessage(c, fmt.Sprintf("Global configuration updated for %d clients.", updateCounter), "success")
c.Redirect(http.StatusSeeOther, "/admin/device/edit") c.Redirect(http.StatusSeeOther, "/admin/device/edit")
return return
} }

View File

@@ -10,9 +10,12 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/common" "github.com/h44z/wg-portal/internal/common"
"github.com/h44z/wg-portal/internal/ldap" "github.com/h44z/wg-portal/internal/users"
log "github.com/sirupsen/logrus" "github.com/h44z/wg-portal/internal/wireguard"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tatsushid/go-fastping" "github.com/tatsushid/go-fastping"
csrf "github.com/utrack/gin-csrf"
) )
type LdapCreateForm struct { type LdapCreateForm struct {
@@ -21,136 +24,129 @@ type LdapCreateForm struct {
} }
func (s *Server) GetAdminEditPeer(c *gin.Context) { func (s *Server) GetAdminEditPeer(c *gin.Context) {
device := s.users.GetDevice() peer := s.peers.GetPeerByKey(c.Query("pkey"))
user := s.users.GetUserByKey(c.Query("pkey"))
currentSession, err := s.setFormInSession(c, user) currentSession, err := s.setFormInSession(c, peer)
if err != nil { if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "Session error", err.Error()) s.GetHandleError(c, http.StatusInternalServerError, "Session error", err.Error())
return return
} }
c.HTML(http.StatusOK, "admin_edit_client.html", struct { c.HTML(http.StatusOK, "admin_edit_client.html", gin.H{
Route string "Route": c.Request.URL.Path,
Alerts []FlashData "Alerts": GetFlashes(c),
Session SessionData "Session": currentSession,
Static StaticData "Static": s.getStaticData(),
Peer User "Peer": currentSession.FormData.(wireguard.Peer),
Device Device "EditableKeys": s.config.Core.EditableKeys,
EditableKeys bool "Device": s.peers.GetDevice(currentSession.DeviceName),
}{ "DeviceNames": s.GetDeviceNames(),
Route: c.Request.URL.Path, "AdminEmail": s.config.Core.AdminUser,
Alerts: s.getFlashes(c), "Csrf": csrf.GetToken(c),
Session: currentSession,
Static: s.getStaticData(),
Peer: currentSession.FormData.(User),
Device: device,
EditableKeys: s.config.Core.EditableKeys,
}) })
} }
func (s *Server) PostAdminEditPeer(c *gin.Context) { func (s *Server) PostAdminEditPeer(c *gin.Context) {
currentUser := s.users.GetUserByKey(c.Query("pkey")) currentPeer := s.peers.GetPeerByKey(c.Query("pkey"))
urlEncodedKey := url.QueryEscape(c.Query("pkey")) urlEncodedKey := url.QueryEscape(c.Query("pkey"))
currentSession := s.getSessionData(c) currentSession := GetSessionData(c)
var formUser User var formPeer wireguard.Peer
if currentSession.FormData != nil { if currentSession.FormData != nil {
formUser = currentSession.FormData.(User) formPeer = currentSession.FormData.(wireguard.Peer)
} }
if err := c.ShouldBind(&formUser); err != nil { if err := c.ShouldBind(&formPeer); err != nil {
_ = s.updateFormInSession(c, formUser) _ = s.updateFormInSession(c, formPeer)
s.setFlashMessage(c, "failed to bind form data: "+err.Error(), "danger") SetFlashMessage(c, "failed to bind form data: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/peer/edit?pkey="+urlEncodedKey+"&formerr=bind") c.Redirect(http.StatusSeeOther, "/admin/peer/edit?pkey="+urlEncodedKey+"&formerr=bind")
return return
} }
// Clean list input // Clean list input
formUser.IPs = common.ParseStringList(formUser.IPsStr) formPeer.IPsStr = common.ListToString(common.ParseStringList(formPeer.IPsStr))
formUser.AllowedIPs = common.ParseStringList(formUser.AllowedIPsStr) formPeer.AllowedIPsStr = common.ListToString(common.ParseStringList(formPeer.AllowedIPsStr))
formUser.IPsStr = common.ListToString(formUser.IPs) formPeer.AllowedIPsSrvStr = common.ListToString(common.ParseStringList(formPeer.AllowedIPsSrvStr))
formUser.AllowedIPsStr = common.ListToString(formUser.AllowedIPs)
disabled := c.PostForm("isdisabled") != "" disabled := c.PostForm("isdisabled") != ""
now := time.Now() now := time.Now()
if disabled && currentUser.DeactivatedAt == nil { if disabled && currentPeer.DeactivatedAt == nil {
formUser.DeactivatedAt = &now formPeer.DeactivatedAt = &now
formPeer.DeactivatedReason = wireguard.DeactivatedReasonAdminEdit
} else if !disabled { } else if !disabled {
formUser.DeactivatedAt = nil formPeer.DeactivatedAt = nil
formPeer.DeactivatedReason = ""
// If a peer was deactivated due to expiry, remove the expires-at date to avoid
// unwanted re-expiry.
if currentPeer.DeactivatedReason == wireguard.DeactivatedReasonExpired {
formPeer.ExpiresAt = nil
}
} }
// Update in database // Update in database
if err := s.UpdateUser(formUser, now); err != nil { if err := s.UpdatePeer(formPeer, now); err != nil {
_ = s.updateFormInSession(c, formUser) _ = s.updateFormInSession(c, formPeer)
s.setFlashMessage(c, "failed to update user: "+err.Error(), "danger") SetFlashMessage(c, "failed to update user: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/peer/edit?pkey="+urlEncodedKey+"&formerr=update") c.Redirect(http.StatusSeeOther, "/admin/peer/edit?pkey="+urlEncodedKey+"&formerr=update")
return return
} }
s.setFlashMessage(c, "changes applied successfully", "success") SetFlashMessage(c, "changes applied successfully", "success")
c.Redirect(http.StatusSeeOther, "/admin/peer/edit?pkey="+urlEncodedKey) c.Redirect(http.StatusSeeOther, "/admin/peer/edit?pkey="+urlEncodedKey)
} }
func (s *Server) GetAdminCreatePeer(c *gin.Context) { func (s *Server) GetAdminCreatePeer(c *gin.Context) {
device := s.users.GetDevice() currentSession, err := s.setNewPeerFormInSession(c)
currentSession, err := s.setNewUserFormInSession(c)
if err != nil { if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "Session error", err.Error()) s.GetHandleError(c, http.StatusInternalServerError, "Session error", err.Error())
return return
} }
c.HTML(http.StatusOK, "admin_edit_client.html", struct { c.HTML(http.StatusOK, "admin_edit_client.html", gin.H{
Route string "Route": c.Request.URL.Path,
Alerts []FlashData "Alerts": GetFlashes(c),
Session SessionData "Session": currentSession,
Static StaticData "Static": s.getStaticData(),
Peer User "Peer": currentSession.FormData.(wireguard.Peer),
Device Device "EditableKeys": s.config.Core.EditableKeys,
EditableKeys bool "Device": s.peers.GetDevice(currentSession.DeviceName),
}{ "DeviceNames": s.GetDeviceNames(),
Route: c.Request.URL.Path, "AdminEmail": s.config.Core.AdminUser,
Alerts: s.getFlashes(c), "Csrf": csrf.GetToken(c),
Session: currentSession,
Static: s.getStaticData(),
Peer: currentSession.FormData.(User),
Device: device,
EditableKeys: s.config.Core.EditableKeys,
}) })
} }
func (s *Server) PostAdminCreatePeer(c *gin.Context) { func (s *Server) PostAdminCreatePeer(c *gin.Context) {
currentSession := s.getSessionData(c) currentSession := GetSessionData(c)
var formUser User var formPeer wireguard.Peer
if currentSession.FormData != nil { if currentSession.FormData != nil {
formUser = currentSession.FormData.(User) formPeer = currentSession.FormData.(wireguard.Peer)
} }
if err := c.ShouldBind(&formUser); err != nil { if err := c.ShouldBind(&formPeer); err != nil {
_ = s.updateFormInSession(c, formUser) _ = s.updateFormInSession(c, formPeer)
s.setFlashMessage(c, "failed to bind form data: "+err.Error(), "danger") SetFlashMessage(c, "failed to bind form data: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/peer/create?formerr=bind") c.Redirect(http.StatusSeeOther, "/admin/peer/create?formerr=bind")
return return
} }
// Clean list input // Clean list input
formUser.IPs = common.ParseStringList(formUser.IPsStr) formPeer.IPsStr = common.ListToString(common.ParseStringList(formPeer.IPsStr))
formUser.AllowedIPs = common.ParseStringList(formUser.AllowedIPsStr) formPeer.AllowedIPsStr = common.ListToString(common.ParseStringList(formPeer.AllowedIPsStr))
formUser.IPsStr = common.ListToString(formUser.IPs) formPeer.AllowedIPsSrvStr = common.ListToString(common.ParseStringList(formPeer.AllowedIPsSrvStr))
formUser.AllowedIPsStr = common.ListToString(formUser.AllowedIPs)
disabled := c.PostForm("isdisabled") != "" disabled := c.PostForm("isdisabled") != ""
now := time.Now() now := time.Now()
if disabled { if disabled {
formUser.DeactivatedAt = &now formPeer.DeactivatedAt = &now
formPeer.DeactivatedReason = wireguard.DeactivatedReasonAdminCreate
} }
if err := s.CreateUser(formUser); err != nil { if err := s.CreatePeer(currentSession.DeviceName, formPeer); err != nil {
_ = s.updateFormInSession(c, formUser) _ = s.updateFormInSession(c, formPeer)
s.setFlashMessage(c, "failed to add user: "+err.Error(), "danger") SetFlashMessage(c, "failed to add user: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/peer/create?formerr=create") c.Redirect(http.StatusSeeOther, "/admin/peer/create?formerr=create")
return return
} }
s.setFlashMessage(c, "client created successfully", "success") SetFlashMessage(c, "client created successfully", "success")
c.Redirect(http.StatusSeeOther, "/admin") c.Redirect(http.StatusSeeOther, "/admin")
} }
@@ -161,34 +157,28 @@ func (s *Server) GetAdminCreateLdapPeers(c *gin.Context) {
return return
} }
c.HTML(http.StatusOK, "admin_create_clients.html", struct { c.HTML(http.StatusOK, "admin_create_clients.html", gin.H{
Route string "Route": c.Request.URL.Path,
Alerts []FlashData "Alerts": GetFlashes(c),
Session SessionData "Session": currentSession,
Static StaticData "Static": s.getStaticData(),
Users []*ldap.UserCacheHolderEntry "Users": s.users.GetFilteredAndSortedUsers("lastname", "asc", ""),
FormData LdapCreateForm "FormData": currentSession.FormData.(LdapCreateForm),
Device Device "Device": s.peers.GetDevice(currentSession.DeviceName),
}{ "DeviceNames": s.GetDeviceNames(),
Route: c.Request.URL.Path, "Csrf": csrf.GetToken(c),
Alerts: s.getFlashes(c),
Session: currentSession,
Static: s.getStaticData(),
Users: s.ldapUsers.GetSortedUsers("sn", "asc"),
FormData: currentSession.FormData.(LdapCreateForm),
Device: s.users.GetDevice(),
}) })
} }
func (s *Server) PostAdminCreateLdapPeers(c *gin.Context) { func (s *Server) PostAdminCreateLdapPeers(c *gin.Context) {
currentSession := s.getSessionData(c) currentSession := GetSessionData(c)
var formData LdapCreateForm var formData LdapCreateForm
if currentSession.FormData != nil { if currentSession.FormData != nil {
formData = currentSession.FormData.(LdapCreateForm) formData = currentSession.FormData.(LdapCreateForm)
} }
if err := c.ShouldBind(&formData); err != nil { if err := c.ShouldBind(&formData); err != nil {
_ = s.updateFormInSession(c, formData) _ = s.updateFormInSession(c, formData)
s.setFlashMessage(c, "failed to bind form data: "+err.Error(), "danger") SetFlashMessage(c, "failed to bind form data: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/peer/createldap?formerr=bind") c.Redirect(http.StatusSeeOther, "/admin/peer/createldap?formerr=bind")
return return
} }
@@ -196,48 +186,48 @@ func (s *Server) PostAdminCreateLdapPeers(c *gin.Context) {
emails := common.ParseStringList(formData.Emails) emails := common.ParseStringList(formData.Emails)
for i := range emails { for i := range emails {
// TODO: also check email addr for validity? // TODO: also check email addr for validity?
if !strings.ContainsRune(emails[i], '@') || s.ldapUsers.GetUserDNByMail(emails[i]) == "" { if !strings.ContainsRune(emails[i], '@') {
_ = s.updateFormInSession(c, formData) _ = s.updateFormInSession(c, formData)
s.setFlashMessage(c, "invalid email address: "+emails[i], "danger") SetFlashMessage(c, "invalid email address: "+emails[i], "danger")
c.Redirect(http.StatusSeeOther, "/admin/peer/createldap?formerr=mail") c.Redirect(http.StatusSeeOther, "/admin/peer/createldap?formerr=mail")
return return
} }
} }
log.Infof("creating %d ldap peers", len(emails)) logrus.Infof("creating %d ldap peers", len(emails))
for i := range emails { for i := range emails {
if err := s.CreateUserByEmail(emails[i], formData.Identifier, false); err != nil { if err := s.CreatePeerByEmail(currentSession.DeviceName, emails[i], formData.Identifier); err != nil {
_ = s.updateFormInSession(c, formData) _ = s.updateFormInSession(c, formData)
s.setFlashMessage(c, "failed to add user: "+err.Error(), "danger") SetFlashMessage(c, "failed to add user: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/peer/createldap?formerr=create") c.Redirect(http.StatusSeeOther, "/admin/peer/createldap?formerr=create")
return return
} }
} }
s.setFlashMessage(c, "client(s) created successfully", "success") SetFlashMessage(c, "client(s) created successfully", "success")
c.Redirect(http.StatusSeeOther, "/admin/peer/createldap") c.Redirect(http.StatusSeeOther, "/admin/peer/createldap")
} }
func (s *Server) GetAdminDeletePeer(c *gin.Context) { func (s *Server) GetAdminDeletePeer(c *gin.Context) {
currentUser := s.users.GetUserByKey(c.Query("pkey")) currentPeer := s.peers.GetPeerByKey(c.Query("pkey"))
if err := s.DeleteUser(currentUser); err != nil { if err := s.DeletePeer(currentPeer); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "Deletion error", err.Error()) s.GetHandleError(c, http.StatusInternalServerError, "Deletion error", err.Error())
return return
} }
s.setFlashMessage(c, "user deleted successfully", "success") SetFlashMessage(c, "peer deleted successfully", "success")
c.Redirect(http.StatusSeeOther, "/admin") c.Redirect(http.StatusSeeOther, "/admin")
} }
func (s *Server) GetPeerQRCode(c *gin.Context) { func (s *Server) GetPeerQRCode(c *gin.Context) {
user := s.users.GetUserByKey(c.Query("pkey")) peer := s.peers.GetPeerByKey(c.Query("pkey"))
currentSession := s.getSessionData(c) currentSession := GetSessionData(c)
if !currentSession.IsAdmin && user.Email != currentSession.Email { if !currentSession.IsAdmin && peer.Email != currentSession.Email {
s.GetHandleError(c, http.StatusUnauthorized, "No permissions", "You don't have permissions to view this resource!") s.GetHandleError(c, http.StatusUnauthorized, "No permissions", "You don't have permissions to view this resource!")
return return
} }
png, err := user.GetQRCode() png, err := peer.GetQRCode()
if err != nil { if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "QRCode error", err.Error()) s.GetHandleError(c, http.StatusInternalServerError, "QRCode error", err.Error())
return return
@@ -247,91 +237,54 @@ func (s *Server) GetPeerQRCode(c *gin.Context) {
} }
func (s *Server) GetPeerConfig(c *gin.Context) { func (s *Server) GetPeerConfig(c *gin.Context) {
user := s.users.GetUserByKey(c.Query("pkey")) peer := s.peers.GetPeerByKey(c.Query("pkey"))
currentSession := s.getSessionData(c) currentSession := GetSessionData(c)
if !currentSession.IsAdmin && user.Email != currentSession.Email { if !currentSession.IsAdmin && peer.Email != currentSession.Email {
s.GetHandleError(c, http.StatusUnauthorized, "No permissions", "You don't have permissions to view this resource!") s.GetHandleError(c, http.StatusUnauthorized, "No permissions", "You don't have permissions to view this resource!")
return return
} }
cfg, err := user.GetClientConfigFile(s.users.GetDevice()) cfg, err := peer.GetConfigFile(s.peers.GetDevice(peer.DeviceName))
if err != nil { if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "ConfigFile error", err.Error()) s.GetHandleError(c, http.StatusInternalServerError, "ConfigFile error", err.Error())
return return
} }
c.Header("Content-Disposition", "attachment; filename="+user.GetConfigFileName()) c.Header("Content-Disposition", "attachment; filename="+peer.GetConfigFileName())
c.Data(http.StatusOK, "application/config", cfg) c.Data(http.StatusOK, "application/config", cfg)
return return
} }
func (s *Server) GetPeerConfigMail(c *gin.Context) { func (s *Server) GetPeerConfigMail(c *gin.Context) {
user := s.users.GetUserByKey(c.Query("pkey")) peer := s.peers.GetPeerByKey(c.Query("pkey"))
currentSession := s.getSessionData(c) currentSession := GetSessionData(c)
if !currentSession.IsAdmin && user.Email != currentSession.Email { if !currentSession.IsAdmin && peer.Email != currentSession.Email {
s.GetHandleError(c, http.StatusUnauthorized, "No permissions", "You don't have permissions to view this resource!") s.GetHandleError(c, http.StatusUnauthorized, "No permissions", "You don't have permissions to view this resource!")
return return
} }
cfg, err := user.GetClientConfigFile(s.users.GetDevice()) if err := s.sendPeerConfigMail(peer); err != nil {
if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "ConfigFile error", err.Error())
return
}
png, err := user.GetQRCode()
if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "QRCode error", err.Error())
return
}
// Apply mail template
var tplBuff bytes.Buffer
if err := s.mailTpl.Execute(&tplBuff, struct {
Client User
QrcodePngName string
PortalUrl string
}{
Client: user,
QrcodePngName: "wireguard-config.png",
PortalUrl: s.config.Core.ExternalUrl,
}); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "Template error", err.Error())
return
}
// Send mail
attachments := []common.MailAttachment{
{
Name: user.GetConfigFileName(),
ContentType: "application/config",
Data: bytes.NewReader(cfg),
},
{
Name: "wireguard-config.png",
ContentType: "image/png",
Data: bytes.NewReader(png),
},
}
if err := common.SendEmailWithAttachments(s.config.Email, s.config.Core.MailFrom, "", "WireGuard VPN Configuration",
"Your mail client does not support HTML. Please find the configuration attached to this mail.", tplBuff.String(),
[]string{user.Email}, attachments); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "Email error", err.Error()) s.GetHandleError(c, http.StatusInternalServerError, "Email error", err.Error())
return return
} }
s.setFlashMessage(c, "mail sent successfully", "success") SetFlashMessage(c, "mail sent successfully", "success")
if strings.HasPrefix(c.Request.URL.Path, "/user") {
c.Redirect(http.StatusSeeOther, "/user/profile")
} else {
c.Redirect(http.StatusSeeOther, "/admin") c.Redirect(http.StatusSeeOther, "/admin")
}
} }
func (s *Server) GetPeerStatus(c *gin.Context) { func (s *Server) GetPeerStatus(c *gin.Context) {
user := s.users.GetUserByKey(c.Query("pkey")) peer := s.peers.GetPeerByKey(c.Query("pkey"))
currentSession := s.getSessionData(c) currentSession := GetSessionData(c)
if !currentSession.IsAdmin && user.Email != currentSession.Email { if !currentSession.IsAdmin && peer.Email != currentSession.Email {
s.GetHandleError(c, http.StatusUnauthorized, "No permissions", "You don't have permissions to view this resource!") s.GetHandleError(c, http.StatusUnauthorized, "No permissions", "You don't have permissions to view this resource!")
return return
} }
if user.Peer == nil { // no peer means disabled if peer.Peer == nil { // no peer means disabled
c.JSON(http.StatusOK, false) c.JSON(http.StatusOK, false)
return return
} }
@@ -339,7 +292,7 @@ func (s *Server) GetPeerStatus(c *gin.Context) {
isOnline := false isOnline := false
ping := make(chan bool) ping := make(chan bool)
defer close(ping) defer close(ping)
for _, cidr := range user.IPs { for _, cidr := range peer.GetIPAddresses() {
ip, _, _ := net.ParseCIDR(cidr) ip, _, _ := net.ParseCIDR(cidr)
var ra *net.IPAddr var ra *net.IPAddr
if common.IsIPv6(ip.String()) { if common.IsIPv6(ip.String()) {
@@ -371,3 +324,198 @@ func (s *Server) GetPeerStatus(c *gin.Context) {
c.JSON(http.StatusOK, isOnline) c.JSON(http.StatusOK, isOnline)
return return
} }
func (s *Server) GetAdminSendEmails(c *gin.Context) {
currentSession := GetSessionData(c)
if !currentSession.IsAdmin {
s.GetHandleError(c, http.StatusUnauthorized, "No permissions", "You don't have permissions to view this resource!")
return
}
peers := s.peers.GetActivePeers(currentSession.DeviceName)
for _, peer := range peers {
if err := s.sendPeerConfigMail(peer); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "Email error", err.Error())
return
}
}
SetFlashMessage(c, "emails sent successfully", "success")
c.Redirect(http.StatusSeeOther, "/admin")
}
func (s *Server) sendPeerConfigMail(peer wireguard.Peer) error {
user := s.users.GetUser(peer.Email)
cfg, err := peer.GetConfigFile(s.peers.GetDevice(peer.DeviceName))
if err != nil {
return errors.Wrap(err, "failed to get config file")
}
png, err := peer.GetQRCode()
if err != nil {
return errors.Wrap(err, "failed to get qr-code")
}
// Apply mail template
qrcodeFileName := "wireguard-qrcode.png"
var tplBuff bytes.Buffer
if err := s.mailTpl.Execute(&tplBuff, struct {
Peer wireguard.Peer
User *users.User
QrcodePngName string
PortalUrl string
}{
Peer: peer,
User: user,
QrcodePngName: qrcodeFileName,
PortalUrl: s.config.Core.ExternalUrl,
}); err != nil {
return errors.Wrap(err, "failed to execute mail template")
}
// Send mail
attachments := []common.MailAttachment{
{
Name: peer.GetConfigFileName(),
ContentType: "application/config",
Data: bytes.NewReader(cfg),
},
{
Name: qrcodeFileName,
ContentType: "image/png",
Data: bytes.NewReader(png),
Embedded: true,
},
{
Name: qrcodeFileName,
ContentType: "image/png",
Data: bytes.NewReader(png),
},
}
if err := common.SendEmailWithAttachments(s.config.Email, s.config.Core.MailFrom, "", "WireGuard VPN Configuration",
"Your mail client does not support HTML. Please find the configuration attached to this mail.", tplBuff.String(),
[]string{peer.Email}, attachments); err != nil {
return errors.Wrap(err, "failed to send email")
}
return nil
}
func (s *Server) GetUserCreatePeer(c *gin.Context) {
currentSession, err := s.setNewPeerFormInSession(c)
if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "Session error", err.Error())
return
}
c.HTML(http.StatusOK, "user_create_client.html", gin.H{
"Route": c.Request.URL.Path,
"Alerts": GetFlashes(c),
"Session": currentSession,
"Static": s.getStaticData(),
"Peer": currentSession.FormData.(wireguard.Peer),
"EditableKeys": s.config.Core.EditableKeys,
"Device": s.peers.GetDevice(currentSession.DeviceName),
"DeviceNames": s.GetDeviceNames(),
"AdminEmail": s.config.Core.AdminUser,
"Csrf": csrf.GetToken(c),
})
}
func (s *Server) PostUserCreatePeer(c *gin.Context) {
currentSession := GetSessionData(c)
var formPeer wireguard.Peer
if currentSession.FormData != nil {
formPeer = currentSession.FormData.(wireguard.Peer)
}
formPeer.Email = currentSession.Email
formPeer.Identifier = currentSession.Email
formPeer.DeviceType = wireguard.DeviceTypeServer
if err := c.ShouldBind(&formPeer); err != nil {
_ = s.updateFormInSession(c, formPeer)
SetFlashMessage(c, "failed to bind form data: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/user/peer/create?formerr=bind")
return
}
// if public key was manually set, remove the incorrect private key
if formPeer.PublicKey != currentSession.FormData.(wireguard.Peer).PublicKey {
formPeer.PrivateKey = ""
}
disabled := c.PostForm("isdisabled") != ""
now := time.Now()
if disabled {
formPeer.DeactivatedAt = &now
formPeer.DeactivatedReason = wireguard.DeactivatedReasonUserCreate
}
if err := s.CreatePeer(currentSession.DeviceName, formPeer); err != nil {
_ = s.updateFormInSession(c, formPeer)
SetFlashMessage(c, "failed to add user: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/user/peer/create?formerr=create")
return
}
SetFlashMessage(c, "client created successfully", "success")
c.Redirect(http.StatusSeeOther, "/user/profile")
}
func (s *Server) GetUserEditPeer(c *gin.Context) {
peer := s.peers.GetPeerByKey(c.Query("pkey"))
currentSession, err := s.setFormInSession(c, peer)
if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "Session error", err.Error())
return
}
if peer.Email != currentSession.Email {
s.GetHandleError(c, http.StatusUnauthorized, "No permissions", "You don't have permissions to view this resource!")
return
}
c.HTML(http.StatusOK, "user_edit_client.html", gin.H{
"Route": c.Request.URL.Path,
"Alerts": GetFlashes(c),
"Session": currentSession,
"Static": s.getStaticData(),
"Peer": currentSession.FormData.(wireguard.Peer),
"EditableKeys": s.config.Core.EditableKeys,
"Device": s.peers.GetDevice(currentSession.DeviceName),
"DeviceNames": s.GetDeviceNames(),
"AdminEmail": s.config.Core.AdminUser,
"Csrf": csrf.GetToken(c),
})
}
func (s *Server) PostUserEditPeer(c *gin.Context) {
currentPeer := s.peers.GetPeerByKey(c.Query("pkey"))
urlEncodedKey := url.QueryEscape(c.Query("pkey"))
currentSession := GetSessionData(c)
if currentPeer.Email != currentSession.Email {
s.GetHandleError(c, http.StatusUnauthorized, "No permissions", "You don't have permissions to view this resource!")
return
}
disabled := c.PostForm("isdisabled") != ""
now := time.Now()
if disabled && currentPeer.DeactivatedAt == nil {
currentPeer.DeactivatedAt = &now
currentPeer.DeactivatedReason = wireguard.DeactivatedReasonUserEdit
}
// Update in database
if err := s.UpdatePeer(currentPeer, now); err != nil {
_ = s.updateFormInSession(c, currentPeer)
SetFlashMessage(c, "failed to update user: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/user/peer/edit?pkey="+urlEncodedKey+"&formerr=update")
return
}
SetFlashMessage(c, "changes applied successfully", "success")
c.Redirect(http.StatusSeeOther, "/user/peer/edit?pkey="+urlEncodedKey)
}

View File

@@ -0,0 +1,212 @@
package server
import (
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/users"
csrf "github.com/utrack/gin-csrf"
"gorm.io/gorm"
)
func (s *Server) GetAdminUsersIndex(c *gin.Context) {
currentSession := GetSessionData(c)
sort := c.Query("sort")
if sort != "" {
if currentSession.SortedBy["users"] != sort {
currentSession.SortedBy["users"] = sort
currentSession.SortDirection["users"] = "asc"
} else {
if currentSession.SortDirection["users"] == "asc" {
currentSession.SortDirection["users"] = "desc"
} else {
currentSession.SortDirection["users"] = "asc"
}
}
if err := UpdateSessionData(c, currentSession); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "sort error", "failed to save session")
return
}
c.Redirect(http.StatusSeeOther, "/admin/users/")
return
}
search, searching := c.GetQuery("search")
if searching {
currentSession.Search["users"] = search
if err := UpdateSessionData(c, currentSession); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "search error", "failed to save session")
return
}
c.Redirect(http.StatusSeeOther, "/admin/users/")
return
}
dbUsers := s.users.GetFilteredAndSortedUsersUnscoped(currentSession.SortedBy["users"], currentSession.SortDirection["users"], currentSession.Search["users"])
c.HTML(http.StatusOK, "admin_user_index.html", gin.H{
"Route": c.Request.URL.Path,
"Alerts": GetFlashes(c),
"Session": currentSession,
"Static": s.getStaticData(),
"Users": dbUsers,
"TotalUsers": len(s.users.GetUsers()),
"Device": s.peers.GetDevice(currentSession.DeviceName),
"DeviceNames": s.GetDeviceNames(),
})
}
func (s *Server) GetAdminUsersEdit(c *gin.Context) {
user := s.users.GetUserUnscoped(c.Query("pkey"))
currentSession, err := s.setFormInSession(c, *user)
if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "Session error", err.Error())
return
}
c.HTML(http.StatusOK, "admin_edit_user.html", gin.H{
"Route": c.Request.URL.Path,
"Alerts": GetFlashes(c),
"Session": currentSession,
"Static": s.getStaticData(),
"User": currentSession.FormData.(users.User),
"Device": s.peers.GetDevice(currentSession.DeviceName),
"DeviceNames": s.GetDeviceNames(),
"Epoch": time.Time{},
"Csrf": csrf.GetToken(c),
})
}
func (s *Server) GetAdminUsersDelete(c *gin.Context) {
user := s.users.GetUserUnscoped(c.Query("pkey"))
if user == nil {
SetFlashMessage(c, "invalid user", "danger")
c.Redirect(http.StatusSeeOther, "/admin/users/")
return
}
urlEncodedKey := url.QueryEscape(c.Query("pkey"))
if err := s.HardDeleteUser(*user); err != nil {
SetFlashMessage(c, "failed to delete user: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/users/edit?pkey="+urlEncodedKey+"&formerr=delete")
return
}
SetFlashMessage(c, "user deleted successfully", "success")
c.Redirect(http.StatusSeeOther, "/admin/users/")
}
func (s *Server) PostAdminUsersEdit(c *gin.Context) {
currentUser := s.users.GetUserUnscoped(c.Query("pkey"))
if currentUser == nil {
SetFlashMessage(c, "invalid user", "danger")
c.Redirect(http.StatusSeeOther, "/admin/users/")
return
}
urlEncodedKey := url.QueryEscape(c.Query("pkey"))
currentSession := GetSessionData(c)
var formUser users.User
if currentSession.FormData != nil {
formUser = currentSession.FormData.(users.User)
}
if err := c.ShouldBind(&formUser); err != nil {
_ = s.updateFormInSession(c, formUser)
SetFlashMessage(c, "failed to bind form data: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/users/edit?pkey="+urlEncodedKey+"&formerr=bind")
return
}
disabled := c.PostForm("isdisabled") != ""
if disabled {
formUser.DeletedAt = gorm.DeletedAt{
Time: time.Now(),
Valid: true,
}
} else {
formUser.DeletedAt = gorm.DeletedAt{}
}
formUser.IsAdmin = c.PostForm("isadmin") != ""
if err := s.UpdateUser(formUser); err != nil {
_ = s.updateFormInSession(c, formUser)
SetFlashMessage(c, "failed to update user: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/users/edit?pkey="+urlEncodedKey+"&formerr=update")
return
}
SetFlashMessage(c, "changes applied successfully", "success")
c.Redirect(http.StatusSeeOther, "/admin/users/edit?pkey="+urlEncodedKey)
}
func (s *Server) GetAdminUsersCreate(c *gin.Context) {
user := users.User{}
currentSession, err := s.setFormInSession(c, user)
if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "Session error", err.Error())
return
}
c.HTML(http.StatusOK, "admin_edit_user.html", gin.H{
"Route": c.Request.URL.Path,
"Alerts": GetFlashes(c),
"Session": currentSession,
"Static": s.getStaticData(),
"User": currentSession.FormData.(users.User),
"Device": s.peers.GetDevice(currentSession.DeviceName),
"DeviceNames": s.GetDeviceNames(),
"Epoch": time.Time{},
"Csrf": csrf.GetToken(c),
})
}
func (s *Server) PostAdminUsersCreate(c *gin.Context) {
currentSession := GetSessionData(c)
var formUser users.User
if currentSession.FormData != nil {
formUser = currentSession.FormData.(users.User)
}
if err := c.ShouldBind(&formUser); err != nil {
_ = s.updateFormInSession(c, formUser)
SetFlashMessage(c, "failed to bind form data: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/users/create?formerr=bind")
return
}
if formUser.Password == "" {
_ = s.updateFormInSession(c, formUser)
SetFlashMessage(c, "invalid password", "danger")
c.Redirect(http.StatusSeeOther, "/admin/users/create?formerr=create")
return
}
disabled := c.PostForm("isdisabled") != ""
if disabled {
formUser.DeletedAt = gorm.DeletedAt{
Time: time.Now(),
Valid: true,
}
} else {
formUser.DeletedAt = gorm.DeletedAt{}
}
formUser.IsAdmin = c.PostForm("isadmin") == "true"
formUser.Source = users.UserSourceDatabase
if err := s.CreateUser(formUser, currentSession.DeviceName); err != nil {
_ = s.updateFormInSession(c, formUser)
SetFlashMessage(c, "failed to add user: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/users/create?formerr=create")
return
}
SetFlashMessage(c, "user created successfully", "success")
c.Redirect(http.StatusSeeOther, "/admin/users/")
}

View File

@@ -1,201 +0,0 @@
package server
import (
"crypto/md5"
"errors"
"fmt"
"io/ioutil"
"syscall"
"time"
"github.com/h44z/wg-portal/internal/common"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
func (s *Server) PrepareNewUser() (User, error) {
device := s.users.GetDevice()
user := User{}
user.IsNew = true
user.AllowedIPsStr = device.AllowedIPsStr
user.IPs = make([]string, len(device.IPs))
for i := range device.IPs {
freeIP, err := s.users.GetAvailableIp(device.IPs[i])
if err != nil {
return User{}, err
}
user.IPs[i] = freeIP
}
user.IPsStr = common.ListToString(user.IPs)
psk, err := wgtypes.GenerateKey()
if err != nil {
return User{}, err
}
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
return User{}, err
}
user.PresharedKey = psk.String()
user.PrivateKey = key.String()
user.PublicKey = key.PublicKey().String()
user.UID = fmt.Sprintf("u%x", md5.Sum([]byte(user.PublicKey)))
return user, nil
}
func (s *Server) CreateUserByEmail(email, identifierSuffix string, disabled bool) error {
ldapUser := s.ldapUsers.GetUserData(s.ldapUsers.GetUserDNByMail(email))
if ldapUser.DN == "" {
return errors.New("no user with email " + email + " found")
}
device := s.users.GetDevice()
user := User{}
user.AllowedIPsStr = device.AllowedIPsStr
user.IPs = make([]string, len(device.IPs))
for i := range device.IPs {
freeIP, err := s.users.GetAvailableIp(device.IPs[i])
if err != nil {
return err
}
user.IPs[i] = freeIP
}
user.IPsStr = common.ListToString(user.IPs)
psk, err := wgtypes.GenerateKey()
if err != nil {
return err
}
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
return err
}
user.PresharedKey = psk.String()
user.PrivateKey = key.String()
user.PublicKey = key.PublicKey().String()
user.UID = fmt.Sprintf("u%x", md5.Sum([]byte(user.PublicKey)))
user.Email = email
user.Identifier = fmt.Sprintf("%s %s (%s)", ldapUser.Firstname, ldapUser.Lastname, identifierSuffix)
now := time.Now()
if disabled {
user.DeactivatedAt = &now
}
return s.CreateUser(user)
}
func (s *Server) CreateUser(user User) error {
device := s.users.GetDevice()
user.AllowedIPsStr = device.AllowedIPsStr
if len(user.IPs) == 0 {
for i := range device.IPs {
freeIP, err := s.users.GetAvailableIp(device.IPs[i])
if err != nil {
return err
}
user.IPs[i] = freeIP
}
user.IPsStr = common.ListToString(user.IPs)
}
if user.PrivateKey == "" { // if private key is empty create a new one
psk, err := wgtypes.GenerateKey()
if err != nil {
return err
}
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
return err
}
user.PresharedKey = psk.String()
user.PrivateKey = key.String()
user.PublicKey = key.PublicKey().String()
}
user.UID = fmt.Sprintf("u%x", md5.Sum([]byte(user.PublicKey)))
// Create WireGuard interface
if user.DeactivatedAt == nil {
if err := s.wg.AddPeer(user.GetPeerConfig()); err != nil {
return err
}
}
// Create in database
if err := s.users.CreateUser(user); err != nil {
return err
}
return s.WriteWireGuardConfigFile()
}
func (s *Server) UpdateUser(user User, updateTime time.Time) error {
currentUser := s.users.GetUserByKey(user.PublicKey)
// Update WireGuard device
var err error
switch {
case user.DeactivatedAt == &updateTime:
err = s.wg.RemovePeer(user.PublicKey)
case user.DeactivatedAt == nil && currentUser.Peer != nil:
err = s.wg.UpdatePeer(user.GetPeerConfig())
case user.DeactivatedAt == nil && currentUser.Peer == nil:
err = s.wg.AddPeer(user.GetPeerConfig())
}
if err != nil {
return err
}
// Update in database
if err := s.users.UpdateUser(user); err != nil {
return err
}
return s.WriteWireGuardConfigFile()
}
func (s *Server) DeleteUser(user User) error {
// Delete WireGuard peer
if err := s.wg.RemovePeer(user.PublicKey); err != nil {
return err
}
// Delete in database
if err := s.users.DeleteUser(user); err != nil {
return err
}
return s.WriteWireGuardConfigFile()
}
func (s *Server) RestoreWireGuardInterface() error {
activeUsers := s.users.GetActiveUsers()
for i := range activeUsers {
if activeUsers[i].Peer == nil {
if err := s.wg.AddPeer(activeUsers[i].GetPeerConfig()); err != nil {
return err
}
}
}
return nil
}
func (s *Server) WriteWireGuardConfigFile() error {
if s.config.WG.WireGuardConfig == "" {
return nil // writing disabled
}
if err := syscall.Access(s.config.WG.WireGuardConfig, syscall.O_RDWR); err != nil {
return err
}
device := s.users.GetDevice()
cfg, err := device.GetDeviceConfigFile(s.users.GetActiveUsers())
if err != nil {
return err
}
if err := ioutil.WriteFile(s.config.WG.WireGuardConfig, cfg, 0644); err != nil {
return err
}
return nil
}

View File

@@ -1,34 +1,222 @@
package server package server
import ( import (
"strings"
"time" "time"
gldap "github.com/go-ldap/ldap/v3"
"github.com/h44z/wg-portal/internal/ldap" "github.com/h44z/wg-portal/internal/ldap"
log "github.com/sirupsen/logrus" "github.com/h44z/wg-portal/internal/users"
"github.com/h44z/wg-portal/internal/wireguard"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
) )
// SyncLdapAttributesWithWireGuard starts to synchronize the "disabled" attribute from ldap. func (s *Server) SyncLdapWithUserDatabase() {
// Users will be automatically disabled once they are disabled in ldap. logrus.Info("starting ldap user synchronization...")
// This method is blocking.
func (s *Server) SyncLdapAttributesWithWireGuard() error { running := true
allUsers := s.users.GetAllUsers() for running {
for i := range allUsers { // Main work here
user := allUsers[i] logrus.Trace("syncing ldap users to database...")
if user.LdapUser == nil { ldapUsers, err := ldap.FindAllObjects(&s.config.LDAP, ldap.Users)
continue // skip non ldap users if err != nil {
logrus.Errorf("failed to fetch users from ldap: %v", err)
continue
}
ldapGroups, err := ldap.FindAllObjects(&s.config.LDAP, ldap.Groups)
if err != nil {
logrus.Errorf("failed to fetch groups from ldap: %v", err)
continue
} }
if user.DeactivatedAt != nil { logrus.Tracef("found %d users and %d groups in ldap", len(ldapUsers), len(ldapGroups))
continue // skip already disabled interfaces
}
if ldap.IsLdapUserDisabled(allUsers[i].LdapUser.Attributes["userAccountControl"]) { // Update existing LDAP users
now := time.Now() s.updateLdapUsers(ldapUsers, ldapGroups)
user.DeactivatedAt = &now
if err := s.UpdateUser(user, now); err != nil { // Disable missing LDAP users
log.Errorf("Failed to disable user %s: %v", user.Email, err) s.disableMissingLdapUsers(ldapUsers)
logrus.Trace("synchronized ldap users to database")
// Select blocks until one of the cases happens
select {
case <-time.After(1 * time.Minute):
// Sleep for 1 minute
case <-s.ctx.Done():
logrus.Trace("ldap-sync shutting down (context ended)...")
running = false
continue
}
}
logrus.Info("ldap user synchronization stopped")
}
func (s Server) userIsInAdminGroup(userData *ldap.RawLdapData, groupTreeData []ldap.RawLdapData) bool {
if s.config.LDAP.EveryoneAdmin {
return true
}
if s.config.LDAP.AdminLdapGroup_ == nil {
return false
}
for _, userGroup := range userData.RawAttributes[s.config.LDAP.GroupMemberAttribute] {
var userGroupDn, _ = gldap.ParseDN(string(userGroup))
if s.dnIsAdminGroup(userGroupDn, groupTreeData) {
return true
}
}
return false
}
// dnIsAdminGroup checks if the given DN is equal to the admin group, or if it is included in a groupTree that has the
// admin group as parent/root.
//
// WGPortal-Admin (L0)
//
// \_ IT-Admin (L1)
// |_ Alice (L2)
// |_ Bob (L2)
// \_ Eve (L2)
// \_ External-Company (L1)
// |_ External-Admin (L2)
// |_ Sam (L3)
// \_ Steve (L3)
//
// All DNs in the example above are member of the admin group.
func (s Server) dnIsAdminGroup(dn *gldap.DN, groupTreeData []ldap.RawLdapData) bool {
if s.config.LDAP.AdminLdapGroup_ == nil {
return false
}
if s.config.LDAP.AdminLdapGroup_.Equal(dn) {
return true
}
// Recursively check the whole group tree
for _, group := range groupTreeData {
var groupDn, _ = gldap.ParseDN(group.DN)
if !dn.Equal(groupDn) {
continue
}
for _, parentGroupDn := range group.RawAttributes[s.config.LDAP.GroupMemberAttribute] {
var parentDn, _ = gldap.ParseDN(string(parentGroupDn))
if s.dnIsAdminGroup(parentDn, groupTreeData) {
return true
}
}
break
}
return false
}
func (s Server) userChangedInLdap(user *users.User, ldapData *ldap.RawLdapData, ldapGroupData []ldap.RawLdapData) bool {
if user.Firstname != ldapData.Attributes[s.config.LDAP.FirstNameAttribute] {
return true
}
if user.Lastname != ldapData.Attributes[s.config.LDAP.LastNameAttribute] {
return true
}
if user.Email != strings.ToLower(ldapData.Attributes[s.config.LDAP.EmailAttribute]) {
return true
}
if user.Phone != ldapData.Attributes[s.config.LDAP.PhoneAttribute] {
return true
}
if user.Source != users.UserSourceLdap {
return true
}
if user.DeletedAt.Valid {
return true
}
if user.IsAdmin != s.userIsInAdminGroup(ldapData, ldapGroupData) {
return true
}
return false
}
func (s *Server) disableMissingLdapUsers(ldapUsers []ldap.RawLdapData) {
// Disable missing LDAP users
activeUsers := s.users.GetUsers()
for i := range activeUsers {
if activeUsers[i].Source != users.UserSourceLdap {
continue
}
existsInLDAP := false
for j := range ldapUsers {
if activeUsers[i].Email == strings.ToLower(ldapUsers[j].Attributes[s.config.LDAP.EmailAttribute]) {
existsInLDAP = true
break
}
}
if existsInLDAP {
continue
}
// disable all peers for the given user
for _, peer := range s.peers.GetPeersByMail(activeUsers[i].Email) {
now := time.Now()
peer.DeactivatedAt = &now
peer.DeactivatedReason = wireguard.DeactivatedReasonLdapMissing
if err := s.UpdatePeer(peer, now); err != nil {
logrus.Errorf("failed to update deactivated peer %s: %v", peer.PublicKey, err)
}
}
if err := s.users.DeleteUser(&activeUsers[i], true); err != nil {
logrus.Errorf("failed to delete deactivated user %s in database: %v", activeUsers[i].Email, err)
}
}
}
func (s *Server) updateLdapUsers(ldapUsers []ldap.RawLdapData, ldapGroups []ldap.RawLdapData) {
for i := range ldapUsers {
if ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute] == "" {
logrus.Tracef("skipping sync of %s, empty email attribute", ldapUsers[i].DN)
continue
}
user, err := s.users.GetOrCreateUserUnscoped(ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute])
if err != nil {
logrus.Errorf("failed to get/create user %s in database: %v", ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute], err)
}
// re-enable LDAP user if the user was disabled
if user.DeletedAt.Valid {
// enable all peers for the given user
for _, peer := range s.peers.GetPeersByMail(user.Email) {
now := time.Now()
peer.DeactivatedAt = nil
peer.DeactivatedReason = ""
if err = s.UpdatePeer(peer, now); err != nil {
logrus.Errorf("failed to update activated peer %s: %v", peer.PublicKey, err)
}
}
}
// Sync attributes from ldap
if s.userChangedInLdap(user, &ldapUsers[i], ldapGroups) {
logrus.Debugf("updating ldap user %s", user.Email)
user.Firstname = ldapUsers[i].Attributes[s.config.LDAP.FirstNameAttribute]
user.Lastname = ldapUsers[i].Attributes[s.config.LDAP.LastNameAttribute]
user.Email = ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute]
user.Phone = ldapUsers[i].Attributes[s.config.LDAP.PhoneAttribute]
user.IsAdmin = s.userIsInAdminGroup(&ldapUsers[i], ldapGroups)
user.Source = users.UserSourceLdap
user.DeletedAt = gorm.DeletedAt{} // Not deleted
if err = s.users.UpdateUser(user); err != nil {
logrus.Errorf("failed to update ldap user %s in database: %v", user.Email, err)
continue
}
} }
} }
}
return nil
} }

View File

@@ -2,27 +2,52 @@ package server
import ( import (
"net/http" "net/http"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
wgportal "github.com/h44z/wg-portal"
_ "github.com/h44z/wg-portal/internal/server/docs" // docs is generated by Swag CLI, you have to import it.
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
csrf "github.com/utrack/gin-csrf"
) )
func SetupRoutes(s *Server) { func SetupRoutes(s *Server) {
csrfMiddleware := csrf.Middleware(csrf.Options{
Secret: s.config.Core.SessionSecret,
ErrorFunc: func(c *gin.Context) {
c.String(400, "CSRF token mismatch")
c.Abort()
},
})
// Startpage // Startpage
s.server.GET("/", s.GetIndex) s.server.GET("/", s.GetIndex)
s.server.GET("/favicon.ico", func(c *gin.Context) {
file, _ := wgportal.Statics.ReadFile("assets/img/favicon.ico")
c.Data(
http.StatusOK,
"image/x-icon",
file,
)
})
// Auth routes // Auth routes
auth := s.server.Group("/auth") auth := s.server.Group("/auth")
auth.Use(csrfMiddleware)
auth.GET("/login", s.GetLogin) auth.GET("/login", s.GetLogin)
auth.POST("/login", s.PostLogin) auth.POST("/login", s.PostLogin)
auth.GET("/logout", s.GetLogout) auth.GET("/logout", s.GetLogout)
// Admin routes // Admin routes
admin := s.server.Group("/admin") admin := s.server.Group("/admin")
admin.Use(s.RequireAuthentication(s.config.AdminLdapGroup)) admin.Use(csrfMiddleware)
admin.Use(s.RequireAuthentication("admin"))
admin.GET("/", s.GetAdminIndex) admin.GET("/", s.GetAdminIndex)
admin.GET("/device/edit", s.GetAdminEditInterface) admin.GET("/device/edit", s.GetAdminEditInterface)
admin.POST("/device/edit", s.PostAdminEditInterface) admin.POST("/device/edit", s.PostAdminEditInterface)
admin.GET("/device/download", s.GetInterfaceConfig) admin.GET("/device/download", s.GetInterfaceConfig)
admin.GET("/device/write", s.GetSaveConfig)
admin.GET("/device/applyglobals", s.GetApplyGlobalConfig) admin.GET("/device/applyglobals", s.GetApplyGlobalConfig)
admin.GET("/peer/edit", s.GetAdminEditPeer) admin.GET("/peer/edit", s.GetAdminEditPeer)
admin.POST("/peer/edit", s.PostAdminEditPeer) admin.POST("/peer/edit", s.PostAdminEditPeer)
@@ -33,20 +58,74 @@ func SetupRoutes(s *Server) {
admin.GET("/peer/delete", s.GetAdminDeletePeer) admin.GET("/peer/delete", s.GetAdminDeletePeer)
admin.GET("/peer/download", s.GetPeerConfig) admin.GET("/peer/download", s.GetPeerConfig)
admin.GET("/peer/email", s.GetPeerConfigMail) admin.GET("/peer/email", s.GetPeerConfigMail)
admin.GET("/peer/emailall", s.GetAdminSendEmails)
admin.GET("/users/", s.GetAdminUsersIndex)
admin.GET("/users/create", s.GetAdminUsersCreate)
admin.POST("/users/create", s.PostAdminUsersCreate)
admin.GET("/users/edit", s.GetAdminUsersEdit)
admin.GET("/users/delete", s.GetAdminUsersDelete)
admin.POST("/users/edit", s.PostAdminUsersEdit)
// User routes // User routes
user := s.server.Group("/user") user := s.server.Group("/user")
user.Use(csrfMiddleware)
user.Use(s.RequireAuthentication("")) // empty scope = all logged in users user.Use(s.RequireAuthentication("")) // empty scope = all logged in users
user.GET("/qrcode", s.GetPeerQRCode) user.GET("/qrcode", s.GetPeerQRCode)
user.GET("/profile", s.GetUserIndex) user.GET("/profile", s.GetUserIndex)
user.GET("/download", s.GetPeerConfig) user.GET("/download", s.GetPeerConfig)
user.GET("/email", s.GetPeerConfigMail) user.GET("/email", s.GetPeerConfigMail)
user.GET("/status", s.GetPeerStatus) user.GET("/status", s.GetPeerStatus)
if s.config.WG.UserManagePeers {
user.GET("/peer/create", s.GetUserCreatePeer)
user.POST("/peer/create", s.PostUserCreatePeer)
user.GET("/peer/edit", s.GetUserEditPeer)
user.POST("/peer/edit", s.PostUserEditPeer)
}
}
func SetupApiRoutes(s *Server) {
api := ApiServer{s: s}
// Admin authenticated routes
apiV1Backend := s.server.Group("/api/v1/backend")
apiV1Backend.Use(s.RequireApiAuthentication("admin"))
apiV1Backend.GET("/users", api.GetUsers)
apiV1Backend.POST("/users", api.PostUser)
apiV1Backend.GET("/user", api.GetUser)
apiV1Backend.PUT("/user", api.PutUser)
apiV1Backend.PATCH("/user", api.PatchUser)
apiV1Backend.DELETE("/user", api.DeleteUser)
apiV1Backend.GET("/peers", api.GetPeers)
apiV1Backend.POST("/peers", api.PostPeer)
apiV1Backend.GET("/peer", api.GetPeer)
apiV1Backend.PUT("/peer", api.PutPeer)
apiV1Backend.PATCH("/peer", api.PatchPeer)
apiV1Backend.DELETE("/peer", api.DeletePeer)
apiV1Backend.GET("/devices", api.GetDevices)
apiV1Backend.GET("/device", api.GetDevice)
apiV1Backend.PUT("/device", api.PutDevice)
apiV1Backend.PATCH("/device", api.PatchDevice)
// Simple authenticated routes
apiV1Deployment := s.server.Group("/api/v1/provisioning")
apiV1Deployment.Use(s.RequireApiAuthentication(""))
apiV1Deployment.GET("/peers", api.GetPeerDeploymentInformation)
apiV1Deployment.GET("/peer", api.GetPeerDeploymentConfig)
apiV1Deployment.POST("/peers", api.PostPeerDeploymentConfig)
// Swagger doc/ui
s.server.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
} }
func (s *Server) RequireAuthentication(scope string) gin.HandlerFunc { func (s *Server) RequireAuthentication(scope string) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
session := s.getSessionData(c) session := GetSessionData(c)
if !session.LoggedIn { if !session.LoggedIn {
// Abort the request with the appropriate error code // Abort the request with the appropriate error code
@@ -55,14 +134,81 @@ func (s *Server) RequireAuthentication(scope string) gin.HandlerFunc {
return return
} }
if scope != "" && !session.IsAdmin && // admins always have access if scope == "admin" && !session.IsAdmin {
!s.ldapUsers.IsInGroup(session.UserName, scope) {
// Abort the request with the appropriate error code // Abort the request with the appropriate error code
c.Abort() c.Abort()
s.GetHandleError(c, http.StatusUnauthorized, "unauthorized", "not enough permissions") s.GetHandleError(c, http.StatusUnauthorized, "unauthorized", "not enough permissions")
return return
} }
// default case if some random scope was set...
if scope != "" && !session.IsAdmin {
// Abort the request with the appropriate error code
c.Abort()
s.GetHandleError(c, http.StatusUnauthorized, "unauthorized", "not enough permissions")
return
}
// Check if logged-in user is still valid
if !s.isUserStillValid(session.Email) {
_ = DestroySessionData(c)
c.Abort()
s.GetHandleError(c, http.StatusUnauthorized, "unauthorized", "session no longer available")
return
}
// Continue down the chain to handler etc
c.Next()
}
}
func (s *Server) RequireApiAuthentication(scope string) gin.HandlerFunc {
return func(c *gin.Context) {
username, password, hasAuth := c.Request.BasicAuth()
if !hasAuth {
c.Abort()
c.JSON(http.StatusUnauthorized, ApiError{Message: "unauthorized"})
return
}
// Validate form input
if strings.Trim(username, " ") == "" || strings.Trim(password, " ") == "" {
c.Abort()
c.JSON(http.StatusUnauthorized, ApiError{Message: "unauthorized"})
return
}
// Check all available auth backends
user, err := s.checkAuthentication(username, password)
if err != nil {
c.Abort()
c.JSON(http.StatusInternalServerError, ApiError{Message: "login error"})
return
}
// Check if user is authenticated
if user == nil {
c.Abort()
c.JSON(http.StatusUnauthorized, ApiError{Message: "unauthorized"})
return
}
// Check admin scope
if scope == "admin" && !user.IsAdmin {
// Abort the request with the appropriate error code
c.Abort()
c.JSON(http.StatusForbidden, ApiError{Message: "unauthorized"})
return
}
// default case if some random scope was set...
if scope != "" && !user.IsAdmin {
// Abort the request with the appropriate error code
c.Abort()
c.JSON(http.StatusForbidden, ApiError{Message: "unauthorized"})
return
}
// Continue down the chain to handler etc // Continue down the chain to handler etc
c.Next() c.Next()
} }

356
internal/server/server.go Normal file
View File

@@ -0,0 +1,356 @@
package server
import (
"context"
"encoding/gob"
"html/template"
"io"
"io/fs"
"math/rand"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/memstore"
"github.com/gin-gonic/gin"
wgportal "github.com/h44z/wg-portal"
ldapprovider "github.com/h44z/wg-portal/internal/authentication/providers/ldap"
passwordprovider "github.com/h44z/wg-portal/internal/authentication/providers/password"
"github.com/h44z/wg-portal/internal/common"
"github.com/h44z/wg-portal/internal/users"
"github.com/h44z/wg-portal/internal/wireguard"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
ginlogrus "github.com/toorop/gin-logrus"
"gorm.io/gorm"
)
const SessionIdentifier = "wgPortalSession"
func init() {
gob.Register(SessionData{})
gob.Register(FlashData{})
gob.Register(wireguard.Peer{})
gob.Register(wireguard.Device{})
gob.Register(LdapCreateForm{})
gob.Register(users.User{})
}
type SessionData struct {
LoggedIn bool
IsAdmin bool
Firstname string
Lastname string
Email string
DeviceName string
SortedBy map[string]string
SortDirection map[string]string
Search map[string]string
AlertData string
AlertType string
FormData interface{}
}
type FlashData struct {
HasAlert bool
Message string
Type string
}
type StaticData struct {
WebsiteTitle string
WebsiteLogo string
CompanyName string
Year int
Version string
}
type Server struct {
ctx context.Context
config *Config
server *gin.Engine
mailTpl *template.Template
auth *AuthManager
db *gorm.DB
users *users.Manager
wg *wireguard.Manager
peers *wireguard.PeerManager
}
func (s *Server) Setup(ctx context.Context) error {
var err error
dir := s.getExecutableDirectory()
rDir, _ := filepath.Abs(filepath.Dir(os.Args[0]))
logrus.Infof("real working directory: %s", rDir)
logrus.Infof("current working directory: %s", dir)
// Init rand
rand.Seed(time.Now().UnixNano())
s.config = NewConfig()
s.ctx = ctx
// Setup database connection
s.db, err = common.GetDatabaseForConfig(&s.config.Database)
if err != nil {
return errors.WithMessage(err, "database setup failed")
}
err = common.MigrateDatabase(s.db, DatabaseVersion)
if err != nil {
return errors.WithMessage(err, "database migration failed")
}
// Setup http server
gin.SetMode(gin.DebugMode)
gin.DefaultWriter = io.Discard
s.server = gin.New()
if logrus.GetLevel() == logrus.TraceLevel {
s.server.Use(ginlogrus.Logger(logrus.StandardLogger()))
}
s.server.Use(gin.Recovery())
// Authentication cookies
cookieStore := memstore.NewStore([]byte(s.config.Core.SessionSecret))
cookieStore.Options(sessions.Options{
Path: "/",
MaxAge: 86400, // auth session is valid for 1 day
Secure: strings.HasPrefix(s.config.Core.ExternalUrl, "https"),
HttpOnly: true,
})
s.server.Use(sessions.Sessions("authsession", cookieStore))
s.server.SetFuncMap(template.FuncMap{
"formatDate": common.FormatDateHTML,
"formatBytes": common.ByteCountSI,
"urlEncode": url.QueryEscape,
"startsWith": strings.HasPrefix,
"userForEmail": func(users []users.User, email string) *users.User {
for i := range users {
if users[i].Email == email {
return &users[i]
}
}
return nil
},
})
// Setup templates
templates := template.Must(template.New("").Funcs(s.server.FuncMap).ParseFS(wgportal.Templates, "assets/tpl/*.html"))
s.server.SetHTMLTemplate(templates)
// Serve static files
s.server.StaticFS("/css", http.FS(fsMust(fs.Sub(wgportal.Statics, "assets/css"))))
s.server.StaticFS("/js", http.FS(fsMust(fs.Sub(wgportal.Statics, "assets/js"))))
s.server.StaticFS("/img", http.FS(fsMust(fs.Sub(wgportal.Statics, "assets/img"))))
s.server.StaticFS("/fonts", http.FS(fsMust(fs.Sub(wgportal.Statics, "assets/fonts"))))
// Setup all routes
SetupRoutes(s)
SetupApiRoutes(s)
// Setup user database (also needed for database authentication)
s.users, err = users.NewManager(s.db)
if err != nil {
return errors.WithMessage(err, "user-manager initialization failed")
}
// Setup auth manager
s.auth = NewAuthManager(s)
pwProvider, err := passwordprovider.New(&s.config.Database)
if err != nil {
return errors.WithMessage(err, "password provider initialization failed")
}
if err = pwProvider.InitializeAdmin(s.config.Core.AdminUser, s.config.Core.AdminPassword); err != nil {
return errors.WithMessage(err, "admin initialization failed")
}
s.auth.RegisterProvider(pwProvider)
if s.config.Core.LdapEnabled {
ldapProvider, err := ldapprovider.New(&s.config.LDAP)
if err != nil {
s.config.Core.LdapEnabled = false
logrus.Warnf("failed to setup LDAP connection, LDAP features disabled")
}
s.auth.RegisterProviderWithoutError(ldapProvider, err)
}
// Setup WireGuard stuff
s.wg = &wireguard.Manager{Cfg: &s.config.WG}
if err = s.wg.Init(); err != nil {
return errors.WithMessage(err, "unable to initialize WireGuard manager")
}
// Setup peer manager
if s.peers, err = wireguard.NewPeerManager(s.db, s.wg); err != nil {
return errors.WithMessage(err, "unable to setup peer manager")
}
for _, deviceName := range s.wg.Cfg.DeviceNames {
if err = s.RestoreWireGuardInterface(deviceName); err != nil {
return errors.WithMessagef(err, "unable to restore WireGuard state for %s", deviceName)
}
}
// Setup mail template
s.mailTpl, err = template.New("email.html").ParseFS(wgportal.Templates, "assets/tpl/email.html")
if err != nil {
return errors.Wrap(err, "unable to pare mail template")
}
logrus.Infof("setup of service completed!")
return nil
}
func (s *Server) Run() {
logrus.Infof("starting web service on %s", s.config.Core.ListeningAddress)
// Start ldap sync
if s.config.Core.LdapEnabled {
go s.SyncLdapWithUserDatabase()
}
go s.RunBackgroundTasks(s.ctx)
// Run web service
srv := &http.Server{
Addr: s.config.Core.ListeningAddress,
Handler: s.server,
}
go func() {
if err := srv.ListenAndServe(); err != nil {
logrus.Debugf("web service on %s exited: %v", s.config.Core.ListeningAddress, err)
}
}()
<-s.ctx.Done()
logrus.Debug("web service shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}
func (s *Server) getExecutableDirectory() string {
dir, err := filepath.Abs(filepath.Dir(os.Args[0]))
if err != nil {
logrus.Errorf("failed to get executable directory: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "assets")); os.IsNotExist(err) {
return "." // assets directory not found -> we are developing in goland =)
}
return dir
}
func (s *Server) getStaticData() StaticData {
return StaticData{
WebsiteTitle: s.config.Core.Title,
WebsiteLogo: s.config.Core.LogoUrl,
CompanyName: s.config.Core.CompanyName,
Year: time.Now().Year(),
Version: Version,
}
}
func GetSessionData(c *gin.Context) SessionData {
session := sessions.Default(c)
rawSessionData := session.Get(SessionIdentifier)
var sessionData SessionData
if rawSessionData != nil {
sessionData = rawSessionData.(SessionData)
} else {
sessionData = SessionData{
Search: map[string]string{"peers": "", "userpeers": "", "users": ""},
SortedBy: map[string]string{"peers": "handshake", "userpeers": "id", "users": "email"},
SortDirection: map[string]string{"peers": "desc", "userpeers": "asc", "users": "asc"},
Email: "",
Firstname: "",
Lastname: "",
DeviceName: "",
IsAdmin: false,
LoggedIn: false,
}
session.Set(SessionIdentifier, sessionData)
if err := session.Save(); err != nil {
logrus.Errorf("failed to store session: %v", err)
}
}
return sessionData
}
func GetFlashes(c *gin.Context) []FlashData {
session := sessions.Default(c)
flashes := session.Flashes()
if err := session.Save(); err != nil {
logrus.Errorf("failed to store session after setting flash: %v", err)
}
flashData := make([]FlashData, len(flashes))
for i := range flashes {
flashData[i] = flashes[i].(FlashData)
}
return flashData
}
func UpdateSessionData(c *gin.Context, data SessionData) error {
session := sessions.Default(c)
session.Set(SessionIdentifier, data)
if err := session.Save(); err != nil {
logrus.Errorf("failed to store session: %v", err)
return errors.Wrap(err, "failed to store session")
}
return nil
}
func DestroySessionData(c *gin.Context) error {
session := sessions.Default(c)
session.Delete(SessionIdentifier)
if err := session.Save(); err != nil {
logrus.Errorf("failed to destroy session: %v", err)
return errors.Wrap(err, "failed to destroy session")
}
return nil
}
func SetFlashMessage(c *gin.Context, message, typ string) {
session := sessions.Default(c)
session.AddFlash(FlashData{
Message: message,
Type: typ,
})
if err := session.Save(); err != nil {
logrus.Errorf("failed to store session after setting flash: %v", err)
}
}
func (s SessionData) GetSortIcon(table, field string) string {
if s.SortedBy[table] != field {
return "fa-sort"
}
if s.SortDirection[table] == "asc" {
return "fa-sort-alpha-down"
} else {
return "fa-sort-alpha-up"
}
}
func fsMust(f fs.FS, err error) fs.FS {
if err != nil {
panic(err)
}
return f
}

View File

@@ -0,0 +1,447 @@
package server
import (
"context"
"crypto/md5"
"fmt"
"os"
"path"
"syscall"
"time"
"github.com/h44z/wg-portal/internal/users"
"github.com/h44z/wg-portal/internal/wireguard"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"gorm.io/gorm"
)
// PrepareNewPeer initiates a new peer for the given WireGuard device.
func (s *Server) PrepareNewPeer(device string) (wireguard.Peer, error) {
dev := s.peers.GetDevice(device)
deviceIPs := dev.GetIPAddresses()
peer := wireguard.Peer{}
peer.IsNew = true
switch dev.Type {
case wireguard.DeviceTypeServer:
peerIPs := make([]string, len(deviceIPs))
for i := range deviceIPs {
freeIP, err := s.peers.GetAvailableIp(device, deviceIPs[i])
if err != nil {
return wireguard.Peer{}, errors.WithMessage(err, "failed to get available IP addresses")
}
peerIPs[i] = freeIP
}
peer.SetIPAddresses(peerIPs...)
psk, err := wgtypes.GenerateKey()
if err != nil {
return wireguard.Peer{}, errors.Wrap(err, "failed to generate key")
}
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
return wireguard.Peer{}, errors.Wrap(err, "failed to generate private key")
}
peer.PresharedKey = psk.String()
peer.PrivateKey = key.String()
peer.PublicKey = key.PublicKey().String()
peer.UID = fmt.Sprintf("u%x", md5.Sum([]byte(peer.PublicKey)))
peer.Endpoint = dev.DefaultEndpoint
peer.DNSStr = dev.DNSStr
peer.PersistentKeepalive = dev.DefaultPersistentKeepalive
peer.AllowedIPsStr = dev.DefaultAllowedIPsStr
peer.Mtu = dev.Mtu
peer.DeviceName = device
case wireguard.DeviceTypeClient:
peer.UID = "newendpoint"
}
return peer, nil
}
// CreatePeerByEmail creates a new peer for the given email.
func (s *Server) CreatePeerByEmail(device, email, identifierSuffix string) error {
user := s.users.GetUser(email)
peer, err := s.PrepareNewPeer(device)
if err != nil {
return errors.WithMessage(err, "failed to prepare new peer")
}
peer.Email = email
if user != nil {
peer.Identifier = fmt.Sprintf("%s %s (%s)", user.Firstname, user.Lastname, identifierSuffix)
} else {
peer.Identifier = fmt.Sprintf("%s (%s)", email, identifierSuffix)
}
return s.CreatePeer(device, peer)
}
// CreatePeer creates the new peer in the database. If the peer has no assigned ip addresses, a new one will be assigned
// automatically. Also, if the private key is empty, a new key-pair will be generated.
// This function also configures the new peer on the physical WireGuard interface if the peer is not deactivated.
func (s *Server) CreatePeer(device string, peer wireguard.Peer) error {
dev := s.peers.GetDevice(device)
deviceIPs := dev.GetIPAddresses()
peerIPs := peer.GetIPAddresses()
peer.AllowedIPsStr = dev.DefaultAllowedIPsStr
if len(peerIPs) == 0 && dev.Type == wireguard.DeviceTypeServer {
peerIPs = make([]string, len(deviceIPs))
for i := range deviceIPs {
freeIP, err := s.peers.GetAvailableIp(device, deviceIPs[i])
if err != nil {
return errors.WithMessage(err, "failed to get available IP addresses")
}
peerIPs[i] = freeIP
}
peer.SetIPAddresses(peerIPs...)
}
if peer.PresharedKey == "" && dev.Type == wireguard.DeviceTypeServer { // if preshared key is empty create a new one
psk, err := wgtypes.GenerateKey()
if err != nil {
return errors.Wrap(err, "failed to generate key")
}
peer.PresharedKey = psk.String()
}
if peer.PrivateKey == "" && peer.PublicKey == "" && dev.Type == wireguard.DeviceTypeServer { // if private key is empty create a new one
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
return errors.Wrap(err, "failed to generate private key")
}
peer.PrivateKey = key.String()
peer.PublicKey = key.PublicKey().String()
}
peer.DeviceName = dev.DeviceName
peer.UID = fmt.Sprintf("u%x", md5.Sum([]byte(peer.PublicKey)))
if peer.ExpiresAt != nil && peer.ExpiresAt.IsZero() { // convert 01-01-0001 to nil
peer.ExpiresAt = nil
}
// Create WireGuard interface
if peer.DeactivatedAt == nil {
if err := s.wg.AddPeer(device, peer.GetConfig(&dev)); err != nil {
return errors.WithMessage(err, "failed to add WireGuard peer")
}
}
// Create in database
if err := s.peers.CreatePeer(peer); err != nil {
return errors.WithMessage(err, "failed to create peer")
}
return s.WriteWireGuardConfigFile(device)
}
// UpdatePeer updates the physical WireGuard interface and the database.
func (s *Server) UpdatePeer(peer wireguard.Peer, updateTime time.Time) error {
currentPeer := s.peers.GetPeerByKey(peer.PublicKey)
dev := s.peers.GetDevice(peer.DeviceName)
// Check if expiry date is in the future, an reactivate the peer in case.
if s.config.Core.ExpiryReEnable && currentPeer.DeactivatedReason == wireguard.DeactivatedReasonExpired &&
peer.ExpiresAt != nil && peer.ExpiresAt.After(time.Now()) {
peer.DeactivatedAt = nil
peer.DeactivatedReason = ""
}
// Update WireGuard device
var err error
switch {
case peer.DeactivatedAt != nil && *peer.DeactivatedAt == updateTime:
err = s.wg.RemovePeer(peer.DeviceName, peer.PublicKey)
case peer.DeactivatedAt == nil && currentPeer.Peer != nil:
err = s.wg.UpdatePeer(peer.DeviceName, peer.GetConfig(&dev))
case peer.DeactivatedAt == nil && currentPeer.Peer == nil:
err = s.wg.AddPeer(peer.DeviceName, peer.GetConfig(&dev))
}
if err != nil {
return errors.WithMessage(err, "failed to update WireGuard peer")
}
peer.UID = fmt.Sprintf("u%x", md5.Sum([]byte(peer.PublicKey)))
if peer.ExpiresAt != nil && peer.ExpiresAt.IsZero() { // convert 01-01-0001 to nil
peer.ExpiresAt = nil
}
// Update in database
if err := s.peers.UpdatePeer(peer); err != nil {
return errors.WithMessage(err, "failed to update peer")
}
return s.WriteWireGuardConfigFile(peer.DeviceName)
}
// DeletePeer removes the peer from the physical WireGuard interface and the database.
func (s *Server) DeletePeer(peer wireguard.Peer) error {
// Delete WireGuard peer
if err := s.wg.RemovePeer(peer.DeviceName, peer.PublicKey); err != nil {
return errors.WithMessage(err, "failed to remove WireGuard peer")
}
// Delete in database
if err := s.peers.DeletePeer(peer); err != nil {
return errors.WithMessage(err, "failed to remove peer")
}
return s.WriteWireGuardConfigFile(peer.DeviceName)
}
// RestoreWireGuardInterface restores the state of the physical WireGuard interface from the database.
func (s *Server) RestoreWireGuardInterface(device string) error {
activePeers := s.peers.GetActivePeers(device)
dev := s.peers.GetDevice(device)
for i := range activePeers {
if activePeers[i].Peer == nil {
if err := s.wg.AddPeer(device, activePeers[i].GetConfig(&dev)); err != nil {
return errors.WithMessage(err, "failed to add WireGuard peer")
}
}
}
return nil
}
// WriteWireGuardConfigFile writes the configuration file for the physical WireGuard interface.
func (s *Server) WriteWireGuardConfigFile(device string) error {
if s.config.WG.ConfigDirectoryPath == "" {
return nil // writing disabled
}
if err := syscall.Access(s.config.WG.ConfigDirectoryPath, syscall.O_RDWR); err != nil {
return errors.Wrap(err, "failed to check WireGuard config access rights")
}
dev := s.peers.GetDevice(device)
cfg, err := dev.GetConfigFile(s.peers.GetActivePeers(device), s.config.Core.WGExporterFriendlyNames)
if err != nil {
return errors.WithMessage(err, "failed to get config file")
}
filePath := path.Join(s.config.WG.ConfigDirectoryPath, dev.DeviceName+".conf")
if err := os.WriteFile(filePath, cfg, 0644); err != nil {
return errors.Wrap(err, "failed to write WireGuard config file")
}
return nil
}
// CreateUser creates the user in the database and optionally adds a default WireGuard peer for the user.
func (s *Server) CreateUser(user users.User, device string) error {
if user.Email == "" {
return errors.New("cannot create user with empty email address")
}
// Check if user already exists, if so re-enable
if existingUser := s.users.GetUserUnscoped(user.Email); existingUser != nil {
user.DeletedAt = gorm.DeletedAt{} // reset deleted flag to enable that user again
return s.UpdateUser(user)
}
// Hash user password (if set)
if user.Password != "" {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
return errors.Wrap(err, "unable to hash password")
}
user.Password = users.PrivateString(hashedPassword)
}
// Create user in database
if err := s.users.CreateUser(&user); err != nil {
return errors.WithMessage(err, "failed to create user in manager")
}
// Check if user already has a peer setup, if not, create one
return s.CreateUserDefaultPeer(user.Email, device)
}
// UpdateUser updates the user in the database. If the user is marked as deleted, it will get remove from the database.
// Also, if the user is re-enabled, all it's linked WireGuard peers will be activated again.
func (s *Server) UpdateUser(user users.User) error {
currentUser := s.users.GetUserUnscoped(user.Email)
// Hash user password (if set)
if user.Password != "" {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
return errors.Wrap(err, "unable to hash password")
}
user.Password = users.PrivateString(hashedPassword)
} else {
user.Password = currentUser.Password // keep current password
}
// Update in database
if err := s.users.UpdateUser(&user); err != nil {
return errors.WithMessage(err, "failed to update user in manager")
}
// Set to deleted (disabled) if user's deletedAt date is not empty
if user.DeletedAt.Valid {
return s.DeleteUser(user)
}
// Otherwise, if user was deleted (disabled), reactivate it's peers
if currentUser.DeletedAt.Valid {
for _, peer := range s.peers.GetPeersByMail(user.Email) {
now := time.Now()
peer.DeactivatedAt = nil
peer.DeactivatedReason = ""
if err := s.UpdatePeer(peer, now); err != nil {
logrus.Errorf("failed to update (re)activated peer %s for %s: %v", peer.PublicKey, peer.Email, err)
}
}
}
return nil
}
// DeleteUser soft-deletes the user from the database (disable the user).
// Also, if the user has linked WireGuard peers, they will be deactivated.
func (s *Server) DeleteUser(user users.User) error {
// Update in database
if err := s.users.DeleteUser(&user, true); err != nil {
return errors.WithMessage(err, "failed to disable user in manager")
}
// Disable users peers
for _, peer := range s.peers.GetPeersByMail(user.Email) {
now := time.Now()
peer.DeactivatedAt = &now
peer.DeactivatedReason = wireguard.DeactivatedReasonUserMissing
if err := s.UpdatePeer(peer, now); err != nil {
logrus.Errorf("failed to update deactivated peer %s for %s: %v", peer.PublicKey, peer.Email, err)
}
}
return nil
}
// HardDeleteUser removes the user from the database.
// Also, if the user has linked WireGuard peers, they will be deleted.
func (s *Server) HardDeleteUser(user users.User) error {
// Update in database
if err := s.users.DeleteUser(&user, false); err != nil {
return errors.WithMessage(err, "failed to delete user in manager")
}
// remove all linked peers
for _, peer := range s.peers.GetPeersByMail(user.Email) {
if err := s.DeletePeer(peer); err != nil {
logrus.Errorf("failed to delete peer %s for %s: %v", peer.PublicKey, user.Email, err)
}
}
return nil
}
func (s *Server) CreateUserDefaultPeer(email, device string) error {
// Check if automatic peer creation is enabled
if !s.config.Core.CreateDefaultPeer {
return nil
}
// Check if user is active, if not, quit
var existingUser *users.User
if existingUser = s.users.GetUser(email); existingUser == nil {
return nil
}
// Check if user already has a peer setup, if not, create one
peers := s.peers.GetPeersByMail(email)
if len(peers) != 0 {
return nil
}
// Create default vpn peer
peer, err := s.PrepareNewPeer(device)
if err != nil {
return errors.WithMessage(err, "failed to prepare new peer")
}
peer.Email = email
if existingUser.Firstname != "" && existingUser.Lastname != "" {
peer.Identifier = fmt.Sprintf("%s %s (%s)", existingUser.Firstname, existingUser.Lastname, "Default")
} else {
peer.Identifier = fmt.Sprintf("%s (%s)", existingUser.Email, "Default")
}
peer.CreatedBy = existingUser.Email
peer.UpdatedBy = existingUser.Email
if err := s.CreatePeer(device, peer); err != nil {
return errors.WithMessagef(err, "failed to automatically create vpn peer for %s", email)
}
return nil
}
func (s *Server) GetDeviceNames() map[string]string {
devNames := make(map[string]string, len(s.wg.Cfg.DeviceNames))
for _, devName := range s.wg.Cfg.DeviceNames {
dev := s.peers.GetDevice(devName)
devNames[devName] = dev.DisplayName
}
return devNames
}
func (s *Server) RunBackgroundTasks(ctx context.Context) {
running := true
for running {
select {
case <-ctx.Done():
running = false
continue
case <-time.After(time.Duration(s.config.Core.BackgroundTaskInterval) * time.Second):
// sleep completed, select will stop blocking
}
logrus.Debug("running periodic background tasks...")
err := s.checkExpiredPeers()
if err != nil {
logrus.Errorf("failed to check expired peers: %v", err)
}
}
}
func (s *Server) checkExpiredPeers() error {
now := time.Now()
for _, devName := range s.wg.Cfg.DeviceNames {
changed := false
peers := s.peers.GetAllPeers(devName)
for _, peer := range peers {
if peer.IsExpired() && !peer.IsDeactivated() {
changed = true
peer.UpdatedAt = now
peer.DeactivatedAt = &now
peer.DeactivatedReason = wireguard.DeactivatedReasonExpired
res := s.db.Save(&peer)
if res.Error != nil {
return fmt.Errorf("failed save expired peer %s: %w", peer.PublicKey, res.Error)
}
err := s.wg.RemovePeer(peer.DeviceName, peer.PublicKey)
if err != nil {
return fmt.Errorf("failed to expire peer %s: %w", peer.PublicKey, err)
}
}
}
if changed {
err := s.WriteWireGuardConfigFile(devName)
if err != nil {
return fmt.Errorf("failed to persist config for interface %s: %w", devName, err)
}
}
}
return nil
}

View File

@@ -1,746 +0,0 @@
package server
import (
"bytes"
"crypto/md5"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
"text/template"
"time"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"github.com/h44z/wg-portal/internal/wireguard"
"github.com/h44z/wg-portal/internal/common"
"github.com/h44z/wg-portal/internal/ldap"
log "github.com/sirupsen/logrus"
"github.com/skip2/go-qrcode"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
//
// CUSTOM VALIDATORS ----------------------------------------------------------------------------
//
var cidrList validator.Func = func(fl validator.FieldLevel) bool {
cidrListStr := fl.Field().String()
cidrList := common.ParseStringList(cidrListStr)
for i := range cidrList {
_, _, err := net.ParseCIDR(cidrList[i])
if err != nil {
return false
}
}
return true
}
var ipList validator.Func = func(fl validator.FieldLevel) bool {
ipListStr := fl.Field().String()
ipList := common.ParseStringList(ipListStr)
for i := range ipList {
ip := net.ParseIP(ipList[i])
if ip == nil {
return false
}
}
return true
}
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("cidrlist", cidrList)
v.RegisterValidation("iplist", ipList)
}
}
//
// USER ----------------------------------------------------------------------------------------
//
type User struct {
Peer *wgtypes.Peer `gorm:"-"`
LdapUser *ldap.UserCacheHolderEntry `gorm:"-"` // optional, it is still possible to have users without ldap
Config string `gorm:"-"`
UID string `form:"uid" binding:"alphanum"` // uid for html identification
IsOnline bool `gorm:"-"`
IsNew bool `gorm:"-"`
Identifier string `form:"identifier" binding:"required,lt=64"` // Identifier AND Email make a WireGuard peer unique
Email string `gorm:"index" form:"mail" binding:"required,email"`
LastHandshake string `gorm:"-"`
LastHandshakeTime string `gorm:"-"`
IgnorePersistentKeepalive bool `form:"ignorekeepalive"`
PresharedKey string `form:"presharedkey" binding:"omitempty,base64"`
AllowedIPsStr string `form:"allowedip" binding:"cidrlist"`
IPsStr string `form:"ip" binding:"cidrlist"`
AllowedIPs []string `gorm:"-"` // IPs that are used in the client config file
IPs []string `gorm:"-"` // The IPs of the client
PrivateKey string `form:"privkey" binding:"omitempty,base64"`
PublicKey string `gorm:"primaryKey" form:"pubkey" binding:"required,base64"`
DeactivatedAt *time.Time
CreatedBy string
UpdatedBy string
CreatedAt time.Time
UpdatedAt time.Time
}
func (u User) GetClientConfigFile(device Device) ([]byte, error) {
tpl, err := template.New("client").Funcs(template.FuncMap{"StringsJoin": strings.Join}).Parse(wireguard.ClientCfgTpl)
if err != nil {
return nil, err
}
var tplBuff bytes.Buffer
err = tpl.Execute(&tplBuff, struct {
Client User
Server Device
}{
Client: u,
Server: device,
})
if err != nil {
return nil, err
}
return tplBuff.Bytes(), nil
}
func (u User) GetPeerConfig() wgtypes.PeerConfig {
publicKey, _ := wgtypes.ParseKey(u.PublicKey)
var presharedKey *wgtypes.Key
if u.PresharedKey != "" {
presharedKeyTmp, _ := wgtypes.ParseKey(u.PresharedKey)
presharedKey = &presharedKeyTmp
}
cfg := wgtypes.PeerConfig{
PublicKey: publicKey,
Remove: false,
UpdateOnly: false,
PresharedKey: presharedKey,
Endpoint: nil,
PersistentKeepaliveInterval: nil,
ReplaceAllowedIPs: true,
AllowedIPs: make([]net.IPNet, len(u.IPs)),
}
for i, ip := range u.IPs {
_, ipNet, err := net.ParseCIDR(ip)
if err == nil {
cfg.AllowedIPs[i] = *ipNet
}
}
return cfg
}
func (u User) GetQRCode() ([]byte, error) {
png, err := qrcode.Encode(u.Config, qrcode.Medium, 250)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to create qrcode")
return nil, err
}
return png, nil
}
func (u User) IsValid() bool {
if u.PublicKey == "" {
return false
}
return true
}
func (u User) ToMap() map[string]string {
out := make(map[string]string)
v := reflect.ValueOf(u)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
typ := v.Type()
for i := 0; i < v.NumField(); i++ {
// gets us a StructField
fi := typ.Field(i)
if tagv := fi.Tag.Get("form"); tagv != "" {
// set key of map to value in struct field
out[tagv] = v.Field(i).String()
}
}
return out
}
func (u User) GetConfigFileName() string {
reg := regexp.MustCompile("[^a-zA-Z0-9_-]+")
return reg.ReplaceAllString(strings.ReplaceAll(u.Identifier, " ", "-"), "") + ".conf"
}
//
// DEVICE --------------------------------------------------------------------------------------
//
type Device struct {
Interface *wgtypes.Device `gorm:"-"`
DeviceName string `form:"device" gorm:"primaryKey" binding:"required,alphanum"`
PrivateKey string `form:"privkey" binding:"required,base64"`
PublicKey string `form:"pubkey" binding:"required,base64"`
PersistentKeepalive int `form:"keepalive" binding:"gte=0"`
ListenPort int `form:"port" binding:"required,gt=0"`
Mtu int `form:"mtu" binding:"gte=0,lte=1500"`
Endpoint string `form:"endpoint" binding:"required,hostname_port"`
AllowedIPsStr string `form:"allowedip" binding:"cidrlist"`
IPsStr string `form:"ip" binding:"required,cidrlist"`
AllowedIPs []string `gorm:"-"` // IPs that are used in the client config file
IPs []string `gorm:"-"` // The IPs of the client
DNSStr string `form:"dns" binding:"iplist"`
DNS []string `gorm:"-"` // The DNS servers of the client
PreUp string `form:"preup"`
PostUp string `form:"postup"`
PreDown string `form:"predown"`
PostDown string `form:"postdown"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (d Device) IsValid() bool {
if d.PublicKey == "" {
return false
}
if len(d.IPs) == 0 {
return false
}
if d.Endpoint == "" {
return false
}
return true
}
func (d Device) GetDeviceConfig() wgtypes.Config {
var privateKey *wgtypes.Key
if d.PrivateKey != "" {
pKey, _ := wgtypes.ParseKey(d.PrivateKey)
privateKey = &pKey
}
cfg := wgtypes.Config{
PrivateKey: privateKey,
ListenPort: &d.ListenPort,
}
return cfg
}
func (d Device) GetDeviceConfigFile(clients []User) ([]byte, error) {
tpl, err := template.New("server").Funcs(template.FuncMap{"StringsJoin": strings.Join}).Parse(wireguard.DeviceCfgTpl)
if err != nil {
return nil, err
}
var tplBuff bytes.Buffer
err = tpl.Execute(&tplBuff, struct {
Clients []User
Server Device
}{
Clients: clients,
Server: d,
})
if err != nil {
return nil, err
}
return tplBuff.Bytes(), nil
}
//
// USER-MANAGER --------------------------------------------------------------------------------
//
type UserManager struct {
db *gorm.DB
wg *wireguard.Manager
ldapUsers *ldap.SynchronizedUserCacheHolder
}
func NewUserManager(dbPath string, wg *wireguard.Manager, ldapUsers *ldap.SynchronizedUserCacheHolder) *UserManager {
um := &UserManager{wg: wg, ldapUsers: ldapUsers}
var err error
if _, err = os.Stat(filepath.Dir(dbPath)); os.IsNotExist(err) {
if err = os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil {
log.Errorf("failed to create database directory (%s): %v", filepath.Dir(dbPath), err)
return nil
}
}
um.db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
log.Errorf("failed to open sqlite database (%s): %v", dbPath, err)
return nil
}
err = um.db.AutoMigrate(&User{}, &Device{})
if err != nil {
log.Errorf("failed to migrate sqlite database: %v", err)
return nil
}
return um
}
func (u *UserManager) InitFromCurrentInterface() error {
peers, err := u.wg.GetPeerList()
if err != nil {
log.Errorf("failed to init user-manager from peers: %v", err)
return err
}
device, err := u.wg.GetDeviceInfo()
if err != nil {
log.Errorf("failed to init user-manager from device: %v", err)
return err
}
var ipAddresses []string
var mtu int
if u.wg.Cfg.ManageIPAddresses {
if ipAddresses, err = u.wg.GetIPAddress(); err != nil {
log.Errorf("failed to init user-manager from device: %v", err)
return err
}
if mtu, err = u.wg.GetMTU(); err != nil {
log.Errorf("failed to init user-manager from device: %v", err)
return err
}
}
// Check if entries already exist in database, if not create them
for _, peer := range peers {
if err := u.validateOrCreateUserForPeer(peer); err != nil {
return err
}
}
if err := u.validateOrCreateDevice(*device, ipAddresses, mtu); err != nil {
return err
}
return nil
}
func (u *UserManager) validateOrCreateUserForPeer(peer wgtypes.Peer) error {
user := User{}
u.db.Where("public_key = ?", peer.PublicKey.String()).FirstOrInit(&user)
if user.PublicKey == "" { // user not found, create
user.UID = fmt.Sprintf("u%x", md5.Sum([]byte(peer.PublicKey.String())))
user.PublicKey = peer.PublicKey.String()
user.PrivateKey = "" // UNKNOWN
if peer.PresharedKey != (wgtypes.Key{}) {
user.PresharedKey = peer.PresharedKey.String()
}
user.Email = "autodetected@example.com"
user.Identifier = "Autodetected (" + user.PublicKey[0:8] + ")"
user.UpdatedAt = time.Now()
user.CreatedAt = time.Now()
user.AllowedIPs = make([]string, 0) // UNKNOWN
user.IPs = make([]string, len(peer.AllowedIPs))
for i, ip := range peer.AllowedIPs {
user.IPs[i] = ip.String()
}
user.AllowedIPsStr = strings.Join(user.AllowedIPs, ", ")
user.IPsStr = strings.Join(user.IPs, ", ")
res := u.db.Create(&user)
if res.Error != nil {
log.Errorf("failed to create autodetected peer: %v", res.Error)
return res.Error
}
}
return nil
}
func (u *UserManager) validateOrCreateDevice(dev wgtypes.Device, ipAddresses []string, mtu int) error {
device := Device{}
u.db.Where("device_name = ?", dev.Name).FirstOrInit(&device)
if device.PublicKey == "" { // device not found, create
device.PublicKey = dev.PublicKey.String()
device.PrivateKey = dev.PrivateKey.String()
device.DeviceName = dev.Name
device.ListenPort = dev.ListenPort
device.Mtu = 0
device.PersistentKeepalive = 16 // Default
device.IPsStr = strings.Join(ipAddresses, ", ")
if mtu == wireguard.WireGuardDefaultMTU {
mtu = 0
}
device.Mtu = mtu
res := u.db.Create(&device)
if res.Error != nil {
log.Errorf("failed to create autodetected device: %v", res.Error)
return res.Error
}
}
return nil
}
func (u *UserManager) populateUserData(user *User) {
user.AllowedIPs = strings.Split(user.AllowedIPsStr, ", ")
user.IPs = strings.Split(user.IPsStr, ", ")
// Set config file
tmpCfg, _ := user.GetClientConfigFile(u.GetDevice())
user.Config = string(tmpCfg)
// set data from WireGuard interface
user.Peer, _ = u.wg.GetPeer(user.PublicKey)
user.LastHandshake = "never"
user.LastHandshakeTime = "Never connected, or user is disabled."
if user.Peer != nil {
since := time.Since(user.Peer.LastHandshakeTime)
sinceSeconds := int(since.Round(time.Second).Seconds())
sinceMinutes := int(sinceSeconds / 60)
sinceSeconds -= sinceMinutes * 60
if sinceMinutes > 2*10080 { // 2 weeks
user.LastHandshake = "a while ago"
} else if sinceMinutes > 10080 { // 1 week
user.LastHandshake = "a week ago"
} else {
user.LastHandshake = fmt.Sprintf("%02dm %02ds", sinceMinutes, sinceSeconds)
}
user.LastHandshakeTime = user.Peer.LastHandshakeTime.Format(time.UnixDate)
}
user.IsOnline = false // todo: calculate online status
// set ldap data
user.LdapUser = u.ldapUsers.GetUserData(u.ldapUsers.GetUserDNByMail(user.Email))
}
func (u *UserManager) populateDeviceData(device *Device) {
device.AllowedIPs = strings.Split(device.AllowedIPsStr, ", ")
device.IPs = strings.Split(device.IPsStr, ", ")
device.DNS = strings.Split(device.DNSStr, ", ")
// set data from WireGuard interface
device.Interface, _ = u.wg.GetDeviceInfo()
}
func (u *UserManager) GetAllUsers() []User {
users := make([]User, 0)
u.db.Find(&users)
for i := range users {
u.populateUserData(&users[i])
}
return users
}
func (u *UserManager) GetActiveUsers() []User {
users := make([]User, 0)
u.db.Where("deactivated_at IS NULL").Find(&users)
for i := range users {
u.populateUserData(&users[i])
}
return users
}
func (u *UserManager) GetFilteredAndSortedUsers(sortKey, sortDirection, search string) []User {
users := make([]User, 0)
u.db.Find(&users)
filteredUsers := make([]User, 0, len(users))
for i := range users {
u.populateUserData(&users[i])
if search == "" ||
strings.Contains(users[i].Email, search) ||
strings.Contains(users[i].Identifier, search) ||
strings.Contains(users[i].PublicKey, search) {
filteredUsers = append(filteredUsers, users[i])
}
}
sort.Slice(filteredUsers, func(i, j int) bool {
var sortValueLeft string
var sortValueRight string
switch sortKey {
case "id":
sortValueLeft = filteredUsers[i].Identifier
sortValueRight = filteredUsers[j].Identifier
case "pubKey":
sortValueLeft = filteredUsers[i].PublicKey
sortValueRight = filteredUsers[j].PublicKey
case "mail":
sortValueLeft = filteredUsers[i].Email
sortValueRight = filteredUsers[j].Email
case "ip":
sortValueLeft = filteredUsers[i].IPsStr
sortValueRight = filteredUsers[j].IPsStr
case "handshake":
if filteredUsers[i].Peer == nil {
return false
} else if filteredUsers[j].Peer == nil {
return true
}
sortValueLeft = filteredUsers[i].Peer.LastHandshakeTime.Format(time.RFC3339)
sortValueRight = filteredUsers[j].Peer.LastHandshakeTime.Format(time.RFC3339)
}
if sortDirection == "asc" {
return sortValueLeft < sortValueRight
} else {
return sortValueLeft > sortValueRight
}
})
return filteredUsers
}
func (u *UserManager) GetSortedUsersForEmail(sortKey, sortDirection, email string) []User {
users := make([]User, 0)
u.db.Where("email = ?", email).Find(&users)
for i := range users {
u.populateUserData(&users[i])
}
sort.Slice(users, func(i, j int) bool {
var sortValueLeft string
var sortValueRight string
switch sortKey {
case "id":
sortValueLeft = users[i].Identifier
sortValueRight = users[j].Identifier
case "pubKey":
sortValueLeft = users[i].PublicKey
sortValueRight = users[j].PublicKey
case "mail":
sortValueLeft = users[i].Email
sortValueRight = users[j].Email
case "ip":
sortValueLeft = users[i].IPsStr
sortValueRight = users[j].IPsStr
case "handshake":
if users[i].Peer == nil {
return true
} else if users[j].Peer == nil {
return false
}
sortValueLeft = users[i].Peer.LastHandshakeTime.Format(time.RFC3339)
sortValueRight = users[j].Peer.LastHandshakeTime.Format(time.RFC3339)
}
if sortDirection == "asc" {
return sortValueLeft < sortValueRight
} else {
return sortValueLeft > sortValueRight
}
})
return users
}
func (u *UserManager) GetDevice() Device {
devices := make([]Device, 0, 1)
u.db.Find(&devices)
for i := range devices {
u.populateDeviceData(&devices[i])
}
return devices[0] // use first device for now... more to come?
}
func (u *UserManager) GetUserByKey(publicKey string) User {
user := User{}
u.db.Where("public_key = ?", publicKey).FirstOrInit(&user)
u.populateUserData(&user)
return user
}
func (u *UserManager) GetUsersByMail(mail string) []User {
var users []User
u.db.Where("email = ?", mail).Find(&users)
for i := range users {
u.populateUserData(&users[i])
}
return users
}
func (u *UserManager) CreateUser(user User) error {
user.UID = fmt.Sprintf("u%x", md5.Sum([]byte(user.PublicKey)))
user.UpdatedAt = time.Now()
user.CreatedAt = time.Now()
user.AllowedIPsStr = strings.Join(user.AllowedIPs, ", ")
user.IPsStr = strings.Join(user.IPs, ", ")
res := u.db.Create(&user)
if res.Error != nil {
log.Errorf("failed to create user: %v", res.Error)
return res.Error
}
return nil
}
func (u *UserManager) UpdateUser(user User) error {
user.UpdatedAt = time.Now()
user.AllowedIPsStr = strings.Join(user.AllowedIPs, ", ")
user.IPsStr = strings.Join(user.IPs, ", ")
res := u.db.Save(&user)
if res.Error != nil {
log.Errorf("failed to update user: %v", res.Error)
return res.Error
}
return nil
}
func (u *UserManager) DeleteUser(user User) error {
res := u.db.Delete(&user)
if res.Error != nil {
log.Errorf("failed to delete user: %v", res.Error)
return res.Error
}
return nil
}
func (u *UserManager) UpdateDevice(device Device) error {
device.UpdatedAt = time.Now()
device.AllowedIPsStr = strings.Join(device.AllowedIPs, ", ")
device.IPsStr = strings.Join(device.IPs, ", ")
device.DNSStr = strings.Join(device.DNS, ", ")
res := u.db.Save(&device)
if res.Error != nil {
log.Errorf("failed to update device: %v", res.Error)
return res.Error
}
return nil
}
func (u *UserManager) GetAllReservedIps() ([]string, error) {
reservedIps := make([]string, 0)
users := u.GetAllUsers()
for _, user := range users {
for _, cidr := range user.IPs {
if cidr == "" {
continue
}
ip, _, err := net.ParseCIDR(cidr)
if err != nil {
return nil, err
}
reservedIps = append(reservedIps, ip.String())
}
}
device := u.GetDevice()
for _, cidr := range device.IPs {
if cidr == "" {
continue
}
ip, _, err := net.ParseCIDR(cidr)
if err != nil {
return nil, err
}
reservedIps = append(reservedIps, ip.String())
}
return reservedIps, nil
}
func (u *UserManager) IsIPReserved(cidr string) bool {
reserved, err := u.GetAllReservedIps()
if err != nil {
return true // in case something failed, assume the ip is reserved
}
ip, ipnet, err := net.ParseCIDR(cidr)
if err != nil {
return true
}
// this two addresses are not usable
broadcastAddr := common.BroadcastAddr(ipnet).String()
networkAddr := ipnet.IP.String()
address := ip.String()
if address == broadcastAddr || address == networkAddr {
return true
}
for _, r := range reserved {
if address == r {
return true
}
}
return false
}
// GetAvailableIp search for an available ip in cidr against a list of reserved ips
func (u *UserManager) GetAvailableIp(cidr string) (string, error) {
reserved, err := u.GetAllReservedIps()
if err != nil {
return "", err
}
ip, ipnet, err := net.ParseCIDR(cidr)
if err != nil {
return "", err
}
// this two addresses are not usable
broadcastAddr := common.BroadcastAddr(ipnet).String()
networkAddr := ipnet.IP.String()
for ip := ip.Mask(ipnet.Mask); ipnet.Contains(ip); common.IncreaseIP(ip) {
ok := true
address := ip.String()
for _, r := range reserved {
if address == r {
ok = false
break
}
}
if ok && address != networkAddr && address != broadcastAddr {
netMask := "/32"
if common.IsIPv6(address) {
netMask = "/128"
}
return address + netMask, nil
}
}
return "", errors.New("no more available address from cidr")
}

View File

@@ -0,0 +1,4 @@
package server
var Version = "testbuild"
var DatabaseVersion = "1.0.9"

229
internal/users/manager.go Normal file
View File

@@ -0,0 +1,229 @@
package users
import (
"sort"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type Manager struct {
db *gorm.DB
}
func NewManager(db *gorm.DB) (*Manager, error) {
m := &Manager{db: db}
// check if old user table exists (from version <= 1.0.2), if so rename it to peers.
if m.db.Migrator().HasTable("users") && !m.db.Migrator().HasTable("peers") {
if err := m.db.Migrator().RenameTable("users", "peers"); err != nil {
return nil, errors.Wrapf(err, "failed to migrate old database structure")
} else {
logrus.Infof("upgraded database format from version v1.0.2")
}
}
if err := m.db.AutoMigrate(&User{}); err != nil {
return nil, errors.Wrap(err, "failed to migrate user database")
}
return m, nil
}
func (m Manager) GetUsers() []User {
users := make([]User, 0)
m.db.Find(&users)
return users
}
func (m Manager) GetUsersUnscoped() []User {
users := make([]User, 0)
m.db.Unscoped().Find(&users)
return users
}
func (m Manager) UserExists(email string) bool {
return m.GetUser(email) != nil
}
func (m Manager) GetUser(email string) *User {
email = strings.ToLower(email)
user := User{}
m.db.Where("email = ?", email).First(&user)
if user.Email != email {
return nil
}
return &user
}
func (m Manager) GetUserUnscoped(email string) *User {
email = strings.ToLower(email)
user := User{}
m.db.Unscoped().Where("email = ?", email).First(&user)
if user.Email != email {
return nil
}
return &user
}
func (m Manager) GetFilteredAndSortedUsers(sortKey, sortDirection, search string) []User {
users := make([]User, 0)
m.db.Find(&users)
filteredUsers := filterUsers(users, search)
sortUsers(filteredUsers, sortKey, sortDirection)
return filteredUsers
}
func (m Manager) GetFilteredAndSortedUsersUnscoped(sortKey, sortDirection, search string) []User {
users := make([]User, 0)
m.db.Unscoped().Find(&users)
filteredUsers := filterUsers(users, search)
sortUsers(filteredUsers, sortKey, sortDirection)
return filteredUsers
}
func (m Manager) GetOrCreateUser(email string) (*User, error) {
email = strings.ToLower(email)
user := User{}
m.db.Where("email = ?", email).FirstOrInit(&user)
if user.Email != email {
user.Email = email
user.CreatedAt = time.Now()
user.UpdatedAt = time.Now()
user.IsAdmin = false
user.Source = UserSourceDatabase
res := m.db.Create(&user)
if res.Error != nil {
return nil, errors.Wrapf(res.Error, "failed to create user %s", email)
}
}
return &user, nil
}
func (m Manager) GetOrCreateUserUnscoped(email string) (*User, error) {
email = strings.ToLower(email)
user := User{}
m.db.Unscoped().Where("email = ?", email).FirstOrInit(&user)
if user.Email != email {
user.Email = email
user.CreatedAt = time.Now()
user.UpdatedAt = time.Now()
user.IsAdmin = false
user.Source = UserSourceDatabase
res := m.db.Create(&user)
if res.Error != nil {
return nil, errors.Wrapf(res.Error, "failed to create user %s", email)
}
}
return &user, nil
}
func (m Manager) CreateUser(user *User) error {
user.Email = strings.ToLower(user.Email)
user.Source = UserSourceDatabase
res := m.db.Create(user)
if res.Error != nil {
return errors.Wrapf(res.Error, "failed to create user %s", user.Email)
}
return nil
}
func (m Manager) UpdateUser(user *User) error {
user.Email = strings.ToLower(user.Email)
res := m.db.Save(user)
if res.Error != nil {
return errors.Wrapf(res.Error, "failed to update user %s", user.Email)
}
return nil
}
func (m Manager) DeleteUser(user *User, soft bool) error {
user.Email = strings.ToLower(user.Email)
var res *gorm.DB
if soft {
res = m.db.Delete(user)
} else {
res = m.db.Unscoped().Delete(user)
}
if res.Error != nil {
return errors.Wrapf(res.Error, "failed to update user %s", user.Email)
}
return nil
}
func sortUsers(users []User, key, direction string) {
sort.Slice(users, func(i, j int) bool {
var sortValueLeft string
var sortValueRight string
switch key {
case "email":
sortValueLeft = users[i].Email
sortValueRight = users[j].Email
case "firstname":
sortValueLeft = users[i].Firstname
sortValueRight = users[j].Firstname
case "lastname":
sortValueLeft = users[i].Lastname
sortValueRight = users[j].Lastname
case "phone":
sortValueLeft = users[i].Phone
sortValueRight = users[j].Phone
case "source":
sortValueLeft = string(users[i].Source)
sortValueRight = string(users[j].Source)
case "admin":
sortValueLeft = strconv.FormatBool(users[i].IsAdmin)
sortValueRight = strconv.FormatBool(users[j].IsAdmin)
}
if direction == "asc" {
return sortValueLeft < sortValueRight
} else {
return sortValueLeft > sortValueRight
}
})
}
func filterUsers(users []User, search string) []User {
if search == "" {
return users
}
filteredUsers := make([]User, 0, len(users))
for i := range users {
if strings.Contains(users[i].Email, strings.ToLower(search)) ||
strings.Contains(users[i].Firstname, search) ||
strings.Contains(users[i].Lastname, search) ||
strings.Contains(string(users[i].Source), search) ||
strings.Contains(users[i].Phone, search) {
filteredUsers = append(filteredUsers, users[i])
}
}
return filteredUsers
}

45
internal/users/user.go Normal file
View File

@@ -0,0 +1,45 @@
package users
import (
"time"
"gorm.io/gorm"
)
type UserSource string
const (
UserSourceLdap UserSource = "ldap" // LDAP / ActiveDirectory
UserSourceDatabase UserSource = "db" // sqlite / mysql database
)
type PrivateString string
func (PrivateString) MarshalJSON() ([]byte, error) {
return []byte(`""`), nil
}
func (PrivateString) String() string {
return ""
}
// User is the user model that gets linked to peer entries, by default an empty usermodel with only the email address is created
type User struct {
// required fields
Email string `gorm:"primaryKey" form:"email" binding:"required,email"`
Source UserSource
IsAdmin bool `form:"isadmin"`
// optional fields
Firstname string `form:"firstname" binding:"required"`
Lastname string `form:"lastname" binding:"required"`
Phone string `form:"phone" binding:"omitempty"`
// optional, integrated password authentication
Password PrivateString `form:"password" binding:"omitempty"`
// database internal fields
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index" json:",omitempty" swaggertype:"string"`
}

View File

@@ -1,7 +1,18 @@
package wireguard package wireguard
import "github.com/h44z/wg-portal/internal/common"
type Config struct { type Config struct {
DeviceName string `yaml:"device" envconfig:"WG_DEVICE"` DeviceNames []string `yaml:"devices" envconfig:"WG_DEVICES"` // managed devices
WireGuardConfig string `yaml:"configFile" envconfig:"WG_CONFIG_FILE"` // optional, if set, updates will be written to this file DefaultDeviceName string `yaml:"defaultDevice" envconfig:"WG_DEFAULT_DEVICE"` // this device is used for auto-created peers, use GetDefaultDeviceName() to access this field
ConfigDirectoryPath string `yaml:"configDirectory" envconfig:"WG_CONFIG_PATH"` // optional, if set, updates will be written to this path, filename: <devicename>.conf
ManageIPAddresses bool `yaml:"manageIPAddresses" envconfig:"MANAGE_IPS"` // handle ip-address setup of interface ManageIPAddresses bool `yaml:"manageIPAddresses" envconfig:"MANAGE_IPS"` // handle ip-address setup of interface
UserManagePeers bool `yaml:"userManagePeers" envconfig:"USER_MANAGE_PEERS"` // user can manage own peers
}
func (c Config) GetDefaultDeviceName() string {
if c.DefaultDeviceName == "" || !common.ListContains(c.DeviceNames, c.DefaultDeviceName) {
return c.DeviceNames[0]
}
return c.DefaultDeviceName
} }

View File

@@ -1,13 +1,14 @@
package wireguard package wireguard
import ( import (
"fmt"
"sync" "sync"
"github.com/pkg/errors"
"golang.zx2c4.com/wireguard/wgctrl" "golang.zx2c4.com/wireguard/wgctrl"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes" "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
) )
// Manager offers a synchronized management interface to the real WireGuard interface.
type Manager struct { type Manager struct {
Cfg *Config Cfg *Config
wg *wgctrl.Client wg *wgctrl.Client
@@ -18,45 +19,45 @@ func (m *Manager) Init() error {
var err error var err error
m.wg, err = wgctrl.New() m.wg, err = wgctrl.New()
if err != nil { if err != nil {
return fmt.Errorf("could not create WireGuard client: %w", err) return errors.Wrap(err, "could not create WireGuard client")
} }
return nil return nil
} }
func (m *Manager) GetDeviceInfo() (*wgtypes.Device, error) { func (m *Manager) GetDeviceInfo(device string) (*wgtypes.Device, error) {
dev, err := m.wg.Device(m.Cfg.DeviceName) dev, err := m.wg.Device(device)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not get WireGuard device: %w", err) return nil, errors.Wrap(err, "could not get WireGuard device")
} }
return dev, nil return dev, nil
} }
func (m *Manager) GetPeerList() ([]wgtypes.Peer, error) { func (m *Manager) GetPeerList(device string) ([]wgtypes.Peer, error) {
m.mux.RLock() m.mux.RLock()
defer m.mux.RUnlock() defer m.mux.RUnlock()
dev, err := m.wg.Device(m.Cfg.DeviceName) dev, err := m.wg.Device(device)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not get WireGuard device: %w", err) return nil, errors.Wrap(err, "could not get WireGuard device")
} }
return dev.Peers, nil return dev.Peers, nil
} }
func (m *Manager) GetPeer(pubKey string) (*wgtypes.Peer, error) { func (m *Manager) GetPeer(device string, pubKey string) (*wgtypes.Peer, error) {
m.mux.RLock() m.mux.RLock()
defer m.mux.RUnlock() defer m.mux.RUnlock()
publicKey, err := wgtypes.ParseKey(pubKey) publicKey, err := wgtypes.ParseKey(pubKey)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid public key: %w", err) return nil, errors.Wrap(err, "invalid public key")
} }
peers, err := m.GetPeerList() peers, err := m.GetPeerList(device)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not get WireGuard peers: %w", err) return nil, errors.Wrap(err, "could not get WireGuard peers")
} }
for _, peer := range peers { for _, peer := range peers {
@@ -65,41 +66,41 @@ func (m *Manager) GetPeer(pubKey string) (*wgtypes.Peer, error) {
} }
} }
return nil, fmt.Errorf("could not find WireGuard peer: %s", pubKey) return nil, errors.Errorf("could not find WireGuard peer: %s", pubKey)
} }
func (m *Manager) AddPeer(cfg wgtypes.PeerConfig) error { func (m *Manager) AddPeer(device string, cfg wgtypes.PeerConfig) error {
m.mux.Lock() m.mux.Lock()
defer m.mux.Unlock() defer m.mux.Unlock()
err := m.wg.ConfigureDevice(m.Cfg.DeviceName, wgtypes.Config{Peers: []wgtypes.PeerConfig{cfg}}) err := m.wg.ConfigureDevice(device, wgtypes.Config{Peers: []wgtypes.PeerConfig{cfg}})
if err != nil { if err != nil {
return fmt.Errorf("could not configure WireGuard device: %w", err) return errors.Wrap(err, "could not configure WireGuard device")
} }
return nil return nil
} }
func (m *Manager) UpdatePeer(cfg wgtypes.PeerConfig) error { func (m *Manager) UpdatePeer(device string, cfg wgtypes.PeerConfig) error {
m.mux.Lock() m.mux.Lock()
defer m.mux.Unlock() defer m.mux.Unlock()
cfg.UpdateOnly = true cfg.UpdateOnly = true
err := m.wg.ConfigureDevice(m.Cfg.DeviceName, wgtypes.Config{Peers: []wgtypes.PeerConfig{cfg}}) err := m.wg.ConfigureDevice(device, wgtypes.Config{Peers: []wgtypes.PeerConfig{cfg}})
if err != nil { if err != nil {
return fmt.Errorf("could not configure WireGuard device: %w", err) return errors.Wrap(err, "could not configure WireGuard device")
} }
return nil return nil
} }
func (m *Manager) RemovePeer(pubKey string) error { func (m *Manager) RemovePeer(device string, pubKey string) error {
m.mux.Lock() m.mux.Lock()
defer m.mux.Unlock() defer m.mux.Unlock()
publicKey, err := wgtypes.ParseKey(pubKey) publicKey, err := wgtypes.ParseKey(pubKey)
if err != nil { if err != nil {
return fmt.Errorf("invalid public key: %w", err) return errors.Wrap(err, "invalid public key")
} }
peer := wgtypes.PeerConfig{ peer := wgtypes.PeerConfig{
@@ -107,14 +108,14 @@ func (m *Manager) RemovePeer(pubKey string) error {
Remove: true, Remove: true,
} }
err = m.wg.ConfigureDevice(m.Cfg.DeviceName, wgtypes.Config{Peers: []wgtypes.PeerConfig{peer}}) err = m.wg.ConfigureDevice(device, wgtypes.Config{Peers: []wgtypes.PeerConfig{peer}})
if err != nil { if err != nil {
return fmt.Errorf("could not configure WireGuard device: %w", err) return errors.Wrap(err, "could not configure WireGuard device")
} }
return nil return nil
} }
func (m *Manager) UpdateDevice(name string, cfg wgtypes.Config) error { func (m *Manager) UpdateDevice(device string, cfg wgtypes.Config) error {
return m.wg.ConfigureDevice(name, cfg) return m.wg.ConfigureDevice(device, cfg)
} }

View File

@@ -0,0 +1,121 @@
package wireguard
import (
"fmt"
"net"
"github.com/milosgajdos/tenus"
"github.com/pkg/errors"
)
const DefaultMTU = 1420
func (m *Manager) GetIPAddress(device string) ([]string, error) {
wgInterface, err := tenus.NewLinkFrom(device)
if err != nil {
return nil, errors.Wrapf(err, "could not retrieve WireGuard interface %s", device)
}
// Get golang net.interface
iface := wgInterface.NetInterface()
if iface == nil { // Not sure if this check is really necessary
return nil, errors.Wrap(err, "could not retrieve WireGuard net.interface")
}
addrs, err := iface.Addrs()
if err != nil {
return nil, errors.Wrap(err, "could not retrieve WireGuard ip addresses")
}
ipAddresses := make([]string, 0, len(addrs))
for _, addr := range addrs {
var ip net.IP
var mask net.IPMask
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
mask = v.Mask
case *net.IPAddr:
ip = v.IP
mask = ip.DefaultMask()
}
if ip == nil || mask == nil {
continue // something is wrong?
}
maskSize, _ := mask.Size()
cidr := fmt.Sprintf("%s/%d", ip.String(), maskSize)
ipAddresses = append(ipAddresses, cidr)
}
return ipAddresses, nil
}
func (m *Manager) SetIPAddress(device string, cidrs []string) error {
wgInterface, err := tenus.NewLinkFrom(device)
if err != nil {
return errors.Wrapf(err, "could not retrieve WireGuard interface %s", device)
}
// First remove existing IP addresses
existingIPs, err := m.GetIPAddress(device)
if err != nil {
return errors.Wrap(err, "could not retrieve IP addresses")
}
for _, cidr := range existingIPs {
wgIp, wgIpNet, err := net.ParseCIDR(cidr)
if err != nil {
return errors.Wrapf(err, "unable to parse cidr %s", cidr)
}
if err := wgInterface.UnsetLinkIp(wgIp, wgIpNet); err != nil {
return errors.Wrapf(err, "failed to unset ip %s", cidr)
}
}
// Next set new IP addresses
for _, cidr := range cidrs {
wgIp, wgIpNet, err := net.ParseCIDR(cidr)
if err != nil {
return errors.Wrapf(err, "unable to parse cidr %s", cidr)
}
if err := wgInterface.SetLinkIp(wgIp, wgIpNet); err != nil {
return errors.Wrapf(err, "failed to set ip %s", cidr)
}
}
return nil
}
func (m *Manager) GetMTU(device string) (int, error) {
wgInterface, err := tenus.NewLinkFrom(device)
if err != nil {
return 0, errors.Wrapf(err, "could not retrieve WireGuard interface %s", device)
}
// Get golang net.interface
iface := wgInterface.NetInterface()
if iface == nil { // Not sure if this check is really necessary
return 0, errors.Wrap(err, "could not retrieve WireGuard net.interface")
}
return iface.MTU, nil
}
func (m *Manager) SetMTU(device string, mtu int) error {
wgInterface, err := tenus.NewLinkFrom(device)
if err != nil {
return errors.Wrapf(err, "could not retrieve WireGuard interface %s", device)
}
if mtu == 0 {
mtu = DefaultMTU
}
if err := wgInterface.SetLinkMTU(mtu); err != nil {
return errors.Wrapf(err, "could not set MTU on interface %s", device)
}
return nil
}

View File

@@ -1,120 +0,0 @@
package wireguard
import (
"fmt"
"net"
"github.com/milosgajdos/tenus"
)
const WireGuardDefaultMTU = 1420
func (m *Manager) GetIPAddress() ([]string, error) {
wgInterface, err := tenus.NewLinkFrom(m.Cfg.DeviceName)
if err != nil {
return nil, fmt.Errorf("could not retrieve WireGuard interface %s: %w", m.Cfg.DeviceName, err)
}
// Get golang net.interface
iface := wgInterface.NetInterface()
if iface == nil { // Not sure if this check is really necessary
return nil, fmt.Errorf("could not retrieve WireGuard net.interface: %w", err)
}
addrs, err := iface.Addrs()
if err != nil {
return nil, fmt.Errorf("could not retrieve WireGuard ip addresses: %w", err)
}
ipAddresses := make([]string, 0, len(addrs))
for _, addr := range addrs {
var ip net.IP
var mask net.IPMask
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
mask = v.Mask
case *net.IPAddr:
ip = v.IP
mask = ip.DefaultMask()
}
if ip == nil {
continue // something is wrong?
}
maskSize, _ := mask.Size()
cidr := fmt.Sprintf("%s/%d", ip.String(), maskSize)
ipAddresses = append(ipAddresses, cidr)
}
return ipAddresses, nil
}
func (m *Manager) SetIPAddress(cidrs []string) error {
wgInterface, err := tenus.NewLinkFrom(m.Cfg.DeviceName)
if err != nil {
return fmt.Errorf("could not retrieve WireGuard interface %s: %w", m.Cfg.DeviceName, err)
}
// First remove existing IP addresses
existingIPs, err := m.GetIPAddress()
if err != nil {
return err
}
for _, cidr := range existingIPs {
wgIp, wgIpNet, err := net.ParseCIDR(cidr)
if err != nil {
return fmt.Errorf("unable to parse cidr %s: %w", cidr, err)
}
if err := wgInterface.UnsetLinkIp(wgIp, wgIpNet); err != nil {
return fmt.Errorf("failed to unset ip %s: %w", cidr, err)
}
}
// Next set new IP adrresses
for _, cidr := range cidrs {
wgIp, wgIpNet, err := net.ParseCIDR(cidr)
if err != nil {
return fmt.Errorf("unable to parse cidr %s: %w", cidr, err)
}
if err := wgInterface.SetLinkIp(wgIp, wgIpNet); err != nil {
return fmt.Errorf("failed to set ip %s: %w", cidr, err)
}
}
return nil
}
func (m *Manager) GetMTU() (int, error) {
wgInterface, err := tenus.NewLinkFrom(m.Cfg.DeviceName)
if err != nil {
return 0, fmt.Errorf("could not retrieve WireGuard interface %s: %w", m.Cfg.DeviceName, err)
}
// Get golang net.interface
iface := wgInterface.NetInterface()
if iface == nil { // Not sure if this check is really necessary
return 0, fmt.Errorf("could not retrieve WireGuard net.interface: %w", err)
}
return iface.MTU, nil
}
func (m *Manager) SetMTU(mtu int) error {
wgInterface, err := tenus.NewLinkFrom(m.Cfg.DeviceName)
if err != nil {
return fmt.Errorf("could not retrieve WireGuard interface %s: %w", m.Cfg.DeviceName, err)
}
if mtu == 0 {
mtu = WireGuardDefaultMTU
}
if err := wgInterface.SetLinkMTU(mtu); err != nil {
return fmt.Errorf("could not set MTU on interface %s: %w", m.Cfg.DeviceName, err)
}
return nil
}

View File

@@ -0,0 +1,927 @@
package wireguard
// WireGuard documentation: https://manpages.debian.org/unstable/wireguard-tools/wg.8.en.html
import (
"bytes"
"crypto/md5"
"fmt"
"net"
"regexp"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"github.com/h44z/wg-portal/internal/common"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/skip2/go-qrcode"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"gorm.io/gorm"
)
const (
DeactivatedReasonExpired = "expired"
DeactivatedReasonUserEdit = "user edit action"
DeactivatedReasonUserCreate = "user create action"
DeactivatedReasonAdminEdit = "admin edit action"
DeactivatedReasonAdminCreate = "admin create action"
DeactivatedReasonApiEdit = "api edit action"
DeactivatedReasonApiCreate = "api create action"
DeactivatedReasonLdapMissing = "missing in ldap"
DeactivatedReasonUserMissing = "missing user"
)
// CUSTOM VALIDATORS ----------------------------------------------------------------------------
var cidrList validator.Func = func(fl validator.FieldLevel) bool {
cidrListStr := fl.Field().String()
cidrList := common.ParseStringList(cidrListStr)
for i := range cidrList {
_, _, err := net.ParseCIDR(cidrList[i])
if err != nil {
return false
}
}
return true
}
var dnsList validator.Func = func(fl validator.FieldLevel) bool {
dnsListStr := fl.Field().String()
dnsList := common.ParseStringList(dnsListStr)
validate := binding.Validator.Engine().(*validator.Validate)
for i := range dnsList {
ip := net.ParseIP(dnsList[i])
if ip == nil {
err := validate.Var(dnsList[i], "fqdn")
if err != nil {
return false
}
}
}
return true
}
var ipList validator.Func = func(fl validator.FieldLevel) bool {
ipListStr := fl.Field().String()
ipList := common.ParseStringList(ipListStr)
for i := range ipList {
ip := net.ParseIP(ipList[i])
if ip == nil {
return false
}
}
return true
}
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
_ = v.RegisterValidation("cidrlist", cidrList)
_ = v.RegisterValidation("iplist", ipList)
_ = v.RegisterValidation("dnsList", dnsList)
}
}
//
// PEER ----------------------------------------------------------------------------------------
//
type Peer struct {
Peer *wgtypes.Peer `gorm:"-" json:"-"` // WireGuard peer
Config string `gorm:"-" json:"-"`
UID string `form:"uid" binding:"required,alphanum"` // uid for html identification
DeviceName string `gorm:"index" form:"device" binding:"required"`
DeviceType DeviceType `gorm:"-" form:"devicetype" binding:"required,oneof=client server"`
Identifier string `form:"identifier" binding:"required,max=64"` // Identifier AND Email make a WireGuard peer unique
Email string `gorm:"index" form:"mail" binding:"required,email"`
IgnoreGlobalSettings bool `form:"ignoreglobalsettings"`
IsOnline bool `gorm:"-" json:"-"`
IsNew bool `gorm:"-" json:"-"`
LastHandshake string `gorm:"-" json:"-"`
LastHandshakeTime string `gorm:"-" json:"-"`
// Core WireGuard Settings
PublicKey string `gorm:"primaryKey" form:"pubkey" binding:"required,base64"` // the public key of the peer itself
PresharedKey string `form:"presharedkey" binding:"omitempty,base64"`
AllowedIPsStr string `form:"allowedip" binding:"cidrlist"` // a comma separated list of IPs that are used in the client config file
AllowedIPsSrvStr string `form:"allowedipSrv" binding:"cidrlist"` // a comma separated list of IPs that are used in the server config file
Endpoint string `form:"endpoint" binding:"omitempty,hostname_port"`
PersistentKeepalive int `form:"keepalive" binding:"gte=0"`
// Misc. WireGuard Settings
PrivateKey string `form:"privkey" binding:"omitempty,base64"`
IPsStr string `form:"ip" binding:"cidrlist,required_if=DeviceType server"` // a comma separated list of IPs of the client
DNSStr string `form:"dns" binding:"dnsList"` // comma separated list of the DNS servers for the client
// Global Device Settings (can be ignored, only make sense if device is in server mode)
Mtu int `form:"mtu" binding:"gte=0,lte=1500"`
DeactivatedAt *time.Time `json:",omitempty"`
DeactivatedReason string `json:",omitempty"`
ExpiresAt *time.Time `json:",omitempty" form:"expires_at" binding:"omitempty" time_format:"2006-01-02"`
CreatedBy string
UpdatedBy string
CreatedAt time.Time
UpdatedAt time.Time
}
func (p *Peer) SetIPAddresses(addresses ...string) {
p.IPsStr = common.ListToString(addresses)
}
func (p Peer) GetIPAddresses() []string {
return common.ParseStringList(p.IPsStr)
}
func (p *Peer) SetDNSServers(addresses ...string) {
p.DNSStr = common.ListToString(addresses)
}
func (p Peer) GetDNSServers() []string {
return common.ParseStringList(p.DNSStr)
}
func (p *Peer) SetAllowedIPs(addresses ...string) {
p.AllowedIPsStr = common.ListToString(addresses)
}
func (p Peer) GetAllowedIPs() []string {
return common.ParseStringList(p.AllowedIPsStr)
}
func (p Peer) GetAllowedIPsSrv() []string {
return common.ParseStringList(p.AllowedIPsSrvStr)
}
func (p Peer) GetConfig(dev *Device) wgtypes.PeerConfig {
publicKey, _ := wgtypes.ParseKey(p.PublicKey)
var presharedKey *wgtypes.Key
if p.PresharedKey != "" {
presharedKeyTmp, _ := wgtypes.ParseKey(p.PresharedKey)
presharedKey = &presharedKeyTmp
}
var endpoint *net.UDPAddr
if p.Endpoint != "" && dev.Type == DeviceTypeClient {
addr, err := net.ResolveUDPAddr("udp", p.Endpoint)
if err == nil {
endpoint = addr
}
}
var keepAlive *time.Duration
if p.PersistentKeepalive != 0 {
keepAliveDuration := time.Duration(p.PersistentKeepalive) * time.Second
keepAlive = &keepAliveDuration
}
allowedIPs := make([]net.IPNet, 0)
var peerAllowedIPs []string
switch dev.Type {
case DeviceTypeClient:
peerAllowedIPs = p.GetAllowedIPs()
case DeviceTypeServer:
peerAllowedIPs = p.GetIPAddresses()
peerAllowedIPs = append(peerAllowedIPs, p.GetAllowedIPsSrv()...)
}
for _, ip := range peerAllowedIPs {
_, ipNet, err := net.ParseCIDR(ip)
if err == nil {
allowedIPs = append(allowedIPs, *ipNet)
}
}
cfg := wgtypes.PeerConfig{
PublicKey: publicKey,
Remove: false,
UpdateOnly: false,
PresharedKey: presharedKey,
Endpoint: endpoint,
PersistentKeepaliveInterval: keepAlive,
ReplaceAllowedIPs: true,
AllowedIPs: allowedIPs,
}
return cfg
}
func (p Peer) GetConfigFile(device Device) ([]byte, error) {
var tplBuff bytes.Buffer
err := templateCache.ExecuteTemplate(&tplBuff, "peer.tpl", gin.H{
"Peer": p,
"Interface": device,
})
if err != nil {
return nil, errors.Wrap(err, "failed to execute client template")
}
return tplBuff.Bytes(), nil
}
func (p Peer) GetQRCode() ([]byte, error) {
png, err := qrcode.Encode(p.Config, qrcode.Medium, 250)
if err == nil {
return png, nil
}
if err.Error() != "content too long to encode" {
logrus.Errorf("failed to create qrcode: %v", err)
return nil, errors.Wrap(err, "failed to encode qrcode")
}
png, err = qrcode.Encode(p.Config, qrcode.Low, 250)
if err != nil {
logrus.Errorf("failed to create qrcode: %v", err)
return nil, errors.Wrap(err, "failed to encode qrcode")
}
return png, nil
}
func (p Peer) IsValid() bool {
if p.PublicKey == "" {
return false
}
return true
}
func (p Peer) WillExpire() bool {
if p.ExpiresAt == nil {
return false
}
if p.DeactivatedAt != nil {
return false // already deactivated...
}
if p.ExpiresAt.After(time.Now()) {
return true
}
return false
}
func (p Peer) IsExpired() bool {
if p.ExpiresAt == nil {
return false
}
if p.ExpiresAt.Before(time.Now()) {
return true
}
return false
}
func (p Peer) IsDeactivated() bool {
return p.DeactivatedAt != nil
}
func (p Peer) GetConfigFileName() string {
reg := regexp.MustCompile("[^a-zA-Z0-9_-]+")
return reg.ReplaceAllString(strings.ReplaceAll(p.Identifier, " ", "-"), "") + ".conf"
}
//
// DEVICE --------------------------------------------------------------------------------------
//
type DeviceType string
const (
DeviceTypeServer DeviceType = "server"
DeviceTypeClient DeviceType = "client"
)
type Device struct {
Interface *wgtypes.Device `gorm:"-" json:"-"`
Peers []Peer `gorm:"foreignKey:DeviceName" binding:"-" json:"-"` // linked WireGuard peers
Type DeviceType `form:"devicetype" binding:"required,oneof=client server"`
DeviceName string `form:"device" gorm:"primaryKey" binding:"required" validator:"regexp=[0-9a-zA-Z\\-]+"`
DisplayName string `form:"displayname" binding:"omitempty,max=200"`
// Core WireGuard Settings (Interface section)
PrivateKey string `form:"privkey" binding:"required,base64"`
ListenPort int `form:"port" binding:"required_if=Type server,omitempty,gt=0,lt=65535"`
FirewallMark int32 `form:"firewallmark" binding:"gte=0"`
// Misc. WireGuard Settings
PublicKey string `form:"pubkey" binding:"required,base64"`
Mtu int `form:"mtu" binding:"gte=0,lte=1500"` // the interface MTU, wg-quick addition
IPsStr string `form:"ip" binding:"required,cidrlist"` // comma separated list of the IPs of the client, wg-quick addition
DNSStr string `form:"dns" binding:"dnsList"` // comma separated list of the DNS servers of the client, wg-quick addition
RoutingTable string `form:"routingtable"` // the routing table, wg-quick addition
PreUp string `form:"preup"` // pre up script, wg-quick addition
PostUp string `form:"postup"` // post up script, wg-quick addition
PreDown string `form:"predown"` // pre down script, wg-quick addition
PostDown string `form:"postdown"` // post down script, wg-quick addition
SaveConfig bool `form:"saveconfig"` // if set to `true', the configuration is saved from the current state of the interface upon shutdown, wg-quick addition
// Settings that are applied to all peer by default
DefaultEndpoint string `form:"endpoint" binding:"required_if=Type server,omitempty,hostname_port"`
DefaultAllowedIPsStr string `form:"allowedip" binding:"cidrlist"` // comma separated list of IPs that are used in the client config file
DefaultPersistentKeepalive int `form:"keepalive" binding:"gte=0"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (d Device) IsValid() bool {
switch d.Type {
case DeviceTypeServer:
if d.PublicKey == "" {
return false
}
if len(d.GetIPAddresses()) == 0 {
return false
}
if d.DefaultEndpoint == "" {
return false
}
case DeviceTypeClient:
if d.PublicKey == "" {
return false
}
if len(d.GetIPAddresses()) == 0 {
return false
}
}
return true
}
func (d *Device) SetIPAddresses(addresses ...string) {
d.IPsStr = common.ListToString(addresses)
}
func (d Device) GetIPAddresses() []string {
return common.ParseStringList(d.IPsStr)
}
func (d *Device) SetDNSServers(addresses ...string) {
d.DNSStr = common.ListToString(addresses)
}
func (d Device) GetDNSServers() []string {
return common.ParseStringList(d.DNSStr)
}
func (d *Device) SetDefaultAllowedIPs(addresses ...string) {
d.DefaultAllowedIPsStr = common.ListToString(addresses)
}
func (d Device) GetDefaultAllowedIPs() []string {
return common.ParseStringList(d.DefaultAllowedIPsStr)
}
func (d Device) GetConfig() wgtypes.Config {
var privateKey *wgtypes.Key
if d.PrivateKey != "" {
pKey, _ := wgtypes.ParseKey(d.PrivateKey)
privateKey = &pKey
}
fwMark := int(d.FirewallMark)
cfg := wgtypes.Config{
PrivateKey: privateKey,
ListenPort: &d.ListenPort,
FirewallMark: &fwMark,
}
return cfg
}
func (d Device) GetConfigFile(peers []Peer, friendlyNames bool) ([]byte, error) {
var tplBuff bytes.Buffer
err := templateCache.ExecuteTemplate(&tplBuff, "interface.tpl", gin.H{
"Peers": peers,
"Interface": d,
"FriendlyNames": friendlyNames,
})
if err != nil {
return nil, errors.Wrap(err, "failed to execute server template")
}
return tplBuff.Bytes(), nil
}
//
// PEER-MANAGER --------------------------------------------------------------------------------
//
type PeerManager struct {
db *gorm.DB
wg *Manager
}
func NewPeerManager(db *gorm.DB, wg *Manager) (*PeerManager, error) {
pm := &PeerManager{db: db, wg: wg}
// check if old device table exists (from version <= 1.0.3), if so migrate it.
if db.Migrator().HasColumn(&Device{}, "endpoint") {
if err := db.Migrator().RenameColumn(&Device{}, "endpoint", "default_endpoint"); err != nil {
return nil, errors.Wrapf(err, "failed to migrate old database structure for column endpoint")
}
}
if db.Migrator().HasColumn(&Device{}, "allowed_ips_str") {
if err := db.Migrator().RenameColumn(&Device{}, "allowed_ips_str", "default_allowed_ips_str"); err != nil {
return nil, errors.Wrapf(err, "failed to migrate old database structure for column allowed_ips_str")
}
}
if db.Migrator().HasColumn(&Device{}, "persistent_keepalive") {
if err := db.Migrator().RenameColumn(&Device{}, "persistent_keepalive", "default_persistent_keepalive"); err != nil {
return nil, errors.Wrapf(err, "failed to migrate old database structure for column persistent_keepalive")
}
}
if err := pm.db.AutoMigrate(&Device{}, &Peer{}); err != nil {
return nil, errors.WithMessage(err, "failed to migrate peer database")
}
if err := pm.initFromPhysicalInterface(); err != nil {
return nil, errors.WithMessagef(err, "unable to initialize peer manager")
}
// check if peers without device name exist (from version <= 1.0.3), if so assign them to the default device.
peers := make([]Peer, 0)
pm.db.Find(&peers)
for i := range peers {
if peers[i].DeviceName == "" {
peers[i].DeviceName = wg.Cfg.GetDefaultDeviceName()
pm.db.Save(&peers[i])
}
}
// validate and update existing peers if needed
for _, deviceName := range wg.Cfg.DeviceNames {
dev := pm.GetDevice(deviceName)
peers := pm.GetAllPeers(deviceName)
for i := range peers {
if err := pm.fixPeerDefaultData(&peers[i], &dev); err != nil {
return nil, errors.WithMessagef(err, "unable to fix peers for interface %s", deviceName)
}
}
}
return pm, nil
}
// initFromPhysicalInterface read all WireGuard peers from the WireGuard interface configuration. If a peer does not
// exist in the local database, it gets created.
func (m *PeerManager) initFromPhysicalInterface() error {
for _, deviceName := range m.wg.Cfg.DeviceNames {
peers, err := m.wg.GetPeerList(deviceName)
if err != nil {
return errors.Wrapf(err, "failed to get peer list for device %s", deviceName)
}
device, err := m.wg.GetDeviceInfo(deviceName)
if err != nil {
return errors.Wrapf(err, "failed to get device info for device %s", deviceName)
}
var ipAddresses []string
var mtu int
if m.wg.Cfg.ManageIPAddresses {
if ipAddresses, err = m.wg.GetIPAddress(deviceName); err != nil {
return errors.Wrapf(err, "failed to get ip address for device %s", deviceName)
}
if mtu, err = m.wg.GetMTU(deviceName); err != nil {
return errors.Wrapf(err, "failed to get MTU for device %s", deviceName)
}
}
// Check if device already exists in database, if not, create it
if err := m.validateOrCreateDevice(*device, ipAddresses, mtu); err != nil {
return errors.WithMessagef(err, "failed to validate device %s", device.Name)
}
// Check if entries already exist in database, if not, create them
for _, peer := range peers {
if err := m.validateOrCreatePeer(deviceName, peer); err != nil {
return errors.WithMessagef(err, "failed to validate peer %s for device %s", peer.PublicKey, deviceName)
}
}
}
return nil
}
// validateOrCreatePeer checks if the given WireGuard peer already exists in the database, if not, the peer entry will be created
// assumption: server mode is used
func (m *PeerManager) validateOrCreatePeer(device string, wgPeer wgtypes.Peer) error {
peer := Peer{}
m.db.Where("public_key = ?", wgPeer.PublicKey.String()).FirstOrInit(&peer)
dev := m.GetDevice(device)
if peer.PublicKey == "" { // peer not found, create
peer.UID = fmt.Sprintf("u%x", md5.Sum([]byte(wgPeer.PublicKey.String())))
if dev.Type == DeviceTypeServer {
peer.PublicKey = wgPeer.PublicKey.String()
peer.Identifier = "Autodetected Client (" + peer.PublicKey[0:8] + ")"
} else if dev.Type == DeviceTypeClient {
peer.PublicKey = wgPeer.PublicKey.String()
if wgPeer.Endpoint != nil {
peer.Endpoint = wgPeer.Endpoint.String()
}
peer.Identifier = "Autodetected Endpoint (" + peer.PublicKey[0:8] + ")"
}
if wgPeer.PresharedKey != (wgtypes.Key{}) {
peer.PresharedKey = wgPeer.PresharedKey.String()
}
peer.Email = "autodetected@example.com"
peer.UpdatedAt = time.Now()
peer.CreatedAt = time.Now()
IPs := make([]string, len(wgPeer.AllowedIPs)) // use allowed IP's as the peer IP's
for i, ip := range wgPeer.AllowedIPs {
IPs[i] = ip.String()
}
peer.SetIPAddresses(IPs...)
peer.DeviceName = device
res := m.db.Create(&peer)
if res.Error != nil {
return errors.Wrapf(res.Error, "failed to create autodetected peer %s", peer.PublicKey)
}
}
if peer.DeviceName == "" {
peer.DeviceName = device
res := m.db.Save(&peer)
if res.Error != nil {
return errors.Wrapf(res.Error, "failed to update autodetected peer %s", peer.PublicKey)
}
}
return nil
}
// validateOrCreateDevice checks if the given WireGuard device already exists in the database, if not, the peer entry will be created
func (m *PeerManager) validateOrCreateDevice(dev wgtypes.Device, ipAddresses []string, mtu int) error {
device := Device{}
m.db.Where("device_name = ?", dev.Name).FirstOrInit(&device)
if device.PublicKey == "" { // device not found, create
device.Type = DeviceTypeServer // imported device, we assume that server mode is used
device.PublicKey = dev.PublicKey.String()
device.PrivateKey = dev.PrivateKey.String()
device.DeviceName = dev.Name
device.ListenPort = dev.ListenPort
device.FirewallMark = int32(dev.FirewallMark)
device.Mtu = 0
device.DefaultPersistentKeepalive = 16 // Default
device.IPsStr = strings.Join(ipAddresses, ", ")
if mtu == DefaultMTU {
mtu = 0
}
device.Mtu = mtu
res := m.db.Create(&device)
if res.Error != nil {
return errors.Wrapf(res.Error, "failed to create autodetected device")
}
}
if device.Type == "" {
device.Type = DeviceTypeServer // from version <= 1.0.3, only server mode devices were supported
res := m.db.Save(&device)
if res.Error != nil {
return errors.Wrapf(res.Error, "failed to update autodetected device")
}
}
return nil
}
// populatePeerData enriches the peer struct with WireGuard live data like last handshake, ...
func (m *PeerManager) populatePeerData(peer *Peer) {
// Set config file
tmpCfg, _ := peer.GetConfigFile(m.GetDevice(peer.DeviceName))
peer.Config = string(tmpCfg)
// set data from WireGuard interface
peer.Peer, _ = m.wg.GetPeer(peer.DeviceName, peer.PublicKey)
peer.LastHandshake = "never"
peer.LastHandshakeTime = "Never connected, or user is disabled."
if peer.Peer != nil {
since := time.Since(peer.Peer.LastHandshakeTime)
sinceSeconds := int(since.Round(time.Second).Seconds())
sinceMinutes := sinceSeconds / 60
sinceSeconds -= sinceMinutes * 60
if sinceMinutes > 2*10080 { // 2 weeks
peer.LastHandshake = "a while ago"
} else if sinceMinutes > 10080 { // 1 week
peer.LastHandshake = "a week ago"
} else {
peer.LastHandshake = fmt.Sprintf("%02dm %02ds", sinceMinutes, sinceSeconds)
}
peer.LastHandshakeTime = peer.Peer.LastHandshakeTime.Format(time.UnixDate)
}
peer.IsOnline = false
}
// fixPeerDefaultData tries to fill all required fields for the given peer
// also tries to migrate data if the database schema changed
func (m *PeerManager) fixPeerDefaultData(peer *Peer, device *Device) error {
updatePeer := false
switch device.Type {
case DeviceTypeServer:
if peer.Endpoint == "" {
peer.Endpoint = device.DefaultEndpoint
updatePeer = true
}
case DeviceTypeClient:
}
if updatePeer {
return m.UpdatePeer(*peer)
}
return nil
}
// populateDeviceData enriches the device struct with WireGuard live data like interface information
func (m *PeerManager) populateDeviceData(device *Device) {
// set data from WireGuard interface
device.Interface, _ = m.wg.GetDeviceInfo(device.DeviceName)
}
func (m *PeerManager) GetAllPeers(device string) []Peer {
peers := make([]Peer, 0)
m.db.Where("device_name = ?", device).Find(&peers)
for i := range peers {
m.populatePeerData(&peers[i])
}
return peers
}
func (m *PeerManager) GetActivePeers(device string) []Peer {
peers := make([]Peer, 0)
m.db.Where("device_name = ? AND deactivated_at IS NULL", device).Find(&peers)
for i := range peers {
m.populatePeerData(&peers[i])
}
return peers
}
func (m *PeerManager) GetFilteredAndSortedPeers(device, sortKey, sortDirection, search string) []Peer {
peers := make([]Peer, 0)
m.db.Where("device_name = ?", device).Find(&peers)
filteredPeers := make([]Peer, 0, len(peers))
for i := range peers {
m.populatePeerData(&peers[i])
if search == "" ||
strings.Contains(peers[i].Email, strings.ToLower(search)) ||
strings.Contains(peers[i].Identifier, search) ||
strings.Contains(peers[i].PublicKey, search) {
filteredPeers = append(filteredPeers, peers[i])
}
}
sortPeers(sortKey, sortDirection, filteredPeers)
return filteredPeers
}
func (m *PeerManager) GetSortedPeersForEmail(sortKey, sortDirection, email string) []Peer {
email = strings.ToLower(email)
peers := make([]Peer, 0)
m.db.Where("email = ?", email).Find(&peers)
for i := range peers {
m.populatePeerData(&peers[i])
}
sortPeers(sortKey, sortDirection, peers)
return peers
}
func sortPeers(sortKey string, sortDirection string, peers []Peer) {
sort.Slice(peers, func(i, j int) bool {
var sortValueLeft string
var sortValueRight string
switch sortKey {
case "id":
sortValueLeft = peers[i].Identifier
sortValueRight = peers[j].Identifier
case "pubKey":
sortValueLeft = peers[i].PublicKey
sortValueRight = peers[j].PublicKey
case "mail":
sortValueLeft = peers[i].Email
sortValueRight = peers[j].Email
case "ip":
sortValueLeft = peers[i].IPsStr
sortValueRight = peers[j].IPsStr
case "endpoint":
sortValueLeft = peers[i].Endpoint
sortValueRight = peers[j].Endpoint
case "handshake":
if peers[i].Peer == nil {
return true
} else if peers[j].Peer == nil {
return false
}
sortValueLeft = peers[i].Peer.LastHandshakeTime.Format(time.RFC3339)
sortValueRight = peers[j].Peer.LastHandshakeTime.Format(time.RFC3339)
}
if sortDirection == "asc" {
return sortValueLeft < sortValueRight
} else {
return sortValueLeft > sortValueRight
}
})
}
func (m *PeerManager) GetDevice(device string) Device {
dev := Device{}
m.db.Where("device_name = ?", device).First(&dev)
m.populateDeviceData(&dev)
return dev
}
func (m *PeerManager) GetPeerByKey(publicKey string) Peer {
peer := Peer{}
m.db.Where("public_key = ?", publicKey).FirstOrInit(&peer)
m.populatePeerData(&peer)
return peer
}
func (m *PeerManager) GetPeersByMail(mail string) []Peer {
mail = strings.ToLower(mail)
var peers []Peer
m.db.Where("email = ?", mail).Find(&peers)
for i := range peers {
m.populatePeerData(&peers[i])
}
return peers
}
// ---- Database helpers -----
func (m *PeerManager) CreatePeer(peer Peer) error {
peer.UID = fmt.Sprintf("u%x", md5.Sum([]byte(peer.PublicKey)))
peer.UpdatedAt = time.Now()
peer.CreatedAt = time.Now()
peer.Email = strings.ToLower(peer.Email)
res := m.db.Create(&peer)
if res.Error != nil {
logrus.Errorf("failed to create peer: %v", res.Error)
return errors.Wrap(res.Error, "failed to create peer")
}
return nil
}
func (m *PeerManager) UpdatePeer(peer Peer) error {
peer.UpdatedAt = time.Now()
peer.Email = strings.ToLower(peer.Email)
res := m.db.Save(&peer)
if res.Error != nil {
logrus.Errorf("failed to update peer: %v", res.Error)
return errors.Wrap(res.Error, "failed to update peer")
}
return nil
}
func (m *PeerManager) DeletePeer(peer Peer) error {
res := m.db.Delete(&peer)
if res.Error != nil {
logrus.Errorf("failed to delete peer: %v", res.Error)
return errors.Wrap(res.Error, "failed to delete peer")
}
return nil
}
func (m *PeerManager) UpdateDevice(device Device) error {
device.UpdatedAt = time.Now()
res := m.db.Save(&device)
if res.Error != nil {
logrus.Errorf("failed to update device: %v", res.Error)
return errors.Wrap(res.Error, "failed to update device")
}
return nil
}
// ---- IP helpers ----
func (m *PeerManager) GetAllReservedIps(device string) ([]string, error) {
reservedIps := make([]string, 0)
peers := m.GetAllPeers(device)
for _, user := range peers {
for _, cidr := range user.GetIPAddresses() {
if cidr == "" {
continue
}
ip, _, err := net.ParseCIDR(cidr)
if err != nil {
return nil, errors.Wrap(err, "failed to parse cidr")
}
reservedIps = append(reservedIps, ip.String())
}
}
dev := m.GetDevice(device)
for _, cidr := range dev.GetIPAddresses() {
if cidr == "" {
continue
}
ip, _, err := net.ParseCIDR(cidr)
if err != nil {
return nil, errors.Wrap(err, "failed to parse cidr")
}
reservedIps = append(reservedIps, ip.String())
}
return reservedIps, nil
}
func (m *PeerManager) IsIPReserved(device string, cidr string) bool {
reserved, err := m.GetAllReservedIps(device)
if err != nil {
return true // in case something failed, assume the ip is reserved
}
ip, ipnet, err := net.ParseCIDR(cidr)
if err != nil {
return true
}
// this two addresses are not usable
broadcastAddr := common.BroadcastAddr(ipnet).String()
networkAddr := ipnet.IP.String()
address := ip.String()
if address == broadcastAddr || address == networkAddr {
return true
}
for _, r := range reserved {
if address == r {
return true
}
}
return false
}
// GetAvailableIp search for an available ip in cidr against a list of reserved ips
func (m *PeerManager) GetAvailableIp(device string, cidr string) (string, error) {
reserved, err := m.GetAllReservedIps(device)
if err != nil {
return "", errors.WithMessagef(err, "failed to get all reserved IP addresses for %s", device)
}
ip, ipnet, err := net.ParseCIDR(cidr)
if err != nil {
return "", errors.Wrap(err, "failed to parse cidr")
}
// this two addresses are not usable
broadcastAddr := common.BroadcastAddr(ipnet).String()
networkAddr := ipnet.IP.String()
for ip := ip.Mask(ipnet.Mask); ipnet.Contains(ip); common.IncreaseIP(ip) {
ok := true
address := ip.String()
for _, r := range reserved {
if address == r {
ok = false
break
}
}
if ok && address != networkAddr && address != broadcastAddr {
netMask := "/32"
if common.IsIPv6(address) {
netMask = "/128"
}
return address + netMask, nil
}
}
return "", errors.New("no more available address from cidr")
}

View File

@@ -1,53 +1,20 @@
package wireguard package wireguard
var ( import (
ClientCfgTpl = `#{{ .Client.Identifier }} "embed"
[Interface] "strings"
Address = {{ .Client.IPsStr }} "text/template"
PrivateKey = {{ .Client.PrivateKey }}
{{- if .Server.DNSStr}}
DNS = {{ .Server.DNSStr }}
{{- end}}
{{- if ne .Server.Mtu 0}}
MTU = {{.Server.Mtu}}
{{- end}}
[Peer]
PublicKey = {{ .Server.PublicKey }}
{{- if .Client.PresharedKey}}
PresharedKey = {{ .Client.PresharedKey }}
{{- end}}
AllowedIPs = {{ .Client.AllowedIPsStr }}
Endpoint = {{ .Server.Endpoint }}
{{- if and (ne .Server.PersistentKeepalive 0) (not .Client.IgnorePersistentKeepalive)}}
PersistentKeepalive = {{.Server.PersistentKeepalive}}
{{- end}}
`
DeviceCfgTpl = `# AUTOGENERATED FILE - DO NOT EDIT
# Updated: {{ .Server.UpdatedAt }} / Created: {{ .Server.CreatedAt }}
[Interface]
{{- range .Server.IPs}}
Address = {{ . }}
{{- end}}
ListenPort = {{ .Server.ListenPort }}
PrivateKey = {{ .Server.PrivateKey }}
{{- if ne .Server.Mtu 0}}
MTU = {{.Server.Mtu}}
{{- end}}
PreUp = {{ .Server.PreUp }}
PostUp = {{ .Server.PostUp }}
PreDown = {{ .Server.PreDown }}
PostDown = {{ .Server.PostDown }}
{{range .Clients}}
{{if not .DeactivatedAt -}}
# {{.Identifier}} / {{.Email}} / Updated: {{.UpdatedAt}} / Created: {{.CreatedAt}}
[Peer]
PublicKey = {{ .PublicKey }}
{{- if .PresharedKey}}
PresharedKey = {{ .PresharedKey }}
{{- end}}
AllowedIPs = {{ StringsJoin .IPs ", " }}
{{- end}}
{{end}}`
) )
//go:embed tpl/*
var Templates embed.FS
var templateCache *template.Template
func init() {
var err error
templateCache, err = template.New("server").Funcs(template.FuncMap{"StringsJoin": strings.Join}).ParseFS(Templates, "tpl/*.tpl")
if err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,81 @@
# AUTOGENERATED FILE - DO NOT EDIT
# -WGP- Interface: {{ .Interface.DeviceName }} / Updated: {{ .Interface.UpdatedAt }} / Created: {{ .Interface.CreatedAt }}
# -WGP- Interface display name: {{ .Interface.DisplayName }}
# -WGP- Interface mode: {{ .Interface.Type }}
# -WGP- PublicKey = {{ .Interface.PublicKey }}
[Interface]
# Core settings
PrivateKey = {{ .Interface.PrivateKey }}
Address = {{ .Interface.IPsStr }}
# Misc. settings (optional)
{{- if ne .Interface.ListenPort 0}}
ListenPort = {{ .Interface.ListenPort }}
{{- end}}
{{- if ne .Interface.Mtu 0}}
MTU = {{.Interface.Mtu}}
{{- end}}
{{- if and (ne .Interface.DNSStr "") (eq $.Interface.Type "client")}}
DNS = {{ .Interface.DNSStr }}
{{- end}}
{{- if ne .Interface.FirewallMark 0}}
FwMark = {{.Interface.FirewallMark}}
{{- end}}
{{- if ne .Interface.RoutingTable ""}}
Table = {{.Interface.RoutingTable}}
{{- end}}
{{- if .Interface.SaveConfig}}
SaveConfig = true
{{- end}}
# Interface hooks (optional)
{{- if .Interface.PreUp}}
PreUp = {{ .Interface.PreUp }}
{{- end}}
{{- if .Interface.PostUp}}
PostUp = {{ .Interface.PostUp }}
{{- end}}
{{- if .Interface.PreDown}}
PreDown = {{ .Interface.PreDown }}
{{- end}}
{{- if .Interface.PostDown}}
PostDown = {{ .Interface.PostDown }}
{{- end}}
#
# Peers
#
{{range .Peers}}
{{- if not .DeactivatedAt}}
# -WGP- Peer: {{.Identifier}} / Updated: {{.UpdatedAt}} / Created: {{.CreatedAt}}
# -WGP- Peer email: {{.Email}}
{{- if .PrivateKey}}
# -WGP- PrivateKey: {{.PrivateKey}}
{{- end}}
[Peer]
{{- if $.FriendlyNames}}
# friendly_name = {{ .Identifier }}
{{- end}}
PublicKey = {{ .PublicKey }}
{{- if .PresharedKey}}
PresharedKey = {{ .PresharedKey }}
{{- end}}
{{- if eq $.Interface.Type "server"}}
AllowedIPs = {{ .IPsStr }}{{if ne .AllowedIPsSrvStr ""}}, {{ .AllowedIPsSrvStr }}{{end}}
{{- end}}
{{- if eq $.Interface.Type "client"}}
{{- if .AllowedIPsStr}}
AllowedIPs = {{ .AllowedIPsStr }}
{{- end}}
{{- end}}
{{- if and (ne .Endpoint "") (eq $.Interface.Type "client")}}
Endpoint = {{ .Endpoint }}
{{- end}}
{{- if ne .PersistentKeepalive 0}}
PersistentKeepalive = {{ .PersistentKeepalive }}
{{- end}}
{{- end}}
{{end}}

View File

@@ -0,0 +1,31 @@
# AUTOGENERATED FILE - PROVIDED BY WIREGUARD PORTAL
# WireGuard configuration: {{ .Peer.Identifier }}
# -WGP- PublicKey: {{ .Peer.PublicKey }}
[Interface]
# Core settings
PrivateKey = {{or .Peer.PrivateKey "<please-insert-your-private-key>" }}
Address = {{ .Peer.IPsStr }}
# Misc. settings (optional)
{{- if .Peer.DNSStr}}
DNS = {{ .Peer.DNSStr }}
{{- end}}
{{- if ne .Peer.Mtu 0}}
MTU = {{.Peer.Mtu}}
{{- end}}
[Peer]
PublicKey = {{ .Interface.PublicKey }}
Endpoint = {{ .Peer.Endpoint }}
{{- if .Peer.AllowedIPsStr}}
AllowedIPs = {{ .Peer.AllowedIPsStr }}
{{- end}}
{{- if .Peer.PresharedKey}}
PresharedKey = {{ .Peer.PresharedKey }}
{{- end}}
{{- if ne .Peer.PersistentKeepalive 0}}
PersistentKeepalive = {{.Peer.PersistentKeepalive}}
{{- end}}

View File

@@ -1,7 +0,0 @@
#!/bin/bash
set -e
goss -g /app/goss/wgportal/goss.yaml validate --format json_oneline
exit 0

View File

@@ -1,3 +0,0 @@
process:
wgportal:
running: true

View File

@@ -1,3 +0,0 @@
process:
wgportal:
running: true

View File

@@ -1,6 +1,9 @@
LISTENING_ADDRESS=:8080 LISTENING_ADDRESS=:8080
WG_DEVICES=wg0
WG_DEFAULT_DEVICE=wg0
WG_CONFIG_PATH=/etc/wireguard
EXTERNAL_URL=https://vpn.company.com EXTERNAL_URL=https://vpn.company.com
WEBSITE_TITLE=WireGuard VPN WEBSITE_TITLE=WireGuard VPN
COMPANY_NAME=Your Company Name COMPANY_NAME=Your Company Name
ADMIN_USER=admin ADMIN_USER=admin@wgportal.local
ADMIN_PASS=supersecret ADMIN_PASS=supersecret

59
tests/README.md Normal file
View File

@@ -0,0 +1,59 @@
# pyswagger unittests for the API & UI
## Requirements
```
wg-quick up conf/wg-example0.conf
sudo LOG_LEVEL=debug CONFIG_FILE=conf/config.yml ../dist/wg-portal-amd64
python3 -m venv ~/venv/apitest
~/venv/apitest/bin/pip install pyswagger mechanize requests pytest PyYAML
```
## Running
### API
```
~/venv/apitest/bin/python3 -m unittest test_API.TestAPI
```
### UI
```
~/venv/lsl/bin/pytest pytest_UI.py
```
## Debugging
Debugging for requests http request/response is included for the API unittesting.
To use, adjust the log level for "api" logger to DEBUG
```python
log.setLevel(logging.DEBUG)
<action>
log.setLevel(logging.INFO)
```
This will provide:
```
2021-09-29 14:55:15,585 DEBUG api HTTP
---------------- request ----------------
GET http://localhost:8123/api/v1/provisioning/peers?Email=test%2Bn4gbm7%40example.org
User-Agent: python-requests/2.26.0
Accept-Encoding: gzip, deflate
Accept: application/json
Connection: keep-alive
Authorization: Basic d2dAZXhhbXBsZS5vcmc6YWJhZGNob2ljZQ==
None
---------------- response ----------------
200 OK http://localhost:8123/api/v1/provisioning/peers?Email=test%2Bn4gbm7%40example.org
Content-Type: application/json; charset=utf-8
Date: Wed, 29 Sep 2021 12:55:15 GMT
Content-Length: 285
[{"PublicKey":"hO3pxnft/8QL6nbE+79HN464Z+L4+D/JjUvNE+8LmTs=",
"Identifier":"Test User (Default)","Device":"wg-example0","DeviceIdentifier":"example0"},
{"PublicKey":"RVS2gsdRpFjyOpr1nAlEkrs194lQytaPHhaxL5amQxY=",
"Identifier":"debug","Device":"wg-example0","DeviceIdentifier":"example0"}]
```

27
tests/conf/config.yml Normal file
View File

@@ -0,0 +1,27 @@
core:
listeningAddress: :8123
externalUrl: https://wg.example.org
title: Example WireGuard VPN
company: Example.org
mailFrom: WireGuard VPN <noreply+wg@example.org>
logoUrl: /img/logo.png
adminUser: wg@example.org
adminPass: abadchoice
editableKeys: true
createDefaultPeer: true
selfProvisioning: true
ldapEnabled: false
database:
typ: sqlite
database: test.db
# :memory: does not work
email:
host: 127.0.0.1
port: 25
tls: false
wg:
devices:
- wg-example0
defaultDevice: wg-example0
configDirectory: /etc/wireguard
manageIPAddresses: true

View File

@@ -0,0 +1,16 @@
# AUTOGENERATED FILE - DO NOT EDIT
# -WGP- Interface: wg-example / Updated: 2021-09-27 08:52:05.537618409 +0000 UTC / Created: 2021-09-24 10:06:46.903674496 +0000 UTC
# -WGP- Interface display name: TheInterface
# -WGP- Interface mode: server
# -WGP- PublicKey = HIgo9xNzJMWLKASShiTqIybxZ0U3wGLiUeJ1PKf8ykw=
[Interface]
# Core settings
PrivateKey = yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=
Address = 10.0.0.0/24
# Misc. settings (optional)
ListenPort = 51820
FwMark = 1
SaveConfig = true

214
tests/pytest_UI.py Normal file
View File

@@ -0,0 +1,214 @@
import logging.config
import http.cookiejar
import random
import string
import mechanize
import yaml
import pytest
@pytest.fixture(scope="function")
def browser():
# Fake Cookie Policy to send the Secure cookies via http
class InSecureCookiePolicy(http.cookiejar.DefaultCookiePolicy):
def set_ok(self, cookie, request):
return True
def return_ok(self, cookie, request):
return True
def domain_return_ok(self, domain, request):
return True
def path_return_ok(self, path, request):
return True
b = mechanize.Browser()
b.set_cookiejar(http.cookiejar.CookieJar(InSecureCookiePolicy()))
b.set_handle_robots(False)
b.set_debug_http(True)
return b
@pytest.fixture
def config():
cfg = yaml.load(open('conf/config.yml', 'r'))
return cfg
@pytest.fixture()
def admin(browser, config):
auth = (c := config['core'])['adminUser'], c['adminPass']
return _login(browser, auth)
def _create_user(admin, values):
b = admin
b.follow_link(text="User Management")
b.follow_link(predicate=has_attr('Add a user'))
# FIXME name form
b.select_form(predicate=lambda x: x.method == 'post')
for k, v in values.items():
b.form.set_value(v, k)
b.submit()
alert = b._factory.root.findall('body/div/div[@role="alert"]')
assert len(alert) == 1 and alert[0].text.strip() == "user created successfully"
return values["email"],values["password"]
def _destroy_user(admin, uid):
b = admin
b.follow_link(text="User Management")
for user in b._factory.root.findall('body/div/div/table[@id="userTable"]/tbody/'):
email,*_ = list(map(lambda x: x.text.strip() if x.text else '', list(user)))
if email == uid:
break
else:
assert False
a = user.findall('td/a[@title="Edit user"]')
assert len(a) == 1
b.follow_link(url=a[0].attrib['href'])
# FIXME name form
b.select_form(predicate=lambda x: x.method == 'post')
disabled = b.find_control("isdisabled")
disabled.set_single("true")
b.submit()
def _destroy_peer(admin, uid):
b = admin
b.follow_link(text="Administration")
peers = b._factory.root.findall('body/div/div/table[@id="userTable"]/tbody/tr')
for idx,peer in enumerate(peers):
if idx % 2 == 1:
continue
head, Identifier, PublicKey, EMail, IPs, Handshake, tail = list(map(lambda x: x.text.strip() if x.text else x, list(peer)))
print(Identifier)
if EMail != uid:
continue
peer = peers[idx+1]
a = peer.findall('.//a[@title="Delete peer"]')
assert len(a) == 1
b.follow_link(url=a[0].attrib['href'])
def _list_peers(user):
r = []
b = user
b.follow_link(predicate=has_attr('User-Profile'))
profiles = b._factory.root.findall('body/div/div/table[@id="userTable"]/tbody/tr')
for idx,profile in enumerate(profiles):
if idx % 2 == 1:
continue
head, Identifier, PublicKey, EMail, IPs, Handshake = list(map(lambda x: x.text.strip() if x.text else x, list(profile)))
profile = profiles[idx+1]
pre = profile.findall('.//pre')
assert len(pre) == 1
r.append((PublicKey, pre))
return r
@pytest.fixture(scope="session")
def user_data():
values = {
"email": f"test+{randstr()}@example.org",
"password": randstr(12),
"firstname": randstr(8),
"lastname": randstr(12)
}
return values
@pytest.fixture
def user(admin, user_data, config):
b = admin
auth = _create_user(b, user_data)
_logout(b)
_login(b, auth)
assert b.find_link(predicate=has_attr('User-Profile'))
yield b
_logout(b)
auth = (c := config['core'])['adminUser'], c['adminPass']
_login(b, auth)
_destroy_user(b, user_data["email"])
_destroy_peer(b, user_data["email"])
@pytest.fixture
def peer(admin, user, user_data):
pass
def _login(browser, auth):
b = browser
b.open("http://localhost:8123/")
b.follow_link(text="Login")
b.select_form(name="login")
username, password = auth
b.form.set_value(username, "username")
b.form.set_value(password, "password")
b.submit()
return b
def _logout(browser):
browser.follow_link(text="Logout")
return browser
def has_attr(value, attr='title'):
def find_attr(x):
return any([a == (attr, value) for a in x.attrs])
return find_attr
def _server(browser, addr):
b = browser
b.follow_link(text="Administration")
b.follow_link(predicate=has_attr('Edit interface settings'))
b.select_form("server")
values = {
"displayname": "example0",
"endpoint": "wg.example.org:51280",
"ip": addr
}
for k, v in values.items():
b.form.set_value(v, k)
b.submit()
return b
@pytest.fixture
def server(admin):
return _server(admin, "10.0.0.0/24")
def randstr(l=6):
return ''.join([random.choice(string.ascii_lowercase + string.digits) for i in range(l)])
def test_admin_login(admin):
b = admin
b.find_link("Administration")
def test_admin_server(admin):
ip = "10.0.0.0/28"
b = _server(admin, ip)
b.select_form("server")
assert ip == b.form.get_value("ip")
def test_admin_create_peer(server, user_data):
auth = _create_user(server, user_data)
def test_admin_create_user(admin, user_data):
auth = _create_user(admin, user_data)
def test_user_login(server, user):
b = user
b.follow_link(predicate=has_attr('User-Profile'))
def test_user_config(server, user):
b = user
peers = _list_peers(b)
assert len(peers) >= 1

484
tests/test_API.py Normal file
View File

@@ -0,0 +1,484 @@
import ipaddress
import collections
import string
import unittest
import datetime
import re
import uuid
import subprocess
import random
import logging
import logging.config
import mechanize
from pyswagger import App, Security
from pyswagger.contrib.client.requests import Client
log = logging.getLogger("api")
class HttpFormatter(logging.Formatter):
def _formatHeaders(self, d):
return '\n'.join(f'{k}: {v}' for k, v in d.items())
def formatMessage(self, record):
result = super().formatMessage(record)
if record.name == 'api':
result += '''
---------------- request ----------------
{req.method} {req.url}
{reqhdrs}
{req.body}
---------------- response ----------------
{res.status_code} {res.reason} {res.url}
{reshdrs}
{res.text}
---------------- end ----------------
'''.format(req=record.req, res=record.res, reqhdrs=self._formatHeaders(record.req.headers),
reshdrs=self._formatHeaders(record.res.headers), )
return result
logging.config.dictConfig(
{
"version": 1,
"formatters": {
"http": {
"()": HttpFormatter,
"format": "{asctime} {levelname} {name} {message}",
"style":'{',
},
"detailed": {
"class": "logging.Formatter",
"format": "%(asctime)s %(name)-9s %(levelname)-4s %(message)s",
},
"plain": {
"class": "logging.Formatter",
"format": "%(message)s",
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": "DEBUG",
"formatter": "detailed",
},
"console_http": {
"class": "logging.StreamHandler",
"level": "DEBUG",
"formatter": "http",
},
},
"root": {
"level": "DEBUG",
"handlers": ["console"],
"propagate": True
},
'loggers': {
'api': {
"level": "INFO",
"handlers": ["console_http"]
},
"requests.packages.urllib3": {
"level": "DEBUG",
"handlers": ["console"],
"propagate": True
},
},
}
)
log = logging.getLogger("api")
class ApiError(Exception):
pass
def logHttp(response, *args, **kwargs):
extra = {'req': response.request, 'res': response}
log.debug('HTTP', extra=extra)
class WGPClient:
def __init__(self, url, *auths):
app = App._create_(url)
auth = Security(app)
for t, cred in auths:
auth.update_with(t, cred)
client = Client(auth)
self.app, self.client = app, client
self.client._Client__s.hooks['response'] = logHttp
def call(self, name, **kwargs):
# print(f"{name} {kwargs}")
op = self.app.op[name]
req, resp = op(**kwargs)
now = datetime.datetime.now()
resp = self.client.request((req, resp))
then = datetime.datetime.now()
delta = then - now
# print(f"{resp.status} {delta}")
if 200 <= resp.status <= 299:
pass
elif 400 <= resp.status <= 499:
raise ApiError(resp.data["Message"])
elif 500 == resp.status:
raise ValueError(resp.data["Message"])
elif 501 == resp.status:
raise NotImplementedError(name)
elif 502 <= resp.status <= 599:
raise ApiError(resp.data["Message"])
return resp
def GetDevice(self, **kwargs):
return self.call("GetDevice", **kwargs).data
def PatchDevice(self, **kwargs):
return self.call("PatchDevice", **kwargs).data
def PutDevice(self, **kwargs):
return self.call("PutDevice", **kwargs).data
def GetDevices(self, **kwargs):
# FIXME - could return empty list?
return self.call("GetDevices", **kwargs).data or []
def DeletePeer(self, **kwargs):
return self.call("DeletePeer", **kwargs).data
def GetPeer(self, **kwargs):
return self.call("GetPeer", **kwargs).data
def PatchPeer(self, **kwargs):
return self.call("PatchPeer", **kwargs).data
def PostPeer(self, **kwargs):
return self.call("PostPeer", **kwargs).data
def PutPeer(self, **kwargs):
return self.call("PutPeer", **kwargs).data
def GetPeerDeploymentConfig(self, **kwargs):
return self.call("GetPeerDeploymentConfig", **kwargs).data
def PostPeerDeploymentConfig(self, **kwargs):
return self.call("PostPeerDeploymentConfig", **kwargs).raw
def GetPeerDeploymentInformation(self, **kwargs):
return self.call("GetPeerDeploymentInformation", **kwargs).data
def GetPeers(self, **kwargs):
return self.call("GetPeers", **kwargs).data
def DeleteUser(self, **kwargs):
return self.call("DeleteUser", **kwargs).data
def GetUser(self, **kwargs):
return self.call("GetUser", **kwargs).data
def PatchUser(self, **kwargs):
return self.call("PatchUser", **kwargs).data
def PostUser(self, **kwargs):
return self.call("PostUser", **kwargs).data
def PutUser(self, **kwargs):
return self.call("PutUser", **kwargs).data
def GetUsers(self, **kwargs):
return self.call("GetUsers", **kwargs).data
def generate_wireguard_keys():
"""
Generate a WireGuard private & public key
Requires that the 'wg' command is available on PATH
Returns (private_key, public_key), both strings
"""
privkey = subprocess.check_output("wg genkey", shell=True).decode("utf-8").strip()
pubkey = subprocess.check_output(f"echo '{privkey}' | wg pubkey", shell=True).decode("utf-8").strip()
return (privkey, pubkey)
KeyTuple = collections.namedtuple("Keys", "private public")
class TestAPI(unittest.TestCase):
URL = 'http://localhost:8123/swagger/doc.json'
AUTH = {
"api": ('ApiBasicAuth', ("wg@example.org", "abadchoice")),
"general": ('GeneralBasicAuth', ("wg@example.org", "abadchoice"))
}
DEVICE = "wg-example0"
IFADDR = "10.17.0.0/24"
log = logging.getLogger("TestAPI")
def _client(self, *auth):
auth = ["general"] if auth is None else auth
self.c = WGPClient(self.URL, *[self.AUTH[i] for i in auth])
@property
def randmail(self):
return 'test+' + ''.join(
[random.choice(string.ascii_lowercase + string.digits) for i in range(6)]) + '@example.org'
@classmethod
def setUpClass(cls) -> None:
cls.finishInstallation()
@classmethod
def finishInstallation(cls) -> None:
import http.cookiejar
# Fake Cookie Policy to send the Secure cookies via http
class InSecureCookiePolicy(http.cookiejar.DefaultCookiePolicy):
def set_ok(self, cookie, request):
return True
def return_ok(self, cookie, request):
return True
def domain_return_ok(self, domain, request):
return True
def path_return_ok(self, path, request):
return True
b = mechanize.Browser()
b.set_cookiejar(http.cookiejar.CookieJar(InSecureCookiePolicy()))
b.set_handle_robots(False)
b.open("http://localhost:8123/")
b.follow_link(text="Login")
b.select_form(name="login")
username, password = cls.AUTH['api'][1]
b.form.set_value(username, "username")
b.form.set_value(password, "password")
b.submit()
b.follow_link(text="Administration")
b.follow_link(predicate=lambda x: any([a == ('title', 'Edit interface settings') for a in x.attrs]))
b.select_form("server")
values = {
"displayname": "example0",
"endpoint": "wg.example.org:51280",
"ip": cls.IFADDR
}
for k, v in values.items():
b.form.set_value(v, k)
b.submit()
b.select_form("server")
# cls.log.debug(b.form.get_value("ip"))
def setUp(self) -> None:
self._client('api')
self.user = self.randmail
# create a user …
self.c.PostUser(User={"Firstname": "Test", "Lastname": "User", "Email": self.user})
self.keys = KeyTuple(*generate_wireguard_keys())
def _test_generate(self):
def key_of(op):
a, *b = list(filter(lambda x: len(x), re.split("([A-Z][a-z]+)", op.operationId)))
return ''.join(b), a
for op in sorted(self.c.app.op.values(), key=key_of):
print(f"""
def {op.operationId}(self, **kwargs):
return self. call("{op.operationId}", **kwargs)
""")
def test_ops(self):
for op in sorted(self.c.app.op.values(), key=lambda op: op.operationId):
self.assertTrue(hasattr(self.c, op.operationId), f"{op.operationId} is missing")
def test_Device(self):
# FIXME device has to be completed via webif to be valid before it can be used via API
devices = self.c.GetDevices()
self.assertTrue(len(devices) > 0)
for device in devices:
dev = self.c.GetDevice(DeviceName=device.DeviceName)
with self.assertRaises(NotImplementedError):
new = self.c.PutDevice(DeviceName=dev.DeviceName,
Device={
"DeviceName": dev.DeviceName,
"IPsStr": dev.IPsStr,
"PrivateKey": dev.PrivateKey,
"Type": "client",
"PublicKey": dev.PublicKey}
)
with self.assertRaises(NotImplementedError):
new = self.c.PatchDevice(DeviceName=dev.DeviceName,
Device={
"DeviceName": dev.DeviceName,
"IPsStr": dev.IPsStr,
"PrivateKey": dev.PrivateKey,
"Type": "client",
"PublicKey": dev.PublicKey}
)
break
def easy_peer(self):
data = self.c.PostPeerDeploymentConfig(ProvisioningRequest={"Email": self.user, "Identifier": "debug"})
data = data.decode()
pubkey = re.search("# -WGP- PublicKey: (?P<pubkey>[^\n]+)\n", data, re.MULTILINE)['pubkey']
privkey = re.search("PrivateKey = (?P<key>[^\n]+)\n", data, re.MULTILINE)['key']
self.keys = KeyTuple(privkey, pubkey)
def test_Peers(self):
privkey, pubkey = generate_wireguard_keys()
peer = {"UID": uuid.uuid4().hex,
"Identifier": uuid.uuid4().hex,
"DeviceName": self.DEVICE,
"PublicKey": pubkey,
"DeviceType": "client",
"IPsStr": str(self.IFADDR),
"Email": self.user}
# keypair is created server side if private key is not submitted
with self.assertRaisesRegex(ApiError, "peer not found"):
self.c.PostPeer(DeviceName=self.DEVICE, Peer=peer)
# create
peer["PrivateKey"] = privkey
p = self.c.PostPeer(DeviceName=self.DEVICE, Peer=peer)
self.assertListEqual([p.PrivateKey, p.PublicKey], [privkey, pubkey])
# lookup created peer
for p in self.c.GetPeers(DeviceName=self.DEVICE):
if pubkey == p.PublicKey:
break
else:
self.assertTrue(False)
# get
gp = self.c.GetPeer(PublicKey=p.PublicKey)
self.assertListEqual([gp.PrivateKey, gp.PublicKey], [p.PrivateKey, p.PublicKey])
# change?
peer['Identifier'] = 'changed'
n = self.c.PatchPeer(PublicKey=p.PublicKey, Peer=peer)
self.assertListEqual([n.PrivateKey, n.PublicKey], [privkey, pubkey])
# change ?
peer['Identifier'] = 'changedagain'
n = self.c.PutPeer(PublicKey=p.PublicKey, Peer=peer)
self.assertListEqual([n.PrivateKey, n.PublicKey], [privkey, pubkey])
# invalid change operations
n = peer.copy()
n['PrivateKey'], n['PublicKey'] = generate_wireguard_keys()
with self.assertRaisesRegex(ApiError, "PublicKey parameter must match the model public key"):
self.c.PutPeer(PublicKey=p.PublicKey, Peer=n)
with self.assertRaisesRegex(ApiError, "PublicKey parameter must match the model public key"):
self.c.PatchPeer(PublicKey=p.PublicKey, Peer=n)
n = self.c.DeletePeer(PublicKey=p.PublicKey)
def test_Deployment(self):
log.setLevel(logging.DEBUG)
self._client("general")
self.easy_peer()
self.c.GetPeerDeploymentConfig(PublicKey=self.keys.public)
self.c.GetPeerDeploymentInformation(Email=self.user)
log.setLevel(logging.INFO)
def test_User(self):
u = self.c.PostUser(User={"Firstname": "Test", "Lastname": "User", "Email": self.randmail})
for i in self.c.GetUsers():
if i.Email == u.Email:
break
else:
self.assertTrue(False)
u = self.c.GetUser(Email=u.Email)
self.c.PutUser(Email=u.Email, User={"Firstname": "Test", "Lastname": "User", "Email": u.Email})
self.c.PatchUser(Email=u.Email, User={"Firstname": "Test", "Lastname": "User", "Email": u.Email})
# list a deleted user
self.c.DeleteUser(Email=u.Email)
for i in self.c.GetUsers():
break
def _clear_peers(self):
for p in self.c.GetPeers(DeviceName=self.DEVICE):
self.c.DeletePeer(PublicKey=p.PublicKey)
def _clear_users(self):
for p in self.c.GetUsers():
if p.Email == self.AUTH['api'][1][0]:
continue
self.c.DeleteUser(Email=p.Email)
def _createPeer(self):
privkey, pubkey = generate_wireguard_keys()
peer = {"UID": uuid.uuid4().hex,
"Identifier": uuid.uuid4().hex,
"DeviceName": self.DEVICE,
"PublicKey": pubkey,
"PrivateKey": privkey,
"DeviceType": "client",
# "IPsStr": str(self.ifaddr),
"Email": self.user}
self.c.PostPeer(DeviceName=self.DEVICE, Peer=peer)
return pubkey
def test_address_exhaustion(self):
global log
self._clear_peers()
self._clear_users()
self.NETWORK = ipaddress.ip_network("10.0.0.0/29")
addr = ipaddress.ip_address(
random.randrange(int(self.NETWORK.network_address) + 1, int(self.NETWORK.broadcast_address) - 1))
self.__class__.IFADDR = str(ipaddress.ip_interface(f"{addr}/{self.NETWORK.prefixlen}"))
# reconfigure via web ui - set the ifaddr with less addrs in pool
self.finishInstallation()
keys = set()
EADDRESSEXHAUSTED = "failed to get available IP addresses: no more available address from cidr"
with self.assertRaisesRegex(ValueError, EADDRESSEXHAUSTED):
for i in range(self.NETWORK.num_addresses + 1):
keys.add(self._createPeer())
n = keys.pop()
self.c.DeletePeer(PublicKey=n)
self._createPeer()
with self.assertRaisesRegex(ValueError, EADDRESSEXHAUSTED):
self._createPeer()
# expand network
self.NETWORK = ipaddress.ip_network("10.0.0.0/28")
addr = ipaddress.ip_address(
random.randrange(int(self.NETWORK.network_address) + 1, int(self.NETWORK.broadcast_address) - 1))
self.__class__.IFADDR = str(ipaddress.ip_interface(f"{addr}/{self.NETWORK.prefixlen}"))
self.finishInstallation()
self._createPeer()