Restructure into one folder per private repo/host, not a flat Docker/ dump

rocky-docker-lxc/, caddy-vm/, monitoring-vm/ (was missing entirely -
Grafana+Loki+Prometheus stack, refreshed and CA mount stripped same as
everything else), and automation/{ansible,automation-vm,iac}/ for the
three repos that live on the automation VM. Hawser is genuinely
deployed on all three of caddy-vm/monitoring-vm/automation-vm so it's
duplicated in each rather than picking one canonical home.

Old Ansible/ and "Infrastrucutre as Code/" content moved under
automation/ansible/old-playbooks and automation/iac/old-content as-is
for now - contents still need refreshing against the current repos and
a per-folder README, that's the next pass.
This commit is contained in:
thesabear committed 2026-07-06 02:41:53 +03:00
1 parent 08f705ffd7
commit c05c7164c8
146 files changed
+101 -84

No files matched your search

@@ -0,0 +1 @@
asupersecretpasswordfor ansiblevaultunlock
@@ -0,0 +1,3 @@
Coming Soon...
There will soon be a full write-up of the way that the Ansible Roles and Tags laid out during the VM creation via Terraform, is used to configure a VM based on very specific use cases and criteria. The way I have designed the entire workflow is condusive to having a secure Packer built ISO expanded in its capabilites simply by the addition of a new Ansible Role and Tag.
@@ -0,0 +1,8 @@
[defaults]
inventory = ./inventory.ini
roles_path = ./roles:./roles/bootstrap_roles:./roles/cis_hardening:./roles/kestra:./roles/monitoring:./roles/secure_vault:./roles/wazuh_docker
ansible_python_interpreter = /path/to/ansible-modern/bin/python3
collections_paths = /path/to/ansible-modern/lib64/python3.11/site-packages/ansible_collections:/path/to/ansible_collections:/usr/share/ansible/collections
remote_user = ansible
private_key_file = /path/to/.ssh/key_file
host_key_checking = False
@@ -0,0 +1,2 @@
---
roles_metadata: "{{ lookup('file', 'roles_metadata.yml') | from_yaml }}"
@@ -0,0 +1,14 @@
$ANSIBLE_VAULT;1.1;AES256
64373439343462336430396135326665346633343564656532636363356339326561643161353635
3636363336663638373932643830646634366336366363370a616235646239386166343964636336
66303364663462396235626566613266313561376466646631346464653564623630333137383536
3630363935333333390a633633333264323834323863623035626662326639663731323938633534
65303062303837636430346435316538336434386464373836336538633933633465376162333138
63626433643734633634363433666139653736306630393031653566666362343833363936383265
38656136636434653135633561323839656337623963393731663762333836343665323239356536
35663433316535313063323264316338353761323537393035373338376634656265363361623037
32323935333262373437373662646538613665626265626534333862616138373361666639376437
36613238336332313734316235363265636164363566326636646534646561363666333637323737
63313336623130306636383362646138656130383661633738623365663762393761306164333536
39356564636164306332333763663461343462653562343330623862363131653863333363333332
3162
@@ -0,0 +1,31 @@
# VM metadata from Terraform provisioning
vmid: id number here
vm_name: "vm name"
target_node: "hypervisor node name"
template: "template-name"
cores: 4
sockets: 1
memory: 57344
bridge: vmbr0
macaddr: "xx:x:x:x:x:x"
disk_size: 250GB
disk_storage: "storage name here"
ip: xx.xx.xx.xx
vlan: 0
vm_tags:
- packages
- hostname_network
- root_password
- disk_resize
- base_users
- firewall_ssh
- endlessh
- zsh_custom
- ssh_hardening
- banners
- file_permissions
- service_disable
- kernel_hardening
- docker_gitlab_setup
@@ -0,0 +1,32 @@
# VM metadata from Terraform provisioning
vmid: id number here
vm_name: "vm name"
target_node: "hypervisor node name"
template: "template-name"
cores: 1
sockets: 1
memory: 4096
bridge: vmbr0
macaddr: "xx:x:x:x:x:x"
disk_size: 20GB
disk_storage: "storage name here"
ip: xx.xx.xx.xx
vlan: 0
vm_tags:
- packages
- hostname_network
- root_password
- disk_resize
- base_users
- firewall_ssh
- endlessh
- zsh_custom
- vault_agent
- haproxy
- ssh_hardening
- banners
- file_permissions
- service_disable
- kernel_hardening
@@ -0,0 +1,14 @@
all:
hosts:
haproxy:
ansible_host: xx.xx.xx.xx
ansible_port: xxxx
initialized: true
service: haproxy
docker:
ansible_host: xx.xx.xx.xx
ansible_port: xxxx
initialized: true
service: docker
vars:
ansible_python_interpreter: /usr/bin/python3
@@ -0,0 +1,8 @@
all:
vars:
ansible_python_interpreter: /usr/bin/python3
hosts:
haproxy:
ansible_host: xx.xx.xx.xx
ansible_port: 22
service: haproxy
@@ -0,0 +1,29 @@
---
- name: Compliance stage
hosts: all
become: true
gather_facts: true
vars:
stage_name: compliance
vars_files:
- /path/to/Infrastrucutre as Code/ansible/roles_metadata.yml
tasks:
- name: Run roles for this stage
ansible.builtin.include_role:
name: "{{ item }}"
loop: >-
{{
vm_tags
| select('in',
roles_metadata
| dict2items
| selectattr('value.' ~ stage_name, 'defined')
| selectattr('value.' ~ stage_name)
| map(attribute='key')
| list
)
| list
}}
@@ -0,0 +1,87 @@
---
- name: Initialization stage
hosts: all
become: true
gather_facts: true
vars:
stage_name: initialization
inventory_init: "/path/to/Infrastrucutre as Code/ansible/inventory_initialization.yml"
inventory_active: "/path/to/Infrastrucutre as Code/ansible/inventory.yml"
vars_files:
- /path/to/Infrastrucutre as Code/ansible/roles_metadata.yml
- /path/to/Infrastrucutre as Code/ansible/group_vars/all/vault_token.yml
pre_tasks:
- name: Ensure active inventory exists
ansible.builtin.file:
path: "{{ inventory_active }}"
state: touch
delegate_to: localhost
run_once: true
- name: Load existing active inventory
ansible.builtin.slurp:
src: "{{ inventory_active }}"
register: active_inv_raw
delegate_to: localhost
run_once: true
- name: Parse active inventory YAML (or initialize empty)
ansible.builtin.set_fact:
active_inventory: >-
{{
(active_inv_raw.content | b64decode | from_yaml)
if (active_inv_raw.content | b64decode | from_yaml is mapping)
else {'all': {'hosts': {}}}
}}
run_once: true
tasks:
- name: Run initialization roles for uninitialized hosts
ansible.builtin.include_role:
name: "{{ item }}"
loop: >-
{{
vm_tags
| select('in',
roles_metadata
| dict2items
| selectattr('value.' ~ stage_name, 'defined')
| selectattr('value.' ~ stage_name)
| map(attribute='key')
| list
)
| list
}}
post_tasks:
- name: Append or update host in active inventory
ansible.builtin.set_fact:
active_inventory: >-
{{
active_inventory |
combine({
'all': {
'hosts': (
active_inventory.all.hosts |
combine({
inventory_hostname: {
'ansible_host': hostvars[inventory_hostname]['ansible_host'],
'ansible_port': hostvars[inventory_hostname]['ssh_port'] | default(22),
'initialized': true
}
})
)
}
}, recursive=True)
}}
delegate_to: localhost
- name: Write updated active inventory back to file
ansible.builtin.copy:
dest: "{{ inventory_active }}"
content: "{{ active_inventory | to_nice_yaml }}"
delegate_to: localhost
run_once: true
@@ -0,0 +1,29 @@
---
- name: Provisioning stage
hosts: all
become: true
gather_facts: true
vars:
stage_name: provisioning
vars_files:
- /path/to/Infrastrucutre as Code/ansible/roles_metadata.yml
tasks:
- name: Run roles for this stage
ansible.builtin.include_role:
name: "{{ item }}"
loop: >-
{{
vm_tags
| select('in',
roles_metadata
| dict2items
| selectattr('value.' ~ stage_name, 'defined')
| selectattr('value.' ~ stage_name)
| map(attribute='key')
| list
)
| list
}}
@@ -0,0 +1,9 @@
collections:
- name: community.hashi_vault
- name: community.general
- name: ansible.posix
- name: community.crypto
- name: community.dns
- name: middleware_automation.keycloak
- name: community.network
- name: community.docker
@@ -0,0 +1,15 @@
---
- name: Configure default home directory and shell for system users
block:
- name: Set default home directory and shell for system users
ansible.builtin.lineinfile:
path: /etc/default/useradd
regexp: "^{{ setting.key }}="
line: "{{ setting.key }}={{ setting.value }}"
create: yes
backup: yes
loop:
- { key: "HOME", value: "/nonexistent" }
- { key: "SHELL", value: "/sbin/nologin" }
loop_control:
loop_var: setting
@@ -0,0 +1,28 @@
- name: Expand disk and root filesystem
block:
- name: Attempt to grow partition (safe if already full)
command: growpart /dev/sda 2
register: growpart_result
ignore_errors: true
- name: Determine if partition actually grew
set_fact:
partition_grew: >-
{{ growpart_result.rc == 0 and
(growpart_result.stdout is defined and 'NOCHANGE' not in growpart_result.stdout) }}
- name: Resize LVM physical volume if needed
command: pvresize /dev/sda2
when: partition_grew
register: pvresize_result
changed_when: "'Physical volume' in pvresize_result.stdout and 'resized' in pvresize_result.stdout"
- name: Extend logical volume if needed
command: lvextend -l +100%FREE /dev/mapper/rocky-lv_root
when: partition_grew
register: lvextend_result
changed_when: lvextend_result.rc == 0 and "'0 extents found" not in lvextend_result.stderr
- name: Grow the XFS filesystem if needed
command: xfs_growfs /
when: partition_grew and (lvextend_result is changed or lvextend_result is not defined)
@@ -0,0 +1,23 @@
---
docker_old_root: "/var/lib/docker"
docker_new_root_base: "/opt/docker/docker_data"
docker_new_root: "{{ docker_new_root_base }}/docker"
docker_volumes_dir: "/opt/docker/docker_volumes"
docker_homelab_dir: "/opt/docker/homelab"
docker_volumes_owner: "user"
docker_volumes_group: "group"
git_repo_url: "[email protected]:USER/REPO.git"
git_repo_version: "main"
ssh_key_path: "/home/USER/.ssh/id_rsa"
git_repo_owner: "user"
git_repo_group: "group"
docker_daemon_conf: "/etc/docker/daemon.json"
daemon_json_template: |
{
"data-root": "{{ docker_new_root }}"
}
@@ -0,0 +1,5 @@
---
- name: restart docker
service:
name: docker
state: restarted
@@ -0,0 +1,75 @@
---
# 1. Ensure SSH key exists so git clone works
- name: Ensure .ssh directory exists
file:
path: "{{ ssh_key_path | dirname }}"
state: directory
owner: "user"
group: "user"
mode: "0700"
- name: Ensure SSH key exists (don't overwrite)
copy:
src: "{{ ssh_key_path }}"
dest: "{{ ssh_key_path }}"
owner: "user"
group: "user"
mode: "0600"
# 2. Ensure docker_data directory exists
- name: Ensure new Docker data root base exists
file:
path: "{{ docker_new_root_base }}"
state: directory
owner: root
group: root
mode: "0755"
# 3. Stop Docker ONLY if /var/lib/docker still exists
- name: Stop Docker if old root exists
service:
name: docker
state: stopped
when: docker_old_root is exists
# 4. Move /var/lib/docker → new location (idempotent)
- name: Move Docker data root to new location
command: "mv {{ docker_old_root }} {{ docker_new_root }}"
args:
creates: "{{ docker_new_root }}"
when: docker_old_root is exists
# 5. Ensure daemon.json points to new data-root
- name: Configure Docker daemon.json
copy:
dest: "{{ docker_daemon_conf }}"
content: "{{ daemon_json_template }}"
owner: root
group: root
mode: "0644"
notify: restart docker
# 6. Ensure docker_volumes exists
- name: Ensure docker_volumes directory exists
file:
path: "{{ docker_volumes_dir }}"
state: directory
owner: "{{ docker_volumes_owner }}"
group: "{{ docker_volumes_group }}"
mode: "0755"
# 7. Ensure homelab directory exists
- name: Ensure homelab directory exists
file:
path: "{{ docker_homelab_dir }}"
state: directory
owner: "{{ git_repo_owner }}"
group: "{{ git_repo_group }}"
mode: "0755"
# 8. Start Docker if it was stopped
- name: Start Docker
service:
name: docker
state: started
when: docker_new_root is exists
@@ -0,0 +1,4 @@
---
endlessh_port: 22
endlessh_repo: https://github.com/skeeto/endlessh.git
endlessh_clone_path: /usr/local/src/endlessh
@@ -0,0 +1,67 @@
---
- name: Ensure endlessh group exists
ansible.builtin.group:
name: endlessh
system: true
- name: Ensure endlessh user exists
ansible.builtin.user:
name: endlessh
group: endlessh
system: true
shell: /usr/sbin/nologin
create_home: false
- name: Ensure Endlessh repo is cloned and updated
ansible.builtin.git:
repo: "{{ endlessh_repo }}"
dest: "{{ endlessh_clone_path }}"
version: master
update: yes
register: git_result
- name: Build Endlessh if repo was updated
ansible.builtin.command: make
args:
chdir: "{{ endlessh_clone_path }}"
when: git_result.after != git_result.before
register: make_result
# no changed_when needed; we'll rely on the copy step to track changes
- name: Copy Endlessh binary to /usr/local/bin if updated
ansible.builtin.copy:
src: "{{ endlessh_clone_path }}/endlessh"
dest: /usr/local/bin/endlessh
mode: "0755"
remote_src: true
when: git_result.after != git_result.before
register: copy_result
- name: Create Endlessh config directory
ansible.builtin.file:
path: /etc/endlessh
state: directory
mode: "0755"
- name: Deploy Endlessh config
ansible.builtin.template:
src: endlessh_config.j2
dest: /etc/endlessh/config
mode: "0644"
- name: Deploy Endlessh systemd service
ansible.builtin.template:
src: endlessh.service.j2
dest: /etc/systemd/system/endlessh.service
mode: "0644"
- name: Reload systemd daemon
ansible.builtin.systemd:
daemon_reload: true
- name: Restart Endlessh if binary updated
ansible.builtin.systemd:
name: endlessh
enabled: true
state: restarted
when: copy_result is changed
@@ -0,0 +1,20 @@
[Unit]
Description=Endlessh SSH Tarpit
After=network.target
[Service]
ExecStart=/usr/local/bin/endlessh -f /etc/endlessh/config
Restart=always
User=endlessh
Group=endlessh
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
RestrictAddressFamilies=AF_INET AF_INET6
ReadWritePaths=/etc/endlessh
LockPersonality=true
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,5 @@
Port {{ endlessh_port }}
Delay 10000
MaxLineLength 32
MaxClients 4096
LogLevel 1
@@ -0,0 +1,4 @@
---
# Customize if needed; this defaults to port 22 for endlessh
endlessh_port: 22
# The SSH port will be dynamically computed as: 10000 + (vmid % 100)
@@ -0,0 +1,69 @@
---
- name: Configure firewall and SSH port
block:
- name: Fail if vmid is not defined
ansible.builtin.fail:
msg: "'vmid' must be defined to calculate dynamic SSH port"
when: vmid is not defined
- name: Calculate SSH port from vmid
ansible.builtin.set_fact:
ssh_port: "{{ 10000 + (vmid | int % 100) }}"
- name: Enable and start firewalld
ansible.builtin.service:
name: firewalld
state: started
enabled: true
- name: Set firewalld default zone to drop
ansible.posix.firewalld:
zone: public
state: enabled
permanent: yes
target: DROP
- name: Reload firewalld to apply permanent changes
ansible.builtin.command: firewall-cmd --reload
- name: Allow custom SSH port in firewalld
ansible.posix.firewalld:
port: "{{ ssh_port }}/tcp"
permanent: true
state: enabled
immediate: true
- name: Allow Endlessh port in firewalld
ansible.posix.firewalld:
port: "{{ endlessh_port }}/tcp"
permanent: true
state: enabled
immediate: true
- name: Ensure custom SSH port is set in sshd_config
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?Port\s+'
line: "Port {{ ssh_port }}"
backup: yes
validate: "/usr/sbin/sshd -T -f %s"
register: sshd_config_changed
- name: Gather SELinux facts
ansible.builtin.setup:
filter: ansible_selinux
- name: Allow SSH to use custom port in SELinux
ansible.builtin.command: >
semanage port -a -t ssh_port_t -p tcp {{ ssh_port }}
register: semanage_result
failed_when: semanage_result.rc not in [0, 1]
changed_when: semanage_result.rc == 0
when: ansible_selinux.status == "enabled"
- name: Restart and enable sshd service if needed
ansible.builtin.service:
name: sshd
state: restarted
enabled: true
when: sshd_config_changed is changed
@@ -0,0 +1,29 @@
---
- name: Set hostname and update /etc/hosts
vars:
default_domain: subdomain.domain
block:
- name: Set hostname fact
ansible.builtin.set_fact:
short_hostname: "{{ vm_name }}"
custom_hostname: "{{ vm_name }}.{{ default_domain }}"
- name: Set system hostname
ansible.builtin.hostname:
name: "{{ custom_hostname }}"
- name: Ensure /etc/hosts contains loopback entry for hostname
ansible.builtin.lineinfile:
path: /etc/hosts
regexp: '^127\.0\.1\.1\s+'
line: "127.0.1.1 {{ custom_hostname }} {{ short_hostname }}"
state: present
create: yes
- name: Ensure /etc/hosts contains primary network IP entry
ansible.builtin.lineinfile:
path: /etc/hosts
regexp: '^{{ ip }}\s+'
line: "{{ ip }} {{ custom_hostname }} {{ short_hostname }}"
state: present
create: yes
@@ -0,0 +1,10 @@
---
base_packages:
- firewalld
- policycoreutils-python-utils
- git
- make
- gcc
- cloud-utils-growpart
- lvm2
- xfsprogs
@@ -0,0 +1,32 @@
---
- name: Package management tasks
tags: [packages]
block:
- name: Clean all cached metadata
ansible.builtin.command: dnf clean all
become: true
- name: Rebuild the DNF cache
ansible.builtin.command: dnf makecache
become: true
- name: Update all packages to latest
ansible.builtin.dnf:
name: "*"
state: latest
update_cache: true
become: true
- name: Reboot if kernel was upgraded
ansible.builtin.reboot:
reboot_timeout: 120
pre_reboot_delay: 10
when: ansible_facts.packages['kernel'] is defined
become: true
- name: Install base system packages
ansible.builtin.package:
name: "{{ base_packages }}"
state: present
update_cache: true
become: true
@@ -0,0 +1,39 @@
---
- name: Set Portainer Agent installation directory
vars:
portainer_agent_dir: /opt/portainer-agent
block:
- name: Create Portainer Agent directory
become: true
ansible.builtin.file:
path: "{{ portainer_agent_dir }}"
state: directory
owner: user
group: user
mode: "0755"
- name: Create Portainer Agent docker-compose file
become: true
ansible.builtin.copy:
dest: "{{ portainer_agent_dir }}/docker-compose.yml"
owner: user
group: user
mode: "0644"
content: |
services:
agent:
image: portainer/agent:latest
container_name: portainer_agent
restart: always
ports:
- "9001:9001"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /var/lib/docker/volumes:/var/lib/docker/volumes
- name: Ensure Portainer Agent is running
become: true
community.docker.docker_compose_v2:
project_src: "{{ portainer_agent_dir }}"
state: present # ensures containers exist
recreate: always # recreates if the definition changed
@@ -0,0 +1,2 @@
---
root_password_plain: "{{ lookup('env', 'ROOT_PASSWORD') }}"
@@ -0,0 +1,16 @@
---
- name: Set root password
block:
- name: Fail if root password is not defined
fail:
msg: "The variable 'root_password_plain' must be provided (e.g., via GitLab CI/CD Variable)"
when: root_password_plain is not defined
- name: Generate root password hash
set_fact:
root_password_hash: "{{ root_password_plain | password_hash('sha512') }}"
- name: Set root user password
user:
name: root
password: "{{ root_password_hash }}"
@@ -0,0 +1,82 @@
---
- name: Customize Zsh for users
vars:
zsh_users:
- user
block:
- name: Ensure parent home directory exists (safety)
ansible.builtin.file:
path: "/home/{{ user }}"
state: directory
owner: "{{ user }}"
group: "{{ user }}"
mode: "0755"
loop: "{{ zsh_users }}"
loop_control:
loop_var: user
become: true
- name: Remove any old oh-my-zsh
ansible.builtin.file:
path: "/home/{{ user }}/.oh-my-zsh"
state: absent
loop: "{{ zsh_users }}"
loop_control:
loop_var: user
become: true
- name: Clone Oh My Zsh as root
ansible.builtin.git:
repo: "https://github.com/ohmyzsh/ohmyzsh.git"
dest: "/home/{{ user }}/.oh-my-zsh"
version: master
update: yes
force: yes
loop: "{{ zsh_users }}"
loop_control:
loop_var: user
become: true
- name: Add trusted safe.directory system-wide for Oh My Zsh
ansible.builtin.command:
cmd: git config --system --add safe.directory /home/{{ user }}/.oh-my-zsh
loop: "{{ zsh_users }}"
loop_control:
loop_var: user
become: true
- name: Install syntax-highlighting plugin
ansible.builtin.git:
repo: "https://github.com/zsh-users/zsh-syntax-highlighting.git"
dest: "/home/{{ user }}/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting"
version: master
update: yes
force: yes
loop: "{{ zsh_users }}"
loop_control:
loop_var: user
become: true
- name: Deploy customized .zshrc
ansible.builtin.template:
src: zshrc.j2
dest: "/home/{{ user }}/.zshrc"
owner: "{{ user }}"
group: "{{ user }}"
mode: "0644"
loop: "{{ zsh_users }}"
loop_control:
loop_var: user
become: true
- name: Fix ownership recursively for Oh My Zsh directory
ansible.builtin.file:
path: "/home/{{ user }}/.oh-my-zsh"
owner: "{{ user }}"
group: "{{ user }}"
recurse: yes
loop: "{{ zsh_users }}"
loop_control:
loop_var: user
become: true
@@ -0,0 +1,21 @@
export ZSH="\$HOME/.oh-my-zsh"
ZSH_THEME="robbyrussell"
plugins=(git zsh-autosuggestions zsh-syntax-highlighting)
source \$ZSH/oh-my-zsh.sh
# Where to save history and how much to keep
HISTFILE=~/.zsh_history
HISTSIZE=10000 # In-memory history
SAVEHIST=10000 # Lines to save to file
# Recommended options
setopt APPEND_HISTORY # Don’t overwrite history file; append to it
setopt SHARE_HISTORY # Share history across all sessions
setopt HIST_IGNORE_DUPS # Don’t record duplicate commands
setopt HIST_IGNORE_SPACE # Ignore commands starting with a space
setopt HIST_VERIFY # Edit a command before running from history
setopt INC_APPEND_HISTORY # Write history incrementally as commands are run
PS1='%F{51}%n%F{255}@%F{227}%m %F{84}%~%f %F{15}$ '
fastfetch
@@ -0,0 +1,11 @@
---
cis_banner: |
*********************************************************************
* WARNING: Unauthorized access to this system is prohibited. *
* All activities are monitored and recorded. *
*********************************************************************
cis_motd_banner: |
*********************************************************************
* Welcome to the system. Access is restricted to authorized users. *
*********************************************************************
@@ -0,0 +1,26 @@
---
# Login banners for /etc/motd, /etc/issue, /etc/issue.net
- name: Ensure /etc/motd contains standard CIS banner
ansible.builtin.copy:
dest: /etc/motd
owner: root
group: root
mode: "0644"
content: "{{ cis_motd_banner }}"
- name: Ensure /etc/issue contains standard CIS banner
ansible.builtin.copy:
dest: /etc/issue
owner: root
group: root
mode: "0644"
content: "{{ cis_motd_banner }}"
- name: Ensure /etc/issue.net contains standard CIS banner
ansible.builtin.copy:
dest: /etc/issue.net
owner: root
group: root
mode: "0644"
content: "{{ cis_banner }}"
@@ -0,0 +1,6 @@
---
# Parent-level defaults
cis_tag: cis_hardening
audit_rules_file: /etc/audit/rules.d/cis.rules
rsyslog_remote: "" # set to "logserver.example.com:514" to forward logs
txt_grub_args: "" # additional kernel args if needed, e.g. "audit=1"
@@ -0,0 +1,16 @@
---
# Default ownership and permissions for critical files and directories
file_permissions_list:
- { path: "/root", mode: "0700", state: "directory" }
- { path: "/etc/passwd", mode: "0644", state: "file" }
- { path: "/etc/shadow", mode: "0400", state: "file" }
- { path: "/etc/group", mode: "0644", state: "file" }
- { path: "/etc/gshadow", mode: "0400", state: "file" }
- { path: "/etc/crontab", mode: "0600", state: "file" }
- { path: "/etc/cron.hourly", mode: "0700", state: "directory" }
- { path: "/etc/cron.daily", mode: "0700", state: "directory" }
- { path: "/etc/cron.weekly", mode: "0700", state: "directory" }
- { path: "/etc/cron.monthly", mode: "0700", state: "directory" }
- { path: "/etc/cron.d", mode: "0700", state: "directory" }
- { path: "/etc/rsyslog.conf", mode: "0640", state: "file" }
@@ -0,0 +1,11 @@
---
- name: Ensure system files and directories have correct ownership and permissions
ansible.builtin.file:
path: "{{ file_item.path }}"
owner: root
group: root
mode: "{{ file_item.mode }}"
state: "{{ file_item.state }}"
loop: "{{ file_permissions_list }}"
loop_control:
loop_var: file_item
@@ -0,0 +1,4 @@
tmp_mount_options: "defaults,nosuid,nodev,noexec,relatime"
var_tmp_mount_options: "defaults,nosuid,nodev,noexec,relatime"
home_mount_options: "defaults,nosuid,nodev,relatime"
dev_shm_mount_options: "defaults,nosuid,nodev,noexec,relatime"
@@ -0,0 +1,34 @@
---
# Remount and persist secure options using defaults variables
- name: Gather mount facts
ansible.builtin.setup:
filter: ansible_mounts
- name: Ensure /tmp has secure mount options
ansible.builtin.mount:
path: /tmp
fstype: "{{ ansible_mounts | selectattr('mount','equalto','/tmp') | map(attribute='fstype') | first }}"
opts: "{{ tmp_mount_options }}"
state: mounted
- name: Ensure /var/tmp has secure mount options
ansible.builtin.mount:
path: /var/tmp
fstype: "{{ ansible_mounts | selectattr('mount','equalto','/var/tmp') | map(attribute='fstype') | first }}"
opts: "{{ var_tmp_mount_options }}"
state: mounted
- name: Ensure /home has secure mount options
ansible.builtin.mount:
path: /home
fstype: "{{ ansible_mounts | selectattr('mount','equalto','/home') | map(attribute='fstype') | first }}"
opts: "{{ home_mount_options }}"
state: mounted
- name: Ensure /dev/shm has secure mount options
ansible.builtin.mount:
path: /dev/shm
fstype: tmpfs
opts: "{{ dev_shm_mount_options }}"
state: mounted
@@ -0,0 +1,17 @@
---
- name: restart rsyslog
ansible.builtin.service:
name: rsyslog
state: restarted
- name: restart auditd
ansible.builtin.service:
name: auditd
state: restarted
- name: reload systemd
ansible.builtin.command: systemctl daemon-reexec
- name: update initramfs
ansible.builtin.command: dracut -f
when: ansible_facts['os_family'] == 'RedHat' or ansible_facts['distribution'] in ['Rocky','RedHat','CentOS']
@@ -0,0 +1,7 @@
---
# Kernel defaults (override per host if needed)
modprobe_blacklist_file: /etc/modprobe.d/cis_blacklist.conf
# Additional kernel modules to disable
blacklist_modules:
- tipc
@@ -0,0 +1,18 @@
# blacklist_module.yml
- name: Check if module {{ mod }} exists on system
ansible.builtin.shell: "modprobe -n -v {{ mod }}"
register: modprobe_check
ignore_errors: true
- name: Blacklist module {{ mod }} in {{ modprobe_blacklist_file }}
ansible.builtin.lineinfile:
path: "{{ modprobe_blacklist_file }}"
line: "install {{ mod }} /bin/false"
create: yes
state: present
when: modprobe_check.rc == 0
- name: Unload module {{ mod }} if currently loaded
ansible.builtin.shell: "modprobe -r {{ mod }}"
ignore_errors: true
when: modprobe_check.rc == 0
@@ -0,0 +1,78 @@
---
# Kernel & sysctl hardening (CIS network/kernel items)
- name: Ensure IPv4 forwarding is disabled
ansible.builtin.sysctl:
name: net.ipv4.ip_forward
value: "0"
state: present
reload: yes
- name: Ensure IPv6 forwarding is disabled
ansible.builtin.sysctl:
name: net.ipv6.conf.all.forwarding
value: "0"
state: present
reload: yes
- name: Ensure source routed packets are not accepted
ansible.builtin.sysctl:
name: net.ipv4.conf.all.accept_source_route
value: "0"
state: present
reload: yes
- name: Ensure ICMP redirects are not accepted
ansible.builtin.sysctl:
name: net.ipv4.conf.all.accept_redirects
value: "0"
state: present
reload: yes
- name: Ensure secure ICMP redirects are not accepted
ansible.builtin.sysctl:
name: net.ipv4.conf.all.secure_redirects
value: "0"
state: present
reload: yes
- name: Ensure Address Space Layout Randomization (ASLR) is enabled
ansible.builtin.sysctl:
name: kernel.randomize_va_space
value: "2"
state: present
reload: yes
- name: Ensure fs.suid_dumpable is 0 (disable core dumps)
ansible.builtin.sysctl:
name: fs.suid_dumpable
value: "0"
state: present
reload: yes
- name: Create /etc/modprobe.d/cis_blacklist.conf with common blacklists
ansible.builtin.copy:
dest: /etc/modprobe.d/cis_blacklist.conf
owner: root
group: root
mode: "0644"
content: |
# CIS blacklist
install cramfs /bin/true
install freevxfs /bin/true
install jffs2 /bin/true
install hfs /bin/true
install hfsplus /bin/true
install squashfs /bin/true
install udf /bin/true
- name: Regenerate initramfs if modprobe blacklist changed
ansible.builtin.command: dracut -f
changed_when: false
when: ansible_facts.get('os_family') == 'RedHat' or ansible_facts.get('distribution') in ['Rocky', 'RedHat', 'CentOS']
- name: Blacklist all modules
include_tasks: blacklist_module.yml
loop: "{{ blacklist_modules }}"
loop_control:
loop_var: mod
@@ -0,0 +1,9 @@
---
# Password policy defaults
pw_minlen: 14
pw_minclass: 4
pw_max_days: 365
pw_min_days: 7
pw_warn_age: 7
faillock_deny: 5
faillock_unlock_time: 900
@@ -0,0 +1,60 @@
---
# Password and PAM related hardening
- name: Ensure password hashing algorithm is SHA512
ansible.builtin.lineinfile:
path: /etc/login.defs
regexp: "^ENCRYPT_METHOD"
line: "ENCRYPT_METHOD SHA512"
create: yes
- name: Ensure PASS_MAX_DAYS is 365
ansible.builtin.lineinfile:
path: /etc/login.defs
regexp: "^PASS_MAX_DAYS"
line: "PASS_MAX_DAYS 365"
create: yes
- name: Ensure PASS_MIN_DAYS is 7
ansible.builtin.lineinfile:
path: /etc/login.defs
regexp: "^PASS_MIN_DAYS"
line: "PASS_MIN_DAYS 7"
create: yes
- name: Ensure PASS_WARN_AGE is 7
ansible.builtin.lineinfile:
path: /etc/login.defs
regexp: "^PASS_WARN_AGE"
line: "PASS_WARN_AGE 7"
create: yes
- name: Ensure pwquality minlen and requirements
ansible.builtin.lineinfile:
path: /etc/security/pwquality.conf
regexp: "^minlen"
line: "minlen = 14"
create: yes
- name: Ensure pwquality minclass (character classes) is 4
ansible.builtin.lineinfile:
path: /etc/security/pwquality.conf
regexp: "^minclass"
line: "minclass = 4"
create: yes
- name: Ensure pam_faillock preauth is configured
ansible.builtin.lineinfile:
path: /etc/pam.d/system-auth
insertafter: "^auth.*required.*pam_unix.so"
line: "auth required pam_faillock.so preauth silent audit deny=5 unlock_time=900"
state: present
backrefs: no
- name: Ensure pam_faillock authfail/account lines exist
ansible.builtin.blockinfile:
path: /etc/pam.d/system-auth
insertafter: "^auth.*required.*pam_unix.so"
block: |
auth [default=die] pam_faillock.so authfail deny=5 unlock_time=900 audit
account required pam_faillock.so
@@ -0,0 +1,10 @@
---
unused_services:
- avahi-daemon
- bluetooth
- cups
- rpcbind
- nfslock
- rsh.socket
- telnet.socket
- rsyncd
@@ -0,0 +1,10 @@
- name: Ensure list of unnecessary services are stopped and masked
ansible.builtin.service:
name: "{{ svc }}"
enabled: no
state: stopped
masked: yes
loop: "{{ unused_services }}"
loop_control:
loop_var: svc
ignore_errors: true
@@ -0,0 +1,32 @@
---
# General settings
ssh_protocol: 2
ssh_permit_root_login: "no"
ssh_password_authentication: "no"
ssh_challenge_response_authentication: "no"
ssh_kerberos_authentication: "no"
ssh_gssapi_authentication: "no"
ssh_use_pam: "yes"
PrintLastLog: "yes"
# Session security
ssh_x11_forwarding: "no"
ssh_allow_tcp_forwarding: "yes"
ssh_permit_tunnel: "no"
ssh_allow_agent_forwarding: "no"
# Idle timeout / login attempts
ssh_client_alive_interval: 300
ssh_client_alive_count_max: 3
ssh_login_grace_time: 60
ssh_max_auth_tries: 3
ssh_max_sessions: 2
ssh_max_startups: "10:30:60"
# SSH access restrictions
ssh_allow_users: "adminuser ansibleuser"
# Strong encryption
ssh_ciphers: "aes256-ctr,aes192-ctr,aes128-ctr"
ssh_macs: "hmac-sha2-512,hmac-sha2-256"
ssh_kex_algorithms: "[email protected],diffie-hellman-group-exchange-sha256"
@@ -0,0 +1,82 @@
---
- name: Ensure OpenSSH server is installed
ansible.builtin.package:
name: openssh-server
state: present
- name: Ensure SSH daemon is enabled and running
ansible.builtin.service:
name: sshd
enabled: true
state: started
- name: Backup current sshd_config
ansible.builtin.copy:
src: /etc/ssh/sshd_config
dest: /etc/ssh/sshd_config.bak
owner: root
group: root
mode: "0600"
remote_src: true
- name: Harden SSH configuration
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "{{ ssh_item.regexp }}"
line: "{{ ssh_item.line }}"
state: present
backrefs: yes
loop:
- regexp: "^#?Protocol"
line: "Protocol {{ ssh_protocol }}"
- regexp: "^#?PermitRootLogin"
line: "PermitRootLogin {{ ssh_permit_root_login }}"
- regexp: "^#?PasswordAuthentication"
line: "PasswordAuthentication {{ ssh_password_authentication }}"
- regexp: "^#?ChallengeResponseAuthentication"
line: "ChallengeResponseAuthentication {{ ssh_challenge_response_authentication }}"
- regexp: "^#?KerberosAuthentication"
line: "KerberosAuthentication {{ ssh_kerberos_authentication }}"
- regexp: "^#?GSSAPIAuthentication"
line: "GSSAPIAuthentication {{ ssh_gssapi_authentication }}"
- regexp: "^#?UsePAM"
line: "UsePAM {{ ssh_use_pam }}"
- regexp: "^#?X11Forwarding"
line: "X11Forwarding {{ ssh_x11_forwarding }}"
- regexp: "^#?AllowTcpForwarding"
line: "AllowTcpForwarding {{ ssh_allow_tcp_forwarding }}"
- regexp: "^#?PermitTunnel"
line: "PermitTunnel {{ ssh_permit_tunnel }}"
- regexp: "^#?AllowAgentForwarding"
line: "AllowAgentForwarding {{ ssh_allow_agent_forwarding }}"
- regexp: "^#?ClientAliveInterval"
line: "ClientAliveInterval {{ ssh_client_alive_interval }}"
- regexp: "^#?ClientAliveCountMax"
line: "ClientAliveCountMax {{ ssh_client_alive_count_max }}"
- regexp: "^#?LoginGraceTime"
line: "LoginGraceTime {{ ssh_login_grace_time }}"
- regexp: "^#?MaxAuthTries"
line: "MaxAuthTries {{ ssh_max_auth_tries }}"
- regexp: "^#?MaxSessions"
line: "MaxSessions {{ ssh_max_sessions }}"
- regexp: "^#?MaxStartups"
line: "MaxStartups {{ ssh_max_startups }}"
- regexp: "^#?Ciphers"
line: "Ciphers {{ ssh_ciphers }}"
- regexp: "^#?MACs"
line: "MACs {{ ssh_macs }}"
- regexp: "^#?KexAlgorithms"
line: "KexAlgorithms {{ ssh_kex_algorithms }}"
- regexp: "^#?PrintLastLog"
line: "PrintLastLog {{ PrintLastLog }}"
- regexp: '^\s*#?\s*AllowUsers'
line: "AllowUsers {{ ssh_allow_users }}"
- regexp: "^#?Banner"
line: "Banner /etc/issue.net"
loop_control:
loop_var: ssh_item
- name: Restart SSH service to apply changes
ansible.builtin.service:
name: sshd
state: restarted
@@ -0,0 +1,29 @@
---
# Parent CIS hardening role: import each subrole with single tag
- import_role:
name: cis_hardening/kernel_hardening
tags: [cis_hardening]
- import_role:
name: cis_hardening/password_policy
tags: [cis_hardening]
- import_role:
name: cis_hardening/file_permissions
tags: [cis_hardening]
- import_role:
name: cis_hardening/service_disable
tags: [cis_hardening]
- import_role:
name: cis_hardening/banners
tags: [cis_hardening]
- import_role:
name: cis_hardening/ssh_hardening
tags: [cis_hardening]
- import_role:
name: cis_hardening/filesystem_hardening
tags: [cis_hardening]
@@ -0,0 +1,55 @@
# Vault connection
vault_addr: "http://VAULT_IP:VAULT_PORT"
# Directory paths
haproxy_certs_dir: "/etc/haproxy/certs"
haproxy_cert_path: "{{ haproxy_pem_path }}"
haproxy_pem_path: "{{ haproxy_certs_dir }}/haproxy.pem"
haproxy_ca_path: "{{ haproxy_certs_dir }}/haproxy_ca.crt"
# Systemd service names
haproxy_service: "haproxy"
# HAProxy tuning
haproxy_global_maxconn: 4096
haproxy_timeout_connect_ms: 5000
haproxy_timeout_client_ms: 60000
haproxy_timeout_server_ms: 60000
haproxy_enable_hsts: true
vault_agent_templates:
- src: "haproxy.pem.ctmpl"
dest: "{{ haproxy_pem_path }}"
service: "haproxy"
- src: "haproxy_ca.ctmpl"
dest: "{{ haproxy_ca_path }}"
service: "haproxy"
# === Services routed internally through HAProxy ===
securemtls_services:
vault:
hostname: vault.local.DOMAIN_PLACEHOLDER
balance: roundrobin
mode: http
timeout_connect: 5s
timeout_server: 30s
default_server_inter: 3s
default_server_rise: 2
default_server_fall: 3
backend_servers:
- address: VAULT_IP
port: VAULT_PORT
proto: http
headers:
- "http-request set-header Host vault.local.DOMAIN_PLACEHOLDER"
- "http-request set-header X-Forwarded-Proto https"
rewrites:
- "http-response replace-header Location ^http://VAULT_IP:VAULT_PORT/(.*) https://vault.local.DOMAIN_PLACEHOLDER/\\1"
technitium:
hostname: "technitium.local.DOMAIN_PLACEHOLDER"
backend_mode: http
backend_servers:
- address: TECHNITIUM_IP
port: TECHNITIUM_PORT
proto: "http"
@@ -0,0 +1,5 @@
---
- name: Reload HAProxy
systemd:
name: haproxy
state: reloaded
@@ -0,0 +1,107 @@
---
- name: Install HAProxy
dnf:
name: haproxy
state: present
- name: Ensure firewalld is running and enabled
systemd:
name: firewalld
enabled: true
state: started
- name: Allow inbound HTTPS (443/tcp)
ansible.posix.firewalld:
service: https
permanent: true
state: enabled
immediate: true
- name: Ensure HAProxy certificate directories exist
file:
path: "{{ cert_dir }}"
state: directory
owner: haproxy
group: haproxy
mode: "0750"
loop:
- "{{ haproxy_certs_dir }}"
- "{{ haproxy_certs_dir }}/clients"
loop_control:
loop_var: cert_dir
- name: Apply SELinux context for HAProxy certs
command: semanage fcontext -a -t etc_t "{{ haproxy_certs_dir }}(/.*)?"
become: true
register: semanage_fcontext_result
failed_when: false
changed_when: semanage_fcontext_result.rc == 0
- name: Restore SELinux context for HAProxy certs
command: restorecon -Rv "{{ haproxy_certs_dir }}"
become: true
changed_when: false
- name: Allow HAProxy to connect to any port (SELinux)
ansible.posix.seboolean:
name: haproxy_connect_any
state: true
persistent: true
- name: Wait for HAProxy PEM and CA files
wait_for:
path: "{{ pem_item }}"
state: present
timeout: 120
loop:
- "{{ haproxy_pem_path }}"
- "{{ haproxy_ca_path }}"
loop_control:
loop_var: pem_item
- name: Ensure permissions for HAProxy certs
file:
path: "{{ cert_item }}"
owner: haproxy
group: haproxy
mode: "0644"
loop:
- "{{ haproxy_pem_path }}"
- "{{ haproxy_ca_path }}"
loop_control:
loop_var: cert_item
- name: Deploy HAProxy configuration
template:
src: haproxy.cfg.j2
dest: /etc/haproxy/haproxy.cfg
owner: root
group: root
mode: "0644"
notify: Reload HAProxy
- name: Ensure HAProxy service starts after vault-agent
lineinfile:
path: /usr/lib/systemd/system/haproxy.service
regexp: "^After="
line: "After=network-online.target vault-agent.service"
backup: yes
- name: Reload systemd daemon
command: systemctl daemon-reload
- name: Enable and start HAProxy
systemd:
name: haproxy
enabled: yes
state: started
- name: Check SELinux mode
command: getenforce
register: selinux_state
changed_when: false
- name: Warn if SELinux is not enforcing
debug:
msg: "⚠️ SELinux is not enforcing — HAProxy cert contexts may not be correctly applied."
when: selinux_state.stdout != "Enforcing"
@@ -0,0 +1,86 @@
# ==========================================
# HAProxy global settings
# ==========================================
global
log /dev/log local0
log /dev/log local1 notice
chroot /var/lib/haproxy
pidfile /run/haproxy.pid
maxconn {{ haproxy_global_maxconn | default(4096) }}
user haproxy
group haproxy
daemon
defaults
log global
option httplog
option dontlognull
timeout connect {{ haproxy_timeout_connect_ms | default('5000') }}
timeout client {{ haproxy_timeout_client_ms | default('60000') }}
timeout server {{ haproxy_timeout_server_ms | default('60000') }}
option http-server-close
option forwardfor
retries 3
maxconn 2000
# ==========================================
# HAProxy frontends and backends template
# ==========================================
frontend https-in
mode http
bind *:443 ssl crt {{ haproxy_pem_path }} ca-file {{ haproxy_ca_path }} verify required alpn h2,http/1.1
option httplog
option forwardfor
timeout client 30s
http-request set-header X-Client-Cert %[ssl_c_der,base64]
{% for service_name, service in securemtls_services.items() %}
acl host_{{ service_name }} hdr(host) -i {{ service.hostname }}
use_backend {{ service_name }} if host_{{ service_name }}
{% endfor %}
# ==========================================
# Backends
# ==========================================
{% for service_name, service in securemtls_services.items() %}
backend {{ service_name }}
balance {{ service.balance | default('roundrobin') }}
mode {{ service.mode | default('http') }}
timeout connect {{ service.timeout_connect | default('5s') }}
timeout server {{ service.timeout_server | default('30s') }}
option {{ service.options | default('http-server-close') }}
default-server inter {{ service.default_server_inter | default('3s') }}
default-server rise {{ service.default_server_rise | default('2') }}
default-server fall {{ service.default_server_fall | default('3') }}
{% for server in service.backend_servers %}
{% if server.proto == "https" %}
server {{ service_name }}-{{ loop.index }} {{ server.address }}:{{ server.port }} ssl verify none check
{% else %}
server {{ service_name }}-{{ loop.index }} {{ server.address }}:{{ server.port }} check
{% endif %}
{% endfor %}
{% if service.headers is defined %}
{% for hdr in service.headers %}
{{ hdr }}
{% endfor %}
{% endif %}
{% if service.rewrites is defined %}
{% for rewrite in service.rewrites %}
{{ rewrite }}
{% endfor %}
{% endif %}
{% endfor %}
# ==========================================
# Vault backend for HAProxy
# ==========================================
backend vault_backend
mode tcp
option ssl-hello-chk
server vault1 VAULT_IP:VAULT_PORT ssl verify required ca-file {{ haproxy_ca_path }} crt {{ haproxy_cert_path }}
@@ -0,0 +1,28 @@
# Networking
technitium_port: 53
technitium_tls_port: 853
technitium_admin_port: TECHNITIUM_PORT
technitium_bind_address: "0.0.0.0"
# ==========================================================
# Directory paths
# ==========================================================
technitium_certs_dir: "/etc/technitium/certs"
technitium_crt_path: "{{ technitium_certs_dir }}/technitium.crt"
technitium_key_path: "{{ technitium_certs_dir }}/technitium.key"
technitium_ca_path: "{{ technitium_certs_dir }}/technitium_ca.crt"
# Install paths
technitium_install_dir: "/opt/technitium/dns"
technitium_data_dir: "{{ technitium_install_dir }}/data"
vault_agent_templates:
- src: "technitium_cert.ctmpl"
dest: "{{ technitium_crt_path }}"
service: "technitium"
- src: "technitium_key.ctmpl"
dest: "{{ technitium_key_path }}"
service: "technitium"
- src: "technitium_ca.ctmpl"
dest: "{{ technitium_ca_path }}"
service: "technitium"
@@ -0,0 +1,8 @@
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: yes
- name: Restart technitium
ansible.builtin.systemd:
name: technitium
state: restarted
@@ -0,0 +1,125 @@
---
# ==========================================================
# Install and configure Technitium DNS Server
# ==========================================================
- name: Ensure Technitium install directory exists
ansible.builtin.file:
path: "{{ technitium_install_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Ensure Technitium data directory exists
ansible.builtin.file:
path: "{{ technitium_data_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Ensure Technitium TLS directory exists
ansible.builtin.file:
path: "{{ technitium_certs_dir }}"
state: directory
owner: vault
group: vault
mode: "0700"
# ==========================================================
# Install Technitium if not already installed
# ==========================================================
- name: Install Technitium DNS Server
ansible.builtin.command: bash install.sh
args:
chdir: /tmp
creates: "{{ technitium_install_dir }}/start.sh"
environment:
INSTALL_URL: "https://download.technitium.com/dns/install.sh"
register: technitium_install_result
ignore_errors: true
changed_when: technitium_install_result.rc == 0
# ==========================================================
# SELinux setup (idempotent)
# ==========================================================
- name: Apply SELinux context for Technitium certs
ansible.builtin.command: semanage fcontext -a -t etc_t '{{ technitium_certs_dir }}(/.*)?'
register: selinux_ctx
changed_when: selinux_ctx.rc == 0
ignore_errors: true
- name: Restore SELinux context for Technitium certs
ansible.builtin.command: restorecon -Rv "{{ technitium_certs_dir }}"
changed_when: false
# ==========================================================
# Firewalld setup
# ==========================================================
- name: Ensure firewalld is running
ansible.builtin.systemd:
name: firewalld
enabled: true
state: started
- name: Open Technitium DNS and admin ports
ansible.posix.firewalld:
port: "{{ item.port }}/{{ item.proto }}"
permanent: true
state: enabled
immediate: true
loop:
- { port: 53, proto: "udp" }
- { port: 53, proto: "tcp" }
- { port: 853, proto: "tcp" }
- { port: 443, proto: "tcp" }
- { port: 5380, proto: "tcp" }
# ==========================================================
# Systemd service deployment
# ==========================================================
- name: Deploy Technitium systemd unit
ansible.builtin.template:
src: "technitium.service.j2"
dest: "/etc/systemd/system/technitium.service"
owner: root
group: root
mode: "0644"
notify:
- Reload systemd
- Restart technitium
- name: Wait for Vault-Agent to render certificates
ansible.builtin.wait_for:
path: "{{ cert_path }}"
state: present
timeout: 60
loop:
- "{{ technitium_crt_path }}"
- "{{ technitium_key_path }}"
- "{{ technitium_ca_path }}"
loop_control:
loop_var: cert_path
- name: Fix ownership and permissions for Technitium certs
ansible.builtin.file:
path: "{{ cert.path }}"
owner: vault
group: vault
mode: "{{ cert.mode }}"
loop:
- { path: "{{ technitium_crt_path }}", mode: "0644" }
- { path: "{{ technitium_key_path }}", mode: "0600" }
- { path: "{{ technitium_ca_path }}", mode: "0644" }
loop_control:
loop_var: cert
# ==========================================================
# Start and enable service
# ==========================================================
- name: Ensure Technitium DNS service is running
ansible.builtin.systemd:
name: technitium
enabled: true
state: started
@@ -0,0 +1,16 @@
[Unit]
Description=Technitium DNS Server
After=network.target
[Service]
Type=simple
WorkingDirectory={{ technitium_install_dir }}
#ExecStart={{ technitium_install_dir }}/start.sh
ExecStart={{ technitium_install_dir }}/start.sh --tls-cert /etc/technitium/certs/technitium.crt --tls-key /etc/technitium/certs/technitium.key
Restart=always
RestartSec=5
User=root
Group=root
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,4 @@
technitium_service_name: "technitium"
technitium_binary_url: "https://download.technitium.com/dns/DnsServerPortable.tar.gz"
technitium_data_dir: "{{ technitium_install_dir }}/data"
technitium_conf_file: "{{ technitium_install_dir }}/Technitium.Dns.Server.json"
@@ -0,0 +1,41 @@
---
vault_addr: "http://VAULT_IP:VAULT_PORT"
vault_agent_config_path: "/etc/vault-agent-config.hcl"
vault_agent_pid_file: "/var/run/vault-agent.pid"
vault_agent_user: "root"
vault_agent_service: "vault-agent"
vault_agent_systemd_unit: "/etc/systemd/system/vault-agent.service"
# Paths to role_id / secret_id files (written by bootstrap process)
vault_role_id_file: "/etc/vault-agent/role_id"
vault_secret_id_file: "/etc/vault-agent/secret_id"
# Templates
vault_templates_dir: "/etc/vault-agent-templates"
vault_agent_templates:
# HAProxy
- src: "haproxy.pem.ctmpl"
dest: "/etc/haproxy/certs/haproxy.pem"
service: haproxy
- src: "haproxy_ca.ctmpl"
dest: "/etc/haproxy/certs/haproxy_ca.crt"
service: haproxy
- src: "thesabear-client.ctmpl"
dest: "/etc/haproxy/certs/clients/thesabear-client.pem"
service: "haproxy"
# Technitium
- src: "technitium_cert.ctmpl"
dest: "/etc/technitium/certs/technitium.crt"
service: technitium
- src: "technitium_key.ctmpl"
dest: "/etc/technitium/certs/technitium.key"
service: technitium
- src: "technitium_ca.ctmpl"
dest: "/etc/technitium/certs/technitium_ca.crt"
service: technitium
# Default Vault PKI options (can remain)
pki_mount_path: "pki"
@@ -0,0 +1,3 @@
{{- with secret "pki/cert/ca" -}}
{{ trim .Data.certificate "\n" }}
{{- end }}
@@ -0,0 +1,7 @@
{% raw %}
{{- with secret "pki/issue/{{ wildcard_role_name }}" "common_name={{ common_name }}" -}}
{{ .Data.certificate }}
{{ .Data.private_key }}
{{ .Data.issuing_ca }}
{{- end }}
{% endraw %}
@@ -0,0 +1,6 @@
{{ with secret "pki/issue/securevault-wildcard" "common_name=*.local.DOMAIN_PLACEHOLDER" "ttl=8760h" }}
{{ .Data.certificate }}
{{ .Data.issuing_ca }}
{{ .Data.private_key }}
{{ end }}
@@ -0,0 +1,3 @@
{{- with secret "pki/issue/securevault-wildcard" "common_name=*.local.DOMAIN_PLACEHOLDER" -}}
{{ .Data.issuing_ca }}
{{- end }}
@@ -0,0 +1,3 @@
{{- with secret "pki_int/issue/securevault-wildcard" "common_name=technitium.local.DOMAIN_PLACEHOLDER" -}}
{{ .Data.issuing_ca }}
{{- end }}
@@ -0,0 +1,3 @@
{{- with secret "pki_int/issue/securevault-wildcard" "common_name=technitium.local.DOMAIN_PLACEHOLDER" -}}
{{ .Data.certificate }}
{{- end }}
@@ -0,0 +1,3 @@
{{- with secret "pki_int/issue/securevault-wildcard" "common_name=technitium.local.DOMAIN_PLACEHOLDER" -}}
{{ .Data.private_key }}
{{- end }}
@@ -0,0 +1,6 @@
{{ with secret "pki_int/issue/mtls-client-cert" "common_name=clientname.local.DOMAIN_PLACEHOLDER" "ttl=8760h" }}
{{ .Data.certificate }}
{{ .Data.private_key }}
{{ .Data.issuing_ca }}
{{ end }}
@@ -0,0 +1,3 @@
---
- name: Reload systemd
command: systemctl daemon-reload
@@ -0,0 +1,230 @@
---
# ==========================================================
# 1️⃣ Install Vault and Dependencies
# ==========================================================
- name: Ensure HashiCorp repository (RHEL9)
ansible.builtin.copy:
dest: /etc/yum.repos.d/hashicorp.repo
mode: "0644"
content: |
[hashicorp]
name=HashiCorp Stable Repository
baseurl=https://rpm.releases.hashicorp.com/RHEL/9/x86_64/stable/
enabled=1
gpgcheck=1
gpgkey=https://rpm.releases.hashicorp.com/gpg
- name: Install Vault (includes vault-agent)
ansible.builtin.dnf:
name: vault
state: present
# ==========================================================
# 2️⃣ Firewall Configuration
# ==========================================================
- name: Ensure firewalld is running and enabled
ansible.builtin.systemd:
name: firewalld
enabled: true
state: started
- name: Allow outbound access to Vault (VAULT_PORT/tcp)
ansible.posix.firewalld:
rich_rule: >
rule family="ipv4"
destination address="VAULT_IP"
port port="VAULT_PORT" protocol="tcp" accept
permanent: true
state: enabled
immediate: true
# ==========================================================
# 3️⃣ Vault-Agent Directories and Configs
# ==========================================================
- name: Ensure Vault-Agent directories exist
ansible.builtin.file:
path: "{{ vault_dir.path }}"
state: directory
owner: root
group: root
mode: "{{ vault_dir.mode | default('0750') }}"
loop:
- { path: "{{ vault_templates_dir }}" }
- { path: "/etc/vault-agent", mode: "0700" }
loop_control:
loop_var: vault_dir
- name: Deploy Vault-Agent configuration
ansible.builtin.template:
src: vault-agent-config.hcl.j2
dest: "{{ vault_agent_config_path }}"
owner: root
group: root
mode: "0600"
- name: Install Vault-Agent systemd unit
ansible.builtin.template:
src: vault-agent.service.j2
dest: "{{ vault_agent_systemd_unit }}"
owner: root
group: root
mode: "0644"
notify: Reload systemd
- name: Enable Vault-Agent but do not start yet
ansible.builtin.systemd:
name: "{{ vault_agent_service }}"
enabled: true
state: stopped
# ==========================================================
# 4️⃣ Vault-Agent Templates and Cert Paths
# ==========================================================
- name: Copy Vault-Agent templates
ansible.builtin.copy:
src: "{{ vault_item.src }}"
dest: "{{ vault_templates_dir }}/{{ vault_item.src | basename }}"
owner: root
group: root
mode: "0600"
loop: "{{ vault_agent_templates }}"
loop_control:
loop_var: vault_item
- name: Ensure directories for Vault-rendered files exist
ansible.builtin.file:
path: "{{ vault_template.dest | dirname }}"
state: directory
owner: root
group: root
mode: "0755"
loop: "{{ vault_agent_templates }}"
loop_control:
loop_var: vault_template
- name: Start Vault-Agent (now that templates exist)
ansible.builtin.systemd:
name: "{{ vault_agent_service }}"
state: started
# ==========================================================
# 5️⃣ AppRole Authentication Setup
# ==========================================================
- name: Fetch AppRole credentials for this host
delegate_to: localhost
block:
- name: Get role_id
ansible.builtin.command:
cmd: >
vault read -field=role_id auth/approle/role/{{ inventory_hostname }}-role/role-id
register: role_id_raw
changed_when: false
failed_when: role_id_raw.stdout | trim == ""
environment:
VAULT_ADDR: "http://VAULT_IP:VAULT_PORT"
VAULT_TOKEN: "{{ vault_token }}"
- name: Get secret_id
ansible.builtin.command:
cmd: >
vault write -f -field=secret_id auth/approle/role/{{ inventory_hostname }}-role/secret-id
register: secret_id_raw
changed_when: false
failed_when: secret_id_raw.stdout | trim == ""
environment:
VAULT_ADDR: "http://VAULT_IP:VAULT_PORT"
VAULT_TOKEN: "{{ vault_token }}"
- name: Write AppRole credentials to Vault-Agent directory
ansible.builtin.copy:
dest: "/etc/vault-agent/{{ cred.name }}"
content: "{{ cred.content }}"
owner: root
group: root
mode: "0600"
loop:
- { name: "role_id", content: "{{ role_id_raw.stdout | trim }}" }
- { name: "secret_id", content: "{{ secret_id_raw.stdout | trim }}" }
loop_control:
loop_var: cred
label: "{{ cred.name }}"
# ==========================================================
# 6️⃣ Register Service in Vault
# ==========================================================
- name: Login to Vault with AppRole and register VM
delegate_to: localhost
block:
- name: Login to Vault with AppRole
ansible.builtin.command:
cmd: >
vault write -field=token auth/approle/login
role_id={{ role_id_raw.stdout | trim }}
secret_id={{ secret_id_raw.stdout | trim }}
register: vault_login
changed_when: false
environment:
VAULT_ADDR: "http://VAULT_IP:VAULT_PORT"
- name: Register VM IP in Vault KV
ansible.builtin.uri:
url: "http://VAULT_IP:VAULT_PORT/v1/kv/data/services/{{ inventory_hostname }}"
method: POST
headers:
X-Vault-Token: "{{ vault_login.stdout }}"
body:
data:
ip: "{{ ansible_default_ipv4.address }}"
body_format: json
status_code: [200, 204]
# ==========================================================
# 7️⃣ SELinux Context for Vault Rendered Paths
# ==========================================================
- name: Check if SELinux is enforcing
ansible.builtin.command: getenforce
register: selinux_status
changed_when: false
failed_when: false
- name: Gather all Vault-Agent template and rendered paths
ansible.builtin.set_fact:
all_vault_paths: >-
{{
(
(vault_agent_templates | default([]) | map(attribute='dest') | map('dirname') | list)
+
(vault_agent_templates | default([]) | map(attribute='dest') | list)
) | unique
}}
when: vault_agent_templates is defined and vault_agent_templates | length > 0
- name: Filter only paths that exist on the host
ansible.builtin.find:
paths: "{{ all_vault_paths }}"
file_type: directory
register: existing_selinux_paths
when: all_vault_paths is defined and all_vault_paths | length > 0
- name: Apply SELinux fcontext to all existing Vault paths
ansible.builtin.command:
cmd: semanage fcontext -a -t etc_t '{{ selinux_path_item.path }}(/.*)?'
loop: "{{ existing_selinux_paths.files | default([]) }}"
loop_control:
loop_var: selinux_path_item
ignore_errors: true
when: selinux_status.stdout == "Enforcing"
- name: Restore SELinux context for all existing Vault paths
ansible.builtin.command:
cmd: restorecon -Rv "{{ selinux_path_item.path }}"
loop: "{{ existing_selinux_paths.files | default([]) }}"
loop_control:
loop_var: selinux_path_item
ignore_errors: true
when: selinux_status.stdout == "Enforcing"
- name: Warn if SELinux is not enforcing
ansible.builtin.debug:
msg: "⚠️ SELinux is not enforcing — Vault-Agent directories may not be properly confined."
when: selinux_status.stdout != "Enforcing"
@@ -0,0 +1,30 @@
pid_file = "{{ vault_agent_pid_file }}"
auto_auth {
method {
type = "approle"
config = {
role_id_file_path = "{{ vault_role_id_file }}"
secret_id_file_path = "{{ vault_secret_id_file }}"
remove_secret_id_file_after_reading = false
}
}
sink {
type = "file"
config = {
path = "/etc/vault-token"
}
}
}
{% for tpl in vault_agent_templates if tpl.service == hostvars[inventory_hostname].service %}
template {
source = "{{ vault_templates_dir }}/{{ tpl.src }}"
destination = "{{ tpl.dest }}"
perms = "0644"
}
{% endfor %}
vault {
address = "{{ vault_addr }}"
}
@@ -0,0 +1,13 @@
[Unit]
Description=Vault Agent
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/vault agent -config={{ vault_agent_config_path }}
Restart=always
RestartSec=5
User={{ vault_agent_user }}
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,123 @@
roles_metadata:
# Bootstrap roles
base_users:
initialization: true
provisioning: false
compliance: false
description: "Base user creation tasks"
disk_resize:
initialization: true
provisioning: false
compliance: true
description: "Disk resizing tasks"
docker_gitlab_setup:
initialization: true
provisioning: false
compliance: false
description: "Docker runtime files moving and homelab repo copy"
endlessh:
initialization: true
provisioning: false
compliance: false
description: "Endlessh setup"
firewall_ssh:
initialization: true
provisioning: false
compliance: true
description: "Firewall and SSH configuration"
hostname_network:
initialization: true
provisioning: false
compliance: false
description: "Set hostname and networking"
packages:
initialization: true
provisioning: false
compliance: true
description: "base packages and update tasks"
portainer_agent:
initialization: true
provisioning: false
compliance: true
description: "Portainer agent setup"
root_password:
initialization: true
provisioning: false
compliance: false
description: "Root password setup"
zsh_custom:
initialization: true
provisioning: false
compliance: false
description: "Custom Zsh setup"
# CIS Hardening roles
banners:
initialization: false
provisioning: false
compliance: true
description: "Login banners setup"
file_permissions:
initialization: false
provisioning: false
compliance: true
description: "File permissions hardening"
filesystem_hardening:
initialization: false
provisioning: false
compliance: true
description: "filesystems hardening"
kernel_hardening:
initialization: false
provisioning: false
compliance: true
description: "Kernel hardening tasks"
password_policy:
initialization: false
provisioning: false
compliance: true
description: "Password policy enforcement"
service_disable:
initialization: false
provisioning: false
compliance: true
description: "Disable unnecessary services"
ssh_hardening:
initialization: false
provisioning: false
compliance: true
description: "SSH hardening"
# SecureVault roles
haproxy:
initialization: false
provisioning: true
compliance: true
description: "haproxy deployment"
vault_agent:
initialization: true
provisioning: false
compliance: false
description: "vault_agent deployment"
technitium:
initialization: false
provisioning: true
compliance: false
description: "technitium deployment"
@@ -0,0 +1,20 @@
Coming Soon...
A description of how I use a minimal ISO to built out create a base image with defaults configurations which is perfect for its use as a base for creating Proxmox templates from.
#ROCKY10-CIS
cd /mnt/storage/template/iso
qm create 5000 --memory 2048 --core 2 --name rocky10-cis --net0 virtio,bridge=vmbr0
qm importdisk 5000 packer-rocky10.iso storage --format qcow2
qm set 5000 \
--scsihw virtio-scsi-pci \
--scsi0 storage:5000/vm-5000-disk-0.qcow2,discard=on,ssd=1 \
--cpu host \
--numa 1 \
--balloon 0
qm set 5000 --agent enabled=1,freeze-fs-on-backup=1,fstrim_cloned_disks=1
qm set 5000 --boot c --bootdisk scsi0
qm set 5000 --serial0 socket --vga serial0
@@ -0,0 +1 @@
SuperSecretPasswordToUnlock.vault_pass.txtFile
@@ -0,0 +1,82 @@
#version=RHEL10
lang en_US.UTF-8
keyboard us
timezone Europe/Sofia --utc
authselect --enablefeatures=with-faillock
selinux --enforcing
firewall --disabled
services --enabled=sshd,qemu-guest-agent
firstboot --disable
network --bootproto=dhcp --device=link --onboot=on --hostname=rocky10 --activate
ignoredisk --only-use=vda
rootpw --plaintext thisisatemporarypassword
reboot
# Clear all partitions and create new MBR label
clearpart --all --initlabel --drives=vda --disklabel=msdos
# /boot with ext4
part /boot --fstype="ext4" --size=1024 --ondisk=vda --label=BOOT
# LVM physical volume on rest of disk
part pv.01 --fstype="lvmpv" --ondisk=vda --grow --size=1 --label=PV01
volgroup rocky pv.01
# Logical volumes
logvol /tmp --fstype=xfs --size=2048 --name=lv_tmp --vgname=rocky --fsoptions="defaults,nodev,nosuid"
logvol /home --fstype=xfs --size=2048 --name=lv_home --vgname=rocky --fsoptions="defaults,nodev"
logvol /var --fstype=xfs --size=4096 --name=lv_var --vgname=rocky --fsoptions="defaults,nodev"
logvol /var/tmp --fstype=xfs --size=1024 --name=lv_vartmp --vgname=rocky --fsoptions="defaults,nodev,nosuid,noexec"
logvol /var/log --fstype=xfs --size=2048 --name=lv_varlog --vgname=rocky --fsoptions="defaults,nodev,noexec,nosuid"
logvol /var/log/audit --fstype=xfs --size=2048 --name=lv_audit --vgname=rocky --fsoptions="defaults,nodev,noexec,nosuid"
logvol / --fstype=xfs --name=lv_root --vgname=rocky --grow --size=512 --fsoptions="defaults"
# BIOS-compatible bootloader
bootloader --location=mbr --boot-drive=vda
# Repositories
repo --name="base" --baseurl=https://download.rockylinux.org/pub/rocky/10/BaseOS/x86_64/os/
repo --name="appstream" --baseurl=https://download.rockylinux.org/pub/rocky/10/AppStream/x86_64/os/
%packages
@^minimal-environment
openssh-server
openssh-clients
qemu-guest-agent
nano
zsh
git
curl
sudo
util-linux-user
rsync
tree
nmap-ncat
%end
%post --erroronfail
# Enable Docker repository
dnf install -y epel-release dnf-plugins-core
dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
# Install Docker fastfetch jq
dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin fastfetch jq
# Enable services
systemctl enable sshd
systemctl enable docker
systemctl restart docker
systemctl restart sshd
# Enable password authentication for SSH
cat > /etc/ssh/sshd_config.d/99-password-login.conf <<EOF
PermitRootLogin yes
PasswordAuthentication yes
Subsystem sftp /usr/libexec/openssh/sftp-server
EOF
# Optional: enable LVM resize service (if available)
systemctl enable resize-lvm.service || true
%end
@@ -0,0 +1,105 @@
packer {
required_plugins {
qemu = {
version = ">=1.0.0"
source = "github.com/hashicorp/qemu"
}
}
}
variable "admin_username" {
type = string
}
variable "admin_password_hash" {
type = string
sensitive = true
}
variable "admin_ssh_key" {
type = string
}
variable "ansible_username" {
type = string
}
variable "ansible_ssh_key" {
type = string
}
variable "iso_url" {
type = string
default = "https://download.rockylinux.org/pub/rocky/10/isos/x86_64/Rocky-10-latest-x86_64-minimal.iso"
}
variable "iso_checksum" {
type = string
default = "sha256:de75c2f7cc566ea964017a1e94883913f066c4ebeb1d356964e398ed76cadd12"
}
source "qemu" "rocky10" {
iso_url = var.iso_url
iso_checksum = var.iso_checksum
output_directory = "output-rocky10"
shutdown_command = "shutdown -h now"
format = "qcow2"
disk_size = 20480
http_directory = "http"
communicator = "ssh"
ssh_username = "root"
ssh_password = "HaveATemporaryPasswordHere"
ssh_timeout = "20m"
boot_wait = "20s"
boot_command = [
"<up><wait>",
"e<wait>", # edit boot entry
"<down><down><end><wait>", # go to linux line end
" inst.ks=http://{{ .HTTPIP }}:{{ .HTTPPort }}/ks.cfg",
" console=tty0 console=ttyS0,115200n8",
" ip=dhcp",
"<f10><wait>" # boot with edited entry
]
headless = true
qemu_binary = "/usr/bin/qemu-system-x86_64"
accelerator = "kvm"
cpus = 2
memory = 2048
qemuargs = [
["-serial", "file:serial.log"],
["-cpu", "host"],
["-display", "none"]
]
}
build {
sources = ["source.qemu.rocky10"]
provisioner "file" {
source = "scripts/setup-admin-user.sh"
destination = "/tmp/setup-admin-user.sh"
}
provisioner "file" {
source = "scripts/setup-ansible-user.sh"
destination = "/tmp/setup-ansible-user.sh"
}
# Run setup-admin-user.sh
provisioner "shell" {
inline = [
"chmod +x /tmp/setup-admin-user.sh",
"/tmp/setup-admin-user.sh \"${var.admin_username}\" \"${var.admin_password_hash}\" \"${var.admin_ssh_key}\""
]
}
provisioner "shell" {
inline = [
"chmod +x /tmp/setup-ansible-user.sh",
"/tmp/setup-ansible-user.sh \"${var.ansible_username}\" \"${var.ansible_ssh_key}\""
]
}
}
@@ -0,0 +1,108 @@
#!/bin/bash
set -euo pipefail
USERNAME="$1"
HASHED_PASSWORD="$2"
SSH_KEY="$3"
echo "Creating admin user: $USERNAME"
# --- Install dependencies early
if command -v dnf &>/dev/null; then
dnf install -y zsh git curl sudo util-linux-user
elif command -v apt-get &>/dev/null; then
apt-get update
apt-get install -y zsh git curl sudo
else
echo "Unsupported package manager"
exit 1
fi
# --- Create user if not exists
if ! id "$USERNAME" &>/dev/null; then
useradd -m -s /bin/zsh "$USERNAME"
echo "${USERNAME}:${HASHED_PASSWORD}" | chpasswd -e
# Disable forced password change and expiration
chage -M 99999 -I -1 -E -1 "$USERNAME"
else
echo "User $USERNAME already exists, skipping creation"
fi
USER_HOME=$(eval echo "~$USERNAME")
# --- Create fastfetch config
mkdir -p "$USER_HOME/.config/fastfetch"
cat <<'EOF' > "$USER_HOME/.config/fastfetch/config.jsonc"
{
"displayMode": "default",
"modules": [
"os", "host", "kernel", "uptime", "packages", "shell",
"cpu", "memory",
{
"type": "disk",
"folders": ["/", "/home", "/tmp", "/var", "/var/log", "/var/log/audit", "/var/tmp"]
},
"locale"
]
}
EOF
chown -R "$USERNAME:$USERNAME" "$USER_HOME/.config"
chmod -R 700 "$USER_HOME/.config"
# --- SSH Setup
mkdir -p "$USER_HOME/.ssh"
echo "$SSH_KEY" > "$USER_HOME/.ssh/authorized_keys"
chmod 700 "$USER_HOME/.ssh"
chmod 600 "$USER_HOME/.ssh/authorized_keys"
chown -R "$USERNAME:$USERNAME" "$USER_HOME/.ssh"
# --- Groups and Sudo
if ! getent group docker >/dev/null; then
groupadd docker
fi
if getent group sudo >/dev/null; then
usermod -aG sudo,docker "$USERNAME"
elif getent group wheel >/dev/null; then
usermod -aG wheel,docker "$USERNAME"
else
echo "No sudo-equivalent group found, skipping"
fi
echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/99_$USERNAME"
chmod 440 "/etc/sudoers.d/99_$USERNAME"
# --- Install Oh My Zsh and config as the user
runuser -l "$USERNAME" -c 'export RUNZSH=no CHSH=no KEEP_ZSHRC=yes && \
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" --unattended'
# --- Install autosuggestions plugin
runuser -l "$USERNAME" -c "git clone https://github.com/zsh-users/zsh-autosuggestions \
~/.oh-my-zsh/custom/plugins/zsh-autosuggestions"
# --- Generate custom .zshrc
cat << 'EOF' > "$USER_HOME/.zshrc"
export ZSH="\$HOME/.oh-my-zsh"
ZSH_THEME="robbyrussell"
plugins=(git zsh-autosuggestions)
source \$ZSH/oh-my-zsh.sh
PS1='%F{51}%n%F{255}@%F{227}%m %F{84}%~%f %F{15}$ '
fastfetch
EOF
chown "$USERNAME:$USERNAME" "$USER_HOME/.zshrc"
chmod 644 "$USER_HOME/.zshrc"
# --- Set shell (do this last to avoid breaking script if user shell changes mid-run)
chsh -s "$(command -v zsh)" "$USERNAME"
# --- Deny admin from using Ansible
cat <<EOF > /etc/sudoers.d/99_no_ansible_for_admin
$USERNAME ALL=(ALL) !/usr/bin/ansible, !/usr/bin/ansible-playbook
EOF
chmod 440 /etc/sudoers.d/99_no_ansible_for_admin
echo "✅ User $USERNAME configured successfully"
@@ -0,0 +1,33 @@
#!/bin/bash
set -euo pipefail
USERNAME="$1"
SSH_KEY="$2"
echo "Creating ansible user: $USERNAME"
# --- Create user if not exists
if ! id "$USERNAME" &>/dev/null; then
useradd -m "$USERNAME"
mkdir -p "/home/$USERNAME/.ssh"
echo "$SSH_KEY" > "/home/$USERNAME/.ssh/authorized_keys"
chmod 700 "/home/$USERNAME/.ssh"
chmod 600 "/home/$USERNAME/.ssh/authorized_keys"
chown -R "$USERNAME:$USERNAME" "/home/$USERNAME/.ssh"
else
echo "User $USERNAME already exists, skipping creation"
fi
# --- Harden the ansible user
# Disable password (no password login)
passwd -d "$USERNAME"
passwd -l "$USERNAME"
# Allow full passwordless sudo (needed for playbook tasks)
cat <<EOF > "/etc/sudoers.d/99_${USERNAME}"
${USERNAME} ALL=(ALL) NOPASSWD: ALL
EOF
chmod 0440 "/etc/sudoers.d/99_${USERNAME}"
echo "✅ User $USERNAME hardened for automation-only access"
@@ -0,0 +1,27 @@
#!/bin/bash
set -euo pipefail
VAULT_FILE=".vault_pass.txt"
VARS_FILE="terraform.pkrvar.hcl"
PACKER_TEMPLATE="packer.pkr.hcl"
# Check if vault password file exists
if [[ ! -f "$VAULT_FILE" ]]; then
echo "❌ Vault password file '$VAULT_FILE' not found."
exit 1
fi
# Decrypt the vars file temporarily
echo "🔓 Decrypting $VARS_FILE..."
ansible-vault decrypt "$VARS_FILE" --vault-password-file "$VAULT_FILE"
# Run packer build
echo "🏗️ Running Packer build..."
packer build -var-file="$VARS_FILE" "$PACKER_TEMPLATE"
# Re-encrypt the vars file
echo "🔒 Re-encrypting $VARS_FILE..."
ansible-vault encrypt "$VARS_FILE" --vault-password-file "$VAULT_FILE"
echo "✅ Done!"
@@ -0,0 +1,27 @@
$ANSIBLE_VAULT;1.1;AES256
37323332646365326338303234343138326439366364323631663634653136363935363865636334
6534666264666666326235393861356362353932633630390a366334646166623237623233663763
32323438646339353263366361323632646433643134663666616631316262363539373330633536
3966623765323965650a613664306466353931643234333837376237303131643932306563346532
65363634643964613936376661386639303431623534373464636638333238626130316664663666
33356661313632366531646564366465366530663366326135326133313662323939393566653333
62393839316330303164646131393162373138613764303864616562666130663035323864383031
31303536346332393164306162343062616436303138643138653734633630666462633565326234
66636562663165333863373563373535666533396334363865303237373331633761633838653961
38383634326530333531656438346238336133613636356238653264303137333839313233656339
38323730626234643963636662326663353737346332366263373139353962663239663363623736
38656434386261316136323130646235666139643131313132396536393234373263323735336631
37623537613164346663363138343238386362646533343837363165353539636539323434393839
32323633636534623738356338336561653033633939366537653662643430396364383033343663
37306666353432643636353464333362343162306435363236623866366639663339363339353736
64663332386662353864303634653539323831396136383565363462316638613530623236623038
63613262393035303730376164656239646336343133363931613136393362383734363235336637
34613830633136636130303737636339306332393062656538616133393638613564326534336233
35623764366232366538363464383334386539333963373066373065323466313033663735626466
30366430323565333365343036343566663133316236663431393335353930323731633934346636
38653039653930356633666336333365663833316635353866313965653632363365303165393538
30666431663838633434363637656365613133323834343630383133396435333061373832633361
30393730653963303933303934626562373663623265356562376633366238323563623462343162
32626164663630393831316136303835613362663534383938353965366538653838343539663263
65663833343834356137366263623331373236323065376536633237613030346135663563333763
6539613565366337613162366434373430643634393665326666
@@ -0,0 +1,15 @@
variable "admin_username" {
default = "admin"
}
variable "admin_password_hash" {
type = string
sensitive = true
}
variable "admin_ssh_key" {
type = string
}
variable "ansible_username" {}
variable "ansible_ssh_key" {}
+176
View File
@@ -0,0 +1,176 @@
# <span style="color:#4DA3FF;">Infrastructure as Code</span>
This repository contains the full Infrastructure-as-Code stack that builds, configures, and manages my homelab environment. It brings together **Packer**, **Terraform**, and **Ansible** into a single, structured workflow that provisions VM templates, deploys infrastructure on Proxmox, and configures each service automatically.
[![GitLab repo](https://img.shields.io/badge/GitLab-Public-blue)](https://gitlab.com/)
[![Terraform](https://img.shields.io/badge/Terraform-Blue?logo=terraform&logoColor=white)](https://www.terraform.io/)
[![Packer](https://img.shields.io/badge/Packer-6C9BFF?logo=hashicorp&logoColor=white)](https://www.packer.io/)
[![Ansible](https://img.shields.io/badge/Ansible-Red?logo=ansible&logoColor=white)](https://www.ansible.com/)
The goal is to maintain everything as code: reproducible, version-controlled, and modular.
---
## <span style="color:#E0A800;">🧱 Repository Structure</span>
```
Infrastrucutre-as-Code/
├── Packer
├── Terraform
├── Ansible
├── SSO
└── README.md
```
---
## ![Packer badge](https://img.shields.io/badge/Packer-6C9BFF?logo=hashicorp&logoColor=white) **Packer**
Packer
├── rocky10-proxmox-template
│ ├── http
│ │ └── ks.cfg
│ ├── scripts
│ │ ├── setup-admin-user.sh
│ │ └── setup-ansible-user.sh
│ ├── packer.pkr.hcl
│ ├── secure-packer-build.sh
│ ├── terraform.pkrvar.hcl
│ └── variables.pkr.hcl
└── README.md
Packer is used to create the base OS templates that all my virtual machines are built from.
This includes:
- Automated OS installation (using a minimal Rocky10 base image)
- Hardening steps during image creation
- Preconfigured admin and automation users and passwordless SSH access for them
- Preparation for provisioning tools such as Ansible
Each Packer build lives in its own directory containing:
- `packer.pkr.hcl` build files
- Kickstart / cloud-init automation
- Hardening & configuration scripts
These ISO's are uploaded to Proxmox as templates to be used by Terraform and form the baseline for the rest of the IaC pipeline.
---
## ![Terraform badge](https://img.shields.io/badge/Terraform-4C9BFF?logo=terraform&logoColor=white) **Terraform**
Terraform
├── host_tags.yml.tpl
├── main.tf
├── providers.tf
├── README.md
├── terraform.tfvars
└── variables.tf
Terraform handles the infrastructure provisioning layer.
It manages all Proxmox-related resources, including:
- VM creation from Packer templates
- Network, firewall, and storage definitions
- Infrastructure for identity and trust components
- Modular, reusable Terraform modules
Terraform defines **what the homelab *is***.
---
## ![Ansible badge](https://img.shields.io/badge/Ansible-FF5A5A?logo=ansible&logoColor=white) **Ansible**
Ansible
├── group_vars
│ ├── all
│ │ └── vault_token.yml
│ └── all.yml
├── host_vars
│ ├── docker-host
│ │ └── host_tags.yml
│ └── haproxy-vm
│ └── host_tags.yml
├── playbooks
│ ├── compliance.yml
│ ├── initialization.yml
│ └── provisioning.yml
├── roles
│ ├── bootstrap_roles
│ │ ├── base_users
│ │ ├── disk_resize
│ │ ├── docker_gitlab_setup
│ │ ├── endlessh
│ │ ├── firewall_ssh
│ │ ├── hostname_network
│ │ ├── packages
│ │ ├── portainer_agent
│ │ ├── root_password
│ │ └── zsh_custom
│ ├── cis_hardening
│ │ ├── banners
│ │ ├── defaults
│ │ ├── file_permissions
│ │ ├── filesystem_hardening
│ │ ├── handlers
│ │ ├── kernel_hardening
│ │ ├── password_policy
│ │ ├── service_disable
│ │ ├── ssh_hardening
│ │ └── tasks
│ └── secure_vault
│ ├── haproxy
│ ├── technitium
│ └── vault_agent
├── ansible.cfg
├── inventory_initialization.yml
├── inventory.yml
├── README.md
├── requirements.yml
└── roles_metadata.yml
Ansible configures the systems after Terraform brings them online.
Roles and playbooks in this layer include:
- Service installation and configuration
- Certificate lifecycle management using Hashicrp Vault
- mTLS enforcement for internal services
- Service deployment and automation
- Modular, reusable roles
Ansible defines **how the homelab *behaves***.
---
## ![SSO badge](https://img.shields.io/badge/SSO-00C2A2?logo=keycloak&logoColor=white) **SSO**
This directory contains everything related to the Single Sign-On stack.
This includes:
- Identity provider access to all locally routed applications and services behind HAProxy
- Integrations with Vault, HAProxy and mTLS layers for true SSO once mTLS trust is verified
The goal is to centralize and standardize authentication across the environment.
---
## <span style="color:#F2921D;">🔧 Workflow Overview</span>
The full workflow follows a simple, layered model:
1. **Packer** → build secure, repeatable VM templates
2. **Terraform** → instantiate infrastructure from those templates
3. **Ansible** → configure, secure, and deploy services
Each layer owns a specific responsibility, keeping the stack clean, modular, and easy to reason about.
---
## <span style="color:#17A2B8;">📌 Notes</span>
- Everything here is designed to be reproducible.
- No secrets live in this repository — Vault injects them during provisioning.
- Each component stands alone while participating in the overall pipeline.
---
If you're exploring, a good path is **Packer → Terraform → Ansible** to see how everything connects.
+7
View File
@@ -0,0 +1,7 @@
Coming Soon...
This is the final piece of the IaC pipeline I need to construct. I have tried and tested Authelia, Authentik and Keycloak and none of them can do or fit in with my needs, which are automatically logging in once the mTLS layer provided via HAproxy is verified and access to the local domain granted, for the sign-in to be done.
I have done some research and it looks like a small python script can be made which will allow for a pre-auth proxy service that sits between HAProxy and the backend apps.
I have yet to get this fully working and in production. Once it is tested and working this readme and folder will be updated with all the relevant code and information for deployment.
@@ -0,0 +1,3 @@
Coming Soon...
A full explanation of the files in the Terraform folder and how they are laid out in order to consume the Packer built ISO, which has been uploaded into a Proxmox template, for the construction of a VM with specific parameters.
@@ -0,0 +1,20 @@
# VM metadata from Terraform provisioning
vmid: ${vmid}
vm_name: ${name}
target_node: ${target_node}
template: ${template}
cores: ${cores}
sockets: ${sockets}
memory: ${memory}
bridge: ${bridge}
macaddr: ${macaddr}
disk_size: ${disk_size}GB
disk_storage: ${disk_storage}
ip: ${ip}
vlan: ${vlan}
vm_tags:
%{ for tag in tags ~}
- ${tag}
%{ endfor ~}
@@ -0,0 +1,108 @@
locals {
ansible_repo_path = var.ansible_repo_path != "" ? var.ansible_repo_path : "${path.module}/../ansible"
}
resource "proxmox_vm_qemu" "vm" {
for_each = var.vms
agent = 1
skip_ipv6 = true
vmid = each.value.vmid
name = each.value.name
target_node = each.value.target_node
clone = each.value.template
onboot = each.value.onboot
os_type = "other"
cpu {
type = "host"
numa = true
cores = each.value.cores
sockets = each.value.sockets
}
memory = each.value.memory
balloon = 0
scsihw = "virtio-scsi-pci"
bootdisk = "scsi0"
disk {
slot = "scsi0"
type = "disk"
storage = each.value.disk_storage
size = each.value.disk_size
format = "qcow2"
discard = true
}
network {
id = 0
bridge = each.value.bridge
model = "virtio"
macaddr = each.value.macaddr
tag = each.value.vlan != 0 ? each.value.vlan : null
}
lifecycle {
ignore_changes = [
usb,
]
}
serial {
id = 0
}
tags = length(each.value.tags) > 0 ? join(",", each.value.tags) : null
}
resource "null_resource" "wait_for_ssh" {
for_each = var.vms
depends_on = [proxmox_vm_qemu.vm]
connection {
type = "ssh"
host = each.value.ip
user = "ansible"
private_key = file("~/.ssh/auto")
timeout = "5m"
}
provisioner "local-exec" {
command = <<EOT
echo "Waiting for SSH on ${each.value.ip}..."
for i in {1..30}; do
if ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i ~/.ssh/auto ansible@${each.value.ip} 'echo SSH Ready' >/dev/null 2>&1; then
echo "✅ SSH is up!"
exit 0
fi
echo "Retry $i: SSH not ready yet..."
sleep 10
done
echo "❌ SSH did not become available on ${each.value.ip} after timeout."
exit 1
EOT
}
}
resource "local_file" "host_tags" {
for_each = var.vms
content = templatefile("${path.module}/host_tags.yml.tpl", {
vmid = each.value.vmid
name = each.value.name
target_node = each.value.target_node
template = each.value.template
cores = each.value.cores
sockets = each.value.sockets
memory = each.value.memory
bridge = each.value.bridge
macaddr = each.value.macaddr
disk_size = each.value.disk_size
disk_storage = each.value.disk_storage
ip = each.value.ip
vlan = each.value.vlan
tags = each.value.tags
})
filename = "${local.ansible_repo_path}/host_vars/${each.value.name}/host_tags.yml"
}
@@ -0,0 +1,16 @@
terraform {
required_version = ">= 0.14"
required_providers {
proxmox = {
source = "telmate/proxmox"
version = "3.0.2-rc03"
}
}
}
provider "proxmox" {
pm_tls_insecure = true
pm_api_url = "https://host:port/api2/json"
pm_api_token_secret = "pm_api_token_secret"
pm_api_token_id = "pm_api_token_id"
}
@@ -0,0 +1,78 @@
vms = {
haproxy = {
vmid = id number here
name = "vm name"
target_node = "hypervisor node name"
template = "template-name"
onboot = true
cores = 2
sockets = 1
memory = 2048
bridge = "vmbr0"
macaddr = "xx:x:x:x:x:x"
disk_size = 20
disk_storage = "storage name here"
ip = "xx.xx.xx.x"
gateway = "x.x.x.x"
netmask = "24"
vlan = "0"
startup = {
order = 3
up = 10
down = 10
}
tags = [
"packages",
"hostname_network",
"root_password",
"disk_resize",
"base_users",
"firewall_ssh",
"endlessh",
"portainer_agent",
"zsh_custom",
"cis_hardening"
]
}
docker = {
vmid = id number here
name = "vm name"
target_node = "hypervisor node name"
template = "template-name"
onboot = true
cores = 2
sockets = 1
memory = 8192
bridge = "vmbr0"
macaddr = "xx:x:x:x:x:x"
disk_size = 20
disk_storage = "storage name here"
ip = "xx.xx.xx.x"
gateway = "x.x.x.x"
netmask = "24"
vlan = "0"
startup = {
order = 3
up = 10
down = 20
}
tags = [
"packages",
"hostname_network",
"root_password",
"disk_resize",
"base_users",
"firewall_ssh",
"endlessh",
"portainer_agent",
"zsh_custom",
"vault_agent",
"cis_hardening",
"docker_gitlab_setup"
"s_nail",
"lynis"
]
}
}
ansible_repo_path = ""
@@ -0,0 +1,32 @@
variable "vms" {
description = "Map of VMs to create"
type = map(object({
vmid = number
name = string
target_node = string
template = string
onboot = bool
cores = number
sockets = number
memory = number
bridge = string
macaddr = string
disk_size = number
disk_storage = string
ip = string
gateway = string
netmask = string
vlan = string
startup = object({
order = number
up = number
down = number
})
tags = list(string)
}))
}
variable "ansible_repo_path" {
description = "Path to the ansible repo"
type = string
}