#!/usr/bin/env python3
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author:  Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
#          https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.

# https://github.com/Linuxfabrik/monitoring-plugins/blob/main/CONTRIBUTING.md

"""See the check's README for more details."""

import argparse
import grp
import os
import pwd
import sys

import lib.base
from lib.globals import STATE_OK, STATE_UNKNOWN, STATE_WARN

__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
__version__ = '2026040101'

DESCRIPTION = """Verifies that critical system files have the expected owner and group. Ships with a
built-in list of important files (GRUB, SSH, sudoers, PAM, cron, etc.) and supports
custom entries. Alerts when the actual ownership does not match the expected values."""

# sorted by path
DEFAULT_FILES = [
    'root:root,/boot/grub/grub.cfg',  # GRUB config (Debian/Ubuntu)
    'root:root,/boot/grub/grub.conf',  # GRUB config (legacy)
    'root:root,/boot/grub2/grub.cfg',  # GRUB2 config (RHEL/Fedora)
    'root:root,/boot/grub2/grubenv',  # GRUB2 environment
    'root:root,/boot/grub2/user.cfg',  # GRUB2 user config
    'root:root,/etc/anacrontab',  # anacron job table
    'root:root,/etc/at.allow',  # at access control
    'root:root,/etc/cron.allow',  # cron access control
    'root:root,/etc/cron.d',  # cron drop-in directory
    'root:root,/etc/cron.daily',  # daily cron jobs
    'root:root,/etc/cron.hourly',  # hourly cron jobs
    'root:root,/etc/cron.monthly',  # monthly cron jobs
    'root:root,/etc/cron.weekly',  # weekly cron jobs
    'root:root,/etc/crontab',  # system crontab
    'root:root,/etc/default/grub',  # GRUB boot defaults
    'root:root,/etc/fstab',  # filesystem mount table
    'graylog:graylog,/etc/graylog/certs',  # Graylog TLS certificates
    'root:root,/etc/group',  # group database
    'root:root,/etc/group-',  # group database backup
    #'root:root,/etc/gshadow',                  # different on Debian and Ubuntu
    #'root:root,/etc/gshadow-',                 # different on Debian and Ubuntu
    'root:root,/etc/hosts',  # host resolution
    'root:root,/etc/hosts.allow',  # TCP wrappers allow
    'root:root,/etc/hosts.deny',  # TCP wrappers deny
    'root:root,/etc/issue',  # pre-login banner (local)
    'root:root,/etc/issue.net',  # pre-login banner (remote)
    'root:root,/etc/login.defs',  # login defaults (password aging, UID/GID ranges)
    'root:root,/etc/logrotate.conf',  # log rotation config
    'root:root,/etc/logrotate.d',  # log rotation drop-in directory
    'lool:lool,/etc/loolwsd/loolwsd.xml',  # LibreOffice Online config
    'root:root,/etc/motd',  # message of the day
    'root:named,/etc/named.conf',  # BIND DNS config
    'root:root,/etc/pam.d',  # PAM configuration directory
    'root:root,/etc/passwd',  # user database
    'root:root,/etc/passwd-',  # user database backup
    'root:root,/etc/profile',  # system-wide shell profile
    'root:root,/etc/rsyslog.conf',  # syslog config
    'root:root,/etc/security/access.conf',  # PAM access control
    'root:root,/etc/security/limits.conf',  # PAM resource limits
    #'root:root,/etc/shadow',                   # different on Debian and Ubuntu
    #'root:root,/etc/shadow-',                  # different on SLES (15)
    'root:root,/etc/shells',  # valid login shells
    'root:root,/etc/ssh/ssh_config',  # SSH client config
    'root:root,/etc/ssh/sshd_config',  # SSH server config
    'root:root,/etc/sssd/sssd.conf',  # SSSD config
    'root:root,/etc/sudoers',  # sudo policy
    'root:root,/etc/sudoers.d',  # sudo drop-in directory
    'root:root,/etc/sysctl.conf',  # kernel parameter config
    'root:root,/etc/sysctl.d',  # kernel parameter drop-in directory
    'root:root,/etc/systemd/coredump.conf',  # core dump handling
    'root:root,/etc/systemd/journald.conf',  # journal logging config
    'root:root,/etc/systemd/logind.conf',  # login manager config
    'root:root,/etc/systemd/system.conf',  # systemd system manager config
    'vdsm:kvm,/home/ovirt',  # oVirt host agent
    'root:root,/tmp',  # temporary files
    'icinga:icinga,/tmp/linuxfabrik-monitoring-plugins-sqlite.db',  # monitoring plugins database
    'hnet:hnet,/var/hnet',  # hnet data directory
    'unbound:unbound,/var/lib/unbound/root.key',  # DNS root trust anchor
    'ldap:ldap,/var/run/openldap',  # OpenLDAP runtime directory
]


def parse_args():
    """Parse command line arguments using argparse."""
    parser = argparse.ArgumentParser(description=DESCRIPTION)

    parser.add_argument(
        '-V',
        '--version',
        action='version',
        version=f'%(prog)s: v{__version__} by {__author__}',
    )

    parser.add_argument(
        '--filename',
        help='File to be checked. '
        'Format: `owner:group,path`. '
        'Can be specified multiple times. '
        'User-supplied entries are merged with the default file list. '
        'If the same path appears in both, the user-supplied entry wins. '
        'Example: `--filename root:root,/etc/passwd`.',
        action='append',
        dest='FILES',
        default=None,
    )

    parser.add_argument(
        '--no-default-files',
        help='Only check files specified via `--filename`, skip the built-in default file list.',
        dest='NO_DEFAULT_FILES',
        action='store_true',
        default=False,
    )

    args, _ = parser.parse_known_args()
    return args


def check_file(file_spec):
    """Check ownership of a single file. Returns a dict with the result, or None if the file
    should be skipped.
    """
    if not file_spec:
        return None
    try:
        expected_owner_group, path = file_spec.split(',', 1)
    except ValueError:
        lib.base.cu('--filename parameter seems to be in the wrong format.')
    try:
        expected_owner, expected_group = expected_owner_group.split(':')
    except ValueError:
        lib.base.cu('--filename parameter seems to be in the wrong format.')

    try:
        stat_info = os.stat(path)
    except OSError:
        return None

    try:
        found_owner = pwd.getpwuid(stat_info.st_uid).pw_name
    except KeyError:
        found_owner = str(stat_info.st_uid)
    try:
        found_group = grp.getgrgid(stat_info.st_gid).gr_name
    except KeyError:
        found_group = str(stat_info.st_gid)

    if expected_owner != found_owner or expected_group != found_group:
        file_state = STATE_WARN
    else:
        file_state = STATE_OK

    return {
        'file': path,
        'expected': f'{expected_owner}:{expected_group}',
        'found': f'{found_owner}:{found_group}{lib.base.state2str(file_state, prefix=" ")}',
        'state': file_state,
    }


def main():
    """The main function. This is where the magic happens."""

    # parse the command line
    try:
        args = parse_args()
    except SystemExit:
        sys.exit(STATE_UNKNOWN)

    # build the file list: merge defaults with user-supplied entries (last wins by path)
    if args.NO_DEFAULT_FILES:
        file_list = args.FILES or []
    else:
        files_by_path = {}
        for entry in DEFAULT_FILES:
            path = entry.split(',', 1)[1]
            files_by_path[path] = entry
        for entry in args.FILES or []:
            path = entry.split(',', 1)[1]
            files_by_path[path] = entry
        file_list = list(files_by_path.values())

    state = STATE_OK
    table_values = []

    for file_spec in file_list:
        result = check_file(file_spec)
        if result is None:
            continue
        state = lib.base.get_worst(result.pop('state'), state)
        table_values.append(result)

    # build the message
    if not table_values:
        lib.base.oao('No files checked.', state)

    if state == STATE_OK:
        msg = 'Everything is ok.\n\n'
    else:
        msg = 'One or more problems with owners or groups.\n\n'
    msg += lib.base.get_table(
        table_values,
        ['file', 'expected', 'found'],
        header=['Path', 'Expected', 'Found'],
    )

    # over and out
    lib.base.oao(msg, state)


if __name__ == '__main__':
    try:
        main()
    except Exception:
        lib.base.cu()
