Compare commits

...

18 Commits

Author SHA1 Message Date
Christoph Haas
5f4c041ee7 fix potentially unsafe external link 2021-02-27 00:19:47 +01:00
Christoph Haas
9ef4200be0 fix ldap provider, disable gin logs by default 2021-02-27 00:13:48 +01:00
Christoph Haas
e1c7a43496 fix ldap sync for disabled users, check if admin username is an email address, rename username to email 2021-02-26 23:43:52 +01:00
h44z
5bc3aa0036 Create codeql-analysis.yml 2021-02-26 23:19:42 +01:00
Christoph Haas
2b77148b81 migrate old database format correctly, fix typo, update readme 2021-02-26 23:13:11 +01:00
Christoph Haas
9bd80dbd33 fix docker build 2021-02-26 22:27:48 +01:00
Christoph Haas
9faa459c44 cleanup 2021-02-26 22:17:04 +01:00
Christoph Haas
8ea82c1916 add travis token 2021-02-25 09:46:19 +01:00
Christoph Haas
ca83caf357 fix travis 2021-02-24 23:01:13 +01:00
Christoph Haas
14339e72d4 WIP: dont use gox 2021-02-24 22:52:19 +01:00
Christoph Haas
6d4fcba00c WIP: use gox for cross platform compiling, try to enable cross platform cgo builds 2021-02-24 22:40:35 +01:00
Christoph Haas
4fe4d93e0d WIP: use gox for cross platform compiling 2021-02-24 22:16:03 +01:00
Christoph Haas
9b10d099b6 WIP: new user management and authentication system, use go 1.16 embed 2021-02-24 21:24:45 +01:00
Christoph Haas
43bab58f0a WIP: context for clean shutdown 2021-02-22 22:25:08 +01:00
Christoph Haas
984f744548 rename user to peer 2021-02-21 23:23:58 +01:00
Christoph Haas
53814dbc27 cleanup, typos, ... 2021-02-08 22:56:02 +01:00
Christoph Haas
dd47f84c3d use logrus for gin's log output, fix nil pointer bug 2021-01-13 17:49:52 +01:00
Christoph Haas
ec752f8b08 use logrus for gin's log output 2021-01-13 17:27:01 +01:00
52 changed files with 3015 additions and 1537 deletions

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

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

View File

@@ -1,4 +1,43 @@
language: go
dist: bionic
sudo: required
go:
- 1.15.x
- 1.16.x # Latest go version
env:
- GO111MODULE=on
addons:
apt:
packages:
- gcc-multilib
before_install:
- # skip
install:
- # skip
script:
- go get -t -v ./...
- diff -u <(echo -n) <(gofmt -d .)
- go vet $(go list ./... | grep -v /vendor/)
- make build
# 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 ln -s /usr/include/asm-generic /usr/include/asm
- make build-cross-plat
deploy:
provider: releases
skip_cleanup: true # Important, otherwise the build output would be purged.
api_key:
# *encrypted* GitHub key, as the output of the Travis CI CLI tool
secure: "uZ7Vg7mEP7aUyaf/Uq5UZt6r3Ig/iOWcf7DZMhAWilOayeqdfW8kp2VzKFgTM6PJJm5Zv+OYV4dniO11QDjIX5sJfS10ApaxvPjw/a4NkqKykfrKZABWRmuvSv/PSjzl7jnWgnPqydBmHCfowsxI6X9j1uivgXZDMYg9BKOnDJtVoakUWJ47GKWr7ZegvF5DwB3EaPwDUmJIAJRiMqO+I2QmuVmLvvzkuhSQ/yuCjel/O7kudJuioJOvsxSHH5Mjh7HZoYayAFikVIGCXJStzMCeLwa+lUHUXoofoDT8SHMmcw2Oil1OpeC1PhvtT6VFLzYl9aphl472F9zP0TlBzR5VJ3+r5dwFVhf0MHp0LflIIg8RGjZg/H60yUUPbGYW7gN3wjdH1l7i66HcqFVs39GgzPCpxNuz8bhhUJOtR6K9FujYpp8AkFCwB327LwGzBLWP3wLGkmhj3ca3FBGJLZhzRdK6gpdp9KgY+33wJ/5R7zsUGtEGTjzsGB1GmBBb887qt0mh/cfm/mdh5HPWvZCif2WTyWd2W8gUiN4oTPhRdE/FRFUqoR1WEZeQrjgj3tThywrXIpRVdigN74UMsnlThSHxPZdJHPLftei2A3b+yfYgxt43sp22MqyuB6K7mT5ximQLWldN2Ibf7kKb5RO9/WX5P8LUj1KXtY3dh2o="
file:
- dist/wg-portal-amd64
- dist/wg-portal-arm64
- dist/wg-portal-arm
- dist/wg-portal.env
- dist/wg-portal.service
on:
repo: h44z/wg-portal
tags: true # The deployment happens only if the commit has a tag.

View File

