Compare commits

..

60 Commits

Author SHA1 Message Date
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
58 changed files with 5241 additions and 1748 deletions

2
.gitignore vendored
View File

@@ -32,3 +32,5 @@ ssh.key
.testCoverage.txt .testCoverage.txt
wg_portal.db wg_portal.db
go.sum go.sum
swagger.json
swagger.yaml

View File

@@ -20,11 +20,11 @@ script:
- go get -t -v ./... - go get -t -v ./...
- diff -u <(echo -n) <(gofmt -d .) - diff -u <(echo -n) <(gofmt -d .)
- go vet $(go list ./... | grep -v /vendor/) - go vet $(go list ./... | grep -v /vendor/)
- make build - make ENV_BUILD_IDENTIFIER=$TRAVIS_TAG ENV_BUILD_VERSION=$(echo $TRAVIS_COMMIT | cut -c1-7) build
# Switch over GCC to cross compilation (breaks 386, hence why do it here only) # Switch over GCC to cross compilation (breaks 386, hence why do it here only)
- 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 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 gcc-aarch64-linux-gnu libc6-dev-arm64-cross
- sudo ln -s /usr/include/asm-generic /usr/include/asm - sudo ln -s /usr/include/asm-generic /usr/include/asm
- make build-cross-plat - make ENV_BUILD_IDENTIFIER=$TRAVIS_TAG ENV_BUILD_VERSION=$(echo $TRAVIS_COMMIT | cut -c1-7) build-cross-plat
deploy: deploy:
provider: releases provider: releases

View File

@@ -6,6 +6,12 @@
######- ######-
FROM golang:1.16 as builder FROM golang:1.16 as builder
ARG BUILD_IDENTIFIER
ENV ENV_BUILD_IDENTIFIER=$BUILD_IDENTIFIER
ARG BUILD_VERSION
ENV ENV_BUILD_VERSION=$BUILD_VERSION
RUN mkdir /build RUN mkdir /build
# Copy the source from the current directory to the Working Directory inside the container # Copy the source from the current directory to the Working Directory inside the container
@@ -17,28 +23,32 @@ WORKDIR /build
# Workaround for failing travis-ci builds # Workaround for failing travis-ci builds
RUN rm -rf ~/go; rm -rf go.sum RUN rm -rf ~/go; rm -rf go.sum
# Download dependencies
RUN curl -L https://git.prolicht.digital/pub/healthcheck/-/releases/v1.0.1/downloads/binaries/hc -o /build/hc; \
chmod +rx /build/hc; \
echo "Building version: $ENV_BUILD_IDENTIFIER-$ENV_BUILD_VERSION"
# Build the Go app # Build the Go app
RUN go clean -modcache; go mod tidy; make build RUN go clean -modcache; go mod tidy; make build-docker
######- ######-
# 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.16 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 # Import healthcheck binary
COPY --from=builder /build/scripts /app/ COPY --from=builder /build/hc /app/hc
# Copy binaries
COPY --from=builder /build/dist/wgportal /app/wgportal
# Set the Current Working Directory inside the container # Set the Current Working Directory inside the container
WORKDIR /app WORKDIR /app
@@ -46,5 +56,4 @@ WORKDIR /app
# Command to run the executable # Command to run the executable
CMD [ "/app/wgportal" ] CMD [ "/app/wgportal" ]
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

View File

