initial commit

auth
James Batt 5 years ago
commit c40f7971c3

@ -0,0 +1,3 @@
.git/
**/node_modules/
**/build/

@ -0,0 +1,38 @@
FROM rust:1.38.0-alpine as boringtun
RUN apk add musl-dev
RUN cargo install \
--root / \
--bin boringtun \
--git https://github.com/cloudflare/boringtun.git \
--rev 0b980a2f5a5f8622bf0e3a024ace63ad01b5d0f6
FROM node:10 as website
WORKDIR /code
COPY ./website/package.json ./
COPY ./website/package-lock.json ./
RUN npm install
COPY ./website/ ./
RUN npm run build
FROM golang:1.13 as server
WORKDIR /code
ENV GOOS=linux
ENV GARCH=amd64
ENV CGO_ENABLED=0
ENV GO111MODULE=on
COPY ./go.mod ./
COPY ./go.sum ./
RUN go mod download
COPY ./main.go ./main.go
COPY ./internal/ ./internal
RUN go build -o server
FROM alpine:3.10
RUN apk add iptables
RUN apk add wireguard-tools
ENV WIREGUARD_USERSPACE_IMPLEMENTATION=boringtun
ENV STORAGE_DIRECTORY="/data"
COPY --from=boringtun /bin/boringtun /usr/local/bin/boringtun
COPY --from=server /code/server ./server
COPY --from=website /code/build ./website/build
CMD ./server

@ -0,0 +1,26 @@
#!/bin/bash
# This script will build the Dockerfile
# and then run it with a minimalistic set of
# docker run arguments
#
# note that "WIREGUARD_PRIVATE_KEY" used in
# this configuration is for the demo and clearly
# not secure, please don't copy-paste it
set -eou pipefail
docker build -t demo .
read -p "Enter LAN ip address i.e. 192.168.0.2 : " external_address
docker run \
-it \
--rm \
--name wg \
--cap-add NET_ADMIN \
--device /dev/net/tun:/dev/net/tun \
-v wgdata:/data \
-p 8000:8000/tcp \
-p 51820:51820/udp \
-e WIREGUARD_PRIVATE_KEY="kH4F1lldSzgEMB7wfQ1ccujAhZCCCCEeh2Kvhxf+XFw=" \
-e WEB_EXTERNAL_ADDRESS="$external_address" \
demo

@ -0,0 +1,16 @@
module github.com/place1/wireguard-access-server
go 1.13
require (
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc // indirect
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect
github.com/coreos/go-iptables v0.4.3
github.com/gorilla/mux v1.7.3
github.com/pkg/errors v0.8.1
github.com/sirupsen/logrus v1.4.2
github.com/vishvananda/netlink v1.0.0
github.com/vishvananda/netns v0.0.0-20190625233234-7109fa855b0f // indirect
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20191008142428-8d021180e987
gopkg.in/alecthomas/kingpin.v2 v2.2.6
)

@ -0,0 +1,68 @@
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/coreos/go-iptables v0.4.3 h1:jJg1aFuhCqWbgBl1VTqgTHG5faPM60A5JDMjQ2HYv+A=
github.com/coreos/go-iptables v0.4.3/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a h1:84IpUNXj4mCR9CuCEvSiCArMbzr/TMbuPIadKDwypkI=
github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw=
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/mdlayher/genetlink v0.0.0-20191004171646-5cf585d3b847 h1:EFRfaQaWMFsAqLGDvz9jYIlcMImQFCnCmohvVdVgdY8=
github.com/mdlayher/genetlink v0.0.0-20191004171646-5cf585d3b847/go.mod h1:LNhNWFVJapYK8zEjVHUIle4gy+Oahfc3UtcaqZ8Dz98=
github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA=
github.com/mdlayher/netlink v0.0.0-20191004170026-3c8695cb0643/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M=
github.com/mdlayher/netlink v0.0.0-20191008140946-2a17fd90af51 h1:rP02cBlv8sk9kC1iRINOapZNB9B5S6JChwmYXDiFKpU=
github.com/mdlayher/netlink v0.0.0-20191008140946-2a17fd90af51/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/vishvananda/netlink v1.0.0 h1:bqNY2lgheFIu1meHUFSH3d7vG93AFyqg3oGbJCOJgSM=
github.com/vishvananda/netlink v1.0.0/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk=
github.com/vishvananda/netns v0.0.0-20190625233234-7109fa855b0f h1:nBX3nTcmxEtHSERBJaIo1Qa26VwRaopnZmfDQUXsF4I=
github.com/vishvananda/netns v0.0.0-20190625233234-7109fa855b0f/go.mod h1:ZjcWmFBXmLKZu9Nxj3WKYEafiSqer2rnvPr0en9UNpI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191002192127-34f69633bfdc h1:c0o/qxkaO2LF5t6fQrT4b5hzyggAkLLlCUjqfRxd8Q4=
golang.org/x/crypto v0.0.0-20191002192127-34f69633bfdc/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191003171128-d98b1b443823/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191007182048-72f939374954 h1:JGZucVF/L/TotR719NbujzadOZ2AgnYlqphQGHDCKaU=
golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190830023255-19e00faab6ad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191003212358-c178f38b412c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191008105621-543471e840be h1:QAcqgptGM8IQBC9K/RC4o+O9YmqEm0diQn9QmZw/0mU=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.zx2c4.com/wireguard v0.0.20190908 h1:SUoXDdwSMtomLdvke+zz83/u9tNvl4hHmcTIWp38tow=
golang.zx2c4.com/wireguard v0.0.20190908/go.mod h1:LhfXh5z6bLC2lW2ve6BzYZFwnnsXK3OQjySR0Yh2dO8=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20191008142428-8d021180e987 h1:26OAgqBTufVr8WKonCEhhjO1oKsYhHv0iM5Dg92G1TM=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20191008142428-8d021180e987/go.mod h1:7hq1rEDsx7/FWl8IEEnfH2Xhs6M2MNnjUfN0PeI8Rm0=
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=

