Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ mkinstalldirs :
mkdir -p "$(DESTDIR)$(libdir)/$(PACKAGE)"
mkdir -p "$(DESTDIR)$(sbindir)"
mkdir -p "$(DESTDIR)$(nirococonfdir)"
mkdir -p --mode=0750 "$(DESTDIR)/var/log/nilrt-snac"


uninstall :
Expand All @@ -148,4 +149,7 @@ uninstall :
rm -vf "$(DESTDIR)/etc/wireguard"/wglv0.*
rm -vf "$(DESTDIR)$(sbindir)/nilrt-snac"
rm -vf "$(DESTDIR)$(nirococonfdir)/x-niroco-static-port.ini"
rm -vf "$(DESTDIR)$(docdir)/$(PACKAGE)/snac.conf.example"
rm -vf "$(DESTDIR)$(docdir)/$(PACKAGE)/snac.conf.example"

# Note: log directory is NOT removed to preserve audit logs
# Admins should manually remove /var/log/nilrt-snac if desired
7 changes: 6 additions & 1 deletion azure-pipeline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ resources:
- repository: DevCentral/ni-central
type: git
name: ni-central
- repository: Tools
type: git
name: DevCentral/Tools
# Use an old version of setup-venv.yml
ref: 1034f8129f933c91c24abbd737e429590e3f23ff

