Skip to content

Commit 4546822

Browse files
committed
Direct Routed networks: IPv4 is optional, enabling IPv6-only networks
Nothing on an L3 network depends on IPv4: there is no DHCP, no password or metadata service, ConfigDrive carries whatever families exist, the IPv6 address derives from the subnet and NIC MAC with EUI-64, and the allocation chain was already family-conditional. Make that a supported configuration: at network creation each address family is optional, a given family must be complete - IPv4 is gateway, netmask and startip (v4 addresses are drawn from a pool), IPv6 is ip6gateway and ip6cidr alone (no range needed) - and at least one family must be present. Enforced by NetworkServiceImpl.validateL3AddressFamilies(), which also replaces the Shared-network start/end-IP check for L3, since an L3 network defined by ip6cidr alone is valid. The create form validates the same groups client-side. Sweep the paths that quietly assumed an IPv4 address exists: - security_group.py: add_to_ipset() skips empty and '0' placeholder addresses, and vm_ip is normalised to '' - an IPv6-only Instance's default rules now program cleanly (its v4 ipset stays empty, the v6 ipset is populated as before). The classic golden test stays byte-identical. - LibvirtComputingResource.addNetworkRules() only passes --vmip when there is one, as the other call sites already did. UI, Instances overview: the address column now shows the active families - IPv4 as before, IPv6 compressed and, when long, elided in the middle with the last four characters always visible; hovering shows the full address and clicking copies it to the clipboard. Tests: unit tests for the family validation, golden-harness tests for the IPv6-only rule programming, and three integration tests in test_l3_networks.py (IPv6-only create, IPv6-only deploy asserting a /128 with fe80::1 and no IPv4, and rejection of incomplete families). Claude-Session: https://claude.ai/code/session_01LkswKyuC2a58YCHFTEPnay
1 parent 29d3ec8 commit 4546822

10 files changed

Lines changed: 236 additions & 9 deletions

File tree

