From efdbf3b9b5788ff10707602ec173e86936278854 Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Sat, 11 Apr 2026 12:57:44 +0200 Subject: [PATCH 01/23] feat(tunnel): add WireGuard, STUN, and tunnel client libraries Userspace WireGuard (gvisor netstack), STUN discovery with Cloudflare/Google fallback, STUNBind UDP adapter, and GraphQL queries for tunnel lifecycle. Includes tests for WireGuard hex key encoding, STUN server parsing, and STUNBind endpoint handling. Co-authored-by: Thomas Krampl --- internal/tunnel/queries.go | 51 +++++++++ internal/tunnel/stun.go | 117 +++++++++++++++++++ internal/tunnel/stun_test.go | 128 +++++++++++++++++++++ internal/tunnel/stunbind.go | 182 ++++++++++++++++++++++++++++++ internal/tunnel/stunbind_test.go | 72 ++++++++++++ internal/tunnel/tunnel.go | 144 +++++++++++++++++++++++ internal/tunnel/wireguard.go | 103 +++++++++++++++++ internal/tunnel/wireguard_test.go | 28 +++++ 8 files changed, 825 insertions(+) create mode 100644 internal/tunnel/queries.go create mode 100644 internal/tunnel/stun.go create mode 100644 internal/tunnel/stun_test.go create mode 100644 internal/tunnel/stunbind.go create mode 100644 internal/tunnel/stunbind_test.go create mode 100644 internal/tunnel/tunnel.go create mode 100644 internal/tunnel/wireguard.go create mode 100644 internal/tunnel/wireguard_test.go diff --git a/internal/tunnel/queries.go b/internal/tunnel/queries.go new file mode 100644 index 00000000..361ebe2d --- /dev/null +++ b/internal/tunnel/queries.go @@ -0,0 +1,51 @@ +package tunnel + +// genqlient operation definitions — DO NOT REMOVE, used by go generate + +var _ = `# @genqlient +mutation CreateTunnel($input: CreateTunnelInput!) { + createTunnel(input: $input) { + tunnel { + id + phase + gatewayPublicKey + gatewaySTUNEndpoint + message + } + } +} +` + +var _ = `# @genqlient +query GetTunnel($teamSlug: Slug!, $environmentName: String!, $id: ID!) { + team(slug: $teamSlug) { + environment(name: $environmentName) { + tunnel(id: $id) { + id + phase + gatewayPublicKey + gatewaySTUNEndpoint + message + } + } + } +} +` + +var _ = `# @genqlient +mutation UpdateTunnelSTUNEndpoint($tunnelID: ID!, $clientSTUNEndpoint: String!) { + updateTunnelSTUNEndpoint(input: { tunnelID: $tunnelID, clientSTUNEndpoint: $clientSTUNEndpoint }) { + tunnel { + id + } + } +} +` + +var _ = `# @genqlient +mutation DeleteTunnel($tunnelID: ID!) { + deleteTunnel(input: { tunnelID: $tunnelID }) { + success + } +} +` diff --git a/internal/tunnel/stun.go b/internal/tunnel/stun.go new file mode 100644 index 00000000..b890ac2d --- /dev/null +++ b/internal/tunnel/stun.go @@ -0,0 +1,117 @@ +package tunnel + +import ( + "fmt" + "net" + "time" + + "github.com/pion/stun/v3" +) + +// defaultSTUNServers is the ordered list of STUN servers. +// Cloudflare is primary (anycast, Oslo/Stockholm PoP), Google servers are fallback. +var defaultSTUNServers = []string{ + "stun.cloudflare.com:3478", + "stun.l.google.com:19302", + "stun1.l.google.com:19302", + "stun2.l.google.com:19302", +} + +// DiscoverSTUNEndpoint performs STUN discovery to find the external UDP endpoint. +// Returns the discovered endpoint (ip:port string) and the UDP connection that was used. +// The caller must keep the connection open for hole-punching — close it only after the tunnel is established. +// +// Returns an error if: +// - All STUN servers fail (network issue) +// - Symmetric NAT detected (different endpoints returned by different servers) +func DiscoverSTUNEndpoint(listenPort int) (endpoint string, conn *net.UDPConn, err error) { + conn, err = net.ListenUDP("udp4", &net.UDPAddr{Port: listenPort}) + if err != nil { + return "", nil, fmt.Errorf("listen UDP for STUN: %w", err) + } + + endpoint, err = discoverFromServers(conn, defaultSTUNServers) + if err != nil { + conn.Close() + return "", nil, err + } + + return endpoint, conn, nil +} + +func discoverFromServers(conn *net.UDPConn, servers []string) (string, error) { + var lastErr error + var firstEndpoint string + + for i, server := range servers { + endpoint, err := discoverFromServer(conn, server) + if err != nil { + lastErr = fmt.Errorf("STUN server %s: %w", server, err) + continue + } + + if i == 0 { + firstEndpoint = endpoint + continue + } + + // Check for symmetric NAT: if two different servers report different ports, + // we have symmetric NAT and cannot hole-punch. + if endpoint != firstEndpoint { + return "", fmt.Errorf( + "Your network uses symmetric NAT which prevents direct tunnel connections. "+ + "Try from a different network or disable VPN. "+ + "(Server 1 reported %s, server 2 reported %s)", + firstEndpoint, endpoint, + ) + } + return firstEndpoint, nil + } + + // If we only tried one server successfully, return that + if firstEndpoint != "" { + return firstEndpoint, nil + } + + return "", fmt.Errorf("all STUN servers failed (check network connectivity): %w", lastErr) +} + +func discoverFromServer(conn *net.UDPConn, server string) (string, error) { + serverAddr, err := net.ResolveUDPAddr("udp4", server) + if err != nil { + return "", fmt.Errorf("resolve: %w", err) + } + + message := stun.MustBuild(stun.TransactionID, stun.BindingRequest) + + if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + return "", fmt.Errorf("set deadline: %w", err) + } + defer conn.SetDeadline(time.Time{}) + + if _, err := conn.WriteTo(message.Raw, serverAddr); err != nil { + return "", fmt.Errorf("send STUN request: %w", err) + } + + buf := make([]byte, 1024) + n, _, err := conn.ReadFromUDP(buf) + if err != nil { + return "", fmt.Errorf("read STUN response: %w", err) + } + + resp := &stun.Message{Raw: buf[:n]} + if err := resp.Decode(); err != nil { + return "", fmt.Errorf("decode STUN response: %w", err) + } + + var xorAddr stun.XORMappedAddress + if err := xorAddr.GetFrom(resp); err != nil { + var mappedAddr stun.MappedAddress + if err2 := mappedAddr.GetFrom(resp); err2 != nil { + return "", fmt.Errorf("get mapped address: %w", err) + } + return fmt.Sprintf("%s:%d", mappedAddr.IP.String(), mappedAddr.Port), nil + } + + return fmt.Sprintf("%s:%d", xorAddr.IP.String(), xorAddr.Port), nil +} diff --git a/internal/tunnel/stun_test.go b/internal/tunnel/stun_test.go new file mode 100644 index 00000000..659c2ac7 --- /dev/null +++ b/internal/tunnel/stun_test.go @@ -0,0 +1,128 @@ +package tunnel + +import ( + "net" + "regexp" + "strings" + "testing" + + "github.com/pion/stun/v3" +) + +func TestDiscoverSTUNEndpoint(t *testing.T) { + endpoint, conn, err := DiscoverSTUNEndpoint(0) + if err != nil { + t.Fatalf("discover STUN endpoint: %v", err) + } + defer conn.Close() + + pattern := regexp.MustCompile(`^\d+\.\d+\.\d+\.\d+:\d+$`) + if !pattern.MatchString(endpoint) { + t.Errorf("endpoint %q does not match x.x.x.x:port pattern", endpoint) + } + t.Logf("Discovered STUN endpoint: %s", endpoint) +} + +func startMockSTUNServer(t *testing.T, mappedIP string, mappedPort int) string { + t.Helper() + udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { udpConn.Close() }) + + go func() { + buf := make([]byte, 1024) + for { + n, addr, err := udpConn.ReadFromUDP(buf) + if err != nil { + return + } + req := &stun.Message{Raw: make([]byte, n)} + copy(req.Raw, buf[:n]) + if err := req.Decode(); err != nil { + continue + } + resp, err := stun.Build( + stun.NewTransactionIDSetter(req.TransactionID), + stun.NewType(stun.MethodBinding, stun.ClassSuccessResponse), + &stun.XORMappedAddress{ + IP: net.ParseIP(mappedIP), + Port: mappedPort, + }, + stun.Fingerprint, + ) + if err != nil { + continue + } + udpConn.WriteTo(resp.Raw, addr) + } + }() + + return udpConn.LocalAddr().String() +} + +func TestSymmetricNATDetection(t *testing.T) { + server1 := startMockSTUNServer(t, "1.2.3.4", 10000) + server2 := startMockSTUNServer(t, "1.2.3.4", 20000) + + localConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0}) + if err != nil { + t.Fatal(err) + } + defer localConn.Close() + + _, err = discoverFromServers(localConn, []string{server1, server2}) + if err == nil { + t.Fatal("expected symmetric NAT error, got nil") + } + if !strings.Contains(strings.ToLower(err.Error()), "symmetric nat") { + t.Errorf("expected error to mention symmetric NAT, got: %v", err) + } +} + +func TestAllSTUNServersFailed(t *testing.T) { + deadConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0}) + if err != nil { + t.Fatal(err) + } + deadServer := deadConn.LocalAddr().String() + deadConn.Close() + + localConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0}) + if err != nil { + t.Fatal(err) + } + defer localConn.Close() + + _, err = discoverFromServers(localConn, []string{deadServer}) + if err == nil { + t.Fatal("expected error when all STUN servers fail, got nil") + } + if !strings.Contains(strings.ToLower(err.Error()), "all stun servers failed") { + t.Errorf("expected 'all STUN servers failed' in error, got: %v", err) + } +} + +func TestSTUNEndpointFormat(t *testing.T) { + server := startMockSTUNServer(t, "5.6.7.8", 12345) + + localConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0}) + if err != nil { + t.Fatal(err) + } + defer localConn.Close() + + endpoint, err := discoverFromServer(localConn, server) + if err != nil { + t.Fatalf("discoverFromServer: %v", err) + } + + pattern := regexp.MustCompile(`^\d+\.\d+\.\d+\.\d+:\d+$`) + if !pattern.MatchString(endpoint) { + t.Errorf("endpoint %q does not match ip:port format", endpoint) + } + if endpoint != "5.6.7.8:12345" { + t.Errorf("expected endpoint 5.6.7.8:12345, got %q", endpoint) + } +} diff --git a/internal/tunnel/stunbind.go b/internal/tunnel/stunbind.go new file mode 100644 index 00000000..af28d3c2 --- /dev/null +++ b/internal/tunnel/stunbind.go @@ -0,0 +1,182 @@ +package tunnel + +import ( + "fmt" + "io" + "net" + "net/netip" + "sync" + + "golang.zx2c4.com/wireguard/conn" +) + +type STUNBind struct { + conn *net.UDPConn + closeOnce sync.Once +} + +type StdNetEndpoint net.UDPAddr + +func NewSTUNBind(udpConn *net.UDPConn) *STUNBind { + return &STUNBind{conn: udpConn} +} + +func (b *STUNBind) Open(port uint16) ([]conn.ReceiveFunc, uint16, error) { + _ = port + if b == nil || b.conn == nil { + return nil, 0, fmt.Errorf("nil UDP connection") + } + + localAddr, ok := b.conn.LocalAddr().(*net.UDPAddr) + if !ok || localAddr == nil { + return nil, 0, fmt.Errorf("local address is not UDP") + } + + recv := func(packets [][]byte, sizes []int, eps []conn.Endpoint) (int, error) { + if len(packets) == 0 || len(sizes) == 0 || len(eps) == 0 { + return 0, io.ErrShortBuffer + } + + n, addr, err := b.conn.ReadFromUDP(packets[0]) + if err != nil { + return 0, err + } + + sizes[0] = n + eps[0] = newStdNetEndpoint(addr) + return 1, nil + } + + return []conn.ReceiveFunc{recv}, uint16(localAddr.Port), nil +} + +func (b *STUNBind) Close() error { + if b == nil || b.conn == nil { + return nil + } + + var err error + b.closeOnce.Do(func() { + err = b.conn.Close() + }) + return err +} + +func (b *STUNBind) SetMark(mark uint32) error { + _ = mark + return nil +} + +func (b *STUNBind) Send(bufs [][]byte, ep conn.Endpoint) error { + if b == nil || b.conn == nil { + return fmt.Errorf("nil UDP connection") + } + + addr, err := udpAddrFromEndpoint(ep) + if err != nil { + return err + } + + for _, buf := range bufs { + if _, err := b.conn.WriteToUDP(buf, addr); err != nil { + return err + } + } + + return nil +} + +func (b *STUNBind) ParseEndpoint(s string) (conn.Endpoint, error) { + addr, err := net.ResolveUDPAddr("udp", s) + if err != nil { + return nil, err + } + return newStdNetEndpoint(addr), nil +} + +func (b *STUNBind) BatchSize() int { + return 1 +} + +func (e *StdNetEndpoint) ClearSrc() {} + +func (e *StdNetEndpoint) SrcToString() string { + return "" +} + +func (e *StdNetEndpoint) DstToString() string { + addr := (*net.UDPAddr)(e) + if addr == nil { + return "" + } + return addr.String() +} + +func (e *StdNetEndpoint) DstToBytes() []byte { + addr := (*net.UDPAddr)(e) + if addr == nil { + return nil + } + b, err := addr.AddrPort().MarshalBinary() + if err != nil { + return nil + } + return b +} + +func (e *StdNetEndpoint) DstIP() netip.Addr { + return udpAddrIP((*net.UDPAddr)(e)) +} + +func (e *StdNetEndpoint) SrcIP() netip.Addr { + return netip.Addr{} +} + +func newStdNetEndpoint(addr *net.UDPAddr) *StdNetEndpoint { + if addr == nil { + return nil + } + + clone := *addr + if addr.IP != nil { + clone.IP = append(net.IP(nil), addr.IP...) + } + + return (*StdNetEndpoint)(&clone) +} + +func udpAddrFromEndpoint(ep conn.Endpoint) (*net.UDPAddr, error) { + switch e := ep.(type) { + case *StdNetEndpoint: + if e == nil { + return nil, fmt.Errorf("nil endpoint") + } + addr := (*net.UDPAddr)(e) + clone := *addr + if addr.IP != nil { + clone.IP = append(net.IP(nil), addr.IP...) + } + return &clone, nil + case interface{ DstToString() string }: + addr, err := net.ResolveUDPAddr("udp", e.DstToString()) + if err != nil { + return nil, fmt.Errorf("resolve endpoint %q: %w", e.DstToString(), err) + } + return addr, nil + default: + return nil, fmt.Errorf("unsupported endpoint type %T", ep) + } +} + +func udpAddrIP(addr *net.UDPAddr) netip.Addr { + if addr == nil { + return netip.Addr{} + } + + ip, ok := netip.AddrFromSlice(addr.IP) + if !ok { + return netip.Addr{} + } + + return ip.Unmap() +} diff --git a/internal/tunnel/stunbind_test.go b/internal/tunnel/stunbind_test.go new file mode 100644 index 00000000..7657d5ad --- /dev/null +++ b/internal/tunnel/stunbind_test.go @@ -0,0 +1,72 @@ +package tunnel + +import ( + "net" + "testing" +) + +func TestSTUNBindOpen(t *testing.T) { + udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0}) + if err != nil { + t.Fatal(err) + } + expectedPort := uint16(udpConn.LocalAddr().(*net.UDPAddr).Port) + + bind := NewSTUNBind(udpConn) + recvFuncs, port, err := bind.Open(0) + if err != nil { + t.Fatalf("Open: %v", err) + } + if len(recvFuncs) == 0 { + t.Error("expected non-empty receive functions slice") + } + if port != expectedPort { + t.Errorf("expected port %d, got %d", expectedPort, port) + } + + bind.Close() +} + +func TestSTUNBindClose(t *testing.T) { + udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0}) + if err != nil { + t.Fatal(err) + } + + bind := NewSTUNBind(udpConn) + _, _, err = bind.Open(0) + if err != nil { + t.Fatalf("Open: %v", err) + } + + if err := bind.Close(); err != nil { + t.Errorf("Close returned unexpected error: %v", err) + } + + if err := bind.Close(); err != nil { + t.Errorf("second Close returned unexpected error: %v", err) + } +} + +func TestSTUNBindParseEndpoint(t *testing.T) { + udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0}) + if err != nil { + t.Fatal(err) + } + defer udpConn.Close() + + bind := NewSTUNBind(udpConn) + + input := "1.2.3.4:5678" + ep, err := bind.ParseEndpoint(input) + if err != nil { + t.Fatalf("ParseEndpoint(%q): %v", input, err) + } + if ep == nil { + t.Fatal("ParseEndpoint returned nil endpoint") + } + got := ep.DstToString() + if got != input { + t.Errorf("DstToString() = %q, want %q", got, input) + } +} diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go new file mode 100644 index 00000000..ff97ce4f --- /dev/null +++ b/internal/tunnel/tunnel.go @@ -0,0 +1,144 @@ +package tunnel + +import ( + "context" + "fmt" + "time" + + "github.com/nais/cli/internal/naisapi" + "github.com/nais/cli/internal/naisapi/gql" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +type TunnelInfo struct { + TunnelID string + GatewayPublicKey wgtypes.Key + GatewayEndpoint string + PrivateKey wgtypes.Key + WireGuardTunnel *WireGuardTunnel +} + +type Config struct { + TeamSlug string + Environment string + InstanceName string + ListenAddr string + TargetHost string + TargetPort int +} + +func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (*TunnelInfo, error) { + privateKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + return nil, fmt.Errorf("generate wireguard key: %w", err) + } + publicKey := privateKey.PublicKey() + + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return nil, fmt.Errorf("get graphql client: %w", err) + } + + progress("Creating tunnel...") + + createResp, err := gql.CreateTunnel(ctx, client, gql.CreateTunnelInput{ + TeamSlug: cfg.TeamSlug, + EnvironmentName: cfg.Environment, + InstanceName: cfg.InstanceName, + TargetHost: cfg.TargetHost, + TargetPort: cfg.TargetPort, + ClientPublicKey: publicKey.String(), + }) + if err != nil { + return nil, fmt.Errorf("create tunnel: %w", err) + } + tunnelID := createResp.CreateTunnel.Tunnel.Id + + progress("Gateway starting...") + + timeout := time.After(60 * time.Second) + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + var gatewayPublicKey string + var gatewaySTUNEndpoint string + + for { + select { + case <-timeout: + return nil, fmt.Errorf("gateway did not become ready within 60s — try again or check cluster status") + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + pollResp, err := gql.GetTunnel(ctx, client, cfg.TeamSlug, cfg.Environment, tunnelID) + if err != nil { + return nil, fmt.Errorf("poll tunnel status: %w", err) + } + t := pollResp.Team.Environment.Tunnel + if t.Id == "" { + continue + } + switch t.Phase { + case gql.TunnelPhaseReady, gql.TunnelPhaseConnected: + gatewayPublicKey = t.GatewayPublicKey + gatewaySTUNEndpoint = t.GatewaySTUNEndpoint + progress("Gateway ready!") + case gql.TunnelPhaseFailed: + return nil, fmt.Errorf("gateway failed to start: %s", t.Message) + case gql.TunnelPhaseTerminated: + return nil, fmt.Errorf("gateway terminated unexpectedly: %s", t.Message) + default: + progress(fmt.Sprintf("Gateway phase: %s...", t.Phase)) + continue + } + } + if gatewayPublicKey != "" { + break + } + } + + progress("Discovering STUN endpoint...") + stunEndpoint, stunConn, err := DiscoverSTUNEndpoint(0) + if err != nil { + return nil, fmt.Errorf("STUN discovery failed: %w", err) + } + stunConnOwned := true + defer func() { + if stunConnOwned { + _ = stunConn.Close() + } + }() + + _, err = gql.UpdateTunnelSTUNEndpoint(ctx, client, tunnelID, stunEndpoint) + if err != nil { + return nil, fmt.Errorf("update STUN endpoint: %w", err) + } + + gwKey, err := wgtypes.ParseKey(gatewayPublicKey) + if err != nil { + return nil, fmt.Errorf("parse gateway public key: %w", err) + } + + wgTunnel, err := SetupWireGuardWithConn(privateKey, gwKey, gatewaySTUNEndpoint, stunConn) + if err != nil { + return nil, fmt.Errorf("setup wireguard: %w", err) + } + stunConnOwned = false + + return &TunnelInfo{ + TunnelID: tunnelID, + GatewayPublicKey: gwKey, + GatewayEndpoint: gatewaySTUNEndpoint, + PrivateKey: privateKey, + WireGuardTunnel: wgTunnel, + }, nil +} + +func DeleteTunnel(ctx context.Context, tunnelID string) error { + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return fmt.Errorf("get graphql client: %w", err) + } + _, err = gql.DeleteTunnel(ctx, client, tunnelID) + return err +} diff --git a/internal/tunnel/wireguard.go b/internal/tunnel/wireguard.go new file mode 100644 index 00000000..bfab3a95 --- /dev/null +++ b/internal/tunnel/wireguard.go @@ -0,0 +1,103 @@ +package tunnel + +import ( + "encoding/hex" + "fmt" + "net" + "net/netip" + + "golang.zx2c4.com/wireguard/conn" + "golang.zx2c4.com/wireguard/device" + "golang.zx2c4.com/wireguard/tun/netstack" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +const ( + // TunnelIPClient is the WireGuard tunnel IP for the CLI side. + TunnelIPClient = "10.0.0.1/30" + // TunnelIPGateway is the WireGuard tunnel IP for the gateway side. + TunnelIPGateway = "10.0.0.2/30" + // PersistentKeepalive in seconds — keeps NAT mappings alive (< Cloud NAT 30s timeout). + PersistentKeepalive = 20 +) + +// WireGuardTunnel wraps a userspace WireGuard device with an associated netstack. +type WireGuardTunnel struct { + dev *device.Device + net *netstack.Net +} + +// SetupWireGuard creates a userspace WireGuard device for the CLI side of the tunnel. +// privateKey: CLI's WireGuard private key +// gatewayPublicKey: gateway's WireGuard public key +// gatewayEndpoint: gateway's UDP endpoint (ip:port) discovered via STUN +func SetupWireGuard(privateKey wgtypes.Key, gatewayPublicKey wgtypes.Key, gatewayEndpoint string) (*WireGuardTunnel, error) { + return setupWireGuard(privateKey, gatewayPublicKey, gatewayEndpoint, conn.NewDefaultBind(), "listen_port=0\n") +} + +// SetupWireGuardWithConn creates a userspace WireGuard device using an existing STUN UDP socket. +func SetupWireGuardWithConn(privateKey wgtypes.Key, gatewayPublicKey wgtypes.Key, gatewayEndpoint string, stunConn *net.UDPConn) (*WireGuardTunnel, error) { + return setupWireGuard(privateKey, gatewayPublicKey, gatewayEndpoint, NewSTUNBind(stunConn), "") +} + +func setupWireGuard(privateKey wgtypes.Key, gatewayPublicKey wgtypes.Key, gatewayEndpoint string, bind conn.Bind, listenPortConfig string) (*WireGuardTunnel, error) { + prefix, err := netip.ParsePrefix(TunnelIPClient) + if err != nil { + return nil, fmt.Errorf("parse tunnel IP: %w", err) + } + + tun, net, err := netstack.CreateNetTUN( + []netip.Addr{prefix.Addr()}, + []netip.Addr{}, // no DNS + 1420, // MTU + ) + if err != nil { + return nil, fmt.Errorf("create netstack TUN: %w", err) + } + if bind == nil { + return nil, fmt.Errorf("wireguard bind is nil") + } + + logger := device.NewLogger(device.LogLevelError, "[wireguard-cli] ") + dev := device.NewDevice(tun, bind, logger) + + cfg := fmt.Sprintf(`private_key=%s +%spublic_key=%s +persistent_keepalive_interval=%d +allowed_ip=0.0.0.0/0 +endpoint=%s +`, encodeKeyHex(privateKey), listenPortConfig, encodeKeyHex(gatewayPublicKey), PersistentKeepalive, gatewayEndpoint) + + if err := dev.IpcSet(cfg); err != nil { + dev.Close() + return nil, fmt.Errorf("configure wireguard: %w", err) + } + + if err := dev.Up(); err != nil { + dev.Close() + return nil, fmt.Errorf("bring up wireguard: %w", err) + } + + return &WireGuardTunnel{dev: dev, net: net}, nil +} + +// Net returns the netstack network for creating TCP connections through the tunnel. +func (t *WireGuardTunnel) Net() *netstack.Net { + return t.net +} + +// DialTCP creates a TCP connection through the WireGuard tunnel. +func (t *WireGuardTunnel) DialTCP(addr string) (net.Conn, error) { + return t.net.Dial("tcp", addr) +} + +// Close shuts down the WireGuard device cleanly. +func (t *WireGuardTunnel) Close() { + if t != nil && t.dev != nil { + t.dev.Close() + } +} + +func encodeKeyHex(key wgtypes.Key) string { + return hex.EncodeToString(key[:]) +} diff --git a/internal/tunnel/wireguard_test.go b/internal/tunnel/wireguard_test.go new file mode 100644 index 00000000..f2f2e471 --- /dev/null +++ b/internal/tunnel/wireguard_test.go @@ -0,0 +1,28 @@ +package tunnel + +import ( + "testing" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +func TestSetupWireGuard(t *testing.T) { + privKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + peerKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + t.Fatalf("generate peer key: %v", err) + } + + tun, err := SetupWireGuard(privKey, peerKey.PublicKey(), "127.0.0.1:51820") + if err != nil { + t.Fatalf("setup wireguard: %v", err) + } + defer tun.Close() + + if tun.Net() == nil { + t.Error("expected non-nil net") + } +} From 74d04b6ffec21130b8fef01069ff07ec15136476 Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Sat, 11 Apr 2026 12:57:52 +0200 Subject: [PATCH 02/23] feat(tunnel): add GraphQL schema and regenerate client Tunnel types, mutations, and queries added to schema.graphql. genqlient config updated to include tunnel operations directory. Client code regenerated with tunnel CreateTunnel, UpdateSTUNEndpoint, DeleteTunnel, and GetTunnel operations. Co-authored-by: Thomas Krampl --- internal/naisapi/gql/generated.go | 404 ++++++++++++++++++++++++++++++ schema.graphql | 63 +++++ 2 files changed, 467 insertions(+) diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index 587cbfe5..8248246c 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -790,6 +790,81 @@ func (v *CreateSecretResponse) GetCreateSecret() CreateSecretCreateSecretCreateS return v.CreateSecret } +// CreateTunnelCreateTunnelCreateTunnelPayload includes the requested fields of the GraphQL type CreateTunnelPayload. +type CreateTunnelCreateTunnelCreateTunnelPayload struct { + Tunnel CreateTunnelCreateTunnelCreateTunnelPayloadTunnel `json:"tunnel"` +} + +// GetTunnel returns CreateTunnelCreateTunnelCreateTunnelPayload.Tunnel, and is useful for accessing the field via an interface. +func (v *CreateTunnelCreateTunnelCreateTunnelPayload) GetTunnel() CreateTunnelCreateTunnelCreateTunnelPayloadTunnel { + return v.Tunnel +} + +// CreateTunnelCreateTunnelCreateTunnelPayloadTunnel includes the requested fields of the GraphQL type Tunnel. +type CreateTunnelCreateTunnelCreateTunnelPayloadTunnel struct { + Id string `json:"id"` + Phase TunnelPhase `json:"phase"` + GatewayPublicKey string `json:"gatewayPublicKey"` + GatewaySTUNEndpoint string `json:"gatewaySTUNEndpoint"` + Message string `json:"message"` +} + +// GetId returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.Id, and is useful for accessing the field via an interface. +func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetId() string { return v.Id } + +// GetPhase returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.Phase, and is useful for accessing the field via an interface. +func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetPhase() TunnelPhase { return v.Phase } + +// GetGatewayPublicKey returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.GatewayPublicKey, and is useful for accessing the field via an interface. +func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetGatewayPublicKey() string { + return v.GatewayPublicKey +} + +// GetGatewaySTUNEndpoint returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.GatewaySTUNEndpoint, and is useful for accessing the field via an interface. +func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetGatewaySTUNEndpoint() string { + return v.GatewaySTUNEndpoint +} + +// GetMessage returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.Message, and is useful for accessing the field via an interface. +func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetMessage() string { return v.Message } + +type CreateTunnelInput struct { + TeamSlug string `json:"teamSlug"` + EnvironmentName string `json:"environmentName"` + InstanceName string `json:"instanceName"` + TargetHost string `json:"targetHost"` + TargetPort int `json:"targetPort"` + ClientPublicKey string `json:"clientPublicKey"` +} + +// GetTeamSlug returns CreateTunnelInput.TeamSlug, and is useful for accessing the field via an interface. +func (v *CreateTunnelInput) GetTeamSlug() string { return v.TeamSlug } + +// GetEnvironmentName returns CreateTunnelInput.EnvironmentName, and is useful for accessing the field via an interface. +func (v *CreateTunnelInput) GetEnvironmentName() string { return v.EnvironmentName } + +// GetInstanceName returns CreateTunnelInput.InstanceName, and is useful for accessing the field via an interface. +func (v *CreateTunnelInput) GetInstanceName() string { return v.InstanceName } + +// GetTargetHost returns CreateTunnelInput.TargetHost, and is useful for accessing the field via an interface. +func (v *CreateTunnelInput) GetTargetHost() string { return v.TargetHost } + +// GetTargetPort returns CreateTunnelInput.TargetPort, and is useful for accessing the field via an interface. +func (v *CreateTunnelInput) GetTargetPort() int { return v.TargetPort } + +// GetClientPublicKey returns CreateTunnelInput.ClientPublicKey, and is useful for accessing the field via an interface. +func (v *CreateTunnelInput) GetClientPublicKey() string { return v.ClientPublicKey } + +// CreateTunnelResponse is returned by CreateTunnel on success. +type CreateTunnelResponse struct { + CreateTunnel CreateTunnelCreateTunnelCreateTunnelPayload `json:"createTunnel"` +} + +// GetCreateTunnel returns CreateTunnelResponse.CreateTunnel, and is useful for accessing the field via an interface. +func (v *CreateTunnelResponse) GetCreateTunnel() CreateTunnelCreateTunnelCreateTunnelPayload { + return v.CreateTunnel +} + // CreateValkeyCreateValkeyCreateValkeyPayload includes the requested fields of the GraphQL type CreateValkeyPayload. type CreateValkeyCreateValkeyCreateValkeyPayload struct { // Valkey instance that was created. @@ -984,6 +1059,24 @@ func (v *DeleteSecretResponse) GetDeleteSecret() DeleteSecretDeleteSecretDeleteS return v.DeleteSecret } +// DeleteTunnelDeleteTunnelDeleteTunnelPayload includes the requested fields of the GraphQL type DeleteTunnelPayload. +type DeleteTunnelDeleteTunnelDeleteTunnelPayload struct { + Success bool `json:"success"` +} + +// GetSuccess returns DeleteTunnelDeleteTunnelDeleteTunnelPayload.Success, and is useful for accessing the field via an interface. +func (v *DeleteTunnelDeleteTunnelDeleteTunnelPayload) GetSuccess() bool { return v.Success } + +// DeleteTunnelResponse is returned by DeleteTunnel on success. +type DeleteTunnelResponse struct { + DeleteTunnel DeleteTunnelDeleteTunnelDeleteTunnelPayload `json:"deleteTunnel"` +} + +// GetDeleteTunnel returns DeleteTunnelResponse.DeleteTunnel, and is useful for accessing the field via an interface. +func (v *DeleteTunnelResponse) GetDeleteTunnel() DeleteTunnelDeleteTunnelDeleteTunnelPayload { + return v.DeleteTunnel +} + // DeleteValkeyDeleteValkeyDeleteValkeyPayload includes the requested fields of the GraphQL type DeleteValkeyPayload. type DeleteValkeyDeleteValkeyDeleteValkeyPayload struct { // Whether or not the job was deleted. @@ -27532,6 +27625,65 @@ func (v *GetTeamVulnerabilitySummaryTeamVulnerabilitySummary) GetLastUpdated() t return v.LastUpdated } +// GetTunnelResponse is returned by GetTunnel on success. +type GetTunnelResponse struct { + // Get a team by its slug. + Team GetTunnelTeam `json:"team"` +} + +// GetTeam returns GetTunnelResponse.Team, and is useful for accessing the field via an interface. +func (v *GetTunnelResponse) GetTeam() GetTunnelTeam { return v.Team } + +// GetTunnelTeam includes the requested fields of the GraphQL type Team. +// The GraphQL type's documentation follows. +// +// The team type represents a team on the [Nais platform](https://nais.io/). +// +// Learn more about what Nais teams are and what they can be used for in the [official Nais documentation](https://docs.nais.io/explanations/team/). +// +// External resources (e.g. entraIDGroupID, gitHubTeamSlug) are managed by [Nais API reconcilers](https://github.com/nais/api-reconcilers). +type GetTunnelTeam struct { + // Get a specific environment for the team. + Environment GetTunnelTeamEnvironment `json:"environment"` +} + +// GetEnvironment returns GetTunnelTeam.Environment, and is useful for accessing the field via an interface. +func (v *GetTunnelTeam) GetEnvironment() GetTunnelTeamEnvironment { return v.Environment } + +// GetTunnelTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. +type GetTunnelTeamEnvironment struct { + Tunnel GetTunnelTeamEnvironmentTunnel `json:"tunnel"` +} + +// GetTunnel returns GetTunnelTeamEnvironment.Tunnel, and is useful for accessing the field via an interface. +func (v *GetTunnelTeamEnvironment) GetTunnel() GetTunnelTeamEnvironmentTunnel { return v.Tunnel } + +// GetTunnelTeamEnvironmentTunnel includes the requested fields of the GraphQL type Tunnel. +type GetTunnelTeamEnvironmentTunnel struct { + Id string `json:"id"` + Phase TunnelPhase `json:"phase"` + GatewayPublicKey string `json:"gatewayPublicKey"` + GatewaySTUNEndpoint string `json:"gatewaySTUNEndpoint"` + Message string `json:"message"` +} + +// GetId returns GetTunnelTeamEnvironmentTunnel.Id, and is useful for accessing the field via an interface. +func (v *GetTunnelTeamEnvironmentTunnel) GetId() string { return v.Id } + +// GetPhase returns GetTunnelTeamEnvironmentTunnel.Phase, and is useful for accessing the field via an interface. +func (v *GetTunnelTeamEnvironmentTunnel) GetPhase() TunnelPhase { return v.Phase } + +// GetGatewayPublicKey returns GetTunnelTeamEnvironmentTunnel.GatewayPublicKey, and is useful for accessing the field via an interface. +func (v *GetTunnelTeamEnvironmentTunnel) GetGatewayPublicKey() string { return v.GatewayPublicKey } + +// GetGatewaySTUNEndpoint returns GetTunnelTeamEnvironmentTunnel.GatewaySTUNEndpoint, and is useful for accessing the field via an interface. +func (v *GetTunnelTeamEnvironmentTunnel) GetGatewaySTUNEndpoint() string { + return v.GatewaySTUNEndpoint +} + +// GetMessage returns GetTunnelTeamEnvironmentTunnel.Message, and is useful for accessing the field via an interface. +func (v *GetTunnelTeamEnvironmentTunnel) GetMessage() string { return v.Message } + // GetValkeyResponse is returned by GetValkey on success. type GetValkeyResponse struct { // Get a team by its slug. @@ -30875,6 +31027,26 @@ type TriggerJobTriggerJobTriggerJobPayloadJobRun struct { // GetName returns TriggerJobTriggerJobTriggerJobPayloadJobRun.Name, and is useful for accessing the field via an interface. func (v *TriggerJobTriggerJobTriggerJobPayloadJobRun) GetName() string { return v.Name } +type TunnelPhase string + +const ( + TunnelPhasePending TunnelPhase = "PENDING" + TunnelPhaseProvisioning TunnelPhase = "PROVISIONING" + TunnelPhaseReady TunnelPhase = "READY" + TunnelPhaseConnected TunnelPhase = "CONNECTED" + TunnelPhaseFailed TunnelPhase = "FAILED" + TunnelPhaseTerminated TunnelPhase = "TERMINATED" +) + +var AllTunnelPhase = []TunnelPhase{ + TunnelPhasePending, + TunnelPhaseProvisioning, + TunnelPhaseReady, + TunnelPhaseConnected, + TunnelPhaseFailed, + TunnelPhaseTerminated, +} + // UpdateConfigValueResponse is returned by UpdateConfigValue on success. type UpdateConfigValueResponse struct { // Update a value within a config. @@ -30999,6 +31171,36 @@ func (v *UpdateSecretValueUpdateSecretValueUpdateSecretValuePayloadSecret) GetNa return v.Name } +// UpdateTunnelSTUNEndpointResponse is returned by UpdateTunnelSTUNEndpoint on success. +type UpdateTunnelSTUNEndpointResponse struct { + UpdateTunnelSTUNEndpoint UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayload `json:"updateTunnelSTUNEndpoint"` +} + +// GetUpdateTunnelSTUNEndpoint returns UpdateTunnelSTUNEndpointResponse.UpdateTunnelSTUNEndpoint, and is useful for accessing the field via an interface. +func (v *UpdateTunnelSTUNEndpointResponse) GetUpdateTunnelSTUNEndpoint() UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayload { + return v.UpdateTunnelSTUNEndpoint +} + +// UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayload includes the requested fields of the GraphQL type UpdateTunnelSTUNEndpointPayload. +type UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayload struct { + Tunnel UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayloadTunnel `json:"tunnel"` +} + +// GetTunnel returns UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayload.Tunnel, and is useful for accessing the field via an interface. +func (v *UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayload) GetTunnel() UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayloadTunnel { + return v.Tunnel +} + +// UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayloadTunnel includes the requested fields of the GraphQL type Tunnel. +type UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayloadTunnel struct { + Id string `json:"id"` +} + +// GetId returns UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayloadTunnel.Id, and is useful for accessing the field via an interface. +func (v *UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayloadTunnel) GetId() string { + return v.Id +} + // UpdateValkeyResponse is returned by UpdateValkey on success. type UpdateValkeyResponse struct { // Update an existing Valkey instance. @@ -31664,6 +31866,14 @@ func (v *__CreateSecretInput) GetEnvironment() string { return v.Environment } // GetTeam returns __CreateSecretInput.Team, and is useful for accessing the field via an interface. func (v *__CreateSecretInput) GetTeam() string { return v.Team } +// __CreateTunnelInput is used internally by genqlient +type __CreateTunnelInput struct { + Input CreateTunnelInput `json:"input"` +} + +// GetInput returns __CreateTunnelInput.Input, and is useful for accessing the field via an interface. +func (v *__CreateTunnelInput) GetInput() CreateTunnelInput { return v.Input } + // __CreateValkeyCredentialsInput is used internally by genqlient type __CreateValkeyCredentialsInput struct { TeamSlug string `json:"teamSlug"` @@ -31780,6 +31990,14 @@ func (v *__DeleteSecretInput) GetEnvironment() string { return v.Environment } // GetTeam returns __DeleteSecretInput.Team, and is useful for accessing the field via an interface. func (v *__DeleteSecretInput) GetTeam() string { return v.Team } +// __DeleteTunnelInput is used internally by genqlient +type __DeleteTunnelInput struct { + TunnelID string `json:"tunnelID"` +} + +// GetTunnelID returns __DeleteTunnelInput.TunnelID, and is useful for accessing the field via an interface. +func (v *__DeleteTunnelInput) GetTunnelID() string { return v.TunnelID } + // __DeleteValkeyInput is used internally by genqlient type __DeleteValkeyInput struct { Name string `json:"name"` @@ -32212,6 +32430,22 @@ func (v *__GetTeamVulnerabilitySummaryInput) GetFilter() *TeamVulnerabilitySumma return v.Filter } +// __GetTunnelInput is used internally by genqlient +type __GetTunnelInput struct { + TeamSlug string `json:"teamSlug"` + EnvironmentName string `json:"environmentName"` + Id string `json:"id"` +} + +// GetTeamSlug returns __GetTunnelInput.TeamSlug, and is useful for accessing the field via an interface. +func (v *__GetTunnelInput) GetTeamSlug() string { return v.TeamSlug } + +// GetEnvironmentName returns __GetTunnelInput.EnvironmentName, and is useful for accessing the field via an interface. +func (v *__GetTunnelInput) GetEnvironmentName() string { return v.EnvironmentName } + +// GetId returns __GetTunnelInput.Id, and is useful for accessing the field via an interface. +func (v *__GetTunnelInput) GetId() string { return v.Id } + // __GetValkeyInput is used internally by genqlient type __GetValkeyInput struct { Name string `json:"name"` @@ -32534,6 +32768,18 @@ func (v *__UpdateSecretValueInput) GetTeam() string { return v.Team } // GetValue returns __UpdateSecretValueInput.Value, and is useful for accessing the field via an interface. func (v *__UpdateSecretValueInput) GetValue() SecretValueInput { return v.Value } +// __UpdateTunnelSTUNEndpointInput is used internally by genqlient +type __UpdateTunnelSTUNEndpointInput struct { + TunnelID string `json:"tunnelID"` + ClientSTUNEndpoint string `json:"clientSTUNEndpoint"` +} + +// GetTunnelID returns __UpdateTunnelSTUNEndpointInput.TunnelID, and is useful for accessing the field via an interface. +func (v *__UpdateTunnelSTUNEndpointInput) GetTunnelID() string { return v.TunnelID } + +// GetClientSTUNEndpoint returns __UpdateTunnelSTUNEndpointInput.ClientSTUNEndpoint, and is useful for accessing the field via an interface. +func (v *__UpdateTunnelSTUNEndpointInput) GetClientSTUNEndpoint() string { return v.ClientSTUNEndpoint } + // __UpdateValkeyInput is used internally by genqlient type __UpdateValkeyInput struct { Name string `json:"name,omitempty"` @@ -32965,6 +33211,46 @@ func CreateSecret( return data_, err_ } +// The mutation executed by CreateTunnel. +const CreateTunnel_Operation = ` +mutation CreateTunnel ($input: CreateTunnelInput!) { + createTunnel(input: $input) { + tunnel { + id + phase + gatewayPublicKey + gatewaySTUNEndpoint + message + } + } +} +` + +func CreateTunnel( + ctx_ context.Context, + client_ graphql.Client, + input CreateTunnelInput, +) (data_ *CreateTunnelResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "CreateTunnel", + Query: CreateTunnel_Operation, + Variables: &__CreateTunnelInput{ + Input: input, + }, + } + + data_ = &CreateTunnelResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The mutation executed by CreateValkey. const CreateValkey_Operation = ` mutation CreateValkey ($name: String!, $environmentName: String!, $teamSlug: Slug!, $memory: ValkeyMemory!, $tier: ValkeyTier!, $maxMemoryPolicy: ValkeyMaxMemoryPolicy) { @@ -33212,6 +33498,40 @@ func DeleteSecret( return data_, err_ } +// The mutation executed by DeleteTunnel. +const DeleteTunnel_Operation = ` +mutation DeleteTunnel ($tunnelID: ID!) { + deleteTunnel(input: {tunnelID:$tunnelID}) { + success + } +} +` + +func DeleteTunnel( + ctx_ context.Context, + client_ graphql.Client, + tunnelID string, +) (data_ *DeleteTunnelResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "DeleteTunnel", + Query: DeleteTunnel_Operation, + Variables: &__DeleteTunnelInput{ + TunnelID: tunnelID, + }, + } + + data_ = &DeleteTunnelResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The mutation executed by DeleteValkey. const DeleteValkey_Operation = ` mutation DeleteValkey ($name: String!, $environmentName: String!, $teamSlug: Slug!) { @@ -35005,6 +35325,52 @@ func GetTeamVulnerabilitySummary( return data_, err_ } +// The query executed by GetTunnel. +const GetTunnel_Operation = ` +query GetTunnel ($teamSlug: Slug!, $environmentName: String!, $id: ID!) { + team(slug: $teamSlug) { + environment(name: $environmentName) { + tunnel(id: $id) { + id + phase + gatewayPublicKey + gatewaySTUNEndpoint + message + } + } + } +} +` + +func GetTunnel( + ctx_ context.Context, + client_ graphql.Client, + teamSlug string, + environmentName string, + id string, +) (data_ *GetTunnelResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetTunnel", + Query: GetTunnel_Operation, + Variables: &__GetTunnelInput{ + TeamSlug: teamSlug, + EnvironmentName: environmentName, + Id: id, + }, + } + + data_ = &GetTunnelResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by GetValkey. const GetValkey_Operation = ` query GetValkey ($name: String!, $environmentName: String!, $teamSlug: Slug!) { @@ -35968,6 +36334,44 @@ func UpdateSecretValue( return data_, err_ } +// The mutation executed by UpdateTunnelSTUNEndpoint. +const UpdateTunnelSTUNEndpoint_Operation = ` +mutation UpdateTunnelSTUNEndpoint ($tunnelID: ID!, $clientSTUNEndpoint: String!) { + updateTunnelSTUNEndpoint(input: {tunnelID:$tunnelID,clientSTUNEndpoint:$clientSTUNEndpoint}) { + tunnel { + id + } + } +} +` + +func UpdateTunnelSTUNEndpoint( + ctx_ context.Context, + client_ graphql.Client, + tunnelID string, + clientSTUNEndpoint string, +) (data_ *UpdateTunnelSTUNEndpointResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "UpdateTunnelSTUNEndpoint", + Query: UpdateTunnelSTUNEndpoint_Operation, + Variables: &__UpdateTunnelSTUNEndpointInput{ + TunnelID: tunnelID, + ClientSTUNEndpoint: clientSTUNEndpoint, + }, + } + + data_ = &UpdateTunnelSTUNEndpointResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The mutation executed by UpdateValkey. const UpdateValkey_Operation = ` mutation UpdateValkey ($name: String!, $environmentName: String!, $teamSlug: Slug!, $memory: ValkeyMemory!, $tier: ValkeyTier!, $maxMemoryPolicy: ValkeyMaxMemoryPolicy) { diff --git a/schema.graphql b/schema.graphql index cd519f0a..80d7b870 100644 --- a/schema.graphql +++ b/schema.graphql @@ -12925,4 +12925,67 @@ The vulnerability. node: WorkloadWithVulnerability! } +enum TunnelPhase { + PENDING + PROVISIONING + READY + CONNECTED + FAILED + TERMINATED +} + +type TunnelTarget { + host: String! + port: Int! +} + +type Tunnel implements Node { + id: ID! + phase: TunnelPhase! + gatewayPublicKey: String! + gatewaySTUNEndpoint: String! + target: TunnelTarget! + message: String! + createdAt: Time! +} + +input CreateTunnelInput { + teamSlug: Slug! + environmentName: String! + instanceName: String! + targetHost: String! + targetPort: Int! + clientPublicKey: String! +} + +type CreateTunnelPayload { + tunnel: Tunnel! +} + +input UpdateTunnelSTUNEndpointInput { + tunnelID: ID! + clientSTUNEndpoint: String! +} + +type UpdateTunnelSTUNEndpointPayload { + tunnel: Tunnel! +} + +input DeleteTunnelInput { + tunnelID: ID! +} + +type DeleteTunnelPayload { + success: Boolean! +} + +extend type Mutation { + createTunnel(input: CreateTunnelInput!): CreateTunnelPayload! + updateTunnelSTUNEndpoint(input: UpdateTunnelSTUNEndpointInput!): UpdateTunnelSTUNEndpointPayload! + deleteTunnel(input: DeleteTunnelInput!): DeleteTunnelPayload! +} + +extend type TeamEnvironment { + tunnel(id: ID!): Tunnel +} From e2fad2a1ab37ea5a50597b8a00e5fcd58cfe798d Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Sat, 11 Apr 2026 12:58:01 +0200 Subject: [PATCH 03/23] feat(tunnel): add valkey proxy command with WireGuard tunnel New 'nais valkey proxy' subcommand that creates a point-to-point WireGuard tunnel to a Valkey instance via the tunnel operator gateway. Establishes userspace WireGuard with STUN hole-punching and exposes a local TCP listener. Adds wireguard-go, pion/stun, and gvisor dependencies. Co-authored-by: Thomas Krampl --- go.mod | 21 ++-- go.sum | 48 ++++++--- internal/valkey/command/command.go | 1 + internal/valkey/command/flag/flag.go | 6 ++ internal/valkey/command/proxy.go | 147 +++++++++++++++++++++++++++ 5 files changed, 203 insertions(+), 20 deletions(-) create mode 100644 internal/valkey/command/proxy.go diff --git a/go.mod b/go.mod index 08ab82b9..3586d3db 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/nais/krakend/pkg/migration v0.0.0-20260512141116-49be12bd3d09 github.com/nais/liberator v0.0.0-20260526061822-791cc0e0457c github.com/nais/naistrix v0.32.1 + github.com/pion/stun/v3 v3.1.2 github.com/pkg/errors v0.9.1 github.com/pterm/pterm v0.12.83 github.com/sethvargo/go-retry v0.3.0 @@ -50,6 +51,8 @@ require ( golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 golang.org/x/term v0.43.0 + golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 + golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 google.golang.org/api v0.282.0 google.golang.org/grpc v1.81.1 gopkg.in/yaml.v3 v3.0.1 @@ -93,11 +96,11 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/cloudflare/circl v1.6.3 // indirect + github.com/cloudflare/circl v1.6.1 // indirect github.com/containerd/console v1.0.5 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect - github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect @@ -111,8 +114,8 @@ require ( github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.9.0 // indirect - github.com/go-git/go-git/v5 v5.19.1 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-git/go-git/v5 v5.16.5 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -123,6 +126,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-containerregistry v0.20.6 // indirect github.com/google/go-github/v73 v73.0.0 // indirect @@ -148,7 +152,6 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.18.2 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/dsig v1.2.1 // indirect @@ -178,7 +181,10 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect - github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/pion/dtls/v3 v3.1.2 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -210,6 +216,7 @@ require ( github.com/valyala/fastjson v1.6.10 // indirect github.com/vbatts/tar-split v0.12.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/wlynxg/anet v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect @@ -237,6 +244,7 @@ require ( golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.44.0 // indirect golang.org/x/vuln v1.3.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genai v1.54.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect @@ -246,6 +254,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect honnef.co/go/tools v0.7.0 // indirect k8s.io/apiextensions-apiserver v0.36.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect diff --git a/go.sum b/go.sum index 4f2f5663..3bc12673 100644 --- a/go.sum +++ b/go.sum @@ -103,8 +103,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= -github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= @@ -113,8 +113,8 @@ github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= -github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -163,12 +163,12 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= -github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= -github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= +github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= +github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -221,6 +221,8 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= @@ -407,8 +409,16 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= -github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY= +github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -510,6 +520,8 @@ github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+Tc github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= @@ -577,8 +589,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/exp/typeparams v0.0.0-20251125195548-87e1e737ad39 h1:yzGKB4T4r1nFi65o7dQ96ERTfU2trk8Ige9aqqADqf4= golang.org/x/exp/typeparams v0.0.0-20251125195548-87e1e737ad39/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -662,6 +674,12 @@ golang.org/x/vuln v1.3.0 h1:hZYzR8uRhYhDSX88d+40TWbKAVw7BIvRWm26rtEn8jw= golang.org/x/vuln v1.3.0/go.mod h1:MIY2PaR1y52stzZM3uHBboUAdVJvSVMl5nP3OQrwQaE= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= @@ -715,8 +733,10 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= -gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= +gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= +gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= diff --git a/internal/valkey/command/command.go b/internal/valkey/command/command.go index 96e889fe..09615a2d 100644 --- a/internal/valkey/command/command.go +++ b/internal/valkey/command/command.go @@ -27,6 +27,7 @@ func Valkey(parentFlags *flags.GlobalFlags) *naistrix.Command { delete(f), get(f), list(f), + proxy(f), updateValkey(f), }, } diff --git a/internal/valkey/command/flag/flag.go b/internal/valkey/command/flag/flag.go index 9f2503af..208b8e89 100644 --- a/internal/valkey/command/flag/flag.go +++ b/internal/valkey/command/flag/flag.go @@ -140,6 +140,12 @@ func (m *MaxMemoryPolicy) IsValid() bool { return false } +type Proxy struct { + *Valkey + Instance string `name:"instance" short:"i" usage:"The |INSTANCE| name of the Valkey instance."` + ListenAddr string `name:"listen-addr" short:"a" usage:"Address to listen on for the proxy. Defaults to |localhost:6379|."` +} + type Credentials struct { *Valkey Permission Permission `name:"permission" short:"p" usage:"Permission level for the credentials (READ, WRITE, READWRITE, ADMIN)."` diff --git a/internal/valkey/command/proxy.go b/internal/valkey/command/proxy.go new file mode 100644 index 00000000..9d43fe7d --- /dev/null +++ b/internal/valkey/command/proxy.go @@ -0,0 +1,147 @@ +package command + +import ( + "context" + "fmt" + "io" + "net" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/nais/cli/internal/naisapi/gql" + "github.com/nais/cli/internal/tunnel" + "github.com/nais/cli/internal/validation" + "github.com/nais/cli/internal/valkey" + "github.com/nais/cli/internal/valkey/command/flag" + "github.com/nais/naistrix" +) + +func proxy(parentFlags *flag.Valkey) *naistrix.Command { + flags := &flag.Proxy{ + Valkey: parentFlags, + ListenAddr: "localhost:6379", + } + + return &naistrix.Command{ + Name: "proxy", + Title: "Create a proxy to a Valkey instance.", + Description: "Allows your user to connect to Valkey instances and starts a proxy.", + Flags: flags, + ValidateFunc: func(ctx context.Context, args *naistrix.Arguments) error { + if err := validateSingleEnvironmentFlagUsage(); err != nil { + return err + } + if err := validation.CheckEnvironment(string(flags.Environment)); err != nil { + return err + } + if flags.Instance == "" { + return fmt.Errorf("--instance flag is required") + } + return nil + }, + AutoCompleteFunc: func(ctx context.Context, args *naistrix.Arguments, _ string) ([]string, string) { + return autoCompleteValkeyNames(ctx, flags.Team, string(flags.Environment), true) + }, + Examples: []naistrix.Example{ + { + Description: "Create a proxy to a Valkey instance named my-valkey in environment dev.", + Command: "proxy --instance my-valkey --environment dev", + }, + }, + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + creds, err := valkey.CreateCredentials( + ctx, + flags.Team, + string(flags.Environment), + flags.Instance, + gql.CredentialPermissionReadwrite, + "1h", + ) + if err != nil { + return fmt.Errorf("get valkey credentials: %w", err) + } + + tunnelInfo, err := tunnel.CreateAndConnect(ctx, tunnel.Config{ + TeamSlug: flags.Team, + Environment: string(flags.Environment), + InstanceName: flags.Instance, + ListenAddr: flags.ListenAddr, + TargetHost: creds.Host, + TargetPort: creds.Port, + }, func(msg string) { out.Infof("%s\n", msg) }) + if err != nil { + return fmt.Errorf("create tunnel: %w", err) + } + defer tunnelInfo.WireGuardTunnel.Close() + defer tunnel.DeleteTunnel(context.Background(), tunnelInfo.TunnelID) //nolint:errcheck + + ctx, stop := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT) + defer stop() + + lc := net.ListenConfig{} + listener, err := lc.Listen(ctx, "tcp", flags.ListenAddr) + if err != nil { + return fmt.Errorf("listen on %s: %w", flags.ListenAddr, err) + } + defer listener.Close() + + out.Infof("Listening on %s, forwarding to %s via WireGuard tunnel\n", + listener.Addr().String(), flags.Instance) + + go func() { + <-ctx.Done() + listener.Close() + }() + + gatewayAddr := fmt.Sprintf("10.0.0.2:%d", creds.Port) + var wg sync.WaitGroup + for ctx.Err() == nil { + conn, err := listener.Accept() + if err != nil { + if ctx.Err() != nil { + break + } + out.Infof("accept error: %v\n", err) + continue + } + + wg.Add(1) + go func() { + defer wg.Done() + defer conn.Close() + + remote, err := tunnelInfo.WireGuardTunnel.DialTCP(gatewayAddr) + if err != nil { + out.Infof("dial through tunnel: %v\n", err) + return + } + defer remote.Close() + + done := make(chan struct{}, 2) + go func() { + io.Copy(remote, conn) //nolint:errcheck + done <- struct{}{} + }() + go func() { + io.Copy(conn, remote) //nolint:errcheck + done <- struct{}{} + }() + <-done + }() + } + + drainDone := make(chan struct{}) + go func() { + wg.Wait() + close(drainDone) + }() + select { + case <-drainDone: + case <-time.After(5 * time.Second): + } + return nil + }, + } +} From 24686398867f8c88c25fac40372ffff59ad4d15b Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Mon, 13 Apr 2026 14:47:59 +0200 Subject: [PATCH 04/23] fix: fmt/generate/check/modernize Co-authored-by: Thomas Krampl --- internal/valkey/command/proxy.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/valkey/command/proxy.go b/internal/valkey/command/proxy.go index 9d43fe7d..ea33c7c9 100644 --- a/internal/valkey/command/proxy.go +++ b/internal/valkey/command/proxy.go @@ -107,9 +107,7 @@ func proxy(parentFlags *flag.Valkey) *naistrix.Command { continue } - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { defer conn.Close() remote, err := tunnelInfo.WireGuardTunnel.DialTCP(gatewayAddr) @@ -129,7 +127,7 @@ func proxy(parentFlags *flag.Valkey) *naistrix.Command { done <- struct{}{} }() <-done - }() + }) } drainDone := make(chan struct{}) From ea8972a41b13f0425aa7804ab87b0a979c27721f Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Tue, 14 Apr 2026 00:35:45 +0200 Subject: [PATCH 05/23] refactor(tunnel): STUN-first flow, remove UpdateSTUNEndpoint, name-based lookup - Discover STUN endpoint before creating tunnel; pass clientSTUNEndpoint in CreateTunnelInput instead of calling UpdateTunnelSTUNEndpoint after - Remove UpdateTunnelSTUNEndpoint query/mutation entirely - Change GetTunnel to look up by name instead of ID - Change DeleteTunnel to use teamSlug/environmentName/tunnelName - Remove InstanceName from Config and CreateTunnelInput - Track tunnel by Name (TunnelInfo.TunnelName) instead of UUID - Update proxy.go to use new DeleteTunnel signature - Regenerate genqlient client from updated schema Co-authored-by: Thomas Krampl --- internal/naisapi/gql/generated.go | 148 +++++++++--------------------- internal/tunnel/queries.go | 20 ++-- internal/tunnel/tunnel.go | 68 +++++++------- internal/valkey/command/proxy.go | 13 ++- schema.graphql | 19 ++-- 5 files changed, 96 insertions(+), 172 deletions(-) diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index 8248246c..0193a81e 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -803,6 +803,7 @@ func (v *CreateTunnelCreateTunnelCreateTunnelPayload) GetTunnel() CreateTunnelCr // CreateTunnelCreateTunnelCreateTunnelPayloadTunnel includes the requested fields of the GraphQL type Tunnel. type CreateTunnelCreateTunnelCreateTunnelPayloadTunnel struct { Id string `json:"id"` + Name string `json:"name"` Phase TunnelPhase `json:"phase"` GatewayPublicKey string `json:"gatewayPublicKey"` GatewaySTUNEndpoint string `json:"gatewaySTUNEndpoint"` @@ -812,6 +813,9 @@ type CreateTunnelCreateTunnelCreateTunnelPayloadTunnel struct { // GetId returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.Id, and is useful for accessing the field via an interface. func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetId() string { return v.Id } +// GetName returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.Name, and is useful for accessing the field via an interface. +func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetName() string { return v.Name } + // GetPhase returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.Phase, and is useful for accessing the field via an interface. func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetPhase() TunnelPhase { return v.Phase } @@ -829,12 +833,12 @@ func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetGatewaySTUNEndpoi func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetMessage() string { return v.Message } type CreateTunnelInput struct { - TeamSlug string `json:"teamSlug"` - EnvironmentName string `json:"environmentName"` - InstanceName string `json:"instanceName"` - TargetHost string `json:"targetHost"` - TargetPort int `json:"targetPort"` - ClientPublicKey string `json:"clientPublicKey"` + TeamSlug string `json:"teamSlug"` + EnvironmentName string `json:"environmentName"` + TargetHost string `json:"targetHost"` + TargetPort int `json:"targetPort"` + ClientPublicKey string `json:"clientPublicKey"` + ClientSTUNEndpoint string `json:"clientSTUNEndpoint"` } // GetTeamSlug returns CreateTunnelInput.TeamSlug, and is useful for accessing the field via an interface. @@ -843,9 +847,6 @@ func (v *CreateTunnelInput) GetTeamSlug() string { return v.TeamSlug } // GetEnvironmentName returns CreateTunnelInput.EnvironmentName, and is useful for accessing the field via an interface. func (v *CreateTunnelInput) GetEnvironmentName() string { return v.EnvironmentName } -// GetInstanceName returns CreateTunnelInput.InstanceName, and is useful for accessing the field via an interface. -func (v *CreateTunnelInput) GetInstanceName() string { return v.InstanceName } - // GetTargetHost returns CreateTunnelInput.TargetHost, and is useful for accessing the field via an interface. func (v *CreateTunnelInput) GetTargetHost() string { return v.TargetHost } @@ -855,6 +856,9 @@ func (v *CreateTunnelInput) GetTargetPort() int { return v.TargetPort } // GetClientPublicKey returns CreateTunnelInput.ClientPublicKey, and is useful for accessing the field via an interface. func (v *CreateTunnelInput) GetClientPublicKey() string { return v.ClientPublicKey } +// GetClientSTUNEndpoint returns CreateTunnelInput.ClientSTUNEndpoint, and is useful for accessing the field via an interface. +func (v *CreateTunnelInput) GetClientSTUNEndpoint() string { return v.ClientSTUNEndpoint } + // CreateTunnelResponse is returned by CreateTunnel on success. type CreateTunnelResponse struct { CreateTunnel CreateTunnelCreateTunnelCreateTunnelPayload `json:"createTunnel"` @@ -27661,6 +27665,7 @@ func (v *GetTunnelTeamEnvironment) GetTunnel() GetTunnelTeamEnvironmentTunnel { // GetTunnelTeamEnvironmentTunnel includes the requested fields of the GraphQL type Tunnel. type GetTunnelTeamEnvironmentTunnel struct { Id string `json:"id"` + Name string `json:"name"` Phase TunnelPhase `json:"phase"` GatewayPublicKey string `json:"gatewayPublicKey"` GatewaySTUNEndpoint string `json:"gatewaySTUNEndpoint"` @@ -27670,6 +27675,9 @@ type GetTunnelTeamEnvironmentTunnel struct { // GetId returns GetTunnelTeamEnvironmentTunnel.Id, and is useful for accessing the field via an interface. func (v *GetTunnelTeamEnvironmentTunnel) GetId() string { return v.Id } +// GetName returns GetTunnelTeamEnvironmentTunnel.Name, and is useful for accessing the field via an interface. +func (v *GetTunnelTeamEnvironmentTunnel) GetName() string { return v.Name } + // GetPhase returns GetTunnelTeamEnvironmentTunnel.Phase, and is useful for accessing the field via an interface. func (v *GetTunnelTeamEnvironmentTunnel) GetPhase() TunnelPhase { return v.Phase } @@ -31171,36 +31179,6 @@ func (v *UpdateSecretValueUpdateSecretValueUpdateSecretValuePayloadSecret) GetNa return v.Name } -// UpdateTunnelSTUNEndpointResponse is returned by UpdateTunnelSTUNEndpoint on success. -type UpdateTunnelSTUNEndpointResponse struct { - UpdateTunnelSTUNEndpoint UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayload `json:"updateTunnelSTUNEndpoint"` -} - -// GetUpdateTunnelSTUNEndpoint returns UpdateTunnelSTUNEndpointResponse.UpdateTunnelSTUNEndpoint, and is useful for accessing the field via an interface. -func (v *UpdateTunnelSTUNEndpointResponse) GetUpdateTunnelSTUNEndpoint() UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayload { - return v.UpdateTunnelSTUNEndpoint -} - -// UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayload includes the requested fields of the GraphQL type UpdateTunnelSTUNEndpointPayload. -type UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayload struct { - Tunnel UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayloadTunnel `json:"tunnel"` -} - -// GetTunnel returns UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayload.Tunnel, and is useful for accessing the field via an interface. -func (v *UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayload) GetTunnel() UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayloadTunnel { - return v.Tunnel -} - -// UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayloadTunnel includes the requested fields of the GraphQL type Tunnel. -type UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayloadTunnel struct { - Id string `json:"id"` -} - -// GetId returns UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayloadTunnel.Id, and is useful for accessing the field via an interface. -func (v *UpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointUpdateTunnelSTUNEndpointPayloadTunnel) GetId() string { - return v.Id -} - // UpdateValkeyResponse is returned by UpdateValkey on success. type UpdateValkeyResponse struct { // Update an existing Valkey instance. @@ -31992,11 +31970,19 @@ func (v *__DeleteSecretInput) GetTeam() string { return v.Team } // __DeleteTunnelInput is used internally by genqlient type __DeleteTunnelInput struct { - TunnelID string `json:"tunnelID"` + TeamSlug string `json:"teamSlug"` + EnvironmentName string `json:"environmentName"` + TunnelName string `json:"tunnelName"` } -// GetTunnelID returns __DeleteTunnelInput.TunnelID, and is useful for accessing the field via an interface. -func (v *__DeleteTunnelInput) GetTunnelID() string { return v.TunnelID } +// GetTeamSlug returns __DeleteTunnelInput.TeamSlug, and is useful for accessing the field via an interface. +func (v *__DeleteTunnelInput) GetTeamSlug() string { return v.TeamSlug } + +// GetEnvironmentName returns __DeleteTunnelInput.EnvironmentName, and is useful for accessing the field via an interface. +func (v *__DeleteTunnelInput) GetEnvironmentName() string { return v.EnvironmentName } + +// GetTunnelName returns __DeleteTunnelInput.TunnelName, and is useful for accessing the field via an interface. +func (v *__DeleteTunnelInput) GetTunnelName() string { return v.TunnelName } // __DeleteValkeyInput is used internally by genqlient type __DeleteValkeyInput struct { @@ -32434,7 +32420,7 @@ func (v *__GetTeamVulnerabilitySummaryInput) GetFilter() *TeamVulnerabilitySumma type __GetTunnelInput struct { TeamSlug string `json:"teamSlug"` EnvironmentName string `json:"environmentName"` - Id string `json:"id"` + Name string `json:"name"` } // GetTeamSlug returns __GetTunnelInput.TeamSlug, and is useful for accessing the field via an interface. @@ -32443,8 +32429,8 @@ func (v *__GetTunnelInput) GetTeamSlug() string { return v.TeamSlug } // GetEnvironmentName returns __GetTunnelInput.EnvironmentName, and is useful for accessing the field via an interface. func (v *__GetTunnelInput) GetEnvironmentName() string { return v.EnvironmentName } -// GetId returns __GetTunnelInput.Id, and is useful for accessing the field via an interface. -func (v *__GetTunnelInput) GetId() string { return v.Id } +// GetName returns __GetTunnelInput.Name, and is useful for accessing the field via an interface. +func (v *__GetTunnelInput) GetName() string { return v.Name } // __GetValkeyInput is used internally by genqlient type __GetValkeyInput struct { @@ -32768,18 +32754,6 @@ func (v *__UpdateSecretValueInput) GetTeam() string { return v.Team } // GetValue returns __UpdateSecretValueInput.Value, and is useful for accessing the field via an interface. func (v *__UpdateSecretValueInput) GetValue() SecretValueInput { return v.Value } -// __UpdateTunnelSTUNEndpointInput is used internally by genqlient -type __UpdateTunnelSTUNEndpointInput struct { - TunnelID string `json:"tunnelID"` - ClientSTUNEndpoint string `json:"clientSTUNEndpoint"` -} - -// GetTunnelID returns __UpdateTunnelSTUNEndpointInput.TunnelID, and is useful for accessing the field via an interface. -func (v *__UpdateTunnelSTUNEndpointInput) GetTunnelID() string { return v.TunnelID } - -// GetClientSTUNEndpoint returns __UpdateTunnelSTUNEndpointInput.ClientSTUNEndpoint, and is useful for accessing the field via an interface. -func (v *__UpdateTunnelSTUNEndpointInput) GetClientSTUNEndpoint() string { return v.ClientSTUNEndpoint } - // __UpdateValkeyInput is used internally by genqlient type __UpdateValkeyInput struct { Name string `json:"name,omitempty"` @@ -33217,6 +33191,7 @@ mutation CreateTunnel ($input: CreateTunnelInput!) { createTunnel(input: $input) { tunnel { id + name phase gatewayPublicKey gatewaySTUNEndpoint @@ -33500,8 +33475,8 @@ func DeleteSecret( // The mutation executed by DeleteTunnel. const DeleteTunnel_Operation = ` -mutation DeleteTunnel ($tunnelID: ID!) { - deleteTunnel(input: {tunnelID:$tunnelID}) { +mutation DeleteTunnel ($teamSlug: Slug!, $environmentName: String!, $tunnelName: String!) { + deleteTunnel(input: {teamSlug:$teamSlug,environmentName:$environmentName,tunnelName:$tunnelName}) { success } } @@ -33510,13 +33485,17 @@ mutation DeleteTunnel ($tunnelID: ID!) { func DeleteTunnel( ctx_ context.Context, client_ graphql.Client, - tunnelID string, + teamSlug string, + environmentName string, + tunnelName string, ) (data_ *DeleteTunnelResponse, err_ error) { req_ := &graphql.Request{ OpName: "DeleteTunnel", Query: DeleteTunnel_Operation, Variables: &__DeleteTunnelInput{ - TunnelID: tunnelID, + TeamSlug: teamSlug, + EnvironmentName: environmentName, + TunnelName: tunnelName, }, } @@ -35327,11 +35306,12 @@ func GetTeamVulnerabilitySummary( // The query executed by GetTunnel. const GetTunnel_Operation = ` -query GetTunnel ($teamSlug: Slug!, $environmentName: String!, $id: ID!) { +query GetTunnel ($teamSlug: Slug!, $environmentName: String!, $name: String!) { team(slug: $teamSlug) { environment(name: $environmentName) { - tunnel(id: $id) { + tunnel(name: $name) { id + name phase gatewayPublicKey gatewaySTUNEndpoint @@ -35347,7 +35327,7 @@ func GetTunnel( client_ graphql.Client, teamSlug string, environmentName string, - id string, + name string, ) (data_ *GetTunnelResponse, err_ error) { req_ := &graphql.Request{ OpName: "GetTunnel", @@ -35355,7 +35335,7 @@ func GetTunnel( Variables: &__GetTunnelInput{ TeamSlug: teamSlug, EnvironmentName: environmentName, - Id: id, + Name: name, }, } @@ -36334,44 +36314,6 @@ func UpdateSecretValue( return data_, err_ } -// The mutation executed by UpdateTunnelSTUNEndpoint. -const UpdateTunnelSTUNEndpoint_Operation = ` -mutation UpdateTunnelSTUNEndpoint ($tunnelID: ID!, $clientSTUNEndpoint: String!) { - updateTunnelSTUNEndpoint(input: {tunnelID:$tunnelID,clientSTUNEndpoint:$clientSTUNEndpoint}) { - tunnel { - id - } - } -} -` - -func UpdateTunnelSTUNEndpoint( - ctx_ context.Context, - client_ graphql.Client, - tunnelID string, - clientSTUNEndpoint string, -) (data_ *UpdateTunnelSTUNEndpointResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "UpdateTunnelSTUNEndpoint", - Query: UpdateTunnelSTUNEndpoint_Operation, - Variables: &__UpdateTunnelSTUNEndpointInput{ - TunnelID: tunnelID, - ClientSTUNEndpoint: clientSTUNEndpoint, - }, - } - - data_ = &UpdateTunnelSTUNEndpointResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - // The mutation executed by UpdateValkey. const UpdateValkey_Operation = ` mutation UpdateValkey ($name: String!, $environmentName: String!, $teamSlug: Slug!, $memory: ValkeyMemory!, $tier: ValkeyTier!, $maxMemoryPolicy: ValkeyMaxMemoryPolicy) { diff --git a/internal/tunnel/queries.go b/internal/tunnel/queries.go index 361ebe2d..549925f7 100644 --- a/internal/tunnel/queries.go +++ b/internal/tunnel/queries.go @@ -7,6 +7,7 @@ mutation CreateTunnel($input: CreateTunnelInput!) { createTunnel(input: $input) { tunnel { id + name phase gatewayPublicKey gatewaySTUNEndpoint @@ -17,11 +18,12 @@ mutation CreateTunnel($input: CreateTunnelInput!) { ` var _ = `# @genqlient -query GetTunnel($teamSlug: Slug!, $environmentName: String!, $id: ID!) { +query GetTunnel($teamSlug: Slug!, $environmentName: String!, $name: String!) { team(slug: $teamSlug) { environment(name: $environmentName) { - tunnel(id: $id) { + tunnel(name: $name) { id + name phase gatewayPublicKey gatewaySTUNEndpoint @@ -33,18 +35,8 @@ query GetTunnel($teamSlug: Slug!, $environmentName: String!, $id: ID!) { ` var _ = `# @genqlient -mutation UpdateTunnelSTUNEndpoint($tunnelID: ID!, $clientSTUNEndpoint: String!) { - updateTunnelSTUNEndpoint(input: { tunnelID: $tunnelID, clientSTUNEndpoint: $clientSTUNEndpoint }) { - tunnel { - id - } - } -} -` - -var _ = `# @genqlient -mutation DeleteTunnel($tunnelID: ID!) { - deleteTunnel(input: { tunnelID: $tunnelID }) { +mutation DeleteTunnel($teamSlug: Slug!, $environmentName: String!, $tunnelName: String!) { + deleteTunnel(input: { teamSlug: $teamSlug, environmentName: $environmentName, tunnelName: $tunnelName }) { success } } diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index ff97ce4f..6e4b4dd9 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -11,7 +11,9 @@ import ( ) type TunnelInfo struct { - TunnelID string + TunnelName string + TeamSlug string + EnvironmentName string GatewayPublicKey wgtypes.Key GatewayEndpoint string PrivateKey wgtypes.Key @@ -19,12 +21,11 @@ type TunnelInfo struct { } type Config struct { - TeamSlug string - Environment string - InstanceName string - ListenAddr string - TargetHost string - TargetPort int + TeamSlug string + Environment string + ListenAddr string + TargetHost string + TargetPort int } func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (*TunnelInfo, error) { @@ -34,6 +35,18 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* } publicKey := privateKey.PublicKey() + progress("Discovering STUN endpoint...") + stunEndpoint, stunConn, err := DiscoverSTUNEndpoint(0) + if err != nil { + return nil, fmt.Errorf("STUN discovery failed: %w", err) + } + stunConnOwned := true + defer func() { + if stunConnOwned { + _ = stunConn.Close() + } + }() + client, err := naisapi.GraphqlClient(ctx) if err != nil { return nil, fmt.Errorf("get graphql client: %w", err) @@ -42,17 +55,17 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* progress("Creating tunnel...") createResp, err := gql.CreateTunnel(ctx, client, gql.CreateTunnelInput{ - TeamSlug: cfg.TeamSlug, - EnvironmentName: cfg.Environment, - InstanceName: cfg.InstanceName, - TargetHost: cfg.TargetHost, - TargetPort: cfg.TargetPort, - ClientPublicKey: publicKey.String(), + TeamSlug: cfg.TeamSlug, + EnvironmentName: cfg.Environment, + TargetHost: cfg.TargetHost, + TargetPort: cfg.TargetPort, + ClientPublicKey: publicKey.String(), + ClientSTUNEndpoint: stunEndpoint, }) if err != nil { return nil, fmt.Errorf("create tunnel: %w", err) } - tunnelID := createResp.CreateTunnel.Tunnel.Id + tunnelName := createResp.CreateTunnel.Tunnel.Name progress("Gateway starting...") @@ -70,7 +83,7 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* case <-ctx.Done(): return nil, ctx.Err() case <-ticker.C: - pollResp, err := gql.GetTunnel(ctx, client, cfg.TeamSlug, cfg.Environment, tunnelID) + pollResp, err := gql.GetTunnel(ctx, client, cfg.TeamSlug, cfg.Environment, tunnelName) if err != nil { return nil, fmt.Errorf("poll tunnel status: %w", err) } @@ -97,23 +110,6 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* } } - progress("Discovering STUN endpoint...") - stunEndpoint, stunConn, err := DiscoverSTUNEndpoint(0) - if err != nil { - return nil, fmt.Errorf("STUN discovery failed: %w", err) - } - stunConnOwned := true - defer func() { - if stunConnOwned { - _ = stunConn.Close() - } - }() - - _, err = gql.UpdateTunnelSTUNEndpoint(ctx, client, tunnelID, stunEndpoint) - if err != nil { - return nil, fmt.Errorf("update STUN endpoint: %w", err) - } - gwKey, err := wgtypes.ParseKey(gatewayPublicKey) if err != nil { return nil, fmt.Errorf("parse gateway public key: %w", err) @@ -126,7 +122,9 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* stunConnOwned = false return &TunnelInfo{ - TunnelID: tunnelID, + TunnelName: tunnelName, + TeamSlug: cfg.TeamSlug, + EnvironmentName: cfg.Environment, GatewayPublicKey: gwKey, GatewayEndpoint: gatewaySTUNEndpoint, PrivateKey: privateKey, @@ -134,11 +132,11 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* }, nil } -func DeleteTunnel(ctx context.Context, tunnelID string) error { +func DeleteTunnel(ctx context.Context, teamSlug, environmentName, tunnelName string) error { client, err := naisapi.GraphqlClient(ctx) if err != nil { return fmt.Errorf("get graphql client: %w", err) } - _, err = gql.DeleteTunnel(ctx, client, tunnelID) + _, err = gql.DeleteTunnel(ctx, client, teamSlug, environmentName, tunnelName) return err } diff --git a/internal/valkey/command/proxy.go b/internal/valkey/command/proxy.go index ea33c7c9..3d62af0d 100644 --- a/internal/valkey/command/proxy.go +++ b/internal/valkey/command/proxy.go @@ -64,18 +64,17 @@ func proxy(parentFlags *flag.Valkey) *naistrix.Command { } tunnelInfo, err := tunnel.CreateAndConnect(ctx, tunnel.Config{ - TeamSlug: flags.Team, - Environment: string(flags.Environment), - InstanceName: flags.Instance, - ListenAddr: flags.ListenAddr, - TargetHost: creds.Host, - TargetPort: creds.Port, + TeamSlug: flags.Team, + Environment: string(flags.Environment), + ListenAddr: flags.ListenAddr, + TargetHost: creds.Host, + TargetPort: creds.Port, }, func(msg string) { out.Infof("%s\n", msg) }) if err != nil { return fmt.Errorf("create tunnel: %w", err) } defer tunnelInfo.WireGuardTunnel.Close() - defer tunnel.DeleteTunnel(context.Background(), tunnelInfo.TunnelID) //nolint:errcheck + defer tunnel.DeleteTunnel(context.Background(), tunnelInfo.TeamSlug, tunnelInfo.EnvironmentName, tunnelInfo.TunnelName) //nolint:errcheck ctx, stop := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT) defer stop() diff --git a/schema.graphql b/schema.graphql index 80d7b870..9a42de42 100644 --- a/schema.graphql +++ b/schema.graphql @@ -12941,6 +12941,7 @@ type TunnelTarget { type Tunnel implements Node { id: ID! + name: String! phase: TunnelPhase! gatewayPublicKey: String! gatewaySTUNEndpoint: String! @@ -12952,27 +12953,20 @@ type Tunnel implements Node { input CreateTunnelInput { teamSlug: Slug! environmentName: String! - instanceName: String! targetHost: String! targetPort: Int! clientPublicKey: String! -} - -type CreateTunnelPayload { - tunnel: Tunnel! -} - -input UpdateTunnelSTUNEndpointInput { - tunnelID: ID! clientSTUNEndpoint: String! } -type UpdateTunnelSTUNEndpointPayload { +type CreateTunnelPayload { tunnel: Tunnel! } input DeleteTunnelInput { - tunnelID: ID! + teamSlug: Slug! + environmentName: String! + tunnelName: String! } type DeleteTunnelPayload { @@ -12981,11 +12975,10 @@ type DeleteTunnelPayload { extend type Mutation { createTunnel(input: CreateTunnelInput!): CreateTunnelPayload! - updateTunnelSTUNEndpoint(input: UpdateTunnelSTUNEndpointInput!): UpdateTunnelSTUNEndpointPayload! deleteTunnel(input: DeleteTunnelInput!): DeleteTunnelPayload! } extend type TeamEnvironment { - tunnel(id: ID!): Tunnel + tunnel(name: String!): Tunnel } From ed432ca216a5b61ca08d707de3834a77a07122eb Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Tue, 14 Apr 2026 10:03:21 +0200 Subject: [PATCH 06/23] fix: linter errors Co-authored-by: Thomas Krampl --- internal/tunnel/stun.go | 6 +++--- internal/tunnel/stunbind.go | 2 +- internal/valkey/command/proxy.go | 27 ++++++++++++--------------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/internal/tunnel/stun.go b/internal/tunnel/stun.go index b890ac2d..d78987b7 100644 --- a/internal/tunnel/stun.go +++ b/internal/tunnel/stun.go @@ -32,7 +32,7 @@ func DiscoverSTUNEndpoint(listenPort int) (endpoint string, conn *net.UDPConn, e endpoint, err = discoverFromServers(conn, defaultSTUNServers) if err != nil { - conn.Close() + conn.Close() // #nosec G104 -- best-effort cleanup on error path return "", nil, err } @@ -59,9 +59,9 @@ func discoverFromServers(conn *net.UDPConn, servers []string) (string, error) { // we have symmetric NAT and cannot hole-punch. if endpoint != firstEndpoint { return "", fmt.Errorf( - "Your network uses symmetric NAT which prevents direct tunnel connections. "+ + "your network uses symmetric NAT which prevents direct tunnel connections. "+ "Try from a different network or disable VPN. "+ - "(Server 1 reported %s, server 2 reported %s)", + "(server 1 reported %s, server 2 reported %s)", firstEndpoint, endpoint, ) } diff --git a/internal/tunnel/stunbind.go b/internal/tunnel/stunbind.go index af28d3c2..8892c34f 100644 --- a/internal/tunnel/stunbind.go +++ b/internal/tunnel/stunbind.go @@ -47,7 +47,7 @@ func (b *STUNBind) Open(port uint16) ([]conn.ReceiveFunc, uint16, error) { return 1, nil } - return []conn.ReceiveFunc{recv}, uint16(localAddr.Port), nil + return []conn.ReceiveFunc{recv}, uint16(localAddr.Port), nil // #nosec G115 -- port is always 0-65535 } func (b *STUNBind) Close() error { diff --git a/internal/valkey/command/proxy.go b/internal/valkey/command/proxy.go index 3d62af0d..acc26187 100644 --- a/internal/valkey/command/proxy.go +++ b/internal/valkey/command/proxy.go @@ -29,18 +29,15 @@ func proxy(parentFlags *flag.Valkey) *naistrix.Command { Title: "Create a proxy to a Valkey instance.", Description: "Allows your user to connect to Valkey instances and starts a proxy.", Flags: flags, - ValidateFunc: func(ctx context.Context, args *naistrix.Arguments) error { - if err := validateSingleEnvironmentFlagUsage(); err != nil { - return err - } - if err := validation.CheckEnvironment(string(flags.Environment)); err != nil { - return err - } - if flags.Instance == "" { - return fmt.Errorf("--instance flag is required") - } - return nil - }, + ValidateFunc: naistrix.ValidateFuncs( + validation.RequireEnvironment(flags), + func(context.Context, *naistrix.Arguments) error { + if flags.Instance == "" { + return fmt.Errorf("--instance flag is required") + } + return nil + }, + ), AutoCompleteFunc: func(ctx context.Context, args *naistrix.Arguments, _ string) ([]string, string) { return autoCompleteValkeyNames(ctx, flags.Team, string(flags.Environment), true) }, @@ -91,7 +88,7 @@ func proxy(parentFlags *flag.Valkey) *naistrix.Command { go func() { <-ctx.Done() - listener.Close() + listener.Close() // #nosec G104 -- best-effort shutdown on context cancellation }() gatewayAddr := fmt.Sprintf("10.0.0.2:%d", creds.Port) @@ -118,11 +115,11 @@ func proxy(parentFlags *flag.Valkey) *naistrix.Command { done := make(chan struct{}, 2) go func() { - io.Copy(remote, conn) //nolint:errcheck + io.Copy(remote, conn) // #nosec G104 //nolint:errcheck done <- struct{}{} }() go func() { - io.Copy(conn, remote) //nolint:errcheck + io.Copy(conn, remote) // #nosec G104 //nolint:errcheck done <- struct{}{} }() <-done From 5e9c4f44a08012738340f7f7e536322751202b93 Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Fri, 17 Apr 2026 20:41:28 +0200 Subject: [PATCH 07/23] refactor(tunnel): replace STUN with forwarder endpoint flow --- internal/naisapi/gql/generated.go | 56 ++++++++++++++----------------- internal/tunnel/queries.go | 4 +-- internal/tunnel/tunnel.go | 32 +++++------------- internal/tunnel/wireguard.go | 7 +--- 4 files changed, 38 insertions(+), 61 deletions(-) diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index 0193a81e..fc297002 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -802,12 +802,12 @@ func (v *CreateTunnelCreateTunnelCreateTunnelPayload) GetTunnel() CreateTunnelCr // CreateTunnelCreateTunnelCreateTunnelPayloadTunnel includes the requested fields of the GraphQL type Tunnel. type CreateTunnelCreateTunnelCreateTunnelPayloadTunnel struct { - Id string `json:"id"` - Name string `json:"name"` - Phase TunnelPhase `json:"phase"` - GatewayPublicKey string `json:"gatewayPublicKey"` - GatewaySTUNEndpoint string `json:"gatewaySTUNEndpoint"` - Message string `json:"message"` + Id string `json:"id"` + Name string `json:"name"` + Phase TunnelPhase `json:"phase"` + GatewayPublicKey string `json:"gatewayPublicKey"` + ForwarderEndpoint string `json:"forwarderEndpoint"` + Message string `json:"message"` } // GetId returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.Id, and is useful for accessing the field via an interface. @@ -824,21 +824,20 @@ func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetGatewayPublicKey( return v.GatewayPublicKey } -// GetGatewaySTUNEndpoint returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.GatewaySTUNEndpoint, and is useful for accessing the field via an interface. -func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetGatewaySTUNEndpoint() string { - return v.GatewaySTUNEndpoint +// GetForwarderEndpoint returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.ForwarderEndpoint, and is useful for accessing the field via an interface. +func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetForwarderEndpoint() string { + return v.ForwarderEndpoint } // GetMessage returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.Message, and is useful for accessing the field via an interface. func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetMessage() string { return v.Message } type CreateTunnelInput struct { - TeamSlug string `json:"teamSlug"` - EnvironmentName string `json:"environmentName"` - TargetHost string `json:"targetHost"` - TargetPort int `json:"targetPort"` - ClientPublicKey string `json:"clientPublicKey"` - ClientSTUNEndpoint string `json:"clientSTUNEndpoint"` + TeamSlug string `json:"teamSlug"` + EnvironmentName string `json:"environmentName"` + TargetHost string `json:"targetHost"` + TargetPort int `json:"targetPort"` + ClientPublicKey string `json:"clientPublicKey"` } // GetTeamSlug returns CreateTunnelInput.TeamSlug, and is useful for accessing the field via an interface. @@ -856,9 +855,6 @@ func (v *CreateTunnelInput) GetTargetPort() int { return v.TargetPort } // GetClientPublicKey returns CreateTunnelInput.ClientPublicKey, and is useful for accessing the field via an interface. func (v *CreateTunnelInput) GetClientPublicKey() string { return v.ClientPublicKey } -// GetClientSTUNEndpoint returns CreateTunnelInput.ClientSTUNEndpoint, and is useful for accessing the field via an interface. -func (v *CreateTunnelInput) GetClientSTUNEndpoint() string { return v.ClientSTUNEndpoint } - // CreateTunnelResponse is returned by CreateTunnel on success. type CreateTunnelResponse struct { CreateTunnel CreateTunnelCreateTunnelCreateTunnelPayload `json:"createTunnel"` @@ -27664,12 +27660,12 @@ func (v *GetTunnelTeamEnvironment) GetTunnel() GetTunnelTeamEnvironmentTunnel { // GetTunnelTeamEnvironmentTunnel includes the requested fields of the GraphQL type Tunnel. type GetTunnelTeamEnvironmentTunnel struct { - Id string `json:"id"` - Name string `json:"name"` - Phase TunnelPhase `json:"phase"` - GatewayPublicKey string `json:"gatewayPublicKey"` - GatewaySTUNEndpoint string `json:"gatewaySTUNEndpoint"` - Message string `json:"message"` + Id string `json:"id"` + Name string `json:"name"` + Phase TunnelPhase `json:"phase"` + GatewayPublicKey string `json:"gatewayPublicKey"` + ForwarderEndpoint string `json:"forwarderEndpoint"` + Message string `json:"message"` } // GetId returns GetTunnelTeamEnvironmentTunnel.Id, and is useful for accessing the field via an interface. @@ -27684,9 +27680,9 @@ func (v *GetTunnelTeamEnvironmentTunnel) GetPhase() TunnelPhase { return v.Phase // GetGatewayPublicKey returns GetTunnelTeamEnvironmentTunnel.GatewayPublicKey, and is useful for accessing the field via an interface. func (v *GetTunnelTeamEnvironmentTunnel) GetGatewayPublicKey() string { return v.GatewayPublicKey } -// GetGatewaySTUNEndpoint returns GetTunnelTeamEnvironmentTunnel.GatewaySTUNEndpoint, and is useful for accessing the field via an interface. -func (v *GetTunnelTeamEnvironmentTunnel) GetGatewaySTUNEndpoint() string { - return v.GatewaySTUNEndpoint +// GetForwarderEndpoint returns GetTunnelTeamEnvironmentTunnel.ForwarderEndpoint, and is useful for accessing the field via an interface. +func (v *GetTunnelTeamEnvironmentTunnel) GetForwarderEndpoint() string { + return v.ForwarderEndpoint } // GetMessage returns GetTunnelTeamEnvironmentTunnel.Message, and is useful for accessing the field via an interface. @@ -33194,7 +33190,7 @@ mutation CreateTunnel ($input: CreateTunnelInput!) { name phase gatewayPublicKey - gatewaySTUNEndpoint + forwarderEndpoint message } } @@ -35314,7 +35310,7 @@ query GetTunnel ($teamSlug: Slug!, $environmentName: String!, $name: String!) { name phase gatewayPublicKey - gatewaySTUNEndpoint + forwarderEndpoint message } } @@ -35975,7 +35971,7 @@ func TailLog( type TailLogWsResponse graphql.BaseResponse[*TailLogResponse] -func TailLogForwardData(interfaceChan interface{}, jsonRawMsg json.RawMessage) error { +func TailLogForwardData(interfaceChan any, jsonRawMsg json.RawMessage) error { var gqlResp graphql.Response var wsResp TailLogWsResponse err := json.Unmarshal(jsonRawMsg, &gqlResp) diff --git a/internal/tunnel/queries.go b/internal/tunnel/queries.go index 549925f7..83ddf71f 100644 --- a/internal/tunnel/queries.go +++ b/internal/tunnel/queries.go @@ -10,7 +10,7 @@ mutation CreateTunnel($input: CreateTunnelInput!) { name phase gatewayPublicKey - gatewaySTUNEndpoint + forwarderEndpoint message } } @@ -26,7 +26,7 @@ query GetTunnel($teamSlug: Slug!, $environmentName: String!, $name: String!) { name phase gatewayPublicKey - gatewaySTUNEndpoint + forwarderEndpoint message } } diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 6e4b4dd9..6920a28a 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -35,18 +35,6 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* } publicKey := privateKey.PublicKey() - progress("Discovering STUN endpoint...") - stunEndpoint, stunConn, err := DiscoverSTUNEndpoint(0) - if err != nil { - return nil, fmt.Errorf("STUN discovery failed: %w", err) - } - stunConnOwned := true - defer func() { - if stunConnOwned { - _ = stunConn.Close() - } - }() - client, err := naisapi.GraphqlClient(ctx) if err != nil { return nil, fmt.Errorf("get graphql client: %w", err) @@ -55,12 +43,11 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* progress("Creating tunnel...") createResp, err := gql.CreateTunnel(ctx, client, gql.CreateTunnelInput{ - TeamSlug: cfg.TeamSlug, - EnvironmentName: cfg.Environment, - TargetHost: cfg.TargetHost, - TargetPort: cfg.TargetPort, - ClientPublicKey: publicKey.String(), - ClientSTUNEndpoint: stunEndpoint, + TeamSlug: cfg.TeamSlug, + EnvironmentName: cfg.Environment, + TargetHost: cfg.TargetHost, + TargetPort: cfg.TargetPort, + ClientPublicKey: publicKey.String(), }) if err != nil { return nil, fmt.Errorf("create tunnel: %w", err) @@ -74,7 +61,7 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* defer ticker.Stop() var gatewayPublicKey string - var gatewaySTUNEndpoint string + var forwarderEndpoint string for { select { @@ -94,7 +81,7 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* switch t.Phase { case gql.TunnelPhaseReady, gql.TunnelPhaseConnected: gatewayPublicKey = t.GatewayPublicKey - gatewaySTUNEndpoint = t.GatewaySTUNEndpoint + forwarderEndpoint = t.ForwarderEndpoint progress("Gateway ready!") case gql.TunnelPhaseFailed: return nil, fmt.Errorf("gateway failed to start: %s", t.Message) @@ -115,18 +102,17 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* return nil, fmt.Errorf("parse gateway public key: %w", err) } - wgTunnel, err := SetupWireGuardWithConn(privateKey, gwKey, gatewaySTUNEndpoint, stunConn) + wgTunnel, err := SetupWireGuard(privateKey, gwKey, forwarderEndpoint) if err != nil { return nil, fmt.Errorf("setup wireguard: %w", err) } - stunConnOwned = false return &TunnelInfo{ TunnelName: tunnelName, TeamSlug: cfg.TeamSlug, EnvironmentName: cfg.Environment, GatewayPublicKey: gwKey, - GatewayEndpoint: gatewaySTUNEndpoint, + GatewayEndpoint: forwarderEndpoint, PrivateKey: privateKey, WireGuardTunnel: wgTunnel, }, nil diff --git a/internal/tunnel/wireguard.go b/internal/tunnel/wireguard.go index bfab3a95..4115f045 100644 --- a/internal/tunnel/wireguard.go +++ b/internal/tunnel/wireguard.go @@ -30,16 +30,11 @@ type WireGuardTunnel struct { // SetupWireGuard creates a userspace WireGuard device for the CLI side of the tunnel. // privateKey: CLI's WireGuard private key // gatewayPublicKey: gateway's WireGuard public key -// gatewayEndpoint: gateway's UDP endpoint (ip:port) discovered via STUN +// gatewayEndpoint: gateway's UDP endpoint (ip:port) exposed by the forwarder func SetupWireGuard(privateKey wgtypes.Key, gatewayPublicKey wgtypes.Key, gatewayEndpoint string) (*WireGuardTunnel, error) { return setupWireGuard(privateKey, gatewayPublicKey, gatewayEndpoint, conn.NewDefaultBind(), "listen_port=0\n") } -// SetupWireGuardWithConn creates a userspace WireGuard device using an existing STUN UDP socket. -func SetupWireGuardWithConn(privateKey wgtypes.Key, gatewayPublicKey wgtypes.Key, gatewayEndpoint string, stunConn *net.UDPConn) (*WireGuardTunnel, error) { - return setupWireGuard(privateKey, gatewayPublicKey, gatewayEndpoint, NewSTUNBind(stunConn), "") -} - func setupWireGuard(privateKey wgtypes.Key, gatewayPublicKey wgtypes.Key, gatewayEndpoint string, bind conn.Bind, listenPortConfig string) (*WireGuardTunnel, error) { prefix, err := netip.ParsePrefix(TunnelIPClient) if err != nil { From fa84f3224b33bac42d89ce8a1db983ab0e31a3a6 Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Fri, 17 Apr 2026 20:41:28 +0200 Subject: [PATCH 08/23] refactor(tunnel): remove STUN client code --- internal/tunnel/stun.go | 117 -------------------- internal/tunnel/stun_test.go | 128 ---------------------- internal/tunnel/stunbind.go | 182 ------------------------------- internal/tunnel/stunbind_test.go | 72 ------------ 4 files changed, 499 deletions(-) delete mode 100644 internal/tunnel/stun.go delete mode 100644 internal/tunnel/stun_test.go delete mode 100644 internal/tunnel/stunbind.go delete mode 100644 internal/tunnel/stunbind_test.go diff --git a/internal/tunnel/stun.go b/internal/tunnel/stun.go deleted file mode 100644 index d78987b7..00000000 --- a/internal/tunnel/stun.go +++ /dev/null @@ -1,117 +0,0 @@ -package tunnel - -import ( - "fmt" - "net" - "time" - - "github.com/pion/stun/v3" -) - -// defaultSTUNServers is the ordered list of STUN servers. -// Cloudflare is primary (anycast, Oslo/Stockholm PoP), Google servers are fallback. -var defaultSTUNServers = []string{ - "stun.cloudflare.com:3478", - "stun.l.google.com:19302", - "stun1.l.google.com:19302", - "stun2.l.google.com:19302", -} - -// DiscoverSTUNEndpoint performs STUN discovery to find the external UDP endpoint. -// Returns the discovered endpoint (ip:port string) and the UDP connection that was used. -// The caller must keep the connection open for hole-punching — close it only after the tunnel is established. -// -// Returns an error if: -// - All STUN servers fail (network issue) -// - Symmetric NAT detected (different endpoints returned by different servers) -func DiscoverSTUNEndpoint(listenPort int) (endpoint string, conn *net.UDPConn, err error) { - conn, err = net.ListenUDP("udp4", &net.UDPAddr{Port: listenPort}) - if err != nil { - return "", nil, fmt.Errorf("listen UDP for STUN: %w", err) - } - - endpoint, err = discoverFromServers(conn, defaultSTUNServers) - if err != nil { - conn.Close() // #nosec G104 -- best-effort cleanup on error path - return "", nil, err - } - - return endpoint, conn, nil -} - -func discoverFromServers(conn *net.UDPConn, servers []string) (string, error) { - var lastErr error - var firstEndpoint string - - for i, server := range servers { - endpoint, err := discoverFromServer(conn, server) - if err != nil { - lastErr = fmt.Errorf("STUN server %s: %w", server, err) - continue - } - - if i == 0 { - firstEndpoint = endpoint - continue - } - - // Check for symmetric NAT: if two different servers report different ports, - // we have symmetric NAT and cannot hole-punch. - if endpoint != firstEndpoint { - return "", fmt.Errorf( - "your network uses symmetric NAT which prevents direct tunnel connections. "+ - "Try from a different network or disable VPN. "+ - "(server 1 reported %s, server 2 reported %s)", - firstEndpoint, endpoint, - ) - } - return firstEndpoint, nil - } - - // If we only tried one server successfully, return that - if firstEndpoint != "" { - return firstEndpoint, nil - } - - return "", fmt.Errorf("all STUN servers failed (check network connectivity): %w", lastErr) -} - -func discoverFromServer(conn *net.UDPConn, server string) (string, error) { - serverAddr, err := net.ResolveUDPAddr("udp4", server) - if err != nil { - return "", fmt.Errorf("resolve: %w", err) - } - - message := stun.MustBuild(stun.TransactionID, stun.BindingRequest) - - if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { - return "", fmt.Errorf("set deadline: %w", err) - } - defer conn.SetDeadline(time.Time{}) - - if _, err := conn.WriteTo(message.Raw, serverAddr); err != nil { - return "", fmt.Errorf("send STUN request: %w", err) - } - - buf := make([]byte, 1024) - n, _, err := conn.ReadFromUDP(buf) - if err != nil { - return "", fmt.Errorf("read STUN response: %w", err) - } - - resp := &stun.Message{Raw: buf[:n]} - if err := resp.Decode(); err != nil { - return "", fmt.Errorf("decode STUN response: %w", err) - } - - var xorAddr stun.XORMappedAddress - if err := xorAddr.GetFrom(resp); err != nil { - var mappedAddr stun.MappedAddress - if err2 := mappedAddr.GetFrom(resp); err2 != nil { - return "", fmt.Errorf("get mapped address: %w", err) - } - return fmt.Sprintf("%s:%d", mappedAddr.IP.String(), mappedAddr.Port), nil - } - - return fmt.Sprintf("%s:%d", xorAddr.IP.String(), xorAddr.Port), nil -} diff --git a/internal/tunnel/stun_test.go b/internal/tunnel/stun_test.go deleted file mode 100644 index 659c2ac7..00000000 --- a/internal/tunnel/stun_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package tunnel - -import ( - "net" - "regexp" - "strings" - "testing" - - "github.com/pion/stun/v3" -) - -func TestDiscoverSTUNEndpoint(t *testing.T) { - endpoint, conn, err := DiscoverSTUNEndpoint(0) - if err != nil { - t.Fatalf("discover STUN endpoint: %v", err) - } - defer conn.Close() - - pattern := regexp.MustCompile(`^\d+\.\d+\.\d+\.\d+:\d+$`) - if !pattern.MatchString(endpoint) { - t.Errorf("endpoint %q does not match x.x.x.x:port pattern", endpoint) - } - t.Logf("Discovered STUN endpoint: %s", endpoint) -} - -func startMockSTUNServer(t *testing.T, mappedIP string, mappedPort int) string { - t.Helper() - udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { udpConn.Close() }) - - go func() { - buf := make([]byte, 1024) - for { - n, addr, err := udpConn.ReadFromUDP(buf) - if err != nil { - return - } - req := &stun.Message{Raw: make([]byte, n)} - copy(req.Raw, buf[:n]) - if err := req.Decode(); err != nil { - continue - } - resp, err := stun.Build( - stun.NewTransactionIDSetter(req.TransactionID), - stun.NewType(stun.MethodBinding, stun.ClassSuccessResponse), - &stun.XORMappedAddress{ - IP: net.ParseIP(mappedIP), - Port: mappedPort, - }, - stun.Fingerprint, - ) - if err != nil { - continue - } - udpConn.WriteTo(resp.Raw, addr) - } - }() - - return udpConn.LocalAddr().String() -} - -func TestSymmetricNATDetection(t *testing.T) { - server1 := startMockSTUNServer(t, "1.2.3.4", 10000) - server2 := startMockSTUNServer(t, "1.2.3.4", 20000) - - localConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0}) - if err != nil { - t.Fatal(err) - } - defer localConn.Close() - - _, err = discoverFromServers(localConn, []string{server1, server2}) - if err == nil { - t.Fatal("expected symmetric NAT error, got nil") - } - if !strings.Contains(strings.ToLower(err.Error()), "symmetric nat") { - t.Errorf("expected error to mention symmetric NAT, got: %v", err) - } -} - -func TestAllSTUNServersFailed(t *testing.T) { - deadConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0}) - if err != nil { - t.Fatal(err) - } - deadServer := deadConn.LocalAddr().String() - deadConn.Close() - - localConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0}) - if err != nil { - t.Fatal(err) - } - defer localConn.Close() - - _, err = discoverFromServers(localConn, []string{deadServer}) - if err == nil { - t.Fatal("expected error when all STUN servers fail, got nil") - } - if !strings.Contains(strings.ToLower(err.Error()), "all stun servers failed") { - t.Errorf("expected 'all STUN servers failed' in error, got: %v", err) - } -} - -func TestSTUNEndpointFormat(t *testing.T) { - server := startMockSTUNServer(t, "5.6.7.8", 12345) - - localConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0}) - if err != nil { - t.Fatal(err) - } - defer localConn.Close() - - endpoint, err := discoverFromServer(localConn, server) - if err != nil { - t.Fatalf("discoverFromServer: %v", err) - } - - pattern := regexp.MustCompile(`^\d+\.\d+\.\d+\.\d+:\d+$`) - if !pattern.MatchString(endpoint) { - t.Errorf("endpoint %q does not match ip:port format", endpoint) - } - if endpoint != "5.6.7.8:12345" { - t.Errorf("expected endpoint 5.6.7.8:12345, got %q", endpoint) - } -} diff --git a/internal/tunnel/stunbind.go b/internal/tunnel/stunbind.go deleted file mode 100644 index 8892c34f..00000000 --- a/internal/tunnel/stunbind.go +++ /dev/null @@ -1,182 +0,0 @@ -package tunnel - -import ( - "fmt" - "io" - "net" - "net/netip" - "sync" - - "golang.zx2c4.com/wireguard/conn" -) - -type STUNBind struct { - conn *net.UDPConn - closeOnce sync.Once -} - -type StdNetEndpoint net.UDPAddr - -func NewSTUNBind(udpConn *net.UDPConn) *STUNBind { - return &STUNBind{conn: udpConn} -} - -func (b *STUNBind) Open(port uint16) ([]conn.ReceiveFunc, uint16, error) { - _ = port - if b == nil || b.conn == nil { - return nil, 0, fmt.Errorf("nil UDP connection") - } - - localAddr, ok := b.conn.LocalAddr().(*net.UDPAddr) - if !ok || localAddr == nil { - return nil, 0, fmt.Errorf("local address is not UDP") - } - - recv := func(packets [][]byte, sizes []int, eps []conn.Endpoint) (int, error) { - if len(packets) == 0 || len(sizes) == 0 || len(eps) == 0 { - return 0, io.ErrShortBuffer - } - - n, addr, err := b.conn.ReadFromUDP(packets[0]) - if err != nil { - return 0, err - } - - sizes[0] = n - eps[0] = newStdNetEndpoint(addr) - return 1, nil - } - - return []conn.ReceiveFunc{recv}, uint16(localAddr.Port), nil // #nosec G115 -- port is always 0-65535 -} - -func (b *STUNBind) Close() error { - if b == nil || b.conn == nil { - return nil - } - - var err error - b.closeOnce.Do(func() { - err = b.conn.Close() - }) - return err -} - -func (b *STUNBind) SetMark(mark uint32) error { - _ = mark - return nil -} - -func (b *STUNBind) Send(bufs [][]byte, ep conn.Endpoint) error { - if b == nil || b.conn == nil { - return fmt.Errorf("nil UDP connection") - } - - addr, err := udpAddrFromEndpoint(ep) - if err != nil { - return err - } - - for _, buf := range bufs { - if _, err := b.conn.WriteToUDP(buf, addr); err != nil { - return err - } - } - - return nil -} - -func (b *STUNBind) ParseEndpoint(s string) (conn.Endpoint, error) { - addr, err := net.ResolveUDPAddr("udp", s) - if err != nil { - return nil, err - } - return newStdNetEndpoint(addr), nil -} - -func (b *STUNBind) BatchSize() int { - return 1 -} - -func (e *StdNetEndpoint) ClearSrc() {} - -func (e *StdNetEndpoint) SrcToString() string { - return "" -} - -func (e *StdNetEndpoint) DstToString() string { - addr := (*net.UDPAddr)(e) - if addr == nil { - return "" - } - return addr.String() -} - -func (e *StdNetEndpoint) DstToBytes() []byte { - addr := (*net.UDPAddr)(e) - if addr == nil { - return nil - } - b, err := addr.AddrPort().MarshalBinary() - if err != nil { - return nil - } - return b -} - -func (e *StdNetEndpoint) DstIP() netip.Addr { - return udpAddrIP((*net.UDPAddr)(e)) -} - -func (e *StdNetEndpoint) SrcIP() netip.Addr { - return netip.Addr{} -} - -func newStdNetEndpoint(addr *net.UDPAddr) *StdNetEndpoint { - if addr == nil { - return nil - } - - clone := *addr - if addr.IP != nil { - clone.IP = append(net.IP(nil), addr.IP...) - } - - return (*StdNetEndpoint)(&clone) -} - -func udpAddrFromEndpoint(ep conn.Endpoint) (*net.UDPAddr, error) { - switch e := ep.(type) { - case *StdNetEndpoint: - if e == nil { - return nil, fmt.Errorf("nil endpoint") - } - addr := (*net.UDPAddr)(e) - clone := *addr - if addr.IP != nil { - clone.IP = append(net.IP(nil), addr.IP...) - } - return &clone, nil - case interface{ DstToString() string }: - addr, err := net.ResolveUDPAddr("udp", e.DstToString()) - if err != nil { - return nil, fmt.Errorf("resolve endpoint %q: %w", e.DstToString(), err) - } - return addr, nil - default: - return nil, fmt.Errorf("unsupported endpoint type %T", ep) - } -} - -func udpAddrIP(addr *net.UDPAddr) netip.Addr { - if addr == nil { - return netip.Addr{} - } - - ip, ok := netip.AddrFromSlice(addr.IP) - if !ok { - return netip.Addr{} - } - - return ip.Unmap() -} diff --git a/internal/tunnel/stunbind_test.go b/internal/tunnel/stunbind_test.go deleted file mode 100644 index 7657d5ad..00000000 --- a/internal/tunnel/stunbind_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package tunnel - -import ( - "net" - "testing" -) - -func TestSTUNBindOpen(t *testing.T) { - udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0}) - if err != nil { - t.Fatal(err) - } - expectedPort := uint16(udpConn.LocalAddr().(*net.UDPAddr).Port) - - bind := NewSTUNBind(udpConn) - recvFuncs, port, err := bind.Open(0) - if err != nil { - t.Fatalf("Open: %v", err) - } - if len(recvFuncs) == 0 { - t.Error("expected non-empty receive functions slice") - } - if port != expectedPort { - t.Errorf("expected port %d, got %d", expectedPort, port) - } - - bind.Close() -} - -func TestSTUNBindClose(t *testing.T) { - udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0}) - if err != nil { - t.Fatal(err) - } - - bind := NewSTUNBind(udpConn) - _, _, err = bind.Open(0) - if err != nil { - t.Fatalf("Open: %v", err) - } - - if err := bind.Close(); err != nil { - t.Errorf("Close returned unexpected error: %v", err) - } - - if err := bind.Close(); err != nil { - t.Errorf("second Close returned unexpected error: %v", err) - } -} - -func TestSTUNBindParseEndpoint(t *testing.T) { - udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0}) - if err != nil { - t.Fatal(err) - } - defer udpConn.Close() - - bind := NewSTUNBind(udpConn) - - input := "1.2.3.4:5678" - ep, err := bind.ParseEndpoint(input) - if err != nil { - t.Fatalf("ParseEndpoint(%q): %v", input, err) - } - if ep == nil { - t.Fatal("ParseEndpoint returned nil endpoint") - } - got := ep.DstToString() - if got != input { - t.Errorf("DstToString() = %q, want %q", got, input) - } -} From 4c526725994ce6d72d0253ec8f2b42a1e66912b4 Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Mon, 20 Apr 2026 17:02:19 +0200 Subject: [PATCH 09/23] fix: update schema to replace STUN with forwarder endpoint and regenerate --- go.mod | 5 ----- go.sum | 10 ---------- internal/naisapi/gql/generated.go | 6 ++---- schema.graphql | 3 +-- 4 files changed, 3 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 3586d3db..12c8e31f 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,6 @@ require ( github.com/nais/krakend/pkg/migration v0.0.0-20260512141116-49be12bd3d09 github.com/nais/liberator v0.0.0-20260526061822-791cc0e0457c github.com/nais/naistrix v0.32.1 - github.com/pion/stun/v3 v3.1.2 github.com/pkg/errors v0.9.1 github.com/pterm/pterm v0.12.83 github.com/sethvargo/go-retry v0.3.0 @@ -181,9 +180,6 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect - github.com/pion/dtls/v3 v3.1.2 // indirect - github.com/pion/logging v0.2.4 // indirect - github.com/pion/transport/v4 v4.0.1 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect @@ -216,7 +212,6 @@ require ( github.com/valyala/fastjson v1.6.10 // indirect github.com/vbatts/tar-split v0.12.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect - github.com/wlynxg/anet v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect diff --git a/go.sum b/go.sum index 3bc12673..54867fea 100644 --- a/go.sum +++ b/go.sum @@ -409,14 +409,6 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= -github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= -github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= -github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY= -github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA= -github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= -github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -520,8 +512,6 @@ github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+Tc github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= -github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= -github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index fc297002..8dc5cd8a 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -27681,9 +27681,7 @@ func (v *GetTunnelTeamEnvironmentTunnel) GetPhase() TunnelPhase { return v.Phase func (v *GetTunnelTeamEnvironmentTunnel) GetGatewayPublicKey() string { return v.GatewayPublicKey } // GetForwarderEndpoint returns GetTunnelTeamEnvironmentTunnel.ForwarderEndpoint, and is useful for accessing the field via an interface. -func (v *GetTunnelTeamEnvironmentTunnel) GetForwarderEndpoint() string { - return v.ForwarderEndpoint -} +func (v *GetTunnelTeamEnvironmentTunnel) GetForwarderEndpoint() string { return v.ForwarderEndpoint } // GetMessage returns GetTunnelTeamEnvironmentTunnel.Message, and is useful for accessing the field via an interface. func (v *GetTunnelTeamEnvironmentTunnel) GetMessage() string { return v.Message } @@ -35971,7 +35969,7 @@ func TailLog( type TailLogWsResponse graphql.BaseResponse[*TailLogResponse] -func TailLogForwardData(interfaceChan any, jsonRawMsg json.RawMessage) error { +func TailLogForwardData(interfaceChan interface{}, jsonRawMsg json.RawMessage) error { var gqlResp graphql.Response var wsResp TailLogWsResponse err := json.Unmarshal(jsonRawMsg, &gqlResp) diff --git a/schema.graphql b/schema.graphql index 9a42de42..863b6707 100644 --- a/schema.graphql +++ b/schema.graphql @@ -12944,7 +12944,7 @@ type Tunnel implements Node { name: String! phase: TunnelPhase! gatewayPublicKey: String! - gatewaySTUNEndpoint: String! + forwarderEndpoint: String! target: TunnelTarget! message: String! createdAt: Time! @@ -12956,7 +12956,6 @@ input CreateTunnelInput { targetHost: String! targetPort: Int! clientPublicKey: String! - clientSTUNEndpoint: String! } type CreateTunnelPayload { From a6f87ee8d0c6c62ff3fe480731fe8bc088096c09 Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Tue, 21 Apr 2026 13:50:56 +0200 Subject: [PATCH 10/23] fix: case-insensitive tunnel phase comparison Operator stores mixed-case phases ("Ready") while GraphQL enum uses uppercase ("READY"). Normalize with ToUpper before switch to avoid falling through to the timeout branch. --- internal/tunnel/tunnel.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 6920a28a..ef243613 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -3,6 +3,7 @@ package tunnel import ( "context" "fmt" + "strings" "time" "github.com/nais/cli/internal/naisapi" @@ -78,7 +79,7 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* if t.Id == "" { continue } - switch t.Phase { + switch phase := gql.TunnelPhase(strings.ToUpper(string(t.Phase))); phase { case gql.TunnelPhaseReady, gql.TunnelPhaseConnected: gatewayPublicKey = t.GatewayPublicKey forwarderEndpoint = t.ForwarderEndpoint From 81ba71c7682424834a688f6a534ade7361a0c78c Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Wed, 22 Apr 2026 09:39:05 +0200 Subject: [PATCH 11/23] fix: log public keys and endpoint when connecting WireGuard --- internal/tunnel/tunnel.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index ef243613..0ad8fe93 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -83,7 +83,7 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* case gql.TunnelPhaseReady, gql.TunnelPhaseConnected: gatewayPublicKey = t.GatewayPublicKey forwarderEndpoint = t.ForwarderEndpoint - progress("Gateway ready!") + progress(fmt.Sprintf("Gateway ready! publicKey=%s endpoint=%s", gatewayPublicKey, forwarderEndpoint)) case gql.TunnelPhaseFailed: return nil, fmt.Errorf("gateway failed to start: %s", t.Message) case gql.TunnelPhaseTerminated: @@ -103,6 +103,8 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* return nil, fmt.Errorf("parse gateway public key: %w", err) } + progress(fmt.Sprintf("Connecting WireGuard: clientPublicKey=%s gatewayPublicKey=%s endpoint=%s", publicKey.String(), gwKey.String(), forwarderEndpoint)) + wgTunnel, err := SetupWireGuard(privateKey, gwKey, forwarderEndpoint) if err != nil { return nil, fmt.Errorf("setup wireguard: %w", err) From d1245c88a0da30a4b400b1b54d5ba3f060d24f9f Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Wed, 22 Apr 2026 12:45:35 +0200 Subject: [PATCH 12/23] refactor: clean up comments, use Fprintf, simplify slice/struct init --- internal/app/envvars.go | 1 - internal/secret/command/get.go | 2 -- internal/status/command/status.go | 10 +++++----- internal/tunnel/queries.go | 2 +- internal/tunnel/wireguard.go | 3 --- 5 files changed, 6 insertions(+), 12 deletions(-) diff --git a/internal/app/envvars.go b/internal/app/envvars.go index 3dcb5bd6..cc635da3 100644 --- a/internal/app/envvars.go +++ b/internal/app/envvars.go @@ -95,7 +95,6 @@ func GetApplicationEnvVars(ctx context.Context, slug, name, env string) ([]EnvVa return nil, nil } - // Select the newest group by creation time. newest := groups[0] for _, g := range groups[1:] { if g.Created.After(newest.Created) { diff --git a/internal/secret/command/get.go b/internal/secret/command/get.go index f7da566d..5541bb7a 100644 --- a/internal/secret/command/get.go +++ b/internal/secret/command/get.go @@ -17,8 +17,6 @@ import ( "github.com/nais/naistrix/output" ) -// Entry represents a key-value pair in a secret. When values are not fetched, -// the Value field is empty and omitted from JSON output. type Entry struct { Key string `json:"key"` Value string `json:"value,omitempty"` diff --git a/internal/status/command/status.go b/internal/status/command/status.go index 73921fe2..d4d113e6 100644 --- a/internal/status/command/status.go +++ b/internal/status/command/status.go @@ -31,10 +31,11 @@ func (f workloadsWithIssues) String() string { } var issues strings.Builder - issues.WriteString(fmt.Sprintf("%v workloads with issues\n\n", len(f))) + fmt.Fprintf(&issues, "%v workloads with issues\n\n", len(f)) for _, w := range f { - issues.WriteString(fmt.Sprintf("%s (%s): %s\n", w.Kind, w.Environment, w.Name)) - issues.WriteString(formatErrorTypes(w.ErrorTypes) + "\n\n") + fmt.Fprintf(&issues, "%s (%s): %s\n", w.Kind, w.Environment, w.Name) + issues.WriteString(formatErrorTypes(w.ErrorTypes)) + issues.WriteString("\n\n") } return strings.TrimRight(issues.String(), "\n") @@ -67,7 +68,7 @@ func Status(parentFlags *flags.GlobalFlags) *naistrix.Command { var entries []statusEntry for _, t := range ret { - workloadsWithCriticalIssues := make([]gql.TeamStatusMeUserTeamsTeamMemberConnectionNodesTeamMemberTeamWorkloadsWorkloadConnectionNodesWorkload, 0) + var workloadsWithCriticalIssues []gql.TeamStatusMeUserTeamsTeamMemberConnectionNodesTeamMemberTeamWorkloadsWorkloadConnectionNodesWorkload for _, w := range t.Team.Workloads.Nodes { if w.GetIssues().PageInfo.TotalCount > 0 { workloadsWithCriticalIssues = append(workloadsWithCriticalIssues, w) @@ -81,7 +82,6 @@ func Status(parentFlags *flags.GlobalFlags) *naistrix.Command { }, Workloads: t.Team.Workloads.PageInfo.TotalCount, NotNais: len(workloadsWithCriticalIssues), - Issues: make(workloadsWithIssues, 0), } for _, f := range workloadsWithCriticalIssues { a := workload{ diff --git a/internal/tunnel/queries.go b/internal/tunnel/queries.go index 83ddf71f..60f3db77 100644 --- a/internal/tunnel/queries.go +++ b/internal/tunnel/queries.go @@ -1,6 +1,6 @@ package tunnel -// genqlient operation definitions — DO NOT REMOVE, used by go generate +// genqlient operation definitions used by go generate. var _ = `# @genqlient mutation CreateTunnel($input: CreateTunnelInput!) { diff --git a/internal/tunnel/wireguard.go b/internal/tunnel/wireguard.go index 4115f045..20d931aa 100644 --- a/internal/tunnel/wireguard.go +++ b/internal/tunnel/wireguard.go @@ -28,9 +28,6 @@ type WireGuardTunnel struct { } // SetupWireGuard creates a userspace WireGuard device for the CLI side of the tunnel. -// privateKey: CLI's WireGuard private key -// gatewayPublicKey: gateway's WireGuard public key -// gatewayEndpoint: gateway's UDP endpoint (ip:port) exposed by the forwarder func SetupWireGuard(privateKey wgtypes.Key, gatewayPublicKey wgtypes.Key, gatewayEndpoint string) (*WireGuardTunnel, error) { return setupWireGuard(privateKey, gatewayPublicKey, gatewayEndpoint, conn.NewDefaultBind(), "listen_port=0\n") } From 3fee39f9c27385c17814634d715d259c64cd1ce5 Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Thu, 23 Apr 2026 00:37:47 +0200 Subject: [PATCH 13/23] feat: use pterm spinner for tunnel connect progress --- internal/tunnel/tunnel.go | 10 +++++----- internal/valkey/command/proxy.go | 11 ++++++++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 0ad8fe93..48de457b 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -41,7 +41,7 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* return nil, fmt.Errorf("get graphql client: %w", err) } - progress("Creating tunnel...") + progress("Creating tunnel") createResp, err := gql.CreateTunnel(ctx, client, gql.CreateTunnelInput{ TeamSlug: cfg.TeamSlug, @@ -55,7 +55,7 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* } tunnelName := createResp.CreateTunnel.Tunnel.Name - progress("Gateway starting...") + progress("Waiting for gateway") timeout := time.After(60 * time.Second) ticker := time.NewTicker(2 * time.Second) @@ -83,13 +83,13 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* case gql.TunnelPhaseReady, gql.TunnelPhaseConnected: gatewayPublicKey = t.GatewayPublicKey forwarderEndpoint = t.ForwarderEndpoint - progress(fmt.Sprintf("Gateway ready! publicKey=%s endpoint=%s", gatewayPublicKey, forwarderEndpoint)) + progress("Gateway ready") case gql.TunnelPhaseFailed: return nil, fmt.Errorf("gateway failed to start: %s", t.Message) case gql.TunnelPhaseTerminated: return nil, fmt.Errorf("gateway terminated unexpectedly: %s", t.Message) default: - progress(fmt.Sprintf("Gateway phase: %s...", t.Phase)) + progress(fmt.Sprintf("Gateway %s", t.Phase)) continue } } @@ -103,7 +103,7 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* return nil, fmt.Errorf("parse gateway public key: %w", err) } - progress(fmt.Sprintf("Connecting WireGuard: clientPublicKey=%s gatewayPublicKey=%s endpoint=%s", publicKey.String(), gwKey.String(), forwarderEndpoint)) + progress("Connecting WireGuard") wgTunnel, err := SetupWireGuard(privateKey, gwKey, forwarderEndpoint) if err != nil { diff --git a/internal/valkey/command/proxy.go b/internal/valkey/command/proxy.go index acc26187..af2cefbb 100644 --- a/internal/valkey/command/proxy.go +++ b/internal/valkey/command/proxy.go @@ -16,6 +16,7 @@ import ( "github.com/nais/cli/internal/valkey" "github.com/nais/cli/internal/valkey/command/flag" "github.com/nais/naistrix" + "github.com/pterm/pterm" ) func proxy(parentFlags *flag.Valkey) *naistrix.Command { @@ -60,14 +61,17 @@ func proxy(parentFlags *flag.Valkey) *naistrix.Command { return fmt.Errorf("get valkey credentials: %w", err) } + spinner, _ := pterm.DefaultSpinner.Start("Creating tunnel") + tunnelInfo, err := tunnel.CreateAndConnect(ctx, tunnel.Config{ TeamSlug: flags.Team, Environment: string(flags.Environment), ListenAddr: flags.ListenAddr, TargetHost: creds.Host, TargetPort: creds.Port, - }, func(msg string) { out.Infof("%s\n", msg) }) + }, func(msg string) { spinner.UpdateText(msg) }) if err != nil { + spinner.Fail() return fmt.Errorf("create tunnel: %w", err) } defer tunnelInfo.WireGuardTunnel.Close() @@ -79,12 +83,13 @@ func proxy(parentFlags *flag.Valkey) *naistrix.Command { lc := net.ListenConfig{} listener, err := lc.Listen(ctx, "tcp", flags.ListenAddr) if err != nil { + spinner.Fail() return fmt.Errorf("listen on %s: %w", flags.ListenAddr, err) } defer listener.Close() - out.Infof("Listening on %s, forwarding to %s via WireGuard tunnel\n", - listener.Addr().String(), flags.Instance) + spinner.Success(fmt.Sprintf("Listening on %s, forwarding to %s via WireGuard tunnel", + listener.Addr().String(), flags.Instance)) go func() { <-ctx.Done() From 38cf5791aa4fd021a3f091554f5353e96f55957b Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Fri, 24 Apr 2026 13:19:14 +0200 Subject: [PATCH 14/23] perf: tune gVisor netstack TCP buffers and disable RACK Increase TCP buffer max to 8MiB RX / 6MiB TX and disable RACK recovery to fix spurious retransmissions caused by a gVisor bug (tailscale#9707). --- internal/tunnel/wireguard.go | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/internal/tunnel/wireguard.go b/internal/tunnel/wireguard.go index 20d931aa..c967f021 100644 --- a/internal/tunnel/wireguard.go +++ b/internal/tunnel/wireguard.go @@ -5,11 +5,15 @@ import ( "fmt" "net" "net/netip" + "unsafe" "golang.zx2c4.com/wireguard/conn" "golang.zx2c4.com/wireguard/device" "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/stack" + "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" ) const ( @@ -38,7 +42,7 @@ func setupWireGuard(privateKey wgtypes.Key, gatewayPublicKey wgtypes.Key, gatewa return nil, fmt.Errorf("parse tunnel IP: %w", err) } - tun, net, err := netstack.CreateNetTUN( + tun, wgNet, err := netstack.CreateNetTUN( []netip.Addr{prefix.Addr()}, []netip.Addr{}, // no DNS 1420, // MTU @@ -50,6 +54,8 @@ func setupWireGuard(privateKey wgtypes.Key, gatewayPublicKey wgtypes.Key, gatewa return nil, fmt.Errorf("wireguard bind is nil") } + tuneStack((*netstackView)(unsafe.Pointer(wgNet)).stack) + logger := device.NewLogger(device.LogLevelError, "[wireguard-cli] ") dev := device.NewDevice(tun, bind, logger) @@ -70,7 +76,30 @@ endpoint=%s return nil, fmt.Errorf("bring up wireguard: %w", err) } - return &WireGuardTunnel{dev: dev, net: net}, nil + return &WireGuardTunnel{dev: dev, net: wgNet}, nil +} + +type netstackView struct { + _ unsafe.Pointer + stack *stack.Stack +} + +func tuneStack(s *stack.Stack) { + if s == nil { + return + } + s.SetTransportProtocolOption(tcp.ProtocolNumber, &tcpip.TCPReceiveBufferSizeRangeOption{ + Min: tcp.MinBufferSize, + Default: tcp.DefaultReceiveBufferSize, + Max: 8 << 20, + }) + s.SetTransportProtocolOption(tcp.ProtocolNumber, &tcpip.TCPSendBufferSizeRangeOption{ + Min: tcp.MinBufferSize, + Default: tcp.DefaultSendBufferSize, + Max: 6 << 20, + }) + rackOpt := tcpip.TCPRecovery(0) + s.SetTransportProtocolOption(tcp.ProtocolNumber, &rackOpt) } // Net returns the netstack network for creating TCP connections through the tunnel. @@ -83,7 +112,6 @@ func (t *WireGuardTunnel) DialTCP(addr string) (net.Conn, error) { return t.net.Dial("tcp", addr) } -// Close shuts down the WireGuard device cleanly. func (t *WireGuardTunnel) Close() { if t != nil && t.dev != nil { t.dev.Close() From f539599608630eb73e3310ff01b44d4c969441f9 Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Fri, 24 Apr 2026 13:56:15 +0200 Subject: [PATCH 15/23] fix: retry transient errors during tunnel status polling Poll errors are now retried instead of being fatal, fixing a race where the API watcher hasn't synced the newly created tunnel yet. The last error is included in the timeout message if the gateway never becomes ready. --- internal/tunnel/tunnel.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 48de457b..3d36783d 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -63,18 +63,24 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* var gatewayPublicKey string var forwarderEndpoint string + var lastPollErr error for { select { case <-timeout: + if lastPollErr != nil { + return nil, fmt.Errorf("gateway did not become ready within 60s (last error: %v)", lastPollErr) + } return nil, fmt.Errorf("gateway did not become ready within 60s — try again or check cluster status") case <-ctx.Done(): return nil, ctx.Err() case <-ticker.C: pollResp, err := gql.GetTunnel(ctx, client, cfg.TeamSlug, cfg.Environment, tunnelName) if err != nil { - return nil, fmt.Errorf("poll tunnel status: %w", err) + lastPollErr = err + continue } + lastPollErr = nil t := pollResp.Team.Environment.Tunnel if t.Id == "" { continue From da33e4c36c03fc20ef2455cef7010c2a1e595d3a Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Mon, 27 Apr 2026 10:08:31 +0200 Subject: [PATCH 16/23] refactor: use upstream netstack TUN, remove unsafe stack tuning The custom netTUN fork and unsafe pointer trick for gVisor stack tuning aren't worth the maintenance burden (~200 lines of forked code per repo). Back to upstream wireguard-go/tun/netstack as-is. Upstream already enables TCP SACK, the most impactful tuning option. --- internal/tunnel/wireguard.go | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/internal/tunnel/wireguard.go b/internal/tunnel/wireguard.go index c967f021..a147d6c8 100644 --- a/internal/tunnel/wireguard.go +++ b/internal/tunnel/wireguard.go @@ -5,15 +5,11 @@ import ( "fmt" "net" "net/netip" - "unsafe" "golang.zx2c4.com/wireguard/conn" "golang.zx2c4.com/wireguard/device" "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - "gvisor.dev/gvisor/pkg/tcpip" - "gvisor.dev/gvisor/pkg/tcpip/stack" - "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" ) const ( @@ -54,8 +50,6 @@ func setupWireGuard(privateKey wgtypes.Key, gatewayPublicKey wgtypes.Key, gatewa return nil, fmt.Errorf("wireguard bind is nil") } - tuneStack((*netstackView)(unsafe.Pointer(wgNet)).stack) - logger := device.NewLogger(device.LogLevelError, "[wireguard-cli] ") dev := device.NewDevice(tun, bind, logger) @@ -79,29 +73,6 @@ endpoint=%s return &WireGuardTunnel{dev: dev, net: wgNet}, nil } -type netstackView struct { - _ unsafe.Pointer - stack *stack.Stack -} - -func tuneStack(s *stack.Stack) { - if s == nil { - return - } - s.SetTransportProtocolOption(tcp.ProtocolNumber, &tcpip.TCPReceiveBufferSizeRangeOption{ - Min: tcp.MinBufferSize, - Default: tcp.DefaultReceiveBufferSize, - Max: 8 << 20, - }) - s.SetTransportProtocolOption(tcp.ProtocolNumber, &tcpip.TCPSendBufferSizeRangeOption{ - Min: tcp.MinBufferSize, - Default: tcp.DefaultSendBufferSize, - Max: 6 << 20, - }) - rackOpt := tcpip.TCPRecovery(0) - s.SetTransportProtocolOption(tcp.ProtocolNumber, &rackOpt) -} - // Net returns the netstack network for creating TCP connections through the tunnel. func (t *WireGuardTunnel) Net() *netstack.Net { return t.net From e60e48c989858fabf54d96e9473eead8490d4796 Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Mon, 27 Apr 2026 10:09:11 +0200 Subject: [PATCH 17/23] fix: set WireGuard MTU to 1400 for GKE VPC GKE VPC default MTU is 1460. The previous value of 1420 (standard WireGuard default for 1500 MTU networks) exceeds the GKE limit after adding 60 bytes of WireGuard overhead, causing fragmentation. --- internal/tunnel/wireguard.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tunnel/wireguard.go b/internal/tunnel/wireguard.go index a147d6c8..0bc84b5c 100644 --- a/internal/tunnel/wireguard.go +++ b/internal/tunnel/wireguard.go @@ -41,7 +41,7 @@ func setupWireGuard(privateKey wgtypes.Key, gatewayPublicKey wgtypes.Key, gatewa tun, wgNet, err := netstack.CreateNetTUN( []netip.Addr{prefix.Addr()}, []netip.Addr{}, // no DNS - 1420, // MTU + 1400, // GKE VPC MTU is 1460; WireGuard overhead is 60 bytes (20 IPv4 + 8 UDP + 32 WG) ) if err != nil { return nil, fmt.Errorf("create netstack TUN: %w", err) From 12568595629f506ce3114e054cbd7b905b4f0965 Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Mon, 27 Apr 2026 10:36:11 +0200 Subject: [PATCH 18/23] perf: tune gVisor netstack TCP buffers and disable RACK Accesses the unexported gVisor stack via unsafe to set buffer sizes (8MiB RX, 6MiB TX) matching Tailscale production values and disable RACK to avoid spurious retransmissions (tailscale/tailscale#9707). Benchmarked at ~1674 Mbps vs ~677 Mbps without tuning (2.5x). --- internal/tunnel/wireguard.go | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/internal/tunnel/wireguard.go b/internal/tunnel/wireguard.go index 0bc84b5c..d287cc1a 100644 --- a/internal/tunnel/wireguard.go +++ b/internal/tunnel/wireguard.go @@ -5,11 +5,15 @@ import ( "fmt" "net" "net/netip" + "unsafe" "golang.zx2c4.com/wireguard/conn" "golang.zx2c4.com/wireguard/device" "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/stack" + "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" ) const ( @@ -41,7 +45,7 @@ func setupWireGuard(privateKey wgtypes.Key, gatewayPublicKey wgtypes.Key, gatewa tun, wgNet, err := netstack.CreateNetTUN( []netip.Addr{prefix.Addr()}, []netip.Addr{}, // no DNS - 1400, // GKE VPC MTU is 1460; WireGuard overhead is 60 bytes (20 IPv4 + 8 UDP + 32 WG) + 1400, // GKE VPC MTU is 1460; WireGuard overhead is 60 bytes (20 IPv4 + 8 UDP + 32 WG) ) if err != nil { return nil, fmt.Errorf("create netstack TUN: %w", err) @@ -50,6 +54,8 @@ func setupWireGuard(privateKey wgtypes.Key, gatewayPublicKey wgtypes.Key, gatewa return nil, fmt.Errorf("wireguard bind is nil") } + tuneStack((*netstackView)(unsafe.Pointer(wgNet)).stack) + logger := device.NewLogger(device.LogLevelError, "[wireguard-cli] ") dev := device.NewDevice(tun, bind, logger) @@ -73,6 +79,32 @@ endpoint=%s return &WireGuardTunnel{dev: dev, net: wgNet}, nil } +// netstackView mirrors the memory layout of netstack.Net to access the unexported stack field. +type netstackView struct { + _ unsafe.Pointer + stack *stack.Stack +} + +// tuneStack adjusts gVisor netstack TCP parameters for better throughput. +func tuneStack(s *stack.Stack) { + if s == nil { + return + } + s.SetTransportProtocolOption(tcp.ProtocolNumber, &tcpip.TCPReceiveBufferSizeRangeOption{ + Min: tcp.MinBufferSize, + Default: tcp.DefaultReceiveBufferSize, + Max: 8 << 20, // 8MiB — Tailscale production value (tailscale/tailscale#12994) + }) + s.SetTransportProtocolOption(tcp.ProtocolNumber, &tcpip.TCPSendBufferSizeRangeOption{ + Min: tcp.MinBufferSize, + Default: tcp.DefaultSendBufferSize, + Max: 6 << 20, // 6MiB + }) + // RACK disabled: gVisor bug causes spurious retransmissions (tailscale/tailscale#9707) + rackOpt := tcpip.TCPRecovery(0) + s.SetTransportProtocolOption(tcp.ProtocolNumber, &rackOpt) +} + // Net returns the netstack network for creating TCP connections through the tunnel. func (t *WireGuardTunnel) Net() *netstack.Net { return t.net From 8410fdf5b712f1d80155e88ff11bb6c611a0ec0b Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Mon, 27 Apr 2026 12:30:23 +0200 Subject: [PATCH 19/23] refactor: simplify proxy bidirectional copy with WaitGroup.Go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace channel-based goroutine synchronization with sync.WaitGroup.Go (Go 1.25+) and remove unnecessary shutdown drain timeout — the CLI process exits immediately after the command completes. --- internal/valkey/command/proxy.go | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/internal/valkey/command/proxy.go b/internal/valkey/command/proxy.go index af2cefbb..28de1204 100644 --- a/internal/valkey/command/proxy.go +++ b/internal/valkey/command/proxy.go @@ -8,7 +8,6 @@ import ( "os/signal" "sync" "syscall" - "time" "github.com/nais/cli/internal/naisapi/gql" "github.com/nais/cli/internal/tunnel" @@ -118,28 +117,13 @@ func proxy(parentFlags *flag.Valkey) *naistrix.Command { } defer remote.Close() - done := make(chan struct{}, 2) - go func() { - io.Copy(remote, conn) // #nosec G104 //nolint:errcheck - done <- struct{}{} - }() - go func() { - io.Copy(conn, remote) // #nosec G104 //nolint:errcheck - done <- struct{}{} - }() - <-done + var copyWg sync.WaitGroup + copyWg.Go(func() { io.Copy(remote, conn) }) // #nosec G104 //nolint:errcheck + copyWg.Go(func() { io.Copy(conn, remote) }) // #nosec G104 //nolint:errcheck + copyWg.Wait() }) } - drainDone := make(chan struct{}) - go func() { - wg.Wait() - close(drainDone) - }() - select { - case <-drainDone: - case <-time.After(5 * time.Second): - } return nil }, } From 159283775d0d477eb471781804ea45913047f00d Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Mon, 27 Apr 2026 13:52:55 +0200 Subject: [PATCH 20/23] perf: reduce tunnel status poll interval from 2s to 500ms --- internal/tunnel/tunnel.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 3d36783d..05fe302a 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -58,7 +58,7 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* progress("Waiting for gateway") timeout := time.After(60 * time.Second) - ticker := time.NewTicker(2 * time.Second) + ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() var gatewayPublicKey string From fd62781f336ba50e6e5b6d4ab426ce227c30e850 Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Tue, 28 Apr 2026 12:00:53 +0200 Subject: [PATCH 21/23] fix: add nosec annotation for unsafe.Pointer in wireguard --- internal/tunnel/wireguard.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tunnel/wireguard.go b/internal/tunnel/wireguard.go index d287cc1a..3f5bb2ae 100644 --- a/internal/tunnel/wireguard.go +++ b/internal/tunnel/wireguard.go @@ -54,7 +54,7 @@ func setupWireGuard(privateKey wgtypes.Key, gatewayPublicKey wgtypes.Key, gatewa return nil, fmt.Errorf("wireguard bind is nil") } - tuneStack((*netstackView)(unsafe.Pointer(wgNet)).stack) + tuneStack((*netstackView)(unsafe.Pointer(wgNet)).stack) // #nosec G103 -- mirrors netstack.Net layout to access unexported stack field logger := device.NewLogger(device.LogLevelError, "[wireguard-cli] ") dev := device.NewDevice(tun, bind, logger) From 5fe6a45a569dd9a3ad3b4bb61466e7f1aac8a3f1 Mon Sep 17 00:00:00 2001 From: Christer Edvartsen Date: Thu, 28 May 2026 17:14:47 +0200 Subject: [PATCH 22/23] build: bump deps --- go.mod | 13 +++++++------ go.sum | 24 ++++++++++++------------ 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index 12c8e31f..876b8b5f 100644 --- a/go.mod +++ b/go.mod @@ -55,6 +55,7 @@ require ( google.golang.org/api v0.282.0 google.golang.org/grpc v1.81.1 gopkg.in/yaml.v3 v3.0.1 + gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c k8s.io/api v0.36.1 k8s.io/apimachinery v0.36.1 k8s.io/client-go v0.36.1 @@ -95,11 +96,11 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/cloudflare/circl v1.6.1 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/containerd/console v1.0.5 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect - github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect @@ -113,8 +114,8 @@ require ( github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.6.2 // indirect - github.com/go-git/go-git/v5 v5.16.5 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/go-git/go-git/v5 v5.19.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -151,6 +152,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/dsig v1.2.1 // indirect @@ -180,7 +182,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect - github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -249,7 +251,6 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect honnef.co/go/tools v0.7.0 // indirect k8s.io/apiextensions-apiserver v0.36.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect diff --git a/go.sum b/go.sum index 54867fea..1e480f37 100644 --- a/go.sum +++ b/go.sum @@ -103,8 +103,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= -github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= @@ -113,8 +113,8 @@ github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= -github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -163,12 +163,12 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= -github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= -github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= +github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= +github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -409,8 +409,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= -github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -579,8 +579,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= -golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp/typeparams v0.0.0-20251125195548-87e1e737ad39 h1:yzGKB4T4r1nFi65o7dQ96ERTfU2trk8Ige9aqqADqf4= golang.org/x/exp/typeparams v0.0.0-20251125195548-87e1e737ad39/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= From dd005ec79a1a3f57400c1333c09eecc6d8296112 Mon Sep 17 00:00:00 2001 From: Vegar Sechmann Molvig Date: Mon, 1 Jun 2026 16:28:15 +0200 Subject: [PATCH 23/23] fix: clean up tunnel on failure, print credentials in proxy, drain connections on shutdown --- internal/tunnel/tunnel.go | 9 ++++++++- internal/valkey/command/flag/flag.go | 1 - internal/valkey/command/proxy.go | 22 +++++++++++++--------- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 05fe302a..7cf1e296 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -29,7 +29,7 @@ type Config struct { TargetPort int } -func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (*TunnelInfo, error) { +func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (ret *TunnelInfo, retErr error) { privateKey, err := wgtypes.GeneratePrivateKey() if err != nil { return nil, fmt.Errorf("generate wireguard key: %w", err) @@ -55,6 +55,13 @@ func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (* } tunnelName := createResp.CreateTunnel.Tunnel.Name + // Clean up the server-side tunnel on any failure after creation. + defer func() { + if retErr != nil { + _ = DeleteTunnel(context.Background(), cfg.TeamSlug, cfg.Environment, tunnelName) + } + }() + progress("Waiting for gateway") timeout := time.After(60 * time.Second) diff --git a/internal/valkey/command/flag/flag.go b/internal/valkey/command/flag/flag.go index 208b8e89..dcbc3c3a 100644 --- a/internal/valkey/command/flag/flag.go +++ b/internal/valkey/command/flag/flag.go @@ -142,7 +142,6 @@ func (m *MaxMemoryPolicy) IsValid() bool { type Proxy struct { *Valkey - Instance string `name:"instance" short:"i" usage:"The |INSTANCE| name of the Valkey instance."` ListenAddr string `name:"listen-addr" short:"a" usage:"Address to listen on for the proxy. Defaults to |localhost:6379|."` } diff --git a/internal/valkey/command/proxy.go b/internal/valkey/command/proxy.go index 28de1204..ba83f08a 100644 --- a/internal/valkey/command/proxy.go +++ b/internal/valkey/command/proxy.go @@ -29,22 +29,21 @@ func proxy(parentFlags *flag.Valkey) *naistrix.Command { Title: "Create a proxy to a Valkey instance.", Description: "Allows your user to connect to Valkey instances and starts a proxy.", Flags: flags, + Args: defaultArgs, ValidateFunc: naistrix.ValidateFuncs( validation.RequireEnvironment(flags), - func(context.Context, *naistrix.Arguments) error { - if flags.Instance == "" { - return fmt.Errorf("--instance flag is required") - } - return nil - }, + validateArgs, ), AutoCompleteFunc: func(ctx context.Context, args *naistrix.Arguments, _ string) ([]string, string) { + if args.Len() != 0 { + return nil, "" + } return autoCompleteValkeyNames(ctx, flags.Team, string(flags.Environment), true) }, Examples: []naistrix.Example{ { Description: "Create a proxy to a Valkey instance named my-valkey in environment dev.", - Command: "proxy --instance my-valkey --environment dev", + Command: "my-valkey --environment dev", }, }, RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { @@ -52,7 +51,7 @@ func proxy(parentFlags *flag.Valkey) *naistrix.Command { ctx, flags.Team, string(flags.Environment), - flags.Instance, + args.Get("name"), gql.CredentialPermissionReadwrite, "1h", ) @@ -88,7 +87,11 @@ func proxy(parentFlags *flag.Valkey) *naistrix.Command { defer listener.Close() spinner.Success(fmt.Sprintf("Listening on %s, forwarding to %s via WireGuard tunnel", - listener.Addr().String(), flags.Instance)) + listener.Addr().String(), args.Get("name"))) + + out.Println(fmt.Sprintf("VALKEY_USERNAME=%q", creds.Username)) + out.Println(fmt.Sprintf("VALKEY_PASSWORD=%q", creds.Password)) + out.Println(fmt.Sprintf("VALKEY_URI=%q", fmt.Sprintf("rediss://%s:%s@%s", creds.Username, creds.Password, listener.Addr().String()))) go func() { <-ctx.Done() @@ -124,6 +127,7 @@ func proxy(parentFlags *flag.Valkey) *naistrix.Command { }) } + wg.Wait() return nil }, }