├── LICENSE ├── README.md ├── go.mod ├── go.sum └── main.go /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Tailscale Community 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tailscale-prefix-mover 2 | 3 | [![status: experimental](https://img.shields.io/badge/status-experimental-blue)](https://tailscale.com/kb/1167/release-stages/#experimental) 4 | 5 | Provide a set of prefixes within `100.60.0.0/10` and this tool will find devices within those prefixes and reassign devices to other space within the CGNAT prefix. 6 | 7 | See [Visual Subnet Calculator](https://www.davidc.net/sites/default/subnets/subnets.html?network=100.64.0.0&mask=10&division=1.0) for an easy subnet calculator. 8 | 9 | ## Usage 10 | 11 | ```shell 12 | go run github.com/tailscale-dev/tailscale-prefix-mover -help 13 | ``` 14 | 15 | ### Example 16 | 17 | Pass `-apply` to make changes. 18 | 19 | ```shell 20 | export TAILSCALE_TAILNET=... 21 | export TAILSCALE_API_KEY=... 22 | 23 | go run github.com/tailscale-dev/tailscale-prefix-mover -from-prefixes=100.72.0.0/13,100.96.0.0/11 24 | Moving devices from [100.72.0.0/13 100.96.0.0/11] to [100.64.0.0/13 100.80.0.0/12] 25 | Setting v4 address [w.x.y.z ] to [nodeid:1234567890 / name:device123.example.ts.net]... done. 26 | Setting v4 address [w.x.y.z ] to [nodeid:9876543210 / name:device987.example.ts.net]... done. 27 | Pass -apply to make changes. 28 | Done. 29 | ``` 30 | 31 | ### Example with -to-prefixes 32 | 33 | Pass `-apply` to make changes. 34 | 35 | ```shell 36 | export TAILSCALE_TAILNET=... 37 | export TAILSCALE_API_KEY=... 38 | 39 | go run github.com/tailscale-dev/tailscale-prefix-mover -from-prefixes=100.72.0.0/13,100.96.0.0/11 -to-prefixes=100.64.0.0/24 40 | Moving devices from [100.72.0.0/13 100.96.0.0/11] to [100.64.0.0/24] 41 | Setting v4 address [w.x.y.z ] to [nodeid:1234567890 / name:device123.example.ts.net]... done. 42 | Setting v4 address [w.x.y.z ] to [nodeid:9876543210 / name:device987.example.ts.net]... done. 43 | Pass -apply to make changes. 44 | Done. 45 | ``` 46 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tailscale-dev/tailscale-prefix-mover 2 | 3 | go 1.22.0 4 | 5 | toolchain go1.22.3 6 | 7 | require ( 8 | github.com/tailscale/tailscale-client-go v1.17.1-0.20240517204238-4ffdd2da2d29 // TODO: update once library is released 9 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba 10 | ) 11 | 12 | require ( 13 | github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5 // indirect 14 | golang.org/x/oauth2 v0.19.0 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 4 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 8 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 9 | github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5 h1:erxeiTyq+nw4Cz5+hLDkOwNF5/9IQWCQPv0gpb3+QHU= 10 | github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= 11 | github.com/tailscale/tailscale-client-go v1.17.1-0.20240515175515-5de5ead197a1 h1:zkMJ0cy93NV8PamPzmJMgw2AhJP6V4PwcYivH48G2Q0= 12 | github.com/tailscale/tailscale-client-go v1.17.1-0.20240515175515-5de5ead197a1/go.mod h1:Zxz9AWl4cNX8F+jE7iIeo6Me7dGPXNdCFIoVeovH6eI= 13 | github.com/tailscale/tailscale-client-go v1.17.1-0.20240517204238-4ffdd2da2d29 h1:y8mr58+6S8zcSTd53chFOudb6a5+6mTZRmIZDmym2aM= 14 | github.com/tailscale/tailscale-client-go v1.17.1-0.20240517204238-4ffdd2da2d29/go.mod h1:Zxz9AWl4cNX8F+jE7iIeo6Me7dGPXNdCFIoVeovH6eI= 15 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= 16 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= 17 | golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg= 18 | golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8= 19 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 20 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 21 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/binary" 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "log" 10 | "math/rand/v2" 11 | "net/netip" 12 | "os" 13 | "strings" 14 | 15 | "github.com/tailscale/tailscale-client-go/tailscale" 16 | "go4.org/netipx" 17 | ) 18 | 19 | var ( 20 | fromPrefixes prefixSlice 21 | toPrefixes prefixSlice 22 | apply = flag.Bool("apply", false, "make changes, otherwise will just print devices found within -from-prefixes") 23 | maxRetries = flag.Int("max-retries", 5, "max times to retry if random new IP is already in use") 24 | continueOnError = flag.Bool("continue-on-error", false, "continue reassigning devices if an error for any device is encountered") 25 | 26 | cgnatPfx = netip.MustParsePrefix("100.64.0.0/10") 27 | ) 28 | 29 | type prefixSlice []netip.Prefix 30 | 31 | func (i *prefixSlice) String() string { 32 | return fmt.Sprintf("%s", *i) 33 | } 34 | 35 | func (i *prefixSlice) Set(value string) error { 36 | values := strings.Split(value, ",") 37 | for _, v := range values { 38 | parsedPrefix, err := netip.ParsePrefix(strings.TrimSpace(v)) 39 | if err != nil { 40 | return err 41 | } 42 | if !cgnatPfx.Overlaps(parsedPrefix) { 43 | return errors.New(fmt.Sprintf("prefix [%s] is not within [%s]", v, cgnatPfx)) 44 | } 45 | *i = append(*i, parsedPrefix) 46 | } 47 | return nil 48 | } 49 | 50 | func usage() { 51 | fmt.Fprintf(os.Stderr, "usage: tailscale-prefix-mover [flags]\n") 52 | flag.PrintDefaults() 53 | } 54 | 55 | func checkArgs() error { 56 | if fromPrefixes == nil || len(fromPrefixes) == 0 { 57 | return errors.New("missing required flag -from-prefixes") 58 | } 59 | return nil 60 | } 61 | 62 | func main() { 63 | flag.Var(&fromPrefixes, "from-prefixes", fmt.Sprintf("prefixes to move devices FROM - must be within %s", cgnatPfx)) 64 | flag.Var(&toPrefixes, "to-prefixes", fmt.Sprintf("prefixes to move devices to - must be within %s", cgnatPfx)) 65 | flag.Parse() 66 | 67 | err := checkArgs() 68 | if err != nil { 69 | fmt.Printf("%s\n", err) 70 | usage() 71 | os.Exit(1) 72 | } 73 | 74 | apiKey := os.Getenv("TAILSCALE_API_KEY") 75 | tailnet := os.Getenv("TAILSCALE_TAILNET") 76 | 77 | tailscaleClient, err := tailscale.NewClient(apiKey, tailnet) 78 | if err != nil { 79 | log.Fatalln(err) 80 | } 81 | 82 | availablePrefixes := toPrefixes 83 | if availablePrefixes == nil { 84 | availablePrefixes, err = calculateAvailablePrefixes(fromPrefixes) 85 | if err != nil { 86 | log.Fatalln(err) 87 | } 88 | } 89 | 90 | fmt.Printf("Moving devices from %s to %s\n", fromPrefixes, availablePrefixes) 91 | 92 | ctx := context.Background() 93 | devices, err := tailscaleClient.Devices(ctx) 94 | if err != nil { 95 | log.Fatalln(err) 96 | } 97 | 98 | errCount := 0 99 | for _, fromPrefix := range fromPrefixes { 100 | for _, device := range devices { 101 | v4Address, err := netip.ParseAddr(device.Addresses[0]) 102 | if err != nil { 103 | log.Fatalln(err) 104 | } 105 | 106 | if fromPrefix.Contains(v4Address) { 107 | err = reassignDeviceAddress(ctx, tailscaleClient, device, availablePrefixes) 108 | if err != nil { 109 | errCount++ 110 | fmt.Printf("error setting address for device [nodeid:%-16s / name:%s] - [%s]\n", device.ID, device.Name, err) 111 | if *continueOnError { 112 | fmt.Printf(" Continuing...\n") 113 | continue 114 | } else { 115 | fmt.Printf(" Stopping.\n") 116 | break // unnecessary because log.Fatal will exit, but seems good to have here anyway 117 | } 118 | } 119 | } 120 | } 121 | } 122 | 123 | if !*apply { 124 | fmt.Printf("Pass -apply to make changes.\n") 125 | } 126 | 127 | if errCount > 0 { 128 | fmt.Printf("Done.\n") 129 | os.Exit(1) 130 | } else { 131 | fmt.Printf("Done.\n") 132 | } 133 | } 134 | 135 | func reassignDeviceAddress(ctx context.Context, tailscaleClient *tailscale.Client, device tailscale.Device, availablePrefixes []netip.Prefix) error { 136 | for i := 0; i < *maxRetries; i++ { 137 | prefix := availablePrefixes[rand.IntN(len(availablePrefixes))] 138 | var newAddress string 139 | if *apply { 140 | newAddress = randV4(prefix).String() 141 | } else { 142 | newAddress = "v.x.y.z" 143 | } 144 | 145 | fmt.Printf("Setting v4 address [%-15s] to [nodeid:%-18s / name:%s]... ", newAddress, device.ID, device.Name) 146 | if !*apply { 147 | fmt.Printf("done.\n") 148 | return nil 149 | } 150 | err := tailscaleClient.SetDeviceIPv4Address(ctx, device.ID, newAddress) 151 | if err != nil && err.Error() == "address already in use (500)" { 152 | fmt.Printf("[%s] - retrying...\n", err) 153 | continue 154 | } else if err != nil { 155 | return err 156 | } else { 157 | fmt.Printf("done.\n") 158 | return nil 159 | } 160 | } 161 | return errors.New(fmt.Sprintf("Unable to set new address after [%v] tries", *maxRetries)) 162 | } 163 | 164 | func calculateAvailablePrefixes(prefixes []netip.Prefix) ([]netip.Prefix, error) { 165 | var b netipx.IPSetBuilder 166 | b.AddPrefix(cgnatPfx) 167 | 168 | for _, p := range prefixes { 169 | b.RemovePrefix(p) 170 | } 171 | 172 | s, err := b.IPSet() 173 | if err != nil { 174 | return nil, err 175 | } 176 | return s.Prefixes(), nil 177 | } 178 | 179 | // credit to https://github.com/maisem 180 | func randV4(maskedPfx netip.Prefix) netip.Addr { 181 | bits := 32 - maskedPfx.Bits() 182 | randBits := rand.Uint32N(1 << uint(bits)) 183 | 184 | ip4 := maskedPfx.Addr().As4() 185 | pn := binary.BigEndian.Uint32(ip4[:]) 186 | binary.BigEndian.PutUint32(ip4[:], randBits|pn) 187 | return netip.AddrFrom4(ip4) 188 | } 189 | --------------------------------------------------------------------------------