@ -0,0 +1,184 @@
package config
import (
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"github.com/vishvananda/netlink"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"gopkg.in/alecthomas/kingpin.v2"
)
type AppConfig struct {
Web struct {
// ExternalAddress is the address that
// clients should use to connect to this
// server. It will be used in generated
// VPN client connection configuration
// ExternalAddress should not include any
// port infomation.
// The WireGuard port will be appended.
ExternalAddress string
// Port that the web server should listen on
Port int
}
Storage struct {
// 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).
Directory string
}
WireGuard struct {
// UserspaceImplementation is a command (program on $PATH)
// that implements the WireGuard protocol in userspace.
// In our Docker image we make use of `boringtun` so that
// users aren't required to setup kernel modules
UserspaceImplementation string
// The network interface name of the WireGuard
// network device
InterfaceName string
// The WireGuard PrivateKey
// If this value is lost then any existing
// clients (WireGuard peers) will no longer
// be able to connect.
// Clients will either have to manually update
// their connection configuration or setup
// their VPN again using the web ui (easier for most people)
PrivateKey string
// The WireGuard ListenPort
Port int
}
VPN struct {
// SubnetCIDR configures a network address space
// that client (WireGuard peers) will be allocated
// an IP address from
SubnetCIDR string
// 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
GatewayInterface netlink.Link
}
}
var (
app = kingpin.New("TODO: name-this-program", "An all-in-one WireGuard VPN solution")
logLevel = app.Flag("loglevel", "Enable debug mode").Default("info").OverrideDefaultFromEnvar("LOG_LEVEL").String()
webPort = app.Flag("web-port", "The web server port").Default("8000").OverrideDefaultFromEnvar("WEB_PORT").Int()
webExternalAddress = app.Flag("web-external-address", "The external address that the service is accessible from excluding any scheme or port (e.g. vpn.example.com). Defaults to the IP address of your default interface").OverrideDefaultFromEnvar("WEB_EXTERNAL_ADDRESS").String()
storageDirectory = app.Flag("storage-directory", "The directory where vpn devices (i.e. peers) will be stored").OverrideDefaultFromEnvar("STORAGE_DIRECTORY").String()
wireGuardUserspaceImplementation = app.Flag("wireguard-userspace-implementation", "The a userspace implementation of wireguard e.g. wireguard-go or boringtun").OverrideDefaultFromEnvar("WIREGUARD_USERSPACE_IMPLEMENTATION").String()
wireGuardInterfaceName = app.Flag("wireguard-interface-name", "The name of the WireGuard interface").Default("wg0").OverrideDefaultFromEnvar("WIREGUARD_INTERFACE_NAME").String()
wireguardPort = app.Flag("wireguard-port", "The WireGuard ListenPort").Default("51820").OverrideDefaultFromEnvar("WIREGUARD_PORT").Int()
wireguardPrivateKey = app.Flag("wireguard-private-key", "The WireGuard private key").OverrideDefaultFromEnvar("WIREGUARD_PRIVATE_KEY").String()
vpnGatewayInterfaceName = app.Flag("vpn-gateway-interface-name", "The name of the network interface you want VPN client traffic to foward to").OverrideDefaultFromEnvar("VPN_GATEWAY_INTERFACE_NAME").String()
vpnSubnetCIDR = app.Flag("vpn-subnet-cidr", "The subnet CIDR that clients should be networked within").Default("10.44.0.1/24").OverrideDefaultFromEnvar("VPN_SUBNET_CIDR").String()
)
func Read() *AppConfig {
kingpin.MustParse(app.Parse(os.Args[1:]))
config := AppConfig{}
config.Web.Port = *webPort
config.Web.ExternalAddress = *webExternalAddress
config.Storage.Directory = *storageDirectory
config.WireGuard.UserspaceImplementation = *wireGuardUserspaceImplementation
config.WireGuard.InterfaceName = *wireGuardInterfaceName
config.WireGuard.Port = *wireguardPort
config.WireGuard.PrivateKey = *wireguardPrivateKey
config.VPN.SubnetCIDR = *vpnSubnetCIDR
config.VPN.GatewayInterface = findGatewayLink(*vpnGatewayInterfaceName)
if config.Web.ExternalAddress == "" && config.VPN.GatewayInterface != nil {
if ip, err := linkIPAddr(config.VPN.GatewayInterface); err == nil {
config.Web.ExternalAddress = ip.String()
logrus.Infof("no external address was configured - using %s from the gateway interface", config.Web.ExternalAddress)
}
}
level, err := logrus.ParseLevel(*logLevel)
if err != nil {
logrus.Fatal(errors.Wrap(err, "invalid log level - should be one of fatal, error, warn, info, debug, trace"))
}
logrus.SetLevel(level)
logrus.SetReportCaller(true)
logrus.SetFormatter(&logrus.TextFormatter{
CallerPrettyfier: func(f *runtime.Frame) (string, string) {
return "", fmt.Sprintf("%s:%d", filepath.Base(f.File), f.Line)
},
})
if config.WireGuard.PrivateKey == "" {
logrus.Warn("no private key has been configured! using an in-memory private key that will be lost when the process exits!")
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
logrus.Fatal(errors.Wrap(err, "failed to generate a server private key"))
}
config.WireGuard.PrivateKey = key.String()
}
if config.Storage.Directory == "" {
logrus.Warn("storage directory not configured - using in-memory storage backend! wireguard devices will be lost when the process exits!")
}
return &config
}
func defaultInterface() string {
links, err := netlink.LinkList()
if err != nil {
logrus.Warn(errors.Wrap(err, "failed to list network interfaces"))
return ""
}
for _, link := range links {
routes, err := netlink.RouteList(link, 4)
if err != nil {
logrus.Warn(errors.Wrapf(err, "failed to list routes for interface %s", link.Attrs().Name))
return ""
}
for _, route := range routes {
if route.Dst == nil {
return link.Attrs().Name
}
}
}
return ""
}
func findGatewayLink(name string) netlink.Link {
if name == "" {
if name = defaultInterface(); name == "" {
logrus.Warn("a gateway interface name was not configured - vpn forwarding rules will not be applied!")
return nil
} else {
logrus.Infof("no gateway interface name was configured - using the system's default route's interface %s", name)
}
}
if name == "" {
}
link, err := netlink.LinkByName(name)
if err != nil {
logrus.Warn(errors.Wrapf(err, "the gateway interface '%s' could not be found - vpn forwarding rules will not be applied!", name))
return nil
}
return link
}
func linkIPAddr(link netlink.Link) (net.IP, error) {
routes, err := netlink.RouteList(link, 4)
if err != nil {
return nil, errors.Wrapf(err, "failed to list routes for interface %s", link.Attrs().Name)
}
for _, route := range routes {
if route.Src != nil {
return route.Src, nil
}
}
return nil, fmt.Errorf("no source IP found for interface %s", link.Attrs().Name)
}

