Compare commits
10 Commits
da0a9e7a32
...
6f2b24c51a
| Author | SHA1 | Date |
|---|---|---|
|
|
6f2b24c51a | |
|
|
5d56984e38 | |
|
|
aced2c754e | |
|
|
63cd59fb91 | |
|
|
eb3ae766a5 | |
|
|
a84e6ac41c | |
|
|
c56c86d57b | |
|
|
11e66dbddd | |
|
|
fb8ca498ef | |
|
|
45fd7e5d36 |
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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" ;;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
git
|
||||
jq
|
||||
pam-u2f
|
||||
libfido2
|
||||
btop
|
||||
fastfetch
|
||||
openssh
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
@ -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."
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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."
|
||||
|
|
@ -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."
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue