Skip to content

Commit ddbfbec

Browse files
yroblataskbotCopilot
authored
feat: add functionality for a proxy tunnel command (#1315)
Co-authored-by: taskbot <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent e8af727 commit ddbfbec

File tree

9 files changed

+537
-0
lines changed

9 files changed

+537
-0
lines changed

cmd/thv/app/proxy.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ func init() {
171171
if err := proxyCmd.MarkFlagRequired("target-uri"); err != nil {
172172
logger.Warnf("Warning: Failed to mark flag as required: %v", err)
173173
}
174+
175+
// Attach the subcommand to the main proxy command
176+
proxyCmd.AddCommand(proxyTunnelCmd)
174177
}
175178

176179
func proxyCmdFunc(cmd *cobra.Command, args []string) error {

cmd/thv/app/proxy_tunnel.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package app
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/url"
8+
"os/signal"
9+
"strings"
10+
"syscall"
11+
12+
"github.com/spf13/cobra"
13+
14+
"github.com/stacklok/toolhive/pkg/logger"
15+
"github.com/stacklok/toolhive/pkg/transport/types"
16+
"github.com/stacklok/toolhive/pkg/workloads"
17+
)
18+
19+
var (
20+
tunnelProvider string
21+
providerArgsJSON string
22+
)
23+
24+
var proxyTunnelCmd = &cobra.Command{
25+
Use: "tunnel [flags] TARGET SERVER_NAME",
26+
Short: "Create a tunnel proxy for exposing internal endpoints",
27+
Long: `Create a tunnel proxy for exposing internal endpoints.
28+
29+
TARGET may be either:
30+
• a URL (http://..., https://...) -> used directly as the target URI
31+
• a workload name -> resolved to its URL
32+
33+
Examples:
34+
thv proxy tunnel http://localhost:8080 my-server --tunnel-provider ngrok
35+
thv proxy tunnel my-workload my-server --tunnel-provider ngrok
36+
37+
Flags:
38+
--tunnel-provider string The provider to use for the tunnel (e.g., "ngrok") - mandatory
39+
--provider-args string JSON object with provider-specific arguments (default "{}")
40+
`,
41+
Args: cobra.ExactArgs(2),
42+
RunE: proxyTunnelCmdFunc,
43+
}
44+
45+
func init() {
46+
proxyTunnelCmd.Flags().StringVar(&tunnelProvider, "tunnel-provider", "",
47+
"The provider to use for the tunnel (e.g., 'ngrok') - mandatory")
48+
proxyTunnelCmd.Flags().StringVar(&providerArgsJSON, "provider-args", "{}", "JSON object with provider-specific arguments")
49+
50+
// Mark tunnel-provider as required
51+
if err := proxyTunnelCmd.MarkFlagRequired("tunnel-provider"); err != nil {
52+
logger.Warnf("Warning: Failed to mark flag as required: %v", err)
53+
}
54+
}
55+
56+
func proxyTunnelCmdFunc(cmd *cobra.Command, args []string) error {
57+
ctx, cancel := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
58+
defer cancel()
59+
60+
targetArg := args[0] // URL or workload name
61+
serverName := args[1]
62+
63+
// Validate provider
64+
provider, ok := types.SupportedTunnelProviders[tunnelProvider]
65+
if !ok {
66+
return fmt.Errorf("invalid tunnel provider %q, supported providers: %v", tunnelProvider, types.GetSupportedProviderNames())
67+
}
68+
69+
var rawArgs map[string]any
70+
if err := json.Unmarshal([]byte(providerArgsJSON), &rawArgs); err != nil {
71+
return fmt.Errorf("invalid --provider-args: %w", err)
72+
}
73+
74+
// validate target uri
75+
finalTargetURI, err := resolveTarget(ctx, targetArg)
76+
if err != nil {
77+
return err
78+
}
79+
80+
// parse provider-specific configuration
81+
if err := provider.ParseConfig(rawArgs); err != nil {
82+
return fmt.Errorf("invalid provider config: %w", err)
83+
}
84+
85+
// Start the tunnel using the selected provider
86+
if err := provider.StartTunnel(ctx, serverName, finalTargetURI); err != nil {
87+
return fmt.Errorf("failed to start tunnel: %w", err)
88+
}
89+
90+
// Consume until interrupt
91+
<-ctx.Done()
92+
logger.Info("Shutting down tunnel")
93+
return nil
94+
}
95+
96+
func resolveTarget(ctx context.Context, target string) (string, error) {
97+
// If it's a URL, validate and return it
98+
if looksLikeURL(target) {
99+
if err := validateProxyTargetURI(target); err != nil {
100+
return "", fmt.Errorf("invalid target URI: %w", err)
101+
}
102+
return target, nil
103+
}
104+
105+
// Otherwise treat as workload name
106+
workloadManager, err := workloads.NewManager(ctx)
107+
if err != nil {
108+
return "", fmt.Errorf("failed to create workload manager: %w", err)
109+
}
110+
tunnelWorkload, err := workloadManager.GetWorkload(ctx, target)
111+
if err != nil {
112+
return "", fmt.Errorf("failed to get workload %q: %w", target, err)
113+
}
114+
if tunnelWorkload.URL == "" {
115+
return "", fmt.Errorf("workload %q has empty URL", target)
116+
}
117+
return tunnelWorkload.URL, nil
118+
}
119+
120+
func looksLikeURL(s string) bool {
121+
// Fast-path for common schemes
122+
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
123+
return true
124+
}
125+
// Fallback parse check
126+
u, err := url.Parse(s)
127+
return err == nil && u.Scheme != "" && u.Host != ""
128+
}

