public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [RFC] sdn: OVN zone and controller plugins
@ 2026-10-09  9:22 Rémi PAETA
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
  0 siblings, 1 reply; 18+ messages in thread
From: Rémi PAETA @ 2026-10-09  9:22 UTC (permalink / raw)
  To: pve-devel

Hi all, 

I work at Comaite, where we run Proxmox VE clusters, and I would like 
to discuss adding OVN (Open Virtual Network) support to the SDN stack 
before sending patches. 

Vision 
====== 

OVN provides a distributed virtual network whose control plane knows 
where every guest port lives. Through the existing SDN objects, it 
could offer: 

Zones 
- an "ovn" zone type tied to an "ovn" controller describing the OVN 
deployment, spanning the nodes of the zone over Geneve (or VXLAN) 
tunnels 
- optional port security per zone: off (default, like a Linux 
bridge), MAC only, or MAC and IPAM addresses 

Vnets 
- each vnet is an OVN logical switch, a distributed L2 segment 
- guests keep their L2 connectivity across live migrations, with the 
port location updated by OVN 

Subnets 
- a distributed logical router per zone, with the subnet gateway as a 
router port, so that traffic between the vnets of a zone is routed 
locally on each node 
- SNAT and external connectivity through a gateway router, or a 
distributed gateway port, hosted on chosen nodes with failover 
between them 
- addresses from the IPAM (PVE, Netbox, phpIPAM) used for the port 
addresses and port security 

DHCP and DNS 
- OVN native DHCPv4/DHCPv6 and router advertisements, answered by 
ovn-controller on the local node from the IPAM mappings, without a 
dnsmasq instance 
- the existing DNS plugin integration (PowerDNS) unchanged 

Firewall 
- the existing guest firewall keeps working 
- later, possibly OVN ACLs, port groups and address sets 

I would propose to deliver this in steps: 

1. controller, zones and vnets: L2 overlay, port security, guest 
firewall, IPAM and DNS integration 
2. subnets: distributed routing, gateway, SNAT and external access 
3. native DHCP and router advertisements 
4. optionally, OVN ACLs for the firewall 

Design principles 
================= 

- The OVN central components (northbound/southbound databases, 
ovn-northd) are deployed by the administrator, outside of PVE, 
ideally as a RAFT cluster, on the PVE nodes or elsewhere. PVE only 
needs their URLs, declared in the "ovn" controller, much like the 
Netbox or phpIPAM plugins use an existing service. 

- PVE manages the logical switches, routers and ports it created, 
tagged in external_ids, so the northbound database can be shared 
with other consumers, and configures ovn-controller on every node 
of the zone. 

- Everything lives in pve-network. pve-manager does not need any 
change; proxmox-ve-rs needs the new zone type to be known so that 
the nftables firewall keeps working. 

I have a working prototype of the first step (controller, zones and 
vnets) on a three node test cluster. We have not signed the CLA yet, 
but will sign the entity CLA before sending any patch. 

Questions 
========= 

1. Are you interested in OVN support upstream? 

2. Is relying on externally deployed OVN central components fine for 
a first version, possibly with a deployment helper later? 

3. Does the mapping of zones, vnets, subnets and DHCP above match how 
you see the SDN model, and is a step by step delivery fine? 

4. Are there plans or constraints in the SDN stack (fabrics, the Rust 
rewrite, the firewall) that this should align with? 

If the direction suits you, I would send the first step as an RFC 
series with the implementation details and test results. 

Regards, 

------------------------------------------------------ 



^ permalink raw reply	[flat|nested] 18+ messages in thread

* [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add OVN zone and controller plugins
  2026-10-09  9:22 [RFC] sdn: OVN zone and controller plugins Rémi PAETA
@ 2026-10-13 12:14 ` Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH ve-rs 01/16] ve-config: sdn: add ovn zone type Rémi PAETA
                     ` (15 more replies)
  0 siblings, 16 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

This series adds OVN (Open Virtual Network) support to the SDN stack,
as first discussed in:

  https://lore.proxmox.com/pve-devel/1116090294.1714263.1791537779868.JavaMail.zimbra@comaite.com/

It covers the first step outlined there: an OVN controller, OVN zones
and vnets, providing a distributed L2 overlay with optional per-port
anti-spoofing. Routing (subnet gateways, SNAT), native DHCP and OVN
ACLs are left for later steps.

Sent as RFC, as some design choices below would benefit from your
feedback before going further.

I am contributing on behalf of my employer, Comaite, whose entity CLA
has been accepted by Proxmox.


Overview
========

- The OVN central components (northbound/southbound databases,
  ovn-northd) are deployed by the administrator, ideally as a RAFT
  cluster, on the PVE nodes or elsewhere. An "ovn" SDN controller holds
  their connection strings, plain TCP or SSL.

- Each vnet of an "ovn" zone is an OVN logical switch. On reload, the
  northbound database is synced once for the whole cluster: switches
  get created, updated and deleted, ports of removed guests and guest
  interfaces get cleaned up. Only objects tagged with pve-sdn-*
  external_ids are touched, so the deployment can be shared.

- On every node of an OVN zone, ovn-controller gets configured (remote,
  encapsulation, tunnel IP from the route towards the southbound
  database). pvenetcommit.service removes the Open vSwitch database on
  every boot, so a oneshot pve-sdn-ovn-chassis.service sets it again
  before the guests start.

- Guest interfaces are plugged into br-int and bound to a logical
  switch port named after them. Each vnet still gets an empty OVS
  bridge, which lets qemu-server and pve-container read its MTU and set
  up the firewall bridge like for any OVS bridge, so the guest firewall
  works unchanged. pve-manager needs no backend change.

- Port security, per zone with a per-vnet override: off (default),
  mac, or mac-ip with the IPAM addresses (IPv4 and IPv6). The guest MAC
  is set from add_bridge_fdb. A changed level applies on reload,
  without restarting guests.

- With SSL, the administrator provides the CA and one certificate per
  node in /etc/pve/priv/sdn/ovn/<controller>/. ovn-controller only
  takes certificates on its command line, so a runtime drop-in for its
  unit adds them; it is only restarted when that setup changes.

- Guest ports are bound to the chassis of their node (requested-chassis
  option). On live migration, they are bound to both nodes with
  activation-strategy=rarp, and back to the destination only when
  qemu-server calls add_bridge_fdb on resume, see below.


Changes since the discussion mail
=================================

- Port security can be overridden per vnet, which adds a
  'port-security' option to the common vnet schema (refused on other
  zone types, see the sixth network patch).

- The web UI is now part of the series (pve-manager patches).

- The proxmox-ve-rs patch is needed for both firewalls: without it,
  proxmox-firewall fails on the whole SDN configuration, and
  pve-firewall, through libpve-rs-perl, generates no SDN IPSet at all,
  for any zone, as soon as an OVN zone exists. My first mail only
  mentioned the nftables firewall.

- SSL connections to the OVN databases.

- Live migration: while QEMU runs on both nodes during the memory copy,
  both ovn-controller instances kept claiming the port from each other,
  every 500 ms, until the source released it. Measured with a probe
  every ~55 ms, this lost 2 to 3 s of traffic, while QEMU reported a
  downtime of ~20 ms. Binding the port to both chassis with
  activation-strategy=rarp during the migration, as OVN recommends for
  this case, and to the destination only on resume (qemu-server already
  calls add_bridge_fdb there) brings it down to 0.4 to 0.8 s, close to
  the resolution of the probe. This needs no change outside
  pve-network; the chassis hostname is set to the node name, as
  requested-chassis refers to it and Open vSwitch uses the FQDN.


Dependencies
============

- proxmox-ve-rs (first patch): libpve-rs-perl and proxmox-firewall
  need to be rebuilt with it before OVN zones can be used, so
  pve-network will likely need Breaks on older versions of both. How
  would you prefer to handle this?

- pve-manager depends on a libpve-network-perl knowing the vnet
  'port-security' option.

- Hotplugged NICs of VMs only get their MAC and port security on the
  next start, as qemu-server does not call add_bridge_fdb on hotplug.
  This is the existing bug #6490 for bridges with MAC learning
  disabled; I sent a separate fix for it:

    https://lore.proxmox.com/pve-devel/20261013095852.2586609-1-remi.paeta@comaite.com/


Known limitations
=================

- No routing, SNAT or DHCP yet: subnet gateways, SNAT and DHCP ranges
  are refused on OVN zones, as are VLAN tags, VLAN-aware vnets and
  isolated ports.

- With mac-ip, IPv6 link-local traffic is only allowed from the
  address derived from the MAC, which breaks guests using stable
  privacy link-local addresses for link-local protocols.

- If the IPAM cannot be queried, a reload keeps the current port
  addresses, while a starting guest only gets its MAC enforced.

- If the northbound database cannot be reached, running guests keep
  their connectivity, but guests on OVN zones cannot be started.


Testing
=======

Unit tests cover the new code (332 tests in pve-network). On a three
node cluster with the OVN databases clustered on the nodes, over SSL:

- VM and container connectivity across nodes, live migration
  (0.4 to 0.8 s of loss, QEMU downtime ~20 ms), container migration
- guest firewall with iptables and nftables, ipfilter, macfilter,
  firewall toggling on running guests
- port security levels with IPv4 and IPv6, spoofed MAC and IPs blocked
- northbound/southbound leader failover and full outage of all
  database members (no loss for running guests)
- node crash, HA recovery and port claim on another node with a stale
  binding of the dead node
- Geneve to VXLAN encapsulation switch and back
- zone restricted to nodes, removal of zones and of the controller
- orphaned ports after removing or moving guest interfaces


Design notes
============

- The OVN central components are deployed and operated separately, by
  the administrator. Deploying and managing them directly on the
  Proxmox VE cluster (for example as a RAFT cluster on some of the
  nodes, set up from the API) could be a later evolution.

- The guest start is refused while the northbound database cannot be
  reached. Starting the guest without its port would not help: the
  southbound database usually runs on the same hosts and is down too,
  so no chassis can claim a port, and a guest started elsewhere by HA
  could not be claimed anyway, as requested-chassis still names the
  previous node and only the northbound database can change it.

- With mac-ip and an unreachable IPAM, a starting guest only gets its
  MAC enforced (fail-open). The IPs are looked up through
  Ipams::get_ips_from_mac, so the MAC cache answers for known guests,
  and this only happens for a MAC the IPAM never returned before. Zones
  with DHCP fail the guest start in that case instead (through
  add_dhcp_mapping); I kept fail-open here so that an IPAM outage does
  not prevent starting guests, but can change it if you prefer.

- Certificates are left to the PKI of the OVN deployment (renewal,
  expiry). An API/GUI import could come later, ideally with the key
  generated on each node and only the signed certificate imported.


Open questions
==============

1. Each vnet currently gets an empty OVS bridge, while the guest
   interfaces are plugged into br-int. It only exists because
   qemu-server reads the vnet MTU from /sys/class/net/<vnet>/mtu
   (read_bridge_mtu, which also checks the bridges on migration
   targets), and to keep the firewall bridge setup of OVS bridges. This
   needs no change outside pve-network. Is this acceptable, or would
   you prefer one of these alternatives?

   a) An SDN dispatcher, for example
      PVE::Network::SDN::Zones::get_bridge_mtu(), returning the zone
      MTU for OVN vnets and reading sysfs otherwise, called by
      qemu-server and pve-container instead of read_bridge_mtu(). No
      placeholder interface anymore, but it touches both repositories
      (pve-common cannot depend on pve-network), and every place
      assuming that a vnet is a local interface would need the same
      treatment.

   b) A lighter placeholder: a dummy interface named after the vnet
      instead of an OVS bridge. Still no change outside pve-network,
      and nothing in the Open vSwitch database, but the firewall bridge
      setup would need to be checked again.

2. Is extending the common vnet schema with 'port-security' fine? As
   vnets have a single section type, I saw no other place for a per-vnet
   option.

3. VLAN-aware vnets are out of scope for this series. Guest-tagged
   trunks could later use the vlan-passthru option of logical switches,
   while per-NIC VLAN tags would need one logical switch per VLAN. Is
   either of interest?

4. Not specific to OVN, but noticed while testing it: deleting a vnet
   without subnets is not refused while guests still use it, on any
   zone type. Running guests end up unplugged and fail on their next
   start. Would you take a separate patch refusing the deletion with the
   list of guests using the vnet?


ve-rs:

 proxmox-ve-config/src/sdn/config.rs |  3 +++
 proxmox-ve-config/tests/sdn/main.rs | 28 ++++++++++++++++++++++++++++
 2 files changed, 31 insertions(+)

network:

 debian/control                                |   1 +
 debian/libpve-network-perl.install            |   1 +
 debian/libpve-network-perl.postinst           |   2 +
 src/PVE/API2/Network/SDN.pm                   |   8 +
 src/PVE/API2/Network/SDN/Vnets.pm             |   8 +
 src/PVE/Network/SDN.pm                        |  39 +-
 src/PVE/Network/SDN/Controllers.pm            |   6 +
 src/PVE/Network/SDN/Controllers/Makefile      |   2 +-
 src/PVE/Network/SDN/Controllers/OvnPlugin.pm  |  90 ++
 src/PVE/Network/SDN/Makefile                  |   3 +-
 src/PVE/Network/SDN/Ovn.pm                    | 1024 ++++++++++++++++++
 src/PVE/Network/SDN/SubnetPlugin.pm           |   9 +
 src/PVE/Network/SDN/VnetPlugin.pm             |   8 +
 src/PVE/Network/SDN/Zones.pm                  |   2 +
 src/PVE/Network/SDN/Zones/Makefile            |   2 +-
 src/PVE/Network/SDN/Zones/OvnPlugin.pm        | 184 ++++
 src/services/Makefile                         |   1 +
 src/services/pve-sdn-ovn-chassis.service      |  16 +
 src/test/Makefile                             |   5 +-
 src/test/run_test_ovn.pl                      | 835 ++++++++++++++
 src/test/run_test_vnets_blackbox.pl           |  21 +
 .../ovn/basic/expected_controller_config      |   8 +
 .../zones/ovn/basic/expected_sdn_interfaces   |  12 +
 src/test/zones/ovn/basic/interfaces           |   5 +
 src/test/zones/ovn/basic/sdn_config           |  24 +
 .../ovn/with_evpn/expected_controller_config  |  44 +
 .../ovn/with_evpn/expected_sdn_interfaces     |  47 +
 src/test/zones/ovn/with_evpn/interfaces       |   7 +
 src/test/zones/ovn/with_evpn/sdn_config       |  35 +
 29 files changed, 2444 insertions(+), 5 deletions(-)

manager:

 www/manager6/Makefile                      |  2 +
 www/manager6/Utils.js                      | 10 +++++
 www/manager6/form/SDNControllerSelector.js |  9 +++-
 www/manager6/sdn/VnetEdit.js               | 37 +++++++++++++++-
 www/manager6/sdn/controllers/OvnEdit.js    | 50 ++++++++++++++++++++++
 www/manager6/sdn/zones/OvnEdit.js          | 34 +++++++++++++++
 6 files changed, 139 insertions(+), 3 deletions(-)

docs:

 pvesdn.adoc | 295 +++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 293 insertions(+), 2 deletions(-)

Summary over all repositories:
  38 files changed, 2907 insertions(+), 10 deletions(-)




^ permalink raw reply	[flat|nested] 18+ messages in thread

* [RFC PATCH ve-rs 01/16] ve-config: sdn: add ovn zone type
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
@ 2026-10-13 12:14   ` Rémi PAETA
  2026-10-13 12:14   ` SPAM: [RFC PATCH network 02/16] sdn: controllers: add ovn plugin Rémi PAETA
                     ` (14 subsequent siblings)
  15 siblings, 0 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

pve-network gains an "ovn" zone type. An unknown zone type makes the
parsing of the whole SDN running configuration fail, so
proxmox-firewall would stop generating any rule on nodes with OVN
zones, for all guests.

The zone type does not need any specific handling: OVN zones only
carry vnets and subnets like the other zone types.

Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
 proxmox-ve-config/src/sdn/config.rs |  3 +++
 proxmox-ve-config/tests/sdn/main.rs | 28 ++++++++++++++++++++++++++++
 2 files changed, 31 insertions(+)

diff --git a/proxmox-ve-config/src/sdn/config.rs b/proxmox-ve-config/src/sdn/config.rs
index 2f30cf2..5a5c291 100644
--- a/proxmox-ve-config/src/sdn/config.rs
+++ b/proxmox-ve-config/src/sdn/config.rs
@@ -69,6 +69,7 @@ pub enum ZoneType {
     Qinq,
     Vxlan,
     Evpn,
+    Ovn,
 }
 
 proxmox_serde::forward_deserialize_to_from_str!(ZoneType);
@@ -84,6 +85,7 @@ impl FromStr for ZoneType {
             "qinq" => Ok(ZoneType::Qinq),
             "vxlan" => Ok(ZoneType::Vxlan),
             "evpn" => Ok(ZoneType::Evpn),
+            "ovn" => Ok(ZoneType::Ovn),
             _ => Err(SdnConfigError::InvalidZoneType),
         }
     }
@@ -97,6 +99,7 @@ impl Display for ZoneType {
             ZoneType::Qinq => "qinq",
             ZoneType::Vxlan => "vxlan",
             ZoneType::Evpn => "evpn",
+            ZoneType::Ovn => "ovn",
         })
     }
 }
diff --git a/proxmox-ve-config/tests/sdn/main.rs b/proxmox-ve-config/tests/sdn/main.rs
index a5f6845..5585669 100644
--- a/proxmox-ve-config/tests/sdn/main.rs
+++ b/proxmox-ve-config/tests/sdn/main.rs
@@ -72,6 +72,34 @@ fn parse_running_config() {
     assert_eq!(sdn_config, parsed_config);
 }
 
+#[test]
+fn parse_running_config_ovn_zone() {
+    let running_config: RunningConfig = serde_json::from_str(
+        r#"{
+            "zones": { "ids": { "ovnzone": { "type": "ovn", "controller": "ovnctl", "ipam": "pve" } } },
+            "vnets": { "ids": { "ovnvnet": { "type": "vnet", "zone": "ovnzone" } } },
+            "subnets": { "ids": {} }
+        }"#,
+    )
+    .unwrap();
+
+    let parsed_config = SdnConfig::try_from(running_config).unwrap();
+
+    let sdn_config = SdnConfig::from_zones([ZoneConfig::from_vnets(
+        ZoneName::from_str("ovnzone").unwrap(),
+        ZoneType::Ovn,
+        [VnetConfig::new(
+            VnetName::from_str("ovnvnet").unwrap(),
+            None,
+        )],
+    )
+    .unwrap()])
+    .unwrap();
+
+    assert_eq!(sdn_config, parsed_config);
+    assert_eq!(ZoneType::Ovn.to_string(), "ovn");
+}
+
 #[test]
 fn sdn_config() {
     let mut sdn_config = SdnConfig::new();
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 18+ messages in thread

* SPAM: [RFC PATCH network 02/16] sdn: controllers: add ovn plugin
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH ve-rs 01/16] ve-config: sdn: add ovn zone type Rémi PAETA
@ 2026-10-13 12:14   ` Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH network 03/16] sdn: zones: " Rémi PAETA
                     ` (13 subsequent siblings)
  15 siblings, 0 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

An OVN controller describes an existing OVN deployment: the connection
strings of its northbound and southbound databases and the tunnel
encapsulation used between the chassis. The OVN central components are
deployed and operated by the administrator, PVE only connects to them,
similar to how the external IPAM plugins use an existing service.

Only plain TCP connections are supported for now.

OVN does not use FRR, so OVN controllers are not taken into account
when deciding whether an FRR configuration needs to be generated.

Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
 src/PVE/Network/SDN.pm                       |  4 +-
 src/PVE/Network/SDN/Controllers.pm           |  2 +
 src/PVE/Network/SDN/Controllers/Makefile     |  2 +-
 src/PVE/Network/SDN/Controllers/OvnPlugin.pm | 86 ++++++++++++++++++++
 src/test/Makefile                            |  5 +-
 src/test/run_test_ovn.pl                     | 28 +++++++
 6 files changed, 124 insertions(+), 3 deletions(-)
 create mode 100644 src/PVE/Network/SDN/Controllers/OvnPlugin.pm
 create mode 100755 src/test/run_test_ovn.pl

diff --git a/src/PVE/Network/SDN.pm b/src/PVE/Network/SDN.pm
index 33a3cf3..aeb9b4c 100644
--- a/src/PVE/Network/SDN.pm
+++ b/src/PVE/Network/SDN.pm
@@ -126,13 +126,15 @@ sub running_config_has_frr {
 
     # all can be empty if the SDN configuration was never applied
     my $controllers = $running_config->{controllers}->{ids} // {};
+    # OVN controllers do not generate any FRR configuration
+    my @frr_controllers = grep { $controllers->{$_}->{type} ne 'ovn' } keys %$controllers;
     my $fabrics = $running_config->{fabrics}->{ids} // {};
     my $route_maps = $running_config->{'route-maps'}->{ids} // {};
     my $prefix_lists = $running_config->{'prefix-lists'}->{ids} // {};
 
     my $local_frr_config = PVE::Network::SDN::Frr::local_frr_config_exists();
 
-    return %$controllers || %$fabrics || %$route_maps || %$prefix_lists || $local_frr_config;
+    return @frr_controllers || %$fabrics || %$route_maps || %$prefix_lists || $local_frr_config;
 }
 
 sub pending_config {
diff --git a/src/PVE/Network/SDN/Controllers.pm b/src/PVE/Network/SDN/Controllers.pm
index b2c2f9d..ac0d70a 100644
--- a/src/PVE/Network/SDN/Controllers.pm
+++ b/src/PVE/Network/SDN/Controllers.pm
@@ -15,11 +15,13 @@ use PVE::Network::SDN::Controllers::EvpnPlugin;
 use PVE::Network::SDN::Controllers::BgpPlugin;
 use PVE::Network::SDN::Controllers::IsisPlugin;
 use PVE::Network::SDN::Controllers::FaucetPlugin;
+use PVE::Network::SDN::Controllers::OvnPlugin;
 use PVE::Network::SDN::Controllers::Plugin;
 PVE::Network::SDN::Controllers::EvpnPlugin->register();
 PVE::Network::SDN::Controllers::BgpPlugin->register();
 PVE::Network::SDN::Controllers::IsisPlugin->register();
 PVE::Network::SDN::Controllers::FaucetPlugin->register();
+PVE::Network::SDN::Controllers::OvnPlugin->register();
 PVE::Network::SDN::Controllers::Plugin->init();
 
 sub sdn_controllers_config {
diff --git a/src/PVE/Network/SDN/Controllers/Makefile b/src/PVE/Network/SDN/Controllers/Makefile
index fd9f881..a1cd14e 100644
--- a/src/PVE/Network/SDN/Controllers/Makefile
+++ b/src/PVE/Network/SDN/Controllers/Makefile
@@ -1,4 +1,4 @@
-SOURCES=Plugin.pm FaucetPlugin.pm EvpnPlugin.pm BgpPlugin.pm IsisPlugin.pm
+SOURCES=Plugin.pm FaucetPlugin.pm EvpnPlugin.pm BgpPlugin.pm IsisPlugin.pm OvnPlugin.pm
 
 
 PERL5DIR=${DESTDIR}/usr/share/perl5
diff --git a/src/PVE/Network/SDN/Controllers/OvnPlugin.pm b/src/PVE/Network/SDN/Controllers/OvnPlugin.pm
new file mode 100644
index 0000000..f7a9b96
--- /dev/null
+++ b/src/PVE/Network/SDN/Controllers/OvnPlugin.pm
@@ -0,0 +1,86 @@
+package PVE::Network::SDN::Controllers::OvnPlugin;
+
+use v5.36;
+
+use PVE::JSONSchema;
+use PVE::Tools;
+
+use PVE::Network::SDN::Controllers::Plugin;
+
+use base('PVE::Network::SDN::Controllers::Plugin');
+
+PVE::JSONSchema::register_format('pve-sdn-ovn-remotes', \&pve_verify_sdn_ovn_remotes);
+
+sub pve_verify_sdn_ovn_remotes {
+    my ($remotes, $noerr) = @_;
+
+    for my $remote (split(/,/, $remotes)) {
+        next if $remote =~ m/^tcp:$PVE::Tools::IPV4RE:\d+$/;
+        next if $remote =~ m/^tcp:\[$PVE::Tools::IPV6RE\]:\d+$/;
+
+        return undef if $noerr;
+        die "invalid OVN database connection '$remote', expected tcp:<ip>:<port>\n";
+    }
+
+    return $remotes;
+}
+
+sub type {
+    return 'ovn';
+}
+
+sub properties {
+    return {
+        'nb-connection' => {
+            description => "Comma separated list of the OVN northbound database servers,"
+                . " for example tcp:10.0.0.1:6641,tcp:10.0.0.2:6641.",
+            type => 'string',
+            format => 'pve-sdn-ovn-remotes',
+        },
+        'sb-connection' => {
+            description => "Comma separated list of the OVN southbound database servers,"
+                . " for example tcp:10.0.0.1:6642,tcp:10.0.0.2:6642.",
+            type => 'string',
+            format => 'pve-sdn-ovn-remotes',
+        },
+        'encap-type' => {
+            description => "Tunnel encapsulation used between the chassis.",
+            type => 'string',
+            enum => ['geneve', 'vxlan'],
+            default => 'geneve',
+        },
+    };
+}
+
+sub options {
+    return {
+        'nb-connection' => { optional => 0 },
+        'sb-connection' => { optional => 0 },
+        'encap-type' => { optional => 1 },
+    };
+}
+
+# OVN does not use FRR
+sub generate_frr_config {
+    my ($class, $plugin_config, $controller_cfg, $id, $uplinks, $config) = @_;
+
+    return;
+}
+
+sub generate_zone_frr_config {
+    my ($class, $plugin_config, $controller, $controller_cfg, $id, $uplinks, $config) = @_;
+
+    return;
+}
+
+sub on_delete_hook {
+    my ($class, $controllerid, $zone_cfg) = @_;
+
+    for my $id (sort keys %{ $zone_cfg->{ids} }) {
+        my $zone = $zone_cfg->{ids}->{$id};
+        die "controller $controllerid is used by $id\n"
+            if defined($zone->{controller}) && $zone->{controller} eq $controllerid;
+    }
+}
+
+1;
diff --git a/src/test/Makefile b/src/test/Makefile
index 5a937a4..b3c5d69 100644
--- a/src/test/Makefile
+++ b/src/test/Makefile
@@ -1,6 +1,6 @@
 all: test
 
-test: test_zones test_ipams test_dns test_subnets test_vnets_blackbox
+test: test_zones test_ipams test_dns test_subnets test_vnets_blackbox test_ovn
 
 test_zones: run_test_zones.pl
 	./run_test_zones.pl
@@ -17,4 +17,7 @@ test_subnets: run_test_subnets.pl
 test_vnets_blackbox: run_test_vnets_blackbox.pl
 	./run_test_vnets_blackbox.pl
 
+test_ovn: run_test_ovn.pl
+	./run_test_ovn.pl
+
 clean:
diff --git a/src/test/run_test_ovn.pl b/src/test/run_test_ovn.pl
new file mode 100755
index 0000000..60e752b
--- /dev/null
+++ b/src/test/run_test_ovn.pl
@@ -0,0 +1,28 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+
+use lib qw(..);
+
+use Test::More;
+
+use PVE::Network::SDN;
+use PVE::Network::SDN::Controllers::OvnPlugin;
+
+# controller schema
+
+ok(
+    PVE::Network::SDN::Controllers::OvnPlugin::pve_verify_sdn_ovn_remotes(
+        'tcp:10.0.0.1:6641,tcp:[fd00::1]:6641', 1,
+    ),
+    'remotes format: valid',
+);
+for my $invalid ('ssl:10.0.0.1:6641', 'tcp:10.0.0.1', 'tcp:ovn.example.com:6641') {
+    ok(
+        !PVE::Network::SDN::Controllers::OvnPlugin::pve_verify_sdn_ovn_remotes($invalid, 1),
+        "remotes format: invalid '$invalid'",
+    );
+}
+
+done_testing();
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 18+ messages in thread

* [RFC PATCH network 03/16] sdn: zones: add ovn plugin
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH ve-rs 01/16] ve-config: sdn: add ovn zone type Rémi PAETA
  2026-10-13 12:14   ` SPAM: [RFC PATCH network 02/16] sdn: controllers: add ovn plugin Rémi PAETA
