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
pull/17/head
PLACE 4 years ago committed by GitHub
parent e34f21813a
commit 304a6526cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

1
.gitignore vendored

@ -49,6 +49,7 @@ website/coverage
website/build
# misc
.env
website/.DS_Store
website/.env.local
website/.env.development.local

@ -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 :)

@ -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

@ -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 <username>"
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 <username>"
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

@ -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

@ -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: "<loadbalancer-ip>:51820"
wireguard:
config:
privateKey: "<wireguard-private-key>"
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 | `[]` | |

@ -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

@ -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 }}

@ -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 }}

@ -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

@ -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

@ -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="<value>"`.
## 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 <username>"
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
```

@ -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
```

@ -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!}
```

@ -0,0 +1,3 @@
# Helm Chart
{!../deploy/helm/wg-access-server/README.md!}

@ -0,0 +1,3 @@
# Welcome
{!../README.md!}

@ -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

@ -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=

@ -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) {

@ -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 != "" {

@ -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"))
}
}
}
}
}

@ -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"
}

@ -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()

@ -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))
}

@ -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
}

@ -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
}

@ -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"`
}

@ -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")

@ -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

@ -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

@ -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{}

@ -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
}
}

@ -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"`

@ -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"
)

@ -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 {

@ -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
}

@ -0,0 +1,6 @@
package authsession
type Identity struct {
Subject string
Claims Claims
}

@ -0,0 +1,3 @@
package authsession
type ClaimsMiddleware func(user *Identity) error

@ -15,10 +15,6 @@ type AuthSession struct {
Identity *Identity
}
type Identity struct {
Subject string
}
type authSessionKey string
var sessionKey authSessionKey = "auth-session"

@ -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 {

@ -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)

@ -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;
}

@ -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",

@ -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.

@ -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;
}

@ -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'])

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 87 KiB

@ -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",

@ -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"
}

@ -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,
};
}

@ -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));
}
});

@ -1,6 +0,0 @@
import { store } from 'react-easy-state';
import { Device } from './sdk/devices_pb';
export const AppState = store({
devices: new Array<Device.AsObject>(),
});

@ -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<T>(seconds: number, cb: () => Promise<T>) {
let running = false;
let sink: ((next: T) => void) | undefined;
return {
update: async () => {
if (sink) {
sink(await cb());
}
},
...fromResource<T>(
async s => {
sink = s;
running = true;
while (running) {
sink(await cb());
await sleep(seconds);
}
},
() => {
running = false;
}
)
}
}

