feat(clientproxy): automatically close boundary connect when proxy is no longer in use (#6232)

Co-authored-by: Andrew Gaffney <andrew@gaffney.cc>
This commit is contained in:
daniandAndrew Gaffney authored and GitHub committed 2025-11-14 09:37:30 -05:00
1 parent d0050ad881
commit 08015e9d98
7 files changed
+289 -48

No files matched your search

+59 -11
View File
@@ -69,6 +69,7 @@ type Command struct {
flagUsername string
flagDbname string
flagMongoDbAuthenticationDatabase string
flagInactiveTimeout time.Duration
// HTTP
httpFlags
@@ -220,6 +221,13 @@ func (c *Command) Flags() *base.FlagSets {
Usage: "Target scope name, if authorizing the session via scope parameters and target name. Mutually exclusive with -scope-id.",
})
f.DurationVar(&base.DurationVar{
Name: "inactive-timeout",
Target: &c.flagInactiveTimeout,
Completion: complete.PredictAnything,
Usage: "How long to wait between connections before closing the session. Increase this value if the proxy closes during long-running processes, or use -1 to disable the timeout.",
})
switch c.Func {
case "connect":
f.StringVar(&base.StringVar{
@@ -508,11 +516,32 @@ func (c *Command) Run(args []string) (retCode int) {
clientProxyCloseCh := make(chan struct{})
connCountCloseCh := make(chan struct{})
if c.flagInactiveTimeout == 0 {
// no timeout was specified by the user, so use our defaults based on subcommand
switch c.Func {
case "connect":
// connect is when there is no subcommand specified, this case should
// have the most generous timeout
apiProxyOpts = append(apiProxyOpts, apiproxy.WithInactivityTimeout(30*time.Second))
case "rdp":
// rdp has a gui, so give the user a chance to click "reconnect"
apiProxyOpts = append(apiProxyOpts, apiproxy.WithInactivityTimeout(5*time.Second))
case "ssh":
// one second is probably enough for ssh
apiProxyOpts = append(apiProxyOpts, apiproxy.WithInactivityTimeout(time.Second))
default:
// for other protocols, give some extra leeway just in case
apiProxyOpts = append(apiProxyOpts, apiproxy.WithInactivityTimeout(3*time.Second))
}
} else {
apiProxyOpts = append(apiProxyOpts, apiproxy.WithInactivityTimeout(c.flagInactiveTimeout))
}
proxyError := new(atomic.Error)
go func() {
defer close(clientProxyCloseCh)
if err = clientProxy.Start(); err != nil {
c.proxyCancel()
defer c.proxyCancel()
if err = clientProxy.Start(apiProxyOpts...); err != nil {
proxyError.Store(err)
}
}()
@@ -595,10 +624,8 @@ func (c *Command) Run(args []string) (retCode int) {
if c.execCmdReturnValue != nil {
// Don't print out in this case, so ensure we clear it
termInfo.Reason = ""
} else if time.Now().After(clientProxy.SessionExpiration()) {
termInfo.Reason = "Session has expired"
} else if clientProxy.ConnectionsLeft() == 0 {
termInfo.Reason = "No connections left in session"
} else if r := clientProxy.CloseReason(); r != "" {
termInfo.Reason = r
} else if err := proxyError.Load(); err != nil {
termInfo.Reason = "Error from proxy client: " + err.Error()
}
@@ -825,10 +852,9 @@ func (c *Command) handleExec(clientProxy *apiproxy.ClientProxy, passthroughArgs
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmdExit := make(chan struct{})
if err := cmd.Run(); err != nil {
exitCode := 2
cmdError := func(err error) {
if exitError, ok := err.(*exec.ExitError); ok {
if exitError.Success() {
c.execCmdReturnValue.Store(0)
@@ -841,8 +867,30 @@ func (c *Command) handleExec(clientProxy *apiproxy.ClientProxy, passthroughArgs
}
c.PrintCliError(fmt.Errorf("Failed to run command: %w", err))
c.execCmdReturnValue.Store(int32(exitCode))
c.execCmdReturnValue.Store(2)
return
}
c.execCmdReturnValue.Store(0)
go func() {
defer close(cmdExit)
if err := cmd.Start(); err != nil {
cmdError(err)
return
}
if err := cmd.Wait(); err != nil {
cmdError(err)
return
}
c.execCmdReturnValue.Store(0)
}()
for {
select {
case <-c.proxyCtx.Done():
// the proxy exited for some reason, end the cmd since connections are no longer possible
_ = endProcess(cmd.Process)
case <-cmdExit:
return
}
}
}
@@ -0,0 +1,19 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build !windows
package connect
import (
"os"
"syscall"
)
// endProcess gracefully ends the provided os process
func endProcess(p *os.Process) error {
if p == nil {
return nil
}
return p.Signal(syscall.SIGTERM)
}
@@ -0,0 +1,18 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build windows
package connect
import (
"os"
)
// endProcess kills the provided os process
func endProcess(p *os.Process) error {
if p == nil {
return nil
}
return p.Kill()
}
+1 -1
View File
@@ -59,7 +59,7 @@ func (r *rdpFlags) buildArgs(c *Command, port, ip, addr string) []string {
case "mstsc.exe":
args = append(args, "/v", addr)
case "open":
args = append(args, "-n", "-W", fmt.Sprintf("rdp://full%saddress=s%s%s", "%20", "%3A", url.QueryEscape(addr)))
args = append(args, "-W", fmt.Sprintf("rdp://full%saddress=s%s%s", "%20", "%3A", url.QueryEscape(addr)))
}
return args
}
+97
View File
@@ -19,6 +19,7 @@ import (
"github.com/hashicorp/boundary/internal/tests/helper"
"github.com/hashicorp/go-hclog"
"github.com/stretchr/testify/require"
"go.uber.org/atomic"
_ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/targets/tcp"
)
@@ -140,3 +141,99 @@ func TestConnectionsLeft(t *testing.T) {
// Wait to ensure cleanup and that the second-start logic works
wg.Wait()
}
func TestConnectionTimeout(t *testing.T) {
require := require.New(t)
logger := hclog.New(&hclog.LoggerOptions{
Name: t.Name(),
Level: hclog.Trace,
})
// Create controller and worker
conf, err := config.DevController()
require.NoError(err)
c1 := controller.NewTestController(t, &controller.TestControllerOpts{
Config: conf,
InitialResourcesSuffix: "1234567890",
Logger: logger.Named("c1"),
WorkerRPCGracePeriod: helper.DefaultControllerRPCGracePeriod,
})
helper.ExpectWorkers(t, c1)
w1 := worker.NewTestWorker(t, &worker.TestWorkerOpts{
WorkerAuthKms: c1.Config().WorkerAuthKms,
InitialUpstreams: c1.ClusterAddrs(),
Logger: logger.Named("w1"),
SuccessfulControllerRPCGracePeriodDuration: helper.DefaultControllerRPCGracePeriod,
Name: "w1",
})
helper.ExpectWorkers(t, c1, w1)
// Connect target
client := c1.Client()
client.SetToken(c1.Token().Token)
tcl := targets.NewClient(client)
tgt, err := tcl.Read(c1.Context(), "ttcp_1234567890")
require.NoError(err)
require.NotNil(tgt)
// Create test server, update default port on target
ts := helper.NewTestTcpServer(t)
require.NotNil(t, ts)
defer ts.Close()
var sessionConnsLimit int32 = 2
tgt = updateTargetForProxy(t, c1.Context(), tcl, tgt, ts.Port(), sessionConnsLimit, w1.Name())
// Authorize session to get authorization data
sess, err := tcl.AuthorizeSession(c1.Context(), tgt.Item.Id)
require.NoError(err)
sessAuthz, err := sess.GetSessionAuthorization()
require.NoError(err)
// Create a context we can cancel to stop the proxy, a channel for conns
// left, and a waitgroup to ensure cleanup
pxyCtx, pxyCancel := context.WithCancel(c1.Context())
defer pxyCancel()
wg := new(sync.WaitGroup)
pxy, err := proxy.New(pxyCtx, sessAuthz.AuthorizationToken)
require.NoError(err)
wg.Add(1)
done := atomic.NewBool(false)
go func() {
defer wg.Done()
require.NoError(pxy.Start(proxy.WithInactivityTimeout(time.Second)))
done.Store(true)
}()
addr := pxy.ListenerAddress(context.Background())
require.NotEmpty(addr)
addrPort, err := netip.ParseAddrPort(addr)
require.NoError(err)
echo := []byte("echo")
readBuf := make([]byte, len(echo))
conn, err := net.DialTCP("tcp", nil, net.TCPAddrFromAddrPort(addrPort))
require.NoError(err)
written, err := conn.Write(echo)
require.NoError(err)
require.Equal(written, len(echo))
read, err := conn.Read(readBuf)
require.NoError(err)
require.Equal(read, len(echo))
require.NoError(conn.Close())
start := time.Now()
for {
if done.Load() || time.Since(start) > time.Second*2 {
require.True(done.Load(), "proxy did not close itself within the expected time frame (2 seconds)")
break
}
time.Sleep(10 * time.Millisecond)
}
require.Equal("Inactivity timeout reached", pxy.CloseReason())
pxyCancel()
wg.Wait()
}