diff --git a/Makefile b/Makefile index 10f6eca..582baf9 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ clean: @rm -r dist compile: - CGO_ENABLED=0 GOOS=linux go build -mod=vendor -a -ldflags="-s -w" -o dist/dsnet ./cmd/dsnet.go + CGO_ENABLED=0 GOOS=linux go build -mod=vendor -a -ldflags="-s -w" -o dist/dsnet ./cmd/main.go build: compile upx dist/dsnet diff --git a/cmd/cli/config.go b/cmd/cli/config.go new file mode 100644 index 0000000..2ec5f3a --- /dev/null +++ b/cmd/cli/config.go @@ -0,0 +1,338 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "math/rand" + "net" + "os" + "time" + + "github.com/go-playground/validator" + "github.com/naggie/dsnet/lib" + "github.com/spf13/viper" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// see https://github.com/WireGuard/wgctrl-go/blob/master/wgtypes/types.go for definitions +type PeerConfig struct { + // Used to update DNS + Hostname string `validate:"required,gte=1,lte=255"` + // username of person running this host/router + Owner string `validate:"required,gte=1,lte=255"` + // Description of what the host is and/or does + Description string `validate:"required,gte=1,lte=255"` + // Internal VPN IP address. Added to AllowedIPs in server config as a /32 + IP net.IP + IP6 net.IP + Added time.Time `validate:"required"` + // TODO ExternalIP support (Endpoint) + //ExternalIP net.UDPAddr `validate:"required,udp4_addr"` + // TODO support routing additional networks (AllowedIPs) + Networks []lib.JSONIPNet `validate:"required"` + PublicKey lib.JSONKey `validate:"required,len=44"` + PrivateKey lib.JSONKey `json:"-"` // omitted from config! + PresharedKey lib.JSONKey `validate:"required,len=44"` +} + +type DsnetConfig struct { + // When generating configs, the ExternalHostname has precendence for the + // server Endpoint, followed by ExternalIP (IPv4) and ExternalIP6 (IPv6) + // The IPs are discovered automatically on init. Define an ExternalHostname + // if you're using dynamic DNS, want to change IPs without updating + // configs, or want wireguard to be able to choose between IPv4/IPv6. It is + // only possible to specify one Endpoint per peer entry in wireguard. + ExternalHostname string + ExternalIP net.IP + ExternalIP6 net.IP + ListenPort int `validate:"gte=1,lte=65535"` + // domain to append to hostnames. Relies on separate DNS server for + // resolution. Informational only. + Domain string `validate:"required,gte=1,lte=255"` + InterfaceName string `validate:"required,gte=1,lte=255"` + // IP network from which to allocate automatic sequential addresses + // Network is chosen randomly when not specified + Network lib.JSONIPNet `validate:"required"` + Network6 lib.JSONIPNet `validate:"required"` + IP net.IP + IP6 net.IP + DNS net.IP + // extra networks available, will be added to AllowedIPs + Networks []lib.JSONIPNet `validate:"required"` + // TODO Default subnets to route via VPN + ReportFile string `validate:"required"` + PrivateKey lib.JSONKey `validate:"required,len=44"` + PostUp string + PostDown string + Peers []PeerConfig `validate:"dive"` +} + +// MustLoadDsnetConfig is like LoadDsnetConfig, except it exits on error +func MustLoadDsnetConfig() *DsnetConfig { + conf, err := LoadDsnetConfig() + check(err) + return conf +} + +// LoadDsnetConfig parses the json config file, validates and stuffs +// it in to a struct +func LoadDsnetConfig() (*DsnetConfig, error) { + configFile := viper.GetString("config_file") + raw, err := ioutil.ReadFile(configFile) + + if os.IsNotExist(err) { + return nil, fmt.Errorf("%s does not exist. `dsnet init` may be required.", configFile) + } else if os.IsPermission(err) { + return nil, fmt.Errorf("%s cannot be accessed. Sudo may be required.", configFile) + } else if err != nil { + return nil, err + } + + conf := DsnetConfig{} + err = json.Unmarshal(raw, &conf) + if err != nil { + return nil, err + } + + err = validator.New().Struct(conf) + if err != nil { + return nil, err + } + + if conf.ExternalHostname == "" && len(conf.ExternalIP) == 0 && len(conf.ExternalIP6) == 0 { + return nil, fmt.Errorf("Config does not contain ExternalIP, ExternalIP6 or ExternalHostname") + } + + return &conf, nil +} + +// Save writes the configuration to disk +func (conf *DsnetConfig) Save() error { + configFile := viper.GetString("config_file") + _json, _ := json.MarshalIndent(conf, "", " ") + err := ioutil.WriteFile(configFile, _json, 0600) + if err != nil { + return err + } + return nil +} + +// MustSave is like Save except it exits on error +func (conf *DsnetConfig) MustSave() { + err := conf.Save() + check(err) +} + +// AddPeer adds a provided peer to the Peers list in the conf +func (conf *DsnetConfig) AddPeer(peer PeerConfig) error { + // TODO validate all PeerConfig (keys etc) + + for _, p := range conf.Peers { + if peer.Hostname == p.Hostname { + return fmt.Errorf("%s is not an unique hostname", peer.Hostname) + } + } + + for _, p := range conf.Peers { + if peer.PublicKey.Key == p.PublicKey.Key { + return fmt.Errorf("%s is not an unique public key", peer.Hostname) + } + } + + for _, p := range conf.Peers { + if peer.PresharedKey.Key == p.PresharedKey.Key { + return fmt.Errorf("%s is not an unique preshared key", peer.Hostname) + } + } + + if conf.IPAllocated(peer.IP) { + return fmt.Errorf("%s is already allocated", peer.IP) + } + + for _, peerIPNet := range peer.Networks { + if conf.IPAllocated(peerIPNet.IPNet.IP) { + return fmt.Errorf("%s is already allocated", peerIPNet) + } + } + + conf.Peers = append(conf.Peers, peer) + return nil +} + +// MustAddPeer is like AddPeer, except it exist on error +func (conf *DsnetConfig) MustAddPeer(peer PeerConfig) { + err := conf.AddPeer(peer) + check(err) +} + +// RemovePeer removes a peer from the peer list based on hostname +func (conf *DsnetConfig) RemovePeer(hostname string) error { + peerIndex := -1 + + for i, peer := range conf.Peers { + if peer.Hostname == hostname { + peerIndex = i + } + } + + if peerIndex == -1 { + return fmt.Errorf("Could not find peer with hostname %s", hostname) + } + + // remove peer from slice, retaining order + copy(conf.Peers[peerIndex:], conf.Peers[peerIndex+1:]) // shift left + conf.Peers = conf.Peers[:len(conf.Peers)-1] // truncate + return nil +} + +// MustRemovePeer is like RemovePeer, except it exits on error +func (conf *DsnetConfig) MustRemovePeer(hostname string) { + err := conf.RemovePeer(hostname) + check(err) +} + +// IPAllocated checks the existing used ips and returns bool +// depending on if the IP is in use +func (conf DsnetConfig) IPAllocated(IP net.IP) bool { + if IP.Equal(conf.IP) || IP.Equal(conf.IP6) { + return true + } + + for _, peer := range conf.Peers { + if IP.Equal(peer.IP) || IP.Equal(peer.IP6) { + return true + } + + for _, peerIPNet := range peer.Networks { + if IP.Equal(peerIPNet.IPNet.IP) { + return true + } + } + } + + return false +} + +// AllocateIP finds a free IPv4 for a new Peer (sequential allocation) +func (conf DsnetConfig) AllocateIP() (net.IP, error) { + network := conf.Network.IPNet + ones, bits := network.Mask.Size() + zeros := bits - ones + + // avoids network addr + min := 1 + // avoids broadcast addr + overflow + max := (1 << zeros) - 2 + + IP := make(net.IP, len(network.IP)) + + for i := min; i <= max; i++ { + // dst, src! + copy(IP, network.IP) + + // OR the host part with the network part + for j := 0; j < len(IP); j++ { + shift := (len(IP) - j - 1) * 8 + IP[j] = IP[j] | byte(i>>shift) + } + + if !conf.IPAllocated(IP) { + return IP, nil + } + } + + return nil, fmt.Errorf("IP range exhausted") +} + +// MustAllocateIP is like AllocateIP, except it exits on error +func (conf DsnetConfig) MustAllocateIP() net.IP { + ip, err := conf.AllocateIP() + check(err) + return ip +} + +// AllocateIP6 finds a free IPv6 for a new Peer (pseudorandom allocation) +func (conf DsnetConfig) AllocateIP6() (net.IP, error) { + network := conf.Network6.IPNet + ones, bits := network.Mask.Size() + zeros := bits - ones + + rbs := make([]byte, zeros) + rand.Seed(time.Now().UTC().UnixNano()) + + IP := make(net.IP, len(network.IP)) + + for i := 0; i <= 10000; i++ { + rand.Read(rbs) + // dst, src! Copy prefix of IP + copy(IP, network.IP) + + // OR the host part with the network part + for j := ones / 8; j < len(IP); j++ { + IP[j] = IP[j] | rbs[j] + } + + if !conf.IPAllocated(IP) { + return IP, nil + } + } + + return nil, fmt.Errorf("Could not allocate random IPv6 after 10000 tries. This was highly unlikely!") +} + +// MustAllocateIP6 is like AllocateIP6, except it exits on error +func (conf DsnetConfig) MustAllocateIP6() net.IP { + ip, err := conf.AllocateIP6() + check(err) + return ip +} + +func (conf DsnetConfig) GetWgPeerConfigs() []wgtypes.PeerConfig { + wgPeers := make([]wgtypes.PeerConfig, 0, len(conf.Peers)) + + for _, peer := range conf.Peers { + // create a new PSK in memory to avoid passing the same value by + // pointer to each peer (d'oh) + presharedKey := peer.PresharedKey.Key + + // AllowedIPs = private IP + defined networks + allowedIPs := make([]net.IPNet, 0, len(peer.Networks)+2) + + if len(peer.IP) > 0 { + allowedIPs = append( + allowedIPs, + net.IPNet{ + IP: peer.IP, + Mask: net.IPMask{255, 255, 255, 255}, + }, + ) + } + + if len(peer.IP6) > 0 { + allowedIPs = append( + allowedIPs, + net.IPNet{ + IP: peer.IP6, + Mask: net.IPMask{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, + }, + ) + } + + for _, net := range peer.Networks { + allowedIPs = append(allowedIPs, net.IPNet) + } + + wgPeers = append(wgPeers, wgtypes.PeerConfig{ + PublicKey: peer.PublicKey.Key, + Remove: false, + UpdateOnly: false, + PresharedKey: &presharedKey, + Endpoint: nil, + ReplaceAllowedIPs: true, + AllowedIPs: allowedIPs, + }) + } + + return wgPeers +} diff --git a/cmd/cli/types.go b/cmd/cli/types.go new file mode 100644 index 0000000..e78c409 --- /dev/null +++ b/cmd/cli/types.go @@ -0,0 +1,85 @@ +package cli + +import ( + "net" + "strings" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +type JSONIPNet struct { + IPNet net.IPNet +} + +func (n JSONIPNet) MarshalJSON() ([]byte, error) { + if len(n.IPNet.IP) == 0 { + return []byte("\"\""), nil + } else { + return []byte("\"" + n.IPNet.String() + "\""), nil + } +} + +func (n *JSONIPNet) UnmarshalJSON(b []byte) error { + cidr := strings.Trim(string(b), "\"") + + if cidr == "" { + // Leave as empty/uninitialised IPNet. A bit like omitempty behaviour, + // but we can leave the field there and blank which is useful if the + // user wishes to add the cidr manually. + return nil + } + + IP, IPNet, err := net.ParseCIDR(cidr) + + if err == nil { + IPNet.IP = IP + n.IPNet = *IPNet + } + + return err +} + +func (n *JSONIPNet) String() string { + return n.IPNet.String() +} + +type JSONKey struct { + Key wgtypes.Key +} + +func (k JSONKey) MarshalJSON() ([]byte, error) { + return []byte("\"" + k.Key.String() + "\""), nil +} + +func (k JSONKey) PublicKey() JSONKey { + return JSONKey{ + Key: k.Key.PublicKey(), + } +} + +func (k *JSONKey) UnmarshalJSON(b []byte) error { + b64Key := strings.Trim(string(b), "\"") + key, err := wgtypes.ParseKey(b64Key) + k.Key = key + return err +} + +func GenerateJSONPrivateKey() JSONKey { + privateKey, err := wgtypes.GeneratePrivateKey() + + check(err) + + return JSONKey{ + Key: privateKey, + } +} + +func GenerateJSONKey() JSONKey { + privateKey, err := wgtypes.GenerateKey() + + check(err) + + return JSONKey{ + Key: privateKey, + } +} diff --git a/cmd/cli/util.go b/cmd/cli/util.go new file mode 100644 index 0000000..728ed7d --- /dev/null +++ b/cmd/cli/util.go @@ -0,0 +1,65 @@ +package cli + +import ( + "fmt" + "os" + "strings" + + "github.com/naggie/dsnet/lib" + "github.com/spf13/viper" +) + +func check(e error, optMsg ...string) { + if e != nil { + if len(optMsg) > 0 { + ExitFail("%s - %s", e, strings.Join(optMsg, " ")) + } + ExitFail("%s", e) + } +} + +func ExitFail(format string, a ...interface{}) { + fmt.Fprintf(os.Stderr, "\033[31m"+format+"\033[0m\n", a...) + os.Exit(1) +} + +func jsonPeerToDsnetPeer(peers []PeerConfig) []lib.Peer { + libPeers := make([]lib.Peer, len(peers)) + for _, p := range peers { + libPeers = append(libPeers, lib.Peer{ + Hostname: p.Hostname, + Owner: p.Owner, + Description: p.Description, + IP: p.IP, + IP6: p.IP6, + Added: p.Added, + PublicKey: p.PublicKey, + PrivateKey: p.PrivateKey, + PresharedKey: p.PresharedKey, + Networks: p.Networks, + }) + } + return libPeers +} + +func GetServer(config *DsnetConfig) *lib.Server { + fallbackWGBin := viper.GetString("fallback_wg_bing") + return &lib.Server{ + ExternalHostname: config.ExternalHostname, + ExternalIP: config.ExternalIP, + ExternalIP6: config.ExternalIP6, + ListenPort: config.ListenPort, + Domain: config.Domain, + InterfaceName: config.InterfaceName, + Network: config.Network, + Network6: config.Network6, + IP: config.IP, + IP6: config.IP6, + DNS: config.DNS, + PrivateKey: config.PrivateKey, + PostUp: config.PostUp, + PostDown: config.PostDown, + FallbackWGBin: fallbackWGBin, + Peers: jsonPeerToDsnetPeer(config.Peers), + } +} diff --git a/cmd/dsnet.go b/cmd/main.go similarity index 88% rename from cmd/dsnet.go rename to cmd/main.go index f938d69..a71b508 100644 --- a/cmd/dsnet.go +++ b/cmd/main.go @@ -6,6 +6,8 @@ import ( "strings" "github.com/naggie/dsnet" + "github.com/naggie/dsnet/cmd/cli" + "github.com/naggie/dsnet/utils" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -30,6 +32,28 @@ var ( ), } + upCmd = &cobra.Command{ + Use: "up", + Short: "Create the interface, run pre/post up, sync", + Run: func(cmd *cobra.Command, args []string) { + config := cli.MustLoadDsnetConfig() + server := cli.GetServer(config) + server.Up() + utils.ShellOut(config.PostUp, "PostUp") + }, + } + + downCmd = &cobra.Command{ + Use: "down", + Short: "Destroy the interface, run pre/post down", + Run: func(cmd *cobra.Command, args []string) { + config := cli.MustLoadDsnetConfig() + server := cli.GetServer(config) + server.DeleteLink() + utils.ShellOut(config.PostDown, "PostDown") + }, + } + addCmd = &cobra.Command{ PreRunE: func(cmd *cobra.Command, args []string) error { // Make sure we have the hostname @@ -59,14 +83,6 @@ var ( Short: "Regenerate keys and config for peer", } - upCmd = &cobra.Command{ - Run: func(cmd *cobra.Command, args []string) { - dsnet.Up() - }, - Use: "up", - Short: "Create the interface, run pre/post up, sync", - } - syncCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { dsnet.Sync() @@ -100,14 +116,6 @@ var ( Short: "Remove a peer by hostname provided as argument + sync", } - downCmd = &cobra.Command{ - Run: func(cmd *cobra.Command, args []string) { - dsnet.Down() - }, - Use: "down", - Short: "Destroy the interface, run pre/post down", - } - versionCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { fmt.Printf("dsnet version %s\ncommit %s\nbuilt %s", dsnet.VERSION, dsnet.GIT_COMMIT, dsnet.BUILD_DATE) @@ -117,7 +125,7 @@ var ( } ) -func main() { +func init() { // Flags. rootCmd.PersistentFlags().String("output", "wg-quick", "config file format: vyatta/wg-quick/nixos") addCmd.Flags().StringVar(&owner, "owner", "", "owner of the new peer") @@ -134,18 +142,24 @@ func main() { dsnet.ExitFail(err.Error()) } + viper.SetDefault("config_file", "/etc/dsnetconfig.json") + viper.SetDefault("fallback_wg_bing", "wireguard-go") + // Adds subcommands. rootCmd.AddCommand(initCmd) rootCmd.AddCommand(addCmd) rootCmd.AddCommand(regenerateCmd) - rootCmd.AddCommand(upCmd) rootCmd.AddCommand(syncCmd) rootCmd.AddCommand(reportCmd) rootCmd.AddCommand(removeCmd) - rootCmd.AddCommand(downCmd) rootCmd.AddCommand(versionCmd) + rootCmd.AddCommand(upCmd) + rootCmd.AddCommand(downCmd) +} + +func main() { if err := rootCmd.Execute(); err != nil { - dsnet.ExitFail(err.Error()) + cli.ExitFail(err.Error()) } } diff --git a/down.go b/down.go deleted file mode 100644 index 3103347..0000000 --- a/down.go +++ /dev/null @@ -1,29 +0,0 @@ -package dsnet - -import ( - "github.com/vishvananda/netlink" -) - -func Down() { - conf := MustLoadDsnetConfig() - DelLink(conf) - RunPostDown(conf) -} - -func RunPostDown(conf *DsnetConfig) { - ShellOut(conf.PostDown, "PostDown") -} - -func DelLink(conf *DsnetConfig) { - linkAttrs := netlink.NewLinkAttrs() - linkAttrs.Name = conf.InterfaceName - - link := &netlink.GenericLink{ - LinkAttrs: linkAttrs, - } - - err := netlink.LinkDel(link) - if err != nil { - ExitFail("Could not delete interface '%s' (%v)", conf.InterfaceName, err) - } -} diff --git a/up.go b/up.go deleted file mode 100644 index 63c4326..0000000 --- a/up.go +++ /dev/null @@ -1,75 +0,0 @@ -package dsnet - -import ( - "net" - - "github.com/vishvananda/netlink" -) - -func Up() { - conf := MustLoadDsnetConfig() - CreateLink(conf) - MustConfigureDevice(conf) - RunPostUp(conf) -} - -func RunPostUp(conf *DsnetConfig) { - ShellOut(conf.PostUp, "PostUp") -} - -// CreateLink sets up the WG interface and link with the correct -// address -func CreateLink(conf *DsnetConfig) { - if len(conf.IP) == 0 && len(conf.IP6) == 0 { - ExitFail("No IPv4 or IPv6 network defined in config") - } - - linkAttrs := netlink.NewLinkAttrs() - linkAttrs.Name = conf.InterfaceName - - link := &netlink.GenericLink{ - LinkAttrs: linkAttrs, - LinkType: "wireguard", - } - - err := netlink.LinkAdd(link) - if err != nil { - FailWithoutExit("Could not add interface '%s' (%v), falling back to the userspace implementation", conf.InterfaceName, err) - ShellOut(WG_USERSPACE_IMPLEMENTATION+" "+conf.InterfaceName, "Userspace implementation") - } - - if len(conf.IP) != 0 { - addr := &netlink.Addr{ - IPNet: &net.IPNet{ - IP: conf.IP, - Mask: conf.Network.IPNet.Mask, - }, - } - - err = netlink.AddrAdd(link, addr) - if err != nil { - ExitFail("Could not add ipv4 addr %s to interface %s", addr.IP, err) - } - } - - if len(conf.IP6) != 0 { - addr6 := &netlink.Addr{ - IPNet: &net.IPNet{ - IP: conf.IP6, - Mask: conf.Network6.IPNet.Mask, - }, - } - - err = netlink.AddrAdd(link, addr6) - if err != nil { - ExitFail("Could not add ipv6 addr %s to interface %s", addr6.IP, err) - } - } - - // bring up interface (UNKNOWN state instead of UP, a wireguard quirk) - err = netlink.LinkSetUp(link) - - if err != nil { - ExitFail("Could not bring up device '%s' (%v)", conf.InterfaceName, err) - } -} diff --git a/util.go b/util.go index 00cb64a..50a75e7 100644 --- a/util.go +++ b/util.go @@ -4,7 +4,6 @@ import ( "bufio" "fmt" "os" - "os/exec" "strings" ) @@ -40,16 +39,6 @@ func ExitFail(format string, a ...interface{}) { os.Exit(1) } -func ShellOut(command string, name string) { - if command != "" { - shell := exec.Command("/bin/sh", "-c", command) - err := shell.Run() - if err != nil { - ExitFail("%s '%s' failed", name, command, err) - } - } -} - func ConfirmOrAbort(format string, a ...interface{}) { fmt.Fprintf(os.Stderr, format+" [y/n] ", a...) diff --git a/utils/shellout.go b/utils/shellout.go new file mode 100644 index 0000000..fb0b625 --- /dev/null +++ b/utils/shellout.go @@ -0,0 +1,17 @@ +package utils + +import ( + "fmt" + "os/exec" +) + +func ShellOut(command string, name string) error { + if command != "" { + shell := exec.Command("/bin/sh", "-c", command) + err := shell.Run() + if err != nil { + return fmt.Errorf("failed to execute(%s - `%s`): %s ", name, command, err) + } + } + return nil +}