From cbfa239a8e3c9a7847151a1f4090c2fc32f2907a Mon Sep 17 00:00:00 2001 From: Lily Dayton Date: Fri, 21 Aug 2026 10:01:32 +0300 Subject: [PATCH 1/4] Give the role the two things a cloud box needed, and stop asking for them each time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provisioning a fresh EC2 box surfaced three defaults written for a laptop: the login shell lives inside the Homebrew prefix, the home is required to sit on its own filesystem, and the Brewfile assumes brew is installed. A cloud image has none of that, so two of the role's own guards refuse the run and the third would install nothing. Each was answerable, but answering them by hand on every box is how a preset earns its place. `ansible/hosts/lily.yml` is that preset — the shell, the filesystem requirement and the Brewfile turned off, Claude Code turned on, and the authorized keys. It is passed with `-e @hosts/lily.yml` rather than made role defaults: a fork of this public repo that inherited the keys would provision boxes those keys can log into. That placement is also the only one that works, since `-e` replaces the variable wholesale and a key added to defaults is discarded on every run that passes the preset. `ansible/security-group.yml` opens ssh to an address on the group a box already carries. It takes an instance ARN and an address and reads everything else from them: the region and instance id are ARN fields, and the group is read off the instance, so nothing here names a box or a group. Four things it refuses rather than guesses — missing inputs, an ARN that is not an EC2 instance, an address carrying its own prefix, and an instance that returns anything other than exactly one group. `purge_rules` is false and commented, because the default strips every rule the play was not told about, which on a shared group is how it would quietly take ssh away from somebody else. The play runs from the operator's machine rather than the target. A box's own instance profile has no business holding ec2:AuthorizeSecurityGroupIngress, and the ones this was written against do not. Claude-Session: https://claude.ai/code/session_01C9wuTPhHTSS4pvqgTq3A26 --- ansible/hosts/lily.yml | 26 +++++++++ ansible/requirements.yml | 4 +- ansible/security-group.yml | 117 +++++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 ansible/hosts/lily.yml create mode 100644 ansible/security-group.yml diff --git a/ansible/hosts/lily.yml b/ansible/hosts/lily.yml new file mode 100644 index 0000000..83a75d2 --- /dev/null +++ b/ansible/hosts/lily.yml @@ -0,0 +1,26 @@ +--- +# Defaults for provisioning a cloud dev box as lily. Passed explicitly, never made role +# defaults: a fork of this public repo that inherited the key below would provision boxes +# this account can log into. +# +# ansible-playbook -i inventory.ini site.yml -e @hosts/lily.yml + +dev_user_authorized_keys: + - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHEHSv9U28JSaick1k4nTgA5Fb4nDUM1RYqv/uIlxBZ+ lily.dayton@mac" + - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA3frltPWfDQwvR8Cb4CnsSjR605S/lSLTsI0IG2aG8D lily.dayton@wsl" + +# A cloud image has neither Homebrew nor zsh, and the role's default shell lives inside the +# brew prefix -- so the shell guard refuses every box that has no prefix. +dev_user_shell: /bin/bash +dev_user_brewfile: false + +# The home lands on the root volume. A cloud box's separate filesystem, where it has one, is +# the instance store its own workload already claimed. +dev_user_home_require_separate_fs: false + +dev_user_claude_code: true +dev_user_overlay_repo: "" + +# Root on a box that belongs to other things is a grant worth naming per host. +# dev_user_sudo_nopasswd: true +# dev_user_groups: [docker] diff --git a/ansible/requirements.yml b/ansible/requirements.yml index 47508df..0d33778 100644 --- a/ansible/requirements.yml +++ b/ansible/requirements.yml @@ -1,6 +1,8 @@ --- -# authorized_key lives in ansible.posix; git_config in community.general. Install with: +# authorized_key lives in ansible.posix; git_config in community.general; the security-group +# play needs amazon.aws. Install with: # ansible-galaxy collection install -r requirements.yml collections: - name: ansible.posix - name: community.general + - name: amazon.aws diff --git a/ansible/security-group.yml b/ansible/security-group.yml new file mode 100644 index 0000000..d113f75 --- /dev/null +++ b/ansible/security-group.yml @@ -0,0 +1,117 @@ +--- +# Opens ssh to an address on the security group a running instance already carries. Two inputs, +# and everything else is read from them: the ARN carries the region and the instance id, and the +# instance carries the group. +# +# ansible-playbook security-group.yml \ +# -e instance_arn=arn:aws:ec2:us-east-2:1234:instance/i-0abc -e allow_ips=1.2.3.4 +# +# allow_ips takes one address or several comma-separated; each is authorized as a /32. +# +# Runs from the operator's machine, not the target -- a box's own instance profile has no +# business holding ec2:AuthorizeSecurityGroupIngress, and these do not. +# +# A group created by a stack is owned by that stack, so a rule added here is drift its next +# deploy reverts. Fine for reaching a box today, wrong as a way to keep it reachable. +- name: Authorize ssh from an address + hosts: localhost + connection: local + gather_facts: false + + vars: + ssh_port: 22 + arn_parts: "{{ instance_arn | default('') | split(':') }}" + # A list either way, so one address needs no special spelling at the call site. + allow_list: >- + {{ allow_ips if allow_ips | default('') is sequence and allow_ips is not string + else allow_ips | default('') | split(',') | map('trim') | reject('equalto', '') | list }} + + tasks: + - name: Refuse a run that names no instance or address + ansible.builtin.fail: + msg: >- + Set instance_arn and allow_ips. Neither is inferred: a group opened in whatever region + the environment happened to name is not a mistake anything reports. + when: instance_arn | default('') | length == 0 or allow_list | length == 0 + + - name: Refuse anything that is not an EC2 instance ARN + ansible.builtin.fail: + msg: >- + '{{ instance_arn }}' is not an EC2 instance ARN. Expected + arn:aws:ec2:::instance/. + when: >- + arn_parts | length != 6 + or arn_parts[0] != 'arn' + or arn_parts[2] != 'ec2' + or not arn_parts[5].startswith('instance/') + + - name: Refuse an address that already carries a mask + ansible.builtin.fail: + msg: >- + allow_ips takes bare addresses; this play appends /32. '{{ item }}' carries its own + prefix, which would open more than one host. + loop: "{{ allow_list }}" + when: "'/' in item" + + - name: What the ARN names + ansible.builtin.set_fact: + target_region: "{{ arn_parts[3] }}" + target_instance: "{{ arn_parts[5] | split('/') | last }}" + + - name: Read the group off the instance + amazon.aws.ec2_instance_info: + region: "{{ target_region }}" + instance_ids: ["{{ target_instance }}"] + register: instance_info + + - name: Refuse an instance that is not there, or carries no single group + ansible.builtin.fail: + msg: >- + {{ target_instance }} in {{ target_region }} returned + {{ instance_info.instances | length }} instances carrying {{ groups_on_box | length }} + security groups. This play authorizes on exactly one. + vars: + groups_on_box: "{{ (instance_info.instances[0].security_groups | default([])) if instance_info.instances | length == 1 else [] }}" + when: instance_info.instances | length != 1 or groups_on_box | length != 1 + + - name: Which group is being modified + ansible.builtin.set_fact: + target_group: "{{ instance_info.instances[0].security_groups[0].group_id }}" + + # ec2_security_group is create-or-update, so state=present demands a name and a description + # even when handed a group_id. Both are read back off the group rather than written here: + # a literal would rename somebody else's group the first time it drifted. + - name: Read the group's own name and description + amazon.aws.ec2_security_group_info: + region: "{{ target_region }}" + filters: + group-id: "{{ target_group }}" + register: group_info + + - name: Authorize each address as a /32 + amazon.aws.ec2_security_group: + group_id: "{{ target_group }}" + name: "{{ group_info.security_groups[0].group_name }}" + description: "{{ group_info.security_groups[0].description }}" + vpc_id: "{{ group_info.security_groups[0].vpc_id }}" + region: "{{ target_region }}" + # The group belongs to something else. Purging is how this play would silently take + # ssh away from whoever else is on it. + purge_rules: false + purge_rules_egress: false + purge_tags: false + rules: + - proto: tcp + from_port: "{{ ssh_port }}" + to_port: "{{ ssh_port }}" + cidr_ip: "{{ allow_list | map('regex_replace', '^(.*)$', '\\1/32') | list }}" + rule_desc: "dev access, added by security-group.yml" + register: sg_result + + - name: What port {{ ssh_port }} allows now + ansible.builtin.debug: + msg: >- + {{ target_group }} ({{ target_region }}): + {{ sg_result.ip_permissions | selectattr('from_port', 'defined') + | selectattr('from_port', 'equalto', ssh_port) + | map(attribute='ip_ranges') | flatten | map(attribute='cidr_ip') | list }} From 605168184d48fe5ef018f7eb834d2cb26c2ad6ad Mon Sep 17 00:00:00 2001 From: Lily Dayton Date: Fri, 21 Aug 2026 10:02:47 +0300 Subject: [PATCH 2/4] Put Ansible in the Brewfile, since the repo now carries a playbook that needs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The role and the security-group play both live here, and neither runs without ansible-playbook. Homebrew's formula is the whole community package rather than ansible-core, so it brings amazon.aws, ansible.posix and community.general with it — which is every collection requirements.yml asks for, and the reason a galaxy install is not also needed. Claude-Session: https://claude.ai/code/session_01C9wuTPhHTSS4pvqgTq3A26 --- Brewfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Brewfile b/Brewfile index 883c476..97353d0 100644 --- a/Brewfile +++ b/Brewfile @@ -5,6 +5,7 @@ brew "python" # latest Python for general development brew "typescript" # typed superset of JavaScript that compiles to plain JS # Development Tools +brew "ansible" # provisioning; bundles the collections ansible/ needs brew "bazelisk" # Bazel launcher (auto-switches per .bazelversion) brew "direnv" # load/unload environment variables per directory brew "fnm" # fast Node.js version manager (auto-switches per .nvmrc) From 350eb2499176fa75b0e0a6b3f67999faca7bbd1b Mon Sep 17 00:00:00 2001 From: Lily Dayton Date: Fri, 21 Aug 2026 10:03:37 +0300 Subject: [PATCH 3/4] Take the sudo and docker grants the preset was leaving to be typed by hand Both were commented out on the reasoning that root is a per-host decision, which is true of the role's defaults and not of this file: a preset for boxes provisioned to be worked on is exactly the place that decision gets made. The docker group is root-equivalent on a rootful daemon, so a preset granting one and withholding the other was not a smaller grant, only a less usable box. Claude-Session: https://claude.ai/code/session_01C9wuTPhHTSS4pvqgTq3A26 --- ansible/hosts/lily.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ansible/hosts/lily.yml b/ansible/hosts/lily.yml index 83a75d2..6b7207a 100644 --- a/ansible/hosts/lily.yml +++ b/ansible/hosts/lily.yml @@ -21,6 +21,8 @@ dev_user_home_require_separate_fs: false dev_user_claude_code: true dev_user_overlay_repo: "" -# Root on a box that belongs to other things is a grant worth naming per host. -# dev_user_sudo_nopasswd: true -# dev_user_groups: [docker] +# Root on a box that belongs to other things is a grant worth naming per host, and this preset +# names it: these boxes are provisioned to be worked on, and the docker group is root-equivalent +# anyway, so withholding sudo beside it buys nothing. +dev_user_sudo_nopasswd: true +dev_user_groups: [docker] From 79a4ccedbfa42a3ee8185b91a03a470e53b95f9c Mon Sep 17 00:00:00 2001 From: Lily Dayton Date: Fri, 21 Aug 2026 10:09:19 +0300 Subject: [PATCH 4/4] Check the address family, and grant a group where it exists Two defects a review of the branch turned up, both of which ran green. The play promised a /32 per address and delivered one only for IPv4. An IPv6 address took the same suffix, and the module normalizes what it is handed to a network address, so 2001:db8::1 arrived as 2001:db8::/32 -- not one host but 2^96 of them. It failed, but on an IPv4 field rejecting an IPv6 value rather than on anything checking, so the same input against a play that spelled the field correctly would have opened the widest rule on the group. The address family is now checked, which also catches input that is not an address at all and used to travel as far as AWS. dev_user_groups went straight into the user module, which fails on a group it cannot find rather than creating it. That made every name in the list something the target had to already have -- and the preset added docker, so a stock cloud image stopped being provisionable at account creation. Groups are now granted where they exist and named where they do not, which is how the brew prefix above is already handled: docker on a box with no daemon is a name, not access. Claude-Session: https://claude.ai/code/session_01C9wuTPhHTSS4pvqgTq3A26 --- ansible/roles/dev_user/tasks/main.yml | 20 +++++++++++++++++++- ansible/security-group.yml | 15 ++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/ansible/roles/dev_user/tasks/main.yml b/ansible/roles/dev_user/tasks/main.yml index 3abfdf5..5c33030 100644 --- a/ansible/roles/dev_user/tasks/main.yml +++ b/ansible/roles/dev_user/tasks/main.yml @@ -137,13 +137,31 @@ first, or set dev_user_shell to a shell that is present (/bin/bash on a stock Ubuntu). when: not (dev_user_shell_stat.stat.exists | default(false)) +# A group is granted where it exists. The user module fails on a name it cannot find, which +# turns dev_user_groups into a list of things every target must already have -- docker on a box +# with no daemon is a name, not access. Same treatment the brew prefix gets above. +- name: Read the groups this box has + ansible.builtin.getent: + database: group + +- name: Which requested groups are actually here + ansible.builtin.set_fact: + dev_user_groups_present: "{{ dev_user_groups | select('in', ansible_facts.getent_group) | list }}" + dev_user_groups_absent: "{{ dev_user_groups | reject('in', ansible_facts.getent_group) | list }}" + +- name: Say which requested groups this box does not have + ansible.builtin.debug: + msg: >- + Not on this box, so not granted: {{ dev_user_groups_absent | join(', ') }}. + when: dev_user_groups_absent | length > 0 + - name: Create the account ansible.builtin.user: name: "{{ dev_user_name }}" comment: "{{ dev_user_comment }}" shell: "{{ dev_user_shell }}" home: "{{ dev_user_home }}" - groups: "{{ dev_user_groups + ([dev_user_brew_group] if dev_user_brew_share_mode == 'group' else []) }}" + groups: "{{ dev_user_groups_present + ([dev_user_brew_group] if dev_user_brew_share_mode == 'group' else []) }}" append: true create_home: true generate_ssh_key: true diff --git a/ansible/security-group.yml b/ansible/security-group.yml index d113f75..bcded09 100644 --- a/ansible/security-group.yml +++ b/ansible/security-group.yml @@ -6,7 +6,9 @@ # ansible-playbook security-group.yml \ # -e instance_arn=arn:aws:ec2:us-east-2:1234:instance/i-0abc -e allow_ips=1.2.3.4 # -# allow_ips takes one address or several comma-separated; each is authorized as a /32. +# allow_ips takes one IPv4 address or several comma-separated; each is authorized as a /32. +# IPv4 only, and checked: /32 is one host there and 2^96 of them in IPv6, so the same suffix +# that makes this play narrow would make it the widest rule on the group. # # Runs from the operator's machine, not the target -- a box's own instance profile has no # business holding ec2:AuthorizeSecurityGroupIngress, and these do not. @@ -53,6 +55,17 @@ loop: "{{ allow_list }}" when: "'/' in item" + # An IPv6 address takes the /32 too, and the module normalizes what it is given to a network + # address -- so 2001:db8::1 arrives as 2001:db8::/32, which is not one host but 2^96. That it + # failed rather than landed was an IPv4 field rejecting an IPv6 value, not a check. + - name: Refuse anything that is not an IPv4 address + ansible.builtin.fail: + msg: >- + allow_ips takes IPv4 addresses and '{{ item }}' is not one. This play appends /32, + which is a single host in IPv4 and a /32 of IPv6 space anywhere else. + loop: "{{ allow_list }}" + when: item | ansible.utils.ipv4 != item + - name: What the ARN names ansible.builtin.set_fact: target_region: "{{ arn_parts[3] }}"