diff --git a/go.mod b/go.mod index c0de1376..c4f8e48c 100644 --- a/go.mod +++ b/go.mod @@ -116,6 +116,8 @@ require ( github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/Rican7/retry v0.1.0 // indirect github.com/RoaringBitmap/roaring v1.2.1 // indirect + github.com/adrg/xdg v0.5.3 // indirect + github.com/anatol/vmtest v0.0.0-20250318022921-2f32244e2f0f // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/apache/thrift v0.20.0 // indirect @@ -128,6 +130,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.2.0 // indirect github.com/blang/semver/v4 v4.0.0 // indirect + github.com/bramvdbogaerde/go-scp v1.5.0 // indirect github.com/buraksezer/consistent v0.10.0 // indirect github.com/cactus/go-statsd-client/statsd v0.0.0-20200423205355-cb0885a1018c // indirect github.com/cactus/go-statsd-client/v5 v5.1.0 // indirect @@ -231,8 +234,10 @@ require ( github.com/joyent/triton-go v0.0.0-20180628001255-830d2b111e62 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/kdomanski/iso9660 v0.4.0 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/labstack/echo/v4 v4.10.0 // indirect github.com/labstack/gommon v0.4.0 // indirect diff --git a/go.sum b/go.sum index 0636513a..9ff0e4bb 100644 --- a/go.sum +++ b/go.sum @@ -90,6 +90,8 @@ github.com/RoaringBitmap/roaring v1.2.1 h1:58/LJlg/81wfEHd5L9qsHduznOIhyv4qb1yWc github.com/RoaringBitmap/roaring v1.2.1/go.mod h1:icnadbWcNyfEHlYdr+tDlOTih1Bf/h+rzPpv4sbomAA= github.com/abdullin/seq v0.0.0-20160510034733-d5467c17e7af h1:DBNMBMuMiWYu0b+8KMJuWmfCkcxl09JwdlqwDZZ6U14= github.com/abdullin/seq v0.0.0-20160510034733-d5467c17e7af/go.mod h1:5Jv4cbFiHJMsVxt52+i0Ha45fjshj6wxYr1r19tB9bw= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -97,6 +99,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/alessio/shellescape v1.2.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/alphadose/haxmap v1.4.1 h1:VtD6VCxUkjNIfJk/aWdYFfOzrRddDFjmvmRmILg7x8Q= github.com/alphadose/haxmap v1.4.1/go.mod h1:rjHw1IAqbxm0S3U5tD16GoKsiAd8FWx5BJ2IYqXwgmM= +github.com/anatol/vmtest v0.0.0-20250318022921-2f32244e2f0f h1:k3vr4NtQzqEak4d+pBcyZ/NJuzOMPuJftLx1Fx1M2uo= +github.com/anatol/vmtest v0.0.0-20250318022921-2f32244e2f0f/go.mod h1:m5pN88x7ZnEDGXZldwg7RCX+EikR9qz/iSI2GzXq++Y= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= @@ -145,6 +149,8 @@ github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4Yn github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b h1:AP/Y7sqYicnjGDfD5VcY4CIfh1hRXBUavxrvELjTiOE= github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b/go.mod h1:ac9efd0D1fsDb3EJvhqgXRbFx7bs2wqZ10HQPeU8U/Q= +github.com/bramvdbogaerde/go-scp v1.5.0 h1:a9BinAjTfQh273eh7vd3qUgmBC+bx+3TRDtkZWmIpzM= +github.com/bramvdbogaerde/go-scp v1.5.0/go.mod h1:on2aH5AxaFb2G0N5Vsdy6B0Ml7k9HuHSwfo1y0QzAbQ= github.com/brianvoe/gofakeit/v6 v6.22.0 h1:BzOsDot1o3cufTfOk+fWKE9nFYojyDV+XHdCWL2+uyE= github.com/brianvoe/gofakeit/v6 v6.22.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -663,6 +669,8 @@ github.com/k3s-io/kine v0.13.2 h1:l++g2KY/3UaPJiGpgYuGoqaaYKeMpVj9fP/yfnSxHxo= github.com/k3s-io/kine v0.13.2/go.mod h1:Zi9F142tmeXVqhPjL6KHVnwOBs8wc/V5r3avKSpIHn0= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kdomanski/iso9660 v0.4.0 h1:BPKKdcINz3m0MdjIMwS0wx1nofsOjxOq8TOr45WGHFg= +github.com/kdomanski/iso9660 v0.4.0/go.mod h1:OxUSupHsO9ceI8lBLPJKWBTphLemjrCQY8LPXM7qSzU= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= @@ -673,6 +681,8 @@ github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47e github.com/klauspost/compress v1.14.4/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= diff --git a/pkg/tunnel/connection/splice.go b/pkg/tunnel/connection/splice.go index 1e122a41..2e4d3f37 100644 --- a/pkg/tunnel/connection/splice.go +++ b/pkg/tunnel/connection/splice.go @@ -30,7 +30,7 @@ func Splice(tun tun.Device, conn Connection) error { for { _, err := tun.Read([][]byte{pkt[:]}, sizes, 0) if err != nil { - if strings.Contains(err.Error(), "file already closed") { + if strings.Contains(err.Error(), "closed") { slog.Debug("TUN device closed") return nil } diff --git a/pkg/tunnel/router/mock_router.go b/pkg/tunnel/router/mock_router.go deleted file mode 100644 index 56560ab9..00000000 --- a/pkg/tunnel/router/mock_router.go +++ /dev/null @@ -1,151 +0,0 @@ -package router - -import ( - "context" - "net/netip" - "sync" - - "golang.zx2c4.com/wireguard/tun" - - "github.com/apoxy-dev/apoxy-cli/pkg/tunnel/connection" -) - -// MockRouter implements the Router interface for testing purposes. -type MockRouter struct { - lock sync.Mutex - routes map[string]netip.Prefix - tunDev tun.Device - mux *connection.MuxedConnection - startErr error - getTunDevErr error - addPeerErr error - removePeerErr error - closeErr error -} - -// NewMockRouter creates a new MockRouter for testing. -func NewMockRouter() *MockRouter { - return &MockRouter{ - routes: make(map[string]netip.Prefix), - mux: connection.NewMuxedConnection(), - } -} - -// SetTunnelDevice sets the mock tunnel device. -func (m *MockRouter) SetTunnelDevice(dev tun.Device) { - m.lock.Lock() - defer m.lock.Unlock() - m.tunDev = dev -} - -// SetStartError sets the error that will be returned by Start. -func (m *MockRouter) SetStartError(err error) { - m.lock.Lock() - defer m.lock.Unlock() - m.startErr = err -} - -// SetGetTunnelDeviceError sets the error that will be returned by GetTunnelDevice. -func (m *MockRouter) SetGetTunnelDeviceError(err error) { - m.lock.Lock() - defer m.lock.Unlock() - m.getTunDevErr = err -} - -// SetAddPeerError sets the error that will be returned by AddPeer. -func (m *MockRouter) SetAddPeerError(err error) { - m.lock.Lock() - defer m.lock.Unlock() - m.addPeerErr = err -} - -// SetRemovePeerError sets the error that will be returned by RemovePeer. -func (m *MockRouter) SetRemovePeerError(err error) { - m.lock.Lock() - defer m.lock.Unlock() - m.removePeerErr = err -} - -// SetCloseError sets the error that will be returned by Close. -func (m *MockRouter) SetCloseError(err error) { - m.lock.Lock() - defer m.lock.Unlock() - m.closeErr = err -} - -// GetRoutes returns all routes currently added to the router. -func (m *MockRouter) GetRoutes() []netip.Prefix { - m.lock.Lock() - defer m.lock.Unlock() - - routes := make([]netip.Prefix, 0, len(m.routes)) - for _, route := range m.routes { - routes = append(routes, route) - } - return routes -} - -// GetMuxedConnection returns the muxed connection for testing. -func (m *MockRouter) GetMuxedConnection() *connection.MuxedConnection { - return m.mux -} - -// Start is a mock implementation that satisfies the Router interface. -func (m *MockRouter) Start(ctx context.Context) error { - m.lock.Lock() - defer m.lock.Unlock() - - if m.startErr != nil { - return m.startErr - } - - // Simply block until context is done - <-ctx.Done() - return nil -} - -// AddPeer adds a peer route to the tunnel. -func (m *MockRouter) AddPeer(peer netip.Prefix, conn connection.Connection) (netip.Addr, []netip.Prefix, error) { - m.lock.Lock() - defer m.lock.Unlock() - - if m.addPeerErr != nil { - return netip.Addr{}, nil, m.addPeerErr - } - - m.routes[peer.String()] = peer - return peer.Addr(), []netip.Prefix{peer}, nil -} - -// RemovePeer removes a peer route from the tunnel. -func (m *MockRouter) RemovePeer(peer netip.Prefix) error { - m.lock.Lock() - defer m.lock.Unlock() - - if m.removePeerErr != nil { - return m.removePeerErr - } - - delete(m.routes, peer.String()) - return nil -} - -// Close releases any resources associated with the router. -func (m *MockRouter) Close() error { - m.lock.Lock() - defer m.lock.Unlock() - - if m.closeErr != nil { - return m.closeErr - } - - if m.tunDev != nil { - if err := m.tunDev.Close(); err != nil { - return err - } - m.tunDev = nil - } - - m.routes = make(map[string]netip.Prefix) - return nil -} diff --git a/pkg/tunnel/router/netlink_linux.go b/pkg/tunnel/router/netlink_linux.go index 94b12a73..36ec5391 100644 --- a/pkg/tunnel/router/netlink_linux.go +++ b/pkg/tunnel/router/netlink_linux.go @@ -87,38 +87,38 @@ func extPrefixes(link netlink.Link) (netip.Addr, []netip.Prefix, error) { } // NewNetlinkRouter creates a new netlink-based tunnel router. -// Option represents a router configuration option. -type Option func(*routerOptions) +// NetlinkRouterOption represents a router configuration option. +type NetlinkRouterOption func(*netlinkRouterOptions) -type routerOptions struct { +type netlinkRouterOptions struct { extIfaceName string tunIfaceName string } -func defaultOptions() *routerOptions { - return &routerOptions{ +func defaultNetlinkOptions() *netlinkRouterOptions { + return &netlinkRouterOptions{ extIfaceName: "eth0", tunIfaceName: "tun0", } } // WithExternalInterface sets the external interface name. -func WithExternalInterface(name string) Option { - return func(o *routerOptions) { +func WithExternalInterface(name string) NetlinkRouterOption { + return func(o *netlinkRouterOptions) { o.extIfaceName = name } } // WithTunnelInterface sets the tunnel interface name. -func WithTunnelInterface(name string) Option { - return func(o *routerOptions) { +func WithTunnelInterface(name string) NetlinkRouterOption { + return func(o *netlinkRouterOptions) { o.tunIfaceName = name } } // NewNetlinkRouter creates a new netlink-based tunnel router. -func NewNetlinkRouter(opts ...Option) (*NetlinkRouter, error) { - options := defaultOptions() +func NewNetlinkRouter(opts ...NetlinkRouterOption) (*NetlinkRouter, error) { + options := defaultNetlinkOptions() for _, opt := range opts { opt(options) } diff --git a/pkg/tunnel/router/netlink_linux_test.go b/pkg/tunnel/router/netlink_linux_test.go index 20da2c04..8aaced6e 100644 --- a/pkg/tunnel/router/netlink_linux_test.go +++ b/pkg/tunnel/router/netlink_linux_test.go @@ -1,53 +1,60 @@ //go:build linux -package router +package router_test import ( + "context" "net/netip" - "reflect" "testing" + "time" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" "github.com/apoxy-dev/apoxy-cli/pkg/tunnel/connection" + "github.com/apoxy-dev/apoxy-cli/pkg/tunnel/router" + "github.com/apoxy-dev/apoxy-cli/pkg/utils/vm" ) -func TestNetlinkRouterMock(t *testing.T) { - // This is a mock test that doesn't actually create routes - // but validates the struct and interface implementation +func TestNetlinkRouter(t *testing.T) { + // Run the test in a linux VM + child := vm.RunTestInVM(t) + if !child { + return + } - r := &NetlinkRouter{} + r, err := router.NewNetlinkRouter() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) - // Test that it implements the Router interface - var _ Router = r + // Start the router + var g errgroup.Group - // Test Start method existence - assert.NotPanics(t, func() { - startMethod := reflect.ValueOf(r).MethodByName("Start") - assert.True(t, startMethod.IsValid(), "Start method should exist") + g.Go(func() error { + return r.Start(ctx) }) - // Test GetTunnelDevice method existence - assert.NotPanics(t, func() { - getTunnelDeviceMethod := reflect.ValueOf(r).MethodByName("GetTunnelDevice") - assert.True(t, getTunnelDeviceMethod.IsValid(), "GetTunnelDevice method should exist") + t.Cleanup(func() { + require.NoError(t, r.Close()) }) - conn := connection.NewMuxedConnection() + time.Sleep(100 * time.Millisecond) // Give some time for the router to start // Test AddPeer prefix := netip.MustParsePrefix("fd00::1/128") - _, _, err := r.AddPeer(prefix, conn) - // Should fail since we didn't initialize the link - assert.Error(t, err) + conn := connection.NewMuxedConnection() + _, _, err = r.AddPeer(prefix, conn) + require.NoError(t, err) // Test RemovePeer err = r.RemovePeer(prefix) - // Should fail since we didn't initialize the link - assert.Error(t, err) + require.NoError(t, err) // Test Close - err = r.Close() + cancel() + + err = g.Wait() require.NoError(t, err) } diff --git a/pkg/tunnel/router/netlink_test.go b/pkg/tunnel/router/netlink_test.go new file mode 100644 index 00000000..75027f20 --- /dev/null +++ b/pkg/tunnel/router/netlink_test.go @@ -0,0 +1,16 @@ +//go:build !linux + +package router_test + +import ( + "testing" + + "github.com/apoxy-dev/apoxy-cli/pkg/utils/vm" +) + +// A stub for non-linux operating systems, when the test is compiled for the VM +// it will use the linux version of this test. +func TestNetlinkRouter(t *testing.T) { + // Run the test in a linux VM. + vm.RunTestInVM(t) +} diff --git a/pkg/tunnel/router/netstack_test.go b/pkg/tunnel/router/netstack_test.go index fd447809..96785554 100644 --- a/pkg/tunnel/router/netstack_test.go +++ b/pkg/tunnel/router/netstack_test.go @@ -1,4 +1,4 @@ -package router +package router_test import ( "context" @@ -10,10 +10,11 @@ import ( "golang.org/x/sync/errgroup" "github.com/apoxy-dev/apoxy-cli/pkg/tunnel/connection" + "github.com/apoxy-dev/apoxy-cli/pkg/tunnel/router" ) func TestNetstackRouter(t *testing.T) { - r, err := NewNetstackRouter() + r, err := router.NewNetstackRouter() require.NoError(t, err) require.NotNil(t, r) diff --git a/pkg/tunnel/router/router.go b/pkg/tunnel/router/router.go index c3c660b3..85940c65 100644 --- a/pkg/tunnel/router/router.go +++ b/pkg/tunnel/router/router.go @@ -2,6 +2,7 @@ package router import ( "context" + "io" "net/netip" "github.com/apoxy-dev/apoxy-cli/pkg/tunnel/connection" @@ -9,6 +10,8 @@ import ( // Router is an interface for managing tunnel routing. type Router interface { + io.Closer + // Start initializes the router and starts forwarding traffic. // It's a blocking call that should be run in a separate goroutine. Start(ctx context.Context) error @@ -19,7 +22,4 @@ type Router interface { // RemovePeer removes a peer route from the tunnel identified by the given prefix. RemovePeer(peer netip.Prefix) error - - // Close releases any resources associated with the router. - Close() error } diff --git a/pkg/tunnel/server.go b/pkg/tunnel/server.go index e41d57fb..5eca488b 100644 --- a/pkg/tunnel/server.go +++ b/pkg/tunnel/server.go @@ -1,5 +1,3 @@ -//go:build linux - package tunnel import ( diff --git a/pkg/utils/permissions.go b/pkg/utils/permissions.go deleted file mode 100644 index df6055e2..00000000 --- a/pkg/utils/permissions.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build !linux - -package utils - -// IsNetAdmin checks if the current user has NET_ADMIN capabilities. -// NET_ADMIN is required to create TUN devices and configure routes etc. -// This is a placeholder implementation for non-Linux systems. -func IsNetAdmin() (bool, error) { - return false, nil -} diff --git a/pkg/utils/permissions_linux.go b/pkg/utils/permissions_linux.go deleted file mode 100644 index 50f8542c..00000000 --- a/pkg/utils/permissions_linux.go +++ /dev/null @@ -1,38 +0,0 @@ -//go:build linux - -package utils - -import ( - "fmt" - - "golang.org/x/sys/unix" -) - -func IsNetAdmin() (bool, error) { - // Check if we are running as root - if unix.Geteuid() == 0 { - return true, nil - } - - // Get the current process's capabilities - var capData unix.CapUserData - var capHeader unix.CapUserHeader - - // Set the version to the latest version - capHeader.Version = unix.LINUX_CAPABILITY_VERSION_3 - - // Get capabilities - err := unix.Capget(&capHeader, &capData) - if err != nil { - return false, fmt.Errorf("failed to get capabilities: %v", err) - } - - // Check if the NET_ADMIN capability is present - const CAP_NET_ADMIN = 12 - netAdminMask := uint32(1) << (CAP_NET_ADMIN % 32) - if capData.Effective&(netAdminMask) != 0 { - return true, nil - } - - return false, nil -} diff --git a/pkg/utils/vm/cloud-config.yaml b/pkg/utils/vm/cloud-config.yaml new file mode 100644 index 00000000..c878af51 --- /dev/null +++ b/pkg/utils/vm/cloud-config.yaml @@ -0,0 +1,8 @@ +#cloud-config +users: + - name: apoxy + passwd: "$y$j9T$DYK6iqqZ4oLpelFrIImj9/$HDnxX01K563KozrNIzmSr4TFtqwn9qE.403Y9D7p/81" # apoxy + lock_passwd: false + shell: /bin/bash + sudo: ALL=(ALL) NOPASSWD:ALL +ssh_pwauth: true \ No newline at end of file diff --git a/pkg/utils/vm/metadata.yaml b/pkg/utils/vm/metadata.yaml new file mode 100644 index 00000000..49405c3a --- /dev/null +++ b/pkg/utils/vm/metadata.yaml @@ -0,0 +1,2 @@ +instance-id: vmtest +local-hostname: vmtest \ No newline at end of file diff --git a/pkg/utils/vm/network-config.yaml b/pkg/utils/vm/network-config.yaml new file mode 100644 index 00000000..bf68b2b1 --- /dev/null +++ b/pkg/utils/vm/network-config.yaml @@ -0,0 +1,7 @@ +version: 2 +ethernets: + eth0: + match: + macaddress: "52:54:00:12:34:56" + set-name: eth0 + dhcp4: true diff --git a/pkg/utils/vm/vm.go b/pkg/utils/vm/vm.go new file mode 100644 index 00000000..08d8ea30 --- /dev/null +++ b/pkg/utils/vm/vm.go @@ -0,0 +1,265 @@ +package vm + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" + + _ "embed" + + "github.com/adrg/xdg" + "github.com/anatol/vmtest" + scp "github.com/bramvdbogaerde/go-scp" + "github.com/kdomanski/iso9660" + "github.com/klauspost/cpuid/v2" + "golang.org/x/crypto/ssh" +) + +//go:embed cloud-config.yaml +var userData string + +//go:embed metadata.yaml +var metaData string + +//go:embed network-config.yaml +var networkConfig string + +// RunTestInVM runs the test as root inside a linux VM using QEMU. +func RunTestInVM(t *testing.T) bool { + t.Helper() + + if cpuid.CPU.VM() { + // We are the child running in the VM, nothing we need to do. + return true + } + + // Use an XDG directory for the image + imageDir, err := xdg.CacheFile("vmtest") + if err != nil { + t.Fatalf("failed to get cache directory: %v", err) + return false + } + imagePath := filepath.Join(imageDir, "debian-12-genericcloud.qcow2") + + // Download the image if not already present + if _, err := os.Stat(imagePath); os.IsNotExist(err) { + imageURL := fmt.Sprintf("https://cdimage.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-%s.qcow2", runtime.GOARCH) + + t.Logf("Downloading image from %s...\n", imageURL) + + if err := os.MkdirAll(filepath.Dir(imagePath), 0o755); err != nil { + t.Fatalf("failed to create cache directory: %v", err) + return false + } + + resp, err := http.Get(imageURL) + if err != nil { + t.Fatalf("failed to download image: %v", err) + } + defer resp.Body.Close() + + out, err := os.Create(imagePath) + if err != nil { + t.Fatalf("failed to create image file: %v", err) + return false + } + defer out.Close() + + if _, err := io.Copy(out, resp.Body); err != nil { + t.Fatalf("failed to save image: %v", err) + return false + } + } else { + t.Logf("Using existing image at %s\n", imagePath) + } + + tempDir := t.TempDir() + + cloudInitISOPath := filepath.Join(tempDir, "cloud-init.iso") + cloudInitISOFile, err := os.Create(cloudInitISOPath) + if err != nil { + t.Fatalf("failed to create cloud-init ISO file: %v", err) + return false + } + + t.Logf("Creating cloud-init ISO at %s...\n", cloudInitISOPath) + + err = createCloudInitISO(cloudInitISOFile, userData, networkConfig, metaData) + _ = cloudInitISOFile.Close() + if err != nil { + t.Fatalf("failed to create cloud-init ISO: %v", err) + return false + } + + qemuParams := []string{ + "-cpu", "host", "-m", "1024M", + "-netdev", "user,id=net0,hostfwd=tcp::10022-:22", + "-device", "virtio-net-pci,netdev=net0,mac=52:54:00:12:34:56", + "-snapshot", + } + + if runtime.GOOS == "linux" { + qemuParams = append(qemuParams, "-enable-kvm") + } + + // Launch the QEMU VM using vmtest + opts := vmtest.QemuOptions{ + OperatingSystem: vmtest.OS_LINUX, + Disks: []vmtest.QemuDisk{ + {Path: imagePath, Format: "qcow2"}, + }, + Params: qemuParams, + Timeout: 90 * time.Second, + Verbose: testing.Verbose(), + CdRom: cloudInitISOPath, + } + + qemu, err := vmtest.NewQemu(&opts) + if err != nil { + t.Fatalf("failed to create QEMU instance: %v", err) + } + t.Cleanup(qemu.Shutdown) + + _, testSourceFile, _, ok := runtime.Caller(1) + if !ok { + t.Fatalf("failed to get test file path") + return false + } + + t.Logf("Compiling test binary from %s...\n", testSourceFile) + + // Compile the test binary + testBinary := filepath.Join(tempDir, "testbin") + cmd := exec.Command("go", "test", "-c", "-o", testBinary, testSourceFile) + cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH="+runtime.GOARCH) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + t.Fatalf("failed to compile test binary: %v", err) + return false + } + t.Cleanup(func() { + if err := os.Remove(testBinary); err != nil { + t.Logf("failed to remove test binary: %v", err) + } + }) + + t.Logf("Waiting for VM to boot...\n") + + config := &ssh.ClientConfig{ + User: "apoxy", + Auth: []ssh.AuthMethod{ + ssh.Password("apoxy"), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + } + + // Wait for SSH to become available + var conn *ssh.Client + for i := 0; i < 10; i++ { + conn, err = ssh.Dial("tcp", "localhost:10022", config) + if err == nil { + break + } + time.Sleep(time.Second) + } + if err != nil { + t.Fatalf("failed to connect to VM via SSH: %v", err) + return false + } + t.Cleanup(func() { + if err := conn.Close(); err != nil { + t.Logf("failed to close SSH connection: %v", err) + } + }) + + t.Logf("Copy test binary to VM...\n") + + // Copy the test binary to the VM using SCP + scpClient, err := scp.NewClientBySSH(conn) + if err != nil { + t.Fatalf("failed to create SCP client: %v", err) + return false + } + t.Cleanup(scpClient.Close) + + f, err := os.Open(testBinary) + if err != nil { + t.Fatalf("failed to open compiled binary: %v", err) + return false + } + t.Cleanup(func() { + if err := f.Close(); err != nil { + t.Logf("failed to close compiled binary: %v", err) + } + }) + + if err := scpClient.CopyFile(context.TODO(), f, "testbin", "0755"); err != nil && !errors.Is(err, io.EOF) { + t.Fatalf("failed to copy binary to VM: %v", err) + return false + } + + // Run the test binary in the VM + sess, err := conn.NewSession() + if err != nil { + t.Fatalf("failed to create SSH session: %v", err) + return false + } + defer sess.Close() + + testCmd := "sudo -E ./testbin" + if testing.Verbose() { + testCmd += " -test.v" + } + testCmd += " -test.run " + t.Name() + + output, err := sess.CombinedOutput(testCmd) + + t.Log(string(output)) + + if err != nil { + t.Fatalf("failed to run test binary in VM: %v", err) + return false + } + + return false +} + +func createCloudInitISO(w io.Writer, userData, networkConfig, metaData string) error { + writer, err := iso9660.NewWriter() + if err != nil { + return fmt.Errorf("failed to create iso9660 writer: %w", err) + } + + if err := writer.AddFile(bytes.NewReader([]byte(userData)), "user-data"); err != nil { + return fmt.Errorf("failed to add user-data to ISO: %w", err) + } + + if err := writer.AddFile(bytes.NewReader([]byte(networkConfig)), "network-config"); err != nil { + return fmt.Errorf("failed to add network-config to ISO: %w", err) + } + + if err := writer.AddFile(bytes.NewReader([]byte(metaData)), "meta-data"); err != nil { + return fmt.Errorf("failed to add meta-data to ISO: %w", err) + } + + if err := writer.WriteTo(w, "cidata"); err != nil { + return fmt.Errorf("failed to write ISO: %w", err) + } + + if err := writer.Cleanup(); err != nil { + return fmt.Errorf("failed to cleanup iso9660 writer: %w", err) + } + + return nil +}