docs/design/direct-routed-networks.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,14 @@ This is the mechanism Shared networks already use: `DirectNetworkGuru.allocateDi
268268
`IpAddressManagerImpl.allocateDirectIp()`
269269
(`server/src/main/java/com/cloud/network/IpAddressManagerImpl.java:2434`).
270270

271+
**Each family is optional (added 2026-09-09): IPv6-only networks are supported.** Nothing on an
272+
L3 network depends on IPv4 — no DHCP, no password or metadata service, and ConfigDrive carries
273+
whatever families exist — so network creation requires only that at least one family is given and
274+
that a given family is complete: IPv4 is gateway + netmask + startip (a pool is mandatory, since
275+
v4 addresses are drawn from one), IPv6 is ip6gateway + ip6cidr alone (no range — §6.3.4).
276+
Enforced by `NetworkServiceImpl.validateL3AddressFamilies()`; the allocation chain was already
277+
family-conditional and needed no change. IPv4-only networks work symmetrically.
278+
271279
Consequences, all good:
272280

273281
* No new tables, no new allocation logic, no new capacity accounting.

plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5868,7 +5868,9 @@ public boolean addNetworkRules(final String vmName, final String vmId, final Str
58685868
cmd.add("add_network_rules");
58695869
cmd.add("--vmname", vmName);
58705870
cmd.add("--vmid", vmId);
5871-
cmd.add("--vmip", guestIP);
5871+
if (StringUtils.isNotBlank(guestIP)) {
5872+
cmd.add("--vmip", guestIP);
5873+
}
58725874
if (StringUtils.isNotBlank(guestIP6)) {
58735875
cmd.add("--vmip6", guestIP6);
58745876
}

scripts/vm/network/security_group.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,9 @@ def create_ipset_forvm(ipsetname, type='iphash', family='inet'):
481481

482482

483483
def add_to_ipset(ipsetname, ips, action):
484-
for ip in ips:
484+
# An address slot can be empty (an IPv6-only NIC has no IPv4 address) or the
485+
# '0' placeholder used for absent secondary IPs; neither belongs in an ipset
486+
for ip in [_f for _f in ips if _f and str(_f) != '0']:
485487
logging.debug("vm ip " + str(ip))
486488
execute("ipset -! " + action + " " + ipsetname + " " + str(ip))
487489

@@ -620,7 +622,10 @@ def add_l3_fw_framework(brname):
620622
def default_network_rules(vm_name, vm_id, vm_ip, vm_ip6, vm_mac, vif, brname, sec_ips, is_first_nic=False, direct_routed=False):
621623
""" direct_routed marks an Instance on a Direct Routed (L3) network, whose traffic the host
622624
routes rather than bridges. The Agent decides this - it is the only component that knows the
623-
network type - and passes --directrouted; the script never infers it. """
625+
network type - and passes --directrouted; the script never infers it.
626+
627+
vm_ip may be empty: an Instance on an IPv6-only network has no IPv4 address. """
628+
vm_ip = vm_ip or ''
624629
l3 = direct_routed
625630

626631
if l3:
@@ -1221,6 +1226,7 @@ def parse_network_rules(rules):
12211226

12221227

12231228
def add_network_rules(vm_name, vm_id, vm_ip, vm_ip6, signature, seqno, vmMac, rules, vif, brname, sec_ips, direct_routed=False):
1229+
vm_ip = vm_ip or ''
12241230
try:
12251231
vmName = vm_name
12261232
domId = get_vm_id(vmName)

scripts/vm/network/tests/test_security_group.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def fake_execute(cmd):
7676
return captured
7777

7878

79-
def capture_default_network_rules(sg, brname, direct_routed):
79+
def capture_default_network_rules(sg, brname, direct_routed, vm_ip=VM_ARGS['vm_ip']):
8080
""" Run default_network_rules with all side effects stubbed, returning the commands the
8181
script would have executed. """
8282
captured = []
@@ -94,7 +94,7 @@ def fake_execute(cmd):
9494
sg.delete_rules_for_vm_in_bridge_firewall_chain = lambda name: None
9595
sg.destroy_ebtables_rules = lambda name, vif: None
9696

97-
ok = sg.default_network_rules(VM_ARGS['vm_name'], VM_ARGS['vm_id'], VM_ARGS['vm_ip'], VM_ARGS['vm_ip6'],
97+
ok = sg.default_network_rules(VM_ARGS['vm_name'], VM_ARGS['vm_id'], vm_ip, VM_ARGS['vm_ip6'],
9898
VM_ARGS['vm_mac'], VM_ARGS['vif'], brname, VM_ARGS['sec_ips'],
9999
is_first_nic=True, direct_routed=direct_routed)
100100
return ok, captured
@@ -238,6 +238,28 @@ def regenerate_golden():
238238
print("wrote %d commands to %s" % (len(captured), GOLDEN))
239239

240240

241+
class TestIpv6OnlyDirectRouted(unittest.TestCase):
242+
""" An Instance on an IPv6-only Direct Routed network has no IPv4 address at all; the
243+
default rules must program cleanly without one. """
244+
245+
def setUp(self):
246+
self.sg = load_script()
247+
ok, self.captured = capture_default_network_rules(self.sg, "brdr-42", direct_routed=True, vm_ip=None)
248+
self.assertTrue(ok, "default rules must succeed for an IPv6-only Instance")
249+
250+
def test_no_command_carries_a_none_address(self):
251+
offenders = [c for c in self.captured if 'None' in c]
252+
self.assertEqual([], offenders)
253+
254+
def test_ipv4_ipset_stays_empty(self):
255+
adds = [c for c in self.captured if c.startswith('ipset -! -A ') and not c.split()[3].endswith('-6')]
256+
self.assertEqual([], adds)
257+
258+
def test_ipv6_ipset_still_populated(self):
259+
adds = [c for c in self.captured if c.startswith('ipset -! -A ') and c.split()[3].endswith('-6')]
260+
self.assertTrue(any(VM_ARGS['vm_ip6'] in c for c in adds), "the IPv6 address must still reach its ipset")
261+
262+
241263
if __name__ == '__main__':
242264
if '--regenerate' in sys.argv:
243265
regenerate_golden()

server/src/main/java/com/cloud/network/NetworkServiceImpl.java

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,34 @@ protected NetworkServiceImpl() {
872872
* True when the NIC belongs to a Direct Routed (L3) network, where secondary IPs need a host
873873
* route and neighbour entry on the hypervisor whether or not security groups are in use.
874874
*/
875+
/**
876+
* An L3 (Direct Routed) network has no DHCP and no password/metadata service, so nothing in
877+
* it depends on IPv4: each address family is optional, making IPv6-only networks possible.
878+
* What remains mandatory is that a given family is complete — IPv4 is gateway, netmask and
879+
* startip (endip defaults to startip), IPv6 is ip6gateway and ip6cidr (no range: addresses
880+
* derive from the subnet and the NIC MAC with EUI-64) — and that at least one family is
881+
* present at all.
882+
*/
883+
protected void validateL3AddressFamilies(String gateway, String netmask, String startIP, String endIP,
884+
String ip6Gateway, String ip6Cidr, String startIPv6, String endIPv6) {
885+
boolean anyIpv4 = !StringUtils.isAllBlank(gateway, netmask, startIP, endIP);
886+
boolean completeIpv4 = StringUtils.isNoneBlank(gateway, netmask, startIP);
887+
boolean anyIpv6 = !StringUtils.isAllBlank(ip6Gateway, ip6Cidr, startIPv6, endIPv6);
888+
boolean completeIpv6 = StringUtils.isNoneBlank(ip6Gateway, ip6Cidr);
889+
if (anyIpv4 && !completeIpv4) {
890+
throw new InvalidParameterValueException(String.format(
891+
"IPv4 is optional for %s networks, but when any IPv4 detail is given, gateway, netmask and startip are all required", GuestType.L3));
892+
}
893+
if (anyIpv6 && !completeIpv6) {
894+
throw new InvalidParameterValueException(String.format(
895+
"IPv6 is optional for %s networks, but when any IPv6 detail is given, ip6gateway and ip6cidr are both required", GuestType.L3));
896+
}
897+
if (!anyIpv4 && !anyIpv6) {
898+
throw new InvalidParameterValueException(String.format(
899+
"A %s network needs at least one address family: IPv4 (gateway, netmask, startip) or IPv6 (ip6gateway, ip6cidr)", GuestType.L3));
900+
}
901+
}
902+
875903
/**
876904
* Whether the NIC sits on a Direct Routed (L3) network. Such a NIC needs the agent told about
877905
* secondary IPs regardless of the zone's security group setting: the host route and neighbour
@@ -1630,14 +1658,17 @@ public Network createGuestNetwork(CreateNetworkCmd cmd) throws InsufficientCapac
16301658
}
16311659
}
16321660

1633-
// Start and end IP address are mandatory for shared and L3 (Direct Routed) networks.
1634-
if ((ntwkOff.getGuestType() == GuestType.Shared || ntwkOff.getGuestType() == GuestType.L3) && vpcId == null) {
1661+
// Start and end IP address are mandatory for shared networks.
1662+
if (ntwkOff.getGuestType() == GuestType.Shared && vpcId == null) {
16351663
if (!AllowEmptyStartEndIpAddress.valueIn(owner.getAccountId()) &&
16361664
(startIP == null && endIP == null) &&
16371665
(startIPv6 == null && endIPv6 == null)) {
16381666
throw new InvalidParameterValueException("Either IPv4 or IPv6 start and end address are mandatory");
16391667
}
16401668
}
1669+
if (ntwkOff.getGuestType() == GuestType.L3) {
1670+
validateL3AddressFamilies(gateway, netmask, startIP, endIP, ip6Gateway, ip6Cidr, startIPv6, endIPv6);
1671+
}
16411672

16421673
String cidr = null;
16431674
if (ipv4) {

server/src/test/java/com/cloud/network/NetworkServiceImplTest.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1378,4 +1378,44 @@ public void getAndValidateSupportForKeepMacAddressOnPublicNicParameterTestReturn
13781378

13791379
Assert.assertFalse(service.getAndValidateSupportForKeepMacAddressOnPublicNicParameter(false, networkOfferingVO));
13801380
}
1381+
1382+
@Test
1383+
public void validateL3AddressFamiliesAcceptsDualStack() {
1384+
service.validateL3AddressFamilies("10.1.1.1", "255.255.255.0", "10.1.1.10", "10.1.1.20", "fd00::1", "fd00::/64", null, null);
1385+
}
1386+
1387+
@Test
1388+
public void validateL3AddressFamiliesAcceptsIpv4Only() {
1389+
service.validateL3AddressFamilies("10.1.1.1", "255.255.255.0", "10.1.1.10", null, null, null, null, null);
1390+
}
1391+
1392+
@Test
1393+
public void validateL3AddressFamiliesAcceptsIpv6Only() {
1394+
service.validateL3AddressFamilies(null, null, null, null, "fd00::1", "fd00::/64", null, null);
1395+
}
1396+
1397+
@Test(expected = InvalidParameterValueException.class)
1398+
public void validateL3AddressFamiliesRejectsIncompleteIpv4() {
1399+
service.validateL3AddressFamilies("10.1.1.1", "255.255.255.0", null, null, "fd00::1", "fd00::/64", null, null);
1400+
}
1401+
1402+
@Test(expected = InvalidParameterValueException.class)
1403+
public void validateL3AddressFamiliesRejectsNetmaskAlone() {
1404+
service.validateL3AddressFamilies(null, "255.255.255.0", null, null, "fd00::1", "fd00::/64", null, null);
1405+
}
1406+
1407+
@Test(expected = InvalidParameterValueException.class)
1408+
public void validateL3AddressFamiliesRejectsIncompleteIpv6() {
1409+
service.validateL3AddressFamilies(null, null, null, null, "fd00::1", null, null, null);
1410+
}
1411+
1412+
@Test(expected = InvalidParameterValueException.class)
1413+
public void validateL3AddressFamiliesRejectsIpv6RangeWithoutCidr() {
1414+
service.validateL3AddressFamilies(null, null, null, null, null, null, "fd00::100", "fd00::200");
1415+
}
1416+
1417+
@Test(expected = InvalidParameterValueException.class)
1418+
public void validateL3AddressFamiliesRejectsNoFamilyAtAll() {
1419+
service.validateL3AddressFamilies(null, null, null, null, null, null, null, null);
1420+
}
13811421
}

test/integration/smoke/test_l3_networks.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,81 @@ def test_05_l3_subnets_may_not_overlap_zone_wide(self):
235235
self.fail("creating an L3 network overlapping another must fail")
236236
except (CloudstackAPIException, Exception):
237237
pass
238+
239+
def create_ipv6_only_l3_network(self, ip6gateway="2001:db8:113::1", ip6cidr="2001:db8:113::/64"):
240+
services = {
241+
"name": "Test IPv6-only L3 Network",
242+
"displaytext": "Test IPv6-only L3 Network",
243+
"ip6gateway": ip6gateway,
244+
"ip6cidr": ip6cidr
245+
}
246+
return Network.create(
247+
self.apiclient,
248+
services,
249+
zoneid=self.zone.id,
250+
networkofferingid=self.network_offering.id,
251+
accountid=self.account.name,
252+
domainid=self.account.domainid
253+
)
254+
255+
@attr(tags=["advanced", "smoke"], required_hardware="false")
256+
def test_06_create_ipv6_only_l3_network(self):
257+
""" Nothing on an L3 network depends on IPv4 - no DHCP, no password or metadata
258+
service - so IPv4 is optional and an IPv6-only network is valid. Addresses
259+
derive from the subnet and the NIC MAC (EUI-64), so no IPv6 range is needed
260+
either: ip6gateway and ip6cidr alone define the network. """
261+
network = self.create_ipv6_only_l3_network()
262+
self.cleanup.append(network)
263+
264+
self.assertEqual(network.type, "L3", "network type should be L3")
265+
self.assertEqual(network.ip6cidr, "2001:db8:113::/64", "the network must carry its IPv6 CIDR")
266+
self.assertFalse(getattr(network, "cidr", None), "an IPv6-only network must carry no IPv4 CIDR")
267+
268+
@attr(tags=["advanced", "smoke"], required_hardware="false")
269+
def test_07_deploy_vm_in_ipv6_only_l3_network(self):
270+
""" An Instance in an IPv6-only L3 network gets a /128 with the shared link-local
271+
gateway and no IPv4 address at all. """
272+
network = self.create_ipv6_only_l3_network()
273+
self.cleanup.append(network)
274+
275+
virtual_machine = VirtualMachine.create(
276+
self.apiclient,
277+
self.services["virtual_machine"],
278+
accountid=self.account.name,
279+
domainid=self.account.domainid,
280+
serviceofferingid=self.service_offering.id,
281+
networkids=[network.id]
282+
)
283+
self.cleanup.append(virtual_machine)
284+
285+
self.assertEqual(virtual_machine.state, "Running")
286+
nic = virtual_machine.nic[0]
287+
self.assertTrue(getattr(nic, "ip6address", None), "the NIC must carry an IPv6 address")
288+
self.assertEqual(nic.ip6gateway, "fe80::1", "an L3 NIC uses the shared link-local IPv6 gateway")
289+
self.assertFalse(getattr(nic, "ipaddress", None), "the NIC of an IPv6-only network must carry no IPv4 address")
290+
291+
@attr(tags=["advanced", "smoke"], required_hardware="false")
292+
def test_08_l3_network_rejects_incomplete_address_family(self):
293+
""" Each address family is optional, but a given family must be complete, and at
294+
least one family must be present. """
295+
incomplete_ipv4 = {
296+
"name": "Test L3 incomplete IPv4 - must fail",
297+
"displaytext": "Test L3 incomplete IPv4 - must fail",
298+
"gateway": "203.0.113.1",
299+
"netmask": "255.255.255.0"
300+
}
301+
no_family = {
302+
"name": "Test L3 without addresses - must fail",
303+
"displaytext": "Test L3 without addresses - must fail"
304+
}
305+
for services in [incomplete_ipv4, no_family]:
306+
with self.assertRaises(Exception):
307+
network = Network.create(
308+
self.apiclient,
309+
services,
310+
zoneid=self.zone.id,
311+
networkofferingid=self.network_offering.id,
312+
accountid=self.account.name,
313+
domainid=self.account.domainid
314+
)
315+
self.cleanup.append(network)

ui/public/locales/en.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3692,6 +3692,9 @@
36923692
"message.error.required.input": "Please enter input",
36933693
"message.error.reset.config": "Unable to reset config to default value",
36943694
"message.error.retrieve.kubeconfig": "Unable to retrieve Kubernetes Cluster config",
3695+
"message.error.l3.ipv4.incomplete": "IPv4 is optional for L3 networks, but when any IPv4 detail is given, the IPv4 gateway, netmask and start IP are all required",
3696+
"message.error.l3.ipv6.incomplete": "IPv6 is optional for L3 networks, but when any IPv6 detail is given, the IPv6 gateway and IPv6 CIDR are both required",
3697+
"message.error.l3.no.address.family": "Provide at least one address family: IPv4 (gateway, netmask, start IP) or IPv6 (IPv6 gateway, IPv6 CIDR)",
36953698
"message.error.routedid": "Please enter the Routed ID for this network; the selected network offering requires the operator to choose it",
36963699
"message.error.routing.policy.term": "Community need to have the following format number:number",
36973700
"message.error.s3nfs.path": "Please enter S3 NFS Path",
@@ -3913,7 +3916,7 @@
39133916
"message.note.about.keypair.permissions.title": "Note about API key pair rule permissions",
39143917
"message.note.about.keypair.permissions.body": "During the creation of API key pairs, it is possible to define a corresponding set of rule permissions. If a rule set is defined, the API key pair will only have access to APIs for which access has been explicitly granted (i.e., APIs whose corresponding rules are marked as allowed). On the other hand, if no rule set is specified, the API key pair permissions will follow and adapt to the permission set of the user's account role.",
39153918
"message.offering.internet.protocol.warning": "WARNING: IPv6 supported Networks use static routing and will require upstream routes to be configured manually.",
3916-
"message.create.l3.network": "The hypervisor routes a public IPv4/IPv6 address directly to each Instance on this network: no Virtual Router, no NAT and no DHCP. The subnet is an allocation pool routed to the hypervisors; its gateway is required but never used, since Instances always use the shared link-local gateway. Instances receive their configuration via ConfigDrive.",
3919+
"message.create.l3.network": "The hypervisor routes a public IPv4/IPv6 address directly to each Instance on this network: no Virtual Router, no NAT and no DHCP. IPv4 and IPv6 are each optional - provide at least one family; IPv6-only networks are supported. The subnet is an allocation pool routed to the hypervisors; its gateway is required but never used, since Instances always use the shared link-local gateway. Instances receive their configuration via ConfigDrive.",
39173920
"message.success.create.l3.network": "Successfully created L3 (Direct Routed) network",
39183921
"message.offering.l3": "The hypervisor routes a public IPv4/IPv6 address directly to each Instance: no Virtual Router, no NAT and no DHCP. UserData via ConfigDrive is always enabled, since it is the only channel that carries the Instance's network configuration. DNS is strongly recommended; Security Groups are optional.",
39193922
"message.offering.ipv6.warning": "Please refer documentation for creating IPv6 enabled Network/VPC offering <a href='http://docs.cloudstack.apache.org/en/latest/plugins/ipv6.html#isolated-network-and-vpc-tier'>IPv6 support in CloudStack - Isolated Networks and VPC Network Tiers</a>",

ui/src/components/view/ListView.vue

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,13 @@
344344
:to="{ path: $route.path + '/' + record.id }"
345345
>{{ text }}</router-link>
346346
<span v-else>
347-
<copy-label :label="text" />
347+
<copy-label v-if="text" :label="text" />
348+
<br v-if="text && ipV6Address(null, record)"/>
349+
<copy-label
350+
v-if="ipV6Address(null, record)"
351+
:label="shortenIpV6(ipV6Address(null, record))"
352+
:copyValue="ipV6Address(null, record)"
353+
:tooltip="ipV6Address(null, record)" />
348354
</span>
349355
<span v-if="record.issourcenat">
350356
&nbsp;
@@ -1402,6 +1408,12 @@ export default {
14021408
14031409
return record.nic.filter(e => { return e.ip6address }).map(e => { return e.ip6address }).join(', ') || text
14041410
},
1411+
shortenIpV6 (address) {
1412+
if (!address || address.length <= 16) {
1413+
return address
1414+
}
1415+
return address.substring(0, 11) + '' + address.substring(address.length - 4)
1416+
},
14051417
generateCommentsPath (record) {
14061418
if (this.entityTypeToPath(record.entitytype) === 'ssh') {
14071419
return '/' + this.entityTypeToPath(record.entitytype) + '/' + record.entityname

0 commit comments

Comments
 (0)