diff --git a/.gitignore b/.gitignore index f1c181e..059c35d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,14 @@ *.dll *.so *.dylib +wesher +wg # Test binary, build with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out + +# Misc +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c766ad --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# wesher + +Mesh overlay network manager, using [wireguard](https://www.wireguard.com/). + +**⚠ WARNING**: since mesh membership is controlled by a mesh-wide pre-shared key, this effectively downgrades some of the +security benefits from wireguard. See "security considerations" below for more info. + +## Quickstart + +Before starting, make sure [wireguard](https://www.wireguard.com/) is installed on all nodes. + +Install `wesher` on all nodes with: +``` +$ go get github.com/costela/wesher +``` + +On the first node (assuming `$GOPATH/bin` is in the `$PATH`): +``` +# wesher +``` + +Running the command above on a terminal will currently output a generated cluster key, like: +``` +new cluster key generated: XXXXX +``` + +Then, on any further node: +``` +# wesher --clusterkey XXXXX --joinaddrs x.x.x.x +``` + +Where `XXXXX` is the base64 encoded 32 bit key printed by the step above and `x.x.x.x` is the hostname or IP of any of +the nodes already joined to the mesh cluster. + +*Note*: `wireguard`, and therefore `wesher`, need root access. + +## Overview + +## Configuration options + +## Security considerations + +The decision of whom to allow in the mesh is made by [memberlist](github.com/hashicorp/memberlist) and is secured by a +cluster-wide pre-shared key. +Compromise of this key will allow an attacker to: +- access services exposed on the overlay network +- impersonate and/or disrupt traffic to/from other nodes +It will not, however, allow the attacker access to decrypt the traffic between other nodes. + +This pre-shared key is currently static, set up during cluster bootstrapping, but will - in a future version - be +rotated. + +## Current known limitations + +### Overlay IP collisions + +Since the assignment of IPs on the overlay network is currently decided by the individual node and implemented as a +naive hashing of the hostname, there can be no guarantee two hosts will not generate the same overlay IPs. +This limitation may be worked around in a future version. + diff --git a/cluster.go b/cluster.go new file mode 100644 index 0000000..4b9c48c --- /dev/null +++ b/cluster.go @@ -0,0 +1,239 @@ +package main + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/gob" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net" + "os" + "path" + "time" + + "github.com/mattn/go-isatty" + + "github.com/hashicorp/errwrap" + + "github.com/hashicorp/go-multierror" + + "github.com/hashicorp/memberlist" + "github.com/sirupsen/logrus" +) + +// ClusterState keeps track of information needed to rejoin the cluster +type ClusterState struct { + ClusterKey []byte + Nodes []node +} + +type cluster struct { + localName string // used to avoid LocalNode(); should not change + ml *memberlist.Memberlist + wg *wgState + state *ClusterState + events chan memberlist.NodeEvent +} + +const statePath = "/var/lib/wesher/state.json" + +func newCluster(config *config, wg *wgState) (*cluster, error) { + clusterKey := config.ClusterKey + + state := loadState() + if len(clusterKey) == 0 { + clusterKey = state.ClusterKey + } + + if len(clusterKey) == 0 { + clusterKey = make([]byte, clusterKeyLen) + _, err := rand.Read(clusterKey) + if err != nil { + return nil, err + } + // TODO: refactor this into subcommand ("showkey"?) + if isatty.IsTerminal(os.Stdout.Fd()) { + fmt.Printf("new cluster key generated: %s\n", base64.StdEncoding.EncodeToString(clusterKey)) + } + } + state.ClusterKey = clusterKey + + mlConfig := memberlist.DefaultWANConfig() + mlConfig.LogOutput = logrus.StandardLogger().WriterLevel(logrus.DebugLevel) + mlConfig.SecretKey = clusterKey + mlConfig.BindAddr = config.BindAddr + mlConfig.BindPort = config.ClusterPort + mlConfig.AdvertisePort = config.ClusterPort + if config.UseIPAsName && config.BindAddr != "0.0.0.0" { + mlConfig.Name = config.BindAddr + } + + ml, err := memberlist.Create(mlConfig) + if err != nil { + return nil, err + } + + cluster := cluster{ + localName: ml.LocalNode().Name, + ml: ml, + wg: wg, + events: make(chan memberlist.NodeEvent, 1), + state: state, + } + mlConfig.Conflict = &cluster + mlConfig.Events = &memberlist.ChannelEventDelegate{Ch: cluster.events} + mlConfig.Delegate = &cluster + + wg.assignIP((*net.IPNet)(config.OverlayNet), cluster.localName) + + ml.UpdateNode(1 * time.Second) // we currently do not update after creation + return &cluster, nil +} + +func (c *cluster) NotifyConflict(node, other *memberlist.Node) { + logrus.Errorf("node name conflict detected: %s", other.Name) +} + +// none if these are used +func (c *cluster) NotifyMsg([]byte) {} +func (c *cluster) GetBroadcasts(overhead, limit int) [][]byte { return nil } +func (c *cluster) LocalState(join bool) []byte { return nil } +func (c *cluster) MergeRemoteState(buf []byte, join bool) {} + +type nodeMeta struct { + OverlayAddr net.IP + PubKey string +} + +func (c *cluster) NodeMeta(limit int) []byte { + buf := &bytes.Buffer{} + if err := gob.NewEncoder(buf).Encode(nodeMeta{ + OverlayAddr: c.wg.OverlayAddr, + PubKey: c.wg.PubKey, + }); err != nil { + logrus.Errorf("could not encode local state: %s", err) + return nil + } + if buf.Len() > limit { + logrus.Errorf("could not fit node metadata into %d bytes", limit) + return nil + } + return buf.Bytes() +} + +func decodeNodeMeta(b []byte) (nodeMeta, error) { + nm := nodeMeta{} + if err := gob.NewDecoder(bytes.NewReader(b)).Decode(&nm); err != nil { + return nm, errwrap.Wrapf("could not decode: {{err}}", err) + } + return nm, nil +} + +func (c *cluster) join(addrs []string) error { + if len(addrs) == 0 { + for _, n := range c.state.Nodes { + addrs = append(addrs, n.Addr.String()) + } + } + + if _, err := c.ml.Join(addrs); err != nil { + return err + } else if len(addrs) > 0 && c.ml.NumMembers() < 2 { + return errors.New("could not join to any of the provided addresses") + } + return nil +} + +func (c *cluster) leave() { + c.saveState() + c.ml.Leave(10 * time.Second) + c.ml.Shutdown() // ignore errors +} + +func (c *cluster) members() (<-chan []node, <-chan error) { + changes := make(chan []node) + errc := make(chan error, 1) + go func() { + for { + event := <-c.events + if event.Node.Name == c.localName { + // ignore events about ourselves + continue + } + switch event.Event { + case memberlist.NodeJoin: + logrus.Infof("node %s joined", event.Node) + case memberlist.NodeUpdate: + logrus.Infof("node %s updated", event.Node) + case memberlist.NodeLeave: + logrus.Infof("node %s left", event.Node) + } + + nodes := make([]node, 0) + var errs error + for _, n := range c.ml.Members() { + if n.Name == c.localName { + continue + } + meta, err := decodeNodeMeta(n.Meta) + if err != nil { + errs = multierror.Append(errs, err) + continue + } + nodes = append(nodes, node{ + Addr: n.Addr, + nodeMeta: meta, + }) + } + c.state.Nodes = nodes + changes <- nodes + if errs != nil { + errc <- errs + } + c.saveState() + } + }() + return changes, errc +} + +type node struct { + Addr net.IP + nodeMeta +} + +func (n *node) String() string { + return n.Addr.String() +} + +func (c *cluster) saveState() error { + if err := os.MkdirAll(path.Dir(statePath), 0700); err != nil { + return err + } + + stateOut, err := json.MarshalIndent(c.state, "", " ") + if err != nil { + return err + } + + return ioutil.WriteFile(statePath, stateOut, 0700) +} + +func loadState() *ClusterState { + content, err := ioutil.ReadFile(statePath) + if err != nil { + if !os.IsNotExist(err) { + logrus.Warnf("could not open state in %s: %s", statePath, err) + } + return &ClusterState{} + } + + s := &ClusterState{} + if err := json.Unmarshal(content, s); err != nil { + logrus.Warnf("could not decode state: %s", err) + return &ClusterState{} // avoid partially unmarshalled content + } + return s +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..a635779 --- /dev/null +++ b/config.go @@ -0,0 +1,55 @@ +package main + +import ( + "fmt" + "net" + + "github.com/stevenroose/gonfig" +) + +const clusterKeyLen = 32 + +type config struct { + LogLevel string `desc:"set the verbosity (debug/info/warn/error)" default:"warn"` + ClusterKey []byte `desc:"shared key for cluster membership; must be 32 bytes base64 encoded; will be generated if not provided"` + JoinAddrs []string `desc:"comma separated list of IP addresses to at least one existing cluster member; if not provided, will attempt resuming any known state or otherwise wait for further members."` + BindAddr string `desc:"IP address to bind to for cluster membership" default:"0.0.0.0"` + ClusterPort int `desc:"port used for membership gossip traffic (both TCP and UDP); must be the same across cluster" default:"7946"` + WireguardPort int `desc:"port used for wireguard traffic (UDP); must be the same across cluster" default:"51820"` + OverlayNet *network `desc:"the network in which to allocate addresses for the overlay mesh network (CIDR format); smaller networks increase the chance of IP collision" default:"10.0.0.0/8"` + InterfaceName string `desc:"name of the wireguard interface to create and manage" default:"wgoverlay"` + + // for easier local testing + UseIPAsName bool `default:"false" opts:"hidden"` +} + +func loadConfig() (*config, error) { + var config config + err := gonfig.Load(&config, gonfig.Conf{EnvPrefix: "WESHER_"}) + if err != nil { + return nil, err + } + + // perform some validation + if len(config.ClusterKey) != 0 && len(config.ClusterKey) != clusterKeyLen { + return nil, fmt.Errorf("unsupported cluster key length; expected %d, got %d", clusterKeyLen, len(config.ClusterKey)) + } + + if bits, _ := ((*net.IPNet)(config.OverlayNet)).Mask.Size(); bits%8 != 0 { + return nil, fmt.Errorf("unsupported overlay network size; net mask must be multiple of 8, got %d", bits) + } + + return &config, nil +} + +type network net.IPNet + +// UnmarshalText parses the provided byte array into the network receiver +func (n *network) UnmarshalText(data []byte) error { + _, ipnet, err := net.ParseCIDR(string(data)) + if err != nil { + return err + } + *n = network(*ipnet) + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c0d04c9 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/costela/wesher + +require ( + github.com/hashicorp/errwrap v1.0.0 + github.com/hashicorp/go-multierror v1.0.0 + github.com/hashicorp/memberlist v0.1.3 + github.com/mattn/go-isatty v0.0.7 + github.com/sirupsen/logrus v1.3.0 + github.com/stevenroose/gonfig v0.1.4 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b321f7a --- /dev/null +++ b/go.sum @@ -0,0 +1,57 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da h1:8GUt8eRujhVEGZFFEjBj46YV4rDjvGrNxb0KMWYkL2I= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c h1:964Od4U6p2jUkFxvCydnIczKteheJEzHRToSGK3Bnlw= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3 h1:zKjpN5BK/P5lMYrLmBHdBULWbJ0XpYR+7NGzqkZzoD4= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/memberlist v0.1.3 h1:EmmoJme1matNzb+hMpDuR/0sbJSUisxyqBGG676r31M= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/mattn/go-isatty v0.0.7 h1:UvyT9uN+3r7yLEYSlJsbQGdsaB/a0DlgWP3pql6iwOc= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/miekg/dns v1.0.14 h1:9jZdLNd/P4+SfEJ0TNyxYpsK8N4GtfylBLqtbYN1sbA= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/sirupsen/logrus v1.3.0 h1:hI/7Q+DtNZ2kINb6qt/lS+IyXnHQe9e90POfeewL/ME= +github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/stevenroose/gonfig v0.1.4 h1:oaMK7zCihVqlPIHXHNwDT9Hl/tH09RALGQ8TmUuYdl0= +github.com/stevenroose/gonfig v0.1.4/go.mod h1:JBkjIE8NdLbRNBowFCgK7wirNR0GHhnRhtdJgZMIylM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3 h1:KYQXGkl6vs02hK7pK4eIbw0NpNPedieTSTEiJ//bwGs= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519 h1:x6rhz8Y9CjbgQkccRGmELH6K+LJj7tOoh3XWeC1yaQM= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5 h1:x6r4Jo0KNzOOzYd8lbcRsqjuqEASK6ob3auvWYM4/8U= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190116161447-11f53e031339 h1:g/Jesu8+QLnA0CPzF3E1pURg0Byr7i6jLoX5sqjcAh0= +golang.org/x/sys v0.0.0-20190116161447-11f53e031339/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go new file mode 100644 index 0000000..619d1fa --- /dev/null +++ b/main.go @@ -0,0 +1,63 @@ +package main // import "github.com/costela/wesher" + +import ( + "os" + "os/signal" + "syscall" + + "github.com/sirupsen/logrus" +) + +func main() { + config, err := loadConfig() + if err != nil { + logrus.Fatal(err) + } + logLevel, err := logrus.ParseLevel(config.LogLevel) + if err != nil { + logrus.Fatalf("could not parse loglevel: %s", err) + } + logrus.SetLevel(logLevel) + + wg, err := newWGConfig(config.InterfaceName, config.WireguardPort) + if err != nil { + logrus.Fatal(err) + } + + cluster, err := newCluster(config, wg) + if err != nil { + logrus.Fatalf("could not create cluster: %s", err) + } + + nodec, errc := cluster.members() // avoid deadlocks by starting before join + if err := cluster.join(config.JoinAddrs); err != nil { + logrus.Fatalf("could not join cluster: %s", err) + } + + incomingSigs := make(chan os.Signal, 1) + signal.Notify(incomingSigs, syscall.SIGTERM, os.Interrupt) + for { + select { + case nodes := <-nodec: + logrus.Info("cluster members:\n") + for _, node := range nodes { + logrus.Infof("\taddr: %s, overlay: %s, pubkey: %s", node.Addr, node.OverlayAddr, node.PubKey) + } + if err := wg.downInterface(); err != nil { + logrus.Errorf("could not down interface: %s", err) + } + if err := wg.writeConf(nodes); err != nil { + logrus.Errorf("could not write config: %s", err) + } + if err := wg.upInterface(); err != nil { + logrus.Errorf("could not up interface: %s", err) + } + case errs := <-errc: + logrus.Errorf("could not receive node info: %s", errs) + case <-incomingSigs: + logrus.Info("terminating...") + cluster.leave() + os.Exit(0) + } + } +} diff --git a/wireguard.go b/wireguard.go new file mode 100644 index 0000000..a973e7e --- /dev/null +++ b/wireguard.go @@ -0,0 +1,117 @@ +package main + +import ( + "crypto/md5" + "fmt" + "net" + "os" + "os/exec" + "strings" + "text/template" +) + +const wgConfPath = "/etc/wireguard/%s.conf" +const wgConfTpl = ` +# this file was generated automatically by wesher - DO NOT MODIFY +[Interface] +PrivateKey = {{ .PrivKey }} +Address = {{ .OverlayAddr }} +ListenPort = {{ .Port }} + +{{ range .Nodes }} +[Peer] +PublicKey = {{ .PubKey }} +Endpoint = {{ .Addr }}:{{ $.Port }} +AllowedIPs = {{ .OverlayAddr }}/32 +{{ end }}` + +type wgState struct { + iface string + OverlayAddr net.IP + Port int + PrivKey string + PubKey string +} + +func newWGConfig(iface string, port int) (*wgState, error) { + if err := exec.Command("wg").Run(); err != nil { + return nil, fmt.Errorf("could not exec wireguard: %s", err) + } + + privKey, pubKey, err := wgKeyPair() + if err != nil { + return nil, err + } + + wgState := wgState{ + iface: iface, + Port: port, + PrivKey: privKey, + PubKey: pubKey, + } + return &wgState, nil +} + +func (wg *wgState) assignIP(ipnet *net.IPNet, name string) { + // TODO: this is way too brittle and opaque + ip := []byte(ipnet.IP) + bits, size := ipnet.Mask.Size() + + h := md5.New() + h.Write([]byte(name)) + hb := h.Sum(nil) + + for i := 0; i < (size-bits)/8; i++ { + ip[size/8-i-1] = hb[i] + } + wg.OverlayAddr = net.IP(ip) +} + +func (wg *wgState) writeConf(nodes []node) error { + tpl := template.Must(template.New("wgconf").Parse(wgConfTpl)) + out, err := os.OpenFile( + fmt.Sprintf(wgConfPath, wg.iface), + os.O_WRONLY|os.O_CREATE|os.O_TRUNC, + 0600, + ) + if err != nil { + return err + } + return tpl.Execute(out, struct { + *wgState + Nodes []node + }{wg, nodes}) +} + +func (wg *wgState) downInterface() error { + if err := exec.Command("wg-quick", "down", wg.iface).Run(); err != nil { + return err + } + return nil +} + +func (wg *wgState) upInterface() error { + if err := exec.Command("wg-quick", "up", wg.iface).Run(); err != nil { + return err + } + return nil +} + +func wgKeyPair() (string, string, error) { + cmd := exec.Command("wg", "genkey") + outPriv := strings.Builder{} + cmd.Stdout = &outPriv + if err := cmd.Run(); err != nil { + return "", "", err + } + + cmd = exec.Command("wg", "pubkey") + outPub := strings.Builder{} + cmd.Stdout = &outPub + cmd.Stdin = strings.NewReader(outPriv.String()) + if err := cmd.Run(); err != nil { + return "", "", err + } + + return strings.TrimSpace(outPriv.String()), strings.TrimSpace(outPub.String()), nil +}