@@ -18,6 +18,9 @@ build-cross-plat: dep build $(addsuffix -arm,$(addprefix $(BUILDDIR)/,$(BINARIES
cp scripts/wg-portal.service $(BUILDDIR) cp scripts/wg-portal.service $(BUILDDIR)
cp scripts/wg-portal.env $(BUILDDIR) cp scripts/wg-portal.env $(BUILDDIR)
build-docker: dep
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 $(GOCMD) build -o $(BUILDDIR)/wgportal -ldflags "-w -s -linkmode external -extldflags \"-static\" -X github.com/h44z/wg-portal/internal/server.Version=${ENV_BUILD_IDENTIFIER}-${ENV_BUILD_VERSION}" -tags netgo cmd/wg-portal/main.go
dep: dep:
$(GOCMD) mod download $(GOCMD) mod download
@@ -48,13 +51,17 @@ docker-build:
docker-push: docker-push:
docker push $(IMAGE) docker push $(IMAGE)
api-docs:
cd internal/server; swag init --parseDependency --parseInternal --generalInfo api.go
$(GOCMD) fmt internal/server/docs/docs.go
$(BUILDDIR)/%-amd64: cmd/%/main.go dep phony $(BUILDDIR)/%-amd64: cmd/%/main.go dep phony
GOOS=linux GOARCH=amd64 $(GOCMD) build -o $@ $< GOOS=linux GOARCH=amd64 $(GOCMD) build -ldflags "-X github.com/h44z/wg-portal/internal/server.Version=${ENV_BUILD_IDENTIFIER}-${ENV_BUILD_VERSION}" -o $@ $<
# On arch-linux install aarch64-linux-gnu-gcc to crosscompile for arm64 # On arch-linux install aarch64-linux-gnu-gcc to crosscompile for arm64
$(BUILDDIR)/%-arm64: cmd/%/main.go dep phony $(BUILDDIR)/%-arm64: cmd/%/main.go dep phony
CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc GOOS=linux GOARCH=arm64 $(GOCMD) build -ldflags "-linkmode external -extldflags -static" -o $@ $< CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc GOOS=linux GOARCH=arm64 $(GOCMD) build -ldflags "-linkmode external -extldflags \"-static\" -X github.com/h44z/wg-portal/internal/server.Version=${ENV_BUILD_IDENTIFIER}-${ENV_BUILD_VERSION}" -o $@ $<
# On arch-linux install arm-linux-gnueabihf-gcc to crosscompile for arm # On arch-linux install arm-linux-gnueabihf-gcc to crosscompile for arm
$(BUILDDIR)/%-arm: cmd/%/main.go dep phony $(BUILDDIR)/%-arm: cmd/%/main.go dep phony
CGO_ENABLED=1 CC=arm-linux-gnueabi-gcc GOOS=linux GOARCH=arm GOARM=7 $(GOCMD) build -ldflags "-linkmode external -extldflags -static" -o $@ $< CGO_ENABLED=1 CC=arm-linux-gnueabi-gcc GOOS=linux GOARCH=arm GOARM=7 $(GOCMD) build -ldflags "-linkmode external -extldflags \"-static\" -X github.com/h44z/wg-portal/internal/server.Version=${ENV_BUILD_IDENTIFIER}-${ENV_BUILD_VERSION}" -o $@ $<

View File

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

118
README.md
View File

@@ -29,6 +29,8 @@ It also supports LDAP (Active Directory or OpenLDAP) as authentication provider.
* Responsive template * Responsive template
* One single binary * One single binary
* Can be used with existing WireGuard setups * Can be used with existing WireGuard setups
* Support for multiple WireGuard interfaces
* REST API for management and client deployment
![Screenshot](screenshot.png) ![Screenshot](screenshot.png)
@@ -37,6 +39,8 @@ It also supports LDAP (Active Directory or OpenLDAP) as authentication provider.
### Docker ### Docker
The easiest way to run WireGuard Portal is to use the Docker image provided. The easiest way to run WireGuard Portal is to use the Docker image provided.
HINT: the *latest* tag always refers to the master branch and might contain unstable or incompatible code!
Docker Compose snippet with some sample configuration values: Docker Compose snippet with some sample configuration values:
``` ```
version: '3.6' version: '3.6'
@@ -54,14 +58,21 @@ 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@domain.com
- ADMIN_PASS=supersecret - ADMIN_PASS=supersecret
# 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_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
@@ -71,7 +82,7 @@ services:
``` ```
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 backup 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#L57). For a full list of configuration options take a look at the source file [internal/server/configuration.go](internal/server/configuration.go#L56).
### 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.
@@ -86,10 +97,113 @@ make build-cross-plat
The compiled binary 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).
## 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. |
| 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. |
| LDAP_ENABLED | ldapEnabled | core | false | Enable or disable the LDAP backend. |
| SESSION_SECRET | sessionSecret | core | secret | Use a custom secret to encrypt session data. |
| 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. |
| 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. |
| 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. |
| 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`.
## What is out of scope ## What is out of scope
* Generation or application of any `iptables` or `nftables` rules * 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 * 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

@@ -80,4 +80,24 @@ pre{background:#f7f7f9}iframe{overflow:hidden;border:none}@media (min-width: 768
.form-group.required label:after { .form-group.required label:after {
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;
} }

View File

@@ -0,0 +1,3 @@
.navbar {
padding: 0.5rem 1rem;
}

View File

@@ -25,6 +25,11 @@
} }
}); });
}); });
$(function() {
$('select.device-selector').change(function() {
this.form.submit();
});
});
})(jQuery); // End of use strict })(jQuery); // End of use strict

View File

@@ -20,16 +20,17 @@
<h2>Enter valid LDAP user email addresses to quickly create new accounts.</h2> <h2>Enter valid LDAP 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>

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}}" required>
</div> </div>
</div> </div>
{{else}} {{else}}
@@ -47,48 +53,64 @@
<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 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-12">
<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>
@@ -98,6 +120,80 @@
<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-12">
<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>
<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>

View File

@@ -16,98 +16,240 @@
<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">
<input type="hidden" name="device" value="{{.Device.DeviceName}}"> <li class="nav-item">
<h3>Server's interface configuration</h3> <a class="nav-link {{if eq .Device.Type "server"}}active{{end}}" data-toggle="tab" href="#server">Server Mode</a>
{{if .EditableKeys}} </li>
<div class="form-row"> <li class="nav-item">
<div class="form-group required col-md-12"> <a class="nav-link {{if eq .Device.Type "client"}}active{{end}}" data-toggle="tab" href="#client">Client Mode</a>
<label for="inputServerPrivateKey">Private Key</label> </li>
<input type="text" name="privkey" class="form-control" id="inputServerPrivateKey" value="{{.Device.PrivateKey}}"> </ul>
</div>
</div> <div id="configContent" class="tab-content">
<div class="form-row"> <!-- server mode -->
<div class="form-group required col-md-12"> <div class="tab-pane fade {{if eq .Device.Type "server"}}active show{{end}}" id="server">
<label for="inputServerPublicKey">Public Key</label> <form method="post" enctype="multipart/form-data">
<input type="text" name="pubkey" class="form-control" id="inputServerPublicKey" value="{{.Device.PublicKey}}"> <input type="hidden" name="_csrf" value="{{.Csrf}}">
</div> <input type="hidden" name="device" value="{{.Device.DeviceName}}">
</div> <input type="hidden" name="devicetype" value="server">
{{else}} <h3>Server's interface configuration</h3>
<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="server_DisplayName">Display Name</label>
<label for="inputServerPublicKey">Public Key</label> <input type="text" name="displayname" class="form-control" id="server_DisplayName" value="{{.Device.DisplayName}}">
<input type="text" name="pubkey" readonly class="form-control" id="inputServerPublicKey" value="{{.Device.PublicKey}}"> </div>
</div> </div>
</div> {{if .EditableKeys}}
{{end}} <div class="form-row">
<div class="form-row"> <div class="form-group required col-md-12">
<div class="form-group required col-md-6"> <label for="server_PrivateKey">Private Key</label>
<label for="inputListenPort">Listen port</label> <input type="text" name="privkey" class="form-control" id="server_PrivateKey" value="{{.Device.PrivateKey}}" required>
<input type="number" name="port" class="form-control" id="inputListenPort" placeholder="51820" value="{{.Device.ListenPort}}"> </div>
</div> </div>
<div class="form-group required col-md-6"> <div class="form-row">
<label for="inputIPs">Server IP address</label> <div class="form-group required col-md-12">
<input type="text" name="ip" class="form-control" id="inputIPs" placeholder="10.6.6.1/24" value="{{.Device.IPsStr}}"> <label for="server_PublicKey">Public Key</label>
</div> <input type="text" name="pubkey" class="form-control" id="server_PublicKey" value="{{.Device.PublicKey}}" required>
</div> </div>
<h3>Client's global configuration</h3> </div>
<div class="form-row"> {{else}}
<div class="form-group required col-md-12"> <input type="hidden" name="privkey" value="{{.Device.PrivateKey}}">
<label for="inputPublicEndpoint">Public Endpoint for Clients</label> <div class="form-row">
<input type="text" name="endpoint" class="form-control" id="inputPublicEndpoint" placeholder="vpn.company.com:51820" value="{{.Device.Endpoint}}"> <div class="form-group col-md-12">
</div> <label for="server_ro_PublicKey">Public Key</label>
</div> <input type="text" name="pubkey" readonly class="form-control" id="server_ro_PublicKey" value="{{.Device.PublicKey}}">
<div class="form-row"> </div>
<div class="form-group col-md-6"> </div>
<label for="inputDNS">DNS Servers</label> {{end}}
<input type="text" name="dns" class="form-control" id="inputDNS" placeholder="1.1.1.1" value="{{.Device.DNSStr}}"> <div class="form-row">
</div> <div class="form-group required col-md-6">
<div class="form-group col-md-6"> <label for="server_ListenPort">Listen port</label>
<label for="inputAllowedIP">Default allowed IPs</label> <input type="number" name="port" class="form-control" id="server_ListenPort" placeholder="51820" value="{{.Device.ListenPort}}" required>
<input type="text" name="allowedip" class="form-control" id="inputAllowedIP" placeholder="10.6.6.0/24" value="{{.Device.AllowedIPsStr}}"> </div>
</div> <div class="form-group required col-md-6">
</div> <label for="server_IPs">Server IP address</label>
<div class="form-row"> <input type="text" name="ip" class="form-control" id="server_IPs" placeholder="10.6.6.1/24" value="{{.Device.IPsStr}}" required>
<div class="form-group col-md-6"> </div>
<label for="inputMTU">Global MTU</label> </div>
<input type="number" name="mtu" class="form-control" id="inputMTU" placeholder="0" value="{{.Device.Mtu}}"> <h3>Client's global configuration (<span class="text-blue">g</span>)</h3>
</div> <div class="form-row">
<div class="form-group col-md-6"> <div class="form-group required col-md-12">
<label for="inputPersistentKeepalive">Persistent Keepalive</label> <label for="server_PublicEndpoint">Public Endpoint for Clients</label>
<input type="number" name="keepalive" class="form-control" id="inputPersistentKeepalive" placeholder="16" value="{{.Device.PersistentKeepalive}}"> <input type="text" name="endpoint" class="form-control" id="server_PublicEndpoint" placeholder="vpn.company.com:51820" value="{{.Device.DefaultEndpoint}}" required>
</div> </div>
</div> </div>
<h3>Interface configuration hooks</h3> <div class="form-row">
<div class="form-row"> <div class="form-group col-md-6">
<div class="form-group col-md-12"> <label for="server_DNS">DNS Servers</label>
<label for="inputPreUp">Pre Up</label> <input type="text" name="dns" class="form-control" id="server_DNS" placeholder="1.1.1.1" value="{{.Device.DNSStr}}">
<input type="text" name="preup" class="form-control" id="inputPreUp" value="{{.Device.PreUp}}"> </div>
</div> <div class="form-group col-md-6">
</div> <label for="server_AllowedIP">Default allowed IPs</label>
<div class="form-row"> <input type="text" name="allowedip" class="form-control" id="server_AllowedIP" placeholder="10.6.6.0/24" value="{{.Device.DefaultAllowedIPsStr}}">
<div class="form-group col-md-12"> </div>
<label for="inputPostUp">Post Up</label> </div>
<input type="text" name="postup" class="form-control" id="inputPostUp" value="{{.Device.PostUp}}"> <div class="form-row">
</div> <div class="form-group col-md-6">
</div> <label for="server_MTU">MTU (also used for the server interface, 0 = default)</label>
<div class="form-row"> <input type="number" name="mtu" class="form-control" id="server_MTU" placeholder="" value="{{.Device.Mtu}}">
<div class="form-group col-md-12"> </div>
<label for="inputPreDown">Pre Down</label> <div class="form-group col-md-6">
<input type="text" name="predown" class="form-control" id="inputPreDown" value="{{.Device.PreDown}}"> <label for="server_PersistentKeepalive">Persistent Keepalive (0 = off)</label>
</div> <input type="number" name="keepalive" class="form-control" id="server_PersistentKeepalive" placeholder="16" value="{{.Device.DefaultPersistentKeepalive}}">
</div> </div>
<div class="form-row"> </div>
<div class="form-group col-md-12"> <h3>Interface configuration hooks</h3>
<label for="inputPostDown">Post Down</label> <div class="form-row">
<input type="text" name="postdown" class="form-control" id="inputPostDown" value="{{.Device.PostDown}}"> <div class="form-group col-md-12">
</div> <label for="server_PreUp">Pre Up</label>
<input type="text" name="preup" class="form-control" id="server_PreUp" value="{{.Device.PreUp}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="server_PostUp">Post Up</label>
<input type="text" name="postup" class="form-control" id="server_PostUp" value="{{.Device.PostUp}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="server_PreDown">Pre Down</label>
<input type="text" name="predown" class="form-control" id="server_PreDown" value="{{.Device.PreDown}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="server_PostDown">Post Down</label>
<input type="text" name="postdown" class="form-control" id="server_PostDown" value="{{.Device.PostDown}}">
</div>
</div>
<div class="form-row">
<div class="d-flex align-items-center">
<a href="#" class="advanced-settings btn btn-link collapsed" data-toggle="collapse" data-target="#collapseAdvancedServer" aria-expanded="false" aria-controls="collapseAdvancedServer">
Advanced Settings
</a>
</div>
</div>
<div id="collapseAdvancedServer" class="collapse" aria-labelledby="collapseAdvancedServer">
<div class="form-row">
<div class="form-group col-md-6">
<label for="server_FirewallMark">Firewall Mark (0 = default or off)</label>
<input type="number" name="firewallmark" class="form-control" id="server_FirewallMark" placeholder="" value="{{.Device.FirewallMark}}">
</div>
<div class="form-group col-md-6">
<label for="server_RoutingTable">Routing Table (empty = default or auto)</label>
<input type="text" name="routingtable" class="form-control" id="server_RoutingTable" placeholder="auto" value="{{.Device.RoutingTable}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<div class="custom-control custom-switch">
<input class="custom-control-input" name="saveconfig" type="checkbox" value="true" id="server_SaveConfig" {{if .Peer.SaveConfig}}checked{{end}}>
<label class="custom-control-label" for="server_SaveConfig">
Save Configuration (if interface was edited via WireGuard configuration tool)
</label>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Save</button>
<a href="/admin" class="btn btn-secondary">Cancel</a>
<a href="/admin/device/applyglobals" class="btn btn-dark float-right">Apply Global Settings (<span class="text-blue">g</span>) to clients</a>
</form>
</div> </div>
<button type="submit" class="btn btn-primary">Save</button> <!-- client mode -->
<a href="/admin" class="btn btn-secondary">Cancel</a> <div class="tab-pane fade {{if eq .Device.Type "client"}}active show{{end}}" id="client">
<a href="/admin/device/applyglobals" class="btn btn-dark float-right">Apply Allowed IP's to clients</a> <form method="post" enctype="multipart/form-data">
</form> <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> </div>
{{template "prt_footer.html" .}} {{template "prt_footer.html" .}}
<script src="/js/jquery.min.js"></script> <script src="/js/jquery.min.js"></script>

View File

@@ -22,11 +22,12 @@
{{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}}">
{{if eq .User.CreatedAt .Epoch}} {{if eq .User.CreatedAt .Epoch}}
<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</label> <label for="inputEmail">Email</label>
<input type="text" name="email" class="form-control" id="inputEmail" value="{{.User.Email}}"> <input type="text" name="email" class="form-control" id="inputEmail" value="{{.User.Email}}" required>
</div> </div>
</div> </div>
{{else}} {{else}}
@@ -35,13 +36,13 @@
<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="inputFirstname">Firstname</label> <label for="inputFirstname">Firstname</label>
<input type="text" name="firstname" class="form-control" id="inputFirstname" value="{{.User.Firstname}}"> <input type="text" name="firstname" class="form-control" id="inputFirstname" value="{{.User.Firstname}}" 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="inputLastname">Lastname</label> <label for="inputLastname">Lastname</label>
<input type="text" name="lastname" class="form-control" id="inputLastname" value="{{.User.Lastname}}"> <input type="text" name="lastname" class="form-control" id="inputLastname" value="{{.User.Lastname}}" required>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
@@ -53,7 +54,7 @@
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-12 {{if eq .User.CreatedAt .Epoch}}required{{end}}"> <div class="form-group col-md-12 {{if eq .User.CreatedAt .Epoch}}required{{end}}">
<label for="inputPassword">Password</label> <label for="inputPassword">Password</label>
<input type="password" name="password" class="form-control" id="inputPassword"> <input type="password" name="password" class="form-control" id="inputPassword" {{if eq .User.CreatedAt .Epoch}}required{{end}}>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">

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,22 +76,68 @@
</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-10 col-12">
{{if eq $.Device.Type "server"}}
<h2 class="mt-2">Current VPN Peers</h2> <h2 class="mt-2">Current VPN Peers</h2>
{{end}}
{{if eq $.Device.Type "client"}}
<h2 class="mt-2">Current VPN Endpoints</h2>
{{end}}
</div> </div>
<div class="col-sm-2 col-12 text-right"> <div class="col-sm-2 col-12 text-right">
{{if eq $.Device.Type "server"}}
<a href="/admin/peer/createldap" title="Add multiple peers" class="btn btn-primary"><i class="fa fa-fw fa-user-plus"></i></a> <a href="/admin/peer/createldap" title="Add multiple peers" class="btn btn-primary"><i class="fa fa-fw fa-user-plus"></i></a>
<a href="/admin/peer/create" title="Manually add a peer" class="btn btn-primary"><i class="fa fa-fw fa-plus"></i>M</a> {{end}}
<a href="/admin/peer/create" title="Add a peer" class="btn btn-primary"><i class="fa fa-fw fa-plus"></i>M</a>
</div> </div>
</div> </div>
<div class="mt-2 table-responsive"> <div class="mt-2 table-responsive">
@@ -98,14 +147,22 @@
<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 "peers" "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 "peers" "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>
{{if eq $.Device.Type "server"}}
<th scope="col"><a href="?sort=mail">E-Mail <i class="fa fa-fw {{.Session.GetSortIcon "peers" "mail"}}"></i></a></th> <th scope="col"><a href="?sort=mail">E-Mail <i class="fa fa-fw {{.Session.GetSortIcon "peers" "mail"}}"></i></a></th>
{{end}}
{{if eq $.Device.Type "server"}}
<th scope="col"><a href="?sort=ip">IP's <i class="fa fa-fw {{.Session.GetSortIcon "peers" "ip"}}"></i></a></th> <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"><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>
@@ -114,8 +171,15 @@
</th> </th>
<td>{{$p.Identifier}}</td> <td>{{$p.Identifier}}</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}}
@@ -132,9 +196,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>
@@ -142,14 +208,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.User}} {{if not $peerUser}}
<p>No user information available...</p> <p>No user information available...</p>
{{else}} {{else}}
<ul> <ul>
<li>Firstname: {{$p.User.Firstname}}</li> <li>Firstname: {{$peerUser.Firstname}}</li>
<li>Lastname: {{$p.User.Lastname}}</li> <li>Lastname: {{$peerUser.Lastname}}</li>
<li>Phone: {{$p.User.Phone}}</li> <li>Phone: {{$peerUser.Phone}}</li>
<li>Mail: {{$p.User.Email}}</li> <li>Mail: {{$peerUser.Email}}</li>
</ul> </ul>
{{end}} {{end}}
<h4>Connection / Traffic</h4> <h4>Connection / Traffic</h4>
@@ -160,22 +226,28 @@
<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">
{{if eq $.Device.Type "server"}}
<img class="list-image-large" src="/user/qrcode?pkey={{$p.PublicKey}}"/> <img class="list-image-large" src="/user/qrcode?pkey={{$p.PublicKey}}"/>
{{end}}
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{{if eq $.Device.Type "server"}}
<div class="float-right mt-5"> <div class="float-right mt-5">
<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>

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

@@ -13,12 +13,22 @@
<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">
<input type="hidden" name="_csrf" value="{{.Csrf}}">
<div class="form-group"> <div class="form-group">
<label for="inputUsername">Email</label> <label for="inputUsername">Email</label>
<input type="text" name="username" class="form-control" id="inputUsername" aria-describedby="usernameHelp" placeholder="Enter email"> <input type="text" name="username" class="form-control" id="inputUsername" aria-describedby="usernameHelp" placeholder="Enter email">
@@ -27,15 +37,16 @@
<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>

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

@@ -22,6 +22,19 @@
{{end}} {{end}}
{{end}}{{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>
@@ -43,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

@@ -30,6 +30,7 @@
</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>
@@ -58,14 +59,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.User}} {{if not $peerUser}}
<p>No user information available...</p> <p>No user information available...</p>
{{else}} {{else}}
<ul> <ul>
<li>Firstname: {{$p.User.Firstname}}</li> <li>Firstname: {{$peerUser.Firstname}}</li>
<li>Lastname: {{$p.User.Lastname}}</li> <li>Lastname: {{$peerUser.Lastname}}</li>
<li>Phone: {{$p.User.Phone}}</li> <li>Phone: {{$peerUser.Phone}}</li>
<li>Mail: {{$p.User.Email}}</li> <li>Mail: {{$peerUser.Email}}</li>
</ul> </ul>
{{end}} {{end}}
<h4>Traffic</h4> <h4>Traffic</h4>

View File

@@ -8,24 +8,26 @@ import (
"syscall" "syscall"
"time" "time"
"git.prolicht.digital/pub/healthcheck"
"github.com/h44z/wg-portal/internal/server" "github.com/h44z/wg-portal/internal/server"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
var Version = "unknown (local build)"
func main() { func main() {
_ = setupLogger(logrus.StandardLogger()) _ = setupLogger(logrus.StandardLogger())
c := make(chan os.Signal, 1) c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) signal.Notify(c, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
logrus.Infof("starting WireGuard Portal Server [%s]...", Version) logrus.Infof("starting WireGuard Portal Server [%s]...", server.Version)
// Context for clean shutdown // Context for clean shutdown
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
// start health check service on port 11223
healthcheck.New(healthcheck.WithContext(ctx)).Start()
service := server.Server{} service := server.Server{}
if err := service.Setup(ctx); err != nil { if err := service.Setup(ctx); err != nil {
logrus.Fatalf("setup failed: %v", err) logrus.Fatalf("setup failed: %v", err)

View File

@@ -1,7 +1,7 @@
version: '3.6' version: '3.6'
services: services:
wg-portal: wg-portal:
image: h44z/wg-portal:latest image: h44z/wg-portal:1.0.6
container_name: wg-portal container_name: wg-portal
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:

32
go.mod
View File

@@ -3,25 +3,35 @@ module github.com/h44z/wg-portal
go 1.16 go 1.16
require ( require (
git.prolicht.digital/pub/healthcheck v1.0.1
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
github.com/evanphx/json-patch v0.5.2
github.com/gin-contrib/sessions v0.0.3 github.com/gin-contrib/sessions v0.0.3
github.com/gin-gonic/gin v1.6.3 github.com/gin-gonic/gin v1.6.3
github.com/go-ldap/ldap/v3 v3.2.4 github.com/go-ldap/ldap/v3 v3.2.4
github.com/go-playground/validator/v10 v10.2.0 github.com/go-openapi/spec v0.20.3 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/validator/v10 v10.4.1
github.com/gorilla/sessions v1.2.1 // indirect github.com/gorilla/sessions v1.2.1 // indirect
github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible
github.com/kelseyhightower/envconfig v1.4.0 github.com/kelseyhightower/envconfig v1.4.0
github.com/mailru/easyjson v0.7.7 // indirect
github.com/milosgajdos/tenus v0.0.3 github.com/milosgajdos/tenus v0.0.3
github.com/mitchellh/gox v1.0.1 // indirect
github.com/necrose99/gox v0.4.0 // indirect
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/sirupsen/logrus v1.7.0 github.com/sirupsen/logrus v1.8.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/swaggo/gin-swagger v1.3.0
github.com/swaggo/swag v1.7.0
github.com/tatsushid/go-fastping v0.0.0-20160109021039-d7bb493dee3e github.com/tatsushid/go-fastping v0.0.0-20160109021039-d7bb493dee3e
github.com/toorop/gin-logrus v0.0.0-20200831135515-d2ee50d38dae github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca
github.com/xhit/go-simple-mail/v2 v2.8.1
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6 // indirect
golang.org/x/sys v0.0.0-20210426080607-c94f62235c83 // indirect
golang.org/x/tools v0.1.0 // indirect
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20200609130330-bd2cb7843e1b golang.zx2c4.com/wireguard/wgctrl v0.0.0-20200609130330-bd2cb7843e1b
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
gorm.io/driver/mysql v1.0.4 gorm.io/driver/mysql v1.0.5
gorm.io/driver/sqlite v1.1.3 gorm.io/driver/sqlite v1.1.4
gorm.io/gorm v1.20.12 gorm.io/gorm v1.21.6
) )

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

@@ -2,7 +2,6 @@ package ldap
import ( import (
"crypto/tls" "crypto/tls"
"fmt"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -69,13 +68,11 @@ func (provider Provider) Login(ctx *authentication.AuthContext) (string, error)
// Search for the given username // Search for the given username
attrs := []string{"dn", provider.config.EmailAttribute} attrs := []string{"dn", provider.config.EmailAttribute}
if provider.config.DisabledAttribute != "" { loginFilter := strings.Replace(provider.config.LoginFilter, "{{login_identifier}}", username, -1)
attrs = append(attrs, provider.config.DisabledAttribute)
}
searchRequest := ldap.NewSearchRequest( searchRequest := ldap.NewSearchRequest(
provider.config.BaseDN, provider.config.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&(objectClass=%s)(%s=%s))", provider.config.UserClass, provider.config.EmailAttribute, username), loginFilter,
attrs, attrs,
nil, nil,
) )
@@ -89,24 +86,8 @@ func (provider Provider) Login(ctx *authentication.AuthContext) (string, error)
return "", errors.Errorf("invalid amount of ldap entries (%d)", len(sr.Entries)) return "", errors.Errorf("invalid amount of ldap entries (%d)", len(sr.Entries))
} }
userDN := sr.Entries[0].DN
// Check if user is disabled, if so deny login
if provider.config.DisabledAttribute != "" {
uac := sr.Entries[0].GetAttributeValue(provider.config.DisabledAttribute)
switch provider.config.Type {
case ldapconfig.TypeActiveDirectory:
if ldapconfig.IsActiveDirectoryUserDisabled(uac) {
return "", errors.New("user is disabled")
}
case ldapconfig.TypeOpenLDAP:
if ldapconfig.IsOpenLdapUserDisabled(uac) {
return "", errors.New("user is disabled")
}
}
}
// Bind as the user to verify their password // Bind as the user to verify their password
userDN := sr.Entries[0].DN
err = client.Bind(userDN, password) err = client.Bind(userDN, password)
if err != nil { if err != nil {
return "", errors.Wrapf(err, "invalid credentials") return "", errors.Wrapf(err, "invalid credentials")
@@ -136,13 +117,11 @@ func (provider Provider) GetUserModel(ctx *authentication.AuthContext) (*authent
// Search for the given username // Search for the given username
attrs := []string{"dn", provider.config.EmailAttribute, provider.config.FirstNameAttribute, provider.config.LastNameAttribute, attrs := []string{"dn", provider.config.EmailAttribute, provider.config.FirstNameAttribute, provider.config.LastNameAttribute,
provider.config.PhoneAttribute, provider.config.GroupMemberAttribute} provider.config.PhoneAttribute, provider.config.GroupMemberAttribute}
if provider.config.DisabledAttribute != "" { loginFilter := strings.Replace(provider.config.LoginFilter, "{{login_identifier}}", username, -1)
attrs = append(attrs, provider.config.DisabledAttribute)
}
searchRequest := ldap.NewSearchRequest( searchRequest := ldap.NewSearchRequest(
provider.config.BaseDN, provider.config.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&(objectClass=%s)(%s=%s))", provider.config.UserClass, provider.config.EmailAttribute, username), loginFilter,
attrs, attrs,
nil, nil,
) )
@@ -175,14 +154,15 @@ func (provider Provider) GetUserModel(ctx *authentication.AuthContext) (*authent
} }
func (provider Provider) open() (*ldap.Conn, error) { func (provider Provider) open() (*ldap.Conn, error) {
conn, err := ldap.DialURL(provider.config.URL) tlsConfig := &tls.Config{InsecureSkipVerify: !provider.config.CertValidation}
conn, err := ldap.DialURL(provider.config.URL, ldap.DialWithTLSConfig(tlsConfig))
if err != nil { if err != nil {
return nil, err return nil, err
} }
if provider.config.StartTLS { if provider.config.StartTLS {
// Reconnect with TLS // Reconnect with TLS
err = conn.StartTLS(&tls.Config{InsecureSkipVerify: true}) err = conn.StartTLS(tlsConfig)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/authentication" "github.com/h44z/wg-portal/internal/authentication"
"github.com/h44z/wg-portal/internal/common"
"github.com/h44z/wg-portal/internal/users" "github.com/h44z/wg-portal/internal/users"
"github.com/pkg/errors" "github.com/pkg/errors"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@@ -22,11 +23,11 @@ type Provider struct {
db *gorm.DB db *gorm.DB
} }
func New(cfg *users.Config) (*Provider, error) { func New(cfg *common.DatabaseConfig) (*Provider, error) {
p := &Provider{} p := &Provider{}
var err error var err error
p.db, err = users.GetDatabaseForConfig(cfg) p.db, err = common.GetDatabaseForConfig(cfg)
if err != nil { if err != nil {
return nil, errors.Wrapf(err, "failed to setup authentication database %s", cfg.Database) return nil, errors.Wrapf(err, "failed to setup authentication database %s", cfg.Database)
} }
@@ -107,6 +108,7 @@ func (provider Provider) GetUserModel(ctx *authentication.AuthContext) (*authent
} }
func (provider Provider) InitializeAdmin(email, password string) error { func (provider Provider) InitializeAdmin(email, password string) error {
email = strings.ToLower(email)
if !emailRegex.MatchString(email) { if !emailRegex.MatchString(email) {
return errors.New("admin username must be an email address") return errors.New("admin username must be an email address")
} }
@@ -134,7 +136,7 @@ func (provider Provider) InitializeAdmin(email, password string) error {
} }
admin.Email = email admin.Email = email
admin.Password = string(hashedPassword) admin.Password = users.PrivateString(hashedPassword)
admin.Firstname = "WireGuard" admin.Firstname = "WireGuard"
admin.Lastname = "Administrator" admin.Lastname = "Administrator"
admin.CreatedAt = time.Now() admin.CreatedAt = time.Now()
@@ -168,7 +170,7 @@ func (provider Provider) InitializeAdmin(email, password string) error {
return errors.Wrap(err, "failed to hash admin password") return errors.Wrap(err, "failed to hash admin password")
} }
admin.Password = string(hashedPassword) admin.Password = users.PrivateString(hashedPassword)
admin.IsAdmin = true admin.IsAdmin = true
admin.UpdatedAt = time.Now() admin.UpdatedAt = time.Now()

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

@@ -0,0 +1,153 @@
package common
import (
"fmt"
"os"
"path/filepath"
"sort"
"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
},
})
}
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,38 @@ package common
import ( import (
"crypto/tls" "crypto/tls"
"io" "io"
"net/smtp" "io/ioutil"
"strconv" "time"
"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.
CertValidation bool `yaml:"certcheck" envconfig:"EMAIL_CERT_VALIDATION"` Encryption MailEncryption `yaml:"encryption" envconfig:"EMAIL_ENCRYPTION"`
Username string `yaml:"user" envconfig:"EMAIL_USERNAME"` CertValidation bool `yaml:"certcheck" envconfig:"EMAIL_CERT_VALIDATION"`
Password string `yaml:"pass" envconfig:"EMAIL_PASSWORD"` Username string `yaml:"user" envconfig:"EMAIL_USERNAME"`
Password string `yaml:"pass" envconfig:"EMAIL_PASSWORD"`
AuthType MailAuthType `yaml:"auth" envconfig:"EMAIL_AUTHTYPE"`
} }
type MailAttachment struct { type MailAttachment struct {
@@ -27,53 +45,73 @@ type MailAttachment struct {
} }
// SendEmailWithAttachments sends a mail with optional 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 := ioutil.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

@@ -60,6 +60,16 @@ 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

View File

@@ -8,20 +8,20 @@ const (
) )
type Config struct { type Config struct {
URL string `yaml:"url" envconfig:"LDAP_URL"` URL string `yaml:"url" envconfig:"LDAP_URL"`
StartTLS bool `yaml:"startTLS" envconfig:"LDAP_STARTTLS"` StartTLS bool `yaml:"startTLS" envconfig:"LDAP_STARTTLS"`
BaseDN string `yaml:"dn" envconfig:"LDAP_BASEDN"` CertValidation bool `yaml:"certcheck" envconfig:"LDAP_CERT_VALIDATION"`
BindUser string `yaml:"user" envconfig:"LDAP_USER"` BaseDN string `yaml:"dn" envconfig:"LDAP_BASEDN"`
BindPass string `yaml:"pass" envconfig:"LDAP_PASSWORD"` BindUser string `yaml:"user" envconfig:"LDAP_USER"`
BindPass string `yaml:"pass" envconfig:"LDAP_PASSWORD"`
Type Type `yaml:"typ" envconfig:"LDAP_TYPE"` // AD for active directory, OpenLDAP for OpenLDAP
UserClass string `yaml:"userClass" envconfig:"LDAP_USER_CLASS"`
EmailAttribute string `yaml:"attrEmail" envconfig:"LDAP_ATTR_EMAIL"` EmailAttribute string `yaml:"attrEmail" envconfig:"LDAP_ATTR_EMAIL"`
FirstNameAttribute string `yaml:"attrFirstname" envconfig:"LDAP_ATTR_FIRSTNAME"` FirstNameAttribute string `yaml:"attrFirstname" envconfig:"LDAP_ATTR_FIRSTNAME"`
LastNameAttribute string `yaml:"attrLastname" envconfig:"LDAP_ATTR_LASTNAME"` LastNameAttribute string `yaml:"attrLastname" envconfig:"LDAP_ATTR_LASTNAME"`
PhoneAttribute string `yaml:"attrPhone" envconfig:"LDAP_ATTR_PHONE"` PhoneAttribute string `yaml:"attrPhone" envconfig:"LDAP_ATTR_PHONE"`
GroupMemberAttribute string `yaml:"attrGroups" envconfig:"LDAP_ATTR_GROUPS"` GroupMemberAttribute string `yaml:"attrGroups" envconfig:"LDAP_ATTR_GROUPS"`
DisabledAttribute string `yaml:"attrDisabled" envconfig:"LDAP_ATTR_DISABLED"`
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"`
AdminLdapGroup string `yaml:"adminGroup" envconfig:"LDAP_ADMIN_GROUP"` // Members of this group receive admin rights in WG-Portal AdminLdapGroup string `yaml:"adminGroup" envconfig:"LDAP_ADMIN_GROUP"` // Members of this group receive admin rights in WG-Portal
} }

