Compare commits

...

10 Commits

Author SHA1 Message Date
Amir Alexander Abdelbaki 6f2b24c51a docs: update readme and docs for recent changes
- freeipa-ansible.md: expand into full container installation guide
  covering SMB shares (ansipa-scans, ansipa-luks-keys), KeyAdmin access
  control, LUKS_KEY_UPLOAD_PASSWORD env var, updated collect-luks-keys
  flow via SMB, daemon enable/disable policy, security scan + alert
  pipeline, and Keycloak section
- modules.md: add Virtualisation & Remote Desktop section (qemu,
  rdp-client, lamco-rdp-server)
- archiso.md: document system reset mode (reset-arch.sh), launch.sh
  action selection, libfido2 in packages.extra
- readme.md: update Cliff Notes and docs table to reflect all changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:39:38 +02:00
Amir Alexander Abdelbaki 5d56984e38 feat(ansipa): store LUKS backup keys on SMB share with KeyAdmin access control
ansipa-smb-setup.sh:
- Adds KeyAdmin Linux group and luks-upload service account (member of
  KeyAdmin) on the IPA container, both persisted across restarts.
- LUKS base dir /data/luks-keys owned root:KeyAdmin, mode 2750 (setgid
  so new files inherit the group).
- New [ansipa-luks-keys] SMB share: valid users = @KeyAdmin, read only,
  write list = luks-upload. Human admins gain read access by being added
  to KeyAdmin: useradd -r -G KeyAdmin <user> && smbpasswd -a <user>.
- LUKS_KEY_UPLOAD_PASSWORD sourced from env / /data/samba/ansipa-smb.env
  alongside the existing SMB_SCAN_PASSWORD.

collect-luks-keys.yml:
- After fetching /_LUKS_BACKUP_KEY from each client, uploads it to the
  ansipa-luks-keys share via smbclient using a temp credentials file
  (no_log, deleted in post_tasks).
- Local staging copy is removed after a successful upload.
- SMB credentials file uses an epoch-stamped path to avoid collisions.

.env.example: documents LUKS_KEY_UPLOAD_PASSWORD.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:33:17 +02:00
Amir Alexander Abdelbaki aced2c754e feat(ansipa): add daemon enable/disable policy via host-group regex
Host groups named policy-daemon-enable-<unit> and
policy-daemon-disable-<unit> are now matched by a wildcard case arm in
the group parser — no per-service configuration required.

Enforcement (every 30 min via existing timer):
  enable:  systemctl enable --now <unit>; state written to
           /var/lib/ansipa-policies/daemon-enabled
  disable: systemctl disable --now <unit>; state written to
           /var/lib/ansipa-policies/daemon-disabled
  revert:  when a host leaves a group the opposite action is applied
           on the next run (enable→disable, disable→enable)
  conflict: unit in both lists is skipped with a warning

The .service suffix is optional — _svc_unit() appends it when the name
contains no dot, so all systemd unit types work as-is.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:25:15 +02:00
Amir Alexander Abdelbaki 63cd59fb91 feat(modules): add lamco-rdp-server module
Installs lamco-rdp-server from AUR (native Wayland RDP server, Rust,
H.264/VA-API). Enables lamco-rdp-server.service as a systemd user
service. Wired into tui-install.sh alongside the existing rdp-client
and qemu entries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:15:59 +02:00
Amir Alexander Abdelbaki eb3ae766a5 feat(modules): add RDP client and QEMU/KVM modules
rdp-client.sh: installs Remmina with the FreeRDP and libvncserver plugins
for RDP and VNC sessions.

qemu.sh: installs the full QEMU/KVM stack (qemu-full, libvirt, virt-manager,
virt-viewer, dnsmasq, bridge-utils, edk2-ovmf, swtpm, vde2), enables and
starts libvirtd, auto-starts the default NAT network, and adds the user to
the libvirt and kvm groups.

Both modules are wired into tui-install.sh: count_steps, checklist,
confirmation summary, and run_module dispatch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:12:21 +02:00
Amir Alexander Abdelbaki a84e6ac41c feat(archiso): add system reset mode to installer
Adds a post-keymap action selection to launch.sh (Install vs Reset).
The reset routine (reset-arch.sh) unlocks LUKS via FIDO2 token and/or
passphrase, snapshots /etc credentials and config, wipes and recreates
the @ btrfs subvolume, reinstalls base packages via pacstrap, restores
auth files (passwd/shadow/pam.d/sudoers) and system config, then
regenerates the initramfs and GRUB menu from chroot. User home data is
preserved; ~/.config is cleared except Yubico/ auth keys so FIDO2 PAM
login continues to work. libfido2 added to packages.extra for live-env
token unlock support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:09:00 +02:00
Amir Alexander Abdelbaki c56c86d57b fix(freeipa): harden container SMB setup and fetch-alerts script
ansipa-smb.service: WantedBy=multi-user.target (was smb.service) so the
  setup service always runs at boot, not only when smb.service pulls it in

docker-compose.yml: add NetBIOS UDP ports 137/138 to match Dockerfile EXPOSE
  and nmb.service being enabled

ansipa-smb-setup.sh:
  - use printf '%q' when writing SMB_SCAN_PASSWORD to ansipa-smb.env so
    passwords with spaces or shell-special chars are correctly quoted
  - always write /etc/cron.d/ansipa-check-scans (remove the [[ ! -f ]] guard)
    since /etc/cron.d is on the ephemeral container layer and is lost on
    container recreation; the service runs on every start anyway

Dockerfile: add -e SMB_SCAN_PASSWORD and -p 445:445 to the quick-test comment

ansipa-fetch-alerts.sh: replace $NEW && log with [[ "$NEW" == true ]] && log
  to avoid set -e ambiguity with the 'false' builtin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:13:53 +02:00
Amir Alexander Abdelbaki 11e66dbddd feat(freeipa): scan result reporting, alert notifications, and SMB share
Container (ansipa image):
- Add samba + cronie to Dockerfile; expose ports 445/139
- ansipa-smb-setup.sh: idempotent setup of smbd + scanupload user +
  /data/scan-results/{archive,alerts}/ on every container start
- ansipa-smb.service: runs setup before smb.service on each boot
- ansipa-check-scans.sh: hourly cron on server; analyses archive logs for
  ClamAV/rkhunter/chkrootkit findings and writes <host>/<date>.alert files
- docker-compose.yml: add SMB_SCAN_PASSWORD env var + port mappings
- .env.example: document SMB_SCAN_PASSWORD

Client (policy-security-scan):
- Scan script now uploads log to //ipa-server/ansipa-scans/archive/<host>/
  via smbclient after each run

Client (policy-scan-notify — new policy group):
- ansipa-fetch-alerts.sh: root timer (10 min) downloads alerts from SMB into
  ~/administration/<hostname>/ for each active login session; deletes server
  alert when user removes local file (acknowledgment)
- ansipa-scan-notify.sh: user daemon started via /etc/profile.d/ansipa-notify.sh;
  sends notify-send every 10 min while *.alert files remain in ~/administration/
- deploy-ansipa-policies.yml: installs samba-client, deploys SMB creds file
  (/etc/ansipa-smb.creds, 0600), and deploys both notification scripts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 12:32:21 +02:00
Amir Alexander Abdelbaki fb8ca498ef feat(freeipa): add AppArmor deny profiles to binary blocking policy
Binary blocking now applies two layers:
  1. PATH-priority wrapper in /usr/local/bin/ (existing)
  2. Empty AppArmor profile in /etc/apparmor.d/ loaded in enforce mode

An empty AppArmor profile denies all access — the blocked binary cannot
load shared libraries and exits immediately with a permission error,
covering callers that use absolute paths and bypassed the wrapper.

AppArmor layer is skipped silently when apparmor_parser is not present,
and deferred with a warning if the real binary is not yet installed.
Profiles are unloaded and deleted when the host leaves the policy group.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 12:00:55 +02:00
Amir Alexander Abdelbaki 45fd7e5d36 feat(freeipa): add policy enforcement for binary blocking, backups, scans, and sudo
Introduces a FreeIPA host-group-driven policy system alongside a sudo
rules management playbook:

- ansipa-enforce-policies.sh: client-side enforcer (systemd timer, 30 min)
  - policy-block-binary-<name>: PATH-priority wrapper blocks the binary
  - policy-timeshift-backup: daily Timeshift snapshot cron (03:00)
  - policy-security-scan: daily ClamAV/rkhunter/chkrootkit cron (02:00)
  Policies are reversible — leaving a group removes enforcement on next run.

- deploy-ansipa-policies.yml: deploys enforcer + systemd service/timer to clients

- manage-sudo-rules.yml: creates FreeIPA sudo rules (allow_sudoers,
  allow_sudo_nopasswd) that SSSD clients already pick up via --sudo enrollment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 11:34:09 +02:00
24 changed files with 1804 additions and 87 deletions

View File