@ -0,0 +1,167 @@
package services
import (
"fmt"
"net"
"sync"
"time"
"github.com/pkg/errors"
"github.com/place1/wireguard-access-server/internal/storage"
"github.com/place1/wireguard-access-server/internal/wg"
"github.com/sirupsen/logrus"
)
var vpnip, vpnsubnet = MustParseCIDR("10.0.0.1/24")
var peerid = 1
// we need to give each device (i.e. wg peer)
// an ip within the VPN's subnet
// idk how i'm going to maintain this infomation just yet
func nextPeerID() int {
peerid = peerid + 1
return peerid
}
type DeviceManager struct {
wgserver *wg.Server
storage storage.Storage
}
func NewDeviceManager(w *wg.Server, s storage.Storage) *DeviceManager {
return &DeviceManager{w, s}
}
func (d *DeviceManager) Sync() error {
devices, err := d.ListDevices()
if err != nil {
return errors.Wrap(err, "failed to list devices")
}
for _, device := range devices {
if err := d.wgserver.AddPeer(device.PublicKey, device.Address); err != nil {
logrus.Warn(errors.Wrapf(err, "failed to sync device '%s' (ignoring)", device.Name))
}
}
return nil
}
func (d *DeviceManager) AddDevice(name string, publicKey string) (*storage.Device, error) {
if name == "" {
return nil, errors.New("device name must not be empty")
}
clientAddr, err := d.nextClientAddress()
if err != nil {
return nil, errors.Wrap(err, "failed to generate an ip address for device")
}
device := &storage.Device{
Name: name,
PublicKey: publicKey,
Endpoint: d.wgserver.Endpoint(),
Address: clientAddr,
DNS: d.wgserver.DNS(),
CreatedAt: time.Now(),
ServerPublicKey: d.wgserver.PublicKey(),
}
if err := d.storage.Save(device); err != nil {
// TODO: might need to clean up the wg config?
// might need to save before adding to wg?
// idk lol
return nil, errors.Wrap(err, "failed to save the new device")
}
if err := d.wgserver.AddPeer(publicKey, clientAddr); err != nil {
return nil, errors.Wrap(err, "unable to provision peer")
}
return device, nil
}
func (d *DeviceManager) ListDevices() ([]*storage.Device, error) {
return d.storage.List()
}
func (d *DeviceManager) DeleteDevice(name string) error {
device, err := d.storage.Get(name)
if err != nil {
return errors.Wrap(err, "failed to retrieve device")
}
if err := d.storage.Delete(device); err != nil {
return err
}
if err := d.wgserver.RemovePeer(device.PublicKey); err != nil {
return errors.Wrap(err, "device was removed from storage but failed to be removed from the wireguard interface")
}
return nil
}
var nextIPLock = sync.Mutex{}
func (d *DeviceManager) nextClientAddress() (string, error) {
nextIPLock.Lock()
defer nextIPLock.Unlock()
devices, err := d.ListDevices()
if err != nil {
return "", errors.Wrap(err, "failed to list devices")
}
// TODO: read up on better ways to allocate client's IP
// addresses from a configurable CIDR
usedIPs := []net.IP{
MustParseIP("10.0.0.0"),
MustParseIP("10.0.0.1"),
MustParseIP("10.0.0.255"),
}
for _, device := range devices {
ip, _ := MustParseCIDR(device.Address)
usedIPs = append(usedIPs, ip)
}
ip := vpnip
for ip := ip.Mask(vpnsubnet.Mask); vpnsubnet.Contains(ip); ip = nextIP(ip) {
if !contains(usedIPs, ip) {
return fmt.Sprintf("%s/32", ip.String()), nil
}
}
return "", fmt.Errorf("there are no free IP addresses in the vpn subnet: '%s'", vpnsubnet)
}
func contains(ips []net.IP, target net.IP) bool {
for _, ip := range ips {
if ip.Equal(target) {
return true
}
}
return false
}
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
}

@ -0,0 +1,22 @@
package storage
import (
"time"
)
type Storage interface {
Save(*Device) error
List() ([]*Device, error)
Get(string) (*Device, error)
Delete(*Device) error
}
type Device struct {
Name string `json:"name"`
PublicKey string `json:"publicKey"`
Endpoint string `json:"endpoint"`
Address string `json:"address"`
DNS string `json:"dns"`
CreatedAt time.Time `json:"createdAt"`
ServerPublicKey string `json:"serverPublicKey"`
}

