From 304a6526cc327d6e03c1cc69442797720ff410d0 Mon Sep 17 00:00:00 2001 From: PLACE Date: Sat, 21 Mar 2020 21:45:24 +1100 Subject: [PATCH] basic admin feature, network isolation, docs, helm, k8s, docs (#15) * wip * wip * wip * wip * wip * wip * helm update * wip * wip * wip * secret for private key * updated publish script * wip * refactored to mobx, added list all devices for admins * dockerfile fix * fixed basic auth * healthcheck fix * removed healthcheck because it caused issues with traefik * helm chart updates * wip * wip * super basic healthcheck endpoint * wip * added changelog, updated docs --- .gitignore | 1 + CHANGELOG.md | 41 +++ Dockerfile | 5 +- README.md | 196 ++----------- TODO.md | 27 ++ deploy/helm/wg-access-server/README.md | 70 +++++ .../templates/deployment.yaml | 22 ++ .../wg-access-server/templates/ingress.yaml | 6 +- .../wg-access-server/templates/secret.yaml | 18 ++ .../wg-access-server/templates/service.yaml | 2 +- deploy/helm/wg-access-server/values.yaml | 15 +- docs/configuration.md | 141 +++++++++ docs/deployment/1-docker.md | 14 + docs/deployment/2-docker-compose.md | 11 + docs/deployment/3-kubernetes.md | 3 + docs/index.md | 3 + go.mod | 7 +- go.sum | 7 +- internal/config/config.go | 84 ++++-- internal/devices/devices.go | 19 +- internal/devices/metadata.go | 46 +++ internal/network/network.go | 174 +++++++++++ internal/services/converters.go | 7 +- internal/services/device_service.go | 53 +++- internal/services/network.go | 88 ------ internal/services/server_service.go | 16 +- internal/storage/contracts.go | 12 + internal/storage/disk.go | 2 +- main.go | 55 ++-- mkdocs.yml | 32 +++ .../authnz}/authconfig/authconfig.go | 6 +- .../auth => pkg/authnz}/authconfig/basic.go | 10 +- .../auth => pkg/authnz}/authconfig/gitlab.go | 2 +- .../auth => pkg/authnz}/authconfig/oidc.go | 6 +- .../authnz}/authruntime/runtime.go | 2 +- pkg/authnz/authsession/claims.go | 35 +++ pkg/authnz/authsession/identity.go | 6 + pkg/authnz/authsession/middleware.go | 3 + .../authnz}/authsession/session.go | 4 - .../authnz}/authtemplates/login.go | 2 +- .../auth => pkg/authnz}/authutil/random.go | 0 {internal/auth => pkg/authnz}/router.go | 29 +- proto/devices.proto | 16 ++ proto/proto/devices.pb.go | 203 +++++++++++-- proto/proto/server.pb.go | 49 +++- proto/server.proto | 2 + publish.py | 43 +-- requirements-docs.txt | 5 + screenshots/devices.png | Bin 57789 -> 89227 bytes website/package-lock.json | 169 +++++++++-- website/package.json | 12 +- website/src/Api.ts | 17 +- website/src/AppState.ts | 19 ++ website/src/Store.ts | 6 - website/src/Util.ts | 46 +++ website/src/components/AddDevice.tsx | 168 +++++------ website/src/components/DeviceListItem.tsx | 72 +++-- website/src/components/Devices.tsx | 45 +-- website/src/components/Navigation.tsx | 21 +- website/src/components/PopoverDisplay.tsx | 2 +- website/src/index.tsx | 56 ++-- website/src/pages/YourDevices.tsx | 10 + website/src/pages/admin/AllDevices.tsx | 56 ++++ website/src/sdk/devices_pb.ts | 271 ++++++++++++++++++ website/src/sdk/server_pb.ts | 38 +++ website/tsconfig.json | 3 +- 66 files changed, 2022 insertions(+), 589 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 TODO.md create mode 100644 deploy/helm/wg-access-server/README.md create mode 100644 deploy/helm/wg-access-server/templates/secret.yaml create mode 100644 docs/configuration.md create mode 100644 docs/deployment/1-docker.md create mode 100644 docs/deployment/2-docker-compose.md create mode 100644 docs/deployment/3-kubernetes.md create mode 100644 docs/index.md create mode 100644 internal/devices/metadata.go create mode 100644 internal/network/network.go delete mode 100644 internal/services/network.go create mode 100644 mkdocs.yml rename {internal/auth => pkg/authnz}/authconfig/authconfig.go (76%) rename {internal/auth => pkg/authnz}/authconfig/basic.go (88%) rename {internal/auth => pkg/authnz}/authconfig/gitlab.go (90%) rename {internal/auth => pkg/authnz}/authconfig/oidc.go (94%) rename {internal/auth => pkg/authnz}/authruntime/runtime.go (94%) create mode 100644 pkg/authnz/authsession/claims.go create mode 100644 pkg/authnz/authsession/identity.go create mode 100644 pkg/authnz/authsession/middleware.go rename {internal/auth => pkg/authnz}/authsession/session.go (97%) rename {internal/auth => pkg/authnz}/authtemplates/login.go (97%) rename {internal/auth => pkg/authnz}/authutil/random.go (100%) rename {internal/auth => pkg/authnz}/router.go (64%) create mode 100644 requirements-docs.txt create mode 100644 website/src/AppState.ts delete mode 100644 website/src/Store.ts create mode 100644 website/src/Util.ts create mode 100644 website/src/pages/YourDevices.tsx create mode 100644 website/src/pages/admin/AllDevices.tsx diff --git a/.gitignore b/.gitignore index dbd10f7..a9326e1 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ website/coverage website/build # misc +.env website/.DS_Store website/.env.local website/.env.development.local diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..46d85f0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] + +### Added + +- Added support for an admin account. An admin can see all devices registered + with the server. +- Added support for networking isolation modes. You can now allow/deny VPN LAN, + Server LAN and internet traffic. Selective network CIDRs can be white listed. +- New docker compose example ([@antoniebou13](https://github.com/Place1/wg-access-server/pull/13)) +- Added a helm chart +- Added a basic kubernetes quickstart.yaml manifest (based on helm template) +- Added a documentation site based on [mkdocs](https://www.mkdocs.org/). Hosted + on github pages (still a wip!) + +## [0.0.9] + +### Changed + +- Some UI/UX improvements + +## [0.0.8] + +### Added + +- Added an embedded DNS proxy + +### Changed + +- Completely re-implemented the auth subsystem to avoid trying to integrate + with Dex. OIDC, Gitlab and Basic auth are supported. + +## [0.0.0] -> [0.0.7] + +MVP :) diff --git a/Dockerfile b/Dockerfile index c531504..4ce92fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,8 @@ RUN go mod download COPY ./proto/ ./proto/ COPY ./main.go ./main.go -COPY ./internal/ ./internal +COPY ./internal/ ./internal/ +COPY ./pkg/ ./pkg/ RUN go build -o server @@ -51,7 +52,5 @@ ENV STORAGE_DIRECTORY="/data" COPY --from=server /code/server /server COPY --from=website /code/build /website/build -HEALTHCHECK --interval=5m --timeout=3s CMD curl -f http://localhost:8000/ || exit 1 - # Command to start the server CMD /server diff --git a/README.md b/README.md index a9fc5b6..71f947c 100644 --- a/README.md +++ b/README.md @@ -2,42 +2,21 @@ ## What is this -This project aims to create a simple VPN solution for developers, -homelab enthusiasts and anyone else feeling adventurous. - -This project offers a single docker container that provides a WireGuard -VPN server and device management web ui. - -You can use wg-access-server's web ui to connect your Linux/Mac/Windows/iOS/Android -devices. The server automatically configure iptables rules to ensure that client VPN traffic -can access the internet via the server's default gateway or configured gateway NIC. -Currently, all VPN clients can route traffic to each other. VPN client isolation via -iptables can be added if there's demand for it. - -wg-access-server embeds a user-space wireguard implementation to simplify -deployment - you just run the container, no kernel setup required. +wg-access-server is a single binary that provides a WireGuard +VPN server and device management web ui. We support user authentication, +_1 click_ device registration that works with Mac, Linux, Windows, Ios and Android +including QR codes. You can configure different network isolation modes for +better control and more. -Support for the kernal's wireguard implementation could be added if -there's demand for it. - -Currently wg-access-server requires `NET_ADMIN` and access to `/dev/net/tun` to create -a user-space virtual network interface ([wikipedia](https://en.wikipedia.org/wiki/TUN/TAP)). +This project aims to deliver a simple VPN solution for developers, +homelab enthusiasts and anyone else feeling adventurous. -wg-access-server also configures iptables and network routes within it's own network -namespace to route client VPN traffic. The container doesn't require host networking -but it can be enabled if you want client VPN traffic to be able to access the host's -network as well. +wg-access-server is a functional but young project. Contributes are welcome! ## Running with Docker Here's a quick command to run the server to try it out. -If you open your browser using your LAN ip address you can even connect your -phone to try it out: for example, i'll open my browser at http://192.168.0.XX:8000 -using the local LAN IP address. - -You can connect to the web server on the local machine browser at http://localhost:8000 - ```bash docker run \ -it \ @@ -45,171 +24,44 @@ docker run \ --cap-add NET_ADMIN \ --device /dev/net/tun:/dev/net/tun \ -v wg-access-server-data:/data \ + -e "WIREGUARD_PRIVATE_KEY=$(wg genkey)" \ -p 8000:8000/tcp \ -p 51820:51820/udp \ place1/wg-access-server ``` -## Running with Docker-Compose - -You modify the docker-compose.yml file for you need then run this following command. - -```bash -docker-compose up -d -``` +If you open your browser using your LAN ip address you can even connect your +phone to try it out: for example, i'll open my browser at http://192.168.0.XX:8000 +using the local LAN IP address. You can connect to the web server on the local machine browser at http://localhost:8000 -## Configuration +## Running with Docker-Compose -You can configure the server using a yaml configuration file. Just mount the file into the container like this: +You modify the docker-compose.yml file for you need then run this following command. ```bash -docker run \ - ... \ - -v $(pwd)/config.yaml:/config.yaml \ - place1/wg-access-server +docker-compose up ``` -If you want to put the config file in a different location in the container you -can set the config file path using: `-e CONFIG=/path/to/config.yaml` - -Here's and example showing the recommended config: - -```yaml -wireguard: - // The WireGuard PrivateKey - // You can generate this value using "$ wg genkey" - // If this value is empty then the server will use an in-memory - // generated key - privateKey: "" -// Auth configures optional authentication backends -// to controll access to the web ui. -// Devices will be managed on a per-user basis if any -// auth backends are configured. -// If no authentication backends are configured then -// the server will not require any authentication. -// It's recommended to make use of basic authentication -// or use an upstream HTTP proxy that enforces authentication -// Optional -auth: - // HTTP Basic Authentication - basic: - // Users is a list of htpasswd encoded username:password pairs - // supports BCrypt, Sha, Ssha, Md5 - // You can create a user using "htpasswd -nB " - users: [] -``` +You can connect to the web server on the local machine browser at http://localhost:8000 -Here's an example showing the all config values: - -```yaml -loglevel: debug -storage: - // Directory that VPN devices (WireGuard peers) - // should be saved under. - // If this value is empty then an InMemory storage - // backend will be used (not recommended). - // Defaults to "/data" inside the docker container - directory: /data -wireguard: - // The network interface name for wireguard - // Optional - interfaceName: wg0 - // The WireGuard PrivateKey - // You can generate this value using "$ wg genkey" - // If this value is empty then the server will use an in-memory - // generated key - privateKey: "" - // ExternalAddress is the address that clients - // use to connect to the wireguard interface - // By default, this will be empty and the web ui - // will use the current page's origin i.e. window.location.origin - // Optional - externalHost: "" - // The WireGuard ListenPort - // Optional - port: 51820 -} `yaml:"wireguard"` -vpn: - // CIDR configures a network address space - // that client (WireGuard peers) will be allocated - // an IP address from. - // Optional - cidr: "10.44.0.0/24" - // GatewayInterface will be used in iptable forwarding - // rules that send VPN traffic from clients to this interface - // Most use-cases will want this interface to have access - // to the outside internet - // If not configured then the server will select the default - // network interface e.g. eth0 - // Optional - gatewayInterface: "" -dns: - // upstream DNS servers. - // that the server-side DNS proxy will forward requests to. - // By default /etc/resolv.conf will be used to find upstream - // DNS servers. - // Optional - upstream: - - "1.1.1.1" -// Auth configures optional authentication backends -// to controll access to the web ui. -// Devices will be managed on a per-user basis if any -// auth backends are configured. -// If no authentication backends are configured then -// the server will not require any authentication. -// It's recommended to make use of basic authentication -// or use an upstream HTTP proxy that enforces authentication -// Optional -auth: - // HTTP Basic Authentication - basic: - // Users is a list of htpasswd encoded username:password pairs - // supports BCrypt, Sha, Ssha, Md5 - // You can create a user using "htpasswd -nB " - users: [] - oidc: - name: "" - issuer: "" - clientID: "" - clientSecret: "" - scopes: "" - redirectURL: "" - // Optionally restrict login to users with an allowed email domain - // if empty or omitted, any email domain will be allowed. - emailDomains: - - example.com - gitlab: - name: "" - baseURL: "" - clientID: "" - clientSecret: "" - redirectURL: "" - // Optionally restrict login to users with an allowed email domain - // if empty or omitted, any email domain will be allowed. - emailDomains: - - example.com -``` +If you open your browser to your machine's LAN IP address you'll be able +to connect your phone using the UI and QR code! ## Screenshots -![Connect iOS](./screenshots/connect-ios.png) +![Devices](https://github.com/Place1/wg-access-server/raw/master/screenshots/devices.png) -![Connect MacOS](./screenshots/connect-macos.png) +![Connect iOS](https://github.com/Place1/wg-access-server/raw/master/screenshots/connect-ios.png) -![Devices](./screenshots/devices.png) +![Connect MacOS](https://github.com/Place1/wg-access-server/raw/master/screenshots/connect-macos.png) -![Sign In](./screenshots/signin.png) +![Sign In](https://github.com/Place1/wg-access-server/raw/master/screenshots/signin.png) -## Roadmap +## Changelog -- [ ] Implement administration features - - administration of all devices - - see when a device last connected - - see owns the device -- [ ] VPN network client isolation -- [ ] ??? PRs, feedback, suggestions welcome +See the [CHANGELOG.md](https://github.com/Place1/wg-access-server/blob/master/CHANGELOG.md) file ## Development diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..0934824 --- /dev/null +++ b/TODO.md @@ -0,0 +1,27 @@ +## Docs +- [x] mkdocs +- [ ] about +- [x] deploying + - [x] simple docker 1 liner + - [x] docker-compose + - [x] kubernetes quickstart + - [x] helm +- [x] configuring + - [x] general + - [x] config file/flag/env +- [ ] how-to-guides + - [ ] docker + docker-compose + - [ ] kubernetes + nginx ingress + - [ ] raspberry-pi + pihole dns + +## Features +- [ ] ARM docker image for raspberry-pi +- [ ] admin + - [x] list all devices + - [ ] remove device +- [x] networking + - [x] isolate clients + - [x] forward to internet only (isolate LAN/WAN) + - [x] allowed networks (configure forwarding to specific CIDRs) + - [x] also limit which CIDRs clients forward + - [x] i.e. only forward to specific server-side LAN and not all internet traffic diff --git a/deploy/helm/wg-access-server/README.md b/deploy/helm/wg-access-server/README.md new file mode 100644 index 0000000..bd11d46 --- /dev/null +++ b/deploy/helm/wg-access-server/README.md @@ -0,0 +1,70 @@ +## Installing the Chart + +To install the chart with the release name `my-release`: + +```bash +$ helm install my-release --repo https://place1.github.io/wg-access-server wg-access-server +``` + +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. + +By default an in-memory wireguard private key will be generated and devices will not persist +between pod restarts. + +## Uninstalling the Chart + +To uninstall/delete the my-release deployment: + +```bash +$ helm delete my-release +``` + +The command removes all the Kubernetes components associated with the chart and deletes the release. + +## Example values.yaml + +```yaml +config: + wireguard: + externalHost: ":51820" +wireguard: + config: + privateKey: "" + service: + type: "LoadBalancer" +persistence: + enabled: true +ingress: + enabled: true + hosts: ["vpn.example.com"] + tls: + - hosts: ["vpn.example.com"] + secretName: "tls-wg-access-server" +``` + +## All Configuration + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| config | object | `{}` | inline wg-access-server config (config.yaml) | +| wireguard.config.privateKey | string | "" | A wireguard private key. You can generate one using `$ wg genkey` | +| wireguard.service.type | string | `"ClusterIP"` | | +| ingress.enabled | bool | `false` | | +| ingress.hosts | string | `nil` | | +| ingress.tls | list | `[]` | | +| ingress.annotations | object | `{}` | | +| persistence.enabled | bool | `false` | | +| persistence.size | string | `"100Mi"` | | +| persistence.subPath | string | `""` | | +| persistence.annotations | object | `{}` | | +| persistence.accessModes[0] | string | `"ReadWriteOnce"` | | +| strategy.type | string | `"Recreate"` | | +| resources | object | `{}` | pod cpu/memory resource requests and limits | +| nameOverride | string | `""` | | +| fullnameOverride | string | `""` | | +| affinity | object | `{}` | | +| nodeSelector | object | `{}` | | +| tolerations | list | `[]` | | +| image.pullPolicy | string | `"IfNotPresent"` | | +| image.repository | string | `"place1/wg-access-server"` | | +| imagePullSecrets | list | `[]` | | diff --git a/deploy/helm/wg-access-server/templates/deployment.yaml b/deploy/helm/wg-access-server/templates/deployment.yaml index c40bea7..a8a546a 100644 --- a/deploy/helm/wg-access-server/templates/deployment.yaml +++ b/deploy/helm/wg-access-server/templates/deployment.yaml @@ -35,6 +35,28 @@ spec: - name: wireguard containerPort: 51820 protocol: UDP + env: + {{- if .Values.wireguard.config.privateKey }} + - name: WIREGUARD_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: "{{ $fullName }}" + key: privateKey + {{- end }} + {{- if .Values.web.config.adminUsername }} + - name: ADMIN_USERNAME + valueFrom: + secretKeyRef: + name: "{{ $fullName }}" + key: adminUsername + {{- end}} + {{- if .Values.web.config.adminPassword }} + - name: ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: "{{ $fullName }}" + key: adminPassword + {{- end}} volumeMounts: - name: tun mountPath: /dev/net/tun diff --git a/deploy/helm/wg-access-server/templates/ingress.yaml b/deploy/helm/wg-access-server/templates/ingress.yaml index 19c6e91..9b63f61 100644 --- a/deploy/helm/wg-access-server/templates/ingress.yaml +++ b/deploy/helm/wg-access-server/templates/ingress.yaml @@ -26,11 +26,9 @@ spec: - host: {{ .host | quote }} http: paths: - {{- range .paths }} - - path: {{ . }} + - path: / backend: - serviceName: {{ $fullName }} + serviceName: {{ $fullName }}-web servicePort: 80 - {{- end }} {{- end }} {{- end }} diff --git a/deploy/helm/wg-access-server/templates/secret.yaml b/deploy/helm/wg-access-server/templates/secret.yaml new file mode 100644 index 0000000..baf61c2 --- /dev/null +++ b/deploy/helm/wg-access-server/templates/secret.yaml @@ -0,0 +1,18 @@ +{{- $fullName := include "wg-access-server.fullname" . -}} +{{- if .Values.wireguard.config.privateKey }} +apiVersion: v1 +kind: Secret +metadata: + name: "{{ $fullName }}" + labels: + {{- include "wg-access-server.labels" . | nindent 4 }} +type: Opaque +data: + privateKey: {{ .Values.wireguard.config.privateKey | b64enc | quote }} + {{- if .Values.web.config.adminUsername }} + adminUsername: {{ .Values.web.config.adminUsername | b64enc | quote }} + {{- end }} + {{- if .Values.web.config.adminPassword }} + adminPassword: {{ .Values.web.config.adminPassword | b64enc | quote }} + {{- end }} +{{- end }} diff --git a/deploy/helm/wg-access-server/templates/service.yaml b/deploy/helm/wg-access-server/templates/service.yaml index 6869197..52bdaca 100644 --- a/deploy/helm/wg-access-server/templates/service.yaml +++ b/deploy/helm/wg-access-server/templates/service.yaml @@ -6,7 +6,7 @@ metadata: labels: {{- include "wg-access-server.labels" . | nindent 4 }} spec: - type: {{ .Values.web.service.type }} + type: ClusterIP ports: - port: 80 targetPort: http diff --git a/deploy/helm/wg-access-server/values.yaml b/deploy/helm/wg-access-server/values.yaml index 5d47ecf..ffabba1 100644 --- a/deploy/helm/wg-access-server/values.yaml +++ b/deploy/helm/wg-access-server/values.yaml @@ -1,17 +1,14 @@ # wg-access-server config -config: - wireguard: - # The WireGuard PrivateKey - # You can generate this value using "$ wg genkey" - # If this value is empty then the server will use an in-memory - # generated key - privateKey: "" +config: {} web: - service: - type: ClusterIP + config: + adminUsername: "" + adminPassword: "" wireguard: + config: + privateKey: "" service: type: ClusterIP diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..95f1434 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,141 @@ +# Configuration + +## Environment Variables + +| Variable | Description | +|-----------------------|-------------| +| CONFIG | Set the config file path | +| WIREGUARD_PRIVATE_KEY | Set the wireguard private key | +| STORAGE_DIRECTORY | Set the directory where device config will be persisted | +| ADMIN_USERNAME | Set the username (subject) for the admin account | +| ADMIN_PASSWORD | Set the admin account's password. The admin account will be a basic-auth user. Leave blank if your admin username authenticates via a configured authentication backend. | +| UPSTREAM_DNS | Set the upstream DNS server to proxy client DNS requests to. If empty, resolv.conf will be respected. | +| LOG_LEVEL | Set the server's log level (debug, **info**, error, critical) | +| DISABLE_METADATA | If true, the server will not record device level metadata such as the last handshake time, tx/rx data size | + +## CLI Flags + +All environment variables can be configured via a +CLI flag as well. + +For example you can configure `STORAGE_DIRECTORY` by passing `--storage-directory=""`. + +## Config File (config.yaml) + +Here's an annotated config file example: + +```yaml +loglevel: debug +storage: + # Directory that VPN devices (WireGuard peers) + # should be saved under. + # If this value is empty then an InMemory storage + # backend will be used (not recommended). + # Defaults to "/data" inside the docker container + directory: /data +wireguard: + # The network interface name for wireguard + # Optional + interfaceName: wg0 + # The WireGuard PrivateKey + # You can generate this value using "$ wg genkey" + # If this value is empty then the server will use an in-memory + # generated key + privateKey: "" + # ExternalAddress is the address that clients + # use to connect to the wireguard interface + # By default, this will be empty and the web ui + # will use the current page's origin i.e. window.location.origin + # Optional + externalHost: "" + # The WireGuard ListenPort + # Optional + port: 51820 +} `yaml:"wireguard"` +vpn: + # CIDR configures a network address space + # that client (WireGuard peers) will be allocated + # an IP address from. + # Optional + cidr: "10.44.0.0/24" + # GatewayInterface will be used in iptable forwarding + # rules that send VPN traffic from clients to this interface + # Most use-cases will want this interface to have access + # to the outside internet + # If not configured then the server will select the default + # network interface e.g. eth0 + # Optional + gatewayInterface: "" + // Rules allows you to configure what level + // of network isolation should be enfoced. + rules: + # AllowVPNLAN enables routing between VPN clients + # i.e. allows the VPN to work like a LAN. + # true by default + # Optional + allowVPNLAN: true + # AllowServerLAN enables routing to private IPv4 + # address ranges. Enabling this will allow VPN clients + # to access networks on the server's LAN. + # true by default + # Optional + allowServerLAN: true + # AllowInternet enables routing of all traffic + # to the public internet. + # true by default + # Optional + allowInternet: true + # AllowedNetworks allows you to whitelist a partcular + # network CIDR. This is useful if you want to block + # access to the Server's LAN but allow access to a few + # specific IPs or a small range. + # e.g. "192.0.2.0/24" or "192.0.2.10/32". + # no networks are whitelisted by default (empty array) + # Optional + allowedNetworks: [] +dns: + # upstream DNS servers. + # that the server-side DNS proxy will forward requests to. + # By default /etc/resolv.conf will be used to find upstream + # DNS servers. + # Optional + upstream: + - "1.1.1.1" +# Auth configures optional authentication backends +# to controll access to the web ui. +# Devices will be managed on a per-user basis if any +# auth backends are configured. +# If no authentication backends are configured then +# the server will not require any authentication. +# It's recommended to make use of basic authentication +# or use an upstream HTTP proxy that enforces authentication +# Optional +auth: + # HTTP Basic Authentication + basic: + # Users is a list of htpasswd encoded username:password pairs + # supports BCrypt, Sha, Ssha, Md5 + # You can create a user using "htpasswd -nB " + users: [] + oidc: + name: "" + issuer: "" + clientID: "" + clientSecret: "" + scopes: "" + redirectURL: "" + # Optionally restrict login to users with an allowed email domain + # if empty or omitted, any email domain will be allowed. + emailDomains: + - example.com + gitlab: + name: "" + baseURL: "" + clientID: "" + clientSecret: "" + redirectURL: "" + # Optionally restrict login to users with an allowed email domain + # if empty or omitted, any email domain will be allowed. + emailDomains: + - example.com +``` diff --git a/docs/deployment/1-docker.md b/docs/deployment/1-docker.md new file mode 100644 index 0000000..af0099c --- /dev/null +++ b/docs/deployment/1-docker.md @@ -0,0 +1,14 @@ +# Docker + +## TL;DR; + +Here's a one-liner to run wg-access-server: + +```bash +docker run --rm -it + --cap-add NET_ADMIN \ + --device /dev/net/tun:/dev/net/tun \ + -p 8000:8000/tcp \ + -p 51820:51820/udp \ + place1/wg-access-server +``` diff --git a/docs/deployment/2-docker-compose.md b/docs/deployment/2-docker-compose.md new file mode 100644 index 0000000..f227c90 --- /dev/null +++ b/docs/deployment/2-docker-compose.md @@ -0,0 +1,11 @@ +# Docker Compose + +You can run wg-access-server using the following example +docker Docker Compose file. + +Checkout the [configuration docs](../configuration.md) to learn how wg-access-server +can be configured. + +```yaml +{!../docker-compose.yml!} +``` diff --git a/docs/deployment/3-kubernetes.md b/docs/deployment/3-kubernetes.md new file mode 100644 index 0000000..3c46f03 --- /dev/null +++ b/docs/deployment/3-kubernetes.md @@ -0,0 +1,3 @@ +# Helm Chart + +{!../deploy/helm/wg-access-server/README.md!} diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..842936c --- /dev/null +++ b/docs/index.md @@ -0,0 +1,3 @@ +# Welcome + +{!../README.md!} diff --git a/go.mod b/go.mod index 622d741..ea178a1 100644 --- a/go.mod +++ b/go.mod @@ -23,14 +23,14 @@ require ( github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.8.1 - github.com/place1/wg-embed v0.0.0-20200220103052-288c50323e73 + github.com/place1/wg-embed v0.1.0 github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect github.com/rs/cors v1.7.0 // indirect github.com/sirupsen/logrus v1.4.2 github.com/smartystreets/goconvey v1.6.4 // indirect github.com/tg123/go-htpasswd v1.0.0 github.com/vishvananda/netlink v1.0.0 - golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413 // indirect + golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413 golang.org/x/net v0.0.0-20200202094626-16171245cfb2 // indirect golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 // indirect @@ -42,4 +42,7 @@ require ( gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect gopkg.in/square/go-jose.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.2.2 + gotest.tools v2.2.0+incompatible // indirect ) + +// replace github.com/place1/wg-embed => ../wg-embed diff --git a/go.sum b/go.sum index f76b4d4..8554b13 100644 --- a/go.sum +++ b/go.sum @@ -3,7 +3,6 @@ cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/Sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4 h1:Hs82Z41s6SdL1CELW+XaDYmOH4hkBN4/N9og/AsOv7E= @@ -91,8 +90,8 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/place1/wg-embed v0.0.0-20200220103052-288c50323e73 h1:vqidjPSGwdYc3zR3O1+0HrME8By/CGbRXsLY45xFxyI= -github.com/place1/wg-embed v0.0.0-20200220103052-288c50323e73/go.mod h1:Nse1dFm7Lq10qbz5FlM3vjZch0Dif6xjJL1OfrXuozc= +github.com/place1/wg-embed v0.1.0 h1:24tOYkcE++zI4lvXUa464KUwbyi46EZ7zmQm8UyLvAI= +github.com/place1/wg-embed v0.1.0/go.mod h1:Nse1dFm7Lq10qbz5FlM3vjZch0Dif6xjJL1OfrXuozc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU= @@ -220,5 +219,7 @@ gopkg.in/square/go-jose.v2 v2.4.0 h1:0kXPskUMGAXXWJlP05ktEMOV0vmzFQUWw6d+aZJQU8A gopkg.in/square/go-jose.v2 v2.4.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/config/config.go b/internal/config/config.go index fb04c44..97e6f9b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,18 +10,23 @@ import ( "gopkg.in/yaml.v2" - "github.com/place1/wg-access-server/internal/auth/authconfig" + "github.com/place1/wg-access-server/internal/network" + "github.com/place1/wg-access-server/pkg/authnz/authconfig" "github.com/vishvananda/netlink" "github.com/pkg/errors" "github.com/sirupsen/logrus" + "golang.org/x/crypto/bcrypt" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "gopkg.in/alecthomas/kingpin.v2" ) type AppConfig struct { - LogLevel string `yaml:"loglevel"` - Storage struct { + LogLevel string `yaml:"loglevel"` + DisableMetadata bool `yaml:"disableMetadata"` + AdminSubject string `yaml:"adminSubject"` + AdminPassword string `yaml:"adminPassword"` + Storage struct { // Directory that VPN devices (WireGuard peers) // should be saved under. // If this value is empty then an InMemory storage @@ -60,6 +65,9 @@ type AppConfig struct { // Most use-cases will want this interface to have access // to the outside internet GatewayInterface string `yaml:"gatewayInterface"` + // Rules allows you to configure what level + // of network isolation should be enfoced. + Rules *network.NetworkRules `yaml:"rules"` } DNS struct { Upstream []string `yaml:"upstream"` @@ -70,41 +78,58 @@ type AppConfig struct { // auth backends are configured. // If no authentication backends are configured then // the server will not require any authentication. - Auth *authconfig.AuthConfig `yaml:"auth"` + Auth authconfig.AuthConfig `yaml:"auth"` } var ( - app = kingpin.New("wg-access-server", "An all-in-one WireGuard Access Server & VPN solution") - configPath = app.Flag("config", "Path to a config file").OverrideDefaultFromEnvar("CONFIG").String() + app = kingpin.New("wg-access-server", "An all-in-one WireGuard Access Server & VPN solution") + configPath = app.Flag("config", "Path to a config file").Envar("CONFIG").String() + logLevel = app.Flag("log-level", "Log level (debug, info, error)").Envar("LOG_LEVEL").Default("info").String() + storagePath = app.Flag("storage-directory", "Path to a storage directory").Envar("STORAGE_DIRECTORY").String() + privateKey = app.Flag("wireguard-private-key", "Wireguard private key").Envar("WIREGUARD_PRIVATE_KEY").String() + disableMetadata = app.Flag("disable-metadata", "Disable metadata collection (i.e. metrics)").Envar("DISABLE_METADATA").Default("false").Bool() + adminUsername = app.Flag("admin-username", "Admin username (defaults to admin)").Envar("ADMIN_USERNAME").String() + adminPassword = app.Flag("admin-password", "Admin password (provide plaintext, stored in-memory only)").Envar("ADMIN_PASSWORD").String() + upstreamDNS = app.Flag("upstream-dns", "An upstream DNS server to proxy DNS traffic to").Envar("UPSTREAM_DNS").String() ) func Read() *AppConfig { kingpin.MustParse(app.Parse(os.Args[1:])) config := AppConfig{} - config.LogLevel = "info" + config.LogLevel = *logLevel config.WireGuard.InterfaceName = "wg0" config.WireGuard.Port = 51820 config.VPN.CIDR = "10.44.0.0/24" - - if *configPath != "" { - if b, err := ioutil.ReadFile(*configPath); err == nil { - if err := yaml.Unmarshal(b, &config); err != nil { - logrus.Fatal(errors.Wrap(err, "failed to bind configuration file")) - } + config.DisableMetadata = *disableMetadata + config.Storage.Directory = *storagePath + config.WireGuard.PrivateKey = *privateKey + if adminPassword != nil { + config.AdminPassword = *adminPassword + config.AdminSubject = *adminUsername + if config.AdminSubject == "" { + config.AdminSubject = "admin" } } - if v, ok := os.LookupEnv("LOG_LEVEL"); ok { - config.LogLevel = v + if upstreamDNS != nil { + config.DNS.Upstream = []string{*upstreamDNS} } - if v, ok := os.LookupEnv("STORAGE_DIRECTORY"); ok { - config.Storage.Directory = v + if config.VPN.Rules == nil { + config.VPN.Rules = &network.NetworkRules{ + AllowVPNLAN: true, + AllowServerLAN: true, + AllowInternet: true, + } } - if v, ok := os.LookupEnv("WIREGUARD_PRIVATE_KEY"); ok { - config.WireGuard.PrivateKey = v + if *configPath != "" { + if b, err := ioutil.ReadFile(*configPath); err == nil { + if err := yaml.Unmarshal(b, &config); err != nil { + logrus.Fatal(errors.Wrap(err, "failed to bind configuration file")) + } + } } level, err := logrus.ParseLevel(config.LogLevel) @@ -120,6 +145,10 @@ func Read() *AppConfig { }, }) + if config.DisableMetadata { + logrus.Info("Metadata collection has been disabled. No metrics or device connectivity information will be recorded or shown") + } + if config.VPN.GatewayInterface == "" { iface, err := defaultInterface() if err != nil { @@ -145,13 +174,22 @@ func Read() *AppConfig { if err != nil { logrus.Fatal(errors.Wrap(err, "failed to get absolute path to storage directory")) } + os.MkdirAll(config.Storage.Directory, 0700) } - return &config -} + if config.AdminPassword != "" { + if config.Auth.Basic == nil { + config.Auth.Basic = &authconfig.BasicAuthConfig{} + } + // htpasswd.AcceptBcrypt(config.AdminPassword) + pw, err := bcrypt.GenerateFromPassword([]byte(config.AdminPassword), bcrypt.DefaultCost) + if err != nil { + logrus.Fatal(errors.Wrap(err, "failed to generate a bcrypt hash for the provided admin password")) + } + config.Auth.Basic.Users = append(config.Auth.Basic.Users, fmt.Sprintf("%s:%s", config.AdminSubject, string(pw))) + } -func (config *AppConfig) IsAuthEnabled() bool { - return config.Auth != nil + return &config } func defaultInterface() (string, error) { diff --git a/internal/devices/devices.go b/internal/devices/devices.go index e7de1ca..937643e 100644 --- a/internal/devices/devices.go +++ b/internal/devices/devices.go @@ -25,7 +25,8 @@ func New(iface string, s storage.Storage, cidr string) *DeviceManager { return &DeviceManager{iface, s, cidr} } -func (d *DeviceManager) Sync() error { +func (d *DeviceManager) StartSync(disableMetadataCollection bool) error { + // sync devices from storage once devices, err := d.ListDevices("") if err != nil { return errors.Wrap(err, "failed to list devices") @@ -35,6 +36,12 @@ func (d *DeviceManager) Sync() error { logrus.Warn(errors.Wrapf(err, "failed to sync device '%s' (ignoring)", device.Name)) } } + + // start the metrics loop + if !disableMetadataCollection { + go metadataLoop(d) + } + return nil } @@ -56,7 +63,7 @@ func (d *DeviceManager) AddDevice(user string, name string, publicKey string) (* CreatedAt: time.Now(), } - if err := d.storage.Save(key(user, device.Name), device); err != nil { + if err := d.SaveDevice(device); err != nil { return nil, errors.Wrap(err, "failed to save the new device") } @@ -67,6 +74,14 @@ func (d *DeviceManager) AddDevice(user string, name string, publicKey string) (* return device, nil } +func (d *DeviceManager) SaveDevice(device *storage.Device) error { + return d.storage.Save(key(device.Owner, device.Name), device) +} + +func (d *DeviceManager) ListAllDevices() ([]*storage.Device, error) { + return d.storage.List("") +} + func (d *DeviceManager) ListDevices(user string) ([]*storage.Device, error) { prefix := "" if user != "" { diff --git a/internal/devices/metadata.go b/internal/devices/metadata.go new file mode 100644 index 0000000..9028360 --- /dev/null +++ b/internal/devices/metadata.go @@ -0,0 +1,46 @@ +package devices + +import ( + "time" + + "github.com/pkg/errors" + "github.com/place1/wg-embed/pkg/wgembed" + "github.com/sirupsen/logrus" +) + +func metadataLoop(d *DeviceManager) { + for { + syncMetrics(d) + time.Sleep(5 * time.Second) + } +} + +func syncMetrics(d *DeviceManager) { + devices, err := d.ListAllDevices() + if err != nil { + logrus.Warn(errors.Wrap(err, "failed to list devices - metrics cannot be recorded")) + return + } + peers, err := wgembed.ListPeers(d.iface) + if err != nil { + logrus.Warn(errors.Wrap(err, "failed to list peers - metrics cannot be recorded")) + return + } + for _, peer := range peers { + for _, device := range devices { + if peer.PublicKey.String() == device.PublicKey { + device.ReceiveBytes = peer.ReceiveBytes + device.TransmitBytes = peer.TransmitBytes + if !peer.LastHandshakeTime.IsZero() { + device.LastHandshakeTime = &peer.LastHandshakeTime + } + if peer.Endpoint != nil { + device.Endpoint = peer.Endpoint.IP.String() + } + if err := d.SaveDevice(device); err != nil { + logrus.Debug(errors.Wrap(err, "failed to update device metadata")) + } + } + } + } +} diff --git a/internal/network/network.go b/internal/network/network.go new file mode 100644 index 0000000..26e9949 --- /dev/null +++ b/internal/network/network.go @@ -0,0 +1,174 @@ +package network + +import ( + "fmt" + "net" + + "github.com/coreos/go-iptables/iptables" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/vishvananda/netlink" +) + +func ServerVPNIP(cidr string) *net.IPNet { + vpnip, vpnsubnet := MustParseCIDR(cidr) + vpnsubnet.IP = nextIP(vpnip.Mask(vpnsubnet.Mask)) + return vpnsubnet +} + +func ConfigureRouting(wgIface string, cidr string) error { + // Networking configuration (ip links and route tables) + // to ensure that network traffic in the VPN subnet + // moves through the wireguard interface + link, err := netlink.LinkByName(wgIface) + if err != nil { + return errors.Wrap(err, "failed to find wireguard interface") + } + vpnip := ServerVPNIP(cidr) + logrus.Infof("server VPN subnet IP is %s", vpnip.String()) + addr, err := netlink.ParseAddr(vpnip.String()) + if err != nil { + return errors.Wrap(err, "failed to parse subnet address") + } + if err := netlink.AddrAdd(link, addr); err != nil { + logrus.Warn(errors.Wrap(err, "failed to add subnet to wireguard interface")) + } + if err := netlink.LinkSetUp(link); err != nil { + logrus.Warn(errors.Wrap(err, "failed to bring wireguard interface up")) + } + return nil +} + +type NetworkRules struct { + // AllowVPNLAN enables routing between VPN clients + // i.e. allows the VPN to work like a LAN. + // true by default + AllowVPNLAN bool `yaml:"allowVPNLAN"` + // AllowServerLAN enables routing to private IPv4 + // address ranges. Enabling this will allow VPN clients + // to access networks on the server's LAN. + // true by default + AllowServerLAN bool `yaml:"allowServerLAN"` + // AllowInternet enables routing of all traffic + // to the public internet. + // true by default + AllowInternet bool `yaml:"allowInternet"` + // AllowedNetworks allows you to whitelist a partcular + // network CIDR. This is useful if you want to block + // access to the Server's LAN but allow access to a few + // specific IPs or a small range. + // e.g. "192.0.2.0/24" or "192.0.2.10/32". + // no networks are whitelisted by default (empty array) + AllowedNetworks []string `yaml:"allowedNetworks"` +} + +func ConfigureForwarding(wgIface string, gatewayIface string, cidr string, rules NetworkRules) error { + // Networking configuration (iptables) configuration + // to ensure that traffic from clients the wireguard interface + // is sent to the provided network interface + ipt, err := iptables.New() + if err != nil { + return errors.Wrap(err, "failed to init iptables") + } + + // Cleanup our chains first so that we don't leak + // iptable rules when the network configuration changes. + ipt.ClearChain("filter", "WG_ACCESS_SERVER_FORWARD") + ipt.ClearChain("nat", "WG_ACCESS_SERVER_POSTROUTING") + + // Create our own chain for forwarding rules + ipt.NewChain("filter", "WG_ACCESS_SERVER_FORWARD") + ipt.AppendUnique("filter", "FORWARD", "-j", "WG_ACCESS_SERVER_FORWARD") + + // Create our own chain for postrouting rules + ipt.NewChain("nat", "WG_ACCESS_SERVER_POSTROUTING") + ipt.AppendUnique("nat", "POSTROUTING", "-j", "WG_ACCESS_SERVER_POSTROUTING") + + if err := ConfigureRouting(wgIface, cidr); err != nil { + logrus.Error(errors.Wrap(err, "failed to configure interface")) + } + + // https://simple.wikipedia.org/wiki/Private_network + privateCIDRs := []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"} + + // White listed networks + if len(rules.AllowedNetworks) != 0 { + for _, subnet := range rules.AllowedNetworks { + if err := ipt.AppendUnique("filter", "WG_ACCESS_SERVER_FORWARD", "-s", cidr, "-d", subnet, "-j", "ACCEPT"); err != nil { + return errors.Wrap(err, "failed to set ip tables rule") + } + } + } + + // VPN LAN + if rules.AllowVPNLAN { + if err := ipt.AppendUnique("filter", "WG_ACCESS_SERVER_FORWARD", "-s", cidr, "-d", cidr, "-j", "ACCEPT"); err != nil { + return errors.Wrap(err, "failed to set ip tables rule") + } + } else { + if err := ipt.AppendUnique("filter", "WG_ACCESS_SERVER_FORWARD", "-s", cidr, "-d", cidr, "-j", "REJECT"); err != nil { + return errors.Wrap(err, "failed to set ip tables rule") + } + } + + // Server LAN + for _, privateCIDR := range privateCIDRs { + if err := ipt.AppendUnique("filter", "WG_ACCESS_SERVER_FORWARD", "-s", cidr, "-d", privateCIDR, "-j", boolToRule(rules.AllowServerLAN)); err != nil { + return errors.Wrap(err, "failed to set ip tables rule") + } + } + + // Internet + if rules.AllowInternet && gatewayIface != "" { + // TODO: do we actually need to specify a gateway interface? + // I suppose i neet to refresh my knowledge of nat. + // if you're reading this please open a Github issue and help teach me nat and iptables :P + if err := ipt.AppendUnique("filter", "WG_ACCESS_SERVER_FORWARD", "-s", cidr, "-i", gatewayIface, "-o", wgIface, "-j", "ACCEPT"); err != nil { + return errors.Wrap(err, "failed to set ip tables rule") + } + if err := ipt.AppendUnique("filter", "WG_ACCESS_SERVER_FORWARD", "-s", cidr, "-i", wgIface, "-o", gatewayIface, "-j", "ACCEPT"); err != nil { + return errors.Wrap(err, "failed to set ip tables rule") + } + if err := ipt.AppendUnique("nat", "WG_ACCESS_SERVER_POSTROUTING", "-s", cidr, "-o", gatewayIface, "-j", "MASQUERADE"); err != nil { + return errors.Wrap(err, "failed to set ip tables rule") + } + } else { + if err := ipt.AppendUnique("filter", "WG_ACCESS_SERVER_FORWARD", "-s", cidr, "-j", "REJECT"); err != nil { + return errors.Wrap(err, "failed to set ip tables rule") + } + } + + return nil +} + +func MustParseCIDR(cidr string) (net.IP, *net.IPNet) { + ip, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + panic(err) + } + return ip, ipnet +} + +func MustParseIP(ip string) net.IP { + netip, _ := MustParseCIDR(fmt.Sprintf("%s/32", ip)) + return netip +} + +func nextIP(ip net.IP) net.IP { + next := make([]byte, len(ip)) + copy(next, ip) + for j := len(next) - 1; j >= 0; j-- { + next[j]++ + if next[j] > 0 { + break + } + } + return next +} + +func boolToRule(accept bool) string { + if accept { + return "ACCEPT" + } + return "REJECT" +} diff --git a/internal/services/converters.go b/internal/services/converters.go index 2bcb3ac..956f556 100644 --- a/internal/services/converters.go +++ b/internal/services/converters.go @@ -13,8 +13,11 @@ func TimestampToTime(value *timestamp.Timestamp) time.Time { return time.Unix(value.Seconds, int64(value.Nanos)) } -func TimeToTimestamp(value time.Time) *timestamp.Timestamp { - t, err := ptypes.TimestampProto(value) +func TimeToTimestamp(value *time.Time) *timestamp.Timestamp { + if value == nil { + return nil + } + t, err := ptypes.TimestampProto(*value) if err != nil { logrus.Error("bad time value") t = ptypes.TimestampNow() diff --git a/internal/services/device_service.go b/internal/services/device_service.go index 2a0f29c..90b4889 100644 --- a/internal/services/device_service.go +++ b/internal/services/device_service.go @@ -2,8 +2,9 @@ package services import ( "context" + "time" - "github.com/place1/wg-access-server/internal/auth/authsession" + "github.com/place1/wg-access-server/pkg/authnz/authsession" "github.com/golang/protobuf/ptypes/empty" "github.com/place1/wg-access-server/internal/devices" @@ -63,13 +64,46 @@ func (d *DeviceService) DeleteDevice(ctx context.Context, req *proto.DeleteDevic return &empty.Empty{}, nil } +func (d *DeviceService) ListAllDevices(ctx context.Context, req *proto.ListAllDevicesReq) (*proto.ListAllDevicesRes, error) { + user, err := authsession.CurrentUser(ctx) + if err != nil { + return nil, status.Errorf(codes.PermissionDenied, "not authenticated") + } + + if !user.Claims.Contains("admin") { + return nil, status.Errorf(codes.PermissionDenied, "must be an admin") + } + + devices, err := d.DeviceManager.ListAllDevices() + if err != nil { + logrus.Error(err) + return nil, status.Errorf(codes.Internal, "failed to retrieve devices") + } + + return &proto.ListAllDevicesRes{ + Items: mapDevices(devices), + }, nil +} + func mapDevice(d *storage.Device) *proto.Device { return &proto.Device{ - Name: d.Name, - Owner: d.Owner, - PublicKey: d.PublicKey, - Address: d.Address, - CreatedAt: TimeToTimestamp(d.CreatedAt), + Name: d.Name, + Owner: d.Owner, + PublicKey: d.PublicKey, + Address: d.Address, + CreatedAt: TimeToTimestamp(&d.CreatedAt), + LastHandshakeTime: TimeToTimestamp(d.LastHandshakeTime), + ReceiveBytes: d.ReceiveBytes, + TransmitBytes: d.TransmitBytes, + Endpoint: d.Endpoint, + /** + * Wireguard is a connectionless UDP protocol - data is only + * sent over the wire when the client is sending real traffic. + * Wireguard has no keep alive packets by default to remain as + * silent as possible. + * + */ + Connected: isConnected(d.LastHandshakeTime), } } @@ -80,3 +114,10 @@ func mapDevices(devices []*storage.Device) []*proto.Device { } return items } + +func isConnected(lastHandshake *time.Time) bool { + if lastHandshake == nil { + return false + } + return lastHandshake.After(time.Now().Add(-1 * time.Minute)) +} diff --git a/internal/services/network.go b/internal/services/network.go deleted file mode 100644 index 92f2e01..0000000 --- a/internal/services/network.go +++ /dev/null @@ -1,88 +0,0 @@ -package services - -import ( - "fmt" - "net" - - "github.com/coreos/go-iptables/iptables" - "github.com/pkg/errors" - "github.com/sirupsen/logrus" - "github.com/vishvananda/netlink" -) - -func ServerVPNIP(cidr string) *net.IPNet { - vpnip, vpnsubnet := MustParseCIDR(cidr) - vpnsubnet.IP = nextIP(vpnip.Mask(vpnsubnet.Mask)) - return vpnsubnet -} - -func ConfigureRouting(wgIface string, cidr string) error { - // Networking configuration (ip links and route tables) - // to ensure that network traffic in the VPN subnet - // moves through the wireguard interface - link, err := netlink.LinkByName(wgIface) - if err != nil { - return errors.Wrap(err, "failed to find wireguard interface") - } - vpnip := ServerVPNIP(cidr) - logrus.Infof("server VPN subnet IP is %s", vpnip.String()) - addr, err := netlink.ParseAddr(vpnip.String()) - if err != nil { - return errors.Wrap(err, "failed to parse subnet address") - } - if err := netlink.AddrAdd(link, addr); err != nil { - logrus.Warn(errors.Wrap(err, "failed to add subnet to wireguard interface")) - } - if err := netlink.LinkSetUp(link); err != nil { - logrus.Warn(errors.Wrap(err, "failed to bring wireguard interface up")) - } - return nil -} - -func ConfigureForwarding(wgIface string, gatewayIface string, cidr string) error { - // Networking configuration (iptables) configuration - // to ensure that traffic from clients the wireguard interface - // is sent to the provided network interface - ipt, err := iptables.New() - if err != nil { - return errors.Wrap(err, "failed to init iptables") - } - logrus.Infof("iptables rule - accept forwarding traffic from %s to interface %s", gatewayIface, wgIface) - if err := ipt.AppendUnique("filter", "FORWARD", "-i", gatewayIface, "-o", wgIface, "-j", "ACCEPT"); err != nil { - return errors.Wrap(err, "failed to set ip tables rule") - } - logrus.Infof("iptables rule - accept forwarding traffic from %s to interface %s", wgIface, gatewayIface) - if err := ipt.AppendUnique("filter", "FORWARD", "-i", wgIface, "-o", gatewayIface, "-j", "ACCEPT"); err != nil { - return errors.Wrap(err, "failed to set ip tables rule") - } - logrus.Infof("iptables rule - masquerade traffic from %s to interface %s", cidr, gatewayIface) - if err := ipt.AppendUnique("nat", "POSTROUTING", "-s", cidr, "-o", gatewayIface, "-j", "MASQUERADE"); err != nil { - return errors.Wrap(err, "failed to set ip tables rule") - } - return nil -} - -func MustParseCIDR(cidr string) (net.IP, *net.IPNet) { - ip, ipnet, err := net.ParseCIDR(cidr) - if err != nil { - panic(err) - } - return ip, ipnet -} - -func MustParseIP(ip string) net.IP { - netip, _ := MustParseCIDR(fmt.Sprintf("%s/32", ip)) - return netip -} - -func nextIP(ip net.IP) net.IP { - next := make([]byte, len(ip)) - copy(next, ip) - for j := len(next) - 1; j >= 0; j-- { - next[j]++ - if next[j] > 0 { - break - } - } - return next -} diff --git a/internal/services/server_service.go b/internal/services/server_service.go index 6b2e773..e70dd3e 100644 --- a/internal/services/server_service.go +++ b/internal/services/server_service.go @@ -1,10 +1,11 @@ package services import ( + "github.com/place1/wg-access-server/internal/network" "context" - "github.com/place1/wg-access-server/internal/auth/authsession" "github.com/place1/wg-access-server/internal/config" + "github.com/place1/wg-access-server/pkg/authnz/authsession" "github.com/place1/wg-access-server/proto/proto" "github.com/place1/wg-embed/pkg/wgembed" "github.com/sirupsen/logrus" @@ -17,7 +18,8 @@ type ServerService struct { } func (s *ServerService) Info(ctx context.Context, req *proto.InfoReq) (*proto.InfoRes, error) { - if _, err := authsession.CurrentUser(ctx); err != nil { + user, err := authsession.CurrentUser(ctx) + if err != nil { return nil, status.Errorf(codes.PermissionDenied, "not authenticated") } @@ -28,9 +30,11 @@ func (s *ServerService) Info(ctx context.Context, req *proto.InfoReq) (*proto.In } return &proto.InfoRes{ - Host: stringValue(s.Config.WireGuard.ExternalHost), - PublicKey: publicKey, - Port: int32(s.Config.WireGuard.Port), - HostVpnIp: ServerVPNIP(s.Config.VPN.CIDR).IP.String(), + Host: stringValue(s.Config.WireGuard.ExternalHost), + PublicKey: publicKey, + Port: int32(s.Config.WireGuard.Port), + HostVpnIp: network.ServerVPNIP(s.Config.VPN.CIDR).IP.String(), + MetadataEnabled: !s.Config.DisableMetadata, + IsAdmin: user.Claims.Contains("admin"), }, nil } diff --git a/internal/storage/contracts.go b/internal/storage/contracts.go index 26ae9f3..eea937d 100644 --- a/internal/storage/contracts.go +++ b/internal/storage/contracts.go @@ -17,4 +17,16 @@ type Device struct { PublicKey string `json:"publicKey"` Address string `json:"address"` CreatedAt time.Time `json:"createdAt"` + + /** + * Metadata fields below. + * All metadata tracking can be disabled + * from the config file. + */ + + // metadata about the device during the current session + LastHandshakeTime *time.Time `json:"lastHandshakeTime"` + ReceiveBytes int64 `json:"receivedBytes"` + TransmitBytes int64 `json:"transmitBytes"` + Endpoint string `json:"endpoint"` } diff --git a/internal/storage/disk.go b/internal/storage/disk.go index d080648..efcae31 100644 --- a/internal/storage/disk.go +++ b/internal/storage/disk.go @@ -28,7 +28,7 @@ func NewDiskStorage(directory string) *DiskStorage { func (s *DiskStorage) Save(key string, device *Device) error { path := s.deviceFilePath(key) - logrus.Infof("saving new device %s", path) + logrus.Debugf("saving device %s", path) bytes, err := json.Marshal(device) if err != nil { return errors.Wrap(err, "failed to marshal device") diff --git a/main.go b/main.go index da02ac2..be80cb6 100644 --- a/main.go +++ b/main.go @@ -2,8 +2,11 @@ package main import ( "crypto/rand" + "fmt" "math" "net/http" + "net/url" + "os" "runtime/debug" "github.com/improbable-eng/grpc-web/go/grpcweb" @@ -13,15 +16,18 @@ import ( "github.com/place1/wg-embed/pkg/wgembed" "github.com/pkg/errors" - "github.com/place1/wg-access-server/internal/auth" - "github.com/place1/wg-access-server/internal/auth/authsession" "github.com/place1/wg-access-server/internal/config" "github.com/place1/wg-access-server/internal/devices" "github.com/place1/wg-access-server/internal/dnsproxy" + "github.com/place1/wg-access-server/internal/network" "github.com/place1/wg-access-server/internal/services" "github.com/place1/wg-access-server/internal/storage" + "github.com/place1/wg-access-server/pkg/authnz" + "github.com/place1/wg-access-server/pkg/authnz/authsession" "github.com/sirupsen/logrus" + "net/http/httputil" + grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware" grpc_logrus "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus" grpc_recovery "github.com/grpc-ecosystem/go-grpc-middleware/recovery" @@ -32,7 +38,7 @@ func main() { conf := config.Read() // The server's IP within the VPN virtual network - vpnip := services.ServerVPNIP(conf.VPN.CIDR) + vpnip := network.ServerVPNIP(conf.VPN.CIDR) // WireGuard Server wg, err := wgembed.New(conf.WireGuard.InterfaceName) @@ -49,18 +55,9 @@ func main() { }, }) - // Networking configuration - if err := services.ConfigureRouting(conf.WireGuard.InterfaceName, conf.VPN.CIDR); err != nil { + if err := network.ConfigureForwarding(conf.WireGuard.InterfaceName, conf.VPN.GatewayInterface, conf.VPN.CIDR, *conf.VPN.Rules); err != nil { logrus.Fatal(err) } - if conf.VPN.GatewayInterface != "" { - logrus.Infof("vpn gateway interface is %s", conf.VPN.GatewayInterface) - if err := services.ConfigureForwarding(conf.WireGuard.InterfaceName, conf.VPN.GatewayInterface, conf.VPN.CIDR); err != nil { - logrus.Fatal(err) - } - } else { - logrus.Warn("VPN.GatewayInterface is not configured - vpn clients will not have access to the internet") - } // DNS Server dns, err := dnsproxy.New(conf.DNS.Upstream) @@ -80,20 +77,28 @@ func main() { // Services deviceManager := devices.New(wg.Name(), storageDriver, conf.VPN.CIDR) - if err := deviceManager.Sync(); err != nil { + if err := deviceManager.StartSync(conf.DisableMetadata); err != nil { logrus.Fatal(errors.Wrap(err, "failed to sync")) } // Router router := mux.NewRouter() - router.PathPrefix("/").Handler(http.FileServer(http.Dir("website/build"))) + + // if the built website exists, serve that + // otherwise proxy to a local webpack development server + if _, err := os.Stat("website/build"); os.IsNotExist(err) { + u, _ := url.Parse("http://localhost:3000") + router.NotFoundHandler = httputil.NewSingleHostReverseProxy(u) + } else { + router.PathPrefix("/").Handler(http.FileServer(http.Dir("website/build"))) + } // GRPC Server server := grpc.NewServer([]grpc.ServerOption{ grpc.MaxRecvMsgSize(int(1 * math.Pow(2, 20))), // 1MB grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer( - grpc_recovery.UnaryServerInterceptor(), grpc_logrus.UnaryServerInterceptor(logrus.NewEntry(logrus.StandardLogger())), + grpc_recovery.UnaryServerInterceptor(), )), }...) proto.RegisterServerServer(server, &services.ServerService{ @@ -121,8 +126,13 @@ func main() { } }) - if conf.IsAuthEnabled() { - handler = auth.New(conf.Auth).Wrap(handler) + if conf.Auth.IsEnabled() { + handler = authnz.New(conf.Auth, func(user *authsession.Identity) error { + if user.Subject == conf.AdminSubject { + user.Claims.Add("admin", "true") + } + return nil + }).Wrap(handler) } else { base := handler handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -134,11 +144,18 @@ func main() { }) } + publicRouter := mux.NewRouter() + publicRouter.Handle("/health", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + fmt.Fprintf(w, "ok") + })).Methods("GET") + publicRouter.NotFoundHandler = handler + // Listen address := "0.0.0.0:8000" srv := &http.Server{ Addr: address, - Handler: handler, + Handler: publicRouter, } // Start Web server diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..344ea9c --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,32 @@ +site_name: wg-access-server +strict: true +repo_name: place1/wg-access-server +repo_url: https://github.com/place1/wg-access-server +site_url: https://place1.github.io/wg-access-server + +theme: + name: material + include_sidebar: true + logo: + icon: cloud_queue # globe icon + feature: + tabs: false + palette: + primary: light blue + accent: teal + +markdown_extensions: + - admonition + - codehilite + - pymdownx.inlinehilite + - pymdownx.superfences + - markdown_include.include: + base_path: docs + - meta + - pymdownx.tasklist: + custom_checkbox: true + - toc: + permalink: " ΒΆ" + +plugins: + - search diff --git a/internal/auth/authconfig/authconfig.go b/pkg/authnz/authconfig/authconfig.go similarity index 76% rename from internal/auth/authconfig/authconfig.go rename to pkg/authnz/authconfig/authconfig.go index 38b3056..c8d1743 100644 --- a/internal/auth/authconfig/authconfig.go +++ b/pkg/authnz/authconfig/authconfig.go @@ -1,7 +1,7 @@ package authconfig import ( - "github.com/place1/wg-access-server/internal/auth/authruntime" + "github.com/place1/wg-access-server/pkg/authnz/authruntime" ) type AuthConfig struct { @@ -10,6 +10,10 @@ type AuthConfig struct { Basic *BasicAuthConfig `yaml:"basic"` } +func (c *AuthConfig) IsEnabled() bool { + return c.OIDC != nil || c.Gitlab != nil || c.Basic != nil +} + func (c *AuthConfig) Providers() []*authruntime.Provider { providers := []*authruntime.Provider{} diff --git a/internal/auth/authconfig/basic.go b/pkg/authnz/authconfig/basic.go similarity index 88% rename from internal/auth/authconfig/basic.go rename to pkg/authnz/authconfig/basic.go index 00d1f50..63d1e5d 100644 --- a/internal/auth/authconfig/basic.go +++ b/pkg/authnz/authconfig/basic.go @@ -5,8 +5,8 @@ import ( "net/http" "strings" - "github.com/place1/wg-access-server/internal/auth/authruntime" - "github.com/place1/wg-access-server/internal/auth/authsession" + "github.com/place1/wg-access-server/pkg/authnz/authruntime" + "github.com/place1/wg-access-server/pkg/authnz/authsession" "github.com/tg123/go-htpasswd" ) @@ -43,9 +43,13 @@ func basicAuthLogin(c *BasicAuthConfig, runtime *authruntime.ProviderRuntime) ht Subject: u, }, }) + runtime.Done(w, r) } - runtime.Done(w, r) + w.Header().Set("WWW-Authenticate", `Basic realm="site"`) + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintln(w, "unauthorized") + return } } diff --git a/internal/auth/authconfig/gitlab.go b/pkg/authnz/authconfig/gitlab.go similarity index 90% rename from internal/auth/authconfig/gitlab.go rename to pkg/authnz/authconfig/gitlab.go index 51c2a3d..e1223ef 100644 --- a/internal/auth/authconfig/gitlab.go +++ b/pkg/authnz/authconfig/gitlab.go @@ -1,6 +1,6 @@ package authconfig -import "github.com/place1/wg-access-server/internal/auth/authruntime" +import "github.com/place1/wg-access-server/pkg/authnz/authruntime" type GitlabConfig struct { Name string `yaml:"name"` diff --git a/internal/auth/authconfig/oidc.go b/pkg/authnz/authconfig/oidc.go similarity index 94% rename from internal/auth/authconfig/oidc.go rename to pkg/authnz/authconfig/oidc.go index 098277c..e340209 100644 --- a/internal/auth/authconfig/oidc.go +++ b/pkg/authnz/authconfig/oidc.go @@ -10,9 +10,9 @@ import ( "github.com/coreos/go-oidc" "github.com/gorilla/mux" "github.com/pkg/errors" - "github.com/place1/wg-access-server/internal/auth/authruntime" - "github.com/place1/wg-access-server/internal/auth/authsession" - "github.com/place1/wg-access-server/internal/auth/authutil" + "github.com/place1/wg-access-server/pkg/authnz/authruntime" + "github.com/place1/wg-access-server/pkg/authnz/authsession" + "github.com/place1/wg-access-server/pkg/authnz/authutil" "github.com/sirupsen/logrus" "golang.org/x/oauth2" ) diff --git a/internal/auth/authruntime/runtime.go b/pkg/authnz/authruntime/runtime.go similarity index 94% rename from internal/auth/authruntime/runtime.go rename to pkg/authnz/authruntime/runtime.go index 1a82abf..ff9eee4 100644 --- a/internal/auth/authruntime/runtime.go +++ b/pkg/authnz/authruntime/runtime.go @@ -5,7 +5,7 @@ import ( "github.com/gorilla/mux" "github.com/gorilla/sessions" - "github.com/place1/wg-access-server/internal/auth/authsession" + "github.com/place1/wg-access-server/pkg/authnz/authsession" ) type Provider struct { diff --git a/pkg/authnz/authsession/claims.go b/pkg/authnz/authsession/claims.go new file mode 100644 index 0000000..9e3be70 --- /dev/null +++ b/pkg/authnz/authsession/claims.go @@ -0,0 +1,35 @@ +package authsession + +type claim struct { + Name string + Value string +} + +type Claims []claim + +func (c *Claims) Add(name string, value string) { + *c = append(*c, claim{ + Name: name, + Value: value, + }) +} + +func (c *Claims) Contains(claim string) bool { + for _, curr := range *c { + if curr.Name == claim { + return true + } + } + return false +} + +func (c *Claims) Has(claim string, value string) bool { + for _, curr := range *c { + if curr.Name == claim { + if curr.Value == value { + return true + } + } + } + return false +} diff --git a/pkg/authnz/authsession/identity.go b/pkg/authnz/authsession/identity.go new file mode 100644 index 0000000..f8bcfb8 --- /dev/null +++ b/pkg/authnz/authsession/identity.go @@ -0,0 +1,6 @@ +package authsession + +type Identity struct { + Subject string + Claims Claims +} diff --git a/pkg/authnz/authsession/middleware.go b/pkg/authnz/authsession/middleware.go new file mode 100644 index 0000000..4ea0438 --- /dev/null +++ b/pkg/authnz/authsession/middleware.go @@ -0,0 +1,3 @@ +package authsession + +type ClaimsMiddleware func(user *Identity) error diff --git a/internal/auth/authsession/session.go b/pkg/authnz/authsession/session.go similarity index 97% rename from internal/auth/authsession/session.go rename to pkg/authnz/authsession/session.go index b079373..892f870 100644 --- a/internal/auth/authsession/session.go +++ b/pkg/authnz/authsession/session.go @@ -15,10 +15,6 @@ type AuthSession struct { Identity *Identity } -type Identity struct { - Subject string -} - type authSessionKey string var sessionKey authSessionKey = "auth-session" diff --git a/internal/auth/authtemplates/login.go b/pkg/authnz/authtemplates/login.go similarity index 97% rename from internal/auth/authtemplates/login.go rename to pkg/authnz/authtemplates/login.go index b70aa98..2e70c86 100644 --- a/internal/auth/authtemplates/login.go +++ b/pkg/authnz/authtemplates/login.go @@ -4,7 +4,7 @@ import ( "html/template" "io" - "github.com/place1/wg-access-server/internal/auth/authruntime" + "github.com/place1/wg-access-server/pkg/authnz/authruntime" ) type LoginPage struct { diff --git a/internal/auth/authutil/random.go b/pkg/authnz/authutil/random.go similarity index 100% rename from internal/auth/authutil/random.go rename to pkg/authnz/authutil/random.go diff --git a/internal/auth/router.go b/pkg/authnz/router.go similarity index 64% rename from internal/auth/router.go rename to pkg/authnz/router.go index a886510..0105141 100644 --- a/internal/auth/router.go +++ b/pkg/authnz/router.go @@ -1,26 +1,30 @@ -package auth +package authnz import ( "fmt" "net/http" "strconv" - "github.com/place1/wg-access-server/internal/auth/authconfig" - "github.com/place1/wg-access-server/internal/auth/authruntime" - "github.com/place1/wg-access-server/internal/auth/authsession" - "github.com/place1/wg-access-server/internal/auth/authtemplates" - "github.com/place1/wg-access-server/internal/auth/authutil" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "github.com/place1/wg-access-server/pkg/authnz/authconfig" + "github.com/place1/wg-access-server/pkg/authnz/authruntime" + "github.com/place1/wg-access-server/pkg/authnz/authsession" + "github.com/place1/wg-access-server/pkg/authnz/authtemplates" + "github.com/place1/wg-access-server/pkg/authnz/authutil" "github.com/gorilla/mux" "github.com/gorilla/sessions" ) type AuthMiddleware struct { - config *authconfig.AuthConfig + config authconfig.AuthConfig + claimsMiddleware authsession.ClaimsMiddleware } -func New(config *authconfig.AuthConfig) *AuthMiddleware { - return &AuthMiddleware{config} +func New(config authconfig.AuthConfig, claimsMiddleware authsession.ClaimsMiddleware) *AuthMiddleware { + return &AuthMiddleware{config, claimsMiddleware} } func (m *AuthMiddleware) Wrap(next http.Handler) http.Handler { @@ -61,6 +65,13 @@ func (m *AuthMiddleware) Wrap(next http.Handler) http.Handler { router.PathPrefix("/").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s, err := runtime.GetSession(r); err == nil { + if m.claimsMiddleware != nil { + if err := m.claimsMiddleware(s.Identity); err != nil { + logrus.Error(errors.Wrap(err, "authz middleware failure")) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + } next.ServeHTTP(w, r.WithContext(authsession.SetIdentityCtx(r.Context(), s))) } else { next.ServeHTTP(w, r) diff --git a/proto/devices.proto b/proto/devices.proto index 17bada3..a46cadc 100644 --- a/proto/devices.proto +++ b/proto/devices.proto @@ -9,6 +9,9 @@ service Devices { rpc AddDevice(AddDeviceReq) returns (Device) {} rpc ListDevices(ListDevicesReq) returns (ListDevicesRes) {} rpc DeleteDevice(DeleteDeviceReq) returns (google.protobuf.Empty) {} + + // admin only + rpc ListAllDevices(ListAllDevicesReq) returns (ListAllDevicesRes) {} } message Device { @@ -17,6 +20,11 @@ message Device { string public_key = 3; string address = 4; google.protobuf.Timestamp created_at = 5; + bool connected = 6; + google.protobuf.Timestamp last_handshake_time = 7; + int64 receive_bytes = 8; + int64 transmit_bytes = 9; + string endpoint = 10; } message AddDeviceReq { @@ -35,3 +43,11 @@ message ListDevicesRes { message DeleteDeviceReq { string name = 1; } + +message ListAllDevicesReq { + +} + +message ListAllDevicesRes { + repeated Device items = 1; +} diff --git a/proto/proto/devices.pb.go b/proto/proto/devices.pb.go index 9fbe763..fd1dc4d 100644 --- a/proto/proto/devices.pb.go +++ b/proto/proto/devices.pb.go @@ -32,6 +32,11 @@ type Device struct { PublicKey string `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` Address string `protobuf:"bytes,4,opt,name=address,proto3" json:"address,omitempty"` CreatedAt *timestamp.Timestamp `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + Connected bool `protobuf:"varint,6,opt,name=connected,proto3" json:"connected,omitempty"` + LastHandshakeTime *timestamp.Timestamp `protobuf:"bytes,7,opt,name=last_handshake_time,json=lastHandshakeTime,proto3" json:"last_handshake_time,omitempty"` + ReceiveBytes int64 `protobuf:"varint,8,opt,name=receive_bytes,json=receiveBytes,proto3" json:"receive_bytes,omitempty"` + TransmitBytes int64 `protobuf:"varint,9,opt,name=transmit_bytes,json=transmitBytes,proto3" json:"transmit_bytes,omitempty"` + Endpoint string `protobuf:"bytes,10,opt,name=endpoint,proto3" json:"endpoint,omitempty"` XXX_NoUnkeyedLiteral struct{} `json:"-"` XXX_unrecognized []byte `json:"-"` XXX_sizecache int32 `json:"-"` @@ -97,6 +102,41 @@ func (m *Device) GetCreatedAt() *timestamp.Timestamp { return nil } +func (m *Device) GetConnected() bool { + if m != nil { + return m.Connected + } + return false +} + +func (m *Device) GetLastHandshakeTime() *timestamp.Timestamp { + if m != nil { + return m.LastHandshakeTime + } + return nil +} + +func (m *Device) GetReceiveBytes() int64 { + if m != nil { + return m.ReceiveBytes + } + return 0 +} + +func (m *Device) GetTransmitBytes() int64 { + if m != nil { + return m.TransmitBytes + } + return 0 +} + +func (m *Device) GetEndpoint() string { + if m != nil { + return m.Endpoint + } + return "" +} + type AddDeviceReq struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` PublicKey string `protobuf:"bytes,2,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` @@ -253,39 +293,120 @@ func (m *DeleteDeviceReq) GetName() string { return "" } +type ListAllDevicesReq struct { + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *ListAllDevicesReq) Reset() { *m = ListAllDevicesReq{} } +func (m *ListAllDevicesReq) String() string { return proto.CompactTextString(m) } +func (*ListAllDevicesReq) ProtoMessage() {} +func (*ListAllDevicesReq) Descriptor() ([]byte, []int) { + return fileDescriptor_6d27ec3f2c0e2043, []int{5} +} + +func (m *ListAllDevicesReq) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_ListAllDevicesReq.Unmarshal(m, b) +} +func (m *ListAllDevicesReq) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_ListAllDevicesReq.Marshal(b, m, deterministic) +} +func (m *ListAllDevicesReq) XXX_Merge(src proto.Message) { + xxx_messageInfo_ListAllDevicesReq.Merge(m, src) +} +func (m *ListAllDevicesReq) XXX_Size() int { + return xxx_messageInfo_ListAllDevicesReq.Size(m) +} +func (m *ListAllDevicesReq) XXX_DiscardUnknown() { + xxx_messageInfo_ListAllDevicesReq.DiscardUnknown(m) +} + +var xxx_messageInfo_ListAllDevicesReq proto.InternalMessageInfo + +type ListAllDevicesRes struct { + Items []*Device `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *ListAllDevicesRes) Reset() { *m = ListAllDevicesRes{} } +func (m *ListAllDevicesRes) String() string { return proto.CompactTextString(m) } +func (*ListAllDevicesRes) ProtoMessage() {} +func (*ListAllDevicesRes) Descriptor() ([]byte, []int) { + return fileDescriptor_6d27ec3f2c0e2043, []int{6} +} + +func (m *ListAllDevicesRes) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_ListAllDevicesRes.Unmarshal(m, b) +} +func (m *ListAllDevicesRes) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_ListAllDevicesRes.Marshal(b, m, deterministic) +} +func (m *ListAllDevicesRes) XXX_Merge(src proto.Message) { + xxx_messageInfo_ListAllDevicesRes.Merge(m, src) +} +func (m *ListAllDevicesRes) XXX_Size() int { + return xxx_messageInfo_ListAllDevicesRes.Size(m) +} +func (m *ListAllDevicesRes) XXX_DiscardUnknown() { + xxx_messageInfo_ListAllDevicesRes.DiscardUnknown(m) +} + +var xxx_messageInfo_ListAllDevicesRes proto.InternalMessageInfo + +func (m *ListAllDevicesRes) GetItems() []*Device { + if m != nil { + return m.Items + } + return nil +} + func init() { proto.RegisterType((*Device)(nil), "proto.Device") proto.RegisterType((*AddDeviceReq)(nil), "proto.AddDeviceReq") proto.RegisterType((*ListDevicesReq)(nil), "proto.ListDevicesReq") proto.RegisterType((*ListDevicesRes)(nil), "proto.ListDevicesRes") proto.RegisterType((*DeleteDeviceReq)(nil), "proto.DeleteDeviceReq") + proto.RegisterType((*ListAllDevicesReq)(nil), "proto.ListAllDevicesReq") + proto.RegisterType((*ListAllDevicesRes)(nil), "proto.ListAllDevicesRes") } func init() { proto.RegisterFile("devices.proto", fileDescriptor_6d27ec3f2c0e2043) } var fileDescriptor_6d27ec3f2c0e2043 = []byte{ - // 332 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x7c, 0x92, 0xc1, 0x4e, 0xfa, 0x40, - 0x10, 0xc6, 0x29, 0x50, 0x48, 0x07, 0xf8, 0xff, 0xcd, 0xa8, 0x64, 0xb3, 0xc6, 0x48, 0xd6, 0x98, - 0x70, 0x2a, 0x11, 0xe3, 0xc1, 0x83, 0x89, 0x24, 0x78, 0xd2, 0x53, 0xe3, 0x9d, 0x14, 0x76, 0x24, - 0x8d, 0x94, 0x96, 0xee, 0xa2, 0xe1, 0x85, 0x7c, 0x0b, 0xdf, 0xcd, 0xb8, 0x5b, 0x08, 0xad, 0xc4, - 0xd3, 0xee, 0x7c, 0xdf, 0xe4, 0x9b, 0xdf, 0x6c, 0x16, 0x3a, 0x92, 0xde, 0xa3, 0x19, 0x29, 0x3f, - 0xcd, 0x12, 0x9d, 0xa0, 0x6b, 0x0e, 0x7e, 0x31, 0x4f, 0x92, 0xf9, 0x82, 0x06, 0xa6, 0x9a, 0xae, - 0x5f, 0x07, 0x3a, 0x8a, 0x49, 0xe9, 0x30, 0x4e, 0x6d, 0x1f, 0x3f, 0x2b, 0x37, 0x50, 0x9c, 0xea, - 0x8d, 0x35, 0xc5, 0xa7, 0x03, 0x8d, 0xb1, 0x89, 0x45, 0x84, 0xfa, 0x32, 0x8c, 0x89, 0x39, 0x3d, - 0xa7, 0xef, 0x05, 0xe6, 0x8e, 0x27, 0xe0, 0x26, 0x1f, 0x4b, 0xca, 0x58, 0xd5, 0x88, 0xb6, 0xc0, - 0x73, 0x80, 0x74, 0x3d, 0x5d, 0x44, 0xb3, 0xc9, 0x1b, 0x6d, 0x58, 0xcd, 0x58, 0x9e, 0x55, 0x9e, - 0x68, 0x83, 0x0c, 0x9a, 0xa1, 0x94, 0x19, 0x29, 0xc5, 0xea, 0xc6, 0xdb, 0x96, 0x78, 0x07, 0x30, - 0xcb, 0x28, 0xd4, 0x24, 0x27, 0xa1, 0x66, 0x6e, 0xcf, 0xe9, 0xb7, 0x86, 0xdc, 0xb7, 0x7c, 0xfe, - 0x96, 0xcf, 0x7f, 0xd9, 0x2e, 0x10, 0x78, 0x79, 0xf7, 0x48, 0x8b, 0x11, 0xb4, 0x47, 0x52, 0x5a, - 0xd4, 0x80, 0x56, 0x07, 0x69, 0x8b, 0x5c, 0xd5, 0x12, 0x97, 0x38, 0x82, 0x7f, 0xcf, 0x91, 0xd2, - 0x36, 0x43, 0x05, 0xb4, 0x12, 0xb7, 0x25, 0x45, 0xe1, 0x25, 0xb8, 0x91, 0xa6, 0x58, 0x31, 0xa7, - 0x57, 0xeb, 0xb7, 0x86, 0x1d, 0x4b, 0xe5, 0xe7, 0x73, 0xad, 0x27, 0xae, 0xe0, 0xff, 0x98, 0x16, - 0xa4, 0xe9, 0x4f, 0x9c, 0xe1, 0x97, 0x03, 0xcd, 0x3c, 0x1a, 0xaf, 0xc1, 0xdb, 0xe1, 0xe3, 0x71, - 0x9e, 0xba, 0xbf, 0x10, 0x2f, 0x8e, 0x12, 0x15, 0xbc, 0x87, 0xd6, 0x1e, 0x1c, 0x9e, 0xe6, 0x7e, - 0x71, 0x05, 0x7e, 0x50, 0x56, 0xa2, 0x82, 0x0f, 0xd0, 0xde, 0x87, 0xc4, 0xee, 0x2e, 0xbf, 0x40, - 0xce, 0xbb, 0xbf, 0xde, 0xff, 0xf1, 0xe7, 0x7f, 0x88, 0xca, 0xb4, 0x61, 0x94, 0x9b, 0xef, 0x00, - 0x00, 0x00, 0xff, 0xff, 0x55, 0x8e, 0x8a, 0x9f, 0x78, 0x02, 0x00, 0x00, + // 467 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x8c, 0x93, 0xcb, 0x6f, 0xd3, 0x40, + 0x10, 0xc6, 0xe3, 0xa4, 0x79, 0x78, 0x92, 0x14, 0x3a, 0x81, 0x6a, 0x65, 0x40, 0x58, 0x5b, 0x55, + 0xf2, 0xc9, 0x15, 0x41, 0x48, 0x70, 0x40, 0x22, 0xa8, 0x20, 0x04, 0x9c, 0x2c, 0xee, 0x96, 0x63, + 0x0f, 0xad, 0x55, 0xbf, 0xea, 0xdd, 0x16, 0xe5, 0x0f, 0xe0, 0x7f, 0xe6, 0x88, 0xbc, 0x6b, 0xa7, + 0x76, 0x1a, 0x1e, 0x27, 0x7b, 0xbe, 0xef, 0xdb, 0x99, 0x9d, 0x9f, 0x6c, 0x98, 0x47, 0x74, 0x1b, + 0x87, 0x24, 0xdc, 0xa2, 0xcc, 0x65, 0x8e, 0x43, 0xf5, 0xb0, 0x9e, 0x5f, 0xe4, 0xf9, 0x45, 0x42, + 0x67, 0xaa, 0x5a, 0xdf, 0x7c, 0x3f, 0x93, 0x71, 0x4a, 0x42, 0x06, 0x69, 0xa1, 0x73, 0xd6, 0x93, + 0xdd, 0x00, 0xa5, 0x85, 0xdc, 0x68, 0x93, 0xff, 0xea, 0xc3, 0xe8, 0x5c, 0xb5, 0x45, 0x84, 0x83, + 0x2c, 0x48, 0x89, 0x19, 0xb6, 0xe1, 0x98, 0x9e, 0x7a, 0xc7, 0x47, 0x30, 0xcc, 0x7f, 0x64, 0x54, + 0xb2, 0xbe, 0x12, 0x75, 0x81, 0xcf, 0x00, 0x8a, 0x9b, 0x75, 0x12, 0x87, 0xfe, 0x15, 0x6d, 0xd8, + 0x40, 0x59, 0xa6, 0x56, 0xbe, 0xd0, 0x06, 0x19, 0x8c, 0x83, 0x28, 0x2a, 0x49, 0x08, 0x76, 0xa0, + 0xbc, 0xa6, 0xc4, 0x37, 0x00, 0x61, 0x49, 0x81, 0xa4, 0xc8, 0x0f, 0x24, 0x1b, 0xda, 0x86, 0x33, + 0x5d, 0x5a, 0xae, 0xbe, 0x9f, 0xdb, 0xdc, 0xcf, 0xfd, 0xd6, 0x2c, 0xe0, 0x99, 0x75, 0x7a, 0x25, + 0xf1, 0x29, 0x98, 0x61, 0x9e, 0x65, 0x14, 0x4a, 0x8a, 0xd8, 0xc8, 0x36, 0x9c, 0x89, 0x77, 0x27, + 0xe0, 0x67, 0x58, 0x24, 0x81, 0x90, 0xfe, 0x65, 0x90, 0x45, 0xe2, 0x32, 0xb8, 0x22, 0xbf, 0xa2, + 0xc0, 0xc6, 0xff, 0x9c, 0x70, 0x54, 0x1d, 0xfb, 0xd4, 0x9c, 0xaa, 0x74, 0x3c, 0x81, 0x79, 0x49, + 0x21, 0xc5, 0xb7, 0xe4, 0xaf, 0x37, 0x92, 0x04, 0x9b, 0xd8, 0x86, 0x33, 0xf0, 0x66, 0xb5, 0xf8, + 0xbe, 0xd2, 0xf0, 0x14, 0x0e, 0x65, 0x19, 0x64, 0x22, 0x8d, 0x65, 0x9d, 0x32, 0x55, 0x6a, 0xde, + 0xa8, 0x3a, 0x66, 0xc1, 0x84, 0xb2, 0xa8, 0xc8, 0xe3, 0x4c, 0x32, 0x50, 0x2c, 0xb6, 0x35, 0x5f, + 0xc1, 0x6c, 0x15, 0x45, 0x1a, 0xbe, 0x47, 0xd7, 0x7b, 0xf9, 0x77, 0x49, 0xf7, 0x77, 0x48, 0xf3, + 0x87, 0x70, 0xf8, 0x35, 0x16, 0x52, 0xf7, 0x10, 0x1e, 0x5d, 0xf3, 0x57, 0x3b, 0x8a, 0xc0, 0x13, + 0x18, 0xc6, 0x92, 0x52, 0xc1, 0x0c, 0x7b, 0xe0, 0x4c, 0x97, 0x73, 0x4d, 0xc1, 0xad, 0xe7, 0x6a, + 0x8f, 0x9f, 0xc2, 0x83, 0x73, 0x4a, 0x48, 0xd2, 0x5f, 0xaf, 0xc3, 0x17, 0x70, 0x54, 0x75, 0x5f, + 0x25, 0x49, 0x6b, 0xe4, 0xeb, 0xfb, 0xe2, 0xff, 0x4d, 0x5d, 0xfe, 0xec, 0xc3, 0xb8, 0x3e, 0x83, + 0x2f, 0xc0, 0xdc, 0xd2, 0xc0, 0x45, 0x1d, 0x6f, 0xf3, 0xb1, 0xba, 0x3d, 0x78, 0x0f, 0xdf, 0xc2, + 0xb4, 0xb5, 0x2b, 0x3e, 0xae, 0xfd, 0x2e, 0x11, 0x6b, 0xaf, 0x2c, 0x78, 0x0f, 0xdf, 0xc1, 0xac, + 0xbd, 0x33, 0x1e, 0x6f, 0xfb, 0x77, 0x40, 0x58, 0xc7, 0xf7, 0x3e, 0x9f, 0x0f, 0xd5, 0x0f, 0xc4, + 0x7b, 0xf8, 0x51, 0xc3, 0xbe, 0xdb, 0x1c, 0x59, 0x6b, 0x58, 0x87, 0x92, 0xf5, 0x27, 0x47, 0xf0, + 0xde, 0x7a, 0xa4, 0xac, 0x97, 0xbf, 0x03, 0x00, 0x00, 0xff, 0xff, 0x2f, 0x41, 0xb7, 0x19, 0xe1, + 0x03, 0x00, 0x00, } // Reference imports to suppress errors if they are not otherwise used. @@ -303,6 +424,8 @@ type DevicesClient interface { AddDevice(ctx context.Context, in *AddDeviceReq, opts ...grpc.CallOption) (*Device, error) ListDevices(ctx context.Context, in *ListDevicesReq, opts ...grpc.CallOption) (*ListDevicesRes, error) DeleteDevice(ctx context.Context, in *DeleteDeviceReq, opts ...grpc.CallOption) (*empty.Empty, error) + // admin only + ListAllDevices(ctx context.Context, in *ListAllDevicesReq, opts ...grpc.CallOption) (*ListAllDevicesRes, error) } type devicesClient struct { @@ -340,11 +463,22 @@ func (c *devicesClient) DeleteDevice(ctx context.Context, in *DeleteDeviceReq, o return out, nil } +func (c *devicesClient) ListAllDevices(ctx context.Context, in *ListAllDevicesReq, opts ...grpc.CallOption) (*ListAllDevicesRes, error) { + out := new(ListAllDevicesRes) + err := c.cc.Invoke(ctx, "/proto.Devices/ListAllDevices", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + // DevicesServer is the server API for Devices service. type DevicesServer interface { AddDevice(context.Context, *AddDeviceReq) (*Device, error) ListDevices(context.Context, *ListDevicesReq) (*ListDevicesRes, error) DeleteDevice(context.Context, *DeleteDeviceReq) (*empty.Empty, error) + // admin only + ListAllDevices(context.Context, *ListAllDevicesReq) (*ListAllDevicesRes, error) } // UnimplementedDevicesServer can be embedded to have forward compatible implementations. @@ -360,6 +494,9 @@ func (*UnimplementedDevicesServer) ListDevices(ctx context.Context, req *ListDev func (*UnimplementedDevicesServer) DeleteDevice(ctx context.Context, req *DeleteDeviceReq) (*empty.Empty, error) { return nil, status.Errorf(codes.Unimplemented, "method DeleteDevice not implemented") } +func (*UnimplementedDevicesServer) ListAllDevices(ctx context.Context, req *ListAllDevicesReq) (*ListAllDevicesRes, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListAllDevices not implemented") +} func RegisterDevicesServer(s *grpc.Server, srv DevicesServer) { s.RegisterService(&_Devices_serviceDesc, srv) @@ -419,6 +556,24 @@ func _Devices_DeleteDevice_Handler(srv interface{}, ctx context.Context, dec fun return interceptor(ctx, in, info, handler) } +func _Devices_ListAllDevices_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListAllDevicesReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DevicesServer).ListAllDevices(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/proto.Devices/ListAllDevices", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DevicesServer).ListAllDevices(ctx, req.(*ListAllDevicesReq)) + } + return interceptor(ctx, in, info, handler) +} + var _Devices_serviceDesc = grpc.ServiceDesc{ ServiceName: "proto.Devices", HandlerType: (*DevicesServer)(nil), @@ -435,6 +590,10 @@ var _Devices_serviceDesc = grpc.ServiceDesc{ MethodName: "DeleteDevice", Handler: _Devices_DeleteDevice_Handler, }, + { + MethodName: "ListAllDevices", + Handler: _Devices_ListAllDevices_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "devices.proto", diff --git a/proto/proto/server.pb.go b/proto/proto/server.pb.go index e6e78bf..1d11596 100644 --- a/proto/proto/server.pb.go +++ b/proto/proto/server.pb.go @@ -61,6 +61,8 @@ type InfoRes struct { Host *wrappers.StringValue `protobuf:"bytes,2,opt,name=host,proto3" json:"host,omitempty"` Port int32 `protobuf:"varint,3,opt,name=port,proto3" json:"port,omitempty"` HostVpnIp string `protobuf:"bytes,4,opt,name=host_vpn_ip,json=hostVpnIp,proto3" json:"host_vpn_ip,omitempty"` + MetadataEnabled bool `protobuf:"varint,5,opt,name=metadata_enabled,json=metadataEnabled,proto3" json:"metadata_enabled,omitempty"` + IsAdmin bool `protobuf:"varint,6,opt,name=is_admin,json=isAdmin,proto3" json:"is_admin,omitempty"` XXX_NoUnkeyedLiteral struct{} `json:"-"` XXX_unrecognized []byte `json:"-"` XXX_sizecache int32 `json:"-"` @@ -119,6 +121,20 @@ func (m *InfoRes) GetHostVpnIp() string { return "" } +func (m *InfoRes) GetMetadataEnabled() bool { + if m != nil { + return m.MetadataEnabled + } + return false +} + +func (m *InfoRes) GetIsAdmin() bool { + if m != nil { + return m.IsAdmin + } + return false +} + func init() { proto.RegisterType((*InfoReq)(nil), "proto.InfoReq") proto.RegisterType((*InfoRes)(nil), "proto.InfoRes") @@ -127,21 +143,24 @@ func init() { func init() { proto.RegisterFile("server.proto", fileDescriptor_ad098daeda4239f7) } var fileDescriptor_ad098daeda4239f7 = []byte{ - // 217 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x54, 0x8f, 0xc1, 0x4e, 0xc3, 0x30, - 0x10, 0x44, 0x31, 0xa4, 0x45, 0xd9, 0x22, 0x0e, 0x7b, 0xb2, 0x2a, 0xa8, 0xa2, 0x9c, 0x7c, 0x72, - 0x51, 0xf8, 0x8a, 0x8a, 0x9b, 0x2b, 0xf5, 0x1a, 0x35, 0x68, 0x1b, 0x22, 0x22, 0x7b, 0xb1, 0x9d, - 0xa2, 0xfe, 0x04, 0xdf, 0x8c, 0x62, 0xd3, 0x03, 0x27, 0xcf, 0x8c, 0x47, 0x7a, 0xb3, 0xf0, 0x10, - 0xc8, 0x9f, 0xc9, 0x6b, 0xf6, 0x2e, 0x3a, 0x5c, 0xa4, 0x67, 0xbd, 0xe9, 0x9d, 0xeb, 0x47, 0xda, - 0x26, 0xd7, 0x4d, 0xa7, 0xed, 0xb7, 0x3f, 0x32, 0x93, 0x0f, 0xb9, 0x56, 0x97, 0x70, 0xbf, 0xb3, - 0x27, 0x67, 0xe8, 0xab, 0xfe, 0x11, 0x57, 0x1d, 0xf0, 0x19, 0x80, 0xa7, 0x6e, 0x1c, 0xde, 0xdb, - 0x4f, 0xba, 0x48, 0x51, 0x09, 0x55, 0x9a, 0x32, 0x27, 0x6f, 0x74, 0xc1, 0x17, 0x28, 0x3e, 0x5c, - 0x88, 0xf2, 0xb6, 0x12, 0x6a, 0xd5, 0x3c, 0xe9, 0x0c, 0xd1, 0x57, 0x88, 0xde, 0x47, 0x3f, 0xd8, - 0xfe, 0x70, 0x1c, 0x27, 0x32, 0xa9, 0x89, 0x08, 0x05, 0x3b, 0x1f, 0xe5, 0x5d, 0x25, 0xd4, 0xc2, - 0x24, 0x8d, 0x1b, 0x58, 0xcd, 0x7f, 0xed, 0x99, 0x6d, 0x3b, 0xb0, 0x2c, 0x32, 0x65, 0x8e, 0x0e, - 0x6c, 0x77, 0xdc, 0x34, 0xb0, 0xdc, 0xa7, 0x93, 0x50, 0x41, 0x31, 0x2f, 0xc3, 0xc7, 0x8c, 0xd0, - 0x7f, 0x93, 0xd7, 0xff, 0x7d, 0xa8, 0x6f, 0xba, 0x65, 0x0a, 0x5e, 0x7f, 0x03, 0x00, 0x00, 0xff, - 0xff, 0x4d, 0xc6, 0xae, 0x09, 0x0d, 0x01, 0x00, 0x00, + // 263 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x54, 0x90, 0xb1, 0x4e, 0xf3, 0x30, + 0x1c, 0xc4, 0x3f, 0x7f, 0xa4, 0x69, 0xf3, 0x2f, 0x02, 0xe4, 0xc9, 0x54, 0x50, 0x45, 0x99, 0xc2, + 0x92, 0xa2, 0xf0, 0x04, 0x0c, 0x0c, 0x15, 0x5b, 0x2a, 0x75, 0x8d, 0x1c, 0xf2, 0x6f, 0xb0, 0x48, + 0x6d, 0x63, 0x3b, 0x45, 0x7d, 0x4f, 0x1e, 0x08, 0xc5, 0x26, 0x03, 0x93, 0xef, 0x7e, 0x3e, 0xe9, + 0x7c, 0x86, 0x4b, 0x8b, 0xe6, 0x84, 0xa6, 0xd0, 0x46, 0x39, 0x45, 0x67, 0xfe, 0x58, 0xad, 0x3b, + 0xa5, 0xba, 0x1e, 0x37, 0xde, 0x35, 0xc3, 0x61, 0xf3, 0x65, 0xb8, 0xd6, 0x68, 0x6c, 0x88, 0x65, + 0x09, 0xcc, 0xb7, 0xf2, 0xa0, 0x2a, 0xfc, 0xcc, 0xbe, 0xc9, 0xa4, 0x2d, 0xbd, 0x07, 0xd0, 0x43, + 0xd3, 0x8b, 0xb7, 0xfa, 0x03, 0xcf, 0x8c, 0xa4, 0x24, 0x4f, 0xaa, 0x24, 0x90, 0x57, 0x3c, 0xd3, + 0x47, 0x88, 0xde, 0x95, 0x75, 0xec, 0x7f, 0x4a, 0xf2, 0x65, 0x79, 0x57, 0x84, 0x92, 0x62, 0x2a, + 0x29, 0x76, 0xce, 0x08, 0xd9, 0xed, 0x79, 0x3f, 0x60, 0xe5, 0x93, 0x94, 0x42, 0xa4, 0x95, 0x71, + 0xec, 0x22, 0x25, 0xf9, 0xac, 0xf2, 0x9a, 0xae, 0x61, 0x39, 0xde, 0xd5, 0x27, 0x2d, 0x6b, 0xa1, + 0x59, 0x14, 0x5a, 0x46, 0xb4, 0xd7, 0x72, 0xab, 0xe9, 0x03, 0xdc, 0x1c, 0xd1, 0xf1, 0x96, 0x3b, + 0x5e, 0xa3, 0xe4, 0x4d, 0x8f, 0x2d, 0x9b, 0xa5, 0x24, 0x5f, 0x54, 0xd7, 0x13, 0x7f, 0x09, 0x98, + 0xde, 0xc2, 0x42, 0xd8, 0x9a, 0xb7, 0x47, 0x21, 0x59, 0xec, 0x23, 0x73, 0x61, 0x9f, 0x47, 0x5b, + 0x96, 0x10, 0xef, 0xfc, 0xc7, 0xd0, 0x1c, 0xa2, 0x71, 0x1f, 0xbd, 0x0a, 0x0f, 0x2d, 0x7e, 0x87, + 0xaf, 0xfe, 0x7a, 0x9b, 0xfd, 0x6b, 0x62, 0x0f, 0x9e, 0x7e, 0x02, 0x00, 0x00, 0xff, 0xff, 0x55, + 0xd5, 0x11, 0x8a, 0x53, 0x01, 0x00, 0x00, } // Reference imports to suppress errors if they are not otherwise used. diff --git a/proto/server.proto b/proto/server.proto index ab97418..4b260e8 100644 --- a/proto/server.proto +++ b/proto/server.proto @@ -17,4 +17,6 @@ message InfoRes { google.protobuf.StringValue host = 2; int32 port = 3; string host_vpn_ip = 4; + bool metadata_enabled = 5; + bool is_admin = 6; } diff --git a/publish.py b/publish.py index 6130c94..8e9d0cc 100755 --- a/publish.py +++ b/publish.py @@ -4,41 +4,46 @@ import subprocess import json import yaml +def is_release_candidate(version): + return '-rc' in version + # print the latest tags so we don't have to google our own # image to check :P r = urllib.request.urlopen('https://registry.hub.docker.com/v2/repositories/place1/wg-access-server/tags?page_size=10') \ .read() \ .decode('utf-8') tags = json.loads(r).get('results', []) -print('current docker tags:', sorted( - [t.get('name') for t in tags], reverse=True)) +print('current docker tags:', sorted([t.get('name') for t in tags], reverse=True)) # tag the new image version = input('Version: ') docker_tag = f"place1/wg-access-server:{version}" -# subprocess.run(['docker', 'build', '-t', docker_tag, '.']) +subprocess.run(['docker', 'build', '-t', docker_tag, '.']) # update the helm chart and quickstart manifest -with open('deploy/helm/wg-access-server/Chart.yaml', 'r+') as f: - chart = yaml.load(f) - chart['version'] = version - chart['appVersion'] = version - f.seek(0) - yaml.dump(chart, f, default_flow_style=False) - f.truncate() -with open('deploy/k8s/quickstart.yaml', 'w') as f: - subprocess.run(['helm', 'template', '--name-template', - 'quickstart', 'deploy/helm/wg-access-server/'], stdout=f) -subprocess.run(['helm', 'package', 'deploy/helm/wg-access-server/', - '--destination', 'docs/charts/']) -subprocess.run(['helm', 'repo', 'index', 'docs/', '--url', - 'https://place1.github.io/wg-access-server']) +if not is_release_candidate(version): + with open('deploy/helm/wg-access-server/Chart.yaml', 'r+') as f: + chart = yaml.load(f) + chart['version'] = version + chart['appVersion'] = version + f.seek(0) + yaml.dump(chart, f, default_flow_style=False) + f.truncate() + with open('deploy/k8s/quickstart.yaml', 'w') as f: + subprocess.run(['helm', 'template', '--name-template', + 'quickstart', 'deploy/helm/wg-access-server/'], stdout=f) + subprocess.run(['helm', 'package', 'deploy/helm/wg-access-server/', + '--destination', 'docs/charts/']) + subprocess.run(['helm', 'repo', 'index', 'docs/', '--url', + 'https://place1.github.io/wg-access-server']) # commit changes -subprocess.run(['git', 'add', 'deploy']) +if not is_release_candidate(version): + subprocess.run(['git', 'add', 'deploy']) # tag the current commit -subprocess.run(['git', 'tag', '-a', f'v{version}', '-m', f'v{version}']) +if not is_release_candidate(version): + subprocess.run(['git', 'tag', '-a', f'v{version}', '-m', f'v{version}']) # push everything subprocess.run(['git', 'push']) diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 0000000..67428a4 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,5 @@ +mkdocs-material==4.6.3 +mkdocs==1.1 +pymdown-extensions==6.3 +pygments~=2.5.2 +markdown-include==0.5.1 diff --git a/screenshots/devices.png b/screenshots/devices.png index 33e7d16c295af4182e9be8905d0e3f6c00b3a39b..6bc290ee5beb598f60836be9acd330c36625cc5f 100644 GIT binary patch literal 89227 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 diff --git a/website/package-lock.json b/website/package-lock.json index a407c1a..c97a003 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -1364,11 +1364,6 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz", "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==" }, - "@nx-js/observer-util": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@nx-js/observer-util/-/observer-util-4.2.2.tgz", - "integrity": "sha512-9OayX1xkdGjdnsDiO2YdaYJ6aMyCF7/NY4QWVgIgjSAZJ4OX2fD766Ts79hEzBscenQy2DCaSoY8VkguIMB1ZA==" - }, "@svgr/babel-plugin-add-jsx-attribute": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-4.2.0.tgz", @@ -1547,6 +1542,12 @@ "integrity": "sha512-ifFemzjNchFBCtHS6bZNhSZCBu7tbtOe0e8qY0z2J4HtFXmPJjm6fXSaQsTG7yhShBEZtt2oP/bkwu5k+emlkQ==", "dev": true }, + "@types/history": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.5.tgz", + "integrity": "sha512-wLD/Aq2VggCJXSjxEwrMafIP51Z+13H78nXIX0ABEuIGhmB5sNGbR113MOKo+yfw+RDo1ZU3DM6yfnnRF/+ouw==", + "dev": true + }, "@types/istanbul-lib-coverage": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", @@ -1592,6 +1593,12 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-13.5.0.tgz", "integrity": "sha512-Onhn+z72D2O2Pb2ql2xukJ55rglumsVo1H6Fmyi8mlU9SvKdBk/pUSUAiBY/d9bAOF7VVWajX3sths/+g6ZiAQ==" }, + "@types/numeral": { + "version": "0.0.26", + "resolved": "https://registry.npmjs.org/@types/numeral/-/numeral-0.0.26.tgz", + "integrity": "sha512-DwCsRqeOWopdEsm5KLTxKVKDSDoj+pzZD1vlwu1GQJ6IF3RhjuleYlRwyRH6MJLGaf3v8wFTnC6wo3yYfz0bnA==", + "dev": true + }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -1633,6 +1640,27 @@ "@types/react": "*" } }, + "@types/react-router": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.4.tgz", + "integrity": "sha512-PZtnBuyfL07sqCJvGg3z+0+kt6fobc/xmle08jBiezLS8FrmGeiGkJnuxL/8Zgy9L83ypUhniV5atZn/L8n9MQ==", + "dev": true, + "requires": { + "@types/history": "*", + "@types/react": "*" + } + }, + "@types/react-router-dom": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.3.tgz", + "integrity": "sha512-pCq7AkOvjE65jkGS5fQwQhvUp4+4PVD9g39gXLZViP2UqFiFzsEpB3PKf0O6mdbKsewSK8N14/eegisa/0CwnA==", + "dev": true, + "requires": { + "@types/history": "*", + "@types/react": "*", + "@types/react-router": "*" + } + }, "@types/react-transition-group": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.2.3.tgz", @@ -4465,9 +4493,9 @@ } }, "date-fns": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.9.0.tgz", - "integrity": "sha512-khbFLu/MlzLjEzy9Gh8oY1hNt/Dvxw3J6Rbc28cVoYWQaC1S3YI4xwkF9ZWcjDLscbZlY9hISMr66RFzZagLsA==" + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.10.0.tgz", + "integrity": "sha512-EhfEKevYGWhWlZbNeplfhIU/+N+x0iCIx7VzKlXma2EdQyznVlZhCptXUY+BegNpPW2kjdx15Rvq503YcXXrcA==" }, "debug": { "version": "4.1.1", @@ -6412,6 +6440,11 @@ "integrity": "sha512-Fkbz1nyvvt6GC6ODcxh9Fen6LLB3OTCgGHzHwM2Eni44SUhzqPz1UQgFp9sfBEfInOhx3yBdwo9ZLjZAmJ+TtA==", "dev": true }, + "gud": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz", + "integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw==" + }, "gzip-size": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-5.1.1.tgz", @@ -6542,6 +6575,19 @@ "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz", "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==" }, + "history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "requires": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, "hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -8999,6 +9045,16 @@ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" }, + "mini-create-react-context": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.3.2.tgz", + "integrity": "sha512-2v+OeetEyliMt5VHMXsBhABoJ0/M4RCe7fatd/fBy6SMiKazUSEt3gxxypfnk2SHMkdBYvorHRoQxuGoiwbzAw==", + "requires": { + "@babel/runtime": "^7.4.0", + "gud": "^1.0.0", + "tiny-warning": "^1.0.2" + } + }, "mini-css-extract-plugin": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.8.0.tgz", @@ -9144,6 +9200,29 @@ } } }, + "mobx": { + "version": "5.15.4", + "resolved": "https://registry.npmjs.org/mobx/-/mobx-5.15.4.tgz", + "integrity": "sha512-xRFJxSU2Im3nrGCdjSuOTFmxVDGeqOHL+TyADCGbT0k4HHqGmx5u2yaHNryvoORpI4DfbzjJ5jPmuv+d7sioFw==" + }, + "mobx-react": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/mobx-react/-/mobx-react-6.1.8.tgz", + "integrity": "sha512-NCMJn/hrWoeyeNbzCsBDtftWSy6VlFgw1VzhogrciPFvJIl2xs+8rJJdPlRHQTiNirwNoHNKJgUE4WhPZPvKDw==", + "requires": { + "mobx-react-lite": "^1.4.2" + } + }, + "mobx-react-lite": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-1.5.2.tgz", + "integrity": "sha512-PyZmARqqWtpuQaAoHF5pKX7h6TKNLwq6vtovm4zZvG6sEbMRHHSqioGXSeQbpRmG8Kw8uln3q/W1yMO5IfL5Sg==" + }, + "mobx-utils": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/mobx-utils/-/mobx-utils-5.5.5.tgz", + "integrity": "sha512-N8832YcFRy3Hnl1L0YIbEKKAbwT2vXhGL3tzJsS8uI9Sp+R3osngUVFO2cbyOdApnWPBAe8KCFP0cbQuZzfXsA==" + }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", @@ -9451,6 +9530,11 @@ "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" }, + "numeral": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz", + "integrity": "sha1-StCAk21EPCVhrtnyGX7//iX05QY=" + }, "nwsapi": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", @@ -11432,14 +11516,6 @@ "scheduler": "^0.18.0" } }, - "react-easy-state": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/react-easy-state/-/react-easy-state-6.1.3.tgz", - "integrity": "sha512-uWQ7ittvJylwn/Xgz7Ub1jjsbpthQ9Ad1KDLxXfbXCb2OPnov4porVdnOJU2PKeRezcam3ZgfPUtf9L9rjtyWg==", - "requires": { - "@nx-js/observer-util": "^4.2.2" - } - }, "react-error-overlay": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.4.tgz", @@ -11450,6 +11526,52 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz", "integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q==" }, + "react-router": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.1.2.tgz", + "integrity": "sha512-yjEuMFy1ONK246B+rsa0cUam5OeAQ8pyclRDgpxuSCrAlJ1qN9uZ5IgyKC7gQg0w8OM50NXHEegPh/ks9YuR2A==", + "requires": { + "@babel/runtime": "^7.1.2", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "mini-create-react-context": "^0.3.0", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "requires": { + "isarray": "0.0.1" + } + } + } + }, + "react-router-dom": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.1.2.tgz", + "integrity": "sha512-7BPHAaIwWpZS074UKaw1FjVdZBSVWEk8IuDXdB+OkLb8vd/WRQIpA4ag9WQk61aEfQs47wHyjWUoUGGZxpQXew==", + "requires": { + "@babel/runtime": "^7.1.2", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.1.2", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + } + }, "react-scripts": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-3.3.0.tgz", @@ -11861,6 +11983,11 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=" }, + "resolve-pathname": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" + }, "resolve-url": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", @@ -13171,6 +13298,11 @@ "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=" }, + "tiny-invariant": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz", + "integrity": "sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw==" + }, "tiny-warning": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", @@ -13596,6 +13728,11 @@ "spdx-expression-parse": "^3.0.0" } }, + "value-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" + }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/website/package.json b/website/package.json index fbfccd0..327bb6a 100644 --- a/website/package.json +++ b/website/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "codegen": "grpc-ts-web -o src/sdk ../proto/*.proto", + "codegen": "node_modules/.bin/grpc-ts-web -o src/sdk ../proto/*.proto", "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", @@ -18,11 +18,15 @@ "@types/react": "16.9.19", "@types/react-dom": "16.9.5", "common-tags": "^1.8.0", - "date-fns": "^2.9.0", + "date-fns": "^2.10.0", + "mobx": "^5.15.4", + "mobx-react": "^6.1.8", + "mobx-utils": "^5.5.5", + "numeral": "^2.0.6", "qrcode": "^1.4.4", "react": "^16.12.0", "react-dom": "^16.12.0", - "react-easy-state": "^6.1.3", + "react-router-dom": "^5.1.2", "react-scripts": "3.3.0", "tweetnacl-ts": "^1.0.3", "typeface-roboto": "0.0.75", @@ -46,7 +50,9 @@ "proxy": "http://localhost:8000", "devDependencies": { "@types/common-tags": "^1.8.0", + "@types/numeral": "0.0.26", "@types/qrcode": "^1.3.4", + "@types/react-router-dom": "^5.1.3", "grpc-ts-web": "0.1.6", "prettier": "^1.19.1" } diff --git a/website/src/Api.ts b/website/src/Api.ts index 16584e8..3d6a644 100644 --- a/website/src/Api.ts +++ b/website/src/Api.ts @@ -1,3 +1,4 @@ +import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb'; import { Devices } from './sdk/devices_pb'; import { Server } from './sdk/server_pb'; @@ -8,9 +9,23 @@ export const grpc = { devices: new Devices(backend), } - // https://github.com/SafetyCulture/grpc-web-devtools const devtools = (window as any).__GRPCWEB_DEVTOOLS__; if (devtools) { devtools(Object.values(grpc)); } + +// utils +export function toDate(timestamp: Timestamp.AsObject): Date { + const t = new Timestamp(); + t.setSeconds(timestamp.seconds); + t.setNanos(timestamp.nanos); + return t.toDate(); +} + +export function dateToTimestamp(date: Date): Timestamp.AsObject { + return { + seconds: Math.round(date.getTime() / 1000), + nanos: 0, + }; +} diff --git a/website/src/AppState.ts b/website/src/AppState.ts new file mode 100644 index 0000000..d4a4f7a --- /dev/null +++ b/website/src/AppState.ts @@ -0,0 +1,19 @@ +import { observable } from 'mobx'; +import { InfoRes } from './sdk/server_pb'; + +class GlobalAppState { + + @observable + info?: InfoRes.AsObject; + +} + +export const AppState = new GlobalAppState(); + +console.info('see global app state by typing "window.AppState"'); + +Object.assign(window as any, { + get AppState() { + return JSON.parse(JSON.stringify(AppState)); + } +}); diff --git a/website/src/Store.ts b/website/src/Store.ts deleted file mode 100644 index 54118fa..0000000 --- a/website/src/Store.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { store } from 'react-easy-state'; -import { Device } from './sdk/devices_pb'; - -export const AppState = store({ - devices: new Array(), -}); diff --git a/website/src/Util.ts b/website/src/Util.ts new file mode 100644 index 0000000..7e546fb --- /dev/null +++ b/website/src/Util.ts @@ -0,0 +1,46 @@ +import formatDistance from "date-fns/formatDistance"; +import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb'; +import { toDate } from "./Api"; +import { fromResource } from "mobx-utils"; + +export function sleep(seconds: number) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, seconds * 1000); + }); +} + +export function lastSeen(timestamp: Timestamp.AsObject | undefined): string { + if (timestamp === undefined) { + return 'Never'; + } + return formatDistance(toDate(timestamp), new Date(), { + addSuffix: true, + }); +} + +export function updatingDatasource(seconds: number, cb: () => Promise) { + let running = false; + let sink: ((next: T) => void) | undefined; + return { + update: async () => { + if (sink) { + sink(await cb()); + } + }, + ...fromResource( + async s => { + sink = s; + running = true; + while (running) { + sink(await cb()); + await sleep(seconds); + } + }, + () => { + running = false; + } + ) + } +} diff --git a/website/src/components/AddDevice.tsx b/website/src/components/AddDevice.tsx index f688966..90917fb 100644 --- a/website/src/components/AddDevice.tsx +++ b/website/src/components/AddDevice.tsx @@ -6,52 +6,47 @@ import DialogContent from '@material-ui/core/DialogContent'; import DialogTitle from '@material-ui/core/DialogTitle'; import FormControl from '@material-ui/core/FormControl'; import FormHelperText from '@material-ui/core/FormHelperText'; -import Grid from '@material-ui/core/Grid'; import Input from '@material-ui/core/Input'; import InputLabel from '@material-ui/core/InputLabel'; -import Paper from '@material-ui/core/Paper'; import AddIcon from '@material-ui/icons/Add'; import Typography from '@material-ui/core/Typography'; +import Card from '@material-ui/core/Card'; +import CardHeader from '@material-ui/core/CardHeader'; +import CardContent from '@material-ui/core/CardContent'; import qrcode from 'qrcode'; -import { makeStyles } from '@material-ui/core/styles'; import { codeBlock } from 'common-tags'; import { box_keyPair } from 'tweetnacl-ts'; -import { AppState } from '../Store'; +import { AppState } from '../AppState'; import { GetConnected } from './GetConnected'; import { grpc } from '../Api'; +import { observable } from 'mobx'; +import { observer } from 'mobx-react'; +interface Props { + onAdd: () => void; +} + +@observer +export class AddDevice extends React.Component { + + @observable + dialogOpen = false; + + @observable + error?: string; -const useStyles = makeStyles(theme => ({ - hidden: { - display: 'none', - }, - button: { - margin: theme.spacing(1), - }, - fabButton: { - position: 'absolute', - margin: '0 auto', - left: 0, - right: 0, - }, - paper: { - padding: theme.spacing(2), - }, -})); - -export default function AddDevice() { - const classes = useStyles(); - const [dialogOpen, setDialogOpen] = React.useState(false); - const [error, setError] = React.useState(''); - const [name, setName] = React.useState(''); - const [qrCodeUri, setQrCodeUri] = React.useState(''); - const [configFileUri, setConfigFileUri] = React.useState(''); - - const reset = () => { - setName(''); + @observable + formState = { + name: '', }; - const addDevice = async (event: React.FormEvent) => { + @observable + qrCode?: string; + + @observable + configFileUri?: string; + + submit = async (event: React.FormEvent) => { event.preventDefault(); const keypair = box_keyPair(); @@ -59,9 +54,13 @@ export default function AddDevice() { const privateKey = window.btoa(String.fromCharCode(...(new Uint8Array(keypair.secretKey) as any))); try { - const device = await grpc.devices.addDevice({ name, publicKey }); - const info = await grpc.server.info({}); - AppState.devices.push(device); + const device = await grpc.devices.addDevice({ + name: this.formState.name, + publicKey, + }); + this.props.onAdd(); + + const info = AppState.info!; const configFile = codeBlock` [Interface] PrivateKey = ${privateKey} @@ -73,40 +72,47 @@ export default function AddDevice() { AllowedIPs = 0.0.0.0/1, 128.0.0.0/1, ::/0 Endpoint = ${`${info.host?.value || window.location.hostname}:${info.port || '51820'}`} `; - setQrCodeUri(await qrcode.toDataURL(configFile)); - setConfigFileUri(URL.createObjectURL(new Blob([configFile]))); - reset(); - setDialogOpen(true); + + this.qrCode = await qrcode.toDataURL(configFile); + this.configFileUri = URL.createObjectURL(new Blob([configFile])); + this.dialogOpen = true; + this.reset(); + } catch (error) { console.log(error); - setError('failed'); + // TODO: unwrap grpc error message + this.error = 'failed'; } - }; + } - return ( - - - - - -

Add A Device

-
- + reset = () => { + this.formState.name = ''; + } + + render() { + return ( + <> + + + + + Device Name setName(event.currentTarget.value)} + value={this.formState.name} + onChange={(event) => this.formState.name = event.currentTarget.value} aria-describedby="device-name-text" /> - {error} + {this.error} @@ -115,35 +121,33 @@ export default function AddDevice() { variant="contained" endIcon={} type="submit" - className={classes.button} > Add - - - - - - Get Connected - - - - - - - - - ); + + + + Get Connected + + + + + + + + + ); + } } diff --git a/website/src/components/DeviceListItem.tsx b/website/src/components/DeviceListItem.tsx index cc6b153..7e40e05 100644 --- a/website/src/components/DeviceListItem.tsx +++ b/website/src/components/DeviceListItem.tsx @@ -2,36 +2,32 @@ import React from 'react'; import Card from '@material-ui/core/Card'; import CardHeader from '@material-ui/core/CardHeader'; import CardContent from '@material-ui/core/CardContent'; -import Typography from '@material-ui/core/Typography'; import Avatar from '@material-ui/core/Avatar'; -import DonutSmallIcon from '@material-ui/icons/DonutSmall'; +import WifiIcon from '@material-ui/icons/Wifi'; +import WifiOffIcon from '@material-ui/icons/WifiOff'; import MenuItem from '@material-ui/core/MenuItem'; -import formatDistanceToNow from 'date-fns/formatDistanceToNow'; -import { view } from 'react-easy-state'; -import { AppState } from '../Store'; +import numeral from 'numeral'; +import { lastSeen } from '../Util'; +import { AppState } from '../AppState'; import { IconMenu } from './IconMenu'; import { PopoverDisplay } from './PopoverDisplay'; import { Device } from '../sdk/devices_pb' import { grpc } from '../Api'; +import { observer } from 'mobx-react'; interface Props { device: Device.AsObject; + onRemove: () => void; } -class DeviceListItem extends React.Component { - dateString(date: Date) { - if (date.getUTCMilliseconds() === 0) { - return 'never'; - } - return formatDistanceToNow(date, { addSuffix: true }); - } - +@observer +export class DeviceListItem extends React.Component { removeDevice = async () => { try { await grpc.devices.deleteDevice({ name: this.props.device.name, }); - AppState.devices = AppState.devices.filter(device => device.name !== this.props.device.name); + this.props.onRemove(); } catch { window.alert('api request failed'); } @@ -44,8 +40,12 @@ class DeviceListItem extends React.Component { - + + {/* */} + {device.connected + ? + : + } } action={ @@ -57,13 +57,43 @@ class DeviceListItem extends React.Component { } /> - - Public Key: {device.publicKey} - + + + {AppState.info?.metadataEnabled && device.connected && + <> + + + + + + + + + + + + + + } + {AppState.info?.metadataEnabled && !device.connected && + <> + + + + + + + + + } + + + + + +
Endpoint{device.endpoint}
Sent{numeral(device.transmitBytes).format('0b')}
Received{numeral(device.receiveBytes).format('0b')}
Disconnected
Last Seen{lastSeen(device.lastHandshakeTime)}
Public key{device.publicKey}
); } } - -export default view(DeviceListItem); diff --git a/website/src/components/Devices.tsx b/website/src/components/Devices.tsx index d6e8ef8..b108c30 100644 --- a/website/src/components/Devices.tsx +++ b/website/src/components/Devices.tsx @@ -1,32 +1,39 @@ import React from 'react'; import Grid from '@material-ui/core/Grid'; -import { view } from 'react-easy-state'; -import { AppState } from '../Store'; -import DeviceListItem from './DeviceListItem'; +import { observable } from 'mobx'; +import { observer } from 'mobx-react'; import { grpc } from '../Api'; +import { updatingDatasource as autorefresh } from '../Util'; +import { DeviceListItem } from './DeviceListItem'; +import { AddDevice } from './AddDevice'; -class Devices extends React.Component { +@observer +export class Devices extends React.Component { - componentDidMount() { - this.load(); - } - - async load() { - const res = await grpc.devices.listDevices({}); - AppState.devices = res.items; - } + @observable + devices = autorefresh(5, async () => { + return (await grpc.devices.listDevices({})).items; + }); render() { + if (!this.devices.current()) { + return

loading...

+ } return ( - - {AppState.devices.map((device, i) => ( - - + + + + {this.devices.current().map((device, i) => ( + + this.devices.update()} /> + + ))} - ))} + + + this.devices.update()} /> + ); } } - -export default view(Devices); diff --git a/website/src/components/Navigation.tsx b/website/src/components/Navigation.tsx index a671138..b584fc0 100644 --- a/website/src/components/Navigation.tsx +++ b/website/src/components/Navigation.tsx @@ -1,11 +1,14 @@ import React from 'react'; import { makeStyles } from '@material-ui/core/styles'; +import { getCookie } from '../Cookies'; +import { AppState } from '../AppState'; +import { NavLink } from 'react-router-dom'; import AppBar from '@material-ui/core/AppBar'; import Toolbar from '@material-ui/core/Toolbar'; import Typography from '@material-ui/core/Typography'; import Link from '@material-ui/core/Link'; import Button from '@material-ui/core/Button'; -import { getCookie } from '../Cookies'; +import Chip from '@material-ui/core/Chip'; const useStyles = makeStyles(theme => ({ title: { @@ -20,10 +23,22 @@ export default function Navigation() { - Your Devices + Welcome + {AppState.info?.isAdmin && + + } + + {AppState.info?.isAdmin && + + + + } + {hasAuthCookie && - + diff --git a/website/src/components/PopoverDisplay.tsx b/website/src/components/PopoverDisplay.tsx index ce1f950..6bb0e24 100644 --- a/website/src/components/PopoverDisplay.tsx +++ b/website/src/components/PopoverDisplay.tsx @@ -15,7 +15,7 @@ export class PopoverDisplay extends React.Component { render() { return ( - { - return ( - - - - - - - - - ); -}); + render() { + if (!AppState.info) { + return

loading...

+ } + return ( + + + + + + + {AppState.info.isAdmin && + <> + + + } + + + + ); + } +} ReactDOM.render(, document.getElementById('root')); diff --git a/website/src/pages/YourDevices.tsx b/website/src/pages/YourDevices.tsx new file mode 100644 index 0000000..e78085d --- /dev/null +++ b/website/src/pages/YourDevices.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { Devices } from '../components/Devices'; + +export class YourDevices extends React.Component { + render() { + return ( + + ); + } +} diff --git a/website/src/pages/admin/AllDevices.tsx b/website/src/pages/admin/AllDevices.tsx new file mode 100644 index 0000000..b46fa81 --- /dev/null +++ b/website/src/pages/admin/AllDevices.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import Table from '@material-ui/core/Table'; +import TableBody from '@material-ui/core/TableBody'; +import TableCell from '@material-ui/core/TableCell'; +import TableContainer from '@material-ui/core/TableContainer'; +import TableHead from '@material-ui/core/TableHead'; +import TableRow from '@material-ui/core/TableRow'; +import { observer } from 'mobx-react'; +import { lazyObservable } from 'mobx-utils'; +import { grpc } from '../../Api'; +import { Device } from '../../sdk/devices_pb'; +import { lastSeen } from '../../Util'; + +@observer +export class AllDevices extends React.Component { + + devices = lazyObservable(async sink => { + const res = await grpc.devices.listAllDevices({}); + sink(res.items); + }); + + render() { + if (!this.devices.current()) { + return

loading...

+ } + + const rows = this.devices.current(); + + return ( + + + + + Owner + Device + Connected + Last Seen + + + + {rows.map((row, i) => ( + + + {row.owner} + + {row.name} + {row.connected ? 'yes' : 'no'} + {lastSeen(row.lastHandshakeTime)} + + ))} + +
+
+ ); + } +} diff --git a/website/src/sdk/devices_pb.ts b/website/src/sdk/devices_pb.ts index 71524cc..189c1a5 100644 --- a/website/src/sdk/devices_pb.ts +++ b/website/src/sdk/devices_pb.ts @@ -32,6 +32,12 @@ export class Devices { googleProtobufEmpty.Empty.deserializeBinary ); + private methodInfoListAllDevices = new grpcWeb.AbstractClientBase.MethodInfo( + ListAllDevicesRes, + (req: ListAllDevicesReq) => req.serializeBinary(), + ListAllDevicesRes.deserializeBinary + ); + constructor( private hostname: string, private defaultMetadata?: () => grpcWeb.Metadata, @@ -94,6 +100,25 @@ export class Devices { }); } + listAllDevices(req: ListAllDevicesReq.AsObject, metadata?: grpcWeb.Metadata): Promise { + return new Promise((resolve, reject) => { + const message = ListAllDevicesReqFromObject(req); + this.client_.rpcCall( + this.hostname + '/proto.Devices/ListAllDevices', + message, + Object.assign({}, this.defaultMetadata ? this.defaultMetadata() : {}, metadata), + this.methodInfoListAllDevices, + (err: grpcWeb.Error, res: ListAllDevicesRes) => { + if (err) { + reject(err); + } else { + resolve(res.toObject()); + } + }, + ); + }); + } + } @@ -106,6 +131,11 @@ export declare namespace Device { publicKey: string, address: string, createdAt?: googleProtobufTimestamp.Timestamp.AsObject, + connected: boolean, + lastHandshakeTime?: googleProtobufTimestamp.Timestamp.AsObject, + receiveBytes: number, + transmitBytes: number, + endpoint: string, } } @@ -161,6 +191,46 @@ export class Device extends jspb.Message { (jspb.Message as any).setWrapperField(this, 5, value); } + getConnected(): boolean { + return jspb.Message.getFieldWithDefault(this, 6, false); + } + + setConnected(value: boolean): void { + (jspb.Message as any).setProto3BooleanField(this, 6, value); + } + + getLastHandshakeTime(): googleProtobufTimestamp.Timestamp { + return jspb.Message.getWrapperField(this, googleProtobufTimestamp.Timestamp, 7); + } + + setLastHandshakeTime(value?: googleProtobufTimestamp.Timestamp): void { + (jspb.Message as any).setWrapperField(this, 7, value); + } + + getReceiveBytes(): number { + return jspb.Message.getFieldWithDefault(this, 8, 0); + } + + setReceiveBytes(value: number): void { + (jspb.Message as any).setProto3IntField(this, 8, value); + } + + getTransmitBytes(): number { + return jspb.Message.getFieldWithDefault(this, 9, 0); + } + + setTransmitBytes(value: number): void { + (jspb.Message as any).setProto3IntField(this, 9, value); + } + + getEndpoint(): string { + return jspb.Message.getFieldWithDefault(this, 10, ""); + } + + setEndpoint(value: string): void { + (jspb.Message as any).setProto3StringField(this, 10, value); + } + serializeBinary(): Uint8Array { const writer = new jspb.BinaryWriter(); Device.serializeBinaryToWriter(this, writer); @@ -174,6 +244,11 @@ export class Device extends jspb.Message { publicKey: this.getPublicKey(), address: this.getAddress(), createdAt: (f = this.getCreatedAt()) && f.toObject(), + connected: this.getConnected(), + lastHandshakeTime: (f = this.getLastHandshakeTime()) && f.toObject(), + receiveBytes: this.getReceiveBytes(), + transmitBytes: this.getTransmitBytes(), + endpoint: this.getEndpoint(), }; } @@ -199,6 +274,26 @@ export class Device extends jspb.Message { if (field5 != null) { writer.writeMessage(5, field5, googleProtobufTimestamp.Timestamp.serializeBinaryToWriter); } + const field6 = message.getConnected(); + if (field6 != false) { + writer.writeBool(6, field6); + } + const field7 = message.getLastHandshakeTime(); + if (field7 != null) { + writer.writeMessage(7, field7, googleProtobufTimestamp.Timestamp.serializeBinaryToWriter); + } + const field8 = message.getReceiveBytes(); + if (field8 != 0) { + writer.writeInt64(8, field8); + } + const field9 = message.getTransmitBytes(); + if (field9 != 0) { + writer.writeInt64(9, field9); + } + const field10 = message.getEndpoint(); + if (field10.length > 0) { + writer.writeString(10, field10); + } } static deserializeBinary(bytes: Uint8Array): Device { @@ -235,6 +330,27 @@ export class Device extends jspb.Message { reader.readMessage(field5, googleProtobufTimestamp.Timestamp.deserializeBinaryFromReader); message.setCreatedAt(field5); break; + case 6: + const field6 = reader.readBool() + message.setConnected(field6); + break; + case 7: + const field7 = new googleProtobufTimestamp.Timestamp(); + reader.readMessage(field7, googleProtobufTimestamp.Timestamp.deserializeBinaryFromReader); + message.setLastHandshakeTime(field7); + break; + case 8: + const field8 = reader.readInt64() + message.setReceiveBytes(field8); + break; + case 9: + const field9 = reader.readInt64() + message.setTransmitBytes(field9); + break; + case 10: + const field10 = reader.readString() + message.setEndpoint(field10); + break; default: reader.skipField(); break; @@ -537,6 +653,137 @@ export class DeleteDeviceReq extends jspb.Message { } } +export declare namespace ListAllDevicesReq { + export type AsObject = { + } +} + +export class ListAllDevicesReq extends jspb.Message { + + private static repeatedFields_ = [ + + ]; + + constructor(data?: jspb.Message.MessageArray) { + super(); + jspb.Message.initialize(this, data || [], 0, -1, ListAllDevicesReq.repeatedFields_, null); + } + + + serializeBinary(): Uint8Array { + const writer = new jspb.BinaryWriter(); + ListAllDevicesReq.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); + } + + toObject(): ListAllDevicesReq.AsObject { + let f: any; + return { + }; + } + + static serializeBinaryToWriter(message: ListAllDevicesReq, writer: jspb.BinaryWriter): void { + } + + static deserializeBinary(bytes: Uint8Array): ListAllDevicesReq { + var reader = new jspb.BinaryReader(bytes); + var message = new ListAllDevicesReq(); + return ListAllDevicesReq.deserializeBinaryFromReader(message, reader); + } + + static deserializeBinaryFromReader(message: ListAllDevicesReq, reader: jspb.BinaryReader): ListAllDevicesReq { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + const field = reader.getFieldNumber(); + switch (field) { + default: + reader.skipField(); + break; + } + } + return message; + } + +} +export declare namespace ListAllDevicesRes { + export type AsObject = { + items: Array, + } +} + +export class ListAllDevicesRes extends jspb.Message { + + private static repeatedFields_ = [ + 1, + ]; + + constructor(data?: jspb.Message.MessageArray) { + super(); + jspb.Message.initialize(this, data || [], 0, -1, ListAllDevicesRes.repeatedFields_, null); + } + + + getItems(): Array { + return jspb.Message.getRepeatedWrapperField(this, Device, 1); + } + + setItems(value: Array): void { + (jspb.Message as any).setRepeatedWrapperField(this, 1, value); + } + + addItems(value?: Device, index?: number): Device { + return jspb.Message.addToRepeatedWrapperField(this, 1, value, Device, index); + } + + serializeBinary(): Uint8Array { + const writer = new jspb.BinaryWriter(); + ListAllDevicesRes.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); + } + + toObject(): ListAllDevicesRes.AsObject { + let f: any; + return { + items: this.getItems().map((item) => item.toObject()), + }; + } + + static serializeBinaryToWriter(message: ListAllDevicesRes, writer: jspb.BinaryWriter): void { + const field1 = message.getItems(); + if (field1.length > 0) { + writer.writeRepeatedMessage(1, field1, Device.serializeBinaryToWriter); + } + } + + static deserializeBinary(bytes: Uint8Array): ListAllDevicesRes { + var reader = new jspb.BinaryReader(bytes); + var message = new ListAllDevicesRes(); + return ListAllDevicesRes.deserializeBinaryFromReader(message, reader); + } + + static deserializeBinaryFromReader(message: ListAllDevicesRes, reader: jspb.BinaryReader): ListAllDevicesRes { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + const field = reader.getFieldNumber(); + switch (field) { + case 1: + const field1 = new Device(); + reader.readMessage(field1, Device.deserializeBinaryFromReader); + message.addItems(field1); + break; + default: + reader.skipField(); + break; + } + } + return message; + } + +} function DeviceFromObject(obj: Device.AsObject | undefined): Device | undefined { @@ -549,6 +796,11 @@ function DeviceFromObject(obj: Device.AsObject | undefined): Device | undefined message.setPublicKey(obj.publicKey); message.setAddress(obj.address); message.setCreatedAt(TimestampFromObject(obj.createdAt)); + message.setConnected(obj.connected); + message.setLastHandshakeTime(TimestampFromObject(obj.lastHandshakeTime)); + message.setReceiveBytes(obj.receiveBytes); + message.setTransmitBytes(obj.transmitBytes); + message.setEndpoint(obj.endpoint); return message; } @@ -600,6 +852,25 @@ function DeleteDeviceReqFromObject(obj: DeleteDeviceReq.AsObject | undefined): D return message; } +function ListAllDevicesReqFromObject(obj: ListAllDevicesReq.AsObject | undefined): ListAllDevicesReq | undefined { + if (obj === undefined) { + return undefined; + } + const message = new ListAllDevicesReq(); + return message; +} + +function ListAllDevicesResFromObject(obj: ListAllDevicesRes.AsObject | undefined): ListAllDevicesRes | undefined { + if (obj === undefined) { + return undefined; + } + const message = new ListAllDevicesRes(); + (obj.items || []) + .map((item) => DeviceFromObject(item)) + .forEach((item) => message.addItems(item)); + return message; +} + function EmptyFromObject(obj: googleProtobufEmpty.Empty.AsObject | undefined): googleProtobufEmpty.Empty | undefined { if (obj === undefined) { return undefined; diff --git a/website/src/sdk/server_pb.ts b/website/src/sdk/server_pb.ts index 321f835..601ad8b 100644 --- a/website/src/sdk/server_pb.ts +++ b/website/src/sdk/server_pb.ts @@ -108,6 +108,8 @@ export declare namespace InfoRes { host?: googleProtobufWrappers.StringValue.AsObject, port: number, hostVpnIp: string, + metadataEnabled: boolean, + isAdmin: boolean, } } @@ -155,6 +157,22 @@ export class InfoRes extends jspb.Message { (jspb.Message as any).setProto3StringField(this, 4, value); } + getMetadataEnabled(): boolean { + return jspb.Message.getFieldWithDefault(this, 5, false); + } + + setMetadataEnabled(value: boolean): void { + (jspb.Message as any).setProto3BooleanField(this, 5, value); + } + + getIsAdmin(): boolean { + return jspb.Message.getFieldWithDefault(this, 6, false); + } + + setIsAdmin(value: boolean): void { + (jspb.Message as any).setProto3BooleanField(this, 6, value); + } + serializeBinary(): Uint8Array { const writer = new jspb.BinaryWriter(); InfoRes.serializeBinaryToWriter(this, writer); @@ -167,6 +185,8 @@ export class InfoRes extends jspb.Message { host: (f = this.getHost()) && f.toObject(), port: this.getPort(), hostVpnIp: this.getHostVpnIp(), + metadataEnabled: this.getMetadataEnabled(), + isAdmin: this.getIsAdmin(), }; } @@ -188,6 +208,14 @@ export class InfoRes extends jspb.Message { if (field4.length > 0) { writer.writeString(4, field4); } + const field5 = message.getMetadataEnabled(); + if (field5 != false) { + writer.writeBool(5, field5); + } + const field6 = message.getIsAdmin(); + if (field6 != false) { + writer.writeBool(6, field6); + } } static deserializeBinary(bytes: Uint8Array): InfoRes { @@ -220,6 +248,14 @@ export class InfoRes extends jspb.Message { const field4 = reader.readString() message.setHostVpnIp(field4); break; + case 5: + const field5 = reader.readBool() + message.setMetadataEnabled(field5); + break; + case 6: + const field6 = reader.readBool() + message.setIsAdmin(field6); + break; default: reader.skipField(); break; @@ -248,6 +284,8 @@ function InfoResFromObject(obj: InfoRes.AsObject | undefined): InfoRes | undefin message.setHost(StringValueFromObject(obj.host)); message.setPort(obj.port); message.setHostVpnIp(obj.hostVpnIp); + message.setMetadataEnabled(obj.metadataEnabled); + message.setIsAdmin(obj.isAdmin); return message; } diff --git a/website/tsconfig.json b/website/tsconfig.json index af10394..2d971e4 100644 --- a/website/tsconfig.json +++ b/website/tsconfig.json @@ -13,7 +13,8 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react" + "jsx": "react", + "experimentalDecorators": true }, "include": ["src"] }