From 3586a329875bc59d90beb998dbaf8ceef50e95e4 Mon Sep 17 00:00:00 2001 From: Miek Gieben Date: Tue, 2 Jun 2026 10:56:16 +0200 Subject: [PATCH] feat: add 'uc proxy' command to proxy a service port to a local port (#384) Signed-off-by: Miek Gieben Co-authored-by: Pasha Sviderski --- cmd/uncloud/main.go | 1 + cmd/uncloud/proxy.go | 176 +++++++++++++++++++++++ pkg/client/client.go | 5 + pkg/client/image.go | 2 +- website/docs/9-cli-reference/uc.md | 1 + website/docs/9-cli-reference/uc_proxy.md | 36 +++++ 6 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 cmd/uncloud/proxy.go create mode 100644 website/docs/9-cli-reference/uc_proxy.md diff --git a/cmd/uncloud/main.go b/cmd/uncloud/main.go index be8d93d6..c712c6b1 100644 --- a/cmd/uncloud/main.go +++ b/cmd/uncloud/main.go @@ -131,6 +131,7 @@ func main() { NewDocsCommand(), NewImagesCommand(), NewPsCommand(), + NewProxyCommand(), caddy.NewRootCommand(), cmdcontext.NewRootCommand(), dns.NewRootCommand(), diff --git a/cmd/uncloud/proxy.go b/cmd/uncloud/proxy.go new file mode 100644 index 00000000..969dc7a7 --- /dev/null +++ b/cmd/uncloud/proxy.go @@ -0,0 +1,176 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + "strings" + + "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/internal/cli/completion" + "github.com/psviderski/uncloud/internal/cli/tui" + "github.com/psviderski/uncloud/internal/proxy" + "github.com/psviderski/uncloud/pkg/api" + "github.com/spf13/cobra" +) + +type proxyOptions struct { + localPort int + remotePort int + service string +} + +// NewProxyCommand creates a new command to proxy a local port to a service's port in the cluster. +func NewProxyCommand() *cobra.Command { + opts := proxyOptions{} + cmd := &cobra.Command{ + Use: "proxy SERVICE [LOCAL_PORT:]REMOTE_PORT", + Args: cobra.ExactArgs(2), + Short: "Proxy a service port to a local port.", + Long: `Proxy a service port in the cluster to a local port on this machine. + +If the service runs multiple containers, the command connects to the first running and healthy one. +If you don't provide a local port, the command picks a random one. + +The connection stays open for as long as the command runs.`, + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + + opts.service = args[0] + + parts := strings.Split(args[1], ":") + switch len(parts) { + case 1: + remoteport, err := strconv.Atoi(parts[0]) + if err != nil { + return fmt.Errorf("invalid remote port: '%s': %w", parts[0], err) + } + opts.remotePort = remoteport + case 2: + localport, err := strconv.Atoi(parts[0]) + if err != nil { + return fmt.Errorf("invalid local port: '%s': %w", parts[0], err) + } + remoteport, err := strconv.Atoi(parts[1]) + if err != nil { + return fmt.Errorf("invalid remote port: '%s': %w", parts[1], err) + } + opts.localPort = localport + opts.remotePort = remoteport + default: + return fmt.Errorf("invalid port") + } + + return runProxy(cmd.Context(), uncli, opts) + }, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + uncli := cmd.Context().Value("cli").(*cli.CLI) + return completion.Services(cmd.Context(), uncli, args, toComplete) + }, + } + + return cmd +} + +func runProxy(ctx context.Context, uncli *cli.CLI, opts proxyOptions) error { + if opts.localPort < 0 || opts.localPort > 65535 { + return fmt.Errorf("invalid local port %d: must be between 0 and 65535", opts.localPort) + } + if opts.remotePort < 1 || opts.remotePort > 65535 { + return fmt.Errorf("invalid remote port %d: must be between 1 and 65535", opts.remotePort) + } + + clusterClient, err := uncli.ConnectCluster(ctx) + if err != nil { + return fmt.Errorf("connect to cluster: %w", err) + } + defer clusterClient.Close() + + svc, err := clusterClient.InspectService(ctx, opts.service) + if err != nil { + if errors.Is(err, api.ErrNotFound) { + return fmt.Errorf("service '%s' not found in the cluster", opts.service) + } + return fmt.Errorf("inspect service '%s': %w", opts.service, err) + } + + // Pick the first running and healthy container to proxy to. + var ctr *api.MachineServiceContainer + for i := range svc.Containers { + if svc.Containers[i].Container.Healthy() { + ctr = &svc.Containers[i] + break + } + } + if ctr == nil { + return fmt.Errorf("no running healthy container found for service '%s'", opts.service) + } + + containerID := ctr.Container.ShortID() + ip := ctr.Container.UncloudNetworkIP() + if !ip.IsValid() { + return fmt.Errorf("container '%s' is not connected to the uncloud Docker network (could be host network)", + containerID) + } + + dialer, err := clusterClient.Dialer() + if err != nil { + return fmt.Errorf("get proxy dialer: %w", err) + } + + listener, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(opts.localPort))) + if err != nil { + return fmt.Errorf("listen on 127.0.0.1:%d: %w", opts.localPort, err) + } + + // There is no precheck if we can connect, as this always succeeds, only the proxy connects with the + // endpoint and shuffles the data, *it* will actually experience errors. + remoteAddr := net.JoinHostPort(ip.String(), strconv.Itoa(opts.remotePort)) + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + p := &proxy.Proxy{ + Listener: listener, + RemoteAddr: remoteAddr, + DialContext: dialer.DialContext, + OnError: func(err error) { + fmt.Printf("Failed to proxy to '%s': %v\n", remoteAddr, err) + cancel() + }, + } + + // Run the proxy in the background and signal when it has fully shut down. + done := make(chan struct{}) + go func() { + p.Run(ctx) + close(done) + }() + + // Prefix the local address with the scheme for common HTTP ports so it becomes control-clickable in most + // terminals. We assume plain HTTP since TLS is typically terminated by Caddy in front of the service. + fmt.Printf("%s%s → %s (%s%s%s)\n", schemeForPort(opts.remotePort), p.Listener.Addr().String(), + remoteAddr, opts.service, tui.Faint.Render("/"), containerID) + + <-ctx.Done() + // Wait for the proxy to drain in-flight connections and shut down gracefully. + <-done + + return nil +} + +// schemeForPort returns the "http://" URL scheme prefix for the ports most likely to serve plain HTTP, +// or an empty string otherwise. +func schemeForPort(port int) string { + switch port { + case 80, 3000, 8000, 8080, 8081, 8888, 9090: + return "http://" + default: + return "" + } +} diff --git a/pkg/client/client.go b/pkg/client/client.go index aa1ea273..a0faf69c 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -65,6 +65,11 @@ func (cli *Client) Close() error { return errors.Join(cli.conn.Close(), cli.connector.Close()) } +// Dialer returns a proxy dialer for establishing connections within the cluster if supported by the connector. +func (cli *Client) Dialer() (proxy.ContextDialer, error) { + return cli.connector.Dialer() +} + // progressOut returns an output stream for progress writer. func (cli *Client) progressOut() *streams.Out { return streams.NewOut(os.Stdout) diff --git a/pkg/client/image.go b/pkg/client/image.go index b652cac8..dd93ac63 100644 --- a/pkg/client/image.go +++ b/pkg/client/image.go @@ -241,7 +241,7 @@ func (cli *Client) pushImageToMachine( machineIP := network.MachineIP(machineSubnet) unregistryAddr := net.JoinHostPort(machineIP.String(), strconv.Itoa(constants.UnregistryPort)) - dialer, err := cli.connector.Dialer() + dialer, err := cli.Dialer() if err != nil { return fmt.Errorf("get proxy dialer: %w", err) } diff --git a/website/docs/9-cli-reference/uc.md b/website/docs/9-cli-reference/uc.md index 8a4b2502..feb32eee 100644 --- a/website/docs/9-cli-reference/uc.md +++ b/website/docs/9-cli-reference/uc.md @@ -26,6 +26,7 @@ A CLI tool for managing Uncloud resources such as machines, services, and volume * [uc logs](uc_logs.md) - View service logs. * [uc ls](uc_ls.md) - List services. * [uc machine](uc_machine.md) - Manage machines in the cluster. +* [uc proxy](uc_proxy.md) - Proxy a service port to a local port. * [uc ps](uc_ps.md) - List all service containers. * [uc rm](uc_rm.md) - Remove one or more services. * [uc run](uc_run.md) - Run a service. diff --git a/website/docs/9-cli-reference/uc_proxy.md b/website/docs/9-cli-reference/uc_proxy.md new file mode 100644 index 00000000..32b73ed9 --- /dev/null +++ b/website/docs/9-cli-reference/uc_proxy.md @@ -0,0 +1,36 @@ +# uc proxy + +Proxy a service port to a local port. + +## Synopsis + +Proxy a service port in the cluster to a local port on this machine. + +If the service runs multiple containers, the command connects to the first running and healthy one. +If you don't provide a local port, the command picks a random one. + +The connection stays open for as long as the command runs. + +``` +uc proxy SERVICE [LOCAL_PORT:]REMOTE_PORT [flags] +``` + +## Options + +``` + -h, --help help for proxy +``` + +## Options inherited from parent commands + +``` + --connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT] + Format: [ssh://]user@host[:port], ssh+go://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock + -c, --context string Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT] + --uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml") +``` + +## See also + +* [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes. +