├── .gitignore ├── Dockerfile ├── README.md ├── go.mod ├── go.sum ├── main.go └── start.sh /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19-alpine as builder 2 | WORKDIR /app 3 | COPY . ./ 4 | 5 | RUN go mod download 6 | RUN go build 7 | 8 | FROM alpine:latest 9 | RUN apk update && apk add ca-certificates iptables ip6tables bash bind-tools jq && rm -rf /var/cache/apk/* 10 | 11 | WORKDIR /app 12 | COPY . ./ 13 | ENV TSFILE=tailscale_1.30.2_amd64.tgz 14 | ENV DNSPROXYFILE=dnsproxy-linux-amd64-v0.45.2.tar.gz 15 | ENV DNSPROXYVERSION=v0.45.2 16 | RUN wget https://pkgs.tailscale.com/stable/${TSFILE} && tar xzf ${TSFILE} --strip-components=1 17 | RUN wget https://github.com/AdguardTeam/dnsproxy/releases/download/${DNSPROXYVERSION}/${DNSPROXYFILE} && tar xzf ${DNSPROXYFILE} --strip-components=1 18 | COPY --from=builder /app/tailscale-router /app/tailscale-router 19 | COPY . ./ 20 | 21 | RUN mkdir -p /var/run/tailscale /var/cache/tailscale /var/lib/tailscale 22 | 23 | CMD ["/app/start.sh"] 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tailscale-router 2 | 3 | ## How to use 4 | 1. Clone this app locally 5 | 2. Create an app `flyctl apps create my-unique-tailscale-router-app-name` 6 | 3. Get a secret from the tailscale admin console: tailscale admin console > settings > keys > `generate auth key` _(you probably want to choose the reusable and ephemeral options)_ 7 | 4. Set the token you get as a secret `flyctl secrets set TAILSCALE_AUTHKEY=thekeyyougot -a my-unique-tailscale-router-app-name` 8 | 5. Build this repo `docker build -t registry.fly.io/my-unique-tailscale-router-app-name:latest .` 9 | 6. Push the image `docker push registry.fly.io/my-unique-tailscale-router-app-name:latest` 10 | 7. Deploy a machine `flyctl m run registry.fly.io/my-unique-tailscale-router-app-name:latest -a my-unique-tailscale-router-app-name --cpus 1 --memory 256` 11 | 8. Follow steps `3` and `5` of https://tailscale.com/kb/1019/subnets/ to enable subnets for the machine that got automatically configured 12 | 9. Enjoy 13 | 14 | ## Test it Out 15 | 16 | You can test if it's working by finding the IP address of your new Fly.io app and using `dig`: 17 | 18 | ```bash 19 | # Get the IP address of your app: 20 | flyctl m list -a my-unique-tailscale-router-app-name 21 | 22 | # Use dig to test DNS queries the DNS proxy setup in this repository 23 | dig @ aaaa my-unique-tailscale-router-app-name.internal 24 | ``` 25 | 26 | ## DNS Setup 27 | 28 | You can enable split DNS in your Tailscale settings to automatically resolve `*.internal` addresses through the DNS proxy setup in your new Fly.io app. 29 | 30 | Tailscale documentation for that is [found here](https://tailscale.com/kb/1054/dns/). 31 | 32 | 1. Add a nameserver 33 | 2. Use the IP address of your new Fly.io app 34 | 3. Restrict to search domains, and use search domain `internal` 35 | 36 | Then addresses should resolve! Maybe use `curl` to make an HTTP request to one of your apps. Be sure to use the `internal_port` of your application: 37 | 38 | ```bash 39 | curl http://some-fly-app.internal:8080 40 | ``` 41 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fly-apps/tailscale-router 2 | 3 | go 1.19 4 | 5 | require github.com/rainu/go-command-chain v0.1.0 // indirect 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 3 | github.com/rainu/go-command-chain v0.1.0 h1:RVbL53pX6LTZdYyjJ1jZORYwFH+0nVYiX+y/wTQTO5o= 4 | github.com/rainu/go-command-chain v0.1.0/go.mod h1:RvLsDKnTGD9XoUY7nmBz73ayffI0bFCDH/EVJPRgfks= 5 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 6 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 8 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 9 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "os/exec" 10 | "runtime" 11 | "strings" 12 | ) 13 | 14 | type keyResp struct { 15 | Key string `json:"key"` 16 | } 17 | 18 | type devicesResp struct { 19 | Devices []struct { 20 | NodeKey string `json:"nodeKey"` 21 | ID string `json:"id"` 22 | } `json:"devices"` 23 | } 24 | 25 | func main() { 26 | fmt.Println("tailscale-router: setting up") 27 | tailnet := "-" 28 | api_key := os.Getenv("TAILSCALE_API_TOKEN") 29 | 30 | fmt.Println("tailscale-router: tailnet name", tailnet) 31 | fmt.Println("tailscale-router: api key", api_key) 32 | 33 | var jsonData = []byte(`{ 34 | "capabilities": { 35 | "devices": { 36 | "create": { 37 | "reusable": true, 38 | "ephemeral": true 39 | } 40 | } 41 | } 42 | }`) 43 | 44 | fmt.Println("tailscale-router: creating auth key") 45 | request, err := http.NewRequest("POST", fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/keys", tailnet), bytes.NewBuffer(jsonData)) 46 | if err != nil { 47 | panic(err) 48 | } 49 | request.Header.Set("Content-Type", "application/json; charset=UTF-8") 50 | request.SetBasicAuth(api_key, "") 51 | 52 | client := &http.Client{} 53 | response, error := client.Do(request) 54 | if error != nil { 55 | fmt.Println("ERROR: Create key") 56 | panic(error) 57 | } 58 | defer response.Body.Close() 59 | 60 | var out keyResp 61 | err = json.NewDecoder(response.Body).Decode(&out) 62 | if err != nil { 63 | fmt.Println("ERROR: Decode key resp") 64 | panic(error) 65 | } 66 | key := out.Key 67 | fmt.Println("tailscale-router: key is", key) 68 | 69 | fmt.Println("tailscale-router: grepping /etc/hosts to get fly-local-6pn") 70 | output, err := exec.Command("grep", "fly-local-6pn", "/etc/hosts").Output() 71 | if err != nil { 72 | fmt.Println("ERROR: get subnet") 73 | panic(err) 74 | } 75 | 76 | fmt.Println("tailscale-router: calculating subnet") 77 | subnet := strings.Join(strings.Split(strings.TrimSuffix(string(output), "\n"), ":")[0:3], ":") + "::/48" 78 | 79 | tailscale_binary_path := "/app/tailscale" 80 | 81 | if runtime.GOOS == "darwin" { 82 | output, err := exec.Command("bash", "-c", "ps -xo comm | grep MacOS/Tailscale").Output() 83 | if err != nil { 84 | panic(err) 85 | } 86 | tailscale_binary_path = strings.TrimSuffix(string(output), "\n") 87 | } 88 | 89 | fmt.Println("tailscale-router: running tailscale up") 90 | upcmd := exec.Command("bash", "-c", fmt.Sprintf("%s up --authkey=%s --advertise-routes=%s", tailscale_binary_path, key, subnet)) 91 | err = upcmd.Run() 92 | if err != nil { 93 | panic(err) 94 | } 95 | 96 | fmt.Println("tailscale-router: getting PublicKey from tailscale status") 97 | output, err = exec.Command("bash", "-c", fmt.Sprintf("%s status --json | jq -r .Self.PublicKey", tailscale_binary_path)).Output() 98 | if err != nil { 99 | panic(err) 100 | } 101 | 102 | nodeKey := strings.TrimSuffix(string(output), "\n") 103 | 104 | fmt.Println("tailscale-router: getting all devices") 105 | request, err = http.NewRequest("GET", fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/devices", tailnet), nil) 106 | if err != nil { 107 | panic(err) 108 | } 109 | request.Header.Set("Content-Type", "application/json; charset=UTF-8") 110 | request.SetBasicAuth(api_key, "") 111 | 112 | client = &http.Client{} 113 | response, error = client.Do(request) 114 | if error != nil { 115 | fmt.Println("ERROR: read devices") 116 | panic(error) 117 | } 118 | defer response.Body.Close() 119 | 120 | var devicesOut devicesResp 121 | err = json.NewDecoder(response.Body).Decode(&devicesOut) 122 | if err != nil { 123 | fmt.Println("ERROR: Decode key resp") 124 | panic(error) 125 | } 126 | 127 | selfID := "" 128 | 129 | fmt.Println("tailscale-router: finding our ID") 130 | for _, v := range devicesOut.Devices { 131 | if v.NodeKey == nodeKey { 132 | selfID = v.ID 133 | break 134 | } 135 | } 136 | 137 | jsonData = []byte(fmt.Sprintf(`{ 138 | "routes": ["%s"] 139 | }`, subnet)) 140 | 141 | fmt.Println("tailscale-router: configuring routes") 142 | request, err = http.NewRequest("POST", fmt.Sprintf("https://api.tailscale.com/api/v2/device/%s/routes", selfID), bytes.NewBuffer(jsonData)) 143 | if err != nil { 144 | panic(err) 145 | } 146 | request.Header.Set("Content-Type", "application/json; charset=UTF-8") 147 | request.SetBasicAuth(api_key, "") 148 | 149 | client = &http.Client{} 150 | response, error = client.Do(request) 151 | if error != nil { 152 | panic(error) 153 | } 154 | defer response.Body.Close() 155 | 156 | fmt.Println("tailscale-router: fully configured") 157 | os.Exit(0) 158 | } 159 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo 'net.ipv4.ip_forward = 1' | tee -a /etc/sysctl.conf 3 | echo 'net.ipv6.conf.all.forwarding = 1' | tee -a /etc/sysctl.conf 4 | sysctl -p /etc/sysctl.conf 5 | 6 | /app/tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/var/run/tailscale/tailscaled.sock & 7 | /app/tailscale-router 8 | 9 | if [ $? -eq 0 ] 10 | then 11 | /app/linux-amd64/dnsproxy -u fdaa::3 12 | else 13 | exit 1 14 | fi 15 | 16 | 17 | tail -f /dev/null 18 | --------------------------------------------------------------------------------