`. It can also
105 | be used to update a DNS zone via a custom script that operates on the report
106 | file as mentioned above.
107 |
108 | "Owner": "naggie",
109 |
110 | The owner of the peer, copied to the report file.
111 |
112 | "Description": "Home server",
113 |
114 | A description of the peer, copied to the report file; the lack of which in
115 | `wq-quick` is what inspired me to write dsnet in the first place.
116 |
117 |
118 | "IP": "10.164.236.2",
119 |
120 | The private VPN IP allocated by dsnet for this peer. It is the lowest available
121 | IP in the pool from `Network`, above.
122 |
123 | "Added": "2020-05-07T10:04:46.336286992+01:00",
124 |
125 | The timestamp of when the peer was added by dsnet.
126 |
127 | "Networks": [],
128 |
129 | Any other CIDR networks that can be routed through this peer.
130 |
131 | "PublicKey": "altJeQ/V52JZQrGcA9RiKcpZusYU6zMUJhl7Wbd9rX0=",
132 |
133 | The public key derived from the private key generated by dsnet when the peer
134 | was added.
135 |
136 | "PresharedKey": "GcUtlze0BMuxo3iVEjpOahKdTf8xVfF8hDW3Ylw5az0=",
137 |
138 | The pre-shared key for this peer. The peer has the same key defined as the
139 | pre-shared key for the server peer. This is optional in wireguard but not for
140 | dsnet due to the extra (post quantum!) security it provides.
141 |
142 | "PersistentKeepalive": 25
143 |
144 | The PersistentKeepalive value for the server in generated client configs, and
145 | for each peer connected to the server.
146 |
147 |
148 | }
149 |
--------------------------------------------------------------------------------
/cmd/cli/report.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net"
7 | "time"
8 |
9 | "github.com/naggie/dsnet/lib"
10 | "github.com/spf13/viper"
11 | "github.com/vishvananda/netlink"
12 | "golang.zx2c4.com/wireguard/wgctrl"
13 | "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
14 | )
15 |
16 | type DsnetReport struct {
17 | ExternalIP net.IP
18 | ExternalIP6 net.IP
19 | ExternalHostname string
20 | InterfaceName string
21 | ListenPort int
22 | // domain to append to hostnames. Relies on separate DNS server for
23 | // resolution. Informational only.
24 | Domain string
25 | IP net.IP
26 | IP6 net.IP
27 | // IP network from which to allocate automatic sequential addresses
28 | // Network is chosen randomly when not specified
29 | Network lib.JSONIPNet
30 | Network6 lib.JSONIPNet
31 | DNS net.IP
32 | PeersOnline int
33 | PeersTotal int
34 | Peers []PeerReport
35 | ReceiveBytes uint64
36 | TransmitBytes uint64
37 | ReceiveBytesSI string
38 | TransmitBytesSI string
39 | // when the report was made
40 | Timestamp time.Time
41 | }
42 |
43 | type PeerReport struct {
44 | // Used to update DNS
45 | Hostname string
46 | // username of person running this host/router
47 | Owner string
48 | // Description of what the host is and/or does
49 | Description string
50 | // Has a handshake occurred in the last 3 mins?
51 | Online bool
52 | // No handshake for 28 days
53 | Dormant bool
54 | // date peer was added to dsnet config
55 | Added time.Time
56 | // Internal VPN IP address. Added to AllowedIPs in server config as a /32
57 | IP net.IP
58 | IP6 net.IP
59 | // Last known external IP
60 | ExternalIP net.IP
61 | // TODO ExternalIP support (Endpoint)
62 | //ExternalIP net.UDPAddr `validate:"required,udp4_addr"`
63 | // TODO support routing additional networks (AllowedIPs)
64 | Networks []lib.JSONIPNet
65 | LastHandshakeTime time.Time
66 | ReceiveBytes uint64
67 | TransmitBytes uint64
68 | ReceiveBytesSI string
69 | TransmitBytesSI string
70 | }
71 |
72 | func GenerateReport() error {
73 | conf, err := LoadConfigFile()
74 | if err != nil {
75 | return fmt.Errorf("%w - failure to load config", err)
76 | }
77 |
78 | wg, err := wgctrl.New()
79 | if err != nil {
80 | return fmt.Errorf("%w - failure to create new client", err)
81 | }
82 | defer wg.Close()
83 |
84 | dev, err := wg.Device(conf.InterfaceName)
85 |
86 | if err != nil {
87 | return fmt.Errorf("%w - Could not retrieve device '%s'", err, conf.InterfaceName)
88 | }
89 |
90 | report, err := GetReport(dev, conf)
91 | if err != nil {
92 | return err
93 | }
94 | report.Print()
95 | return nil
96 | }
97 |
98 | func GetReport(dev *wgtypes.Device, conf *DsnetConfig) (DsnetReport, error) {
99 | peerTimeout := viper.GetDuration("peer_timeout")
100 | peerExpiry := viper.GetDuration("peer_expiry")
101 | wgPeerIndex := make(map[wgtypes.Key]wgtypes.Peer)
102 | peerReports := make([]PeerReport, 0)
103 | peersOnline := 0
104 |
105 | linkDev, err := netlink.LinkByName(conf.InterfaceName)
106 | if err != nil {
107 | return DsnetReport{}, fmt.Errorf("%w - error getting link", err)
108 | }
109 |
110 | stats := linkDev.Attrs().Statistics
111 |
112 | for _, peer := range dev.Peers {
113 | wgPeerIndex[peer.PublicKey] = peer
114 | }
115 |
116 | for _, peer := range conf.Peers {
117 | wgPeer, known := wgPeerIndex[peer.PublicKey.Key]
118 |
119 | if !known {
120 | // dangling peer, sync will remove. Dangling peers aren't such a
121 | // problem now that add/remove performs a sync too.
122 | continue
123 | }
124 |
125 | online := time.Since(wgPeer.LastHandshakeTime) < peerTimeout
126 | dormant := !wgPeer.LastHandshakeTime.IsZero() && time.Since(wgPeer.LastHandshakeTime) > peerExpiry
127 |
128 | if online {
129 | peersOnline++
130 | }
131 |
132 | externalIP := net.IP{}
133 | if wgPeer.Endpoint != nil {
134 | externalIP = wgPeer.Endpoint.IP
135 | }
136 |
137 | uReceiveBytes := uint64(wgPeer.ReceiveBytes)
138 | uTransmitBytes := uint64(wgPeer.TransmitBytes)
139 |
140 | peerReports = append(peerReports, PeerReport{
141 | Hostname: peer.Hostname,
142 | Online: online,
143 | Dormant: dormant,
144 | Owner: peer.Owner,
145 | Description: peer.Description,
146 | Added: peer.Added,
147 | IP: peer.IP,
148 | IP6: peer.IP6,
149 | ExternalIP: externalIP,
150 | Networks: peer.Networks,
151 | LastHandshakeTime: wgPeer.LastHandshakeTime,
152 | ReceiveBytes: uReceiveBytes,
153 | TransmitBytes: uTransmitBytes,
154 | ReceiveBytesSI: BytesToSI(uReceiveBytes),
155 | TransmitBytesSI: BytesToSI(uTransmitBytes),
156 | })
157 | }
158 |
159 | return DsnetReport{
160 | ExternalIP: conf.ExternalIP,
161 | ExternalIP6: conf.ExternalIP6,
162 | ExternalHostname: conf.ExternalHostname,
163 | InterfaceName: conf.InterfaceName,
164 | ListenPort: conf.ListenPort,
165 | Domain: conf.Domain,
166 | IP: conf.IP,
167 | IP6: conf.IP6,
168 | Network: conf.Network,
169 | Network6: conf.Network6,
170 | DNS: conf.DNS,
171 | Peers: peerReports,
172 | PeersOnline: peersOnline,
173 | PeersTotal: len(peerReports),
174 | ReceiveBytes: stats.RxBytes,
175 | TransmitBytes: stats.TxBytes,
176 | ReceiveBytesSI: BytesToSI(stats.RxBytes),
177 | TransmitBytesSI: BytesToSI(stats.TxBytes),
178 | Timestamp: time.Now(),
179 | }, nil
180 | }
181 |
182 | func (report *DsnetReport) Print() {
183 | _json, _ := json.MarshalIndent(report, "", " ")
184 | _json = append(_json, '\n')
185 |
186 | fmt.Print(string(_json))
187 | }
188 |
--------------------------------------------------------------------------------
/contrib/report_rendering/js/dsnetreport.js:
--------------------------------------------------------------------------------
1 | // Simple javascript to build a HTML table from 'dsnetreport.json'
2 |
3 | // URL for dsnetreport.json
4 | var dsnetreport_url = "dsnetreport.json"
5 | // Update interval in seconds
6 | var update_interval = 10
7 | // Declare our headings
8 | var header_list = ["Hostname", "Status", "IP", "Owner", "Description", "Up", "Down"];
9 |
10 | function build_table() {
11 | // Get our div
12 | var report = document.getElementById("dsnetreport");
13 | report.innerHTML = "";
14 | // Make our table
15 | var table = document.createElement("table");
16 | var header = table.createTHead();
17 | var row = header.insertRow();
18 | header_list.forEach(function(heading, index) {
19 | var cell = row.insertCell();
20 | // By default, insertCell() creates elements as '' even if in a for no reason
21 | cell.outerHTML = "| " + heading + " | ";
22 | });
23 | // Create a summary to go at the bottom
24 | var devices_online = document.createElement("em")
25 |
26 | // By default, this looks for dsnetreport.json in the current directory
27 | fetch(dsnetreport_url, {cache: "no-cache"})
28 | .then(response => response.json())
29 | .then(data => {
30 | // Create our summary statement
31 | devices_online.innerHTML = data.PeersOnline + " of " + data.PeersTotal + " devices connected"
32 | // Iterate over the peers
33 | data.Peers.forEach(function(peer, index) {
34 | // Create the row
35 | var row = table.insertRow();
36 | row.id = "peer-" + peer.Hostname;
37 | row.classList.add("peer")
38 | // Different colour text if the peer is dormant
39 | if (peer.Dormant) {
40 | row.classList.add("dormant")
41 | }
42 |
43 | // Hostname
44 | var hostname = row.insertCell();
45 | hostname.classList.add("hostname")
46 | hostname.innerHTML = peer.Hostname;
47 | hostname.title = peer.Hostname + "." + data.Domain;
48 |
49 | // Status
50 | var status = row.insertCell();
51 | status.classList.add("status")
52 | status.setAttribute("nowrap", true)
53 | // Set indicators based on online status
54 | if (peer.Online) {
55 | status.title = "Handshake in last 3 minutes";
56 | status.classList.add("indicator-green")
57 | status.innerHTML = "online";
58 | } else {
59 | handshake = new Date(peer.LastHandshakeTime);
60 | // Add some information about when the peer was last seen
61 | status.title = "No handshake since since " + handshake.toLocaleString();
62 | status.classList.add("indicator-null")
63 | status.innerHTML = "offline";
64 | }
65 |
66 | // IP
67 | // Could also have external IP as a title?
68 | var IP = row.insertCell();
69 | IP.classList.add("ip")
70 | IP.innerHTML = peer.IP;
71 |
72 | // Owner
73 | var owner = row.insertCell();
74 | owner.classList.add("owner")
75 | owner.innerHTML = peer.Owner;
76 |
77 | // Description
78 | var desc = row.insertCell();
79 | desc.classList.add("description")
80 | desc.innerHTML = peer.Description;
81 |
82 | // Data up in SI units
83 | var data_up = row.insertCell();
84 | data_up.classList.add("up")
85 | data_up.innerHTML = peer.ReceiveBytesSI;
86 |
87 | // Data down in SI units
88 | var data_down = row.insertCell();
89 | data_down.classList.add("down")
90 | data_down.innerHTML = peer.TransmitBytesSI;
91 |
92 | });
93 | }).catch(error => {
94 | // If we encounter an error, don't do anything useful, just complain
95 | console.log(error);
96 | });
97 | // Add the table to the div
98 | report.appendChild(table);
99 | // Add the summary to the div
100 | report.appendChild(devices_online);
101 | }
102 |
103 | // Currently only updates online status and transfer stats
104 | function update_table() {
105 | fetch(dsnetreport_url, {cache: "no-cache"})
106 | .then(response => response.json())
107 | .then(data => {
108 | data.Peers.forEach(function(peer, index) {
109 | var peer_row = document.getElementById("peer-"+peer.Hostname)
110 |
111 | // Update status
112 | var status = peer_row.getElementsByClassName('status')[0]
113 | status.classList.remove("indicator-green", "indicator-null")
114 | // Set indicators based on online status
115 | if (peer.Online) {
116 | status.title = "Handshake in last 3 minutes";
117 | status.classList.add("indicator-green")
118 | status.innerHTML = "online";
119 | } else {
120 | handshake = new Date(peer.LastHandshakeTime);
121 | // Add some information about when the peer was last seen
122 | status.title = "No handshake since since " + handshake.toLocaleString();
123 | status.classList.add("indicator-null")
124 | status.innerHTML = "offline";
125 | }
126 |
127 | // Data up in SI units
128 | var data_up = peer_row.getElementsByClassName('up')[0];
129 | data_up.innerHTML = peer.ReceiveBytesSI;
130 |
131 | // Data down in SI units
132 | var data_down = peer_row.getElementsByClassName('down')[0];
133 | data_down.innerHTML = peer.TransmitBytesSI;
134 |
135 | });
136 | }).catch(error => {
137 | // If we encounter an error, don't do anything useful, just complain
138 | console.log(error);
139 | });
140 | }
141 |
142 | // Build the table when the page has loaded
143 | document.addEventListener("DOMContentLoaded", function() {
144 | build_table();
145 |
146 | // Set up interval to update table
147 | var counter = 0;
148 | var i = setInterval(function() {
149 | update_table();
150 | }, update_interval * 1000);
151 |
152 | }, false);
153 |
--------------------------------------------------------------------------------
/cmd/root.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "os"
8 | "strings"
9 | "time"
10 |
11 | "github.com/naggie/dsnet"
12 | "github.com/naggie/dsnet/cmd/cli"
13 | "github.com/naggie/dsnet/utils"
14 | "github.com/spf13/cobra"
15 | "github.com/spf13/viper"
16 | )
17 |
18 | var (
19 | // Flags.
20 | owner string
21 | description string
22 | confirm bool
23 |
24 | // Commands.
25 | rootCmd = &cobra.Command{}
26 |
27 | initCmd = &cobra.Command{
28 | Use: "init",
29 | Short: fmt.Sprintf(
30 | "Create %s containing default configuration + new keys without loading. Edit to taste.",
31 | viper.GetString("config_file"),
32 | ),
33 | RunE: func(cmd *cobra.Command, args []string) error {
34 | return cli.Init()
35 | },
36 | }
37 |
38 | upCmd = &cobra.Command{
39 | Use: "up",
40 | Short: "Create the interface, run pre/post up, sync",
41 | RunE: func(cmd *cobra.Command, args []string) error {
42 | config, err := cli.LoadConfigFile()
43 | if err != nil {
44 | return fmt.Errorf("%w - failure to load config file", err)
45 | }
46 | server := cli.GetServer(config)
47 | if e := server.Up(); e != nil {
48 | return e
49 | }
50 | if e := utils.ShellOut(config.PostUp, "PostUp"); e != nil {
51 | return e
52 | }
53 | return nil
54 | },
55 | }
56 |
57 | downCmd = &cobra.Command{
58 | Use: "down",
59 | Short: "Destroy the interface, run pre/post down",
60 | RunE: func(cmd *cobra.Command, args []string) error {
61 | config, err := cli.LoadConfigFile()
62 | if err != nil {
63 | return fmt.Errorf("%w - failure to load config file", err)
64 | }
65 | server := cli.GetServer(config)
66 | if e := server.DeleteLink(); e != nil {
67 | return e
68 | }
69 | if e := utils.ShellOut(config.PostDown, "PostDown"); e != nil {
70 | return e
71 | }
72 | return nil
73 | },
74 | }
75 |
76 | addCmd = &cobra.Command{
77 | Use: "add ",
78 | Short: "Add a new peer + sync, optionally using a provided WireGuard private key",
79 | PreRunE: func(cmd *cobra.Command, args []string) error {
80 | // Make sure we have the hostname
81 | if len(args) != 1 {
82 | return errors.New("Missing hostname argument")
83 | }
84 | return nil
85 | },
86 | RunE: func(cmd *cobra.Command, args []string) error {
87 | privKey, err := cmd.PersistentFlags().GetBool("private-key")
88 | if err != nil {
89 | return err
90 | }
91 | pubKey, err := cmd.PersistentFlags().GetBool("public-key")
92 | if err != nil {
93 | return err
94 | }
95 | return cli.Add(args[0], privKey, pubKey, owner, description, confirm)
96 | },
97 | }
98 |
99 | regenerateCmd = &cobra.Command{
100 | Use: "regenerate [hostname]",
101 | Short: "Regenerate keys and config for peer",
102 | PreRunE: func(cmd *cobra.Command, args []string) error {
103 | if len(args) != 1 {
104 | return errors.New("Missing hostname argument")
105 | }
106 | return nil
107 | },
108 | RunE: func(cmd *cobra.Command, args []string) error {
109 | return cli.Regenerate(args[0], confirm)
110 | },
111 | }
112 |
113 | syncCmd = &cobra.Command{
114 | Use: "sync",
115 | Short: fmt.Sprintf("Update wireguard configuration from %s after validating", viper.GetString("config_file")),
116 | RunE: func(cmd *cobra.Command, args []string) error {
117 | return cli.Sync()
118 | },
119 | }
120 |
121 | reportCmd = &cobra.Command{
122 | Use: "report",
123 | Short: "Generate a JSON status report to stdout",
124 | RunE: func(cmd *cobra.Command, args []string) error {
125 | return cli.GenerateReport()
126 | },
127 | }
128 |
129 | removeCmd = &cobra.Command{
130 | Use: "remove [hostname]",
131 | Short: "Remove a peer by hostname provided as argument + sync",
132 | PreRunE: func(cmd *cobra.Command, args []string) error {
133 | // Make sure we have the hostname
134 | if len(args) != 1 {
135 | return errors.New("Missing hostname argument")
136 | }
137 |
138 | return nil
139 | },
140 | RunE: func(cmd *cobra.Command, args []string) error {
141 | return cli.Remove(args[0], confirm)
142 | },
143 | }
144 |
145 | versionCmd = &cobra.Command{
146 | Run: func(cmd *cobra.Command, args []string) {
147 | fmt.Printf("dsnet version %s\ncommit %s\nbuilt %s", dsnet.VERSION, dsnet.GIT_COMMIT, dsnet.BUILD_DATE)
148 | },
149 | Use: "version",
150 | Short: "Print version",
151 | }
152 |
153 | patchCmd = &cobra.Command{
154 | Use: "patch",
155 | Short: "Pipe in JSON to patch the config file. Top level keys are replaced, not merged! Does not sync with interface. Run dsnet sync to apply.",
156 | PreRunE: func(cmd *cobra.Command, args []string) error {
157 | // Make sure we have the hostname
158 | if len(args) > 0 {
159 | return errors.New("Too many arguments")
160 | }
161 |
162 | return nil
163 | },
164 | RunE: func(cmd *cobra.Command, args []string) error {
165 | // Read the JSON from stdin
166 | jsonData, err := os.ReadFile("/dev/stdin")
167 | if err != nil {
168 | return fmt.Errorf("failed to read from stdin: %w", err)
169 | }
170 | // Unmarshal the JSON into a DsnetConfig struct
171 | var patch map[string]interface{}
172 | if err := json.Unmarshal(jsonData, &patch); err != nil {
173 | return fmt.Errorf("failed to unmarshal JSON: %w", err)
174 | }
175 | err = cli.Patch(patch)
176 |
177 | if err != nil {
178 | return fmt.Errorf("failed to apply patch: %w", err)
179 | }
180 | return nil
181 | },
182 | }
183 | )
184 |
185 | func init() {
186 | // Flags.
187 | rootCmd.PersistentFlags().String("output", "wg-quick", "config file format: vyatta/wg-quick/nixos")
188 | addCmd.Flags().StringVar(&owner, "owner", "", "owner of the new peer")
189 | addCmd.Flags().StringVar(&description, "description", "", "description of the new peer")
190 | addCmd.Flags().BoolVar(&confirm, "confirm", false, "confirm")
191 | addCmd.PersistentFlags().BoolP("private-key", "r", false, "Accept user-supplied private key. If supplied, dsnet will generate a public key.")
192 | addCmd.PersistentFlags().BoolP("public-key", "u", false, "Accept user-supplied public key. If supplied, the user must add the private key to the generated config (or provide it with --private-key).")
193 | removeCmd.Flags().BoolVar(&confirm, "confirm", false, "confirm")
194 | regenerateCmd.Flags().BoolVar(&confirm, "confirm", false, "confirm")
195 |
196 | // Environment variable handling.
197 | viper.AutomaticEnv()
198 | viper.SetEnvPrefix("DSNET")
199 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
200 |
201 | if err := viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output")); err != nil {
202 | fmt.Fprintf(os.Stderr, "\033[31m%s\033[0m\n", err.Error())
203 | os.Exit(1)
204 | }
205 |
206 | viper.SetDefault("config_file", "/etc/dsnetconfig.json")
207 | viper.SetDefault("fallback_wg_bing", "wireguard-go")
208 | viper.SetDefault("listen_port", 51820)
209 | viper.SetDefault("MTU", 1420)
210 | viper.SetDefault("interface_name", "dsnet")
211 |
212 | // if last handshake (different from keepalive, see https://www.wireguard.com/protocol/)
213 | viper.SetDefault("peer_timeout", 3*time.Minute)
214 |
215 | // when is a peer considered gone forever? (could remove)
216 | viper.SetDefault("peer_expiry", 28*time.Hour*24)
217 |
218 | // Adds subcommands.
219 | rootCmd.AddCommand(initCmd)
220 | rootCmd.AddCommand(addCmd)
221 | rootCmd.AddCommand(regenerateCmd)
222 | rootCmd.AddCommand(syncCmd)
223 | rootCmd.AddCommand(reportCmd)
224 | rootCmd.AddCommand(removeCmd)
225 | rootCmd.AddCommand(versionCmd)
226 | rootCmd.AddCommand(upCmd)
227 | rootCmd.AddCommand(downCmd)
228 | rootCmd.AddCommand(patchCmd)
229 | }
230 |
231 | func main() {
232 | // do not show usage on non cli-parsing related errors
233 | rootCmd.SilenceUsage = true
234 |
235 | // we handle errors ourselves
236 | rootCmd.SilenceErrors = true
237 |
238 | if err := rootCmd.Execute(); err != nil {
239 | fmt.Fprintf(os.Stderr, "\033[31m%s\033[0m\n", err.Error())
240 | os.Exit(1)
241 | }
242 | os.Exit(0)
243 | }
244 |
--------------------------------------------------------------------------------
/cmd/cli/config.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io/ioutil"
7 | "net"
8 | "os"
9 | "strings"
10 | "time"
11 |
12 | "github.com/go-playground/validator"
13 | "github.com/naggie/dsnet/lib"
14 | "github.com/spf13/viper"
15 | "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
16 | )
17 |
18 | // see https://github.com/WireGuard/wgctrl-go/blob/master/wgtypes/types.go for definitions
19 | type PeerConfig struct {
20 | // Used to update DNS
21 | Hostname string `validate:"required,gte=1,lte=255"`
22 | // username of person running this host/router
23 | Owner string `validate:"required,gte=1,lte=255"`
24 | // Description of what the host is and/or does
25 | Description string `validate:"required,gte=1,lte=255"`
26 | // Internal VPN IP address. Added to AllowedIPs in server config as a /32
27 | IP net.IP
28 | IP6 net.IP
29 | Added time.Time `validate:"required"`
30 | // TODO ExternalIP support (Endpoint)
31 | //ExternalIP net.UDPAddr `validate:"required,udp4_addr"`
32 | // TODO support routing additional networks (AllowedIPs)
33 | Networks []lib.JSONIPNet `validate:"required"`
34 | PublicKey lib.JSONKey `validate:"required,len=44"`
35 | PrivateKey lib.JSONKey `json:"-"` // omitted from config!
36 | PresharedKey lib.JSONKey `validate:"required,len=44"`
37 | }
38 |
39 | type DsnetConfig struct {
40 | // When generating configs, the ExternalHostname has precendence for the
41 | // server Endpoint, followed by ExternalIP (IPv4) and ExternalIP6 (IPv6)
42 | // The IPs are discovered automatically on init. Define an ExternalHostname
43 | // if you're using dynamic DNS, want to change IPs without updating
44 | // configs, or want wireguard to be able to choose between IPv4/IPv6. It is
45 | // only possible to specify one Endpoint per peer entry in wireguard.
46 | ExternalHostname string
47 | ExternalIP net.IP
48 | ExternalIP6 net.IP
49 | ListenPort int `validate:"gte=1,lte=65535"`
50 | // domain to append to hostnames. Relies on separate DNS server for
51 | // resolution. Informational only.
52 | Domain string `validate:"required,gte=1,lte=255"`
53 | InterfaceName string `validate:"required,gte=1,lte=255"`
54 | // IP network from which to allocate automatic sequential addresses
55 | // Network is chosen randomly when not specified
56 | Network lib.JSONIPNet `validate:"required"`
57 | Network6 lib.JSONIPNet `validate:"required"`
58 | IP net.IP
59 | IP6 net.IP
60 | DNS net.IP
61 | // extra networks available, will be added to AllowedIPs
62 | Networks []lib.JSONIPNet `validate:"required"`
63 | // TODO Default subnets to route via VPN
64 | PrivateKey lib.JSONKey `validate:"required,len=44"`
65 | PostUp string
66 | PostDown string
67 | Peers []PeerConfig `validate:"dive"`
68 | // used for server and client
69 | PersistentKeepalive int `validate:"gte=0,lte=255"`
70 | MTU int `validate:"gte=0,lte=65535"`
71 | }
72 |
73 | // LoadConfigFile parses the json config file, validates and stuffs
74 | // it in to a struct
75 | func LoadConfigFile() (*DsnetConfig, error) {
76 | configFile := viper.GetString("config_file")
77 | raw, err := ioutil.ReadFile(configFile)
78 |
79 | if os.IsNotExist(err) {
80 | return nil, fmt.Errorf("%s does not exist. `dsnet init` may be required", configFile)
81 | } else if os.IsPermission(err) {
82 | return nil, fmt.Errorf("%s cannot be accessed. Sudo may be required", configFile)
83 | } else if err != nil {
84 | return nil, err
85 | }
86 |
87 | conf := DsnetConfig{
88 | // set default for if key is not set. If it is set, this will not be
89 | // used _even if value is zero!_
90 | // Effectively, this is a migration
91 | PersistentKeepalive: 25,
92 | MTU: 1420,
93 | }
94 |
95 | err = json.Unmarshal(raw, &conf)
96 | if err != nil {
97 | return nil, err
98 | }
99 |
100 | err = validator.New().Struct(conf)
101 | if err != nil {
102 | return nil, err
103 | }
104 |
105 | if conf.ExternalHostname == "" && len(conf.ExternalIP) == 0 && len(conf.ExternalIP6) == 0 {
106 | return nil, fmt.Errorf("config does not contain ExternalIP, ExternalIP6 or ExternalHostname")
107 | }
108 |
109 | return &conf, nil
110 | }
111 |
112 | // Save writes the configuration to disk
113 | func (conf *DsnetConfig) Save() error {
114 | configFile := viper.GetString("config_file")
115 | _json, _ := json.MarshalIndent(conf, "", " ")
116 | _json = append(_json, '\n')
117 | err := ioutil.WriteFile(configFile, _json, 0600)
118 | if err != nil {
119 | return err
120 | }
121 | return nil
122 | }
123 |
124 | // AddPeer adds a provided peer to the Peers list in the conf
125 | func (conf *DsnetConfig) AddPeer(peer lib.Peer) error {
126 | // TODO validate all PeerConfig (keys etc)
127 |
128 | for _, p := range conf.Peers {
129 | if peer.Hostname == p.Hostname {
130 | return fmt.Errorf("%s is not an unique hostname", peer.Hostname)
131 | }
132 | }
133 |
134 | for _, p := range conf.Peers {
135 | if peer.PublicKey.Key == p.PublicKey.Key {
136 | return fmt.Errorf("%s is not an unique public key", peer.Hostname)
137 | }
138 | }
139 |
140 | for _, p := range conf.Peers {
141 | if peer.PresharedKey.Key == p.PresharedKey.Key {
142 | return fmt.Errorf("%s is not an unique preshared key", peer.Hostname)
143 | }
144 | }
145 |
146 | newPeerConfig := PeerConfig{
147 | Hostname: peer.Hostname,
148 | Description: peer.Description,
149 | Owner: peer.Owner,
150 | IP: peer.IP,
151 | IP6: peer.IP6,
152 | Added: peer.Added,
153 | Networks: peer.Networks,
154 | PublicKey: peer.PublicKey,
155 | PrivateKey: peer.PrivateKey,
156 | PresharedKey: peer.PresharedKey,
157 | }
158 |
159 | conf.Peers = append(conf.Peers, newPeerConfig)
160 | return nil
161 | }
162 |
163 | // RemovePeer removes a peer from the peer list based on hostname
164 | func (conf *DsnetConfig) RemovePeer(hostname string) error {
165 | peerIndex := -1
166 |
167 | for i, peer := range conf.Peers {
168 | if peer.Hostname == hostname {
169 | peerIndex = i
170 | }
171 | }
172 |
173 | if peerIndex == -1 {
174 | return fmt.Errorf("failed to find peer with hostname %s", hostname)
175 | }
176 |
177 | // remove peer from slice, retaining order
178 | copy(conf.Peers[peerIndex:], conf.Peers[peerIndex+1:]) // shift left
179 | conf.Peers = conf.Peers[:len(conf.Peers)-1] // truncate
180 | return nil
181 | }
182 |
183 | func (conf DsnetConfig) GetWgPeerConfigs() []wgtypes.PeerConfig {
184 | wgPeers := make([]wgtypes.PeerConfig, 0, len(conf.Peers))
185 |
186 | for _, peer := range conf.Peers {
187 | // create a new PSK in memory to avoid passing the same value by
188 | // pointer to each peer (d'oh)
189 | presharedKey := peer.PresharedKey.Key
190 |
191 | // AllowedIPs = private IP + defined networks
192 | allowedIPs := make([]net.IPNet, 0, len(peer.Networks)+2)
193 |
194 | if len(peer.IP) > 0 {
195 | allowedIPs = append(
196 | allowedIPs,
197 | net.IPNet{
198 | IP: peer.IP,
199 | Mask: net.IPMask{255, 255, 255, 255},
200 | },
201 | )
202 | }
203 |
204 | if len(peer.IP6) > 0 {
205 | allowedIPs = append(
206 | allowedIPs,
207 | net.IPNet{
208 | IP: peer.IP6,
209 | Mask: net.IPMask{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff},
210 | },
211 | )
212 | }
213 |
214 | for _, net := range peer.Networks {
215 | allowedIPs = append(allowedIPs, net.IPNet)
216 | }
217 |
218 | wgPeers = append(wgPeers, wgtypes.PeerConfig{
219 | PublicKey: peer.PublicKey.Key,
220 | Remove: false,
221 | UpdateOnly: false,
222 | PresharedKey: &presharedKey,
223 | Endpoint: nil,
224 | ReplaceAllowedIPs: true,
225 | AllowedIPs: allowedIPs,
226 | })
227 | }
228 |
229 | return wgPeers
230 | }
231 |
232 | func (conf *DsnetConfig) Merge(patch map[string]interface{}) error {
233 | // Merge the patch into the config
234 |
235 | if val, ok := patch["ExternalHostname"].(string); ok && val != "" {
236 | conf.ExternalHostname = val
237 | }
238 | if val, ok := patch["ExternalIP"].(string); ok && len(val) > 0 {
239 | conf.ExternalIP = net.ParseIP(val)
240 | }
241 | if val, ok := patch["ExternalIP6"].(string); ok && len(val) > 0 {
242 | conf.ExternalIP6 = net.ParseIP(val)
243 | }
244 | if val, ok := patch["ListenPort"].(int); ok && val > 0 {
245 | conf.ListenPort = val
246 | }
247 | if val, ok := patch["Domain"].(string); ok && val != "" {
248 | conf.Domain = val
249 | }
250 | if val, ok := patch["InterfaceName"].(string); ok && val != "" {
251 | conf.InterfaceName = val
252 | }
253 | if val, ok := patch["Network"].(string); ok && len(val) > 0 {
254 | net, err := lib.ParseJSONIPNet(val)
255 | if err != nil {
256 | return fmt.Errorf("failed to parse network: %w", err)
257 | }
258 | conf.Network = net
259 | }
260 | if val, ok := patch["Network6"].(string); ok && len(val) > 0 {
261 | net, err := lib.ParseJSONIPNet(val)
262 | if err != nil {
263 | return fmt.Errorf("failed to parse network6: %w", err)
264 | }
265 | conf.Network6 = net
266 | }
267 | if val, ok := patch["IP"].(string); ok && len(val) > 0 {
268 | conf.IP = net.ParseIP(val)
269 | }
270 | if val, ok := patch["IP6"].(string); ok && len(val) > 0 {
271 | conf.IP6 = net.ParseIP(val)
272 | }
273 | if val, ok := patch["DNS"].(string); ok && len(val) > 0 {
274 | conf.DNS = net.ParseIP(val)
275 | }
276 | if val, ok := patch["Networks"].([]string); ok && len(val) > 0 {
277 | conf.Networks = make([]lib.JSONIPNet, len(val))
278 | for i, v := range val {
279 | net, err := lib.ParseJSONIPNet(v)
280 | if err != nil {
281 | return fmt.Errorf("failed to parse network: %w", err)
282 | }
283 | conf.Networks[i] = net
284 | }
285 | }
286 | if val, ok := patch["PrivateKey"].(string); ok && len(val) > 0 {
287 | conf.PrivateKey = lib.JSONKey{}
288 | b64Key := strings.Trim(val, "\"")
289 | key, err := wgtypes.ParseKey(b64Key)
290 | if err != nil {
291 | return fmt.Errorf("failed to parse private key: %w", err)
292 | }
293 | conf.PrivateKey.Key = key
294 | }
295 | if val, ok := patch["PostUp"].(string); ok && len(val) > 0 {
296 | conf.PostUp = val
297 | }
298 | if val, ok := patch["PostDown"].(string); ok && len(val) > 0 {
299 | conf.PostDown = val
300 | }
301 | if val, ok := patch["Peers"].([]interface{}); ok && len(val) > 0 {
302 | conf.Peers = make([]PeerConfig, len(val))
303 | for i, v := range val {
304 | peerMap, ok := v.(map[string]interface{})
305 | if !ok {
306 | return fmt.Errorf("failed to parse peer: %v", v)
307 | }
308 | peer := PeerConfig{}
309 | // decode manually without peerstructure
310 | if val, ok := peerMap["Hostname"].(string); ok && val != "" {
311 | peer.Hostname = val
312 | } else {
313 | return fmt.Errorf("failed to parse peer hostname: %v", peerMap)
314 | }
315 | if val, ok := peerMap["Description"].(string); ok && val != "" {
316 | peer.Description = val
317 | } else {
318 | return fmt.Errorf("failed to parse peer description: %v", peerMap)
319 | }
320 | if val, ok := peerMap["Owner"].(string); ok && val != "" {
321 | peer.Owner = val
322 | } else {
323 | return fmt.Errorf("failed to parse peer owner: %v", peerMap)
324 | }
325 | if val, ok := peerMap["IP"].(string); ok && len(val) > 0 {
326 | peer.IP = net.ParseIP(val)
327 | } else {
328 | return fmt.Errorf("failed to parse peer IP: %v", peerMap)
329 | }
330 | if val, ok := peerMap["IP6"].(string); ok && len(val) > 0 {
331 | peer.IP6 = net.ParseIP(val)
332 | } else {
333 | return fmt.Errorf("failed to parse peer IP6: %v", peerMap)
334 | }
335 | if val, ok := peerMap["Added"].(string); ok && len(val) > 0 {
336 | t, err := time.Parse(time.RFC3339, val)
337 | if err != nil {
338 | return fmt.Errorf("failed to parse peer Added: %w", err)
339 | }
340 | peer.Added = t
341 | } else {
342 | return fmt.Errorf("failed to parse peer Added: %v", peerMap)
343 | }
344 | if val, ok := peerMap["Networks"].([]interface{}); ok && len(val) > 0 {
345 | peer.Networks = make([]lib.JSONIPNet, len(val))
346 | for j, v := range val {
347 | net, err := lib.ParseJSONIPNet(v.(string))
348 | if err != nil {
349 | return fmt.Errorf("failed to parse peer network: %w", err)
350 | }
351 | peer.Networks[j] = net
352 | }
353 | } else {
354 | return fmt.Errorf("failed to parse peer networks: %v", peerMap)
355 | }
356 | if val, ok := peerMap["PublicKey"].(string); ok && len(val) > 0 {
357 | b64Key := strings.Trim(val, "\"")
358 | key, err := wgtypes.ParseKey(b64Key)
359 | if err != nil {
360 | return fmt.Errorf("failed to parse peer public key: %w", err)
361 | }
362 | peer.PublicKey.Key = key
363 | } else {
364 | return fmt.Errorf("failed to parse peer public key: %v", peerMap)
365 | }
366 |
367 | if val, ok := peerMap["PresharedKey"].(string); ok && len(val) > 0 {
368 | b64Key := strings.Trim(val, "\"")
369 | key, err := wgtypes.ParseKey(b64Key)
370 | if err != nil {
371 | return fmt.Errorf("failed to parse peer preshared key: %w", err)
372 | }
373 | peer.PresharedKey.Key = key
374 | } else {
375 | return fmt.Errorf("failed to parse peer preshared key: %v", peerMap)
376 | }
377 |
378 | conf.Peers[i] = peer
379 | }
380 | }
381 |
382 | // Validate the updated configuration
383 | return validator.New().Struct(conf)
384 | }
385 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | Set up a VPN in one minute:
22 |
23 |
24 | 
25 |
26 | The server peer is listening, and a client peer config has been generated and
27 | added to the server peer:
28 |
29 | 
30 |
31 | More client peers can be added with `dsnet add`. They can connect immediately
32 | after! Don't forget to [enable IP forwarding](https://askubuntu.com/questions/311053/how-to-make-ip-forwarding-permanent)
33 | to allow peers to talk to one another.
34 |
35 | It works on AMD64 based linux and also ARMv5.
36 |
37 | Usage:
38 | dsnet [command]
39 |
40 | Available Commands:
41 | add Add a new peer + sync
42 | down Destroy the interface, run pre/post down
43 | help Help about any command
44 | init Create /etc/dsnetconfig.json containing default configuration + new keys without loading. Edit to taste.
45 | regenerate Regenerate keys and config for peer
46 | remove Remove a peer by hostname provided as argument + sync
47 | report Generate a JSON status report to stdout
48 | sync Update wireguard configuration from /etc/dsnetconfig.json after validating
49 | up Create the interface, run pre/post up, sync
50 | version Print version
51 |
52 | Flags:
53 | -h, --help help for this command
54 | --output string config file format: vyatta/wg-quick/nixos (default "wg-quick")
55 |
56 | Use "dsnet [command] --help" for more information about a command.
57 |
58 |
59 | Quick start (AMD64 linux) -- install wireguard, then, after making sure `/usr/local/bin` is in your path:
60 |
61 | sudo wget https://github.com/naggie/dsnet/releases/latest/download/dsnet-linux-amd64 -O /usr/local/bin/dsnet
62 | sudo chmod +x /usr/local/bin/dsnet
63 | sudo dsnet init
64 | # edit /etc/dsnetconfig.json to taste
65 | sudo dsnet up
66 | sudo dsnet add banana > dsnet-banana.conf
67 | sudo dsnet add apple > dsnet-apple.conf
68 | # enable IP forwarding to allow peers to talk to one another
69 | sudo sysctl -w net.ipv4.ip_forward=1 # edit /etc/sysctl.conf to make this persistent across reboots
70 |
71 | Copy the generated configuration file to your device and connect!
72 |
73 | To send configurations, here are a few suggestions.
74 | - [ffsend](https://github.com/timvisee/ffsend), the most straightforward option;
75 | - [magic wormhole](https://magic-wormhole.readthedocs.io/), a more advanced
76 | option, where the file never passes through another server;
77 | - [wormhole-william](https://github.com/psanford/wormhole-william), a Go
78 | implementation of the above.
79 |
80 | For the above options, one should transfer the password separately.
81 |
82 | A local QR code generator, such as the popular
83 | [qrencode](https://fukuchi.org/works/qrencode/) may also be used to generate a
84 | QR code of the configuration. For instance: `dsnet add | qrencode -t ansiutf8`.
85 | This works because the dsnet prompts are on STDERR and not passed to qrencode.
86 |
87 | The peer private key is generated on the server, which is technically not as
88 | secure as generating it on the client peer and then providing the server the
89 | public key; there is provision to specify a public key in the code when adding
90 | a peer to avoid the server generating the private key. The feature will be
91 | added when requested.
92 |
93 | Note that named arguments can be specified on the command line as well as
94 | entered by prompt; this allows for unattended usage.
95 |
96 | # GUI
97 |
98 | Dsnet does not include or require a GUI, however there is now a separate
99 | official monitoring GUI: .
100 |
101 | # Configuration overview
102 |
103 | The configuration is a single JSON file. Beyond possible initial
104 | customisations, the file is managed entirely by dsnet.
105 |
106 | dsnetconfig.json is the only file the server needs to run the VPN. It contains
107 | the server keys, peer public/shared keys and IP settings. **A working version is
108 | automatically generated by `dsnet init` which can be modified as required.**
109 |
110 | Currently its location is fixed as all my deployments are for a single network.
111 | I may add a feature to allow setting of the location via environment variable
112 | in the future to support multiple networks on a single host.
113 |
114 | Main (automatically generated) configuration example:
115 |
116 |
117 | {
118 | "ExternalHostname": "",
119 | "ExternalIP": "198.51.100.2",
120 | "ExternalIP6": "2001:0db8:85a3:0000:0000:8a2e:0370:7334",
121 | "ListenPort": 51820,
122 | "Domain": "dsnet",
123 | "InterfaceName": "dsnet",
124 | "Network": "10.164.236.0/22",
125 | "Network6": "fd00:7b31:106a:ae00::/64",
126 | "IP": "10.164.236.1",
127 | "IP6": "fd00:d631:74ca:7b00:a28:11a1:b821:f013",
128 | "DNS": "",
129 | "Networks": [],
130 | "PrivateKey": "uC+xz3v1mfjWBHepwiCgAmPebZcY+EdhaHAvqX2r7U8=",
131 | "PostUp": "",
132 | "PostDown" "",
133 | "Peers": [
134 | {
135 | "Hostname": "test",
136 | "Owner": "naggie",
137 | "Description": "Home server",
138 | "IP": "10.164.236.2",
139 | "IP6": "fd00:7b31:106a:ae00:44c3:29c3:53b1:a6f9",
140 | "Added": "2020-05-07T10:04:46.336286992+01:00",
141 | "Networks": [],
142 | "PublicKey": "altJeQ/V52JZQrGcA9RiKcpZusYU6zMUJhl7Wbd9rX0=",
143 | "PresharedKey": "GcUtlze0BMuxo3iVEjpOahKdTf8xVfF8hDW3Ylw5az0="
144 | }
145 | ]
146 | }
147 |
148 |
149 | See [CONFIG.md](CONFIG.md) for an explanation of each field.
150 |
151 |
152 | # Report file overview
153 |
154 | An example report file, generated by `dsnet report`. Suggested location:
155 | `/var/lib/dsnetreport.json`:
156 |
157 | {
158 | "ExternalIP": "198.51.100.2",
159 | "InterfaceName": "dsnet",
160 | "ListenPort": 51820,
161 | "Domain": "dsnet",
162 | "IP": "10.164.236.1",
163 | "Network": "10.164.236.0/22",
164 | "DNS": "",
165 | "PeersOnline": 4,
166 | "PeersTotal": 13,
167 | "ReceiveBytes": 32517164,
168 | "TransmitBytes": 85384984,
169 | "ReceiveBytesSI": "32.5 MB",
170 | "TransmitBytesSI": "85.4 MB",
171 | "Peers": [
172 | {
173 | "Hostname": "test",
174 | "Owner": "naggie",
175 | "Description": "Home server",
176 | "Online": false,
177 | "Dormant": true,
178 | "Added": "2020-03-12T20:15:42.798800741Z",
179 | "IP": "10.164.236.2",
180 | "ExternalIP": "198.51.100.223",
181 | "Networks": [],
182 | "Added": "2020-05-07T10:04:46.336286992+01:00",
183 | "ReceiveBytes": 32517164,
184 | "TransmitBytes": 85384984,
185 | "ReceiveBytesSI": "32.5 MB",
186 | "TransmitBytesSI": "85.4 MB"
187 | }
188 |
189 | <...>
190 | ]
191 | }
192 |
193 | Fields mean the same as they do above, or are self explanatory. Note that some
194 | data is converted into human readable formats in addition to machine formats --
195 | this is technically redundant but useful with Hugo shortcodes and other site generators.
196 |
197 | The report can be converted, for instance, into a HTML table as below:
198 |
199 | 
200 |
201 | See
202 | [etc/README.md](https://github.com/naggie/dsnet/blob/master/contrib/report_rendering/README.md)
203 | for hugo and PHP code for rendering a similar table.
204 |
205 | # Generating other config files
206 |
207 | dsnet currently supports the generation of a `wg-quick` configuration by
208 | default. It can also generate VyOS/Vyatta configuration for EdgeOS/Unifi devices
209 | such as the Edgerouter 4 using the
210 | [wireguard-vyatta](https://github.com/WireGuard/wireguard-vyatta-ubnt) package,
211 | as well as configuration for [NixOS](https://nixos.org), ready to be added to
212 | `configuration.nix` environment definition. [MikroTik RouterOS](https://mikrotik.com/software)
213 | support is also available.
214 |
215 | To change the config file format, set the following environment variables:
216 |
217 | * `DSNET_OUTPUT=vyatta`
218 | * `DSNET_OUTPUT=wg-quick`
219 | * `DSNET_OUTPUT=nixos`
220 | * `DSNET_OUTPUT=routeros`
221 |
222 | Example vyatta output:
223 |
224 | configure
225 | set interfaces wireguard wg23 address 10.165.52.3/22
226 | set interfaces wireguard wg23 address fd00:7b31:106a:ae00:f7bb:bf31:201f:60ab/64
227 | set interfaces wireguard wg23 route-allowed-ips true
228 | set interfaces wireguard wg23 private-key cAtj1tbjGGmVoxdY78q9Sv0EgNlawbzffGWjajQkLFw=
229 | set interfaces wireguard wg23 description dsnet
230 |
231 | set interfaces wireguard wg23 peer PjxQM7OwVYvOJfORA1EluLw8CchSu7jLq92YYJi5ohY= endpoint 123.123.123.123:51820
232 | set interfaces wireguard wg23 peer PjxQM7OwVYvOJfORA1EluLw8CchSu7jLq92YYJi5ohY= persistent-keepalive 25
233 | set interfaces wireguard wg23 peer PjxQM7OwVYvOJfORA1EluLw8CchSu7jLq92YYJi5ohY= preshared-key w1FtOKoMEdnhsjREtSvpg1CHEKFzFzJWaQYZwaUCV38=
234 | set interfaces wireguard wg23 peer PjxQM7OwVYvOJfORA1EluLw8CchSu7jLq92YYJi5ohY= allowed-ips 10.165.52.0/22
235 | set interfaces wireguard wg23 peer PjxQM7OwVYvOJfORA1EluLw8CchSu7jLq92YYJi5ohY= allowed-ips fd00:7b31:106a:ae00::/64
236 | commit; save
237 |
238 | The interface (in this case `wg23`) is deterministically chosen in the range
239 | `wg0-wg999`. This is such that you can use multiple dsnet configurations and
240 | the interface numbers will (probably) be different. The interface number is
241 | arbitrary, so if it is already assigned replace it with a number of your
242 | choice.
243 |
244 | Example NixOS output:
245 |
246 | networking.wireguard.interfaces = {
247 | dsnet = {
248 | ips = [
249 | "10.9.8.2/22"
250 | "fd00:80f8:af4a:4700:aaaa:bbbb:cccc:88ad/64"
251 | ];
252 | privateKey = "2PvML6bsmTCK+cBxpV9SfF261fsH6gICixtppfG6KFc=";
253 | peers = [
254 | {
255 | publicKey = "zCDo5yn7Muy3mPBXtarwm5S7JjNKM0IdIdGqoreWmSA=";
256 | presharedKey = "5Fa8Zc8gIkpfBPJUJn5OEVuE00iqmXnS34v4evv1MUM=";
257 | allowedIPs = [
258 | "10.56.72.0/22"
259 | "fd00:80f8:af4a:4700::/64"
260 | ];
261 | endpoint = "123.123.123.123:51820";
262 | persistentKeepalive = 25;
263 | }
264 | ];
265 | };
266 | };
267 |
268 | Example MikroTik RouterOS output:
269 |
270 | /interface wireguard
271 | add name=wg0 private-key="CDWdi0IcMZgla1hCYI41JejjuFaPCle+vPBxvX5OvVE=";
272 | /interface list member
273 | add interface=wg0 list=LAN
274 | /ip address
275 | add address=10.55.148.2/22 interface=wg0
276 | /ipv6 address
277 | add address=fd00:1965:946d:5000:5a88:878d:dc0:c777/64 advertise=no eui-64=no no-dad=no interface=wg0
278 | /interface wireguard peers
279 | add interface=wg0 \
280 | public-key="iE7dleTu34JOCC4A8xdIZcnbNE+aoji8i1JpP+gdt0M=" \
281 | preshared-key="Ch0BdZ6Um29D34awlWBSNa+cz1wGOUuHshjYIyqKxGU=" \
282 | endpoint-address=198.51.100.73 \
283 | endpoint-port=51820 \
284 | persistent-keepalive=25s \
285 | allowed-address=10.55.148.0/22,fd00:1965:946d:5000::/64,192.168.10.0/24,fe80::1/64
286 |
287 | # FAQ
288 |
289 | > Does dsnet support IPv6?
290 |
291 | Yes! By default since version 0.2, a random ULA subnet is generated with a 0
292 | subnet ID. Peers are allocated random addresses when added. Existing IPv4
293 | configs will not be updated -- add a `Network6` subnet to the existing config
294 | to allocate addresses to new peers.
295 |
296 | Like IPv4, it's up to you if you want to provide NAT IPv6 access to the
297 | internet; alternatively (and preferably) you can allocate a a real IPv6 subnet
298 | such that all peers have a real globally routeable IPv6 address.
299 |
300 | Upon initialisation, the server IPv4 and IPv6 external IP addresses are
301 | discovered on a best-effort basis. Clients will have configuration configured
302 | for the server IPv4 preferentially. If not IPv4 is configured, IPv6 is used;
303 | this is to give the best chance of the VPN working regardless of the dodgy
304 | network you're on.
305 |
306 | > Is dsnet production ready?
307 |
308 | Absolutely, it's just a configuration generator so your VPN does not depend on
309 | dsnet after adding peers. I use it in production at 2 companies so far.
310 |
311 | Note that before version 1.0, the config file schema may change. Changes will
312 | be made clear in release notes.
313 |
314 | > Client private keys are generated on the server. Can I avoid this?
315 |
316 | Allowing generation of the pub/priv keypair on the client is not yet supported,
317 | but will be soon as provision exists within the code base. Note that whilst
318 | client peer private keys are generated on the server, they are never stored.
319 |
320 |
321 | > How do I get dsnet to bring the (server) interface up on startup?
322 |
323 | Assuming you're running a systemd powered linux distribution (most of them are):
324 |
325 | 1. Copy
326 | [etc/dsnet.service](https://github.com/naggie/dsnet/blob/master/etc/dsnet.service)
327 | to `/etc/systemd/system/`
328 | 2. Run `sudo systemctl daemon-reload` to get systemd to see it
329 | 3. Then run `sudo systemctl enable dsnet` to enable it at boot
330 |
331 | > How can I generate the report periodically?
332 |
333 | Either with cron or a systemd timer. Cron is easiest:
334 |
335 | echo '* * * * * root /usr/local/bin/dsnet report | sudo tee /etc/cron.d/dsnetreport'
336 |
337 | Note that whilst report generation requires root, consuming the report does not
338 | as it's just a world-readable file. This is important for web interfaces that
339 | need to be secure.
340 |
341 | This is also why dsnet loads its configuration from a file -- it's possible to
342 | set permissions such that dsnet synchronises the config generated by a non-root
343 | user. Combined with a periodic `dsnet sync` like above, it's possible to build
344 | a secure web interface that does not require root. A web interface is currently
345 | being created by a friend; it will not be part of dstask, rather a separate
346 | project.
347 |
348 |
349 | # NixOS
350 |
351 | Dsnet is available in the NixOS package repository as both a package and a
352 | service module.
353 |
354 | Dsnet keeps its own configuration at `/etc/dsnetconfig.json`, which is more of
355 | a database. The way this module works is to patch this database with whatever
356 | is configured in the nix service instantiation. This happens automatically when
357 | required.
358 |
359 | This way it is possible to decide what to let dnset manage and what parts you
360 | want to keep declaratively.
361 |
362 | Example usage:
363 |
364 | ```
365 | services.dsnet = {
366 | enable = true;
367 | settings = {
368 | ExternalHostname = "vpn.example.com";
369 | Network = "10.171.90.0/24";
370 | Network6 = "";
371 | IP = "10.171.90.1";
372 | IP6 = "";
373 | DNS = "10.171.90.1";
374 | Networks = [ "0.0.0.0/0" ];
375 | };
376 |
377 | ```
378 |
379 | Minimal usage:
380 |
381 | ```
382 | services.dsnet.enable = true;
383 | ```
384 |
385 | You're then free to use `dsnet add` (etc) as usual. Editing the nix
386 | configuration will reload/restart dsnet as appropriate.
387 |
388 | ----
389 |
390 | The dsnet logo was kindly designed by [@mirorauhala](https://github.com/mirorauhala).
391 |
392 |
--------------------------------------------------------------------------------
/contrib/dsnet-nsupdate/dsnet-nsupdate:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 |
3 | import sys
4 | import json
5 | import logging
6 | import colorlog
7 | from time import sleep
8 | import re
9 | import dns.update
10 | import dns.query
11 | import dns.tsigkeyring
12 | import dns.resolver
13 | import dns.reversename
14 | import dns.rdata
15 | import dns.rdatatype
16 |
17 | # Only log warnings
18 | log_level = logging.INFO
19 |
20 | #########################################
21 | #
22 | # Define your nameservers here
23 | #
24 | #########################################
25 |
26 | # Default TTL for dsnet records is 5 minutes
27 | default_ttl = 300
28 |
29 | # Declare our internal DNS server
30 | # dsnet_int_nameserver = '10.164.236.1'
31 | # Or leave as 'json' to use "DNS" from dsnetreport.json
32 | dsnet_int_nameserver = 'json'
33 |
34 | # Define an external DNS server here if using split horizon
35 | # dsnet_ext_nameserver = '198.51.100.2'
36 | # Or set to 'json' to use "ExternalIP" from dsnetreport.json
37 | # dsnet_ext_nameserver = 'json'
38 | # Or set to 'None' to disable split horizon DNS
39 | dsnet_ext_nameserver = None
40 |
41 | # Specifically declare our zone (NOTE THE '.' AT THE END)
42 | dsnet_zone = 'example.com.'
43 | # Or set to 'json' to use "Domain" from dsnetreport.json
44 | # dsnet_zone = 'json'
45 |
46 | # Declare our reverse zones here
47 | dsnet_reverse_zone = '236.164.10.in-addr.arpa.'
48 | dsnet_reverse6_zone = '0.0.e.a.a.6.0.1.1.3.b.7.0.0.d.f.ip6.arpa.'
49 | # In the future we should automatically determine the reverse zone
50 | # from the 'Network' and 'Network6' parameters in the JSON
51 | # Currently the below does not work correctly:
52 | # dns.reversename.from_address(ipv4_space).to_text()
53 | # dns.reversename.from_address(ipv6_space).to_text()
54 |
55 | # Which TSIG key file do we need to use
56 | dns_tsig_key_file = '/etc/bind/dsnet-update.key'
57 |
58 | # Which TXT record are we using to track current peers?
59 | dsnet_current_peers_record = '_dsnet_peers'
60 |
61 | #########################################
62 |
63 | # Logger format
64 | log_format = colorlog.ColoredFormatter(
65 | "%(asctime)s %(log_color)s[%(levelname)s]%(reset)s %(name)s: %(message)s",
66 | datefmt="%Y-%m-%dT%H:%M:%S",
67 | log_colors={
68 | 'DEBUG': 'cyan',
69 | 'INFO': 'green',
70 | 'WARNING': 'yellow',
71 | 'ERROR': 'red',
72 | 'CRITICAL': 'red,bg_white',
73 | }
74 | )
75 |
76 | # Set up the fancy colour logging
77 | handler = colorlog.StreamHandler()
78 | handler.setFormatter(log_format)
79 | logger = colorlog.getLogger('dsnsupdate')
80 | logger.addHandler(handler)
81 | logger.setLevel(log_level)
82 |
83 | # Set up some resolver instances
84 | # Internally
85 | resolver_int = dns.resolver.Resolver(configure=False)
86 |
87 | # And externally
88 | if dsnet_ext_nameserver:
89 | resolver_ext = dns.resolver.Resolver(configure=False)
90 |
91 |
92 | # Dirty function to load a TSIG key from a file
93 | def load_tsig_key(tsig_file):
94 | try:
95 | # Open the file
96 | f = open(tsig_file)
97 | # Read the contents
98 | lines = f.readlines()
99 | # Close it again
100 | f.close()
101 | except FileNotFoundError:
102 | # If the file isn't found, log and error and quit
103 | logger.error("Failed to load TSIG key!")
104 | sys.exit(1)
105 |
106 | # Iterate through the lines we read
107 | for line in lines:
108 | if 'key' in line:
109 | # Read the line with the key name
110 | key_line = line
111 | if 'secret' in line:
112 | # Read the line with the secret
113 | secret_line = line
114 |
115 | if not key_line:
116 | # If we don't have a key name, log an error and quit
117 | logger.error("No key name found!")
118 | sys.exit(1)
119 |
120 | if not secret_line:
121 | # If we don't have a secret, log an error and quit
122 | logger.error("No secrets found!")
123 | sys.exit(1)
124 |
125 | # Construct the key dict for dnspython
126 | dns_key = {}
127 | # Grab the key name from the raw line
128 | key_name = key_line.split(' ')[1]
129 | # Grab the secret from the raw line
130 | key_secret = secret_line.split('"')[1]
131 | # Place it in the dict
132 | dns_key[key_name] = key_secret
133 |
134 | # Return the dict
135 | return dns_key
136 |
137 |
138 | def process_hostname(hostname):
139 | # Identify if the hostname supplied is a valid
140 | # FQDN for the zone we are mananging
141 | if hostname.endswith('.' + dsnet_zone):
142 | fqdn = hostname
143 | elif hostname.endswith('.' + dsnet_zone[:-1]):
144 | fqdn = hostname + '.'
145 | else:
146 | fqdn = hostname + '.' + dsnet_zone
147 |
148 | # Check if the name has been delegated
149 | try:
150 | answer_ns = resolver_int.query(fqdn, 'NS')
151 | # Name has been delegated, and will be ignored!
152 | return fqdn
153 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
154 | # If it's not delegated, that's fine!
155 | pass
156 |
157 | # Check if it already exists
158 | try:
159 | answer = resolver_int.query(fqdn, 'A')
160 | # If the TTL is over 300, it's probably a service
161 | if answer.rrset.ttl > default_ttl:
162 | # Add a -dsnet suffix to it to prevent spoofing
163 | # Or more likely, the name is in use in a subnet
164 | # thus -dsnet should be appended
165 | logger.info(str(hostname) + ' already taken! Using ' +
166 | str(hostname) + '-dsnet instead')
167 | fqdn = fqdn[:-12] + '-dsnet.' + dsnet_zone
168 |
169 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
170 | # If the host doesn't exist, that's fine!
171 | pass
172 |
173 | return fqdn
174 |
175 |
176 | def get_current_peers(peer_txt_record):
177 | # Set up our current peers dict
178 | current_peers = {}
179 | try:
180 | # Grab the TXT record containing our current list of peers
181 | peer_list = resolver_int.query(peer_txt_record, 'TXT')
182 | for peer_entry in peer_list:
183 | # For each peer in the result decode the hostname
184 | peer = peer_entry.strings[0].decode()
185 | # Create an entry in the dict for it
186 | current_peers[peer] = {}
187 | # Determine it's FQDN
188 | fqdn = process_hostname(peer)
189 | current_peers[peer]['fqdn'] = fqdn
190 |
191 | # Delegation
192 | try:
193 | # Determine if the name is delegated
194 | answer_ns = resolver_int.query(fqdn, 'NS')
195 | ns_record = answer_ns[0].to_text()
196 | logger.debug(fqdn + ' has been delegated to ' + ns_record)
197 | current_peers[peer]['delegated'] = True
198 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
199 | current_peers[peer]['delegated'] = False
200 |
201 | # IPv4
202 | try:
203 | # Resolve IPv4 record
204 | answer = resolver_int.query(fqdn, 'A')
205 | current_peers[peer]['ip'] = answer[0].to_text()
206 | # Generate our reverse record name from the IPv4
207 | # And get what's currently in the DNS
208 | reverse_ptr = dns.reversename.from_address(current_peers[peer]['ip'])
209 | current_peers[peer]['reverse'] = reverse_ptr.to_text()
210 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
211 | # Set these to None if they do not exist
212 | logger.debug('Incomplete IPv4 records for ' + fqdn)
213 | current_peers[peer]['ip'] = None
214 | current_peers[peer]['reverse'] = None
215 | if current_peers[peer]['reverse']:
216 | try:
217 | # If there's an A record, query the reverse for it
218 | answer_ptr = resolver_int.query(current_peers[peer]['reverse'],
219 | 'PTR')
220 | current_peers[peer]['reverse_ptr'] = answer_ptr[0].to_text()
221 | except(dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
222 | # Set to None if it doesn't exist
223 | logger.debug('Incomplete IPv4 records for ' + fqdn)
224 | current_peers[peer]['reverse_ptr'] = None
225 | else:
226 | current_peers[peer]['reverse_ptr'] = None
227 |
228 | # IPv6
229 | try:
230 | # Resolve IPv6 record
231 | answer6 = resolver_int.query(fqdn, 'AAAA')
232 | current_peers[peer]['ip6'] = answer6[0].to_text()
233 | # Generate our reverse record name from the IPv6
234 | # And get what's currently in the DNS
235 | reverse6_ptr = dns.reversename.from_address(current_peers[peer]['ip6'])
236 | current_peers[peer]['reverse6'] = reverse6_ptr.to_text()
237 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
238 | # Set these to None if they do not exist
239 | logger.debug('Incomplete IPv6 records for ' + fqdn)
240 | current_peers[peer]['ip6'] = None
241 | current_peers[peer]['reverse6'] = None
242 | if current_peers[peer]['reverse6']:
243 | try:
244 | # If there's an AAAA record, query the reverse for it
245 | answer6_ptr = resolver_int.query(current_peers[peer]['reverse6'],
246 | 'PTR')
247 | current_peers[peer]['reverse6_ptr'] = answer6_ptr[0].to_text()
248 | except(dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
249 | # Set to None if it doesn't exist
250 | logger.debug('Incomplete IPv6 records for ' + fqdn)
251 | current_peers[peer]['reverse6_ptr'] = None
252 | else:
253 | current_peers[peer]['reverse6_ptr'] = None
254 |
255 | # External IP
256 | if dsnet_ext_nameserver:
257 | try:
258 | # Resolve external IP
259 | answer_ext = resolver_ext.query(fqdn, 'A')
260 | current_peers[peer]['ext_ip'] = answer_ext[0].to_text()
261 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
262 | # Set to None if it doesn't exist
263 | current_peers[peer]['ext_ip'] = None
264 |
265 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
266 | # If we are here, it means our TXT record doesn't exist
267 | # So we have no idea what's in DNS current and it needs fixing
268 | # manually. DNS is working fine, however.
269 | logger.error("Couldn't retrieve current list of peers! Exiting...")
270 | sys.exit(1)
271 |
272 | # If we get here, we've successfully processed all the current peers
273 | # So return the dict
274 | return current_peers
275 |
276 |
277 | def process_peer_json(json_data):
278 | # The JSON data has multiple entries, so iterate throug them
279 | for entry in json_data:
280 | if entry == 'Peers':
281 | # We're only interested in the 'Peers' entry
282 | json_peers = json_data['Peers']
283 |
284 | # Sift through the peers from JSON and get the data we want
285 | new_peers = {}
286 | for peer_entry in json_peers:
287 | # Get the peer name
288 | peer = peer_entry['Hostname']
289 | new_peers[peer] = {}
290 | # Get a safe FQDN
291 | fqdn = process_hostname(peer)
292 | new_peers[peer]['fqdn'] = fqdn
293 | # Set the IPv4
294 | new_peers[peer]['ip'] = peer_entry['IP']
295 | # Set the IPv6
296 | new_peers[peer]['ip6'] = peer_entry['IP6']
297 | if dsnet_ext_nameserver:
298 | if peer_entry['Online']:
299 | # Only set an external IP if the peer is online
300 | new_peers[peer]['ext_ip'] = peer_entry['ExternalIP']
301 | else:
302 | # Else set it to None
303 | new_peers[peer]['ext_ip'] = None
304 | # Construct the reverse records for the peer for IPv4
305 | reverse_ptr = dns.reversename.from_address(peer_entry['IP'])
306 | new_peers[peer]['reverse'] = reverse_ptr.to_text()
307 | new_peers[peer]['reverse_ptr'] = fqdn
308 | # And IPv6
309 | if new_peers[peer]['ip6']:
310 | # If enabled
311 | reverse6_ptr = dns.reversename.from_address(peer_entry['IP6'])
312 | new_peers[peer]['reverse6'] = reverse6_ptr.to_text()
313 | new_peers[peer]['reverse6_ptr'] = fqdn
314 | else:
315 | # Else set to None
316 | new_peers[peer]['ip6'] = None
317 | new_peers[peer]['reverse6'] = None
318 | new_peers[peer]['reverse6_ptr'] = None
319 |
320 | # Return a list of what needs to be in DNS
321 | return new_peers
322 |
323 |
324 | def main():
325 | logger.info('Updating dsnet DNS zone')
326 | # We should have a json file as an argument
327 | if len(sys.argv) < 2:
328 | # Quit if not present
329 | logger.error('I need JSON to live!')
330 | sys.exit(1)
331 |
332 | with open(sys.argv[1]) as update_file:
333 | # Open and load that JSON file
334 | dsnet_json = json.load(update_file)
335 |
336 | # If we're using the JSON data for our zone
337 | # then pull that in
338 | global dsnet_zone
339 | if dsnet_zone.lower() == 'json':
340 | dsnet_zone = dsnet_json['Domain']
341 | # Just in case people forget...
342 | if not dsnet_zone.endswith('.'):
343 | dsnet_zone = dsnet_zone + '.'
344 | logger.debug('Using DNS zone: ' + dsnet_zone)
345 |
346 | # Create the full FQDN for our peer list txt record
347 | dsnet_current_peers_txt = dsnet_current_peers_record + '.' + dsnet_zone
348 |
349 | # If we're using the JSON data for our int nameserver
350 | # then pull that in
351 | global dsnet_int_nameserver
352 | if dsnet_int_nameserver.lower() == 'json':
353 | dsnet_int_nameserver = dsnet_json['DNS']
354 | logger.debug('Using internal nameserver: ' + dsnet_int_nameserver)
355 |
356 | # If we're using the JSON data for our ext nameserver
357 | # then pull that in
358 | global dsnet_ext_nameserver
359 | if dsnet_ext_nameserver:
360 | if dsnet_ext_nameserver.lower() == 'json':
361 | dsnet_ext_nameserver = dsnet_json['ExternalIP']
362 | logger.debug('Using external nameserver: ' + dsnet_ext_nameserver)
363 | else:
364 | logger.debug('No external nameserver specified!')
365 |
366 | # Add these to the resolver objects
367 | resolver_int.nameservers = [dsnet_int_nameserver]
368 | if dsnet_ext_nameserver:
369 | resolver_ext.nameservers = [dsnet_ext_nameserver]
370 |
371 | # Determine our reverse zones from the data in the JSON
372 | # For IPv4
373 | ipv4_space = re.sub('\/[0-9]+$', '', dsnet_json['Network'])
374 | logger.debug('Using IPv4 address space ' + dsnet_json['Network'])
375 | logger.debug('with reverse zone ' + dsnet_reverse_zone)
376 |
377 | # And for IPv6
378 | ipv6_space = re.sub('\/[0-9]+$', '', dsnet_json['Network6'])
379 | logger.debug('Using IPv6 address space ' + dsnet_json['Network6'])
380 | logger.debug('with reverse zone ' + dsnet_reverse6_zone)
381 |
382 | # Get a list of what's currently in DNS
383 | current_peers = get_current_peers(dsnet_current_peers_txt)
384 |
385 | # Print some debug info about current peers
386 | logger.debug("Current peers:")
387 | logger.debug(current_peers)
388 |
389 | # Work out what needs to be in DNS
390 | new_peers = process_peer_json(dsnet_json)
391 |
392 | # Print some debug info
393 | logger.debug("New peers:")
394 | logger.debug(new_peers)
395 |
396 | # Set up some lists for what we're updating
397 | add_peers = []
398 | update_int_peers = []
399 | update_int6_peers = []
400 | if dsnet_ext_nameserver:
401 | update_ext_peers = []
402 | update_ptr_peers = []
403 | update_ptr6_peers = []
404 | delete_peers = []
405 |
406 | # What do we delete?
407 | for peer in current_peers:
408 | # If the peer is in current_peers but not new_peers
409 | # it has been deleted
410 | if peer not in new_peers:
411 | # Add it to the list
412 | delete_peers.append(peer)
413 |
414 | # What do we add?
415 | for peer in new_peers:
416 | # If the peer is in new_peers but not current_peers, it is new
417 | if peer not in current_peers:
418 | # Add it to the list
419 | add_peers.append(peer)
420 | else:
421 | # What do we update?
422 | # Check if this peer is delegated to it's own DNS first
423 | if not current_peers[peer]['delegated']:
424 | # Check internal IPv4
425 | if new_peers[peer]['ip'] != current_peers[peer]['ip']:
426 | # Update if the internal IPv4 doesn't match
427 | update_int_peers.append(peer)
428 | # Check internal IPv6
429 | if new_peers[peer]['ip6'] != current_peers[peer]['ip6']:
430 | # Update if the internal IPv4 doesn't match
431 | update_int6_peers.append(peer)
432 |
433 | if dsnet_ext_nameserver:
434 | # Check external IP
435 | if new_peers[peer]['ext_ip'] != current_peers[peer]['ext_ip']:
436 | # Update if the external IP doesn't match
437 | update_ext_peers.append(peer)
438 |
439 | # Check reverse IPv4 record
440 | if new_peers[peer]['reverse_ptr'] != current_peers[peer]['reverse_ptr']:
441 | # Update if the PTR records don't match
442 | # Check if it's in our IPv4 reverse zone
443 | if new_peers[peer]['reverse'].endswith(dsnet_reverse_zone):
444 | update_ptr_peers.append(peer)
445 | else:
446 | logger.warn(peer + " internal IPv4 not in our reverse zone!")
447 |
448 | # Check reverse IPv6 record
449 | if new_peers[peer]['reverse6_ptr'] != current_peers[peer]['reverse6_ptr']:
450 | # Update if the PTR records don't match
451 | # Check if it's in our IPv6 reverse zone
452 | if new_peers[peer]['reverse6'].endswith(dsnet_reverse6_zone):
453 | update_ptr6_peers.append(peer)
454 | else:
455 | logger.warn(peer + " internal IPv6 not in our reverse zone!")
456 |
457 | # List peers we're adding
458 | if add_peers:
459 | logger.info("Adding peers:")
460 | for peer in add_peers:
461 | logger.info(" - " + peer)
462 |
463 | # List peers we're updating the internal IPv4 of
464 | if update_int_peers:
465 | logger.info("Updating internal IPv4 peers:")
466 | for peer in update_int_peers:
467 | logger.info(" - " + peer + ": " + str(new_peers[peer]['ip']))
468 |
469 | # List peers we're updating the internal IPv6 of
470 | if update_int6_peers:
471 | logger.info("Updating internal IPv6 peers:")
472 | for peer in update_int6_peers:
473 | logger.info(" - " + peer + ": " + str(new_peers[peer]['ip6']))
474 |
475 | if dsnet_ext_nameserver:
476 | # List peers we're updating the external IP of
477 | if update_ext_peers:
478 | logger.info("Updating external peers:")
479 | for peer in update_ext_peers:
480 | logger.info(" - " + peer + ": " + str(new_peers[peer]['ext_ip']))
481 |
482 | # List peers we're updating the reverse IPv4 of
483 | if update_ptr_peers:
484 | logger.info("Updating IPv4 reverse peers:")
485 | for peer in update_ptr_peers:
486 | logger.info(" - " + peer + ": " + str(new_peers[peer]['reverse_ptr']))
487 |
488 | # List peers we're updating the reverse IPv6 of
489 | if update_ptr6_peers:
490 | logger.info("Updating IPv6 reverse peers:")
491 | for peer in update_ptr6_peers:
492 | logger.info(" - " + peer + ": " + str(new_peers[peer]['reverse6_ptr']))
493 |
494 | # List peers we're deleting
495 | if delete_peers:
496 | logger.info("Deleting peers:")
497 | for peer in delete_peers:
498 | logger.info(" - " + peer)
499 |
500 | # If there's nothing in any of these lists,
501 | # we don't need to do anything!
502 | if not add_peers and not delete_peers:
503 | if not update_int_peers and not update_int6_peers:
504 | if not update_ptr_peers and not update_ptr6_peers:
505 | if dsnet_ext_nameserver:
506 | if not update_ext_peers:
507 | logger.info("Nothing to do! Exiting...")
508 | sys.exit(0)
509 | else:
510 | logger.info("Nothing to do! Exiting...")
511 | sys.exit(0)
512 |
513 | # Load the TSIG key from file
514 | dsnet_update_key = load_tsig_key(dns_tsig_key_file)
515 | # Add it to the keyring
516 | keyring = dns.tsigkeyring.from_text(dsnet_update_key)
517 |
518 | # Set up the update entries for each zone
519 | update_int = dns.update.Update(dsnet_zone, keyring=keyring)
520 | update_ext = dns.update.Update(dsnet_zone, keyring=keyring)
521 | update_reverse = dns.update.Update(dsnet_reverse_zone, keyring=keyring)
522 | update_reverse6 = dns.update.Update(dsnet_reverse6_zone, keyring=keyring)
523 |
524 | # Manage the TXT record first
525 | # Only change the TXT records we are adding
526 | for peer in add_peers:
527 | # Add the TXT record for the peer
528 | update_int.add(dsnet_current_peers_txt, default_ttl, 'TXT', peer)
529 | # Or deleting
530 | for peer in delete_peers:
531 | # Construct an rdata object so we can delete a SPECIFIC record
532 | datatype = dns.rdatatype.from_text('TXT')
533 | rdata = dns.rdata.from_text(dns.rdataclass.IN, datatype, peer)
534 | update_int.delete(dsnet_current_peers_txt, rdata)
535 |
536 | # For new peers
537 | for peer in add_peers:
538 | # Add the A record and reverse
539 | update_int.replace(new_peers[peer]['fqdn'], default_ttl,
540 | 'A', new_peers[peer]['ip'])
541 | update_reverse.replace(new_peers[peer]['reverse'], default_ttl,
542 | 'PTR', new_peers[peer]['fqdn'])
543 |
544 | # Add the AAAA record and reverse if there is an IPv6
545 | if new_peers[peer]['ip6']:
546 | update_int.replace(new_peers[peer]['fqdn'], default_ttl,
547 | 'AAAA', new_peers[peer]['ip6'])
548 | update_reverse6.replace(new_peers[peer]['reverse'], default_ttl,
549 | 'PTR', new_peers[peer]['fqdn'])
550 |
551 | if dsnet_ext_nameserver:
552 | # An external IP if present
553 | if new_peers[peer]['ext_ip']:
554 | update_ext.replace(new_peers[peer]['fqdn'], default_ttl,
555 | 'A', new_peers[peer]['ext_ip'])
556 |
557 | # Update IPv4 records as needed
558 | for peer in update_int_peers:
559 | # Update if present
560 | if new_peers[peer]['ip']:
561 | update_int.replace(new_peers[peer]['fqdn'], default_ttl,
562 | 'A', new_peers[peer]['ip'])
563 | # Delete if removed for some reason
564 | else:
565 | update_int.delete(current_peers[peer]['fqdn'], 'A')
566 |
567 | # Update IPv6 records as needed
568 | for peer in update_int6_peers:
569 | # Update if present
570 | if new_peers[peer]['ip6']:
571 | update_int.replace(new_peers[peer]['fqdn'], default_ttl,
572 | 'AAAA', new_peers[peer]['ip6'])
573 | # Delete if removed for some reason
574 | else:
575 | update_int.delete(current_peers[peer]['fqdn'], 'AAAA')
576 |
577 | if dsnet_ext_nameserver:
578 | # Update external IPs if needed
579 | for peer in update_ext_peers:
580 | # Update if present
581 | if new_peers[peer]['ext_ip']:
582 | update_ext.replace(new_peers[peer]['fqdn'], default_ttl,
583 | 'A', new_peers[peer]['ext_ip'])
584 | # Delete if host has disconnected
585 | else:
586 | update_ext.delete(current_peers[peer]['fqdn'], 'A')
587 |
588 | # Update reverse IPv4 reconds as needed
589 | for peer in update_ptr_peers:
590 | # Update if present
591 | if new_peers[peer]['reverse']:
592 | update_reverse.replace(new_peers[peer]['reverse'], default_ttl,
593 | 'PTR', new_peers[peer]['fqdn'])
594 | # Delete if removed for some reason
595 | else:
596 | update_reverse.delete(current_peers[peer]['reverse'], 'PTR')
597 |
598 | # Update reverse IPv6 reconds as needed
599 | for peer in update_ptr6_peers:
600 | # Update if present
601 | if new_peers[peer]['reverse6']:
602 | update_reverse6.replace(new_peers[peer]['reverse6'], default_ttl,
603 | 'PTR', new_peers[peer]['fqdn'])
604 | # Delete if removed for some reason
605 | else:
606 | update_reverse6.delete(current_peers[peer]['reverse6'], 'PTR')
607 |
608 | # For deleted peers
609 | for peer in delete_peers:
610 | # Delete the forward records
611 | update_int.delete(current_peers[peer]['fqdn'], 'A')
612 | update_int.delete(current_peers[peer]['fqdn'], 'AAAA')
613 | # Delete the external IP record if it exists
614 | if dsnet_ext_nameserver:
615 | if current_peers[peer]['ext_ip']:
616 | update_ext.delete(current_peers[peer]['fqdn'], 'A')
617 | # Delete the reverse records
618 | update_reverse.delete(current_peers[peer]['reverse'], 'PTR')
619 | update_reverse6.delete(current_peers[peer]['reverse6'], 'PTR')
620 |
621 | try:
622 | # Send the updates to the DNS servers, via TCP because they are LONG
623 | # Internal forward zone
624 | logger.debug(update_int)
625 | response = dns.query.tcp(update_int, dsnet_int_nameserver, timeout=10)
626 |
627 | if dsnet_ext_nameserver:
628 | # External forward zone
629 | logger.debug(update_ext)
630 | response = dns.query.tcp(update_ext, dsnet_ext_nameserver, timeout=10)
631 |
632 | # IPv4 reverse zone
633 | logger.debug(update_reverse)
634 | response = dns.query.tcp(update_reverse, dsnet_int_nameserver, timeout=10)
635 |
636 | # IPv6 reverse zone
637 | logger.debug(update_reverse6)
638 | response = dns.query.tcp(update_reverse6, dsnet_int_nameserver, timeout=10)
639 | except dns.tsig.PeerBadKey:
640 | # Warn if we get a TSIG key error
641 | logger.error("TSIG key failure on update!")
642 | sys.exit(1)
643 |
644 | # All done!
645 | sys.exit(0)
646 |
647 |
648 | if __name__ == '__main__':
649 | main()
650 |
--------------------------------------------------------------------------------
|