diff --git a/cmd/unikraft/help_test.go b/cmd/unikraft/help_test.go index 5e6807d8..135484db 100644 --- a/cmd/unikraft/help_test.go +++ b/cmd/unikraft/help_test.go @@ -114,6 +114,7 @@ func instancesHelpTests(t *testing.T, unikraftPath string) { []string{"unikraft", "instance", "stop", "--help"}, []string{"unikraft", "instance", "suspend", "--help"}, []string{"unikraft", "instance", "restart", "--help"}, + []string{"unikraft", "instance", "tunnel", "--help"}, ) } diff --git a/cmd/unikraft/integration/instance_test.go b/cmd/unikraft/integration/instance_test.go index 9a3826f3..ee3b8459 100644 --- a/cmd/unikraft/integration/instance_test.go +++ b/cmd/unikraft/integration/instance_test.go @@ -825,4 +825,36 @@ cmd: ["cat", "/rom/hello.txt"] // Command still running } }) + + t.Run("tunnel", func(t *testing.T) { + r := runner(t, true) + instName := uniq() + + // Create an nginx instance without a public service. + r.Run(t, []string{ + "unikraft", "instance", "create", + "--output", "quiet", + "--set", "name=test-" + instName, + "--set", "metro=" + r.Config.MetroName, + "--set", "image=nginx:latest", + "--set", "autostart=true", + "--set", "resources.memory=128", + "--set", "resources.vcpus=1", + }) + r.Run(t, []string{"unikraft", "instance", "wait", "--until", "state==running", "--timeout", "60s", "test-" + instName}) + + // Use metro/instance:port/tcp syntax for the tunnel target. + tunnel := r.StartBackground(t, + []string{"unikraft", "instance", "tunnel", "18081:" + r.Config.MetroName + "/test-" + instName + ":8080/tcp"}, + "127.0.0.1:18081", + 0, + ) + + // Verify that we can reach the instance through the tunnel. + body := integ.HTTPGet(t, "http://127.0.0.1:18081") + assert.Regexp(t, "Thank you for using nginx.", body) + + r.StopBackground(t, tunnel) + r.Run(t, []string{"unikraft", "instance", "delete", "test-" + instName}) + }) } diff --git a/cmd/unikraft/testdata/TestHelp/instances b/cmd/unikraft/testdata/TestHelp/instances index f0a2633e..2eef588a 100644 --- a/cmd/unikraft/testdata/TestHelp/instances +++ b/cmd/unikraft/testdata/TestHelp/instances @@ -28,6 +28,8 @@ Resources: Suspend one or more instances. restart Restart one or more instances. + tunnel + Forward a local port to an unexposed instance. Templates: template, templates @@ -1459,3 +1461,56 @@ Global flags: [default: true] --timeout= ($UNIKRAFT_TIMEOUT) Set a deadline for the command (e.g. 30s, 5m, 1h). + +$ unikraft instance tunnel --help + +Forward a local port to an unexposed instance through an intermediate +TLS tunnel service. + +When you need to access an instance on Unikraft Cloud which is not +publicly exposed to the internet, you can use the +`unikraft instance tunnel` subcommand to forward from a local +port to a port which the instance listens on. + +Usage: + unikraft instances tunnel ... [flags] + +Arguments: + ... + Forwarding target(s) in the form [LOCAL_PORT:]:DEST_PORT[/TYPE]. + +Examples: + # Forward local port 8080 to instance "nginx" port 8080 + unikraft instance tunnel nginx:8080 + + # Forward local port 8333 to instance "nginx" port 8080 + unikraft instance tunnel 8333:nginx:8080 + + # Forward multiple ports from multiple instances + unikraft instance tunnel 8080:my-instance1:8080/tcp 8443:my-instance2:8080/tcp + + # Forward local port 8080 to instance "my-instance1" port 8080 on fra metro + # using TCP + unikraft instance tunnel 8080:fra/my-instance1:8080/tcp + + # Use a custom relay port to avoid collisions + unikraft instance tunnel -p 5500 my-instance:8080 + +Global flags: + -h, --help + Show context-sensitive help. + --config= ($UNIKRAFT_CONFIG) + Path to the configuration file. + --log-level= ($UNIKRAFT_LOG_LEVEL) + Set the logging level. + [default: info, choices: trace, debug, info, warn, error, fatal] + --log-type= ($UNIKRAFT_LOG_TYPE) + Set the log type. + [default: text, choices: text, json] + --profile= ($UNIKRAFT_PROFILE) + Set the current profile. + --[no-]telemetry ($UNIKRAFT_TELEMETRY) + Toggle anonymous usage analytics. + [default: true] + --timeout= ($UNIKRAFT_TIMEOUT) + Set a deadline for the command (e.g. 30s, 5m, 1h). diff --git a/go.mod b/go.mod index 6f4fb8f3..b012d39b 100644 --- a/go.mod +++ b/go.mod @@ -49,7 +49,7 @@ require ( gotest.tools/v3 v3.5.2 mvdan.cc/sh/v3 v3.13.1 sigs.k8s.io/yaml v1.6.0 - unikraft.com/cloud/sdk v0.0.0-20260610162742-d9f288f10c67 + unikraft.com/cloud/sdk v0.0.0-20260701141639-e77a1fd88abf unikraft.com/x/colors v0.0.0-20260313145522-d793c36d706e unikraft.com/x/filters v0.0.0-20260416164455-ec39ae908f3f unikraft.com/x/fingerprint v0.0.0-20260126094137-ab6e717e5679 diff --git a/go.sum b/go.sum index ee06d5c7..3c45dc04 100644 --- a/go.sum +++ b/go.sum @@ -485,8 +485,8 @@ sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= tailscale.com v1.94.1 h1:0dAst/ozTuFkgmxZULc3oNwR9+qPIt5ucvzH7kaM0Jw= tailscale.com v1.94.1/go.mod h1:gLnVrEOP32GWvroaAHHGhjSGMPJ1i4DvqNwEg+Yuov4= -unikraft.com/cloud/sdk v0.0.0-20260610162742-d9f288f10c67 h1:T/hIAHzgEqQmBLkO0l9YC5GX8Sixpzc5HYklKxbdNBM= -unikraft.com/cloud/sdk v0.0.0-20260610162742-d9f288f10c67/go.mod h1:XM2/ocAKjvRqXN5oXoKQAI/bIdu452d9q9bdtehQcao= +unikraft.com/cloud/sdk v0.0.0-20260701141639-e77a1fd88abf h1:VmeyVleZ4YYgzBT+uSO58CWWKZUigxArXC2fHP/SVSM= +unikraft.com/cloud/sdk v0.0.0-20260701141639-e77a1fd88abf/go.mod h1:XM2/ocAKjvRqXN5oXoKQAI/bIdu452d9q9bdtehQcao= unikraft.com/x/colors v0.0.0-20260313145522-d793c36d706e h1:C/V6l4ut5XpcVTN5CvnskRv6NHDbyIeLdgFVLEJ9BIE= unikraft.com/x/colors v0.0.0-20260313145522-d793c36d706e/go.mod h1:SVlAGfyQ7MwJom7m9M2w83+TrO+nJoiLxeduJAxagEo= unikraft.com/x/filters v0.0.0-20260416164455-ec39ae908f3f h1:v6pitpzsBnOjyzDIW0/YAEHHSI6cNOw4QOwQV0uD+dc= diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index 42d198d8..e7dfd96a 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -20,6 +20,7 @@ import ( "strconv" "strings" + "github.com/MakeNowJust/heredoc" "github.com/alecthomas/kong" "github.com/distribution/reference" "github.com/go-json-experiment/json/jsontext" @@ -39,6 +40,7 @@ import ( "unikraft.com/cli/internal/resource" "unikraft.com/cli/internal/resource/cmd" "unikraft.com/cli/internal/resource/value" + "unikraft.com/cli/internal/tunnel" "unikraft.com/cli/internal/types" ) @@ -58,6 +60,7 @@ type InstancesCmd struct { Stop InstancesStopCmd `cmd:"" help:"Stop one or more instances."` Suspend InstancesSuspendCmd `cmd:"" help:"Suspend one or more instances."` Restart InstancesRestartCmd `cmd:"" help:"Restart one or more instances."` + Tunnel InstancesTunnelCmd `cmd:"" help:"Forward a local port to an unexposed instance."` } // InstanceCreateCmd extends the generic resource create command with shortcut @@ -1845,3 +1848,73 @@ func suspendInstances(ctx context.Context, g *group.Group[multimetro.MetroClient }) return multimetro.Keys(suspended), err } + +type InstancesTunnelCmd struct { + Targets []string `arg:"" name:"target" min:"1" help:"Forwarding target(s) in the form [LOCAL_PORT:]:DEST_PORT[/TYPE]." placeholder:""` + + TunnelProxyPorts []string `short:"p" name:"tunnel-proxy-port" help:"Remote port(s) exposed by the tunnel service. When a single value is given it is used as the starting port for multiple targets." default:"4444" hidden:""` + ProxyControlPort uint `short:"P" name:"tunnel-control-port" help:"Command-and-control port of the tunnel service." default:"4443" hidden:""` + TunnelServiceImage string ` name:"tunnel-image" help:"Image to use for the tunnel service." default:"official/utils/tunnel:1.0" hidden:""` +} + +func (InstancesTunnelCmd) Help() string { + return heredoc.Docf(` + Forward a local port to an unexposed instance through an intermediate + TLS tunnel service. + + When you need to access an instance on Unikraft Cloud which is not + publicly exposed to the internet, you can use the + %[1]sunikraft instance tunnel%[1]s subcommand to forward from a local + port to a port which the instance listens on. + `, "`") +} + +func (cmd InstancesTunnelCmd) Examples() []kingkong.Example { + return []kingkong.Example{ + { + Description: "Forward local port 8080 to instance \"nginx\" port 8080", + Commands: []string{"unikraft instance tunnel nginx:8080"}, + }, + { + Description: "Forward local port 8333 to instance \"nginx\" port 8080", + Commands: []string{"unikraft instance tunnel 8333:nginx:8080"}, + }, + { + Description: "Forward multiple ports from multiple instances", + Commands: []string{"unikraft instance tunnel 8080:my-instance1:8080/tcp 8443:my-instance2:8080/tcp"}, + }, + { + Description: "Forward local port 8080 to instance \"my-instance1\" port 8080 on fra metro using TCP", + Commands: []string{"unikraft instance tunnel 8080:fra/my-instance1:8080/tcp"}, + }, + { + Description: "Use a custom relay port to avoid collisions", + Commands: []string{"unikraft instance tunnel -p 5500 my-instance:8080"}, + }, + } +} + +func (cmd *InstancesTunnelCmd) Run(ctx context.Context, stdio config.Stdio) error { + targets, err := tunnel.ParseTargets(cmd.Targets, cmd.TunnelProxyPorts) + if err != nil { + return fmt.Errorf("could not parse targets: %w", err) + } + + tun, err := tunnel.New(ctx, targets) + if err != nil { + return fmt.Errorf("could not create tunnel: %w", err) + } + + g, err := multimetro.NewClient(ctx) + if err != nil { + return err + } + + defer func() { + if err := tun.Close(context.WithoutCancel(ctx), g); err != nil { + log.G(ctx).Error().Err(err).Msg("could not terminate tunnel proxy") + } + }() + + return tun.Run(ctx, g, cmd.ProxyControlPort, cmd.TunnelServiceImage) +} diff --git a/internal/integration/env.go b/internal/integration/env.go index 94053871..c8f862fa 100644 --- a/internal/integration/env.go +++ b/internal/integration/env.go @@ -8,11 +8,13 @@ package integration import ( "bytes" "context" + "net" "os" "os/exec" "path/filepath" "slices" "strings" + "sync" "testing" "time" @@ -138,3 +140,90 @@ func (env *TestEnv) Run(t *testing.T, args []string, opts ...CmdOption) string { } return out } + +// BackgroundProcess is a handle to a process started by StartBackground. +type BackgroundProcess struct { + cmd *exec.Cmd + output *bytes.Buffer + t *testing.T + once sync.Once +} + +func (p *BackgroundProcess) stop() { + p.once.Do(func() { + _ = p.cmd.Cancel() + err := p.cmd.Wait() + if err != nil && p.output.Len() > 0 { + p.t.Logf("background process output:\n%s", p.output.String()) + } + }) +} + +// StartBackground starts a command in the background and registers cleanup to +// stop it when the test ends. If waitAddr is non-empty, it polls that TCP +// address until it is reachable or the timeout (default 90s) expires. +// The returned handle can be passed to StopBackground for early termination. +func (env *TestEnv) StartBackground(t *testing.T, args []string, waitAddr string, timeout time.Duration) *BackgroundProcess { + t.Helper() + t.Logf("starting background: %s", strings.Join(args, " ")) + + var c *exec.Cmd + if args[0] == "unikraft" { + c = exec.CommandContext(t.Context(), env.unikraftPath, args[1:]...) + } else { + c = exec.CommandContext(t.Context(), args[0], args[1:]...) + } + + c.Env = os.Environ() + c.Env = slices.DeleteFunc(c.Env, func(s string) bool { + return strings.HasPrefix(s, "UNIKRAFT_") + }) + c.Env = append(c.Env, "NO_COLOR=1") + c.Env = append(c.Env, "UNIKRAFT_CONFIG="+env.configPath) + c.Env = append(c.Env, "BUILDKIT_PROGRESS=quiet") + c.Env = append(c.Env, resource.UnikraftSandboxEnv+"="+env.sandboxPath) + c.Cancel = func() error { + return c.Process.Signal(os.Interrupt) + } + c.WaitDelay = 30 * time.Second + + var output bytes.Buffer + c.Stdout = &output + c.Stderr = &output + + require.NoError(t, c.Start(), "failed to start background command %q", strings.Join(args, " ")) + + proc := &BackgroundProcess{cmd: c, output: &output, t: t} + t.Cleanup(proc.stop) + + if waitAddr != "" { + waitForAddr(t, waitAddr, timeout) + } + + return proc +} + +// StopBackground terminates a background process immediately, without waiting +// for the test to finish. +func (env *TestEnv) StopBackground(t *testing.T, proc *BackgroundProcess) { + t.Helper() + t.Logf("stopping background process") + proc.stop() +} + +func waitForAddr(t *testing.T, addr string, timeout time.Duration) { + t.Helper() + if timeout == 0 { + timeout = 90 * time.Second + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", addr, time.Second) + if err == nil { + conn.Close() + return + } + time.Sleep(500 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s to become reachable", addr) +} diff --git a/internal/tunnel/parse.go b/internal/tunnel/parse.go new file mode 100644 index 00000000..acfd2480 --- /dev/null +++ b/internal/tunnel/parse.go @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package tunnel + +import ( + "context" + "fmt" + "strconv" + "strings" + + "unikraft.com/cli/internal/multimetro" + "unikraft.com/cloud/sdk/platform" + "unikraft.com/cloud/sdk/platform/group" + "unikraft.com/x/log" +) + +// proxyInfo holds the metadata for a running tunnel proxy instance. +type proxyInfo struct { + uuid string + fqdn string + // closed indicates whether this proxy instance has been closed. + closed bool +} + +// ParseTargets parses raw target strings and proxy port specifications into +// structured target objects. +// proxyPorts configures which ports the tunnel service exposes: +// if a single port is given it is used as the starting port for sequential +// assignment; otherwise there must be exactly one port per target. +func ParseTargets(rawTargets []string, proxyPorts []string) ([]target, error) { + if len(rawTargets) == 0 { + return nil, fmt.Errorf("at least one target must be specified") + } + + if len(proxyPorts) == 0 { + return nil, fmt.Errorf("at least one proxy port must be specified") + } + + targets := make([]target, len(rawTargets)) + for i, raw := range rawTargets { + t, err := parseTarget(raw) + if err != nil { + return nil, fmt.Errorf("parsing target %q: %w", raw, err) + } + targets[i] = t + } + + parsedPorts, err := parseProxyPorts(proxyPorts) + if err != nil { + return nil, err + } + + if len(parsedPorts) > 1 && len(parsedPorts) != len(targets) { + return nil, fmt.Errorf("number of proxy ports must be either 1 or equal to the number of targets") + } + + // Assign exposed proxy ports. + for i := range targets { + if len(parsedPorts) == 1 { + targets[i].exposedProxyPort = parsedPorts[0] + uint16(i) + } else { + targets[i].exposedProxyPort = parsedPorts[i] + } + } + return targets, nil +} + +func parseProxyPorts(ports []string) ([]uint16, error) { + parsed := make([]uint16, 0, len(ports)) + for _, port := range ports { + p, err := strconv.ParseUint(port, 10, 16) + if err != nil { + return nil, fmt.Errorf("%q is not a valid port number", port) + } + parsed = append(parsed, uint16(p)) + } + return parsed, nil +} + +// formatProxyArgs formats the arguments to pass to a tunnel service instance +// for the given set of targets. +// NOTE(craciunoiuc): Proxy arguments can be found at: +// https://github.com/unikraft-cloud/catalog/tree/prod-staging/library/official/utils/tunlr/1.0 +func formatProxyArgs(targets []target, authStr string, proxyControlPort uint) []string { + connections := make([]string, 0, len(targets)) + for _, tgt := range targets { + connections = append(connections, fmt.Sprintf("TCP2%s:%s:%d:%d:%d", + strings.ToUpper(tgt.network), + tgt.host, + tgt.dest, + tgt.exposedProxyPort, + 27, + )) + } + return []string{ + // HEARTBEAT_PORT:CTLR_AUTH_TIMEOUT + fmt.Sprintf("%d:%d", proxyControlPort, 5), + // AUTH_TIMEOUT:AUTH_COOKIE + fmt.Sprintf("%d:%s", 5, authStr), + // EVS_TIMEOUT + "600", + // [CONNSTR0|CONNSTR1|...] + "[" + strings.Join(connections, "|") + "]", + } +} + +// parseTarget parses a single CLI forwarding target string of the form +// [LOCAL_PORT:][METRO/]INSTANCE:DEST_PORT[/TYPE]. +func parseTarget(raw string) (target, error) { + rest := raw + network := "tcp" + + // Split connection type from the last "/" — but only if the suffix looks + // like a protocol name rather than a metro/instance path component. + if idx := strings.LastIndex(rest, "/"); idx >= 0 { + suffix := rest[idx+1:] + if strings.EqualFold(suffix, "tcp") || strings.EqualFold(suffix, "udp") { + network = strings.ToLower(suffix) + rest = rest[:idx] + } + } + + if network != "tcp" { + return target{}, fmt.Errorf("unsupported connection type %q: only tcp is supported", network) + } + + segments := strings.SplitN(rest, ":", 3) + switch len(segments) { + case 2: + // INSTANCE:DEST_PORT — no local port override, use 0 for random port. + if _, parseErr := strconv.ParseUint(segments[0], 10, 16); parseErr == nil { + return target{}, fmt.Errorf("%q is not a valid instance identifier", segments[0]) + } + rport64, parseErr := strconv.ParseUint(segments[1], 10, 16) + if parseErr != nil { + return target{}, fmt.Errorf("%q is not a valid port number", segments[1]) + } + return target{host: segments[0], source: 0, dest: uint16(rport64), network: network}, nil + case 3: + // LOCAL_PORT:INSTANCE:DEST_PORT + lport64, parseErr := strconv.ParseUint(segments[0], 10, 16) + if parseErr != nil { + return target{}, fmt.Errorf("%q is not a valid port number", segments[0]) + } + rport64, parseErr := strconv.ParseUint(segments[2], 10, 16) + if parseErr != nil { + return target{}, fmt.Errorf("%q is not a valid port number", segments[2]) + } + return target{host: segments[1], source: uint16(lport64), dest: uint16(rport64), network: network}, nil + default: + return target{}, fmt.Errorf("%q is not a valid forwarding target (expected [LOCAL_PORT:]INSTANCE:DEST_PORT[/TYPE])", raw) + } +} + +// createProxy creates a single tunnel proxy instance in the given metro for +// the provided targets. +func createProxy(ctx context.Context, g *group.Group[multimetro.MetroClient], metro string, targets []target, authStr string, proxyControlPort uint, tunnelImage string) (proxyInfo, error) { + args := formatProxyArgs(targets, authStr, proxyControlPort) + + services := make([]platform.Service, 0, len(targets)+1) + for _, tgt := range targets { + p := uint32(tgt.exposedProxyPort) + services = append(services, platform.Service{ + Port: p, + DestinationPort: &p, + Handlers: []platform.ConnectionHandler{platform.ConnectionHandlerTls}, + }) + } + ctrlPort := uint32(proxyControlPort) + services = append(services, platform.Service{ + Port: ctrlPort, + DestinationPort: &ctrlPort, + Handlers: []platform.ConnectionHandler{platform.ConnectionHandlerTls}, + }) + + image := tunnelImage + req := platform.CreateInstanceRequest{ + Image: &image, + MemoryMb: new(int64(128)), + Args: args, + ServiceGroup: &platform.CreateInstanceRequestServiceGroup{ + Services: services, + }, + Autostart: new(bool(true)), + TimeoutS: new(int64(-1)), + Features: []platform.InstanceFeature{platform.InstanceFeatureDeleteOnStop}, + } + + return group.CollectMetro(ctx, g, metro, func(ctx context.Context, c multimetro.MetroClient) (proxyInfo, error) { + log.G(ctx).Trace().Msg("creating tunnel proxy instance") + resp, err := c.CreateInstance(ctx, req) + if err != nil { + return proxyInfo{}, fmt.Errorf("creating proxy instance: %w", err) + } + if resp.Data == nil || len(resp.Data.Instances) == 0 { + return proxyInfo{}, fmt.Errorf("no instance returned after creation") + } + inst := resp.Data.Instances[0] + uuid := inst.Uuid + + var fqdn string + if inst.ServiceGroup != nil && len(inst.ServiceGroup.Domains) > 0 { + fqdn = inst.ServiceGroup.Domains[0].Fqdn + } + if fqdn == "" { + return proxyInfo{}, fmt.Errorf("tunnel proxy has no service group domain") + } + return proxyInfo{uuid: uuid, fqdn: fqdn}, nil + }) +} diff --git a/internal/tunnel/relay.go b/internal/tunnel/relay.go new file mode 100644 index 00000000..c31f948c --- /dev/null +++ b/internal/tunnel/relay.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package tunnel + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "time" + + "unikraft.com/x/log" +) + +// tunnelRelay relays connections from a local listener to a remote host over TLS. +// NOTE(craciunoiuc): Protocol and heartbeat encoding can be found at: +// https://github.com/unikraft-cloud/catalog/tree/prod-staging/library/official/utils/tunlr/1.0 +type tunnelRelay struct { + localAddr string + remoteAddr string + connectionType string + auth string + name string + nameAddr string + insecure bool +} + +const tunnelHeartbeat = "\xf0\x9f\x91\x8b\xf0\x9f\x90\x92\x00" + +var ( + tunnelNoNetTimeout = time.Time{} + tunnelImmediateNetCancel = time.Unix(1, 0) +) + +// up starts a local listener and relays accepted connections to the remote host. +func (r *tunnelRelay) up(ctx context.Context) error { + var lc net.ListenConfig + l, err := lc.Listen(ctx, r.connectionType+"4", r.localAddr) + if err != nil { + return err + } + stop := context.AfterFunc(ctx, func() { l.Close() }) + defer func() { + stop() + l.Close() + }() + + log.G(ctx).Info().Str("from", l.Addr().String()).Str("to", r.nameAddr).Msg("tunnelling") + log.G(ctx).Debug().Str("via", r.remoteAddr).Msg("tunnelling") + + for { + conn, err := l.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + return nil + } + return fmt.Errorf("accepting incoming connection: %w", err) + } + c := &tunnelConnection{relay: r, conn: conn} + go c.handle(ctx, []byte(r.auth), r.name, r.nameAddr) + } +} + +// controlUp dials the remote control port, signals ready, then sends periodic +// heartbeats to keep the tunnel service alive. +func (r *tunnelRelay) controlUp(ctx context.Context, ready chan struct{}) error { + rc, err := r.dialRemote(ctx) + if err != nil { + return err + } + stop := context.AfterFunc(ctx, func() { rc.Close() }) + defer func() { + stop() + rc.Close() + }() + + ready <- struct{}{} // signal that the control connection is established + + // Send auth and initial heartbeat. + _, err = io.CopyN(rc, bytes.NewReader([]byte(r.auth+tunnelHeartbeat)), int64(len(r.auth)+len(tunnelHeartbeat))) + if err != nil { + return err + } + // Send a heartbeat every minute to keep the connection alive. + for { + time.Sleep(time.Minute) + _, err := io.CopyN(rc, bytes.NewReader([]byte(tunnelHeartbeat)), int64(len(tunnelHeartbeat))) + if err != nil { + return err + } + } +} + +func (r *tunnelRelay) dialRemote(ctx context.Context) (net.Conn, error) { + var d tls.Dialer + d.Config = &tls.Config{ + InsecureSkipVerify: r.insecure, //nolint:gosec // insecure connections are allowed if wanted + } + return d.DialContext(ctx, "tcp4", r.remoteAddr) +} diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go new file mode 100644 index 00000000..76e778c5 --- /dev/null +++ b/internal/tunnel/tunnel.go @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package tunnel + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + + "golang.org/x/sync/errgroup" + + "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/multimetro" + "unikraft.com/cli/internal/volimport" + "unikraft.com/cloud/sdk/platform" + "unikraft.com/cloud/sdk/platform/group" + "unikraft.com/x/log" + "unikraft.com/x/ptr" +) + +// target describes a single forwarding destination parsed from the CLI +// argument format [LOCAL_PORT:][METRO/]INSTANCE:DEST_PORT[/TYPE]. +type target struct { + // host is the instance identifier (possibly including a metro prefix like + // "fra/my-instance"). After resolution it is replaced by a private IP. + host string + // source is the local port to listen on. 0 means the OS picks a free port. + source uint16 + // dest is the port on the remote instance to forward to. + dest uint16 + // network is the connection type, e.g. "tcp" (like in net.Dial). + network string + // exposedProxyPort is the port exposed by the tunnel service for this + // target. It is computed from the proxy port configuration. + exposedProxyPort uint16 + // metro is the metro where this target's instance lives, set by resolve. + metro string +} + +type Tunnel struct { + Targets []target + + auth string + // proxies maps metro name to the running tunnel proxy in that metro. + // Populated by Run, consumed by Start and Terminate. + proxies map[string]proxyInfo +} + +// New creates a tunnel structure. It sets targets, assigns proxy ports, +// and resolves instance names to private IPs via the platform API. +func New(ctx context.Context, targets []target) (*Tunnel, error) { + authStr, err := volimport.GenRandAuth() + if err != nil { + return nil, fmt.Errorf("could not generate auth string: %w", err) + } + + t := &Tunnel{Targets: targets, auth: authStr} + if err := t.resolve(ctx); err != nil { + return nil, err + } + return t, nil +} + +// resolve looks up each target's instance on the platform, replacing the Host +// with its private IP and recording the metro. +func (t *Tunnel) resolve(ctx context.Context) error { + keys := make(multimetro.Keys, len(t.Targets)) + for i, tgt := range t.Targets { + keys[i] = multimetro.ParseKey(tgt.host) + } + + g, err := multimetro.NewClient(ctx) + if err != nil { + return err + } + + type instanceInfo struct { + ip string + metro string + } + type resolvedInstance struct { + ref group.Ref + info instanceInfo + } + + resolved, err := group.CollectRefsSlices(ctx, g, keys.Refs(), + func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]resolvedInstance, group.Refs, error) { + log.G(ctx).Trace().Msg("getting instances for tunnel") + resp, err := c.GetInstances(ctx, refs.NameOrUUIDs(), platform.GetInstancesOpts{Details: new(true)}) + if err != nil && !platform.ErrorContainsOnly(err, platform.APIHTTPErrorNotFound) { + return nil, nil, err + } + var found group.Refs + var results []resolvedInstance + for i, inst := range resp.Data.Instances { + if inst.Status == nil || *inst.Status != platform.ResponseStatusSuccess { + continue + } + if len(inst.NetworkInterfaces) == 0 || inst.NetworkInterfaces[0].PrivateIp == "" { + return nil, nil, fmt.Errorf("instance %q has no private IP", refs[i].Display) + } + found = append(found, refs[i]) + results = append(results, resolvedInstance{ + ref: refs[i], + info: instanceInfo{ + ip: inst.NetworkInterfaces[0].PrivateIp, + metro: c.Metro.Name, + }, + }) + } + return results, found, nil + }) + if err != nil { + return fmt.Errorf("could not resolve instances: %w", err) + } + + // NOTE(craciunoiuc): if the same instance name appears in multiple metros + // (no metro prefix given), the last-seen result wins. Users should include + // a metro prefix to disambiguate (e.g. fra/my-instance:8080). + infoByDisplay := make(map[string]instanceInfo, len(resolved)) + for _, r := range resolved { + infoByDisplay[r.ref.Display] = r.info + } + + for i := range t.Targets { + info, ok := infoByDisplay[t.Targets[i].host] + if !ok { + return fmt.Errorf("could not determine metro for %q: include the metro prefix (e.g. fra/my-instance:8080)", t.Targets[i].host) + } + t.Targets[i].host = info.ip + t.Targets[i].metro = info.metro + } + return nil +} + +// Run creates one tunnel service proxy instance per unique metro and stores +// their metadata. For each metro, a control relay is +// established to the proxy in that metro, then data relays are started for +// every target in that metro. +func (t *Tunnel) Run(ctx context.Context, g *group.Group[multimetro.MetroClient], proxyControlPort uint, tunnelImage string) error { + // Group targets by metro. + metroTargets := make(map[string][]target) + for _, tgt := range t.Targets { + metroTargets[tgt.metro] = append(metroTargets[tgt.metro], tgt) + } + profile, err := config.G(ctx).CurrentProfile() + if err != nil { + return fmt.Errorf("getting current profile: %w", err) + } + + metroInsecureMap := make(map[string]bool) + for _, m := range profile.Metros { + metroInsecureMap[m.Name] = ptr.ZeroIfNil(m.Insecure) + } + + t.proxies = make(map[string]proxyInfo, len(metroTargets)) + for metro, targets := range metroTargets { + info, err := createProxy(ctx, g, metro, targets, t.auth, proxyControlPort, tunnelImage) + if err != nil { + return fmt.Errorf("creating proxy in metro %q: %w", metro, err) + } + t.proxies[metro] = info + } + + // Start a control relay per metro proxy. + for metro, proxy := range t.proxies { + cr := tunnelRelay{ + remoteAddr: net.JoinHostPort(proxy.fqdn, strconv.FormatUint(uint64(proxyControlPort), 10)), + auth: t.auth, + insecure: metroInsecureMap[metro], + } + ready := make(chan struct{}) + go func() { + defer close(ready) + if err := cr.controlUp(ctx, ready); err != nil { + log.G(ctx).Error().Err(err).Str("metro", metro).Msg("control relay error") + } + }() + // Wait for this metro's control relay before starting its data relays. + <-ready + } + + eg, ctx := errgroup.WithContext(ctx) + for _, tgt := range t.Targets { + proxy := t.proxies[tgt.metro] + r := tunnelRelay{ + // TODO(antoineco): allow dual-stack by creating two separate listeners. + // Alternatively, we could default to "::" to create a tcp46 socket, but + // listening on all addresses is an insecure default. + localAddr: net.JoinHostPort("127.0.0.1", strconv.FormatUint(uint64(tgt.source), 10)), + remoteAddr: net.JoinHostPort(proxy.fqdn, strconv.FormatUint(uint64(tgt.exposedProxyPort), 10)), + // NOTE(craciunoiuc): Only TCP is supported at the moment. This refers to the + // local listener; the remote side always uses TLS-over-TCP. + connectionType: tgt.network, + auth: t.auth, + name: proxy.uuid, + nameAddr: fmt.Sprintf("%s:%d", tgt.host, tgt.dest), + insecure: metroInsecureMap[tgt.metro], + } + eg.Go(func() error { + return r.up(ctx) + }) + } + + return eg.Wait() +} + +// Close removes all tunnel proxy instances across all metros. +func (t *Tunnel) Close(ctx context.Context, g *group.Group[multimetro.MetroClient]) error { + var errs []error + for metro, proxy := range t.proxies { + if proxy.closed { + continue + } + + err := group.DoMetro(ctx, g, metro, func(ctx context.Context, c multimetro.MetroClient) error { + log.G(ctx).Trace().Msg("deleting tunnel proxy instance") + _, err := c.DeleteInstances(ctx, []platform.DeleteInstanceRequestItem{{Uuid: &proxy.uuid}}) + if err != nil && !platform.ErrorContainsOnly(err, platform.APIHTTPErrorNotFound) { + return fmt.Errorf("deleting proxy instance %q: %w", proxy.uuid, err) + } + proxy.closed = true + return nil + }) + if err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// tunnelConnection represents an accepted local connection being relayed to a +// remote host through the tunnel service. +type tunnelConnection struct { + relay *tunnelRelay + conn net.Conn +} + +// handle relays data between the local connection and the remote host. +func (c *tunnelConnection) handle(ctx context.Context, auth []byte, instance, instanceRaw string) { + defer func() { + c.conn.Close() + log.G(ctx).Info().Str("for", instanceRaw).Msg("closed connection") + }() + + rc, err := c.relay.dialRemote(ctx) + if err != nil { + log.G(ctx).Error().Err(err).Msg("failed to connect to remote host") + return + } + defer rc.Close() + + log.G(ctx).Debug(). + Str("for", c.conn.RemoteAddr().String()). + Str("from", rc.LocalAddr().String()). + Str("to", rc.RemoteAddr().String()). + Msg("opened connection") + log.G(ctx).Info().Str("to", instanceRaw).Msg("accepted connection") + + _ = rc.SetDeadline(tunnelNoNetTimeout) + _ = c.conn.SetDeadline(tunnelNoNetTimeout) + + defer func() { + _ = c.conn.SetDeadline(tunnelImmediateNetCancel) + }() + + if len(auth) > 0 { + _, err = rc.Write(auth) + if err != nil { + log.G(ctx).Error().Err(err).Msg("failed to write auth to remote host") + return + } + + statusRaw := bytes.NewBuffer(nil) + n, err := io.CopyN(statusRaw, rc, 2) + if err != nil { + log.G(ctx).Error().Err(err).Msg("failed to read auth status from remote host") + return + } + if n != 2 { + log.G(ctx).Error().Msg("invalid auth status from remote host") + return + } + + var status int16 + if err = binary.Read(statusRaw, binary.LittleEndian, &status); err != nil { + log.G(ctx).Error().Err(err).Msg("failed to parse auth status from remote host") + return + } + + if status == 0 { + log.G(ctx).Error().Msg("no available connections to remote host, try again later") + return + } else if status < 0 { + log.G(ctx).Error().Msgf("internal tunnel error (C=%d), to view logs run:", status) + log.G(ctx).Error().Msgf(" unikraft instance logs %s\n", instance) + return + } + } + + writerDone := make(chan struct{}) + go func() { + defer func() { + _ = rc.SetDeadline(tunnelImmediateNetCancel) + close(writerDone) + }() + _, err = io.Copy(rc, c.conn) + if err != nil && !isNetClosedError(err) && !isNetTimeoutError(err) { + log.G(ctx).Error().Err(err).Msg("failed to copy data from client to remote host") + } + }() + + _, err = io.Copy(c.conn, rc) + if err != nil { + if !isNetTimeoutError(err) && !isNetClosedError(err) { + log.G(ctx).Error().Err(err).Msg("failed to copy data from remote host to client") + } + } else { + // Remote closed the connection cleanly; return to close our side. + return + } + + <-writerDone +} + +func isNetTimeoutError(err error) bool { + var neterr net.Error + return errors.As(err, &neterr) && neterr.Timeout() +} + +func isNetClosedError(err error) bool { + return errors.Is(err, net.ErrClosed) || + strings.Contains(err.Error(), "connection reset by peer") +}