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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion pkg/api/handlers/compat/networks.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
package compat

import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"maps"
"math/bits"
"net"
"net/http"
"net/netip"
Expand Down Expand Up @@ -123,7 +125,9 @@ func convertLibpodNetworktoDockerNetwork(runtime *libpod.Runtime, statuses []abi
ipamConfig := dockerNetwork.IPAMConfig{
Subnet: subnet,
Gateway: gateway,
// TODO add range
}
if ipRange, ok := leaseRangeToIPRangePrefix(sub.LeaseRange); ok {
ipamConfig.IPRange = ipRange
}
ipamConfigs = append(ipamConfigs, ipamConfig)
}
Expand Down Expand Up @@ -506,3 +510,59 @@ func Prune(w http.ResponseWriter, r *http.Request) {
}
utils.WriteResponse(w, http.StatusOK, response{NetworksDeleted: prunedNetworks})
}

// leaseRangeToIPRangePrefix converts a LeaseRange back to a CIDR prefix for the Docker
// compat API. This is the reverse of the conversion done in createNetwork, where an
// IPRange CIDR (e.g. "192.168.0.128/25") is expanded to [FirstIP, LastIP] via
// FirstIPInSubnet/LastIPInSubnet. FirstIPInSubnet returns network+1 (first usable host),
// so StartIP is one greater than the network address. LastIPInSubnet returns the broadcast.
// Only succeeds for IPv4 ranges that form a valid aligned CIDR block.
func leaseRangeToIPRangePrefix(lr *nettypes.LeaseRange) (netip.Prefix, bool) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a few thoughts on this!

First, this function could be a lot shorter and cleaner. I'm not a huge fan of using raw bitwise operations here; we can make the logic much more elegant and readable. Here is a quick pseudocode mockup of what I mean:

FUNCTION leaseRangeToIPRangePrefix(leaseRange):

    // 1. Initial Validation
    IF leaseRange IS NULL:
        RETURN Null, False
        
    startIP = ParseIPv4(leaseRange.StartIP)
    endIP = ParseIPv4(leaseRange.EndIP)
    
    // Ensure IPs are valid and mathematically start <= end
    IF startIP IS INVALID OR endIP IS INVALID OR startIP > endIP:
        RETURN Null, False

    // 2. Calculate the total number of IPs in the range
    startInt = ConvertTo32BitInteger(startIP)
    endInt = ConvertTo32BitInteger(endIP)
    
    totalIPs = (endInt - startInt) + 1

    // 3. Verify the range size is a valid subnet size
    // A valid subnet size must be a perfect power of 2 (1, 2, 4, 8, 16, etc.)
    // In binary, a perfect power of 2 has exactly one "1" bit.
    IF CountSetBits(totalIPs) != 1:
        RETURN Null, False

    // 4. Calculate the subnet mask / prefix length
    // The number of trailing zeros in the total IPs equals the host bits
    numberOfHostBits = CountTrailingZeros(totalIPs)
    networkPrefixLength = 32 - numberOfHostBits
    
    proposedPrefix = CreateSubnetPrefix(startIP, networkPrefixLength)

    // 5. Verify network boundary alignment
    // The start IP must perfectly match the base network address of the subnet
    IF proposedPrefix DOES NOT EQUAL ApplyNetworkMask(proposedPrefix):
        RETURN Null, False

    // 6. Success
    RETURN proposedPrefix, True

Second, we definitely need to add unit tests for this function.

Lastly (and this isn't a blocker), something in the back of my mind is screaming that this function actually belongs in libnetwork under container-libs. WDYT? @Luap99

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am fine with having this code here, I don't think we will need it elsewhere. most of that libnetwork code is going into netavark now so it is not like we could resuse it from there.

if lr == nil || lr.StartIP == nil || lr.EndIP == nil {
return netip.Prefix{}, false
}
start4 := lr.StartIP.To4()
end4 := lr.EndIP.To4()
if start4 == nil || end4 == nil {
// IPv6 not supported: no lossless round-trip without storing the original CIDR
return netip.Prefix{}, false
}

startU := binary.BigEndian.Uint32(start4)
endU := binary.BigEndian.Uint32(end4)

if endU < startU {
return netip.Prefix{}, false
}

// For /32, FirstIPInSubnet returns the address unchanged so StartIP == EndIP.
if startU == endU {
addr, _ := netip.AddrFromSlice(start4)
return netip.PrefixFrom(addr.Unmap(), 32), true
}

// For all other subnets, FirstIPInSubnet increments the network address by 1,
// so the actual network address is StartIP - 1.
if startU == 0 {
return netip.Prefix{}, false
}
networkU := startU - 1
totalIPs := endU - networkU + 1

// A valid CIDR block size must be a power of two.
if bits.OnesCount32(totalIPs) != 1 {
return netip.Prefix{}, false
}
prefixLen := 32 - bits.TrailingZeros32(totalIPs)

var netBytes [4]byte
binary.BigEndian.PutUint32(netBytes[:], networkU)
networkAddr, _ := netip.AddrFromSlice(netBytes[:])
proposedPrefix := netip.PrefixFrom(networkAddr.Unmap(), prefixLen)

// Verify network boundary alignment: network address must be at the subnet boundary.
if proposedPrefix.Masked() != proposedPrefix {
return netip.Prefix{}, false
}
return proposedPrefix, true
}
118 changes: 118 additions & 0 deletions pkg/api/handlers/compat/networks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//go:build !remote && (linux || freebsd)

