Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vitepress/sidebar.mts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const handbook: DefaultTheme.SidebarItem[] = [
{ text: 'Benchmarks', link: '/docs/networking/wg-mesh/benchmarks' },
],
},
{ text: 'Address formats', link: '/docs/networking/address-formats' },
{ text: 'Host networking', link: '/docs/networking/host-networking' },
{ text: 'Public IPs', link: '/docs/networking/public-ips' },
{
Expand Down
11 changes: 4 additions & 7 deletions atlas/atlas/core/mesh_address.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@

MESH_PREFIX = 0xFDAA
MESH_NETWORK = ipaddress.IPv6Network((MESH_PREFIX << 112, 16))
MAXIMUM_VIRTUAL_MACHINE_NUMBER = 0xFFFFFFFFFFFFFFFF
# Atlas takes the last tenant-0 VM number, so no VM can use its address.
ATLAS_VIRTUAL_MACHINE_NUMBER = MAXIMUM_VIRTUAL_MACHINE_NUMBER
# A VM address is fdaa | region 16 | tenant 32 | padding 48 | VM number 16. The padding is zero.
MAXIMUM_VIRTUAL_MACHINE_NUMBER = 0xFFFF
# Atlas fills the padding and VM number, so no VM can use its address.
ATLAS_VIRTUAL_MACHINE_NUMBER = (1 << 64) - 1
WIREGUARD_PREFIX = 0xFDAB
# The prefix and the region take the first 32 bits, so the low 96 bits of the UUID are the peer part.
WIREGUARD_PEER_MASK = (1 << 96) - 1
Expand All @@ -42,10 +43,6 @@ def get_virtual_machine_mesh_address(
frappe.throw(
_("Virtual Machine number {0} is too large for a mesh address.").format(virtual_machine_number)
)
if virtual_machine.tenant_id == 0 and virtual_machine_number == ATLAS_VIRTUAL_MACHINE_NUMBER:
frappe.throw(
_("Virtual Machine number {0} is the Atlas mesh address.").format(virtual_machine_number)
)

address = (
(MESH_PREFIX << 112) | (region_id << 96) | (virtual_machine.tenant_id << 64) | virtual_machine_number
Expand Down
19 changes: 8 additions & 11 deletions atlas/atlas/core/test_mesh_address.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,18 @@ def setUp(self) -> None:
self.get_settings_patch.start()
self.addCleanup(self.get_settings_patch.stop)

def test_a_wide_vm_number_fills_the_64_bit_field(self) -> None:
virtual_machine = frappe._dict(name="vm-18446744073709551615", tenant_id=7)
def test_the_largest_vm_number_leaves_the_padding_zero(self) -> None:
virtual_machine = frappe._dict(name="vm-7-65535", tenant_id=7)

address = get_virtual_machine_mesh_address(virtual_machine)
self.assertEqual(get_virtual_machine_mesh_address(virtual_machine), "fdaa:1:0:7::ffff")

self.assertEqual(address, "fdaa:1:0:7:ffff:ffff:ffff:ffff")
def test_the_vm_number_comes_after_the_tenant_in_the_name(self) -> None:
virtual_machine = frappe._dict(name="vm-42-0007", tenant_id=42)

def test_a_tenant_zero_vm_cannot_take_the_atlas_address(self) -> None:
virtual_machine = frappe._dict(name="vm-18446744073709551615", tenant_id=0)
self.assertEqual(get_virtual_machine_mesh_address(virtual_machine), "fdaa:1:0:2a::7")

with self.assertRaises(frappe.ValidationError):
get_virtual_machine_mesh_address(virtual_machine)

def test_a_vm_number_above_64_bits_is_refused(self) -> None:
virtual_machine = frappe._dict(name="vm-18446744073709551616", tenant_id=7)
def test_a_vm_number_above_16_bits_is_refused(self) -> None:
virtual_machine = frappe._dict(name="vm-7-65536", tenant_id=7)

with self.assertRaises(frappe.ValidationError):
get_virtual_machine_mesh_address(virtual_machine)
Expand Down
2 changes: 1 addition & 1 deletion atlas/service/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ See the [HTTP proxy specification](../../services/http-proxy/SPEC.md).

## IPv6 Router Server

- The pool needs a prefix of `/84` or shorter, no gateway, and no allocations.
- The pool needs a prefix of `/80` or shorter, no gateway, and no allocations.
- Installation fails if the eBPF program is not attached.
- Archive is refused while tenant allocations use the pool.

Expand Down
10 changes: 4 additions & 6 deletions atlas/service/core/ipv6_router/address.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
from atlas.vm.core.models import IPV6_INTERNET_DESTINATION

# Must match bpf/address.h in services/ipv6-router.
TENANT_BITS = 24
MACHINE_BITS = 20
TENANT_BITS = 32
MACHINE_BITS = 16
MAXIMUM_PREFIX_LENGTH = 128 - TENANT_BITS - MACHINE_BITS


Expand All @@ -31,11 +31,9 @@ def get_routed_ipv6(prefix: str, mesh_address: str) -> str:

tenant = (mesh >> 64) & 0xFFFFFFFF
machine = mesh & 0xFFFFFFFFFFFFFFFF
if tenant >= 1 << TENANT_BITS or machine >= 1 << MACHINE_BITS:
if machine >= 1 << MACHINE_BITS:
frappe.throw(
_("Mesh address {0} has a tenant or VM number that the routed IPv6 layout cannot hold.").format(
mesh_address
),
_("Mesh address {0} has padding that the routed IPv6 layout cannot hold.").format(mesh_address),
exc=AtlasUserError,
)

Expand Down
29 changes: 11 additions & 18 deletions atlas/service/core/ipv6_router/test_address.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,24 @@


class TestRoutedIPv6(UnitTestCase):
def test_the_tenant_and_vm_fill_the_low_44_bits(self) -> None:
self.assertEqual(
get_routed_ipv6("2001:db8:1:2:3::/80", "fdaa:1:0:abcd::5"),
"2001:db8:1:2:3:a:bcd0:5",
)
def test_the_tenant_and_vm_fill_the_low_48_bits(self) -> None:
self.assertEqual(get_routed_ipv6("2001:db8:1:2:3::/80", "fdaa:1:0:2a::7"), "2001:db8:1:2:3:0:2a:7")

def test_a_shorter_prefix_keeps_zero_reserved_bits(self) -> None:
self.assertEqual(get_routed_ipv6("2001:db8:1::/48", "fdaa:1:0:2::3"), "2001:db8:1::20:3")
def test_a_shorter_prefix_keeps_the_bits_above_the_host_part_zero(self) -> None:
self.assertEqual(get_routed_ipv6("2001:db8:1::/48", "fdaa:1:0:2::3"), "2001:db8:1::2:3")

def test_the_largest_tenant_and_vm_fit(self) -> None:
self.assertEqual(
get_routed_ipv6("2001:db8::/64", "fdaa:1:ff:ffff::f:ffff"), "2001:db8::fff:ffff:ffff"
get_routed_ipv6("2001:db8::/64", "fdaa:1:ffff:ffff::ffff"), "2001:db8::ffff:ffff:ffff"
)

def test_a_tenant_that_needs_more_than_24_bits_is_refused(self) -> None:
with self.assertRaisesRegex(frappe.ValidationError, "cannot hold"):
get_routed_ipv6("2001:db8::/64", "fdaa:1:100:0::1")

def test_a_vm_number_that_needs_more_than_20_bits_is_refused(self) -> None:
with self.assertRaisesRegex(frappe.ValidationError, "cannot hold"):
get_routed_ipv6("2001:db8::/64", "fdaa:1:0:1::10:0")
def test_an_address_with_padding_is_refused(self) -> None:
with self.assertRaisesRegex(frappe.ValidationError, "padding"):
get_routed_ipv6("2001:db8::/64", "fdaa:1:0:1::1:0")

def test_a_block_smaller_than_84_bits_is_refused(self) -> None:
with self.assertRaisesRegex(frappe.ValidationError, "/84"):
get_routed_ipv6("2001:db8::/96", "fdaa:1:0:1::1")
def test_a_block_smaller_than_80_bits_is_refused(self) -> None:
with self.assertRaisesRegex(frappe.ValidationError, "/80"):
get_routed_ipv6("2001:db8::/84", "fdaa:1:0:1::1")

def test_a_noncanonical_block_is_refused(self) -> None:
with self.assertRaisesRegex(frappe.ValidationError, "canonical IPv6 prefix"):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def test_a_pool_with_allocations_is_refused(self) -> None:
self.validate(public_ip_pool(), has_allocations=True)

def test_a_small_pool_is_refused(self) -> None:
with self.assertRaisesRegex(frappe.ValidationError, "/84"):
with self.assertRaisesRegex(frappe.ValidationError, "/80"):
self.validate(public_ip_pool(prefix="2001:db8::/96"))


Expand Down
2 changes: 1 addition & 1 deletion atlas/vm/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ This file identifies code owners and rules that a code change must preserve. The

## Rules to preserve

- The VM record name is the Metal VM ID from the `vm-.#######` series. A deleted name is never reused.
- The VM record name is the Metal VM ID from the `vm-<tenant ID>-.####` series, one counter for each tenant. The number after the last dash is the VM number in the mesh address, from 1 to 65,535 within the tenant. See [address formats](../../docs/networking/address-formats.md). A deleted name is never reused.
- `VirtualMachineService.create` commits a draft and any public address request before it calls Metal. An uncertain response keeps the draft. Only confirmed Metal absence removes it.
- Metal is the authority for current host state. `Virtual Machine State` is a cache for lists and image checks. A failed Metal read must not appear as a guest state.
- Placement checks capacity under a MariaDB named lock with READ COMMITTED isolation. Keep the lock until the draft or migration reservation commits. CPU can be oversubscribed. Memory and disk cannot.
Expand Down
16 changes: 8 additions & 8 deletions atlas/vm/doctype/virtual_machine/test_virtual_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,16 +359,16 @@ def test_request_rejects_boolean_tenant_id(self) -> None:


class TestVirtualMachineDocument(UnitTestCase):
def test_autoname_assigns_permanent_virtual_machine_id(self) -> None:
virtual_machine = frappe.new_doc("Virtual Machine")

with patch.object(
virtual_machine_module, "make_autoname", return_value="vm-0000042"
) as make_autoname:
def test_each_tenant_numbers_its_virtual_machines_from_one(self) -> None:
self.addCleanup(frappe.db.delete, "Series", {"name": ["in", ["vm-4000000001-", "vm-4000000002-"]]})
names = []
for tenant_id in (4_000_000_001, 4_000_000_001, 4_000_000_002):
virtual_machine = frappe.new_doc("Virtual Machine")
virtual_machine.tenant_id = tenant_id
virtual_machine.autoname()
names.append(virtual_machine.name)

self.assertEqual(virtual_machine.name, "vm-0000042")
make_autoname.assert_called_once_with("vm-.#######", doc=virtual_machine)
self.assertEqual(names, ["vm-4000000001-0001", "vm-4000000001-0002", "vm-4000000002-0001"])

# New records have no Server, so virtual-field reads must skip Metal lookup.
def test_new_document_reads_virtual_fields_without_a_server(self) -> None:
Expand Down
4 changes: 2 additions & 2 deletions atlas/vm/doctype/virtual_machine/virtual_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ class VirtualMachine(Document):
# end: auto-generated types

def autoname(self) -> None:
"""Assign a permanent virtual machine ID."""
self.name = make_autoname("vm-.#######", doc=self)
"""Assign a permanent virtual machine ID. Each tenant has its own counter."""
self.name = make_autoname(f"vm-{self.tenant_id}-.####", doc=self)

@request_cache
def get_metal_vm_info(self) -> MetalVirtualMachine | None:
Expand Down
71 changes: 71 additions & 0 deletions docs/networking/address-formats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Address formats

Atlas derives each private IPv6 address from IDs. No service looks up an address in a table. This page is the reference for each layout.

## Rules for every layout

Each field starts on a 16-bit boundary, so you can read the IDs from the address. The first hextet gives the address type, and the second hextet gives the region.

The tenant ID has 32 bits. A VM number and a WireGuard peer number have 16 bits each and use the last hextet.

Padding fields are zero. They hold space for later fields. Do not use them.

| Prefix | Use |
| --- | --- |
| `fdaa::/16` | VM mesh addresses and the Atlas address |
| `fdab::/16` | Host `wg0` addresses |
| `fdac::/16` | WireGuard gateway peers |

## VM mesh address

```text
fdaa : region : tenant (32 bits) : padding (48 bits) : VM number
```

Example: `vm-42-0007` in region `1` is `fdaa:1:0:2a::7`.

The VM name is `vm-<tenant ID>-<VM number>`. Each tenant has its own counter, so a VM number is unique only within its tenant. Each tenant has the `/64` `fdaa:<region>:<tenant>::/64`. A tenant can have VM numbers from 1 to 65,535. Atlas refuses a larger number.

## Atlas address

```text
fdaa : region : 0 : 0 : ffff : ffff : ffff : ffff
```

The Atlas address is in tenant 0 and has all padding bits set. No VM can have this address.

## Host wg0 address

```text
fdab : region : low 96 bits of the host UUID
```

Example: `fdab:1:7f90:766a:8d2c:2f76:8d76:2045`. This layout has no tenant or VM field.

## WireGuard gateway peer

```text
fdac : region : node : tenant (32 bits) : padding (32 bits) : peer number
```

Example: peer `9` of tenant `42` on `wireguard-002` in region `1` is `fdac:1:2:0:2a::9`.

The node field comes before the tenant. Then each node owns one `/48`, and a host can send the replies for that node to one VM. The node address is `fdac:<region>:<node>::1`. See [WireGuard gateway](wireguard-gateway.md).

## IPv6 router public address

```text
<provider block, /80 or shorter> : tenant (32 bits) : VM number
```

Example: block `2001:db8:1:2:3::/80` maps `fdaa:1:0:2a::7` to `2001:db8:1:2:3:0:2a:7`.

The public address has no padding. A `/80` block has exactly 48 host bits. A mesh address with padding has no public address. See [IPv6 router](ipv6-router.md).

## HTTP proxy auto-proxy label

```text
label = base36((VM number << 32) | tenant)
```

The value has 48 bits, so a label has at most 10 characters. See [OpenResty](http-proxy/openresty.md#auto-proxy-traffic).
4 changes: 2 additions & 2 deletions docs/networking/http-proxy/openresty.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ The wildcard certificate covers site HTTPS traffic. An address of `-` returns `5

An auto-proxy name sends HTTP to a VM without a saved site-map entry. The hostname uses a configured prefix, such as `site-` or `*-vm-`, followed by a base-36 label. OpenResty sends the request to the decoded VM mesh address on port `80`.

Atlas puts the 64-bit VM number above the 32-bit tenant ID: `value = (vm << 32) | tenant`. The VM number is the numeric suffix of its Atlas ID, so `VM-00011` uses `11`. Atlas writes the 96-bit value in lowercase base 36. The regional mesh prefix supplies `fdaa:<region>` when OpenResty decodes the label.
Atlas puts the 16-bit VM number above the 32-bit tenant ID: `value = (vm << 32) | tenant`. The VM number is the numeric suffix of its Atlas ID, so `vm-2-0011` uses `11`. Atlas writes the 48-bit value in lowercase base 36, in at most 10 characters. The regional mesh prefix supplies `fdaa:<region>` when OpenResty decodes the label.

::: details Try the base-36 mapping in Python

Expand Down Expand Up @@ -62,7 +62,7 @@ print(name, mesh) # site-lpc8lqa.example.com fdaa:1:0:2::b

:::

The proxy accepts only configured host prefixes and a canonical lowercase label. The label has at most 19 digits and no leading zero. Values larger than 96 bits and unsupported prefixes do not route. The control daemon reserves matching site keys so a stored route cannot override an auto-proxy name.
The proxy accepts only configured host prefixes and a canonical lowercase label. The label has at most 10 digits and no leading zero. Values larger than 48 bits and unsupported prefixes do not route. The control daemon reserves matching site keys so a stored route cannot override an auto-proxy name.

::: info Static route, live destination
The name encodes tenant and VM identity, not a host location. WG Mesh finds the VM's current Metal host after a move. An auto-proxy name alone does not grant access through tenant or guest firewall rules.
Expand Down
4 changes: 2 additions & 2 deletions docs/networking/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ Atlas itself is one more `wg0` peer on each host, outside the mesh peer list. Ho
Each VM gets a stable address. The Atlas app derives it when it creates the VM:

```text
fdaa : region : tenant : VM number
fdaa : region : tenant : padding : VM number
```

For example, `fdaa:1:0:2::3` is VM `3` of tenant `2` in region `1`.
For example, `fdaa:1:0:2::3` is VM `3` of tenant `2` in region `1`. See [address formats](address-formats.md) for every layout.

The address stays with the VM when it moves. WG Mesh checks a learned host location for each packet and starts a lookup when it has no valid entry.

Expand Down
22 changes: 11 additions & 11 deletions docs/networking/ipv6-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ The VM can move while its mesh and public addresses stay the same. WG Mesh finds
## How the address maps

```text
public = <block prefix> | reserved, all zero | tenant 24 bits | VM 20 bits
private = fdaa | region 16 bits | tenant 32 bits | VM 64 bits
public = <block prefix> | tenant 32 bits | VM 16 bits
private = fdaa | region 16 bits | tenant 32 bits | padding 48 bits | VM 16 bits
```

For block `2001:db8:1:2:3::/80`, mesh address `fdaa:1:0:abcd::5` maps to `2001:db8:1:2:3:a:bcd0:5`.
For block `2001:db8:1:2:3::/80`, mesh address `fdaa:1:0:2a::7` maps to `2001:db8:1:2:3:0:2a:7`. The tenant and the VM number keep their own hextets.

The block must be inside `2000::/3`, with a prefix from `/3` to `/84`. A VM whose tenant ID is 2^24 or more, or whose VM number is 2^20 or more, gets no public address.
The block must be inside `2000::/3`, with a prefix from `/3` to `/80`. In a shorter block, the bits between the block and the host part are zero. A mesh address with padding, such as the Atlas address, has no public address. See [address formats](address-formats.md).

::: details Try the IPv6 address mapping in Python

Expand All @@ -34,21 +34,21 @@ This example derives the public address and recovers the tenant and VM number fr
from ipaddress import IPv6Address, IPv6Network

block = IPv6Network("2001:db8:1:2:3::/80")
mesh = IPv6Address("fdaa:1:0:abcd::5")
mesh = IPv6Address("fdaa:1:0:2a::7")
region = 1

tenant = (int(mesh) >> 64) & 0xFFFFFFFF
vm = int(mesh) & 0xFFFFFFFFFFFFFFFF
assert tenant < 1 << 24 and vm < 1 << 20
assert vm < 1 << 16

public = IPv6Address(int(block.network_address) | (tenant << 20) | vm)
public = IPv6Address(int(block.network_address) | (tenant << 16) | vm)
suffix = int(public) - int(block.network_address)
decoded_tenant, decoded_vm = divmod(suffix, 1 << 20)
decoded_tenant, decoded_vm = divmod(suffix, 1 << 16)
decoded_mesh = IPv6Address(
(0xFDAA << 112) | (region << 96) | (decoded_tenant << 64) | decoded_vm
)
print(public, decoded_mesh)
# 2001:db8:1:2:3:a:bcd0:5 fdaa:1:0:abcd::5
# 2001:db8:1:2:3:0:2a:7 fdaa:1:0:2a::7
```

:::
Expand All @@ -61,8 +61,8 @@ The [gateway packet example](wg-mesh/gateways.md#how-the-packet-changes) shows t

A `tc` eBPF program on the router's `eth0` ingress rewrites the address and fixes the checksum. For an ICMPv6 error, it also rewrites the address inside the quoted packet. It drops:

- A packet to a block address with a reserved bit set.
- A mesh source from another region, or a tenant or VM number that does not fit the public layout.
- A packet to a block address with a set bit between the block and the host part.
- A mesh source from another region, or a mesh source with padding.
- A translated packet that is not TCP, UDP, ICMPv6, or a fragment of one of them.

## Block unassigned traffic
Expand Down
2 changes: 1 addition & 1 deletion docs/networking/public-ips.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ The router receives the full prefix. Each tenant VM gets a derived `/128`, so At

Requirements:

- Prefix length `/84` or shorter. AWS `/80` and Scaleway `/64` qualify.
- Prefix length `/80` or shorter. AWS `/80` and Scaleway `/64` qualify.
- No allocations in the pool before router creation.
- An Active router before routed automatic assignment.

Expand Down
2 changes: 1 addition & 1 deletion docs/networking/wg-mesh/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This page lists the exact rules and state of WG Mesh. Read [how VMs reach each o

| Range | Use |
| --- | --- |
| `fdaa::/16` | VM addresses: `fdaa \| region 16 bits \| tenant 32 bits \| VM ID 64 bits`. |
| `fdaa::/16` | VM addresses: `fdaa \| region 16 bits \| tenant 32 bits \| padding 48 bits \| VM 16 bits`. See [address formats](../address-formats.md). |
| `fdab::/16` | Host WireGuard addresses. |

For example, `fdaa:1:0:2::3` is region `1`, tenant `2`, VM `3`. The data path reads the tenant field for isolation.
Expand Down
8 changes: 5 additions & 3 deletions docs/networking/wireguard-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ The answer holds the WireGuard settings of the device:

| Field | Example | Use |
| --- | --- | --- |
| `address` | `fdac:1:2:0:2a:0:9:0/128` | Interface address of the device. |
| `address` | `fdac:1:2:0:2a::9/128` | Interface address of the device. |
| `allowed_ips` | `["fdaa:1:0:2a::/64"]` | The VM addresses of the tenant. |
| `endpoint` | `wireguard-002.par-1.example.com:51820` | The node that serves the device. |
| `public_key` | `<node public key>` | Public key of that node. |

The `tenant_id` is from 1 to 4,294,967,295, and the `client_id` is from 1 to 65,535.

A repeated request with the same key returns the same settings. A new device goes to the node with the fewest devices. `DELETE /v1/peers` with `{"tenant_id": 42, "client_id": 9}` removes a device, and `GET /v1/peers` lists every device with its `node_id`.

`PUT /v1/peers` replaces the complete table, for example to restore it from Central's records. Send each device's `node_id` from the list, because the device address carries the node number. A device without `node_id` goes to the node with the fewest devices.
Expand All @@ -50,10 +52,10 @@ The gateway eBPF filters drop a packet unless the device and the VM belong to th
## Addresses

```text
fdac | region 16 | node 16 | tenant 32 | client 32 | zero 16
fdac | region 16 | node 16 | tenant 32 | padding 32 | client 16
```

Each node owns one `fdac:<region>:<node>::/48`. Atlas reserves `fdaa::/16`, `fdab::/16`, and `fdac::/16`. A customer network that uses one of these ranges conflicts with the routes of its devices, so it must use another range.
Each node owns one `fdac:<region>:<node>::/48`. See [address formats](address-formats.md#wireguard-gateway-peer). Atlas reserves `fdaa::/16`, `fdab::/16`, and `fdac::/16`. A customer network that uses one of these ranges conflicts with the routes of its devices, so it must use another range.

## Add a node

Expand Down
Loading
Loading