@ -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<Props> {
@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 (
<React.Fragment>
<Grid container spacing={3}>
<Grid item xs></Grid>
<Grid item xs={12} md={4} lg={6}>
<Paper className={classes.paper}>
<h2>Add A Device</h2>
<form onSubmit={addDevice}>
<FormControl error={error !== ''} fullWidth>
reset = () => {
this.formState.name = '';
}
render() {
return (
<>
<Card>
<CardHeader
title="Add A Device"
/>
<CardContent>
<form onSubmit={this.submit}>
<FormControl error={!!this.error} fullWidth>
<InputLabel htmlFor="device-name">Device Name</InputLabel>
<Input
id="device-name"
value={name}
onChange={(event) => setName(event.currentTarget.value)}
value={this.formState.name}
onChange={(event) => this.formState.name = event.currentTarget.value}
aria-describedby="device-name-text"
/>
<FormHelperText id="device-name-text">{error}</FormHelperText>
<FormHelperText id="device-name-text">{this.error}</FormHelperText>
</FormControl>
<Typography component="div" align="right">
<Button
color="secondary"
type="button"
onClick={reset}
className={classes.button}
onClick={this.reset}
>
Cancel
</Button>
@ -115,35 +121,33 @@ export default function AddDevice() {
variant="contained"
endIcon={<AddIcon />}
type="submit"
className={classes.button}
>
Add
</Button>
</Typography>
</form>
</Paper>
</Grid>
<Grid item xs></Grid>
</Grid>
<Dialog
disableBackdropClick
disableEscapeKeyDown
maxWidth="xl"
open={dialogOpen}
>
<DialogTitle>Get Connected</DialogTitle>
<DialogContent>
<GetConnected
qrCodeUri={qrCodeUri}
configFileUri={configFileUri}
/>
</DialogContent>
<DialogActions>
<Button color="secondary" variant="outlined" onClick={() => setDialogOpen(false)}>
Done
</Button>
</DialogActions>
</Dialog>
</React.Fragment>
);
</CardContent>
</Card>
<Dialog
disableBackdropClick
disableEscapeKeyDown
maxWidth="xl"
open={this.dialogOpen}
>
<DialogTitle>Get Connected</DialogTitle>
<DialogContent>
<GetConnected
qrCodeUri={this.qrCode!}
configFileUri={this.configFileUri!}
/>
</DialogContent>
<DialogActions>
<Button color="secondary" variant="outlined" onClick={() => this.dialogOpen = false}>
Done
</Button>
</DialogActions>
</Dialog>
</>
);
}
}

@ -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<Props> {
dateString(date: Date) {
if (date.getUTCMilliseconds() === 0) {
return 'never';
}
return formatDistanceToNow(date, { addSuffix: true });
}
@observer
export class DeviceListItem extends React.Component<Props> {
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<Props> {
<CardHeader
title={device.name}
avatar={
<Avatar>
<DonutSmallIcon />
<Avatar style={{ backgroundColor: device.connected ? '#76de8a' : '#bdbdbd' }}>
{/* <DonutSmallIcon /> */}
{device.connected
? <WifiIcon />
: <WifiOffIcon />
}
</Avatar>
}
action={
@ -57,13 +57,43 @@ class DeviceListItem extends React.Component<Props> {
}
/>
<CardContent>
<Typography component="p">
Public Key: <PopoverDisplay label="show">{device.publicKey}</PopoverDisplay>
</Typography>
<table cellPadding="5">
<tbody>
{AppState.info?.metadataEnabled && device.connected &&
<>
<tr>
<td>Endpoint</td>
<td>{device.endpoint}</td>
</tr>
<tr>
<td>Sent</td>
<td>{numeral(device.transmitBytes).format('0b')}</td>
</tr>
<tr>
<td>Received</td>
<td>{numeral(device.receiveBytes).format('0b')}</td>
</tr>
</>
}
{AppState.info?.metadataEnabled && !device.connected &&
<>
<tr>
<td>Disconnected</td>
</tr>
<tr>
<td>Last Seen</td>
<td>{lastSeen(device.lastHandshakeTime)}</td>
</tr>
</>
}
<tr>
<td>Public key</td>
<td><PopoverDisplay label="show">{device.publicKey}</PopoverDisplay></td>
</tr>
</tbody>
</table>
</CardContent>
</Card>
);
}
}
export default view(DeviceListItem);

@ -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 <p>loading...</p>
}
return (
<Grid container spacing={3}>
{AppState.devices.map((device, i) => (
<Grid key={i} item xs={12} sm={6} md={4} lg={3}>
<DeviceListItem device={device} />
<Grid container spacing={3} justify="center">
<Grid item xs={12}>
<Grid container spacing={3}>
{this.devices.current().map((device, i) => (
<Grid key={i} item xs={12} sm={6} md={4} lg={3}>
<DeviceListItem device={device} onRemove={() => this.devices.update()} />
</Grid>
))}
</Grid>
))}
</Grid>
<Grid item xs={12} sm={10} md={10} lg={6}>
<AddDevice onAdd={() => this.devices.update()} />
</Grid>
</Grid>
);
}
}
export default view(Devices);