View File

@@ -2,8 +2,6 @@ package ldap
import ( import (
"crypto/tls" "crypto/tls"
"fmt"
"strconv"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -16,14 +14,15 @@ type RawLdapData struct {
} }
func Open(cfg *Config) (*ldap.Conn, error) { func Open(cfg *Config) (*ldap.Conn, error) {
conn, err := ldap.DialURL(cfg.URL) tlsConfig := &tls.Config{InsecureSkipVerify: !cfg.CertValidation}
conn, err := ldap.DialURL(cfg.URL, ldap.DialWithTLSConfig(tlsConfig))
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to connect to LDAP") return nil, errors.Wrap(err, "failed to connect to LDAP")
} }
if cfg.StartTLS { if cfg.StartTLS {
// Reconnect with TLS // Reconnect with TLS
err = conn.StartTLS(&tls.Config{InsecureSkipVerify: true}) err = conn.StartTLS(tlsConfig)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to star TLS on connection") return nil, errors.Wrap(err, "failed to star TLS on connection")
} }
@@ -53,13 +52,10 @@ func FindAllUsers(cfg *Config) ([]RawLdapData, error) {
// Search all users // Search all users
attrs := []string{"dn", cfg.EmailAttribute, cfg.EmailAttribute, cfg.FirstNameAttribute, cfg.LastNameAttribute, attrs := []string{"dn", cfg.EmailAttribute, cfg.EmailAttribute, cfg.FirstNameAttribute, cfg.LastNameAttribute,
cfg.PhoneAttribute, cfg.GroupMemberAttribute} cfg.PhoneAttribute, cfg.GroupMemberAttribute}
if cfg.DisabledAttribute != "" {
attrs = append(attrs, cfg.DisabledAttribute)
}
searchRequest := ldap.NewSearchRequest( searchRequest := ldap.NewSearchRequest(
cfg.BaseDN, cfg.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(objectClass=%s)", cfg.UserClass), attrs, nil, cfg.SyncFilter, attrs, nil,
) )
sr, err := client.Search(searchRequest) sr, err := client.Search(searchRequest)
@@ -86,27 +82,3 @@ func FindAllUsers(cfg *Config) ([]RawLdapData, error) {
return tmpData, nil return tmpData, nil
} }
func IsActiveDirectoryUserDisabled(userAccountControl string) bool {
if userAccountControl == "" {
return false
}
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
}
func IsOpenLdapUserDisabled(pwdAccountLockedTime string) bool {
if pwdAccountLockedTime != "" {
return true
}
return false
}

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

@@ -0,0 +1,926 @@
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
// @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
// @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
// @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.BindJSON(&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
// @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.BindJSON(&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
// @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
// @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
// @Produce json
// @Param device 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("device")))
if deviceName == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "device 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
// @Produce json
// @Param pkey 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("pkey")
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
}
c.JSON(http.StatusOK, peer)
}
// PostPeer godoc
// @Tags Peers
// @Summary Creates a new peer based on the given peer model
// @Accept json
// @Produce json
// @Param device 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("device")))
if deviceName == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "device 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.BindJSON(&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
// @Accept json
// @Produce json
// @Param pkey 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.BindJSON(&updatePeer); err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}
pkey := c.Query("pkey")
if pkey == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "pkey 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: "pkey parameter must match the model public key"})
return
}
now := time.Now()
if updatePeer.DeactivatedAt != nil {
updatePeer.DeactivatedAt = &now
}
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
// @Accept json
// @Produce json
// @Param pkey 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("pkey")
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: "pkey parameter must match the model public key"})
return
}
now := time.Now()
if mergedPeer.DeactivatedAt != nil {
mergedPeer.DeactivatedAt = &now
}
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
// @Produce json
// @Param pkey query string true "Public Key"
// @Success 202 "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("pkey")
if pkey == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "pkey 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
// @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
// @Produce json
// @Param device 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("device")))
if deviceName == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "device 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)
// @Accept json
// @Produce json
// @Param device query string true "Device Name"
// @Param body 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.BindJSON(&updateDevice); err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}
deviceName := strings.ToLower(strings.TrimSpace(c.Query("device")))
if deviceName == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "device 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: "device 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)
// @Accept json
// @Produce json
// @Param device query string true "Device Name"
// @Param body 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("device")))
if deviceName == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "device 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: "device 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
// @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
// @Produce plain
// @Param pkey 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("pkey")
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
}
// 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
// @Accept json
// @Produce plain
// @Param body 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.BindJSON(&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)
}

View File

