google_guest_agent/network/manager/dhclient_linux.go (373 lines of code) (raw):
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package manager is responsible for detecting the current network manager service, and
// writing and rolling back appropriate configurations for each network manager service.
package manager
import (
"context"
"fmt"
"net"
"path"
"regexp"
"slices"
"strings"
"time"
"github.com/GoogleCloudPlatform/guest-agent/google_guest_agent/cfg"
"github.com/GoogleCloudPlatform/guest-agent/google_guest_agent/ps"
"github.com/GoogleCloudPlatform/guest-agent/google_guest_agent/run"
"github.com/GoogleCloudPlatform/guest-agent/utils"
"github.com/GoogleCloudPlatform/guest-logging-go/logger"
)
const (
// The base directory for dhclient files managed by guest agent.
// For finer control of the execution, dhclient is invoked for
// each interface individually such that each call will have its
// own PID file. This is where those PID and lease files are
// expected to be written.
defaultBaseDhclientDir = "/run"
)
var (
// ipv4 is a wrapper containing the protocol version and its respective
// dhclient argument.
ipv4 = ipVersion{"ipv4", "-4"}
// ipv6 is a wrapper containing the protocol version and its respective
// dhclient argument.
ipv6 = ipVersion{"ipv6", "-6"}
// baseDhclientDir points to the base directory for DHClient leases and PIDs.
baseDhclientDir = defaultBaseDhclientDir
// vlanIfaceCommonSet is a set of commands to setup common elements of a vlan interface
// it sets link and dev level configurations.
vlanIfaceCommonSet = run.CommandSet{
{
Command: "ip link add link {{.ParentInterface}} name {{.Iface}} type vlan id {{.Vlan}} reorder_hdr off",
Error: "vlan({{.Iface}}): failed to add link",
},
{
Command: "ip link set dev {{.Iface}} address {{.MacAddress}}",
Error: "vlan({{.Iface}}): failed to set itnerface's mac address",
},
{
Command: "ip link set dev {{.Iface}} mtu {{.MTU}}",
Error: "vlan({{.Iface}}): failed to set interface's MTU",
},
{
Command: "ip link set up {{.Iface}}",
Error: "vlan({{.Iface}}): failed to bring interface up",
},
}
// ipAddressSet is a set of commands used to setup the ip address both in the ipv4 and
// ipv6 cases.
ipAddressSet = run.CommandSet{
{
Command: "ip {{.IPVersion.Flag}} addr add dev {{.Iface}} {{.Address}}",
Error: "vlan({{.Iface}}): failed to set ip address {{.Address}}",
},
}
// commonRouteSet is a set of commands used to setup routes both in the ipv4 and ipv6 cases.
commonRouteSet = run.CommandSet{
{
Command: "ip {{.IPVersion.Flag}} route add {{.Gateway}} dev {{.Iface}}",
Error: "vlan({{.Iface}}): failed to add {{.IPVersion.Desc}} route to gateway {{.Gateway}}",
},
}
// ipv4RouteCommand is a set of commands relevant only for setting routes for ipv4 networks.
ipv4RouteCommand = run.CommandSet{
{
Command: "ip route add {{.Address}} via {{.Gateway}}",
Error: "vlan({{.Iface}}): failed to set gateway route",
},
}
// deleteLinkCmd is a command spec dedicated to deleting ethernet links.
deleteLinkCmd = run.CommandSpec{
Command: "ip link delete {{.Iface}}",
Error: "vlan({{.Iface}}): failed to delete link",
}
)
// InterfaceConfig wraps the vlan's link and interface's configuration.
type InterfaceConfig struct {
// Iface is the interface name.
Iface string
// ParentInterface is the name of the vlan's parent interface.
ParentInterface string
// MTU is the vlan's MTU value.
MTU int
// MacAddress is the vlan's Mac Address.
MacAddress string
// Vlan is the vlan's id.
Vlan int
}
// IPConfig wraps the interface's configuration as well as the IP configuration.
type IPConfig struct {
// InterfaceConfig contains the interface's config.
InterfaceConfig
// IPVersion is either ipv4 or ipv6.
IPVersion ipVersion
// Address is the IP address.
Address string
// Gateway is the gateway address (for ipv4 it will be md's gateway entry and for
// ipv6 it will be populated with GatewayIpv6).
Gateway string
}
// ipVersion is a wrapper containing the human-readable version string and
// the respective dhclient argument.
type ipVersion struct {
// Desc is the human-readable IP protocol version.
Desc string
// Flag is the respective argument for DHClient invocation.
Flag string
}
// dhclient implements the manager.Service interface for dhclient use cases.
type dhclient struct{}
// Name returns the name of the network manager service.
func (n *dhclient) Name() string {
return "dhclient"
}
// Configure gives the opportunity for the Service implementation to adjust its configuration
// based on the Guest Agent configuration.
func (n *dhclient) Configure(ctx context.Context, config *cfg.Sections) {
}
// isDhclientInstalled returns true if the dhclient binary/executable is
// installed in the running system.
func (n *dhclient) isDhclientInstalled() (bool, error) {
return cliExists("dhclient")
}
// IsManaging checks if the dhclient CLI is available.
func (n *dhclient) IsManaging(ctx context.Context, iface string) (bool, error) {
return n.isDhclientInstalled()
}
// SetupEthernetInterface sets up the non-primary interfaces with dhclient, having different setup procedures
// for IPv6 network interfaces and IPv4 network interfaces.
func (n *dhclient) SetupEthernetInterface(ctx context.Context, config *cfg.Sections, nics *Interfaces) error {
dhcpCommand := config.NetworkInterfaces.DHCPCommand
if dhcpCommand != "" {
tokens := strings.Split(dhcpCommand, " ")
return run.Quiet(ctx, tokens[0], tokens[1:]...)
}
// Get all interfaces separated by ipv4 and ipv6.
googleInterfaces, googleIpv6Interfaces := interfaceListsIpv4Ipv6(nics.EthernetInterfaces)
obtainIpv4Interfaces, obtainIpv6Interfaces, releaseIpv6Interfaces, err := partitionInterfaces(ctx, googleInterfaces, googleIpv6Interfaces)
if err != nil {
return fmt.Errorf("error partitioning interfaces: %v", err)
}
// Release IPv6 leases.
for _, iface := range releaseIpv6Interfaces {
if err := runDhclient(ctx, ipv6, iface, true); err != nil {
logger.Errorf("failed to run dhclient: %+x", err)
}
}
// Setup IPV4.
for _, iface := range obtainIpv4Interfaces {
if err := runDhclient(ctx, ipv4, iface, false); err != nil {
logger.Errorf("failed to run dhclient: %+x", err)
}
}
if len(obtainIpv6Interfaces) == 0 {
return nil
}
// Wait for tentative IPs to resolve as part of SLAAC for primary network interface.
tentative := []string{"-6", "-o", "a", "s", "dev", googleInterfaces[0], "scope", "link", "tentative"}
for i := 0; i < 5; i++ {
res := run.WithOutput(ctx, "ip", tentative...)
if res.ExitCode == 0 && res.StdOut == "" {
break
}
time.Sleep(1 * time.Second)
}
// Setup IPv6.
for _, iface := range obtainIpv6Interfaces {
// Set appropriate system values.
val := fmt.Sprintf("net.ipv6.conf.%s.accept_ra_rt_info_max_plen=128", iface)
if err := run.Quiet(ctx, "sysctl", val); err != nil {
return err
}
if err := runDhclient(ctx, ipv6, iface, false); err != nil {
logger.Errorf("failed to run dhclient: %+x", err)
}
}
return nil
}
// SetupVlanInterface calls the appropriate native commands to configure a vlan interface.
func (n *dhclient) SetupVlanInterface(ctx context.Context, config *cfg.Sections, nics *Interfaces) error {
logger.Debugf("vlans: %+v", nics.VlanInterfaces)
sysInterfaces, err := net.Interfaces()
if err != nil {
return fmt.Errorf("failed to list systems interfaces: %+v", err)
}
interfaceMap := map[string]net.Interface{}
for _, curr := range sysInterfaces {
interfaceMap[curr.Name] = curr
}
var keepMe []string
for _, curr := range nics.VlanInterfaces {
logger.Debugf("vlan(%d) parent interface: %s", curr.Vlan, curr.ParentInterfaceID)
// For dhclient/native implementation we use a "gcp." prefix to the interface name
// so we can determine it is a guest agent managed vlan interface.
iface := fmt.Sprintf("gcp.%s.%d", curr.ParentInterfaceID, curr.Vlan)
existingIface, found := interfaceMap[iface]
// If the interface already exists and has the same configuration just keep it.
if found && existingIface.HardwareAddr.String() == curr.Mac &&
existingIface.MTU == curr.MTU {
keepMe = append(keepMe, iface)
continue
}
// Generic description of the interface.
ifaceDesc := InterfaceConfig{iface, curr.ParentInterfaceID, curr.MTU, curr.Mac, curr.Vlan}
// If the vlan interface exists but the configuration has changed we recreate it.
if found {
if err := deleteLinkCmd.RunQuiet(ctx, ifaceDesc); err != nil {
return err
}
}
if err := vlanIfaceCommonSet.RunQuiet(ctx, ifaceDesc); err != nil {
return err
}
batches := make(map[any][]run.CommandSet)
if curr.IP != "" {
// ipv4 specific configurations.
ipv4Config := IPConfig{
InterfaceConfig: ifaceDesc,
IPVersion: ipv4,
Address: curr.IP,
Gateway: curr.Gateway,
}
batches[ipv4Config] = []run.CommandSet{ipAddressSet, commonRouteSet, ipv4RouteCommand}
}
for i, ipv6Address := range curr.IPv6 {
// ipv6 specific configurations.
ipv6Config := IPConfig{
InterfaceConfig: ifaceDesc,
IPVersion: ipv6,
Address: ipv6Address,
Gateway: curr.GatewayIPv6,
}
batches[ipv6Config] = []run.CommandSet{ipAddressSet}
if i == 0 {
batches[ipv6Config] = append(batches[ipv6Config], commonRouteSet)
}
}
for data, batch := range batches {
for _, curr := range batch {
if err := curr.RunQuiet(ctx, data); err != nil {
return err
}
}
}
keepMe = append(keepMe, iface)
}
if err := n.removeVlanInterfaces(ctx, keepMe); err != nil {
return fmt.Errorf("failed to remove uninstalled vlan interfaces: %+v", err)
}
return nil
}
func (n *dhclient) removeVlanInterfaces(ctx context.Context, keepMe []string) error {
sysInterfaces, err := net.Interfaces()
if err != nil {
return fmt.Errorf("failed to list systems interfaces: %+v", err)
}
vlanExpStr := `(?P<prefix>gcp).(?P<parent>.*)\.(?P<vlan>[0-9]+)`
vlanExp := regexp.MustCompile(vlanExpStr)
// Remove vlan interfaces that are no longer present/configured.
for _, curr := range sysInterfaces {
iface := curr.Name
// If this is an interface to keep skip it.
if slices.Contains(keepMe, iface) {
continue
}
groups := utils.RegexGroupsMap(vlanExp, iface)
// If it's not a vlan interface skip it.
if _, found := groups["vlan"]; !found {
continue
}
ifaceConfig := InterfaceConfig{
Iface: iface,
}
if err := deleteLinkCmd.RunQuiet(ctx, ifaceConfig); err != nil {
return err
}
}
return nil
}
// pidFilePath gets the expected file path for the PID pertaining to the provided
// interface and IP version.
func pidFilePath(iface string, ipVersion ipVersion) string {
return path.Join(baseDhclientDir, fmt.Sprintf("dhclient.google-guest-agent.%s.%s.pid", iface, ipVersion.Flag))
}
// leaseFilePath gets the expected file path for the leases pertaining to the provided
// interface and IP version.
func leaseFilePath(iface string, ipVersion ipVersion) string {
return path.Join(baseDhclientDir, fmt.Sprintf("dhclient.google-guest-agent.%s.%s.lease", iface, ipVersion.Flag))
}
// runDhclient obtains a lease with the provided IP version for the given
// network interface. If release is set, this will release leases instead.
func runDhclient(ctx context.Context, ipVersion ipVersion, nic string, release bool) error {
pidFile := pidFilePath(nic, ipVersion)
leaseFile := leaseFilePath(nic, ipVersion)
dhclientArgs := []string{ipVersion.Flag, "-pf", pidFile, "-lf", leaseFile}
if release {
// Only release if the flag is set.
releaseArgs := append(dhclientArgs, "-r", nic)
if err := run.Quiet(ctx, "dhclient", releaseArgs...); err != nil {
return fmt.Errorf("error releasing lease for %s: %v", nic, err)
}
} else {
// Now obtain a lease if release is not set.
dhclientArgs = append(dhclientArgs, nic)
if err := run.Quiet(ctx, "dhclient", dhclientArgs...); err != nil {
return fmt.Errorf("error running dhclient for %s: %v", nic, err)
}
}
return nil
}
// paritionInterfaces creates 3 lists of interfaces
// The first list contains interfaces for which to obtain an IPv4 lease.
// The second list contains interfaces for which to obtain an IPv6 lease.
// The third list contains interfaces for which to release their IPv6 lease.
// It will skip primary NIC for IPv4 if process is already running or disabled via config.
// Secondary NICs will be configured as long as there's no already existing dhclient
// process managing it.
func partitionInterfaces(ctx context.Context, interfaces, ipv6Interfaces []string) ([]string, []string, []string, error) {
var obtainIpv4Interfaces []string
var obtainIpv6Interfaces []string
var releaseIpv6Interfaces []string
for i, iface := range interfaces {
if !shouldManageInterface(i == 0) {
// Do not setup anything for this interface to avoid duplicate processes.
logger.Debugf("ManagePrimaryNIC is disabled, skipping dhclient launch for %s", iface)
continue
}
if isInvalid(iface) {
continue
}
// On 18.04 we fallback to dhclient as networkctl is very old and has not reload support for example.
// Default netplan config will take care of it, do not launch dhclient for primary NIC on 18.04.
if (i == 0) && isUbuntu1804() {
logger.Debugf("ManagePrimaryNIC is enabled, but skipping primary nic as its managed by default OS config")
continue
}
// Check for IPv4 interfaces for which to obtain a lease.
processExists, err := dhclientProcessExists(ctx, iface, ipv4)
if err != nil {
return nil, nil, nil, err
}
if !processExists {
obtainIpv4Interfaces = append(obtainIpv4Interfaces, iface)
}
// Check for IPv6 interfaces for which to obtain a lease.
processExists, err = dhclientProcessExists(ctx, iface, ipv6)
if err != nil {
return nil, nil, nil, err
}
if slices.Contains(ipv6Interfaces, iface) && !processExists {
// Obtain a lease and spin up the DHClient process.
obtainIpv6Interfaces = append(obtainIpv6Interfaces, iface)
} else if !slices.Contains(ipv6Interfaces, iface) && processExists {
// Release the lease since the DHClient IPv6 process is running,
// but the interface is no longer IPv6.
releaseIpv6Interfaces = append(releaseIpv6Interfaces, iface)
}
}
return obtainIpv4Interfaces, obtainIpv6Interfaces, releaseIpv6Interfaces, nil
}
// dhclientProcessExists checks if a dhclient process for the provided
// interface and IP version exists.
func dhclientProcessExists(_ context.Context, iface string, ipVersion ipVersion) (bool, error) {
processes, err := ps.Find(".*dhclient.*")
if err != nil {
return false, fmt.Errorf("error finding dhclient process: %v", err)
}
// Check for any dhclient process that contains the iface and IP version provided.
for _, process := range processes {
commandLine := process.CommandLine
containsInterface := slices.Contains(commandLine, iface)
containsProtocolArg := slices.Contains(commandLine, ipVersion.Flag)
if containsInterface {
// IPv4 DHClient calls don't necessarily have the '-4' flag set.
if ipVersion == ipv6 {
return containsProtocolArg, nil
}
if ipVersion == ipv4 && !slices.Contains(commandLine, ipv6.Flag) {
return true, nil
}
}
}
return false, nil
}
// anyDhclientProcessExists returns true if there's at-least one dhclient process
// running for any of the known ethernet interfaces, regarless of Ipv4/Ipv6 stack.
func anyDhclientProcessExists(nics *Interfaces) (bool, error) {
processes, err := ps.Find(".*dhclient.*")
if err != nil {
return false, fmt.Errorf("error finding dhclient process: %v", err)
}
if len(processes) == 0 {
return false, nil
}
interfaces, err := interfaceNames(nics.EthernetInterfaces)
if err != nil {
return false, fmt.Errorf("error getting interface names: %v", err)
}
for _, process := range processes {
commandLine := process.CommandLine
for _, iface := range interfaces {
if slices.Contains(commandLine, iface) {
return true, nil
}
}
}
return false, nil
}
// Rollback releases all leases from DHClient, effectively undoing the dhclient configurations.
func (n *dhclient) Rollback(ctx context.Context, nics *Interfaces) error {
if err := n.RollbackNics(ctx, nics); err != nil {
return fmt.Errorf("failed to rollback ethernet interfaces: %w", err)
}
// This prevents incorrect rollback by dhclient where NICs are managed by netplan.
// VLAN interfaces does not have dhclient process running and IPs are assigned
// directly by running [ipAddressSet] command. Attempt to rollback any VLAN interfaces
// only if network stack is managed by dhclient (at-least one dhclient process for
// known ethernet interfaces). Simple dhclient existence does not prove this its managed by
// dhcliet as in case of Debian-12 we have dhclient but NICs are managed by netplan/networkd.
managed, err := anyDhclientProcessExists(nics)
if err != nil {
return fmt.Errorf("unable to detect if nics are dhclient managed: %w", err)
}
if !managed {
logger.Infof("No dhclient process found for any of the known ethernet interfaces, skipping vlan rollback.")
return nil
}
if err := n.removeVlanInterfaces(ctx, nil); err != nil {
return fmt.Errorf("failed to rollback vlan interfaces: %+v", err)
}
return nil
}
// Rollback releases all leases from DHClient, effectively undoing the dhclient
// configurations - only regular nics are handled.
func (n *dhclient) RollbackNics(ctx context.Context, nics *Interfaces) error {
// Determine if we can even rollback dhclient processes.
if isInstalled, err := n.isDhclientInstalled(); !isInstalled || err != nil {
logger.Debugf("No preconditions met for dhclient roll back, skipping.")
return nil
}
googleInterfaces, googleIpv6Interfaces := interfaceListsIpv4Ipv6(nics.EthernetInterfaces)
// Release all the interface leases from dhclient.
for _, iface := range googleInterfaces {
ipv4Exists, err := dhclientProcessExists(ctx, iface, ipv4)
if err != nil {
return fmt.Errorf("error checking if ipv4 dhclient process for %s exists: %v", iface, err)
}
if ipv4Exists {
if err = runDhclient(ctx, ipv4, iface, true); err != nil {
return err
}
}
}
for _, iface := range googleIpv6Interfaces {
ipv6Exists, err := dhclientProcessExists(ctx, iface, ipv6)
if err != nil {
return fmt.Errorf("error checking if ipv6 dhclient process for %s exists: %v", iface, err)
}
if ipv6Exists {
if err = runDhclient(ctx, ipv6, iface, true); err != nil {
return err
}
}
}
return nil
}