├── .dockerignore ├── .github └── workflows │ └── build.yaml ├── .gitignore ├── .vscode └── launch.json ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── TODO.md ├── cmd ├── contracts.go ├── migrate │ └── main.go └── serve │ └── main.go ├── codegen.sh ├── deploy ├── helm │ └── wg-access-server │ │ ├── .helmignore │ │ ├── Chart.yaml │ │ ├── README.md │ │ ├── templates │ │ ├── _helpers.tpl │ │ ├── configmap.yaml │ │ ├── deployment.yaml │ │ ├── ingress.yaml │ │ ├── pvc.yaml │ │ ├── secret.yaml │ │ └── service.yaml │ │ └── values.yaml └── k8s │ └── quickstart.yaml ├── docker-compose.yml ├── docs ├── 2-configuration.md ├── 3-storage.md ├── 4-auth.md ├── charts │ ├── wg-access-server-0.0.9.tgz │ ├── wg-access-server-0.1.0.tgz │ ├── wg-access-server-0.1.1.tgz │ ├── wg-access-server-0.2.0-rc3.tgz │ ├── wg-access-server-0.2.0-rc4.tgz │ ├── wg-access-server-0.2.0-rc5.tgz │ ├── wg-access-server-0.2.0-rc6.tgz │ ├── wg-access-server-0.2.0-rc7.tgz │ ├── wg-access-server-0.2.0-rc8.tgz │ ├── wg-access-server-0.2.0.tgz │ ├── wg-access-server-0.2.1.tgz │ ├── wg-access-server-0.2.2.tgz │ ├── wg-access-server-0.2.3.tgz │ ├── wg-access-server-0.2.4-rc1.tgz │ ├── wg-access-server-0.2.4.tgz │ ├── wg-access-server-0.2.5-rc1.tgz │ ├── wg-access-server-0.2.5-rc2.tgz │ ├── wg-access-server-0.2.5-rc3.tgz │ ├── wg-access-server-0.2.5.tgz │ ├── wg-access-server-v0.3.0-rc1.tgz │ ├── wg-access-server-v0.3.0-rc2.tgz │ ├── wg-access-server-v0.3.0.tgz │ ├── wg-access-server-v0.4.0.tgz │ ├── wg-access-server-v0.4.1.tgz │ ├── wg-access-server-v0.4.2.tgz │ ├── wg-access-server-v0.4.3.tgz │ ├── wg-access-server-v0.4.4.tgz │ ├── wg-access-server-v0.4.5.tgz │ └── wg-access-server-v0.4.6.tgz ├── deployment │ ├── 1-docker.md │ ├── 2-docker-compose.md │ └── 3-kubernetes.md ├── index.md └── index.yaml ├── go.mod ├── go.sum ├── internal ├── config │ └── config.go ├── devices │ ├── devices.go │ └── metadata.go ├── dnsproxy │ └── server.go ├── network │ └── network.go ├── services │ ├── api_router.go │ ├── converters.go │ ├── device_service.go │ ├── health.go │ ├── middleware.go │ ├── server_service.go │ └── website_router.go ├── storage │ ├── contracts.go │ ├── contracts_test.go │ ├── inmemory.go │ ├── inprocesswatcher.go │ ├── pgwatcher.go │ ├── sql.go │ └── utils.go └── traces │ └── traces.go ├── main.go ├── mkdocs.yml ├── pkg └── authnz │ ├── authconfig │ ├── authconfig.go │ ├── basic.go │ ├── gitlab.go │ └── oidc.go │ ├── authruntime │ └── runtime.go │ ├── authsession │ ├── claims.go │ ├── identity.go │ ├── middleware.go │ └── session.go │ ├── authtemplates │ └── login.go │ ├── authutil │ └── random.go │ └── router.go ├── proto ├── devices.proto ├── proto │ ├── devices.pb.go │ └── server.pb.go └── server.proto ├── publish.py ├── requirements-docs.txt ├── screenshots ├── connect-desktop.png ├── connect-mobile.png ├── devices.png └── signin.png ├── scripts └── run-postgres.sh └── website ├── .gitignore ├── .prettierrc.js ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo-192.png ├── logo-310.png ├── roboto │ ├── roboto-latin-400.woff │ ├── roboto-latin-400.woff2 │ ├── roboto-latin-500.woff │ └── roboto-latin-500.woff2 └── robots.txt ├── src ├── Api.ts ├── App.tsx ├── AppState.ts ├── Cookies.ts ├── Platform.ts ├── Util.ts ├── components │ ├── AddDevice.tsx │ ├── DeviceListItem.tsx │ ├── Devices.tsx │ ├── GetConnected.tsx │ ├── IconMenu.tsx │ ├── Icons.tsx │ ├── Info.tsx │ ├── Navigation.tsx │ ├── PopoverDisplay.tsx │ ├── Present.tsx │ ├── QRCode.tsx │ ├── TabPanel.tsx │ └── Toast.tsx ├── index.css ├── index.tsx ├── pages │ ├── YourDevices.tsx │ └── admin │ │ └── AllDevices.tsx ├── react-app-env.d.ts └── sdk │ ├── devices_pb.ts │ └── server_pb.ts ├── tsconfig.json └── types ├── import.d.ts └── static.d.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | **/node_modules/ 3 | **/build/ 4 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build and push docker images 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | tags: 8 | - 'v*.*.*' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Prepare 15 | id: prep 16 | run: | 17 | TAGS="" 18 | IMAGE="place1/wg-access-server" 19 | 20 | if [[ "$GITHUB_REF" == refs/heads/master ]]; then 21 | TAGS="$IMAGE:master" 22 | elif [[ "$GITHUB_REF" == refs/tags/* ]]; then 23 | VERSION="${GITHUB_REF#refs/tags/}" 24 | TAGS="$IMAGE:$VERSION" 25 | if [[ ! "$VERSION" =~ -rc.* ]]; then 26 | TAGS="$TAGS,$IMAGE:latest" 27 | fi 28 | fi 29 | 30 | echo "building ref: $GITHUB_REF" 31 | echo "docker images: $TAGS" 32 | echo ::set-output name=tags::${TAGS} 33 | 34 | - name: Checkout 35 | uses: actions/checkout@v2 36 | 37 | - name: Setup QEMU 38 | uses: docker/setup-qemu-action@v1 39 | 40 | - name: Setup Docker buildx 41 | uses: docker/setup-buildx-action@v1 42 | with: 43 | version: latest 44 | driver-opts: image=moby/buildkit:v0.8-beta 45 | 46 | - name: Cache Docker layers 47 | uses: actions/cache@v2 48 | with: 49 | path: /tmp/.buildx-cache 50 | key: ${{ runner.os }}-buildx-${{ github.sha }} 51 | restore-keys: | 52 | ${{ runner.os }}-buildx- 53 | 54 | - name: Login Docker hub 55 | uses: docker/login-action@v1 56 | with: 57 | username: ${{ secrets.DOCKER_USERNAME }} 58 | password: ${{ secrets.DOCKER_TOKEN }} 59 | 60 | - name: Build and push 61 | uses: docker/build-push-action@v2 62 | with: 63 | context: . 64 | file: Dockerfile 65 | platforms: linux/amd64,linux/arm64,linux/arm/v7 66 | push: ${{ github.event_name != 'pull_request' }} 67 | tags: ${{ steps.prep.outputs.tags }} 68 | cache-from: type=local,src=/tmp/.buildx-cache 69 | cache-to: type=local,dest=/tmp/.buildx-cache 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Default ignore for this project ### 2 | config.yaml 3 | data/ 4 | ./wg-access-server 5 | site/ 6 | 7 | ### Code ### 8 | .vscode/* 9 | !.vscode/settings.json 10 | !.vscode/tasks.json 11 | !.vscode/launch.json 12 | !.vscode/extensions.json 13 | 14 | ### Go ### 15 | # Binaries for programs and plugins 16 | *.exe 17 | *.exe~ 18 | *.dll 19 | *.so 20 | *.dylib 21 | 22 | # Test binary, built with `go test -c` 23 | *.test 24 | 25 | # Output of the go coverage tool, specifically when used with LiteIDE 26 | *.out 27 | 28 | ### Go Patch ### 29 | /vendor/ 30 | /Godeps/ 31 | 32 | ### react ### 33 | .DS_* 34 | *.log 35 | logs 36 | **/*.backup.* 37 | **/*.back.* 38 | 39 | ### website react app ### 40 | 41 | # dependencies 42 | website/node_modules 43 | website/.pnp 44 | website/.pnp.js 45 | 46 | # testing 47 | website/coverage 48 | internal/storage/sqlite.db 49 | 50 | # production 51 | website/build 52 | 53 | # misc 54 | .env 55 | website/.DS_Store 56 | website/.env.local 57 | website/.env.development.local 58 | website/.env.test.local 59 | website/.env.production.local 60 | 61 | website/npm-debug.log* 62 | website/yarn-debug.log* 63 | website/yarn-error.log* 64 | website/yarn.lock 65 | 66 | __debug_bin 67 | 68 | db.sqlite3 69 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${workspaceFolder}/main.go", 13 | "env": { 14 | "WG_ADMIN_PASSWORD": "example", 15 | "WG_WIREGUARD_PRIVATE_KEY": "4DRYOeSSeZyWRrLw357Pg9sv/RppMGwveTwz7sxM4mo=", 16 | "WG_STORAGE": "sqlite3:///tmp/wg-access-server.sqlite3" 17 | }, 18 | "args": [ 19 | "serve", 20 | "--config=config.yaml", 21 | "--no-wireguard-enabled", 22 | "--no-dns-enabled" 23 | ] 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [v0.4.6] 9 | 10 | - The docker compose file now works without a config file (it's optional) 11 | 12 | ## [v0.4.5] 13 | 14 | - Fixed a bug that caused devices to not correctly sync when using mysql or sqlite3 15 | 16 | ## [v0.4.4] 17 | 18 | ### Changed 19 | 20 | - The default AllowedIPs setting was changed from "0.0.0.0/1, 128.0.0.0/1" to "0.0.0.0/0". 21 | 22 | ## [v0.4.3] 23 | 24 | ### Changed 25 | 26 | - The device list on the website now updates a little less frequently. 27 | - The device list now always shows the "last seen" field to hopefully 28 | better reflect what the "connected" status means. 29 | - The metadata scraping loop has been updated to be more efficient when 30 | there are many disconnected peers compared to connected peers. 31 | - The metadata scraping algorithm is now more friendly for HA deployments. 32 | 33 | ## [v0.4.2] 34 | 35 | ### Bug Fixes 36 | 37 | - The vpn Allowed IPs setting is now correctly enforced. 38 | 39 | ## [v0.4.1] 40 | 41 | ### Bug Fixes 42 | 43 | - Fixed a bug that caused devices to get disconnected intermittently 44 | - The helm template now respects the "replicas" value 45 | 46 | ## [v0.4.0] 47 | 48 | ### Added 49 | 50 | - High availability (HA) is now supported when using the `postgresql://` storage backend. 51 | You can now deploy multiple replicas of wg-access-server pointing to the same Postgres DB. 52 | - The wireguard service can now be disabled via the config file. Helpful for developing 53 | on Mac and Windows. 54 | 55 | ### Removed 56 | 57 | - The `file://` storage backend was deprecated in v0.3.0 and has now been removed. 58 | See the v0.3.0 changelog entry for more information about migrating your data. 59 | 60 | ## [v0.3.0] 61 | 62 | ### Added 63 | 64 | - arm64 and arm/v7 docker image support + github actions thanks to [@timtorChen](https://github.com/Place1/wg-access-server/pull/73) 65 | 66 | ### Changed 67 | 68 | - the wireguard private key is now required when the storage backend is persistent (i.e. not `memory://`) 69 | - configuration flags, environment variables and file properties have been refactored for consistency 70 | * all configuration file properties (excluding auth providers) can now be set via flags and environment variables 71 | * all environment variables are prefixed with `WG_` to avoid collisions in hosted environments like Kubernetes 72 | * all flags & environment variables are named consistently 73 | * **breaking:** no functionality has been removed but you'll need to update any flags/envvars that you're using 74 | 75 | ### Deprecations 76 | 77 | - deprecated support for having no admin account 78 | * a config error will be thrown in v0.4.0 if an admin account is not configured 79 | * see the README.md for examples on setting the admin account 80 | - deprecated `file://` storage in favour of `sqlite3://` 81 | * will be removed in v0.4.0 82 | * there is now a storage `migrate` command that you can use to move your data to a different storage backend 83 | * see the docs for migrating your data: https://place1.github.io/wg-access-server/3-storage/#example-file-to-sqlite3 84 | 85 | ## [0.2.5] 86 | 87 | ### Added 88 | 89 | - Admin users can now delete devices from the "all devices" page (issue [#57](https://github.com/Place1/wg-access-server/issues/57)) 90 | 91 | ### Bug Fixes 92 | 93 | - Fixes website routing to solve 404s (issue [#56](https://github.com/Place1/wg-access-server/issues/56)) 94 | 95 | ## [0.2.4] 96 | 97 | ### Bug Fixes 98 | 99 | - Improved config validation and error reporting (issue [#58](https://github.com/Place1/wg-access-server/issues/58) [#61](https://github.com/Place1/wg-access-server/issues/61)) 100 | 101 | ## [0.2.3] 102 | 103 | ### Added 104 | 105 | - Helm chart now supports configuring a LoadBalancer service for the web ui ([@nqngo](https://github.com/Place1/wg-access-server/pull/60)) 106 | 107 | ## [0.2.2] 108 | 109 | ### Changed 110 | 111 | - Changed the default "AllowedIPs" to `0.0.0.0/0` 112 | 113 | ## [0.2.1] 114 | 115 | ### Changed 116 | 117 | - The "is connected" now shows devices as connected if they've been active within the last 3 minutes 118 | - Improved handling of oidc/gitlab authentication with domain verification when a user hasn't set their email 119 | 120 | ## [0.2.0] 121 | 122 | ### Added 123 | 124 | - New SQL storage backend supporting SQLite, MySQL and PostgreSQL ([@halkeye](https://github.com/Place1/wg-access-server/pull/37)) 125 | - Support for mapping claims from an OIDC auth backend to wg-access-server claims using a simple rule 126 | syntax ([@halkeye](https://github.com/Place1/wg-access-server/pull/39)). You can use this feature 127 | to decide which user has the 'admin' claim based on your own OIDC claims. 128 | - The VPN DNS proxy feature can now be disabled using config: `dns.enabled = false` 129 | - When disabled the `DNS` wireguard config value will be omitted from client wg config files 130 | - When disabled the DNSasd proxy will not be started server-side (i.e. port 53 won't be used) 131 | - Config options to change the web, wireguard and dns ports. 132 | - Better instructions for connecting a linux device ([@nfg](https://github.com/Place1/wg-access-server/pull/38)) 133 | - More helm chart flexibility ([@halkeye](https://github.com/Place1/wg-access-server/pull/33)) 134 | 135 | ### Changes 136 | 137 | - The admin UI will now show the device owner's name or email if available. 138 | - The admin UI will now show the auth provider for a given device if more than 1 auth provider is in use. 139 | - Bug fix: upstream dns now correctly configured using resolvconf if not set in config file, flag or envvar. 140 | 141 | ### Removed 142 | 143 | - dns port configuration was removed because wireguard client's only support port 53 for dns 144 | 145 | ### How to upgrade 146 | 147 | - If you've been using the `storage.directory="/some/path"` config value then 148 | you'll need to update it to `storage=file:///some/path` 149 | - If you've been using the `--storage-directory=/some/path` cli flag then 150 | you'll need to update it to `--storage="file:///some/path"` 151 | - If you've been using the `STORAGE_DIRECTORY=/some/path` environment variable then 152 | you'll need to update it to `STORAGE="file:///some/path"` 153 | 154 | ## [0.1.1] 155 | 156 | ### Changes 157 | 158 | - Helm chart bug fixes and improvements 159 | 160 | ## [0.1.0] 161 | 162 | ### Added 163 | 164 | - Added support for an admin account. An admin can see all devices registered 165 | with the server. 166 | - Added support for configuring "AllowedIPs" 167 | - New docker compose example ([@antoniebou13](https://github.com/Place1/wg-access-server/pull/13)) 168 | - Added a helm chart 169 | - Added a basic kubernetes quickstart.yaml manifest (based on helm template) 170 | - Added a documentation site based on [mkdocs](https://www.mkdocs.org/). Hosted 171 | on github pages (still a wip!) 172 | 173 | ## [0.0.9] 174 | 175 | ### Changed 176 | 177 | - Some UI/UX improvements 178 | 179 | ## [0.0.8] 180 | 181 | ### Added 182 | 183 | - Added an embedded DNS proxy 184 | 185 | ### Changed 186 | 187 | - Completely re-implemented the auth subsystem to avoid trying to integrate 188 | with Dex. OIDC, Gitlab and Basic auth are supported. 189 | 190 | ## [0.0.0] -> [0.0.7] 191 | 192 | MVP :) 193 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ### Build stage for the website frontend 2 | FROM node:10 as website 3 | RUN apt-get update 4 | RUN apt-get install -y protobuf-compiler libprotobuf-dev 5 | WORKDIR /code 6 | COPY ./website/package.json ./ 7 | COPY ./website/package-lock.json ./ 8 | RUN npm ci --no-audit --prefer-offline 9 | COPY ./proto/ ../proto/ 10 | COPY ./website/ ./ 11 | RUN npm run codegen 12 | RUN npm run build 13 | 14 | ### Build stage for the website backend server 15 | FROM golang:1.13.8-alpine as server 16 | RUN apk add gcc musl-dev 17 | RUN apk add protobuf 18 | RUN apk add protobuf-dev 19 | WORKDIR /code 20 | ENV GOOS=linux 21 | ENV GARCH=amd64 22 | ENV CGO_ENABLED=1 23 | ENV GO111MODULE=on 24 | RUN go get github.com/golang/protobuf/protoc-gen-go@v1.3.5 25 | COPY ./go.mod ./ 26 | COPY ./go.sum ./ 27 | RUN go mod download 28 | COPY ./proto/ ./proto/ 29 | COPY ./codegen.sh ./ 30 | RUN ./codegen.sh 31 | COPY ./main.go ./main.go 32 | COPY ./cmd/ ./cmd/ 33 | COPY ./pkg/ ./pkg/ 34 | COPY ./internal/ ./internal/ 35 | RUN go build -o wg-access-server 36 | 37 | ### Server 38 | FROM alpine:3.10 39 | RUN apk add iptables 40 | RUN apk add wireguard-tools 41 | RUN apk add curl 42 | ENV WG_CONFIG="/config.yaml" 43 | ENV WG_STORAGE="sqlite3:///data/db.sqlite3" 44 | COPY --from=server /code/wg-access-server /usr/local/bin/wg-access-server 45 | COPY --from=website /code/build /website/build 46 | CMD ["wg-access-server", "serve"] 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 James Batt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wg-access-server 2 | 3 | wg-access-server is a single binary that provides a WireGuard 4 | VPN server and device management web ui. We support user authentication, 5 | _1 click_ device registration that works with Mac, Linux, Windows, Ios and Android 6 | including QR codes. You can configure different network isolation modes for 7 | better control and more. 8 | 9 | This project aims to deliver a simple VPN solution for developers, 10 | homelab enthusiasts and anyone else feeling adventurous. 11 | 12 | wg-access-server is a functional but young project. Contributions are welcome! 13 | 14 | ## Documentation 15 | 16 | [See our documentation website](https://place1.github.io/wg-access-server/) 17 | 18 | Quick Links: 19 | 20 | - [Configuration Overview](https://place1.github.io/wg-access-server/2-configuration/) 21 | - [Deploy With Docker](https://place1.github.io/wg-access-server/deployment/1-docker/) 22 | - [Deploy With Helm](https://place1.github.io/wg-access-server/deployment/2-docker-compose/) 23 | - [Deploy With Docker-Compose](https://place1.github.io/wg-access-server/deployment/2-docker-compose/) 24 | 25 | ## Running with Docker 26 | 27 | Here's a quick command to run the server to try it out. 28 | 29 | ```bash 30 | export WG_ADMIN_PASSWORD="example" 31 | export WG_WIREGUARD_PRIVATE_KEY="$(wg genkey)" 32 | 33 | docker run \ 34 | -it \ 35 | --rm \ 36 | --cap-add NET_ADMIN \ 37 | --device /dev/net/tun:/dev/net/tun \ 38 | -v wg-access-server-data:/data \ 39 | -e "WG_ADMIN_PASSWORD=$WG_ADMIN_PASSWORD" \ 40 | -e "WG_WIREGUARD_PRIVATE_KEY=$WG_WIREGUARD_PRIVATE_KEY" \ 41 | -p 8000:8000/tcp \ 42 | -p 51820:51820/udp \ 43 | place1/wg-access-server 44 | ``` 45 | 46 | If you open your browser using your LAN ip address you can even connect your 47 | phone to try it out: for example, i'll open my browser at http://192.168.0.XX:8000 48 | using the local LAN IP address. 49 | 50 | You can connect to the web server on the local machine browser at http://localhost:8000 51 | 52 | ## Running on Kubernetes via Helm 53 | 54 | wg-access-server ships a Helm chart to make it easy to get started on 55 | Kubernetes. 56 | 57 | Here's a quick start, but you can read more at the [Helm Chart Deployment Docs](https://place1.github.io/wg-access-server/deployment/3-kubernetes/) 58 | 59 | ```bash 60 | # deploy 61 | helm install my-release --repo https://place1.github.io/wg-access-server wg-access-server 62 | 63 | # cleanup 64 | helm delete my-release 65 | ``` 66 | 67 | ## Running with Docker-Compose 68 | 69 | Download the the docker-compose.yml file from the repo and run the following command. 70 | 71 | ```bash 72 | export WG_ADMIN_PASSWORD="example" 73 | export WG_WIREGUARD_PRIVATE_KEY="$(wg genkey)" 74 | 75 | docker-compose up 76 | ``` 77 | 78 | You can connect to the web server on the local machine browser at http://localhost:8000 79 | 80 | If you open your browser to your machine's LAN IP address you'll be able 81 | to connect your phone using the UI and QR code! 82 | 83 | ## Screenshots 84 | 85 | ![Devices](https://github.com/Place1/wg-access-server/raw/master/screenshots/devices.png) 86 | 87 | ![Connect iOS](https://github.com/Place1/wg-access-server/raw/master/screenshots/connect-mobile.png) 88 | 89 | ![Connect MacOS](https://github.com/Place1/wg-access-server/raw/master/screenshots/connect-desktop.png) 90 | 91 | ![Sign In](https://github.com/Place1/wg-access-server/raw/master/screenshots/signin.png) 92 | 93 | ## Changelog 94 | 95 | See the [CHANGELOG.md](https://github.com/Place1/wg-access-server/blob/master/CHANGELOG.md) file 96 | 97 | ## Development 98 | 99 | The software is made up a Golang Server and React App. 100 | 101 | Here's how I develop locally: 102 | 103 | 1. run `cd website && npm install && npm start` to get the frontend running on `:3000` 104 | 2. run `sudo go run ./main.go` to get the server running on `:8000` 105 | 106 | Here are some notes about the development configuration: 107 | 108 | - sudo is required because the server uses iptables/ip to configure the VPN networking 109 | - you'll access the website on `:3000` and it'll proxy API requests to `:8000` thanks to webpack 110 | - in-memory storage and generated wireguard keys will be used 111 | 112 | GRPC codegeneration: 113 | 114 | The client communicates with the server via gRPC-Web. You can edit the API specification 115 | in `./proto/*.proto`. 116 | 117 | After changing a service or message definition you'll want to re-generate server and client 118 | code using: `./codegen.sh`. 119 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ## Docs 2 | - [x] mkdocs 3 | - [ ] about 4 | - [x] deploying 5 | - [x] simple docker 1 liner 6 | - [x] docker-compose 7 | - [x] kubernetes quickstart 8 | - [x] helm 9 | - [x] configuring 10 | - [x] general 11 | - [x] config file/flag/env 12 | - [ ] how-to-guides 13 | - [ ] docker + docker-compose 14 | - [ ] kubernetes + nginx ingress 15 | - [ ] raspberry-pi + pihole dns 16 | 17 | ## Features 18 | - [ ] ARM docker image for raspberry-pi 19 | - [ ] admin 20 | - [x] list all devices 21 | - [ ] remove device 22 | - [x] networking 23 | - [x] isolate clients 24 | - [x] forward to internet only (isolate LAN/WAN) 25 | - [x] allowed networks (configure forwarding to specific CIDRs) 26 | - [x] also limit which CIDRs clients forward 27 | - [x] i.e. only forward to specific server-side LAN and not all internet traffic 28 | 29 | ## Reading This? 30 | 31 | What do you want to see? Open an issue and let me know 😀 32 | -------------------------------------------------------------------------------- /cmd/contracts.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | // Command represents a wg-access-server 4 | // subcommand module 5 | type Command interface { 6 | Name() string 7 | Run() 8 | } 9 | -------------------------------------------------------------------------------- /cmd/migrate/main.go: -------------------------------------------------------------------------------- 1 | package migrate 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "github.com/place1/wg-access-server/internal/storage" 6 | "github.com/sirupsen/logrus" 7 | "gopkg.in/alecthomas/kingpin.v2" 8 | ) 9 | 10 | func Register(app *kingpin.Application) *migratecmd { 11 | cmd := &migratecmd{} 12 | cli := app.Command(cmd.Name(), "Migrate your wg-access-server devices between storage backends. This tool is provided on a best effort bases.") 13 | cli.Arg("source", "The source storage URI").Required().StringVar(&cmd.src) 14 | cli.Arg("destination", "The destination storage URI").Required().StringVar(&cmd.dest) 15 | return cmd 16 | } 17 | 18 | type migratecmd struct { 19 | src string 20 | dest string 21 | } 22 | 23 | func (cmd *migratecmd) Name() string { 24 | return "migrate" 25 | } 26 | 27 | func (cmd *migratecmd) Run() { 28 | srcBackend, err := storage.NewStorage(cmd.src) 29 | if err != nil { 30 | logrus.Fatal(errors.Wrap(err, "failed to create src storage backend")) 31 | } 32 | if err := srcBackend.Open(); err != nil { 33 | logrus.Fatal(errors.Wrap(err, "failed to connect/open src storage backend")) 34 | } 35 | defer srcBackend.Close() 36 | 37 | destBackend, err := storage.NewStorage(cmd.dest) 38 | if err != nil { 39 | logrus.Fatal(errors.Wrap(err, "failed to create destination storage backend")) 40 | } 41 | if err := destBackend.Open(); err != nil { 42 | logrus.Fatal(errors.Wrap(err, "failed to connect/open destination storage backend")) 43 | } 44 | defer destBackend.Close() 45 | 46 | srcDevices, err := srcBackend.List("") 47 | if err != nil { 48 | logrus.Fatal(errors.Wrap(err, "failed to list all devices from source storage backend")) 49 | } 50 | 51 | logrus.Infof("copying %v devices from source --> destination backend", len(srcDevices)) 52 | 53 | for _, device := range srcDevices { 54 | if err := destBackend.Save(device); err != nil { 55 | logrus.Fatal(errors.Wrap(err, "failed to write device to destination storage backend")) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /codegen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | SCRIPT_DIR="$(dirname $0)" 5 | OUT_DIR="$SCRIPT_DIR/proto/proto" 6 | 7 | mkdir -p "$OUT_DIR" || true 8 | 9 | protoc \ 10 | -I proto/ \ 11 | proto/*.proto \ 12 | --go_out="plugins=grpc:$OUT_DIR" 13 | -------------------------------------------------------------------------------- /deploy/helm/wg-access-server/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /deploy/helm/wg-access-server/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | appVersion: v0.4.6 3 | description: A Wireguard VPN Access Server 4 | name: wg-access-server 5 | version: v0.4.6 6 | -------------------------------------------------------------------------------- /deploy/helm/wg-access-server/README.md: -------------------------------------------------------------------------------- 1 | ## Installing the Chart 2 | 3 | To install the chart with the release name `my-release`: 4 | 5 | ```bash 6 | $ helm install my-release --repo https://place1.github.io/wg-access-server wg-access-server 7 | ``` 8 | 9 | The command deploys wg-access-server on the Kubernetes cluster in the default configuration. The configuration section lists the parameters that can be configured during installation. 10 | 11 | By default an in-memory wireguard private key will be generated and devices will not persist 12 | between pod restarts. 13 | 14 | ## Uninstalling the Chart 15 | 16 | To uninstall/delete the my-release deployment: 17 | 18 | ```bash 19 | $ helm delete my-release 20 | ``` 21 | 22 | The command removes all the Kubernetes components associated with the chart and deletes the release. 23 | 24 | ## Example values.yaml 25 | 26 | ```yaml 27 | config: 28 | wireguard: 29 | externalHost: "" 30 | 31 | # wg access server is an http server without TLS. Exposing it via a loadbalancer is NOT secure! 32 | # Uncomment the following section only if you are running on private network or simple testing. 33 | # A much better option would be TLS terminating ingress controller or reverse-proxy. 34 | # web: 35 | # service: 36 | # type: "LoadBalancer" 37 | # loadBalancerIP: "" 38 | 39 | wireguard: 40 | config: 41 | privateKey: "" 42 | service: 43 | type: "LoadBalancer" 44 | loadBalancerIP: "" 45 | 46 | persistence: 47 | enabled: true 48 | 49 | ingress: 50 | enabled: true 51 | hosts: ["vpn.example.com"] 52 | tls: 53 | - hosts: ["vpn.example.com"] 54 | secretName: "tls-wg-access-server" 55 | ``` 56 | 57 | 58 | 59 | ## All Configuration 60 | 61 | | Key | Type | Default | Description | 62 | |-----|------|---------|-------------| 63 | | config | object | `{}` | inline wg-access-server config (config.yaml) | 64 | | web.service.type | string | `"ClusterIP"` | | 65 | | wireguard.config.privateKey | string | "" | A wireguard private key. You can generate one using `$ wg genkey` | 66 | | wireguard.service.type | string | `"ClusterIP"` | | 67 | | ingress.enabled | bool | `false` | | 68 | | ingress.hosts | string | `nil` | | 69 | | ingress.tls | list | `[]` | | 70 | | ingress.annotations | object | `{}` | | 71 | | persistence.enabled | bool | `false` | | 72 | | persistence.existingClaim | string | `""` | Use existing PVC claim for persistence instead | 73 | | persistence.size | string | `"100Mi"` | | 74 | | persistence.subPath | string | `""` | | 75 | | persistence.annotations | object | `{}` | | 76 | | persistence.accessModes[0] | string | `"ReadWriteOnce"` | | 77 | | strategy.type | string | `"Recreate"` | | 78 | | resources | object | `{}` | pod cpu/memory resource requests and limits | 79 | | nameOverride | string | `""` | | 80 | | fullnameOverride | string | `""` | | 81 | | affinity | object | `{}` | | 82 | | nodeSelector | object | `{}` | | 83 | | tolerations | list | `[]` | | 84 | | image.pullPolicy | string | `"IfNotPresent"` | | 85 | | image.repository | string | `"place1/wg-access-server"` | | 86 | | imagePullSecrets | list | `[]` | | 87 | -------------------------------------------------------------------------------- /deploy/helm/wg-access-server/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "wg-access-server.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "wg-access-server.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "wg-access-server.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "wg-access-server.labels" -}} 38 | helm.sh/chart: {{ include "wg-access-server.chart" . }} 39 | {{ include "wg-access-server.selectorLabels" . }} 40 | {{- if .Chart.AppVersion }} 41 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 42 | {{- end }} 43 | app.kubernetes.io/managed-by: {{ .Release.Service }} 44 | {{- end -}} 45 | 46 | {{/* 47 | Selector labels 48 | */}} 49 | {{- define "wg-access-server.selectorLabels" -}} 50 | app: {{ include "wg-access-server.name" . }} 51 | app.kubernetes.io/name: {{ include "wg-access-server.name" . }} 52 | app.kubernetes.io/instance: {{ .Release.Name }} 53 | {{- end -}} 54 | 55 | {{/* 56 | Create the name of the service account to use 57 | */}} 58 | {{- define "wg-access-server.serviceAccountName" -}} 59 | {{- if .Values.serviceAccount.create -}} 60 | {{ default (include "wg-access-server.fullname" .) .Values.serviceAccount.name }} 61 | {{- else -}} 62 | {{ default "default" .Values.serviceAccount.name }} 63 | {{- end -}} 64 | {{- end -}} 65 | -------------------------------------------------------------------------------- /deploy/helm/wg-access-server/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ include "wg-access-server.fullname" . }} 5 | labels: 6 | {{- include "wg-access-server.labels" . | nindent 4 }} 7 | data: 8 | config.yaml: |- 9 | {{- if .Values.config }} 10 | {{ toYaml .Values.config | indent 4 }} 11 | {{- end }} 12 | -------------------------------------------------------------------------------- /deploy/helm/wg-access-server/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | {{- $fullName := include "wg-access-server.fullname" . -}} 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: {{ include "wg-access-server.fullname" . }} 6 | labels: 7 | {{- include "wg-access-server.labels" . | nindent 4 }} 8 | spec: 9 | replicas: {{ .Values.replicas }} 10 | strategy: 11 | {{- if .Values.persistence.enabled }} 12 | type: {{ .Values.strategy.type | default "Recreate" | quote }} 13 | {{- else }} 14 | type: {{ .Values.strategy.type | default "RollingUpdate" | quote }} 15 | {{- end }} 16 | selector: 17 | matchLabels: 18 | {{- include "wg-access-server.selectorLabels" . | nindent 6 }} 19 | template: 20 | metadata: 21 | annotations: 22 | checksum/configmap: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} 23 | labels: 24 | {{- include "wg-access-server.selectorLabels" . | nindent 8 }} 25 | spec: 26 | {{- with .Values.imagePullSecrets }} 27 | imagePullSecrets: 28 | {{- toYaml . | nindent 8 }} 29 | {{- end }} 30 | containers: 31 | - name: {{ .Chart.Name }} 32 | securityContext: 33 | capabilities: 34 | add: ['NET_ADMIN'] 35 | image: "{{ .Values.image.repository }}:{{ .Chart.AppVersion }}" 36 | imagePullPolicy: {{ .Values.image.pullPolicy }} 37 | ports: 38 | - name: http 39 | containerPort: 8000 40 | protocol: TCP 41 | - name: wireguard 42 | containerPort: 51820 43 | protocol: UDP 44 | env: 45 | {{- if .Values.wireguard.config.privateKey }} 46 | - name: WG_WIREGUARD_PRIVATE_KEY 47 | valueFrom: 48 | secretKeyRef: 49 | name: "{{ $fullName }}" 50 | key: privateKey 51 | {{- end }} 52 | {{- if .Values.web.config.adminUsername }} 53 | - name: WG_ADMIN_USERNAME 54 | valueFrom: 55 | secretKeyRef: 56 | name: "{{ $fullName }}" 57 | key: adminUsername 58 | {{- end}} 59 | {{- if .Values.web.config.adminPassword }} 60 | - name: WG_ADMIN_PASSWORD 61 | valueFrom: 62 | secretKeyRef: 63 | name: "{{ $fullName }}" 64 | key: adminPassword 65 | {{- end}} 66 | volumeMounts: 67 | - name: tun 68 | mountPath: /dev/net/tun 69 | - name: data 70 | mountPath: /data 71 | - name: config 72 | mountPath: /config.yaml 73 | subPath: config.yaml 74 | readinessProbe: 75 | httpGet: 76 | path: / 77 | port: http 78 | resources: 79 | {{- toYaml .Values.resources | nindent 12 }} 80 | volumes: 81 | - name: tun 82 | hostPath: 83 | type: 'CharDevice' 84 | path: /dev/net/tun 85 | - name: data 86 | {{- if .Values.persistence.enabled }} 87 | persistentVolumeClaim: 88 | claimName: {{ if .Values.persistence.existingClaim }}{{ .Values.persistence.existingClaim }}{{- else }}{{ $fullName }}{{- end }} 89 | {{- end }} 90 | {{- if not .Values.persistence.enabled }} 91 | emptyDir: {} 92 | {{- end }} 93 | - name: config 94 | configMap: 95 | name: "{{ $fullName }}" 96 | {{- with .Values.nodeSelector }} 97 | nodeSelector: 98 | {{- toYaml . | nindent 8 }} 99 | {{- end }} 100 | {{- with .Values.affinity }} 101 | affinity: 102 | {{- toYaml . | nindent 8 }} 103 | {{- end }} 104 | {{- with .Values.tolerations }} 105 | tolerations: 106 | {{- toYaml . | nindent 8 }} 107 | {{- end }} 108 | -------------------------------------------------------------------------------- /deploy/helm/wg-access-server/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "wg-access-server.fullname" . -}} 3 | apiVersion: networking.k8s.io/v1beta1 4 | kind: Ingress 5 | metadata: 6 | name: {{ $fullName }} 7 | labels: 8 | {{- include "wg-access-server.labels" . | nindent 4 }} 9 | {{- with .Values.ingress.annotations }} 10 | annotations: 11 | {{- toYaml . | nindent 4 }} 12 | {{- end }} 13 | spec: 14 | {{- if .Values.ingress.tls }} 15 | tls: 16 | {{- range .Values.ingress.tls }} 17 | - hosts: 18 | {{- range .hosts }} 19 | - {{ . | quote }} 20 | {{- end }} 21 | secretName: {{ .secretName }} 22 | {{- end }} 23 | {{- end }} 24 | rules: 25 | {{- range .Values.ingress.hosts }} 26 | - host: {{ . | quote }} 27 | http: 28 | paths: 29 | - path: / 30 | backend: 31 | serviceName: {{ $fullName }}-web 32 | servicePort: 80 33 | {{- end }} 34 | {{- end }} 35 | -------------------------------------------------------------------------------- /deploy/helm/wg-access-server/templates/pvc.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.persistence.enabled -}} 2 | {{- $fullName := include "wg-access-server.fullname" . -}} 3 | apiVersion: v1 4 | kind: PersistentVolumeClaim 5 | metadata: 6 | name: "{{ $fullName }}" 7 | labels: 8 | {{- include "wg-access-server.labels" . | nindent 4 }} 9 | {{- with .Values.persistence.annotations }} 10 | annotations: 11 | {{- toYaml . | nindent 4 }} 12 | {{- end }} 13 | spec: 14 | accessModes: 15 | {{ toYaml .Values.persistence.accessModes | indent 4 }} 16 | {{- if .Values.persistence.storageClass }} 17 | {{- if (eq "-" .Values.persistence.storageClass) }} 18 | storageClassName: "" 19 | {{- else }} 20 | storageClassName: "{{ .Values.persistence.storageClass }}" 21 | {{- end }} 22 | {{- end }} 23 | {{- if .Values.persistence.volumeBindingMode }} 24 | volumeBindingModeName: "{{ .Values.persistence.volumeBindingMode }}" 25 | {{- end }} 26 | resources: 27 | requests: 28 | storage: "{{ .Values.persistence.size }}" 29 | {{- end -}} 30 | -------------------------------------------------------------------------------- /deploy/helm/wg-access-server/templates/secret.yaml: -------------------------------------------------------------------------------- 1 | {{- $fullName := include "wg-access-server.fullname" . -}} 2 | {{- if .Values.wireguard.config.privateKey }} 3 | apiVersion: v1 4 | kind: Secret 5 | metadata: 6 | name: "{{ $fullName }}" 7 | labels: 8 | {{- include "wg-access-server.labels" . | nindent 4 }} 9 | type: Opaque 10 | data: 11 | privateKey: {{ .Values.wireguard.config.privateKey | b64enc | quote }} 12 | {{- if .Values.web.config.adminUsername }} 13 | adminUsername: {{ .Values.web.config.adminUsername | b64enc | quote }} 14 | {{- end }} 15 | {{- if .Values.web.config.adminPassword }} 16 | adminPassword: {{ .Values.web.config.adminPassword | b64enc | quote }} 17 | {{- end }} 18 | {{- end }} 19 | -------------------------------------------------------------------------------- /deploy/helm/wg-access-server/templates/service.yaml: -------------------------------------------------------------------------------- 1 | {{- $fullName := include "wg-access-server.fullname" . -}} 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: {{ $fullName }}-web 6 | labels: 7 | {{- include "wg-access-server.labels" . | nindent 4 }} 8 | {{- if .Values.web.service.annotations }} 9 | annotations: 10 | {{ toYaml .Values.web.service.annotations | indent 4 }} 11 | {{- end }} 12 | spec: 13 | {{- if .Values.web.service.externalTrafficPolicy }} 14 | externalTrafficPolicy: {{ .Values.web.service.externalTrafficPolicy }} 15 | {{- end }} 16 | type: {{ .Values.web.service.type }} 17 | {{- if .Values.web.service.loadBalancerIP }} 18 | loadBalancerIP: {{ .Values.web.service.loadBalancerIP }} 19 | {{- end }} 20 | ports: 21 | - port: 80 22 | targetPort: 8000 23 | protocol: TCP 24 | name: http 25 | selector: 26 | {{- include "wg-access-server.selectorLabels" . | nindent 4 }} 27 | 28 | --- 29 | 30 | apiVersion: v1 31 | kind: Service 32 | metadata: 33 | name: {{ $fullName }}-wireguard 34 | labels: 35 | {{- include "wg-access-server.labels" . | nindent 4 }} 36 | {{- if .Values.wireguard.service.annotations }} 37 | annotations: 38 | {{ toYaml .Values.wireguard.service.annotations | indent 4 }} 39 | {{- end }} 40 | spec: 41 | type: {{ .Values.wireguard.service.type }} 42 | sessionAffinity: ClientIP 43 | {{- if .Values.wireguard.service.externalTrafficPolicy }} 44 | externalTrafficPolicy: {{ .Values.wireguard.service.externalTrafficPolicy }} 45 | {{- end }} 46 | {{- if .Values.wireguard.service.loadBalancerIP }} 47 | loadBalancerIP: {{ .Values.wireguard.service.loadBalancerIP }} 48 | {{- end }} 49 | ports: 50 | - port: 51820 51 | targetPort: 51820 52 | protocol: UDP 53 | name: wireguard 54 | selector: 55 | {{- include "wg-access-server.selectorLabels" . | nindent 4 }} 56 | -------------------------------------------------------------------------------- /deploy/helm/wg-access-server/values.yaml: -------------------------------------------------------------------------------- 1 | # wg-access-server config 2 | config: {} 3 | 4 | web: 5 | config: 6 | adminUsername: "" 7 | adminPassword: "" 8 | service: 9 | type: ClusterIP 10 | 11 | wireguard: 12 | config: 13 | privateKey: "" 14 | service: 15 | type: ClusterIP 16 | 17 | persistence: 18 | enabled: false 19 | ## Persistent Volume Storage Class 20 | ## If defined, storageClassName: 21 | ## If set to "-", storageClassName: "", which disables dynamic provisioning 22 | ## If undefined (the default) or set to null, no storageClassName spec is 23 | ## set, choosing the default provisioner. (gp2 on AWS, standard on 24 | ## GKE, AWS & OpenStack) 25 | ## 26 | # storageClass: "-" 27 | size: 100Mi 28 | annotations: {} 29 | accessModes: 30 | - ReadWriteOnce 31 | subPath: "" 32 | 33 | 34 | ingress: 35 | enabled: false 36 | annotations: {} 37 | # kubernetes.io/ingress.class: nginx 38 | # kubernetes.io/tls-acme: "true" 39 | hosts: 40 | # - www.example.com 41 | tls: [] 42 | # - secretName: chart-example-tls 43 | # hosts: 44 | # - chart-example.local 45 | 46 | nameOverride: "" 47 | 48 | fullnameOverride: "" 49 | 50 | imagePullSecrets: [] 51 | 52 | image: 53 | repository: place1/wg-access-server 54 | pullPolicy: IfNotPresent 55 | 56 | # multiple replicas is only supported when using 57 | # a supported highly-available storage backend (i.e. postgresql) 58 | replicas: 1 59 | 60 | strategy: {} 61 | # the deployment strategy type will default to "Recreate" when persistence is enabled 62 | # or "RollingUpdate" when persistence is not enabled. 63 | # type: Recreate 64 | 65 | resources: {} 66 | # We usually recommend not to specify default resources and to leave this as a conscious 67 | # choice for the user. This also increases chances charts run on environments with little 68 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 69 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 70 | # limits: 71 | # cpu: 100m 72 | # memory: 128Mi 73 | # requests: 74 | # cpu: 100m 75 | # memory: 128Mi 76 | 77 | nodeSelector: {} 78 | 79 | tolerations: [] 80 | 81 | affinity: {} 82 | -------------------------------------------------------------------------------- /deploy/k8s/quickstart.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Source: wg-access-server/templates/configmap.yaml 3 | apiVersion: v1 4 | kind: ConfigMap 5 | metadata: 6 | name: quickstart-wg-access-server 7 | labels: 8 | helm.sh/chart: wg-access-server-v0.4.6 9 | app: wg-access-server 10 | app.kubernetes.io/name: wg-access-server 11 | app.kubernetes.io/instance: quickstart 12 | app.kubernetes.io/version: "v0.4.6" 13 | app.kubernetes.io/managed-by: Helm 14 | data: 15 | config.yaml: |- 16 | --- 17 | # Source: wg-access-server/templates/service.yaml 18 | apiVersion: v1 19 | kind: Service 20 | metadata: 21 | name: quickstart-wg-access-server-web 22 | labels: 23 | helm.sh/chart: wg-access-server-v0.4.6 24 | app: wg-access-server 25 | app.kubernetes.io/name: wg-access-server 26 | app.kubernetes.io/instance: quickstart 27 | app.kubernetes.io/version: "v0.4.6" 28 | app.kubernetes.io/managed-by: Helm 29 | spec: 30 | type: ClusterIP 31 | ports: 32 | - port: 80 33 | targetPort: 8000 34 | protocol: TCP 35 | name: http 36 | selector: 37 | app: wg-access-server 38 | app.kubernetes.io/name: wg-access-server 39 | app.kubernetes.io/instance: quickstart 40 | --- 41 | # Source: wg-access-server/templates/service.yaml 42 | apiVersion: v1 43 | kind: Service 44 | metadata: 45 | name: quickstart-wg-access-server-wireguard 46 | labels: 47 | helm.sh/chart: wg-access-server-v0.4.6 48 | app: wg-access-server 49 | app.kubernetes.io/name: wg-access-server 50 | app.kubernetes.io/instance: quickstart 51 | app.kubernetes.io/version: "v0.4.6" 52 | app.kubernetes.io/managed-by: Helm 53 | spec: 54 | type: ClusterIP 55 | sessionAffinity: ClientIP 56 | ports: 57 | - port: 51820 58 | targetPort: 51820 59 | protocol: UDP 60 | name: wireguard 61 | selector: 62 | app: wg-access-server 63 | app.kubernetes.io/name: wg-access-server 64 | app.kubernetes.io/instance: quickstart 65 | --- 66 | # Source: wg-access-server/templates/deployment.yaml 67 | apiVersion: apps/v1 68 | kind: Deployment 69 | metadata: 70 | name: quickstart-wg-access-server 71 | labels: 72 | helm.sh/chart: wg-access-server-v0.4.6 73 | app: wg-access-server 74 | app.kubernetes.io/name: wg-access-server 75 | app.kubernetes.io/instance: quickstart 76 | app.kubernetes.io/version: "v0.4.6" 77 | app.kubernetes.io/managed-by: Helm 78 | spec: 79 | replicas: 1 80 | strategy: 81 | type: "RollingUpdate" 82 | selector: 83 | matchLabels: 84 | app: wg-access-server 85 | app.kubernetes.io/name: wg-access-server 86 | app.kubernetes.io/instance: quickstart 87 | template: 88 | metadata: 89 | annotations: 90 | checksum/configmap: f01034243376b67a5dd2b4b4adaa46b6f051f406eb0d4ef30bc0fc8a53d5f8c7 91 | labels: 92 | app: wg-access-server 93 | app.kubernetes.io/name: wg-access-server 94 | app.kubernetes.io/instance: quickstart 95 | spec: 96 | containers: 97 | - name: wg-access-server 98 | securityContext: 99 | capabilities: 100 | add: ['NET_ADMIN'] 101 | image: "place1/wg-access-server:v0.4.6" 102 | imagePullPolicy: IfNotPresent 103 | ports: 104 | - name: http 105 | containerPort: 8000 106 | protocol: TCP 107 | - name: wireguard 108 | containerPort: 51820 109 | protocol: UDP 110 | env: 111 | volumeMounts: 112 | - name: tun 113 | mountPath: /dev/net/tun 114 | - name: data 115 | mountPath: /data 116 | - name: config 117 | mountPath: /config.yaml 118 | subPath: config.yaml 119 | readinessProbe: 120 | httpGet: 121 | path: / 122 | port: http 123 | resources: 124 | {} 125 | volumes: 126 | - name: tun 127 | hostPath: 128 | type: 'CharDevice' 129 | path: /dev/net/tun 130 | - name: data 131 | emptyDir: {} 132 | - name: config 133 | configMap: 134 | name: "quickstart-wg-access-server" 135 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.0" 2 | services: 3 | wg-access-server: 4 | # to build the docker image from the source 5 | # build: 6 | # dockerfile: Dockerfile 7 | # context: . 8 | image: place1/wg-access-server 9 | container_name: wg-access-server 10 | cap_add: 11 | - NET_ADMIN 12 | volumes: 13 | - "wg-access-server-data:/data" 14 | # - "./config.yaml:/config.yaml" # if you have a custom config file 15 | environment: 16 | - "WG_ADMIN_USERNAME=admin" 17 | - "WG_ADMIN_PASSWORD=${WG_ADMIN_PASSWORD:?\n\nplease set the WG_ADMIN_PASSWORD environment variable:\n export WG_ADMIN_PASSWORD=example\n}" 18 | - "WG_WIREGUARD_PRIVATE_KEY=${WG_WIREGUARD_PRIVATE_KEY:?\n\nplease set the WG_WIREGUARD_PRIVATE_KEY environment variable:\n export WG_WIREGUARD_PRIVATE_KEY=$(wg genkey)\n}" 19 | ports: 20 | - "8000:8000/tcp" 21 | - "51820:51820/udp" 22 | devices: 23 | - "/dev/net/tun:/dev/net/tun" 24 | 25 | # shared volumes with the host 26 | volumes: 27 | wg-access-server-data: 28 | driver: local 29 | -------------------------------------------------------------------------------- /docs/2-configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | You can configure wg-access-server using environment variables, cli flags or a config file 4 | taking precedence over one another in that order. 5 | 6 | The default configuration should work out of the box if you're just looking to try it out. 7 | 8 | The only required configuration is an admin password and a wireguard private key. The admin 9 | password can be anything you like. You can generate a wireguard private key by 10 | [following the official docs](https://www.wireguard.com/quickstart/#key-generation). 11 | 12 | TLDR: 13 | 14 | ```bash 15 | wg genkey 16 | ``` 17 | 18 | The config file format is `yaml` and an example is provided [below](#the-config-file-configyaml). 19 | 20 | Here's what you can configure: 21 | 22 | | Environment Variable | CLI Flag | Config File Path | Required | Default (docker) | Description | 23 | | -------------------------- | -------------------------- | ---------------------- | -------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 24 | | `WG_CONFIG` | `--config` | | | | The path to a wg-access-server config.yaml file | 25 | | `WG_LOG_LEVEL` | `--log-level` | `logLevel` | | `info` | The global log level | 26 | | `WG_ADMIN_USERNAME` | `--admin-username` | `adminUsername` | | `admin` | The admin account username | 27 | | `WG_ADMIN_PASSWORD` | `--admin-password` | `adminPassword` | Yes | | The admin account password | 28 | | `WG_PORT` | `--port` | `port` | | `8000` | The port the web ui will listen on (http) | 29 | | `WG_EXTERNAL_HOST` | `--external-host` | `externalHost` | | | The external domain for the server (e.g. https://www.mydomain.com) | 30 | | `WG_STORAGE` | `--storage` | `storage` | | `sqlite3:///data/db.sqlite3` | A storage backend connection string. See [storage docs](./3-storage.md) | 31 | | `WG_DISABLE_METADATA` | `--disable-metadata` | `disableMetadata` | | `false` | Turn off collection of device metadata logging. Includes last handshake time and RX/TX bytes only. | 32 | | `WG_WIREGUARD_ENABLED` | `--[no-]wireguard-enabled` | `wireguard.enabled` | | `true` | Enable/disable the wireguard server. Useful for development on non-linux machines. | 33 | | `WG_WIREGUARD_INTERFACE` | `--wireguard-interface` | `wireguard.interface` | | `wg0` | The wireguard network interface name | 34 | | `WG_WIREGUARD_PRIVATE_KEY` | `--wireguard-private-key` | `wireguard.privateKey` | Yes | | The wireguard private key. This value is required and must be stable. If this value changes all devices must re-register. | 35 | | `WG_WIREGUARD_PORT` | `--wireguard-port` | `wireguard.port` | | `51820` | The wireguard server port (udp) | 36 | | `WG_VPN_CIDR` | `--vpn-cidr` | `vpn.cidr` | | `10.44.0.0/24` | The VPN network range. VPN clients will be assigned IP addresses in this range. | 37 | | `WG_VPN_GATEWAY_INTERFACE` | `--vpn-gateway-interface` | `vpn.gatewayInterface` | | _default gateway interface (e.g. eth0)_ | The VPN gateway interface. VPN client traffic will be forwarded to this interface. | 38 | | `WG_VPN_ALLOWED_IPS` | `--vpn-allowed-ips` | `vpn.allowedIPs` | | `0.0.0.0/0` | Allowed IPs that clients may route through this VPN. This will be set in the client's WireGuard connection file and routing is also enforced by the server using iptables. | 39 | | `WG_DNS_ENABLED` | `--[no-]dns-enabled` | `dns.enabled` | | `true` | Enable/disable the embedded DNS proxy server. This is enabled by default and allows VPN clients to avoid DNS leaks by sending all DNS requests to wg-access-server itself. | 40 | | `WG_DNS_UPSTREAM` | `--dns-upstream` | `dns.upstream` | | _resolveconf autodetection or 1.1.1.1_ | The upstream DNS server to proxy DNS requests to. By default the host machine's resolveconf configuration is used to find it's upstream DNS server, otherwise 1.1.1.1 (cloudflare) is used. | 41 | 42 | ## The Config File (config.yaml) 43 | 44 | Here's an example config file to get started with. 45 | 46 | ```yaml 47 | loglevel: info 48 | storage: sqlite3:///data/db.sqlite3 49 | wireguard: 50 | privateKey: "" 51 | dns: 52 | upstream: 53 | - "8.8.8.8" 54 | ``` 55 | -------------------------------------------------------------------------------- /docs/3-storage.md: -------------------------------------------------------------------------------- 1 | # Storage 2 | 3 | wg-access-server supports 4 storage backends. 4 | 5 | | Backend | Persistent | Supports HA | Use Case | 6 | | -------- | ---------- | ----------- | ---------------------------------------- | 7 | | memory | ❌ | ❌ | Local development | 8 | | sqlite3 | ✔️ | ❌ | Production - single instance deployments | 9 | | postgres | ✔️ | ✔️ | Production - multi instance deployments | 10 | | mysql | ✔️ | ❌ | Production - single instance deployments | 11 | 12 | ## Backends 13 | 14 | ### Memory 15 | 16 | This is the default backend if you're running the binary directly and haven't configured 17 | another storage backend. Data will be lost between restarts. Handy for development. 18 | 19 | ### Sqlite3 20 | 21 | This is the default backend if you're running the docker container directly or using docker-compose. 22 | 23 | The database file will be written to `/data/db.sqlite3` within the container by default. 24 | 25 | Sqlite3 is probably the simplest storage backend to get started with because it doesn't require 26 | any additional setup to be done. It should work out of the box and should be able to support a 27 | large number of users & devices. 28 | 29 | Example connection string: 30 | 31 | - Relative path: `sqlite3://path/to/db.sqlite3` 32 | - Absolute path: `sqlite3:///absolute/path/to/db.sqlite3` 33 | 34 | ### Postgres 35 | 36 | This backend requires an external Postgres database to be deployed. 37 | 38 | Postgres will support highly-available deployments of wg-access-server in the near future 39 | and is the recommended storage backend where possible. 40 | 41 | Example connection string: 42 | 43 | - `postgresql://user:password@localhost:5432/database?sslmode=disable` 44 | 45 | ### Mysql 46 | 47 | This backend requires an external Mysql database to be deployed. Mysql flavours should be compatible. 48 | wg-access-server uses [this golang driver](github.com/go-sql-driver/mysql) if you want to check the 49 | compatibility of your favorite flavour. 50 | 51 | Example connection string: 52 | 53 | - `mysql://user:password@localhost:3306/database?ssl-mode=disabled` 54 | 55 | ### File (removed) 56 | 57 | The `file://` backend was deprecated in 0.3.0 and has been removed in 0.4.0 58 | 59 | If you'd like to migrate your `file://` storage to a supported backend you must use 60 | version 0.3.0 and then follow the migration guide below to migrate to a different storage backend. 61 | 62 | _Note that the migration tool itself doesn't support the `file://` backend on versions 63 | released after 0.3.0_. 64 | 65 | ## Migration Between Backends 66 | 67 | You can migrate your registered devices between backends using the `wg-access-server migrate ` 68 | command. 69 | 70 | The migrate command was added in `v0.3.0` and is provided on a _best effort_ level. As an open source 71 | project any community support here is warmly welcomed. 72 | 73 | ### Example: `file://` to `sqlite3://` 74 | 75 | If you're using the now deprecated `file://` backend you can migrate to `sqlite3://` like this: 76 | 77 | ```bash 78 | # after upgrading to place1/wg-access-server:v0.3.0 79 | docker exec -it wg-access-server migrate file:///data sqlite3:///data/db.sqlite3 80 | ``` 81 | 82 | If you need to do the above within a kubernetes deployment substitute `docker exec` with the equivalent 83 | `kubectl exec` command. 84 | 85 | The migrate command is non-destructive but it's always a good idea to take a backup of your data first! 86 | 87 | ### Example: `sqlite3://` to `postgresql://` 88 | 89 | First you'll need to make sure your postgres server is up and that you can connect to it from your 90 | wg-access-server container/pod/vm. 91 | 92 | ```bash 93 | wg-access-server migrate sqlite3:///data/db.sqlite3 postgresql://user:password@localhost:5432/database?sslmode=disable 94 | ``` 95 | 96 | Remember to update your wg-access-server config to connect to postgres 😀 97 | -------------------------------------------------------------------------------- /docs/4-auth.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | Authentication is pluggable in wg-access-server. Community contributions are welcome 4 | for supporting new authentication backends. 5 | 6 | If you're just getting started you can skip over this section and rely on the default 7 | admin account instead. 8 | 9 | If your authentication system is not yet supported and you aren't quite ready to 10 | contribute you could try using a project like [dex](https://github.com/dexidp/dex) 11 | or SaaS provider like [Auth0](https://auth0.com/) which supports a wider variety of 12 | authentication protocols. wg-access-server can happily be an OpenID Connect client 13 | to a larger solution like this. 14 | 15 | The following authentication backends are currently supported: 16 | 17 | | Backend | Use Case | Notes | 18 | | -------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | 19 | | Basic Auth | Deployments with a static list of users. Simple and great for self-hosters and home use-cases | The wg-access-server admin account is powered by this backend | 20 | | OpenID Connect | For delegating authentication to an existing identity solution | | 21 | | Gitlab | For delegating authentication to gitlab. Supports self-hosted Gitlab. | | 22 | 23 | ## Configuration 24 | 25 | Currently authentication providers are only configurable via the wg-access-server 26 | config file (config.yaml). 27 | 28 | Below is an annotated example config section that can be used as a starting point. 29 | 30 | ```yaml 31 | # Configure zero or more authentication backends 32 | auth: 33 | # HTTP Basic Authentication 34 | basic: 35 | # Users is a list of htpasswd encoded username:password pairs 36 | # supports BCrypt, Sha, Ssha, Md5 37 | # You can create a user using "htpasswd -nB " 38 | users: [] 39 | oidc: 40 | # A name for the backend (can be anything you want) 41 | name: "My OIDC Backend" 42 | # Should point to the OIDC Issuer (excluding /.well-known/openid-configuration) 43 | issuer: "https://identity.example.com" 44 | # Your OIDC client credentials which would be provided by your OIDC provider 45 | clientID: "" 46 | clientSecret: "" 47 | # List of scopes to request defaults to ["openid"] 48 | scopes: 49 | - openid 50 | # The full redirect URL 51 | # The path can be almost anything as long as it doesn't 52 | # conflict with a path that the web UI uses. 53 | # /callback is recommended. 54 | redirectURL: "https://wg-access-server.example.com/callback" 55 | # You can optionally restrict access to users with an email address 56 | # that matches an allowed domain. 57 | # If empty or omitted then all email domains will be allowed. 58 | emailDomains: 59 | - example.com 60 | # This is an advanced feature that allows you to define 61 | # OIDC claim mapping expressions. 62 | # This feature is used to define wg-access-server admins 63 | # based off a claim in your OIDC token 64 | # See https://github.com/Knetic/govaluate/blob/9aa49832a739dcd78a5542ff189fb82c3e423116/MANUAL.md for how to write rules 65 | claimMapping: 66 | admin: "'WireguardAdmins' in group_membership" 67 | gitlab: 68 | name: "My Gitlab Backend" 69 | baseURL: "https://mygitlab.example.com" 70 | clientID: "" 71 | clientSecret: "" 72 | redirectURL: "https:///wg-access-server.example.com/callback" 73 | emailDomains: 74 | - example.com 75 | ``` 76 | -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.0.9.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.0.9.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.1.0.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.1.0.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.1.1.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.1.1.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.2.0-rc3.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.2.0-rc3.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.2.0-rc4.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.2.0-rc4.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.2.0-rc5.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.2.0-rc5.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.2.0-rc6.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.2.0-rc6.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.2.0-rc7.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.2.0-rc7.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.2.0-rc8.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.2.0-rc8.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.2.0.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.2.0.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.2.1.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.2.1.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.2.2.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.2.2.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.2.3.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.2.3.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.2.4-rc1.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.2.4-rc1.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.2.4.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.2.4.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.2.5-rc1.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.2.5-rc1.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.2.5-rc2.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.2.5-rc2.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.2.5-rc3.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.2.5-rc3.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-0.2.5.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-0.2.5.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-v0.3.0-rc1.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-v0.3.0-rc1.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-v0.3.0-rc2.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-v0.3.0-rc2.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-v0.3.0.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-v0.3.0.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-v0.4.0.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-v0.4.0.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-v0.4.1.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-v0.4.1.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-v0.4.2.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-v0.4.2.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-v0.4.3.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-v0.4.3.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-v0.4.4.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-v0.4.4.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-v0.4.5.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-v0.4.5.tgz -------------------------------------------------------------------------------- /docs/charts/wg-access-server-v0.4.6.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/docs/charts/wg-access-server-v0.4.6.tgz -------------------------------------------------------------------------------- /docs/deployment/1-docker.md: -------------------------------------------------------------------------------- 1 | # Docker 2 | 3 | ## TL;DR; 4 | 5 | Here's a one-liner to run wg-access-server: 6 | 7 | ```bash 8 | docker run --rm -it 9 | --cap-add NET_ADMIN \ 10 | --device /dev/net/tun:/dev/net/tun \ 11 | -p 8000:8000/tcp \ 12 | -p 51820:51820/udp \ 13 | place1/wg-access-server 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/deployment/2-docker-compose.md: -------------------------------------------------------------------------------- 1 | # Docker Compose 2 | 3 | You can run wg-access-server using the following example 4 | docker Docker Compose file. 5 | 6 | Checkout the [configuration docs](../2-configuration.md) to learn how wg-access-server 7 | can be configured. 8 | 9 | ```yaml 10 | {!../docker-compose.yml!} 11 | ``` 12 | -------------------------------------------------------------------------------- /docs/deployment/3-kubernetes.md: -------------------------------------------------------------------------------- 1 | # Helm Chart 2 | 3 | {!../deploy/helm/wg-access-server/README.md!} 4 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | {!../README.md!} 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/place1/wg-access-server 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect 7 | github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect 8 | github.com/coreos/go-iptables v0.4.5 9 | github.com/coreos/go-oidc v2.2.1+incompatible 10 | github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect 11 | github.com/docker/docker v1.13.1 // indirect 12 | github.com/docker/libnetwork v0.8.0-dev.2.0.20200217033114-6659f7f4d8c1 13 | github.com/golang/protobuf v1.4.2 14 | github.com/google/uuid v1.1.1 15 | github.com/gorilla/mux v1.7.4 16 | github.com/gorilla/sessions v1.2.0 17 | github.com/gorilla/websocket v1.4.2 // indirect 18 | github.com/grpc-ecosystem/go-grpc-middleware v1.2.0 19 | github.com/improbable-eng/grpc-web v0.13.0 20 | github.com/ishidawataru/sctp v0.0.0-20191218070446-00ab2ac2db07 // indirect 21 | github.com/jinzhu/gorm v1.9.16 22 | github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect 23 | github.com/miekg/dns v1.1.30 24 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223 // indirect 25 | github.com/patrickmn/go-cache v2.1.0+incompatible 26 | github.com/pkg/errors v0.9.1 27 | github.com/place1/pg-events v0.2.0 28 | github.com/place1/wg-embed v0.4.1 29 | github.com/pquerna/cachecontrol v0.0.0-20200921180117-858c6e7e6b7e // indirect 30 | github.com/rs/cors v1.7.0 // indirect 31 | github.com/sirupsen/logrus v1.7.0 32 | github.com/stretchr/testify v1.6.1 33 | github.com/tg123/go-htpasswd v1.0.0 34 | github.com/vishvananda/netlink v1.1.0 35 | golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 36 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d 37 | golang.zx2c4.com/wireguard/wgctrl v0.0.0-20200609130330-bd2cb7843e1b 38 | google.golang.org/appengine v1.6.6 // indirect 39 | google.golang.org/genproto v0.0.0-20200715011427-11fb19a81f2c // indirect 40 | google.golang.org/grpc v1.30.0 41 | google.golang.org/protobuf v1.25.0 // indirect 42 | gopkg.in/Knetic/govaluate.v2 v2.3.0 43 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 44 | gopkg.in/square/go-jose.v2 v2.5.1 // indirect 45 | gopkg.in/yaml.v2 v2.3.0 46 | gotest.tools v2.2.0+incompatible // indirect 47 | ) 48 | 49 | // replace github.com/place1/wg-embed => ../wg-embed 50 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/place1/wg-access-server/pkg/authnz/authconfig" 5 | ) 6 | 7 | type AppConfig struct { 8 | // Set the log level. 9 | // Defaults to "info" (fatal, error, warn, info, debug, trace) 10 | LogLevel string `yaml:"loglevel"` 11 | // Set the superadmin username 12 | // Defaults to "admin" 13 | AdminUsername string `yaml:"adminUsername"` 14 | // Set the superadmin password (required) 15 | AdminPassword string `yaml:"adminPassword"` 16 | // Port sets the port that the web UI will listen on. 17 | // Defaults to 8000 18 | Port int `yaml:"port"` 19 | // ExternalAddress is the address that clients 20 | // use to connect to the wireguard interface 21 | // By default, this will be empty and the web ui 22 | // will use the current page's origin. 23 | ExternalHost string `yaml:"externalHost"` 24 | // The storage backend where device configuration will 25 | // be persisted. 26 | // Supports memory:// postgresql:// mysql:// sqlite3:// 27 | // Defaults to memory:// 28 | Storage string `yaml:"storage"` 29 | // DisableMetadata allows you to turn off collection of device 30 | // metadata including last handshake time & rx/tx bytes 31 | DisableMetadata bool `yaml:"disableMetadata"` 32 | // Configure WireGuard related settings 33 | WireGuard struct { 34 | // Set this to false to disable the embedded wireguard 35 | // server. This is useful for development environments 36 | // on mac and windows where we don't currently support 37 | // the OS's network stack. 38 | Enabled bool `yaml:"enabled"` 39 | // The network interface name of the WireGuard 40 | // network device. 41 | // Defaults to wg0 42 | Interface string `yaml:"interface"` 43 | // The WireGuard PrivateKey 44 | // If this value is lost then any existing 45 | // clients (WireGuard peers) will no longer 46 | // be able to connect. 47 | // Clients will either have to manually update 48 | // their connection configuration or setup 49 | // their VPN again using the web ui (easier for most people) 50 | PrivateKey string `yaml:"privateKey"` 51 | // The WireGuard ListenPort 52 | // Defaults to 51820 53 | Port int `yaml:"port"` 54 | } `yaml:"wireguard"` 55 | // Configure VPN related settings (networking) 56 | VPN struct { 57 | // CIDR configures a network address space 58 | // that client (WireGuard peers) will be allocated 59 | // an IP address from 60 | // defaults to 10.44.0.0/24 61 | CIDR string `yaml:"cidr"` 62 | // GatewayInterface will be used in iptable forwarding 63 | // rules that send VPN traffic from clients to this interface 64 | // Most use-cases will want this interface to have access 65 | // to the outside internet 66 | GatewayInterface string `yaml:"gatewayInterface"` 67 | // The "AllowedIPs" for VPN clients. 68 | // This value will be included in client config 69 | // files and in server-side iptable rules 70 | // to enforce network access. 71 | // defaults to ["0.0.0.0/0"] 72 | AllowedIPs []string `yaml:"allowedIPs"` 73 | } `yaml:"vpn"` 74 | // Configure the embeded DNS server 75 | DNS struct { 76 | // Enabled allows you to turn on/off 77 | // the VPN DNS proxy feature. 78 | // DNS Proxying is enabled by default. 79 | Enabled bool `yaml:"enabled"` 80 | // Upstream configures the addresses of upstream 81 | // DNS servers to which client DNS requests will be sent to. 82 | // Defaults the host's upstream DNS servers (via resolveconf) 83 | // or 1.1.1.1 if resolveconf cannot be used. 84 | // NOTE: currently wg-access-server will only use the first upstream. 85 | Upstream []string `yaml:"upstream"` 86 | } `yaml:"dns"` 87 | // Auth configures optional authentication backends 88 | // to controll access to the web ui. 89 | // Devices will be managed on a per-user basis if any 90 | // auth backends are configured. 91 | // If no authentication backends are configured then 92 | // the server will not require any authentication. 93 | Auth authconfig.AuthConfig `yaml:"auth"` 94 | } 95 | -------------------------------------------------------------------------------- /internal/devices/devices.go: -------------------------------------------------------------------------------- 1 | package devices 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "sync" 7 | "time" 8 | 9 | "github.com/place1/wg-embed/pkg/wgembed" 10 | 11 | "github.com/pkg/errors" 12 | "github.com/place1/wg-access-server/internal/storage" 13 | "github.com/place1/wg-access-server/pkg/authnz/authsession" 14 | "github.com/sirupsen/logrus" 15 | ) 16 | 17 | type DeviceManager struct { 18 | wg wgembed.WireGuardInterface 19 | storage storage.Storage 20 | cidr string 21 | } 22 | 23 | func New(wg wgembed.WireGuardInterface, s storage.Storage, cidr string) *DeviceManager { 24 | return &DeviceManager{wg, s, cidr} 25 | } 26 | 27 | func (d *DeviceManager) StartSync(disableMetadataCollection bool) error { 28 | // Start listening to the device add/remove events 29 | d.storage.OnAdd(func(device *storage.Device) { 30 | logrus.Debugf("storage event: device added: %s/%s", device.Owner, device.Name) 31 | if err := d.wg.AddPeer(device.PublicKey, device.Address); err != nil { 32 | logrus.Error(errors.Wrap(err, "failed to add wireguard peer")) 33 | } 34 | }) 35 | 36 | d.storage.OnDelete(func(device *storage.Device) { 37 | logrus.Debugf("storage event: device removed: %s/%s", device.Owner, device.Name) 38 | if err := d.wg.RemovePeer(device.PublicKey); err != nil { 39 | logrus.Error(errors.Wrap(err, "failed to remove wireguard peer")) 40 | } 41 | }) 42 | 43 | d.storage.OnReconnect(func() { 44 | if err := d.sync(); err != nil { 45 | logrus.Error(errors.Wrap(err, "device sync after storage backend reconnect event failed")) 46 | } 47 | }) 48 | 49 | // Do an initial sync of existing devices 50 | if err := d.sync(); err != nil { 51 | return errors.Wrap(err, "initial device sync from storage failed") 52 | } 53 | 54 | // start the metrics loop 55 | if !disableMetadataCollection { 56 | go metadataLoop(d) 57 | } 58 | 59 | return nil 60 | } 61 | 62 | func (d *DeviceManager) AddDevice(identity *authsession.Identity, name string, publicKey string) (*storage.Device, error) { 63 | if name == "" { 64 | return nil, errors.New("device name must not be empty") 65 | } 66 | 67 | clientAddr, err := d.nextClientAddress() 68 | if err != nil { 69 | return nil, errors.Wrap(err, "failed to generate an ip address for device") 70 | } 71 | 72 | device := &storage.Device{ 73 | Owner: identity.Subject, 74 | OwnerName: identity.Name, 75 | OwnerEmail: identity.Email, 76 | OwnerProvider: identity.Provider, 77 | Name: name, 78 | PublicKey: publicKey, 79 | Address: clientAddr, 80 | CreatedAt: time.Now(), 81 | } 82 | 83 | if err := d.SaveDevice(device); err != nil { 84 | return nil, errors.Wrap(err, "failed to save the new device") 85 | } 86 | 87 | return device, nil 88 | } 89 | 90 | func (d *DeviceManager) SaveDevice(device *storage.Device) error { 91 | return d.storage.Save(device) 92 | } 93 | 94 | func (d *DeviceManager) sync() error { 95 | devices, err := d.ListAllDevices() 96 | if err != nil { 97 | return errors.Wrap(err, "failed to list devices") 98 | } 99 | 100 | peers, err := d.wg.ListPeers() 101 | if err != nil { 102 | return errors.Wrap(err, "failed to list peers") 103 | } 104 | 105 | // Remove any peers for devices that are no longer in storage 106 | for _, peer := range peers { 107 | if !deviceListContains(devices, peer.PublicKey.String()) { 108 | if err := d.wg.RemovePeer(peer.PublicKey.String()); err != nil { 109 | logrus.Error(errors.Wrapf(err, "failed to remove peer during sync: %s", peer.PublicKey.String())) 110 | } 111 | } 112 | } 113 | 114 | // Add peers for all devices in storage 115 | for _, device := range devices { 116 | if err := d.wg.AddPeer(device.PublicKey, device.Address); err != nil { 117 | logrus.Warn(errors.Wrapf(err, "failed to add device during sync: %s", device.Name)) 118 | } 119 | } 120 | 121 | return nil 122 | } 123 | 124 | func (d *DeviceManager) ListAllDevices() ([]*storage.Device, error) { 125 | return d.storage.List("") 126 | } 127 | 128 | func (d *DeviceManager) ListDevices(user string) ([]*storage.Device, error) { 129 | return d.storage.List(user) 130 | } 131 | 132 | func (d *DeviceManager) DeleteDevice(user string, name string) error { 133 | device, err := d.storage.Get(user, name) 134 | if err != nil { 135 | return errors.Wrap(err, "failed to retrieve device") 136 | } 137 | 138 | if err := d.storage.Delete(device); err != nil { 139 | return err 140 | } 141 | 142 | return nil 143 | } 144 | 145 | func (d *DeviceManager) GetByPublicKey(publicKey string) (*storage.Device, error) { 146 | return d.storage.GetByPublicKey(publicKey) 147 | } 148 | 149 | var nextIPLock = sync.Mutex{} 150 | 151 | func (d *DeviceManager) nextClientAddress() (string, error) { 152 | nextIPLock.Lock() 153 | defer nextIPLock.Unlock() 154 | 155 | devices, err := d.ListDevices("") 156 | if err != nil { 157 | return "", errors.Wrap(err, "failed to list devices") 158 | } 159 | 160 | vpnip, vpnsubnet := MustParseCIDR(d.cidr) 161 | ip := vpnip.Mask(vpnsubnet.Mask) 162 | 163 | // TODO: read up on better ways to allocate client's IP 164 | // addresses from a configurable CIDR 165 | usedIPs := []net.IP{ 166 | ip, // x.x.x.0 167 | nextIP(ip), // x.x.x.1 168 | } 169 | for _, device := range devices { 170 | ip, _ := MustParseCIDR(device.Address) 171 | usedIPs = append(usedIPs, ip) 172 | } 173 | 174 | for ip := ip; vpnsubnet.Contains(ip); ip = nextIP(ip) { 175 | if !contains(usedIPs, ip) { 176 | return fmt.Sprintf("%s/32", ip.String()), nil 177 | } 178 | } 179 | 180 | return "", fmt.Errorf("there are no free IP addresses in the vpn subnet: '%s'", vpnsubnet) 181 | } 182 | 183 | func contains(ips []net.IP, target net.IP) bool { 184 | for _, ip := range ips { 185 | if ip.Equal(target) { 186 | return true 187 | } 188 | } 189 | return false 190 | } 191 | 192 | func MustParseCIDR(cidr string) (net.IP, *net.IPNet) { 193 | ip, ipnet, err := net.ParseCIDR(cidr) 194 | if err != nil { 195 | panic(err) 196 | } 197 | return ip, ipnet 198 | } 199 | 200 | func MustParseIP(ip string) net.IP { 201 | netip, _ := MustParseCIDR(fmt.Sprintf("%s/32", ip)) 202 | return netip 203 | } 204 | 205 | func nextIP(ip net.IP) net.IP { 206 | next := make([]byte, len(ip)) 207 | copy(next, ip) 208 | for j := len(next) - 1; j >= 0; j-- { 209 | next[j]++ 210 | if next[j] > 0 { 211 | break 212 | } 213 | } 214 | return next 215 | } 216 | 217 | func deviceListContains(devices []*storage.Device, publicKey string) bool { 218 | for _, device := range devices { 219 | if device.PublicKey == publicKey { 220 | return true 221 | } 222 | } 223 | return false 224 | } 225 | -------------------------------------------------------------------------------- /internal/devices/metadata.go: -------------------------------------------------------------------------------- 1 | package devices 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | func metadataLoop(d *DeviceManager) { 11 | for { 12 | syncMetrics(d) 13 | time.Sleep(30 * time.Second) 14 | } 15 | } 16 | 17 | func syncMetrics(d *DeviceManager) { 18 | logrus.Debug("metadata sync executing") 19 | 20 | peers, err := d.wg.ListPeers() 21 | if err != nil { 22 | logrus.Warn(errors.Wrap(err, "failed to list peers - metrics cannot be recorded")) 23 | return 24 | } 25 | 26 | for _, peer := range peers { 27 | // if the peer is connected we can update their metrics 28 | // importantly, we'll ignore peers that we know about 29 | // but aren't connected at the moment. 30 | // they may actually be connected to another replica. 31 | if peer.Endpoint != nil { 32 | if device, err := d.GetByPublicKey(peer.PublicKey.String()); err == nil { 33 | device.Endpoint = peer.Endpoint.IP.String() 34 | device.ReceiveBytes = peer.ReceiveBytes 35 | device.TransmitBytes = peer.TransmitBytes 36 | if !peer.LastHandshakeTime.IsZero() { 37 | device.LastHandshakeTime = &peer.LastHandshakeTime 38 | } 39 | if err := d.SaveDevice(device); err != nil { 40 | logrus.Error(errors.Wrap(err, "failed to save device during metadata sync")) 41 | } 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/dnsproxy/server.go: -------------------------------------------------------------------------------- 1 | package dnsproxy 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "runtime/debug" 7 | "strings" 8 | "time" 9 | 10 | "github.com/miekg/dns" 11 | "github.com/patrickmn/go-cache" 12 | "github.com/pkg/errors" 13 | "github.com/sirupsen/logrus" 14 | ) 15 | 16 | type DNSServerOpts struct { 17 | Upstream []string 18 | } 19 | 20 | type DNSServer struct { 21 | server *dns.Server 22 | client *dns.Client 23 | cache *cache.Cache 24 | upstream []string 25 | } 26 | 27 | func New(opts DNSServerOpts) (*DNSServer, error) { 28 | if len(opts.Upstream) == 0 { 29 | return nil, errors.New("at least 1 upstream dns server is required for the dns proxy server to function") 30 | } 31 | 32 | addr := "0.0.0.0:53" 33 | logrus.Infof("starting dns server on %s with upstreams: %s", addr, strings.Join(opts.Upstream, ", ")) 34 | 35 | dnsServer := &DNSServer{ 36 | server: &dns.Server{ 37 | Addr: addr, 38 | Net: "udp", 39 | }, 40 | client: &dns.Client{ 41 | SingleInflight: true, 42 | Timeout: 5 * time.Second, 43 | }, 44 | cache: cache.New(10*time.Minute, 10*time.Minute), 45 | upstream: opts.Upstream, 46 | } 47 | dnsServer.server.Handler = dnsServer 48 | 49 | go func() { 50 | if err := dnsServer.server.ListenAndServe(); err != nil { 51 | logrus.Error(errors.Wrap(err, "failed to start dns server")) 52 | } 53 | }() 54 | 55 | return dnsServer, nil 56 | } 57 | 58 | func (d *DNSServer) Close() error { 59 | return d.server.Shutdown() 60 | } 61 | 62 | func (d *DNSServer) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { 63 | defer func() { 64 | if err := recover(); err != nil { 65 | logrus.Errorf("dns server panic handled: %v\n%s", err, string(debug.Stack())) 66 | dns.HandleFailed(w, r) 67 | } 68 | }() 69 | 70 | logrus.Debugf("dns query: %s", prettyPrintMsg(r)) 71 | 72 | switch r.Opcode { 73 | case dns.OpcodeQuery: 74 | m, err := d.Lookup(r) 75 | if err != nil { 76 | logrus.Errorf("failed lookup record with error: %s\n%s", err.Error(), r) 77 | dns.HandleFailed(w, r) 78 | return 79 | } 80 | m.SetReply(r) 81 | w.WriteMsg(m) 82 | default: 83 | m := &dns.Msg{} 84 | m.SetReply(r) 85 | w.WriteMsg(m) 86 | } 87 | 88 | } 89 | 90 | func (d *DNSServer) Lookup(m *dns.Msg) (*dns.Msg, error) { 91 | key := makekey(m) 92 | 93 | // check the cache first 94 | if item, found := d.cache.Get(key); found { 95 | logrus.Debugf("dns cache hit %s", prettyPrintMsg(m)) 96 | return item.(*dns.Msg), nil 97 | } 98 | 99 | // fallback to upstream exchange 100 | response, _, err := d.client.Exchange(m, net.JoinHostPort(d.upstream[0], "53")) 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | if len(response.Answer) > 0 { 106 | ttl := time.Duration(response.Answer[0].Header().Ttl) * time.Second 107 | logrus.Debugf("caching dns response for %s for %v seconds", prettyPrintMsg(m), ttl) 108 | d.cache.Set(key, response, ttl) 109 | } 110 | 111 | return response, nil 112 | } 113 | 114 | func makekey(m *dns.Msg) string { 115 | q := m.Question[0] 116 | return fmt.Sprintf("%s:%d:%d", q.Name, q.Qtype, q.Qclass) 117 | } 118 | 119 | func prettyPrintMsg(m *dns.Msg) string { 120 | if len(m.Question) > 0 { 121 | return fmt.Sprintf("dns query for: %s", makekey(m)) 122 | } 123 | return m.String() 124 | } 125 | -------------------------------------------------------------------------------- /internal/network/network.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | 7 | "github.com/coreos/go-iptables/iptables" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | func ServerVPNIP(cidr string) *net.IPNet { 12 | vpnip, vpnsubnet := MustParseCIDR(cidr) 13 | vpnsubnet.IP = nextIP(vpnip.Mask(vpnsubnet.Mask)) 14 | return vpnsubnet 15 | } 16 | 17 | func ConfigureForwarding(wgIface string, gatewayIface string, cidr string, allowedIPs []string) error { 18 | // Networking configuration (iptables) configuration 19 | // to ensure that traffic from clients the wireguard interface 20 | // is sent to the provided network interface 21 | ipt, err := iptables.New() 22 | if err != nil { 23 | return errors.Wrap(err, "failed to init iptables") 24 | } 25 | 26 | // Cleanup our chains first so that we don't leak 27 | // iptable rules when the network configuration changes. 28 | ipt.ClearChain("filter", "WG_ACCESS_SERVER_FORWARD") 29 | ipt.ClearChain("nat", "WG_ACCESS_SERVER_POSTROUTING") 30 | 31 | // Create our own chain for forwarding rules 32 | ipt.NewChain("filter", "WG_ACCESS_SERVER_FORWARD") 33 | ipt.AppendUnique("filter", "FORWARD", "-j", "WG_ACCESS_SERVER_FORWARD") 34 | 35 | // Create our own chain for postrouting rules 36 | ipt.NewChain("nat", "WG_ACCESS_SERVER_POSTROUTING") 37 | ipt.AppendUnique("nat", "POSTROUTING", "-j", "WG_ACCESS_SERVER_POSTROUTING") 38 | 39 | // Accept client traffic for given allowed ips 40 | for _, allowedCIDR := range allowedIPs { 41 | if err := ipt.AppendUnique("filter", "WG_ACCESS_SERVER_FORWARD", "-s", cidr, "-d", allowedCIDR, "-j", "ACCEPT"); err != nil { 42 | return errors.Wrap(err, "failed to set ip tables rule") 43 | } 44 | } 45 | 46 | if gatewayIface != "" { 47 | if err := ipt.AppendUnique("nat", "WG_ACCESS_SERVER_POSTROUTING", "-s", cidr, "-o", gatewayIface, "-j", "MASQUERADE"); err != nil { 48 | return errors.Wrap(err, "failed to set ip tables rule") 49 | } 50 | } 51 | 52 | if err := ipt.AppendUnique("filter", "WG_ACCESS_SERVER_FORWARD", "-s", cidr, "-j", "REJECT"); err != nil { 53 | return errors.Wrap(err, "failed to set ip tables rule") 54 | } 55 | 56 | return nil 57 | } 58 | 59 | func MustParseCIDR(cidr string) (net.IP, *net.IPNet) { 60 | ip, ipnet, err := net.ParseCIDR(cidr) 61 | if err != nil { 62 | panic(err) 63 | } 64 | return ip, ipnet 65 | } 66 | 67 | func MustParseIP(ip string) net.IP { 68 | netip, _ := MustParseCIDR(fmt.Sprintf("%s/32", ip)) 69 | return netip 70 | } 71 | 72 | func nextIP(ip net.IP) net.IP { 73 | next := make([]byte, len(ip)) 74 | copy(next, ip) 75 | for j := len(next) - 1; j >= 0; j-- { 76 | next[j]++ 77 | if next[j] > 0 { 78 | break 79 | } 80 | } 81 | return next 82 | } 83 | 84 | func boolToRule(accept bool) string { 85 | if accept { 86 | return "ACCEPT" 87 | } 88 | return "REJECT" 89 | } 90 | -------------------------------------------------------------------------------- /internal/services/api_router.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math" 7 | "net/http" 8 | 9 | "github.com/place1/wg-embed/pkg/wgembed" 10 | 11 | grpc_logrus "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus" 12 | "github.com/improbable-eng/grpc-web/go/grpcweb" 13 | "github.com/place1/wg-access-server/internal/config" 14 | "github.com/place1/wg-access-server/internal/devices" 15 | "github.com/place1/wg-access-server/internal/traces" 16 | "github.com/place1/wg-access-server/proto/proto" 17 | "google.golang.org/grpc" 18 | ) 19 | 20 | type ApiServices struct { 21 | Config *config.AppConfig 22 | DeviceManager *devices.DeviceManager 23 | Wg wgembed.WireGuardInterface 24 | } 25 | 26 | func ApiRouter(deps *ApiServices) http.Handler { 27 | // Native GRPC server 28 | server := grpc.NewServer([]grpc.ServerOption{ 29 | grpc.MaxRecvMsgSize(int(1 * math.Pow(2, 20))), // 1MB 30 | grpc.UnaryInterceptor(func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) { 31 | return grpc_logrus.UnaryServerInterceptor(traces.Logger(ctx))(ctx, req, info, handler) 32 | }), 33 | }...) 34 | 35 | // Register GRPC services 36 | proto.RegisterServerServer(server, &ServerService{ 37 | Config: deps.Config, 38 | Wg: deps.Wg, 39 | }) 40 | proto.RegisterDevicesServer(server, &DeviceService{ 41 | DeviceManager: deps.DeviceManager, 42 | }) 43 | 44 | // Grpc Web in process proxy (wrapper) 45 | grpcServer := grpcweb.WrapServer(server, 46 | grpcweb.WithAllowNonRootResource(true), 47 | ) 48 | 49 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 50 | if grpcServer.IsGrpcWebRequest(r) { 51 | grpcServer.ServeHTTP(w, r) 52 | return 53 | } 54 | 55 | w.WriteHeader(400) 56 | fmt.Fprintln(w, "expected grpc request") 57 | return 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /internal/services/converters.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/golang/protobuf/ptypes" 7 | "github.com/golang/protobuf/ptypes/timestamp" 8 | "github.com/golang/protobuf/ptypes/wrappers" 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | func TimestampToTime(value *timestamp.Timestamp) time.Time { 13 | return time.Unix(value.Seconds, int64(value.Nanos)) 14 | } 15 | 16 | func TimeToTimestamp(value *time.Time) *timestamp.Timestamp { 17 | if value == nil { 18 | return nil 19 | } 20 | t, err := ptypes.TimestampProto(*value) 21 | if err != nil { 22 | logrus.Error("bad time value") 23 | t = ptypes.TimestampNow() 24 | } 25 | return t 26 | } 27 | 28 | func stringValue(value *string) *wrappers.StringValue { 29 | if value != nil { 30 | return &wrappers.StringValue{ 31 | Value: *value, 32 | } 33 | } 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /internal/services/device_service.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus" 8 | "github.com/place1/wg-access-server/pkg/authnz/authsession" 9 | 10 | "github.com/golang/protobuf/ptypes/empty" 11 | "github.com/place1/wg-access-server/internal/devices" 12 | "github.com/place1/wg-access-server/internal/storage" 13 | "github.com/place1/wg-access-server/proto/proto" 14 | "google.golang.org/grpc/codes" 15 | "google.golang.org/grpc/status" 16 | ) 17 | 18 | type DeviceService struct { 19 | DeviceManager *devices.DeviceManager 20 | } 21 | 22 | func (d *DeviceService) AddDevice(ctx context.Context, req *proto.AddDeviceReq) (*proto.Device, error) { 23 | user, err := authsession.CurrentUser(ctx) 24 | if err != nil { 25 | return nil, status.Errorf(codes.PermissionDenied, "not authenticated") 26 | } 27 | 28 | device, err := d.DeviceManager.AddDevice(user, req.GetName(), req.GetPublicKey()) 29 | if err != nil { 30 | ctxlogrus.Extract(ctx).Error(err) 31 | return nil, status.Errorf(codes.Internal, "failed to add device") 32 | } 33 | 34 | return mapDevice(device), nil 35 | } 36 | 37 | func (d *DeviceService) ListDevices(ctx context.Context, req *proto.ListDevicesReq) (*proto.ListDevicesRes, error) { 38 | user, err := authsession.CurrentUser(ctx) 39 | if err != nil { 40 | return nil, status.Errorf(codes.PermissionDenied, "not authenticated") 41 | } 42 | 43 | devices, err := d.DeviceManager.ListDevices(user.Subject) 44 | if err != nil { 45 | ctxlogrus.Extract(ctx).Error(err) 46 | return nil, status.Errorf(codes.Internal, "failed to retrieve devices") 47 | } 48 | return &proto.ListDevicesRes{ 49 | Items: mapDevices(devices), 50 | }, nil 51 | } 52 | 53 | func (d *DeviceService) DeleteDevice(ctx context.Context, req *proto.DeleteDeviceReq) (*empty.Empty, error) { 54 | user, err := authsession.CurrentUser(ctx) 55 | if err != nil { 56 | return nil, status.Errorf(codes.PermissionDenied, "not authenticated") 57 | } 58 | 59 | deviceOwner := user.Subject 60 | 61 | if req.Owner != nil { 62 | if user.Claims.Contains("admin") { 63 | deviceOwner = req.Owner.Value 64 | } else { 65 | return nil, status.Errorf(codes.PermissionDenied, "must be an admin") 66 | } 67 | } 68 | 69 | if err := d.DeviceManager.DeleteDevice(deviceOwner, req.GetName()); err != nil { 70 | ctxlogrus.Extract(ctx).Error(err) 71 | return nil, status.Errorf(codes.Internal, "failed to delete device") 72 | } 73 | 74 | return &empty.Empty{}, nil 75 | } 76 | 77 | func (d *DeviceService) ListAllDevices(ctx context.Context, req *proto.ListAllDevicesReq) (*proto.ListAllDevicesRes, error) { 78 | user, err := authsession.CurrentUser(ctx) 79 | if err != nil { 80 | return nil, status.Errorf(codes.PermissionDenied, "not authenticated") 81 | } 82 | 83 | if !user.Claims.Contains("admin") { 84 | return nil, status.Errorf(codes.PermissionDenied, "must be an admin") 85 | } 86 | 87 | devices, err := d.DeviceManager.ListAllDevices() 88 | if err != nil { 89 | ctxlogrus.Extract(ctx).Error(err) 90 | return nil, status.Errorf(codes.Internal, "failed to retrieve devices") 91 | } 92 | 93 | return &proto.ListAllDevicesRes{ 94 | Items: mapDevices(devices), 95 | }, nil 96 | } 97 | 98 | func mapDevice(d *storage.Device) *proto.Device { 99 | return &proto.Device{ 100 | Name: d.Name, 101 | Owner: d.Owner, 102 | OwnerName: d.OwnerName, 103 | OwnerEmail: d.OwnerEmail, 104 | OwnerProvider: d.OwnerProvider, 105 | PublicKey: d.PublicKey, 106 | Address: d.Address, 107 | CreatedAt: TimeToTimestamp(&d.CreatedAt), 108 | LastHandshakeTime: TimeToTimestamp(d.LastHandshakeTime), 109 | ReceiveBytes: d.ReceiveBytes, 110 | TransmitBytes: d.TransmitBytes, 111 | Endpoint: d.Endpoint, 112 | /** 113 | * Wireguard is a connectionless UDP protocol - data is only 114 | * sent over the wire when the client is sending real traffic. 115 | * Wireguard has no keep alive packets by default to remain as 116 | * silent as possible. 117 | * 118 | */ 119 | Connected: isConnected(d.LastHandshakeTime), 120 | } 121 | } 122 | 123 | func mapDevices(devices []*storage.Device) []*proto.Device { 124 | items := []*proto.Device{} 125 | for _, d := range devices { 126 | items = append(items, mapDevice(d)) 127 | } 128 | return items 129 | } 130 | 131 | func isConnected(lastHandshake *time.Time) bool { 132 | if lastHandshake == nil { 133 | return false 134 | } 135 | return lastHandshake.After(time.Now().Add(-3 * time.Minute)) 136 | } 137 | -------------------------------------------------------------------------------- /internal/services/health.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func HealthEndpoint() http.Handler { 9 | return http.HandlerFunc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 10 | w.WriteHeader(200) 11 | fmt.Fprintf(w, "ok") 12 | })) 13 | } 14 | -------------------------------------------------------------------------------- /internal/services/middleware.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "runtime/debug" 7 | 8 | "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus" 9 | "github.com/place1/wg-access-server/internal/traces" 10 | ) 11 | 12 | func TracesMiddleware(next http.Handler) http.Handler { 13 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 | next.ServeHTTP(w, r.WithContext(traces.WithTraceID(r.Context()))) 15 | }) 16 | } 17 | 18 | func RecoveryMiddleware(next http.Handler) http.Handler { 19 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | defer func() { 21 | if err := recover(); err != nil { 22 | ctxlogrus.Extract(r.Context()). 23 | WithField("stack", string(debug.Stack())). 24 | Error(err) 25 | w.WriteHeader(500) 26 | fmt.Fprintf(w, "server error\ntrace = %s\n", traces.TraceID(r.Context())) 27 | } 28 | }() 29 | next.ServeHTTP(w, r) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /internal/services/server_service.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus" 8 | "github.com/place1/wg-access-server/internal/network" 9 | 10 | "github.com/place1/wg-access-server/internal/config" 11 | "github.com/place1/wg-access-server/pkg/authnz/authsession" 12 | "github.com/place1/wg-access-server/proto/proto" 13 | "github.com/place1/wg-embed/pkg/wgembed" 14 | "google.golang.org/grpc/codes" 15 | "google.golang.org/grpc/status" 16 | ) 17 | 18 | type ServerService struct { 19 | Config *config.AppConfig 20 | Wg wgembed.WireGuardInterface 21 | } 22 | 23 | func (s *ServerService) Info(ctx context.Context, req *proto.InfoReq) (*proto.InfoRes, error) { 24 | user, err := authsession.CurrentUser(ctx) 25 | if err != nil { 26 | return nil, status.Errorf(codes.PermissionDenied, "not authenticated") 27 | } 28 | 29 | publicKey, err := s.Wg.PublicKey() 30 | if err != nil { 31 | ctxlogrus.Extract(ctx).Error(err) 32 | return nil, status.Errorf(codes.Internal, "failed to get public key") 33 | } 34 | 35 | return &proto.InfoRes{ 36 | Host: stringValue(&s.Config.ExternalHost), 37 | PublicKey: publicKey, 38 | Port: int32(s.Config.WireGuard.Port), 39 | HostVpnIp: network.ServerVPNIP(s.Config.VPN.CIDR).IP.String(), 40 | MetadataEnabled: !s.Config.DisableMetadata, 41 | IsAdmin: user.Claims.Contains("admin"), 42 | AllowedIps: allowedIPs(s.Config), 43 | DnsEnabled: s.Config.DNS.Enabled, 44 | DnsAddress: network.ServerVPNIP(s.Config.VPN.CIDR).IP.String(), 45 | }, nil 46 | } 47 | 48 | func allowedIPs(config *config.AppConfig) string { 49 | return strings.Join(config.VPN.AllowedIPs, ", ") 50 | } 51 | -------------------------------------------------------------------------------- /internal/services/website_router.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httputil" 6 | "net/url" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/gorilla/mux" 13 | "github.com/pkg/errors" 14 | "github.com/sirupsen/logrus" 15 | ) 16 | 17 | func WebsiteRouter() *mux.Router { 18 | router := mux.NewRouter() 19 | 20 | staticFiles, err := filepath.Abs("website/build") 21 | if err != nil { 22 | logrus.Fatal(errors.Wrap(err, "failed to create absolut path to website static files")) 23 | } 24 | 25 | if _, err := os.Stat(staticFiles); os.IsNotExist(err) { 26 | // if the static files directory doesn't exist 27 | // then proxy to a local webpack development server 28 | // i.e. we're developing wg-access-server locally 29 | logrus.Info("missing ./website/build - will reverse proxy to website dev server") 30 | u, _ := url.Parse("http://localhost:3000") 31 | router.NotFoundHandler = httputil.NewSingleHostReverseProxy(u) 32 | } else { 33 | // if the static files directory exists then 34 | // handle static file requests. 35 | // the react app handles routing so we also 36 | // add a catch-all route to serve the react index page. 37 | logrus.Info("serving website from ./website/build") 38 | router.PathPrefix("/").Handler( 39 | FileServerWith404( 40 | http.Dir(staticFiles), 41 | func(w http.ResponseWriter, r *http.Request) bool { 42 | http.ServeFile(w, r, filepath.Join(staticFiles, "index.html")) 43 | return false 44 | }, 45 | ), 46 | ) 47 | } 48 | return router 49 | } 50 | 51 | // credit: https://gist.github.com/lummie/91cd1c18b2e32fa9f316862221a6fd5c 52 | type FSHandler404 = func(w http.ResponseWriter, r *http.Request) (doDefaultFileServe bool) 53 | 54 | // credit: https://gist.github.com/lummie/91cd1c18b2e32fa9f316862221a6fd5c 55 | func FileServerWith404(root http.FileSystem, handler404 FSHandler404) http.Handler { 56 | fs := http.FileServer(root) 57 | 58 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 59 | //make sure the url path starts with / 60 | upath := r.URL.Path 61 | if !strings.HasPrefix(upath, "/") { 62 | upath = "/" + upath 63 | r.URL.Path = upath 64 | } 65 | upath = path.Clean(upath) 66 | 67 | // attempt to open the file via the http.FileSystem 68 | f, err := root.Open(upath) 69 | if err != nil { 70 | if os.IsNotExist(err) { 71 | // call handler 72 | if handler404 != nil { 73 | doDefault := handler404(w, r) 74 | if !doDefault { 75 | return 76 | } 77 | } 78 | } 79 | } 80 | 81 | // close if successfully opened 82 | if err == nil { 83 | f.Close() 84 | } 85 | 86 | // default serve 87 | fs.ServeHTTP(w, r) 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /internal/storage/contracts.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "time" 7 | 8 | "github.com/pkg/errors" 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | type Storage interface { 13 | Watcher 14 | Save(device *Device) error 15 | List(owner string) ([]*Device, error) 16 | Get(owner string, name string) (*Device, error) 17 | GetByPublicKey(publicKey string) (*Device, error) 18 | Delete(device *Device) error 19 | Close() error 20 | Open() error 21 | } 22 | 23 | type Watcher interface { 24 | OnAdd(cb Callback) 25 | OnDelete(cb Callback) 26 | OnReconnect(func()) 27 | EmitAdd(device *Device) 28 | EmitDelete(device *Device) 29 | } 30 | 31 | type Callback func(device *Device) 32 | 33 | type Device struct { 34 | Owner string `json:"owner" gorm:"type:varchar(100);unique_index:key;primary_key"` 35 | OwnerName string `json:"owner_name"` 36 | OwnerEmail string `json:"owner_email"` 37 | OwnerProvider string `json:"owner_provider"` 38 | Name string `json:"name" gorm:"type:varchar(100);unique_index:key;primary_key"` 39 | PublicKey string `json:"public_key" gorm:"unique_index"` 40 | Address string `json:"address"` 41 | CreatedAt time.Time `json:"created_at" gorm:"column:created_at"` 42 | 43 | /** 44 | * Metadata fields below. 45 | * All metadata tracking can be disabled 46 | * from the config file. 47 | */ 48 | 49 | // metadata about the device during the current session 50 | LastHandshakeTime *time.Time `json:"last_handshake_time"` 51 | ReceiveBytes int64 `json:"received_bytes"` 52 | TransmitBytes int64 `json:"transmit_bytes"` 53 | Endpoint string `json:"endpoint"` 54 | } 55 | 56 | func NewStorage(uri string) (Storage, error) { 57 | u, err := url.Parse(uri) 58 | if err != nil { 59 | return nil, errors.Wrap(err, "error parsing storage uri") 60 | } 61 | 62 | switch u.Scheme { 63 | case "memory": 64 | logrus.Warn("storing data in memory - devices will not persist between restarts") 65 | return NewMemoryStorage(), nil 66 | case "postgresql": 67 | fallthrough 68 | case "postgres": 69 | fallthrough 70 | case "mysql": 71 | fallthrough 72 | case "sqlite3": 73 | logrus.Infof("storing data in SQL backend %s", u.Scheme) 74 | return NewSqlStorage(u), nil 75 | } 76 | 77 | return nil, fmt.Errorf("unknown storage backend %s", u.Scheme) 78 | } 79 | -------------------------------------------------------------------------------- /internal/storage/contracts_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestMemoryStorage(t *testing.T) { 10 | require := require.New(t) 11 | 12 | s, err := NewStorage("memory://") 13 | require.NoError(err) 14 | 15 | require.IsType(&InMemoryStorage{}, s) 16 | } 17 | 18 | func TestPostgresqlStorage(t *testing.T) { 19 | require := require.New(t) 20 | 21 | s, err := NewStorage("postgresql://localhost:5432/dbname?sslmode=disable") 22 | require.NoError(err) 23 | 24 | require.IsType(&SQLStorage{}, s) 25 | } 26 | 27 | func TestMysqlStorage(t *testing.T) { 28 | require := require.New(t) 29 | 30 | s, err := NewStorage("mysql://localhost:1234/dbname?sslmode=disable") 31 | require.NoError(err) 32 | 33 | require.IsType(&SQLStorage{}, s) 34 | } 35 | 36 | func TestSqliteStorage(t *testing.T) { 37 | require := require.New(t) 38 | 39 | s, err := NewStorage("sqlite3:///some/path/sqlite.db") 40 | require.NoError(err) 41 | 42 | require.IsType(&SQLStorage{}, s) 43 | } 44 | 45 | func TestSqliteStorageRelativePath(t *testing.T) { 46 | require := require.New(t) 47 | 48 | s, err := NewStorage("sqlite3://sqlite.db") 49 | require.NoError(err) 50 | 51 | require.IsType(&SQLStorage{}, s) 52 | } 53 | 54 | func TestUnknownStorage(t *testing.T) { 55 | require := require.New(t) 56 | 57 | s, err := NewStorage("foo://") 58 | require.Nil(s) 59 | require.Error(err) 60 | require.Equal(err.Error(), "unknown storage backend foo") 61 | } 62 | -------------------------------------------------------------------------------- /internal/storage/inmemory.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | // implements Storage interface 9 | type InMemoryStorage struct { 10 | *InProcessWatcher 11 | db map[string]*Device 12 | } 13 | 14 | func NewMemoryStorage() *InMemoryStorage { 15 | db := make(map[string]*Device) 16 | return &InMemoryStorage{ 17 | InProcessWatcher: NewInProcessWatcher(), 18 | db: db, 19 | } 20 | } 21 | 22 | func (s *InMemoryStorage) Open() error { 23 | return nil 24 | } 25 | 26 | func (s *InMemoryStorage) Close() error { 27 | return nil 28 | } 29 | 30 | func (s *InMemoryStorage) Save(device *Device) error { 31 | s.db[key(device)] = device 32 | s.EmitAdd(device) 33 | return nil 34 | } 35 | 36 | func (s *InMemoryStorage) List(username string) ([]*Device, error) { 37 | devices := []*Device{} 38 | prefix := func() string { 39 | if username != "" { 40 | return keyStr(username, "") 41 | } 42 | return "" 43 | }() 44 | for key, device := range s.db { 45 | if strings.HasPrefix(key, prefix) { 46 | devices = append(devices, device) 47 | } 48 | } 49 | return devices, nil 50 | } 51 | 52 | func (s *InMemoryStorage) Get(owner string, name string) (*Device, error) { 53 | device, ok := s.db[keyStr(owner, name)] 54 | if !ok { 55 | return nil, errors.New("device doesn't exist") 56 | } 57 | return device, nil 58 | } 59 | 60 | func (s *InMemoryStorage) GetByPublicKey(publicKey string) (*Device, error) { 61 | devices, err := s.List("") 62 | if err != nil { 63 | return nil, err 64 | } 65 | for _, device := range devices { 66 | if device.PublicKey == publicKey { 67 | return device, nil 68 | } 69 | } 70 | return nil, errors.New("device doesn't exist") 71 | } 72 | 73 | func (s *InMemoryStorage) Delete(device *Device) error { 74 | delete(s.db, key(device)) 75 | s.EmitDelete(device) 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /internal/storage/inprocesswatcher.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | type InProcessWatcher struct { 4 | add []Callback 5 | delete []Callback 6 | } 7 | 8 | func NewInProcessWatcher() *InProcessWatcher { 9 | return &InProcessWatcher{ 10 | add: []Callback{}, 11 | delete: []Callback{}, 12 | } 13 | } 14 | 15 | func (w *InProcessWatcher) OnAdd(cb Callback) { 16 | w.add = append(w.add, cb) 17 | } 18 | 19 | func (w *InProcessWatcher) OnDelete(cb Callback) { 20 | w.delete = append(w.delete, cb) 21 | } 22 | 23 | func (w *InProcessWatcher) OnReconnect(cb func()) { 24 | // noop because the inprocess watcher can't disconnect 25 | } 26 | 27 | func (w *InProcessWatcher) EmitAdd(device *Device) { 28 | for _, cb := range w.add { 29 | cb(device) 30 | } 31 | } 32 | 33 | func (w *InProcessWatcher) EmitDelete(device *Device) { 34 | for _, cb := range w.delete { 35 | cb(device) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /internal/storage/pgwatcher.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/place1/pg-events/pkg/pgevents" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | type PgWatcher struct { 12 | *pgevents.Listener 13 | } 14 | 15 | func NewPgWatcher(connectionString string, table string) (*PgWatcher, error) { 16 | listener, err := pgevents.OpenListener(connectionString) 17 | if err != nil { 18 | return nil, errors.Wrap(err, "failed to open pg listener") 19 | } 20 | 21 | if err := listener.Attach(table); err != nil { 22 | return nil, errors.Wrapf(err, "failed to attach listener to table: %s", table) 23 | } 24 | 25 | return &PgWatcher{ 26 | Listener: listener, 27 | }, nil 28 | } 29 | 30 | func (w *PgWatcher) OnAdd(cb Callback) { 31 | w.Listener.OnEvent(func(event *pgevents.TableEvent) { 32 | // we only emit the "add" event on an insert because wg-access-server 33 | // doesn't allow anyone to modify their public key or allowed IPs. 34 | // a future change to wg-access-server may require listening to "updates" 35 | // if either of those properties become mutable. 36 | if event.Action == "INSERT" { 37 | w.emit(cb, event) 38 | } 39 | }) 40 | } 41 | 42 | func (w *PgWatcher) OnDelete(cb Callback) { 43 | w.Listener.OnEvent(func(event *pgevents.TableEvent) { 44 | if event.Action == "DELETE" { 45 | w.emit(cb, event) 46 | } 47 | }) 48 | } 49 | 50 | func (w *PgWatcher) OnReconnect(cb func()) { 51 | w.Listener.OnReconnect(cb) 52 | } 53 | 54 | func (w *PgWatcher) emit(cb Callback, event *pgevents.TableEvent) { 55 | device := &Device{} 56 | if err := json.Unmarshal([]byte(event.Data), device); err != nil { 57 | logrus.Error(errors.Wrap(err, "failed to unmarshal postgres event data into device struct")) 58 | } else { 59 | cb(device) 60 | } 61 | } 62 | 63 | func (w *PgWatcher) EmitAdd(device *Device) { 64 | // noop because we rely on postgres channels 65 | } 66 | 67 | func (w *PgWatcher) EmitDelete(device *Device) { 68 | // noop because we rely on postgres channels 69 | } 70 | -------------------------------------------------------------------------------- /internal/storage/sql.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/jinzhu/gorm" 10 | _ "github.com/jinzhu/gorm/dialects/mysql" 11 | _ "github.com/jinzhu/gorm/dialects/postgres" 12 | _ "github.com/jinzhu/gorm/dialects/sqlite" 13 | "github.com/pkg/errors" 14 | "github.com/sirupsen/logrus" 15 | ) 16 | 17 | // GormLogger is a custom logger for Gorm, making it use logrus. 18 | type GormLogger struct{} 19 | 20 | // Print handles log events from Gorm for the custom logger. 21 | func (*GormLogger) Print(v ...interface{}) { 22 | switch v[0] { 23 | case "sql": 24 | logrus.WithFields( 25 | logrus.Fields{ 26 | "module": "gorm", 27 | "type": "sql", 28 | "rows": v[5], 29 | "src_ref": v[1], 30 | "values": v[4], 31 | }, 32 | ).Debug(v[3]) 33 | case "logrus": 34 | logrus.WithFields(logrus.Fields{"module": "gorm", "type": "logrus"}).Print(v[2]) 35 | } 36 | } 37 | 38 | // implements Storage interface 39 | type SQLStorage struct { 40 | Watcher 41 | db *gorm.DB 42 | sqlType string 43 | connectionString string 44 | } 45 | 46 | func NewSqlStorage(u *url.URL) *SQLStorage { 47 | var connectionString string 48 | 49 | switch u.Scheme { 50 | case "postgresql": 51 | // handle `postgresql` as the scheme to be compatible with 52 | // standar uri style postgresql connection strings (i.e. like psql) 53 | u.Scheme = "postgres" 54 | fallthrough 55 | case "postgres": 56 | connectionString = pgconn(u) 57 | case "mysql": 58 | connectionString = mysqlconn(u) 59 | case "sqlite3": 60 | connectionString = sqlite3conn(u) 61 | default: 62 | // unreachable because our storage backend factory 63 | // function (contracts.go) already checks the url scheme. 64 | logrus.Panicf("unknown sql storage backend %s", u.Scheme) 65 | } 66 | 67 | return &SQLStorage{ 68 | Watcher: nil, 69 | db: nil, 70 | sqlType: u.Scheme, 71 | connectionString: connectionString, 72 | } 73 | } 74 | 75 | func pgconn(u *url.URL) string { 76 | password, _ := u.User.Password() 77 | decodedQuery, err := url.QueryUnescape(u.RawQuery) 78 | if err != nil { 79 | logrus.Warnf("failed to unescape connection string query parameters - they will be ignored") 80 | decodedQuery = "" 81 | } 82 | return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s %s", 83 | u.Hostname(), 84 | u.Port(), 85 | u.User.Username(), 86 | password, 87 | strings.TrimLeft(u.Path, "/"), 88 | decodedQuery, 89 | ) 90 | } 91 | 92 | func mysqlconn(u *url.URL) string { 93 | password, _ := u.User.Password() 94 | return fmt.Sprintf( 95 | "%s:%s@%s/%s?%s", 96 | u.User.Username(), 97 | password, 98 | u.Host, 99 | strings.TrimLeft(u.Path, "/"), 100 | u.RawQuery, 101 | ) 102 | } 103 | 104 | func sqlite3conn(u *url.URL) string { 105 | return filepath.Join(u.Host, u.Path) 106 | } 107 | 108 | func (s *SQLStorage) Open() error { 109 | db, err := gorm.Open(s.sqlType, s.connectionString) 110 | if err != nil { 111 | return errors.Wrap(err, fmt.Sprintf("failed to connect to %s", s.sqlType)) 112 | } 113 | s.db = db 114 | 115 | db.SetLogger(&GormLogger{}) 116 | db.LogMode(true) 117 | 118 | // Migrate the schema 119 | s.db.AutoMigrate(&Device{}) 120 | 121 | if s.sqlType == "postgres" { 122 | watcher, err := NewPgWatcher(s.connectionString, db.NewScope(&Device{}).TableName()) 123 | if err != nil { 124 | return errors.Wrap(err, "failed to create pg watcher") 125 | } 126 | s.Watcher = watcher 127 | } else { 128 | s.Watcher = NewInProcessWatcher() 129 | } 130 | 131 | return nil 132 | } 133 | 134 | func (s *SQLStorage) Close() error { 135 | if s.db != nil { 136 | return s.db.Close() 137 | } 138 | return nil 139 | } 140 | 141 | func (s *SQLStorage) Save(device *Device) error { 142 | logrus.Debugf("saving device %s", key(device)) 143 | if err := s.db.Save(&device).Error; err != nil { 144 | return errors.Wrapf(err, "failed to write device") 145 | } 146 | s.Watcher.EmitAdd(device) 147 | return nil 148 | } 149 | 150 | func (s *SQLStorage) List(username string) ([]*Device, error) { 151 | var err error 152 | devices := []*Device{} 153 | if username != "" { 154 | err = s.db.Where("owner = ?", username).Find(&devices).Error 155 | } else { 156 | err = s.db.Find(&devices).Error 157 | } 158 | 159 | logrus.Debugf("found %d device(s)", len(devices)) 160 | if err != nil { 161 | return nil, errors.Wrapf(err, "failed to read devices from sql") 162 | } 163 | return devices, nil 164 | } 165 | 166 | func (s *SQLStorage) Get(owner string, name string) (*Device, error) { 167 | device := &Device{} 168 | if err := s.db.Where("owner = ? AND name = ?", owner, name).First(&device).Error; err != nil { 169 | return nil, errors.Wrapf(err, "failed to read device") 170 | } 171 | return device, nil 172 | } 173 | 174 | func (s *SQLStorage) GetByPublicKey(publicKey string) (*Device, error) { 175 | device := &Device{} 176 | if err := s.db.Where("public_key = ?", publicKey).First(&device).Error; err != nil { 177 | return nil, errors.Wrapf(err, "failed to read device") 178 | } 179 | return device, nil 180 | } 181 | 182 | func (s *SQLStorage) Delete(device *Device) error { 183 | if err := s.db.Delete(&device).Error; err != nil { 184 | return errors.Wrap(err, "failed to delete device file") 185 | } 186 | s.Watcher.EmitDelete(device) 187 | return nil 188 | } 189 | -------------------------------------------------------------------------------- /internal/storage/utils.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "path/filepath" 5 | ) 6 | 7 | func keyStr(owner string, name string) string { 8 | return filepath.Join(owner, name) 9 | } 10 | 11 | func key(device *Device) string { 12 | return keyStr(device.Owner, device.Name) 13 | } 14 | -------------------------------------------------------------------------------- /internal/traces/traces.go: -------------------------------------------------------------------------------- 1 | package traces 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | "github.com/pkg/errors" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | const ( 12 | TraceIDKey = "trace.id" 13 | ) 14 | 15 | func WithTraceID(ctx context.Context) context.Context { 16 | id, err := uuid.NewRandom() 17 | if err != nil { 18 | logrus.Warn(errors.Wrap(err, "failed to generate trace id")) 19 | return ctx 20 | } 21 | return context.WithValue(ctx, TraceIDKey, id.String()) 22 | } 23 | 24 | func Logger(ctx context.Context) *logrus.Entry { 25 | return logrus.WithField("trace.id", TraceID(ctx)) 26 | 27 | } 28 | 29 | func TraceID(ctx context.Context) string { 30 | if id, ok := ctx.Value(TraceIDKey).(string); ok { 31 | return id 32 | } 33 | return "" 34 | } 35 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | 9 | "github.com/pkg/errors" 10 | "github.com/place1/wg-access-server/cmd" 11 | "github.com/place1/wg-access-server/cmd/migrate" 12 | "github.com/place1/wg-access-server/cmd/serve" 13 | "github.com/sirupsen/logrus" 14 | "gopkg.in/alecthomas/kingpin.v2" 15 | ) 16 | 17 | var ( 18 | app = kingpin.New("wg-access-server", "An all-in-one WireGuard Access Server & VPN solution") 19 | logLevel = app.Flag("log-level", "Log level: trace, debug, info, error, fatal").Envar("WG_LOG_LEVEL").Default("info").String() 20 | ) 21 | 22 | func main() { 23 | // all the subcommands for wg-access-server 24 | commands := []cmd.Command{ 25 | serve.Register(app), 26 | migrate.Register(app), 27 | } 28 | 29 | // parse CLI arguments 30 | clicmd := kingpin.MustParse(app.Parse(os.Args[1:])) 31 | 32 | // set global log level 33 | level, err := logrus.ParseLevel(*logLevel) 34 | if err != nil { 35 | logrus.Fatal(errors.Wrap(err, "invalid log level - should be one of fatal, error, warn, info, debug, trace")) 36 | } 37 | logrus.SetLevel(level) 38 | logrus.SetReportCaller(true) 39 | logrus.SetFormatter(&logrus.TextFormatter{ 40 | CallerPrettyfier: func(f *runtime.Frame) (string, string) { 41 | return "", fmt.Sprintf("%s:%d", filepath.Base(f.File), f.Line) 42 | }, 43 | }) 44 | 45 | for _, c := range commands { 46 | if clicmd == c.Name() { 47 | c.Run() 48 | return 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: wg-access-server 2 | strict: true 3 | repo_name: place1/wg-access-server 4 | repo_url: https://github.com/place1/wg-access-server 5 | site_url: https://place1.github.io/wg-access-server 6 | 7 | theme: 8 | name: material 9 | include_sidebar: true 10 | logo: 11 | icon: cloud_queue # globe icon 12 | feature: 13 | tabs: false 14 | palette: 15 | primary: light blue 16 | accent: teal 17 | 18 | markdown_extensions: 19 | - admonition 20 | - codehilite 21 | - pymdownx.inlinehilite 22 | - pymdownx.superfences 23 | - markdown_include.include: 24 | base_path: docs 25 | - meta 26 | - pymdownx.tasklist: 27 | custom_checkbox: true 28 | - toc: 29 | permalink: " ¶" 30 | 31 | plugins: 32 | - search 33 | -------------------------------------------------------------------------------- /pkg/authnz/authconfig/authconfig.go: -------------------------------------------------------------------------------- 1 | package authconfig 2 | 3 | import ( 4 | "github.com/place1/wg-access-server/pkg/authnz/authruntime" 5 | ) 6 | 7 | type AuthConfig struct { 8 | OIDC *OIDCConfig `yaml:"oidc"` 9 | Gitlab *GitlabConfig `yaml:"gitlab"` 10 | Basic *BasicAuthConfig `yaml:"basic"` 11 | } 12 | 13 | func (c *AuthConfig) IsEnabled() bool { 14 | return c.OIDC != nil || c.Gitlab != nil || c.Basic != nil 15 | } 16 | 17 | func (c *AuthConfig) Providers() []*authruntime.Provider { 18 | providers := []*authruntime.Provider{} 19 | 20 | if c.OIDC != nil { 21 | providers = append(providers, c.OIDC.Provider()) 22 | } 23 | 24 | if c.Gitlab != nil { 25 | providers = append(providers, c.Gitlab.Provider()) 26 | } 27 | 28 | if c.Basic != nil { 29 | providers = append(providers, c.Basic.Provider()) 30 | } 31 | 32 | return providers 33 | } 34 | -------------------------------------------------------------------------------- /pkg/authnz/authconfig/basic.go: -------------------------------------------------------------------------------- 1 | package authconfig 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/place1/wg-access-server/pkg/authnz/authruntime" 9 | "github.com/place1/wg-access-server/pkg/authnz/authsession" 10 | "github.com/tg123/go-htpasswd" 11 | ) 12 | 13 | type BasicAuthConfig struct { 14 | // Users is a list of htpasswd encoded username:password pairs 15 | // supports BCrypt, Sha, Ssha, Md5 16 | // example: "htpasswd -nB " 17 | // copy the result into your user's array 18 | Users []string `yaml:"users"` 19 | } 20 | 21 | func (c *BasicAuthConfig) Provider() *authruntime.Provider { 22 | return &authruntime.Provider{ 23 | Type: "Basic", 24 | Invoke: func(w http.ResponseWriter, r *http.Request, runtime *authruntime.ProviderRuntime) { 25 | basicAuthLogin(c, runtime)(w, r) 26 | }, 27 | } 28 | } 29 | 30 | func basicAuthLogin(c *BasicAuthConfig, runtime *authruntime.ProviderRuntime) http.HandlerFunc { 31 | return func(w http.ResponseWriter, r *http.Request) { 32 | u, p, ok := r.BasicAuth() 33 | if !ok { 34 | w.Header().Set("WWW-Authenticate", `Basic realm="site"`) 35 | w.WriteHeader(http.StatusUnauthorized) 36 | fmt.Fprintln(w, "unauthorized") 37 | return 38 | } 39 | 40 | if ok := checkCreds(c.Users, u, p); ok { 41 | runtime.SetSession(w, r, &authsession.AuthSession{ 42 | Identity: &authsession.Identity{ 43 | Provider: "basic", 44 | Subject: u, 45 | Name: u, 46 | Email: "", // basic auth has no email 47 | }, 48 | }) 49 | runtime.Done(w, r) 50 | return 51 | } 52 | 53 | w.Header().Set("WWW-Authenticate", `Basic realm="site"`) 54 | w.WriteHeader(http.StatusUnauthorized) 55 | fmt.Fprintln(w, "unauthorized") 56 | return 57 | } 58 | } 59 | 60 | func checkCreds(users []string, username string, password string) bool { 61 | for _, user := range users { 62 | if u, p, ok := parsehtpassword(user); ok { 63 | if u == username { 64 | return checkhtpasswd(p, password) 65 | } 66 | } 67 | } 68 | return false 69 | } 70 | 71 | func parsehtpassword(user string) (string, string, bool) { 72 | segments := strings.SplitN(user, ":", 2) 73 | if len(segments) >= 1 { 74 | return segments[0], segments[1], true 75 | } 76 | return "", "", false 77 | } 78 | 79 | func checkhtpasswd(required string, given string) bool { 80 | if encoded, err := htpasswd.AcceptBcrypt(required); encoded != nil && err == nil { 81 | return encoded.MatchesPassword(given) 82 | } 83 | if encoded, err := htpasswd.AcceptSha(required); encoded != nil && err == nil { 84 | return encoded.MatchesPassword(given) 85 | } 86 | if encoded, err := htpasswd.AcceptSsha(required); encoded != nil && err == nil { 87 | return encoded.MatchesPassword(given) 88 | } 89 | if encoded, err := htpasswd.AcceptMd5(required); encoded != nil && err == nil { 90 | return encoded.MatchesPassword(given) 91 | } 92 | return false 93 | } 94 | -------------------------------------------------------------------------------- /pkg/authnz/authconfig/gitlab.go: -------------------------------------------------------------------------------- 1 | package authconfig 2 | 3 | import "github.com/place1/wg-access-server/pkg/authnz/authruntime" 4 | 5 | type GitlabConfig struct { 6 | Name string `yaml:"name"` 7 | BaseURL string `yaml:"baseURL"` 8 | ClientID string `yaml:"clientID"` 9 | ClientSecret string `yaml:"clientSecret"` 10 | RedirectURL string `yaml:"redirectURL"` 11 | EmailDomains []string `yaml:"emailDomains"` 12 | } 13 | 14 | func (c *GitlabConfig) Provider() *authruntime.Provider { 15 | o := OIDCConfig{ 16 | Name: c.Name, 17 | Issuer: c.BaseURL, 18 | ClientID: c.ClientID, 19 | ClientSecret: c.ClientSecret, 20 | RedirectURL: c.RedirectURL, 21 | Scopes: []string{"openid"}, 22 | EmailDomains: c.EmailDomains, 23 | } 24 | p := o.Provider() 25 | p.Type = "Gitlab" 26 | return p 27 | } 28 | -------------------------------------------------------------------------------- /pkg/authnz/authconfig/oidc.go: -------------------------------------------------------------------------------- 1 | package authconfig 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/coreos/go-oidc" 12 | "github.com/gorilla/mux" 13 | "github.com/pkg/errors" 14 | "github.com/place1/wg-access-server/pkg/authnz/authruntime" 15 | "github.com/place1/wg-access-server/pkg/authnz/authsession" 16 | "github.com/place1/wg-access-server/pkg/authnz/authutil" 17 | "github.com/sirupsen/logrus" 18 | "golang.org/x/oauth2" 19 | "gopkg.in/Knetic/govaluate.v2" 20 | "gopkg.in/yaml.v2" 21 | ) 22 | 23 | type OIDCConfig struct { 24 | Name string `yaml:"name"` 25 | Issuer string `yaml:"issuer"` 26 | ClientID string `yaml:"clientID"` 27 | ClientSecret string `yaml:"clientSecret"` 28 | Scopes []string `yaml:"scopes"` 29 | RedirectURL string `yaml:"redirectURL"` 30 | EmailDomains []string `yaml:"emailDomains"` 31 | ClaimMapping map[string]ruleExpression `yaml:"claimMapping"` 32 | } 33 | 34 | func (c *OIDCConfig) Provider() *authruntime.Provider { 35 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) 36 | defer cancel() 37 | provider, err := oidc.NewProvider(ctx, c.Issuer) 38 | if err != nil { 39 | logrus.Fatal(errors.Wrap(err, "failed to create oidc provider")) 40 | } 41 | 42 | if c.Scopes == nil { 43 | c.Scopes = []string{"openid"} 44 | } 45 | 46 | oauthConfig := &oauth2.Config{ 47 | RedirectURL: c.RedirectURL, 48 | ClientID: c.ClientID, 49 | ClientSecret: c.ClientSecret, 50 | Scopes: c.Scopes, 51 | Endpoint: provider.Endpoint(), 52 | } 53 | 54 | redirectURL, err := url.Parse(c.RedirectURL) 55 | if err != nil { 56 | panic(errors.Wrapf(err, "redirect url is not valid: %s", c.RedirectURL)) 57 | } 58 | 59 | return &authruntime.Provider{ 60 | Type: "OIDC", 61 | Invoke: func(w http.ResponseWriter, r *http.Request, runtime *authruntime.ProviderRuntime) { 62 | c.loginHandler(runtime, oauthConfig)(w, r) 63 | }, 64 | RegisterRoutes: func(router *mux.Router, runtime *authruntime.ProviderRuntime) error { 65 | router.HandleFunc(redirectURL.Path, c.callbackHandler(runtime, oauthConfig, provider)) 66 | return nil 67 | }, 68 | } 69 | } 70 | 71 | func (c *OIDCConfig) loginHandler(runtime *authruntime.ProviderRuntime, oauthConfig *oauth2.Config) http.HandlerFunc { 72 | return func(w http.ResponseWriter, r *http.Request) { 73 | oauthStateString := authutil.RandomString(32) 74 | runtime.SetSession(w, r, &authsession.AuthSession{ 75 | Nonce: &oauthStateString, 76 | }) 77 | url := oauthConfig.AuthCodeURL(oauthStateString) 78 | http.Redirect(w, r, url, http.StatusTemporaryRedirect) 79 | } 80 | } 81 | 82 | func (c *OIDCConfig) callbackHandler(runtime *authruntime.ProviderRuntime, oauthConfig *oauth2.Config, provider *oidc.Provider) http.HandlerFunc { 83 | return func(w http.ResponseWriter, r *http.Request) { 84 | s, err := runtime.GetSession(r) 85 | if err != nil { 86 | http.Error(w, "no session", http.StatusBadRequest) 87 | return 88 | } 89 | 90 | state := r.FormValue("state") 91 | if s.Nonce == nil || *s.Nonce != state { 92 | http.Error(w, "bad nonce", http.StatusBadRequest) 93 | return 94 | } 95 | 96 | code := r.FormValue("code") 97 | token, _ := oauthConfig.Exchange(r.Context(), code) 98 | info, err := provider.UserInfo(r.Context(), oauthConfig.TokenSource(r.Context(), token)) 99 | if err != nil { 100 | http.Error(w, err.Error(), http.StatusBadRequest) 101 | return 102 | } 103 | 104 | if msg, valid := verifyEmailDomain(c.EmailDomains, info.Email); !valid { 105 | http.Error(w, msg, http.StatusForbidden) 106 | return 107 | } 108 | 109 | oidcProfileData := make(map[string]interface{}) 110 | info.Claims(&oidcProfileData) 111 | 112 | claims := &authsession.Claims{} 113 | for claimName, rule := range c.ClaimMapping { 114 | result, err := rule.Evaluate(oidcProfileData) 115 | 116 | if err != nil { 117 | http.Error(w, err.Error(), http.StatusBadRequest) 118 | return 119 | } 120 | 121 | // If result is 'false' or an empty string then don't include the Claim 122 | if val, ok := result.(bool); ok && val { 123 | claims.Add(claimName, strconv.FormatBool(val)) 124 | } else if val, ok := result.(string); ok && len(val) > 0 { 125 | claims.Add(claimName, val) 126 | } 127 | } 128 | 129 | runtime.SetSession(w, r, &authsession.AuthSession{ 130 | Identity: &authsession.Identity{ 131 | Provider: c.Name, 132 | Subject: info.Subject, 133 | Email: info.Email, 134 | Name: oidcProfileData["name"].(string), 135 | Claims: *claims, 136 | }, 137 | }) 138 | 139 | runtime.Done(w, r) 140 | } 141 | } 142 | 143 | func verifyEmailDomain(allowedDomains []string, email string) (string, bool) { 144 | if len(allowedDomains) == 0 { 145 | return "", true 146 | } 147 | 148 | parsed := strings.Split(email, "@") 149 | 150 | // check we have 2 parts i.e. @ 151 | if len(parsed) != 2 { 152 | return "missing or invalid email address", false 153 | } 154 | 155 | // match the domain against the list of allowed domains 156 | for _, domain := range allowedDomains { 157 | if domain == parsed[1] { 158 | return "", true 159 | } 160 | } 161 | 162 | return "email domain not authorized", false 163 | } 164 | 165 | type ruleExpression struct { 166 | *govaluate.EvaluableExpression 167 | } 168 | 169 | // MarshalYAML will encode a RuleExpression/govalidate into yaml string 170 | func (r ruleExpression) MarshalYAML() (interface{}, error) { 171 | return yaml.Marshal(r.String()) 172 | } 173 | 174 | // UnmarshalYAML will decode a RuleExpression/govalidate into yaml string 175 | func (r *ruleExpression) UnmarshalYAML(unmarshal func(interface{}) error) error { 176 | var ruleStr string 177 | if err := unmarshal(&ruleStr); err != nil { 178 | return err 179 | } 180 | parsedRule, err := govaluate.NewEvaluableExpression(ruleStr) 181 | if err != nil { 182 | return errors.Wrap(err, "Unable to process oidc rule") 183 | } 184 | ruleExpression := &ruleExpression{parsedRule} 185 | *r = *ruleExpression 186 | return nil 187 | } 188 | -------------------------------------------------------------------------------- /pkg/authnz/authruntime/runtime.go: -------------------------------------------------------------------------------- 1 | package authruntime 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/mux" 7 | "github.com/gorilla/sessions" 8 | "github.com/place1/wg-access-server/pkg/authnz/authsession" 9 | ) 10 | 11 | type Provider struct { 12 | Type string 13 | Invoke func(http.ResponseWriter, *http.Request, *ProviderRuntime) 14 | RegisterRoutes func(*mux.Router, *ProviderRuntime) error 15 | } 16 | 17 | type ProviderRuntime struct { 18 | store sessions.Store 19 | } 20 | 21 | func NewProviderRuntime(store sessions.Store) *ProviderRuntime { 22 | return &ProviderRuntime{store} 23 | } 24 | 25 | func (p *ProviderRuntime) SetSession(w http.ResponseWriter, r *http.Request, s *authsession.AuthSession) error { 26 | return authsession.SetSession(p.store, r, w, s) 27 | } 28 | 29 | func (p *ProviderRuntime) GetSession(r *http.Request) (*authsession.AuthSession, error) { 30 | return authsession.GetSession(p.store, r) 31 | } 32 | 33 | func (p *ProviderRuntime) ClearSession(w http.ResponseWriter, r *http.Request) error { 34 | return authsession.ClearSession(p.store, r, w) 35 | } 36 | 37 | func (p *ProviderRuntime) Restart(w http.ResponseWriter, r *http.Request) { 38 | http.Redirect(w, r, "/signin", http.StatusTemporaryRedirect) 39 | } 40 | 41 | func (p *ProviderRuntime) Done(w http.ResponseWriter, r *http.Request) { 42 | http.Redirect(w, r, "/", http.StatusTemporaryRedirect) 43 | } 44 | -------------------------------------------------------------------------------- /pkg/authnz/authsession/claims.go: -------------------------------------------------------------------------------- 1 | package authsession 2 | 3 | type claim struct { 4 | Name string 5 | Value string 6 | } 7 | 8 | type Claims []claim 9 | 10 | func (c *Claims) Add(name string, value string) { 11 | *c = append(*c, claim{ 12 | Name: name, 13 | Value: value, 14 | }) 15 | } 16 | 17 | func (c *Claims) Contains(claim string) bool { 18 | for _, curr := range *c { 19 | if curr.Name == claim { 20 | return true 21 | } 22 | } 23 | return false 24 | } 25 | 26 | func (c *Claims) Has(claim string, value string) bool { 27 | for _, curr := range *c { 28 | if curr.Name == claim { 29 | if curr.Value == value { 30 | return true 31 | } 32 | } 33 | } 34 | return false 35 | } 36 | -------------------------------------------------------------------------------- /pkg/authnz/authsession/identity.go: -------------------------------------------------------------------------------- 1 | package authsession 2 | 3 | type Identity struct { 4 | // Provider is the name of the authentication provider 5 | // that authenticated (created) this Identity struct. 6 | Provider string 7 | // Subject is the canonical identifer for this Identity. 8 | Subject string 9 | // Name is the name of the person this Identity refers to. 10 | // It may be empty. 11 | Name string 12 | // Email is the email address of the person this Identity refers to. 13 | // It may be empty. 14 | Email string 15 | // Claims are any additional claims that middleware have 16 | // added to this Identity. 17 | Claims Claims 18 | } 19 | -------------------------------------------------------------------------------- /pkg/authnz/authsession/middleware.go: -------------------------------------------------------------------------------- 1 | package authsession 2 | 3 | type ClaimsMiddleware func(user *Identity) error 4 | -------------------------------------------------------------------------------- /pkg/authnz/authsession/session.go: -------------------------------------------------------------------------------- 1 | package authsession 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | 8 | "github.com/gorilla/sessions" 9 | "github.com/pkg/errors" 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | type AuthSession struct { 14 | Nonce *string 15 | Identity *Identity 16 | } 17 | 18 | type authSessionKey string 19 | 20 | var sessionKey authSessionKey = "auth-session" 21 | 22 | func GetSession(store sessions.Store, r *http.Request) (*AuthSession, error) { 23 | session, _ := store.Get(r, string(sessionKey)) 24 | if data, ok := session.Values[string(sessionKey)].([]byte); ok { 25 | s := &AuthSession{} 26 | if err := json.Unmarshal(data, s); err != nil { 27 | return nil, errors.Wrap(err, "failed to parse session") 28 | } 29 | return s, nil 30 | } 31 | return nil, errors.New("session not authenticated") 32 | } 33 | 34 | func SetSession(store sessions.Store, r *http.Request, w http.ResponseWriter, s *AuthSession) error { 35 | data, err := json.Marshal(s) 36 | if err != nil { 37 | return errors.Wrap(err, "failed to marshal session") 38 | } 39 | session, _ := store.Get(r, string(sessionKey)) 40 | session.Values[string(sessionKey)] = data 41 | if err := session.Save(r, w); err != nil { 42 | return err 43 | } 44 | return nil 45 | } 46 | 47 | func ClearSession(store sessions.Store, r *http.Request, w http.ResponseWriter) error { 48 | session, _ := store.Get(r, string(sessionKey)) 49 | session.Options.MaxAge = -1 50 | if err := session.Save(r, w); err != nil { 51 | logrus.Error(err) 52 | return err 53 | } 54 | return nil 55 | } 56 | 57 | func SetIdentityCtx(parent context.Context, session *AuthSession) context.Context { 58 | return context.WithValue(parent, sessionKey, session) 59 | } 60 | 61 | func CurrentUser(ctx context.Context) (*Identity, error) { 62 | if session, ok := ctx.Value(sessionKey).(*AuthSession); ok { 63 | if session.Identity != nil { 64 | return session.Identity, nil 65 | } 66 | } 67 | return nil, errors.New("unauthenticated") 68 | } 69 | 70 | func Authenticated(ctx context.Context) bool { 71 | _, err := CurrentUser(ctx) 72 | return err == nil 73 | } 74 | -------------------------------------------------------------------------------- /pkg/authnz/authtemplates/login.go: -------------------------------------------------------------------------------- 1 | package authtemplates 2 | 3 | import ( 4 | "html/template" 5 | "io" 6 | 7 | "github.com/place1/wg-access-server/pkg/authnz/authruntime" 8 | ) 9 | 10 | type LoginPage struct { 11 | Providers []*authruntime.Provider 12 | } 13 | 14 | func RenderLoginPage(w io.Writer, data LoginPage) error { 15 | tpl, err := template.New("login-page").Parse(loginPage) 16 | if err != nil { 17 | return err 18 | } 19 | return tpl.Execute(w, data) 20 | } 21 | 22 | const loginPage string = ` 23 | 117 | 118 |
119 |