@ -71,8 +71,11 @@ pam-u2f
btop
fastfetch
openssh
libfido2
```
`libfido2` is included to support FIDO2 / token-based LUKS unlock in the system reset mode (see below).
These are added on top of the standard Arch `releng` package set.
---
@ -93,7 +96,10 @@ install-arch auto # automated mode (reads /answerfile.json)
### `/root/launch.sh`
Internal dispatcher used by `install-arch`.
Internal dispatcher used by `install-arch`. After keymap selection, it prompts for one of two actions:
- **Install** — runs the normal guided or automated installer.
- **Reset** — runs `setup/reset-arch.sh` (see [System Reset Mode](#system-reset-mode)).
### `/answerfile.json`
@ -101,6 +107,48 @@ Only present when built with `--preconf`. Both installer scripts check for this
---
## System Reset Mode
`setup/reset-arch.sh` performs a non-destructive system reinstall from the live environment, keeping user home data and authentication keys intact.
### What it does
1. Detects LUKS2 encryption on the selected partition and unlocks it:
- **Option 1** (recommended): tries the enrolled FIDO2/TPM2 token first, falls back to passphrase.
- **Option 2**: passphrase only.
- **Option 3**: enrolled token only.
2. Snapshots `/etc` credentials and config from the existing `@` subvolume.
3. Wipes `~/.config` from `@home` for all users, **preserving** `~/.config/Yubico/` so FIDO2 PAM login continues to work after reset.
4. Deletes and recreates the `@` (root) Btrfs subvolume.
5. Reinstalls base system packages via `pacstrap`.
6. Restores `passwd`, `shadow`, `pam.d`, `sudoers`, `fstab`, `mkinitcpio.conf`, and GRUB config from the snapshot.
7. Regenerates initramfs and GRUB menu from chroot.
### How to run it
Boot from the ISO. At the action prompt, select **Reset**.
The reset mode is also available standalone on any live Arch environment:
```bash
bash /path/to/setup/reset-arch.sh
```
### What is preserved
| Data | Preserved |
|------|-----------|
| User home directories (`/home/*`) | Yes |
| User passwords (`/etc/shadow`) | Yes |
| FIDO2 keys (`~/.config/Yubico/`) | Yes |
| PAM configuration | Yes |
| sudoers rules | Yes |
| fstab, mkinitcpio config | Yes |
| App configs (`~/.config/*`) | **No** (wiped except Yubico) |
| Installed packages | **No** (reinstalled from base) |
---
## Automated Deployment Workflow
```

View File

@ -1,6 +1,6 @@
# FreeIPA & Ansible
The FreeIPA/Ansible system provides centralised identity management for a fleet of Arch Linux machines: single sign-on, host-group-driven package and module deployment, LUKS backup key collection, and automatic Keycloak configuration.
The FreeIPA/Ansible system provides centralised identity management for a fleet of Arch Linux machines: single sign-on, host-group-driven package and module deployment, policy enforcement, LUKS backup key collection, scan-result aggregation, and automatic Keycloak configuration.
All relevant files live under `setup/modules/FreeipaAnsible/`.
@ -9,54 +9,140 @@ All relevant files live under `setup/modules/FreeipaAnsible/`.
## Architecture
```
┌────────────────────────────────────┐
│ FreeIPA Server │
│ (can run in Docker / LXC) │
│ │
│ • User/host directory │
│ • Kerberos KDC │
│ • DNS (optional) │
│ • Host group management │
└──────────┬─────────────────────────┘
│ SSSD / Kerberos
┌────────────────────────────────────┐
│ Enrolled client machine │
│ │
│ • sssd — authentication │
│ • ipa CLI — host group queries │
│ • Ansible-deployed timers │
│ ├── package installer │
│ ├── module installer │
│ ├── Flatpak installer │
│ └── baseuser group sync │
└────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ FreeIPA + Keycloak + Samba container (docker-compose) │
│ │
│ • FreeIPA — user/host directory, Kerberos KDC, DNS (optional) │
│ • Keycloak — OIDC provider federating FreeIPA via LDAP │
│ • Samba — two SMB shares for scan results and LUKS backup keys │
│ • cronie — hourly scan-result analyser (ansipa-check-scans) │
└───────────┬─────────────────────────┬────────────────────────────────┘
│ SSSD / Kerberos │ SMB (445)
▼ ▼
┌──────────────────────┐ ┌─────────────────────────────────────────┐
│ Enrolled client │ │ Ansible controller │
│ │ │ │
│ • sssd │ │ • deploy-ansipa-*.yml playbooks │
│ • ipa CLI │ │ • collect-luks-keys.yml │
│ • Ansible-deployed │ │ (uploads keys to ansipa-luks-keys │
│ timers: │ │ share as luks-upload service acct) │
│ ├── pkg installer │ └─────────────────────────────────────────┘
│ ├── module install│
│ ├── Flatpak install
│ ├── baseuser sync │
│ └── policy enforcer (every 30 min)
│ ├── binary blocking
│ ├── daemon enable/disable
│ ├── Timeshift backups
│ ├── security scans + SMB upload
│ └── alert fetch + desktop notify
└──────────────────────┘
```
---
## FreeIPA Server
## FreeIPA Server Container
### Docker / OCI Image
A pre-built Docker image is available via `setup/modules/FreeipaAnsible/image/`:
### Quick Start
```bash
cd setup/modules/FreeipaAnsible/image
cp .env.example .env
# Edit .env with your domain, admin password, realm, etc.
$EDITOR .env # set all required variables
docker compose up -d
docker compose logs -f freeipa # watch first-boot (~10 min)
# Once freeipa is healthy:
./keycloak-configure.sh # wire Keycloak → FreeIPA LDAP
```
The container runs `ipa-first-boot.sh` on first start to initialise the IPA instance, then optionally `keycloak-configure.sh` to wire up Keycloak as an OIDC provider.
### Interactive Server Setup
To run FreeIPA without Keycloak:
```bash
bash setup/modules/optional-Modules/apps/freeipa-server.sh
docker compose up -d freeipa
```
Prompts for realm, domain, admin password, and whether to generate client-install scripts.
### Environment Variables (`.env`)
| Variable | Required | Description |
|----------|----------|-------------|
| `IPA_HOSTNAME` | yes | FQDN of the IPA server (e.g. `ipa.corp.example.com`) |
| `IPA_DOMAIN` | yes | DNS domain (e.g. `corp.example.com`) |
| `IPA_REALM` | — | Kerberos realm; defaults to `IPA_DOMAIN` uppercased |
| `IPA_ADMIN_PASSWORD` | yes | `admin` account password |
| `IPA_DM_PASSWORD` | yes | Directory Manager password |
| `IPA_SETUP_DNS` | — | `true` to enable integrated DNS (default `false`) |
| `IPA_DNS_FORWARDER` | — | Upstream DNS when `IPA_SETUP_DNS=true` |
| `IPA_SETUP_KRA` | — | `true` to enable the Key Recovery Authority |
| `SMB_SCAN_PASSWORD` | yes | Password for the `scanupload` Samba service account |
| `LUKS_KEY_UPLOAD_PASSWORD` | yes | Password for the `luks-upload` Samba service account |
| `KC_HOSTNAME` | yes | Public hostname of Keycloak |
| `KC_REALM` | — | Keycloak realm name (default `corp`) |
| `KC_ADMIN` | — | Keycloak admin username (default `admin`) |
| `KC_ADMIN_PASSWORD` | yes | Keycloak admin password |
| `KC_DB_PASSWORD` | yes | PostgreSQL password for Keycloak |
| `IPA_BIND_DN` | — | LDAP bind DN for Keycloak federation (default: Directory Manager) |
| `IPA_BIND_PASSWORD` | — | LDAP bind password; leave blank to reuse `IPA_DM_PASSWORD` |
| `IPA_USE_LDAPS` | — | `true` to use LDAPS for Keycloak federation |
### Exposed Ports
| Port | Protocol | Service |
|------|----------|---------|
| 389 | TCP | LDAP |
| 636 | TCP | LDAPS |
| 88 | TCP + UDP | Kerberos |
| 464 | TCP + UDP | kpasswd |
| 443 | TCP | HTTPS (IPA web UI) |
| 445 | TCP | SMB (Samba shares) |
| 139 | TCP | NetBIOS session (Samba) |
| 137 | UDP | NetBIOS name service |
| 138 | UDP | NetBIOS datagram |
| 8080 | TCP | Keycloak HTTP |
| 8443 | TCP | Keycloak HTTPS |
### Container Internals
On first start, `ipa-first-boot.service` runs `ipa-first-boot.sh` to initialise the FreeIPA instance. On every start, `ansipa-smb.service` runs `ansipa-smb-setup.sh` to configure Samba (the container rootfs is ephemeral — Samba config and users must be re-applied after restarts).
Data persisted to the `/data` volume:
```
/data/
├── samba/
│ ├── passdb.tdb # Samba password database (survives restarts)
│ └── ansipa-smb.env # Persisted SMB passwords (auto-written on first start)
├── scan-results/
│ ├── archive/<host>/ # Client scan logs (written by clients via SMB)
│ └── alerts/<host>/ # Alert files generated by ansipa-check-scans (hourly)
└── luks-keys/ # LUKS backup keys (written by Ansible controller via SMB)
```
---
## SMB Shares
The container exposes two Samba shares, both configured by `ansipa-smb-setup.sh`.
### `ansipa-scans` — Scan Result Archive
- **Path:** `/data/scan-results`
- **Authenticated user:** `scanupload` (write-only; no browse)
- **Purpose:** Clients enrolled in `policy-security-scan` upload their daily ClamAV / rkhunter / chkrootkit logs here after each scan run.
- **Analysis:** `ansipa-check-scans.sh` runs hourly via cronie; it reads each host's archive logs and writes `*.alert` files to the `alerts/` subdirectory when concerning patterns are found.
- **Credentials file on clients:** `/etc/ansipa-smb.creds` (deployed by `deploy-ansipa-policies.yml`)
### `ansipa-luks-keys` — LUKS Backup Key Store
- **Path:** `/data/luks-keys`
- **Permissions:** write-only for `luks-upload`; read-only for members of the `KeyAdmin` Linux group
- **Purpose:** The Ansible controller writes each host's LUKS backup key here after collecting it via `collect-luks-keys.yml`.
- **Access control:** Add an admin Samba user to the `KeyAdmin` group on the container:
```bash
# On the freeipa container
useradd -r -G KeyAdmin <username>
smbpasswd -a <username>
```
The `KeyAdmin` group and the `luks-upload` / `scanupload` service accounts are created by `ansipa-smb-setup.sh` on every container start.
---
@ -118,9 +204,9 @@ All playbooks live in `setup/modules/FreeipaAnsible/ansible/` and require an inv
ansible-playbook -i inventory deploy-ansipa-install.yml
```
Deploys `ansipa-install-packages.sh` + a systemd timer that runs every 30 minutes. The script queries IPA for host groups named `ansipa-install-<package>` and installs/removes packages to match.
Deploys `ansipa-install-packages.sh` + a systemd timer (every 30 min). The script queries IPA for host groups named `ansipa-install-<package>` and installs or removes packages to match.
**Group naming convention:** `ansipa-install-firefox` → installs the `firefox` package.
**Group naming convention:** `ansipa-install-firefox` → installs `firefox`.
### Deploy Module Auto-Installer
@ -129,13 +215,9 @@ ansible-playbook -i inventory deploy-ansipa-modules.yml \
[-e ansipa_user=amir]
```
Deploys `ansipa-install-modules.sh` + timer. Queries for groups named `ansipa-module-<name>` and runs the matching script from `/usr/local/lib/ansipa-modules/<name>.sh`.
Deploys `ansipa-install-modules.sh` + timer. Queries for groups named `ansipa-module-<name>` and runs the matching script from `/usr/local/lib/ansipa-modules/<name>.sh`. Module scripts are copied from `setup/modules/optional-Modules/apps/*.sh`.
Module scripts are the same ones used by `install-modules.sh` — copied from `setup/modules/optional-Modules/apps/*.sh`.
**Group naming convention:** `ansipa-module-docker` → runs `docker.sh` on the host.
Each module is applied once and stamped in `/var/lib/ansipa-modules/<name>.done`. Re-running the timer skips already-applied modules.
Each module is applied once and stamped in `/var/lib/ansipa-modules/<name>.done`.
### Deploy BaseUser Sync
@ -143,27 +225,40 @@ Each module is applied once and stamped in `/var/lib/ansipa-modules/<name>.done`
ansible-playbook -i inventory deploy-baseuser-sync.yml
```
Deploys a `systemd.path` unit that triggers whenever a user logs in. If the user is a member of the IPA `BaseUser` group, they are automatically added to the local `baseusers` group — useful for desktop permission grants.
Deploys a `systemd.path` unit that triggers on login. Users who are members of the IPA `BaseUser` group are automatically added to the local `baseusers` group.
### Deploy Policy Enforcer
```bash
ansible-playbook -i inventory deploy-ansipa-policies.yml \
-e smb_scan_password=<SMB_SCAN_PASSWORD>
```
Deploys `ansipa-enforce-policies.sh`, `ansipa-fetch-alerts.sh`, `ansipa-scan-notify.sh`, a systemd timer (every 30 min), and `/etc/ansipa-smb.creds`. Use `ansible-vault` for the password in production.
### Collect LUKS Backup Keys
```bash
ansible-playbook -i inventory collect-luks-keys.yml \
[-e luks_keys_store=/secure/location]
-e luks_smb_server=ipa.corp.example.com \
-e luks_upload_password=<LUKS_KEY_UPLOAD_PASSWORD>
```
For each enrolled host, checks for `/_LUKS_BACKUP_KEY` (placed there by the M-Archy installer when disk encryption is enabled) and fetches it to the controller as:
For each enrolled host:
1. Fetches `/_LUKS_BACKUP_KEY` from the client to a local staging directory.
2. Uploads the staged key to `//ipa-server/ansipa-luks-keys/<hostname>_LUKS_BACKUP_KEY` via `smbclient` using a temporary credentials file (`no_log`, deleted in `post_tasks`).
3. Removes the local staging copy after a successful upload.
```
<luks_keys_store>/<HOSTNAME>_LUKS_BACKUP_KEY
```
Keys on the SMB share are accessible only to `KeyAdmin` group members (see [SMB Shares](#smb-shares)).
Keys are stored with mode `0400`. The store directory is created with mode `0700`.
**Schedule automatic collection:**
**Schedule for automatic collection:**
```bash
# Add to crontab on the Ansible controller
0 3 * * * cd /path/to/playbooks && ansible-playbook -i inventory collect-luks-keys.yml
0 3 * * * cd /path/to/FreeipaAnsible/ansible && \
ansible-playbook -i inventory collect-luks-keys.yml \
-e luks_smb_server=ipa.corp.example.com \
-e luks_upload_password=<LUKS_KEY_UPLOAD_PASSWORD>
```
---
@ -176,6 +271,63 @@ Keys are stored with mode `0400`. The store directory is created with mode `0700
| `ansipa-module-<name>` | `ansipa-install-modules.sh` | Run module script once |
| `fp_install-<app>` | `ansipa-install-flatpaks.sh` | Install Flatpak app |
| `BaseUser` | `auto-add-baseuser.sh` | Add user to local `baseusers` group |
| `policy-block-binary-<name>` | `ansipa-enforce-policies.sh` | Block binary via PATH wrapper + AppArmor |
| `policy-daemon-enable-<unit>` | `ansipa-enforce-policies.sh` | `systemctl enable --now <unit>`; reverted on leave |
| `policy-daemon-disable-<unit>` | `ansipa-enforce-policies.sh` | `systemctl disable --now <unit>`; reverted on leave |
| `policy-timeshift-backup` | `ansipa-enforce-policies.sh` | Daily Timeshift snapshot at 03:00 |
| `policy-security-scan` | `ansipa-enforce-policies.sh` | Daily ClamAV + rkhunter + chkrootkit scan + SMB upload |
| `policy-scan-notify` | `ansipa-enforce-policies.sh` | Fetch server alerts, notify user every 10 min until acknowledged |
---
## Policy Enforcement
`ansipa-enforce-policies.sh` runs every 30 minutes on each enrolled client (deployed by `deploy-ansipa-policies.yml`). All policies are idempotent and reversible — leaving a host group undoes the policy on the next run.
### Binary Blocking
Adding a host to `policy-block-binary-<name>` applies two layers:
1. **PATH wrapper** — a script in `/usr/local/bin/<name>` that prints a policy message and exits 1. Takes priority over the real binary for `$PATH`-based calls.
2. **AppArmor deny profile**`/etc/apparmor.d/ansipa-block-<name>` with an empty profile, denying all file access. Blocks absolute-path calls and direct `exec()`. Skipped silently if `apparmor_parser` is not present.
Leaving the group removes both layers on the next enforcer run.
### Daemon Enable / Disable
| Group | Effect on join | Effect on leave |
|-------|---------------|-----------------|
| `policy-daemon-enable-<unit>` | `systemctl enable --now <unit>` | `systemctl disable --now <unit>` |
| `policy-daemon-disable-<unit>` | `systemctl disable --now <unit>` | `systemctl enable --now <unit>` |
The `.service` suffix is optional — it is appended automatically when the unit name contains no dot, so any systemd unit type works. If a unit appears in both enable and disable groups simultaneously, it is skipped with a warning.
State is tracked in `/var/lib/ansipa-policies/daemon-enabled` and `daemon-disabled` so revert actions are applied correctly when a host leaves a group.
### Security Scans & Alert Pipeline
```
Client (policy-security-scan)
└── daily 02:00: clamscan + rkhunter + chkrootkit
└── smbclient → //ipa-server/ansipa-scans/archive/<host>/<date>.log
IPA container (hourly cron)
└── ansipa-check-scans.sh
└── grep for FOUND / Warning / Possible rootkit / etc.
└── writes /data/scan-results/alerts/<host>/<date>.alert
Client (policy-scan-notify, every 10 min via systemd timer)
└── ansipa-fetch-alerts.sh (root)
└── downloads *.alert files → ~/administration/<host>/ per active user
└── ansipa-scan-notify.sh (user daemon, started on login via profile.d)
└── notify-send every 10 min while *.alert files remain
└── delete file to acknowledge → removed from server on next fetch
```
**Prerequisites for scan policies:**
- Add host to `ansipa-module-anti-malware` before `policy-security-scan` (installs ClamAV, rkhunter, chkrootkit).
- `samba-client` is installed automatically by `deploy-ansipa-policies.yml`.
- SMB credentials are written to `/etc/ansipa-smb.creds` (root-only, `0600`).
---
@ -190,15 +342,40 @@ Keys are stored with mode `0400`. The store directory is created with mode `0700
4. Key written to /_LUKS_BACKUP_KEY (mode 0400, root-only)
inside the encrypted Btrfs volume
Post-install (Ansible)
──────────────────────
5. collect-luks-keys.yml runs from the controller
6. Fetches /_LUKS_BACKUP_KEY from each client
7. Stores as luks-keys/<HOSTNAME>_LUKS_BACKUP_KEY (mode 0400)
on the controller
Post-install (Ansible controller)
──────────────────────────────────
5. collect-luks-keys.yml runs
6. Fetches /_LUKS_BACKUP_KEY from each client (become: yes)
7. Uploads to //ipa-server/ansipa-luks-keys/<host>_LUKS_BACKUP_KEY
as 'luks-upload' service account (write-only, no_log)
8. Local staging copy deleted after successful upload
Recovery (KeyAdmin member)
───────────────────────────
9. Connect to //ipa-server/ansipa-luks-keys with a KeyAdmin Samba account
10. Retrieve <host>_LUKS_BACKUP_KEY and use with cryptsetup
```
The backup key lives inside the encrypted partition, so it is only accessible when the disk is already unlocked. Its purpose is to allow an admin to unlock the disk for recovery without knowing the user's passphrase.
The backup key lives inside the encrypted partition and is only accessible when the disk is already unlocked. Its purpose is to allow an admin to unlock the disk for recovery without knowing the user's passphrase.
---
## Keycloak
`keycloak-configure.sh` performs the initial wiring after both FreeIPA and Keycloak containers are healthy:
1. Creates the configured realm in Keycloak.
2. Sets up an LDAP federation pointing at the FreeIPA LDAP/LDAPS endpoint.
3. Configures user and group mappers.
Run it once after the first `docker compose up`:
```bash
cd setup/modules/FreeipaAnsible/image
./keycloak-configure.sh
```
The Keycloak admin console is available at `http://<KC_HOSTNAME>:8080` (dev mode) or `https://<KC_HOSTNAME>:8443` (requires TLS cert for production `start` command).
---

View File

@ -89,6 +89,20 @@ bash ~/Dotfiles/setup/install-modules.sh
| `ssh-server` | openssh | SSH daemon with key-auth enforcement |
| `wireshark` | wireshark-qt | Packet capture and analysis GUI |
### Virtualisation & Remote Desktop
| ID | Packages | Description |
|----|---------|-------------|
| `qemu` | qemu-full · libvirt · virt-manager · virt-viewer · dnsmasq · bridge-utils · edk2-ovmf · swtpm · vde2 | Full QEMU/KVM stack with virt-manager GUI; enables libvirtd, auto-starts default NAT network, adds user to `libvirt` and `kvm` groups |
| `rdp-client` | remmina · freerdp · libvncserver | Remmina remote desktop client with RDP (FreeRDP) and VNC support |
| `lamco-rdp-server` | lamco-rdp-server (AUR) | Native Wayland RDP server written in Rust with H.264/VA-API encoding; runs as a systemd user service |
**lamco-rdp-server notes:**
- Enabled as a user service: `systemctl --user enable lamco-rdp-server.service`
- Start manually: `systemctl --user start lamco-rdp-server`
- Optional GUI tray: `lamco-rdp-server-gui`
- Requires an `xdg-desktop-portal` matching your compositor (`-hyprland`, `-wlr`, `-gnome`, `-kde`)
### Development
| ID | Packages | Description |

View File

@ -23,8 +23,9 @@ To add modules to an existing system: `bash ~/Dotfiles/setup/install-modules.sh`
- **Single source of truth for colours** — edit `colors.conf`, run `apply-theme.sh` to propagate everywhere.
- **Answerfile** — generate with `setup/generate-answerfile.sh`, place at `/answerfile.json` for a fully automated install. Passwords are never stored in it.
- **Hostname uniqueness** — the MAC address of the primary NIC is appended automatically when an answerfile hostname is set (`myhost` → `myhost-aabbccddee11`).
- **LUKS encryption** — backup key is auto-generated from `/dev/urandom`, enrolled in a second LUKS slot, written to `/_LUKS_BACKUP_KEY` (root-only, inside the encrypted container). Collectable via Ansible.
- **Custom ISO**`setup/archiso/` builds a live USB that can embed a pre-baked answerfile for zero-touch deployment.
- **LUKS encryption** — backup key is auto-generated from `/dev/urandom`, enrolled in a second LUKS slot, written to `/_LUKS_BACKUP_KEY` (root-only, inside the encrypted container). Collected by Ansible and stored on the SMB `ansipa-luks-keys` share (KeyAdmin-only read access).
- **Custom ISO**`setup/archiso/` builds a live USB that can embed a pre-baked answerfile for zero-touch deployment. The live environment also includes a **System Reset** mode that reinstalls the root subvolume while preserving home data and FIDO2 auth keys.
- **FreeIPA + Keycloak + Samba container**`setup/modules/FreeipaAnsible/image/` ships a single `docker compose up` stack: FreeIPA for identity, Keycloak for OIDC, and Samba for scan-result and LUKS-key SMB shares. Host-group-driven policies (binary blocking, daemon enable/disable, daily scans, alert delivery) are enforced on enrolled clients every 30 minutes via Ansible-deployed timers.
- **Modular** — core, shell, services, and desktop are independent components; pick only what you need.
---
@ -41,7 +42,7 @@ Full docs live in [`docs/md/`](docs/md/) (Markdown) and [`docs/html/`](docs/html
| Theming & CyberQueer palette | [theming.md](docs/md/theming.md) | [theming.html](docs/html/theming.html) |
| Optional modules & app catalogue | [modules.md](docs/md/modules.md) | [modules.html](docs/html/modules.html) |
| Custom Archiso builder | [archiso.md](docs/md/archiso.md) | [archiso.html](docs/html/archiso.html) |
| FreeIPA & Ansible | [freeipa-ansible.md](docs/md/freeipa-ansible.md) | [freeipa-ansible.html](docs/html/freeipa-ansible.html) |
| FreeIPA, Ansible, Keycloak & SMB | [freeipa-ansible.md](docs/md/freeipa-ansible.md) | [freeipa-ansible.html](docs/html/freeipa-ansible.html) |
| Editors (Neovim, Micro, Yazi) | [editors.md](docs/md/editors.md) | [editors.html](docs/html/editors.html) |
| Utilities (encrypt, ClamAV, updates) | [utilities.md](docs/md/utilities.md) | [utilities.html](docs/html/utilities.html) |

View File

@ -85,6 +85,7 @@ echo "Embedding installer scripts..."
mkdir -p "$PROFILE/airootfs/root/installer"
cp "$DOTFILES_DIR/setup/archbaseos-guided-install.sh" "$PROFILE/airootfs/root/installer/"
cp "$DOTFILES_DIR/setup/arch-autoinstall.sh" "$PROFILE/airootfs/root/installer/"
cp "$DOTFILES_DIR/setup/reset-arch.sh" "$PROFILE/airootfs/root/installer/"
chmod 755 \
"$PROFILE/airootfs/root/launch.sh" \

View File

@ -34,6 +34,19 @@ fi
loadkeys "$LIVE_KEYMAP"
# ── Action selection (skipped in auto mode) ──────────────────────────────────
if [[ "$MODE" != "auto" ]]; then
echo ""
echo "Select action:"
echo " 1) Install system (fresh install, erases disk)"
echo " 2) Reset system (wipe root, keep user data & passwords)"
read -rp "Choice [1]: " _ACTION_IDX
_ACTION_IDX="${_ACTION_IDX:-1}"
if [[ "$_ACTION_IDX" == "2" ]]; then
exec bash "$INSTALLER_DIR/reset-arch.sh"
fi
fi
case "$MODE" in
auto) exec bash "$INSTALLER_DIR/arch-autoinstall.sh" "${@:2}" ;;
guided) exec bash "$INSTALLER_DIR/archbaseos-guided-install.sh" ;;

View File

@ -2,6 +2,7 @@
git
jq
pam-u2f
libfido2
btop
fastfetch
openssh

View File

@ -0,0 +1,447 @@
#!/usr/bin/env bash
# ansipa-enforce-policies.sh — enforce FreeIPA host-group-driven policies on this client.
#
# Policies are idempotent and reversible: joining a group applies the policy;
# leaving the group removes it on the next run (every 30 min via systemd timer).
#
# Host-group naming conventions:
# policy-block-binary-<name> Block execution of <name> via two layers:
# 1. PATH-priority wrapper in /usr/local/bin/ (catches $PATH calls)
# 2. AppArmor deny profile in /etc/apparmor.d/ (catches absolute paths)
# AppArmor layer is skipped silently if apparmor_parser is not present.
# policy-daemon-enable-<unit> Ensure <unit> is enabled and running (systemctl enable --now).
# Leaving the group reverts: service is disabled and stopped.
# policy-daemon-disable-<unit> Ensure <unit> is disabled and stopped (systemctl disable --now).
# Leaving the group reverts: service is re-enabled and started.
# <unit> may omit the .service suffix; all systemd unit types work.
# If a unit appears in both enable and disable groups it is skipped.
# policy-timeshift-backup Enforce a daily Timeshift snapshot (requires timeshift installed)
# policy-security-scan Enforce daily ClamAV + rkhunter + chkrootkit scans
#
# Notes:
# - Install scan tools first: add the host to ansipa-module-anti-malware.
# - Configure Timeshift (type + target device) before enabling policy-timeshift-backup.
set -euo pipefail
LOG_TAG="ansipa-policies"
STATE_DIR="/var/lib/ansipa-policies"
BLOCK_DIR="/usr/local/bin"
APPARMOR_DIR="/etc/apparmor.d"
CRON_DIR="/etc/cron.d"
log() { echo "[$LOG_TAG] $*"; logger -t "$LOG_TAG" "$*" 2>/dev/null || true; }
warn() { echo "[$LOG_TAG][WARN] $*" >&2; logger -t "$LOG_TAG" "WARN: $*" 2>/dev/null || true; }
HOST_FQDN=$(hostname -f 2>/dev/null || hostname)
if ! command -v ipa &>/dev/null; then
warn "ipa command not found — host not enrolled in FreeIPA. Exiting."
exit 0
fi
kinit -k "host/$HOST_FQDN" &>/dev/null || true
mkdir -p "$STATE_DIR"
# ── Fetch host group membership ───────────────────────────────────────────────
RAW_GROUPS=$(ipa host-show "$HOST_FQDN" --all 2>/dev/null \
| grep -i "Member of host-groups:" | sed 's/.*: //' || true)
# ── Parse active policy groups ────────────────────────────────────────────────
ACTIVE_BLOCK_BINARIES=()
ACTIVE_DAEMON_ENABLE=()
ACTIVE_DAEMON_DISABLE=()
WANT_TIMESHIFT_BACKUP=false
WANT_SECURITY_SCAN=false
WANT_SCAN_NOTIFY=false
if [[ -n "$RAW_GROUPS" ]]; then
while IFS=',' read -ra GRP_ARRAY; do
for g in "${GRP_ARRAY[@]}"; do
g="${g// /}"
case "$g" in
policy-block-binary-*) ACTIVE_BLOCK_BINARIES+=("${g#policy-block-binary-}") ;;
policy-daemon-enable-*) ACTIVE_DAEMON_ENABLE+=("${g#policy-daemon-enable-}") ;;
policy-daemon-disable-*) ACTIVE_DAEMON_DISABLE+=("${g#policy-daemon-disable-}") ;;
policy-timeshift-backup) WANT_TIMESHIFT_BACKUP=true ;;
policy-security-scan) WANT_SECURITY_SCAN=true ;;
policy-scan-notify) WANT_SCAN_NOTIFY=true ;;
esac
done
done <<< "$RAW_GROUPS"
fi
log "Active policies — block-binary: ${ACTIVE_BLOCK_BINARIES[*]:-none}" \
"| daemon-enable: ${ACTIVE_DAEMON_ENABLE[*]:-none} | daemon-disable: ${ACTIVE_DAEMON_DISABLE[*]:-none}" \
"| timeshift-backup: $WANT_TIMESHIFT_BACKUP" \
"| security-scan: $WANT_SECURITY_SCAN | scan-notify: $WANT_SCAN_NOTIFY"
# ── Helpers ───────────────────────────────────────────────────────────────────
in_active_list() {
local needle="$1"
for b in "${ACTIVE_BLOCK_BINARIES[@]}"; do
[[ "$b" == "$needle" ]] && return 0
done
return 1
}
# Find the real installed binary, skipping /usr/local/bin where our wrapper lives.
find_real_binary() {
local name="$1"
for dir in /usr/bin /usr/sbin /bin /sbin /usr/local/sbin /opt/bin; do
[[ -x "$dir/$name" ]] && echo "$dir/$name" && return 0
done
return 1
}
aa_profile_file() { echo "$APPARMOR_DIR/ansipa-block-${1}"; }
# Load an AppArmor deny profile for a binary path.
# An empty AppArmor profile denies all access: the binary cannot load shared
# libraries or open any files, so it exits immediately with a permission error.
apply_apparmor_block() {
local bin="$1"
command -v apparmor_parser &>/dev/null || return 0
local bin_path
bin_path=$(find_real_binary "$bin") || {
warn "AppArmor block: real binary '$bin' not found on disk — profile skipped until it is installed."
return 0
}
local profile_file
profile_file=$(aa_profile_file "$bin")
# Write the profile only if it doesn't exist or points to a different path.
if [[ ! -f "$profile_file" ]] || ! grep -qF "$bin_path" "$profile_file" 2>/dev/null; then
log "Writing AppArmor block profile: $profile_file ($bin_path)"
cat > "$profile_file" <<PROFILE
#include <tunables/global>
# ansipa-block-policy: managed by ansipa-enforce-policies — do not edit manually.
# Deny all access so the binary cannot load libraries or run.
# To unblock manually: apparmor_parser -R $profile_file && rm $profile_file
$bin_path {
}
PROFILE
fi
# Load (or reload) the profile in enforce mode.
if ! apparmor_parser -r "$profile_file" 2>/dev/null; then
warn "apparmor_parser failed to load $profile_file — AppArmor block not active"
fi
}
# Remove the AppArmor deny profile for a binary.
remove_apparmor_block() {
local bin="$1"
command -v apparmor_parser &>/dev/null || return 0
local profile_file
profile_file=$(aa_profile_file "$bin")
[[ -f "$profile_file" ]] || return 0
if grep -q "ansipa-block-policy" "$profile_file" 2>/dev/null; then
apparmor_parser -R "$profile_file" 2>/dev/null || true
rm -f "$profile_file"
log "Removed AppArmor block profile: $bin"
fi
}
# ── Binary blocking ───────────────────────────────────────────────────────────
# Layer 1: PATH-priority wrapper in /usr/local/bin/ — blocks $PATH-based calls.
# Layer 2: AppArmor deny profile — blocks absolute-path calls and direct exec().
# Both layers use the "ansipa policy" sentinel to identify managed files.
BLOCK_STATE="$STATE_DIR/blocked-binaries"
[[ -f "$BLOCK_STATE" ]] || touch "$BLOCK_STATE"
for BIN in "${ACTIVE_BLOCK_BINARIES[@]}"; do
WRAPPER="$BLOCK_DIR/$BIN"
if [[ ! -f "$WRAPPER" ]] || ! grep -q "blocked by ansipa policy" "$WRAPPER" 2>/dev/null; then
log "Applying PATH wrapper block: $BIN"
cat > "$WRAPPER" <<WRAPPER
#!/bin/bash
# blocked by ansipa policy
echo "[$LOG_TAG] '$BIN' is blocked by system policy on this host." >&2
exit 1
WRAPPER
chmod 755 "$WRAPPER"
fi
apply_apparmor_block "$BIN"
done
# Remove blocks for binaries no longer in any active policy group.
while IFS= read -r OLD_BIN; do
[[ -z "$OLD_BIN" ]] && continue
if ! in_active_list "$OLD_BIN"; then
WRAPPER="$BLOCK_DIR/$OLD_BIN"
if [[ -f "$WRAPPER" ]] && grep -q "blocked by ansipa policy" "$WRAPPER" 2>/dev/null; then
rm -f "$WRAPPER"
log "Removed PATH wrapper block: $OLD_BIN"
fi
remove_apparmor_block "$OLD_BIN"
fi
done < "$BLOCK_STATE"
# Persist current blocked list.
if [[ ${#ACTIVE_BLOCK_BINARIES[@]} -gt 0 ]]; then
printf '%s\n' "${ACTIVE_BLOCK_BINARIES[@]}" | sort -u > "$BLOCK_STATE"
else
> "$BLOCK_STATE"
fi
# ── Timeshift daily backup ─────────────────────────────────────────────────────
TIMESHIFT_CRON="$CRON_DIR/ansipa-timeshift-backup"
if [[ "$WANT_TIMESHIFT_BACKUP" == true ]]; then
if [[ ! -f "$TIMESHIFT_CRON" ]]; then
if ! command -v timeshift &>/dev/null; then
warn "timeshift not found — add host to ansipa-module-timeshift first. Cron will be installed anyway."
fi
log "Enabling daily Timeshift backups"
cat > "$TIMESHIFT_CRON" <<'CRON'
# ansipa-policy-timeshift-backup: managed by ansipa-enforce-policies — do not edit manually.
# Timeshift must be configured on this host (type + target device) before snapshots work.
0 3 * * * root /usr/bin/timeshift --create --comments "ansipa-daily" --tags D 2>&1 | logger -t timeshift-backup
CRON
chmod 644 "$TIMESHIFT_CRON"
fi
else
if [[ -f "$TIMESHIFT_CRON" ]]; then
rm -f "$TIMESHIFT_CRON"
log "Removed Timeshift backup cron (host left policy-timeshift-backup group)"
fi
fi
# ── Security scan ─────────────────────────────────────────────────────────────
SCAN_CRON="$CRON_DIR/ansipa-security-scan"
SCAN_SCRIPT="/usr/local/bin/ansipa-security-scan.sh"
if [[ "$WANT_SECURITY_SCAN" == true ]]; then
# (Re-)write the scan script so it stays current with this version of the enforcer.
cat > "$SCAN_SCRIPT" <<'SCAN'
#!/bin/bash
# ansipa-security-scan — daily ClamAV / rkhunter / chkrootkit run + SMB upload.
# Managed by ansipa-enforce-policies — do not edit manually.
LOG=/var/log/ansipa-security-scan.log
HOSTNAME=$(hostname -f 2>/dev/null || hostname)
DATE=$(date +%Y-%m-%d)
{
echo "=== ansipa-security-scan: $DATE $HOSTNAME ==="
if command -v freshclam &>/dev/null; then
freshclam --quiet 2>/dev/null || true
fi
if command -v clamscan &>/dev/null; then
clamscan -r --infected --quiet /home /etc /tmp /var/tmp 2>/dev/null || true
fi
if command -v rkhunter &>/dev/null; then
rkhunter --update --quiet 2>/dev/null || true
rkhunter --check --skip-keypress --quiet 2>/dev/null || true
fi
if command -v chkrootkit &>/dev/null; then
chkrootkit 2>/dev/null || true
fi
echo "=== scan complete ==="
} >> "$LOG" 2>&1
# ── Upload to server SMB share ────────────────────────────────────────────────
IPA_SERVER=$(awk '/^server[[:space:]]*=/{print $3}' /etc/ipa/default.conf 2>/dev/null || echo "")
if [[ -n "$IPA_SERVER" ]] && [[ -f /etc/ansipa-smb.creds ]] && command -v smbclient &>/dev/null; then
# Create host archive dir (mkdir is idempotent; errors suppressed).
smbclient "//$IPA_SERVER/ansipa-scans" -A /etc/ansipa-smb.creds \
-c "mkdir archive; mkdir archive\\$HOSTNAME; put $LOG archive\\$HOSTNAME\\$DATE.log" \
>> "$LOG" 2>&1 \
&& echo "[ansipa] Scan results uploaded to $IPA_SERVER/ansipa-scans/archive/$HOSTNAME/$DATE.log" >> "$LOG" \
|| echo "[ansipa][WARN] SMB upload failed — results remain local at $LOG" >> "$LOG"
else
echo "[ansipa] SMB upload skipped (no credentials or smbclient not found)." >> "$LOG"
fi
SCAN
chmod 755 "$SCAN_SCRIPT"
if [[ ! -f "$SCAN_CRON" ]]; then
log "Enabling daily security scans (ClamAV / rkhunter / chkrootkit)"
cat > "$SCAN_CRON" <<'CRON'
# ansipa-policy-security-scan: managed by ansipa-enforce-policies — do not edit manually.
# Install scan tools by adding the host to the ansipa-module-anti-malware group.
0 2 * * * root /usr/local/bin/ansipa-security-scan.sh
CRON
chmod 644 "$SCAN_CRON"
fi
else
if [[ -f "$SCAN_CRON" ]]; then
rm -f "$SCAN_CRON"
rm -f "$SCAN_SCRIPT"
log "Removed security scan policy (host left policy-security-scan group)"
fi
fi
# ── Scan notification daemon ──────────────────────────────────────────────────
# policy-scan-notify:
# - Root timer (every 10 min): ansipa-fetch-alerts.sh downloads alerts from the
# server SMB share and places them in ~/administration/<hostname>/ per active user.
# - profile.d snippet: starts ansipa-scan-notify.sh as a user daemon on login;
# the daemon sends notify-send every 10 min while *.alert files remain.
# Deleting a file from ~/administration/ counts as acknowledgment.
#
# Requires: ansipa-fetch-alerts.sh and ansipa-scan-notify.sh deployed by
# deploy-ansipa-policies.yml (static scripts — not written inline here).
FETCH_SVC="/etc/systemd/system/ansipa-fetch-alerts.service"
FETCH_TIMER="/etc/systemd/system/ansipa-fetch-alerts.timer"
NOTIFY_PROFILED="/etc/profile.d/ansipa-notify.sh"
if [[ "$WANT_SCAN_NOTIFY" == true ]]; then
if [[ ! -x /usr/local/bin/ansipa-fetch-alerts.sh ]]; then
warn "ansipa-fetch-alerts.sh not found — run deploy-ansipa-policies.yml first."
fi
if [[ ! -f "$FETCH_SVC" ]]; then
log "Installing ansipa-fetch-alerts systemd service + timer"
cat > "$FETCH_SVC" <<'UNIT'
[Unit]
Description=Fetch Ansipa security alerts from the server SMB share
After=network-online.target sssd.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ansipa-fetch-alerts.sh
StandardOutput=journal
StandardError=journal
UNIT
cat > "$FETCH_TIMER" <<'UNIT'
[Unit]
Description=Periodic ansipa security alert fetch
[Timer]
OnBootSec=2min
OnUnitActiveSec=10min
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now ansipa-fetch-alerts.timer
log "ansipa-fetch-alerts.timer enabled"
fi
if [[ ! -f "$NOTIFY_PROFILED" ]]; then
log "Installing /etc/profile.d/ansipa-notify.sh"
cat > "$NOTIFY_PROFILED" <<'PROFILED'
# ansipa-notify: launch the scan alert notification daemon on login.
# Managed by ansipa-enforce-policies — do not edit manually.
_NOTIFY_DAEMON=/usr/local/bin/ansipa-scan-notify.sh
if [[ -x "$_NOTIFY_DAEMON" ]] && \
! pgrep -u "$(id -u)" -f "ansipa-scan-notify" >/dev/null 2>&1; then
"$_NOTIFY_DAEMON" &
disown
fi
unset _NOTIFY_DAEMON
PROFILED
chmod 644 "$NOTIFY_PROFILED"
fi
else
if [[ -f "$FETCH_TIMER" ]]; then
systemctl disable --now ansipa-fetch-alerts.timer 2>/dev/null || true
rm -f "$FETCH_SVC" "$FETCH_TIMER"
systemctl daemon-reload
log "Removed ansipa-fetch-alerts timer (host left policy-scan-notify group)"
fi
if [[ -f "$NOTIFY_PROFILED" ]]; then
rm -f "$NOTIFY_PROFILED"
log "Removed /etc/profile.d/ansipa-notify.sh"
fi
fi
# ── Daemon enable / disable ───────────────────────────────────────────────────
# policy-daemon-enable-<unit>: ensure the unit is enabled and running.
# Leaving the group reverts: unit is disabled and stopped.
# policy-daemon-disable-<unit>: ensure the unit is disabled and stopped.
# Leaving the group reverts: unit is re-enabled and started.
# <unit> may omit the .service suffix; systemd accepts both forms.
# Conflicts (unit in both lists): logged as a warning, unit is left untouched.
DAEMON_ENABLE_STATE="$STATE_DIR/daemon-enabled"
DAEMON_DISABLE_STATE="$STATE_DIR/daemon-disabled"
[[ -f "$DAEMON_ENABLE_STATE" ]] || touch "$DAEMON_ENABLE_STATE"
[[ -f "$DAEMON_DISABLE_STATE" ]] || touch "$DAEMON_DISABLE_STATE"
# Append .service only when the name has no unit-type suffix already.
_svc_unit() { [[ "$1" == *.* ]] && echo "$1" || echo "${1}.service"; }
_in_enable_list() { local n="$1"; for s in "${ACTIVE_DAEMON_ENABLE[@]}"; do [[ "$s" == "$n" ]] && return 0; done; return 1; }
_in_disable_list() { local n="$1"; for s in "${ACTIVE_DAEMON_DISABLE[@]}"; do [[ "$s" == "$n" ]] && return 0; done; return 1; }
# Apply enable policies
for _SVC in "${ACTIVE_DAEMON_ENABLE[@]}"; do
if _in_disable_list "$_SVC"; then
warn "Conflict: '$_SVC' is in both daemon-enable and daemon-disable groups — skipped"
continue
fi
_UNIT=$(_svc_unit "$_SVC")
_EN=$(systemctl is-enabled "$_UNIT" 2>/dev/null || echo "not-found")
_AC=$(systemctl is-active "$_UNIT" 2>/dev/null || echo "inactive")
if [[ "$_EN" != "enabled" || "$_AC" != "active" ]]; then
log "Enabling service: $_UNIT (enabled=$_EN active=$_AC)"
systemctl enable --now "$_UNIT" 2>/dev/null \
&& log "Service enabled: $_UNIT" \
|| warn "Failed to enable $_UNIT — unit may not exist on this host"
fi
done
# Apply disable policies
for _SVC in "${ACTIVE_DAEMON_DISABLE[@]}"; do
if _in_enable_list "$_SVC"; then
continue # conflict already warned above
fi
_UNIT=$(_svc_unit "$_SVC")
_EN=$(systemctl is-enabled "$_UNIT" 2>/dev/null || echo "not-found")
_AC=$(systemctl is-active "$_UNIT" 2>/dev/null || echo "inactive")
if [[ "$_EN" == "enabled" || "$_AC" == "active" ]]; then
log "Disabling service: $_UNIT (enabled=$_EN active=$_AC)"
systemctl disable --now "$_UNIT" 2>/dev/null \
&& log "Service disabled: $_UNIT" \
|| warn "Failed to disable $_UNIT — unit may not exist on this host"
fi
done
# Revert: host left a daemon-enable group → disable and stop the service
while IFS= read -r _OLD; do
[[ -z "$_OLD" ]] && continue
if ! _in_enable_list "$_OLD"; then
_UNIT=$(_svc_unit "$_OLD")
log "Reverting enable policy: disabling $_UNIT (host left daemon-enable group)"
systemctl disable --now "$_UNIT" 2>/dev/null \
|| warn "Failed to disable (revert) $_UNIT"
fi
done < "$DAEMON_ENABLE_STATE"
# Revert: host left a daemon-disable group → re-enable and start the service
while IFS= read -r _OLD; do
[[ -z "$_OLD" ]] && continue
if ! _in_disable_list "$_OLD"; then
_UNIT=$(_svc_unit "$_OLD")
log "Reverting disable policy: enabling $_UNIT (host left daemon-disable group)"
systemctl enable --now "$_UNIT" 2>/dev/null \
|| warn "Failed to enable (revert) $_UNIT"
fi
done < "$DAEMON_DISABLE_STATE"
# Persist current state
if [[ ${#ACTIVE_DAEMON_ENABLE[@]} -gt 0 ]]; then
printf '%s\n' "${ACTIVE_DAEMON_ENABLE[@]}" | sort -u > "$DAEMON_ENABLE_STATE"
else
> "$DAEMON_ENABLE_STATE"
fi
if [[ ${#ACTIVE_DAEMON_DISABLE[@]} -gt 0 ]]; then
printf '%s\n' "${ACTIVE_DAEMON_DISABLE[@]}" | sort -u > "$DAEMON_DISABLE_STATE"
else
> "$DAEMON_DISABLE_STATE"
fi
log "Policy enforcement complete."

View File

@ -0,0 +1,128 @@
#!/usr/bin/env bash
# ansipa-fetch-alerts.sh — fetch security alerts from the server SMB share.
# Runs as root every 10 minutes via ansipa-fetch-alerts.timer (policy-scan-notify).
#
# For each alert on the server that hasn't been acknowledged yet:
# - Downloads it to ~/administration/<hostname>/ for every active login session.
# - A local file that has been deleted counts as acknowledged and is removed
# from the server alerts directory on the next run.
#
# Prerequisites:
# /etc/ansipa-smb.creds — Samba credentials file (deployed by deploy-ansipa-policies.yml)
# /etc/ipa/default.conf — FreeIPA client config (provides server hostname)
# smbclient — from the samba-client package
set -euo pipefail
LOG_TAG="ansipa-fetch-alerts"
ADMIN_SUBDIR="administration"
CREDS_FILE="/etc/ansipa-smb.creds"
SMB_SHARE="ansipa-scans"
STATE_DIR="/var/lib/ansipa-policies"
FETCHED_STATE="$STATE_DIR/fetched-alerts"
log() { echo "[$LOG_TAG] $*"; logger -t "$LOG_TAG" "$*" 2>/dev/null || true; }
warn() { echo "[$LOG_TAG][WARN] $*" >&2; logger -t "$LOG_TAG" "WARN: $*" 2>/dev/null || true; }
# ── Prerequisites ─────────────────────────────────────────────────────────────
if [[ ! -f "$CREDS_FILE" ]]; then
warn "Credentials file $CREDS_FILE not found — run deploy-ansipa-policies.yml first."
exit 0
fi
if ! command -v smbclient &>/dev/null; then
warn "smbclient not installed — install samba-client."
exit 0
fi
IPA_SERVER=$(awk '/^server[[:space:]]*=/{print $3}' /etc/ipa/default.conf 2>/dev/null || echo "")
if [[ -z "$IPA_SERVER" ]]; then
warn "Cannot read IPA server from /etc/ipa/default.conf — host enrolled?"
exit 0
fi
HOSTNAME=$(hostname -f 2>/dev/null || hostname)
mkdir -p "$STATE_DIR"
touch "$FETCHED_STATE"
smb() { smbclient "//$IPA_SERVER/$SMB_SHARE" -A "$CREDS_FILE" "$@" 2>/dev/null; }
# ── List active login sessions ────────────────────────────────────────────────
ACTIVE_USERS=()
while IFS= read -r LINE; do
USER=$(echo "$LINE" | awk '{print $3}')
[[ -z "$USER" || "$USER" == "root" ]] && continue
HOME_DIR=$(getent passwd "$USER" | cut -d: -f6) || continue
[[ -d "$HOME_DIR" ]] && ACTIVE_USERS+=("$USER:$HOME_DIR")
done < <(loginctl list-sessions --no-legend 2>/dev/null || who 2>/dev/null || true)
# Deduplicate by user.
mapfile -t ACTIVE_USERS < <(printf '%s\n' "${ACTIVE_USERS[@]}" | sort -u)
# ── List alerts on server for this host ───────────────────────────────────────
SERVER_ALERTS=()
while IFS= read -r LINE; do
# smbclient ls output: " filename.alert A 1234 date"
FILE=$(echo "$LINE" | awk '{print $1}')
[[ "$FILE" == *.alert ]] && SERVER_ALERTS+=("$FILE")
done < <(smb -c "ls alerts\\$HOSTNAME\\" 2>/dev/null || true)
# ── Check for locally deleted alerts (acknowledged) ───────────────────────────
while IFS= read -r ALERT_NAME; do
[[ -z "$ALERT_NAME" ]] && continue
# If none of the active users still have this alert file, it was acknowledged.
ALL_DELETED=true
for USER_INFO in "${ACTIVE_USERS[@]}"; do
HOME_DIR="${USER_INFO#*:}"
USER="${USER_INFO%%:*}"
LOCAL_FILE="$HOME_DIR/$ADMIN_SUBDIR/$HOSTNAME/$ALERT_NAME"
if [[ -f "$LOCAL_FILE" ]]; then
ALL_DELETED=false
break
fi
done
if [[ "$ALL_DELETED" == true ]] && [[ ${#ACTIVE_USERS[@]} -gt 0 ]]; then
log "Alert acknowledged (deleted locally): $ALERT_NAME — removing from server"
smb -c "del alerts\\$HOSTNAME\\$ALERT_NAME" 2>/dev/null || true
# Remove from state file.
sed -i "/^$ALERT_NAME\$/d" "$FETCHED_STATE" 2>/dev/null || true
fi
done < "$FETCHED_STATE"
# ── Download new/pending alerts to user home dirs ─────────────────────────────
TMP_DIR=$(mktemp -d /tmp/ansipa-alerts.XXXXXX)
trap 'rm -rf "$TMP_DIR"' EXIT
for ALERT_NAME in "${SERVER_ALERTS[@]}"; do
TMP_FILE="$TMP_DIR/$ALERT_NAME"
# Download alert content from server.
smb -c "get alerts\\$HOSTNAME\\$ALERT_NAME $TMP_FILE" 2>/dev/null || {
warn "Failed to download alert: $ALERT_NAME"
continue
}
NEW=false
for USER_INFO in "${ACTIVE_USERS[@]}"; do
HOME_DIR="${USER_INFO#*:}"
USER="${USER_INFO%%:*}"
LOCAL_DIR="$HOME_DIR/$ADMIN_SUBDIR/$HOSTNAME"
LOCAL_FILE="$LOCAL_DIR/$ALERT_NAME"
mkdir -p "$LOCAL_DIR"
chown "$USER" "$LOCAL_DIR" 2>/dev/null || true
if [[ ! -f "$LOCAL_FILE" ]]; then
cp "$TMP_FILE" "$LOCAL_FILE"
chown "$USER" "$LOCAL_FILE"
chmod 644 "$LOCAL_FILE"
NEW=true
fi
done
# Track fetched alerts so we can detect acknowledgment on the next run.
if ! grep -qx "$ALERT_NAME" "$FETCHED_STATE" 2>/dev/null; then
echo "$ALERT_NAME" >> "$FETCHED_STATE"
fi
[[ "$NEW" == true ]] && log "New alert delivered: $ALERT_NAME"
done
log "Done. ${#SERVER_ALERTS[@]} server alert(s) for $HOSTNAME."

View File

@ -0,0 +1,50 @@
#!/usr/bin/env bash
# ansipa-scan-notify.sh — user-session scan alert notification daemon.
# Started automatically on login via /etc/profile.d/ansipa-notify.sh.
#
# Behaviour:
# - Checks ~/administration/ for *.alert files every 10 minutes.
# - Sends a desktop notification (notify-send) for any unacknowledged alerts.
# - Re-notifies every 10 minutes as long as alert files remain.
# - Deleting an alert file counts as acknowledgment — notifications stop.
# - Exits when no alert files remain AND none have been seen this session,
# but keeps running once any alert is ever found (to catch future ones).
ADMIN_DIR="$HOME/administration"
NOTIFY_INTERVAL=600 # 10 minutes
ICON="security-high" # freedesktop icon name
notified_once=false
notify_alerts() {
local alerts=() file count=0
mapfile -t alerts < <(find "$ADMIN_DIR" -name "*.alert" 2>/dev/null | sort)
count=${#alerts[@]}
[[ $count -eq 0 ]] && return 0
local title body
if [[ $count -eq 1 ]]; then
local name
name=$(basename "${alerts[0]}" .alert)
title="Security alert: $name"
body="Check $ADMIN_DIR\nDelete the file to acknowledge."
else
title="$count unacknowledged security alerts"
body="Check $ADMIN_DIR\nDelete files to acknowledge."
fi
notify-send -u critical -i "$ICON" -t 0 "$title" "$body" 2>/dev/null \
|| notify-send -u critical "$title" "$body" 2>/dev/null \
|| echo "[ansipa-notify] ALERT: $title$body" >&2
notified_once=true
}
mkdir -p "$ADMIN_DIR"
while true; do
notify_alerts
sleep "$NOTIFY_INTERVAL"
done

View File

@ -1,56 +1,80 @@
---
# collect-luks-keys.yml — fetch LUKS backup keys from enrolled clients.
# collect-luks-keys.yml — fetch LUKS backup keys from enrolled clients and store them
# on the ansipa-luks-keys SMB share (accessible only to KeyAdmin group members).
#
# When a client was installed with disk encryption via the M-Archy installer,
# a backup LUKS key is stored at /_LUKS_BACKUP_KEY inside the encrypted root.
# This playbook fetches those keys to the controller and names each copy
# <HOSTNAME>_LUKS_BACKUP_KEY so they can be archived securely.
# Flow per host:
# 1. Fetch /_LUKS_BACKUP_KEY from the client to a local staging directory.
# 2. Upload the staged file to //IPA_SERVER/ansipa-luks-keys/ via smbclient.
# 3. Delete the local staging copy.
#
# Keys are stored in luks-keys/ relative to the playbook directory.
# Protect that directory carefully — keys can unlock client root partitions.
# The ansipa-luks-keys SMB share is write-only for 'luks-upload' and read-only
# for members of the 'KeyAdmin' group. Add a Samba user to KeyAdmin on the IPA
# container to grant read access:
# useradd -r -G KeyAdmin <user> && smbpasswd -a <user>
#
# Usage:
# ansible-playbook -i inventory collect-luks-keys.yml
# ansible-playbook -i inventory collect-luks-keys.yml -e luks_keys_store=/secure/path
# ansible-playbook -i inventory collect-luks-keys.yml \
# -e luks_smb_server=ipa.corp.example.com \
# -e luks_upload_password=<LUKS_KEY_UPLOAD_PASSWORD>
#
# To run automatically, add a cron job on the Ansible controller:
# 0 3 * * * cd /path/to/playbooks && ansible-playbook -i inventory collect-luks-keys.yml
# Or set defaults in group_vars / ansible-vault. The smb_server can also be
# auto-detected from /etc/ipa/default.conf on the clients.
- name: Collect LUKS backup keys from enrolled clients
- name: Collect and archive LUKS backup keys
hosts: all
become: yes
vars:
luks_key_path: /_LUKS_BACKUP_KEY
luks_keys_store: "{{ playbook_dir }}/luks-keys"
# Local staging dir — files are deleted after a successful SMB upload.
luks_keys_stage: "{{ playbook_dir }}/luks-keys-stage"
luks_smb_server: "{{ luks_smb_server | mandatory('luks_smb_server is required — use -e luks_smb_server=<IPA host>') }}"
luks_smb_share: ansipa-luks-keys
luks_upload_user: luks-upload
luks_upload_password: "{{ luks_upload_password | mandatory('luks_upload_password is required — use -e luks_upload_password=... or ansible-vault') }}"
# Temp credentials file on the controller — removed at the end of the play.
_smb_creds_file: "/tmp/.ansipa-luks-upload-{{ ansible_date_time.epoch }}.creds"
tasks:
- name: Ensure local key store directory exists
- name: Ensure local staging directory exists
file:
path: "{{ luks_keys_store }}"
path: "{{ luks_keys_stage }}"
state: directory
mode: '0700'
delegate_to: localhost
run_once: true
become: false
- name: Write temporary SMB credentials file on controller
copy:
dest: "{{ _smb_creds_file }}"
mode: '0600'
content: |
username = {{ luks_upload_user }}
password = {{ luks_upload_password }}
domain = WORKGROUP
delegate_to: localhost
run_once: true
become: false
no_log: true
- name: Check for LUKS backup key on client
stat:
path: "{{ luks_key_path }}"
register: luks_key_stat
- name: Fetch LUKS backup key to controller
- name: Fetch LUKS backup key to local staging area
fetch:
src: "{{ luks_key_path }}"
dest: "{{ luks_keys_store }}/{{ inventory_hostname }}_LUKS_BACKUP_KEY"
dest: "{{ luks_keys_stage }}/{{ inventory_hostname }}_LUKS_BACKUP_KEY"
flat: yes
when: luks_key_stat.stat.exists
register: luks_key_fetch
- name: Secure fetched key permissions
- name: Secure staged key permissions
file:
path: "{{ luks_keys_store }}/{{ inventory_hostname }}_LUKS_BACKUP_KEY"
path: "{{ luks_keys_stage }}/{{ inventory_hostname }}_LUKS_BACKUP_KEY"
mode: '0400'
delegate_to: localhost
become: false
@ -58,12 +82,49 @@
- luks_key_stat.stat.exists
- luks_key_fetch is changed
- name: Upload key to ansipa-luks-keys SMB share
shell: >
smbclient "//{{ luks_smb_server }}/{{ luks_smb_share }}"
-A "{{ _smb_creds_file }}"
-c "put {{ luks_keys_stage }}/{{ inventory_hostname }}_LUKS_BACKUP_KEY {{ inventory_hostname }}_LUKS_BACKUP_KEY"
delegate_to: localhost
become: false
when:
- luks_key_stat.stat.exists
- luks_key_fetch is changed
register: smb_upload
no_log: true
- name: Remove local staging copy after successful upload
file:
path: "{{ luks_keys_stage }}/{{ inventory_hostname }}_LUKS_BACKUP_KEY"
state: absent
delegate_to: localhost
become: false
when:
- luks_key_stat.stat.exists
- luks_key_fetch is changed
- smb_upload is succeeded
- name: Report key status
debug:
msg: >-
{{ inventory_hostname }}:
{% if luks_key_stat.stat.exists %}
key found and fetched to {{ luks_keys_store }}/{{ inventory_hostname }}_LUKS_BACKUP_KEY
{% if not luks_key_stat.stat.exists %}
no /_LUKS_BACKUP_KEY present (unencrypted install or key already removed)
{% elif luks_key_fetch is changed and smb_upload is succeeded %}
key uploaded to //{{ luks_smb_server }}/{{ luks_smb_share }}/{{ inventory_hostname }}_LUKS_BACKUP_KEY
{% elif luks_key_fetch is not changed %}
key unchanged since last collection — skipped upload
{% else %}
no /_LUKS_BACKUP_KEY present (unencrypted or already collected)
WARNING: key fetched but SMB upload failed — check smbclient output
{% endif %}
post_tasks:
- name: Remove temporary SMB credentials file
file:
path: "{{ _smb_creds_file }}"
state: absent
delegate_to: localhost
run_once: true
become: false

View File

@ -0,0 +1,115 @@
---
# deploy-ansipa-policies.yml — deploy the policy enforcement daemon to enrolled clients.
#
# Installs ansipa-enforce-policies.sh and a systemd timer that runs it every 30 minutes.
# Policies are declared by adding hosts to the following FreeIPA host groups:
#
# policy-block-binary-<name> Block execution of <name> via a PATH-priority wrapper + AppArmor
# policy-daemon-enable-<unit> Ensure <unit> is enabled and running; reverted when host leaves group
# policy-daemon-disable-<unit> Ensure <unit> is disabled and stopped; reverted when host leaves group
# policy-timeshift-backup Enforce daily Timeshift snapshots (03:00)
# policy-security-scan Enforce daily ClamAV + rkhunter + chkrootkit scans + SMB upload (02:00)
# policy-scan-notify Fetch alerts from server, notify user every 10 min until acknowledged
#
# Prerequisites:
# - Host enrolled in FreeIPA (sssd + ipa CLI available)
# - For security-scan / scan-notify: samba-client installed (handled below)
# - For security-scan / scan-notify: smb_scan_password set (use ansible-vault in production)
# - For security-scan tools: also add host to ansipa-module-anti-malware group
# - For timeshift-backup: also add host to ansipa-module-timeshift group
#
# Usage:
# ansible-playbook -i inventory deploy-ansipa-policies.yml \
# -e smb_scan_password=<password> # or use --vault-password-file
- name: Deploy FreeIPA policy enforcer
hosts: all
become: yes
vars:
smb_scan_password: "{{ smb_scan_password | mandatory('smb_scan_password is required — use -e smb_scan_password=... or ansible-vault') }}"
tasks:
- name: Install samba-client (required for scan upload and alert fetch)
package:
name: "{{ item }}"
state: present
loop:
- samba-client
ignore_errors: yes
- name: Deploy SMB credentials file
copy:
dest: /etc/ansipa-smb.creds
mode: '0600'
owner: root
group: root
content: |
username = scanupload
password = {{ smb_scan_password }}
domain = WORKGROUP
- name: Deploy policy enforcer script
copy:
src: ansipa-enforce-policies.sh
dest: /usr/local/bin/ansipa-enforce-policies.sh
mode: '0755'
- name: Deploy alert fetch script
copy:
src: ansipa-fetch-alerts.sh
dest: /usr/local/bin/ansipa-fetch-alerts.sh
mode: '0755'
- name: Deploy user notification daemon
copy:
src: ansipa-scan-notify.sh
dest: /usr/local/bin/ansipa-scan-notify.sh
mode: '0755'
- name: Create policy state directory
file:
path: /var/lib/ansipa-policies
state: directory
mode: '0700'
- name: Install policy enforcer systemd service
copy:
dest: /etc/systemd/system/ansipa-enforce-policies.service
mode: '0644'
content: |
[Unit]
Description=Enforce FreeIPA host-group policies (binary blocks, backups, scans)
After=network-online.target sssd.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ansipa-enforce-policies.sh
StandardOutput=journal
StandardError=journal
- name: Install policy enforcer systemd timer
copy:
dest: /etc/systemd/system/ansipa-enforce-policies.timer
mode: '0644'
content: |
[Unit]
Description=Periodic FreeIPA policy enforcement
[Timer]
OnBootSec=5min
OnUnitActiveSec=30min
[Install]
WantedBy=timers.target
- name: Reload systemd
command: systemctl daemon-reload
- name: Enable and start policy enforcer timer
systemd:
name: ansipa-enforce-policies.timer
enabled: yes
state: started

View File

@ -0,0 +1,96 @@
---
# manage-sudo-rules.yml — create and maintain FreeIPA sudo rules.
#
# This playbook provisions the sudo rules that enrolled clients pick up via SSSD
# (configured by the --sudo flag in freeipa-enroll.sh). Run it once when setting
# up the domain, and again whenever you add or change a rule.
#
# Default rules created:
# allow_sudoers Members of the 'sudoers' IPA group get full sudo (password required)
# allow_sudo_nopasswd Members of 'sudo-nopasswd' get full sudo (NOPASSWD)
#
# To grant a user sudo access:
# ipa group-add-member sudoers --users=<username>
# To grant passwordless sudo:
# ipa group-add-member sudo-nopasswd --users=<username>
#
# Prerequisites:
# - Active admin Kerberos ticket on the target host: kinit admin
# - ipa CLI available (run on the IPA server or any enrolled admin workstation)
#
# Usage:
# kinit admin
# ansible-playbook -i ipa-server.example.com, manage-sudo-rules.yml
# # or, if 'ipa_server' is defined in your inventory:
# ansible-playbook -i inventory manage-sudo-rules.yml
- name: Manage FreeIPA sudo rules
hosts: "{{ ipa_admin_host | default('ipa_server') }}"
become: no
vars:
sudo_rules:
- rule_name: allow_sudoers
group: sudoers
description: "Full sudo access for members of the sudoers group (password required)"
nopasswd: false
- rule_name: allow_sudo_nopasswd
group: sudo-nopasswd
description: "Full sudo access for members of sudo-nopasswd group (no password)"
nopasswd: true
tasks:
- name: Verify ipa command is available and authenticated
command: ipa ping
changed_when: false
register: ipa_ping
failed_when: ipa_ping.rc != 0
- name: Ensure IPA user groups exist for each sudo rule
shell: >
ipa group-show "{{ item.group }}" >/dev/null 2>&1 ||
ipa group-add "{{ item.group }}"
--desc="{{ item.description }}"
register: group_result
changed_when: "'Added group' in group_result.stdout"
loop: "{{ sudo_rules }}"
- name: Ensure sudo rules exist
shell: >
ipa sudorule-show "{{ item.rule_name }}" >/dev/null 2>&1 ||
ipa sudorule-add "{{ item.rule_name }}"
--desc="{{ item.description }}"
--cmdcat=all
--hostcat=all
register: rule_result
changed_when: "'Added Sudo Rule' in rule_result.stdout"
loop: "{{ sudo_rules }}"
- name: Assign groups to their sudo rules
shell: >
ipa sudorule-show "{{ item.rule_name }}" --all 2>/dev/null |
grep -q "{{ item.group }}" ||
ipa sudorule-add-user "{{ item.rule_name }}" --groups="{{ item.group }}"
register: assign_result
changed_when: "'Number of members added' in assign_result.stdout"
loop: "{{ sudo_rules }}"
- name: Set NOPASSWD (sudooption !authenticate) on passwordless rules
shell: >
ipa sudorule-show "{{ item.rule_name }}" --all 2>/dev/null |
grep -q "!authenticate" ||
ipa sudorule-add-option "{{ item.rule_name }}" --sudooption "!authenticate"
register: nopasswd_result
changed_when: "'Added option' in nopasswd_result.stdout"
loop: "{{ sudo_rules | selectattr('nopasswd', 'equalto', true) | list }}"
- name: Show configured sudo rules
command: ipa sudorule-find --all
changed_when: false
register: sudo_summary
- name: Display sudo rules summary
debug:
msg: "{{ sudo_summary.stdout_lines }}"

View File

@ -8,6 +8,18 @@ IPA_SETUP_DNS=false
IPA_DNS_FORWARDER=
IPA_SETUP_KRA=false
# ── Ansipa SMB shares ─────────────────────────────────────────────────────────
# SMB_SCAN_PASSWORD — password for 'scanupload'; deploy to clients via Ansible
# with smb_scan_password=<this value> (use ansible-vault).
# LUKS_KEY_UPLOAD_PASSWORD — password for the 'luks-upload' service account used
# by the Ansible controller to write LUKS backup keys to
# the ansipa-luks-keys share. Pass to collect-luks-keys.yml
# with -e luks_upload_password=<this value>.
# To grant read access, add a Samba user to KeyAdmin on the
# container: useradd -r -G KeyAdmin <user> && smbpasswd -a <user>
SMB_SCAN_PASSWORD=ChangeMe_ScanPass!
LUKS_KEY_UPLOAD_PASSWORD=ChangeMe_LuksUpload!
# ── Keycloak ──────────────────────────────────────────────────────────────────
KC_HOSTNAME=keycloak.corp.example.com
KC_REALM=corp

View File

@ -12,7 +12,8 @@
# -e IPA_DOMAIN=example.com \
# -e IPA_ADMIN_PASSWORD=Secret123 \
# -e IPA_DM_PASSWORD=Secret456 \
# -p 443:443 -p 389:389 -p 636:636 -p 88:88 \
# -e SMB_SCAN_PASSWORD=Secret789 \
# -p 443:443 -p 389:389 -p 636:636 -p 88:88 -p 445:445 \
# freeipa-server
#
# For production use docker-compose.yml instead.
@ -37,6 +38,8 @@ RUN dnf install -y --setopt=install_weak_deps=False \
net-tools \
rsync \
hostname \
samba \
cronie \
&& dnf clean all \
&& rm -rf /var/cache/dnf
@ -56,13 +59,20 @@ RUN systemctl mask \
COPY ipa-first-boot.sh /usr/local/sbin/ipa-first-boot.sh
COPY ipa-first-boot.service /etc/systemd/system/ipa-first-boot.service
COPY ansipa-smb-setup.sh /usr/local/sbin/ansipa-smb-setup.sh
COPY ansipa-smb.service /etc/systemd/system/ansipa-smb.service
COPY ansipa-check-scans.sh /usr/local/sbin/ansipa-check-scans.sh
RUN chmod +x /usr/local/sbin/ipa-first-boot.sh \
&& systemctl enable ipa-first-boot.service
&& chmod +x /usr/local/sbin/ansipa-smb-setup.sh \
&& chmod +x /usr/local/sbin/ansipa-check-scans.sh \
&& systemctl enable ipa-first-boot.service \
&& systemctl enable ansipa-smb.service \
&& systemctl enable smb.service nmb.service crond.service
VOLUME ["/data"]
# LDAP, LDAPS, Kerberos, kpasswd, HTTPS, DNS, NTP
EXPOSE 389 636 88/tcp 88/udp 464/tcp 464/udp 443 80 53/tcp 53/udp 123/udp
# LDAP, LDAPS, Kerberos, kpasswd, HTTPS, DNS, NTP, SMB
EXPOSE 389 636 88/tcp 88/udp 464/tcp 464/udp 443 80 53/tcp 53/udp 123/udp 445/tcp 445/udp 137/udp 138/udp 139/tcp
STOPSIGNAL SIGRTMIN+3
CMD ["/sbin/init"]

View File

@ -0,0 +1,71 @@
#!/bin/bash
# ansipa-check-scans.sh — analyse client scan logs and create alert files.
# Runs hourly via /etc/cron.d/ansipa-check-scans (installed by ansipa-smb-setup.sh).
#
# Input: /data/scan-results/archive/<hostname>/<YYYY-MM-DD>.log
# Output: /data/scan-results/alerts/<hostname>/<YYYY-MM-DD>.alert
# (created only when concerning patterns are found; client deletes to acknowledge)
SCAN_BASE="/data/scan-results"
ARCHIVE_DIR="$SCAN_BASE/archive"
ALERT_DIR="$SCAN_BASE/alerts"
LOG=/var/log/ansipa-check-scans.log
log() { printf '[%s] [ansipa-check-scans] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG"; }
# Patterns that indicate a concerning scan result (case-insensitive).
CONCERN_PATTERNS=(
"FOUND" # ClamAV: virus or trojan found
"Infected files: [^0]" # ClamAV summary with non-zero count
"Warning:" # rkhunter warning
"Possible rootkit" # rkhunter
"INFECTED" # generic
"Suspicious file" # chkrootkit
"INFECTED SOURCE" # chkrootkit
)
shopt -s nullglob
for HOST_DIR in "$ARCHIVE_DIR"/*/; do
[[ -d "$HOST_DIR" ]] || continue
HOSTNAME=$(basename "$HOST_DIR")
mkdir -p "$ALERT_DIR/$HOSTNAME"
for SCAN_LOG in "$HOST_DIR"*.log; do
[[ -f "$SCAN_LOG" ]] || continue
LOG_DATE=$(basename "$SCAN_LOG" .log)
ALERT_FILE="$ALERT_DIR/$HOSTNAME/$LOG_DATE.alert"
# Skip if we already generated an alert for this log.
[[ -f "$ALERT_FILE" ]] && continue
FINDINGS=()
for PATTERN in "${CONCERN_PATTERNS[@]}"; do
while IFS= read -r LINE; do
FINDINGS+=("$LINE")
done < <(grep -iE "$PATTERN" "$SCAN_LOG" 2>/dev/null || true)
done
# Deduplicate.
mapfile -t FINDINGS < <(printf '%s\n' "${FINDINGS[@]}" | sort -u)
if [[ ${#FINDINGS[@]} -gt 0 ]]; then
log "ALERT: $HOSTNAME / $LOG_DATE${#FINDINGS[@]} finding(s)"
{
printf '=== Ansipa Security Alert ===\n'
printf 'Host: %s\n' "$HOSTNAME"
printf 'Scan: %s\n' "$LOG_DATE"
printf 'Findings: %d\n' "${#FINDINGS[@]}"
printf '\nConcerning lines:\n'
printf ' %s\n' "${FINDINGS[@]}"
printf '\nFull log: %s\n' "$SCAN_LOG"
printf '\nTo acknowledge: delete this file on the client.\n'
printf '=== Generated: %s ===\n' "$(date)"
} > "$ALERT_FILE"
else
log "OK: $HOSTNAME / $LOG_DATE — clean"
fi
done
done
log "Check complete."

View File

@ -0,0 +1,144 @@
#!/bin/bash
# ansipa-smb-setup.sh — configure the Samba scan-results and LUKS-key shares on the IPA container.
#
# Runs on every container start via ansipa-smb.service so that smb.conf and
# Samba users are always in place after container restarts (ephemeral rootfs).
#
# Password sources (first match wins per variable):
# 1. Environment variable (first boot / explicit override)
# 2. /data/samba/ansipa-smb.env (persisted from first boot)
#
# Shares:
# ansipa-scans — write-only for 'scanupload'; clients push scan results here.
# ansipa-luks-keys — write-only for 'luks-upload' (Ansible controller);
# read for members of the 'KeyAdmin' Linux group.
# Add a Samba user to KeyAdmin to grant key-read access:
# useradd -r -G KeyAdmin <user>
# smbpasswd -a <user>
set -euo pipefail
LOG_TAG="ansipa-smb-setup"
SCAN_BASE="/data/scan-results"
LUKS_BASE="/data/luks-keys"
SMB_CONF="/etc/samba/smb.conf"
SMB_USER="scanupload"
LUKS_UPLOAD_USER="luks-upload"
KEYADMIN_GROUP="KeyAdmin"
ENV_FILE="/data/samba/ansipa-smb.env"
log() { echo "[$LOG_TAG] $*"; }
die() { echo "[$LOG_TAG][ERROR] $*" >&2; exit 1; }
# ── Resolve passwords ─────────────────────────────────────────────────────────
SMB_PASS="${SMB_SCAN_PASSWORD:-}"
LUKS_PASS="${LUKS_KEY_UPLOAD_PASSWORD:-}"
if [[ -f "$ENV_FILE" ]]; then
# shellcheck source=/dev/null
source "$ENV_FILE"
SMB_PASS="${SMB_SCAN_PASSWORD:-$SMB_PASS}"
LUKS_PASS="${LUKS_KEY_UPLOAD_PASSWORD:-$LUKS_PASS}"
fi
[[ -z "$SMB_PASS" ]] && die "SMB_SCAN_PASSWORD not set and $ENV_FILE not present. Set it in .env."
[[ -z "$LUKS_PASS" ]] && die "LUKS_KEY_UPLOAD_PASSWORD not set and $ENV_FILE not present. Set it in .env."
# ── Persist for subsequent restarts ──────────────────────────────────────────
mkdir -p "$(dirname "$ENV_FILE")"
{
printf 'SMB_SCAN_PASSWORD=%q\n' "$SMB_PASS"
printf 'LUKS_KEY_UPLOAD_PASSWORD=%q\n' "$LUKS_PASS"
} > "$ENV_FILE"
chmod 600 "$ENV_FILE"
# ── Directory structure (idempotent) ──────────────────────────────────────────
mkdir -p "$SCAN_BASE/archive" "$SCAN_BASE/alerts"
mkdir -p "$LUKS_BASE"
# ── KeyAdmin group ────────────────────────────────────────────────────────────
if ! getent group "$KEYADMIN_GROUP" &>/dev/null; then
groupadd -r "$KEYADMIN_GROUP"
log "Created group: $KEYADMIN_GROUP"
fi
# ── System users ──────────────────────────────────────────────────────────────
if ! id "$SMB_USER" &>/dev/null; then
useradd -r -s /sbin/nologin -d "$SCAN_BASE" -M "$SMB_USER"
log "Created system user: $SMB_USER"
fi
if ! id "$LUKS_UPLOAD_USER" &>/dev/null; then
useradd -r -s /sbin/nologin -d "$LUKS_BASE" -M -G "$KEYADMIN_GROUP" "$LUKS_UPLOAD_USER"
log "Created system user: $LUKS_UPLOAD_USER (member of $KEYADMIN_GROUP)"
else
# Ensure group membership is correct after container recreations
usermod -aG "$KEYADMIN_GROUP" "$LUKS_UPLOAD_USER"
fi
chown -R "$SMB_USER:$SMB_USER" "$SCAN_BASE"
chown -R "root:$KEYADMIN_GROUP" "$LUKS_BASE"
chmod 2750 "$LUKS_BASE" # setgid so new files inherit KeyAdmin group
# ── smb.conf ──────────────────────────────────────────────────────────────────
log "Writing $SMB_CONF"
cat > "$SMB_CONF" <<CONF
[global]
workgroup = WORKGROUP
server string = Ansipa Security Server
security = user
map to guest = never
# Store passdb on the persistent volume so passwords survive container restarts.
passdb backend = tdbsam:/data/samba/passdb.tdb
log file = /var/log/samba/log.%m
max log size = 50
# Disable printing subsystem entirely.
load printers = no
printing = bsd
printcap name = /dev/null
disable spoolss = yes
[ansipa-scans]
comment = Ansipa scan results — managed by ansipa-enforce-policies
path = $SCAN_BASE
valid users = $SMB_USER
read only = no
browseable = no
create mask = 0644
directory mask = 0755
force user = $SMB_USER
[ansipa-luks-keys]
comment = Ansipa LUKS backup keys — KeyAdmin read, luks-upload write only
path = $LUKS_BASE
valid users = @$KEYADMIN_GROUP
read only = yes
write list = $LUKS_UPLOAD_USER
browseable = no
create mask = 0640
directory mask = 0750
CONF
# ── Samba passwords (idempotent — smbpasswd -a adds or updates) ───────────────
_smb_set_pass() {
local user="$1" pass="$2"
log "Setting Samba password for $user"
printf '%s\n%s\n' "$pass" "$pass" | smbpasswd -a -s "$user" 2>/dev/null || \
printf '%s\n%s\n' "$pass" "$pass" | smbpasswd -s "$user" 2>/dev/null || \
log "WARN: smbpasswd returned non-zero for $user (may already be set correctly)"
}
_smb_set_pass "$SMB_USER" "$SMB_PASS"
_smb_set_pass "$LUKS_UPLOAD_USER" "$LUKS_PASS"
# ── Server-side scan checker cron (hourly, analysed on the IPA server itself) ─
# Always (re-)write: /etc/cron.d is on the ephemeral container layer and is
# lost on container recreation, so we must restore it on every start.
cat > /etc/cron.d/ansipa-check-scans <<'CRON'
# ansipa: analyze client scan logs and write alerts — managed, do not edit.
0 * * * * root /usr/local/sbin/ansipa-check-scans.sh 2>&1 | logger -t ansipa-check-scans
CRON
chmod 644 /etc/cron.d/ansipa-check-scans
log "Installed hourly scan-checker cron"
log "Samba setup complete. Share: //localhost/ansipa-scans user: $SMB_USER"

View File

@ -0,0 +1,20 @@
[Unit]
Description=Ansipa Scan Results SMB Share Setup
# Run before smb so smb.conf and the Samba user exist when smbd starts.
Before=smb.service
After=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
# SMB_SCAN_PASSWORD comes from the container environment on first boot.
# On subsequent restarts it is read from /data/samba/ansipa-smb.env by the script.
PassEnvironment=SMB_SCAN_PASSWORD
ExecStart=/usr/local/sbin/ansipa-smb-setup.sh
StandardOutput=journal
StandardError=journal
[Install]
# multi-user.target ensures this runs on every container start.
# Before=smb.service guarantees smb.conf and the Samba user exist before smbd starts.
WantedBy=multi-user.target

View File

@ -48,6 +48,7 @@ services:
IPA_SETUP_DNS: ${IPA_SETUP_DNS:-false}
IPA_DNS_FORWARDER: ${IPA_DNS_FORWARDER:-}
IPA_SETUP_KRA: ${IPA_SETUP_KRA:-false}
SMB_SCAN_PASSWORD: ${SMB_SCAN_PASSWORD:?set SMB_SCAN_PASSWORD in .env}
ports:
- "389:389"
- "636:636"
@ -56,6 +57,10 @@ services:
- "464:464"
- "464:464/udp"
- "443:443"
- "445:445"
- "139:139"
- "137:137/udp"
- "138:138/udp"
networks:
ipa-net:
ipv4_address: 172.30.0.10

View File

@ -0,0 +1,16 @@
#!/bin/bash
set -euo pipefail
# lamco-rdp-server — native Wayland RDP server (Rust, H.264, VA-API)
# Builds from AUR; requires cargo, clang, cmake, nasm (auto-pulled as makedeps)
yay -S --answerdiff None --answerclean All --noconfirm lamco-rdp-server
# Runs as a systemd user service (session-scoped, D-Bus activated)
systemctl --user enable lamco-rdp-server.service
echo "lamco-rdp-server enabled as a user service."
echo "Start it with: systemctl --user start lamco-rdp-server"
echo "Or launch the GUI tray: lamco-rdp-server-gui"
echo ""
echo "Optional: install a matching xdg-desktop-portal for your compositor:"
echo " xdg-desktop-portal-hyprland / -wlr / -gnome / -kde"

View File

@ -0,0 +1,22 @@
#!/bin/bash
set -euo pipefail
# QEMU/KVM + libvirt stack + virt-manager GUI
sudo pacman -S --noconfirm --needed \
qemu-full \
libvirt \
virt-manager \
virt-viewer \
dnsmasq \
bridge-utils \
edk2-ovmf \
swtpm \
vde2
sudo systemctl enable --now libvirtd.service
# Enable the default NAT network at libvirt startup
sudo virsh net-autostart default 2>/dev/null || true
sudo usermod -aG libvirt,kvm "$USER"
echo "QEMU/KVM installed. Log out and back in for group membership to take effect."

View File

@ -0,0 +1,10 @@
#!/bin/bash
set -euo pipefail
# Remmina + protocol plugins
sudo pacman -S --noconfirm --needed \
remmina \
freerdp \
libvncserver
echo "Remmina installed with RDP (freerdp) and VNC support."

242
setup/reset-arch.sh Executable file
View File

@ -0,0 +1,242 @@
#!/usr/bin/env bash
# reset-arch.sh — Reset the root btrfs subvolume while preserving user home data.
#
# What this does:
# 1. Detects LUKS encryption; unlocks via FIDO2 token and/or passphrase
# 2. Saves user credentials and system config from the old @ subvolume
# 3. Clears app configs (~/.config) from @home, preserving auth keys (Yubico/)
# 4. Deletes and recreates the @ (root) btrfs subvolume
# 5. Reinstalls the base system via pacstrap
# 6. Restores credentials, PAM, fstab, mkinitcpio, GRUB config
# 7. Regenerates initramfs and GRUB menu from chroot so the system boots cleanly
set -euo pipefail
TMPDIR=$(mktemp -d /tmp/arch-reset.XXXXXX)
trap 'rm -rf "$TMPDIR"' EXIT
pause() { read -rp "Press ENTER to continue..."; }
echo "======================================="
echo " M-Archy System Reset"
echo "======================================="
echo "This will:"
echo " • Delete and recreate the root (@) btrfs subvolume"
echo " • Reinstall base system packages from scratch"
echo " • Clear all user ~/.config directories (auth keys preserved)"
echo " • Preserve home directories, passwords, and FIDO2 login keys"
echo ""
# ── Required tools in live environment ──────────────────────────────────────
pacman -Syd --noconfirm cryptsetup btrfs-progs jq libfido2
# ── Drive selection ──────────────────────────────────────────────────────────
lsblk
echo ""
read -rp "Enter drive to reset (e.g., /dev/sda): " DRIVE
ROOT_PART="${DRIVE}2"
EFI_PART="${DRIVE}1"
echo ""
echo "WARNING: The root subvolume on $ROOT_PART will be DELETED and reinstalled."
echo " User home directories will be preserved."
echo " App configs (~/.config) will be wiped (Yubico auth keys excepted)."
echo ""
read -rp "Type YES to continue: " _CONFIRM
[[ "$_CONFIRM" == "YES" ]] || { echo "Aborted."; exit 1; }
# ── LUKS detection and unlock ────────────────────────────────────────────────
MAPPER_DEV="$ROOT_PART"
if cryptsetup isLuks "$ROOT_PART" 2>/dev/null; then
echo ""
echo "Partition $ROOT_PART is LUKS2-encrypted."
echo "Select unlock method:"
echo " 1) Try enrolled token (FIDO2/TPM2) first, fall back to passphrase [recommended]"
echo " 2) Passphrase only"
echo " 3) Enrolled token only (FIDO2/TPM2)"
read -rp "Choice [1]: " _UNLOCK
_UNLOCK="${_UNLOCK:-1}"
case "$_UNLOCK" in
1)
echo "Insert FIDO2 key if using one, then press ENTER..."
pause
if ! cryptsetup open --token-only "$ROOT_PART" cryptroot 2>/dev/null; then
echo "Token unlock failed — enter passphrase..."
cryptsetup open "$ROOT_PART" cryptroot
fi
;;
2)
cryptsetup open "$ROOT_PART" cryptroot
;;
3)
echo "Insert FIDO2 key and press ENTER..."
pause
cryptsetup open --token-only "$ROOT_PART" cryptroot
;;
*)
echo "Invalid choice, using passphrase..."
cryptsetup open "$ROOT_PART" cryptroot
;;
esac
MAPPER_DEV="/dev/mapper/cryptroot"
echo "Partition unlocked."
fi
# ── Detect installed kernel from EFI partition ───────────────────────────────
TMPBOOT=$(mktemp -d /tmp/arch-reset-boot.XXXXXX)
mount "$EFI_PART" "$TMPBOOT"
KERNEL_PKG="linux"
for _img in "$TMPBOOT"/vmlinuz-*; do
[[ -f "$_img" ]] && KERNEL_PKG=$(basename "$_img" | sed 's/^vmlinuz-//') && break
done
umount "$TMPBOOT"; rmdir "$TMPBOOT"
echo "Detected kernel: $KERNEL_PKG"
# ── Detect GPU from live hardware ────────────────────────────────────────────
GPU_INFO=$(lspci 2>/dev/null | grep -E "VGA|3D" || true)
GPU_PKGS=""
if echo "$GPU_INFO" | grep -qi nvidia; then GPU_PKGS="nvidia-open"
elif echo "$GPU_INFO" | grep -qi amd; then GPU_PKGS="xf86-video-amdgpu"
elif echo "$GPU_INFO" | grep -qi intel; then GPU_PKGS="xf86-video-intel"
fi
# ── Mount btrfs top-level ────────────────────────────────────────────────────
BTRFS_MNT="$TMPDIR/btrfs"
mkdir -p "$BTRFS_MNT"
mount -o subvolid=5 "$MAPPER_DEV" "$BTRFS_MNT"
# ── Save critical configuration from @ ──────────────────────────────────────
echo "Saving system configuration..."
SAVED="$TMPDIR/saved"
_save() {
local src="$BTRFS_MNT/@/etc/$1"
local dst="$SAVED/etc/$1"
[[ -e "$src" ]] || return 0
mkdir -p "$(dirname "$dst")"
cp -a "$src" "$dst"
}
_save passwd
_save shadow
_save group
_save gshadow
_save sudoers
_save sudoers.d
_save pam.d
_save hostname
_save locale.conf
_save locale.gen
_save vconsole.conf
_save fstab
_save mkinitcpio.conf
_save mkinitcpio.conf.d
_save default/grub
_save NetworkManager
# Save timezone symlink target as plain text (the symlink itself can't cross roots)
{ readlink "$BTRFS_MNT/@/etc/localtime" 2>/dev/null || echo "/usr/share/zoneinfo/UTC"; } \
> "$SAVED/timezone"
# ── Clear ~/.config in @home (preserve auth-critical subdirs) ────────────────
echo "Clearing user app configs..."
# Yubico/ holds U2F/FIDO2 PAM keys — deleting these would break FIDO2 login
PRESERVED_CONFIG_DIRS=("Yubico" "pam-u2f")
for _homedir in "$BTRFS_MNT/@home"/*/; do
[[ -d "$_homedir" ]] || continue
_user=$(basename "$_homedir")
_cfgdir="$_homedir/.config"
[[ -d "$_cfgdir" ]] || continue
echo " Clearing ~/.config for: $_user"
# Top-level files in .config
find "$_cfgdir" -mindepth 1 -maxdepth 1 ! -type d -delete
# Subdirectories, skipping preserved ones
while IFS= read -r -d '' _subdir; do
_dname=$(basename "$_subdir")
_skip=false
for _keep in "${PRESERVED_CONFIG_DIRS[@]}"; do
[[ "$_dname" == "$_keep" ]] && _skip=true && break
done
$_skip || rm -rf "$_subdir"
done < <(find "$_cfgdir" -mindepth 1 -maxdepth 1 -type d -print0)
done
# ── Delete @ and recreate fresh ──────────────────────────────────────────────
echo "Deleting root subvolume @..."
btrfs subvolume delete "$BTRFS_MNT/@"
echo "Creating fresh root subvolume @..."
btrfs subvolume create "$BTRFS_MNT/@"
# ── Mount for installation ───────────────────────────────────────────────────
umount "$BTRFS_MNT"
mount -o subvol=@ "$MAPPER_DEV" /mnt
mkdir -p /mnt/home
mount -o subvol=@home "$MAPPER_DEV" /mnt/home
mkdir -p /mnt/boot
mount "$EFI_PART" /mnt/boot
# ── Pacstrap base system ─────────────────────────────────────────────────────
echo "Reinstalling base system (this will take a while)..."
# shellcheck disable=SC2086
pacstrap /mnt \
base base-devel "$KERNEL_PKG" linux-firmware vim zsh git networkmanager grub efibootmgr \
btrfs-progs cryptsetup libfido2 pam-u2f sudo less jq $GPU_PKGS
# ── Restore saved configuration ──────────────────────────────────────────────
echo "Restoring system configuration..."
_restore() {
local src="$SAVED/etc/$1"
local dst="/mnt/etc/$1"
[[ -e "$src" ]] || return 0
mkdir -p "$(dirname "$dst")"
cp -a "$src" "$dst"
}
# Auth files — explicit permissions
[[ -f "$SAVED/etc/passwd" ]] && install -m 644 "$SAVED/etc/passwd" /mnt/etc/passwd
[[ -f "$SAVED/etc/group" ]] && install -m 644 "$SAVED/etc/group" /mnt/etc/group
[[ -f "$SAVED/etc/shadow" ]] && install -m 000 "$SAVED/etc/shadow" /mnt/etc/shadow
[[ -f "$SAVED/etc/gshadow" ]] && install -m 000 "$SAVED/etc/gshadow" /mnt/etc/gshadow
_restore sudoers
_restore sudoers.d
_restore pam.d
_restore hostname
_restore locale.conf
_restore locale.gen
_restore vconsole.conf
_restore fstab
_restore mkinitcpio.conf
_restore mkinitcpio.conf.d
_restore default/grub
_restore NetworkManager
TZ_TARGET=$(cat "$SAVED/timezone")
ln -sf "$TZ_TARGET" /mnt/etc/localtime
# ── Chroot: regenerate initramfs, GRUB menu, services ───────────────────────
echo "Finalizing inside chroot..."
arch-chroot /mnt /bin/bash <<'CHROOT_EOF'
set -euo pipefail
locale-gen
hwclock --systohc
systemctl enable NetworkManager
mkinitcpio -P
grub-mkconfig -o /boot/grub/grub.cfg
# Re-apply correct ownership for user home directories using restored UIDs
while IFS=: read -r _uname _ _uid _gid _ _home _; do
(( _uid >= 1000 )) && [[ -d "$_home" ]] && chown -R "${_uid}:${_gid}" "$_home" || true
done < /etc/passwd
CHROOT_EOF
echo ""
echo "======================================="
echo " Reset complete!"
echo "======================================="
echo " umount -R /mnt && reboot"

View File

@ -160,6 +160,9 @@ count_steps() {
[[ "$a" == *"geany"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"codeblocks"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"kate"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"rdp-client"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"lamco-rdp-server"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"qemu"* ]] && TOTAL=$(( TOTAL + 1 ))
}
# ── Answerfile ────────────────────────────────────────────────────────────────
@ -356,6 +359,10 @@ else
"geany" "Geany lightweight IDE + plugins (official)" off \
"codeblocks" "Code::Blocks C/C++ IDE (official)" off \
"kate" "Kate KDE advanced text editor (official)" off \
\
"rdp-client" "RDP Client Remmina + FreeRDP + VNC plugins" off \
"lamco-rdp-server" "Lamco RDP Server native Wayland RDP server (AUR, Rust)" off \
"qemu" "QEMU/KVM full virt stack + virt-manager GUI" off \
3>&1 1>&2 2>&3) || SELECTED_APPS=""
fi
@ -423,6 +430,9 @@ if ! $ANSWERFILE_MODE; then
[[ "$SELECTED_APPS" == *"geany"* ]] && SUMMARY+=" ✦ Geany\n"
[[ "$SELECTED_APPS" == *"codeblocks"* ]] && SUMMARY+=" ✦ Code::Blocks\n"
[[ "$SELECTED_APPS" == *"kate"* ]] && SUMMARY+=" ✦ Kate\n"
[[ "$SELECTED_APPS" == *"rdp-client"* ]] && SUMMARY+=" ✦ RDP Client (Remmina + FreeRDP)\n"
[[ "$SELECTED_APPS" == *"lamco-rdp-server"* ]] && SUMMARY+=" ✦ Lamco RDP Server (native Wayland)\n"
[[ "$SELECTED_APPS" == *"qemu"* ]] && SUMMARY+=" ✦ QEMU/KVM + virt-manager\n"
fi
dialog --backtitle "$BACKTITLE" \
@ -505,6 +515,9 @@ fi
[[ "$SELECTED_APPS" == *"geany"* ]] && run_module "Geany" "$APPS/geany.sh"
[[ "$SELECTED_APPS" == *"codeblocks"* ]] && run_module "Code::Blocks" "$APPS/codeblocks.sh"
[[ "$SELECTED_APPS" == *"kate"* ]] && run_module "Kate" "$APPS/kate.sh"
[[ "$SELECTED_APPS" == *"rdp-client"* ]] && run_module "RDP Client" "$APPS/rdp-client.sh"
[[ "$SELECTED_APPS" == *"lamco-rdp-server"* ]] && run_module "Lamco RDP Server" "$APPS/lamco-rdp-server.sh"
[[ "$SELECTED_APPS" == *"qemu"* ]] && run_module "QEMU/KVM" "$APPS/qemu.sh"
# ── Colorway (final step) ─────────────────────────────────────────────────────
# Read defaults from repo colors.conf for pre-population