diff --git a/docs/md/ansipa-administration.md b/docs/md/ansipa-administration.md index da34a88..e2d426a 100644 --- a/docs/md/ansipa-administration.md +++ b/docs/md/ansipa-administration.md @@ -159,10 +159,59 @@ lm-sensors: WARN ≥ 65 W, CRIT ≥ 95 W. Add the host to `dev_mod_*` that insta `lm_sensors` if RAPL is unavailable. ### `usr_mon_logins` (user group) -`Ansipa_Logins` check reporting SSH login attempts in the last 24 h (successful + +`Ansipa_Logins` check reporting failed **local** login attempts in the last 24 h +— console/tty, graphical greeters (gdm/sddm/greetd/lightdm): anything +authenticating via PAM that isn't sshd or sudo. Reports failed + invalid-user +counts, with thresholds. Installed fleet-wide when the group exists; needs +`dev_mon_base` on the hosts. SSH activity is tracked separately by +`usr_mon_sshlogins`; sudo/access-control denials by `usr_mon_rightsviolations` +— both below. + +### `usr_mon_sshlogins` (user group) +`Ansipa_SSHLogins` check reporting SSH login attempts in the last 24 h (successful + failed + invalid-user), with thresholds. Installed fleet-wide when the group exists; needs `dev_mon_base` on the hosts. +### `usr_mon_rightsviolations` (user group) +`Ansipa_RightsViolations` check reporting authorization/access-control denials +by an already-authenticated user in the last 24 h — not "who are you" failures +(that's the two checks above), but "you are who you say, but that's not +allowed": sudo denials (`user NOT in sudoers`, `command not allowed`, incorrect +sudo password — works on every distro, sudo always logs these), plus SELinux +AVC denials (Fedora/RHEL, only when `getenforce` reports `Enforcing`) and +AppArmor denials (Debian/Ubuntu, only if loaded). Each source degrades +gracefully when not applicable to the host — this is a best-effort union of +what the system already logs, not a guarantee of catching every possible file +access denial; watching arbitrary paths would need explicit `auditd` watch +rules on specific files, which is a deliberate per-host decision this check +does not make for you. WARN ≥ 3 total, CRIT ≥ 10 total. Installed fleet-wide +when the group exists; needs `dev_mon_base` on the hosts. + +### `usr_mon_iploc` (user group) +`Ansipa_IPLoc` check reporting this device's current public IP and its +geolocation (city/region/country/ISP), and **WARNing when the observed country +changes** since the last reading — e.g. a device that's normally in one +country suddenly appearing in another. + +- Uses two free, no-API-key third-party services: `api.ipify.org` (what's my + public IP) and `ip-api.com` (IP → geolocation). The device's public IP is + sent to both on every actual lookup — be aware of that before enabling this + fleet-wide. +- Reports the network **egress** location, not necessarily the device's + physical location: a host behind a VPN will show the VPN exit country, and + that will (correctly, if noisily) trigger a "country changed" WARN the + moment the VPN connects or disconnects. +- No hardcoded country blocklist — *any* change from the last-observed + country trips a WARN once, then returns to OK on the next reading if the + country is stable at the new location. This is intentionally an anomaly + ("it just moved") signal, not a permanent flag on any particular country. +- CheckMK invokes local checks on its own polling cadence (often every + minute) — far more often than an external API should be hit. This check + self-throttles to one real lookup per 30 minutes and replays its last + result in between; the geolocation call additionally only re-fires when + the public IP itself has changed. +- Installed fleet-wide when the group exists; needs `dev_mon_base` on the hosts. + --- ## Software-installer policies (`dev_pkg_*`, `dev_mod_*`, `dev_fp_*`) @@ -241,6 +290,50 @@ the live `policies.d/*.sh` files directly on the share — changes take effect fleet-wide on the next 30-min enforcer tick, no redeploy needed. Credential in the group description (auto-set by `ansipa-smb-setup.sh`). +### `usr_ctl_dnshostfile` +Grants members write access to `/etc/hosts` via a POSIX ACL +(`setfacl -m u::rw /etc/hosts`) on every enrolled host. Unlike the other +`usr_*` grants above, this doesn't rely on runtime SSSD/PAM resolution — a +filesystem ACL can't check group membership dynamically at the point of use, +so the enforcer computes the group's actual member list (`ipa group-show +usr_ctl_dnshostfile --all`) and re-applies it every tick, revoking the ACL +entry for anyone who has left. + +- **Scope:** `/etc/hosts` only — deliberately not `/etc/resolv.conf`, which on + systemd-resolved systems is usually a symlink to a tmpfs stub regenerated on + boot/network changes, so a static ACL there would silently vanish. +- **Requires:** the `acl` package (`setfacl`/`getfacl`) on the client. +- **Security note:** this is a real grant, not a cosmetic one — a member can + redirect any hostname to any IP *on that machine*, enough to locally spoof a + login page or update server. Grant deliberately. + +### `usr_ctl_netman` / `usr_ctl_wifi` +Grant members the ability to modify this device's NetworkManager connections +via NetworkManager's own per-connection `connection.permissions` mechanism — +NOT a blanket sudo/root grant. NetworkManager already lets a connection's +designated non-root "permitted user" modify/activate that connection without +a password (`org.freedesktop.NetworkManager.settings.modify.own`, allowed for +local users by default on every mainstream distro's polkit rules); these +policies just add the member to that list. + +- **`usr_ctl_netman`** — every **non-Wi-Fi** connection (ethernet, bridge, + bond, VLAN, …): IP address, gateway, DNS, routes. Wi-Fi connections are left + completely untouched. +- **`usr_ctl_wifi`** — every **Wi-Fi** connection only: SSID, security/PSK, + etc. Wired/other connections are left completely untouched. +- A user can be in either, both, or neither — the two are kept deliberately + separate rather than one combined "network admin" group. +- Like `usr_ctl_dnshostfile`, membership is resolved to concrete usernames + (`ipa group-show ... --all`) and re-applied every enforcer tick, since + NetworkManager's connection permissions are static profile metadata, not + something checkable dynamically at the point of use. +- **Requires:** NetworkManager (`nmcli`) on the client. +- Applies to connections that exist at enforcement time; a brand-new + connection created afterwards is picked up on the next 30-min tick. Users + can already create/manage their own new connections by default (standard + NetworkManager multi-user behavior) — these policies are specifically about + granting access to existing, previously root-owned connections. + --- ## BaseUser sync (`BaseUser` group) diff --git a/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh b/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh index 9062068..a1d0f76 100755 --- a/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh +++ b/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh @@ -77,10 +77,48 @@ # automatically: commit and push (GPG-signed) to the ansipa-policy # git server, which ansipa-pull-apply.sh verifies and distributes # fleet-wide. See docs/md/freeipa-ansible.md. -# usr_mon_logins (CheckMK) Local check for SSH login attempts in the last 24 h: +# usr_ctl_dnshostfile Grants members write access to /etc/hosts via a POSIX ACL +# (setfacl). Unlike the grants above, membership can't be checked +# dynamically at the point of use (a filesystem ACL isn't a +# runtime SSSD/PAM check), so the enforcer resolves the group's +# actual member list every tick and revokes the ACL entry for +# anyone who has left. Requires the 'acl' package. +# usr_ctl_netman Grants members modify access to this host's NON-Wi-Fi +# NetworkManager connections (IP/gateway/DNS/routes) via +# NetworkManager's own connection.permissions mechanism — NOT sudo. +# Wi-Fi connections are left completely untouched (see +# usr_ctl_wifi). Requires NetworkManager (nmcli). +# usr_ctl_wifi Grants members modify access to this host's Wi-Fi +# NetworkManager connections only (SSID/security) — the mirror of +# usr_ctl_netman; wired/other connections are untouched. A user +# can be in either, both, or neither. Requires NetworkManager (nmcli). +# usr_mon_logins (CheckMK) Local check for failed LOCAL login attempts in the +# last 24 h — console/tty, graphical greeters: anything via PAM +# that isn't sshd or sudo (those are usr_mon_sshlogins and +# usr_mon_rightsviolations, below). +# Reports failed + invalid-user counts with thresholds. +# WARN: ≥10 failed or ≥5 invalid; CRIT: ≥50 failed or ≥20 invalid. +# Installed fleet-wide when the group exists; requires dev_mon_base. +# usr_mon_sshlogins (CheckMK) Local check for SSH login attempts in the last 24 h: # successful + failed + invalid-user counts with thresholds. # WARN: ≥10 failed or ≥5 invalid; CRIT: ≥50 failed or ≥20 invalid. # Installed fleet-wide when the group exists; requires dev_mon_base. +# usr_mon_rightsviolations (CheckMK) Local check for authorization/access-control denials +# by an already-authenticated user in the last 24 h: sudo denials +# (every distro), plus SELinux AVC denials (Fedora/RHEL, if +# enforcing) and AppArmor denials (Debian/Ubuntu, if loaded) — +# each source degrades gracefully when not applicable to the host. +# WARN: ≥3 total; CRIT: ≥10 total. +# Installed fleet-wide when the group exists; requires dev_mon_base. +# usr_mon_iploc (CheckMK) Local check reporting this device's current public IP +# and geolocation (via api.ipify.org + ip-api.com — both free, +# no-API-key third-party services); WARNs when the observed +# country changes since the last reading (e.g. a device normally +# in one country suddenly showing up elsewhere). Reports the +# network EGRESS location — a VPN's exit country, not necessarily +# the device's physical location. Self-throttles to one real +# lookup per 30 min regardless of how often CheckMK polls. +# Installed fleet-wide when the group exists; requires dev_mon_base. # # Notes: # - Install scan tools first: add the host to dev_mod_anti-malware. diff --git a/setup/modules/FreeipaAnsible/ansible/deploy-ansipa-policies.yml b/setup/modules/FreeipaAnsible/ansible/deploy-ansipa-policies.yml index 7ba98a9..cc9d8b4 100644 --- a/setup/modules/FreeipaAnsible/ansible/deploy-ansipa-policies.yml +++ b/setup/modules/FreeipaAnsible/ansible/deploy-ansipa-policies.yml @@ -30,6 +30,14 @@ # usr_smb_rw_ Mount read-write Samba share ~/‹name› for members (rw beats r if both) # usr_smb_adm_policystore Mount the policy store at ~/policystore (rw) for admin members; # credential in IPA group description (set by ansipa-smb-setup.sh) +# usr_ctl_dnshostfile Grant write access to /etc/hosts via a POSIX ACL (setfacl). +# Membership resolved to concrete usernames every tick (a +# filesystem ACL can't be checked dynamically). Requires 'acl'. +# usr_ctl_netman Grant modify access to non-Wi-Fi NetworkManager connections +# (IP/gateway/DNS/routes) via connection.permissions — not sudo. +# Wi-Fi is untouched (see usr_ctl_wifi). Requires nmcli. +# usr_ctl_wifi Grant modify access to Wi-Fi NetworkManager connections only +# (SSID/security) — the mirror of usr_ctl_netman. Requires nmcli. # # CheckMK monitoring policies (device host-groups, unless noted): # dev_mon_base Install CheckMK agent; register host in CMK; check: installed packages. @@ -43,8 +51,18 @@ # dev_mon_timeshift Local check for most recent Timeshift snapshot age. # WARN if >5 days, CRIT if >10 days, CRIT if no snapshots found. # dev_mon_power Local check for CPU package TDP/power via Intel RAPL or lm-sensors. -# usr_mon_logins (User group) Local check for SSH login attempts in last 24h. +# usr_mon_logins (User group) Local check for failed LOCAL login attempts in +# last 24h (console/tty/greeter — anything via PAM that isn't +# sshd or sudo). Reports: failed + invalid-user counts with thresholds. +# usr_mon_sshlogins (User group) Local check for SSH login attempts in last 24h. # Reports: successful + failed + invalid-user counts with thresholds. +# usr_mon_rightsviolations (User group) Local check for authorization/access-control denials +# in last 24h: sudo denials (every distro) + SELinux AVC denials +# (if enforcing) + AppArmor denials (if loaded). +# usr_mon_iploc (User group) Local check for this device's public IP + geolocation +# (api.ipify.org + ip-api.com); WARNs on a country change since the +# last reading. Reports network egress location, not necessarily the +# device's physical location. Self-throttled to one lookup/30min. # # Prerequisites: # - Host enrolled in FreeIPA (sssd + ipa CLI available) diff --git a/setup/modules/FreeipaAnsible/ansible/lib/ansipa-policy.sh b/setup/modules/FreeipaAnsible/ansible/lib/ansipa-policy.sh index cccf66d..12e79d9 100644 --- a/setup/modules/FreeipaAnsible/ansible/lib/ansipa-policy.sh +++ b/setup/modules/FreeipaAnsible/ansible/lib/ansipa-policy.sh @@ -152,6 +152,20 @@ _smb_parse_cred() { echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]}" } +# ── Group member list helper ────────────────────────────────────────────────── +# Returns newline-separated usernames belonging to the given IPA user group. +# Used by capability-grant policies (usr_ctl_*) that apply to concrete user +# accounts rather than relying on runtime SSSD/PAM resolution — filesystem +# ACLs and NetworkManager connection permissions are static profile metadata, +# not something that can be checked dynamically at the point of use the way a +# sudo rule or PATH wrapper can, so the enforcer must compute and re-apply the +# member set on every tick. +_ipa_group_member_users() { + ipa group-show "$1" --all 2>/dev/null \ + | awk -F': ' '/Member users:/{print $2; exit}' \ + | tr ',' '\n' | sed 's/^ *//; s/ *$//' | grep -v '^$' || true +} + # NOTE: this file previously had an _ansipa_sync_policystore() here, which # synced *.sh files from the ansipa-policystore SMB share directly into # $POLICY_DIR with no integrity check before they were sourced as root. That diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/dev_mon.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/dev_mon.sh index 7d09c50..c7fbee6 100644 --- a/setup/modules/FreeipaAnsible/ansible/policies.d/dev_mon.sh +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/dev_mon.sh @@ -3,7 +3,13 @@ # dev_mon_base install agent, register host, installed-packages check # dev_mon_timeshift Timeshift snapshot age check # dev_mon_power CPU package power via RAPL / lm-sensors -# usr_mon_logins SSH login audit check (user group, but requires dev_mon_base) +# usr_mon_logins failed LOCAL login audit (console/tty/greeter — anything +# via PAM that isn't sshd or sudo); user group, requires dev_mon_base +# usr_mon_sshlogins SSH login audit check (user group, but requires dev_mon_base) +# usr_mon_rightsviolations sudo denials + SELinux/AppArmor access-control +# denials (user group, but requires dev_mon_base) +# usr_mon_iploc public IP + geolocation, flags a country change +# (user group, but requires dev_mon_base) # (ClamAV scan reporting lives in its own policy, dev_mon_clamscan, which runs # the scan and writes the Ansipa_ClamScan local check the agent here collects.) # Revert: deregisters the host and removes the agent socket when dev_mon_base leaves. @@ -284,9 +290,40 @@ else fi' fi - # ── usr_mon_logins: SSH login audit (requires dev_mon_base agent) ───── + # ── usr_mon_logins: failed LOCAL login audit (requires dev_mon_base agent) ─ + # "Local" = anything authenticating via PAM that ISN'T sshd — console/tty + # (login), graphical greeters (gdm/sddm/greetd/lightdm). SSH activity is + # tracked separately by usr_mon_sshlogins; sudo/access-control denials by + # usr_mon_rightsviolations — both excluded here to keep the three clean. if _mon_usr_active "logins"; then _cmk_write_check "ansipa_logins" '#!/bin/bash +if command -v journalctl &>/dev/null && journalctl --since "24 hours ago" -q &>/dev/null; then + LINES=$(journalctl --since "24 hours ago" --no-pager -q 2>/dev/null \ + | grep -iE "authentication failure|failed login" | grep -viE "sshd|sudo") +else + LINES=$( + { grep "$(date "+%b %e")" /var/log/secure /var/log/auth.log 2>/dev/null + grep "$(date -d yesterday "+%b %e" 2>/dev/null || true)" /var/log/secure /var/log/auth.log 2>/dev/null + } | grep -iE "authentication failure|failed login" | grep -viE "sshd|sudo" + ) +fi +FAILED=0 +[[ -n "$LINES" ]] && FAILED=$(grep -c . <<< "$LINES") +INVALID=$(grep -icE "unknown user|user unknown" <<< "$LINES" 2>/dev/null || echo 0) +PERF="failed=${FAILED};10;50 invalid=${INVALID};5;20" +MSG="Local failed logins 24h: ${FAILED} failed / ${INVALID} invalid user" +if [[ "$FAILED" -ge 50 ]] || [[ "$INVALID" -ge 20 ]]; then + echo "2 Ansipa_Logins ${PERF} HIGH ACTIVITY — ${MSG}" +elif [[ "$FAILED" -ge 10 ]] || [[ "$INVALID" -ge 5 ]]; then + echo "1 Ansipa_Logins ${PERF} ELEVATED — ${MSG}" +else + echo "0 Ansipa_Logins ${PERF} ${MSG}" +fi' + fi + + # ── usr_mon_sshlogins: SSH login audit (requires dev_mon_base agent) ── + if _mon_usr_active "sshlogins"; then + _cmk_write_check "ansipa_sshlogins" '#!/bin/bash count_journal() { local pat="$1" journalctl -u sshd --since "24 hours ago" --no-pager -q 2>/dev/null \ @@ -308,13 +345,138 @@ else INVALID=$(count_secure "sshd.*Invalid user") fi PERF="successful=${ACCEPTED};; failed=${FAILED};10;50 invalid=${INVALID};5;20" -MSG="Logins 24h: ${ACCEPTED} ok / ${FAILED} failed / ${INVALID} invalid user" +MSG="SSH logins 24h: ${ACCEPTED} ok / ${FAILED} failed / ${INVALID} invalid user" if [[ "$FAILED" -ge 50 ]] || [[ "$INVALID" -ge 20 ]]; then - echo "2 Ansipa_Logins ${PERF} HIGH ACTIVITY — ${MSG}" + echo "2 Ansipa_SSHLogins ${PERF} HIGH ACTIVITY — ${MSG}" elif [[ "$FAILED" -ge 10 ]] || [[ "$INVALID" -ge 5 ]]; then - echo "1 Ansipa_Logins ${PERF} ELEVATED — ${MSG}" + echo "1 Ansipa_SSHLogins ${PERF} ELEVATED — ${MSG}" else - echo "0 Ansipa_Logins ${PERF} ${MSG}" + echo "0 Ansipa_SSHLogins ${PERF} ${MSG}" +fi' + fi + + # ── usr_mon_rightsviolations: authorization/access-control denials ──── + # An already-authenticated user attempting something they're not allowed + # to do: sudo denials (works on every distro — sudo always logs these), + # plus SELinux AVC denials (Fedora/RHEL, only if enforcing) and AppArmor + # denials (Debian/Ubuntu, only if loaded) via the journal. Each source + # degrades gracefully when not applicable to this host. This is a + # best-effort union of what's already logged system-wide, not a + # guarantee of catching every possible file-access denial — watching + # arbitrary paths needs auditd watches on specific files, which is a + # deliberate per-host decision this check does not assume for you. + if _mon_usr_active "rightsviolations"; then + _cmk_write_check "ansipa_rightsviolations" '#!/bin/bash +if command -v journalctl &>/dev/null && journalctl --since "24 hours ago" -q &>/dev/null; then + SUDO_LINES=$(journalctl --since "24 hours ago" --no-pager -q 2>/dev/null | grep -i "sudo") +else + SUDO_LINES=$( + { grep "$(date "+%b %e")" /var/log/secure /var/log/auth.log 2>/dev/null + grep "$(date -d yesterday "+%b %e" 2>/dev/null || true)" /var/log/secure /var/log/auth.log 2>/dev/null + } | grep -i "sudo" + ) +fi +SUDO_DENIED=0 +[[ -n "$SUDO_LINES" ]] && SUDO_DENIED=$(grep -icE \ + "not in the sudoers file|command not allowed|incorrect password attempt|authentication failure" \ + <<< "$SUDO_LINES") + +SELINUX_DENIED=0 +if command -v getenforce &>/dev/null && [[ "$(getenforce 2>/dev/null)" == "Enforcing" ]] \ + && command -v journalctl &>/dev/null && journalctl --since "24 hours ago" -q &>/dev/null; then + SELINUX_DENIED=$(journalctl --since "24 hours ago" --no-pager -q 2>/dev/null | grep -c "avc: denied") +fi + +APPARMOR_DENIED=0 +if command -v journalctl &>/dev/null && journalctl --since "24 hours ago" -q &>/dev/null; then + APPARMOR_DENIED=$(journalctl --since "24 hours ago" --no-pager -q 2>/dev/null | grep -c "apparmor=\"DENIED\"") +fi + +TOTAL=$(( SUDO_DENIED + SELINUX_DENIED + APPARMOR_DENIED )) +PERF="sudo_denied=${SUDO_DENIED} selinux_denied=${SELINUX_DENIED} apparmor_denied=${APPARMOR_DENIED} total=${TOTAL};3;10" +MSG="Rights violations 24h: ${SUDO_DENIED} sudo / ${SELINUX_DENIED} selinux / ${APPARMOR_DENIED} apparmor" +if [[ "$TOTAL" -ge 10 ]]; then + echo "2 Ansipa_RightsViolations ${PERF} HIGH ACTIVITY — ${MSG}" +elif [[ "$TOTAL" -ge 3 ]]; then + echo "1 Ansipa_RightsViolations ${PERF} ELEVATED — ${MSG}" +else + echo "0 Ansipa_RightsViolations ${PERF} ${MSG}" +fi' + fi + + # ── usr_mon_iploc: public IP + geolocation, flag a country change ───── + # NOTE: this reports where the device's NETWORK EGRESS appears to be — + # if the host is behind a VPN, that is the VPN exit location, not + # necessarily the device's physical location. Uses two free, + # no-API-key third-party services (api.ipify.org for the IP, + # ip-api.com for geolocation) — the device's public IP is sent to + # both on every actual lookup; see docs/md/ansipa-administration.md. + # CheckMK invokes local checks on its own polling cadence (often every + # 1 min), far more often than external services should be hit, so this + # throttles itself to one real lookup per THROTTLE_SECONDS and replays + # its last result in between. The geolocation call additionally only + # re-fires when the public IP itself has changed. + if _mon_usr_active "iploc"; then + _cmk_write_check "ansipa_iploc" '#!/bin/bash +STATE_DIR="/var/lib/ansipa-policies" +TS_STATE="$STATE_DIR/iploc-last-ts" +IP_STATE="$STATE_DIR/iploc-last-ip" +LOC_STATE="$STATE_DIR/iploc-last-loc" +OUT_STATE="$STATE_DIR/iploc-last-output" +THROTTLE_SECONDS=1800 +mkdir -p "$STATE_DIR" 2>/dev/null + +NOW=$(date +%s) +LAST_TS=$(cat "$TS_STATE" 2>/dev/null || echo 0) +if [[ -f "$OUT_STATE" ]] && (( NOW - LAST_TS < THROTTLE_SECONDS )); then + cat "$OUT_STATE" + exit 0 +fi + +_emit() { echo "$1" | tee "$OUT_STATE" >/dev/null; echo "$1"; echo "$NOW" > "$TS_STATE"; } + +PUB_IP=$(curl -fsS -m 5 https://api.ipify.org 2>/dev/null \ + || curl -fsS -m 5 https://ifconfig.me/ip 2>/dev/null \ + || curl -fsS -m 5 https://icanhazip.com 2>/dev/null | tr -d "[:space:]") + +if [[ -z "$PUB_IP" ]]; then + _emit "3 Ansipa_IPLoc - Could not determine public IP (no internet / lookup services unreachable)" + exit 0 +fi + +LAST_IP=$(cat "$IP_STATE" 2>/dev/null || echo "") +LAST_LOC=$(cat "$LOC_STATE" 2>/dev/null || echo "") + +if [[ "$PUB_IP" == "$LAST_IP" ]] && [[ -n "$LAST_LOC" ]]; then + CUR_LOC="$LAST_LOC" +else + GEO=$(curl -fsS -m 5 "http://ip-api.com/line/${PUB_IP}?fields=status,country,countryCode,regionName,city,isp" 2>/dev/null) + if [[ -z "$GEO" ]]; then + _emit "3 Ansipa_IPLoc - Public IP is ${PUB_IP}, geolocation lookup failed" + exit 0 + fi + mapfile -t _G <<< "$GEO" + if [[ "${_G[0]:-}" != "success" ]]; then + _emit "3 Ansipa_IPLoc - Public IP is ${PUB_IP}, geolocation lookup returned: ${_G[0]:-unknown}" + exit 0 + fi + CUR_LOC="${_G[1]:-unknown}|${_G[2]:-??}|${_G[3]:-}|${_G[4]:-}|${_G[5]:-}" +fi + +IFS="|" read -r CUR_COUNTRY CUR_CC CUR_REGION CUR_CITY CUR_ISP <<< "$CUR_LOC" + +LAST_CC="" +if [[ -n "$LAST_LOC" ]] && [[ "$LAST_LOC" != "$CUR_LOC" ]]; then + IFS="|" read -r LAST_COUNTRY LAST_CC _ _ _ <<< "$LAST_LOC" +fi + +echo "$PUB_IP" > "$IP_STATE" +echo "$CUR_LOC" > "$LOC_STATE" + +if [[ -n "$LAST_CC" ]] && [[ "$LAST_CC" != "$CUR_CC" ]]; then + _emit "1 Ansipa_IPLoc - COUNTRY CHANGED: ${LAST_COUNTRY} -> ${CUR_COUNTRY} (now ${CUR_CITY}, ${CUR_REGION} - public IP ${PUB_IP}, ${CUR_ISP})" +else + _emit "0 Ansipa_IPLoc - ${CUR_CITY}, ${CUR_REGION}, ${CUR_COUNTRY} (${CUR_ISP}) - public IP ${PUB_IP}" fi' fi diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/usr_ctl_dnshostfile.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_ctl_dnshostfile.sh new file mode 100644 index 0000000..b44108a --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_ctl_dnshostfile.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# policy: usr_ctl_dnshostfile — grant specific IPA users write access to +# /etc/hosts via a POSIX ACL. Default (no membership) leaves /etc/hosts at +# its normal root-only permissions — membership is what GRANTS the right, +# it does not restrict anything for non-members. +# +# Scope: /etc/hosts only, deliberately not /etc/resolv.conf. On systems using +# systemd-resolved, /etc/resolv.conf is usually a symlink to a stub file on +# tmpfs that gets regenerated on boot/network changes, so a static ACL there +# would silently vanish; covering it properly would mean resolving and +# re-applying to whatever the symlink currently targets on every run, which +# is a deliberate follow-up rather than something folded in here silently. +# +# Requires: the 'acl' package (setfacl/getfacl) on the client. +# Security note: this is a real grant, not a cosmetic one — a member can +# redirect any hostname to any IP on this machine (local DNS override), which +# is enough to locally spoof a login page or update server. Grant deliberately. + +HOSTS_FILE="/etc/hosts" +DNSHOST_STATE="$STATE_DIR/dnshostfile-acl-users" +[[ -f "$DNSHOST_STATE" ]] || touch "$DNSHOST_STATE" + +if ! command -v setfacl &>/dev/null; then + warn "usr_ctl_dnshostfile: setfacl not found (install the 'acl' package) — skipping" +else + _dnshost_grp_exists=false + grep -qxF "usr_ctl_dnshostfile" <<< "$_ALL_USER_GROUPS" && _dnshost_grp_exists=true + + _DNSHOST_USERS=() + if [[ "$_dnshost_grp_exists" == true ]]; then + while IFS= read -r _u; do + [[ -z "$_u" ]] && continue + _DNSHOST_USERS+=("$_u") + done < <(_ipa_group_member_users usr_ctl_dnshostfile) + fi + + _dnshost_in_desired() { + local n="$1" + for _d in "${_DNSHOST_USERS[@]+"${_DNSHOST_USERS[@]}"}"; do [[ "$_d" == "$n" ]] && return 0; done + return 1 + } + + # Grant: add an ACL entry for every currently-desired user (idempotent). + for _u in "${_DNSHOST_USERS[@]+"${_DNSHOST_USERS[@]}"}"; do + getent passwd "$_u" &>/dev/null || continue + if ! getfacl "$HOSTS_FILE" 2>/dev/null | grep -qxF "user:${_u}:rw-"; then + setfacl -m "u:${_u}:rw" "$HOSTS_FILE" \ + && log "usr_ctl_dnshostfile: granted $_u write access to $HOSTS_FILE" \ + || warn "usr_ctl_dnshostfile: setfacl grant failed for $_u" + fi + done + + # Revert: remove the ACL entry for anyone previously granted who is no + # longer in the group (or the group was deleted entirely). + _NEW_DNSHOST_STATE=() + while IFS= read -r _old_u; do + [[ -z "$_old_u" ]] && continue + if _dnshost_in_desired "$_old_u"; then + _NEW_DNSHOST_STATE+=("$_old_u") + else + setfacl -x "u:${_old_u}" "$HOSTS_FILE" 2>/dev/null \ + && log "usr_ctl_dnshostfile: revoked $_old_u write access to $HOSTS_FILE" + fi + done < "$DNSHOST_STATE" + + if [[ ${#_DNSHOST_USERS[@]} -gt 0 ]]; then + printf '%s\n' "${_DNSHOST_USERS[@]}" | sort -u > "$DNSHOST_STATE" + else + > "$DNSHOST_STATE" + fi + unset _dnshost_grp_exists _DNSHOST_USERS _u _NEW_DNSHOST_STATE _old_u +fi diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/usr_ctl_netman.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_ctl_netman.sh new file mode 100644 index 0000000..3368029 --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_ctl_netman.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# policy: usr_ctl_netman — grant specific IPA users the ability to modify this +# device's NON-WI-FI NetworkManager connections (IP address, gateway, DNS, +# routes, etc.) — NOT a blanket sudo/root grant, and deliberately NOT extended +# to Wi-Fi connections (see usr_ctl_wifi for that, kept separate on purpose). +# +# Mechanism: NetworkManager already lets a connection's designated non-root +# "permitted user" modify/activate THAT connection without a password +# (org.freedesktop.NetworkManager.settings.modify.own — allowed for local +# users by default on every mainstream distro's polkit rules). This policy +# adds the member to the `connection.permissions` list of every EXISTING +# non-Wi-Fi connection; Wi-Fi connections are left completely untouched, so +# a member can repoint the wired IP/gateway but cannot touch Wi-Fi settings. +# +# Requires: NetworkManager (nmcli) on the client. +# Note: applies to connections that exist at enforcement time; a brand-new +# wired connection created afterwards is picked up on the next 30-min tick. +# Users can already create/manage their OWN new connections by default +# (standard NetworkManager multi-user behavior) — this policy is specifically +# about granting access to EXISTING, previously root-owned connections. + +NETMAN_STATE="$STATE_DIR/netman-acl-users" +[[ -f "$NETMAN_STATE" ]] || touch "$NETMAN_STATE" + +if ! command -v nmcli &>/dev/null; then + warn "usr_ctl_netman: nmcli not found (NetworkManager not installed) — skipping" +else + _netman_grp_exists=false + grep -qxF "usr_ctl_netman" <<< "$_ALL_USER_GROUPS" && _netman_grp_exists=true + + _NETMAN_USERS=() + if [[ "$_netman_grp_exists" == true ]]; then + while IFS= read -r _u; do + [[ -z "$_u" ]] && continue + _NETMAN_USERS+=("$_u") + done < <(_ipa_group_member_users usr_ctl_netman) + fi + + _netman_in_desired() { + local n="$1" + for _d in "${_NETMAN_USERS[@]+"${_NETMAN_USERS[@]}"}"; do [[ "$_d" == "$n" ]] && return 0; done + return 1 + } + + # Every current non-Wi-Fi connection UUID (ethernet, bridge, bond, vlan, ...). + _NON_WIFI_UUIDS=() + while IFS=: read -r _type _uuid; do + [[ -z "$_uuid" ]] && continue + [[ "$_type" == "wifi" || "$_type" == "802-11-wireless" ]] && continue + _NON_WIFI_UUIDS+=("$_uuid") + done < <(nmcli -t -f TYPE,UUID connection show 2>/dev/null) + + # Grant: add each desired user to every non-Wi-Fi connection's permissions. + for _u in "${_NETMAN_USERS[@]+"${_NETMAN_USERS[@]}"}"; do + getent passwd "$_u" &>/dev/null || continue + for _uuid in "${_NON_WIFI_UUIDS[@]+"${_NON_WIFI_UUIDS[@]}"}"; do + _perms=$(nmcli -t -g connection.permissions connection show "$_uuid" 2>/dev/null) + if [[ "$_perms" != *"user:${_u}:"* ]]; then + nmcli connection modify "$_uuid" +connection.permissions "user:${_u}:" &>/dev/null \ + && log "usr_ctl_netman: granted $_u modify access to connection $_uuid" + fi + done + done + + # Revert: strip any previously-granted user from every non-Wi-Fi connection + # once they're no longer in the group (or the group is gone). + while IFS= read -r _old_u; do + [[ -z "$_old_u" ]] && continue + if ! _netman_in_desired "$_old_u"; then + for _uuid in "${_NON_WIFI_UUIDS[@]+"${_NON_WIFI_UUIDS[@]}"}"; do + _perms=$(nmcli -t -g connection.permissions connection show "$_uuid" 2>/dev/null) + [[ "$_perms" == *"user:${_old_u}:"* ]] && \ + nmcli connection modify "$_uuid" -connection.permissions "user:${_old_u}:" &>/dev/null + done + log "usr_ctl_netman: revoked $_old_u network-modify access (left usr_ctl_netman)" + fi + done < "$NETMAN_STATE" + + if [[ ${#_NETMAN_USERS[@]} -gt 0 ]]; then + printf '%s\n' "${_NETMAN_USERS[@]}" | sort -u > "$NETMAN_STATE" + else + > "$NETMAN_STATE" + fi + unset _netman_grp_exists _NETMAN_USERS _NON_WIFI_UUIDS _u _uuid _perms _old_u +fi diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/usr_ctl_wifi.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_ctl_wifi.sh new file mode 100644 index 0000000..cf8e295 --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_ctl_wifi.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# policy: usr_ctl_wifi — grant specific IPA users the ability to modify this +# device's Wi-Fi NetworkManager connections (SSID, PSK/security, etc.). Kept +# as its own group deliberately separate from usr_ctl_netman (wired IP/gateway +# control) — a user can be in either, both, or neither. +# +# Mechanism: identical to usr_ctl_netman but filtered to ONLY Wi-Fi +# connections — adds the member to `connection.permissions` on every existing +# Wi-Fi connection, relying on NetworkManager's own +# org.freedesktop.NetworkManager.settings.modify.own polkit rule (allowed for +# local users by default) to let them edit/activate those connections without +# a password. Wired/other connections are left completely untouched. +# +# Requires: NetworkManager (nmcli) on the client. +# Note: applies to connections that exist at enforcement time; a brand-new +# Wi-Fi connection created afterwards is picked up on the next 30-min tick. + +WIFI_STATE="$STATE_DIR/wifi-acl-users" +[[ -f "$WIFI_STATE" ]] || touch "$WIFI_STATE" + +if ! command -v nmcli &>/dev/null; then + warn "usr_ctl_wifi: nmcli not found (NetworkManager not installed) — skipping" +else + _wifi_grp_exists=false + grep -qxF "usr_ctl_wifi" <<< "$_ALL_USER_GROUPS" && _wifi_grp_exists=true + + _WIFI_USERS=() + if [[ "$_wifi_grp_exists" == true ]]; then + while IFS= read -r _u; do + [[ -z "$_u" ]] && continue + _WIFI_USERS+=("$_u") + done < <(_ipa_group_member_users usr_ctl_wifi) + fi + + _wifi_in_desired() { + local n="$1" + for _d in "${_WIFI_USERS[@]+"${_WIFI_USERS[@]}"}"; do [[ "$_d" == "$n" ]] && return 0; done + return 1 + } + + # Every current Wi-Fi connection UUID. + _WIFI_UUIDS=() + while IFS=: read -r _type _uuid; do + [[ -z "$_uuid" ]] && continue + [[ "$_type" == "wifi" || "$_type" == "802-11-wireless" ]] || continue + _WIFI_UUIDS+=("$_uuid") + done < <(nmcli -t -f TYPE,UUID connection show 2>/dev/null) + + # Grant: add each desired user to every Wi-Fi connection's permissions. + for _u in "${_WIFI_USERS[@]+"${_WIFI_USERS[@]}"}"; do + getent passwd "$_u" &>/dev/null || continue + for _uuid in "${_WIFI_UUIDS[@]+"${_WIFI_UUIDS[@]}"}"; do + _perms=$(nmcli -t -g connection.permissions connection show "$_uuid" 2>/dev/null) + if [[ "$_perms" != *"user:${_u}:"* ]]; then + nmcli connection modify "$_uuid" +connection.permissions "user:${_u}:" &>/dev/null \ + && log "usr_ctl_wifi: granted $_u modify access to Wi-Fi connection $_uuid" + fi + done + done + + # Revert: strip any previously-granted user from every Wi-Fi connection + # once they're no longer in the group (or the group is gone). + while IFS= read -r _old_u; do + [[ -z "$_old_u" ]] && continue + if ! _wifi_in_desired "$_old_u"; then + for _uuid in "${_WIFI_UUIDS[@]+"${_WIFI_UUIDS[@]}"}"; do + _perms=$(nmcli -t -g connection.permissions connection show "$_uuid" 2>/dev/null) + [[ "$_perms" == *"user:${_old_u}:"* ]] && \ + nmcli connection modify "$_uuid" -connection.permissions "user:${_old_u}:" &>/dev/null + done + log "usr_ctl_wifi: revoked $_old_u Wi-Fi-modify access (left usr_ctl_wifi)" + fi + done < "$WIFI_STATE" + + if [[ ${#_WIFI_USERS[@]} -gt 0 ]]; then + printf '%s\n' "${_WIFI_USERS[@]}" | sort -u > "$WIFI_STATE" + else + > "$WIFI_STATE" + fi + unset _wifi_grp_exists _WIFI_USERS _WIFI_UUIDS _u _uuid _perms _old_u +fi diff --git a/setup/modules/FreeipaAnsible/image/ansipa-checkmk-setup.sh b/setup/modules/FreeipaAnsible/image/ansipa-checkmk-setup.sh index 8df3efe..df6f593 100644 --- a/setup/modules/FreeipaAnsible/image/ansipa-checkmk-setup.sh +++ b/setup/modules/FreeipaAnsible/image/ansipa-checkmk-setup.sh @@ -171,10 +171,23 @@ for _hgrp in dev_mon_clamscan dev_mon_timeshift dev_mon_power; do ipa hostgroup-add "$_hgrp" &>/dev/null || true done -# usr_mon_logins is a USER group (monitoring follows the user, not the device). +# usr_mon_logins / usr_mon_sshlogins / usr_mon_rightsviolations / usr_mon_iploc +# are USER groups (monitoring follows the user, not the device). logins = +# failed LOCAL logins (console/tty/greeter); sshlogins = SSH activity; +# rightsviolations = sudo denials + SELinux/AppArmor access-control denials; +# iploc = public IP + geolocation, flags a country change — each tracked separately. ipa group-show usr_mon_logins &>/dev/null 2>&1 || \ ipa group-add usr_mon_logins \ + --desc="Members: failed local login monitoring enabled on enrolled devices" &>/dev/null || true +ipa group-show usr_mon_sshlogins &>/dev/null 2>&1 || \ + ipa group-add usr_mon_sshlogins \ --desc="Members: SSH login monitoring enabled on enrolled devices" &>/dev/null || true +ipa group-show usr_mon_rightsviolations &>/dev/null 2>&1 || \ + ipa group-add usr_mon_rightsviolations \ + --desc="Members: sudo/SELinux/AppArmor rights-violation monitoring enabled on enrolled devices" &>/dev/null || true +ipa group-show usr_mon_iploc &>/dev/null 2>&1 || \ + ipa group-add usr_mon_iploc \ + --desc="Members: public IP/geolocation monitoring enabled on enrolled devices" &>/dev/null || true kdestroy &>/dev/null || true