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"