lib/resources/service.rb



# encoding: utf-8
# author: Christoph Hartmann
# author: Dominik Richter
# author: Stephan Renatus
# license: All rights reserved

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.any?
  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'
  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 }
    end

    describe service('service_name').runlevels(3, 5) do
      it { should be_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
    os = inspec.os
    family = os[:family]

    # 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?(family)
      version = inspec.os[:release].to_f
      if version < 15.04
        Upstart.new(inspec, service_ctl)
      else
        Systemd.new(inspec, service_ctl)
      end
    elsif %w{debian}.include?(family)
      version = inspec.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}.include?(family)
      version = inspec.os[:release].to_i
      if (%w{ redhat centos }.include?(family) && version >= 7) || (family == 'fedora' && version >= 15)
        Systemd.new(inspec, service_ctl)
      else
        SysV.new(inspec, service_ctl || '/sbin/service')
      end
    elsif %w{wrlinux}.include?(family)
      SysV.new(inspec, service_ctl)
    elsif %w{darwin}.include?(family)
      LaunchCtl.new(inspec, service_ctl)
    elsif os.windows?
      WindowsSrv.new(inspec)
    elsif %w{freebsd}.include?(family)
      BSDInit.new(inspec, service_ctl)
    elsif %w{arch opensuse}.include?(family)
      Systemd.new(inspec, service_ctl)
    elsif %w{aix}.include?(family)
      SrcMstr.new(inspec)
    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

  # 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

  def to_s
    "Service #{@service_name}"
  end
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 info(service_name)
    cmd = inspec.command("#{service_ctl} show --all #{service_name}")
    return nil if cmd.exit_status.to_i != 0

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

    # LoadState values eg. loaded, not-found
    installed = params['LoadState'] == 'loaded'
    # test via 'systemctl is-active service'
    # SubState values running
    running = params['SubState'] == 'running'
    # test via systemctl --quiet is-enabled
    # ActiveState values eg.g inactive, active
    enabled = params['UnitFileState'] == 'enabled'

    {
      name: params['Id'],
      description: params['Description'],
      installed: installed,
      running: running,
      enabled: enabled,
      type: 'systemd',
    }
  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

  # #rubocop:disable Style/TrailingComma
  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
  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

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

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

  private

  def info_enabled(status, service_name)
    # check if a service is enabled
    # http://upstart.ubuntu.com/cookbook/#determine-if-a-job-is-disabled
    # $ initctl show-config $job | grep -q "^  start on" && echo enabled || echo disabled
    # Ubuntu 10.04 show-config is not supported
    # @see http://manpages.ubuntu.com/manpages/maverick/man8/initctl.8.html
    support_for_show_config = Gem::Version.new('1.3')

    if version >= support_for_show_config
      config = inspec.command("#{service_ctl} show-config #{service_name}").stdout
    else # use config file as fallback
      config = inspec.file("/etc/init/#{service_name}.conf").content
    end

    # disregard if the config does not exist
    return nil if config.nil?
    enabled = !config[/^\s*start on/].nil?

    # implement fallback for Ubuntu 10.04
    if inspec.os[:family] == 'ubuntu' &&
       inspec.os[:release].to_f >= 10.04 &&
       inspec.os[:release].to_f < 12.04 &&
       status.exit_status == 0
      enabled = true
    end

    enabled
  end

  def version
    @version ||= (
      out = inspec.command("#{service_ctl} --version").stdout
      Gem::Version.new(out[/\(upstart ([^\)]+)\)/, 1])
    )
  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 -name S*')
    service_line = %r{rc(?<runlevel>[0-6])\.d/S[^/]*?#{Regexp.escape service_name}$}
    all_services = enabled_services_cmd.stdout.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
  # 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),
      type: 'windows',
    }
  end

  private

  # detect if service is enabled
  def service_enabled?(service)
    !service['WMI'].nil? &&
      !service['WMI']['StartMode'].nil? &&
      service['WMI']['StartMode'] == 'Auto'
  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_re: /^(\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'
  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'
  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'
  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'
  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'
  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'
  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