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) diff --git a/ansible/hosts/lily.yml b/ansible/hosts/lily.yml new file mode 100644 index 0000000..6b7207a --- /dev/null +++ b/ansible/hosts/lily.yml @@ -0,0 +1,28 @@ +--- +# 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, 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] 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/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 new file mode 100644 index 0000000..bcded09 --- /dev/null +++ b/ansible/security-group.yml @@ -0,0 +1,130 @@ +--- +# 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 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. +# +# 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" + + # 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] }}" + 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 }}