Sign In

120 | 121 | {{range $i, $p := .Providers}} 122 | 123 | 124 | 125 | {{end}} 126 | 127 | 135 | 136 |
137 | ` 138 | -------------------------------------------------------------------------------- /pkg/authnz/authutil/random.go: -------------------------------------------------------------------------------- 1 | package authutil 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | 7 | "github.com/pkg/errors" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | func RandomString(size int) string { 12 | blk := make([]byte, size) 13 | _, err := rand.Read(blk) 14 | if err != nil { 15 | logrus.Fatal(errors.Wrap(err, "failed to make a random string")) 16 | } 17 | return base64.StdEncoding.EncodeToString(blk) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/authnz/router.go: -------------------------------------------------------------------------------- 1 | package authnz 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus" 9 | "github.com/pkg/errors" 10 | 11 | "github.com/place1/wg-access-server/pkg/authnz/authconfig" 12 | "github.com/place1/wg-access-server/pkg/authnz/authruntime" 13 | "github.com/place1/wg-access-server/pkg/authnz/authsession" 14 | "github.com/place1/wg-access-server/pkg/authnz/authtemplates" 15 | "github.com/place1/wg-access-server/pkg/authnz/authutil" 16 | 17 | "github.com/gorilla/mux" 18 | "github.com/gorilla/sessions" 19 | ) 20 | 21 | type AuthMiddleware struct { 22 | config authconfig.AuthConfig 23 | claimsMiddleware authsession.ClaimsMiddleware 24 | router *mux.Router 25 | runtime *authruntime.ProviderRuntime 26 | } 27 | 28 | func New(config authconfig.AuthConfig, claimsMiddleware authsession.ClaimsMiddleware) *AuthMiddleware { 29 | router := mux.NewRouter() 30 | store := sessions.NewCookieStore([]byte(authutil.RandomString(32))) 31 | runtime := authruntime.NewProviderRuntime(store) 32 | providers := config.Providers() 33 | 34 | for _, p := range providers { 35 | if p.RegisterRoutes != nil { 36 | p.RegisterRoutes(router, runtime) 37 | } 38 | } 39 | 40 | router.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) { 41 | w.WriteHeader(http.StatusOK) 42 | fmt.Fprint(w, authtemplates.RenderLoginPage(w, authtemplates.LoginPage{ 43 | Providers: providers, 44 | })) 45 | }) 46 | 47 | router.HandleFunc("/signin/{index}", func(w http.ResponseWriter, r *http.Request) { 48 | index, err := strconv.Atoi(mux.Vars(r)["index"]) 49 | if err != nil || index < 0 || len(providers) <= index { 50 | w.WriteHeader(http.StatusBadRequest) 51 | fmt.Fprintf(w, "unknown provider") 52 | return 53 | } 54 | provider := providers[index] 55 | provider.Invoke(w, r, runtime) 56 | }) 57 | 58 | router.HandleFunc("/signout", func(w http.ResponseWriter, r *http.Request) { 59 | runtime.ClearSession(w, r) 60 | runtime.Restart(w, r) 61 | }) 62 | 63 | return &AuthMiddleware{ 64 | config, 65 | claimsMiddleware, 66 | router, 67 | runtime, 68 | } 69 | } 70 | 71 | func NewMiddleware(config authconfig.AuthConfig, claimsMiddleware authsession.ClaimsMiddleware) mux.MiddlewareFunc { 72 | return New(config, claimsMiddleware).Middleware 73 | } 74 | 75 | func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler { 76 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 77 | // check if the request is for an auth 78 | // related page i.e. /signin 79 | // to be handled by our own router 80 | if ok := m.router.Match(r, &mux.RouteMatch{}); ok { 81 | m.router.ServeHTTP(w, r) 82 | return 83 | } 84 | 85 | // otherwise we apply the standard middleware 86 | // functionality i.e. annotate the request context 87 | // with the request user (identity) 88 | if s, err := m.runtime.GetSession(r); err == nil { 89 | if m.claimsMiddleware != nil { 90 | if err := m.claimsMiddleware(s.Identity); err != nil { 91 | ctxlogrus.Extract(r.Context()).Error(errors.Wrap(err, "authz middleware failure")) 92 | http.Error(w, "internal server error", http.StatusInternalServerError) 93 | return 94 | } 95 | } 96 | next.ServeHTTP(w, r.WithContext(authsession.SetIdentityCtx(r.Context(), s))) 97 | } else { 98 | next.ServeHTTP(w, r) 99 | } 100 | }) 101 | } 102 | 103 | func RequireAuthentication(next http.Handler) http.Handler { 104 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 105 | if authsession.Authenticated(r.Context()) { 106 | next.ServeHTTP(w, r) 107 | } else { 108 | http.Redirect(w, r, "/signin", http.StatusTemporaryRedirect) 109 | } 110 | }) 111 | } 112 | -------------------------------------------------------------------------------- /proto/devices.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package proto; 4 | 5 | import "google/protobuf/wrappers.proto"; 6 | import "google/protobuf/timestamp.proto"; 7 | import "google/protobuf/empty.proto"; 8 | 9 | service Devices { 10 | rpc AddDevice(AddDeviceReq) returns (Device) {} 11 | rpc ListDevices(ListDevicesReq) returns (ListDevicesRes) {} 12 | rpc DeleteDevice(DeleteDeviceReq) returns (google.protobuf.Empty) {} 13 | 14 | // admin only 15 | rpc ListAllDevices(ListAllDevicesReq) returns (ListAllDevicesRes) {} 16 | } 17 | 18 | message Device { 19 | string name = 1; 20 | string owner = 2; 21 | string public_key = 3; 22 | string address = 4; 23 | google.protobuf.Timestamp created_at = 5; 24 | bool connected = 6; 25 | google.protobuf.Timestamp last_handshake_time = 7; 26 | int64 receive_bytes = 8; 27 | int64 transmit_bytes = 9; 28 | string endpoint = 10; 29 | string owner_name = 11; 30 | string owner_email = 12; 31 | string owner_provider = 13; 32 | } 33 | 34 | message AddDeviceReq { 35 | string name = 1; 36 | string public_key = 2; 37 | } 38 | 39 | message ListDevicesReq { 40 | 41 | } 42 | 43 | message ListDevicesRes { 44 | repeated Device items = 1; 45 | } 46 | 47 | message DeleteDeviceReq { 48 | string name = 1; 49 | 50 | // admin's may delete a device owned 51 | // by someone other than the current user 52 | // if empty, defaults to the current user 53 | google.protobuf.StringValue owner = 2; 54 | } 55 | 56 | message ListAllDevicesReq { 57 | 58 | } 59 | 60 | message ListAllDevicesRes { 61 | repeated Device items = 1; 62 | } 63 | -------------------------------------------------------------------------------- /proto/proto/server.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // source: server.proto 3 | 4 | package proto 5 | 6 | import ( 7 | context "context" 8 | fmt "fmt" 9 | proto "github.com/golang/protobuf/proto" 10 | wrappers "github.com/golang/protobuf/ptypes/wrappers" 11 | grpc "google.golang.org/grpc" 12 | codes "google.golang.org/grpc/codes" 13 | status "google.golang.org/grpc/status" 14 | math "math" 15 | ) 16 | 17 | // Reference imports to suppress errors if they are not otherwise used. 18 | var _ = proto.Marshal 19 | var _ = fmt.Errorf 20 | var _ = math.Inf 21 | 22 | // This is a compile-time assertion to ensure that this generated file 23 | // is compatible with the proto package it is being compiled against. 24 | // A compilation error at this line likely means your copy of the 25 | // proto package needs to be updated. 26 | const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package 27 | 28 | type InfoReq struct { 29 | XXX_NoUnkeyedLiteral struct{} `json:"-"` 30 | XXX_unrecognized []byte `json:"-"` 31 | XXX_sizecache int32 `json:"-"` 32 | } 33 | 34 | func (m *InfoReq) Reset() { *m = InfoReq{} } 35 | func (m *InfoReq) String() string { return proto.CompactTextString(m) } 36 | func (*InfoReq) ProtoMessage() {} 37 | func (*InfoReq) Descriptor() ([]byte, []int) { 38 | return fileDescriptor_ad098daeda4239f7, []int{0} 39 | } 40 | 41 | func (m *InfoReq) XXX_Unmarshal(b []byte) error { 42 | return xxx_messageInfo_InfoReq.Unmarshal(m, b) 43 | } 44 | func (m *InfoReq) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { 45 | return xxx_messageInfo_InfoReq.Marshal(b, m, deterministic) 46 | } 47 | func (m *InfoReq) XXX_Merge(src proto.Message) { 48 | xxx_messageInfo_InfoReq.Merge(m, src) 49 | } 50 | func (m *InfoReq) XXX_Size() int { 51 | return xxx_messageInfo_InfoReq.Size(m) 52 | } 53 | func (m *InfoReq) XXX_DiscardUnknown() { 54 | xxx_messageInfo_InfoReq.DiscardUnknown(m) 55 | } 56 | 57 | var xxx_messageInfo_InfoReq proto.InternalMessageInfo 58 | 59 | type InfoRes struct { 60 | PublicKey string `protobuf:"bytes,1,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` 61 | Host *wrappers.StringValue `protobuf:"bytes,2,opt,name=host,proto3" json:"host,omitempty"` 62 | Port int32 `protobuf:"varint,3,opt,name=port,proto3" json:"port,omitempty"` 63 | HostVpnIp string `protobuf:"bytes,4,opt,name=host_vpn_ip,json=hostVpnIp,proto3" json:"host_vpn_ip,omitempty"` 64 | MetadataEnabled bool `protobuf:"varint,5,opt,name=metadata_enabled,json=metadataEnabled,proto3" json:"metadata_enabled,omitempty"` 65 | IsAdmin bool `protobuf:"varint,6,opt,name=is_admin,json=isAdmin,proto3" json:"is_admin,omitempty"` 66 | AllowedIps string `protobuf:"bytes,7,opt,name=allowed_ips,json=allowedIps,proto3" json:"allowed_ips,omitempty"` 67 | DnsEnabled bool `protobuf:"varint,8,opt,name=dns_enabled,json=dnsEnabled,proto3" json:"dns_enabled,omitempty"` 68 | DnsAddress string `protobuf:"bytes,9,opt,name=dns_address,json=dnsAddress,proto3" json:"dns_address,omitempty"` 69 | XXX_NoUnkeyedLiteral struct{} `json:"-"` 70 | XXX_unrecognized []byte `json:"-"` 71 | XXX_sizecache int32 `json:"-"` 72 | } 73 | 74 | func (m *InfoRes) Reset() { *m = InfoRes{} } 75 | func (m *InfoRes) String() string { return proto.CompactTextString(m) } 76 | func (*InfoRes) ProtoMessage() {} 77 | func (*InfoRes) Descriptor() ([]byte, []int) { 78 | return fileDescriptor_ad098daeda4239f7, []int{1} 79 | } 80 | 81 | func (m *InfoRes) XXX_Unmarshal(b []byte) error { 82 | return xxx_messageInfo_InfoRes.Unmarshal(m, b) 83 | } 84 | func (m *InfoRes) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { 85 | return xxx_messageInfo_InfoRes.Marshal(b, m, deterministic) 86 | } 87 | func (m *InfoRes) XXX_Merge(src proto.Message) { 88 | xxx_messageInfo_InfoRes.Merge(m, src) 89 | } 90 | func (m *InfoRes) XXX_Size() int { 91 | return xxx_messageInfo_InfoRes.Size(m) 92 | } 93 | func (m *InfoRes) XXX_DiscardUnknown() { 94 | xxx_messageInfo_InfoRes.DiscardUnknown(m) 95 | } 96 | 97 | var xxx_messageInfo_InfoRes proto.InternalMessageInfo 98 | 99 | func (m *InfoRes) GetPublicKey() string { 100 | if m != nil { 101 | return m.PublicKey 102 | } 103 | return "" 104 | } 105 | 106 | func (m *InfoRes) GetHost() *wrappers.StringValue { 107 | if m != nil { 108 | return m.Host 109 | } 110 | return nil 111 | } 112 | 113 | func (m *InfoRes) GetPort() int32 { 114 | if m != nil { 115 | return m.Port 116 | } 117 | return 0 118 | } 119 | 120 | func (m *InfoRes) GetHostVpnIp() string { 121 | if m != nil { 122 | return m.HostVpnIp 123 | } 124 | return "" 125 | } 126 | 127 | func (m *InfoRes) GetMetadataEnabled() bool { 128 | if m != nil { 129 | return m.MetadataEnabled 130 | } 131 | return false 132 | } 133 | 134 | func (m *InfoRes) GetIsAdmin() bool { 135 | if m != nil { 136 | return m.IsAdmin 137 | } 138 | return false 139 | } 140 | 141 | func (m *InfoRes) GetAllowedIps() string { 142 | if m != nil { 143 | return m.AllowedIps 144 | } 145 | return "" 146 | } 147 | 148 | func (m *InfoRes) GetDnsEnabled() bool { 149 | if m != nil { 150 | return m.DnsEnabled 151 | } 152 | return false 153 | } 154 | 155 | func (m *InfoRes) GetDnsAddress() string { 156 | if m != nil { 157 | return m.DnsAddress 158 | } 159 | return "" 160 | } 161 | 162 | func init() { 163 | proto.RegisterType((*InfoReq)(nil), "proto.InfoReq") 164 | proto.RegisterType((*InfoRes)(nil), "proto.InfoRes") 165 | } 166 | 167 | func init() { proto.RegisterFile("server.proto", fileDescriptor_ad098daeda4239f7) } 168 | 169 | var fileDescriptor_ad098daeda4239f7 = []byte{ 170 | // 308 bytes of a gzipped FileDescriptorProto 171 | 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x54, 0x90, 0xcf, 0x4e, 0xf3, 0x30, 172 | 0x10, 0xc4, 0xbf, 0xf4, 0x4b, 0xff, 0x64, 0x8b, 0x00, 0xf9, 0x64, 0x2a, 0x28, 0x51, 0x4f, 0xe1, 173 | 0x92, 0xa2, 0xf2, 0x04, 0x3d, 0x70, 0xa8, 0xb8, 0xa5, 0x52, 0xaf, 0x91, 0x83, 0xb7, 0xc5, 0x22, 174 | 0xb5, 0x8d, 0x37, 0x6d, 0xd5, 0x27, 0xe3, 0xf5, 0x50, 0xec, 0x06, 0x89, 0x93, 0x77, 0x7e, 0x9e, 175 | 0xdd, 0x91, 0x06, 0xae, 0x08, 0xdd, 0x11, 0x5d, 0x6e, 0x9d, 0x69, 0x0c, 0xeb, 0xfb, 0x67, 0x32, 176 | 0xdd, 0x19, 0xb3, 0xab, 0x71, 0xee, 0x55, 0x75, 0xd8, 0xce, 0x4f, 0x4e, 0x58, 0x8b, 0x8e, 0x82, 177 | 0x6d, 0x96, 0xc0, 0x70, 0xa5, 0xb7, 0xa6, 0xc0, 0xaf, 0xd9, 0x77, 0xaf, 0x9b, 0x89, 0x3d, 0x00, 178 | 0xd8, 0x43, 0x55, 0xab, 0xf7, 0xf2, 0x13, 0xcf, 0x3c, 0x4a, 0xa3, 0x2c, 0x29, 0x92, 0x40, 0xde, 179 | 0xf0, 0xcc, 0x9e, 0x21, 0xfe, 0x30, 0xd4, 0xf0, 0x5e, 0x1a, 0x65, 0xe3, 0xc5, 0x7d, 0x1e, 0x42, 180 | 0xf2, 0x2e, 0x24, 0x5f, 0x37, 0x4e, 0xe9, 0xdd, 0x46, 0xd4, 0x07, 0x2c, 0xbc, 0x93, 0x31, 0x88, 181 | 0xad, 0x71, 0x0d, 0xff, 0x9f, 0x46, 0x59, 0xbf, 0xf0, 0x33, 0x9b, 0xc2, 0xb8, 0xfd, 0x2b, 0x8f, 182 | 0x56, 0x97, 0xca, 0xf2, 0x38, 0xa4, 0xb4, 0x68, 0x63, 0xf5, 0xca, 0xb2, 0x27, 0xb8, 0xdd, 0x63, 183 | 0x23, 0xa4, 0x68, 0x44, 0x89, 0x5a, 0x54, 0x35, 0x4a, 0xde, 0x4f, 0xa3, 0x6c, 0x54, 0xdc, 0x74, 184 | 0xfc, 0x35, 0x60, 0x76, 0x07, 0x23, 0x45, 0xa5, 0x90, 0x7b, 0xa5, 0xf9, 0xc0, 0x5b, 0x86, 0x8a, 185 | 0x96, 0xad, 0x64, 0x8f, 0x30, 0x16, 0x75, 0x6d, 0x4e, 0x28, 0x4b, 0x65, 0x89, 0x0f, 0x7d, 0x0a, 186 | 0x5c, 0xd0, 0xca, 0x52, 0x6b, 0x90, 0x9a, 0x7e, 0x13, 0x46, 0x7e, 0x1d, 0xa4, 0xa6, 0xee, 0xf8, 187 | 0xc5, 0x20, 0xa4, 0x74, 0x48, 0xc4, 0x93, 0x70, 0x41, 0x6a, 0x5a, 0x06, 0xb2, 0x58, 0xc0, 0x60, 188 | 0xed, 0xbb, 0x67, 0x19, 0xc4, 0x6d, 0x85, 0xec, 0x3a, 0x74, 0x91, 0x5f, 0xba, 0x9d, 0xfc, 0xd5, 189 | 0x34, 0xfb, 0x57, 0x0d, 0x3c, 0x78, 0xf9, 0x09, 0x00, 0x00, 0xff, 0xff, 0xc8, 0x30, 0xb0, 0x21, 190 | 0xb6, 0x01, 0x00, 0x00, 191 | } 192 | 193 | // Reference imports to suppress errors if they are not otherwise used. 194 | var _ context.Context 195 | var _ grpc.ClientConnInterface 196 | 197 | // This is a compile-time assertion to ensure that this generated file 198 | // is compatible with the grpc package it is being compiled against. 199 | const _ = grpc.SupportPackageIsVersion6 200 | 201 | // ServerClient is the client API for Server service. 202 | // 203 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. 204 | type ServerClient interface { 205 | Info(ctx context.Context, in *InfoReq, opts ...grpc.CallOption) (*InfoRes, error) 206 | } 207 | 208 | type serverClient struct { 209 | cc grpc.ClientConnInterface 210 | } 211 | 212 | func NewServerClient(cc grpc.ClientConnInterface) ServerClient { 213 | return &serverClient{cc} 214 | } 215 | 216 | func (c *serverClient) Info(ctx context.Context, in *InfoReq, opts ...grpc.CallOption) (*InfoRes, error) { 217 | out := new(InfoRes) 218 | err := c.cc.Invoke(ctx, "/proto.Server/Info", in, out, opts...) 219 | if err != nil { 220 | return nil, err 221 | } 222 | return out, nil 223 | } 224 | 225 | // ServerServer is the server API for Server service. 226 | type ServerServer interface { 227 | Info(context.Context, *InfoReq) (*InfoRes, error) 228 | } 229 | 230 | // UnimplementedServerServer can be embedded to have forward compatible implementations. 231 | type UnimplementedServerServer struct { 232 | } 233 | 234 | func (*UnimplementedServerServer) Info(ctx context.Context, req *InfoReq) (*InfoRes, error) { 235 | return nil, status.Errorf(codes.Unimplemented, "method Info not implemented") 236 | } 237 | 238 | func RegisterServerServer(s *grpc.Server, srv ServerServer) { 239 | s.RegisterService(&_Server_serviceDesc, srv) 240 | } 241 | 242 | func _Server_Info_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 243 | in := new(InfoReq) 244 | if err := dec(in); err != nil { 245 | return nil, err 246 | } 247 | if interceptor == nil { 248 | return srv.(ServerServer).Info(ctx, in) 249 | } 250 | info := &grpc.UnaryServerInfo{ 251 | Server: srv, 252 | FullMethod: "/proto.Server/Info", 253 | } 254 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 255 | return srv.(ServerServer).Info(ctx, req.(*InfoReq)) 256 | } 257 | return interceptor(ctx, in, info, handler) 258 | } 259 | 260 | var _Server_serviceDesc = grpc.ServiceDesc{ 261 | ServiceName: "proto.Server", 262 | HandlerType: (*ServerServer)(nil), 263 | Methods: []grpc.MethodDesc{ 264 | { 265 | MethodName: "Info", 266 | Handler: _Server_Info_Handler, 267 | }, 268 | }, 269 | Streams: []grpc.StreamDesc{}, 270 | Metadata: "server.proto", 271 | } 272 | -------------------------------------------------------------------------------- /proto/server.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package proto; 4 | 5 | import "google/protobuf/wrappers.proto"; 6 | 7 | service Server { 8 | rpc Info(InfoReq) returns (InfoRes) {} 9 | } 10 | 11 | message InfoReq { 12 | 13 | } 14 | 15 | message InfoRes { 16 | string public_key = 1; 17 | google.protobuf.StringValue host = 2; 18 | int32 port = 3; 19 | string host_vpn_ip = 4; 20 | bool metadata_enabled = 5; 21 | bool is_admin = 6; 22 | string allowed_ips = 7; 23 | bool dns_enabled = 8; 24 | string dns_address = 9; 25 | } 26 | -------------------------------------------------------------------------------- /publish.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import urllib.request 3 | import subprocess 4 | import json 5 | import yaml 6 | from datetime import datetime 7 | 8 | # print the latest tags so we don't have to google our own 9 | # image to check :P 10 | r = urllib.request.urlopen('https://registry.hub.docker.com/v2/repositories/place1/wg-access-server/tags?page_size=10') \ 11 | .read() \ 12 | .decode('utf-8') 13 | tags = json.loads(r).get('results', []) 14 | tags.sort(key=lambda t: datetime.strptime(t.get('last_updated'), '%Y-%m-%dT%H:%M:%S.%f%z')) 15 | tags = [t.get('name') for t in tags] 16 | tags = tags[-4:] 17 | print('current docker tags:') 18 | print('\n'.join([' ' + t for t in tags])) 19 | 20 | # tag the new image 21 | version = input('pick a published tag: ') 22 | docker_tag = f"place1/wg-access-server:{version}" 23 | 24 | # update the helm chart and quickstart manifest 25 | with open('deploy/helm/wg-access-server/Chart.yaml', 'r+') as f: 26 | chart = yaml.load(f) 27 | chart['version'] = version 28 | chart['appVersion'] = version 29 | f.seek(0) 30 | yaml.dump(chart, f, default_flow_style=False) 31 | f.truncate() 32 | with open('deploy/k8s/quickstart.yaml', 'w') as f: 33 | subprocess.run(['helm', 'template', '--name-template', 34 | 'quickstart', 'deploy/helm/wg-access-server/'], stdout=f) 35 | subprocess.run(['helm', 'package', 'deploy/helm/wg-access-server/', 36 | '--destination', 'docs/charts/']) 37 | subprocess.run(['helm', 'repo', 'index', 'docs/', '--url', 38 | 'https://place1.github.io/wg-access-server']) 39 | 40 | # update gh-pages (docs) 41 | subprocess.run(['mkdocs', 'gh-deploy']) 42 | 43 | # commit changes 44 | subprocess.run(['git', 'add', '.']) 45 | subprocess.run(['git', 'commit', '-m', f'{version} - helm & docs update']) 46 | 47 | # push everything 48 | subprocess.run(['git', 'push']) 49 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | mkdocs-material==4.6.3 2 | mkdocs==1.1 3 | pymdown-extensions==6.3 4 | pygments~=2.5.2 5 | markdown-include==0.5.1 6 | -------------------------------------------------------------------------------- /screenshots/connect-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/screenshots/connect-desktop.png -------------------------------------------------------------------------------- /screenshots/connect-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/screenshots/connect-mobile.png -------------------------------------------------------------------------------- /screenshots/devices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/screenshots/devices.png -------------------------------------------------------------------------------- /screenshots/signin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/screenshots/signin.png -------------------------------------------------------------------------------- /scripts/run-postgres.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eou pipefail 3 | 4 | NAME="wg-access-server" 5 | 6 | if [[ ! "$(docker ps -aqf name=$NAME)" ]]; then 7 | docker run \ 8 | -e 'POSTGRES_USER=postgres' \ 9 | -e 'POSTGRES_PASSWORD=example' \ 10 | -e 'POSTGRES_DB=postgres' \ 11 | -p 5432:5432 \ 12 | -d \ 13 | --name "$NAME" \ 14 | postgres:11-alpine 15 | else 16 | docker start "$NAME" 17 | fi 18 | 19 | echo "started postgres: $NAME" 20 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | .build 2 | build 3 | web_modules 4 | node_modules -------------------------------------------------------------------------------- /website/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: "all", 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | }; 8 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | # New Project 2 | 3 | > ✨ Bootstrapped with Create Snowpack App (CSA). 4 | 5 | ## Available Scripts 6 | 7 | ### npm start 8 | 9 | Runs the app in the development mode. 10 | Open http://localhost:3000 to view it in the browser. 11 | 12 | The page will reload if you make edits. 13 | You will also see any lint errors in the console. 14 | 15 | ### npm test 16 | 17 | Launches the test runner in the interactive watch mode. 18 | See the section about running tests for more information. 19 | 20 | ### npm run build 21 | 22 | Builds a static copy of your site to the `build/` folder. 23 | Your app is ready to be deployed! 24 | 25 | **For the best production performance:** Add a build bundler plugin like "@snowpack/plugin-webpack" or "@snowpack/plugin-parcel" to your `snowpack.config.json` config file. 26 | 27 | ### Q: What about Eject? 28 | 29 | No eject needed! Snowpack guarantees zero lock-in, and CSA strives for the same. 30 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "codegen": "node_modules/.bin/grpc-ts-web -o src/sdk ../proto/*.proto", 4 | "start": "react-scripts start", 5 | "build": "react-scripts build", 6 | "test": "react-scripts test", 7 | "eject": "react-scripts eject", 8 | "prettier": "prettier ./src/**/*.{ts,tsx} --write" 9 | }, 10 | "dependencies": { 11 | "@material-ui/core": "^4.11.0", 12 | "@material-ui/icons": "^4.9.1", 13 | "@material-ui/lab": "^4.0.0-alpha.56", 14 | "common-tags": "^1.8.0", 15 | "date-fns": "^2.16.1", 16 | "google-protobuf": "^4.0.0-rc.1", 17 | "mobx": "^5.15.4", 18 | "mobx-react": "^6.2.2", 19 | "mobx-utils": "^5.6.1", 20 | "numeral": "^2.0.6", 21 | "qrcode": "^1.4.4", 22 | "react": "^16.13.1", 23 | "react-dom": "^16.13.1", 24 | "react-router-dom": "^5.2.0", 25 | "react-scripts": "3.4.3", 26 | "tweetnacl-ts": "^1.0.3" 27 | }, 28 | "devDependencies": { 29 | "@types/common-tags": "^1.8.0", 30 | "@types/numeral": "0.0.28", 31 | "@types/qrcode": "^1.3.5", 32 | "@types/react": "^16.9.50", 33 | "@types/react-dom": "^16.9.8", 34 | "@types/react-router-dom": "^5.1.5", 35 | "grpc-ts-web": "0.1.8", 36 | "prettier": "^2.1.2", 37 | "typescript": "^4.0.3" 38 | }, 39 | "proxy": "http://localhost:8000/api", 40 | "browserslist": { 41 | "production": [ 42 | ">0.2%", 43 | "not dead", 44 | "not op_mini all" 45 | ], 46 | "development": [ 47 | "last 1 chrome version", 48 | "last 1 firefox version", 49 | "last 1 safari version" 50 | ] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /website/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/website/public/favicon.ico -------------------------------------------------------------------------------- /website/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 24 | WireGuard Access Portal 25 | 26 | 27 | 28 | 29 |
30 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /website/public/logo-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/website/public/logo-192.png -------------------------------------------------------------------------------- /website/public/logo-310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/website/public/logo-310.png -------------------------------------------------------------------------------- /website/public/roboto/roboto-latin-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/website/public/roboto/roboto-latin-400.woff -------------------------------------------------------------------------------- /website/public/roboto/roboto-latin-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/website/public/roboto/roboto-latin-400.woff2 -------------------------------------------------------------------------------- /website/public/roboto/roboto-latin-500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/website/public/roboto/roboto-latin-500.woff -------------------------------------------------------------------------------- /website/public/roboto/roboto-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Place1/wg-access-server/3a4a15f9c41015990efaa64b5466524a816b6727/website/public/roboto/roboto-latin-500.woff2 -------------------------------------------------------------------------------- /website/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /website/src/Api.ts: -------------------------------------------------------------------------------- 1 | import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb'; 2 | import { Devices } from './sdk/devices_pb'; 3 | import { Server } from './sdk/server_pb'; 4 | 5 | const backend = window.location.origin + '/api'; 6 | 7 | export const grpc = { 8 | server: new Server(backend), 9 | devices: new Devices(backend), 10 | }; 11 | 12 | // https://github.com/SafetyCulture/grpc-web-devtools 13 | const devtools = (window as any).__GRPCWEB_DEVTOOLS__; 14 | if (devtools) { 15 | devtools(Object.values(grpc)); 16 | } 17 | 18 | // utils 19 | export function toDate(timestamp: Timestamp.AsObject): Date { 20 | const t = new Timestamp(); 21 | t.setSeconds(timestamp.seconds); 22 | t.setNanos(timestamp.nanos); 23 | return t.toDate(); 24 | } 25 | 26 | export function dateToTimestamp(date: Date): Timestamp.AsObject { 27 | return { 28 | seconds: Math.round(date.getTime() / 1000), 29 | nanos: 0, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /website/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import CssBaseline from '@material-ui/core/CssBaseline'; 3 | import Box from '@material-ui/core/Box'; 4 | import Navigation from './components/Navigation'; 5 | import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; 6 | import { observer } from 'mobx-react'; 7 | import { grpc } from './Api'; 8 | import { AppState } from './AppState'; 9 | import { YourDevices } from './pages/YourDevices'; 10 | import { AllDevices } from './pages/admin/AllDevices'; 11 | 12 | @observer 13 | export class App extends React.Component { 14 | async componentDidMount() { 15 | AppState.info = await grpc.server.info({}); 16 | } 17 | 18 | render() { 19 | if (!AppState.info) { 20 | return

