Skip to content
Open
24 changes: 23 additions & 1 deletion pkg/virtualkubelet/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,36 @@ type PodCIDR struct {
type Network struct {
// EnableTunnel enables WebSocket tunneling for pod port exposure
EnableTunnel bool `yaml:"EnableTunnel" default:"false"`
// TunnelType selects the port-forwarding backend: "" or "wstunnel" (default, backward-compatible) or "rathole".
TunnelType string `yaml:"TunnelType,omitempty"`
// WildcardDNS specifies the DNS domain for generating tunnel endpoints
WildcardDNS string `yaml:"WildcardDNS,omitempty"`
// WSTunnelExecutableURL specifies the URL to download the wstunnel executable (default is "https://github.com/interlink-hq/interlink-artifacts/raw/main/wstunnel/v10.4.4/linux-amd64/wstunnel")
WSTunnelExecutableURL string `yaml:"WSTunnelExecutable,omitempty"`
// WstunnelTemplatePath is the path to a custom wstunnel template file
// WstunnelTemplatePath is the path to a custom tunnel template file (applies to both wstunnel and rathole)
WstunnelTemplatePath string `yaml:"WstunnelTemplatePath,omitempty"`
// WstunnelCommand specifies the command template for setting up wstunnel clients
WstunnelCommand string `yaml:"WstunnelCommand,omitempty"`
// RatholeExecutableURL specifies the URL to download the rathole executable zip archive
// (default is "https://github.com/rathole-org/rathole/releases/download/v0.5.0/rathole-x86_64-unknown-linux-gnu.zip")
RatholeExecutableURL string `yaml:"RatholeExecutableURL,omitempty"`
// RatholeCommand specifies a custom command template for rathole clients in TLS mode
// (i.e., when RatholeCAIssuerName is set). Five %s format verbs are substituted in order:
// the rathole download URL, base64-encoded CA cert, base64-encoded client cert,
// base64-encoded client key, and base64-encoded client TOML config.
// Default: DefaultRatholeCommand.
RatholeCommand string `yaml:"RatholeCommand,omitempty"`
// RatholeWSCommand specifies a custom command template for rathole clients in WebSocket fallback
// mode (i.e., when RatholeCAIssuerName is empty). Two %s format verbs are substituted in order:
// the rathole download URL and the base64-encoded client TOML config.
// Default: DefaultRatholeWSCommand.
RatholeWSCommand string `yaml:"RatholeWSCommand,omitempty"`
// RatholeCAIssuerName is the cert-manager ClusterIssuer or Issuer name for the admin-provided CA.
// When set, rathole uses TLS transport; cert-manager issues both the server and client certificates.
// A Traefik IngressRouteTCP resource is created to expose the rathole server via TLS on port 443.
RatholeCAIssuerName string `yaml:"RatholeCAIssuerName,omitempty"`
// RatholeCAIssuerKind is the kind of the cert-manager issuer: "ClusterIssuer" (default) or "Issuer".
RatholeCAIssuerKind string `yaml:"RatholeCAIssuerKind,omitempty"`
// FullMesh enables full mesh networking with slirp4netns and WireGuard
FullMesh bool `yaml:"FullMesh" default:"false"`
// MeshScriptTemplatePath is the path to a custom mesh.sh template file
Expand Down
33 changes: 33 additions & 0 deletions pkg/virtualkubelet/config_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package virtualkubelet

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -124,6 +125,38 @@ func TestNetwork_Configuration(t *testing.T) {
assert.NotEmpty(t, network.WstunnelCommand)
}

func TestNetwork_RatholeConfiguration(t *testing.T) {
network := Network{
EnableTunnel: true,
TunnelType: "rathole",
WildcardDNS: "tunnel.example.com",
RatholeExecutableURL: "https://example.com/rathole.zip",
// RatholeCommand is the TLS-mode template: 5 %s args (URL, CA cert, client cert, client key, client TOML)
RatholeCommand: "curl -L %s -o r.zip && unzip r.zip && echo %s | base64 -d > /tmp/ca.crt && echo %s | base64 -d > /tmp/cl.crt && echo %s | base64 -d > /tmp/cl.key && echo %s | base64 -d > /tmp/c.toml && ./rathole --client /tmp/c.toml &",
// RatholeWSCommand is the WebSocket-fallback template: 2 %s args (URL, client TOML)
RatholeWSCommand: "curl -L %s -o r.zip && unzip r.zip && echo %s | base64 -d > /tmp/c.toml && ./rathole --client /tmp/c.toml &",
}

assert.True(t, network.EnableTunnel)
assert.Equal(t, "rathole", network.TunnelType)
assert.Equal(t, "tunnel.example.com", network.WildcardDNS)
assert.Equal(t, "https://example.com/rathole.zip", network.RatholeExecutableURL)
assert.NotEmpty(t, network.RatholeCommand)
assert.NotEmpty(t, network.RatholeWSCommand)
// Validate that RatholeCommand contains exactly 5 %s verbs (TLS mode)
assert.Equal(t, 5, strings.Count(network.RatholeCommand, "%s"), "RatholeCommand must have exactly 5 %%s format verbs for TLS mode")
// Validate that RatholeWSCommand contains exactly 2 %s verbs (WebSocket fallback)
assert.Equal(t, 2, strings.Count(network.RatholeWSCommand, "%s"), "RatholeWSCommand must have exactly 2 %%s format verbs for WebSocket mode")
}

func TestNetwork_WstunnelDefaultTunnelType(t *testing.T) {
// Empty TunnelType means wstunnel (backward-compatible default)
network := Network{
EnableTunnel: true,
}
assert.Empty(t, network.TunnelType, "empty TunnelType should default to wstunnel behaviour")
}

func TestAccelerator_AvailableIsKubernetesQuantity(t *testing.T) {
tests := []struct {
name string
Expand Down
121 changes: 115 additions & 6 deletions pkg/virtualkubelet/mesh.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,8 @@ func deriveWGPublicKey(privB64 string) (string, error) {
return base64.StdEncoding.EncodeToString(pubRaw), nil
}

// addWstunnelClientAnnotation adds the wstunnel client command annotation to the original pod
// addWstunnelClientAnnotation adds the tunnel client command annotation to the original pod.
// In rathole mode it writes a rathole client command; otherwise it writes a wstunnel command.
func (p *Provider) addWstunnelClientAnnotation(ctx context.Context, pod *v1.Pod, td *WstunnelTemplateData) error {
if pod.Annotations == nil {
pod.Annotations = make(map[string]string)
Expand All @@ -252,7 +253,8 @@ func (p *Provider) addWstunnelClientAnnotation(ctx context.Context, pod *v1.Pod,
clearConflictingNetworkAnnotations(pod, fullMeshEnabledForPod)

// Check if FullMesh mode is enabled and not disabled for this specific pod
if fullMeshEnabledForPod {
switch {
case fullMeshEnabledForPod:
log.G(ctx).Infof("FullMesh mode enabled, generating pre-exec script for pod %s/%s", pod.Namespace, pod.Name)

// Generate full mesh script
Expand Down Expand Up @@ -289,10 +291,116 @@ PersistentKeepalive = %d

pod.Annotations["interlink.eu/wireguard-client-snippet"] = wgSnippet

} else {
case p.config.Network.TunnelType == tunnelTypeRathole:
// Rathole mode: build a client TOML config and generate the client bootstrap command.
// When RatholeCAIssuerName is set, use TLS transport with cert-manager-issued certificates;
// otherwise fall back to WebSocket transport for backward compatibility.
ratholeEndpoint := fmt.Sprintf("rathole-%s.%s", td.Name, td.WildcardDNS)
ratholeEndpoint = sanitizeFullDNSName(ratholeEndpoint)
if td.WildcardDNS == "" {
ratholeEndpoint = td.Name
}

ratholeURL := p.config.Network.RatholeExecutableURL
if ratholeURL == "" {
ratholeURL = DefaultRatholeExecutableURL
}

var mainCmd string

if p.config.Network.RatholeCAIssuerName != "" {
// TLS mode: rathole client uses TLS transport; Traefik terminates TLS at port 443.
// Wait for the client certificate secret to be issued by cert-manager.
clientCertSecretName := td.Name + "-rathole-client-tls"
if err := p.waitForRatholeCertSecret(ctx, clientCertSecretName, td.Namespace); err != nil {
return fmt.Errorf("rathole client certificate not ready: %w", err)
}

certSecret, err := p.clientSet.CoreV1().Secrets(td.Namespace).Get(ctx, clientCertSecretName, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("failed to read rathole client certificate secret: %w", err)
}

// Validate all required keys are present and non-empty.
for _, key := range []string{"ca.crt", "tls.crt", "tls.key"} {
if len(certSecret.Data[key]) == 0 {
return fmt.Errorf("rathole client certificate secret %s/%s is missing required key %q", td.Namespace, clientCertSecretName, key)
}
}

caCrtB64 := base64.StdEncoding.EncodeToString(certSecret.Data["ca.crt"])
clientCrtB64 := base64.StdEncoding.EncodeToString(certSecret.Data["tls.crt"])
clientKeyB64 := base64.StdEncoding.EncodeToString(certSecret.Data["tls.key"])

Comment thread
dciangot marked this conversation as resolved.
var tomlBuilder strings.Builder
fmt.Fprintf(&tomlBuilder, "[client]\nremote_addr = \"%s:443\"\n\n", ratholeEndpoint)
tomlBuilder.WriteString("[client.transport]\ntype = \"tls\"\n\n")
tomlBuilder.WriteString("[client.transport.tls]\n")
fmt.Fprintf(&tomlBuilder, "hostname = \"%s\"\n", ratholeEndpoint)
tomlBuilder.WriteString("trusted_root = \"/tmp/rathole-ca.crt\"\n")
tomlBuilder.WriteString("cert = \"/tmp/rathole-client.crt\"\n")
tomlBuilder.WriteString("key = \"/tmp/rathole-client.key\"\n\n")
for _, port := range td.ExposedPorts {
if strings.ToUpper(port.Protocol) == protocolUDP {
log.G(ctx).Debugf("Skipping UDP port %d in rathole client config (TLS transport forwards TCP only)", port.Port)
continue
}
fmt.Fprintf(&tomlBuilder, "[client.services.p%d]\ntoken = \"%s\"\nlocal_addr = \"127.0.0.1:%d\"\n\n",
port.Port, td.RandomPassword, port.Port)
}

configB64 := base64.StdEncoding.EncodeToString([]byte(tomlBuilder.String()))

ratholeCmd := p.config.Network.RatholeCommand
if ratholeCmd == "" {
ratholeCmd = DefaultRatholeCommand
}
// Validate that the TLS command template has exactly 5 %s format verbs
// (URL, CA cert, client cert, client key, client TOML).
if strings.Count(ratholeCmd, "%s") != 5 {
return fmt.Errorf("RatholeCommand must have exactly 5 %%s format verbs (url, ca, cert, key, toml); got %d in %q",
strings.Count(ratholeCmd, "%s"), p.config.Network.RatholeCommand)
}
mainCmd = fmt.Sprintf(ratholeCmd, ratholeURL, caCrtB64, clientCrtB64, clientKeyB64, configB64)
} else {
// WebSocket fallback (no CA issuer configured)
log.G(ctx).Debugf("RatholeCAIssuerName not set; using WebSocket transport for pod %s/%s", pod.Namespace, pod.Name)

var tomlBuilder strings.Builder
fmt.Fprintf(&tomlBuilder, "[client]\nremote_addr = \"%s:80\"\n\n", ratholeEndpoint)
tomlBuilder.WriteString("[client.transport]\ntype = \"websocket\"\n\n")
for _, port := range td.ExposedPorts {
if strings.ToUpper(port.Protocol) == protocolUDP {
log.G(ctx).Debugf("Skipping UDP port %d in rathole client config (websocket transport forwards TCP only)", port.Port)
continue
}
fmt.Fprintf(&tomlBuilder, "[client.services.p%d]\ntoken = \"%s\"\nlocal_addr = \"127.0.0.1:%d\"\n\n",
port.Port, td.RandomPassword, port.Port)
}

configB64 := base64.StdEncoding.EncodeToString([]byte(tomlBuilder.String()))

ratholeWSCmd := p.config.Network.RatholeWSCommand
if ratholeWSCmd == "" {
ratholeWSCmd = DefaultRatholeWSCommand
}
// Validate that the WebSocket command template has exactly 2 %s format verbs
// (URL, client TOML).
if strings.Count(ratholeWSCmd, "%s") != 2 {
return fmt.Errorf("RatholeWSCommand must have exactly 2 %%s format verbs (url, toml); got %d in %q",
strings.Count(ratholeWSCmd, "%s"), p.config.Network.RatholeWSCommand)
}
mainCmd = fmt.Sprintf(ratholeWSCmd, ratholeURL, configB64)
}
Comment thread
dciangot marked this conversation as resolved.

// Remove any stale wstunnel annotation and set the rathole one
delete(pod.Annotations, annWSTunnelClientCmds)
pod.Annotations[annRatholeClientCmds] = mainCmd

default:
var rOptions []string
for _, port := range td.ExposedPorts {
if strings.ToUpper(port.Protocol) == "UDP" {
if strings.ToUpper(port.Protocol) == protocolUDP {
continue
}
rOptions = append(rOptions, fmt.Sprintf("-R tcp://0.0.0.0:%d:localhost:%d", port.Port, port.Port))
Expand Down Expand Up @@ -347,15 +455,16 @@ PersistentKeepalive = %d

// clearConflictingNetworkAnnotations removes generated annotations that are specific to
// the opposite network mode to keep pod network bootstrap behavior uniform.
// When fullMeshEnabledForPod is true, any stale wstunnel client command annotation is removed.
// When false, any stale WireGuard snippet annotation is removed.
// When fullMeshEnabledForPod is true, any stale wstunnel and rathole client command annotations
// are removed. When false, any stale WireGuard snippet annotation is removed.
func clearConflictingNetworkAnnotations(pod *v1.Pod, fullMeshEnabledForPod bool) {
if pod == nil || pod.Annotations == nil {
return
}

if fullMeshEnabledForPod {
delete(pod.Annotations, annWSTunnelClientCmds)
delete(pod.Annotations, annRatholeClientCmds)
return
}

Expand Down
18 changes: 18 additions & 0 deletions pkg/virtualkubelet/mesh_annotations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ func TestClearConflictingNetworkAnnotations(t *testing.T) {
assert.Equal(t, "value", pod.Annotations["keep"])
})

t.Run("full mesh also removes rathole command annotation", func(t *testing.T) {
pod := &v1.Pod{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
annRatholeClientCmds: "rathole-command",
annWGClientSnippet: "wireguard-snippet",
"keep": "value",
},
},
}

clearConflictingNetworkAnnotations(pod, true)

assert.NotContains(t, pod.Annotations, annRatholeClientCmds)
assert.Contains(t, pod.Annotations, annWGClientSnippet)
assert.Equal(t, "value", pod.Annotations["keep"])
})

t.Run("non mesh removes wireguard snippet annotation", func(t *testing.T) {
pod := &v1.Pod{
ObjectMeta: metav1.ObjectMeta{
Expand Down
Loading
Loading