@ 2026-10-13 12:14   ` Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH network 04/16] sdn: ovn: sync the northbound database on reload Rémi PAETA
                     ` (12 subsequent siblings)
  15 siblings, 0 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

Every vnet of an OVN zone maps to the OVN logical switch of the same
name. Guest interfaces get plugged into the OVN integration bridge
(br-int), through a firewall bridge if the guest firewall is enabled,
and bound to the logical switch port named after the interface. New
ports get the 'unknown' address, so the logical switch forwards to
them like a learning switch.

Each vnet still gets an OVS bridge in the SDN interfaces file. It never
has any port, but it lets qemu-server and pve-container read the vnet
MTU, and since it is an OVS bridge, they set up the firewall bridge
like for any other OVS bridge, so the guest firewall works unchanged.

br-int is created by ovn-controller and deliberately not managed by
ifupdown2, whose OVS addon would remove the guest ports it does not
know about.

The default MTU of 1442 leaves room for the Geneve overhead on a 1500
bytes underlay. VLAN tags, VLAN-aware vnets and isolated ports are not
supported and refused on vnets of OVN zones.

Errors of ovn-nbctl are reported with its own message, for example that
the northbound database cannot be reached, instead of its whole command
line.

Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
 debian/control                                |   1 +
 src/PVE/Network/SDN/Controllers.pm            |   4 +
 src/PVE/Network/SDN/Makefile                  |   3 +-
 src/PVE/Network/SDN/Ovn.pm                    | 183 ++++++++++++++++++
 src/PVE/Network/SDN/Zones.pm                  |   2 +
 src/PVE/Network/SDN/Zones/Makefile            |   2 +-
 src/PVE/Network/SDN/Zones/OvnPlugin.pm        | 124 ++++++++++++
 src/test/run_test_ovn.pl                      | 131 +++++++++++++
 .../ovn/basic/expected_controller_config      |   8 +
 .../zones/ovn/basic/expected_sdn_interfaces   |  12 ++
 src/test/zones/ovn/basic/interfaces           |   5 +
 src/test/zones/ovn/basic/sdn_config           |  24 +++
 .../ovn/with_evpn/expected_controller_config  |  44 +++++
 .../ovn/with_evpn/expected_sdn_interfaces     |  47 +++++
 src/test/zones/ovn/with_evpn/interfaces       |   7 +
 src/test/zones/ovn/with_evpn/sdn_config       |  35 ++++
 16 files changed, 630 insertions(+), 2 deletions(-)
 create mode 100644 src/PVE/Network/SDN/Ovn.pm
 create mode 100644 src/PVE/Network/SDN/Zones/OvnPlugin.pm
 create mode 100644 src/test/zones/ovn/basic/expected_controller_config
 create mode 100644 src/test/zones/ovn/basic/expected_sdn_interfaces
 create mode 100644 src/test/zones/ovn/basic/interfaces
 create mode 100644 src/test/zones/ovn/basic/sdn_config
 create mode 100644 src/test/zones/ovn/with_evpn/expected_controller_config
 create mode 100644 src/test/zones/ovn/with_evpn/expected_sdn_interfaces
 create mode 100644 src/test/zones/ovn/with_evpn/interfaces
 create mode 100644 src/test/zones/ovn/with_evpn/sdn_config

diff --git a/debian/control b/debian/control
index d8c1a03..a28cc2d 100644
--- a/debian/control
+++ b/debian/control
@@ -34,6 +34,7 @@ Depends: libpve-common-perl (>= 9.1.1),
 Recommends: frr-pythontools (>= 10.3.1-1+pve2~),
             wireguard-tools,
 Suggests: dnsmasq,
+          ovn-host,
 Description: Proxmox VE's SDN (Software Defined Network) stack
  This package contains the perl side of the Software Defined Network
  implementation for Proxmox VE.