@@ -1,12 +1,12 @@
package common package server
import ( import (
"os" "os"
"reflect" "reflect"
"runtime" "runtime"
"github.com/h44z/wg-portal/internal/common"
"github.com/h44z/wg-portal/internal/ldap" "github.com/h44z/wg-portal/internal/ldap"
"github.com/h44z/wg-portal/internal/users"
"github.com/h44z/wg-portal/internal/wireguard" "github.com/h44z/wg-portal/internal/wireguard"
"github.com/kelseyhightower/envconfig" "github.com/kelseyhightower/envconfig"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -55,21 +55,23 @@ func loadConfigEnv(cfg interface{}) error {
type Config struct { type Config struct {
Core struct { Core struct {
ListeningAddress string `yaml:"listeningAddress" envconfig:"LISTENING_ADDRESS"` ListeningAddress string `yaml:"listeningAddress" envconfig:"LISTENING_ADDRESS"`
ExternalUrl string `yaml:"externalUrl" envconfig:"EXTERNAL_URL"` ExternalUrl string `yaml:"externalUrl" envconfig:"EXTERNAL_URL"`
Title string `yaml:"title" envconfig:"WEBSITE_TITLE"` Title string `yaml:"title" envconfig:"WEBSITE_TITLE"`
CompanyName string `yaml:"company" envconfig:"COMPANY_NAME"` CompanyName string `yaml:"company" envconfig:"COMPANY_NAME"`
MailFrom string `yaml:"mailFrom" envconfig:"MAIL_FROM"` MailFrom string `yaml:"mailFrom" envconfig:"MAIL_FROM"`
AdminUser string `yaml:"adminUser" envconfig:"ADMIN_USER"` // must be an email address AdminUser string `yaml:"adminUser" envconfig:"ADMIN_USER"` // must be an email address
AdminPassword string `yaml:"adminPass" envconfig:"ADMIN_PASS"` AdminPassword string `yaml:"adminPass" envconfig:"ADMIN_PASS"`
EditableKeys bool `yaml:"editableKeys" envconfig:"EDITABLE_KEYS"` EditableKeys bool `yaml:"editableKeys" envconfig:"EDITABLE_KEYS"`
CreateDefaultPeer bool `yaml:"createDefaultPeer" envconfig:"CREATE_DEFAULT_PEER"` CreateDefaultPeer bool `yaml:"createDefaultPeer" envconfig:"CREATE_DEFAULT_PEER"`
LdapEnabled bool `yaml:"ldapEnabled" envconfig:"LDAP_ENABLED"` SelfProvisioningAllowed bool `yaml:"selfProvisioning" envconfig:"SELF_PROVISIONING"`
LdapEnabled bool `yaml:"ldapEnabled" envconfig:"LDAP_ENABLED"`
SessionSecret string `yaml:"sessionSecret" envconfig:"SESSION_SECRET"`
} `yaml:"core"` } `yaml:"core"`
Database users.Config `yaml:"database"` Database common.DatabaseConfig `yaml:"database"`
Email MailConfig `yaml:"email"` Email common.MailConfig `yaml:"email"`
LDAP ldap.Config `yaml:"ldap"` LDAP ldap.Config `yaml:"ldap"`
WG wireguard.Config `yaml:"wg"` WG wireguard.Config `yaml:"wg"`
} }
func NewConfig() *Config { func NewConfig() *Config {
@@ -84,6 +86,8 @@ func NewConfig() *Config {
cfg.Core.AdminUser = "admin@wgportal.local" cfg.Core.AdminUser = "admin@wgportal.local"
cfg.Core.AdminPassword = "wgportal" cfg.Core.AdminPassword = "wgportal"
cfg.Core.LdapEnabled = false cfg.Core.LdapEnabled = false
cfg.Core.EditableKeys = true
cfg.Core.SessionSecret = "secret"
cfg.Database.Typ = "sqlite" cfg.Database.Typ = "sqlite"
cfg.Database.Database = "data/wg_portal.db" cfg.Database.Database = "data/wg_portal.db"
@@ -93,21 +97,23 @@ func NewConfig() *Config {
cfg.LDAP.StartTLS = true cfg.LDAP.StartTLS = true
cfg.LDAP.BindUser = "company\\\\ldap_wireguard" cfg.LDAP.BindUser = "company\\\\ldap_wireguard"
cfg.LDAP.BindPass = "SuperSecret" cfg.LDAP.BindPass = "SuperSecret"
cfg.LDAP.Type = "AD"
cfg.LDAP.UserClass = "organizationalPerson"
cfg.LDAP.EmailAttribute = "mail" cfg.LDAP.EmailAttribute = "mail"
cfg.LDAP.FirstNameAttribute = "givenName" cfg.LDAP.FirstNameAttribute = "givenName"
cfg.LDAP.LastNameAttribute = "sn" cfg.LDAP.LastNameAttribute = "sn"
cfg.LDAP.PhoneAttribute = "telephoneNumber" cfg.LDAP.PhoneAttribute = "telephoneNumber"
cfg.LDAP.GroupMemberAttribute = "memberOf" cfg.LDAP.GroupMemberAttribute = "memberOf"
cfg.LDAP.DisabledAttribute = "userAccountControl"
cfg.LDAP.AdminLdapGroup = "CN=WireGuardAdmins,OU=_O_IT,DC=COMPANY,DC=LOCAL" 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.WG.DeviceName = "wg0" cfg.WG.DeviceNames = []string{"wg0"}
cfg.WG.WireGuardConfig = "/etc/wireguard/wg0.conf" cfg.WG.DefaultDeviceName = "wg0"
cfg.WG.ConfigDirectoryPath = "/etc/wireguard"
cfg.WG.ManageIPAddresses = true cfg.WG.ManageIPAddresses = true
cfg.Email.Host = "127.0.0.1" cfg.Email.Host = "127.0.0.1"
cfg.Email.Port = 25 cfg.Email.Port = 25
cfg.Email.Encryption = common.MailEncryptionNone
cfg.Email.AuthType = common.MailAuthPlain
// Load config from file and environment // Load config from file and environment
cfgFile, ok := os.LookupEnv("CONFIG_FILE") cfgFile, ok := os.LookupEnv("CONFIG_FILE")

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

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,13 @@ import (
"net/http" "net/http"
"strings" "strings"
"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/authentication"
"github.com/h44z/wg-portal/internal/users" "github.com/h44z/wg-portal/internal/users"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
csrf "github.com/utrack/gin-csrf"
) )
func (s *Server) GetLogin(c *gin.Context) { func (s *Server) GetLogin(c *gin.Context) {
@@ -31,6 +34,7 @@ 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),
}) })
} }
@@ -51,65 +55,15 @@ func (s *Server) PostLogin(c *gin.Context) {
return return
} }
// Check user database for an matching entry // Check all available auth backends
var loginProvider authentication.AuthProvider user, err := s.checkAuthentication(username, password)
email := "" if err != nil {
user := s.users.GetUser(username) // retrieve active candidate user from db s.GetHandleError(c, http.StatusInternalServerError, "login error", err.Error())
if user != nil { // existing user return
loginProvider = s.auth.GetProvider(string(user.Source))
if loginProvider == nil {
s.GetHandleError(c, http.StatusInternalServerError, "login error", "login provider unavailable")
return
}
authEmail, err := loginProvider.Login(&authentication.AuthContext{
Username: username,
Password: password,
})
if err == nil {
email = authEmail
}
} else { // possible new 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
}
email = authEmail
loginProvider = provider
// create new user in the database (or reactivate him)
userData, err := loginProvider.GetUserModel(&authentication.AuthContext{
Username: email,
})
if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "login error", err.Error())
return
}
if err := s.CreateUser(users.User{
Email: userData.Email,
Source: users.UserSource(loginProvider.GetName()),
IsAdmin: userData.IsAdmin,
Firstname: userData.Firstname,
Lastname: userData.Lastname,
Phone: userData.Phone,
}); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "login error", "failed to update user data")
return
}
user = s.users.GetUser(username)
break
}
} }
// Check if user is authenticated // Check if user is authenticated
if email == "" || loginProvider == nil || user == nil { if user == nil {
c.Redirect(http.StatusSeeOther, "/auth/login?err=authfail") c.Redirect(http.StatusSeeOther, "/auth/login?err=authfail")
return return
} }
@@ -121,9 +75,10 @@ func (s *Server) PostLogin(c *gin.Context) {
sessionData.Email = user.Email sessionData.Email = user.Email
sessionData.Firstname = user.Firstname sessionData.Firstname = user.Firstname
sessionData.Lastname = user.Lastname sessionData.Lastname = user.Lastname
sessionData.DeviceName = s.wg.Cfg.DeviceNames[0]
// 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 err := s.CreateUserDefaultPeer(user.Email); err != nil { if err := s.CreateUserDefaultPeer(user.Email, s.wg.Cfg.GetDefaultDeviceName()); err != nil {
// Not a fatal error, just log it... // Not a fatal error, just log it...
logrus.Errorf("failed to automatically create vpn peer for %s: %v", sessionData.Email, err) logrus.Errorf("failed to automatically create vpn peer for %s: %v", sessionData.Email, err)
} }
@@ -149,3 +104,48 @@ func (s *Server) GetLogout(c *gin.Context) {
} }
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