@ -0,0 +1,82 @@
package storage
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
// implements Storage interface
type DiskStorage struct {
directory string
}
func NewDiskStorage(directory string) *DiskStorage {
if _, err := os.Stat(directory); os.IsNotExist(err) {
if err := os.MkdirAll(directory, 0600); err != nil {
logrus.Fatal(errors.Wrap(err, "failed to create storage directory"))
}
}
return &DiskStorage{directory}
}
func (s *DiskStorage) Save(device *Device) error {
path := s.deviceFilePath(device.Name)
logrus.Infof("saving new device %s", path)
bytes, err := json.Marshal(device)
if err != nil {
return errors.Wrap(err, "failed to marshal device")
}
if err := ioutil.WriteFile(path, bytes, 0600); err != nil {
return errors.Wrapf(err, "failed to write device to file %s", path)
}
return nil
}
func (s *DiskStorage) List() ([]*Device, error) {
devices := []*Device{}
files, err := ioutil.ReadDir(s.directory)
if err != nil {
return nil, errors.Wrap(err, "failed to list storage directory")
}
for _, file := range files {
device, err := s.Get(filepath.Base(strings.TrimSuffix(file.Name(), filepath.Ext(file.Name()))))
if err != nil {
return nil, errors.Wrap(err, "failed to read device file")
}
devices = append(devices, device)
}
return devices, nil
}
func (s *DiskStorage) Get(name string) (*Device, error) {
path := s.deviceFilePath(name)
bytes, err := ioutil.ReadFile(path)
if err != nil {
return nil, errors.Wrapf(err, "failed to read device file %s", path)
}
device := &Device{}
if err := json.Unmarshal(bytes, device); err != nil {
return nil, errors.Wrapf(err, "failed to unmarshal device file %s", path)
}
return device, nil
}
func (s *DiskStorage) Delete(device *Device) error {
if err := os.Remove(s.deviceFilePath(device.Name)); err != nil {
return errors.Wrap(err, "failed to delete device file")
}
return nil
}
func (s *DiskStorage) deviceFilePath(name string) string {
// TODO: protect against path traversal
// and make sure names are reasonably sane
return filepath.Join(s.directory, fmt.Sprintf("%s.json", name))
}

@ -0,0 +1,38 @@
package storage
import "errors"
// implements Storage interface
type InMemoryStorage struct{}
var memory = map[string]*Device{}
func NewMemoryStorage() *InMemoryStorage {
return &InMemoryStorage{}
}
func (s *InMemoryStorage) Save(device *Device) error {
memory[device.Name] = device
return nil
}
func (s *InMemoryStorage) List() ([]*Device, error) {
devices := []*Device{}
for _, device := range memory {
devices = append(devices, device)
}
return devices, nil
}
func (s *InMemoryStorage) Get(name string) (*Device, error) {
device, ok := memory[name]
if !ok {
return nil, errors.New("device doesn't exist")
}
return device, nil
}
func (s *InMemoryStorage) Delete(device *Device) error {
delete(memory, device.Name)
return nil
}

