From ec4f1f4dc573ad4778b82a464349e09e9d15f389 Mon Sep 17 00:00:00 2001 From: Michael Stapelberg Date: Mon, 26 Nov 2018 18:29:03 +0100 Subject: [PATCH] netconfig: implement WireGuard support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To set up a tunnel, create a /perm/wireguard.json as illustrated in netconfig_test.go, and don’t forget to adjust your /perm/interfaces.json with the address configuration for the WireGuard tunnel. Note that static routes cannot currently be configured, so the usefulness is limited. If you have a use-case you’d like to see covered, please explain it in https://github.com/rtr7/router7/issues/14 --- integration/netconfig/netconfig_test.go | 87 +++++++++++++ internal/netconfig/netconfig.go | 22 +++- internal/netconfig/wireguard.go | 133 ++++++++++++++++++++ internal/wg/setdevice.go | 155 ++++++++++++++++++++++++ internal/wg/types.go | 82 +++++++++++++ 5 files changed, 475 insertions(+), 4 deletions(-) create mode 100644 internal/netconfig/wireguard.go create mode 100644 internal/wg/setdevice.go create mode 100644 internal/wg/types.go diff --git a/integration/netconfig/netconfig_test.go b/integration/netconfig/netconfig_test.go index 378fba9..d8cd0d6 100644 --- a/integration/netconfig/netconfig_test.go +++ b/integration/netconfig/netconfig_test.go @@ -15,6 +15,7 @@ package integration_test import ( + "bytes" "fmt" "io/ioutil" "os" @@ -43,6 +44,52 @@ const goldenInterfaces = ` "spoof_hardware_addr": "02:73:53:00:b0:aa", "name": "lan0", "addr": "192.168.42.1/24" + }, + { + "name": "wg0", + "addr": "fe80::1/64" + } + ] +} +` + +const goldenWireguard = ` +{ + "interfaces":[ + { + "name": "wg0", + "private_key": "gBCV3afBKfW7RycmeZFMpJykvO+58KfSEIyavay90kE=", + "port": 51820, + "peers": [ + { + "public_key": "ScxV5nQsUIaaOp3qdwPqRcgMkR3oR6nyi1tBLUovqBs=", + "endpoint": "192.168.42.23:12345", + "allowed_ips": [ + "fe80::/64", + "10.0.137.0/24" + ] + }, + { + "public_key": "AVU3LodtnFaFnJmMyNNW7cUk4462lqnVULTFkjWYvRo=", + "endpoint": "[::1]:12345", + "allowed_ips": [ + "10.0.0.0/8" + ] + } + ] + }, + { + "name": "wg1", + "private_key": "gBCV3afBKfW7RycmeZFMpJykvO+58KfSEIyavay90kE=", + "port": 51820, + "peers": [ + { + "public_key": "ScxV5nQsUIaaOp3qdwPqRcgMkR3oR6nyi1tBLUovqBs=", + "allowed_ips": [ + "fe80::/64" + ] + } + ] } ] } @@ -168,6 +215,7 @@ func TestNetconfig(t *testing.T) { {"dhcp4/wire/lease.json", goldenDhcp4}, {"dhcp6/wire/lease.json", goldenDhcp6}, {"interfaces.json", goldenInterfaces}, + {"wireguard.json", goldenWireguard}, {"portforwardings.json", pf}, } { if err := os.MkdirAll(filepath.Join(tmp, filepath.Dir(golden.filename)), 0755); err != nil { @@ -283,6 +331,45 @@ func TestNetconfig(t *testing.T) { } }) + t.Run("VerifyWireguard", func(t *testing.T) { + var stderr bytes.Buffer + wg := exec.Command("ip", "netns", "exec", ns, "wg", "show", "wg0") + wg.Stderr = &stderr + out, err := wg.Output() + if err != nil { + t.Fatalf("%v: %v (stderr: %v)", wg.Args, err, strings.TrimSpace(stderr.String())) + } + const want = `interface: wg0 + public key: 3ck9nX4ylfXm0fq4pWJ9n8Jku4fvzIXBVe3BsCNldB8= + private key: (hidden) + listening port: 51820 + +peer: ScxV5nQsUIaaOp3qdwPqRcgMkR3oR6nyi1tBLUovqBs= + endpoint: 192.168.42.23:12345 + allowed ips: 10.0.137.0/24, fe80::/64 + +peer: AVU3LodtnFaFnJmMyNNW7cUk4462lqnVULTFkjWYvRo= + endpoint: [::1]:12345 + allowed ips: 10.0.0.0/8` + if got := strings.TrimSpace(string(out)); got != want { + t.Fatalf("unexpected wg output: diff (-want +got):\n%s", diff.LineDiff(want, got)) + } + + out, err = exec.Command("ip", "-netns", ns, "address", "show", "dev", "wg0").Output() + if err != nil { + t.Fatal(err) + } + upRe := regexp.MustCompile(`wg0: <[^>]+,UP`) + if !upRe.MatchString(string(out)) { + t.Errorf("regexp %s does not match %s", upRe, string(out)) + } + addr6Re := regexp.MustCompile(`(?m)^\s*inet6 fe80::1/64 scope link\s*$`) + if !addr6Re.MatchString(string(out)) { + t.Errorf("regexp %s does not match %s", addr6Re, string(out)) + } + + }) + opts := []cmp.Option{ cmp.Transformer("formatting", func(line string) string { return strings.TrimSpace(strings.Replace(line, "dnat to", "dnat", -1)) diff --git a/internal/netconfig/netconfig.go b/internal/netconfig/netconfig.go index 093606a..c39f2be 100644 --- a/internal/netconfig/netconfig.go +++ b/internal/netconfig/netconfig.go @@ -214,12 +214,14 @@ func applyInterfaces(dir, root string) error { if err := json.Unmarshal(b, &cfg); err != nil { return err } + byName := make(map[string]InterfaceDetails) byHardwareAddr := make(map[string]InterfaceDetails) for _, details := range cfg.Interfaces { byHardwareAddr[details.HardwareAddr] = details if spoof := details.SpoofHardwareAddr; spoof != "" { byHardwareAddr[spoof] = details } + byName[details.Name] = details } links, err := netlink.LinkList() for _, l := range links { @@ -227,13 +229,21 @@ func applyInterfaces(dir, root string) error { // TODO: prefix log line with details about the interface. // link &{LinkAttrs:{Index:2 MTU:1500 TxQLen:1000 Name:eth0 HardwareAddr:00:0d:b9:49:70:18 Flags:broadcast|multicast RawFlags:4098 ParentIndex:0 MasterIndex:0 Namespace: Alias: Statistics:0xc4200f45f8 Promisc:0 Xdp:0xc4200ca180 EncapType:ether Protinfo: OperState:down NetNsID:0 NumTxQueues:0 NumRxQueues:0 Vfs:[]}}, attr &{Index:2 MTU:1500 TxQLen:1000 Name:eth0 HardwareAddr:00:0d:b9:49:70:18 Flags:broadcast|multicast RawFlags:4098 ParentIndex:0 MasterIndex:0 Namespace: Alias: Statistics:0xc4200f45f8 Promisc:0 Xdp:0xc4200ca180 EncapType:ether Protinfo: OperState:down NetNsID:0 NumTxQueues:0 NumRxQueues:0 Vfs:[]} + var ( + details InterfaceDetails + ok bool + ) addr := attr.HardwareAddr.String() - details, ok := byHardwareAddr[addr] - if !ok { - if addr == "" { + if addr == "" { + details, ok = byName[attr.Name] + if !ok { continue // not a configurable interface (e.g. sit0) } - log.Printf("no config for hardwareattr %s", addr) + } else { + details, ok = byHardwareAddr[addr] + } + if !ok { + log.Printf("no config for interface %s/%s", attr.Name, addr) continue } log.Printf("apply details %+v", details) @@ -732,5 +742,9 @@ func Apply(dir, root string) error { return fmt.Errorf("firewall: %v", err) } + if err := applyWireGuard(dir); err != nil { + return fmt.Errorf("wireguard: %v", err) + } + return firstErr } diff --git a/internal/netconfig/wireguard.go b/internal/netconfig/wireguard.go new file mode 100644 index 0000000..1b85016 --- /dev/null +++ b/internal/netconfig/wireguard.go @@ -0,0 +1,133 @@ +// Copyright 2018 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package netconfig + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" + "net" + "os" + "path/filepath" + "syscall" + + "github.com/mdlayher/genetlink" + "github.com/rtr7/router7/internal/wg" + "github.com/vishvananda/netlink" +) + +type wireguardPeer struct { + PublicKey string `json:"public_key"` // base64-encoded + Endpoint string `json:"endpoint"` // e.g. “[::1]:12345” + AllowedIPs []string `json:"allowed_ips"` // e.g. “["fe80::/64", "10.0.137.0/24"]” +} + +type wireguardInterface struct { + Name string `json:"name"` // e.g. “wg0” + PrivateKey string `json:"private_key"` // base64-encoded + Port int `json:"port"` // e.g. “51820” + Peers []wireguardPeer `json:"peers"` +} + +type wireguardInterfaces struct { + Interfaces []wireguardInterface `json:"interfaces"` +} + +type wgLink struct { + name string +} + +func (w *wgLink) Type() string { + return "wireguard" +} + +func (w *wgLink) Attrs() *netlink.LinkAttrs { + attrs := netlink.NewLinkAttrs() + attrs.Name = w.name + return &attrs +} + +func applyWireGuard(dir string) error { + b, err := ioutil.ReadFile(filepath.Join(dir, "wireguard.json")) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var cfg wireguardInterfaces + if err := json.Unmarshal(b, &cfg); err != nil { + return err + } + + h, err := netlink.NewHandle() + if err != nil { + return fmt.Errorf("netlink.NewHandle: %v", err) + } + defer h.Delete() + + conn, err := genetlink.Dial(nil) + if err != nil { + return fmt.Errorf("genetlink.Dial: %v", err) + } + defer conn.Close() + + for _, iface := range cfg.Interfaces { + l := &wgLink{iface.Name} + if err := h.LinkAdd(l); err != nil { + if ee, ok := err.(syscall.Errno); !ok || ee != syscall.EEXIST { + return fmt.Errorf("LinkAdd(%v): %v", l, err) + } + } + + var peers []*wg.Peer + for _, p := range iface.Peers { + var ips []*net.IPNet + for _, ip := range p.AllowedIPs { + _, ipnet, err := net.ParseCIDR(ip) + if err != nil { + return err + } + + ips = append(ips, ipnet) + } + b, err := base64.StdEncoding.DecodeString(p.PublicKey) + if err != nil { + return err + } + peers = append(peers, &wg.Peer{ + PublicKey: b, + Endpoint: p.Endpoint, + AllowedIPs: ips, + }) + } + b, err := base64.StdEncoding.DecodeString(iface.PrivateKey) + if err != nil { + return err + } + d := &wg.Device{ + Ifname: iface.Name, + PrivateKey: b, + ListenPort: uint16(iface.Port), + Peers: peers, + } + if err := wg.SetDevice(conn, d); err != nil { + return err + } + } + + return nil +} diff --git a/internal/wg/setdevice.go b/internal/wg/setdevice.go new file mode 100644 index 0000000..42f4f7a --- /dev/null +++ b/internal/wg/setdevice.go @@ -0,0 +1,155 @@ +// Copyright 2018 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wg + +import ( + "fmt" + "net" + "unsafe" + + "github.com/google/nftables/binaryutil" + "github.com/mdlayher/genetlink" + "github.com/mdlayher/netlink" + "golang.org/x/sys/unix" +) + +func allowedIPFromNet(n *net.IPNet) ([]byte, error) { + ones, _ := n.Mask.Size() + family := uint16(unix.AF_INET) + if n.IP.To4() == nil { + family = unix.AF_INET6 + } + return netlink.MarshalAttributes([]netlink.Attribute{ + {Type: wgallowedip_a_family, Data: binaryutil.NativeEndian.PutUint16(family)}, + {Type: wgallowedip_a_ipaddr, Data: n.IP}, + {Type: wgallowedip_a_cidr_mask, Data: []byte{byte(ones)}}, + }) +} + +func sockaddrFromEndpoint(endpoint string) ([]byte, error) { + host, service, err := net.SplitHostPort(endpoint) + if err != nil { + return nil, err + } + ip := net.ParseIP(host) + if ip == nil { + return nil, fmt.Errorf("invalid endpoint %q: %q is not an IP", endpoint, host) + } + port, err := net.LookupPort("udp4", service) + if err != nil { + return nil, err + } + if ip.To4() == nil { + addr := unix.RawSockaddrInet6{ + Family: unix.AF_INET6, + Port: uint16((port&0xFF)<<8) | uint16((port&0xFF00)>>8), + Addr: func() [16]byte { + var buf [16]byte + copy(buf[:], ip) + return buf + }(), + } + sap := (*[28]byte)(unsafe.Pointer(&addr)) + return (*sap)[:], nil + } else { + addr := unix.RawSockaddrInet4{ + Family: unix.AF_INET, + Port: uint16((port&0xFF)<<8) | uint16((port&0xFF00)>>8), + Addr: func() [4]byte { + var buf [4]byte + copy(buf[:], ip.To4()) + return buf + }(), + } + sap := (*[16]byte)(unsafe.Pointer(&addr)) + return (*sap)[:], nil + } +} + +func SetDevice(conn *genetlink.Conn, d *Device) error { + family, err := conn.GetFamily("wireguard") + if err != nil { + return err + } + + var peers []netlink.Attribute + for _, p := range d.Peers { + var ips []netlink.Attribute + for _, net := range p.AllowedIPs { + allowedIP, err := allowedIPFromNet(net) + if err != nil { + return err + } + ips = append(ips, netlink.Attribute{Type: unix.NLA_F_NESTED, Data: allowedIP}) + } + allowedIPs, err := netlink.MarshalAttributes(ips) + if err != nil { + return err + } + + attrs := []netlink.Attribute{ + {Type: wgpeer_a_public_key, Data: p.PublicKey}, + {Type: wgpeer_a_flags, Data: binaryutil.NativeEndian.PutUint32(0)}, + + {Type: wgpeer_a_persistent_keepalive_interval, Data: binaryutil.NativeEndian.PutUint16(0)}, + {Type: wgpeer_a_allowedips, Data: allowedIPs}, + } + if p.Endpoint != "" { + sockaddr, err := sockaddrFromEndpoint(p.Endpoint) + if err != nil { + return err + } + attrs = append(attrs, netlink.Attribute{Type: wgpeer_a_endpoint, Data: sockaddr}) + } + peer, err := netlink.MarshalAttributes(attrs) + if err != nil { + return err + } + peers = append(peers, netlink.Attribute{Type: unix.NLA_F_NESTED, Data: peer}) + } + peersData, err := netlink.MarshalAttributes(peers) + if err != nil { + return err + } + + data, err := netlink.MarshalAttributes([]netlink.Attribute{ + {Type: wgdevice_a_ifname, Data: []byte(d.Ifname + "\x00")}, + {Type: wgdevice_a_flags, Data: binaryutil.NativeEndian.PutUint32(0)}, + {Type: wgdevice_a_private_key, Data: d.PrivateKey}, + {Type: wgdevice_a_listen_port, Data: binaryutil.NativeEndian.PutUint16(d.ListenPort)}, + {Type: wgdevice_a_fwmark, Data: binaryutil.NativeEndian.PutUint32(0)}, + {Type: unix.NLA_F_NESTED | wgdevice_a_peers, Data: peersData}, + }) + if err != nil { + return err + } + get := genetlink.Message{ + Header: genetlink.Header{ + Command: wg_cmd_set_device, + Version: family.Version, + }, + Data: data, + } + + const flags = netlink.HeaderFlagsRequest | netlink.HeaderFlagsAcknowledge + reply, err := conn.Execute(get, family.ID, flags) + if err != nil { + return err + } + if got, want := len(reply), 1; got != want { + return fmt.Errorf("unexpected number of replies: got %d, want %d", got, want) + } + return nil +} diff --git a/internal/wg/types.go b/internal/wg/types.go new file mode 100644 index 0000000..3c108a5 --- /dev/null +++ b/internal/wg/types.go @@ -0,0 +1,82 @@ +// Copyright 2018 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package wg implements the WireGuard Linux kernel module’s generic netlink +// interface, e.g. for configuring WireGuard network interfaces (e.g. wg0) +// without resorting to the wg command line tool. +package wg + +import ( + "net" + "time" +) + +const ( + wg_cmd_get_device = iota + wg_cmd_set_device +) +const ( + wgdevice_a_unspec = iota + wgdevice_a_ifindex + wgdevice_a_ifname + wgdevice_a_private_key + wgdevice_a_public_key + wgdevice_a_flags + wgdevice_a_listen_port + wgdevice_a_fwmark + wgdevice_a_peers +) + +const ( + wgpeer_a_unspec = iota + wgpeer_a_public_key + wgpeer_a_preshared_key + wgpeer_a_flags + wgpeer_a_endpoint + wgpeer_a_persistent_keepalive_interval + wgpeer_a_last_handshake_time + wgpeer_a_rx_bytes + wgpeer_a_tx_bytes + wgpeer_a_allowedips + wgpeer_a_protocol_version +) + +const ( + wgallowedip_a_unspec = iota + wgallowedip_a_family + wgallowedip_a_ipaddr + wgallowedip_a_cidr_mask +) + +type Peer struct { + PublicKey []byte + PresharedKey []byte + PersistentKeepaliveInterval uint16 + LastHandshakeTime time.Time + RxBytes uint64 + TxBytes uint64 + AllowedIPs []*net.IPNet + ProtocolVersion uint32 + Endpoint string +} + +type Device struct { + Ifindex uint32 + Ifname string + PrivateKey []byte + PublicKey []byte + ListenPort uint16 + Fwmark uint32 + Peers []*Peer +}