@@ -4,37 +4,39 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/pkg/errors"
"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),
"Message": message, "Message": message,
"Details": details, "Details": details,
}, },
"Route": c.Request.URL.Path, "Route": c.Request.URL.Path,
"Session": 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: GetFlashes(c), "DeviceNames": s.GetDeviceNames(),
Session: GetSessionData(c),
Static: s.getStaticData(),
Device: s.peers.GetDevice(),
}) })
} }
@@ -74,25 +76,35 @@ func (s *Server) GetAdminIndex(c *gin.Context) {
return return
} }
device := s.peers.GetDevice() deviceName := c.Query("device")
users := s.peers.GetFilteredAndSortedPeers(currentSession.SortedBy["peers"], currentSession.SortDirection["peers"], currentSession.Search["peers"]) 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 []Peer return
TotalPeers int }
Device Device
}{ device := s.peers.GetDevice(currentSession.DeviceName)
Route: c.Request.URL.Path, users := s.peers.GetFilteredAndSortedPeers(currentSession.DeviceName, currentSession.SortedBy["peers"], currentSession.SortDirection["peers"], currentSession.Search["peers"])
Alerts: GetFlashes(c),
Session: currentSession, c.HTML(http.StatusOK, "admin_index.html", gin.H{
Static: s.getStaticData(), "Route": c.Request.URL.Path,
Peers: users, "Alerts": GetFlashes(c),
TotalPeers: len(s.peers.GetAllPeers()), "Session": currentSession,
Device: device, "Static": s.getStaticData(),
"Peers": users,
"TotalPeers": len(s.peers.GetAllPeers(currentSession.DeviceName)),
"Users": s.users.GetUsers(),
"Device": device,
"DeviceNames": s.GetDeviceNames(),
}) })
} }
@@ -120,25 +132,18 @@ func (s *Server) GetUserIndex(c *gin.Context) {
return return
} }
device := s.peers.GetDevice() peers := s.peers.GetSortedPeersForEmail(currentSession.SortedBy["userpeers"], currentSession.SortDirection["userpeers"], currentSession.Email)
users := s.peers.GetSortedPeersForEmail(currentSession.SortedBy["userpeers"], currentSession.SortDirection["userpeers"], 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 []Peer "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: GetFlashes(c),
Session: currentSession,
Static: s.getStaticData(),
Peers: users,
TotalPeers: len(users),
Device: device,
}) })
} }
@@ -155,10 +160,11 @@ func (s *Server) updateFormInSession(c *gin.Context, formData interface{}) error
func (s *Server) setNewPeerFormInSession(c *gin.Context) (SessionData, error) { func (s *Server) setNewPeerFormInSession(c *gin.Context) (SessionData, error) {
currentSession := GetSessionData(c) currentSession := GetSessionData(c)
// If session does not contain a peer 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.PrepareNewPeer() user, err := s.PrepareNewPeer(currentSession.DeviceName)
if err != nil { if err != nil {
return currentSession, errors.WithMessage(err, "failed to prepare new peer") return currentSession, errors.WithMessage(err, "failed to prepare new peer")
} }

View File

@@ -1,47 +1,42 @@
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.peers.GetDevice() currentSession := GetSessionData(c)
users := s.peers.GetAllPeers() 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 []Peer "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: 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 := 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)
@@ -50,12 +45,20 @@ func (s *Server) PostAdminEditInterface(c *gin.Context) {
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.GetConfig()) err := s.wg.UpdateDevice(formDevice.DeviceName, formDevice.GetConfig())
@@ -76,7 +79,7 @@ func (s *Server) PostAdminEditInterface(c *gin.Context) {
} }
// 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)
SetFlashMessage(c, "Failed to update WireGuard config-file: "+err.Error(), "danger") SetFlashMessage(c, "Failed to update WireGuard config-file: "+err.Error(), "danger")
@@ -86,12 +89,12 @@ func (s *Server) PostAdminEditInterface(c *gin.Context) {
// 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)
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)
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")
@@ -106,9 +109,10 @@ func (s *Server) PostAdminEditInterface(c *gin.Context) {
} }
func (s *Server) GetInterfaceConfig(c *gin.Context) { func (s *Server) GetInterfaceConfig(c *gin.Context) {
device := s.peers.GetDevice() currentSession := GetSessionData(c)
users := s.peers.GetActivePeers() device := s.peers.GetDevice(currentSession.DeviceName)
cfg, err := device.GetConfigFile(users) peers := s.peers.GetActivePeers(device.DeviceName)
cfg, err := device.GetConfigFile(peers)
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) GetApplyGlobalConfig(c *gin.Context) { func (s *Server) GetSaveConfig(c *gin.Context) {
device := s.peers.GetDevice() currentSession := GetSessionData(c)
users := s.peers.GetAllPeers()
for _, user := range users { err := s.WriteWireGuardConfigFile(currentSession.DeviceName)
user.AllowedIPs = device.AllowedIPs if err != nil {
user.AllowedIPsStr = device.AllowedIPsStr SetFlashMessage(c, "Failed to save WireGuard config-file: "+err.Error(), "danger")
if err := s.peers.UpdatePeer(user); err != nil { c.Redirect(http.StatusSeeOther, "/admin/")
SetFlashMessage(c, err.Error(), "danger") return
c.Redirect(http.StatusSeeOther, "/admin/device/edit")
}
} }
SetFlashMessage(c, "Allowed IP's updated for all clients.", "success") SetFlashMessage(c, "Updated WireGuard config-file", "success")
c.Redirect(http.StatusSeeOther, "/admin/")
return
}
func (s *Server) GetApplyGlobalConfig(c *gin.Context) {
currentSession := GetSessionData(c)
device := s.peers.GetDevice(currentSession.DeviceName)
peers := s.peers.GetAllPeers(device.DeviceName)
if device.Type == wireguard.DeviceTypeClient {
SetFlashMessage(c, "Cannot apply global configuration while interface is in client mode.", "danger")
c.Redirect(http.StatusSeeOther, "/admin/device/edit")
return
}
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

@@ -11,8 +11,10 @@ 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/users" "github.com/h44z/wg-portal/internal/users"
"github.com/h44z/wg-portal/internal/wireguard"
"github.com/sirupsen/logrus" "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,7 +23,6 @@ type LdapCreateForm struct {
} }
func (s *Server) GetAdminEditPeer(c *gin.Context) { func (s *Server) GetAdminEditPeer(c *gin.Context) {
device := s.peers.GetDevice()
peer := s.peers.GetPeerByKey(c.Query("pkey")) peer := s.peers.GetPeerByKey(c.Query("pkey"))
currentSession, err := s.setFormInSession(c, peer) currentSession, err := s.setFormInSession(c, peer)
@@ -30,22 +31,17 @@ func (s *Server) GetAdminEditPeer(c *gin.Context) {
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 Peer "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: GetFlashes(c), "Csrf": csrf.GetToken(c),
Session: currentSession,
Static: s.getStaticData(),
Peer: currentSession.FormData.(Peer),
Device: device,
EditableKeys: s.config.Core.EditableKeys,
}) })
} }
@@ -54,9 +50,9 @@ func (s *Server) PostAdminEditPeer(c *gin.Context) {
urlEncodedKey := url.QueryEscape(c.Query("pkey")) urlEncodedKey := url.QueryEscape(c.Query("pkey"))
currentSession := GetSessionData(c) currentSession := GetSessionData(c)
var formPeer Peer var formPeer wireguard.Peer
if currentSession.FormData != nil { if currentSession.FormData != nil {
formPeer = currentSession.FormData.(Peer) formPeer = currentSession.FormData.(wireguard.Peer)
} }
if err := c.ShouldBind(&formPeer); err != nil { if err := c.ShouldBind(&formPeer); err != nil {
_ = s.updateFormInSession(c, formPeer) _ = s.updateFormInSession(c, formPeer)
@@ -66,10 +62,8 @@ func (s *Server) PostAdminEditPeer(c *gin.Context) {
} }
// Clean list input // Clean list input
formPeer.IPs = common.ParseStringList(formPeer.IPsStr) formPeer.IPsStr = common.ListToString(common.ParseStringList(formPeer.IPsStr))
formPeer.AllowedIPs = common.ParseStringList(formPeer.AllowedIPsStr) formPeer.AllowedIPsStr = common.ListToString(common.ParseStringList(formPeer.AllowedIPsStr))
formPeer.IPsStr = common.ListToString(formPeer.IPs)
formPeer.AllowedIPsStr = common.ListToString(formPeer.AllowedIPs)
disabled := c.PostForm("isdisabled") != "" disabled := c.PostForm("isdisabled") != ""
now := time.Now() now := time.Now()
@@ -92,37 +86,30 @@ func (s *Server) PostAdminEditPeer(c *gin.Context) {
} }
func (s *Server) GetAdminCreatePeer(c *gin.Context) { func (s *Server) GetAdminCreatePeer(c *gin.Context) {
device := s.peers.GetDevice()
currentSession, err := s.setNewPeerFormInSession(c) currentSession, err := s.setNewPeerFormInSession(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 Peer "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: GetFlashes(c), "Csrf": csrf.GetToken(c),
Session: currentSession,
Static: s.getStaticData(),
Peer: currentSession.FormData.(Peer),
Device: device,
EditableKeys: s.config.Core.EditableKeys,
}) })
} }
func (s *Server) PostAdminCreatePeer(c *gin.Context) { func (s *Server) PostAdminCreatePeer(c *gin.Context) {
currentSession := GetSessionData(c) currentSession := GetSessionData(c)
var formPeer Peer var formPeer wireguard.Peer
if currentSession.FormData != nil { if currentSession.FormData != nil {
formPeer = currentSession.FormData.(Peer) formPeer = currentSession.FormData.(wireguard.Peer)
} }
if err := c.ShouldBind(&formPeer); err != nil { if err := c.ShouldBind(&formPeer); err != nil {
_ = s.updateFormInSession(c, formPeer) _ = s.updateFormInSession(c, formPeer)
@@ -132,10 +119,8 @@ func (s *Server) PostAdminCreatePeer(c *gin.Context) {
} }
// Clean list input // Clean list input
formPeer.IPs = common.ParseStringList(formPeer.IPsStr) formPeer.IPsStr = common.ListToString(common.ParseStringList(formPeer.IPsStr))
formPeer.AllowedIPs = common.ParseStringList(formPeer.AllowedIPsStr) formPeer.AllowedIPsStr = common.ListToString(common.ParseStringList(formPeer.AllowedIPsStr))
formPeer.IPsStr = common.ListToString(formPeer.IPs)
formPeer.AllowedIPsStr = common.ListToString(formPeer.AllowedIPs)
disabled := c.PostForm("isdisabled") != "" disabled := c.PostForm("isdisabled") != ""
now := time.Now() now := time.Now()
@@ -143,7 +128,7 @@ func (s *Server) PostAdminCreatePeer(c *gin.Context) {
formPeer.DeactivatedAt = &now formPeer.DeactivatedAt = &now
} }
if err := s.CreatePeer(formPeer); err != nil { if err := s.CreatePeer(currentSession.DeviceName, formPeer); err != nil {
_ = s.updateFormInSession(c, formPeer) _ = s.updateFormInSession(c, formPeer)
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")
@@ -161,22 +146,16 @@ 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 []users.User "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: GetFlashes(c),
Session: currentSession,
Static: s.getStaticData(),
Users: s.users.GetFilteredAndSortedUsers("lastname", "asc", ""),
FormData: currentSession.FormData.(LdapCreateForm),
Device: s.peers.GetDevice(),
}) })
} }
@@ -196,7 +175,7 @@ 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.users.GetUser(emails[i]) == nil { if !strings.ContainsRune(emails[i], '@') {
_ = s.updateFormInSession(c, formData) _ = s.updateFormInSession(c, formData)
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")
@@ -207,7 +186,7 @@ func (s *Server) PostAdminCreateLdapPeers(c *gin.Context) {
logrus.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.CreatePeerByEmail(emails[i], formData.Identifier, false); err != nil { if err := s.CreatePeerByEmail(currentSession.DeviceName, emails[i], formData.Identifier, false); err != nil {
_ = s.updateFormInSession(c, formData) _ = s.updateFormInSession(c, formData)
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")
@@ -220,24 +199,24 @@ func (s *Server) PostAdminCreateLdapPeers(c *gin.Context) {
} }
func (s *Server) GetAdminDeletePeer(c *gin.Context) { func (s *Server) GetAdminDeletePeer(c *gin.Context) {
currentUser := s.peers.GetPeerByKey(c.Query("pkey")) currentPeer := s.peers.GetPeerByKey(c.Query("pkey"))
if err := s.DeletePeer(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
} }
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.peers.GetPeerByKey(c.Query("pkey")) peer := s.peers.GetPeerByKey(c.Query("pkey"))
currentSession := 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,51 +226,56 @@ func (s *Server) GetPeerQRCode(c *gin.Context) {
} }
func (s *Server) GetPeerConfig(c *gin.Context) { func (s *Server) GetPeerConfig(c *gin.Context) {
user := s.peers.GetPeerByKey(c.Query("pkey")) peer := s.peers.GetPeerByKey(c.Query("pkey"))
currentSession := 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.GetConfigFile(s.peers.GetDevice()) cfg, err := peer.GetConfigFile(s.peers.GetDevice(currentSession.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.peers.GetPeerByKey(c.Query("pkey")) peer := s.peers.GetPeerByKey(c.Query("pkey"))
currentSession := 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.GetConfigFile(s.peers.GetDevice()) user := s.users.GetUser(peer.Email)
cfg, err := peer.GetConfigFile(s.peers.GetDevice(currentSession.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
} }
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
} }
// Apply mail template // Apply mail template
qrcodeFileName := "wireguard-qrcode.png"
var tplBuff bytes.Buffer var tplBuff bytes.Buffer
if err := s.mailTpl.Execute(&tplBuff, struct { if err := s.mailTpl.Execute(&tplBuff, struct {
Client Peer Peer wireguard.Peer
User *users.User
QrcodePngName string QrcodePngName string
PortalUrl string PortalUrl string
}{ }{
Client: user, Peer: peer,
QrcodePngName: "wireguard-config.png", User: user,
QrcodePngName: qrcodeFileName,
PortalUrl: s.config.Core.ExternalUrl, PortalUrl: s.config.Core.ExternalUrl,
}); err != nil { }); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "Template error", err.Error()) s.GetHandleError(c, http.StatusInternalServerError, "Template error", err.Error())
@@ -301,12 +285,18 @@ func (s *Server) GetPeerConfigMail(c *gin.Context) {
// Send mail // Send mail
attachments := []common.MailAttachment{ attachments := []common.MailAttachment{
{ {
Name: user.GetConfigFileName(), Name: peer.GetConfigFileName(),
ContentType: "application/config", ContentType: "application/config",
Data: bytes.NewReader(cfg), Data: bytes.NewReader(cfg),
}, },
{ {
Name: "wireguard-config.png", Name: qrcodeFileName,
ContentType: "image/png",
Data: bytes.NewReader(png),
Embedded: true,
},
{
Name: qrcodeFileName,
ContentType: "image/png", ContentType: "image/png",
Data: bytes.NewReader(png), Data: bytes.NewReader(png),
}, },
@@ -314,24 +304,28 @@ func (s *Server) GetPeerConfigMail(c *gin.Context) {
if err := common.SendEmailWithAttachments(s.config.Email, s.config.Core.MailFrom, "", "WireGuard VPN Configuration", 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(), "Your mail client does not support HTML. Please find the configuration attached to this mail.", tplBuff.String(),
[]string{user.Email}, attachments); err != nil { []string{peer.Email}, attachments); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "Email error", err.Error()) s.GetHandleError(c, http.StatusInternalServerError, "Email error", err.Error())
return return
} }
SetFlashMessage(c, "mail sent successfully", "success") SetFlashMessage(c, "mail sent successfully", "success")
c.Redirect(http.StatusSeeOther, "/admin") if strings.HasPrefix(c.Request.URL.Path, "/user") {
c.Redirect(http.StatusSeeOther, "/user/profile")
} else {
c.Redirect(http.StatusSeeOther, "/admin")
}
} }
func (s *Server) GetPeerStatus(c *gin.Context) { func (s *Server) GetPeerStatus(c *gin.Context) {
user := s.peers.GetPeerByKey(c.Query("pkey")) peer := s.peers.GetPeerByKey(c.Query("pkey"))
currentSession := 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 +333,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()) {

View File

@@ -7,7 +7,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/users" "github.com/h44z/wg-portal/internal/users"
"golang.org/x/crypto/bcrypt" csrf "github.com/utrack/gin-csrf"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -49,22 +49,15 @@ func (s *Server) GetAdminUsersIndex(c *gin.Context) {
dbUsers := s.users.GetFilteredAndSortedUsersUnscoped(currentSession.SortedBy["users"], currentSession.SortDirection["users"], currentSession.Search["users"]) dbUsers := s.users.GetFilteredAndSortedUsersUnscoped(currentSession.SortedBy["users"], currentSession.SortDirection["users"], currentSession.Search["users"])
c.HTML(http.StatusOK, "admin_user_index.html", struct { c.HTML(http.StatusOK, "admin_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(),
Users []users.User "Users": dbUsers,
TotalUsers int "TotalUsers": len(s.users.GetUsers()),
Device Device "Device": s.peers.GetDevice(currentSession.DeviceName),
}{ "DeviceNames": s.GetDeviceNames(),
Route: c.Request.URL.Path,
Alerts: GetFlashes(c),
Session: currentSession,
Static: s.getStaticData(),
Users: dbUsers,
TotalUsers: len(s.users.GetUsers()),
Device: s.peers.GetDevice(),
}) })
} }
@@ -77,21 +70,16 @@ func (s *Server) GetAdminUsersEdit(c *gin.Context) {
return return
} }
c.HTML(http.StatusOK, "admin_edit_user.html", struct { c.HTML(http.StatusOK, "admin_edit_user.html", gin.H{
Route string "Route": c.Request.URL.Path,
Alerts []FlashData "Alerts": GetFlashes(c),
Session SessionData "Session": currentSession,
Static StaticData "Static": s.getStaticData(),
User users.User "User": currentSession.FormData.(users.User),
Device Device "Device": s.peers.GetDevice(currentSession.DeviceName),
Epoch time.Time "DeviceNames": s.GetDeviceNames(),
}{ "Epoch": time.Time{},
Route: c.Request.URL.Path, "Csrf": csrf.GetToken(c),
Alerts: GetFlashes(c),
Session: currentSession,
Static: s.getStaticData(),
User: currentSession.FormData.(users.User),
Device: s.peers.GetDevice(),
}) })
} }
@@ -116,19 +104,6 @@ func (s *Server) PostAdminUsersEdit(c *gin.Context) {
return return
} }
if formUser.Password != "" {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(formUser.Password), bcrypt.DefaultCost)
if err != nil {
_ = s.updateFormInSession(c, formUser)
SetFlashMessage(c, "failed to hash admin password", "danger")
c.Redirect(http.StatusSeeOther, "/admin/users/edit?pkey="+urlEncodedKey+"&formerr=bind")
return
}
formUser.Password = string(hashedPassword)
} else {
formUser.Password = currentUser.Password
}
disabled := c.PostForm("isdisabled") != "" disabled := c.PostForm("isdisabled") != ""
if disabled { if disabled {
formUser.DeletedAt = gorm.DeletedAt{ formUser.DeletedAt = gorm.DeletedAt{
@@ -160,21 +135,16 @@ func (s *Server) GetAdminUsersCreate(c *gin.Context) {
return return
} }
c.HTML(http.StatusOK, "admin_edit_user.html", struct { c.HTML(http.StatusOK, "admin_edit_user.html", gin.H{
Route string "Route": c.Request.URL.Path,
Alerts []FlashData "Alerts": GetFlashes(c),
Session SessionData "Session": currentSession,
Static StaticData "Static": s.getStaticData(),
User users.User "User": currentSession.FormData.(users.User),
Device Device "Device": s.peers.GetDevice(currentSession.DeviceName),
Epoch time.Time "DeviceNames": s.GetDeviceNames(),
}{ "Epoch": time.Time{},
Route: c.Request.URL.Path, "Csrf": csrf.GetToken(c),
Alerts: GetFlashes(c),
Session: currentSession,
Static: s.getStaticData(),
User: currentSession.FormData.(users.User),
Device: s.peers.GetDevice(),
}) })
} }
@@ -191,15 +161,7 @@ func (s *Server) PostAdminUsersCreate(c *gin.Context) {
return return
} }
if formUser.Password != "" { if formUser.Password == "" {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(formUser.Password), bcrypt.DefaultCost)
if err != nil {
SetFlashMessage(c, "failed to hash admin password", "danger")
c.Redirect(http.StatusSeeOther, "/admin/users/create?formerr=bind")
return
}
formUser.Password = string(hashedPassword)
} else {
_ = s.updateFormInSession(c, formUser) _ = s.updateFormInSession(c, formUser)
SetFlashMessage(c, "invalid password", "danger") SetFlashMessage(c, "invalid password", "danger")
c.Redirect(http.StatusSeeOther, "/admin/users/create?formerr=create") c.Redirect(http.StatusSeeOther, "/admin/users/create?formerr=create")
@@ -218,7 +180,7 @@ func (s *Server) PostAdminUsersCreate(c *gin.Context) {
formUser.IsAdmin = c.PostForm("isadmin") == "true" formUser.IsAdmin = c.PostForm("isadmin") == "true"
formUser.Source = users.UserSourceDatabase formUser.Source = users.UserSourceDatabase
if err := s.CreateUser(formUser); err != nil { if err := s.CreateUser(formUser, currentSession.DeviceName); err != nil {
_ = s.updateFormInSession(c, formUser) _ = s.updateFormInSession(c, formUser)
SetFlashMessage(c, "failed to add user: "+err.Error(), "danger") SetFlashMessage(c, "failed to add user: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/users/create?formerr=create") c.Redirect(http.StatusSeeOther, "/admin/users/create?formerr=create")

View File

@@ -1,6 +1,7 @@
package server package server
import ( import (
"strings"
"time" "time"
"github.com/h44z/wg-portal/internal/ldap" "github.com/h44z/wg-portal/internal/ldap"
@@ -31,107 +32,33 @@ func (s *Server) SyncLdapWithUserDatabase() {
continue continue
} }
for i := range ldapUsers { // Update existing LDAP users
// prefilter s.updateLdapUsers(ldapUsers)
if ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute] == "" ||
ldapUsers[i].Attributes[s.config.LDAP.FirstNameAttribute] == "" ||
ldapUsers[i].Attributes[s.config.LDAP.LastNameAttribute] == "" {
continue
}
user, err := s.users.GetOrCreateUserUnscoped(ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute]) // Disable missing LDAP users
if err != nil { s.disableMissingLdapUsers(ldapUsers)
logrus.Errorf("failed to get/create user %s in database: %v", ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute], err)
}
// check if user should be deactivated
ldapDeactivated := false
switch s.config.LDAP.Type {
case ldap.TypeActiveDirectory:
ldapDeactivated = ldap.IsActiveDirectoryUserDisabled(ldapUsers[i].Attributes[s.config.LDAP.DisabledAttribute])
case ldap.TypeOpenLDAP:
ldapDeactivated = ldap.IsOpenLdapUserDisabled(ldapUsers[i].Attributes[s.config.LDAP.DisabledAttribute])
}
// check if user has been disabled in ldap, update peers accordingly
if ldapDeactivated != user.DeletedAt.Valid {
if ldapDeactivated {
// disable all peers for the given user
for _, peer := range s.peers.GetPeersByMail(user.Email) {
now := time.Now()
peer.DeactivatedAt = &now
if err = s.UpdatePeer(peer, now); err != nil {
logrus.Errorf("failed to update deactivated peer %s: %v", peer.PublicKey, err)
}
}
} else {
// enable all peers for the given user
for _, peer := range s.peers.GetPeersByMail(user.Email) {
now := time.Now()
peer.DeactivatedAt = nil
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]) {
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 = false
user.Source = users.UserSourceLdap
user.DeletedAt = gorm.DeletedAt{} // Not deleted
for _, group := range ldapUsers[i].RawAttributes[s.config.LDAP.GroupMemberAttribute] {
if string(group) == s.config.LDAP.AdminLdapGroup {
user.IsAdmin = true
break
}
}
if err = s.users.UpdateUser(user); err != nil {
logrus.Errorf("failed to update ldap user %s in database: %v", user.Email, err)
continue
}
if ldapDeactivated {
if err = s.users.DeleteUser(user); err != nil {
logrus.Errorf("failed to delete deactivated user %s in database: %v", user.Email, err)
continue
}
}
}
}
} }
logrus.Info("ldap user synchronization stopped") logrus.Info("ldap user synchronization stopped")
} }
func (s Server) UserChangedInLdap(user *users.User, ldapData *ldap.RawLdapData) bool { func (s Server) userChangedInLdap(user *users.User, ldapData *ldap.RawLdapData) bool {
if user.Firstname != ldapData.Attributes[s.config.LDAP.FirstNameAttribute] { if user.Firstname != ldapData.Attributes[s.config.LDAP.FirstNameAttribute] {
return true return true
} }
if user.Lastname != ldapData.Attributes[s.config.LDAP.LastNameAttribute] { if user.Lastname != ldapData.Attributes[s.config.LDAP.LastNameAttribute] {
return true return true
} }
if user.Email != ldapData.Attributes[s.config.LDAP.EmailAttribute] { if user.Email != strings.ToLower(ldapData.Attributes[s.config.LDAP.EmailAttribute]) {
return true return true
} }
if user.Phone != ldapData.Attributes[s.config.LDAP.PhoneAttribute] { if user.Phone != ldapData.Attributes[s.config.LDAP.PhoneAttribute] {
return true return true
} }
if user.Source != users.UserSourceLdap {
ldapDeactivated := false return true
switch s.config.LDAP.Type {
case ldap.TypeActiveDirectory:
ldapDeactivated = ldap.IsActiveDirectoryUserDisabled(ldapData.Attributes[s.config.LDAP.DisabledAttribute])
case ldap.TypeOpenLDAP:
ldapDeactivated = ldap.IsOpenLdapUserDisabled(ldapData.Attributes[s.config.LDAP.DisabledAttribute])
} }
if ldapDeactivated != user.DeletedAt.Valid {
if user.DeletedAt.Valid {
return true return true
} }
@@ -148,3 +75,88 @@ func (s Server) UserChangedInLdap(user *users.User, ldapData *ldap.RawLdapData)
return false 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
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]); err != nil {
logrus.Errorf("failed to delete deactivated user %s in database: %v", activeUsers[i].Email, err)
}
}
}
func (s *Server) updateLdapUsers(ldapUsers []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
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]) {
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 = false
user.Source = users.UserSourceLdap
user.DeletedAt = gorm.DeletedAt{} // Not deleted
for _, group := range ldapUsers[i].RawAttributes[s.config.LDAP.GroupMemberAttribute] {
if string(group) == s.config.LDAP.AdminLdapGroup {
user.IsAdmin = true
break
}
}
if err = s.users.UpdateUser(user); err != nil {
logrus.Errorf("failed to update ldap user %s in database: %v", user.Email, err)
continue
}
}
}
}

View File

