")
43 | if templ_7745c5c3_Err != nil {
44 | return templ_7745c5c3_Err
45 | }
46 | templ_7745c5c3_Err = components.Title().Render(ctx, templ_7745c5c3_Buffer)
47 | if templ_7745c5c3_Err != nil {
48 | return templ_7745c5c3_Err
49 | }
50 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
create an exit node in your tailnet in seconds.
")
51 | if templ_7745c5c3_Err != nil {
52 | return templ_7745c5c3_Err
53 | }
54 | templ_7745c5c3_Err = components.Footer().Render(ctx, templ_7745c5c3_Buffer)
55 | if templ_7745c5c3_Err != nil {
56 | return templ_7745c5c3_Err
57 | }
58 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "")
59 | if templ_7745c5c3_Err != nil {
60 | return templ_7745c5c3_Err
61 | }
62 | return nil
63 | })
64 | }
65 |
66 | var _ = templruntime.GeneratedTemplate
67 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/cterence/tailout/cmd"
8 | "github.com/cterence/tailout/tailout"
9 | )
10 |
11 | func main() {
12 | app, err := tailout.New()
13 | if err != nil {
14 | fmt.Fprintf(os.Stderr, "error: %s\n", err)
15 | os.Exit(1)
16 | }
17 |
18 | if err := cmd.New(app).Execute(); err != nil {
19 | os.Exit(1)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/renovate.json5:
--------------------------------------------------------------------------------
1 | {
2 | $schema: "https://docs.renovatebot.com/renovate-schema.json",
3 | automerge: true,
4 | automergeStrategy: "squash",
5 | commitBodyTable: true,
6 | configMigration: true,
7 | extends: [
8 | "config:best-practices",
9 | "docker:enableMajor",
10 | "helpers:pinGitHubActionDigests",
11 | ":gitSignOff",
12 | ":automergeStableNonMajor",
13 | ":automergeDigest",
14 | ":semanticCommitsDisabled",
15 | ":skipStatusChecks",
16 | ],
17 | postUpdateOptions: [
18 | "gomodTidy",
19 | "gomodUpdateImportPaths"
20 | ],
21 | "pre-commit": {
22 | enabled: true
23 | },
24 | }
25 |
--------------------------------------------------------------------------------
/tailout/app.go:
--------------------------------------------------------------------------------
1 | package tailout
2 |
3 | import (
4 | "io"
5 | "os"
6 |
7 | "github.com/cterence/tailout/tailout/config"
8 | )
9 |
10 | type App struct {
11 | Config *config.Config
12 |
13 | Out io.Writer
14 | Err io.Writer
15 | }
16 |
17 | func New() (*App, error) {
18 | c := &config.Config{}
19 | app := &App{
20 | Config: c,
21 | Out: os.Stdout,
22 | Err: os.Stderr,
23 | }
24 | return app, nil
25 | }
26 |
--------------------------------------------------------------------------------
/tailout/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "reflect"
7 | "strings"
8 |
9 | "github.com/spf13/pflag"
10 | "github.com/spf13/viper"
11 | )
12 |
13 | type Config struct {
14 | Tailscale TailscaleConfig `mapstructure:"tailscale"`
15 | UI UIConfig `mapstructure:"ui"`
16 | Region string `mapstructure:"region"`
17 | Create CreateConfig `mapstructure:"create"`
18 | NonInteractive bool `mapstructure:"non_interactive"`
19 | DryRun bool `mapstructure:"dry_run"`
20 | Stop StopConfig `mapstructure:"stop"`
21 | }
22 |
23 | type CreateConfig struct {
24 | Shutdown string `mapstructure:"shutdown"`
25 | Connect bool `mapstructure:"connect"`
26 | }
27 | type TailscaleConfig struct {
28 | BaseURL string `mapstructure:"base_url"`
29 | AuthKey string `mapstructure:"auth_key"`
30 | APIKey string `mapstructure:"api_key"`
31 | Tailnet string `mapstructure:"tailnet"`
32 | }
33 |
34 | type StopConfig struct {
35 | All bool `mapstructure:"all"`
36 | }
37 |
38 | type UIConfig struct {
39 | Port string `mapstructure:"port"`
40 | Address string `mapstructure:"address"`
41 | }
42 |
43 | func (c *Config) Load(flags *pflag.FlagSet, cmdName string) error {
44 | v := viper.New()
45 |
46 | // Tailout looks for configuration files called config.yaml, config.json,
47 | // config.toml, config.hcl, etc.
48 | v.SetConfigName("config")
49 |
50 | // Tailout looks for configuration files in the common configuration
51 | // directories.
52 | v.AddConfigPath("/etc/tailout/")
53 | v.AddConfigPath("$HOME/.tailout/")
54 | v.AddConfigPath(".")
55 |
56 | err := v.ReadInConfig()
57 | if err != nil {
58 | var configFileNotFound viper.ConfigFileNotFoundError
59 | if !errors.As(err, &configFileNotFound) {
60 | return fmt.Errorf("failed to read configuration file: %w", err)
61 | }
62 | }
63 |
64 | // Tailout can be configured with environment variables that start with
65 | // TAILOUT_.
66 | v.SetEnvPrefix("tailout")
67 | v.AutomaticEnv()
68 |
69 | // Options with dashes in flag names have underscores when set inside a
70 | // configuration file or with environment variables.
71 | flags.SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
72 | name = strings.ReplaceAll(name, "-", "_")
73 | return pflag.NormalizedName(name)
74 | })
75 |
76 | // Nested configuration options set with environment variables use an
77 | // underscore as a separator.
78 | v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
79 | bindEnvironmentVariables(v, *c)
80 |
81 | if err := v.BindPFlags(flags); err != nil {
82 | return fmt.Errorf("failed to bind flags: %w", err)
83 | }
84 |
85 | // Bind tailscale and command specific nested flags and remove prefix when binding
86 | // FIXME: This is a workaround for a limitation of Viper, found here:
87 | // https://github.com/spf13/viper/issues/1072
88 | var bindErr error
89 | flags.Visit(func(f *pflag.Flag) {
90 | if bindErr != nil {
91 | return
92 | }
93 | flagName := strings.ReplaceAll(f.Name, "-", "_")
94 | if err := v.BindPFlag(cmdName+"."+f.Name, flags.Lookup(f.Name)); err != nil {
95 | bindErr = fmt.Errorf("failed to bind flag %s: %w", f.Name, err)
96 | return
97 | }
98 | if strings.HasPrefix(flagName, "tailscale_") {
99 | if err := v.BindPFlag("tailscale."+strings.TrimPrefix(flagName, "tailscale_"), flags.Lookup(f.Name)); err != nil {
100 | bindErr = fmt.Errorf("failed to bind tailscale flag %s: %w", f.Name, err)
101 | return
102 | }
103 | }
104 | })
105 | if bindErr != nil {
106 | return bindErr
107 | }
108 |
109 | // Useful for debugging viper fully-merged configuration
110 | // spew.Dump(v.AllSettings())
111 |
112 | if err := v.Unmarshal(c); err != nil {
113 | return fmt.Errorf("failed to unmarshal config: %w", err)
114 | }
115 |
116 | return nil
117 | }
118 |
119 | // bindEnvironmentVariables inspects iface's structure and recursively binds its
120 | // fields to environment variables. This is a workaround to a limitation of
121 | // Viper, found here:
122 | // https://github.com/spf13/viper/issues/188#issuecomment-399884438
123 | func bindEnvironmentVariables(v *viper.Viper, iface interface{}, parts ...string) {
124 | ifv := reflect.ValueOf(iface)
125 | ift := reflect.TypeOf(iface)
126 | for i := range ift.NumField() {
127 | val := ifv.Field(i)
128 | typ := ift.Field(i)
129 | tv, ok := typ.Tag.Lookup("mapstructure")
130 | if !ok {
131 | continue
132 | }
133 | switch val.Kind() {
134 | case reflect.Struct:
135 | bindEnvironmentVariables(v, val.Interface(), append(parts, tv)...)
136 | default:
137 | if err := v.BindEnv(strings.Join(append(parts, tv), ".")); err != nil {
138 | panic(fmt.Sprintf("failed to bind environment variable %s: %v", strings.Join(append(parts, tv), "."), err))
139 | }
140 | }
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/tailout/connect.go:
--------------------------------------------------------------------------------
1 | package tailout
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "net/netip"
8 | "net/url"
9 | "slices"
10 |
11 | "github.com/cterence/tailout/internal"
12 | "github.com/manifoldco/promptui"
13 | "tailscale.com/client/tailscale"
14 | tsapi "tailscale.com/client/tailscale/v2"
15 | "tailscale.com/ipn"
16 | "tailscale.com/tailcfg"
17 | )
18 |
19 | func (app *App) Connect(args []string) error {
20 | var nodeConnect string
21 |
22 | nonInteractive := app.Config.NonInteractive
23 |
24 | baseURL, err := url.Parse(app.Config.Tailscale.BaseURL)
25 | if err != nil {
26 | return fmt.Errorf("failed to parse base URL: %w", err)
27 | }
28 |
29 | apiClient := &tsapi.Client{
30 | APIKey: app.Config.Tailscale.APIKey,
31 | Tailnet: app.Config.Tailscale.Tailnet,
32 | BaseURL: baseURL,
33 | }
34 |
35 | var deviceToConnectTo tsapi.Device
36 |
37 | tailoutDevices, err := internal.GetActiveNodes(apiClient)
38 | if err != nil {
39 | return fmt.Errorf("failed to get active nodes: %w", err)
40 | }
41 |
42 | if len(args) != 0 {
43 | nodeConnect = args[0]
44 | i := slices.IndexFunc(tailoutDevices, func(e tsapi.Device) bool {
45 | return e.Hostname == nodeConnect
46 | })
47 | deviceToConnectTo = tailoutDevices[i]
48 | } else if !nonInteractive {
49 | if len(tailoutDevices) == 0 {
50 | return errors.New("no tailout node found in your tailnet")
51 | }
52 |
53 | // Use promptui to select a node
54 | prompt := promptui.Select{
55 | Label: "Select a node",
56 | Items: tailoutDevices,
57 | Templates: &promptui.SelectTemplates{
58 | Label: "{{ . }}",
59 | Active: "{{ .Hostname | cyan }}",
60 | Inactive: "{{ .Hostname }}",
61 | Selected: "{{ .Hostname | yellow }}",
62 | },
63 | }
64 |
65 | idx, _, err := prompt.Run()
66 | if err != nil {
67 | return fmt.Errorf("failed to select node: %w", err)
68 | }
69 |
70 | deviceToConnectTo = tailoutDevices[idx]
71 | nodeConnect = deviceToConnectTo.ID
72 | } else {
73 | return errors.New("no node name provided")
74 | }
75 |
76 | var localClient tailscale.LocalClient
77 |
78 | prefs := ipn.NewPrefs()
79 |
80 | prefs.ExitNodeID = tailcfg.StableNodeID(nodeConnect)
81 | prefs.ExitNodeIP = netip.MustParseAddr(deviceToConnectTo.Addresses[0])
82 |
83 | _, err = localClient.EditPrefs(context.TODO(), &ipn.MaskedPrefs{
84 | Prefs: *prefs,
85 | ExitNodeIDSet: true,
86 | ExitNodeIPSet: true,
87 | })
88 | if err != nil {
89 | return fmt.Errorf("failed to run tailscale up command: %w", err)
90 | }
91 |
92 | fmt.Println("Connected.")
93 | return nil
94 | }
95 |
--------------------------------------------------------------------------------
/tailout/create.go:
--------------------------------------------------------------------------------
1 | package tailout
2 |
3 | import (
4 | "context"
5 | "encoding/base64"
6 | "errors"
7 | "fmt"
8 | "log"
9 | "net/url"
10 | "sort"
11 | "strconv"
12 | "sync"
13 | "time"
14 |
15 | "github.com/adhocore/chin"
16 | "github.com/aws/aws-sdk-go-v2/aws"
17 | "github.com/aws/aws-sdk-go-v2/config"
18 | "github.com/aws/aws-sdk-go-v2/service/ec2"
19 | "github.com/aws/aws-sdk-go-v2/service/ec2/types"
20 | "github.com/aws/aws-sdk-go-v2/service/sts"
21 | "github.com/cterence/tailout/internal"
22 | tsapi "tailscale.com/client/tailscale/v2"
23 | )
24 |
25 | func (app *App) Create() error {
26 | nonInteractive := app.Config.NonInteractive
27 | region := app.Config.Region
28 | dryRun := app.Config.DryRun
29 | connect := app.Config.Create.Connect
30 | shutdown := app.Config.Create.Shutdown
31 |
32 | if app.Config.Tailscale.AuthKey == "" {
33 | return errors.New("no tailscale auth key found")
34 | }
35 |
36 | // TODO: add option for no shutdown
37 | duration, err := time.ParseDuration(shutdown)
38 | if err != nil {
39 | return fmt.Errorf("failed to parse duration: %w", err)
40 | }
41 |
42 | durationMinutes := int(duration.Minutes())
43 | if durationMinutes < 1 {
44 | return errors.New("duration must be at least 1 minute")
45 | }
46 |
47 | // Create EC2 service client
48 |
49 | if region == "" && !nonInteractive {
50 | region, err = internal.SelectRegion()
51 | if err != nil {
52 | return fmt.Errorf("failed to select region: %w", err)
53 | }
54 | } else if region == "" && nonInteractive {
55 | return errors.New("selected non-interactive mode but no region was explicitly specified")
56 | }
57 |
58 | cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion(region))
59 | if err != nil {
60 | log.Fatalf("unable to load SDK config, %v", err)
61 | }
62 |
63 | ec2Svc := ec2.NewFromConfig(cfg)
64 |
65 | // DescribeImages to get the latest Amazon Linux AMI
66 | amazonLinuxImages, err := ec2Svc.DescribeImages(context.TODO(), &ec2.DescribeImagesInput{
67 | Filters: []types.Filter{
68 | {
69 | Name: aws.String("name"),
70 | Values: []string{"al2023-ami-*"},
71 | },
72 | {
73 | Name: aws.String("state"),
74 | Values: []string{"available"},
75 | },
76 | {
77 | Name: aws.String("is-public"),
78 | Values: []string{"true"},
79 | },
80 | {
81 | Name: aws.String("architecture"),
82 | Values: []string{"x86_64"},
83 | },
84 | },
85 | Owners: []string{"amazon"},
86 | })
87 | if err != nil {
88 | return fmt.Errorf("failed to describe Amazon Linux images: %w", err)
89 | }
90 |
91 | if len(amazonLinuxImages.Images) == 0 {
92 | return errors.New("no Amazon Linux images found")
93 | }
94 |
95 | sort.Slice(amazonLinuxImages.Images, func(i, j int) bool {
96 | return *amazonLinuxImages.Images[i].CreationDate > *amazonLinuxImages.Images[j].CreationDate
97 | })
98 |
99 | // Get the latest Amazon Linux AMI ID
100 | latestAMI := amazonLinuxImages.Images[0]
101 | imageID := *latestAMI.ImageId
102 |
103 | // Define the instance details
104 | // TODO: add option for instance type
105 | instanceType := "t3a.micro"
106 | userDataScript := `#!/bin/bash
107 | # Allow ip forwarding
108 | echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.conf
109 | echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.conf
110 | sudo sysctl -p /etc/sysctl.conf
111 |
112 | TOKEN=$(curl -sSL -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 30")
113 | INSTANCE_ID=$(curl -sSL -H "X-aws-ec2-metadata-token: ${TOKEN}" http://169.254.169.254/latest/meta-data/instance-id)
114 |
115 | curl -fsSL https://tailscale.com/install.sh | sh
116 | sudo tailscale up --auth-key=` + app.Config.Tailscale.AuthKey + ` --hostname=tailout-` + region + `-${INSTANCE_ID} --advertise-exit-node --ssh
117 | sudo echo "sudo shutdown" | at now + ` + strconv.Itoa(durationMinutes) + ` minutes`
118 |
119 | // Encode the string in base64
120 | userDataScriptBase64 := base64.StdEncoding.EncodeToString([]byte(userDataScript))
121 |
122 | // Create instance input parameters
123 | runInput := &ec2.RunInstancesInput{
124 | ImageId: aws.String(imageID),
125 | InstanceType: types.InstanceTypeT3aMicro,
126 | MinCount: aws.Int32(1),
127 | MaxCount: aws.Int32(1),
128 | UserData: aws.String(userDataScriptBase64),
129 | DryRun: aws.Bool(dryRun),
130 | InstanceMarketOptions: &types.InstanceMarketOptionsRequest{
131 | MarketType: types.MarketTypeSpot,
132 | SpotOptions: &types.SpotMarketOptions{
133 | InstanceInterruptionBehavior: types.InstanceInterruptionBehaviorTerminate,
134 | },
135 | },
136 | }
137 |
138 | stsSvc := sts.NewFromConfig(cfg)
139 |
140 | identity, err := stsSvc.GetCallerIdentity(context.TODO(), &sts.GetCallerIdentityInput{})
141 | if err != nil {
142 | return fmt.Errorf("failed to get account ID: %w", err)
143 | }
144 |
145 | fmt.Printf(`Creating tailout node in AWS with the following parameters:
146 | - AWS Account ID: %s
147 | - AMI ID: %s
148 | - Instance Type: %s
149 | - Region: %s
150 | - Auto shutdown after: %s
151 | - Connect after instance up: %v
152 | - Network: default VPC / Subnet / Security group of the region
153 | `, *identity.Account, imageID, instanceType, region, shutdown, connect)
154 |
155 | if !nonInteractive {
156 | result, err := internal.PromptYesNo("Do you want to create this instance?")
157 | if err != nil {
158 | return fmt.Errorf("failed to prompt for confirmation: %w", err)
159 | }
160 |
161 | if !result {
162 | return nil
163 | }
164 | }
165 |
166 | // Run the EC2 instance
167 | runResult, err := ec2Svc.RunInstances(context.TODO(), runInput)
168 | if err != nil {
169 | return fmt.Errorf("failed to create EC2 instance: %w", err)
170 | }
171 |
172 | if len(runResult.Instances) == 0 {
173 | fmt.Println("No instances created.")
174 | return nil
175 | }
176 |
177 | createdInstance := runResult.Instances[0]
178 | fmt.Println("EC2 instance created successfully:", *createdInstance.InstanceId)
179 | nodeName := fmt.Sprintf("tailout-%s-%s", region, *createdInstance.InstanceId)
180 | fmt.Println("Instance will be named", nodeName)
181 | // Create tags for the instance
182 | tags := []types.Tag{
183 | {
184 | Key: aws.String("App"),
185 | Value: aws.String("tailout"),
186 | },
187 | {
188 | Key: aws.String("Name"),
189 | Value: aws.String(nodeName),
190 | },
191 | }
192 |
193 | // Add the tags to the instance
194 | _, err = ec2Svc.CreateTags(context.TODO(), &ec2.CreateTagsInput{
195 | Resources: []string{*createdInstance.InstanceId},
196 | Tags: tags,
197 | })
198 | if err != nil {
199 | return fmt.Errorf("failed to add tags to the instance: %w", err)
200 | }
201 |
202 | // Initialize loading spinner
203 | var wg sync.WaitGroup
204 | var s *chin.Chin
205 |
206 | if !nonInteractive {
207 | s = chin.New().WithWait(&wg)
208 | go s.Start()
209 | }
210 |
211 | fmt.Println("Waiting for instance to be running...")
212 |
213 | // Add a handler for the instance state change event
214 | err = ec2.NewInstanceExistsWaiter(ec2Svc).Wait(context.TODO(), &ec2.DescribeInstancesInput{
215 | InstanceIds: []string{*createdInstance.InstanceId},
216 | }, time.Minute*2)
217 | if err != nil {
218 | return fmt.Errorf("failed to wait for instance to be created: %w", err)
219 | }
220 |
221 | fmt.Println("OK.")
222 | fmt.Println("Waiting for instance to join tailnet...")
223 |
224 | // Call internal.GetNodes periodically and search for the instance
225 | // If the instance is found, print the command to use it as an exit node
226 |
227 | timeout := time.Now().Add(3 * time.Minute)
228 |
229 | baseURL, err := url.Parse(app.Config.Tailscale.BaseURL)
230 | if err != nil {
231 | return fmt.Errorf("failed to parse base URL: %w", err)
232 | }
233 |
234 | client := &tsapi.Client{
235 | APIKey: app.Config.Tailscale.APIKey,
236 | Tailnet: app.Config.Tailscale.Tailnet,
237 | BaseURL: baseURL,
238 | }
239 |
240 | for {
241 | nodes, err := client.Devices().List(context.TODO())
242 | if err != nil {
243 | return fmt.Errorf("failed to get devices: %w", err)
244 | }
245 |
246 | for _, node := range nodes {
247 | if node.Hostname == nodeName {
248 | goto found
249 | }
250 | }
251 |
252 | // Timeouts after 2 minutes
253 | if time.Now().After(timeout) {
254 | return errors.New("timeout waiting for instance to join tailnet")
255 | }
256 |
257 | time.Sleep(2 * time.Second)
258 | }
259 |
260 | found:
261 | // Stop the loading spinner
262 | if !nonInteractive {
263 | s.Stop()
264 | wg.Wait()
265 | }
266 | // Get public IP address of created instance
267 | describeInput := &ec2.DescribeInstancesInput{
268 | InstanceIds: []string{*createdInstance.InstanceId},
269 | }
270 |
271 | describeResult, err := ec2Svc.DescribeInstances(context.TODO(), describeInput)
272 | if err != nil {
273 | return fmt.Errorf("failed to describe EC2 instance: %w", err)
274 | }
275 |
276 | if len(describeResult.Reservations) == 0 {
277 | return errors.New("no reservations found")
278 | }
279 |
280 | reservation := describeResult.Reservations[0]
281 | if len(reservation.Instances) == 0 {
282 | return errors.New("no instances found")
283 | }
284 |
285 | instance := reservation.Instances[0]
286 | if instance.PublicIpAddress == nil {
287 | return errors.New("no public IP address found")
288 | }
289 |
290 | fmt.Printf("Node %s joined tailnet.\n", nodeName)
291 | fmt.Println("Public IP address:", *instance.PublicIpAddress)
292 | fmt.Println("Planned termination time:", time.Now().Add(duration).Format(time.RFC3339))
293 |
294 | if connect {
295 | fmt.Println()
296 | args := []string{nodeName}
297 | err = app.Connect(args)
298 | if err != nil {
299 | return fmt.Errorf("failed to connect to node: %w", err)
300 | }
301 | }
302 | return nil
303 | }
304 |
--------------------------------------------------------------------------------
/tailout/disconnect.go:
--------------------------------------------------------------------------------
1 | package tailout
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "net/netip"
8 |
9 | "tailscale.com/client/tailscale"
10 | "tailscale.com/ipn"
11 | )
12 |
13 | func (app *App) Disconnect() error {
14 | var localClient tailscale.LocalClient
15 | prefs, err := localClient.GetPrefs(context.TODO())
16 | if err != nil {
17 | return fmt.Errorf("failed to get prefs: %w", err)
18 | }
19 |
20 | if prefs.ExitNodeID == "" {
21 | return errors.New("not connected to an exit node")
22 | }
23 |
24 | disconnectPrefs := ipn.NewPrefs()
25 |
26 | disconnectPrefs.ExitNodeID = ""
27 | disconnectPrefs.ExitNodeIP = netip.Addr{}
28 |
29 | _, err = localClient.EditPrefs(context.TODO(), &ipn.MaskedPrefs{
30 | Prefs: *disconnectPrefs,
31 | ExitNodeIDSet: true,
32 | ExitNodeIPSet: true,
33 | })
34 | if err != nil {
35 | return fmt.Errorf("failed to run tailscale up command: %w", err)
36 | }
37 |
38 | fmt.Println("Disconnected.")
39 | return nil
40 | }
41 |
--------------------------------------------------------------------------------
/tailout/init.go:
--------------------------------------------------------------------------------
1 | package tailout
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "net/url"
8 |
9 | "github.com/cterence/tailout/internal"
10 | tsapi "tailscale.com/client/tailscale/v2"
11 | )
12 |
13 | func (app *App) Init() error {
14 | dryRun := app.Config.DryRun
15 | nonInteractive := app.Config.NonInteractive
16 |
17 | baseURL, err := url.Parse(app.Config.Tailscale.BaseURL)
18 | if err != nil {
19 | return fmt.Errorf("failed to parse base URL: %w", err)
20 | }
21 |
22 | apiClient := &tsapi.Client{
23 | APIKey: app.Config.Tailscale.APIKey,
24 | Tailnet: app.Config.Tailscale.Tailnet,
25 | BaseURL: baseURL,
26 | }
27 |
28 | // Get the ACL configuration
29 | acl, err := apiClient.PolicyFile().Get(context.TODO())
30 | if err != nil {
31 | return fmt.Errorf("failed to get acl: %w", err)
32 | }
33 |
34 | allowTailoutSSH := tsapi.ACLSSH{
35 | Action: "check",
36 | Source: []string{"autogroup:member"},
37 | Destination: []string{"tag:tailout"},
38 | Users: []string{"autogroup:nonroot", "root"},
39 | }
40 |
41 | tailoutSSHConfigExists, tailoutTagExists, tailoutAutoApproversExists := false, false, false
42 |
43 | for _, sshConfig := range acl.SSH {
44 | if sshConfig.Action == "check" && sshConfig.Source[0] == "autogroup:member" && sshConfig.Destination[0] == "tag:tailout" && sshConfig.Users[0] == "autogroup:nonroot" && sshConfig.Users[1] == "root" {
45 | tailoutSSHConfigExists = true
46 | }
47 | }
48 |
49 | if acl.TagOwners["tag:tailout"] != nil {
50 | fmt.Println("Tag 'tag:tailout' already exists.")
51 | tailoutTagExists = true
52 | } else {
53 | acl.TagOwners["tag:tailout"] = []string{}
54 | }
55 |
56 | if acl.AutoApprovers == nil {
57 | fmt.Println("Auto approvers configuration does not exist.")
58 | acl.AutoApprovers = &tsapi.ACLAutoApprovers{}
59 | }
60 |
61 | for _, exitNode := range acl.AutoApprovers.ExitNode {
62 | if exitNode == "tag:tailout" {
63 | fmt.Println("Auto approvers for tag:tailout nodes already exists.")
64 | tailoutAutoApproversExists = true
65 | }
66 | }
67 |
68 | if !tailoutAutoApproversExists {
69 | acl.AutoApprovers.ExitNode = append(acl.AutoApprovers.ExitNode, "tag:tailout")
70 | }
71 |
72 | if tailoutSSHConfigExists {
73 | fmt.Println("SSH configuration for tailout already exists.")
74 | } else {
75 | acl.SSH = append(acl.SSH, allowTailoutSSH)
76 | }
77 |
78 | if tailoutTagExists && tailoutAutoApproversExists && tailoutSSHConfigExists && !dryRun {
79 | fmt.Println("Nothing to do.")
80 | return nil
81 | }
82 |
83 | // Validate the updated acl configuration
84 | err = apiClient.PolicyFile().Validate(context.TODO(), *acl)
85 | if err != nil {
86 | return fmt.Errorf("failed to validate acl: %w", err)
87 | }
88 |
89 | // Update the acl configuration
90 | aclJSON, err := json.MarshalIndent(acl, "", " ")
91 | if err != nil {
92 | return fmt.Errorf("failed to marshal acl: %w", err)
93 | }
94 |
95 | // Make a prompt to show the update that will be done
96 | fmt.Printf(`
97 | The following update to the acl will be done:
98 | - Add tag:tailout to tagOwners
99 | - Update auto approvers to allow exit nodes tagged with tag:tailout
100 | - Add a SSH configuration allowing users to SSH into tagged tailout nodes
101 |
102 | Your new acl document will look like this:
103 | %s
104 | `, aclJSON)
105 |
106 | if !dryRun {
107 | if !nonInteractive {
108 | result, err := internal.PromptYesNo("Do you want to continue?")
109 | if err != nil {
110 | return fmt.Errorf("failed to prompt for confirmation: %w", err)
111 | }
112 |
113 | if !result {
114 | fmt.Println("Aborting...")
115 | return nil
116 | }
117 | }
118 |
119 | err = apiClient.PolicyFile().Set(context.TODO(), *acl, "")
120 | if err != nil {
121 | return fmt.Errorf("failed to update acl: %w", err)
122 | }
123 |
124 | fmt.Println("ACL updated.")
125 | } else {
126 | fmt.Println("Dry run, not updating acl.")
127 | }
128 | return nil
129 | }
130 |
--------------------------------------------------------------------------------
/tailout/status.go:
--------------------------------------------------------------------------------
1 | package tailout
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "io"
7 | "net/http"
8 | "net/netip"
9 | "net/url"
10 | "slices"
11 |
12 | "github.com/cterence/tailout/internal"
13 | "tailscale.com/client/tailscale"
14 | tsapi "tailscale.com/client/tailscale/v2"
15 | )
16 |
17 | func (app *App) Status() error {
18 | baseURL, err := url.Parse(app.Config.Tailscale.BaseURL)
19 | if err != nil {
20 | return fmt.Errorf("failed to parse base URL: %w", err)
21 | }
22 |
23 | client := &tsapi.Client{
24 | APIKey: app.Config.Tailscale.APIKey,
25 | Tailnet: app.Config.Tailscale.Tailnet,
26 | BaseURL: baseURL,
27 | }
28 |
29 | nodes, err := internal.GetActiveNodes(client)
30 | if err != nil {
31 | return fmt.Errorf("failed to get active nodes: %w", err)
32 | }
33 |
34 | var localClient tailscale.LocalClient
35 | status, err := localClient.Status(context.TODO())
36 | if err != nil {
37 | return fmt.Errorf("failed to get tailscale preferences: %w", err)
38 | }
39 |
40 | var currentNode tsapi.Device
41 |
42 | if status.ExitNodeStatus != nil {
43 | i := slices.IndexFunc(nodes, func(e tsapi.Device) bool {
44 | return netip.MustParsePrefix(e.Addresses[0]+"/32") == status.ExitNodeStatus.TailscaleIPs[0]
45 | })
46 | currentNode = nodes[i]
47 | }
48 |
49 | if len(nodes) == 0 {
50 | fmt.Println("No active node created by tailout found.")
51 | } else {
52 | fmt.Println("Active nodes created by tailout:")
53 | for _, node := range nodes {
54 | if currentNode.Hostname == node.Hostname {
55 | fmt.Println("-", node.Hostname, "[Connected]")
56 | } else {
57 | fmt.Println("-", node.Hostname)
58 | }
59 | }
60 | }
61 |
62 | // Query for the public IP address of this Node
63 | httpClient := &http.Client{}
64 | req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, "https://ifconfig.me/ip", nil)
65 | if err != nil {
66 | return fmt.Errorf("failed to create request: %w", err)
67 | }
68 | resp, err := httpClient.Do(req)
69 | if err != nil {
70 | return fmt.Errorf("failed to get public IP: %w", err)
71 | }
72 | defer resp.Body.Close()
73 |
74 | ipAddr, err := io.ReadAll(resp.Body)
75 | if err != nil {
76 | return fmt.Errorf("failed to get public IP: %w", err)
77 | }
78 |
79 | fmt.Println("Public IP: " + string(ipAddr))
80 | return nil
81 | }
82 |
--------------------------------------------------------------------------------
/tailout/stop.go:
--------------------------------------------------------------------------------
1 | package tailout
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "log"
8 | "net/url"
9 | "regexp"
10 | "strings"
11 |
12 | "github.com/aws/aws-sdk-go-v2/aws"
13 | "github.com/aws/aws-sdk-go-v2/config"
14 | "github.com/aws/aws-sdk-go-v2/service/ec2"
15 | "github.com/cterence/tailout/internal"
16 | "github.com/ktr0731/go-fuzzyfinder"
17 | tsapi "tailscale.com/client/tailscale/v2"
18 | )
19 |
20 | func (app *App) Stop(args []string) error {
21 | nonInteractive := app.Config.NonInteractive
22 | dryRun := app.Config.DryRun
23 | stopAll := app.Config.Stop.All
24 |
25 | nodesToStop := []tsapi.Device{}
26 |
27 | baseURL, err := url.Parse(app.Config.Tailscale.BaseURL)
28 | if err != nil {
29 | return fmt.Errorf("failed to parse base URL: %w", err)
30 | }
31 |
32 | client := &tsapi.Client{
33 | APIKey: app.Config.Tailscale.APIKey,
34 | Tailnet: app.Config.Tailscale.Tailnet,
35 | BaseURL: baseURL,
36 | }
37 |
38 | tailoutNodes, err := internal.GetActiveNodes(client)
39 | if err != nil {
40 | return fmt.Errorf("failed to get active nodes: %w", err)
41 | }
42 |
43 | if len(tailoutNodes) == 0 {
44 | fmt.Println("No tailout node found in your tailnet")
45 | return nil
46 | }
47 |
48 | if len(args) == 0 && !nonInteractive && !stopAll {
49 | // Create a fuzzy finder selector with the tailout nodes
50 | idx, err := fuzzyfinder.FindMulti(tailoutNodes, func(i int) string {
51 | return tailoutNodes[i].Hostname
52 | })
53 | if err != nil {
54 | return fmt.Errorf("failed to find node: %w", err)
55 | }
56 |
57 | nodesToStop = []tsapi.Device{}
58 | for _, i := range idx {
59 | nodesToStop = append(nodesToStop, tailoutNodes[i])
60 | }
61 | } else {
62 | if !stopAll {
63 | for _, node := range tailoutNodes {
64 | for _, arg := range args {
65 | if node.Hostname == arg {
66 | nodesToStop = append(nodesToStop, node)
67 | }
68 | }
69 | }
70 | } else {
71 | nodesToStop = tailoutNodes
72 | }
73 | }
74 |
75 | if !nonInteractive {
76 | fmt.Println("The following nodes will be stopped:")
77 | for _, node := range nodesToStop {
78 | fmt.Println("-", node.Hostname)
79 | }
80 |
81 | result, err := internal.PromptYesNo("Are you sure you want to stop these Nodes?")
82 | if err != nil {
83 | return fmt.Errorf("failed to prompt for confirmation: %w", err)
84 | }
85 |
86 | if !result {
87 | fmt.Println("Aborting...")
88 | return nil
89 | }
90 | }
91 |
92 | // TODO: cleanup tailout instances that were not last seen recently
93 | // TODO: warning when stopping a device to which you are connected, propose to disconnect before
94 | for _, node := range nodesToStop {
95 | fmt.Println("Stopping", node.Hostname)
96 |
97 | regionNames, err := internal.GetRegions()
98 | if err != nil {
99 | return fmt.Errorf("failed to retrieve regions: %w", err)
100 | }
101 | var region string
102 | for _, regionName := range regionNames {
103 | if strings.Contains(node.Hostname, regionName) {
104 | region = regionName
105 | }
106 | }
107 |
108 | if region == "" {
109 | return errors.New("failed to extract region from node name")
110 | }
111 |
112 | // Create a session to share configuration, and load external configuration.
113 | cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion(region))
114 | if err != nil {
115 | log.Fatalf("unable to load SDK config, %v", err)
116 | }
117 |
118 | ec2Svc := ec2.NewFromConfig(cfg)
119 |
120 | // Extract the instance ID from the Node name with a regex
121 |
122 | instanceID := regexp.MustCompile(`i\-[a-z0-9]{17}$`).FindString(node.Hostname)
123 |
124 | _, err = ec2Svc.TerminateInstances(context.TODO(), &ec2.TerminateInstancesInput{
125 | DryRun: aws.Bool(dryRun),
126 | InstanceIds: []string{instanceID},
127 | })
128 | if err != nil {
129 | return fmt.Errorf("failed to terminate instance: %w", err)
130 | }
131 |
132 | fmt.Println("Successfully terminated instance", node.Hostname)
133 |
134 | err = client.Devices().Delete(context.TODO(), node.ID)
135 | if err != nil {
136 | return fmt.Errorf("failed to delete node from tailnet: %w", err)
137 | }
138 |
139 | fmt.Println("Successfully deleted node", node.Hostname)
140 | }
141 | return nil
142 | }
143 |
--------------------------------------------------------------------------------
/tailout/ui.go:
--------------------------------------------------------------------------------
1 | package tailout
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 | "net/http"
7 | "net/url"
8 | "time"
9 |
10 | "github.com/cterence/tailout/internal"
11 | "github.com/cterence/tailout/internal/views"
12 | tsapi "tailscale.com/client/tailscale/v2"
13 |
14 | "github.com/a-h/templ"
15 | )
16 |
17 | func (app *App) UI(args []string) error {
18 | indexComponent := views.Index()
19 | app.Config.NonInteractive = true
20 |
21 | baseURL, err := url.Parse(app.Config.Tailscale.BaseURL)
22 | if err != nil {
23 | return fmt.Errorf("failed to parse base URL: %w", err)
24 | }
25 |
26 | client := &tsapi.Client{
27 | APIKey: app.Config.Tailscale.APIKey,
28 | Tailnet: app.Config.Tailscale.Tailnet,
29 | BaseURL: baseURL,
30 | }
31 |
32 | http.Handle("/", templ.Handler(indexComponent))
33 |
34 | http.HandleFunc("/create", func(w http.ResponseWriter, r *http.Request) {
35 | slog.Info("Creating tailout node")
36 | go func() {
37 | err := app.Create()
38 | if err != nil {
39 | slog.Error("failed to create node", "error", err)
40 | }
41 | }()
42 | w.WriteHeader(http.StatusCreated)
43 | })
44 |
45 | http.HandleFunc("/stop", func(w http.ResponseWriter, r *http.Request) {
46 | slog.Info("Stopping tailout nodes")
47 | app.Config.Stop.All = true
48 | go func() {
49 | err := app.Stop(nil)
50 | if err != nil {
51 | slog.Error("failed to stop nodes", "error", err)
52 | }
53 | }()
54 | w.WriteHeader(http.StatusNoContent)
55 | })
56 |
57 | http.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
58 | nodes, err := internal.GetActiveNodes(client)
59 | if err != nil {
60 | slog.Error("failed to get active nodes", "error", err)
61 | w.WriteHeader(http.StatusInternalServerError)
62 | return
63 | }
64 | table := ""
65 | for _, node := range nodes {
66 | table += fmt.Sprintf("