@ -0,0 +1,53 @@
package web
import (
"encoding/json"
"net/http"
"github.com/place1/wireguard-access-server/internal/services"
"github.com/place1/wireguard-access-server/internal/storage"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
type AddDeviceRequest struct {
Name string `json:"name"`
PublicKey string `json:"publicKey"`
}
type AddDeviceResponse struct {
Device *storage.Device `json:"device"`
}
func AddDevice(devices *services.DeviceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
req := AddDeviceRequest{}
if err := decoder.Decode(&req); err != nil {
logrus.Error(errors.Wrap(err, "unable to decode request body"))
http.Error(w, "bad request payload", http.StatusBadRequest)
return
}
device, err := devices.AddDevice(req.Name, req.PublicKey)
if err != nil {
logrus.Error(errors.Wrap(err, "unable to add device"))
http.Error(w, "failed to add the new device", http.StatusInternalServerError)
return
}
response, err := json.Marshal(AddDeviceResponse{
Device: device,
})
if err != nil {
logrus.Error(errors.Wrap(err, "failed to marshal response"))
http.Error(w, "failed to marshal response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(response)
return
}
}

@ -0,0 +1,32 @@
package web
import (
"net/http"
"github.com/gorilla/mux"
"github.com/place1/wireguard-access-server/internal/services"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
func DeleteDevice(devices *services.DeviceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
name, ok := vars["name"]
if !ok {
http.Error(w, "missing device name in path", http.StatusBadRequest)
return
}
if err := devices.DeleteDevice(name); err != nil {
logrus.Error(errors.Wrap(err, "failed to remove device"))
http.Error(w, "failed to remove device", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
}

@ -0,0 +1,41 @@
package web
import (
"encoding/json"
"net/http"
"github.com/pkg/errors"
"github.com/place1/wireguard-access-server/internal/services"
"github.com/place1/wireguard-access-server/internal/storage"
"github.com/sirupsen/logrus"
)
type ListDeviceRequest struct{}
type ListDeviceResponse struct {
Items []*storage.Device `json:"items"`
}
func ListDevices(devices *services.DeviceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
devices, err := devices.ListDevices()
if err != nil {
logrus.Error(errors.Wrap(err, "failed to list devices"))
http.Error(w, "failed to list devices", http.StatusInternalServerError)
return
}
response, err := json.Marshal(ListDeviceResponse{
Items: devices,
})
if err != nil {
logrus.Error(errors.Wrap(err, "failed to marshal response"))
http.Error(w, "failed to marshal response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(response)
return
}
}

@ -0,0 +1,174 @@
package wg
import (
"fmt"
"net"
"strings"
"sync"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
type Server struct {
client *wgctrl.Client
iface string
externalName string
port int
publicKey wgtypes.Key
lock sync.Mutex
}
func New(iface string, privateKey string, port int, externalName string) (*Server, error) {
// wgctrl.New() will search for a kernel implementation
// of wireguard, then user implementations
// user implementations are found in /var/run/wireguard/<iface>.sock
// this unix socket likely requires root to access
client, err := wgctrl.New()
if err != nil {
logrus.Fatal(errors.Wrap(err, "failed to create wgctrl"))
}
key, err := wgtypes.ParseKey(privateKey)
if err != nil {
return nil, errors.Wrap(err, "bad private key format")
}
server := &Server{
client: client,
iface: iface,
port: port,
externalName: externalName,
publicKey: key.PublicKey(),
}
err = server.configure(func(config *wgtypes.Config) error {
config.PrivateKey = &key
config.ListenPort = &port
return nil
})
if err != nil {
return nil, errors.Wrap(err, "failed to configure wireguard - is wireguard running?")
}
return server, nil
}
func (s *Server) AddPeer(publicKey string, addressCIDR string) error {
logrus.
WithField("publicKey", publicKey).
WithField("address", addressCIDR).
Debugf("adding peer")
key, err := wgtypes.ParseKey(publicKey)
if err != nil {
return errors.Wrapf(err, "bad public key %v", publicKey)
}
_, allowedIPs, err := net.ParseCIDR(addressCIDR)
if err != nil || allowedIPs == nil {
return errors.Wrap(err, "bad CIDR value for AllowedIPs")
}
if s.HasPeer(key.String()) {
s.RemovePeer(key.String())
}
return s.configure(func(config *wgtypes.Config) error {
config.ReplacePeers = false
config.Peers = []wgtypes.PeerConfig{
wgtypes.PeerConfig{
PublicKey: key,
AllowedIPs: []net.IPNet{*allowedIPs},
},
}
return nil
})
}
func (s *Server) ListPeers() ([]wgtypes.Peer, error) {
d, err := s.Device()
if err != nil {
return nil, err
}
return d.Peers, nil
}
func (s *Server) Peer(publicKey string) (*wgtypes.Peer, error) {
peers, err := s.ListPeers()
if err != nil {
return nil, err
}
for _, peer := range peers {
if peer.PublicKey.String() == publicKey {
return &peer, nil
}
}
return nil, fmt.Errorf("peer with public key '%s' not found", publicKey)
}
func (s *Server) HasPeer(publicKey string) bool {
peers, err := s.ListPeers()
if err != nil {
logrus.Error(errors.Wrap(err, "failed to list peers"))
return false
}
for _, peer := range peers {
if peer.PublicKey.String() == publicKey {
return true
}
}
return false
}
func (s *Server) RemovePeer(publicKey string) error {
logrus.WithField("publicKey", publicKey).Debug("removing peer")
key, err := wgtypes.ParseKey(publicKey)
if err != nil {
return errors.Wrap(err, "bad public key")
}
return s.configure(func(config *wgtypes.Config) error {
config.ReplacePeers = false
config.Peers = []wgtypes.PeerConfig{
wgtypes.PeerConfig{
Remove: true,
PublicKey: key,
},
}
return nil
})
}
func (s *Server) PublicKey() string {
return s.publicKey.String()
}
func (s *Server) Endpoint() string {
return fmt.Sprintf("%s:%d", s.externalName, s.port)
}
func (s *Server) DNS() string {
return "1.1.1.1, 8.8.8.8" // TODO: dns stuff
}
func (s *Server) Device() (*wgtypes.Device, error) {
return s.client.Device(s.iface)
}
func (s *Server) Close() error {
return s.client.Close()
}
func (s *Server) configure(cb func(*wgtypes.Config) error) error {
s.lock.Lock()
defer s.lock.Unlock()
next := wgtypes.Config{}
if err := cb(&next); err != nil {
return errors.Wrap(err, "failed to get next wireguard config")
} else {
return s.client.ConfigureDevice(s.iface, next)
}
}
func trimLines(input string) string {
lines := strings.Split(strings.TrimSpace(input), "\n")
output := make([]string, len(lines))
for index, line := range lines {
output[index] = strings.TrimSpace(line)
}
return strings.Join(output, "\n")
}

@ -0,0 +1,136 @@
package main
import (
"fmt"
"net/http"
"os/exec"
"time"
"github.com/coreos/go-iptables/iptables"
"github.com/vishvananda/netlink"
"github.com/gorilla/mux"
"github.com/pkg/errors"
"github.com/place1/wireguard-access-server/internal/config"
"github.com/place1/wireguard-access-server/internal/services"
"github.com/place1/wireguard-access-server/internal/storage"
"github.com/place1/wireguard-access-server/internal/web"
"github.com/place1/wireguard-access-server/internal/wg"
"github.com/sirupsen/logrus"
)
func main() {
config := config.Read()
// Userspace wireguard command
if config.WireGuard.UserspaceImplementation != "" {
go func() {
logrus.Infof("using userspace wireguard implementation %s", config.WireGuard.UserspaceImplementation)
var command *exec.Cmd
if config.WireGuard.UserspaceImplementation == "boringtun" {
command = exec.Command(
config.WireGuard.UserspaceImplementation,
config.WireGuard.InterfaceName,
"--disable-drop-privileges=root",
"--foreground",
)
} else {
command = exec.Command(
config.WireGuard.UserspaceImplementation,
"-f",
config.WireGuard.InterfaceName,
)
}
entry := logrus.NewEntry(logrus.New()).WithField("process", config.WireGuard.UserspaceImplementation)
command.Stdout = entry.Writer()
command.Stderr = entry.Writer()
logrus.Infof("starting %s", command.String())
if err := command.Run(); err != nil {
logrus.Fatal(errors.Wrap(err, "userspace wireguard exitted"))
}
}()
// Wait for the userspace wireguard process to
// startup and create the wg0 interface
// Super sorry if this just caused a race
// condition for you :(
time.Sleep(1 * time.Second)
}
// WireGuard
wgserver, err := wg.New(
config.WireGuard.InterfaceName,
config.WireGuard.PrivateKey,
config.WireGuard.Port,
config.Web.ExternalAddress,
)
if err != nil {
logrus.Fatal(errors.Wrap(err, "failed to create wgserver"))
}
defer wgserver.Close()
logrus.Infof("wireguard server public key is %s", wgserver.PublicKey())
logrus.Infof("wireguard endpoint is %s", wgserver.Endpoint())
// Networking configuration (ip links and route tables)
link, err := netlink.LinkByName(config.WireGuard.InterfaceName)
if err != nil {
logrus.Fatal(errors.Wrap(err, "failed to find wireguard interface"))
}
addr, err := netlink.ParseAddr("10.0.0.1/24")
if err != nil {
logrus.Fatal(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"))
}
// Networking configuration (iptables)
if config.VPN.GatewayInterface != nil {
ipt, err := iptables.New()
if err != nil {
logrus.Fatal(errors.Wrap(err, "failed to init iptables"))
}
if err := ipt.AppendUnique("filter", "FORWARD", "-s", "10.0.0.1/24", "-o", config.WireGuard.InterfaceName, "-j", "ACCEPT"); err != nil {
logrus.Fatal(errors.Wrap(err, "failed to set ip tables rule"))
}
if err := ipt.AppendUnique("filter", "FORWARD", "-s", "10.0.0.1/24", "-i", config.WireGuard.InterfaceName, "-j", "ACCEPT"); err != nil {
logrus.Fatal(errors.Wrap(err, "failed to set ip tables rule"))
}
if err := ipt.AppendUnique("nat", "POSTROUTING", "-s", "10.0.0.1/24", "-o", config.VPN.GatewayInterface.Attrs().Name, "-j", "MASQUERADE"); err != nil {
logrus.Fatal(errors.Wrap(err, "failed to set ip tables rule"))
}
}
// Storage
var storageDriver storage.Storage
if config.Storage.Directory != "" {
storageDriver = storage.NewDiskStorage(config.Storage.Directory)
} else {
storageDriver = storage.NewMemoryStorage()
}
// Services
deviceManager := services.NewDeviceManager(wgserver, storageDriver)
if err := deviceManager.Sync(); err != nil {
logrus.Fatal(errors.Wrap(err, "failed to sync"))
}
// Router
router := mux.NewRouter()
router.HandleFunc("/api/devices", web.AddDevice(deviceManager)).Methods("POST")
router.HandleFunc("/api/devices", web.ListDevices(deviceManager)).Methods("GET")
router.HandleFunc("/api/devices/{name}", web.DeleteDevice(deviceManager)).Methods("DELETE")
router.PathPrefix("/").Handler(http.FileServer(http.Dir("website/build")))
// Listen
address := fmt.Sprintf("0.0.0.0:%d", config.Web.Port)
logrus.Infof("website listening on %s", address)
if err := http.ListenAndServe(address, router); err != nil {
logrus.Fatal(errors.Wrap(err, "server exited"))
}
}

23
website/.gitignore vendored

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

@ -0,0 +1,44 @@
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `yarn start`
Runs the app in the development mode.<br />
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br />
You will also see any lint errors in the console.
### `yarn test`
Launches the test runner in the interactive watch mode.<br />
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `yarn build`
Builds the app for production to the `build` folder.<br />
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br />
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

File diff suppressed because it is too large Load Diff

@ -0,0 +1,49 @@
{
"name": "website",
"version": "0.1.0",
"private": true,
"dependencies": {
"@material-ui/core": "^4.5.1",
"@material-ui/icons": "^4.5.1",
"@types/jest": "24.0.19",
"@types/node": "12.11.5",
"@types/react": "16.9.9",
"@types/react-dom": "16.9.2",
"common-tags": "^1.8.0",
"date-fns": "^2.6.0",
"qrcode": "^1.4.2",
"react": "^16.11.0",
"react-dom": "^16.11.0",
"react-easy-state": "^6.1.3",
"react-scripts": "3.2.0",
"tweetnacl-ts": "^1.0.3",
"typeface-roboto": "0.0.75",
"typescript": "3.6.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://localhost:8000",
"devDependencies": {
"@types/common-tags": "^1.8.0",
"@types/qrcode": "^1.3.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

@ -0,0 +1,2 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *

@ -0,0 +1,30 @@
export enum Platform {
Unknown,
Mac,
Ios,
Windows,
Android,
Linux,
}
// adapted from
// https://stackoverflow.com/questions/38241480/detect-macos-ios-windows-android-and-linux-os-with-js
export function getPlatform() {
const userAgent = window.navigator.userAgent;
const platform = window.navigator.platform;
const macosPlatforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K'];
const windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE'];
const iosPlatforms = ['iPhone', 'iPad', 'iPod'];
if (macosPlatforms.indexOf(platform) !== -1) {
return Platform.Mac;
} else if (iosPlatforms.indexOf(platform) !== -1) {
return Platform.Ios;
} else if (windowsPlatforms.indexOf(platform) !== -1) {
return Platform.Windows;
} else if (/Android/.test(userAgent)) {
return Platform.Android;
} else if (/Linux/.test(platform)) {
return Platform.Linux;
}
return Platform.Unknown;
}

@ -0,0 +1 @@
/// <reference types="react-scripts" />

@ -0,0 +1,19 @@
import { store } from 'react-easy-state';
export interface IDevice {
name: string;
publicKey: string;
endpoint: string;
address: string;
dns: string;
createdAt: string;
serverPublicKey: string;
// TODO: these fields on backend
// receiveBytes: number;
// transmitBytes: number;
// lastHandshakeTime: string;
}
export const AppState = store({
devices: new Array<IDevice>(),
});

@ -0,0 +1,124 @@
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 AddIcon from '@material-ui/icons/Add';
import CardActions from '@material-ui/core/CardActions';
import Button from '@material-ui/core/Button';
import TextField from '@material-ui/core/TextField';
import Dialog from '@material-ui/core/Dialog';
import DialogTitle from '@material-ui/core/DialogTitle';
import DialogContent from '@material-ui/core/DialogContent';
import DialogActions from '@material-ui/core/DialogActions';
import qrcode from 'qrcode';
import { view } from 'react-easy-state';
import { box_keyPair } from 'tweetnacl-ts';
import { codeBlock } from 'common-tags';
import { FormHelperText } from '@material-ui/core';
import { GetConnected } from './GetConnected';
import { IDevice, AppState } from '../Store';
class AddDevice extends React.Component {
state = {
name: '',
open: false,
qrCodeUri: '',
configFileUri: '',
error: '',
};
onAdd = async (event: React.FormEvent) => {
event.preventDefault();
const keypair = box_keyPair();
const b64PublicKey = window.btoa(String.fromCharCode(...new Uint8Array(keypair.publicKey) as any));
const b64PrivateKey = window.btoa(String.fromCharCode(...new Uint8Array(keypair.secretKey) as any));
const res = await fetch('/api/devices', {
method: 'POST',
body: JSON.stringify({
name: this.state.name,
publicKey: b64PublicKey,
}),
});
if (res.status >= 400) {
this.setState({ error: await res.text() });
return;
}
const { device } = await res.json() as { device: IDevice };
AppState.devices.push(device);
const configFile = codeBlock`
[Interface]
PrivateKey = ${b64PrivateKey}
Address = ${device.address}
DNS = ${'1.1.1.1, 8.8.8.8'}
[Peer]
PublicKey = ${device.serverPublicKey}
AllowedIPs = 0.0.0.0/0
Endpoint = ${device.endpoint}
`;
this.setState({
open: true,
qrCodeUri: await qrcode.toDataURL(configFile),
configFileUri: URL.createObjectURL(new Blob([configFile])),
});
}
render() {
return (
<form onSubmit={this.onAdd}>
<Card>
<CardHeader
title="Add a device"
/>
<CardContent>
<TextField
label="Device Name"
error={this.state.error !== ''}
value={this.state.name}
onChange={(event) => this.setState({ name: event.currentTarget.value })}
style={{ marginTop: -20, marginBottom: 8 }}
fullWidth
/>
{this.state.error !== '' && <FormHelperText>{this.state.error}</FormHelperText>}
</CardContent>
<CardActions>
<Button
color="primary"
variant="contained"
endIcon={<AddIcon />}
type="submit"
>
Add
</Button>
<Dialog
disableBackdropClick
disableEscapeKeyDown
maxWidth="xl"
open={this.state.open}
>
<DialogTitle>Get Connected</DialogTitle>
<DialogContent>
<GetConnected
qrCodeUri={this.state.qrCodeUri}
configFileUri={this.state.configFileUri}
/>
</DialogContent>
<DialogActions>
<Button color="secondary" variant="outlined" onClick={() => this.setState({ open: false })}>
Done
</Button>
</DialogActions>
</Dialog>
</CardActions>
</Card>
</form>
);
}
}
export default view(AddDevice);

@ -0,0 +1,65 @@
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 MenuItem from '@material-ui/core/MenuItem';
import formatDistanceToNow from 'date-fns/formatDistanceToNow'
import { view } from 'react-easy-state';
import { IDevice, AppState } from '../Store';
import { IconMenu } from './IconMenu';
import { PopoverDisplay } from './PopoverDisplay';
interface Props {
device: IDevice;
}
class Device extends React.Component<Props> {
dateString(date: Date) {
if (date.getUTCMilliseconds() === 0) {
return 'never';
}
return formatDistanceToNow(date, { addSuffix: true });
}
removeDevice = async () => {
const res = await fetch(`/api/devices/${this.props.device.name}`, {
method: 'DELETE',
});
if (res.status === 204) {
AppState.devices = AppState.devices.filter(device => device.name !== this.props.device.name);
} else {
window.alert(await res.text());
}
}
render() {
const device = this.props.device;
return (
<Card>
<CardHeader
title={device.name}
avatar={<Avatar><DonutSmallIcon /></Avatar>}
action={
<IconMenu>
<MenuItem style={{ color: 'red' }} onClick={this.removeDevice}>Delete</MenuItem>
</IconMenu>
}
/>
<CardContent>
<Typography component="p">
Public Key: <PopoverDisplay label="show">{device.publicKey}</PopoverDisplay>
</Typography>
<Typography component="p">
Endpoint: {device.endpoint}
</Typography>
</CardContent>
</Card>
);
}
}
export default view(Device);

@ -0,0 +1,39 @@
import React from 'react';
import Grid from '@material-ui/core/Grid';
import { view } from 'react-easy-state';
import { AppState } from '../Store';
import Device from './Device';
import AddDevice from './AddDevice';
class Devices extends React.Component {
componentDidMount() {
this.load();
}
async load() {
const res = await fetch('/api/devices');
const data = await res.json();
AppState.devices = data.items;
}
render() {
return (
<Grid container spacing={3} style={{ padding: '1rem' }}>
<Grid item xs={12} sm={6}>
<h1>Your Devices</h1>
</Grid>
<Grid item xs={12} sm={6}>
<AddDevice />
</Grid>
{AppState.devices.map((device, i) =>
<Grid key={i} item xs={12} sm={6}>
<Device device={device} />
</Grid>
)}
</Grid>
);
}
}
export default view(Devices);

@ -0,0 +1,36 @@
import React from 'react';
import Fab from '@material-ui/core/Fab';
import VerifiedUserIcon from '@material-ui/icons/VerifiedUser';
interface Props {
configFileUri: string;
}
export class DownloadConfig extends React.Component<Props> {
downloadConfig = () => {
console.log('downloading config file', this.props.configFileUri);
const anchor = document.createElement('a');
anchor.href = this.props.configFileUri;
anchor.download = 'wireguard.conf';
anchor.style.display = 'none';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
}
render() {
return (
<Fab
variant="extended"
size="small"
color="primary"
style={{ padding: 30, borderRadius: 60 }}
onClick={this.downloadConfig}
>
Download VPN Config
<VerifiedUserIcon style={{ marginLeft: 15 }} />
</Fab>
);
}
}

@ -0,0 +1,30 @@
import React from 'react';
import Fab from '@material-ui/core/Fab';
interface Props {
label: string;
icon: React.ReactNode;
href: string;
}
export class DownloadLink extends React.Component<Props> {
render() {
return (
<a
href={this.props.href}
target="__blank"
rel="noopener noreferrer"
>
<Fab
variant="extended"
size="small"
color="primary"
style={{ padding: 30, borderRadius: 60 }}
>
<span>{this.props.label}</span>
<span style={{ marginLeft: 15 }}>{this.props.icon}</span>
</Fab>
</a>
);
}
}

@ -0,0 +1,163 @@
import React from 'react';
import Tabs from '@material-ui/core/Tabs';
import Tab from '@material-ui/core/Tab';
import Paper from '@material-ui/core/Paper';
import Grid from '@material-ui/core/Grid';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import Typography from '@material-ui/core/Typography';
import { MacOSIcon, IosIcon, WindowsIcon, LinuxIcon, AndroidIcon } from './Icons';
import { TabPanel } from './TabPanel';
import { Platform, getPlatform } from '../Platform';
import { DownloadConfig } from './DownloadConfig';
import { DownloadLink } from './DownloadLink';
interface Props {
qrCodeUri: string;
configFileUri: string;
}
export class GetConnected extends React.Component<Props> {
state = {
platform: getPlatform(),
}
render() {
return (
<React.Fragment>
<Paper>
<Tabs
value={this.state.platform}
onChange={(_, platform) => this.setState({ platform })}
indicatorColor="primary"
textColor="primary"
variant="scrollable"
scrollButtons="auto"
>
<Tab icon={<LinuxIcon />} value={Platform.Linux} />
<Tab icon={<MacOSIcon />} value={Platform.Mac} />
<Tab icon={<WindowsIcon />} value={Platform.Windows} />
<Tab icon={<IosIcon />} value={Platform.Ios} />
<Tab icon={<AndroidIcon />} value={Platform.Android} />
</Tabs>
</Paper>
<TabPanel for={Platform.Linux} value={this.state.platform}>
<Grid container direction="row" justify="space-around" alignItems="center">
<Grid item xs={12} sm={6}>
<List>
<ListItem>
<ListItemText primary="1. hmmm todo" />
</ListItem>
</List>
</Grid>
<Grid item direction="column" container spacing={3} xs={12} sm={6}>
<Grid item>
<DownloadConfig configFileUri={this.props.configFileUri} />
</Grid>
<Grid item>
<DownloadLink
label="Download WireGuard"
href="https://www.wireguard.com/install/"
icon={<LinuxIcon />}
/>
</Grid>
</Grid>
</Grid>
</TabPanel>
<TabPanel for={Platform.Mac} value={this.state.platform}>
<Grid container direction="row" justify="space-around" alignItems="center">
<Grid item xs={12} sm={6}>
<List>
<ListItem>
<ListItemText primary="1. Install WireGuard for MacOS" />
</ListItem>
<ListItem>
<ListItemText primary="2. Download your connection file" />
</ListItem>
<ListItem>
<ListItemText primary="3. Add tunnel from file" />
</ListItem>
</List>
</Grid>
<Grid item direction="column" container spacing={3} xs={12} sm={6}>
<Grid item>
<DownloadConfig configFileUri={this.props.configFileUri} />
</Grid>
<Grid item>
<DownloadLink
label="Download WireGuard"
href="https://itunes.apple.com/us/app/wireguard/id1451685025?ls=1&mt=12"
icon={<MacOSIcon />}
/>
</Grid>
</Grid>
</Grid>
</TabPanel>
<TabPanel for={Platform.Ios} value={this.state.platform}>
<Grid container direction="row" justify="space-around" alignItems="center">
<Grid item>
<List>
<ListItem>
<ListItemText primary="1. Install the WireGuard app" />
</ListItem>
<ListItem>
<ListItemText primary="2. Add a tunnel" />
</ListItem>
<ListItem>
<ListItemText primary="3. Create from QR code" />
</ListItem>
</List>
</Grid>
<Grid item>
<img alt="wireguard qr code" src={this.props.qrCodeUri} />
</Grid>
</Grid>
</TabPanel>
<TabPanel for={Platform.Android} value={this.state.platform}>
<p>TODO: I don't have an android phone :(</p>
<p>PRs welcome :)</p>
</TabPanel>
<TabPanel for={Platform.Windows} value={this.state.platform}>
<Grid container direction="row" justify="space-around" alignItems="center">
<Grid item xs={12} sm={6}>
<List>
<ListItem>
<ListItemText primary="1. Install WireGuard for Windows" />
</ListItem>
<ListItem>
<ListItemText primary="2. Download your connection file" />
</ListItem>
<ListItem>
<ListItemText primary="3. Add tunnel from file" />
</ListItem>
</List>
</Grid>
<Grid item direction="column" container spacing={3} xs={12} sm={6}>
<Grid item>
<DownloadConfig configFileUri={this.props.configFileUri} />
</Grid>
<Grid item>
<DownloadLink
label="Download WireGuard"
href="https://download.wireguard.com/windows-client/wireguard-amd64-0.0.32.msi"
icon={<WindowsIcon />}
/>
</Grid>
</Grid>
</Grid>
</TabPanel>
<Grid container justify="center">
<Typography style={{ fontStyle: 'italic', maxWidth: 600 }}>
The VPN configuration file or QR code will not be available again.
<br />
If you lose your connection settings or reset your device, you can remove and re-add it to generate a new connection file or QR code.
<br />
They contain your WireGuard Private Key and should never be shared.
</Typography>
</Grid>
</React.Fragment>
);
}
}

@ -0,0 +1,37 @@
import React from 'react';
import IconButton from '@material-ui/core/IconButton';
import MoreVertIcon from '@material-ui/icons/MoreVert';
import Menu from '@material-ui/core/Menu';
interface Props {
children: React.ReactNode;
}
export function IconMenu(props: Props) {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<div>
<IconButton aria-controls="icon-menu" aria-haspopup="true" onClick={handleClick}>
<MoreVertIcon />
</IconButton>
<Menu
id="icon-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}
>
<div onClick={handleClose}>{props.children}</div>
</Menu>
</div>
);
}

File diff suppressed because one or more lines are too long

@ -0,0 +1,36 @@
import React from 'react';
import Button from '@material-ui/core/Button';
import Popover from '@material-ui/core/Popover';
interface Props {
label: string;
children: React.ReactNode;
}
export class PopoverDisplay extends React.Component<Props> {
state = {
anchorEl: undefined as any,
}
render() {
return (
<React.Fragment>
<Button variant="text" color="secondary" onClick={(event) => this.setState({ anchorEl: event.currentTarget })}>
{this.props.label}
</Button>
<Popover
open={Boolean(this.state.anchorEl)}
anchorEl={this.state.anchorEl}
onClose={() => this.setState({ anchorEl: undefined })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<div style={{ padding: '2rem' }}>
{this.props.children}
</div>
</Popover>
</React.Fragment>
)
}
}

@ -0,0 +1,16 @@
import React from 'react';
interface Props {
for: any;
value: any;
}
export class TabPanel extends React.Component<Props> {
render() {
return (
<div style={{ padding: '1.5rem 1rem' }} hidden={this.props.for !== this.props.value}>
{this.props.children}
</div>
);
}
}

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom';
import Devices from './components/Devices';
import { view } from 'react-easy-state';
import 'typeface-roboto';
import './index.css';
const App = view(() => {
return <Devices />;
});
ReactDOM.render(<App />, document.getElementById('root'));

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
},
"include": [
"src"
]
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save