package compat

import (
"net"
"net/netip"
"testing"

nettypes "go.podman.io/common/libnetwork/types"
)

func TestLeaseRangeToIPRangePrefix(t *testing.T) {
mustPrefix := func(s string) netip.Prefix {
p, err := netip.ParsePrefix(s)
if err != nil {
t.Fatalf("invalid prefix %q: %v", s, err)
}
return p
}
leaseRange := func(start, end string) *nettypes.LeaseRange {
return &nettypes.LeaseRange{
StartIP: net.ParseIP(start),
EndIP: net.ParseIP(end),
}
}

tests := []struct {
name string
lr *nettypes.LeaseRange
want netip.Prefix
wantOK bool
}{
{
name: "nil LeaseRange",
lr: nil,
wantOK: false,
},
{
name: "nil StartIP",
lr: &nettypes.LeaseRange{EndIP: net.ParseIP("192.168.1.1")},
wantOK: false,
},
{
name: "nil EndIP",
lr: &nettypes.LeaseRange{StartIP: net.ParseIP("192.168.1.1")},
wantOK: false,
},
{
// /24: StartIP = network+1 = .1, EndIP = broadcast = .255
name: "/24 subnet",
lr: leaseRange("192.168.1.1", "192.168.1.255"),
want: mustPrefix("192.168.1.0/24"),
wantOK: true,
},
{
// /25: StartIP = .129, EndIP = .255
name: "/25 subnet",
lr: leaseRange("10.10.61.129", "10.10.61.255"),
want: mustPrefix("10.10.61.128/25"),
wantOK: true,
},
{
// /16: StartIP = .0.1, EndIP = .255.255
name: "/16 subnet",
lr: leaseRange("10.10.0.1", "10.10.255.255"),
want: mustPrefix("10.10.0.0/16"),
wantOK: true,
},
{
// /32: single host, StartIP == EndIP (FirstIPInSubnet returns address unchanged)
name: "/32 single host",
lr: leaseRange("10.0.0.5", "10.0.0.5"),
want: mustPrefix("10.0.0.5/32"),
wantOK: true,
},
{
// misaligned: StartIP and EndIP do not form a valid CIDR block
name: "misaligned range",
lr: leaseRange("192.168.1.2", "192.168.1.255"),
wantOK: false,
},
{
// non-power-of-two size: 3 addresses
name: "non-power-of-two size",
lr: leaseRange("192.168.1.1", "192.168.1.3"),
wantOK: false,
},
{
// end before start
name: "end before start",
lr: leaseRange("192.168.1.5", "192.168.1.1"),
wantOK: false,
},
{
// IPv6 not supported
name: "IPv6 not supported",
lr: &nettypes.LeaseRange{
StartIP: net.ParseIP("::1"),
EndIP: net.ParseIP("::1"),
},
wantOK: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := leaseRangeToIPRangePrefix(tt.lr)
if ok != tt.wantOK {
t.Errorf("leaseRangeToIPRangePrefix() ok = %v, want %v", ok, tt.wantOK)
return
}
if ok && got != tt.want {
t.Errorf("leaseRangeToIPRangePrefix() = %v, want %v", got, tt.want)
}
})
}
}
6 changes: 6 additions & 0 deletions test/apiv2/35-networks.at
Original file line number Diff line number Diff line change
Expand Up @@ -257,4 +257,10 @@ t POST networks/netcon/connect Container=c1 200 OK
# cleanup
podman network rm -f netcon

# IPRange is populated in IPAM config when a network is created with --ip-range
podman network create --subnet 10.10.61.0/24 --ip-range 10.10.61.128/25 iprange-test
t GET networks/iprange-test 200 \
.IPAM.Config[0].IPRange="10.10.61.128/25"
podman network rm -f iprange-test

# vim: filetype=sh