").appendTo(i),n=i.uniqueId().attr("id");return this._addClass(s,"ui-tooltip-content"),this._addClass(i,"ui-tooltip","ui-widget ui-widget-content"),i.appendTo(this._appendTo(e)),this.tooltips[n]={element:e,tooltip:i}},_find:function(t){var e=t.data("ui-tooltip-id");return e?this.tooltips[e]:null},_removeTooltip:function(t){t.remove(),delete this.tooltips[t.attr("id")]},_appendTo:function(t){var e=t.closest(".ui-front, dialog");return e.length||(e=this.document[0].body),e},_destroy:function(){var e=this;t.each(this.tooltips,function(i,s){var n=t.Event("blur"),o=s.element;n.target=n.currentTarget=o[0],e.close(n,!0),t("#"+i).remove(),o.data("ui-tooltip-title")&&(o.attr("title")||o.attr("title",o.data("ui-tooltip-title")),o.removeData("ui-tooltip-title"))}),this.liveRegion.remove()}}),t.uiBackCompat!==!1&&t.widget("ui.tooltip",t.ui.tooltip,{options:{tooltipClass:null},_tooltip:function(){var t=this._superApply(arguments);return this.options.tooltipClass&&t.tooltip.addClass(this.options.tooltipClass),t}}),t.ui.tooltip});
\ No newline at end of file
diff --git a/assets/tpl/admin_create_clients.html b/assets/tpl/admin_create_clients.html
deleted file mode 100644
index 3d59b41..0000000
--- a/assets/tpl/admin_create_clients.html
+++ /dev/null
@@ -1,73 +0,0 @@
-
-
-
-
-
-
{{ .Static.WebsiteTitle }} - Admin
-
-
-
-
-
-
-
-
-
-
- {{template "prt_nav.html" .}}
-
-
Create new clients
-
Enter valid user email addresses to quickly create new accounts.
- {{template "prt_flashes.html" .}}
-
-
- {{template "prt_footer.html" .}}
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/assets/tpl/admin_edit_client.html b/assets/tpl/admin_edit_client.html
deleted file mode 100644
index d7d5cbf..0000000
--- a/assets/tpl/admin_edit_client.html
+++ /dev/null
@@ -1,221 +0,0 @@
-
-
-
-
-
-
{{ .Static.WebsiteTitle }} - Admin
-
-
-
-
-
-
-
- {{template "prt_nav.html" .}}
-
- {{template "prt_flashes.html" .}}
-
-
- {{if eq .Device.Type "server"}}
- {{if .Peer.IsNew}}
-
Create a new client
- {{else}}
-
Edit client: {{.Peer.Identifier}}
- {{end}}
-
-
- {{end}}
-
-
- {{if eq .Device.Type "client"}}
- {{if .Peer.IsNew}}
-
Create a new remote endpoint
- {{else}}
-
Edit remote endpoint: {{.Peer.Identifier}}
- {{end}}
-
-
- {{end}}
-
- {{template "prt_footer.html" .}}
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/assets/tpl/admin_edit_interface.html b/assets/tpl/admin_edit_interface.html
deleted file mode 100644
index 737f006..0000000
--- a/assets/tpl/admin_edit_interface.html
+++ /dev/null
@@ -1,263 +0,0 @@
-
-
-
-
-
-
{{ .Static.WebsiteTitle }} - Admin
-
-
-
-
-
-
-
- {{template "prt_nav.html" .}}
-
-
Edit interface {{.Device.DeviceName}}
- {{template "prt_flashes.html" .}}
-
-
-
-
-
- {{template "prt_footer.html" .}}
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/assets/tpl/admin_edit_user.html b/assets/tpl/admin_edit_user.html
deleted file mode 100644
index 1218ca5..0000000
--- a/assets/tpl/admin_edit_user.html
+++ /dev/null
@@ -1,95 +0,0 @@
-
-
-
-
-
-
{{ .Static.WebsiteTitle }} - Users
-
-
-
-
-
-
-
- {{template "prt_nav.html" .}}
-
- {{if eq .User.CreatedAt .Epoch}}
-
Create a new user
- {{else}}
-
Edit user {{.User.Email}}
- {{end}}
-
- {{template "prt_flashes.html" .}}
-
-
-
- {{template "prt_footer.html" .}}
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/assets/tpl/admin_index.html b/assets/tpl/admin_index.html
deleted file mode 100644
index f4e5544..0000000
--- a/assets/tpl/admin_index.html
+++ /dev/null
@@ -1,278 +0,0 @@
-
-
-
-
-
-
{{ .Static.WebsiteTitle }} - Admin
-
-
-
-
-
-
-
- {{template "prt_nav.html" .}}
-
-
WireGuard VPN Administration
- {{template "prt_flashes.html" .}}
-
-
-
-
- {{if eq $.Device.Type "server"}}
-
-
-
-
- Public Key:
- {{.Device.PublicKey}}
-
-
- Public Endpoint:
- {{.Device.DefaultEndpoint}}
-
-
- Listening Port:
- {{.Device.ListenPort}}
-
-
- Enabled Peers:
- {{len .Device.Interface.Peers}}
-
-
- Total Peers:
- {{.TotalPeers}}
-
-
-
-
-
-
-
-
- IP Address:
- {{.Device.IPsStr}}
-
-
- Default allowed IP's:
- {{.Device.DefaultAllowedIPsStr}}
-
-
- Default DNS servers:
- {{.Device.DNSStr}}
-
-
- Default MTU:
- {{.Device.Mtu}}
-
-
- Default Keepalive Interval:
- {{.Device.DefaultPersistentKeepalive}}
-
-
-
-
- {{end}}
- {{if eq $.Device.Type "client"}}
-
-
-
-
- Public Key:
- {{.Device.PublicKey}}
-
-
- Enabled Endpoints:
- {{len .Device.Interface.Peers}}
-
-
- Total Endpoints:
- {{.TotalPeers}}
-
-
-
-
-
-
-
-
- IP Address:
- {{.Device.IPsStr}}
-
-
- DNS servers:
- {{.Device.DNSStr}}
-
-
- Default MTU:
- {{.Device.Mtu}}
-
-
-
-
- {{end}}
-
-
-
-
-
-
- {{if eq $.Device.Type "server"}}
-
Current VPN Peers
- {{end}}
- {{if eq $.Device.Type "client"}}
- Current VPN Endpoints
- {{end}}
-
-
-
- {{if eq $.Device.Type "server"}}
-
- {{end}}
-
-
-
-
-
-
-
-
- Identifier
- Public Key
- {{if eq $.Device.Type "server"}}
- E-Mail
- {{end}}
- {{if eq $.Device.Type "server"}}
- IP's
- {{end}}
- {{if eq $.Device.Type "client"}}
- Endpoint
- {{end}}
- Handshake
-
-
-
-
- {{range $i, $p :=.Peers}}
- {{$peerUser:=(userForEmail $.Users $p.Email)}}
-
-
-
-
-
-
- {{$p.Identifier}}{{if $p.WillExpire}} {{end}}
- {{$p.PublicKey}}
- {{if eq $.Device.Type "server"}}
- {{$p.Email}}
- {{end}}
- {{if eq $.Device.Type "server"}}
- {{$p.IPsStr}}
- {{end}}
- {{if eq $.Device.Type "client"}}
- {{$p.Endpoint}}
- {{end}}
- {{$p.LastHandshake}}
-
- {{if eq $.Session.IsAdmin true}}
-
- {{end}}
-
-
-
-
-
-
-
-
-
-
-
User details
- {{if not $peerUser}}
-
No user information available...
- {{else}}
-
- Firstname: {{$peerUser.Firstname}}
- Lastname: {{$peerUser.Lastname}}
- Phone: {{$peerUser.Phone}}
- Mail: {{$peerUser.Email}}
-
- {{end}}
-
Connection / Traffic
- {{if not $p.Peer}}
-
No Traffic data available...
- {{else}}
-
{{if $p.DeactivatedAt}}-{{else}} {{$p.Peer.Endpoint}}{{end}}
-
{{if $p.DeactivatedAt}}-{{else}} {{formatBytes $p.Peer.ReceiveBytes}} / {{formatBytes $p.Peer.TransmitBytes}}{{end}}
- {{end}}
-
- {{if eq $.Device.Type "server"}}
-
- {{end}}
-
-
-
-
- {{if eq $.Device.Type "server"}}
-
- {{end}}
-
-
- {{if $p.DeactivatedAt}}
-
Peer is disabled!
- {{end}}
- {{if $p.WillExpire}}
-
Peer will expire on {{ formatDate $p.ExpiresAt}}
- {{end}}
- {{if eq $.Device.Type "server"}}
-
- {{end}}
-
-
-
-
-
- {{end}}
-
-
-
Currently listed peers: {{len .Peers}}
-
-
- {{template "prt_footer.html" .}}
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/assets/tpl/admin_user_index.html b/assets/tpl/admin_user_index.html
deleted file mode 100644
index 87dd814..0000000
--- a/assets/tpl/admin_user_index.html
+++ /dev/null
@@ -1,69 +0,0 @@
-
-
-
-
-
-
{{ .Static.WebsiteTitle }} - Users
-
-
-
-
-
-
-
- {{template "prt_nav.html" .}}
-
-
WireGuard VPN Users
- {{template "prt_flashes.html" .}}
-
-
-
-
-
- E-Mail
- Lastname
- Firstname
- Source
- Is Admin
-
-
-
-
- {{range $i, $u :=.Users}}
-
- {{$u.Email}}
- {{$u.Lastname}}
- {{$u.Firstname}}
- {{$u.Source}}
- {{if $u.IsAdmin}}True{{else}}False{{end}}
-
- {{if eq $.Session.IsAdmin true}}
- {{if eq $u.Source "db"}}
-
- {{end}}
- {{end}}
-
-
- {{end}}
-
-
-
Currently listed users: {{len .Users}}
-
-
- {{template "prt_footer.html" .}}
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/assets/tpl/email.html b/assets/tpl/email.html
deleted file mode 100644
index 3bc2238..0000000
--- a/assets/tpl/email.html
+++ /dev/null
@@ -1,187 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Email Template
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{if $.User}}
- Hello {{$.User.Firstname}} {{$.User.Lastname}}
- {{else}}
- Hello
- {{end}}
-
-
- You or your administrator probably requested this VPN configuration. Scan the Qrcode or open the attached configuration file ({{$.Peer.GetConfigFileName}}) in the WireGuard VPN client to establish a secure VPN connection.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- About WireGuard
-
-
- 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.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/assets/tpl/error.html b/assets/tpl/error.html
deleted file mode 100644
index b2cda50..0000000
--- a/assets/tpl/error.html
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
-
{{ .Static.WebsiteTitle }} - Error
-
-
-
-
-
-
-
- {{template "prt_nav.html" .}}
-
- {{template "prt_footer.html" .}}
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/assets/tpl/index.html b/assets/tpl/index.html
deleted file mode 100644
index 10d93c5..0000000
--- a/assets/tpl/index.html
+++ /dev/null
@@ -1,89 +0,0 @@
-
-
-
-
-
-
-
{{ .Static.WebsiteTitle }}
-
-
-
-
-
-
-
- {{template "prt_nav.html" .}}
-
-
- {{template "prt_flashes.html" .}}
-
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.
-
More Information
-
-
-
-
-
-
Installation
-
Installation instructions for client software can be found on the official WireGuard website.
-
Open Instructions
-
-
-
-
-
-
-
-
About
-
WireGuard® is an extremely simple yet fast and modern VPN that utilizes state-of-the-art cryptography.
-
More details
-
-
-
-
-
-
-
-
WireGuard Portal
-
WireGuard Portal is a simple, web based configuration portal for WireGuard.
-
More details
-
-
-
-
-
-
-
VPN Profiles
-
You can access and download your personal VPN configurations via your Userprofile.
-
-
To find all your configured profiles click on the button below.
-
- Open My Profile
-
-
-
- {{with eq $.Session.LoggedIn true}}{{with eq $.Session.IsAdmin true}}
-
-
Administration Area
-
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.
-
-
To find all your configured profiles click on the button below.
-
- Open WireGuard Administration
- Open User Administration
-
-
- {{end}}{{end}}
-
-
- {{template "prt_footer.html" .}}
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/assets/tpl/login.html b/assets/tpl/login.html
deleted file mode 100644
index 402be7d..0000000
--- a/assets/tpl/login.html
+++ /dev/null
@@ -1,66 +0,0 @@
-
-
-
-
-
-
-
{{ .static.WebsiteTitle }} - Login
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{template "prt_flashes.html" .}}
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/assets/tpl/prt_flashes.html b/assets/tpl/prt_flashes.html
deleted file mode 100644
index f21c787..0000000
--- a/assets/tpl/prt_flashes.html
+++ /dev/null
@@ -1,5 +0,0 @@
-{{range $flash := $.Alerts}}
-
- {{$flash.Message}}
-
-{{end}}
\ No newline at end of file
diff --git a/assets/tpl/prt_footer.html b/assets/tpl/prt_footer.html
deleted file mode 100644
index f454568..0000000
--- a/assets/tpl/prt_footer.html
+++ /dev/null
@@ -1,5 +0,0 @@
-
\ No newline at end of file
diff --git a/assets/tpl/prt_nav.html b/assets/tpl/prt_nav.html
deleted file mode 100644
index e997a44..0000000
--- a/assets/tpl/prt_nav.html
+++ /dev/null
@@ -1,61 +0,0 @@
-
-
-
-
-
-
-
-
-
- {{with eq $.Session.LoggedIn true}}{{with eq $.Session.IsAdmin true}}
- {{with eq $.Route "/admin/"}}
-
- {{end}}
- {{with eq $.Route "/admin/users/"}}
-
- {{end}}
- {{end}}{{end}}
-
- {{with eq $.Session.LoggedIn true}}{{with eq $.Session.IsAdmin true}}
- {{with startsWith $.Route "/admin/"}}
-
- {{end}}
- {{end}}{{end}}
- {{if eq $.Session.LoggedIn true}}
-
- {{else}}
-
Login
- {{end}}
-
-
-{{if not $.Device.IsValid}}
-
-
Warning: WireGuard Interface {{$.Device.DeviceName}} is not fully configured! Configurations may be incomplete and non functional!
-
-{{end}}
\ No newline at end of file
diff --git a/assets/tpl/user_create_client.html b/assets/tpl/user_create_client.html
deleted file mode 100644
index 5e6540c..0000000
--- a/assets/tpl/user_create_client.html
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
{{ .Static.WebsiteTitle }} - Admin
-
-
-
-
-
-
-
- {{template "prt_nav.html" .}}
-
- {{template "prt_flashes.html" .}}
-
-
-
Create a new client
-
-
-
- {{template "prt_footer.html" .}}
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/assets/tpl/user_edit_client.html b/assets/tpl/user_edit_client.html
deleted file mode 100644
index 9d73174..0000000
--- a/assets/tpl/user_edit_client.html
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
-
-
-
-
{{ .Static.WebsiteTitle }} - Admin
-
-
-
-
-
-
-
- {{template "prt_nav.html" .}}
-
- {{template "prt_flashes.html" .}}
-
-
-
Edit client: {{.Peer.Identifier}}
-
-
-
- {{template "prt_footer.html" .}}
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/assets/tpl/user_index.html b/assets/tpl/user_index.html
deleted file mode 100644
index 4da4fae..0000000
--- a/assets/tpl/user_index.html
+++ /dev/null
@@ -1,135 +0,0 @@
-
-
-
-
-
-
{{ .Static.WebsiteTitle }} - Profile
-
-
-
-
-
-
-
- {{template "prt_nav.html" .}}
-
-
WireGuard VPN User-Portal
-
-
-
-
Your VPN Profiles
-
-
- {{if eq $.UserManagePeers true}}
-
- {{end}}
-
-
-
-
-
-
-
- Identifier
- Public Key
- E-Mail
- IP's
- Handshake
- {{if eq $.UserManagePeers true}}
-
- {{end}}
-
-
-
- {{range $i, $p :=.Peers}}
- {{$peerUser:=(userForEmail $.Users $p.Email)}}
-
-
-
-
-
-
- {{$p.Identifier}}{{if $p.WillExpire}} {{end}}
- {{$p.PublicKey}}
- {{$p.Email}}
- {{$p.IPsStr}}
- {{$p.LastHandshake}}
- {{if eq $.UserManagePeers true}}
-
-
-
- {{end}}
-
-
-
-
-
-
-
-
-
-
User details
- {{if not $peerUser}}
-
No user information available...
- {{else}}
-
- Firstname: {{$peerUser.Firstname}}
- Lastname: {{$peerUser.Lastname}}
- Phone: {{$peerUser.Phone}}
- Mail: {{$peerUser.Email}}
-
- {{end}}
-
Traffic
- {{if not $p.Peer}}
-
No Traffic data available...
- {{else}}
-
{{if $p.DeactivatedAt}}-{{else}} {{formatBytes $p.Peer.ReceiveBytes}} / {{formatBytes $p.Peer.TransmitBytes}}{{end}}
- {{end}}
-
-
-
-
-
-
-
-
- {{if $p.DeactivatedAt}}
-
Peer is disabled!
- {{end}}
- {{if $p.WillExpire}}
-
Profile expires on {{ formatDate $p.ExpiresAt}}
- {{end}}
-
-
-
-
-
-
- {{end}}
-
-
-
Currently listed peers: {{len .Peers}}
-
-
- {{template "prt_footer.html" .}}
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/cmd/hc/main.go b/cmd/hc/main.go
deleted file mode 100644
index ece44d1..0000000
--- a/cmd/hc/main.go
+++ /dev/null
@@ -1,35 +0,0 @@
-// source taken from https://git.prolicht.digital/golib/healthcheck/-/blob/master/cmd/hc/main.go
-
-package main
-
-import (
- "net/http"
- "os"
- "time"
-)
-
-// main checks the given URL, if the response is not 200, it will return with exit code 1
-// on success, exit code 0 will be returned
-func main() {
- os.Exit(checkWebEndpointFromArgs())
-}
-
-func checkWebEndpointFromArgs() int {
- if len(os.Args) < 2 {
- return 1
- }
- if status := checkWebEndpoint(os.Args[1]); !status {
- return 1
- }
- return 0
-}
-
-func checkWebEndpoint(url string) bool {
- client := &http.Client{
- Timeout: time.Second * 2,
- }
- if resp, err := client.Get(url); err != nil || resp.StatusCode < 200 || resp.StatusCode > 299 {
- return false
- }
- return true
-}
diff --git a/cmd/wg-portal/main.go b/cmd/wg-portal/main.go
index 430d92d..31f9024 100644
--- a/cmd/wg-portal/main.go
+++ b/cmd/wg-portal/main.go
@@ -2,103 +2,64 @@ package main
import (
"context"
- "io"
+ "fmt"
"os"
- "os/signal"
- "runtime"
"syscall"
- "time"
- "git.prolicht.digital/golib/healthcheck"
- "github.com/h44z/wg-portal/internal/server"
+ "github.com/h44z/wg-portal/internal"
+ "github.com/h44z/wg-portal/internal/adapters"
+ "github.com/h44z/wg-portal/internal/app"
+ "github.com/h44z/wg-portal/internal/config"
+ "github.com/h44z/wg-portal/internal/ports/api/core"
+ handlersV0 "github.com/h44z/wg-portal/internal/ports/api/v0/handlers"
"github.com/sirupsen/logrus"
+ evbus "github.com/vardius/message-bus"
)
func main() {
- _ = setupLogger(logrus.StandardLogger())
+ ctx := internal.SignalAwareContext(context.Background(), syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM)
- c := make(chan os.Signal, 1)
- signal.Notify(c, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
+ logrus.Infof("Starting web portal...")
- logrus.Infof("sysinfo: os=%s, arch=%s", runtime.GOOS, runtime.GOARCH)
- logrus.Infof("starting WireGuard Portal Server [%s]...", server.Version)
+ cfg, err := config.GetConfig()
+ internal.AssertNoError(err)
- // Context for clean shutdown
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
+ rawDb, err := adapters.NewDatabase(cfg.Database)
+ internal.AssertNoError(err)
- // start health check service on port 11223
- healthcheck.New(healthcheck.ListenOn("127.0.0.1:11223")).StartWithContext(ctx)
+ database, err := adapters.NewSqlRepository(rawDb)
+ internal.AssertNoError(err)
- service := server.Server{}
- if err := service.Setup(ctx); err != nil {
- logrus.Fatalf("setup failed: %v", err)
+ wireGuard := adapters.NewWireGuardRepository()
+
+ shouldExit, err := app.HandleProgramArgs(cfg, rawDb, wireGuard)
+ switch {
+ case shouldExit && err == nil:
+ return
+ case shouldExit && err != nil:
+ logrus.Errorf("failed to process program args: %v", err)
+ os.Exit(1)
+ case !shouldExit:
+ internal.AssertNoError(err)
}
- // Attach signal handlers to context
- go func() {
- osCall := <-c
- logrus.Tracef("received system call: %v", osCall)
- cancel() // cancel the context
- }()
+ queueSize := 100
+ eventBus := evbus.New(queueSize)
- // Start main process in background
- go service.Run()
+ backend, err := app.New(cfg, eventBus, database, wireGuard)
+ internal.AssertNoError(err)
+ backend.Users.StartBackgroundJobs(ctx)
- <-ctx.Done() // Wait until the context gets canceled
+ apiFrontend := handlersV0.NewRestApi(cfg, backend)
- // Give goroutines some time to stop gracefully
- logrus.Info("stopping WireGuard Portal Server...")
- time.Sleep(2 * time.Second)
+ webSrv, err := core.NewServer(cfg, apiFrontend)
+ internal.AssertNoError(err)
- logrus.Infof("stopped WireGuard Portal Server...")
- logrus.Exit(0)
-}
-
-func setupLogger(logger *logrus.Logger) error {
- // Check environment variables for logrus settings
- level, ok := os.LookupEnv("LOG_LEVEL")
- if !ok {
- level = "debug" // Default logrus level
- }
-
- useJSON, ok := os.LookupEnv("LOG_JSON")
- if !ok {
- useJSON = "false" // Default use human readable logging
- }
-
- useColor, ok := os.LookupEnv("LOG_COLOR")
- if !ok {
- useColor = "true"
- }
-
- switch level {
- case "off":
- logger.SetOutput(io.Discard)
- case "info":
- logger.SetLevel(logrus.InfoLevel)
- case "debug":
- logger.SetLevel(logrus.DebugLevel)
- case "trace":
- logger.SetLevel(logrus.TraceLevel)
- }
-
- var formatter logrus.Formatter
- if useJSON == "false" {
- f := new(logrus.TextFormatter)
- f.TimestampFormat = "2006-01-02 15:04:05"
- f.FullTimestamp = true
- if useColor == "true" {
- f.ForceColors = true
- }
- formatter = f
- } else {
- f := new(logrus.JSONFormatter)
- f.TimestampFormat = "2006-01-02 15:04:05"
- formatter = f
- }
-
- logger.SetFormatter(formatter)
-
- return nil
+ go webSrv.Run(ctx, cfg.Web.ListeningAddress)
+ fmt.Println(backend) // TODO: Remove
+
+ // wait until context gets cancelled
+ <-ctx.Done()
+
+ logrus.Infof("Stopped web portal")
}
diff --git a/efs.go b/efs.go
deleted file mode 100644
index cb7469b..0000000
--- a/efs.go
+++ /dev/null
@@ -1,12 +0,0 @@
-package wg_portal
-
-import "embed"
-
-//go:embed assets/tpl/*
-var Templates embed.FS
-
-//go:embed assets/css/*
-//go:embed assets/fonts/*
-//go:embed assets/img/*
-//go:embed assets/js/*
-var Statics embed.FS
diff --git a/go.mod b/go.mod
index 788e2b4..3cf6186 100644
--- a/go.mod
+++ b/go.mod
@@ -1,41 +1,95 @@
module github.com/h44z/wg-portal
-go 1.16
+go 1.19
require (
- git.prolicht.digital/golib/healthcheck v1.1.1
- github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 // indirect
- github.com/evanphx/json-patch v0.5.2
+ github.com/coreos/go-oidc v2.2.1+incompatible
+ github.com/coreos/go-oidc/v3 v3.5.0
+ github.com/gin-contrib/cors v1.4.0
github.com/gin-contrib/sessions v0.0.5
- github.com/gin-gonic/gin v1.7.7
- github.com/go-ldap/ldap/v3 v3.4.3
- github.com/go-openapi/spec v0.20.6 // indirect
- github.com/go-openapi/swag v0.21.1 // indirect
- github.com/go-playground/validator/v10 v10.11.0
- github.com/go-test/deep v1.0.8 // indirect
- github.com/golang/protobuf v1.5.2 // indirect
- github.com/google/go-cmp v0.5.8 // indirect
- github.com/json-iterator/go v1.1.12 // indirect
- github.com/kelseyhightower/envconfig v1.4.0
- github.com/mailru/easyjson v0.7.7 // indirect
- github.com/mattn/go-isatty v0.0.14 // indirect
- github.com/milosgajdos/tenus v0.0.3
- github.com/pkg/errors v0.9.1
- github.com/sirupsen/logrus v1.8.1
- github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
- github.com/swaggo/gin-swagger v1.4.3
- github.com/swaggo/swag v1.8.2
- github.com/tatsushid/go-fastping v0.0.0-20160109021039-d7bb493dee3e
+ github.com/gin-gonic/gin v1.8.2
+ github.com/go-ldap/ldap/v3 v3.4.4
+ github.com/h44z/lightmigrate v1.0.0
+ github.com/h44z/lightmigrate-mysql v0.0.0-20220114152421-d1fec9d056f1
+ github.com/sirupsen/logrus v1.9.0
+ github.com/stretchr/testify v1.8.1
+ github.com/swaggo/swag v1.8.9
github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca
- github.com/xhit/go-simple-mail/v2 v2.11.0
- golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e
- golang.org/x/net v0.0.0-20220526153639-5463443f8c37 // indirect
- golang.org/x/sync v0.0.0-20220513210516-0976fa681c29 // indirect
- golang.zx2c4.com/wireguard/wgctrl v0.0.0-20220504211119-3d4a969bb56b
- google.golang.org/protobuf v1.28.0 // indirect
- gopkg.in/yaml.v3 v3.0.0
- gorm.io/driver/mysql v1.4.3
- gorm.io/driver/sqlite v1.4.3
- gorm.io/gorm v1.24.0
+ github.com/vardius/message-bus v1.1.5
+ github.com/vishvananda/netlink v1.1.0
+ github.com/xhit/go-simple-mail/v2 v2.13.0
+ golang.org/x/crypto v0.5.0
+ golang.org/x/oauth2 v0.4.0
+ golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb
+ gopkg.in/yaml.v2 v2.4.0
+ gorm.io/driver/mysql v1.4.5
+ gorm.io/driver/postgres v1.4.6
+ gorm.io/driver/sqlite v1.4.4
+ gorm.io/driver/sqlserver v1.4.2
+ gorm.io/gorm v1.24.3
+)
+
+require (
+ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
+ github.com/KyleBanks/depth v1.2.1 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/dchest/uniuri v1.2.0 // indirect
+ github.com/ghodss/yaml v1.0.0 // indirect
+ github.com/gin-contrib/sse v0.1.0 // indirect
+ github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
+ github.com/go-jose/go-jose/v3 v3.0.0 // indirect
+ github.com/go-openapi/jsonpointer v0.19.6 // indirect
+ github.com/go-openapi/jsonreference v0.20.2 // indirect
+ github.com/go-openapi/spec v0.20.8 // indirect
+ github.com/go-openapi/swag v0.22.3 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.0 // indirect
+ github.com/go-playground/validator/v10 v10.11.1 // indirect
+ github.com/go-sql-driver/mysql v1.7.0 // indirect
+ github.com/go-test/deep v1.0.8 // indirect
+ github.com/goccy/go-json v0.10.0 // indirect
+ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
+ github.com/golang-sql/sqlexp v0.1.0 // indirect
+ github.com/golang/protobuf v1.5.2 // indirect
+ github.com/google/go-cmp v0.5.9 // indirect
+ github.com/gorilla/context v1.1.1 // indirect
+ github.com/gorilla/securecookie v1.1.1 // indirect
+ github.com/gorilla/sessions v1.2.1 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
+ github.com/jackc/pgx/v5 v5.2.0 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
+ github.com/josharian/native v1.1.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/leodido/go-urn v1.2.1 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/mattn/go-isatty v0.0.17 // indirect
+ github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
+ github.com/mdlayher/genetlink v1.3.1 // indirect
+ github.com/mdlayher/netlink v1.7.1 // indirect
+ github.com/mdlayher/socket v0.4.0 // indirect
+ github.com/microsoft/go-mssqldb v0.19.0 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/pelletier/go-toml/v2 v2.0.6 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/pquerna/cachecontrol v0.1.0 // indirect
+ github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b // indirect
+ github.com/stretchr/objx v0.5.0 // indirect
+ github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
+ github.com/ugorji/go/codec v1.2.8 // indirect
+ github.com/vishvananda/netns v0.0.2 // indirect
+ golang.org/x/net v0.5.0 // indirect
+ golang.org/x/sync v0.1.0 // indirect
+ golang.org/x/sys v0.4.0 // indirect
+ golang.org/x/text v0.6.0 // indirect
+ golang.org/x/tools v0.5.0 // indirect
+ golang.zx2c4.com/wireguard v0.0.0-20220920152132-bb719d3a6e2c // indirect
+ google.golang.org/appengine v1.6.7 // indirect
+ google.golang.org/protobuf v1.28.1 // indirect
+ gopkg.in/square/go-jose.v2 v2.6.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 911073a..76f79f8 100644
--- a/go.sum
+++ b/go.sum
@@ -1,39 +1,35 @@
-git.prolicht.digital/golib/healthcheck v1.1.1 h1:bdx0MuGqAq0PCooPpiuPXsr4/Ok+yfJwq8P9ITq2eLI=
-git.prolicht.digital/golib/healthcheck v1.1.1/go.mod h1:wEqVrqHJ8NsSx5qlFGUlw74wJ/wDSKaA34QoyvsEkdc=
-github.com/Azure/go-ntlmssp v0.0.0-20211209120228-48547f28849e h1:ZU22z/2YRFLyf/P4ZwUYSdNCWsMEI0VeyrFoI2rAhJQ=
-github.com/Azure/go-ntlmssp v0.0.0-20211209120228-48547f28849e/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.2/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0/go.mod h1:bhXu1AjYL+wutSL/kpSq6s7733q2Rb0yuot9Zgfqa/0=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w=
+github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
+github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
+github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
+github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
-github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
-github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
-github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
-github.com/antonlindstrom/pgstore v0.0.0-20200229204646-b08ebf1105e0/go.mod h1:2Ti6VUHVxpC0VSmTZzEvpzysnaGAfGBOoMIz5ykPyyw=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
-github.com/bos-hieu/mongostore v0.0.2/go.mod h1:8AbbVmDEb0yqJsBrWxZIAZOxIfv/tsP8CDtdHduZHGg=
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
-github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI=
-github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
-github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
-github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
-github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
-github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
+github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk=
+github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
+github.com/coreos/go-oidc/v3 v3.5.0 h1:VxKtbccHZxs8juq7RdJntSqtXFtde9YpNpGn0yqgEHw=
+github.com/coreos/go-oidc/v3 v3.5.0/go.mod h1:ecXRtV4romGPeO6ieExAsUK9cb/3fp9hXNz1tlv8PIM=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
-github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs=
-github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
-github.com/docker/libcontainer v2.2.1+incompatible h1:++SbbkCw+X8vAd4j2gOCzZ2Nn7s2xFALTf7LZKmM1/0=
-github.com/docker/libcontainer v2.2.1+incompatible/go.mod h1:osvj61pYsqhNCMLGX31xr7klUBhHb/ZBuXS0o1Fvwbw=
-github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
-github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
+github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g=
+github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY=
+github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
+github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/garyburd/redigo v1.6.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
+github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
-github.com/gin-contrib/gzip v0.0.5 h1:mhnVU32YnnBh2LPH2iqRqsA/eR7SAqRaD388jL2s/j0=
-github.com/gin-contrib/gzip v0.0.5/go.mod h1:OPIK6HR0Um2vNmBUTlayD7qle4yVVRZT0PyhdUigrKk=
+github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
+github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs=
github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963/go.mod h1:4lkInX8nHSR62NSmhXM3xtPeMSyfiR58NaEz+om1lHM=
github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE=
github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY=
@@ -41,62 +37,68 @@ github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NB
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
-github.com/gin-gonic/gin v1.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY=
-github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs=
-github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
+github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
+github.com/gin-gonic/gin v1.8.2 h1:UzKToD9/PoFj/V4rvlKqTRKnQYyz8Sc1MJlv4JHPtvY=
+github.com/gin-gonic/gin v1.8.2/go.mod h1:qw5AYuDrzRTnhvusDsrov+fDIxp9Dleuu12h8nfB398=
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A=
github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
-github.com/go-ldap/ldap/v3 v3.4.3 h1:JCKUtJPIcyOuG7ctGabLKMgIlKnGumD/iGjuWeEruDI=
-github.com/go-ldap/ldap/v3 v3.4.3/go.mod h1:7LdHfVt6iIOESVEe3Bs4Jp2sHEKgDeduAhgM1/f9qmo=
+github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
+github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
+github.com/go-ldap/ldap/v3 v3.4.4 h1:qPjipEpt+qDa6SI/h1fzuGWoRUY+qqQ9sOZq67/PYUs=
+github.com/go-ldap/ldap/v3 v3.4.4/go.mod h1:fe1MsuN5eJJ1FeLT/LEBVdWfNWKh459R7aXgXtJC+aI=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
-github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
-github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
-github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
+github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
+github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
-github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
-github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ=
-github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
+github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
+github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
+github.com/go-openapi/spec v0.20.8 h1:ubHmXNY3FCIOinT8RNrrPfGc9t7I1qhPtdOGoG2AxRU=
+github.com/go-openapi/spec v0.20.8/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
-github.com/go-openapi/swag v0.21.1 h1:wm0rhTb5z7qpJRHBdPOMuY4QjVUMbF6/kwoYeRAOrKU=
-github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
+github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
+github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
-github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
-github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
-github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
-github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
-github.com/go-playground/validator/v10 v10.11.0 h1:0W+xRM511GY47Yy3bZUbJVitCNg2BOGlCyvTqsp/xIw=
-github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
-github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
-github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
-github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
-github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
+github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ=
+github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
+github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
+github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
-github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
-github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
+github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA=
+github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
+github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
+github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
+github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
+github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
-github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
-github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
-github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
@@ -105,333 +107,228 @@ github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
-github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
-github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
-github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
-github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
-github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
-github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
-github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5lahk=
-github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
-github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
-github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
-github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
-github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
-github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
+github.com/h44z/lightmigrate v1.0.0 h1:wvkXvySwUTUuEMx0MAZaXzLa8vqspy0g5KwVfqLnpWQ=
+github.com/h44z/lightmigrate v1.0.0/go.mod h1:2QbrB1JaoGU+2kWOqf98jeULUSzJtdxovMYbdCwPyaE=
+github.com/h44z/lightmigrate-mysql v0.0.0-20220114152421-d1fec9d056f1 h1:HyKr9mclK5tvGlpipJTG6unzaJs8Jhh/WhcjpBJZM2s=
+github.com/h44z/lightmigrate-mysql v0.0.0-20220114152421-d1fec9d056f1/go.mod h1:xjqn0LXf6ly5GLm+6FDGi3+S20gDSExNYAm0k95rFzo=
+github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
-github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
-github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
-github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
-github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
-github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
-github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
-github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
-github.com/jackc/pgproto3/v2 v2.0.7/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
-github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
-github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
-github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
-github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
-github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0=
-github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po=
-github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ=
-github.com/jackc/pgtype v1.6.2/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig=
-github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
-github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
-github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
-github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA=
-github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o=
-github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg=
-github.com/jackc/pgx/v4 v4.10.1/go.mod h1:QlrWebbs3kqEZPHCTGyxecvzG6tvIsYu+A5b1raylkA=
-github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
-github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
-github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
-github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
-github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
-github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.2.0 h1:NdPpngX0Y6z6XDFKqmFQaE+bCtkqzvQIOt1wvBlAqs8=
+github.com/jackc/pgx/v5 v5.2.0/go.mod h1:Ptn7zmohNsWEsdxRawMzk3gaKma2obW+NWTnKa0S4nk=
+github.com/jackc/puddle/v2 v2.1.2/go.mod h1:2lpufsF5mRHO6SuZkm0fNYxM6SWHfvyFj62KwNzgels=
+github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
+github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
+github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
+github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
+github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc=
+github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
-github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
-github.com/josharian/native v1.0.0 h1:Ts/E8zCSEsG17dUqv7joXJFybuMLjQfWE04tsBODTxk=
-github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
+github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
+github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
-github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
-github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
-github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
-github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/kidstuff/mongostore v0.0.0-20181113001930-e650cd85ee4b/go.mod h1:g2nVr8KZVXJSS97Jo8pJ0jgq29P6H7dG0oplUA86MQw=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
-github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
-github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
-github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
-github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
-github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
-github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
-github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
-github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
-github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
-github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
-github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
-github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
-github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
-github.com/mattn/go-sqlite3 v1.14.5/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
+github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
+github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
-github.com/mdlayher/genetlink v1.2.0 h1:4yrIkRV5Wfk1WfpWTcoOlGmsWgQj3OtQN9ZsbrE+XtU=
-github.com/mdlayher/genetlink v1.2.0/go.mod h1:ra5LDov2KrUCZJiAtEvXXZBxGMInICMXIwshlJ+qRxQ=
-github.com/mdlayher/netlink v1.6.0 h1:rOHX5yl7qnlpiVkFWoqccueppMtXzeziFjWAjLg6sz0=
-github.com/mdlayher/netlink v1.6.0/go.mod h1:0o3PlBmGst1xve7wQ7j/hwpNaFaH4qCRyWCdcZk8/vA=
-github.com/mdlayher/socket v0.1.1/go.mod h1:mYV5YIZAfHh4dzDVzI8x8tWLWCliuX8Mon5Awbj+qDs=
-github.com/mdlayher/socket v0.2.3 h1:XZA2X2TjdOwNoNPVPclRCURoX/hokBY8nkTmRZFEheM=
-github.com/mdlayher/socket v0.2.3/go.mod h1:bz12/FozYNH/VbvC3q7TRIK/Y6dH1kCKsXaUeXi/FmY=
+github.com/mdlayher/genetlink v1.3.1 h1:roBiPnual+eqtRkKX2Jb8UQN5ZPWnhDCGj/wR6Jlz2w=
+github.com/mdlayher/genetlink v1.3.1/go.mod h1:uaIPxkWmGk753VVIzDtROxQ8+T+dkHqOI0vB1NA9S/Q=
+github.com/mdlayher/netlink v1.7.1 h1:FdUaT/e33HjEXagwELR8R3/KL1Fq5x3G5jgHLp/BTmg=
+github.com/mdlayher/netlink v1.7.1/go.mod h1:nKO5CSjE/DJjVhk/TNp6vCE1ktVxEA8VEh8drhZzxsQ=
+github.com/mdlayher/socket v0.4.0 h1:280wsy40IC9M9q1uPGcLBwXpcTQDtoGwVt+BNoITxIw=
+github.com/mdlayher/socket v0.4.0/go.mod h1:xxFqz5GRCUN3UEOm9CZqEJsAbe1C8OwSK46NlmWuVoc=
github.com/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc=
+github.com/microsoft/go-mssqldb v0.19.0 h1:LMRSgLcNMF8paPX14xlyQBmBH+jnFylPsYpVZf86eHM=
+github.com/microsoft/go-mssqldb v0.19.0/go.mod h1:ukJCBnnzLzpVF0qYRT+eg1e+eSwjeQ7IvenUv8QPook=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
-github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
-github.com/milosgajdos/tenus v0.0.3 h1:jmaJzwaY1DUyYVD0lM4U+uvP2kkEg1VahDqRFxIkVBE=
-github.com/milosgajdos/tenus v0.0.3/go.mod h1:eIjx29vNeDOYWJuCnaHY2r4fq5egetV26ry3on7p8qY=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
+github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
+github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
-github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U=
-github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
-github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
-github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
-github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
+github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
+github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
+github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
+github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
+github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
-github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8cTqKc=
+github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI=
github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg=
github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b h1:aUNXCGgukb4gtY99imuIeoh8Vr0GSwAlYxPAhqZrpFc=
github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg=
-github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
-github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
-github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
-github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
-github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
-github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
-github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
-github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
-github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
-github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
-github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
-github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
-github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
-github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
-github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
-github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
-github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
+github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
+github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/swaggo/files v0.0.0-20210815190702-a29dd2bc99b2/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
-github.com/swaggo/gin-swagger v1.4.3 h1:mHJz+yzJne0udgYnC5qlDf4e7KuxUbVNX2dhD/cw2rU=
-github.com/swaggo/gin-swagger v1.4.3/go.mod h1:hBg6tGeKJsUu/P79BH+WGUR8nq2LuGE0O160+s4iefo=
-github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ=
-github.com/swaggo/swag v1.8.2 h1:D4aBiVS2a65zhyk3WFqOUz7Rz0sOaUcgeErcid5uGL4=
-github.com/swaggo/swag v1.8.2/go.mod h1:jMLeXOOmYyjk8PvHTsXBdrubsNd9gUJTTCzL5iBnseg=
-github.com/tatsushid/go-fastping v0.0.0-20160109021039-d7bb493dee3e h1:nt2877sKfojlHCTOBXbpWjBkuWKritFaGIfgQwbQUls=
-github.com/tatsushid/go-fastping v0.0.0-20160109021039-d7bb493dee3e/go.mod h1:B4+Kq1u5FlULTjFSM707Q6e/cOHFv0z/6QRoxubDIQ8=
-github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/swaggo/swag v1.8.9 h1:kHtaBe/Ob9AZzAANfcn5c6RyCke9gG9QpH0jky0I/sA=
+github.com/swaggo/swag v1.8.9/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f h1:oqdnd6OGlOUu1InG37hWcCB3a+Jy3fwjylyVboaNMwY=
github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f/go.mod h1:X3Dd1SB8Gt1V968NTzpKFjMM6O8ccta2NPC6MprOxZQ=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
-github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
-github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
+github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
-github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
-github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
-github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
+github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
+github.com/ugorji/go/codec v1.2.8 h1:sgBJS6COt0b/P40VouWKdseidkDgHxYGm0SAglUHfP0=
+github.com/ugorji/go/codec v1.2.8/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca h1:lpvAjPK+PcxnbcB8H7axIb4fMNwjX9bE4DzwPjGg8aE=
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca/go.mod h1:XXKxNbpoLihvvT7orUZbs/iZayg1n4ip7iJakJPAwA8=
-github.com/wader/gormstore/v2 v2.0.0/go.mod h1:3BgNKFxRdVo2E4pq3e/eiim8qRDZzaveaIcIvu2T8r0=
-github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
-github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
-github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
-github.com/xhit/go-simple-mail/v2 v2.11.0 h1:o/056V50zfkO3Mm5tVdo9rG3ryg4ZmJ2XW5GMinHfVs=
-github.com/xhit/go-simple-mail/v2 v2.11.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
-github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
-github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
-go.mongodb.org/mongo-driver v1.9.0/go.mod h1:0sQWfOeY63QTntERDJJ/0SuKK0T1uVSgKCuAROlKEPY=
-go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
-go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
-go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
-go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
-go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
-go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
-go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
-go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
+github.com/vardius/message-bus v1.1.5 h1:YSAC2WB4HRlwc4neFPTmT88kzzoiQ+9WRRbej/E/LZc=
+github.com/vardius/message-bus v1.1.5/go.mod h1:6xladCV2lMkUAE4bzzS85qKOiB5miV7aBVRafiTJGqw=
+github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0=
+github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
+github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU=
+github.com/vishvananda/netns v0.0.2 h1:Cn05BRLm+iRP/DZxyVSsfVyrzgjDbwHwkVt38qvXnNI=
+github.com/vishvananda/netns v0.0.2/go.mod h1:yitZXdAVI+yPFSb4QUe+VW3vOVl4PZPNcBgbPxAtJxw=
+github.com/xhit/go-simple-mail/v2 v2.13.0 h1:OANWU9jHZrVfBkNkvLf8Ww0fexwpQVF/v/5f96fFTLI=
+github.com/xhit/go-simple-mail/v2 v2.13.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
-golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
-golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
-golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
+golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
-golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
-golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 h1:kQgndtyPBW/JIYERgdxfwMYh3AVStj88WQTlNDi2a+o=
-golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
+golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
+golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
+golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
-golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20210928044308-7d9f5e0b762b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220418201149-a630d4f3e7a2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220526153639-5463443f8c37 h1:lUkvobShwKsOesNfWWlCS5q7fnbG1MEliIzwu886fn8=
-golang.org/x/net v0.0.0-20220526153639-5463443f8c37/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
+golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
+golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
+golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
+golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk=
+golang.org/x/oauth2 v0.4.0 h1:NF0gk8LVPg1Ml7SSbGyySuoxdsXitj7TvgvuRxIMc/M=
+golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220513210516-0976fa681c29 h1:w8s32wxx3sY+OjLlv9qltkLU5yvJzxjjgiHWLjdIcw4=
-golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220315194320-039c03cc5b86/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
+golang.org/x/sys v0.0.0-20220224120231-95c6836cb0e7/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
+golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
+golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
-golang.org/x/tools v0.1.10 h1:QjFRCZxdOhBJ/UNgnBZLbNV13DlbnK0quyivTnXJM20=
-golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
-golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.5.0 h1:+bSpV5HIeWkuvgaMfI3UmKRThoTA5ODJTUd8T17NO+4=
+golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
-golang.zx2c4.com/wireguard v0.0.0-20220407013110-ef5c587f782d h1:q4JksJ2n0fmbXC0Aj0eOs6E0AcPqnKglxWXWFqGD6x0=
-golang.zx2c4.com/wireguard v0.0.0-20220407013110-ef5c587f782d/go.mod h1:bVQfyl2sCM/QIIGHpWbFGfHPuDvqnCNkT6MQLTCjO/U=
-golang.zx2c4.com/wireguard/wgctrl v0.0.0-20220504211119-3d4a969bb56b h1:9JncmKXcUwE918my+H6xmjBdhK2jM/UTUNXxhRG1BAk=
-golang.zx2c4.com/wireguard/wgctrl v0.0.0-20220504211119-3d4a969bb56b/go.mod h1:yp4gl6zOlnDGOZeWeDfMwQcsdOIQnMdhuPx9mwwWBL4=
+golang.zx2c4.com/wireguard v0.0.0-20220920152132-bb719d3a6e2c h1:Okh6a1xpnJslG9Mn84pId1Mn+Q8cvpo4HCeeFWHo0cA=
+golang.zx2c4.com/wireguard v0.0.0-20220920152132-bb719d3a6e2c/go.mod h1:enML0deDxY1ux+B6ANGiwtg0yAJi1rctkTpcHNAVPyg=
+golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb h1:9aqVcYEDHmSNb0uOWukxV5lHV09WqiSiCuhEgWNETLY=
+golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb/go.mod h1:mQqgjkW8GQQcJQsbBvK890TKqUK1DfKWkuBGbOkuMHQ=
+google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
+google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
+google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -440,28 +337,31 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
-gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
+gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
+gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
+gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
+gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
-gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gorm.io/driver/mysql v1.0.4/go.mod h1:MEgp8tk2n60cSBCq5iTcPDw3ns8Gs+zOva9EUhkknTs=
-gorm.io/driver/mysql v1.4.3 h1:/JhWJhO2v17d8hjApTltKNADm7K7YI2ogkR7avJUL3k=
-gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
-gorm.io/driver/postgres v1.0.8/go.mod h1:4eOzrI1MUfm6ObJU/UcmbXyiHSs8jSwH95G5P5dxcAg=
-gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9DYw=
-gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
-gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
-gorm.io/gorm v1.20.7/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
-gorm.io/gorm v1.20.12/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/mysql v1.4.5 h1:u1lytId4+o9dDaNcPCFzNv7h6wvmc92UjNk3z8enSBU=
+gorm.io/driver/mysql v1.4.5/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8oc=
+gorm.io/driver/postgres v1.4.6 h1:1FPESNXqIKG5JmraaH2bfCVlMQ7paLoCreFxDtqzwdc=
+gorm.io/driver/postgres v1.4.6/go.mod h1:UJChCNLFKeBqQRE+HrkFUbKbq9idPXmTOk2u4Wok8S4=
+gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc=
+gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
+gorm.io/driver/sqlserver v1.4.2 h1:nMtEeKqv2R/vv9FoHUFWfXfP6SskAgRar0TPlZV1stk=
+gorm.io/driver/sqlserver v1.4.2/go.mod h1:XHwBuB4Tlh7DqO0x7Ema8dmyWsQW7wi38VQOAFkrbXY=
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
-gorm.io/gorm v1.24.0 h1:j/CoiSm6xpRpmzbFJsQHYj+I8bGYWLXVHeYEyyKlF74=
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
-honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
+gorm.io/gorm v1.24.3 h1:WL2ifUmzR/SLp85CSURAfybcHnGZ+yLSGSxgYXlFBHg=
+gorm.io/gorm v1.24.3/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
diff --git a/internal/adapters/database.go b/internal/adapters/database.go
new file mode 100644
index 0000000..c2eb02a
--- /dev/null
+++ b/internal/adapters/database.go
@@ -0,0 +1,527 @@
+package adapters
+
+import (
+ "context"
+ "embed"
+ "errors"
+ "fmt"
+ "github.com/sirupsen/logrus"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/h44z/lightmigrate"
+ "github.com/h44z/lightmigrate-mysql/mysql"
+ "github.com/h44z/wg-portal/internal/config"
+ "github.com/h44z/wg-portal/internal/domain"
+ gormMySQL "gorm.io/driver/mysql"
+ "gorm.io/driver/postgres"
+ "gorm.io/driver/sqlite"
+ "gorm.io/driver/sqlserver"
+ "gorm.io/gorm"
+)
+
+//go:embed migrations/*.sql
+var sqlMigrationFs embed.FS
+var SchemaVersion uint64 = 1
+
+func NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
+ var gormDb *gorm.DB
+ var err error
+
+ switch cfg.Type {
+ case config.DatabaseMySQL:
+ gormDb, err = gorm.Open(gormMySQL.Open(cfg.DSN), &gorm.Config{})
+ if err != nil {
+ return nil, fmt.Errorf("failed to open MySQL database: %w", err)
+ }
+
+ sqlDB, _ := gormDb.DB()
+ sqlDB.SetConnMaxLifetime(time.Minute * 5)
+ sqlDB.SetMaxIdleConns(2)
+ sqlDB.SetMaxOpenConns(10)
+ err = sqlDB.Ping() // This DOES open a connection if necessary. This makes sure the database is accessible
+ if err != nil {
+ return nil, fmt.Errorf("failed to ping MySQL database: %w", err)
+ }
+ case config.DatabaseMsSQL:
+ gormDb, err = gorm.Open(sqlserver.Open(cfg.DSN), &gorm.Config{})
+ if err != nil {
+ return nil, fmt.Errorf("failed to open sqlserver database: %w", err)
+ }
+ case config.DatabasePostgres:
+ gormDb, err = gorm.Open(postgres.Open(cfg.DSN), &gorm.Config{})
+ if err != nil {
+ return nil, fmt.Errorf("failed to open Postgres database: %w", err)
+ }
+ case config.DatabaseSQLite:
+ if _, err = os.Stat(filepath.Dir(cfg.DSN)); os.IsNotExist(err) {
+ if err = os.MkdirAll(filepath.Dir(cfg.DSN), 0700); err != nil {
+ return nil, fmt.Errorf("failed to create database base directory: %w", err)
+ }
+ }
+ gormDb, err = gorm.Open(sqlite.Open(cfg.DSN), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
+ if err != nil {
+ return nil, fmt.Errorf("failed to open sqlite database: %w", err)
+ }
+ }
+
+ return gormDb, nil
+}
+
+type SqlRepo struct {
+ db *gorm.DB
+}
+
+func NewSqlRepository(db *gorm.DB) (*SqlRepo, error) {
+ repo := &SqlRepo{
+ db: db,
+ }
+
+ err := repo.migrate()
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize database: %w", err)
+ }
+
+ return repo, nil
+}
+
+func (r *SqlRepo) migrate() error {
+ // TODO: REMOVE
+ logrus.Warnf("user migration: %v", r.db.AutoMigrate(&domain.User{}))
+ logrus.Warnf("interface migration: %v", r.db.AutoMigrate(&domain.Interface{}))
+ logrus.Warnf("peer migration: %v", r.db.AutoMigrate(&domain.Peer{}))
+ // TODO: REMOVE THE ABOVE LINES
+
+ rawDb, err := r.db.DB()
+ if err != nil {
+ return fmt.Errorf("failed to get raw db handle: %w", err)
+ }
+
+ driver, err := mysql.NewDriver(rawDb, "migration_test_db", mysql.WithLocking(false)) // without locking, the mysql driver also works for sqlite =)
+ if err != nil {
+ return fmt.Errorf("unable to setup driver: %w", err)
+ }
+ defer driver.Close()
+
+ source, err := lightmigrate.NewFsSource(sqlMigrationFs, "migrations")
+ if err != nil {
+ return fmt.Errorf("failed to open migration source fs: %w", err)
+ }
+ defer source.Close()
+
+ migrator, err := lightmigrate.NewMigrator(source, driver, lightmigrate.WithVerboseLogging(true))
+ if err != nil {
+ return fmt.Errorf("unable to setup migrator: %w", err)
+ }
+
+ err = migrator.Migrate(SchemaVersion)
+ if err != nil && !errors.Is(err, lightmigrate.ErrNoChange) {
+ return fmt.Errorf("failed to migrate database schema: %w", err)
+ }
+
+ return nil
+}
+
+// region interfaces
+
+func (r *SqlRepo) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) {
+ var in domain.Interface
+
+ err := r.db.WithContext(ctx).First(&in, id).Error
+
+ if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, domain.ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &in, nil
+}
+
+func (r *SqlRepo) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error) {
+ in, err := r.GetInterface(ctx, id)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to load interface: %w", err)
+ }
+
+ peers, err := r.GetInterfacePeers(ctx, id)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to load peers: %w", err)
+ }
+
+ return in, peers, nil
+}
+
+func (r *SqlRepo) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
+ var interfaces []domain.Interface
+
+ err := r.db.WithContext(ctx).Preload("Addresses").Find(&interfaces).Error
+ if err != nil {
+ return nil, err
+ }
+
+ return interfaces, nil
+}
+
+func (r *SqlRepo) FindInterfaces(ctx context.Context, search string) ([]domain.Interface, error) {
+ var users []domain.Interface
+
+ searchValue := "%" + strings.ToLower(search) + "%"
+ err := r.db.WithContext(ctx).
+ Where("identifier LIKE ?", searchValue).
+ Or("display_name LIKE ?", searchValue).
+ Preload("Addresses").
+ Find(&users).Error
+ if err != nil {
+ return nil, err
+ }
+
+ return users, nil
+}
+
+func (r *SqlRepo) SaveInterface(ctx context.Context, id domain.InterfaceIdentifier, updateFunc func(in *domain.Interface) (*domain.Interface, error)) error {
+ err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ in, err := r.getOrCreateInterface(tx, id)
+ if err != nil {
+ return err // return any error will roll back
+ }
+
+ in, err = updateFunc(in)
+ if err != nil {
+ return err
+ }
+
+ err = r.upsertInterface(tx, in)
+ if err != nil {
+ return err
+ }
+
+ // return nil will commit the whole transaction
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (r *SqlRepo) getOrCreateInterface(tx *gorm.DB, id domain.InterfaceIdentifier) (*domain.Interface, error) {
+ var in domain.Interface
+
+ // interfaceDefaults will be applied to newly created interface records
+ interfaceDefaults := domain.Interface{
+ BaseModel: domain.BaseModel{
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ },
+ Identifier: id,
+ }
+
+ err := tx.Attrs(interfaceDefaults).FirstOrCreate(&in, id).Error
+ if err != nil {
+ return nil, err
+ }
+
+ return &in, nil
+}
+
+func (r *SqlRepo) upsertInterface(tx *gorm.DB, in *domain.Interface) error {
+ err := tx.Save(in).Error
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (r *SqlRepo) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error {
+ err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ err := r.db.WithContext(ctx).Where("interface_identifier = ?", id).Delete(&domain.Peer{}).Error
+ if err != nil {
+ return err
+ }
+
+ err = r.db.WithContext(ctx).Delete(&domain.Interface{}, id).Error
+ if err != nil {
+ return err
+ }
+
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (r *SqlRepo) GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIdentifier][]domain.Cidr, error) {
+ var ips []struct {
+ domain.Cidr
+ InterfaceId domain.InterfaceIdentifier `gorm:"column:interface_identifier"`
+ }
+
+ err := r.db.WithContext(ctx).
+ Table("interface_addresses").
+ Joins("LEFT JOIN cidrs ON interface_addresses.cidr_addr = cidrs.addr AND interface_addresses.cidr_net_length = cidrs.net_len").
+ Scan(&ips).Error
+ if err != nil {
+ return nil, err
+ }
+
+ result := make(map[domain.InterfaceIdentifier][]domain.Cidr)
+ for _, ip := range ips {
+ result[ip.InterfaceId] = append(result[ip.InterfaceId], ip.Cidr)
+ }
+ return result, nil
+}
+
+// endregion interfaces
+
+// region peers
+
+func (r *SqlRepo) GetInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.Peer, error) {
+ var peers []domain.Peer
+
+ err := r.db.WithContext(ctx).Where("interface_identifier = ?", id).Find(&peers).Error
+ if err != nil {
+ return nil, err
+ }
+
+ return peers, nil
+}
+
+func (r *SqlRepo) FindInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier, search string) ([]domain.Peer, error) {
+ var peers []domain.Peer
+
+ searchValue := "%" + strings.ToLower(search) + "%"
+ err := r.db.WithContext(ctx).Where("interface_identifier = ?", id).
+ Where("identifier LIKE ?", searchValue).
+ Or("display_name LIKE ?", searchValue).
+ Or("iface_address_str_v LIKE ?", searchValue).
+ Find(&peers).Error
+ if err != nil {
+ return nil, err
+ }
+
+ return peers, nil
+}
+
+func (r *SqlRepo) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) {
+ var peers []domain.Peer
+
+ err := r.db.WithContext(ctx).Where("user_identifier = ?", id).Find(&peers).Error
+ if err != nil {
+ return nil, err
+ }
+
+ return peers, nil
+}
+
+func (r *SqlRepo) FindUserPeers(ctx context.Context, id domain.UserIdentifier, search string) ([]domain.Peer, error) {
+ var peers []domain.Peer
+
+ searchValue := "%" + strings.ToLower(search) + "%"
+ err := r.db.WithContext(ctx).Where("user_identifier = ?", id).
+ Where("identifier LIKE ?", searchValue).
+ Or("display_name LIKE ?", searchValue).
+ Or("iface_address_str_v LIKE ?", searchValue).
+ Find(&peers).Error
+ if err != nil {
+ return nil, err
+ }
+
+ return peers, nil
+}
+
+func (r *SqlRepo) SavePeer(ctx context.Context, id domain.PeerIdentifier, updateFunc func(in *domain.Peer) (*domain.Peer, error)) error {
+ err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ peer, err := r.getOrCreatePeer(tx, id)
+ if err != nil {
+ return err // return any error will roll back
+ }
+
+ peer, err = updateFunc(peer)
+ if err != nil {
+ return err
+ }
+
+ err = r.upsertPeer(tx, peer)
+ if err != nil {
+ return err
+ }
+
+ // return nil will commit the whole transaction
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (r *SqlRepo) getOrCreatePeer(tx *gorm.DB, id domain.PeerIdentifier) (*domain.Peer, error) {
+ var peer domain.Peer
+
+ // interfaceDefaults will be applied to newly created interface records
+ interfaceDefaults := domain.Peer{
+ BaseModel: domain.BaseModel{
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ },
+ Identifier: id,
+ }
+
+ err := tx.Attrs(interfaceDefaults).FirstOrCreate(&peer, id).Error
+ if err != nil {
+ return nil, err
+ }
+
+ return &peer, nil
+}
+
+func (r *SqlRepo) upsertPeer(tx *gorm.DB, peer *domain.Peer) error {
+ err := tx.Save(peer).Error
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (r *SqlRepo) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error {
+ err := r.db.WithContext(ctx).Delete(&domain.Peer{}, id).Error
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// endregion peers
+
+// region users
+
+func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
+ var user domain.User
+
+ err := r.db.WithContext(ctx).First(&user, id).Error
+
+ if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, domain.ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &user, nil
+}
+
+func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
+ var users []domain.User
+
+ err := r.db.WithContext(ctx).Find(&users).Error
+ if err != nil {
+ return nil, err
+ }
+
+ return users, nil
+}
+
+func (r *SqlRepo) FindUsers(ctx context.Context, search string) ([]domain.User, error) {
+ var users []domain.User
+
+ searchValue := "%" + strings.ToLower(search) + "%"
+ err := r.db.WithContext(ctx).
+ Where("identifier LIKE ?", searchValue).
+ Or("firstname LIKE ?", searchValue).
+ Or("lastname LIKE ?", searchValue).
+ Or("email LIKE ?", searchValue).
+ Find(&users).Error
+ if err != nil {
+ return nil, err
+ }
+
+ return users, nil
+}
+
+func (r *SqlRepo) SaveUser(ctx context.Context, id domain.UserIdentifier, updateFunc func(u *domain.User) (*domain.User, error)) error {
+ userInfo := domain.GetUserInfo(ctx)
+
+ err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ user, err := r.getOrCreateUser(string(userInfo.Id), tx, id)
+ if err != nil {
+ return err // return any error will roll back
+ }
+
+ user, err = updateFunc(user)
+ if err != nil {
+ return err
+ }
+
+ user.UpdatedAt = time.Now()
+ user.UpdatedBy = string(userInfo.Id)
+
+ err = r.upsertUser(tx, user)
+ if err != nil {
+ return err
+ }
+
+ // return nil will commit the whole transaction
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (r *SqlRepo) DeleteUser(ctx context.Context, id domain.UserIdentifier) error {
+ err := r.db.WithContext(ctx).Delete(&domain.User{}, id).Error
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (r *SqlRepo) getOrCreateUser(creator string, tx *gorm.DB, id domain.UserIdentifier) (*domain.User, error) {
+ var user domain.User
+
+ // userDefaults will be applied to newly created user records
+ userDefaults := domain.User{
+ BaseModel: domain.BaseModel{
+ CreatedBy: creator,
+ UpdatedBy: creator,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ },
+ Identifier: id,
+ Source: domain.UserSourceDatabase,
+ IsAdmin: false,
+ }
+
+ err := tx.Attrs(userDefaults).FirstOrCreate(&user, id).Error
+ if err != nil {
+ return nil, err
+ }
+
+ return &user, nil
+}
+
+func (r *SqlRepo) upsertUser(tx *gorm.DB, user *domain.User) error {
+ err := tx.Save(user).Error
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// endregion users
diff --git a/internal/adapters/database_integration_test.go b/internal/adapters/database_integration_test.go
new file mode 100644
index 0000000..fe79c53
--- /dev/null
+++ b/internal/adapters/database_integration_test.go
@@ -0,0 +1,43 @@
+//go:build integration
+
+package adapters
+
+import (
+ "database/sql"
+ "fmt"
+
+ "github.com/stretchr/testify/assert"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+
+ "testing"
+)
+
+func tempSqliteDb(t *testing.T) *gorm.DB {
+
+ // github.com/mattn/go-sqlite3
+ db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return db
+}
+
+func Test_sqlRepo_migrate(t *testing.T) {
+ db := tempSqliteDb(t)
+
+ r := SqlRepo{db: db}
+
+ err := r.migrate()
+ assert.NoError(t, err)
+
+ // check result
+ var sqlStatement []sql.NullString
+ db.Raw("SELECT sql FROM sqlite_master").Find(&sqlStatement)
+ fmt.Println("Table Schemas:")
+ for _, stm := range sqlStatement {
+ if stm.Valid {
+ fmt.Println(stm.String)
+ }
+ }
+}
diff --git a/internal/adapters/filesystem.go b/internal/adapters/filesystem.go
new file mode 100644
index 0000000..f3f8a83
--- /dev/null
+++ b/internal/adapters/filesystem.go
@@ -0,0 +1,45 @@
+package adapters
+
+import (
+ "context"
+ "io"
+ "os"
+ "path/filepath"
+)
+
+type filesystemRepo struct {
+ basePath string
+}
+
+func NewFileSystemRepository(basePath string) (*filesystemRepo, error) {
+ r := &filesystemRepo{basePath: basePath}
+
+ if err := os.MkdirAll(r.basePath, os.ModePerm); err != nil {
+ return nil, err
+ }
+
+ return r, nil
+}
+
+func (r *filesystemRepo) WriteFile(_ context.Context, path string, contents io.Reader) error {
+ filePath := filepath.Join(r.basePath, path)
+
+ err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm)
+ if err != nil {
+ return err
+ }
+
+ file, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.ModePerm)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ _, err = io.Copy(file, contents)
+ if err != nil {
+ return err
+ }
+
+ return nil
+
+}
diff --git a/internal/adapters/mailer.go b/internal/adapters/mailer.go
new file mode 100644
index 0000000..469873e
--- /dev/null
+++ b/internal/adapters/mailer.go
@@ -0,0 +1,150 @@
+package adapters
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "time"
+
+ "github.com/h44z/wg-portal/internal/config"
+ "github.com/h44z/wg-portal/internal/domain"
+ mail "github.com/xhit/go-simple-mail/v2"
+)
+
+type mailRepo struct {
+ cfg *config.MailConfig
+}
+
+func NewSmtpMailRepo(cfg *config.MailConfig) *mailRepo {
+ return &mailRepo{cfg: cfg}
+}
+
+// Send sends a mail.
+func (r *mailRepo) Send(_ context.Context, subject, body string, to []string, options *domain.MailOptions) error {
+ if options == nil {
+ options = &domain.MailOptions{}
+ }
+ r.setDefaultOptions(r.cfg.From, options)
+
+ if len(to) == 0 {
+ return errors.New("missing email recipient")
+ }
+
+ uniqueTo := r.uniqueAddresses(to)
+ email := mail.NewMSG()
+ email.SetFrom(r.cfg.From).
+ AddTo(uniqueTo...).
+ SetReplyTo(options.ReplyTo).
+ SetSubject(subject).
+ SetBody(mail.TextPlain, body)
+
+ if len(options.Cc) > 0 {
+ // the underlying mail library does not allow the same address to appear in TO and CC... so filter entries that are already included
+ // in the TO addresses
+ cc := r.removeDuplicates(r.uniqueAddresses(options.Cc), uniqueTo)
+ email.AddCc(cc...)
+ }
+ if len(options.Bcc) > 0 {
+ // the underlying mail library does not allow the same address to appear in TO or CC and BCC... so filter entries that are already
+ //included in the TO and CC addresses
+ bcc := r.removeDuplicates(r.uniqueAddresses(options.Bcc), uniqueTo)
+ bcc = r.removeDuplicates(bcc, options.Cc)
+
+ email.AddCc(r.uniqueAddresses(options.Bcc)...)
+ }
+ if options.HtmlBody != "" {
+ email.AddAlternative(mail.TextHTML, options.HtmlBody)
+ }
+
+ for _, attachment := range options.Attachments {
+ attachmentData, err := ioutil.ReadAll(attachment.Data)
+ if err != nil {
+ return fmt.Errorf("failed to read attachment data for %s: %w", attachment.Name, err)
+ }
+
+ if attachment.Embedded {
+ email.AddInlineData(attachmentData, attachment.Name, attachment.ContentType)
+ } else {
+ email.AddAttachmentData(attachmentData, attachment.Name, attachment.ContentType)
+ }
+ }
+
+ // Call Send and pass the client
+ srv := r.getMailServer()
+ client, err := srv.Connect()
+ if err != nil {
+ return fmt.Errorf("failed to connect to SMTP server: %w", err)
+ }
+
+ err = email.Send(client)
+ if err != nil {
+ return fmt.Errorf("failed to send email: %w", err)
+ }
+
+ return nil
+}
+
+func (r *mailRepo) setDefaultOptions(sender string, options *domain.MailOptions) {
+ if options.ReplyTo == "" {
+ options.ReplyTo = sender
+ }
+}
+
+func (r *mailRepo) getMailServer() *mail.SMTPServer {
+ srv := mail.NewSMTPClient()
+
+ srv.ConnectTimeout = 30 * time.Second
+ srv.SendTimeout = 30 * time.Second
+ srv.Host = r.cfg.Host
+ srv.Port = r.cfg.Port
+ srv.Username = r.cfg.Username
+ srv.Password = r.cfg.Password
+
+ switch r.cfg.Encryption {
+ case config.MailEncryptionTLS:
+ srv.Encryption = mail.EncryptionSSLTLS
+ case config.MailEncryptionStartTLS:
+ srv.Encryption = mail.EncryptionSTARTTLS
+ default: // MailEncryptionNone
+ srv.Encryption = mail.EncryptionNone
+ }
+ srv.TLSConfig = &tls.Config{ServerName: srv.Host, InsecureSkipVerify: !r.cfg.CertValidation}
+ switch r.cfg.AuthType {
+ case config.MailAuthPlain:
+ srv.Authentication = mail.AuthPlain
+ case config.MailAuthLogin:
+ srv.Authentication = mail.AuthLogin
+ case config.MailAuthCramMD5:
+ srv.Authentication = mail.AuthCRAMMD5
+ }
+
+ return srv
+}
+
+// uniqueAddresses removes duplicates in the given string slice
+func (r *mailRepo) uniqueAddresses(slice []string) []string {
+ keys := make(map[string]struct{})
+ uniqueSlice := make([]string, 0, len(slice))
+ for _, entry := range slice {
+ if _, exists := keys[entry]; !exists {
+ keys[entry] = struct{}{}
+ uniqueSlice = append(uniqueSlice, entry)
+ }
+ }
+ return uniqueSlice
+}
+
+func (r *mailRepo) removeDuplicates(slice []string, remove []string) []string {
+ uniqueSlice := make([]string, 0, len(slice))
+
+ for _, i := range remove {
+ for _, j := range slice {
+ if i != j {
+ uniqueSlice = append(uniqueSlice, j)
+ }
+ }
+ }
+ return uniqueSlice
+}
diff --git a/internal/adapters/migrations/1_init.down.sql b/internal/adapters/migrations/1_init.down.sql
new file mode 100644
index 0000000..1b10e6f
--- /dev/null
+++ b/internal/adapters/migrations/1_init.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS test;
\ No newline at end of file
diff --git a/internal/adapters/migrations/1_init.up.sql b/internal/adapters/migrations/1_init.up.sql
new file mode 100644
index 0000000..acae08b
--- /dev/null
+++ b/internal/adapters/migrations/1_init.up.sql
@@ -0,0 +1,3 @@
+CREATE TABLE IF NOT EXISTS test (
+ firstname VARCHAR(16)
+);
\ No newline at end of file
diff --git a/internal/adapters/wireguard.go b/internal/adapters/wireguard.go
new file mode 100644
index 0000000..c7978a5
--- /dev/null
+++ b/internal/adapters/wireguard.go
@@ -0,0 +1,412 @@
+package adapters
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+
+ "github.com/h44z/wg-portal/internal/domain"
+ "github.com/h44z/wg-portal/internal/lowlevel"
+ "github.com/vishvananda/netlink"
+ "golang.zx2c4.com/wireguard/wgctrl"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+)
+
+type wgRepo struct {
+ wg lowlevel.WireGuardClient
+ nl lowlevel.NetlinkClient
+}
+
+func NewWireGuardRepository() *wgRepo {
+ wg, err := wgctrl.New()
+ if err != nil {
+ panic("failed to init wgctrl: " + err.Error())
+ }
+
+ nl := &lowlevel.NetlinkManager{}
+
+ repo := &wgRepo{
+ wg: wg,
+ nl: nl,
+ }
+
+ return repo
+}
+
+func (r *wgRepo) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) {
+ devices, err := r.wg.Devices()
+ if err != nil {
+ return nil, fmt.Errorf("device list error: %w", err)
+ }
+
+ interfaces := make([]domain.PhysicalInterface, 0, len(devices))
+ for _, device := range devices {
+ interfaceModel, err := r.convertWireGuardInterface(device)
+ if err != nil {
+ return nil, fmt.Errorf("interface convert failed for %s: %w", device.Name, err)
+ }
+ interfaces = append(interfaces, interfaceModel)
+ }
+
+ return interfaces, nil
+}
+
+func (r *wgRepo) GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) {
+ return r.getInterface(id)
+}
+
+func (r *wgRepo) GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) {
+ device, err := r.wg.Device(string(deviceId))
+ if err != nil {
+ return nil, fmt.Errorf("device error: %w", err)
+ }
+
+ peers := make([]domain.PhysicalPeer, 0, len(device.Peers))
+ for _, peer := range device.Peers {
+ peerModel, err := r.convertWireGuardPeer(&peer)
+ if err != nil {
+ return nil, fmt.Errorf("peer convert failed for %v: %w", peer.PublicKey, err)
+ }
+ peers = append(peers, peerModel)
+ }
+
+ return peers, nil
+}
+
+func (r *wgRepo) GetPeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) (*domain.PhysicalPeer, error) {
+ return r.getPeer(deviceId, id)
+}
+
+func (r *wgRepo) convertWireGuardInterface(device *wgtypes.Device) (domain.PhysicalInterface, error) {
+ // read data from wgctrl interface
+
+ iface := domain.PhysicalInterface{
+ Identifier: domain.InterfaceIdentifier(device.Name),
+ KeyPair: domain.KeyPair{
+ PrivateKey: device.PrivateKey.String(),
+ PublicKey: device.PublicKey.String(),
+ },
+ ListenPort: device.ListenPort,
+ Addresses: nil,
+ Mtu: 0,
+ FirewallMark: int32(device.FirewallMark),
+ DeviceUp: false,
+ ImportSource: "wgctrl",
+ DeviceType: device.Type.String(),
+ BytesUpload: 0,
+ BytesDownload: 0,
+ }
+
+ // read data from netlink interface
+
+ lowLevelInterface, err := r.nl.LinkByName(device.Name)
+ if err != nil {
+ return domain.PhysicalInterface{}, fmt.Errorf("netlink error for %s: %w", device.Name, err)
+ }
+ ipAddresses, err := r.nl.AddrList(lowLevelInterface)
+ if err != nil {
+ return domain.PhysicalInterface{}, fmt.Errorf("ip read error for %s: %w", device.Name, err)
+ }
+
+ for _, addr := range ipAddresses {
+ iface.Addresses = append(iface.Addresses, domain.CidrFromNetlinkAddr(addr))
+ }
+ iface.Mtu = lowLevelInterface.Attrs().MTU
+ iface.DeviceUp = lowLevelInterface.Attrs().OperState == netlink.OperUnknown // wg only supports unknown
+ if stats := lowLevelInterface.Attrs().Statistics; stats != nil {
+ iface.BytesUpload = stats.TxBytes
+ iface.BytesDownload = stats.RxBytes
+ }
+
+ return iface, nil
+}
+
+func (r *wgRepo) convertWireGuardPeer(peer *wgtypes.Peer) (domain.PhysicalPeer, error) {
+ peerModel := domain.PhysicalPeer{
+ Identifier: domain.PeerIdentifier(peer.PublicKey.String()),
+ Endpoint: "",
+ AllowedIPs: nil,
+ KeyPair: domain.KeyPair{
+ PublicKey: peer.PublicKey.String(),
+ },
+ PresharedKey: "",
+ PersistentKeepalive: int(peer.PersistentKeepaliveInterval.Seconds()),
+ LastHandshake: peer.LastHandshakeTime,
+ ProtocolVersion: peer.ProtocolVersion,
+ BytesUpload: uint64(peer.ReceiveBytes),
+ BytesDownload: uint64(peer.TransmitBytes),
+ }
+
+ for _, addr := range peer.AllowedIPs {
+ peerModel.AllowedIPs = append(peerModel.AllowedIPs, domain.CidrFromIpNet(addr))
+ }
+ if peer.Endpoint != nil {
+ peerModel.Endpoint = peer.Endpoint.String()
+ }
+ if peer.PresharedKey != (wgtypes.Key{}) {
+ peerModel.PresharedKey = domain.PreSharedKey(peer.PresharedKey.String())
+ }
+
+ return peerModel, nil
+}
+
+func (r *wgRepo) SaveInterface(_ context.Context, id domain.InterfaceIdentifier, updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error)) error {
+ physicalInterface, err := r.getOrCreateInterface(id)
+ if err != nil {
+ return err
+ }
+
+ if updateFunc != nil {
+ physicalInterface, err = updateFunc(physicalInterface)
+ if err != nil {
+ return err
+ }
+ }
+
+ if err := r.updateLowLevelInterface(physicalInterface); err != nil {
+ return err
+ }
+ if err := r.updateWireGuardInterface(physicalInterface); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (r *wgRepo) getOrCreateInterface(id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) {
+ device, err := r.getInterface(id)
+ if err == nil {
+ return device, nil
+ }
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, fmt.Errorf("device error: %w", err)
+ }
+
+ // create new device
+ if err := r.createLowLevelInterface(id); err != nil {
+ return nil, err
+ }
+
+ device, err = r.getInterface(id)
+ return device, err
+}
+
+func (r *wgRepo) getInterface(id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) {
+ device, err := r.wg.Device(string(id))
+ if err != nil {
+ return nil, err
+ }
+
+ pi, err := r.convertWireGuardInterface(device)
+ return &pi, err
+}
+
+func (r *wgRepo) createLowLevelInterface(id domain.InterfaceIdentifier) error {
+ link := &netlink.GenericLink{
+ LinkAttrs: netlink.LinkAttrs{
+ Name: string(id),
+ },
+ LinkType: "wireguard",
+ }
+ err := r.nl.LinkAdd(link)
+ if err != nil {
+ return fmt.Errorf("link add failed: %w", err)
+ }
+
+ return nil
+}
+
+func (r *wgRepo) updateLowLevelInterface(pi *domain.PhysicalInterface) error {
+ link, err := r.nl.LinkByName(string(pi.Identifier))
+ if err != nil {
+ return err
+ }
+ if pi.Mtu != 0 {
+ if err := r.nl.LinkSetMTU(link, pi.Mtu); err != nil {
+ return fmt.Errorf("mtu error: %w", err)
+ }
+ }
+
+ for i, addr := range pi.Addresses {
+ var err error
+ if i == 0 {
+ err = r.nl.AddrReplace(link, addr.NetlinkAddr())
+ } else {
+ err = r.nl.AddrAdd(link, addr.NetlinkAddr())
+ }
+ if err != nil {
+ return fmt.Errorf("failed to set ip %s: %w", addr.String(), err)
+ }
+ }
+
+ // Update link state
+ if pi.DeviceUp {
+ if err := r.nl.LinkSetUp(link); err != nil {
+ return fmt.Errorf("failed to bring up device: %w", err)
+ }
+ } else {
+ if err := r.nl.LinkSetDown(link); err != nil {
+ return fmt.Errorf("failed to bring down device: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func (r *wgRepo) updateWireGuardInterface(pi *domain.PhysicalInterface) error {
+ pKey, err := wgtypes.NewKey(pi.KeyPair.GetPrivateKeyBytes())
+ if err != nil {
+ return err
+ }
+
+ var fwMark *int
+ if pi.FirewallMark != 0 {
+ *fwMark = int(pi.FirewallMark)
+ }
+ err = r.wg.ConfigureDevice(string(pi.Identifier), wgtypes.Config{
+ PrivateKey: &pKey,
+ ListenPort: &pi.ListenPort,
+ FirewallMark: fwMark,
+ ReplacePeers: false,
+ })
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (r *wgRepo) DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error {
+ if err := r.deleteLowLevelInterface(id); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (r *wgRepo) deleteLowLevelInterface(id domain.InterfaceIdentifier) error {
+ link, err := r.nl.LinkByName(string(id))
+ if err != nil {
+ return fmt.Errorf("unable to find low level interface: %w", err)
+ }
+
+ err = r.nl.LinkDel(link)
+ if err != nil {
+ return fmt.Errorf("failed to delete low level interface: %w", err)
+ }
+
+ return nil
+}
+
+func (r *wgRepo) SavePeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier, updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error)) error {
+ physicalPeer, err := r.getOrCreatePeer(deviceId, id)
+ if err != nil {
+ return err
+ }
+
+ physicalPeer, err = updateFunc(physicalPeer)
+ if err != nil {
+ return err
+ }
+
+ if err := r.updatePeer(deviceId, physicalPeer); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (r *wgRepo) getOrCreatePeer(deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) (*domain.PhysicalPeer, error) {
+ peer, err := r.getPeer(deviceId, id)
+ if err == nil {
+ return peer, nil
+ }
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, fmt.Errorf("peer error: %w", err)
+ }
+
+ // create new peer
+ err = r.wg.ConfigureDevice(string(deviceId), wgtypes.Config{Peers: []wgtypes.PeerConfig{{
+ PublicKey: id.ToPublicKey(),
+ }}})
+
+ peer, err = r.getPeer(deviceId, id)
+ return peer, nil
+}
+
+func (r *wgRepo) getPeer(deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) (*domain.PhysicalPeer, error) {
+ if !id.IsPublicKey() {
+ return nil, errors.New("invalid public key")
+ }
+
+ device, err := r.wg.Device(string(deviceId))
+ if err != nil {
+ return nil, err
+ }
+
+ publicKey := id.ToPublicKey()
+ for _, peer := range device.Peers {
+ if peer.PublicKey != publicKey {
+ continue
+ }
+
+ peerModel, err := r.convertWireGuardPeer(&peer)
+ return &peerModel, err
+ }
+
+ return nil, os.ErrNotExist
+}
+
+func (r *wgRepo) updatePeer(deviceId domain.InterfaceIdentifier, pp *domain.PhysicalPeer) error {
+ cfg := wgtypes.PeerConfig{
+ PublicKey: pp.GetPublicKey(),
+ Remove: false,
+ UpdateOnly: true,
+ PresharedKey: pp.GetPresharedKey(),
+ Endpoint: pp.GetEndpointAddress(),
+ PersistentKeepaliveInterval: pp.GetPersistentKeepaliveTime(),
+ ReplaceAllowedIPs: true,
+ AllowedIPs: nil,
+ }
+
+ ips, err := pp.GetAllowedIPs()
+ if err != nil {
+ return err
+ }
+ cfg.AllowedIPs = ips
+
+ err = r.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}})
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (r *wgRepo) DeletePeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error {
+ if !id.IsPublicKey() {
+ return errors.New("invalid public key")
+ }
+
+ err := r.deletePeer(deviceId, id)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (r *wgRepo) deletePeer(deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error {
+ cfg := wgtypes.PeerConfig{
+ PublicKey: id.ToPublicKey(),
+ Remove: true,
+ }
+
+ err := r.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}})
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/internal/adapters/wireguard_integration_test.go b/internal/adapters/wireguard_integration_test.go
new file mode 100644
index 0000000..07aaac7
--- /dev/null
+++ b/internal/adapters/wireguard_integration_test.go
@@ -0,0 +1,121 @@
+//go:build integration
+
+package adapters
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/h44z/wg-portal/internal/domain"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// setup WireGuard manager with no linked store
+func setup(t *testing.T) *wgRepo {
+ if getProcessOwner() != "root" {
+ t.Fatalf("this tests need to be executed as root user")
+ }
+
+ repo := NewWireGuardRepository()
+
+ return repo
+}
+
+func getProcessOwner() string {
+ stdout, err := exec.Command("ps", "-o", "user=", "-p", strconv.Itoa(os.Getpid())).Output()
+ if err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+ return strings.TrimSpace(string(stdout))
+}
+
+func Test_wgRepository_GetInterfaces(t *testing.T) {
+ mgr := setup(t)
+
+ interfaceName := domain.InterfaceIdentifier("wg_test_001")
+ defer mgr.DeleteInterface(context.Background(), interfaceName)
+ err := mgr.SaveInterface(context.Background(), interfaceName, nil)
+ require.NoError(t, err)
+
+ interfaceName2 := domain.InterfaceIdentifier("wg_test_002")
+ defer mgr.DeleteInterface(context.Background(), interfaceName2)
+ err = mgr.SaveInterface(context.Background(), interfaceName2, nil)
+ require.NoError(t, err)
+
+ interfaces, err := mgr.GetInterfaces(context.Background())
+ assert.NoError(t, err)
+ assert.Len(t, interfaces, 2)
+ for _, iface := range interfaces {
+ assert.True(t, iface.Identifier == interfaceName || iface.Identifier == interfaceName2)
+ }
+}
+
+func TestWireGuardCreateInterface(t *testing.T) {
+ mgr := setup(t)
+
+ interfaceName := domain.InterfaceIdentifier("wg_test_001")
+ ipAddress := "10.11.12.13"
+ ipV6Address := "1337:d34d:b33f::2"
+ defer mgr.DeleteInterface(context.Background(), interfaceName)
+
+ err := mgr.SaveInterface(context.Background(), interfaceName, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
+ pi.Addresses = []domain.Cidr{
+ {Ip: domain.IpAddress(net.ParseIP(ipAddress)), NetLength: 24, Bits: 32},
+ {Ip: domain.IpAddress(net.ParseIP(ipV6Address)), NetLength: 64, Bits: 128},
+ }
+ return pi, nil
+ })
+ assert.NoError(t, err)
+
+ // Validate that the interface has been created
+ cmd := exec.Command("ip", "addr")
+ out, err := cmd.CombinedOutput()
+ assert.NoError(t, err)
+ assert.Contains(t, string(out), interfaceName)
+ assert.Contains(t, string(out), ipAddress)
+ assert.Contains(t, string(out), ipV6Address)
+}
+
+func TestWireGuardUpdateInterface(t *testing.T) {
+ mgr := setup(t)
+
+ interfaceName := domain.InterfaceIdentifier("wg_test_001")
+ defer mgr.DeleteInterface(context.Background(), interfaceName)
+
+ err := mgr.SaveInterface(context.Background(), interfaceName, nil)
+ require.NoError(t, err)
+
+ cmd := exec.Command("ip", "addr")
+ out, err := cmd.CombinedOutput()
+ require.NoError(t, err)
+ require.Contains(t, string(out), interfaceName)
+
+ ipAddress := "10.11.12.13"
+ ipV6Address := "1337:d34d:b33f::2"
+ err = mgr.SaveInterface(context.Background(), interfaceName, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
+ pi.Addresses = []domain.Cidr{
+ {Ip: domain.IpAddress(net.ParseIP(ipAddress)), NetLength: 24, Bits: 32},
+ {Ip: domain.IpAddress(net.ParseIP(ipV6Address)), NetLength: 64, Bits: 128},
+ }
+ return pi, nil
+ })
+ assert.NoError(t, err)
+
+ // Validate that the interface has been updated
+ cmd = exec.Command("ip", "addr")
+ out, err = cmd.CombinedOutput()
+ assert.NoError(t, err)
+ assert.Contains(t, string(out), interfaceName)
+ assert.Contains(t, string(out), ipAddress)
+ assert.Contains(t, string(out), ipV6Address)
+}
diff --git a/internal/app/auth.go b/internal/app/auth.go
new file mode 100644
index 0000000..4412196
--- /dev/null
+++ b/internal/app/auth.go
@@ -0,0 +1,367 @@
+package app
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "net/url"
+ "path"
+ "strings"
+ "time"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+ evbus "github.com/vardius/message-bus"
+
+ "github.com/h44z/wg-portal/internal/config"
+ "github.com/h44z/wg-portal/internal/domain"
+)
+
+type authUserManager interface {
+ Get(context.Context, domain.UserIdentifier) (*domain.User, error)
+ Register(ctx context.Context, user *domain.User) error
+}
+
+type authenticator struct {
+ cfg *config.Auth
+ bus evbus.MessageBus
+
+ oauthAuthenticators map[string]domain.OauthAuthenticator
+ ldapAuthenticators map[string]domain.LdapAuthenticator
+
+ users authUserManager
+}
+
+func newAuthenticator(cfg *config.Auth, bus evbus.MessageBus, users authUserManager) (*authenticator, error) {
+ a := &authenticator{
+ cfg: cfg,
+ bus: bus,
+ users: users,
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ err := a.setupExternalAuthProviders(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ return a, nil
+}
+
+func (a *authenticator) setupExternalAuthProviders(ctx context.Context) error {
+ extUrl, err := url.Parse(a.cfg.CallbackUrlPrefix)
+ if err != nil {
+ return fmt.Errorf("failed to parse external url: %w", err)
+ }
+
+ a.oauthAuthenticators = make(map[string]domain.OauthAuthenticator, len(a.cfg.OpenIDConnect)+len(a.cfg.OAuth))
+ a.ldapAuthenticators = make(map[string]domain.LdapAuthenticator, len(a.cfg.Ldap))
+
+ for i := range a.cfg.OpenIDConnect { // OIDC
+ providerCfg := &a.cfg.OpenIDConnect[i]
+ providerId := strings.ToLower(providerCfg.ProviderName)
+
+ if _, exists := a.oauthAuthenticators[providerId]; exists {
+ return fmt.Errorf("auth provider with name %s is already registerd", providerId)
+ }
+
+ redirectUrl := *extUrl
+ redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", providerId, "/callback")
+
+ provider, err := newOidcAuthenticator(ctx, redirectUrl.String(), providerCfg)
+ if err != nil {
+ return fmt.Errorf("failed to setup oidc authentication provider %s: %w", providerCfg.ProviderName, err)
+ }
+ a.oauthAuthenticators[providerId] = provider
+ }
+ for i := range a.cfg.OAuth { // PLAIN OAUTH
+ providerCfg := &a.cfg.OAuth[i]
+ providerId := strings.ToLower(providerCfg.ProviderName)
+
+ if _, exists := a.oauthAuthenticators[providerId]; exists {
+ return fmt.Errorf("auth provider with name %s is already registerd", providerId)
+ }
+
+ redirectUrl := *extUrl
+ redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", providerId, "/callback")
+
+ provider, err := newPlainOauthAuthenticator(ctx, redirectUrl.String(), providerCfg)
+ if err != nil {
+ return fmt.Errorf("failed to setup oauth authentication provider %s: %w", providerId, err)
+ }
+ a.oauthAuthenticators[providerId] = provider
+ }
+ for i := range a.cfg.Ldap { // LDAP
+ providerCfg := &a.cfg.Ldap[i]
+ providerId := strings.ToLower(providerCfg.URL)
+
+ if _, exists := a.ldapAuthenticators[providerId]; exists {
+ return fmt.Errorf("auth provider with name %s is already registerd", providerId)
+ }
+
+ provider, err := newLdapAuthenticator(ctx, providerCfg)
+ if err != nil {
+ return fmt.Errorf("failed to setup ldap authentication provider %s: %w", providerId, err)
+ }
+ a.ldapAuthenticators[providerId] = provider
+ }
+
+ return nil
+}
+
+func (a *authenticator) GetExternalLoginProviders(_ context.Context) []domain.LoginProviderInfo {
+ authProviders := make([]domain.LoginProviderInfo, 0, len(a.cfg.OAuth)+len(a.cfg.OpenIDConnect))
+
+ for _, provider := range a.cfg.OpenIDConnect {
+ providerId := strings.ToLower(provider.ProviderName)
+ providerName := provider.DisplayName
+ if providerName == "" {
+ providerName = provider.ProviderName
+ }
+ authProviders = append(authProviders, domain.LoginProviderInfo{
+ Identifier: providerId,
+ Name: providerName,
+ ProviderUrl: fmt.Sprintf("/auth/login/%s/init", providerId),
+ CallbackUrl: fmt.Sprintf("/auth/login/%s/callback", providerId),
+ })
+ }
+
+ for _, provider := range a.cfg.OAuth {
+ providerId := strings.ToLower(provider.ProviderName)
+ providerName := provider.DisplayName
+ if providerName == "" {
+ providerName = provider.ProviderName
+ }
+ authProviders = append(authProviders, domain.LoginProviderInfo{
+ Identifier: providerId,
+ Name: providerName,
+ ProviderUrl: fmt.Sprintf("%s/%s/init", a.cfg.CallbackUrlPrefix, providerId),
+ CallbackUrl: fmt.Sprintf("%s/%s/callback", a.cfg.CallbackUrlPrefix, providerId),
+ })
+ }
+
+ return authProviders
+}
+
+func (a *authenticator) IsUserValid(ctx context.Context, id domain.UserIdentifier) bool {
+ user, err := a.users.Get(ctx, id)
+ if err != nil {
+ return false
+ }
+
+ if user.IsDisabled() {
+ return false
+ }
+
+ return true
+}
+
+// region password authentication
+
+func (a *authenticator) PlainLogin(ctx context.Context, username, password string) (*domain.User, error) {
+ // Validate form input
+ username = strings.TrimSpace(username)
+ password = strings.TrimSpace(password)
+ if username == "" || password == "" {
+ return nil, fmt.Errorf("missing username or password")
+ }
+
+ user, err := a.passwordAuthentication(ctx, domain.UserIdentifier(username), password)
+ if err != nil {
+ return nil, fmt.Errorf("login failed: %w", err)
+ }
+
+ a.bus.Publish(TopicAuthLogin, user.Identifier)
+
+ return user, nil
+}
+
+func (a *authenticator) passwordAuthentication(ctx context.Context, identifier domain.UserIdentifier, password string) (*domain.User, error) {
+ var ldapUserInfo *domain.AuthenticatorUserInfo
+ var ldapProvider domain.LdapAuthenticator
+
+ var userInDatabase = false
+ var userSource domain.UserSource
+ existingUser, err := a.users.Get(ctx, identifier)
+ if err == nil {
+ userInDatabase = true
+ userSource = domain.UserSourceDatabase
+ } else {
+ // search user in ldap if registration is enabled
+ for _, ldapAuth := range a.ldapAuthenticators {
+ if !ldapAuth.RegistrationEnabled() {
+ continue
+ }
+ rawUserInfo, err := ldapAuth.GetUserInfo(context.Background(), identifier)
+ if err != nil {
+ continue // user not found / other ldap error
+ }
+ ldapUserInfo, err = ldapAuth.ParseUserInfo(rawUserInfo)
+ if err != nil {
+ continue
+ }
+
+ // ldap user found
+ userSource = domain.UserSourceLdap
+ ldapProvider = ldapAuth
+
+ break
+ }
+ }
+
+ if userSource == "" {
+ return nil, errors.New("user not found")
+ }
+
+ switch userSource {
+ case domain.UserSourceDatabase:
+ err = existingUser.CheckPassword(password)
+ case domain.UserSourceLdap:
+ err = ldapProvider.PlaintextAuthentication(identifier, password)
+ default:
+ err = errors.New("no authentication backend available")
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to authenticate: %w", err)
+ }
+
+ if !userInDatabase {
+ user, err := a.processUserInfo(ctx, ldapUserInfo, domain.UserSourceLdap, ldapProvider.GetName(), ldapProvider.RegistrationEnabled())
+ if err != nil {
+ return nil, fmt.Errorf("unable to process user information: %w", err)
+ }
+ return user, nil
+ } else {
+ return existingUser, nil
+ }
+}
+
+// endregion password authentication
+
+// region oauth authentication
+
+func (a *authenticator) OauthLoginStep1(_ context.Context, providerId string) (authCodeUrl, state, nonce string, err error) {
+ oauthProvider, ok := a.oauthAuthenticators[providerId]
+ if !ok {
+ return "", "", "", fmt.Errorf("missing oauth provider %s", providerId)
+ }
+
+ // Prepare authentication flow, set state cookies
+ state, err = a.randString(16)
+ if err != nil {
+ return "", "", "", fmt.Errorf("failed to generate state: %w", err)
+ }
+
+ switch oauthProvider.GetType() {
+ case domain.AuthenticatorTypeOAuth:
+ authCodeUrl = oauthProvider.AuthCodeURL(state)
+ case domain.AuthenticatorTypeOidc:
+ nonce, err = a.randString(16)
+ if err != nil {
+ return "", "", "", fmt.Errorf("failed to generate nonce: %w", err)
+ }
+
+ authCodeUrl = oauthProvider.AuthCodeURL(state, oidc.Nonce(nonce))
+ }
+
+ return
+}
+
+func (a *authenticator) randString(nByte int) (string, error) {
+ b := make([]byte, nByte)
+ if _, err := io.ReadFull(rand.Reader, b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+func (a *authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error) {
+ oauthProvider, ok := a.oauthAuthenticators[providerId]
+ if !ok {
+ return nil, fmt.Errorf("missing oauth provider %s", providerId)
+ }
+
+ oauth2Token, err := oauthProvider.Exchange(ctx, code)
+ if err != nil {
+ return nil, fmt.Errorf("unable to exchange code: %w", err)
+ }
+
+ rawUserInfo, err := oauthProvider.GetUserInfo(ctx, oauth2Token, nonce)
+ if err != nil {
+ return nil, fmt.Errorf("unable to fetch user information: %w", err)
+ }
+
+ userInfo, err := oauthProvider.ParseUserInfo(rawUserInfo)
+ if err != nil {
+ return nil, fmt.Errorf("unable to parse user information: %w", err)
+ }
+
+ user, err := a.processUserInfo(ctx, userInfo, domain.UserSourceOauth, oauthProvider.GetName(), oauthProvider.RegistrationEnabled())
+ if err != nil {
+ return nil, fmt.Errorf("unable to process user information: %w", err)
+ }
+
+ a.bus.Publish(TopicAuthLogin, user.Identifier)
+
+ return user, nil
+}
+
+func (a *authenticator) processUserInfo(ctx context.Context, userInfo *domain.AuthenticatorUserInfo, source domain.UserSource, provider string, withReg bool) (*domain.User, error) {
+ // Search user in backend
+ user, err := a.users.Get(ctx, userInfo.Identifier)
+ switch {
+ case err != nil && withReg:
+ user, err = a.registerNewUser(ctx, userInfo, source, provider)
+ if err != nil {
+ return nil, fmt.Errorf("failed to register user: %w", err)
+ }
+ case err != nil:
+ return nil, fmt.Errorf("registration disabled, cannot create missing user: %w", err)
+ }
+
+ return user, nil
+}
+
+func (a *authenticator) registerNewUser(ctx context.Context, userInfo *domain.AuthenticatorUserInfo, source domain.UserSource, provider string) (*domain.User, error) {
+ // convert user info to domain.User
+ user := &domain.User{
+ Identifier: userInfo.Identifier,
+ Email: userInfo.Email,
+ Source: source,
+ ProviderName: provider,
+ IsAdmin: userInfo.IsAdmin,
+ Firstname: userInfo.Firstname,
+ Lastname: userInfo.Lastname,
+ Phone: userInfo.Phone,
+ Department: userInfo.Department,
+ }
+
+ err := a.users.Register(ctx, user)
+ if err != nil {
+ return nil, fmt.Errorf("failed to register new user: %w", err)
+ }
+
+ return user, nil
+}
+
+func (a *authenticator) getAuthenticatorConfig(id string) (interface{}, error) {
+ for i := range a.cfg.OpenIDConnect {
+ if a.cfg.OpenIDConnect[i].ProviderName == id {
+ return a.cfg.OpenIDConnect[i], nil
+ }
+ }
+
+ for i := range a.cfg.OAuth {
+ if a.cfg.OAuth[i].ProviderName == id {
+ return a.cfg.OAuth[i], nil
+ }
+ }
+
+ return nil, fmt.Errorf("no configuration for authenticator id %s", id)
+}
+
+// endregion oauth authentication
diff --git a/internal/app/auth_ldap.go b/internal/app/auth_ldap.go
new file mode 100644
index 0000000..cfd095e
--- /dev/null
+++ b/internal/app/auth_ldap.go
@@ -0,0 +1,168 @@
+package app
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/go-ldap/ldap/v3"
+ "github.com/h44z/wg-portal/internal"
+ "github.com/h44z/wg-portal/internal/config"
+ "github.com/h44z/wg-portal/internal/domain"
+)
+
+type ldapAuthenticator struct {
+ cfg *config.LdapProvider
+}
+
+func newLdapAuthenticator(_ context.Context, cfg *config.LdapProvider) (*ldapAuthenticator, error) {
+ var provider = &ldapAuthenticator{}
+
+ provider.cfg = cfg
+
+ dn, err := ldap.ParseDN(cfg.AdminGroupDN)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse admin group DN: %w", err)
+ }
+ provider.cfg.FieldMap = provider.getLdapFieldMapping(cfg.FieldMap)
+ provider.cfg.ParsedAdminGroupDN = dn
+
+ return provider, nil
+}
+
+func (l ldapAuthenticator) GetName() string {
+ return l.cfg.ProviderName
+}
+
+func (l ldapAuthenticator) RegistrationEnabled() bool {
+ return l.cfg.RegistrationEnabled
+}
+
+func (l ldapAuthenticator) PlaintextAuthentication(userId domain.UserIdentifier, plainPassword string) error {
+ conn, err := ldapConnect(l.cfg)
+ if err != nil {
+ return fmt.Errorf("failed to setup connection: %w", err)
+ }
+ defer ldapDisconnect(conn)
+
+ attrs := []string{"dn"}
+
+ loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", string(userId), -1)
+ searchRequest := ldap.NewSearchRequest(
+ l.cfg.BaseDN,
+ ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit
+ loginFilter, attrs, nil,
+ )
+
+ sr, err := conn.Search(searchRequest)
+ if err != nil {
+ return fmt.Errorf("failed to search in ldap: %w", err)
+ }
+
+ if len(sr.Entries) == 0 {
+ return domain.ErrNotFound
+ }
+
+ if len(sr.Entries) > 1 {
+ return domain.ErrNotUnique
+ }
+
+ // Bind as the user to verify their password
+ userDN := sr.Entries[0].DN
+ err = conn.Bind(userDN, plainPassword)
+ if err != nil {
+ return fmt.Errorf("invalid credentials: %w", err)
+ }
+ _ = conn.Unbind()
+
+ return nil
+}
+
+func (l ldapAuthenticator) GetUserInfo(_ context.Context, userId domain.UserIdentifier) (map[string]interface{}, error) {
+ conn, err := ldapConnect(l.cfg)
+ if err != nil {
+ return nil, fmt.Errorf("failed to setup connection: %w", err)
+ }
+ defer ldapDisconnect(conn)
+
+ attrs := ldapSearchAttributes(&l.cfg.FieldMap)
+
+ loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", string(userId), -1)
+ searchRequest := ldap.NewSearchRequest(
+ l.cfg.BaseDN,
+ ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit
+ loginFilter, attrs, nil,
+ )
+
+ sr, err := conn.Search(searchRequest)
+ if err != nil {
+ return nil, fmt.Errorf("failed to search in ldap: %w", err)
+ }
+
+ if len(sr.Entries) == 0 {
+ return nil, domain.ErrNotFound
+ }
+
+ if len(sr.Entries) > 1 {
+ return nil, domain.ErrNotUnique
+ }
+
+ users := ldapConvertEntries(sr, &l.cfg.FieldMap)
+
+ return users[0], nil
+}
+
+func (l ldapAuthenticator) ParseUserInfo(raw map[string]interface{}) (*domain.AuthenticatorUserInfo, error) {
+ isAdmin, err := ldapIsMemberOf(raw[l.cfg.FieldMap.GroupMembership].([][]byte), l.cfg.ParsedAdminGroupDN)
+ if err != nil {
+ return nil, fmt.Errorf("failed to check admin group: %w", err)
+ }
+ userInfo := &domain.AuthenticatorUserInfo{
+ Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, l.cfg.FieldMap.UserIdentifier, "")),
+ Email: internal.MapDefaultString(raw, l.cfg.FieldMap.Email, ""),
+ Firstname: internal.MapDefaultString(raw, l.cfg.FieldMap.Firstname, ""),
+ Lastname: internal.MapDefaultString(raw, l.cfg.FieldMap.Lastname, ""),
+ Phone: internal.MapDefaultString(raw, l.cfg.FieldMap.Phone, ""),
+ Department: internal.MapDefaultString(raw, l.cfg.FieldMap.Department, ""),
+ IsAdmin: isAdmin,
+ }
+
+ return userInfo, nil
+}
+
+func (l ldapAuthenticator) getLdapFieldMapping(f config.LdapFields) config.LdapFields {
+ defaultMap := config.LdapFields{
+ BaseFields: config.BaseFields{
+ UserIdentifier: "mail",
+ Email: "mail",
+ Firstname: "givenName",
+ Lastname: "sn",
+ Phone: "telephoneNumber",
+ Department: "department",
+ },
+ GroupMembership: "memberOf",
+ }
+ if f.UserIdentifier != "" {
+ defaultMap.UserIdentifier = f.UserIdentifier
+ }
+ if f.Email != "" {
+ defaultMap.Email = f.Email
+ }
+ if f.Firstname != "" {
+ defaultMap.Firstname = f.Firstname
+ }
+ if f.Lastname != "" {
+ defaultMap.Lastname = f.Lastname
+ }
+ if f.Phone != "" {
+ defaultMap.Phone = f.Phone
+ }
+ if f.Department != "" {
+ defaultMap.Department = f.Department
+ }
+ if f.GroupMembership != "" {
+ defaultMap.GroupMembership = f.GroupMembership
+ }
+
+ return defaultMap
+}
diff --git a/internal/app/auth_oauth.go b/internal/app/auth_oauth.go
new file mode 100644
index 0000000..118d085
--- /dev/null
+++ b/internal/app/auth_oauth.go
@@ -0,0 +1,149 @@
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/h44z/wg-portal/internal"
+ "github.com/h44z/wg-portal/internal/config"
+ "github.com/h44z/wg-portal/internal/domain"
+ "golang.org/x/oauth2"
+)
+
+type plainOauthAuthenticator struct {
+ name string
+ cfg *oauth2.Config
+ userInfoEndpoint string
+ client *http.Client
+ userInfoMapping config.OauthFields
+ registrationEnabled bool
+}
+
+func newPlainOauthAuthenticator(_ context.Context, callbackUrl string, cfg *config.OAuthProvider) (*plainOauthAuthenticator, error) {
+ var provider = &plainOauthAuthenticator{}
+
+ provider.name = cfg.ProviderName
+ provider.client = &http.Client{
+ Timeout: time.Second * 10,
+ }
+ provider.cfg = &oauth2.Config{
+ ClientID: cfg.ClientID,
+ ClientSecret: cfg.ClientSecret,
+ Endpoint: oauth2.Endpoint{
+ AuthURL: cfg.AuthURL,
+ TokenURL: cfg.TokenURL,
+ AuthStyle: oauth2.AuthStyleAutoDetect,
+ },
+ RedirectURL: callbackUrl,
+ Scopes: cfg.Scopes,
+ }
+ provider.userInfoEndpoint = cfg.UserInfoURL
+ provider.userInfoMapping = getOauthFieldMapping(cfg.FieldMap)
+ provider.registrationEnabled = cfg.RegistrationEnabled
+
+ return provider, nil
+}
+
+func (p plainOauthAuthenticator) GetName() string {
+ return p.name
+}
+
+func (p plainOauthAuthenticator) RegistrationEnabled() bool {
+ return p.registrationEnabled
+}
+
+func (p plainOauthAuthenticator) GetType() domain.AuthenticatorType {
+ return domain.AuthenticatorTypeOAuth
+}
+
+func (p plainOauthAuthenticator) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
+ return p.cfg.AuthCodeURL(state, opts...)
+}
+
+func (p plainOauthAuthenticator) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
+ return p.cfg.Exchange(ctx, code, opts...)
+}
+
+func (p plainOauthAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token, _ string) (map[string]interface{}, error) {
+ req, err := http.NewRequest("GET", p.userInfoEndpoint, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create user info get request: %w", err)
+ }
+ req.Header.Add("Authorization", "Bearer "+token.AccessToken)
+ req.WithContext(ctx)
+
+ response, err := p.client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get user info: %w", err)
+ }
+ defer response.Body.Close()
+ contents, err := io.ReadAll(response.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ var userFields map[string]interface{}
+ err = json.Unmarshal(contents, &userFields)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse user info: %w", err)
+ }
+
+ return userFields, nil
+}
+
+func (p plainOauthAuthenticator) ParseUserInfo(raw map[string]interface{}) (*domain.AuthenticatorUserInfo, error) {
+ isAdmin, _ := strconv.ParseBool(internal.MapDefaultString(raw, p.userInfoMapping.IsAdmin, ""))
+ userInfo := &domain.AuthenticatorUserInfo{
+ Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, p.userInfoMapping.UserIdentifier, "")),
+ Email: internal.MapDefaultString(raw, p.userInfoMapping.Email, ""),
+ Firstname: internal.MapDefaultString(raw, p.userInfoMapping.Firstname, ""),
+ Lastname: internal.MapDefaultString(raw, p.userInfoMapping.Lastname, ""),
+ Phone: internal.MapDefaultString(raw, p.userInfoMapping.Phone, ""),
+ Department: internal.MapDefaultString(raw, p.userInfoMapping.Department, ""),
+ IsAdmin: isAdmin,
+ }
+
+ return userInfo, nil
+}
+
+func getOauthFieldMapping(f config.OauthFields) config.OauthFields {
+ defaultMap := config.OauthFields{
+ BaseFields: config.BaseFields{
+ UserIdentifier: "sub",
+ Email: "email",
+ Firstname: "given_name",
+ Lastname: "family_name",
+ Phone: "phone",
+ Department: "department",
+ },
+ IsAdmin: "admin_flag",
+ }
+ if f.UserIdentifier != "" {
+ defaultMap.UserIdentifier = f.UserIdentifier
+ }
+ if f.Email != "" {
+ defaultMap.Email = f.Email
+ }
+ if f.Firstname != "" {
+ defaultMap.Firstname = f.Firstname
+ }
+ if f.Lastname != "" {
+ defaultMap.Lastname = f.Lastname
+ }
+ if f.Phone != "" {
+ defaultMap.Phone = f.Phone
+ }
+ if f.Department != "" {
+ defaultMap.Department = f.Department
+ }
+ if f.IsAdmin != "" {
+ defaultMap.IsAdmin = f.IsAdmin
+ }
+
+ return defaultMap
+}
diff --git a/internal/app/auth_oidc.go b/internal/app/auth_oidc.go
new file mode 100644
index 0000000..1815f55
--- /dev/null
+++ b/internal/app/auth_oidc.go
@@ -0,0 +1,107 @@
+package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strconv"
+
+ "github.com/coreos/go-oidc"
+ "github.com/h44z/wg-portal/internal"
+ "github.com/h44z/wg-portal/internal/config"
+ "github.com/h44z/wg-portal/internal/domain"
+ "golang.org/x/oauth2"
+)
+
+type oidcAuthenticator struct {
+ name string
+ provider *oidc.Provider
+ verifier *oidc.IDTokenVerifier
+ cfg *oauth2.Config
+ userInfoMapping config.OauthFields
+ registrationEnabled bool
+}
+
+func newOidcAuthenticator(ctx context.Context, callbackUrl string, cfg *config.OpenIDConnectProvider) (*oidcAuthenticator, error) {
+ var err error
+ var provider = &oidcAuthenticator{}
+
+ provider.name = cfg.ProviderName
+ provider.provider, err = oidc.NewProvider(context.Background(), cfg.BaseUrl) // use new context here, see https://github.com/coreos/go-oidc/issues/339
+ if err != nil {
+ return nil, fmt.Errorf("failed to create new oidc provider: %w", err)
+ }
+ provider.verifier = provider.provider.Verifier(&oidc.Config{
+ ClientID: cfg.ClientID,
+ })
+
+ scopes := []string{oidc.ScopeOpenID}
+ scopes = append(scopes, cfg.ExtraScopes...)
+ provider.cfg = &oauth2.Config{
+ ClientID: cfg.ClientID,
+ ClientSecret: cfg.ClientSecret,
+ Endpoint: provider.provider.Endpoint(),
+ RedirectURL: callbackUrl,
+ Scopes: scopes,
+ }
+ provider.userInfoMapping = getOauthFieldMapping(cfg.FieldMap)
+ provider.registrationEnabled = cfg.RegistrationEnabled
+
+ return provider, nil
+}
+
+func (o oidcAuthenticator) GetName() string {
+ return o.name
+}
+
+func (o oidcAuthenticator) RegistrationEnabled() bool {
+ return o.registrationEnabled
+}
+
+func (o oidcAuthenticator) GetType() domain.AuthenticatorType {
+ return domain.AuthenticatorTypeOidc
+}
+
+func (o oidcAuthenticator) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
+ return o.cfg.AuthCodeURL(state, opts...)
+}
+
+func (o oidcAuthenticator) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
+ return o.cfg.Exchange(ctx, code, opts...)
+}
+
+func (o oidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token, nonce string) (map[string]interface{}, error) {
+ rawIDToken, ok := token.Extra("id_token").(string)
+ if !ok {
+ return nil, errors.New("token does not contain id_token")
+ }
+ idToken, err := o.verifier.Verify(ctx, rawIDToken)
+ if err != nil {
+ return nil, fmt.Errorf("failed to validate id_token: %w", err)
+ }
+ if idToken.Nonce != nonce {
+ return nil, errors.New("nonce mismatch")
+ }
+
+ var tokenFields map[string]interface{}
+ if err = idToken.Claims(&tokenFields); err != nil {
+ return nil, fmt.Errorf("failed to parse extra claims: %w", err)
+ }
+
+ return tokenFields, nil
+}
+
+func (o oidcAuthenticator) ParseUserInfo(raw map[string]interface{}) (*domain.AuthenticatorUserInfo, error) {
+ isAdmin, _ := strconv.ParseBool(internal.MapDefaultString(raw, o.userInfoMapping.IsAdmin, ""))
+ userInfo := &domain.AuthenticatorUserInfo{
+ Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, o.userInfoMapping.UserIdentifier, "")),
+ Email: internal.MapDefaultString(raw, o.userInfoMapping.Email, ""),
+ Firstname: internal.MapDefaultString(raw, o.userInfoMapping.Firstname, ""),
+ Lastname: internal.MapDefaultString(raw, o.userInfoMapping.Lastname, ""),
+ Phone: internal.MapDefaultString(raw, o.userInfoMapping.Phone, ""),
+ Department: internal.MapDefaultString(raw, o.userInfoMapping.Department, ""),
+ IsAdmin: isAdmin,
+ }
+
+ return userInfo, nil
+}
diff --git a/internal/app/core.go b/internal/app/core.go
new file mode 100644
index 0000000..47a635d
--- /dev/null
+++ b/internal/app/core.go
@@ -0,0 +1,114 @@
+package app
+
+import (
+ "context"
+ "flag"
+ "github.com/h44z/wg-portal/internal/config"
+ "github.com/h44z/wg-portal/internal/domain"
+ evbus "github.com/vardius/message-bus"
+ "gorm.io/gorm"
+)
+
+// region global-repositories
+
+type wireGuardRepo interface {
+ GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error)
+ GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error)
+ GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error)
+ GetPeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) (*domain.PhysicalPeer, error)
+ SaveInterface(_ context.Context, id domain.InterfaceIdentifier, updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error)) error
+ DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error
+ SavePeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier, updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error)) error
+ DeletePeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error
+}
+
+type dbRepo interface {
+ GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error)
+ GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error)
+ GetAllInterfaces(ctx context.Context) ([]domain.Interface, error)
+ FindInterfaces(ctx context.Context, search string) ([]domain.Interface, error)
+ SaveInterface(ctx context.Context, id domain.InterfaceIdentifier, updateFunc func(in *domain.Interface) (*domain.Interface, error)) error
+ DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error
+ GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIdentifier][]domain.Cidr, error)
+ GetInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.Peer, error)
+ FindInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier, search string) ([]domain.Peer, error)
+ GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
+ FindUserPeers(ctx context.Context, id domain.UserIdentifier, search string) ([]domain.Peer, error)
+ SavePeer(ctx context.Context, id domain.PeerIdentifier, updateFunc func(in *domain.Peer) (*domain.Peer, error)) error
+ DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
+ GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
+ GetAllUsers(ctx context.Context) ([]domain.User, error)
+ FindUsers(ctx context.Context, search string) ([]domain.User, error)
+ SaveUser(ctx context.Context, id domain.UserIdentifier, updateFunc func(u *domain.User) (*domain.User, error)) error
+ DeleteUser(ctx context.Context, id domain.UserIdentifier) error
+}
+
+// endregion global-repositories
+
+type App struct {
+ Config *config.Config
+ bus evbus.MessageBus
+ db dbRepo
+ wg wireGuardRepo
+
+ Authenticator *authenticator
+ Users *userManager
+ WireGuard *wireGuardManager
+}
+
+func New(cfg *config.Config, bus evbus.MessageBus, db dbRepo, wg wireGuardRepo) (*App, error) {
+ users, err := newUserManager(cfg, bus, db, db)
+ if err != nil {
+ return nil, err
+ }
+
+ auth, err := newAuthenticator(&cfg.Auth, bus, users)
+ if err != nil {
+ return nil, err
+ }
+
+ wireGuard, err := newWireGuardManager(cfg, bus, wg, db)
+ if err != nil {
+ return nil, err
+ }
+
+ a := &App{
+ Config: cfg,
+ bus: bus,
+ db: db,
+ wg: wg,
+
+ Authenticator: auth,
+ Users: users,
+ WireGuard: wireGuard,
+ }
+
+ if a.Config.Core.ImportExisting {
+ err := a.WireGuard.ImportNewInterfaces(context.Background())
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if a.Config.Core.RestoreState {
+ err := a.WireGuard.RestoreInterfaceState(context.Background(), true)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return a, nil
+}
+
+func HandleProgramArgs(cfg *config.Config, db *gorm.DB, wg wireGuardRepo) (exit bool, err error) {
+ migrationSource := flag.String("migrateFrom", "", "path to v1 database file or DSN")
+ migrationDbType := flag.String("migrateFromType", string(config.DatabaseSQLite), "old database type, either mysql, mssql, postgres or sqlite")
+ flag.Parse()
+
+ if *migrationSource != "" {
+ err = migrateFromV1(cfg, db, wg, *migrationSource, *migrationDbType)
+ exit = true
+ }
+
+ return
+}
diff --git a/internal/app/eventbus.go b/internal/app/eventbus.go
new file mode 100644
index 0000000..fa4cfa3
--- /dev/null
+++ b/internal/app/eventbus.go
@@ -0,0 +1,6 @@
+package app
+
+const TopicUserCreated = "user:created"
+const TopicUserRegistered = "user:registered"
+const TopicUserDisabled = "user:disabled"
+const TopicAuthLogin = "auth:login"
diff --git a/internal/app/ldap_utils.go b/internal/app/ldap_utils.go
new file mode 100644
index 0000000..07e14c1
--- /dev/null
+++ b/internal/app/ldap_utils.go
@@ -0,0 +1,137 @@
+package app
+
+import (
+ "crypto/tls"
+ "fmt"
+ "os"
+
+ "github.com/h44z/wg-portal/internal"
+
+ "github.com/go-ldap/ldap/v3"
+ "github.com/h44z/wg-portal/internal/config"
+)
+
+type rawLdapUser map[string]interface{}
+
+func ldapFindAllUsers(conn *ldap.Conn, baseDn, filter string, fields *config.LdapFields) ([]rawLdapUser, error) {
+ // Search all users
+ attrs := ldapSearchAttributes(fields)
+ searchRequest := ldap.NewSearchRequest(
+ baseDn,
+ ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
+ filter, attrs, nil,
+ )
+
+ sr, err := conn.Search(searchRequest)
+ if err != nil {
+ return nil, fmt.Errorf("failed to search: %w", err)
+ }
+
+ results := ldapConvertEntries(sr, fields)
+
+ return results, nil
+}
+
+func ldapConnect(cfg *config.LdapProvider) (*ldap.Conn, error) {
+ tlsConfig := &tls.Config{InsecureSkipVerify: !cfg.CertValidation}
+ if cfg.TlsCertificatePath != "" {
+ certificate, err := os.ReadFile(cfg.TlsCertificatePath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load TLS certificate: %w", err)
+
+ }
+
+ key, err := os.ReadFile(cfg.TlsKeyPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load TLS key: %w", err)
+ }
+
+ keyPair, err := tls.X509KeyPair(certificate, key)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate X509 keypair: %w", err)
+
+ }
+ tlsConfig = &tls.Config{Certificates: []tls.Certificate{keyPair}}
+ }
+
+ conn, err := ldap.DialURL(cfg.URL, ldap.DialWithTLSConfig(tlsConfig))
+ if err != nil {
+ return nil, fmt.Errorf("dial error: %w", err)
+ }
+
+ if cfg.StartTLS { // Reconnect with TLS
+ if err = conn.StartTLS(tlsConfig); err != nil {
+ return nil, fmt.Errorf("failed to start TLS on connection: %w", err)
+ }
+ }
+
+ if err = conn.Bind(cfg.BindUser, cfg.BindPass); err != nil {
+ return nil, fmt.Errorf("failed to bind to LDAP: %w", err)
+ }
+
+ return conn, nil
+}
+
+func ldapDisconnect(conn *ldap.Conn) {
+ if conn != nil {
+ conn.Close()
+ }
+}
+
+func ldapConvertEntries(sr *ldap.SearchResult, fields *config.LdapFields) []rawLdapUser {
+ users := make([]rawLdapUser, len(sr.Entries))
+
+ for i, entry := range sr.Entries {
+ userData := make(rawLdapUser)
+ userData[fields.UserIdentifier] = entry.DN
+ userData[fields.Email] = entry.GetAttributeValue(fields.Email)
+ userData[fields.Firstname] = entry.GetAttributeValue(fields.Firstname)
+ userData[fields.Lastname] = entry.GetAttributeValue(fields.Lastname)
+ userData[fields.Phone] = entry.GetAttributeValue(fields.Phone)
+ userData[fields.Department] = entry.GetAttributeValue(fields.Department)
+ userData[fields.GroupMembership] = entry.GetRawAttributeValues(fields.GroupMembership)
+
+ users[i] = userData
+ }
+ return users
+}
+
+func ldapSearchAttributes(fields *config.LdapFields) []string {
+ attrs := []string{"dn", fields.UserIdentifier}
+
+ if fields.Email != "" {
+ attrs = append(attrs, fields.Email)
+ }
+ if fields.Firstname != "" {
+ attrs = append(attrs, fields.Firstname)
+ }
+ if fields.Lastname != "" {
+ attrs = append(attrs, fields.Lastname)
+ }
+ if fields.Phone != "" {
+ attrs = append(attrs, fields.Phone)
+ }
+ if fields.Department != "" {
+ attrs = append(attrs, fields.Department)
+ }
+ if fields.GroupMembership != "" {
+ attrs = append(attrs, fields.GroupMembership)
+ }
+
+ return internal.UniqueStringSlice(attrs)
+}
+
+// ldapIsMemberOf checks if the groupData array contains the group DN
+func ldapIsMemberOf(groupData [][]byte, groupDN *ldap.DN) (bool, error) {
+ for _, group := range groupData {
+ dn, err := ldap.ParseDN(string(group))
+ if err != nil {
+ return false, fmt.Errorf("failed to parse group DN: %w", err)
+ }
+ if groupDN.Equal(dn) {
+ return true, nil
+ }
+ }
+
+ return false, nil
+}
diff --git a/internal/app/migrate_v1.go b/internal/app/migrate_v1.go
new file mode 100644
index 0000000..dd452c2
--- /dev/null
+++ b/internal/app/migrate_v1.go
@@ -0,0 +1,384 @@
+package app
+
+import (
+ "errors"
+ "fmt"
+ "github.com/h44z/wg-portal/internal/adapters"
+ "github.com/h44z/wg-portal/internal/config"
+ "github.com/h44z/wg-portal/internal/domain"
+ "github.com/sirupsen/logrus"
+ "gorm.io/gorm"
+ "os"
+ "time"
+)
+
+func migrateFromV1(cfg *config.Config, db *gorm.DB, wg wireGuardRepo, source, typ string) error {
+ sourceType := config.SupportedDatabase(typ)
+ switch sourceType {
+ case config.DatabaseMySQL, config.DatabasePostgres, config.DatabaseMsSQL:
+ case config.DatabaseSQLite:
+ if _, err := os.Stat(source); os.IsNotExist(err) {
+ return fmt.Errorf("invalid source database: %w", err)
+ }
+ default:
+ return errors.New("unsupported database")
+ }
+
+ oldDb, err := adapters.NewDatabase(config.DatabaseConfig{
+ Type: sourceType,
+ DSN: source,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to open old database: %w", err)
+ }
+
+ // check if old db is a valid WireGuard Portal v1 database
+ type DatabaseMigrationInfo struct {
+ Version string `gorm:"primaryKey"`
+ Applied time.Time
+ }
+
+ lastVersion := DatabaseMigrationInfo{}
+ err = oldDb.Order("applied desc, version desc").FirstOrInit(&lastVersion).Error
+ if err != nil {
+ return fmt.Errorf("unable to validate old database: %w", err)
+ }
+ latestVersion := "1.0.9"
+ if lastVersion.Version != latestVersion {
+ return fmt.Errorf("unsupported old version, update to database version %s first: %w", latestVersion, err)
+ }
+
+ logrus.Infof("Found valid V1 database with version: %s", lastVersion.Version)
+
+ if err := migrateV1Users(oldDb, db); err != nil {
+ return fmt.Errorf("user migration failed: %w", err)
+ }
+
+ if err := migrateV1Interfaces(oldDb, db); err != nil {
+ return fmt.Errorf("user migration failed: %w", err)
+ }
+
+ if err := migrateV1Peers(oldDb, db); err != nil {
+ return fmt.Errorf("peer migration failed: %w", err)
+ }
+
+ return nil
+}
+
+func migrateV1Users(oldDb, newDb *gorm.DB) error {
+ type User struct {
+ Email string `gorm:"primaryKey"`
+ Source string
+ IsAdmin bool
+ Firstname string
+ Lastname string
+ Phone string
+ Password string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ DeletedAt gorm.DeletedAt `gorm:"index"`
+ }
+
+ var oldUsers []User
+ err := oldDb.Find(&oldUsers).Error
+ if err != nil {
+ return fmt.Errorf("unable to fetch old user records: %w", err)
+ }
+
+ for _, oldUser := range oldUsers {
+ var deletionTime *time.Time
+ deletionReason := ""
+ if oldUser.DeletedAt.Valid {
+ delTime := oldUser.DeletedAt.Time
+ deletionTime = &delTime
+ deletionReason = "disabled prior to migration"
+ }
+ newUser := domain.User{
+ BaseModel: domain.BaseModel{
+ CreatedBy: "v1migrator",
+ UpdatedBy: "v1migrator",
+ CreatedAt: oldUser.CreatedAt,
+ UpdatedAt: oldUser.UpdatedAt,
+ },
+ Identifier: domain.UserIdentifier(oldUser.Email),
+ Email: oldUser.Email,
+ Source: domain.UserSource(oldUser.Source),
+ ProviderName: "",
+ IsAdmin: oldUser.IsAdmin,
+ Firstname: oldUser.Firstname,
+ Lastname: oldUser.Lastname,
+ Phone: oldUser.Phone,
+ Department: "",
+ Notes: "",
+ Password: domain.PrivateString(oldUser.Password),
+ Disabled: deletionTime,
+ DisabledReason: deletionReason,
+ Locked: nil,
+ LockedReason: "",
+ LinkedPeerCount: 0,
+ }
+
+ if err := newDb.Save(&newUser).Error; err != nil {
+ return fmt.Errorf("failed to migrate user %s: %w", oldUser.Email, err)
+ }
+ }
+
+ return nil
+}
+
+func migrateV1Interfaces(oldDb, newDb *gorm.DB) error {
+ type Device struct {
+ Type string
+ DeviceName string `gorm:"primaryKey"`
+ DisplayName string
+ PrivateKey string
+ ListenPort int
+ FirewallMark int32
+ PublicKey string
+ Mtu int
+ IPsStr string
+ DNSStr string
+ RoutingTable string
+ PreUp string
+ PostUp string
+ PreDown string
+ PostDown string
+ SaveConfig bool
+ DefaultEndpoint string
+ DefaultAllowedIPsStr string
+ DefaultPersistentKeepalive int
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ }
+
+ var oldDevices []Device
+ err := oldDb.Find(&oldDevices).Error
+ if err != nil {
+ return fmt.Errorf("unable to fetch old device records: %w", err)
+ }
+
+ for _, oldDevice := range oldDevices {
+ ips, err := domain.CidrsFromString(oldDevice.IPsStr)
+ if err != nil {
+ return fmt.Errorf("failed to parse %s ip addresses: %w", oldDevice.DeviceName, err)
+ }
+ networks := make([]domain.Cidr, len(ips))
+ for i, ip := range ips {
+ networks[i] = domain.CidrFromIpNet(*ip.IpNet())
+ }
+ newInterface := domain.Interface{
+ BaseModel: domain.BaseModel{
+ CreatedBy: "v1migrator",
+ UpdatedBy: "v1migrator",
+ CreatedAt: oldDevice.CreatedAt,
+ UpdatedAt: oldDevice.UpdatedAt,
+ },
+ Identifier: domain.InterfaceIdentifier(oldDevice.DeviceName),
+ KeyPair: domain.KeyPair{
+ PrivateKey: oldDevice.PrivateKey,
+ PublicKey: oldDevice.PublicKey,
+ },
+ ListenPort: oldDevice.ListenPort,
+ Addresses: ips,
+ DnsStr: "",
+ DnsSearchStr: "",
+ Mtu: oldDevice.Mtu,
+ FirewallMark: oldDevice.FirewallMark,
+ RoutingTable: oldDevice.RoutingTable,
+ PreUp: oldDevice.PreUp,
+ PostUp: oldDevice.PostUp,
+ PreDown: oldDevice.PreDown,
+ PostDown: oldDevice.PostDown,
+ SaveConfig: oldDevice.SaveConfig,
+ DisplayName: oldDevice.DisplayName,
+ Type: domain.InterfaceType(oldDevice.Type),
+ DriverType: "",
+ Disabled: nil,
+ DisabledReason: "",
+ PeerDefNetworkStr: domain.CidrsToString(networks),
+ PeerDefDnsStr: oldDevice.DNSStr,
+ PeerDefDnsSearchStr: "",
+ PeerDefEndpoint: oldDevice.DefaultEndpoint,
+ PeerDefAllowedIPsStr: oldDevice.DefaultAllowedIPsStr,
+ PeerDefMtu: oldDevice.Mtu,
+ PeerDefPersistentKeepalive: oldDevice.DefaultPersistentKeepalive,
+ PeerDefFirewallMark: 0,
+ PeerDefRoutingTable: "",
+ PeerDefPreUp: "",
+ PeerDefPostUp: "",
+ PeerDefPreDown: "",
+ PeerDefPostDown: "",
+ }
+
+ if err := newDb.Save(&newInterface).Error; err != nil {
+ return fmt.Errorf("failed to migrate device %s: %w", oldDevice.DeviceName, err)
+ }
+ }
+
+ return nil
+}
+
+func migrateV1Peers(oldDb, newDb *gorm.DB) error {
+ type Peer struct {
+ UID string
+ DeviceName string `gorm:"index"`
+ Identifier string
+ Email string `gorm:"index" form:"mail" binding:"required,email"`
+ IgnoreGlobalSettings bool
+ PublicKey string `gorm:"primaryKey"`
+ PresharedKey string
+ AllowedIPsStr string
+ AllowedIPsSrvStr string
+ Endpoint string
+ PersistentKeepalive int
+ PrivateKey string
+ IPsStr string
+ DNSStr string
+ Mtu int
+ DeactivatedAt *time.Time `json:",omitempty"`
+ DeactivatedReason string `json:",omitempty"`
+ ExpiresAt *time.Time
+ CreatedBy string
+ UpdatedBy string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ }
+
+ var oldPeers []Peer
+ err := oldDb.Find(&oldPeers).Error
+ if err != nil {
+ return fmt.Errorf("unable to fetch old peer records: %w", err)
+ }
+
+ for _, oldPeer := range oldPeers {
+ ips, err := domain.CidrsFromString(oldPeer.IPsStr)
+ if err != nil {
+ return fmt.Errorf("failed to parse %s ip addresses: %w", oldPeer.PublicKey, err)
+ }
+ var disableTime *time.Time
+ disableReason := ""
+ if oldPeer.DeactivatedAt != nil {
+ disTime := *oldPeer.DeactivatedAt
+ disableTime = &disTime
+ disableReason = oldPeer.DeactivatedReason
+ }
+ var expiryTime *time.Time
+ if oldPeer.ExpiresAt != nil {
+ expTime := *oldPeer.ExpiresAt
+ expiryTime = &expTime
+ }
+ var iface domain.Interface
+ var ifaceType domain.InterfaceType
+ err = newDb.First(&iface, "identifier = ?", oldPeer.DeviceName).Error
+ if err != nil {
+ return fmt.Errorf("failed to find interface %s for peer %s: %w", oldPeer.DeviceName, oldPeer.PublicKey, err)
+ }
+ switch iface.Type {
+ case domain.InterfaceTypeClient:
+ ifaceType = domain.InterfaceTypeServer
+ case domain.InterfaceTypeServer:
+ ifaceType = domain.InterfaceTypeClient
+ case domain.InterfaceTypeAny:
+ ifaceType = domain.InterfaceTypeAny
+ }
+ var user domain.User
+ err = newDb.First(&user, "identifier = ?", oldPeer.Email).Error // migrated users use the email address as identifier
+ if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+ return fmt.Errorf("failed to find user %s for peer %s: %w", oldPeer.Email, oldPeer.PublicKey, err)
+ }
+ if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+ // create dummy user
+ now := time.Now()
+ user = domain.User{
+ BaseModel: domain.BaseModel{
+ CreatedBy: "v1migrator",
+ UpdatedBy: "v1migrator",
+ CreatedAt: now,
+ UpdatedAt: now,
+ },
+ Identifier: domain.UserIdentifier(oldPeer.Email),
+ Email: oldPeer.Email,
+ Source: domain.UserSourceDatabase,
+ ProviderName: "",
+ IsAdmin: false,
+ Locked: &now,
+ LockedReason: "migration dummy user",
+ Notes: "created by migration from v1",
+ }
+
+ if err := newDb.Save(&user).Error; err != nil {
+ return fmt.Errorf("failed to migrate dummy user %s: %w", oldPeer.Email, err)
+ }
+ }
+ newPeer := domain.Peer{
+ BaseModel: domain.BaseModel{
+ CreatedBy: "v1migrator",
+ UpdatedBy: "v1migrator",
+ CreatedAt: oldPeer.CreatedAt,
+ UpdatedAt: oldPeer.UpdatedAt,
+ },
+ Endpoint: domain.StringConfigOption{
+ Value: oldPeer.Endpoint, Overridable: !oldPeer.IgnoreGlobalSettings,
+ },
+ EndpointPublicKey: iface.PublicKey,
+ AllowedIPsStr: domain.StringConfigOption{
+ Value: oldPeer.AllowedIPsStr, Overridable: !oldPeer.IgnoreGlobalSettings,
+ },
+ ExtraAllowedIPsStr: oldPeer.AllowedIPsSrvStr,
+ PresharedKey: domain.PreSharedKey(oldPeer.PresharedKey),
+ PersistentKeepalive: domain.IntConfigOption{
+ Value: oldPeer.PersistentKeepalive, Overridable: !oldPeer.IgnoreGlobalSettings,
+ },
+ DisplayName: oldPeer.Identifier,
+ Identifier: domain.PeerIdentifier(oldPeer.PublicKey),
+ UserIdentifier: user.Identifier,
+ InterfaceIdentifier: iface.Identifier,
+ Temporary: nil,
+ Disabled: disableTime,
+ DisabledReason: disableReason,
+ ExpiresAt: expiryTime,
+ Notes: "",
+ Interface: domain.PeerInterfaceConfig{
+ KeyPair: domain.KeyPair{
+ PrivateKey: oldPeer.PrivateKey,
+ PublicKey: oldPeer.PublicKey,
+ },
+ Type: ifaceType,
+ Addresses: ips,
+ DnsStr: domain.StringConfigOption{
+ Value: oldPeer.DNSStr, Overridable: !oldPeer.IgnoreGlobalSettings,
+ },
+ DnsSearchStr: domain.StringConfigOption{
+ Value: iface.PeerDefDnsSearchStr, Overridable: !oldPeer.IgnoreGlobalSettings,
+ },
+ Mtu: domain.IntConfigOption{
+ Value: oldPeer.Mtu, Overridable: !oldPeer.IgnoreGlobalSettings,
+ },
+ FirewallMark: domain.Int32ConfigOption{
+ Value: iface.PeerDefFirewallMark, Overridable: !oldPeer.IgnoreGlobalSettings,
+ },
+ RoutingTable: domain.StringConfigOption{
+ Value: iface.PeerDefRoutingTable, Overridable: !oldPeer.IgnoreGlobalSettings,
+ },
+ PreUp: domain.StringConfigOption{
+ Value: iface.PeerDefPreUp, Overridable: !oldPeer.IgnoreGlobalSettings,
+ },
+ PostUp: domain.StringConfigOption{
+ Value: iface.PeerDefPostUp, Overridable: !oldPeer.IgnoreGlobalSettings,
+ },
+ PreDown: domain.StringConfigOption{
+ Value: iface.PeerDefPreDown, Overridable: !oldPeer.IgnoreGlobalSettings,
+ },
+ PostDown: domain.StringConfigOption{
+ Value: iface.PeerDefPostDown, Overridable: !oldPeer.IgnoreGlobalSettings,
+ },
+ },
+ }
+
+ if err := newDb.Save(&newPeer).Error; err != nil {
+ return fmt.Errorf("failed to migrate peer %s (%s): %w", oldPeer.Identifier, oldPeer.PublicKey, err)
+ }
+ }
+
+ return nil
+}
diff --git a/internal/app/template.go b/internal/app/template.go
new file mode 100644
index 0000000..15f95ad
--- /dev/null
+++ b/internal/app/template.go
@@ -0,0 +1,128 @@
+package app
+
+import (
+ "bytes"
+ "embed"
+ "fmt"
+ htmlTemplate "html/template"
+ "io"
+ "text/template"
+
+ "github.com/h44z/wg-portal/internal/domain"
+)
+
+//go:embed tpl_files/*
+var TemplateFiles embed.FS
+
+type templateHandler struct {
+ wireGuardTemplates *template.Template
+
+ mailHtmlTemplates *htmlTemplate.Template
+ mailTextTemplates *template.Template
+}
+
+func newTemplateHandler() (*templateHandler, error) {
+ templateCache, err := template.New("WireGuard").ParseFS(TemplateFiles, "tpl_files/*.tpl")
+ if err != nil {
+ return nil, err
+ }
+
+ mailHtmlTemplateCache, err := htmlTemplate.New("WireGuard").ParseFS(TemplateFiles, "tpl_files/*.gohtml")
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse html template files: %w", err)
+ }
+
+ mailTxtTemplateCache, err := template.New("WireGuard").ParseFS(TemplateFiles, "tpl_files/*.gotpl")
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse text template files: %w", err)
+ }
+
+ handler := &templateHandler{
+ wireGuardTemplates: templateCache,
+ mailHtmlTemplates: mailHtmlTemplateCache,
+ mailTextTemplates: mailTxtTemplateCache,
+ }
+
+ return handler, nil
+}
+
+func (c templateHandler) GetInterfaceConfig(cfg *domain.Interface, peers []*domain.Peer) (io.Reader, error) {
+ var tplBuff bytes.Buffer
+
+ err := c.wireGuardTemplates.ExecuteTemplate(&tplBuff, "wg_interface.tpl", map[string]interface{}{
+ "Interface": cfg,
+ "Peers": peers,
+ "Portal": map[string]interface{}{
+ "Version": "unknown",
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to execute interface template for %s: %w", cfg.Identifier, err)
+ }
+
+ return &tplBuff, nil
+}
+
+func (c templateHandler) GetPeerConfig(peer *domain.Peer) (io.Reader, error) {
+ var tplBuff bytes.Buffer
+
+ err := c.wireGuardTemplates.ExecuteTemplate(&tplBuff, "wg_peer.tpl", map[string]interface{}{
+ "Peer": peer,
+ "Portal": map[string]interface{}{
+ "Version": "unknown",
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to execute peer template for %s: %w", peer.Identifier, err)
+ }
+
+ return &tplBuff, nil
+}
+
+func (c templateHandler) GetConfigMail(user *domain.User, peer *domain.Peer, link string) (io.Reader, io.Reader, error) {
+ var tplBuff bytes.Buffer
+ var htmlTplBuff bytes.Buffer
+
+ err := c.mailTextTemplates.ExecuteTemplate(&tplBuff, "mail_with_link.gotpl", map[string]interface{}{
+ "User": user,
+ "Peer": peer,
+ "Link": link,
+ })
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gotpl")
+ }
+
+ err = c.mailHtmlTemplates.ExecuteTemplate(&tplBuff, "mail_with_link.gohtml", map[string]interface{}{
+ "User": user,
+ "Peer": peer,
+ "Link": link,
+ })
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gohtml")
+ }
+
+ return &tplBuff, &htmlTplBuff, nil
+}
+
+func (c templateHandler) GetConfigMailWithAttachment(user *domain.User, peer *domain.Peer) (io.Reader, io.Reader, error) {
+ var tplBuff bytes.Buffer
+ var htmlTplBuff bytes.Buffer
+
+ err := c.mailTextTemplates.ExecuteTemplate(&tplBuff, "mail_with_attachment.gotpl", map[string]interface{}{
+ "User": user,
+ "Peer": peer,
+ })
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to execute template mail_with_attachment.gotpl")
+ }
+
+ err = c.mailHtmlTemplates.ExecuteTemplate(&tplBuff, "mail_with_attachment.gohtml", map[string]interface{}{
+ "User": user,
+ "Peer": peer,
+ })
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to execute template mail_with_attachment.gohtml")
+ }
+
+ return &tplBuff, &htmlTplBuff, nil
+}
diff --git a/internal/app/tpl_files/mail_with_attachment.gohtml b/internal/app/tpl_files/mail_with_attachment.gohtml
new file mode 100644
index 0000000..e69de29
diff --git a/internal/app/tpl_files/mail_with_attachment.gotpl b/internal/app/tpl_files/mail_with_attachment.gotpl
new file mode 100644
index 0000000..8c5a4cd
--- /dev/null
+++ b/internal/app/tpl_files/mail_with_attachment.gotpl
@@ -0,0 +1 @@
+package tpl_files
diff --git a/internal/app/tpl_files/mail_with_link.gohtml b/internal/app/tpl_files/mail_with_link.gohtml
new file mode 100644
index 0000000..e69de29
diff --git a/internal/app/tpl_files/mail_with_link.gotpl b/internal/app/tpl_files/mail_with_link.gotpl
new file mode 100644
index 0000000..e69de29
diff --git a/internal/wireguard/tpl/interface.tpl b/internal/app/tpl_files/wg_interface.tpl
similarity index 56%
rename from internal/wireguard/tpl/interface.tpl
rename to internal/app/tpl_files/wg_interface.tpl
index 745347c..a47542a 100644
--- a/internal/wireguard/tpl/interface.tpl
+++ b/internal/app/tpl_files/wg_interface.tpl
@@ -1,14 +1,18 @@
# AUTOGENERATED FILE - DO NOT EDIT
-# -WGP- Interface: {{ .Interface.DeviceName }} / Updated: {{ .Interface.UpdatedAt }} / Created: {{ .Interface.CreatedAt }}
-# -WGP- Interface display name: {{ .Interface.DisplayName }}
-# -WGP- Interface mode: {{ .Interface.Type }}
-# -WGP- PublicKey = {{ .Interface.PublicKey }}
+# This file uses wg-quick format. See https://man7.org/linux/man-pages/man8/wg-quick.8.html#CONFIGURATION
+
+# -WGP- WIREGUARD PORTAL CONFIGURATION FILE, version {{ .Portal.Version }}
+# Lines starting with the -WGP- tag are used by the WireGuard Portal configuration parser.
[Interface]
+# -WGP- Interface: {{ .Interface.Identifier }} | Updated: {{ .Interface.UpdatedAt }} | Created: {{ .Interface.CreatedAt }}
+# -WGP- Display name: {{ .Interface.DisplayName }}
+# -WGP- Interface mode: {{ .Interface.Type }}
+# -WGP- PublicKey = {{ .Interface.KeyPair.PublicKey }}
# Core settings
-PrivateKey = {{ .Interface.PrivateKey }}
-Address = {{ .Interface.IPsStr }}
+PrivateKey = {{ .Interface.KeyPair.PrivateKey }}
+Address = {{ .Interface.AddressStr }}
# Misc. settings (optional)
{{- if ne .Interface.ListenPort 0}}
@@ -17,8 +21,8 @@ ListenPort = {{ .Interface.ListenPort }}
{{- if ne .Interface.Mtu 0}}
MTU = {{.Interface.Mtu}}
{{- end}}
-{{- if and (ne .Interface.DNSStr "") (eq $.Interface.Type "client")}}
-DNS = {{ .Interface.DNSStr }}
+{{- if and (ne .Interface.DnsStr "") (eq $.Interface.Type "client")}}
+DNS = {{ .Interface.DnsStr }}
{{- end}}
{{- if ne .Interface.FirewallMark 0}}
FwMark = {{.Interface.FirewallMark}}
@@ -49,22 +53,19 @@ PostDown = {{ .Interface.PostDown }}
#
{{range .Peers}}
-{{- if not .DeactivatedAt}}
-# -WGP- Peer: {{.Identifier}} / Updated: {{.UpdatedAt}} / Created: {{.CreatedAt}}
-# -WGP- Peer email: {{.Email}}
-{{- if .PrivateKey}}
-# -WGP- PrivateKey: {{.PrivateKey}}
-{{- end}}
+{{- if not .DisabledAt}}
[Peer]
-{{- if $.FriendlyNames}}
-# friendly_name = {{ .Identifier }}
+# -WGP- Peer: {{.Uid}} | Updated: {{.UpdatedAt}} | Created: {{.CreatedAt}}
+# -WGP- Display name: {{ .Identifier }}
+{{- if .KeyPair.PrivateKey}}
+# -WGP- PrivateKey: {{.KeyPair.PrivateKey}}
{{- end}}
-PublicKey = {{ .PublicKey }}
+PublicKey = {{ .KeyPair.PublicKey }}
{{- if .PresharedKey}}
PresharedKey = {{ .PresharedKey }}
{{- end}}
{{- if eq $.Interface.Type "server"}}
-AllowedIPs = {{ .IPsStr }}{{if ne .AllowedIPsSrvStr ""}}, {{ .AllowedIPsSrvStr }}{{end}}
+AllowedIPs = {{ .AddressStr }}{{if ne .ExtraAllowedIPsStr ""}}, {{ .ExtraAllowedIPsStr }}{{end}}
{{- end}}
{{- if eq $.Interface.Type "client"}}
{{- if .AllowedIPsStr}}
@@ -78,4 +79,4 @@ Endpoint = {{ .Endpoint }}
PersistentKeepalive = {{ .PersistentKeepalive }}
{{- end}}
{{- end}}
-{{end}}
+{{end}}
\ No newline at end of file
diff --git a/internal/app/tpl_files/wg_peer.tpl b/internal/app/tpl_files/wg_peer.tpl
new file mode 100644
index 0000000..8157559
--- /dev/null
+++ b/internal/app/tpl_files/wg_peer.tpl
@@ -0,0 +1,60 @@
+# AUTOGENERATED FILE - DO NOT EDIT
+# This file uses wg-quick format. See https://man7.org/linux/man-pages/man8/wg-quick.8.html#CONFIGURATION
+
+# -WGP- WIREGUARD PORTAL CONFIGURATION FILE, version {{ .Portal.Version }}
+# Lines starting with the -WGP- tag are used by the WireGuard Portal configuration parser.
+
+[Interface]
+# -WGP- Peer: {{.Peer.Identifier}} | Updated: {{.Peer.UpdatedAt}} | Created: {{.Peer.CreatedAt}}
+# -WGP- Display name: {{ .Peer.DisplayName }}
+# -WGP- PublicKey: {{ .Peer.KeyPair.PublicKey }}
+{{- if eq .Peer.Interface.Type "server"}}
+# -WGP- Peer type: client
+{{else}}
+# -WGP- Peer type: server
+{{- end}}
+
+# Core settings
+PrivateKey = {{ .Peer.KeyPair.PrivateKey }}
+Address = {{ .Peer.Interface.AddressStr }}
+
+# Misc. settings (optional)
+{{- if .Peer.Interface.DnsStr.GetValue}}
+DNS = {{ .Peer.Interface.DnsStr.GetValue }}
+{{- end}}
+{{- if ne .Peer.Interface.Mtu.GetValue 0}}
+MTU = {{ .Peer.Interface.Mtu.GetValue }}
+{{- end}}
+{{- if ne .Peer.Interface.FirewallMark.GetValue 0}}
+FwMark = {{ .Peer.Interface.FirewallMark.GetValue }}
+{{- end}}
+{{- if ne .Peer.Interface.RoutingTable.GetValue ""}}
+Table = {{ .Peer.Interface.RoutingTable.GetValue }}
+{{- end}}
+
+# Interface hooks (optional)
+{{- if .Peer.Interface.PreUp.GetValue}}
+PreUp = {{ .Peer.Interface.PreUp.GetValue }}
+{{- end}}
+{{- if .Peer.Interface.PostUp.GetValue}}
+PostUp = {{ .Peer.Interface.PostUp.GetValue }}
+{{- end}}
+{{- if .Peer.Interface.PreDown.GetValue}}
+PreDown = {{ .Peer.Interface.PreDown.GetValue }}
+{{- end}}
+{{- if .Peer.Interface.PostDown.GetValue}}
+PostDown = {{ .Peer.Interface.PostDown.GetValue }}
+{{- end}}
+
+[Peer]
+PublicKey = {{ .Peer.Interface.PublicKey }}
+Endpoint = {{ .Peer.Endpoint.GetValue }}
+{{- if .Peer.AllowedIPsStr.GetValue}}
+AllowedIPs = {{ .Peer.AllowedIPsStr.GetValue }}
+{{- end}}
+{{- if .Peer.PresharedKey}}
+PresharedKey = {{ .Peer.PresharedKey }}
+{{- end}}
+{{- if ne .Peer.PersistentKeepalive.GetValue 0}}
+PersistentKeepalive = {{ .Peer.PersistentKeepalive.GetValue }}
+{{- end}}
\ No newline at end of file
diff --git a/internal/app/user.go b/internal/app/user.go
new file mode 100644
index 0000000..95acb3c
--- /dev/null
+++ b/internal/app/user.go
@@ -0,0 +1,448 @@
+package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/h44z/wg-portal/internal"
+
+ "github.com/go-ldap/ldap/v3"
+
+ "github.com/sirupsen/logrus"
+
+ evbus "github.com/vardius/message-bus"
+
+ "github.com/h44z/wg-portal/internal/config"
+ "github.com/h44z/wg-portal/internal/domain"
+)
+
+// region local-dependencies
+
+type userRepo interface {
+ GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
+ GetAllUsers(ctx context.Context) ([]domain.User, error)
+ FindUsers(ctx context.Context, search string) ([]domain.User, error)
+ SaveUser(ctx context.Context, id domain.UserIdentifier, updateFunc func(u *domain.User) (*domain.User, error)) error
+ DeleteUser(ctx context.Context, id domain.UserIdentifier) error
+}
+
+type peerRepo interface {
+ GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
+}
+
+// endregion local-dependencies
+
+type userManager struct {
+ cfg *config.Config
+ bus evbus.MessageBus
+
+ syncInterval time.Duration
+ users userRepo
+ peers peerRepo
+}
+
+func newUserManager(cfg *config.Config, bus evbus.MessageBus, users userRepo, peers peerRepo) (*userManager, error) {
+ m := &userManager{
+ cfg: cfg,
+ bus: bus,
+
+ syncInterval: 10 * time.Second,
+ users: users,
+ peers: peers,
+ }
+ return m, nil
+}
+
+func (m userManager) Register(ctx context.Context, user *domain.User) error {
+ err := m.New(ctx, user)
+ if err != nil {
+ return err
+ }
+
+ m.bus.Publish(TopicUserRegistered, user)
+
+ return nil
+}
+
+func (m userManager) New(ctx context.Context, user *domain.User) error {
+ if user.Identifier == "" {
+ return errors.New("missing user identifier")
+ }
+
+ err := m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
+ u.Identifier = user.Identifier
+ u.Email = user.Email
+ u.Source = user.Source
+ u.IsAdmin = user.IsAdmin
+ u.Firstname = user.Firstname
+ u.Lastname = user.Lastname
+ u.Phone = user.Phone
+ u.Department = user.Department
+ return u, nil
+ })
+ if err != nil {
+ return fmt.Errorf("failed to save user: %w", err)
+ }
+
+ m.bus.Publish(TopicUserCreated, user)
+
+ return nil
+}
+
+func (m userManager) StartBackgroundJobs(ctx context.Context) {
+ go m.runLdapSynchronizationService(ctx)
+}
+
+func (m userManager) Get(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
+ user, err := m.users.GetUser(ctx, id)
+ if err != nil {
+ return nil, fmt.Errorf("unable to load peer %s: %w", id, err)
+ }
+ peers, _ := m.peers.GetUserPeers(ctx, id) // ignore error, list will be empty in error case
+
+ user.LinkedPeerCount = len(peers)
+
+ return user, nil
+}
+
+func (m userManager) GetAll(ctx context.Context) ([]domain.User, error) {
+ users, err := m.users.GetAllUsers(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("unable to load users: %w", err)
+ }
+
+ ch := make(chan *domain.User)
+ wg := sync.WaitGroup{}
+ workers := int(math.Min(float64(len(users)), 10))
+ wg.Add(workers)
+ for i := 0; i < workers; i++ {
+ go func() {
+ defer wg.Done()
+ for user := range ch {
+ peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case
+ user.LinkedPeerCount = len(peers)
+ }
+ }()
+ }
+ for i := range users {
+ ch <- &users[i]
+ }
+ close(ch)
+ wg.Wait()
+
+ return users, nil
+}
+
+func (m userManager) Update(ctx context.Context, user *domain.User) (*domain.User, error) {
+ existingUser, err := m.users.GetUser(ctx, user.Identifier)
+ if err != nil {
+ return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err)
+ }
+
+ if err := m.validateModifications(ctx, existingUser, user); err != nil {
+ return nil, fmt.Errorf("update not allowed: %w", err)
+ }
+
+ user.CopyCalculatedAttributes(existingUser)
+ err = user.HashPassword()
+ if err != nil {
+ return nil, err
+ }
+ if user.Password == "" { // keep old password
+ user.Password = existingUser.Password
+ }
+
+ err = m.users.SaveUser(ctx, existingUser.Identifier, func(u *domain.User) (*domain.User, error) {
+ user.CopyCalculatedAttributes(u)
+ return user, nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("update failure: %w", err)
+ }
+
+ return user, nil
+}
+
+func (m userManager) Create(ctx context.Context, user *domain.User) (*domain.User, error) {
+ existingUser, err := m.users.GetUser(ctx, user.Identifier)
+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
+ return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err)
+ }
+ if existingUser != nil {
+ return nil, fmt.Errorf("user %s already exists", user.Identifier)
+ }
+
+ if err := m.validateCreation(ctx, user); err != nil {
+ return nil, fmt.Errorf("creation not allowed: %w", err)
+ }
+
+ err = user.HashPassword()
+ if err != nil {
+ return nil, err
+ }
+
+ err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
+ user.CopyCalculatedAttributes(u)
+ return user, nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("creation failure: %w", err)
+ }
+
+ return user, nil
+}
+
+func (m userManager) validateModifications(ctx context.Context, old, new *domain.User) error {
+ currentUser := domain.GetUserInfo(ctx)
+
+ if err := old.EditAllowed(); err != nil {
+ return fmt.Errorf("no access: %w", err)
+ }
+
+ if err := old.CanChangePassword(); err != nil && string(new.Password) != "" {
+ return fmt.Errorf("no access: %w", err)
+ }
+
+ if currentUser.Id == old.Identifier && old.IsAdmin && !new.IsAdmin {
+ return fmt.Errorf("cannot remove own admin rights")
+ }
+
+ if currentUser.Id == old.Identifier && new.IsDisabled() {
+ return fmt.Errorf("cannot disable own user")
+ }
+
+ if old.Source != new.Source {
+ return fmt.Errorf("cannot change user source")
+ }
+
+ return nil
+}
+
+func (m userManager) validateCreation(ctx context.Context, new *domain.User) error {
+ if new.Identifier == "" {
+ return fmt.Errorf("invalid user identifier")
+ }
+
+ if new.Source != domain.UserSourceDatabase {
+ return fmt.Errorf("invalid user source: %s", new.Source)
+ }
+
+ if string(new.Password) == "" {
+ return fmt.Errorf("invalid password")
+ }
+
+ return nil
+}
+
+func (m userManager) runLdapSynchronizationService(ctx context.Context) {
+ running := true
+ for running {
+ select {
+ case <-ctx.Done():
+ running = false
+ continue
+ case <-time.After(m.syncInterval):
+ // select blocks until one of the cases evaluate to true
+ }
+
+ for _, ldapCfg := range m.cfg.Auth.Ldap { // LDAP Auth providers
+ if !ldapCfg.Synchronize {
+ continue // sync disabled
+ }
+
+ err := m.synchronizeLdapUsers(ctx, &ldapCfg)
+ if err != nil {
+ logrus.Errorf("failed to synchronize LDAP users for %s: %v", ldapCfg.ProviderName, err)
+ }
+ }
+ }
+}
+
+func (m userManager) synchronizeLdapUsers(ctx context.Context, provider *config.LdapProvider) error {
+ logrus.Tracef("starting to synchronize users for %s", provider.ProviderName)
+
+ dn, err := ldap.ParseDN(provider.AdminGroupDN)
+ if err != nil {
+ return fmt.Errorf("failed to parse admin group DN: %w", err)
+ }
+ provider.ParsedAdminGroupDN = dn
+
+ conn, err := ldapConnect(provider)
+ if err != nil {
+ return fmt.Errorf("failed to setup LDAP connection: %w", err)
+ }
+ defer ldapDisconnect(conn)
+
+ rawUsers, err := ldapFindAllUsers(conn, provider.BaseDN, provider.SyncFilter, &provider.FieldMap)
+ if err != nil {
+ return err
+ }
+
+ logrus.Tracef("fetched %d raw ldap users...", len(rawUsers))
+
+ // Update existing LDAP users
+ err = m.updateLdapUsers(ctx, provider.ProviderName, rawUsers, &provider.FieldMap, provider.ParsedAdminGroupDN)
+ if err != nil {
+ return err
+ }
+
+ // Disable missing LDAP users
+ if provider.DisableMissing {
+ err = m.disableMissingLdapUsers(ctx, provider.ProviderName, rawUsers, &provider.FieldMap)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (m userManager) updateLdapUsers(ctx context.Context, providerName string, rawUsers []rawLdapUser, fields *config.LdapFields, adminGroupDN *ldap.DN) error {
+ for _, rawUser := range rawUsers {
+ user, err := convertRawLdapUser(providerName, rawUser, fields, adminGroupDN)
+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
+ return fmt.Errorf("failed to convert LDAP data for %v: %w", rawUser["dn"], err)
+ }
+
+ existingUser, err := m.users.GetUser(ctx, user.Identifier)
+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
+ return fmt.Errorf("find error for user id %s: %w", user.Identifier, err)
+ }
+
+ if existingUser == nil {
+ err := m.New(ctx, user)
+ if err != nil {
+ return fmt.Errorf("create error for user id %s: %w", user.Identifier, err)
+ }
+ }
+
+ if existingUser != nil && existingUser.Source == domain.UserSourceLdap && userChangedInLdap(existingUser, user) {
+ err := m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
+ u.UpdatedAt = time.Now()
+ u.UpdatedBy = "ldap_sync"
+ u.Email = user.Email
+ u.Firstname = user.Firstname
+ u.Lastname = user.Lastname
+ u.Phone = user.Phone
+ u.Department = user.Department
+ u.IsAdmin = user.IsAdmin
+ u.Disabled = user.Disabled
+
+ return u, nil
+ })
+ if err != nil {
+ return fmt.Errorf("update error for user id %s: %w", user.Identifier, err)
+ }
+ }
+ }
+
+ return nil
+}
+
+func (m userManager) disableMissingLdapUsers(ctx context.Context, providerName string, rawUsers []rawLdapUser, fields *config.LdapFields) error {
+ allUsers, err := m.users.GetAllUsers(ctx)
+ if err != nil {
+ return err
+ }
+ for _, user := range allUsers {
+ if user.Source != domain.UserSourceLdap {
+ continue // ignore non ldap users
+ }
+ if user.ProviderName != providerName {
+ continue // user was synchronized through different provider
+ }
+ if user.IsDisabled() {
+ continue // ignore deactivated
+ }
+
+ existsInLDAP := false
+ for _, rawUser := range rawUsers {
+ userId := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, ""))
+ if user.Identifier == userId {
+ existsInLDAP = true
+ break
+ }
+ }
+
+ if existsInLDAP {
+ continue
+ }
+
+ err := m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
+ now := time.Now()
+ u.Disabled = &now
+ u.DisabledReason = "missing in ldap"
+ return u, nil
+ })
+ if err != nil {
+ return fmt.Errorf("disable error for user id %s: %w", user.Identifier, err)
+ }
+
+ m.bus.Publish(TopicUserDisabled, user)
+ }
+
+ return nil
+}
+
+func convertRawLdapUser(providerName string, raw rawLdapUser, fields *config.LdapFields, adminGroupDN *ldap.DN) (*domain.User, error) {
+ now := time.Now()
+
+ isAdmin, err := ldapIsMemberOf(raw[fields.GroupMembership].([][]byte), adminGroupDN)
+ if err != nil {
+ return nil, fmt.Errorf("failed to check admin group: %w", err)
+ }
+
+ return &domain.User{
+ BaseModel: domain.BaseModel{
+ CreatedBy: "ldap_sync",
+ UpdatedBy: "ldap_sync",
+ CreatedAt: now,
+ UpdatedAt: now,
+ },
+ Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, fields.UserIdentifier, "")),
+ Email: strings.ToLower(internal.MapDefaultString(raw, fields.Email, "")),
+ Source: domain.UserSourceLdap,
+ ProviderName: providerName,
+ IsAdmin: isAdmin,
+ Firstname: internal.MapDefaultString(raw, fields.Firstname, ""),
+ Lastname: internal.MapDefaultString(raw, fields.Lastname, ""),
+ Phone: internal.MapDefaultString(raw, fields.Phone, ""),
+ Department: internal.MapDefaultString(raw, fields.Department, ""),
+ Notes: "",
+ Password: "",
+ Disabled: nil,
+ }, nil
+}
+
+func userChangedInLdap(dbUser, ldapUser *domain.User) bool {
+ if dbUser.Firstname != ldapUser.Firstname {
+ return true
+ }
+ if dbUser.Lastname != ldapUser.Lastname {
+ return true
+ }
+ if dbUser.Email != ldapUser.Email {
+ return true
+ }
+ if dbUser.Phone != ldapUser.Phone {
+ return true
+ }
+ if dbUser.Department != ldapUser.Department {
+ return true
+ }
+
+ if dbUser.IsDisabled() != ldapUser.IsDisabled() {
+ return true
+ }
+
+ if dbUser.IsAdmin != ldapUser.IsAdmin {
+ return true
+ }
+
+ return false
+}
diff --git a/internal/app/wireguard.go b/internal/app/wireguard.go
new file mode 100644
index 0000000..495ce87
--- /dev/null
+++ b/internal/app/wireguard.go
@@ -0,0 +1,523 @@
+package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/h44z/wg-portal/internal"
+ "github.com/sirupsen/logrus"
+
+ evbus "github.com/vardius/message-bus"
+
+ "github.com/h44z/wg-portal/internal/config"
+ "github.com/h44z/wg-portal/internal/domain"
+)
+
+// region local-dependencies
+
+type wireGuardDatabaseRepo interface {
+ GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error)
+ GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error)
+ GetAllInterfaces(ctx context.Context) ([]domain.Interface, error)
+ FindInterfaces(ctx context.Context, search string) ([]domain.Interface, error)
+ GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIdentifier][]domain.Cidr, error)
+ SaveInterface(ctx context.Context, id domain.InterfaceIdentifier, updateFunc func(in *domain.Interface) (*domain.Interface, error)) error
+ DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error
+ GetInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.Peer, error)
+ FindInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier, search string) ([]domain.Peer, error)
+ GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
+ FindUserPeers(ctx context.Context, id domain.UserIdentifier, search string) ([]domain.Peer, error)
+ SavePeer(ctx context.Context, id domain.PeerIdentifier, updateFunc func(in *domain.Peer) (*domain.Peer, error)) error
+ DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
+}
+
+// endregion local-dependencies
+
+type wireGuardManager struct {
+ cfg *config.Config
+ bus evbus.MessageBus
+
+ db wireGuardDatabaseRepo
+ wg wireGuardRepo
+}
+
+func newWireGuardManager(cfg *config.Config, bus evbus.MessageBus, wgRepo wireGuardRepo, db wireGuardDatabaseRepo) (*wireGuardManager, error) {
+ m := &wireGuardManager{
+ cfg: cfg,
+ bus: bus,
+ wg: wgRepo,
+ db: db,
+ }
+
+ m.connectToMessageBus()
+
+ return m, nil
+}
+
+func (m wireGuardManager) connectToMessageBus() {
+ _ = m.bus.Subscribe(TopicUserCreated, m.handleUserCreationEvent)
+}
+
+func (m wireGuardManager) handleUserCreationEvent(user *domain.User) {
+ logrus.Errorf("Handling new user event for %s", user.Identifier)
+
+ err := m.CreateDefaultPeer(context.Background(), user)
+ if err != nil {
+ logrus.Errorf("Failed to create default peer")
+ return
+ }
+}
+
+func (m wireGuardManager) GetImportableInterfaces(ctx context.Context) ([]domain.PhysicalInterface, error) {
+ physicalInterfaces, err := m.wg.GetInterfaces(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ return physicalInterfaces, nil
+}
+
+func (m wireGuardManager) ImportNewInterfaces(ctx context.Context, filter ...domain.InterfaceIdentifier) error {
+ physicalInterfaces, err := m.wg.GetInterfaces(ctx)
+ if err != nil {
+ return err
+ }
+
+ // if no filter is given, exclude already existing interfaces
+ var excludedInterfaces []domain.InterfaceIdentifier
+ if len(filter) == 0 {
+ existingInterfaces, err := m.db.GetAllInterfaces(ctx)
+ if err != nil {
+ return err
+ }
+ for _, existingInterface := range existingInterfaces {
+ excludedInterfaces = append(excludedInterfaces, existingInterface.Identifier)
+ }
+ }
+
+ for _, physicalInterface := range physicalInterfaces {
+ if internal.SliceContains(excludedInterfaces, physicalInterface.Identifier) {
+ continue
+ }
+
+ if len(filter) != 0 && !internal.SliceContains(filter, physicalInterface.Identifier) {
+ continue
+ }
+
+ logrus.Infof("importing new interface %s...", physicalInterface.Identifier)
+
+ physicalPeers, err := m.wg.GetPeers(ctx, physicalInterface.Identifier)
+ if err != nil {
+ return err
+ }
+
+ err = m.importInterface(ctx, &physicalInterface, physicalPeers)
+ if err != nil {
+ return fmt.Errorf("import of %s failed: %w", physicalInterface.Identifier, err)
+ }
+
+ logrus.Infof("imported new interface %s and %d peers", physicalInterface.Identifier, len(physicalPeers))
+ }
+
+ return nil
+}
+
+func (m wireGuardManager) importInterface(ctx context.Context, in *domain.PhysicalInterface, peers []domain.PhysicalPeer) error {
+ now := time.Now()
+ iface := domain.ConvertPhysicalInterface(in)
+ iface.BaseModel = domain.BaseModel{
+ CreatedBy: "importer",
+ UpdatedBy: "importer",
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ iface.PeerDefAllowedIPsStr = iface.AddressStr()
+
+ existingInterface, err := m.db.GetInterface(ctx, iface.Identifier)
+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
+ return err
+ }
+ if existingInterface != nil {
+ return errors.New("interface already exists")
+ }
+
+ err = m.db.SaveInterface(ctx, iface.Identifier, func(_ *domain.Interface) (*domain.Interface, error) {
+ return iface, nil
+ })
+ if err != nil {
+ return fmt.Errorf("database save failed: %w", err)
+ }
+
+ // import peers
+ for _, peer := range peers {
+ err = m.importPeer(ctx, iface, &peer)
+ if err != nil {
+ return fmt.Errorf("import of peer %s failed: %w", peer.Identifier, err)
+ }
+ }
+
+ return nil
+}
+
+func (m wireGuardManager) importPeer(ctx context.Context, in *domain.Interface, p *domain.PhysicalPeer) error {
+ now := time.Now()
+ peer := domain.ConvertPhysicalPeer(p)
+ peer.BaseModel = domain.BaseModel{
+ CreatedBy: "importer",
+ UpdatedBy: "importer",
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ peer.InterfaceIdentifier = in.Identifier
+ peer.EndpointPublicKey = in.PublicKey
+ peer.AllowedIPsStr = domain.StringConfigOption{Value: in.PeerDefAllowedIPsStr, Overridable: true}
+ peer.Interface.Addresses = p.AllowedIPs // use allowed IP's as the peer IP's
+ peer.Interface.DnsStr = domain.StringConfigOption{Value: in.PeerDefDnsStr, Overridable: true}
+ peer.Interface.DnsSearchStr = domain.StringConfigOption{Value: in.PeerDefDnsSearchStr, Overridable: true}
+ peer.Interface.Mtu = domain.IntConfigOption{Value: in.PeerDefMtu, Overridable: true}
+ peer.Interface.FirewallMark = domain.Int32ConfigOption{Value: in.PeerDefFirewallMark, Overridable: true}
+ peer.Interface.RoutingTable = domain.StringConfigOption{Value: in.PeerDefRoutingTable, Overridable: true}
+ peer.Interface.PreUp = domain.StringConfigOption{Value: in.PeerDefPreUp, Overridable: true}
+ peer.Interface.PostUp = domain.StringConfigOption{Value: in.PeerDefPostUp, Overridable: true}
+ peer.Interface.PreDown = domain.StringConfigOption{Value: in.PeerDefPreDown, Overridable: true}
+ peer.Interface.PostDown = domain.StringConfigOption{Value: in.PeerDefPostDown, Overridable: true}
+
+ switch in.Type {
+ case domain.InterfaceTypeAny:
+ peer.Interface.Type = domain.InterfaceTypeAny
+ peer.DisplayName = "Autodetected Peer (" + peer.Interface.PublicKey[0:8] + ")"
+ case domain.InterfaceTypeClient:
+ peer.Interface.Type = domain.InterfaceTypeServer
+ peer.DisplayName = "Autodetected Endpoint (" + peer.Interface.PublicKey[0:8] + ")"
+ case domain.InterfaceTypeServer:
+ peer.Interface.Type = domain.InterfaceTypeClient
+ peer.DisplayName = "Autodetected Client (" + peer.Interface.PublicKey[0:8] + ")"
+ }
+
+ err := m.db.SavePeer(ctx, peer.Identifier, func(_ *domain.Peer) (*domain.Peer, error) {
+ return peer, nil
+ })
+ if err != nil {
+ return fmt.Errorf("database save failed: %w", err)
+ }
+
+ return nil
+}
+
+func (m wireGuardManager) RestoreInterfaceState(ctx context.Context, updateDbOnError bool, filter ...domain.InterfaceIdentifier) error {
+ interfaces, err := m.db.GetAllInterfaces(ctx)
+ if err != nil {
+ return err
+ }
+
+ for _, iface := range interfaces {
+ if len(filter) != 0 && !internal.SliceContains(filter, iface.Identifier) {
+ continue
+ }
+
+ peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier)
+ if err != nil {
+ return fmt.Errorf("failed to load peers for %s: %w", iface.Identifier, err)
+ }
+
+ physicalInterface, err := m.wg.GetInterface(ctx, iface.Identifier)
+ if err != nil {
+ // try to create a new interface
+ err := m.wg.SaveInterface(ctx, iface.Identifier, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
+ domain.MergeToPhysicalInterface(pi, &iface)
+
+ return pi, nil
+ })
+ if err != nil {
+ if updateDbOnError {
+ // disable interface in database as no physical interface exists
+ _ = m.db.SaveInterface(ctx, iface.Identifier, func(in *domain.Interface) (*domain.Interface, error) {
+ now := time.Now()
+ in.Disabled = &now // set
+ in.DisabledReason = "no physical interface available"
+ return in, nil
+ })
+ }
+ return fmt.Errorf("failed to create physical interface %s: %w", iface.Identifier, err)
+ }
+
+ // restore peers
+ for _, peer := range peers {
+ err := m.wg.SavePeer(ctx, iface.Identifier, peer.Identifier, func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
+ domain.MergeToPhysicalPeer(pp, &peer)
+ return pp, nil
+ })
+ if err != nil {
+ return fmt.Errorf("failed to create physical peer %s: %w", peer.Identifier, err)
+ }
+ }
+ } else {
+ if physicalInterface.DeviceUp != !iface.IsDisabled() {
+ // try to move interface to stored state
+ err := m.wg.SaveInterface(ctx, iface.Identifier, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
+ pi.DeviceUp = !iface.IsDisabled()
+
+ return pi, nil
+ })
+ if err != nil {
+ if updateDbOnError {
+ // disable interface in database as no physical interface is available
+ _ = m.db.SaveInterface(ctx, iface.Identifier, func(in *domain.Interface) (*domain.Interface, error) {
+ if iface.IsDisabled() {
+ now := time.Now()
+ in.Disabled = &now // set
+ in.DisabledReason = "no physical interface active"
+ } else {
+ in.Disabled = nil
+ in.DisabledReason = ""
+ }
+ return in, nil
+ })
+ }
+ return fmt.Errorf("failed to change physical interface state for %s: %w", iface.Identifier, err)
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+func (m wireGuardManager) CreateDefaultPeer(ctx context.Context, user *domain.User) error {
+ // TODO: implement
+ return nil
+}
+
+func (m wireGuardManager) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error) {
+ return m.db.GetInterfaceAndPeers(ctx, id)
+}
+
+func (m wireGuardManager) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
+ return m.db.GetAllInterfaces(ctx)
+}
+
+func (m wireGuardManager) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) {
+ return m.db.GetUserPeers(ctx, id)
+}
+
+func (m wireGuardManager) PrepareInterface(ctx context.Context) (*domain.Interface, error) {
+ currentUser := domain.GetUserInfo(ctx)
+
+ kp, err := domain.NewFreshKeypair()
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate keys: %w", err)
+ }
+
+ id, err := m.getNewInterfaceName(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate new identifier: %w", err)
+ }
+
+ ipv4, ipv6, err := m.getFreshIpConfig(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate new ip config: %w", err)
+ }
+
+ freshInterface := &domain.Interface{
+ BaseModel: domain.BaseModel{
+ CreatedBy: string(currentUser.Id),
+ UpdatedBy: string(currentUser.Id),
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ },
+ Identifier: id,
+ KeyPair: kp,
+ ListenPort: 0, // TODO
+ Addresses: []domain.Cidr{ipv4, ipv6},
+ DnsStr: "",
+ DnsSearchStr: "",
+ Mtu: 1420,
+ FirewallMark: 0,
+ RoutingTable: "",
+ PreUp: "",
+ PostUp: "",
+ PreDown: "",
+ PostDown: "",
+ SaveConfig: false,
+ DisplayName: string(id),
+ Type: domain.InterfaceTypeServer,
+ DriverType: "",
+ Disabled: nil,
+ DisabledReason: "",
+ PeerDefNetworkStr: "", // TODO
+ PeerDefDnsStr: "", // TODO
+ PeerDefDnsSearchStr: "",
+ PeerDefEndpoint: "",
+ PeerDefAllowedIPsStr: "",
+ PeerDefMtu: 1420,
+ PeerDefPersistentKeepalive: 16,
+ PeerDefFirewallMark: 0,
+ PeerDefRoutingTable: "",
+ PeerDefPreUp: "",
+ PeerDefPostUp: "",
+ PeerDefPreDown: "",
+ PeerDefPostDown: "",
+ }
+
+ return freshInterface, nil
+}
+
+func (m wireGuardManager) getNewInterfaceName(ctx context.Context) (domain.InterfaceIdentifier, error) {
+ namePrefix := "wg"
+ nameSuffix := 1
+
+ existingInterfaces, err := m.db.GetAllInterfaces(ctx)
+ if err != nil {
+ return "", err
+ }
+ var name domain.InterfaceIdentifier
+ for {
+ name = domain.InterfaceIdentifier(fmt.Sprintf("%s%d", namePrefix, nameSuffix))
+
+ conflict := false
+ for _, in := range existingInterfaces {
+ if in.Identifier == name {
+ conflict = true
+ break
+ }
+ }
+ if !conflict {
+ break
+ }
+
+ nameSuffix++
+ }
+
+ return name, nil
+}
+
+func (m wireGuardManager) getFreshIpConfig(ctx context.Context) (ipV4, ipV6 domain.Cidr, err error) {
+ ips, err := m.db.GetInterfaceIps(ctx)
+ if err != nil {
+ err = fmt.Errorf("failed to get existing IP addresses: %w", err)
+ return
+ }
+
+ ipV4, _ = domain.CidrFromString("10.6.6.1/24")
+ ipV6, _ = domain.CidrFromString("fdfd:d3ad:c0de:1234::1/64")
+
+ for {
+ ipV4Conflict := false
+ ipV6Conflict := false
+ for _, usedIps := range ips {
+ for _, ip := range usedIps {
+ if ipV4 == ip {
+ ipV4Conflict = true
+ }
+
+ if ipV6 == ip {
+ ipV6Conflict = true
+ }
+ }
+ }
+
+ if !ipV4Conflict && !ipV6Conflict {
+ break
+ }
+
+ if ipV4Conflict {
+ ipV4 = ipV4.NextSubnet()
+ }
+
+ if ipV6Conflict {
+ ipV6 = ipV6.NextSubnet()
+ }
+ }
+
+ return
+}
+
+func (m wireGuardManager) CreateInterface(ctx context.Context, in *domain.Interface) (*domain.Interface, error) {
+ existingInterface, err := m.db.GetInterface(ctx, in.Identifier)
+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
+ return nil, fmt.Errorf("unable to load existing interface %s: %w", in.Identifier, err)
+ }
+ if existingInterface != nil {
+ return nil, fmt.Errorf("interface %s already exists", in.Identifier)
+ }
+
+ if err := m.validateCreation(ctx, existingInterface, in); err != nil {
+ return nil, fmt.Errorf("creation not allowed: %w", err)
+ }
+
+ err = m.db.SaveInterface(ctx, in.Identifier, func(i *domain.Interface) (*domain.Interface, error) {
+ in.CopyCalculatedAttributes(i)
+
+ err = m.wg.SaveInterface(ctx, in.Identifier, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
+ domain.MergeToPhysicalInterface(pi, in)
+ return pi, nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to create physical interface %s: %w", in.Identifier, err)
+ }
+
+ return in, nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("creation failure: %w", err)
+ }
+
+ return in, nil
+}
+
+func (m wireGuardManager) UpdateInterface(ctx context.Context, in *domain.Interface) (*domain.Interface, error) {
+ existingInterface, err := m.db.GetInterface(ctx, in.Identifier)
+ if err != nil {
+ return nil, fmt.Errorf("unable to load existing interface %s: %w", in.Identifier, err)
+ }
+
+ if err := m.validateModifications(ctx, existingInterface, in); err != nil {
+ return nil, fmt.Errorf("update not allowed: %w", err)
+ }
+
+ err = m.db.SaveInterface(ctx, in.Identifier, func(i *domain.Interface) (*domain.Interface, error) {
+ in.CopyCalculatedAttributes(i)
+
+ err = m.wg.SaveInterface(ctx, in.Identifier, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
+ domain.MergeToPhysicalInterface(pi, in)
+ return pi, nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to update physical interface %s: %w", in.Identifier, err)
+ }
+
+ return in, nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("update failure: %w", err)
+ }
+
+ return in, nil
+}
+
+func (m wireGuardManager) validateModifications(ctx context.Context, old, new *domain.Interface) error {
+ currentUser := domain.GetUserInfo(ctx)
+
+ if !currentUser.IsAdmin {
+ return fmt.Errorf("insufficient permissions")
+ }
+
+ return nil
+}
+
+func (m wireGuardManager) validateCreation(ctx context.Context, old, new *domain.Interface) error {
+ currentUser := domain.GetUserInfo(ctx)
+
+ if new.Identifier == "" {
+ return fmt.Errorf("invalid interface identifier")
+ }
+
+ if !currentUser.IsAdmin {
+ return fmt.Errorf("insufficient permissions")
+ }
+
+ return nil
+}
diff --git a/internal/authentication/provider.go b/internal/authentication/provider.go
deleted file mode 100644
index a1e57d1..0000000
--- a/internal/authentication/provider.go
+++ /dev/null
@@ -1,32 +0,0 @@
-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)
-}
diff --git a/internal/authentication/providers/ldap/provider.go b/internal/authentication/providers/ldap/provider.go
deleted file mode 100644
index 88a588d..0000000
--- a/internal/authentication/providers/ldap/provider.go
+++ /dev/null
@@ -1,210 +0,0 @@
-package ldap
-
-import (
- "crypto/tls"
- "os"
- "strings"
-
- "github.com/gin-gonic/gin"
- "github.com/go-ldap/ldap/v3"
- "github.com/h44z/wg-portal/internal/authentication"
- ldapconfig "github.com/h44z/wg-portal/internal/ldap"
- "github.com/h44z/wg-portal/internal/users"
- "github.com/pkg/errors"
-)
-
-// Provider implements a password login method for an LDAP backend.
-type Provider struct {
- config *ldapconfig.Config
-}
-
-func New(cfg *ldapconfig.Config) (*Provider, error) {
- p := &Provider{
- config: cfg,
- }
-
- // test ldap connectivity
- client, err := p.open()
- if err != nil {
- return nil, errors.Wrap(err, "unable to open ldap connection")
- }
- defer p.close(client)
-
- return p, nil
-}
-
-// GetName return provider name
-func (Provider) GetName() string {
- return string(users.UserSourceLdap)
-}
-
-// GetType return provider type
-func (Provider) GetType() authentication.AuthProviderType {
- return authentication.AuthProviderTypePassword
-}
-
-// GetPriority return provider priority
-func (Provider) GetPriority() int {
- return 1 // LDAP password provider
-}
-
-func (provider Provider) SetupRoutes(_ *gin.RouterGroup) {
- // nothing here
-}
-
-func (provider Provider) Login(ctx *authentication.AuthContext) (string, error) {
- username := strings.ToLower(ctx.Username)
- password := ctx.Password
-
- // Validate input
- if strings.Trim(username, " ") == "" || strings.Trim(password, " ") == "" {
- return "", errors.New("empty username or password")
- }
-
- client, err := provider.open()
- if err != nil {
- return "", errors.Wrap(err, "unable to open ldap connection")
- }
- defer provider.close(client)
-
- // Search for the given username
- attrs := []string{"dn", provider.config.EmailAttribute}
- loginFilter := strings.Replace(provider.config.LoginFilter, "{{login_identifier}}", username, -1)
- searchRequest := ldap.NewSearchRequest(
- provider.config.BaseDN,
- ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
- loginFilter,
- attrs,
- nil,
- )
-
- sr, err := client.Search(searchRequest)
- if err != nil {
- return "", errors.Wrap(err, "unable to find user in ldap")
- }
-
- if len(sr.Entries) != 1 {
- return "", errors.Errorf("invalid amount of ldap entries (%d)", len(sr.Entries))
- }
-
- // Bind as the user to verify their password
- userDN := sr.Entries[0].DN
- err = client.Bind(userDN, password)
- if err != nil {
- return "", errors.Wrapf(err, "invalid credentials")
- }
-
- return sr.Entries[0].GetAttributeValue(provider.config.EmailAttribute), nil
-}
-
-func (provider Provider) Logout(_ *authentication.AuthContext) error {
- return nil // nothing here
-}
-
-func (provider Provider) GetUserModel(ctx *authentication.AuthContext) (*authentication.User, error) {
- username := strings.ToLower(ctx.Username)
-
- // Validate input
- if strings.Trim(username, " ") == "" {
- return nil, errors.New("empty username")
- }
-
- client, err := provider.open()
- if err != nil {
- return nil, errors.Wrap(err, "unable to open ldap connection")
- }
- defer provider.close(client)
-
- // Search for the given username
- attrs := []string{"dn", provider.config.EmailAttribute, provider.config.FirstNameAttribute, provider.config.LastNameAttribute,
- provider.config.PhoneAttribute, provider.config.GroupMemberAttribute}
- loginFilter := strings.Replace(provider.config.LoginFilter, "{{login_identifier}}", username, -1)
- searchRequest := ldap.NewSearchRequest(
- provider.config.BaseDN,
- ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
- loginFilter,
- attrs,
- nil,
- )
-
- sr, err := client.Search(searchRequest)
- if err != nil {
- return nil, errors.Wrap(err, "unable to find user in ldap")
- }
-
- if len(sr.Entries) != 1 {
- return nil, errors.Wrapf(err, "invalid amount of ldap entries (%d)", len(sr.Entries))
- }
-
- user := &authentication.User{
- Firstname: sr.Entries[0].GetAttributeValue(provider.config.FirstNameAttribute),
- Lastname: sr.Entries[0].GetAttributeValue(provider.config.LastNameAttribute),
- Email: sr.Entries[0].GetAttributeValue(provider.config.EmailAttribute),
- Phone: sr.Entries[0].GetAttributeValue(provider.config.PhoneAttribute),
- IsAdmin: false,
- }
-
- for _, group := range sr.Entries[0].GetAttributeValues(provider.config.GroupMemberAttribute) {
- if group == provider.config.AdminLdapGroup {
- user.IsAdmin = true
- break
- }
- }
-
- return user, nil
-}
-
-func (provider Provider) open() (*ldap.Conn, error) {
- var tlsConfig *tls.Config
-
- if provider.config.LdapCertConn {
-
- certPlain, err := os.ReadFile(provider.config.LdapTlsCert)
- if err != nil {
- return nil, errors.WithMessage(err, "failed to load the certificate")
-
- }
-
- key, err := os.ReadFile(provider.config.LdapTlsKey)
- if err != nil {
- return nil, errors.WithMessage(err, "failed to load the key")
- }
-
- certX509, err := tls.X509KeyPair(certPlain, key)
- if err != nil {
- return nil, errors.WithMessage(err, "failed X509")
-
- }
- tlsConfig = &tls.Config{Certificates: []tls.Certificate{certX509}}
-
- } else {
-
- tlsConfig = &tls.Config{InsecureSkipVerify: !provider.config.CertValidation}
- }
-
- conn, err := ldap.DialURL(provider.config.URL, ldap.DialWithTLSConfig(tlsConfig))
- if err != nil {
- return nil, errors.WithMessage(err, "failed to connect to LDAP")
- }
-
- if provider.config.StartTLS {
- // Reconnect with TLS
- err = conn.StartTLS(tlsConfig)
- if err != nil {
- return nil, errors.WithMessage(err, "failed to start TLS session")
- }
- }
-
- err = conn.Bind(provider.config.BindUser, provider.config.BindPass)
- if err != nil {
- return nil, errors.WithMessage(err, "failed to bind user")
- }
-
- return conn, nil
-}
-
-func (provider Provider) close(conn *ldap.Conn) {
- if conn != nil {
- conn.Close()
- }
-}
diff --git a/internal/authentication/providers/password/provider.go b/internal/authentication/providers/password/provider.go
deleted file mode 100644
index 7b28c18..0000000
--- a/internal/authentication/providers/password/provider.go
+++ /dev/null
@@ -1,195 +0,0 @@
-package password
-
-import (
- "fmt"
- "math/rand"
- "regexp"
- "strings"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/h44z/wg-portal/internal/authentication"
- "github.com/h44z/wg-portal/internal/common"
- "github.com/h44z/wg-portal/internal/users"
- "github.com/pkg/errors"
- "golang.org/x/crypto/bcrypt"
- "gorm.io/gorm"
-)
-
-var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
-
-// Provider implements a password login method for a database backend.
-type Provider struct {
- db *gorm.DB
-}
-
-func New(cfg *common.DatabaseConfig) (*Provider, error) {
- p := &Provider{}
-
- var err error
- p.db, err = common.GetDatabaseForConfig(cfg)
- if err != nil {
- return nil, errors.Wrapf(err, "failed to setup authentication database %s", cfg.Database)
- }
-
- return p, nil
-}
-
-// GetName return provider name
-func (Provider) GetName() string {
- return string(users.UserSourceDatabase)
-}
-
-// GetType return provider type
-func (Provider) GetType() authentication.AuthProviderType {
- return authentication.AuthProviderTypePassword
-}
-
-// GetPriority return provider priority
-func (Provider) GetPriority() int {
- return 0 // DB password provider = highest prio
-}
-
-func (provider Provider) SetupRoutes(_ *gin.RouterGroup) {
- // nothing here
-}
-
-func (provider Provider) Login(ctx *authentication.AuthContext) (string, error) {
- username := strings.ToLower(ctx.Username)
- password := ctx.Password
-
- // Validate input
- if strings.Trim(username, " ") == "" || strings.Trim(password, " ") == "" {
- return "", errors.New("empty username or password")
- }
-
- // Authenticate against the users database
- user := users.User{}
- provider.db.Where("email = ?", username).First(&user)
-
- if user.Email == "" {
- return "", errors.New("invalid username")
- }
-
- // Compare the stored hashed password, with the hashed version of the password that was received
- if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
- return "", errors.New("invalid password")
- }
-
- return user.Email, nil
-}
-
-func (provider Provider) Logout(_ *authentication.AuthContext) error {
- return nil // nothing here
-}
-
-func (provider Provider) GetUserModel(ctx *authentication.AuthContext) (*authentication.User, error) {
- username := strings.ToLower(ctx.Username)
-
- // Validate input
- if strings.Trim(username, " ") == "" {
- return nil, errors.New("empty username")
- }
-
- // Fetch usermodel from users database
- user := users.User{}
- provider.db.Where("email = ?", username).First(&user)
- if user.Email != username {
- return nil, errors.New("invalid or disabled username")
- }
-
- return &authentication.User{
- Email: user.Email,
- IsAdmin: user.IsAdmin,
- Firstname: user.Firstname,
- Lastname: user.Lastname,
- Phone: user.Phone,
- }, nil
-}
-
-func (provider Provider) InitializeAdmin(email, password string) error {
- email = strings.ToLower(email)
- if !emailRegex.MatchString(email) {
- return errors.New("admin username must be an email address")
- }
-
- admin := users.User{}
- provider.db.Unscoped().Where("email = ?", email).FirstOrInit(&admin)
-
- // newly created admin
- if admin.Email != email {
- // For security reasons a random admin password will be generated if the default one is still in use!
- if password == "wgportal" {
- password = generateRandomPassword()
-
- fmt.Println("#############################################")
- fmt.Println("Administrator credentials:")
- fmt.Println(" Email: ", email)
- fmt.Println(" Password: ", password)
- fmt.Println()
- fmt.Println("This information will only be displayed once!")
- fmt.Println("#############################################")
- }
- hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
- if err != nil {
- return errors.Wrap(err, "failed to hash admin password")
- }
-
- admin.Email = email
- admin.Password = users.PrivateString(hashedPassword)
- admin.Firstname = "WireGuard"
- admin.Lastname = "Administrator"
- admin.CreatedAt = time.Now()
- admin.UpdatedAt = time.Now()
- admin.IsAdmin = true
- admin.Source = users.UserSourceDatabase
-
- res := provider.db.Create(admin)
- if res.Error != nil {
- return errors.Wrapf(res.Error, "failed to create admin %s", admin.Email)
- }
- }
-
- // update/reactivate
- if !admin.IsAdmin || admin.DeletedAt.Valid {
- // For security reasons a random admin password will be generated if the default one is still in use!
- if password == "wgportal" {
- password = generateRandomPassword()
-
- fmt.Println("#############################################")
- fmt.Println("Administrator credentials:")
- fmt.Println(" Email: ", email)
- fmt.Println(" Password: ", password)
- fmt.Println()
- fmt.Println("This information will only be displayed once!")
- fmt.Println("#############################################")
- }
-
- hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
- if err != nil {
- return errors.Wrap(err, "failed to hash admin password")
- }
-
- admin.Password = users.PrivateString(hashedPassword)
- admin.IsAdmin = true
- admin.UpdatedAt = time.Now()
-
- res := provider.db.Save(admin)
- if res.Error != nil {
- return errors.Wrapf(res.Error, "failed to update admin %s", admin.Email)
- }
- }
-
- return nil
-}
-
-func generateRandomPassword() string {
- rand.Seed(time.Now().Unix())
- var randPassword strings.Builder
- charSet := "abcdedfghijklmnopqrstABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$"
- for i := 0; i < 12; i++ {
- random := rand.Intn(len(charSet))
- randPassword.WriteString(string(charSet[random]))
- }
- return randPassword.String()
-}
diff --git a/internal/authentication/user.go b/internal/authentication/user.go
deleted file mode 100644
index a5afcfc..0000000
--- a/internal/authentication/user.go
+++ /dev/null
@@ -1,12 +0,0 @@
-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
-}
diff --git a/internal/common/db.go b/internal/common/db.go
deleted file mode 100644
index 39e4158..0000000
--- a/internal/common/db.go
+++ /dev/null
@@ -1,195 +0,0 @@
-package common
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
-
- "github.com/pkg/errors"
- "github.com/sirupsen/logrus"
- "gorm.io/driver/mysql"
- "gorm.io/driver/sqlite"
- "gorm.io/gorm"
- "gorm.io/gorm/logger"
-)
-
-func init() {
- migrations = append(migrations, Migration{
- version: "1.0.7",
- migrateFn: func(db *gorm.DB) error {
- if err := db.Exec("UPDATE users SET email = LOWER(email)").Error; err != nil {
- return errors.Wrap(err, "failed to convert user emails to lower case")
- }
- if err := db.Exec("UPDATE peers SET email = LOWER(email)").Error; err != nil {
- return errors.Wrap(err, "failed to convert peer emails to lower case")
- }
- logrus.Infof("upgraded database format to version 1.0.7")
- return nil
- },
- })
- migrations = append(migrations, Migration{
- version: "1.0.8",
- migrateFn: func(db *gorm.DB) error {
- logrus.Infof("upgraded database format to version 1.0.8")
- return nil
- },
- })
-
- migrations = append(migrations, Migration{
- version: "1.0.9",
- migrateFn: func(db *gorm.DB) error {
- if db.Dialector.Name() != (sqlite.Dialector{}).Name() {
- logrus.Infof("upgraded database format to version 1.0.9")
- return nil // only perform migration for sqlite
- }
-
- type sqlIndex struct {
- Name string `gorm:"column:name"`
- Table string `gorm:"column:tbl_name"`
- }
- var indices []sqlIndex
- if err := db.Raw("SELECT name, tbl_name FROM sqlite_master WHERE type == 'index'").Scan(&indices).Error; err != nil {
- return errors.Wrap(err, "failed to fetch indices")
- }
-
- for _, index := range indices {
- if index.Table != "devices" && index.Table != "peers" && index.Table != "users" {
- continue
- }
- if strings.Contains(index.Name, "autoindex") {
- continue
- }
- if err := db.Exec("DROP INDEX " + index.Name).Error; err != nil {
- return errors.Wrap(err, "failed to drop index "+index.Name)
- }
- }
-
- logrus.Infof("upgraded database format to version 1.0.9")
- return nil
- },
- })
-}
-
-type SupportedDatabase string
-
-const (
- SupportedDatabaseMySQL SupportedDatabase = "mysql"
- SupportedDatabaseSQLite SupportedDatabase = "sqlite"
-)
-
-type DatabaseConfig struct {
- Typ SupportedDatabase `yaml:"typ" envconfig:"DATABASE_TYPE"` //mysql or sqlite
- Host string `yaml:"host" envconfig:"DATABASE_HOST"`
- Port int `yaml:"port" envconfig:"DATABASE_PORT"`
- Database string `yaml:"database" envconfig:"DATABASE_NAME"` // On SQLite: the database file-path, otherwise the database name
- User string `yaml:"user" envconfig:"DATABASE_USERNAME"`
- Password string `yaml:"password" envconfig:"DATABASE_PASSWORD"`
-}
-
-func GetDatabaseForConfig(cfg *DatabaseConfig) (db *gorm.DB, err error) {
- switch cfg.Typ {
- case SupportedDatabaseSQLite:
- if _, err = os.Stat(filepath.Dir(cfg.Database)); os.IsNotExist(err) {
- if err = os.MkdirAll(filepath.Dir(cfg.Database), 0700); err != nil {
- return
- }
- }
- db, err = gorm.Open(sqlite.Open(cfg.Database), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
- if err != nil {
- return
- }
- case SupportedDatabaseMySQL:
- connectionString := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database)
- db, err = gorm.Open(mysql.Open(connectionString), &gorm.Config{})
- if err != nil {
- return
- }
-
- sqlDB, _ := db.DB()
- sqlDB.SetConnMaxLifetime(time.Minute * 5)
- sqlDB.SetMaxIdleConns(2)
- sqlDB.SetMaxOpenConns(10)
- err = sqlDB.Ping() // This DOES open a connection if necessary. This makes sure the database is accessible
- if err != nil {
- return nil, errors.Wrap(err, "failed to ping mysql authentication database")
- }
- }
-
- // Enable Logger (logrus)
- logCfg := logger.Config{
- SlowThreshold: time.Second, // all slower than one second
- Colorful: false,
- LogLevel: logger.Silent, // default: log nothing
- }
-
- if logrus.StandardLogger().GetLevel() == logrus.TraceLevel {
- logCfg.LogLevel = logger.Info
- logCfg.SlowThreshold = 500 * time.Millisecond // all slower than half a second
- }
-
- db.Config.Logger = logger.New(logrus.StandardLogger(), logCfg)
- return
-}
-
-type DatabaseMigrationInfo struct {
- Version string `gorm:"primaryKey"`
- Applied time.Time
-}
-
-type Migration struct {
- version string
- migrateFn func(db *gorm.DB) error
-}
-
-var migrations []Migration
-
-func MigrateDatabase(db *gorm.DB, version string) error {
- if err := db.AutoMigrate(&DatabaseMigrationInfo{}); err != nil {
- return errors.Wrap(err, "failed to migrate version database")
- }
-
- existingMigration := DatabaseMigrationInfo{}
- db.Where("version = ?", version).FirstOrInit(&existingMigration)
-
- if existingMigration.Version == "" {
- lastVersion := DatabaseMigrationInfo{}
- db.Order("applied desc, version desc").FirstOrInit(&lastVersion)
-
- if lastVersion.Version == "" {
- // fresh database, no migrations to apply
- res := db.Create(&DatabaseMigrationInfo{
- Version: version,
- Applied: time.Now(),
- })
- if res.Error != nil {
- return errors.Wrapf(res.Error, "failed to write version %s to database", version)
- }
- return nil
- }
-
- sort.Slice(migrations, func(i, j int) bool {
- return migrations[i].version < migrations[j].version
- })
-
- for _, migration := range migrations {
- if migration.version > lastVersion.Version {
- if err := migration.migrateFn(db); err != nil {
- return errors.Wrapf(err, "failed to migrate to version %s", migration.version)
- }
-
- res := db.Create(&DatabaseMigrationInfo{
- Version: migration.version,
- Applied: time.Now(),
- })
- if res.Error != nil {
- return errors.Wrapf(res.Error, "failed to write version %s to database", migration.version)
- }
- }
- }
- }
-
- return nil
-}
diff --git a/internal/common/email.go b/internal/common/email.go
deleted file mode 100644
index acd449b..0000000
--- a/internal/common/email.go
+++ /dev/null
@@ -1,116 +0,0 @@
-package common
-
-import (
- "crypto/tls"
- "io"
- "time"
-
- "github.com/pkg/errors"
- mail "github.com/xhit/go-simple-mail/v2"
-)
-
-type MailEncryption string
-
-const (
- MailEncryptionNone MailEncryption = "none"
- MailEncryptionTLS MailEncryption = "tls"
- MailEncryptionStartTLS MailEncryption = "starttls"
-)
-
-type MailAuthType string
-
-const (
- MailAuthPlain MailAuthType = "plain"
- MailAuthLogin MailAuthType = "login"
- MailAuthCramMD5 MailAuthType = "crammd5"
-)
-
-type MailConfig struct {
- Host string `yaml:"host" envconfig:"EMAIL_HOST"`
- Port int `yaml:"port" envconfig:"EMAIL_PORT"`
- TLS bool `yaml:"tls" envconfig:"EMAIL_TLS"` // Deprecated, use MailConfig.Encryption instead.
- Encryption MailEncryption `yaml:"encryption" envconfig:"EMAIL_ENCRYPTION"`
- CertValidation bool `yaml:"certcheck" envconfig:"EMAIL_CERT_VALIDATION"`
- Username string `yaml:"user" envconfig:"EMAIL_USERNAME"`
- Password string `yaml:"pass" envconfig:"EMAIL_PASSWORD"`
- AuthType MailAuthType `yaml:"auth" envconfig:"EMAIL_AUTHTYPE"`
-}
-
-type MailAttachment struct {
- Name string
- ContentType string
- Data io.Reader
- Embedded bool
-}
-
-// SendEmailWithAttachments sends a mail with optional attachments.
-func SendEmailWithAttachments(cfg MailConfig, sender, replyTo, subject, body, htmlBody string, receivers []string, attachments []MailAttachment) error {
- srv := mail.NewSMTPClient()
-
- srv.ConnectTimeout = 30 * time.Second
- srv.SendTimeout = 30 * time.Second
- srv.Host = cfg.Host
- srv.Port = cfg.Port
- srv.Username = cfg.Username
- srv.Password = cfg.Password
-
- // TODO: remove this once the deprecated MailConfig.TLS config option has been removed
- if cfg.TLS {
- cfg.Encryption = MailEncryptionStartTLS
- }
- switch cfg.Encryption {
- case MailEncryptionTLS:
- srv.Encryption = mail.EncryptionSSLTLS
- case MailEncryptionStartTLS:
- srv.Encryption = mail.EncryptionSTARTTLS
- default: // MailEncryptionNone
- srv.Encryption = mail.EncryptionNone
- }
- srv.TLSConfig = &tls.Config{ServerName: srv.Host, InsecureSkipVerify: !cfg.CertValidation}
- switch cfg.AuthType {
- case MailAuthPlain:
- srv.Authentication = mail.AuthPlain
- case MailAuthLogin:
- srv.Authentication = mail.AuthLogin
- case MailAuthCramMD5:
- srv.Authentication = mail.AuthCRAMMD5
- }
-
- client, err := srv.Connect()
- if err != nil {
- return errors.Wrap(err, "failed to connect via SMTP")
- }
-
- if replyTo == "" {
- replyTo = sender
- }
-
- email := mail.NewMSG()
- email.SetFrom(sender).
- AddTo(receivers...).
- SetReplyTo(replyTo).
- SetSubject(subject)
-
- email.SetBody(mail.TextHTML, htmlBody)
- email.AddAlternative(mail.TextPlain, body)
-
- for _, attachment := range attachments {
- attachmentData, err := io.ReadAll(attachment.Data)
- if err != nil {
- return errors.Wrapf(err, "failed to read attachment data for %s", attachment.Name)
- }
-
- if attachment.Embedded {
- email.AddInlineData(attachmentData, attachment.Name, attachment.ContentType)
- } else {
- email.AddAttachmentData(attachmentData, attachment.Name, attachment.ContentType)
- }
- }
-
- // Call Send and pass the client
- err = email.Send(client)
- if err != nil {
- return errors.Wrapf(err, "failed to send email")
- }
- return nil
-}
diff --git a/internal/common/util.go b/internal/common/util.go
deleted file mode 100644
index e33ebf9..0000000
--- a/internal/common/util.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package common
-
-import (
- "fmt"
- "net"
- "strings"
- "time"
-)
-
-// BroadcastAddr returns the last address in the given network, or the broadcast address.
-func BroadcastAddr(n *net.IPNet) net.IP {
- // The golang net package doesn't make it easy to calculate the broadcast address. :(
- var broadcast net.IP
- if len(n.IP) == 4 {
- broadcast = net.ParseIP("0.0.0.0").To4()
- } else {
- broadcast = net.ParseIP("::")
- }
- for i := 0; i < len(n.IP); i++ {
- broadcast[i] = n.IP[i] | ^n.Mask[i]
- }
- return broadcast
-}
-
-// http://play.golang.org/p/m8TNTtygK0
-func IncreaseIP(ip net.IP) {
- for j := len(ip) - 1; j >= 0; j-- {
- ip[j]++
- if ip[j] > 0 {
- break
- }
- }
-}
-
-// IsIPv6 check if given ip is IPv6
-func IsIPv6(address string) bool {
- ip := net.ParseIP(address)
- if ip == nil {
- return false
- }
- 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))
- for i := range tokens {
- tokens[i] = strings.TrimSpace(tokens[i])
- if tokens[i] != "" {
- validatedTokens = append(validatedTokens, tokens[i])
- }
- }
-
- return validatedTokens
-}
-
-// ListToString converts a list of strings into a comma separated string.
-func ListToString(lst []string) string {
- return strings.Join(lst, ", ")
-}
-
-// ListContains checks if a needle exists in the given list.
-func ListContains(lst []string, needle string) bool {
- for _, entry := range lst {
- if entry == needle {
- return true
- }
- }
- return false
-}
-
-// https://yourbasic.org/golang/formatting-byte-size-to-human-readable-format/
-func ByteCountSI(b int64) string {
- const unit = 1000
- if b < unit {
- return fmt.Sprintf("%d B", b)
- }
- div, exp := int64(unit), 0
- for n := b / unit; n >= unit; n /= unit {
- div *= unit
- exp++
- }
- return fmt.Sprintf("%.1f %cB",
- float64(b)/float64(div), "kMGTPE"[exp])
-}
-
-func FormatDateHTML(t *time.Time) string {
- if t == nil {
- return ""
- }
-
- return t.Format("2006-01-02")
-}
diff --git a/internal/config/auth.go b/internal/config/auth.go
new file mode 100644
index 0000000..5032662
--- /dev/null
+++ b/internal/config/auth.go
@@ -0,0 +1,118 @@
+package config
+
+import (
+ "github.com/go-ldap/ldap/v3"
+)
+
+type Auth struct {
+ OpenIDConnect []OpenIDConnectProvider `yaml:"oidc"`
+ OAuth []OAuthProvider `yaml:"oauth"`
+ Ldap []LdapProvider `yaml:"ldap"`
+ CallbackUrlPrefix string `yaml:"callback_url_prefix"`
+}
+
+type BaseFields struct {
+ UserIdentifier string `yaml:"user_identifier"`
+ Email string `yaml:"email"`
+ Firstname string `yaml:"firstname"`
+ Lastname string `yaml:"lastname"`
+ Phone string `yaml:"phone"`
+ Department string `yaml:"department"`
+}
+
+type OauthFields struct {
+ BaseFields `yaml:",inline"`
+ IsAdmin string `yaml:"is_admin"`
+}
+
+type LdapFields struct {
+ BaseFields `yaml:",inline"`
+ GroupMembership string `yaml:"memberof"`
+}
+
+type LdapProvider struct {
+ // ProviderName is an internal name that is used to distinguish LDAP servers. It must not contain spaces or special characters.
+ ProviderName string `yaml:"provider_name"`
+
+ URL string `yaml:"url"`
+ StartTLS bool `yaml:"start_tls"`
+ CertValidation bool `yaml:"cert_validation"`
+ TlsCertificatePath string `yaml:"tls_certificate_path"`
+ TlsKeyPath string `yaml:"tls_key_path"`
+
+ BaseDN string `yaml:"base_dn"`
+ BindUser string `yaml:"bind_user"`
+ BindPass string `yaml:"bind_pass"`
+
+ FieldMap LdapFields `yaml:"field_map"`
+
+ LoginFilter string `yaml:"login_filter"` // {{login_identifier}} gets replaced with the login email address / username
+ AdminGroupDN string `yaml:"admin_group"` // Members of this group receive admin rights in WG-Portal
+ ParsedAdminGroupDN *ldap.DN `yaml:"-"`
+
+ Synchronize bool `yaml:"synchronize"`
+ // If DisableMissing is false, missing users will be deactivated
+ DisableMissing bool `yaml:"deactivate_missing"`
+ SyncFilter string `yaml:"sync_filter"`
+
+ // If RegistrationEnabled is set to true, wg-portal will create new users that do not exist in the database.
+ RegistrationEnabled bool `yaml:"registration_enabled"`
+}
+
+type OpenIDConnectProvider struct {
+ // ProviderName is an internal name that is used to distinguish oauth endpoints. It must not contain spaces or special characters.
+ ProviderName string `yaml:"provider_name"`
+
+ // DisplayName is shown to the user on the login page. If it is empty, ProviderName will be displayed.
+ DisplayName string `yaml:"display_name"`
+
+ BaseUrl string `yaml:"base_url"`
+
+ // ClientID is the application's ID.
+ ClientID string `yaml:"client_id"`
+
+ // ClientSecret is the application's secret.
+ ClientSecret string `yaml:"client_secret"`
+
+ // ExtraScopes specifies optional requested permissions.
+ ExtraScopes []string `yaml:"extra_scopes"`
+
+ // FieldMap is used to map the names of the user-info endpoint fields to wg-portal fields
+ FieldMap OauthFields `yaml:"field_map"`
+
+ // If RegistrationEnabled is set to true, missing users will be created in the database
+ RegistrationEnabled bool `yaml:"registration_enabled"`
+}
+
+type OAuthProvider struct {
+ // ProviderName is an internal name that is used to distinguish oauth endpoints. It must not contain spaces or special characters.
+ ProviderName string `yaml:"provider_name"`
+
+ // DisplayName is shown to the user on the login page. If it is empty, ProviderName will be displayed.
+ DisplayName string `yaml:"display_name"`
+
+ BaseUrl string `yaml:"base_url"`
+
+ // ClientID is the application's ID.
+ ClientID string `yaml:"client_id"`
+
+ // ClientSecret is the application's secret.
+ ClientSecret string `yaml:"client_secret"`
+
+ AuthURL string `yaml:"auth_url"`
+ TokenURL string `yaml:"token_url"`
+ UserInfoURL string `yaml:"user_info_url"`
+
+ // RedirectURL is the URL to redirect users going through
+ // the OAuth flow, after the resource owner's URLs.
+ RedirectURL string `yaml:"redirect_url"`
+
+ // Scope specifies optional requested permissions.
+ Scopes []string `yaml:"scopes"`
+
+ // FieldMap is used to map the names of the user-info endpoint fields to wg-portal fields
+ FieldMap OauthFields `yaml:"field_map"`
+
+ // If RegistrationEnabled is set to true, wg-portal will create new users that do not exist in the database.
+ RegistrationEnabled bool `yaml:"registration_enabled"`
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..9603cd3
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,89 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "gopkg.in/yaml.v2"
+)
+
+type Config struct {
+ Core struct {
+ // AdminUser defines the default administrator account that will be created
+ AdminUser string `yaml:"admin_user"`
+ AdminPassword string `yaml:"admin_password"`
+
+ EditableKeys bool `yaml:"editable_keys"`
+ CreateDefaultPeer bool `yaml:"create_default_peer"`
+ SelfProvisioningAllowed bool `yaml:"self_provisioning_allowed"`
+ LdapSyncEnabled bool `yaml:"ldap_enabled"`
+ ImportExisting bool `yaml:"import_existing"`
+ RestoreState bool `yaml:"restore_state"`
+ } `yaml:"core"`
+
+ Advanced struct {
+ LogLevel string `yaml:"log_level"`
+ StartupTimeout time.Duration `yaml:"startup_timeout"`
+ LdapSyncInterval time.Duration `yaml:"ldap_sync_interval"`
+ } `yaml:"advanced"`
+
+ Mail MailConfig `yaml:"mail"`
+
+ Auth Auth `yaml:"auth"`
+
+ Database DatabaseConfig `yaml:"database"`
+
+ Web WebConfig `yaml:"web"`
+}
+
+func GetConfig() (*Config, error) {
+ cfg := &Config{}
+
+ // default config
+
+ cfg.Core.ImportExisting = true
+ cfg.Core.RestoreState = true
+
+ cfg.Database = DatabaseConfig{
+ Type: "sqlite",
+ DSN: "sqlite.db",
+ }
+
+ cfg.Web = WebConfig{
+ ListeningAddress: ":8888",
+ SessionSecret: "verysecret",
+ SessionIdentifier: "wgPortalSession",
+ }
+
+ cfg.Auth.CallbackUrlPrefix = "/api/v0"
+
+ // override config values from YAML file
+
+ cfgFileName := "config.yml"
+ if envCfgFileName := os.Getenv("WG_PORTAL_CONFIG"); envCfgFileName != "" {
+ cfgFileName = envCfgFileName
+ }
+
+ if err := loadConfigFile(cfg, cfgFileName); err != nil {
+ return nil, fmt.Errorf("failed to load config from yaml: %w", err)
+ }
+
+ return cfg, nil
+}
+
+func loadConfigFile(cfg any, filename string) error {
+ f, err := os.Open(filename)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ decoder := yaml.NewDecoder(f)
+ err = decoder.Decode(cfg)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/internal/config/database.go b/internal/config/database.go
new file mode 100644
index 0000000..1bed851
--- /dev/null
+++ b/internal/config/database.go
@@ -0,0 +1,15 @@
+package config
+
+type SupportedDatabase string
+
+const (
+ DatabaseMySQL SupportedDatabase = "mysql"
+ DatabaseMsSQL SupportedDatabase = "mssql"
+ DatabasePostgres SupportedDatabase = "postgres"
+ DatabaseSQLite SupportedDatabase = "sqlite"
+)
+
+type DatabaseConfig struct {
+ Type SupportedDatabase `yaml:"type"`
+ DSN string `yaml:"dsn"` // On SQLite: the database file-path, otherwise the dsn (see: https://gorm.io/docs/connecting_to_the_database.html)
+}
diff --git a/internal/config/mail.go b/internal/config/mail.go
new file mode 100644
index 0000000..12ac811
--- /dev/null
+++ b/internal/config/mail.go
@@ -0,0 +1,29 @@
+package config
+
+type MailEncryption string
+
+const (
+ MailEncryptionNone MailEncryption = "none"
+ MailEncryptionTLS MailEncryption = "tls"
+ MailEncryptionStartTLS MailEncryption = "starttls"
+)
+
+type MailAuthType string
+
+const (
+ MailAuthPlain MailAuthType = "plain"
+ MailAuthLogin MailAuthType = "login"
+ MailAuthCramMD5 MailAuthType = "crammd5"
+)
+
+type MailConfig struct {
+ Host string `envconfig:"EMAIL_HOST"`
+ Port int `envconfig:"EMAIL_PORT"`
+ Encryption MailEncryption `envconfig:"EMAIL_ENCRYPTION"`
+ CertValidation bool `envconfig:"EMAIL_CERT_VALIDATION"`
+ Username string `envconfig:"EMAIL_USERNAME"`
+ Password string `envconfig:"EMAIL_PASSWORD"`
+ AuthType MailAuthType `envconfig:"EMAIL_AUTHTYPE"`
+
+ From string `envconfig:"EMAIL_FROM"`
+}
diff --git a/internal/config/web.go b/internal/config/web.go
new file mode 100644
index 0000000..00f9b58
--- /dev/null
+++ b/internal/config/web.go
@@ -0,0 +1,11 @@
+package config
+
+type WebConfig struct {
+ ExternalUrl string `yaml:"external_url"`
+ ListeningAddress string `yaml:"listening_address"`
+ SessionIdentifier string `yaml:"session_identifier"`
+ SessionSecret string `yaml:"session_secret"`
+ CsrfSecret string `yaml:"csrf_secret"`
+ SiteTitle string `yaml:"site_title"`
+ SiteCompanyName string `yaml:"site_company_name"`
+}
diff --git a/internal/domain/auth.go b/internal/domain/auth.go
new file mode 100644
index 0000000..2173e33
--- /dev/null
+++ b/internal/domain/auth.go
@@ -0,0 +1,51 @@
+package domain
+
+import (
+ "context"
+
+ "golang.org/x/oauth2"
+)
+
+type LoginProvider string
+
+type LoginProviderInfo struct {
+ Identifier string
+ Name string
+ ProviderUrl string
+ CallbackUrl string
+}
+
+type AuthenticatorUserInfo struct {
+ Identifier UserIdentifier
+ Email string
+ Firstname string
+ Lastname string
+ Phone string
+ Department string
+ IsAdmin bool
+}
+
+type AuthenticatorType string
+
+const (
+ AuthenticatorTypeOAuth AuthenticatorType = "oauth"
+ AuthenticatorTypeOidc AuthenticatorType = "oidc"
+)
+
+type OauthAuthenticator interface {
+ GetName() string
+ GetType() AuthenticatorType
+ AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
+ Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
+ GetUserInfo(ctx context.Context, token *oauth2.Token, nonce string) (map[string]interface{}, error)
+ ParseUserInfo(raw map[string]interface{}) (*AuthenticatorUserInfo, error)
+ RegistrationEnabled() bool
+}
+
+type LdapAuthenticator interface {
+ GetName() string
+ PlaintextAuthentication(userId UserIdentifier, plainPassword string) error
+ GetUserInfo(ctx context.Context, username UserIdentifier) (map[string]interface{}, error)
+ ParseUserInfo(raw map[string]interface{}) (*AuthenticatorUserInfo, error)
+ RegistrationEnabled() bool
+}
diff --git a/internal/domain/base.go b/internal/domain/base.go
new file mode 100644
index 0000000..3c95c7f
--- /dev/null
+++ b/internal/domain/base.go
@@ -0,0 +1,22 @@
+package domain
+
+import (
+ "time"
+)
+
+type BaseModel struct {
+ CreatedBy string
+ UpdatedBy string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+type PrivateString string
+
+func (PrivateString) MarshalJSON() ([]byte, error) {
+ return []byte(`""`), nil
+}
+
+func (PrivateString) String() string {
+ return ""
+}
diff --git a/internal/domain/context.go b/internal/domain/context.go
new file mode 100644
index 0000000..1132ad6
--- /dev/null
+++ b/internal/domain/context.go
@@ -0,0 +1,53 @@
+package domain
+
+import (
+ "context"
+
+ "github.com/gin-gonic/gin"
+)
+
+const CtxUserInfo = "userInfo"
+
+type ContextUserInfo struct {
+ Id UserIdentifier
+ IsAdmin bool
+}
+
+func DefaultContextUserInfo() *ContextUserInfo {
+ return &ContextUserInfo{
+ Id: "_WG_SYS_UNKNOWN_",
+ IsAdmin: false,
+ }
+}
+
+func SetUserInfoFromGin(c *gin.Context) context.Context {
+ ginUserInfo, exists := c.Get(CtxUserInfo)
+
+ info := DefaultContextUserInfo()
+ if exists {
+ if ginInfo, ok := ginUserInfo.(*ContextUserInfo); ok {
+ info = ginInfo
+ }
+ }
+
+ ctx := context.WithValue(c.Request.Context(), CtxUserInfo, info)
+ return ctx
+}
+
+func SetUserInfo(ctx context.Context, info *ContextUserInfo) context.Context {
+ ctx = context.WithValue(ctx, CtxUserInfo, info)
+ return ctx
+}
+
+func GetUserInfo(ctx context.Context) *ContextUserInfo {
+ rawInfo := ctx.Value(CtxUserInfo)
+ if rawInfo == nil {
+ return DefaultContextUserInfo()
+ }
+
+ if info, ok := rawInfo.(*ContextUserInfo); ok {
+ return info
+ }
+
+ return DefaultContextUserInfo()
+}
diff --git a/internal/domain/crypto.go b/internal/domain/crypto.go
new file mode 100644
index 0000000..8f029d3
--- /dev/null
+++ b/internal/domain/crypto.go
@@ -0,0 +1,67 @@
+package domain
+
+import (
+ "encoding/base64"
+
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+)
+
+type KeyPair struct {
+ PrivateKey string
+ PublicKey string
+}
+
+func (p KeyPair) GetPrivateKeyBytes() []byte {
+ data, _ := base64.StdEncoding.DecodeString(p.PrivateKey)
+ return data
+}
+
+func (p KeyPair) GetPublicKeyBytes() []byte {
+ data, _ := base64.StdEncoding.DecodeString(p.PublicKey)
+ return data
+}
+
+func (p KeyPair) GetPrivateKey() wgtypes.Key {
+ key, _ := wgtypes.ParseKey(p.PrivateKey)
+ return key
+}
+
+func (p KeyPair) GetPublicKey() wgtypes.Key {
+ key, _ := wgtypes.ParseKey(p.PublicKey)
+ return key
+}
+
+type PreSharedKey string
+
+func NewFreshKeypair() (KeyPair, error) {
+ privateKey, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ return KeyPair{}, err
+ }
+
+ return KeyPair{
+ PrivateKey: privateKey.String(),
+ PublicKey: privateKey.PublicKey().String(),
+ }, nil
+}
+
+func NewPreSharedKey() (PreSharedKey, error) {
+ preSharedKey, err := wgtypes.GenerateKey()
+ if err != nil {
+ return "", err
+ }
+
+ return PreSharedKey(preSharedKey.String()), nil
+}
+
+func KeyBytesToString(key []byte) string {
+ return base64.StdEncoding.EncodeToString(key)
+}
+
+func PublicKeyFromPrivateKey(key string) string {
+ privKey, err := wgtypes.ParseKey(key)
+ if err != nil {
+ return ""
+ }
+ return privKey.PublicKey().String()
+}
diff --git a/internal/domain/errors.go b/internal/domain/errors.go
new file mode 100644
index 0000000..12821e5
--- /dev/null
+++ b/internal/domain/errors.go
@@ -0,0 +1,6 @@
+package domain
+
+import "errors"
+
+var ErrNotFound = errors.New("record not found")
+var ErrNotUnique = errors.New("record not unique")
diff --git a/internal/domain/interface.go b/internal/domain/interface.go
new file mode 100644
index 0000000..eb40876
--- /dev/null
+++ b/internal/domain/interface.go
@@ -0,0 +1,151 @@
+package domain
+
+import (
+ "time"
+)
+
+const (
+ InterfaceTypeServer InterfaceType = "server"
+ InterfaceTypeClient InterfaceType = "client"
+ InterfaceTypeAny InterfaceType = "any"
+)
+
+type InterfaceIdentifier string
+type InterfaceType string
+
+type Interface struct {
+ BaseModel
+
+ // WireGuard specific (for the [interface] section of the config file)
+
+ Identifier InterfaceIdentifier `gorm:"primaryKey"` // device name, for example: wg0
+ KeyPair // private/public Key of the server interface
+ ListenPort int // the listening port, for example: 51820
+
+ Addresses []Cidr `gorm:"many2many:interface_addresses;"` // the interface ip addresses
+ DnsStr string // the dns server that should be set if the interface is up, comma separated
+ DnsSearchStr string // the dns search option string that should be set if the interface is up, will be appended to DnsStr
+
+ Mtu int // the device MTU
+ FirewallMark int32 // a firewall mark
+ RoutingTable string // the routing table
+
+ PreUp string // action that is executed before the device is up
+ PostUp string // action that is executed after the device is up
+ PreDown string // action that is executed before the device is down
+ PostDown string // action that is executed after the device is down
+
+ SaveConfig bool // automatically persist config changes to the wgX.conf file
+
+ // WG Portal specific
+ DisplayName string // a nice display name/ description for the interface
+ Type InterfaceType // the interface type, either InterfaceTypeServer or InterfaceTypeClient
+ DriverType string // the interface driver type (linux, software, ...)
+ Disabled *time.Time `gorm:"index"` // flag that specifies if the interface is enabled (up) or not (down)
+ DisabledReason string // the reason why the interface has been disabled
+
+ // Default settings for the peer, used for new peers, those settings will be published to ConfigOption options of
+ // the peer config
+
+ PeerDefNetworkStr string // the default subnets from which peers will get their IP addresses, comma seperated
+ PeerDefDnsStr string // the default dns server for the peer
+ PeerDefDnsSearchStr string // the default dns search options for the peer
+ PeerDefEndpoint string // the default endpoint for the peer
+ PeerDefAllowedIPsStr string // the default allowed IP string for the peer
+ PeerDefMtu int // the default device MTU
+ PeerDefPersistentKeepalive int // the default persistent keep-alive Value
+ PeerDefFirewallMark int32 // default firewall mark
+ PeerDefRoutingTable string // the default routing table
+
+ PeerDefPreUp string // default action that is executed before the device is up
+ PeerDefPostUp string // default action that is executed after the device is up
+ PeerDefPreDown string // default action that is executed before the device is down
+ PeerDefPostDown string // default action that is executed after the device is down
+}
+
+func (i *Interface) IsValid() bool {
+ return true // TODO: implement check
+}
+
+func (i *Interface) IsDisabled() bool {
+ if i == nil {
+ return true
+ }
+ return i.Disabled != nil
+}
+
+func (i *Interface) AddressStr() string {
+ return CidrsToString(i.Addresses)
+}
+
+func (i *Interface) CopyCalculatedAttributes(src *Interface) {
+ i.BaseModel = src.BaseModel
+}
+
+type PhysicalInterface struct {
+ Identifier InterfaceIdentifier // device name, for example: wg0
+ KeyPair // private/public Key of the server interface
+ ListenPort int // the listening port, for example: 51820
+
+ Addresses []Cidr // the interface ip addresses
+
+ Mtu int // the device MTU
+ FirewallMark int32 // a firewall mark
+
+ DeviceUp bool // device status
+
+ ImportSource string // import source (wgctrl, file, ...)
+ DeviceType string // device type (Linux kernel, userspace, ...)
+
+ BytesUpload uint64
+ BytesDownload uint64
+}
+
+func ConvertPhysicalInterface(pi *PhysicalInterface) *Interface {
+ iface := &Interface{
+ Identifier: pi.Identifier,
+ KeyPair: pi.KeyPair,
+ ListenPort: pi.ListenPort,
+ Addresses: pi.Addresses,
+ DnsStr: "",
+ DnsSearchStr: "",
+ Mtu: pi.Mtu,
+ FirewallMark: pi.FirewallMark,
+ RoutingTable: "",
+ PreUp: "",
+ PostUp: "",
+ PreDown: "",
+ PostDown: "",
+ SaveConfig: false,
+ DisplayName: string(pi.Identifier),
+ Type: InterfaceTypeAny,
+ DriverType: pi.DeviceType,
+ Disabled: nil,
+ PeerDefNetworkStr: "",
+ PeerDefDnsStr: "",
+ PeerDefDnsSearchStr: "",
+ PeerDefEndpoint: "",
+ PeerDefAllowedIPsStr: "",
+ PeerDefMtu: pi.Mtu,
+ PeerDefPersistentKeepalive: 0,
+ PeerDefFirewallMark: 0,
+ PeerDefRoutingTable: "",
+ PeerDefPreUp: "",
+ PeerDefPostUp: "",
+ PeerDefPreDown: "",
+ PeerDefPostDown: "",
+ }
+
+ return iface
+}
+
+func MergeToPhysicalInterface(pi *PhysicalInterface, i *Interface) {
+ pi.Identifier = i.Identifier
+ pi.PublicKey = i.PublicKey
+ pi.PrivateKey = i.PrivateKey
+ pi.ListenPort = i.ListenPort
+ pi.Mtu = i.Mtu
+ pi.FirewallMark = i.FirewallMark
+ pi.DeviceUp = !i.IsDisabled()
+ pi.Addresses = i.Addresses
+}
diff --git a/internal/domain/ip.go b/internal/domain/ip.go
new file mode 100644
index 0000000..47b664a
--- /dev/null
+++ b/internal/domain/ip.go
@@ -0,0 +1,170 @@
+package domain
+
+import (
+ "github.com/vishvananda/netlink"
+ "net"
+ "net/netip"
+ "strings"
+)
+
+type Cidr struct {
+ Cidr string `gorm:"primaryKey;column:cidr"` // Sqlite/GORM does not support composite primary keys...
+ Addr string `gorm:"column:addr"`
+ NetLength int `gorm:"column:net_len"`
+}
+
+func (c Cidr) Prefix() netip.Prefix {
+ return netip.PrefixFrom(netip.MustParseAddr(c.Addr), c.NetLength)
+}
+
+func (c Cidr) String() string {
+ return c.Prefix().String()
+}
+
+func CidrFromString(str string) (Cidr, error) {
+ prefix, err := netip.ParsePrefix(strings.TrimSpace(str))
+ if err != nil {
+ return Cidr{}, err
+ }
+ return CidrFromPrefix(prefix), nil
+}
+
+func CidrsFromString(str string) ([]Cidr, error) {
+ strParts := strings.Split(str, ",")
+ cidrs := make([]Cidr, len(strParts))
+
+ for i, cidrStr := range strParts {
+ cidr, err := CidrFromString(cidrStr)
+ if err != nil {
+ return nil, err
+ }
+ cidrs[i] = cidr
+ }
+
+ return cidrs, nil
+}
+
+func CidrsMust(cidrs []Cidr, err error) []Cidr {
+ if err != nil {
+ panic(err)
+ }
+
+ return cidrs
+}
+
+func CidrsFromArray(strs []string) ([]Cidr, error) {
+ cidrs := make([]Cidr, len(strs))
+
+ for i, cidrStr := range strs {
+ cidr, err := CidrFromString(cidrStr)
+ if err != nil {
+ return nil, err
+ }
+ cidrs[i] = cidr
+ }
+
+ return cidrs, nil
+}
+
+func CidrFromPrefix(prefix netip.Prefix) Cidr {
+ return Cidr{
+ Cidr: prefix.String(),
+ Addr: prefix.Addr().String(),
+ NetLength: prefix.Bits(),
+ }
+}
+
+func CidrFromIpNet(ipNet net.IPNet) Cidr {
+ prefix, _ := CidrFromString(ipNet.String())
+ return prefix
+}
+
+func CidrFromNetlinkAddr(addr netlink.Addr) Cidr {
+ prefix, _ := CidrFromString(addr.String())
+ return prefix
+}
+
+func (c Cidr) IpNet() *net.IPNet {
+ _, cidr, _ := net.ParseCIDR(c.String())
+ return cidr
+}
+
+func (c Cidr) NetlinkAddr() *netlink.Addr {
+ return &netlink.Addr{
+ IPNet: c.IpNet(),
+ }
+}
+
+func (c Cidr) IsV4() bool {
+ return c.Prefix().Addr().Is4()
+}
+
+// BroadcastAddr returns the last address in the given network (for IPv6), or the broadcast address.
+func (c Cidr) BroadcastAddr() Cidr {
+ prefix := c.Prefix()
+ if !prefix.IsValid() {
+ return Cidr{}
+ }
+ a16 := prefix.Addr().As16()
+ var off uint8
+ var bits uint8 = 128
+ if prefix.Addr().Is4() {
+ off = 12
+ bits = 32
+ }
+ for b := uint8(prefix.Bits()); b < bits; b++ {
+ byteNum, bitInByte := b/8, 7-(b%8)
+ a16[off+byteNum] |= 1 << uint(bitInByte)
+ }
+ if prefix.Addr().Is4() {
+ return Cidr{
+ Addr: netip.AddrFrom16(a16).Unmap().String(),
+ NetLength: prefix.Bits(),
+ }
+ } else {
+ return Cidr{
+ Addr: netip.AddrFrom16(a16).String(), // doesn't unmap
+ NetLength: prefix.Bits(),
+ }
+ }
+}
+
+// NetworkAddr returns the network address in the given prefix.
+func (c Cidr) NetworkAddr() Cidr {
+ prefix := c.Prefix()
+ if !prefix.IsValid() {
+ return Cidr{}
+ }
+
+ return CidrFromPrefix(prefix.Masked())
+}
+
+func (c Cidr) NextAddr() Cidr {
+ prefix := c.Prefix()
+ return Cidr{
+ Addr: prefix.Addr().Next().String(),
+ NetLength: prefix.Bits(),
+ }
+}
+
+func (c Cidr) NextSubnet() Cidr {
+ prefix := c.Prefix()
+ return Cidr{
+ Addr: c.BroadcastAddr().Prefix().Addr().Next().String(),
+ NetLength: prefix.Bits(),
+ }
+}
+
+func CidrsToString(slice []Cidr) string {
+ return strings.Join(CidrsToStringSlice(slice), ",")
+}
+
+func CidrsToStringSlice(slice []Cidr) []string {
+ cidrs := make([]string, len(slice))
+
+ for i, cidr := range slice {
+ cidrs[i] = cidr.String()
+ }
+
+ return cidrs
+}
diff --git a/internal/domain/ip_test.go b/internal/domain/ip_test.go
new file mode 100644
index 0000000..bf43bbb
--- /dev/null
+++ b/internal/domain/ip_test.go
@@ -0,0 +1,204 @@
+package domain
+
+import (
+ "net/netip"
+ "reflect"
+ "testing"
+)
+
+func TestCidrFromString(t *testing.T) {
+ type args struct {
+ str string
+ }
+ tests := []struct {
+ name string
+ args args
+ want Cidr
+ wantErr bool
+ }{
+ {
+ name: "IPv4",
+ args: args{str: "1.2.3.4/24"},
+ want: CidrFromPrefix(netip.MustParsePrefix("1.2.3.4/24")),
+ wantErr: false,
+ },
+ {
+ name: "IPv4 Network",
+ args: args{str: "1.2.3.0/24"},
+ want: CidrFromPrefix(netip.MustParsePrefix("1.2.3.0/24")),
+ wantErr: false,
+ },
+ {
+ name: "IPv4 error",
+ args: args{str: "1.1/24"},
+ want: Cidr{},
+ wantErr: true,
+ },
+ {
+ name: "IPv6 short",
+ args: args{str: "fe00:1234::1/64"},
+ want: CidrFromPrefix(netip.MustParsePrefix("fe00:1234::1/64")),
+ wantErr: false,
+ },
+ {
+ name: "IPv6",
+ args: args{str: "2A02:810A:900:333E:3B74:D237:E076:8B36/128"},
+ want: CidrFromPrefix(netip.MustParsePrefix("2A02:810A:900:333E:3B74:D237:E076:8B36/128")),
+ wantErr: false,
+ },
+ {
+ name: "IPv6 Network",
+ args: args{str: "fe00::/56"},
+ want: CidrFromPrefix(netip.MustParsePrefix("fe00::/56")),
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := CidrFromString(tt.args.str)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("CidrFromString() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("CidrFromString() got = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestCidr_BroadcastAddr(t *testing.T) {
+ type fields struct {
+ Prefix netip.Prefix
+ }
+ tests := []struct {
+ name string
+ fields fields
+ want Cidr
+ }{
+ {
+ name: "V4",
+ fields: fields{Prefix: netip.MustParsePrefix("1.2.3.4/24")},
+ want: CidrFromPrefix(netip.MustParsePrefix("1.2.3.255/24")),
+ },
+ {
+ name: "V6",
+ fields: fields{Prefix: netip.MustParsePrefix("fe00:d3ad:b33f:c0d3::/64")},
+ want: CidrFromPrefix(netip.MustParsePrefix("fe00:d3ad:b33f:c0d3:ffff:ffff:ffff:ffff/64")),
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := CidrFromPrefix(tt.fields.Prefix)
+ if got := c.BroadcastAddr(); !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("BroadcastAddr() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestCidr_NetworkAddr(t *testing.T) {
+ type fields struct {
+ Prefix netip.Prefix
+ }
+ tests := []struct {
+ name string
+ fields fields
+ want Cidr
+ }{
+
+ {
+ name: "V4",
+ fields: fields{Prefix: netip.MustParsePrefix("1.2.3.4/24")},
+ want: CidrFromPrefix(netip.MustParsePrefix("1.2.3.0/24")),
+ },
+ {
+ name: "V6",
+ fields: fields{Prefix: netip.MustParsePrefix("fe00:d3ad:b33f:c0d3::1234/64")},
+ want: CidrFromPrefix(netip.MustParsePrefix("fe00:d3ad:b33f:c0d3::/64")),
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := CidrFromPrefix(tt.fields.Prefix)
+ if got := c.NetworkAddr(); !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("NetworkAddr() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestCidr_NextAddr(t *testing.T) {
+ type fields struct {
+ Prefix netip.Prefix
+ }
+ tests := []struct {
+ name string
+ fields fields
+ want Cidr
+ }{
+ {
+ name: "V4 normal",
+ fields: fields{Prefix: netip.MustParsePrefix("1.2.3.4/24")},
+ want: CidrFromPrefix(netip.MustParsePrefix("1.2.3.5/24")),
+ },
+ {
+ name: "V4 broadcast",
+ fields: fields{Prefix: netip.MustParsePrefix("1.2.3.254/24")},
+ want: CidrFromPrefix(netip.MustParsePrefix("1.2.3.255/24")),
+ },
+ {
+ name: "V4 overflow",
+ fields: fields{Prefix: netip.MustParsePrefix("1.2.3.255/24")},
+ want: CidrFromPrefix(netip.MustParsePrefix("1.2.4.0/24")),
+ },
+ {
+ name: "V6 normal",
+ fields: fields{Prefix: netip.MustParsePrefix("fe00::1/64")},
+ want: CidrFromPrefix(netip.MustParsePrefix("fe00::2/64")),
+ },
+ {
+ name: "V6 overflow",
+ fields: fields{Prefix: netip.MustParsePrefix("fe00::ffff:ffff:ffff:ffff/64")},
+ want: CidrFromPrefix(netip.MustParsePrefix("fe00:0:0:1::/64")),
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := CidrFromPrefix(tt.fields.Prefix)
+ if got := c.NextAddr(); !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("NextAddr() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestCidr_NextSubnet(t *testing.T) {
+ type fields struct {
+ Prefix netip.Prefix
+ }
+ tests := []struct {
+ name string
+ fields fields
+ want Cidr
+ }{
+ {
+ name: "V4",
+ fields: fields{Prefix: netip.MustParsePrefix("1.2.3.4/24")},
+ want: CidrFromPrefix(netip.MustParsePrefix("1.2.4.0/24")),
+ },
+ {
+ name: "V4 bigger subnet",
+ fields: fields{Prefix: netip.MustParsePrefix("1.2.3.4/16")},
+ want: CidrFromPrefix(netip.MustParsePrefix("1.3.0.0/16")),
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := CidrFromPrefix(tt.fields.Prefix)
+ if got := c.NextSubnet(); !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("NextSubnet() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/domain/mail.go b/internal/domain/mail.go
new file mode 100644
index 0000000..32caaa5
--- /dev/null
+++ b/internal/domain/mail.go
@@ -0,0 +1,18 @@
+package domain
+
+import "io"
+
+type MailOptions struct {
+ ReplyTo string // defaults to the sender
+ HtmlBody string // if html body is empty, a text-only email will be sent
+ Cc []string
+ Bcc []string
+ Attachments []MailAttachment
+}
+
+type MailAttachment struct {
+ Name string
+ ContentType string
+ Data io.Reader
+ Embedded bool
+}
diff --git a/internal/domain/options.go b/internal/domain/options.go
new file mode 100644
index 0000000..b0fddf3
--- /dev/null
+++ b/internal/domain/options.go
@@ -0,0 +1,113 @@
+package domain
+
+type StringConfigOption struct {
+ Value string `gorm:"column:v"`
+ Overridable bool `gorm:"column:o"`
+}
+
+func (o StringConfigOption) GetValue() string {
+ return o.Value
+}
+
+func (o *StringConfigOption) SetValue(value string) {
+ o.Value = value
+}
+
+func (o *StringConfigOption) TrySetValue(value string) bool {
+ if o.Overridable {
+ o.Value = value
+ return true
+ }
+ return false
+}
+
+func NewStringConfigOption(value string, overridable bool) StringConfigOption {
+ return StringConfigOption{
+ Value: value,
+ Overridable: overridable,
+ }
+}
+
+type IntConfigOption struct {
+ Value int `gorm:"column:v"`
+ Overridable bool `gorm:"column:o"`
+}
+
+func (o IntConfigOption) GetValue() int {
+ return o.Value
+}
+
+func (o *IntConfigOption) SetValue(value int) {
+ o.Value = value
+}
+
+func (o *IntConfigOption) TrySetValue(value int) bool {
+ if o.Overridable {
+ o.Value = value
+ return true
+ }
+ return false
+}
+
+func NewIntConfigOption(value int, overridable bool) IntConfigOption {
+ return IntConfigOption{
+ Value: value,
+ Overridable: overridable,
+ }
+}
+
+type Int32ConfigOption struct {
+ Value int32 `gorm:"column:v"`
+ Overridable bool `gorm:"column:o"`
+}
+
+func (o Int32ConfigOption) GetValue() int32 {
+ return o.Value
+}
+
+func (o *Int32ConfigOption) SetValue(value int32) {
+ o.Value = value
+}
+
+func (o *Int32ConfigOption) TrySetValue(value int32) bool {
+ if o.Overridable {
+ o.Value = value
+ return true
+ }
+ return false
+}
+
+func NewInt32ConfigOption(value int32, overridable bool) Int32ConfigOption {
+ return Int32ConfigOption{
+ Value: value,
+ Overridable: overridable,
+ }
+}
+
+type BoolConfigOption struct {
+ Value bool `gorm:"column:v"`
+ Overridable bool `gorm:"column:o"`
+}
+
+func (o BoolConfigOption) GetValue() bool {
+ return o.Value
+}
+
+func (o *BoolConfigOption) SetValue(value bool) {
+ o.Value = value
+}
+
+func (o *BoolConfigOption) TrySetValue(value bool) bool {
+ if o.Overridable {
+ o.Value = value
+ return true
+ }
+ return false
+}
+
+func NewBoolConfigOption(value bool, overridable bool) BoolConfigOption {
+ return BoolConfigOption{
+ Value: value,
+ Overridable: overridable,
+ }
+}
diff --git a/internal/domain/peer.go b/internal/domain/peer.go
new file mode 100644
index 0000000..acdbb6d
--- /dev/null
+++ b/internal/domain/peer.go
@@ -0,0 +1,168 @@
+package domain
+
+import (
+ "net"
+ "time"
+
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+)
+
+type PeerIdentifier string
+
+func (i PeerIdentifier) IsPublicKey() bool {
+ _, err := wgtypes.ParseKey(string(i))
+ if err != nil {
+ return false
+ }
+ return true
+}
+
+func (i PeerIdentifier) ToPublicKey() wgtypes.Key {
+ publicKey, _ := wgtypes.ParseKey(string(i))
+ return publicKey
+}
+
+type Peer struct {
+ BaseModel
+
+ // WireGuard specific (for the [peer] section of the config file)
+
+ Endpoint StringConfigOption `gorm:"embedded;embeddedPrefix:endpoint_"` // the endpoint address
+ EndpointPublicKey string `gorm:"column:endpoint_pubkey"` // the endpoint public key
+ AllowedIPsStr StringConfigOption `gorm:"embedded;embeddedPrefix:allowed_ips_str_"` // all allowed ip subnets, comma seperated
+ ExtraAllowedIPsStr string // all allowed ip subnets on the server side, comma seperated
+ PresharedKey PreSharedKey // the pre-shared Key of the peer
+ PersistentKeepalive IntConfigOption `gorm:"embedded;embeddedPrefix:persistent_keep_alive_"` // the persistent keep-alive interval
+
+ // WG Portal specific
+
+ DisplayName string // a nice display name/ description for the peer
+ Identifier PeerIdentifier `gorm:"primaryKey;column:identifier"` // peer unique identifier
+ UserIdentifier UserIdentifier `gorm:"index;column:user_identifier"` // the owner
+ InterfaceIdentifier InterfaceIdentifier `gorm:"index;column:interface_identifier"` // the interface id
+ Temporary *time.Time `gorm:"-"` // is this a temporary peer (only prepared, but never saved to db)
+ Disabled *time.Time `gorm:"column:disabled"` // if this field is set, the peer is disabled
+ DisabledReason string // the reason why the peer has been disabled
+ ExpiresAt *time.Time `gorm:"column:expires_at"` // expiry dates for peers
+ Notes string `form:"notes" binding:"omitempty"` // a note field for peers
+
+ // Interface settings for the peer, used to generate the [interface] section in the peer config file
+ Interface PeerInterfaceConfig `gorm:"embedded"`
+}
+
+func (p Peer) IsDisabled() bool {
+ return p.Disabled != nil
+}
+
+type PeerInterfaceConfig struct {
+ KeyPair // private/public Key of the peer
+
+ Type InterfaceType `gorm:"column:iface_type"` // the interface type (server, client, any)
+
+ Addresses []Cidr `gorm:"many2many:peer_addresses;"` // the interface ip addresses
+ DnsStr StringConfigOption `gorm:"embedded;embeddedPrefix:iface_dns_str_"` // the dns server that should be set if the interface is up, comma separated
+ DnsSearchStr StringConfigOption `gorm:"embedded;embeddedPrefix:iface_dns_search_str_"` // the dns search option string that should be set if the interface is up, will be appended to DnsStr
+ Mtu IntConfigOption `gorm:"embedded;embeddedPrefix:iface_mtu_"` // the device MTU
+ FirewallMark Int32ConfigOption `gorm:"embedded;embeddedPrefix:iface_firewall_mark_"` // a firewall mark
+ RoutingTable StringConfigOption `gorm:"embedded;embeddedPrefix:iface_routing_table_"` // the routing table
+
+ PreUp StringConfigOption `gorm:"embedded;embeddedPrefix:iface_pre_up_"` // action that is executed before the device is up
+ PostUp StringConfigOption `gorm:"embedded;embeddedPrefix:iface_post_up_"` // action that is executed after the device is up
+ PreDown StringConfigOption `gorm:"embedded;embeddedPrefix:iface_pre_down_"` // action that is executed before the device is down
+ PostDown StringConfigOption `gorm:"embedded;embeddedPrefix:iface_post_down_"` // action that is executed after the device is down
+}
+
+func (p *PeerInterfaceConfig) AddressStr() string {
+ return CidrsToString(p.Addresses)
+}
+
+type PhysicalPeer struct {
+ Identifier PeerIdentifier // peer unique identifier
+
+ Endpoint string // the endpoint address
+ AllowedIPs []Cidr // all allowed ip subnets
+ KeyPair // private/public Key of the peer, for imports it only contains the public key as the private key is not known to the server
+ PresharedKey PreSharedKey // the pre-shared Key of the peer
+ PersistentKeepalive int // the persistent keep-alive interval
+
+ LastHandshake time.Time
+ ProtocolVersion int
+
+ BytesUpload uint64
+ BytesDownload uint64
+}
+
+func (p PhysicalPeer) GetPresharedKey() *wgtypes.Key {
+ if p.PrivateKey == "" {
+ return nil
+ }
+ key, err := wgtypes.ParseKey(p.PrivateKey)
+ if err != nil {
+ return nil
+ }
+
+ return &key
+}
+
+func (p PhysicalPeer) GetEndpointAddress() *net.UDPAddr {
+ if p.Endpoint == "" {
+ return nil
+ }
+ addr, err := net.ResolveUDPAddr("udp", p.Endpoint)
+ if err != nil {
+ return nil
+ }
+
+ return addr
+}
+
+func (p PhysicalPeer) GetPersistentKeepaliveTime() *time.Duration {
+ if p.PersistentKeepalive == 0 {
+ return nil
+ }
+
+ keepAliveDuration := time.Duration(p.PersistentKeepalive) * time.Second
+ return &keepAliveDuration
+}
+
+func (p PhysicalPeer) GetAllowedIPs() ([]net.IPNet, error) {
+ allowedIPs := make([]net.IPNet, len(p.AllowedIPs))
+ for i, ip := range p.AllowedIPs {
+ allowedIPs[i] = *ip.IpNet()
+ }
+
+ return allowedIPs, nil
+}
+
+func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer {
+ peer := &Peer{
+ Endpoint: StringConfigOption{Value: pp.Endpoint, Overridable: true},
+ EndpointPublicKey: "",
+ AllowedIPsStr: StringConfigOption{Value: "", Overridable: true},
+ ExtraAllowedIPsStr: "",
+ PresharedKey: pp.PresharedKey,
+ PersistentKeepalive: IntConfigOption{Value: pp.PersistentKeepalive, Overridable: true},
+ DisplayName: string(pp.Identifier),
+ Identifier: pp.Identifier,
+ UserIdentifier: "",
+ InterfaceIdentifier: "",
+ Temporary: nil,
+ Disabled: nil,
+ Interface: PeerInterfaceConfig{
+ KeyPair: pp.KeyPair,
+ },
+ }
+
+ return peer
+}
+
+func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) {
+ pp.Identifier = p.Identifier
+ pp.Endpoint = p.Endpoint.GetValue()
+ allowedIPs, _ := CidrsFromString(p.AllowedIPsStr.GetValue())
+ extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr)
+ pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...)
+ pp.PresharedKey = p.PresharedKey
+ pp.PublicKey = p.Interface.PublicKey
+ pp.PersistentKeepalive = p.PersistentKeepalive.GetValue()
+}
diff --git a/internal/domain/user.go b/internal/domain/user.go
new file mode 100644
index 0000000..fcf0bcc
--- /dev/null
+++ b/internal/domain/user.go
@@ -0,0 +1,105 @@
+package domain
+
+import (
+ "errors"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+const (
+ UserSourceLdap UserSource = "ldap" // LDAP / ActiveDirectory
+ UserSourceDatabase UserSource = "db" // sqlite / mysql database
+ UserSourceOauth UserSource = "oauth" // oauth / open id connect
+)
+
+type UserIdentifier string
+
+type UserSource string
+
+// User is the user model that gets linked to peer entries, by default an empty user model with only the email address is created
+type User struct {
+ BaseModel
+
+ // required fields
+ Identifier UserIdentifier `gorm:"primaryKey;column:identifier"`
+ Email string `form:"email" binding:"required,email"`
+ Source UserSource
+ ProviderName string
+ IsAdmin bool
+
+ // optional fields
+ Firstname string `form:"firstname" binding:"omitempty"`
+ Lastname string `form:"lastname" binding:"omitempty"`
+ Phone string `form:"phone" binding:"omitempty"`
+ Department string `form:"department" binding:"omitempty"`
+ Notes string `form:"notes" binding:"omitempty"`
+
+ // optional, integrated password authentication
+ Password PrivateString `form:"password" binding:"omitempty"`
+ Disabled *time.Time `gorm:"index;column:disabled"` // if this field is set, the user is disabled
+ DisabledReason string // the reason why the user has been disabled
+ Locked *time.Time `gorm:"index;column:locked"` // if this field is set, the user is locked and can no longer login
+ LockedReason string // the reason why the user has been locked
+
+ LinkedPeerCount int `gorm:"-"`
+}
+
+func (u *User) IsDisabled() bool {
+ return u.Disabled != nil
+}
+
+func (u *User) CanChangePassword() error {
+ if u.Source == UserSourceDatabase {
+ return nil
+ }
+
+ return errors.New("password change only allowed for database source")
+}
+
+func (u *User) EditAllowed() error {
+ if u.Source == UserSourceDatabase {
+ return nil
+ }
+
+ return errors.New("edit only allowed for database source")
+}
+
+func (u *User) CheckPassword(password string) error {
+ if u.Source != UserSourceDatabase {
+ return errors.New("invalid user source")
+ }
+
+ if u.Password == "" {
+ return errors.New("empty user password")
+ }
+
+ if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil {
+ return errors.New("wrong password")
+ }
+
+ return nil
+}
+
+func (u *User) HashPassword() error {
+ if u.Password == "" {
+ return nil // nothing to hash
+ }
+
+ if _, err := bcrypt.Cost([]byte(u.Password)); err == nil {
+ return nil // password already hashed
+ }
+
+ hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
+ if err != nil {
+ return err
+ }
+ u.Password = PrivateString(hash)
+
+ return nil
+}
+
+func (u *User) CopyCalculatedAttributes(src *User) {
+ u.BaseModel = src.BaseModel
+ u.LinkedPeerCount = src.LinkedPeerCount
+}
diff --git a/internal/ldap/config.go b/internal/ldap/config.go
deleted file mode 100644
index 8334a07..0000000
--- a/internal/ldap/config.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package ldap
-
-import (
- gldap "github.com/go-ldap/ldap/v3"
-)
-
-type Type string
-
-const (
- TypeActiveDirectory Type = "AD"
- TypeOpenLDAP Type = "OpenLDAP"
-)
-
-type Config struct {
- URL string `yaml:"url" envconfig:"LDAP_URL"`
- StartTLS bool `yaml:"startTLS" envconfig:"LDAP_STARTTLS"`
- CertValidation bool `yaml:"certcheck" envconfig:"LDAP_CERT_VALIDATION"`
- BaseDN string `yaml:"dn" envconfig:"LDAP_BASEDN"`
- BindUser string `yaml:"user" envconfig:"LDAP_USER"`
- BindPass string `yaml:"pass" envconfig:"LDAP_PASSWORD"`
-
- EmailAttribute string `yaml:"attrEmail" envconfig:"LDAP_ATTR_EMAIL"`
- FirstNameAttribute string `yaml:"attrFirstname" envconfig:"LDAP_ATTR_FIRSTNAME"`
- LastNameAttribute string `yaml:"attrLastname" envconfig:"LDAP_ATTR_LASTNAME"`
- PhoneAttribute string `yaml:"attrPhone" envconfig:"LDAP_ATTR_PHONE"`
- GroupMemberAttribute string `yaml:"attrGroups" envconfig:"LDAP_ATTR_GROUPS"`
-
- LoginFilter string `yaml:"loginFilter" envconfig:"LDAP_LOGIN_FILTER"` // {{login_identifier}} gets replaced with the login email address
- SyncFilter string `yaml:"syncFilter" envconfig:"LDAP_SYNC_FILTER"`
- SyncGroupFilter string `yaml:"syncGroupFilter" envconfig:"LDAP_SYNC_GROUP_FILTER"`
- AdminLdapGroup string `yaml:"adminGroup" envconfig:"LDAP_ADMIN_GROUP"` // Members of this group receive admin rights in WG-Portal
- AdminLdapGroup_ *gldap.DN `yaml:"-"`
- EveryoneAdmin bool `yaml:"everyoneAdmin" envconfig:"LDAP_EVERYONE_ADMIN"`
- LdapCertConn bool `yaml:"ldapCertConn" envconfig:"LDAP_CERT_CONN"`
- LdapTlsCert string `yaml:"ldapTlsCert" envconfig:"LDAPTLS_CERT"`
- LdapTlsKey string `yaml:"ldapTlsKey" envconfig:"LDAPTLS_KEY"`
-}
diff --git a/internal/ldap/ldap.go b/internal/ldap/ldap.go
deleted file mode 100644
index 78be238..0000000
--- a/internal/ldap/ldap.go
+++ /dev/null
@@ -1,137 +0,0 @@
-package ldap
-
-import (
- "crypto/tls"
- "os"
-
- "github.com/go-ldap/ldap/v3"
- "github.com/pkg/errors"
-)
-
-type ObjectType int
-
-const (
- Users ObjectType = iota
- Groups
-)
-
-type RawLdapData struct {
- DN string
- Attributes map[string]string
- RawAttributes map[string][][]byte
-}
-
-func Open(cfg *Config) (*ldap.Conn, error) {
- var tlsConfig *tls.Config
-
- if cfg.LdapCertConn {
-
- certPlain, err := os.ReadFile(cfg.LdapTlsCert)
- if err != nil {
- return nil, errors.WithMessage(err, "failed to load the certificate")
-
- }
-
- key, err := os.ReadFile(cfg.LdapTlsKey)
- if err != nil {
- return nil, errors.WithMessage(err, "failed to load the key")
- }
-
- certX509, err := tls.X509KeyPair(certPlain, key)
- if err != nil {
- return nil, errors.WithMessage(err, "failed X509")
-
- }
- tlsConfig = &tls.Config{Certificates: []tls.Certificate{certX509}}
-
- } else {
-
- tlsConfig = &tls.Config{InsecureSkipVerify: !cfg.CertValidation}
- }
-
- conn, err := ldap.DialURL(cfg.URL, ldap.DialWithTLSConfig(tlsConfig))
- if err != nil {
- return nil, errors.Wrap(err, "failed to connect to LDAP")
- }
-
- if cfg.StartTLS {
- // Reconnect with TLS
- err = conn.StartTLS(tlsConfig)
- if err != nil {
- return nil, errors.Wrap(err, "failed to star TLS on connection")
- }
- }
-
- err = conn.Bind(cfg.BindUser, cfg.BindPass)
- if err != nil {
- return nil, errors.Wrap(err, "failed to bind to LDAP")
- }
-
- return conn, nil
-}
-
-func Close(conn *ldap.Conn) {
- if conn != nil {
- conn.Close()
- }
-}
-
-func FindAllObjects(cfg *Config, objType ObjectType) ([]RawLdapData, error) {
- client, err := Open(cfg)
- if err != nil {
- return nil, errors.WithMessage(err, "failed to open ldap connection")
- }
- defer Close(client)
-
- var searchRequest *ldap.SearchRequest
- var attrs []string
-
- switch objType {
- case Users:
- // Search all users
- attrs = []string{"dn", cfg.EmailAttribute, cfg.EmailAttribute, cfg.FirstNameAttribute, cfg.LastNameAttribute,
- cfg.PhoneAttribute, cfg.GroupMemberAttribute}
- searchRequest = ldap.NewSearchRequest(
- cfg.BaseDN,
- ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
- cfg.SyncFilter, attrs, nil,
- )
- case Groups:
- if cfg.SyncGroupFilter == "" {
- return nil, nil // no groups
- }
- // Search all groups
- attrs = []string{"dn", cfg.GroupMemberAttribute}
- searchRequest = ldap.NewSearchRequest(
- cfg.BaseDN,
- ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
- cfg.SyncGroupFilter, attrs, nil,
- )
- default:
- panic("invalid object type")
- }
-
- sr, err := client.Search(searchRequest)
- if err != nil {
- return nil, errors.Wrapf(err, "failed to search in ldap")
- }
-
- tmpData := make([]RawLdapData, 0, len(sr.Entries))
-
- for _, entry := range sr.Entries {
- tmp := RawLdapData{
- DN: entry.DN,
- Attributes: make(map[string]string, len(attrs)),
- RawAttributes: make(map[string][][]byte, len(attrs)),
- }
-
- for _, field := range attrs {
- tmp.Attributes[field] = entry.GetAttributeValue(field)
- tmp.RawAttributes[field] = entry.GetRawAttributeValues(field)
- }
-
- tmpData = append(tmpData, tmp)
- }
-
- return tmpData, nil
-}
diff --git a/internal/lowlevel/doc.go b/internal/lowlevel/doc.go
new file mode 100644
index 0000000..057cf03
--- /dev/null
+++ b/internal/lowlevel/doc.go
@@ -0,0 +1,6 @@
+package lowlevel
+
+/**
+This package contains wrappers for low level api's like netlink or the WireGuard control library.
+Wrapping those external libraries makes mocking and testing code easier.
+*/
diff --git a/internal/lowlevel/mocks/NetlinkClient.go b/internal/lowlevel/mocks/NetlinkClient.go
new file mode 100644
index 0000000..2922725
--- /dev/null
+++ b/internal/lowlevel/mocks/NetlinkClient.go
@@ -0,0 +1,157 @@
+// Code generated by mockery v2.10.0. DO NOT EDIT.
+
+package mocks
+
+import (
+ mock "github.com/stretchr/testify/mock"
+ netlink "github.com/vishvananda/netlink"
+)
+
+// NetlinkClient is an autogenerated mock type for the NetlinkClient type
+type NetlinkClient struct {
+ mock.Mock
+}
+
+// AddrAdd provides a mock function with given fields: link, addr
+func (_m *NetlinkClient) AddrAdd(link netlink.Link, addr *netlink.Addr) error {
+ ret := _m.Called(link, addr)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(netlink.Link, *netlink.Addr) error); ok {
+ r0 = rf(link, addr)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// AddrList provides a mock function with given fields: link
+func (_m *NetlinkClient) AddrList(link netlink.Link) ([]netlink.Addr, error) {
+ ret := _m.Called(link)
+
+ var r0 []netlink.Addr
+ if rf, ok := ret.Get(0).(func(netlink.Link) []netlink.Addr); ok {
+ r0 = rf(link)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]netlink.Addr)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(netlink.Link) error); ok {
+ r1 = rf(link)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// AddrReplace provides a mock function with given fields: link, addr
+func (_m *NetlinkClient) AddrReplace(link netlink.Link, addr *netlink.Addr) error {
+ ret := _m.Called(link, addr)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(netlink.Link, *netlink.Addr) error); ok {
+ r0 = rf(link, addr)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// LinkAdd provides a mock function with given fields: link
+func (_m *NetlinkClient) LinkAdd(link netlink.Link) error {
+ ret := _m.Called(link)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(netlink.Link) error); ok {
+ r0 = rf(link)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// LinkByName provides a mock function with given fields: name
+func (_m *NetlinkClient) LinkByName(name string) (netlink.Link, error) {
+ ret := _m.Called(name)
+
+ var r0 netlink.Link
+ if rf, ok := ret.Get(0).(func(string) netlink.Link); ok {
+ r0 = rf(name)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(netlink.Link)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(string) error); ok {
+ r1 = rf(name)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// LinkDel provides a mock function with given fields: link
+func (_m *NetlinkClient) LinkDel(link netlink.Link) error {
+ ret := _m.Called(link)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(netlink.Link) error); ok {
+ r0 = rf(link)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// LinkSetDown provides a mock function with given fields: link
+func (_m *NetlinkClient) LinkSetDown(link netlink.Link) error {
+ ret := _m.Called(link)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(netlink.Link) error); ok {
+ r0 = rf(link)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// LinkSetMTU provides a mock function with given fields: link, mtu
+func (_m *NetlinkClient) LinkSetMTU(link netlink.Link, mtu int) error {
+ ret := _m.Called(link, mtu)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(netlink.Link, int) error); ok {
+ r0 = rf(link, mtu)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// LinkSetUp provides a mock function with given fields: link
+func (_m *NetlinkClient) LinkSetUp(link netlink.Link) error {
+ ret := _m.Called(link)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(netlink.Link) error); ok {
+ r0 = rf(link)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
diff --git a/internal/lowlevel/mocks/WireGuardClient.go b/internal/lowlevel/mocks/WireGuardClient.go
new file mode 100644
index 0000000..e566b3c
--- /dev/null
+++ b/internal/lowlevel/mocks/WireGuardClient.go
@@ -0,0 +1,87 @@
+// Code generated by mockery v2.10.0. DO NOT EDIT.
+
+package mocks
+
+import (
+ mock "github.com/stretchr/testify/mock"
+ wgtypes "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+)
+
+// WireGuardClient is an autogenerated mock type for the WireGuardClient type
+type WireGuardClient struct {
+ mock.Mock
+}
+
+// Close provides a mock function with given fields:
+func (_m *WireGuardClient) Close() error {
+ ret := _m.Called()
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func() error); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// ConfigureDevice provides a mock function with given fields: name, cfg
+func (_m *WireGuardClient) ConfigureDevice(name string, cfg wgtypes.Config) error {
+ ret := _m.Called(name, cfg)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string, wgtypes.Config) error); ok {
+ r0 = rf(name, cfg)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// Device provides a mock function with given fields: name
+func (_m *WireGuardClient) Device(name string) (*wgtypes.Device, error) {
+ ret := _m.Called(name)
+
+ var r0 *wgtypes.Device
+ if rf, ok := ret.Get(0).(func(string) *wgtypes.Device); ok {
+ r0 = rf(name)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*wgtypes.Device)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(string) error); ok {
+ r1 = rf(name)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// Devices provides a mock function with given fields:
+func (_m *WireGuardClient) Devices() ([]*wgtypes.Device, error) {
+ ret := _m.Called()
+
+ var r0 []*wgtypes.Device
+ if rf, ok := ret.Get(0).(func() []*wgtypes.Device); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]*wgtypes.Device)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
diff --git a/internal/lowlevel/netlink.go b/internal/lowlevel/netlink.go
new file mode 100644
index 0000000..b86d13e
--- /dev/null
+++ b/internal/lowlevel/netlink.go
@@ -0,0 +1,63 @@
+package lowlevel
+
+import (
+ "github.com/vishvananda/netlink"
+)
+
+// A NetlinkClient is a type which can control a netlink device.
+type NetlinkClient interface {
+ LinkAdd(link netlink.Link) error
+ LinkDel(link netlink.Link) error
+ LinkByName(name string) (netlink.Link, error)
+ LinkSetUp(link netlink.Link) error
+ LinkSetDown(link netlink.Link) error
+ LinkSetMTU(link netlink.Link, mtu int) error
+ AddrReplace(link netlink.Link, addr *netlink.Addr) error
+ AddrAdd(link netlink.Link, addr *netlink.Addr) error
+ AddrList(link netlink.Link) ([]netlink.Addr, error)
+}
+
+type NetlinkManager struct {
+}
+
+func (n NetlinkManager) LinkAdd(link netlink.Link) error { return netlink.LinkAdd(link) }
+
+func (n NetlinkManager) LinkDel(link netlink.Link) error { return netlink.LinkDel(link) }
+
+func (n NetlinkManager) LinkByName(name string) (netlink.Link, error) {
+ return netlink.LinkByName(name)
+}
+
+func (n NetlinkManager) LinkSetUp(link netlink.Link) error { return netlink.LinkSetUp(link) }
+
+func (n NetlinkManager) LinkSetDown(link netlink.Link) error { return netlink.LinkSetDown(link) }
+
+func (n NetlinkManager) LinkSetMTU(link netlink.Link, mtu int) error {
+ return netlink.LinkSetMTU(link, mtu)
+}
+
+func (n NetlinkManager) AddrReplace(link netlink.Link, addr *netlink.Addr) error {
+ return netlink.AddrReplace(link, addr)
+}
+
+func (n NetlinkManager) AddrAdd(link netlink.Link, addr *netlink.Addr) error {
+ return netlink.AddrAdd(link, addr)
+}
+
+func (n NetlinkManager) AddrList(link netlink.Link) ([]netlink.Addr, error) {
+ listIPv4, err := netlink.AddrList(link, netlink.FAMILY_V4)
+ if err != nil {
+ return nil, err
+ }
+
+ listIPv6, err := netlink.AddrList(link, netlink.FAMILY_V6)
+ if err != nil {
+ return nil, err
+ }
+
+ ipAddresses := make([]netlink.Addr, 0, len(listIPv4)+len(listIPv6))
+ ipAddresses = append(ipAddresses, listIPv4...)
+ ipAddresses = append(ipAddresses, listIPv6...)
+
+ return ipAddresses, nil
+}
diff --git a/internal/lowlevel/wgctrl.go b/internal/lowlevel/wgctrl.go
new file mode 100644
index 0000000..ab6832c
--- /dev/null
+++ b/internal/lowlevel/wgctrl.go
@@ -0,0 +1,15 @@
+package lowlevel
+
+import (
+ "io"
+
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+)
+
+// A WireGuardClient is a type which can control a WireGuard device.
+type WireGuardClient interface {
+ io.Closer
+ Devices() ([]*wgtypes.Device, error)
+ Device(name string) (*wgtypes.Device, error)
+ ConfigureDevice(name string, cfg wgtypes.Config) error
+}
diff --git a/internal/ports/api/build_tool/main.go b/internal/ports/api/build_tool/main.go
new file mode 100644
index 0000000..d695e30
--- /dev/null
+++ b/internal/ports/api/build_tool/main.go
@@ -0,0 +1,70 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/sirupsen/logrus"
+ "github.com/swaggo/swag"
+ "github.com/swaggo/swag/gen"
+)
+
+// this replaces the call to: swag init --propertyStrategy pascalcase --parseDependency --parseInternal --generalInfo base.go
+func main() {
+ wd, err := os.Getwd() // should be the project root
+ if err != nil {
+ panic(err)
+ }
+
+ apiBasePath := filepath.Join(wd, "/internal/ports/api")
+ apis := []string{"v0"}
+
+ hasError := false
+ for _, apiVersion := range apis {
+ apiPath := filepath.Join(apiBasePath, apiVersion, "handlers")
+
+ apiVersion = strings.TrimLeft(apiVersion, "api-")
+ log.Println("")
+ log.Println("Generate swagger docs for API", apiVersion)
+ log.Println("Api path:", apiPath)
+
+ err := generateApi(apiBasePath, apiPath, apiVersion)
+ if err != nil {
+ hasError = true
+ logrus.Errorf("failed to generate API docs for %s: %v", apiVersion, err)
+ }
+
+ log.Println("Generated swagger docs for API", apiVersion)
+ }
+
+ if hasError {
+ os.Exit(1)
+ }
+}
+
+func generateApi(basePath, apiPath, version string) error {
+ err := gen.New().Build(&gen.Config{
+ SearchDir: apiPath,
+ Excludes: "",
+ MainAPIFile: "base.go",
+ PropNamingStrategy: swag.PascalCase,
+ OutputDir: filepath.Join(basePath, "core/assets/doc"),
+ OutputTypes: []string{"json", "yaml"},
+ ParseVendor: false,
+ ParseDependency: true,
+ MarkdownFilesDir: "",
+ ParseInternal: true,
+ GeneratedTime: false,
+ CodeExampleFilesDir: "",
+ ParseDepth: 3,
+ InstanceName: version,
+ })
+ if err != nil {
+ return fmt.Errorf("swag failed: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/ports/api/core/assets.go b/internal/ports/api/core/assets.go
new file mode 100644
index 0000000..67be5be
--- /dev/null
+++ b/internal/ports/api/core/assets.go
@@ -0,0 +1,21 @@
+package core
+
+import "embed"
+
+//go:embed assets/tpl/*
+var apiTemplates embed.FS
+
+//go:embed assets/css/*
+//go:embed assets/fonts/*
+//go:embed assets/img/*
+//go:embed assets/js/*
+//go:embed assets/doc/*
+var apiStatics embed.FS
+
+//go:embed frontend-dist/assets/*
+//go:embed frontend-dist/img/*
+//go:embed frontend-dist/index.html
+//go:embed frontend-dist/favicon.ico
+//go:embed frontend-dist/favicon.png
+//go:embed frontend-dist/favicon-large.png
+var frontendStatics embed.FS
diff --git a/assets/css/bootstrap.min.css b/internal/ports/api/core/assets/css/bootstrap.min.css
similarity index 100%
rename from assets/css/bootstrap.min.css
rename to internal/ports/api/core/assets/css/bootstrap.min.css
diff --git a/internal/ports/api/core/assets/doc/v0_swagger.json b/internal/ports/api/core/assets/doc/v0_swagger.json
new file mode 100644
index 0000000..f0572e4
--- /dev/null
+++ b/internal/ports/api/core/assets/doc/v0_swagger.json
@@ -0,0 +1,764 @@
+{
+ "swagger": "2.0",
+ "info": {
+ "description": "WireGuard Portal API - a testing API endpoint",
+ "title": "WireGuard Portal API",
+ "contact": {
+ "name": "WireGuard Portal Developers",
+ "url": "https://github.com/h44z/wg-portal"
+ },
+ "version": "0.0"
+ },
+ "basePath": "/api/v0",
+ "paths": {
+ "/auth/login": {
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Get all available external login providers.",
+ "operationId": "auth_handleLoginPost",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.LoginProviderInfo"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/auth/logout": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Get all available external login providers.",
+ "operationId": "auth_handleLogoutGet",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.LoginProviderInfo"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/auth/providers": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Get all available external login providers.",
+ "operationId": "auth_handleExternalLoginProvidersGet",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.LoginProviderInfo"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/auth/session": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Get information about the currently logged-in user.",
+ "operationId": "auth_handleSessionInfoGet",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.SessionInfo"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/model.Error"
+ }
+ }
+ }
+ }
+ },
+ "/auth/{provider}/callback": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Handle the OAuth callback.",
+ "operationId": "auth_handleOauthCallbackGet",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.LoginProviderInfo"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/auth/{provider}/init": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Initiate the OAuth login flow.",
+ "operationId": "auth_handleOauthInitiateGet",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.LoginProviderInfo"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/config/frontend.js": {
+ "get": {
+ "produces": [
+ "text/javascript"
+ ],
+ "tags": [
+ "Configuration"
+ ],
+ "summary": "Get the dynamic frontend configuration javascript.",
+ "operationId": "config_handleConfigJsGet",
+ "responses": {
+ "200": {
+ "description": "The JavaScript contents",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "/csrf": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Security"
+ ],
+ "summary": "Get a CSRF token for the current session.",
+ "operationId": "base_handleCsrfGet",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "/hostname": {
+ "get": {
+ "description": "Nothing more to describe...",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Testing"
+ ],
+ "summary": "Get the current host name.",
+ "operationId": "test_handleHostnameGet",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "string"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/model.Error"
+ }
+ }
+ }
+ }
+ },
+ "/interface/all": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Interface"
+ ],
+ "summary": "Get all available interfaces.",
+ "operationId": "interfaces_handleAllGet",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.Interface"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/model.Error"
+ }
+ }
+ }
+ }
+ },
+ "/interface/get/{id}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Interface"
+ ],
+ "summary": "Get single interface.",
+ "operationId": "interfaces_handleSingleGet",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/model.Interface"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/model.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/model.Error"
+ }
+ }
+ }
+ }
+ },
+ "/interface/peers/{id}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Interface"
+ ],
+ "summary": "Get peers for the given interface.",
+ "operationId": "interfaces_handlePeersGet",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.Peer"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/model.Error"
+ }
+ }
+ }
+ }
+ },
+ "/now": {
+ "get": {
+ "description": "Nothing more to describe...",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Testing"
+ ],
+ "summary": "Get the current local time.",
+ "operationId": "test_handleCurrentTimeGet",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "string"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/model.Error"
+ }
+ }
+ }
+ }
+ },
+ "/peer/all/{id}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Peer"
+ ],
+ "summary": "Get peers for the given interface.",
+ "operationId": "peers_handlePeersGet",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.Peer"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/model.Error"
+ }
+ }
+ }
+ }
+ },
+ "/users": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Get all user records.",
+ "operationId": "users_handleAllGet",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.User"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/model.Error"
+ }
+ }
+ }
+ }
+ }
+ },
+ "definitions": {
+ "model.Error": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "integer"
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ },
+ "model.Interface": {
+ "type": "object",
+ "properties": {
+ "Addresses": {
+ "description": "the interface ip addresses",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "Disabled": {
+ "description": "flag that specifies if the interface is enabled (up) or not (down)",
+ "type": "boolean"
+ },
+ "DisabledReason": {
+ "description": "the reason why the interface has been disabled",
+ "type": "string"
+ },
+ "DisplayName": {
+ "description": "a nice display name/ description for the interface",
+ "type": "string"
+ },
+ "Dns": {
+ "description": "the dns server that should be set if the interface is up, comma separated",
+ "type": "string"
+ },
+ "DnsSearch": {
+ "description": "the dns search option string that should be set if the interface is up, will be appended to DnsStr",
+ "type": "string"
+ },
+ "EnabledPeers": {
+ "type": "integer"
+ },
+ "FirewallMark": {
+ "description": "a firewall mark",
+ "type": "integer"
+ },
+ "Identifier": {
+ "description": "device name, for example: wg0",
+ "type": "string",
+ "example": "wg0"
+ },
+ "ListenPort": {
+ "description": "the listening port, for example: 51820",
+ "type": "integer"
+ },
+ "Mode": {
+ "description": "the interface type, either 'server', 'client' or 'any'",
+ "type": "string",
+ "example": "server"
+ },
+ "Mtu": {
+ "description": "the device MTU",
+ "type": "integer"
+ },
+ "PeerDefAllowedIPs": {
+ "description": "the default allowed IP string for the peer",
+ "type": "string"
+ },
+ "PeerDefDns": {
+ "description": "the default dns server for the peer",
+ "type": "string"
+ },
+ "PeerDefDnsSearch": {
+ "description": "the default dns search options for the peer",
+ "type": "string"
+ },
+ "PeerDefEndpoint": {
+ "description": "the default endpoint for the peer",
+ "type": "string"
+ },
+ "PeerDefFirewallMark": {
+ "description": "default firewall mark",
+ "type": "integer"
+ },
+ "PeerDefMtu": {
+ "description": "the default device MTU",
+ "type": "integer"
+ },
+ "PeerDefNetwork": {
+ "description": "the default subnets from which peers will get their IP addresses, comma seperated",
+ "type": "string"
+ },
+ "PeerDefPersistentKeepalive": {
+ "description": "the default persistent keep-alive Value",
+ "type": "integer"
+ },
+ "PeerDefPostDown": {
+ "description": "default action that is executed after the device is down",
+ "type": "string"
+ },
+ "PeerDefPostUp": {
+ "description": "default action that is executed after the device is up",
+ "type": "string"
+ },
+ "PeerDefPreDown": {
+ "description": "default action that is executed before the device is down",
+ "type": "string"
+ },
+ "PeerDefPreUp": {
+ "description": "default action that is executed before the device is up",
+ "type": "string"
+ },
+ "PeerDefRoutingTable": {
+ "description": "the default routing table",
+ "type": "string"
+ },
+ "PostDown": {
+ "description": "action that is executed after the device is down",
+ "type": "string"
+ },
+ "PostUp": {
+ "description": "action that is executed after the device is up",
+ "type": "string"
+ },
+ "PreDown": {
+ "description": "action that is executed before the device is down",
+ "type": "string"
+ },
+ "PreUp": {
+ "description": "action that is executed before the device is up",
+ "type": "string"
+ },
+ "PrivateKey": {
+ "description": "private Key of the server interface",
+ "type": "string",
+ "example": "abcdef=="
+ },
+ "PublicKey": {
+ "description": "public Key of the server interface",
+ "type": "string",
+ "example": "abcdef=="
+ },
+ "RoutingTable": {
+ "description": "the routing table",
+ "type": "string"
+ },
+ "SaveConfig": {
+ "description": "automatically persist config changes to the wgX.conf file",
+ "type": "boolean"
+ },
+ "TotalPeers": {
+ "type": "integer"
+ }
+ }
+ },
+ "model.LoginProviderInfo": {
+ "type": "object",
+ "properties": {
+ "CallbackUrl": {
+ "type": "string",
+ "example": "/auth/google/callback"
+ },
+ "Identifier": {
+ "type": "string",
+ "example": "google"
+ },
+ "Name": {
+ "type": "string",
+ "example": "Login with Google"
+ },
+ "ProviderUrl": {
+ "type": "string",
+ "example": "/auth/google/login"
+ }
+ }
+ },
+ "model.Peer": {
+ "type": "object",
+ "properties": {
+ "Addresses": {
+ "description": "the interface ip addresses",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "AllowedIPs": {
+ "description": "all allowed ip subnets, comma seperated",
+ "type": "string"
+ },
+ "Disabled": {
+ "description": "flag that specifies if the peer is enabled (up) or not (down)",
+ "type": "boolean"
+ },
+ "DisabledReason": {
+ "description": "the reason why the peer has been disabled",
+ "type": "string"
+ },
+ "DisplayName": {
+ "description": "a nice display name/ description for the peer",
+ "type": "string"
+ },
+ "Dns": {
+ "description": "the dns server that should be set if the interface is up, comma separated",
+ "type": "string"
+ },
+ "DnsSearch": {
+ "description": "the dns search option string that should be set if the interface is up, will be appended to DnsStr",
+ "type": "string"
+ },
+ "Endpoint": {
+ "description": "the endpoint address",
+ "type": "string"
+ },
+ "EndpointPublicKey": {
+ "description": "the endpoint public key",
+ "type": "string"
+ },
+ "ExtraAllowedIPs": {
+ "description": "all allowed ip subnets on the server side, comma seperated",
+ "type": "string"
+ },
+ "FirewallMark": {
+ "description": "a firewall mark",
+ "type": "integer"
+ },
+ "Identifier": {
+ "description": "peer unique identifier",
+ "type": "string",
+ "example": "super_nice_peer"
+ },
+ "InterfaceIdentifier": {
+ "description": "the interface id",
+ "type": "string"
+ },
+ "Mode": {
+ "description": "the peer interface type (server, client, any)",
+ "type": "string"
+ },
+ "Mtu": {
+ "description": "the device MTU",
+ "type": "integer"
+ },
+ "PersistentKeepalive": {
+ "description": "the persistent keep-alive interval",
+ "type": "integer"
+ },
+ "PostDown": {
+ "description": "action that is executed after the device is down",
+ "type": "string"
+ },
+ "PostUp": {
+ "description": "action that is executed after the device is up",
+ "type": "string"
+ },
+ "PreDown": {
+ "description": "action that is executed before the device is down",
+ "type": "string"
+ },
+ "PreUp": {
+ "description": "action that is executed before the device is up",
+ "type": "string"
+ },
+ "PresharedKey": {
+ "description": "the pre-shared Key of the peer",
+ "type": "string"
+ },
+ "PrivateKey": {
+ "description": "private Key of the server peer",
+ "type": "string",
+ "example": "abcdef=="
+ },
+ "PublicKey": {
+ "description": "public Key of the server peer",
+ "type": "string",
+ "example": "abcdef=="
+ },
+ "RoutingTable": {
+ "description": "the routing table",
+ "type": "string"
+ },
+ "UserIdentifier": {
+ "description": "the owner",
+ "type": "string"
+ }
+ }
+ },
+ "model.SessionInfo": {
+ "type": "object",
+ "properties": {
+ "IsAdmin": {
+ "type": "boolean"
+ },
+ "LoggedIn": {
+ "type": "boolean"
+ },
+ "UserEmail": {
+ "type": "string"
+ },
+ "UserFirstname": {
+ "type": "string"
+ },
+ "UserIdentifier": {
+ "type": "string"
+ },
+ "UserLastname": {
+ "type": "string"
+ }
+ }
+ },
+ "model.User": {
+ "type": "object",
+ "properties": {
+ "Department": {
+ "type": "string"
+ },
+ "Disabled": {
+ "description": "if this field is set, the user is disabled",
+ "type": "boolean"
+ },
+ "DisabledReason": {
+ "description": "the reason why the user has been disabled",
+ "type": "string"
+ },
+ "Email": {
+ "type": "string"
+ },
+ "Firstname": {
+ "type": "string"
+ },
+ "Identifier": {
+ "type": "string"
+ },
+ "IsAdmin": {
+ "type": "boolean"
+ },
+ "Lastname": {
+ "type": "string"
+ },
+ "Notes": {
+ "type": "string"
+ },
+ "Password": {
+ "type": "string"
+ },
+ "PeerCount": {
+ "type": "integer"
+ },
+ "Phone": {
+ "type": "string"
+ },
+ "ProviderName": {
+ "type": "string"
+ },
+ "Source": {
+ "type": "string"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/internal/ports/api/core/assets/doc/v0_swagger.yaml b/internal/ports/api/core/assets/doc/v0_swagger.yaml
new file mode 100644
index 0000000..287279b
--- /dev/null
+++ b/internal/ports/api/core/assets/doc/v0_swagger.yaml
@@ -0,0 +1,531 @@
+basePath: /api/v0
+definitions:
+ model.Error:
+ properties:
+ code:
+ type: integer
+ message:
+ type: string
+ type: object
+ model.Interface:
+ properties:
+ Addresses:
+ description: the interface ip addresses
+ items:
+ type: string
+ type: array
+ Disabled:
+ description: flag that specifies if the interface is enabled (up) or not (down)
+ type: boolean
+ DisabledReason:
+ description: the reason why the interface has been disabled
+ type: string
+ DisplayName:
+ description: a nice display name/ description for the interface
+ type: string
+ Dns:
+ description: the dns server that should be set if the interface is up, comma
+ separated
+ type: string
+ DnsSearch:
+ description: the dns search option string that should be set if the interface
+ is up, will be appended to DnsStr
+ type: string
+ EnabledPeers:
+ type: integer
+ FirewallMark:
+ description: a firewall mark
+ type: integer
+ Identifier:
+ description: 'device name, for example: wg0'
+ example: wg0
+ type: string
+ ListenPort:
+ description: 'the listening port, for example: 51820'
+ type: integer
+ Mode:
+ description: the interface type, either 'server', 'client' or 'any'
+ example: server
+ type: string
+ Mtu:
+ description: the device MTU
+ type: integer
+ PeerDefAllowedIPs:
+ description: the default allowed IP string for the peer
+ type: string
+ PeerDefDns:
+ description: the default dns server for the peer
+ type: string
+ PeerDefDnsSearch:
+ description: the default dns search options for the peer
+ type: string
+ PeerDefEndpoint:
+ description: the default endpoint for the peer
+ type: string
+ PeerDefFirewallMark:
+ description: default firewall mark
+ type: integer
+ PeerDefMtu:
+ description: the default device MTU
+ type: integer
+ PeerDefNetwork:
+ description: the default subnets from which peers will get their IP addresses,
+ comma seperated
+ type: string
+ PeerDefPersistentKeepalive:
+ description: the default persistent keep-alive Value
+ type: integer
+ PeerDefPostDown:
+ description: default action that is executed after the device is down
+ type: string
+ PeerDefPostUp:
+ description: default action that is executed after the device is up
+ type: string
+ PeerDefPreDown:
+ description: default action that is executed before the device is down
+ type: string
+ PeerDefPreUp:
+ description: default action that is executed before the device is up
+ type: string
+ PeerDefRoutingTable:
+ description: the default routing table
+ type: string
+ PostDown:
+ description: action that is executed after the device is down
+ type: string
+ PostUp:
+ description: action that is executed after the device is up
+ type: string
+ PreDown:
+ description: action that is executed before the device is down
+ type: string
+ PreUp:
+ description: action that is executed before the device is up
+ type: string
+ PrivateKey:
+ description: private Key of the server interface
+ example: abcdef==
+ type: string
+ PublicKey:
+ description: public Key of the server interface
+ example: abcdef==
+ type: string
+ RoutingTable:
+ description: the routing table
+ type: string
+ SaveConfig:
+ description: automatically persist config changes to the wgX.conf file
+ type: boolean
+ TotalPeers:
+ type: integer
+ type: object
+ model.LoginProviderInfo:
+ properties:
+ CallbackUrl:
+ example: /auth/google/callback
+ type: string
+ Identifier:
+ example: google
+ type: string
+ Name:
+ example: Login with Google
+ type: string
+ ProviderUrl:
+ example: /auth/google/login
+ type: string
+ type: object
+ model.Peer:
+ properties:
+ Addresses:
+ description: the interface ip addresses
+ items:
+ type: string
+ type: array
+ AllowedIPs:
+ description: all allowed ip subnets, comma seperated
+ type: string
+ Disabled:
+ description: flag that specifies if the peer is enabled (up) or not (down)
+ type: boolean
+ DisabledReason:
+ description: the reason why the peer has been disabled
+ type: string
+ DisplayName:
+ description: a nice display name/ description for the peer
+ type: string
+ Dns:
+ description: the dns server that should be set if the interface is up, comma
+ separated
+ type: string
+ DnsSearch:
+ description: the dns search option string that should be set if the interface
+ is up, will be appended to DnsStr
+ type: string
+ Endpoint:
+ description: the endpoint address
+ type: string
+ EndpointPublicKey:
+ description: the endpoint public key
+ type: string
+ ExtraAllowedIPs:
+ description: all allowed ip subnets on the server side, comma seperated
+ type: string
+ FirewallMark:
+ description: a firewall mark
+ type: integer
+ Identifier:
+ description: peer unique identifier
+ example: super_nice_peer
+ type: string
+ InterfaceIdentifier:
+ description: the interface id
+ type: string
+ Mode:
+ description: the peer interface type (server, client, any)
+ type: string
+ Mtu:
+ description: the device MTU
+ type: integer
+ PersistentKeepalive:
+ description: the persistent keep-alive interval
+ type: integer
+ PostDown:
+ description: action that is executed after the device is down
+ type: string
+ PostUp:
+ description: action that is executed after the device is up
+ type: string
+ PreDown:
+ description: action that is executed before the device is down
+ type: string
+ PreUp:
+ description: action that is executed before the device is up
+ type: string
+ PresharedKey:
+ description: the pre-shared Key of the peer
+ type: string
+ PrivateKey:
+ description: private Key of the server peer
+ example: abcdef==
+ type: string
+ PublicKey:
+ description: public Key of the server peer
+ example: abcdef==
+ type: string
+ RoutingTable:
+ description: the routing table
+ type: string
+ UserIdentifier:
+ description: the owner
+ type: string
+ type: object
+ model.SessionInfo:
+ properties:
+ IsAdmin:
+ type: boolean
+ LoggedIn:
+ type: boolean
+ UserEmail:
+ type: string
+ UserFirstname:
+ type: string
+ UserIdentifier:
+ type: string
+ UserLastname:
+ type: string
+ type: object
+ model.User:
+ properties:
+ Department:
+ type: string
+ Disabled:
+ description: if this field is set, the user is disabled
+ type: boolean
+ DisabledReason:
+ description: the reason why the user has been disabled
+ type: string
+ Email:
+ type: string
+ Firstname:
+ type: string
+ Identifier:
+ type: string
+ IsAdmin:
+ type: boolean
+ Lastname:
+ type: string
+ Notes:
+ type: string
+ Password:
+ type: string
+ PeerCount:
+ type: integer
+ Phone:
+ type: string
+ ProviderName:
+ type: string
+ Source:
+ type: string
+ type: object
+info:
+ contact:
+ name: WireGuard Portal Developers
+ url: https://github.com/h44z/wg-portal
+ description: WireGuard Portal API - a testing API endpoint
+ title: WireGuard Portal API
+ version: "0.0"
+paths:
+ /auth/{provider}/callback:
+ get:
+ operationId: auth_handleOauthCallbackGet
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/model.LoginProviderInfo'
+ type: array
+ summary: Handle the OAuth callback.
+ tags:
+ - Authentication
+ /auth/{provider}/init:
+ get:
+ operationId: auth_handleOauthInitiateGet
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/model.LoginProviderInfo'
+ type: array
+ summary: Initiate the OAuth login flow.
+ tags:
+ - Authentication
+ /auth/login:
+ post:
+ operationId: auth_handleLoginPost
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/model.LoginProviderInfo'
+ type: array
+ summary: Get all available external login providers.
+ tags:
+ - Authentication
+ /auth/logout:
+ get:
+ operationId: auth_handleLogoutGet
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/model.LoginProviderInfo'
+ type: array
+ summary: Get all available external login providers.
+ tags:
+ - Authentication
+ /auth/providers:
+ get:
+ operationId: auth_handleExternalLoginProvidersGet
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/model.LoginProviderInfo'
+ type: array
+ summary: Get all available external login providers.
+ tags:
+ - Authentication
+ /auth/session:
+ get:
+ operationId: auth_handleSessionInfoGet
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/model.SessionInfo'
+ type: array
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/model.Error'
+ summary: Get information about the currently logged-in user.
+ tags:
+ - Authentication
+ /config/frontend.js:
+ get:
+ operationId: config_handleConfigJsGet
+ produces:
+ - text/javascript
+ responses:
+ "200":
+ description: The JavaScript contents
+ schema:
+ type: string
+ summary: Get the dynamic frontend configuration javascript.
+ tags:
+ - Configuration
+ /csrf:
+ get:
+ operationId: base_handleCsrfGet
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: string
+ summary: Get a CSRF token for the current session.
+ tags:
+ - Security
+ /hostname:
+ get:
+ description: Nothing more to describe...
+ operationId: test_handleHostnameGet
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: string
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/model.Error'
+ summary: Get the current host name.
+ tags:
+ - Testing
+ /interface/all:
+ get:
+ operationId: interfaces_handleAllGet
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/model.Interface'
+ type: array
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/model.Error'
+ summary: Get all available interfaces.
+ tags:
+ - Interface
+ /interface/get/{id}:
+ get:
+ operationId: interfaces_handleSingleGet
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/model.Interface'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/model.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/model.Error'
+ summary: Get single interface.
+ tags:
+ - Interface
+ /interface/peers/{id}:
+ get:
+ operationId: interfaces_handlePeersGet
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/model.Peer'
+ type: array
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/model.Error'
+ summary: Get peers for the given interface.
+ tags:
+ - Interface
+ /now:
+ get:
+ description: Nothing more to describe...
+ operationId: test_handleCurrentTimeGet
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: string
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/model.Error'
+ summary: Get the current local time.
+ tags:
+ - Testing
+ /peer/all/{id}:
+ get:
+ operationId: peers_handlePeersGet
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/model.Peer'
+ type: array
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/model.Error'
+ summary: Get peers for the given interface.
+ tags:
+ - Peer
+ /users:
+ get:
+ operationId: users_handleAllGet
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/model.User'
+ type: array
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/model.Error'
+ summary: Get all user records.
+ tags:
+ - Users
+swagger: "2.0"
diff --git a/assets/fonts/FontAwesome.otf b/internal/ports/api/core/assets/fonts/FontAwesome.otf
similarity index 100%
rename from assets/fonts/FontAwesome.otf
rename to internal/ports/api/core/assets/fonts/FontAwesome.otf
diff --git a/assets/fonts/fa-brands-400.eot b/internal/ports/api/core/assets/fonts/fa-brands-400.eot
similarity index 100%
rename from assets/fonts/fa-brands-400.eot
rename to internal/ports/api/core/assets/fonts/fa-brands-400.eot
diff --git a/assets/fonts/fa-brands-400.svg b/internal/ports/api/core/assets/fonts/fa-brands-400.svg
similarity index 100%
rename from assets/fonts/fa-brands-400.svg
rename to internal/ports/api/core/assets/fonts/fa-brands-400.svg
diff --git a/assets/fonts/fa-brands-400.ttf b/internal/ports/api/core/assets/fonts/fa-brands-400.ttf
similarity index 100%
rename from assets/fonts/fa-brands-400.ttf
rename to internal/ports/api/core/assets/fonts/fa-brands-400.ttf
diff --git a/assets/fonts/fa-brands-400.woff b/internal/ports/api/core/assets/fonts/fa-brands-400.woff
similarity index 100%
rename from assets/fonts/fa-brands-400.woff
rename to internal/ports/api/core/assets/fonts/fa-brands-400.woff
diff --git a/assets/fonts/fa-brands-400.woff2 b/internal/ports/api/core/assets/fonts/fa-brands-400.woff2
similarity index 100%
rename from assets/fonts/fa-brands-400.woff2
rename to internal/ports/api/core/assets/fonts/fa-brands-400.woff2
diff --git a/assets/fonts/fa-regular-400.eot b/internal/ports/api/core/assets/fonts/fa-regular-400.eot
similarity index 100%
rename from assets/fonts/fa-regular-400.eot
rename to internal/ports/api/core/assets/fonts/fa-regular-400.eot
diff --git a/assets/fonts/fa-regular-400.svg b/internal/ports/api/core/assets/fonts/fa-regular-400.svg
similarity index 100%
rename from assets/fonts/fa-regular-400.svg
rename to internal/ports/api/core/assets/fonts/fa-regular-400.svg
diff --git a/assets/fonts/fa-regular-400.ttf b/internal/ports/api/core/assets/fonts/fa-regular-400.ttf
similarity index 100%
rename from assets/fonts/fa-regular-400.ttf
rename to internal/ports/api/core/assets/fonts/fa-regular-400.ttf
diff --git a/assets/fonts/fa-regular-400.woff b/internal/ports/api/core/assets/fonts/fa-regular-400.woff
similarity index 100%
rename from assets/fonts/fa-regular-400.woff
rename to internal/ports/api/core/assets/fonts/fa-regular-400.woff
diff --git a/assets/fonts/fa-regular-400.woff2 b/internal/ports/api/core/assets/fonts/fa-regular-400.woff2
similarity index 100%
rename from assets/fonts/fa-regular-400.woff2
rename to internal/ports/api/core/assets/fonts/fa-regular-400.woff2
diff --git a/assets/fonts/fa-solid-900.eot b/internal/ports/api/core/assets/fonts/fa-solid-900.eot
similarity index 100%
rename from assets/fonts/fa-solid-900.eot
rename to internal/ports/api/core/assets/fonts/fa-solid-900.eot
diff --git a/assets/fonts/fa-solid-900.svg b/internal/ports/api/core/assets/fonts/fa-solid-900.svg
similarity index 100%
rename from assets/fonts/fa-solid-900.svg
rename to internal/ports/api/core/assets/fonts/fa-solid-900.svg
diff --git a/assets/fonts/fa-solid-900.ttf b/internal/ports/api/core/assets/fonts/fa-solid-900.ttf
similarity index 100%
rename from assets/fonts/fa-solid-900.ttf
rename to internal/ports/api/core/assets/fonts/fa-solid-900.ttf
diff --git a/assets/fonts/fa-solid-900.woff b/internal/ports/api/core/assets/fonts/fa-solid-900.woff
similarity index 100%
rename from assets/fonts/fa-solid-900.woff
rename to internal/ports/api/core/assets/fonts/fa-solid-900.woff
diff --git a/assets/fonts/fa-solid-900.woff2 b/internal/ports/api/core/assets/fonts/fa-solid-900.woff2
similarity index 100%
rename from assets/fonts/fa-solid-900.woff2
rename to internal/ports/api/core/assets/fonts/fa-solid-900.woff2
diff --git a/internal/ports/api/core/assets/fonts/font-awesome.min.css b/internal/ports/api/core/assets/fonts/font-awesome.min.css
new file mode 100644
index 0000000..09de3b7
--- /dev/null
+++ b/internal/ports/api/core/assets/fonts/font-awesome.min.css
@@ -0,0 +1,4 @@
+/*!
+ * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome
+ * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
+ */@font-face{font-family:'FontAwesome';src:url('fontawesome-webfont.eot?v=4.7.0');src:url('fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'),url('fontawesome-webfont.woff2?v=4.7.0') format('woff2'),url('fontawesome-webfont.woff?v=4.7.0') format('woff'),url('fontawesome-webfont.ttf?v=4.7.0') format('truetype'),url('fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale} .fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%} .fa-2x{font-size:2em} .fa-3x{font-size:3em} .fa-4x{font-size:4em} .fa-5x{font-size:5em} .fa-fw{width:1.28571429em;text-align:center} .fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none} .fa-ul>li{position:relative} .fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center} .fa-li.fa-lg{left:-1.85714286em} .fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em} .fa-pull-left{float:left} .fa-pull-right{float:right} .fa.fa-pull-left{margin-right:.3em} .fa.fa-pull-right{margin-left:.3em} .pull-right{float:right} .pull-left{float:left} .fa.pull-left{margin-right:.3em} .fa.pull-right{margin-left:.3em} .fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear} .fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)} @-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)} 100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}} @keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)} 100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}} .fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)} .fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)} .fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)} .fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)} .fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)} :root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none} .fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle} .fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center} .fa-stack-1x{line-height:inherit} .fa-stack-2x{font-size:2em} .fa-inverse{color:#fff} .fa-glass:before{content:"\f000"} .fa-music:before{content:"\f001"} .fa-search:before{content:"\f002"} .fa-envelope-o:before{content:"\f003"} .fa-heart:before{content:"\f004"} .fa-star:before{content:"\f005"} .fa-star-o:before{content:"\f006"} .fa-user:before{content:"\f007"} .fa-film:before{content:"\f008"} .fa-th-large:before{content:"\f009"} .fa-th:before{content:"\f00a"} .fa-th-list:before{content:"\f00b"} .fa-check:before{content:"\f00c"} .fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"} .fa-search-plus:before{content:"\f00e"} .fa-search-minus:before{content:"\f010"} .fa-power-off:before{content:"\f011"} .fa-signal:before{content:"\f012"} .fa-gear:before,.fa-cog:before{content:"\f013"} .fa-trash-o:before{content:"\f014"} .fa-home:before{content:"\f015"} .fa-file-o:before{content:"\f016"} .fa-clock-o:before{content:"\f017"} .fa-road:before{content:"\f018"} .fa-download:before{content:"\f019"} .fa-arrow-circle-o-down:before{content:"\f01a"} .fa-arrow-circle-o-up:before{content:"\f01b"} .fa-inbox:before{content:"\f01c"} .fa-play-circle-o:before{content:"\f01d"} .fa-rotate-right:before,.fa-repeat:before{content:"\f01e"} .fa-refresh:before{content:"\f021"} .fa-list-alt:before{content:"\f022"} .fa-lock:before{content:"\f023"} .fa-flag:before{content:"\f024"} .fa-headphones:before{content:"\f025"} .fa-volume-off:before{content:"\f026"} .fa-volume-down:before{content:"\f027"} .fa-volume-up:before{content:"\f028"} .fa-qrcode:before{content:"\f029"} .fa-barcode:before{content:"\f02a"} .fa-tag:before{content:"\f02b"} .fa-tags:before{content:"\f02c"} .fa-book:before{content:"\f02d"} .fa-bookmark:before{content:"\f02e"} .fa-print:before{content:"\f02f"} .fa-camera:before{content:"\f030"} .fa-font:before{content:"\f031"} .fa-bold:before{content:"\f032"} .fa-italic:before{content:"\f033"} .fa-text-height:before{content:"\f034"} .fa-text-width:before{content:"\f035"} .fa-align-left:before{content:"\f036"} .fa-align-center:before{content:"\f037"} .fa-align-right:before{content:"\f038"} .fa-align-justify:before{content:"\f039"} .fa-list:before{content:"\f03a"} .fa-dedent:before,.fa-outdent:before{content:"\f03b"} .fa-indent:before{content:"\f03c"} .fa-video-camera:before{content:"\f03d"} .fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"} .fa-pencil:before{content:"\f040"} .fa-map-marker:before{content:"\f041"} .fa-adjust:before{content:"\f042"} .fa-tint:before{content:"\f043"} .fa-edit:before,.fa-pencil-square-o:before{content:"\f044"} .fa-share-square-o:before{content:"\f045"} .fa-check-square-o:before{content:"\f046"} .fa-arrows:before{content:"\f047"} .fa-step-backward:before{content:"\f048"} .fa-fast-backward:before{content:"\f049"} .fa-backward:before{content:"\f04a"} .fa-play:before{content:"\f04b"} .fa-pause:before{content:"\f04c"} .fa-stop:before{content:"\f04d"} .fa-forward:before{content:"\f04e"} .fa-fast-forward:before{content:"\f050"} .fa-step-forward:before{content:"\f051"} .fa-eject:before{content:"\f052"} .fa-chevron-left:before{content:"\f053"} .fa-chevron-right:before{content:"\f054"} .fa-plus-circle:before{content:"\f055"} .fa-minus-circle:before{content:"\f056"} .fa-times-circle:before{content:"\f057"} .fa-check-circle:before{content:"\f058"} .fa-question-circle:before{content:"\f059"} .fa-info-circle:before{content:"\f05a"} .fa-crosshairs:before{content:"\f05b"} .fa-times-circle-o:before{content:"\f05c"} .fa-check-circle-o:before{content:"\f05d"} .fa-ban:before{content:"\f05e"} .fa-arrow-left:before{content:"\f060"} .fa-arrow-right:before{content:"\f061"} .fa-arrow-up:before{content:"\f062"} .fa-arrow-down:before{content:"\f063"} .fa-mail-forward:before,.fa-share:before{content:"\f064"} .fa-expand:before{content:"\f065"} .fa-compress:before{content:"\f066"} .fa-plus:before{content:"\f067"} .fa-minus:before{content:"\f068"} .fa-asterisk:before{content:"\f069"} .fa-exclamation-circle:before{content:"\f06a"} .fa-gift:before{content:"\f06b"} .fa-leaf:before{content:"\f06c"} .fa-fire:before{content:"\f06d"} .fa-eye:before{content:"\f06e"} .fa-eye-slash:before{content:"\f070"} .fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"} .fa-plane:before{content:"\f072"} .fa-calendar:before{content:"\f073"} .fa-random:before{content:"\f074"} .fa-comment:before{content:"\f075"} .fa-magnet:before{content:"\f076"} .fa-chevron-up:before{content:"\f077"} .fa-chevron-down:before{content:"\f078"} .fa-retweet:before{content:"\f079"} .fa-shopping-cart:before{content:"\f07a"} .fa-folder:before{content:"\f07b"} .fa-folder-open:before{content:"\f07c"} .fa-arrows-v:before{content:"\f07d"} .fa-arrows-h:before{content:"\f07e"} .fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"} .fa-twitter-square:before{content:"\f081"} .fa-facebook-square:before{content:"\f082"} .fa-camera-retro:before{content:"\f083"} .fa-key:before{content:"\f084"} .fa-gears:before,.fa-cogs:before{content:"\f085"} .fa-comments:before{content:"\f086"} .fa-thumbs-o-up:before{content:"\f087"} .fa-thumbs-o-down:before{content:"\f088"} .fa-star-half:before{content:"\f089"} .fa-heart-o:before{content:"\f08a"} .fa-sign-out:before{content:"\f08b"} .fa-linkedin-square:before{content:"\f08c"} .fa-thumb-tack:before{content:"\f08d"} .fa-external-link:before{content:"\f08e"} .fa-sign-in:before{content:"\f090"} .fa-trophy:before{content:"\f091"} .fa-github-square:before{content:"\f092"} .fa-upload:before{content:"\f093"} .fa-lemon-o:before{content:"\f094"} .fa-phone:before{content:"\f095"} .fa-square-o:before{content:"\f096"} .fa-bookmark-o:before{content:"\f097"} .fa-phone-square:before{content:"\f098"} .fa-twitter:before{content:"\f099"} .fa-facebook-f:before,.fa-facebook:before{content:"\f09a"} .fa-github:before{content:"\f09b"} .fa-unlock:before{content:"\f09c"} .fa-credit-card:before{content:"\f09d"} .fa-feed:before,.fa-rss:before{content:"\f09e"} .fa-hdd-o:before{content:"\f0a0"} .fa-bullhorn:before{content:"\f0a1"} .fa-bell:before{content:"\f0f3"} .fa-certificate:before{content:"\f0a3"} .fa-hand-o-right:before{content:"\f0a4"} .fa-hand-o-left:before{content:"\f0a5"} .fa-hand-o-up:before{content:"\f0a6"} .fa-hand-o-down:before{content:"\f0a7"} .fa-arrow-circle-left:before{content:"\f0a8"} .fa-arrow-circle-right:before{content:"\f0a9"} .fa-arrow-circle-up:before{content:"\f0aa"} .fa-arrow-circle-down:before{content:"\f0ab"} .fa-globe:before{content:"\f0ac"} .fa-wrench:before{content:"\f0ad"} .fa-tasks:before{content:"\f0ae"} .fa-filter:before{content:"\f0b0"} .fa-briefcase:before{content:"\f0b1"} .fa-arrows-alt:before{content:"\f0b2"} .fa-group:before,.fa-users:before{content:"\f0c0"} .fa-chain:before,.fa-link:before{content:"\f0c1"} .fa-cloud:before{content:"\f0c2"} .fa-flask:before{content:"\f0c3"} .fa-cut:before,.fa-scissors:before{content:"\f0c4"} .fa-copy:before,.fa-files-o:before{content:"\f0c5"} .fa-paperclip:before{content:"\f0c6"} .fa-save:before,.fa-floppy-o:before{content:"\f0c7"} .fa-square:before{content:"\f0c8"} .fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"} .fa-list-ul:before{content:"\f0ca"} .fa-list-ol:before{content:"\f0cb"} .fa-strikethrough:before{content:"\f0cc"} .fa-underline:before{content:"\f0cd"} .fa-table:before{content:"\f0ce"} .fa-magic:before{content:"\f0d0"} .fa-truck:before{content:"\f0d1"} .fa-pinterest:before{content:"\f0d2"} .fa-pinterest-square:before{content:"\f0d3"} .fa-google-plus-square:before{content:"\f0d4"} .fa-google-plus:before{content:"\f0d5"} .fa-money:before{content:"\f0d6"} .fa-caret-down:before{content:"\f0d7"} .fa-caret-up:before{content:"\f0d8"} .fa-caret-left:before{content:"\f0d9"} .fa-caret-right:before{content:"\f0da"} .fa-columns:before{content:"\f0db"} .fa-unsorted:before,.fa-sort:before{content:"\f0dc"} .fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"} .fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"} .fa-envelope:before{content:"\f0e0"} .fa-linkedin:before{content:"\f0e1"} .fa-rotate-left:before,.fa-undo:before{content:"\f0e2"} .fa-legal:before,.fa-gavel:before{content:"\f0e3"} .fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"} .fa-comment-o:before{content:"\f0e5"} .fa-comments-o:before{content:"\f0e6"} .fa-flash:before,.fa-bolt:before{content:"\f0e7"} .fa-sitemap:before{content:"\f0e8"} .fa-umbrella:before{content:"\f0e9"} .fa-paste:before,.fa-clipboard:before{content:"\f0ea"} .fa-lightbulb-o:before{content:"\f0eb"} .fa-exchange:before{content:"\f0ec"} .fa-cloud-download:before{content:"\f0ed"} .fa-cloud-upload:before{content:"\f0ee"} .fa-user-md:before{content:"\f0f0"} .fa-stethoscope:before{content:"\f0f1"} .fa-suitcase:before{content:"\f0f2"} .fa-bell-o:before{content:"\f0a2"} .fa-coffee:before{content:"\f0f4"} .fa-cutlery:before{content:"\f0f5"} .fa-file-text-o:before{content:"\f0f6"} .fa-building-o:before{content:"\f0f7"} .fa-hospital-o:before{content:"\f0f8"} .fa-ambulance:before{content:"\f0f9"} .fa-medkit:before{content:"\f0fa"} .fa-fighter-jet:before{content:"\f0fb"} .fa-beer:before{content:"\f0fc"} .fa-h-square:before{content:"\f0fd"} .fa-plus-square:before{content:"\f0fe"} .fa-angle-double-left:before{content:"\f100"} .fa-angle-double-right:before{content:"\f101"} .fa-angle-double-up:before{content:"\f102"} .fa-angle-double-down:before{content:"\f103"} .fa-angle-left:before{content:"\f104"} .fa-angle-right:before{content:"\f105"} .fa-angle-up:before{content:"\f106"} .fa-angle-down:before{content:"\f107"} .fa-desktop:before{content:"\f108"} .fa-laptop:before{content:"\f109"} .fa-tablet:before{content:"\f10a"} .fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"} .fa-circle-o:before{content:"\f10c"} .fa-quote-left:before{content:"\f10d"} .fa-quote-right:before{content:"\f10e"} .fa-spinner:before{content:"\f110"} .fa-circle:before{content:"\f111"} .fa-mail-reply:before,.fa-reply:before{content:"\f112"} .fa-github-alt:before{content:"\f113"} .fa-folder-o:before{content:"\f114"} .fa-folder-open-o:before{content:"\f115"} .fa-smile-o:before{content:"\f118"} .fa-frown-o:before{content:"\f119"} .fa-meh-o:before{content:"\f11a"} .fa-gamepad:before{content:"\f11b"} .fa-keyboard-o:before{content:"\f11c"} .fa-flag-o:before{content:"\f11d"} .fa-flag-checkered:before{content:"\f11e"} .fa-terminal:before{content:"\f120"} .fa-code:before{content:"\f121"} .fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"} .fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"} .fa-location-arrow:before{content:"\f124"} .fa-crop:before{content:"\f125"} .fa-code-fork:before{content:"\f126"} .fa-unlink:before,.fa-chain-broken:before{content:"\f127"} .fa-question:before{content:"\f128"} .fa-info:before{content:"\f129"} .fa-exclamation:before{content:"\f12a"} .fa-superscript:before{content:"\f12b"} .fa-subscript:before{content:"\f12c"} .fa-eraser:before{content:"\f12d"} .fa-puzzle-piece:before{content:"\f12e"} .fa-microphone:before{content:"\f130"} .fa-microphone-slash:before{content:"\f131"} .fa-shield:before{content:"\f132"} .fa-calendar-o:before{content:"\f133"} .fa-fire-extinguisher:before{content:"\f134"} .fa-rocket:before{content:"\f135"} .fa-maxcdn:before{content:"\f136"} .fa-chevron-circle-left:before{content:"\f137"} .fa-chevron-circle-right:before{content:"\f138"} .fa-chevron-circle-up:before{content:"\f139"} .fa-chevron-circle-down:before{content:"\f13a"} .fa-html5:before{content:"\f13b"} .fa-css3:before{content:"\f13c"} .fa-anchor:before{content:"\f13d"} .fa-unlock-alt:before{content:"\f13e"} .fa-bullseye:before{content:"\f140"} .fa-ellipsis-h:before{content:"\f141"} .fa-ellipsis-v:before{content:"\f142"} .fa-rss-square:before{content:"\f143"} .fa-play-circle:before{content:"\f144"} .fa-ticket:before{content:"\f145"} .fa-minus-square:before{content:"\f146"} .fa-minus-square-o:before{content:"\f147"} .fa-level-up:before{content:"\f148"} .fa-level-down:before{content:"\f149"} .fa-check-square:before{content:"\f14a"} .fa-pencil-square:before{content:"\f14b"} .fa-external-link-square:before{content:"\f14c"} .fa-share-square:before{content:"\f14d"} .fa-compass:before{content:"\f14e"} .fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"} .fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"} .fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"} .fa-euro:before,.fa-eur:before{content:"\f153"} .fa-gbp:before{content:"\f154"} .fa-dollar:before,.fa-usd:before{content:"\f155"} .fa-rupee:before,.fa-inr:before{content:"\f156"} .fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"} .fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"} .fa-won:before,.fa-krw:before{content:"\f159"} .fa-bitcoin:before,.fa-btc:before{content:"\f15a"} .fa-file:before{content:"\f15b"} .fa-file-text:before{content:"\f15c"} .fa-sort-alpha-asc:before{content:"\f15d"} .fa-sort-alpha-desc:before{content:"\f15e"} .fa-sort-amount-asc:before{content:"\f160"} .fa-sort-amount-desc:before{content:"\f161"} .fa-sort-numeric-asc:before{content:"\f162"} .fa-sort-numeric-desc:before{content:"\f163"} .fa-thumbs-up:before{content:"\f164"} .fa-thumbs-down:before{content:"\f165"} .fa-youtube-square:before{content:"\f166"} .fa-youtube:before{content:"\f167"} .fa-xing:before{content:"\f168"} .fa-xing-square:before{content:"\f169"} .fa-youtube-play:before{content:"\f16a"} .fa-dropbox:before{content:"\f16b"} .fa-stack-overflow:before{content:"\f16c"} .fa-instagram:before{content:"\f16d"} .fa-flickr:before{content:"\f16e"} .fa-adn:before{content:"\f170"} .fa-bitbucket:before{content:"\f171"} .fa-bitbucket-square:before{content:"\f172"} .fa-tumblr:before{content:"\f173"} .fa-tumblr-square:before{content:"\f174"} .fa-long-arrow-down:before{content:"\f175"} .fa-long-arrow-up:before{content:"\f176"} .fa-long-arrow-left:before{content:"\f177"} .fa-long-arrow-right:before{content:"\f178"} .fa-apple:before{content:"\f179"} .fa-windows:before{content:"\f17a"} .fa-android:before{content:"\f17b"} .fa-linux:before{content:"\f17c"} .fa-dribbble:before{content:"\f17d"} .fa-skype:before{content:"\f17e"} .fa-foursquare:before{content:"\f180"} .fa-trello:before{content:"\f181"} .fa-female:before{content:"\f182"} .fa-male:before{content:"\f183"} .fa-gittip:before,.fa-gratipay:before{content:"\f184"} .fa-sun-o:before{content:"\f185"} .fa-moon-o:before{content:"\f186"} .fa-archive:before{content:"\f187"} .fa-bug:before{content:"\f188"} .fa-vk:before{content:"\f189"} .fa-weibo:before{content:"\f18a"} .fa-renren:before{content:"\f18b"} .fa-pagelines:before{content:"\f18c"} .fa-stack-exchange:before{content:"\f18d"} .fa-arrow-circle-o-right:before{content:"\f18e"} .fa-arrow-circle-o-left:before{content:"\f190"} .fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"} .fa-dot-circle-o:before{content:"\f192"} .fa-wheelchair:before{content:"\f193"} .fa-vimeo-square:before{content:"\f194"} .fa-turkish-lira:before,.fa-try:before{content:"\f195"} .fa-plus-square-o:before{content:"\f196"} .fa-space-shuttle:before{content:"\f197"} .fa-slack:before{content:"\f198"} .fa-envelope-square:before{content:"\f199"} .fa-wordpress:before{content:"\f19a"} .fa-openid:before{content:"\f19b"} .fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"} .fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"} .fa-yahoo:before{content:"\f19e"} .fa-google:before{content:"\f1a0"} .fa-reddit:before{content:"\f1a1"} .fa-reddit-square:before{content:"\f1a2"} .fa-stumbleupon-circle:before{content:"\f1a3"} .fa-stumbleupon:before{content:"\f1a4"} .fa-delicious:before{content:"\f1a5"} .fa-digg:before{content:"\f1a6"} .fa-pied-piper-pp:before{content:"\f1a7"} .fa-pied-piper-alt:before{content:"\f1a8"} .fa-drupal:before{content:"\f1a9"} .fa-joomla:before{content:"\f1aa"} .fa-language:before{content:"\f1ab"} .fa-fax:before{content:"\f1ac"} .fa-building:before{content:"\f1ad"} .fa-child:before{content:"\f1ae"} .fa-paw:before{content:"\f1b0"} .fa-spoon:before{content:"\f1b1"} .fa-cube:before{content:"\f1b2"} .fa-cubes:before{content:"\f1b3"} .fa-behance:before{content:"\f1b4"} .fa-behance-square:before{content:"\f1b5"} .fa-steam:before{content:"\f1b6"} .fa-steam-square:before{content:"\f1b7"} .fa-recycle:before{content:"\f1b8"} .fa-automobile:before,.fa-car:before{content:"\f1b9"} .fa-cab:before,.fa-taxi:before{content:"\f1ba"} .fa-tree:before{content:"\f1bb"} .fa-spotify:before{content:"\f1bc"} .fa-deviantart:before{content:"\f1bd"} .fa-soundcloud:before{content:"\f1be"} .fa-database:before{content:"\f1c0"} .fa-file-pdf-o:before{content:"\f1c1"} .fa-file-word-o:before{content:"\f1c2"} .fa-file-excel-o:before{content:"\f1c3"} .fa-file-powerpoint-o:before{content:"\f1c4"} .fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"} .fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"} .fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"} .fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"} .fa-file-code-o:before{content:"\f1c9"} .fa-vine:before{content:"\f1ca"} .fa-codepen:before{content:"\f1cb"} .fa-jsfiddle:before{content:"\f1cc"} .fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"} .fa-circle-o-notch:before{content:"\f1ce"} .fa-ra:before,.fa-resistance:before,.fa-rebel:before{content:"\f1d0"} .fa-ge:before,.fa-empire:before{content:"\f1d1"} .fa-git-square:before{content:"\f1d2"} .fa-git:before{content:"\f1d3"} .fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"} .fa-tencent-weibo:before{content:"\f1d5"} .fa-qq:before{content:"\f1d6"} .fa-wechat:before,.fa-weixin:before{content:"\f1d7"} .fa-send:before,.fa-paper-plane:before{content:"\f1d8"} .fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"} .fa-history:before{content:"\f1da"} .fa-circle-thin:before{content:"\f1db"} .fa-header:before{content:"\f1dc"} .fa-paragraph:before{content:"\f1dd"} .fa-sliders:before{content:"\f1de"} .fa-share-alt:before{content:"\f1e0"} .fa-share-alt-square:before{content:"\f1e1"} .fa-bomb:before{content:"\f1e2"} .fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"} .fa-tty:before{content:"\f1e4"} .fa-binoculars:before{content:"\f1e5"} .fa-plug:before{content:"\f1e6"} .fa-slideshare:before{content:"\f1e7"} .fa-twitch:before{content:"\f1e8"} .fa-yelp:before{content:"\f1e9"} .fa-newspaper-o:before{content:"\f1ea"} .fa-wifi:before{content:"\f1eb"} .fa-calculator:before{content:"\f1ec"} .fa-paypal:before{content:"\f1ed"} .fa-google-wallet:before{content:"\f1ee"} .fa-cc-visa:before{content:"\f1f0"} .fa-cc-mastercard:before{content:"\f1f1"} .fa-cc-discover:before{content:"\f1f2"} .fa-cc-amex:before{content:"\f1f3"} .fa-cc-paypal:before{content:"\f1f4"} .fa-cc-stripe:before{content:"\f1f5"} .fa-bell-slash:before{content:"\f1f6"} .fa-bell-slash-o:before{content:"\f1f7"} .fa-trash:before{content:"\f1f8"} .fa-copyright:before{content:"\f1f9"} .fa-at:before{content:"\f1fa"} .fa-eyedropper:before{content:"\f1fb"} .fa-paint-brush:before{content:"\f1fc"} .fa-birthday-cake:before{content:"\f1fd"} .fa-area-chart:before{content:"\f1fe"} .fa-pie-chart:before{content:"\f200"} .fa-line-chart:before{content:"\f201"} .fa-lastfm:before{content:"\f202"} .fa-lastfm-square:before{content:"\f203"} .fa-toggle-off:before{content:"\f204"} .fa-toggle-on:before{content:"\f205"} .fa-bicycle:before{content:"\f206"} .fa-bus:before{content:"\f207"} .fa-ioxhost:before{content:"\f208"} .fa-angellist:before{content:"\f209"} .fa-cc:before{content:"\f20a"} .fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"} .fa-meanpath:before{content:"\f20c"} .fa-buysellads:before{content:"\f20d"} .fa-connectdevelop:before{content:"\f20e"} .fa-dashcube:before{content:"\f210"} .fa-forumbee:before{content:"\f211"} .fa-leanpub:before{content:"\f212"} .fa-sellsy:before{content:"\f213"} .fa-shirtsinbulk:before{content:"\f214"} .fa-simplybuilt:before{content:"\f215"} .fa-skyatlas:before{content:"\f216"} .fa-cart-plus:before{content:"\f217"} .fa-cart-arrow-down:before{content:"\f218"} .fa-diamond:before{content:"\f219"} .fa-ship:before{content:"\f21a"} .fa-user-secret:before{content:"\f21b"} .fa-motorcycle:before{content:"\f21c"} .fa-street-view:before{content:"\f21d"} .fa-heartbeat:before{content:"\f21e"} .fa-venus:before{content:"\f221"} .fa-mars:before{content:"\f222"} .fa-mercury:before{content:"\f223"} .fa-intersex:before,.fa-transgender:before{content:"\f224"} .fa-transgender-alt:before{content:"\f225"} .fa-venus-double:before{content:"\f226"} .fa-mars-double:before{content:"\f227"} .fa-venus-mars:before{content:"\f228"} .fa-mars-stroke:before{content:"\f229"} .fa-mars-stroke-v:before{content:"\f22a"} .fa-mars-stroke-h:before{content:"\f22b"} .fa-neuter:before{content:"\f22c"} .fa-genderless:before{content:"\f22d"} .fa-facebook-official:before{content:"\f230"} .fa-pinterest-p:before{content:"\f231"} .fa-whatsapp:before{content:"\f232"} .fa-server:before{content:"\f233"} .fa-user-plus:before{content:"\f234"} .fa-user-times:before{content:"\f235"} .fa-hotel:before,.fa-bed:before{content:"\f236"} .fa-viacoin:before{content:"\f237"} .fa-train:before{content:"\f238"} .fa-subway:before{content:"\f239"} .fa-medium:before{content:"\f23a"} .fa-yc:before,.fa-y-combinator:before{content:"\f23b"} .fa-optin-monster:before{content:"\f23c"} .fa-opencart:before{content:"\f23d"} .fa-expeditedssl:before{content:"\f23e"} .fa-battery-4:before,.fa-battery:before,.fa-battery-full:before{content:"\f240"} .fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"} .fa-battery-2:before,.fa-battery-half:before{content:"\f242"} .fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"} .fa-battery-0:before,.fa-battery-empty:before{content:"\f244"} .fa-mouse-pointer:before{content:"\f245"} .fa-i-cursor:before{content:"\f246"} .fa-object-group:before{content:"\f247"} .fa-object-ungroup:before{content:"\f248"} .fa-sticky-note:before{content:"\f249"} .fa-sticky-note-o:before{content:"\f24a"} .fa-cc-jcb:before{content:"\f24b"} .fa-cc-diners-club:before{content:"\f24c"} .fa-clone:before{content:"\f24d"} .fa-balance-scale:before{content:"\f24e"} .fa-hourglass-o:before{content:"\f250"} .fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"} .fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"} .fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"} .fa-hourglass:before{content:"\f254"} .fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"} .fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"} .fa-hand-scissors-o:before{content:"\f257"} .fa-hand-lizard-o:before{content:"\f258"} .fa-hand-spock-o:before{content:"\f259"} .fa-hand-pointer-o:before{content:"\f25a"} .fa-hand-peace-o:before{content:"\f25b"} .fa-trademark:before{content:"\f25c"} .fa-registered:before{content:"\f25d"} .fa-creative-commons:before{content:"\f25e"} .fa-gg:before{content:"\f260"} .fa-gg-circle:before{content:"\f261"} .fa-tripadvisor:before{content:"\f262"} .fa-odnoklassniki:before{content:"\f263"} .fa-odnoklassniki-square:before{content:"\f264"} .fa-get-pocket:before{content:"\f265"} .fa-wikipedia-w:before{content:"\f266"} .fa-safari:before{content:"\f267"} .fa-chrome:before{content:"\f268"} .fa-firefox:before{content:"\f269"} .fa-opera:before{content:"\f26a"} .fa-internet-explorer:before{content:"\f26b"} .fa-tv:before,.fa-television:before{content:"\f26c"} .fa-contao:before{content:"\f26d"} .fa-500px:before{content:"\f26e"} .fa-amazon:before{content:"\f270"} .fa-calendar-plus-o:before{content:"\f271"} .fa-calendar-minus-o:before{content:"\f272"} .fa-calendar-times-o:before{content:"\f273"} .fa-calendar-check-o:before{content:"\f274"} .fa-industry:before{content:"\f275"} .fa-map-pin:before{content:"\f276"} .fa-map-signs:before{content:"\f277"} .fa-map-o:before{content:"\f278"} .fa-map:before{content:"\f279"} .fa-commenting:before{content:"\f27a"} .fa-commenting-o:before{content:"\f27b"} .fa-houzz:before{content:"\f27c"} .fa-vimeo:before{content:"\f27d"} .fa-black-tie:before{content:"\f27e"} .fa-fonticons:before{content:"\f280"} .fa-reddit-alien:before{content:"\f281"} .fa-edge:before{content:"\f282"} .fa-credit-card-alt:before{content:"\f283"} .fa-codiepie:before{content:"\f284"} .fa-modx:before{content:"\f285"} .fa-fort-awesome:before{content:"\f286"} .fa-usb:before{content:"\f287"} .fa-product-hunt:before{content:"\f288"} .fa-mixcloud:before{content:"\f289"} .fa-scribd:before{content:"\f28a"} .fa-pause-circle:before{content:"\f28b"} .fa-pause-circle-o:before{content:"\f28c"} .fa-stop-circle:before{content:"\f28d"} .fa-stop-circle-o:before{content:"\f28e"} .fa-shopping-bag:before{content:"\f290"} .fa-shopping-basket:before{content:"\f291"} .fa-hashtag:before{content:"\f292"} .fa-bluetooth:before{content:"\f293"} .fa-bluetooth-b:before{content:"\f294"} .fa-percent:before{content:"\f295"} .fa-gitlab:before{content:"\f296"} .fa-wpbeginner:before{content:"\f297"} .fa-wpforms:before{content:"\f298"} .fa-envira:before{content:"\f299"} .fa-universal-access:before{content:"\f29a"} .fa-wheelchair-alt:before{content:"\f29b"} .fa-question-circle-o:before{content:"\f29c"} .fa-blind:before{content:"\f29d"} .fa-audio-description:before{content:"\f29e"} .fa-volume-control-phone:before{content:"\f2a0"} .fa-braille:before{content:"\f2a1"} .fa-assistive-listening-systems:before{content:"\f2a2"} .fa-asl-interpreting:before,.fa-american-sign-language-interpreting:before{content:"\f2a3"} .fa-deafness:before,.fa-hard-of-hearing:before,.fa-deaf:before{content:"\f2a4"} .fa-glide:before{content:"\f2a5"} .fa-glide-g:before{content:"\f2a6"} .fa-signing:before,.fa-sign-language:before{content:"\f2a7"} .fa-low-vision:before{content:"\f2a8"} .fa-viadeo:before{content:"\f2a9"} .fa-viadeo-square:before{content:"\f2aa"} .fa-snapchat:before{content:"\f2ab"} .fa-snapchat-ghost:before{content:"\f2ac"} .fa-snapchat-square:before{content:"\f2ad"} .fa-pied-piper:before{content:"\f2ae"} .fa-first-order:before{content:"\f2b0"} .fa-yoast:before{content:"\f2b1"} .fa-themeisle:before{content:"\f2b2"} .fa-google-plus-circle:before,.fa-google-plus-official:before{content:"\f2b3"} .fa-fa:before,.fa-font-awesome:before{content:"\f2b4"} .fa-handshake-o:before{content:"\f2b5"} .fa-envelope-open:before{content:"\f2b6"} .fa-envelope-open-o:before{content:"\f2b7"} .fa-linode:before{content:"\f2b8"} .fa-address-book:before{content:"\f2b9"} .fa-address-book-o:before{content:"\f2ba"} .fa-vcard:before,.fa-address-card:before{content:"\f2bb"} .fa-vcard-o:before,.fa-address-card-o:before{content:"\f2bc"} .fa-user-circle:before{content:"\f2bd"} .fa-user-circle-o:before{content:"\f2be"} .fa-user-o:before{content:"\f2c0"} .fa-id-badge:before{content:"\f2c1"} .fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"} .fa-drivers-license-o:before,.fa-id-card-o:before{content:"\f2c3"} .fa-quora:before{content:"\f2c4"} .fa-free-code-camp:before{content:"\f2c5"} .fa-telegram:before{content:"\f2c6"} .fa-thermometer-4:before,.fa-thermometer:before,.fa-thermometer-full:before{content:"\f2c7"} .fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"} .fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"} .fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"} .fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"} .fa-shower:before{content:"\f2cc"} .fa-bathtub:before,.fa-s15:before,.fa-bath:before{content:"\f2cd"} .fa-podcast:before{content:"\f2ce"} .fa-window-maximize:before{content:"\f2d0"} .fa-window-minimize:before{content:"\f2d1"} .fa-window-restore:before{content:"\f2d2"} .fa-times-rectangle:before,.fa-window-close:before{content:"\f2d3"} .fa-times-rectangle-o:before,.fa-window-close-o:before{content:"\f2d4"} .fa-bandcamp:before{content:"\f2d5"} .fa-grav:before{content:"\f2d6"} .fa-etsy:before{content:"\f2d7"} .fa-imdb:before{content:"\f2d8"} .fa-ravelry:before{content:"\f2d9"} .fa-eercast:before{content:"\f2da"} .fa-microchip:before{content:"\f2db"} .fa-snowflake-o:before{content:"\f2dc"} .fa-superpowers:before{content:"\f2dd"} .fa-wpexplorer:before{content:"\f2de"} .fa-meetup:before{content:"\f2e0"} .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);border:0} .sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}
diff --git a/assets/fonts/fontawesome-all.min.css b/internal/ports/api/core/assets/fonts/fontawesome-all.min.css
similarity index 97%
rename from assets/fonts/fontawesome-all.min.css
rename to internal/ports/api/core/assets/fonts/fontawesome-all.min.css
index 27e7ddd..9367cb6 100644
--- a/assets/fonts/fontawesome-all.min.css
+++ b/internal/ports/api/core/assets/fonts/fontawesome-all.min.css
@@ -2,4 +2,4 @@
* Font Awesome Free 5.12.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
-.fa,.fab,.fad,.fal,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-.0667em}.fa-xs{font-size:.75em}.fa-sm{font-size:.875em}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:2.5em;padding-left:0}.fa-ul>li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:.08em solid #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s linear infinite;animation:fa-spin 2s linear infinite}.fa-pulse{-webkit-animation:fa-spin 1s steps(8) infinite;animation:fa-spin 1s steps(8) infinite}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical,.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}:root .fa-flip-both,:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{-webkit-filter:none;filter:none}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-500px:before{content:"\f26e"}.fa-accessible-icon:before{content:"\f368"}.fa-accusoft:before{content:"\f369"}.fa-acquisitions-incorporated:before{content:"\f6af"}.fa-ad:before{content:"\f641"}.fa-address-book:before{content:"\f2b9"}.fa-address-card:before{content:"\f2bb"}.fa-adjust:before{content:"\f042"}.fa-adn:before{content:"\f170"}.fa-adobe:before{content:"\f778"}.fa-adversal:before{content:"\f36a"}.fa-affiliatetheme:before{content:"\f36b"}.fa-air-freshener:before{content:"\f5d0"}.fa-airbnb:before{content:"\f834"}.fa-algolia:before{content:"\f36c"}.fa-align-center:before{content:"\f037"}.fa-align-justify:before{content:"\f039"}.fa-align-left:before{content:"\f036"}.fa-align-right:before{content:"\f038"}.fa-alipay:before{content:"\f642"}.fa-allergies:before{content:"\f461"}.fa-amazon:before{content:"\f270"}.fa-amazon-pay:before{content:"\f42c"}.fa-ambulance:before{content:"\f0f9"}.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-amilia:before{content:"\f36d"}.fa-anchor:before{content:"\f13d"}.fa-android:before{content:"\f17b"}.fa-angellist:before{content:"\f209"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-down:before{content:"\f107"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angry:before{content:"\f556"}.fa-angrycreative:before{content:"\f36e"}.fa-angular:before{content:"\f420"}.fa-ankh:before{content:"\f644"}.fa-app-store:before{content:"\f36f"}.fa-app-store-ios:before{content:"\f370"}.fa-apper:before{content:"\f371"}.fa-apple:before{content:"\f179"}.fa-apple-alt:before{content:"\f5d1"}.fa-apple-pay:before{content:"\f415"}.fa-archive:before{content:"\f187"}.fa-archway:before{content:"\f557"}.fa-arrow-alt-circle-down:before{content:"\f358"}.fa-arrow-alt-circle-left:before{content:"\f359"}.fa-arrow-alt-circle-right:before{content:"\f35a"}.fa-arrow-alt-circle-up:before{content:"\f35b"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-down:before{content:"\f063"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrows-alt:before{content:"\f0b2"}.fa-arrows-alt-h:before{content:"\f337"}.fa-arrows-alt-v:before{content:"\f338"}.fa-artstation:before{content:"\f77a"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asterisk:before{content:"\f069"}.fa-asymmetrik:before{content:"\f372"}.fa-at:before{content:"\f1fa"}.fa-atlas:before{content:"\f558"}.fa-atlassian:before{content:"\f77b"}.fa-atom:before{content:"\f5d2"}.fa-audible:before{content:"\f373"}.fa-audio-description:before{content:"\f29e"}.fa-autoprefixer:before{content:"\f41c"}.fa-avianex:before{content:"\f374"}.fa-aviato:before{content:"\f421"}.fa-award:before{content:"\f559"}.fa-aws:before{content:"\f375"}.fa-baby:before{content:"\f77c"}.fa-baby-carriage:before{content:"\f77d"}.fa-backspace:before{content:"\f55a"}.fa-backward:before{content:"\f04a"}.fa-bacon:before{content:"\f7e5"}.fa-bahai:before{content:"\f666"}.fa-balance-scale:before{content:"\f24e"}.fa-balance-scale-left:before{content:"\f515"}.fa-balance-scale-right:before{content:"\f516"}.fa-ban:before{content:"\f05e"}.fa-band-aid:before{content:"\f462"}.fa-bandcamp:before{content:"\f2d5"}.fa-barcode:before{content:"\f02a"}.fa-bars:before{content:"\f0c9"}.fa-baseball-ball:before{content:"\f433"}.fa-basketball-ball:before{content:"\f434"}.fa-bath:before{content:"\f2cd"}.fa-battery-empty:before{content:"\f244"}.fa-battery-full:before{content:"\f240"}.fa-battery-half:before{content:"\f242"}.fa-battery-quarter:before{content:"\f243"}.fa-battery-three-quarters:before{content:"\f241"}.fa-battle-net:before{content:"\f835"}.fa-bed:before{content:"\f236"}.fa-beer:before{content:"\f0fc"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-bell:before{content:"\f0f3"}.fa-bell-slash:before{content:"\f1f6"}.fa-bezier-curve:before{content:"\f55b"}.fa-bible:before{content:"\f647"}.fa-bicycle:before{content:"\f206"}.fa-biking:before{content:"\f84a"}.fa-bimobject:before{content:"\f378"}.fa-binoculars:before{content:"\f1e5"}.fa-biohazard:before{content:"\f780"}.fa-birthday-cake:before{content:"\f1fd"}.fa-bitbucket:before{content:"\f171"}.fa-bitcoin:before{content:"\f379"}.fa-bity:before{content:"\f37a"}.fa-black-tie:before{content:"\f27e"}.fa-blackberry:before{content:"\f37b"}.fa-blender:before{content:"\f517"}.fa-blender-phone:before{content:"\f6b6"}.fa-blind:before{content:"\f29d"}.fa-blog:before{content:"\f781"}.fa-blogger:before{content:"\f37c"}.fa-blogger-b:before{content:"\f37d"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-bold:before{content:"\f032"}.fa-bolt:before{content:"\f0e7"}.fa-bomb:before{content:"\f1e2"}.fa-bone:before{content:"\f5d7"}.fa-bong:before{content:"\f55c"}.fa-book:before{content:"\f02d"}.fa-book-dead:before{content:"\f6b7"}.fa-book-medical:before{content:"\f7e6"}.fa-book-open:before{content:"\f518"}.fa-book-reader:before{content:"\f5da"}.fa-bookmark:before{content:"\f02e"}.fa-bootstrap:before{content:"\f836"}.fa-border-all:before{content:"\f84c"}.fa-border-none:before{content:"\f850"}.fa-border-style:before{content:"\f853"}.fa-bowling-ball:before{content:"\f436"}.fa-box:before{content:"\f466"}.fa-box-open:before{content:"\f49e"}.fa-boxes:before{content:"\f468"}.fa-braille:before{content:"\f2a1"}.fa-brain:before{content:"\f5dc"}.fa-bread-slice:before{content:"\f7ec"}.fa-briefcase:before{content:"\f0b1"}.fa-briefcase-medical:before{content:"\f469"}.fa-broadcast-tower:before{content:"\f519"}.fa-broom:before{content:"\f51a"}.fa-brush:before{content:"\f55d"}.fa-btc:before{content:"\f15a"}.fa-buffer:before{content:"\f837"}.fa-bug:before{content:"\f188"}.fa-building:before{content:"\f1ad"}.fa-bullhorn:before{content:"\f0a1"}.fa-bullseye:before{content:"\f140"}.fa-burn:before{content:"\f46a"}.fa-buromobelexperte:before{content:"\f37f"}.fa-bus:before{content:"\f207"}.fa-bus-alt:before{content:"\f55e"}.fa-business-time:before{content:"\f64a"}.fa-buy-n-large:before{content:"\f8a6"}.fa-buysellads:before{content:"\f20d"}.fa-calculator:before{content:"\f1ec"}.fa-calendar:before{content:"\f133"}.fa-calendar-alt:before{content:"\f073"}.fa-calendar-check:before{content:"\f274"}.fa-calendar-day:before{content:"\f783"}.fa-calendar-minus:before{content:"\f272"}.fa-calendar-plus:before{content:"\f271"}.fa-calendar-times:before{content:"\f273"}.fa-calendar-week:before{content:"\f784"}.fa-camera:before{content:"\f030"}.fa-camera-retro:before{content:"\f083"}.fa-campground:before{content:"\f6bb"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-candy-cane:before{content:"\f786"}.fa-cannabis:before{content:"\f55f"}.fa-capsules:before{content:"\f46b"}.fa-car:before{content:"\f1b9"}.fa-car-alt:before{content:"\f5de"}.fa-car-battery:before{content:"\f5df"}.fa-car-crash:before{content:"\f5e1"}.fa-car-side:before{content:"\f5e4"}.fa-caravan:before{content:"\f8ff"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-caret-square-down:before{content:"\f150"}.fa-caret-square-left:before{content:"\f191"}.fa-caret-square-right:before{content:"\f152"}.fa-caret-square-up:before{content:"\f151"}.fa-caret-up:before{content:"\f0d8"}.fa-carrot:before{content:"\f787"}.fa-cart-arrow-down:before{content:"\f218"}.fa-cart-plus:before{content:"\f217"}.fa-cash-register:before{content:"\f788"}.fa-cat:before{content:"\f6be"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-apple-pay:before{content:"\f416"}.fa-cc-diners-club:before{content:"\f24c"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-cc-visa:before{content:"\f1f0"}.fa-centercode:before{content:"\f380"}.fa-centos:before{content:"\f789"}.fa-certificate:before{content:"\f0a3"}.fa-chair:before{content:"\f6c0"}.fa-chalkboard:before{content:"\f51b"}.fa-chalkboard-teacher:before{content:"\f51c"}.fa-charging-station:before{content:"\f5e7"}.fa-chart-area:before{content:"\f1fe"}.fa-chart-bar:before{content:"\f080"}.fa-chart-line:before{content:"\f201"}.fa-chart-pie:before{content:"\f200"}.fa-check:before{content:"\f00c"}.fa-check-circle:before{content:"\f058"}.fa-check-double:before{content:"\f560"}.fa-check-square:before{content:"\f14a"}.fa-cheese:before{content:"\f7ef"}.fa-chess:before{content:"\f439"}.fa-chess-bishop:before{content:"\f43a"}.fa-chess-board:before{content:"\f43c"}.fa-chess-king:before{content:"\f43f"}.fa-chess-knight:before{content:"\f441"}.fa-chess-pawn:before{content:"\f443"}.fa-chess-queen:before{content:"\f445"}.fa-chess-rook:before{content:"\f447"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-down:before{content:"\f078"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-chevron-up:before{content:"\f077"}.fa-child:before{content:"\f1ae"}.fa-chrome:before{content:"\f268"}.fa-chromecast:before{content:"\f838"}.fa-church:before{content:"\f51d"}.fa-circle:before{content:"\f111"}.fa-circle-notch:before{content:"\f1ce"}.fa-city:before{content:"\f64f"}.fa-clinic-medical:before{content:"\f7f2"}.fa-clipboard:before{content:"\f328"}.fa-clipboard-check:before{content:"\f46c"}.fa-clipboard-list:before{content:"\f46d"}.fa-clock:before{content:"\f017"}.fa-clone:before{content:"\f24d"}.fa-closed-captioning:before{content:"\f20a"}.fa-cloud:before{content:"\f0c2"}.fa-cloud-download-alt:before{content:"\f381"}.fa-cloud-meatball:before{content:"\f73b"}.fa-cloud-moon:before{content:"\f6c3"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-cloud-rain:before{content:"\f73d"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-cloud-sun:before{content:"\f6c4"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-cloud-upload-alt:before{content:"\f382"}.fa-cloudscale:before{content:"\f383"}.fa-cloudsmith:before{content:"\f384"}.fa-cloudversify:before{content:"\f385"}.fa-cocktail:before{content:"\f561"}.fa-code:before{content:"\f121"}.fa-code-branch:before{content:"\f126"}.fa-codepen:before{content:"\f1cb"}.fa-codiepie:before{content:"\f284"}.fa-coffee:before{content:"\f0f4"}.fa-cog:before{content:"\f013"}.fa-cogs:before{content:"\f085"}.fa-coins:before{content:"\f51e"}.fa-columns:before{content:"\f0db"}.fa-comment:before{content:"\f075"}.fa-comment-alt:before{content:"\f27a"}.fa-comment-dollar:before{content:"\f651"}.fa-comment-dots:before{content:"\f4ad"}.fa-comment-medical:before{content:"\f7f5"}.fa-comment-slash:before{content:"\f4b3"}.fa-comments:before{content:"\f086"}.fa-comments-dollar:before{content:"\f653"}.fa-compact-disc:before{content:"\f51f"}.fa-compass:before{content:"\f14e"}.fa-compress:before{content:"\f066"}.fa-compress-alt:before{content:"\f422"}.fa-compress-arrows-alt:before{content:"\f78c"}.fa-concierge-bell:before{content:"\f562"}.fa-confluence:before{content:"\f78d"}.fa-connectdevelop:before{content:"\f20e"}.fa-contao:before{content:"\f26d"}.fa-cookie:before{content:"\f563"}.fa-cookie-bite:before{content:"\f564"}.fa-copy:before{content:"\f0c5"}.fa-copyright:before{content:"\f1f9"}.fa-cotton-bureau:before{content:"\f89e"}.fa-couch:before{content:"\f4b8"}.fa-cpanel:before{content:"\f388"}.fa-creative-commons:before{content:"\f25e"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-credit-card:before{content:"\f09d"}.fa-critical-role:before{content:"\f6c9"}.fa-crop:before{content:"\f125"}.fa-crop-alt:before{content:"\f565"}.fa-cross:before{content:"\f654"}.fa-crosshairs:before{content:"\f05b"}.fa-crow:before{content:"\f520"}.fa-crown:before{content:"\f521"}.fa-crutch:before{content:"\f7f7"}.fa-css3:before{content:"\f13c"}.fa-css3-alt:before{content:"\f38b"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-cut:before{content:"\f0c4"}.fa-cuttlefish:before{content:"\f38c"}.fa-d-and-d:before{content:"\f38d"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-dashcube:before{content:"\f210"}.fa-database:before{content:"\f1c0"}.fa-deaf:before{content:"\f2a4"}.fa-delicious:before{content:"\f1a5"}.fa-democrat:before{content:"\f747"}.fa-deploydog:before{content:"\f38e"}.fa-deskpro:before{content:"\f38f"}.fa-desktop:before{content:"\f108"}.fa-dev:before{content:"\f6cc"}.fa-deviantart:before{content:"\f1bd"}.fa-dharmachakra:before{content:"\f655"}.fa-dhl:before{content:"\f790"}.fa-diagnoses:before{content:"\f470"}.fa-diaspora:before{content:"\f791"}.fa-dice:before{content:"\f522"}.fa-dice-d20:before{content:"\f6cf"}.fa-dice-d6:before{content:"\f6d1"}.fa-dice-five:before{content:"\f523"}.fa-dice-four:before{content:"\f524"}.fa-dice-one:before{content:"\f525"}.fa-dice-six:before{content:"\f526"}.fa-dice-three:before{content:"\f527"}.fa-dice-two:before{content:"\f528"}.fa-digg:before{content:"\f1a6"}.fa-digital-ocean:before{content:"\f391"}.fa-digital-tachograph:before{content:"\f566"}.fa-directions:before{content:"\f5eb"}.fa-discord:before{content:"\f392"}.fa-discourse:before{content:"\f393"}.fa-divide:before{content:"\f529"}.fa-dizzy:before{content:"\f567"}.fa-dna:before{content:"\f471"}.fa-dochub:before{content:"\f394"}.fa-docker:before{content:"\f395"}.fa-dog:before{content:"\f6d3"}.fa-dollar-sign:before{content:"\f155"}.fa-dolly:before{content:"\f472"}.fa-dolly-flatbed:before{content:"\f474"}.fa-donate:before{content:"\f4b9"}.fa-door-closed:before{content:"\f52a"}.fa-door-open:before{content:"\f52b"}.fa-dot-circle:before{content:"\f192"}.fa-dove:before{content:"\f4ba"}.fa-download:before{content:"\f019"}.fa-draft2digital:before{content:"\f396"}.fa-drafting-compass:before{content:"\f568"}.fa-dragon:before{content:"\f6d5"}.fa-draw-polygon:before{content:"\f5ee"}.fa-dribbble:before{content:"\f17d"}.fa-dribbble-square:before{content:"\f397"}.fa-dropbox:before{content:"\f16b"}.fa-drum:before{content:"\f569"}.fa-drum-steelpan:before{content:"\f56a"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-drupal:before{content:"\f1a9"}.fa-dumbbell:before{content:"\f44b"}.fa-dumpster:before{content:"\f793"}.fa-dumpster-fire:before{content:"\f794"}.fa-dungeon:before{content:"\f6d9"}.fa-dyalog:before{content:"\f399"}.fa-earlybirds:before{content:"\f39a"}.fa-ebay:before{content:"\f4f4"}.fa-edge:before{content:"\f282"}.fa-edit:before{content:"\f044"}.fa-egg:before{content:"\f7fb"}.fa-eject:before{content:"\f052"}.fa-elementor:before{content:"\f430"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-ello:before{content:"\f5f1"}.fa-ember:before{content:"\f423"}.fa-empire:before{content:"\f1d1"}.fa-envelope:before{content:"\f0e0"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-text:before{content:"\f658"}.fa-envelope-square:before{content:"\f199"}.fa-envira:before{content:"\f299"}.fa-equals:before{content:"\f52c"}.fa-eraser:before{content:"\f12d"}.fa-erlang:before{content:"\f39d"}.fa-ethereum:before{content:"\f42e"}.fa-ethernet:before{content:"\f796"}.fa-etsy:before{content:"\f2d7"}.fa-euro-sign:before{content:"\f153"}.fa-evernote:before{content:"\f839"}.fa-exchange-alt:before{content:"\f362"}.fa-exclamation:before{content:"\f12a"}.fa-exclamation-circle:before{content:"\f06a"}.fa-exclamation-triangle:before{content:"\f071"}.fa-expand:before{content:"\f065"}.fa-expand-alt:before{content:"\f424"}.fa-expand-arrows-alt:before{content:"\f31e"}.fa-expeditedssl:before{content:"\f23e"}.fa-external-link-alt:before{content:"\f35d"}.fa-external-link-square-alt:before{content:"\f360"}.fa-eye:before{content:"\f06e"}.fa-eye-dropper:before{content:"\f1fb"}.fa-eye-slash:before{content:"\f070"}.fa-facebook:before{content:"\f09a"}.fa-facebook-f:before{content:"\f39e"}.fa-facebook-messenger:before{content:"\f39f"}.fa-facebook-square:before{content:"\f082"}.fa-fan:before{content:"\f863"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-fast-backward:before{content:"\f049"}.fa-fast-forward:before{content:"\f050"}.fa-fax:before{content:"\f1ac"}.fa-feather:before{content:"\f52d"}.fa-feather-alt:before{content:"\f56b"}.fa-fedex:before{content:"\f797"}.fa-fedora:before{content:"\f798"}.fa-female:before{content:"\f182"}.fa-fighter-jet:before{content:"\f0fb"}.fa-figma:before{content:"\f799"}.fa-file:before{content:"\f15b"}.fa-file-alt:before{content:"\f15c"}.fa-file-archive:before{content:"\f1c6"}.fa-file-audio:before{content:"\f1c7"}.fa-file-code:before{content:"\f1c9"}.fa-file-contract:before{content:"\f56c"}.fa-file-csv:before{content:"\f6dd"}.fa-file-download:before{content:"\f56d"}.fa-file-excel:before{content:"\f1c3"}.fa-file-export:before{content:"\f56e"}.fa-file-image:before{content:"\f1c5"}.fa-file-import:before{content:"\f56f"}.fa-file-invoice:before{content:"\f570"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-file-medical:before{content:"\f477"}.fa-file-medical-alt:before{content:"\f478"}.fa-file-pdf:before{content:"\f1c1"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-file-prescription:before{content:"\f572"}.fa-file-signature:before{content:"\f573"}.fa-file-upload:before{content:"\f574"}.fa-file-video:before{content:"\f1c8"}.fa-file-word:before{content:"\f1c2"}.fa-fill:before{content:"\f575"}.fa-fill-drip:before{content:"\f576"}.fa-film:before{content:"\f008"}.fa-filter:before{content:"\f0b0"}.fa-fingerprint:before{content:"\f577"}.fa-fire:before{content:"\f06d"}.fa-fire-alt:before{content:"\f7e4"}.fa-fire-extinguisher:before{content:"\f134"}.fa-firefox:before{content:"\f269"}.fa-firefox-browser:before{content:"\f907"}.fa-first-aid:before{content:"\f479"}.fa-first-order:before{content:"\f2b0"}.fa-first-order-alt:before{content:"\f50a"}.fa-firstdraft:before{content:"\f3a1"}.fa-fish:before{content:"\f578"}.fa-fist-raised:before{content:"\f6de"}.fa-flag:before{content:"\f024"}.fa-flag-checkered:before{content:"\f11e"}.fa-flag-usa:before{content:"\f74d"}.fa-flask:before{content:"\f0c3"}.fa-flickr:before{content:"\f16e"}.fa-flipboard:before{content:"\f44d"}.fa-flushed:before{content:"\f579"}.fa-fly:before{content:"\f417"}.fa-folder:before{content:"\f07b"}.fa-folder-minus:before{content:"\f65d"}.fa-folder-open:before{content:"\f07c"}.fa-folder-plus:before{content:"\f65e"}.fa-font:before{content:"\f031"}.fa-font-awesome:before{content:"\f2b4"}.fa-font-awesome-alt:before{content:"\f35c"}.fa-font-awesome-flag:before{content:"\f425"}.fa-font-awesome-logo-full:before{content:"\f4e6"}.fa-fonticons:before{content:"\f280"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-football-ball:before{content:"\f44e"}.fa-fort-awesome:before{content:"\f286"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-forumbee:before{content:"\f211"}.fa-forward:before{content:"\f04e"}.fa-foursquare:before{content:"\f180"}.fa-free-code-camp:before{content:"\f2c5"}.fa-freebsd:before{content:"\f3a4"}.fa-frog:before{content:"\f52e"}.fa-frown:before{content:"\f119"}.fa-frown-open:before{content:"\f57a"}.fa-fulcrum:before{content:"\f50b"}.fa-funnel-dollar:before{content:"\f662"}.fa-futbol:before{content:"\f1e3"}.fa-galactic-republic:before{content:"\f50c"}.fa-galactic-senate:before{content:"\f50d"}.fa-gamepad:before{content:"\f11b"}.fa-gas-pump:before{content:"\f52f"}.fa-gavel:before{content:"\f0e3"}.fa-gem:before{content:"\f3a5"}.fa-genderless:before{content:"\f22d"}.fa-get-pocket:before{content:"\f265"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-ghost:before{content:"\f6e2"}.fa-gift:before{content:"\f06b"}.fa-gifts:before{content:"\f79c"}.fa-git:before{content:"\f1d3"}.fa-git-alt:before{content:"\f841"}.fa-git-square:before{content:"\f1d2"}.fa-github:before{content:"\f09b"}.fa-github-alt:before{content:"\f113"}.fa-github-square:before{content:"\f092"}.fa-gitkraken:before{content:"\f3a6"}.fa-gitlab:before{content:"\f296"}.fa-gitter:before{content:"\f426"}.fa-glass-cheers:before{content:"\f79f"}.fa-glass-martini:before{content:"\f000"}.fa-glass-martini-alt:before{content:"\f57b"}.fa-glass-whiskey:before{content:"\f7a0"}.fa-glasses:before{content:"\f530"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-globe:before{content:"\f0ac"}.fa-globe-africa:before{content:"\f57c"}.fa-globe-americas:before{content:"\f57d"}.fa-globe-asia:before{content:"\f57e"}.fa-globe-europe:before{content:"\f7a2"}.fa-gofore:before{content:"\f3a7"}.fa-golf-ball:before{content:"\f450"}.fa-goodreads:before{content:"\f3a8"}.fa-goodreads-g:before{content:"\f3a9"}.fa-google:before{content:"\f1a0"}.fa-google-drive:before{content:"\f3aa"}.fa-google-play:before{content:"\f3ab"}.fa-google-plus:before{content:"\f2b3"}.fa-google-plus-g:before{content:"\f0d5"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-wallet:before{content:"\f1ee"}.fa-gopuram:before{content:"\f664"}.fa-graduation-cap:before{content:"\f19d"}.fa-gratipay:before{content:"\f184"}.fa-grav:before{content:"\f2d6"}.fa-greater-than:before{content:"\f531"}.fa-greater-than-equal:before{content:"\f532"}.fa-grimace:before{content:"\f57f"}.fa-grin:before{content:"\f580"}.fa-grin-alt:before{content:"\f581"}.fa-grin-beam:before{content:"\f582"}.fa-grin-beam-sweat:before{content:"\f583"}.fa-grin-hearts:before{content:"\f584"}.fa-grin-squint:before{content:"\f585"}.fa-grin-squint-tears:before{content:"\f586"}.fa-grin-stars:before{content:"\f587"}.fa-grin-tears:before{content:"\f588"}.fa-grin-tongue:before{content:"\f589"}.fa-grin-tongue-squint:before{content:"\f58a"}.fa-grin-tongue-wink:before{content:"\f58b"}.fa-grin-wink:before{content:"\f58c"}.fa-grip-horizontal:before{content:"\f58d"}.fa-grip-lines:before{content:"\f7a4"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-grip-vertical:before{content:"\f58e"}.fa-gripfire:before{content:"\f3ac"}.fa-grunt:before{content:"\f3ad"}.fa-guitar:before{content:"\f7a6"}.fa-gulp:before{content:"\f3ae"}.fa-h-square:before{content:"\f0fd"}.fa-hacker-news:before{content:"\f1d4"}.fa-hacker-news-square:before{content:"\f3af"}.fa-hackerrank:before{content:"\f5f7"}.fa-hamburger:before{content:"\f805"}.fa-hammer:before{content:"\f6e3"}.fa-hamsa:before{content:"\f665"}.fa-hand-holding:before{content:"\f4bd"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-hand-holding-usd:before{content:"\f4c0"}.fa-hand-lizard:before{content:"\f258"}.fa-hand-middle-finger:before{content:"\f806"}.fa-hand-paper:before{content:"\f256"}.fa-hand-peace:before{content:"\f25b"}.fa-hand-point-down:before{content:"\f0a7"}.fa-hand-point-left:before{content:"\f0a5"}.fa-hand-point-right:before{content:"\f0a4"}.fa-hand-point-up:before{content:"\f0a6"}.fa-hand-pointer:before{content:"\f25a"}.fa-hand-rock:before{content:"\f255"}.fa-hand-scissors:before{content:"\f257"}.fa-hand-spock:before{content:"\f259"}.fa-hands:before{content:"\f4c2"}.fa-hands-helping:before{content:"\f4c4"}.fa-handshake:before{content:"\f2b5"}.fa-hanukiah:before{content:"\f6e6"}.fa-hard-hat:before{content:"\f807"}.fa-hashtag:before{content:"\f292"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-hat-wizard:before{content:"\f6e8"}.fa-hdd:before{content:"\f0a0"}.fa-heading:before{content:"\f1dc"}.fa-headphones:before{content:"\f025"}.fa-headphones-alt:before{content:"\f58f"}.fa-headset:before{content:"\f590"}.fa-heart:before{content:"\f004"}.fa-heart-broken:before{content:"\f7a9"}.fa-heartbeat:before{content:"\f21e"}.fa-helicopter:before{content:"\f533"}.fa-highlighter:before{content:"\f591"}.fa-hiking:before{content:"\f6ec"}.fa-hippo:before{content:"\f6ed"}.fa-hips:before{content:"\f452"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-history:before{content:"\f1da"}.fa-hockey-puck:before{content:"\f453"}.fa-holly-berry:before{content:"\f7aa"}.fa-home:before{content:"\f015"}.fa-hooli:before{content:"\f427"}.fa-hornbill:before{content:"\f592"}.fa-horse:before{content:"\f6f0"}.fa-horse-head:before{content:"\f7ab"}.fa-hospital:before{content:"\f0f8"}.fa-hospital-alt:before{content:"\f47d"}.fa-hospital-symbol:before{content:"\f47e"}.fa-hot-tub:before{content:"\f593"}.fa-hotdog:before{content:"\f80f"}.fa-hotel:before{content:"\f594"}.fa-hotjar:before{content:"\f3b1"}.fa-hourglass:before{content:"\f254"}.fa-hourglass-end:before{content:"\f253"}.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-start:before{content:"\f251"}.fa-house-damage:before{content:"\f6f1"}.fa-houzz:before{content:"\f27c"}.fa-hryvnia:before{content:"\f6f2"}.fa-html5:before{content:"\f13b"}.fa-hubspot:before{content:"\f3b2"}.fa-i-cursor:before{content:"\f246"}.fa-ice-cream:before{content:"\f810"}.fa-icicles:before{content:"\f7ad"}.fa-icons:before{content:"\f86d"}.fa-id-badge:before{content:"\f2c1"}.fa-id-card:before{content:"\f2c2"}.fa-id-card-alt:before{content:"\f47f"}.fa-ideal:before{content:"\f913"}.fa-igloo:before{content:"\f7ae"}.fa-image:before{content:"\f03e"}.fa-images:before{content:"\f302"}.fa-imdb:before{content:"\f2d8"}.fa-inbox:before{content:"\f01c"}.fa-indent:before{content:"\f03c"}.fa-industry:before{content:"\f275"}.fa-infinity:before{content:"\f534"}.fa-info:before{content:"\f129"}.fa-info-circle:before{content:"\f05a"}.fa-instagram:before{content:"\f16d"}.fa-intercom:before{content:"\f7af"}.fa-internet-explorer:before{content:"\f26b"}.fa-invision:before{content:"\f7b0"}.fa-ioxhost:before{content:"\f208"}.fa-italic:before{content:"\f033"}.fa-itch-io:before{content:"\f83a"}.fa-itunes:before{content:"\f3b4"}.fa-itunes-note:before{content:"\f3b5"}.fa-java:before{content:"\f4e4"}.fa-jedi:before{content:"\f669"}.fa-jedi-order:before{content:"\f50e"}.fa-jenkins:before{content:"\f3b6"}.fa-jira:before{content:"\f7b1"}.fa-joget:before{content:"\f3b7"}.fa-joint:before{content:"\f595"}.fa-joomla:before{content:"\f1aa"}.fa-journal-whills:before{content:"\f66a"}.fa-js:before{content:"\f3b8"}.fa-js-square:before{content:"\f3b9"}.fa-jsfiddle:before{content:"\f1cc"}.fa-kaaba:before{content:"\f66b"}.fa-kaggle:before{content:"\f5fa"}.fa-key:before{content:"\f084"}.fa-keybase:before{content:"\f4f5"}.fa-keyboard:before{content:"\f11c"}.fa-keycdn:before{content:"\f3ba"}.fa-khanda:before{content:"\f66d"}.fa-kickstarter:before{content:"\f3bb"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-kiss:before{content:"\f596"}.fa-kiss-beam:before{content:"\f597"}.fa-kiss-wink-heart:before{content:"\f598"}.fa-kiwi-bird:before{content:"\f535"}.fa-korvue:before{content:"\f42f"}.fa-landmark:before{content:"\f66f"}.fa-language:before{content:"\f1ab"}.fa-laptop:before{content:"\f109"}.fa-laptop-code:before{content:"\f5fc"}.fa-laptop-medical:before{content:"\f812"}.fa-laravel:before{content:"\f3bd"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-laugh:before{content:"\f599"}.fa-laugh-beam:before{content:"\f59a"}.fa-laugh-squint:before{content:"\f59b"}.fa-laugh-wink:before{content:"\f59c"}.fa-layer-group:before{content:"\f5fd"}.fa-leaf:before{content:"\f06c"}.fa-leanpub:before{content:"\f212"}.fa-lemon:before{content:"\f094"}.fa-less:before{content:"\f41d"}.fa-less-than:before{content:"\f536"}.fa-less-than-equal:before{content:"\f537"}.fa-level-down-alt:before{content:"\f3be"}.fa-level-up-alt:before{content:"\f3bf"}.fa-life-ring:before{content:"\f1cd"}.fa-lightbulb:before{content:"\f0eb"}.fa-line:before{content:"\f3c0"}.fa-link:before{content:"\f0c1"}.fa-linkedin:before{content:"\f08c"}.fa-linkedin-in:before{content:"\f0e1"}.fa-linode:before{content:"\f2b8"}.fa-linux:before{content:"\f17c"}.fa-lira-sign:before{content:"\f195"}.fa-list:before{content:"\f03a"}.fa-list-alt:before{content:"\f022"}.fa-list-ol:before{content:"\f0cb"}.fa-list-ul:before{content:"\f0ca"}.fa-location-arrow:before{content:"\f124"}.fa-lock:before{content:"\f023"}.fa-lock-open:before{content:"\f3c1"}.fa-long-arrow-alt-down:before{content:"\f309"}.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-long-arrow-alt-right:before{content:"\f30b"}.fa-long-arrow-alt-up:before{content:"\f30c"}.fa-low-vision:before{content:"\f2a8"}.fa-luggage-cart:before{content:"\f59d"}.fa-lyft:before{content:"\f3c3"}.fa-magento:before{content:"\f3c4"}.fa-magic:before{content:"\f0d0"}.fa-magnet:before{content:"\f076"}.fa-mail-bulk:before{content:"\f674"}.fa-mailchimp:before{content:"\f59e"}.fa-male:before{content:"\f183"}.fa-mandalorian:before{content:"\f50f"}.fa-map:before{content:"\f279"}.fa-map-marked:before{content:"\f59f"}.fa-map-marked-alt:before{content:"\f5a0"}.fa-map-marker:before{content:"\f041"}.fa-map-marker-alt:before{content:"\f3c5"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-markdown:before{content:"\f60f"}.fa-marker:before{content:"\f5a1"}.fa-mars:before{content:"\f222"}.fa-mars-double:before{content:"\f227"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mask:before{content:"\f6fa"}.fa-mastodon:before{content:"\f4f6"}.fa-maxcdn:before{content:"\f136"}.fa-mdb:before{content:"\f8ca"}.fa-medal:before{content:"\f5a2"}.fa-medapps:before{content:"\f3c6"}.fa-medium:before{content:"\f23a"}.fa-medium-m:before{content:"\f3c7"}.fa-medkit:before{content:"\f0fa"}.fa-medrt:before{content:"\f3c8"}.fa-meetup:before{content:"\f2e0"}.fa-megaport:before{content:"\f5a3"}.fa-meh:before{content:"\f11a"}.fa-meh-blank:before{content:"\f5a4"}.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-memory:before{content:"\f538"}.fa-mendeley:before{content:"\f7b3"}.fa-menorah:before{content:"\f676"}.fa-mercury:before{content:"\f223"}.fa-meteor:before{content:"\f753"}.fa-microblog:before{content:"\f91a"}.fa-microchip:before{content:"\f2db"}.fa-microphone:before{content:"\f130"}.fa-microphone-alt:before{content:"\f3c9"}.fa-microphone-alt-slash:before{content:"\f539"}.fa-microphone-slash:before{content:"\f131"}.fa-microscope:before{content:"\f610"}.fa-microsoft:before{content:"\f3ca"}.fa-minus:before{content:"\f068"}.fa-minus-circle:before{content:"\f056"}.fa-minus-square:before{content:"\f146"}.fa-mitten:before{content:"\f7b5"}.fa-mix:before{content:"\f3cb"}.fa-mixcloud:before{content:"\f289"}.fa-mizuni:before{content:"\f3cc"}.fa-mobile:before{content:"\f10b"}.fa-mobile-alt:before{content:"\f3cd"}.fa-modx:before{content:"\f285"}.fa-monero:before{content:"\f3d0"}.fa-money-bill:before{content:"\f0d6"}.fa-money-bill-alt:before{content:"\f3d1"}.fa-money-bill-wave:before{content:"\f53a"}.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-money-check:before{content:"\f53c"}.fa-money-check-alt:before{content:"\f53d"}.fa-monument:before{content:"\f5a6"}.fa-moon:before{content:"\f186"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-mosque:before{content:"\f678"}.fa-motorcycle:before{content:"\f21c"}.fa-mountain:before{content:"\f6fc"}.fa-mouse:before{content:"\f8cc"}.fa-mouse-pointer:before{content:"\f245"}.fa-mug-hot:before{content:"\f7b6"}.fa-music:before{content:"\f001"}.fa-napster:before{content:"\f3d2"}.fa-neos:before{content:"\f612"}.fa-network-wired:before{content:"\f6ff"}.fa-neuter:before{content:"\f22c"}.fa-newspaper:before{content:"\f1ea"}.fa-nimblr:before{content:"\f5a8"}.fa-node:before{content:"\f419"}.fa-node-js:before{content:"\f3d3"}.fa-not-equal:before{content:"\f53e"}.fa-notes-medical:before{content:"\f481"}.fa-npm:before{content:"\f3d4"}.fa-ns8:before{content:"\f3d5"}.fa-nutritionix:before{content:"\f3d6"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-oil-can:before{content:"\f613"}.fa-old-republic:before{content:"\f510"}.fa-om:before{content:"\f679"}.fa-opencart:before{content:"\f23d"}.fa-openid:before{content:"\f19b"}.fa-opera:before{content:"\f26a"}.fa-optin-monster:before{content:"\f23c"}.fa-orcid:before{content:"\f8d2"}.fa-osi:before{content:"\f41a"}.fa-otter:before{content:"\f700"}.fa-outdent:before{content:"\f03b"}.fa-page4:before{content:"\f3d7"}.fa-pagelines:before{content:"\f18c"}.fa-pager:before{content:"\f815"}.fa-paint-brush:before{content:"\f1fc"}.fa-paint-roller:before{content:"\f5aa"}.fa-palette:before{content:"\f53f"}.fa-palfed:before{content:"\f3d8"}.fa-pallet:before{content:"\f482"}.fa-paper-plane:before{content:"\f1d8"}.fa-paperclip:before{content:"\f0c6"}.fa-parachute-box:before{content:"\f4cd"}.fa-paragraph:before{content:"\f1dd"}.fa-parking:before{content:"\f540"}.fa-passport:before{content:"\f5ab"}.fa-pastafarianism:before{content:"\f67b"}.fa-paste:before{content:"\f0ea"}.fa-patreon:before{content:"\f3d9"}.fa-pause:before{content:"\f04c"}.fa-pause-circle:before{content:"\f28b"}.fa-paw:before{content:"\f1b0"}.fa-paypal:before{content:"\f1ed"}.fa-peace:before{content:"\f67c"}.fa-pen:before{content:"\f304"}.fa-pen-alt:before{content:"\f305"}.fa-pen-fancy:before{content:"\f5ac"}.fa-pen-nib:before{content:"\f5ad"}.fa-pen-square:before{content:"\f14b"}.fa-pencil-alt:before{content:"\f303"}.fa-pencil-ruler:before{content:"\f5ae"}.fa-penny-arcade:before{content:"\f704"}.fa-people-carry:before{content:"\f4ce"}.fa-pepper-hot:before{content:"\f816"}.fa-percent:before{content:"\f295"}.fa-percentage:before{content:"\f541"}.fa-periscope:before{content:"\f3da"}.fa-person-booth:before{content:"\f756"}.fa-phabricator:before{content:"\f3db"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-phoenix-squadron:before{content:"\f511"}.fa-phone:before{content:"\f095"}.fa-phone-alt:before{content:"\f879"}.fa-phone-slash:before{content:"\f3dd"}.fa-phone-square:before{content:"\f098"}.fa-phone-square-alt:before{content:"\f87b"}.fa-phone-volume:before{content:"\f2a0"}.fa-photo-video:before{content:"\f87c"}.fa-php:before{content:"\f457"}.fa-pied-piper:before{content:"\f2ae"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-square:before{content:"\f91e"}.fa-piggy-bank:before{content:"\f4d3"}.fa-pills:before{content:"\f484"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-p:before{content:"\f231"}.fa-pinterest-square:before{content:"\f0d3"}.fa-pizza-slice:before{content:"\f818"}.fa-place-of-worship:before{content:"\f67f"}.fa-plane:before{content:"\f072"}.fa-plane-arrival:before{content:"\f5af"}.fa-plane-departure:before{content:"\f5b0"}.fa-play:before{content:"\f04b"}.fa-play-circle:before{content:"\f144"}.fa-playstation:before{content:"\f3df"}.fa-plug:before{content:"\f1e6"}.fa-plus:before{content:"\f067"}.fa-plus-circle:before{content:"\f055"}.fa-plus-square:before{content:"\f0fe"}.fa-podcast:before{content:"\f2ce"}.fa-poll:before{content:"\f681"}.fa-poll-h:before{content:"\f682"}.fa-poo:before{content:"\f2fe"}.fa-poo-storm:before{content:"\f75a"}.fa-poop:before{content:"\f619"}.fa-portrait:before{content:"\f3e0"}.fa-pound-sign:before{content:"\f154"}.fa-power-off:before{content:"\f011"}.fa-pray:before{content:"\f683"}.fa-praying-hands:before{content:"\f684"}.fa-prescription:before{content:"\f5b1"}.fa-prescription-bottle:before{content:"\f485"}.fa-prescription-bottle-alt:before{content:"\f486"}.fa-print:before{content:"\f02f"}.fa-procedures:before{content:"\f487"}.fa-product-hunt:before{content:"\f288"}.fa-project-diagram:before{content:"\f542"}.fa-pushed:before{content:"\f3e1"}.fa-puzzle-piece:before{content:"\f12e"}.fa-python:before{content:"\f3e2"}.fa-qq:before{content:"\f1d6"}.fa-qrcode:before{content:"\f029"}.fa-question:before{content:"\f128"}.fa-question-circle:before{content:"\f059"}.fa-quidditch:before{content:"\f458"}.fa-quinscape:before{content:"\f459"}.fa-quora:before{content:"\f2c4"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-quran:before{content:"\f687"}.fa-r-project:before{content:"\f4f7"}.fa-radiation:before{content:"\f7b9"}.fa-radiation-alt:before{content:"\f7ba"}.fa-rainbow:before{content:"\f75b"}.fa-random:before{content:"\f074"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-ravelry:before{content:"\f2d9"}.fa-react:before{content:"\f41b"}.fa-reacteurope:before{content:"\f75d"}.fa-readme:before{content:"\f4d5"}.fa-rebel:before{content:"\f1d0"}.fa-receipt:before{content:"\f543"}.fa-record-vinyl:before{content:"\f8d9"}.fa-recycle:before{content:"\f1b8"}.fa-red-river:before{content:"\f3e3"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-alien:before{content:"\f281"}.fa-reddit-square:before{content:"\f1a2"}.fa-redhat:before{content:"\f7bc"}.fa-redo:before{content:"\f01e"}.fa-redo-alt:before{content:"\f2f9"}.fa-registered:before{content:"\f25d"}.fa-remove-format:before{content:"\f87d"}.fa-renren:before{content:"\f18b"}.fa-reply:before{content:"\f3e5"}.fa-reply-all:before{content:"\f122"}.fa-replyd:before{content:"\f3e6"}.fa-republican:before{content:"\f75e"}.fa-researchgate:before{content:"\f4f8"}.fa-resolving:before{content:"\f3e7"}.fa-restroom:before{content:"\f7bd"}.fa-retweet:before{content:"\f079"}.fa-rev:before{content:"\f5b2"}.fa-ribbon:before{content:"\f4d6"}.fa-ring:before{content:"\f70b"}.fa-road:before{content:"\f018"}.fa-robot:before{content:"\f544"}.fa-rocket:before{content:"\f135"}.fa-rocketchat:before{content:"\f3e8"}.fa-rockrms:before{content:"\f3e9"}.fa-route:before{content:"\f4d7"}.fa-rss:before{content:"\f09e"}.fa-rss-square:before{content:"\f143"}.fa-ruble-sign:before{content:"\f158"}.fa-ruler:before{content:"\f545"}.fa-ruler-combined:before{content:"\f546"}.fa-ruler-horizontal:before{content:"\f547"}.fa-ruler-vertical:before{content:"\f548"}.fa-running:before{content:"\f70c"}.fa-rupee-sign:before{content:"\f156"}.fa-sad-cry:before{content:"\f5b3"}.fa-sad-tear:before{content:"\f5b4"}.fa-safari:before{content:"\f267"}.fa-salesforce:before{content:"\f83b"}.fa-sass:before{content:"\f41e"}.fa-satellite:before{content:"\f7bf"}.fa-satellite-dish:before{content:"\f7c0"}.fa-save:before{content:"\f0c7"}.fa-schlix:before{content:"\f3ea"}.fa-school:before{content:"\f549"}.fa-screwdriver:before{content:"\f54a"}.fa-scribd:before{content:"\f28a"}.fa-scroll:before{content:"\f70e"}.fa-sd-card:before{content:"\f7c2"}.fa-search:before{content:"\f002"}.fa-search-dollar:before{content:"\f688"}.fa-search-location:before{content:"\f689"}.fa-search-minus:before{content:"\f010"}.fa-search-plus:before{content:"\f00e"}.fa-searchengin:before{content:"\f3eb"}.fa-seedling:before{content:"\f4d8"}.fa-sellcast:before{content:"\f2da"}.fa-sellsy:before{content:"\f213"}.fa-server:before{content:"\f233"}.fa-servicestack:before{content:"\f3ec"}.fa-shapes:before{content:"\f61f"}.fa-share:before{content:"\f064"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-share-square:before{content:"\f14d"}.fa-shekel-sign:before{content:"\f20b"}.fa-shield-alt:before{content:"\f3ed"}.fa-ship:before{content:"\f21a"}.fa-shipping-fast:before{content:"\f48b"}.fa-shirtsinbulk:before{content:"\f214"}.fa-shoe-prints:before{content:"\f54b"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-shopping-cart:before{content:"\f07a"}.fa-shopware:before{content:"\f5b5"}.fa-shower:before{content:"\f2cc"}.fa-shuttle-van:before{content:"\f5b6"}.fa-sign:before{content:"\f4d9"}.fa-sign-in-alt:before{content:"\f2f6"}.fa-sign-language:before{content:"\f2a7"}.fa-sign-out-alt:before{content:"\f2f5"}.fa-signal:before{content:"\f012"}.fa-signature:before{content:"\f5b7"}.fa-sim-card:before{content:"\f7c4"}.fa-simplybuilt:before{content:"\f215"}.fa-sistrix:before{content:"\f3ee"}.fa-sitemap:before{content:"\f0e8"}.fa-sith:before{content:"\f512"}.fa-skating:before{content:"\f7c5"}.fa-sketch:before{content:"\f7c6"}.fa-skiing:before{content:"\f7c9"}.fa-skiing-nordic:before{content:"\f7ca"}.fa-skull:before{content:"\f54c"}.fa-skull-crossbones:before{content:"\f714"}.fa-skyatlas:before{content:"\f216"}.fa-skype:before{content:"\f17e"}.fa-slack:before{content:"\f198"}.fa-slack-hash:before{content:"\f3ef"}.fa-slash:before{content:"\f715"}.fa-sleigh:before{content:"\f7cc"}.fa-sliders-h:before{content:"\f1de"}.fa-slideshare:before{content:"\f1e7"}.fa-smile:before{content:"\f118"}.fa-smile-beam:before{content:"\f5b8"}.fa-smile-wink:before{content:"\f4da"}.fa-smog:before{content:"\f75f"}.fa-smoking:before{content:"\f48d"}.fa-smoking-ban:before{content:"\f54d"}.fa-sms:before{content:"\f7cd"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-snowboarding:before{content:"\f7ce"}.fa-snowflake:before{content:"\f2dc"}.fa-snowman:before{content:"\f7d0"}.fa-snowplow:before{content:"\f7d2"}.fa-socks:before{content:"\f696"}.fa-solar-panel:before{content:"\f5ba"}.fa-sort:before{content:"\f0dc"}.fa-sort-alpha-down:before{content:"\f15d"}.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-sort-alpha-up:before{content:"\f15e"}.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-sort-amount-down:before{content:"\f160"}.fa-sort-amount-down-alt:before{content:"\f884"}.fa-sort-amount-up:before{content:"\f161"}.fa-sort-amount-up-alt:before{content:"\f885"}.fa-sort-down:before{content:"\f0dd"}.fa-sort-numeric-down:before{content:"\f162"}.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-sort-numeric-up:before{content:"\f163"}.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-sort-up:before{content:"\f0de"}.fa-soundcloud:before{content:"\f1be"}.fa-sourcetree:before{content:"\f7d3"}.fa-spa:before{content:"\f5bb"}.fa-space-shuttle:before{content:"\f197"}.fa-speakap:before{content:"\f3f3"}.fa-speaker-deck:before{content:"\f83c"}.fa-spell-check:before{content:"\f891"}.fa-spider:before{content:"\f717"}.fa-spinner:before{content:"\f110"}.fa-splotch:before{content:"\f5bc"}.fa-spotify:before{content:"\f1bc"}.fa-spray-can:before{content:"\f5bd"}.fa-square:before{content:"\f0c8"}.fa-square-full:before{content:"\f45c"}.fa-square-root-alt:before{content:"\f698"}.fa-squarespace:before{content:"\f5be"}.fa-stack-exchange:before{content:"\f18d"}.fa-stack-overflow:before{content:"\f16c"}.fa-stackpath:before{content:"\f842"}.fa-stamp:before{content:"\f5bf"}.fa-star:before{content:"\f005"}.fa-star-and-crescent:before{content:"\f699"}.fa-star-half:before{content:"\f089"}.fa-star-half-alt:before{content:"\f5c0"}.fa-star-of-david:before{content:"\f69a"}.fa-star-of-life:before{content:"\f621"}.fa-staylinked:before{content:"\f3f5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-steam-symbol:before{content:"\f3f6"}.fa-step-backward:before{content:"\f048"}.fa-step-forward:before{content:"\f051"}.fa-stethoscope:before{content:"\f0f1"}.fa-sticker-mule:before{content:"\f3f7"}.fa-sticky-note:before{content:"\f249"}.fa-stop:before{content:"\f04d"}.fa-stop-circle:before{content:"\f28d"}.fa-stopwatch:before{content:"\f2f2"}.fa-store:before{content:"\f54e"}.fa-store-alt:before{content:"\f54f"}.fa-strava:before{content:"\f428"}.fa-stream:before{content:"\f550"}.fa-street-view:before{content:"\f21d"}.fa-strikethrough:before{content:"\f0cc"}.fa-stripe:before{content:"\f429"}.fa-stripe-s:before{content:"\f42a"}.fa-stroopwafel:before{content:"\f551"}.fa-studiovinari:before{content:"\f3f8"}.fa-stumbleupon:before{content:"\f1a4"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-subscript:before{content:"\f12c"}.fa-subway:before{content:"\f239"}.fa-suitcase:before{content:"\f0f2"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-sun:before{content:"\f185"}.fa-superpowers:before{content:"\f2dd"}.fa-superscript:before{content:"\f12b"}.fa-supple:before{content:"\f3f9"}.fa-surprise:before{content:"\f5c2"}.fa-suse:before{content:"\f7d6"}.fa-swatchbook:before{content:"\f5c3"}.fa-swift:before{content:"\f8e1"}.fa-swimmer:before{content:"\f5c4"}.fa-swimming-pool:before{content:"\f5c5"}.fa-symfony:before{content:"\f83d"}.fa-synagogue:before{content:"\f69b"}.fa-sync:before{content:"\f021"}.fa-sync-alt:before{content:"\f2f1"}.fa-syringe:before{content:"\f48e"}.fa-table:before{content:"\f0ce"}.fa-table-tennis:before{content:"\f45d"}.fa-tablet:before{content:"\f10a"}.fa-tablet-alt:before{content:"\f3fa"}.fa-tablets:before{content:"\f490"}.fa-tachometer-alt:before{content:"\f3fd"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-tape:before{content:"\f4db"}.fa-tasks:before{content:"\f0ae"}.fa-taxi:before{content:"\f1ba"}.fa-teamspeak:before{content:"\f4f9"}.fa-teeth:before{content:"\f62e"}.fa-teeth-open:before{content:"\f62f"}.fa-telegram:before{content:"\f2c6"}.fa-telegram-plane:before{content:"\f3fe"}.fa-temperature-high:before{content:"\f769"}.fa-temperature-low:before{content:"\f76b"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-tenge:before{content:"\f7d7"}.fa-terminal:before{content:"\f120"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-th:before{content:"\f00a"}.fa-th-large:before{content:"\f009"}.fa-th-list:before{content:"\f00b"}.fa-the-red-yeti:before{content:"\f69d"}.fa-theater-masks:before{content:"\f630"}.fa-themeco:before{content:"\f5c6"}.fa-themeisle:before{content:"\f2b2"}.fa-thermometer:before{content:"\f491"}.fa-thermometer-empty:before{content:"\f2cb"}.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-think-peaks:before{content:"\f731"}.fa-thumbs-down:before{content:"\f165"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbtack:before{content:"\f08d"}.fa-ticket-alt:before{content:"\f3ff"}.fa-times:before{content:"\f00d"}.fa-times-circle:before{content:"\f057"}.fa-tint:before{content:"\f043"}.fa-tint-slash:before{content:"\f5c7"}.fa-tired:before{content:"\f5c8"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-toilet:before{content:"\f7d8"}.fa-toilet-paper:before{content:"\f71e"}.fa-toolbox:before{content:"\f552"}.fa-tools:before{content:"\f7d9"}.fa-tooth:before{content:"\f5c9"}.fa-torah:before{content:"\f6a0"}.fa-torii-gate:before{content:"\f6a1"}.fa-tractor:before{content:"\f722"}.fa-trade-federation:before{content:"\f513"}.fa-trademark:before{content:"\f25c"}.fa-traffic-light:before{content:"\f637"}.fa-trailer:before{content:"\f941"}.fa-train:before{content:"\f238"}.fa-tram:before{content:"\f7da"}.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-trash:before{content:"\f1f8"}.fa-trash-alt:before{content:"\f2ed"}.fa-trash-restore:before{content:"\f829"}.fa-trash-restore-alt:before{content:"\f82a"}.fa-tree:before{content:"\f1bb"}.fa-trello:before{content:"\f181"}.fa-tripadvisor:before{content:"\f262"}.fa-trophy:before{content:"\f091"}.fa-truck:before{content:"\f0d1"}.fa-truck-loading:before{content:"\f4de"}.fa-truck-monster:before{content:"\f63b"}.fa-truck-moving:before{content:"\f4df"}.fa-truck-pickup:before{content:"\f63c"}.fa-tshirt:before{content:"\f553"}.fa-tty:before{content:"\f1e4"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-tv:before{content:"\f26c"}.fa-twitch:before{content:"\f1e8"}.fa-twitter:before{content:"\f099"}.fa-twitter-square:before{content:"\f081"}.fa-typo3:before{content:"\f42b"}.fa-uber:before{content:"\f402"}.fa-ubuntu:before{content:"\f7df"}.fa-uikit:before{content:"\f403"}.fa-umbraco:before{content:"\f8e8"}.fa-umbrella:before{content:"\f0e9"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-underline:before{content:"\f0cd"}.fa-undo:before{content:"\f0e2"}.fa-undo-alt:before{content:"\f2ea"}.fa-uniregistry:before{content:"\f404"}.fa-unity:before{content:"\f949"}.fa-universal-access:before{content:"\f29a"}.fa-university:before{content:"\f19c"}.fa-unlink:before{content:"\f127"}.fa-unlock:before{content:"\f09c"}.fa-unlock-alt:before{content:"\f13e"}.fa-untappd:before{content:"\f405"}.fa-upload:before{content:"\f093"}.fa-ups:before{content:"\f7e0"}.fa-usb:before{content:"\f287"}.fa-user:before{content:"\f007"}.fa-user-alt:before{content:"\f406"}.fa-user-alt-slash:before{content:"\f4fa"}.fa-user-astronaut:before{content:"\f4fb"}.fa-user-check:before{content:"\f4fc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-clock:before{content:"\f4fd"}.fa-user-cog:before{content:"\f4fe"}.fa-user-edit:before{content:"\f4ff"}.fa-user-friends:before{content:"\f500"}.fa-user-graduate:before{content:"\f501"}.fa-user-injured:before{content:"\f728"}.fa-user-lock:before{content:"\f502"}.fa-user-md:before{content:"\f0f0"}.fa-user-minus:before{content:"\f503"}.fa-user-ninja:before{content:"\f504"}.fa-user-nurse:before{content:"\f82f"}.fa-user-plus:before{content:"\f234"}.fa-user-secret:before{content:"\f21b"}.fa-user-shield:before{content:"\f505"}.fa-user-slash:before{content:"\f506"}.fa-user-tag:before{content:"\f507"}.fa-user-tie:before{content:"\f508"}.fa-user-times:before{content:"\f235"}.fa-users:before{content:"\f0c0"}.fa-users-cog:before{content:"\f509"}.fa-usps:before{content:"\f7e1"}.fa-ussunnah:before{content:"\f407"}.fa-utensil-spoon:before{content:"\f2e5"}.fa-utensils:before{content:"\f2e7"}.fa-vaadin:before{content:"\f408"}.fa-vector-square:before{content:"\f5cb"}.fa-venus:before{content:"\f221"}.fa-venus-double:before{content:"\f226"}.fa-venus-mars:before{content:"\f228"}.fa-viacoin:before{content:"\f237"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-vial:before{content:"\f492"}.fa-vials:before{content:"\f493"}.fa-viber:before{content:"\f409"}.fa-video:before{content:"\f03d"}.fa-video-slash:before{content:"\f4e2"}.fa-vihara:before{content:"\f6a7"}.fa-vimeo:before{content:"\f40a"}.fa-vimeo-square:before{content:"\f194"}.fa-vimeo-v:before{content:"\f27d"}.fa-vine:before{content:"\f1ca"}.fa-vk:before{content:"\f189"}.fa-vnv:before{content:"\f40b"}.fa-voicemail:before{content:"\f897"}.fa-volleyball-ball:before{content:"\f45f"}.fa-volume-down:before{content:"\f027"}.fa-volume-mute:before{content:"\f6a9"}.fa-volume-off:before{content:"\f026"}.fa-volume-up:before{content:"\f028"}.fa-vote-yea:before{content:"\f772"}.fa-vr-cardboard:before{content:"\f729"}.fa-vuejs:before{content:"\f41f"}.fa-walking:before{content:"\f554"}.fa-wallet:before{content:"\f555"}.fa-warehouse:before{content:"\f494"}.fa-water:before{content:"\f773"}.fa-wave-square:before{content:"\f83e"}.fa-waze:before{content:"\f83f"}.fa-weebly:before{content:"\f5cc"}.fa-weibo:before{content:"\f18a"}.fa-weight:before{content:"\f496"}.fa-weight-hanging:before{content:"\f5cd"}.fa-weixin:before{content:"\f1d7"}.fa-whatsapp:before{content:"\f232"}.fa-whatsapp-square:before{content:"\f40c"}.fa-wheelchair:before{content:"\f193"}.fa-whmcs:before{content:"\f40d"}.fa-wifi:before{content:"\f1eb"}.fa-wikipedia-w:before{content:"\f266"}.fa-wind:before{content:"\f72e"}.fa-window-close:before{content:"\f410"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-windows:before{content:"\f17a"}.fa-wine-bottle:before{content:"\f72f"}.fa-wine-glass:before{content:"\f4e3"}.fa-wine-glass-alt:before{content:"\f5ce"}.fa-wix:before{content:"\f5cf"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-won-sign:before{content:"\f159"}.fa-wordpress:before{content:"\f19a"}.fa-wordpress-simple:before{content:"\f411"}.fa-wpbeginner:before{content:"\f297"}.fa-wpexplorer:before{content:"\f2de"}.fa-wpforms:before{content:"\f298"}.fa-wpressr:before{content:"\f3e4"}.fa-wrench:before{content:"\f0ad"}.fa-x-ray:before{content:"\f497"}.fa-xbox:before{content:"\f412"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-y-combinator:before{content:"\f23b"}.fa-yahoo:before{content:"\f19e"}.fa-yammer:before{content:"\f840"}.fa-yandex:before{content:"\f413"}.fa-yandex-international:before{content:"\f414"}.fa-yarn:before{content:"\f7e3"}.fa-yelp:before{content:"\f1e9"}.fa-yen-sign:before{content:"\f157"}.fa-yin-yang:before{content:"\f6ad"}.fa-yoast:before{content:"\f2b1"}.fa-youtube:before{content:"\f167"}.fa-youtube-square:before{content:"\f431"}.fa-zhihu:before{content:"\f63f"}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}@font-face{font-family:"Font Awesome 5 Brands";font-style:normal;font-weight:normal;font-display:auto;src:url(../fonts/fa-brands-400.eot);src:url(../fonts/fa-brands-400.eot?#iefix) format("embedded-opentype"),url(../fonts/fa-brands-400.woff2) format("woff2"),url(../fonts/fa-brands-400.woff) format("woff"),url(../fonts/fa-brands-400.ttf) format("truetype"),url(../fonts/fa-brands-400.svg#fontawesome) format("svg")}.fab{font-family:"Font Awesome 5 Brands"}@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:400;font-display:auto;src:url(../fonts/fa-regular-400.eot);src:url(../fonts/fa-regular-400.eot?#iefix) format("embedded-opentype"),url(../fonts/fa-regular-400.woff2) format("woff2"),url(../fonts/fa-regular-400.woff) format("woff"),url(../fonts/fa-regular-400.ttf) format("truetype"),url(../fonts/fa-regular-400.svg#fontawesome) format("svg")}.far{font-weight:400}@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:900;font-display:auto;src:url(../fonts/fa-solid-900.eot);src:url(../fonts/fa-solid-900.eot?#iefix) format("embedded-opentype"),url(../fonts/fa-solid-900.woff2) format("woff2"),url(../fonts/fa-solid-900.woff) format("woff"),url(../fonts/fa-solid-900.ttf) format("truetype"),url(../fonts/fa-solid-900.svg#fontawesome) format("svg")}.fa,.far,.fas{font-family:"Font Awesome 5 Free"}.fa,.fas{font-weight:900}
\ No newline at end of file
+.fa,.fab,.fad,.fal,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-.0667em}.fa-xs{font-size:.75em}.fa-sm{font-size:.875em}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:2.5em;padding-left:0}.fa-ul>li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:.08em solid #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s linear infinite;animation:fa-spin 2s linear infinite}.fa-pulse{-webkit-animation:fa-spin 1s steps(8) infinite;animation:fa-spin 1s steps(8) infinite}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical,.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}:root .fa-flip-both,:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{-webkit-filter:none;filter:none}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-500px:before{content:"\f26e"}.fa-accessible-icon:before{content:"\f368"}.fa-accusoft:before{content:"\f369"}.fa-acquisitions-incorporated:before{content:"\f6af"}.fa-ad:before{content:"\f641"}.fa-address-book:before{content:"\f2b9"}.fa-address-card:before{content:"\f2bb"}.fa-adjust:before{content:"\f042"}.fa-adn:before{content:"\f170"}.fa-adobe:before{content:"\f778"}.fa-adversal:before{content:"\f36a"}.fa-affiliatetheme:before{content:"\f36b"}.fa-air-freshener:before{content:"\f5d0"}.fa-airbnb:before{content:"\f834"}.fa-algolia:before{content:"\f36c"}.fa-align-center:before{content:"\f037"}.fa-align-justify:before{content:"\f039"}.fa-align-left:before{content:"\f036"}.fa-align-right:before{content:"\f038"}.fa-alipay:before{content:"\f642"}.fa-allergies:before{content:"\f461"}.fa-amazon:before{content:"\f270"}.fa-amazon-pay:before{content:"\f42c"}.fa-ambulance:before{content:"\f0f9"}.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-amilia:before{content:"\f36d"}.fa-anchor:before{content:"\f13d"}.fa-android:before{content:"\f17b"}.fa-angellist:before{content:"\f209"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-down:before{content:"\f107"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angry:before{content:"\f556"}.fa-angrycreative:before{content:"\f36e"}.fa-angular:before{content:"\f420"}.fa-ankh:before{content:"\f644"}.fa-app-store:before{content:"\f36f"}.fa-app-store-ios:before{content:"\f370"}.fa-apper:before{content:"\f371"}.fa-apple:before{content:"\f179"}.fa-apple-alt:before{content:"\f5d1"}.fa-apple-pay:before{content:"\f415"}.fa-archive:before{content:"\f187"}.fa-archway:before{content:"\f557"}.fa-arrow-alt-circle-down:before{content:"\f358"}.fa-arrow-alt-circle-left:before{content:"\f359"}.fa-arrow-alt-circle-right:before{content:"\f35a"}.fa-arrow-alt-circle-up:before{content:"\f35b"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-down:before{content:"\f063"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrows-alt:before{content:"\f0b2"}.fa-arrows-alt-h:before{content:"\f337"}.fa-arrows-alt-v:before{content:"\f338"}.fa-artstation:before{content:"\f77a"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asterisk:before{content:"\f069"}.fa-asymmetrik:before{content:"\f372"}.fa-at:before{content:"\f1fa"}.fa-atlas:before{content:"\f558"}.fa-atlassian:before{content:"\f77b"}.fa-atom:before{content:"\f5d2"}.fa-audible:before{content:"\f373"}.fa-audio-description:before{content:"\f29e"}.fa-autoprefixer:before{content:"\f41c"}.fa-avianex:before{content:"\f374"}.fa-aviato:before{content:"\f421"}.fa-award:before{content:"\f559"}.fa-aws:before{content:"\f375"}.fa-baby:before{content:"\f77c"}.fa-baby-carriage:before{content:"\f77d"}.fa-backspace:before{content:"\f55a"}.fa-backward:before{content:"\f04a"}.fa-bacon:before{content:"\f7e5"}.fa-bahai:before{content:"\f666"}.fa-balance-scale:before{content:"\f24e"}.fa-balance-scale-left:before{content:"\f515"}.fa-balance-scale-right:before{content:"\f516"}.fa-ban:before{content:"\f05e"}.fa-band-aid:before{content:"\f462"}.fa-bandcamp:before{content:"\f2d5"}.fa-barcode:before{content:"\f02a"}.fa-bars:before{content:"\f0c9"}.fa-baseball-ball:before{content:"\f433"}.fa-basketball-ball:before{content:"\f434"}.fa-bath:before{content:"\f2cd"}.fa-battery-empty:before{content:"\f244"}.fa-battery-full:before{content:"\f240"}.fa-battery-half:before{content:"\f242"}.fa-battery-quarter:before{content:"\f243"}.fa-battery-three-quarters:before{content:"\f241"}.fa-battle-net:before{content:"\f835"}.fa-bed:before{content:"\f236"}.fa-beer:before{content:"\f0fc"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-bell:before{content:"\f0f3"}.fa-bell-slash:before{content:"\f1f6"}.fa-bezier-curve:before{content:"\f55b"}.fa-bible:before{content:"\f647"}.fa-bicycle:before{content:"\f206"}.fa-biking:before{content:"\f84a"}.fa-bimobject:before{content:"\f378"}.fa-binoculars:before{content:"\f1e5"}.fa-biohazard:before{content:"\f780"}.fa-birthday-cake:before{content:"\f1fd"}.fa-bitbucket:before{content:"\f171"}.fa-bitcoin:before{content:"\f379"}.fa-bity:before{content:"\f37a"}.fa-black-tie:before{content:"\f27e"}.fa-blackberry:before{content:"\f37b"}.fa-blender:before{content:"\f517"}.fa-blender-phone:before{content:"\f6b6"}.fa-blind:before{content:"\f29d"}.fa-blog:before{content:"\f781"}.fa-blogger:before{content:"\f37c"}.fa-blogger-b:before{content:"\f37d"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-bold:before{content:"\f032"}.fa-bolt:before{content:"\f0e7"}.fa-bomb:before{content:"\f1e2"}.fa-bone:before{content:"\f5d7"}.fa-bong:before{content:"\f55c"}.fa-book:before{content:"\f02d"}.fa-book-dead:before{content:"\f6b7"}.fa-book-medical:before{content:"\f7e6"}.fa-book-open:before{content:"\f518"}.fa-book-reader:before{content:"\f5da"}.fa-bookmark:before{content:"\f02e"}.fa-bootstrap:before{content:"\f836"}.fa-border-all:before{content:"\f84c"}.fa-border-none:before{content:"\f850"}.fa-border-style:before{content:"\f853"}.fa-bowling-ball:before{content:"\f436"}.fa-box:before{content:"\f466"}.fa-box-open:before{content:"\f49e"}.fa-boxes:before{content:"\f468"}.fa-braille:before{content:"\f2a1"}.fa-brain:before{content:"\f5dc"}.fa-bread-slice:before{content:"\f7ec"}.fa-briefcase:before{content:"\f0b1"}.fa-briefcase-medical:before{content:"\f469"}.fa-broadcast-tower:before{content:"\f519"}.fa-broom:before{content:"\f51a"}.fa-brush:before{content:"\f55d"}.fa-btc:before{content:"\f15a"}.fa-buffer:before{content:"\f837"}.fa-bug:before{content:"\f188"}.fa-building:before{content:"\f1ad"}.fa-bullhorn:before{content:"\f0a1"}.fa-bullseye:before{content:"\f140"}.fa-burn:before{content:"\f46a"}.fa-buromobelexperte:before{content:"\f37f"}.fa-bus:before{content:"\f207"}.fa-bus-alt:before{content:"\f55e"}.fa-business-time:before{content:"\f64a"}.fa-buy-n-large:before{content:"\f8a6"}.fa-buysellads:before{content:"\f20d"}.fa-calculator:before{content:"\f1ec"}.fa-calendar:before{content:"\f133"}.fa-calendar-alt:before{content:"\f073"}.fa-calendar-check:before{content:"\f274"}.fa-calendar-day:before{content:"\f783"}.fa-calendar-minus:before{content:"\f272"}.fa-calendar-plus:before{content:"\f271"}.fa-calendar-times:before{content:"\f273"}.fa-calendar-week:before{content:"\f784"}.fa-camera:before{content:"\f030"}.fa-camera-retro:before{content:"\f083"}.fa-campground:before{content:"\f6bb"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-candy-cane:before{content:"\f786"}.fa-cannabis:before{content:"\f55f"}.fa-capsules:before{content:"\f46b"}.fa-car:before{content:"\f1b9"}.fa-car-alt:before{content:"\f5de"}.fa-car-battery:before{content:"\f5df"}.fa-car-crash:before{content:"\f5e1"}.fa-car-side:before{content:"\f5e4"}.fa-caravan:before{content:"\f8ff"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-caret-square-down:before{content:"\f150"}.fa-caret-square-left:before{content:"\f191"}.fa-caret-square-right:before{content:"\f152"}.fa-caret-square-up:before{content:"\f151"}.fa-caret-up:before{content:"\f0d8"}.fa-carrot:before{content:"\f787"}.fa-cart-arrow-down:before{content:"\f218"}.fa-cart-plus:before{content:"\f217"}.fa-cash-register:before{content:"\f788"}.fa-cat:before{content:"\f6be"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-apple-pay:before{content:"\f416"}.fa-cc-diners-club:before{content:"\f24c"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-cc-visa:before{content:"\f1f0"}.fa-centercode:before{content:"\f380"}.fa-centos:before{content:"\f789"}.fa-certificate:before{content:"\f0a3"}.fa-chair:before{content:"\f6c0"}.fa-chalkboard:before{content:"\f51b"}.fa-chalkboard-teacher:before{content:"\f51c"}.fa-charging-station:before{content:"\f5e7"}.fa-chart-area:before{content:"\f1fe"}.fa-chart-bar:before{content:"\f080"}.fa-chart-line:before{content:"\f201"}.fa-chart-pie:before{content:"\f200"}.fa-check:before{content:"\f00c"}.fa-check-circle:before{content:"\f058"}.fa-check-double:before{content:"\f560"}.fa-check-square:before{content:"\f14a"}.fa-cheese:before{content:"\f7ef"}.fa-chess:before{content:"\f439"}.fa-chess-bishop:before{content:"\f43a"}.fa-chess-board:before{content:"\f43c"}.fa-chess-king:before{content:"\f43f"}.fa-chess-knight:before{content:"\f441"}.fa-chess-pawn:before{content:"\f443"}.fa-chess-queen:before{content:"\f445"}.fa-chess-rook:before{content:"\f447"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-down:before{content:"\f078"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-chevron-up:before{content:"\f077"}.fa-child:before{content:"\f1ae"}.fa-chrome:before{content:"\f268"}.fa-chromecast:before{content:"\f838"}.fa-church:before{content:"\f51d"}.fa-circle:before{content:"\f111"}.fa-circle-notch:before{content:"\f1ce"}.fa-city:before{content:"\f64f"}.fa-clinic-medical:before{content:"\f7f2"}.fa-clipboard:before{content:"\f328"}.fa-clipboard-check:before{content:"\f46c"}.fa-clipboard-list:before{content:"\f46d"}.fa-clock:before{content:"\f017"}.fa-clone:before{content:"\f24d"}.fa-closed-captioning:before{content:"\f20a"}.fa-cloud:before{content:"\f0c2"}.fa-cloud-download-alt:before{content:"\f381"}.fa-cloud-meatball:before{content:"\f73b"}.fa-cloud-moon:before{content:"\f6c3"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-cloud-rain:before{content:"\f73d"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-cloud-sun:before{content:"\f6c4"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-cloud-upload-alt:before{content:"\f382"}.fa-cloudscale:before{content:"\f383"}.fa-cloudsmith:before{content:"\f384"}.fa-cloudversify:before{content:"\f385"}.fa-cocktail:before{content:"\f561"}.fa-code:before{content:"\f121"}.fa-code-branch:before{content:"\f126"}.fa-codepen:before{content:"\f1cb"}.fa-codiepie:before{content:"\f284"}.fa-coffee:before{content:"\f0f4"}.fa-cog:before{content:"\f013"}.fa-cogs:before{content:"\f085"}.fa-coins:before{content:"\f51e"}.fa-columns:before{content:"\f0db"}.fa-comment:before{content:"\f075"}.fa-comment-alt:before{content:"\f27a"}.fa-comment-dollar:before{content:"\f651"}.fa-comment-dots:before{content:"\f4ad"}.fa-comment-medical:before{content:"\f7f5"}.fa-comment-slash:before{content:"\f4b3"}.fa-comments:before{content:"\f086"}.fa-comments-dollar:before{content:"\f653"}.fa-compact-disc:before{content:"\f51f"}.fa-compass:before{content:"\f14e"}.fa-compress:before{content:"\f066"}.fa-compress-alt:before{content:"\f422"}.fa-compress-arrows-alt:before{content:"\f78c"}.fa-concierge-bell:before{content:"\f562"}.fa-confluence:before{content:"\f78d"}.fa-connectdevelop:before{content:"\f20e"}.fa-contao:before{content:"\f26d"}.fa-cookie:before{content:"\f563"}.fa-cookie-bite:before{content:"\f564"}.fa-copy:before{content:"\f0c5"}.fa-copyright:before{content:"\f1f9"}.fa-cotton-bureau:before{content:"\f89e"}.fa-couch:before{content:"\f4b8"}.fa-cpanel:before{content:"\f388"}.fa-creative-commons:before{content:"\f25e"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-credit-card:before{content:"\f09d"}.fa-critical-role:before{content:"\f6c9"}.fa-crop:before{content:"\f125"}.fa-crop-alt:before{content:"\f565"}.fa-cross:before{content:"\f654"}.fa-crosshairs:before{content:"\f05b"}.fa-crow:before{content:"\f520"}.fa-crown:before{content:"\f521"}.fa-crutch:before{content:"\f7f7"}.fa-css3:before{content:"\f13c"}.fa-css3-alt:before{content:"\f38b"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-cut:before{content:"\f0c4"}.fa-cuttlefish:before{content:"\f38c"}.fa-d-and-d:before{content:"\f38d"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-dashcube:before{content:"\f210"}.fa-database:before{content:"\f1c0"}.fa-deaf:before{content:"\f2a4"}.fa-delicious:before{content:"\f1a5"}.fa-democrat:before{content:"\f747"}.fa-deploydog:before{content:"\f38e"}.fa-deskpro:before{content:"\f38f"}.fa-desktop:before{content:"\f108"}.fa-dev:before{content:"\f6cc"}.fa-deviantart:before{content:"\f1bd"}.fa-dharmachakra:before{content:"\f655"}.fa-dhl:before{content:"\f790"}.fa-diagnoses:before{content:"\f470"}.fa-diaspora:before{content:"\f791"}.fa-dice:before{content:"\f522"}.fa-dice-d20:before{content:"\f6cf"}.fa-dice-d6:before{content:"\f6d1"}.fa-dice-five:before{content:"\f523"}.fa-dice-four:before{content:"\f524"}.fa-dice-one:before{content:"\f525"}.fa-dice-six:before{content:"\f526"}.fa-dice-three:before{content:"\f527"}.fa-dice-two:before{content:"\f528"}.fa-digg:before{content:"\f1a6"}.fa-digital-ocean:before{content:"\f391"}.fa-digital-tachograph:before{content:"\f566"}.fa-directions:before{content:"\f5eb"}.fa-discord:before{content:"\f392"}.fa-discourse:before{content:"\f393"}.fa-divide:before{content:"\f529"}.fa-dizzy:before{content:"\f567"}.fa-dna:before{content:"\f471"}.fa-dochub:before{content:"\f394"}.fa-docker:before{content:"\f395"}.fa-dog:before{content:"\f6d3"}.fa-dollar-sign:before{content:"\f155"}.fa-dolly:before{content:"\f472"}.fa-dolly-flatbed:before{content:"\f474"}.fa-donate:before{content:"\f4b9"}.fa-door-closed:before{content:"\f52a"}.fa-door-open:before{content:"\f52b"}.fa-dot-circle:before{content:"\f192"}.fa-dove:before{content:"\f4ba"}.fa-download:before{content:"\f019"}.fa-draft2digital:before{content:"\f396"}.fa-drafting-compass:before{content:"\f568"}.fa-dragon:before{content:"\f6d5"}.fa-draw-polygon:before{content:"\f5ee"}.fa-dribbble:before{content:"\f17d"}.fa-dribbble-square:before{content:"\f397"}.fa-dropbox:before{content:"\f16b"}.fa-drum:before{content:"\f569"}.fa-drum-steelpan:before{content:"\f56a"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-drupal:before{content:"\f1a9"}.fa-dumbbell:before{content:"\f44b"}.fa-dumpster:before{content:"\f793"}.fa-dumpster-fire:before{content:"\f794"}.fa-dungeon:before{content:"\f6d9"}.fa-dyalog:before{content:"\f399"}.fa-earlybirds:before{content:"\f39a"}.fa-ebay:before{content:"\f4f4"}.fa-edge:before{content:"\f282"}.fa-edit:before{content:"\f044"}.fa-egg:before{content:"\f7fb"}.fa-eject:before{content:"\f052"}.fa-elementor:before{content:"\f430"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-ello:before{content:"\f5f1"}.fa-ember:before{content:"\f423"}.fa-empire:before{content:"\f1d1"}.fa-envelope:before{content:"\f0e0"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-text:before{content:"\f658"}.fa-envelope-square:before{content:"\f199"}.fa-envira:before{content:"\f299"}.fa-equals:before{content:"\f52c"}.fa-eraser:before{content:"\f12d"}.fa-erlang:before{content:"\f39d"}.fa-ethereum:before{content:"\f42e"}.fa-ethernet:before{content:"\f796"}.fa-etsy:before{content:"\f2d7"}.fa-euro-sign:before{content:"\f153"}.fa-evernote:before{content:"\f839"}.fa-exchange-alt:before{content:"\f362"}.fa-exclamation:before{content:"\f12a"}.fa-exclamation-circle:before{content:"\f06a"}.fa-exclamation-triangle:before{content:"\f071"}.fa-expand:before{content:"\f065"}.fa-expand-alt:before{content:"\f424"}.fa-expand-arrows-alt:before{content:"\f31e"}.fa-expeditedssl:before{content:"\f23e"}.fa-external-link-alt:before{content:"\f35d"}.fa-external-link-square-alt:before{content:"\f360"}.fa-eye:before{content:"\f06e"}.fa-eye-dropper:before{content:"\f1fb"}.fa-eye-slash:before{content:"\f070"}.fa-facebook:before{content:"\f09a"}.fa-facebook-f:before{content:"\f39e"}.fa-facebook-messenger:before{content:"\f39f"}.fa-facebook-square:before{content:"\f082"}.fa-fan:before{content:"\f863"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-fast-backward:before{content:"\f049"}.fa-fast-forward:before{content:"\f050"}.fa-fax:before{content:"\f1ac"}.fa-feather:before{content:"\f52d"}.fa-feather-alt:before{content:"\f56b"}.fa-fedex:before{content:"\f797"}.fa-fedora:before{content:"\f798"}.fa-female:before{content:"\f182"}.fa-fighter-jet:before{content:"\f0fb"}.fa-figma:before{content:"\f799"}.fa-file:before{content:"\f15b"}.fa-file-alt:before{content:"\f15c"}.fa-file-archive:before{content:"\f1c6"}.fa-file-audio:before{content:"\f1c7"}.fa-file-code:before{content:"\f1c9"}.fa-file-contract:before{content:"\f56c"}.fa-file-csv:before{content:"\f6dd"}.fa-file-download:before{content:"\f56d"}.fa-file-excel:before{content:"\f1c3"}.fa-file-export:before{content:"\f56e"}.fa-file-image:before{content:"\f1c5"}.fa-file-import:before{content:"\f56f"}.fa-file-invoice:before{content:"\f570"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-file-medical:before{content:"\f477"}.fa-file-medical-alt:before{content:"\f478"}.fa-file-pdf:before{content:"\f1c1"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-file-prescription:before{content:"\f572"}.fa-file-signature:before{content:"\f573"}.fa-file-upload:before{content:"\f574"}.fa-file-video:before{content:"\f1c8"}.fa-file-word:before{content:"\f1c2"}.fa-fill:before{content:"\f575"}.fa-fill-drip:before{content:"\f576"}.fa-film:before{content:"\f008"}.fa-filter:before{content:"\f0b0"}.fa-fingerprint:before{content:"\f577"}.fa-fire:before{content:"\f06d"}.fa-fire-alt:before{content:"\f7e4"}.fa-fire-extinguisher:before{content:"\f134"}.fa-firefox:before{content:"\f269"}.fa-firefox-browser:before{content:"\f907"}.fa-first-aid:before{content:"\f479"}.fa-first-order:before{content:"\f2b0"}.fa-first-order-alt:before{content:"\f50a"}.fa-firstdraft:before{content:"\f3a1"}.fa-fish:before{content:"\f578"}.fa-fist-raised:before{content:"\f6de"}.fa-flag:before{content:"\f024"}.fa-flag-checkered:before{content:"\f11e"}.fa-flag-usa:before{content:"\f74d"}.fa-flask:before{content:"\f0c3"}.fa-flickr:before{content:"\f16e"}.fa-flipboard:before{content:"\f44d"}.fa-flushed:before{content:"\f579"}.fa-fly:before{content:"\f417"}.fa-folder:before{content:"\f07b"}.fa-folder-minus:before{content:"\f65d"}.fa-folder-open:before{content:"\f07c"}.fa-folder-plus:before{content:"\f65e"}.fa-font:before{content:"\f031"}.fa-font-awesome:before{content:"\f2b4"}.fa-font-awesome-alt:before{content:"\f35c"}.fa-font-awesome-flag:before{content:"\f425"}.fa-font-awesome-logo-full:before{content:"\f4e6"}.fa-fonticons:before{content:"\f280"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-football-ball:before{content:"\f44e"}.fa-fort-awesome:before{content:"\f286"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-forumbee:before{content:"\f211"}.fa-forward:before{content:"\f04e"}.fa-foursquare:before{content:"\f180"}.fa-free-code-camp:before{content:"\f2c5"}.fa-freebsd:before{content:"\f3a4"}.fa-frog:before{content:"\f52e"}.fa-frown:before{content:"\f119"}.fa-frown-open:before{content:"\f57a"}.fa-fulcrum:before{content:"\f50b"}.fa-funnel-dollar:before{content:"\f662"}.fa-futbol:before{content:"\f1e3"}.fa-galactic-republic:before{content:"\f50c"}.fa-galactic-senate:before{content:"\f50d"}.fa-gamepad:before{content:"\f11b"}.fa-gas-pump:before{content:"\f52f"}.fa-gavel:before{content:"\f0e3"}.fa-gem:before{content:"\f3a5"}.fa-genderless:before{content:"\f22d"}.fa-get-pocket:before{content:"\f265"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-ghost:before{content:"\f6e2"}.fa-gift:before{content:"\f06b"}.fa-gifts:before{content:"\f79c"}.fa-git:before{content:"\f1d3"}.fa-git-alt:before{content:"\f841"}.fa-git-square:before{content:"\f1d2"}.fa-github:before{content:"\f09b"}.fa-github-alt:before{content:"\f113"}.fa-github-square:before{content:"\f092"}.fa-gitkraken:before{content:"\f3a6"}.fa-gitlab:before{content:"\f296"}.fa-gitter:before{content:"\f426"}.fa-glass-cheers:before{content:"\f79f"}.fa-glass-martini:before{content:"\f000"}.fa-glass-martini-alt:before{content:"\f57b"}.fa-glass-whiskey:before{content:"\f7a0"}.fa-glasses:before{content:"\f530"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-globe:before{content:"\f0ac"}.fa-globe-africa:before{content:"\f57c"}.fa-globe-americas:before{content:"\f57d"}.fa-globe-asia:before{content:"\f57e"}.fa-globe-europe:before{content:"\f7a2"}.fa-gofore:before{content:"\f3a7"}.fa-golf-ball:before{content:"\f450"}.fa-goodreads:before{content:"\f3a8"}.fa-goodreads-g:before{content:"\f3a9"}.fa-google:before{content:"\f1a0"}.fa-google-drive:before{content:"\f3aa"}.fa-google-play:before{content:"\f3ab"}.fa-google-plus:before{content:"\f2b3"}.fa-google-plus-g:before{content:"\f0d5"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-wallet:before{content:"\f1ee"}.fa-gopuram:before{content:"\f664"}.fa-graduation-cap:before{content:"\f19d"}.fa-gratipay:before{content:"\f184"}.fa-grav:before{content:"\f2d6"}.fa-greater-than:before{content:"\f531"}.fa-greater-than-equal:before{content:"\f532"}.fa-grimace:before{content:"\f57f"}.fa-grin:before{content:"\f580"}.fa-grin-alt:before{content:"\f581"}.fa-grin-beam:before{content:"\f582"}.fa-grin-beam-sweat:before{content:"\f583"}.fa-grin-hearts:before{content:"\f584"}.fa-grin-squint:before{content:"\f585"}.fa-grin-squint-tears:before{content:"\f586"}.fa-grin-stars:before{content:"\f587"}.fa-grin-tears:before{content:"\f588"}.fa-grin-tongue:before{content:"\f589"}.fa-grin-tongue-squint:before{content:"\f58a"}.fa-grin-tongue-wink:before{content:"\f58b"}.fa-grin-wink:before{content:"\f58c"}.fa-grip-horizontal:before{content:"\f58d"}.fa-grip-lines:before{content:"\f7a4"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-grip-vertical:before{content:"\f58e"}.fa-gripfire:before{content:"\f3ac"}.fa-grunt:before{content:"\f3ad"}.fa-guitar:before{content:"\f7a6"}.fa-gulp:before{content:"\f3ae"}.fa-h-square:before{content:"\f0fd"}.fa-hacker-news:before{content:"\f1d4"}.fa-hacker-news-square:before{content:"\f3af"}.fa-hackerrank:before{content:"\f5f7"}.fa-hamburger:before{content:"\f805"}.fa-hammer:before{content:"\f6e3"}.fa-hamsa:before{content:"\f665"}.fa-hand-holding:before{content:"\f4bd"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-hand-holding-usd:before{content:"\f4c0"}.fa-hand-lizard:before{content:"\f258"}.fa-hand-middle-finger:before{content:"\f806"}.fa-hand-paper:before{content:"\f256"}.fa-hand-peace:before{content:"\f25b"}.fa-hand-point-down:before{content:"\f0a7"}.fa-hand-point-left:before{content:"\f0a5"}.fa-hand-point-right:before{content:"\f0a4"}.fa-hand-point-up:before{content:"\f0a6"}.fa-hand-pointer:before{content:"\f25a"}.fa-hand-rock:before{content:"\f255"}.fa-hand-scissors:before{content:"\f257"}.fa-hand-spock:before{content:"\f259"}.fa-hands:before{content:"\f4c2"}.fa-hands-helping:before{content:"\f4c4"}.fa-handshake:before{content:"\f2b5"}.fa-hanukiah:before{content:"\f6e6"}.fa-hard-hat:before{content:"\f807"}.fa-hashtag:before{content:"\f292"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-hat-wizard:before{content:"\f6e8"}.fa-hdd:before{content:"\f0a0"}.fa-heading:before{content:"\f1dc"}.fa-headphones:before{content:"\f025"}.fa-headphones-alt:before{content:"\f58f"}.fa-headset:before{content:"\f590"}.fa-heart:before{content:"\f004"}.fa-heart-broken:before{content:"\f7a9"}.fa-heartbeat:before{content:"\f21e"}.fa-helicopter:before{content:"\f533"}.fa-highlighter:before{content:"\f591"}.fa-hiking:before{content:"\f6ec"}.fa-hippo:before{content:"\f6ed"}.fa-hips:before{content:"\f452"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-history:before{content:"\f1da"}.fa-hockey-puck:before{content:"\f453"}.fa-holly-berry:before{content:"\f7aa"}.fa-home:before{content:"\f015"}.fa-hooli:before{content:"\f427"}.fa-hornbill:before{content:"\f592"}.fa-horse:before{content:"\f6f0"}.fa-horse-head:before{content:"\f7ab"}.fa-hospital:before{content:"\f0f8"}.fa-hospital-alt:before{content:"\f47d"}.fa-hospital-symbol:before{content:"\f47e"}.fa-hot-tub:before{content:"\f593"}.fa-hotdog:before{content:"\f80f"}.fa-hotel:before{content:"\f594"}.fa-hotjar:before{content:"\f3b1"}.fa-hourglass:before{content:"\f254"}.fa-hourglass-end:before{content:"\f253"}.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-start:before{content:"\f251"}.fa-house-damage:before{content:"\f6f1"}.fa-houzz:before{content:"\f27c"}.fa-hryvnia:before{content:"\f6f2"}.fa-html5:before{content:"\f13b"}.fa-hubspot:before{content:"\f3b2"}.fa-i-cursor:before{content:"\f246"}.fa-ice-cream:before{content:"\f810"}.fa-icicles:before{content:"\f7ad"}.fa-icons:before{content:"\f86d"}.fa-id-badge:before{content:"\f2c1"}.fa-id-card:before{content:"\f2c2"}.fa-id-card-alt:before{content:"\f47f"}.fa-ideal:before{content:"\f913"}.fa-igloo:before{content:"\f7ae"}.fa-image:before{content:"\f03e"}.fa-images:before{content:"\f302"}.fa-imdb:before{content:"\f2d8"}.fa-inbox:before{content:"\f01c"}.fa-indent:before{content:"\f03c"}.fa-industry:before{content:"\f275"}.fa-infinity:before{content:"\f534"}.fa-info:before{content:"\f129"}.fa-info-circle:before{content:"\f05a"}.fa-instagram:before{content:"\f16d"}.fa-intercom:before{content:"\f7af"}.fa-internet-explorer:before{content:"\f26b"}.fa-invision:before{content:"\f7b0"}.fa-ioxhost:before{content:"\f208"}.fa-italic:before{content:"\f033"}.fa-itch-io:before{content:"\f83a"}.fa-itunes:before{content:"\f3b4"}.fa-itunes-note:before{content:"\f3b5"}.fa-java:before{content:"\f4e4"}.fa-jedi:before{content:"\f669"}.fa-jedi-order:before{content:"\f50e"}.fa-jenkins:before{content:"\f3b6"}.fa-jira:before{content:"\f7b1"}.fa-joget:before{content:"\f3b7"}.fa-joint:before{content:"\f595"}.fa-joomla:before{content:"\f1aa"}.fa-journal-whills:before{content:"\f66a"}.fa-js:before{content:"\f3b8"}.fa-js-square:before{content:"\f3b9"}.fa-jsfiddle:before{content:"\f1cc"}.fa-kaaba:before{content:"\f66b"}.fa-kaggle:before{content:"\f5fa"}.fa-key:before{content:"\f084"}.fa-keybase:before{content:"\f4f5"}.fa-keyboard:before{content:"\f11c"}.fa-keycdn:before{content:"\f3ba"}.fa-khanda:before{content:"\f66d"}.fa-kickstarter:before{content:"\f3bb"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-kiss:before{content:"\f596"}.fa-kiss-beam:before{content:"\f597"}.fa-kiss-wink-heart:before{content:"\f598"}.fa-kiwi-bird:before{content:"\f535"}.fa-korvue:before{content:"\f42f"}.fa-landmark:before{content:"\f66f"}.fa-language:before{content:"\f1ab"}.fa-laptop:before{content:"\f109"}.fa-laptop-code:before{content:"\f5fc"}.fa-laptop-medical:before{content:"\f812"}.fa-laravel:before{content:"\f3bd"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-laugh:before{content:"\f599"}.fa-laugh-beam:before{content:"\f59a"}.fa-laugh-squint:before{content:"\f59b"}.fa-laugh-wink:before{content:"\f59c"}.fa-layer-group:before{content:"\f5fd"}.fa-leaf:before{content:"\f06c"}.fa-leanpub:before{content:"\f212"}.fa-lemon:before{content:"\f094"}.fa-less:before{content:"\f41d"}.fa-less-than:before{content:"\f536"}.fa-less-than-equal:before{content:"\f537"}.fa-level-down-alt:before{content:"\f3be"}.fa-level-up-alt:before{content:"\f3bf"}.fa-life-ring:before{content:"\f1cd"}.fa-lightbulb:before{content:"\f0eb"}.fa-line:before{content:"\f3c0"}.fa-link:before{content:"\f0c1"}.fa-linkedin:before{content:"\f08c"}.fa-linkedin-in:before{content:"\f0e1"}.fa-linode:before{content:"\f2b8"}.fa-linux:before{content:"\f17c"}.fa-lira-sign:before{content:"\f195"}.fa-list:before{content:"\f03a"}.fa-list-alt:before{content:"\f022"}.fa-list-ol:before{content:"\f0cb"}.fa-list-ul:before{content:"\f0ca"}.fa-location-arrow:before{content:"\f124"}.fa-lock:before{content:"\f023"}.fa-lock-open:before{content:"\f3c1"}.fa-long-arrow-alt-down:before{content:"\f309"}.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-long-arrow-alt-right:before{content:"\f30b"}.fa-long-arrow-alt-up:before{content:"\f30c"}.fa-low-vision:before{content:"\f2a8"}.fa-luggage-cart:before{content:"\f59d"}.fa-lyft:before{content:"\f3c3"}.fa-magento:before{content:"\f3c4"}.fa-magic:before{content:"\f0d0"}.fa-magnet:before{content:"\f076"}.fa-mail-bulk:before{content:"\f674"}.fa-mailchimp:before{content:"\f59e"}.fa-male:before{content:"\f183"}.fa-mandalorian:before{content:"\f50f"}.fa-map:before{content:"\f279"}.fa-map-marked:before{content:"\f59f"}.fa-map-marked-alt:before{content:"\f5a0"}.fa-map-marker:before{content:"\f041"}.fa-map-marker-alt:before{content:"\f3c5"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-markdown:before{content:"\f60f"}.fa-marker:before{content:"\f5a1"}.fa-mars:before{content:"\f222"}.fa-mars-double:before{content:"\f227"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mask:before{content:"\f6fa"}.fa-mastodon:before{content:"\f4f6"}.fa-maxcdn:before{content:"\f136"}.fa-mdb:before{content:"\f8ca"}.fa-medal:before{content:"\f5a2"}.fa-medapps:before{content:"\f3c6"}.fa-medium:before{content:"\f23a"}.fa-medium-m:before{content:"\f3c7"}.fa-medkit:before{content:"\f0fa"}.fa-medrt:before{content:"\f3c8"}.fa-meetup:before{content:"\f2e0"}.fa-megaport:before{content:"\f5a3"}.fa-meh:before{content:"\f11a"}.fa-meh-blank:before{content:"\f5a4"}.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-memory:before{content:"\f538"}.fa-mendeley:before{content:"\f7b3"}.fa-menorah:before{content:"\f676"}.fa-mercury:before{content:"\f223"}.fa-meteor:before{content:"\f753"}.fa-microblog:before{content:"\f91a"}.fa-microchip:before{content:"\f2db"}.fa-microphone:before{content:"\f130"}.fa-microphone-alt:before{content:"\f3c9"}.fa-microphone-alt-slash:before{content:"\f539"}.fa-microphone-slash:before{content:"\f131"}.fa-microscope:before{content:"\f610"}.fa-microsoft:before{content:"\f3ca"}.fa-minus:before{content:"\f068"}.fa-minus-circle:before{content:"\f056"}.fa-minus-square:before{content:"\f146"}.fa-mitten:before{content:"\f7b5"}.fa-mix:before{content:"\f3cb"}.fa-mixcloud:before{content:"\f289"}.fa-mizuni:before{content:"\f3cc"}.fa-mobile:before{content:"\f10b"}.fa-mobile-alt:before{content:"\f3cd"}.fa-modx:before{content:"\f285"}.fa-monero:before{content:"\f3d0"}.fa-money-bill:before{content:"\f0d6"}.fa-money-bill-alt:before{content:"\f3d1"}.fa-money-bill-wave:before{content:"\f53a"}.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-money-check:before{content:"\f53c"}.fa-money-check-alt:before{content:"\f53d"}.fa-monument:before{content:"\f5a6"}.fa-moon:before{content:"\f186"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-mosque:before{content:"\f678"}.fa-motorcycle:before{content:"\f21c"}.fa-mountain:before{content:"\f6fc"}.fa-mouse:before{content:"\f8cc"}.fa-mouse-pointer:before{content:"\f245"}.fa-mug-hot:before{content:"\f7b6"}.fa-music:before{content:"\f001"}.fa-napster:before{content:"\f3d2"}.fa-neos:before{content:"\f612"}.fa-network-wired:before{content:"\f6ff"}.fa-neuter:before{content:"\f22c"}.fa-newspaper:before{content:"\f1ea"}.fa-nimblr:before{content:"\f5a8"}.fa-node:before{content:"\f419"}.fa-node-js:before{content:"\f3d3"}.fa-not-equal:before{content:"\f53e"}.fa-notes-medical:before{content:"\f481"}.fa-npm:before{content:"\f3d4"}.fa-ns8:before{content:"\f3d5"}.fa-nutritionix:before{content:"\f3d6"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-oil-can:before{content:"\f613"}.fa-old-republic:before{content:"\f510"}.fa-om:before{content:"\f679"}.fa-opencart:before{content:"\f23d"}.fa-openid:before{content:"\f19b"}.fa-opera:before{content:"\f26a"}.fa-optin-monster:before{content:"\f23c"}.fa-orcid:before{content:"\f8d2"}.fa-osi:before{content:"\f41a"}.fa-otter:before{content:"\f700"}.fa-outdent:before{content:"\f03b"}.fa-page4:before{content:"\f3d7"}.fa-pagelines:before{content:"\f18c"}.fa-pager:before{content:"\f815"}.fa-paint-brush:before{content:"\f1fc"}.fa-paint-roller:before{content:"\f5aa"}.fa-palette:before{content:"\f53f"}.fa-palfed:before{content:"\f3d8"}.fa-pallet:before{content:"\f482"}.fa-paper-plane:before{content:"\f1d8"}.fa-paperclip:before{content:"\f0c6"}.fa-parachute-box:before{content:"\f4cd"}.fa-paragraph:before{content:"\f1dd"}.fa-parking:before{content:"\f540"}.fa-passport:before{content:"\f5ab"}.fa-pastafarianism:before{content:"\f67b"}.fa-paste:before{content:"\f0ea"}.fa-patreon:before{content:"\f3d9"}.fa-pause:before{content:"\f04c"}.fa-pause-circle:before{content:"\f28b"}.fa-paw:before{content:"\f1b0"}.fa-paypal:before{content:"\f1ed"}.fa-peace:before{content:"\f67c"}.fa-pen:before{content:"\f304"}.fa-pen-alt:before{content:"\f305"}.fa-pen-fancy:before{content:"\f5ac"}.fa-pen-nib:before{content:"\f5ad"}.fa-pen-square:before{content:"\f14b"}.fa-pencil-alt:before{content:"\f303"}.fa-pencil-ruler:before{content:"\f5ae"}.fa-penny-arcade:before{content:"\f704"}.fa-people-carry:before{content:"\f4ce"}.fa-pepper-hot:before{content:"\f816"}.fa-percent:before{content:"\f295"}.fa-percentage:before{content:"\f541"}.fa-periscope:before{content:"\f3da"}.fa-person-booth:before{content:"\f756"}.fa-phabricator:before{content:"\f3db"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-phoenix-squadron:before{content:"\f511"}.fa-phone:before{content:"\f095"}.fa-phone-alt:before{content:"\f879"}.fa-phone-slash:before{content:"\f3dd"}.fa-phone-square:before{content:"\f098"}.fa-phone-square-alt:before{content:"\f87b"}.fa-phone-volume:before{content:"\f2a0"}.fa-photo-video:before{content:"\f87c"}.fa-php:before{content:"\f457"}.fa-pied-piper:before{content:"\f2ae"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-square:before{content:"\f91e"}.fa-piggy-bank:before{content:"\f4d3"}.fa-pills:before{content:"\f484"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-p:before{content:"\f231"}.fa-pinterest-square:before{content:"\f0d3"}.fa-pizza-slice:before{content:"\f818"}.fa-place-of-worship:before{content:"\f67f"}.fa-plane:before{content:"\f072"}.fa-plane-arrival:before{content:"\f5af"}.fa-plane-departure:before{content:"\f5b0"}.fa-play:before{content:"\f04b"}.fa-play-circle:before{content:"\f144"}.fa-playstation:before{content:"\f3df"}.fa-plug:before{content:"\f1e6"}.fa-plus:before{content:"\f067"}.fa-plus-circle:before{content:"\f055"}.fa-plus-square:before{content:"\f0fe"}.fa-podcast:before{content:"\f2ce"}.fa-poll:before{content:"\f681"}.fa-poll-h:before{content:"\f682"}.fa-poo:before{content:"\f2fe"}.fa-poo-storm:before{content:"\f75a"}.fa-poop:before{content:"\f619"}.fa-portrait:before{content:"\f3e0"}.fa-pound-sign:before{content:"\f154"}.fa-power-off:before{content:"\f011"}.fa-pray:before{content:"\f683"}.fa-praying-hands:before{content:"\f684"}.fa-prescription:before{content:"\f5b1"}.fa-prescription-bottle:before{content:"\f485"}.fa-prescription-bottle-alt:before{content:"\f486"}.fa-print:before{content:"\f02f"}.fa-procedures:before{content:"\f487"}.fa-product-hunt:before{content:"\f288"}.fa-project-diagram:before{content:"\f542"}.fa-pushed:before{content:"\f3e1"}.fa-puzzle-piece:before{content:"\f12e"}.fa-python:before{content:"\f3e2"}.fa-qq:before{content:"\f1d6"}.fa-qrcode:before{content:"\f029"}.fa-question:before{content:"\f128"}.fa-question-circle:before{content:"\f059"}.fa-quidditch:before{content:"\f458"}.fa-quinscape:before{content:"\f459"}.fa-quora:before{content:"\f2c4"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-quran:before{content:"\f687"}.fa-r-project:before{content:"\f4f7"}.fa-radiation:before{content:"\f7b9"}.fa-radiation-alt:before{content:"\f7ba"}.fa-rainbow:before{content:"\f75b"}.fa-random:before{content:"\f074"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-ravelry:before{content:"\f2d9"}.fa-react:before{content:"\f41b"}.fa-reacteurope:before{content:"\f75d"}.fa-readme:before{content:"\f4d5"}.fa-rebel:before{content:"\f1d0"}.fa-receipt:before{content:"\f543"}.fa-record-vinyl:before{content:"\f8d9"}.fa-recycle:before{content:"\f1b8"}.fa-red-river:before{content:"\f3e3"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-alien:before{content:"\f281"}.fa-reddit-square:before{content:"\f1a2"}.fa-redhat:before{content:"\f7bc"}.fa-redo:before{content:"\f01e"}.fa-redo-alt:before{content:"\f2f9"}.fa-registered:before{content:"\f25d"}.fa-remove-format:before{content:"\f87d"}.fa-renren:before{content:"\f18b"}.fa-reply:before{content:"\f3e5"}.fa-reply-all:before{content:"\f122"}.fa-replyd:before{content:"\f3e6"}.fa-republican:before{content:"\f75e"}.fa-researchgate:before{content:"\f4f8"}.fa-resolving:before{content:"\f3e7"}.fa-restroom:before{content:"\f7bd"}.fa-retweet:before{content:"\f079"}.fa-rev:before{content:"\f5b2"}.fa-ribbon:before{content:"\f4d6"}.fa-ring:before{content:"\f70b"}.fa-road:before{content:"\f018"}.fa-robot:before{content:"\f544"}.fa-rocket:before{content:"\f135"}.fa-rocketchat:before{content:"\f3e8"}.fa-rockrms:before{content:"\f3e9"}.fa-route:before{content:"\f4d7"}.fa-rss:before{content:"\f09e"}.fa-rss-square:before{content:"\f143"}.fa-ruble-sign:before{content:"\f158"}.fa-ruler:before{content:"\f545"}.fa-ruler-combined:before{content:"\f546"}.fa-ruler-horizontal:before{content:"\f547"}.fa-ruler-vertical:before{content:"\f548"}.fa-running:before{content:"\f70c"}.fa-rupee-sign:before{content:"\f156"}.fa-sad-cry:before{content:"\f5b3"}.fa-sad-tear:before{content:"\f5b4"}.fa-safari:before{content:"\f267"}.fa-salesforce:before{content:"\f83b"}.fa-sass:before{content:"\f41e"}.fa-satellite:before{content:"\f7bf"}.fa-satellite-dish:before{content:"\f7c0"}.fa-save:before{content:"\f0c7"}.fa-schlix:before{content:"\f3ea"}.fa-school:before{content:"\f549"}.fa-screwdriver:before{content:"\f54a"}.fa-scribd:before{content:"\f28a"}.fa-scroll:before{content:"\f70e"}.fa-sd-card:before{content:"\f7c2"}.fa-search:before{content:"\f002"}.fa-search-dollar:before{content:"\f688"}.fa-search-location:before{content:"\f689"}.fa-search-minus:before{content:"\f010"}.fa-search-plus:before{content:"\f00e"}.fa-searchengin:before{content:"\f3eb"}.fa-seedling:before{content:"\f4d8"}.fa-sellcast:before{content:"\f2da"}.fa-sellsy:before{content:"\f213"}.fa-server:before{content:"\f233"}.fa-servicestack:before{content:"\f3ec"}.fa-shapes:before{content:"\f61f"}.fa-share:before{content:"\f064"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-share-square:before{content:"\f14d"}.fa-shekel-sign:before{content:"\f20b"}.fa-shield-alt:before{content:"\f3ed"}.fa-ship:before{content:"\f21a"}.fa-shipping-fast:before{content:"\f48b"}.fa-shirtsinbulk:before{content:"\f214"}.fa-shoe-prints:before{content:"\f54b"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-shopping-cart:before{content:"\f07a"}.fa-shopware:before{content:"\f5b5"}.fa-shower:before{content:"\f2cc"}.fa-shuttle-van:before{content:"\f5b6"}.fa-sign:before{content:"\f4d9"}.fa-sign-in-alt:before{content:"\f2f6"}.fa-sign-language:before{content:"\f2a7"}.fa-sign-out-alt:before{content:"\f2f5"}.fa-signal:before{content:"\f012"}.fa-signature:before{content:"\f5b7"}.fa-sim-card:before{content:"\f7c4"}.fa-simplybuilt:before{content:"\f215"}.fa-sistrix:before{content:"\f3ee"}.fa-sitemap:before{content:"\f0e8"}.fa-sith:before{content:"\f512"}.fa-skating:before{content:"\f7c5"}.fa-sketch:before{content:"\f7c6"}.fa-skiing:before{content:"\f7c9"}.fa-skiing-nordic:before{content:"\f7ca"}.fa-skull:before{content:"\f54c"}.fa-skull-crossbones:before{content:"\f714"}.fa-skyatlas:before{content:"\f216"}.fa-skype:before{content:"\f17e"}.fa-slack:before{content:"\f198"}.fa-slack-hash:before{content:"\f3ef"}.fa-slash:before{content:"\f715"}.fa-sleigh:before{content:"\f7cc"}.fa-sliders-h:before{content:"\f1de"}.fa-slideshare:before{content:"\f1e7"}.fa-smile:before{content:"\f118"}.fa-smile-beam:before{content:"\f5b8"}.fa-smile-wink:before{content:"\f4da"}.fa-smog:before{content:"\f75f"}.fa-smoking:before{content:"\f48d"}.fa-smoking-ban:before{content:"\f54d"}.fa-sms:before{content:"\f7cd"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-snowboarding:before{content:"\f7ce"}.fa-snowflake:before{content:"\f2dc"}.fa-snowman:before{content:"\f7d0"}.fa-snowplow:before{content:"\f7d2"}.fa-socks:before{content:"\f696"}.fa-solar-panel:before{content:"\f5ba"}.fa-sort:before{content:"\f0dc"}.fa-sort-alpha-down:before{content:"\f15d"}.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-sort-alpha-up:before{content:"\f15e"}.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-sort-amount-down:before{content:"\f160"}.fa-sort-amount-down-alt:before{content:"\f884"}.fa-sort-amount-up:before{content:"\f161"}.fa-sort-amount-up-alt:before{content:"\f885"}.fa-sort-down:before{content:"\f0dd"}.fa-sort-numeric-down:before{content:"\f162"}.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-sort-numeric-up:before{content:"\f163"}.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-sort-up:before{content:"\f0de"}.fa-soundcloud:before{content:"\f1be"}.fa-sourcetree:before{content:"\f7d3"}.fa-spa:before{content:"\f5bb"}.fa-space-shuttle:before{content:"\f197"}.fa-speakap:before{content:"\f3f3"}.fa-speaker-deck:before{content:"\f83c"}.fa-spell-check:before{content:"\f891"}.fa-spider:before{content:"\f717"}.fa-spinner:before{content:"\f110"}.fa-splotch:before{content:"\f5bc"}.fa-spotify:before{content:"\f1bc"}.fa-spray-can:before{content:"\f5bd"}.fa-square:before{content:"\f0c8"}.fa-square-full:before{content:"\f45c"}.fa-square-root-alt:before{content:"\f698"}.fa-squarespace:before{content:"\f5be"}.fa-stack-exchange:before{content:"\f18d"}.fa-stack-overflow:before{content:"\f16c"}.fa-stackpath:before{content:"\f842"}.fa-stamp:before{content:"\f5bf"}.fa-star:before{content:"\f005"}.fa-star-and-crescent:before{content:"\f699"}.fa-star-half:before{content:"\f089"}.fa-star-half-alt:before{content:"\f5c0"}.fa-star-of-david:before{content:"\f69a"}.fa-star-of-life:before{content:"\f621"}.fa-staylinked:before{content:"\f3f5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-steam-symbol:before{content:"\f3f6"}.fa-step-backward:before{content:"\f048"}.fa-step-forward:before{content:"\f051"}.fa-stethoscope:before{content:"\f0f1"}.fa-sticker-mule:before{content:"\f3f7"}.fa-sticky-note:before{content:"\f249"}.fa-stop:before{content:"\f04d"}.fa-stop-circle:before{content:"\f28d"}.fa-stopwatch:before{content:"\f2f2"}.fa-store:before{content:"\f54e"}.fa-store-alt:before{content:"\f54f"}.fa-strava:before{content:"\f428"}.fa-stream:before{content:"\f550"}.fa-street-view:before{content:"\f21d"}.fa-strikethrough:before{content:"\f0cc"}.fa-stripe:before{content:"\f429"}.fa-stripe-s:before{content:"\f42a"}.fa-stroopwafel:before{content:"\f551"}.fa-studiovinari:before{content:"\f3f8"}.fa-stumbleupon:before{content:"\f1a4"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-subscript:before{content:"\f12c"}.fa-subway:before{content:"\f239"}.fa-suitcase:before{content:"\f0f2"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-sun:before{content:"\f185"}.fa-superpowers:before{content:"\f2dd"}.fa-superscript:before{content:"\f12b"}.fa-supple:before{content:"\f3f9"}.fa-surprise:before{content:"\f5c2"}.fa-suse:before{content:"\f7d6"}.fa-swatchbook:before{content:"\f5c3"}.fa-swift:before{content:"\f8e1"}.fa-swimmer:before{content:"\f5c4"}.fa-swimming-pool:before{content:"\f5c5"}.fa-symfony:before{content:"\f83d"}.fa-synagogue:before{content:"\f69b"}.fa-sync:before{content:"\f021"}.fa-sync-alt:before{content:"\f2f1"}.fa-syringe:before{content:"\f48e"}.fa-table:before{content:"\f0ce"}.fa-table-tennis:before{content:"\f45d"}.fa-tablet:before{content:"\f10a"}.fa-tablet-alt:before{content:"\f3fa"}.fa-tablets:before{content:"\f490"}.fa-tachometer-alt:before{content:"\f3fd"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-tape:before{content:"\f4db"}.fa-tasks:before{content:"\f0ae"}.fa-taxi:before{content:"\f1ba"}.fa-teamspeak:before{content:"\f4f9"}.fa-teeth:before{content:"\f62e"}.fa-teeth-open:before{content:"\f62f"}.fa-telegram:before{content:"\f2c6"}.fa-telegram-plane:before{content:"\f3fe"}.fa-temperature-high:before{content:"\f769"}.fa-temperature-low:before{content:"\f76b"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-tenge:before{content:"\f7d7"}.fa-terminal:before{content:"\f120"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-th:before{content:"\f00a"}.fa-th-large:before{content:"\f009"}.fa-th-list:before{content:"\f00b"}.fa-the-red-yeti:before{content:"\f69d"}.fa-theater-masks:before{content:"\f630"}.fa-themeco:before{content:"\f5c6"}.fa-themeisle:before{content:"\f2b2"}.fa-thermometer:before{content:"\f491"}.fa-thermometer-empty:before{content:"\f2cb"}.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-think-peaks:before{content:"\f731"}.fa-thumbs-down:before{content:"\f165"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbtack:before{content:"\f08d"}.fa-ticket-alt:before{content:"\f3ff"}.fa-times:before{content:"\f00d"}.fa-times-circle:before{content:"\f057"}.fa-tint:before{content:"\f043"}.fa-tint-slash:before{content:"\f5c7"}.fa-tired:before{content:"\f5c8"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-toilet:before{content:"\f7d8"}.fa-toilet-paper:before{content:"\f71e"}.fa-toolbox:before{content:"\f552"}.fa-tools:before{content:"\f7d9"}.fa-tooth:before{content:"\f5c9"}.fa-torah:before{content:"\f6a0"}.fa-torii-gate:before{content:"\f6a1"}.fa-tractor:before{content:"\f722"}.fa-trade-federation:before{content:"\f513"}.fa-trademark:before{content:"\f25c"}.fa-traffic-light:before{content:"\f637"}.fa-trailer:before{content:"\f941"}.fa-train:before{content:"\f238"}.fa-tram:before{content:"\f7da"}.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-trash:before{content:"\f1f8"}.fa-trash-alt:before{content:"\f2ed"}.fa-trash-restore:before{content:"\f829"}.fa-trash-restore-alt:before{content:"\f82a"}.fa-tree:before{content:"\f1bb"}.fa-trello:before{content:"\f181"}.fa-tripadvisor:before{content:"\f262"}.fa-trophy:before{content:"\f091"}.fa-truck:before{content:"\f0d1"}.fa-truck-loading:before{content:"\f4de"}.fa-truck-monster:before{content:"\f63b"}.fa-truck-moving:before{content:"\f4df"}.fa-truck-pickup:before{content:"\f63c"}.fa-tshirt:before{content:"\f553"}.fa-tty:before{content:"\f1e4"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-tv:before{content:"\f26c"}.fa-twitch:before{content:"\f1e8"}.fa-twitter:before{content:"\f099"}.fa-twitter-square:before{content:"\f081"}.fa-typo3:before{content:"\f42b"}.fa-uber:before{content:"\f402"}.fa-ubuntu:before{content:"\f7df"}.fa-uikit:before{content:"\f403"}.fa-umbraco:before{content:"\f8e8"}.fa-umbrella:before{content:"\f0e9"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-underline:before{content:"\f0cd"}.fa-undo:before{content:"\f0e2"}.fa-undo-alt:before{content:"\f2ea"}.fa-uniregistry:before{content:"\f404"}.fa-unity:before{content:"\f949"}.fa-universal-access:before{content:"\f29a"}.fa-university:before{content:"\f19c"}.fa-unlink:before{content:"\f127"}.fa-unlock:before{content:"\f09c"}.fa-unlock-alt:before{content:"\f13e"}.fa-untappd:before{content:"\f405"}.fa-upload:before{content:"\f093"}.fa-ups:before{content:"\f7e0"}.fa-usb:before{content:"\f287"}.fa-user:before{content:"\f007"}.fa-user-alt:before{content:"\f406"}.fa-user-alt-slash:before{content:"\f4fa"}.fa-user-astronaut:before{content:"\f4fb"}.fa-user-check:before{content:"\f4fc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-clock:before{content:"\f4fd"}.fa-user-cog:before{content:"\f4fe"}.fa-user-edit:before{content:"\f4ff"}.fa-user-friends:before{content:"\f500"}.fa-user-graduate:before{content:"\f501"}.fa-user-injured:before{content:"\f728"}.fa-user-lock:before{content:"\f502"}.fa-user-md:before{content:"\f0f0"}.fa-user-minus:before{content:"\f503"}.fa-user-ninja:before{content:"\f504"}.fa-user-nurse:before{content:"\f82f"}.fa-user-plus:before{content:"\f234"}.fa-user-secret:before{content:"\f21b"}.fa-user-shield:before{content:"\f505"}.fa-user-slash:before{content:"\f506"}.fa-user-tag:before{content:"\f507"}.fa-user-tie:before{content:"\f508"}.fa-user-times:before{content:"\f235"}.fa-users:before{content:"\f0c0"}.fa-users-cog:before{content:"\f509"}.fa-usps:before{content:"\f7e1"}.fa-ussunnah:before{content:"\f407"}.fa-utensil-spoon:before{content:"\f2e5"}.fa-utensils:before{content:"\f2e7"}.fa-vaadin:before{content:"\f408"}.fa-vector-square:before{content:"\f5cb"}.fa-venus:before{content:"\f221"}.fa-venus-double:before{content:"\f226"}.fa-venus-mars:before{content:"\f228"}.fa-viacoin:before{content:"\f237"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-vial:before{content:"\f492"}.fa-vials:before{content:"\f493"}.fa-viber:before{content:"\f409"}.fa-video:before{content:"\f03d"}.fa-video-slash:before{content:"\f4e2"}.fa-vihara:before{content:"\f6a7"}.fa-vimeo:before{content:"\f40a"}.fa-vimeo-square:before{content:"\f194"}.fa-vimeo-v:before{content:"\f27d"}.fa-vine:before{content:"\f1ca"}.fa-vk:before{content:"\f189"}.fa-vnv:before{content:"\f40b"}.fa-voicemail:before{content:"\f897"}.fa-volleyball-ball:before{content:"\f45f"}.fa-volume-down:before{content:"\f027"}.fa-volume-mute:before{content:"\f6a9"}.fa-volume-off:before{content:"\f026"}.fa-volume-up:before{content:"\f028"}.fa-vote-yea:before{content:"\f772"}.fa-vr-cardboard:before{content:"\f729"}.fa-vuejs:before{content:"\f41f"}.fa-walking:before{content:"\f554"}.fa-wallet:before{content:"\f555"}.fa-warehouse:before{content:"\f494"}.fa-water:before{content:"\f773"}.fa-wave-square:before{content:"\f83e"}.fa-waze:before{content:"\f83f"}.fa-weebly:before{content:"\f5cc"}.fa-weibo:before{content:"\f18a"}.fa-weight:before{content:"\f496"}.fa-weight-hanging:before{content:"\f5cd"}.fa-weixin:before{content:"\f1d7"}.fa-whatsapp:before{content:"\f232"}.fa-whatsapp-square:before{content:"\f40c"}.fa-wheelchair:before{content:"\f193"}.fa-whmcs:before{content:"\f40d"}.fa-wifi:before{content:"\f1eb"}.fa-wikipedia-w:before{content:"\f266"}.fa-wind:before{content:"\f72e"}.fa-window-close:before{content:"\f410"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-windows:before{content:"\f17a"}.fa-wine-bottle:before{content:"\f72f"}.fa-wine-glass:before{content:"\f4e3"}.fa-wine-glass-alt:before{content:"\f5ce"}.fa-wix:before{content:"\f5cf"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-won-sign:before{content:"\f159"}.fa-wordpress:before{content:"\f19a"}.fa-wordpress-simple:before{content:"\f411"}.fa-wpbeginner:before{content:"\f297"}.fa-wpexplorer:before{content:"\f2de"}.fa-wpforms:before{content:"\f298"}.fa-wpressr:before{content:"\f3e4"}.fa-wrench:before{content:"\f0ad"}.fa-x-ray:before{content:"\f497"}.fa-xbox:before{content:"\f412"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-y-combinator:before{content:"\f23b"}.fa-yahoo:before{content:"\f19e"}.fa-yammer:before{content:"\f840"}.fa-yandex:before{content:"\f413"}.fa-yandex-international:before{content:"\f414"}.fa-yarn:before{content:"\f7e3"}.fa-yelp:before{content:"\f1e9"}.fa-yen-sign:before{content:"\f157"}.fa-yin-yang:before{content:"\f6ad"}.fa-yoast:before{content:"\f2b1"}.fa-youtube:before{content:"\f167"}.fa-youtube-square:before{content:"\f431"}.fa-zhihu:before{content:"\f63f"}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}@font-face{font-family:"Font Awesome 5 Brands";font-style:normal;font-weight:normal;font-display:auto;src:url(fa-brands-400.eot);src:url(fa-brands-400.eot?#iefix) format("embedded-opentype"),url(fa-brands-400.woff2) format("woff2"),url(fa-brands-400.woff) format("woff"),url(fa-brands-400.ttf) format("truetype"),url(fa-brands-400.svg#fontawesome) format("svg")}.fab{font-family:"Font Awesome 5 Brands"}@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:400;font-display:auto;src:url(fa-regular-400.eot);src:url(fa-regular-400.eot?#iefix) format("embedded-opentype"),url(fa-regular-400.woff2) format("woff2"),url(fa-regular-400.woff) format("woff"),url(fa-regular-400.ttf) format("truetype"),url(fa-regular-400.svg#fontawesome) format("svg")}.far{font-weight:400}@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:900;font-display:auto;src:url(fa-solid-900.eot);src:url(fa-solid-900.eot?#iefix) format("embedded-opentype"),url(fa-solid-900.woff2) format("woff2"),url(fa-solid-900.woff) format("woff"),url(fa-solid-900.ttf) format("truetype"),url(fa-solid-900.svg#fontawesome) format("svg")}.fa,.far,.fas{font-family:"Font Awesome 5 Free"}.fa,.fas{font-weight:900}
\ No newline at end of file
diff --git a/assets/fonts/fontawesome-webfont.eot b/internal/ports/api/core/assets/fonts/fontawesome-webfont.eot
similarity index 100%
rename from assets/fonts/fontawesome-webfont.eot
rename to internal/ports/api/core/assets/fonts/fontawesome-webfont.eot
diff --git a/assets/fonts/fontawesome-webfont.svg b/internal/ports/api/core/assets/fonts/fontawesome-webfont.svg
similarity index 100%
rename from assets/fonts/fontawesome-webfont.svg
rename to internal/ports/api/core/assets/fonts/fontawesome-webfont.svg
diff --git a/assets/fonts/fontawesome-webfont.ttf b/internal/ports/api/core/assets/fonts/fontawesome-webfont.ttf
similarity index 100%
rename from assets/fonts/fontawesome-webfont.ttf
rename to internal/ports/api/core/assets/fonts/fontawesome-webfont.ttf
diff --git a/assets/fonts/fontawesome-webfont.woff b/internal/ports/api/core/assets/fonts/fontawesome-webfont.woff
similarity index 100%
rename from assets/fonts/fontawesome-webfont.woff
rename to internal/ports/api/core/assets/fonts/fontawesome-webfont.woff
diff --git a/assets/fonts/fontawesome-webfont.woff2 b/internal/ports/api/core/assets/fonts/fontawesome-webfont.woff2
similarity index 100%
rename from assets/fonts/fontawesome-webfont.woff2
rename to internal/ports/api/core/assets/fonts/fontawesome-webfont.woff2
diff --git a/assets/fonts/fontawesome5-overrides.min.css b/internal/ports/api/core/assets/fonts/fontawesome5-overrides.min.css
similarity index 100%
rename from assets/fonts/fontawesome5-overrides.min.css
rename to internal/ports/api/core/assets/fonts/fontawesome5-overrides.min.css
diff --git a/internal/ports/api/core/assets/img/header-logo-small.png b/internal/ports/api/core/assets/img/header-logo-small.png
new file mode 100644
index 0000000..4743535
Binary files /dev/null and b/internal/ports/api/core/assets/img/header-logo-small.png differ
diff --git a/assets/img/header-logo.png b/internal/ports/api/core/assets/img/header-logo.png
similarity index 100%
rename from assets/img/header-logo.png
rename to internal/ports/api/core/assets/img/header-logo.png
diff --git a/assets/js/bootstrap.bundle.min.js b/internal/ports/api/core/assets/js/bootstrap.bundle.min.js
similarity index 100%
rename from assets/js/bootstrap.bundle.min.js
rename to internal/ports/api/core/assets/js/bootstrap.bundle.min.js
diff --git a/assets/js/jquery.easing.js b/internal/ports/api/core/assets/js/jquery.easing.js
similarity index 100%
rename from assets/js/jquery.easing.js
rename to internal/ports/api/core/assets/js/jquery.easing.js
diff --git a/assets/js/jquery.min.js b/internal/ports/api/core/assets/js/jquery.min.js
similarity index 100%
rename from assets/js/jquery.min.js
rename to internal/ports/api/core/assets/js/jquery.min.js
diff --git a/assets/js/popper.min.js b/internal/ports/api/core/assets/js/popper.min.js
similarity index 100%
rename from assets/js/popper.min.js
rename to internal/ports/api/core/assets/js/popper.min.js
diff --git a/internal/ports/api/core/assets/js/rapidoc-min.js b/internal/ports/api/core/assets/js/rapidoc-min.js
new file mode 100644
index 0000000..6a28927
--- /dev/null
+++ b/internal/ports/api/core/assets/js/rapidoc-min.js
@@ -0,0 +1,2 @@
+/*! RapiDoc 9.1.8 | Author - Mrinmoy Majumdar | License information can be found in rapidoc-min.js.LICENSE.txt */
+(()=>{var e,t,r={310:(e,t,r)=>{"use strict";const n=new WeakMap,a=e=>(...t)=>{const r=e(...t);return n.set(r,!0),r},o=e=>"function"==typeof e&&n.has(e),i="undefined"!=typeof window&&null!=window.customElements&&void 0!==window.customElements.polyfillWrapFlushCallback,s=(e,t,r=null)=>{for(;t!==r;){const r=t.nextSibling;e.removeChild(t),t=r}},l={},c={},p=`{{lit-${String(Math.random()).slice(2)}}}`,u=`\x3c!--${p}--\x3e`,d=new RegExp(`${p}|${u}`),h="$lit$";class f{constructor(e,t){this.parts=[],this.element=t;const r=[],n=[],a=document.createTreeWalker(t.content,133,null,!1);let o=0,i=-1,s=0;const{strings:l,values:{length:c}}=e;for(;s
0;){const t=l[s],r=v.exec(t)[2],n=r.toLowerCase()+h,a=e.getAttribute(n);e.removeAttribute(n);const o=a.split(d);this.parts.push({type:"attribute",index:i,name:r,strings:o}),s+=o.length-1}}"TEMPLATE"===e.tagName&&(n.push(e),a.currentNode=e.content)}else if(3===e.nodeType){const t=e.data;if(t.indexOf(p)>=0){const n=e.parentNode,a=t.split(d),o=a.length-1;for(let t=0;t{const r=e.length-t.length;return r>=0&&e.slice(r)===t},y=e=>-1!==e.index,g=()=>document.createComment(""),v=/([ \x09\x0a\x0c\x0d])([^\0-\x1F\x7F-\x9F "'>=/]+)([ \x09\x0a\x0c\x0d]*=[ \x09\x0a\x0c\x0d]*(?:[^ \x09\x0a\x0c\x0d"'`<>=]*|"[^"]*|'[^']*))$/;class b{constructor(e,t,r){this.__parts=[],this.template=e,this.processor=t,this.options=r}update(e){let t=0;for(const r of this.__parts)void 0!==r&&r.setValue(e[t]),t++;for(const e of this.__parts)void 0!==e&&e.commit()}_clone(){const e=i?this.template.element.content.cloneNode(!0):document.importNode(this.template.element.content,!0),t=[],r=this.template.parts,n=document.createTreeWalker(e,133,null,!1);let a,o=0,s=0,l=n.nextNode();for(;oe}),w=` ${p} `;class S{constructor(e,t,r,n){this.strings=e,this.values=t,this.type=r,this.processor=n}getHTML(){const e=this.strings.length-1;let t="",r=!1;for(let n=0;n-1||r)&&-1===e.indexOf("--\x3e",a+1);const o=v.exec(e);t+=null===o?e+(r?w:u):e.substr(0,o.index)+o[1]+o[2]+h+o[3]+p}return t+=this.strings[e],t}getTemplateElement(){const e=document.createElement("template");let t=this.getHTML();return void 0!==x&&(t=x.createHTML(t)),e.innerHTML=t,e}}const k=e=>null===e||!("object"==typeof e||"function"==typeof e),$=e=>Array.isArray(e)||!(!e||!e[Symbol.iterator]);class A{constructor(e,t,r){this.dirty=!0,this.element=e,this.name=t,this.strings=r,this.parts=[];for(let e=0;e{try{const e={get capture(){return j=!0,!1}};window.addEventListener("test",e,e),window.removeEventListener("test",e,e)}catch(e){}})();class I{constructor(e,t,r){this.value=void 0,this.__pendingValue=void 0,this.element=e,this.eventName=t,this.eventContext=r,this.__boundHandleEvent=e=>this.handleEvent(e)}setValue(e){this.__pendingValue=e}commit(){for(;o(this.__pendingValue);){const e=this.__pendingValue;this.__pendingValue=l,e(this)}if(this.__pendingValue===l)return;const e=this.__pendingValue,t=this.value,r=null==e||null!=t&&(e.capture!==t.capture||e.once!==t.once||e.passive!==t.passive),n=null!=e&&(null==t||r);r&&this.element.removeEventListener(this.eventName,this.__boundHandleEvent,this.__options),n&&(this.__options=P(e),this.element.addEventListener(this.eventName,this.__boundHandleEvent,this.__options)),this.value=e,this.__pendingValue=l}handleEvent(e){"function"==typeof this.value?this.value.call(this.eventContext||this.element,e):this.value.handleEvent(e)}}const P=e=>e&&(j?{capture:e.capture,passive:e.passive,once:e.once}:e.capture);const R=new class{handleAttributeExpressions(e,t,r,n){const a=t[0];if("."===a){return new _(e,t.slice(1),r).parts}if("@"===a)return[new I(e,t.slice(1),n.eventContext)];if("?"===a)return[new T(e,t.slice(1),r)];return new A(e,t,r).parts}handleTextExpression(e){return new O(e)}};function L(e){let t=N.get(e.type);void 0===t&&(t={stringsArray:new WeakMap,keyString:new Map},N.set(e.type,t));let r=t.stringsArray.get(e.strings);if(void 0!==r)return r;const n=e.strings.join(p);return r=t.keyString.get(n),void 0===r&&(r=new f(e,e.getTemplateElement()),t.keyString.set(n,r)),t.stringsArray.set(e.strings,r),r}const N=new Map,F=new WeakMap;"undefined"!=typeof window&&(window.litHtmlVersions||(window.litHtmlVersions=[])).push("1.4.1");const D=(e,...t)=>new S(e,t,"html",R);window.JSCompiler_renameProperty=(e,t)=>e;const B={toAttribute(e,t){switch(t){case Boolean:return e?"":null;case Object:case Array:return null==e?e:JSON.stringify(e)}return e},fromAttribute(e,t){switch(t){case Boolean:return null!==e;case Number:return null===e?null:Number(e);case Object:case Array:return JSON.parse(e)}return e}},z=(e,t)=>t!==e&&(t==t||e==e),q={attribute:!0,type:String,converter:B,reflect:!1,hasChanged:z},U="finalized";class M extends HTMLElement{constructor(){super(),this.initialize()}static get observedAttributes(){this.finalize();const e=[];return this._classProperties.forEach(((t,r)=>{const n=this._attributeNameForProperty(r,t);void 0!==n&&(this._attributeToPropertyMap.set(n,r),e.push(n))})),e}static _ensureClassProperties(){if(!this.hasOwnProperty(JSCompiler_renameProperty("_classProperties",this))){this._classProperties=new Map;const e=Object.getPrototypeOf(this)._classProperties;void 0!==e&&e.forEach(((e,t)=>this._classProperties.set(t,e)))}}static createProperty(e,t=q){if(this._ensureClassProperties(),this._classProperties.set(e,t),t.noAccessor||this.prototype.hasOwnProperty(e))return;const r="symbol"==typeof e?Symbol():`__${e}`,n=this.getPropertyDescriptor(e,r,t);void 0!==n&&Object.defineProperty(this.prototype,e,n)}static getPropertyDescriptor(e,t,r){return{get(){return this[t]},set(n){const a=this[e];this[t]=n,this.requestUpdateInternal(e,a,r)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this._classProperties&&this._classProperties.get(e)||q}static finalize(){const e=Object.getPrototypeOf(this);if(e.hasOwnProperty(U)||e.finalize(),this.finalized=!0,this._ensureClassProperties(),this._attributeToPropertyMap=new Map,this.hasOwnProperty(JSCompiler_renameProperty("properties",this))){const e=this.properties,t=[...Object.getOwnPropertyNames(e),..."function"==typeof Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(e):[]];for(const r of t)this.createProperty(r,e[r])}}static _attributeNameForProperty(e,t){const r=t.attribute;return!1===r?void 0:"string"==typeof r?r:"string"==typeof e?e.toLowerCase():void 0}static _valueHasChanged(e,t,r=z){return r(e,t)}static _propertyValueFromAttribute(e,t){const r=t.type,n=t.converter||B,a="function"==typeof n?n:n.fromAttribute;return a?a(e,r):e}static _propertyValueToAttribute(e,t){if(void 0===t.reflect)return;const r=t.type,n=t.converter;return(n&&n.toAttribute||B.toAttribute)(e,r)}initialize(){this._updateState=0,this._updatePromise=new Promise((e=>this._enableUpdatingResolver=e)),this._changedProperties=new Map,this._saveInstanceProperties(),this.requestUpdateInternal()}_saveInstanceProperties(){this.constructor._classProperties.forEach(((e,t)=>{if(this.hasOwnProperty(t)){const e=this[t];delete this[t],this._instanceProperties||(this._instanceProperties=new Map),this._instanceProperties.set(t,e)}}))}_applyInstanceProperties(){this._instanceProperties.forEach(((e,t)=>this[t]=e)),this._instanceProperties=void 0}connectedCallback(){this.enableUpdating()}enableUpdating(){void 0!==this._enableUpdatingResolver&&(this._enableUpdatingResolver(),this._enableUpdatingResolver=void 0)}disconnectedCallback(){}attributeChangedCallback(e,t,r){t!==r&&this._attributeToProperty(e,r)}_propertyToAttribute(e,t,r=q){const n=this.constructor,a=n._attributeNameForProperty(e,r);if(void 0!==a){const e=n._propertyValueToAttribute(t,r);if(void 0===e)return;this._updateState=8|this._updateState,null==e?this.removeAttribute(a):this.setAttribute(a,e),this._updateState=-9&this._updateState}}_attributeToProperty(e,t){if(8&this._updateState)return;const r=this.constructor,n=r._attributeToPropertyMap.get(e);if(void 0!==n){const e=r.getPropertyOptions(n);this._updateState=16|this._updateState,this[n]=r._propertyValueFromAttribute(t,e),this._updateState=-17&this._updateState}}requestUpdateInternal(e,t,r){let n=!0;if(void 0!==e){const a=this.constructor;r=r||a.getPropertyOptions(e),a._valueHasChanged(this[e],t,r.hasChanged)?(this._changedProperties.has(e)||this._changedProperties.set(e,t),!0!==r.reflect||16&this._updateState||(void 0===this._reflectingProperties&&(this._reflectingProperties=new Map),this._reflectingProperties.set(e,r))):n=!1}!this._hasRequestedUpdate&&n&&(this._updatePromise=this._enqueueUpdate())}requestUpdate(e,t){return this.requestUpdateInternal(e,t),this.updateComplete}async _enqueueUpdate(){this._updateState=4|this._updateState;try{await this._updatePromise}catch(e){}const e=this.performUpdate();return null!=e&&await e,!this._hasRequestedUpdate}get _hasRequestedUpdate(){return 4&this._updateState}get hasUpdated(){return 1&this._updateState}performUpdate(){if(!this._hasRequestedUpdate)return;this._instanceProperties&&this._applyInstanceProperties();let e=!1;const t=this._changedProperties;try{e=this.shouldUpdate(t),e?this.update(t):this._markUpdated()}catch(t){throw e=!1,this._markUpdated(),t}e&&(1&this._updateState||(this._updateState=1|this._updateState,this.firstUpdated(t)),this.updated(t))}_markUpdated(){this._changedProperties=new Map,this._updateState=-5&this._updateState}get updateComplete(){return this._getUpdateComplete()}_getUpdateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._updatePromise}shouldUpdate(e){return!0}update(e){void 0!==this._reflectingProperties&&this._reflectingProperties.size>0&&(this._reflectingProperties.forEach(((e,t)=>this._propertyToAttribute(t,this[t],e))),this._reflectingProperties=void 0),this._markUpdated()}updated(e){}firstUpdated(e){}}M.finalized=!0;const H=Element.prototype;H.msMatchesSelector||H.webkitMatchesSelector;const V=window.ShadowRoot&&(void 0===window.ShadyCSS||window.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,W=Symbol();class G{constructor(e,t){if(t!==W)throw new Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e}get styleSheet(){return void 0===this._styleSheet&&(V?(this._styleSheet=new CSSStyleSheet,this._styleSheet.replaceSync(this.cssText)):this._styleSheet=null),this._styleSheet}toString(){return this.cssText}}const K=e=>new G(String(e),W),J=(e,...t)=>{const r=t.reduce(((t,r,n)=>t+(e=>{if(e instanceof G)return e.cssText;if("number"==typeof e)return e;throw new Error(`Value passed to 'css' function must be a 'css' function result: ${e}. Use 'unsafeCSS' to pass non-literal values, but\n take care to ensure page security.`)})(r)+e[n+1]),e[0]);return new G(r,W)};(window.litElementVersions||(window.litElementVersions=[])).push("2.5.1");const Y={};class Z extends M{static getStyles(){return this.styles}static _getUniqueStyles(){if(this.hasOwnProperty(JSCompiler_renameProperty("_styles",this)))return;const e=this.getStyles();if(Array.isArray(e)){const t=(e,r)=>e.reduceRight(((e,r)=>Array.isArray(r)?t(r,e):(e.add(r),e)),r),r=t(e,new Set),n=[];r.forEach((e=>n.unshift(e))),this._styles=n}else this._styles=void 0===e?[]:[e];this._styles=this._styles.map((e=>{if(e instanceof CSSStyleSheet&&!V){const t=Array.prototype.slice.call(e.cssRules).reduce(((e,t)=>e+t.cssText),"");return K(t)}return e}))}initialize(){super.initialize(),this.constructor._getUniqueStyles(),this.renderRoot=this.createRenderRoot(),window.ShadowRoot&&this.renderRoot instanceof window.ShadowRoot&&this.adoptStyles()}createRenderRoot(){return this.attachShadow(this.constructor.shadowRootOptions)}adoptStyles(){const e=this.constructor._styles;0!==e.length&&(void 0===window.ShadyCSS||window.ShadyCSS.nativeShadow?V?this.renderRoot.adoptedStyleSheets=e.map((e=>e instanceof CSSStyleSheet?e:e.styleSheet)):this._needsShimAdoptedStyleSheets=!0:window.ShadyCSS.ScopingShim.prepareAdoptedCssText(e.map((e=>e.cssText)),this.localName))}connectedCallback(){super.connectedCallback(),this.hasUpdated&&void 0!==window.ShadyCSS&&window.ShadyCSS.styleElement(this)}update(e){const t=this.render();super.update(e),t!==Y&&this.constructor.render(t,this.renderRoot,{scopeName:this.localName,eventContext:this}),this._needsShimAdoptedStyleSheets&&(this._needsShimAdoptedStyleSheets=!1,this.constructor._styles.forEach((e=>{const t=document.createElement("style");t.textContent=e.cssText,this.renderRoot.appendChild(t)})))}render(){return Y}}function Q(){return{baseUrl:null,breaks:!1,extensions:null,gfm:!0,headerIds:!0,headerPrefix:"",highlight:null,langPrefix:"language-",mangle:!0,pedantic:!1,renderer:null,sanitize:!1,sanitizer:null,silent:!1,smartLists:!1,smartypants:!1,tokenizer:null,walkTokens:null,xhtml:!1}}Z.finalized=!0,Z.render=(e,t,r)=>{let n=F.get(t);void 0===n&&(s(t,t.firstChild),F.set(t,n=new O(Object.assign({templateFactory:L},r))),n.appendInto(t)),n.setValue(e),n.commit()},Z.shadowRootOptions={mode:"open"};let X={baseUrl:null,breaks:!1,extensions:null,gfm:!0,headerIds:!0,headerPrefix:"",highlight:null,langPrefix:"language-",mangle:!0,pedantic:!1,renderer:null,sanitize:!1,sanitizer:null,silent:!1,smartLists:!1,smartypants:!1,tokenizer:null,walkTokens:null,xhtml:!1};const ee=/[&<>"']/,te=/[&<>"']/g,re=/[<>"']|&(?!#?\w+;)/,ne=/[<>"']|&(?!#?\w+;)/g,ae={"&":"&","<":"<",">":">",'"':""","'":"'"},oe=e=>ae[e];function ie(e,t){if(t){if(ee.test(e))return e.replace(te,oe)}else if(re.test(e))return e.replace(ne,oe);return e}const se=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function le(e){return e.replace(se,((e,t)=>"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""))}const ce=/(^|[^\[])\^/g;function pe(e,t){e=e.source||e,t=t||"";const r={replace:(t,n)=>(n=(n=n.source||n).replace(ce,"$1"),e=e.replace(t,n),r),getRegex:()=>new RegExp(e,t)};return r}const ue=/[^\w:]/g,de=/^$|^[a-z][a-z0-9+.-]*:|^[?#]/i;function he(e,t,r){if(e){let e;try{e=decodeURIComponent(le(r)).replace(ue,"").toLowerCase()}catch(e){return null}if(0===e.indexOf("javascript:")||0===e.indexOf("vbscript:")||0===e.indexOf("data:"))return null}t&&!de.test(r)&&(r=function(e,t){fe[" "+e]||(me.test(e)?fe[" "+e]=e+"/":fe[" "+e]=we(e,"/",!0));const r=-1===(e=fe[" "+e]).indexOf(":");return"//"===t.substring(0,2)?r?t:e.replace(ye,"$1")+t:"/"===t.charAt(0)?r?t:e.replace(ge,"$1")+t:e+t}(t,r));try{r=encodeURI(r).replace(/%25/g,"%")}catch(e){return null}return r}const fe={},me=/^[^:]+:\/*[^/]*$/,ye=/^([^:]+:)[\s\S]*$/,ge=/^([^:]+:\/*[^/]*)[\s\S]*$/;const ve={exec:function(){}};function be(e){let t,r,n=1;for(;n{let n=!1,a=t;for(;--a>=0&&"\\"===r[a];)n=!n;return n?"|":" |"})).split(/ \|/);let n=0;if(r[0].trim()||r.shift(),r.length>0&&!r[r.length-1].trim()&&r.pop(),r.length>t)r.splice(t);else for(;r.length1;)1&t&&(r+=e),t>>=1,e+=e;return r+e}function $e(e,t,r,n){const a=t.href,o=t.title?ie(t.title):null,i=e[1].replace(/\\([\[\]])/g,"$1");if("!"!==e[0].charAt(0)){n.state.inLink=!0;const e={type:"link",raw:r,href:a,title:o,text:i,tokens:n.inlineTokens(i,[])};return n.state.inLink=!1,e}return{type:"image",raw:r,href:a,title:o,text:ie(i)}}class Ae{constructor(e){this.options=e||X}space(e){const t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:we(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],r=function(e,t){const r=e.match(/^(\s+)(?:```)/);if(null===r)return t;const n=r[1];return t.split("\n").map((e=>{const t=e.match(/^\s+/);if(null===t)return e;const[r]=t;return r.length>=n.length?e.slice(n.length):e})).join("\n")}(e,t[3]||"");return{type:"code",raw:e,lang:t[2]?t[2].trim():t[2],text:r}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(/#$/.test(e)){const t=we(e,"#");this.options.pedantic?e=t.trim():t&&!/ $/.test(t)||(e=t.trim())}const r={type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:[]};return this.lexer.inline(r.text,r.tokens),r}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:t[0]}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){const e=t[0].replace(/^ *> ?/gm,"");return{type:"blockquote",raw:t[0],tokens:this.lexer.blockTokens(e,[]),text:e}}}list(e){let t=this.rules.block.list.exec(e);if(t){let r,n,a,o,i,s,l,c,p,u,d,h,f=t[1].trim();const m=f.length>1,y={type:"list",raw:"",ordered:m,start:m?+f.slice(0,-1):"",loose:!1,items:[]};f=m?`\\d{1,9}\\${f.slice(-1)}`:`\\${f}`,this.options.pedantic&&(f=m?f:"[*+-]");const g=new RegExp(`^( {0,3}${f})((?: [^\\n]*)?(?:\\n|$))`);for(;e&&(h=!1,t=g.exec(e))&&!this.rules.block.hr.test(e);){if(r=t[0],e=e.substring(r.length),c=t[2].split("\n",1)[0],p=e.split("\n",1)[0],this.options.pedantic?(o=2,d=c.trimLeft()):(o=t[2].search(/[^ ]/),o=o>4?1:o,d=c.slice(o),o+=t[1].length),s=!1,!c&&/^ *$/.test(p)&&(r+=p+"\n",e=e.substring(p.length+1),h=!0),!h){const t=new RegExp(`^ {0,${Math.min(3,o-1)}}(?:[*+-]|\\d{1,9}[.)])`);for(;e&&(u=e.split("\n",1)[0],c=u,this.options.pedantic&&(c=c.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),!t.test(c));){if(c.search(/[^ ]/)>=o||!c.trim())d+="\n"+c.slice(o);else{if(s)break;d+="\n"+c}s||c.trim()||(s=!0),r+=u+"\n",e=e.substring(u.length+1)}}y.loose||(l?y.loose=!0:/\n *\n *$/.test(r)&&(l=!0)),this.options.gfm&&(n=/^\[[ xX]\] /.exec(d),n&&(a="[ ] "!==n[0],d=d.replace(/^\[[ xX]\] +/,""))),y.items.push({type:"list_item",raw:r,task:!!n,checked:a,loose:!1,text:d}),y.raw+=r}y.items[y.items.length-1].raw=r.trimRight(),y.items[y.items.length-1].text=d.trimRight(),y.raw=y.raw.trimRight();const v=y.items.length;for(i=0;i"space"===e.type)),t=e.every((e=>{const t=e.raw.split("");let r=0;for(const e of t)if("\n"===e&&(r+=1),r>1)return!0;return!1}));!y.loose&&e.length&&t&&(y.loose=!0,y.items[i].loose=!0)}return y}}html(e){const t=this.rules.block.html.exec(e);if(t){const e={type:"html",raw:t[0],pre:!this.options.sanitizer&&("pre"===t[1]||"script"===t[1]||"style"===t[1]),text:t[0]};return this.options.sanitize&&(e.type="paragraph",e.text=this.options.sanitizer?this.options.sanitizer(t[0]):ie(t[0]),e.tokens=[],this.lexer.inline(e.text,e.tokens)),e}}def(e){const t=this.rules.block.def.exec(e);if(t){t[3]&&(t[3]=t[3].substring(1,t[3].length-1));return{type:"def",tag:t[1].toLowerCase().replace(/\s+/g," "),raw:t[0],href:t[2],title:t[3]}}}table(e){const t=this.rules.block.table.exec(e);if(t){const e={type:"table",header:xe(t[1]).map((e=>({text:e}))),align:t[2].replace(/^ *|\| *$/g,"").split(/ *\| */),rows:t[3]&&t[3].trim()?t[3].replace(/\n[ \t]*$/,"").split("\n"):[]};if(e.header.length===e.align.length){e.raw=t[0];let r,n,a,o,i=e.align.length;for(r=0;r({text:e})));for(i=e.header.length,n=0;n/i.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:this.options.sanitize?"text":"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,text:this.options.sanitize?this.options.sanitizer?this.options.sanitizer(t[0]):ie(t[0]):t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&/^$/.test(e))return;const t=we(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;const r=e.length;let n=0,a=0;for(;a-1){const r=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,r).trim(),t[3]=""}}let r=t[2],n="";if(this.options.pedantic){const e=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(r);e&&(r=e[1],n=e[3])}else n=t[3]?t[3].slice(1,-1):"";return r=r.trim(),/^$/.test(e)?r.slice(1):r.slice(1,-1)),$e(t,{href:r?r.replace(this.rules.inline._escapes,"$1"):r,title:n?n.replace(this.rules.inline._escapes,"$1"):n},t[0],this.lexer)}}reflink(e,t){let r;if((r=this.rules.inline.reflink.exec(e))||(r=this.rules.inline.nolink.exec(e))){let e=(r[2]||r[1]).replace(/\s+/g," ");if(e=t[e.toLowerCase()],!e||!e.href){const e=r[0].charAt(0);return{type:"text",raw:e,text:e}}return $e(r,e,r[0],this.lexer)}}emStrong(e,t,r=""){let n=this.rules.inline.emStrong.lDelim.exec(e);if(!n)return;if(n[3]&&r.match(/[\p{L}\p{N}]/u))return;const a=n[1]||n[2]||"";if(!a||a&&(""===r||this.rules.inline.punctuation.exec(r))){const r=n[0].length-1;let a,o,i=r,s=0;const l="*"===n[0][0]?this.rules.inline.emStrong.rDelimAst:this.rules.inline.emStrong.rDelimUnd;for(l.lastIndex=0,t=t.slice(-1*e.length+r);null!=(n=l.exec(t));){if(a=n[1]||n[2]||n[3]||n[4]||n[5]||n[6],!a)continue;if(o=a.length,n[3]||n[4]){i+=o;continue}if((n[5]||n[6])&&r%3&&!((r+o)%3)){s+=o;continue}if(i-=o,i>0)continue;if(o=Math.min(o,o+i+s),Math.min(r,o)%2){const t=e.slice(1,r+n.index+o);return{type:"em",raw:e.slice(0,r+n.index+o+1),text:t,tokens:this.lexer.inlineTokens(t,[])}}const t=e.slice(2,r+n.index+o-1);return{type:"strong",raw:e.slice(0,r+n.index+o+1),text:t,tokens:this.lexer.inlineTokens(t,[])}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(/\n/g," ");const r=/[^ ]/.test(e),n=/^ /.test(e)&&/ $/.test(e);return r&&n&&(e=e.substring(1,e.length-1)),e=ie(e,!0),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2],[])}}autolink(e,t){const r=this.rules.inline.autolink.exec(e);if(r){let e,n;return"@"===r[2]?(e=ie(this.options.mangle?t(r[1]):r[1]),n="mailto:"+e):(e=ie(r[1]),n=e),{type:"link",raw:r[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e,t){let r;if(r=this.rules.inline.url.exec(e)){let e,n;if("@"===r[2])e=ie(this.options.mangle?t(r[0]):r[0]),n="mailto:"+e;else{let t;do{t=r[0],r[0]=this.rules.inline._backpedal.exec(r[0])[0]}while(t!==r[0]);e=ie(r[0]),n="www."===r[1]?"http://"+e:e}return{type:"link",raw:r[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e,t){const r=this.rules.inline.text.exec(e);if(r){let e;return e=this.lexer.state.inRawBlock?this.options.sanitize?this.options.sanitizer?this.options.sanitizer(r[0]):ie(r[0]):r[0]:ie(this.options.smartypants?t(r[0]):r[0]),{type:"text",raw:r[0],text:e}}}}const Ee={newline:/^(?: *(?:\n|$))+/,code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,fences:/^ {0,3}(`{3,}(?=[^`\n]*\n)|~{3,})([^\n]*)\n(?:|([\s\S]*?)\n)(?: {0,3}\1[~`]* *(?=\n|$)|$)/,hr:/^ {0,3}((?:- *){3,}|(?:_ *){3,}|(?:\* *){3,})(?:\n+|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,blockquote:/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/,list:/^( {0,3}bull)( [^\n]+?)?(?:\n|$)/,html:"^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))",def:/^ {0,3}\[(label)\]: *(?:\n *)?([^\s>]+)>?(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/,table:ve,lheading:/^([^\n]+)\n {0,3}(=+|-+) *(?:\n+|$)/,_paragraph:/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,text:/^[^\n]+/,_label:/(?!\s*\])(?:\\.|[^\[\]\\])+/,_title:/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/};Ee.def=pe(Ee.def).replace("label",Ee._label).replace("title",Ee._title).getRegex(),Ee.bullet=/(?:[*+-]|\d{1,9}[.)])/,Ee.listItemStart=pe(/^( *)(bull) */).replace("bull",Ee.bullet).getRegex(),Ee.list=pe(Ee.list).replace(/bull/g,Ee.bullet).replace("hr","\\n+(?=\\1?(?:(?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$))").replace("def","\\n+(?="+Ee.def.source+")").getRegex(),Ee._tag="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",Ee._comment=/|$)/,Ee.html=pe(Ee.html,"i").replace("comment",Ee._comment).replace("tag",Ee._tag).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),Ee.paragraph=pe(Ee._paragraph).replace("hr",Ee.hr).replace("heading"," {0,3}#{1,6} ").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",Ee._tag).getRegex(),Ee.blockquote=pe(Ee.blockquote).replace("paragraph",Ee.paragraph).getRegex(),Ee.normal=be({},Ee),Ee.gfm=be({},Ee.normal,{table:"^ *([^\\n ].*\\|.*)\\n {0,3}(?:\\| *)?(:?-+:? *(?:\\| *:?-+:? *)*)(?:\\| *)?(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)"}),Ee.gfm.table=pe(Ee.gfm.table).replace("hr",Ee.hr).replace("heading"," {0,3}#{1,6} ").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",Ee._tag).getRegex(),Ee.gfm.paragraph=pe(Ee._paragraph).replace("hr",Ee.hr).replace("heading"," {0,3}#{1,6} ").replace("|lheading","").replace("table",Ee.gfm.table).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",Ee._tag).getRegex(),Ee.pedantic=be({},Ee.normal,{html:pe("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?\\1> *(?:\\n{2,}|\\s*$)| \\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",Ee._comment).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:ve,paragraph:pe(Ee.normal._paragraph).replace("hr",Ee.hr).replace("heading"," *#{1,6} *[^\n]").replace("lheading",Ee.lheading).replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").getRegex()});const Oe={escape:/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,autolink:/^<(scheme:[^\s\x00-\x1f<>]*|email)>/,url:ve,tag:"^comment|^[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^",link:/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/,reflink:/^!?\[(label)\]\[(ref)\]/,nolink:/^!?\[(ref)\](?:\[\])?/,reflinkSearch:"reflink|nolink(?!\\()",emStrong:{lDelim:/^(?:\*+(?:([punct_])|[^\s*]))|^_+(?:([punct*])|([^\s_]))/,rDelimAst:/^[^_*]*?\_\_[^_*]*?\*[^_*]*?(?=\_\_)|[punct_](\*+)(?=[\s]|$)|[^punct*_\s](\*+)(?=[punct_\s]|$)|[punct_\s](\*+)(?=[^punct*_\s])|[\s](\*+)(?=[punct_])|[punct_](\*+)(?=[punct_])|[^punct*_\s](\*+)(?=[^punct*_\s])/,rDelimUnd:/^[^_*]*?\*\*[^_*]*?\_[^_*]*?(?=\*\*)|[punct*](\_+)(?=[\s]|$)|[^punct*_\s](\_+)(?=[punct*\s]|$)|[punct*\s](\_+)(?=[^punct*_\s])|[\s](\_+)(?=[punct*])|[punct*](\_+)(?=[punct*])/},code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,br:/^( {2,}|\\)\n(?!\s*$)/,del:ve,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\.5&&(r="x"+r.toString(16)),n+=""+r+";";return n}Oe._punctuation="!\"#$%&'()+\\-.,/:;<=>?@\\[\\]`^{|}~",Oe.punctuation=pe(Oe.punctuation).replace(/punctuation/g,Oe._punctuation).getRegex(),Oe.blockSkip=/\[[^\]]*?\]\([^\)]*?\)|`[^`]*?`|<[^>]*?>/g,Oe.escapedEmSt=/\\\*|\\_/g,Oe._comment=pe(Ee._comment).replace("(?:--\x3e|$)","--\x3e").getRegex(),Oe.emStrong.lDelim=pe(Oe.emStrong.lDelim).replace(/punct/g,Oe._punctuation).getRegex(),Oe.emStrong.rDelimAst=pe(Oe.emStrong.rDelimAst,"g").replace(/punct/g,Oe._punctuation).getRegex(),Oe.emStrong.rDelimUnd=pe(Oe.emStrong.rDelimUnd,"g").replace(/punct/g,Oe._punctuation).getRegex(),Oe._escapes=/\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/g,Oe._scheme=/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/,Oe._email=/[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])?)+(?![-_])/,Oe.autolink=pe(Oe.autolink).replace("scheme",Oe._scheme).replace("email",Oe._email).getRegex(),Oe._attribute=/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/,Oe.tag=pe(Oe.tag).replace("comment",Oe._comment).replace("attribute",Oe._attribute).getRegex(),Oe._label=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Oe._href=/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/,Oe._title=/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/,Oe.link=pe(Oe.link).replace("label",Oe._label).replace("href",Oe._href).replace("title",Oe._title).getRegex(),Oe.reflink=pe(Oe.reflink).replace("label",Oe._label).replace("ref",Ee._label).getRegex(),Oe.nolink=pe(Oe.nolink).replace("ref",Ee._label).getRegex(),Oe.reflinkSearch=pe(Oe.reflinkSearch,"g").replace("reflink",Oe.reflink).replace("nolink",Oe.nolink).getRegex(),Oe.normal=be({},Oe),Oe.pedantic=be({},Oe.normal,{strong:{start:/^__|\*\*/,middle:/^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/,endAst:/\*\*(?!\*)/g,endUnd:/__(?!_)/g},em:{start:/^_|\*/,middle:/^()\*(?=\S)([\s\S]*?\S)\*(?!\*)|^_(?=\S)([\s\S]*?\S)_(?!_)/,endAst:/\*(?!\*)/g,endUnd:/_(?!_)/g},link:pe(/^!?\[(label)\]\((.*?)\)/).replace("label",Oe._label).getRegex(),reflink:pe(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",Oe._label).getRegex()}),Oe.gfm=be({},Oe.normal,{escape:pe(Oe.escape).replace("])","~|])").getRegex(),_extended_email:/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/,url:/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,_backpedal:/(?:[^?!.,:;*_~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\!!(r=n.call({lexer:this},e,t))&&(e=e.substring(r.raw.length),t.push(r),!0)))))if(r=this.tokenizer.space(e))e=e.substring(r.raw.length),1===r.raw.length&&t.length>0?t[t.length-1].raw+="\n":t.push(r);else if(r=this.tokenizer.code(e))e=e.substring(r.raw.length),n=t[t.length-1],!n||"paragraph"!==n.type&&"text"!==n.type?t.push(r):(n.raw+="\n"+r.raw,n.text+="\n"+r.text,this.inlineQueue[this.inlineQueue.length-1].src=n.text);else if(r=this.tokenizer.fences(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.heading(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.hr(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.blockquote(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.list(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.html(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.def(e))e=e.substring(r.raw.length),n=t[t.length-1],!n||"paragraph"!==n.type&&"text"!==n.type?this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title}):(n.raw+="\n"+r.raw,n.text+="\n"+r.raw,this.inlineQueue[this.inlineQueue.length-1].src=n.text);else if(r=this.tokenizer.table(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.lheading(e))e=e.substring(r.raw.length),t.push(r);else{if(a=e,this.options.extensions&&this.options.extensions.startBlock){let t=1/0;const r=e.slice(1);let n;this.options.extensions.startBlock.forEach((function(e){n=e.call({lexer:this},r),"number"==typeof n&&n>=0&&(t=Math.min(t,n))})),t<1/0&&t>=0&&(a=e.substring(0,t+1))}if(this.state.top&&(r=this.tokenizer.paragraph(a)))n=t[t.length-1],o&&"paragraph"===n.type?(n.raw+="\n"+r.raw,n.text+="\n"+r.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=n.text):t.push(r),o=a.length!==e.length,e=e.substring(r.raw.length);else if(r=this.tokenizer.text(e))e=e.substring(r.raw.length),n=t[t.length-1],n&&"text"===n.type?(n.raw+="\n"+r.raw,n.text+="\n"+r.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=n.text):t.push(r);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t){this.inlineQueue.push({src:e,tokens:t})}inlineTokens(e,t=[]){let r,n,a,o,i,s,l=e;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(o=this.tokenizer.rules.inline.reflinkSearch.exec(l));)e.includes(o[0].slice(o[0].lastIndexOf("[")+1,-1))&&(l=l.slice(0,o.index)+"["+ke("a",o[0].length-2)+"]"+l.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(o=this.tokenizer.rules.inline.blockSkip.exec(l));)l=l.slice(0,o.index)+"["+ke("a",o[0].length-2)+"]"+l.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(o=this.tokenizer.rules.inline.escapedEmSt.exec(l));)l=l.slice(0,o.index)+"++"+l.slice(this.tokenizer.rules.inline.escapedEmSt.lastIndex);for(;e;)if(i||(s=""),i=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some((n=>!!(r=n.call({lexer:this},e,t))&&(e=e.substring(r.raw.length),t.push(r),!0)))))if(r=this.tokenizer.escape(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.tag(e))e=e.substring(r.raw.length),n=t[t.length-1],n&&"text"===r.type&&"text"===n.type?(n.raw+=r.raw,n.text+=r.text):t.push(r);else if(r=this.tokenizer.link(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.reflink(e,this.tokens.links))e=e.substring(r.raw.length),n=t[t.length-1],n&&"text"===r.type&&"text"===n.type?(n.raw+=r.raw,n.text+=r.text):t.push(r);else if(r=this.tokenizer.emStrong(e,l,s))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.codespan(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.br(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.del(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.autolink(e,_e))e=e.substring(r.raw.length),t.push(r);else if(this.state.inLink||!(r=this.tokenizer.url(e,_e))){if(a=e,this.options.extensions&&this.options.extensions.startInline){let t=1/0;const r=e.slice(1);let n;this.options.extensions.startInline.forEach((function(e){n=e.call({lexer:this},r),"number"==typeof n&&n>=0&&(t=Math.min(t,n))})),t<1/0&&t>=0&&(a=e.substring(0,t+1))}if(r=this.tokenizer.inlineText(a,Te))e=e.substring(r.raw.length),"_"!==r.raw.slice(-1)&&(s=r.raw.slice(-1)),i=!0,n=t[t.length-1],n&&"text"===n.type?(n.raw+=r.raw,n.text+=r.text):t.push(r);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}else e=e.substring(r.raw.length),t.push(r);return t}}class je{constructor(e){this.options=e||X}code(e,t,r){const n=(t||"").match(/\S*/)[0];if(this.options.highlight){const t=this.options.highlight(e,n);null!=t&&t!==e&&(r=!0,e=t)}return e=e.replace(/\n$/,"")+"\n",n?''+(r?e:ie(e,!0))+"
\n":""+(r?e:ie(e,!0))+"
\n"}blockquote(e){return"\n"+e+" \n"}html(e){return e}heading(e,t,r,n){return this.options.headerIds?"\n":""+e+" \n"}hr(){return this.options.xhtml?" \n":" \n"}list(e,t,r){const n=t?"ol":"ul";return"<"+n+(t&&1!==r?' start="'+r+'"':"")+">\n"+e+""+n+">\n"}listitem(e){return""+e+" \n"}checkbox(e){return" "}paragraph(e){return""+e+"
\n"}table(e,t){return t&&(t=""+t+" "),"\n"}tablerow(e){return"\n"+e+" \n"}tablecell(e,t){const r=t.header?"th":"td";return(t.align?"<"+r+' align="'+t.align+'">':"<"+r+">")+e+""+r+">\n"}strong(e){return""+e+" "}em(e){return""+e+" "}codespan(e){return""+e+"
"}br(){return this.options.xhtml?" ":" "}del(e){return""+e+""}link(e,t,r){if(null===(e=he(this.options.sanitize,this.options.baseUrl,e)))return r;let n='"+r+" ",n}image(e,t,r){if(null===(e=he(this.options.sanitize,this.options.baseUrl,e)))return r;let n=' ":">",n}text(e){return e}}class Ie{strong(e){return e}em(e){return e}codespan(e){return e}del(e){return e}html(e){return e}text(e){return e}link(e,t,r){return""+r}image(e,t,r){return""+r}br(){return""}}class Pe{constructor(){this.seen={}}serialize(e){return e.toLowerCase().trim().replace(/<[!\/a-z].*?>/gi,"").replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g,"").replace(/\s/g,"-")}getNextSafeSlug(e,t){let r=e,n=0;if(this.seen.hasOwnProperty(r)){n=this.seen[e];do{n++,r=e+"-"+n}while(this.seen.hasOwnProperty(r))}return t||(this.seen[e]=n,this.seen[r]=0),r}slug(e,t={}){const r=this.serialize(e);return this.getNextSafeSlug(r,t.dryrun)}}class Re{constructor(e){this.options=e||X,this.options.renderer=this.options.renderer||new je,this.renderer=this.options.renderer,this.renderer.options=this.options,this.textRenderer=new Ie,this.slugger=new Pe}static parse(e,t){return new Re(t).parse(e)}static parseInline(e,t){return new Re(t).parseInline(e)}parse(e,t=!0){let r,n,a,o,i,s,l,c,p,u,d,h,f,m,y,g,v,b,x,w="";const S=e.length;for(r=0;r0&&"paragraph"===y.tokens[0].type?(y.tokens[0].text=b+" "+y.tokens[0].text,y.tokens[0].tokens&&y.tokens[0].tokens.length>0&&"text"===y.tokens[0].tokens[0].type&&(y.tokens[0].tokens[0].text=b+" "+y.tokens[0].tokens[0].text)):y.tokens.unshift({type:"text",text:b}):m+=b),m+=this.parse(y.tokens,f),p+=this.renderer.listitem(m,v,g);w+=this.renderer.list(p,d,h);continue;case"html":w+=this.renderer.html(u.text);continue;case"paragraph":w+=this.renderer.paragraph(this.parseInline(u.tokens));continue;case"text":for(p=u.tokens?this.parseInline(u.tokens):u.text;r+1{n(e.text,e.lang,(function(t,r){if(t)return o(t);null!=r&&r!==e.text&&(e.text=r,e.escaped=!0),i--,0===i&&o()}))}),0))})),void(0===i&&o())}try{const r=Ce.lex(e,t);return t.walkTokens&&Le.walkTokens(r,t.walkTokens),Re.parse(r,t)}catch(e){if(e.message+="\nPlease report this to https://github.com/markedjs/marked.",t.silent)return" An error occurred:
"+ie(e.message+"",!0)+" ";throw e}}Le.options=Le.setOptions=function(e){var t;return be(Le.defaults,e),t=Le.defaults,X=t,Le},Le.getDefaults=Q,Le.defaults=X,Le.use=function(...e){const t=be({},...e),r=Le.defaults.extensions||{renderers:{},childTokens:{}};let n;e.forEach((e=>{if(e.extensions&&(n=!0,e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if(e.renderer){const t=r.renderers?r.renderers[e.name]:null;r.renderers[e.name]=t?function(...r){let n=e.renderer.apply(this,r);return!1===n&&(n=t.apply(this,r)),n}:e.renderer}if(e.tokenizer){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");r[e.level]?r[e.level].unshift(e.tokenizer):r[e.level]=[e.tokenizer],e.start&&("block"===e.level?r.startBlock?r.startBlock.push(e.start):r.startBlock=[e.start]:"inline"===e.level&&(r.startInline?r.startInline.push(e.start):r.startInline=[e.start]))}e.childTokens&&(r.childTokens[e.name]=e.childTokens)}))),e.renderer){const r=Le.defaults.renderer||new je;for(const t in e.renderer){const n=r[t];r[t]=(...a)=>{let o=e.renderer[t].apply(r,a);return!1===o&&(o=n.apply(r,a)),o}}t.renderer=r}if(e.tokenizer){const r=Le.defaults.tokenizer||new Ae;for(const t in e.tokenizer){const n=r[t];r[t]=(...a)=>{let o=e.tokenizer[t].apply(r,a);return!1===o&&(o=n.apply(r,a)),o}}t.tokenizer=r}if(e.walkTokens){const r=Le.defaults.walkTokens;t.walkTokens=function(t){e.walkTokens.call(this,t),r&&r.call(this,t)}}n&&(t.extensions=r),Le.setOptions(t)}))},Le.walkTokens=function(e,t){for(const r of e)switch(t.call(Le,r),r.type){case"table":for(const e of r.header)Le.walkTokens(e.tokens,t);for(const e of r.rows)for(const r of e)Le.walkTokens(r.tokens,t);break;case"list":Le.walkTokens(r.items,t);break;default:Le.defaults.extensions&&Le.defaults.extensions.childTokens&&Le.defaults.extensions.childTokens[r.type]?Le.defaults.extensions.childTokens[r.type].forEach((function(e){Le.walkTokens(r[e],t)})):r.tokens&&Le.walkTokens(r.tokens,t)}},Le.parseInline=function(e,t){if(null==e)throw new Error("marked.parseInline(): input parameter is undefined or null");if("string"!=typeof e)throw new Error("marked.parseInline(): input parameter is of type "+Object.prototype.toString.call(e)+", string expected");Se(t=be({},Le.defaults,t||{}));try{const r=Ce.lexInline(e,t);return t.walkTokens&&Le.walkTokens(r,t.walkTokens),Re.parseInline(r,t)}catch(e){if(e.message+="\nPlease report this to https://github.com/markedjs/marked.",t.silent)return"An error occurred:
"+ie(e.message+"",!0)+" ";throw e}},Le.Parser=Re,Le.parser=Re.parse,Le.Renderer=je,Le.TextRenderer=Ie,Le.Lexer=Ce,Le.lexer=Ce.lex,Le.Tokenizer=Ae,Le.Slugger=Pe,Le.parse=Le;Le.options,Le.setOptions,Le.use,Le.walkTokens,Le.parseInline,Re.parse,Ce.lex;var Ne=r(660),Fe=r.n(Ne);r(251),r(358),r(46),r(503),r(277),r(874),r(366),r(57),r(16);const De=J`.hover-bg:hover{background:var(--bg3)}::selection{background:var(--selection-bg);color:var(--selection-fg)}.regular-font{font-family:var(--font-regular)}.mono-font{font-family:var(--font-mono)}.title{font-size:calc(var(--font-size-small) + 18px);font-weight:400}.sub-title{font-size:20px}.req-res-title{font-family:var(--font-regular);font-size:calc(var(--font-size-small) + 4px);font-weight:700;margin-bottom:8px;text-align:left}.tiny-title{font-size:calc(var(--font-size-small) + 1px);font-weight:700}.regular-font-size{font-size:var(--font-size-regular)}.small-font-size{font-size:var(--font-size-small)}.upper{text-transform:uppercase}.primary-text{color:var(--primary-color)}.bold-text{font-weight:700}.gray-text{color:var(--light-fg)}.red-text{color:var(--red)}.blue-text{color:var(--blue)}.multiline{overflow:scroll;max-height:var(--resp-area-height,300px);color:var(--fg3)}.method-fg.put{color:var(--orange)}.method-fg.post{color:var(--green)}.method-fg.get{color:var(--blue)}.method-fg.delete{color:var(--red)}.method-fg.head,.method-fg.options,.method-fg.patch{color:var(--yellow)}h1{font-family:var(--font-regular);font-size:28px;padding-top:10px;letter-spacing:normal;font-weight:400}h2{font-family:var(--font-regular);font-size:24px;padding-top:10px;letter-spacing:normal;font-weight:400}h3{font-family:var(--font-regular);font-size:18px;padding-top:10px;letter-spacing:normal;font-weight:400}h4{font-family:var(--font-regular);font-size:16px;padding-top:10px;letter-spacing:normal;font-weight:400}h5{font-family:var(--font-regular);font-size:14px;padding-top:10px;letter-spacing:normal;font-weight:400}h6{font-family:var(--font-regular);font-size:14px;padding-top:10px;letter-spacing:normal;font-weight:400}h1,h2,h3,h4,h5{margin-block-end:.2em}p{margin-block-start:.5em}a{color:var(--blue);cursor:pointer}a.inactive-link{color:var(--fg);text-decoration:none;cursor:text}code,pre{margin:0;font-family:var(--font-mono);font-size:calc(var(--font-size-mono) - 1px)}.m-markdown,.m-markdown-small{display:block}.m-markdown p,.m-markdown span{font-size:var(--font-size-regular);line-height:calc(var(--font-size-regular) + 8px)}.m-markdown li{font-size:var(--font-size-regular);line-height:calc(var(--font-size-regular) + 10px)}.m-markdown-small li,.m-markdown-small p,.m-markdown-small span{font-size:var(--font-size-small);line-height:calc(var(--font-size-small) + 6px)}.m-markdown-small li{line-height:calc(var(--font-size-small) + 8px)}.m-markdown p:not(:first-child){margin-block-start:24px}.m-markdown-small p:not(:first-child){margin-block-start:12px}.m-markdown-small p:first-child{margin-block-start:0}.m-markdown p,.m-markdown-small p{margin-block-end:0}.m-markdown code span{font-size:var(--font-size-mono)}.m-markdown code,.m-markdown-small code{padding:1px 6px;border-radius:2px;color:var(--inline-code-fg);background-color:var(--bg3);font-size:calc(var(--font-size-mono));line-height:1.2}.m-markdown-small code{font-size:calc(var(--font-size-mono) - 1px)}.m-markdown pre,.m-markdown-small pre{white-space:pre-wrap;overflow-x:auto;line-height:normal;border-radius:2px;border:1px solid var(--code-border-color)}.m-markdown pre{padding:12px;background-color:var(--code-bg);color:var(--code-fg)}.m-markdown-small pre{margin-top:4px;padding:2px 4px;background-color:var(--bg3);color:var(--fg2)}.m-markdown pre code,.m-markdown-small pre code{border:none;padding:0}.m-markdown pre code{color:var(--code-fg);background-color:var(--code-bg);background-color:transparent}.m-markdown-small pre code{color:var(--fg2);background-color:var(--bg3)}.m-markdown ol,.m-markdown ul{padding-inline-start:30px}.m-markdown-small ol,.m-markdown-small ul{padding-inline-start:20px}.m-markdown a,.m-markdown-small a{color:var(--blue)}.m-markdown img,.m-markdown-small img{max-width:100%}.m-markdown table,.m-markdown-small table{border-spacing:0;margin:10px 0;border-collapse:separate;border:1px solid var(--border-color);border-radius:var(--border-radius);font-size:calc(var(--font-size-small) + 1px);line-height:calc(var(--font-size-small) + 4px);max-width:100%}.m-markdown-small table{font-size:var(--font-size-small);line-height:calc(var(--font-size-small) + 2px);margin:8px 0}.m-markdown td,.m-markdown th,.m-markdown-small td,.m-markdown-small th{vertical-align:top;border-top:1px solid var(--border-color);line-height:calc(var(--font-size-small) + 4px)}.m-markdown tr:first-child th,.m-markdown-small tr:first-child th{border-top:0 none}.m-markdown td,.m-markdown th{padding:10px 12px}.m-markdown-small td,.m-markdown-small th{padding:8px 8px}.m-markdown th,.m-markdown-small th{font-weight:600;background-color:var(--bg2);vertical-align:middle}.m-markdown-small table code{font-size:calc(var(--font-size-mono) - 2px)}.m-markdown table code{font-size:calc(var(--font-size-mono) - 1px)}.m-markdown blockquote,.m-markdown-small blockquote{margin-inline-start:0;margin-inline-end:0;border-left:3px solid var(--border-color);padding:6px 0 6px 6px}.m-markdown hr{border:1px solid var(--border-color)}`,Be=J`.m-btn{border-radius:var(--border-radius);font-weight:600;display:inline-block;padding:6px 16px;font-size:var(--font-size-small);outline:0;line-height:1;text-align:center;white-space:nowrap;border:2px solid var(--primary-color);background-color:transparent;transition:background-color .2s;user-select:none;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.24)}.m-btn.primary{background-color:var(--primary-color);color:var(--primary-color-invert)}.m-btn.thin-border{border-width:1px}.m-btn.large{padding:8px 14px}.m-btn.small{padding:5px 12px}.m-btn.tiny{padding:5px 6px}.m-btn.circle{border-radius:50%}.m-btn:hover{background-color:var(--primary-color);color:var(--primary-color-invert)}.m-btn.nav{border:2px solid var(--nav-accent-color)}.m-btn.nav:hover{background-color:var(--nav-accent-color)}.m-btn:disabled{background-color:var(--bg3);color:var(--fg3);border-color:var(--fg3);cursor:not-allowed;opacity:.4}.toolbar-btn{cursor:pointer;padding:4px;margin:0 2px;font-size:var(--font-size-small);min-width:50px;color:var(--primary-color-invert);border-radius:2px;border:none;background-color:var(--primary-color)}button,input,pre,select,textarea{color:var(--fg);outline:0;background-color:var(--input-bg);border:1px solid var(--border-color);border-radius:var(--border-radius)}button{font-family:var(--font-regular)}input[type=file],input[type=password],input[type=text],pre,select,textarea{font-family:var(--font-mono);font-weight:400;font-size:var(--font-size-small);transition:border .2s;padding:6px 5px}select{font-family:var(--font-regular);padding:5px 30px 5px 5px;background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2212%22%20height%3D%2212%22%3E%3Cpath%20d%3D%22M10.3%203.3L6%207.6%201.7%203.3A1%201%200%2000.3%204.7l5%205a1%201%200%20001.4%200l5-5a1%201%200%2010-1.4-1.4z%22%20fill%3D%22%23777777%22%2F%3E%3C%2Fsvg%3E");background-position:calc(100% - 5px) center;background-repeat:no-repeat;background-size:10px;-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer}select:hover{border-color:var(--primary-color)}input[type=password]::placeholder,input[type=text]::placeholder,textarea::placeholder{color:var(--placeholder-color);opacity:1}input[type=password]:active,input[type=password]:focus,input[type=text]:active,input[type=text]:focus,select:focus,textarea:active,textarea:focus{border:1px solid var(--primary-color)}input[type=file]{font-family:var(--font-regular);padding:2px;cursor:pointer;border:1px solid var(--primary-color);min-height:calc(var(--font-size-small) + 18px)}input[type=file]::-webkit-file-upload-button{font-family:var(--font-regular);font-size:var(--font-size-small);outline:0;cursor:pointer;padding:3px 8px;border:1px solid var(--primary-color);background-color:var(--primary-color);color:var(--primary-color-invert);border-radius:var(--border-radius);-webkit-appearance:none}pre,textarea{scrollbar-width:thin;scrollbar-color:var(--border-color) var(--input-bg)}pre::-webkit-scrollbar,textarea::-webkit-scrollbar{width:8px;height:8px}pre::-webkit-scrollbar-track,textarea::-webkit-scrollbar-track{background:var(--input-bg)}pre::-webkit-scrollbar-thumb,textarea::-webkit-scrollbar-thumb{border-radius:2px;background-color:var(--border-color)}.link{font-size:var(--font-size-small);text-decoration:underline;color:var(--blue);font-family:var(--font-mono);margin-bottom:2px}input[type=checkbox]:focus{outline:0}input[type=checkbox]{appearance:none;display:inline-block;background-color:var(--light-bg);border:1px solid var(--light-bg);border-radius:9px;cursor:pointer;height:18px;position:relative;transition:border .25s .15s,box-shadow .25s .3s,padding .25s;min-width:36px;width:36px;vertical-align:top}input[type=checkbox]:after{position:absolute;background-color:var(--bg);border:1px solid var(--light-bg);border-radius:8px;content:'';top:0;left:0;right:16px;display:block;height:16px;transition:border .25s .15s,left .25s .1s,right .15s .175s}input[type=checkbox]:checked{box-shadow:inset 0 0 0 13px var(--green);border-color:var(--green)}input[type=checkbox]:checked:after{border:1px solid var(--green);left:16px;right:1px;transition:border .25s,left .15s .25s,right .25s .175s}`,ze=J`.col,.row{display:flex}.row{align-items:center;flex-direction:row}.col{align-items:stretch;flex-direction:column}`,qe=J`.m-table{border-spacing:0;border-collapse:separate;border:1px solid var(--light-border-color);border-radius:var(--border-radius);margin:0;max-width:100%;direction:ltr}.m-table tr:first-child td,.m-table tr:first-child th{border-top:0 none}.m-table td,.m-table th{font-size:var(--font-size-small);line-height:calc(var(--font-size-small) + 4px);padding:4px 5px 4px;vertical-align:top}.m-table.padded-12 td,.m-table.padded-12 th{padding:12px}.m-table td:not([align]),.m-table th:not([align]){text-align:left}.m-table th{color:var(--fg2);font-size:var(--font-size-small);line-height:calc(var(--font-size-small) + 18px);font-weight:600;letter-spacing:normal;background-color:var(--bg2);vertical-align:bottom;border-bottom:1px solid var(--light-border-color)}.m-table>tbody>tr>td,.m-table>tr>td{border-top:1px solid var(--light-border-color);text-overflow:ellipsis;overflow:hidden}.table-title{font-size:var(--font-size-small);font-weight:700;vertical-align:middle;margin:12px 0 4px 0}`,Ue=J`.only-large-screen{display:none}.endpoint-head .path{display:flex;font-family:var(--font-mono);font-size:var(--font-size-small);align-items:center;overflow-wrap:break-word;word-break:break-all}.endpoint-head .descr{font-size:var(--font-size-small);color:var(--light-fg);font-weight:400;align-items:center;overflow-wrap:break-word;word-break:break-all;display:none}.m-endpoint.expanded{margin-bottom:16px}.m-endpoint>.endpoint-head{border-width:1px 1px 1px 5px;border-style:solid;border-color:transparent;border-top-color:var(--light-border-color);display:flex;padding:6px 16px;align-items:center;cursor:pointer}.m-endpoint>.endpoint-head.put.expanded,.m-endpoint>.endpoint-head.put:hover{border-color:var(--orange);background-color:var(--light-orange)}.m-endpoint>.endpoint-head.post.expanded,.m-endpoint>.endpoint-head.post:hover{border-color:var(--green);background-color:var(--light-green)}.m-endpoint>.endpoint-head.get.expanded,.m-endpoint>.endpoint-head.get:hover{border-color:var(--blue);background-color:var(--light-blue)}.m-endpoint>.endpoint-head.delete.expanded,.m-endpoint>.endpoint-head.delete:hover{border-color:var(--red);background-color:var(--light-red)}.m-endpoint>.endpoint-head.head.expanded,.m-endpoint>.endpoint-head.head:hover,.m-endpoint>.endpoint-head.options.expanded,.m-endpoint>.endpoint-head.options:hover,.m-endpoint>.endpoint-head.patch.expanded,.m-endpoint>.endpoint-head.patch:hover{border-color:var(--yellow);background-color:var(--light-yellow)}.m-endpoint>.endpoint-head.deprecated.expanded,.m-endpoint>.endpoint-head.deprecated:hover{border-color:var(--border-color);filter:opacity(.6)}.m-endpoint .endpoint-body{flex-wrap:wrap;padding:16px 0 0 0;border-width:0 1px 1px 5px;border-style:solid;box-shadow:0 4px 3px -3px rgba(0,0,0,.15)}.m-endpoint .endpoint-body.delete{border-color:var(--red)}.m-endpoint .endpoint-body.put{border-color:var(--orange)}.m-endpoint .endpoint-body.post{border-color:var(--green)}.m-endpoint .endpoint-body.get{border-color:var(--blue)}.m-endpoint .endpoint-body.head,.m-endpoint .endpoint-body.options,.m-endpoint .endpoint-body.patch{border-color:var(--yellow)}.m-endpoint .endpoint-body.deprecated{border-color:var(--border-color);filter:opacity(.6)}.endpoint-head .deprecated{color:var(--light-fg);filter:opacity(.6)}.summary{padding:8px 8px}.summary .title{font-size:calc(var(--font-size-regular) + 2px);margin-bottom:6px;word-break:break-all}.method{padding:2px 5px;vertical-align:middle;font-size:var(--font-size-small);height:calc(var(--font-size-small) + 16px);line-height:calc(var(--font-size-small) + 8px);width:60px;border-radius:2px;display:inline-block;text-align:center;font-weight:700;text-transform:uppercase;margin-right:5px}.method.delete{border:2px solid var(--red)}.method.put{border:2px solid var(--orange)}.method.post{border:2px solid var(--green)}.method.get{border:2px solid var(--blue)}.method.get.deprecated{border:2px solid var(--border-color)}.method.head,.method.options,.method.patch{border:2px solid var(--yellow)}.req-resp-container{display:flex;margin-top:16px;align-items:stretch;flex-wrap:wrap;flex-direction:column;border-top:1px solid var(--light-border-color)}.view-mode-request,api-response.view-mode{flex:1;min-height:100px;padding:16px 8px;overflow:hidden}.view-mode-request{border-width:0 0 1px 0;border-style:dashed}.head .view-mode-request,.options .view-mode-request,.patch .view-mode-request{border-color:var(--yellow)}.put .view-mode-request{border-color:var(--orange)}.post .view-mode-request{border-color:var(--green)}.get .view-mode-request{border-color:var(--blue)}.delete .view-mode-request{border-color:var(--red)}@media only screen and (min-width:1024px){.only-large-screen{display:block}.endpoint-head .path{font-size:var(--font-size-regular)}.endpoint-head .descr{display:flex}.descr .m-markdown-small,.endpoint-head .m-markdown-small{display:block}.req-resp-container{flex-direction:var(--layout,row);flex-wrap:nowrap}api-response.view-mode{padding:16px}.view-mode-request.row-layout{border-width:0 1px 0 0;padding:16px}.summary{padding:8px 16px}}`,Me=J`code[class*=language-],pre[class*=language-]{text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;tab-size:2;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-]{white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:var(--light-fg)}.token.punctuation{color:var(--fg)}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:var(--pink)}.token.function-name{color:var(--blue)}.token.boolean,.token.function,.token.number{color:var(--red)}.token.class-name,.token.constant,.token.property,.token.symbol{color:var(--code-property-color)}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:var(--code-keyword-color)}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:var(--green)}.token.entity,.token.operator,.token.url{color:var(--code-operator-color)}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green}`,He=J`.tab-panel{border:none}.tab-buttons{height:30px;border-bottom:1px solid var(--light-border-color);align-items:stretch;overflow-y:hidden;overflow-x:auto;scrollbar-width:thin}.tab-buttons::-webkit-scrollbar{height:1px;background-color:var(--border-color)}.tab-btn{border:none;border-bottom:3px solid transparent;color:var(--light-fg);background-color:transparent;white-space:nowrap;cursor:pointer;outline:0;font-family:var(--font-regular);font-size:var(--font-size-small);margin-right:16px;padding:1px}.tab-btn.active{border-bottom:3px solid var(--primary-color);font-weight:700;color:var(--primary-color)}.tab-btn:hover{color:var(--primary-color)}.tab-content{margin:-1px 0 0 0;position:relative}`,Ve=J`.nav-bar{width:0;height:100%;overflow:hidden;color:var(--nav-text-color);background-color:var(--nav-bg-color);background-blend-mode:multiply;line-height:calc(var(--font-size-small) + 4px);display:none;position:relative;flex-direction:column;flex-wrap:nowrap;word-break:break-word}::slotted([slot=nav-logo]){padding:16px 16px 0 16px}.nav-scroll{overflow-x:hidden;overflow-y:auto;overflow-y:overlay;scrollbar-width:thin;scrollbar-color:var(--nav-hover-bg-color) transparent}.nav-bar-tag{display:flex;align-items:center;justify-content:space-between;flex-direction:row}.nav-bar.read .nav-bar-tag-icon{display:none}.nav-bar-tag-icon{color:var(--nav-text-color);font-size:20px}.nav-bar-tag-icon:hover{color:var(--nav-hover-text-color)}.nav-bar.focused .nav-bar-tag-and-paths.collapsed .nav-bar-paths-under-tag{display:none}.nav-bar.focused .nav-bar-tag-and-paths.collapsed .nav-bar-tag-icon::after{content:'⌵';width:16px;height:16px;text-align:center;display:inline-block;transform:rotate(270deg)}.nav-bar.focused .nav-bar-tag-and-paths.expanded .nav-bar-tag-icon::after{content:'⌵';width:16px;height:16px;text-align:center;display:inline-block}.nav-scroll::-webkit-scrollbar{width:var(--scroll-bar-width,8px)}.nav-scroll::-webkit-scrollbar-track{background:0 0}.nav-scroll::-webkit-scrollbar-thumb{background-color:var(--nav-hover-bg-color)}.nav-bar-tag{font-size:var(--font-size-regular);color:var(--nav-accent-color);border-left:4px solid transparent;font-weight:700;padding:15px 15px 15px 10px;text-transform:capitalize}.nav-bar-components,.nav-bar-h1,.nav-bar-h2,.nav-bar-info,.nav-bar-path,.nav-bar-tag{display:flex;cursor:pointer;border-left:4px solid transparent}.nav-bar-h1,.nav-bar-h2,.nav-bar-path{font-size:calc(var(--font-size-small) + 1px);padding:var(--nav-item-padding)}.nav-bar-path.small-font{font-size:var(--font-size-small)}.nav-bar-info{font-size:var(--font-size-regular);padding:16px 10px;font-weight:700}.nav-bar-section{display:flex;flex-direction:row;justify-content:space-between;font-size:var(--font-size-small);color:var(--nav-text-color);padding:var(--nav-item-padding);font-weight:700}.nav-bar-section.operations{cursor:pointer}.nav-bar-section.operations:hover{color:var(--nav-hover-text-color);background-color:var(--nav-hover-bg-color)}.nav-bar-section:first-child{display:none}.nav-bar-h2{margin-left:12px}.nav-bar-h1.active,.nav-bar-h2.active,.nav-bar-info.active,.nav-bar-path.active,.nav-bar-section.operations.active,.nav-bar-tag.active{border-left:4px solid var(--nav-accent-color);color:var(--nav-hover-text-color)}.nav-bar-h1:hover,.nav-bar-h2:hover,.nav-bar-info:hover,.nav-bar-path:hover,.nav-bar-tag:hover{color:var(--nav-hover-text-color);background-color:var(--nav-hover-bg-color)}`,We=J`#api-info{font-size:calc(var(--font-size-regular) - 1px);margin-top:8px margin-left: -15px}#api-info span:before{content:"|";display:inline-block;opacity:.5;width:15px;text-align:center}#api-info span:first-child:before{content:"";width:0}`,Ge=J``;const Ke=/[\s#:?&={}]/g,Je="_rapidoc_api_key";function Ye(e){return new Promise((t=>setTimeout(t,e)))}function Ze(e,t){const r=t.currentTarget,n=document.createElement("textarea");n.value=e,n.style.position="fixed",document.body.appendChild(n),n.focus(),n.select();try{document.execCommand("copy"),r.innerText="Copied",setTimeout((()=>{r.innerText="Copy"}),5e3)}catch(e){console.error("Unable to copy",e)}document.body.removeChild(n)}function Qe(e,t,r="includes"){if("includes"===r){return`${t.method} ${t.path} ${t.summary||t.description||""} ${t.operationId||""}`.toLowerCase().includes(e.toLowerCase())}return new RegExp(e,"i").test(`${t.method} ${t.path}`)}function Xe(e,t=new Set){return e?(Object.keys(e).forEach((r=>{var n;if(t.add(r),e[r].properties)Xe(e[r].properties,t);else if(null!==(n=e[r].items)&&void 0!==n&&n.properties){var a;Xe(null===(a=e[r].items)||void 0===a?void 0:a.properties,t)}})),t):t}function et(e,t){if(e){const r=document.createElement("a");document.body.appendChild(r),r.style="display: none",r.href=e,r.download=t,r.click(),r.remove()}}function tt(e){if(e){const t=document.createElement("a");document.body.appendChild(t),t.style="display: none",t.href=e,t.target="_blank",t.click(),t.remove()}}var rt=r(764).Buffer;function nt(e){if(e.__esModule)return e;var t=Object.defineProperty({},"__esModule",{value:!0});return Object.keys(e).forEach((function(r){var n=Object.getOwnPropertyDescriptor(e,r);Object.defineProperty(t,r,n.get?n:{enumerable:!0,get:function(){return e[r]}})})),t}var at=function(e){return e&&e.Math==Math&&e},ot=at("object"==typeof globalThis&&globalThis)||at("object"==typeof window&&window)||at("object"==typeof self&&self)||at("object"==typeof ot&&ot)||function(){return this}()||Function("return this")(),it=function(e){try{return!!e()}catch(e){return!0}},st=!it((function(){var e=function(){}.bind();return"function"!=typeof e||e.hasOwnProperty("prototype")})),lt=st,ct=Function.prototype,pt=ct.apply,ut=ct.call,dt="object"==typeof Reflect&&Reflect.apply||(lt?ut.bind(pt):function(){return ut.apply(pt,arguments)}),ht=st,ft=Function.prototype,mt=ft.bind,yt=ft.call,gt=ht&&mt.bind(yt,yt),vt=ht?function(e){return e&>(e)}:function(e){return e&&function(){return yt.apply(e,arguments)}},bt=function(e){return"function"==typeof e},xt={},wt=!it((function(){return 7!=Object.defineProperty({},1,{get:function(){return 7}})[1]})),St=st,kt=Function.prototype.call,$t=St?kt.bind(kt):function(){return kt.apply(kt,arguments)},At={},Et={}.propertyIsEnumerable,Ot=Object.getOwnPropertyDescriptor,Tt=Ot&&!Et.call({1:2},1);At.f=Tt?function(e){var t=Ot(this,e);return!!t&&t.enumerable}:Et;var _t,Ct,jt=function(e,t){return{enumerable:!(1&e),configurable:!(2&e),writable:!(4&e),value:t}},It=vt,Pt=It({}.toString),Rt=It("".slice),Lt=function(e){return Rt(Pt(e),8,-1)},Nt=vt,Ft=it,Dt=Lt,Bt=ot.Object,zt=Nt("".split),qt=Ft((function(){return!Bt("z").propertyIsEnumerable(0)}))?function(e){return"String"==Dt(e)?zt(e,""):Bt(e)}:Bt,Ut=ot.TypeError,Mt=function(e){if(null==e)throw Ut("Can't call method on "+e);return e},Ht=qt,Vt=Mt,Wt=function(e){return Ht(Vt(e))},Gt=bt,Kt=function(e){return"object"==typeof e?null!==e:Gt(e)},Jt={},Yt=Jt,Zt=ot,Qt=bt,Xt=function(e){return Qt(e)?e:void 0},er=function(e,t){return arguments.length<2?Xt(Yt[e])||Xt(Zt[e]):Yt[e]&&Yt[e][t]||Zt[e]&&Zt[e][t]},tr=vt({}.isPrototypeOf),rr=er("navigator","userAgent")||"",nr=ot,ar=rr,or=nr.process,ir=nr.Deno,sr=or&&or.versions||ir&&ir.version,lr=sr&&sr.v8;lr&&(Ct=(_t=lr.split("."))[0]>0&&_t[0]<4?1:+(_t[0]+_t[1])),!Ct&&ar&&(!(_t=ar.match(/Edge\/(\d+)/))||_t[1]>=74)&&(_t=ar.match(/Chrome\/(\d+)/))&&(Ct=+_t[1]);var cr=Ct,pr=cr,ur=it,dr=!!Object.getOwnPropertySymbols&&!ur((function(){var e=Symbol();return!String(e)||!(Object(e)instanceof Symbol)||!Symbol.sham&&pr&&pr<41})),hr=dr&&!Symbol.sham&&"symbol"==typeof Symbol.iterator,fr=er,mr=bt,yr=tr,gr=hr,vr=ot.Object,br=gr?function(e){return"symbol"==typeof e}:function(e){var t=fr("Symbol");return mr(t)&&yr(t.prototype,vr(e))},xr=ot.String,wr=function(e){try{return xr(e)}catch(e){return"Object"}},Sr=bt,kr=wr,$r=ot.TypeError,Ar=function(e){if(Sr(e))return e;throw $r(kr(e)+" is not a function")},Er=Ar,Or=function(e,t){var r=e[t];return null==r?void 0:Er(r)},Tr=$t,_r=bt,Cr=Kt,jr=ot.TypeError,Ir={exports:{}},Pr=ot,Rr=Object.defineProperty,Lr=function(e,t){try{Rr(Pr,e,{value:t,configurable:!0,writable:!0})}catch(r){Pr[e]=t}return t},Nr="__core-js_shared__",Fr=ot[Nr]||Lr(Nr,{}),Dr=Fr;(Ir.exports=function(e,t){return Dr[e]||(Dr[e]=void 0!==t?t:{})})("versions",[]).push({version:"3.21.1",mode:"pure",copyright:"© 2014-2022 Denis Pushkarev (zloirock.ru)",license:"https://github.com/zloirock/core-js/blob/v3.21.1/LICENSE",source:"https://github.com/zloirock/core-js"});var Br=Mt,zr=ot.Object,qr=function(e){return zr(Br(e))},Ur=qr,Mr=vt({}.hasOwnProperty),Hr=Object.hasOwn||function(e,t){return Mr(Ur(e),t)},Vr=vt,Wr=0,Gr=Math.random(),Kr=Vr(1..toString),Jr=function(e){return"Symbol("+(void 0===e?"":e)+")_"+Kr(++Wr+Gr,36)},Yr=ot,Zr=Ir.exports,Qr=Hr,Xr=Jr,en=dr,tn=hr,rn=Zr("wks"),nn=Yr.Symbol,an=nn&&nn.for,on=tn?nn:nn&&nn.withoutSetter||Xr,sn=function(e){if(!Qr(rn,e)||!en&&"string"!=typeof rn[e]){var t="Symbol."+e;en&&Qr(nn,e)?rn[e]=nn[e]:rn[e]=tn&&an?an(t):on(t)}return rn[e]},ln=$t,cn=Kt,pn=br,un=Or,dn=function(e,t){var r,n;if("string"===t&&_r(r=e.toString)&&!Cr(n=Tr(r,e)))return n;if(_r(r=e.valueOf)&&!Cr(n=Tr(r,e)))return n;if("string"!==t&&_r(r=e.toString)&&!Cr(n=Tr(r,e)))return n;throw jr("Can't convert object to primitive value")},hn=sn,fn=ot.TypeError,mn=hn("toPrimitive"),yn=function(e,t){if(!cn(e)||pn(e))return e;var r,n=un(e,mn);if(n){if(void 0===t&&(t="default"),r=ln(n,e,t),!cn(r)||pn(r))return r;throw fn("Can't convert object to primitive value")}return void 0===t&&(t="number"),dn(e,t)},gn=br,vn=function(e){var t=yn(e,"string");return gn(t)?t:t+""},bn=Kt,xn=ot.document,wn=bn(xn)&&bn(xn.createElement),Sn=function(e){return wn?xn.createElement(e):{}},kn=Sn,$n=!wt&&!it((function(){return 7!=Object.defineProperty(kn("div"),"a",{get:function(){return 7}}).a})),An=wt,En=$t,On=At,Tn=jt,_n=Wt,Cn=vn,jn=Hr,In=$n,Pn=Object.getOwnPropertyDescriptor;xt.f=An?Pn:function(e,t){if(e=_n(e),t=Cn(t),In)try{return Pn(e,t)}catch(e){}if(jn(e,t))return Tn(!En(On.f,e,t),e[t])};var Rn=it,Ln=bt,Nn=/#|\.prototype\./,Fn=function(e,t){var r=Bn[Dn(e)];return r==qn||r!=zn&&(Ln(t)?Rn(t):!!t)},Dn=Fn.normalize=function(e){return String(e).replace(Nn,".").toLowerCase()},Bn=Fn.data={},zn=Fn.NATIVE="N",qn=Fn.POLYFILL="P",Un=Fn,Mn=Ar,Hn=st,Vn=vt(vt.bind),Wn=function(e,t){return Mn(e),void 0===t?e:Hn?Vn(e,t):function(){return e.apply(t,arguments)}},Gn={},Kn=wt&&it((function(){return 42!=Object.defineProperty((function(){}),"prototype",{value:42,writable:!1}).prototype})),Jn=ot,Yn=Kt,Zn=Jn.String,Qn=Jn.TypeError,Xn=function(e){if(Yn(e))return e;throw Qn(Zn(e)+" is not an object")},ea=wt,ta=$n,ra=Kn,na=Xn,aa=vn,oa=ot.TypeError,ia=Object.defineProperty,sa=Object.getOwnPropertyDescriptor,la="enumerable",ca="configurable",pa="writable";Gn.f=ea?ra?function(e,t,r){if(na(e),t=aa(t),na(r),"function"==typeof e&&"prototype"===t&&"value"in r&&pa in r&&!r.writable){var n=sa(e,t);n&&n.writable&&(e[t]=r.value,r={configurable:ca in r?r.configurable:n.configurable,enumerable:la in r?r.enumerable:n.enumerable,writable:!1})}return ia(e,t,r)}:ia:function(e,t,r){if(na(e),t=aa(t),na(r),ta)try{return ia(e,t,r)}catch(e){}if("get"in r||"set"in r)throw oa("Accessors not supported");return"value"in r&&(e[t]=r.value),e};var ua=Gn,da=jt,ha=wt?function(e,t,r){return ua.f(e,t,da(1,r))}:function(e,t,r){return e[t]=r,e},fa=ot,ma=dt,ya=vt,ga=bt,va=xt.f,ba=Un,xa=Jt,wa=Wn,Sa=ha,ka=Hr,$a=function(e){var t=function(r,n,a){if(this instanceof t){switch(arguments.length){case 0:return new e;case 1:return new e(r);case 2:return new e(r,n)}return new e(r,n,a)}return ma(e,this,arguments)};return t.prototype=e.prototype,t},Aa=function(e,t){var r,n,a,o,i,s,l,c,p=e.target,u=e.global,d=e.stat,h=e.proto,f=u?fa:d?fa[p]:(fa[p]||{}).prototype,m=u?xa:xa[p]||Sa(xa,p,{})[p],y=m.prototype;for(a in t)r=!ba(u?a:p+(d?".":"#")+a,e.forced)&&f&&ka(f,a),i=m[a],r&&(s=e.noTargetGet?(c=va(f,a))&&c.value:f[a]),o=r&&s?s:t[a],r&&typeof i==typeof o||(l=e.bind&&r?wa(o,fa):e.wrap&&r?$a(o):h&&ga(o)?ya(o):o,(e.sham||o&&o.sham||i&&i.sham)&&Sa(l,"sham",!0),Sa(m,a,l),h&&(ka(xa,n=p+"Prototype")||Sa(xa,n,{}),Sa(xa[n],a,o),e.real&&y&&!y[a]&&Sa(y,a,o)))},Ea=Math.ceil,Oa=Math.floor,Ta=function(e){var t=+e;return t!=t||0===t?0:(t>0?Oa:Ea)(t)},_a=Ta,Ca=Math.max,ja=Math.min,Ia=function(e,t){var r=_a(e);return r<0?Ca(r+t,0):ja(r,t)},Pa=Ta,Ra=Math.min,La=function(e){return e>0?Ra(Pa(e),9007199254740991):0},Na=La,Fa=function(e){return Na(e.length)},Da=Wt,Ba=Ia,za=Fa,qa=function(e){return function(t,r,n){var a,o=Da(t),i=za(o),s=Ba(n,i);if(e&&r!=r){for(;i>s;)if((a=o[s++])!=a)return!0}else for(;i>s;s++)if((e||s in o)&&o[s]===r)return e||s||0;return!e&&-1}},Ua={includes:qa(!0),indexOf:qa(!1)},Ma={},Ha=Hr,Va=Wt,Wa=Ua.indexOf,Ga=Ma,Ka=vt([].push),Ja=function(e,t){var r,n=Va(e),a=0,o=[];for(r in n)!Ha(Ga,r)&&Ha(n,r)&&Ka(o,r);for(;t.length>a;)Ha(n,r=t[a++])&&(~Wa(o,r)||Ka(o,r));return o},Ya=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"],Za=Ja,Qa=Ya,Xa=Object.keys||function(e){return Za(e,Qa)},eo=qr,to=Xa;Aa({target:"Object",stat:!0,forced:it((function(){to(1)}))},{keys:function(e){return to(eo(e))}});var ro=Jt.Object.keys,no=ro,ao=Lt,oo=Array.isArray||function(e){return"Array"==ao(e)},io={};io[sn("toStringTag")]="z";var so="[object z]"===String(io),lo=ot,co=so,po=bt,uo=Lt,ho=sn("toStringTag"),fo=lo.Object,mo="Arguments"==uo(function(){return arguments}()),yo=co?uo:function(e){var t,r,n;return void 0===e?"Undefined":null===e?"Null":"string"==typeof(r=function(e,t){try{return e[t]}catch(e){}}(t=fo(e),ho))?r:mo?uo(t):"Object"==(n=uo(t))&&po(t.callee)?"Arguments":n},go=yo,vo=ot.String,bo=function(e){if("Symbol"===go(e))throw TypeError("Cannot convert a Symbol value to a string");return vo(e)},xo={},wo=wt,So=Kn,ko=Gn,$o=Xn,Ao=Wt,Eo=Xa;xo.f=wo&&!So?Object.defineProperties:function(e,t){$o(e);for(var r,n=Ao(t),a=Eo(t),o=a.length,i=0;o>i;)ko.f(e,r=a[i++],n[r]);return e};var Oo,To=er("document","documentElement"),_o=Ir.exports,Co=Jr,jo=_o("keys"),Io=function(e){return jo[e]||(jo[e]=Co(e))},Po=Xn,Ro=xo,Lo=Ya,No=Ma,Fo=To,Do=Sn,Bo=Io("IE_PROTO"),zo=function(){},qo=function(e){return"
+
+
+
+