diff --git a/CHANGELOG.md b/CHANGELOG.md index e56dd827..0fc960c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,8 @@ upstream is changed. - **IMPORTANT: Backup your SQLite database before upgrading** - Introduces safer table renaming migration strategy - Addresses longstanding database integrity issues +- Add flag to directly manipulate the policy in the database + [#2765](https://github.com/juanfont/headscale/pull/2765) - DERPmap update frequency default changed from 24h to 3h [#2741](https://github.com/juanfont/headscale/pull/2741) - DERPmap update mechanism has been improved with retry, diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go index 6d6476fb..e1b8e7b3 100644 --- a/cmd/headscale/cli/nodes.go +++ b/cmd/headscale/cli/nodes.go @@ -9,7 +9,6 @@ import ( "strings" "time" - survey "github.com/AlecAivazis/survey/v2" v1 "github.com/juanfont/headscale/gen/go/headscale/v1" "github.com/juanfont/headscale/hscontrol/util" "github.com/pterm/pterm" @@ -222,8 +221,6 @@ var listNodeRoutesCmd = &cobra.Command{ fmt.Sprintf("Error converting ID to integer: %s", err), output, ) - - return } ctx, client, conn, cancel := newHeadscaleCLIWithConfig() @@ -290,8 +287,6 @@ var expireNodeCmd = &cobra.Command{ fmt.Sprintf("Error converting ID to integer: %s", err), output, ) - - return } ctx, client, conn, cancel := newHeadscaleCLIWithConfig() @@ -312,8 +307,6 @@ var expireNodeCmd = &cobra.Command{ ), output, ) - - return } SuccessOutput(response.GetNode(), "Node expired", output) @@ -333,8 +326,6 @@ var renameNodeCmd = &cobra.Command{ fmt.Sprintf("Error converting ID to integer: %s", err), output, ) - - return } ctx, client, conn, cancel := newHeadscaleCLIWithConfig() @@ -360,8 +351,6 @@ var renameNodeCmd = &cobra.Command{ ), output, ) - - return } SuccessOutput(response.GetNode(), "Node renamed", output) @@ -382,8 +371,6 @@ var deleteNodeCmd = &cobra.Command{ fmt.Sprintf("Error converting ID to integer: %s", err), output, ) - - return } ctx, client, conn, cancel := newHeadscaleCLIWithConfig() @@ -401,8 +388,6 @@ var deleteNodeCmd = &cobra.Command{ "Error getting node node: "+status.Convert(err).Message(), output, ) - - return } deleteRequest := &v1.DeleteNodeRequest{ @@ -412,16 +397,10 @@ var deleteNodeCmd = &cobra.Command{ confirm := false force, _ := cmd.Flags().GetBool("force") if !force { - prompt := &survey.Confirm{ - Message: fmt.Sprintf( - "Do you want to remove the node %s?", - getResponse.GetNode().GetName(), - ), - } - err = survey.AskOne(prompt, &confirm) - if err != nil { - return - } + confirm = util.YesNo(fmt.Sprintf( + "Do you want to remove the node %s?", + getResponse.GetNode().GetName(), + )) } if confirm || force { @@ -437,8 +416,6 @@ var deleteNodeCmd = &cobra.Command{ "Error deleting node: "+status.Convert(err).Message(), output, ) - - return } SuccessOutput( map[string]string{"Result": "Node deleted"}, @@ -465,8 +442,6 @@ var moveNodeCmd = &cobra.Command{ fmt.Sprintf("Error converting ID to integer: %s", err), output, ) - - return } user, err := cmd.Flags().GetUint64("user") @@ -476,8 +451,6 @@ var moveNodeCmd = &cobra.Command{ fmt.Sprintf("Error getting user: %s", err), output, ) - - return } ctx, client, conn, cancel := newHeadscaleCLIWithConfig() @@ -495,8 +468,6 @@ var moveNodeCmd = &cobra.Command{ "Error getting node: "+status.Convert(err).Message(), output, ) - - return } moveRequest := &v1.MoveNodeRequest{ @@ -511,8 +482,6 @@ var moveNodeCmd = &cobra.Command{ "Error moving node: "+status.Convert(err).Message(), output, ) - - return } SuccessOutput(moveResponse.GetNode(), "Node moved to another user", output) @@ -535,20 +504,13 @@ If you remove IPv4 or IPv6 prefixes from the config, it can be run to remove the IPs that should no longer be assigned to nodes.`, Run: func(cmd *cobra.Command, args []string) { - var err error output, _ := cmd.Flags().GetString("output") confirm := false force, _ := cmd.Flags().GetBool("force") if !force { - prompt := &survey.Confirm{ - Message: "Are you sure that you want to assign/remove IPs to/from nodes?", - } - err = survey.AskOne(prompt, &confirm) - if err != nil { - return - } + confirm = util.YesNo("Are you sure that you want to assign/remove IPs to/from nodes?") } if confirm || force { @@ -563,8 +525,6 @@ be assigned to nodes.`, "Error backfilling IPs: "+status.Convert(err).Message(), output, ) - - return } SuccessOutput(changes, "Node IPs backfilled successfully", output) @@ -763,8 +723,6 @@ var tagCmd = &cobra.Command{ fmt.Sprintf("Error converting ID to integer: %s", err), output, ) - - return } tagsToSet, err := cmd.Flags().GetStringSlice("tags") if err != nil { @@ -773,8 +731,6 @@ var tagCmd = &cobra.Command{ fmt.Sprintf("Error retrieving list of tags to add to node, %v", err), output, ) - - return } // Sending tags to node @@ -789,8 +745,6 @@ var tagCmd = &cobra.Command{ fmt.Sprintf("Error while sending tags to headscale: %s", err), output, ) - - return } if resp != nil { @@ -820,8 +774,6 @@ var approveRoutesCmd = &cobra.Command{ fmt.Sprintf("Error converting ID to integer: %s", err), output, ) - - return } routes, err := cmd.Flags().GetStringSlice("routes") if err != nil { @@ -830,8 +782,6 @@ var approveRoutesCmd = &cobra.Command{ fmt.Sprintf("Error retrieving list of routes to add to node, %v", err), output, ) - - return } // Sending routes to node @@ -846,8 +796,6 @@ var approveRoutesCmd = &cobra.Command{ fmt.Sprintf("Error while sending routes to headscale: %s", err), output, ) - - return } if resp != nil { diff --git a/cmd/headscale/cli/policy.go b/cmd/headscale/cli/policy.go index caf9d436..b8a9a2ad 100644 --- a/cmd/headscale/cli/policy.go +++ b/cmd/headscale/cli/policy.go @@ -6,21 +6,30 @@ import ( "os" v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + "github.com/juanfont/headscale/hscontrol/db" "github.com/juanfont/headscale/hscontrol/policy" "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" "github.com/rs/zerolog/log" "github.com/spf13/cobra" "tailscale.com/types/views" ) +const ( + bypassFlag = "bypass-grpc-and-access-database-directly" +) + func init() { rootCmd.AddCommand(policyCmd) + + getPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing gRPC and does not require the server to be running") policyCmd.AddCommand(getPolicy) setPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format") if err := setPolicy.MarkFlagRequired("file"); err != nil { log.Fatal().Err(err).Msg("") } + setPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing gRPC and does not require the server to be running") policyCmd.AddCommand(setPolicy) checkPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format") @@ -41,21 +50,58 @@ var getPolicy = &cobra.Command{ Aliases: []string{"show", "view", "fetch"}, Run: func(cmd *cobra.Command, args []string) { output, _ := cmd.Flags().GetString("output") - ctx, client, conn, cancel := newHeadscaleCLIWithConfig() - defer cancel() - defer conn.Close() + var policy string + if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass { + confirm := false + force, _ := cmd.Flags().GetBool("force") + if !force { + confirm = util.YesNo("DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") + } - request := &v1.GetPolicyRequest{} + if !confirm && !force { + ErrorOutput(nil, "Aborting command", output) + return + } - response, err := client.GetPolicy(ctx, request) - if err != nil { - ErrorOutput(err, fmt.Sprintf("Failed loading ACL Policy: %s", err), output) + cfg, err := types.LoadServerConfig() + if err != nil { + ErrorOutput(err, fmt.Sprintf("Failed loading config: %s", err), output) + } + + d, err := db.NewHeadscaleDatabase( + cfg.Database, + cfg.BaseDomain, + nil, + ) + if err != nil { + ErrorOutput(err, fmt.Sprintf("Failed to open database: %s", err), output) + } + + pol, err := d.GetPolicy() + if err != nil { + ErrorOutput(err, fmt.Sprintf("Failed loading Policy from database: %s", err), output) + } + + policy = pol.Data + } else { + ctx, client, conn, cancel := newHeadscaleCLIWithConfig() + defer cancel() + defer conn.Close() + + request := &v1.GetPolicyRequest{} + + response, err := client.GetPolicy(ctx, request) + if err != nil { + ErrorOutput(err, fmt.Sprintf("Failed loading ACL Policy: %s", err), output) + } + + policy = response.GetPolicy() } // TODO(pallabpain): Maybe print this better? // This does not pass output as we dont support yaml, json or json-line // output for this command. It is HuJSON already. - SuccessOutput("", response.GetPolicy(), "") + SuccessOutput("", policy, "") }, } @@ -81,14 +127,52 @@ var setPolicy = &cobra.Command{ ErrorOutput(err, fmt.Sprintf("Error reading the policy file: %s", err), output) } - request := &v1.SetPolicyRequest{Policy: string(policyBytes)} + _, err = policy.NewPolicyManager(policyBytes, nil, views.Slice[types.NodeView]{}) + if err != nil { + ErrorOutput(err, fmt.Sprintf("Error parsing the policy file: %s", err), output) + return + } - ctx, client, conn, cancel := newHeadscaleCLIWithConfig() - defer cancel() - defer conn.Close() + if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass { + confirm := false + force, _ := cmd.Flags().GetBool("force") + if !force { + confirm = util.YesNo("DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") + } - if _, err := client.SetPolicy(ctx, request); err != nil { - ErrorOutput(err, fmt.Sprintf("Failed to set ACL Policy: %s", err), output) + if !confirm && !force { + ErrorOutput(nil, "Aborting command", output) + return + } + + cfg, err := types.LoadServerConfig() + if err != nil { + ErrorOutput(err, fmt.Sprintf("Failed loading config: %s", err), output) + } + + d, err := db.NewHeadscaleDatabase( + cfg.Database, + cfg.BaseDomain, + nil, + ) + if err != nil { + ErrorOutput(err, fmt.Sprintf("Failed to open database: %s", err), output) + } + + _, err = d.SetPolicy(string(policyBytes)) + if err != nil { + ErrorOutput(err, fmt.Sprintf("Failed to set ACL Policy: %s", err), output) + } + } else { + request := &v1.SetPolicyRequest{Policy: string(policyBytes)} + + ctx, client, conn, cancel := newHeadscaleCLIWithConfig() + defer cancel() + defer conn.Close() + + if _, err := client.SetPolicy(ctx, request); err != nil { + ErrorOutput(err, fmt.Sprintf("Failed to set ACL Policy: %s", err), output) + } } SuccessOutput(nil, "Policy updated.", "") diff --git a/cmd/headscale/cli/users.go b/cmd/headscale/cli/users.go index 8b32d935..9a816c78 100644 --- a/cmd/headscale/cli/users.go +++ b/cmd/headscale/cli/users.go @@ -6,8 +6,8 @@ import ( "net/url" "strconv" - survey "github.com/AlecAivazis/survey/v2" v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + "github.com/juanfont/headscale/hscontrol/util" "github.com/pterm/pterm" "github.com/rs/zerolog/log" "github.com/spf13/cobra" @@ -161,16 +161,10 @@ var destroyUserCmd = &cobra.Command{ confirm := false force, _ := cmd.Flags().GetBool("force") if !force { - prompt := &survey.Confirm{ - Message: fmt.Sprintf( - "Do you want to remove the user %q (%d) and any associated preauthkeys?", - user.GetName(), user.GetId(), - ), - } - err := survey.AskOne(prompt, &confirm) - if err != nil { - return - } + confirm = util.YesNo(fmt.Sprintf( + "Do you want to remove the user %q (%d) and any associated preauthkeys?", + user.GetName(), user.GetId(), + )) } if confirm || force { diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index 0347c0a9..f6b5f71a 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -169,7 +169,14 @@ func ErrorOutput(errResult error, override string, outputFormat string) { Error string `json:"error"` } - fmt.Fprintf(os.Stderr, "%s\n", output(errOutput{errResult.Error()}, override, outputFormat)) + var errorMessage string + if errResult != nil { + errorMessage = errResult.Error() + } else { + errorMessage = override + } + + fmt.Fprintf(os.Stderr, "%s\n", output(errOutput{errorMessage}, override, outputFormat)) os.Exit(1) } diff --git a/flake.nix b/flake.nix index 70b51c7b..60dcd088 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,7 @@ overlay = _: prev: let pkgs = nixpkgs.legacyPackages.${prev.system}; buildGo = pkgs.buildGo124Module; - vendorHash = "sha256-83L2NMyOwKCHWqcowStJ7Ze/U9CJYhzleDRLrJNhX2g="; + vendorHash = "sha256-hIY6asY3rOIqf/5P6lFmnNCDWcqNPJaj+tqJuOvGJlo="; in { headscale = buildGo { pname = "headscale"; diff --git a/go.mod b/go.mod index 3af028b9..c8e22857 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,10 @@ module github.com/juanfont/headscale -go 1.24.0 +go 1.24.4 -toolchain go1.24.2 +toolchain go1.24.6 require ( - github.com/AlecAivazis/survey/v2 v2.3.7 github.com/arl/statsviz v0.6.0 github.com/cenkalti/backoff/v5 v5.0.2 github.com/chasefleming/elem-go v0.30.0 @@ -55,7 +54,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.30.0 - tailscale.com v1.84.3 + tailscale.com v1.86.5 zgo.at/zcache/v2 v2.2.0 zombiezen.com/go/postgrestest v1.0.1 ) @@ -149,8 +148,6 @@ require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gookit/color v1.5.4 // indirect - github.com/gorilla/csrf v1.7.3 // indirect - github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect @@ -164,7 +161,6 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jsimonetti/rtnetlink v1.4.1 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect @@ -177,7 +173,6 @@ require ( github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/sdnotify v1.0.0 // indirect github.com/mdlayher/socket v0.5.0 // indirect - github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/miekg/dns v1.1.58 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -215,7 +210,7 @@ require ( github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect github.com/tailscale/setec v0.0.0-20250305161714-445cadbbca3d // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect - github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 // indirect + github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect github.com/vishvananda/netns v0.0.4 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect @@ -235,7 +230,7 @@ require ( golang.org/x/sys v0.34.0 // indirect golang.org/x/term v0.33.0 // indirect golang.org/x/text v0.27.0 // indirect - golang.org/x/time v0.10.0 // indirect + golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.35.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect diff --git a/go.sum b/go.sum index f7774361..25ffe5d8 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= -github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= -github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= @@ -31,8 +29,6 @@ github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= @@ -131,7 +127,6 @@ github.com/creachadair/mds v0.24.3/go.mod h1:0oeHt9QWu8VfnmskOL4zi2CumjEvB29Scmt github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -154,8 +149,6 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI= -github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= @@ -226,8 +219,6 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= @@ -242,12 +233,8 @@ github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQ github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= -github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= -github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= -github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.0 h1:+epNPbD5EqgpEMm5wrl4Hqts3jZt8+kYaqUisuuIGTk= @@ -256,8 +243,6 @@ github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKe github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= -github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= -github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= @@ -289,8 +274,6 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNaPO3hE= github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -321,11 +304,9 @@ github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -341,9 +322,6 @@ github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= -github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= -github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= @@ -492,8 +470,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:U github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= -github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 h1:h/41LFTrwMxB9Xvvug0kRdQCU5TlV1+pAMQw0ZtDE3U= -github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= +github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw= +github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= @@ -561,8 +539,8 @@ golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5Z golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= -golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -590,7 +568,6 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -629,14 +606,13 @@ golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= -golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -712,8 +688,8 @@ modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.84.3 h1:Ur9LMedSgicwbqpy5xn7t49G8490/s6rqAJOk5Q5AYE= -tailscale.com v1.84.3/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo= +tailscale.com v1.86.5 h1:yBtWFjuLYDmxVnfnvPbZNZcKADCYgNfMd0rUAOA9XCs= +tailscale.com v1.86.5/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M= zgo.at/zcache/v2 v2.2.0 h1:K29/IPjMniZfveYE+IRXfrl11tMzHkIPuyGrfVZ2fGo= zgo.at/zcache/v2 v2.2.0/go.mod h1:gyCeoLVo01QjDZynjime8xUGHHMbsLiPyUTBpDGd4Gk= zombiezen.com/go/postgrestest v1.0.1 h1:aXoADQAJmZDU3+xilYVut0pHhgc0sF8ZspPW9gFNwP4= diff --git a/hscontrol/util/prompt.go b/hscontrol/util/prompt.go new file mode 100644 index 00000000..098f1979 --- /dev/null +++ b/hscontrol/util/prompt.go @@ -0,0 +1,24 @@ +package util + +import ( + "fmt" + "os" + "strings" +) + +// YesNo takes a question and prompts the user to answer the +// question with a yes or no. It appends a [y/n] to the message. +// The question is written to stderr so that content can be redirected +// without interfering with the prompt. +func YesNo(msg string) bool { + fmt.Fprint(os.Stderr, msg+" [y/n] ") + + var resp string + fmt.Scanln(&resp) + resp = strings.ToLower(resp) + switch resp { + case "y", "yes", "sure": + return true + } + return false +} diff --git a/hscontrol/util/prompt_test.go b/hscontrol/util/prompt_test.go new file mode 100644 index 00000000..d726ec60 --- /dev/null +++ b/hscontrol/util/prompt_test.go @@ -0,0 +1,209 @@ +package util + +import ( + "bytes" + "io" + "os" + "strings" + "testing" +) + +func TestYesNo(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "y answer", + input: "y\n", + expected: true, + }, + { + name: "Y answer", + input: "Y\n", + expected: true, + }, + { + name: "yes answer", + input: "yes\n", + expected: true, + }, + { + name: "YES answer", + input: "YES\n", + expected: true, + }, + { + name: "sure answer", + input: "sure\n", + expected: true, + }, + { + name: "SURE answer", + input: "SURE\n", + expected: true, + }, + { + name: "n answer", + input: "n\n", + expected: false, + }, + { + name: "no answer", + input: "no\n", + expected: false, + }, + { + name: "empty answer", + input: "\n", + expected: false, + }, + { + name: "invalid answer", + input: "maybe\n", + expected: false, + }, + { + name: "random text", + input: "foobar\n", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Capture stdin + oldStdin := os.Stdin + r, w, _ := os.Pipe() + os.Stdin = r + + // Capture stderr + oldStderr := os.Stderr + stderrR, stderrW, _ := os.Pipe() + os.Stderr = stderrW + + // Write test input + go func() { + defer w.Close() + w.WriteString(tt.input) + }() + + // Call the function + result := YesNo("Test question") + + // Restore stdin and stderr + os.Stdin = oldStdin + os.Stderr = oldStderr + stderrW.Close() + + // Check the result + if result != tt.expected { + t.Errorf("YesNo() = %v, want %v", result, tt.expected) + } + + // Check that the prompt was written to stderr + var stderrBuf bytes.Buffer + io.Copy(&stderrBuf, stderrR) + stderrR.Close() + + expectedPrompt := "Test question [y/n] " + actualPrompt := stderrBuf.String() + if actualPrompt != expectedPrompt { + t.Errorf("Expected prompt %q, got %q", expectedPrompt, actualPrompt) + } + }) + } +} + +func TestYesNoPromptMessage(t *testing.T) { + // Capture stdin + oldStdin := os.Stdin + r, w, _ := os.Pipe() + os.Stdin = r + + // Capture stderr + oldStderr := os.Stderr + stderrR, stderrW, _ := os.Pipe() + os.Stderr = stderrW + + // Write test input + go func() { + defer w.Close() + w.WriteString("n\n") + }() + + // Call the function with a custom message + customMessage := "Do you want to continue with this dangerous operation?" + YesNo(customMessage) + + // Restore stdin and stderr + os.Stdin = oldStdin + os.Stderr = oldStderr + stderrW.Close() + + // Check that the custom message was included in the prompt + var stderrBuf bytes.Buffer + io.Copy(&stderrBuf, stderrR) + stderrR.Close() + + expectedPrompt := customMessage + " [y/n] " + actualPrompt := stderrBuf.String() + if actualPrompt != expectedPrompt { + t.Errorf("Expected prompt %q, got %q", expectedPrompt, actualPrompt) + } +} + +func TestYesNoCaseInsensitive(t *testing.T) { + testCases := []struct { + input string + expected bool + }{ + {"y\n", true}, + {"Y\n", true}, + {"yes\n", true}, + {"Yes\n", true}, + {"YES\n", true}, + {"yEs\n", true}, + {"sure\n", true}, + {"Sure\n", true}, + {"SURE\n", true}, + {"SuRe\n", true}, + } + + for _, tc := range testCases { + t.Run("input_"+strings.TrimSpace(tc.input), func(t *testing.T) { + // Capture stdin + oldStdin := os.Stdin + r, w, _ := os.Pipe() + os.Stdin = r + + // Capture stderr to avoid output during tests + oldStderr := os.Stderr + stderrR, stderrW, _ := os.Pipe() + os.Stderr = stderrW + + // Write test input + go func() { + defer w.Close() + w.WriteString(tc.input) + }() + + // Call the function + result := YesNo("Test") + + // Restore stdin and stderr + os.Stdin = oldStdin + os.Stderr = oldStderr + stderrW.Close() + + // Drain stderr + io.Copy(io.Discard, stderrR) + stderrR.Close() + + if result != tc.expected { + t.Errorf("Input %q: expected %v, got %v", strings.TrimSpace(tc.input), tc.expected, result) + } + }) + } +}