Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

dhcp ipam: support customizing dhcp options from CNI args #670

Merged
merged 10 commits into from
Dec 15, 2021
25 changes: 18 additions & 7 deletions plugins/ipam/dhcp/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,10 @@ import (
"time"

"github.com/containernetworking/cni/pkg/skel"
"github.com/containernetworking/cni/pkg/types"
current "github.com/containernetworking/cni/pkg/types/100"
"github.com/coreos/go-systemd/v22/activation"
)

const listenFdsStart = 3

var errNoMoreTries = errors.New("no more tries")

type DHCP struct {
Expand All @@ -55,21 +52,35 @@ func newDHCP(clientTimeout, clientResendMax time.Duration) *DHCP {
}
}

// TODO: current client ID is too long. At least the container ID should not be used directly.
// A seperate issue is necessary to ensure no breaking change is affecting other users.
func generateClientID(containerID string, netName string, ifName string) string {
return containerID + "/" + netName + "/" + ifName
clientID := containerID + "/" + netName + "/" + ifName
// defined in RFC 2132, length size can not be larger than 1 octet. So we truncate 254 to make everyone happy.
if len(clientID) > 254 {
clientID = clientID[0:254]
Copy link
Member

Choose a reason for hiding this comment

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

IMO, containerID and ifName are enough to be composed as clientID.

Copy link
Contributor Author

@SilverBut SilverBut Nov 27, 2021

Choose a reason for hiding this comment

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

This is actually not changed to ensure backward compatibility, since I'm not sure if anyone is using this default config in somewhere of their config...

}
return clientID
}

