Compare commits

..

38 Commits

Author SHA1 Message Date
Christoph Haas
e0968b3239 support AllowIPs for peers in server config (#24) 2021-06-18 14:13:44 +02:00
Christoph Haas
e1db939a18 update readme to clarify some things 2021-06-18 14:12:22 +02:00
Christoph Haas
92d09535bc fix foreign key problem (#23) 2021-06-08 16:17:30 +02:00
Christoph Haas
d165fc0658 ensure that email attribute is set 2021-05-17 09:06:26 +02:00
Christoph Haas
cadbe4a090 fix migrations for fresh databases 2021-05-16 23:55:59 +02:00
Christoph Haas
d516d74d3f ldap filter: skip empty emails by default, fix ldap sync (case insensitive email comparison, user source difference) 2021-05-16 23:43:47 +02:00
Christoph Haas
c9e7145a5b add docker latest tag warning to README.md 2021-05-16 23:14:18 +02:00
h44z
88278bf677 Merge pull request #21 from h44z/ldap_filter
use LDAP filter strings
2021-05-16 23:11:55 +02:00
Christoph Haas
1c4d47293c skip migrations for fresh databases 2021-05-16 23:11:03 +02:00
Christoph Haas
27de6e8b8c use LDAP filter strings 2021-05-10 10:31:56 +02:00
Christoph Haas
3ecb0925d6 use low error correction if qr-code content is too long (#18) 2021-05-10 09:26:36 +02:00
Christoph Haas
edfecd536a use query params throughout the whole rest api (#11) 2021-05-03 11:40:06 +02:00
Christoph Haas
d794f807ad use query param for public key in api requests (#11) 2021-05-03 10:44:27 +02:00
h44z
84e5359977 Merge pull request #16 from xhit/patch-1
Fix email encryption type SSL/TLS
2021-04-30 11:00:25 +02:00
Santiago De la Cruz
5ac45b7a4f Fix email encryption type SSL/TLS
mail.EncryptionTLS is deprecated and is the same like mail.EncryptionSTARTTLS

The correct here is mail.EncryptionSSLTLS
2021-04-29 12:53:03 -04:00
Christoph Haas
ab02f656be add ServerName to TLS config 2021-04-29 18:19:41 +02:00
Christoph Haas
0d4e12a6c1 increase smtp timeout to 30 seconds 2021-04-29 17:04:26 +02:00
Christoph Haas
9a420d26e1 use html email body by default, add alternative text only body 2021-04-29 16:54:01 +02:00
Christoph Haas
19e6fa2a1a switch to another email lib to support more AUTH types 2021-04-29 16:45:28 +02:00
Christoph Haas
7b1f59d86a deployment api completed (#11) 2021-04-29 11:23:32 +02:00
Christoph Haas
9c8a1df01f Set server name in TLS config (#13) 2021-04-29 10:59:00 +02:00
Christoph Haas
87964f8ec4 RESTful API for WireGuard Portal (#11) 2021-04-26 22:00:50 +02:00
Christoph Haas
35513ae994 WIP: RESTful API for WireGuard Portal, user endpoint (#11) 2021-04-26 20:02:40 +02:00
Christoph Haas
b6d9814021 use lowercase email addresses for filtering (#14) 2021-04-22 20:46:03 +02:00
Christoph Haas
97edd103be transform email addresses to lower case in ldap sync (#14) 2021-04-22 20:41:30 +02:00
Christoph Haas
e052f400aa convert all email addresses to lower case (#14) 2021-04-22 20:29:37 +02:00
Christoph Haas
926733dea4 add ssl/tls option for email encryption (#13) 2021-04-22 14:11:54 +02:00
Christoph Haas
7042523c54 configurable cert-check for the ldap auth provider (#12) 2021-04-21 11:07:16 +02:00
Christoph Haas
e65a4a8148 disable cert-check should also work for ldap via ssl (#12) 2021-04-21 10:04:10 +02:00
Christoph Haas
28c2494d88 cleanup import statements 2021-04-09 23:17:44 +02:00
Christoph Haas
11b9a567d1 include tag version in travis builds 2021-04-08 21:30:16 +02:00
Christoph Haas
f34594f8d2 fix allowed ip's for peers in server-mode 2021-04-08 19:10:38 +02:00
Christoph Haas
46dc6dc2ad remove endpoint from peer in server-mode 2021-04-08 18:39:52 +02:00
Christoph Haas
2ca1226d50 fix .local DNS lookup (https://github.com/golang/go/issues/35067) 2021-04-08 18:37:49 +02:00
Christoph Haas
066f939294 fix version display 2021-04-08 18:10:53 +02:00
Christoph Haas
17bc297d77 WIP: smaller docker image, fix docker build 2021-04-08 17:58:25 +02:00
Christoph Haas
79e4513edb WIP: smaller docker image, sqlite needs cgo 2021-04-08 09:38:32 +02:00
Christoph Haas
f793ece922 WIP: smaller docker image 2021-04-08 09:23:48 +02:00
36 changed files with 3098 additions and 393 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

@@ -9,11 +9,11 @@
[![Docker Pulls](https://img.shields.io/docker/pulls/h44z/wg-portal.svg)](https://hub.docker.com/r/h44z/wg-portal/) [![Docker Pulls](https://img.shields.io/docker/pulls/h44z/wg-portal.svg)](https://hub.docker.com/r/h44z/wg-portal/)
A simple, web based configuration portal for [WireGuard](https://wireguard.com). A simple, web based configuration portal for [WireGuard](https://wireguard.com).
The portal uses the WireGuard [wgctrl](https://github.com/WireGuard/wgctrl-go) library to manage the VPN The portal uses the WireGuard [wgctrl](https://github.com/WireGuard/wgctrl-go) library to manage existing VPN
interface. This allows for seamless activation or deactivation of new users, without disturbing existing VPN interfaces. This allows for seamless activation or deactivation of new users, without disturbing existing VPN
connections. connections.
The configuration portal currently supports using SQLite, MySQL as a user source for authentication and profile data. The configuration portal currently supports using SQLite and MySQL as a user source for authentication and profile data.
It also supports LDAP (Active Directory or OpenLDAP) as authentication provider. It also supports LDAP (Active Directory or OpenLDAP) as authentication provider.
## Features ## Features
@@ -30,14 +30,19 @@ It also supports LDAP (Active Directory or OpenLDAP) as authentication provider.
* 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 * Support for multiple WireGuard interfaces
* REST API for management and client deployment
![Screenshot](screenshot.png) ![Screenshot](screenshot.png)
## Setup ## Setup
Make sure that your host system has at least one WireGuard interface (for example wg0) available.
If you did not start up a WireGuard interface yet, take a look at [wg-quick](https://manpages.debian.org/unstable/wireguard-tools/wg-quick.8.en.html) in order to get started.
### Docker ### Docker
The easiest way to run WireGuard Portal is 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'
@@ -114,6 +119,7 @@ The following configuration options are available:
| ADMIN_PASS | adminPass | core | wgportal | The administrator password. If unchanged, a random password will be set on first startup. | | 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. | | 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. | | 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. | | LDAP_ENABLED | ldapEnabled | core | false | Enable or disable the LDAP backend. |
| SESSION_SECRET | sessionSecret | core | secret | Use a custom secret to encrypt session data. | | SESSION_SECRET | sessionSecret | core | secret | Use a custom secret to encrypt session data. |
| DATABASE_TYPE | typ | database | sqlite | Either mysql or sqlite. | | DATABASE_TYPE | typ | database | sqlite | Either mysql or sqlite. |
@@ -124,10 +130,12 @@ The following configuration options are available:
| DATABASE_PASSWORD | password | database | | The mysql password. | | DATABASE_PASSWORD | password | database | | The mysql password. |
| EMAIL_HOST | host | email | 127.0.0.1 | The email server address. | | EMAIL_HOST | host | email | 127.0.0.1 | The email server address. |
| EMAIL_PORT | port | email | 25 | The email server port. | | EMAIL_PORT | port | email | 25 | The email server port. |
| EMAIL_TLS | tls | email | false | Use STARTTLS. | | 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_CERT_VALIDATION | certcheck | email | false | Validate the email server certificate. |
| EMAIL_USERNAME | user | email | | An optional username for SMTP authentication. | | EMAIL_USERNAME | user | email | | An optional username for SMTP authentication. |
| EMAIL_PASSWORD | pass | email | | An optional password 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_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_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. | | WG_CONFIG_PATH | configDirectory | wg | /etc/wireguard | If set, interface configuration updates will be written to this path, filename: <devicename>.conf. |
@@ -138,15 +146,14 @@ The following configuration options are available:
| LDAP_BASEDN | dn | ldap | DC=COMPANY,DC=LOCAL | The base DN for searching users. | | 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_USER | user | ldap | company\\\\ldap_wireguard | The bind user. |
| LDAP_PASSWORD | pass | ldap | SuperSecret | The bind password. | | LDAP_PASSWORD | pass | ldap | SuperSecret | The bind password. |
| LDAP_TYPE | typ | ldap | AD | Either AD or OpenLDAP. | | 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_USER_CLASS | userClass | ldap | organizationalPerson | The user class that specifies the LDAP object category of users. | | 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_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_EMAIL | attrEmail | ldap | mail | User email attribute. |
| LDAP_ATTR_FIRSTNAME | attrFirstname | ldap | givenName | User firstname attribute. | | LDAP_ATTR_FIRSTNAME | attrFirstname | ldap | givenName | User firstname attribute. |
| LDAP_ATTR_LASTNAME | attrLastname | ldap | sn | User lastname attribute. | | LDAP_ATTR_LASTNAME | attrLastname | ldap | sn | User lastname attribute. |
| LDAP_ATTR_PHONE | attrPhone | ldap | telephoneNumber | User phone number attribute. | | LDAP_ATTR_PHONE | attrPhone | ldap | telephoneNumber | User phone number attribute. |
| LDAP_ATTR_GROUPS | attrGroups | ldap | memberOf | User groups attribute. | | LDAP_ATTR_GROUPS | attrGroups | ldap | memberOf | User groups attribute. |
| LDAP_ATTR_DISABLED | attrDisabled | ldap | userAccountControl | User status attribute. This attribute is used to detect deactivated users. |
| LOG_LEVEL | | | debug | Specify log level, one of: trace, debug, info, off. | | LOG_LEVEL | | | debug | Specify log level, one of: trace, debug, info, off. |
| LOG_JSON | | | false | Format log output as JSON. | | LOG_JSON | | | false | Format log output as JSON. |
| LOG_COLOR | | | true | Colorize log output. | | LOG_COLOR | | | true | Colorize log output. |
@@ -171,7 +178,6 @@ ldap:
user: wireguard@test.test user: wireguard@test.test
pass: test pass: test
adminGroup: CN=WireGuardAdmins,CN=Users,DC=test,DC=test adminGroup: CN=WireGuardAdmins,CN=Users,DC=test,DC=test
typ: AD
database: database:
typ: sqlite typ: sqlite
database: data/wg_portal.db database: data/wg_portal.db
@@ -190,11 +196,16 @@ wg:
manageIPAddresses: true manageIPAddresses: true
``` ```
## What is out of scope ### 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`.
* Generation or application of any `iptables` or `nftables` rules ## What is out of scope
* Setting up or changing IP-addresses of the WireGuard interface on operating systems other than linux * Creating or removing WireGuard (wgX) interfaces.
* Importing private keys of an existing WireGuard setup * Generation or application of any `iptables` or `nftables` rules.
* Setting up or changing IP-addresses of the WireGuard interface on operating systems other than linux.
* Importing private keys of an existing WireGuard setup.
## Application stack ## Application stack

View File

@@ -82,6 +82,12 @@
<input type="text" name="allowedip" class="form-control" id="server_AllowedIP" value="{{.Peer.AllowedIPsStr}}"> <input type="text" name="allowedip" class="form-control" id="server_AllowedIP" value="{{.Peer.AllowedIPsStr}}">
</div> </div>
</div> </div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="server_AllowedIPSrv">Extra Allowed IPs (Server sided)</label>
<input type="text" name="allowedipSrv" class="form-control" id="server_AllowedIPSrv" value="{{.Peer.AllowedIPsSrvStr}}">
</div>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-12 global-config"> <div class="form-group col-md-12 global-config">
<label for="server_DNS">Client DNS Servers</label> <label for="server_DNS">Client DNS Servers</label>

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:

13
go.mod
View File

@@ -3,21 +3,32 @@ 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-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/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/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/sirupsen/logrus v1.8.1 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-20210225092905-2c785434f26f github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca 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/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-20210107192922-496545a6307b gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
gorm.io/driver/mysql v1.0.5 gorm.io/driver/mysql v1.0.5

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: !provider.config.CertValidation}) err = conn.StartTLS(tlsConfig)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -7,10 +7,9 @@ import (
"strings" "strings"
"time" "time"
"github.com/h44z/wg-portal/internal/common"
"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"
@@ -109,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")
} }
@@ -136,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()
@@ -170,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()

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"time" "time"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -14,6 +15,29 @@ import (
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
) )
func init() {
migrations = append(migrations, Migration{
version: "1.0.7",
migrateFn: func(db *gorm.DB) error {
if err := db.Exec("UPDATE users SET email = LOWER(email)").Error; err != nil {
return errors.Wrap(err, "failed to convert user emails to lower case")
}
if err := db.Exec("UPDATE peers SET email = LOWER(email)").Error; err != nil {
return errors.Wrap(err, "failed to convert peer emails to lower case")
}
logrus.Infof("upgraded database format to version 1.0.7")
return nil
},
})
migrations = append(migrations, Migration{
version: "1.0.8",
migrateFn: func(db *gorm.DB) error {
logrus.Infof("upgraded database format to version 1.0.8")
return nil
},
})
}
type SupportedDatabase string type SupportedDatabase string
const ( const (
@@ -80,16 +104,18 @@ type DatabaseMigrationInfo struct {
Applied time.Time 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 { func MigrateDatabase(db *gorm.DB, version string) error {
if err := db.AutoMigrate(&DatabaseMigrationInfo{}); err != nil { if err := db.AutoMigrate(&DatabaseMigrationInfo{}); err != nil {
return errors.Wrap(err, "failed to migrate version database") return errors.Wrap(err, "failed to migrate version database")
} }
newVersion := DatabaseMigrationInfo{
Version: version,
Applied: time.Now(),
}
existingMigration := DatabaseMigrationInfo{} existingMigration := DatabaseMigrationInfo{}
db.Where("version = ?", version).FirstOrInit(&existingMigration) db.Where("version = ?", version).FirstOrInit(&existingMigration)
@@ -97,11 +123,36 @@ func MigrateDatabase(db *gorm.DB, version string) error {
lastVersion := DatabaseMigrationInfo{} lastVersion := DatabaseMigrationInfo{}
db.Order("applied desc, version desc").FirstOrInit(&lastVersion) db.Order("applied desc, version desc").FirstOrInit(&lastVersion)
// TODO: migrate database if lastVersion.Version == "" {
// fresh database, no migrations to apply
res := db.Create(&newVersion) res := db.Create(&DatabaseMigrationInfo{
Version: version,
Applied: time.Now(),
})
if res.Error != nil { if res.Error != nil {
return errors.Wrap(res.Error, "failed to write version to database") 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)
}
}
} }
} }

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.
Encryption MailEncryption `yaml:"encryption" envconfig:"EMAIL_ENCRYPTION"`
CertValidation bool `yaml:"certcheck" envconfig:"EMAIL_CERT_VALIDATION"` CertValidation bool `yaml:"certcheck" envconfig:"EMAIL_CERT_VALIDATION"`
Username string `yaml:"user" envconfig:"EMAIL_USERNAME"` Username string `yaml:"user" envconfig:"EMAIL_USERNAME"`
Password string `yaml:"pass" envconfig:"EMAIL_PASSWORD"` Password string `yaml:"pass" envconfig:"EMAIL_PASSWORD"`
AuthType MailAuthType `yaml:"auth" envconfig:"EMAIL_AUTHTYPE"`
} }
type MailAttachment struct { type MailAttachment struct {
@@ -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.TLS { // Call Send and pass the client
return e.SendWithStartTLS(hostname, auth, &tls.Config{InsecureSkipVerify: !cfg.CertValidation}) err = email.Send(client)
} else { if err != nil {
return e.Send(hostname, auth) return errors.Wrapf(err, "failed to send email")
} }
return nil
} }

View File

@@ -15,14 +15,13 @@ type Config struct {
BindUser string `yaml:"user" envconfig:"LDAP_USER"` BindUser string `yaml:"user" envconfig:"LDAP_USER"`
BindPass string `yaml:"pass" envconfig:"LDAP_PASSWORD"` 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: !cfg.CertValidation}) 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.ParseInt(userAccountControl, 10, 32)
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

@@ -64,6 +64,7 @@ type Config struct {
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"`
SelfProvisioningAllowed bool `yaml:"selfProvisioning" envconfig:"SELF_PROVISIONING"`
LdapEnabled bool `yaml:"ldapEnabled" envconfig:"LDAP_ENABLED"` LdapEnabled bool `yaml:"ldapEnabled" envconfig:"LDAP_ENABLED"`
SessionSecret string `yaml:"sessionSecret" envconfig:"SESSION_SECRET"` SessionSecret string `yaml:"sessionSecret" envconfig:"SESSION_SECRET"`
} `yaml:"core"` } `yaml:"core"`
@@ -96,15 +97,14 @@ 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.DeviceNames = []string{"wg0"} cfg.WG.DeviceNames = []string{"wg0"}
cfg.WG.DefaultDeviceName = "wg0" cfg.WG.DefaultDeviceName = "wg0"
@@ -112,6 +112,8 @@ func NewConfig() *Config {
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,12 +4,13 @@ import (
"net/http" "net/http"
"strings" "strings"
csrf "github.com/utrack/gin-csrf" "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) {
@@ -54,65 +55,15 @@ func (s *Server) PostLogin(c *gin.Context) {
return return
} }
// Check user database for an matching entry
var loginProvider authentication.AuthProvider
email := ""
user := s.users.GetUser(username) // retrieve active candidate user from db
if user != nil { // existing user
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 // Check all available auth backends
for _, provider := range s.auth.GetProvidersForType(authentication.AuthProviderTypePassword) { user, err := s.checkAuthentication(username, password)
// 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 { if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "login error", err.Error()) s.GetHandleError(c, http.StatusInternalServerError, "login error", err.Error())
return 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,
}, s.wg.Cfg.GetDefaultDeviceName()); 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
} }
@@ -153,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

@@ -64,6 +64,7 @@ func (s *Server) PostAdminEditPeer(c *gin.Context) {
// Clean list input // Clean list input
formPeer.IPsStr = common.ListToString(common.ParseStringList(formPeer.IPsStr)) formPeer.IPsStr = common.ListToString(common.ParseStringList(formPeer.IPsStr))
formPeer.AllowedIPsStr = common.ListToString(common.ParseStringList(formPeer.AllowedIPsStr)) formPeer.AllowedIPsStr = common.ListToString(common.ParseStringList(formPeer.AllowedIPsStr))
formPeer.AllowedIPsSrvStr = common.ListToString(common.ParseStringList(formPeer.AllowedIPsSrvStr))
disabled := c.PostForm("isdisabled") != "" disabled := c.PostForm("isdisabled") != ""
now := time.Now() now := time.Now()
@@ -121,6 +122,7 @@ func (s *Server) PostAdminCreatePeer(c *gin.Context) {
// Clean list input // Clean list input
formPeer.IPsStr = common.ListToString(common.ParseStringList(formPeer.IPsStr)) formPeer.IPsStr = common.ListToString(common.ParseStringList(formPeer.IPsStr))
formPeer.AllowedIPsStr = common.ListToString(common.ParseStringList(formPeer.AllowedIPsStr)) formPeer.AllowedIPsStr = common.ListToString(common.ParseStringList(formPeer.AllowedIPsStr))
formPeer.AllowedIPsSrvStr = common.ListToString(common.ParseStringList(formPeer.AllowedIPsSrvStr))
disabled := c.PostForm("isdisabled") != "" disabled := c.PostForm("isdisabled") != ""
now := time.Now() now := time.Now()
@@ -265,6 +267,7 @@ func (s *Server) GetPeerConfigMail(c *gin.Context) {
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 {
Peer wireguard.Peer Peer wireguard.Peer
@@ -274,7 +277,7 @@ func (s *Server) GetPeerConfigMail(c *gin.Context) {
}{ }{
Peer: peer, Peer: peer,
User: user, User: user,
QrcodePngName: "wireguard-config.png", 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())
@@ -289,7 +292,13 @@ func (s *Server) GetPeerConfigMail(c *gin.Context) {
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),
}, },

View File

@@ -8,7 +8,6 @@ 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"
csrf "github.com/utrack/gin-csrf" csrf "github.com/utrack/gin-csrf"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -105,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{
@@ -175,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")

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,11 +32,89 @@ func (s *Server) SyncLdapWithUserDatabase() {
continue continue
} }
// Update existing LDAP users
s.updateLdapUsers(ldapUsers)
// Disable missing LDAP users
s.disableMissingLdapUsers(ldapUsers)
}
logrus.Info("ldap user synchronization stopped")
}
func (s Server) userChangedInLdap(user *users.User, ldapData *ldap.RawLdapData) bool {
if user.Firstname != ldapData.Attributes[s.config.LDAP.FirstNameAttribute] {
return true
}
if user.Lastname != ldapData.Attributes[s.config.LDAP.LastNameAttribute] {
return true
}
if user.Email != strings.ToLower(ldapData.Attributes[s.config.LDAP.EmailAttribute]) {
return true
}
if user.Phone != ldapData.Attributes[s.config.LDAP.PhoneAttribute] {
return true
}
if user.Source != users.UserSourceLdap {
return true
}
if user.DeletedAt.Valid {
return true
}
ldapAdmin := false
for _, group := range ldapData.RawAttributes[s.config.LDAP.GroupMemberAttribute] {
if string(group) == s.config.LDAP.AdminLdapGroup {
ldapAdmin = true
break
}
}
if user.IsAdmin != ldapAdmin {
return true
}
return false
}
func (s *Server) disableMissingLdapUsers(ldapUsers []ldap.RawLdapData) {
// Disable missing LDAP users
activeUsers := s.users.GetUsers()
for i := range activeUsers {
if activeUsers[i].Source != users.UserSourceLdap {
continue
}
existsInLDAP := false
for j := range ldapUsers {
if activeUsers[i].Email == strings.ToLower(ldapUsers[j].Attributes[s.config.LDAP.EmailAttribute]) {
existsInLDAP = true
break
}
}
if existsInLDAP {
continue
}
// disable all peers for the given user
for _, peer := range s.peers.GetPeersByMail(activeUsers[i].Email) {
now := time.Now()
peer.DeactivatedAt = &now
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 { for i := range ldapUsers {
// prefilter if ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute] == "" {
if ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute] == "" || logrus.Tracef("skipping sync of %s, empty email attribute", ldapUsers[i].DN)
ldapUsers[i].Attributes[s.config.LDAP.FirstNameAttribute] == "" ||
ldapUsers[i].Attributes[s.config.LDAP.LastNameAttribute] == "" {
continue continue
} }
@@ -44,27 +123,8 @@ func (s *Server) SyncLdapWithUserDatabase() {
logrus.Errorf("failed to get/create user %s in database: %v", ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute], err) 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 // re-enable LDAP user if the user was disabled
ldapDeactivated := false if user.DeletedAt.Valid {
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 // enable all peers for the given user
for _, peer := range s.peers.GetPeersByMail(user.Email) { for _, peer := range s.peers.GetPeersByMail(user.Email) {
now := time.Now() now := time.Now()
@@ -74,10 +134,10 @@ func (s *Server) SyncLdapWithUserDatabase() {
} }
} }
} }
}
// Sync attributes from ldap // Sync attributes from ldap
if s.UserChangedInLdap(user, &ldapUsers[i]) { if s.userChangedInLdap(user, &ldapUsers[i]) {
logrus.Debugf("updating ldap user %s", user.Email)
user.Firstname = ldapUsers[i].Attributes[s.config.LDAP.FirstNameAttribute] user.Firstname = ldapUsers[i].Attributes[s.config.LDAP.FirstNameAttribute]
user.Lastname = ldapUsers[i].Attributes[s.config.LDAP.LastNameAttribute] user.Lastname = ldapUsers[i].Attributes[s.config.LDAP.LastNameAttribute]
user.Email = ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute] user.Email = ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute]
@@ -97,54 +157,6 @@ func (s *Server) SyncLdapWithUserDatabase() {
logrus.Errorf("failed to update ldap user %s in database: %v", user.Email, err) logrus.Errorf("failed to update ldap user %s in database: %v", user.Email, err)
continue 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")
}
func (s Server) UserChangedInLdap(user *users.User, ldapData *ldap.RawLdapData) bool {
if user.Firstname != ldapData.Attributes[s.config.LDAP.FirstNameAttribute] {
return true
}
if user.Lastname != ldapData.Attributes[s.config.LDAP.LastNameAttribute] {
return true
}
if user.Email != ldapData.Attributes[s.config.LDAP.EmailAttribute] {
return true
}
if user.Phone != ldapData.Attributes[s.config.LDAP.PhoneAttribute] {
return true
}
ldapDeactivated := false
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 {
return true
}
ldapAdmin := false
for _, group := range ldapData.RawAttributes[s.config.LDAP.GroupMemberAttribute] {
if string(group) == s.config.LDAP.AdminLdapGroup {
ldapAdmin = true
break
}
}
if user.IsAdmin != ldapAdmin {
return true
}
return false
}

View File

@@ -2,12 +2,25 @@ package server
import ( import (
"net/http" "net/http"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
wgportal "github.com/h44z/wg-portal" 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) {
@@ -21,12 +34,14 @@ 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)
@@ -52,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)
@@ -60,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)
@@ -78,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()
@@ -90,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

@@ -26,7 +26,6 @@ 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"
csrf "github.com/utrack/gin-csrf"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -104,7 +103,7 @@ func (s *Server) Setup(ctx context.Context) error {
if err != nil { if err != nil {
return errors.WithMessage(err, "database setup failed") return errors.WithMessage(err, "database setup failed")
} }
err = common.MigrateDatabase(s.db, Version) err = common.MigrateDatabase(s.db, DatabaseVersion)
if err != nil { if err != nil {
return errors.WithMessage(err, "database migration failed") return errors.WithMessage(err, "database migration failed")
} }
@@ -118,13 +117,6 @@ func (s *Server) Setup(ctx context.Context) error {
} }
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.Use(sessions.Sessions("authsession", memstore.NewStore([]byte(s.config.Core.SessionSecret))))
s.server.Use(csrf.Middleware(csrf.Options{
Secret: s.config.Core.SessionSecret,
ErrorFunc: func(c *gin.Context) {
c.String(400, "CSRF token mismatch")
c.Abort()
},
}))
s.server.SetFuncMap(template.FuncMap{ s.server.SetFuncMap(template.FuncMap{
"formatBytes": common.ByteCountSI, "formatBytes": common.ByteCountSI,
"urlEncode": url.QueryEscape, "urlEncode": url.QueryEscape,
@@ -151,6 +143,7 @@ func (s *Server) Setup(ctx context.Context) error {
// 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.db) s.users, err = users.NewManager(s.db)

View File

@@ -12,6 +12,7 @@ import (
"github.com/h44z/wg-portal/internal/wireguard" "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"
) )
@@ -52,6 +53,7 @@ func (s *Server) PrepareNewPeer(device string) (wireguard.Peer, error) {
peer.PersistentKeepalive = dev.DefaultPersistentKeepalive peer.PersistentKeepalive = dev.DefaultPersistentKeepalive
peer.AllowedIPsStr = dev.DefaultAllowedIPsStr peer.AllowedIPsStr = dev.DefaultAllowedIPsStr
peer.Mtu = dev.Mtu peer.Mtu = dev.Mtu
peer.DeviceName = device
case wireguard.DeviceTypeClient: case wireguard.DeviceTypeClient:
peer.UID = "newendpoint" peer.UID = "newendpoint"
} }
@@ -225,6 +227,15 @@ func (s *Server) CreateUser(user users.User, device string) 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")
@@ -243,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")

View File

@@ -1,3 +1,4 @@
package server package server
var Version = "1.0.6" var Version = "testbuild"
var DatabaseVersion = "1.0.8"

View File

@@ -51,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)
@@ -62,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)
@@ -93,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)
@@ -113,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)
@@ -133,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)
@@ -142,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)
@@ -151,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)
@@ -200,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

@@ -4,7 +4,6 @@ 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"
) )

View File

@@ -4,9 +4,8 @@ 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

View File

@@ -13,7 +13,6 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding" "github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/h44z/wg-portal/internal/common" "github.com/h44z/wg-portal/internal/common"
@@ -64,26 +63,26 @@ func init() {
// //
type Peer struct { type Peer struct {
Peer *wgtypes.Peer `gorm:"-"` // WireGuard peer Peer *wgtypes.Peer `gorm:"-" json:"-"` // WireGuard peer
Device *Device `gorm:"foreignKey:DeviceName" binding:"-"` // linked WireGuard device Config string `gorm:"-" json:"-"`
Config string `gorm:"-"`
UID string `form:"uid" binding:"required,alphanum"` // uid for html identification UID string `form:"uid" binding:"required,alphanum" json:"-"` // uid for html identification
DeviceName string `gorm:"index" form:"device" binding:"required"` DeviceName string `gorm:"index" form:"device" binding:"required"`
DeviceType DeviceType `gorm:"-" form:"devicetype" binding:"required,oneof=client server"` 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 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"` Email string `gorm:"index" form:"mail" binding:"required,email"`
IgnoreGlobalSettings bool `form:"ignoreglobalsettings"` IgnoreGlobalSettings bool `form:"ignoreglobalsettings"`
IsOnline bool `gorm:"-"` IsOnline bool `gorm:"-" json:"-"`
IsNew bool `gorm:"-"` IsNew bool `gorm:"-" json:"-"`
LastHandshake string `gorm:"-"` LastHandshake string `gorm:"-" json:"-"`
LastHandshakeTime string `gorm:"-"` LastHandshakeTime string `gorm:"-" json:"-"`
// Core WireGuard Settings // Core WireGuard Settings
PublicKey string `gorm:"primaryKey" form:"pubkey" binding:"required,base64"` // the public key of the peer itself PublicKey string `gorm:"primaryKey" form:"pubkey" binding:"required,base64"` // the public key of the peer itself
PresharedKey string `form:"presharedkey" binding:"omitempty,base64"` 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 AllowedIPsStr string `form:"allowedip" binding:"cidrlist"` // a comma separated list of IPs that are used in the client config file
AllowedIPsSrvStr string `form:"allowedipSrv" binding:"cidrlist"` // a comma separated list of IPs that are used in the server config file
Endpoint string `form:"endpoint" binding:"omitempty,hostname_port"` Endpoint string `form:"endpoint" binding:"omitempty,hostname_port"`
PersistentKeepalive int `form:"keepalive" binding:"gte=0"` PersistentKeepalive int `form:"keepalive" binding:"gte=0"`
@@ -94,7 +93,7 @@ type Peer struct {
// Global Device Settings (can be ignored, only make sense if device is in server mode) // Global Device Settings (can be ignored, only make sense if device is in server mode)
Mtu int `form:"mtu" binding:"gte=0,lte=1500"` Mtu int `form:"mtu" binding:"gte=0,lte=1500"`
DeactivatedAt *time.Time DeactivatedAt *time.Time `json:",omitempty"`
CreatedBy string CreatedBy string
UpdatedBy string UpdatedBy string
CreatedAt time.Time CreatedAt time.Time
@@ -125,7 +124,11 @@ func (p Peer) GetAllowedIPs() []string {
return common.ParseStringList(p.AllowedIPsStr) return common.ParseStringList(p.AllowedIPsStr)
} }
func (p Peer) GetConfig(_ *Device) wgtypes.PeerConfig { func (p Peer) GetAllowedIPsSrv() []string {
return common.ParseStringList(p.AllowedIPsSrvStr)
}
func (p Peer) GetConfig(dev *Device) wgtypes.PeerConfig {
publicKey, _ := wgtypes.ParseKey(p.PublicKey) publicKey, _ := wgtypes.ParseKey(p.PublicKey)
var presharedKey *wgtypes.Key var presharedKey *wgtypes.Key
@@ -135,7 +138,7 @@ func (p Peer) GetConfig(_ *Device) wgtypes.PeerConfig {
} }
var endpoint *net.UDPAddr var endpoint *net.UDPAddr
if p.Endpoint != "" { if p.Endpoint != "" && dev.Type == DeviceTypeClient {
addr, err := net.ResolveUDPAddr("udp", p.Endpoint) addr, err := net.ResolveUDPAddr("udp", p.Endpoint)
if err == nil { if err == nil {
endpoint = addr endpoint = addr
@@ -148,12 +151,19 @@ func (p Peer) GetConfig(_ *Device) wgtypes.PeerConfig {
keepAlive = &keepAliveDuration keepAlive = &keepAliveDuration
} }
peerAllowedIPs := p.GetAllowedIPs() allowedIPs := make([]net.IPNet, 0)
allowedIPs := make([]net.IPNet, len(peerAllowedIPs)) var peerAllowedIPs []string
for i, ip := range peerAllowedIPs { switch dev.Type {
case DeviceTypeClient:
peerAllowedIPs = p.GetAllowedIPs()
case DeviceTypeServer:
peerAllowedIPs = p.GetIPAddresses()
peerAllowedIPs = append(peerAllowedIPs, p.GetAllowedIPsSrv()...)
}
for _, ip := range peerAllowedIPs {
_, ipNet, err := net.ParseCIDR(ip) _, ipNet, err := net.ParseCIDR(ip)
if err == nil { if err == nil {
allowedIPs[i] = *ipNet allowedIPs = append(allowedIPs, *ipNet)
} }
} }
@@ -187,12 +197,21 @@ func (p Peer) GetConfigFile(device Device) ([]byte, error) {
func (p Peer) GetQRCode() ([]byte, error) { func (p Peer) GetQRCode() ([]byte, error) {
png, err := qrcode.Encode(p.Config, qrcode.Medium, 250) png, err := qrcode.Encode(p.Config, qrcode.Medium, 250)
if err != nil { if err == nil {
logrus.WithFields(logrus.Fields{ return png, nil
"err": err, }
}).Error("failed to create qrcode")
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") 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 return png, nil
} }
@@ -221,7 +240,8 @@ const (
) )
type Device struct { type Device struct {
Interface *wgtypes.Device `gorm:"-"` Interface *wgtypes.Device `gorm:"-" json:"-"`
Peers []Peer `gorm:"foreignKey:DeviceName" binding:"-" json:"-"` // linked WireGuard peers
Type DeviceType `form:"devicetype" binding:"required,oneof=client server"` Type DeviceType `form:"devicetype" binding:"required,oneof=client server"`
DeviceName string `form:"device" gorm:"primaryKey" binding:"required,alphanum"` DeviceName string `form:"device" gorm:"primaryKey" binding:"required,alphanum"`
@@ -361,7 +381,7 @@ func NewPeerManager(db *gorm.DB, wg *Manager) (*PeerManager, error) {
} }
} }
if err := pm.db.AutoMigrate(&Peer{}, &Device{}); err != nil { if err := pm.db.AutoMigrate(&Device{}, &Peer{}); err != nil {
return nil, errors.WithMessage(err, "failed to migrate peer database") return nil, errors.WithMessage(err, "failed to migrate peer database")
} }
@@ -605,7 +625,7 @@ func (m *PeerManager) GetFilteredAndSortedPeers(device, sortKey, sortDirection,
m.populatePeerData(&peers[i]) m.populatePeerData(&peers[i])
if search == "" || if search == "" ||
strings.Contains(peers[i].Email, search) || strings.Contains(peers[i].Email, strings.ToLower(search)) ||
strings.Contains(peers[i].Identifier, search) || strings.Contains(peers[i].Identifier, search) ||
strings.Contains(peers[i].PublicKey, search) { strings.Contains(peers[i].PublicKey, search) {
filteredPeers = append(filteredPeers, peers[i]) filteredPeers = append(filteredPeers, peers[i])
@@ -618,6 +638,7 @@ func (m *PeerManager) GetFilteredAndSortedPeers(device, sortKey, sortDirection,
} }
func (m *PeerManager) GetSortedPeersForEmail(sortKey, sortDirection, email string) []Peer { func (m *PeerManager) GetSortedPeersForEmail(sortKey, sortDirection, email string) []Peer {
email = strings.ToLower(email)
peers := make([]Peer, 0) peers := make([]Peer, 0)
m.db.Where("email = ?", email).Find(&peers) m.db.Where("email = ?", email).Find(&peers)
@@ -686,6 +707,7 @@ func (m *PeerManager) GetPeerByKey(publicKey string) Peer {
} }
func (m *PeerManager) GetPeersByMail(mail string) []Peer { func (m *PeerManager) GetPeersByMail(mail string) []Peer {
mail = strings.ToLower(mail)
var peers []Peer var peers []Peer
m.db.Where("email = ?", mail).Find(&peers) m.db.Where("email = ?", mail).Find(&peers)
for i := range peers { for i := range peers {
@@ -701,6 +723,7 @@ func (m *PeerManager) CreatePeer(peer Peer) error {
peer.UID = fmt.Sprintf("u%x", md5.Sum([]byte(peer.PublicKey))) peer.UID = fmt.Sprintf("u%x", md5.Sum([]byte(peer.PublicKey)))
peer.UpdatedAt = time.Now() peer.UpdatedAt = time.Now()
peer.CreatedAt = time.Now() peer.CreatedAt = time.Now()
peer.Email = strings.ToLower(peer.Email)
res := m.db.Create(&peer) res := m.db.Create(&peer)
if res.Error != nil { if res.Error != nil {
@@ -713,6 +736,7 @@ func (m *PeerManager) CreatePeer(peer Peer) error {
func (m *PeerManager) UpdatePeer(peer Peer) error { func (m *PeerManager) UpdatePeer(peer Peer) error {
peer.UpdatedAt = time.Now() peer.UpdatedAt = time.Now()
peer.Email = strings.ToLower(peer.Email)
res := m.db.Save(&peer) res := m.db.Save(&peer)
if res.Error != nil { if res.Error != nil {

View File

@@ -61,7 +61,7 @@ PublicKey = {{ .PublicKey }}
PresharedKey = {{ .PresharedKey }} PresharedKey = {{ .PresharedKey }}
{{- end}} {{- end}}
{{- if eq $.Interface.Type "server"}} {{- if eq $.Interface.Type "server"}}
AllowedIPs = {{ .IPsStr }} AllowedIPs = {{ .IPsStr }}{{if ne .AllowedIPsSrvStr ""}}, {{ .AllowedIPsSrvStr }}{{end}}
{{- end}} {{- end}}
{{- if eq $.Interface.Type "client"}} {{- if eq $.Interface.Type "client"}}
{{- if .AllowedIPsStr}} {{- if .AllowedIPsStr}}

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