From 867dc7740a6edf96320e610b69857365977cc429 Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 10 Jul 2026 18:34:44 +0200 Subject: [PATCH] Replace from-scratch rewrite with real ngoduykhanh/wireguard-ui fork The from-scratch Go rewrite had unresolved bugs (missing go.sum, UI 404s, path issues) from being built without a working local Go toolchain to verify against. Switching strategy: use the actual upstream wireguard-ui codebase (proven, battle-tested single-server manager) as the base, and extend it for multi-server support instead of re-deriving everything from zero. Kept our own installers (bootstrap.sh, update.sh, scripts/install.sh, scripts/proxmox-install.sh) - these still apply, just need updating to build/install the upstream module layout instead of the old cmd/wireguard-ui-multi structure. Module path intentionally left as upstream's own (github.com/ngoduykhanh/wireguard-ui) for now to avoid touching every internal import; revisit if this needs to be fully rebranded. Co-Authored-By: Claude Sonnet 5 --- ...ttings.local.json.tmp.2113725.efbfaa50a5c4 | 10 + CONTRIBUTING.md | 67 + DEVLOG.md | 382 +++ Dockerfile | 77 + LICENSE | 21 + README.md | 430 ++- assets/.gitkeep | 0 cmd/wireguard-ui-multi/main.go | 142 - custom/img/favicon.ico | Bin 0 -> 17014 bytes custom/js/helper.js | 145 + custom/js/wake_on_lan_hosts.js | 210 ++ db/.gitignore | 5 + docker-compose.yaml | 27 + emailer/interface.go | 10 + emailer/sendgrid.go | 54 + emailer/smtp.go | 100 + examples/docker-compose/README.md | 30 + examples/docker-compose/boringtun.yml | 43 + examples/docker-compose/linuxserver.yml | 42 + examples/docker-compose/system.yml | 27 + go.mod | 51 +- go.sum | 188 + handler/middlewares.go | 20 + handler/response.go | 6 + handler/routes.go | 1196 +++++++ handler/routes_wake_on_lan.go | 172 + handler/session.go | 249 ++ init.sh | 23 + internal/api/auth.go | 139 - internal/api/handlers.go | 484 --- internal/api/router.go | 97 - internal/api/ui_handlers.go | 39 - internal/database/database.go | 85 - internal/firewall/nftables.go | 75 - internal/server/model.go | 205 -- internal/ui/static/app.js | 106 - internal/ui/static/login.js | 25 - internal/ui/static/server.js | 173 - internal/ui/static/style.css | 209 -- internal/ui/templates/dashboard.html | 19 - internal/ui/templates/login.html | 18 - internal/ui/templates/server.html | 60 - internal/wireguard/config.go | 86 - internal/wireguard/keys.go | 47 - internal/wireguard/manager.go | 78 - internal/wireguard/migrate.go | 203 -- main.go | 340 ++ model/client.go | 38 + model/client_defaults.go | 9 + model/misc.go | 20 + model/server.go | 28 + model/setting.go | 17 + model/user.go | 10 + model/wake_on_lan_host.go | 31 + package.json | 13 + prepare_assets.sh | 29 + router/router.go | 158 + router/validator.go | 20 + store/jsondb/jsondb.go | 410 +++ store/jsondb/jsondb_wake_on_lan.go | 88 + store/store.go | 30 + systemd/wireguard-ui-multi.service | 22 - telegram/bot.go | 161 + templates/about.html | 145 + templates/base.html | 674 ++++ templates/clients.html | 964 ++++++ templates/global_settings.html | 284 ++ templates/login.html | 130 + templates/profile.html | 136 + templates/server.html | 255 ++ templates/status.html | 75 + templates/users_settings.html | 294 ++ templates/wake_on_lan_hosts.html | 123 + templates/wg.conf | 33 + util/cache.go | 8 + util/config.go | 119 + util/hash.go | 32 + util/util.go | 876 +++++ yarn.lock | 3052 +++++++++++++++++ 79 files changed, 11951 insertions(+), 2548 deletions(-) create mode 100644 .claude/settings.local.json.tmp.2113725.efbfaa50a5c4 create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 assets/.gitkeep delete mode 100644 cmd/wireguard-ui-multi/main.go create mode 100644 custom/img/favicon.ico create mode 100644 custom/js/helper.js create mode 100644 custom/js/wake_on_lan_hosts.js create mode 100644 db/.gitignore create mode 100644 docker-compose.yaml create mode 100644 emailer/interface.go create mode 100644 emailer/sendgrid.go create mode 100644 emailer/smtp.go create mode 100644 examples/docker-compose/README.md create mode 100644 examples/docker-compose/boringtun.yml create mode 100644 examples/docker-compose/linuxserver.yml create mode 100644 examples/docker-compose/system.yml create mode 100644 go.sum create mode 100644 handler/middlewares.go create mode 100644 handler/response.go create mode 100644 handler/routes.go create mode 100644 handler/routes_wake_on_lan.go create mode 100644 handler/session.go create mode 100755 init.sh delete mode 100644 internal/api/auth.go delete mode 100644 internal/api/handlers.go delete mode 100644 internal/api/router.go delete mode 100644 internal/api/ui_handlers.go delete mode 100644 internal/database/database.go delete mode 100644 internal/firewall/nftables.go delete mode 100644 internal/server/model.go delete mode 100644 internal/ui/static/app.js delete mode 100644 internal/ui/static/login.js delete mode 100644 internal/ui/static/server.js delete mode 100644 internal/ui/static/style.css delete mode 100644 internal/ui/templates/dashboard.html delete mode 100644 internal/ui/templates/login.html delete mode 100644 internal/ui/templates/server.html delete mode 100644 internal/wireguard/config.go delete mode 100644 internal/wireguard/keys.go delete mode 100644 internal/wireguard/manager.go delete mode 100644 internal/wireguard/migrate.go create mode 100644 main.go create mode 100644 model/client.go create mode 100644 model/client_defaults.go create mode 100644 model/misc.go create mode 100644 model/server.go create mode 100644 model/setting.go create mode 100644 model/user.go create mode 100644 model/wake_on_lan_host.go create mode 100644 package.json create mode 100755 prepare_assets.sh create mode 100644 router/router.go create mode 100644 router/validator.go create mode 100644 store/jsondb/jsondb.go create mode 100644 store/jsondb/jsondb_wake_on_lan.go create mode 100644 store/store.go delete mode 100644 systemd/wireguard-ui-multi.service create mode 100644 telegram/bot.go create mode 100644 templates/about.html create mode 100644 templates/base.html create mode 100644 templates/clients.html create mode 100644 templates/global_settings.html create mode 100644 templates/login.html create mode 100644 templates/profile.html create mode 100644 templates/server.html create mode 100644 templates/status.html create mode 100644 templates/users_settings.html create mode 100644 templates/wake_on_lan_hosts.html create mode 100644 templates/wg.conf create mode 100644 util/cache.go create mode 100644 util/config.go create mode 100644 util/hash.go create mode 100644 util/util.go create mode 100644 yarn.lock diff --git a/.claude/settings.local.json.tmp.2113725.efbfaa50a5c4 b/.claude/settings.local.json.tmp.2113725.efbfaa50a5c4 new file mode 100644 index 0000000..f7ea5ab --- /dev/null +++ b/.claude/settings.local.json.tmp.2113725.efbfaa50a5c4 @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Read(//usr/local/go/**)", + "Bash(apt list *)", + "Bash(chmod +x *)", + "Bash(git add *)" + ] + } +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3b22f62 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,67 @@ +# Contributing Guidelines + +Thank you for your interest in contributing to my project. Whether it's a bug report, new feature, correction, or additional +documentation, I greatly value feedback and contributions from my community. + +Please read through this document before submitting any issues or pull requests to ensure I have all the necessary +information to effectively respond to your bug report or contribution. + +## Reporting Bugs/Feature Requests + +I welcome you to use the GitHub issue tracker to report bugs or suggest features. + +When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already +reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: + +- A reproducible test case or series of steps +- The version of my code being used +- Any modifications you've made relevant to the bug +- Anything unusual about your environment or deployment + +## Contributing via Pull Requests + +### Discussion of New Features +Before initiating the implementation of a new feature, I encourage contributors to open a discussion by creating a new GitHub issue. This allows me to provide feedback, share insights, and ensure alignment with the project's direction and save your time. + +#### Process for Discussing New Features: + +1. **Create an Issue:** + - Go to the "Issues" tab in the repository. + - Click on "New Issue." + - Clearly describe the proposed feature, its purpose, and potential benefits. + +2. **Engage in Discussion:** + - Respond promptly to comments and feedback from the community. + - Be open to adjusting the feature based on collaborative input. + +3. **Consensus Building:** + - Strive to reach a consensus on the proposed feature. + - Ensure alignment with the overall project vision. + +### Bug Fixes and Improvements + +For bug fixes, documentation improvements, and general enhancements, feel free to submit a pull request directly. + +#### Pull Request Guidelines: + +1. **Fork the Repository:** + - Fork the repository to your GitHub account. + +2. **Create a Branch:** + - Create a new branch for your changes. + +3. **Make Changes:** + - Make your changes and ensure they adhere to coding standards. + +4. **Submit a Pull Request:** + - Submit a pull request to the main repository. + +5. **Engage in Review:** + - Be responsive to feedback and address any requested changes. + +6. **Merge Process:** + - Once approved, your changes will be merged into the main branch. + +## Licensing + +See the [LICENSE](LICENSE) file for my project's licensing. diff --git a/DEVLOG.md b/DEVLOG.md index 902c15f..228c3eb 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -448,3 +448,385 @@ Keine Commits in dieser Session. - systemd/wireguard-ui-multi.service | 22 ++ --- +## 2026-07-10 17:31 – 17:31 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +- 9a1d811 Add one-shot bootstrap installer script + +### Geänderte Dateien +- DEVLOG.md | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++ +- scripts/bootstrap.sh | 71 ++++++++++ + +--- +## 2026-07-10 17:31 – 17:31 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++ +- scripts/bootstrap.sh | 71 ++++++++++ + +--- +## 2026-07-10 17:32 – 17:32 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++ +- scripts/bootstrap.sh | 71 ++++++++++ + +--- +## 2026-07-10 17:32 – 17:32 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++ +- scripts/bootstrap.sh | 71 ++++++++++ + +--- +## 2026-07-10 17:34 – 17:34 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++ +- scripts/bootstrap.sh | 71 ++++++++++ + +--- +## 2026-07-10 17:35 – 17:35 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++ +- scripts/bootstrap.sh | 71 ++++++++++ + +--- +## 2026-07-10 17:38 – 17:38 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++ +- scripts/bootstrap.sh | 71 ++++++++++ + +--- +## 2026-07-10 17:40 – 17:40 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++ +- scripts/bootstrap.sh | 71 ++++++++++ + +--- +## 2026-07-10 17:41 – 17:42 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +- 18188f4 Move bootstrap installer to project root, document one-liner install + +### Geänderte Dateien +- README.md | 25 +++++++++++++++++++++++-- +- scripts/bootstrap.sh => bootstrap.sh | 0 + +--- +## 2026-07-10 17:47 – 17:47 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 25 +++++++++++++++++++++++-- +- scripts/bootstrap.sh => bootstrap.sh | 0 + +--- +## 2026-07-10 17:48 – 17:48 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +- 6e0e681 Fix bootstrap.sh: go not found on PATH after install + +### Geänderte Dateien +- bootstrap.sh | 8 ++++++++ + +--- +## 2026-07-10 17:50 – 17:51 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- bootstrap.sh | 8 ++++++++ + +--- +## 2026-07-10 17:52 – 17:52 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- bootstrap.sh | 8 ++++++++ + +--- +## 2026-07-10 17:53 – 17:53 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +- f333623 Run go mod tidy before build in all installers + +### Geänderte Dateien +- bootstrap.sh | 1 + +- scripts/install.sh | 1 + +- scripts/proxmox-install.sh | 1 + + +--- +## 2026-07-10 17:54 – 17:54 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- bootstrap.sh | 1 + +- scripts/install.sh | 1 + +- scripts/proxmox-install.sh | 1 + + +--- +## 2026-07-10 17:55 – 17:55 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- bootstrap.sh | 1 + +- scripts/install.sh | 1 + +- scripts/proxmox-install.sh | 1 + + +--- +## 2026-07-10 17:57 – 17:57 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- bootstrap.sh | 1 + +- scripts/install.sh | 1 + +- scripts/proxmox-install.sh | 1 + + +--- +## 2026-07-10 17:58 – 17:59 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +- b2b6b82 Add update.sh, persist Go on PATH via profile.d + +### Geänderte Dateien +- README.md | 10 +++++++ +- bootstrap.sh | 5 ++++ +- scripts/install.sh | 8 ++++++ +- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-07-10 18:05 – 18:05 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 10 +++++++ +- bootstrap.sh | 5 ++++ +- scripts/install.sh | 8 ++++++ +- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-07-10 18:07 – 18:07 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 10 ++++++++++ +- bootstrap.sh | 5 +++++ +- scripts/install.sh | 8 ++++++++ +- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-07-10 18:07 – 18:08 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 10 ++++++++++ +- bootstrap.sh | 5 +++++ +- scripts/install.sh | 8 ++++++++ +- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-07-10 18:09 – 18:09 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 10 ++++++++++ +- bootstrap.sh | 5 +++++ +- scripts/install.sh | 8 ++++++++ +- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-07-10 18:11 – 18:11 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 10 ++++++++++ +- bootstrap.sh | 5 +++++ +- scripts/install.sh | 8 ++++++++ +- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-07-10 18:14 – 18:14 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 10 ++++++++++ +- bootstrap.sh | 5 +++++ +- scripts/install.sh | 8 ++++++++ +- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-07-10 18:14 – 18:14 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 10 ++++++++++ +- bootstrap.sh | 5 +++++ +- scripts/install.sh | 8 ++++++++ +- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-07-10 18:16 – 18:16 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 10 ++++++++++ +- bootstrap.sh | 5 +++++ +- scripts/install.sh | 8 ++++++++ +- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-07-10 18:16 – 18:18 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +- 41894e6 Fix 404s: serve UI templates/static from configurable ui-root, not CWD + +### Geänderte Dateien +- README.md | 15 +++++++++++++++ +- bootstrap.sh | 71 ++++++++++++++++++++++++++++++++++++++++++++++------------------------- +- cmd/wireguard-ui-multi/main.go | 7 ++++--- +- internal/api/router.go | 15 +++++++++++++-- +- internal/api/ui_handlers.go | 8 +++----- +- scripts/install.sh | 9 ++++++++- +- scripts/proxmox-install.sh | 2 +- +- update.sh | 2 +- + +--- +## 2026-07-10 18:22 – 18:23 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +- 6eeea65 Fix "Text file busy" on reinstall while service is running + +### Geänderte Dateien +- scripts/install.sh | 5 +++-- + +--- +## 2026-07-10 18:27 – 18:27 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- scripts/install.sh | 5 +++-- + +--- +## 2026-07-10 18:30 – 18:31 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** wireguard-ui-multi + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- scripts/install.sh | 5 +++-- + +--- diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0a96884 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,77 @@ +# Build stage +FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.21-alpine3.19 AS builder +LABEL maintainer="Khanh Ngo " + +ARG BUILDPLATFORM +ARG TARGETOS +ARG TARGETARCH +ARG APP_VERSION=dev +ARG BUILD_TIME +ARG GIT_COMMIT + +ARG BUILD_DEPENDENCIES="npm \ + yarn" + +# Get dependencies +RUN apk add --update --no-cache ${BUILD_DEPENDENCIES} + +WORKDIR /build + +# Add dependencies +COPY go.mod /build +COPY go.sum /build +COPY package.json /build +COPY yarn.lock /build + +# Prepare assets +RUN yarn install --pure-lockfile --production && \ + yarn cache clean + +# Move admin-lte dist +RUN mkdir -p assets/dist/js assets/dist/css && \ + cp /build/node_modules/admin-lte/dist/js/adminlte.min.js \ + assets/dist/js/adminlte.min.js && \ + cp /build/node_modules/admin-lte/dist/css/adminlte.min.css \ + assets/dist/css/adminlte.min.css + +# Move plugin assets +RUN mkdir -p assets/plugins && \ + cp -r /build/node_modules/admin-lte/plugins/jquery/ \ + /build/node_modules/admin-lte/plugins/fontawesome-free/ \ + /build/node_modules/admin-lte/plugins/bootstrap/ \ + /build/node_modules/admin-lte/plugins/icheck-bootstrap/ \ + /build/node_modules/admin-lte/plugins/toastr/ \ + /build/node_modules/admin-lte/plugins/jquery-validation/ \ + /build/node_modules/admin-lte/plugins/select2/ \ + /build/node_modules/jquery-tags-input/ \ + assets/plugins/ + +# Add sources +COPY . /build + +# Move custom assets +RUN cp -r /build/custom/ assets/ + +# Build +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-X 'main.appVersion=${APP_VERSION}' -X 'main.buildTime=${BUILD_TIME}' -X 'main.gitCommit=${GIT_COMMIT}'" -a -o wg-ui . + +# Release stage +FROM alpine:3.19 + +RUN addgroup -S wgui && \ + adduser -S -D -G wgui wgui + +RUN apk --no-cache add ca-certificates wireguard-tools jq iptables + +WORKDIR /app + +RUN mkdir -p db + +# Copy binary files +COPY --from=builder --chown=wgui:wgui /build/wg-ui . +RUN chmod +x wg-ui +COPY init.sh . +RUN chmod +x init.sh + +EXPOSE 5000/tcp +ENTRYPOINT ["./init.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d7c99ad --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 Khanh Ngo - k[at]ndk.name + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 64fb413..74c446e 100644 --- a/README.md +++ b/README.md @@ -1,280 +1,246 @@ -# wireguard-ui-multi +![](https://github.com/ngoduykhanh/wireguard-ui/workflows/wireguard-ui%20build%20release/badge.svg) -Native Multi-Server-Verwaltungsoberfläche für WireGuard — **ohne Docker**. -Im Gegensatz zum ursprünglichen `wireguard-ui`, das genau eine WireGuard-Instanz -verwaltet, kann `wireguard-ui-multi` mehrere unabhängige WireGuard-Interfaces -gleichzeitig verwalten (z. B. `wg-home`, `wg-rz`, `wg-winter`), jedes mit -eigenem Port, eigenem Adressbereich, eigenen Peers und eigenem Status. +# wireguard-ui -Zielumgebungen: Debian/Ubuntu, Proxmox LXC Container, generisches Linux mit -systemd. Betrieb als natives Go-Binary. +A web user interface to manage your WireGuard setup. ## Features -- **Multi-Server-Verwaltung**: beliebig viele WireGuard-Server, jeder mit - eigenem Interface-Namen, Port, Private/Public Key, Adressbereich, DNS, MTU - und Enabled/Disabled-Status (Tabelle `servers` in SQLite). -- **Peer-Verwaltung pro Server**: Peers gehören zu genau einem Server - (Fremdschlüssel `server_id`), inklusive Name, E-Mail, Public/Private/ - Preshared Key, Allowed IPs, Endpoint, Persistent Keepalive, Enabled-Status - und optionalem Ablaufdatum (`expires_at`). -- **Automatische Config-Erzeugung**: Server-Configs werden nach - `/etc/wireguard/.conf` im Standard-`wg-quick`-Format geschrieben. -- **Service-Steuerung**: Start/Stop/Reload je Interface über `wg-quick up`, - `wg-quick down` und `wg syncconf` (Hot-Reload ohne Verbindungsabbruch), - Status-Abfrage über `wg show`. -- **QR-Code & Config-Download**: Peer-Konfiguration kann als `.conf`-Datei - heruntergeladen oder als QR-Code (PNG) angezeigt werden — Private Keys - verlassen den Server nur in dieser generierten Peer-Config, nie über die - UI/JSON-API. -- **REST-API** für Server- und Peer-Verwaltung (siehe unten) plus - Web-Dashboard. -- **Firewall-Vorbereitung**: optionale Lifecycle-Hook-Skripte - (`server-start`, `server-stop`, `peer-add`, `peer-remove`) in - `/etc/wireguard-manager/hooks/` sowie ein Generator für einen - Vorschlags-nftables-Ruleset pro Server (Port freigeben, Forwarding - Tunnel ↔ LAN-Interface). -- **Audit Log**: Tabelle `audit_log` protokolliert Aktionen mit Akteur, - Aktion, Ziel und Detail. -- **Sitzungsbasierte Authentifizierung** mit CSRF-Schutz: jede mutierende - Anfrage (POST/PUT/DELETE) benötigt einen gültigen Session-Cookie plus - den Header `X-CSRF-Token`. -- Optional HTTPS über `--tls-cert` / `--tls-key`. +- Friendly UI +- Authentication +- Manage extra client information (name, email, etc.) +- Retrieve client config using QR code / file / email / Telegram -## Installation +![wireguard-ui 0.3.7](https://user-images.githubusercontent.com/37958026/177041280-e3e7ca16-d4cf-4e95-9920-68af15e780dd.png) -### Hardware-Anforderungen +## Run WireGuard-UI -Betrieb selbst ist sehr genügsam (kleines Go-Binary + SQLite, kein Docker/JVM): +> ⚠️The default username and password are `admin`. Please change it to secure your setup. -- **Betrieb:** 1 vCPU, 128-256 MB RAM reichen locker -- **Build aus Quellcode:** mind. **1 GB RAM** während `go build` — das - `modernc.org/sqlite`-Package (reines Go, kein cgo, aber sehr großzügiger - generierter Code) sprengt den `go`-Compiler bei 512 MB LXC-RAM - (`signal: killed`, OOM-Killer). Bei 1 GB lief der Build durch. -- Nach dem Build kann der Container/Server wieder auf 256-512 MB reduziert - werden, falls Ressourcen knapp sind. -- Alternative ohne Build-RAM-Bedarf: fertiges Release-Binary nutzen, sobald - eine Release-Pipeline existiert (`bootstrap.sh` versucht das automatisch - zuerst und fällt nur bei Fehlschlag auf den Source-Build zurück). +### Using binary file -### Schnellinstallation (Einzeiler) +Download the binary file from the release page and run it directly on the host machine -Auf einem frischen Debian/Ubuntu-Host (als root), lädt und installiert alles -in einem Schritt (Abhängigkeiten, Go-Toolchain falls nötig, Quellcode, Build, -`install.sh`): +``` +./wireguard-ui +``` + +### Using docker compose + +The [examples/docker-compose](examples/docker-compose) folder contains example docker-compose files. +Choose the example which fits you the most, adjust the configuration for your needs, then run it like below: + +``` +docker-compose up +``` + +## Environment Variables + +| Variable | Description | Default | +|-------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------| +| `BASE_PATH` | Set this variable if you run wireguard-ui under a subpath of your reverse proxy virtual host (e.g. /wireguard) | N/A | +| `BIND_ADDRESS` | The addresses that can access to the web interface and the port, use unix:///abspath/to/file.socket for unix domain socket. | 0.0.0.0:80 | +| `SESSION_SECRET` | The secret key used to encrypt the session cookies. Set this to a random value | N/A | +| `SESSION_SECRET_FILE` | Optional filepath for the secret key used to encrypt the session cookies. Leave `SESSION_SECRET` blank to take effect | N/A | +| `SESSION_MAX_DURATION` | Max time in days a remembered session is refreshed and valid. Non-refreshed session is valid for 7 days max, regardless of this setting. | 90 | +| `SUBNET_RANGES` | The list of address subdivision ranges. Format: `SR Name:10.0.1.0/24; SR2:10.0.2.0/24,10.0.3.0/24` Each CIDR must be inside one of the server interfaces. | N/A | +| `WGUI_USERNAME` | The username for the login page. Used for db initialization only | `admin` | +| `WGUI_PASSWORD` | The password for the user on the login page. Will be hashed automatically. Used for db initialization only | `admin` | +| `WGUI_PASSWORD_FILE` | Optional filepath for the user login password. Will be hashed automatically. Used for db initialization only. Leave `WGUI_PASSWORD` blank to take effect | N/A | +| `WGUI_PASSWORD_HASH` | The password hash for the user on the login page. (alternative to `WGUI_PASSWORD`). Used for db initialization only | N/A | +| `WGUI_PASSWORD_HASH_FILE` | Optional filepath for the user login password hash. (alternative to `WGUI_PASSWORD_FILE`). Used for db initialization only. Leave `WGUI_PASSWORD_HASH` blank to take effect | N/A | +| `WGUI_ENDPOINT_ADDRESS` | The default endpoint address used in global settings where clients should connect to. The endpoint can contain a port as well, useful when you are listening internally on the `WGUI_SERVER_LISTEN_PORT` port, but you forward on another port (ex 9000). Ex: myvpn.dyndns.com:9000 | Resolved to your public ip address | +| `WGUI_FAVICON_FILE_PATH` | The file path used as website favicon | Embedded WireGuard logo | +| `WGUI_DNS` | The default DNS servers (comma-separated-list) used in the global settings | `1.1.1.1` | +| `WGUI_MTU` | The default MTU used in global settings | `1450` | +| `WGUI_PERSISTENT_KEEPALIVE` | The default persistent keepalive for WireGuard in global settings | `15` | +| `WGUI_FIREWALL_MARK` | The default WireGuard firewall mark | `0xca6c` (51820) | +| `WGUI_TABLE` | The default WireGuard table value settings | `auto` | +| `WGUI_CONFIG_FILE_PATH` | The default WireGuard config file path used in global settings | `/etc/wireguard/wg0.conf` | +| `WGUI_LOG_LEVEL` | The default log level. Possible values: `DEBUG`, `INFO`, `WARN`, `ERROR`, `OFF` | `INFO` | +| `WG_CONF_TEMPLATE` | The custom `wg.conf` config file template. Please refer to our [default template](https://github.com/ngoduykhanh/wireguard-ui/blob/master/templates/wg.conf) | N/A | +| `EMAIL_FROM_ADDRESS` | The sender email address | N/A | +| `EMAIL_FROM_NAME` | The sender name | `WireGuard UI` | +| `SENDGRID_API_KEY` | The SendGrid api key | N/A | +| `SENDGRID_API_KEY_FILE` | Optional filepath for the SendGrid api key. Leave `SENDGRID_API_KEY` blank to take effect | N/A | +| `SMTP_HOSTNAME` | The SMTP IP address or hostname | `127.0.0.1` | +| `SMTP_PORT` | The SMTP port | `25` | +| `SMTP_USERNAME` | The SMTP username | N/A | +| `SMTP_PASSWORD` | The SMTP user password | N/A | +| `SMTP_PASSWORD_FILE` | Optional filepath for the SMTP user password. Leave `SMTP_PASSWORD` blank to take effect | N/A | +| `SMTP_AUTH_TYPE` | The SMTP authentication type. Possible values: `PLAIN`, `LOGIN`, `NONE` | `NONE` | +| `SMTP_ENCRYPTION` | The encryption method. Possible values: `NONE`, `SSL`, `SSLTLS`, `TLS`, `STARTTLS` | `STARTTLS` | +| `SMTP_HELO` | Hostname to use for the HELO message. smtp-relay.gmail.com needs this set to anything but `localhost` | `localhost` | +| `TELEGRAM_TOKEN` | Telegram bot token for distributing configs to clients | N/A | +| `TELEGRAM_ALLOW_CONF_REQUEST` | Allow users to get configs from the bot by sending a message | `false` | +| `TELEGRAM_FLOOD_WAIT` | Time in minutes before the next conf request is processed | `60` | + +### Defaults for server configuration + +These environment variables are used to control the default server settings used when initializing the database. + +| Variable | Description | Default | +|-----------------------------------|-----------------------------------------------------------------------------------------------|-----------------| +| `WGUI_SERVER_INTERFACE_ADDRESSES` | The default interface addresses (comma-separated-list) for the WireGuard server configuration | `10.252.1.0/24` | +| `WGUI_SERVER_LISTEN_PORT` | The default server listen port | `51820` | +| `WGUI_SERVER_POST_UP_SCRIPT` | The default server post-up script | N/A | +| `WGUI_SERVER_POST_DOWN_SCRIPT` | The default server post-down script | N/A | + +### Defaults for new clients + +These environment variables are used to set the defaults used in `New Client` dialog. + +| Variable | Description | Default | +|---------------------------------------------|-------------------------------------------------------------------------------------------------|-------------| +| `WGUI_DEFAULT_CLIENT_ALLOWED_IPS` | Comma-separated-list of CIDRs for the `Allowed IPs` field. (default ) | `0.0.0.0/0` | +| `WGUI_DEFAULT_CLIENT_EXTRA_ALLOWED_IPS` | Comma-separated-list of CIDRs for the `Extra Allowed IPs` field. (default empty) | N/A | +| `WGUI_DEFAULT_CLIENT_USE_SERVER_DNS` | Boolean value [`0`, `f`, `F`, `false`, `False`, `FALSE`, `1`, `t`, `T`, `true`, `True`, `TRUE`] | `true` | +| `WGUI_DEFAULT_CLIENT_ENABLE_AFTER_CREATION` | Boolean value [`0`, `f`, `F`, `false`, `False`, `FALSE`, `1`, `t`, `T`, `true`, `True`, `TRUE`] | `true` | + +### Docker only + +These environment variables only apply to the docker container. + +| Variable | Description | Default | +|-----------------------|---------------------------------------------------------------|---------| +| `WGUI_MANAGE_START` | Start/stop WireGuard when the container is started/stopped | `false` | +| `WGUI_MANAGE_RESTART` | Auto restart WireGuard when we Apply Config changes in the UI | `false` | + +## Auto restart WireGuard daemon + +WireGuard-UI only takes care of configuration generation. You can use systemd to watch for the changes and restart the +service. Following is an example: + +### Using systemd + +Create `/etc/systemd/system/wgui.service` ```bash -curl -fsSL https://gitea.perlbach24.de/scripte/wireguard-ui-multi/raw/branch/main/bootstrap.sh | bash +cd /etc/systemd/system/ +cat << EOF > wgui.service +[Unit] +Description=Restart WireGuard +After=network.target + +[Service] +Type=oneshot +ExecStart=/usr/bin/systemctl restart wg-quick@wg0.service + +[Install] +RequiredBy=wgui.path +EOF ``` -Danach nur noch: +Create `/etc/systemd/system/wgui.path` ```bash -sudo systemctl enable --now wireguard-ui-multi.service +cd /etc/systemd/system/ +cat << EOF > wgui.path +[Unit] +Description=Watch /etc/wireguard/wg0.conf for changes + +[Path] +PathModified=/etc/wireguard/wg0.conf + +[Install] +WantedBy=multi-user.target +EOF ``` -Der Quellcode bleibt unter `/opt/wireguard-ui-multi-src` liegen; erneutes -Ausführen des Einzeilers aktualisiert die Installation. +Apply it -### Update - -Für ein gezieltes Update (holt neuesten Code, baut neu, installiert neu und -startet den Dienst neu): - -```bash -cd /opt/wireguard-ui-multi-src -sudo ./update.sh +```sh +systemctl enable wgui.{path,service} +systemctl start wgui.{path,service} ``` -### Manuelle Installation +### Using openrc -#### 1. Aus dem Quellcode bauen +Create `/usr/local/bin/wgui` file and make it executable -```bash -go build -o wireguard-ui-multi ./cmd/wireguard-ui-multi +```sh +cd /usr/local/bin/ +cat << EOF > wgui +#!/bin/sh +wg-quick down wg0 +wg-quick up wg0 +EOF +chmod +x wgui ``` -#### 2. Installationsskript ausführen (als root) +Create `/etc/init.d/wgui` file and make it executable -```bash -sudo ./scripts/install.sh +```sh +cd /etc/init.d/ +cat << EOF > wgui +#!/sbin/openrc-run + +command=/sbin/inotifyd +command_args="/usr/local/bin/wgui /etc/wireguard/wg0.conf:w" +pidfile=/run/${RC_SVCNAME}.pid +command_background=yes +EOF +chmod +x wgui ``` -Das Skript ist idempotent und: +Apply it -- kopiert die Binary nach `/usr/local/bin/wireguard-ui-multi` -- legt `/etc/wireguard-ui-multi`, `/var/lib/wireguard-ui-multi` und - `/etc/wireguard-manager/hooks` an -- installiert die systemd-Unit nach - `/etc/systemd/system/wireguard-ui-multi.service` -- setzt `chmod 0700` auf das Datenverzeichnis (dort liegt die SQLite-DB mit - Passwort-Hashes) - -**Wichtig:** Das Skript startet den Dienst nicht automatisch. Danach manuell -aktivieren: - -```bash -sudo systemctl enable --now wireguard-ui-multi.service -sudo systemctl status wireguard-ui-multi.service -sudo journalctl -u wireguard-ui-multi.service -f +```sh +rc-service wgui start +rc-update add wgui default ``` -## Konfiguration +### Using Docker -Die Anwendung wird über Kommandozeilen-Flags konfiguriert (siehe -`cmd/wireguard-ui-multi/main.go`): +Set `WGUI_MANAGE_RESTART=true` to manage Wireguard interface restarts. +Using `WGUI_MANAGE_START=true` can also replace the function of `wg-quick@wg0` service, to start Wireguard at boot, by +running the container with `restart: unless-stopped`. These settings can also pick up changes to Wireguard Config File +Path, after restarting the container. Please make sure you have `--cap-add=NET_ADMIN` in your container config to make +this feature work. -| Flag | Default | Bedeutung | -|----------------|-------------------------------------------------------|-------------------------------------------------------| -| `--listen` | `:8443` | Listen-Adresse des Webservers | -| `--db` | `/var/lib/wireguard-ui-multi/wireguard-ui-multi.db` | Pfad zur SQLite-Datenbankdatei | -| `--config-dir` | `/etc/wireguard` | Zielverzeichnis für generierte `wg-quick`-Configs | -| `--hooks-dir` | `/etc/wireguard-manager/hooks` | Verzeichnis mit optionalen Hook-Skripten | -| `--lan-iface` | `eth0` | LAN-Interface für die vorgeschlagenen nftables-Forward-Regeln | -| `--tls-cert` | (leer) | Pfad zum TLS-Zertifikat (aktiviert HTTPS zusammen mit `--tls-key`) | -| `--tls-key` | (leer) | Pfad zum TLS-Private-Key | +## Build -Die in `systemd/wireguard-ui-multi.service` hinterlegte `ExecStart`-Zeile -setzt `--db`, `--config-dir` und `--hooks-dir` bereits passend zur -Installationsstruktur. +### Build docker image -### Erststart / Admin-Passwort +Go to the project root directory and run the following command: -Beim allerersten Start (leere `users`-Tabelle) wird automatisch ein -`admin`-Benutzer mit einem zufällig erzeugten 32-stelligen Hex-Passwort -angelegt. Das Klartext-Passwort wird **genau einmal** auf `stderr` -ausgegeben (z. B. sichtbar via `journalctl -u wireguard-ui-multi.service`) -und danach nur noch als bcrypt-Hash in der Datenbank gespeichert. Nach dem -ersten Login sollte das Passwort umgehend geändert werden. - -## LXC / Proxmox Hinweise - -WireGuard benötigt Zugriff auf das `wireguard`-Kernelmodul des Hosts sowie -`CAP_NET_ADMIN` und Zugriff auf `/dev/net/tun` im Container: - -- Auf dem **Proxmox-Host** muss das `wireguard`-Kernelmodul geladen sein - (`modprobe wireguard`; bei Bedarf `/etc/modules` ergänzen). -- Der LXC-Container sollte entweder **privilegiert** betrieben werden, oder - als unprivilegierter Container mit gezielten Lockerungen - (`lxc.cap.drop` ohne `net_admin`, `lxc.cgroup2.devices.allow: c 10:200 rwm` - für `/dev/net/tun`) konfiguriert werden. In der Praxis ist ein - privilegierter Container für WireGuard-Hosting deutlich unkomplizierter. -- `/dev/net/tun` muss im Container vorhanden und beschreibbar sein - (`ls -l /dev/net/tun`); ggf. per Bind-Mount/`lxc.mount.entry` durchreichen. -- Die systemd-Unit läuft als `root` mit `AmbientCapabilities=CAP_NET_ADMIN`, - weil sie `wg-quick`, `systemctl` und `nft` aufruft — diese Tools benötigen - in der Praxis root-Rechte im Container. -- Läuft `nftables` bereits als eigener Dienst im Container/Host, sollte der - von `wireguard-ui-multi` vorgeschlagene Ruleset (siehe unten) manuell in - die bestehende Regelbasis integriert statt blind angewendet werden, um - Konflikte mit vorhandenen Tabellen/Chains zu vermeiden. - -## Server- & Peer-Verwaltung - -**Server anlegen** (UI oder `POST /api/servers`): Name, Interface-Name -(z. B. `wg-home`), Listen-Port, Adressbereich (z. B. `10.20.22.0/24`), DNS, -MTU angeben. Private/Public Key werden serverseitig automatisch erzeugt. - -**Server starten/stoppen/neuladen**: über die Dashboard-Buttons oder -`POST /api/servers/{id}/start|stop|reload`. Start schreibt zunächst die -`wg-quick`-Config nach `/etc/wireguard/.conf` und ruft dann -`wg-quick up ` auf; Reload nutzt `wg syncconf` für einen -Hot-Reload ohne Tunnelabbruch. - -**Peer hinzufügen** (UI oder `POST /api/server/{id}/peer`): Name, optional -E-Mail/Beschreibung und Ablaufdatum angeben — Schlüsselpaar und Preshared -Key werden automatisch generiert. - -**Config/QR-Code abrufen**: `GET /api/server/{id}/peer/{peerid}/config` -liefert die fertige `.conf`-Datei zum Download, `GET -/api/server/{id}/peer/{peerid}/qrcode` liefert denselben Inhalt als -PNG-QR-Code zum Scannen mit der WireGuard-App. - -### REST-API-Übersicht - -``` -POST /api/login -POST /api/logout - -GET /api/servers -POST /api/servers -GET /api/servers/{id} -PUT /api/servers/{id} -DELETE /api/servers/{id} -POST /api/servers/{id}/start -POST /api/servers/{id}/stop -POST /api/servers/{id}/reload -GET /api/servers/{id}/config - -GET /api/server/{id}/peers -POST /api/server/{id}/peer -DELETE /api/server/{id}/peer/{peerid} -GET /api/server/{id}/peer/{peerid}/config -GET /api/server/{id}/peer/{peerid}/qrcode +```sh +docker build --build-arg=GIT_COMMIT=$(git rev-parse --short HEAD) -t wireguard-ui . ``` -Alle Endpunkte außer `/api/login` erfordern einen gültigen Session-Cookie; -mutierende Methoden (POST/PUT/DELETE) benötigen zusätzlich den Header -`X-CSRF-Token` mit dem beim Login ausgegebenen Token. +or -## Backup / Restore - -Ein automatisiertes Backup-/Restore-Werkzeug ist aktuell **nicht** -implementiert. Für ein manuelles Backup genügt es, folgende Pfade zu -sichern: - -- die SQLite-Datenbank: `/var/lib/wireguard-ui-multi/wireguard-ui-multi.db` - (enthält Server, Peers, Keys, Audit Log, Benutzer) -- die generierten Interface-Configs: `/etc/wireguard/*.conf` -- ggf. eigene Hook-Skripte: `/etc/wireguard-manager/hooks/` - -Beispiel: - -```bash -sudo tar czf wireguard-ui-multi-backup-$(date +%F).tar.gz \ - /var/lib/wireguard-ui-multi/wireguard-ui-multi.db \ - /etc/wireguard/*.conf \ - /etc/wireguard-manager/hooks +```sh +docker compose build --build-arg=GIT_COMMIT=$(git rev-parse --short HEAD) ``` -**Restore**: Dienst stoppen, Archiv an denselben Pfaden entpacken, -Berechtigungen prüfen (`chmod 0700` auf das Datenverzeichnis) und Dienst -wieder starten: +:information_source: A container image is available on [Docker Hub](https://hub.docker.com/r/ngoduykhanh/wireguard-ui) +which you can pull and use -```bash -sudo systemctl stop wireguard-ui-multi.service -sudo tar xzf wireguard-ui-multi-backup-YYYY-MM-DD.tar.gz -C / -sudo systemctl start wireguard-ui-multi.service +``` +docker pull ngoduykhanh/wireguard-ui +```` + +### Build binary file + +Prepare the assets directory + +```sh +./prepare_assets.sh ``` -## Migration von einer bestehenden wireguard-ui-Installation +Then build your executable -Für den Umstieg von einer klassischen Single-Interface-Installation -(`/etc/wireguard/wg0.conf`) ist ein Migrationswerkzeug vorgesehen, das eine -bestehende `wg0.conf` einliest und als ersten verwalteten Server samt seiner -Peers importiert. Damit lässt sich eine vorhandene WireGuard-Instanz -übernehmen, ohne bestehende Clients neu konfigurieren zu müssen. Details zum -genauen Ablauf und den Aufrufoptionen siehe die Implementierung im -`wireguard`-Package des Repos, sobald verfügbar; grundsätzlich gilt: vor der -Migration ein Backup der bestehenden `wg0.conf` anlegen. +```sh +go build -o wireguard-ui +``` -## Sicherheitshinweise +## License -- **Private Keys werden nie im Frontend/JSON angezeigt** — sie werden - ausschließlich serverseitig in generierten `.conf`-Dateien bzw. - QR-Codes für einzelne Peers ausgeliefert. -- **HTTPS verwenden**: entweder direkt über `--tls-cert`/`--tls-key`, oder - die Anwendung hinter einem Reverse Proxy (nginx, Caddy, Traefik) mit - TLS-Terminierung betreiben. Ohne TLS gibt der Dienst beim Start eine - deutliche Warnung aus. -- **Standard-Admin-Passwort sofort ändern**: das beim Erststart einmalig - ausgegebene zufällige Passwort sollte direkt nach dem ersten Login - geändert werden. -- Mutierende API-Aufrufe erfordern einen gültigen Session-Cookie **und** - den CSRF-Header `X-CSRF-Token` — Clients/Skripte, die die API direkt - ansprechen, müssen sich zunächst über `/api/login` anmelden und den - zurückgegebenen Token mitführen. -- Die Datenverzeichnisse (`/var/lib/wireguard-ui-multi`) sollten - restriktive Berechtigungen (`0700`) behalten, da dort Schlüsselmaterial - und Passwort-Hashes liegen. +MIT. See [LICENSE](https://github.com/ngoduykhanh/wireguard-ui/blob/master/LICENSE). + +## Support + +If you like the project and want to support it, you can *buy me a coffee* ☕ + +Buy Me A Coffee diff --git a/assets/.gitkeep b/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/wireguard-ui-multi/main.go b/cmd/wireguard-ui-multi/main.go deleted file mode 100644 index ad2122a..0000000 --- a/cmd/wireguard-ui-multi/main.go +++ /dev/null @@ -1,142 +0,0 @@ -// Command wireguard-ui-multi runs the native multi-server WireGuard management UI. -package main - -import ( - "context" - "crypto/rand" - "encoding/hex" - "errors" - "flag" - "fmt" - "log/slog" - "net/http" - "os" - "os/signal" - "path/filepath" - "syscall" - "time" - - "gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/api" - "gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/database" - "gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/firewall" - "gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/wireguard" -) - -func main() { - var ( - listen = flag.String("listen", ":8443", "address to listen on") - dbPath = flag.String("db", "/var/lib/wireguard-ui-multi/wireguard-ui-multi.db", "path to the sqlite database file") - configDir = flag.String("config-dir", "/etc/wireguard", "directory where wg-quick interface configs are written") - hooksDir = flag.String("hooks-dir", "/etc/wireguard-manager/hooks", "directory containing optional lifecycle hook scripts") - lanIface = flag.String("lan-iface", "eth0", "LAN interface used for nftables forward rules") - tlsCert = flag.String("tls-cert", "", "path to TLS certificate (optional; enables HTTPS together with -tls-key)") - tlsKey = flag.String("tls-key", "", "path to TLS private key (optional; enables HTTPS together with -tls-cert)") - uiRoot = flag.String("ui-root", "/usr/local/share/wireguard-ui-multi/ui", "directory containing the ui templates/ and static/ subdirectories") - ) - flag.Parse() - - logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - - if err := run(logger, *listen, *dbPath, *configDir, *hooksDir, *lanIface, *tlsCert, *tlsKey, *uiRoot); err != nil { - logger.Error("fatal", "error", err) - os.Exit(1) - } -} - -func run(logger *slog.Logger, listen, dbPath, configDir, hooksDir, lanIface, tlsCert, tlsKey, uiRoot string) error { - // Wire package-level config before anything touches the filesystem/wg-quick. - wireguard.ConfigDir = configDir - firewall.HooksDir = hooksDir - - if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil { - return fmt.Errorf("create db directory: %w", err) - } - - db, err := database.Open(dbPath) - if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer db.Close() - - if err := ensureAdminUser(db, logger); err != nil { - return fmt.Errorf("bootstrap admin user: %w", err) - } - - a := api.New(db, logger, lanIface, uiRoot) - - srv := &http.Server{ - Addr: listen, - Handler: a.Routes(), - } - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - serveErr := make(chan error, 1) - go func() { - useTLS := tlsCert != "" && tlsKey != "" - if useTLS { - logger.Info("starting HTTPS server", "listen", listen) - serveErr <- srv.ListenAndServeTLS(tlsCert, tlsKey) - } else { - logger.Warn("starting plain HTTP server — TLS is strongly recommended in production; set -tls-cert and -tls-key", "listen", listen) - serveErr <- srv.ListenAndServe() - } - }() - - select { - case err := <-serveErr: - if err != nil && !errors.Is(err, http.ErrServerClosed) { - return fmt.Errorf("serve: %w", err) - } - case <-ctx.Done(): - logger.Info("shutdown signal received, stopping server") - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := srv.Shutdown(shutdownCtx); err != nil { - return fmt.Errorf("graceful shutdown: %w", err) - } - } - - logger.Info("server stopped") - return nil -} - -// ensureAdminUser creates a default admin account with a random password on first -// run (i.e. when the users table is empty). The plaintext password is printed -// exactly once and never persisted — only its bcrypt hash is stored. -func ensureAdminUser(db *database.DB, logger *slog.Logger) error { - var count int - if err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil { - return fmt.Errorf("count users: %w", err) - } - if count > 0 { - return nil - } - - passwordBytes := make([]byte, 16) - if _, err := rand.Read(passwordBytes); err != nil { - return fmt.Errorf("generate password: %w", err) - } - password := hex.EncodeToString(passwordBytes) - - hash, err := api.HashPassword(password) - if err != nil { - return fmt.Errorf("hash password: %w", err) - } - - if _, err := db.Exec(`INSERT INTO users (username, password_hash) VALUES (?, ?)`, "admin", hash); err != nil { - return fmt.Errorf("insert admin user: %w", err) - } - - fmt.Fprintln(os.Stderr, "================================================================") - fmt.Fprintln(os.Stderr, " First run: created default admin account") - fmt.Fprintln(os.Stderr, " username: admin") - fmt.Fprintf(os.Stderr, " password: %s\n", password) - fmt.Fprintln(os.Stderr, " This password is shown ONLY ONCE and is not stored anywhere in") - fmt.Fprintln(os.Stderr, " plaintext. Log in and change it immediately.") - fmt.Fprintln(os.Stderr, "================================================================") - logger.Info("created default admin user; see above for the one-time password") - - return nil -} diff --git a/custom/img/favicon.ico b/custom/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7852f45fcd3f079ad11290ffe83b0d4bd5e65e0b GIT binary patch literal 17014 zcmeHOX^a$A5FWhatY?w;8ZR!~<_T=Dv$8;rMNMDaFWiQV}A@s8JFy0sY}YP&69FTTzrCuB->F>sNE^^z?M~G4xEg36S2Ic}Knas_NBy zRqu5oF$DkH+7kGiNnA5Fk&qIJ#G!allxW98<@tYYg^#%W)fI7^CL6OgSuE2e<1bm`rppjyzQXePpllF<_@Zrk0|=-ig|W8 z*o$(K#oA%RjQyg(5_C*W>o|($a9MvrbnVhs7H^O*3Cd3++Yby345@v-yI?Q+O-&LL z8;K|ASn%;Tdg&b)zJQGGft`$KYg@j{$dO{7v?NZ$8n{@M#5TA7=({P-^8gsWbmJfO zkF}~@dvbE6H4klmcB28=P2&1TS>B|Y$GGv0ea>7z{E=Ni&p&Xq;8#ENL#NSgpqH5} z&yrZ(($ce=U!GtC<2YKFBmXulbBFq+)u?vhYbD3CE}J(5<%9eLM^BT@bA!^WcYE07 zI*#93^aYHMa`bAE=l0+yKRrs$?&U@UHv0$1FZFlp>r|)9#@!q}T0fln)T<18@O^3C z!6sMXnWCy5wm?dFa%?(PgQWP<2U|u!rJ)U96{k5BT5eg#kF%sIqxoCNqDJH1d7e8!tp3-gZjy zA96{J#_4A~vVHPNt*u?V!Ukr0;UBEWyn!rcP~W`W%f?TGbvE6D?w_>I_~9ptHhNNw zhtFQEE7>;ix}3w|RE~cCKn~q_PSqtdOTVkyAvYWaF0V1kC+doEzh!OQT#$kIs0xEk zr%v^d!3xZ)Db6&>%?ukEN+J`e+`H~FHiJaKt9Ebk0|O3dtTA!i3@p4UCtg48@>-RS>xH> zK1a|$jxvCazu<`lF(EK7fj^sAiUHGd=P)`C!n(N_`t@}_02-Z9=$IRGxSW+7eRNrO zG=ICea|=x|C!qh7rlGS&lU-EprOI>${R+;4F5>uUbUDr`?{d|>BChq~32Xinv}VAE zJY6^^Lk!`7yHo~wZlt)#i*6(8VYe)=gvSpLlm|xJAM^OI57MRFc#5S>#(AkRE;7rVmd6u$4`=_$F*Z}Zv;dl&kK7qCU5*wY~WHQ$Z zoa~u#6uA$pJ}LGF&uolyurx=uws-rk%GvX5_({jhC|3aA;CL2<`ffLxji`hEzp?W@ z_*?B5?Ux74=Q{`cS$tnADUJosrA~Dy-#B4H&(40ws7E{KhR^jQe(^gd=K3mUjK%`D zd=mD>qx_Chk9KZ;n)H)VIx}A5Vh?n&6ZS%xcwdvVW4U@RhvGG6SoJ9-H-_UaUoMMJ znwmP3+<1k%A^aTy$naa51Kb$Mx8BOpZd5t;uWLE}isik)F_nB7&DWNed3#eFfPT-S z-Zj^QeoINm9Q`Qr|8}`z4qnOeXD|2ID_5-X)ED?C;pwu|Dg8!FdA*&VD1L*-?h;R- zePZsYs*wgg_B;vofzMOwAs^c7z@EPj3p?2f`4-4^2}=_(rPo_Yk z1I(G`DEJL~%;H^+X~6WG6(jo{wo%J{p|JT5lU^ocDzN#-S)RZxY0m@iTLu5_d~e7q544N*-R>Z=1oo-GQ+fI54|iqGjcMN;XWYaaopC{b?=jed*nz!m zZ=7-fc0JHpH|3~+oj)h2wDSsl`8b&JKn`u-6Z+`f#6D)6e&=(yEFPcOb3GmhR5i4)dZcqyEFIiGKpbWZ!lIOjTQ z!A*L^oOqFRTf*t|34iw~^nOAuW5lsf{=IMHd4Ovko{4xr^c3VrF30z0D)7^L;J6#| uf6Q}ma;JO{;h|g)VTS$}7X4x`KEHwtUd2409^`&FH`b2w9TKRQ1pWgWN|x6E literal 0 HcmV?d00001 diff --git a/custom/js/helper.js b/custom/js/helper.js new file mode 100644 index 0000000..5b43272 --- /dev/null +++ b/custom/js/helper.js @@ -0,0 +1,145 @@ +function renderClientList(data) { + $.each(data, function(index, obj) { + // render telegram button + let telegramButton = '' + if (obj.Client.telegram_userid) { + telegramButton = `
+ +
` + } + + let telegramHtml = ""; + if (obj.Client.telegram_userid && obj.Client.telegram_userid.length > 0) { + telegramHtml = `` + } + + // render client status css tag style + let clientStatusHtml = '>' + if (obj.Client.enabled) { + clientStatusHtml = `style="visibility: hidden;">` + } + + // render client allocated ip addresses + let allocatedIpsHtml = ""; + $.each(obj.Client.allocated_ips, function(index, obj) { + allocatedIpsHtml += `${obj} `; + }) + + // render client allowed ip addresses + let allowedIpsHtml = ""; + $.each(obj.Client.allowed_ips, function(index, obj) { + allowedIpsHtml += `${obj} `; + }) + + let subnetRangesString = ""; + if (obj.Client.subnet_ranges && obj.Client.subnet_ranges.length > 0) { + subnetRangesString = obj.Client.subnet_ranges.join(',') + } + + let additionalNotesHtml = ""; + if (obj.Client.additional_notes && obj.Client.additional_notes.length > 0) { + additionalNotesHtml = `` + } + + // render client html content + let html = `
+
+
+
+
+
+ Download +
+
+ +
+
+ +
+ ${telegramButton} +
+ + + +
+
+ ${obj.Client.name} + + + ${telegramHtml} + ${additionalNotesHtml} + ${obj.Client.email} + + ${prettyDateTime(obj.Client.created_at)} + + ${prettyDateTime(obj.Client.updated_at)} + + ${obj.Client.use_server_dns ? 'DNS enabled' : 'DNS disabled'} + + ${obj.Client.additional_notes} + IP Allocation` + + allocatedIpsHtml + + `Allowed IPs` + + allowedIpsHtml + +`
+
+
` + + // add the client html elements to the list + $('#client-list').append(html); + }); +} + +function renderUserList(data) { + $.each(data, function(index, obj) { + let clientStatusHtml = '>' + + // render user html content + let html = `
+
+
+
+ +
+
+ +
+
+ ${obj.username} + ${obj.admin? 'Administrator':'Manager'} +
+
+
` + + // add the user html elements to the list + $('#users-list').append(html); + }); +} + + +function prettyDateTime(timeStr) { + const dt = new Date(timeStr); + const offsetMs = dt.getTimezoneOffset() * 60 * 1000; + const dateLocal = new Date(dt.getTime() - offsetMs); + return dateLocal.toISOString().slice(0, 19).replace(/-/g, "/").replace("T", " "); +} diff --git a/custom/js/wake_on_lan_hosts.js b/custom/js/wake_on_lan_hosts.js new file mode 100644 index 0000000..36ad1f3 --- /dev/null +++ b/custom/js/wake_on_lan_hosts.js @@ -0,0 +1,210 @@ +var base_url = jQuery(".brand-link").attr('href'); +if (base_url.substring(base_url.length - 1, base_url.length) != "/") + base_url = base_url + "/"; + + +const wake_on_lan_new_template = '
\n' + + '\t
\n' + + '\t\t
\n' + + '\t\t\t
\n' + + '\t\t\t\t\n' + + '\t\t\t\t\n' + + '\t\t\t\t\n' + + '\t\t\t
\n' + + '\t\t\t
\n' + + '\t\t\t {{ .Name }}\n' + + '\t\t\t {{ .MacAddress }}\n' + + '\t\t\t Unused\n' + + '\t\t
\n' + + '\t
\n' + + '
'; + +jQuery(function ($) { + $.validator.addMethod('mac', function (value, element) { + return this.optional(element) || /^([0-9A-F]{2}[:]){5}([0-9A-F]{2})$/.test(value); + }, 'Please enter a valid MAC Address.(uppercase letters and numbers, : only) ex: 00:AB:12:EF:DD:AA'); +}); + +jQuery.each(["put", "delete"], function (i, method) { + jQuery[method] = function (url, data, callback, type) { + if (jQuery.isFunction(data)) { + type = type || callback; + callback = data; + data = undefined; + } + + return jQuery.ajax({ + url: url, + type: method, + dataType: type, + data: data, + success: callback, + contentType: 'application/json' + }); + }; +}); + +jQuery(function ($) { + let newHostHtml = '
'; + $('h1').parents(".row").append(newHostHtml); +}); + +jQuery(function ($) { + $('.btn-outline-success').click(function () { + const $this = $(this); + $.put(base_url + 'wake_on_lan_host/' + $this.data('mac-address'), function (result) { + $this.parents('.info-box').find('.latest-used').text(prettyDateTime(result)); + }); + }); +}); + +jQuery(function ($) { + let $modal_remove_wake_on_lan_host = $('#modal_remove_wake_on_lan_host'); + let $remove_client_confirm = $('#remove_wake_on_host_confirm'); + + $modal_remove_wake_on_lan_host.on('show.bs.modal', function (event) { + const $btn = $(event.relatedTarget); + const $modal = $(this); + + const $editBtn = $btn.parents('.btn-group').find('.btn_modify_wake_on_lan_host'); + $modal.find('.modal-body').text("You are about to remove Wake On Lan Host " + $editBtn.data('name')); + $remove_client_confirm.val($editBtn.data('mac-address')); + }) + + $remove_client_confirm.click(function () { + const macAddress = $remove_client_confirm.val().replaceAll(":", "-"); + $.delete(base_url + 'wake_on_lan_host/' + macAddress); + $('#' + macAddress).remove(); + + $modal_remove_wake_on_lan_host.modal('hide'); + }); +}); + +jQuery(function ($) { + $('.latest-used').each(function () { + const $this = $(this); + const timeText = $this.text().trim(); + try { + if (timeText != "Unused") { + $this.text(prettyDateTime(timeText)); + } + } catch (ex) { + console.log(timeText); + throw ex; + } + }); +}); + +jQuery(function ($) { + let $modal_wake_on_lan_host = $("#modal_wake_on_lan_host"); + let $name = $('#frm_wake_on_lan_host_name'); + let $macAddress = $('#frm_wake_on_lan_host_mac_address'); + let $oldMacAddress = $('#frm_wake_on_lan_host_old_mac_address'); + let $contentRow = $('.content .row'); + let $frm_wake_on_lan_host = $("#frm_wake_on_lan_host"); + + // https://jqueryvalidation.org/ + let validator = $frm_wake_on_lan_host.validate({ + submitHandler: function () { + let data = { + name: $name.val(), + mac_address: $macAddress.val().toUpperCase(), + old_mac_address: $oldMacAddress.val().toUpperCase() + }; + $.ajax({ + cache: false, + method: 'POST', + url: base_url + 'wake_on_lan_host', + dataType: 'json', + contentType: "application/json", + data: JSON.stringify(data), + success: function (response) { + /** @type {string} */ + let oldMacAddress = $oldMacAddress.val().toUpperCase(); + + if (oldMacAddress != '') { + let macAddress = response.MacAddress; + let name = response.Name; + + let $container = $('#' + oldMacAddress.replaceAll(":", "-")); + if (macAddress != oldMacAddress) { + $container.attr('id', macAddress.replaceAll(":", "-")); + $container.find('.mac-address').text(macAddress); + $container.find('[data-mac-address]').data('mac-address', macAddress); + } + + $container.find('.name').text(name); + $container.find('[data-name]').data('name', name); + } else { + const $template = $( + wake_on_lan_new_template + .replace(/{{ .Id }}/g, response.MacAddress.replaceAll(":", "-").toUpperCase()) + .replace(/{{ .MacAddress }}/g, response.MacAddress.toUpperCase()) + .replace(/{{ .Name }}/g, response.Name) + ); + + $contentRow.append($template); + } + $modal_wake_on_lan_host.modal('hide'); + toastr.success('Wake on Lan Host Save successfully'); + }, + error: function (jqXHR, exception) { + const responseJson = jQuery.parseJSON(jqXHR.responseText); + toastr.error(responseJson['message']); + + if (typeof (console) != 'undefined') + console.log(exception); + } + }); + + return false; + }, + rules: { + name: { + required: true, + }, + mac_address: { + required: true, + mac: true, + } + }, + messages: { + name: { + required: "Please enter a name" + }, + mac_address: { + required: "Please enter a Mac Address" + } + }, + errorElement: 'span', + errorPlacement: function (error, element) { + error.addClass('invalid-feedback'); + element.closest('.form-group').append(error); + }, + highlight: function (element) { + $(element).addClass('is-invalid'); + }, + unhighlight: function (element) { + $(element).removeClass('is-invalid'); + } + }); + + $modal_wake_on_lan_host.on('show.bs.modal', function (e) { + const $btn = $(e.relatedTarget); + validator.resetForm(); + $macAddress.removeClass('is-invalid'); + + $name.val($btn.data('name')); + $macAddress.val($btn.data('mac-address')); + $oldMacAddress.val($btn.data('mac-address')); + }); +}); diff --git a/db/.gitignore b/db/.gitignore new file mode 100644 index 0000000..76bedae --- /dev/null +++ b/db/.gitignore @@ -0,0 +1,5 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore + diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..a7d49c0 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,27 @@ +version: "3" + +services: + wg: + build: . + #image: ngoduykhanh/wireguard-ui:latest + container_name: wgui + cap_add: + - NET_ADMIN + network_mode: host + environment: + - SENDGRID_API_KEY + - EMAIL_FROM_ADDRESS + - EMAIL_FROM_NAME + - SESSION_SECRET + - WGUI_USERNAME=alpha + - WGUI_PASSWORD=this-unusual-password + - WG_CONF_TEMPLATE + - WGUI_MANAGE_START=false + - WGUI_MANAGE_RESTART=false + logging: + driver: json-file + options: + max-size: 50m + volumes: + - ./db:/app/db + - /etc/wireguard:/etc/wireguard diff --git a/emailer/interface.go b/emailer/interface.go new file mode 100644 index 0000000..5a486fc --- /dev/null +++ b/emailer/interface.go @@ -0,0 +1,10 @@ +package emailer + +type Attachment struct { + Name string + Data []byte +} + +type Emailer interface { + Send(toName string, to string, subject string, content string, attachments []Attachment) error +} diff --git a/emailer/sendgrid.go b/emailer/sendgrid.go new file mode 100644 index 0000000..864c953 --- /dev/null +++ b/emailer/sendgrid.go @@ -0,0 +1,54 @@ +package emailer + +import ( + "encoding/base64" + + "github.com/sendgrid/sendgrid-go" + "github.com/sendgrid/sendgrid-go/helpers/mail" +) + +type SendgridApiMail struct { + apiKey string + fromName string + from string +} + +func NewSendgridApiMail(apiKey, fromName, from string) *SendgridApiMail { + ans := SendgridApiMail{apiKey: apiKey, fromName: fromName, from: from} + return &ans +} + +func (o *SendgridApiMail) Send(toName string, to string, subject string, content string, attachments []Attachment) error { + m := mail.NewV3Mail() + + mailFrom := mail.NewEmail(o.fromName, o.from) + mailContent := mail.NewContent("text/html", content) + mailTo := mail.NewEmail(toName, to) + + m.SetFrom(mailFrom) + m.AddContent(mailContent) + + personalization := mail.NewPersonalization() + personalization.AddTos(mailTo) + personalization.Subject = subject + + m.AddPersonalizations(personalization) + + toAdd := make([]*mail.Attachment, 0, len(attachments)) + for i := range attachments { + var att mail.Attachment + encoded := base64.StdEncoding.EncodeToString(attachments[i].Data) + att.SetContent(encoded) + att.SetType("text/plain") + att.SetFilename(attachments[i].Name) + att.SetDisposition("attachment") + toAdd = append(toAdd, &att) + } + + m.AddAttachment(toAdd...) + request := sendgrid.GetRequest(o.apiKey, "/v3/mail/send", "https://api.sendgrid.com") + request.Method = "POST" + request.Body = mail.GetRequestBody(m) + _, err := sendgrid.API(request) + return err +} diff --git a/emailer/smtp.go b/emailer/smtp.go new file mode 100644 index 0000000..2586924 --- /dev/null +++ b/emailer/smtp.go @@ -0,0 +1,100 @@ +package emailer + +import ( + "crypto/tls" + "fmt" + "strings" + "time" + + mail "github.com/xhit/go-simple-mail/v2" +) + +type SmtpMail struct { + hostname string + port int + username string + password string + smtpHelo string + authType mail.AuthType + encryption mail.Encryption + noTLSCheck bool + fromName string + from string +} + +func authType(authType string) mail.AuthType { + switch strings.ToUpper(authType) { + case "PLAIN": + return mail.AuthPlain + case "LOGIN": + return mail.AuthLogin + default: + return mail.AuthNone + } +} + +func encryptionType(encryptionType string) mail.Encryption { + switch strings.ToUpper(encryptionType) { + case "NONE": + return mail.EncryptionNone + case "SSL": + return mail.EncryptionSSL + case "SSLTLS": + return mail.EncryptionSSLTLS + case "TLS": + return mail.EncryptionTLS + default: + return mail.EncryptionSTARTTLS + } +} + +func NewSmtpMail(hostname string, port int, username string, password string, SmtpHelo string, noTLSCheck bool, auth string, fromName, from string, encryption string) *SmtpMail { + ans := SmtpMail{hostname: hostname, port: port, username: username, password: password, smtpHelo: SmtpHelo, noTLSCheck: noTLSCheck, fromName: fromName, from: from, authType: authType(auth), encryption: encryptionType(encryption)} + return &ans +} + +func addressField(address string, name string) string { + if name == "" { + return address + } + return fmt.Sprintf("%s <%s>", name, address) +} + +func (o *SmtpMail) Send(toName string, to string, subject string, content string, attachments []Attachment) error { + server := mail.NewSMTPClient() + + server.Host = o.hostname + server.Port = o.port + server.Authentication = o.authType + server.Username = o.username + server.Password = o.password + server.Helo = o.smtpHelo + server.Encryption = o.encryption + server.KeepAlive = false + server.ConnectTimeout = 10 * time.Second + server.SendTimeout = 10 * time.Second + + if o.noTLSCheck { + server.TLSConfig = &tls.Config{InsecureSkipVerify: true} + } + + smtpClient, err := server.Connect() + + if err != nil { + return err + } + + email := mail.NewMSG() + email.SetFrom(addressField(o.from, o.fromName)). + AddTo(addressField(to, toName)). + SetSubject(subject). + SetBody(mail.TextHTML, content) + + for _, v := range attachments { + email.Attach(&mail.File{Name: v.Name, Data: v.Data}) + } + + err = email.Send(smtpClient) + + return err +} diff --git a/examples/docker-compose/README.md b/examples/docker-compose/README.md new file mode 100644 index 0000000..951df08 --- /dev/null +++ b/examples/docker-compose/README.md @@ -0,0 +1,30 @@ +## Prerequisites + +### Kernel Module + +Depending on if the Wireguard kernel module is available on your system you have more or less choices which example to use. + +You can check if the kernel modules are available via the following command: +```shell +modprobe wireguard +``` + +If the command exits successfully and doesn't print an error the kernel modules are available. +If it does error, you either have to install them manually (or activate if deactivated) or use an userspace implementation. +For an example of an userspace implementation, see _borigtun_. + +### Credentials + +Username and password for all examples is `admin` by default. +For security reasons it's highly recommended to change them before the first startup. + +## Examples +- **[system](system.yml)** + + If you have Wireguard already installed on your system and only want to run the UI in docker this might fit the most. +- **[linuxserver](linuxserver.yml)** + + If you have the Wireguard kernel modules installed (included in the mainline kernel since version 5.6) but want it running inside of docker, this might fit the most. +- **[boringtun](boringtun.yml)** + + If Wireguard kernel modules are not available, you can switch to an userspace implementation like [boringtun](https://github.com/cloudflare/boringtun). diff --git a/examples/docker-compose/boringtun.yml b/examples/docker-compose/boringtun.yml new file mode 100644 index 0000000..a1bdd2f --- /dev/null +++ b/examples/docker-compose/boringtun.yml @@ -0,0 +1,43 @@ +version: "3" + +services: + boringtun: + image: ghcr.io/ntkme/boringtun:edge + command: + - wg0 + container_name: boringtun + # use the network of the 'wireguard-ui' service. this enables to show active clients in the status page + network_mode: service:wireguard-ui + cap_add: + - NET_ADMIN + volumes: + - /dev/net/tun:/dev/net/tun + - ./config:/etc/wireguard + + wireguard-ui: + image: ngoduykhanh/wireguard-ui:latest + container_name: wireguard-ui + cap_add: + - NET_ADMIN + environment: + - SENDGRID_API_KEY + - EMAIL_FROM_ADDRESS + - EMAIL_FROM_NAME + - SESSION_SECRET + - WGUI_USERNAME=admin + - WGUI_PASSWORD=admin + - WG_CONF_TEMPLATE + - WGUI_MANAGE_START=true + - WGUI_MANAGE_RESTART=true + logging: + driver: json-file + options: + max-size: 50m + volumes: + - ./db:/app/db + - ./config:/etc/wireguard + ports: + # port for wireguard-ui + - "5000:5000" + # port of the wireguard server. this must be set here as the `boringtun` container joins the network of this container and hasn't its own network over which it could publish the ports + - "51820:51820/udp" diff --git a/examples/docker-compose/linuxserver.yml b/examples/docker-compose/linuxserver.yml new file mode 100644 index 0000000..1b7a66f --- /dev/null +++ b/examples/docker-compose/linuxserver.yml @@ -0,0 +1,42 @@ +version: "3" + +services: + wireguard: + image: linuxserver/wireguard:latest + container_name: wireguard + cap_add: + - NET_ADMIN + volumes: + - ./config:/config + ports: + # port for wireguard-ui. this must be set here as the `wireguard-ui` container joins the network of this container and hasn't its own network over which it could publish the ports + - "5000:5000" + # port of the wireguard server + - "51820:51820/udp" + + wireguard-ui: + image: ngoduykhanh/wireguard-ui:latest + container_name: wireguard-ui + depends_on: + - wireguard + cap_add: + - NET_ADMIN + # use the network of the 'wireguard' service. this enables to show active clients in the status page + network_mode: service:wireguard + environment: + - SENDGRID_API_KEY + - EMAIL_FROM_ADDRESS + - EMAIL_FROM_NAME + - SESSION_SECRET + - WGUI_USERNAME=admin + - WGUI_PASSWORD=admin + - WG_CONF_TEMPLATE + - WGUI_MANAGE_START=true + - WGUI_MANAGE_RESTART=true + logging: + driver: json-file + options: + max-size: 50m + volumes: + - ./db:/app/db + - ./config:/etc/wireguard diff --git a/examples/docker-compose/system.yml b/examples/docker-compose/system.yml new file mode 100644 index 0000000..c27f31e --- /dev/null +++ b/examples/docker-compose/system.yml @@ -0,0 +1,27 @@ +version: "3" + +services: + wireguard-ui: + image: ngoduykhanh/wireguard-ui:latest + container_name: wireguard-ui + cap_add: + - NET_ADMIN + # required to show active clients. with this set, you don't need to expose the ui port (5000) anymore + network_mode: host + environment: + - SENDGRID_API_KEY + - EMAIL_FROM_ADDRESS + - EMAIL_FROM_NAME + - SESSION_SECRET + - WGUI_USERNAME=admin + - WGUI_PASSWORD=admin + - WG_CONF_TEMPLATE + - WGUI_MANAGE_START=false + - WGUI_MANAGE_RESTART=false + logging: + driver: json-file + options: + max-size: 50m + volumes: + - ./db:/app/db + - /etc/wireguard:/etc/wireguard diff --git a/go.mod b/go.mod index 1a981a2..e9647ca 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,52 @@ -module gitea.perlbach24.de/scripte/wireguard-ui-multi +module github.com/ngoduykhanh/wireguard-ui -go 1.22 +go 1.21 require ( + github.com/NicoNex/echotron/v3 v3.27.0 + github.com/glendc/go-external-ip v0.1.0 + github.com/gorilla/sessions v1.2.2 + github.com/labstack/echo-contrib v0.15.0 + github.com/labstack/echo/v4 v4.11.4 + github.com/labstack/gommon v0.4.2 + github.com/rs/xid v1.5.0 + github.com/sabhiram/go-wol v0.0.0-20211224004021-c83b0c2f887d + github.com/sdomino/scribble v0.0.0-20230717151034-b95d4df19aa8 + github.com/sendgrid/sendgrid-go v3.14.0+incompatible github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e - golang.org/x/crypto v0.24.0 - modernc.org/sqlite v1.30.1 + github.com/xhit/go-simple-mail/v2 v2.16.0 + golang.org/x/crypto v0.17.0 + golang.org/x/mod v0.14.0 + //golang.zx2c4.com/wireguard v0.0.20200121 // indirect + golang.zx2c4.com/wireguard/wgctrl v0.0.0-20210803171230-4253848d036c + gopkg.in/go-playground/validator.v9 v9.31.0 +) + +require ( + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-test/deep v1.1.0 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/jcelliott/lumber v0.0.0-20160324203708-dd349441af25 // indirect + github.com/josharian/native v1.1.0 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mdlayher/genetlink v1.3.2 // indirect + github.com/mdlayher/netlink v1.7.2 // indirect + github.com/mdlayher/socket v0.5.0 // indirect + github.com/sendgrid/rest v2.6.9+incompatible // indirect + github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sync v0.5.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect + golang.zx2c4.com/wireguard v0.0.0-20210427022245-097af6e1351b // indirect + gopkg.in/go-playground/assert.v1 v1.2.1 // indirect ) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3aa2ceb --- /dev/null +++ b/go.sum @@ -0,0 +1,188 @@ +github.com/NicoNex/echotron/v3 v3.27.0 h1:iq4BLPO+Dz1JHjh2HPk0D0NldAZSYcAjaOicgYEhUzw= +github.com/NicoNex/echotron/v3 v3.27.0/go.mod h1:LpP5IyHw0y+DZUZMBgXEDAF9O8feXrQu7w7nlJzzoZI= +github.com/coreos/bbolt v1.3.1-coreos.6.0.20180223184059-4f5275f4ebbf/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +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/glendc/go-external-ip v0.1.0 h1:iX3xQ2Q26atAmLTbd++nUce2P5ht5P4uD4V7caSY/xg= +github.com/glendc/go-external-ip v0.1.0/go.mod h1:CNx312s2FLAJoWNdJWZ2Fpf5O4oLsMFwuYviHjS4uJE= +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.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= +github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= +github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= +github.com/jcelliott/lumber v0.0.0-20160324203708-dd349441af25 h1:EFT6MH3igZK/dIVqgGbTqWVvkZ7wJ5iGN03SVtvvdd8= +github.com/jcelliott/lumber v0.0.0-20160324203708-dd349441af25/go.mod h1:sWkGw/wsaHtRsT9zGQ/WyJCotGWG/Anow/9hsAcBWRw= +github.com/jessevdk/go-flags v0.0.0-20150816100521-1acbbaff2f34/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/josharian/native v0.0.0-20200817173448-b6b71def0850/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/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw= +github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ= +github.com/jsimonetti/rtnetlink v0.0.0-20201009170750-9c6f07d100c1/go.mod h1:hqoO/u39cqLeBLebZ8fWdE96O7FxrAsRYhnVOdgHxok= +github.com/jsimonetti/rtnetlink v0.0.0-20201216134343-bde56ed16391/go.mod h1:cR77jAZG3Y3bsb8hF6fHJbFoyFukLFOkQ98S0pQz3xw= +github.com/jsimonetti/rtnetlink v0.0.0-20201220180245-69540ac93943/go.mod h1:z4c53zj6Eex712ROyh8WI0ihysb5j2ROyV42iNogmAs= +github.com/jsimonetti/rtnetlink v0.0.0-20210122163228-8d122574c736/go.mod h1:ZXpIyOK59ZnN7J0BV99cZUPmsqDRZ3eq5X+st7u/oSA= +github.com/jsimonetti/rtnetlink v0.0.0-20210212075122-66c871082f2b/go.mod h1:8w9Rh8m+aHZIG69YPGGem1i5VzoyRC8nw2kA8B+ik5U= +github.com/labstack/echo-contrib v0.15.0 h1:9K+oRU265y4Mu9zpRDv3X+DGTqUALY6oRHCSZZKCRVU= +github.com/labstack/echo-contrib v0.15.0/go.mod h1:lei+qt5CLB4oa7VHTE0yEfQSEB9XTJI1LUqko9UWvo4= +github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8= +github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mdlayher/ethtool v0.0.0-20210210192532-2b88debcdd43/go.mod h1:+t7E0lkKfbBsebllff1xdTmyJt8lH37niI6kwFk9OTo= +github.com/mdlayher/genetlink v1.0.0/go.mod h1:0rJ0h4itni50A86M2kHcgS85ttZazNt7a8H2a2cw0Gc= +github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= +github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA= +github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M= +github.com/mdlayher/netlink v1.1.0/go.mod h1:H4WCitaheIsdF9yOYu8CFmCgQthAPIWZmcKp9uZHgmY= +github.com/mdlayher/netlink v1.1.1/go.mod h1:WTYpFb/WTvlRJAyKhZL5/uy69TDDpHHu2VZmb2XgV7o= +github.com/mdlayher/netlink v1.2.0/go.mod h1:kwVW1io0AZy9A1E2YYgaD4Cj+C+GPkU6klXCMzIJ9p8= +github.com/mdlayher/netlink v1.2.1/go.mod h1:bacnNlfhqHqqLo4WsYeXSqfyXkInQ9JneWI68v1KwSU= +github.com/mdlayher/netlink v1.2.2-0.20210123213345-5cc92139ae3e/go.mod h1:bacnNlfhqHqqLo4WsYeXSqfyXkInQ9JneWI68v1KwSU= +github.com/mdlayher/netlink v1.3.0/go.mod h1:xK/BssKuwcRXHrtN04UBkwQ6dY9VviGGuriDdoPSWys= +github.com/mdlayher/netlink v1.4.0/go.mod h1:dRJi5IABcZpBD2A3D0Mv/AiX8I9uDEu5oGkAVrekmf8= +github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= +github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= +github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= +github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/sabhiram/go-colorize v0.0.0-20210403184538-366f55d711cf/go.mod h1:GvlEbMJBpbAXFn06UajbdBlGZ18iLvHyuIrgG//L8uk= +github.com/sabhiram/go-wol v0.0.0-20211224004021-c83b0c2f887d h1:NDtoSmsxTpDYTqvUurn2ooAzDaYbJSB9/tOhLzaewgo= +github.com/sabhiram/go-wol v0.0.0-20211224004021-c83b0c2f887d/go.mod h1:SVPBBd492Gk7Cq5lPd6OAYtIGk2r1FsyH8KT3IB8h7c= +github.com/sdomino/scribble v0.0.0-20230717151034-b95d4df19aa8 h1:hlNRl87eAZhh2QMJVShuXHL6OOd0ObZM0JozDIruNeM= +github.com/sdomino/scribble v0.0.0-20230717151034-b95d4df19aa8/go.mod h1:W6zxGUBCXRR5QugSd/nFcFVmwoGnvpjiNY/JwT03Wew= +github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= +github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= +github.com/sendgrid/sendgrid-go v3.14.0+incompatible h1:KDSasSTktAqMJCYClHVE94Fcif2i7P7wzISv1sU6DUA= +github.com/sendgrid/sendgrid-go v3.14.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v0.0.0-20150929183540-2b15294402a8/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA= +github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210503195802-e9a32991a82e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +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-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/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-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201216054612-986b41b23924/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210504132125-bbd867fde50d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/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-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201118182958-a01c418693c7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201218084310-7d0127a74742/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210110051926-789bb1bd4061/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210123111255-9b0068b26619/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210216163648-f7da38b97c65/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210309040221-94ec62e08169/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6/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-20210927094055-39ccf1dd6fa6/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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.zx2c4.com/wireguard v0.0.0-20210427022245-097af6e1351b h1:XDLXhn7ryprJVo+Lpkiib6CIuXE2031GDwtfEm7vLjI= +golang.zx2c4.com/wireguard v0.0.0-20210427022245-097af6e1351b/go.mod h1:a057zjmoc00UN7gVkaJt2sXVK523kMJcogDTEvPIasg= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20210803171230-4253848d036c h1:ADNrRDI5NR23/TUCnEmlLZLt4u9DnZ2nwRkPrAcFvto= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20210803171230-4253848d036c/go.mod h1:+1XihzyZUBJcSc5WO9SwNA7v26puQwOEDwanaxfNXPQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v9 v9.31.0 h1:bmXmP2RSNtFES+bn4uYuHT7iJFJv7Vj+an+ZQdDaD1M= +gopkg.in/go-playground/validator.v9 v9.31.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler/middlewares.go b/handler/middlewares.go new file mode 100644 index 0000000..b03ef46 --- /dev/null +++ b/handler/middlewares.go @@ -0,0 +1,20 @@ +package handler + +import ( + "net/http" + + "github.com/labstack/echo/v4" +) + +// ContentTypeJson checks that the requests have the Content-Type header set to "application/json". +// This helps against CSRF attacks. +func ContentTypeJson(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + contentType := c.Request().Header.Get("Content-Type") + if contentType != "application/json" { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Only JSON allowed"}) + } + + return next(c) + } +} diff --git a/handler/response.go b/handler/response.go new file mode 100644 index 0000000..711115f --- /dev/null +++ b/handler/response.go @@ -0,0 +1,6 @@ +package handler + +type jsonHTTPResponse struct { + Status bool `json:"status"` + Message string `json:"message"` +} diff --git a/handler/routes.go b/handler/routes.go new file mode 100644 index 0000000..ede3654 --- /dev/null +++ b/handler/routes.go @@ -0,0 +1,1196 @@ +package handler + +import ( + "crypto/subtle" + "encoding/base64" + "encoding/json" + "fmt" + "io/fs" + "net/http" + "os" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/gorilla/sessions" + "github.com/labstack/echo-contrib/session" + "github.com/labstack/echo/v4" + "github.com/labstack/gommon/log" + "github.com/rs/xid" + "github.com/skip2/go-qrcode" + "golang.zx2c4.com/wireguard/wgctrl" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/ngoduykhanh/wireguard-ui/emailer" + "github.com/ngoduykhanh/wireguard-ui/model" + "github.com/ngoduykhanh/wireguard-ui/store" + "github.com/ngoduykhanh/wireguard-ui/telegram" + "github.com/ngoduykhanh/wireguard-ui/util" +) + +var usernameRegexp = regexp.MustCompile("^\\w[\\w\\-.]*$") + +// Health check handler +func Health() echo.HandlerFunc { + return func(c echo.Context) error { + return c.String(http.StatusOK, "ok") + } +} + +func Favicon() echo.HandlerFunc { + return func(c echo.Context) error { + if favicon, ok := os.LookupEnv(util.FaviconFilePathEnvVar); ok { + return c.File(favicon) + } + return c.Redirect(http.StatusFound, util.BasePath+"/static/custom/img/favicon.ico") + } +} + +// LoginPage handler +func LoginPage() echo.HandlerFunc { + return func(c echo.Context) error { + return c.Render(http.StatusOK, "login.html", map[string]interface{}{}) + } +} + +// Login for signing in handler +func Login(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + data := make(map[string]interface{}) + err := json.NewDecoder(c.Request().Body).Decode(&data) + + if err != nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"}) + } + + username := data["username"].(string) + password := data["password"].(string) + rememberMe := data["rememberMe"].(bool) + + if !usernameRegexp.MatchString(username) { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"}) + } + + dbuser, err := db.GetUserByName(username) + if err != nil { + log.Infof("Cannot query user %s from DB", username) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Invalid credentials"}) + } + + userCorrect := subtle.ConstantTimeCompare([]byte(username), []byte(dbuser.Username)) == 1 + + var passwordCorrect bool + if dbuser.PasswordHash != "" { + match, err := util.VerifyHash(dbuser.PasswordHash, password) + if err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify password"}) + } + passwordCorrect = match + } else { + passwordCorrect = subtle.ConstantTimeCompare([]byte(password), []byte(dbuser.Password)) == 1 + } + + if userCorrect && passwordCorrect { + ageMax := 0 + if rememberMe { + ageMax = 86400 * 7 + } + + cookiePath := util.GetCookiePath() + + sess, _ := session.Get("session", c) + sess.Options = &sessions.Options{ + Path: cookiePath, + MaxAge: ageMax, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } + + // set session_token + tokenUID := xid.New().String() + now := time.Now().UTC().Unix() + sess.Values["username"] = dbuser.Username + sess.Values["user_hash"] = util.GetDBUserCRC32(dbuser) + sess.Values["admin"] = dbuser.Admin + sess.Values["session_token"] = tokenUID + sess.Values["max_age"] = ageMax + sess.Values["created_at"] = now + sess.Values["updated_at"] = now + sess.Save(c.Request(), c.Response()) + + // set session_token in cookie + cookie := new(http.Cookie) + cookie.Name = "session_token" + cookie.Path = cookiePath + cookie.Value = tokenUID + cookie.MaxAge = ageMax + cookie.HttpOnly = true + cookie.SameSite = http.SameSiteLaxMode + c.SetCookie(cookie) + + return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Logged in successfully"}) + } + + return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Invalid credentials"}) + } +} + +// GetUsers handler return a JSON list of all users +func GetUsers(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + usersList, err := db.GetUsers() + if err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{ + false, fmt.Sprintf("Cannot get user list: %v", err), + }) + } + + return c.JSON(http.StatusOK, usersList) + } +} + +// GetUser handler returns a JSON object of single user +func GetUser(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + username := c.Param("username") + + if !usernameRegexp.MatchString(username) { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"}) + } + + if !isAdmin(c) && (username != currentUser(c)) { + return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Manager cannot access other user data"}) + } + + userData, err := db.GetUserByName(username) + if err != nil { + return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "User not found"}) + } + + return c.JSON(http.StatusOK, userData) + } +} + +// Logout to log a user out +func Logout() echo.HandlerFunc { + return func(c echo.Context) error { + clearSession(c) + return c.Redirect(http.StatusTemporaryRedirect, util.BasePath+"/login") + } +} + +// LoadProfile to load user information +func LoadProfile() echo.HandlerFunc { + return func(c echo.Context) error { + return c.Render(http.StatusOK, "profile.html", map[string]interface{}{ + "baseData": model.BaseData{Active: "profile", CurrentUser: currentUser(c), Admin: isAdmin(c)}, + }) + } +} + +// UsersSettings handler +func UsersSettings() echo.HandlerFunc { + return func(c echo.Context) error { + return c.Render(http.StatusOK, "users_settings.html", map[string]interface{}{ + "baseData": model.BaseData{Active: "users-settings", CurrentUser: currentUser(c), Admin: isAdmin(c)}, + }) + } +} + +// UpdateUser to update user information +func UpdateUser(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + data := make(map[string]interface{}) + err := json.NewDecoder(c.Request().Body).Decode(&data) + + if err != nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"}) + } + + username := data["username"].(string) + password := data["password"].(string) + previousUsername := data["previous_username"].(string) + admin := data["admin"].(bool) + + if !isAdmin(c) && (previousUsername != currentUser(c)) { + return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Manager cannot access other user data"}) + } + + if !isAdmin(c) { + admin = false + } + + if !usernameRegexp.MatchString(previousUsername) { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"}) + } + + user, err := db.GetUserByName(previousUsername) + if err != nil { + return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, err.Error()}) + } + + if username == "" || !usernameRegexp.MatchString(username) { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"}) + } else { + user.Username = username + } + + if username != previousUsername { + _, err := db.GetUserByName(username) + if err == nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "This username is taken"}) + } + } + + if password != "" { + hash, err := util.HashPassword(password) + if err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) + } + user.PasswordHash = hash + } + + if previousUsername != currentUser(c) { + user.Admin = admin + } + + if err := db.DeleteUser(previousUsername); err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) + } + if err := db.SaveUser(user); err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) + } + log.Infof("Updated user information successfully") + + if previousUsername == currentUser(c) { + setUser(c, user.Username, user.Admin, util.GetDBUserCRC32(user)) + } + + return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated user information successfully"}) + } +} + +// CreateUser to create new user +func CreateUser(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + data := make(map[string]interface{}) + err := json.NewDecoder(c.Request().Body).Decode(&data) + + if err != nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"}) + } + + var user model.User + username := data["username"].(string) + password := data["password"].(string) + admin := data["admin"].(bool) + + if username == "" || !usernameRegexp.MatchString(username) { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"}) + } else { + user.Username = username + } + + { + _, err := db.GetUserByName(username) + if err == nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "This username is taken"}) + } + } + + hash, err := util.HashPassword(password) + if err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) + } + user.PasswordHash = hash + + user.Admin = admin + + if err := db.SaveUser(user); err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) + } + log.Infof("Created user successfully") + + return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Created user successfully"}) + } +} + +// RemoveUser handler +func RemoveUser(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + data := make(map[string]interface{}) + err := json.NewDecoder(c.Request().Body).Decode(&data) + + if err != nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"}) + } + + username := data["username"].(string) + + if !usernameRegexp.MatchString(username) { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"}) + } + + if username == currentUser(c) { + return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "User cannot delete itself"}) + } + // delete user from database + + if err := db.DeleteUser(username); err != nil { + log.Error("Cannot delete user: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot delete user from database"}) + } + + log.Infof("Removed user: %s", username) + + return c.JSON(http.StatusOK, jsonHTTPResponse{true, "User removed"}) + } +} + +// WireGuardClients handler +func WireGuardClients(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + clientDataList, err := db.GetClients(true) + if err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{ + false, fmt.Sprintf("Cannot get client list: %v", err), + }) + } + + return c.Render(http.StatusOK, "clients.html", map[string]interface{}{ + "baseData": model.BaseData{Active: "", CurrentUser: currentUser(c), Admin: isAdmin(c)}, + "clientDataList": clientDataList, + }) + } +} + +// GetClients handler return a JSON list of Wireguard client data +func GetClients(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + clientDataList, err := db.GetClients(true) + if err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{ + false, fmt.Sprintf("Cannot get client list: %v", err), + }) + } + + for i, clientData := range clientDataList { + clientDataList[i] = util.FillClientSubnetRange(clientData) + } + + return c.JSON(http.StatusOK, clientDataList) + } +} + +// GetClient handler returns a JSON object of Wireguard client data +func GetClient(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + clientID := c.Param("id") + + if _, err := xid.FromString(clientID); err != nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"}) + } + + qrCodeSettings := model.QRCodeSettings{ + Enabled: true, + IncludeDNS: true, + IncludeMTU: true, + } + + clientData, err := db.GetClientByID(clientID, qrCodeSettings) + if err != nil { + return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"}) + } + + return c.JSON(http.StatusOK, util.FillClientSubnetRange(clientData)) + } +} + +// NewClient handler +func NewClient(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + var client model.Client + c.Bind(&client) + + // Validate Telegram userid if provided + if client.TgUserid != "" { + idNum, err := strconv.ParseInt(client.TgUserid, 10, 64) + if err != nil || idNum == 0 { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Telegram userid must be a non-zero number"}) + } + } + + // read server information + server, err := db.GetServer() + if err != nil { + log.Error("Cannot fetch server from database: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) + } + + // validate the input Allocation IPs + allocatedIPs, err := util.GetAllocatedIPs("") + check, err := util.ValidateIPAllocation(server.Interface.Addresses, allocatedIPs, client.AllocatedIPs) + if !check { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, fmt.Sprintf("%s", err)}) + } + + // validate the input AllowedIPs + if util.ValidateAllowedIPs(client.AllowedIPs) == false { + log.Warnf("Invalid Allowed IPs input from user: %v", client.AllowedIPs) + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Allowed IPs must be in CIDR format"}) + } + + // validate extra AllowedIPs + if util.ValidateExtraAllowedIPs(client.ExtraAllowedIPs) == false { + log.Warnf("Invalid Extra AllowedIPs input from user: %v", client.ExtraAllowedIPs) + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Extra AllowedIPs must be in CIDR format"}) + } + + // gen ID + guid := xid.New() + client.ID = guid.String() + + // gen Wireguard key pair + if client.PublicKey == "" { + key, err := wgtypes.GeneratePrivateKey() + if err != nil { + log.Error("Cannot generate wireguard key pair: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"}) + } + client.PrivateKey = key.String() + client.PublicKey = key.PublicKey().String() + } else { + _, err := wgtypes.ParseKey(client.PublicKey) + if err != nil { + log.Error("Cannot verify wireguard public key: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify Wireguard public key"}) + } + // check for duplicates + clients, err := db.GetClients(false) + if err != nil { + log.Error("Cannot get clients for duplicate check") + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get clients for duplicate check"}) + } + for _, other := range clients { + if other.Client.PublicKey == client.PublicKey { + log.Error("Duplicate Public Key") + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Duplicate Public Key"}) + } + } + } + + if client.PresharedKey == "" { + presharedKey, err := wgtypes.GenerateKey() + if err != nil { + log.Error("Cannot generated preshared key: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{ + false, "Cannot generate Wireguard preshared key", + }) + } + client.PresharedKey = presharedKey.String() + } else if client.PresharedKey == "-" { + client.PresharedKey = "" + log.Infof("skipped PresharedKey generation for user: %v", client.Name) + } else { + _, err := wgtypes.ParseKey(client.PresharedKey) + if err != nil { + log.Error("Cannot verify wireguard preshared key: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify Wireguard preshared key"}) + } + } + client.CreatedAt = time.Now().UTC() + client.UpdatedAt = client.CreatedAt + + // write client to the database + if err := db.SaveClient(client); err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{ + false, err.Error(), + }) + } + log.Infof("Created wireguard client: %v", client) + + return c.JSON(http.StatusOK, client) + } +} + +// EmailClient handler to send the configuration via email +func EmailClient(db store.IStore, mailer emailer.Emailer, emailSubject, emailContent string) echo.HandlerFunc { + type clientIdEmailPayload struct { + ID string `json:"id"` + Email string `json:"email"` + } + + return func(c echo.Context) error { + var payload clientIdEmailPayload + c.Bind(&payload) + // TODO validate email + + if _, err := xid.FromString(payload.ID); err != nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"}) + } + + qrCodeSettings := model.QRCodeSettings{ + Enabled: true, + IncludeDNS: true, + IncludeMTU: true, + } + clientData, err := db.GetClientByID(payload.ID, qrCodeSettings) + if err != nil { + log.Errorf("Cannot generate client id %s config file for downloading: %v", payload.ID, err) + return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"}) + } + + // build config + server, _ := db.GetServer() + globalSettings, _ := db.GetGlobalSettings() + config := util.BuildClientConfig(*clientData.Client, server, globalSettings) + + cfgAtt := emailer.Attachment{Name: "wg0.conf", Data: []byte(config)} + var attachments []emailer.Attachment + if clientData.Client.PrivateKey != "" { + qrdata, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(clientData.QRCode, "data:image/png;base64,")) + if err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "decoding: " + err.Error()}) + } + qrAtt := emailer.Attachment{Name: "wg.png", Data: qrdata} + attachments = []emailer.Attachment{cfgAtt, qrAtt} + } else { + attachments = []emailer.Attachment{cfgAtt} + } + err = mailer.Send( + clientData.Client.Name, + payload.Email, + emailSubject, + emailContent, + attachments, + ) + + if err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) + } + + return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Email sent successfully"}) + } +} + +// SendTelegramClient handler to send the configuration via Telegram +func SendTelegramClient(db store.IStore) echo.HandlerFunc { + type clientIdUseridPayload struct { + ID string `json:"id"` + Userid string `json:"userid"` + } + return func(c echo.Context) error { + var payload clientIdUseridPayload + c.Bind(&payload) + + clientData, err := db.GetClientByID(payload.ID, model.QRCodeSettings{Enabled: false}) + if err != nil { + log.Errorf("Cannot generate client id %s config file for downloading: %v", payload.ID, err) + return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"}) + } + + // build config + server, _ := db.GetServer() + globalSettings, _ := db.GetGlobalSettings() + config := util.BuildClientConfig(*clientData.Client, server, globalSettings) + configData := []byte(config) + var qrData []byte + + if clientData.Client.PrivateKey != "" { + qrData, err = qrcode.Encode(config, qrcode.Medium, 512) + if err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "qr gen: " + err.Error()}) + } + } + + userid, err := strconv.ParseInt(clientData.Client.TgUserid, 10, 64) + if err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "userid: " + err.Error()}) + } + + err = telegram.SendConfig(userid, clientData.Client.Name, configData, qrData, false) + + if err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) + } + + return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Telegram message sent successfully"}) + } +} + +// UpdateClient handler to update client information +func UpdateClient(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + var _client model.Client + c.Bind(&_client) + + if _, err := xid.FromString(_client.ID); err != nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"}) + } + + // validate client existence + clientData, err := db.GetClientByID(_client.ID, model.QRCodeSettings{Enabled: false}) + if err != nil { + return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"}) + } + + // Validate Telegram userid if provided + if _client.TgUserid != "" { + idNum, err := strconv.ParseInt(_client.TgUserid, 10, 64) + if err != nil || idNum == 0 { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Telegram userid must be a non-zero number"}) + } + } + + server, err := db.GetServer() + if err != nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{ + false, fmt.Sprintf("Cannot fetch server config: %s", err), + }) + } + client := *clientData.Client + // validate the input Allocation IPs + allocatedIPs, err := util.GetAllocatedIPs(client.ID) + check, err := util.ValidateIPAllocation(server.Interface.Addresses, allocatedIPs, _client.AllocatedIPs) + if !check { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, fmt.Sprintf("%s", err)}) + } + + // validate the input AllowedIPs + if util.ValidateAllowedIPs(_client.AllowedIPs) == false { + log.Warnf("Invalid Allowed IPs input from user: %v", _client.AllowedIPs) + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Allowed IPs must be in CIDR format"}) + } + + if util.ValidateExtraAllowedIPs(_client.ExtraAllowedIPs) == false { + log.Warnf("Invalid Allowed IPs input from user: %v", _client.ExtraAllowedIPs) + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Extra Allowed IPs must be in CIDR format"}) + } + + // update Wireguard Client PublicKey + if client.PublicKey != _client.PublicKey && _client.PublicKey != "" { + _, err := wgtypes.ParseKey(_client.PublicKey) + if err != nil { + log.Error("Cannot verify provided Wireguard public key: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify provided Wireguard public key"}) + } + // check for duplicates + clients, err := db.GetClients(false) + if err != nil { + log.Error("Cannot get client list for duplicate public key check") + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get client list for duplicate public key check"}) + } + for _, other := range clients { + if other.Client.PublicKey == _client.PublicKey { + log.Error("Duplicate Public Key") + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Duplicate Public Key"}) + } + } + + // When replacing any PublicKey, discard any locally stored Wireguard Client PrivateKey + // Client PubKey no longer corresponds to locally stored PrivKey. + // QR code (needs PrivateKey) for this client is no longer possible now. + + if client.PrivateKey != "" { + client.PrivateKey = "" + } + } + + // update Wireguard Client PresharedKey + if client.PresharedKey != _client.PresharedKey && _client.PresharedKey != "" { + _, err := wgtypes.ParseKey(_client.PresharedKey) + if err != nil { + log.Error("Cannot verify provided Wireguard preshared key: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify provided Wireguard preshared key"}) + } + } + + // map new data + client.Name = _client.Name + client.Email = _client.Email + client.TgUserid = _client.TgUserid + client.Enabled = _client.Enabled + client.UseServerDNS = _client.UseServerDNS + client.AllocatedIPs = _client.AllocatedIPs + client.AllowedIPs = _client.AllowedIPs + client.ExtraAllowedIPs = _client.ExtraAllowedIPs + client.Endpoint = _client.Endpoint + client.PublicKey = _client.PublicKey + client.PresharedKey = _client.PresharedKey + client.UpdatedAt = time.Now().UTC() + client.AdditionalNotes = strings.ReplaceAll(strings.Trim(_client.AdditionalNotes, "\r\n"), "\r\n", "\n") + + // write to the database + if err := db.SaveClient(client); err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) + } + log.Infof("Updated client information successfully => %v", client) + + return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated client successfully"}) + } +} + +// SetClientStatus handler to enable / disable a client +func SetClientStatus(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + data := make(map[string]interface{}) + err := json.NewDecoder(c.Request().Body).Decode(&data) + + if err != nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"}) + } + + clientID := data["id"].(string) + status := data["status"].(bool) + + if _, err := xid.FromString(clientID); err != nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"}) + } + + clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false}) + if err != nil { + return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, err.Error()}) + } + + client := *clientData.Client + + client.Enabled = status + if err := db.SaveClient(client); err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) + } + log.Infof("Changed client %s enabled status to %v", client.ID, status) + + return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Changed client status successfully"}) + } +} + +// DownloadClient handler +func DownloadClient(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + clientID := c.QueryParam("clientid") + if clientID == "" { + return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Missing clientid parameter"}) + } + + if _, err := xid.FromString(clientID); err != nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"}) + } + + clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false}) + if err != nil { + log.Errorf("Cannot generate client id %s config file for downloading: %v", clientID, err) + return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"}) + } + + // build config + server, err := db.GetServer() + if err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) + } + globalSettings, err := db.GetGlobalSettings() + if err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) + } + config := util.BuildClientConfig(*clientData.Client, server, globalSettings) + + // create io reader from string + reader := strings.NewReader(config) + + // set response header for downloading + c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s.conf", clientData.Client.Name)) + return c.Stream(http.StatusOK, "text/conf", reader) + } +} + +// RemoveClient handler +func RemoveClient(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + client := new(model.Client) + c.Bind(client) + + if _, err := xid.FromString(client.ID); err != nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"}) + } + + // delete client from database + + if err := db.DeleteClient(client.ID); err != nil { + log.Error("Cannot delete wireguard client: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot delete client from database"}) + } + + log.Infof("Removed wireguard client: %v", client) + return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Client removed"}) + } +} + +// WireGuardServer handler +func WireGuardServer(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + server, err := db.GetServer() + if err != nil { + log.Error("Cannot get server config: ", err) + } + + return c.Render(http.StatusOK, "server.html", map[string]interface{}{ + "baseData": model.BaseData{Active: "wg-server", CurrentUser: currentUser(c), Admin: isAdmin(c)}, + "serverInterface": server.Interface, + "serverKeyPair": server.KeyPair, + }) + } +} + +// WireGuardServerInterfaces handler +func WireGuardServerInterfaces(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + var serverInterface model.ServerInterface + c.Bind(&serverInterface) + + // validate the input addresses + if util.ValidateServerAddresses(serverInterface.Addresses) == false { + log.Warnf("Invalid server interface addresses input from user: %v", serverInterface.Addresses) + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Interface IP address must be in CIDR format"}) + } + + serverInterface.UpdatedAt = time.Now().UTC() + + // write config to the database + + if err := db.SaveServerInterface(serverInterface); err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Interface IP address must be in CIDR format"}) + } + log.Infof("Updated wireguard server interfaces settings: %v", serverInterface) + + return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated interface addresses successfully"}) + } +} + +// WireGuardServerKeyPair handler to generate private and public keys +func WireGuardServerKeyPair(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + // gen Wireguard key pair + key, err := wgtypes.GeneratePrivateKey() + if err != nil { + log.Error("Cannot generate wireguard key pair: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"}) + } + + var serverKeyPair model.ServerKeypair + serverKeyPair.PrivateKey = key.String() + serverKeyPair.PublicKey = key.PublicKey().String() + serverKeyPair.UpdatedAt = time.Now().UTC() + + if err := db.SaveServerKeyPair(serverKeyPair); err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"}) + } + log.Infof("Updated wireguard server interfaces settings: %v", serverKeyPair) + + return c.JSON(http.StatusOK, serverKeyPair) + } +} + +// GlobalSettings handler +func GlobalSettings(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + globalSettings, err := db.GetGlobalSettings() + if err != nil { + log.Error("Cannot get global settings: ", err) + } + + return c.Render(http.StatusOK, "global_settings.html", map[string]interface{}{ + "baseData": model.BaseData{Active: "global-settings", CurrentUser: currentUser(c), Admin: isAdmin(c)}, + "globalSettings": globalSettings, + }) + } +} + +// Status handler +func Status(db store.IStore) echo.HandlerFunc { + type PeerVM struct { + Name string + Email string + PublicKey string + ReceivedBytes int64 + TransmitBytes int64 + LastHandshakeTime time.Time + LastHandshakeRel time.Duration + Connected bool + AllocatedIP string + Endpoint string + } + + type DeviceVM struct { + Name string + Peers []PeerVM + } + return func(c echo.Context) error { + wgClient, err := wgctrl.New() + if err != nil { + return c.Render(http.StatusInternalServerError, "status.html", map[string]interface{}{ + "baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c), Admin: isAdmin(c)}, + "error": err.Error(), + "devices": nil, + }) + } + + devices, err := wgClient.Devices() + if err != nil { + return c.Render(http.StatusInternalServerError, "status.html", map[string]interface{}{ + "baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c), Admin: isAdmin(c)}, + "error": err.Error(), + "devices": nil, + }) + } + + devicesVm := make([]DeviceVM, 0, len(devices)) + if len(devices) > 0 { + m := make(map[string]*model.Client) + clients, err := db.GetClients(false) + if err != nil { + return c.Render(http.StatusInternalServerError, "status.html", map[string]interface{}{ + "baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c), Admin: isAdmin(c)}, + "error": err.Error(), + "devices": nil, + }) + } + for i := range clients { + if clients[i].Client != nil { + m[clients[i].Client.PublicKey] = clients[i].Client + } + } + + conv := map[bool]int{true: 1, false: 0} + for i := range devices { + devVm := DeviceVM{Name: devices[i].Name} + for j := range devices[i].Peers { + var allocatedIPs string + for _, ip := range devices[i].Peers[j].AllowedIPs { + if len(allocatedIPs) > 0 { + allocatedIPs += "
" + } + allocatedIPs += ip.String() + } + pVm := PeerVM{ + PublicKey: devices[i].Peers[j].PublicKey.String(), + ReceivedBytes: devices[i].Peers[j].ReceiveBytes, + TransmitBytes: devices[i].Peers[j].TransmitBytes, + LastHandshakeTime: devices[i].Peers[j].LastHandshakeTime, + LastHandshakeRel: time.Since(devices[i].Peers[j].LastHandshakeTime), + AllocatedIP: allocatedIPs, + } + pVm.Connected = pVm.LastHandshakeRel.Minutes() < 3. + + if isAdmin(c) { + pVm.Endpoint = devices[i].Peers[j].Endpoint.String() + } + + if _client, ok := m[pVm.PublicKey]; ok { + pVm.Name = _client.Name + pVm.Email = _client.Email + } + devVm.Peers = append(devVm.Peers, pVm) + } + sort.SliceStable(devVm.Peers, func(i, j int) bool { return devVm.Peers[i].Name < devVm.Peers[j].Name }) + sort.SliceStable(devVm.Peers, func(i, j int) bool { return conv[devVm.Peers[i].Connected] > conv[devVm.Peers[j].Connected] }) + devicesVm = append(devicesVm, devVm) + } + } + + return c.Render(http.StatusOK, "status.html", map[string]interface{}{ + "baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c), Admin: isAdmin(c)}, + "devices": devicesVm, + "error": "", + }) + } +} + +// GlobalSettingSubmit handler to update the global settings +func GlobalSettingSubmit(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + var globalSettings model.GlobalSetting + c.Bind(&globalSettings) + + // validate the input dns server list + if util.ValidateIPAddressList(globalSettings.DNSServers) == false { + log.Warnf("Invalid DNS server list input from user: %v", globalSettings.DNSServers) + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Invalid DNS server address"}) + } + + globalSettings.UpdatedAt = time.Now().UTC() + + // write config to the database + if err := db.SaveGlobalSettings(globalSettings); err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"}) + } + + log.Infof("Updated global settings: %v", globalSettings) + + return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated global settings successfully"}) + } +} + +// MachineIPAddresses handler to get local interface ip addresses +func MachineIPAddresses() echo.HandlerFunc { + return func(c echo.Context) error { + // get private ip addresses + interfaceList, err := util.GetInterfaceIPs() + if err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get machine ip addresses"}) + } + + // get public ip address + // TODO: Remove the go-external-ip dependency + publicInterface, err := util.GetPublicIP() + if err != nil { + log.Warn("Cannot get machine public ip address: ", err) + } else { + // prepend public ip to the list + interfaceList = append([]model.Interface{publicInterface}, interfaceList...) + } + + return c.JSON(http.StatusOK, interfaceList) + } +} + +// GetOrderedSubnetRanges handler to get the ordered list of subnet ranges +func GetOrderedSubnetRanges() echo.HandlerFunc { + return func(c echo.Context) error { + return c.JSON(http.StatusOK, util.SubnetRangesOrder) + } +} + +// SuggestIPAllocation handler to get the list of ip address for client +func SuggestIPAllocation(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + server, err := db.GetServer() + if err != nil { + log.Error("Cannot fetch server config from database: ", err) + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()}) + } + + // return the list of suggestedIPs + // we take the first available ip address from + // each server's network addresses. + suggestedIPs := make([]string, 0) + allocatedIPs, err := util.GetAllocatedIPs("") + if err != nil { + log.Error("Cannot suggest ip allocation. Failed to get list of allocated ip addresses: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{ + false, "Cannot suggest ip allocation: failed to get list of allocated ip addresses", + }) + } + + sr := c.QueryParam("sr") + searchCIDRList := make([]string, 0) + found := false + + // Use subnet range or default to interface addresses + if util.SubnetRanges[sr] != nil { + for _, cidr := range util.SubnetRanges[sr] { + searchCIDRList = append(searchCIDRList, cidr.String()) + } + } else { + searchCIDRList = append(searchCIDRList, server.Interface.Addresses...) + } + + // Save only unique IPs + ipSet := make(map[string]struct{}) + + for _, cidr := range searchCIDRList { + ip, err := util.GetAvailableIP(cidr, allocatedIPs, server.Interface.Addresses) + if err != nil { + log.Error("Failed to get available ip from a CIDR: ", err) + continue + } + found = true + if strings.Contains(ip, ":") { + ipSet[fmt.Sprintf("%s/128", ip)] = struct{}{} + } else { + ipSet[fmt.Sprintf("%s/32", ip)] = struct{}{} + } + } + + if !found { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{ + false, + "Cannot suggest ip allocation: failed to get available ip. Try a different subnet or deallocate some ips.", + }) + } + + for ip := range ipSet { + suggestedIPs = append(suggestedIPs, ip) + } + + return c.JSON(http.StatusOK, suggestedIPs) + } +} + +// ApplyServerConfig handler to write config file and restart Wireguard server +func ApplyServerConfig(db store.IStore, tmplDir fs.FS) echo.HandlerFunc { + return func(c echo.Context) error { + server, err := db.GetServer() + if err != nil { + log.Error("Cannot get server config: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get server config"}) + } + + clients, err := db.GetClients(false) + if err != nil { + log.Error("Cannot get client config: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get client config"}) + } + + users, err := db.GetUsers() + if err != nil { + log.Error("Cannot get users config: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get users config"}) + } + + settings, err := db.GetGlobalSettings() + if err != nil { + log.Error("Cannot get global settings: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get global settings"}) + } + + // Write config file + err = util.WriteWireGuardServerConfig(tmplDir, server, clients, users, settings) + if err != nil { + log.Error("Cannot apply server config: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{ + false, fmt.Sprintf("Cannot apply server config: %v", err), + }) + } + + err = util.UpdateHashes(db) + if err != nil { + log.Error("Cannot update hashes: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{ + false, fmt.Sprintf("Cannot update hashes: %v", err), + }) + } + + return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Applied server config successfully"}) + } +} + +// GetHashesChanges handler returns if database hashes have changed +func GetHashesChanges(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + if util.HashesChanged(db) { + return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Hashes changed"}) + } else { + return c.JSON(http.StatusOK, jsonHTTPResponse{false, "Hashes not changed"}) + } + } +} + +// AboutPage handler +func AboutPage() echo.HandlerFunc { + return func(c echo.Context) error { + return c.Render(http.StatusOK, "about.html", map[string]interface{}{ + "baseData": model.BaseData{Active: "about", CurrentUser: currentUser(c), Admin: isAdmin(c)}, + }) + } +} diff --git a/handler/routes_wake_on_lan.go b/handler/routes_wake_on_lan.go new file mode 100644 index 0000000..1747a1e --- /dev/null +++ b/handler/routes_wake_on_lan.go @@ -0,0 +1,172 @@ +package handler + +import ( + "fmt" + "net" + "net/http" + "time" + + "github.com/labstack/echo/v4" + "github.com/labstack/gommon/log" + "github.com/ngoduykhanh/wireguard-ui/model" + "github.com/ngoduykhanh/wireguard-ui/store" + "github.com/sabhiram/go-wol/wol" +) + +type WakeOnLanHostSavePayload struct { + Name string `json:"name"` + MacAddress string `json:"mac_address"` + OldMacAddress string `json:"old_mac_address"` +} + +func createError(c echo.Context, err error, msg string) error { + log.Error(msg, err) + return c.JSON( + http.StatusInternalServerError, + jsonHTTPResponse{ + false, + msg}) +} + +func GetWakeOnLanHosts(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + var err error + + hosts, err := db.GetWakeOnLanHosts() + if err != nil { + return createError(c, err, fmt.Sprintf("wake_on_lan_hosts database error: %s", err)) + } + + err = c.Render(http.StatusOK, "wake_on_lan_hosts.html", map[string]interface{}{ + "baseData": model.BaseData{Active: "wake_on_lan_hosts", CurrentUser: currentUser(c), Admin: isAdmin(c)}, + "hosts": hosts, + "error": "", + }) + if err != nil { + return createError(c, err, fmt.Sprintf("wake_on_lan_hosts.html render error: %s", err)) + } + + return nil + } +} + +func SaveWakeOnLanHost(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + var payload WakeOnLanHostSavePayload + err := c.Bind(&payload) + if err != nil { + log.Error("Wake On Host Save Payload Bind Error: ", err) + return c.JSON(http.StatusInternalServerError, payload) + } + + var host = model.WakeOnLanHost{ + MacAddress: payload.MacAddress, + Name: payload.Name, + } + if len(payload.OldMacAddress) != 0 { // Edit + if payload.OldMacAddress != payload.MacAddress { // modified mac address + oldHost, err := db.GetWakeOnLanHost(payload.OldMacAddress) + if err != nil { + return createError(c, err, fmt.Sprintf("Wake On Host Update Err: %s", err)) + } + + if payload.OldMacAddress != payload.MacAddress { + existHost, _ := db.GetWakeOnLanHost(payload.MacAddress) + if existHost != nil { + return createError(c, nil, "Mac Address already exists.") + } + } + + err = db.DeleteWakeOnHostLanHost(payload.OldMacAddress) + if err != nil { + return createError(c, err, fmt.Sprintf("Wake On Host Update Err: %s", err)) + } + host.LatestUsed = oldHost.LatestUsed + } + err = db.SaveWakeOnLanHost(host) + } else { // new + existHost, _ := db.GetWakeOnLanHost(payload.MacAddress) + if existHost != nil { + return createError(c, nil, "Mac Address already exists.") + } + + err = db.SaveWakeOnLanHost(host) + } + + if err != nil { + return createError(c, err, fmt.Sprintf("Wake On Host Save Error: %s", err)) + } + + return c.JSON(http.StatusOK, host) + } +} + +func DeleteWakeOnHost(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + var macAddress = c.Param("mac_address") + var host, err = db.GetWakeOnLanHost(macAddress) + + if err != nil { + log.Error("Wake On Host Delete Error: ", err) + return createError(c, err, fmt.Sprintf("Wake On Host Delete Error: %s", macAddress)) + } + + err = db.DeleteWakeOnHost(*host) + if err != nil { + return createError(c, err, fmt.Sprintf("Wake On Host Delete Error: %s", macAddress)) + } + + return c.JSON(http.StatusOK, nil) + } +} + +func WakeOnHost(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + macAddress := c.Param("mac_address") + host, err := db.GetWakeOnLanHost(macAddress) + + now := time.Now().UTC() + host.LatestUsed = &now + err = db.SaveWakeOnLanHost(*host) + if err != nil { + return createError(c, err, fmt.Sprintf("Latest Used Update Error: %s", macAddress)) + } + + magicPacket, err := wol.New(macAddress) + if err != nil { + return createError(c, err, fmt.Sprintf("Magic Packet Create Error: %s", macAddress)) + } + + bytes, err := magicPacket.Marshal() + if err != nil { + return createError(c, err, fmt.Sprintf("Magic Packet Bytestream Error: %s", macAddress)) + } + + udpAddr, err := net.ResolveUDPAddr("udp", "255.255.255.255:0") + if err != nil { + return createError(c, err, fmt.Sprintf("ResolveUDPAddr Error: %s", macAddress)) + } + + // Grab a UDP connection to send our packet of bytes. + conn, err := net.DialUDP("udp", nil, udpAddr) + if err != nil { + return err + } + defer func(conn *net.UDPConn) { + err := conn.Close() + if err != nil { + log.Error(err) + } + }(conn) + + n, err := conn.Write(bytes) + if err == nil && n != 102 { + return createError(c, nil, fmt.Sprintf("magic packet sent was %d bytes (expected 102 bytes sent)", n)) + } + if err != nil { + return createError(c, err, fmt.Sprintf("Network Send Error: %s", macAddress)) + } + + return c.JSON(http.StatusOK, host.LatestUsed) + } +} diff --git a/handler/session.go b/handler/session.go new file mode 100644 index 0000000..b660d9c --- /dev/null +++ b/handler/session.go @@ -0,0 +1,249 @@ +package handler + +import ( + "fmt" + "net/http" + "time" + + "github.com/gorilla/sessions" + "github.com/labstack/echo-contrib/session" + "github.com/labstack/echo/v4" + "github.com/ngoduykhanh/wireguard-ui/util" +) + +func ValidSession(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if !isValidSession(c) { + nextURL := c.Request().URL + if nextURL != nil && c.Request().Method == http.MethodGet { + return c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(util.BasePath+"/login?next=%s", c.Request().URL)) + } else { + return c.Redirect(http.StatusTemporaryRedirect, util.BasePath+"/login") + } + } + return next(c) + } +} + +// RefreshSession must only be used after ValidSession middleware +// RefreshSession checks if the session is eligible for the refresh, but doesn't check if it's fully valid +func RefreshSession(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + doRefreshSession(c) + return next(c) + } +} + +func NeedsAdmin(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if !isAdmin(c) { + return c.Redirect(http.StatusTemporaryRedirect, util.BasePath+"/") + } + return next(c) + } +} + +func isValidSession(c echo.Context) bool { + if util.DisableLogin { + return true + } + sess, _ := session.Get("session", c) + cookie, err := c.Cookie("session_token") + if err != nil || sess.Values["session_token"] != cookie.Value { + return false + } + + // Check time bounds + createdAt := getCreatedAt(sess) + updatedAt := getUpdatedAt(sess) + maxAge := getMaxAge(sess) + // Temporary session is considered valid within 24h if browser is not closed before + // This value is not saved and is used as virtual expiration + if maxAge == 0 { + maxAge = 86400 + } + expiration := updatedAt + int64(maxAge) + now := time.Now().UTC().Unix() + if updatedAt > now || expiration < now || createdAt+util.SessionMaxDuration < now { + return false + } + + // Check if user still exists and unchanged + username := fmt.Sprintf("%s", sess.Values["username"]) + userHash := getUserHash(sess) + if uHash, ok := util.DBUsersToCRC32[username]; !ok || userHash != uHash { + return false + } + + return true +} + +// Refreshes a "remember me" session when the user visits web pages (not API) +// Session must be valid before calling this function +// Refresh is performed at most once per 24h +func doRefreshSession(c echo.Context) { + if util.DisableLogin { + return + } + + sess, _ := session.Get("session", c) + maxAge := getMaxAge(sess) + if maxAge <= 0 { + return + } + + oldCookie, err := c.Cookie("session_token") + if err != nil || sess.Values["session_token"] != oldCookie.Value { + return + } + + // Refresh no sooner than 24h + createdAt := getCreatedAt(sess) + updatedAt := getUpdatedAt(sess) + expiration := updatedAt + int64(getMaxAge(sess)) + now := time.Now().UTC().Unix() + if updatedAt > now || expiration < now || now-updatedAt < 86_400 || createdAt+util.SessionMaxDuration < now { + return + } + + cookiePath := util.GetCookiePath() + + sess.Values["updated_at"] = now + sess.Options = &sessions.Options{ + Path: cookiePath, + MaxAge: maxAge, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } + sess.Save(c.Request(), c.Response()) + + cookie := new(http.Cookie) + cookie.Name = "session_token" + cookie.Path = cookiePath + cookie.Value = oldCookie.Value + cookie.MaxAge = maxAge + cookie.HttpOnly = true + cookie.SameSite = http.SameSiteLaxMode + c.SetCookie(cookie) +} + +// Get time in seconds this session is valid without updating +func getMaxAge(sess *sessions.Session) int { + if util.DisableLogin { + return 0 + } + + maxAge := sess.Values["max_age"] + + switch typedMaxAge := maxAge.(type) { + case int: + return typedMaxAge + default: + return 0 + } +} + +// Get a timestamp in seconds of the time the session was created +func getCreatedAt(sess *sessions.Session) int64 { + if util.DisableLogin { + return 0 + } + + createdAt := sess.Values["created_at"] + + switch typedCreatedAt := createdAt.(type) { + case int64: + return typedCreatedAt + default: + return 0 + } +} + +// Get a timestamp in seconds of the last session update +func getUpdatedAt(sess *sessions.Session) int64 { + if util.DisableLogin { + return 0 + } + + lastUpdate := sess.Values["updated_at"] + + switch typedLastUpdate := lastUpdate.(type) { + case int64: + return typedLastUpdate + default: + return 0 + } +} + +// Get CRC32 of a user at the moment of log in +// Any changes to user will result in logout of other (not updated) sessions +func getUserHash(sess *sessions.Session) uint32 { + if util.DisableLogin { + return 0 + } + + userHash := sess.Values["user_hash"] + + switch typedUserHash := userHash.(type) { + case uint32: + return typedUserHash + default: + return 0 + } +} + +// currentUser to get username of logged in user +func currentUser(c echo.Context) string { + if util.DisableLogin { + return "" + } + + sess, _ := session.Get("session", c) + username := fmt.Sprintf("%s", sess.Values["username"]) + return username +} + +// isAdmin to get user type: admin or manager +func isAdmin(c echo.Context) bool { + if util.DisableLogin { + return true + } + + sess, _ := session.Get("session", c) + admin := fmt.Sprintf("%t", sess.Values["admin"]) + return admin == "true" +} + +func setUser(c echo.Context, username string, admin bool, userCRC32 uint32) { + sess, _ := session.Get("session", c) + sess.Values["username"] = username + sess.Values["user_hash"] = userCRC32 + sess.Values["admin"] = admin + sess.Save(c.Request(), c.Response()) +} + +// clearSession to remove current session +func clearSession(c echo.Context) { + sess, _ := session.Get("session", c) + sess.Values["username"] = "" + sess.Values["user_hash"] = 0 + sess.Values["admin"] = false + sess.Values["session_token"] = "" + sess.Values["max_age"] = -1 + sess.Options.MaxAge = -1 + sess.Save(c.Request(), c.Response()) + + cookiePath := util.GetCookiePath() + + cookie, err := c.Cookie("session_token") + if err != nil { + cookie = new(http.Cookie) + } + + cookie.Name = "session_token" + cookie.Path = cookiePath + cookie.MaxAge = -1 + cookie.HttpOnly = true + cookie.SameSite = http.SameSiteLaxMode + c.SetCookie(cookie) +} diff --git a/init.sh b/init.sh new file mode 100755 index 0000000..08b98e8 --- /dev/null +++ b/init.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# extract wg config file path, or use default +conf="$(jq -r .config_file_path db/server/global_settings.json || echo /etc/wireguard/wg0.conf)" + +# manage wireguard stop/start with the container +case $WGUI_MANAGE_START in (1|t|T|true|True|TRUE) + wg-quick up "$conf" + trap 'wg-quick down "$conf"' SIGTERM # catches container stop +esac + +# manage wireguard restarts +case $WGUI_MANAGE_RESTART in (1|t|T|true|True|TRUE) + [[ -f $conf ]] || touch "$conf" # inotifyd needs file to exist + inotifyd - "$conf":w | while read -r event file; do + wg-quick down "$file" + wg-quick up "$file" + done & +esac + + +./wg-ui & +wait $! diff --git a/internal/api/auth.go b/internal/api/auth.go deleted file mode 100644 index 358a2bb..0000000 --- a/internal/api/auth.go +++ /dev/null @@ -1,139 +0,0 @@ -package api - -import ( - "crypto/rand" - "crypto/subtle" - "encoding/base64" - "errors" - "net/http" - "sync" - "time" - - "golang.org/x/crypto/bcrypt" -) - -const sessionCookieName = "wgm_session" -const csrfCookieName = "wgm_csrf" -const sessionTTL = 12 * time.Hour - -type session struct { - username string - csrf string - expiresAt time.Time -} - -// SessionStore is a simple in-memory session store (single-process deployment). -type SessionStore struct { - mu sync.Mutex - sessions map[string]*session -} - -func NewSessionStore() *SessionStore { - return &SessionStore{sessions: make(map[string]*session)} -} - -func randomToken() (string, error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(b), nil -} - -func (s *SessionStore) Create(username string) (sessionToken, csrfToken string, err error) { - sessionToken, err = randomToken() - if err != nil { - return "", "", err - } - csrfToken, err = randomToken() - if err != nil { - return "", "", err - } - s.mu.Lock() - s.sessions[sessionToken] = &session{ - username: username, - csrf: csrfToken, - expiresAt: time.Now().Add(sessionTTL), - } - s.mu.Unlock() - return sessionToken, csrfToken, nil -} - -func (s *SessionStore) Get(token string) (*session, bool) { - s.mu.Lock() - defer s.mu.Unlock() - sess, ok := s.sessions[token] - if !ok || time.Now().After(sess.expiresAt) { - delete(s.sessions, token) - return nil, false - } - return sess, true -} - -func (s *SessionStore) Delete(token string) { - s.mu.Lock() - delete(s.sessions, token) - s.mu.Unlock() -} - -// HashPassword bcrypt-hashes a plaintext password for storage. -func HashPassword(pw string) (string, error) { - b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) - return string(b), err -} - -// CheckPassword compares a plaintext password against a stored bcrypt hash. -func CheckPassword(hash, pw string) bool { - return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil -} - -var ErrUnauthenticated = errors.New("unauthenticated") - -// requireAuth resolves the session from the request cookie, or fails. -func (a *API) requireAuth(r *http.Request) (*session, error) { - c, err := r.Cookie(sessionCookieName) - if err != nil { - return nil, ErrUnauthenticated - } - sess, ok := a.sessions.Get(c.Value) - if !ok { - return nil, ErrUnauthenticated - } - return sess, nil -} - -// requireCSRF checks the X-CSRF-Token header against the session's csrf token, -// mandatory for all state-changing (non-GET) requests. -func requireCSRF(sess *session, r *http.Request) bool { - if r.Method == http.MethodGet || r.Method == http.MethodHead { - return true - } - token := r.Header.Get("X-CSRF-Token") - return subtle.ConstantTimeCompare([]byte(token), []byte(sess.csrf)) == 1 -} - -func setSessionCookies(w http.ResponseWriter, sessionToken, csrfToken string) { - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: sessionToken, - Path: "/", - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteStrictMode, - MaxAge: int(sessionTTL.Seconds()), - }) - http.SetCookie(w, &http.Cookie{ - Name: csrfCookieName, - Value: csrfToken, - Path: "/", - HttpOnly: false, // readable by frontend JS to echo back in X-CSRF-Token header - Secure: true, - SameSite: http.SameSiteStrictMode, - MaxAge: int(sessionTTL.Seconds()), - }) -} - -func clearSessionCookies(w http.ResponseWriter) { - http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1}) - http.SetCookie(w, &http.Cookie{Name: csrfCookieName, Value: "", Path: "/", MaxAge: -1}) -} diff --git a/internal/api/handlers.go b/internal/api/handlers.go deleted file mode 100644 index 6721830..0000000 --- a/internal/api/handlers.go +++ /dev/null @@ -1,484 +0,0 @@ -package api - -import ( - "bytes" - "database/sql" - "encoding/json" - "errors" - "net/http" - "strconv" - - qrcode "github.com/skip2/go-qrcode" - - "gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/firewall" - "gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server" - wg "gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/wireguard" -) - -func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) -} - -func writeErr(w http.ResponseWriter, status int, msg string) { - writeJSON(w, status, map[string]string{"error": msg}) -} - -func idParam(r *http.Request, name string) (int64, error) { - return strconv.ParseInt(r.PathValue(name), 10, 64) -} - -// --- Auth --- - -type loginRequest struct { - Username string `json:"username"` - Password string `json:"password"` -} - -func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) { - var req loginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeErr(w, http.StatusBadRequest, "invalid request body") - return - } - - var hash string - err := a.db.QueryRow(`SELECT password_hash FROM users WHERE username = ?`, req.Username).Scan(&hash) - if errors.Is(err, sql.ErrNoRows) || (err == nil && !CheckPassword(hash, req.Password)) { - writeErr(w, http.StatusUnauthorized, "invalid credentials") - return - } - if err != nil { - writeErr(w, http.StatusInternalServerError, "login failed") - return - } - - sessionToken, csrfToken, err := a.sessions.Create(req.Username) - if err != nil { - writeErr(w, http.StatusInternalServerError, "could not create session") - return - } - setSessionCookies(w, sessionToken, csrfToken) - _ = a.db.LogAudit(req.Username, "login", "session", "") - writeJSON(w, http.StatusOK, map[string]string{"csrf_token": csrfToken}) -} - -func (a *API) handleLogout(w http.ResponseWriter, r *http.Request, sess *session) { - if c, err := r.Cookie(sessionCookieName); err == nil { - a.sessions.Delete(c.Value) - } - clearSessionCookies(w) - _ = a.db.LogAudit(sess.username, "logout", "session", "") - w.WriteHeader(http.StatusNoContent) -} - -// --- Servers --- - -func (a *API) handleListServers(w http.ResponseWriter, r *http.Request, _ *session) { - servers, err := a.store.ListServers() - if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - type serverStatus struct { - *server.Server - Status wg.Status `json:"status"` - } - out := make([]serverStatus, 0, len(servers)) - for _, s := range servers { - out = append(out, serverStatus{Server: s, Status: wg.GetStatus(s.InterfaceName)}) - } - writeJSON(w, http.StatusOK, out) -} - -type createServerRequest struct { - Name string `json:"name"` - InterfaceName string `json:"interface_name"` - ListenPort int `json:"listen_port"` - AddressRange string `json:"address_range"` - DNS string `json:"dns"` - MTU int `json:"mtu"` -} - -func (a *API) handleCreateServer(w http.ResponseWriter, r *http.Request, sess *session) { - var req createServerRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeErr(w, http.StatusBadRequest, "invalid request body") - return - } - if req.Name == "" || req.InterfaceName == "" || req.ListenPort == 0 || req.AddressRange == "" { - writeErr(w, http.StatusBadRequest, "name, interface_name, listen_port, address_range required") - return - } - if req.MTU == 0 { - req.MTU = 1420 - } - - priv, pub, err := wg.GenerateKeyPair() - if err != nil { - writeErr(w, http.StatusInternalServerError, "key generation failed") - return - } - - srv := &server.Server{ - Name: req.Name, InterfaceName: req.InterfaceName, ListenPort: req.ListenPort, - PrivateKey: priv, PublicKey: pub, AddressRange: req.AddressRange, - DNS: req.DNS, MTU: req.MTU, Enabled: true, - } - id, err := a.store.CreateServer(srv) - if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - srv.ID = id - - if err := wg.WriteConfig(srv, nil); err != nil { - writeErr(w, http.StatusInternalServerError, "config write failed: "+err.Error()) - return - } - _ = a.db.LogAudit(sess.username, "server.create", req.Name, "") - writeJSON(w, http.StatusCreated, srv) -} - -func (a *API) handleGetServer(w http.ResponseWriter, r *http.Request, _ *session) { - id, err := idParam(r, "id") - if err != nil { - writeErr(w, http.StatusBadRequest, "invalid id") - return - } - srv, err := a.store.GetServer(id) - if errors.Is(err, server.ErrNotFound) { - writeErr(w, http.StatusNotFound, "server not found") - return - } - if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, srv) -} - -func (a *API) handleUpdateServer(w http.ResponseWriter, r *http.Request, sess *session) { - id, err := idParam(r, "id") - if err != nil { - writeErr(w, http.StatusBadRequest, "invalid id") - return - } - srv, err := a.store.GetServer(id) - if errors.Is(err, server.ErrNotFound) { - writeErr(w, http.StatusNotFound, "server not found") - return - } else if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - - var req createServerRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeErr(w, http.StatusBadRequest, "invalid request body") - return - } - srv.Name, srv.AddressRange, srv.DNS = req.Name, req.AddressRange, req.DNS - if req.MTU > 0 { - srv.MTU = req.MTU - } - if req.ListenPort > 0 { - srv.ListenPort = req.ListenPort - } - if err := a.store.UpdateServer(srv); err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - peers, _ := a.store.ListPeersByServer(srv.ID) - if err := wg.WriteConfig(srv, peers); err != nil { - writeErr(w, http.StatusInternalServerError, "config write failed: "+err.Error()) - return - } - _ = a.db.LogAudit(sess.username, "server.update", srv.Name, "") - writeJSON(w, http.StatusOK, srv) -} - -func (a *API) handleDeleteServer(w http.ResponseWriter, r *http.Request, sess *session) { - id, err := idParam(r, "id") - if err != nil { - writeErr(w, http.StatusBadRequest, "invalid id") - return - } - srv, err := a.store.GetServer(id) - if err != nil { - writeErr(w, http.StatusNotFound, "server not found") - return - } - _ = wg.Down(srv.InterfaceName) - if err := a.store.DeleteServer(id); err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - _ = a.db.LogAudit(sess.username, "server.delete", srv.Name, "") - w.WriteHeader(http.StatusNoContent) -} - -func (a *API) handleStartServer(w http.ResponseWriter, r *http.Request, sess *session) { - a.serverAction(w, r, sess, "server.start", func(srv *server.Server) error { - if err := wg.Up(srv.InterfaceName); err != nil { - return err - } - return firewall.RunHook(firewall.HookServerStart, srv.InterfaceName) - }) -} - -func (a *API) handleStopServer(w http.ResponseWriter, r *http.Request, sess *session) { - a.serverAction(w, r, sess, "server.stop", func(srv *server.Server) error { - if err := wg.Down(srv.InterfaceName); err != nil { - return err - } - return firewall.RunHook(firewall.HookServerStop, srv.InterfaceName) - }) -} - -func (a *API) handleReloadServer(w http.ResponseWriter, r *http.Request, sess *session) { - a.serverAction(w, r, sess, "server.reload", func(srv *server.Server) error { - peers, err := a.store.ListPeersByServer(srv.ID) - if err != nil { - return err - } - if err := wg.WriteConfig(srv, peers); err != nil { - return err - } - return wg.Reload(srv.InterfaceName, wg.ConfigPath(srv)) - }) -} - -func (a *API) serverAction(w http.ResponseWriter, r *http.Request, sess *session, action string, fn func(*server.Server) error) { - id, err := idParam(r, "id") - if err != nil { - writeErr(w, http.StatusBadRequest, "invalid id") - return - } - srv, err := a.store.GetServer(id) - if errors.Is(err, server.ErrNotFound) { - writeErr(w, http.StatusNotFound, "server not found") - return - } else if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - if err := fn(srv); err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - _ = a.db.LogAudit(sess.username, action, srv.Name, "") - writeJSON(w, http.StatusOK, map[string]string{"status": string(wg.GetStatus(srv.InterfaceName))}) -} - -func (a *API) handleDownloadServerConfig(w http.ResponseWriter, r *http.Request, _ *session) { - id, err := idParam(r, "id") - if err != nil { - writeErr(w, http.StatusBadRequest, "invalid id") - return - } - srv, err := a.store.GetServer(id) - if err != nil { - writeErr(w, http.StatusNotFound, "server not found") - return - } - peers, err := a.store.ListPeersByServer(id) - if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - w.Header().Set("Content-Type", "text/plain") - w.Header().Set("Content-Disposition", "attachment; filename="+srv.InterfaceName+".conf") - _, _ = w.Write([]byte(wg.RenderConfig(srv, peers))) -} - -// --- Peers --- - -func (a *API) handleListPeers(w http.ResponseWriter, r *http.Request, _ *session) { - id, err := idParam(r, "id") - if err != nil { - writeErr(w, http.StatusBadRequest, "invalid id") - return - } - peers, err := a.store.ListPeersByServer(id) - if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - // never expose private keys in listing responses - type safePeer struct { - *server.Peer - } - out := make([]map[string]any, 0, len(peers)) - for _, p := range peers { - out = append(out, map[string]any{ - "id": p.ID, "server_id": p.ServerID, "name": p.Name, "email": p.Email, - "public_key": p.PublicKey, "allowed_ips": p.AllowedIPs, "endpoint": p.Endpoint, - "persistent_keepalive": p.PersistentKeepalive, "enabled": p.Enabled, - "expires_at": p.ExpiresAt, - }) - } - writeJSON(w, http.StatusOK, out) -} - -type createPeerRequest struct { - Name string `json:"name"` - Email string `json:"email"` - AllowedIPs string `json:"allowed_ips"` - PersistentKeepalive int `json:"persistent_keepalive"` - UsePresharedKey bool `json:"use_preshared_key"` -} - -func (a *API) handleCreatePeer(w http.ResponseWriter, r *http.Request, sess *session) { - serverID, err := idParam(r, "id") - if err != nil { - writeErr(w, http.StatusBadRequest, "invalid id") - return - } - srv, err := a.store.GetServer(serverID) - if errors.Is(err, server.ErrNotFound) { - writeErr(w, http.StatusNotFound, "server not found") - return - } else if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - - var req createPeerRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeErr(w, http.StatusBadRequest, "invalid request body") - return - } - if req.Name == "" || req.AllowedIPs == "" { - writeErr(w, http.StatusBadRequest, "name and allowed_ips required") - return - } - if req.PersistentKeepalive == 0 { - req.PersistentKeepalive = 25 - } - - priv, pub, err := wg.GenerateKeyPair() - if err != nil { - writeErr(w, http.StatusInternalServerError, "key generation failed") - return - } - var psk string - if req.UsePresharedKey { - psk, err = wg.GeneratePresharedKey() - if err != nil { - writeErr(w, http.StatusInternalServerError, "psk generation failed") - return - } - } - - p := &server.Peer{ - ServerID: serverID, Name: req.Name, Email: req.Email, PublicKey: pub, PrivateKey: priv, - PresharedKey: psk, AllowedIPs: req.AllowedIPs, PersistentKeepalive: req.PersistentKeepalive, - Enabled: true, - } - id, err := a.store.CreatePeer(p) - if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - p.ID = id - - peers, _ := a.store.ListPeersByServer(serverID) - if err := wg.WriteConfig(srv, peers); err != nil { - writeErr(w, http.StatusInternalServerError, "config write failed: "+err.Error()) - return - } - _ = firewall.RunHook(firewall.HookPeerAdd, srv.InterfaceName, p.PublicKey) - _ = a.db.LogAudit(sess.username, "peer.create", p.Name, "server="+srv.Name) - writeJSON(w, http.StatusCreated, p) -} - -func (a *API) handleDeletePeer(w http.ResponseWriter, r *http.Request, sess *session) { - serverID, err := idParam(r, "id") - if err != nil { - writeErr(w, http.StatusBadRequest, "invalid id") - return - } - peerID, err := idParam(r, "peerid") - if err != nil { - writeErr(w, http.StatusBadRequest, "invalid peer id") - return - } - srv, err := a.store.GetServer(serverID) - if err != nil { - writeErr(w, http.StatusNotFound, "server not found") - return - } - p, err := a.store.GetPeer(peerID) - if err != nil { - writeErr(w, http.StatusNotFound, "peer not found") - return - } - if err := a.store.DeletePeer(peerID); err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - peers, _ := a.store.ListPeersByServer(serverID) - if err := wg.WriteConfig(srv, peers); err != nil { - writeErr(w, http.StatusInternalServerError, "config write failed: "+err.Error()) - return - } - _ = firewall.RunHook(firewall.HookPeerRemove, srv.InterfaceName, p.PublicKey) - _ = a.db.LogAudit(sess.username, "peer.delete", p.Name, "server="+srv.Name) - w.WriteHeader(http.StatusNoContent) -} - -func (a *API) handleDownloadPeerConfig(w http.ResponseWriter, r *http.Request, _ *session) { - srv, p, err := a.loadServerAndPeer(r) - if err != nil { - writeErr(w, http.StatusNotFound, err.Error()) - return - } - host := r.URL.Query().Get("host") - if host == "" { - host = r.Host - } - w.Header().Set("Content-Type", "text/plain") - w.Header().Set("Content-Disposition", "attachment; filename="+p.Name+".conf") - _, _ = w.Write([]byte(wg.RenderClientConfig(srv, p, host))) -} - -func (a *API) handlePeerQRCode(w http.ResponseWriter, r *http.Request, _ *session) { - srv, p, err := a.loadServerAndPeer(r) - if err != nil { - writeErr(w, http.StatusNotFound, err.Error()) - return - } - host := r.URL.Query().Get("host") - if host == "" { - host = r.Host - } - png, err := qrcode.Encode(wg.RenderClientConfig(srv, p, host), qrcode.Medium, 256) - if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - w.Header().Set("Content-Type", "image/png") - _, _ = w.Write(bytes.NewBuffer(png).Bytes()) -} - -func (a *API) loadServerAndPeer(r *http.Request) (*server.Server, *server.Peer, error) { - serverID, err := idParam(r, "id") - if err != nil { - return nil, nil, errors.New("invalid id") - } - peerID, err := idParam(r, "peerid") - if err != nil { - return nil, nil, errors.New("invalid peer id") - } - srv, err := a.store.GetServer(serverID) - if err != nil { - return nil, nil, errors.New("server not found") - } - p, err := a.store.GetPeer(peerID) - if err != nil { - return nil, nil, errors.New("peer not found") - } - return srv, p, nil -} diff --git a/internal/api/router.go b/internal/api/router.go deleted file mode 100644 index 5639a4c..0000000 --- a/internal/api/router.go +++ /dev/null @@ -1,97 +0,0 @@ -package api - -import ( - "log/slog" - "net/http" - "path/filepath" - - "gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/database" - "gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server" -) - -// API holds shared dependencies for HTTP handlers. -type API struct { - db *database.DB - store *server.Store - sessions *SessionStore - log *slog.Logger - lanIface string - uiRoot string -} - -func New(db *database.DB, log *slog.Logger, lanIface, uiRoot string) *API { - return &API{ - db: db, - store: server.NewStore(db), - sessions: NewSessionStore(), - log: log, - lanIface: lanIface, - uiRoot: uiRoot, - } -} - -func (a *API) templatesDir() string { - return filepath.Join(a.uiRoot, "templates") -} - -func (a *API) staticDir() string { - return filepath.Join(a.uiRoot, "static") -} - -// Routes builds the full HTTP handler tree (API + UI), using Go 1.22 mux patterns. -func (a *API) Routes() http.Handler { - mux := http.NewServeMux() - - // Auth - mux.HandleFunc("POST /api/login", a.handleLogin) - mux.HandleFunc("POST /api/logout", a.withAuth(a.handleLogout)) - - // Servers - mux.HandleFunc("GET /api/servers", a.withAuth(a.handleListServers)) - mux.HandleFunc("POST /api/servers", a.withAuth(a.handleCreateServer)) - mux.HandleFunc("GET /api/servers/{id}", a.withAuth(a.handleGetServer)) - mux.HandleFunc("PUT /api/servers/{id}", a.withAuth(a.handleUpdateServer)) - mux.HandleFunc("DELETE /api/servers/{id}", a.withAuth(a.handleDeleteServer)) - mux.HandleFunc("POST /api/servers/{id}/start", a.withAuth(a.handleStartServer)) - mux.HandleFunc("POST /api/servers/{id}/stop", a.withAuth(a.handleStopServer)) - mux.HandleFunc("POST /api/servers/{id}/reload", a.withAuth(a.handleReloadServer)) - mux.HandleFunc("GET /api/servers/{id}/config", a.withAuth(a.handleDownloadServerConfig)) - - // Peers - mux.HandleFunc("GET /api/server/{id}/peers", a.withAuth(a.handleListPeers)) - mux.HandleFunc("POST /api/server/{id}/peer", a.withAuth(a.handleCreatePeer)) - mux.HandleFunc("DELETE /api/server/{id}/peer/{peerid}", a.withAuth(a.handleDeletePeer)) - mux.HandleFunc("GET /api/server/{id}/peer/{peerid}/config", a.withAuth(a.handleDownloadPeerConfig)) - mux.HandleFunc("GET /api/server/{id}/peer/{peerid}/qrcode", a.withAuth(a.handlePeerQRCode)) - - // UI - mux.HandleFunc("GET /", a.handleDashboard) - mux.HandleFunc("GET /login", a.handleLoginPage) - mux.HandleFunc("GET /servers/{id}", a.handleServerPage) - mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(a.staticDir())))) - - return a.logMiddleware(mux) -} - -func (a *API) logMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - a.log.Info("request", "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr) - next.ServeHTTP(w, r) - }) -} - -// withAuth enforces a valid session and, for mutating requests, a matching CSRF token. -func (a *API) withAuth(next func(http.ResponseWriter, *http.Request, *session)) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - sess, err := a.requireAuth(r) - if err != nil { - http.Error(w, "unauthenticated", http.StatusUnauthorized) - return - } - if !requireCSRF(sess, r) { - http.Error(w, "invalid csrf token", http.StatusForbidden) - return - } - next(w, r, sess) - } -} diff --git a/internal/api/ui_handlers.go b/internal/api/ui_handlers.go deleted file mode 100644 index 93ed18c..0000000 --- a/internal/api/ui_handlers.go +++ /dev/null @@ -1,39 +0,0 @@ -package api - -import ( - "net/http" -) - -// hasSession reports whether the request carries a valid, non-expired session cookie. -func (a *API) hasSession(r *http.Request) bool { - c, err := r.Cookie(sessionCookieName) - if err != nil { - return false - } - _, ok := a.sessions.Get(c.Value) - return ok -} - -func (a *API) handleDashboard(w http.ResponseWriter, r *http.Request) { - if !a.hasSession(r) { - http.Redirect(w, r, "/login", http.StatusFound) - return - } - http.ServeFile(w, r, a.templatesDir()+"/dashboard.html") -} - -func (a *API) handleLoginPage(w http.ResponseWriter, r *http.Request) { - if a.hasSession(r) { - http.Redirect(w, r, "/", http.StatusFound) - return - } - http.ServeFile(w, r, a.templatesDir()+"/login.html") -} - -func (a *API) handleServerPage(w http.ResponseWriter, r *http.Request) { - if !a.hasSession(r) { - http.Redirect(w, r, "/login", http.StatusFound) - return - } - http.ServeFile(w, r, a.templatesDir()+"/server.html") -} diff --git a/internal/database/database.go b/internal/database/database.go deleted file mode 100644 index 966e12f..0000000 --- a/internal/database/database.go +++ /dev/null @@ -1,85 +0,0 @@ -package database - -import ( - "database/sql" - "fmt" - - _ "modernc.org/sqlite" -) - -// DB wraps the sqlite connection used by the whole application. -type DB struct { - *sql.DB -} - -const schema = ` -CREATE TABLE IF NOT EXISTS servers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - interface_name TEXT NOT NULL UNIQUE, - listen_port INTEGER NOT NULL, - private_key TEXT NOT NULL, - public_key TEXT NOT NULL, - address_range TEXT NOT NULL, - dns TEXT DEFAULT '', - mtu INTEGER DEFAULT 1420, - enabled INTEGER NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS peers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, - name TEXT NOT NULL, - email TEXT DEFAULT '', - public_key TEXT NOT NULL, - private_key TEXT DEFAULT '', - preshared_key TEXT DEFAULT '', - allowed_ips TEXT NOT NULL, - endpoint TEXT DEFAULT '', - persistent_keepalive INTEGER DEFAULT 25, - enabled INTEGER NOT NULL DEFAULT 1, - expires_at DATETIME, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - actor TEXT NOT NULL, - action TEXT NOT NULL, - target TEXT NOT NULL, - detail TEXT DEFAULT '', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_peers_server_id ON peers(server_id); -` - -// Open opens (creating if needed) the sqlite database at path and applies schema. -func Open(path string) (*DB, error) { - sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)") - if err != nil { - return nil, fmt.Errorf("open sqlite: %w", err) - } - if _, err := sqlDB.Exec(schema); err != nil { - sqlDB.Close() - return nil, fmt.Errorf("apply schema: %w", err) - } - return &DB{sqlDB}, nil -} - -// LogAudit records an entry in the audit log. -func (db *DB) LogAudit(actor, action, target, detail string) error { - _, err := db.Exec(`INSERT INTO audit_log (actor, action, target, detail) VALUES (?, ?, ?, ?)`, - actor, action, target, detail) - return err -} diff --git a/internal/firewall/nftables.go b/internal/firewall/nftables.go deleted file mode 100644 index aa20573..0000000 --- a/internal/firewall/nftables.go +++ /dev/null @@ -1,75 +0,0 @@ -package firewall - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - - "gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server" -) - -// HooksDir holds optional user-defined shell scripts run around lifecycle events. -var HooksDir = "/etc/wireguard-manager/hooks" - -// HookEvent names the lifecycle points a hook script may exist for. -type HookEvent string - -const ( - HookServerStart HookEvent = "server-start" - HookServerStop HookEvent = "server-stop" - HookPeerAdd HookEvent = "peer-add" - HookPeerRemove HookEvent = "peer-remove" -) - -// RunHook executes /etc/wireguard-manager/hooks/ if present and executable, -// passing iface (and optionally peer pubkey) as arguments. Missing hook is not an error. -func RunHook(event HookEvent, args ...string) error { - path := filepath.Join(HooksDir, string(event)) - if _, err := os.Stat(path); err != nil { - return nil // hook not installed, skip silently - } - cmd := exec.Command(path, args...) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("hook %s: %w: %s", event, err, out) - } - return nil -} - -// NFTRuleset renders a suggested nftables ruleset snippet for a server, allowing -// its UDP listen port in and forwarding traffic between the tunnel and lanIface. -func NFTRuleset(srv *server.Server, lanIface string) string { - return fmt.Sprintf(`table inet wireguard_%s { - chain input { - type filter hook input priority 0; policy accept; - udp dport %d accept - } - chain forward { - type filter hook forward priority 0; policy accept; - iifname "%s" oifname "%s" accept - iifname "%s" oifname "%s" accept - } -} -`, srv.InterfaceName, srv.ListenPort, srv.InterfaceName, lanIface, lanIface, srv.InterfaceName) -} - -// ApplyRuleset writes the ruleset to a temp file and loads it with `nft -f`. -func ApplyRuleset(srv *server.Server, lanIface string) error { - tmp, err := os.CreateTemp("", "wgm-nft-*.conf") - if err != nil { - return err - } - defer os.Remove(tmp.Name()) - - if _, err := tmp.WriteString(NFTRuleset(srv, lanIface)); err != nil { - tmp.Close() - return err - } - tmp.Close() - - cmd := exec.Command("nft", "-f", tmp.Name()) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("nft -f: %w: %s", err, out) - } - return nil -} diff --git a/internal/server/model.go b/internal/server/model.go deleted file mode 100644 index 483887c..0000000 --- a/internal/server/model.go +++ /dev/null @@ -1,205 +0,0 @@ -package server - -import ( - "database/sql" - "errors" - "time" - - "gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/database" -) - -// Server represents a single, independent WireGuard interface. -type Server struct { - ID int64 - Name string - InterfaceName string - ListenPort int - PrivateKey string - PublicKey string - AddressRange string - DNS string - MTU int - Enabled bool - CreatedAt time.Time - UpdatedAt time.Time -} - -// Peer represents a WireGuard client belonging to a Server. -type Peer struct { - ID int64 - ServerID int64 - Name string - Email string - PublicKey string - PrivateKey string - PresharedKey string - AllowedIPs string - Endpoint string - PersistentKeepalive int - Enabled bool - ExpiresAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time -} - -var ErrNotFound = errors.New("not found") - -// Store provides CRUD access to servers and peers. -type Store struct { - db *database.DB -} - -func NewStore(db *database.DB) *Store { - return &Store{db: db} -} - -func (s *Store) CreateServer(srv *Server) (int64, error) { - res, err := s.db.Exec(`INSERT INTO servers - (name, interface_name, listen_port, private_key, public_key, address_range, dns, mtu, enabled) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - srv.Name, srv.InterfaceName, srv.ListenPort, srv.PrivateKey, srv.PublicKey, - srv.AddressRange, srv.DNS, srv.MTU, boolToInt(srv.Enabled)) - if err != nil { - return 0, err - } - return res.LastInsertId() -} - -func (s *Store) UpdateServer(srv *Server) error { - _, err := s.db.Exec(`UPDATE servers SET - name = ?, interface_name = ?, listen_port = ?, private_key = ?, public_key = ?, - address_range = ?, dns = ?, mtu = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ?`, - srv.Name, srv.InterfaceName, srv.ListenPort, srv.PrivateKey, srv.PublicKey, - srv.AddressRange, srv.DNS, srv.MTU, boolToInt(srv.Enabled), srv.ID) - return err -} - -func (s *Store) DeleteServer(id int64) error { - _, err := s.db.Exec(`DELETE FROM servers WHERE id = ?`, id) - return err -} - -func (s *Store) GetServer(id int64) (*Server, error) { - row := s.db.QueryRow(`SELECT id, name, interface_name, listen_port, private_key, public_key, - address_range, dns, mtu, enabled, created_at, updated_at FROM servers WHERE id = ?`, id) - return scanServer(row) -} - -func (s *Store) ListServers() ([]*Server, error) { - rows, err := s.db.Query(`SELECT id, name, interface_name, listen_port, private_key, public_key, - address_range, dns, mtu, enabled, created_at, updated_at FROM servers ORDER BY name`) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []*Server - for rows.Next() { - srv, err := scanServerRows(rows) - if err != nil { - return nil, err - } - out = append(out, srv) - } - return out, rows.Err() -} - -func (s *Store) CreatePeer(p *Peer) (int64, error) { - res, err := s.db.Exec(`INSERT INTO peers - (server_id, name, email, public_key, private_key, preshared_key, allowed_ips, endpoint, - persistent_keepalive, enabled, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - p.ServerID, p.Name, p.Email, p.PublicKey, p.PrivateKey, p.PresharedKey, p.AllowedIPs, - p.Endpoint, p.PersistentKeepalive, boolToInt(p.Enabled), p.ExpiresAt) - if err != nil { - return 0, err - } - return res.LastInsertId() -} - -func (s *Store) UpdatePeer(p *Peer) error { - _, err := s.db.Exec(`UPDATE peers SET - name = ?, email = ?, public_key = ?, preshared_key = ?, allowed_ips = ?, endpoint = ?, - persistent_keepalive = ?, enabled = ?, expires_at = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ?`, - p.Name, p.Email, p.PublicKey, p.PresharedKey, p.AllowedIPs, p.Endpoint, - p.PersistentKeepalive, boolToInt(p.Enabled), p.ExpiresAt, p.ID) - return err -} - -func (s *Store) DeletePeer(id int64) error { - _, err := s.db.Exec(`DELETE FROM peers WHERE id = ?`, id) - return err -} - -func (s *Store) GetPeer(id int64) (*Peer, error) { - row := s.db.QueryRow(`SELECT id, server_id, name, email, public_key, private_key, preshared_key, - allowed_ips, endpoint, persistent_keepalive, enabled, expires_at, created_at, updated_at - FROM peers WHERE id = ?`, id) - return scanPeer(row) -} - -func (s *Store) ListPeersByServer(serverID int64) ([]*Peer, error) { - rows, err := s.db.Query(`SELECT id, server_id, name, email, public_key, private_key, preshared_key, - allowed_ips, endpoint, persistent_keepalive, enabled, expires_at, created_at, updated_at - FROM peers WHERE server_id = ? ORDER BY name`, serverID) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []*Peer - for rows.Next() { - p, err := scanPeerRows(rows) - if err != nil { - return nil, err - } - out = append(out, p) - } - return out, rows.Err() -} - -type scanner interface { - Scan(dest ...any) error -} - -func scanServer(row scanner) (*Server, error) { - var srv Server - var enabled int - if err := row.Scan(&srv.ID, &srv.Name, &srv.InterfaceName, &srv.ListenPort, &srv.PrivateKey, - &srv.PublicKey, &srv.AddressRange, &srv.DNS, &srv.MTU, &enabled, &srv.CreatedAt, &srv.UpdatedAt); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrNotFound - } - return nil, err - } - srv.Enabled = enabled != 0 - return &srv, nil -} - -func scanServerRows(rows *sql.Rows) (*Server, error) { return scanServer(rows) } - -func scanPeer(row scanner) (*Peer, error) { - var p Peer - var enabled int - if err := row.Scan(&p.ID, &p.ServerID, &p.Name, &p.Email, &p.PublicKey, &p.PrivateKey, - &p.PresharedKey, &p.AllowedIPs, &p.Endpoint, &p.PersistentKeepalive, &enabled, - &p.ExpiresAt, &p.CreatedAt, &p.UpdatedAt); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrNotFound - } - return nil, err - } - p.Enabled = enabled != 0 - return &p, nil -} - -func scanPeerRows(rows *sql.Rows) (*Peer, error) { return scanPeer(rows) } - -func boolToInt(b bool) int { - if b { - return 1 - } - return 0 -} diff --git a/internal/ui/static/app.js b/internal/ui/static/app.js deleted file mode 100644 index 62bef60..0000000 --- a/internal/ui/static/app.js +++ /dev/null @@ -1,106 +0,0 @@ -function getCookie(name) { - const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)")); - return match ? decodeURIComponent(match[1]) : ""; -} - -async function apiFetch(url, options) { - options = options || {}; - options.headers = options.headers || {}; - if (options.method && options.method !== "GET") { - options.headers["X-CSRF-Token"] = getCookie("wgm_csrf"); - } - const res = await fetch(url, options); - if (res.status === 401) { - window.location.href = "/login"; - throw new Error("unauthenticated"); - } - return res; -} - -async function loadServers() { - const tbody = document.querySelector("#servers tbody"); - tbody.innerHTML = ""; - const res = await apiFetch("/api/servers"); - if (!res.ok) return; - const servers = await res.json(); - - for (const s of servers) { - const tr = document.createElement("tr"); - - const nameTd = document.createElement("td"); - const link = document.createElement("a"); - link.href = "/servers/" + s.ID; - link.textContent = s.Name; - nameTd.appendChild(link); - - const ifaceTd = document.createElement("td"); - ifaceTd.textContent = s.InterfaceName; - - const portTd = document.createElement("td"); - portTd.textContent = s.ListenPort; - - const statusTd = document.createElement("td"); - const badge = document.createElement("span"); - badge.className = "badge " + (s.status === "UP" ? "up" : "down"); - badge.textContent = s.status; - statusTd.appendChild(badge); - - const actionsTd = document.createElement("td"); - actionsTd.appendChild(makeActionButton("Start", () => serverAction(s.ID, "start"))); - actionsTd.appendChild(makeActionButton("Stop", () => serverAction(s.ID, "stop"))); - actionsTd.appendChild(makeActionButton("Reload", () => serverAction(s.ID, "reload"))); - - tr.appendChild(nameTd); - tr.appendChild(ifaceTd); - tr.appendChild(portTd); - tr.appendChild(statusTd); - tr.appendChild(actionsTd); - tbody.appendChild(tr); - } -} - -function makeActionButton(label, onClick) { - const btn = document.createElement("button"); - btn.textContent = label; - btn.className = "secondary"; - btn.addEventListener("click", onClick); - return btn; -} - -async function serverAction(id, action) { - await apiFetch("/api/servers/" + id + "/" + action, { method: "POST" }); - loadServers(); -} - -document.getElementById("new-server").addEventListener("click", async () => { - const name = prompt("Name des Servers (z.B. WGhome):"); - if (!name) return; - const interfaceName = prompt("Interface (z.B. wg-home):"); - if (!interfaceName) return; - const listenPort = parseInt(prompt("Listen Port (z.B. 51822):"), 10); - if (!listenPort) return; - const addressRange = prompt("Address Range (z.B. 10.20.22.0/24):"); - if (!addressRange) return; - const dns = prompt("DNS (optional):") || ""; - - const res = await apiFetch("/api/servers", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: name, - interface_name: interfaceName, - listen_port: listenPort, - address_range: addressRange, - dns: dns, - mtu: 1420, - }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - alert(data.error || "Server konnte nicht erstellt werden."); - return; - } - loadServers(); -}); - -loadServers(); diff --git a/internal/ui/static/login.js b/internal/ui/static/login.js deleted file mode 100644 index a03bf56..0000000 --- a/internal/ui/static/login.js +++ /dev/null @@ -1,25 +0,0 @@ -document.getElementById("login-form").addEventListener("submit", async function (e) { - e.preventDefault(); - const errEl = document.getElementById("login-error"); - errEl.textContent = ""; - - const form = e.target; - const username = form.username.value; - const password = form.password.value; - - try { - const res = await fetch("/api/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username, password }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - errEl.textContent = data.error || "Anmeldung fehlgeschlagen."; - return; - } - window.location.href = "/"; - } catch (err) { - errEl.textContent = "Verbindung fehlgeschlagen."; - } -}); diff --git a/internal/ui/static/server.js b/internal/ui/static/server.js deleted file mode 100644 index 8630982..0000000 --- a/internal/ui/static/server.js +++ /dev/null @@ -1,173 +0,0 @@ -function getCookie(name) { - const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)")); - return match ? decodeURIComponent(match[1]) : ""; -} - -async function apiFetch(url, options) { - options = options || {}; - options.headers = options.headers || {}; - if (options.method && options.method !== "GET") { - options.headers["X-CSRF-Token"] = getCookie("wgm_csrf"); - } - const res = await fetch(url, options); - if (res.status === 401) { - window.location.href = "/login"; - throw new Error("unauthenticated"); - } - return res; -} - -function serverIDFromPath() { - const parts = window.location.pathname.split("/").filter(Boolean); - return parts[1]; -} - -const serverID = serverIDFromPath(); -const errEl = document.getElementById("server-error"); - -async function loadServer() { - errEl.textContent = ""; - const res = await apiFetch("/api/servers/" + serverID); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - errEl.textContent = data.error || "Server konnte nicht geladen werden."; - return; - } - const s = await res.json(); - document.getElementById("server-name").textContent = s.Name; - document.getElementById("d-interface").textContent = s.InterfaceName; - document.getElementById("d-port").textContent = s.ListenPort; - document.getElementById("d-address").textContent = s.AddressRange; - document.getElementById("d-dns").textContent = s.DNS || "-"; - document.getElementById("d-mtu").textContent = s.MTU; - - const statusRes = await apiFetch("/api/servers"); - if (statusRes.ok) { - const servers = await statusRes.json(); - const match = servers.find((x) => String(x.ID) === String(serverID)); - const statusTd = document.getElementById("d-status"); - statusTd.innerHTML = ""; - const badge = document.createElement("span"); - const status = match ? match.status : "DOWN"; - badge.className = "badge " + (status === "UP" ? "up" : "down"); - badge.textContent = status; - statusTd.appendChild(badge); - } -} - -async function loadPeers() { - const tbody = document.querySelector("#peers tbody"); - tbody.innerHTML = ""; - const res = await apiFetch("/api/server/" + serverID + "/peers"); - if (!res.ok) return; - const peers = await res.json(); - - for (const p of peers) { - const tr = document.createElement("tr"); - - const nameTd = document.createElement("td"); - nameTd.textContent = p.name; - - const emailTd = document.createElement("td"); - emailTd.textContent = p.email || "-"; - - const allowedTd = document.createElement("td"); - allowedTd.textContent = p.allowed_ips; - - const enabledTd = document.createElement("td"); - enabledTd.textContent = p.enabled ? "Ja" : "Nein"; - - const actionsTd = document.createElement("td"); - - const qrBtn = document.createElement("button"); - qrBtn.textContent = "QR-Code"; - qrBtn.className = "secondary"; - qrBtn.addEventListener("click", () => showQRCode(p.id)); - actionsTd.appendChild(qrBtn); - - const dlLink = document.createElement("a"); - dlLink.href = "/api/server/" + serverID + "/peer/" + p.id + "/config?host=" + encodeURIComponent(window.location.hostname); - dlLink.textContent = "Config"; - dlLink.style.marginLeft = "0.5rem"; - actionsTd.appendChild(dlLink); - - const delBtn = document.createElement("button"); - delBtn.textContent = "Löschen"; - delBtn.className = "danger"; - delBtn.addEventListener("click", () => deletePeer(p.id)); - actionsTd.appendChild(delBtn); - - tr.appendChild(nameTd); - tr.appendChild(emailTd); - tr.appendChild(allowedTd); - tr.appendChild(enabledTd); - tr.appendChild(actionsTd); - tbody.appendChild(tr); - } -} - -function showQRCode(peerID) { - const modal = document.getElementById("qrcode-modal"); - const img = document.getElementById("qrcode-img"); - img.src = "/api/server/" + serverID + "/peer/" + peerID + "/qrcode?host=" + encodeURIComponent(window.location.hostname) + "&t=" + Date.now(); - modal.classList.remove("hidden"); -} - -document.getElementById("qrcode-close").addEventListener("click", () => { - document.getElementById("qrcode-modal").classList.add("hidden"); -}); - -async function deletePeer(peerID) { - if (!confirm("Peer wirklich löschen?")) return; - await apiFetch("/api/server/" + serverID + "/peer/" + peerID, { method: "DELETE" }); - loadPeers(); -} - -document.getElementById("btn-start").addEventListener("click", async () => { - await apiFetch("/api/servers/" + serverID + "/start", { method: "POST" }); - loadServer(); -}); - -document.getElementById("btn-stop").addEventListener("click", async () => { - await apiFetch("/api/servers/" + serverID + "/stop", { method: "POST" }); - loadServer(); -}); - -document.getElementById("btn-reload").addEventListener("click", async () => { - await apiFetch("/api/servers/" + serverID + "/reload", { method: "POST" }); - loadServer(); -}); - -document.getElementById("btn-download").addEventListener("click", () => { - window.location.href = "/api/servers/" + serverID + "/config"; -}); - -document.getElementById("peer-form").addEventListener("submit", async (e) => { - e.preventDefault(); - const errP = document.getElementById("peer-error"); - errP.textContent = ""; - const form = e.target; - const body = { - name: form.name.value, - email: form.email.value, - allowed_ips: form.allowed_ips.value, - persistent_keepalive: parseInt(form.persistent_keepalive.value, 10) || 25, - use_preshared_key: form.use_preshared_key.checked, - }; - const res = await apiFetch("/api/server/" + serverID + "/peer", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - errP.textContent = data.error || "Peer konnte nicht erstellt werden."; - return; - } - form.reset(); - form.persistent_keepalive.value = 25; - loadPeers(); -}); - -loadServer(); -loadPeers(); diff --git a/internal/ui/static/style.css b/internal/ui/static/style.css deleted file mode 100644 index af5da0b..0000000 --- a/internal/ui/static/style.css +++ /dev/null @@ -1,209 +0,0 @@ -* { - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; - max-width: 960px; - margin: 2rem auto; - padding: 0 1rem; - color: #1c1c1c; - background: #fafafa; -} - -h1, h2 { - color: #222; -} - -a { - color: #2563eb; - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -table { - width: 100%; - border-collapse: collapse; - margin: 1rem 0; - background: #fff; -} - -table.details { - width: auto; - min-width: 320px; -} - -th, td { - text-align: left; - padding: 0.5rem 0.75rem; - border-bottom: 1px solid #e2e2e2; -} - -thead th { - background: #f0f0f0; - font-weight: 600; -} - -tbody tr:hover { - background: #f7f7f7; -} - -button { - cursor: pointer; - background: #2563eb; - color: #fff; - border: none; - border-radius: 4px; - padding: 0.4rem 0.8rem; - margin: 0.15rem; - font-size: 0.9rem; -} - -button:hover { - background: #1d4ed8; -} - -button.danger { - background: #dc2626; -} - -button.danger:hover { - background: #b91c1c; -} - -button.secondary { - background: #6b7280; -} - -button.secondary:hover { - background: #4b5563; -} - -.actions { - margin: 1rem 0; -} - -form { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - align-items: center; - background: #fff; - padding: 1rem; - border: 1px solid #e2e2e2; - border-radius: 6px; - max-width: 480px; -} - -form#login-form { - flex-direction: column; - align-items: stretch; - max-width: 320px; - margin: 3rem auto; -} - -input[type="text"], -input[type="email"], -input[type="password"], -input[type="number"] { - padding: 0.4rem 0.6rem; - border: 1px solid #ccc; - border-radius: 4px; - font-size: 0.9rem; -} - -label { - font-size: 0.9rem; -} - -.error { - color: #dc2626; - font-size: 0.9rem; - min-height: 1.2em; -} - -.badge { - display: inline-block; - padding: 0.15rem 0.6rem; - border-radius: 999px; - font-size: 0.8rem; - font-weight: 600; - color: #fff; -} - -.badge.up { - background: #16a34a; -} - -.badge.down { - background: #dc2626; -} - -.modal { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; -} - -.modal.hidden { - display: none; -} - -.modal-content { - background: #fff; - padding: 1rem; - border-radius: 6px; - text-align: center; -} - -.modal-content img { - display: block; - margin-top: 0.5rem; - max-width: 320px; -} - -@media (prefers-color-scheme: dark) { - body { - background: #17181a; - color: #e6e6e6; - } - - h1, h2 { - color: #f2f2f2; - } - - table, form { - background: #212226; - } - - thead th { - background: #2a2b30; - } - - th, td { - border-bottom: 1px solid #33343a; - } - - tbody tr:hover { - background: #26272c; - } - - input { - background: #1b1c1f; - color: #e6e6e6; - border: 1px solid #3a3b41; - } - - .modal-content { - background: #212226; - } -} diff --git a/internal/ui/templates/dashboard.html b/internal/ui/templates/dashboard.html deleted file mode 100644 index 35a53ce..0000000 --- a/internal/ui/templates/dashboard.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - -wireguard-ui-multi — Dashboard - - - -

WireGuard Server

- - - - - -
NameInterfacePortStatusAktionen
- - - - diff --git a/internal/ui/templates/login.html b/internal/ui/templates/login.html deleted file mode 100644 index 8ce23d3..0000000 --- a/internal/ui/templates/login.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -wireguard-ui-multi — Login - - - -
-

Anmelden

- - - -

-
- - - diff --git a/internal/ui/templates/server.html b/internal/ui/templates/server.html deleted file mode 100644 index ebe63f3..0000000 --- a/internal/ui/templates/server.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - -wireguard-ui-multi — Server - - - -

← Zurück zum Dashboard

- -

Server

-

- - - - - - - - - - -
Interface
Port
Address Range
DNS
MTU
Status
- -
- - - - -
- -

Peers

- - - - - -
NameEmailAllowed IPsAktivAktionen
- -

Neuen Peer hinzufügen

-
- - - - - - -

-
- - - - - - diff --git a/internal/wireguard/config.go b/internal/wireguard/config.go deleted file mode 100644 index bc4d03e..0000000 --- a/internal/wireguard/config.go +++ /dev/null @@ -1,86 +0,0 @@ -package wireguard - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server" -) - -// ConfigDir is where per-interface wgX.conf files are written, e.g. /etc/wireguard. -var ConfigDir = "/etc/wireguard" - -// RenderConfig builds the wg-quick compatible config text for a server and its peers. -func RenderConfig(srv *server.Server, peers []*server.Peer) string { - var b strings.Builder - - fmt.Fprintf(&b, "[Interface]\n") - fmt.Fprintf(&b, "PrivateKey = %s\n", srv.PrivateKey) - fmt.Fprintf(&b, "Address = %s\n", srv.AddressRange) - fmt.Fprintf(&b, "ListenPort = %d\n", srv.ListenPort) - if srv.MTU > 0 { - fmt.Fprintf(&b, "MTU = %d\n", srv.MTU) - } - if srv.DNS != "" { - fmt.Fprintf(&b, "DNS = %s\n", srv.DNS) - } - - for _, p := range peers { - if !p.Enabled { - continue - } - b.WriteString("\n[Peer]\n") - fmt.Fprintf(&b, "# %s\n", p.Name) - fmt.Fprintf(&b, "PublicKey = %s\n", p.PublicKey) - if p.PresharedKey != "" { - fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey) - } - fmt.Fprintf(&b, "AllowedIPs = %s\n", p.AllowedIPs) - if p.PersistentKeepalive > 0 { - fmt.Fprintf(&b, "PersistentKeepalive = %d\n", p.PersistentKeepalive) - } - } - - return b.String() -} - -// RenderClientConfig builds the config a peer/client would use to connect to srv. -func RenderClientConfig(srv *server.Server, p *server.Peer, endpointHost string) string { - var b strings.Builder - - b.WriteString("[Interface]\n") - fmt.Fprintf(&b, "PrivateKey = %s\n", p.PrivateKey) - fmt.Fprintf(&b, "Address = %s\n", p.AllowedIPs) - if srv.DNS != "" { - fmt.Fprintf(&b, "DNS = %s\n", srv.DNS) - } - - b.WriteString("\n[Peer]\n") - fmt.Fprintf(&b, "PublicKey = %s\n", srv.PublicKey) - if p.PresharedKey != "" { - fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey) - } - fmt.Fprintf(&b, "Endpoint = %s:%d\n", endpointHost, srv.ListenPort) - fmt.Fprintf(&b, "AllowedIPs = 0.0.0.0/0, ::/0\n") - if p.PersistentKeepalive > 0 { - fmt.Fprintf(&b, "PersistentKeepalive = %d\n", p.PersistentKeepalive) - } - - return b.String() -} - -// WriteConfig writes the rendered server config to ConfigDir/.conf with 0600 perms. -func WriteConfig(srv *server.Server, peers []*server.Peer) error { - if err := os.MkdirAll(ConfigDir, 0700); err != nil { - return err - } - path := filepath.Join(ConfigDir, srv.InterfaceName+".conf") - return os.WriteFile(path, []byte(RenderConfig(srv, peers)), 0600) -} - -// ConfigPath returns the on-disk path for a server's config file. -func ConfigPath(srv *server.Server) string { - return filepath.Join(ConfigDir, srv.InterfaceName+".conf") -} diff --git a/internal/wireguard/keys.go b/internal/wireguard/keys.go deleted file mode 100644 index 605683a..0000000 --- a/internal/wireguard/keys.go +++ /dev/null @@ -1,47 +0,0 @@ -package wireguard - -import ( - "crypto/rand" - "encoding/base64" - - "golang.org/x/crypto/curve25519" -) - -// GenerateKeyPair creates a new WireGuard-compatible Curve25519 key pair, -// base64-encoded like `wg genkey` / `wg pubkey`. -func GenerateKeyPair() (privateKey, publicKey string, err error) { - var priv [32]byte - if _, err := rand.Read(priv[:]); err != nil { - return "", "", err - } - // Clamp per RFC 7748 / WireGuard convention. - priv[0] &= 248 - priv[31] &= 127 - priv[31] |= 64 - - var pub [32]byte - curve25519.ScalarBaseMult(&pub, &priv) - - return base64.StdEncoding.EncodeToString(priv[:]), base64.StdEncoding.EncodeToString(pub[:]), nil -} - -// PublicFromPrivate derives the public key for an existing base64 private key. -func PublicFromPrivate(privateKeyB64 string) (string, error) { - privBytes, err := base64.StdEncoding.DecodeString(privateKeyB64) - if err != nil { - return "", err - } - var priv, pub [32]byte - copy(priv[:], privBytes) - curve25519.ScalarBaseMult(&pub, &priv) - return base64.StdEncoding.EncodeToString(pub[:]), nil -} - -// GeneratePresharedKey creates a random base64 preshared key. -func GeneratePresharedKey() (string, error) { - var key [32]byte - if _, err := rand.Read(key[:]); err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(key[:]), nil -} diff --git a/internal/wireguard/manager.go b/internal/wireguard/manager.go deleted file mode 100644 index fce1be4..0000000 --- a/internal/wireguard/manager.go +++ /dev/null @@ -1,78 +0,0 @@ -package wireguard - -import ( - "fmt" - "os/exec" - "strings" -) - -// Status of a WireGuard interface. -type Status string - -const ( - StatusUp Status = "UP" - StatusDown Status = "DOWN" -) - -// Up brings up the given interface via wg-quick. -func Up(iface string) error { - return run("wg-quick", "up", iface) -} - -// Down brings down the given interface via wg-quick. -func Down(iface string) error { - return run("wg-quick", "down", iface) -} - -// Reload applies config changes to a running interface without a full restart, -// using `wg syncconf` against a stripped config (wg-quick strip). -func Reload(iface, confPath string) error { - strip := exec.Command("wg-quick", "strip", confPath) - stripped, err := strip.Output() - if err != nil { - return fmt.Errorf("wg-quick strip: %w", err) - } - sync := exec.Command("wg", "syncconf", iface, "/dev/stdin") - sync.Stdin = strings.NewReader(string(stripped)) - if out, err := sync.CombinedOutput(); err != nil { - return fmt.Errorf("wg syncconf: %w: %s", err, out) - } - return nil -} - -// IsUp checks whether the interface currently exists / is up. -func IsUp(iface string) bool { - cmd := exec.Command("wg", "show", iface) - return cmd.Run() == nil -} - -func GetStatus(iface string) Status { - if IsUp(iface) { - return StatusUp - } - return StatusDown -} - -// EnableService enables and starts the systemd wg-quick@.service unit. -func EnableService(iface string) error { - if err := run("systemctl", "enable", "wg-quick@"+iface); err != nil { - return err - } - return run("systemctl", "start", "wg-quick@"+iface) -} - -// DisableService stops and disables the systemd wg-quick@.service unit. -func DisableService(iface string) error { - if err := run("systemctl", "stop", "wg-quick@"+iface); err != nil { - return err - } - return run("systemctl", "disable", "wg-quick@"+iface) -} - -func run(name string, args ...string) error { - cmd := exec.Command(name, args...) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, out) - } - return nil -} diff --git a/internal/wireguard/migrate.go b/internal/wireguard/migrate.go deleted file mode 100644 index 654d794..0000000 --- a/internal/wireguard/migrate.go +++ /dev/null @@ -1,203 +0,0 @@ -package wireguard - -import ( - "bufio" - "fmt" - "os" - "strconv" - "strings" - - "gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server" -) - -// ParsedLegacyConfig is the parsed result of a legacy wg-quick style config file. -type ParsedLegacyConfig struct { - PrivateKey string - Address string // e.g. "10.10.0.1/24" (used as AddressRange for the new Server) - ListenPort int - DNS string - MTU int - Peers []ParsedLegacyPeer -} - -// ParsedLegacyPeer is a single [Peer] section from a legacy config. -type ParsedLegacyPeer struct { - Name string - PublicKey string - PresharedKey string - AllowedIPs string - Endpoint string - PersistentKeepalive int -} - -// ParseLegacyConfig reads and parses a wg-quick INI-style config file (e.g. /etc/wireguard/wg0.conf). -func ParseLegacyConfig(path string) (*ParsedLegacyConfig, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - - cfg := &ParsedLegacyConfig{} - var curSection string - var curPeer *ParsedLegacyPeer - - // pendingName holds a comment found on the line(s) immediately before a - // "[Peer]" header, e.g. "# client-laptop". wg-quick has no native peer - // name field, so this is the only place a human-readable name can come - // from; it's consumed (and reset) as soon as the next [Peer] section starts. - var pendingName string - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - - if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { - pendingName = strings.TrimSpace(strings.TrimLeft(line, "#;")) - continue - } - - // Strip inline comments. - if idx := strings.IndexAny(line, "#;"); idx >= 0 { - line = strings.TrimSpace(line[:idx]) - if line == "" { - continue - } - } - - if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { - section := strings.ToLower(strings.TrimSpace(line[1 : len(line)-1])) - switch section { - case "interface": - curSection = "interface" - curPeer = nil - case "peer": - curSection = "peer" - cfg.Peers = append(cfg.Peers, ParsedLegacyPeer{Name: pendingName}) - curPeer = &cfg.Peers[len(cfg.Peers)-1] - default: - curSection = "" - curPeer = nil - } - pendingName = "" - continue - } - - key, value, ok := splitKV(line) - if !ok { - continue - } - - switch curSection { - case "interface": - switch { - case strings.EqualFold(key, "PrivateKey"): - cfg.PrivateKey = value - case strings.EqualFold(key, "Address"): - cfg.Address = value - case strings.EqualFold(key, "ListenPort"): - cfg.ListenPort, _ = strconv.Atoi(value) - case strings.EqualFold(key, "DNS"): - cfg.DNS = value - case strings.EqualFold(key, "MTU"): - cfg.MTU, _ = strconv.Atoi(value) - } - case "peer": - if curPeer == nil { - continue - } - switch { - case strings.EqualFold(key, "PublicKey"): - curPeer.PublicKey = value - case strings.EqualFold(key, "PresharedKey"): - curPeer.PresharedKey = value - case strings.EqualFold(key, "AllowedIPs"): - curPeer.AllowedIPs = value - case strings.EqualFold(key, "Endpoint"): - curPeer.Endpoint = value - case strings.EqualFold(key, "PersistentKeepalive"): - curPeer.PersistentKeepalive, _ = strconv.Atoi(value) - } - } - } - if err := scanner.Err(); err != nil { - return nil, err - } - - return cfg, nil -} - -func splitKV(line string) (key, value string, ok bool) { - idx := strings.Index(line, "=") - if idx < 0 { - return "", "", false - } - key = strings.TrimSpace(line[:idx]) - value = strings.TrimSpace(line[idx+1:]) - if key == "" { - return "", "", false - } - return key, value, true -} - -// ImportLegacyServer parses legacyConfPath and creates a corresponding Server + its Peers -// in the given store, using serverName and interfaceName for the new Server record. -// Returns the new server's ID. -func ImportLegacyServer(store *server.Store, legacyConfPath, serverName, interfaceName string) (int64, error) { - parsed, err := ParseLegacyConfig(legacyConfPath) - if err != nil { - return 0, fmt.Errorf("parse legacy config %q: %w", legacyConfPath, err) - } - - pubKey, err := PublicFromPrivate(parsed.PrivateKey) - if err != nil { - return 0, fmt.Errorf("derive public key: %w", err) - } - - mtu := parsed.MTU - if mtu == 0 { - mtu = 1420 - } - - srv := &server.Server{ - Name: serverName, - InterfaceName: interfaceName, - ListenPort: parsed.ListenPort, - PrivateKey: parsed.PrivateKey, - PublicKey: pubKey, - AddressRange: parsed.Address, - DNS: parsed.DNS, - MTU: mtu, - Enabled: true, - } - - serverID, err := store.CreateServer(srv) - if err != nil { - return 0, fmt.Errorf("create server: %w", err) - } - - for i, pp := range parsed.Peers { - name := pp.Name - if name == "" { - name = fmt.Sprintf("peer-%d", i+1) - } - peer := &server.Peer{ - ServerID: serverID, - Name: name, - PublicKey: pp.PublicKey, - PresharedKey: pp.PresharedKey, - AllowedIPs: pp.AllowedIPs, - Endpoint: pp.Endpoint, - PersistentKeepalive: pp.PersistentKeepalive, - Enabled: true, - } - if _, err := store.CreatePeer(peer); err != nil { - return serverID, fmt.Errorf("create peer %q (index %d): %w", name, i, err) - } - } - - return serverID, nil -} diff --git a/main.go b/main.go new file mode 100644 index 0000000..1125746 --- /dev/null +++ b/main.go @@ -0,0 +1,340 @@ +package main + +import ( + "crypto/sha512" + "embed" + "flag" + "fmt" + "io/fs" + "net" + "net/http" + "os" + "strings" + "syscall" + "time" + + "github.com/labstack/echo/v4" + "github.com/labstack/gommon/log" + "github.com/ngoduykhanh/wireguard-ui/store" + "github.com/ngoduykhanh/wireguard-ui/telegram" + + "github.com/ngoduykhanh/wireguard-ui/emailer" + "github.com/ngoduykhanh/wireguard-ui/handler" + "github.com/ngoduykhanh/wireguard-ui/router" + "github.com/ngoduykhanh/wireguard-ui/store/jsondb" + "github.com/ngoduykhanh/wireguard-ui/util" +) + +var ( + // command-line banner information + appVersion = "development" + gitCommit = "N/A" + gitRef = "N/A" + buildTime = fmt.Sprintf(time.Now().UTC().Format("01-02-2006 15:04:05")) + // configuration variables + flagDisableLogin = false + flagBindAddress = "0.0.0.0:5000" + flagSmtpHostname = "127.0.0.1" + flagSmtpPort = 25 + flagSmtpUsername string + flagSmtpPassword string + flagSmtpAuthType = "NONE" + flagSmtpNoTLSCheck = false + flagSmtpEncryption = "STARTTLS" + flagSmtpHelo = "localhost" + flagSendgridApiKey string + flagEmailFrom string + flagEmailFromName = "WireGuard UI" + flagTelegramToken string + flagTelegramAllowConfRequest = false + flagTelegramFloodWait = 60 + flagSessionSecret = util.RandomString(32) + flagSessionMaxDuration = 90 + flagWgConfTemplate string + flagBasePath string + flagSubnetRanges string +) + +const ( + defaultEmailSubject = "Your wireguard configuration" + defaultEmailContent = `Hi,
+

In this email you can find your personal configuration for our wireguard server.

+ +

Best

+` +) + +// embed the "templates" directory +// +//go:embed templates/* +var embeddedTemplates embed.FS + +// embed the "assets" directory +// +//go:embed assets/* +var embeddedAssets embed.FS + +func init() { + // command-line flags and env variables + flag.BoolVar(&flagDisableLogin, "disable-login", util.LookupEnvOrBool("DISABLE_LOGIN", flagDisableLogin), "Disable authentication on the app. This is potentially dangerous.") + flag.StringVar(&flagBindAddress, "bind-address", util.LookupEnvOrString("BIND_ADDRESS", flagBindAddress), "Address:Port to which the app will be bound.") + flag.StringVar(&flagSmtpHostname, "smtp-hostname", util.LookupEnvOrString("SMTP_HOSTNAME", flagSmtpHostname), "SMTP Hostname") + flag.IntVar(&flagSmtpPort, "smtp-port", util.LookupEnvOrInt("SMTP_PORT", flagSmtpPort), "SMTP Port") + flag.StringVar(&flagSmtpHelo, "smtp-helo", util.LookupEnvOrString("SMTP_HELO", flagSmtpHelo), "SMTP HELO Hostname") + flag.StringVar(&flagSmtpUsername, "smtp-username", util.LookupEnvOrString("SMTP_USERNAME", flagSmtpUsername), "SMTP Username") + flag.BoolVar(&flagSmtpNoTLSCheck, "smtp-no-tls-check", util.LookupEnvOrBool("SMTP_NO_TLS_CHECK", flagSmtpNoTLSCheck), "Disable TLS verification for SMTP. This is potentially dangerous.") + flag.StringVar(&flagSmtpEncryption, "smtp-encryption", util.LookupEnvOrString("SMTP_ENCRYPTION", flagSmtpEncryption), "SMTP Encryption : NONE, SSL, SSLTLS, TLS or STARTTLS (by default)") + flag.StringVar(&flagSmtpAuthType, "smtp-auth-type", util.LookupEnvOrString("SMTP_AUTH_TYPE", flagSmtpAuthType), "SMTP Auth Type : PLAIN, LOGIN or NONE.") + flag.StringVar(&flagEmailFrom, "email-from", util.LookupEnvOrString("EMAIL_FROM_ADDRESS", flagEmailFrom), "'From' email address.") + flag.StringVar(&flagEmailFromName, "email-from-name", util.LookupEnvOrString("EMAIL_FROM_NAME", flagEmailFromName), "'From' email name.") + flag.StringVar(&flagTelegramToken, "telegram-token", util.LookupEnvOrString("TELEGRAM_TOKEN", flagTelegramToken), "Telegram bot token for distributing configs to clients.") + flag.BoolVar(&flagTelegramAllowConfRequest, "telegram-allow-conf-request", util.LookupEnvOrBool("TELEGRAM_ALLOW_CONF_REQUEST", flagTelegramAllowConfRequest), "Allow users to get configs from the bot by sending a message.") + flag.IntVar(&flagTelegramFloodWait, "telegram-flood-wait", util.LookupEnvOrInt("TELEGRAM_FLOOD_WAIT", flagTelegramFloodWait), "Time in minutes before the next conf request is processed.") + flag.StringVar(&flagWgConfTemplate, "wg-conf-template", util.LookupEnvOrString("WG_CONF_TEMPLATE", flagWgConfTemplate), "Path to custom wg.conf template.") + flag.StringVar(&flagBasePath, "base-path", util.LookupEnvOrString("BASE_PATH", flagBasePath), "The base path of the URL") + flag.StringVar(&flagSubnetRanges, "subnet-ranges", util.LookupEnvOrString("SUBNET_RANGES", flagSubnetRanges), "IP ranges to choose from when assigning an IP for a client.") + flag.IntVar(&flagSessionMaxDuration, "session-max-duration", util.LookupEnvOrInt("SESSION_MAX_DURATION", flagSessionMaxDuration), "Max time in days a remembered session is refreshed and valid.") + + var ( + smtpPasswordLookup = util.LookupEnvOrString("SMTP_PASSWORD", flagSmtpPassword) + sendgridApiKeyLookup = util.LookupEnvOrString("SENDGRID_API_KEY", flagSendgridApiKey) + sessionSecretLookup = util.LookupEnvOrString("SESSION_SECRET", flagSessionSecret) + ) + + // check empty smtpPassword env var + if smtpPasswordLookup != "" { + flag.StringVar(&flagSmtpPassword, "smtp-password", smtpPasswordLookup, "SMTP Password") + } else { + flag.StringVar(&flagSmtpPassword, "smtp-password", util.LookupEnvOrFile("SMTP_PASSWORD_FILE", flagSmtpPassword), "SMTP Password File") + } + + // check empty sendgridApiKey env var + if sendgridApiKeyLookup != "" { + flag.StringVar(&flagSendgridApiKey, "sendgrid-api-key", sendgridApiKeyLookup, "Your sendgrid api key.") + } else { + flag.StringVar(&flagSendgridApiKey, "sendgrid-api-key", util.LookupEnvOrFile("SENDGRID_API_KEY_FILE", flagSendgridApiKey), "File containing your sendgrid api key.") + } + + // check empty sessionSecret env var + if sessionSecretLookup != "" { + flag.StringVar(&flagSessionSecret, "session-secret", sessionSecretLookup, "The key used to encrypt session cookies.") + } else { + flag.StringVar(&flagSessionSecret, "session-secret", util.LookupEnvOrFile("SESSION_SECRET_FILE", flagSessionSecret), "File containing the key used to encrypt session cookies.") + } + + flag.Parse() + + // update runtime config + util.DisableLogin = flagDisableLogin + util.BindAddress = flagBindAddress + util.SmtpHostname = flagSmtpHostname + util.SmtpPort = flagSmtpPort + util.SmtpHelo = flagSmtpHelo + util.SmtpUsername = flagSmtpUsername + util.SmtpPassword = flagSmtpPassword + util.SmtpAuthType = flagSmtpAuthType + util.SmtpNoTLSCheck = flagSmtpNoTLSCheck + util.SmtpEncryption = flagSmtpEncryption + util.SendgridApiKey = flagSendgridApiKey + util.EmailFrom = flagEmailFrom + util.EmailFromName = flagEmailFromName + util.SessionSecret = sha512.Sum512([]byte(flagSessionSecret)) + util.SessionMaxDuration = int64(flagSessionMaxDuration) * 86_400 // Store in seconds + util.WgConfTemplate = flagWgConfTemplate + util.BasePath = util.ParseBasePath(flagBasePath) + util.SubnetRanges = util.ParseSubnetRanges(flagSubnetRanges) + + lvl, _ := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO")) + + telegram.Token = flagTelegramToken + telegram.AllowConfRequest = flagTelegramAllowConfRequest + telegram.FloodWait = flagTelegramFloodWait + telegram.LogLevel = lvl + + // print only if log level is INFO or lower + if lvl <= log.INFO { + // print app information + fmt.Println("Wireguard UI") + fmt.Println("App Version\t:", appVersion) + fmt.Println("Git Commit\t:", gitCommit) + fmt.Println("Git Ref\t\t:", gitRef) + fmt.Println("Build Time\t:", buildTime) + fmt.Println("Git Repo\t:", "https://github.com/ngoduykhanh/wireguard-ui") + fmt.Println("Authentication\t:", !util.DisableLogin) + fmt.Println("Bind address\t:", util.BindAddress) + //fmt.Println("Sendgrid key\t:", util.SendgridApiKey) + fmt.Println("Email from\t:", util.EmailFrom) + fmt.Println("Email from name\t:", util.EmailFromName) + //fmt.Println("Session secret\t:", util.SessionSecret) + fmt.Println("Custom wg.conf\t:", util.WgConfTemplate) + fmt.Println("Base path\t:", util.BasePath+"/") + fmt.Println("Subnet ranges\t:", util.GetSubnetRangesString()) + } +} + +func main() { + db, err := jsondb.New("./db") + if err != nil { + panic(err) + } + if err := db.Init(); err != nil { + panic(err) + } + // set app extra data + extraData := make(map[string]interface{}) + extraData["appVersion"] = appVersion + extraData["gitCommit"] = gitCommit + extraData["basePath"] = util.BasePath + extraData["loginDisabled"] = flagDisableLogin + + // strip the "templates/" prefix from the embedded directory so files can be read by their direct name (e.g. + // "base.html" instead of "templates/base.html") + tmplDir, _ := fs.Sub(fs.FS(embeddedTemplates), "templates") + + // create the wireguard config on start, if it doesn't exist + initServerConfig(db, tmplDir) + + // Check if subnet ranges are valid for the server configuration + // Remove any non-valid CIDRs + if err := util.ValidateAndFixSubnetRanges(db); err != nil { + panic(err) + } + + // Print valid ranges + if lvl, _ := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO")); lvl <= log.INFO { + fmt.Println("Valid subnet ranges:", util.GetSubnetRangesString()) + } + + // register routes + app := router.New(tmplDir, extraData, util.SessionSecret) + + app.GET(util.BasePath, handler.WireGuardClients(db), handler.ValidSession, handler.RefreshSession) + + // Important: Make sure that all non-GET routes check the request content type using handler.ContentTypeJson to + // mitigate CSRF attacks. This is effective, because browsers don't allow setting the Content-Type header on + // cross-origin requests. + + if !util.DisableLogin { + app.GET(util.BasePath+"/login", handler.LoginPage()) + app.POST(util.BasePath+"/login", handler.Login(db), handler.ContentTypeJson) + app.GET(util.BasePath+"/logout", handler.Logout(), handler.ValidSession) + app.GET(util.BasePath+"/profile", handler.LoadProfile(), handler.ValidSession, handler.RefreshSession) + app.GET(util.BasePath+"/users-settings", handler.UsersSettings(), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin) + app.POST(util.BasePath+"/update-user", handler.UpdateUser(db), handler.ValidSession, handler.ContentTypeJson) + app.POST(util.BasePath+"/create-user", handler.CreateUser(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) + app.POST(util.BasePath+"/remove-user", handler.RemoveUser(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) + app.GET(util.BasePath+"/get-users", handler.GetUsers(db), handler.ValidSession, handler.NeedsAdmin) + app.GET(util.BasePath+"/api/user/:username", handler.GetUser(db), handler.ValidSession) + } + + var sendmail emailer.Emailer + if util.SendgridApiKey != "" { + sendmail = emailer.NewSendgridApiMail(util.SendgridApiKey, util.EmailFromName, util.EmailFrom) + } else { + sendmail = emailer.NewSmtpMail(util.SmtpHostname, util.SmtpPort, util.SmtpUsername, util.SmtpPassword, util.SmtpHelo, util.SmtpNoTLSCheck, util.SmtpAuthType, util.EmailFromName, util.EmailFrom, util.SmtpEncryption) + } + + app.GET(util.BasePath+"/test-hash", handler.GetHashesChanges(db), handler.ValidSession) + app.GET(util.BasePath+"/about", handler.AboutPage()) + app.GET(util.BasePath+"/_health", handler.Health()) + app.GET(util.BasePath+"/favicon", handler.Favicon()) + app.POST(util.BasePath+"/new-client", handler.NewClient(db), handler.ValidSession, handler.ContentTypeJson) + app.POST(util.BasePath+"/update-client", handler.UpdateClient(db), handler.ValidSession, handler.ContentTypeJson) + app.POST(util.BasePath+"/email-client", handler.EmailClient(db, sendmail, defaultEmailSubject, defaultEmailContent), handler.ValidSession, handler.ContentTypeJson) + app.POST(util.BasePath+"/send-telegram-client", handler.SendTelegramClient(db), handler.ValidSession, handler.ContentTypeJson) + app.POST(util.BasePath+"/client/set-status", handler.SetClientStatus(db), handler.ValidSession, handler.ContentTypeJson) + app.POST(util.BasePath+"/remove-client", handler.RemoveClient(db), handler.ValidSession, handler.ContentTypeJson) + app.GET(util.BasePath+"/download", handler.DownloadClient(db), handler.ValidSession) + app.GET(util.BasePath+"/wg-server", handler.WireGuardServer(db), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin) + app.POST(util.BasePath+"/wg-server/interfaces", handler.WireGuardServerInterfaces(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) + app.POST(util.BasePath+"/wg-server/keypair", handler.WireGuardServerKeyPair(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) + app.GET(util.BasePath+"/global-settings", handler.GlobalSettings(db), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin) + app.POST(util.BasePath+"/global-settings", handler.GlobalSettingSubmit(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) + app.GET(util.BasePath+"/status", handler.Status(db), handler.ValidSession, handler.RefreshSession) + app.GET(util.BasePath+"/api/clients", handler.GetClients(db), handler.ValidSession) + app.GET(util.BasePath+"/api/client/:id", handler.GetClient(db), handler.ValidSession) + app.GET(util.BasePath+"/api/machine-ips", handler.MachineIPAddresses(), handler.ValidSession) + app.GET(util.BasePath+"/api/subnet-ranges", handler.GetOrderedSubnetRanges(), handler.ValidSession) + app.GET(util.BasePath+"/api/suggest-client-ips", handler.SuggestIPAllocation(db), handler.ValidSession) + app.POST(util.BasePath+"/api/apply-wg-config", handler.ApplyServerConfig(db, tmplDir), handler.ValidSession, handler.ContentTypeJson) + app.GET(util.BasePath+"/wake_on_lan_hosts", handler.GetWakeOnLanHosts(db), handler.ValidSession, handler.RefreshSession) + app.POST(util.BasePath+"/wake_on_lan_host", handler.SaveWakeOnLanHost(db), handler.ValidSession, handler.ContentTypeJson) + app.DELETE(util.BasePath+"/wake_on_lan_host/:mac_address", handler.DeleteWakeOnHost(db), handler.ValidSession, handler.ContentTypeJson) + app.PUT(util.BasePath+"/wake_on_lan_host/:mac_address", handler.WakeOnHost(db), handler.ValidSession, handler.ContentTypeJson) + + // strip the "assets/" prefix from the embedded directory so files can be called directly without the "assets/" + // prefix + assetsDir, _ := fs.Sub(fs.FS(embeddedAssets), "assets") + assetHandler := http.FileServer(http.FS(assetsDir)) + // serves other static files + app.GET(util.BasePath+"/static/*", echo.WrapHandler(http.StripPrefix(util.BasePath+"/static/", assetHandler))) + + initDeps := telegram.TgBotInitDependencies{ + DB: db, + SendRequestedConfigsToTelegram: util.SendRequestedConfigsToTelegram, + } + + initTelegram(initDeps) + + if strings.HasPrefix(util.BindAddress, "unix://") { + // Listen on unix domain socket. + // https://github.com/labstack/echo/issues/830 + err := syscall.Unlink(util.BindAddress[6:]) + if err != nil { + app.Logger.Fatalf("Cannot unlink unix socket: Error: %v", err) + } + l, err := net.Listen("unix", util.BindAddress[6:]) + if err != nil { + app.Logger.Fatalf("Cannot create unix socket. Error: %v", err) + } + app.Listener = l + app.Logger.Fatal(app.Start("")) + } else { + // Listen on TCP socket + app.Logger.Fatal(app.Start(util.BindAddress)) + } +} + +func initServerConfig(db store.IStore, tmplDir fs.FS) { + settings, err := db.GetGlobalSettings() + if err != nil { + log.Fatalf("Cannot get global settings: %v", err) + } + + if _, err := os.Stat(settings.ConfigFilePath); err == nil { + // file exists, don't overwrite it implicitly + return + } + + server, err := db.GetServer() + if err != nil { + log.Fatalf("Cannot get server config: %v", err) + } + + clients, err := db.GetClients(false) + if err != nil { + log.Fatalf("Cannot get client config: %v", err) + } + + users, err := db.GetUsers() + if err != nil { + log.Fatalf("Cannot get user config: %v", err) + } + + // write config file + err = util.WriteWireGuardServerConfig(tmplDir, server, clients, users, settings) + if err != nil { + log.Fatalf("Cannot create server config: %v", err) + } +} + +func initTelegram(initDeps telegram.TgBotInitDependencies) { + go func() { + for { + err := telegram.Start(initDeps) + if err == nil { + break + } + } + }() +} diff --git a/model/client.go b/model/client.go new file mode 100644 index 0000000..d835124 --- /dev/null +++ b/model/client.go @@ -0,0 +1,38 @@ +package model + +import ( + "time" +) + +// Client model +type Client struct { + ID string `json:"id"` + PrivateKey string `json:"private_key"` + PublicKey string `json:"public_key"` + PresharedKey string `json:"preshared_key"` + Name string `json:"name"` + TgUserid string `json:"telegram_userid"` + Email string `json:"email"` + SubnetRanges []string `json:"subnet_ranges,omitempty"` + AllocatedIPs []string `json:"allocated_ips"` + AllowedIPs []string `json:"allowed_ips"` + ExtraAllowedIPs []string `json:"extra_allowed_ips"` + Endpoint string `json:"endpoint"` + AdditionalNotes string `json:"additional_notes"` + UseServerDNS bool `json:"use_server_dns"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ClientData includes the Client and extra data +type ClientData struct { + Client *Client + QRCode string +} + +type QRCodeSettings struct { + Enabled bool + IncludeDNS bool + IncludeMTU bool +} diff --git a/model/client_defaults.go b/model/client_defaults.go new file mode 100644 index 0000000..615ebed --- /dev/null +++ b/model/client_defaults.go @@ -0,0 +1,9 @@ +package model + +// ClientDefaults Defaults for creation of new clients used in the templates +type ClientDefaults struct { + AllowedIps []string + ExtraAllowedIps []string + UseServerDNS bool + EnableAfterCreation bool +} diff --git a/model/misc.go b/model/misc.go new file mode 100644 index 0000000..dc95f15 --- /dev/null +++ b/model/misc.go @@ -0,0 +1,20 @@ +package model + +// Interface model +type Interface struct { + Name string `json:"name"` + IPAddress string `json:"ip_address"` +} + +// BaseData struct to pass value to the base template +type BaseData struct { + Active string + CurrentUser string + Admin bool +} + +// ClientServerHashes struct, to save hashes to detect changes +type ClientServerHashes struct { + Client string `json:"client"` + Server string `json:"server"` +} diff --git a/model/server.go b/model/server.go new file mode 100644 index 0000000..0aa804f --- /dev/null +++ b/model/server.go @@ -0,0 +1,28 @@ +package model + +import ( + "time" +) + +// Server model +type Server struct { + KeyPair *ServerKeypair + Interface *ServerInterface +} + +// ServerKeypair model +type ServerKeypair struct { + PrivateKey string `json:"private_key"` + PublicKey string `json:"public_key"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ServerInterface model +type ServerInterface struct { + Addresses []string `json:"addresses"` + ListenPort int `json:"listen_port,string"` // ,string to get listen_port string input as int + UpdatedAt time.Time `json:"updated_at"` + PostUp string `json:"post_up"` + PreDown string `json:"pre_down"` + PostDown string `json:"post_down"` +} diff --git a/model/setting.go b/model/setting.go new file mode 100644 index 0000000..c9e152c --- /dev/null +++ b/model/setting.go @@ -0,0 +1,17 @@ +package model + +import ( + "time" +) + +// GlobalSetting model +type GlobalSetting struct { + EndpointAddress string `json:"endpoint_address"` + DNSServers []string `json:"dns_servers"` + MTU int `json:"mtu,string"` + PersistentKeepalive int `json:"persistent_keepalive,string"` + FirewallMark string `json:"firewall_mark"` + Table string `json:"table"` + ConfigFilePath string `json:"config_file_path"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/model/user.go b/model/user.go new file mode 100644 index 0000000..71f4d13 --- /dev/null +++ b/model/user.go @@ -0,0 +1,10 @@ +package model + +// User model +type User struct { + Username string `json:"username"` + Password string `json:"password"` + // PasswordHash takes precedence over Password. + PasswordHash string `json:"password_hash"` + Admin bool `json:"admin"` +} diff --git a/model/wake_on_lan_host.go b/model/wake_on_lan_host.go new file mode 100644 index 0000000..73966f0 --- /dev/null +++ b/model/wake_on_lan_host.go @@ -0,0 +1,31 @@ +package model + +import ( + "errors" + "net" + "strings" + "time" +) + +type WakeOnLanHost struct { + MacAddress string `json:"MacAddress"` + Name string `json:"Name"` + LatestUsed *time.Time `json:"LatestUsed"` +} + +func (host WakeOnLanHost) ResolveResourceName() (string, error) { + resourceName := strings.Trim(host.MacAddress, " \t\r\n\000") + if len(resourceName) == 0 { + return "", errors.New("mac Address is Empty") + } + resourceName = strings.ToUpper(resourceName) + resourceName = strings.ReplaceAll(resourceName, ":", "-") + + if _, err := net.ParseMAC(resourceName); err != nil { + return "", errors.New("invalid mac address") + } + + return resourceName, nil +} + +const WakeOnLanHostCollectionName = "wake_on_lan_hosts" diff --git a/package.json b/package.json new file mode 100644 index 0000000..a0cda64 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "wireguard-ui", + "version": "1.0.0", + "description": "Wireguard web interface", + "main": "index.js", + "repository": "git@github.com:ngoduykhanh/wireguard-ui.git", + "author": "Khanh Ngo ", + "license": "MIT", + "dependencies": { + "admin-lte": "^3.0", + "jquery-tags-input": "^1.3.5" + } +} diff --git a/prepare_assets.sh b/prepare_assets.sh new file mode 100755 index 0000000..66a66b3 --- /dev/null +++ b/prepare_assets.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -e + +DIR=$(dirname "$0") + +# install node modules +YARN=yarn +[ -x /usr/bin/lsb_release ] && [ -n "`lsb_release -i | grep Debian`" ] && YARN=yarnpkg +$YARN install --pure-lockfile --production + +# Copy admin-lte dist +mkdir -p "${DIR}/assets/dist/js" "${DIR}/assets/dist/css" && \ + cp -r "${DIR}/node_modules/admin-lte/dist/js/adminlte.min.js" "${DIR}/assets/dist/js/adminlte.min.js" && \ + cp -r "${DIR}/node_modules/admin-lte/dist/css/adminlte.min.css" "${DIR}/assets/dist/css/adminlte.min.css" + +# Copy helper js +cp -r "${DIR}/custom" "${DIR}/assets" + +# Copy plugins +mkdir -p "${DIR}/assets/plugins" && \ + cp -r "${DIR}/node_modules/admin-lte/plugins/jquery" \ + "${DIR}/node_modules/admin-lte/plugins/fontawesome-free" \ + "${DIR}/node_modules/admin-lte/plugins/bootstrap" \ + "${DIR}/node_modules/admin-lte/plugins/icheck-bootstrap" \ + "${DIR}/node_modules/admin-lte/plugins/toastr" \ + "${DIR}/node_modules/admin-lte/plugins/jquery-validation" \ + "${DIR}/node_modules/admin-lte/plugins/select2" \ + "${DIR}/node_modules/jquery-tags-input" \ + "${DIR}/assets/plugins/" diff --git a/router/router.go b/router/router.go new file mode 100644 index 0000000..59d352e --- /dev/null +++ b/router/router.go @@ -0,0 +1,158 @@ +package router + +import ( + "errors" + "io" + "io/fs" + "reflect" + "strings" + "text/template" + + "github.com/gorilla/sessions" + "github.com/labstack/echo-contrib/session" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/labstack/gommon/log" + "github.com/ngoduykhanh/wireguard-ui/util" +) + +// TemplateRegistry is a custom html/template renderer for Echo framework +type TemplateRegistry struct { + templates map[string]*template.Template + extraData map[string]interface{} +} + +// Render e.Renderer interface +func (t *TemplateRegistry) Render(w io.Writer, name string, data interface{}, c echo.Context) error { + tmpl, ok := t.templates[name] + if !ok { + err := errors.New("Template not found -> " + name) + return err + } + + // inject more app data information. E.g. appVersion + if reflect.TypeOf(data).Kind() == reflect.Map { + for k, v := range t.extraData { + data.(map[string]interface{})[k] = v + } + + data.(map[string]interface{})["client_defaults"] = util.ClientDefaultsFromEnv() + } + + // login page does not need the base layout + if name == "login.html" { + return tmpl.Execute(w, data) + } + + return tmpl.ExecuteTemplate(w, "base.html", data) +} + +// New function +func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo.Echo { + e := echo.New() + + cookiePath := util.GetCookiePath() + + cookieStore := sessions.NewCookieStore(secret[:32], secret[32:]) + cookieStore.Options.Path = cookiePath + cookieStore.Options.HttpOnly = true + cookieStore.MaxAge(86400 * 7) + + e.Use(session.Middleware(cookieStore)) + + // read html template file to string + tmplBaseString, err := util.StringFromEmbedFile(tmplDir, "base.html") + if err != nil { + log.Fatal(err) + } + + tmplLoginString, err := util.StringFromEmbedFile(tmplDir, "login.html") + if err != nil { + log.Fatal(err) + } + + tmplProfileString, err := util.StringFromEmbedFile(tmplDir, "profile.html") + if err != nil { + log.Fatal(err) + } + + tmplClientsString, err := util.StringFromEmbedFile(tmplDir, "clients.html") + if err != nil { + log.Fatal(err) + } + + tmplServerString, err := util.StringFromEmbedFile(tmplDir, "server.html") + if err != nil { + log.Fatal(err) + } + + tmplGlobalSettingsString, err := util.StringFromEmbedFile(tmplDir, "global_settings.html") + if err != nil { + log.Fatal(err) + } + + tmplUsersSettingsString, err := util.StringFromEmbedFile(tmplDir, "users_settings.html") + if err != nil { + log.Fatal(err) + } + + tmplStatusString, err := util.StringFromEmbedFile(tmplDir, "status.html") + if err != nil { + log.Fatal(err) + } + + tmplWakeOnLanHostsString, err := util.StringFromEmbedFile(tmplDir, "wake_on_lan_hosts.html") + if err != nil { + log.Fatal(err) + } + + aboutPageString, err := util.StringFromEmbedFile(tmplDir, "about.html") + if err != nil { + log.Fatal(err) + } + + // create template list + funcs := template.FuncMap{ + "StringsJoin": strings.Join, + } + templates := make(map[string]*template.Template) + templates["login.html"] = template.Must(template.New("login").Funcs(funcs).Parse(tmplLoginString)) + templates["profile.html"] = template.Must(template.New("profile").Funcs(funcs).Parse(tmplBaseString + tmplProfileString)) + templates["clients.html"] = template.Must(template.New("clients").Funcs(funcs).Parse(tmplBaseString + tmplClientsString)) + templates["server.html"] = template.Must(template.New("server").Funcs(funcs).Parse(tmplBaseString + tmplServerString)) + templates["global_settings.html"] = template.Must(template.New("global_settings").Funcs(funcs).Parse(tmplBaseString + tmplGlobalSettingsString)) + templates["users_settings.html"] = template.Must(template.New("users_settings").Funcs(funcs).Parse(tmplBaseString + tmplUsersSettingsString)) + templates["status.html"] = template.Must(template.New("status").Funcs(funcs).Parse(tmplBaseString + tmplStatusString)) + templates["wake_on_lan_hosts.html"] = template.Must(template.New("wake_on_lan_hosts").Funcs(funcs).Parse(tmplBaseString + tmplWakeOnLanHostsString)) + templates["about.html"] = template.Must(template.New("about").Funcs(funcs).Parse(tmplBaseString + aboutPageString)) + + lvl, err := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO")) + if err != nil { + log.Fatal(err) + } + logConfig := middleware.DefaultLoggerConfig + logConfig.Skipper = func(c echo.Context) bool { + resp := c.Response() + if resp.Status >= 500 && lvl > log.ERROR { // do not log if response is 5XX but log level is higher than ERROR + return true + } else if resp.Status >= 400 && lvl > log.WARN { // do not log if response is 4XX but log level is higher than WARN + return true + } else if lvl > log.DEBUG { // do not log if log level is higher than DEBUG + return true + } + return false + } + + e.Logger.SetLevel(lvl) + e.Pre(middleware.RemoveTrailingSlash()) + e.Use(middleware.LoggerWithConfig(logConfig)) + e.HideBanner = true + e.HidePort = lvl > log.INFO // hide the port output if the log level is higher than INFO + e.Validator = NewValidator() + e.Renderer = &TemplateRegistry{ + templates: templates, + extraData: extraData, + } + + return e +} diff --git a/router/validator.go b/router/validator.go new file mode 100644 index 0000000..a35c66f --- /dev/null +++ b/router/validator.go @@ -0,0 +1,20 @@ +package router + +import "gopkg.in/go-playground/validator.v9" + +// NewValidator func +func NewValidator() *Validator { + return &Validator{ + validator: validator.New(), + } +} + +// Validator struct +type Validator struct { + validator *validator.Validate +} + +// Validate func +func (v *Validator) Validate(i interface{}) error { + return v.validator.Struct(i) +} diff --git a/store/jsondb/jsondb.go b/store/jsondb/jsondb.go new file mode 100644 index 0000000..1cd0a43 --- /dev/null +++ b/store/jsondb/jsondb.go @@ -0,0 +1,410 @@ +package jsondb + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path" + "strconv" + "time" + + "github.com/sdomino/scribble" + "github.com/skip2/go-qrcode" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/ngoduykhanh/wireguard-ui/model" + "github.com/ngoduykhanh/wireguard-ui/util" +) + +type JsonDB struct { + conn *scribble.Driver + dbPath string +} + +// New returns a new pointer JsonDB +func New(dbPath string) (*JsonDB, error) { + conn, err := scribble.New(dbPath, nil) + if err != nil { + return nil, err + } + ans := JsonDB{ + conn: conn, + dbPath: dbPath, + } + return &ans, nil +} + +func (o *JsonDB) Init() error { + var clientPath = path.Join(o.dbPath, "clients") + var serverPath = path.Join(o.dbPath, "server") + var userPath = path.Join(o.dbPath, "users") + var wakeOnLanHostsPath = path.Join(o.dbPath, "wake_on_lan_hosts") + var serverInterfacePath = path.Join(serverPath, "interfaces.json") + var serverKeyPairPath = path.Join(serverPath, "keypair.json") + var globalSettingPath = path.Join(serverPath, "global_settings.json") + var hashesPath = path.Join(serverPath, "hashes.json") + + // create directories if they do not exist + if _, err := os.Stat(clientPath); os.IsNotExist(err) { + os.MkdirAll(clientPath, os.ModePerm) + } + if _, err := os.Stat(serverPath); os.IsNotExist(err) { + os.MkdirAll(serverPath, os.ModePerm) + } + if _, err := os.Stat(userPath); os.IsNotExist(err) { + os.MkdirAll(userPath, os.ModePerm) + } + if _, err := os.Stat(wakeOnLanHostsPath); os.IsNotExist(err) { + os.MkdirAll(wakeOnLanHostsPath, os.ModePerm) + } + + // server's interface + if _, err := os.Stat(serverInterfacePath); os.IsNotExist(err) { + serverInterface := new(model.ServerInterface) + serverInterface.Addresses = util.LookupEnvOrStrings(util.ServerAddressesEnvVar, []string{util.DefaultServerAddress}) + serverInterface.ListenPort = util.LookupEnvOrInt(util.ServerListenPortEnvVar, util.DefaultServerPort) + serverInterface.PostUp = util.LookupEnvOrString(util.ServerPostUpScriptEnvVar, "") + serverInterface.PostDown = util.LookupEnvOrString(util.ServerPostDownScriptEnvVar, "") + serverInterface.UpdatedAt = time.Now().UTC() + o.conn.Write("server", "interfaces", serverInterface) + err := util.ManagePerms(serverInterfacePath) + if err != nil { + return err + } + } + + // server's key pair + if _, err := os.Stat(serverKeyPairPath); os.IsNotExist(err) { + key, err := wgtypes.GeneratePrivateKey() + if err != nil { + return scribble.ErrMissingCollection + } + serverKeyPair := new(model.ServerKeypair) + serverKeyPair.PrivateKey = key.String() + serverKeyPair.PublicKey = key.PublicKey().String() + serverKeyPair.UpdatedAt = time.Now().UTC() + o.conn.Write("server", "keypair", serverKeyPair) + err = util.ManagePerms(serverKeyPairPath) + if err != nil { + return err + } + } + + // global settings + if _, err := os.Stat(globalSettingPath); os.IsNotExist(err) { + endpointAddress := util.LookupEnvOrString(util.EndpointAddressEnvVar, "") + if endpointAddress == "" { + // automatically find an external IP address + publicInterface, err := util.GetPublicIP() + if err != nil { + return err + } + endpointAddress = publicInterface.IPAddress + } + + globalSetting := new(model.GlobalSetting) + globalSetting.EndpointAddress = endpointAddress + globalSetting.DNSServers = util.LookupEnvOrStrings(util.DNSEnvVar, []string{util.DefaultDNS}) + globalSetting.MTU = util.LookupEnvOrInt(util.MTUEnvVar, util.DefaultMTU) + globalSetting.PersistentKeepalive = util.LookupEnvOrInt(util.PersistentKeepaliveEnvVar, util.DefaultPersistentKeepalive) + globalSetting.FirewallMark = util.LookupEnvOrString(util.FirewallMarkEnvVar, util.DefaultFirewallMark) + globalSetting.Table = util.LookupEnvOrString(util.TableEnvVar, util.DefaultTable) + globalSetting.ConfigFilePath = util.LookupEnvOrString(util.ConfigFilePathEnvVar, util.DefaultConfigFilePath) + globalSetting.UpdatedAt = time.Now().UTC() + o.conn.Write("server", "global_settings", globalSetting) + err := util.ManagePerms(globalSettingPath) + if err != nil { + return err + } + } + + // hashes + if _, err := os.Stat(hashesPath); os.IsNotExist(err) { + clientServerHashes := new(model.ClientServerHashes) + clientServerHashes.Client = "none" + clientServerHashes.Server = "none" + o.conn.Write("server", "hashes", clientServerHashes) + err := util.ManagePerms(hashesPath) + if err != nil { + return err + } + } + + // user info + results, err := o.conn.ReadAll("users") + if err != nil || len(results) < 1 { + user := new(model.User) + user.Username = util.LookupEnvOrString(util.UsernameEnvVar, util.DefaultUsername) + user.Admin = util.DefaultIsAdmin + user.PasswordHash = util.LookupEnvOrString(util.PasswordHashEnvVar, "") + if user.PasswordHash == "" { + user.PasswordHash = util.LookupEnvOrFile(util.PasswordHashFileEnvVar, "") + if user.PasswordHash == "" { + plaintext := util.LookupEnvOrString(util.PasswordEnvVar, util.DefaultPassword) + if plaintext == util.DefaultPassword { + plaintext = util.LookupEnvOrFile(util.PasswordFileEnvVar, util.DefaultPassword) + } + hash, err := util.HashPassword(plaintext) + if err != nil { + return err + } + user.PasswordHash = hash + } + } + + o.conn.Write("users", user.Username, user) + results, _ = o.conn.ReadAll("users") + err = util.ManagePerms(path.Join(path.Join(o.dbPath, "users"), user.Username+".json")) + if err != nil { + return err + } + } + + // init cache + for _, i := range results { + user := model.User{} + + if err := json.Unmarshal([]byte(i), &user); err == nil { + util.DBUsersToCRC32[user.Username] = util.GetDBUserCRC32(user) + } + } + + clients, err := o.GetClients(false) + if err != nil { + return nil + } + for _, cl := range clients { + client := cl.Client + if client.Enabled && len(client.TgUserid) > 0 { + if userid, err := strconv.ParseInt(client.TgUserid, 10, 64); err == nil { + util.UpdateTgToClientID(userid, client.ID) + } + } + } + + return nil +} + +// GetUsers func to get all users from the database +func (o *JsonDB) GetUsers() ([]model.User, error) { + var users []model.User + results, err := o.conn.ReadAll("users") + if err != nil { + return users, err + } + for _, i := range results { + user := model.User{} + + if err := json.Unmarshal(i, &user); err != nil { + return users, fmt.Errorf("cannot decode user json structure: %v", err) + } + users = append(users, user) + } + return users, err +} + +// GetUserByName func to get single user from the database +func (o *JsonDB) GetUserByName(username string) (model.User, error) { + user := model.User{} + + if err := o.conn.Read("users", username, &user); err != nil { + return user, err + } + + return user, nil +} + +// SaveUser func to save user in the database +func (o *JsonDB) SaveUser(user model.User) error { + userPath := path.Join(path.Join(o.dbPath, "users"), user.Username+".json") + output := o.conn.Write("users", user.Username, user) + err := util.ManagePerms(userPath) + if err != nil { + return err + } + util.DBUsersToCRC32[user.Username] = util.GetDBUserCRC32(user) + return output +} + +// DeleteUser func to remove user from the database +func (o *JsonDB) DeleteUser(username string) error { + delete(util.DBUsersToCRC32, username) + return o.conn.Delete("users", username) +} + +// GetGlobalSettings func to query global settings from the database +func (o *JsonDB) GetGlobalSettings() (model.GlobalSetting, error) { + settings := model.GlobalSetting{} + return settings, o.conn.Read("server", "global_settings", &settings) +} + +// GetServer func to query Server settings from the database +func (o *JsonDB) GetServer() (model.Server, error) { + server := model.Server{} + // read server interface information + serverInterface := model.ServerInterface{} + if err := o.conn.Read("server", "interfaces", &serverInterface); err != nil { + return server, err + } + + // read server key pair information + serverKeyPair := model.ServerKeypair{} + if err := o.conn.Read("server", "keypair", &serverKeyPair); err != nil { + return server, err + } + + // create Server object and return + server.Interface = &serverInterface + server.KeyPair = &serverKeyPair + return server, nil +} + +func (o *JsonDB) GetClients(hasQRCode bool) ([]model.ClientData, error) { + var clients []model.ClientData + + // read all client json files in "clients" directory + records, err := o.conn.ReadAll("clients") + if err != nil { + return clients, err + } + + // build the ClientData list + for _, f := range records { + client := model.Client{} + clientData := model.ClientData{} + + // get client info + if err := json.Unmarshal(f, &client); err != nil { + return clients, fmt.Errorf("cannot decode client json structure: %v", err) + } + + // generate client qrcode image in base64 + if hasQRCode && client.PrivateKey != "" { + server, _ := o.GetServer() + globalSettings, _ := o.GetGlobalSettings() + + png, err := qrcode.Encode(util.BuildClientConfig(client, server, globalSettings), qrcode.Medium, 256) + if err == nil { + clientData.QRCode = "data:image/png;base64," + base64.StdEncoding.EncodeToString(png) + } else { + fmt.Print("Cannot generate QR code: ", err) + } + } + + // create the list of clients and their qrcode data + clientData.Client = &client + clients = append(clients, clientData) + } + + return clients, nil +} + +func (o *JsonDB) GetClientByID(clientID string, qrCodeSettings model.QRCodeSettings) (model.ClientData, error) { + client := model.Client{} + clientData := model.ClientData{} + + // read client information + if err := o.conn.Read("clients", clientID, &client); err != nil { + return clientData, err + } + + // generate client qrcode image in base64 + if qrCodeSettings.Enabled && client.PrivateKey != "" { + server, _ := o.GetServer() + globalSettings, _ := o.GetGlobalSettings() + client := client + if !qrCodeSettings.IncludeDNS { + globalSettings.DNSServers = []string{} + } + if !qrCodeSettings.IncludeMTU { + globalSettings.MTU = 0 + } + + png, err := qrcode.Encode(util.BuildClientConfig(client, server, globalSettings), qrcode.Medium, 256) + if err == nil { + clientData.QRCode = "data:image/png;base64," + base64.StdEncoding.EncodeToString(png) + } else { + fmt.Print("Cannot generate QR code: ", err) + } + } + + clientData.Client = &client + + return clientData, nil +} + +func (o *JsonDB) SaveClient(client model.Client) error { + clientPath := path.Join(path.Join(o.dbPath, "clients"), client.ID+".json") + output := o.conn.Write("clients", client.ID, client) + if output == nil { + if client.Enabled && len(client.TgUserid) > 0 { + if userid, err := strconv.ParseInt(client.TgUserid, 10, 64); err == nil { + util.UpdateTgToClientID(userid, client.ID) + } + } else { + util.RemoveTgToClientID(client.ID) + } + } else { + util.RemoveTgToClientID(client.ID) + } + err := util.ManagePerms(clientPath) + if err != nil { + return err + } + return output +} + +func (o *JsonDB) DeleteClient(clientID string) error { + util.RemoveTgToClientID(clientID) + return o.conn.Delete("clients", clientID) +} + +func (o *JsonDB) SaveServerInterface(serverInterface model.ServerInterface) error { + serverInterfacePath := path.Join(path.Join(o.dbPath, "server"), "interfaces.json") + output := o.conn.Write("server", "interfaces", serverInterface) + err := util.ManagePerms(serverInterfacePath) + if err != nil { + return err + } + return output +} + +func (o *JsonDB) SaveServerKeyPair(serverKeyPair model.ServerKeypair) error { + serverKeyPairPath := path.Join(path.Join(o.dbPath, "server"), "keypair.json") + output := o.conn.Write("server", "keypair", serverKeyPair) + err := util.ManagePerms(serverKeyPairPath) + if err != nil { + return err + } + return output +} + +func (o *JsonDB) SaveGlobalSettings(globalSettings model.GlobalSetting) error { + globalSettingsPath := path.Join(path.Join(o.dbPath, "server"), "global_settings.json") + output := o.conn.Write("server", "global_settings", globalSettings) + err := util.ManagePerms(globalSettingsPath) + if err != nil { + return err + } + return output +} + +func (o *JsonDB) GetPath() string { + return o.dbPath +} + +func (o *JsonDB) GetHashes() (model.ClientServerHashes, error) { + hashes := model.ClientServerHashes{} + return hashes, o.conn.Read("server", "hashes", &hashes) +} + +func (o *JsonDB) SaveHashes(hashes model.ClientServerHashes) error { + hashesPath := path.Join(path.Join(o.dbPath, "server"), "hashes.json") + output := o.conn.Write("server", "hashes", hashes) + err := util.ManagePerms(hashesPath) + if err != nil { + return err + } + return output +} diff --git a/store/jsondb/jsondb_wake_on_lan.go b/store/jsondb/jsondb_wake_on_lan.go new file mode 100644 index 0000000..d210d61 --- /dev/null +++ b/store/jsondb/jsondb_wake_on_lan.go @@ -0,0 +1,88 @@ +package jsondb + +import ( + "encoding/json" + "fmt" + "path" + + "github.com/ngoduykhanh/wireguard-ui/model" + "github.com/ngoduykhanh/wireguard-ui/util" +) + +func (o *JsonDB) GetWakeOnLanHosts() ([]model.WakeOnLanHost, error) { + var hosts []model.WakeOnLanHost + + // read all client json file in "hosts" directory + records, err := o.conn.ReadAll(model.WakeOnLanHostCollectionName) + if err != nil { + return hosts, err + } + + // build the ClientData list + for _, f := range records { + host := model.WakeOnLanHost{} + + // get client info + if err := json.Unmarshal(f, &host); err != nil { + return hosts, fmt.Errorf("cannot decode client json structure: %v", err) + } + + // create the list of hosts and their qrcode data + hosts = append(hosts, host) + } + + return hosts, nil +} + +func (o *JsonDB) GetWakeOnLanHost(macAddress string) (*model.WakeOnLanHost, error) { + host := &model.WakeOnLanHost{ + MacAddress: macAddress, + } + resourceName, err := host.ResolveResourceName() + if err != nil { + return nil, err + } + + err = o.conn.Read(model.WakeOnLanHostCollectionName, resourceName, host) + if err != nil { + host = nil + } + return host, err +} + +func (o *JsonDB) DeleteWakeOnHostLanHost(macAddress string) error { + host := &model.WakeOnLanHost{ + MacAddress: macAddress, + } + resourceName, err := host.ResolveResourceName() + if err != nil { + return err + } + + return o.conn.Delete(model.WakeOnLanHostCollectionName, resourceName) +} + +func (o *JsonDB) SaveWakeOnLanHost(host model.WakeOnLanHost) error { + resourceName, err := host.ResolveResourceName() + if err != nil { + return err + } + + wakeOnLanHostPath := path.Join(path.Join(o.dbPath, model.WakeOnLanHostCollectionName), resourceName+".json") + output := o.conn.Write(model.WakeOnLanHostCollectionName, resourceName, host) + err = util.ManagePerms(wakeOnLanHostPath) + if err != nil { + return err + } + + return output +} + +func (o *JsonDB) DeleteWakeOnHost(host model.WakeOnLanHost) error { + resourceName, err := host.ResolveResourceName() + if err != nil { + return err + } + + return o.conn.Delete(model.WakeOnLanHostCollectionName, resourceName) +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 0000000..ef6d723 --- /dev/null +++ b/store/store.go @@ -0,0 +1,30 @@ +package store + +import ( + "github.com/ngoduykhanh/wireguard-ui/model" +) + +type IStore interface { + Init() error + GetUsers() ([]model.User, error) + GetUserByName(username string) (model.User, error) + SaveUser(user model.User) error + DeleteUser(username string) error + GetGlobalSettings() (model.GlobalSetting, error) + GetServer() (model.Server, error) + GetClients(hasQRCode bool) ([]model.ClientData, error) + GetClientByID(clientID string, qrCode model.QRCodeSettings) (model.ClientData, error) + SaveClient(client model.Client) error + DeleteClient(clientID string) error + SaveServerInterface(serverInterface model.ServerInterface) error + SaveServerKeyPair(serverKeyPair model.ServerKeypair) error + SaveGlobalSettings(globalSettings model.GlobalSetting) error + GetWakeOnLanHosts() ([]model.WakeOnLanHost, error) + GetWakeOnLanHost(macAddress string) (*model.WakeOnLanHost, error) + DeleteWakeOnHostLanHost(macAddress string) error + SaveWakeOnLanHost(host model.WakeOnLanHost) error + DeleteWakeOnHost(host model.WakeOnLanHost) error + GetPath() string + SaveHashes(hashes model.ClientServerHashes) error + GetHashes() (model.ClientServerHashes, error) +} diff --git a/systemd/wireguard-ui-multi.service b/systemd/wireguard-ui-multi.service deleted file mode 100644 index 899ed4d..0000000 --- a/systemd/wireguard-ui-multi.service +++ /dev/null @@ -1,22 +0,0 @@ -[Unit] -Description=wireguard-ui-multi - native multi-server WireGuard management UI -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -# Runs as root because it shells out to wg-quick, systemctl and nft, which -# require CAP_NET_ADMIN (and in practice broad privileges for systemctl unit -# management). AmbientCapabilities is set as defense-in-depth in case this -# unit is ever adapted to run as a non-root user with File capabilities on -# the binary instead. -User=root -Group=root -AmbientCapabilities=CAP_NET_ADMIN -ExecStart=/usr/local/bin/wireguard-ui-multi --db /var/lib/wireguard-ui-multi/wireguard-ui-multi.db --config-dir /etc/wireguard --hooks-dir /etc/wireguard-manager/hooks -Restart=on-failure -RestartSec=5 -WorkingDirectory=/var/lib/wireguard-ui-multi - -[Install] -WantedBy=multi-user.target diff --git a/telegram/bot.go b/telegram/bot.go new file mode 100644 index 0000000..7842f63 --- /dev/null +++ b/telegram/bot.go @@ -0,0 +1,161 @@ +package telegram + +import ( + "fmt" + "sync" + "time" + + "github.com/NicoNex/echotron/v3" + "github.com/labstack/gommon/log" + "github.com/ngoduykhanh/wireguard-ui/store" +) + +type SendRequestedConfigsToTelegram func(db store.IStore, userid int64) []string + +type TgBotInitDependencies struct { + DB store.IStore + SendRequestedConfigsToTelegram SendRequestedConfigsToTelegram +} + +var ( + Token string + AllowConfRequest bool + FloodWait int + LogLevel log.Lvl + + Bot *echotron.API + BotMutex sync.RWMutex + + floodWait = make(map[int64]int64) + floodMessageSent = make(map[int64]struct{}) +) + +func Start(initDeps TgBotInitDependencies) (err error) { + ticker := time.NewTicker(time.Minute) + defer func() { + if err != nil { + BotMutex.Lock() + Bot = nil + BotMutex.Unlock() + ticker.Stop() + } + if r := recover(); r != nil { + err = fmt.Errorf("[PANIC] recovered from panic: %v", r) + } + }() + + token := Token + if token == "" || len(token) < 30 { + return + } + + bot := echotron.NewAPI(token) + + res, err := bot.GetMe() + if !res.Ok || err != nil { + log.Warnf("[Telegram] Unable to connect to bot.\n%v\n%v", res.Description, err) + return + } + + BotMutex.Lock() + Bot = &bot + BotMutex.Unlock() + + if LogLevel <= log.INFO { + fmt.Printf("[Telegram] Authorized as %s\n", res.Result.Username) + } + + go func() { + for range ticker.C { + updateFloodWait() + } + }() + + if !AllowConfRequest { + return + } + + updatesChan := echotron.PollingUpdatesOptions(token, false, echotron.UpdateOptions{AllowedUpdates: []echotron.UpdateType{echotron.MessageUpdate}}) + for update := range updatesChan { + if update.Message != nil { + userid := update.Message.Chat.ID + if _, wait := floodWait[userid]; wait { + if _, notified := floodMessageSent[userid]; notified { + continue + } + floodMessageSent[userid] = struct{}{} + _, err := bot.SendMessage( + fmt.Sprintf("You can only request your configs once per %d minutes", FloodWait), + userid, + &echotron.MessageOptions{ + ReplyToMessageID: update.Message.ID, + }) + if err != nil { + log.Errorf("Failed to send telegram message. Error %v", err) + } + continue + } + floodWait[userid] = time.Now().Unix() + + failed := initDeps.SendRequestedConfigsToTelegram(initDeps.DB, userid) + if len(failed) > 0 { + messageText := "Failed to send configs:\n" + for _, f := range failed { + messageText += f + "\n" + } + _, err := bot.SendMessage( + messageText, + userid, + &echotron.MessageOptions{ + ReplyToMessageID: update.Message.ID, + }) + if err != nil { + log.Errorf("Failed to send telegram message. Error %v", err) + } + } + } + } + return err +} + +func SendConfig(userid int64, clientName string, confData, qrData []byte, ignoreFloodWait bool) error { + BotMutex.RLock() + defer BotMutex.RUnlock() + + if Bot == nil { + return fmt.Errorf("telegram bot is not configured or not available") + } + + if _, wait := floodWait[userid]; wait && !ignoreFloodWait { + return fmt.Errorf("this client already got their config less than %d minutes ago", FloodWait) + } + + if !ignoreFloodWait { + floodWait[userid] = time.Now().Unix() + } + + qrAttachment := echotron.NewInputFileBytes("qr.png", qrData) + _, err := Bot.SendPhoto(qrAttachment, userid, &echotron.PhotoOptions{Caption: clientName}) + if err != nil { + log.Error(err) + return fmt.Errorf("unable to send qr picture") + } + + confAttachment := echotron.NewInputFileBytes(clientName+".conf", confData) + _, err = Bot.SendDocument(confAttachment, userid, nil) + if err != nil { + log.Error(err) + return fmt.Errorf("unable to send conf file") + } + return nil +} + +func updateFloodWait() { + thresholdTS := time.Now().Unix() - 60*int64(FloodWait) + for userid, ts := range floodWait { + if ts < thresholdTS { + delete(floodWait, userid) + delete(floodMessageSent, userid) + } + } +} diff --git a/templates/about.html b/templates/about.html new file mode 100644 index 0000000..edbeb47 --- /dev/null +++ b/templates/about.html @@ -0,0 +1,145 @@ +{{ define "title"}} +About +{{ end }} + +{{ define "top_css"}} +{{ end }} + +{{ define "username"}} +{{ .username }} +{{ end }} + +{{ define "page_title"}} +About +{{ end }} + +{{ define "page_content"}} +
+
+ +
+ +
+
+
+

About Wireguard-UI

+
+ +
+
+ + +
+{{ if .gitCommit }} +
+ + +
+{{ end }} +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+ +
+
+ Copyright © + + Wireguard UI. + All rights reserved. + +
+
+ +
+
+ +
+
+{{ end }} + +{{ define "bottom_js"}} + +{{ end }} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..3640699 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,674 @@ +{{define "base.html"}} + + + + + + + {{template "title" .}} + + + + + + + + + + + + + + + + + + + + + + + + {{template "top_css" .}} + + + + + +
+ + + + + + + + + + + + + + +
+ +
+
+
+
+

{{template "page_title" .}}

+
+
+
+
+ + + {{template "page_content" .}} + +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + {{template "bottom_js" .}} + + + + +{{end}} diff --git a/templates/clients.html b/templates/clients.html new file mode 100644 index 0000000..0ab8733 --- /dev/null +++ b/templates/clients.html @@ -0,0 +1,964 @@ +{{define "title"}} +Wireguard Clients +{{end}} + +{{define "top_css"}} + +{{end}} + +{{define "username"}} +{{ .username }} +{{end}} + +{{define "page_title"}} +Wireguard Clients +{{end}} + +{{define "page_content"}} +
+
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + + +{{end}} + +{{define "bottom_js"}} + + +{{end}} diff --git a/templates/global_settings.html b/templates/global_settings.html new file mode 100644 index 0000000..73b3c93 --- /dev/null +++ b/templates/global_settings.html @@ -0,0 +1,284 @@ +{{define "title"}} +Global Settings +{{end}} + +{{define "top_css"}} +{{end}} + +{{define "username"}} +{{ .username }} +{{end}} + +{{define "page_title"}} +Global Settings +{{end}} + +{{define "page_content"}} +
+
+ +
+ +
+
+
+

Wireguard Global Settings

+
+ + +
+
+
+ +
+ + + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ +
+
+
+
+

Help

+
+ +
+
+
1. Endpoint Address
+
The public IP address of your Wireguard server that the client will connect to. Click on + Suggest button to auto detect the public IP address of your server.
+
2. DNS Servers
+
The DNS servers will be set to client config.
+
3. MTU
+
The MTU will be set to server and client config. By default it is 1450. You might want + to adjust the MTU size if your connection (e.g PPPoE, 3G, satellite network, etc) has a low MTU.
+
Leave blank to omit this setting in the configs.
+
4. Persistent Keepalive
+
By default, WireGuard peers remain silent while they do not need to communicate, + so peers located behind a NAT and/or firewall may be unreachable from other peers + until they reach out to other peers themselves. Adding PersistentKeepalive + can ensure that the connection remains open.
+
Leave blank to omit this setting in the Client config.
+
5. Firewall Mark
+
Add a matching fwmark on all packets going out of a WireGuard non-default-route tunnel. Default value: 0xca6c
+
6. Table
+
Value for the Table setting in the wg conf file. Default value: auto
+
7. Wireguard Config File Path
+
The path of your Wireguard server config file. Please make sure the parent directory + exists and is writable.
+
+
+
+ +
+
+ +
+
+ + + +{{end}} + +{{define "bottom_js"}} + + +{{end}} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..c0a96b9 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,130 @@ + + + + + + + WireGuard UI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..fa80157 --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,136 @@ +{{ define "title"}} +Profile +{{ end }} + +{{ define "top_css"}} +{{ end }} + +{{ define "username"}} +{{ .username }} +{{ end }} + +{{ define "page_title"}} +Profile +{{ end }} + +{{ define "page_content"}} +
+
+ +
+ +
+
+
+

Update user information

+
+ + +
+
+
+ + +
+
+ + +
+ + +
+
+
+ +
+
+ +
+
+{{ end }} + +{{ define "bottom_js"}} + +{{ end }} diff --git a/templates/server.html b/templates/server.html new file mode 100644 index 0000000..e1116a6 --- /dev/null +++ b/templates/server.html @@ -0,0 +1,255 @@ +{{define "title"}} +Wireguard Server +{{end}} + +{{define "top_css"}} +{{end}} + +{{define "username"}} +{{ .username }} +{{end}} + +{{define "page_title"}} +Wireguard Server Settings +{{end}} + +{{define "page_content"}} +
+
+ +
+ +
+
+
+

Interface

+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + + +
+
+ +
+ +
+
+
+

Key Pair

+
+ + +
+
+
+ +
+ + + + +
+
+
+ + +
+
+ + + +
+
+ +
+
+ +
+
+ + + +{{end}} + +{{define "bottom_js"}} + + +{{end}} diff --git a/templates/status.html b/templates/status.html new file mode 100644 index 0000000..a9b770b --- /dev/null +++ b/templates/status.html @@ -0,0 +1,75 @@ +{{define "title"}} +Connected Peers +{{end}} + +{{define "top_css"}} +{{end}} + +{{define "username"}} +{{ .username }} +{{end}} + +{{define "page_title"}} +Connected Peers +{{end}} + +{{define "page_content"}} + +
+
+ {{ if .error }} + + {{ end}} + {{ range $dev := .devices }} + + + + + + + + + + + + + + + + + + {{ range $idx, $peer := $dev.Peers }} + + + + + + + + + + + + + {{ end }} + +
List of connected peers for device with name {{ $dev.Name }}
#NameEmailAllocated IPsEndpointPublic KeyReceivedTransmittedConnected (Approximation)Last Handshake
{{ $idx }}{{ $peer.Name }}{{ $peer.Email }}{{ $peer.AllocatedIP }}{{ $peer.Endpoint }}{{ $peer.PublicKey }}{{ if $peer.Connected }}✓{{end}}{{ $peer.LastHandshakeTime.Format "2006-01-02 15:04:05 MST" }}
+ {{ end }} +
+
+{{end}} +{{define "bottom_js"}} +{{end}} diff --git a/templates/users_settings.html b/templates/users_settings.html new file mode 100644 index 0000000..11a8ef8 --- /dev/null +++ b/templates/users_settings.html @@ -0,0 +1,294 @@ +{{define "title"}} +Users Settings +{{end}} + +{{define "top_css"}} +{{end}} + +{{define "username"}} +{{ .username }} +{{end}} + +{{define "page_title"}} +Users Settings +{{end}} + +{{define "page_content"}} +
+
+
+
+
+
+ + + + + + +{{end}} + +{{define "bottom_js"}} + + +{{end}} diff --git a/templates/wake_on_lan_hosts.html b/templates/wake_on_lan_hosts.html new file mode 100644 index 0000000..80ba3f6 --- /dev/null +++ b/templates/wake_on_lan_hosts.html @@ -0,0 +1,123 @@ +{{define "title"}} + Wake On Lan Hosts +{{end}} + +{{define "top_css"}} +{{end}} + +{{define "username"}} + {{ .username }} +{{end}} + +{{define "page_title"}} + Wake On Lan Hosts +{{end}} + +{{define "page_content"}} + + + + + +
+
+ {{ if .error }} + + {{ end}} + +
+ {{ range $idx, $host := .hosts }} + {{- /*gotype: github.com/ngoduykhanh/wireguard-ui/model.WakeOnLanHost*/ -}} +
+
+
+
+ + + +
+
+ {{ .Name }} + {{ .MacAddress }} + + + {{ if .LatestUsed }} + {{ .LatestUsed.Format "2006-01-02T15:04:05Z07:00"}} + {{ else }} + Unused + {{ end }} + + +
+
+
+ {{ end }} +
+
+
+ +{{end}} +{{define "bottom_js"}} + +{{end}} \ No newline at end of file diff --git a/templates/wg.conf b/templates/wg.conf new file mode 100644 index 0000000..34891f0 --- /dev/null +++ b/templates/wg.conf @@ -0,0 +1,33 @@ +# This file was generated using wireguard-ui (https://github.com/ngoduykhanh/wireguard-ui) +# Please don't modify it manually, otherwise your change might get replaced. + +# Address updated at: {{ .serverConfig.Interface.UpdatedAt }} +# Private Key updated at: {{ .serverConfig.KeyPair.UpdatedAt }} +[Interface] +Address = {{$first :=true}}{{range .serverConfig.Interface.Addresses }}{{if $first}}{{$first = false}}{{else}},{{end}}{{.}}{{end}} +ListenPort = {{ .serverConfig.Interface.ListenPort }} +PrivateKey = {{ .serverConfig.KeyPair.PrivateKey }} +{{if .globalSettings.MTU}}MTU = {{ .globalSettings.MTU }}{{end}} +PostUp = {{ .serverConfig.Interface.PostUp }} +PreDown = {{ .serverConfig.Interface.PreDown }} +PostDown = {{ .serverConfig.Interface.PostDown }} +Table = {{ .globalSettings.Table }} + +{{range .clientDataList}}{{if eq .Client.Enabled true}} +# ID: {{ .Client.ID }} +# Name: {{ .Client.Name }} +# Email: {{ .Client.Email }} +# Telegram: {{ .Client.TgUserid }} +# Created at: {{ .Client.CreatedAt }} +# Update at: {{ .Client.UpdatedAt }} +{{- if .Client.AdditionalNotes}} + +# Notes: +# {{ .Client.AdditionalNotes }}{{end}} +[Peer] +PublicKey = {{ .Client.PublicKey }} +{{if .Client.PresharedKey}}PresharedKey = {{ .Client.PresharedKey }}{{end}} +AllowedIPs = {{$first :=true}}{{range .Client.AllocatedIPs }}{{if $first}}{{$first = false}}{{else}},{{end}}{{.}}{{end}}{{range .Client.ExtraAllowedIPs }},{{.}}{{end}} +{{if $.globalSettings.PersistentKeepalive}}PersistentKeepalive = {{ $.globalSettings.PersistentKeepalive }}{{end}} +{{if .Client.Endpoint}}Endpoint = {{ .Client.Endpoint }}{{end}} +{{end}}{{end}} diff --git a/util/cache.go b/util/cache.go new file mode 100644 index 0000000..48b37ea --- /dev/null +++ b/util/cache.go @@ -0,0 +1,8 @@ +package util + +import "sync" + +var IPToSubnetRange = map[string]uint16{} +var TgUseridToClientID = map[int64][]string{} +var TgUseridToClientIDMutex sync.RWMutex +var DBUsersToCRC32 = map[string]uint32{} diff --git a/util/config.go b/util/config.go new file mode 100644 index 0000000..4af6bd2 --- /dev/null +++ b/util/config.go @@ -0,0 +1,119 @@ +package util + +import ( + "net" + "strings" + + "github.com/labstack/gommon/log" +) + +// Runtime config +var ( + DisableLogin bool + BindAddress string + SmtpHostname string + SmtpPort int + SmtpUsername string + SmtpPassword string + SmtpNoTLSCheck bool + SmtpEncryption string + SmtpAuthType string + SmtpHelo string + SendgridApiKey string + EmailFrom string + EmailFromName string + SessionSecret [64]byte + SessionMaxDuration int64 + WgConfTemplate string + BasePath string + SubnetRanges map[string]([]*net.IPNet) + SubnetRangesOrder []string +) + +const ( + DefaultUsername = "admin" + DefaultPassword = "admin" + DefaultIsAdmin = true + DefaultServerAddress = "10.252.1.0/24" + DefaultServerPort = 51820 + DefaultDNS = "1.1.1.1" + DefaultMTU = 1450 + DefaultPersistentKeepalive = 15 + DefaultFirewallMark = "0xca6c" // i.e. 51820 + DefaultTable = "auto" + DefaultConfigFilePath = "/etc/wireguard/wg0.conf" + UsernameEnvVar = "WGUI_USERNAME" + PasswordEnvVar = "WGUI_PASSWORD" + PasswordFileEnvVar = "WGUI_PASSWORD_FILE" + PasswordHashEnvVar = "WGUI_PASSWORD_HASH" + PasswordHashFileEnvVar = "WGUI_PASSWORD_HASH_FILE" + FaviconFilePathEnvVar = "WGUI_FAVICON_FILE_PATH" + EndpointAddressEnvVar = "WGUI_ENDPOINT_ADDRESS" + DNSEnvVar = "WGUI_DNS" + MTUEnvVar = "WGUI_MTU" + PersistentKeepaliveEnvVar = "WGUI_PERSISTENT_KEEPALIVE" + FirewallMarkEnvVar = "WGUI_FIREWALL_MARK" + TableEnvVar = "WGUI_TABLE" + ConfigFilePathEnvVar = "WGUI_CONFIG_FILE_PATH" + LogLevel = "WGUI_LOG_LEVEL" + ServerAddressesEnvVar = "WGUI_SERVER_INTERFACE_ADDRESSES" + ServerListenPortEnvVar = "WGUI_SERVER_LISTEN_PORT" + ServerPostUpScriptEnvVar = "WGUI_SERVER_POST_UP_SCRIPT" + ServerPostDownScriptEnvVar = "WGUI_SERVER_POST_DOWN_SCRIPT" + DefaultClientAllowedIpsEnvVar = "WGUI_DEFAULT_CLIENT_ALLOWED_IPS" + DefaultClientExtraAllowedIpsEnvVar = "WGUI_DEFAULT_CLIENT_EXTRA_ALLOWED_IPS" + DefaultClientUseServerDNSEnvVar = "WGUI_DEFAULT_CLIENT_USE_SERVER_DNS" + DefaultClientEnableAfterCreationEnvVar = "WGUI_DEFAULT_CLIENT_ENABLE_AFTER_CREATION" +) + +func ParseBasePath(basePath string) string { + if !strings.HasPrefix(basePath, "/") { + basePath = "/" + basePath + } + if strings.HasSuffix(basePath, "/") { + basePath = strings.TrimSuffix(basePath, "/") + } + return basePath +} + +func ParseSubnetRanges(subnetRangesStr string) map[string]([]*net.IPNet) { + subnetRanges := map[string]([]*net.IPNet){} + if subnetRangesStr == "" { + return subnetRanges + } + cidrSet := map[string]bool{} + subnetRangesStr = strings.TrimSpace(subnetRangesStr) + subnetRangesStr = strings.Trim(subnetRangesStr, ";:,") + ranges := strings.Split(subnetRangesStr, ";") + for _, rng := range ranges { + rng = strings.TrimSpace(rng) + rngSpl := strings.Split(rng, ":") + if len(rngSpl) != 2 { + log.Warnf("Unable to parse subnet range: %v. Skipped.", rng) + continue + } + rngName := strings.TrimSpace(rngSpl[0]) + subnetRanges[rngName] = make([]*net.IPNet, 0) + cidrs := strings.Split(rngSpl[1], ",") + for _, cidr := range cidrs { + cidr = strings.TrimSpace(cidr) + _, net, err := net.ParseCIDR(cidr) + if err != nil { + log.Warnf("[%v] Unable to parse CIDR: %v. Skipped.", rngName, cidr) + continue + } + if cidrSet[net.String()] { + log.Warnf("[%v] CIDR already exists: %v. Skipped.", rngName, net.String()) + continue + } + cidrSet[net.String()] = true + subnetRanges[rngName] = append(subnetRanges[rngName], net) + } + if len(subnetRanges[rngName]) == 0 { + delete(subnetRanges, rngName) + } else { + SubnetRangesOrder = append(SubnetRangesOrder, rngName) + } + } + return subnetRanges +} diff --git a/util/hash.go b/util/hash.go new file mode 100644 index 0000000..3733451 --- /dev/null +++ b/util/hash.go @@ -0,0 +1,32 @@ +package util + +import ( + "encoding/base64" + "errors" + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +func HashPassword(plaintext string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(plaintext), 14) + if err != nil { + return "", fmt.Errorf("cannot hash password: %w", err) + } + return base64.StdEncoding.EncodeToString(bytes), nil +} + +func VerifyHash(base64Hash string, plaintext string) (bool, error) { + hash, err := base64.StdEncoding.DecodeString(base64Hash) + if err != nil { + return false, fmt.Errorf("cannot decode base64 hash: %w", err) + } + err = bcrypt.CompareHashAndPassword(hash, []byte(plaintext)) + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("cannot verify password: %w", err) + } + return true, nil +} diff --git a/util/util.go b/util/util.go new file mode 100644 index 0000000..ec700ff --- /dev/null +++ b/util/util.go @@ -0,0 +1,876 @@ +package util + +import ( + "bufio" + "bytes" + "encoding/gob" + "encoding/json" + "errors" + "fmt" + "hash/crc32" + "io" + "io/fs" + "math/rand" + "net" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "text/template" + "time" + + "github.com/ngoduykhanh/wireguard-ui/store" + "github.com/ngoduykhanh/wireguard-ui/telegram" + "github.com/skip2/go-qrcode" + "golang.org/x/mod/sumdb/dirhash" + + externalip "github.com/glendc/go-external-ip" + "github.com/labstack/gommon/log" + "github.com/ngoduykhanh/wireguard-ui/model" + "github.com/sdomino/scribble" +) + +var qrCodeSettings = model.QRCodeSettings{ + Enabled: true, + IncludeDNS: true, + IncludeMTU: true, +} + +// BuildClientConfig to create wireguard client config string +func BuildClientConfig(client model.Client, server model.Server, setting model.GlobalSetting) string { + // Interface section + clientAddress := fmt.Sprintf("Address = %s\n", strings.Join(client.AllocatedIPs, ",")) + clientPrivateKey := fmt.Sprintf("PrivateKey = %s\n", client.PrivateKey) + clientDNS := "" + if client.UseServerDNS { + clientDNS = fmt.Sprintf("DNS = %s\n", strings.Join(setting.DNSServers, ",")) + } + clientMTU := "" + if setting.MTU > 0 { + clientMTU = fmt.Sprintf("MTU = %d\n", setting.MTU) + } + + // Peer section + peerPublicKey := fmt.Sprintf("PublicKey = %s\n", server.KeyPair.PublicKey) + peerPresharedKey := "" + if client.PresharedKey != "" { + peerPresharedKey = fmt.Sprintf("PresharedKey = %s\n", client.PresharedKey) + } + + peerAllowedIPs := fmt.Sprintf("AllowedIPs = %s\n", strings.Join(client.AllowedIPs, ",")) + + desiredHost := setting.EndpointAddress + desiredPort := server.Interface.ListenPort + if strings.Contains(desiredHost, ":") { + split := strings.Split(desiredHost, ":") + desiredHost = split[0] + if n, err := strconv.Atoi(split[1]); err == nil { + desiredPort = n + } else { + log.Error("Endpoint appears to be incorrectly formatted: ", err) + } + } + peerEndpoint := fmt.Sprintf("Endpoint = %s:%d\n", desiredHost, desiredPort) + + peerPersistentKeepalive := "" + if setting.PersistentKeepalive > 0 { + peerPersistentKeepalive = fmt.Sprintf("PersistentKeepalive = %d\n", setting.PersistentKeepalive) + } + + // build the config as string + strConfig := "[Interface]\n" + + clientAddress + + clientPrivateKey + + clientDNS + + clientMTU + + "\n[Peer]\n" + + peerPublicKey + + peerPresharedKey + + peerAllowedIPs + + peerEndpoint + + peerPersistentKeepalive + + return strConfig +} + +// ClientDefaultsFromEnv to read the default values for creating a new client from the environment or use sane defaults +func ClientDefaultsFromEnv() model.ClientDefaults { + clientDefaults := model.ClientDefaults{} + clientDefaults.AllowedIps = LookupEnvOrStrings(DefaultClientAllowedIpsEnvVar, []string{"0.0.0.0/0"}) + clientDefaults.ExtraAllowedIps = LookupEnvOrStrings(DefaultClientExtraAllowedIpsEnvVar, []string{}) + clientDefaults.UseServerDNS = LookupEnvOrBool(DefaultClientUseServerDNSEnvVar, true) + clientDefaults.EnableAfterCreation = LookupEnvOrBool(DefaultClientEnableAfterCreationEnvVar, true) + + return clientDefaults +} + +// ContainsCIDR to check if ipnet1 contains ipnet2 +// https://stackoverflow.com/a/40406619/6111641 +// https://go.dev/play/p/Q4J-JEN3sF +func ContainsCIDR(ipnet1, ipnet2 *net.IPNet) bool { + ones1, _ := ipnet1.Mask.Size() + ones2, _ := ipnet2.Mask.Size() + return ones1 <= ones2 && ipnet1.Contains(ipnet2.IP) +} + +// ValidateCIDR to validate a network CIDR +func ValidateCIDR(cidr string) bool { + _, _, err := net.ParseCIDR(cidr) + if err != nil { + return false + } + return true +} + +// ValidateCIDRList to validate a list of network CIDR +func ValidateCIDRList(cidrs []string, allowEmpty bool) bool { + for _, cidr := range cidrs { + if allowEmpty { + if len(cidr) > 0 { + if ValidateCIDR(cidr) == false { + return false + } + } + } else { + if ValidateCIDR(cidr) == false { + return false + } + } + } + return true +} + +// ValidateAllowedIPs to validate allowed ip addresses in CIDR format +func ValidateAllowedIPs(cidrs []string) bool { + if ValidateCIDRList(cidrs, false) == false { + return false + } + return true +} + +// ValidateExtraAllowedIPs to validate extra Allowed ip addresses, allowing empty strings +func ValidateExtraAllowedIPs(cidrs []string) bool { + if ValidateCIDRList(cidrs, true) == false { + return false + } + return true +} + +// ValidateServerAddresses to validate allowed ip addresses in CIDR format +func ValidateServerAddresses(cidrs []string) bool { + if ValidateCIDRList(cidrs, false) == false { + return false + } + return true +} + +// ValidateIPAddress to validate the IPv4 and IPv6 address +func ValidateIPAddress(ip string) bool { + if net.ParseIP(ip) == nil { + return false + } + return true +} + +// ValidateIPAddressList to validate a list of IPv4 and IPv6 addresses +func ValidateIPAddressList(ips []string) bool { + for _, ip := range ips { + if ValidateIPAddress(ip) == false { + return false + } + } + return true +} + +// GetInterfaceIPs to get local machine's interface ip addresses +func GetInterfaceIPs() ([]model.Interface, error) { + // get machine's interfaces + ifaces, err := net.Interfaces() + if err != nil { + return nil, err + } + + var interfaceList []model.Interface + + // get interface's ip addresses + for _, i := range ifaces { + addrs, err := i.Addrs() + if err != nil { + return nil, err + } + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + if ip == nil || ip.IsLoopback() { + continue + } + ip = ip.To4() + if ip == nil { + continue + } + + iface := model.Interface{} + iface.Name = i.Name + iface.IPAddress = ip.String() + interfaceList = append(interfaceList, iface) + } + } + return interfaceList, err +} + +// GetPublicIP to get machine's public ip address +func GetPublicIP() (model.Interface, error) { + // set time out to 5 seconds + cfg := externalip.ConsensusConfig{} + cfg.Timeout = time.Second * 5 + consensus := externalip.NewConsensus(&cfg, nil) + + // add trusted voters + consensus.AddVoter(externalip.NewHTTPSource("https://checkip.amazonaws.com/"), 1) + consensus.AddVoter(externalip.NewHTTPSource("http://whatismyip.akamai.com"), 1) + consensus.AddVoter(externalip.NewHTTPSource("https://ifconfig.top"), 1) + + publicInterface := model.Interface{} + publicInterface.Name = "Public Address" + + ip, err := consensus.ExternalIP() + if err != nil { + publicInterface.IPAddress = "N/A" + } else { + publicInterface.IPAddress = ip.String() + } + + // error handling happened above, no need to pass it through + return publicInterface, nil +} + +// GetIPFromCIDR get ip from CIDR +func GetIPFromCIDR(cidr string) (string, error) { + ip, _, err := net.ParseCIDR(cidr) + if err != nil { + return "", err + } + return ip.String(), nil +} + +// GetAllocatedIPs to get all ip addresses allocated to clients and server +func GetAllocatedIPs(ignoreClientID string) ([]string, error) { + allocatedIPs := make([]string, 0) + + // initialize database directory + dir := "./db" + db, err := scribble.New(dir, nil) + if err != nil { + return nil, err + } + + // read server information + serverInterface := model.ServerInterface{} + if err := db.Read("server", "interfaces", &serverInterface); err != nil { + return nil, err + } + + // append server's addresses to the result + for _, cidr := range serverInterface.Addresses { + ip, err := GetIPFromCIDR(cidr) + if err != nil { + return nil, err + } + allocatedIPs = append(allocatedIPs, ip) + } + + // read client information + records, err := db.ReadAll("clients") + if err != nil { + return nil, err + } + + // append client's addresses to the result + for _, f := range records { + client := model.Client{} + if err := json.Unmarshal(f, &client); err != nil { + return nil, err + } + + if client.ID != ignoreClientID { + for _, cidr := range client.AllocatedIPs { + ip, err := GetIPFromCIDR(cidr) + if err != nil { + return nil, err + } + allocatedIPs = append(allocatedIPs, ip) + } + } + } + + return allocatedIPs, nil +} + +// inc from https://play.golang.org/p/m8TNTtygK0 +func inc(ip net.IP) { + for j := len(ip) - 1; j >= 0; j-- { + ip[j]++ + if ip[j] > 0 { + break + } + } +} + +// GetBroadcastIP func to get the broadcast ip address of a network +func GetBroadcastIP(n *net.IPNet) net.IP { + 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 +} + +// GetBroadcastAndNetworkAddrsLookup get the ip address that can't be used with current server interfaces +func GetBroadcastAndNetworkAddrsLookup(interfaceAddresses []string) map[string]bool { + list := make(map[string]bool) + for _, ifa := range interfaceAddresses { + _, netAddr, err := net.ParseCIDR(ifa) + if err != nil { + continue + } + + broadcastAddr := GetBroadcastIP(netAddr).String() + networkAddr := netAddr.IP.String() + list[broadcastAddr] = true + list[networkAddr] = true + } + return list +} + +// GetAvailableIP get the ip address that can be allocated from an CIDR +// We need interfaceAddresses to find real broadcast and network addresses +func GetAvailableIP(cidr string, allocatedList, interfaceAddresses []string) (string, error) { + ip, netAddr, err := net.ParseCIDR(cidr) + if err != nil { + return "", err + } + + unavailableIPs := GetBroadcastAndNetworkAddrsLookup(interfaceAddresses) + + for ip := ip.Mask(netAddr.Mask); netAddr.Contains(ip); inc(ip) { + available := true + suggestedAddr := ip.String() + for _, allocatedAddr := range allocatedList { + if suggestedAddr == allocatedAddr { + available = false + break + } + } + if available && !unavailableIPs[suggestedAddr] { + return suggestedAddr, nil + } + } + + return "", errors.New("no more available ip address") +} + +// ValidateIPAllocation to validate the list of client's ip allocation +// They must have a correct format and available in serverAddresses space +func ValidateIPAllocation(serverAddresses []string, ipAllocatedList []string, ipAllocationList []string) (bool, error) { + for _, clientCIDR := range ipAllocationList { + ip, _, _ := net.ParseCIDR(clientCIDR) + + // clientCIDR must be in CIDR format + if ip == nil { + return false, fmt.Errorf("invalid ip allocation input %s. Must be in CIDR format", clientCIDR) + } + + // return false immediately if the ip is already in use (in ipAllocatedList) + for _, item := range ipAllocatedList { + if item == ip.String() { + return false, fmt.Errorf("IP %s already allocated", ip) + } + } + + // even if it is not in use, we still need to check if it + // belongs to a network of the server. + var isValid = false + for _, serverCIDR := range serverAddresses { + _, serverNet, _ := net.ParseCIDR(serverCIDR) + if serverNet.Contains(ip) { + isValid = true + break + } + } + + // current ip allocation is valid, check the next one + if isValid { + continue + } else { + return false, fmt.Errorf("IP %s does not belong to any network addresses of WireGuard server", ip) + } + } + + return true, nil +} + +// findSubnetRangeForIP to find first SR for IP, and cache the match +func findSubnetRangeForIP(cidr string) (uint16, error) { + ip, _, err := net.ParseCIDR(cidr) + if err != nil { + return 0, err + } + + if srName, ok := IPToSubnetRange[ip.String()]; ok { + return srName, nil + } + + for srIndex, sr := range SubnetRangesOrder { + for _, srCIDR := range SubnetRanges[sr] { + if srCIDR.Contains(ip) { + IPToSubnetRange[ip.String()] = uint16(srIndex) + return uint16(srIndex), nil + } + } + } + return 0, fmt.Errorf("subnet range not found for this IP") +} + +// FillClientSubnetRange to fill subnet ranges client belongs to, does nothing if SRs are not found +func FillClientSubnetRange(client model.ClientData) model.ClientData { + cl := *client.Client + for _, ip := range cl.AllocatedIPs { + sr, err := findSubnetRangeForIP(ip) + if err != nil { + continue + } + cl.SubnetRanges = append(cl.SubnetRanges, SubnetRangesOrder[sr]) + } + return model.ClientData{ + Client: &cl, + QRCode: client.QRCode, + } +} + +// ValidateAndFixSubnetRanges to check if subnet ranges are valid for the server configuration +// Removes all non-valid CIDRs +func ValidateAndFixSubnetRanges(db store.IStore) error { + if len(SubnetRangesOrder) == 0 { + return nil + } + + server, err := db.GetServer() + if err != nil { + return err + } + var serverSubnets []*net.IPNet + for _, addr := range server.Interface.Addresses { + addr = strings.TrimSpace(addr) + _, netAddr, err := net.ParseCIDR(addr) + if err != nil { + return err + } + serverSubnets = append(serverSubnets, netAddr) + } + + for _, rng := range SubnetRangesOrder { + cidrs := SubnetRanges[rng] + if len(cidrs) > 0 { + newCIDRs := make([]*net.IPNet, 0) + for _, cidr := range cidrs { + valid := false + + for _, serverSubnet := range serverSubnets { + if ContainsCIDR(serverSubnet, cidr) { + valid = true + break + } + } + + if valid { + newCIDRs = append(newCIDRs, cidr) + } else { + log.Warnf("[%v] CIDR is outside of all server subnets: %v. Removed.", rng, cidr) + } + } + + if len(newCIDRs) > 0 { + SubnetRanges[rng] = newCIDRs + } else { + delete(SubnetRanges, rng) + log.Warnf("[%v] No valid CIDRs in this subnet range. Removed.", rng) + } + } + } + + return nil +} + +// GetSubnetRangesString to get a formatted string, representing active subnet ranges +func GetSubnetRangesString() string { + if len(SubnetRangesOrder) == 0 { + return "" + } + + strB := strings.Builder{} + + for _, rng := range SubnetRangesOrder { + cidrs := SubnetRanges[rng] + if len(cidrs) > 0 { + strB.WriteString(rng) + strB.WriteString(":[") + first := true + for _, cidr := range cidrs { + if !first { + strB.WriteString(", ") + } + strB.WriteString(cidr.String()) + first = false + } + strB.WriteString("] ") + } + } + + return strings.TrimSpace(strB.String()) +} + +// WriteWireGuardServerConfig to write Wireguard server config. e.g. wg0.conf +func WriteWireGuardServerConfig(tmplDir fs.FS, serverConfig model.Server, clientDataList []model.ClientData, usersList []model.User, globalSettings model.GlobalSetting) error { + var tmplWireguardConf string + + // if set, read wg.conf template from WgConfTemplate + if len(WgConfTemplate) > 0 { + fileContentBytes, err := os.ReadFile(WgConfTemplate) + if err != nil { + return err + } + tmplWireguardConf = string(fileContentBytes) + } else { + // read default wg.conf template file to string + fileContent, err := StringFromEmbedFile(tmplDir, "wg.conf") + if err != nil { + return err + } + tmplWireguardConf = fileContent + } + + // escape multiline notes + escapedClientDataList := []model.ClientData{} + for _, cd := range clientDataList { + if cd.Client.AdditionalNotes != "" { + cd.Client.AdditionalNotes = strings.ReplaceAll(cd.Client.AdditionalNotes, "\n", "\n# ") + } + escapedClientDataList = append(escapedClientDataList, cd) + } + + // parse the template + t, err := template.New("wg_config").Parse(tmplWireguardConf) + if err != nil { + return err + } + + // write config file to disk + f, err := os.Create(globalSettings.ConfigFilePath) + if err != nil { + return err + } + + config := map[string]interface{}{ + "serverConfig": serverConfig, + "clientDataList": escapedClientDataList, + "globalSettings": globalSettings, + "usersList": usersList, + } + + err = t.Execute(f, config) + if err != nil { + return err + } + f.Close() + + return nil +} + +// SendRequestedConfigsToTelegram to send client all their configs. Returns failed configs list. +func SendRequestedConfigsToTelegram(db store.IStore, userid int64) []string { + failedList := make([]string, 0) + TgUseridToClientIDMutex.RLock() + if clids, found := TgUseridToClientID[userid]; found && len(clids) > 0 { + TgUseridToClientIDMutex.RUnlock() + + for _, clid := range clids { + clientData, err := db.GetClientByID(clid, qrCodeSettings) + if err != nil { + // return fmt.Errorf("unable to get client") + failedList = append(failedList, clid) + continue + } + + // build config + server, _ := db.GetServer() + globalSettings, _ := db.GetGlobalSettings() + config := BuildClientConfig(*clientData.Client, server, globalSettings) + configData := []byte(config) + var qrData []byte + + if clientData.Client.PrivateKey != "" { + qrData, err = qrcode.Encode(config, qrcode.Medium, 512) + if err != nil { + // return fmt.Errorf("unable to encode qr") + failedList = append(failedList, clientData.Client.Name) + continue + } + } + + userid, err := strconv.ParseInt(clientData.Client.TgUserid, 10, 64) + if err != nil { + // return fmt.Errorf("tg usrid is unreadable") + failedList = append(failedList, clientData.Client.Name) + continue + } + + err = telegram.SendConfig(userid, clientData.Client.Name, configData, qrData, true) + if err != nil { + failedList = append(failedList, clientData.Client.Name) + continue + } + time.Sleep(2 * time.Second) + } + } else { + TgUseridToClientIDMutex.RUnlock() + } + return failedList +} + +func LookupEnvOrString(key string, defaultVal string) string { + if val, ok := os.LookupEnv(key); ok { + return val + } + return defaultVal +} + +func LookupEnvOrBool(key string, defaultVal bool) bool { + if val, ok := os.LookupEnv(key); ok { + v, err := strconv.ParseBool(val) + if err != nil { + fmt.Fprintf(os.Stderr, "LookupEnvOrBool[%s]: %v\n", key, err) + } + return v + } + return defaultVal +} + +func LookupEnvOrInt(key string, defaultVal int) int { + if val, ok := os.LookupEnv(key); ok { + v, err := strconv.Atoi(val) + if err != nil { + fmt.Fprintf(os.Stderr, "LookupEnvOrInt[%s]: %v\n", key, err) + } + return v + } + return defaultVal +} + +func LookupEnvOrStrings(key string, defaultVal []string) []string { + if val, ok := os.LookupEnv(key); ok { + return strings.Split(val, ",") + } + return defaultVal +} + +func LookupEnvOrFile(key string, defaultVal string) string { + if val, ok := os.LookupEnv(key); ok { + if file, err := os.Open(val); err == nil { + var content string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + content += scanner.Text() + } + return content + } + } + return defaultVal +} + +func StringFromEmbedFile(embed fs.FS, filename string) (string, error) { + file, err := embed.Open(filename) + if err != nil { + return "", err + } + content, err := io.ReadAll(file) + if err != nil { + return "", err + } + return string(content), nil +} + +func ParseLogLevel(lvl string) (log.Lvl, error) { + switch strings.ToLower(lvl) { + case "debug": + return log.DEBUG, nil + case "info": + return log.INFO, nil + case "warn": + return log.WARN, nil + case "error": + return log.ERROR, nil + case "off": + return log.OFF, nil + default: + return log.DEBUG, fmt.Errorf("not a valid log level: %s", lvl) + } +} + +// GetCurrentHash returns current hashes +func GetCurrentHash(db store.IStore) (string, string) { + hashClients, _ := dirhash.HashDir(path.Join(db.GetPath(), "clients"), "prefix", dirhash.Hash1) + files := append([]string(nil), "prefix/global_settings.json", "prefix/interfaces.json", "prefix/keypair.json") + + osOpen := func(name string) (io.ReadCloser, error) { + return os.Open(filepath.Join(path.Join(db.GetPath(), "server"), strings.TrimPrefix(name, "prefix"))) + } + hashServer, _ := dirhash.Hash1(files, osOpen) + + return hashClients, hashServer +} + +func HashesChanged(db store.IStore) bool { + old, _ := db.GetHashes() + oldClient := old.Client + oldServer := old.Server + newClient, newServer := GetCurrentHash(db) + + if oldClient != newClient { + //fmt.Println("Hash for client differs") + return true + } + if oldServer != newServer { + //fmt.Println("Hash for server differs") + return true + } + return false +} + +func UpdateHashes(db store.IStore) error { + var clientServerHashes model.ClientServerHashes + clientServerHashes.Client, clientServerHashes.Server = GetCurrentHash(db) + return db.SaveHashes(clientServerHashes) +} + +func RandomString(length int) string { + var seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) + charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, length) + for i := range b { + b[i] = charset[seededRand.Intn(len(charset))] + } + return string(b) +} + +func ManagePerms(path string) error { + err := os.Chmod(path, 0600) + return err +} + +func AddTgToClientID(userid int64, clientID string) { + TgUseridToClientIDMutex.Lock() + defer TgUseridToClientIDMutex.Unlock() + + if _, ok := TgUseridToClientID[userid]; ok && TgUseridToClientID[userid] != nil { + TgUseridToClientID[userid] = append(TgUseridToClientID[userid], clientID) + } else { + TgUseridToClientID[userid] = []string{clientID} + } +} + +func UpdateTgToClientID(userid int64, clientID string) { + TgUseridToClientIDMutex.Lock() + defer TgUseridToClientIDMutex.Unlock() + + // Detach clientID from any existing userid + for uid, cls := range TgUseridToClientID { + if cls != nil { + filtered := filterStringSlice(cls, clientID) + if len(filtered) > 0 { + TgUseridToClientID[uid] = filtered + } else { + delete(TgUseridToClientID, uid) + } + } + } + + // Attach it to the new one + if _, ok := TgUseridToClientID[userid]; ok && TgUseridToClientID[userid] != nil { + TgUseridToClientID[userid] = append(TgUseridToClientID[userid], clientID) + } else { + TgUseridToClientID[userid] = []string{clientID} + } +} + +func RemoveTgToClientID(clientID string) { + TgUseridToClientIDMutex.Lock() + defer TgUseridToClientIDMutex.Unlock() + + // Detach clientID from any existing userid + for uid, cls := range TgUseridToClientID { + if cls != nil { + filtered := filterStringSlice(cls, clientID) + if len(filtered) > 0 { + TgUseridToClientID[uid] = filtered + } else { + delete(TgUseridToClientID, uid) + } + } + } +} + +func filterStringSlice(s []string, excludedStr string) []string { + filtered := s[:0] + for _, v := range s { + if v != excludedStr { + filtered = append(filtered, v) + } + } + return filtered +} + +func GetDBUserCRC32(dbuser model.User) uint32 { + buf := new(bytes.Buffer) + enc := gob.NewEncoder(buf) + if err := enc.Encode(dbuser); err != nil { + panic("model.User is gob-incompatible, session verification is impossible") + } + return crc32.ChecksumIEEE(buf.Bytes()) +} + +func ConcatMultipleSlices(slices ...[]byte) []byte { + var totalLen int + + for _, s := range slices { + totalLen += len(s) + } + + result := make([]byte, totalLen) + + var i int + + for _, s := range slices { + i += copy(result[i:], s) + } + + return result +} + +func GetCookiePath() string { + cookiePath := BasePath + if cookiePath == "" { + cookiePath = "/" + } + return cookiePath +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..696a0ec --- /dev/null +++ b/yarn.lock @@ -0,0 +1,3052 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@fortawesome/fontawesome-free@^5.13.0": + version "5.13.0" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.13.0.tgz#fcb113d1aca4b471b709e8c9c168674fbd6e06d9" + integrity sha512-xKOeQEl5O47GPZYIMToj6uuA2syyFlq9EMSl2ui0uytjY9xbe8XS0pexNWmxrdcCyNGyDmLyYw5FtKsalBUeOg== + +"@fullcalendar/bootstrap@^4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@fullcalendar/bootstrap/-/bootstrap-4.4.0.tgz#4d77d19b4e2d3aaf518f1ade187d8e4db341d2a1" + integrity sha512-2YYM2tPhTwNtYFBcIm4Cf/72pJ3qzRRmzGZx13mJeVYjbSOe1rn/tquff/mMDPPtfCZ4+XqXOLCzAeW7eWHGgw== + +"@fullcalendar/core@^4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@fullcalendar/core/-/core-4.4.0.tgz#79dbc0cca836ce628a07e739a456da11ff141373" + integrity sha512-PC4mmXHJHAlXmUEmZVnePyA8yYCOBdxBNq8yjJqedEtT1X0x36yTFz/Y0Ux6bniICZDqYtk0xoxe6jaxi++e0g== + +"@fullcalendar/daygrid@^4.4.0", "@fullcalendar/daygrid@~4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@fullcalendar/daygrid/-/daygrid-4.4.0.tgz#25fcae7226b62688b4e086a611582e72253b5229" + integrity sha512-pDfvL0XZxKHTZ4VFOmwaYe3LmuABEIZsEopeqQ8y5O6BDen9KCbJqgHeCI8FpASSBd6bNlUx7il7EHdSoHhgIw== + +"@fullcalendar/interaction@^4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@fullcalendar/interaction/-/interaction-4.4.0.tgz#fc8f8baaf5cb3533d6ce0a684d6f9952a4430685" + integrity sha512-nGu0ZzYYlNpIhqfyv3JupteWKFETs3W1MzbRJcEZkuPncn4BooEi4A2blgHfacHAmmpaNkT84tAmhzi734MFBA== + +"@fullcalendar/timegrid@^4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@fullcalendar/timegrid/-/timegrid-4.4.0.tgz#c5837cfd676afff0d95535ac4cc054ed65965976" + integrity sha512-QwJ9oM87/ZTbXaE8PMIVp20GPtVCFmroaeR1GydJ6BKYtbxG/nsaSv7RhqvDa2jLjHaTWC2NjHo9hRfjQjtCZA== + dependencies: + "@fullcalendar/daygrid" "~4.4.0" + +"@lgaitan/pace-progress@^1.0.7": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@lgaitan/pace-progress/-/pace-progress-1.0.7.tgz#c96fbbd9fd4cf528feed34ea0c8f9d8b3e98f0dd" + integrity sha1-yW+72f1M9Sj+7TTqDI+diz6Y8N0= + +"@npmcli/ci-detect@^1.0.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@npmcli/ci-detect/-/ci-detect-1.2.0.tgz#0df142a1ac3bba6cbf2e9da1a6994cd898e32c95" + integrity sha512-JtktVH7ASBVIWsQTFlFpeOzhBJskvoBCTfeeRhhZy7ybATcUvwiwotZ8j5rkqUUyB69lIy/AvboiiiGBjYBKBA== + +"@npmcli/git@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@npmcli/git/-/git-2.0.1.tgz#d7ecaa9c945de6bb1af5a7e6ea634771193c168b" + integrity sha512-hVatexiBtx71F01Ars38Hr5AFUGmJgHAfQtRlO5fJlnAawRGSXwEFgjB5i3XdUUmElZU/RXy7fefN02dZKxgPw== + dependencies: + "@npmcli/promise-spawn" "^1.1.0" + mkdirp "^1.0.3" + npm-pick-manifest "^6.0.0" + promise-inflight "^1.0.1" + promise-retry "^1.1.1" + unique-filename "^1.1.1" + which "^2.0.2" + +"@npmcli/installed-package-contents@^1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@npmcli/installed-package-contents/-/installed-package-contents-1.0.5.tgz#cc78565e55d9f14d46acf46a96f70934e516fa3d" + integrity sha512-aKIwguaaqb6ViwSOFytniGvLPb9SMCUm39TgM3SfUo7n0TxUMbwoXfpwyvQ4blm10lzbAwTsvjr7QZ85LvTi4A== + dependencies: + npm-bundled "^1.1.1" + npm-normalize-package-bin "^1.0.1" + read-package-json-fast "^1.1.1" + readdir-scoped-modules "^1.1.0" + +"@npmcli/move-file@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-1.0.1.tgz#de103070dac0f48ce49cf6693c23af59c0f70464" + integrity sha512-Uv6h1sT+0DrblvIrolFtbvM1FgWm+/sy4B3pvLp67Zys+thcukzS5ekn7HsZFGpWP4Q3fYJCljbWQE/XivMRLw== + dependencies: + mkdirp "^1.0.4" + +"@npmcli/promise-spawn@^1.1.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@npmcli/promise-spawn/-/promise-spawn-1.2.0.tgz#167d70b926f771c8bd8b9183bfc8b5aec29d7e45" + integrity sha512-nFtqjVETliApiRdjbYwKwhlSHx2ZMagyj5b9YbNt0BWeeOVxJd47ZVE2u16vxDHyTOZvk+YLV7INwfAE9a2uow== + dependencies: + infer-owner "^1.0.4" + +"@sindresorhus/is@^0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" + integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== + +"@sweetalert2/theme-bootstrap-4@^3.1.4": + version "3.1.4" + resolved "https://registry.yarnpkg.com/@sweetalert2/theme-bootstrap-4/-/theme-bootstrap-4-3.1.4.tgz#93b72df6b7aeb6f52597670434cdd3283cfdbd27" + integrity sha512-F9ltvRbEP3CNyLCW3p0vmrDOOqZgSUvK5mLIQso7bJ/JnbVdVLiZ6GRnzdhzf5Rz6LO0U9YS++RWdpN+32UHjw== + +"@szmarczak/http-timer@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" + integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA== + dependencies: + defer-to-connect "^1.0.1" + +"@tootallnate/once@1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" + integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== + +"@ttskch/select2-bootstrap4-theme@^1.3.2": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@ttskch/select2-bootstrap4-theme/-/select2-bootstrap4-theme-1.4.0.tgz#1e15ed14c5adbd6f86940811e195bde984aa9882" + integrity sha512-5WVMdFpQLx0vKxX0/LfapI0aE+qUYOuOuMjOc+ecN8vsr7plcBsgIbp4YtlpLmQR9aqgyM1QyqEiO/FwB+qaqQ== + +"@types/color-name@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" + integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== + +acorn-node@^1.3.0: + version "1.8.2" + resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.8.2.tgz#114c95d64539e53dede23de8b9d96df7c7ae2af8" + integrity sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A== + dependencies: + acorn "^7.0.0" + acorn-walk "^7.0.0" + xtend "^4.0.2" + +acorn-walk@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.1.1.tgz#345f0dffad5c735e7373d2fec9a1023e6a44b83e" + integrity sha512-wdlPY2tm/9XBr7QkKlq0WQVgiuGTX6YWPyRyBviSoScBuLfTVQhvwg6wJ369GJ/1nPfTLMfnrFIfjqVg6d+jQQ== + +acorn@^7.0.0, acorn@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.1.tgz#e35668de0b402f359de515c5482a1ab9f89a69bf" + integrity sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg== + +admin-lte@^3.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/admin-lte/-/admin-lte-3.0.4.tgz#c80d0b1e2c7e657c9557a653318437c722e31842" + integrity sha512-OsA/yzAPyZgm3WsQYBXOMO8FZx2+kYoiSV6vYWwluSvenTYW/8CRrsIZFck2WndcZnMx9/HReWo5ePJXD3Xc2g== + dependencies: + "@fortawesome/fontawesome-free" "^5.13.0" + "@fullcalendar/bootstrap" "^4.4.0" + "@fullcalendar/core" "^4.4.0" + "@fullcalendar/daygrid" "^4.4.0" + "@fullcalendar/interaction" "^4.4.0" + "@fullcalendar/timegrid" "^4.4.0" + "@lgaitan/pace-progress" "^1.0.7" + "@sweetalert2/theme-bootstrap-4" "^3.1.4" + "@ttskch/select2-bootstrap4-theme" "^1.3.2" + bootstrap "^4.4.1" + bootstrap-colorpicker "^3.2.0" + bootstrap-slider "^10.6.2" + bootstrap-switch "3.3.4" + bootstrap4-duallistbox "^4.0.1" + bs-custom-file-input "^1.3.4" + chart.js "^2.9.3" + datatables.net "^1.10.20" + datatables.net-autofill-bs4 "^2.3.4" + datatables.net-bs4 "^1.10.20" + datatables.net-buttons-bs4 "^1.6.1" + datatables.net-colreorder-bs4 "^1.5.2" + datatables.net-fixedcolumns-bs4 "^3.3.0" + datatables.net-fixedheader-bs4 "^3.1.6" + datatables.net-keytable-bs4 "^2.5.1" + datatables.net-responsive-bs4 "^2.2.3" + datatables.net-rowgroup-bs4 "^1.1.1" + datatables.net-rowreorder-bs4 "^1.2.6" + datatables.net-scroller-bs4 "^2.0.1" + datatables.net-select-bs4 "^1.3.1" + daterangepicker "^3.0.5" + ekko-lightbox "^5.3.0" + fastclick "^1.0.6" + filterizr "^2.2.3" + flag-icon-css "^3.4.6" + flot "^4.2.0" + fs-extra "^9.0.0" + icheck-bootstrap "^3.0.1" + inputmask "^5.0.3" + ion-rangeslider "^2.3.1" + jquery "^3.4.1" + jquery-knob-chif "^1.2.13" + jquery-mapael "^2.2.0" + jquery-mousewheel "^3.1.13" + jquery-ui-dist "^1.12.1" + jquery-validation "^1.19.1" + jqvmap-novulnerability "^1.5.1" + jsgrid "^1.5.3" + jszip "^3.3.0" + moment "^2.24.0" + overlayscrollbars "^1.11.0" + pdfmake "^0.1.65" + popper.js "^1.16.1" + raphael "^2.3.0" + select2 "^4.0.13" + sparklines "^1.2.0" + summernote "^0.8.16" + sweetalert2 "^9.10.8" + tempusdominus-bootstrap-4 "^5.1.2" + toastr "^2.1.4" + +agent-base@6: + version "6.0.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.1.tgz#808007e4e5867decb0ab6ab2f928fbdb5a596db4" + integrity sha512-01q25QQDwLSsyfhrKbn8yuur+JNw0H+0Y4JiGIKd3z9aYk/w/2kxD/Upc+t2ZBBSUNff50VjPsSW2YxM8QYKVg== + dependencies: + debug "4" + +agentkeepalive@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.1.3.tgz#360a09d743a1f4fde749f9ba07caa6575d08259a" + integrity sha512-wn8fw19xKZwdGPO47jivonaHRTd+nGOMP1z11sgGeQzDy2xd5FG0R67dIMcKHDE2cJ5y+YXV30XVGUBPRSY7Hg== + dependencies: + debug "^4.1.0" + depd "^1.1.2" + humanize-ms "^1.2.1" + +aggregate-error@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.0.1.tgz#db2fe7246e536f40d9b5442a39e117d7dd6a24e0" + integrity sha512-quoaXsZ9/BLNae5yiNoUz+Nhkwz83GhWwtYFglcjEQB2NDHCIpApbqXxIFnm4Pq/Nvhrsq5sYJFyohrrxnTGAA== + dependencies: + clean-stack "^2.0.0" + indent-string "^4.0.0" + +amdefine@>=0.0.4: + version "1.0.1" + resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" + integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= + +ansi-align@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.0.tgz#b536b371cf687caaef236c18d3e21fe3797467cb" + integrity sha512-ZpClVKqXN3RGBmKibdfWzqCY4lnjEuoNzU5T0oEFpfd/z5qJHVarukridD4juLO2FXMiwUQxr9WqQtaYa8XRYw== + dependencies: + string-width "^3.0.0" + +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + +ansi-regex@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" + integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== + +ansi-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" + integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== + +ansi-styles@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" + integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4= + +ansi-styles@^4.1.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359" + integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA== + dependencies: + "@types/color-name" "^1.1.1" + color-convert "^2.0.1" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +array-from@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/array-from/-/array-from-2.1.1.tgz#cfe9d8c26628b9dc5aecc62a9f5d8f1f352c1195" + integrity sha1-z+nYwmYoudxa7MYqn12PHzUsEZU= + +asap@^2.0.0: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= + +ast-transform@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/ast-transform/-/ast-transform-0.0.0.tgz#74944058887d8283e189d954600947bc98fe0062" + integrity sha1-dJRAWIh9goPhidlUYAlHvJj+AGI= + dependencies: + escodegen "~1.2.0" + esprima "~1.0.4" + through "~2.3.4" + +ast-types@^0.7.0: + version "0.7.8" + resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.7.8.tgz#902d2e0d60d071bdcd46dc115e1809ed11c138a9" + integrity sha1-kC0uDWDQcb3NRtwRXhgJ7RHBOKk= + +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + +babel-runtime@^6.11.6: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" + integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4= + dependencies: + core-js "^2.4.0" + regenerator-runtime "^0.11.0" + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + +base64-js@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978" + integrity sha1-EQHpVE9KdrG8OybUUsqW16NeeXg= + +base64-js@^1.1.2, base64-js@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" + integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== + +bootstrap-colorpicker@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/bootstrap-colorpicker/-/bootstrap-colorpicker-3.2.0.tgz#42b053b865a866b2674527813cd59f90137b9704" + integrity sha512-twW93EFLf4MzZ/st+MkfdLCWEEA7r43WPlPnGckzm3Lj2FsbmVS/qgJH2c9IcmO3re5Q1320NO9bhuViwHR9Qw== + dependencies: + bootstrap ">=4.0" + jquery ">=2.1.0" + popper.js ">=1.10" + +bootstrap-slider@^10.6.2: + version "10.6.2" + resolved "https://registry.yarnpkg.com/bootstrap-slider/-/bootstrap-slider-10.6.2.tgz#7341f468c012bdaa6a1d8625d989fdeb8ed7dd38" + integrity sha512-8JTPZB9QVOdrGzYF3YgC3YW6ssfPeBvBwZnXffiZ7YH/zz1D0EKlZvmQsm/w3N0XjVNYQEoQ0ax+jHrErV4K1Q== + +bootstrap-switch@3.3.4: + version "3.3.4" + resolved "https://registry.yarnpkg.com/bootstrap-switch/-/bootstrap-switch-3.3.4.tgz#70e0aeb2a877c0dc766991de108e2170fc29a2ff" + integrity sha1-cOCusqh3wNx2aZHeEI4hcPwpov8= + +bootstrap4-duallistbox@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/bootstrap4-duallistbox/-/bootstrap4-duallistbox-4.0.1.tgz#89fa6ece3496871bbdf0c63dc256dceb5324b203" + integrity sha512-DlIvIhCY8LtlnPe1QYIl+iHpNAvKv/TBqhNGKI/OcuFBoQ1Cx2hVBiqKEFi5fFn0zSRZZdxCJ2RsmBrQurNMiQ== + +bootstrap@>=4.0, bootstrap@>=4.1.2, bootstrap@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.4.1.tgz#8582960eea0c5cd2bede84d8b0baf3789c3e8b01" + integrity sha512-tbx5cHubwE6e2ZG7nqM3g/FZ5PQEDMWmMGNrCUBVRPHXTJaH7CBDdsLeu3eCh3B1tzAxTnAbtmrzvWEvT2NNEA== + +boxen@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/boxen/-/boxen-4.2.0.tgz#e411b62357d6d6d36587c8ac3d5d974daa070e64" + integrity sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ== + dependencies: + ansi-align "^3.0.0" + camelcase "^5.3.1" + chalk "^3.0.0" + cli-boxes "^2.2.0" + string-width "^4.1.0" + term-size "^2.1.0" + type-fest "^0.8.1" + widest-line "^3.1.0" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brfs@^1.4.0: + version "1.6.1" + resolved "https://registry.yarnpkg.com/brfs/-/brfs-1.6.1.tgz#b78ce2336d818e25eea04a0947cba6d4fb8849c3" + integrity sha512-OfZpABRQQf+Xsmju8XE9bDjs+uU4vLREGolP7bDgcpsI17QREyZ4Bl+2KLxxx1kCgA0fAIhKQBaBYh+PEcCqYQ== + dependencies: + quote-stream "^1.0.1" + resolve "^1.1.5" + static-module "^2.2.0" + through2 "^2.0.0" + +brfs@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/brfs/-/brfs-2.0.2.tgz#44237878fa82aa479ce4f5fe2c1796ec69f07845" + integrity sha512-IrFjVtwu4eTJZyu8w/V2gxU7iLTtcHih67sgEdzrhjLBMHp2uYefUBfdM4k2UvcuWMgV7PQDZHSLeNWnLFKWVQ== + dependencies: + quote-stream "^1.0.1" + resolve "^1.1.5" + static-module "^3.0.2" + through2 "^2.0.0" + +brotli@^1.2.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/brotli/-/brotli-1.3.2.tgz#525a9cad4fcba96475d7d388f6aecb13eed52f46" + integrity sha1-UlqcrU/LqWR119OI9q7LE+7VL0Y= + dependencies: + base64-js "^1.1.2" + +browser-resolve@^1.8.1: + version "1.11.3" + resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-1.11.3.tgz#9b7cbb3d0f510e4cb86bdbd796124d28b5890af6" + integrity sha512-exDi1BYWB/6raKHmDTCicQfTkqwN5fioMFV4j8BsfMU4R2DK/QfZfK7kOVkmWCNANf0snkBzqGqAJBao9gZMdQ== + dependencies: + resolve "1.1.7" + +browserify-optional@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/browserify-optional/-/browserify-optional-1.0.1.tgz#1e13722cfde0d85f121676c2a72ced533a018869" + integrity sha1-HhNyLP3g2F8SFnbCpyztUzoBiGk= + dependencies: + ast-transform "0.0.0" + ast-types "^0.7.0" + browser-resolve "^1.8.1" + +bs-custom-file-input@^1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/bs-custom-file-input/-/bs-custom-file-input-1.3.4.tgz#c275cb8d4f1c02ba026324292509fa9a747dbda8" + integrity sha512-NBsQzTnef3OW1MvdKBbMHAYHssCd613MSeJV7z2McXznWtVMnJCy7Ckyc+PwxV6Pk16cu6YBcYWh/ZE0XWNKCA== + +buffer-equal@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-0.0.1.tgz#91bc74b11ea405bc916bc6aa908faafa5b4aac4b" + integrity sha1-kbx0sR6kBbyRa8aqkI+q+ltKrEs= + +buffer-from@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" + integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== + +builtins@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/builtins/-/builtins-1.0.3.tgz#cb94faeb61c8696451db36534e1422f94f0aee88" + integrity sha1-y5T662HIaWRR2zZTThQi+U8K7og= + +cacache@^15.0.0: + version "15.0.4" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.0.4.tgz#b2c23cf4ac4f5ead004fb15a0efb0a20340741f1" + integrity sha512-YlnKQqTbD/6iyoJvEY3KJftjrdBYroCbxxYXzhOzsFLWlp6KX4BOlEf4mTx0cMUfVaTS3ENL2QtDWeRYoGLkkw== + dependencies: + "@npmcli/move-file" "^1.0.1" + chownr "^2.0.0" + fs-minipass "^2.0.0" + glob "^7.1.4" + infer-owner "^1.0.4" + lru-cache "^5.1.1" + minipass "^3.1.1" + minipass-collect "^1.0.2" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.2" + mkdirp "^1.0.3" + p-map "^4.0.0" + promise-inflight "^1.0.1" + rimraf "^3.0.2" + ssri "^8.0.0" + tar "^6.0.2" + unique-filename "^1.1.1" + +cacheable-request@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912" + integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg== + dependencies: + clone-response "^1.0.2" + get-stream "^5.1.0" + http-cache-semantics "^4.0.0" + keyv "^3.0.0" + lowercase-keys "^2.0.0" + normalize-url "^4.1.0" + responselike "^1.0.2" + +camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +chalk@^1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" + integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chart.js@^2.9.3: + version "2.9.3" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.9.3.tgz#ae3884114dafd381bc600f5b35a189138aac1ef7" + integrity sha512-+2jlOobSk52c1VU6fzkh3UwqHMdSlgH1xFv9FKMqHiNCpXsGPQa/+81AFa+i3jZ253Mq9aAycPwDjnn1XbRNNw== + dependencies: + chartjs-color "^2.1.0" + moment "^2.10.2" + +chartjs-color-string@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz#1df096621c0e70720a64f4135ea171d051402f71" + integrity sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A== + dependencies: + color-name "^1.0.0" + +chartjs-color@^2.1.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.4.1.tgz#6118bba202fe1ea79dd7f7c0f9da93467296c3b0" + integrity sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w== + dependencies: + chartjs-color-string "^0.6.0" + color-convert "^1.9.3" + +chownr@^1.1.3, chownr@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + +chownr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" + integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== + +cint@^8.2.1: + version "8.2.1" + resolved "https://registry.yarnpkg.com/cint/-/cint-8.2.1.tgz#70386b1b48e2773d0d63166a55aff94ef4456a12" + integrity sha1-cDhrG0jidz0NYxZqVa/5TvRFahI= + +clean-stack@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" + integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== + +cli-boxes@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.0.tgz#538ecae8f9c6ca508e3c3c95b453fe93cb4c168d" + integrity sha512-gpaBrMAizVEANOpfZp/EEUixTXDyGt7DFzdK5hU+UbWt/J0lB0w20ncZj59Z9a93xHb9u12zF5BS6i9RKbtg4w== + +cli-table@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.1.tgz#f53b05266a8b1a0b934b3d0821e6e2dc5914ae23" + integrity sha1-9TsFJmqLGguTSz0IIebi3FkUriM= + dependencies: + colors "1.0.3" + +clone-response@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" + integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws= + dependencies: + mimic-response "^1.0.0" + +clone@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" + integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= + +color-convert@^1.9.3: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +color-name@^1.0.0, color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +colors@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" + integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs= + +commander@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-5.0.0.tgz#dbf1909b49e5044f8fdaf0adc809f0c0722bdfd0" + integrity sha512-JrDGPAKjMGSP1G0DUoaceEJ3DZgAfr/q6X7FVk4+U5KxUSKviYGM2k6zWkfyyBHy5rAtzgYJFa1ro2O9PtoxwQ== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +concat-stream@~1.6.0: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +configstore@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96" + integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA== + dependencies: + dot-prop "^5.2.0" + graceful-fs "^4.1.2" + make-dir "^3.0.0" + unique-string "^2.0.0" + write-file-atomic "^3.0.0" + xdg-basedir "^4.0.0" + +convert-source-map@^1.5.1: + version "1.7.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" + integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA== + dependencies: + safe-buffer "~5.1.1" + +core-js@^2.4.0: + version "2.6.11" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" + integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== + +core-util-is@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + +crypto-js@^3.1.9-1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.3.0.tgz#846dd1cce2f68aacfa156c8578f926a609b7976b" + integrity sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q== + +crypto-random-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" + integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== + +d@1, d@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" + integrity sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA== + dependencies: + es5-ext "^0.10.50" + type "^1.0.1" + +dash-ast@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/dash-ast/-/dash-ast-1.0.0.tgz#12029ba5fb2f8aa6f0a861795b23c1b4b6c27d37" + integrity sha512-Vy4dx7gquTeMcQR/hDkYLGUnwVil6vk4FOOct+djUnHOUWt+zJPJAaRIXaAFkPXtJjvlY7o3rfRu0/3hpnwoUA== + +datatables.net-autofill-bs4@^2.3.4: + version "2.3.4" + resolved "https://registry.yarnpkg.com/datatables.net-autofill-bs4/-/datatables.net-autofill-bs4-2.3.4.tgz#761fe2873f5350d1388a8edc6cf660e8047b186f" + integrity sha512-oqmR84gwsMFOj4OPwuGxSv6tbJWb8DDOqS3Ltg///gEPsgHgFgF0Si3GDLwNGtzF87T9G/q7HpVO3ZUmM5ChZw== + dependencies: + datatables.net-autofill "2.3.4" + datatables.net-bs4 "^1.10.15" + jquery ">=1.7" + +datatables.net-autofill@2.3.4: + version "2.3.4" + resolved "https://registry.yarnpkg.com/datatables.net-autofill/-/datatables.net-autofill-2.3.4.tgz#f509249fa58ebaf1be8ac3b470016037698a979c" + integrity sha512-0mVCKbtgz8bAxb7Rt0MKjduJiTQNkLM6nPFkqPlMiTzGqlDjixrPA8BV35N60CptKhH8A/twy1sr9lHVrg/Niw== + dependencies: + datatables.net "^1.10.15" + jquery ">=1.7" + +datatables.net-bs4@^1.10.15, datatables.net-bs4@^1.10.20: + version "1.10.20" + resolved "https://registry.yarnpkg.com/datatables.net-bs4/-/datatables.net-bs4-1.10.20.tgz#beff1c8d3510826c0678eaa055270607c0e53882" + integrity sha512-kQmMUMsHMOlAW96ztdoFqjSbLnlGZQ63iIM82kHbmldsfYdzuyhbb4hTx6YNBi481WCO3iPSvI6YodNec46ZAw== + dependencies: + datatables.net "1.10.20" + jquery ">=1.7" + +datatables.net-buttons-bs4@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/datatables.net-buttons-bs4/-/datatables.net-buttons-bs4-1.6.1.tgz#4abd9c83fbd753ffa4913adca1fae094df64b3ba" + integrity sha512-PdE1vrnRIeX+p++wnbpdnr97kgTCs+DDomtTccsQvMrFm29lxVa7uTz1awasfGRrCrJzpb5HjPrPJBzbGHrUyg== + dependencies: + datatables.net-bs4 "^1.10.15" + datatables.net-buttons "1.6.1" + jquery ">=1.7" + +datatables.net-buttons@1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/datatables.net-buttons/-/datatables.net-buttons-1.6.1.tgz#f62847e7c6f905fcf7339e7541a40741bbb3b8fb" + integrity sha512-Lcgvi/xGB2X0jr1n/uQMmg43Z1JQO6aaGjPHO+sAQAMfBGBi9cK1hhfM4Vg8gzC5fz3sW4QrtjAdKvOPnQ1A3w== + dependencies: + datatables.net "^1.10.15" + jquery ">=1.7" + +datatables.net-colreorder-bs4@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/datatables.net-colreorder-bs4/-/datatables.net-colreorder-bs4-1.5.2.tgz#4fe1a9ffe679e7e84f3ccb58c9c4d31ac0d49a1b" + integrity sha512-L5omHV0agczRZwR9eismTOq+/9/glQqZUeRfigEc+5oMKLnubJkVHQLOanY2duDl3stvsZ6ebWbXWEh6tndgDg== + dependencies: + datatables.net-bs4 "^1.10.15" + datatables.net-colreorder "1.5.2" + jquery ">=1.7" + +datatables.net-colreorder@1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/datatables.net-colreorder/-/datatables.net-colreorder-1.5.2.tgz#c425cee1f88b3246be0363c67a152be743ca6bce" + integrity sha512-77ShdeM7QjKI6M1jfWQ4ZempSYMmmpe9NqjimHBp+o9lAto789YdCLiFrW71dwn1v8Awp4qcMShqHNxGzR/HVg== + dependencies: + datatables.net "^1.10.15" + jquery ">=1.7" + +datatables.net-fixedcolumns-bs4@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/datatables.net-fixedcolumns-bs4/-/datatables.net-fixedcolumns-bs4-3.3.0.tgz#15fc5e6cc7ccf5c60bbebe47185476b699a43e40" + integrity sha512-X6EVk0Y5pE4yfCM+Igk1nUrBu1ou1abyldWERqs0mc/x95TFPTc0pqy5XEb/bxvzi0oyYNR2Pojq4j48uLK+iQ== + dependencies: + datatables.net-bs4 "^1.10.15" + datatables.net-fixedcolumns "3.3.0" + jquery ">=1.7" + +datatables.net-fixedcolumns@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/datatables.net-fixedcolumns/-/datatables.net-fixedcolumns-3.3.0.tgz#885b35b7f155ff5e08e1532e23363f14050bb1af" + integrity sha512-+/LJKQyOlUb7j9mD8oCDs5pd6egi1zPI65YaXPwKlwYlX+fI4BNkHhGwmxCrCDM2nS6/2tA59OuNkfLNCKlx1Q== + dependencies: + datatables.net "^1.10.15" + jquery ">=1.7" + +datatables.net-fixedheader-bs4@^3.1.6: + version "3.1.6" + resolved "https://registry.yarnpkg.com/datatables.net-fixedheader-bs4/-/datatables.net-fixedheader-bs4-3.1.6.tgz#d1b12981bf127cfef658a0a742719aaeac6894eb" + integrity sha512-mat50UvCNhE5E2jhqEAO07LJ7I++pxFwpbNUfVtuFg866Tf3uHAKHgsXnsXW8kqiER931g4LbxO28axyquj1tw== + dependencies: + datatables.net-bs4 "^1.10.15" + datatables.net-fixedheader "3.1.6" + jquery ">=1.7" + +datatables.net-fixedheader@3.1.6: + version "3.1.6" + resolved "https://registry.yarnpkg.com/datatables.net-fixedheader/-/datatables.net-fixedheader-3.1.6.tgz#b139b88a213460dbeca5080c6251e81575697188" + integrity sha512-EMx2JogtXEQObaF7ylgB+pY4/vWhu/plTZNf5EvNMAboRha6FT4+14CHIyARVM/bjySPU+6advtft02pW3KxJg== + dependencies: + datatables.net "^1.10.15" + jquery ">=1.7" + +datatables.net-keytable-bs4@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/datatables.net-keytable-bs4/-/datatables.net-keytable-bs4-2.5.1.tgz#011b1950322d9038b1a1958d0a3e58679d902cb2" + integrity sha512-K3WlmCxVsmUpIKNEbP+2gggJ4eVnHI8NUleSDagmpromxErxmvsli7Sa35ZCRaCkMn/81TwFMIA2cHKfYotJyQ== + dependencies: + datatables.net-bs4 "^1.10.15" + datatables.net-keytable "2.5.1" + jquery ">=1.7" + +datatables.net-keytable@2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/datatables.net-keytable/-/datatables.net-keytable-2.5.1.tgz#7a6ba0b8085eaacc2cdc20f6348e8abd77b899c6" + integrity sha512-06b1ilIyIaEK3jcCqHjcZCIBjgSBwW/Aj/H8TMS/J0pO//UR/N0t3O9lvklqZurTtKCkP2HDaS+hgTavgyWnJA== + dependencies: + datatables.net "^1.10.15" + jquery ">=1.7" + +datatables.net-responsive-bs4@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/datatables.net-responsive-bs4/-/datatables.net-responsive-bs4-2.2.3.tgz#639de17c1d31210ebf2b3c25f1c774c13f729e94" + integrity sha512-SQaWI0uLuPcaiBBin9zX+MuQfTSIkK1bYxbXqUV6NLkHCVa6PMQK7Rvftj0ywG4R7uOtjbzY8nSVqxEKvQI0Vg== + dependencies: + datatables.net-bs4 "^1.10.15" + datatables.net-responsive "2.2.3" + jquery ">=1.7" + +datatables.net-responsive@2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/datatables.net-responsive/-/datatables.net-responsive-2.2.3.tgz#50a2b1b4955b16b32f573a3f00f473b0bfbee913" + integrity sha512-8D6VtZcyuH3FG0Hn5A4LPZQEOX3+HrRFM7HjpmsQc/nQDBbdeBLkJX4Sh/o1nzFTSneuT1Wh/lYZHVPpjcN+Sw== + dependencies: + datatables.net "^1.10.15" + jquery ">=1.7" + +datatables.net-rowgroup-bs4@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/datatables.net-rowgroup-bs4/-/datatables.net-rowgroup-bs4-1.1.1.tgz#57c17e611f8f8ec0aa456fd325bbf466545c230d" + integrity sha512-39+6bqlF5emY2VJOTgwEfbJMBjxveKGrKnZb5ltTzCp70q5mX+OTzJVJJlKI6wXjQXx5765B6xbFHNGKIRPNHw== + dependencies: + datatables.net-bs4 "^1.10.15" + datatables.net-rowgroup "1.1.1" + jquery ">=1.7" + +datatables.net-rowgroup@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/datatables.net-rowgroup/-/datatables.net-rowgroup-1.1.1.tgz#616531e5fb3c8642f6a51fb14801f8aff50cf90e" + integrity sha512-uGdD7t7quFZ1Qnze0ScO4qmreoxK07rp9ukU68/ITkWCQjYOfXoqB8izJP9o+TYs7P8sM4Q8ecxzPm7z5pOauw== + dependencies: + datatables.net "^1.10.15" + jquery ">=1.7" + +datatables.net-rowreorder-bs4@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/datatables.net-rowreorder-bs4/-/datatables.net-rowreorder-bs4-1.2.6.tgz#b3333ede3d84d6b5cce346d1aece1fb0d2a937ac" + integrity sha512-Ld1liPNlhOBXjZFg+OpzRdAcYvQNe2JgbXOc2rl2XcU89VHrT5LARt0oZoyuyJiuEIlBgoxNnBzXXw1dFrP5zg== + dependencies: + datatables.net-bs4 "^1.10.15" + datatables.net-rowreorder "1.2.6" + jquery ">=1.7" + +datatables.net-rowreorder@1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/datatables.net-rowreorder/-/datatables.net-rowreorder-1.2.6.tgz#e1788c4dea84b12223bc5dde3e4a70c316a12ae6" + integrity sha512-bGlFPR/0o4YxBAiU6MWB9TZgJHOI6NduQL4vGoQj9/KvqkgzT1EUTOxmv0NKTP7uDO1x5ZEyPhmscIae9ATIIA== + dependencies: + datatables.net "^1.10.15" + jquery ">=1.7" + +datatables.net-scroller-bs4@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/datatables.net-scroller-bs4/-/datatables.net-scroller-bs4-2.0.1.tgz#aa7d33eee1cd26e03c6e5598116292d91874acae" + integrity sha512-NMZNU36aAfNYmryZz8j5EsO6mnUxoyx+5j8PxSNfyrNhMVFtFmSCRJrWNfmfYN5zpOe3/XgjbXytaD5LdWHszg== + dependencies: + datatables.net-bs4 "^1.10.15" + datatables.net-scroller "2.0.1" + jquery ">=1.7" + +datatables.net-scroller@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/datatables.net-scroller/-/datatables.net-scroller-2.0.1.tgz#17040d3f1da9b4e174729b6b7f0e36b6bf818adc" + integrity sha512-FnbJXT1Zu8FVK+wXcMuEVyFJkLXDi65i+jPCSVH2ZkNSPp+mfeBVtZH41MLGspNouP/IdudKIoCXzPNR/+OhpQ== + dependencies: + datatables.net "^1.10.15" + jquery ">=1.7" + +datatables.net-select-bs4@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/datatables.net-select-bs4/-/datatables.net-select-bs4-1.3.1.tgz#1c39c7fd5bfb66b5c8402611c2c64fc47e3cfca4" + integrity sha512-8UOBxChTsn24nP/ZOsIMGZOdTJymQZ8WcQ81NcGgyDz6b4JlsQl8Bwb89AcVT7hncMquPJ3d5WUGG4I9WMhAlw== + dependencies: + datatables.net-bs4 "^1.10.15" + datatables.net-select "1.3.1" + jquery ">=1.7" + +datatables.net-select@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/datatables.net-select/-/datatables.net-select-1.3.1.tgz#ec2c3ad7df2bc9c13c09587d0bfd0ceba52a8bff" + integrity sha512-PeVd/hlAX58QzL0+mGvxnXP7ylLtzZMeAots/uZkQi+6c/KI6JuP8LCJoEMHAsSjQM/BnG7Uw8E1YGOz1tZpQQ== + dependencies: + datatables.net "^1.10.15" + jquery ">=1.7" + +datatables.net@1.10.20, datatables.net@^1.10.15, datatables.net@^1.10.20: + version "1.10.20" + resolved "https://registry.yarnpkg.com/datatables.net/-/datatables.net-1.10.20.tgz#9d65ecc3c83cbe7baa4fa5a053405c8fe42c1350" + integrity sha512-4E4S7tTU607N3h0fZPkGmAtr9mwy462u+VJ6gxYZ8MxcRIjZqHy3Dv1GNry7i3zQCktTdWbULVKBbkAJkuHEnQ== + dependencies: + jquery ">=1.7" + +daterangepicker@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/daterangepicker/-/daterangepicker-3.0.5.tgz#97180f233cf9c222cd0767b9c37c2926976d633a" + integrity sha512-BoVV+OjVARWNE15iF+3Y2QIMioAD2UODHvJwIq+NtG0vxh61dXRmOMXlw2dsvxS8KY4n5uvIcBfIPiEiiGJcBg== + dependencies: + jquery ">=1.10" + moment "^2.9.0" + +debug@4, debug@^4.1.0, debug@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" + integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + dependencies: + ms "^2.1.1" + +debuglog@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" + integrity sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI= + +decompress-response@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" + integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M= + dependencies: + mimic-response "^1.0.0" + +deep-equal@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" + integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g== + dependencies: + is-arguments "^1.0.4" + is-date-object "^1.0.1" + is-regex "^1.0.4" + object-is "^1.0.1" + object-keys "^1.1.1" + regexp.prototype.flags "^1.2.0" + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +deep-is@~0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" + integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= + +defer-to-connect@^1.0.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591" + integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ== + +define-properties@^1.1.2, define-properties@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" + integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== + dependencies: + object-keys "^1.0.12" + +depd@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= + +dezalgo@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.3.tgz#7f742de066fc748bc8db820569dddce49bf0d456" + integrity sha1-f3Qt4Gb8dIvI24IFad3c5Jvw1FY= + dependencies: + asap "^2.0.0" + wrappy "1" + +dfa@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/dfa/-/dfa-1.2.0.tgz#96ac3204e2d29c49ea5b57af8d92c2ae12790657" + integrity sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q== + +dot-prop@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.2.0.tgz#c34ecc29556dc45f1f4c22697b6f4904e0cc4fcb" + integrity sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A== + dependencies: + is-obj "^2.0.0" + +duplexer2@~0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" + integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME= + dependencies: + readable-stream "^2.0.2" + +duplexer3@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" + integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI= + +ekko-lightbox@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/ekko-lightbox/-/ekko-lightbox-5.3.0.tgz#fbfcd9df93a8d1cdbf8770adc8c05aaac4d24f56" + integrity sha512-mbacwySuVD3Ad6F2hTkjSTvJt59bcVv2l/TmBerp4xZnLak8tPtA4AScUn4DL42c1ksTiAO6sGhJZ52P/1Qgew== + +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +encoding@^0.1.12: + version "0.1.12" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" + integrity sha1-U4tm8+5izRq1HsMjgp0flIDHS+s= + dependencies: + iconv-lite "~0.4.13" + +end-of-stream@^1.1.0: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +err-code@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/err-code/-/err-code-1.1.2.tgz#06e0116d3028f6aef4806849eb0ea6a748ae6960" + integrity sha1-BuARbTAo9q70gGhJ6w6mp0iuaWA= + +es-abstract@^1.17.0-next.1, es-abstract@^1.17.5: + version "1.17.5" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.5.tgz#d8c9d1d66c8981fb9200e2251d799eee92774ae9" + integrity sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg== + dependencies: + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + is-callable "^1.1.5" + is-regex "^1.0.5" + object-inspect "^1.7.0" + object-keys "^1.1.1" + object.assign "^4.1.0" + string.prototype.trimleft "^2.1.1" + string.prototype.trimright "^2.1.1" + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +es5-ext@^0.10.35, es5-ext@^0.10.50, es5-ext@~0.10.14: + version "0.10.53" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.53.tgz#93c5a3acfdbef275220ad72644ad02ee18368de1" + integrity sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q== + dependencies: + es6-iterator "~2.0.3" + es6-symbol "~3.1.3" + next-tick "~1.0.0" + +es6-iterator@~2.0.1, es6-iterator@~2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" + integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c= + dependencies: + d "1" + es5-ext "^0.10.35" + es6-symbol "^3.1.1" + +es6-map@^0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.5.tgz#9136e0503dcc06a301690f0bb14ff4e364e949f0" + integrity sha1-kTbgUD3MBqMBaQ8LsU/042TpSfA= + dependencies: + d "1" + es5-ext "~0.10.14" + es6-iterator "~2.0.1" + es6-set "~0.1.5" + es6-symbol "~3.1.1" + event-emitter "~0.3.5" + +es6-set@^0.1.5, es6-set@~0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1" + integrity sha1-0rPsXU2ADO2BjbU40ol02wpzzLE= + dependencies: + d "1" + es5-ext "~0.10.14" + es6-iterator "~2.0.1" + es6-symbol "3.1.1" + event-emitter "~0.3.5" + +es6-symbol@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.1.tgz#bf00ef4fdab6ba1b46ecb7b629b4c7ed5715cc77" + integrity sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc= + dependencies: + d "1" + es5-ext "~0.10.14" + +es6-symbol@^3.1.1, es6-symbol@~3.1.1, es6-symbol@~3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.3.tgz#bad5d3c1bcdac28269f4cb331e431c78ac705d18" + integrity sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA== + dependencies: + d "^1.0.1" + ext "^1.1.2" + +escape-goat@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" + integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== + +escape-string-regexp@^1.0.2: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +escodegen@^1.11.1: + version "1.14.1" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.1.tgz#ba01d0c8278b5e95a9a45350142026659027a457" + integrity sha512-Bmt7NcRySdIfNPfU2ZoXDrrXsG9ZjvDxcAlMfDUgRBjLOWTuIACXPBFJH7Z+cLb40JeQco5toikyc9t9P8E9SQ== + dependencies: + esprima "^4.0.1" + estraverse "^4.2.0" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.6.1" + +escodegen@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.2.0.tgz#09de7967791cc958b7f89a2ddb6d23451af327e1" + integrity sha1-Cd55Z3kcyVi3+Jot220jRRrzJ+E= + dependencies: + esprima "~1.0.4" + estraverse "~1.5.0" + esutils "~1.0.0" + optionalDependencies: + source-map "~0.1.30" + +escodegen@~1.9.0: + version "1.9.1" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.9.1.tgz#dbae17ef96c8e4bedb1356f4504fa4cc2f7cb7e2" + integrity sha512-6hTjO1NAWkHnDk3OqQ4YrCuwwmGHL9S3nPlzBOUG/R44rda3wLNrfvQ5fkSGjyhHFKM7ALPKcKGrwvCLe0lC7Q== + dependencies: + esprima "^3.1.3" + estraverse "^4.2.0" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.6.1" + +esprima@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633" + integrity sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM= + +esprima@^4.0.0, esprima@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esprima@~1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-1.0.4.tgz#9f557e08fc3b4d26ece9dd34f8fbf476b62585ad" + integrity sha1-n1V+CPw7TSbs6d00+Pv0drYlha0= + +estraverse@^4.2.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + +estraverse@~1.5.0: + version "1.5.1" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-1.5.1.tgz#867a3e8e58a9f84618afb6c2ddbcd916b7cbaf71" + integrity sha1-hno+jlip+EYYr7bC3bzZFrfLr3E= + +estree-is-function@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/estree-is-function/-/estree-is-function-1.0.0.tgz#c0adc29806d7f18a74db7df0f3b2666702e37ad2" + integrity sha512-nSCWn1jkSq2QAtkaVLJZY2ezwcFO161HVc174zL1KPW3RJ+O6C3eJb8Nx7OXzvhoEv+nLgSR1g71oWUHUDTrJA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +esutils@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-1.0.0.tgz#8151d358e20c8acc7fb745e7472c0025fe496570" + integrity sha1-gVHTWOIMisx/t0XnRywAJf5JZXA= + +ev-emitter@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ev-emitter/-/ev-emitter-1.1.1.tgz#8f18b0ce5c76a5d18017f71c0a795c65b9138f2a" + integrity sha512-ipiDYhdQSCZ4hSbX4rMW+XzNKMD1prg/sTvoVmSLkuQ1MVlwjJQQA+sW8tMYR3BLUr9KjodFV4pvzunvRhd33Q== + +eve-raphael@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/eve-raphael/-/eve-raphael-0.5.0.tgz#17c754b792beef3fa6684d79cf5a47c63c4cda30" + integrity sha1-F8dUt5K+7z+maE15z1pHxjxM2jA= + +event-emitter@~0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" + integrity sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk= + dependencies: + d "1" + es5-ext "~0.10.14" + +ext@^1.1.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/ext/-/ext-1.4.0.tgz#89ae7a07158f79d35517882904324077e4379244" + integrity sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A== + dependencies: + type "^2.0.0" + +falafel@^2.1.0: + version "2.2.4" + resolved "https://registry.yarnpkg.com/falafel/-/falafel-2.2.4.tgz#b5d86c060c2412a43166243cb1bce44d1abd2819" + integrity sha512-0HXjo8XASWRmsS0X1EkhwEMZaD3Qvp7FfURwjLKjG1ghfRm/MGZl2r4cWUTv41KdNghTw4OUMmVtdGQp3+H+uQ== + dependencies: + acorn "^7.1.1" + foreach "^2.0.5" + isarray "^2.0.1" + object-keys "^1.0.6" + +fast-diff@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" + integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== + +fast-levenshtein@~2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= + +fast-memoize@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/fast-memoize/-/fast-memoize-2.5.2.tgz#79e3bb6a4ec867ea40ba0e7146816f6cdce9b57e" + integrity sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw== + +fastclick@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/fastclick/-/fastclick-1.0.6.tgz#161625b27b1a5806405936bda9a2c1926d06be6a" + integrity sha1-FhYlsnsaWAZAWTa9qaLBkm0Gvmo= + +figgy-pudding@^3.5.1: + version "3.5.2" + resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e" + integrity sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw== + +filterizr@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/filterizr/-/filterizr-2.2.3.tgz#345f7bc5d861cdd63f15e7ad7fc312355752a03a" + integrity sha512-1u3/TadXM12uNUlxHBRM31maMXY4DA2+sbeji4KSHk/m3LE4fMkTst+6W5YBlcBC1umICDiwplWsMqr6VzY5ZA== + dependencies: + fast-memoize "^2.5.1" + imagesloaded "^4.1.4" + +find-up@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +find-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" + integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== + dependencies: + locate-path "^3.0.0" + +flag-icon-css@^3.4.6: + version "3.4.6" + resolved "https://registry.yarnpkg.com/flag-icon-css/-/flag-icon-css-3.4.6.tgz#7e51099c85648c65f86d9ebb9c0ec6f5d8826714" + integrity sha512-rF69rt19Hr63SRQTiPBzQABaYB20LAgZhDkr/AxqSdgmCIN+tC5PRMz56Y0gxehFXJmdRwv55+GMi7R1fCRTwg== + +flot@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/flot/-/flot-4.2.0.tgz#25ec79d9c773fff4cb9816e30714bfcaa9ecc702" + integrity sha512-Uy+0hPOpi8X2mvTG2MOnuI8fbQt5mz/vewyoxA5DZXWhYmywZS+PfFnLANb0Os5VXongqKos9ahF+Wu2U4Cp1g== + +fontkit@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/fontkit/-/fontkit-1.8.0.tgz#deb9351619e90ddc91707b6156a9f14c8ab11554" + integrity sha512-EFDRCca7khfQWYu1iFhsqeABpi87f03MBdkT93ZE6YhqCdMzb5Eojb6c4dlJikGv5liuhByyzA7ikpIPTSBWbQ== + dependencies: + babel-runtime "^6.11.6" + brfs "^1.4.0" + brotli "^1.2.0" + browserify-optional "^1.0.0" + clone "^1.0.1" + deep-equal "^1.0.0" + dfa "^1.0.0" + restructure "^0.5.3" + tiny-inflate "^1.0.2" + unicode-properties "^1.0.0" + unicode-trie "^0.3.0" + +foreach@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" + integrity sha1-C+4AUBiusmDQo6865ljdATbsG5k= + +fs-extra@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.0.0.tgz#b6afc31036e247b2466dc99c29ae797d5d4580a3" + integrity sha512-pmEYSk3vYsG/bF651KPUXZ+hvjpgWYw/Gc7W9NFUe3ZVLczKKWIij3IKpOrQcdw4TILtibFslZ0UmR8Vvzig4g== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^1.0.0" + +fs-minipass@^2.0.0, fs-minipass@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + dependencies: + minipass "^3.0.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== + +get-assigned-identifiers@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/get-assigned-identifiers/-/get-assigned-identifiers-1.2.0.tgz#6dbf411de648cbaf8d9169ebb0d2d576191e2ff1" + integrity sha512-mBBwmeGTrxEMO4pMaaf/uUEFHnYtwr8FTe8Y/mer4rcV/bye0qGm6pw1bGZFGStxC5O76c5ZAVBGnqHmOaJpdQ== + +get-stdin@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-7.0.0.tgz#8d5de98f15171a125c5e516643c7a6d0ea8a96f6" + integrity sha512-zRKcywvrXlXsA0v0i9Io4KDRaAw7+a1ZpjRwl9Wox8PFlVCCHra7E9c4kqXCoCM9nR5tBkaTTZRBoCm60bFqTQ== + +get-stream@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" + integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== + dependencies: + pump "^3.0.0" + +get-stream@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.1.0.tgz#01203cdc92597f9b909067c3e656cc1f4d3c4dc9" + integrity sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw== + dependencies: + pump "^3.0.0" + +glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: + version "7.1.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +global-dirs@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-2.0.1.tgz#acdf3bb6685bcd55cb35e8a052266569e9469201" + integrity sha512-5HqUqdhkEovj2Of/ms3IeS/EekcO54ytHRLV4PEY2rhRwrHXLQjeVEES0Lhka0xwNDtGYn58wyC4s5+MHsOO6A== + dependencies: + ini "^1.3.5" + +got@^9.6.0: + version "9.6.0" + resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85" + integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q== + dependencies: + "@sindresorhus/is" "^0.14.0" + "@szmarczak/http-timer" "^1.1.2" + cacheable-request "^6.0.0" + decompress-response "^3.3.0" + duplexer3 "^0.1.4" + get-stream "^4.1.0" + lowercase-keys "^1.0.1" + mimic-response "^1.0.1" + p-cancelable "^1.0.0" + to-readable-stream "^1.0.0" + url-parse-lax "^3.0.0" + +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423" + integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ== + +has-ansi@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" + integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE= + dependencies: + ansi-regex "^2.0.0" + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-symbols@^1.0.0, has-symbols@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" + integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== + +has-yarn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77" + integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw== + +has@^1.0.1, has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +hosted-git-info@^3.0.2: + version "3.0.4" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-3.0.4.tgz#be4973eb1fd2737b11c9c7c19380739bb249f60d" + integrity sha512-4oT62d2jwSDBbLLFLZE+1vPuQ1h8p9wjrJ8Mqx5TjsyWmBMV5B13eJqn8pvluqubLf3cJPTfiYCIwNwDNmzScQ== + dependencies: + lru-cache "^5.1.1" + +http-cache-semantics@^4.0.0, http-cache-semantics@^4.0.4: + version "4.1.0" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" + integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== + +http-proxy-agent@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" + integrity sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg== + dependencies: + "@tootallnate/once" "1" + agent-base "6" + debug "4" + +https-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" + integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== + dependencies: + agent-base "6" + debug "4" + +humanize-ms@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" + integrity sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0= + dependencies: + ms "^2.0.0" + +icheck-bootstrap@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/icheck-bootstrap/-/icheck-bootstrap-3.0.1.tgz#60c9c9a71524e1d9dd5bd05167a62fef05cc3a1b" + integrity sha512-Rj3SybdcMcayhsP4IJ+hmCNgCKclaFcs/5zwCuLXH1WMo468NegjhZVxbSNKhEjJjnwc4gKETogUmPYSQ9lEZQ== + +iconv-lite@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.5.1.tgz#b2425d3c7b18f7219f2ca663d103bddb91718d64" + integrity sha512-ONHr16SQvKZNSqjQT9gy5z24Jw+uqfO02/ngBSBoqChZ+W8qXX7GPRa1RoUnzGADw8K63R1BXUMzarCVQBpY8Q== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +iconv-lite@~0.4.13: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +ignore-walk@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.3.tgz#017e2447184bfeade7c238e4aefdd1e8f95b1e37" + integrity sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw== + dependencies: + minimatch "^3.0.4" + +imagesloaded@^4.1.4: + version "4.1.4" + resolved "https://registry.yarnpkg.com/imagesloaded/-/imagesloaded-4.1.4.tgz#1376efcd162bb768c34c3727ac89cc04051f3cc7" + integrity sha512-ltiBVcYpc/TYTF5nolkMNsnREHW+ICvfQ3Yla2Sgr71YFwQ86bDwV9hgpFhFtrGPuwEx5+LqOHIrdXBdoWwwsA== + dependencies: + ev-emitter "^1.0.0" + +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= + +import-lazy@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" + integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM= + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= + +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + +infer-owner@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/infer-owner/-/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467" + integrity sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@^2.0.3, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ini@^1.3.5, ini@~1.3.0: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +inputmask@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/inputmask/-/inputmask-5.0.3.tgz#f6758de84b7a67c3c246f520c5f3e2c383b3fd68" + integrity sha512-v1l5IoJK6NE8TapI2g6n/y1/ksMwyVLkkaIS6VPTkdvpgEITLzDtSi7n9Jpp471hL2DdJlae9HpMnFmTpf5VXA== + +ion-rangeslider@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/ion-rangeslider/-/ion-rangeslider-2.3.1.tgz#92ade52cb56fc30b9162d0483ff02b6f9ed237c2" + integrity sha512-6V+24FD13/feliI485gnRHZYD9Ev64M5NAFTxnVib516ATHa9PlXQrC+nOiPngouRYTCLPJyokAJEi3e1Umi5g== + +ip@1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" + integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= + +is-arguments@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.0.4.tgz#3faf966c7cba0ff437fb31f6250082fcf0448cf3" + integrity sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA== + +is-callable@^1.1.4, is-callable@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab" + integrity sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q== + +is-ci@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + dependencies: + ci-info "^2.0.0" + +is-date-object@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e" + integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g== + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-installed-globally@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" + integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g== + dependencies: + global-dirs "^2.0.1" + is-path-inside "^3.0.1" + +is-lambda@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" + integrity sha1-PZh3iZ5qU+/AFgUEzeFfgubwYdU= + +is-npm@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-4.0.0.tgz#c90dd8380696df87a7a6d823c20d0b12bbe3c84d" + integrity sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig== + +is-obj@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" + integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== + +is-path-inside@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.2.tgz#f5220fc82a3e233757291dddc9c5877f2a1f3017" + integrity sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg== + +is-regex@^1.0.4, is-regex@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.5.tgz#39d589a358bf18967f726967120b8fc1aed74eae" + integrity sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ== + dependencies: + has "^1.0.3" + +is-symbol@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937" + integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ== + dependencies: + has-symbols "^1.0.1" + +is-typedarray@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + +is-yarn-global@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" + integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== + +isarray@^2.0.1: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + +jju@^1.1.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/jju/-/jju-1.4.0.tgz#a3abe2718af241a2b2904f84a625970f389ae32a" + integrity sha1-o6vicYryQaKykE+EpiWXDzia4yo= + +jquery-knob-chif@^1.2.13: + version "1.2.13" + resolved "https://registry.yarnpkg.com/jquery-knob-chif/-/jquery-knob-chif-1.2.13.tgz#5f1e462ef3745d27a9fd66ce1141fe82b44a5762" + integrity sha1-Xx5GLvN0XSep/WbOEUH+grRKV2I= + +jquery-mapael@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/jquery-mapael/-/jquery-mapael-2.2.0.tgz#a68850c680ef0ce3f8b865e8a48b2a018250ca73" + integrity sha512-B5cVcCkfs7Ezia1Zs8bEfVacYD/GvaASyqQeidApR/NJ1C4igcExk9VULVsgLcTPkxohcZrrz5uCaPXvuKeZWw== + dependencies: + jquery "^3.0 || ^2.0 || ^1.0" + raphael "^2.2.0 || ^2.1.1" + optionalDependencies: + jquery-mousewheel "^3.1" + +jquery-mousewheel@^3.1, jquery-mousewheel@^3.1.13: + version "3.1.13" + resolved "https://registry.yarnpkg.com/jquery-mousewheel/-/jquery-mousewheel-3.1.13.tgz#06f0335f16e353a695e7206bf50503cb523a6ee5" + integrity sha1-BvAzXxbjU6aV5yBr9QUDy1I6buU= + +jquery-tags-input@^1.3.5: + version "1.3.5" + resolved "https://registry.yarnpkg.com/jquery-tags-input/-/jquery-tags-input-1.3.5.tgz#1c89cb95c61983ad14386d30a006920476b93ed7" + integrity sha1-HInLlcYZg60UOG0woAaSBHa5Ptc= + +jquery-ui-dist@^1.12.1: + version "1.12.1" + resolved "https://registry.yarnpkg.com/jquery-ui-dist/-/jquery-ui-dist-1.12.1.tgz#5c0815d3cc6f90ff5faaf5b268a6e23b4ca904fa" + integrity sha1-XAgV08xvkP9fqvWyaKbiO0ypBPo= + +jquery-validation@^1.19.1: + version "1.19.3" + resolved "https://registry.yarnpkg.com/jquery-validation/-/jquery-validation-1.19.3.tgz#50b350eba8b02bcfd119ba15f199487b7eb64086" + integrity sha512-iXxCS5W7STthSTMFX/NDZfWHBLbJ1behVK3eAgHXAV8/0vRa9M4tiqHvJMr39VGWHMGdlkhrtrkBuaL2UlE8yw== + +jquery@>=1.10, jquery@>=1.12.0, jquery@>=1.7, jquery@>=2.1.0, jquery@^3.0, "jquery@^3.0 || ^2.0 || ^1.0", jquery@^3.4.0, jquery@^3.4.1: + version "3.5.0" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.5.0.tgz#9980b97d9e4194611c36530e7dc46a58d7340fc9" + integrity sha512-Xb7SVYMvygPxbFMpTFQiHh1J7HClEaThguL15N/Gg37Lri/qKyhRGZYzHRyLH8Stq3Aow0LsHO2O2ci86fCrNQ== + +jqvmap-novulnerability@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/jqvmap-novulnerability/-/jqvmap-novulnerability-1.5.1.tgz#140c42623ebbe9b9076ea2dd3b8d155fe9f38ae7" + integrity sha512-O6Jr7AGiut9iNJMelPdy8pH83tNXadOqmhJm5FZy9gtaZ5uuhZK3VNu+YLFuTpXeZI8YXUvlFUYbJJi5XHA+tw== + dependencies: + jquery "^3.4.0" + +js-yaml@^3.12.0: + version "3.13.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" + integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +jsgrid@^1.5.3: + version "1.5.3" + resolved "https://registry.yarnpkg.com/jsgrid/-/jsgrid-1.5.3.tgz#b15fc426483153bee2b6b567312f675d92834a0d" + integrity sha1-sV/EJkgxU77itrVnMS9nXZKDSg0= + +json-buffer@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" + integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg= + +json-parse-even-better-errors@^2.0.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.2.0.tgz#304d29aa54bb01156a1328c454034ff0ac8a7bf4" + integrity sha512-2tLgY7LRNZ9Hd6gmCuBG5/OjRHQpSgJQqJoYyLLOhUgn8LdOYrjaZLcxkWnDads+AD/haWWioPNziXQcgvQJ/g== + +json-parse-helpfulerror@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/json-parse-helpfulerror/-/json-parse-helpfulerror-1.0.3.tgz#13f14ce02eed4e981297b64eb9e3b932e2dd13dc" + integrity sha1-E/FM4C7tTpgSl7ZOueO5MuLdE9w= + dependencies: + jju "^1.1.0" + +json5@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" + integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA== + dependencies: + minimist "^1.2.5" + +jsonfile@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.0.1.tgz#98966cba214378c8c84b82e085907b40bf614179" + integrity sha512-jR2b5v7d2vIOust+w3wtFKZIfpC2pnRmFAhAC/BuweZFQR8qZzxH1OyrQ10HmdVYiXWkYUqPVsz91cG7EL2FBg== + dependencies: + universalify "^1.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +jsonparse@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" + integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= + +jszip@^3.3.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.4.0.tgz#1a69421fa5f0bb9bc222a46bca88182fba075350" + integrity sha512-gZAOYuPl4EhPTXT0GjhI3o+ZAz3su6EhLrKUoAivcKqyqC7laS5JEv4XWZND9BgcDcF83vI85yGbDmDR6UhrIg== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + set-immediate-shim "~1.0.1" + +keyv@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" + integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA== + dependencies: + json-buffer "3.0.0" + +kleur@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" + integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + +latest-version@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face" + integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA== + dependencies: + package-json "^6.3.0" + +levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + +libnpmconfig@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/libnpmconfig/-/libnpmconfig-1.2.1.tgz#c0c2f793a74e67d4825e5039e7a02a0044dfcbc0" + integrity sha512-9esX8rTQAHqarx6qeZqmGQKBNZR5OIbl/Ayr0qQDy3oXja2iFVQQI81R6GZ2a02bSNZ9p3YOGX1O6HHCb1X7kA== + dependencies: + figgy-pudding "^3.5.1" + find-up "^3.0.0" + ini "^1.3.5" + +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + +linebreak@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/linebreak/-/linebreak-1.0.2.tgz#4b5781733e9a9eb2849dba2f963e47c887f8aa06" + integrity sha512-bJwSRsJeAmaZYnkcwl5sCQNfSDAhBuXxb6L27tb+qkBRtUQSSTUa5bcgCPD6hFEkRNlpWHfK7nFMmcANU7ZP1w== + dependencies: + base64-js "0.0.8" + brfs "^2.0.2" + unicode-trie "^1.0.0" + +locate-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" + integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== + dependencies: + p-locate "^3.0.0" + path-exists "^3.0.0" + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + +lodash@^4.17.15, lodash@^4.2.0: + version "4.17.19" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" + integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== + +lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" + integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== + +lowercase-keys@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" + integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +magic-string@^0.22.4: + version "0.22.5" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.5.tgz#8e9cf5afddf44385c1da5bc2a6a0dbd10b03657e" + integrity sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w== + dependencies: + vlq "^0.2.2" + +make-dir@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + dependencies: + semver "^6.0.0" + +make-fetch-happen@^8.0.7: + version "8.0.7" + resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-8.0.7.tgz#7f98e6e75784c541833d0ffe2f82c31418a87ac2" + integrity sha512-rkDA4c1nMXVqLkfOaM5RK2dxkUndjLOCrPycTDZgbkFDzhmaCO3P1dmCW//yt1I/G1EcedJqMsSjWkV79Hh4hQ== + dependencies: + agentkeepalive "^4.1.0" + cacache "^15.0.0" + http-cache-semantics "^4.0.4" + http-proxy-agent "^4.0.1" + https-proxy-agent "^5.0.0" + is-lambda "^1.0.1" + lru-cache "^5.1.1" + minipass "^3.1.3" + minipass-collect "^1.0.2" + minipass-fetch "^1.1.2" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.2" + promise-retry "^1.1.1" + socks-proxy-agent "^5.0.0" + ssri "^8.0.0" + +merge-source-map@1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/merge-source-map/-/merge-source-map-1.0.4.tgz#a5de46538dae84d4114cc5ea02b4772a6346701f" + integrity sha1-pd5GU42uhNQRTMXqArR3KmNGcB8= + dependencies: + source-map "^0.5.6" + +mimic-response@^1.0.0, mimic-response@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" + integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== + +minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + +minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + +minipass-collect@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617" + integrity sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA== + dependencies: + minipass "^3.0.0" + +minipass-fetch@^1.1.2, minipass-fetch@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-1.2.1.tgz#1b97ecb559be56b09812d45b2e9509f1f59ece2f" + integrity sha512-ssHt0dkljEDaKmTgQ04DQgx2ag6G2gMPxA5hpcsoeTbfDgRf2fC2gNSRc6kISjD7ckCpHwwQvXxuTBK8402fXg== + dependencies: + minipass "^3.1.0" + minipass-pipeline "^1.2.2" + minipass-sized "^1.0.3" + minizlib "^2.0.0" + optionalDependencies: + encoding "^0.1.12" + +minipass-flush@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/minipass-flush/-/minipass-flush-1.0.5.tgz#82e7135d7e89a50ffe64610a787953c4c4cbb373" + integrity sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw== + dependencies: + minipass "^3.0.0" + +minipass-json-stream@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz#7edbb92588fbfc2ff1db2fc10397acb7b6b44aa7" + integrity sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg== + dependencies: + jsonparse "^1.3.1" + minipass "^3.0.0" + +minipass-pipeline@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.3.tgz#55f7839307d74859d6e8ada9c3ebe72cec216a34" + integrity sha512-cFOknTvng5vqnwOpDsZTWhNll6Jf8o2x+/diplafmxpuIymAjzoOolZG0VvQf3V2HgqzJNhnuKHYp2BqDgz8IQ== + dependencies: + minipass "^3.0.0" + +minipass-sized@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/minipass-sized/-/minipass-sized-1.0.3.tgz#70ee5a7c5052070afacfbc22977ea79def353b70" + integrity sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g== + dependencies: + minipass "^3.0.0" + +minipass@^3.0.0, minipass@^3.0.1, minipass@^3.1.0, minipass@^3.1.1, minipass@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd" + integrity sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg== + dependencies: + yallist "^4.0.0" + +minizlib@^2.0.0, minizlib@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.0.tgz#fd52c645301ef09a63a2c209697c294c6ce02cf3" + integrity sha512-EzTZN/fjSvifSX0SlqUERCN39o6T40AMarPbv0MrarSFtIITCBh7bi+dU8nxGFHuqs9jdIAeoYoKuQAAASsPPA== + dependencies: + minipass "^3.0.0" + yallist "^4.0.0" + +mkdirp@^1.0.3, mkdirp@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + +moment-timezone@^0.5.11: + version "0.5.28" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.28.tgz#f093d789d091ed7b055d82aa81a82467f72e4338" + integrity sha512-TDJkZvAyKIVWg5EtVqRzU97w0Rb0YVbfpqyjgu6GwXCAohVRqwZjf4fOzDE6p1Ch98Sro/8hQQi65WDXW5STPw== + dependencies: + moment ">= 2.9.0" + +"moment@>= 2.9.0", moment@^2.10.2, moment@^2.22.2, moment@^2.24.0, moment@^2.9.0: + version "2.24.0" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" + integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== + +ms@^2.0.0, ms@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +nested-error-stacks@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz#d2cc9fc5235ddb371fc44d506234339c8e4b0a4b" + integrity sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A== + +next-tick@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" + integrity sha1-yobR/ogoFpsBICCOPchCS524NCw= + +node-alias@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/node-alias/-/node-alias-1.0.4.tgz#1f1b916b56b9ea241c0135f97ced6940f556f292" + integrity sha1-HxuRa1a56iQcATX5fO1pQPVW8pI= + dependencies: + chalk "^1.1.1" + lodash "^4.2.0" + +normalize-url@^4.1.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.0.tgz#453354087e6ca96957bd8f5baf753f5982142129" + integrity sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ== + +npm-bundled@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b" + integrity sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA== + dependencies: + npm-normalize-package-bin "^1.0.1" + +npm-check-updates@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/npm-check-updates/-/npm-check-updates-4.1.2.tgz#700f52b17999aa914a5fbdd9c1a0a51d093d89c0" + integrity sha512-CRO20Z12fygKL/ow4j4pnpyxevda/PuFbWpsF5E9sFW0B+M3d32A1dD+fTHLDjgderhKXr64W8qQ6M/Gq8OLiw== + dependencies: + chalk "^3.0.0" + cint "^8.2.1" + cli-table "^0.3.1" + commander "^5.0.0" + fast-diff "^1.2.0" + find-up "4.1.0" + get-stdin "^7.0.0" + json-parse-helpfulerror "^1.0.3" + libnpmconfig "^1.2.1" + lodash "^4.17.15" + node-alias "^1.0.4" + p-map "^4.0.0" + pacote "^11.1.4" + progress "^2.0.3" + prompts "^2.3.2" + rc-config-loader "^3.0.0" + requireg "^0.2.2" + semver "^7.2.1" + semver-utils "^1.1.4" + spawn-please "^0.3.0" + update-notifier "^4.1.0" + +npm-install-checks@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/npm-install-checks/-/npm-install-checks-4.0.0.tgz#a37facc763a2fde0497ef2c6d0ac7c3fbe00d7b4" + integrity sha512-09OmyDkNLYwqKPOnbI8exiOZU2GVVmQp7tgez2BPi5OZC8M82elDAps7sxC4l//uSUtotWqoEIDwjRvWH4qz8w== + dependencies: + semver "^7.1.1" + +npm-normalize-package-bin@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2" + integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA== + +npm-package-arg@^8.0.0, npm-package-arg@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-8.0.1.tgz#9d76f8d7667b2373ffda60bb801a27ef71e3e270" + integrity sha512-/h5Fm6a/exByzFSTm7jAyHbgOqErl9qSNJDQF32Si/ZzgwT2TERVxRxn3Jurw1wflgyVVAxnFR4fRHPM7y1ClQ== + dependencies: + hosted-git-info "^3.0.2" + semver "^7.0.0" + validate-npm-package-name "^3.0.0" + +npm-packlist@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-2.1.1.tgz#08806a1df79acdc43d02d20c83a3d5472d96c90c" + integrity sha512-95TSDvGwujIhqfSpIiRRLodEF+y6mJMopuZdahoGzqtRDFZXGav46S0p6ngeWaiAkb5R72w6eVARhzej0HvZeQ== + dependencies: + glob "^7.1.6" + ignore-walk "^3.0.3" + npm-bundled "^1.1.1" + npm-normalize-package-bin "^1.0.1" + +npm-pick-manifest@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/npm-pick-manifest/-/npm-pick-manifest-6.1.0.tgz#2befed87b0fce956790f62d32afb56d7539c022a" + integrity sha512-ygs4k6f54ZxJXrzT0x34NybRlLeZ4+6nECAIbr2i0foTnijtS1TJiyzpqtuUAJOps/hO0tNDr8fRV5g+BtRlTw== + dependencies: + npm-install-checks "^4.0.0" + npm-package-arg "^8.0.0" + semver "^7.0.0" + +npm-registry-fetch@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/npm-registry-fetch/-/npm-registry-fetch-8.1.1.tgz#999f0a0cb7fcf31dd0ad9f2ba12663f392af6312" + integrity sha512-3FCYb/YO6k9vfPMSU6H1CbixQAzoLuBqTTpjcks2PHlN59c0ENTYrDF8lCRvgLm1iAhwhwZg7pRq2VOTw3Yfaw== + dependencies: + "@npmcli/ci-detect" "^1.0.0" + lru-cache "^5.1.1" + make-fetch-happen "^8.0.7" + minipass "^3.1.3" + minipass-fetch "^1.1.2" + minipass-json-stream "^1.0.1" + minizlib "^2.0.0" + npm-package-arg "^8.0.0" + +object-inspect@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67" + integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw== + +object-inspect@~1.4.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.4.1.tgz#37ffb10e71adaf3748d05f713b4c9452f402cbc4" + integrity sha512-wqdhLpfCUbEsoEwl3FXwGyv8ief1k/1aUdIPCqVnupM6e8l63BEJdiF/0swtn04/8p05tG/T0FrpTlfwvljOdw== + +object-is@^1.0.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.2.tgz#c5d2e87ff9e119f78b7a088441519e2eec1573b6" + integrity sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + +object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.0.6, object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da" + integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== + dependencies: + define-properties "^1.1.2" + function-bind "^1.1.1" + has-symbols "^1.0.0" + object-keys "^1.0.11" + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +optionator@^0.8.1: + version "0.8.3" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" + integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.6" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + word-wrap "~1.2.3" + +overlayscrollbars@^1.11.0: + version "1.12.0" + resolved "https://registry.yarnpkg.com/overlayscrollbars/-/overlayscrollbars-1.12.0.tgz#e3e257bbb8a179760c2c712ad08ac2c78583c9f6" + integrity sha512-zJGYLeBfaPx2VmiDfBMNTPzm9N8w8wZ6M7dm1ee8TGuet8tsK4nxOzGvEEu0SmueqMHQxhLsstf7iTWCGiYa9Q== + +p-cancelable@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" + integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== + +p-limit@^2.0.0, p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-locate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" + integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== + dependencies: + p-limit "^2.0.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-map@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" + integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== + dependencies: + aggregate-error "^3.0.0" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +package-json@^6.3.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0" + integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ== + dependencies: + got "^9.6.0" + registry-auth-token "^4.0.0" + registry-url "^5.0.0" + semver "^6.2.0" + +pacote@^11.1.4: + version "11.1.4" + resolved "https://registry.yarnpkg.com/pacote/-/pacote-11.1.4.tgz#5529a453c59881b7f059da8af6903b0f79c124b2" + integrity sha512-eUGJvSSpWFZKn3z8gig/HgnBmUl6gIWByIIaHzSyEr3tOWX0w8tFEADXtpu8HGv5E0ShCeTP6enRq8iHKCHSvw== + dependencies: + "@npmcli/git" "^2.0.1" + "@npmcli/installed-package-contents" "^1.0.5" + "@npmcli/promise-spawn" "^1.1.0" + cacache "^15.0.0" + chownr "^1.1.4" + fs-minipass "^2.1.0" + infer-owner "^1.0.4" + lru-cache "^5.1.1" + minipass "^3.0.1" + minipass-fetch "^1.2.1" + mkdirp "^1.0.3" + npm-package-arg "^8.0.1" + npm-packlist "^2.1.0" + npm-pick-manifest "^6.0.0" + npm-registry-fetch "^8.0.0" + promise-inflight "^1.0.1" + promise-retry "^1.1.1" + read-package-json-fast "^1.1.3" + rimraf "^2.7.1" + semver "^7.1.3" + ssri "^8.0.0" + tar "^6.0.1" + which "^2.0.2" + +pako@^0.2.5: + version "0.2.9" + resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" + integrity sha1-8/dSL073gjSNqBYbrZ7P1Rv4OnU= + +pako@~1.0.2: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-parse@^1.0.5, path-parse@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" + integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + +pdfkit@>=0.8.1, pdfkit@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/pdfkit/-/pdfkit-0.11.0.tgz#9cdb2fc42bd2913587fe3ddf48cc5bbb3c36f7de" + integrity sha512-1s9gaumXkYxcVF1iRtSmLiISF2r4nHtsTgpwXiK8Swe+xwk/1pm8FJjYqN7L3x13NsWnGyUFntWcO8vfqq+wwA== + dependencies: + crypto-js "^3.1.9-1" + fontkit "^1.8.0" + linebreak "^1.0.2" + png-js "^1.0.0" + +pdfmake@^0.1.65: + version "0.1.65" + resolved "https://registry.yarnpkg.com/pdfmake/-/pdfmake-0.1.65.tgz#09c4cf796809ec5fce789343560a36780ff47e37" + integrity sha512-MgzRyiKSP3IEUH7vm4oj3lpikmk5oCD9kYxiJM6Z2Xf6CP9EcikeSDey2rGd4WVvn79Y0TGqz2+to8FtWP8MrA== + dependencies: + iconv-lite "^0.5.1" + linebreak "^1.0.2" + pdfkit "^0.11.0" + svg-to-pdfkit "^0.1.8" + +png-js@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/png-js/-/png-js-1.0.0.tgz#e5484f1e8156996e383aceebb3789fd75df1874d" + integrity sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g== + +popper.js@>=1.10, popper.js@^1.14.3, popper.js@^1.16.1: + version "1.16.1" + resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b" + integrity sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ== + +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= + +prepend-http@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" + integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +progress@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" + integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== + +promise-inflight@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" + integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= + +promise-retry@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-1.1.1.tgz#6739e968e3051da20ce6497fb2b50f6911df3d6d" + integrity sha1-ZznpaOMFHaIM5kl/srUPaRHfPW0= + dependencies: + err-code "^1.0.0" + retry "^0.10.0" + +prompts@^2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.3.2.tgz#480572d89ecf39566d2bd3fe2c9fccb7c4c0b068" + integrity sha512-Q06uKs2CkNYVID0VqwfAl9mipo99zkBv/n2JtWY89Yxa3ZabWSrs0e2KTudKVa3peLUvYXMefDqIleLPVUBZMA== + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.4" + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pupa@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.0.1.tgz#dbdc9ff48ffbea4a26a069b6f9f7abb051008726" + integrity sha512-hEJH0s8PXLY/cdXh66tNEQGndDrIKNqNC5xmrysZy3i5C3oEoLna7YAOad+7u125+zH1HNXUmGEkrhb3c2VriA== + dependencies: + escape-goat "^2.0.0" + +quote-stream@^1.0.1, quote-stream@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/quote-stream/-/quote-stream-1.0.2.tgz#84963f8c9c26b942e153feeb53aae74652b7e0b2" + integrity sha1-hJY/jJwmuULhU/7rU6rnRlK34LI= + dependencies: + buffer-equal "0.0.1" + minimist "^1.1.3" + through2 "^2.0.0" + +"raphael@^2.2.0 || ^2.1.1", raphael@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/raphael/-/raphael-2.3.0.tgz#eabeb09dba861a1d4cee077eaafb8c53f3131f89" + integrity sha512-w2yIenZAQnp257XUWGni4bLMVxpUpcIl7qgxEgDIXtmSypYtlNxfXWpOBxs7LBTps5sDwhRnrToJrMUrivqNTQ== + dependencies: + eve-raphael "0.5.0" + +rc-config-loader@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/rc-config-loader/-/rc-config-loader-3.0.0.tgz#1484ed55d6fb8b21057699c8426370f7529c52a7" + integrity sha512-bwfUSB37TWkHfP+PPjb/x8BUjChFmmBK44JMfVnU7paisWqZl/o5k7ttCH+EQLnrbn2Aq8Fo1LAsyUiz+WF4CQ== + dependencies: + debug "^4.1.1" + js-yaml "^3.12.0" + json5 "^2.1.1" + require-from-string "^2.0.2" + +rc@^1.2.8, rc@~1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +read-package-json-fast@^1.1.1, read-package-json-fast@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/read-package-json-fast/-/read-package-json-fast-1.1.3.tgz#3b78464ea8f3c4447f3358635390b6946dc0737e" + integrity sha512-MmFqiyfCXV2Dmm4jH24DEGhxdkUDFivJQj4oPZQPOKywxR7HWBE6WnMWDAapfFHi3wm1b+mhR+XHlUH0CL8axg== + dependencies: + json-parse-even-better-errors "^2.0.1" + npm-normalize-package-bin "^1.0.1" + +readable-stream@^2.0.2, readable-stream@^2.2.2, readable-stream@~2.3.3, readable-stream@~2.3.6: + version "2.3.7" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readdir-scoped-modules@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309" + integrity sha512-asaikDeqAQg7JifRsZn1NJZXo9E+VwlyCfbkZhwyISinqk5zNS6266HS5kah6P0SaQKGF6SkNnZVHUzHFYxYDw== + dependencies: + debuglog "^1.0.1" + dezalgo "^1.0.0" + graceful-fs "^4.1.2" + once "^1.3.0" + +regenerator-runtime@^0.11.0: + version "0.11.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" + integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== + +regexp.prototype.flags@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz#7aba89b3c13a64509dabcf3ca8d9fbb9bdf5cb75" + integrity sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + +registry-auth-token@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.1.1.tgz#40a33be1e82539460f94328b0f7f0f84c16d9479" + integrity sha512-9bKS7nTl9+/A1s7tnPeGrUpRcVY+LUh7bfFgzpndALdPfXQBfQV77rQVtqgUV3ti4vc/Ik81Ex8UJDWDQ12zQA== + dependencies: + rc "^1.2.8" + +registry-url@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009" + integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw== + dependencies: + rc "^1.2.8" + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +requireg@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/requireg/-/requireg-0.2.2.tgz#437e77a5316a54c9bcdbbf5d1f755fe093089830" + integrity sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg== + dependencies: + nested-error-stacks "~2.0.1" + rc "~1.2.7" + resolve "~1.7.1" + +resolve@1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" + integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= + +resolve@^1.1.5: + version "1.17.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" + integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== + dependencies: + path-parse "^1.0.6" + +resolve@~1.7.1: + version "1.7.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.7.1.tgz#aadd656374fd298aee895bc026b8297418677fd3" + integrity sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw== + dependencies: + path-parse "^1.0.5" + +responselike@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" + integrity sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec= + dependencies: + lowercase-keys "^1.0.0" + +restructure@^0.5.3: + version "0.5.4" + resolved "https://registry.yarnpkg.com/restructure/-/restructure-0.5.4.tgz#f54e7dd563590fb34fd6bf55876109aeccb28de8" + integrity sha1-9U591WNZD7NP1r9Vh2EJrsyyjeg= + dependencies: + browserify-optional "^1.0.0" + +retry@^0.10.0: + version "0.10.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.10.1.tgz#e76388d217992c252750241d3d3956fed98d8ff4" + integrity sha1-52OI0heZLCUnUCQdPTlW/tmNj/Q= + +rimraf@^2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" + integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== + dependencies: + glob "^7.1.3" + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +scope-analyzer@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/scope-analyzer/-/scope-analyzer-2.1.1.tgz#5156c27de084d74bf75af9e9506aaf95c6e73dd6" + integrity sha512-azEAihtQ9mEyZGhfgTJy3IbOWEzeOrYbg7NcYEshPKnKd+LZmC3TNd5dmDxbLBsTG/JVWmCp+vDJ03vJjeXMHg== + dependencies: + array-from "^2.1.1" + dash-ast "^1.0.0" + es6-map "^0.1.5" + es6-set "^0.1.5" + es6-symbol "^3.1.1" + estree-is-function "^1.0.0" + get-assigned-identifiers "^1.1.0" + +select2@^4.0.13: + version "4.0.13" + resolved "https://registry.yarnpkg.com/select2/-/select2-4.0.13.tgz#0dbe377df3f96167c4c1626033e924372d8ef44d" + integrity sha512-1JeB87s6oN/TDxQQYCvS5EFoQyvV6eYMZZ0AeA4tdFDYWN3BAGZ8npr17UBFddU0lgAt3H0yjX3X6/ekOj1yjw== + +semver-diff@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" + integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg== + dependencies: + semver "^6.3.0" + +semver-utils@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/semver-utils/-/semver-utils-1.1.4.tgz#cf0405e669a57488913909fc1c3f29bf2a4871e2" + integrity sha512-EjnoLE5OGmDAVV/8YDoN5KiajNadjzIp9BAHOhYeQHt7j0UWxjmgsx4YD48wp4Ue1Qogq38F1GNUJNqF1kKKxA== + +semver@^6.0.0, semver@^6.2.0, semver@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + +semver@^7.0.0, semver@^7.1.1, semver@^7.1.3, semver@^7.2.1: + version "7.3.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938" + integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ== + +set-immediate-shim@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" + integrity sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E= + +shallow-copy@~0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/shallow-copy/-/shallow-copy-0.0.1.tgz#415f42702d73d810330292cc5ee86eae1a11a170" + integrity sha1-QV9CcC1z2BAzApLMXuhurhoRoXA= + +signal-exit@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" + integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== + +sisteransi@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + +smart-buffer@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.1.0.tgz#91605c25d91652f4661ea69ccf45f1b331ca21ba" + integrity sha512-iVICrxOzCynf/SNaBQCw34eM9jROU/s5rzIhpOvzhzuYHfJR/DhZfDkXiZSgKXfgv26HT3Yni3AV/DGw0cGnnw== + +socks-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-5.0.0.tgz#7c0f364e7b1cf4a7a437e71253bed72e9004be60" + integrity sha512-lEpa1zsWCChxiynk+lCycKuC502RxDWLKJZoIhnxrWNjLSDGYRFflHA1/228VkRcnv9TIb8w98derGbpKxJRgA== + dependencies: + agent-base "6" + debug "4" + socks "^2.3.3" + +socks@^2.3.3: + version "2.4.1" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.4.1.tgz#cea68a280a3bf7cb6333dbb40cfb243d10725e9d" + integrity sha512-8mWHeYC1OA0500qzb+sqwm0Hzi8oBpeuI1JugoBVMEJtJvxSgco8xFSK+NRnZcHeeWjTbF82KUDo5sXH22TY5A== + dependencies: + ip "1.1.5" + smart-buffer "^4.1.0" + +source-map@^0.5.6: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= + +source-map@~0.1.30: + version "0.1.43" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.43.tgz#c24bc146ca517c1471f5dacbe2571b2b7f9e3346" + integrity sha1-wkvBRspRfBRx9drL4lcbK3+eM0Y= + dependencies: + amdefine ">=0.0.4" + +source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +sparklines@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/sparklines/-/sparklines-1.2.0.tgz#bbbf2dede9bc337749e430baf67c2b37f88f6fa0" + integrity sha512-6OFlZzdFXyfFGJ8R5wrc9GdjoeQpjFuwkMKF1yVehLATHVrf4dSyZ4veGOFQ0mQ3xdJ0bCQSbd0idsm1gd2qWg== + +spawn-please@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/spawn-please/-/spawn-please-0.3.0.tgz#db338ec4cff63abc69f1d0e08cee9eb8bebd9d11" + integrity sha1-2zOOxM/2Orxp8dDgjO6euL69nRE= + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= + +ssri@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.1.tgz#638e4e439e2ffbd2cd289776d5ca457c4f51a2af" + integrity sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ== + dependencies: + minipass "^3.1.1" + +static-eval@^2.0.0, static-eval@^2.0.2: + version "2.0.5" + resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.0.5.tgz#f0782e66999c4b3651cda99d9ce59c507d188f71" + integrity sha512-nNbV6LbGtMBgv7e9LFkt5JV8RVlRsyJrphfAt9tOtBBW/SfnzZDf2KnS72an8e434A+9e/BmJuTxeGPvrAK7KA== + dependencies: + escodegen "^1.11.1" + +static-module@^2.2.0: + version "2.2.5" + resolved "https://registry.yarnpkg.com/static-module/-/static-module-2.2.5.tgz#bd40abceae33da6b7afb84a0e4329ff8852bfbbf" + integrity sha512-D8vv82E/Kpmz3TXHKG8PPsCPg+RAX6cbCOyvjM6x04qZtQ47EtJFVwRsdov3n5d6/6ynrOY9XB4JkaZwB2xoRQ== + dependencies: + concat-stream "~1.6.0" + convert-source-map "^1.5.1" + duplexer2 "~0.1.4" + escodegen "~1.9.0" + falafel "^2.1.0" + has "^1.0.1" + magic-string "^0.22.4" + merge-source-map "1.0.4" + object-inspect "~1.4.0" + quote-stream "~1.0.2" + readable-stream "~2.3.3" + shallow-copy "~0.0.1" + static-eval "^2.0.0" + through2 "~2.0.3" + +static-module@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/static-module/-/static-module-3.0.3.tgz#cc2301ed3fe353e2d2a2195137013853676f9960" + integrity sha512-RDaMYaI5o/ym0GkCqL/PlD1Pn216omp8fY81okxZ6f6JQxWW5tptOw9reXoZX85yt/scYvbWIt6uoszeyf+/MQ== + dependencies: + acorn-node "^1.3.0" + concat-stream "~1.6.0" + convert-source-map "^1.5.1" + duplexer2 "~0.1.4" + escodegen "~1.9.0" + has "^1.0.1" + magic-string "^0.22.4" + merge-source-map "1.0.4" + object-inspect "~1.4.0" + readable-stream "~2.3.3" + scope-analyzer "^2.0.1" + shallow-copy "~0.0.1" + static-eval "^2.0.2" + through2 "~2.0.3" + +string-width@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" + integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.1.0" + +string-width@^4.0.0, string-width@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5" + integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.0" + +string.prototype.trimend@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913" + integrity sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + +string.prototype.trimleft@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz#4408aa2e5d6ddd0c9a80739b087fbc067c03b3cc" + integrity sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + string.prototype.trimstart "^1.0.0" + +string.prototype.trimright@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz#c76f1cef30f21bbad8afeb8db1511496cfb0f2a3" + integrity sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + string.prototype.trimend "^1.0.0" + +string.prototype.trimstart@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz#14af6d9f34b053f7cfc89b72f8f2ee14b9039a54" + integrity sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +strip-ansi@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" + integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== + dependencies: + ansi-regex "^4.1.0" + +strip-ansi@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" + integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== + dependencies: + ansi-regex "^5.0.0" + +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= + +summernote@^0.8.16: + version "0.8.16" + resolved "https://registry.yarnpkg.com/summernote/-/summernote-0.8.16.tgz#73f03a0cfac81d3c473de73ee8cc819cd6189b31" + integrity sha512-eheLC4jXAw+GEzmg5+/pCwMchempcU4i+T+/y99rZhALqPW7R1XRQD7rO/VUBboz6awApjPRRD30rRD4XJdEaQ== + dependencies: + npm-check-updates "^4.0.1" + +supports-color@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" + integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= + +supports-color@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" + integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== + dependencies: + has-flag "^4.0.0" + +svg-to-pdfkit@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/svg-to-pdfkit/-/svg-to-pdfkit-0.1.8.tgz#5921765922044843f0c1a5b25ec1ef8a4a33b8af" + integrity sha512-QItiGZBy5TstGy+q8mjQTMGRlDDOARXLxH+sgVm1n/LYeo0zFcQlcCh8m4zi8QxctrxB9Kue/lStc/RD5iLadQ== + dependencies: + pdfkit ">=0.8.1" + +sweetalert2@^9.10.8: + version "9.10.12" + resolved "https://registry.yarnpkg.com/sweetalert2/-/sweetalert2-9.10.12.tgz#e3752fcbe13e7d23d0f85ee84b3c5fdc923da552" + integrity sha512-RnarmbDGTPmwecJbaVdq5LvlzbVReIOtPk0huPnXOE19G00xMxGcTY0wjt9AjwsexUnLivLXc3b6nD6+D6NlGg== + +tar@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.1.tgz#7b3bd6c313cb6e0153770108f8d70ac298607efa" + integrity sha512-bKhKrrz2FJJj5s7wynxy/fyxpE0CmCjmOQ1KV4KkgXFWOgoIT/NbTMnB1n+LFNrNk0SSBVGGxcK5AGsyC+pW5Q== + dependencies: + chownr "^1.1.3" + fs-minipass "^2.0.0" + minipass "^3.0.0" + minizlib "^2.1.0" + mkdirp "^1.0.3" + yallist "^4.0.0" + +tar@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.2.tgz#5df17813468a6264ff14f766886c622b84ae2f39" + integrity sha512-Glo3jkRtPcvpDlAs/0+hozav78yoXKFr+c4wgw62NNMO3oo4AaJdCo21Uu7lcwr55h39W2XD1LMERc64wtbItg== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^3.0.0" + minizlib "^2.1.0" + mkdirp "^1.0.3" + yallist "^4.0.0" + +tempusdominus-bootstrap-4@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/tempusdominus-bootstrap-4/-/tempusdominus-bootstrap-4-5.1.2.tgz#3c9906ca6e5d563faa0b81b2fdc6aa79cad9c0be" + integrity sha512-ksD8qc4wOJeE19wvryXmEpRzMUSZu4wSOdG6zKSn8l4ccad16249KOX1j0CccyZpuuES/n4FLqLAUB+Dd1LTBA== + dependencies: + bootstrap ">=4.1.2" + jquery "^3.0" + moment "^2.22.2" + moment-timezone "^0.5.11" + popper.js "^1.14.3" + +term-size@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.0.tgz#1f16adedfe9bdc18800e1776821734086fcc6753" + integrity sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw== + +through2@^2.0.0, through2@~2.0.3: + version "2.0.5" + resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" + integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== + dependencies: + readable-stream "~2.3.6" + xtend "~4.0.1" + +through@~2.3.4: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= + +tiny-inflate@^1.0.0, tiny-inflate@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" + integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw== + +to-readable-stream@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771" + integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q== + +toastr@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/toastr/-/toastr-2.1.4.tgz#8b43be64fb9d0c414871446f2db8e8ca4e95f181" + integrity sha1-i0O+ZPudDEFIcURvLbjoyk6V8YE= + dependencies: + jquery ">=1.12.0" + +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= + dependencies: + prelude-ls "~1.1.2" + +type-fest@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" + integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== + +type@^1.0.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/type/-/type-1.2.0.tgz#848dd7698dafa3e54a6c479e759c4bc3f18847a0" + integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg== + +type@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/type/-/type-2.0.0.tgz#5f16ff6ef2eb44f260494dae271033b29c09a9c3" + integrity sha512-KBt58xCHry4Cejnc2ISQAF7QY+ORngsWfxezO68+12hKV6lQY8P/psIkcbjeHWn7MqcgciWJyCCevFMJdIXpow== + +typedarray-to-buffer@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" + integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== + dependencies: + is-typedarray "^1.0.0" + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= + +unicode-properties@^1.0.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/unicode-properties/-/unicode-properties-1.3.1.tgz#cc642b6314bde2c691d65dd94cece09ed84f1282" + integrity sha512-nIV3Tf3LcUEZttY/2g4ZJtGXhWwSkuLL+rCu0DIAMbjyVPj+8j5gNVz4T/sVbnQybIsd5SFGkPKg/756OY6jlA== + dependencies: + base64-js "^1.3.0" + unicode-trie "^2.0.0" + +unicode-trie@^0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/unicode-trie/-/unicode-trie-0.3.1.tgz#d671dddd89101a08bac37b6a5161010602052085" + integrity sha1-1nHd3YkQGgi6w3tqUWEBBgIFIIU= + dependencies: + pako "^0.2.5" + tiny-inflate "^1.0.0" + +unicode-trie@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unicode-trie/-/unicode-trie-1.0.0.tgz#f649afdca127135edb55ca0ad7c8c60656d92ad1" + integrity sha512-v5raLKsobbFbWLMoX9+bChts/VhPPj3XpkNr/HbqkirXR1DPk8eo9IYKyvk0MQZFkaoRsFj2Rmaqgi2rfAZYtA== + dependencies: + pako "^0.2.5" + tiny-inflate "^1.0.0" + +unicode-trie@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unicode-trie/-/unicode-trie-2.0.0.tgz#8fd8845696e2e14a8b67d78fa9e0dd2cad62fec8" + integrity sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ== + dependencies: + pako "^0.2.5" + tiny-inflate "^1.0.0" + +unique-filename@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" + integrity sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ== + dependencies: + unique-slug "^2.0.0" + +unique-slug@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.2.tgz#baabce91083fc64e945b0f3ad613e264f7cd4e6c" + integrity sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w== + dependencies: + imurmurhash "^0.1.4" + +unique-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" + integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg== + dependencies: + crypto-random-string "^2.0.0" + +universalify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d" + integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug== + +update-notifier@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-4.1.0.tgz#4866b98c3bc5b5473c020b1250583628f9a328f3" + integrity sha512-w3doE1qtI0/ZmgeoDoARmI5fjDoT93IfKgEGqm26dGUOh8oNpaSTsGNdYRN/SjOuo10jcJGwkEL3mroKzktkew== + dependencies: + boxen "^4.2.0" + chalk "^3.0.0" + configstore "^5.0.1" + has-yarn "^2.1.0" + import-lazy "^2.1.0" + is-ci "^2.0.0" + is-installed-globally "^0.3.1" + is-npm "^4.0.0" + is-yarn-global "^0.3.0" + latest-version "^5.0.0" + pupa "^2.0.1" + semver-diff "^3.1.1" + xdg-basedir "^4.0.0" + +url-parse-lax@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c" + integrity sha1-FrXK/Afb42dsGxmZF3gj1lA6yww= + dependencies: + prepend-http "^2.0.0" + +util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + +validate-npm-package-name@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz#5fa912d81eb7d0c74afc140de7317f0ca7df437e" + integrity sha1-X6kS2B630MdK/BQN5zF/DKffQ34= + dependencies: + builtins "^1.0.3" + +vlq@^0.2.2: + version "0.2.3" + resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26" + integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow== + +which@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +widest-line@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" + integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== + dependencies: + string-width "^4.0.0" + +word-wrap@~1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" + integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +write-file-atomic@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" + integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== + dependencies: + imurmurhash "^0.1.4" + is-typedarray "^1.0.0" + signal-exit "^3.0.2" + typedarray-to-buffer "^3.1.5" + +xdg-basedir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" + integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== + +xtend@^4.0.2, xtend@~4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==