yast/yast-network

View on GitHub
src/lib/cfa/interface_file.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# Copyright (c) [2019] SUSE LLC
#
# All Rights Reserved.
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of version 2 of the GNU General Public License as published
# by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, contact SUSE LLC.
#
# To contact SUSE LLC about this file by physical or electronic mail, you may
# find current contact information at www.suse.com.

require "yast"
require "pathname"
require "y2network/ip_address"
require "y2network/interface_type"

Yast.import "FileUtils"

module CFA
  # This class represents a sysconfig file containing an interface configuration
  #
  # The configuration is defined by a set of variables that are included in the file.
  # Check ifcfg(5) for further information.
  #
  # @example Finding the file for a given interface
  #   file = CFA::InterfaceFile.find("wlan0")
  #   file.wireless_essid #=> "dummy"
  #
  # ## Multivalued variables
  #
  # When dealing with multivalued variables, values are returned in a hash which
  # indexes are the suffixes. For instance:
  #
  #   IPADDR='192.168.122.1/24'
  #   IPADDR_EXTRA='192.168.123.1/24'
  #   IPADDR_ALT='10.0.0.1/8'
  #
  # @example Reading multivalued variables
  #   file = CFA::InterfaceFile.find("wlan0")
  #   file.ipaddrs #=> { default: #<IPAddr: ...>,
  #     "_EXTRA" => #<IPAddr: ...>, "_ALT" => #<IPAddr: ...> }
  class InterfaceFile
    # Auxiliar class to hold variables definition information
    Variable = Struct.new(:name, :type, :collection?)

    # @return [String] Interface name
    class << self
      SYSCONFIG_NETWORK_DIR = Pathname.new("/etc/sysconfig/network").freeze

      # @return [Regex] expression to filter out invalid ifcfg-* files
      IGNORE_IFCFG_REGEX = /(\.bak|\.orig|\.rpmnew|\.rpmorig|-range|~|\.old|\.scpmbackup)$/.freeze

      # Returns all configuration files
      #
      # @return [Array<InterfaceFile>]
      def all
        Yast::SCR.Dir(Yast::Path.new(".network.section"))
          .reject { |f| IGNORE_IFCFG_REGEX =~ f || f == "lo" }
          .map { |f| find(f) }
          .compact
      end

      # Finds the ifcfg-* file for a given interface
      #
      # @param interface [String] Interface name
      # @return [CFA::InterfaceFile,nil] Interface file
      def find(interface)
        file = new(interface)
        file.exist? ? file : nil
      end

      # Defines a parameter
      #
      # This method registers the parameter and adds a pair of methods to get and set its
      # value.
      #
      # @param param_name [Symbol] Parameter name
      # @param type       [Symbol] Parameter type (:string, :integer, :symbol, :ipaddr)
      def define_variable(param_name, type = :string)
        name = variable_name(param_name)
        variables[name] = Variable.new(name, type, false)

        define_method param_name do
          @values[name]
        end

        define_method "#{param_name}=" do |value|
          # The `value` should be an object which responds to #to_s so its value can be written to
          # the ifcfg file.
          @values[name] = value
        end
      end

      # Defines an array parameter
      #
      # This method registers the parameter and adds a pair of methods to get and set its
      # value. In this case, the parameter is an array.
      #
      # @param param_name [Symbol] Parameter name
      # @param type       [Symbol] Array elements type (:string, :integer, :symbol, :ipaddr)
      def define_collection_variable(param_name, type = :string)
        name = variable_name(param_name)
        variables[name] = Variable.new(name, type, true)

        define_method "#{param_name}s" do
          @values[name]
        end

        define_method "#{param_name}s=" do |value|
          @values[name] = value
        end
      end

      # Known configuration variables
      #
      # A variable is defined by using {define_variable} or {define_collection_variable} methods.
      #
      # @return [Array<Symbol>]
      def variables
        @variables ||= {}
      end

    private

      # Parameter name to internal variable name
      #
      # @param param_name [Symbol]
      # @return [String] Convert a parameter name to the expected internal name
      def variable_name(param_name)
        param_name.to_s.upcase
      end
    end

    # @return [String] Interface's name
    attr_reader :interface

    # !@attribute [r] ipaddr
    #   @return [Y2Network::IPAddress] IP address
    define_collection_variable(:ipaddr, :ipaddr)

    # !@attribute [r] name
    #   @return [String] Interface's description (e.g., "Ethernet Card 0")
    define_variable(:name, :string)

    # !@attribute [r] interfacetype
    #   @return [String] Forced Interface's type (e.g., "dummy")
    define_variable(:interfacetype, :string)

    # !@attribute [r] MTU
    #   @return [String] Max Transmission Unit for the interface
    define_variable(:mtu, :string)

    # !@attribute [r] bootproto
    #   return [String] Set up protocol (static, dhcp, dhcp4, dhcp6, autoip, dhcp+autoip,
    #                   auto6, 6to4, none)
    define_variable(:bootproto)

    # !@attribute [r] bootproto
    #   return [String] When the interface should be set up (manual, auto, hotplug, nfsroot, off,
    #     and ifplugd which is not handled by wicked, but by ifplugd daemon and is not mentioned
    #     in man page)
    define_variable(:startmode)

    # !@attribute [r] labels
    #   @return [Hash] Label to assign to the address
    define_collection_variable(:label, :string)

    # !@attribute [r] remote_ipaddrs
    #   @return [Hash] Remote IP address of a point to point connection
    define_collection_variable(:remote_ipaddr, :ipaddr)

    # !@attribute [r] ifplugd_priority
    #   return [Integer] when startmode is set to ifplugd this defines its priority. Not handled
    #   by wicked, but own daemon. Not documented in man page.
    define_variable(:ifplugd_priority, :symbol)

    # !@attribute [r] broadcasts
    #   @return [Hash] Broadcasts addresses
    define_collection_variable(:broadcast, :ipaddr)

    # !@attribute [r] prefixlens
    #   @return [Hash] Prefixes lengths
    define_collection_variable(:prefixlen, :integer)

    # !@attribute [r] netmasks
    #   @return [Hash] Netmasks
    define_collection_variable(:netmask)

    # !@attribute [r] lladdr
    #   @return [String] Link layer address
    define_variable(:lladdr)

    # !@attribute [r] ethtool_options
    #   @return [String] setting variables on device activation. See man ethtool
    define_variable(:ethtool_options)

    # !@attribute [r] zone
    #   @return [String] assign zone to interface. Extensions then can handle it
    define_variable(:zone)

    # !@attribute [r] wireless_key_length
    #   @return [Integer] Length in bits for all keys used
    define_variable(:wireless_key_length, :integer)

    # !@attribute [r] wireless_keys
    #   @return [Array<String>] List of wireless keys
    define_collection_variable(:wireless_key, :string)

    # !@attribute [r] wireless_default_key
    #   @return [Integer] Index of the default key
    #   @see #wireless_keys
    define_variable(:wireless_default_key, :integer)

    # !@attribute [r] wireless_essid
    #   @return [String] Wireless SSID/ESSID
    define_variable(:wireless_essid)

    # !@attribute [r] wireless_auth_mode
    #   @return [Symbol] Wireless authorization mode (:no-encryption, :open, :sharedkey, :psk,
    #     :eap)
    define_variable(:wireless_auth_mode, :symbol)

    # @!attribute [r] wireless_mode
    #  @return [String] Operating mode for the device (managed, ad-hoc or master)
    define_variable(:wireless_mode, :string)

    # @!attribute [r] wireless_wpa_password
    #  @return [String] Password as configured on the RADIUS server (for WPA-EAP)
    define_variable(:wireless_wpa_password)

    # @!attribute [r] wireless_wpa_anonid
    #  @return [String] anonymous identity used for initial tunnel (TTLS)
    define_variable(:wireless_wpa_anonid)

    # @!attribute [r] wireless_wpa_driver
    #   @return [String] Driver to be used by the wpa_supplicant program
    define_variable(:wireless_wpa_driver)

    # @!attribute [r] wireless_wpa_psk
    #   @return [String] WPA preshared key (for WPA-PSK)
    define_variable(:wireless_wpa_psk)

    # @!attribute [r] wireless_wpa_identity
    #   @return [String] WPA identify
    define_variable(:wireless_wpa_identity)

    # @!attribute [r] wireless_ca_cert
    #   @return [String] CA certificate used to sign server certificate
    define_variable(:wireless_ca_cert)

    # @!attribute [r] wireless_client_cert
    #   @return [String] CA certificate used to sign server certificate
    define_variable(:wireless_client_cert)

    # @!attribute [r] wireless_client_key
    #   @return [String] client private key used for encryption in TLS
    define_variable(:wireless_client_key)

    # @!attribute [r] wireless_client_key_password
    #   @return [String] client private key password used for encryption in TLS
    define_variable(:wireless_client_key_password)

    # @!attribute [r] wireless_eap_mode
    #   @return [String] WPA-EAP outer authentication method
    define_variable(:wireless_eap_mode)

    # @!attribute [r] wireless_eap_auth
    #   @return [String] WPA-EAP inner authentication with TLS tunnel method
    define_variable(:wireless_eap_auth)

    # @!attribute [r] wireless_ap_scanmode
    #   @return [Integer] SSID scan mode (0, 1 or 2)
    define_variable(:wireless_ap_scanmode, :integer)

    # @!attribute [r] wireless_ap
    #   @return [String] AP MAC address
    define_variable(:wireless_ap)

    # @!attribute [r] wireless_channel
    #   @return [Integer, nil] Wireless channel or nil for auto selection
    define_variable(:wireless_channel, :integer)

    # @!attribute [r] wireless_nwid
    #   @return [String] Network ID
    define_variable(:wireless_nwid)

    # @!attribute [r] wireless_rate
    #   @return [String] Wireless bit rate specification ( Mb/s)
    define_variable(:wireless_rate, :float)

    ## INFINIBAND

    # @!attribute [r] ipoib_mode
    #   @return [String] IPOIB mode ("connected" or "datagram")
    define_variable(:ipoib_mode)

    ## VLAN

    # !@attribute [r] etherdevice
    #   @return [String] Real device for the virtual LAN
    define_variable(:etherdevice)

    # !@attribute [r] vlan_id
    #   @return [String] VLAN ID
    define_variable(:vlan_id, :integer)

    ## BONDING

    # @!attribute [r] bonding_master
    #   TODO: this name doesn't correspond to inclusive naming, however as long as the
    #   name is used in syscfg file, the name is kept even here to avoid confusion
    #   @return [String] whether the interface is a bond device or not
    define_variable(:bonding_master)

    # @!attribute [r] bonding_slaves
    #   TODO: this name doesn't correspond to inclusive naming, however as long as the
    #   name is used in syscfg file, the name is kept even here to avoid confusion
    #   @return [Hash] Bonding slaves
    define_collection_variable(:bonding_slave)

    # @!attribute [r] bonding_module_opts
    #   @return [String] options for the bonding module ('mode=active-backup
    #                     miimon=100')
    define_variable(:bonding_module_opts)

    ## BRIDGE

    # @!attribute [r] bridge
    #   @return [String] whether the interface is a bridge or not
    define_variable(:bridge)

    # @!attribute [r] bridge_ports
    #   @return [String] interfaces members of the bridge
    define_variable(:bridge_ports)

    # @!attribute [r] bridge_stp
    #   @return [String] Spanning Tree Protocol ("off" or "on")
    define_variable(:bridge_stp)

    # @!attribute [r] bridge_forwarddelay
    #   @return [Integer]
    define_variable(:bridge_forwarddelay, :integer)

    ## TUN / TAP

    # @!attribute [r] tunnel
    #   @return [String] tunnel protocol ("sit", "gre", "ipip", "tun", "tap")
    define_variable(:tunnel)

    # @!attribute [r] tunnel_set_owner
    #   @return [String] tunnel owner
    define_variable(:tunnel_set_owner)

    # @!attribute [r] tunnel_set_group
    #   @return [String] tunnel group
    define_variable(:tunnel_set_group)

    # @!attribute [r] dhclient_set_hostname
    #   @return [String] use hostname from dhcp
    define_variable(:dhclient_set_hostname)

    # Constructor
    #
    # @param interface [String] Interface interface
    def initialize(interface)
      @interface = interface
      @values = collection_variables.each_with_object({}) do |variable, hash|
        hash[variable.name] = {}
      end
    end

    SYSCONFIG_NETWORK_PATH = Pathname.new("/etc").join("sysconfig", "network").freeze

    # Returns the file path
    #
    # @return [Pathname]
    def path
      SYSCONFIG_NETWORK_PATH.join("ifcfg-#{interface}")
    end

    # Loads values from the configuration file
    #
    # @return [Hash<String, Object>] All values from the file
    def load
      @values = self.class.variables.values.each_with_object({}) do |variable, hash|
        meth = variable.collection? ? :fetch_collection : :fetch_scalar
        hash[variable.name] = send(meth, variable.name, variable.type)
      end
    end

    # Writes the changes to the file
    #
    # @note Writes only changed values, keeping the rest as they are.
    def save
      self.class.variables.each_key do |name|
        value = @values[name]
        meth = value.is_a?(Hash) ? :write_collection : :write_scalar
        send(meth, name, value)
      end
      Yast::SCR.Write(Yast::Path.new(".network"), nil)
    end

    # Determines the interface's type
    #
    # @return [Y2Network::InterfaceType] Interface's type depending on the configuration
    #                                    If particular type cannot be recognized, then
    #                                    ETHERNET is returned (same default as in wicked)
    def type
      type_by_key_value ||
        type_by_key_existence ||
        type_from_interfacetype ||
        type_by_name ||
        Y2Network::InterfaceType::ETHERNET
    end

    # Empties all known values
    #
    # This method clears all values from the file. The idea is to use this method
    # to do some clean-up before writing the final values.
    def clean
      @values = self.class.variables.values.each_with_object({}) do |variable, hash|
        if variable.collection?
          clean_collection(variable.name)
          hash[variable.name] = {}
        else
          write_scalar(variable.name, nil)
          hash[variable.name] = nil
        end
      end

      @defined_variables = nil
    end

    # Removes the file
    def remove
      return unless exist?

      Yast::SCR.Execute(Yast::Path.new(".target.remove"), path.to_s)
    end

    # Determines whether the ifcfg-* file exists
    #
    # @return [Boolean] true if the file exists; false otherwise
    def exist?
      Yast::FileUtils.Exists(path.to_s)
    end

  private

    # Detects interface type according to type specific option and its value
    #
    # @return [Y2Network::InterfaceType, nil] particular type if recognized, nil otherwise
    def type_by_key_value
      return Y2Network::InterfaceType::BONDING if bonding_master == "yes"
      return Y2Network::InterfaceType::BRIDGE if bridge == "yes"
      return Y2Network::InterfaceType.from_short_name(tunnel) if tunnel

      # in relation to original implementation ommited ENCAP option which leads to isdn
      # and PPPMODE which leads to ppp. Neither of this type has been handled as
      # "netcard" - see Yast::NetworkInterfaces for details

      nil
    end

    KEY_TO_TYPE = {
      "ETHERDEVICE"   => Y2Network::InterfaceType::VLAN,
      "WIRELESS_MODE" => Y2Network::InterfaceType::WIRELESS,
      "MODEM_DEVICE"  => Y2Network::InterfaceType::PPP
    }.freeze

    # Detects interface type according to type specific option
    #
    # @return [Y2Network::InterfaceType, nil] particular type if recognized, nil otherwise
    def type_by_key_existence
      key = KEY_TO_TYPE.keys.find { |k| defined_variables.include?(k) }
      return KEY_TO_TYPE[key] if key

      nil
    end

    # Detects interface type according to sysconfig's INTERFACETYPE option
    #
    # This option is kind of special that it deserve own method mostly for documenting
    # that it should almost never be used. Only meaningful cases for its usage is
    # dummy device definition and loopback device (when an additional to standard ifcfg-lo
    # is defined)
    #
    # @return [Y2Network::InterfaceType, nil] type according to INTERFACETYPE option
    #                                    value if recognized, nil otherwise
    def type_from_interfacetype
      return Y2Network::InterfaceType.from_short_name(interfacetype) if interfacetype

      nil
    end

    # Distinguishes interface type by its name
    #
    # The only case should be loopback device with special name (in sysconfig) "lo"
    #
    # @return [Y2Network::InterfaceType, nil] InterfaceType::LO or nil if not loopback
    def type_by_name
      Y2Network::InterfaceType::LO if interface == "lo"
      nil
    end

    # Returns a list of those keys that have a value
    #
    # @return [Array<String>] name of keys that are included in the file
    def defined_variables
      return @defined_variables if @defined_variables

      if exist?
        @defined_variables = Yast::SCR.Dir(Yast::Path.new(".network.value.\"#{interface}\""))
      end
      @defined_variables ||= []
    end

    # Fetches the value for a given key
    #
    # @param key [String] Value key
    # @param type [Symbol] Type to convert the value to
    # @return [Object] Value for the given key
    def fetch_scalar(key, type)
      path = Yast::Path.new(".network.value.\"#{interface}\".#{key}")
      value = Yast::SCR.Read(path)&.strip
      send("value_as_#{type}", value)
    end

    # Returns a hash containing all the values for a given key
    #
    # When working with collections, all values are represented in a hash, indexed by the suffix.
    #
    # For instance, this set of IPADDR_* keys:
    #
    #   IPADDR='192.168.122.1'
    #   IPADDR0='192.168.122.2'
    #
    # will be represented like:
    #
    #  { :default => "192.168.122.1", "0" => "192.168.122.2" }
    #
    # @param key [Symbol] Key base name (without the suffix)
    # @param type [Symbol] Type to convert the values to
    # @return [Hash<String, Object>]
    def fetch_collection(key, type)
      collection_keys(key).each_with_object({}) do |k, h|
        index = k.sub(key, "")
        h[index] = fetch_scalar(k, type)
      end
    end

    def collection_keys(key)
      collection_keys = defined_variables.select do |k|
        k == key || k.start_with?(key)
      end
      other_keys = self.class.variables.keys - [key]
      collection_keys - other_keys
    end

    # Converts the value into a string (or nil if empty)
    #
    # @param [String] value
    # @return [String,nil]
    def value_as_string(value)
      (value.nil? || value.empty?) ? nil : value
    end

    # Converts the value into an integer (or nil if empty)
    #
    # @param [String] value
    # @return [Integer,nil]
    def value_as_integer(value)
      (value.nil? || value.empty?) ? nil : value.to_i
    end

    # Converts the value into an float (or nil if empty)
    #
    # @param [String] value
    # @return [Float,nil]
    def value_as_float(value)
      (value.nil? || value.empty?) ? nil : value.to_f
    end

    # Converts the value into a symbol (or nil if empty)
    #
    # @param [String] value
    # @return [Symbol,nil]
    def value_as_symbol(value)
      (value.nil? || value.empty?) ? nil : value.downcase.to_sym
    end

    # Converts the value into a IPAddress (or nil if empty)
    #
    # @param [String] value
    # @return [Y2Network::IPAddress,nil]
    def value_as_ipaddr(value)
      (value.nil? || value.empty?) ? nil : Y2Network::IPAddress.from_string(value)
    end

    # Writes an array as a value for a given key
    #
    # @param key    [String] Key
    # @param values [Array<#to_s>] Values to write
    # @see #clean_collection
    def write_collection(key, values)
      clean_collection(key)
      values.sort_by { |s, _v| (s == :default) ? "" : s.to_s }.each do |suffix, value|
        write_key = (suffix == :default) ? key : "#{key}#{suffix}"
        write_scalar(write_key, value)
      end
    end

    # Cleans all values from a collection
    #
    # @todo There is no way to remove elements from the configuration file so we are setting them
    #   to blank. However, using CFA might be an alternative.
    #
    # @param key [String] Key
    def clean_collection(key)
      collection_keys(key).each { |k| write_scalar(k, nil) }
    end

    # Writes the value for a given key
    #
    # If the value is set to nil, the key will be removed.
    #
    # @param key   [Symbol] Key
    # @param value [#to_s,nil] Value to write
    def write_scalar(key, value)
      raw_value = value ? value.to_s : nil
      path = Yast::Path.new(".network.value.\"#{interface}\".#{key}")
      Yast::SCR.Write(path, raw_value)
    end

    # Returns the variables which are collections
    #
    # @return [Array<Variable>] List of collection variables
    def collection_variables
      self.class.variables.values.select(&:collection?)
    end
  end
end