loading...

; 21 | } 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | 29 | {AppState.info.isAdmin && ( 30 | <> 31 | 32 | 33 | )} 34 | 35 | 36 | 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /website/src/AppState.ts: -------------------------------------------------------------------------------- 1 | import { observable } from 'mobx'; 2 | import { InfoRes } from './sdk/server_pb'; 3 | 4 | class GlobalAppState { 5 | @observable 6 | info?: InfoRes.AsObject; 7 | } 8 | 9 | export const AppState = new GlobalAppState(); 10 | 11 | console.info('see global app state by typing "window.AppState"'); 12 | 13 | Object.assign(window as any, { 14 | get AppState() { 15 | return JSON.parse(JSON.stringify(AppState)); 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /website/src/Cookies.ts: -------------------------------------------------------------------------------- 1 | // adapted from: 2 | // https://stackoverflow.com/questions/5968196/check-cookie-if-cookie-exists 3 | export function getCookie(name: string): string | undefined { 4 | const dc = document.cookie; 5 | const prefix = name + '='; 6 | let begin = dc.indexOf('; ' + prefix); 7 | let end = undefined; 8 | if (begin === -1) { 9 | begin = dc.indexOf(prefix); 10 | if (begin !== 0) { 11 | return undefined; 12 | } 13 | } else { 14 | begin += 2; 15 | end = document.cookie.indexOf(';', begin); 16 | if (end === -1) { 17 | end = dc.length; 18 | } 19 | } 20 | // because unescape has been deprecated, replaced with decodeURI 21 | // return unescape(dc.substring(begin + prefix.length, end)); 22 | return decodeURI(dc.substring(begin + prefix.length, end)); 23 | } 24 | -------------------------------------------------------------------------------- /website/src/Platform.ts: -------------------------------------------------------------------------------- 1 | export enum Platform { 2 | Unknown, 3 | Mac, 4 | Ios, 5 | Windows, 6 | Android, 7 | Linux, 8 | } 9 | 10 | // adapted from 11 | // https://stackoverflow.com/questions/38241480/detect-macos-ios-windows-android-and-linux-os-with-js 12 | export function getPlatform() { 13 | const userAgent = window.navigator.userAgent; 14 | const platform = window.navigator.platform; 15 | const macosPlatforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K']; 16 | const windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE']; 17 | const iosPlatforms = ['iPhone', 'iPad', 'iPod']; 18 | if (macosPlatforms.indexOf(platform) !== -1) { 19 | return Platform.Mac; 20 | } else if (iosPlatforms.indexOf(platform) !== -1) { 21 | return Platform.Ios; 22 | } else if (windowsPlatforms.indexOf(platform) !== -1) { 23 | return Platform.Windows; 24 | } else if (/Android/.test(userAgent)) { 25 | return Platform.Android; 26 | } else if (/Linux/.test(platform)) { 27 | return Platform.Linux; 28 | } 29 | return Platform.Unknown; 30 | } 31 | 32 | export function isMobile() { 33 | return [Platform.Ios, Platform.Android].includes(getPlatform()); 34 | } 35 | -------------------------------------------------------------------------------- /website/src/Util.ts: -------------------------------------------------------------------------------- 1 | import formatDistance from 'date-fns/formatDistance'; 2 | import timestamp_pb from 'google-protobuf/google/protobuf/timestamp_pb'; 3 | import { toDate } from './Api'; 4 | import { fromResource, lazyObservable } from 'mobx-utils'; 5 | import { toast } from './components/Toast'; 6 | 7 | export function sleep(seconds: number) { 8 | return new Promise((resolve) => { 9 | setTimeout(() => { 10 | resolve(); 11 | }, seconds * 1000); 12 | }); 13 | } 14 | 15 | export function lastSeen(timestamp: timestamp_pb.Timestamp.AsObject | undefined): string { 16 | if (timestamp === undefined) { 17 | return 'Never'; 18 | } 19 | return formatDistance(toDate(timestamp), new Date(), { 20 | addSuffix: true, 21 | }); 22 | } 23 | 24 | export function lazy(cb: () => Promise) { 25 | const resource = lazyObservable(async (sink) => { 26 | sink(await cb()); 27 | }); 28 | 29 | return { 30 | get current() { 31 | return resource.current(); 32 | }, 33 | refresh: async () => { 34 | resource.refresh(); 35 | }, 36 | }; 37 | } 38 | 39 | export function autorefresh(seconds: number, cb: () => Promise) { 40 | let running = false; 41 | let sink: ((next: T) => void) | undefined; 42 | 43 | const resource = fromResource( 44 | async (s) => { 45 | sink = s; 46 | running = true; 47 | while (running) { 48 | sink(await cb()); 49 | await sleep(seconds); 50 | } 51 | }, 52 | () => { 53 | running = false; 54 | }, 55 | ); 56 | 57 | return { 58 | get current() { 59 | return resource.current(); 60 | }, 61 | refresh: async () => { 62 | if (sink) { 63 | sink(await cb()); 64 | } 65 | }, 66 | dispose: () => { 67 | resource.dispose(); 68 | }, 69 | }; 70 | } 71 | 72 | export function setClipboard(text: string) { 73 | const textarea = document.createElement('textarea'); 74 | textarea.value = text; 75 | document.body.appendChild(textarea); 76 | textarea.select(); 77 | document.execCommand('copy'); 78 | document.body.removeChild(textarea); 79 | toast({ 80 | intent: 'success', 81 | text: 'Added to clipboard', 82 | }); 83 | } 84 | 85 | export interface DownloadOpts { 86 | filename: string, 87 | content: string, 88 | } 89 | 90 | export function download(opts: DownloadOpts) { 91 | const anchor = document.createElement('a'); 92 | anchor.href = URL.createObjectURL(new File([opts.content], opts.filename)); 93 | anchor.download = opts.filename; 94 | anchor.style.display = 'none'; 95 | document.body.appendChild(anchor); 96 | anchor.click(); 97 | document.body.removeChild(anchor); 98 | } 99 | -------------------------------------------------------------------------------- /website/src/components/AddDevice.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@material-ui/core/Button'; 2 | import Card from '@material-ui/core/Card'; 3 | import CardContent from '@material-ui/core/CardContent'; 4 | import CardHeader from '@material-ui/core/CardHeader'; 5 | import Dialog from '@material-ui/core/Dialog'; 6 | import DialogActions from '@material-ui/core/DialogActions'; 7 | import DialogContent from '@material-ui/core/DialogContent'; 8 | import DialogTitle from '@material-ui/core/DialogTitle'; 9 | import FormControl from '@material-ui/core/FormControl'; 10 | import FormHelperText from '@material-ui/core/FormHelperText'; 11 | import Input from '@material-ui/core/Input'; 12 | import InputLabel from '@material-ui/core/InputLabel'; 13 | import Typography from '@material-ui/core/Typography'; 14 | import AddIcon from '@material-ui/icons/Add'; 15 | import { codeBlock } from 'common-tags'; 16 | import { observable } from 'mobx'; 17 | import { observer } from 'mobx-react'; 18 | import React from 'react'; 19 | import { box_keyPair } from 'tweetnacl-ts'; 20 | import { grpc } from '../Api'; 21 | import { AppState } from '../AppState'; 22 | import { GetConnected } from './GetConnected'; 23 | import { Info } from './Info'; 24 | 25 | interface Props { 26 | onAdd: () => void; 27 | } 28 | 29 | @observer 30 | export class AddDevice extends React.Component { 31 | @observable 32 | dialogOpen = false; 33 | 34 | @observable 35 | error?: string; 36 | 37 | @observable 38 | deviceName = ''; 39 | 40 | @observable 41 | configFile?: string; 42 | 43 | submit = async (event: React.FormEvent) => { 44 | event.preventDefault(); 45 | 46 | const keypair = box_keyPair(); 47 | const publicKey = window.btoa(String.fromCharCode(...(new Uint8Array(keypair.publicKey) as any))); 48 | const privateKey = window.btoa(String.fromCharCode(...(new Uint8Array(keypair.secretKey) as any))); 49 | 50 | try { 51 | const device = await grpc.devices.addDevice({ 52 | name: this.deviceName, 53 | publicKey, 54 | }); 55 | this.props.onAdd(); 56 | 57 | const info = AppState.info!; 58 | const configFile = codeBlock` 59 | [Interface] 60 | PrivateKey = ${privateKey} 61 | Address = ${device.address} 62 | ${info.dnsEnabled && `DNS = ${info.dnsAddress}`} 63 | 64 | [Peer] 65 | PublicKey = ${info.publicKey} 66 | AllowedIPs = ${info.allowedIps} 67 | Endpoint = ${`${info.host?.value || window.location.hostname}:${info.port || '51820'}`} 68 | `; 69 | 70 | this.configFile = configFile; 71 | this.dialogOpen = true; 72 | this.reset(); 73 | } catch (error) { 74 | console.log(error); 75 | // TODO: unwrap grpc error message 76 | this.error = 'failed'; 77 | } 78 | }; 79 | 80 | reset = () => { 81 | this.deviceName = ''; 82 | }; 83 | 84 | render() { 85 | return ( 86 | <> 87 | 88 | 89 | 90 |
91 | 92 | Device Name 93 | (this.deviceName = event.currentTarget.value)} 97 | aria-describedby="device-name-text" 98 | /> 99 | {this.error} 100 | 101 | 102 | 105 | 108 | 109 |
110 |
111 |
112 | 113 | 114 | Get Connected 115 | 116 | 117 | Your VPN connection file is not stored by this portal. 118 | 119 | 120 | If you lose this file you can simply create a new device on this portal to generate a new connection 121 | file. 122 | 123 | 124 | The connection file contains your WireGuard Private Key (i.e. password) and should{' '} 125 | never be shared. 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 136 | 137 | 138 | 139 | ); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /website/src/components/DeviceListItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Card from '@material-ui/core/Card'; 3 | import CardHeader from '@material-ui/core/CardHeader'; 4 | import CardContent from '@material-ui/core/CardContent'; 5 | import Avatar from '@material-ui/core/Avatar'; 6 | import WifiIcon from '@material-ui/icons/Wifi'; 7 | import WifiOffIcon from '@material-ui/icons/WifiOff'; 8 | import MenuItem from '@material-ui/core/MenuItem'; 9 | import numeral from 'numeral'; 10 | import { lastSeen } from '../Util'; 11 | import { AppState } from '../AppState'; 12 | import { IconMenu } from './IconMenu'; 13 | import { PopoverDisplay } from './PopoverDisplay'; 14 | import { Device } from '../sdk/devices_pb'; 15 | import { grpc } from '../Api'; 16 | import { observer } from 'mobx-react'; 17 | 18 | interface Props { 19 | device: Device.AsObject; 20 | onRemove: () => void; 21 | } 22 | 23 | @observer 24 | export class DeviceListItem extends React.Component { 25 | removeDevice = async () => { 26 | try { 27 | await grpc.devices.deleteDevice({ 28 | name: this.props.device.name, 29 | }); 30 | this.props.onRemove(); 31 | } catch { 32 | window.alert('api request failed'); 33 | } 34 | }; 35 | 36 | render() { 37 | const device = this.props.device; 38 | return ( 39 | 40 | 44 | {/* */} 45 | {device.connected ? : } 46 | 47 | } 48 | action={ 49 | 50 | 51 | Delete 52 | 53 | 54 | } 55 | /> 56 | 57 | 58 | 59 | {AppState.info?.metadataEnabled && device.connected && ( 60 | <> 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | )} 75 | {AppState.info?.metadataEnabled && !device.connected && ( 76 | 77 | 78 | 79 | )} 80 | 81 | 82 | 83 | 84 | 85 | 86 | 89 | 90 | 91 |
Endpoint{device.endpoint}
Download{numeral(device.transmitBytes).format('0b')}
Upload{numeral(device.receiveBytes).format('0b')}
Disconnected
Last Seen{lastSeen(device.lastHandshakeTime)}
Public key 87 | {device.publicKey} 88 |
92 |
93 |
94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /website/src/components/Devices.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Grid from '@material-ui/core/Grid'; 3 | import { observable } from 'mobx'; 4 | import { observer } from 'mobx-react'; 5 | import { grpc } from '../Api'; 6 | import { autorefresh } from '../Util'; 7 | import { DeviceListItem } from './DeviceListItem'; 8 | import { AddDevice } from './AddDevice'; 9 | 10 | @observer 11 | export class Devices extends React.Component { 12 | @observable 13 | devices = autorefresh(30, async () => { 14 | return (await grpc.devices.listDevices({})).items; 15 | }); 16 | 17 | componentWillUnmount() { 18 | this.devices.dispose(); 19 | } 20 | 21 | render() { 22 | if (!this.devices.current) { 23 | return

loading...

; 24 | } 25 | return ( 26 | 27 | 28 | 29 | {this.devices.current.map((device, i) => ( 30 | 31 | this.devices.refresh()} /> 32 | 33 | ))} 34 | 35 | 36 | 37 | this.devices.refresh()} /> 38 | 39 | 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /website/src/components/GetConnected.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonGroup } from '@material-ui/core'; 2 | import Button from '@material-ui/core/Button'; 3 | import Grid from '@material-ui/core/Grid'; 4 | import List from '@material-ui/core/List'; 5 | import ListItem from '@material-ui/core/ListItem'; 6 | import ListItemText from '@material-ui/core/ListItemText'; 7 | import Paper from '@material-ui/core/Paper'; 8 | import Tab from '@material-ui/core/Tab'; 9 | import Tabs from '@material-ui/core/Tabs'; 10 | import { GetApp } from '@material-ui/icons'; 11 | import Laptop from '@material-ui/icons/Laptop'; 12 | import PhoneIphone from '@material-ui/icons/PhoneIphone'; 13 | import React from 'react'; 14 | import { isMobile } from '../Platform'; 15 | import { download } from '../Util'; 16 | import { LinuxIcon, MacOSIcon, WindowsIcon } from './Icons'; 17 | import { QRCode } from './QRCode'; 18 | import { TabPanel } from './TabPanel'; 19 | 20 | interface Props { 21 | configFile: string; 22 | } 23 | 24 | export class GetConnected extends React.Component { 25 | state = { 26 | currentTab: isMobile() ? 'mobile' : 'desktop', 27 | }; 28 | 29 | go = (href: string) => { 30 | window.open(href, '__blank', 'noopener noreferrer'); 31 | }; 32 | 33 | download = () => { 34 | download({ 35 | filename: 'WireGuard.conf', 36 | content: this.props.configFile, 37 | }); 38 | }; 39 | 40 | getqr = async () => { 41 | return; 42 | }; 43 | 44 | render() { 45 | return ( 46 | 47 | 48 | this.setState({ currentTab })} 51 | indicatorColor="primary" 52 | textColor="primary" 53 | variant="fullWidth" 54 | > 55 | } value="desktop" /> 56 | } value="mobile" /> 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 69 | 74 | 77 | 78 | 79 | 80 | 81 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | ); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /website/src/components/IconMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import IconButton from '@material-ui/core/IconButton'; 3 | import MoreVertIcon from '@material-ui/icons/MoreVert'; 4 | import Menu from '@material-ui/core/Menu'; 5 | 6 | interface Props { 7 | children: React.ReactNode; 8 | } 9 | 10 | export function IconMenu(props: Props) { 11 | const [anchorEl, setAnchorEl] = React.useState(null); 12 | 13 | const handleClick = (event: React.MouseEvent) => { 14 | setAnchorEl(event.currentTarget); 15 | }; 16 | 17 | const handleClose = () => { 18 | setAnchorEl(null); 19 | }; 20 | 21 | return ( 22 |
23 | 24 | 25 | 26 | 27 |
{props.children}
28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /website/src/components/Info.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { observable } from 'mobx'; 3 | import { observer } from 'mobx-react'; 4 | import Popover from '@material-ui/core/Popover'; 5 | import IconButton from '@material-ui/core/IconButton'; 6 | import InfoIcon from '@material-ui/icons/Info'; 7 | 8 | interface Props { 9 | children: React.ReactNode; 10 | } 11 | 12 | @observer 13 | export class Info extends React.Component { 14 | @observable 15 | anchor?: HTMLElement; 16 | 17 | render() { 18 | return ( 19 | <> 20 | (this.anchor = event.currentTarget)}> 21 | 22 | 23 | (this.anchor = undefined)} 27 | anchorOrigin={{ 28 | vertical: 'bottom', 29 | horizontal: 'center', 30 | }} 31 | transformOrigin={{ 32 | vertical: 'top', 33 | horizontal: 'center', 34 | }} 35 | > 36 |
{this.props.children}
37 |
38 | 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /website/src/components/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import { getCookie } from '../Cookies'; 4 | import { AppState } from '../AppState'; 5 | import { NavLink } from 'react-router-dom'; 6 | import AppBar from '@material-ui/core/AppBar'; 7 | import Toolbar from '@material-ui/core/Toolbar'; 8 | import Typography from '@material-ui/core/Typography'; 9 | import Link from '@material-ui/core/Link'; 10 | import Button from '@material-ui/core/Button'; 11 | import Chip from '@material-ui/core/Chip'; 12 | 13 | const useStyles = makeStyles((theme) => ({ 14 | title: { 15 | flexGrow: 1, 16 | }, 17 | })); 18 | 19 | export default function Navigation() { 20 | const classes = useStyles(); 21 | const hasAuthCookie = !!getCookie('auth-session'); 22 | return ( 23 | 24 | 25 | 26 | 27 | Welcome 28 | 29 | {AppState.info?.isAdmin && ( 30 | 37 | )} 38 | 39 | 40 | {AppState.info?.isAdmin && ( 41 | 42 | 43 | 44 | )} 45 | 46 | {hasAuthCookie && ( 47 | 48 | 49 | 50 | )} 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /website/src/components/PopoverDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import Popover from '@material-ui/core/Popover'; 4 | 5 | interface Props { 6 | label: string; 7 | children: React.ReactNode; 8 | } 9 | 10 | export class PopoverDisplay extends React.Component { 11 | state = { 12 | anchorEl: undefined as any, 13 | }; 14 | 15 | render() { 16 | return ( 17 | 18 | 27 | this.setState({ anchorEl: undefined })} 31 | anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} 32 | transformOrigin={{ vertical: 'top', horizontal: 'center' }} 33 | > 34 |
{this.props.children}
35 |
36 |
37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /website/src/components/Present.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@material-ui/core/Button'; 2 | import Dialog from '@material-ui/core/Dialog'; 3 | import DialogActions from '@material-ui/core/DialogActions'; 4 | import DialogContent from '@material-ui/core/DialogContent'; 5 | import DialogContentText from '@material-ui/core/DialogContentText'; 6 | import DialogTitle from '@material-ui/core/DialogTitle'; 7 | import React from 'react'; 8 | import { render, unmountComponentAtNode } from 'react-dom'; 9 | 10 | export function present(content: (close: (result: T) => void) => React.ReactNode) { 11 | const root = document.createElement('div'); 12 | document.body.appendChild(root); 13 | return new Promise((resolve) => { 14 | const close = (result: T) => { 15 | unmountComponentAtNode(root); 16 | resolve(result); 17 | }; 18 | render(<>{content(close)}, root); 19 | }); 20 | } 21 | 22 | export function confirm(msg: string): Promise { 23 | return present((close) => ( 24 | close(false)}> 25 | Confirm 26 | 27 | {msg} 28 | 29 | 30 | 33 | 36 | 37 | 38 | )); 39 | } 40 | -------------------------------------------------------------------------------- /website/src/components/QRCode.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import qrcode from 'qrcode'; 3 | import { lazy } from '../Util'; 4 | import { CircularProgress } from '@material-ui/core'; 5 | 6 | interface Props { 7 | content: string; 8 | } 9 | 10 | export class QRCode extends React.Component { 11 | uri = lazy(async () => { 12 | return await qrcode.toDataURL(this.props.content); 13 | }); 14 | 15 | render() { 16 | if (!this.uri.current) { 17 | return ; 18 | } 19 | return WireGuard QR code; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /website/src/components/TabPanel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface Props { 4 | for: any; 5 | value: any; 6 | } 7 | 8 | export class TabPanel extends React.Component { 9 | render() { 10 | return ( 11 | 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /website/src/components/Toast.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, unmountComponentAtNode } from 'react-dom'; 3 | import Snackbar from '@material-ui/core/Snackbar'; 4 | import Alert from '@material-ui/lab/Alert'; 5 | 6 | interface Props { 7 | intent: 'success' | 'info' | 'warning' | 'error'; 8 | text: string; 9 | } 10 | 11 | export function toast(props: Props) { 12 | const root = document.createElement('div'); 13 | document.body.appendChild(root); 14 | 15 | const onClose = () => { 16 | unmountComponentAtNode(root); 17 | document.body.removeChild(root); 18 | }; 19 | 20 | render( 21 | 27 | 28 | {props.text} 29 | 30 | , 31 | root, 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /website/src/index.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Roboto'; 3 | font-style: normal; 4 | font-display: swap; 5 | font-weight: 400; 6 | src: 7 | local('Roboto'), 8 | local('Roboto-Regular'), 9 | url('/roboto/roboto-latin-400.woff2') format('woff2'), 10 | url('/roboto/roboto-latin-400.woff') format('woff');; 11 | } 12 | 13 | @font-face { 14 | font-family: 'Roboto'; 15 | font-style: normal; 16 | font-display: swap; 17 | font-weight: 500; 18 | src: 19 | local('Roboto Medium '), 20 | local('Roboto-Medium'), 21 | url('/roboto/roboto-latin-500.woff2') format('woff2'), 22 | url('/roboto/roboto-latin-500.woff') format('woff'); 23 | } 24 | 25 | body { 26 | margin: 0; 27 | -webkit-font-smoothing: antialiased; 28 | -moz-osx-font-smoothing: grayscale; 29 | } 30 | -------------------------------------------------------------------------------- /website/src/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import { App } from './App'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root'), 11 | ); 12 | -------------------------------------------------------------------------------- /website/src/pages/YourDevices.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Devices } from '../components/Devices'; 3 | 4 | export class YourDevices extends React.Component { 5 | render() { 6 | return ; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /website/src/pages/admin/AllDevices.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Table from '@material-ui/core/Table'; 3 | import TableBody from '@material-ui/core/TableBody'; 4 | import TableCell from '@material-ui/core/TableCell'; 5 | import TableContainer from '@material-ui/core/TableContainer'; 6 | import TableHead from '@material-ui/core/TableHead'; 7 | import TableRow from '@material-ui/core/TableRow'; 8 | import Button from '@material-ui/core/Button'; 9 | import Typography from '@material-ui/core/Typography'; 10 | import { observer } from 'mobx-react'; 11 | import { grpc } from '../../Api'; 12 | import { lastSeen, lazy } from '../../Util'; 13 | import { Device } from '../../sdk/devices_pb'; 14 | import { confirm } from '../../components/Present'; 15 | import { AppState } from '../../AppState'; 16 | 17 | @observer 18 | export class AllDevices extends React.Component { 19 | devices = lazy(async () => { 20 | const res = await grpc.devices.listAllDevices({}); 21 | return res.items; 22 | }); 23 | 24 | deleteDevice = async (device: Device.AsObject) => { 25 | if (await confirm('Are you sure?')) { 26 | await grpc.devices.deleteDevice({ 27 | name: device.name, 28 | owner: { value: device.owner }, 29 | }); 30 | await this.devices.refresh(); 31 | } 32 | }; 33 | 34 | render() { 35 | if (!this.devices.current) { 36 | return

loading...

; 37 | } 38 | 39 | const rows = this.devices.current; 40 | 41 | // show the provider column 42 | // when there is more than 1 provider in use 43 | // i.e. not all devices are from the same auth provider. 44 | const showProviderCol = rows.length >= 2 && rows.some((r) => r.ownerProvider !== rows[0].ownerProvider); 45 | 46 | return ( 47 |
48 | 49 | Devices 50 | 51 | 52 | 53 | 54 | 55 | Owner 56 | {showProviderCol && Auth Provider} 57 | Device 58 | Connected 59 | Last Seen 60 | Actions 61 | 62 | 63 | 64 | {rows.map((row, i) => ( 65 | 66 | 67 | {row.ownerName || row.ownerEmail || row.owner} 68 | 69 | {showProviderCol && {row.ownerProvider}} 70 | {row.name} 71 | {row.connected ? 'yes' : 'no'} 72 | {lastSeen(row.lastHandshakeTime)} 73 | 74 | 77 | 78 | 79 | ))} 80 | 81 |
82 |
83 | 84 | Server Info 85 | 86 | 87 |
88 |           {JSON.stringify(AppState.info, null, 2)}
89 | 
90 |           
91 |
92 |
93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /website/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /website/src/sdk/server_pb.ts: -------------------------------------------------------------------------------- 1 | // Generated by protoc-gen-grpc-ts-web. DO NOT EDIT! 2 | /* eslint-disable */ 3 | /* tslint:disable */ 4 | 5 | import * as jspb from 'google-protobuf'; 6 | import * as grpcWeb from 'grpc-web'; 7 | 8 | import * as googleProtobufWrappers from 'google-protobuf/google/protobuf/wrappers_pb'; 9 | 10 | export class Server { 11 | 12 | private client_ = new grpcWeb.GrpcWebClientBase({ 13 | format: 'text', 14 | }); 15 | 16 | private methodInfoInfo = new grpcWeb.MethodDescriptor( 17 | "Info", 18 | null, 19 | InfoReq, 20 | InfoRes, 21 | (req: InfoReq) => req.serializeBinary(), 22 | InfoRes.deserializeBinary 23 | ); 24 | 25 | constructor( 26 | private hostname: string, 27 | private defaultMetadata?: () => grpcWeb.Metadata, 28 | ) { } 29 | 30 | info(req: InfoReq.AsObject, metadata?: grpcWeb.Metadata): Promise { 31 | return new Promise((resolve, reject) => { 32 | const message = InfoReqFromObject(req); 33 | this.client_.rpcCall( 34 | this.hostname + '/proto.Server/Info', 35 | message, 36 | Object.assign({}, this.defaultMetadata ? this.defaultMetadata() : {}, metadata), 37 | this.methodInfoInfo, 38 | (err: grpcWeb.Error, res: InfoRes) => { 39 | if (err) { 40 | reject(err); 41 | } else { 42 | resolve(res.toObject()); 43 | } 44 | }, 45 | ); 46 | }); 47 | } 48 | 49 | } 50 | 51 | 52 | 53 | 54 | export declare namespace InfoReq { 55 | export type AsObject = { 56 | } 57 | } 58 | 59 | export class InfoReq extends jspb.Message { 60 | 61 | private static repeatedFields_ = [ 62 | 63 | ]; 64 | 65 | constructor(data?: jspb.Message.MessageArray) { 66 | super(); 67 | jspb.Message.initialize(this, data || [], 0, -1, InfoReq.repeatedFields_, null); 68 | } 69 | 70 | 71 | serializeBinary(): Uint8Array { 72 | const writer = new jspb.BinaryWriter(); 73 | InfoReq.serializeBinaryToWriter(this, writer); 74 | return writer.getResultBuffer(); 75 | } 76 | 77 | toObject(): InfoReq.AsObject { 78 | let f: any; 79 | return { 80 | }; 81 | } 82 | 83 | static serializeBinaryToWriter(message: InfoReq, writer: jspb.BinaryWriter): void { 84 | } 85 | 86 | static deserializeBinary(bytes: Uint8Array): InfoReq { 87 | var reader = new jspb.BinaryReader(bytes); 88 | var message = new InfoReq(); 89 | return InfoReq.deserializeBinaryFromReader(message, reader); 90 | } 91 | 92 | static deserializeBinaryFromReader(message: InfoReq, reader: jspb.BinaryReader): InfoReq { 93 | while (reader.nextField()) { 94 | if (reader.isEndGroup()) { 95 | break; 96 | } 97 | const field = reader.getFieldNumber(); 98 | switch (field) { 99 | default: 100 | reader.skipField(); 101 | break; 102 | } 103 | } 104 | return message; 105 | } 106 | 107 | } 108 | export declare namespace InfoRes { 109 | export type AsObject = { 110 | publicKey: string, 111 | host?: googleProtobufWrappers.StringValue.AsObject, 112 | port: number, 113 | hostVpnIp: string, 114 | metadataEnabled: boolean, 115 | isAdmin: boolean, 116 | allowedIps: string, 117 | dnsEnabled: boolean, 118 | dnsAddress: string, 119 | } 120 | } 121 | 122 | export class InfoRes extends jspb.Message { 123 | 124 | private static repeatedFields_ = [ 125 | 126 | ]; 127 | 128 | constructor(data?: jspb.Message.MessageArray) { 129 | super(); 130 | jspb.Message.initialize(this, data || [], 0, -1, InfoRes.repeatedFields_, null); 131 | } 132 | 133 | 134 | getPublicKey(): string { 135 | return jspb.Message.getFieldWithDefault(this, 1, ""); 136 | } 137 | 138 | setPublicKey(value: string): void { 139 | (jspb.Message as any).setProto3StringField(this, 1, value); 140 | } 141 | 142 | getHost(): googleProtobufWrappers.StringValue { 143 | return jspb.Message.getWrapperField(this, googleProtobufWrappers.StringValue, 2); 144 | } 145 | 146 | setHost(value?: googleProtobufWrappers.StringValue): void { 147 | (jspb.Message as any).setWrapperField(this, 2, value); 148 | } 149 | 150 | getPort(): number { 151 | return jspb.Message.getFieldWithDefault(this, 3, 0); 152 | } 153 | 154 | setPort(value: number): void { 155 | (jspb.Message as any).setProto3IntField(this, 3, value); 156 | } 157 | 158 | getHostVpnIp(): string { 159 | return jspb.Message.getFieldWithDefault(this, 4, ""); 160 | } 161 | 162 | setHostVpnIp(value: string): void { 163 | (jspb.Message as any).setProto3StringField(this, 4, value); 164 | } 165 | 166 | getMetadataEnabled(): boolean { 167 | return jspb.Message.getFieldWithDefault(this, 5, false); 168 | } 169 | 170 | setMetadataEnabled(value: boolean): void { 171 | (jspb.Message as any).setProto3BooleanField(this, 5, value); 172 | } 173 | 174 | getIsAdmin(): boolean { 175 | return jspb.Message.getFieldWithDefault(this, 6, false); 176 | } 177 | 178 | setIsAdmin(value: boolean): void { 179 | (jspb.Message as any).setProto3BooleanField(this, 6, value); 180 | } 181 | 182 | getAllowedIps(): string { 183 | return jspb.Message.getFieldWithDefault(this, 7, ""); 184 | } 185 | 186 | setAllowedIps(value: string): void { 187 | (jspb.Message as any).setProto3StringField(this, 7, value); 188 | } 189 | 190 | getDnsEnabled(): boolean { 191 | return jspb.Message.getFieldWithDefault(this, 8, false); 192 | } 193 | 194 | setDnsEnabled(value: boolean): void { 195 | (jspb.Message as any).setProto3BooleanField(this, 8, value); 196 | } 197 | 198 | getDnsAddress(): string { 199 | return jspb.Message.getFieldWithDefault(this, 9, ""); 200 | } 201 | 202 | setDnsAddress(value: string): void { 203 | (jspb.Message as any).setProto3StringField(this, 9, value); 204 | } 205 | 206 | serializeBinary(): Uint8Array { 207 | const writer = new jspb.BinaryWriter(); 208 | InfoRes.serializeBinaryToWriter(this, writer); 209 | return writer.getResultBuffer(); 210 | } 211 | 212 | toObject(): InfoRes.AsObject { 213 | let f: any; 214 | return {publicKey: this.getPublicKey(), 215 | host: (f = this.getHost()) && f.toObject(), 216 | port: this.getPort(), 217 | hostVpnIp: this.getHostVpnIp(), 218 | metadataEnabled: this.getMetadataEnabled(), 219 | isAdmin: this.getIsAdmin(), 220 | allowedIps: this.getAllowedIps(), 221 | dnsEnabled: this.getDnsEnabled(), 222 | dnsAddress: this.getDnsAddress(), 223 | 224 | }; 225 | } 226 | 227 | static serializeBinaryToWriter(message: InfoRes, writer: jspb.BinaryWriter): void { 228 | const field1 = message.getPublicKey(); 229 | if (field1.length > 0) { 230 | writer.writeString(1, field1); 231 | } 232 | const field2 = message.getHost(); 233 | if (field2 != null) { 234 | writer.writeMessage(2, field2, googleProtobufWrappers.StringValue.serializeBinaryToWriter); 235 | } 236 | const field3 = message.getPort(); 237 | if (field3 != 0) { 238 | writer.writeInt32(3, field3); 239 | } 240 | const field4 = message.getHostVpnIp(); 241 | if (field4.length > 0) { 242 | writer.writeString(4, field4); 243 | } 244 | const field5 = message.getMetadataEnabled(); 245 | if (field5 != false) { 246 | writer.writeBool(5, field5); 247 | } 248 | const field6 = message.getIsAdmin(); 249 | if (field6 != false) { 250 | writer.writeBool(6, field6); 251 | } 252 | const field7 = message.getAllowedIps(); 253 | if (field7.length > 0) { 254 | writer.writeString(7, field7); 255 | } 256 | const field8 = message.getDnsEnabled(); 257 | if (field8 != false) { 258 | writer.writeBool(8, field8); 259 | } 260 | const field9 = message.getDnsAddress(); 261 | if (field9.length > 0) { 262 | writer.writeString(9, field9); 263 | } 264 | } 265 | 266 | static deserializeBinary(bytes: Uint8Array): InfoRes { 267 | var reader = new jspb.BinaryReader(bytes); 268 | var message = new InfoRes(); 269 | return InfoRes.deserializeBinaryFromReader(message, reader); 270 | } 271 | 272 | static deserializeBinaryFromReader(message: InfoRes, reader: jspb.BinaryReader): InfoRes { 273 | while (reader.nextField()) { 274 | if (reader.isEndGroup()) { 275 | break; 276 | } 277 | const field = reader.getFieldNumber(); 278 | switch (field) { 279 | case 1: 280 | const field1 = reader.readString() 281 | message.setPublicKey(field1); 282 | break; 283 | case 2: 284 | const field2 = new googleProtobufWrappers.StringValue(); 285 | reader.readMessage(field2, googleProtobufWrappers.StringValue.deserializeBinaryFromReader); 286 | message.setHost(field2); 287 | break; 288 | case 3: 289 | const field3 = reader.readInt32() 290 | message.setPort(field3); 291 | break; 292 | case 4: 293 | const field4 = reader.readString() 294 | message.setHostVpnIp(field4); 295 | break; 296 | case 5: 297 | const field5 = reader.readBool() 298 | message.setMetadataEnabled(field5); 299 | break; 300 | case 6: 301 | const field6 = reader.readBool() 302 | message.setIsAdmin(field6); 303 | break; 304 | case 7: 305 | const field7 = reader.readString() 306 | message.setAllowedIps(field7); 307 | break; 308 | case 8: 309 | const field8 = reader.readBool() 310 | message.setDnsEnabled(field8); 311 | break; 312 | case 9: 313 | const field9 = reader.readString() 314 | message.setDnsAddress(field9); 315 | break; 316 | default: 317 | reader.skipField(); 318 | break; 319 | } 320 | } 321 | return message; 322 | } 323 | 324 | } 325 | 326 | 327 | function InfoReqFromObject(obj: InfoReq.AsObject | undefined): InfoReq | undefined { 328 | if (obj === undefined) { 329 | return undefined; 330 | } 331 | const message = new InfoReq(); 332 | return message; 333 | } 334 | 335 | function InfoResFromObject(obj: InfoRes.AsObject | undefined): InfoRes | undefined { 336 | if (obj === undefined) { 337 | return undefined; 338 | } 339 | const message = new InfoRes(); 340 | message.setPublicKey(obj.publicKey); 341 | message.setHost(StringValueFromObject(obj.host)); 342 | message.setPort(obj.port); 343 | message.setHostVpnIp(obj.hostVpnIp); 344 | message.setMetadataEnabled(obj.metadataEnabled); 345 | message.setIsAdmin(obj.isAdmin); 346 | message.setAllowedIps(obj.allowedIps); 347 | message.setDnsEnabled(obj.dnsEnabled); 348 | message.setDnsAddress(obj.dnsAddress); 349 | return message; 350 | } 351 | 352 | function StringValueFromObject(obj: googleProtobufWrappers.StringValue.AsObject | undefined): googleProtobufWrappers.StringValue | undefined { 353 | if (obj === undefined) { 354 | return undefined; 355 | } 356 | const message = new googleProtobufWrappers.StringValue(); 357 | message.setValue(obj.value); 358 | return message; 359 | } 360 | 361 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react", 21 | "experimentalDecorators": true 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /website/types/import.d.ts: -------------------------------------------------------------------------------- 1 | // ESM-HMR Interface: `import.meta.hot` 2 | 3 | interface ImportMeta { 4 | // TODO: Import the exact .d.ts files from "esm-hmr" 5 | // https://github.com/pikapkg/esm-hmr 6 | hot: any; 7 | env: Record; 8 | } 9 | -------------------------------------------------------------------------------- /website/types/static.d.ts: -------------------------------------------------------------------------------- 1 | /* Use this file to declare any custom file extensions for importing */ 2 | /* Use this folder to also add/extend a package d.ts file, if needed. */ 3 | 4 | declare module '*.css'; 5 | declare module '*.svg' { 6 | const ref: string; 7 | export default ref; 8 | } 9 | declare module '*.bmp' { 10 | const ref: string; 11 | export default ref; 12 | } 13 | declare module '*.gif' { 14 | const ref: string; 15 | export default ref; 16 | } 17 | declare module '*.jpg' { 18 | const ref: string; 19 | export default ref; 20 | } 21 | declare module '*.jpeg' { 22 | const ref: string; 23 | export default ref; 24 | } 25 | declare module '*.png' { 26 | const ref: string; 27 | export default ref; 28 | } 29 | declare module '*.webp' { 30 | const ref: string; 31 | export default ref; 32 | } 33 | --------------------------------------------------------------------------------