diff --git a/src/PVE/Network/SDN/Controllers.pm b/src/PVE/Network/SDN/Controllers.pm
index ac0d70a..cae94db 100644
--- a/src/PVE/Network/SDN/Controllers.pm
+++ b/src/PVE/Network/SDN/Controllers.pm
@@ -122,6 +122,9 @@ sub generate_frr_config {
     foreach my $id (sort keys %{ $zone_cfg->{ids} }) {
         my $plugin_config = $zone_cfg->{ids}->{$id};
 
+        # OVN zones are not handled by FRR
+        next if $plugin_config->{type} eq 'ovn';
+
         my $controllerid = $plugin_config->{controller};
         next if !$controllerid;
 
@@ -161,6 +164,7 @@ sub generate_frr_config {
         next if !$zoneid;
         my $zone = $zone_cfg->{ids}->{$zoneid};
         next if !$zone;
+        next if $zone->{type} eq 'ovn';
         my $controllerid = $zone->{controller};
         next if !$controllerid;
         my $controller = $controller_cfg->{ids}->{$controllerid};
diff --git a/src/PVE/Network/SDN/Makefile b/src/PVE/Network/SDN/Makefile
index d0b4bce..69c2ed7 100644
--- a/src/PVE/Network/SDN/Makefile
+++ b/src/PVE/Network/SDN/Makefile
@@ -11,7 +11,8 @@ SOURCES=Vnets.pm\
 		Frr.pm\
 		PrefixLists.pm\
 		RouteMaps.pm\
-		WireGuard.pm
+		WireGuard.pm\
+		Ovn.pm
 
 
 PERL5DIR=${DESTDIR}/usr/share/perl5
diff --git a/src/PVE/Network/SDN/Ovn.pm b/src/PVE/Network/SDN/Ovn.pm
new file mode 100644
index 0000000..93235e8
--- /dev/null
+++ b/src/PVE/Network/SDN/Ovn.pm
@@ -0,0 +1,183 @@
+package PVE::Network::SDN::Ovn;
+
+use v5.36;
+
+use PVE::Network;
+use PVE::Tools qw(run_command);
+
+our $NBCTL = '/usr/bin/ovn-nbctl';
+our $VSCTL = '/usr/bin/ovs-vsctl';
+my $NBCTL_TIMEOUT = 10;
+
+our $INTEGRATION_BRIDGE = 'br-int';
+
+# external_ids keys tagging the objects managed by PVE. Northbound objects
+# without them are never touched, so the OVN deployment can be shared with
+# other consumers.
+my $EXT_CONTROLLER = 'pve-sdn-controller';
+my $EXT_ZONE = 'pve-sdn-zone';
+my $EXT_VNET = 'pve-sdn-vnet';
+my $EXT_VMID = 'pve-sdn-vmid';
+
+sub nbctl {
+    my ($controller, @args) = @_;
+
+    my ($output, $errors) = ('', '');
+    eval {
+        run_command(
+            [$NBCTL, "--db=$controller->{'nb-connection'}", "--timeout=$NBCTL_TIMEOUT", @args],
+            outfunc => sub { $output .= "$_[0]\n"; },
+            errfunc => sub { $errors .= "$_[0]\n"; },
+        );
+    };
+    if (my $err = $@) {
+        # the error of ovn-nbctl is more helpful than its whole command line
+        die $errors ne '' ? $errors : $err;
+    }
+
+    return $output;
+}
+
+# runs all commands in a single northbound transaction
+sub nbctl_transaction {
+    my ($controller, $commands) = @_;
+
+    return if !scalar(@$commands);
+
+    nbctl($controller, map { ('--', @$_) } @$commands);
+}
+
+=head3 port_external_ids($controllerid, $zoneid, $vnetid, $iface)
+
+Returns the external_ids identifying the logical switch port of the guest
+interface C<$iface>.
+
+=cut
+
+sub port_external_ids {
+    my ($controllerid, $zoneid, $vnetid, $iface) = @_;
+
+    my $external_ids = {
+        $EXT_CONTROLLER => $controllerid,
+        $EXT_ZONE => $zoneid,
+        $EXT_VNET => $vnetid,
+    };
+    $external_ids->{$EXT_VMID} = $1 if $iface =~ m/^(?:tap|veth)(\d+)i\d+$/;
+
+    return $external_ids;
+}
+
+=head3 port_commands($vnetid, $iface, \%external_ids, $current_switch)
+
+Returns the C<ovn-nbctl> commands creating the logical switch port C<$iface> on
+the logical switch C<$vnetid>. C<$current_switch> is the switch the port
+currently belongs to, if any.
+
+New ports get the C<unknown> address, so the logical switch behaves like a
+learning switch for them. Existing ports keep their addresses.
+
+=cut
+
+sub port_commands {
+    my ($vnetid, $iface, $external_ids, $current_switch) = @_;
+
+    my $commands = [];
+
+    if (!defined($current_switch)) {
+        push @$commands, ['lsp-add', $vnetid, $iface], ['lsp-set-addresses', $iface, 'unknown'];
+    } elsif ($current_switch ne $vnetid) {
+        push @$commands, ['lsp-del', $iface], ['lsp-add', $vnetid, $iface],
+            ['lsp-set-addresses', $iface, 'unknown'];
+    }
+
+    push @$commands,
+        [
+            'set',
+            'Logical_Switch_Port',
+            $iface,
+            map { "external_ids:$_=$external_ids->{$_}" } sort keys %$external_ids,
+        ];
+
+    return $commands;
+}
+
+=head3 ensure_port($controllerid, $controller, $zoneid, $vnetid, $iface)
+
+Creates or updates the logical switch port of the guest interface C<$iface> on
+the logical switch of C<$vnetid>, moving it over from another switch if needed.
+
+=cut
+
+sub ensure_port {
+    my ($controllerid, $controller, $zoneid, $vnetid, $iface) = @_;
+
+    eval {
+        my $current_switch;
+        my $exists = nbctl(
+            $controller,
+            '--bare',
+            '--columns=_uuid',
+            'find',
+            'Logical_Switch_Port',
+            "name=$iface",
+        );
+        if ($exists =~ m/\S/) {
+            # prints "<uuid> (<switch name>)"
+            my $output = nbctl($controller, 'lsp-get-ls', $iface);
+            $current_switch = $1 if $output =~ m/^\S+ \((\S+)\)$/m;
+        }
+
+        my $external_ids = port_external_ids($controllerid, $zoneid, $vnetid, $iface);
+        my $commands = port_commands($vnetid, $iface, $external_ids, $current_switch);
+        nbctl_transaction($controller, $commands);
+    };
+    die "unable to create OVN logical switch port '$iface' on '$vnetid' - $@" if $@;
+}
+
+=head3 firewall_port_name($iface)
+
+Returns the name of the OVS port that pve-common creates on the integration
+bridge for the firewall bridge of the guest interface C<$iface>.
+
+=cut
+
+sub firewall_port_name {
+    my ($iface) = @_;
+
+    die "unable to parse guest interface name '$iface'\n"
+        if $iface !~ m/^(?:tap|veth)(\d+)i(\d+)$/;
+
+    return "fwln${1}o${2}";
+}
+
+=head3 plug_port($iface, $firewall, $rate)
+
+Attaches the guest interface C<$iface> to the integration bridge, through a
+firewall bridge if C<$firewall> is set, and binds it to the logical switch port
+of the same name.
+
+=cut
+
+sub plug_port {
+    my ($iface, $firewall, $rate) = @_;
+
+    die "integration bridge '$INTEGRATION_BRIDGE' does not exist, is ovn-host running?\n"
+        if !-d "/sys/class/net/$INTEGRATION_BRIDGE";
+
+    # the integration bridge is an OVS bridge, so pve-common already knows how
+    # to plug guests into it, including the firewall bridge
+    PVE::Network::tap_plug(
+        $iface,
+        $INTEGRATION_BRIDGE,
+        undef,
+        $firewall,
+        undef,
+        $rate,
+        { learning => 1 },
+    );
+
+    my $port = $firewall ? firewall_port_name($iface) : $iface;
+    run_command([$VSCTL, 'set', 'Interface', $port, "external_ids:iface-id=$iface"]);
+}
+
+1;
diff --git a/src/PVE/Network/SDN/Zones.pm b/src/PVE/Network/SDN/Zones.pm
index f668303..491be7d 100644
--- a/src/PVE/Network/SDN/Zones.pm
+++ b/src/PVE/Network/SDN/Zones.pm
@@ -16,6 +16,7 @@ use PVE::Network::SDN::Zones::VxlanPlugin;
 use PVE::Network::SDN::Zones::EvpnPlugin;
 use PVE::Network::SDN::Zones::FaucetPlugin;
 use PVE::Network::SDN::Zones::SimplePlugin;
+use PVE::Network::SDN::Zones::OvnPlugin;
 use PVE::Network::SDN::Zones::Plugin;
 
 PVE::Network::SDN::Zones::VlanPlugin->register();
@@ -24,6 +25,7 @@ PVE::Network::SDN::Zones::VxlanPlugin->register();
 PVE::Network::SDN::Zones::EvpnPlugin->register();
 PVE::Network::SDN::Zones::FaucetPlugin->register();
 PVE::Network::SDN::Zones::SimplePlugin->register();
+PVE::Network::SDN::Zones::OvnPlugin->register();
 PVE::Network::SDN::Zones::Plugin->init();
 
 my $local_network_sdn_file = "/etc/network/interfaces.d/sdn";
diff --git a/src/PVE/Network/SDN/Zones/Makefile b/src/PVE/Network/SDN/Zones/Makefile
index 8454388..8941acb 100644
--- a/src/PVE/Network/SDN/Zones/Makefile
+++ b/src/PVE/Network/SDN/Zones/Makefile
@@ -1,4 +1,4 @@
-SOURCES=Plugin.pm VlanPlugin.pm VxlanPlugin.pm FaucetPlugin.pm EvpnPlugin.pm QinQPlugin.pm SimplePlugin.pm
+SOURCES=Plugin.pm VlanPlugin.pm VxlanPlugin.pm FaucetPlugin.pm EvpnPlugin.pm QinQPlugin.pm SimplePlugin.pm OvnPlugin.pm
 
 
 PERL5DIR=${DESTDIR}/usr/share/perl5
diff --git a/src/PVE/Network/SDN/Zones/OvnPlugin.pm b/src/PVE/Network/SDN/Zones/OvnPlugin.pm
new file mode 100644
index 0000000..26557e7
--- /dev/null
+++ b/src/PVE/Network/SDN/Zones/OvnPlugin.pm
@@ -0,0 +1,124 @@
+package PVE::Network::SDN::Zones::OvnPlugin;
+
+use v5.36;
+
+use PVE::Exception qw(raise_param_exc);
+use PVE::Network;
+
+use PVE::Network::SDN::Ovn;
+use PVE::Network::SDN::Zones::Plugin;
+
+use base('PVE::Network::SDN::Zones::Plugin');
+
+# leaves room for the Geneve overhead on a 1500 bytes underlay
+my $DEFAULT_MTU = 1442;
+
+sub type {
+    return 'ovn';
+}
+
+sub options {
+    return {
+        nodes => { optional => 1 },
+        controller => { optional => 0 },
+        mtu => { optional => 1 },
+        dns => { optional => 1 },
+        reversedns => { optional => 1 },
+        dnszone => { optional => 1 },
+        ipam => { optional => 1 },
+    };
+}
+
+# Plugin implementation
+sub generate_sdn_config {
+    my (
+        $class,
+        $plugin_config,
+        $zoneid,
+        $vnetid,
+        $vnet,
+        $controller,
+        $controller_cfg,
+        $subnet_cfg,
+        $interfaces_config,
+        $config,
+    ) = @_;
+
+    die "missing OVN controller\n" if !$controller || $controller->{type} ne 'ovn';
+
+    # Guest interfaces get plugged into the OVN integration bridge, so the vnet
+    # bridge never gets any port. It exists so that qemu-server and
+    # pve-container can read the vnet MTU, and since it is an OVS bridge, they
+    # request a firewall bridge for guests with the firewall enabled.
+    my @iface_config = ();
+    push @iface_config, "ovs_type OVSBridge";
+    push @iface_config, "ovs_mtu " . $class->get_mtu($plugin_config);
+    push @iface_config, "alias $vnet->{alias}" if $vnet->{alias};
+    push(@{ $config->{$vnetid} }, @iface_config) if !$config->{$vnetid};
+
+    return $config;
+}
+
+sub status {
+    my ($class, $plugin_config, $zone, $vnetid, $vnet, $status) = @_;
+
+    my $err_msg = $class->generate_status_message($vnetid, $status, []);
+
+    if (!-d "/sys/class/net/$PVE::Network::SDN::Ovn::INTEGRATION_BRIDGE") {
+        push @$err_msg, "missing $PVE::Network::SDN::Ovn::INTEGRATION_BRIDGE";
+    }
+
+    return $err_msg;
+}
+
+sub tap_plug {
+    my ($class, $plugin_config, $vnet, $tag, $iface, $vnetid, $firewall, $trunks, $rate) = @_;
+
+    my $controllerid = $plugin_config->{controller};
+    my $controller =
+        PVE::Network::SDN::running_config()->{controllers}->{ids}->{$controllerid};
+    die "OVN controller '$controllerid' of vnet '$vnetid' is not applied yet\n"
+        if !$controller;
+
+    PVE::Network::SDN::Ovn::ensure_port(
+        $controllerid, $controller, $vnet->{zone}, $vnetid, $iface,
+    );
+
+    # detach from any previous bridge
+    PVE::Network::tap_unplug($iface);
+    PVE::Network::SDN::Ovn::plug_port($iface, $firewall, $rate);
+
+    # Only fail after plugging: on hotplug, qemu-server already unplugged the
+    # interface, so failing earlier would leave the guest disconnected.
+    die "VLAN tags and trunks are not supported on OVN vnet '$vnetid'\n" if $tag || $trunks;
+}
+
+sub on_update_hook {
+    my ($class, $zoneid, $zone_cfg, $controller_cfg) = @_;
+
+    my $controllerid = $zone_cfg->{ids}->{$zoneid}->{controller};
+    my $controller = $controller_cfg->{ids}->{$controllerid};
+
+    die "controller $controllerid don't exist\n" if !$controller;
+    die "$controllerid is not an ovn controller type\n" if $controller->{type} ne 'ovn';
+}
+
+sub vnet_update_hook {
+    my ($class, $vnet_cfg, $vnetid, $zone_cfg) = @_;
+
+    my $vnet = $vnet_cfg->{ids}->{$vnetid};
+
+    raise_param_exc({ tag => "tag is not supported on OVN zones" }) if defined($vnet->{tag});
+    raise_param_exc({ vlanaware => "vlanaware is not supported on OVN zones" })
+        if $vnet->{vlanaware};
+    raise_param_exc({ 'isolate-ports' => "isolate-ports is not supported on OVN zones" })
+        if $vnet->{'isolate-ports'};
+}
+
+sub get_mtu {
+    my ($class, $plugin_config) = @_;
+
+    return $plugin_config->{mtu} // $DEFAULT_MTU;
+}
+
+1;
diff --git a/src/test/run_test_ovn.pl b/src/test/run_test_ovn.pl
index 60e752b..411c60e 100755
--- a/src/test/run_test_ovn.pl
+++ b/src/test/run_test_ovn.pl
@@ -8,7 +8,138 @@ use lib qw(..);
 use Test::More;
 
 use PVE::Network::SDN;
+use PVE::Network::SDN::Ovn;
 use PVE::Network::SDN::Controllers::OvnPlugin;
+use PVE::Network::SDN::Zones::OvnPlugin;
+
+my $controller = {
+    type => 'ovn',
+    'nb-connection' => 'tcp:192.168.0.1:6641,tcp:192.168.0.2:6641',
+    'sb-connection' => 'tcp:192.168.0.1:6642,tcp:192.168.0.2:6642',
+};
+
+my $running_cfg = {
+    controllers => {
+        ids => {
+            ovnctl => $controller,
+            evpnctl => { type => 'evpn', asn => 65000, peers => '192.168.0.1' },
+        },
+    },
+    zones => {
+        ids => {
+            zone1 => { type => 'ovn', controller => 'ovnctl' },
+            zone2 => {
+                type => 'ovn',
+                controller => 'ovnctl',
+                nodes => { node2 => 1 },
+            },
+            evpn => { type => 'evpn', controller => 'evpnctl' },
+        },
+    },
+    vnets => {
+        ids => {
+            vnet1 => { type => 'vnet', zone => 'zone1' },
+            vnet2 => { type => 'vnet', zone => 'zone2' },
+            evpnvnet => { type => 'vnet', zone => 'evpn', tag => 100 },
+        },
+    },
+};
+
+sub ext {
+    my ($zone, $vnet, $vmid) = @_;
+
+    my $ext = {
+        'pve-sdn-controller' => 'ovnctl',
+        'pve-sdn-zone' => $zone,
+        'pve-sdn-vnet' => $vnet,
+    };
+    $ext->{'pve-sdn-vmid'} = $vmid if defined($vmid);
+    return $ext;
+}
+
+# ports
+
+is_deeply(
+    PVE::Network::SDN::Ovn::port_external_ids('ovnctl', 'zone1', 'vnet1', 'veth105i1'),
+    ext('zone1', 'vnet1', 105),
+    'port_external_ids: container interface',
+);
+is_deeply(
+    PVE::Network::SDN::Ovn::port_external_ids('ovnctl', 'zone1', 'vnet1', 'custom0'),
+    ext('zone1', 'vnet1'),
+    'port_external_ids: no guest interface',
+);
+
+my $port_ext = ext('zone1', 'vnet1', 100);
+my @port_tail = (
+    [
+        'set',
+        'Logical_Switch_Port',
+        'tap100i0',
+        'external_ids:pve-sdn-controller=ovnctl',
+        'external_ids:pve-sdn-vmid=100',
+        'external_ids:pve-sdn-vnet=vnet1',
+        'external_ids:pve-sdn-zone=zone1',
+    ],
+);
+
+is_deeply(
+    PVE::Network::SDN::Ovn::port_commands('vnet1', 'tap100i0', $port_ext, undef),
+    [['lsp-add', 'vnet1', 'tap100i0'], ['lsp-set-addresses', 'tap100i0', 'unknown'], @port_tail],
+    'port_commands: new port',
+);
+is_deeply(
+    PVE::Network::SDN::Ovn::port_commands('vnet1', 'tap100i0', $port_ext, 'vnet1'),
+    [@port_tail],
+    'port_commands: existing port keeps its addresses',
+);
+is_deeply(
+    PVE::Network::SDN::Ovn::port_commands('vnet1', 'tap100i0', $port_ext, 'vnet9'),
+    [
+        ['lsp-del', 'tap100i0'],
+        ['lsp-add', 'vnet1', 'tap100i0'],
+        ['lsp-set-addresses', 'tap100i0', 'unknown'],
+        @port_tail,
+    ],
+    'port_commands: port moved to another vnet',
+);
+
+{
+    no warnings 'redefine';
+    local *PVE::Network::SDN::Ovn::run_command = sub {
+        my ($cmd, %param) = @_;
+        $param{errfunc}->('ovn-nbctl: tcp:192.168.0.1:6641: database connection failed');
+        die "command '@$cmd' failed: exit code 1\n";
+    };
+    eval {
+        PVE::Network::SDN::Ovn::ensure_port('ovnctl', $controller, 'zone1', 'vnet1',
+            'tap100i0');
+    };
+}
+is(
+    $@,
+    "unable to create OVN logical switch port 'tap100i0' on 'vnet1' -"
+        . " ovn-nbctl: tcp:192.168.0.1:6641: database connection failed\n",
+    'ensure_port: error of ovn-nbctl instead of its command line',
+);
+
+is(PVE::Network::SDN::Ovn::firewall_port_name('tap100i2'), 'fwln100o2', 'firewall_port_name: vm');
+is(
+    PVE::Network::SDN::Ovn::firewall_port_name('veth105i0'),
+    'fwln105o0',
+    'firewall_port_name: container',
+);
+
+# unsupported options
+
+eval {
+    PVE::Network::SDN::Zones::OvnPlugin->vnet_update_hook(
+        { ids => { vnet1 => { zone => 'zone1', 'isolate-ports' => 1 } } },
+        'vnet1',
+        {},
+    );
+};
+like($@, qr/isolate-ports is not supported/, 'vnet_update_hook: isolate-ports refused');
 
 # controller schema
 
diff --git a/src/test/zones/ovn/basic/expected_controller_config b/src/test/zones/ovn/basic/expected_controller_config
new file mode 100644
index 0000000..2961e18
--- /dev/null
+++ b/src/test/zones/ovn/basic/expected_controller_config
@@ -0,0 +1,8 @@
+frr version 10.6.1
+frr defaults datacenter
+hostname localhost
+log syslog informational
+service integrated-vtysh-config
+!
+line vty
+!
diff --git a/src/test/zones/ovn/basic/expected_sdn_interfaces b/src/test/zones/ovn/basic/expected_sdn_interfaces
new file mode 100644
index 0000000..e0086c1
--- /dev/null
+++ b/src/test/zones/ovn/basic/expected_sdn_interfaces
@@ -0,0 +1,12 @@
+#version:1
+
+auto myvnet
+iface myvnet
+	ovs_type OVSBridge
+	ovs_mtu 1442
+
+auto myvnet2
+iface myvnet2
+	ovs_type OVSBridge
+	ovs_mtu 1400
+	alias tenant2
diff --git a/src/test/zones/ovn/basic/interfaces b/src/test/zones/ovn/basic/interfaces
new file mode 100644
index 0000000..68b6a88
--- /dev/null
+++ b/src/test/zones/ovn/basic/interfaces
@@ -0,0 +1,5 @@
+auto vmbr0
+iface vmbr0 inet manual
+        bridge-ports eth0
+        bridge-stp off
+        bridge-fd 0
diff --git a/src/test/zones/ovn/basic/sdn_config b/src/test/zones/ovn/basic/sdn_config
new file mode 100644
index 0000000..1034a77
--- /dev/null
+++ b/src/test/zones/ovn/basic/sdn_config
@@ -0,0 +1,24 @@
+{
+  version     => 1,
+  vnets       => {
+                   ids => {
+                            myvnet  => { type => "vnet", zone => "myzone" },
+                            myvnet2 => { type => "vnet", zone => "myzone2", alias => "tenant2" },
+                          },
+                 },
+  zones       => {
+                   ids => {
+                            myzone  => { type => "ovn", controller => "ovnctl", ipam => "pve" },
+                            myzone2 => { type => "ovn", controller => "ovnctl", ipam => "pve", mtu => 1400 },
+                          },
+                 },
+  controllers => {
+                   ids => {
+                            ovnctl => {
+                              type => "ovn",
+                              "nb-connection" => "tcp:192.168.0.1:6641,tcp:192.168.0.2:6641",
+                              "sb-connection" => "tcp:192.168.0.1:6642,tcp:192.168.0.2:6642",
+                            },
+                          },
+                 },
+}
diff --git a/src/test/zones/ovn/with_evpn/expected_controller_config b/src/test/zones/ovn/with_evpn/expected_controller_config
new file mode 100644
index 0000000..0c6cc09
--- /dev/null
+++ b/src/test/zones/ovn/with_evpn/expected_controller_config
@@ -0,0 +1,44 @@
+frr version 10.6.1
+frr defaults datacenter
+hostname localhost
+log syslog informational
+service integrated-vtysh-config
+!
+vrf vrf_myzone
+ vni 1000
+exit-vrf
+!
+router bgp 65000
+ bgp router-id 192.168.0.1
+ no bgp hard-administrative-reset
+ no bgp default ipv4-unicast
+ coalesce-time 1000
+ no bgp graceful-restart notification
+ neighbor VTEP peer-group
+ neighbor VTEP remote-as 65000
+ neighbor VTEP bfd
+ neighbor 192.168.0.2 peer-group VTEP
+ neighbor 192.168.0.3 peer-group VTEP
+ !
+ address-family l2vpn evpn
+  neighbor VTEP activate
+  neighbor VTEP route-map MAP_VTEP_IN in
+  neighbor VTEP route-map MAP_VTEP_OUT out
+  advertise-all-vni
+ exit-address-family
+exit
+!
+router bgp 65000 vrf vrf_myzone
+ bgp router-id 192.168.0.1
+ no bgp hard-administrative-reset
+ no bgp graceful-restart notification
+exit
+!
+route-map MAP_VTEP_IN permit 1
+exit
+!
+route-map MAP_VTEP_OUT permit 1
+exit
+!
+line vty
+!
diff --git a/src/test/zones/ovn/with_evpn/expected_sdn_interfaces b/src/test/zones/ovn/with_evpn/expected_sdn_interfaces
new file mode 100644
index 0000000..28eda33
--- /dev/null
+++ b/src/test/zones/ovn/with_evpn/expected_sdn_interfaces
@@ -0,0 +1,47 @@
+#version:1
+
+auto myvnet
+iface myvnet
+	address 10.0.0.1/24
+	hwaddress A2:1D:CB:1A:C0:8B
+	bridge_ports vxlan_myvnet
+	bridge_stp off
+	bridge_fd 0
+	mtu 1450
+	ip-forward on
+	arp-accept on
+	vrf vrf_myzone
+
+auto ovnvnet
+iface ovnvnet
+	ovs_type OVSBridge
+	ovs_mtu 1442
+
+auto vrf_myzone
+iface vrf_myzone
+	vrf-table auto
+	post-up ip route add vrf vrf_myzone unreachable default metric 4278198272
+
+auto vrfbr_myzone
+iface vrfbr_myzone
+	bridge-ports vrfvx_myzone
+	bridge_stp off
+	bridge_fd 0
+	mtu 1450
+	vrf vrf_myzone
+
+auto vrfvx_myzone
+iface vrfvx_myzone
+	vxlan-id 1000
+	vxlan-local-tunnelip 192.168.0.1
+	bridge-learning off
+	bridge-arp-nd-suppress on
+	mtu 1450
+
+auto vxlan_myvnet
+iface vxlan_myvnet
+	vxlan-id 100
+	vxlan-local-tunnelip 192.168.0.1
+	bridge-learning off
+	bridge-arp-nd-suppress on
+	mtu 1450
diff --git a/src/test/zones/ovn/with_evpn/interfaces b/src/test/zones/ovn/with_evpn/interfaces
new file mode 100644
index 0000000..66bb826
--- /dev/null
+++ b/src/test/zones/ovn/with_evpn/interfaces
@@ -0,0 +1,7 @@
+auto vmbr0
+iface vmbr0 inet static
+	address 192.168.0.1/24
+	gateway 192.168.0.254
+        bridge-ports eth0
+        bridge-stp off
+        bridge-fd 0
diff --git a/src/test/zones/ovn/with_evpn/sdn_config b/src/test/zones/ovn/with_evpn/sdn_config
new file mode 100644
index 0000000..505f928
--- /dev/null
+++ b/src/test/zones/ovn/with_evpn/sdn_config
@@ -0,0 +1,35 @@
+{
+  version => 1,
+  vnets   => {
+               ids => {
+                        myvnet => { tag => "100", type => "vnet", zone => "myzone" },
+                        ovnvnet => { type => "vnet", zone => "ovnzone" },
+                      },
+             },
+
+  zones   => {
+               ids => {
+                        myzone => { ipam => "pve", type => "evpn", controller => "evpnctl", 'vrf-vxlan' => 1000, 'mac' => 'A2:1D:CB:1A:C0:8B' },
+                        ovnzone => { ipam => "pve", type => "ovn", controller => "ovnctl" },
+                      },
+             },
+  controllers  => {
+               ids => {
+                        evpnctl => { type => "evpn", 'peers' => '192.168.0.1,192.168.0.2,192.168.0.3', asn => "65000" },
+                        ovnctl => {
+                          type => "ovn",
+                          'nb-connection' => 'tcp:192.168.0.1:6641',
+                          'sb-connection' => 'tcp:192.168.0.1:6642',
+                        },
+                      },
+             },
+
+  subnets => {
+              ids => { 'myzone-10.0.0.0-24' => {
+							'type' => 'subnet',
+							'vnet' => 'myvnet',
+							'gateway' => '10.0.0.1',
+						  }
+		     }
+	     }
+}
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 18+ messages in thread

* [RFC PATCH network 04/16] sdn: ovn: sync the northbound database on reload
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
                     ` (2 preceding siblings ...)
  2026-10-13 12:14   ` [RFC PATCH network 03/16] sdn: zones: " Rémi PAETA
@ 2026-10-13 12:14   ` Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH network 05/16] sdn: ovn: configure the chassis on boot and on network reload Rémi PAETA
                     ` (11 subsequent siblings)
  15 siblings, 0 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

The logical switches of the vnets get created, updated and deleted when
the SDN configuration is applied. Since the northbound database is
shared by all nodes, this is done once for the whole cluster in the
reload API call, before the per-node reload workers get started.

Only objects tagged with the pve-sdn-* external_ids are touched, so an
OVN deployment can be shared with other consumers. A logical switch
named like a vnet but not managed by PVE is refused instead of taken
over.

Ports of guests that do not exist anymore get removed, as do all
managed objects of an OVN controller that was removed from the
configuration. Ports whose interface is not on their vnet in the guest
configuration anymore, because it got removed or moved to another
bridge, get removed too, unless they are still up: on hotplug, the port
gets created before the interface is written to the guest
configuration.

Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
 src/PVE/API2/Network/SDN.pm |   8 ++
 src/PVE/Network/SDN.pm      |  16 +++
 src/PVE/Network/SDN/Ovn.pm  | 268 ++++++++++++++++++++++++++++++++++++
 src/test/run_test_ovn.pl    | 206 +++++++++++++++++++++++++++
 4 files changed, 498 insertions(+)

diff --git a/src/PVE/API2/Network/SDN.pm b/src/PVE/API2/Network/SDN.pm
index e3c8d9d..c4522ad 100644
--- a/src/PVE/API2/Network/SDN.pm
+++ b/src/PVE/API2/Network/SDN.pm
@@ -318,9 +318,11 @@ __PACKAGE__->register_method({
 
         my $previous_config_has_frr;
         my $new_config_has_frr;
+        my $previous_running_config;
 
         PVE::Network::SDN::lock_sdn_config(
             sub {
+                $previous_running_config = PVE::Network::SDN::running_config();
                 $previous_config_has_frr = PVE::Network::SDN::running_config_has_frr();
                 PVE::Network::SDN::commit_config();
                 $new_config_has_frr = PVE::Network::SDN::running_config_has_frr();
@@ -338,6 +340,12 @@ __PACKAGE__->register_method({
         my $code = sub {
             $rpcenv->{type} = 'priv'; # to start tasks in background
             PVE::Cluster::check_cfs_quorum();
+
+            # the OVN northbound database is shared by all nodes, so it is
+            # synced once for the whole cluster
+            eval { PVE::Network::SDN::sync_ovn_northbound($previous_running_config) };
+            warn "syncing OVN northbound database failed - $@" if $@;
+
             my $nodelist = PVE::Cluster::get_nodelist();
             for my $node (@$nodelist) {
                 my $pid = eval { $create_reload_network_worker->($node, $regenerate_frr) };
diff --git a/src/PVE/Network/SDN.pm b/src/PVE/Network/SDN.pm
index aeb9b4c..d601cd0 100644
--- a/src/PVE/Network/SDN.pm
+++ b/src/PVE/Network/SDN.pm
@@ -25,6 +25,7 @@ use PVE::Network::SDN::Subnets;
 use PVE::Network::SDN::Dhcp;
 use PVE::Network::SDN::Frr;
 use PVE::Network::SDN::Fabrics;
+use PVE::Network::SDN::Ovn;
 use PVE::Network::SDN::RouteMaps;
 use PVE::Network::SDN::PrefixLists;
 
@@ -503,6 +504,21 @@ sub generate_dhcp_config {
     PVE::Network::SDN::Dhcp::regenerate_config($reload);
 }
 
+=head3 sync_ovn_northbound(\%previous_running_config)
+
+Reconciles the OVN northbound databases with the current running
+configuration. C<\%previous_running_config> is the running configuration before
+the last commit, so that the objects of removed OVN controllers get cleaned up.
+
+=cut
+
+sub sync_ovn_northbound {
+    my ($previous_running_config) = @_;
+
+    my $running_config = PVE::Network::SDN::running_config();
+    PVE::Network::SDN::Ovn::sync_northbound($previous_running_config, $running_config);
+}
+
 sub encode_value {
     my ($type, $key, $value) = @_;
 
diff --git a/src/PVE/Network/SDN/Ovn.pm b/src/PVE/Network/SDN/Ovn.pm
index 93235e8..142477b 100644
--- a/src/PVE/Network/SDN/Ovn.pm
+++ b/src/PVE/Network/SDN/Ovn.pm
@@ -2,6 +2,9 @@ package PVE::Network::SDN::Ovn;
 
 use v5.36;
 
+use JSON;
+
+use PVE::Cluster;
 use PVE::Network;
 use PVE::Tools qw(run_command);
 
@@ -19,6 +22,180 @@ my $EXT_ZONE = 'pve-sdn-zone';
 my $EXT_VNET = 'pve-sdn-vnet';
 my $EXT_VMID = 'pve-sdn-vmid';
 
+=head3 ovn_controllers(\%running_cfg)
+
+Returns a hash of all controllers of type C<ovn> in C<\%running_cfg>, keyed by
+their ID.
+
+=cut
+
+sub ovn_controllers {
+    my ($running_cfg) = @_;
+
+    my $controllers = $running_cfg->{controllers}->{ids} // {};
+
+    return {
+        map { $_ => $controllers->{$_} }
+        grep { $controllers->{$_}->{type} eq 'ovn' } keys %$controllers
+    };
+}
+
+=head3 desired_state(\%running_cfg)
+
+Computes the northbound objects that should exist for C<\%running_cfg>, keyed by
+OVN controller ID. Every vnet of an C<ovn> zone maps to a logical switch of the
+same name.
+
+=cut
+
+sub desired_state {
+    my ($running_cfg) = @_;
+
+    my $zones = $running_cfg->{zones}->{ids} // {};
+    my $vnets = $running_cfg->{vnets}->{ids} // {};
+
+    my $state = {};
+    $state->{$_} = { switches => {} } for keys ovn_controllers($running_cfg)->%*;
+
+    for my $vnetid (sort keys %$vnets) {
+        my $zoneid = $vnets->{$vnetid}->{zone} or next;
+        my $zone = $zones->{$zoneid} or next;
+        next if $zone->{type} ne 'ovn';
+
+        my $controllerid = $zone->{controller};
+        next if !$controllerid || !$state->{$controllerid};
+
+        $state->{$controllerid}->{switches}->{$vnetid} = {
+            external_ids => {
+                $EXT_CONTROLLER => $controllerid,
+                $EXT_ZONE => $zoneid,
+                $EXT_VNET => $vnetid,
+            },
+        };
+    }
+
+    return $state;
+}
+
+=head3 sync_commands($controllerid, \%desired, \@switches, \@ports, \%vmlist, \%nics)
+
+Returns the list of C<ovn-nbctl> commands (each an array reference) that turn
+the current northbound content (C<\@switches> and C<\@ports>, as returned by
+C<list_rows>) into C<\%desired>, the entry of C<desired_state> for
+C<$controllerid>.
+
+Ports of guests that are not in C<\%vmlist> anymore get removed too.
+
+If C<\%nics> is given, as returned by C<guest_nics>, ports of guest
+interfaces that are not on their vnet anymore get removed too, unless they are
+still up, as a port gets created right before its interface is written to the
+guest config on hotplug.
+
+=cut
+
+sub sync_commands {
+    my ($controllerid, $desired, $switches, $ports, $vmlist, $nics) = @_;
+
+    my $wanted = $desired->{switches} // {};
+
+    my $managed = {};
+    for my $switch (@$switches) {
+        my $name = $switch->{name};
+        my $external_ids = $switch->{external_ids} // {};
+
+        if (($external_ids->{$EXT_CONTROLLER} // '') ne $controllerid) {
+            if ($wanted->{$name}) {
+                die "logical switch '$name' already exists in the OVN northbound database, but"
+                    . " is not managed by SDN controller '$controllerid'\n";
+            }
+            next;
+        }
+
+        $managed->{$name} = $external_ids;
+    }
+
+    my $commands = [];
+
+    for my $name (sort keys %$managed) {
+        push @$commands, ['ls-del', $name] if !$wanted->{$name};
+    }
+
+    for my $name (sort keys %$wanted) {
+        my $external_ids = $wanted->{$name}->{external_ids};
+
+        if (!$managed->{$name}) {
+            push @$commands, ['ls-add', $name];
+        } elsif (
+            !grep { ($managed->{$name}->{$_} // '') ne $external_ids->{$_} }
+            keys %$external_ids
+        ) {
+            next;
+        }
+
+        push @$commands,
+            [
+                'set',
+                'Logical_Switch',
+                $name,
+                map { "external_ids:$_=$external_ids->{$_}" } sort keys %$external_ids,
+            ];
+    }
+
+    for my $port (sort { $a->{name} cmp $b->{name} } @$ports) {
+        my $external_ids = $port->{external_ids} // {};
+        next if ($external_ids->{$EXT_CONTROLLER} // '') ne $controllerid;
+
+        # ports of deleted switches are removed together with their switch
+        my $vnetid = $external_ids->{$EXT_VNET};
+        next if defined($vnetid) && $managed->{$vnetid} && !$wanted->{$vnetid};
+
+        my $vmid = $external_ids->{$EXT_VMID};
+        if (defined($vmid) && !$vmlist->{$vmid}) {
+            push @$commands, ['lsp-del', $port->{name}];
+            next;
+        }
+
+        if (defined($vmid) && $nics && $port->{name} =~ m/^(?:tap|veth)\d+i(\d+)$/) {
+            my $bridge = $nics->{$vmid}->{"net$1"};
+            if ((!defined($bridge) || $bridge ne ($vnetid // '')) && !ovsdb_true($port->{up})) {
+                push @$commands, ['lsp-del', $port->{name}];
+                next;
+            }
+        }
+    }
+
+    return $commands;
+}
+
+# optional booleans are an empty set when unset
+sub ovsdb_true {
+    my ($value) = @_;
+
+    $value = $value->[0] if ref($value) eq 'ARRAY';
+    return $value ? 1 : 0;
+}
+
+=head3 guest_nics()
+
+Returns the bridge of every network interface of all guests, keyed by VMID and
+interface (C<net0>, C<net1>, ...).
+
+=cut
+
+sub guest_nics {
+    my $properties = PVE::Cluster::get_guest_config_properties([map { "net$_" } 0 .. 31]);
+
+    my $nics = {};
+    for my $vmid (keys %$properties) {
+        for my $net (keys $properties->{$vmid}->%*) {
+            my ($bridge) = $properties->{$vmid}->{$net} =~ m/(?:^|,)bridge=([^,]+)/;
+            $nics->{$vmid}->{$net} = $bridge if defined($bridge);
+        }
+    }
+
+    return $nics;
+}
+
 sub nbctl {
     my ($controller, @args) = @_;
 
@@ -47,6 +224,97 @@ sub nbctl_transaction {
     nbctl($controller, map { ('--', @$_) } @$commands);
 }
 
+# converts an OVSDB JSON value (RFC 7047) to plain perl data
+sub ovsdb_to_perl {
+    my ($value) = @_;
+
+    return $value if ref($value) ne 'ARRAY';
+
+    my ($type, $data) = @$value;
+    return { map { $_->[0] => $_->[1] } @$data } if $type eq 'map';
+    return [map { ovsdb_to_perl($_) } @$data] if $type eq 'set';
+    return $data; # uuid
+}
+
+=head3 list_rows($controller, $table, @columns)
+
+Returns all rows of the northbound C<$table> as list of hashes containing
+C<@columns>.
+
+=cut
+
+sub list_rows {
+    my ($controller, $table, @columns) = @_;
+
+    my $output = nbctl(
+        $controller,
+        '--format=json',
+        '--data=json',
+        '--columns=' . join(',', @columns),
+        'list',
+        $table,
+    );
+    my $result = decode_json($output);
+
+    my @headings = $result->{headings}->@*;
+
+    my $rows = [];
+    for my $data ($result->{data}->@*) {
+        push @$rows, { map { $headings[$_] => ovsdb_to_perl($data->[$_]) } 0 .. $#headings };
+    }
+
+    return $rows;
+}
+
+=head3 sync_northbound(\%previous_cfg, \%running_cfg)
+
+Reconciles the northbound database of every OVN controller with
+C<\%running_cfg>. Controllers only present in C<\%previous_cfg> get all their
+managed objects removed.
+
+Each controller is synced independently, errors are collected and raised at the
+end.
+
+=cut
+
+sub sync_northbound {
+    my ($previous_cfg, $running_cfg) = @_;
+
+    my $desired = desired_state($running_cfg);
+    my $controllers = ovn_controllers($running_cfg);
+
+    my $previous_controllers = ovn_controllers($previous_cfg // {});
+    for my $id (keys %$previous_controllers) {
+        next if $controllers->{$id};
+        $controllers->{$id} = $previous_controllers->{$id};
+        $desired->{$id} = { switches => {} };
+    }
+
+    return if !%$controllers;
+
+    my $vmlist = PVE::Cluster::get_vmlist()->{ids} // {};
+    my $guest_nics = guest_nics();
+
+    my @errors;
+    for my $id (sort keys %$controllers) {
+        my $controller = $controllers->{$id};
+
+        eval {
+            my $switches = list_rows($controller, 'Logical_Switch', 'name', 'external_ids');
+            my $ports = list_rows(
+                $controller, 'Logical_Switch_Port', 'name', 'external_ids', 'up',
+            );
+
+            my $commands =
+                sync_commands($id, $desired->{$id}, $switches, $ports, $vmlist, $guest_nics);
+            nbctl_transaction($controller, $commands);
+        };
+        push @errors, "OVN controller '$id': $@" if $@;
+    }
+
+    die join('', @errors) if @errors;
+}
+
 =head3 port_external_ids($controllerid, $zoneid, $vnetid, $iface)
 
 Returns the external_ids identifying the logical switch port of the guest
diff --git a/src/test/run_test_ovn.pl b/src/test/run_test_ovn.pl
index 411c60e..b12ca0b 100755
--- a/src/test/run_test_ovn.pl
+++ b/src/test/run_test_ovn.pl
@@ -5,7 +5,9 @@ use warnings;
 
 use lib qw(..);
 
+use JSON;
 use Test::More;
+use Test::MockModule;
 
 use PVE::Network::SDN;
 use PVE::Network::SDN::Ovn;
@@ -57,6 +59,163 @@ sub ext {
     return $ext;
 }
 
+sub set_switch {
+    my ($vnet, $zone) = @_;
+    return [
+        'set',
+        'Logical_Switch',
+        $vnet,
+        'external_ids:pve-sdn-controller=ovnctl',
+        "external_ids:pve-sdn-vnet=$vnet",
+        "external_ids:pve-sdn-zone=$zone",
+    ];
+}
+
+# desired_state
+
+my $desired = PVE::Network::SDN::Ovn::desired_state($running_cfg);
+is_deeply(
+    $desired,
+    {
+        ovnctl => {
+            switches => {
+                vnet1 => { external_ids => ext('zone1', 'vnet1') },
+                vnet2 => { external_ids => ext('zone2', 'vnet2') },
+            },
+        },
+    },
+    'desired_state: one logical switch per ovn vnet',
+);
+
+is_deeply(
+    PVE::Network::SDN::Ovn::desired_state({}), {}, 'desired_state: empty running config',
+);
+
+# sync_commands
+
+is_deeply(
+    PVE::Network::SDN::Ovn::sync_commands('ovnctl', $desired->{ovnctl}, [], [], {}),
+    [
+        ['ls-add', 'vnet1'],
+        set_switch('vnet1', 'zone1'),
+        ['ls-add', 'vnet2'],
+        set_switch('vnet2', 'zone2'),
+    ],
+    'sync_commands: create missing switches',
+);
+
+my $switches = [
+    { name => 'vnet1', external_ids => ext('zone1', 'vnet1') },
+    { name => 'vnet2', external_ids => ext('oldzone', 'vnet2') },
+    { name => 'oldvnet', external_ids => ext('zone1', 'oldvnet') },
+    { name => 'foreign', external_ids => { 'neutron:network_name' => 'foo' } },
+];
+my $ports = [
+    { name => 'tap100i0', external_ids => ext('zone1', 'vnet1', 100) },
+    { name => 'tap101i0', external_ids => ext('zone1', 'vnet1', 101) },
+    { name => 'tap102i0', external_ids => ext('zone1', 'oldvnet', 102) },
+    { name => 'manual', external_ids => ext('zone1', 'vnet1') },
+    { name => 'foreignport', external_ids => {} },
+];
+
+is_deeply(
+    PVE::Network::SDN::Ovn::sync_commands(
+        'ovnctl',
+        $desired->{ovnctl},
+        $switches,
+        $ports,
+        { 100 => {} },
+    ),
+    [['ls-del', 'oldvnet'], set_switch('vnet2', 'zone2'), ['lsp-del', 'tap101i0']],
+    'sync_commands: delete, update and garbage collect, leave the rest alone',
+);
+
+eval {
+    PVE::Network::SDN::Ovn::sync_commands(
+        'ovnctl',
+        $desired->{ovnctl},
+        [{ name => 'vnet1', external_ids => {} }],
+        [],
+        {},
+    );
+};
+like($@, qr/logical switch 'vnet1' already exists/, 'sync_commands: refuse unmanaged switch');
+
+is_deeply(
+    PVE::Network::SDN::Ovn::sync_commands(
+        'ovnctl',
+        { switches => {} },
+        [{ name => 'vnet1', external_ids => ext('zone1', 'vnet1') }],
+        [{ name => 'tap100i0', external_ids => ext('zone1', 'vnet1', 100) }],
+        {},
+    ),
+    [['ls-del', 'vnet1']],
+    'sync_commands: deleted switch takes its ports along',
+);
+
+# sync_northbound, with ovn-nbctl mocked
+
+my $nb_switches = {
+    ovnctl => [[
+        'vnet1',
+        [
+            'map',
+            [map { [$_, ext('zone1', 'vnet1')->{$_}] } sort keys ext('zone1', 'vnet1')->%*],
+        ],
+    ]],
+    gonectl => [['gone', ['map', [['pve-sdn-controller', 'gonectl']]]]],
+};
+
+my $nbctl_calls = [];
+my $ovn_module = Test::MockModule->new('PVE::Network::SDN::Ovn');
+$ovn_module->mock(
+    run_command => sub {
+        my ($cmd, %param) = @_;
+
+        die "unexpected command @$cmd\n" if $cmd->[0] ne $PVE::Network::SDN::Ovn::NBCTL;
+        my ($db) = $cmd->[1] =~ m/^--db=tcp:192\.168\.0\.(\d+)/;
+        my $id = $db == 1 ? 'ovnctl' : 'gonectl';
+
+        my @args = $cmd->@[3 .. $#$cmd];
+        if ($args[-2] eq 'list') {
+            my $data = $args[-1] eq 'Logical_Switch' ? $nb_switches->{$id} : [];
+            $param{outfunc}
+                ->(encode_json({ headings => ['name', 'external_ids'], data => $data }));
+        } else {
+            push @$nbctl_calls, [$id, @args];
+        }
+    },
+);
+my $cluster_module = Test::MockModule->new('PVE::Cluster');
+$cluster_module->mock(
+    get_vmlist => sub { return { ids => {} } },
+    get_guest_config_properties => sub { return {} },
+);
+
+my $previous_cfg = {
+    controllers => {
+        ids => {
+            gonectl => {
+                type => 'ovn',
+                'nb-connection' => 'tcp:192.168.0.9:6641',
+                'sb-connection' => 'tcp:192.168.0.9:6642',
+            },
+        },
+    },
+};
+
+PVE::Network::SDN::Ovn::sync_northbound($previous_cfg, $running_cfg);
+is_deeply(
+    $nbctl_calls,
+    [
+        ['gonectl', '--', 'ls-del', 'gone'],
+        ['ovnctl', '--', 'ls-add', 'vnet2', '--', set_switch('vnet2', 'zone2')->@*],
+    ],
+    'sync_northbound: one transaction per controller, removed controllers get cleaned up',
+);
+
+$ovn_module->unmock('run_command');
+
 # ports
 
 is_deeply(
@@ -130,6 +289,53 @@ is(
     'firewall_port_name: container',
 );
 
+# guest interfaces removed from their vnet
+
+is_deeply(
+    PVE::Network::SDN::Ovn::sync_commands(
+        'ovnctl',
+        $desired->{ovnctl},
+        [{ name => 'vnet1', external_ids => ext('zone1', 'vnet1') }],
+        [
+            {
+                name => 'tap100i0',
+                external_ids => ext('zone1', 'vnet1', 100),
+                up => ['set', []]->[1],
+            },
+            { name => 'tap100i1', external_ids => ext('zone1', 'vnet1', 100), up => 0 },
+            { name => 'tap100i2', external_ids => ext('zone1', 'vnet1', 100), up => 0 },
+            { name => 'tap100i3', external_ids => ext('zone1', 'vnet1', 100), up => 1 },
+        ],
+        { 100 => {} },
+        { 100 => { net0 => 'vnet1', net1 => 'vmbr0' } },
+    ),
+    [
+        ['ls-add', 'vnet2'],
+        set_switch('vnet2', 'zone2'),
+        ['lsp-del', 'tap100i1'],
+        ['lsp-del', 'tap100i2'],
+    ],
+    'sync_commands: remove ports of interfaces moved or removed, unless still up',
+);
+
+ok(!PVE::Network::SDN::Ovn::ovsdb_true([]), 'ovsdb_true: unset optional boolean');
+ok(PVE::Network::SDN::Ovn::ovsdb_true(JSON::true), 'ovsdb_true: true');
+
+$cluster_module->mock(
+    get_guest_config_properties => sub {
+        return {
+            100 => { net0 => 'virtio=BC:24:11:00:00:01,bridge=vnet1,firewall=1' },
+            101 => { net0 => 'name=eth0,bridge=vmbr0,hwaddr=BC:24:11:00:00:02,type=veth' },
+            102 => { net1 => 'virtio=BC:24:11:00:00:03' },
+        };
+    },
+);
+is_deeply(
+    PVE::Network::SDN::Ovn::guest_nics(),
+    { 100 => { net0 => 'vnet1' }, 101 => { net0 => 'vmbr0' } },
+    'guest_nics: bridges of the guest interfaces',
+);
+
 # unsupported options
 
 eval {
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 18+ messages in thread

* [RFC PATCH network 05/16] sdn: ovn: configure the chassis on boot and on network reload
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
                     ` (3 preceding siblings ...)
  2026-10-13 12:14   ` [RFC PATCH network 04/16] sdn: ovn: sync the northbound database on reload Rémi PAETA
@ 2026-10-13 12:14   ` Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH network 06/16] sdn: ovn: add port security Rémi PAETA
                     ` (10 subsequent siblings)
  15 siblings, 0 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

ovn-controller needs the southbound database, the encapsulation type
and the local tunnel IP, which are stored in the external_ids of the
local Open_vSwitch table. They are now set whenever the network
configuration of a node gets generated, using the local IP of the route
towards the southbound database as tunnel IP. A chassis attached by PVE
gets detached again once the node is not part of any OVN zone anymore.
A node can only be attached to a single OVN deployment.

pvenetcommit.service removes the Open vSwitch database on every boot,
which drops these settings. The new pve-sdn-ovn-chassis.service sets
them again before the guests get started. The postinst needs the
debhelper token for the unit to get enabled.

ovn-controller needs a few seconds to connect to the southbound
database, so wait for up to ten seconds before warning that it is not
connected. The zone status also reports an ovn-controller that is not
connected.

Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
 debian/libpve-network-perl.install       |   1 +
 debian/libpve-network-perl.postinst      |   2 +
 src/PVE/Network/SDN.pm                   |  19 +++
 src/PVE/Network/SDN/Ovn.pm               | 179 +++++++++++++++++++++++
 src/PVE/Network/SDN/Zones/OvnPlugin.pm   |   2 +
 src/services/Makefile                    |   1 +
 src/services/pve-sdn-ovn-chassis.service |  16 ++
 src/test/run_test_ovn.pl                 | 145 ++++++++++++++++++
 8 files changed, 365 insertions(+)
 create mode 100644 src/services/pve-sdn-ovn-chassis.service

diff --git a/debian/libpve-network-perl.install b/debian/libpve-network-perl.install
index 4e63c1f..ad5fc2d 100644
--- a/debian/libpve-network-perl.install
+++ b/debian/libpve-network-perl.install
@@ -1,2 +1,3 @@
 lib/systemd/system/dnsmasq@.service.d/00-dnsmasq-after-networking.conf /usr/lib/systemd/system/dnsmasq@.service.d/
+lib/systemd/system/pve-sdn-ovn-chassis.service /usr/lib/systemd/system/
 usr/share/perl5/PVE/Network
diff --git a/debian/libpve-network-perl.postinst b/debian/libpve-network-perl.postinst
index 99faedf..360863e 100644
--- a/debian/libpve-network-perl.postinst
+++ b/debian/libpve-network-perl.postinst
@@ -32,4 +32,6 @@ case "$1" in
   ;;
 esac
 
+#DEBHELPER#
+
 exit 0
diff --git a/src/PVE/Network/SDN.pm b/src/PVE/Network/SDN.pm
index d601cd0..e2cdf3d 100644
--- a/src/PVE/Network/SDN.pm
+++ b/src/PVE/Network/SDN.pm
@@ -421,12 +421,18 @@ sub write_raw_etc_network_config {
 Generates the network configuration for all SDN plugins and writes it to the SDN
 interfaces files (/etc/network/interfaces.d/sdn).
 
+Also configures the local OVN chassis, as this is called on every node when the
+SDN configuration gets applied.
+
 =cut
 
 sub generate_etc_network_config {
     my $running_cfg = PVE::Network::SDN::running_config();
     my $raw_config = PVE::Network::SDN::generate_raw_etc_network_config($running_cfg);
     PVE::Network::SDN::write_raw_etc_network_config($raw_config);
+
+    eval { PVE::Network::SDN::Ovn::configure_chassis($running_cfg) };
+    log_warn("failed to configure the OVN chassis - $@") if $@;
 }
 
 =head3 generate_frr_raw_config(\%running_config, \%fabric_config)
@@ -519,6 +525,19 @@ sub sync_ovn_northbound {
     PVE::Network::SDN::Ovn::sync_northbound($previous_running_config, $running_config);
 }
 
+=head3 generate_ovn_chassis_config()
+
+Configures the local ovn-controller according to the OVN zones of this node.
+Called on boot by pve-sdn-ovn-chassis.service, as the Open vSwitch database
+gets recreated on every boot.
+
+=cut
+
+sub generate_ovn_chassis_config {
+    my $running_config = PVE::Network::SDN::running_config();
+    PVE::Network::SDN::Ovn::configure_chassis($running_config);
+}
+
 sub encode_value {
     my ($type, $key, $value) = @_;
 
diff --git a/src/PVE/Network/SDN/Ovn.pm b/src/PVE/Network/SDN/Ovn.pm
index 142477b..a3fbe44 100644
--- a/src/PVE/Network/SDN/Ovn.pm
+++ b/src/PVE/Network/SDN/Ovn.pm
@@ -5,12 +5,19 @@ use v5.36;
 use JSON;
 
 use PVE::Cluster;
+use PVE::INotify;
 use PVE::Network;
+use PVE::RESTEnvironment qw(log_warn);
 use PVE::Tools qw(run_command);
 
+use PVE::Network::SDN::Zones::Plugin;
+
 our $NBCTL = '/usr/bin/ovn-nbctl';
 our $VSCTL = '/usr/bin/ovs-vsctl';
+our $APPCTL = '/usr/bin/ovn-appctl';
 my $NBCTL_TIMEOUT = 10;
+# seconds to wait for ovn-controller to connect to the southbound database
+our $CONNECT_TIMEOUT = 10;
 
 our $INTEGRATION_BRIDGE = 'br-int';
 
@@ -22,6 +29,9 @@ my $EXT_ZONE = 'pve-sdn-zone';
 my $EXT_VNET = 'pve-sdn-vnet';
 my $EXT_VMID = 'pve-sdn-vmid';
 
+# chassis settings written to the local Open_vSwitch table
+my @CHASSIS_KEYS = ('ovn-remote', 'ovn-encap-type', 'ovn-encap-ip', $EXT_CONTROLLER);
+
 =head3 ovn_controllers(\%running_cfg)
 
 Returns a hash of all controllers of type C<ovn> in C<\%running_cfg>, keyed by
@@ -448,4 +458,173 @@ sub plug_port {
     run_command([$VSCTL, 'set', 'Interface', $port, "external_ids:iface-id=$iface"]);
 }
 
+=head3 local_controllers(\%running_cfg, $nodename)
+
+Returns the OVN controllers used by the zones that are active on C<$nodename>.
+
+=cut
+
+sub local_controllers {
+    my ($running_cfg, $nodename) = @_;
+
+    my $controllers = ovn_controllers($running_cfg);
+    my $zones = $running_cfg->{zones}->{ids} // {};
+
+    my $local = {};
+    for my $zone (values %$zones) {
+        next if $zone->{type} ne 'ovn';
+        next if $zone->{nodes} && !$zone->{nodes}->{$nodename};
+
+        my $controllerid = $zone->{controller};
+        $local->{$controllerid} = $controllers->{$controllerid}
+            if $controllerid && $controllers->{$controllerid};
+    }
+
+    return $local;
+}
+
+=head3 parse_remote_ip($remotes)
+
+Returns the IP address of the first entry of an OVN database connection string
+such as C<tcp:10.0.0.1:6642,tcp:[fd00::1]:6642>.
+
+=cut
+
+sub parse_remote_ip {
+    my ($remotes) = @_;
+
+    my ($first) = split(/,/, $remotes);
+    return $1 if $first =~ m/^[a-z]+:\[([^\]]+)\]:\d+$/;
+    return $1 if $first =~ m/^[a-z]+:([^:]+):\d+$/;
+
+    die "unable to parse OVN database connection '$first'\n";
+}
+
+=head3 chassis_settings($controllerid, $controller, $encap_ip)
+
+Returns the external_ids to set in the local Open_vSwitch table so that
+ovn-controller joins the OVN deployment of C<$controller>.
+
+=cut
+
+sub chassis_settings {
+    my ($controllerid, $controller, $encap_ip) = @_;
+
+    return {
+        'ovn-remote' => $controller->{'sb-connection'},
+        'ovn-encap-type' => $controller->{'encap-type'} // 'geneve',
+        'ovn-encap-ip' => $encap_ip,
+        $EXT_CONTROLLER => $controllerid,
+    };
+}
+
+=head3 configure_chassis(\%running_cfg)
+
+Configures the local ovn-controller for the OVN controller used by the zones of
+this node, or detaches it if no zone uses OVN anymore. Called on every node when
+the SDN configuration gets applied.
+
+=cut
+
+sub configure_chassis {
+    my ($running_cfg) = @_;
+
+    my $nodename = PVE::INotify::nodename();
+    my $controllers = local_controllers($running_cfg, $nodename);
+    my @ids = sort keys %$controllers;
+
+    if (!-x $VSCTL) {
+        die "Open vSwitch is not installed, but node '$nodename' is part of an OVN zone\n"
+            if @ids;
+        return;
+    }
+
+    if (@ids > 1) {
+        die "node '$nodename' is part of zones using different OVN controllers (@ids), but a"
+            . " node can only be attached to one OVN deployment\n";
+    }
+
+    if (!@ids) {
+        # only detach a chassis that was attached by us
+        my $attached = '';
+        eval {
+            run_command(
+                [
+                    $VSCTL,
+                    '--if-exists',
+                    'get',
+                    'Open_vSwitch',
+                    '.',
+                    "external_ids:$EXT_CONTROLLER",
+                ],
+                outfunc => sub { $attached .= $_[0]; },
+            );
+        };
+        if ($attached) {
+            run_command([$VSCTL, 'remove', 'Open_vSwitch', '.', 'external_ids', @CHASSIS_KEYS]);
+        }
+        return;
+    }
+
+    my $id = $ids[0];
+    my $controller = $controllers->{$id};
+
+    my $remote_ip = parse_remote_ip($controller->{'sb-connection'});
+    my ($encap_ip) = PVE::Network::SDN::Zones::Plugin::get_local_route_ip($remote_ip);
+    die "unable to find the local IP to reach the OVN southbound database at $remote_ip\n"
+        if !$encap_ip;
+
+    my $settings = chassis_settings($id, $controller, $encap_ip);
+    run_command([
+        $VSCTL,
+        'set',
+        'Open_vSwitch',
+        '.',
+        map { "external_ids:$_=$settings->{$_}" } sort keys %$settings,
+    ]);
+
+    if (!wait_controller_connected($CONNECT_TIMEOUT)) {
+        log_warn(
+            "ovn-controller is not connected to the OVN southbound database: running guests keep"
+                . " their connectivity, but new guest interfaces and OVN configuration changes are"
+                . " only applied once it is",
+        );
+    }
+}
+
+# ovn-controller needs a few seconds to connect, especially after a restart
+sub wait_controller_connected {
+    my ($timeout) = @_;
+
+    for (my $i = 0; $i < $timeout; $i++) {
+        return 1 if controller_connected();
+        sleep(1);
+    }
+
+    return controller_connected();
+}
+
+=head3 controller_connected()
+
+Returns whether the local ovn-controller is connected to the southbound
+database.
+
+=cut
+
+sub controller_connected {
+    return 0 if !-x $APPCTL;
+
+    my $status = '';
+    eval {
+        run_command(
+            [$APPCTL, '-t', 'ovn-controller', 'connection-status'],
+            outfunc => sub { $status .= $_[0]; },
+            errfunc => sub { },
+            timeout => 5,
+        );
+    };
+
+    return $status eq 'connected';
+}
+
 1;
diff --git a/src/PVE/Network/SDN/Zones/OvnPlugin.pm b/src/PVE/Network/SDN/Zones/OvnPlugin.pm
index 26557e7..fc15e20 100644
--- a/src/PVE/Network/SDN/Zones/OvnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/OvnPlugin.pm
@@ -66,6 +66,8 @@ sub status {
 
     if (!-d "/sys/class/net/$PVE::Network::SDN::Ovn::INTEGRATION_BRIDGE") {
         push @$err_msg, "missing $PVE::Network::SDN::Ovn::INTEGRATION_BRIDGE";
+    } elsif (!PVE::Network::SDN::Ovn::controller_connected()) {
+        push @$err_msg, "ovn-controller not connected to the southbound database";
     }
 
     return $err_msg;
diff --git a/src/services/Makefile b/src/services/Makefile
index 818c106..b6cd56d 100644
--- a/src/services/Makefile
+++ b/src/services/Makefile
@@ -8,6 +8,7 @@ install:
 	install -d $(SERVICEDIR)
 	install -d $(SERVICEDIR)/dnsmasq@.service.d
 	install -t $(SERVICEDIR)/dnsmasq@.service.d -m 0644 00-dnsmasq-after-networking.conf
+	install -t $(SERVICEDIR) -m 0644 pve-sdn-ovn-chassis.service
 
 .PHONY: clean
 clean:
diff --git a/src/services/pve-sdn-ovn-chassis.service b/src/services/pve-sdn-ovn-chassis.service
new file mode 100644
index 0000000..13775cc
--- /dev/null
+++ b/src/services/pve-sdn-ovn-chassis.service
@@ -0,0 +1,16 @@
+# The Open vSwitch database gets recreated on every boot (see
+# pvenetcommit.service), so the OVN chassis settings need to be set again
+# before the guests get started.
+[Unit]
+Description=Configure the OVN chassis of the Proxmox VE SDN
+Wants=pve-cluster.service
+After=pve-cluster.service ovs-vswitchd.service networking.service
+Before=pve-guests.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/perl -e 'use PVE::Network::SDN; PVE::Network::SDN::generate_ovn_chassis_config()'
+RemainAfterExit=yes
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/test/run_test_ovn.pl b/src/test/run_test_ovn.pl
index b12ca0b..16b7eef 100755
--- a/src/test/run_test_ovn.pl
+++ b/src/test/run_test_ovn.pl
@@ -347,6 +347,151 @@ eval {
 };
 like($@, qr/isolate-ports is not supported/, 'vnet_update_hook: isolate-ports refused');
 
+# chassis
+
+is(
+    PVE::Network::SDN::Ovn::parse_remote_ip('tcp:10.0.0.1:6642,tcp:10.0.0.2:6642'),
+    '10.0.0.1',
+    'parse_remote_ip: ipv4',
+);
+is(
+    PVE::Network::SDN::Ovn::parse_remote_ip('tcp:[fd00::1]:6642'),
+    'fd00::1',
+    'parse_remote_ip: ipv6',
+);
+
+is_deeply(
+    [sort keys PVE::Network::SDN::Ovn::local_controllers($running_cfg, 'node1')->%*],
+    ['ovnctl'],
+    'local_controllers: zone restricted to other nodes is ignored',
+);
+
+is_deeply(
+    PVE::Network::SDN::Ovn::chassis_settings('ovnctl', $controller, '192.168.0.10'),
+    {
+        'ovn-remote' => $controller->{'sb-connection'},
+        'ovn-encap-type' => 'geneve',
+        'ovn-encap-ip' => '192.168.0.10',
+        'pve-sdn-controller' => 'ovnctl',
+    },
+    'chassis_settings',
+);
+
+my $vsctl_calls = [];
+my $attached = '';
+$PVE::Network::SDN::Ovn::VSCTL = '/bin/true';
+$ovn_module->mock(
+    run_command => sub {
+        my ($cmd, %param) = @_;
+        if (grep { $_ eq 'get' } @$cmd) {
+            $param{outfunc}->($attached) if $attached;
+            return;
+        }
+        push @$vsctl_calls, [$cmd->@[1 .. $#$cmd]];
+    },
+    controller_connected => sub { return 1 },
+);
+my $inotify_module = Test::MockModule->new('PVE::INotify');
+$inotify_module->mock(nodename => sub { return 'node1' });
+my $zones_plugin_module = Test::MockModule->new('PVE::Network::SDN::Zones::Plugin');
+$zones_plugin_module->mock(get_local_route_ip => sub { return ('192.168.0.10', 'vmbr0') });
+
+PVE::Network::SDN::Ovn::configure_chassis($running_cfg);
+is_deeply(
+    $vsctl_calls,
+    [[
+        'set',
+        'Open_vSwitch',
+        '.',
+        'external_ids:ovn-encap-ip=192.168.0.10',
+        'external_ids:ovn-encap-type=geneve',
+        "external_ids:ovn-remote=$controller->{'sb-connection'}",
+        'external_ids:pve-sdn-controller=ovnctl',
+    ]],
+    'configure_chassis: attach chassis',
+);
+
+$vsctl_calls = [];
+PVE::Network::SDN::Ovn::configure_chassis({});
+is_deeply($vsctl_calls, [], 'configure_chassis: leave chassis not attached by PVE alone');
+
+$attached = '"ovnctl"';
+PVE::Network::SDN::Ovn::configure_chassis({});
+is_deeply(
+    $vsctl_calls,
+    [[
+        'remove',
+        'Open_vSwitch',
+        '.',
+        'external_ids',
+        'ovn-remote',
+        'ovn-encap-type',
+        'ovn-encap-ip',
+        'pve-sdn-controller',
+    ]],
+    'configure_chassis: detach chassis',
+);
+
+my $two_controllers = {
+    controllers => { ids => { a => $controller, b => $controller } },
+    zones => {
+        ids => {
+            za => { type => 'ovn', controller => 'a' },
+            zb => { type => 'ovn', controller => 'b' },
+        },
+    },
+};
+eval { PVE::Network::SDN::Ovn::configure_chassis($two_controllers) };
+like(
+    $@,
+    qr/can only be attached to one OVN deployment/,
+    'configure_chassis: one deployment per node',
+);
+
+# chassis configured when the SDN configuration gets applied
+
+my $sdn_module = Test::MockModule->new('PVE::Network::SDN');
+my $written;
+$sdn_module->mock(
+    running_config => sub { return $running_cfg },
+    generate_raw_etc_network_config => sub { return "raw" },
+    write_raw_etc_network_config => sub { $written = $_[0] },
+);
+my $chassis_cfg;
+$ovn_module->mock(configure_chassis => sub { $chassis_cfg = $_[0] });
+PVE::Network::SDN::generate_etc_network_config();
+is($written, 'raw', 'generate_etc_network_config: network config written');
+is($chassis_cfg, $running_cfg, 'generate_etc_network_config: chassis configured');
+
+$written = undef;
+$ovn_module->mock(configure_chassis => sub { die "ovs down\n" });
+my @warnings;
+$sdn_module->mock(log_warn => sub { push @warnings, $_[0] });
+eval { PVE::Network::SDN::generate_etc_network_config() };
+is($@, '', 'generate_etc_network_config: chassis failure does not abort');
+is($written, 'raw', 'generate_etc_network_config: network config written despite chassis failure');
+like(
+    join('', @warnings),
+    qr/failed to configure the OVN chassis - ovs down/,
+    'generate_etc_network_config: chassis failure is logged',
+);
+$ovn_module->unmock('configure_chassis');
+$sdn_module->unmock_all();
+
+# ovn-controller connection
+
+my $connection_checks = 0;
+my $connected_after = 2;
+$ovn_module->mock(
+    controller_connected =>
+        sub { $connection_checks++; return $connection_checks >= $connected_after },
+);
+ok(PVE::Network::SDN::Ovn::wait_controller_connected(10), 'wait_controller_connected: connects');
+is($connection_checks, 2, 'wait_controller_connected: waits until connected');
+($connection_checks, $connected_after) = (0, 100);
+ok(!PVE::Network::SDN::Ovn::wait_controller_connected(1), 'wait_controller_connected: timeout');
+$ovn_module->mock(controller_connected => sub { return 1 });
+
 # controller schema
 
 ok(
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 18+ messages in thread

* [RFC PATCH network 06/16] sdn: ovn: add port security
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
                     ` (4 preceding siblings ...)
  2026-10-13 12:14   ` [RFC PATCH network 05/16] sdn: ovn: configure the chassis on boot and on network reload Rémi PAETA
@ 2026-10-13 12:14   ` Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH network 07/16] sdn: ovn: allow overriding the port security per vnet Rémi PAETA
                     ` (9 subsequent siblings)
  15 siblings, 0 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

Add a 'port-security' option to OVN zones:

- off (default): no filtering, like a Linux bridge
- mac: guests can only send with their own MAC
- mac-ip: guests can only send with their own MAC and IPAM addresses,
  and only receive traffic for them

The guest MAC is not passed to tap_plug, so the port addresses and port
security get set in add_bridge_fdb, which qemu-server calls on guest
start and pve-container on plug. The MAC is recorded on the port, so
that the next SDN reload applies a changed level to running guests
without restarting them.

With 'off' and 'mac', ports keep the 'unknown' address, so frames for
other MACs behind the guest (macvlan, VRRP, ...) still get delivered.

The IPs are looked up in the IPAM of the zone, by vnet: vnets without
subnets have no IPs, and the PVE IPAM does not even know their zone.
If the IPAM cannot be queried, for example because a NetBox server is
unreachable, a reload leaves the port untouched instead of lowering its
port security, while a starting guest only gets its MAC enforced, so
that it keeps its connectivity until the next reload.

Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
 src/PVE/Network/SDN/Ovn.pm             | 190 ++++++++++++++++++++++++-
 src/PVE/Network/SDN/Zones/OvnPlugin.pm |  45 ++++++
 src/test/run_test_ovn.pl               | 139 +++++++++++++++++-
 3 files changed, 365 insertions(+), 9 deletions(-)

diff --git a/src/PVE/Network/SDN/Ovn.pm b/src/PVE/Network/SDN/Ovn.pm
index a3fbe44..54d9c58 100644
--- a/src/PVE/Network/SDN/Ovn.pm
+++ b/src/PVE/Network/SDN/Ovn.pm
@@ -28,6 +28,7 @@ my $EXT_CONTROLLER = 'pve-sdn-controller';
 my $EXT_ZONE = 'pve-sdn-zone';
 my $EXT_VNET = 'pve-sdn-vnet';
 my $EXT_VMID = 'pve-sdn-vmid';
+my $EXT_MAC = 'pve-sdn-mac';
 
 # chassis settings written to the local Open_vSwitch table
 my @CHASSIS_KEYS = ('ovn-remote', 'ovn-encap-type', 'ovn-encap-ip', $EXT_CONTROLLER);
@@ -76,6 +77,7 @@ sub desired_state {
         next if !$controllerid || !$state->{$controllerid};
 
         $state->{$controllerid}->{switches}->{$vnetid} = {
+            'port-security' => $zone->{'port-security'} // 'off',
             external_ids => {
                 $EXT_CONTROLLER => $controllerid,
                 $EXT_ZONE => $zoneid,
@@ -87,14 +89,18 @@ sub desired_state {
     return $state;
 }
 
-=head3 sync_commands($controllerid, \%desired, \@switches, \@ports, \%vmlist, \%nics)
+=head3 sync_commands($controllerid, \%desired, \@switches, \@ports, \%vmlist, \%mac_ips, \%nics)
 
 Returns the list of C<ovn-nbctl> commands (each an array reference) that turn
 the current northbound content (C<\@switches> and C<\@ports>, as returned by
 C<list_rows>) into C<\%desired>, the entry of C<desired_state> for
 C<$controllerid>.
 
-Ports of guests that are not in C<\%vmlist> anymore get removed too.
+Ports of guests that are not in C<\%vmlist> anymore get removed too. Ports with a
+known guest MAC get their addresses and port security updated according to the
+port security level of their zone and to the IPs of C<\%mac_ips>, keyed by MAC.
+Ports whose MAC maps to C<undef>, as the IPAM could not be queried, are left
+untouched.
 
 If C<\%nics> is given, as returned by C<guest_nics>, ports of guest
 interfaces that are not on their vnet anymore get removed too, unless they are
@@ -104,7 +110,7 @@ guest config on hotplug.
 =cut
 
 sub sync_commands {
-    my ($controllerid, $desired, $switches, $ports, $vmlist, $nics) = @_;
+    my ($controllerid, $desired, $switches, $ports, $vmlist, $mac_ips, $nics) = @_;
 
     my $wanted = $desired->{switches} // {};
 
@@ -172,11 +178,36 @@ sub sync_commands {
                 next;
             }
         }
+
+        my $mac = $external_ids->{$EXT_MAC};
+        next if !defined($mac) || !defined($vnetid) || !$wanted->{$vnetid};
+        # keep the current addresses instead of lowering the port security
+        next if exists($mac_ips->{$mac}) && !defined($mac_ips->{$mac});
+
+        my ($addresses, $port_security) = port_addresses(
+            $wanted->{$vnetid}->{'port-security'}, $mac, $mac_ips->{$mac} // [],
+        );
+        if (
+            !same_set($addresses, $port->{addresses})
+            || !same_set($port_security, $port->{port_security})
+        ) {
+            push @$commands, port_addresses_commands($port->{name}, $addresses, $port_security)->@*;
+        }
     }
 
     return $commands;
 }
 
+# OVSDB returns a set with a single element as plain value
+sub same_set {
+    my ($wanted, $current) = @_;
+
+    $current = [] if !defined($current);
+    $current = [$current] if ref($current) ne 'ARRAY';
+
+    return join("\n", sort @$wanted) eq join("\n", sort @$current);
+}
+
 # optional booleans are an empty set when unset
 sub ovsdb_true {
     my ($value) = @_;
@@ -312,11 +343,19 @@ sub sync_northbound {
         eval {
             my $switches = list_rows($controller, 'Logical_Switch', 'name', 'external_ids');
             my $ports = list_rows(
-                $controller, 'Logical_Switch_Port', 'name', 'external_ids', 'up',
+                $controller,
+                'Logical_Switch_Port',
+                'name',
+                'external_ids',
+                'addresses',
+                'port_security',
+                'up',
             );
+            my $mac_ips = ipam_lookup($running_cfg, $ports);
 
-            my $commands =
-                sync_commands($id, $desired->{$id}, $switches, $ports, $vmlist, $guest_nics);
+            my $commands = sync_commands(
+                $id, $desired->{$id}, $switches, $ports, $vmlist, $mac_ips, $guest_nics,
+            );
             nbctl_transaction($controller, $commands);
         };
         push @errors, "OVN controller '$id': $@" if $@;
@@ -325,6 +364,51 @@ sub sync_northbound {
     die join('', @errors) if @errors;
 }
 
+=head3 guest_ips(\%running_cfg, $vnetid, $mac)
+
+Returns the IPs registered for C<$mac> in the IPAM of the zone of C<$vnetid>,
+if it has one. Vnets without subnets have no IPs, and the IPAM might not even
+know their zone. Returns C<undef> if the IPAM could not be queried.
+
+=cut
+
+sub guest_ips {
+    my ($running_cfg, $vnetid, $mac) = @_;
+
+    my $zoneid = $running_cfg->{vnets}->{ids}->{$vnetid}->{zone} // return [];
+    my $zone = $running_cfg->{zones}->{ids}->{$zoneid};
+    return [] if !$zone || !$zone->{ipam};
+
+    my $subnets = $running_cfg->{subnets}->{ids} // {};
+    return [] if !grep { ($_->{vnet} // '') eq $vnetid } values %$subnets;
+
+    my ($ip4, $ip6) =
+        eval { PVE::Network::SDN::Ipams::get_ips_from_mac($mac, $zoneid, $zone) };
+    if (my $err = $@) {
+        warn "unable to look up the IPs of $mac in the IPAM of zone '$zoneid' - $err";
+        return;
+    }
+
+    return [grep { defined($_) } $ip4, $ip6];
+}
+
+# returns the IPAM IPs of all managed ports with a known guest MAC, keyed by MAC,
+# undef if the IPAM could not be queried
+sub ipam_lookup {
+    my ($running_cfg, $ports) = @_;
+
+    my $mac_ips = {};
+    for my $port (@$ports) {
+        my $external_ids = $port->{external_ids} // {};
+        my ($mac, $vnetid) = $external_ids->@{ $EXT_MAC, $EXT_VNET };
+        next if !defined($mac) || !defined($vnetid);
+
+        $mac_ips->{$mac} = guest_ips($running_cfg, $vnetid, $mac) if !exists($mac_ips->{$mac});
+    }
+
+    return $mac_ips;
+}
+
 =head3 port_external_ids($controllerid, $zoneid, $vnetid, $iface)
 
 Returns the external_ids identifying the logical switch port of the guest
@@ -352,7 +436,8 @@ the logical switch C<$vnetid>. C<$current_switch> is the switch the port
 currently belongs to, if any.
 
 New ports get the C<unknown> address, so the logical switch behaves like a
-learning switch for them. Existing ports keep their addresses.
+learning switch for them until the guest MAC is known (see C<update_port>).
+Existing ports keep their addresses.
 
 =cut
 
@@ -379,6 +464,97 @@ sub port_commands {
     return $commands;
 }
 
+=head3 port_addresses($level, $mac, \@ips)
+
+Returns the addresses and the port security entries of a port for the guest MAC
+C<$mac> and its IPs C<\@ips>, according to the port security C<$level>:
+
+=over
+
+=item C<off>: no filtering. The port also keeps the C<unknown> address, so that
+frames for other MACs behind the guest (macvlan, VRRP virtual MACs, ...) still
+get delivered.
+
+=item C<mac>: like C<off>, but the guest can only send with its own MAC.
+
+=item C<mac-ip>: the guest can only send with its own MAC and IPs, and only
+receives traffic for them. Without known IPs, only the MAC is enforced.
+
+=back
+
+=cut
+
+sub port_addresses {
+    my ($level, $mac, $ips) = @_;
+
+    $mac = lc($mac);
+    my $entry = join(' ', $mac, @$ips);
+
+    return ([$entry, 'unknown'], []) if $level eq 'off';
+    return ([$entry, 'unknown'], [$mac]) if $level eq 'mac';
+    return ([$entry], [$entry]) if $level eq 'mac-ip';
+
+    die "unknown port security level '$level'\n";
+}
+
+sub port_addresses_commands {
+    my ($iface, $addresses, $port_security) = @_;
+
+    return [
+        ['lsp-set-addresses', $iface, @$addresses],
+        ['lsp-set-port-security', $iface, @$port_security],
+    ];
+}
+
+=head3 port_vnet($controller, $iface)
+
+Returns the vnet recorded on the logical switch port C<$iface>, if it exists.
+
+=cut
+
+sub port_vnet {
+    my ($controller, $iface) = @_;
+
+    my $output = nbctl(
+        $controller,
+        '--if-exists',
+        'get',
+        'Logical_Switch_Port',
+        $iface,
+        "external_ids:$EXT_VNET",
+    );
+    $output =~ s/^\s+|\s+$//g;
+    $output =~ s/^"(.*)"$/$1/;
+
+    return $output eq '' ? undef : $output;
+}
+
+=head3 update_port($controller, $iface, $level, \%running_cfg, $vnetid, $mac)
+
+Sets the guest MAC C<$mac> and its IPAM IPs on the logical switch port C<$iface>
+according to the port security C<$level>, and records the MAC so that later
+syncs can update the port.
+
+=cut
+
+sub update_port {
+    my ($controller, $iface, $level, $running_cfg, $vnetid, $mac) = @_;
+
+    # Without an answer from the IPAM, only the MAC can be enforced: the guest
+    # keeps its connectivity, and the next sync applies the IPs.
+    my $ips = guest_ips($running_cfg, $vnetid, $mac) // [];
+    my ($addresses, $port_security) = port_addresses($level, $mac, $ips);
+
+    nbctl_transaction(
+        $controller,
+        [
+            port_addresses_commands($iface, $addresses, $port_security)->@*,
+            # as written in the guest config, which is what the IPAM is keyed by
+            ['set', 'Logical_Switch_Port', $iface, "external_ids:$EXT_MAC=$mac"],
+        ],
+    );
+}
+
 =head3 ensure_port($controllerid, $controller, $zoneid, $vnetid, $iface)
 
 Creates or updates the logical switch port of the guest interface C<$iface> on
diff --git a/src/PVE/Network/SDN/Zones/OvnPlugin.pm b/src/PVE/Network/SDN/Zones/OvnPlugin.pm
index fc15e20..b25e70c 100644
--- a/src/PVE/Network/SDN/Zones/OvnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/OvnPlugin.pm
@@ -17,10 +17,25 @@ sub type {
     return 'ovn';
 }
 
+sub properties {
+    return {
+        'port-security' => {
+            description =>
+                "Filtering of the traffic of the guests: 'mac' only lets them send with"
+                . " their own MAC, 'mac-ip' additionally restricts them to their IPs from the"
+                . " IPAM.",
+            type => 'string',
+            enum => ['off', 'mac', 'mac-ip'],
+            default => 'off',
+        },
+    };
+}
+
 sub options {
     return {
         nodes => { optional => 1 },
         controller => { optional => 0 },
+        'port-security' => { optional => 1 },
         mtu => { optional => 1 },
         dns => { optional => 1 },
         reversedns => { optional => 1 },
@@ -95,6 +110,36 @@ sub tap_plug {
     die "VLAN tags and trunks are not supported on OVN vnet '$vnetid'\n" if $tag || $trunks;
 }
 
+# Called by qemu-server on guest start and by pve-container on plug, with the
+# guest MAC, which tap_plug does not get.
+sub add_bridge_fdb {
+    my ($class, $plugin_config, $iface, $macaddr) = @_;
+
+    return if !$macaddr;
+
+    my $running_config = PVE::Network::SDN::running_config();
+    my $controllerid = $plugin_config->{controller};
+    my $controller = $running_config->{controllers}->{ids}->{$controllerid} or return;
+
+    eval {
+        my $vnetid = PVE::Network::SDN::Ovn::port_vnet($controller, $iface)
+            or die "logical switch port does not exist\n";
+        my $vnet = $running_config->{vnets}->{ids}->{$vnetid}
+            or die "vnet '$vnetid' is not applied\n";
+        my $zone = $running_config->{zones}->{ids}->{ $vnet->{zone} };
+
+        PVE::Network::SDN::Ovn::update_port(
+            $controller,
+            $iface,
+            $zone->{'port-security'} // 'off',
+            $running_config,
+            $vnetid,
+            $macaddr,
+        );
+    };
+    warn "unable to set the addresses of OVN logical switch port '$iface' - $@" if $@;
+}
+
 sub on_update_hook {
     my ($class, $zoneid, $zone_cfg, $controller_cfg) = @_;
 
diff --git a/src/test/run_test_ovn.pl b/src/test/run_test_ovn.pl
index 16b7eef..3e97a92 100755
--- a/src/test/run_test_ovn.pl
+++ b/src/test/run_test_ovn.pl
@@ -34,6 +34,7 @@ my $running_cfg = {
                 type => 'ovn',
                 controller => 'ovnctl',
                 nodes => { node2 => 1 },
+                'port-security' => 'mac-ip',
             },
             evpn => { type => 'evpn', controller => 'evpnctl' },
         },
@@ -79,8 +80,8 @@ is_deeply(
     {
         ovnctl => {
             switches => {
-                vnet1 => { external_ids => ext('zone1', 'vnet1') },
-                vnet2 => { external_ids => ext('zone2', 'vnet2') },
+                vnet1 => { 'port-security' => 'off', external_ids => ext('zone1', 'vnet1') },
+                vnet2 => { 'port-security' => 'mac-ip', external_ids => ext('zone2', 'vnet2') },
             },
         },
     },
@@ -289,6 +290,72 @@ is(
     'firewall_port_name: container',
 );
 
+# port security
+
+my $mac = 'BC:24:11:00:00:01';
+is_deeply(
+    [PVE::Network::SDN::Ovn::port_addresses('off', $mac, ['10.0.0.5'])],
+    [['bc:24:11:00:00:01 10.0.0.5', 'unknown'], []],
+    'port_addresses: off keeps unknown, no filtering',
+);
+is_deeply(
+    [PVE::Network::SDN::Ovn::port_addresses('mac', $mac, ['10.0.0.5', 'fd00::5'])],
+    [['bc:24:11:00:00:01 10.0.0.5 fd00::5', 'unknown'], ['bc:24:11:00:00:01']],
+    'port_addresses: mac filters the source MAC only',
+);
+is_deeply(
+    [PVE::Network::SDN::Ovn::port_addresses('mac-ip', $mac, ['10.0.0.5'])],
+    [['bc:24:11:00:00:01 10.0.0.5'], ['bc:24:11:00:00:01 10.0.0.5']],
+    'port_addresses: mac-ip filters MAC and IPs, no unknown',
+);
+is_deeply(
+    [PVE::Network::SDN::Ovn::port_addresses('mac-ip', $mac, [])],
+    [['bc:24:11:00:00:01'], ['bc:24:11:00:00:01']],
+    'port_addresses: mac-ip without IPAM IPs only enforces the MAC',
+);
+eval { PVE::Network::SDN::Ovn::port_addresses('strict', $mac, []) };
+like($@, qr/unknown port security level 'strict'/, 'port_addresses: invalid level');
+
+my $sec_switches = [
+    { name => 'vnet1', external_ids => ext('zone1', 'vnet1') },
+    { name => 'vnet2', external_ids => ext('zone2', 'vnet2') },
+];
+my $sec_ports = [
+    { # zone2 is mac-ip, port still open from before the MAC was known
+        name => 'tap100i0',
+        external_ids => { ext('zone2', 'vnet2', 100)->%*, 'pve-sdn-mac' => $mac },
+        addresses => 'unknown',
+        port_security => ['set', []]->[1],
+    },
+    { # zone1 is off and the port is already up to date (single element set)
+        name => 'tap101i0',
+        external_ids =>
+            { ext('zone1', 'vnet1', 101)->%*, 'pve-sdn-mac' => 'BC:24:11:00:00:02' },
+        addresses => ['bc:24:11:00:00:02 10.0.0.6', 'unknown'],
+        port_security => [],
+    },
+    { # MAC not known yet
+        name => 'tap102i0',
+        external_ids => ext('zone2', 'vnet2', 102),
+        addresses => 'unknown',
+    },
+];
+is_deeply(
+    PVE::Network::SDN::Ovn::sync_commands(
+        'ovnctl',
+        $desired->{ovnctl},
+        $sec_switches,
+        $sec_ports,
+        { 100 => {}, 101 => {}, 102 => {} },
+        { $mac => ['10.0.0.5'], 'BC:24:11:00:00:02' => ['10.0.0.6'] },
+    ),
+    [
+        ['lsp-set-addresses', 'tap100i0', 'bc:24:11:00:00:01 10.0.0.5'],
+        ['lsp-set-port-security', 'tap100i0', 'bc:24:11:00:00:01 10.0.0.5'],
+    ],
+    'sync_commands: apply the port security level of the zone to known guests',
+);
+
 # guest interfaces removed from their vnet
 
 is_deeply(
@@ -307,6 +374,7 @@ is_deeply(
             { name => 'tap100i3', external_ids => ext('zone1', 'vnet1', 100), up => 1 },
         ],
         { 100 => {} },
+        {},
         { 100 => { net0 => 'vnet1', net1 => 'vmbr0' } },
     ),
     [
@@ -336,6 +404,73 @@ is_deeply(
     'guest_nics: bridges of the guest interfaces',
 );
 
+# IPAM lookup
+
+my $ipam_calls = 0;
+my $ipams_module = Test::MockModule->new('PVE::Network::SDN::Ipams');
+$ipams_module->mock(get_ips_from_mac => sub { $ipam_calls++; return ('10.0.0.5', undef) });
+my $ipam_cfg = {
+    zones => { ids => { zone1 => { type => 'ovn', ipam => 'pve' } } },
+    vnets => { ids => { vnet1 => { zone => 'zone1' }, vnet2 => { zone => 'zone1' } } },
+    subnets => { ids => { 'zone1-10.0.0.0-24' => { vnet => 'vnet1' } } },
+};
+is_deeply(
+    PVE::Network::SDN::Ovn::guest_ips($ipam_cfg, 'vnet1', $mac),
+    ['10.0.0.5'],
+    'guest_ips: vnet with subnet',
+);
+$ipam_calls = 0;
+is_deeply(
+    PVE::Network::SDN::Ovn::guest_ips($ipam_cfg, 'vnet2', $mac),
+    [],
+    'guest_ips: vnet without subnet has no IPs',
+);
+is($ipam_calls, 0, 'guest_ips: IPAM not queried for a vnet without subnet');
+
+$ipams_module->mock(get_ips_from_mac => sub { $ipam_calls++; die "netbox unreachable\n" });
+my @ipam_warnings;
+{
+    local $SIG{__WARN__} = sub { push @ipam_warnings, $_[0] };
+    is(
+        scalar(PVE::Network::SDN::Ovn::guest_ips($ipam_cfg, 'vnet1', $mac)),
+        undef,
+        'guest_ips: undef if the IPAM cannot be queried',
+    );
+    $ipam_calls = 0;
+    is_deeply(
+        PVE::Network::SDN::Ovn::ipam_lookup(
+            $ipam_cfg,
+            [
+                { external_ids => { 'pve-sdn-mac' => $mac, 'pve-sdn-vnet' => 'vnet1' } },
+                { external_ids => { 'pve-sdn-mac' => $mac, 'pve-sdn-vnet' => 'vnet1' } },
+            ],
+        ),
+        { $mac => undef },
+        'ipam_lookup: failed lookup recorded as undef',
+    );
+    is($ipam_calls, 1, 'ipam_lookup: failed lookup not repeated for the same MAC');
+}
+like(join('', @ipam_warnings), qr/netbox unreachable/, 'guest_ips: IPAM failure is logged');
+$ipams_module->unmock_all();
+
+is_deeply(
+    PVE::Network::SDN::Ovn::sync_commands(
+        'ovnctl',
+        $desired->{ovnctl},
+        [{ name => 'vnet2', external_ids => ext('zone2', 'vnet2') }],
+        [{
+            name => 'tap100i0',
+            external_ids => { ext('zone2', 'vnet2', 100)->%*, 'pve-sdn-mac' => $mac },
+            addresses => ['bc:24:11:00:00:01 10.0.0.5'],
+            port_security => ['bc:24:11:00:00:01 10.0.0.5'],
+        }],
+        { 100 => {} },
+        { $mac => undef },
+    ),
+    [['ls-add', 'vnet1'], set_switch('vnet1', 'zone1')],
+    'sync_commands: port kept as is if the IPAM cannot be queried',
+);
+
 # unsupported options
 
 eval {
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 18+ messages in thread

* [RFC PATCH network 07/16] sdn: ovn: allow overriding the port security per vnet
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
                     ` (5 preceding siblings ...)
  2026-10-13 12:14   ` [RFC PATCH network 06/16] sdn: ovn: add port security Rémi PAETA
@ 2026-10-13 12:14   ` Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH network 08/16] sdn: subnets: refuse gateway, snat and dhcp on ovn zones Rémi PAETA
                     ` (8 subsequent siblings)
  15 siblings, 0 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

The port security level needed depends on the network rather than on
the zone: a vnet for regular guests can enforce their IPAM addresses,
while another one in the same zone carries a Kubernetes cluster or VRRP
virtual addresses and needs no filtering.

Add a 'port-security' vnet option overriding the default of the zone.
It is refused on vnets of other zone types, which do not filter the
guest traffic.

Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
 src/PVE/API2/Network/SDN/Vnets.pm      |  8 +++++++
 src/PVE/Network/SDN/Ovn.pm             | 15 ++++++++++++-
 src/PVE/Network/SDN/VnetPlugin.pm      |  8 +++++++
 src/PVE/Network/SDN/Zones/OvnPlugin.pm |  8 +++----
 src/test/run_test_ovn.pl               | 29 ++++++++++++++++++++++++++
 src/test/run_test_vnets_blackbox.pl    | 21 +++++++++++++++++++
 6 files changed, 84 insertions(+), 5 deletions(-)

diff --git a/src/PVE/API2/Network/SDN/Vnets.pm b/src/PVE/API2/Network/SDN/Vnets.pm
index 3f1cb1b..b753463 100644
--- a/src/PVE/API2/Network/SDN/Vnets.pm
+++ b/src/PVE/API2/Network/SDN/Vnets.pm
@@ -325,6 +325,10 @@ __PACKAGE__->register_method({
                 my $zone_scfg = PVE::Network::SDN::Zones::sdn_zones_config($zone_cfg, $zoneid);
                 my $plugin = PVE::Network::SDN::Zones::Plugin->lookup($zone_scfg->{type});
                 $plugin->vnet_update_hook($cfg, $id, $zone_cfg);
+                my $port_security = $cfg->{ids}->{$id}->{'port-security'};
+                if (defined($port_security) && $zone_scfg->{type} ne 'ovn') {
+                    raise_param_exc({ 'port-security' => "only supported on OVN zones" });
+                }
 
                 PVE::Network::SDN::VnetPlugin->on_update_hook($id, $cfg);
 
@@ -401,6 +405,10 @@ __PACKAGE__->register_method({
                 my $zone_scfg = PVE::Network::SDN::Zones::sdn_zones_config($zone_cfg, $zoneid);
                 my $plugin = PVE::Network::SDN::Zones::Plugin->lookup($zone_scfg->{type});
                 $plugin->vnet_update_hook($cfg, $id, $zone_cfg);
+                my $port_security = $cfg->{ids}->{$id}->{'port-security'};
+                if (defined($port_security) && $zone_scfg->{type} ne 'ovn') {
+                    raise_param_exc({ 'port-security' => "only supported on OVN zones" });
+                }
 
                 PVE::Network::SDN::VnetPlugin->on_update_hook($id, $cfg);
 
diff --git a/src/PVE/Network/SDN/Ovn.pm b/src/PVE/Network/SDN/Ovn.pm
index 54d9c58..cdedc4a 100644
--- a/src/PVE/Network/SDN/Ovn.pm
+++ b/src/PVE/Network/SDN/Ovn.pm
@@ -77,7 +77,7 @@ sub desired_state {
         next if !$controllerid || !$state->{$controllerid};
 
         $state->{$controllerid}->{switches}->{$vnetid} = {
-            'port-security' => $zone->{'port-security'} // 'off',
+            'port-security' => port_security_level($zone, $vnets->{$vnetid}),
             external_ids => {
                 $EXT_CONTROLLER => $controllerid,
                 $EXT_ZONE => $zoneid,
@@ -464,6 +464,19 @@ sub port_commands {
     return $commands;
 }
 
+=head3 port_security_level(\%zone, \%vnet)
+
+Returns the port security level of the guest ports on C<\%vnet>: the one of the
+vnet if set, else the one of its zone C<\%zone>.
+
+=cut
+
+sub port_security_level {
+    my ($zone, $vnet) = @_;
+
+    return $vnet->{'port-security'} // $zone->{'port-security'} // 'off';
+}
+
 =head3 port_addresses($level, $mac, \@ips)
 
 Returns the addresses and the port security entries of a port for the guest MAC
diff --git a/src/PVE/Network/SDN/VnetPlugin.pm b/src/PVE/Network/SDN/VnetPlugin.pm
index e041575..bf079bd 100644
--- a/src/PVE/Network/SDN/VnetPlugin.pm
+++ b/src/PVE/Network/SDN/VnetPlugin.pm
@@ -83,6 +83,13 @@ sub properties {
                 "If true, sets the isolated property for all interfaces on the bridge of this VNet.",
             optional => 1,
         },
+        'port-security' => {
+            type => 'string',
+            description => "Port security level of the guest interfaces (OVN zones only),"
+                . " overriding the one of the zone.",
+            enum => ['off', 'mac', 'mac-ip'],
+            optional => 1,
+        },
     };
 }
 
@@ -93,6 +100,7 @@ sub options {
         alias => { optional => 1 },
         vlanaware => { optional => 1 },
         'isolate-ports' => { optional => 1 },
+        'port-security' => { optional => 1 },
     };
 }
 
diff --git a/src/PVE/Network/SDN/Zones/OvnPlugin.pm b/src/PVE/Network/SDN/Zones/OvnPlugin.pm
index b25e70c..83c2c51 100644
--- a/src/PVE/Network/SDN/Zones/OvnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/OvnPlugin.pm
@@ -21,9 +21,9 @@ sub properties {
     return {
         'port-security' => {
             description =>
-                "Filtering of the traffic of the guests: 'mac' only lets them send with"
-                . " their own MAC, 'mac-ip' additionally restricts them to their IPs from the"
-                . " IPAM.",
+                "Default filtering of the traffic of the guests, which vnets can override:"
+                . " 'mac' only lets them send with their own MAC, 'mac-ip' additionally"
+                . " restricts them to their IPs from the IPAM.",
             type => 'string',
             enum => ['off', 'mac', 'mac-ip'],
             default => 'off',
@@ -131,7 +131,7 @@ sub add_bridge_fdb {
         PVE::Network::SDN::Ovn::update_port(
             $controller,
             $iface,
-            $zone->{'port-security'} // 'off',
+            PVE::Network::SDN::Ovn::port_security_level($zone, $vnet),
             $running_config,
             $vnetid,
             $macaddr,
diff --git a/src/test/run_test_ovn.pl b/src/test/run_test_ovn.pl
index 3e97a92..9f191b3 100755
--- a/src/test/run_test_ovn.pl
+++ b/src/test/run_test_ovn.pl
@@ -316,6 +316,35 @@ is_deeply(
 eval { PVE::Network::SDN::Ovn::port_addresses('strict', $mac, []) };
 like($@, qr/unknown port security level 'strict'/, 'port_addresses: invalid level');
 
+is(
+    PVE::Network::SDN::Ovn::port_security_level({ 'port-security' => 'mac' }, {}),
+    'mac',
+    'port_security_level: zone default',
+);
+is(
+    PVE::Network::SDN::Ovn::port_security_level(
+        { 'port-security' => 'mac' },
+        { 'port-security' => 'off' },
+    ),
+    'off',
+    'port_security_level: vnet overrides zone',
+);
+is(PVE::Network::SDN::Ovn::port_security_level({}, {}), 'off', 'port_security_level: default off');
+
+my $override_cfg = {
+    controllers => $running_cfg->{controllers},
+    zones => $running_cfg->{zones},
+    vnets => {
+        ids => {
+            vnet1 => { type => 'vnet', zone => 'zone1', 'port-security' => 'mac' },
+            vnet2 => { type => 'vnet', zone => 'zone2' },
+        },
+    },
+};
+my $override = PVE::Network::SDN::Ovn::desired_state($override_cfg)->{ovnctl}->{switches};
+is($override->{vnet1}->{'port-security'}, 'mac', 'desired_state: vnet level overrides zone');
+is($override->{vnet2}->{'port-security'}, 'mac-ip', 'desired_state: zone level as default');
+
 my $sec_switches = [
     { name => 'vnet1', external_ids => ext('zone1', 'vnet1') },
     { name => 'vnet2', external_ids => ext('zone2', 'vnet2') },
diff --git a/src/test/run_test_vnets_blackbox.pl b/src/test/run_test_vnets_blackbox.pl
index 9f4c424..af329ea 100755
--- a/src/test/run_test_vnets_blackbox.pl
+++ b/src/test/run_test_vnets_blackbox.pl
@@ -421,6 +421,27 @@ sub test_without_subnet {
 }
 run_test(\&test_without_subnet);
 
+sub test_port_security_needs_ovn_zone {
+    my $test_name = (split(/::/, (caller(0))[3]))[-1];
+
+    create_zone({
+        type => "simple",
+        ipam => "pve",
+        zone => "TESTZONE",
+    });
+
+    eval {
+        create_vnet({
+            type => "vnet",
+            zone => "TESTZONE",
+            vnet => "testvnet",
+            'port-security' => "mac",
+        });
+    };
+    like($@, qr/port-security/, "$test_name: refused on a simple zone");
+}
+run_test(\&test_port_security_needs_ovn_zone);
+
 sub test_nic_join {
     my ($test_name, $subnets) = @_;
 
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 18+ messages in thread

* [RFC PATCH network 08/16] sdn: subnets: refuse gateway, snat and dhcp on ovn zones
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
                     ` (6 preceding siblings ...)
  2026-10-13 12:14   ` [RFC PATCH network 07/16] sdn: ovn: allow overriding the port security per vnet Rémi PAETA
@ 2026-10-13 12:14   ` Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH network 09/16] sdn: ovn: support ssl connections to the databases Rémi PAETA
                     ` (7 subsequent siblings)
  15 siblings, 0 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

OVN zones do not route nor serve DHCP yet, so a subnet gateway, SNAT or
DHCP ranges would have no effect on them. Refuse them instead of
silently ignoring them.

Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
 src/PVE/Network/SDN/SubnetPlugin.pm |  9 +++++++++
 src/test/run_test_ovn.pl            | 12 ++++++++++++
 2 files changed, 21 insertions(+)

diff --git a/src/PVE/Network/SDN/SubnetPlugin.pm b/src/PVE/Network/SDN/SubnetPlugin.pm
index e2a0e50..7de40aa 100644
--- a/src/PVE/Network/SDN/SubnetPlugin.pm
+++ b/src/PVE/Network/SDN/SubnetPlugin.pm
@@ -217,6 +217,15 @@ sub on_update_hook {
         $mac = $vnet->{mac};
     }
 
+    # OVN zones neither route nor serve DHCP (yet), so these options would have no effect
+    if (($zone->{type} // '') eq 'ovn') {
+        for my $option ('gateway', 'snat', 'dhcp-range') {
+            my $value = $subnet->{$option};
+            next if !$value || (ref($value) eq 'ARRAY' && !@$value);
+            raise_param_exc({ $option => "$option is not supported on OVN zones" });
+        }
+    }
+
     my $pointopoint = Net::IP::ip_is_ipv4($gateway) && $mask == 32;
 
     #for /32 pointopoint, we allow gateway outside the subnet
diff --git a/src/test/run_test_ovn.pl b/src/test/run_test_ovn.pl
index 9f191b3..cb36a0f 100755
--- a/src/test/run_test_ovn.pl
+++ b/src/test/run_test_ovn.pl
@@ -12,6 +12,7 @@ use Test::MockModule;
 use PVE::Network::SDN;
 use PVE::Network::SDN::Ovn;
 use PVE::Network::SDN::Controllers::OvnPlugin;
+use PVE::Network::SDN::SubnetPlugin;
 use PVE::Network::SDN::Zones::OvnPlugin;
 
 my $controller = {
@@ -511,6 +512,17 @@ eval {
 };
 like($@, qr/isolate-ports is not supported/, 'vnet_update_hook: isolate-ports refused');
 
+for my $option (['gateway', '10.0.0.1'], ['snat', 1], ['dhcp-range', ['start-address=a']]) {
+    eval {
+        PVE::Network::SDN::SubnetPlugin->on_update_hook(
+            { type => 'ovn' },
+            'zone1-10.0.0.0-24',
+            { cidr => '10.0.0.0/24', mask => 24, $option->[0] => $option->[1] },
+        );
+    };
+    like($@, qr/$option->[0] is not supported on OVN zones/, "subnet: $option->[0] refused");
+}
+
 # chassis
 
 is(
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 18+ messages in thread

* [RFC PATCH network 09/16] sdn: ovn: support ssl connections to the databases
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
                     ` (7 preceding siblings ...)
  2026-10-13 12:14   ` [RFC PATCH network 08/16] sdn: subnets: refuse gateway, snat and dhcp on ovn zones Rémi PAETA
@ 2026-10-13 12:14   ` Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH network 10/16] sdn: ovn: bind guest ports to the chassis of their node Rémi PAETA
                     ` (6 subsequent siblings)
  15 siblings, 0 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

Allow ssl:<ip>:<port> connections to the OVN databases, so that the
northbound and southbound traffic gets authenticated and encrypted.
Without it, any host reaching the southbound database can register as
a chassis and claim the ports of guests.

As for the OVN deployment itself, the certificates are provided by the
administrator, in /etc/pve/priv/sdn/ovn/<controller>/: the CA
certificate ca.pem, and a certificate and key per node, <node>.pem and
<node>.key. Certificates per node allow restricting each chassis to
its own data with the southbound role based access control.

ovn-nbctl gets them as options. ovn-controller only takes them on its
command line, so the node certificate gets copied locally and a runtime
drop-in adds the options to its unit, written along with the chassis
settings on boot and on network reload. ovn-controller only gets
restarted when this setup changes.

Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
 src/PVE/Network/SDN/Controllers/OvnPlugin.pm |  14 +-
 src/PVE/Network/SDN/Ovn.pm                   | 168 ++++++++++++++++++-
 src/PVE/Network/SDN/Zones/OvnPlugin.pm       |   9 +-
 src/test/run_test_ovn.pl                     | 103 +++++++++++-
 4 files changed, 281 insertions(+), 13 deletions(-)

diff --git a/src/PVE/Network/SDN/Controllers/OvnPlugin.pm b/src/PVE/Network/SDN/Controllers/OvnPlugin.pm
index f7a9b96..9e31eca 100644
--- a/src/PVE/Network/SDN/Controllers/OvnPlugin.pm
+++ b/src/PVE/Network/SDN/Controllers/OvnPlugin.pm
@@ -15,11 +15,12 @@ sub pve_verify_sdn_ovn_remotes {
     my ($remotes, $noerr) = @_;
 
     for my $remote (split(/,/, $remotes)) {
-        next if $remote =~ m/^tcp:$PVE::Tools::IPV4RE:\d+$/;
-        next if $remote =~ m/^tcp:\[$PVE::Tools::IPV6RE\]:\d+$/;
+        next if $remote =~ m/^(?:tcp|ssl):$PVE::Tools::IPV4RE:\d+$/;
+        next if $remote =~ m/^(?:tcp|ssl):\[$PVE::Tools::IPV6RE\]:\d+$/;
 
         return undef if $noerr;
-        die "invalid OVN database connection '$remote', expected tcp:<ip>:<port>\n";
+        die "invalid OVN database connection '$remote', expected tcp:<ip>:<port> or"
+            . " ssl:<ip>:<port>\n";
     }
 
     return $remotes;
@@ -33,13 +34,16 @@ sub properties {
     return {
         'nb-connection' => {
             description => "Comma separated list of the OVN northbound database servers,"
-                . " for example tcp:10.0.0.1:6641,tcp:10.0.0.2:6641.",
+                . " for example tcp:10.0.0.1:6641,tcp:10.0.0.2:6641. With ssl:<ip>:<port>,"
+                . " every node needs its certificate in /etc/pve/priv/sdn/ovn/<controller>/"
+                . "<node>.pem and .key, next to the CA certificate ca.pem.",
             type => 'string',
             format => 'pve-sdn-ovn-remotes',
         },
         'sb-connection' => {
             description => "Comma separated list of the OVN southbound database servers,"
-                . " for example tcp:10.0.0.1:6642,tcp:10.0.0.2:6642.",
+                . " for example tcp:10.0.0.1:6642,tcp:10.0.0.2:6642. SSL connections use the"
+                . " same certificates as for the northbound database.",
             type => 'string',
             format => 'pve-sdn-ovn-remotes',
         },
diff --git a/src/PVE/Network/SDN/Ovn.pm b/src/PVE/Network/SDN/Ovn.pm
index cdedc4a..ad9dda3 100644
--- a/src/PVE/Network/SDN/Ovn.pm
+++ b/src/PVE/Network/SDN/Ovn.pm
@@ -2,6 +2,7 @@ package PVE::Network::SDN::Ovn;
 
 use v5.36;
 
+use File::Path qw(make_path);
 use JSON;
 
 use PVE::Cluster;
@@ -21,6 +22,17 @@ our $CONNECT_TIMEOUT = 10;
 
 our $INTEGRATION_BRIDGE = 'br-int';
 
+# certificates provided by the administrator for SSL connections to the OVN
+# databases: <controller>/ca.pem, and <controller>/<node>.pem and .key
+our $SSL_DIR = '/etc/pve/priv/sdn/ovn';
+
+# local copies of the certificates of this node for ovn-controller, which only
+# takes them as command line options, through a runtime drop-in for its unit
+our $CHASSIS_SSL_DIR = '/etc/ovn/pve-sdn';
+our $CONTROLLER_UNIT_FILE = '/usr/lib/systemd/system/ovn-controller.service';
+our $CONTROLLER_DROPIN = '/run/systemd/system/ovn-controller.service.d/pve-sdn-ssl.conf';
+our $SYSTEMCTL = '/usr/bin/systemctl';
+
 # external_ids keys tagging the objects managed by PVE. Northbound objects
 # without them are never touched, so the OVN deployment can be shared with
 # other consumers.
@@ -36,7 +48,7 @@ my @CHASSIS_KEYS = ('ovn-remote', 'ovn-encap-type', 'ovn-encap-ip', $EXT_CONTROL
 =head3 ovn_controllers(\%running_cfg)
 
 Returns a hash of all controllers of type C<ovn> in C<\%running_cfg>, keyed by
-their ID.
+their ID, as returned by C<controller_config>.
 
 =cut
 
@@ -46,11 +58,63 @@ sub ovn_controllers {
     my $controllers = $running_cfg->{controllers}->{ids} // {};
 
     return {
-        map { $_ => $controllers->{$_} }
+        map { $_ => controller_config($running_cfg, $_) }
         grep { $controllers->{$_}->{type} eq 'ovn' } keys %$controllers
     };
 }
 
+=head3 controller_config(\%running_cfg, $controllerid)
+
+Returns a copy of the configuration of the controller C<$controllerid>, with its
+ID under C<id>, which is needed to find its certificates.
+
+=cut
+
+sub controller_config {
+    my ($running_cfg, $controllerid) = @_;
+
+    my $controller = $running_cfg->{controllers}->{ids}->{$controllerid} or return;
+
+    return { $controller->%*, id => $controllerid };
+}
+
+=head3 uses_ssl($connection)
+
+Returns whether the OVN database connection string C<$connection> uses SSL.
+
+=cut
+
+sub uses_ssl {
+    my ($connection) = @_;
+
+    return (grep { m/^ssl:/ } split(/,/, $connection // '')) ? 1 : 0;
+}
+
+=head3 ssl_files($controllerid, $nodename)
+
+Returns the paths of the private key, certificate and CA certificate that node
+C<$nodename> uses for the SSL connections to the databases of the OVN
+controller C<$controllerid>. Dies if one of them is missing.
+
+=cut
+
+sub ssl_files {
+    my ($controllerid, $nodename) = @_;
+
+    my $dir = "$SSL_DIR/$controllerid";
+    my $files = {
+        'private-key' => "$dir/$nodename.key",
+        certificate => "$dir/$nodename.pem",
+        'ca-cert' => "$dir/ca.pem",
+    };
+
+    my @missing = grep { !-f $_ } sort values %$files;
+    die "missing SSL certificates for the OVN controller '$controllerid': @missing\n"
+        if @missing;
+
+    return $files;
+}
+
 =head3 desired_state(\%running_cfg)
 
 Computes the northbound objects that should exist for C<\%running_cfg>, keyed by
@@ -240,10 +304,22 @@ sub guest_nics {
 sub nbctl {
     my ($controller, @args) = @_;
 
+    my @ssl_args = ();
+    if (uses_ssl($controller->{'nb-connection'})) {
+        my $files = ssl_files($controller->{id}, PVE::INotify::nodename());
+        @ssl_args = map { "--$_=$files->{$_}" } sort keys %$files;
+    }
+
     my ($output, $errors) = ('', '');
     eval {
         run_command(
-            [$NBCTL, "--db=$controller->{'nb-connection'}", "--timeout=$NBCTL_TIMEOUT", @args],
+            [
+                $NBCTL,
+                "--db=$controller->{'nb-connection'}",
+                "--timeout=$NBCTL_TIMEOUT",
+                @ssl_args,
+                @args,
+            ],
             outfunc => sub { $output .= "$_[0]\n"; },
             errfunc => sub { $errors .= "$_[0]\n"; },
         );
@@ -752,6 +828,7 @@ sub configure_chassis {
         if ($attached) {
             run_command([$VSCTL, 'remove', 'Open_vSwitch', '.', 'external_ids', @CHASSIS_KEYS]);
         }
+        configure_chassis_ssl(undef, $nodename);
         return;
     }
 
@@ -763,6 +840,10 @@ sub configure_chassis {
     die "unable to find the local IP to reach the OVN southbound database at $remote_ip\n"
         if !$encap_ip;
 
+    configure_chassis_ssl(
+        uses_ssl($controller->{'sb-connection'}) ? $id : undef, $nodename,
+    );
+
     my $settings = chassis_settings($id, $controller, $encap_ip);
     run_command([
         $VSCTL,
@@ -793,6 +874,87 @@ sub wait_controller_connected {
     return controller_connected();
 }
 
+=head3 chassis_ssl_dropin($unit_content)
+
+Returns the systemd drop-in for the ovn-controller unit with the content
+C<$unit_content>, which adds the options for the local copies of the
+certificates to its command line.
+
+=cut
+
+sub chassis_ssl_dropin {
+    my ($unit_content) = @_;
+
+    my @exec_start = $unit_content =~ m/^ExecStart=(.+)$/mg;
+    die "unable to find the command line of ovn-controller in $CONTROLLER_UNIT_FILE\n"
+        if scalar(@exec_start) != 1;
+
+    my $options = join(
+        ' ',
+        "--ovn-controller-ssl-key=$CHASSIS_SSL_DIR/chassis.key",
+        "--ovn-controller-ssl-cert=$CHASSIS_SSL_DIR/chassis.pem",
+        "--ovn-controller-ssl-ca-cert=$CHASSIS_SSL_DIR/ca.pem",
+    );
+
+    return "# generated by the Proxmox VE SDN, do not edit\n"
+        . "[Service]\nExecStart=\nExecStart=$exec_start[0] $options\n";
+}
+
+# writes $content to $path if it changed, returns whether it did
+sub update_file {
+    my ($path, $content, $perm) = @_;
+
+    my $current = -f $path ? PVE::Tools::file_get_contents($path) : undef;
+    return 0 if defined($current) && $current eq $content;
+
+    PVE::Tools::file_set_contents($path, $content, $perm);
+    return 1;
+}
+
+=head3 configure_chassis_ssl($controllerid, $nodename)
+
+Sets up the certificates of node C<$nodename> for the SSL connection of
+ovn-controller to the southbound database of the OVN controller
+C<$controllerid>, or removes that setup if C<$controllerid> is undefined.
+ovn-controller gets restarted if anything changed.
+
+=cut
+
+sub configure_chassis_ssl {
+    my ($controllerid, $nodename) = @_;
+
+    my $changed = 0;
+
+    if (defined($controllerid)) {
+        my $files = ssl_files($controllerid, $nodename);
+
+        make_path($CHASSIS_SSL_DIR, { mode => 0700 });
+        my $copies = {
+            'private-key' => ['chassis.key', 0600],
+            certificate => ['chassis.pem', 0644],
+            'ca-cert' => ['ca.pem', 0644],
+        };
+        for my $type (sort keys %$copies) {
+            my ($name, $perm) = $copies->{$type}->@*;
+            my $content = PVE::Tools::file_get_contents($files->{$type});
+            $changed = 1 if update_file("$CHASSIS_SSL_DIR/$name", $content, $perm);
+        }
+
+        my $dropin = chassis_ssl_dropin(PVE::Tools::file_get_contents($CONTROLLER_UNIT_FILE));
+        make_path($CONTROLLER_DROPIN =~ s|/[^/]+$||r);
+        $changed = 1 if update_file($CONTROLLER_DROPIN, $dropin, 0644);
+    } elsif (-e $CONTROLLER_DROPIN) {
+        unlink($CONTROLLER_DROPIN) or die "unable to remove $CONTROLLER_DROPIN - $!\n";
+        unlink(glob("$CHASSIS_SSL_DIR/*"));
+        $changed = 1;
+    }
+
+    return if !$changed;
+
+    run_command([$SYSTEMCTL, 'daemon-reload']);
+    run_command([$SYSTEMCTL, 'try-restart', 'ovn-controller.service']);
+}
+
 =head3 controller_connected()
 
 Returns whether the local ovn-controller is connected to the southbound
diff --git a/src/PVE/Network/SDN/Zones/OvnPlugin.pm b/src/PVE/Network/SDN/Zones/OvnPlugin.pm
index 83c2c51..a474dce 100644
--- a/src/PVE/Network/SDN/Zones/OvnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/OvnPlugin.pm
@@ -92,8 +92,10 @@ sub tap_plug {
     my ($class, $plugin_config, $vnet, $tag, $iface, $vnetid, $firewall, $trunks, $rate) = @_;
 
     my $controllerid = $plugin_config->{controller};
-    my $controller =
-        PVE::Network::SDN::running_config()->{controllers}->{ids}->{$controllerid};
+    my $controller = PVE::Network::SDN::Ovn::controller_config(
+        PVE::Network::SDN::running_config(),
+        $controllerid,
+    );
     die "OVN controller '$controllerid' of vnet '$vnetid' is not applied yet\n"
         if !$controller;
 
@@ -119,7 +121,8 @@ sub add_bridge_fdb {
 
     my $running_config = PVE::Network::SDN::running_config();
     my $controllerid = $plugin_config->{controller};
-    my $controller = $running_config->{controllers}->{ids}->{$controllerid} or return;
+    my $controller = PVE::Network::SDN::Ovn::controller_config($running_config, $controllerid)
+        or return;
 
     eval {
         my $vnetid = PVE::Network::SDN::Ovn::port_vnet($controller, $iface)
diff --git a/src/test/run_test_ovn.pl b/src/test/run_test_ovn.pl
index cb36a0f..83bfd80 100755
--- a/src/test/run_test_ovn.pl
+++ b/src/test/run_test_ovn.pl
@@ -5,6 +5,7 @@ use warnings;
 
 use lib qw(..);
 
+use File::Temp qw(tempdir);
 use JSON;
 use Test::More;
 use Test::MockModule;
@@ -15,6 +16,10 @@ use PVE::Network::SDN::Controllers::OvnPlugin;
 use PVE::Network::SDN::SubnetPlugin;
 use PVE::Network::SDN::Zones::OvnPlugin;
 
+# keep the tests away from the systemd configuration of the host
+my $tmpdir = tempdir(CLEANUP => 1);
+$PVE::Network::SDN::Ovn::CONTROLLER_DROPIN = "$tmpdir/dropin/pve-sdn-ssl.conf";
+
 my $controller = {
     type => 'ovn',
     'nb-connection' => 'tcp:192.168.0.1:6641,tcp:192.168.0.2:6641',
@@ -668,15 +673,109 @@ is($connection_checks, 2, 'wait_controller_connected: waits until connected');
 ok(!PVE::Network::SDN::Ovn::wait_controller_connected(1), 'wait_controller_connected: timeout');
 $ovn_module->mock(controller_connected => sub { return 1 });
 
+# SSL
+
+ok(PVE::Network::SDN::Ovn::uses_ssl('ssl:10.0.0.1:6641,ssl:10.0.0.2:6641'), 'uses_ssl: ssl');
+ok(!PVE::Network::SDN::Ovn::uses_ssl('tcp:10.0.0.1:6641'), 'uses_ssl: tcp');
+
+$PVE::Network::SDN::Ovn::SSL_DIR = "$tmpdir/ssl";
+eval { PVE::Network::SDN::Ovn::ssl_files('ovnctl', 'node1') };
+like($@, qr/missing SSL certificates .*ca\.pem .*node1\.key .*node1\.pem/, 'ssl_files: missing');
+
+mkdir("$tmpdir/ssl");
+mkdir("$tmpdir/ssl/ovnctl");
+PVE::Tools::file_set_contents("$tmpdir/ssl/ovnctl/$_", "$_\n") for qw(ca.pem node1.pem node1.key);
+is_deeply(
+    PVE::Network::SDN::Ovn::ssl_files('ovnctl', 'node1'),
+    {
+        'private-key' => "$tmpdir/ssl/ovnctl/node1.key",
+        certificate => "$tmpdir/ssl/ovnctl/node1.pem",
+        'ca-cert' => "$tmpdir/ssl/ovnctl/ca.pem",
+    },
+    'ssl_files: present',
+);
+
+my $ssl_controller = {
+    id => 'ovnctl',
+    'nb-connection' => 'ssl:192.168.0.1:6641',
+    'sb-connection' => 'ssl:192.168.0.1:6642',
+};
+my $nbctl_cmd;
+$ovn_module->mock(run_command => sub { $nbctl_cmd = $_[0] });
+PVE::Network::SDN::Ovn::nbctl($ssl_controller, 'ls-list');
+is_deeply(
+    [$nbctl_cmd->@[1 .. $#$nbctl_cmd]],
+    [
+        '--db=ssl:192.168.0.1:6641',
+        '--timeout=10',
+        "--ca-cert=$tmpdir/ssl/ovnctl/ca.pem",
+        "--certificate=$tmpdir/ssl/ovnctl/node1.pem",
+        "--private-key=$tmpdir/ssl/ovnctl/node1.key",
+        'ls-list',
+    ],
+    'nbctl: certificates of the node for ssl connections',
+);
+
+is_deeply(
+    PVE::Network::SDN::Ovn::controller_config($running_cfg, 'ovnctl'),
+    { $controller->%*, id => 'ovnctl' },
+    'controller_config: with its ID',
+);
+
+my $unit = "[Service]\nExecStart=/usr/share/ovn/scripts/ovn-ctl start_controller \$OVN_CTL_OPTS\n";
+like(
+    PVE::Network::SDN::Ovn::chassis_ssl_dropin($unit),
+    qr{^ExecStart=\nExecStart=/usr/share/ovn/scripts/ovn-ctl start_controller \$OVN_CTL_OPTS --ovn-controller-ssl-key=\S+/chassis\.key --ovn-controller-ssl-cert=\S+/chassis\.pem --ovn-controller-ssl-ca-cert=\S+/ca\.pem$}m,
+    'chassis_ssl_dropin: ssl options appended to the command line of the unit',
+);
+eval { PVE::Network::SDN::Ovn::chassis_ssl_dropin("[Service]\n") };
+like($@, qr/unable to find the command line/, 'chassis_ssl_dropin: unexpected unit');
+
+$PVE::Network::SDN::Ovn::CHASSIS_SSL_DIR = "$tmpdir/chassis";
+$PVE::Network::SDN::Ovn::CONTROLLER_UNIT_FILE = "$tmpdir/ovn-controller.service";
+PVE::Tools::file_set_contents("$tmpdir/ovn-controller.service", $unit);
+my $systemctl_calls = [];
+$ovn_module->mock(run_command => sub { push @$systemctl_calls, [$_[0]->@[1 .. $_[0]->$#*]] });
+my $restart = [['daemon-reload'], ['try-restart', 'ovn-controller.service']];
+
+PVE::Network::SDN::Ovn::configure_chassis_ssl('ovnctl', 'node1');
+is_deeply($systemctl_calls, $restart, 'configure_chassis_ssl: restart on setup');
+is(
+    PVE::Tools::file_get_contents("$tmpdir/chassis/chassis.key"),
+    "node1.key\n",
+    'configure_chassis_ssl: key copied',
+);
+is((stat("$tmpdir/chassis/chassis.key"))[2] & 07777, 0600, 'configure_chassis_ssl: key mode');
+ok(-f $PVE::Network::SDN::Ovn::CONTROLLER_DROPIN, 'configure_chassis_ssl: drop-in written');
+
+$systemctl_calls = [];
+PVE::Network::SDN::Ovn::configure_chassis_ssl('ovnctl', 'node1');
+is_deeply($systemctl_calls, [], 'configure_chassis_ssl: no restart without changes');
+
+PVE::Tools::file_set_contents("$tmpdir/ssl/ovnctl/node1.pem", "renewed\n");
+PVE::Network::SDN::Ovn::configure_chassis_ssl('ovnctl', 'node1');
+is_deeply($systemctl_calls, $restart, 'configure_chassis_ssl: restart on renewed certificate');
+
+$systemctl_calls = [];
+PVE::Network::SDN::Ovn::configure_chassis_ssl(undef, 'node1');
+is_deeply($systemctl_calls, $restart, 'configure_chassis_ssl: restart on removal');
+ok(!-e $PVE::Network::SDN::Ovn::CONTROLLER_DROPIN, 'configure_chassis_ssl: drop-in removed');
+ok(!-e "$tmpdir/chassis/chassis.key", 'configure_chassis_ssl: key removed');
+
+$systemctl_calls = [];
+PVE::Network::SDN::Ovn::configure_chassis_ssl(undef, 'node1');
+is_deeply($systemctl_calls, [], 'configure_chassis_ssl: nothing to remove');
+$ovn_module->unmock('run_command');
+
 # controller schema
 
 ok(
     PVE::Network::SDN::Controllers::OvnPlugin::pve_verify_sdn_ovn_remotes(
-        'tcp:10.0.0.1:6641,tcp:[fd00::1]:6641', 1,
+        'tcp:10.0.0.1:6641,tcp:[fd00::1]:6641,ssl:10.0.0.2:6641,ssl:[fd00::2]:6641', 1,
     ),
     'remotes format: valid',
 );
-for my $invalid ('ssl:10.0.0.1:6641', 'tcp:10.0.0.1', 'tcp:ovn.example.com:6641') {
+for my $invalid ('unix:/run/ovn/ovnnb_db.sock', 'tcp:10.0.0.1', 'ssl:ovn.example.com:6641') {
     ok(
         !PVE::Network::SDN::Controllers::OvnPlugin::pve_verify_sdn_ovn_remotes($invalid, 1),
         "remotes format: invalid '$invalid'",
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 18+ messages in thread

* [RFC PATCH network 10/16] sdn: ovn: bind guest ports to the chassis of their node
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
                     ` (8 preceding siblings ...)
  2026-10-13 12:14   ` [RFC PATCH network 09/16] sdn: ovn: support ssl connections to the databases Rémi PAETA
@ 2026-10-13 12:14   ` Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH manager 11/16] ui: sdn: controller selector: allow filtering by controller type Rémi PAETA
                     ` (5 subsequent siblings)
  15 siblings, 0 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

During a live migration, QEMU already runs on the destination node
while the memory gets copied, so both ovn-controller instances see an
interface for the logical switch port and keep claiming it from each
other, every 500 ms, until the source releases it. Traffic goes to the
wrong node in the meantime: 2 to 3 s of loss were measured, while QEMU
reported a downtime of about 20 ms.

Set the requested-chassis option of guest ports to the local node when
plugging them. On the destination of a live migration, set it to both
nodes with activation-strategy=rarp instead, as recommended by OVN for
this case: the destination becomes an additional chassis and only gets
the traffic once the guest announces itself there. qemu-server calls
add_bridge_fdb on the destination when resuming the guest, which binds
the port to the destination only.

requested-chassis refers to the chassis hostname, which Open vSwitch
sets to the FQDN, so set it to the node name when configuring the
chassis.

With this, the loss went down to 0.4 to 0.8 s, close to the resolution
of the probe used.

Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
 src/PVE/Network/SDN/Ovn.pm             | 61 ++++++++++++++++++++++----
 src/PVE/Network/SDN/Zones/OvnPlugin.pm | 16 +++++--
 src/test/run_test_ovn.pl               | 52 +++++++++++++++++++++-
 3 files changed, 116 insertions(+), 13 deletions(-)

diff --git a/src/PVE/Network/SDN/Ovn.pm b/src/PVE/Network/SDN/Ovn.pm
index ad9dda3..646f9b6 100644
--- a/src/PVE/Network/SDN/Ovn.pm
+++ b/src/PVE/Network/SDN/Ovn.pm
@@ -43,7 +43,7 @@ my $EXT_VMID = 'pve-sdn-vmid';
 my $EXT_MAC = 'pve-sdn-mac';
 
 # chassis settings written to the local Open_vSwitch table
-my @CHASSIS_KEYS = ('ovn-remote', 'ovn-encap-type', 'ovn-encap-ip', $EXT_CONTROLLER);
+my @CHASSIS_KEYS = ('ovn-remote', 'ovn-encap-type', 'ovn-encap-ip', 'hostname', $EXT_CONTROLLER);
 
 =head3 ovn_controllers(\%running_cfg)
 
@@ -618,16 +618,52 @@ sub port_vnet {
     return $output eq '' ? undef : $output;
 }
 
-=head3 update_port($controller, $iface, $level, \%running_cfg, $vnetid, $mac)
+=head3 chassis_commands($iface, $nodename, $migrated_from)
+
+Returns the C<ovn-nbctl> commands binding the logical switch port C<$iface> to
+the chassis of the node C<$nodename>, so that no other chassis claims it.
+
+During a live migration from the node C<$migrated_from>, the port is bound to
+both chassis, the source one staying the main one. The destination chassis only
+gets the traffic of the port once the guest announces itself there (QEMU sends a
+RARP after the switchover). Otherwise, both chassis keep claiming the port while
+the memory gets copied, and traffic goes to the wrong node for seconds.
+
+=cut
+
+sub chassis_commands {
+    my ($iface, $nodename, $migrated_from) = @_;
+
+    if (defined($migrated_from)) {
+        return [[
+            'set',
+            'Logical_Switch_Port',
+            $iface,
+            "options:requested-chassis=$migrated_from,$nodename",
+            'options:activation-strategy=rarp',
+        ]];
+    }
+
+    return [
+        ['set', 'Logical_Switch_Port', $iface, "options:requested-chassis=$nodename"],
+        ['remove', 'Logical_Switch_Port', $iface, 'options', 'activation-strategy'],
+    ];
+}
+
+=head3 update_port($controller, $iface, $level, \%running_cfg, $vnetid, $mac, $nodename)
 
 Sets the guest MAC C<$mac> and its IPAM IPs on the logical switch port C<$iface>
 according to the port security C<$level>, and records the MAC so that later
 syncs can update the port.
 
+Also binds the port to the chassis of C<$nodename> only. qemu-server calls this
+on the destination node when resuming a guest after a live migration, which
+ends the binding to both chassis set up by C<ensure_port>.
+
 =cut
 
 sub update_port {
-    my ($controller, $iface, $level, $running_cfg, $vnetid, $mac) = @_;
+    my ($controller, $iface, $level, $running_cfg, $vnetid, $mac, $nodename) = @_;
 
     # Without an answer from the IPAM, only the MAC can be enforced: the guest
     # keeps its connectivity, and the next sync applies the IPs.
@@ -640,19 +676,21 @@ sub update_port {
             port_addresses_commands($iface, $addresses, $port_security)->@*,
             # as written in the guest config, which is what the IPAM is keyed by
             ['set', 'Logical_Switch_Port', $iface, "external_ids:$EXT_MAC=$mac"],
+            chassis_commands($iface, $nodename)->@*,
         ],
     );
 }
 
-=head3 ensure_port($controllerid, $controller, $zoneid, $vnetid, $iface)
+=head3 ensure_port($controllerid, $controller, $zoneid, $vnetid, $iface, $nodename, $migrated_from)
 
 Creates or updates the logical switch port of the guest interface C<$iface> on
-the logical switch of C<$vnetid>, moving it over from another switch if needed.
+the logical switch of C<$vnetid>, moving it over from another switch if needed,
+and binds it to the chassis of C<$nodename>, see C<chassis_commands>.
 
 =cut
 
 sub ensure_port {
-    my ($controllerid, $controller, $zoneid, $vnetid, $iface) = @_;
+    my ($controllerid, $controller, $zoneid, $vnetid, $iface, $nodename, $migrated_from) = @_;
 
     eval {
         my $current_switch;
@@ -672,6 +710,7 @@ sub ensure_port {
 
         my $external_ids = port_external_ids($controllerid, $zoneid, $vnetid, $iface);
         my $commands = port_commands($vnetid, $iface, $external_ids, $current_switch);
+        push @$commands, chassis_commands($iface, $nodename, $migrated_from)->@*;
         nbctl_transaction($controller, $commands);
     };
     die "unable to create OVN logical switch port '$iface' on '$vnetid' - $@" if $@;
@@ -765,20 +804,24 @@ sub parse_remote_ip {
     die "unable to parse OVN database connection '$first'\n";
 }
 
-=head3 chassis_settings($controllerid, $controller, $encap_ip)
+=head3 chassis_settings($controllerid, $controller, $encap_ip, $nodename)
 
 Returns the external_ids to set in the local Open_vSwitch table so that
 ovn-controller joins the OVN deployment of C<$controller>.
 
+The chassis hostname is set to the node name, which logical switch ports refer
+to in their requested-chassis option. Open vSwitch would use the FQDN instead.
+
 =cut
 
 sub chassis_settings {
-    my ($controllerid, $controller, $encap_ip) = @_;
+    my ($controllerid, $controller, $encap_ip, $nodename) = @_;
 
     return {
         'ovn-remote' => $controller->{'sb-connection'},
         'ovn-encap-type' => $controller->{'encap-type'} // 'geneve',
         'ovn-encap-ip' => $encap_ip,
+        hostname => $nodename,
         $EXT_CONTROLLER => $controllerid,
     };
 }
@@ -844,7 +887,7 @@ sub configure_chassis {
         uses_ssl($controller->{'sb-connection'}) ? $id : undef, $nodename,
     );
 
-    my $settings = chassis_settings($id, $controller, $encap_ip);
+    my $settings = chassis_settings($id, $controller, $encap_ip, $nodename);
     run_command([
         $VSCTL,
         'set',
diff --git a/src/PVE/Network/SDN/Zones/OvnPlugin.pm b/src/PVE/Network/SDN/Zones/OvnPlugin.pm
index a474dce..861059c 100644
--- a/src/PVE/Network/SDN/Zones/OvnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/OvnPlugin.pm
@@ -3,6 +3,7 @@ package PVE::Network::SDN::Zones::OvnPlugin;
 use v5.36;
 
 use PVE::Exception qw(raise_param_exc);
+use PVE::INotify;
 use PVE::Network;
 
 use PVE::Network::SDN::Ovn;
@@ -99,8 +100,15 @@ sub tap_plug {
     die "OVN controller '$controllerid' of vnet '$vnetid' is not applied yet\n"
         if !$controller;
 
+    # set by qemu-server on the destination node of a live migration
     PVE::Network::SDN::Ovn::ensure_port(
-        $controllerid, $controller, $vnet->{zone}, $vnetid, $iface,
+        $controllerid,
+        $controller,
+        $vnet->{zone},
+        $vnetid,
+        $iface,
+        PVE::INotify::nodename(),
+        $ENV{PVE_MIGRATED_FROM} || undef,
     );
 
     # detach from any previous bridge
@@ -112,8 +120,9 @@ sub tap_plug {
     die "VLAN tags and trunks are not supported on OVN vnet '$vnetid'\n" if $tag || $trunks;
 }
 
-# Called by qemu-server on guest start and by pve-container on plug, with the
-# guest MAC, which tap_plug does not get.
+# Called by qemu-server on guest start and resume, including after a live
+# migration, and by pve-container on plug, with the guest MAC, which tap_plug
+# does not get.
 sub add_bridge_fdb {
     my ($class, $plugin_config, $iface, $macaddr) = @_;
 
@@ -138,6 +147,7 @@ sub add_bridge_fdb {
             $running_config,
             $vnetid,
             $macaddr,
+            PVE::INotify::nodename(),
         );
     };
     warn "unable to set the addresses of OVN logical switch port '$iface' - $@" if $@;
diff --git a/src/test/run_test_ovn.pl b/src/test/run_test_ovn.pl
index 83bfd80..0daaed0 100755
--- a/src/test/run_test_ovn.pl
+++ b/src/test/run_test_ovn.pl
@@ -270,6 +270,53 @@ is_deeply(
     'port_commands: port moved to another vnet',
 );
 
+is_deeply(
+    PVE::Network::SDN::Ovn::chassis_commands('tap100i0', 'node1'),
+    [
+        ['set', 'Logical_Switch_Port', 'tap100i0', 'options:requested-chassis=node1'],
+        ['remove', 'Logical_Switch_Port', 'tap100i0', 'options', 'activation-strategy'],
+    ],
+    'chassis_commands: bound to the local chassis',
+);
+is_deeply(
+    PVE::Network::SDN::Ovn::chassis_commands('tap100i0', 'node2', 'node1'),
+    [[
+        'set',
+        'Logical_Switch_Port',
+        'tap100i0',
+        'options:requested-chassis=node1,node2',
+        'options:activation-strategy=rarp',
+    ]],
+    'chassis_commands: live migration, bound to both chassis until the guest announces itself',
+);
+
+{
+    my $transactions = [];
+    no warnings 'redefine';
+    local *PVE::Network::SDN::Ovn::nbctl = sub { return '' };
+    local *PVE::Network::SDN::Ovn::nbctl_transaction = sub { push @$transactions, $_[1] };
+
+    PVE::Network::SDN::Ovn::ensure_port(
+        'ovnctl', $controller, 'zone1', 'vnet1', 'tap100i0', 'node2', 'node1',
+    );
+    is_deeply(
+        $transactions->[0]->[-1],
+        PVE::Network::SDN::Ovn::chassis_commands('tap100i0', 'node2', 'node1')->[0],
+        'ensure_port: binds the port to both chassis on an incoming migration',
+    );
+
+    $transactions = [];
+    local *PVE::Network::SDN::Ovn::guest_ips = sub { return [] };
+    PVE::Network::SDN::Ovn::update_port(
+        $controller, 'tap100i0', 'off', {}, 'vnet1', 'BC:24:11:00:00:01', 'node2',
+    );
+    is_deeply(
+        [$transactions->[0]->@[-2, -1]],
+        PVE::Network::SDN::Ovn::chassis_commands('tap100i0', 'node2'),
+        'update_port: binds the port to the local chassis only',
+    );
+}
+
 {
     no warnings 'redefine';
     local *PVE::Network::SDN::Ovn::run_command = sub {
@@ -548,11 +595,12 @@ is_deeply(
 );
 
 is_deeply(
-    PVE::Network::SDN::Ovn::chassis_settings('ovnctl', $controller, '192.168.0.10'),
+    PVE::Network::SDN::Ovn::chassis_settings('ovnctl', $controller, '192.168.0.10', 'node1'),
     {
         'ovn-remote' => $controller->{'sb-connection'},
         'ovn-encap-type' => 'geneve',
         'ovn-encap-ip' => '192.168.0.10',
+        hostname => 'node1',
         'pve-sdn-controller' => 'ovnctl',
     },
     'chassis_settings',
@@ -584,6 +632,7 @@ is_deeply(
         'set',
         'Open_vSwitch',
         '.',
+        'external_ids:hostname=node1',
         'external_ids:ovn-encap-ip=192.168.0.10',
         'external_ids:ovn-encap-type=geneve',
         "external_ids:ovn-remote=$controller->{'sb-connection'}",
@@ -608,6 +657,7 @@ is_deeply(
         'ovn-remote',
         'ovn-encap-type',
         'ovn-encap-ip',
+        'hostname',
         'pve-sdn-controller',
     ]],
     'configure_chassis: detach chassis',
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 18+ messages in thread

* [RFC PATCH manager 11/16] ui: sdn: controller selector: allow filtering by controller type
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
                     ` (9 preceding siblings ...)
  2026-10-13 12:14   ` [RFC PATCH network 10/16] sdn: ovn: bind guest ports to the chassis of their node Rémi PAETA
@ 2026-10-13 12:14   ` Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH manager 12/16] ui: sdn: add ovn controller Rémi PAETA
                     ` (4 subsequent siblings)
  15 siblings, 0 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

Zones that only work with a specific controller type can then limit the
selection to the controllers of that type.

Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
 www/manager6/form/SDNControllerSelector.js | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/www/manager6/form/SDNControllerSelector.js b/www/manager6/form/SDNControllerSelector.js
index cb519df4..5705e453 100644
--- a/www/manager6/form/SDNControllerSelector.js
+++ b/www/manager6/form/SDNControllerSelector.js
@@ -8,6 +8,9 @@ Ext.define(
         valueField: 'controller',
         displayField: 'controller',
 
+        // only list controllers of this type, if set
+        controllerType: undefined,
+
         initComponent: function () {
             var me = this;
 
@@ -19,6 +22,10 @@ Ext.define(
                 },
             });
 
+            if (me.controllerType) {
+                store.addFilter({ property: 'type', value: me.controllerType });
+            }
+
             Ext.apply(me, {
                 store: store,
                 autoSelect: false,
@@ -42,7 +49,7 @@ Ext.define(
     function () {
         Ext.define('pve-sdn-controller', {
             extend: 'Ext.data.Model',
-            fields: ['controller'],
+            fields: ['controller', 'type'],
             proxy: {
                 type: 'proxmox',
                 url: '/api2/json/cluster/sdn/controllers',
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 18+ messages in thread

* [RFC PATCH manager 12/16] ui: sdn: add ovn controller
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
                     ` (10 preceding siblings ...)
  2026-10-13 12:14   ` [RFC PATCH manager 11/16] ui: sdn: controller selector: allow filtering by controller type Rémi PAETA
@ 2026-10-13 12:14   ` Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH manager 13/16] ui: sdn: add ovn zone Rémi PAETA
                     ` (3 subsequent siblings)
  15 siblings, 0 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

Allow creating and editing OVN controllers, which describe an existing
OVN deployment through the connection strings of its northbound and
southbound databases. The tunnel encapsulation defaults to Geneve and
is an advanced option, as VXLAN limits the OVN features.

Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
 www/manager6/Makefile                   |  1 +
 www/manager6/Utils.js                   |  5 +++
 www/manager6/sdn/controllers/OvnEdit.js | 50 +++++++++++++++++++++++++
 3 files changed, 56 insertions(+)
 create mode 100644 www/manager6/sdn/controllers/OvnEdit.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index d2ea786b..3007e5e9 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -324,6 +324,7 @@ JSSRC= 							\
 	sdn/controllers/EvpnEdit.js			\
 	sdn/controllers/BgpEdit.js			\
 	sdn/controllers/IsisEdit.js			\
+	sdn/controllers/OvnEdit.js			\
 	sdn/IpamView.js					\
 	sdn/ipams/Base.js				\
 	sdn/ipams/NetboxEdit.js				\
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 8b99371d..4321682b 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -964,6 +964,11 @@ Ext.define('PVE.Utils', {
                 ipanel: 'IsisInputPanel',
                 faIcon: 'crosshairs',
             },
+            ovn: {
+                name: 'ovn',
+                ipanel: 'OvnInputPanel',
+                faIcon: 'crosshairs',
+            },
         },
 
         sdnipamSchema: {
diff --git a/www/manager6/sdn/controllers/OvnEdit.js b/www/manager6/sdn/controllers/OvnEdit.js
new file mode 100644
index 00000000..bc7a7a90
--- /dev/null
+++ b/www/manager6/sdn/controllers/OvnEdit.js
@@ -0,0 +1,50 @@
+Ext.define('PVE.sdn.controllers.OvnInputPanel', {
+    extend: 'PVE.panel.SDNControllerBase',
+
+    initComponent: function () {
+        let me = this;
+
+        me.items = [
+            {
+                xtype: me.isCreate ? 'textfield' : 'displayfield',
+                name: 'controller',
+                maxLength: 8,
+                value: me.controllerid || '',
+                fieldLabel: 'ID',
+                allowBlank: false,
+            },
+            {
+                xtype: 'proxmoxtextfield',
+                name: 'nb-connection',
+                fieldLabel: gettext('Northbound DB'),
+                emptyText: 'tcp:10.0.0.1:6641,… or ssl:10.0.0.1:6641,…',
+                allowBlank: false,
+            },
+            {
+                xtype: 'proxmoxtextfield',
+                name: 'sb-connection',
+                fieldLabel: gettext('Southbound DB'),
+                emptyText: 'tcp:10.0.0.1:6642,… or ssl:10.0.0.1:6642,…',
+                allowBlank: false,
+            },
+        ];
+
+        me.advancedItems = [
+            {
+                xtype: 'proxmoxKVComboBox',
+                name: 'encap-type',
+                value: '',
+                emptyText: Proxmox.Utils.defaultText + ' (Geneve)',
+                comboItems: [
+                    ['geneve', 'Geneve'],
+                    ['vxlan', 'VXLAN'],
+                ],
+                fieldLabel: gettext('Encapsulation'),
+                allowBlank: true,
+                deleteEmpty: !me.isCreate,
+            },
+        ];
+
+        me.callParent();
+    },
+});
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 18+ messages in thread

* [RFC PATCH manager 13/16] ui: sdn: add ovn zone
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
                     ` (11 preceding siblings ...)
  2026-10-13 12:14   ` [RFC PATCH manager 12/16] ui: sdn: add ovn controller Rémi PAETA
@ 2026-10-13 12:14   ` Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH manager 14/16] ui: sdn: vnet: add port security for ovn zones Rémi PAETA
                     ` (2 subsequent siblings)
  15 siblings, 0 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

Allow creating and editing OVN zones, with the port security level of
the guest ports. Only OVN controllers can be selected for them.

Vnets of OVN zones support neither VLAN tags nor VLAN awareness, so
disable these fields, as the tag would otherwise be required.

Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
 www/manager6/Makefile             |  1 +
 www/manager6/Utils.js             |  5 +++++
 www/manager6/sdn/VnetEdit.js      |  4 ++--
 www/manager6/sdn/zones/OvnEdit.js | 34 +++++++++++++++++++++++++++++++
 4 files changed, 42 insertions(+), 2 deletions(-)
 create mode 100644 www/manager6/sdn/zones/OvnEdit.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 3007e5e9..cc179208 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -335,6 +335,7 @@ JSSRC= 							\
 	sdn/dns/PowerdnsEdit.js				\
 	sdn/zones/Base.js				\
 	sdn/zones/EvpnEdit.js				\
+	sdn/zones/OvnEdit.js				\
 	sdn/zones/QinQEdit.js				\
 	sdn/zones/SimpleEdit.js				\
 	sdn/zones/VlanEdit.js				\
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 4321682b..c9ce1479 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -942,6 +942,11 @@ Ext.define('PVE.Utils', {
                 ipanel: 'EvpnInputPanel',
                 faIcon: 'th',
             },
+            ovn: {
+                name: 'OVN',
+                ipanel: 'OvnInputPanel',
+                faIcon: 'th',
+            },
         },
 
         sdncontrollerSchema: {
diff --git a/www/manager6/sdn/VnetEdit.js b/www/manager6/sdn/VnetEdit.js
index 1c2f5293..2c2b454a 100644
--- a/www/manager6/sdn/VnetEdit.js
+++ b/www/manager6/sdn/VnetEdit.js
@@ -124,7 +124,7 @@ Ext.define('PVE.sdn.VnetInputPanel', {
         let me = this;
 
         let tagField = me.down('#sdnVnetTagField');
-        if (!zoneType || zoneType === 'simple') {
+        if (!zoneType || zoneType === 'simple' || zoneType === 'ovn') {
             tagField.setDisabled(true);
             tagField.setValue('');
             tagField.allowBlank = true;
@@ -136,7 +136,7 @@ Ext.define('PVE.sdn.VnetInputPanel', {
         tagField.validate();
 
         let vlanField = me.down('#sdnVnetVlanAwareField');
-        if (!zoneType || zoneType === 'evpn') {
+        if (!zoneType || zoneType === 'evpn' || zoneType === 'ovn') {
             vlanField.setDisabled(true);
             vlanField.setValue('');
         } else {
diff --git a/www/manager6/sdn/zones/OvnEdit.js b/www/manager6/sdn/zones/OvnEdit.js
new file mode 100644
index 00000000..0633b3c1
--- /dev/null
+++ b/www/manager6/sdn/zones/OvnEdit.js
@@ -0,0 +1,34 @@
+Ext.define('PVE.sdn.zones.OvnInputPanel', {
+    extend: 'PVE.panel.SDNZoneBase',
+
+    initComponent: function () {
+        let me = this;
+
+        me.items = [
+            {
+                xtype: 'pveSDNControllerSelector',
+                fieldLabel: gettext('Controller'),
+                name: 'controller',
+                controllerType: 'ovn',
+                value: '',
+                allowBlank: false,
+            },
+            {
+                xtype: 'proxmoxKVComboBox',
+                name: 'port-security',
+                value: '',
+                emptyText: Proxmox.Utils.defaultText + ' (' + gettext('Off') + ')',
+                comboItems: [
+                    ['off', gettext('Off')],
+                    ['mac', 'MAC'],
+                    ['mac-ip', 'MAC + IP'],
+                ],
+                fieldLabel: gettext('Port Security'),
+                allowBlank: true,
+                deleteEmpty: !me.isCreate,
+            },
+        ];
+
+        me.callParent();
+    },
+});
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 18+ messages in thread

* [RFC PATCH manager 14/16] ui: sdn: vnet: add port security for ovn zones
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
                     ` (12 preceding siblings ...)
  2026-10-13 12:14   ` [RFC PATCH manager 13/16] ui: sdn: add ovn zone Rémi PAETA
@ 2026-10-13 12:14   ` Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH manager 15/16] ui: sdn: vnet: disable isolate ports " Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH docs 16/16] sdn: document ovn zones and controllers Rémi PAETA
  15 siblings, 0 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

Vnets of OVN zones can override the port security level of their zone.
The field is only shown for OVN zones, the only ones supporting it, and
gets removed from the vnet when moving it to another zone type.

Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
 www/manager6/sdn/VnetEdit.js | 26 ++++++++++++++++++++++++++
 1 file changed, 26 insertions(+)

diff --git a/www/manager6/sdn/VnetEdit.js b/www/manager6/sdn/VnetEdit.js
index 2c2b454a..36638e10 100644
--- a/www/manager6/sdn/VnetEdit.js
+++ b/www/manager6/sdn/VnetEdit.js
@@ -32,6 +32,9 @@ Ext.define('PVE.sdn.VnetInputPanel', {
         if (me.down('#sdnVnetVlanAwareField').isDisabled()) {
             addDelete('vlanaware');
         }
+        if (me.down('#sdnVnetPortSecurityField').isDisabled()) {
+            addDelete('port-security');
+        }
 
         return values;
     },
@@ -95,6 +98,23 @@ Ext.define('PVE.sdn.VnetInputPanel', {
                 deleteEmpty: '{!isCreate}',
             },
         },
+        {
+            xtype: 'proxmoxKVComboBox',
+            itemId: 'sdnVnetPortSecurityField',
+            name: 'port-security',
+            value: '',
+            emptyText: gettext('Zone default'),
+            comboItems: [
+                ['off', gettext('Off')],
+                ['mac', 'MAC'],
+                ['mac-ip', 'MAC + IP'],
+            ],
+            fieldLabel: gettext('Port Security'),
+            allowBlank: true,
+            cbind: {
+                deleteEmpty: '{!isCreate}',
+            },
+        },
     ],
     advancedItems: [
         {
@@ -142,6 +162,12 @@ Ext.define('PVE.sdn.VnetInputPanel', {
         } else {
             vlanField.setDisabled(false);
         }
+
+        // only OVN zones filter the traffic of the guest ports
+        let portSecurityField = me.down('#sdnVnetPortSecurityField');
+        let isOvn = zoneType === 'ovn';
+        portSecurityField.setHidden(!isOvn);
+        portSecurityField.setDisabled(!isOvn);
     },
 });
 
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 18+ messages in thread

* [RFC PATCH manager 15/16] ui: sdn: vnet: disable isolate ports for ovn zones
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
                     ` (13 preceding siblings ...)
  2026-10-13 12:14   ` [RFC PATCH manager 14/16] ui: sdn: vnet: add port security for ovn zones Rémi PAETA
@ 2026-10-13 12:14   ` Rémi PAETA
  2026-10-13 12:14   ` [RFC PATCH docs 16/16] sdn: document ovn zones and controllers Rémi PAETA
  15 siblings, 0 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

OVN zones refuse the isolate-ports option, so disable the field for
their vnets and drop the option when editing a vnet of such a zone.

Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
 www/manager6/sdn/VnetEdit.js | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/www/manager6/sdn/VnetEdit.js b/www/manager6/sdn/VnetEdit.js
index 36638e10..771accdd 100644
--- a/www/manager6/sdn/VnetEdit.js
+++ b/www/manager6/sdn/VnetEdit.js
@@ -35,6 +35,9 @@ Ext.define('PVE.sdn.VnetInputPanel', {
         if (me.down('#sdnVnetPortSecurityField').isDisabled()) {
             addDelete('port-security');
         }
+        if (me.down('#sdnVnetIsolatePortsField').isDisabled()) {
+            addDelete('isolate-ports');
+        }
 
         return values;
     },
@@ -119,6 +122,7 @@ Ext.define('PVE.sdn.VnetInputPanel', {
     advancedItems: [
         {
             xtype: 'proxmoxcheckbox',
+            itemId: 'sdnVnetIsolatePortsField',
             name: 'isolate-ports',
             uncheckedValue: null,
             checked: false,
@@ -168,6 +172,9 @@ Ext.define('PVE.sdn.VnetInputPanel', {
         let isOvn = zoneType === 'ovn';
         portSecurityField.setHidden(!isOvn);
         portSecurityField.setDisabled(!isOvn);
+
+        // OVN zones do not support isolating the ports of a vnet
+        me.down('#sdnVnetIsolatePortsField').setDisabled(isOvn);
     },
 });
 
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 18+ messages in thread

* [RFC PATCH docs 16/16] sdn: document ovn zones and controllers
  2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
                     ` (14 preceding siblings ...)
  2026-10-13 12:14   ` [RFC PATCH manager 15/16] ui: sdn: vnet: disable isolate ports " Rémi PAETA
@ 2026-10-13 12:14   ` Rémi PAETA
  15 siblings, 0 replies; 18+ messages in thread
From: Rémi PAETA @ 2026-10-13 12:14 UTC (permalink / raw)
  To: pve-devel

Describe the installation of OVN, the OVN zone with its port security
levels and limitations, the port security override on VNets, and the
OVN controller with its encapsulation types, the detection of the
tunnel IP and SSL connections to the databases.

Add a setup example with the OVN databases clustered on the nodes,
and notes on securing the parts of an OVN deployment that are not
managed by Proxmox VE.

Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
 pvesdn.adoc | 295 +++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 293 insertions(+), 2 deletions(-)

diff --git a/pvesdn.adoc b/pvesdn.adoc
index 3fd3533..dce6dd4 100644
--- a/pvesdn.adoc
+++ b/pvesdn.adoc
@@ -131,6 +131,26 @@ Then enable the frr service on all nodes:
 systemctl enable frr.service
 ----
 
+[[pvesdn_install_ovn]]
+Open Virtual Network
+~~~~~~~~~~~~~~~~~~~~
+
+xref:pvesdn_zone_plugin_ovn[OVN zones] use
+https://www.ovn.org/[Open Virtual Network (OVN)]. This is currently opt-in.
+
+You need to install the `ovn-host` package on every node that is part of an OVN
+zone. It provides `ovn-controller` and pulls in Open vSwitch:
+
+----
+apt update
+apt install ovn-host
+----
+
+The OVN central components, the northbound and southbound databases and
+`ovn-northd`, are not managed by {pve}. You need to deploy them yourself, either
+on the {pve} nodes or on separate hosts, see the
+xref:pvesdn_setup_example_ovn[OVN setup example].
+
 [[pvesdn_main_configuration]]
 Configuration Overview
 ----------------------
@@ -209,6 +229,8 @@ Different technologies can be used for separation:
 
 * EVPN (BGP EVPN): VXLAN with BGP to establish Layer 3 routing
 
+* OVN: Layer 2 overlay network managed by Open Virtual Network (OVN)
+
 
 [[pvesdn_config_common_options]]
 Common Options
@@ -372,6 +394,56 @@ MTU:: Because VXLAN encapsulation uses 50 bytes, the MTU needs to be 50 bytes
   defaults to 1450.
 
 
+[[pvesdn_zone_plugin_ovn]]
+OVN Zones
+~~~~~~~~~
+
+The OVN zone creates a Layer 2 overlay network managed by Open Virtual Network
+(OVN). Each VNet is an OVN logical switch spanning all nodes of the zone through
+Geneve (or VXLAN) tunnels. As OVN knows on which node every guest interface is
+located, it can filter the traffic of each guest port.
+
+The OVN zone requires an xref:pvesdn_controller_plugin_ovn[OVN controller] and
+the `ovn-host` package on every node of the zone, see
+xref:pvesdn_install_ovn[install Open Virtual Network].
+
+OVN zone configuration options:
+
+Controller:: The OVN controller of this zone. All OVN zones of a node must use
+  the same controller, as a node can only be part of one OVN deployment.
+
+Port Security:: Filtering of the traffic of the guests, which VNets can
+  override. With `MAC`, guests can only send traffic with the MAC address of
+  their interface. With `MAC + IP`, they can additionally only use the IP
+  addresses of their interface in the IPAM, and only receive traffic for them.
+  Optional, defaults to `Off`.
+
+MTU:: Because Geneve encapsulation uses up to 58 bytes, the MTU needs to be 58
+  bytes lower than the outgoing physical interface. Optional, defaults to 1442.
+
+With `Off` and `MAC`, guests still receive traffic for other MAC addresses, as
+required by virtual MAC addresses, for example with VRRP or macvlan interfaces.
+Only use `MAC + IP` for guests without additional MAC or IP addresses, which
+excludes for example Kubernetes nodes, VRRP or Keepalived setups. A changed port
+security level is applied with the SDN configuration, without restarting the
+guests.
+
+With `MAC + IP`, the IPv6 link-local address of a guest is only allowed if it is
+derived from its MAC address. Guests that use stable privacy link-local
+addresses, for example by default with NetworkManager, keep their IPv4 and
+global IPv6 connectivity, but cannot send any traffic from their link-local
+address, as required for example by router advertisements or OSPFv3.
+
+If the IPAM cannot be queried when a guest starts, for example because a NetBox
+server is unreachable, only the MAC address of the guest is enforced until the
+SDN configuration gets applied again while the IPAM is reachable. When applying
+the SDN configuration, ports whose IP addresses cannot be looked up keep their
+current port security.
+
+NOTE: OVN zones do not support VLAN tags, VLAN-aware VNets and isolated ports.
+Their subnets cannot have a gateway, SNAT or DHCP ranges, as OVN zones do not
+route traffic or serve DHCP yet.
+
 [[pvesdn_config_vnet]]
 VNets
 -----
@@ -407,7 +479,13 @@ to take effect, you need to restart the affected guest.
 NOTE: Port isolation is local to each host. Use the
 xref:pvesdn_firewall_integration[VNET Firewall] to further isolate traffic in
 the VNET across nodes. For example, DROP by default and only allow traffic from
-the IP subnet to the gateway and vice versa.
+the IP subnet to the gateway and vice versa. OVN zones do not support port
+isolation.
+
+Port Security:: Only for xref:pvesdn_zone_plugin_ovn[OVN zones]. Overrides the
+  port security level of the zone for the guests on this VNet, for example to
+  enforce the IP addresses of regular guests, while another VNet of the same
+  zone carries a Kubernetes cluster without filtering.
 
 [[pvesdn_config_subnet]]
 Subnets
@@ -439,6 +517,9 @@ SNAT:: Enable Source NAT which allows VMs from inside a
   outgoing interface. On EVPN zones, forwarding is done on EVPN gateway-nodes.
   Optional.
 
+NOTE: Subnets of xref:pvesdn_zone_plugin_ovn[OVN zones] support neither a
+gateway, nor SNAT, nor DHCP ranges.
+
 DNS Zone Prefix:: Add a prefix to the domain registration, like
   <hostname>.prefix.<domain>  Optional.
 
@@ -450,7 +531,7 @@ Controllers
 Some zones implement a separated control and data plane that require an external
 controller to manage the VNet's control plane.
 
-Currently, only the `EVPN` zone requires an external controller.
+Currently, the `EVPN` and `OVN` zones require an external controller.
 
 
 [[pvesdn_controller_plugin_evpn]]
@@ -596,6 +677,111 @@ Loopback:: Use a loopback or dummy interface as the source of the EVPN network
   (for multipath).
 
 
+[[pvesdn_controller_plugin_ovn]]
+OVN Controller
+~~~~~~~~~~~~~~
+
+The `OVN` zone requires an OVN controller, which describes an existing OVN
+deployment. The OVN controller plugin manages the logical switches and ports in
+the northbound database and configures `ovn-controller` on every node of the OVN
+zones.
+
+{pve} does not deploy the OVN databases and `ovn-northd`, see the
+xref:pvesdn_setup_example_ovn[OVN setup example].
+
+OVN controller configuration options:
+
+Northbound DB:: A comma separated list of the northbound database servers, for
+  example `tcp:10.0.0.1:6641,tcp:10.0.0.2:6641`. Use `ssl:` instead of `tcp:`
+  for xref:pvesdn_ovn_ssl[SSL connections].
+
+Southbound DB:: A comma separated list of the southbound database servers, for
+  example `tcp:10.0.0.1:6642,tcp:10.0.0.2:6642`. Use `ssl:` instead of `tcp:`
+  for xref:pvesdn_ovn_ssl[SSL connections].
+
+Encapsulation:: The tunnel encapsulation between the nodes, `Geneve` or `VXLAN`.
+  Optional, defaults to `Geneve`.
+
+Northbound Database
+^^^^^^^^^^^^^^^^^^^
+When applying the SDN configuration, the controller creates, updates and
+deletes the logical switches of the VNets and the logical switch ports of the
+guests. It only changes objects it created, which are tagged with `pve-sdn-*`
+external IDs, so other consumers can share the OVN deployment. A logical switch
+with the name of a VNet that was not created by {pve} is refused. When you
+remove an OVN controller, all objects it created get removed from the northbound
+database.
+
+Database Outages
+^^^^^^^^^^^^^^^^
+If the OVN databases cannot be reached, guests that are already running keep
+their connectivity, as `ovn-controller` keeps the flows it installed. Guests on
+OVN zones cannot be started though, as their logical switch port cannot be
+created, and applying the SDN configuration only warns that the northbound
+database could not be synchronized. Run the OVN databases as a cluster of at
+least three members to tolerate the failure of one of them.
+
+Live Migration
+^^^^^^^^^^^^^^
+The logical switch port of a guest interface is bound to the chassis of the node
+the guest runs on, through its `requested-chassis` option. During a live
+migration, the port is bound to both nodes, and the destination node only gets
+the traffic of the guest once the guest announces itself there, after the
+switchover. This avoids both nodes claiming the port while the memory of the
+guest gets copied. Once the guest resumes on the destination node, the port is
+bound to that node only.
+
+Tunnel IP auto-detection
+^^^^^^^^^^^^^^^^^^^^^^^^
+The tunnel IP of a node is the source IP address of the route towards the first
+southbound database server in the default routing table. {pve} sets it, along
+with the southbound database, the encapsulation and the chassis hostname, which
+is set to the node name, in the Open vSwitch configuration of the node, which gets recreated on every boot. The
+`pve-sdn-ovn-chassis` service sets them again on boot, before the guests start.
+When a node is not part of any OVN zone anymore, {pve} removes these settings
+again.
+
+Encapsulation
+^^^^^^^^^^^^^
+Prefer Geneve, which uses UDP port `6081`. VXLAN, which uses UDP port `4789`,
+lacks room for the information OVN needs in the tunnel header: it limits a
+deployment to about 4000 logical networks and about 2000 ports per network, and
+some OVN features do not work with it. Use VXLAN only if the network between the
+nodes does not support Geneve.
+
+Changing the encapsulation interrupts the traffic between the nodes until all of
+them use the new encapsulation, which usually takes a few seconds when applying
+the SDN configuration.
+
+[[pvesdn_ovn_ssl]]
+SSL Connections
+^^^^^^^^^^^^^^^
+SSL connections authenticate and encrypt the traffic with the OVN databases.
+They are configured separately for the northbound database, which {pve} uses to
+manage the logical switches and ports, and for the southbound database, which
+`ovn-controller` uses on every node. Use SSL for both: without it, any host that
+can reach the southbound database can read the whole network topology, register
+as a chassis and claim the ports of guests.
+
+The certificates are provided by the public key infrastructure of the OVN
+deployment, in `/etc/pve/priv/sdn/ovn/<controller>/`:
+
+`ca.pem`::
+  The CA certificate that signed the certificates of the database servers.
+`<node>.pem` and `<node>.key`::
+  The certificate and private key of each node, used for both databases. For
+  the role-based access control of the southbound database, use the Open
+  vSwitch `system-id` of the node as common name.
+
+{pve} copies the certificate of the node to `/etc/ovn/pve-sdn/` for
+`ovn-controller` and restarts it when the certificates or the connection
+settings change. While `ovn-controller` reconnects, the guests on the node can
+lose connectivity for a few seconds.
+
+To renew the certificates, replace the files and apply the SDN configuration.
+An expired or invalid certificate shows up as `ovn-controller` not being
+connected in the status of the OVN zones.
+
 [[pvesdn_config_fabrics]]
 Fabrics
 -------
@@ -1445,6 +1631,12 @@ SDN integrates with the Proxmox VE firewall by automatically generating IPSets
 which can then be referenced in the source / destination fields of firewall
 rules. This happens automatically for VNets and IPAM entries.
 
+NOTE: With xref:pvesdn_zone_plugin_ovn[OVN zones], the firewall only generates
+the SDN IPSets if the `libpve-rs-perl` package, and with the nftables-based
+firewall also the `proxmox-firewall` package, support OVN zones. Otherwise, the
+firewall cannot parse the SDN configuration and no SDN IPSet gets generated, for
+any zone.
+
 VNets and Subnets
 ~~~~~~~~~~~~~~~~~
 
@@ -1867,6 +2059,89 @@ If you have configured an external BGP router, the BGP-EVPN routes (10.0.1.0/24
 and 10.0.2.0/24 in this example), will be announced dynamically.
 
 
+[[pvesdn_setup_example_ovn]]
+OVN Setup Example
+~~~~~~~~~~~~~~~~~
+
+The example assumes a cluster with three nodes, with the node IP addresses
+192.168.0.1, 192.168.0.2 and 192.168.0.3, which also run the OVN central
+components as a three member cluster.
+
+Install the `ovn-central` and `ovn-host` packages on all nodes:
+
+----
+apt install ovn-central ovn-host
+----
+
+On the first node, configure the databases to create the cluster, in
+`/etc/default/ovn-central`:
+
+----
+OVN_CTL_OPTS="--db-nb-addr=192.168.0.1 --db-sb-addr=192.168.0.1 \
+  --db-nb-cluster-local-addr=192.168.0.1 --db-sb-cluster-local-addr=192.168.0.1 \
+  --db-nb-create-insecure-remote=yes --db-sb-create-insecure-remote=yes \
+  --ovn-northd-nb-db=tcp:192.168.0.1:6641,tcp:192.168.0.2:6641,tcp:192.168.0.3:6641 \
+  --ovn-northd-sb-db=tcp:192.168.0.1:6642,tcp:192.168.0.2:6642,tcp:192.168.0.3:6642"
+----
+
+On the other nodes, use their own address and join the cluster of the first
+node, for example on the second node:
+
+----
+OVN_CTL_OPTS="--db-nb-addr=192.168.0.2 --db-sb-addr=192.168.0.2 \
+  --db-nb-cluster-local-addr=192.168.0.2 --db-sb-cluster-local-addr=192.168.0.2 \
+  --db-nb-cluster-remote-addr=192.168.0.1 --db-sb-cluster-remote-addr=192.168.0.1 \
+  --db-nb-create-insecure-remote=yes --db-sb-create-insecure-remote=yes \
+  --ovn-northd-nb-db=tcp:192.168.0.1:6641,tcp:192.168.0.2:6641,tcp:192.168.0.3:6641 \
+  --ovn-northd-sb-db=tcp:192.168.0.1:6642,tcp:192.168.0.2:6642,tcp:192.168.0.3:6642"
+----
+
+Restart `ovn-central` on all nodes, starting with the first one:
+
+----
+systemctl restart ovn-central
+----
+
+This setup uses unencrypted TCP connections. For production setups, use
+xref:pvesdn_ovn_ssl[SSL connections] instead.
+
+Create an OVN controller named `ovnctl`:
+
+----
+ID: ovnctl
+Northbound DB: tcp:192.168.0.1:6641,tcp:192.168.0.2:6641,tcp:192.168.0.3:6641
+Southbound DB: tcp:192.168.0.1:6642,tcp:192.168.0.2:6642,tcp:192.168.0.3:6642
+----
+
+Create an OVN zone named `myovnzone` using the controller:
+
+----
+ID: myovnzone
+Controller: ovnctl
+Port Security: mac
+----
+
+Create a VNet named `ovnvnet1` using the OVN zone `myovnzone`:
+
+----
+ID: ovnvnet1
+Zone: myovnzone
+----
+
+Apply the configuration on the main SDN web interface panel. Then create two
+virtual machines on different nodes, each with a vNIC on `ovnvnet1`, and use the
+following network configuration for them (note the lower MTU):
+
+----
+auto eth0
+iface eth0 inet static
+	address 10.0.4.100/24
+	mtu 1442
+----
+
+Use `10.0.4.101/24` for the second virtual machine. Then, you should be able to
+ping between the virtual machines.
+
 [[pvesdn_setup_example_wireguard]]
 WireGuard Setup Example
 ~~~~~~~~~~~~~~~~~~~~~~~
@@ -2067,3 +2342,19 @@ and add the key to `/etc/ipsec.secrets`, so that the file contents looks like:
 ----
 
 Copy the PSK and the configuration to all nodes participating in the VXLAN network.
+
+OVN Security
+~~~~~~~~~~~~
+
+Besides using xref:pvesdn_ovn_ssl[SSL connections] to the OVN databases,
+consider securing the parts of an OVN deployment that {pve} does not manage:
+
+* The replication between the members of a clustered OVN database uses
+  separate connections, which can also use SSL.
+* The role-based access control of the southbound database restricts each
+  chassis to its own data. It requires SSL connections and certificates whose
+  common name is the name of the chassis.
+* The traffic of the guests in the Geneve or VXLAN tunnels is not encrypted.
+  OVN can encrypt it with IPsec.
+
+Refer to the OVN documentation for these settings.
-- 
2.47.3




^ permalink raw reply related	[flat|nested] 18+ messages in thread

end of thread, other threads:[~2026-10-13 12:34 UTC | newest]

Thread overview: 18+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-10-09  9:22 [RFC] sdn: OVN zone and controller plugins Rémi PAETA
2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
2026-10-13 12:14   ` [RFC PATCH ve-rs 01/16] ve-config: sdn: add ovn zone type Rémi PAETA
2026-10-13 12:14   ` SPAM: [RFC PATCH network 02/16] sdn: controllers: add ovn plugin Rémi PAETA
2026-10-13 12:14   ` [RFC PATCH network 03/16] sdn: zones: " Rémi PAETA
2026-10-13 12:14   ` [RFC PATCH network 04/16] sdn: ovn: sync the northbound database on reload Rémi PAETA
2026-10-13 12:14   ` [RFC PATCH network 05/16] sdn: ovn: configure the chassis on boot and on network reload Rémi PAETA
2026-10-13 12:14   ` [RFC PATCH network 06/16] sdn: ovn: add port security Rémi PAETA
2026-10-13 12:14   ` [RFC PATCH network 07/16] sdn: ovn: allow overriding the port security per vnet Rémi PAETA
2026-10-13 12:14   ` [RFC PATCH network 08/16] sdn: subnets: refuse gateway, snat and dhcp on ovn zones Rémi PAETA
2026-10-13 12:14   ` [RFC PATCH network 09/16] sdn: ovn: support ssl connections to the databases Rémi PAETA
2026-10-13 12:14   ` [RFC PATCH network 10/16] sdn: ovn: bind guest ports to the chassis of their node Rémi PAETA
2026-10-13 12:14   ` [RFC PATCH manager 11/16] ui: sdn: controller selector: allow filtering by controller type Rémi PAETA
2026-10-13 12:14   ` [RFC PATCH manager 12/16] ui: sdn: add ovn controller Rémi PAETA
2026-10-13 12:14   ` [RFC PATCH manager 13/16] ui: sdn: add ovn zone Rémi PAETA
2026-10-13 12:14   ` [RFC PATCH manager 14/16] ui: sdn: vnet: add port security for ovn zones Rémi PAETA
2026-10-13 12:14   ` [RFC PATCH manager 15/16] ui: sdn: vnet: disable isolate ports " Rémi PAETA
2026-10-13 12:14   ` [RFC PATCH docs 16/16] sdn: document ovn zones and controllers Rémi PAETA

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal