Initial prototype server

master
Ambrose Chua 2019-12-20 17:41:49 +08:00
parent 5801fb1087
commit 42eb0ea8ef
8 changed files with 289 additions and 94 deletions

View File

@ -11,7 +11,7 @@ In summary:
* Manage "client" keys
* Exchange keys over HTTP(S)
* Exchange IP addressing (DHCP-like)
* Exchange IP addressing
* Manually gate new peers
* Sets up network interface on the "client"
* Generate Ansible INI inventory
@ -27,3 +27,44 @@ The primary scenario this tool is going to be used for is to manage machines usi
# Usage
> TODO
# Operation
## Server
The "server" manages a WireGuard interface, treating a WireGuard configuration file as a database. It assumes this interface and configuration exists. It only adds new peers to the configuration file and interface, and does not delete existing configuration.
The "server" also exposes the HTTP server with the following endpoints:
### `POST /request`
Request for the assignment of an IP address and accepted as a peer. This blocks until the server has finished configuring the peer, therefore the client SHOULD NOT timeout.
#### Request Body
Content-Type: application/x-www-form-urlencoded
| Name | Description | Required |
|------|-------------|----------|
| PublicKey | The public key of the "client" peer | X |
#### Response Body
Content-Type: application/json
| Name | Type | Description |
|------|------|-------------|
| PublicKey | String | Base64 encoded public key of the "server" peer |
| Endpoint | String | The endpoint of the "server" peer |
| PersistentKeepaliveInterval | Number | Suggests a PersistentKeepaliveInterval |
| AllowedIPs | []String | List of allowed IP addresses in CIDR notation |
| InterfaceIPs | []String | List of IP addresses assigned to the "client" interface |
## Client
The "client" sets up a WireGuard interface, and relies on network backends to do so. *It should not be run more than once*. The following network backends are supported:
- (Not implemented) `none`: Creates an interface and WireGuard configuration file
- `networkd`: Creates a `systemd.netdev` file in `/etc/systemd/network`
It does so by performing `POST /request` to the "server".

View File

@ -19,33 +19,58 @@ var CmdRequest = &cli.Command{
Usage: "Name for new WireGuard interface",
},
&cli.StringFlag{
Name: "config",
Name: "none",
Aliases: []string{"c"},
Value: "",
DefaultText: "/etc/wireguard/<interface>.conf",
Usage: "Path to the WireGuard configuration file",
Usage: "Path to save a WireGuard configuration file",
},
&cli.StringFlag{
Name: "type",
Value: "none",
Usage: "Select network interface backend. Currently only none and networkd are implemented",
Name: "networkd",
Aliases: []string{"n"},
Value: "",
DefaultText: "/etc/systemd/network/<interface>.netdev",
Usage: "Path to save a networkd configuration file",
},
&cli.StringFlag{
Name: "type",
Aliases: []string{"t"},
Value: "networkd",
Usage: "Select network interface backend. Currently only networkd is implemented",
},
&cli.StringFlag{
Name: "server",
Aliases: []string{"s"},
Usage: "wireguard-negotiator server URL",
Required: true,
EnvVars: []string{"WGN_SERVER_URL"},
},
&cli.BoolFlag{
Name: "insecure",
Usage: "Disable TLS verification",
EnvVars: []string{"WGN_SERVER_INSECURE"},
},
},
}
func runRequest(ctx *cli.Context) error {
inter := ctx.String("interface")
config := ctx.String("config")
if !ctx.IsSet("config") {
config = "/etc/wireguard/" + inter + ".conf"
}
netBackend := ctx.String("type")
noneConfig := ctx.String("none")
if !ctx.IsSet("none") {
noneConfig = "/etc/wireguard/" + inter + ".conf"
}
networkdConfig := ctx.String("networkd")
if !ctx.IsSet("networkd") {
networkdConfig = "/etc/systemd/network/" + inter + ".netdev"
}
client := lib.NewClient(ctx.String("server"), ctx.Bool("insecure"))
log.Println(inter)
log.Println(config)
log.Println(netBackend)
log.Println(noneConfig)
log.Println(networkdConfig)
log.Println(client)
return nil

View File

@ -1,16 +1,23 @@
package cmd
import (
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"syscall"
"github.com/urfave/cli/v2"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"gopkg.in/ini.v1"
)
var ErrNoAddressesFound = fmt.Errorf("No address found on the interface")
var CmdServer = &cli.Command{
Name: "server",
Usage: "Start the wireguard-negotiator server",
@ -26,13 +33,14 @@ var CmdServer = &cli.Command{
Aliases: []string{"c"},
Value: "",
DefaultText: "/etc/wireguard/<interface>.conf",
Usage: "Path to the WireGuard configuration file",
Usage: "Path to the existing WireGuard configuration file",
},
&cli.StringFlag{
Name: "endpoint",
Aliases: []string{"e"},
Value: "",
Usage: "Set the endpoint address",
Name: "endpoint",
Aliases: []string{"e"},
Value: "",
Required: true,
Usage: "Set the endpoint address",
},
&cli.StringFlag{
Name: "listen",
@ -51,33 +59,37 @@ type request struct {
func runServer(ctx *cli.Context) error {
inter := ctx.String("interface")
interf, err := net.InterfaceByName(inter)
if err != nil {
log.Fatal(err)
}
config := ctx.String("config")
if !ctx.IsSet("config") {
config = "/etc/wireguard/" + inter + ".conf"
}
endpoint := ctx.String("endpoint")
if !ctx.IsSet("endpoint") {
log.Fatal("Please specify endpoint with -endpoint")
}
listen := ctx.String("listen")
// Obtain the server's public key
serverPublicKey := configReadInterfacePublicKey(config)
// Obtain the network interface
interf, err := net.InterfaceByName(inter)
if err != nil {
return err
}
// Obtain the server's public key
serverPublicKey, err := configReadInterfacePublicKey(config)
if err != nil {
return err
}
// TODO: Define this allocation method
// TODO: Include allocation behaviour in README
terribleCounterThatShouldNotExist := 1
interfAddrs, err := interf.Addrs()
if err != nil {
log.Fatal(err)
return err
}
// TODO: Define this allocation method
// TODO: Include allocation behaviour in README
// Obtain interface address for use in allocation
var interfIPNet *net.IPNet
if len(interfAddrs) < 1 {
log.Fatal("No address found on the interface")
return ErrNoAddressesFound
}
_, interfIPNet, err = net.ParseCIDR(interfAddrs[0].String())
@ -90,62 +102,112 @@ func runServer(ctx *cli.Context) error {
// TODO: Rate limiting
http.HandleFunc("/request", func(w http.ResponseWriter, r *http.Request) {
publicKey := r.PostFormValue("public_key")
// TODO: Ensure public key is new
// Assign an IP address
terribleCounterThatShouldNotExist += 1
ip := incrementIP(interfIPNet.IP, terribleCounterThatShouldNotExist)
if !interfIPNet.Contains(ip) {
log.Fatal("Ran out of IP addresses to allocate")
}
// Enqueue request into the gate
req := request{
ip: ip,
publicKey: publicKey,
}
// Wait for flush of configuration
gateQueue <- req
// Produce configuration to client
ipNet := &net.IPNet{
IP: ip,
Mask: interfIPNet.Mask,
}
resp := struct {
interfaceIP string `json:"interface_ip"`
peerAllowedIPs string `json:"peer_allowed_ips"`
peerPublicKey string `json:"peer_public_key"`
peerEndpoint string `json:"peer_endpoint"`
}{
ipNet.String(),
interfIPNet.IP.Mask(interfIPNet.Mask),
"",
"",
switch r.Method {
case "POST":
publicKey := r.PostFormValue("PublicKey")
// TODO: Ensure public key is new
if len(publicKey) == 0 {
w.WriteHeader(400)
return
}
// Assign an IP address
terribleCounterThatShouldNotExist += 1
ip := incrementIP(interfIPNet.IP, terribleCounterThatShouldNotExist)
if !interfIPNet.Contains(ip) {
log.Println("WARNING: Ran out of addresses to allocate")
w.WriteHeader(500)
return
}
// Enqueue request into the gate
req := request{
ip: ip,
publicKey: publicKey,
}
// Wait for flush of configuration
gateQueue <- req
// Produce configuration to client
ipNet := &net.IPNet{
IP: ip,
Mask: interfIPNet.Mask,
}
resp := struct {
InterfaceIPs string
AllowedIPs string
PublicKey string
Endpoint string
PersistentKeepaliveInterval int
}{
ipNet.String(),
interfIPNet.IP.Mask(interfIPNet.Mask).String(),
serverPublicKey,
endpoint,
25,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
default:
w.WriteHeader(405)
}
})
http.ListenAndServe(listen, nil)
// Shutdown notifier
go func() {
sigint := make(chan os.Signal, 1)
signal.Notify(sigint, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
<-sigint
close(gateQueue)
close(addQueue)
/*
if err := http.Shutdown(context.Background()); err != nil {
log.Printf("Server shutdown error: %v\n", err)
}
*/
}()
return nil
return http.ListenAndServe(listen, nil)
}
func adder(queue chan request, inter string, config string) {
// Write requests to config and add peer
for req := range queue {
configAddPeer(config, req)
interAddPeer(inter, req, config)
for {
select {
case req, ok := <-queue:
if !ok {
break
}
err := configAddPeer(config, req)
if err != nil {
log.Println(err)
}
err = interAddPeer(inter, req, config)
if err != nil {
log.Println(err)
}
}
}
}
func gater(queue chan request, result chan request) {
// Receive requests and prompt the admin
for req := range queue {
// For now, accept all
log.Println(req)
result <- req
for {
select {
case req, ok := <-queue:
if !ok {
break
}
// For now, accept all
log.Println(req.ip.String(), req.publicKey)
result <- req
}
}
}
func configAddPeer(config string, req request) {
func configAddPeer(config string, req request) error {
// For every request, we'll just open the config file again and rewrite it
// We don't need to optimise this because it happens infrequently
@ -159,36 +221,44 @@ func configAddPeer(config string, req request) {
publicKey.SetValue(req.publicKey)
allowedIPs := sec.Key("AllowedIPs")
allowedHost := ipToIPNetWithHostMask(req.ip)
allowedIPs.AddShadow((&allowedHost).String())
allowedIPs.SetValue((&allowedHost).String())
f, err := os.OpenFile(config, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Fatal(err)
return fmt.Errorf("opening %s failed: %w", config, err)
}
_, err = cfg.WriteTo(f)
if err != nil {
log.Fatal(err)
return fmt.Errorf("writing to %s failed: %w", config, err)
}
return nil
}
func configReadInterfacePublicKey(config string) string {
func configReadInterfacePublicKey(config string) (string, error) {
cfg, err := ini.Load(config)
if err != nil {
log.Fatal("Failed to read interface public key")
return "", fmt.Errorf("read interface public key failed: %w", err)
}
return cfg.Section("Interface").Key("PrivateKey")
b64PrivateKey := cfg.Section("Interface").Key("PrivateKey").String()
wgPrivateKey, err := wgtypes.ParseKey(b64PrivateKey)
if err != nil {
return "", fmt.Errorf("read interface public key failed: %w", err)
}
wgPublicKey := wgPrivateKey.PublicKey()
return wgPublicKey.String(), nil
}
func interAddPeer(inter string, req request, config string) {
func interAddPeer(inter string, req request, config string) error {
// For every request, we also need to dynamically add the peer to the interface
// For now, we simply run one fixed command to reread from the config file
cmd := exec.Command("wg", "setconf", inter, config)
err := cmd.Run()
if err != nil {
log.Println(err)
return fmt.Errorf("wq setconf failed: %w", err)
}
return nil
}
// Helpers
@ -206,11 +276,19 @@ func ipToIPNetWithHostMask(ip net.IP) net.IPNet {
}
}
func incrementIP(ip net.IP) net.IP {
func incrementIP(ip net.IP, inc int) net.IP {
result := make(net.IP, len(ip))
copy(result, ip)
for i := len(ip) - 1; i >= 0; i-- {
ip[i]++
if ip[i] != 0 {
break
remainder := inc % 256
overflow := int(result[i])+remainder > 255
result[i] += byte(remainder)
if overflow {
inc += 256
}
inc /= 256
}
return result
}

1
go.mod
View File

@ -4,5 +4,6 @@ go 1.13
require (
github.com/urfave/cli/v2 v2.0.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20191219145116-fa6499c8e75f
gopkg.in/ini.v1 v1.51.0
)

32
go.sum
View File

@ -1,6 +1,13 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw=
github.com/mdlayher/genetlink v0.0.0-20191205172946-651acf4b47ef/go.mod h1:0rJ0h4itni50A86M2kHcgS85ttZazNt7a8H2a2cw0Gc=
github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA=
github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@ -9,6 +16,31 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV
github.com/urfave/cli v1.22.2 h1:gsqYFH8bb9ekPA12kRo0hfjngWQjkJPlN9R0N78BoUo=
github.com/urfave/cli/v2 v2.0.0 h1:+HU9SCbu8GnEUFtIBfuUNXN39ofWViIEJIp6SURMpCg=
github.com/urfave/cli/v2 v2.0.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191002192127-34f69633bfdc/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413 h1:ULYEB3JvPRE/IfO+9uO7vKV/xzVTO7XPAwm8xbf4w2g=
golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
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/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/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-20190826190057-c7b8b68b1456/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/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191218084908-4a24b4065292/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
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.20191012 h1:sdX+y3hrHkW8KJkjY7ZgzpT5Tqo8XnBkH55U1klphko=
golang.zx2c4.com/wireguard v0.0.20191012/go.mod h1:P2HsVp8SKwZEufsnezXZA4GRX/T49/HlU7DGuelXsU4=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20191219145116-fa6499c8e75f h1:xc7xbwx/flY4HCaTpfgGqvsEMELJ5EBF82MP2lvfdbo=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20191219145116-fa6499c8e75f/go.mod h1:QKgTDEXdhtb9dg1EdxK63hefKjD1e+bSXUbRmZBfCSw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno=
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=

7
lib/wgconfig.go Normal file
View File

@ -0,0 +1,7 @@
package lib
import (
//"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// ReadConfig is yet another INI-like configuration file parser, but for WireGuard

23
lib/wgconfig_test.go Normal file
View File

@ -0,0 +1,23 @@
package lib
import "testing"
var testGoodConfiguration1 = `
# Test = Example comment
[Interface]
# Test comment 2
ListenPort = 3333
PrivateKey = MITUgapB4QfRFF54ITXL3TaiYiSsVYkchqfjAXjxM10=
[Peer]
PublicKey = pjFx72IjbMh84SH1nq8Qfbl7HD5mSScHXCV1eISR7lk=
AllowedIPs = 192.168.10.2/32, 2001:470:ed5d:a::2/128
[Peer]
AllowedIPs = 192.168.10.40/32, 2001:470:ed5d:a::28/128
PublicKey = wXU+vSTdEoIwSi+Tmv35SCOFg17wCAwnmYxeQPpbzDg=
`
func TestReadConfig(t *testing.T) {
}

14
main.go
View File

@ -13,19 +13,7 @@ func main() {
app := &cli.App{
Name: "wireguard-negotiator",
Usage: "Exchange WireGuard keys over HTTP(S)",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "server",
Aliases: []string{"s"},
Usage: "wireguard-negotiator server URL",
EnvVars: []string{"WGN_SERVER_URL"},
},
&cli.BoolFlag{
Name: "insecure",
Usage: "Disable TLS verification",
EnvVars: []string{"WGN_SERVER_INSECURE"},
},
},
Flags: []cli.Flag{},
Commands: []*cli.Command{
cmd.CmdServer,
cmd.CmdList,