@ -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() {
<AppBar position="static">
<Toolbar>
<Typography variant="h6" className={classes.title}>
Your Devices
<span>Welcome</span>
{AppState.info?.isAdmin &&
<Chip label="admin" color="secondary" variant="outlined" size="small" style={{ marginLeft: 20, background: 'white' }} />
}
</Typography>
{AppState.info?.isAdmin &&
<Link to="/admin/all-devices" color="inherit" component={NavLink}>
<Button color="inherit">
All Devices
</Button>
</Link>
}
{hasAuthCookie &&
<Link color="inherit" href="/signout">
<Link href="/signout" color="inherit">
<Button color="inherit">
Logout
</Button>

@ -15,7 +15,7 @@ export class PopoverDisplay extends React.Component<Props> {
render() {
return (
<React.Fragment>
<Button variant="text" color="secondary" onClick={event => this.setState({ anchorEl: event.currentTarget })}>
<Button size="small" variant="outlined" color="secondary" style={{padding: 0}} onClick={event => this.setState({ anchorEl: event.currentTarget })}>
{this.props.label}
</Button>
<Popover

@ -1,24 +1,48 @@
import 'typeface-roboto';
import React from 'react';
import ReactDOM from 'react-dom';
import CssBaseline from '@material-ui/core/CssBaseline';
import Box from '@material-ui/core/Box';
import AddDevice from './components/AddDevice';
import Devices from './components/Devices';
import Navigation from './components/Navigation';
import { view } from 'react-easy-state';
import 'typeface-roboto';
import {
BrowserRouter as Router,
Switch,
Route,
} from 'react-router-dom';
import { observer } from 'mobx-react';
import { grpc } from './Api';
import { AppState } from './AppState';
import { YourDevices } from './pages/YourDevices';
import { AllDevices } from './pages/admin/AllDevices';
@observer
class App extends React.Component {
async componentDidMount() {
AppState.info = await grpc.server.info({});
}
const App = view(() => {
return (
<React.Fragment>
<CssBaseline />
<Navigation />
<Box component="div" m={3}>
<Devices />
<AddDevice />
</Box>
</React.Fragment>
);
});
render() {
if (!AppState.info) {
return <p>loading...</p>
}
return (
<Router>
<CssBaseline />
<Navigation />
<Box component="div" m={2}>
<Switch>
<Route exact path="/" component={YourDevices} />
{AppState.info.isAdmin &&
<>
<Route exact path="/admin/all-devices" component={AllDevices} />
</>
}
</Switch>
</Box>
</Router>
);
}
}
ReactDOM.render(<App />, document.getElementById('root'));

@ -0,0 +1,10 @@
import React from 'react';
import { Devices } from '../components/Devices';
export class YourDevices extends React.Component {
render() {
return (
<Devices />
);
}
}

@ -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<Device.AsObject[]>(async sink => {
const res = await grpc.devices.listAllDevices({});
sink(res.items);
});
render() {
if (!this.devices.current()) {
return <p>loading...</p>
}
const rows = this.devices.current();
return (
<TableContainer>
<Table stickyHeader>
<TableHead>
<TableRow>
<TableCell>Owner</TableCell>
<TableCell>Device</TableCell>
<TableCell>Connected</TableCell>
<TableCell>Last Seen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, i) => (
<TableRow key={i}>
<TableCell component="th" scope="row">
{row.owner}
</TableCell>
<TableCell>{row.name}</TableCell>
<TableCell>{row.connected ? 'yes' : 'no'}</TableCell>
<TableCell>{lastSeen(row.lastHandshakeTime)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}
}

@ -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<ListAllDevicesRes.AsObject> {
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<Device.AsObject>,
}
}
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<Device> {
return jspb.Message.getRepeatedWrapperField(this, Device, 1);
}
setItems(value: Array<Device>): 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;

@ -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;
}

@ -13,7 +13,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
"jsx": "react",
"experimentalDecorators": true
},
"include": ["src"]
}

Loading…
Cancel
Save