// Allocate acquires an IP from a DHCP server for a specified container.
// The acquired lease will be maintained until Release() is called.
func (d *DHCP) Allocate(args *skel.CmdArgs, result *current.Result) error {
conf := types.NetConf{}
conf := NetConf{}
if err := json.Unmarshal(args.StdinData, &conf); err != nil {
return fmt.Errorf("error parsing netconf: %v", err)
}

optsRequesting, optsProviding, err := prepareOptions(args.Args, conf.IPAM.ProvideOptions, conf.IPAM.RequestOptions)
if err != nil {
return err
}

clientID := generateClientID(args.ContainerID, conf.Name, args.IfName)
hostNetns := d.hostNetnsPrefix + args.Netns
l, err := AcquireLease(clientID, hostNetns, args.IfName, d.clientTimeout, d.clientResendMax, d.broadcast)
l, err := AcquireLease(clientID, hostNetns, args.IfName,
optsRequesting, optsProviding,
d.clientTimeout, d.clientResendMax, d.broadcast)
if err != nil {
return err
}
Expand All @@ -94,7 +105,7 @@ func (d *DHCP) Allocate(args *skel.CmdArgs, result *current.Result) error {
// Release stops maintenance of the lease acquired in Allocate()
// and sends a release msg to the DHCP server.
func (d *DHCP) Release(args *skel.CmdArgs, reply *struct{}) error {
conf := types.NetConf{}
conf := NetConf{}
if err := json.Unmarshal(args.StdinData, &conf); err != nil {
return fmt.Errorf("error parsing netconf: %v", err)
}
Expand Down
102 changes: 95 additions & 7 deletions plugins/ipam/dhcp/lease.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"log"
"math/rand"
"net"
"strings"
"sync"
"sync/atomic"
"time"
Expand Down Expand Up @@ -62,22 +63,93 @@ type DHCPLease struct {
stopping uint32
stop chan struct{}
wg sync.WaitGroup
// list of requesting and providing options and if they are necessary / their value
optsRequesting map[dhcp4.OptionCode]bool
optsProviding map[dhcp4.OptionCode][]byte
}

var requestOptionsDefault = map[dhcp4.OptionCode]bool{
dhcp4.OptionRouter: true,
dhcp4.OptionSubnetMask: true,
}

func prepareOptions(cniArgs string, ProvideOptions []ProvideOption, RequestOptions []RequestOption) (
optsRequesting map[dhcp4.OptionCode]bool, optsProviding map[dhcp4.OptionCode][]byte, err error) {

// parse CNI args
cniArgsParsed := map[string]string{}
for _, argPair := range strings.Split(cniArgs, ";") {
args := strings.SplitN(argPair, "=", 2)
if len(args) > 1 {
cniArgsParsed[args[0]] = args[1]
}
}

// parse providing options map
var optParsed dhcp4.OptionCode
optsProviding = make(map[dhcp4.OptionCode][]byte)
for _, opt := range ProvideOptions {
optParsed, err = parseOptionName(string(opt.Option))
if err != nil {
err = fmt.Errorf("Can not parse option %q: %w", opt.Option, err)
return
}
if len(opt.Value) > 0 {
if len(opt.Value) > 255 {
err = fmt.Errorf("value too long for option %q: %q", opt.Option, opt.Value)
return
}
optsProviding[optParsed] = []byte(opt.Value)
}
if value, ok := cniArgsParsed[opt.ValueFromCNIArg]; ok {
if len(value) > 255 {
err = fmt.Errorf("value too long for option %q from CNI_ARGS %q: %q", opt.Option, opt.ValueFromCNIArg, opt.Value)
return
}
optsProviding[optParsed] = []byte(value)
}
}

// parse necessary options map
optsRequesting = make(map[dhcp4.OptionCode]bool)
skipRequireDefault := false
for _, opt := range RequestOptions {
if opt.SkipDefault {
skipRequireDefault = true
}
optParsed, err = parseOptionName(string(opt.Option))
if err != nil {
err = fmt.Errorf("Can not parse option %q: %w", opt.Option, err)
return
}
optsRequesting[optParsed] = true
}
for k, v := range requestOptionsDefault {
// only set if not skipping default and this value does not exists
if _, ok := optsRequesting[k]; !ok && !skipRequireDefault {
optsRequesting[k] = v
}
}
return
}

// AcquireLease gets an DHCP lease and then maintains it in the background
// by periodically renewing it. The acquired lease can be released by
// calling DHCPLease.Stop()
func AcquireLease(
clientID, netns, ifName string,
optsRequesting map[dhcp4.OptionCode]bool, optsProviding map[dhcp4.OptionCode][]byte,
timeout, resendMax time.Duration, broadcast bool,
) (*DHCPLease, error) {
errCh := make(chan error, 1)
l := &DHCPLease{
clientID: clientID,
stop: make(chan struct{}),
timeout: timeout,
resendMax: resendMax,
broadcast: broadcast,
clientID: clientID,
stop: make(chan struct{}),
timeout: timeout,
resendMax: resendMax,
broadcast: broadcast,
optsRequesting: optsRequesting,
optsProviding: optsProviding,
}

log.Printf("%v: acquiring lease", clientID)
Expand Down Expand Up @@ -139,7 +211,17 @@ func (l *DHCPLease) acquire() error {

opts := make(dhcp4.Options)
opts[dhcp4.OptionClientIdentifier] = []byte(l.clientID)
opts[dhcp4.OptionParameterRequestList] = []byte{byte(dhcp4.OptionRouter), byte(dhcp4.OptionSubnetMask)}
opts[dhcp4.OptionParameterRequestList] = []byte{}
for k := range l.optsRequesting {
opts[dhcp4.OptionParameterRequestList] = append(opts[dhcp4.OptionParameterRequestList], byte(k))
}
for k, v := range l.optsProviding {
opts[k] = v
}
// client identifier's first byte is "type"
newClientID := []byte{0}
newClientID = append(newClientID, opts[dhcp4.OptionClientIdentifier]...)
opts[dhcp4.OptionClientIdentifier] = newClientID

pkt, err := backoffRetry(l.resendMax, func() (*dhcp4.Packet, error) {
ok, ack, err := DhcpRequest(c, opts)
Expand Down Expand Up @@ -345,6 +427,7 @@ func jitter(span time.Duration) time.Duration {
func backoffRetry(resendMax time.Duration, f func() (*dhcp4.Packet, error)) (*dhcp4.Packet, error) {
var baseDelay time.Duration = resendDelay0
var sleepTime time.Duration
var fastRetryLimit = 3 // fast retry for 3 times to speed up

for {
pkt, err := f()
Expand All @@ -354,7 +437,12 @@ func backoffRetry(resendMax time.Duration, f func() (*dhcp4.Packet, error)) (*dh

log.Print(err)

sleepTime = baseDelay + jitter(time.Second)
if fastRetryLimit == 0 {
dcbw marked this conversation as resolved.
Show resolved Hide resolved
sleepTime = baseDelay + jitter(time.Second)
} else {
sleepTime = jitter(time.Second)
SilverBut marked this conversation as resolved.
Show resolved Hide resolved
fastRetryLimit--
}

log.Printf("retrying in %f seconds", sleepTime.Seconds())

Expand Down
51 changes: 39 additions & 12 deletions plugins/ipam/dhcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,43 @@ import (

const defaultSocketPath = "/run/cni/dhcp.sock"

// The top-level network config - IPAM plugins are passed the full configuration
// of the calling plugin, not just the IPAM section.
type NetConf struct {
types.NetConf
IPAM *IPAMConfig `json:"ipam"`
}

type IPAMConfig struct {
types.IPAM
DaemonSocketPath string `json:"daemonSocketPath"`
// When requesting IP from DHCP server, carry these options for management purpose.
// Some fields have default values, and can be override by setting a new option with the same name at here.
ProvideOptions []ProvideOption `json:"provide"`
// When requesting IP from DHCP server, claiming these options are necessary. Options are necessary unless `optional`
// is set to `false`.
// To override default requesting fields, set `skipDefault` to `false`.
// If an field is not optional, but the server failed to provide it, error will be raised.
RequestOptions []RequestOption `json:"request"`
}

// DHCPOption represents a DHCP option. It can be a number, or a string defined in manual dhcp-options(5).
// Note that not all DHCP options are supported at all time. Error will be raised if unsupported options are used.
type DHCPOption string

type ProvideOption struct {
Option DHCPOption `json:"option"`

Value string `json:"value"`
ValueFromCNIArg string `json:"fromArg"`
}

type RequestOption struct {
SkipDefault bool `json:"skipDefault"`

Option DHCPOption `json:"option"`
}

func main() {
if len(os.Args) > 1 && os.Args[1] == "daemon" {
var pidfilePath string
Expand All @@ -55,7 +92,7 @@ func main() {
}

if err := runDaemon(pidfilePath, hostPrefix, socketPath, timeout, resendMax, broadcast); err != nil {
log.Printf(err.Error())
log.Print(err.Error())
os.Exit(1)
}
} else {
Expand Down Expand Up @@ -88,8 +125,6 @@ func cmdDel(args *skel.CmdArgs) error {
}

func cmdCheck(args *skel.CmdArgs) error {
// TODO: implement
//return fmt.Errorf("not implemented")
// Plugin must return result in same version as specified in netconf
versionDecoder := &version.ConfigDecoder{}
//confVersion, err := versionDecoder.Decode(args.StdinData)
Expand All @@ -106,16 +141,8 @@ func cmdCheck(args *skel.CmdArgs) error {
return nil
}

type SocketPathConf struct {
DaemonSocketPath string `json:"daemonSocketPath,omitempty"`
}

type TempNetConf struct {
IPAM SocketPathConf `json:"ipam,omitempty"`
}

func getSocketPath(stdinData []byte) (string, error) {
conf := TempNetConf{}
conf := NetConf{}
if err := json.Unmarshal(stdinData, &conf); err != nil {
return "", fmt.Errorf("error parsing socket path conf: %v", err)
}
Expand Down
21 changes: 21 additions & 0 deletions plugins/ipam/dhcp/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,33 @@ import (
"encoding/binary"
"fmt"
"net"
"strconv"
"time"

"github.com/containernetworking/cni/pkg/types"
"github.com/d2g/dhcp4"
)

var optionNameToID = map[string]dhcp4.OptionCode{
"dhcp-client-identifier": dhcp4.OptionClientIdentifier,
"subnet-mask": dhcp4.OptionSubnetMask,
"routers": dhcp4.OptionRouter,
"host-name": dhcp4.OptionHostName,
"user-class": dhcp4.OptionUserClass,
"vendor-class-identifier": dhcp4.OptionVendorClassIdentifier,
}

func parseOptionName(option string) (dhcp4.OptionCode, error) {
if val, ok := optionNameToID[option]; ok {
return val, nil
}
i, err := strconv.ParseUint(option, 10, 8)
if err != nil {
return 0, fmt.Errorf("Can not parse option: %w", err)
}
return dhcp4.OptionCode(i), nil
}

func parseRouter(opts dhcp4.Options) net.IP {
if opts, ok := opts[dhcp4.OptionRouter]; ok {
if len(opts) == 4 {
Expand Down
32 changes: 32 additions & 0 deletions plugins/ipam/dhcp/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package main

import (
"net"
"reflect"
"testing"

"github.com/containernetworking/cni/pkg/types"
Expand Down Expand Up @@ -73,3 +74,34 @@ func TestParseCIDRRoutes(t *testing.T) {

validateRoutes(t, routes)
}

func TestParseOptionName(t *testing.T) {
tests := []struct {
name string
option string
want dhcp4.OptionCode
wantErr bool
}{
{
"hostname", "host-name", dhcp4.OptionHostName, false,
},
{
"hostname in number", "12", dhcp4.OptionHostName, false,
},
{
"random string", "doNotparseMe", 0, true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseOptionName(tt.option)
if (err != nil) != tt.wantErr {
t.Errorf("parseOptionName() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseOptionName() = %v, want %v", got, tt.want)
}
})
}
}