lib/resources/service.rb



# encoding: utf-8

require 'hashie'
require 'utils/file_reader'

module Inspec::Resources
  class Runlevels < Hash
    attr_accessor :owner

    def self.from_hash(owner, hash = {}, filter = nil)
      res = Runlevels.new(owner)
      filter = filter.first if filter.is_a?(Array) && filter.length <= 1

      ks = case filter
           when nil
             hash.keys
           when Regexp
             hash.keys.find_all { |x| x.to_s =~ filter }
           when Array
             f = filter.map(&:to_s)
             hash.keys.find_all { |x| f.include?(x.to_s) }
           when Numeric
             hash.keys.include?(filter) ? [filter] : []
           else
             hash.keys.find_all { |x| x == filter }
           end

      ks.each { |k| res[k] = hash[k] }
      res
    end

    def initialize(owner, default = false)
      @owner = owner
      super(default)
    end

    def filter(f)
      Runlevels.from_hash(owner, self, f)
    end

    # Check if all runlevels are enabled
    #
    # @return [boolean] true if all runlevels are enabled
    def enabled?
      values.all?
    end

    # Check if all runlevels are disabled
    #
    # @return [boolean] true if all runlevels are disabled
    def disabled?
      values.none?
    end

    def to_s
      "#{owner} runlevels #{keys.join(', ')}"
    end
  end

  # We detect the init system for each operating system, based on the operating
  # system.
  #
  # Fedora 15 : systemd
  # RedHat 7 : systemd
  # Ubuntu 15.04 : systemd
  # Ubuntu < 15.04 : upstart
  #
  # TODO: extend the logic to detect the running init system, independently of OS
  class Service < Inspec.resource(1)
    name 'service'
    supports platform: 'unix'
    supports platform: 'windows'
    desc 'Use the service InSpec audit resource to test if the named service is installed, running and/or enabled.'
    example "
      describe service('service_name') do
        it { should be_installed }
        it { should be_enabled }
        it { should be_running }
        its('type') { should be 'systemd' }
        its ('startmode') { should be 'Auto'}
      end

      describe service('service_name').runlevels(3, 5) do
        it { should be_enabled }
      end

      describe service('service_name').params do
        its('UnitFileState') { should eq 'enabled' }
      end
    "

    attr_reader :service_ctl

    def initialize(service_name, service_ctl = nil)
      @service_name = service_name
      @service_mgmt = nil
      @service_ctl ||= service_ctl
      @cache = nil
      @service_mgmt = select_service_mgmt

      return skip_resource 'The `service` resource is not supported on your OS yet.' if @service_mgmt.nil?
    end

    def select_service_mgmt # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
      os = inspec.os
      platform = os[:name]

      # Ubuntu
      # @see: https://wiki.ubuntu.com/SystemdForUpstartUsers
      # Ubuntu 15.04 : Systemd
      #   Systemd runs with PID 1 as /sbin/init.
      #   Upstart runs with PID 1 as /sbin/upstart.
      # Ubuntu < 15.04 : Upstart
      # Upstart runs with PID 1 as /sbin/init.
      # Systemd runs with PID 1 as /lib/systemd/systemd.
      if %w{ubuntu}.include?(platform)
        version = os[:release].to_f
        if version < 15.04
          Upstart.new(inspec, service_ctl)
        else
          Systemd.new(inspec, service_ctl)
        end
      elsif %w{linuxmint}.include?(platform)
        version = os[:release].to_f
        if version < 18
          Upstart.new(inspec, service_ctl)
        else
          Systemd.new(inspec, service_ctl)
        end
      elsif %w{debian}.include?(platform)
        version = os[:release].to_i
        if version > 7
          Systemd.new(inspec, service_ctl)
        else
          SysV.new(inspec, service_ctl || '/usr/sbin/service')
        end
      elsif %w{redhat fedora centos oracle cloudlinux}.include?(platform)
        version = os[:release].to_i
        if (%w{redhat centos oracle cloudlinux}.include?(platform) && version >= 7) || (platform == 'fedora' && version >= 15)
          Systemd.new(inspec, service_ctl)
        else
          SysV.new(inspec, service_ctl || '/sbin/service')
        end
      elsif %w{wrlinux}.include?(platform)
        SysV.new(inspec, service_ctl)
      elsif %w{mac_os_x}.include?(platform)
        LaunchCtl.new(inspec, service_ctl)
      elsif os.windows?
        WindowsSrv.new(inspec)
      elsif %w{freebsd}.include?(platform)
        BSDInit.new(inspec, service_ctl)
      elsif %w{arch}.include?(platform)
        Systemd.new(inspec, service_ctl)
      elsif %w{coreos}.include?(platform)
        Systemd.new(inspec, service_ctl)
      elsif %w{suse opensuse}.include?(platform)
        if os[:release].to_i >= 12
          Systemd.new(inspec, service_ctl)
        else
          SysV.new(inspec, service_ctl || '/sbin/service')
        end
      elsif %w{aix}.include?(platform)
        SrcMstr.new(inspec)
      elsif %w{amazon}.include?(platform)
        if os[:release] =~ /^20\d\d/
          Upstart.new(inspec, service_ctl)
        else
          Systemd.new(inspec, service_ctl)
        end
      elsif os.solaris?
        Svcs.new(inspec)
      end
    end

    def info
      return nil if @service_mgmt.nil?
      @cache ||= @service_mgmt.info(@service_name)
    end

    # verifies if the service is enabled
    def enabled?(_level = nil)
      return false if info.nil?
      info[:enabled]
    end

    def params
      return {} if info.nil?
      Hashie::Mash.new(info[:params] || {})
    end

    # verifies the service is registered
    def installed?(_name = nil, _version = nil)
      return false if info.nil?
      info[:installed]
    end

    # verifies the service is currently running
    def running?(_under = nil)
      return false if info.nil?
      info[:running]
    end

    # get all runlevels that are available and their configuration
    def runlevels(*args)
      return Runlevels.new(self) if info.nil? or info[:runlevels].nil?
      Runlevels.from_hash(self, info[:runlevels], args)
    end

    # returns the service type from info
    def type
      return nil if info.nil?
      info[:type]
    end

    # returns the service name from info
    def name
      return @service_name if info.nil?
      info[:name]
    end

    # returns the service description from info
    def description
      return nil if info.nil?
      info[:description]
    end

    # returns the service start up mode from info
    def startmode
      return nil if info.nil?
      info[:startmode]
    end

    def to_s
      "Service #{@service_name}"
    end

    private :info
  end

  class ServiceManager
    attr_reader :inspec, :service_ctl
    def initialize(inspec, service_ctl = nil)
      @inspec = inspec
      @service_ctl ||= service_ctl
    end
  end

  # @see: http://www.freedesktop.org/software/systemd/man/systemctl.html
  # @see: http://www.freedesktop.org/software/systemd/man/systemd-system.conf.html
  class Systemd < ServiceManager
    def initialize(inspec, service_ctl = nil)
      @service_ctl = service_ctl || 'systemctl'
      super
    end

    def is_enabled?(service_name)
      result = inspec.command("#{service_ctl} is-enabled #{service_name} --quiet")
      return true if result.exit_status == 0

      # Some systems may not have a `.service` file for a particular service
      # which causes the `systemctl is-enabled` check to fail despite the
      # service being enabled. In that event we fallback to `sysv_service`.
      if result.stderr =~ /Failed to get.*No such file or directory/
        return inspec.sysv_service(service_name).enabled?
      end

      false
    end

    def is_active?(service_name)
      inspec.command("#{service_ctl} is-active #{service_name} --quiet").exit_status == 0
    end

    def info(service_name)
      cmd = inspec.command("#{service_ctl} show --no-pager --all #{service_name}")
      return nil if cmd.exit_status.to_i != 0

      # parse data
      params = SimpleConfig.new(
        cmd.stdout.chomp,
        assignment_regex: /^\s*([^=]*?)\s*=\s*(.*?)\s*$/,
        multiple_values: false,
      ).params

      # LoadState values eg. loaded, not-found
      installed = params['LoadState'] == 'loaded'

      {
        name: params['Id'],
        description: params['Description'],
        installed: installed,
        running: is_active?(service_name),
        enabled: is_enabled?(service_name),
        type: 'systemd',
        params: params,
      }
    end
  end

  # AIX services
  class SrcMstr < ServiceManager
    attr_reader :name

    def info(service_name)
      @name = service_name
      running = status?
      return nil if running.nil?

      {
        name: service_name,
        description: nil,
        installed: true,
        running: running,
        enabled: enabled?,
        type: 'srcmstr',
      }
    end

    private

    def status?
      status_cmd = inspec.command("lssrc -s #{@name}")
      return nil if status_cmd.exit_status.to_i != 0
      status_cmd.stdout.split(/\n/).last.chomp =~ /active$/ ? true : false
    end

    def enabled?
      enabled_rc_tcpip? || enabled_inittab?
    end

    def enabled_rc_tcpip?
      inspec.command(
        "grep -v ^# /etc/rc.tcpip | grep 'start ' | grep -Eq '(/{0,1}| )#{name} '",
      ).exit_status == 0
    end

    def enabled_inittab?
      inspec.command("lsitab #{name}").exit_status == 0
    end
  end

  # @see: http://upstart.ubuntu.com
  class Upstart < ServiceManager
    include FileReader

    def initialize(service_name, service_ctl = nil)
      @service_ctl = service_ctl || 'initctl'
      super
    end

    def info(service_name)
      # get the status of upstart service
      status = inspec.command("#{service_ctl} status #{service_name}")

      # fallback for systemv services, those are not handled via `initctl`
      return SysV.new(inspec).info(service_name) if status.exit_status.to_i != 0 || status.stdout == ''

      # @see: http://upstart.ubuntu.com/cookbook/#job-states
      # grep for running to indicate the service is there
      running = !status.stdout[%r{start/running}].nil?
      enabled = info_enabled(service_name)

      {
        name: service_name,
        description: nil,
        installed: true,
        running: running,
        enabled: enabled,
        type: 'upstart',
      }
    end

    private

    def info_enabled(service_name)
      # check if a service is enabled
      config = read_file_content("/etc/init/#{service_name}.conf", allow_empty: true)

      !config.match(/^\s*start on/).nil?
    end

    def version
      @version ||= begin
        out = inspec.command("#{service_ctl} --version").stdout
        Gem::Version.new(out[/\(upstart ([^\)]+)\)/, 1])
      end
    end
  end

  class SysV < ServiceManager
    RUNLEVELS = { 0=>false, 1=>false, 2=>false, 3=>false, 4=>false, 5=>false, 6=>false }.freeze

    def initialize(service_name, service_ctl = nil)
      @service_ctl = service_ctl || 'service'
      super
    end

    def info(service_name)
      # check if service is installed
      # read all available services via ls /etc/init.d/
      srvlist = inspec.command('ls -1 /etc/init.d/')
      return nil if srvlist.exit_status != 0

      # check if the service is in list
      service = srvlist.stdout.split("\n").select { |srv| srv == service_name }

      # abort if we could not find any service
      return nil if service.empty?

      # read all enabled services from runlevel
      # on rhel via: 'chkconfig --list', is not installed by default
      # bash: for i in `find /etc/rc*.d -name S*`; do basename $i | sed -r 's/^S[0-9]+//'; done | sort | uniq
      enabled_services_cmd = inspec.command('find /etc/rc*.d /etc/init.d/rc*.d -name "S*"').stdout
      service_line = %r{rc(?<runlevel>[0-6])\.d/S[^/]*?#{Regexp.escape service_name}$}
      all_services = enabled_services_cmd.split("\n").map { |line|
        service_line.match(line)
      }.compact
      enabled = !all_services.empty?

      # Determine a list of runlevels which this service is activated for
      runlevels = RUNLEVELS.dup
      all_services.each { |x| runlevels[x[:runlevel].to_i] = true }

      # check if service is really running
      # service throws an exit code if the service is not installed or
      # not enabled

      cmd = inspec.command("#{service_ctl} #{service_name} status")
      running = cmd.exit_status == 0
      {
        name: service_name,
        description: nil,
        installed: true,
        running: running,
        enabled: enabled,
        runlevels: runlevels,
        type: 'sysv',
      }
    end
  end

  # @see: https://www.freebsd.org/doc/en/articles/linux-users/startup.html
  # @see: https://www.freebsd.org/cgi/man.cgi?query=rc.conf&sektion=5
  class BSDInit < ServiceManager
    def initialize(service_name, service_ctl = nil)
      @service_ctl = service_ctl || 'service'
      super
    end

    def info(service_name)
      # check if service is enabled
      # services are enabled in /etc/rc.conf and /etc/defaults/rc.conf
      # via #{service_name}_enable="YES"
      # service SERVICE status returns the following result if not activated:
      #   Cannot 'status' sshd. Set sshd_enable to YES in /etc/rc.conf or use 'onestatus' instead of 'status'.
      # gather all enabled services
      cmd = inspec.command("#{service_ctl} -e")
      return nil if cmd.exit_status != 0

      # search for the service
      srv = /(^.*#{service_name}$)/.match(cmd.stdout)
      return nil if srv.nil? || srv[0].nil?
      enabled = true

      # check if the service is running
      # if the service is not available or not running, we always get an error code
      cmd = inspec.command("#{service_ctl} #{service_name} onestatus")
      running = cmd.exit_status == 0

      {
        name: service_name,
        description: nil,
        installed: true,
        running: running,
        enabled: enabled,
        type: 'bsd-init',
      }
    end
  end

  class Runit < ServiceManager
    def initialize(service_name, service_ctl = nil)
      @service_ctl = service_ctl || 'sv'
      super
    end

    # rubocop:disable Style/DoubleNegation
    def info(service_name)
      # get the status of runit service
      cmd = inspec.command("#{service_ctl} status #{service_name}")
      # return nil unless cmd.exit_status == 0 # NOTE(sr) why do we do this?

      installed = cmd.exit_status == 0
      running = installed && !!(cmd.stdout =~ /^run:/)
      enabled = installed && (running || !!(cmd.stdout =~ /normally up/) || !!(cmd.stdout =~ /want up/))

      {
        name: service_name,
        description: nil,
        installed: installed,
        running: running,
        enabled: enabled,
        type: 'runit',
      }
    end
  end

  # MacOS / Darwin
  # new launctl on macos 10.10
  class LaunchCtl < ServiceManager
    def initialize(service_name, service_ctl = nil)
      @service_ctl = service_ctl || 'launchctl'
      super
    end

    def info(service_name)
      # get the status of upstart service
      cmd = inspec.command("#{service_ctl} list")
      return nil if cmd.exit_status != 0

      # search for the service
      srv = /(^.*#{service_name}.*)/.match(cmd.stdout)
      return nil if srv.nil? || srv[0].nil?

      # extract values from service
      parsed_srv = /^(?<pid>[0-9-]+)\t(?<exit>[0-9]+)\t(?<name>\S*)$/.match(srv[0])
      enabled = !parsed_srv['name'].nil? # it's in the list

      # check if the service is running
      pid = parsed_srv['pid']
      running = pid != '-'

      # extract service label
      srv = parsed_srv['name'] || service_name

      {
        name: srv,
        description: nil,
        installed: true,
        running: running,
        enabled: enabled,
        type: 'darwin',
      }
    end
  end

  # Determine the service state from Windows
  # Uses Powershell to retrieve the information
  class WindowsSrv < ServiceManager
    # Determine service details
    # PS: Get-Service -Name 'dhcp'| Select-Object -Property Name, DisplayName, Status | ConvertTo-Json
    # {
    #     "Name":  "dhcp",
    #     "DisplayName":  "DHCP Client",
    #     "Status":  4
    # }
    #
    # Until StartMode is not added to Get-Service, we need to do a workaround
    # @see: https://connect.microsoft.com/PowerShell/feedback/details/424948/i-would-like-to-see-the-property-starttype-added-to-get-services
    # Also see: https://msdn.microsoft.com/en-us/library/aa384896(v=vs.85).aspx
    # Use the following powershell to determine the start mode
    # PS: Get-WmiObject -Class Win32_Service | Where-Object {$_.Name -eq $name -or $_.DisplayName -eq $name} | Select-Object -Prop
    # erty Name, StartMode, State, Status | ConvertTo-Json
    # {
    #     "Name":  "Dhcp",
    #     "StartMode":  "Auto",
    #     "State":  "Running",
    #     "Status":  "OK"
    # }
    #
    # Windows Services have the following status code:
    # @see: https://msdn.microsoft.com/en-us/library/windows/desktop/ms685996(v=vs.85).aspx
    # - 1: Stopped
    # - 2: Starting
    # - 3: Stopping
    # - 4: Running
    # - 5: Continue Pending
    # - 6: Pause Pending
    # - 7: Paused
    def info(service_name)
      cmd = inspec.command("New-Object -Type PSObject | Add-Member -MemberType NoteProperty -Name Service -Value (Get-Service -Name '#{service_name}'| Select-Object -Property Name, DisplayName, Status) -PassThru | Add-Member -MemberType NoteProperty -Name WMI -Value (Get-WmiObject -Class Win32_Service | Where-Object {$_.Name -eq '#{service_name}' -or $_.DisplayName -eq '#{service_name}'} | Select-Object -Property StartMode) -PassThru | ConvertTo-Json")

      # cannot rely on exit code for now, successful command returns exit code 1
      # return nil if cmd.exit_status != 0
      # try to parse json
      begin
        service = JSON.parse(cmd.stdout)
      rescue JSON::ParserError => _e
        return nil
      end

      # check that we got a response
      return nil if service.nil? || service['Service'].nil?

      {
        name: service['Service']['Name'],
        description: service['Service']['DisplayName'],
        installed: true,
        running: service_running?(service),
        enabled: service_enabled?(service),
        startmode: service['WMI']['StartMode'],
        type: 'windows',
      }
    end

    private

    # detect if service is enabled
    def service_enabled?(service)
      !service['WMI'].nil? &&
        !service['WMI']['StartMode'].nil? &&
        (service['WMI']['StartMode'] == 'Auto' ||
        service['WMI']['StartMode'] == 'Manual')
    end

    # detect if service is running
    def service_running?(service)
      !service['Service']['Status'].nil? && service['Service']['Status'] == 4
    end
  end

  # Solaris services
  class Svcs < ServiceManager
    def initialize(service_name, service_ctl = nil)
      @service_ctl = service_ctl || 'svcs'
      super
    end

    def info(service_name)
      # get the status of runit service
      cmd = inspec.command("#{service_ctl} -l #{service_name}")
      return nil if cmd.exit_status != 0

      params = SimpleConfig.new(
        cmd.stdout.chomp,
        assignment_regex: /^(\w+)\s*(.*)$/,
        multiple_values: false,
      ).params

      installed = cmd.exit_status == 0
      running = installed && (params['state'] == 'online')
      enabled = installed && (params['enabled'] == 'true')

      {
        name: service_name,
        description: params['name'],
        installed: installed,
        running: running,
        enabled: enabled,
        type: 'svcs',
      }
    end
  end

  # specific resources for specific service managers

  class SystemdService < Service
    name 'systemd_service'
    supports platform: 'unix'
    desc 'Use the systemd_service InSpec audit resource to test if the named service (controlled by systemd) is installed, running and/or enabled.'
    example "
      # to override service mgmt auto-detection
      describe systemd_service('service_name') do
        it { should be_installed }
        it { should be_enabled }
        it { should be_running }
      end

      # to set a non-standard systemctl path
      describe systemd_service('service_name', '/path/to/systemctl') do
        it { should be_running }
      end
    "

    def select_service_mgmt
      Systemd.new(inspec, service_ctl)
    end
  end

  class UpstartService < Service
    name 'upstart_service'
    supports platform: 'unix'
    desc 'Use the upstart_service InSpec audit resource to test if the named service (controlled by upstart) is installed, running and/or enabled.'
    example "
      # to override service mgmt auto-detection
      describe upstart_service('service_name') do
        it { should be_installed }
        it { should be_enabled }
        it { should be_running }
      end

      # to set a non-standard initctl path
      describe upstart_service('service_name', '/path/to/initctl') do
        it { should be_running }
      end
    "

    def select_service_mgmt
      Upstart.new(inspec, service_ctl)
    end
  end

  class SysVService < Service
    name 'sysv_service'
    supports platform: 'unix'
    desc 'Use the sysv_service InSpec audit resource to test if the named service (controlled by SysV) is installed, running and/or enabled.'
    example "
      # to override service mgmt auto-detection
      describe sysv_service('service_name') do
        it { should be_installed }
        it { should be_enabled }
        it { should be_running }
      end

      # to set a non-standard service path
      describe sysv_service('service_name', '/path/to/service') do
        it { should be_running }
      end
    "

    def select_service_mgmt
      SysV.new(inspec, service_ctl)
    end
  end

  class BSDService < Service
    name 'bsd_service'
    supports platform: 'unix'
    desc 'Use the bsd_service InSpec audit resource to test if the named service (controlled by BSD init) is installed, running and/or enabled.'
    example "
      # to override service mgmt auto-detection
      describe bsd_service('service_name') do
        it { should be_installed }
        it { should be_enabled }
        it { should be_running }
      end

      # to set a non-standard service path
      describe bsd_service('service_name', '/path/to/service') do
        it { should be_running }
      end
    "

    def select_service_mgmt
      BSDInit.new(inspec, service_ctl)
    end
  end

  class LaunchdService < Service
    name 'launchd_service'
    supports platform: 'unix'
    desc 'Use the launchd_service InSpec audit resource to test if the named service (controlled by launchd) is installed, running and/or enabled.'
    example "
      # to override service mgmt auto-detection
      describe launchd_service('service_name') do
        it { should be_installed }
        it { should be_enabled }
        it { should be_running }
      end

      # to set a non-standard launchctl path
      describe launchd_service('service_name', '/path/to/launchctl') do
        it { should be_running }
      end
    "

    def select_service_mgmt
      LaunchCtl.new(inspec, service_ctl)
    end
  end

  class RunitService < Service
    name 'runit_service'
    supports platform: 'unix'
    desc 'Use the runit_service InSpec audit resource to test if the named service (controlled by runit) is installed, running and/or enabled.'
    example "
      # to override service mgmt auto-detection
      describe runit_service('service_name') do
        it { should be_installed }
        it { should be_enabled }
        it { should be_running }
      end

      # to set a non-standard sv path
      describe runit_service('service_name', '/path/to/sv') do
        it { should be_running }
      end
    "

    def select_service_mgmt
      Runit.new(inspec, service_ctl)
    end
  end
end