docs/cli/thv_proxy.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/cli/thv_proxy_tunnel.md

Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

go.mod

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ require (
118118
github.com/go-openapi/strfmt v0.23.0 // indirect
119119
github.com/go-openapi/swag v0.23.1 // indirect
120120
github.com/go-openapi/validate v0.24.0 // indirect
121+
github.com/go-stack/stack v1.8.1 // indirect
121122
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
122123
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
123124
github.com/gobuffalo/pop/v6 v6.1.1 // indirect
@@ -142,9 +143,12 @@ require (
142143
github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b // indirect
143144
github.com/in-toto/attestation v1.1.2 // indirect
144145
github.com/in-toto/in-toto-golang v0.9.0 // indirect
146+
github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible // indirect
147+
github.com/inconshreveable/log15/v3 v3.0.0-testing.5 // indirect
145148
github.com/invopop/jsonschema v0.13.0 // indirect
146149
github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b // indirect
147150
github.com/josharian/intern v1.0.0 // indirect
151+
github.com/jpillora/backoff v1.0.0 // indirect
148152
github.com/json-iterator/go v1.1.12 // indirect
149153
github.com/klauspost/compress v1.18.0 // indirect
150154
github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect
@@ -233,6 +237,8 @@ require (
233237
go.uber.org/multierr v1.11.0 // indirect
234238
go.uber.org/zap v1.27.0 // indirect
235239
go.yaml.in/yaml/v2 v2.4.2 // indirect
240+
golang.ngrok.com/muxado/v2 v2.0.1 // indirect
241+
golang.ngrok.com/ngrok/v2 v2.0.0 // indirect
236242
golang.org/x/exp/event v0.0.0-20250620022241-b7579e27df2b // indirect
237243
golang.org/x/net v0.42.0 // indirect
238244
golang.org/x/text v0.27.0 // indirect

go.sum

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,8 @@ github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9
935935
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
936936
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
937937
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
938+
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
939+
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
938940
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
939941
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
940942
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
@@ -1155,6 +1157,10 @@ github.com/in-toto/attestation v1.1.2 h1:MBFn6lsMq6dptQZJBhalXTcWMb/aJy3V+GX3VYj
11551157
github.com/in-toto/attestation v1.1.2/go.mod h1:gYFddHMZj3DiQ0b62ltNi1Vj5rC879bTmBbrv9CRHpM=
11561158
github.com/in-toto/in-toto-golang v0.9.0 h1:tHny7ac4KgtsfrG6ybU8gVOZux2H8jN05AXJ9EBM1XU=
11571159
github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3lUTQd+eF9HdeMo=
1160+
github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible h1:VryeOTiaZfAzwx8xBcID1KlJCeoWSIpsNbSk+/D2LNk=
1161+
github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o=
1162+
github.com/inconshreveable/log15/v3 v3.0.0-testing.5 h1:h4e0f3kjgg+RJBlKOabrohjHe47D3bbAB9BgMrc3DYA=
1163+
github.com/inconshreveable/log15/v3 v3.0.0-testing.5/go.mod h1:3GQg1SVrLoWGfRv/kAZMsdyU5cp8eFc1P3cw+Wwku94=
11581164
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
11591165
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
11601166
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
@@ -1220,6 +1226,8 @@ github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Cc
12201226
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
12211227
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
12221228
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
1229+
github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
1230+
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
12231231
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
12241232
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
12251233
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
@@ -1687,6 +1695,10 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
16871695
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
16881696
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
16891697
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
1698+
golang.ngrok.com/muxado/v2 v2.0.1 h1:jM9i6Pom6GGmnPrHKNR6OJRrUoHFkSZlJ3/S0zqdVpY=
1699+
golang.ngrok.com/muxado/v2 v2.0.1/go.mod h1:wzxJYX4xiAtmwumzL+QsukVwFRXmPNv86vB8RPpOxyM=
1700+
golang.ngrok.com/ngrok/v2 v2.0.0 h1:eUEF7ULph6hUdOVR9r7oue2UhT2vvDoLAo0q//N6vJo=
1701+
golang.ngrok.com/ngrok/v2 v2.0.0/go.mod h1:nppMCtZ44/KeGrDHOV0c4bRyMGdHCEBo2Rvjdv/1Uio=
16901702
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
16911703
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
16921704
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Package ngrok provides an implementation of the TunnelProvider interface using ngrok.
2+
package ngrok
3+
4+
import (
5+
"context"
6+
"fmt"
7+
8+
"golang.ngrok.com/ngrok/v2"
9+
10+
"github.com/stacklok/toolhive/pkg/logger"
11+
)
12+
13+
// TunnelProvider implements the TunnelProvider interface for ngrok.
14+
type TunnelProvider struct {
15+
config TunnelConfig
16+
}
17+
18+
// TunnelConfig holds configuration options for the ngrok tunnel provider.
19+
type TunnelConfig struct {
20+
AuthToken string
21+
Domain string // Optional: specify custom domain
22+
DryRun bool
23+
}
24+
25+
// ParseConfig parses the configuration for the ngrok tunnel provider from a map.
26+
func (p *TunnelProvider) ParseConfig(raw map[string]any) error {
27+
token, ok := raw["ngrok-auth-token"].(string)
28+
if !ok || token == "" {
29+
return fmt.Errorf("ngrok-auth-token is required")
30+
}
31+
32+
cfg := TunnelConfig{
33+
AuthToken: token,
34+
}
35+
36+
if domain, ok := raw["ngrok-domain"].(string); ok {
37+
cfg.Domain = domain
38+
}
39+
40+
p.config = cfg
41+
42+
if dr, ok := raw["dry-run"].(bool); ok {
43+
p.config.DryRun = dr
44+
}
45+
return nil
46+
}
47+
48+
// StartTunnel starts a tunnel using ngrok to the specified target URI.
49+
func (p *TunnelProvider) StartTunnel(ctx context.Context, name, targetURI string) error {
50+
if p.config.DryRun {
51+
// behave like an active tunnel that exits on ctx cancel
52+
<-ctx.Done()
53+
return nil
54+
}
55+
logger.Infof("[ngrok] Starting tunnel %q → %s", name, targetURI)
56+
57+
agent, err := ngrok.NewAgent(
58+
ngrok.WithAuthtoken(p.config.AuthToken),
59+
ngrok.WithEventHandler(func(e ngrok.Event) {
60+
logger.Infof("ngrok event: %s at %s", e.EventType(), e.Timestamp())
61+
}),
62+
)
63+
64+
if err != nil {
65+
return fmt.Errorf("failed to create ngrok agent: %w", err)
66+
}
67+
68+
// Set up only the necessary endpoint options
69+
endpointOpts := []ngrok.EndpointOption{
70+
ngrok.WithDescription("tunnel proxy for " + name),
71+
}
72+
if p.config.Domain != "" {
73+
endpointOpts = append(endpointOpts, ngrok.WithURL(p.config.Domain))
74+
}
75+
76+
forwarder, err := agent.Forward(ctx,
77+
ngrok.WithUpstream(targetURI),
78+
endpointOpts...,
79+
)
80+
if err != nil {
81+
return fmt.Errorf("ngrok.Forward error: %w", err)
82+
}
83+
84+
logger.Infof("ngrok forwarding live at %s", forwarder.URL())
85+
86+
// Run in background, non-blocking on `.Done()`
87+
go func() {
88+
<-forwarder.Done()
89+
logger.Infof("ngrok forwarding stopped: %s", forwarder.URL())
90+
}()
91+
92+
// Return immediately
93+
return nil
94+
}

pkg/transport/types/tunnel.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package types
2+
3+
import (
4+
"context"
5+
6+
"github.com/stacklok/toolhive/pkg/transport/tunnel/ngrok"
7+
)
8+
9+
//go:generate mockgen -destination=mocks/mock_tunnel_provider.go -package=mocks -source=tunnel.go
10+
11+
// SupportedTunnelProviders maps provider names to their implementations.
12+
var SupportedTunnelProviders = map[string]TunnelProvider{
13+
"ngrok": &ngrok.TunnelProvider{},
14+
}
15+
16+
// TunnelProvider defines the interface for tunnel providers.
17+
type TunnelProvider interface {
18+
ParseConfig(config map[string]any) error
19+
StartTunnel(ctx context.Context, name string, targetURI string) error
20+
}
21+
22+
// GetSupportedProviderNames returns a list of supported tunnel provider names.
23+
func GetSupportedProviderNames() []string {
24+
names := make([]string, 0, len(SupportedTunnelProviders))
25+
for name := range SupportedTunnelProviders {
26+
names = append(names, name)
27+
}
28+
return names
29+
}

0 commit comments

Comments
 (0)