Add sysctl detection

This commit is contained in:
Miguel Jacq 2026-06-16 14:23:44 +10:00
parent 3c19ae54b2
commit 9546e1b8ed
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
11 changed files with 544 additions and 2 deletions

View file

@ -303,6 +303,12 @@ def _iter_managed_files(state: Dict[str, Any]) -> Iterable[Tuple[str, Dict[str,
for mf in ac.get("managed_files", []) or []:
yield str(ac_role), mf
# sysctl
sc = _roles(state).get("sysctl") or {}
sc_role = sc.get("role_name") or "sysctl"
for mf in sc.get("managed_files", []) or []:
yield str(sc_role), mf
# etc_custom
ec = _roles(state).get("etc_custom") or {}
ec_role = ec.get("role_name") or "etc_custom"

View file

@ -383,6 +383,7 @@ def explain_state(
for rname in [
"apt_config",
"dnf_config",
"sysctl",
"etc_custom",
"usr_local_custom",
"extra_paths",
@ -435,6 +436,7 @@ def explain_state(
for rname in [
"apt_config",
"dnf_config",
"sysctl",
"etc_custom",
"usr_local_custom",
"extra_paths",

View file

@ -181,6 +181,14 @@ class FirewallRuntimeSnapshot:
notes: List[str] = field(default_factory=list)
@dataclass
class SysctlSnapshot:
role_name: str
managed_files: List[ManagedFile] = field(default_factory=list)
parameters: Dict[str, str] = field(default_factory=dict)
notes: List[str] = field(default_factory=list)
ALLOWED_UNOWNED_EXTS = {
".cfg",
".cnf",
@ -1049,6 +1057,7 @@ _FIREWALL_CAPTURE_COMMANDS: Dict[str, Tuple[str, ...]] = {
"ipset_save": ("ipset", "save"),
"iptables_v4_save": ("iptables-save",),
"iptables_v6_save": ("ip6tables-save",),
"sysctl_all": ("sysctl", "-a"),
}
@ -1104,6 +1113,165 @@ def _write_generated_artifact(
f.write(content)
_SYSCTL_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
_SYSCTL_GENERATED_DEST = "/etc/sysctl.d/99-enroll.conf"
_SYSCTL_GENERATED_SRC_REL = "sysctl/99-enroll.conf"
# Writable-looking action/identity keys that are poor candidates for persistent
# config. This avoids generating a file that tries to replay one-shot triggers or
# host identity that should be managed elsewhere (e.g. /etc/hostname).
_SYSCTL_VOLATILE_KEYS = {
"kernel.domainname",
"kernel.hostname",
"kernel.ns_last_pid",
"net.ipv4.route.flush",
"net.ipv6.route.flush",
"vm.compact_memory",
"vm.drop_caches",
"vm.stat_refresh",
}
def _sysctl_proc_path(key: str) -> str:
return "/proc/sys/" + key.replace(".", "/")
def _sysctl_key_is_persistable(key: str) -> tuple[bool, str]:
if not key or not _SYSCTL_KEY_RE.fullmatch(key):
return False, "invalid key"
if key in _SYSCTL_VOLATILE_KEYS:
return False, "volatile/action key"
proc_path = _sysctl_proc_path(key)
try:
st = os.stat(proc_path)
except OSError:
return False, "no /proc/sys entry"
if not stat.S_ISREG(st.st_mode):
return False, "not a regular /proc/sys entry"
if (stat.S_IMODE(st.st_mode) & 0o222) == 0:
return False, "read-only /proc/sys entry"
return True, ""
def _parse_sysctl_a_output(
text: str,
*,
require_persistable: bool = True,
) -> tuple[Dict[str, str], Dict[str, int]]:
"""Parse `sysctl -a` output into persistable key/value pairs.
`sysctl -a` includes read-only, write-only, multiline, action-like, and
host-identity values. Persisting those can create noisy or failing Ansible
runs, so the default parser keeps only single-line writable-looking keys.
"""
out: Dict[str, str] = {}
skipped: Dict[str, int] = {
"malformed": 0,
"empty_value": 0,
"non_persistable": 0,
"duplicate": 0,
}
for raw in (text or "").splitlines():
line = raw.strip()
if not line:
continue
if " = " in line:
key, value = line.split(" = ", 1)
elif "=" in line:
key, value = line.split("=", 1)
else:
skipped["malformed"] += 1
continue
key = key.strip()
value = value.strip()
if not key:
skipped["malformed"] += 1
continue
if value == "":
skipped["empty_value"] += 1
continue
if key in out:
skipped["duplicate"] += 1
continue
if require_persistable:
ok, _reason = _sysctl_key_is_persistable(key)
if not ok:
skipped["non_persistable"] += 1
continue
out[key] = value
return dict(sorted(out.items())), skipped
def _render_sysctl_conf(parameters: Dict[str, str], notes: List[str]) -> str:
lines = [
"# Generated by Enroll from live sysctl state.",
"# Review before applying broadly; runtime sysctl state can be host/kernel-specific.",
]
for note in notes:
lines.append(f"# {note}")
lines.append("")
for key, value in sorted((parameters or {}).items()):
safe_value = str(value).replace("\n", " ").strip()
lines.append(f"{key} = {safe_value}")
lines.append("")
return "\n".join(lines)
def _collect_sysctl_snapshot(bundle_dir: str) -> SysctlSnapshot:
role_name = "sysctl"
notes: List[str] = []
managed_files: List[ManagedFile] = []
out, err = _run_capture_command("sysctl_all", timeout=20)
if err:
notes.append(err)
return SysctlSnapshot(role_name=role_name, notes=notes)
parameters, skipped = _parse_sysctl_a_output(out or "")
if not parameters:
notes.append("No persistable live sysctl parameters were detected.")
return SysctlSnapshot(role_name=role_name, parameters=parameters, notes=notes)
notes.append(f"Captured {len(parameters)} live writable sysctl parameter(s).")
skipped_total = sum(skipped.values())
if skipped_total:
details = ", ".join(f"{k}={v}" for k, v in sorted(skipped.items()) if v)
notes.append(
"Skipped "
f"{skipped_total} sysctl entr{'y' if skipped_total == 1 else 'ies'} "
f"that were not suitable for persistence ({details})."
)
_write_generated_artifact(
bundle_dir,
role_name,
_SYSCTL_GENERATED_SRC_REL,
_render_sysctl_conf(parameters, notes),
)
managed_files.append(
ManagedFile(
path=_SYSCTL_GENERATED_DEST,
src_rel=_SYSCTL_GENERATED_SRC_REL,
owner="root",
group="root",
mode="0644",
reason="system_sysctl",
)
)
return SysctlSnapshot(
role_name=role_name,
managed_files=managed_files,
parameters=parameters,
notes=notes,
)
def _ipset_save_has_state(text: str) -> bool:
for raw in (text or "").splitlines():
line = raw.strip()
@ -1300,13 +1468,20 @@ def harvest(
_PERSISTENT_IPTABLES_V6_GLOBS
)
if hasattr(os, "geteuid") and os.geteuid() != 0:
running_as_root = not hasattr(os, "geteuid") or os.geteuid() == 0
if not running_as_root:
firewall_runtime_snapshot = FirewallRuntimeSnapshot(
role_name="firewall_runtime",
notes=[
"Live ipset/iptables runtime capture skipped because harvest is not running as root."
],
)
sysctl_snapshot = SysctlSnapshot(
role_name="sysctl",
notes=[
"Live sysctl runtime capture skipped because harvest is not running as root."
],
)
else:
firewall_runtime_snapshot = _collect_firewall_runtime_snapshot(
bundle_dir,
@ -1314,6 +1489,12 @@ def harvest(
persistent_iptables_v4_files=persistent_iptables_v4_files,
persistent_iptables_v6_files=persistent_iptables_v6_files,
)
sysctl_snapshot = _collect_sysctl_snapshot(bundle_dir)
# The generated sysctl role owns /etc/sysctl.d/99-enroll.conf; do not also
# capture an existing file at that path into etc_custom/package roles.
for mf in sysctl_snapshot.managed_files:
captured_global.add(mf.path)
def _pick_installed(cands: List[str]) -> Optional[str]:
for c in cands:
@ -2682,6 +2863,7 @@ def harvest(
"apt_config": asdict(apt_config_snapshot),
"dnf_config": asdict(dnf_config_snapshot),
"firewall_runtime": asdict(firewall_runtime_snapshot),
"sysctl": asdict(sysctl_snapshot),
"etc_custom": asdict(etc_custom_snapshot),
"usr_local_custom": asdict(usr_local_custom_snapshot),
"extra_paths": asdict(extra_paths_snapshot),

View file

@ -728,6 +728,7 @@ def _render_grouped_systemd_tasks(var_prefix: str) -> str:
return f"""- name: Probe whether grouped systemd units exist and are manageable
ansible.builtin.systemd:
name: "{{{{ item.name }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
check_mode: true
loop: "{{{{ {var_prefix}_systemd_units | default([]) }}}}"
register: _enroll_unit_probes
@ -739,6 +740,7 @@ def _render_grouped_systemd_tasks(var_prefix: str) -> str:
ansible.builtin.systemd:
name: "{{{{ item.item.name }}}}"
enabled: "{{{{ item.item.enabled | bool }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
loop: "{{{{ _enroll_unit_probes.results | default([]) }}}}"
when:
- item.item.manage | default(false)
@ -748,6 +750,7 @@ def _render_grouped_systemd_tasks(var_prefix: str) -> str:
ansible.builtin.systemd:
name: "{{{{ item.item.name }}}}"
state: "{{{{ item.item.state }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
loop: "{{{{ _enroll_unit_probes.results | default([]) }}}}"
when:
- item.item.manage | default(false)
@ -755,6 +758,50 @@ def _render_grouped_systemd_tasks(var_prefix: str) -> str:
"""
def _render_sysctl_tasks(var_prefix: str) -> str:
return f"""- name: Ensure sysctl.d exists
ansible.builtin.file:
path: /etc/sysctl.d
state: directory
owner: root
group: root
mode: "0755"
- name: Deploy captured sysctl configuration
vars:
_enroll_ff:
files:
- "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ {var_prefix}_conf_src_rel }}}}"
- "{{{{ role_path }}}}/files/{{{{ {var_prefix}_conf_src_rel }}}}"
ansible.builtin.copy:
src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
dest: /etc/sysctl.d/99-enroll.conf
owner: root
group: root
mode: "0644"
when: ({var_prefix}_conf_src_rel | default('') | length) > 0
notify: Apply captured sysctl configuration
"""
def _render_sysctl_handlers(var_prefix: str) -> str:
return f"""---
- name: Apply captured sysctl configuration
ansible.builtin.command:
argv:
- sysctl
- -e
- -p
- /etc/sysctl.d/99-enroll.conf
register: _enroll_sysctl_apply
changed_when: false
failed_when:
- not ({var_prefix}_ignore_apply_errors | default(true) | bool)
- _enroll_sysctl_apply.rc != 0
when: {var_prefix}_apply | default(true) | bool
"""
def _render_firewall_runtime_tasks(var_prefix: str) -> str:
"""Render tasks for live ipset/iptables snapshots."""
return f"""- name: Ensure firewall runtime snapshot directory exists
@ -1017,6 +1064,7 @@ def _manifest_from_bundle_dir(
apt_config_snapshot: Dict[str, Any] = roles.get("apt_config", {})
dnf_config_snapshot: Dict[str, Any] = roles.get("dnf_config", {})
firewall_runtime_snapshot: Dict[str, Any] = roles.get("firewall_runtime", {})
sysctl_snapshot: Dict[str, Any] = roles.get("sysctl", {})
etc_custom_snapshot: Dict[str, Any] = roles.get("etc_custom", {})
usr_local_custom_snapshot: Dict[str, Any] = roles.get("usr_local_custom", {})
extra_paths_snapshot: Dict[str, Any] = roles.get("extra_paths", {})
@ -1058,6 +1106,7 @@ def _manifest_from_bundle_dir(
manifested_apt_config_roles: List[str] = []
manifested_dnf_config_roles: List[str] = []
manifested_firewall_runtime_roles: List[str] = []
manifested_sysctl_roles: List[str] = []
manifested_etc_custom_roles: List[str] = []
manifested_usr_local_custom_roles: List[str] = []
manifested_extra_paths_roles: List[str] = []
@ -2041,6 +2090,104 @@ DNF/YUM configuration harvested from the system (repos, config files, and RPM GP
manifested_dnf_config_roles.append(role)
# -------------------------
# sysctl role (live writable sysctl state)
# -------------------------
if sysctl_snapshot and (sysctl_snapshot.get("managed_files") or []):
role = sysctl_snapshot.get("role_name", "sysctl")
role_dir = os.path.join(roles_root, role)
_write_role_scaffold(role_dir)
var_prefix = role
managed_files = sysctl_snapshot.get("managed_files", []) or []
conf_src_rel = ""
for mf in managed_files:
if mf.get("path") == "/etc/sysctl.d/99-enroll.conf":
conf_src_rel = mf.get("src_rel") or ""
break
if not conf_src_rel and managed_files:
conf_src_rel = managed_files[0].get("src_rel") or ""
parameters = sysctl_snapshot.get("parameters", {}) or {}
notes = sysctl_snapshot.get("notes", []) or []
# Generated sysctl snapshots are host-specific in site mode.
if site_mode:
_copy_artifacts(
bundle_dir,
role,
_host_role_files_dir(out_dir, fqdn or "", role),
)
else:
_copy_artifacts(bundle_dir, role, os.path.join(role_dir, "files"))
vars_map: Dict[str, Any] = {
f"{var_prefix}_conf_src_rel": conf_src_rel,
f"{var_prefix}_apply": True,
f"{var_prefix}_ignore_apply_errors": True,
}
if site_mode:
_write_role_defaults(
role_dir,
{
f"{var_prefix}_conf_src_rel": "",
f"{var_prefix}_apply": True,
f"{var_prefix}_ignore_apply_errors": True,
},
)
_write_hostvars(out_dir, fqdn or "", role, vars_map)
else:
_write_role_defaults(role_dir, vars_map)
tasks = "---\n" + _render_sysctl_tasks(var_prefix)
with open(
os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
) as f:
f.write(tasks.rstrip() + "\n")
handlers_dir = os.path.join(role_dir, "handlers")
os.makedirs(handlers_dir, exist_ok=True)
with open(os.path.join(handlers_dir, "main.yml"), "w", encoding="utf-8") as f:
f.write(_render_sysctl_handlers(var_prefix))
with open(
os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
) as f:
f.write("---\ndependencies: []\n")
param_count = len(parameters) if isinstance(parameters, dict) else 0
sample_params = []
if isinstance(parameters, dict):
sample_params = sorted(parameters.keys())[:25]
readme = f"""# {role}
Generated from live writable sysctl state captured during harvest.
This role deploys the captured values to `/etc/sysctl.d/99-enroll.conf`. The generated file intentionally contains writable, single-line sysctl values only; read-only, multiline, action-like, and host-identity keys are skipped to avoid creating a noisy or brittle boot-time configuration.
## Captured parameters
Captured parameter count: {param_count}
{os.linesep.join("- " + x for x in sample_params) or "- (none)"}
{"- ..." if param_count > len(sample_params) else ""}
## Notes
{os.linesep.join("- " + n for n in notes) or "- (none)"}
## Safety notes
- `sysctl_apply` defaults to `true` and applies `/etc/sysctl.d/99-enroll.conf` when the file changes.
- `sysctl_ignore_apply_errors` defaults to `true`, because sysctl availability and writability can vary across kernels, containers, and hardware.
- Review this role before applying it broadly across unlike hosts.
"""
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
manifested_sysctl_roles.append(role)
# -------------------------
# firewall_runtime role (live ipset/iptables kernel state)
# -------------------------
@ -2217,6 +2364,7 @@ This role restores live ipset and iptables state only for firewall families wher
- name: Run systemd daemon-reload
ansible.builtin.systemd:
daemon_reload: true
no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
"""
with open(
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
@ -2586,6 +2734,7 @@ User-requested extra file harvesting.
- name: Run systemd daemon-reload
ansible.builtin.systemd:
daemon_reload: true
no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
- name: Restart service
ansible.builtin.service:
@ -2611,6 +2760,7 @@ User-requested extra file harvesting.
f"""- name: Probe whether systemd unit exists and is manageable
ansible.builtin.systemd:
name: "{{{{ {var_prefix}_unit_name }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
check_mode: true
register: _unit_probe
failed_when: false
@ -2621,6 +2771,7 @@ User-requested extra file harvesting.
ansible.builtin.systemd:
name: "{{{{ {var_prefix}_unit_name }}}}"
enabled: "{{{{ {var_prefix}_systemd_enabled | bool }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
when:
- {var_prefix}_manage_unit | default(false)
- _unit_probe is succeeded
@ -2629,6 +2780,7 @@ User-requested extra file harvesting.
ansible.builtin.systemd:
name: "{{{{ {var_prefix}_unit_name }}}}"
state: "{{{{ {var_prefix}_systemd_state }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
when:
- {var_prefix}_manage_unit | default(false)
- _unit_probe is succeeded
@ -2700,6 +2852,7 @@ Generated from `{unit}`.
+ manifested_snap_roles
+ manifested_service_roles
+ manifested_firewall_runtime_roles
+ manifested_sysctl_roles
+ manifested_etc_custom_roles
+ manifested_usr_local_custom_roles
+ manifested_extra_paths_roles
@ -2854,6 +3007,7 @@ Generated from `{unit}`.
- name: Run systemd daemon-reload
ansible.builtin.systemd:
daemon_reload: true
no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
- name: Restart managed services
ansible.builtin.service:
@ -3000,6 +3154,7 @@ Common role for package section/group `{section_label}`.
- name: Run systemd daemon-reload
ansible.builtin.systemd:
daemon_reload: true
no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
"""
with open(
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
@ -3072,6 +3227,7 @@ Generated for package `{pkg}`.
+ manifested_snap_roles
+ manifested_users_roles
+ tail_roles
+ manifested_sysctl_roles
+ manifested_firewall_runtime_roles
)

View file

@ -7,6 +7,7 @@ RESERVED_SINGLETON_ROLE_NAMES = {
"apt_config",
"dnf_config",
"firewall_runtime",
"sysctl",
"etc_custom",
"usr_local_custom",
"extra_paths",

View file

@ -692,6 +692,39 @@
],
"type": "object"
},
"SysctlSnapshot": {
"additionalProperties": false,
"properties": {
"role_name": {
"const": "sysctl"
},
"managed_files": {
"items": {
"$ref": "#/$defs/ManagedFile"
},
"type": "array"
},
"parameters": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"notes": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"role_name",
"managed_files",
"parameters",
"notes"
],
"type": "object"
},
"FlatpakInstall": {
"additionalProperties": false,
"properties": {
@ -884,7 +917,6 @@
"role_name"
],
"type": "object"
},
"SnapSnapshot": {
"additionalProperties": false,
@ -1020,6 +1052,9 @@
"firewall_runtime": {
"$ref": "#/$defs/FirewallRuntimeSnapshot"
},
"sysctl": {
"$ref": "#/$defs/SysctlSnapshot"
},
"flatpak": {
"$ref": "#/$defs/FlatpakSnapshot"
},

View file

@ -96,6 +96,7 @@ def _iter_managed_files(state: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]
"users",
"apt_config",
"dnf_config",
"sysctl",
"etc_custom",
"usr_local_custom",
"extra_paths",