From 97d6892c8302151a281e591c975c96625d1be049 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Thu, 15 Oct 2026 21:24:32 +0530 Subject: [PATCH] Add the prepare-server playbook Two plays: bootstrap as the provider account (ubuntu, over bootstrap.pem), then everything else as the user that first play just created. The split is forced rather than stylistic -- the second play logs in as that user, so its authorized_keys entry and its NOPASSWD sudoers entry both have to exist before it starts. That is why the key tasks live in the 'user' role and 'wheel' runs in the first play: move either into play 2 and it fails with an auth error or "Missing sudo password". Both accounts and both keys are variables. The inventory sits beside the playbook and is read via ansible.cfg, which Ansible picks up from the current working directory rather than the playbook's, so it has to be run from this directory. bootstrap.pem is gitignored -- it is the provider key for the host. --- .gitignore | 3 + prepare-server/ansible.cfg | 15 +++ prepare-server/inventory | 17 ++++ prepare-server/playbook.yml | 37 +++++++ prepare-server/readme.md | 112 +++++++++++++++++++++ prepare-server/roles/common/files/config | 4 + prepare-server/roles/common/tasks/main.yml | 51 ++++++++++ prepare-server/roles/user/tasks/main.yml | 88 ++++++++++++++++ prepare-server/roles/wheel/tasks/main.yml | 22 ++++ prepare-server/roles/zsh/tasks/main.yml | 75 ++++++++++++++ prepare-server/vars/default.yml | 21 ++++ 11 files changed, 445 insertions(+) create mode 100644 prepare-server/ansible.cfg create mode 100644 prepare-server/inventory create mode 100755 prepare-server/playbook.yml create mode 100644 prepare-server/readme.md create mode 100644 prepare-server/roles/common/files/config create mode 100644 prepare-server/roles/common/tasks/main.yml create mode 100644 prepare-server/roles/user/tasks/main.yml create mode 100644 prepare-server/roles/wheel/tasks/main.yml create mode 100644 prepare-server/roles/zsh/tasks/main.yml create mode 100644 prepare-server/vars/default.yml diff --git a/.gitignore b/.gitignore index df5c4e1..4f5eb82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ *.retry .idea + +# Provider bootstrap private key for prepare-server. Never commit this. +bootstrap.pem diff --git a/prepare-server/ansible.cfg b/prepare-server/ansible.cfg new file mode 100644 index 0000000..daacf23 --- /dev/null +++ b/prepare-server/ansible.cfg @@ -0,0 +1,15 @@ +# Ansible configuration for the prepare-server playbook. +# +# This file is only read when you run ansible-playbook from this directory. +# Ansible looks for ansible.cfg in the *current working directory*, not next to +# the playbook, so: +# +# cd prepare-server && ansible-playbook playbook.yml # reads this file +# ansible-playbook prepare-server/playbook.yml # ignores it, falls +# # back to /etc/ansible/hosts +# +# That fallback is silent, so if a host is missing from the inventory below it +# is worth checking which inventory actually got used. + +[defaults] +inventory = inventory diff --git a/prepare-server/inventory b/prepare-server/inventory new file mode 100644 index 0000000..e0f1f52 --- /dev/null +++ b/prepare-server/inventory @@ -0,0 +1,17 @@ +# Inventory for the prepare-server playbook. +# +# Ansible does not discover inventory files by name -- there is no convention +# that makes a file called "inventory" get read. It is used because ansible.cfg +# in this directory names it. + +[servers] +# The alias must match `server` in vars/default.yml, and is what you pass to -l. +# No ansible_host is given because the control node's ~/.ssh/config already maps +# this name to an address. For a host that has no SSH config entry yet, give the +# address the provider handed you instead: +# +# fresh ansible_host=203.0.113.10 +emily + +[servers:vars] +ansible_python_interpreter=/usr/bin/python3 diff --git a/prepare-server/playbook.yml b/prepare-server/playbook.yml new file mode 100755 index 0000000..f83eb7b --- /dev/null +++ b/prepare-server/playbook.yml @@ -0,0 +1,37 @@ +################################################# +# Prepare the server +################################################# +--- +# A freshly provisioned host only has the provider's bootstrap account +# ({{ bootstrap_user }}). That account is used once, to create the real user +# and make it reachable and able to escalate. Everything after this play runs +# as {{ user }} instead. +- name: Bootstrap the new user + hosts: all + become: true + remote_user: "{{ bootstrap_user }}" + vars: + ansible_ssh_private_key_file: "{{ bootstrap_key }}" + vars_files: + - vars/default.yml + + roles: + # Creates {{ user }} and installs its authorized_keys, so that the next + # play can log in as it. + - user + # Has to happen in this play too: the next play runs as {{ user }} with + # become, which needs the NOPASSWD sudoers entry this role writes. + - wheel + +- name: Configure the server as the new user + hosts: all + become: true + remote_user: "{{ user }}" + vars: + ansible_ssh_private_key_file: "{{ user_key }}" + vars_files: + - vars/default.yml + + roles: + - common + - zsh diff --git a/prepare-server/readme.md b/prepare-server/readme.md new file mode 100644 index 0000000..6679182 --- /dev/null +++ b/prepare-server/readme.md @@ -0,0 +1,112 @@ +# Prepare server + +Takes a freshly provisioned Ubuntu host that you can only reach as the provider's +bootstrap account, creates the user you actually want, and configures that account. +When it finishes you log in as `{{ user }}`, not as `ubuntu`. + +This should be the first playbook you run on a new server. The service playbooks +take a `-u [remote user]` and expect that account to already exist — this is the +playbook that creates it. + +## How it works + +Two plays, because the second one logs in as the user the first one creates. + +1. **Bootstrap the new user** — connects as `{{ bootstrap_user }}` (`ubuntu` on + Ubuntu cloud images) with `bootstrap.pem`. Creates `{{ user }}`, installs its + `authorized_keys`, and grants it passwordless sudo. +2. **Configure the server as the new user** — connects as `{{ user }}` with + `{{ user_key }}`. Hardens sshd, installs the user's own SSH config and key, and + sets up zsh. + +Every task after the first play is done as `{{ user }}`, which is the point: the +bootstrap account is used once and then never again. + +## Layout + +``` +prepare-server/ +├── ansible.cfg # read from the CWD; points at ./inventory +├── inventory # the hosts, so -i is not needed +├── bootstrap.pem # provider key: gitignored, must be mode 0600 +├── playbook.yml +├── vars/default.yml +└── roles/ + ├── user/ # play 1 — create the account, install authorized_keys + ├── wheel/ # play 1 — wheel group + passwordless sudo + ├── common/ # play 2 — sshd hardening, .ssh config, the user's own key + └── zsh/ # play 2 — zsh, oh-my-zsh, autosuggestions, syntax highlighting +``` + +## Settings + +| Variable | Purpose | +|---|---| +| `server` | The host name written into the `.ssh/config` on the server. It refers to a specific server, so it is *not* used for key comments — those use `inventory_hostname`, and the two may legitimately differ. | +| `user` | The account to create, and the account every task after the first play runs as. | +| `user_key` | Private key on the control node used to log in as `user`. Its `.pub` sibling must exist — the `user` role authorizes it. | +| `bootstrap_user` | The provider account that exists on a fresh host. `ubuntu` on Ubuntu cloud images. | +| `bootstrap_key` | The provider key that reaches that account: `bootstrap.pem` next to the playbook. | +| `terminus_key`, `clair_key`, `anjin_key` | Additional public keys authorized to log in as `user`. | + +## Prerequisites + +- A fresh host reachable as `{{ bootstrap_user }}`, with passwordless sudo — + cloud images ship it that way. The playbook escalates immediately, so a + bootstrap account that needs a sudo password will not work unattended. +- `bootstrap.pem` in this directory, mode `0600`. OpenSSH refuses to use a + private key that is group- or world-readable, and fails with + *"UNPROTECTED PRIVATE KEY FILE"* rather than anything resembling a permissions + hint. The file is gitignored — do not commit it. +- `~/.ssh/id_ed25519` and `~/.ssh/id_ed25519.pub` on the control node. + +## Running + +```command +cd prepare-server +ansible-playbook playbook.yml +``` + +The `cd` is load-bearing. Ansible reads `ansible.cfg` from the **current working +directory**, not from the playbook's directory, so [ansible.cfg](ansible.cfg) — +and the [inventory](inventory) it names — only apply when you run from in here. +Running `ansible-playbook prepare-server/playbook.yml` from the repository root +ignores both and silently falls back to `/etc/ansible/hosts`, which happens to +list `clair` as well, so it looks like it worked. + +Ansible also has no by-name discovery for inventory files: `inventory` is read +because `ansible.cfg` names it, not because of what it is called. Add `-l ` +once the inventory holds more than one. + +`-u` is deliberately not needed: each play sets `remote_user` itself, so the +bootstrap account is used for the first play and the new user for the second. + +## Why sudo and authorized_keys are set up in the first play + +Both have to happen before the second play connects, not because they logically +belong to bootstrapping: + +- **The second play logs in as `{{ user }}`**, so that account must already have a + working `authorized_keys` entry. That is why the SSH key tasks live in the `user` + role rather than in `common`, where they would otherwise belong. +- **The second play escalates with `become`**, so `{{ user }}` must already be able + to sudo. The `wheel` role writes `/etc/sudoers.d/wheel`, which means it has to run + while we still have an account that can escalate — i.e. during the bootstrap play. + Left until the second play it fails with *"Missing sudo password"*. + +## Notes + +- The `user` role authorizes `{{ user_key }}.pub`, so the control node can always + log back in as `{{ user }}`. If `user_key` is left at its default and + `terminus_key` is that same key, the entry is already there and the task is a + no-op. +- No password is set on `{{ user }}`, which leaves it locked for password logins, + and `common` sets `PasswordAuthentication no` in `sshd_config`. The account is + reachable by key only. +- The `common` role generates an ed25519 key *for* the user on the server. That is + the server's outbound identity and is unrelated to `user_key`, which is the + control node's key for logging in. +- The `user` role deliberately does not set a shell. Doing so would fight the `zsh` + role and flip the shell back on every run. +- `roles/common/files/config` is a template despite the extension, and appends the + `.tanshu.com` suffix to `{{ server }}`. Adjust it for a host outside that domain. diff --git a/prepare-server/roles/common/files/config b/prepare-server/roles/common/files/config new file mode 100644 index 0000000..341ee44 --- /dev/null +++ b/prepare-server/roles/common/files/config @@ -0,0 +1,4 @@ +Host {{ server }} {{ server }}.tanshu.com + HostName {{ server }}.tanshu.com + IdentityFile ~/.ssh/id_ed25519 + User {{ user }} diff --git a/prepare-server/roles/common/tasks/main.yml b/prepare-server/roles/common/tasks/main.yml new file mode 100644 index 0000000..3ce20fc --- /dev/null +++ b/prepare-server/roles/common/tasks/main.yml @@ -0,0 +1,51 @@ +--- +# Tasks for the common role. +# +# Runs in the second play, against the account the server was prepared for +# ({{ user }}). The .ssh directory and the authorized_keys entries are already +# in place by the time this runs: the user role installs them during the +# bootstrap play, because they are what makes logging in as {{ user }} -- +# and therefore this play -- possible in the first place. + +- name: Update the sshd config file to disable password logins + ansible.builtin.replace: + path: /etc/ssh/sshd_config + regexp: "(\\s+)#PasswordAuthentication yes(\\s+.*)?$" + replace: "\\1PasswordAuthentication no\\2" + backup: true + +- name: Check if .ssh config file exists + ansible.builtin.stat: + path: "/home/{{ user }}/.ssh/config" + register: config_status + +- name: No need to upload the .ssh config + when: config_status.stat.exists + ansible.builtin.debug: + msg: No need to upload the .ssh config as it already exists. + +- name: Upload the .ssh config file + when: not config_status.stat.exists + ansible.builtin.template: + src: "files/config" + dest: "/home/{{ user }}/.ssh/config" + group: "{{ user }}" + owner: "{{ user }}" + mode: "0644" + +- name: Check if ed25519 key exists + ansible.builtin.stat: + path: "/home/{{ user }}/.ssh/id_ed25519" + register: key_status + +- name: No need to generate new ed25519 key + when: key_status.stat.exists + ansible.builtin.debug: + msg: No need to generate new ed25519 key as it already exists. + +- name: Generate new id_ed25519 key + when: not key_status.stat.exists + become: true + become_user: "{{ user }}" + ansible.builtin.shell: ssh-keygen -t ed25519 -q -f "/home/{{ user }}/.ssh/id_ed25519" -C "{{ user }}@{{ inventory_hostname }} $(date '+%Y.%m.%d')" -N "" + changed_when: true diff --git a/prepare-server/roles/user/tasks/main.yml b/prepare-server/roles/user/tasks/main.yml new file mode 100644 index 0000000..48d1657 --- /dev/null +++ b/prepare-server/roles/user/tasks/main.yml @@ -0,0 +1,88 @@ +--- +# Tasks for the user role. +# +# Runs in the first (bootstrap) play, against the provider's bootstrap account +# ({{ bootstrap_user }}), and does the two things that must happen before +# anything else can: create the account this server is being prepared for +# ({{ user }}), and install the public keys that make it reachable. Every +# later task logs in as {{ user }} rather than as the bootstrap account, so +# without those keys the second play cannot connect at all. +# +# Authentication is key based only: no password is set, which leaves the +# account locked for password logins. The shell, group membership and the rest +# of the account's configuration belong to the wheel/zsh roles. Setting a +# shell here would fight the zsh role and change it back on every run, so this +# role deliberately does not touch it. + +- name: Create the desired user + ansible.builtin.user: + name: "{{ user }}" + create_home: true + state: present + +- name: Ensure the user's SSH directory exists + ansible.builtin.file: + path: "/home/{{ user }}/.ssh" + state: directory + owner: "{{ user }}" + group: "{{ user }}" + mode: "0700" + +- name: Ensure authorized_keys file exists + ansible.builtin.copy: + content: "" + dest: "/home/{{ user }}/.ssh/authorized_keys" + force: false + owner: "{{ user }}" + group: "{{ user }}" + mode: "0644" + +# The second play logs in as {{ user }} with the private half of this key on +# the control node, so this entry is what makes that login possible. It is +# installed here, during the bootstrap play, rather than being left to depend +# on one of the named keys below happening to be the control node's own. +# +# lineinfile rather than ansible.builtin.authorized_key: the latter redirects +# to ansible.posix, whose 2.1.0 module imports a deprecated helper and emits a +# deprecation warning on every run. This is the same guarantee without it. +- name: Authorize the control node's key for the new user + ansible.builtin.lineinfile: + dest: "/home/{{ user }}/.ssh/authorized_keys" + line: "{{ lookup('file', user_key ~ '.pub') | trim }}" + create: true + owner: "{{ user }}" + group: "{{ user }}" + mode: "0644" + +- name: Check Terminus public key + ansible.builtin.shell: "grep -c \"{{ terminus_key }}\" /home/{{ user }}/.ssh/authorized_keys || true" + register: terminuskey_test + changed_when: false + +- name: Add Terminus public key + ansible.builtin.lineinfile: + dest: "/home/{{ user }}/.ssh/authorized_keys" + line: "{{ terminus_key }}" + when: terminuskey_test.stdout == "0" + +- name: Check clair public key + ansible.builtin.shell: "grep -c \"{{ clair_key }}\" /home/{{ user }}/.ssh/authorized_keys || true" + register: clairkey_test + changed_when: false + +- name: Add clair public key + ansible.builtin.lineinfile: + dest: "/home/{{ user }}/.ssh/authorized_keys" + line: "{{ clair_key }}" + when: clairkey_test.stdout == "0" + +- name: Check Anjin public key + ansible.builtin.shell: "grep -c \"{{ anjin_key }}\" /home/{{ user }}/.ssh/authorized_keys || true" + register: anjin_key_test + changed_when: false + +- name: Add Anjin public key + ansible.builtin.lineinfile: + dest: "/home/{{ user }}/.ssh/authorized_keys" + line: "{{ anjin_key }}" + when: anjin_key_test.stdout == "0" diff --git a/prepare-server/roles/wheel/tasks/main.yml b/prepare-server/roles/wheel/tasks/main.yml new file mode 100644 index 0000000..a578c64 --- /dev/null +++ b/prepare-server/roles/wheel/tasks/main.yml @@ -0,0 +1,22 @@ +--- +- name: Ensure group wheel exists + ansible.builtin.group: + name: wheel + state: present + +- name: Allow wheel group to have passwordless sudo + ansible.builtin.copy: + # A block scalar, so the file ends with a newline. With an inline string + # there is no trailing terminator and visudo rejects it with + # "syntax error: missing line terminator at end of file". + content: | + %wheel ALL=(ALL) NOPASSWD: ALL + dest: /etc/sudoers.d/wheel + mode: "0440" + validate: "/usr/sbin/visudo -cf %s" + +- name: Adding user to group wheel + ansible.builtin.user: + name: "{{ user }}" + groups: wheel + append: true diff --git a/prepare-server/roles/zsh/tasks/main.yml b/prepare-server/roles/zsh/tasks/main.yml new file mode 100644 index 0000000..23b53a7 --- /dev/null +++ b/prepare-server/roles/zsh/tasks/main.yml @@ -0,0 +1,75 @@ +--- +- name: Install ZSH and required packages + ansible.builtin.package: + name: + - zsh + - tmux + - powerline + - git + state: present + update_cache: true + +- name: Change default shell to ZSH + ansible.builtin.user: + name: "{{ user }}" + shell: /bin/zsh + +- name: Check if oh-my-zsh is already installed + ansible.builtin.stat: + path: "/home/{{ user }}/.oh-my-zsh" + register: oh_my_zsh_stat + +- name: Install oh-my-zsh + ansible.builtin.git: + repo: https://github.com/robbyrussell/oh-my-zsh.git + dest: "/home/{{ user }}/.oh-my-zsh" + depth: 1 + when: not oh_my_zsh_stat.stat.exists + become: true + become_user: "{{ user }}" + +- name: Install zsh-autosuggestions plugin + ansible.builtin.git: + repo: https://github.com/zsh-users/zsh-autosuggestions + dest: "/home/{{ user }}/.oh-my-zsh/custom/plugins/zsh-autosuggestions" + depth: 1 + become: true + become_user: "{{ user }}" + +- name: Install zsh-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" + depth: 1 + become: true + become_user: "{{ user }}" + +- name: Ensure .zshrc exists (copy template if needed) + ansible.builtin.copy: + src: "/home/{{ user }}/.oh-my-zsh/templates/zshrc.zsh-template" + dest: "/home/{{ user }}/.zshrc" + remote_src: true + force: false + owner: "{{ user }}" + group: "{{ user }}" + mode: "0644" + +- name: Set ZSH theme to agnoster + ansible.builtin.replace: + path: "/home/{{ user }}/.zshrc" + regexp: 'ZSH_THEME="robbyrussell"' + replace: 'ZSH_THEME="agnoster"' + +- name: Enable ZSH plugins + ansible.builtin.replace: + path: "/home/{{ user }}/.zshrc" + regexp: 'plugins=\(git\)' + replace: "plugins=(git zsh-autosuggestions zsh-syntax-highlighting)" + +- name: Add custom aliases to .zshrc + ansible.builtin.lineinfile: + path: "/home/{{ user }}/.zshrc" + line: "alias ls='ls --color=auto --indicator-style=slash --human-readable --format=long'" + create: true + owner: "{{ user }}" + group: "{{ user }}" diff --git a/prepare-server/vars/default.yml b/prepare-server/vars/default.yml new file mode 100644 index 0000000..d85f6f8 --- /dev/null +++ b/prepare-server/vars/default.yml @@ -0,0 +1,21 @@ +--- +server: "clair" + +# The account this server is being prepared for. Created by the first play, +# and the account every task after that logs in as. +user: "tanshu" + +# Private key on the control node that logs in as {{ user }}. Its public key +# must be one of the keys the user role installs into authorized_keys, +# otherwise the second play has no way to connect. +user_key: "~/.ssh/id_ed25519" + +# The account and key that can reach a freshly provisioned host: the +# provider's bootstrap account, which on Ubuntu cloud images is "ubuntu". +# Only the first play uses these; everything afterwards runs as {{ user }}. +bootstrap_user: "ubuntu" +bootstrap_key: "{{ playbook_dir }}/bootstrap.pem" + +terminus_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK8WQHla0uCcNwmN0DUE49lbjNWa6+7A6OxrX3WEPQH0 tanshu@terminus 2023.01.08" +clair_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBeTz3weHvjJRTlBg+ovP3KfQNDvyn9mVsekAGwX1HMn tanshu@clair 2025-06-30" +anjin_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMlvalE0JqKyjpEsGTgtf/N1d9QK2MgNFJib+e8O2h9M tanshu@anjin 2024.09.28"