Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Brewfile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions ansible/hosts/lily.yml
Original file line number Diff line number Diff line change
@@ -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]
4 changes: 3 additions & 1 deletion ansible/requirements.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 19 additions & 1 deletion ansible/roles/dev_user/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
130 changes: 130 additions & 0 deletions ansible/security-group.yml
Original file line number Diff line number Diff line change
@@ -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:<region>:<account>:instance/<id>.
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 }}
Loading