@@ -4,7 +4,7 @@
######-
# Start from the latest golang base image as builder image (only used to compile the code)
######-
FROM golang:1.15 as builder
FROM golang:1.16 as builder
RUN mkdir /build
@@ -29,7 +29,7 @@ FROM debian:buster
ENV TZ=Europe/Vienna
# GOSS for container health checks
ENV GOSS_VERSION v0.3.14
ENV GOSS_VERSION v0.3.16
RUN apt-get update && apt-get upgrade -y && \
apt-get install --no-install-recommends -y moreutils ca-certificates curl && \
rm -rf /var/cache/apt /var/lib/apt/lists/*; \
@@ -38,7 +38,6 @@ RUN apt-get update && apt-get upgrade -y && \
goss --version
COPY --from=builder /build/dist/wg-portal-amd64 /app/wgportal
COPY --from=builder /build/dist/assets /app/assets
COPY --from=builder /build/scripts /app/
# Set the Current Working Directory inside the container

View File

@@ -11,12 +11,10 @@ IMAGE=h44z/wg-portal
all: dep build
build: dep $(addsuffix -amd64,$(addprefix $(BUILDDIR)/,$(BINARIES)))
cp -r assets $(BUILDDIR)
cp scripts/wg-portal.service $(BUILDDIR)
cp scripts/wg-portal.env $(BUILDDIR)
build-cross-plat: dep build $(addsuffix -arm,$(addprefix $(BUILDDIR)/,$(BINARIES))) $(addsuffix -arm64,$(addprefix $(BUILDDIR)/,$(BINARIES)))
cp -r assets $(BUILDDIR)
cp scripts/wg-portal.service $(BUILDDIR)
cp scripts/wg-portal.env $(BUILDDIR)
@@ -59,4 +57,4 @@ $(BUILDDIR)/%-arm64: cmd/%/main.go dep phony
# On arch-linux install arm-linux-gnueabihf-gcc to crosscompile for arm
$(BUILDDIR)/%-arm: cmd/%/main.go dep phony
CGO_ENABLED=1 CC=arm-linux-gnueabihf-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" -o $@ $<

View File

@@ -10,7 +10,7 @@ use the following instructions:
### Building
This section describes how to build the WireGuard Portal code.
To compile the final binary, use the Makefile provided in the repository.
As WireGuard Portal is written in Go, **golang >= 1.14** must be installed prior to building.
As WireGuard Portal is written in Go, **golang >= 1.16** must be installed prior to building.
```
make build-cross-plat

View File

@@ -3,17 +3,18 @@
[![Build Status](https://travis-ci.com/h44z/wg-portal.svg?token=q4pSqaqT58Jzpxdx62xk&branch=master)](https://travis-ci.com/h44z/wg-portal)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT)
![GitHub last commit](https://img.shields.io/github/last-commit/h44z/wg-portal)
[![Go Report Card](https://goreportcard.com/badge/github.com/h44z/wg-portal)](https://goreportcard.com/report/github.com/h44z/wg-portal)
![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/h44z/wg-portal)
![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/h44z/wg-portal)
[![Docker Pulls](https://img.shields.io/docker/pulls/h44z/wg-portal.svg)](https://hub.docker.com/r/h44z/wg-portal/)
A simple web base configuration portal for [WireGuard](https://wireguard.com).
A simple, web based configuration portal for [WireGuard](https://wireguard.com).
The portal uses the WireGuard [wgctrl](https://github.com/WireGuard/wgctrl-go) library to manage the VPN
interface. This allows for seamless activation or deactivation of new users, without disturbing existing VPN
connections.
The configuration portal is designed to use LDAP (Active Directory) as a user source for authentication and profile data.
It still can be used without LDAP by using a predefined administrator account. Some features like mass creation of accounts
will only be available in combination with LDAP.
The configuration portal currently supports using SQLite, MySQL as a user source for authentication and profile data.
It also supports LDAP (Active Directory or OpenLDAP) as authentication provider.
## Features
* Self-hosted and web based
@@ -23,18 +24,20 @@ will only be available in combination with LDAP.
* Enable / Disable clients seamlessly
* Generation of `wgX.conf` after any modification
* IPv6 ready
* User authentication (LDAP and/or predefined admin account)
* User authentication (SQLite/MySQL and LDAP)
* Dockerized
* Responsive template
* One single binary
* Can be used with existing WireGuard setups
![Screenshot](screenshot.png)
## Setup
### Docker
The easiest way to run WireGuard Portal is using the provided docker image.
The easiest way to run WireGuard Portal is to use the Docker image provided.
Docker compose snippet with sample values:
Docker Compose snippet with some sample configuration values:
```
version: '3.6'
services:
@@ -55,19 +58,20 @@ services:
- WEBSITE_TITLE=WireGuard VPN
- COMPANY_NAME=Your Company Name
- MAIL_FROM=WireGuard VPN <noreply+wireguard@company.com>
- ADMIN_USER=admin # optional admin user
- ADMIN_USER=admin@domain.com
- ADMIN_PASS=supersecret
- ADMIN_LDAP_GROUP=CN=WireGuardAdmins,OU=Users,DC=COMPANY,DC=LOCAL
- EMAIL_HOST=10.10.10.10
- EMAIL_PORT=25
- LDAP_ENABLED=true
- LDAP_URL=ldap://srv-ad01.company.local:389
- LDAP_BASEDN=DC=COMPANY,DC=LOCAL
- LDAP_USER=ldap_wireguard@company.local
- LDAP_PASSWORD=supersecretldappassword
- LDAP_ADMIN_GROUP=CN=WireGuardAdmins,OU=Users,DC=COMPANY,DC=LOCAL
```
Please note that mapping ```/etc/wireguard``` to ```/etc/wireguard``` inside the docker, will erase your host's current configuration.
If needed, please make sure to backup your files from ```/etc/wireguard```.
For a full list of configuration options take a look at the source file [internal/common/configuration.go](internal/common/configuration.go).
For a full list of configuration options take a look at the source file [internal/common/configuration.go](internal/common/configuration.go#L57).
### Standalone
For a standalone application, use the Makefile provided in the repository to build the application.
@@ -79,7 +83,7 @@ make
make build-cross-plat
```
The compiled binary and all necessary assets will be located in the dist folder.
The compiled binary will be located in the dist folder.
A detailed description for using this software with a raspberry pi can be found in the [README-RASPBERRYPI.md](README-RASPBERRYPI.md).
## What is out of scope

View File

@@ -40,6 +40,16 @@ pre{background:#f7f7f9}iframe{overflow:hidden;border:none}@media (min-width: 768
/* --------------------------------------------------
End collapsable table*/
.jumbotron-home {
padding: 1rem 1rem;
}
@media (min-width: 576px) {
.jumbotron-home {
padding: 2rem 2rem;
}
}
@media (min-width: 1440px) {
.container, .container-lg, .container-md, .container-sm, .container-xl {
max-width: 1400px;

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
assets/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
assets/img/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -53,7 +53,7 @@
}
}).tokenfield({
autocomplete: {
source: [{{range $i, $u :=.Users}}{{$u.Mail}},{{end}}],
source: [{{range $i, $u :=.Users}}{{$u.Email}},{{end}}],
delay: 100
},
showAutocompleteOnFocus: false

View File

@@ -54,7 +54,7 @@
<h3>Client's global configuration</h3>
<div class="form-row">
<div class="form-group required col-md-12">
<label for="inputPublicEndpoint">Public Enpoint for Clients</label>
<label for="inputPublicEndpoint">Public Endpoint for Clients</label>
<input type="text" name="endpoint" class="form-control" id="inputPublicEndpoint" placeholder="vpn.company.com:51820" value="{{.Device.Endpoint}}">
</div>
</div>

View File

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

View File

@@ -84,13 +84,11 @@
</div>
<div class="mt-4 row">
<div class="col-sm-10 col-12">
<h2 class="mt-2">Current VPN Users</h2>
<h2 class="mt-2">Current VPN Peers</h2>
</div>
<div class="col-sm-2 col-12 text-right">
{{if not .Static.LdapDisabled}}
<a href="/admin/peer/createldap" title="Add LDAP users" class="btn btn-primary"><i class="fa fa-fw fa-user-plus"></i></a>
{{end}}
<a href="/admin/peer/create" title="Manually add a user" class="btn btn-primary"><i class="fa fa-fw fa-plus"></i>M</a>
<a href="/admin/peer/createldap" title="Add multiple peers" class="btn btn-primary"><i class="fa fa-fw fa-user-plus"></i></a>
<a href="/admin/peer/create" title="Manually add a peer" class="btn btn-primary"><i class="fa fa-fw fa-plus"></i>M</a>
</div>
</div>
<div class="mt-2 table-responsive">
@@ -98,11 +96,11 @@
<thead>
<tr>
<th scope="col" class="list-image-cell"></th><!-- Status and expand -->
<th scope="col"><a href="?sort=id">Identifier <i class="fa fa-fw {{.Session.GetSortIcon "id"}}"></i></a></th>
<th scope="col"><a href="?sort=pubKey">Public Key <i class="fa fa-fw {{.Session.GetSortIcon "pubKey"}}"></i></a></th>
<th scope="col"><a href="?sort=mail">E-Mail <i class="fa fa-fw {{.Session.GetSortIcon "mail"}}"></i></a></th>
<th scope="col"><a href="?sort=ip">IP's <i class="fa fa-fw {{.Session.GetSortIcon "ip"}}"></i></a></th>
<th scope="col"><a href="?sort=handshake">Handshake <i class="fa fa-fw {{.Session.GetSortIcon "handshake"}}"></i></a></th>
<th scope="col"><a href="?sort=id">Identifier <i class="fa fa-fw {{.Session.GetSortIcon "peers" "id"}}"></i></a></th>
<th scope="col"><a href="?sort=pubKey">Public Key <i class="fa fa-fw {{.Session.GetSortIcon "peers" "pubKey"}}"></i></a></th>
<th scope="col"><a href="?sort=mail">E-Mail <i class="fa fa-fw {{.Session.GetSortIcon "peers" "mail"}}"></i></a></th>
<th scope="col"><a href="?sort=ip">IP's <i class="fa fa-fw {{.Session.GetSortIcon "peers" "ip"}}"></i></a></th>
<th scope="col"><a href="?sort=handshake">Handshake <i class="fa fa-fw {{.Session.GetSortIcon "peers" "handshake"}}"></i></a></th>
<th scope="col"></th><!-- Actions -->
</tr>
</thead>
@@ -144,15 +142,14 @@
<div class="tab-content" id="tabContent{{$p.UID}}">
<div id="t1{{$p.UID}}" class="tab-pane fade active show">
<h4>User details</h4>
{{if not $p.LdapUser}}
<p>No LDAP user-information available...</p>
{{if not $p.User}}
<p>No user information available...</p>
{{else}}
<ul>
<li>Firstname: {{$p.LdapUser.Firstname}}</li>
<li>Lastname: {{$p.LdapUser.Lastname}}</li>
<li>Phone: {{index $p.LdapUser.RawLdapData.Attributes "telephoneNumber"}}</li>
<li>Mail: {{$p.LdapUser.Mail}}</li>
<li>Department: {{index $p.LdapUser.RawLdapData.Attributes "department"}}</li>
<li>Firstname: {{$p.User.Firstname}}</li>
<li>Lastname: {{$p.User.Lastname}}</li>
<li>Phone: {{$p.User.Phone}}</li>
<li>Mail: {{$p.User.Email}}</li>
</ul>
{{end}}
<h4>Connection / Traffic</h4>

View File

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

View File

@@ -170,7 +170,7 @@
<td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated using WireGuard Portal.</td>
</tr>
<tr>
<td class="text-footer2" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="{{.PortalUrl}}" target="_blank" class="link" style="color:#000000; text-decoration:none;"><span class="link" style="color:#000000; text-decoration:none;">Visit WireGuard Portal</span></a></td>
<td class="text-footer2" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="{{.PortalUrl}}" target="_blank" rel="noopener noreferrer" class="link" style="color:#000000; text-decoration:none;"><span class="link" style="color:#000000; text-decoration:none;">Visit WireGuard Portal</span></a></td>
</tr>
</table>
</td>

View File

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

View File

@@ -20,9 +20,8 @@
<div class="card-body">
<form class="form-signin" method="post">
<div class="form-group">
<label for="inputUsername">Username</label>
<input type="text" name="username" class="form-control" id="inputUsername" aria-describedby="usernameHelp" placeholder="Enter username">
<small id="usernameHelp" class="form-text text-muted">Please enter your LDAP username, not the email address.</small>
<label for="inputUsername">Email</label>
<input type="text" name="username" class="form-control" id="inputUsername" aria-describedby="usernameHelp" placeholder="Enter email">
</div>
<div class="form-group">
<label for="inputPassword">Password</label>

View File

@@ -1,5 +1,5 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#topNavbar" aria-controls="navbarTogglerDemo03" aria-expanded="false" aria-label="Toggle navigation">
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#topNavbar" aria-controls="topNavbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
@@ -7,19 +7,28 @@
<div id="topNavbar" class="navbar-collapse collapse">
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
<li class="nav-spacer"></li>
{{with eq $.Session.LoggedIn true}}{{with eq $.Session.IsAdmin true}}{{with eq $.Route "/admin/"}}
{{with eq $.Session.LoggedIn true}}{{with eq $.Session.IsAdmin true}}
{{with eq $.Route "/admin/"}}
<form class="form-inline my-2 my-lg-0" method="get">
<input class="form-control mr-sm-2" name="search" type="search" placeholder="Search" aria-label="Search" value="{{$.Session.Search}}">
<input class="form-control mr-sm-2" name="search" type="search" placeholder="Search" aria-label="Search" value="{{index $.Session.Search "peers"}}">
<button class="btn btn-outline-success my-2 my-sm-0" type="submit"><i class="fa fa-search"></i></button>
</form>
{{end}}{{end}}{{end}}
{{end}}
{{with eq $.Route "/admin/users/"}}
<form class="form-inline my-2 my-lg-0" method="get">
<input class="form-control mr-sm-2" name="search" type="search" placeholder="Search" aria-label="Search" value="{{index $.Session.Search "users"}}">
<button class="btn btn-outline-success my-2 my-sm-0" type="submit"><i class="fa fa-search"></i></button>
</form>
{{end}}
{{end}}{{end}}
</ul>
{{if eq $.Session.LoggedIn true}}
<div class="nav-item dropdown">
<a href="#" class="navbar-text dropdown-toggle" data-toggle="dropdown">{{$.Session.Firstname}} {{$.Session.Lastname}} <span class="caret"></span></a>
<div class="dropdown-menu">
{{with eq $.Session.LoggedIn true}}{{with eq $.Session.IsAdmin true}}
<a class="dropdown-item" href="/admin/"><i class="fas fa-file-export"></i> Administration</a>
<a class="dropdown-item" href="/admin/"><i class="fas fa-cogs"></i> Administration</a>
<a class="dropdown-item" href="/admin/users/"><i class="fas fa-users-cog"></i> User Management</a>
<div class="dropdown-divider"></div>
{{end}}{{end}}
<a class="dropdown-item" href="/user/profile"><i class="fas fa-user"></i> Profile</a>

View File

@@ -21,11 +21,11 @@
<thead>
<tr>
<th scope="col" class="list-image-cell"></th><!-- Status and expand -->
<th scope="col"><a href="?sort=id">Identifier <i class="fa fa-fw {{.Session.GetSortIcon "id"}}"></i></a></th>
<th scope="col"><a href="?sort=pubKey">Public Key <i class="fa fa-fw {{.Session.GetSortIcon "pubKey"}}"></i></a></th>
<th scope="col"><a href="?sort=mail">E-Mail <i class="fa fa-fw {{.Session.GetSortIcon "mail"}}"></i></a></th>
<th scope="col"><a href="?sort=ip">IP's <i class="fa fa-fw {{.Session.GetSortIcon "ip"}}"></i></a></th>
<th scope="col"><a href="?sort=handshake">Handshake <i class="fa fa-fw {{.Session.GetSortIcon "handshake"}}"></i></a></th>
<th scope="col"><a href="?sort=id">Identifier <i class="fa fa-fw {{.Session.GetSortIcon "userpeers" "id"}}"></i></a></th>
<th scope="col"><a href="?sort=pubKey">Public Key <i class="fa fa-fw {{.Session.GetSortIcon "userpeers" "pubKey"}}"></i></a></th>
<th scope="col"><a href="?sort=mail">E-Mail <i class="fa fa-fw {{.Session.GetSortIcon "userpeers" "mail"}}"></i></a></th>
<th scope="col"><a href="?sort=ip">IP's <i class="fa fa-fw {{.Session.GetSortIcon "userpeers" "ip"}}"></i></a></th>
<th scope="col"><a href="?sort=handshake">Handshake <i class="fa fa-fw {{.Session.GetSortIcon "userpeers" "handshake"}}"></i></a></th>
</tr>
</thead>
<tbody>
@@ -58,15 +58,14 @@
<div class="tab-content" id="tabContent{{$p.UID}}">
<div id="t1{{$p.UID}}" class="tab-pane fade active show">
<h4>User details</h4>
{{if not $p.LdapUser}}
<p>No LDAP user-information available...</p>
{{if not $p.User}}
<p>No user information available...</p>
{{else}}
<ul>
<li>Firstname: {{$p.LdapUser.Firstname}}</li>
<li>Lastname: {{$p.LdapUser.Lastname}}</li>
<li>Phone: {{$p.UID}}</li>
<li>Mail: {{$p.LdapUser.Mail}}</li>
<li>Department: {{$p.UID}}</li>
<li>Firstname: {{$p.User.Firstname}}</li>
<li>Lastname: {{$p.User.Lastname}}</li>
<li>Phone: {{$p.User.Phone}}</li>
<li>Mail: {{$p.User.Email}}</li>
</ul>
{{end}}
<h4>Traffic</h4>

View File

@@ -1,19 +1,100 @@
package main
import (
"context"
"io/ioutil"
"os"
"os/signal"
"syscall"
"time"
"github.com/h44z/wg-portal/internal/server"
log "github.com/sirupsen/logrus"
"github.com/sirupsen/logrus"
)
var Version = "unknown (local build)"
func main() {
log.Infof("Starting WireGuard Portal Server...")
_ = setupLogger(logrus.StandardLogger())
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
logrus.Infof("starting WireGuard Portal Server [%s]...", Version)
// Context for clean shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := server.Server{}
if err := service.Setup(); err != nil {
log.Fatalf("Setup failed: %v", err)
if err := service.Setup(ctx); err != nil {
logrus.Fatalf("setup failed: %v", err)
}
service.Run()
// Attach signal handlers to context
go func() {
osCall := <-c
logrus.Tracef("received system call: %v", osCall)
cancel() // cancel the context
}()
log.Infof("Stopped WireGuard Portal Server...")
// Start main process in background
go service.Run()
<-ctx.Done() // Wait until the context gets canceled
// Give goroutines some time to stop gracefully
logrus.Info("stopping WireGuard Portal Server...")
time.Sleep(2 * time.Second)
logrus.Infof("stopped WireGuard Portal Server...")
logrus.Exit(0)
}
func setupLogger(logger *logrus.Logger) error {
// Check environment variables for logrus settings
level, ok := os.LookupEnv("LOG_LEVEL")
if !ok {
level = "debug" // Default logrus level
}
useJSON, ok := os.LookupEnv("LOG_JSON")
if !ok {
useJSON = "false" // Default use human readable logging
}
useColor, ok := os.LookupEnv("LOG_COLOR")
if !ok {
useColor = "true"
}
switch level {
case "off":
logger.SetOutput(ioutil.Discard)
case "info":
logger.SetLevel(logrus.InfoLevel)
case "debug":
logger.SetLevel(logrus.DebugLevel)
case "trace":
logger.SetLevel(logrus.TraceLevel)
}
var formatter logrus.Formatter
if useJSON == "false" {
f := new(logrus.TextFormatter)
f.TimestampFormat = "2006-01-02 15:04:05"
f.FullTimestamp = true
if useColor == "true" {
f.ForceColors = true
}
formatter = f
} else {
f := new(logrus.JSONFormatter)
f.TimestampFormat = "2006-01-02 15:04:05"
formatter = f
}
logger.SetFormatter(formatter)
return nil
}

12
efs.go Normal file
View File

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

10
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/h44z/wg-portal
go 1.14
go 1.16
require (
github.com/gin-contrib/sessions v0.0.3
@@ -11,11 +11,17 @@ require (
github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible
github.com/kelseyhightower/envconfig v1.4.0
github.com/milosgajdos/tenus v0.0.3
github.com/mitchellh/gox v1.0.1 // indirect
github.com/necrose99/gox v0.4.0 // indirect
github.com/pkg/errors v0.9.1
github.com/sirupsen/logrus v1.7.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/tatsushid/go-fastping v0.0.0-20160109021039-d7bb493dee3e
github.com/toorop/gin-logrus v0.0.0-20200831135515-d2ee50d38dae
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20200609130330-bd2cb7843e1b
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c
gorm.io/driver/mysql v1.0.4
gorm.io/driver/sqlite v1.1.3
gorm.io/gorm v1.20.5
gorm.io/gorm v1.20.12
)

View File

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

View File

@@ -0,0 +1,203 @@
package ldap
import (
"crypto/tls"
"fmt"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
"github.com/h44z/wg-portal/internal/authentication"
ldapconfig "github.com/h44z/wg-portal/internal/ldap"
"github.com/h44z/wg-portal/internal/users"
"github.com/pkg/errors"
)
// Provider implements a password login method for an LDAP backend.
type Provider struct {
config *ldapconfig.Config
}
func New(cfg *ldapconfig.Config) (*Provider, error) {
p := &Provider{
config: cfg,
}
// test ldap connectivity
client, err := p.open()
if err != nil {
return nil, errors.Wrap(err, "unable to open ldap connection")
}
defer p.close(client)
return p, nil
}
// GetName return provider name
func (Provider) GetName() string {
return string(users.UserSourceLdap)
}
// GetType return provider type
func (Provider) GetType() authentication.AuthProviderType {
return authentication.AuthProviderTypePassword
}
// GetPriority return provider priority
func (Provider) GetPriority() int {
return 1 // LDAP password provider
}
func (provider Provider) SetupRoutes(routes *gin.RouterGroup) {
// nothing todo here
}
func (provider Provider) Login(ctx *authentication.AuthContext) (string, error) {
username := strings.ToLower(ctx.Username)
password := ctx.Password
// Validate input
if strings.Trim(username, " ") == "" || strings.Trim(password, " ") == "" {
return "", errors.New("empty username or password")
}
client, err := provider.open()
if err != nil {
return "", errors.Wrap(err, "unable to open ldap connection")
}
defer provider.close(client)
// Search for the given username
attrs := []string{"dn", provider.config.EmailAttribute}
if provider.config.DisabledAttribute != "" {
attrs = append(attrs, provider.config.DisabledAttribute)
}
searchRequest := ldap.NewSearchRequest(
provider.config.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&(objectClass=%s)(%s=%s))", provider.config.UserClass, provider.config.EmailAttribute, username),
attrs,
nil,
)
sr, err := client.Search(searchRequest)
if err != nil {
return "", errors.Wrap(err, "unable to find user in ldap")
}
if len(sr.Entries) != 1 {
return "", errors.Errorf("invalid amount of ldap entries (%d)", len(sr.Entries))
}
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
err = client.Bind(userDN, password)
if err != nil {
return "", errors.Wrapf(err, "invalid credentials")
}
return sr.Entries[0].GetAttributeValue(provider.config.EmailAttribute), nil
}
func (provider Provider) Logout(context *authentication.AuthContext) error {
return nil // nothing todo here
}
func (provider Provider) GetUserModel(ctx *authentication.AuthContext) (*authentication.User, error) {
username := strings.ToLower(ctx.Username)
// Validate input
if strings.Trim(username, " ") == "" {
return nil, errors.New("empty username")
}
client, err := provider.open()
if err != nil {
return nil, errors.Wrap(err, "unable to open ldap connection")
}
defer provider.close(client)
// Search for the given username
attrs := []string{"dn", provider.config.EmailAttribute, provider.config.FirstNameAttribute, provider.config.LastNameAttribute,
provider.config.PhoneAttribute, provider.config.GroupMemberAttribute}
if provider.config.DisabledAttribute != "" {
attrs = append(attrs, provider.config.DisabledAttribute)
}
searchRequest := ldap.NewSearchRequest(
provider.config.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&(objectClass=%s)(%s=%s))", provider.config.UserClass, provider.config.EmailAttribute, username),
attrs,
nil,
)
sr, err := client.Search(searchRequest)
if err != nil {
return nil, errors.Wrap(err, "unable to find user in ldap")
}
if len(sr.Entries) != 1 {
return nil, errors.Wrapf(err, "invalid amount of ldap entries (%d)", len(sr.Entries))
}
user := &authentication.User{
Firstname: sr.Entries[0].GetAttributeValue(provider.config.FirstNameAttribute),
Lastname: sr.Entries[0].GetAttributeValue(provider.config.LastNameAttribute),
Email: sr.Entries[0].GetAttributeValue(provider.config.EmailAttribute),
Phone: sr.Entries[0].GetAttributeValue(provider.config.PhoneAttribute),
IsAdmin: false,
}
for _, group := range sr.Entries[0].GetAttributeValues(provider.config.GroupMemberAttribute) {
if group == provider.config.AdminLdapGroup {
user.IsAdmin = true
break
}
}
return user, nil
}
func (provider Provider) open() (*ldap.Conn, error) {
conn, err := ldap.DialURL(provider.config.URL)
if err != nil {
return nil, err
}
if provider.config.StartTLS {
// Reconnect with TLS
err = conn.StartTLS(&tls.Config{InsecureSkipVerify: true})
if err != nil {
return nil, err
}
}
err = conn.Bind(provider.config.BindUser, provider.config.BindPass)
if err != nil {
return nil, err
}
return conn, nil
}
func (provider Provider) close(conn *ldap.Conn) {
if conn != nil {
conn.Close()
}
}

View File

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

View File

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

View File

@@ -1,22 +1,22 @@
package common
import (
"errors"
"os"
"reflect"
"runtime"
"github.com/h44z/wg-portal/internal/wireguard"
"github.com/h44z/wg-portal/internal/ldap"
"github.com/h44z/wg-portal/internal/users"
"github.com/h44z/wg-portal/internal/wireguard"
"github.com/kelseyhightower/envconfig"
log "github.com/sirupsen/logrus"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
var ErrInvalidSpecification = errors.New("specification must be a struct pointer")
// LoadConfigFile parses yaml files. It uses to yaml annotation to store the data in a struct.
// loadConfigFile parses yaml files. It uses yaml annotation to store the data in a struct.
func loadConfigFile(cfg interface{}, filename string) error {
s := reflect.ValueOf(cfg)
@@ -30,24 +30,24 @@ func loadConfigFile(cfg interface{}, filename string) error {
f, err := os.Open(filename)
if err != nil {
return err
return errors.Wrapf(err, "failed to open config file %s", filename)
}
defer f.Close()
decoder := yaml.NewDecoder(f)
err = decoder.Decode(cfg)
if err != nil {
return err
return errors.Wrapf(err, "failed to decode config file %s", filename)
}
return nil
}
// LoadConfigEnv processes envconfig annotations and loads environment variables to the given configuration struct.
// loadConfigEnv processes envconfig annotations and loads environment variables to the given configuration struct.
func loadConfigEnv(cfg interface{}) error {
err := envconfig.Process("", cfg)
if err != nil {
return err
return errors.Wrap(err, "failed to process environment config")
}
return nil
@@ -55,22 +55,21 @@ func loadConfigEnv(cfg interface{}) error {
type Config struct {
Core struct {
ListeningAddress string `yaml:"listeningAddress" envconfig:"LISTENING_ADDRESS"`
ExternalUrl string `yaml:"externalUrl" envconfig:"EXTERNAL_URL"`
Title string `yaml:"title" envconfig:"WEBSITE_TITLE"`
CompanyName string `yaml:"company" envconfig:"COMPANY_NAME"`
MailFrom string `yaml:"mailfrom" envconfig:"MAIL_FROM"`
AdminUser string `yaml:"adminUser" envconfig:"ADMIN_USER"` // optional, non LDAP admin user
AdminPassword string `yaml:"adminPass" envconfig:"ADMIN_PASS"`
DatabasePath string `yaml:"database" envconfig:"DATABASE_PATH"`
EditableKeys bool `yaml:"editableKeys" envconfig:"EDITABLE_KEYS"`
CreateInterfaceOnLogin bool `yaml:"createOnLogin" envconfig:"CREATE_INTERFACE_ON_LOGIN"`
SyncLdapStatus bool `yaml:"syncLdapStatus" envconfig:"SYNC_LDAP_STATUS"` // disable account if disabled in ldap
ListeningAddress string `yaml:"listeningAddress" envconfig:"LISTENING_ADDRESS"`
ExternalUrl string `yaml:"externalUrl" envconfig:"EXTERNAL_URL"`
Title string `yaml:"title" envconfig:"WEBSITE_TITLE"`
CompanyName string `yaml:"company" envconfig:"COMPANY_NAME"`
MailFrom string `yaml:"mailFrom" envconfig:"MAIL_FROM"`
AdminUser string `yaml:"adminUser" envconfig:"ADMIN_USER"` // must be an email address
AdminPassword string `yaml:"adminPass" envconfig:"ADMIN_PASS"`
EditableKeys bool `yaml:"editableKeys" envconfig:"EDITABLE_KEYS"`
CreateDefaultPeer bool `yaml:"createDefaultPeer" envconfig:"CREATE_DEFAULT_PEER"`
LdapEnabled bool `yaml:"ldapEnabled" envconfig:"LDAP_ENABLED"`
} `yaml:"core"`
Email MailConfig `yaml:"email"`
LDAP ldap.Config `yaml:"ldap"`
WG wireguard.Config `yaml:"wg"`
AdminLdapGroup string `yaml:"adminLdapGroup" envconfig:"ADMIN_LDAP_GROUP"`
Database users.Config `yaml:"database"`
Email MailConfig `yaml:"email"`
LDAP ldap.Config `yaml:"ldap"`
WG wireguard.Config `yaml:"wg"`
}
func NewConfig() *Config {
@@ -82,18 +81,31 @@ func NewConfig() *Config {
cfg.Core.CompanyName = "WireGuard Portal"
cfg.Core.ExternalUrl = "http://localhost:8123"
cfg.Core.MailFrom = "WireGuard VPN <noreply@company.com>"
cfg.Core.AdminUser = "" // non-ldap admin access is disabled by default
cfg.Core.AdminPassword = ""
cfg.Core.DatabasePath = "data/wg_portal.db"
cfg.Core.AdminUser = "admin@wgportal.local"
cfg.Core.AdminPassword = "wgportal"
cfg.Core.LdapEnabled = false
cfg.Database.Typ = "sqlite"
cfg.Database.Database = "data/wg_portal.db"
cfg.LDAP.URL = "ldap://srv-ad01.company.local:389"
cfg.LDAP.BaseDN = "DC=COMPANY,DC=LOCAL"
cfg.LDAP.StartTLS = true
cfg.LDAP.BindUser = "company\\\\ldap_wireguard"
cfg.LDAP.BindPass = "SuperSecret"
cfg.LDAP.Type = "AD"
cfg.LDAP.UserClass = "organizationalPerson"
cfg.LDAP.EmailAttribute = "mail"
cfg.LDAP.FirstNameAttribute = "givenName"
cfg.LDAP.LastNameAttribute = "sn"
cfg.LDAP.PhoneAttribute = "telephoneNumber"
cfg.LDAP.GroupMemberAttribute = "memberOf"
cfg.LDAP.DisabledAttribute = "userAccountControl"
cfg.LDAP.AdminLdapGroup = "CN=WireGuardAdmins,OU=_O_IT,DC=COMPANY,DC=LOCAL"
cfg.WG.DeviceName = "wg0"
cfg.WG.WireGuardConfig = "/etc/wireguard/wg0.conf"
cfg.WG.ManageIPAddresses = true
cfg.AdminLdapGroup = "CN=WireGuardAdmins,OU=_O_IT,DC=COMPANY,DC=LOCAL"
cfg.Email.Host = "127.0.0.1"
cfg.Email.Port = 25
@@ -104,15 +116,15 @@ func NewConfig() *Config {
}
err := loadConfigFile(cfg, cfgFile)
if err != nil {
log.Warnf("unable to load config.yml file: %v, using default configuration...", err)
logrus.Warnf("unable to load config.yml file: %v, using default configuration...", err)
}
err = loadConfigEnv(cfg)
if err != nil {
log.Warnf("unable to load environment config: %v", err)
logrus.Warnf("unable to load environment config: %v", err)
}
if cfg.WG.ManageIPAddresses && runtime.GOOS != "linux" {
log.Warnf("Managing IP addresses only works on linux! Feature disabled.")
logrus.Warnf("managing IP addresses only works on linux, feature disabled...")
cfg.WG.ManageIPAddresses = false
}

View File

@@ -26,7 +26,7 @@ type MailAttachment struct {
Embedded bool
}
// SendEmailWithAttachments sends a mail with attachments.
// SendEmailWithAttachments sends a mail with optional attachments.
func SendEmailWithAttachments(cfg MailConfig, sender, replyTo, subject, body string, htmlBody string, receivers []string, attachments []MailAttachment) error {
e := email.NewEmail()

View File

@@ -40,6 +40,8 @@ func IsIPv6(address string) bool {
return ip.To4() == nil
}
// ParseStringList converts a comma separated string into a list of strings.
// It also trims spaces from each element of the list.
func ParseStringList(lst string) []string {
tokens := strings.Split(lst, ",")
validatedTokens := make([]string, 0, len(tokens))
@@ -53,6 +55,7 @@ func ParseStringList(lst string) []string {
return validatedTokens
}
// ListToString converts a list of strings into a comma separated string.
func ListToString(lst []string) string {
return strings.Join(lst, ", ")
}

View File

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

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

@@ -0,0 +1,27 @@
package ldap
type Type string
const (
TypeActiveDirectory Type = "AD"
TypeOpenLDAP Type = "OpenLDAP"
)
type Config struct {
URL string `yaml:"url" envconfig:"LDAP_URL"`
StartTLS bool `yaml:"startTLS" envconfig:"LDAP_STARTTLS"`
BaseDN string `yaml:"dn" envconfig:"LDAP_BASEDN"`
BindUser string `yaml:"user" envconfig:"LDAP_USER"`
BindPass string `yaml:"pass" envconfig:"LDAP_PASSWORD"`
Type Type `yaml:"typ" envconfig:"LDAP_TYPE"` // AD for active directory, OpenLDAP for OpenLDAP
UserClass string `yaml:"userClass" envconfig:"LDAP_USER_CLASS"`
EmailAttribute string `yaml:"attrEmail" envconfig:"LDAP_ATTR_EMAIL"`
FirstNameAttribute string `yaml:"attrFirstname" envconfig:"LDAP_ATTR_FIRSTNAME"`
LastNameAttribute string `yaml:"attrLastname" envconfig:"LDAP_ATTR_LASTNAME"`
PhoneAttribute string `yaml:"attrPhone" envconfig:"LDAP_ATTR_PHONE"`
GroupMemberAttribute string `yaml:"attrGroups" envconfig:"LDAP_ATTR_GROUPS"`
DisabledAttribute string `yaml:"attrDisabled" envconfig:"LDAP_ATTR_DISABLED"`
AdminLdapGroup string `yaml:"adminGroup" envconfig:"LDAP_ADMIN_GROUP"` // Members of this group receive admin rights in WG-Portal
}

View File

@@ -1,9 +1,112 @@
package ldap
type Config struct {
URL string `yaml:"url" envconfig:"LDAP_URL"`
StartTLS bool `yaml:"startTLS" envconfig:"LDAP_STARTTLS"`
BaseDN string `yaml:"dn" envconfig:"LDAP_BASEDN"`
BindUser string `yaml:"user" envconfig:"LDAP_USER"`
BindPass string `yaml:"pass" envconfig:"LDAP_PASSWORD"`
import (
"crypto/tls"
"fmt"
"strconv"
"github.com/go-ldap/ldap/v3"
"github.com/pkg/errors"
)
type RawLdapData struct {
DN string
Attributes map[string]string
RawAttributes map[string][][]byte
}
func Open(cfg *Config) (*ldap.Conn, error) {
conn, err := ldap.DialURL(cfg.URL)
if err != nil {
return nil, errors.Wrap(err, "failed to connect to LDAP")
}
if cfg.StartTLS {
// Reconnect with TLS
err = conn.StartTLS(&tls.Config{InsecureSkipVerify: true})
if err != nil {
return nil, errors.Wrap(err, "failed to star TLS on connection")
}
}
err = conn.Bind(cfg.BindUser, cfg.BindPass)
if err != nil {
return nil, errors.Wrap(err, "failed to bind to LDAP")
}
return conn, nil
}
func Close(conn *ldap.Conn) {
if conn != nil {
conn.Close()
}
}
func FindAllUsers(cfg *Config) ([]RawLdapData, error) {
client, err := Open(cfg)
if err != nil {
return nil, errors.WithMessage(err, "failed to open ldap connection")
}
defer Close(client)
// Search all users
attrs := []string{"dn", cfg.EmailAttribute, cfg.EmailAttribute, cfg.FirstNameAttribute, cfg.LastNameAttribute,
cfg.PhoneAttribute, cfg.GroupMemberAttribute}
if cfg.DisabledAttribute != "" {
attrs = append(attrs, cfg.DisabledAttribute)
}
searchRequest := ldap.NewSearchRequest(
cfg.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(objectClass=%s)", cfg.UserClass), attrs, nil,
)
sr, err := client.Search(searchRequest)
if err != nil {
return nil, errors.Wrapf(err, "failed to search in ldap")
}
tmpData := make([]RawLdapData, 0, len(sr.Entries))
for _, entry := range sr.Entries {
tmp := RawLdapData{
DN: entry.DN,
Attributes: make(map[string]string, len(attrs)),
RawAttributes: make(map[string][][]byte, len(attrs)),
}
for _, field := range attrs {
tmp.Attributes[field] = entry.GetAttributeValue(field)
tmp.RawAttributes[field] = entry.GetRawAttributeValues(field)
}
tmpData = append(tmpData, tmp)
}
return tmpData, nil
}
func IsActiveDirectoryUserDisabled(userAccountControl string) bool {
if userAccountControl == "" {
return false
}
uacInt, err := strconv.Atoi(userAccountControl)
if err != nil {
return true
}
if int32(uacInt)&0x2 != 0 {
return true // bit 2 set means account is disabled
}
return false
}
func IsOpenLdapUserDisabled(pwdAccountLockedTime string) bool {
if pwdAccountLockedTime != "" {
return true
}
return false
}

View File

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

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

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

View File

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

View File

@@ -4,13 +4,14 @@ import (
"net/http"
"strings"
log "github.com/sirupsen/logrus"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/authentication"
"github.com/h44z/wg-portal/internal/users"
"github.com/sirupsen/logrus"
)
func (s *Server) GetLogin(c *gin.Context) {
currentSession := s.getSessionData(c)
currentSession := GetSessionData(c)
if currentSession.LoggedIn {
c.Redirect(http.StatusSeeOther, "/") // already logged in
}
@@ -34,7 +35,7 @@ func (s *Server) GetLogin(c *gin.Context) {
}
func (s *Server) PostLogin(c *gin.Context) {
currentSession := s.getSessionData(c)
currentSession := GetSessionData(c)
if currentSession.LoggedIn {
// already logged in
c.Redirect(http.StatusSeeOther, "/")
@@ -50,69 +51,84 @@ func (s *Server) PostLogin(c *gin.Context) {
return
}
adminAuthenticated := false
if s.config.Core.AdminUser != "" && username == s.config.Core.AdminUser && password == s.config.Core.AdminPassword {
adminAuthenticated = true
// 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
for _, provider := range s.auth.GetProvidersForType(authentication.AuthProviderTypePassword) {
// try to log in to the given provider
authEmail, err := provider.Login(&authentication.AuthContext{
Username: username,
Password: password,
})
if err != nil {
continue
}
email = authEmail
loginProvider = provider
// create new user in the database (or reactivate him)
userData, err := loginProvider.GetUserModel(&authentication.AuthContext{
Username: email,
})
if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "login error", err.Error())
return
}
if err := s.CreateUser(users.User{
Email: userData.Email,
Source: users.UserSource(loginProvider.GetName()),
IsAdmin: userData.IsAdmin,
Firstname: userData.Firstname,
Lastname: userData.Lastname,
Phone: userData.Phone,
}); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "login error", "failed to update user data")
return
}
user = s.users.GetUser(username)
break
}
}
// Check if user is in cache, avoid unnecessary ldap requests
if !adminAuthenticated && !s.ldapUsers.UserExists(username) {
c.Redirect(http.StatusSeeOther, "/auth/login?err=authfail")
}
// Check if username and password match
if !adminAuthenticated && !s.ldapAuth.CheckLogin(username, password) {
// Check if user is authenticated
if email == "" || loginProvider == nil || user == nil {
c.Redirect(http.StatusSeeOther, "/auth/login?err=authfail")
return
}
var sessionData SessionData
if adminAuthenticated {
sessionData = SessionData{
LoggedIn: true,
IsAdmin: true,
Email: "autodetected@example.com",
UID: "adminuid",
UserName: username,
Firstname: "System",
Lastname: "Administrator",
SortedBy: "mail",
SortDirection: "asc",
Search: "",
}
} else {
dn := s.ldapUsers.GetUserDN(username)
userData := s.ldapUsers.GetUserData(dn)
sessionData = SessionData{
LoggedIn: true,
IsAdmin: s.ldapUsers.IsInGroup(username, s.config.AdminLdapGroup),
UID: userData.GetUID(),
UserName: username,
Email: userData.Mail,
Firstname: userData.Firstname,
Lastname: userData.Lastname,
SortedBy: "mail",
SortDirection: "asc",
Search: "",
}
}
// Set authenticated session
sessionData := GetSessionData(c)
sessionData.LoggedIn = true
sessionData.IsAdmin = user.IsAdmin
sessionData.Email = user.Email
sessionData.Firstname = user.Firstname
sessionData.Lastname = user.Lastname
// Check if user already has a peer setup, if not create one
if s.config.Core.CreateInterfaceOnLogin && !adminAuthenticated {
users := s.users.GetUsersByMail(sessionData.Email)
if len(users) == 0 { // Create vpn peer
err := s.CreateUser(User{
Identifier: sessionData.Firstname + " " + sessionData.Lastname + " (Default)",
Email: sessionData.Email,
CreatedBy: sessionData.Email,
UpdatedBy: sessionData.Email,
})
log.Errorf("Failed to automatically create vpn peer for %s: %v", sessionData.Email, err)
}
if err := s.CreateUserDefaultPeer(user.Email); err != nil {
// Not a fatal error, just log it...
logrus.Errorf("failed to automatically create vpn peer for %s: %v", sessionData.Email, err)
}
if err := s.updateSessionData(c, sessionData); err != nil {
if err := UpdateSessionData(c, sessionData); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "login error", "failed to save session")
return
}
@@ -120,14 +136,14 @@ func (s *Server) PostLogin(c *gin.Context) {
}
func (s *Server) GetLogout(c *gin.Context) {
currentSession := s.getSessionData(c)
currentSession := GetSessionData(c)
if !currentSession.LoggedIn { // Not logged in
c.Redirect(http.StatusSeeOther, "/")
return
}
if err := s.destroySessionData(c); err != nil {
if err := DestroySessionData(c); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "logout error", "failed to destroy session")
return
}

View File

@@ -4,6 +4,8 @@ import (
"net/http"
"strconv"
"github.com/pkg/errors"
"github.com/gin-gonic/gin"
)
@@ -15,7 +17,7 @@ func (s *Server) GetHandleError(c *gin.Context, code int, message, details strin
"Details": details,
},
"Route": c.Request.URL.Path,
"Session": s.getSessionData(c),
"Session": GetSessionData(c),
"Static": s.getStaticData(),
})
}
@@ -29,90 +31,88 @@ func (s *Server) GetIndex(c *gin.Context) {
Device Device
}{
Route: c.Request.URL.Path,
Alerts: s.getFlashes(c),
Session: s.getSessionData(c),
Alerts: GetFlashes(c),
Session: GetSessionData(c),
Static: s.getStaticData(),
Device: s.users.GetDevice(),
Device: s.peers.GetDevice(),
})
}
func (s *Server) GetAdminIndex(c *gin.Context) {
currentSession := s.getSessionData(c)
currentSession := GetSessionData(c)
sort := c.Query("sort")
if sort != "" {
if currentSession.SortedBy != sort {
currentSession.SortedBy = sort
currentSession.SortDirection = "asc"
if currentSession.SortedBy["peers"] != sort {
currentSession.SortedBy["peers"] = sort
currentSession.SortDirection["peers"] = "asc"
} else {
if currentSession.SortDirection == "asc" {
currentSession.SortDirection = "desc"
if currentSession.SortDirection["peers"] == "asc" {
currentSession.SortDirection["peers"] = "desc"
} else {
currentSession.SortDirection = "asc"
currentSession.SortDirection["peers"] = "asc"
}
}
if err := s.updateSessionData(c, currentSession); err != nil {
if err := UpdateSessionData(c, currentSession); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "sort error", "failed to save session")
return
}
c.Redirect(http.StatusSeeOther, "/admin")
c.Redirect(http.StatusSeeOther, "/admin/")
return
}
search, searching := c.GetQuery("search")
if searching {
currentSession.Search = search
currentSession.Search["peers"] = search
if err := s.updateSessionData(c, currentSession); err != nil {
if err := UpdateSessionData(c, currentSession); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "search error", "failed to save session")
return
}
c.Redirect(http.StatusSeeOther, "/admin")
c.Redirect(http.StatusSeeOther, "/admin/")
return
}
device := s.users.GetDevice()
users := s.users.GetFilteredAndSortedUsers(currentSession.SortedBy, currentSession.SortDirection, currentSession.Search)
device := s.peers.GetDevice()
users := s.peers.GetFilteredAndSortedPeers(currentSession.SortedBy["peers"], currentSession.SortDirection["peers"], currentSession.Search["peers"])
c.HTML(http.StatusOK, "admin_index.html", struct {
Route string
Alerts []FlashData
Session SessionData
Static StaticData
Peers []User
TotalPeers int
Device Device
LdapDisabled bool
Route string
Alerts []FlashData
Session SessionData
Static StaticData
Peers []Peer
TotalPeers int
Device Device
}{
Route: c.Request.URL.Path,
Alerts: s.getFlashes(c),
Session: currentSession,
Static: s.getStaticData(),
Peers: users,
TotalPeers: len(s.users.GetAllUsers()),
Device: device,
LdapDisabled: s.ldapDisabled,
Route: c.Request.URL.Path,
Alerts: GetFlashes(c),
Session: currentSession,
Static: s.getStaticData(),
Peers: users,
TotalPeers: len(s.peers.GetAllPeers()),
Device: device,
})
}
func (s *Server) GetUserIndex(c *gin.Context) {
currentSession := s.getSessionData(c)
currentSession := GetSessionData(c)
sort := c.Query("sort")
if sort != "" {
if currentSession.SortedBy != sort {
currentSession.SortedBy = sort
currentSession.SortDirection = "asc"
if currentSession.SortedBy["userpeers"] != sort {
currentSession.SortedBy["userpeers"] = sort
currentSession.SortDirection["userpeers"] = "asc"
} else {
if currentSession.SortDirection == "asc" {
currentSession.SortDirection = "desc"
if currentSession.SortDirection["userpeers"] == "asc" {
currentSession.SortDirection["userpeers"] = "desc"
} else {
currentSession.SortDirection = "asc"
currentSession.SortDirection["userpeers"] = "asc"
}
}
if err := s.updateSessionData(c, currentSession); err != nil {
if err := UpdateSessionData(c, currentSession); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "sort error", "failed to save session")
return
}
@@ -120,20 +120,20 @@ func (s *Server) GetUserIndex(c *gin.Context) {
return
}
device := s.users.GetDevice()
users := s.users.GetSortedUsersForEmail(currentSession.SortedBy, currentSession.SortDirection, currentSession.Email)
device := s.peers.GetDevice()
users := s.peers.GetSortedPeersForEmail(currentSession.SortedBy["userpeers"], currentSession.SortDirection["userpeers"], currentSession.Email)
c.HTML(http.StatusOK, "user_index.html", struct {
Route string
Alerts []FlashData
Session SessionData
Static StaticData
Peers []User
Peers []Peer
TotalPeers int
Device Device
}{
Route: c.Request.URL.Path,
Alerts: s.getFlashes(c),
Alerts: GetFlashes(c),
Session: currentSession,
Static: s.getStaticData(),
Peers: users,
@@ -143,45 +143,45 @@ func (s *Server) GetUserIndex(c *gin.Context) {
}
func (s *Server) updateFormInSession(c *gin.Context, formData interface{}) error {
currentSession := s.getSessionData(c)
currentSession := GetSessionData(c)
currentSession.FormData = formData
if err := s.updateSessionData(c, currentSession); err != nil {
return err
if err := UpdateSessionData(c, currentSession); err != nil {
return errors.WithMessage(err, "failed to update form in session")
}
return nil
}
func (s *Server) setNewUserFormInSession(c *gin.Context) (SessionData, error) {
currentSession := s.getSessionData(c)
// If session does not contain a user form ignore update
func (s *Server) setNewPeerFormInSession(c *gin.Context) (SessionData, error) {
currentSession := GetSessionData(c)
// If session does not contain a peer form ignore update
// If url contains a formerr parameter reset the form
if currentSession.FormData == nil || c.Query("formerr") == "" {
user, err := s.PrepareNewUser()
user, err := s.PrepareNewPeer()
if err != nil {
return currentSession, err
return currentSession, errors.WithMessage(err, "failed to prepare new peer")
}
currentSession.FormData = user
}
if err := s.updateSessionData(c, currentSession); err != nil {
return currentSession, err
if err := UpdateSessionData(c, currentSession); err != nil {
return currentSession, errors.WithMessage(err, "failed to update peer form in session")
}
return currentSession, nil
}
func (s *Server) setFormInSession(c *gin.Context, formData interface{}) (SessionData, error) {
currentSession := s.getSessionData(c)
currentSession := GetSessionData(c)
// If session does not contain a form ignore update
// If url contains a formerr parameter reset the form
if currentSession.FormData == nil || c.Query("formerr") == "" {
currentSession.FormData = formData
}
if err := s.updateSessionData(c, currentSession); err != nil {
return currentSession, err
if err := UpdateSessionData(c, currentSession); err != nil {
return currentSession, errors.WithMessage(err, "failed to set form in session")
}
return currentSession, nil

View File

@@ -9,8 +9,8 @@ import (
)
func (s *Server) GetAdminEditInterface(c *gin.Context) {
device := s.users.GetDevice()
users := s.users.GetAllUsers()
device := s.peers.GetDevice()
users := s.peers.GetAllPeers()
currentSession, err := s.setFormInSession(c, device)
if err != nil {
@@ -23,12 +23,12 @@ func (s *Server) GetAdminEditInterface(c *gin.Context) {
Alerts []FlashData
Session SessionData
Static StaticData
Peers []User
Peers []Peer
Device Device
EditableKeys bool
}{
Route: c.Request.URL.Path,
Alerts: s.getFlashes(c),
Alerts: GetFlashes(c),
Session: currentSession,
Static: s.getStaticData(),
Peers: users,
@@ -38,14 +38,14 @@ func (s *Server) GetAdminEditInterface(c *gin.Context) {
}
func (s *Server) PostAdminEditInterface(c *gin.Context) {
currentSession := s.getSessionData(c)
currentSession := GetSessionData(c)
var formDevice Device
if currentSession.FormData != nil {
formDevice = currentSession.FormData.(Device)
}
if err := c.ShouldBind(&formDevice); err != nil {
_ = s.updateFormInSession(c, formDevice)
s.setFlashMessage(c, err.Error(), "danger")
SetFlashMessage(c, err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=bind")
return
}
@@ -58,19 +58,19 @@ func (s *Server) PostAdminEditInterface(c *gin.Context) {
formDevice.DNSStr = common.ListToString(formDevice.DNS)
// Update WireGuard device
err := s.wg.UpdateDevice(formDevice.DeviceName, formDevice.GetDeviceConfig())
err := s.wg.UpdateDevice(formDevice.DeviceName, formDevice.GetConfig())
if err != nil {
_ = s.updateFormInSession(c, formDevice)
s.setFlashMessage(c, "Failed to update device in WireGuard: "+err.Error(), "danger")
SetFlashMessage(c, "Failed to update device in WireGuard: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=wg")
return
}
// Update in database
err = s.users.UpdateDevice(formDevice)
err = s.peers.UpdateDevice(formDevice)
if err != nil {
_ = s.updateFormInSession(c, formDevice)
s.setFlashMessage(c, "Failed to update device in database: "+err.Error(), "danger")
SetFlashMessage(c, "Failed to update device in database: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=update")
return
}
@@ -79,7 +79,7 @@ func (s *Server) PostAdminEditInterface(c *gin.Context) {
err = s.WriteWireGuardConfigFile()
if err != nil {
_ = s.updateFormInSession(c, formDevice)
s.setFlashMessage(c, "Failed to update wireguard config-file: "+err.Error(), "danger")
SetFlashMessage(c, "Failed to update WireGuard config-file: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=update")
return
}
@@ -88,27 +88,27 @@ func (s *Server) PostAdminEditInterface(c *gin.Context) {
if s.config.WG.ManageIPAddresses {
if err := s.wg.SetIPAddress(formDevice.IPs); err != nil {
_ = s.updateFormInSession(c, formDevice)
s.setFlashMessage(c, "Failed to update ip address: "+err.Error(), "danger")
SetFlashMessage(c, "Failed to update ip address: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=update")
}
if err := s.wg.SetMTU(formDevice.Mtu); err != nil {
_ = s.updateFormInSession(c, formDevice)
s.setFlashMessage(c, "Failed to update MTU: "+err.Error(), "danger")
SetFlashMessage(c, "Failed to update MTU: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/device/edit?formerr=update")
}
}
s.setFlashMessage(c, "Changes applied successfully!", "success")
SetFlashMessage(c, "Changes applied successfully!", "success")
if !s.config.WG.ManageIPAddresses {
s.setFlashMessage(c, "WireGuard must be restarted to apply ip changes.", "warning")
SetFlashMessage(c, "WireGuard must be restarted to apply ip changes.", "warning")
}
c.Redirect(http.StatusSeeOther, "/admin/device/edit")
}
func (s *Server) GetInterfaceConfig(c *gin.Context) {
device := s.users.GetDevice()
users := s.users.GetActiveUsers()
cfg, err := device.GetDeviceConfigFile(users)
device := s.peers.GetDevice()
users := s.peers.GetActivePeers()
cfg, err := device.GetConfigFile(users)
if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "ConfigFile error", err.Error())
return
@@ -122,19 +122,19 @@ func (s *Server) GetInterfaceConfig(c *gin.Context) {
}
func (s *Server) GetApplyGlobalConfig(c *gin.Context) {
device := s.users.GetDevice()
users := s.users.GetAllUsers()
device := s.peers.GetDevice()
users := s.peers.GetAllPeers()
for _, user := range users {
user.AllowedIPs = device.AllowedIPs
user.AllowedIPsStr = device.AllowedIPsStr
if err := s.users.UpdateUser(user); err != nil {
s.setFlashMessage(c, err.Error(), "danger")
if err := s.peers.UpdatePeer(user); err != nil {
SetFlashMessage(c, err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/device/edit")
}
}
s.setFlashMessage(c, "Allowed ip's updated for all clients.", "success")
SetFlashMessage(c, "Allowed IP's updated for all clients.", "success")
c.Redirect(http.StatusSeeOther, "/admin/device/edit")
return
}

View File

@@ -10,8 +10,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/common"
"github.com/h44z/wg-portal/internal/ldap"
log "github.com/sirupsen/logrus"
"github.com/h44z/wg-portal/internal/users"
"github.com/sirupsen/logrus"
"github.com/tatsushid/go-fastping"
)
@@ -21,10 +21,10 @@ type LdapCreateForm struct {
}
func (s *Server) GetAdminEditPeer(c *gin.Context) {
device := s.users.GetDevice()
user := s.users.GetUserByKey(c.Query("pkey"))
device := s.peers.GetDevice()
peer := s.peers.GetPeerByKey(c.Query("pkey"))
currentSession, err := s.setFormInSession(c, user)
currentSession, err := s.setFormInSession(c, peer)
if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "Session error", err.Error())
return
@@ -35,66 +35,66 @@ func (s *Server) GetAdminEditPeer(c *gin.Context) {
Alerts []FlashData
Session SessionData
Static StaticData
Peer User
Peer Peer
Device Device
EditableKeys bool
}{
Route: c.Request.URL.Path,
Alerts: s.getFlashes(c),
Alerts: GetFlashes(c),
Session: currentSession,
Static: s.getStaticData(),
Peer: currentSession.FormData.(User),
Peer: currentSession.FormData.(Peer),
Device: device,
EditableKeys: s.config.Core.EditableKeys,
})
}
func (s *Server) PostAdminEditPeer(c *gin.Context) {
currentUser := s.users.GetUserByKey(c.Query("pkey"))
currentPeer := s.peers.GetPeerByKey(c.Query("pkey"))
urlEncodedKey := url.QueryEscape(c.Query("pkey"))
currentSession := s.getSessionData(c)
var formUser User
currentSession := GetSessionData(c)
var formPeer Peer
if currentSession.FormData != nil {
formUser = currentSession.FormData.(User)
formPeer = currentSession.FormData.(Peer)
}
if err := c.ShouldBind(&formUser); err != nil {
_ = s.updateFormInSession(c, formUser)
s.setFlashMessage(c, "failed to bind form data: "+err.Error(), "danger")
if err := c.ShouldBind(&formPeer); err != nil {
_ = s.updateFormInSession(c, formPeer)
SetFlashMessage(c, "failed to bind form data: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/peer/edit?pkey="+urlEncodedKey+"&formerr=bind")
return
}
// Clean list input
formUser.IPs = common.ParseStringList(formUser.IPsStr)
formUser.AllowedIPs = common.ParseStringList(formUser.AllowedIPsStr)
formUser.IPsStr = common.ListToString(formUser.IPs)
formUser.AllowedIPsStr = common.ListToString(formUser.AllowedIPs)
formPeer.IPs = common.ParseStringList(formPeer.IPsStr)
formPeer.AllowedIPs = common.ParseStringList(formPeer.AllowedIPsStr)
formPeer.IPsStr = common.ListToString(formPeer.IPs)
formPeer.AllowedIPsStr = common.ListToString(formPeer.AllowedIPs)
disabled := c.PostForm("isdisabled") != ""
now := time.Now()
if disabled && currentUser.DeactivatedAt == nil {
formUser.DeactivatedAt = &now
if disabled && currentPeer.DeactivatedAt == nil {
formPeer.DeactivatedAt = &now
} else if !disabled {
formUser.DeactivatedAt = nil
formPeer.DeactivatedAt = nil
}
// Update in database
if err := s.UpdateUser(formUser, now); err != nil {
_ = s.updateFormInSession(c, formUser)
s.setFlashMessage(c, "failed to update user: "+err.Error(), "danger")
if err := s.UpdatePeer(formPeer, now); err != nil {
_ = s.updateFormInSession(c, formPeer)
SetFlashMessage(c, "failed to update user: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/peer/edit?pkey="+urlEncodedKey+"&formerr=update")
return
}
s.setFlashMessage(c, "changes applied successfully", "success")
SetFlashMessage(c, "changes applied successfully", "success")
c.Redirect(http.StatusSeeOther, "/admin/peer/edit?pkey="+urlEncodedKey)
}
func (s *Server) GetAdminCreatePeer(c *gin.Context) {
device := s.users.GetDevice()
device := s.peers.GetDevice()
currentSession, err := s.setNewUserFormInSession(c)
currentSession, err := s.setNewPeerFormInSession(c)
if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "Session error", err.Error())
return
@@ -104,53 +104,53 @@ func (s *Server) GetAdminCreatePeer(c *gin.Context) {
Alerts []FlashData
Session SessionData
Static StaticData
Peer User
Peer Peer
Device Device
EditableKeys bool
}{
Route: c.Request.URL.Path,
Alerts: s.getFlashes(c),
Alerts: GetFlashes(c),
Session: currentSession,
Static: s.getStaticData(),
Peer: currentSession.FormData.(User),
Peer: currentSession.FormData.(Peer),
Device: device,
EditableKeys: s.config.Core.EditableKeys,
})
}
func (s *Server) PostAdminCreatePeer(c *gin.Context) {
currentSession := s.getSessionData(c)
var formUser User
currentSession := GetSessionData(c)
var formPeer Peer
if currentSession.FormData != nil {
formUser = currentSession.FormData.(User)
formPeer = currentSession.FormData.(Peer)
}
if err := c.ShouldBind(&formUser); err != nil {
_ = s.updateFormInSession(c, formUser)
s.setFlashMessage(c, "failed to bind form data: "+err.Error(), "danger")
if err := c.ShouldBind(&formPeer); err != nil {
_ = s.updateFormInSession(c, formPeer)
SetFlashMessage(c, "failed to bind form data: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/peer/create?formerr=bind")
return
}
// Clean list input
formUser.IPs = common.ParseStringList(formUser.IPsStr)
formUser.AllowedIPs = common.ParseStringList(formUser.AllowedIPsStr)
formUser.IPsStr = common.ListToString(formUser.IPs)
formUser.AllowedIPsStr = common.ListToString(formUser.AllowedIPs)
formPeer.IPs = common.ParseStringList(formPeer.IPsStr)
formPeer.AllowedIPs = common.ParseStringList(formPeer.AllowedIPsStr)
formPeer.IPsStr = common.ListToString(formPeer.IPs)
formPeer.AllowedIPsStr = common.ListToString(formPeer.AllowedIPs)
disabled := c.PostForm("isdisabled") != ""
now := time.Now()
if disabled {
formUser.DeactivatedAt = &now
formPeer.DeactivatedAt = &now
}
if err := s.CreateUser(formUser); err != nil {
_ = s.updateFormInSession(c, formUser)
s.setFlashMessage(c, "failed to add user: "+err.Error(), "danger")
if err := s.CreatePeer(formPeer); err != nil {
_ = s.updateFormInSession(c, formPeer)
SetFlashMessage(c, "failed to add user: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/peer/create?formerr=create")
return
}
s.setFlashMessage(c, "client created successfully", "success")
SetFlashMessage(c, "client created successfully", "success")
c.Redirect(http.StatusSeeOther, "/admin")
}
@@ -166,29 +166,29 @@ func (s *Server) GetAdminCreateLdapPeers(c *gin.Context) {
Alerts []FlashData
Session SessionData
Static StaticData
Users []*ldap.UserCacheHolderEntry
Users []users.User
FormData LdapCreateForm
Device Device
}{
Route: c.Request.URL.Path,
Alerts: s.getFlashes(c),
Alerts: GetFlashes(c),
Session: currentSession,
Static: s.getStaticData(),
Users: s.ldapUsers.GetSortedUsers("sn", "asc"),
Users: s.users.GetFilteredAndSortedUsers("lastname", "asc", ""),
FormData: currentSession.FormData.(LdapCreateForm),
Device: s.users.GetDevice(),
Device: s.peers.GetDevice(),
})
}
func (s *Server) PostAdminCreateLdapPeers(c *gin.Context) {
currentSession := s.getSessionData(c)
currentSession := GetSessionData(c)
var formData LdapCreateForm
if currentSession.FormData != nil {
formData = currentSession.FormData.(LdapCreateForm)
}
if err := c.ShouldBind(&formData); err != nil {
_ = s.updateFormInSession(c, formData)
s.setFlashMessage(c, "failed to bind form data: "+err.Error(), "danger")
SetFlashMessage(c, "failed to bind form data: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/peer/createldap?formerr=bind")
return
}
@@ -196,42 +196,42 @@ func (s *Server) PostAdminCreateLdapPeers(c *gin.Context) {
emails := common.ParseStringList(formData.Emails)
for i := range emails {
// TODO: also check email addr for validity?
if !strings.ContainsRune(emails[i], '@') || s.ldapUsers.GetUserDNByMail(emails[i]) == "" {
if !strings.ContainsRune(emails[i], '@') || s.users.GetUser(emails[i]) == nil {
_ = s.updateFormInSession(c, formData)
s.setFlashMessage(c, "invalid email address: "+emails[i], "danger")
SetFlashMessage(c, "invalid email address: "+emails[i], "danger")
c.Redirect(http.StatusSeeOther, "/admin/peer/createldap?formerr=mail")
return
}
}
log.Infof("creating %d ldap peers", len(emails))
logrus.Infof("creating %d ldap peers", len(emails))
for i := range emails {
if err := s.CreateUserByEmail(emails[i], formData.Identifier, false); err != nil {
if err := s.CreatePeerByEmail(emails[i], formData.Identifier, false); err != nil {
_ = s.updateFormInSession(c, formData)
s.setFlashMessage(c, "failed to add user: "+err.Error(), "danger")
SetFlashMessage(c, "failed to add user: "+err.Error(), "danger")
c.Redirect(http.StatusSeeOther, "/admin/peer/createldap?formerr=create")
return
}
}
s.setFlashMessage(c, "client(s) created successfully", "success")
SetFlashMessage(c, "client(s) created successfully", "success")
c.Redirect(http.StatusSeeOther, "/admin/peer/createldap")
}
func (s *Server) GetAdminDeletePeer(c *gin.Context) {
currentUser := s.users.GetUserByKey(c.Query("pkey"))
if err := s.DeleteUser(currentUser); err != nil {
currentUser := s.peers.GetPeerByKey(c.Query("pkey"))
if err := s.DeletePeer(currentUser); err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "Deletion error", err.Error())
return
}
s.setFlashMessage(c, "user deleted successfully", "success")
SetFlashMessage(c, "user deleted successfully", "success")
c.Redirect(http.StatusSeeOther, "/admin")
}
func (s *Server) GetPeerQRCode(c *gin.Context) {
user := s.users.GetUserByKey(c.Query("pkey"))
currentSession := s.getSessionData(c)
user := s.peers.GetPeerByKey(c.Query("pkey"))
currentSession := GetSessionData(c)
if !currentSession.IsAdmin && user.Email != currentSession.Email {
s.GetHandleError(c, http.StatusUnauthorized, "No permissions", "You don't have permissions to view this resource!")
return
@@ -247,14 +247,14 @@ func (s *Server) GetPeerQRCode(c *gin.Context) {
}
func (s *Server) GetPeerConfig(c *gin.Context) {
user := s.users.GetUserByKey(c.Query("pkey"))
currentSession := s.getSessionData(c)
user := s.peers.GetPeerByKey(c.Query("pkey"))
currentSession := GetSessionData(c)
if !currentSession.IsAdmin && user.Email != currentSession.Email {
s.GetHandleError(c, http.StatusUnauthorized, "No permissions", "You don't have permissions to view this resource!")
return
}
cfg, err := user.GetClientConfigFile(s.users.GetDevice())
cfg, err := user.GetConfigFile(s.peers.GetDevice())
if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "ConfigFile error", err.Error())
return
@@ -266,14 +266,14 @@ func (s *Server) GetPeerConfig(c *gin.Context) {
}
func (s *Server) GetPeerConfigMail(c *gin.Context) {
user := s.users.GetUserByKey(c.Query("pkey"))
currentSession := s.getSessionData(c)
user := s.peers.GetPeerByKey(c.Query("pkey"))
currentSession := GetSessionData(c)
if !currentSession.IsAdmin && user.Email != currentSession.Email {
s.GetHandleError(c, http.StatusUnauthorized, "No permissions", "You don't have permissions to view this resource!")
return
}
cfg, err := user.GetClientConfigFile(s.users.GetDevice())
cfg, err := user.GetConfigFile(s.peers.GetDevice())
if err != nil {
s.GetHandleError(c, http.StatusInternalServerError, "ConfigFile error", err.Error())
return
@@ -286,7 +286,7 @@ func (s *Server) GetPeerConfigMail(c *gin.Context) {
// Apply mail template
var tplBuff bytes.Buffer
if err := s.mailTpl.Execute(&tplBuff, struct {
Client User
Client Peer
QrcodePngName string
PortalUrl string
}{
@@ -319,13 +319,13 @@ func (s *Server) GetPeerConfigMail(c *gin.Context) {
return
}
s.setFlashMessage(c, "mail sent successfully", "success")
SetFlashMessage(c, "mail sent successfully", "success")
c.Redirect(http.StatusSeeOther, "/admin")
}
func (s *Server) GetPeerStatus(c *gin.Context) {
user := s.users.GetUserByKey(c.Query("pkey"))
currentSession := s.getSessionData(c)
user := s.peers.GetPeerByKey(c.Query("pkey"))
currentSession := GetSessionData(c)
if !currentSession.IsAdmin && user.Email != currentSession.Email {
s.GetHandleError(c, http.StatusUnauthorized, "No permissions", "You don't have permissions to view this resource!")
return

View File

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

View File

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

View File

@@ -4,31 +4,147 @@ import (
"time"
"github.com/h44z/wg-portal/internal/ldap"
log "github.com/sirupsen/logrus"
"github.com/h44z/wg-portal/internal/users"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
// SyncLdapAttributesWithWireGuard starts to synchronize the "disabled" attribute from ldap.
// Users will be automatically disabled once they are disabled in ldap.
// This method is blocking.
func (s *Server) SyncLdapAttributesWithWireGuard() error {
allUsers := s.users.GetAllUsers()
for i := range allUsers {
user := allUsers[i]
if user.LdapUser == nil {
continue // skip non ldap users
func (s *Server) SyncLdapWithUserDatabase() {
logrus.Info("starting ldap user synchronization...")
running := true
for running {
// Select blocks until one of the cases happens
select {
case <-time.After(1 * time.Minute):
// Sleep for 1 minute
case <-s.ctx.Done():
logrus.Trace("ldap-sync shutting down (context ended)...")
running = false
continue
}
if user.DeactivatedAt != nil {
continue // skip already disabled interfaces
// Main work here
logrus.Trace("syncing ldap users to database...")
ldapUsers, err := ldap.FindAllUsers(&s.config.LDAP)
if err != nil {
logrus.Errorf("failed to fetch users from ldap: %v", err)
continue
}
if ldap.IsLdapUserDisabled(allUsers[i].LdapUser.Attributes["userAccountControl"]) {
now := time.Now()
user.DeactivatedAt = &now
if err := s.UpdateUser(user, now); err != nil {
log.Errorf("Failed to disable user %s: %v", user.Email, err)
for i := range ldapUsers {
// prefilter
if ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute] == "" ||
ldapUsers[i].Attributes[s.config.LDAP.FirstNameAttribute] == "" ||
ldapUsers[i].Attributes[s.config.LDAP.LastNameAttribute] == "" {
continue
}
user, err := s.users.GetOrCreateUserUnscoped(ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute])
if err != nil {
logrus.Errorf("failed to get/create user %s in database: %v", ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute], err)
}
// check if user should be deactivated
ldapDeactivated := false
switch s.config.LDAP.Type {
case ldap.TypeActiveDirectory:
ldapDeactivated = ldap.IsActiveDirectoryUserDisabled(ldapUsers[i].Attributes[s.config.LDAP.DisabledAttribute])
case ldap.TypeOpenLDAP:
ldapDeactivated = ldap.IsOpenLdapUserDisabled(ldapUsers[i].Attributes[s.config.LDAP.DisabledAttribute])
}
// check if user has been disabled in ldap, update peers accordingly
if ldapDeactivated != user.DeletedAt.Valid {
if ldapDeactivated {
// disable all peers for the given user
for _, peer := range s.peers.GetPeersByMail(user.Email) {
now := time.Now()
peer.DeactivatedAt = &now
if err = s.UpdatePeer(peer, now); err != nil {
logrus.Errorf("failed to update deactivated peer %s: %v", peer.PublicKey, err)
}
}
} else {
// enable all peers for the given user
for _, peer := range s.peers.GetPeersByMail(user.Email) {
now := time.Now()
peer.DeactivatedAt = nil
if err = s.UpdatePeer(peer, now); err != nil {
logrus.Errorf("failed to update activated peer %s: %v", peer.PublicKey, err)
}
}
}
}
// Sync attributes from ldap
if s.UserChangedInLdap(user, &ldapUsers[i]) {
user.Firstname = ldapUsers[i].Attributes[s.config.LDAP.FirstNameAttribute]
user.Lastname = ldapUsers[i].Attributes[s.config.LDAP.LastNameAttribute]
user.Email = ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute]
user.Phone = ldapUsers[i].Attributes[s.config.LDAP.PhoneAttribute]
user.IsAdmin = false
user.Source = users.UserSourceLdap
user.DeletedAt = gorm.DeletedAt{} // Not deleted
for _, group := range ldapUsers[i].RawAttributes[s.config.LDAP.GroupMemberAttribute] {
if string(group) == s.config.LDAP.AdminLdapGroup {
user.IsAdmin = true
break
}
}
if err = s.users.UpdateUser(user); err != nil {
logrus.Errorf("failed to update ldap user %s in database: %v", user.Email, err)
continue
}
if ldapDeactivated {
if err = s.users.DeleteUser(user); err != nil {
logrus.Errorf("failed to delete deactivated user %s in database: %v", user.Email, err)
continue
}
}
}
}
}
return nil
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

@@ -3,11 +3,8 @@ package server
import (
"bytes"
"crypto/md5"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"reflect"
"regexp"
"sort"
@@ -16,18 +13,14 @@ import (
"time"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"github.com/h44z/wg-portal/internal/wireguard"
"github.com/h44z/wg-portal/internal/common"
"github.com/h44z/wg-portal/internal/ldap"
log "github.com/sirupsen/logrus"
"github.com/h44z/wg-portal/internal/users"
"github.com/h44z/wg-portal/internal/wireguard"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/skip2/go-qrcode"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -62,19 +55,19 @@ var ipList validator.Func = func(fl validator.FieldLevel) bool {
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("cidrlist", cidrList)
v.RegisterValidation("iplist", ipList)
_ = v.RegisterValidation("cidrlist", cidrList)
_ = v.RegisterValidation("iplist", ipList)
}
}
//
// USER ----------------------------------------------------------------------------------------
// PEER ----------------------------------------------------------------------------------------
//
type User struct {
Peer *wgtypes.Peer `gorm:"-"`
LdapUser *ldap.UserCacheHolderEntry `gorm:"-"` // optional, it is still possible to have users without ldap
Config string `gorm:"-"`
type Peer struct {
Peer *wgtypes.Peer `gorm:"-"` // WireGuard peer
User *users.User `gorm:"-"` // user reference for the peer
Config string `gorm:"-"`
UID string `form:"uid" binding:"alphanum"` // uid for html identification
IsOnline bool `gorm:"-"`
@@ -100,33 +93,11 @@ type User struct {
UpdatedAt time.Time
}
func (u User) GetClientConfigFile(device Device) ([]byte, error) {
tpl, err := template.New("client").Funcs(template.FuncMap{"StringsJoin": strings.Join}).Parse(wireguard.ClientCfgTpl)
if err != nil {
return nil, err
}
var tplBuff bytes.Buffer
err = tpl.Execute(&tplBuff, struct {
Client User
Server Device
}{
Client: u,
Server: device,
})
if err != nil {
return nil, err
}
return tplBuff.Bytes(), nil
}
func (u User) GetPeerConfig() wgtypes.PeerConfig {
publicKey, _ := wgtypes.ParseKey(u.PublicKey)
func (p Peer) GetConfig() wgtypes.PeerConfig {
publicKey, _ := wgtypes.ParseKey(p.PublicKey)
var presharedKey *wgtypes.Key
if u.PresharedKey != "" {
presharedKeyTmp, _ := wgtypes.ParseKey(u.PresharedKey)
if p.PresharedKey != "" {
presharedKeyTmp, _ := wgtypes.ParseKey(p.PresharedKey)
presharedKey = &presharedKeyTmp
}
@@ -138,9 +109,9 @@ func (u User) GetPeerConfig() wgtypes.PeerConfig {
Endpoint: nil,
PersistentKeepaliveInterval: nil,
ReplaceAllowedIPs: true,
AllowedIPs: make([]net.IPNet, len(u.IPs)),
AllowedIPs: make([]net.IPNet, len(p.IPs)),
}
for i, ip := range u.IPs {
for i, ip := range p.IPs {
_, ipNet, err := net.ParseCIDR(ip)
if err == nil {
cfg.AllowedIPs[i] = *ipNet
@@ -150,29 +121,51 @@ func (u User) GetPeerConfig() wgtypes.PeerConfig {
return cfg
}
func (u User) GetQRCode() ([]byte, error) {
png, err := qrcode.Encode(u.Config, qrcode.Medium, 250)
func (p Peer) GetConfigFile(device Device) ([]byte, error) {
tpl, err := template.New("client").Funcs(template.FuncMap{"StringsJoin": strings.Join}).Parse(wireguard.ClientCfgTpl)
if err != nil {
log.WithFields(log.Fields{
return nil, errors.Wrap(err, "failed to parse client template")
}
var tplBuff bytes.Buffer
err = tpl.Execute(&tplBuff, struct {
Client Peer
Server Device
}{
Client: p,
Server: device,
})
if err != nil {
return nil, errors.Wrap(err, "failed to execute client template")
}
return tplBuff.Bytes(), nil
}
func (p Peer) GetQRCode() ([]byte, error) {
png, err := qrcode.Encode(p.Config, qrcode.Medium, 250)
if err != nil {
logrus.WithFields(logrus.Fields{
"err": err,
}).Error("failed to create qrcode")
return nil, err
return nil, errors.Wrap(err, "failed to encode qrcode")
}
return png, nil
}
func (u User) IsValid() bool {
if u.PublicKey == "" {
func (p Peer) IsValid() bool {
if p.PublicKey == "" {
return false
}
return true
}
func (u User) ToMap() map[string]string {
func (p Peer) ToMap() map[string]string {
out := make(map[string]string)
v := reflect.ValueOf(u)
v := reflect.ValueOf(p)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
@@ -189,9 +182,9 @@ func (u User) ToMap() map[string]string {
return out
}
func (u User) GetConfigFileName() string {
func (p Peer) GetConfigFileName() string {
reg := regexp.MustCompile("[^a-zA-Z0-9_-]+")
return reg.ReplaceAllString(strings.ReplaceAll(u.Identifier, " ", "-"), "") + ".conf"
return reg.ReplaceAllString(strings.ReplaceAll(p.Identifier, " ", "-"), "") + ".conf"
}
//
@@ -236,7 +229,7 @@ func (d Device) IsValid() bool {
return true
}
func (d Device) GetDeviceConfig() wgtypes.Config {
func (d Device) GetConfig() wgtypes.Config {
var privateKey *wgtypes.Key
if d.PrivateKey != "" {
pKey, _ := wgtypes.ParseKey(d.PrivateKey)
@@ -251,134 +244,120 @@ func (d Device) GetDeviceConfig() wgtypes.Config {
return cfg
}
func (d Device) GetDeviceConfigFile(clients []User) ([]byte, error) {
func (d Device) GetConfigFile(peers []Peer) ([]byte, error) {
tpl, err := template.New("server").Funcs(template.FuncMap{"StringsJoin": strings.Join}).Parse(wireguard.DeviceCfgTpl)
if err != nil {
return nil, err
return nil, errors.Wrap(err, "failed to parse server template")
}
var tplBuff bytes.Buffer
err = tpl.Execute(&tplBuff, struct {
Clients []User
Clients []Peer
Server Device
}{
Clients: clients,
Clients: peers,
Server: d,
})
if err != nil {
return nil, err
return nil, errors.Wrap(err, "failed to execute server template")
}
return tplBuff.Bytes(), nil
}
//
// USER-MANAGER --------------------------------------------------------------------------------
// PEER-MANAGER --------------------------------------------------------------------------------
//
type UserManager struct {
db *gorm.DB
wg *wireguard.Manager
ldapUsers *ldap.SynchronizedUserCacheHolder
type PeerManager struct {
db *gorm.DB
wg *wireguard.Manager
users *users.Manager
}
func NewUserManager(dbPath string, wg *wireguard.Manager, ldapUsers *ldap.SynchronizedUserCacheHolder) *UserManager {
um := &UserManager{wg: wg, ldapUsers: ldapUsers}
func NewPeerManager(cfg *common.Config, wg *wireguard.Manager, userDB *users.Manager) (*PeerManager, error) {
um := &PeerManager{wg: wg, users: userDB}
var err error
if _, err = os.Stat(filepath.Dir(dbPath)); os.IsNotExist(err) {
if err = os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil {
log.Errorf("failed to create database directory (%s): %v", filepath.Dir(dbPath), err)
return nil
}
}
um.db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
um.db, err = users.GetDatabaseForConfig(&cfg.Database)
if err != nil {
log.Errorf("failed to open sqlite database (%s): %v", dbPath, err)
return nil
return nil, errors.WithMessage(err, "failed to open peer database")
}
err = um.db.AutoMigrate(&User{}, &Device{})
err = um.db.AutoMigrate(&Peer{}, &Device{})
if err != nil {
log.Errorf("failed to migrate sqlite database: %v", err)
return nil
return nil, errors.WithMessage(err, "failed to migrate peer database")
}
return um
return um, nil
}
func (u *UserManager) InitFromCurrentInterface() error {
func (u *PeerManager) InitFromCurrentInterface() error {
peers, err := u.wg.GetPeerList()
if err != nil {
log.Errorf("failed to init user-manager from peers: %v", err)
return err
return errors.Wrapf(err, "failed to get peer list")
}
device, err := u.wg.GetDeviceInfo()
if err != nil {
log.Errorf("failed to init user-manager from device: %v", err)
return err
return errors.Wrapf(err, "failed to get device info")
}
var ipAddresses []string
var mtu int
if u.wg.Cfg.ManageIPAddresses {
if ipAddresses, err = u.wg.GetIPAddress(); err != nil {
log.Errorf("failed to init user-manager from device: %v", err)
return err
return errors.Wrapf(err, "failed to get ip address")
}
if mtu, err = u.wg.GetMTU(); err != nil {
log.Errorf("failed to init user-manager from device: %v", err)
return err
return errors.Wrapf(err, "failed to get MTU")
}
}
// Check if entries already exist in database, if not create them
for _, peer := range peers {
if err := u.validateOrCreateUserForPeer(peer); err != nil {
return err
if err := u.validateOrCreatePeer(peer); err != nil {
return errors.WithMessagef(err, "failed to validate peer %s", peer.PublicKey)
}
}
if err := u.validateOrCreateDevice(*device, ipAddresses, mtu); err != nil {
return err
return errors.WithMessagef(err, "failed to validate device %s", device.Name)
}
return nil
}
func (u *UserManager) validateOrCreateUserForPeer(peer wgtypes.Peer) error {
user := User{}
u.db.Where("public_key = ?", peer.PublicKey.String()).FirstOrInit(&user)
func (u *PeerManager) validateOrCreatePeer(wgPeer wgtypes.Peer) error {
peer := Peer{}
u.db.Where("public_key = ?", wgPeer.PublicKey.String()).FirstOrInit(&peer)
if user.PublicKey == "" { // user not found, create
user.UID = fmt.Sprintf("u%x", md5.Sum([]byte(peer.PublicKey.String())))
user.PublicKey = peer.PublicKey.String()
user.PrivateKey = "" // UNKNOWN
if peer.PresharedKey != (wgtypes.Key{}) {
user.PresharedKey = peer.PresharedKey.String()
if peer.PublicKey == "" { // peer not found, create
peer.UID = fmt.Sprintf("u%x", md5.Sum([]byte(wgPeer.PublicKey.String())))
peer.PublicKey = wgPeer.PublicKey.String()
peer.PrivateKey = "" // UNKNOWN
if wgPeer.PresharedKey != (wgtypes.Key{}) {
peer.PresharedKey = wgPeer.PresharedKey.String()
}
user.Email = "autodetected@example.com"
user.Identifier = "Autodetected (" + user.PublicKey[0:8] + ")"
user.UpdatedAt = time.Now()
user.CreatedAt = time.Now()
user.AllowedIPs = make([]string, 0) // UNKNOWN
user.IPs = make([]string, len(peer.AllowedIPs))
for i, ip := range peer.AllowedIPs {
user.IPs[i] = ip.String()
peer.Email = "autodetected@example.com"
peer.Identifier = "Autodetected (" + peer.PublicKey[0:8] + ")"
peer.UpdatedAt = time.Now()
peer.CreatedAt = time.Now()
peer.AllowedIPs = make([]string, 0) // UNKNOWN
peer.IPs = make([]string, len(wgPeer.AllowedIPs))
for i, ip := range wgPeer.AllowedIPs {
peer.IPs[i] = ip.String()
}
user.AllowedIPsStr = strings.Join(user.AllowedIPs, ", ")
user.IPsStr = strings.Join(user.IPs, ", ")
peer.AllowedIPsStr = strings.Join(peer.AllowedIPs, ", ")
peer.IPsStr = strings.Join(peer.IPs, ", ")
res := u.db.Create(&user)
res := u.db.Create(&peer)
if res.Error != nil {
log.Errorf("failed to create autodetected peer: %v", res.Error)
return res.Error
return errors.Wrapf(res.Error, "failed to create autodetected peer %s", peer.PublicKey)
}
}
return nil
}
func (u *UserManager) validateOrCreateDevice(dev wgtypes.Device, ipAddresses []string, mtu int) error {
func (u *PeerManager) validateOrCreateDevice(dev wgtypes.Device, ipAddresses []string, mtu int) error {
device := Device{}
u.db.Where("device_name = ?", dev.Name).FirstOrInit(&device)
@@ -390,54 +369,53 @@ func (u *UserManager) validateOrCreateDevice(dev wgtypes.Device, ipAddresses []s
device.Mtu = 0
device.PersistentKeepalive = 16 // Default
device.IPsStr = strings.Join(ipAddresses, ", ")
if mtu == wireguard.WireGuardDefaultMTU {
if mtu == wireguard.DefaultMTU {
mtu = 0
}
device.Mtu = mtu
res := u.db.Create(&device)
if res.Error != nil {
log.Errorf("failed to create autodetected device: %v", res.Error)
return res.Error
return errors.Wrapf(res.Error, "failed to create autodetected device")
}
}
return nil
}
func (u *UserManager) populateUserData(user *User) {
user.AllowedIPs = strings.Split(user.AllowedIPsStr, ", ")
user.IPs = strings.Split(user.IPsStr, ", ")
func (u *PeerManager) populatePeerData(peer *Peer) {
peer.AllowedIPs = strings.Split(peer.AllowedIPsStr, ", ")
peer.IPs = strings.Split(peer.IPsStr, ", ")
// Set config file
tmpCfg, _ := user.GetClientConfigFile(u.GetDevice())
user.Config = string(tmpCfg)
tmpCfg, _ := peer.GetConfigFile(u.GetDevice())
peer.Config = string(tmpCfg)
// set data from WireGuard interface
user.Peer, _ = u.wg.GetPeer(user.PublicKey)
user.LastHandshake = "never"
user.LastHandshakeTime = "Never connected, or user is disabled."
if user.Peer != nil {
since := time.Since(user.Peer.LastHandshakeTime)
peer.Peer, _ = u.wg.GetPeer(peer.PublicKey)
peer.LastHandshake = "never"
peer.LastHandshakeTime = "Never connected, or user is disabled."
if peer.Peer != nil {
since := time.Since(peer.Peer.LastHandshakeTime)
sinceSeconds := int(since.Round(time.Second).Seconds())
sinceMinutes := int(sinceSeconds / 60)
sinceSeconds -= sinceMinutes * 60
if sinceMinutes > 2*10080 { // 2 weeks
user.LastHandshake = "a while ago"
peer.LastHandshake = "a while ago"
} else if sinceMinutes > 10080 { // 1 week
user.LastHandshake = "a week ago"
peer.LastHandshake = "a week ago"
} else {
user.LastHandshake = fmt.Sprintf("%02dm %02ds", sinceMinutes, sinceSeconds)
peer.LastHandshake = fmt.Sprintf("%02dm %02ds", sinceMinutes, sinceSeconds)
}
user.LastHandshakeTime = user.Peer.LastHandshakeTime.Format(time.UnixDate)
peer.LastHandshakeTime = peer.Peer.LastHandshakeTime.Format(time.UnixDate)
}
user.IsOnline = false // todo: calculate online status
peer.IsOnline = false
// set ldap data
user.LdapUser = u.ldapUsers.GetUserData(u.ldapUsers.GetUserDNByMail(user.Email))
// set user data
peer.User = u.users.GetUser(peer.Email)
}
func (u *UserManager) populateDeviceData(device *Device) {
func (u *PeerManager) populateDeviceData(device *Device) {
device.AllowedIPs = strings.Split(device.AllowedIPsStr, ", ")
device.IPs = strings.Split(device.IPsStr, ", ")
device.DNS = strings.Split(device.DNSStr, ", ")
@@ -446,69 +424,69 @@ func (u *UserManager) populateDeviceData(device *Device) {
device.Interface, _ = u.wg.GetDeviceInfo()
}
func (u *UserManager) GetAllUsers() []User {
users := make([]User, 0)
u.db.Find(&users)
func (u *PeerManager) GetAllPeers() []Peer {
peers := make([]Peer, 0)
u.db.Find(&peers)
for i := range users {
u.populateUserData(&users[i])
for i := range peers {
u.populatePeerData(&peers[i])
}
return users
return peers
}
func (u *UserManager) GetActiveUsers() []User {
users := make([]User, 0)
u.db.Where("deactivated_at IS NULL").Find(&users)
func (u *PeerManager) GetActivePeers() []Peer {
peers := make([]Peer, 0)
u.db.Where("deactivated_at IS NULL").Find(&peers)
for i := range users {
u.populateUserData(&users[i])
for i := range peers {
u.populatePeerData(&peers[i])
}
return users
return peers
}
func (u *UserManager) GetFilteredAndSortedUsers(sortKey, sortDirection, search string) []User {
users := make([]User, 0)
u.db.Find(&users)
func (u *PeerManager) GetFilteredAndSortedPeers(sortKey, sortDirection, search string) []Peer {
peers := make([]Peer, 0)
u.db.Find(&peers)
filteredUsers := make([]User, 0, len(users))
for i := range users {
u.populateUserData(&users[i])
filteredPeers := make([]Peer, 0, len(peers))
for i := range peers {
u.populatePeerData(&peers[i])
if search == "" ||
strings.Contains(users[i].Email, search) ||
strings.Contains(users[i].Identifier, search) ||
strings.Contains(users[i].PublicKey, search) {
filteredUsers = append(filteredUsers, users[i])
strings.Contains(peers[i].Email, search) ||
strings.Contains(peers[i].Identifier, search) ||
strings.Contains(peers[i].PublicKey, search) {
filteredPeers = append(filteredPeers, peers[i])
}
}
sort.Slice(filteredUsers, func(i, j int) bool {
sort.Slice(filteredPeers, func(i, j int) bool {
var sortValueLeft string
var sortValueRight string
switch sortKey {
case "id":
sortValueLeft = filteredUsers[i].Identifier
sortValueRight = filteredUsers[j].Identifier
sortValueLeft = filteredPeers[i].Identifier
sortValueRight = filteredPeers[j].Identifier
case "pubKey":
sortValueLeft = filteredUsers[i].PublicKey
sortValueRight = filteredUsers[j].PublicKey
sortValueLeft = filteredPeers[i].PublicKey
sortValueRight = filteredPeers[j].PublicKey
case "mail":
sortValueLeft = filteredUsers[i].Email
sortValueRight = filteredUsers[j].Email
sortValueLeft = filteredPeers[i].Email
sortValueRight = filteredPeers[j].Email
case "ip":
sortValueLeft = filteredUsers[i].IPsStr
sortValueRight = filteredUsers[j].IPsStr
sortValueLeft = filteredPeers[i].IPsStr
sortValueRight = filteredPeers[j].IPsStr
case "handshake":
if filteredUsers[i].Peer == nil {
if filteredPeers[i].Peer == nil {
return false
} else if filteredUsers[j].Peer == nil {
} else if filteredPeers[j].Peer == nil {
return true
}
sortValueLeft = filteredUsers[i].Peer.LastHandshakeTime.Format(time.RFC3339)
sortValueRight = filteredUsers[j].Peer.LastHandshakeTime.Format(time.RFC3339)
sortValueLeft = filteredPeers[i].Peer.LastHandshakeTime.Format(time.RFC3339)
sortValueRight = filteredPeers[j].Peer.LastHandshakeTime.Format(time.RFC3339)
}
if sortDirection == "asc" {
@@ -518,42 +496,42 @@ func (u *UserManager) GetFilteredAndSortedUsers(sortKey, sortDirection, search s
}
})
return filteredUsers
return filteredPeers
}
func (u *UserManager) GetSortedUsersForEmail(sortKey, sortDirection, email string) []User {
users := make([]User, 0)
u.db.Where("email = ?", email).Find(&users)
func (u *PeerManager) GetSortedPeersForEmail(sortKey, sortDirection, email string) []Peer {
peers := make([]Peer, 0)
u.db.Where("email = ?", email).Find(&peers)
for i := range users {
u.populateUserData(&users[i])
for i := range peers {
u.populatePeerData(&peers[i])
}
sort.Slice(users, func(i, j int) bool {
sort.Slice(peers, func(i, j int) bool {
var sortValueLeft string
var sortValueRight string
switch sortKey {
case "id":
sortValueLeft = users[i].Identifier
sortValueRight = users[j].Identifier
sortValueLeft = peers[i].Identifier
sortValueRight = peers[j].Identifier
case "pubKey":
sortValueLeft = users[i].PublicKey
sortValueRight = users[j].PublicKey
sortValueLeft = peers[i].PublicKey
sortValueRight = peers[j].PublicKey
case "mail":
sortValueLeft = users[i].Email
sortValueRight = users[j].Email
sortValueLeft = peers[i].Email
sortValueRight = peers[j].Email
case "ip":
sortValueLeft = users[i].IPsStr
sortValueRight = users[j].IPsStr
sortValueLeft = peers[i].IPsStr
sortValueRight = peers[j].IPsStr
case "handshake":
if users[i].Peer == nil {
if peers[i].Peer == nil {
return true
} else if users[j].Peer == nil {
} else if peers[j].Peer == nil {
return false
}
sortValueLeft = users[i].Peer.LastHandshakeTime.Format(time.RFC3339)
sortValueRight = users[j].Peer.LastHandshakeTime.Format(time.RFC3339)
sortValueLeft = peers[i].Peer.LastHandshakeTime.Format(time.RFC3339)
sortValueRight = peers[j].Peer.LastHandshakeTime.Format(time.RFC3339)
}
if sortDirection == "asc" {
@@ -563,10 +541,10 @@ func (u *UserManager) GetSortedUsersForEmail(sortKey, sortDirection, email strin
}
})
return users
return peers
}
func (u *UserManager) GetDevice() Device {
func (u *PeerManager) GetDevice() Device {
devices := make([]Device, 0, 1)
u.db.Find(&devices)
@@ -577,64 +555,64 @@ func (u *UserManager) GetDevice() Device {
return devices[0] // use first device for now... more to come?
}
func (u *UserManager) GetUserByKey(publicKey string) User {
user := User{}
u.db.Where("public_key = ?", publicKey).FirstOrInit(&user)
u.populateUserData(&user)
return user
func (u *PeerManager) GetPeerByKey(publicKey string) Peer {
peer := Peer{}
u.db.Where("public_key = ?", publicKey).FirstOrInit(&peer)
u.populatePeerData(&peer)
return peer
}
func (u *UserManager) GetUsersByMail(mail string) []User {
var users []User
u.db.Where("email = ?", mail).Find(&users)
for i := range users {
u.populateUserData(&users[i])
func (u *PeerManager) GetPeersByMail(mail string) []Peer {
var peers []Peer
u.db.Where("email = ?", mail).Find(&peers)
for i := range peers {
u.populatePeerData(&peers[i])
}
return users
return peers
}
func (u *UserManager) CreateUser(user User) error {
user.UID = fmt.Sprintf("u%x", md5.Sum([]byte(user.PublicKey)))
user.UpdatedAt = time.Now()
user.CreatedAt = time.Now()
user.AllowedIPsStr = strings.Join(user.AllowedIPs, ", ")
user.IPsStr = strings.Join(user.IPs, ", ")
func (u *PeerManager) CreatePeer(peer Peer) error {
peer.UID = fmt.Sprintf("u%x", md5.Sum([]byte(peer.PublicKey)))
peer.UpdatedAt = time.Now()
peer.CreatedAt = time.Now()
peer.AllowedIPsStr = strings.Join(peer.AllowedIPs, ", ")
peer.IPsStr = strings.Join(peer.IPs, ", ")
res := u.db.Create(&user)
res := u.db.Create(&peer)
if res.Error != nil {
log.Errorf("failed to create user: %v", res.Error)
return res.Error
logrus.Errorf("failed to create peer: %v", res.Error)
return errors.Wrap(res.Error, "failed to create peer")
}
return nil
}
func (u *UserManager) UpdateUser(user User) error {
user.UpdatedAt = time.Now()
user.AllowedIPsStr = strings.Join(user.AllowedIPs, ", ")
user.IPsStr = strings.Join(user.IPs, ", ")
func (u *PeerManager) UpdatePeer(peer Peer) error {
peer.UpdatedAt = time.Now()
peer.AllowedIPsStr = strings.Join(peer.AllowedIPs, ", ")
peer.IPsStr = strings.Join(peer.IPs, ", ")
res := u.db.Save(&user)
res := u.db.Save(&peer)
if res.Error != nil {
log.Errorf("failed to update user: %v", res.Error)
return res.Error
logrus.Errorf("failed to update peer: %v", res.Error)
return errors.Wrap(res.Error, "failed to update peer")
}
return nil
}
func (u *UserManager) DeleteUser(user User) error {
res := u.db.Delete(&user)
func (u *PeerManager) DeletePeer(peer Peer) error {
res := u.db.Delete(&peer)
if res.Error != nil {
log.Errorf("failed to delete user: %v", res.Error)
return res.Error
logrus.Errorf("failed to delete peer: %v", res.Error)
return errors.Wrap(res.Error, "failed to delete peer")
}
return nil
}
func (u *UserManager) UpdateDevice(device Device) error {
func (u *PeerManager) UpdateDevice(device Device) error {
device.UpdatedAt = time.Now()
device.AllowedIPsStr = strings.Join(device.AllowedIPs, ", ")
device.IPsStr = strings.Join(device.IPs, ", ")
@@ -642,24 +620,24 @@ func (u *UserManager) UpdateDevice(device Device) error {
res := u.db.Save(&device)
if res.Error != nil {
log.Errorf("failed to update device: %v", res.Error)
return res.Error
logrus.Errorf("failed to update device: %v", res.Error)
return errors.Wrap(res.Error, "failed to update device")
}
return nil
}
func (u *UserManager) GetAllReservedIps() ([]string, error) {
func (u *PeerManager) GetAllReservedIps() ([]string, error) {
reservedIps := make([]string, 0)
users := u.GetAllUsers()
for _, user := range users {
peers := u.GetAllPeers()
for _, user := range peers {
for _, cidr := range user.IPs {
if cidr == "" {
continue
}
ip, _, err := net.ParseCIDR(cidr)
if err != nil {
return nil, err
return nil, errors.Wrap(err, "failed to parse cidr")
}
reservedIps = append(reservedIps, ip.String())
}
@@ -672,7 +650,7 @@ func (u *UserManager) GetAllReservedIps() ([]string, error) {
}
ip, _, err := net.ParseCIDR(cidr)
if err != nil {
return nil, err
return nil, errors.Wrap(err, "failed to parse cidr")
}
reservedIps = append(reservedIps, ip.String())
@@ -681,7 +659,7 @@ func (u *UserManager) GetAllReservedIps() ([]string, error) {
return reservedIps, nil
}
func (u *UserManager) IsIPReserved(cidr string) bool {
func (u *PeerManager) IsIPReserved(cidr string) bool {
reserved, err := u.GetAllReservedIps()
if err != nil {
return true // in case something failed, assume the ip is reserved
@@ -710,14 +688,14 @@ func (u *UserManager) IsIPReserved(cidr string) bool {
}
// GetAvailableIp search for an available ip in cidr against a list of reserved ips
func (u *UserManager) GetAvailableIp(cidr string) (string, error) {
func (u *PeerManager) GetAvailableIp(cidr string) (string, error) {
reserved, err := u.GetAllReservedIps()
if err != nil {
return "", err
return "", errors.WithMessage(err, "failed to get all reserved IP addresses")
}
ip, ipnet, err := net.ParseCIDR(cidr)
if err != nil {
return "", err
return "", errors.Wrap(err, "failed to parse cidr")
}
// this two addresses are not usable

View File

@@ -4,11 +4,20 @@ import (
"net/http"
"github.com/gin-gonic/gin"
wg_portal "github.com/h44z/wg-portal"
)
func SetupRoutes(s *Server) {
// Startpage
s.server.GET("/", s.GetIndex)
s.server.GET("/favicon.ico", func(c *gin.Context) {
file, _ := wg_portal.Statics.ReadFile("assets/img/favicon.ico")
c.Data(
http.StatusOK,
"image/x-icon",
file,
)
})
// Auth routes
auth := s.server.Group("/auth")
@@ -18,7 +27,7 @@ func SetupRoutes(s *Server) {
// Admin routes
admin := s.server.Group("/admin")
admin.Use(s.RequireAuthentication(s.config.AdminLdapGroup))
admin.Use(s.RequireAuthentication("admin"))
admin.GET("/", s.GetAdminIndex)
admin.GET("/device/edit", s.GetAdminEditInterface)
admin.POST("/device/edit", s.PostAdminEditInterface)
@@ -34,6 +43,12 @@ func SetupRoutes(s *Server) {
admin.GET("/peer/download", s.GetPeerConfig)
admin.GET("/peer/email", s.GetPeerConfigMail)
admin.GET("/users/", s.GetAdminUsersIndex)
admin.GET("/users/create", s.GetAdminUsersCreate)
admin.POST("/users/create", s.PostAdminUsersCreate)
admin.GET("/users/edit", s.GetAdminUsersEdit)
admin.POST("/users/edit", s.PostAdminUsersEdit)
// User routes
user := s.server.Group("/user")
user.Use(s.RequireAuthentication("")) // empty scope = all logged in users
@@ -46,7 +61,7 @@ func SetupRoutes(s *Server) {
func (s *Server) RequireAuthentication(scope string) gin.HandlerFunc {
return func(c *gin.Context) {
session := s.getSessionData(c)
session := GetSessionData(c)
if !session.LoggedIn {
// Abort the request with the appropriate error code
@@ -55,8 +70,15 @@ func (s *Server) RequireAuthentication(scope string) gin.HandlerFunc {
return
}
if scope != "" && !session.IsAdmin && // admins always have access
!s.ldapUsers.IsInGroup(session.UserName, scope) {
if scope == "admin" && !session.IsAdmin {
// Abort the request with the appropriate error code
c.Abort()
s.GetHandleError(c, http.StatusUnauthorized, "unauthorized", "not enough permissions")
return
}
// default case if some randome scope was set...
if scope != "" && !session.IsAdmin {
// Abort the request with the appropriate error code
c.Abort()
s.GetHandleError(c, http.StatusUnauthorized, "unauthorized", "not enough permissions")

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

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

View File

@@ -0,0 +1,297 @@
package server
import (
"crypto/md5"
"fmt"
"io/ioutil"
"syscall"
"time"
"github.com/h44z/wg-portal/internal/common"
"github.com/h44z/wg-portal/internal/users"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"gorm.io/gorm"
)
func (s *Server) PrepareNewPeer() (Peer, error) {
device := s.peers.GetDevice()
peer := Peer{}
peer.IsNew = true
peer.AllowedIPsStr = device.AllowedIPsStr
peer.IPs = make([]string, len(device.IPs))
for i := range device.IPs {
freeIP, err := s.peers.GetAvailableIp(device.IPs[i])
if err != nil {
return Peer{}, errors.WithMessage(err, "failed to get available IP addresses")
}
peer.IPs[i] = freeIP
}
peer.IPsStr = common.ListToString(peer.IPs)
psk, err := wgtypes.GenerateKey()
if err != nil {
return Peer{}, errors.Wrap(err, "failed to generate key")
}
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
return Peer{}, errors.Wrap(err, "failed to generate private key")
}
peer.PresharedKey = psk.String()
peer.PrivateKey = key.String()
peer.PublicKey = key.PublicKey().String()
peer.UID = fmt.Sprintf("u%x", md5.Sum([]byte(peer.PublicKey)))
return peer, nil
}
func (s *Server) CreatePeerByEmail(email, identifierSuffix string, disabled bool) error {
user, err := s.users.GetOrCreateUser(email)
if err != nil {
return errors.WithMessagef(err, "failed to load/create related user %s", email)
}
device := s.peers.GetDevice()
peer := Peer{}
peer.User = user
peer.AllowedIPsStr = device.AllowedIPsStr
peer.IPs = make([]string, len(device.IPs))
for i := range device.IPs {
freeIP, err := s.peers.GetAvailableIp(device.IPs[i])
if err != nil {
return errors.WithMessage(err, "failed to get available IP addresses")
}
peer.IPs[i] = freeIP
}
peer.IPsStr = common.ListToString(peer.IPs)
psk, err := wgtypes.GenerateKey()
if err != nil {
return errors.Wrap(err, "failed to generate key")
}
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
return errors.Wrap(err, "failed to generate private key")
}
peer.PresharedKey = psk.String()
peer.PrivateKey = key.String()
peer.PublicKey = key.PublicKey().String()
peer.UID = fmt.Sprintf("u%x", md5.Sum([]byte(peer.PublicKey)))
peer.Email = email
peer.Identifier = fmt.Sprintf("%s %s (%s)", user.Firstname, user.Lastname, identifierSuffix)
now := time.Now()
if disabled {
peer.DeactivatedAt = &now
}
return s.CreatePeer(peer)
}
func (s *Server) CreatePeer(peer Peer) error {
device := s.peers.GetDevice()
peer.AllowedIPsStr = device.AllowedIPsStr
if peer.IPs == nil || len(peer.IPs) == 0 {
peer.IPs = make([]string, len(device.IPs))
for i := range device.IPs {
freeIP, err := s.peers.GetAvailableIp(device.IPs[i])
if err != nil {
return errors.WithMessage(err, "failed to get available IP addresses")
}
peer.IPs[i] = freeIP
}
peer.IPsStr = common.ListToString(peer.IPs)
}
if peer.PrivateKey == "" { // if private key is empty create a new one
psk, err := wgtypes.GenerateKey()
if err != nil {
return errors.Wrap(err, "failed to generate key")
}
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
return errors.Wrap(err, "failed to generate private key")
}
peer.PresharedKey = psk.String()
peer.PrivateKey = key.String()
peer.PublicKey = key.PublicKey().String()
}
peer.UID = fmt.Sprintf("u%x", md5.Sum([]byte(peer.PublicKey)))
// Create WireGuard interface
if peer.DeactivatedAt == nil {
if err := s.wg.AddPeer(peer.GetConfig()); err != nil {
return errors.WithMessage(err, "failed to add WireGuard peer")
}
}
// Create in database
if err := s.peers.CreatePeer(peer); err != nil {
return errors.WithMessage(err, "failed to create peer")
}
return s.WriteWireGuardConfigFile()
}
func (s *Server) UpdatePeer(peer Peer, updateTime time.Time) error {
currentPeer := s.peers.GetPeerByKey(peer.PublicKey)
// Update WireGuard device
var err error
switch {
case peer.DeactivatedAt == &updateTime:
err = s.wg.RemovePeer(peer.PublicKey)
case peer.DeactivatedAt == nil && currentPeer.Peer != nil:
err = s.wg.UpdatePeer(peer.GetConfig())
case peer.DeactivatedAt == nil && currentPeer.Peer == nil:
err = s.wg.AddPeer(peer.GetConfig())
}
if err != nil {
return errors.WithMessage(err, "failed to update WireGuard peer")
}
// Update in database
if err := s.peers.UpdatePeer(peer); err != nil {
return errors.WithMessage(err, "failed to update peer")
}
return s.WriteWireGuardConfigFile()
}
func (s *Server) DeletePeer(peer Peer) error {
// Delete WireGuard peer
if err := s.wg.RemovePeer(peer.PublicKey); err != nil {
return errors.WithMessage(err, "failed to remove WireGuard peer")
}
// Delete in database
if err := s.peers.DeletePeer(peer); err != nil {
return errors.WithMessage(err, "failed to remove peer")
}
return s.WriteWireGuardConfigFile()
}
func (s *Server) RestoreWireGuardInterface() error {
activePeers := s.peers.GetActivePeers()
for i := range activePeers {
if activePeers[i].Peer == nil {
if err := s.wg.AddPeer(activePeers[i].GetConfig()); err != nil {
return errors.WithMessage(err, "failed to add WireGuard peer")
}
}
}
return nil
}
func (s *Server) WriteWireGuardConfigFile() error {
if s.config.WG.WireGuardConfig == "" {
return nil // writing disabled
}
if err := syscall.Access(s.config.WG.WireGuardConfig, syscall.O_RDWR); err != nil {
return errors.Wrap(err, "failed to check WireGuard config access rights")
}
device := s.peers.GetDevice()
cfg, err := device.GetConfigFile(s.peers.GetActivePeers())
if err != nil {
return errors.WithMessage(err, "failed to get config file")
}
if err := ioutil.WriteFile(s.config.WG.WireGuardConfig, cfg, 0644); err != nil {
return errors.Wrap(err, "failed to write WireGuard config file")
}
return nil
}
func (s *Server) CreateUser(user users.User) error {
if user.Email == "" {
return errors.New("cannot create user with empty email address")
}
// Check if user already exists, if so re-enable
if existingUser := s.users.GetUserUnscoped(user.Email); existingUser != nil {
user.DeletedAt = gorm.DeletedAt{} // reset deleted flag to enable that user again
return s.UpdateUser(user)
}
// Create user in database
if err := s.users.CreateUser(&user); err != nil {
return errors.WithMessage(err, "failed to create user in manager")
}
// Check if user already has a peer setup, if not, create one
return s.CreateUserDefaultPeer(user.Email)
}
func (s *Server) UpdateUser(user users.User) error {
if user.DeletedAt.Valid {
return s.DeleteUser(user)
}
currentUser := s.users.GetUserUnscoped(user.Email)
// Update in database
if err := s.users.UpdateUser(&user); err != nil {
return errors.WithMessage(err, "failed to update user in manager")
}
// If user was deleted (disabled), reactivate it's peers
if currentUser.DeletedAt.Valid {
for _, peer := range s.peers.GetPeersByMail(user.Email) {
now := time.Now()
peer.DeactivatedAt = nil
if err := s.UpdatePeer(peer, now); err != nil {
logrus.Errorf("failed to update (re)activated peer %s for %s: %v", peer.PublicKey, user.Email, err)
}
}
}
return nil
}
func (s *Server) DeleteUser(user users.User) error {
currentUser := s.users.GetUserUnscoped(user.Email)
// Update in database
if err := s.users.DeleteUser(&user); err != nil {
return errors.WithMessage(err, "failed to delete user in manager")
}
// If user was active, disable it's peers
if !currentUser.DeletedAt.Valid {
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 for %s: %v", peer.PublicKey, user.Email, err)
}
}
}
return nil
}
func (s *Server) CreateUserDefaultPeer(email string) error {
// Check if user is active, if not, quit
var existingUser *users.User
if existingUser = s.users.GetUser(email); existingUser == nil {
return nil
}
// Check if user already has a peer setup, if not, create one
if s.config.Core.CreateDefaultPeer {
peers := s.peers.GetPeersByMail(email)
if len(peers) == 0 { // Create default vpn peer
if err := s.CreatePeer(Peer{
Identifier: existingUser.Firstname + " " + existingUser.Lastname + " (Default)",
Email: existingUser.Email,
CreatedBy: existingUser.Email,
UpdatedBy: existingUser.Email,
}); err != nil {
return errors.WithMessagef(err, "failed to automatically create vpn peer for %s", email)
}
}
}
return nil
}

17
internal/users/config.go Normal file
View File

@@ -0,0 +1,17 @@
package users
type SupportedDatabase string
const (
SupportedDatabaseMySQL SupportedDatabase = "mysql"
SupportedDatabaseSQLite SupportedDatabase = "sqlite"
)
type Config struct {
Typ SupportedDatabase `yaml:"typ" envconfig:"DATABASE_TYPE"` //mysql or sqlite
Host string `yaml:"host" envconfig:"DATABASE_HOST"`
Port int `yaml:"port" envconfig:"DATABASE_PORT"`
Database string `yaml:"database" envconfig:"DATABASE_NAME"` // On SQLite: the database file-path, otherwise the database name
User string `yaml:"user" envconfig:"DATABASE_USERNAME"`
Password string `yaml:"password" envconfig:"DATABASE_PASSWORD"`
}

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

@@ -0,0 +1,272 @@
package users
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func GetDatabaseForConfig(cfg *Config) (db *gorm.DB, err error) {
switch cfg.Typ {
case SupportedDatabaseSQLite:
if _, err = os.Stat(filepath.Dir(cfg.Database)); os.IsNotExist(err) {
if err = os.MkdirAll(filepath.Dir(cfg.Database), 0700); err != nil {
return
}
}
db, err = gorm.Open(sqlite.Open(cfg.Database), &gorm.Config{})
if err != nil {
return
}
case SupportedDatabaseMySQL:
connectionString := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database)
db, err = gorm.Open(mysql.Open(connectionString), &gorm.Config{})
if err != nil {
return
}
sqlDB, _ := db.DB()
sqlDB.SetConnMaxLifetime(time.Minute * 5)
sqlDB.SetMaxIdleConns(2)
sqlDB.SetMaxOpenConns(10)
err = sqlDB.Ping() // This DOES open a connection if necessary. This makes sure the database is accessible
if err != nil {
return nil, errors.Wrap(err, "failed to ping mysql authentication database")
}
}
// Enable Logger (logrus)
logCfg := logger.Config{
SlowThreshold: time.Second, // all slower than one second
Colorful: false,
LogLevel: logger.Silent, // default: log nothing
}
if logrus.StandardLogger().GetLevel() == logrus.TraceLevel {
logCfg.LogLevel = logger.Info
logCfg.SlowThreshold = 500 * time.Millisecond // all slower than half a second
}
db.Config.Logger = logger.New(logrus.StandardLogger(), logCfg)
return
}
type Manager struct {
db *gorm.DB
}
func NewManager(cfg *Config) (*Manager, error) {
m := &Manager{}
var err error
m.db, err = GetDatabaseForConfig(cfg)
if err != nil {
return nil, errors.Wrapf(err, "failed to setup user database %s", cfg.Database)
}
// check if old user table exists (from version <= 1.0.2), if so rename it to peers.
if m.db.Migrator().HasTable("users") && !m.db.Migrator().HasTable("peers") {
if err := m.db.Migrator().RenameTable("users", "peers"); err != nil {
return nil, errors.Wrapf(err, "failed to migrate old database structure")
} else {
logrus.Infof("upgraded database format from version v1.0.2")
}
}
return m, m.MigrateUserDB()
}
func (m Manager) MigrateUserDB() error {
if err := m.db.AutoMigrate(&User{}); err != nil {
return errors.Wrap(err, "failed to migrate user database")
}
return nil
}
func (m Manager) GetUsers() []User {
users := make([]User, 0)
m.db.Find(&users)
return users
}
func (m Manager) GetUsersUnscoped() []User {
users := make([]User, 0)
m.db.Unscoped().Find(&users)
return users
}
func (m Manager) UserExists(email string) bool {
return m.GetUser(email) != nil
}
func (m Manager) GetUser(email string) *User {
user := User{}
m.db.Where("email = ?", email).First(&user)
if user.Email != email {
return nil
}
return &user
}
func (m Manager) GetUserUnscoped(email string) *User {
user := User{}
m.db.Unscoped().Where("email = ?", email).First(&user)
if user.Email != email {
return nil
}
return &user
}
func (m Manager) GetFilteredAndSortedUsers(sortKey, sortDirection, search string) []User {
users := make([]User, 0)
m.db.Find(&users)
filteredUsers := filterUsers(users, search)
sortUsers(filteredUsers, sortKey, sortDirection)
return filteredUsers
}
func (m Manager) GetFilteredAndSortedUsersUnscoped(sortKey, sortDirection, search string) []User {
users := make([]User, 0)
m.db.Unscoped().Find(&users)
filteredUsers := filterUsers(users, search)
sortUsers(filteredUsers, sortKey, sortDirection)
return filteredUsers
}
func (m Manager) GetOrCreateUser(email string) (*User, error) {
user := User{}
m.db.Where("email = ?", email).FirstOrInit(&user)
if user.Email != email {
user.Email = email
user.CreatedAt = time.Now()
user.UpdatedAt = time.Now()
user.IsAdmin = false
user.Source = UserSourceDatabase
res := m.db.Create(&user)
if res.Error != nil {
return nil, errors.Wrapf(res.Error, "failed to create user %s", email)
}
}
return &user, nil
}
func (m Manager) GetOrCreateUserUnscoped(email string) (*User, error) {
user := User{}
m.db.Unscoped().Where("email = ?", email).FirstOrInit(&user)
if user.Email != email {
user.Email = email
user.CreatedAt = time.Now()
user.UpdatedAt = time.Now()
user.IsAdmin = false
user.Source = UserSourceDatabase
res := m.db.Create(&user)
if res.Error != nil {
return nil, errors.Wrapf(res.Error, "failed to create user %s", email)
}
}
return &user, nil
}
func (m Manager) CreateUser(user *User) error {
res := m.db.Create(user)
if res.Error != nil {
return errors.Wrapf(res.Error, "failed to create user %s", user.Email)
}
return nil
}
func (m Manager) UpdateUser(user *User) error {
res := m.db.Save(user)
if res.Error != nil {
return errors.Wrapf(res.Error, "failed to update user %s", user.Email)
}
return nil
}
func (m Manager) DeleteUser(user *User) error {
res := m.db.Delete(user)
if res.Error != nil {
return errors.Wrapf(res.Error, "failed to update user %s", user.Email)
}
return nil
}
func sortUsers(users []User, key, direction string) {
sort.Slice(users, func(i, j int) bool {
var sortValueLeft string
var sortValueRight string
switch key {
case "email":
sortValueLeft = users[i].Email
sortValueRight = users[j].Email
case "firstname":
sortValueLeft = users[i].Firstname
sortValueRight = users[j].Firstname
case "lastname":
sortValueLeft = users[i].Lastname
sortValueRight = users[j].Lastname
case "phone":
sortValueLeft = users[i].Phone
sortValueRight = users[j].Phone
case "source":
sortValueLeft = string(users[i].Source)
sortValueRight = string(users[j].Source)
case "admin":
sortValueLeft = strconv.FormatBool(users[i].IsAdmin)
sortValueRight = strconv.FormatBool(users[j].IsAdmin)
}
if direction == "asc" {
return sortValueLeft < sortValueRight
} else {
return sortValueLeft > sortValueRight
}
})
}
func filterUsers(users []User, search string) []User {
if search == "" {
return users
}
filteredUsers := make([]User, 0, len(users))
for i := range users {
if strings.Contains(users[i].Email, search) ||
strings.Contains(users[i].Firstname, search) ||
strings.Contains(users[i].Lastname, search) ||
strings.Contains(string(users[i].Source), search) ||
strings.Contains(users[i].Phone, search) {
filteredUsers = append(filteredUsers, users[i])
}
}
return filteredUsers
}

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

@@ -0,0 +1,36 @@
package users
import (
"time"
"gorm.io/gorm"
)
type UserSource string
const (
UserSourceLdap UserSource = "ldap" // LDAP / ActiveDirectory
UserSourceDatabase UserSource = "db" // sqlite / mysql database
UserSourceOIDC UserSource = "oidc" // open id connect, TODO: implement
)
// User is the user model that gets linked to peer entries, by default an empty usermodel with only the email address is created
type User struct {
// required fields
Email string `gorm:"primaryKey" form:"email" binding:"required,email"`
Source UserSource
IsAdmin bool
// optional fields
Firstname string `form:"firstname" binding:"required"`
Lastname string `form:"lastname" binding:"required"`
Phone string `form:"phone" binding:"omitempty"`
// optional, integrated password authentication
Password string `form:"password" binding:"omitempty"`
// database internal fields
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}

View File

@@ -1,9 +1,10 @@
package wireguard
import (
"fmt"
"sync"
"github.com/pkg/errors"
"golang.zx2c4.com/wireguard/wgctrl"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
@@ -18,7 +19,7 @@ func (m *Manager) Init() error {
var err error
m.wg, err = wgctrl.New()
if err != nil {
return fmt.Errorf("could not create WireGuard client: %w", err)
return errors.Wrap(err, "could not create WireGuard client")
}
return nil
@@ -27,7 +28,7 @@ func (m *Manager) Init() error {
func (m *Manager) GetDeviceInfo() (*wgtypes.Device, error) {
dev, err := m.wg.Device(m.Cfg.DeviceName)
if err != nil {
return nil, fmt.Errorf("could not get WireGuard device: %w", err)
return nil, errors.Wrap(err, "could not get WireGuard device")
}
return dev, nil
@@ -39,7 +40,7 @@ func (m *Manager) GetPeerList() ([]wgtypes.Peer, error) {
dev, err := m.wg.Device(m.Cfg.DeviceName)
if err != nil {
return nil, fmt.Errorf("could not get WireGuard device: %w", err)
return nil, errors.Wrap(err, "could not get WireGuard device")
}
return dev.Peers, nil
@@ -51,12 +52,12 @@ func (m *Manager) GetPeer(pubKey string) (*wgtypes.Peer, error) {
publicKey, err := wgtypes.ParseKey(pubKey)
if err != nil {
return nil, fmt.Errorf("invalid public key: %w", err)
return nil, errors.Wrap(err, "invalid public key")
}
peers, err := m.GetPeerList()
if err != nil {
return nil, fmt.Errorf("could not get WireGuard peers: %w", err)
return nil, errors.Wrap(err, "could not get WireGuard peers")
}
for _, peer := range peers {
@@ -65,7 +66,7 @@ func (m *Manager) GetPeer(pubKey string) (*wgtypes.Peer, error) {
}
}
return nil, fmt.Errorf("could not find WireGuard peer: %s", pubKey)
return nil, errors.Errorf("could not find WireGuard peer: %s", pubKey)
}
func (m *Manager) AddPeer(cfg wgtypes.PeerConfig) error {
@@ -74,7 +75,7 @@ func (m *Manager) AddPeer(cfg wgtypes.PeerConfig) error {
err := m.wg.ConfigureDevice(m.Cfg.DeviceName, wgtypes.Config{Peers: []wgtypes.PeerConfig{cfg}})
if err != nil {
return fmt.Errorf("could not configure WireGuard device: %w", err)
return errors.Wrap(err, "could not configure WireGuard device")
}
return nil
@@ -87,7 +88,7 @@ func (m *Manager) UpdatePeer(cfg wgtypes.PeerConfig) error {
cfg.UpdateOnly = true
err := m.wg.ConfigureDevice(m.Cfg.DeviceName, wgtypes.Config{Peers: []wgtypes.PeerConfig{cfg}})
if err != nil {
return fmt.Errorf("could not configure WireGuard device: %w", err)
return errors.Wrap(err, "could not configure WireGuard device")
}
return nil
@@ -99,7 +100,7 @@ func (m *Manager) RemovePeer(pubKey string) error {
publicKey, err := wgtypes.ParseKey(pubKey)
if err != nil {
return fmt.Errorf("invalid public key: %w", err)
return errors.Wrap(err, "invalid public key")
}
peer := wgtypes.PeerConfig{
@@ -109,7 +110,7 @@ func (m *Manager) RemovePeer(pubKey string) error {
err = m.wg.ConfigureDevice(m.Cfg.DeviceName, wgtypes.Config{Peers: []wgtypes.PeerConfig{peer}})
if err != nil {
return fmt.Errorf("could not configure WireGuard device: %w", err)
return errors.Wrap(err, "could not configure WireGuard device")
}
return nil

View File

@@ -4,26 +4,28 @@ import (
"fmt"
"net"
"github.com/pkg/errors"
"github.com/milosgajdos/tenus"
)
const WireGuardDefaultMTU = 1420
const DefaultMTU = 1420
func (m *Manager) GetIPAddress() ([]string, error) {
wgInterface, err := tenus.NewLinkFrom(m.Cfg.DeviceName)
if err != nil {
return nil, fmt.Errorf("could not retrieve WireGuard interface %s: %w", m.Cfg.DeviceName, err)
return nil, errors.Wrapf(err, "could not retrieve WireGuard interface %s", m.Cfg.DeviceName)
}
// Get golang net.interface
iface := wgInterface.NetInterface()
if iface == nil { // Not sure if this check is really necessary
return nil, fmt.Errorf("could not retrieve WireGuard net.interface: %w", err)
return nil, errors.Wrap(err, "could not retrieve WireGuard net.interface")
}
addrs, err := iface.Addrs()
if err != nil {
return nil, fmt.Errorf("could not retrieve WireGuard ip addresses: %w", err)
return nil, errors.Wrap(err, "could not retrieve WireGuard ip addresses")
}
ipAddresses := make([]string, 0, len(addrs))
@@ -38,7 +40,7 @@ func (m *Manager) GetIPAddress() ([]string, error) {
ip = v.IP
mask = ip.DefaultMask()
}
if ip == nil {
if ip == nil || mask == nil {
continue // something is wrong?
}
@@ -53,34 +55,34 @@ func (m *Manager) GetIPAddress() ([]string, error) {
func (m *Manager) SetIPAddress(cidrs []string) error {
wgInterface, err := tenus.NewLinkFrom(m.Cfg.DeviceName)
if err != nil {
return fmt.Errorf("could not retrieve WireGuard interface %s: %w", m.Cfg.DeviceName, err)
return errors.Wrapf(err, "could not retrieve WireGuard interface %s", m.Cfg.DeviceName)
}
// First remove existing IP addresses
existingIPs, err := m.GetIPAddress()
if err != nil {
return err
return errors.Wrap(err, "could not retrieve IP addresses")
}
for _, cidr := range existingIPs {
wgIp, wgIpNet, err := net.ParseCIDR(cidr)
if err != nil {
return fmt.Errorf("unable to parse cidr %s: %w", cidr, err)
return errors.Wrapf(err, "unable to parse cidr %s", cidr)
}
if err := wgInterface.UnsetLinkIp(wgIp, wgIpNet); err != nil {
return fmt.Errorf("failed to unset ip %s: %w", cidr, err)
return errors.Wrapf(err, "failed to unset ip %s", cidr)
}
}
// Next set new IP adrresses
// Next set new IP addresses
for _, cidr := range cidrs {
wgIp, wgIpNet, err := net.ParseCIDR(cidr)
if err != nil {
return fmt.Errorf("unable to parse cidr %s: %w", cidr, err)
return errors.Wrapf(err, "unable to parse cidr %s", cidr)
}
if err := wgInterface.SetLinkIp(wgIp, wgIpNet); err != nil {
return fmt.Errorf("failed to set ip %s: %w", cidr, err)
return errors.Wrapf(err, "failed to set ip %s", cidr)
}
}
@@ -90,13 +92,13 @@ func (m *Manager) SetIPAddress(cidrs []string) error {
func (m *Manager) GetMTU() (int, error) {
wgInterface, err := tenus.NewLinkFrom(m.Cfg.DeviceName)
if err != nil {
return 0, fmt.Errorf("could not retrieve WireGuard interface %s: %w", m.Cfg.DeviceName, err)
return 0, errors.Wrapf(err, "could not retrieve WireGuard interface %s", m.Cfg.DeviceName)
}
// Get golang net.interface
iface := wgInterface.NetInterface()
if iface == nil { // Not sure if this check is really necessary
return 0, fmt.Errorf("could not retrieve WireGuard net.interface: %w", err)
return 0, errors.Wrap(err, "could not retrieve WireGuard net.interface")
}
return iface.MTU, nil
@@ -105,15 +107,15 @@ func (m *Manager) GetMTU() (int, error) {
func (m *Manager) SetMTU(mtu int) error {
wgInterface, err := tenus.NewLinkFrom(m.Cfg.DeviceName)
if err != nil {
return fmt.Errorf("could not retrieve WireGuard interface %s: %w", m.Cfg.DeviceName, err)
return errors.Wrapf(err, "could not retrieve WireGuard interface %s", m.Cfg.DeviceName)
}
if mtu == 0 {
mtu = WireGuardDefaultMTU
mtu = DefaultMTU
}
if err := wgInterface.SetLinkMTU(mtu); err != nil {
return fmt.Errorf("could not set MTU on interface %s: %w", m.Cfg.DeviceName, err)
return errors.Wrapf(err, "could not set MTU on interface %s", m.Cfg.DeviceName)
}
return nil