Skip to content

Commit ba20c7c

Browse files
mcanevetsmira
authored andcommitted
feat(machined): add ONEGATE proxy route and deterministic interface iteration for OpenNebula
When ONEGATE_ENDPOINT contains a link-local IPv4 address (169.254.x.x), emit a /32 scope-link host route via the first static interface, matching the reference add_onegate_proxy_route behavior. Without this route, VMs using link-local OneGate endpoints cannot reach the metadata service. Interface names are now collected and sorted before processing, matching the reference env | grep ... | sort behavior (ETH0, ETH1, ...). This makes DNS server ordering and ONEGATE route attachment deterministic regardless of Go map iteration order. The interface loop is extracted into processInterfaces to keep ParseMetadata within cyclomatic complexity limits. Signed-off-by: Mickaël Canévet <mickael.canevet@proton.ch> Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com> (cherry picked from commit 5d3a326)
1 parent 739f664 commit ba20c7c

File tree

2 files changed

+252
-12
lines changed

2 files changed

+252
-12
lines changed
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+
5+
package opennebula_test
6+
7+
import (
8+
"net/netip"
9+
"testing"
10+
11+
"github.com/cosi-project/runtime/pkg/state"
12+
"github.com/cosi-project/runtime/pkg/state/impl/inmem"
13+
"github.com/cosi-project/runtime/pkg/state/impl/namespaced"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
17+
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula"
18+
"github.com/siderolabs/talos/pkg/machinery/nethelpers"
19+
"github.com/siderolabs/talos/pkg/machinery/resources/network"
20+
)
21+
22+
// staticIfaceContext builds a minimal context with a static ETH0 and an
23+
// optional ONEGATE_ENDPOINT.
24+
func staticIfaceContext(endpoint string) []byte {
25+
ctx := `ETH0_MAC = "02:00:c0:a8:01:5c"
26+
ETH0_IP = "192.168.1.92"
27+
ETH0_MASK = "255.255.255.0"
28+
`
29+
30+
if endpoint != "" {
31+
ctx += `ONEGATE_ENDPOINT = "` + endpoint + `"` + "\n"
32+
}
33+
34+
return []byte(ctx)
35+
}
36+
37+
func linkLocalRoute(ip, outLink string) network.RouteSpecSpec {
38+
return network.RouteSpecSpec{
39+
ConfigLayer: network.ConfigPlatform,
40+
Destination: netip.PrefixFrom(netip.MustParseAddr(ip), 32),
41+
OutLinkName: outLink,
42+
Table: nethelpers.TableMain,
43+
Protocol: nethelpers.ProtocolStatic,
44+
Type: nethelpers.TypeUnicast,
45+
Family: nethelpers.FamilyInet4,
46+
Scope: nethelpers.ScopeLink,
47+
}
48+
}
49+
50+
func scopeLinkRoutes(routes []network.RouteSpecSpec) []network.RouteSpecSpec {
51+
var out []network.RouteSpecSpec
52+
53+
for _, r := range routes {
54+
if r.Scope == nethelpers.ScopeLink {
55+
out = append(out, r)
56+
}
57+
}
58+
59+
return out
60+
}
61+
62+
func TestOnegateProxyRoute(t *testing.T) {
63+
t.Parallel()
64+
65+
o := &opennebula.OpenNebula{}
66+
st := state.WrapCore(namespaced.NewState(inmem.Build))
67+
68+
tests := []struct {
69+
name string
70+
endpoint string
71+
wantRoute *network.RouteSpecSpec
72+
}{
73+
{
74+
name: "link-local with port and path emits scope-link /32 route",
75+
endpoint: "http://169.254.16.9:5030/RPC2",
76+
wantRoute: func() *network.RouteSpecSpec {
77+
r := linkLocalRoute("169.254.16.9", "eth0")
78+
79+
return &r
80+
}(),
81+
},
82+
{
83+
name: "link-local without port emits route",
84+
endpoint: "http://169.254.16.9/RPC2",
85+
wantRoute: func() *network.RouteSpecSpec {
86+
r := linkLocalRoute("169.254.16.9", "eth0")
87+
88+
return &r
89+
}(),
90+
},
91+
{
92+
name: "non-link-local IP emits no route",
93+
endpoint: "http://10.0.0.1:5030/RPC2",
94+
wantRoute: nil,
95+
},
96+
{
97+
name: "absent ONEGATE_ENDPOINT emits no route",
98+
endpoint: "",
99+
wantRoute: nil,
100+
},
101+
{
102+
name: "IPv6 URL emits no route",
103+
endpoint: "http://[::1]:5030/RPC2",
104+
wantRoute: nil,
105+
},
106+
{
107+
name: "malformed endpoint emits no route without panic",
108+
endpoint: "not-a-url",
109+
wantRoute: nil,
110+
},
111+
}
112+
113+
for _, tt := range tests {
114+
t.Run(tt.name, func(t *testing.T) {
115+
t.Parallel()
116+
117+
cfg, err := o.ParseMetadata(st, staticIfaceContext(tt.endpoint))
118+
require.NoError(t, err)
119+
120+
routes := scopeLinkRoutes(cfg.Routes)
121+
122+
if tt.wantRoute == nil {
123+
assert.Empty(t, routes)
124+
} else {
125+
require.Len(t, routes, 1)
126+
assert.Equal(t, *tt.wantRoute, routes[0])
127+
}
128+
})
129+
}
130+
}
131+
132+
func TestOnegateRouteAttachedToFirstStaticInterface(t *testing.T) {
133+
t.Parallel()
134+
135+
o := &opennebula.OpenNebula{}
136+
st := state.WrapCore(namespaced.NewState(inmem.Build))
137+
138+
// ETH0=dhcp, ETH1=static — route must be on eth1 (first static).
139+
ctx := []byte(`ETH0_MAC = "02:00:c0:a8:01:5c"
140+
ETH0_METHOD = "dhcp"
141+
ETH1_MAC = "02:00:c0:a8:01:5d"
142+
ETH1_IP = "192.168.1.92"
143+
ETH1_MASK = "255.255.255.0"
144+
ONEGATE_ENDPOINT = "http://169.254.16.9:5030/RPC2"
145+
`)
146+
147+
cfg, err := o.ParseMetadata(st, ctx)
148+
require.NoError(t, err)
149+
150+
routes := scopeLinkRoutes(cfg.Routes)
151+
require.Len(t, routes, 1)
152+
assert.Equal(t, "eth1", routes[0].OutLinkName)
153+
}

internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/opennebula.go

Lines changed: 99 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,99 @@ func resolveHostname(oneContext map[string]string) string {
693693
return sanitizeHostname(oneContext["SET_HOSTNAME"])
694694
}
695695

696+
// extractIPv4FromEndpoint extracts the host IPv4 address from a URL-like
697+
// string (e.g. "http://169.254.16.9:5030"). Returns an invalid Addr if no
698+
// IPv4 address can be parsed from the host portion.
699+
func extractIPv4FromEndpoint(endpoint string) netip.Addr {
700+
s := endpoint
701+
702+
// Strip scheme (e.g. "http://").
703+
if idx := strings.Index(s, "://"); idx >= 0 {
704+
s = s[idx+3:]
705+
}
706+
707+
// Strip path, query, and port in order to isolate the bare host.
708+
for _, sep := range []string{"/", "?", ":"} {
709+
if idx := strings.Index(s, sep); idx >= 0 {
710+
s = s[:idx]
711+
}
712+
}
713+
714+
addr, err := netip.ParseAddr(s)
715+
if err != nil {
716+
return netip.Addr{}
717+
}
718+
719+
return addr
720+
}
721+
722+
// parseOnegateProxyRoute emits a /32 scope-link host route to the ONEGATE
723+
// endpoint when its host is a link-local IPv4 address (169.254.x.x). The
724+
// route is attached to outLink (the first static interface), matching the
725+
// reference add_onegate_proxy_route behavior.
726+
func parseOnegateProxyRoute(oneContext map[string]string, outLink string, networkConfig *runtime.PlatformNetworkConfig) {
727+
endpoint := oneContext["ONEGATE_ENDPOINT"]
728+
if endpoint == "" {
729+
return
730+
}
731+
732+
ip := extractIPv4FromEndpoint(endpoint)
733+
if !ip.IsValid() || !ip.Is4() || !ip.IsLinkLocalUnicast() {
734+
return
735+
}
736+
737+
route := network.RouteSpecSpec{
738+
ConfigLayer: network.ConfigPlatform,
739+
Destination: netip.PrefixFrom(ip, 32),
740+
OutLinkName: outLink,
741+
Table: nethelpers.TableMain,
742+
Protocol: nethelpers.ProtocolStatic,
743+
Type: nethelpers.TypeUnicast,
744+
Family: nethelpers.FamilyInet4,
745+
Scope: nethelpers.ScopeLink,
746+
}
747+
748+
route.Normalize()
749+
750+
networkConfig.Routes = append(networkConfig.Routes, route)
751+
}
752+
753+
// processInterfaces iterates ETHn interfaces in sorted order, configures each
754+
// one, and returns the name of the first static interface link (used to attach
755+
// the ONEGATE proxy route). Sorted order matches the reference behavior of
756+
// env | grep ... | sort (ETH0, ETH1, ETH2, ...).
757+
func processInterfaces(
758+
oneContext map[string]string,
759+
networkConfig *runtime.PlatformNetworkConfig,
760+
allDNSIPs *[]netip.Addr,
761+
allSearchDomains *[]string,
762+
) (firstStaticLink string, err error) {
763+
var ifaceNames []string
764+
765+
for key := range oneContext {
766+
if ifaceName, ok := ethInterfaceName(key); ok {
767+
ifaceNames = append(ifaceNames, ifaceName)
768+
}
769+
}
770+
771+
slices.Sort(ifaceNames)
772+
773+
for _, ifaceName := range ifaceNames {
774+
if err := parseInterface(oneContext, ifaceName, networkConfig, allDNSIPs, allSearchDomains); err != nil {
775+
return "", err
776+
}
777+
778+
if firstStaticLink == "" {
779+
method := strings.ToLower(oneContext[ifaceName+"_METHOD"])
780+
if method == "" || method == "static" {
781+
firstStaticLink = strings.ToLower(ifaceName)
782+
}
783+
}
784+
}
785+
786+
return firstStaticLink, nil
787+
}
788+
696789
// ParseMetadata converts opennebula metadata to platform network config.
697790
func (o *OpenNebula) ParseMetadata(st state.State, oneContextPlain []byte) (*runtime.PlatformNetworkConfig, error) {
698791
networkConfig := &runtime.PlatformNetworkConfig{}
@@ -721,19 +814,13 @@ func (o *OpenNebula) ParseMetadata(st state.State, oneContextPlain []byte) (*run
721814

722815
allSearchDomains := append([]string(nil), strings.Fields(oneContext["SEARCH_DOMAIN"])...)
723816

724-
// The presence of ETHn_MAC is the sole trigger for interface configuration,
725-
// matching the behavior of the official OpenNebula guest contextualization
726-
// scripts (one-apps/context-linux: get_context_interfaces() uses ETH*_MAC
727-
// presence exclusively).
728-
for key := range oneContext {
729-
ifaceName, ok := ethInterfaceName(key)
730-
if !ok {
731-
continue
732-
}
817+
firstStaticLink, err := processInterfaces(oneContext, networkConfig, &allDNSIPs, &allSearchDomains)
818+
if err != nil {
819+
return nil, err
820+
}
733821

734-
if err := parseInterface(oneContext, ifaceName, networkConfig, &allDNSIPs, &allSearchDomains); err != nil {
735-
return nil, err
736-
}
822+
if firstStaticLink != "" {
823+
parseOnegateProxyRoute(oneContext, firstStaticLink, networkConfig)
737824
}
738825

739826
if len(allDNSIPs)+len(allSearchDomains) > 0 {

0 commit comments

Comments
 (0)