├── .gitattributes ├── etc ├── wg.png ├── add.png ├── wg2.png ├── report.png ├── init+add.png ├── php-dark.jpg ├── php-light.jpg ├── dsnet-report-js.png ├── logo │ ├── figma-design.fig │ ├── README.md │ ├── with-safe-area.svg │ ├── without-safe-area.svg │ ├── square-brand-dark.svg │ └── square-brand-light.svg └── dsnet.service ├── const.go ├── contrib ├── README.md ├── report_rendering │ ├── js │ │ ├── dsnetreport.html │ │ ├── dsnetreport.css │ │ └── dsnetreport.js │ ├── hugo │ │ └── dsnetreport.html │ ├── php │ │ └── dsnetreport.php │ └── README.md └── dsnet-nsupdate │ ├── README.md │ └── dsnet-nsupdate ├── Makefile ├── utils └── shellout.go ├── cmd ├── cli │ ├── patch.go │ ├── sync.go │ ├── remove.go │ ├── server.go │ ├── regenerate.go │ ├── util.go │ ├── add.go │ ├── init.go │ ├── report.go │ └── config.go └── root.go ├── go.mod ├── .gitignore ├── .github └── workflows │ └── go.yml ├── LICENSE.md ├── do-release.sh ├── lib ├── lib.go ├── types.go ├── generator.go ├── peer.go ├── server.go ├── link.go └── templates.go ├── CONFIG.md └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | contrib/* linguist-vendored 2 | -------------------------------------------------------------------------------- /etc/wg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naggie/dsnet/HEAD/etc/wg.png -------------------------------------------------------------------------------- /etc/add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naggie/dsnet/HEAD/etc/add.png -------------------------------------------------------------------------------- /etc/wg2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naggie/dsnet/HEAD/etc/wg2.png -------------------------------------------------------------------------------- /etc/report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naggie/dsnet/HEAD/etc/report.png -------------------------------------------------------------------------------- /etc/init+add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naggie/dsnet/HEAD/etc/init+add.png -------------------------------------------------------------------------------- /etc/php-dark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naggie/dsnet/HEAD/etc/php-dark.jpg -------------------------------------------------------------------------------- /etc/php-light.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naggie/dsnet/HEAD/etc/php-light.jpg -------------------------------------------------------------------------------- /etc/dsnet-report-js.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naggie/dsnet/HEAD/etc/dsnet-report-js.png -------------------------------------------------------------------------------- /etc/logo/figma-design.fig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naggie/dsnet/HEAD/etc/logo/figma-design.fig -------------------------------------------------------------------------------- /etc/logo/README.md: -------------------------------------------------------------------------------- 1 | The dsnet logo was kindly designed by [@mirorauhala](https://github.com/mirorauhala). 2 | -------------------------------------------------------------------------------- /const.go: -------------------------------------------------------------------------------- 1 | package dsnet 2 | 3 | var ( 4 | // populated with LDFLAGS, see do-release.sh 5 | VERSION = "unknown" 6 | GIT_COMMIT = "unknown" 7 | BUILD_DATE = "unknown" 8 | ) 9 | -------------------------------------------------------------------------------- /contrib/README.md: -------------------------------------------------------------------------------- 1 | ## Contrib 2 | 3 | Code that is not necessarily part of dsnet but has been written for dsnet 4 | 5 | ### dsnet-nsupdate 6 | 7 | A script to maintain a DNS zone based on `dsnetreport.json` 8 | 9 | ### report_rendering 10 | 11 | Scripts to render html report tables from `dsnetreport.json`. 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all build compile quick clean 2 | 3 | all: compile 4 | 5 | clean: 6 | @rm -r dist 7 | 8 | compile: 9 | CGO_ENABLED=0 GOOS=linux go build -a -ldflags="-s -w" -o dist/dsnet ./cmd/root.go 10 | 11 | build: compile 12 | upx dist/dsnet 13 | 14 | quick: compile 15 | 16 | update_deps: 17 | go get 18 | 19 | -------------------------------------------------------------------------------- /contrib/report_rendering/js/dsnetreport.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | WireGuard Peers 8 | 9 | 10 | 11 |

WireGuard Peers

12 |
13 |
14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /utils/shellout.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | ) 7 | 8 | func ShellOut(command string, name string) error { 9 | if command != "" { 10 | shell := exec.Command("/bin/sh", "-c", command) 11 | err := shell.Run() 12 | if err != nil { 13 | return fmt.Errorf("failed to execute(%s - `%s`): %s ", name, command, err) 14 | } 15 | } 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /cmd/cli/patch.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import "fmt" 4 | 5 | func Patch(patch map[string]interface{}) error { 6 | conf, err := LoadConfigFile() 7 | if err != nil { 8 | return fmt.Errorf("%w - failed to load config", err) 9 | } 10 | 11 | conf.Merge(patch) 12 | 13 | if err = conf.Save(); err != nil { 14 | return fmt.Errorf("%w - failure to save config", err) 15 | } 16 | 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /etc/dsnet.service: -------------------------------------------------------------------------------- 1 | # Copy this service file to /etc/systemd/system/ to start dsnet on boot, 2 | # assuming dsnet is installed to /usr/local/bin 3 | [Unit] 4 | Description=dsnet 5 | After=network-online.target 6 | Wants=network-online.target 7 | 8 | [Service] 9 | Type=oneshot 10 | ExecStart=/usr/local/bin/dsnet up 11 | ExecStop=/usr/local/bin/dsnet down 12 | RemainAfterExit=yes 13 | ExecReload=/usr/local/bin/dsnet sync 14 | 15 | [Install] 16 | WantedBy=default.target 17 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/naggie/dsnet 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/go-playground/universal-translator v0.18.0 // indirect 7 | github.com/go-playground/validator v9.31.0+incompatible 8 | github.com/leodido/go-urn v1.2.1 // indirect 9 | github.com/spf13/cobra v1.5.0 10 | github.com/spf13/viper v1.13.0 11 | github.com/vishvananda/netlink v1.1.0 12 | golang.zx2c4.com/wireguard/wgctrl v0.0.0-20220504211119-3d4a969bb56b 13 | gopkg.in/go-playground/assert.v1 v1.2.1 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /cmd/cli/sync.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import "fmt" 4 | 5 | func Sync() error { 6 | // TODO check device settings first 7 | conf, err := LoadConfigFile() 8 | if err != nil { 9 | return fmt.Errorf("%w - failed to load configuration file", err) 10 | } 11 | server := GetServer(conf) 12 | 13 | err = server.ConfigureDevice() 14 | if err != nil { 15 | return fmt.Errorf("%w - failed to sync device configuration", err) 16 | } 17 | 18 | err = server.Up() // set IPs, interface must be up by this point 19 | if err != nil { 20 | return fmt.Errorf("%w - failed to bring up the interface", err) 21 | } 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /cmd/cli/remove.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import "fmt" 4 | 5 | func Remove(hostname string, confirm bool) error { 6 | conf, err := LoadConfigFile() 7 | if err != nil { 8 | return fmt.Errorf("%w - failed to load config", err) 9 | } 10 | 11 | if err = conf.RemovePeer(hostname); err != nil { 12 | return fmt.Errorf("%w - failed to update config", err) 13 | } 14 | 15 | if !confirm { 16 | ConfirmOrAbort("Do you really want to remove %s?", hostname) 17 | } 18 | 19 | if err = conf.Save(); err != nil { 20 | return fmt.Errorf("%w - failure to save config", err) 21 | } 22 | server := GetServer(conf) 23 | 24 | if err = server.ConfigureDevice(); err != nil { 25 | return fmt.Errorf("%w - failed to sync server config to wg interface: %s", err, server.InterfaceName) 26 | } 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | dsnet 8 | 9 | # Test binary, built with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | ## Mac is file noise mess 16 | # General 17 | .DS_Store 18 | .AppleDouble 19 | .LSOverride 20 | 21 | # Icon must end with two \r 22 | Icon 23 | 24 | # Thumbnails 25 | ._* 26 | 27 | # Files that might appear in the root of a volume 28 | .DocumentRevisions-V100 29 | .fseventsd 30 | .Spotlight-V100 31 | .TemporaryItems 32 | .Trashes 33 | .VolumeIcon.icns 34 | .com.apple.timemachine.donotpresent 35 | 36 | # Directories potentially created on remote AFP share 37 | .AppleDB 38 | .AppleDesktop 39 | Network Trash Folder 40 | Temporary Items 41 | .apdisk -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | go: [ "1.17", "1.18" ] 16 | arch: 17 | - "GOARCH=amd64" 18 | - "GOARCH=arm GOARM=5" 19 | - "GOARCH=arm GOARM=6" 20 | - "GOARCH=arm GOARM=7" 21 | - "GOARCH=arm64" 22 | steps: 23 | - uses: actions/checkout@v2 24 | 25 | - name: Set up Go 26 | uses: actions/setup-go@v2 27 | with: 28 | go-version: ${{ matrix.go }} 29 | 30 | - name: Build 31 | run: GOOS=linux CGO_ENABLED=0 ${{ matrix.arch }} go build -v ./cmd/root.go 32 | 33 | - name: Lint 34 | run: test -z $(gofmt -l .) 35 | 36 | - name: Test 37 | run: go test -v ./... 38 | -------------------------------------------------------------------------------- /cmd/cli/server.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/naggie/dsnet/lib" 5 | "github.com/spf13/viper" 6 | ) 7 | 8 | func GetServer(config *DsnetConfig) *lib.Server { 9 | fallbackWGBin := viper.GetString("fallback_wg_bin") 10 | return &lib.Server{ 11 | ExternalHostname: config.ExternalHostname, 12 | ExternalIP: config.ExternalIP, 13 | ExternalIP6: config.ExternalIP6, 14 | ListenPort: config.ListenPort, 15 | Domain: config.Domain, 16 | InterfaceName: config.InterfaceName, 17 | Network: config.Network, 18 | Network6: config.Network6, 19 | IP: config.IP, 20 | IP6: config.IP6, 21 | DNS: config.DNS, 22 | PrivateKey: config.PrivateKey, 23 | PostUp: config.PostUp, 24 | PostDown: config.PostDown, 25 | FallbackWGBin: fallbackWGBin, 26 | Peers: jsonPeerToDsnetPeer(config.Peers), 27 | Networks: config.Networks, 28 | PersistentKeepalive: config.PersistentKeepalive, 29 | MTU: config.MTU, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Callan Bryant 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /contrib/report_rendering/js/dsnetreport.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: #030303; 3 | --bg-highlight: #202020; 4 | --text: #aaaaaa; 5 | --text-muted: #666666; 6 | --text-faint: #444444; 7 | --heading: #cccccc; 8 | } 9 | 10 | body { 11 | font-family: Arial, Helvetica, sans-serif; 12 | background: var(--bg); 13 | color: var(--text); 14 | font-size: 20px; 15 | } 16 | 17 | table { 18 | width: 100%; 19 | border-collapse: collapse; 20 | margin: 80px 0; 21 | user-select: text 22 | } 23 | 24 | table th, 25 | table td { 26 | text-align: left; 27 | padding: 10px 5px; 28 | padding: .6em 29 | } 30 | 31 | table th { 32 | border-bottom: 1px solid var(--heading); 33 | color: var(--heading) 34 | } 35 | 36 | table tr.dormant { 37 | opacity: .3 38 | } 39 | 40 | table tr:nth-child(even) { 41 | background: var(--bg-highlight) 42 | } 43 | 44 | table td.indicator-green::before, 45 | table td.indicator-amber::before, 46 | table td.indicator-red::before, 47 | table td.indicator-null::before { 48 | display: inline-block; 49 | content: ""; 50 | margin-right: .4em; 51 | width: .5em; 52 | height: .5em; 53 | border: .05em solid transparent 54 | } 55 | 56 | table td.indicator-green::before { 57 | background: #0f0; 58 | box-shadow: 0 0 .5em rgba(100, 255, 100, .5) 59 | } 60 | 61 | table td.indicator-amber::before { 62 | background: orange 63 | } 64 | 65 | table td.indicator-red::before { 66 | background: red 67 | } 68 | 69 | table td.indicator-null::before { 70 | background: #000; 71 | border: .05em solid var(--text-faint) 72 | } 73 | -------------------------------------------------------------------------------- /contrib/dsnet-nsupdate/README.md: -------------------------------------------------------------------------------- 1 | ## dsnet-nsupdate 2 | 3 | A script to maintain an up-to-date DNS zone based on `dsnetreport.json`. It does this by comparing what is currently in DNS (aided by creating a list of peers in a TXT record in the DNS zone), compared with what needs to be in DNS based on `dsnetreport.json`. It supports both forward and reverse records for IPv4 and IPv6, and can optionally update an external nameserver in a split-horizon configuration. 4 | 5 | #### Dependencies 6 | - `dnspython` 7 | - `colorlog` for colourful logging messages 8 | 9 | #### Usage 10 | 11 | The majority of data is obtained from `dsnetreport.json`, but can be overridden by specifiying it directly in `dsnet-nsupdate`. It should be run directly with the path to `dsnetreport.json` as it's only argument. 12 | 13 | The script uses a TXT record in the zone to maintain a list of what records have been placed there by it. Each time it is run, it queries this list, and then performs more queries on each hostname in this list to determine what is currently in DNS. It then parses`dsnetreport.json` to determine what SHOULD be in DNS. It then compares the two and updates as neccessary via TSIG authenticated dynamic updates. 14 | 15 | The default TTL for entries maintained by this script is 300. If whilst comparing data in DNS it finds an entry with a TTL of over this, it will assume that this is taken by something else and will assign a '-dsnet' suffix to the hostname before putting it in DNS. It will also determine if a subzone has been delegated to a peer (by way of an NS record for that hostname) and ignore it if this is the case. 16 | -------------------------------------------------------------------------------- /do-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [ $# -eq 0 ]; then 5 | echo "Usage: $0 " 6 | echo "Release version required as argument" 7 | exit 1 8 | fi 9 | 10 | VERSION="$1" 11 | GIT_COMMIT=$(git rev-list -1 HEAD) 12 | BUILD_DATE=$(date) 13 | 14 | RELEASE_FILE=RELEASE.md 15 | 16 | export CGO_ENABLED=0 17 | export GOOS=linux 18 | 19 | LDFLAGS="-s -w \ 20 | -X \"github.com/naggie/dsnet.GIT_COMMIT=$GIT_COMMIT\" \ 21 | -X \"github.com/naggie/dsnet.VERSION=$VERSION\" \ 22 | -X \"github.com/naggie/dsnet.BUILD_DATE=$BUILD_DATE\"\ 23 | " 24 | 25 | # check tag starts with v 26 | if [ "${VERSION:0:1}" != "v" ]; then 27 | echo "Tag must start with v" 28 | exit 1 29 | fi 30 | 31 | nvim "+ normal G $" $RELEASE_FILE 32 | 33 | # build 34 | mkdir -p dist 35 | 36 | export GOOS=linux 37 | export CGO_ENABLED=0 38 | 39 | GOARCH=arm GOARM=5 go build -ldflags="$LDFLAGS" -o dist/dsnet cmd/root.go 40 | # upx -q dsnet 41 | mv dist/dsnet dist/dsnet-linux-arm5 42 | 43 | GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/dsnet cmd/root.go 44 | # upx -q dsnet 45 | mv dist/dsnet dist/dsnet-linux-arm64 46 | 47 | GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/dsnet cmd/root.go 48 | # upx -q dsnet 49 | mv dist/dsnet dist/dsnet-linux-amd64 50 | 51 | # github.com/cli/cli 52 | # https://github.com/cli/cli/releases/download/v2.15.0/gh_2.15.0_linux_amd64.deb 53 | # do: gh auth login 54 | gh release create \ 55 | --title $VERSION \ 56 | --notes-file $RELEASE_FILE \ 57 | --draft \ 58 | $VERSION \ 59 | dist/dsnet-linux-arm5#"dsnet linux-arm5" \ 60 | dist/dsnet-linux-arm64#"dsnet linux-arm64" \ 61 | dist/dsnet-linux-amd64#"dsnet linux-amd64" \ 62 | -------------------------------------------------------------------------------- /contrib/report_rendering/hugo/dsnetreport.html: -------------------------------------------------------------------------------- 1 | 2 | {{ $report := $.Site.Data.dsnetreport }} 3 | 4 | {{ with $report }} 5 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {{ range $report.Peers }} 22 | {{ if .Dormant }} 23 | 24 | {{ else }} 25 | 26 | {{ end }} 27 | 28 | 29 | {{ if .Online }} 30 | 31 | 32 | {{ else }} 33 | 34 | 35 | {{ end }} 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | {{ end }} 45 | 46 |
7 | {{ $report.PeersOnline }} of {{ $report.PeersTotal }} devices connected 8 |
HostnameStatusIPOwnerDescriptionUpDown
{{ .Hostname }}onlineoffline{{ .IP }}{{ .Owner }}{{ .Description }}{{ .ReceiveBytesSI }}{{ .TransmitBytesSI }}
47 | {{ else }} 48 | 49 |
50 |     
51 |         /etc/dsnetreport.json not found or empty
52 |     
53 | 
54 | {{ end }} 55 | -------------------------------------------------------------------------------- /lib/lib.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "fmt" 5 | 6 | "golang.zx2c4.com/wireguard/wgctrl" 7 | "golang.zx2c4.com/wireguard/wgctrl/wgtypes" 8 | ) 9 | 10 | func (s *Server) Up() error { 11 | if err := s.CreateLink(); err != nil { 12 | return err 13 | } 14 | return s.ConfigureDevice() 15 | } 16 | 17 | // ConfigureDevice sets up the WG interface 18 | func (s *Server) ConfigureDevice() error { 19 | wg, err := wgctrl.New() 20 | if err != nil { 21 | return err 22 | } 23 | defer wg.Close() 24 | 25 | dev, err := wg.Device(s.InterfaceName) 26 | 27 | if err != nil { 28 | return fmt.Errorf("could not retrieve device '%s' (%v)", s.InterfaceName, err) 29 | } 30 | 31 | peers := s.GetPeers() 32 | 33 | // compare peers to see if any exist on the device and not the config. If 34 | // so, they should be removed by appending a dummy peer with Remove:true + pubkey. 35 | knownKeys := make(map[wgtypes.Key]bool) 36 | 37 | for _, peer := range peers { 38 | knownKeys[peer.PublicKey] = true 39 | } 40 | 41 | // find deleted peers, and append dummy "remove" peers 42 | for _, peer := range dev.Peers { 43 | if !knownKeys[peer.PublicKey] { 44 | peers = append(peers, wgtypes.PeerConfig{ 45 | PublicKey: peer.PublicKey, 46 | Remove: true, 47 | }) 48 | } 49 | } 50 | 51 | wgConfig := wgtypes.Config{ 52 | PrivateKey: &s.PrivateKey.Key, 53 | ListenPort: &s.ListenPort, 54 | // ReplacePeers with the same peers results in those peers losing 55 | // connection, so it's not possible to do declarative configuration 56 | // idempotently with ReplacePeers like I had assumed. Instead, peers 57 | // must be removed imperatively with Remove:true. Peers can still be 58 | // added/updated with ConfigureDevice declaratively. 59 | ReplacePeers: false, 60 | Peers: peers, 61 | } 62 | 63 | err = wg.ConfigureDevice(s.InterfaceName, wgConfig) 64 | 65 | if err != nil { 66 | return fmt.Errorf("could not configure device '%s' (%v)", s.InterfaceName, err) 67 | } 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /cmd/cli/regenerate.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/naggie/dsnet/lib" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | func Regenerate(hostname string, confirm bool) error { 12 | config, err := LoadConfigFile() 13 | if err != nil { 14 | return fmt.Errorf("%w - failure to load config file", err) 15 | } 16 | server := GetServer(config) 17 | 18 | found := false 19 | 20 | if !confirm { 21 | ConfirmOrAbort("This will invalidate current configuration. Regenerate config for %s?", hostname) 22 | } 23 | 24 | for _, peer := range server.Peers { 25 | if peer.Hostname == hostname { 26 | privateKey, err := lib.GenerateJSONPrivateKey() 27 | if err != nil { 28 | return fmt.Errorf("%w - failed to generate private key", err) 29 | } 30 | 31 | preshareKey, err := lib.GenerateJSONKey() 32 | if err != nil { 33 | return fmt.Errorf("%w - failed to generate preshared key", err) 34 | } 35 | 36 | peer.PrivateKey = privateKey 37 | peer.PublicKey = privateKey.PublicKey() 38 | peer.PresharedKey = preshareKey 39 | 40 | err = config.RemovePeer(hostname) 41 | if err != nil { 42 | return fmt.Errorf("%w - failed to regenerate peer", err) 43 | } 44 | 45 | peerType := viper.GetString("output") 46 | 47 | peerConfigBytes, err := lib.AsciiPeerConfig(peer, peerType, *server) 48 | if err != nil { 49 | return fmt.Errorf("%w - failed to get peer configuration", err) 50 | } 51 | os.Stdout.Write(peerConfigBytes.Bytes()) 52 | found = true 53 | if err = config.AddPeer(peer); err != nil { 54 | return fmt.Errorf("%w - failure to add peer", err) 55 | } 56 | 57 | break 58 | } 59 | } 60 | 61 | if !found { 62 | return fmt.Errorf("unknown hostname: %s", hostname) 63 | } 64 | 65 | // Get a new server configuration so we can update the wg interface with the new peer details 66 | server = GetServer(config) 67 | if err = config.Save(); err != nil { 68 | return fmt.Errorf("%w - failure saving config", err) 69 | } 70 | server.ConfigureDevice() 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /cmd/cli/util.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | // FIXME every function in this file has public scope, but only private references 4 | 5 | import ( 6 | "bufio" 7 | "fmt" 8 | "os" 9 | "strings" 10 | 11 | "github.com/naggie/dsnet/lib" 12 | ) 13 | 14 | func jsonPeerToDsnetPeer(peers []PeerConfig) []lib.Peer { 15 | libPeers := make([]lib.Peer, 0, len(peers)) 16 | for _, p := range peers { 17 | libPeers = append(libPeers, lib.Peer{ 18 | Hostname: p.Hostname, 19 | Owner: p.Owner, 20 | Description: p.Description, 21 | IP: p.IP, 22 | IP6: p.IP6, 23 | Added: p.Added, 24 | PublicKey: p.PublicKey, 25 | PrivateKey: p.PrivateKey, 26 | PresharedKey: p.PresharedKey, 27 | Networks: p.Networks, 28 | }) 29 | } 30 | return libPeers 31 | } 32 | 33 | func PromptString(prompt string, required bool) (string, error) { 34 | reader := bufio.NewReader(os.Stdin) 35 | var text string 36 | var err error 37 | 38 | for text == "" { 39 | fmt.Fprintf(os.Stderr, "%s: ", prompt) 40 | text, err = reader.ReadString('\n') 41 | if err != nil { 42 | return "", fmt.Errorf("%w - error getting input", err) 43 | } 44 | text = strings.TrimSpace(text) 45 | } 46 | return text, nil 47 | } 48 | 49 | // FIXME is it critical for this to panic, or can we cascade the errors? 50 | func ConfirmOrAbort(format string, a ...interface{}) { 51 | fmt.Fprintf(os.Stderr, format+" [y/n] ", a...) 52 | 53 | reader := bufio.NewReader(os.Stdin) 54 | 55 | input, err := reader.ReadString('\n') 56 | if err != nil { 57 | panic(err) 58 | } 59 | 60 | if input == "y\n" { 61 | return 62 | } else { 63 | fmt.Fprintf(os.Stderr, "\033[31mAborted.\033[0m\n") 64 | os.Exit(1) 65 | } 66 | } 67 | 68 | func BytesToSI(b uint64) string { 69 | const unit = 1000 70 | if b < unit { 71 | return fmt.Sprintf("%d B", b) 72 | } 73 | div, exp := int64(unit), 0 74 | for n := b / unit; n >= unit; n /= unit { 75 | div *= unit 76 | exp++ 77 | } 78 | return fmt.Sprintf("%.1f %cB", 79 | float64(b)/float64(div), "kMGTPE"[exp]) 80 | } 81 | -------------------------------------------------------------------------------- /lib/types.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "strings" 7 | 8 | "golang.zx2c4.com/wireguard/wgctrl/wgtypes" 9 | ) 10 | 11 | type JSONIPNet struct { 12 | IPNet net.IPNet 13 | } 14 | 15 | func (n JSONIPNet) MarshalJSON() ([]byte, error) { 16 | if len(n.IPNet.IP) == 0 { 17 | return []byte("\"\""), nil 18 | } else { 19 | return []byte("\"" + n.IPNet.String() + "\""), nil 20 | } 21 | } 22 | 23 | func (n *JSONIPNet) UnmarshalJSON(b []byte) error { 24 | cidr := strings.Trim(string(b), "\"") 25 | 26 | if cidr == "" { 27 | // Leave as empty/uninitialised IPNet. A bit like omitempty behaviour, 28 | // but we can leave the field there and blank which is useful if the 29 | // user wishes to add the cidr manually. 30 | return nil 31 | } 32 | 33 | IP, IPNet, err := net.ParseCIDR(cidr) 34 | 35 | if err == nil { 36 | IPNet.IP = IP 37 | n.IPNet = *IPNet 38 | } 39 | 40 | return err 41 | } 42 | 43 | func (n *JSONIPNet) String() string { 44 | return n.IPNet.String() 45 | } 46 | 47 | type JSONKey struct { 48 | Key wgtypes.Key 49 | } 50 | 51 | func (k JSONKey) MarshalJSON() ([]byte, error) { 52 | return []byte("\"" + k.Key.String() + "\""), nil 53 | } 54 | 55 | func (k JSONKey) PublicKey() JSONKey { 56 | return JSONKey{ 57 | Key: k.Key.PublicKey(), 58 | } 59 | } 60 | 61 | func (k *JSONKey) UnmarshalJSON(b []byte) error { 62 | b64Key := strings.Trim(string(b), "\"") 63 | key, err := wgtypes.ParseKey(b64Key) 64 | k.Key = key 65 | return err 66 | } 67 | 68 | func GenerateJSONPrivateKey() (JSONKey, error) { 69 | privateKey, err := wgtypes.GeneratePrivateKey() 70 | 71 | if err != nil { 72 | return JSONKey{}, fmt.Errorf("failed to generate private key: %s", err) 73 | } 74 | 75 | return JSONKey{ 76 | Key: privateKey, 77 | }, nil 78 | } 79 | 80 | func GenerateJSONKey() (JSONKey, error) { 81 | privateKey, err := wgtypes.GenerateKey() 82 | 83 | if err != nil { 84 | return JSONKey{}, fmt.Errorf("failed to generate key: %s", err) 85 | } 86 | 87 | return JSONKey{ 88 | Key: privateKey, 89 | }, nil 90 | } 91 | 92 | func ParseJSONIPNet(cidr string) (JSONIPNet, error) { 93 | _, ipnet, err := net.ParseCIDR(cidr) 94 | if err != nil { 95 | return JSONIPNet{}, fmt.Errorf("failed to parse CIDR %s: %w", cidr, err) 96 | } 97 | return JSONIPNet{IPNet: *ipnet}, nil 98 | } 99 | -------------------------------------------------------------------------------- /cmd/cli/add.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/naggie/dsnet/lib" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | // Add prompts for the required information and creates a new peer 12 | func Add(hostname string, privKey, pubKey bool, owner, description string, confirm bool) error { 13 | config, err := LoadConfigFile() 14 | if err != nil { 15 | return fmt.Errorf("%w - failed to load configuration file", err) 16 | } 17 | server := GetServer(config) 18 | 19 | var private, public string 20 | if privKey { 21 | if private, err = PromptString("private key", true); err != nil { 22 | return err 23 | } 24 | } 25 | if pubKey { 26 | if public, err = PromptString("public key", true); err != nil { 27 | return err 28 | } 29 | } 30 | if owner == "" { 31 | owner, err = PromptString("owner", true) 32 | if err != nil { 33 | return fmt.Errorf("%w - invalid input for owner", err) 34 | } 35 | } 36 | if description == "" { 37 | description, err = PromptString("Description", true) 38 | if err != nil { 39 | return fmt.Errorf("%w - invalid input for Description", err) 40 | } 41 | } 42 | 43 | // publicKey := MustPromptString("PublicKey (optional)", false) 44 | if !confirm { 45 | ConfirmOrAbort("\nDo you want to add the above configuration?") 46 | } 47 | 48 | // newline (not on stdout) to separate config 49 | fmt.Fprintln(os.Stderr) 50 | 51 | peer, err := lib.NewPeer(server, private, public, owner, hostname, description) 52 | if err != nil { 53 | return fmt.Errorf("%w - failed to get new peer", err) 54 | } 55 | 56 | // TODO Some kind of recovery here would be nice, to avoid 57 | // leaving things in a potential broken state 58 | 59 | if err = config.AddPeer(peer); err != nil { 60 | return fmt.Errorf("%w - failed to add new peer", err) 61 | } 62 | 63 | peerType := viper.GetString("output") 64 | 65 | peerConfigBytes, err := lib.AsciiPeerConfig(peer, peerType, *server) 66 | if err != nil { 67 | return fmt.Errorf("%w - failed to get peer configuration", err) 68 | } 69 | os.Stdout.Write(peerConfigBytes.Bytes()) 70 | 71 | if err = config.Save(); err != nil { 72 | return fmt.Errorf("%w - failed to save config file", err) 73 | } 74 | 75 | server = GetServer(config) 76 | if err = server.ConfigureDevice(); err != nil { 77 | return fmt.Errorf("%w - failed to configure device", err) 78 | } 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /lib/generator.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "text/template" 8 | ) 9 | 10 | func getPeerConfTplString(peerType PeerType) (string, error) { 11 | switch peerType { 12 | case WGQuick: 13 | return wgQuickPeerConf, nil 14 | case Vyatta: 15 | return vyattaPeerConf, nil 16 | case NixOS: 17 | return nixosPeerConf, nil 18 | case RouterOS: 19 | return routerosPeerConf, nil 20 | default: 21 | return "", fmt.Errorf("unrecognized peer type") 22 | } 23 | } 24 | 25 | func (p *Peer) getIfName() string { 26 | // derive deterministic interface name 27 | wgifSeed := 0 28 | for _, b := range p.IP { 29 | wgifSeed += int(b) 30 | } 31 | 32 | for _, b := range p.IP6 { 33 | wgifSeed += int(b) 34 | } 35 | return fmt.Sprintf("wg%d", wgifSeed%999) 36 | } 37 | 38 | // GetWGPeerTemplate returns a template string to be used when 39 | // configuring a peer 40 | func GetWGPeerTemplate(peer Peer, peerType PeerType, server Server) (*bytes.Buffer, error) { 41 | peerConf, err := getPeerConfTplString(peerType) 42 | if err != nil { 43 | return nil, fmt.Errorf("failed to get wg template: %s", err) 44 | } 45 | 46 | // See DsnetConfig type for explanation 47 | var endpoint string 48 | 49 | if server.ExternalHostname != "" { 50 | endpoint = server.ExternalHostname 51 | } else if len(server.ExternalIP) > 0 { 52 | endpoint = server.ExternalIP.String() 53 | } else if len(server.ExternalIP6) > 0 { 54 | endpoint = server.ExternalIP6.String() 55 | } else { 56 | return nil, errors.New("server config requires at least one of ExternalIP, ExternalIP6 or ExternalHostname") 57 | } 58 | 59 | t := template.Must(template.New("peerConf").Parse(peerConf)) 60 | cidrSize, _ := server.Network.IPNet.Mask.Size() 61 | cidrSize6, _ := server.Network6.IPNet.Mask.Size() 62 | 63 | var templateBuff bytes.Buffer 64 | err = t.Execute(&templateBuff, map[string]interface{}{ 65 | "Peer": peer, 66 | "Server": server, 67 | "CidrSize": cidrSize, 68 | "CidrSize6": cidrSize6, 69 | // vyatta requires an interface in range/format wg0-wg999 70 | // deterministically choosing one in this range will probably allow use 71 | // of the config without a colliding interface name 72 | "Wgif": peer.getIfName(), 73 | "Endpoint": endpoint, 74 | }) 75 | if err != nil { 76 | return nil, err 77 | } 78 | return &templateBuff, nil 79 | } 80 | 81 | func AsciiPeerConfig(peer Peer, peerType string, server Server) (*bytes.Buffer, error) { 82 | switch peerType { 83 | case "wg-quick": 84 | return GetWGPeerTemplate(peer, WGQuick, server) 85 | case "vyatta": 86 | return GetWGPeerTemplate(peer, Vyatta, server) 87 | case "nixos": 88 | return GetWGPeerTemplate(peer, NixOS, server) 89 | case "routeros": 90 | return GetWGPeerTemplate(peer, RouterOS, server) 91 | default: 92 | return nil, errors.New("unrecognised OUTPUT type") 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /contrib/report_rendering/php/dsnetreport.php: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 70 | 71 | WireGuard Peers 72 | 73 | 74 |
75 |

76 | WireGuard 77 |

78 |
79 |

WireGuard Peers

80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | $value){ 95 | ?> 96 | 97 | '.$value['Hostname'].''; 99 | echo ''; 100 | echo ''; 101 | echo ''; 102 | echo ''; 103 | echo ''; 104 | echo ''; 105 | ?> 106 | 107 | 110 | 111 |
HostnameStatusIPOwnerDescriptionUpDown
'.($value['Online'] ? '
' : '
').'
'.$value['IP'].''.$value['Owner'].''.$value['Description'].''.$value['ReceiveBytesSI'].''.$value['TransmitBytesSI'].'
112 | 113 | 114 | 115 |
116 | 117 | $value) { 119 | $date_str = substr($value['LastHandshakeTime'], 0, 19); 120 | $d2 = new DateTime($date_str); 121 | echo ''.$value['Hostname'].' • Handshake : '. $d2->format("d-m \a\\t H:i:s").'
'; 122 | } 123 | ?> 124 |
125 | 126 | 127 | -------------------------------------------------------------------------------- /etc/logo/with-safe-area.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /etc/logo/without-safe-area.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /lib/peer.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | "strings" 8 | "time" 9 | 10 | "golang.zx2c4.com/wireguard/wgctrl/wgtypes" 11 | ) 12 | 13 | // PeerType is what configuration to use when generating 14 | // peer config files 15 | type PeerType int 16 | 17 | const ( 18 | // WGQuick is used by wg-quick to set up a peer 19 | // https://manpages.debian.org/unstable/wireguard-tools/wg-quick.8.en.html 20 | WGQuick PeerType = iota 21 | // Vyatta is used by Ubiquiti routers 22 | // https://github.com/WireGuard/wireguard-vyatta-ubnt/ 23 | Vyatta 24 | // NixOS is a declartive linux distro 25 | // https://nixos.wiki/wiki/Wireguard 26 | NixOS 27 | // RouterOS is proprietary Linux based OS by MikroTik 28 | // https://help.mikrotik.com/docs/display/ROS/WireGuard 29 | RouterOS 30 | ) 31 | 32 | type Peer struct { 33 | Hostname string 34 | Owner string 35 | Description string 36 | IP net.IP 37 | IP6 net.IP 38 | Added time.Time 39 | PublicKey JSONKey 40 | PrivateKey JSONKey 41 | PresharedKey JSONKey 42 | Networks []JSONIPNet 43 | PersistentKeepalive int 44 | } 45 | 46 | // NewPeer generates a peer from the supplied arguments and generates keys if needed. 47 | // - server is required and provides network information 48 | // - private is a base64-encoded private key; if the empty string, a new key will be generated 49 | // - public is a base64-encoded public key. If empty, it will be generated from the private key. 50 | // If **not** empty, the private key will be included IFF a private key was provided. 51 | // - owner is the owner name (required) 52 | // - hostname is the name of the peer (required) 53 | // - description is the annotation for the peer 54 | func NewPeer(server *Server, private, public, owner, hostname, description string) (Peer, error) { 55 | if owner == "" { 56 | return Peer{}, errors.New("missing owner") 57 | } 58 | if hostname == "" { 59 | return Peer{}, errors.New("missing hostname") 60 | } 61 | 62 | var privateKey JSONKey 63 | if private != "" { 64 | userKey := &JSONKey{} 65 | userKey.UnmarshalJSON([]byte(private)) 66 | privateKey = *userKey 67 | } else { 68 | var err error 69 | privateKey, err = GenerateJSONPrivateKey() 70 | if err != nil { 71 | return Peer{}, fmt.Errorf("failed to generate private key: %s", err) 72 | } 73 | } 74 | 75 | var publicKey JSONKey 76 | if public != "" { 77 | b64Key := strings.Trim(string(public), "\"") 78 | key, err := wgtypes.ParseKey(b64Key) 79 | if err != nil { 80 | return Peer{}, err 81 | } 82 | publicKey = JSONKey{Key: key} 83 | if private == "" { 84 | privateKey = JSONKey{Key: wgtypes.Key([wgtypes.KeyLen]byte{})} 85 | } else { 86 | pubK := privateKey.PublicKey() 87 | ascK := pubK.Key.String() 88 | if ascK != public { 89 | return Peer{}, fmt.Errorf("user-supplied private and public keys are not related") 90 | } 91 | } 92 | } else { 93 | publicKey = privateKey.PublicKey() 94 | } 95 | 96 | presharedKey, err := GenerateJSONKey() 97 | if err != nil { 98 | return Peer{}, fmt.Errorf("failed to generate private key: %s", err) 99 | } 100 | 101 | newPeer := Peer{ 102 | Owner: owner, 103 | Hostname: hostname, 104 | Description: description, 105 | Added: time.Now(), 106 | PublicKey: publicKey, 107 | PrivateKey: privateKey, 108 | PresharedKey: presharedKey, 109 | Networks: []JSONIPNet{}, 110 | // inherit from server setting, which is derived from config 111 | PersistentKeepalive: server.PersistentKeepalive, 112 | } 113 | 114 | if len(server.Network.IPNet.Mask) > 0 { 115 | newIP, err := server.AllocateIP() 116 | if err != nil { 117 | return Peer{}, fmt.Errorf("failed to allocate ipv4 address: %s", err) 118 | } 119 | newPeer.IP = newIP 120 | } 121 | 122 | if len(server.Network6.IPNet.Mask) > 0 { 123 | newIPV6, err := server.AllocateIP6() 124 | if err != nil { 125 | return Peer{}, fmt.Errorf("failed to allocate ipv6 address: %s", err) 126 | } 127 | newPeer.IP6 = newIPV6 128 | } 129 | 130 | if len(server.IP) == 0 && len(server.IP6) == 0 { 131 | return Peer{}, fmt.Errorf("no IPv4 or IPv6 network defined in config") 132 | } 133 | return newPeer, nil 134 | } 135 | -------------------------------------------------------------------------------- /lib/server.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "net" 7 | "time" 8 | 9 | "golang.zx2c4.com/wireguard/wgctrl/wgtypes" 10 | ) 11 | 12 | type Server struct { 13 | ExternalHostname string 14 | ExternalIP net.IP 15 | ExternalIP6 net.IP 16 | ListenPort int 17 | Domain string 18 | InterfaceName string 19 | Network JSONIPNet 20 | Network6 JSONIPNet 21 | IP net.IP 22 | IP6 net.IP 23 | DNS net.IP 24 | PrivateKey JSONKey 25 | PostUp string 26 | PostDown string 27 | FallbackWGBin string 28 | Peers []Peer 29 | Networks []JSONIPNet 30 | PersistentKeepalive int 31 | MTU int 32 | } 33 | 34 | func (s *Server) GetPeers() []wgtypes.PeerConfig { 35 | wgPeers := make([]wgtypes.PeerConfig, 0, len(s.Peers)) 36 | 37 | for _, peer := range s.Peers { 38 | // create a new PSK in memory to avoid passing the same value by 39 | // pointer to each peer (d'oh) 40 | presharedKey := peer.PresharedKey.Key 41 | 42 | // AllowedIPs = private IP + defined networks 43 | allowedIPs := make([]net.IPNet, 0, len(peer.Networks)+2) 44 | 45 | if len(peer.IP) > 0 { 46 | allowedIPs = append( 47 | allowedIPs, 48 | net.IPNet{ 49 | IP: peer.IP, 50 | Mask: net.IPMask{255, 255, 255, 255}, 51 | }, 52 | ) 53 | } 54 | 55 | if len(peer.IP6) > 0 { 56 | allowedIPs = append( 57 | allowedIPs, 58 | net.IPNet{ 59 | IP: peer.IP6, 60 | Mask: net.IPMask{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, 61 | }, 62 | ) 63 | } 64 | 65 | for _, net := range peer.Networks { 66 | allowedIPs = append(allowedIPs, net.IPNet) 67 | } 68 | 69 | wgPeers = append(wgPeers, wgtypes.PeerConfig{ 70 | PublicKey: peer.PublicKey.Key, 71 | Remove: false, 72 | UpdateOnly: false, 73 | PresharedKey: &presharedKey, 74 | Endpoint: nil, 75 | ReplaceAllowedIPs: true, 76 | AllowedIPs: allowedIPs, 77 | }) 78 | } 79 | 80 | return wgPeers 81 | } 82 | 83 | // AllocateIP finds a free IPv4 for a new Peer (sequential allocation) 84 | func (s *Server) AllocateIP() (net.IP, error) { 85 | network := s.Network.IPNet 86 | ones, bits := network.Mask.Size() 87 | zeros := bits - ones 88 | 89 | // avoids network addr 90 | min := 1 91 | // avoids broadcast addr + overflow 92 | max := (1 << zeros) - 2 93 | 94 | IP := make(net.IP, len(network.IP)) 95 | 96 | for i := min; i <= max; i++ { 97 | // dst, src! 98 | copy(IP, network.IP) 99 | 100 | // OR the host part with the network part 101 | for j := 0; j < len(IP); j++ { 102 | shift := (len(IP) - j - 1) * 8 103 | IP[j] = IP[j] | byte(i>>shift) 104 | } 105 | 106 | if !s.IPAllocated(IP) { 107 | return IP, nil 108 | } 109 | } 110 | 111 | return nil, fmt.Errorf("IP range exhausted") 112 | } 113 | 114 | // AllocateIP6 finds a free IPv6 for a new Peer (pseudorandom allocation) 115 | func (s *Server) AllocateIP6() (net.IP, error) { 116 | network := s.Network6.IPNet 117 | ones, bits := network.Mask.Size() 118 | zeros := bits - ones 119 | 120 | rbs := make([]byte, zeros) 121 | rand.Seed(time.Now().UTC().UnixNano()) 122 | 123 | IP := make(net.IP, len(network.IP)) 124 | 125 | for i := 0; i <= 10000; i++ { 126 | rand.Read(rbs) 127 | // dst, src! Copy prefix of IP 128 | copy(IP, network.IP) 129 | 130 | // OR the host part with the network part 131 | for j := ones / 8; j < len(IP); j++ { 132 | IP[j] = IP[j] | rbs[j] 133 | } 134 | 135 | if !s.IPAllocated(IP) { 136 | return IP, nil 137 | } 138 | } 139 | 140 | return nil, fmt.Errorf("Could not allocate random IPv6 after 10000 tries. This was highly unlikely!") 141 | } 142 | 143 | // IPAllocated checks the existing used ips and returns bool 144 | // depending on if the IP is in use 145 | func (s *Server) IPAllocated(IP net.IP) bool { 146 | if IP.Equal(s.IP) || IP.Equal(s.IP6) { 147 | return true 148 | } 149 | 150 | for _, peer := range s.Peers { 151 | if IP.Equal(peer.IP) || IP.Equal(peer.IP6) { 152 | return true 153 | } 154 | 155 | for _, peerIPNet := range peer.Networks { 156 | if IP.Equal(peerIPNet.IPNet.IP) { 157 | return true 158 | } 159 | } 160 | } 161 | 162 | return false 163 | } 164 | -------------------------------------------------------------------------------- /lib/link.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | 8 | "github.com/naggie/dsnet/utils" 9 | "github.com/vishvananda/netlink" 10 | ) 11 | 12 | // IPExists checks if the given IP address already exists on the specified link 13 | func IPExists(link netlink.Link, ipNet *net.IPNet, family int) (bool, error) { 14 | addrs, err := netlink.AddrList(link, family) 15 | if err != nil { 16 | return false, fmt.Errorf("failed to list addresses for interface: %v", err) 17 | } 18 | for _, addr := range addrs { 19 | if addr.IPNet.String() == ipNet.String() { 20 | return true, nil 21 | } 22 | } 23 | return false, nil 24 | } 25 | 26 | // CreateLink sets up the WG interface and link with the correct 27 | // address 28 | func (s *Server) CreateLink() error { 29 | if len(s.IP) == 0 && len(s.IP6) == 0 { 30 | return errors.New("no IPv4 or IPv6 ip defined in config") 31 | } 32 | 33 | linkAttrs := netlink.NewLinkAttrs() 34 | linkAttrs.Name = s.InterfaceName 35 | 36 | link := &netlink.GenericLink{ 37 | LinkAttrs: linkAttrs, 38 | LinkType: "wireguard", 39 | } 40 | 41 | err := netlink.LinkAdd(link) 42 | if err != nil && s.FallbackWGBin != "" { 43 | // return fmt.Errorf("could not add interface '%s' (%v), falling back to the userspace implementation", s.InterfaceName, err) 44 | cmdStr := fmt.Sprintf("%s %s", s.FallbackWGBin, s.InterfaceName) 45 | if err = utils.ShellOut(cmdStr, "Userspace implementation"); err != nil { 46 | return fmt.Errorf("failed to start userspace wireguard: %s", err) 47 | } 48 | } 49 | 50 | if len(s.IP) != 0 { 51 | addr := &netlink.Addr{ 52 | IPNet: &net.IPNet{ 53 | IP: s.IP, 54 | Mask: s.Network.IPNet.Mask, 55 | }, 56 | } 57 | 58 | exists, err := IPExists(link, addr.IPNet, netlink.FAMILY_V4) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | if !exists { 64 | err = netlink.AddrAdd(link, addr) 65 | if err != nil { 66 | return fmt.Errorf("could not add ipv4 addr %s to interface %s: %v", addr.IP, s.InterfaceName, err) 67 | } 68 | } 69 | 70 | // remove any other IPs on the interface 71 | addrs, err := netlink.AddrList(link, netlink.FAMILY_V4) 72 | if err != nil { 73 | return fmt.Errorf("failed to list addresses for interface: %v", err) 74 | } 75 | for _, addr := range addrs { 76 | if addr.IPNet.String() != addr.IPNet.String() { 77 | err := netlink.AddrDel(link, &addr) 78 | if err != nil { 79 | return fmt.Errorf("failed to delete address %s from interface %s: %v", addr.IP, s.InterfaceName, err) 80 | } 81 | } 82 | } 83 | } 84 | 85 | if len(s.IP6) != 0 { 86 | addr6 := &netlink.Addr{ 87 | IPNet: &net.IPNet{ 88 | IP: s.IP6, 89 | Mask: s.Network6.IPNet.Mask, 90 | }, 91 | } 92 | 93 | exists, err := IPExists(link, addr6.IPNet, netlink.FAMILY_V6) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | if !exists { 99 | err = netlink.AddrAdd(link, addr6) 100 | if err != nil { 101 | return fmt.Errorf("could not add ipv6 addr %s to interface %s: %v. Do you have IPv6 enabled?", addr6.IP, s.InterfaceName, err) 102 | } 103 | } 104 | 105 | // remove any other IPs on the interface 106 | addrs, err := netlink.AddrList(link, netlink.FAMILY_V6) 107 | if err != nil { 108 | return fmt.Errorf("failed to list v6 addresses for interface: %v", err) 109 | } 110 | 111 | for _, addr := range addrs { 112 | if addr.IPNet.String() != addr6.IPNet.String() { 113 | err := netlink.AddrDel(link, &addr) 114 | if err != nil { 115 | return fmt.Errorf("failed to delete address %s from interface %s: %v", addr.IP, s.InterfaceName, err) 116 | } 117 | } 118 | } 119 | } 120 | 121 | // set MTU 122 | err = netlink.LinkSetMTU(link, s.MTU) 123 | if err != nil { 124 | return fmt.Errorf("failed to set MTU %d on interface %s: %v", s.MTU, s.InterfaceName, err) 125 | } 126 | 127 | // bring up interface (UNKNOWN state instead of UP, a wireguard quirk) 128 | err = netlink.LinkSetUp(link) 129 | 130 | if err != nil { 131 | return fmt.Errorf("could not bring up device '%s' (%v)", s.InterfaceName, err) 132 | } 133 | return nil 134 | } 135 | 136 | // DeleteLink removes the Netlink interface if it exists 137 | func (s *Server) DeleteLink() error { 138 | link, err := netlink.LinkByName(s.InterfaceName) 139 | if err != nil { 140 | // If the error is because the link doesn't exist, just return nil 141 | if _, ok := err.(netlink.LinkNotFoundError); ok { 142 | return nil 143 | } 144 | return fmt.Errorf("failed to get interface(%s): %v", s.InterfaceName, err) 145 | } 146 | 147 | err = netlink.LinkDel(link) 148 | if err != nil { 149 | return fmt.Errorf("failed to delete interface(%s): %v", s.InterfaceName, err) 150 | } 151 | return nil 152 | } 153 | -------------------------------------------------------------------------------- /etc/logo/square-brand-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /etc/logo/square-brand-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /contrib/report_rendering/README.md: -------------------------------------------------------------------------------- 1 | This directory contains scripts and templates that can be used to render 2 | `/var/lib/dsnetreport.json`. They are useful for integrating a peer overview 3 | into an existing website or web application. 4 | 5 | Most are contributions from other users. If you have a useful addition, please 6 | do a PR. 7 | 8 | Most look something like this: 9 | 10 | ## Hugo shortcode template 11 | 12 | * `hugo/dsnetreport.html`: A hugo shortcode for rendering a report. 13 | 14 | ![dsnet report table](https://raw.githubusercontent.com/naggie/dsnet/master/etc/report.png) 15 | 16 | # PHP template 17 | See https://github.com/naggie/dsnet/issues/4#issuecomment-632928158 for background. Courtesy of [@Write](https://github.com/Write) 18 | 19 | * `php/dsnetreport.php`: A php file to render a report. 20 | 21 | ![dsnet report table](https://user-images.githubusercontent.com/541722/82712747-0cf42180-9c89-11ea-92fa-0974a34c5c79.jpg) 22 | ![dsnet report table](https://user-images.githubusercontent.com/541722/82712745-0a91c780-9c89-11ea-91a8-828e0be38951.jpg) 23 | 24 | # Clientside JavaScript 25 | 26 | Courtesy of [@frillip](https://github.com/frillip/) 27 | 28 | * `js/dsnetreport.html`: Basic HTML with a `div` to place the table in. 29 | * `js/dsnetreport.js`: Fetches `dsnetreport.json` and renders table. 30 | * `js/dsnetreport.css`: CSS to render the table as per screenshot. 31 | 32 | ![dsnet report table](https://raw.githubusercontent.com/naggie/dsnet/master/etc/dsnet-report-js.png) 33 | 34 | # CLI (bash) 35 | 36 | On the command line, you can use [jtbl](https://github.com/kellyjonbrazil/jtbl) (and [jq](https://stedolan.github.io/jq/)) for a nice table rendering with this snippet: 37 | 38 | ```bash 39 | sudo dsnet report | jq '.Peers' | jtbl 40 | ``` 41 | 42 | The output looks like: 43 | ``` 44 | ╒═════════╤═══════╤══════════╤══════════╤══════════╤══════════╤═════════╤════════╤══════════╤══════════╤══════════╤══════════╤══════════╤══════════╤══════════╕ 45 | │ Owner │ IP6 │ Hostna │ Descri │ Online │ Dorman │ Added │ IP │ Extern │ Networ │ LastHa │ Receiv │ Transm │ Receiv │ Transm │ 46 | │ │ │ me │ ption │ │ t │ │ │ alIP │ ks │ ndshak │ eBytes │ itByte │ eBytes │ itByte │ 47 | │ │ │ │ │ │ │ │ │ │ │ eTime │ │ s │ SI │ sSI │ 48 | ╞═════════╪═══════╪══════════╪══════════╪══════════╪══════════╪═════════╪════════╪══════════╪══════════╪══════════╪══════════╪══════════╪══════════╪══════════╡ 49 | │ xyz │ │ eaetl │ eaetl. │ True │ False │ 2222-0 │ 99.99. │ dddd:d │ [] │ 1111-1 │ 175995 │ 447007 │ 175.9 │ 32.7 M │ 50 | │ │ │ │ fooo │ │ │ 2-22T1 │ 99.9 │ dd:ddd │ │ 1-11T1 │ 424 │ 28 │ MB │ B │ 51 | │ │ │ │ │ │ │ 2:22:5 │ │ d:dddd │ │ 1:11:1 │ │ │ │ │ 52 | │ │ │ │ │ │ │ 2.2274 │ │ :dddd: │ │ 1.1111 │ │ │ │ │ 53 | │ │ │ │ │ │ │ 22222- │ │ dddd:d │ │ 11111- │ │ │ │ │ 54 | │ │ │ │ │ │ │ 22:20 │ │ ddd:dd │ │ 11:11 │ │ │ │ │ 55 | │ │ │ │ │ │ │ │ │ dd │ │ │ │ │ │ │ 56 | ├─────────┼───────┼──────────┼──────────┼──────────┼──────────┼─────────┼────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤ 57 | │ xyz │ │ ammedu │ ammedu │ True │ False │ 2222-0 │ 88.88. │ eeee:e │ [] │ 1111-1 │ 751670 │ 759741 │ 6.7 GB │ 727.7 │ 58 | │ │ │ │ .mymy. │ │ │ 2-22T1 │ 88.8 │ eee:ee │ │ 1-11T1 │ 2852 │ 076 │ │ MB │ 59 | │ │ │ │ com │ │ │ 2:22:4 │ │ ee:eee │ │ 1:11:1 │ │ │ │ │ 60 | │ │ │ │ │ │ │ 2.2292 │ │ e::e │ │ 1.1111 │ │ │ │ │ 61 | │ │ │ │ │ │ │ 22226- │ │ │ │ 11111- │ │ │ │ │ 62 | │ │ │ │ │ │ │ 22:20 │ │ │ │ 11:11 │ │ │ │ │ 63 | ├─────────┼───────┼──────────┼──────────┼──────────┼──────────┼─────────┼────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤ 64 | ... 65 | ``` 66 | 67 | To tighten up the table, use JQ to remove columns you're not interested in: 68 | 69 | ``` 70 | sudo dsnet report | jq '.Peers | map(del(.Added,.Networks,.IP6,.Owner))' | jtbl 71 | ``` -------------------------------------------------------------------------------- /cmd/cli/init.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/ioutil" 7 | "math/rand" 8 | "net" 9 | "net/http" 10 | "os" 11 | "strings" 12 | "time" 13 | 14 | "github.com/naggie/dsnet/lib" 15 | "github.com/spf13/viper" 16 | ) 17 | 18 | func Init() error { 19 | listenPort := viper.GetInt("listen_port") 20 | MTU := viper.GetInt("mtu") 21 | configFile := viper.GetString("config_file") 22 | interfaceName := viper.GetString("interface_name") 23 | 24 | _, err := os.Stat(configFile) 25 | 26 | if !os.IsNotExist(err) { 27 | return fmt.Errorf("Refusing to overwrite existing %s", configFile) 28 | } 29 | 30 | privateKey, err := lib.GenerateJSONPrivateKey() 31 | if err != nil { 32 | return fmt.Errorf("%w - failed to generate private key", err) 33 | } 34 | 35 | externalIPV4, err := getExternalIP() 36 | if err != nil { 37 | return err 38 | } 39 | 40 | externalIPV6, err := getExternalIP6() 41 | if err != nil { 42 | return err 43 | } 44 | 45 | conf := &DsnetConfig{ 46 | PrivateKey: privateKey, 47 | ListenPort: listenPort, 48 | Network: getPrivateNet(), 49 | Network6: getULANet(), 50 | Peers: []PeerConfig{}, 51 | Domain: "dsnet", 52 | ExternalIP: externalIPV4, 53 | ExternalIP6: externalIPV6, 54 | InterfaceName: interfaceName, 55 | Networks: []lib.JSONIPNet{}, 56 | PersistentKeepalive: 25, 57 | MTU: MTU, 58 | } 59 | 60 | server := GetServer(conf) 61 | 62 | ipv4, err := server.AllocateIP() 63 | if err != nil { 64 | return fmt.Errorf("%w - failed to allocate ipv4 address", err) 65 | } 66 | 67 | ipv6, err := server.AllocateIP6() 68 | if err != nil { 69 | return fmt.Errorf("%w - failed to allocate ipv6 address", err) 70 | } 71 | 72 | conf.IP = ipv4 73 | conf.IP6 = ipv6 74 | 75 | if len(conf.ExternalIP) == 0 && len(conf.ExternalIP6) == 0 { 76 | return fmt.Errorf("Could not determine any external IP, v4 or v6") 77 | } 78 | 79 | if err := conf.Save(); err != nil { 80 | return fmt.Errorf("%w - failed to save config file", err) 81 | } 82 | 83 | fmt.Printf("Config written to %s. Please check/edit.\n", configFile) 84 | return nil 85 | } 86 | 87 | // get a random IPv4 /22 subnet on 10.0.0.0 (1023 hosts) (or /24?) 88 | func getPrivateNet() lib.JSONIPNet { 89 | rbs := make([]byte, 2) 90 | rand.Seed(time.Now().UTC().UnixNano()) 91 | rand.Read(rbs) 92 | 93 | return lib.JSONIPNet{ 94 | IPNet: net.IPNet{ 95 | IP: net.IP{10, rbs[0], rbs[1] << 2, 0}, 96 | Mask: net.IPMask{255, 255, 252, 0}, 97 | }, 98 | } 99 | } 100 | 101 | func getULANet() lib.JSONIPNet { 102 | rbs := make([]byte, 5) 103 | rand.Seed(time.Now().UTC().UnixNano()) 104 | rand.Read(rbs) 105 | 106 | // fd00 prefix with 40 bit global id and zero (16 bit) subnet ID 107 | return lib.JSONIPNet{ 108 | IPNet: net.IPNet{ 109 | IP: net.IP{0xfd, 0, rbs[0], rbs[1], rbs[2], rbs[3], rbs[4], 0, 0, 0, 0, 0, 0, 0, 0, 0}, 110 | Mask: net.IPMask{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0}, 111 | }, 112 | } 113 | } 114 | 115 | // TODO factor getExternalIP + getExternalIP6 116 | func getExternalIP() (net.IP, error) { 117 | var IP net.IP 118 | // arbitrary external IP is used (one that's guaranteed to route outside. 119 | // In this case, Google's DNS server. Doesn't actually need to be online.) 120 | conn, err := net.Dial("udp", "8.8.8.8:53") 121 | if err != nil { 122 | return nil, err 123 | } 124 | defer conn.Close() 125 | 126 | localAddr := conn.LocalAddr().String() 127 | IP = net.ParseIP(strings.Split(localAddr, ":")[0]) 128 | IP = IP.To4() 129 | 130 | if !(IP[0] == 10 || (IP[0] == 172 && IP[1] >= 16 && IP[1] <= 31) || (IP[0] == 192 && IP[1] == 168)) { 131 | // not private, so public 132 | return IP, nil 133 | } 134 | 135 | // detect private IP and use icanhazip.com instead 136 | client := http.Client{ 137 | Timeout: 5 * time.Second, 138 | } 139 | resp, err := client.Get("https://ipv4.icanhazip.com/") 140 | if err != nil { 141 | return nil, err 142 | } 143 | defer resp.Body.Close() 144 | 145 | if resp.StatusCode == http.StatusOK { 146 | body, err := ioutil.ReadAll(resp.Body) 147 | if err != nil { 148 | return nil, err 149 | } 150 | IP = net.ParseIP(strings.TrimSpace(string(body))) 151 | return IP.To4(), nil 152 | } 153 | 154 | return nil, errors.New("failed to determine external ip") 155 | } 156 | 157 | func getExternalIP6() (net.IP, error) { 158 | var IP net.IP 159 | conn, err := net.Dial("udp", "2001:4860:4860::8888:53") 160 | if err == nil { 161 | defer conn.Close() 162 | 163 | localAddr := conn.LocalAddr().String() 164 | IP = net.ParseIP(strings.Split(localAddr, ":")[0]) 165 | 166 | // check is not a ULA 167 | if IP[0] != 0xfd && IP[0] != 0xfc { 168 | return IP, nil 169 | } 170 | } 171 | 172 | client := http.Client{ 173 | Timeout: 5 * time.Second, 174 | } 175 | resp, err := client.Get("https://ipv6.icanhazip.com/") 176 | if err == nil { 177 | defer resp.Body.Close() 178 | 179 | if resp.StatusCode == http.StatusOK { 180 | body, err := ioutil.ReadAll(resp.Body) 181 | if err != nil { 182 | return nil, err 183 | } 184 | IP = net.ParseIP(strings.TrimSpace(string(body))) 185 | return IP, nil 186 | } 187 | } 188 | 189 | return net.IP{}, nil 190 | } 191 | -------------------------------------------------------------------------------- /lib/templates.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | const wgQuickPeerConf = `[Interface] 4 | {{ if gt (.Server.Network.IPNet.IP | len) 0 -}} 5 | Address={{ .Peer.IP }}/{{ .CidrSize }} 6 | {{ end -}} 7 | {{ if gt (.Server.Network6.IPNet.IP | len) 0 -}} 8 | Address={{ .Peer.IP6 }}/{{ .CidrSize6 }} 9 | {{ end -}} 10 | PrivateKey={{ .Peer.PrivateKey.Key }} 11 | {{- if .Server.DNS }} 12 | DNS={{ .Server.DNS }} 13 | {{ end }} 14 | 15 | [Peer] 16 | PublicKey={{ .Server.PrivateKey.PublicKey.Key }} 17 | PresharedKey={{ .Peer.PresharedKey.Key }} 18 | Endpoint={{ .Endpoint }}:{{ .Server.ListenPort }} 19 | PersistentKeepalive={{ .Server.PersistentKeepalive }} 20 | {{ if gt (.Server.Network.IPNet.IP | len) 0 -}} 21 | AllowedIPs={{ .Server.Network.IPNet.IP }}/{{ .CidrSize }} 22 | {{ end -}} 23 | {{ if gt (.Server.Network6.IPNet.IP | len) 0 -}} 24 | AllowedIPs={{ .Server.Network6.IPNet.IP }}/{{ .CidrSize6 }} 25 | {{ end -}} 26 | {{ range .Server.Networks -}} 27 | AllowedIPs={{ . }} 28 | {{ end -}} 29 | ` 30 | 31 | const vyattaPeerConf = `configure 32 | {{ if gt (.Server.Network.IPNet.IP | len) 0 -}} 33 | set interfaces wireguard wg0 address {{ .Peer.IP }}/{{ .CidrSize }} 34 | {{ end -}} 35 | {{ if gt (.Server.Network6.IPNet.IP | len) 0 -}} 36 | set interfaces wireguard wg0 address {{ .Peer.IP6 }}/{{ .CidrSize6 }} 37 | {{ end -}} 38 | set interfaces wireguard wg0 route-allowed-ips true 39 | set interfaces wireguard wg0 private-key {{ .Peer.PrivateKey.Key }} 40 | set interfaces wireguard wg0 description {{ .Server.InterfaceName }} 41 | {{- if .Server.DNS }} 42 | #set service dns forwarding name-server {{ .Server.DNS }} 43 | {{ end }} 44 | 45 | set interfaces wireguard wg0 peer {{ .Server.PrivateKey.PublicKey.Key }} endpoint {{ .Endpoint }}:{{ .Server.ListenPort }} 46 | set interfaces wireguard wg0 peer {{ .Server.PrivateKey.PublicKey.Key }} persistent-keepalive {{ .Server.PersistentKeepalive }} 47 | set interfaces wireguard wg0 peer {{ .Server.PrivateKey.PublicKey.Key }} preshared-key {{ .Peer.PresharedKey.Key }} 48 | {{ if gt (.Server.Network.IPNet.IP | len) 0 -}} 49 | set interfaces wireguard wg0 peer {{ .Server.PrivateKey.PublicKey.Key }} allowed-ips {{ .Server.Network.IPNet.IP }}/{{ .CidrSize }} 50 | {{ end -}} 51 | {{ if gt (.Server.Network6.IPNet.IP | len) 0 -}} 52 | set interfaces wireguard wg0 peer {{ .Server.PrivateKey.PublicKey.Key }} allowed-ips {{ .Server.Network6.IPNet.IP }}/{{ .CidrSize6 }} 53 | {{ end -}} 54 | {{ range .Server.Networks -}} 55 | set interfaces wireguard wg0 peer {{ .Server.PrivateKey.PublicKey.Key }} allowed-ips {{ . }} 56 | {{ end -}} 57 | commit; save 58 | ` 59 | 60 | const nixosPeerConf = `networking.wireguard.interfaces = {{ "{" }} 61 | dsnet = {{ "{" }} 62 | ips = [ 63 | {{ if gt (.Server.Network.IPNet.IP | len) 0 -}} 64 | "{{ .Peer.IP }}/{{ .CidrSize }}" 65 | {{ end -}} 66 | {{ if gt (.Server.Network6.IPNet.IP | len) 0 -}} 67 | "{{ .Peer.IP6 }}/{{ .CidrSize6 }}" 68 | {{ end -}} 69 | ]; 70 | privateKey = "{{ .Peer.PrivateKey.Key }}"; 71 | peers = [ 72 | {{ "{" }} 73 | publicKey = "{{ .Server.PrivateKey.PublicKey.Key }}"; 74 | presharedKey = "{{ .Peer.PresharedKey.Key }}"; 75 | allowedIPs = [ 76 | {{ if gt (.Server.Network.IPNet.IP | len) 0 -}} 77 | "{{ .Server.Network.IPNet.IP }}/{{ .CidrSize }}" 78 | {{ end -}} 79 | {{ if gt (.Server.Network6.IPNet.IP | len) 0 -}} 80 | "{{ .Server.Network6.IPNet.IP }}/{{ .CidrSize6 }}" 81 | {{ end -}} 82 | {{ range .Server.Networks -}} 83 | "{{ . }}" 84 | {{ end -}} 85 | ]; 86 | endpoint = "{{ .Endpoint }}:{{ .Server.ListenPort }}"; 87 | persistentKeepalive = {{ .Server.PersistentKeepalive }}; 88 | dynamicEndpointRefreshRestartSeconds = 5; # restart on failure (e.g. DNS issue) 89 | dynamicEndpointRefreshSeconds = 300; # refresh DNS periodically 90 | {{ "}" }} 91 | ]; 92 | {{ "};" }} 93 | {{ "};" }} 94 | ` 95 | 96 | const routerosPeerConf = `/interface wireguard 97 | add name=wg0 private-key="{{ .Peer.PrivateKey.Key }}"; 98 | /interface list member 99 | add interface=wg0 list=LAN 100 | /ip address 101 | {{ if gt (.Server.Network.IPNet.IP | len) 0 -}} 102 | add address={{ .Peer.IP }}/{{ .CidrSize }} interface=wg0 103 | {{ end -}} 104 | /ipv6 address 105 | {{ if gt (.Server.Network6.IPNet.IP | len) 0 -}} 106 | add address={{ .Peer.IP6 }}/{{ .CidrSize6 }} advertise=no interface=wg0 107 | {{ end -}} 108 | /interface wireguard peers 109 | {{/* MikroTik RouterOS does not like trailing commas in arrays */ -}} 110 | {{ $first := true -}} 111 | add interface=wg0 \ 112 | public-key="{{ .Server.PrivateKey.PublicKey.Key }}" \ 113 | preshared-key="{{ .Peer.PresharedKey.Key }}" \ 114 | endpoint-address={{ .Endpoint }} \ 115 | endpoint-port={{ .Server.ListenPort }} \ 116 | persistent-keepalive={{ .Server.PersistentKeepalive }}s \ 117 | allowed-address= 118 | {{- if gt (.Server.Network.IPNet.IP | len) 0 }} 119 | {{- if $first}}{{$first = false}}{{else}},{{end}} 120 | {{- .Server.Network.IPNet.IP }}/{{ .CidrSize }} 121 | {{- end }} 122 | {{- if gt (.Server.Network6.IPNet.IP | len) 0 }} 123 | {{- if $first}}{{$first = false}}{{else}},{{end}} 124 | {{- .Server.Network6.IPNet.IP }}/{{ .CidrSize6 }} 125 | {{- end }} 126 | {{- range .Server.Networks }} 127 | {{- if $first}}{{$first = false}}{{else}},{{end}} 128 | {{- . }} 129 | {{- end }} 130 | ` 131 | -------------------------------------------------------------------------------- /CONFIG.md: -------------------------------------------------------------------------------- 1 | Explanation of each field: 2 | 3 | { 4 | "ExternalHostname": "", 5 | 6 | The `ExternalHostname` is used for the client config server `Endpoint` if 7 | defined. It has precedence over `ExternalIP` and `ExternalIP6`. 8 | 9 | 10 | "ExternalIP": "198.51.100.2", 11 | "ExternalIP6": "2001:0db8:85a3:0000:0000:8a2e:0370:7334", 12 | 13 | This is the external IPv4 and IPv6 that will be the value of Endpoint for the 14 | server peer in client configs. It is automatically detected by opening a socket 15 | or using an external IP discovery service -- the first to give a valid public 16 | IP will win. 17 | 18 | When generating configs, the `ExternalHostname` has precendence for the server 19 | `Endpoint`, followed by `ExternalIP` (IPv4) and `ExternalIP6` (IPv6) The IPs are 20 | discovered automatically on init. Define an `ExternalHostname` if you're using 21 | dynamic DNS, want to change IPs without updating configs, or want wireguard to 22 | be able to choose between IPv4/IPv6. It is only possible to specify one 23 | Endpoint per peer entry in wireguard. 24 | 25 | 26 | "ListenPort": 51820, 27 | 28 | The port wiregard should listen on. 29 | 30 | "Domain": "dsnet", 31 | 32 | The domain to copy to the report file. Not used for anything else; it's useful 33 | for DNS integration. At one site I have a script to add hosts to a zone upon 34 | connection by polling the report file. 35 | 36 | "InterfaceName": "dsnet", 37 | 38 | The wireguard interface name. 39 | 40 | "Network": "10.164.236.0/22", 41 | "Network6": "fd00:7b31:106a:ae00::/64", 42 | 43 | The CIDR network to use when allocating IPs to peers. This subnet, a `/22` in 44 | the `10.0.0.0/16` block is generated randomly to (probably) avoid collisions 45 | with other networks. There are 1022 addresses available. Addresses are 46 | allocated to peers when peers are added with `dsnet add` using the lowest 47 | available address. 48 | 49 | A random ULA network with a subnet of 0 is generated for IPv6. 50 | 51 | "IP": "10.164.236.1", 52 | "IP6": "fd00:7b31:106a:ae00:44c3:29c3:53b1:a6f9", 53 | 54 | This is the private VPN IP of the server peer. It is the first address in the 55 | above pool. 56 | 57 | "DNS": "", 58 | 59 | If defined, this IP address will be set in the generated peer wg-quick config 60 | files. 61 | 62 | "Networks": [], 63 | 64 | This is a list of additional CIDR-notated networks that can be routed through 65 | the server peer. They will be added under the server peer under `AllowedIPs` in 66 | addition to the private network defined in `Network` above. If you want to 67 | route the whole internet through the server peer, add `0.0.0.0/0` to the list 68 | before adding peers. For more advanced options and theory, see 69 | . 70 | 71 | The report contains no sensitive information. At one site I use it together 72 | with [hugo](https://gohugo.io/) 73 | [shortcodes](https://gohugo.io/templates/shortcode-templates/) to generate a 74 | network overview page. The shortcode file is included in this repository under 75 | `etc/`. 76 | 77 | "PostUp": "" 78 | "PostDown": "" 79 | 80 | Allows a user to specify commands to run after the device is up or down. This is 81 | typcially a collection of `iptables` invocations. The commands are executed by 82 | `/bin/sh`. *NOTE* These commands run as root, so make sure you check that they 83 | are secure. 84 | 85 | "PrivateKey": "uC+xz3v1mfjWBHepwiCgAmPebZcY+EdhaHAvqX2r7U8=", 86 | 87 | The server private key, automatically generated and very sensitive! 88 | 89 | "Peers": [] 90 | 91 | The list of peers managed by `dsnet add` and `dsnet remove`. See below for format. 92 | 93 | } 94 | 95 | The configuration file can be manually/programatically managed outside of dsnet 96 | if desired; `dsnet sync` will update wireguard. 97 | 98 | Peer configuration, `Peers: []` in `dsnetconfig.json`: 99 | 100 | { 101 | "Hostname": "test", 102 | 103 | The hostname given via `dsnet add `. It is used to identify the peer 104 | in the report and for peer removal via `dsnet remove `. 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 | Dsnet banner 3 | 4 | 5 |
6 | Packaging status 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 | ![dsnet add](https://raw.githubusercontent.com/naggie/dsnet/master/etc/init+add.png) 25 | 26 | The server peer is listening, and a client peer config has been generated and 27 | added to the server peer: 28 | 29 | ![wg](https://raw.githubusercontent.com/naggie/dsnet/master/etc/wg2.png) 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 | ![dsnet report table](https://raw.githubusercontent.com/naggie/dsnet/master/etc/report.png) 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 | --------------------------------------------------------------------------------