Files
playbooks/prepare-server/readme.md
T
tanshu 97d6892c83 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.
2026-10-15 21:24:32 +05:30

113 lines
5.5 KiB
Markdown

# 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 <host>`
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.