commit 702b7d2fe12a7f5da00ce72237bf9400b43296a1 Author: Sebastian Rust Date: Thu Feb 5 18:19:37 2026 +0100 feat(bundle): initial commit diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..e77ea27 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,61 @@ +name: Build Bootstrap Bundle + +on: + push: + branches: [main] + paths: + - 'bootstrap.yml' + - 'roles/**' + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install ansible ansible-bundler + + - name: Verify playbook syntax + run: | + ansible-playbook --syntax-check bootstrap.yml -e 'user_pubkey="test"' + + - name: Build bundle + run: | + ansible-bundler bootstrap.yml -o bootstrap.run + + - name: Generate checksums + run: | + sha256sum bootstrap.run > bootstrap.run.sha256 + md5sum bootstrap.run > bootstrap.run.md5 + + - name: Upload to release storage + run: | + # Option 1: Upload to Gitea generic packages + curl -X PUT \ + -H "Authorization: token ${{ secrets.DEPLOY_TOKEN }}" \ + -T bootstrap.run \ + "${{ github.server_url }}/api/packages/${{ github.repository_owner }}/generic/bootstrap/latest/bootstrap.run" + + curl -X PUT \ + -H "Authorization: token ${{ secrets.DEPLOY_TOKEN }}" \ + -T bootstrap.run.sha256 \ + "${{ github.server_url }}/api/packages/${{ github.repository_owner }}/generic/bootstrap/latest/bootstrap.run.sha256" + + # Alternative: Upload as artifact (for manual download) + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: bootstrap-bundle + path: | + bootstrap.run + bootstrap.run.sha256 + retention-days: 90 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..089d32e --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Secrets - NEVER commit +*.secret.yml +*.secret.yaml +secrets/ +vault.yml + +# Inventory files (contain hostnames/IPs) +inventory +inventory.txt +inventory.*.txt + +# Pre-built bundles (CI builds fresh) +*.run + +# Ansible artifacts +*.retry +.ansible/ + +# Python +__pycache__/ +*.pyc +.venv/ +venv/ + +# Editor +*.swp +*.swo +*~ +.idea/ +.vscode/ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..a79b949 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Host Bootstrap Bundle + +Ansible playbook for bootstrapping new servers with a secure user and hardened SSH configuration. Designed to be built with [ansible-bundler](https://pypi.org/project/ansible-bundler/) and distributed via CI/CD. + +## Features + +- Creates a user with SSH key authentication +- Configures passwordless sudo (optional) +- Hardens SSH with secure defaults (no root login, no password auth) +- Works with Debian, Ubuntu, RHEL/CentOS, Arch Linux + +## Usage + +### Direct with Ansible + +```bash +ansible-playbook bootstrap.yml -i "host," -e user_name=operator \ + -e 'user_pubkey="ssh-ed25519 AAAA..."' +``` + +### With Bundled Version + +```bash +# Download and verify +curl -sL https://your-server/bootstrap.run -o /tmp/bootstrap.run +curl -sL https://your-server/bootstrap.run.sha256 -o /tmp/bootstrap.run.sha256 +cd /tmp && sha256sum -c bootstrap.run.sha256 + +# Run +chmod +x /tmp/bootstrap.run +./bootstrap.run -e user_name=operator \ + -e 'user_pubkey="ssh-ed25519 AAAA..."' \ + -e user_password=changeme +``` + +## Variables + +### Required + +| Variable | Description | +|----------|-------------| +| `user_name` | Username to create | +| `user_pubkey` | SSH public key (or use `user_pubkey_file` / `user_pubkey_url`) | + +### Optional + +| Variable | Default | Description | +|----------|---------|-------------| +| `user_password` | - | Password for the user | +| `user_shell` | `/bin/bash` | User's login shell | +| `user_home` | `/home/{user}` | Home directory | +| `user_sudo_enabled` | `true` | Enable sudo access | +| `user_sudo_nopasswd` | `true` | Passwordless sudo | +| `user_pubkey_exclusive` | `true` | Replace existing authorized_keys | +| `ssh_server_ports` | `["22"]` | SSH port(s) | +| `ssh_permit_root_login` | `"no"` | Allow root SSH login | +| `ssh_allow_users` | - | Restrict SSH to specific users | + +See `bootstrap.example.yml` for all options. + +## Building + +```bash +pip install ansible ansible-bundler +ansible-bundler bootstrap.yml -o bootstrap.run +sha256sum bootstrap.run > bootstrap.run.sha256 +``` + +## CI/CD + +The included Gitea Actions workflow (`.gitea/workflows/build.yml`) automatically builds and publishes the bundle on push to main. + +Required secret: `DEPLOY_TOKEN` - Gitea token with `write:package` scope. + +## License + +MIT diff --git a/bootstrap.example.yml b/bootstrap.example.yml new file mode 100644 index 0000000..a102e70 --- /dev/null +++ b/bootstrap.example.yml @@ -0,0 +1,46 @@ +--- +# Example configuration for bootstrap.yml +# Copy this to bootstrap.vars.yml and customize + +# Required: User configuration +user_name: "operator" + +# SSH public key (choose one method) +# Method 1: Direct key +user_pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... your-key-comment" + +# Method 2: From local file +# user_pubkey_file: "~/.ssh/id_ed25519.pub" + +# Method 3: From URL (e.g., GitHub keys) +# user_pubkey_url: "https://github.com/username.keys" + +# Optional: User password (for console access or sudo with password) +# user_password: "changeme" + +# Optional: User configuration +# user_home: "/home/operator" +# user_uid: 1000 +# user_shell: /bin/bash +# user_groups: +# - wheel +# - docker + +# Sudo configuration +user_sudo_enabled: true +user_sudo_nopasswd: true + +# Replace all existing authorized_keys with just this key +user_pubkey_exclusive: true + +# SSH hardening options (defaults are secure) +# ssh_server_ports: ["22"] +# ssh_permit_root_login: "no" +# ssh_server_password_login: false +# ssh_max_auth_retries: 2 + +# Restrict SSH access to specific users (recommended after setup) +# ssh_allow_users: "operator" + +# Allow TCP forwarding if needed (for SSH tunnels) +# ssh_allow_tcp_forwarding: "local" diff --git a/bootstrap.yml b/bootstrap.yml new file mode 100644 index 0000000..8e66db2 --- /dev/null +++ b/bootstrap.yml @@ -0,0 +1,88 @@ +--- +# Generic Host Bootstrap Playbook +# +# This playbook sets up a user and hardens SSH on target hosts. +# Designed to be built with ansible-bundler. +# +# Usage: +# ansible-playbook bootstrap.yml -i inventory -e @vars.yml +# +# Or with bundled version: +# ./bootstrap.run -e user_name=operator -e user_pubkey="ssh-ed25519 AAAA..." +# +# Required variables: +# - user_name: Username to create +# - One of: user_pubkey, user_pubkey_file, user_pubkey_url +# +# Optional variables (see roles/users/defaults/main.yml for full list): +# - user_password: Password for the user +# - user_sudo_enabled: Enable sudo (default: true) +# - user_sudo_nopasswd: Passwordless sudo (default: true) +# - ssh_server_ports: SSH port(s) (default: ["22"]) +# - ssh_permit_root_login: Allow root login (default: "no") +# + +- name: Bootstrap and harden host + hosts: all + gather_facts: yes + + vars: + # User defaults (override via -e or vars file) + user_name: "operator" + + # SSH hardening defaults - secure by default + ssh_permit_root_login: "no" + ssh_server_password_login: false + ssh_client_password_login: false + ssh_allow_tcp_forwarding: "no" + ssh_allow_agent_forwarding: false + ssh_x11_forwarding: false + ssh_permit_tunnel: "no" + ssh_use_pam: true + ssh_print_motd: false + ssh_print_last_log: false + ssh_max_auth_retries: 2 + ssh_client_alive_interval: 300 + ssh_client_alive_count: 3 + + # Include sshd_config.d for distro-specific configs + sshd_custom_options: + - "Include /etc/ssh/sshd_config.d/*" + + # Restrict SSH to created user (set to empty string to allow all users) + # ssh_allow_users: "{{ user_name }}" + + pre_tasks: + - name: Update apt cache + ansible.builtin.apt: + update_cache: yes + cache_valid_time: 3600 + become: yes + when: ansible_os_family == "Debian" + + - name: Ensure sudo is installed + ansible.builtin.package: + name: sudo + state: present + become: yes + + roles: + - role: users + become: yes + + - role: ssh_hardening + become: yes + + post_tasks: + - name: Display connection info + ansible.builtin.debug: + msg: | + Host bootstrap complete! + + User '{{ user_name }}' has been created with SSH key authentication. + SSH has been hardened with the following settings: + - Root login: {{ ssh_permit_root_login }} + - Password authentication: {{ ssh_server_password_login }} + - Port(s): {{ ssh_server_ports | default(['22']) | join(', ') }} + + To connect: ssh {{ user_name }}@{{ ansible_host | default(inventory_hostname) }} diff --git a/roles/users/defaults/main.yml b/roles/users/defaults/main.yml new file mode 100644 index 0000000..0d20c38 --- /dev/null +++ b/roles/users/defaults/main.yml @@ -0,0 +1,37 @@ +--- +# User configuration defaults + +# Username to create (required - must be overridden) +# user_name: "operator" + +# User shell +user_shell: /bin/bash + +# Home directory (defaults to /home/{{ user_name }} if not set) +# user_home: "/home/operator" + +# User UID (optional - let system assign if not set) +# user_uid: 1000 + +# Additional groups for the user (optional) +# user_groups: +# - wheel +# - docker + +# Append to existing groups instead of replacing +user_groups_append: true + +# User password (optional - if not set, password login disabled) +# user_password: "changeme" + +# Sudo configuration +user_sudo_enabled: true +user_sudo_nopasswd: true + +# SSH public key (exactly one of these must be defined) +# user_pubkey: "ssh-ed25519 AAAA..." +# user_pubkey_file: "/path/to/key.pub" +# user_pubkey_url: "https://github.com/username.keys" + +# Replace all existing authorized keys with just this one +user_pubkey_exclusive: true diff --git a/roles/users/meta/main.yml b/roles/users/meta/main.yml new file mode 100644 index 0000000..bb672ce --- /dev/null +++ b/roles/users/meta/main.yml @@ -0,0 +1,25 @@ +--- +galaxy_info: + author: "Jack" + description: "Creates and configures a user with SSH key authentication and optional sudo access" + license: "MIT" + min_ansible_version: "2.9" + platforms: + - name: Archlinux + versions: + - all + - name: Debian + versions: + - bullseye + - bookworm + - name: Ubuntu + versions: + - focal + - jammy + - noble + - name: EL + versions: + - "8" + - "9" + +dependencies: [] diff --git a/roles/users/tasks/main.yml b/roles/users/tasks/main.yml new file mode 100644 index 0000000..b716e73 --- /dev/null +++ b/roles/users/tasks/main.yml @@ -0,0 +1,66 @@ +--- +- name: Validate pubkey configuration + ansible.builtin.fail: + msg: "Only one of pubkey, pubkey_file, or pubkey_url can be defined" + when: > + (user_pubkey is defined and (user_pubkey_file is defined or user_pubkey_url is defined)) or + (user_pubkey_file is defined and user_pubkey_url is defined) + +- name: Validate at least one pubkey source is defined + ansible.builtin.fail: + msg: "At least one of pubkey, pubkey_file, or pubkey_url must be defined" + when: + - user_pubkey is not defined + - user_pubkey_file is not defined + - user_pubkey_url is not defined + +- name: Create user account + ansible.builtin.user: + name: "{{ user_name }}" + state: present + shell: "{{ user_shell }}" + createhome: yes + home: "{{ user_home | default('/home/' + user_name) }}" + uid: "{{ user_uid | default(omit) }}" + groups: "{{ user_groups | default(omit) }}" + append: "{{ user_groups_append | default(true) }}" + password: "{{ user_password | password_hash('sha512') if user_password is defined else omit }}" + update_password: "{{ 'always' if user_password is defined else 'on_create' }}" + become: yes + +- name: Configure sudoers for user + ansible.builtin.lineinfile: + dest: /etc/sudoers.d/{{ user_name }} + line: "{{ user_name }} ALL=(ALL) {{ 'NOPASSWD: ' if user_sudo_nopasswd else '' }}ALL" + create: yes + mode: "0440" + validate: 'visudo -cf %s' + become: yes + when: user_sudo_enabled | bool + +- name: Add SSH public key (direct) + ansible.builtin.authorized_key: + user: "{{ user_name }}" + key: "{{ user_pubkey }}" + state: present + exclusive: "{{ user_pubkey_exclusive | bool }}" + become: yes + when: user_pubkey is defined + +- name: Add SSH public key (from file) + ansible.builtin.authorized_key: + user: "{{ user_name }}" + key: "{{ lookup('file', user_pubkey_file) }}" + state: present + exclusive: "{{ user_pubkey_exclusive | bool }}" + become: yes + when: user_pubkey_file is defined + +- name: Add SSH public key (from URL) + ansible.builtin.authorized_key: + user: "{{ user_name }}" + key: "{{ lookup('url', user_pubkey_url) }}" + state: present + exclusive: "{{ user_pubkey_exclusive | bool }}" + become: yes + when: user_pubkey_url is defined