parameters:
- name: rtos_oetest_locked_pr_build
Expand Down Expand Up @@ -77,7 +82,7 @@ stages:
- job: SanityTests
continueOnError: false
steps:
- template: /eng/pipeline/python/templates/setup-venv.yml@DevCentral/ni-central
- template: /eng/pipeline/python/templates/setup-venv.yml@Tools
parameters:
venvName: test_venv
${{ if ne(parameters.rtos_oetest_locked_pr_build, ' ') }}:
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Add support for `/etc/snac/snac.conf` to control which modules are configured
* Add `ClamAV` antivirus verification support (#77)
* When ClamAV packages are installed, `nilrt-snac verify` validates configuration files (`clamd.conf`, `freshclam.conf`) and virus signature databases (`.cvd`, `.cld` files)
* Add logging of all output to `/var/log/nilrt-snac`

## [3.0.0] - 2025-09-18

Expand Down
40 changes: 33 additions & 7 deletions nilrt_snac/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
import logging
import sys
import configparser
from contextlib import nullcontext
from pathlib import Path
from typing import Dict, List, Optional

from nilrt_snac._pre_reqs import verify_prereqs
from nilrt_snac.opkg import opkg_helper
from nilrt_snac._configs import CONFIGS
from nilrt_snac import Errors, logger, SNACError, __version__
from nilrt_snac._logging import logging_context

PROG_NAME = "nilrt-snac"
VERSION_DESCRIPTION = f"""\
Expand Down Expand Up @@ -129,9 +131,19 @@ def _parse_args(argv: List[str]) -> argparse.Namespace:
type=str,
help="Email address for audit actions",
)
configure_parser.add_argument(
"--no-log",
action="store_true",
help="Disable automatic logging to /var/log/nilrt-snac",
)
configure_parser.set_defaults(func=_configure)

verify_parser = subparsers.add_parser("verify", help="Verify SNAC mode configured correctly")
verify_parser.add_argument(
"--log",
action="store_true",
help="Enable logging to /var/log/nilrt-snac (disabled by default for verify)",
)
verify_parser.set_defaults(func=_verify)

debug_group = parser.add_argument_group("Debug")
Expand Down Expand Up @@ -187,13 +199,27 @@ def main( # noqa: D103 - Missing docstring in public function (auto-generated n
logger.error("Command required: {configure, verify}, see --help for more information.")
return Errors.EX_USAGE

try:
if not args.dry_run:
verify_prereqs()
ret_val = args.func(args)
except SNACError as e:
logger.error(e)
return e.return_code
# Determine if logging should be enabled based on command defaults
# configure: logging enabled by default (disable with --no-log)
# verify: logging disabled by default (enable with --log)
if args.cmd == "configure":
enable_logging = not args.no_log
elif args.cmd == "verify":
enable_logging = args.log
else:
enable_logging = False

# Wrap execution in logging context if enabled
cm = logging_context(args.cmd, argv[1:]) if enable_logging else nullcontext(None)
ret_val = Errors.EX_OK
with cm:
try:
if not args.dry_run:
verify_prereqs()
ret_val = args.func(args)
except SNACError as e:
logger.error(e)
ret_val = e.return_code

return ret_val

Expand Down
11 changes: 8 additions & 3 deletions nilrt_snac/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import os
import pathlib
import stat
import subprocess

from nilrt_snac._logging import run_with_logging


def _check_group_ownership(path: str, group: str) -> bool:
Expand All @@ -13,21 +14,25 @@ def _check_group_ownership(path: str, group: str) -> bool:

return group_info.gr_name == group


def _check_owner(path: str, owner: str) -> bool:
"Checks if the owner of a file or directory matches the specified owner."
stat_info = os.stat(path)
uid = stat_info.st_uid
owner_info = grp.getgrgid(uid)
return owner_info.gr_name == owner


def _check_permissions(path: str, expected_mode: int) -> bool:
"Checks if the permissions of a file or directory match the expected mode."
stat_info = os.stat(path)
return stat.S_IMODE(stat_info.st_mode) == expected_mode


def _cmd(*args: str):
"Syntactic sugar for running shell commands."
subprocess.run(args, check=True)
"Syntactic sugar for running shell commands with proper logging."
run_with_logging(*args, check=True)


def get_distro():
try:
Expand Down
84 changes: 40 additions & 44 deletions nilrt_snac/_configs/_auditd_config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import argparse
import grp
import os
import pathlib
import re
import socket
import textwrap
Expand Down Expand Up @@ -77,8 +77,11 @@ class _AuditdConfig(_BaseConfig):
def __init__(self):
super().__init__("auditd")
self._opkg_helper = opkg_helper
self.log_path = os.path.realpath("/var/log")
self.audit_config_path = "/etc/audit/auditd.conf"
self.log_path = pathlib.Path("/var/log")
self.audit_config_path = pathlib.Path("/etc/audit/auditd.conf")
self.audit_email_rule_path = pathlib.Path("/etc/audit/audit_email_alert.pl")
self.audit_email_conf_path = pathlib.Path("/etc/audit/plugins.d/audit_email_alert.conf")
self.init_log_permissions_path = pathlib.Path("/etc/init.d/set_log_permissions.sh")

def configure(self, args: argparse.Namespace) -> None:
print("Configuring auditd...")
Expand Down Expand Up @@ -119,49 +122,43 @@ def configure(self, args: argparse.Namespace) -> None:
self._opkg_helper.install("audispd-plugins")

# Create template audit rule script to send email alerts
audit_rule_script_path = "/etc/audit/audit_email_alert.pl"
if not os.path.exists(audit_rule_script_path):
audit_rule_script = format_email_template_text(audit_email)

with open(audit_rule_script_path, "w") as file:
file.write(audit_rule_script)

# Set the appropriate permissions
_cmd("chmod", "700", audit_rule_script_path)

audit_email_conf_path = "/etc/audit/plugins.d/audit_email_alert.conf"
if not os.path.exists(audit_email_conf_path):
audit_email_rule_file = _ConfigFile(self.audit_email_rule_path)
if not audit_email_rule_file.exists():
audit_email_rule_script = format_email_template_text(audit_email)
audit_email_rule_file.add(audit_email_rule_script)
audit_email_rule_file.chmod(0o700)
audit_email_rule_file.save(dry_run)

audit_email_conf_file = _ConfigFile(self.audit_email_conf_path)
if not audit_email_conf_file.exists():
audit_email_config = textwrap.dedent(
"""\
active = yes
direction = out
path = {audit_rule_script_path}
type = always
"""
).format(audit_rule_script_path=audit_rule_script_path)
).format(audit_rule_script_path=str(self.audit_email_rule_path))

with open(audit_email_conf_path, "w") as file:
file.write(audit_email_config)

# Set the appropriate permissions
audit_email_file = _ConfigFile(audit_email_conf_path)
audit_email_file.chown("root", "sudo")
audit_email_file.chmod(0o600)
audit_email_file.save(dry_run)
audit_email_conf_file.add(audit_email_config)
audit_email_conf_file.chown("root", "sudo")
audit_email_conf_file.chmod(0o600)
audit_email_conf_file.save(dry_run)

# Set the appropriate permissions to allow only root and the 'sudo' group to read/write
auditd_config_file.chown("root", "sudo")
auditd_config_file.chmod(0o660)
auditd_config_file.save(dry_run)

# Enable and start auditd service
if not os.path.exists("/etc/rc2.d/S20auditd"):
_cmd("update-rc.d", "auditd", "defaults")
_cmd("/etc/init.d/auditd", "restart")
if not dry_run:
# Enable and start auditd service
if not pathlib.Path("/etc/rc2.d/S20auditd").exists():
_cmd("update-rc.d", "auditd", "defaults")
_cmd("/etc/init.d/auditd", "restart")

# Set the appropriate permissions to allow only root and the 'adm' group to write/read
init_log_permissions_path = "/etc/init.d/set_log_permissions.sh"
if not os.path.exists(init_log_permissions_path) and not dry_run:
init_log_permissions_file = _ConfigFile(self.init_log_permissions_path)
if not init_log_permissions_file.exists():
init_log_permissions_script = textwrap.dedent(
"""\
#!/bin/sh
Expand All @@ -170,16 +167,15 @@ def configure(self, args: argparse.Namespace) -> None:
setfacl -d -m g:adm:rwx {log_path}
setfacl -d -m o::0 {log_path}
"""
).format(log_path=self.log_path)

with open(init_log_permissions_path, "w") as file:
file.write(init_log_permissions_script)
).format(log_path=str(self.log_path))

# Make the script executable
_cmd("chmod", "700", init_log_permissions_path)
init_log_permissions_file.add(init_log_permissions_script)
init_log_permissions_file.chmod(0o700)
init_log_permissions_file.save(dry_run)

# Schedule the script to run at start
_cmd(*"update-rc.d set_log_permissions.sh start 3 S .".split())
if not dry_run:
# Schedule the script to run at start
_cmd(*"update-rc.d set_log_permissions.sh start 3 S .".split())

def verify(self, args: argparse.Namespace) -> bool:
print("Verifying auditd configuration...")
Expand All @@ -199,24 +195,24 @@ def verify(self, args: argparse.Namespace) -> bool:
logger.error("MISSING: expected action_mail_acct value")

# Check group ownership and permissions of auditd.conf
if not _check_group_ownership(self.audit_config_path, "sudo"):
if not _check_group_ownership(str(self.audit_config_path), "sudo"):
logger.error(f"ERROR: {self.audit_config_path} is not owned by the 'sudo' group.")
valid = False
if not _check_permissions(self.audit_config_path, 0o660):
if not _check_permissions(str(self.audit_config_path), 0o660):
logger.error(f"ERROR: {self.audit_config_path} does not have 660 permissions.")
valid = False
if not _check_owner(self.audit_config_path, "root"):
if not _check_owner(str(self.audit_config_path), "root"):
logger.error(f"ERROR: {self.audit_config_path} is not owned by 'root'.")
valid = False

# Check group ownership and permissions of /var/log
if not _check_group_ownership(self.log_path, "adm"):
if not _check_group_ownership(str(self.log_path), "adm"):
logger.error(f"ERROR: {self.log_path} is not owned by the 'adm' group.")
valid = False
if not _check_permissions(self.log_path, 0o770):
if not _check_permissions(str(self.log_path), 0o770):
logger.error(f"ERROR: {self.log_path} does not have 770 permissions.")
valid = False
if not _check_owner(self.log_path, "root"):
if not _check_owner(str(self.log_path), "root"):
logger.error(f"ERROR: {self.log_path} is not owned by 'root'.")
valid = False

Expand Down
5 changes: 3 additions & 2 deletions nilrt_snac/_configs/_console_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import subprocess

from nilrt_snac._configs._base_config import _BaseConfig
from nilrt_snac._logging import run_with_logging
from nilrt_snac.opkg import opkg_helper as opkg

from nilrt_snac import logger
Expand All @@ -16,8 +17,8 @@ def configure(self, args: argparse.Namespace) -> None:

if not args.dry_run:
logger.debug("Disabling console access...")
subprocess.run(
["nirtcfg", "--set", "section=systemsettings,token=consoleout.enabled,value=False"],
run_with_logging(
"nirtcfg", "--set", "section=systemsettings,token=consoleout.enabled,value=False",
check=True,
)

Expand Down
5 changes: 3 additions & 2 deletions nilrt_snac/_configs/_firewall_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,20 @@
import subprocess

from nilrt_snac._configs._base_config import _BaseConfig
from nilrt_snac._logging import run_with_logging

from nilrt_snac import logger
from nilrt_snac.opkg import opkg_helper


def _cmd(*args: str):
"Syntactic sugar for firewall-cmd -q."
subprocess.run(["firewall-cmd", "-q"] + list(args), check=True)
run_with_logging("firewall-cmd", "-q", *args, check=True)


def _offlinecmd(*args: str):
"Syntactic sugar for firewall-offline-cmd -q."
subprocess.run(["firewall-offline-cmd", "-q"] + list(args), check=True)
run_with_logging("firewall-offline-cmd", "-q", *args, check=True)


def _check_target(policy: str, expected: str = "REJECT") -> bool:
Expand Down
6 changes: 3 additions & 3 deletions nilrt_snac/_configs/_graphical_config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from argparse import Namespace
from subprocess import run

from nilrt_snac._configs._base_config import _BaseConfig
from nilrt_snac._logging import run_with_logging
from nilrt_snac.opkg import opkg_helper as opkg

from nilrt_snac import logger
Expand All @@ -17,8 +17,8 @@ def configure(self, args: Namespace) -> None:
print("Deconfiguring the graphical UI...")
if not args.dry_run:
logger.debug("Disabling the embedded UI...")
run(
["nirtcfg", "--set", "section=systemsettings,token=ui.enabled,value=False"],
run_with_logging(
"nirtcfg", "--set", "section=systemsettings,token=ui.enabled,value=False",
check=True,
)

Expand Down
4 changes: 2 additions & 2 deletions nilrt_snac/_configs/_niauth_config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import argparse
import subprocess

from nilrt_snac._configs._base_config import _BaseConfig
from nilrt_snac._logging import run_with_logging

from nilrt_snac import logger, SNAC_DATA_DIR
from nilrt_snac.opkg import opkg_helper
Expand All @@ -22,7 +22,7 @@ def configure(self, args: argparse.Namespace) -> None:

if not dry_run:
logger.debug("Removing root password")
subprocess.run(["passwd", "-d", "root"], check=True)
run_with_logging("passwd", "-d", "root", check=True)

def verify(self, args: argparse.Namespace) -> bool:
print("Verifying NIAuth...")
Expand Down
Loading
Loading