@@ -1,724 +0,0 @@
package server
import (
"bytes"
"crypto/md5"
"fmt"
"net"
"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/common"
"github.com/h44z/wg-portal/internal/users"
"github.com/h44z/wg-portal/internal/wireguard"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/skip2/go-qrcode"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"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)
}
}
//
// PEER ----------------------------------------------------------------------------------------
//
type Peer struct {
Peer *wgtypes.Peer `gorm:"-"` // WireGuard peer
User *users.User `gorm:"-"` // user reference for the peer
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 (p Peer) GetConfig() wgtypes.PeerConfig {
publicKey, _ := wgtypes.ParseKey(p.PublicKey)
var presharedKey *wgtypes.Key
if p.PresharedKey != "" {
presharedKeyTmp, _ := wgtypes.ParseKey(p.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(p.IPs)),
}
for i, ip := range p.IPs {
_, ipNet, err := net.ParseCIDR(ip)
if err == nil {
cfg.AllowedIPs[i] = *ipNet
}
}
return cfg
}
func (p Peer) GetConfigFile(device Device) ([]byte, error) {
tpl, err := template.New("client").Funcs(template.FuncMap{"StringsJoin": strings.Join}).Parse(wireguard.ClientCfgTpl)
if err != nil {
return nil, errors.Wrap(err, "failed to parse client template")
}
var tplBuff bytes.Buffer
err = tpl.Execute(&tplBuff, struct {
Client Peer
Server Device
}{
Client: p,
Server: 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 {
logrus.WithFields(logrus.Fields{
"err": err,
}).Error("failed to create qrcode")
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) ToMap() map[string]string {
out := make(map[string]string)
v := reflect.ValueOf(p)
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 (p Peer) GetConfigFileName() string {
reg := regexp.MustCompile("[^a-zA-Z0-9_-]+")
return reg.ReplaceAllString(strings.ReplaceAll(p.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) GetConfig() 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) GetConfigFile(peers []Peer) ([]byte, error) {
tpl, err := template.New("server").Funcs(template.FuncMap{"StringsJoin": strings.Join}).Parse(wireguard.DeviceCfgTpl)
if err != nil {
return nil, errors.Wrap(err, "failed to parse server template")
}
var tplBuff bytes.Buffer
err = tpl.Execute(&tplBuff, struct {
Clients []Peer
Server Device
}{
Clients: peers,
Server: d,
})
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 *wireguard.Manager
users *users.Manager
}
func NewPeerManager(cfg *common.Config, wg *wireguard.Manager, userDB *users.Manager) (*PeerManager, error) {
um := &PeerManager{wg: wg, users: userDB}
var err error
um.db, err = users.GetDatabaseForConfig(&cfg.Database)
if err != nil {
return nil, errors.WithMessage(err, "failed to open peer database")
}
err = um.db.AutoMigrate(&Peer{}, &Device{})
if err != nil {
return nil, errors.WithMessage(err, "failed to migrate peer database")
}
return um, nil
}
func (u *PeerManager) InitFromCurrentInterface() error {
peers, err := u.wg.GetPeerList()
if err != nil {
return errors.Wrapf(err, "failed to get peer list")
}
device, err := u.wg.GetDeviceInfo()
if err != nil {
return errors.Wrapf(err, "failed to get device info")
}
var ipAddresses []string
var mtu int
if u.wg.Cfg.ManageIPAddresses {
if ipAddresses, err = u.wg.GetIPAddress(); err != nil {
return errors.Wrapf(err, "failed to get ip address")
}
if mtu, err = u.wg.GetMTU(); err != nil {
return errors.Wrapf(err, "failed to get MTU")
}
}
// Check if entries already exist in database, if not create them
for _, peer := range peers {
if err := u.validateOrCreatePeer(peer); err != nil {
return errors.WithMessagef(err, "failed to validate peer %s", peer.PublicKey)
}
}
if err := u.validateOrCreateDevice(*device, ipAddresses, mtu); err != nil {
return errors.WithMessagef(err, "failed to validate device %s", device.Name)
}
return nil
}
func (u *PeerManager) validateOrCreatePeer(wgPeer wgtypes.Peer) error {
peer := Peer{}
u.db.Where("public_key = ?", wgPeer.PublicKey.String()).FirstOrInit(&peer)
if peer.PublicKey == "" { // peer not found, create
peer.UID = fmt.Sprintf("u%x", md5.Sum([]byte(wgPeer.PublicKey.String())))
peer.PublicKey = wgPeer.PublicKey.String()
peer.PrivateKey = "" // UNKNOWN
if wgPeer.PresharedKey != (wgtypes.Key{}) {
peer.PresharedKey = wgPeer.PresharedKey.String()
}
peer.Email = "autodetected@example.com"
peer.Identifier = "Autodetected (" + peer.PublicKey[0:8] + ")"
peer.UpdatedAt = time.Now()
peer.CreatedAt = time.Now()
peer.AllowedIPs = make([]string, 0) // UNKNOWN
peer.IPs = make([]string, len(wgPeer.AllowedIPs))
for i, ip := range wgPeer.AllowedIPs {
peer.IPs[i] = ip.String()
}
peer.AllowedIPsStr = strings.Join(peer.AllowedIPs, ", ")
peer.IPsStr = strings.Join(peer.IPs, ", ")
res := u.db.Create(&peer)
if res.Error != nil {
return errors.Wrapf(res.Error, "failed to create autodetected peer %s", peer.PublicKey)
}
}
return nil
}
func (u *PeerManager) 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.DefaultMTU {
mtu = 0
}
device.Mtu = mtu
res := u.db.Create(&device)
if res.Error != nil {
return errors.Wrapf(res.Error, "failed to create autodetected device")
}
}
return nil
}
func (u *PeerManager) populatePeerData(peer *Peer) {
peer.AllowedIPs = strings.Split(peer.AllowedIPsStr, ", ")
peer.IPs = strings.Split(peer.IPsStr, ", ")
// Set config file
tmpCfg, _ := peer.GetConfigFile(u.GetDevice())
peer.Config = string(tmpCfg)
// set data from WireGuard interface
peer.Peer, _ = u.wg.GetPeer(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 := int(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
// set user data
peer.User = u.users.GetUser(peer.Email)
}
func (u *PeerManager) 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 *PeerManager) GetAllPeers() []Peer {
peers := make([]Peer, 0)
u.db.Find(&peers)
for i := range peers {
u.populatePeerData(&peers[i])
}
return peers
}
func (u *PeerManager) GetActivePeers() []Peer {
peers := make([]Peer, 0)
u.db.Where("deactivated_at IS NULL").Find(&peers)
for i := range peers {
u.populatePeerData(&peers[i])
}
return peers
}
func (u *PeerManager) GetFilteredAndSortedPeers(sortKey, sortDirection, search string) []Peer {
peers := make([]Peer, 0)
u.db.Find(&peers)
filteredPeers := make([]Peer, 0, len(peers))
for i := range peers {
u.populatePeerData(&peers[i])
if search == "" ||
strings.Contains(peers[i].Email, search) ||
strings.Contains(peers[i].Identifier, search) ||
strings.Contains(peers[i].PublicKey, search) {
filteredPeers = append(filteredPeers, peers[i])
}
}
sort.Slice(filteredPeers, func(i, j int) bool {
var sortValueLeft string
var sortValueRight string
switch sortKey {
case "id":
sortValueLeft = filteredPeers[i].Identifier
sortValueRight = filteredPeers[j].Identifier
case "pubKey":
sortValueLeft = filteredPeers[i].PublicKey
sortValueRight = filteredPeers[j].PublicKey
case "mail":
sortValueLeft = filteredPeers[i].Email
sortValueRight = filteredPeers[j].Email
case "ip":
sortValueLeft = filteredPeers[i].IPsStr
sortValueRight = filteredPeers[j].IPsStr
case "handshake":
if filteredPeers[i].Peer == nil {
return false
} else if filteredPeers[j].Peer == nil {
return true
}
sortValueLeft = filteredPeers[i].Peer.LastHandshakeTime.Format(time.RFC3339)
sortValueRight = filteredPeers[j].Peer.LastHandshakeTime.Format(time.RFC3339)
}
if sortDirection == "asc" {
return sortValueLeft < sortValueRight
} else {
return sortValueLeft > sortValueRight
}
})
return filteredPeers
}
func (u *PeerManager) GetSortedPeersForEmail(sortKey, sortDirection, email string) []Peer {
peers := make([]Peer, 0)
u.db.Where("email = ?", email).Find(&peers)
for i := range peers {
u.populatePeerData(&peers[i])
}
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 "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
}
})
return peers
}
func (u *PeerManager) 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 *PeerManager) GetPeerByKey(publicKey string) Peer {
peer := Peer{}
u.db.Where("public_key = ?", publicKey).FirstOrInit(&peer)
u.populatePeerData(&peer)
return peer
}
func (u *PeerManager) GetPeersByMail(mail string) []Peer {
var peers []Peer
u.db.Where("email = ?", mail).Find(&peers)
for i := range peers {
u.populatePeerData(&peers[i])
}
return peers
}
func (u *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.AllowedIPsStr = strings.Join(peer.AllowedIPs, ", ")
peer.IPsStr = strings.Join(peer.IPs, ", ")
res := u.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 (u *PeerManager) UpdatePeer(peer Peer) error {
peer.UpdatedAt = time.Now()
peer.AllowedIPsStr = strings.Join(peer.AllowedIPs, ", ")
peer.IPsStr = strings.Join(peer.IPs, ", ")
res := u.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 (u *PeerManager) DeletePeer(peer Peer) error {
res := u.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 (u *PeerManager) 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 {
logrus.Errorf("failed to update device: %v", res.Error)
return errors.Wrap(res.Error, "failed to update device")
}
return nil
}
func (u *PeerManager) GetAllReservedIps() ([]string, error) {
reservedIps := make([]string, 0)
peers := u.GetAllPeers()
for _, user := range peers {
for _, cidr := range user.IPs {
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())
}
}
device := u.GetDevice()
for _, cidr := range device.IPs {
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 (u *PeerManager) 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 *PeerManager) GetAvailableIp(cidr string) (string, error) {
reserved, err := u.GetAllReservedIps()
if err != nil {
return "", errors.WithMessage(err, "failed to get all reserved IP addresses")
}
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

@@ -2,16 +2,29 @@ package server
import ( import (
"net/http" "net/http"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
wg_portal "github.com/h44z/wg-portal" 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.
ginSwagger "github.com/swaggo/gin-swagger"
"github.com/swaggo/gin-swagger/swaggerFiles"
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) { s.server.GET("/favicon.ico", func(c *gin.Context) {
file, _ := wg_portal.Statics.ReadFile("assets/img/favicon.ico") file, _ := wgportal.Statics.ReadFile("assets/img/favicon.ico")
c.Data( c.Data(
http.StatusOK, http.StatusOK,
"image/x-icon", "image/x-icon",
@@ -21,17 +34,20 @@ func SetupRoutes(s *Server) {
// 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(csrfMiddleware)
admin.Use(s.RequireAuthentication("admin")) 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)
@@ -51,6 +67,7 @@ func SetupRoutes(s *Server) {
// 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)
@@ -59,6 +76,44 @@ func SetupRoutes(s *Server) {
user.GET("/status", s.GetPeerStatus) user.GET("/status", s.GetPeerStatus)
} }
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 := GetSessionData(c) session := GetSessionData(c)
@@ -77,7 +132,7 @@ func (s *Server) RequireAuthentication(scope string) gin.HandlerFunc {
return return
} }
// default case if some randome scope was set... // default case if some random scope was set...
if scope != "" && !session.IsAdmin { if scope != "" && !session.IsAdmin {
// Abort the request with the appropriate error code // Abort the request with the appropriate error code
c.Abort() c.Abort()
@@ -89,3 +144,55 @@ func (s *Server) RequireAuthentication(scope string) gin.HandlerFunc {
c.Next() 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
c.Next()
}
}

View File

@@ -11,12 +11,13 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/memstore" "github.com/gin-contrib/sessions/memstore"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
wg_portal "github.com/h44z/wg-portal" wgportal "github.com/h44z/wg-portal"
ldapprovider "github.com/h44z/wg-portal/internal/authentication/providers/ldap" ldapprovider "github.com/h44z/wg-portal/internal/authentication/providers/ldap"
passwordprovider "github.com/h44z/wg-portal/internal/authentication/providers/password" passwordprovider "github.com/h44z/wg-portal/internal/authentication/providers/password"
"github.com/h44z/wg-portal/internal/common" "github.com/h44z/wg-portal/internal/common"
@@ -25,6 +26,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
ginlogrus "github.com/toorop/gin-logrus" ginlogrus "github.com/toorop/gin-logrus"
"gorm.io/gorm"
) )
const SessionIdentifier = "wgPortalSession" const SessionIdentifier = "wgPortalSession"
@@ -32,18 +34,19 @@ const SessionIdentifier = "wgPortalSession"
func init() { func init() {
gob.Register(SessionData{}) gob.Register(SessionData{})
gob.Register(FlashData{}) gob.Register(FlashData{})
gob.Register(Peer{}) gob.Register(wireguard.Peer{})
gob.Register(Device{}) gob.Register(wireguard.Device{})
gob.Register(LdapCreateForm{}) gob.Register(LdapCreateForm{})
gob.Register(users.User{}) gob.Register(users.User{})
} }
type SessionData struct { type SessionData struct {
LoggedIn bool LoggedIn bool
IsAdmin bool IsAdmin bool
Firstname string Firstname string
Lastname string Lastname string
Email string Email string
DeviceName string
SortedBy map[string]string SortedBy map[string]string
SortDirection map[string]string SortDirection map[string]string
@@ -65,18 +68,20 @@ type StaticData struct {
WebsiteLogo string WebsiteLogo string
CompanyName string CompanyName string
Year int Year int
Version string
} }
type Server struct { type Server struct {
ctx context.Context ctx context.Context
config *common.Config config *Config
server *gin.Engine server *gin.Engine
mailTpl *template.Template mailTpl *template.Template
auth *AuthManager auth *AuthManager
db *gorm.DB
users *users.Manager users *users.Manager
wg *wireguard.Manager wg *wireguard.Manager
peers *PeerManager peers *wireguard.PeerManager
} }
func (s *Server) Setup(ctx context.Context) error { func (s *Server) Setup(ctx context.Context) error {
@@ -90,9 +95,19 @@ func (s *Server) Setup(ctx context.Context) error {
// Init rand // Init rand
rand.Seed(time.Now().UnixNano()) rand.Seed(time.Now().UnixNano())
s.config = common.NewConfig() s.config = NewConfig()
s.ctx = ctx 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 // Setup http server
gin.SetMode(gin.DebugMode) gin.SetMode(gin.DebugMode)
gin.DefaultWriter = ioutil.Discard gin.DefaultWriter = ioutil.Discard
@@ -101,27 +116,37 @@ func (s *Server) Setup(ctx context.Context) error {
s.server.Use(ginlogrus.Logger(logrus.StandardLogger())) s.server.Use(ginlogrus.Logger(logrus.StandardLogger()))
} }
s.server.Use(gin.Recovery()) s.server.Use(gin.Recovery())
s.server.Use(sessions.Sessions("authsession", memstore.NewStore([]byte(s.config.Core.SessionSecret))))
s.server.SetFuncMap(template.FuncMap{ s.server.SetFuncMap(template.FuncMap{
"formatBytes": common.ByteCountSI, "formatBytes": common.ByteCountSI,
"urlEncode": url.QueryEscape, "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 // Setup templates
templates := template.Must(template.New("").Funcs(s.server.FuncMap).ParseFS(wg_portal.Templates, "assets/tpl/*.html")) templates := template.Must(template.New("").Funcs(s.server.FuncMap).ParseFS(wgportal.Templates, "assets/tpl/*.html"))
s.server.SetHTMLTemplate(templates) s.server.SetHTMLTemplate(templates)
s.server.Use(sessions.Sessions("authsession", memstore.NewStore([]byte("secret")))) // TODO: change key?
// Serve static files // Serve static files
s.server.StaticFS("/css", http.FS(fsMust(fs.Sub(wg_portal.Statics, "assets/css")))) s.server.StaticFS("/css", http.FS(fsMust(fs.Sub(wgportal.Statics, "assets/css"))))
s.server.StaticFS("/js", http.FS(fsMust(fs.Sub(wg_portal.Statics, "assets/js")))) s.server.StaticFS("/js", http.FS(fsMust(fs.Sub(wgportal.Statics, "assets/js"))))
s.server.StaticFS("/img", http.FS(fsMust(fs.Sub(wg_portal.Statics, "assets/img")))) s.server.StaticFS("/img", http.FS(fsMust(fs.Sub(wgportal.Statics, "assets/img"))))
s.server.StaticFS("/fonts", http.FS(fsMust(fs.Sub(wg_portal.Statics, "assets/fonts")))) s.server.StaticFS("/fonts", http.FS(fsMust(fs.Sub(wgportal.Statics, "assets/fonts"))))
// Setup all routes // Setup all routes
SetupRoutes(s) SetupRoutes(s)
SetupApiRoutes(s)
// Setup user database (also needed for database authentication) // Setup user database (also needed for database authentication)
s.users, err = users.NewManager(&s.config.Database) s.users, err = users.NewManager(s.db)
if err != nil { if err != nil {
return errors.WithMessage(err, "user-manager initialization failed") return errors.WithMessage(err, "user-manager initialization failed")
} }
@@ -153,18 +178,18 @@ func (s *Server) Setup(ctx context.Context) error {
} }
// Setup peer manager // Setup peer manager
if s.peers, err = NewPeerManager(s.config, s.wg, s.users); err != nil { if s.peers, err = wireguard.NewPeerManager(s.db, s.wg); err != nil {
return errors.WithMessage(err, "unable to setup peer manager") return errors.WithMessage(err, "unable to setup peer manager")
} }
if err = s.peers.InitFromCurrentInterface(); err != nil {
return errors.WithMessage(err, "unable to initialize peer manager") for _, deviceName := range s.wg.Cfg.DeviceNames {
} if err = s.RestoreWireGuardInterface(deviceName); err != nil {
if err = s.RestoreWireGuardInterface(); err != nil { return errors.WithMessagef(err, "unable to restore WireGuard state for %s", deviceName)
return errors.WithMessage(err, "unable to restore WireGuard state") }
} }
// Setup mail template // Setup mail template
s.mailTpl, err = template.New("email.html").ParseFS(wg_portal.Templates, "assets/tpl/email.html") s.mailTpl, err = template.New("email.html").ParseFS(wgportal.Templates, "assets/tpl/email.html")
if err != nil { if err != nil {
return errors.Wrap(err, "unable to pare mail template") return errors.Wrap(err, "unable to pare mail template")
} }
@@ -174,6 +199,8 @@ func (s *Server) Setup(ctx context.Context) error {
} }
func (s *Server) Run() { func (s *Server) Run() {
logrus.Infof("starting web service on %s", s.config.Core.ListeningAddress)
// Start ldap sync // Start ldap sync
if s.config.Core.LdapEnabled { if s.config.Core.LdapEnabled {
go s.SyncLdapWithUserDatabase() go s.SyncLdapWithUserDatabase()
@@ -220,6 +247,7 @@ func (s *Server) getStaticData() StaticData {
WebsiteLogo: "/img/header-logo.png", WebsiteLogo: "/img/header-logo.png",
CompanyName: s.config.Core.CompanyName, CompanyName: s.config.Core.CompanyName,
Year: time.Now().Year(), Year: time.Now().Year(),
Version: Version,
} }
} }
@@ -233,11 +261,12 @@ func GetSessionData(c *gin.Context) SessionData {
} else { } else {
sessionData = SessionData{ sessionData = SessionData{
Search: map[string]string{"peers": "", "userpeers": "", "users": ""}, Search: map[string]string{"peers": "", "userpeers": "", "users": ""},
SortedBy: map[string]string{"peers": "mail", "userpeers": "mail", "users": "email"}, SortedBy: map[string]string{"peers": "handshake", "userpeers": "id", "users": "email"},
SortDirection: map[string]string{"peers": "asc", "userpeers": "asc", "users": "asc"}, SortDirection: map[string]string{"peers": "desc", "userpeers": "asc", "users": "asc"},
Email: "", Email: "",
Firstname: "", Firstname: "",
Lastname: "", Lastname: "",
DeviceName: "",
IsAdmin: false, IsAdmin: false,
LoggedIn: false, LoggedIn: false,
} }

View File

@@ -4,104 +4,106 @@ import (
"crypto/md5" "crypto/md5"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"path"
"syscall" "syscall"
"time" "time"
"github.com/h44z/wg-portal/internal/common"
"github.com/h44z/wg-portal/internal/users" "github.com/h44z/wg-portal/internal/users"
"github.com/h44z/wg-portal/internal/wireguard"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes" "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"gorm.io/gorm" "gorm.io/gorm"
) )
func (s *Server) PrepareNewPeer() (Peer, error) { // PrepareNewPeer initiates a new peer for the given WireGuard device.
device := s.peers.GetDevice() func (s *Server) PrepareNewPeer(device string) (wireguard.Peer, error) {
dev := s.peers.GetDevice(device)
deviceIPs := dev.GetIPAddresses()
peer := Peer{} peer := wireguard.Peer{}
peer.IsNew = true peer.IsNew = true
peer.AllowedIPsStr = device.AllowedIPsStr
peer.IPs = make([]string, len(device.IPs)) switch dev.Type {
for i := range device.IPs { case wireguard.DeviceTypeServer:
freeIP, err := s.peers.GetAvailableIp(device.IPs[i]) peerIPs := make([]string, len(deviceIPs))
if err != nil { for i := range deviceIPs {
return Peer{}, errors.WithMessage(err, "failed to get available IP addresses") 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.IPs[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"
} }
peer.IPsStr = common.ListToString(peer.IPs)
psk, err := wgtypes.GenerateKey()
if err != nil {
return Peer{}, errors.Wrap(err, "failed to generate key")
}
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
return 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)))
return peer, nil return peer, nil
} }
func (s *Server) CreatePeerByEmail(email, identifierSuffix string, disabled bool) error { // CreatePeerByEmail creates a new peer for the given email.
user, err := s.users.GetOrCreateUser(email) func (s *Server) CreatePeerByEmail(device, email, identifierSuffix string, disabled bool) error {
if err != nil { user := s.users.GetUser(email)
return errors.WithMessagef(err, "failed to load/create related user %s", email)
}
device := s.peers.GetDevice() peer, err := s.PrepareNewPeer(device)
peer := Peer{}
peer.User = user
peer.AllowedIPsStr = device.AllowedIPsStr
peer.IPs = make([]string, len(device.IPs))
for i := range device.IPs {
freeIP, err := s.peers.GetAvailableIp(device.IPs[i])
if err != nil {
return errors.WithMessage(err, "failed to get available IP addresses")
}
peer.IPs[i] = freeIP
}
peer.IPsStr = common.ListToString(peer.IPs)
psk, err := wgtypes.GenerateKey()
if err != nil { if err != nil {
return errors.Wrap(err, "failed to generate key") return errors.WithMessage(err, "failed to prepare new peer")
} }
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
return 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.Email = email peer.Email = email
peer.Identifier = fmt.Sprintf("%s %s (%s)", user.Firstname, user.Lastname, identifierSuffix) if user != nil {
peer.Identifier = fmt.Sprintf("%s %s (%s)", user.Firstname, user.Lastname, identifierSuffix)
} else {
peer.Identifier = fmt.Sprintf("%s (%s)", email, identifierSuffix)
}
now := time.Now() now := time.Now()
if disabled { if disabled {
peer.DeactivatedAt = &now peer.DeactivatedAt = &now
} }
return s.CreatePeer(peer) return s.CreatePeer(device, peer)
} }
func (s *Server) CreatePeer(peer Peer) error { // CreatePeer creates the new peer in the database. If the peer has no assigned ip addresses, a new one will be assigned
device := s.peers.GetDevice() // automatically. Also, if the private key is empty, a new key-pair will be generated.
peer.AllowedIPsStr = device.AllowedIPsStr // This function also configures the new peer on the physical WireGuard interface if the peer is not deactivated.
if peer.IPs == nil || len(peer.IPs) == 0 { func (s *Server) CreatePeer(device string, peer wireguard.Peer) error {
peer.IPs = make([]string, len(device.IPs)) dev := s.peers.GetDevice(device)
for i := range device.IPs { deviceIPs := dev.GetIPAddresses()
freeIP, err := s.peers.GetAvailableIp(device.IPs[i]) 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 { if err != nil {
return errors.WithMessage(err, "failed to get available IP addresses") return errors.WithMessage(err, "failed to get available IP addresses")
} }
peer.IPs[i] = freeIP peerIPs[i] = freeIP
} }
peer.IPsStr = common.ListToString(peer.IPs) peer.SetIPAddresses(peerIPs...)
} }
if peer.PrivateKey == "" { // if private key is empty create a new one if peer.PrivateKey == "" && dev.Type == wireguard.DeviceTypeServer { // if private key is empty create a new one
psk, err := wgtypes.GenerateKey() psk, err := wgtypes.GenerateKey()
if err != nil { if err != nil {
return errors.Wrap(err, "failed to generate key") return errors.Wrap(err, "failed to generate key")
@@ -114,11 +116,12 @@ func (s *Server) CreatePeer(peer Peer) error {
peer.PrivateKey = key.String() peer.PrivateKey = key.String()
peer.PublicKey = key.PublicKey().String() peer.PublicKey = key.PublicKey().String()
} }
peer.DeviceName = dev.DeviceName
peer.UID = fmt.Sprintf("u%x", md5.Sum([]byte(peer.PublicKey))) peer.UID = fmt.Sprintf("u%x", md5.Sum([]byte(peer.PublicKey)))
// Create WireGuard interface // Create WireGuard interface
if peer.DeactivatedAt == nil { if peer.DeactivatedAt == nil {
if err := s.wg.AddPeer(peer.GetConfig()); err != nil { if err := s.wg.AddPeer(device, peer.GetConfig(&dev)); err != nil {
return errors.WithMessage(err, "failed to add WireGuard peer") return errors.WithMessage(err, "failed to add WireGuard peer")
} }
} }
@@ -128,37 +131,42 @@ func (s *Server) CreatePeer(peer Peer) error {
return errors.WithMessage(err, "failed to create peer") return errors.WithMessage(err, "failed to create peer")
} }
return s.WriteWireGuardConfigFile() return s.WriteWireGuardConfigFile(device)
} }
func (s *Server) UpdatePeer(peer Peer, updateTime time.Time) error { // 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) currentPeer := s.peers.GetPeerByKey(peer.PublicKey)
dev := s.peers.GetDevice(peer.DeviceName)
// Update WireGuard device // Update WireGuard device
var err error var err error
switch { switch {
case peer.DeactivatedAt == &updateTime: case peer.DeactivatedAt != nil && *peer.DeactivatedAt == updateTime:
err = s.wg.RemovePeer(peer.PublicKey) err = s.wg.RemovePeer(peer.DeviceName, peer.PublicKey)
case peer.DeactivatedAt == nil && currentPeer.Peer != nil: case peer.DeactivatedAt == nil && currentPeer.Peer != nil:
err = s.wg.UpdatePeer(peer.GetConfig()) err = s.wg.UpdatePeer(peer.DeviceName, peer.GetConfig(&dev))
case peer.DeactivatedAt == nil && currentPeer.Peer == nil: case peer.DeactivatedAt == nil && currentPeer.Peer == nil:
err = s.wg.AddPeer(peer.GetConfig()) err = s.wg.AddPeer(peer.DeviceName, peer.GetConfig(&dev))
} }
if err != nil { if err != nil {
return errors.WithMessage(err, "failed to update WireGuard peer") return errors.WithMessage(err, "failed to update WireGuard peer")
} }
peer.UID = fmt.Sprintf("u%x", md5.Sum([]byte(peer.PublicKey)))
// Update in database // Update in database
if err := s.peers.UpdatePeer(peer); err != nil { if err := s.peers.UpdatePeer(peer); err != nil {
return errors.WithMessage(err, "failed to update peer") return errors.WithMessage(err, "failed to update peer")
} }
return s.WriteWireGuardConfigFile() return s.WriteWireGuardConfigFile(peer.DeviceName)
} }
func (s *Server) DeletePeer(peer Peer) error { // DeletePeer removes the peer from the physical WireGuard interface and the database.
func (s *Server) DeletePeer(peer wireguard.Peer) error {
// Delete WireGuard peer // Delete WireGuard peer
if err := s.wg.RemovePeer(peer.PublicKey); err != nil { if err := s.wg.RemovePeer(peer.DeviceName, peer.PublicKey); err != nil {
return errors.WithMessage(err, "failed to remove WireGuard peer") return errors.WithMessage(err, "failed to remove WireGuard peer")
} }
@@ -167,15 +175,17 @@ func (s *Server) DeletePeer(peer Peer) error {
return errors.WithMessage(err, "failed to remove peer") return errors.WithMessage(err, "failed to remove peer")
} }
return s.WriteWireGuardConfigFile() return s.WriteWireGuardConfigFile(peer.DeviceName)
} }
func (s *Server) RestoreWireGuardInterface() error { // RestoreWireGuardInterface restores the state of the physical WireGuard interface from the database.
activePeers := s.peers.GetActivePeers() func (s *Server) RestoreWireGuardInterface(device string) error {
activePeers := s.peers.GetActivePeers(device)
dev := s.peers.GetDevice(device)
for i := range activePeers { for i := range activePeers {
if activePeers[i].Peer == nil { if activePeers[i].Peer == nil {
if err := s.wg.AddPeer(activePeers[i].GetConfig()); err != nil { if err := s.wg.AddPeer(device, activePeers[i].GetConfig(&dev)); err != nil {
return errors.WithMessage(err, "failed to add WireGuard peer") return errors.WithMessage(err, "failed to add WireGuard peer")
} }
} }
@@ -184,26 +194,29 @@ func (s *Server) RestoreWireGuardInterface() error {
return nil return nil
} }
func (s *Server) WriteWireGuardConfigFile() error { // WriteWireGuardConfigFile writes the configuration file for the physical WireGuard interface.
if s.config.WG.WireGuardConfig == "" { func (s *Server) WriteWireGuardConfigFile(device string) error {
if s.config.WG.ConfigDirectoryPath == "" {
return nil // writing disabled return nil // writing disabled
} }
if err := syscall.Access(s.config.WG.WireGuardConfig, syscall.O_RDWR); err != nil { if err := syscall.Access(s.config.WG.ConfigDirectoryPath, syscall.O_RDWR); err != nil {
return errors.Wrap(err, "failed to check WireGuard config access rights") return errors.Wrap(err, "failed to check WireGuard config access rights")
} }
device := s.peers.GetDevice() dev := s.peers.GetDevice(device)
cfg, err := device.GetConfigFile(s.peers.GetActivePeers()) cfg, err := dev.GetConfigFile(s.peers.GetActivePeers(device))
if err != nil { if err != nil {
return errors.WithMessage(err, "failed to get config file") return errors.WithMessage(err, "failed to get config file")
} }
if err := ioutil.WriteFile(s.config.WG.WireGuardConfig, cfg, 0644); err != nil { filePath := path.Join(s.config.WG.ConfigDirectoryPath, dev.DeviceName+".conf")
if err := ioutil.WriteFile(filePath, cfg, 0644); err != nil {
return errors.Wrap(err, "failed to write WireGuard config file") return errors.Wrap(err, "failed to write WireGuard config file")
} }
return nil return nil
} }
func (s *Server) CreateUser(user users.User) error { // 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 == "" { if user.Email == "" {
return errors.New("cannot create user with empty email address") return errors.New("cannot create user with empty email address")
} }
@@ -214,15 +227,26 @@ func (s *Server) CreateUser(user users.User) error {
return s.UpdateUser(user) 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 // Create user in database
if err := s.users.CreateUser(&user); err != nil { if err := s.users.CreateUser(&user); err != nil {
return errors.WithMessage(err, "failed to create user in manager") return errors.WithMessage(err, "failed to create user in manager")
} }
// Check if user already has a peer setup, if not, create one // Check if user already has a peer setup, if not, create one
return s.CreateUserDefaultPeer(user.Email) 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 { func (s *Server) UpdateUser(user users.User) error {
if user.DeletedAt.Valid { if user.DeletedAt.Valid {
return s.DeleteUser(user) return s.DeleteUser(user)
@@ -230,6 +254,17 @@ func (s *Server) UpdateUser(user users.User) error {
currentUser := s.users.GetUserUnscoped(user.Email) 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 // Update in database
if err := s.users.UpdateUser(&user); err != nil { if err := s.users.UpdateUser(&user); err != nil {
return errors.WithMessage(err, "failed to update user in manager") return errors.WithMessage(err, "failed to update user in manager")
@@ -249,6 +284,8 @@ func (s *Server) UpdateUser(user users.User) error {
return nil return nil
} }
// DeleteUser removes the user from the database.
// Also, if the user has linked WireGuard peers, they will be deactivated.
func (s *Server) DeleteUser(user users.User) error { func (s *Server) DeleteUser(user users.User) error {
currentUser := s.users.GetUserUnscoped(user.Email) currentUser := s.users.GetUserUnscoped(user.Email)
@@ -271,7 +308,7 @@ func (s *Server) DeleteUser(user users.User) error {
return nil return nil
} }
func (s *Server) CreateUserDefaultPeer(email string) error { func (s *Server) CreateUserDefaultPeer(email, device string) error {
// Check if user is active, if not, quit // Check if user is active, if not, quit
var existingUser *users.User var existingUser *users.User
if existingUser = s.users.GetUser(email); existingUser == nil { if existingUser = s.users.GetUser(email); existingUser == nil {
@@ -282,7 +319,7 @@ func (s *Server) CreateUserDefaultPeer(email string) error {
if s.config.Core.CreateDefaultPeer { if s.config.Core.CreateDefaultPeer {
peers := s.peers.GetPeersByMail(email) peers := s.peers.GetPeersByMail(email)
if len(peers) == 0 { // Create default vpn peer if len(peers) == 0 { // Create default vpn peer
if err := s.CreatePeer(Peer{ if err := s.CreatePeer(device, wireguard.Peer{
Identifier: existingUser.Firstname + " " + existingUser.Lastname + " (Default)", Identifier: existingUser.Firstname + " " + existingUser.Lastname + " (Default)",
Email: existingUser.Email, Email: existingUser.Email,
CreatedBy: existingUser.Email, CreatedBy: existingUser.Email,
@@ -295,3 +332,14 @@ func (s *Server) CreateUserDefaultPeer(email string) error {
return nil 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
}

View File

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

View File

@@ -1,17 +0,0 @@
package users
type SupportedDatabase string
const (
SupportedDatabaseMySQL SupportedDatabase = "mysql"
SupportedDatabaseSQLite SupportedDatabase = "sqlite"
)
type Config 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"`
}

View File

@@ -1,9 +1,6 @@
package users package users
import ( import (
"fmt"
"os"
"path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -11,69 +8,15 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger"
) )
func GetDatabaseForConfig(cfg *Config) (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{})
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 Manager struct { type Manager struct {
db *gorm.DB db *gorm.DB
} }
func NewManager(cfg *Config) (*Manager, error) { func NewManager(db *gorm.DB) (*Manager, error) {
m := &Manager{} m := &Manager{db: db}
var err error
m.db, err = GetDatabaseForConfig(cfg)
if err != nil {
return nil, errors.Wrapf(err, "failed to setup user database %s", cfg.Database)
}
// check if old user table exists (from version <= 1.0.2), if so rename it to peers. // 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 m.db.Migrator().HasTable("users") && !m.db.Migrator().HasTable("peers") {
@@ -84,14 +27,11 @@ func NewManager(cfg *Config) (*Manager, error) {
} }
} }
return m, m.MigrateUserDB()
}
func (m Manager) MigrateUserDB() error {
if err := m.db.AutoMigrate(&User{}); err != nil { if err := m.db.AutoMigrate(&User{}); err != nil {
return errors.Wrap(err, "failed to migrate user database") return nil, errors.Wrap(err, "failed to migrate user database")
} }
return nil
return m, nil
} }
func (m Manager) GetUsers() []User { func (m Manager) GetUsers() []User {
@@ -111,6 +51,8 @@ func (m Manager) UserExists(email string) bool {
} }
func (m Manager) GetUser(email string) *User { func (m Manager) GetUser(email string) *User {
email = strings.ToLower(email)
user := User{} user := User{}
m.db.Where("email = ?", email).First(&user) m.db.Where("email = ?", email).First(&user)
@@ -122,6 +64,8 @@ func (m Manager) GetUser(email string) *User {
} }
func (m Manager) GetUserUnscoped(email string) *User { func (m Manager) GetUserUnscoped(email string) *User {
email = strings.ToLower(email)
user := User{} user := User{}
m.db.Unscoped().Where("email = ?", email).First(&user) m.db.Unscoped().Where("email = ?", email).First(&user)
@@ -153,6 +97,8 @@ func (m Manager) GetFilteredAndSortedUsersUnscoped(sortKey, sortDirection, searc
} }
func (m Manager) GetOrCreateUser(email string) (*User, error) { func (m Manager) GetOrCreateUser(email string) (*User, error) {
email = strings.ToLower(email)
user := User{} user := User{}
m.db.Where("email = ?", email).FirstOrInit(&user) m.db.Where("email = ?", email).FirstOrInit(&user)
@@ -173,6 +119,8 @@ func (m Manager) GetOrCreateUser(email string) (*User, error) {
} }
func (m Manager) GetOrCreateUserUnscoped(email string) (*User, error) { func (m Manager) GetOrCreateUserUnscoped(email string) (*User, error) {
email = strings.ToLower(email)
user := User{} user := User{}
m.db.Unscoped().Where("email = ?", email).FirstOrInit(&user) m.db.Unscoped().Where("email = ?", email).FirstOrInit(&user)
@@ -193,6 +141,8 @@ func (m Manager) GetOrCreateUserUnscoped(email string) (*User, error) {
} }
func (m Manager) CreateUser(user *User) error { func (m Manager) CreateUser(user *User) error {
user.Email = strings.ToLower(user.Email)
user.Source = UserSourceDatabase
res := m.db.Create(user) res := m.db.Create(user)
if res.Error != nil { if res.Error != nil {
return errors.Wrapf(res.Error, "failed to create user %s", user.Email) return errors.Wrapf(res.Error, "failed to create user %s", user.Email)
@@ -202,6 +152,7 @@ func (m Manager) CreateUser(user *User) error {
} }
func (m Manager) UpdateUser(user *User) error { func (m Manager) UpdateUser(user *User) error {
user.Email = strings.ToLower(user.Email)
res := m.db.Save(user) res := m.db.Save(user)
if res.Error != nil { if res.Error != nil {
return errors.Wrapf(res.Error, "failed to update user %s", user.Email) return errors.Wrapf(res.Error, "failed to update user %s", user.Email)
@@ -211,6 +162,7 @@ func (m Manager) UpdateUser(user *User) error {
} }
func (m Manager) DeleteUser(user *User) error { func (m Manager) DeleteUser(user *User) error {
user.Email = strings.ToLower(user.Email)
res := m.db.Delete(user) res := m.db.Delete(user)
if res.Error != nil { if res.Error != nil {
return errors.Wrapf(res.Error, "failed to update user %s", user.Email) return errors.Wrapf(res.Error, "failed to update user %s", user.Email)
@@ -260,7 +212,7 @@ func filterUsers(users []User, search string) []User {
filteredUsers := make([]User, 0, len(users)) filteredUsers := make([]User, 0, len(users))
for i := range users { for i := range users {
if strings.Contains(users[i].Email, search) || if strings.Contains(users[i].Email, strings.ToLower(search)) ||
strings.Contains(users[i].Firstname, search) || strings.Contains(users[i].Firstname, search) ||
strings.Contains(users[i].Lastname, search) || strings.Contains(users[i].Lastname, search) ||
strings.Contains(string(users[i].Source), search) || strings.Contains(string(users[i].Source), search) ||

View File

@@ -14,6 +14,16 @@ const (
UserSourceOIDC UserSource = "oidc" // open id connect, TODO: implement UserSourceOIDC UserSource = "oidc" // open id connect, TODO: implement
) )
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 // 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 { type User struct {
// required fields // required fields
@@ -27,10 +37,10 @@ type User struct {
Phone string `form:"phone" binding:"omitempty"` Phone string `form:"phone" binding:"omitempty"`
// optional, integrated password authentication // optional, integrated password authentication
Password string `form:"password" binding:"omitempty"` Password PrivateString `form:"password" binding:"omitempty"`
// database internal fields // database internal fields
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"` DeletedAt gorm.DeletedAt `gorm:"index" json:",omitempty"`
} }

View File

@@ -1,7 +1,17 @@
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
ManageIPAddresses bool `yaml:"manageIPAddresses" envconfig:"MANAGE_IPS"` // handle ip-address setup of interface 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
}
func (c Config) GetDefaultDeviceName() string {
if c.DefaultDeviceName == "" || !common.ListContains(c.DeviceNames, c.DefaultDeviceName) {
return c.DeviceNames[0]
}
return c.DefaultDeviceName
} }

View File

@@ -4,11 +4,11 @@ import (
"sync" "sync"
"github.com/pkg/errors" "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
@@ -25,8 +25,8 @@ func (m *Manager) Init() error {
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, errors.Wrap(err, "could not get WireGuard device") return nil, errors.Wrap(err, "could not get WireGuard device")
} }
@@ -34,11 +34,11 @@ func (m *Manager) GetDeviceInfo() (*wgtypes.Device, error) {
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, errors.Wrap(err, "could not get WireGuard device") return nil, errors.Wrap(err, "could not get WireGuard device")
} }
@@ -46,7 +46,7 @@ func (m *Manager) GetPeerList() ([]wgtypes.Peer, error) {
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()
@@ -55,7 +55,7 @@ func (m *Manager) GetPeer(pubKey string) (*wgtypes.Peer, error) {
return nil, errors.Wrap(err, "invalid public key") 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, errors.Wrap(err, "could not get WireGuard peers") return nil, errors.Wrap(err, "could not get WireGuard peers")
} }
@@ -69,11 +69,11 @@ func (m *Manager) GetPeer(pubKey string) (*wgtypes.Peer, error) {
return nil, errors.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 errors.Wrap(err, "could not configure WireGuard device") return errors.Wrap(err, "could not configure WireGuard device")
} }
@@ -81,12 +81,12 @@ func (m *Manager) AddPeer(cfg wgtypes.PeerConfig) error {
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 errors.Wrap(err, "could not configure WireGuard device") return errors.Wrap(err, "could not configure WireGuard device")
} }
@@ -94,7 +94,7 @@ func (m *Manager) UpdatePeer(cfg wgtypes.PeerConfig) error {
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()
@@ -108,7 +108,7 @@ 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 errors.Wrap(err, "could not configure WireGuard device") return errors.Wrap(err, "could not configure WireGuard device")
} }
@@ -116,6 +116,6 @@ func (m *Manager) RemovePeer(pubKey string) error {
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

@@ -4,17 +4,16 @@ import (
"fmt" "fmt"
"net" "net"
"github.com/pkg/errors"
"github.com/milosgajdos/tenus" "github.com/milosgajdos/tenus"
"github.com/pkg/errors"
) )
const DefaultMTU = 1420 const DefaultMTU = 1420
func (m *Manager) GetIPAddress() ([]string, error) { func (m *Manager) GetIPAddress(device string) ([]string, error) {
wgInterface, err := tenus.NewLinkFrom(m.Cfg.DeviceName) wgInterface, err := tenus.NewLinkFrom(device)
if err != nil { if err != nil {
return nil, errors.Wrapf(err, "could not retrieve WireGuard interface %s", m.Cfg.DeviceName) return nil, errors.Wrapf(err, "could not retrieve WireGuard interface %s", device)
} }
// Get golang net.interface // Get golang net.interface
@@ -52,14 +51,14 @@ func (m *Manager) GetIPAddress() ([]string, error) {
return ipAddresses, nil return ipAddresses, nil
} }
func (m *Manager) SetIPAddress(cidrs []string) error { func (m *Manager) SetIPAddress(device string, cidrs []string) error {
wgInterface, err := tenus.NewLinkFrom(m.Cfg.DeviceName) wgInterface, err := tenus.NewLinkFrom(device)
if err != nil { if err != nil {
return errors.Wrapf(err, "could not retrieve WireGuard interface %s", m.Cfg.DeviceName) return errors.Wrapf(err, "could not retrieve WireGuard interface %s", device)
} }
// First remove existing IP addresses // First remove existing IP addresses
existingIPs, err := m.GetIPAddress() existingIPs, err := m.GetIPAddress(device)
if err != nil { if err != nil {
return errors.Wrap(err, "could not retrieve IP addresses") return errors.Wrap(err, "could not retrieve IP addresses")
} }
@@ -89,10 +88,10 @@ func (m *Manager) SetIPAddress(cidrs []string) error {
return nil return nil
} }
func (m *Manager) GetMTU() (int, error) { func (m *Manager) GetMTU(device string) (int, error) {
wgInterface, err := tenus.NewLinkFrom(m.Cfg.DeviceName) wgInterface, err := tenus.NewLinkFrom(device)
if err != nil { if err != nil {
return 0, errors.Wrapf(err, "could not retrieve WireGuard interface %s", m.Cfg.DeviceName) return 0, errors.Wrapf(err, "could not retrieve WireGuard interface %s", device)
} }
// Get golang net.interface // Get golang net.interface
@@ -104,10 +103,10 @@ func (m *Manager) GetMTU() (int, error) {
return iface.MTU, nil return iface.MTU, nil
} }
func (m *Manager) SetMTU(mtu int) error { func (m *Manager) SetMTU(device string, mtu int) error {
wgInterface, err := tenus.NewLinkFrom(m.Cfg.DeviceName) wgInterface, err := tenus.NewLinkFrom(device)
if err != nil { if err != nil {
return errors.Wrapf(err, "could not retrieve WireGuard interface %s", m.Cfg.DeviceName) return errors.Wrapf(err, "could not retrieve WireGuard interface %s", device)
} }
if mtu == 0 { if mtu == 0 {
@@ -115,7 +114,7 @@ func (m *Manager) SetMTU(mtu int) error {
} }
if err := wgInterface.SetLinkMTU(mtu); err != nil { if err := wgInterface.SetLinkMTU(mtu); err != nil {
return errors.Wrapf(err, "could not set MTU on interface %s", m.Cfg.DeviceName) return errors.Wrapf(err, "could not set MTU on interface %s", device)
} }
return nil return nil

View File

@@ -0,0 +1,862 @@
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"
)
//
// 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)
}
}
//
// PEER ----------------------------------------------------------------------------------------
//
type Peer struct {
Peer *wgtypes.Peer `gorm:"-" json:"-"` // WireGuard peer
Device *Device `gorm:"foreignKey:DeviceName" binding:"-" json:"-"` // linked WireGuard device
Config string `gorm:"-" json:"-"`
UID string `form:"uid" binding:"required,alphanum" json:"-"` // uid for html identification
DeviceName string `gorm:"index" form:"device" binding:"required"`
DeviceType DeviceType `gorm:"-" form:"devicetype" binding:"required,oneof=client server" json:"-"`
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
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:"iplist"` // 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"`
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) 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()
}
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) 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:"-"`
Type DeviceType `form:"devicetype" binding:"required,oneof=client server"`
DeviceName string `form:"device" gorm:"primaryKey" binding:"required,alphanum"`
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:"iplist"` // 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) ([]byte, error) {
var tplBuff bytes.Buffer
err := templateCache.ExecuteTemplate(&tplBuff, "interface.tpl", gin.H{
"Peers": peers,
"Interface": d,
})
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(&Peer{}, &Device{}); 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,78 @@
# 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]
PublicKey = {{ .PublicKey }}
{{- if .PresharedKey}}
PresharedKey = {{ .PresharedKey }}
{{- end}}
{{- if eq $.Interface.Type "server"}}
AllowedIPs = {{ .IPsStr }}
{{- 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,30 @@
# AUTOGENERATED FILE - PROVIDED BY WIREGUARD PORTAL
# WireGuard configuration: {{ .Peer.Identifier }}
# -WGP- PublicKey: {{ .Peer.PublicKey }}
[Interface]
# Core settings
PrivateKey = {{ .Peer.PrivateKey }}
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