diff options
34 files changed, 771 insertions, 435 deletions
@@ -2,7 +2,7 @@ README for debian-ansible ============================================================================= This is a small collection of Ansible roles for Debian targets. These -roles care chosen based on generic usefulness, and are meant to be +roles are chosen based on generic usefulness, and are meant to be highly cohesive, loosely coupled, and parameterised as needed. Legalese @@ -4,26 +4,21 @@ set -eu -o pipefail -cat_with_sep() -{ - for x in "$@" - do - cat "$x" - echo - done +cat_with_sep() { + for x in "$@"; do + cat "$x" + echo + done } -quiet=-q hideok=chronic -if [ "$#" -gt 0 ] -then - case "$1" in +if [ "$#" -gt 0 ]; then + case "$1" in verbose | -v | --verbose) - quiet= - hideok= - shift 1 - ;; - esac + hideok= + shift 1 + ;; + esac fi dir="$(mktemp -d -p .)" @@ -31,31 +26,31 @@ dir="$(mktemp -d -p .)" trap 'rm -rf "$dir"' EXIT rm -f test.log test.py -cp subplot.md "$dir" -cat_with_sep subplot.md roles/*/subplot.md > "$dir/subplot.md" -cat_with_sep subplot/*.py roles/*/subplot.py > "$dir/subplot.py" -cat_with_sep subplot/*.yaml roles/*/subplot.yaml > "$dir/subplot.yaml" +cp subplot.subplot subplot.md "$dir" +cat_with_sep subplot.md roles/*/subplot.md >"$dir/subplot.md" +cat_with_sep subplot/*.py roles/*/subplot.py >"$dir/subplot.py" +cat_with_sep subplot/*.yaml roles/*/subplot.yaml >"$dir/subplot.yaml" ( - set -eu -o pipefail - cd "$dir" - sp-docgen subplot.md -o ../subplot.pdf - sp-docgen subplot.md -o ../subplot.html - sp-codegen subplot.md -o ../test.py + set -eu -o pipefail + cd "$dir" + subplot docgen subplot.subplot -o ../subplot.pdf + subplot docgen subplot.subplot -o ../subplot.html + subplot codegen subplot.subplot -o ../test.py ) # Fix private key permissions. git doesn't preserve them. chmod 0600 ssh/id # Create configuration for the test VMs used by the test suite. -cat > test.cfg <<EOF +cat >test.cfg <<EOF name: debian-ansible-test -base_image: "$HOME/tmp/debian-10-openstack-amd64.qcow2" +base_image: "$HOME/tmp/debian.qcow2" username: debian cpus: 2 memory: 1024 EOF -$hideok python3 test.py --log test.log "$@" +$hideok python3 test.py --log test.log --env ANSIBLE_STDOUT_CALLBACK=yaml "$@" echo "Everything seems to be in order." diff --git a/roles/apache_server/tasks/main.yml b/roles/apache_server/tasks/main.yml index e01d01e..5509818 100644 --- a/roles/apache_server/tasks/main.yml +++ b/roles/apache_server/tasks/main.yml @@ -67,6 +67,7 @@ name: "{{ item }}" with_items: - ssl + - rewrite - name: create dirs for static site contents file: @@ -101,6 +102,12 @@ notify: - restart apache +- name: set default charset to utf8 + copy: + content: | + AddDefaultCharset UTF-8 + dest: /etc/apache2/conf-available/charset.conf + - name: "install htpasswd files" copy: content: "{{ item.htpasswd }}" diff --git a/roles/apache_server/templates/virtualhost.conf.tmpl b/roles/apache_server/templates/virtualhost.conf.tmpl index 8d069ce..1e14db5 100644 --- a/roles/apache_server/templates/virtualhost.conf.tmpl +++ b/roles/apache_server/templates/virtualhost.conf.tmpl @@ -8,6 +8,10 @@ ErrorLog /var/log/apache2/{{ item.domain }}/error.log CustomLog /var/log/apache2/{{ item.domain }}/access.log combined <Directory /srv/http/{{ item.domain }}> +{% if item.redirect|default(false) %} + Redirect permanent / "https://{{ item.redirect }}/" + Require all granted +{% else %} {% if item.letsencrypt|default(false) %} Redirect permanent / "https://{{ item.domain }}/" Require all granted @@ -23,6 +27,7 @@ Require all granted {% endif %} {% endif %} +{% endif %} </Directory> Alias /.well-known/ /srv/letsencrypt/{{ item.domain }}/ @@ -45,6 +50,10 @@ CustomLog /var/log/apache2/{{ item.domain }}/access.log combined <Directory /srv/http/{{ item.domain }}> Options +SymlinksIfOwnerMatch +Indexes +MultiViews +{% if item.redirect|default(false) %} + Redirect permanent / "https://{{ item.redirect }}/" + Require all granted +{% else %} {% if item.htpasswd is defined %} AuthType Basic AuthName "{{ item.htpasswd_name }}" @@ -54,6 +63,7 @@ AllowOverride AuthConfig Require all granted {% endif %} +{% endif %} </Directory> SSLEngine on diff --git a/roles/gitano_server/tasks/main.yml b/roles/gitano_server/tasks/main.yml index 08486fa..b25d142 100644 --- a/roles/gitano_server/tasks/main.yml +++ b/roles/gitano_server/tasks/main.yml @@ -1,3 +1,6 @@ -- include: gitano.yml -- include: git-daemon.yml -- include: cgit.yml +- ansible.builtin.import_tasks: + file: gitano.yml +- ansible.builtin.import_tasks: + file: git-daemon.yml +- ansible.builtin.import_tasks: + file: cgit.yml diff --git a/roles/radicle_node/README.md b/roles/radicle_node/README.md new file mode 100644 index 0000000..b653160 --- /dev/null +++ b/roles/radicle_node/README.md @@ -0,0 +1,2 @@ +This role, `radicle_node`, sets up a Debian system to be a +[Radicle](https://radicle.xyz/) node. diff --git a/roles/radicle_node/files/rad-config-pin b/roles/radicle_node/files/rad-config-pin new file mode 100644 index 0000000..0e40f00 --- /dev/null +++ b/roles/radicle_node/files/rad-config-pin @@ -0,0 +1,23 @@ +#!/usr/bin/python3 + +import json, os, subprocess, sys + +rid = sys.argv[1] + +p = subprocess.run(["rad", "config", "show"], check=True, capture_output=True) +if p.returncode != 0: + sys.exit("rad config show failed") +config = json.loads(p.stdout.decode()) + +config["web"]["pinned"]["repositories"].append(rid) + +p = subprocess.run(["rad", "self", "--home"], check=True, capture_output=True) +if p.returncode != 0: + sys.exit("rad self --home failed") + +home = p.stdout.decode().strip() +filename = os.path.join(home, "config.json") +if os.path.exists(filename): + os.rename(filename, filename + ".bak") +with open(filename, "w") as f: + f.write(json.dumps(config, indent=4)) diff --git a/roles/radicle_node/files/rad-config-update b/roles/radicle_node/files/rad-config-update new file mode 100644 index 0000000..40dd1a9 --- /dev/null +++ b/roles/radicle_node/files/rad-config-update @@ -0,0 +1,34 @@ +#!/usr/bin/python3 + +import json, os, subprocess, sys + +alias = sys.argv[1] +ext = sys.argv[2] +policy = sys.argv[3] +scope = sys.argv[4] +peer = sys.argv[5] + +p = subprocess.run(["rad", "config", "show"], capture_output=True) +if p.returncode != 0: + sys.exit("rad config show failed") +config = json.loads(p.stdout.decode()) + +config["node"]["alias"] = alias +config["node"]["externalAddresses"] = [ext] +config["node"]["policy"] = policy +config["node"]["scope"] = scope + +nodes = config["node"]["connect"] +if peer not in nodes: + nodes.append(peer) + +p = subprocess.run(["rad", "self", "--home"], check=True, capture_output=True) +if p.returncode != 0: + sys.exit("rad self --home failed") + +home = p.stdout.decode().strip() +filename = os.path.join(home, "config.json") +if os.path.exists(filename): + os.rename(filename, filename + ".bak") +with open(filename, "w") as f: + f.write(json.dumps(config, indent=4)) diff --git a/roles/radicle_node/tasks/main.yml b/roles/radicle_node/tasks/main.yml new file mode 100644 index 0000000..8e04a83 --- /dev/null +++ b/roles/radicle_node/tasks/main.yml @@ -0,0 +1,242 @@ +- name: "check radicle_node_version" + shell: | + [ "{{ radicle_node_version }}" = "1" ] || \ + (echo "Unexpected version {{ radicle_node_version }}" 1>&2; exit 1) + +- name: "check that radicle_node_key is set" + shell: | + echo radicle_node_key Ansible variable is not set + exit 1 + when: radicle_node_key is not defined + +- name: "check that radicle_node_key_pub is set" + shell: | + echo radicle_node_key_pub Ansible variable is not set + exit 1 + when: radicle_node_key_pub is not defined + +- name: "install important additional packages for Radicle" + apt: + name: + # For the Radicle installer + - curl + + # Radicle is built on git. + - git + + # Rsync for backups. + - rsync + + # Web server for the web UI. + - caddy + + # Radicle components. + - radicle + - radicle-ci-broker + - radicle-native-ci + +- name: "stop Radicle node if it's running" + shell: | + systemctl stop radicle-node || true + +- name: "stop Radicle CI broker if it's running" + shell: | + systemctl stop radicle-ci-broker || true + +- name: "configure git for _rad user" + shell: | + sudo -u _rad git config --global user.name "_rad" + sudo -u _rad git config --global user.email "liw@liw.fi" + +- name: "create directory for Radicle for the _rad user" + file: + state: directory + path: /home/_rad/.radicle + owner: _rad + group: _rad + mode: 0755 + +- name: "create directory for web pages" + file: + state: directory + path: /srv/http + owner: _rad + group: _rad + mode: 0755 + +- name: "create directory for Radicle backup" + when: radicle_node_backup is defined + file: + state: directory + path: radicle-backup + owner: root + group: root + mode: 0755 + +- name: "restore from backup (step 1 or 2)" + when: radicle_node_backup is defined + synchronize: + src: "{{ radicle_node_backup }}/." + dest: radicle-backup/. + group: no + owner: no + +- name: "restore from backup (step 2 or 2)" + when: radicle_node_backup is defined + shell: | + find radicle-backup -name control.sock -delete + rsync -a --del radicle-backup/home/_rad/.radicle/. /home/_rad/.radicle/. + rsync -a --del radicle-backup/srv/http/. /srv/http/. + chown -R _rad:_rad /home/_rad/.radicle/. /srv/http/. + +- name: "create directory for Radicle keys" + file: + state: directory + path: /home/_rad/.radicle/keys + owner: _rad + group: _rad + mode: 0755 + +- name: "install Radicle private key" + copy: + content: "{{ radicle_node_key }}" + dest: /home/_rad/.radicle/keys/radicle + owner: _rad + group: _rad + mode: 0600 + +- name: "install Radicle public key" + copy: + content: "{{ radicle_node_key_pub }}" + dest: /home/_rad/.radicle/keys/radicle.pub + owner: _rad + group: _rad + mode: 0644 + +- name: "install systemd unit for Radicle node" + template: + src: radicle-node.service.j2 + dest: /lib/systemd/system/radicle-node.service + +- name: "init Radicle node config" + shell: | + if [ ! -e /home/_rad/.radicle/config.json ]; then + sudo -u _rad -i rad config init --alias "{{ radicle_node_domain_name }}" + fi + +- name: "(re)start systemd unit for Radicle node" + systemd: + name: radicle-node + state: restarted + masked: no + enabled: yes + daemon_reload: yes + +- name: "install script to add update Radicle config file" + when: radicle_node_connections is defined + copy: + src: rad-config-update + dest: /home/_rad/rad-config-update + owner: _rad + group: _rad + mode: 0755 + +- name: "connect to other Radicle nodes" + when: radicle_node_connections is defined + with_items: "{{ radicle_node_connections }}" + shell: | + sudo -u _rad -i ./rad-config-update \ + "{{ radicle_node_domain_name }}" \ + "{{ radicle_node_domain_name }}:8776" \ + "{{ radicle_node_policy }}" \ + "{{ radicle_node_scope }}" \ + "{{ item.nid }}@{{ item.host }}:{{ item.port }}" + +- name: "install script to add update Radicle repository pinning" + when: radicle_node_repositories is defined + copy: + src: rad-config-pin + dest: /home/_rad/rad-config-pin + owner: _rad + group: _rad + mode: 0755 + +- name: "seed Radicle repositories" + when: radicle_node_repositories is defined + with_items: "{{ radicle_node_repositories }}" + shell: | + sudo -u _rad rad seed "{{ item.rid }}" + sudo -u _rad -i ./rad-config-pin "{{ item.rid }}" + +- name: "install Caddy configuation file" + template: + src: Caddyfile.j2 + dest: /etc/caddy/Caddyfile + +- name: "create directory for CI logs" + file: + state: directory + path: /srv/http + owner: _rad + group: _rad + +- name: "restart Caddy" + systemd: + name: caddy + state: restarted + masked: no + enabled: yes + daemon_reload: yes + +- name: "install systemd unit for Radicle HTTPD" + template: + src: radicle-httpd.service.j2 + dest: /lib/systemd/system/radicle-httpd.service + +- name: "enable systemd unit for Radicle HTTPD" + systemd: + name: radicle-httpd + state: restarted + masked: no + enabled: yes + daemon_reload: yes + +- name: "install Radicle CI broker config" + copy: + content: | + {{ radicle_node_ci_broker_config }} + dest: /home/_rad/ci-broker.yaml + owner: _rad + group: _rad + mode: 0644 + +- name: "create state directory for Radicle native CI" + file: + state: directory + path: /home/_rad/native-ci.state + owner: _rad + group: _rad + mode: 0755 + +- name: "install Radicle native CI config" + copy: + content: | + state: /srv/http + log: /home/_rad/native-ci.log + dest: /home/_rad/native-ci.yaml + owner: _rad + group: _rad + mode: 0644 + +- name: "install systemd unit for Radicle CI broker" + template: + src: radicle-ci-broker.service.j2 + dest: /lib/systemd/system/radicle-ci-broker.service + +- name: "enable systemd unit for Radicle CI broker" + systemd: + name: radicle-ci-broker + state: restarted + masked: no + enabled: yes + daemon_reload: yes diff --git a/roles/radicle_node/templates/Caddyfile.j2 b/roles/radicle_node/templates/Caddyfile.j2 new file mode 100644 index 0000000..1954b4d --- /dev/null +++ b/roles/radicle_node/templates/Caddyfile.j2 @@ -0,0 +1,14 @@ +:80 { + root * /usr/share/caddy +} +{{ radicle_node_domain_name }}:443 { + reverse_proxy 127.0.0.1:8080 +} +{{ radicle_node_ci_domain_name }}:443 { + root * /srv/http/ + file_server browse +} +{{ radicle_node_wumpus_domain_name }}:443 { + root * /srv/wumpus/ + file_server browse +} diff --git a/roles/radicle_node/templates/radicle-ci-broker.service.j2 b/roles/radicle_node/templates/radicle-ci-broker.service.j2 new file mode 100644 index 0000000..239ccc5 --- /dev/null +++ b/roles/radicle_node/templates/radicle-ci-broker.service.j2 @@ -0,0 +1,18 @@ +[Unit] +After=radicle-node.service +Description=Radicle CI broker + +[Service] +Type=simple +Environment=RAD_HOME=/home/_rad/.radicle +Environment=PATH=/home/_rad/.cargo/bin:/bin:/usr/bin:/sbin:/usr/sbin +Environment=RUST_LOG=info +ExecStart=/bin/ci-broker /home/_rad/ci-broker.yaml +KillMode=control-group +Restart=always +RestartSec=1 +User=_rad +Group=_rad + +[Install] +WantedBy=default.target diff --git a/roles/radicle_node/templates/radicle-httpd.service.j2 b/roles/radicle_node/templates/radicle-httpd.service.j2 new file mode 100644 index 0000000..32b2ecf --- /dev/null +++ b/roles/radicle_node/templates/radicle-httpd.service.j2 @@ -0,0 +1,16 @@ +[Unit] +Description=Radicle HTTP Daemon +After=network.target network-online.target +Requires=network-online.target + +[Service] +User=_rad +Group=_rad +ExecStart=/usr/bin/radicle-httpd --listen 127.0.0.1:8080 +Environment=RAD_HOME=/home/_rad/.radicle RUST_BACKTRACE=1 RUST_LOG=info +KillMode=process +Restart=always +RestartSec=1 + +[Install] +WantedBy=multi-user.target diff --git a/roles/radicle_node/templates/radicle-node.service.j2 b/roles/radicle_node/templates/radicle-node.service.j2 new file mode 100644 index 0000000..ae2af8c --- /dev/null +++ b/roles/radicle_node/templates/radicle-node.service.j2 @@ -0,0 +1,16 @@ +[Unit] +Description=Radicle Node +After=network.target network-online.target +Requires=network-online.target + +[Service] +User=_rad +Group=_rad +ExecStart=/usr/bin/radicle-node --listen 0.0.0.0:8776 --force +Environment=RAD_HOME=/home/_rad/.radicle RUST_BACKTRACE=1 RUST_LOG=info +KillMode=process +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/roles/sane_debian_system/defaults/main.yml b/roles/sane_debian_system/defaults/main.yml index 43d0262..d896b75 100644 --- a/roles/sane_debian_system/defaults/main.yml +++ b/roles/sane_debian_system/defaults/main.yml @@ -1,12 +1,12 @@ # These are the variables expected by this role. # Playbook should set this to the version of this role it expects to -# use. -sane_debian_system_version: null +# use. Defaults to the inventory hostname. +sane_debian_system_version: "{{ inventory_hostname }}" -# The desired hostname. Default is empty, which means hostname won't -# be set. +# The desired hostname. There is no no default, which means hostname +# won't be set. sane_debian_system_hostname: "" # The Debian release code name to use. diff --git a/roles/sane_debian_system/subplot.md b/roles/sane_debian_system/subplot.md index 087ae44..be05984 100644 --- a/roles/sane_debian_system/subplot.md +++ b/roles/sane_debian_system/subplot.md @@ -3,6 +3,13 @@ This role sets up a Debian system so that it can be managed with Ansible in a reasonable way. +## Version history + +### Version 2 + +* `sane_debian_hostname` defaults to the inventory hostname. This + means it's not necessary to set it if the default is sufficient. + ## Minimally sane Debian system ~~~scenario @@ -13,19 +20,36 @@ and I run the playbook then the host has the sudo package installed and the host has the apt-transport-https package installed and the host has the locales package installed -and the host has the ntp package installed +and the host has the systemd-timesyncd package installed and the host has an empty /etc/apt/sources.list.d directory and the host has hostname saneone -and the host has saneone in /etc/hosts for 127.0.1.1 ~~~ ~~~{#sane1.yml .file .yaml} -sane_debian_system_version: 1 +ansible_python_interpreter: /usr/bin/python3 -sane_debian_system_codename: buster +sane_debian_system_version: 2 +sane_debian_system_codename: bullseye sane_debian_system_hostname: saneone ~~~ +## Uses inventory hostname by default + +~~~scenario +given a host running Debian +when I use role sane_debian_system +and I use variables from sane-without-hostname.yml +and I run the playbook +then the host has the sudo package installed +and the host has hostname debian-ansible-test +~~~ + +~~~{#sane-without-hostname.yml .file .yaml} +sane_debian_system_version: 2 + +sane_debian_system_codename: bullseye +~~~ + ## Checks that debian codename is set ~~~scenario @@ -38,5 +62,5 @@ and stdout contains "sane_debian_system_codename" ~~~ ~~~{#sane2.yml .file .yaml} -sane_debian_system_version: 1 +sane_debian_system_version: 2 ~~~ diff --git a/roles/sane_debian_system/subplot.yaml b/roles/sane_debian_system/subplot.yaml index 9ac1ee3..b7f03cb 100644 --- a/roles/sane_debian_system/subplot.yaml +++ b/roles/sane_debian_system/subplot.yaml @@ -1,11 +1,19 @@ - then: the host has the {package} package installed - function: host_has_package_installed + impl: + python: + function: host_has_package_installed - then: the host has an empty {pathname} directory - function: host_directory_is_empty + impl: + python: + function: host_directory_is_empty - then: the host has hostname {hostname} - function: host_hostname_is + impl: + python: + function: host_hostname_is - then: the host has {hostname} in /etc/hosts for {addr} - function: host_hostname_has_address + impl: + python: + function: host_hostname_has_address diff --git a/roles/sane_debian_system/tasks/apt.yml b/roles/sane_debian_system/tasks/apt.yml index 21eea70..0da3332 100644 --- a/roles/sane_debian_system/tasks/apt.yml +++ b/roles/sane_debian_system/tasks/apt.yml @@ -11,28 +11,24 @@ # First update package lists. The ones that come with the image may be # badly out of date. # -# Ignore any error here so that later tasks can fix things such as a badly -# formed sources.list. +# Use shell to run apt-get, rather than the Ansible apt module, so +# that we can pass in the --allow-releaseinfo--change option. +- name: update package lists + shell: | + apt-get update --allow-releaseinfo-change + - name: update package lists ignore_errors: yes apt: update_cache: yes cache_valid_time: 0 -- name: install sudo - apt: - name: sudo - # Now install https transport for APT. This is installed before # changing sources lists, so that if they happen to use https URLs apt # will still work. apt-transport-https is in the main Debian archive, # and we assume those are in the sources.list that come with the # image. -# -# Ignore any error here so that later tasks can fix things such as a badly -# formed sources.list. - name: install apt-transport-https - ignore_errors: yes apt: name: apt-transport-https @@ -41,6 +37,22 @@ src: sources.list.j2 dest: /etc/apt/sources.list +- name: "update package lists" + apt: + update_cache: yes + +- name: install necessary tools + apt: + name: + - sudo + +- name: "allow root to use sudo" + copy: + content: | + root ALL=(ALL:ALL) NOPASSWD: ALL + dest: /etc/sudoers.d/root + mode: 0600 + - name: additional sources.list.d/* with_items: "{{ sane_debian_system_sources_lists }}" apt_repository: @@ -49,15 +61,18 @@ - name: add archive signing keys with_items: "{{ sane_debian_system_sources_lists }}" - apt_key: - data: "{{ item.signing_key }}" - state: present + shell: | + key="{{ item.signing_key }}" + sum="$(echo -n "$key" | sha1sum | awk '{ print $1 }')" + echo "$key" > "/etc/apt/trusted.gpg.d/$sum.asc" when: item.signing_key is defined +# Use shell to run apt-get to update package lists so that we can pass +# in the --allow-releaseinfo--change option. - name: update package lists - apt: - update_cache: yes - cache_valid_time: 0 + shell: | + apt-get update --allow-releaseinfo-change + - name: add archive keyrings with_items: "{{ sane_debian_system_sources_lists }}" diff --git a/roles/sane_debian_system/tasks/env.yml b/roles/sane_debian_system/tasks/env.yml index db8f5ba..eedd864 100644 --- a/roles/sane_debian_system/tasks/env.yml +++ b/roles/sane_debian_system/tasks/env.yml @@ -2,17 +2,17 @@ apt: name: dbus +- name: "start dbus" + systemd: + name: dbus + daemon_reload: yes + enabled: yes + state: started + - name: set /etc/hostname hostname: name: "{{ sane_debian_system_hostname }}" - when: sane_debian_system_hostname is defined - -- name: add hostname to /etc/hosts - lineinfile: - dest: /etc/hosts - regexp: '^127\.0\.1\.1 ' - line: "127.0.1.1 {{ sane_debian_system_hostname }}" - when: sane_debian_system_hostname is defined + when: sane_debian_system_hostname != "" - name: set timezone timezone: @@ -23,7 +23,14 @@ state: present name: - locales - - ntp + +- name: install systemd-timesyncd or ntp + shell: | + if apt-cache show systemd-timesyncd > /dev/null; then + DEBIAN_FRONTEND=noninteractife apt-get install -y systemd-timesyncd + else + DEBIAN_FRONTEND=noninteractife apt-get install -y ntp + fi - name: generate locales locale_gen: diff --git a/roles/sane_debian_system/tasks/main.yml b/roles/sane_debian_system/tasks/main.yml index 7722f1c..bc8c6d3 100644 --- a/roles/sane_debian_system/tasks/main.yml +++ b/roles/sane_debian_system/tasks/main.yml @@ -1,7 +1,10 @@ - name: "sane_debian_system_version" shell: | - [ "{{ sane_debian_system_version }}" = "1" ] || \ + [ "{{ sane_debian_system_version }}" = "2" ] || \ (echo "Unexpected version {{ sane_debian_system_version }}" 1>&2; exit 1) -- include: apt.yml -- include: env.yml +- ansible.builtin.import_tasks: + file: apt.yml + +- ansible.builtin.import_tasks: + file: env.yml diff --git a/roles/sshd/README b/roles/sshd/README new file mode 100644 index 0000000..4b155c8 --- /dev/null +++ b/roles/sshd/README @@ -0,0 +1,23 @@ +This role, sshd, configures an SSH server on a Debian. Specifically +may: + +- set host key and certificate +- set user CA +- set port on which server listens + +To use, define variables below: + +- `sshd_version`---must match the current version for the role +- `sshd_host_key` and `sshd_host_cert`---the host key and + corresponding certificate + - note that you must define both for either to work + - rationale: there's little point in just setting the host key, as + it will still force people to accept it the first time; a host + certificate removes that need and allows the key to change at will +- `sshd_port`---the port where the SSH server should listen + - rationale: on public-facing servers, the default port gets tons of + login attempts by attackers trying to guess passwords +- `sshd_user_ca_pub`---the public keys of the SSH CAs trusted to + certify users + - rationale: using a user CA removes the need to maintain, or have, + `authorized_keys` files diff --git a/roles/sshd/defaults/main.yml b/roles/sshd/defaults/main.yml new file mode 100644 index 0000000..20c9563 --- /dev/null +++ b/roles/sshd/defaults/main.yml @@ -0,0 +1,9 @@ +# The user of the role MUST define the version they want to use. If +# it's not what the version of unix_users being used actually +# provides, the role will fail. +sshd_version: null + + +# Allow SSH server to use `authorized_keys` files? +sshd_allow_authorized_keys: yes + diff --git a/roles/sshd/handlers/main.yml b/roles/sshd/handlers/main.yml new file mode 100644 index 0000000..c4898c0 --- /dev/null +++ b/roles/sshd/handlers/main.yml @@ -0,0 +1,4 @@ +- name: sshd_restart + systemd: + name: ssh + state: restarted diff --git a/roles/sshd/subplot.md b/roles/sshd/subplot.md new file mode 100644 index 0000000..e86e513 --- /dev/null +++ b/roles/sshd/subplot.md @@ -0,0 +1,27 @@ +# Role `sshd` – configure an SSH server + +This role sets up a Debian system so that it can be managed with +Ansible in a reasonable way. + +## Version history + +### Version 1 + +First version. Supports `sshd_version`, `sshd_port`, `sshd_host_key`, +`sshd_host_cert`, and `sshd_user_ca_pub`. + +# Configure SSH + +~~~scenario +given a host running Debian +when I use role sshd +and I use variables from sshd.yml +and I run the playbook +then stdout contains "sshd role version" +~~~ + +~~~{#sshd.yml .file .yaml} +ansible_python_interpreter: /usr/bin/python3 + +sshd_version: 1 +~~~ diff --git a/roles/sshd/tasks/main.yml b/roles/sshd/tasks/main.yml new file mode 100644 index 0000000..ff77c40 --- /dev/null +++ b/roles/sshd/tasks/main.yml @@ -0,0 +1,112 @@ +- name: "sshd role version" + shell: | + [ "{{ sshd_version }}" = "1" ] || \ + (echo "Unexpected version {{ sshd_version }}" 1>&2; exit 1) + +- name: "sshd role configuration sanity check" + when: not sshd_allow_authorized_keys and sshd_user_ca_pub is not defined + shell: | + echo "You MUST define sshd_allow_authorized_keys OR sshd_user_ca_pub" + exit 1 + +- name: "Configure SSH server to read config files in sshd_config.d" + lineinfile: + path: /etc/ssh/sshd_config + regexp: "Include /etc/ssh/sshd_config.d" + line: "Include /etc/ssh/sshd_config.d/*.conf" + insertbefore: BOF + notify: sshd_restart + +- name: "Set SSH host identity" + when: sshd_host_key is defined and sshd_host_cert is defined + copy: + content: | + {{ sshd_host_key }} + dest: /etc/ssh/ssh_host_key + owner: root + group: root + mode: 0600 + notify: sshd_restart + +- name: "Set SSH host certificate" + when: sshd_host_key is defined and sshd_host_cert is defined + copy: + content: | + {{ sshd_host_cert }} + dest: /etc/ssh/ssh_host_key-cert.pub + notify: sshd_restart + +- name: "Configure SSH server host key" + when: sshd_host_key is defined and sshd_host_cert is defined + copy: + content: | + HostKeyAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com + HostKey /etc/ssh/ssh_host_key + HostCertificate /etc/ssh/ssh_host_key-cert.pub + dest: /etc/ssh/sshd_config.d/host_id.conf + notify: sshd_restart + +- name: "Remove old host key settings from /etc/ssh/sshd_config" + when: sshd_host_key is defined and sshd_host_cert is defined + lineinfile: + path: /etc/ssh/sshd_config + state: absent + regex: "(?i)hostkey" + notify: sshd_restart + +- name: "Remove old host cert settings from /etc/ssh/sshd_config" + when: sshd_host_key is defined and sshd_host_cert is defined + lineinfile: + path: /etc/ssh/sshd_config + state: absent + regex: "(?i)hostcertificate" + notify: sshd_restart + +- name: "Remove old user CA settings from /etc/ssh/sshd_config" + when: sshd_host_key is defined and sshd_host_cert is defined + lineinfile: + path: /etc/ssh/sshd_config + state: absent + regex: "(?i)trustedusercakeys" + notify: sshd_restart + +- name: "Remove obsolete SSH host keys and certificates" + when: sshd_host_key is defined and sshd_host_cert is defined + shell: | + find /etc/ssh -maxdepth 1 -type f -name "ssh_host_*_key*" -delete + notify: sshd_restart + +- name: "Configure SSH server port" + when: sshd_port is defined + copy: + content: | + Port {{ sshd_port }} + dest: /etc/ssh/sshd_config.d/port.conf + notify: sshd_restart + +- name: "Configure user CA for SSH server" + when: sshd_user_ca_pub is defined + copy: + content: | + {{ sshd_user_ca_pub }} + dest: /etc/ssh/user_ca_pubs + notify: sshd_restart + +- name: "Configure SSH server to accept user CA" + when: sshd_user_ca_pub is defined + copy: + content: | + TrustedUserCAKeys /etc/ssh/user_ca_pubs + dest: /etc/ssh/sshd_config.d/user_ca.conf + notify: sshd_restart + +- name: "Configure SSH server to not use 'authorized_keys' files at all." + when: not sshd_allow_authorized_keys + copy: + content: | + AuthorizedKeysFile none + dest: /etc/ssh/sshd_config.d/authorized_keys.conf + notify: sshd_restart + +- name: "Run handlers" + meta: flush_handlers diff --git a/roles/unix_users/subplot.md b/roles/unix_users/subplot.md index 2fde3e7..c7929e9 100644 --- a/roles/unix_users/subplot.md +++ b/roles/unix_users/subplot.md @@ -24,6 +24,8 @@ This role makes use of the following variables: * `authorized_keys` – OPTIONAL: text of contents of `~/.ssh/authorized_keys` * `password` – OPTIONAL: encrypted password + * `groups` – OPTIONAL: list of additional groups to which user + should be added Create the encrypted password with something like: @@ -43,10 +45,11 @@ then the host has user foo and the user foo on host has encrypted password foopass and the user foo on host has shell /bin/true and the user foo on host has authorized_keys containing "ssh-rsa" +and the user foo on host is in group operator ~~~ ~~~{#foo.yml .file .yaml} -unix_users_version: 1 +unix_users_version: 2 unix_users: - username: foo @@ -55,4 +58,5 @@ unix_users: password: foopass authorized_keys: | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDKVaQfxzzwpwk763IcPBs308TpYYp6+NTOMvYaj3j3ewz8feYQg3lOlKo/5xaPug2ZywG6v6tpn/p0drovT5YAIPJitP7yJAfEzJe/gO7c9uwx0uIpe6cc8bwRG0XFdUVK0EneB6LpIec+3juj4zitGBm0ffIoLDhJ7J0daTzQN62rZaw/2SjSvgbfnu3a2BYRPz1NGiXdvOCbytVSLlUAR6SxNPrFdh/BJnS4umyDaBL/1j2yaw/WlkfZPn5Ni3USZLRcbHnBUUbo64iwBwJabhdpeh0xLGTqDkaeudUgZjlrRHFyCbwJTPtDzJsPLb5HKGGzdXPHP7Lk6PM2CIOz liw@exolobe1 + groups: [operator] ~~~ diff --git a/roles/unix_users/subplot.py b/roles/unix_users/subplot.py index 7bf921d..05330fd 100644 --- a/roles/unix_users/subplot.py +++ b/roles/unix_users/subplot.py @@ -14,7 +14,7 @@ def host_has_user(ctx, username=None): output, exit = qemu.ssh(["getent", "passwd", username]) assert_eq(exit, 0) output = output.decode("UTF8") - assert f"\n{username}:" in output + assert f"{username}:" in output def host_user_has_shell(ctx, username=None, shell=None): @@ -46,3 +46,14 @@ def host_user_has_authorized_keys_containing(ctx, username=None, substring=None) assert_eq(exit, 0) output = output.decode("UTF8") assert substring in output + + +def host_user_is_in_group(ctx, username=None, group=None): + assert_eq = globals()["assert_eq"] + qemu = ctx["qemu"] + output, exit = qemu.ssh(["sudo", "-u", username, "groups"]) + assert_eq(exit, 0) + output = output.decode("UTF8") + groups = output.split() + logging.debug(f"host_user_is_in_group: groups={groups}") + assert group in groups diff --git a/roles/unix_users/subplot.yaml b/roles/unix_users/subplot.yaml index 10ac86c..e495602 100644 --- a/roles/unix_users/subplot.yaml +++ b/roles/unix_users/subplot.yaml @@ -1,14 +1,29 @@ - then: the host has no user {username} - function: host_does_not_have_user + impl: + python: + function: host_does_not_have_user - then: the host has user {username} - function: host_has_user + impl: + python: + function: host_has_user - then: the user {username} on host has encrypted password {password} - function: host_user_has_password + impl: + python: + function: host_user_has_password - then: the user {username} on host has shell {shell} - function: host_user_has_shell + impl: + python: + function: host_user_has_shell - then: the user {username} on host has authorized_keys containing "{substring}" - function: host_user_has_authorized_keys_containing + impl: + python: + function: host_user_has_authorized_keys_containing + +- then: the user {username} on host is in group {group} + impl: + python: + function: host_user_is_in_group diff --git a/roles/unix_users/tasks/main.yml b/roles/unix_users/tasks/main.yml index cd6fb66..e181054 100644 --- a/roles/unix_users/tasks/main.yml +++ b/roles/unix_users/tasks/main.yml @@ -1,6 +1,6 @@ - name: "check unix_users_version" shell: | - [ "{{ unix_users_version }}" = "1" ] || \ + [ "{{ unix_users_version }}" = "2" ] || \ (echo "Unexpected version {{ unix_users_version }}" 1>&2; exit 1) - name: create system users @@ -10,6 +10,13 @@ comment: "{{ item.comment|default('unnamed user') }}" shell: "{{ item.shell|default('/bin/bash') }}" system: "{{ item.system|default('no') }}" + +- name: add users to additional groups + with_items: "{{ unix_users }}" + when: item.groups is defined + user: + name: "{{ item.username }}" + groups: "{{ item.groups }}" - name: set password for users with_items: "{{ unix_users }}" @@ -62,14 +62,3 @@ Verify that everything looks OK and that other scenarios can be run. given a host running Debian then I can run /bin/true on the host ~~~ - - - ---- -title: "debian-ansible—Ansible roles for Debian systems" -author: Lars Wirzenius -bindings: -- subplot.yaml -functions: -- subplot.py -... diff --git a/subplot.subplot b/subplot.subplot new file mode 100644 index 0000000..5d8083f --- /dev/null +++ b/subplot.subplot @@ -0,0 +1,11 @@ +title: "debian-ansible—Ansible roles for Debian systems" +authors: + - Lars Wirzenius +markdowns: + - subplot.md +bindings: +- subplot.yaml +impls: + python: + - subplot.py + - lib/runcmd.py diff --git a/subplot/daemon.py b/subplot/daemon.py deleted file mode 100644 index e223505..0000000 --- a/subplot/daemon.py +++ /dev/null @@ -1,84 +0,0 @@ -############################################################################# -# Start and stop daemons, or background processes. - - -import logging -import os -import signal -import time - - -# Start a process in the background. -def start_daemon(ctx, name, argv): - runcmd = globals()["runcmd"] - exit_code_is = globals()["exit_code_is"] - - logging.debug(f"Starting daemon {name}") - logging.debug(f" ctx={ctx.as_dict()}") - logging.debug(f" name={name}") - logging.debug(f" argv={argv}") - - if "daemon" not in ctx.as_dict(): - ctx["daemon"] = {} - assert name not in ctx["daemon"] - this = ctx["daemon"][name] = { - "pid-file": f"{name}.pid", - "stderr": f"{name}.stderr", - "stdout": f"{name}.stdout", - } - runcmd( - ctx, - [ - "/usr/sbin/daemonize", - "-c", - os.getcwd(), - "-p", - this["pid-file"], - "-e", - this["stderr"], - "-o", - this["stdout"], - ] - + argv, - ) - - # Wait for a bit for daemon to start and maybe find a problem and die. - time.sleep(3) - if ctx["exit"] != 0: - logging.error(f"obnam-server stderr: {ctx['stderr']}") - - exit_code_is(ctx, 0) - this["pid"] = int(open(this["pid-file"]).read().strip()) - assert process_exists(this["pid"]) - - logging.debug(f"Started daemon {name}") - logging.debug(f" ctx={ctx.as_dict()}") - - -# Stop a daemon. -def stop_daemon(ctx, name): - logging.debug(f"Stopping daemon {name}") - logging.debug(f" ctx={ctx.as_dict()}") - logging.debug(f" ctx['daemon']={ctx.as_dict()['daemon']}") - - this = ctx["daemon"][name] - terminate_process(this["pid"], signal.SIGKILL) - - -# Does a process exist? -def process_exists(pid): - try: - os.kill(pid, 0) - except ProcessLookupError: - return False - return True - - -# Terminate process. -def terminate_process(pid, signalno): - logging.debug(f"Terminating process {pid} with signal {signalno}") - try: - os.kill(pid, signalno) - except ProcessLookupError: - logging.debug("Process did not actually exist (anymore?)") - pass diff --git a/subplot/qemumgr.py b/subplot/qemumgr.py index bfc3f24..c4fd385 100755 --- a/subplot/qemumgr.py +++ b/subplot/qemumgr.py @@ -107,7 +107,7 @@ class QemuSystem: memory = int(self._memory / MiB) argv = [ - "/usr/sbin/daemonize", + "/usr/bin/daemonize", "-c", self._dirname, "-p", @@ -140,7 +140,7 @@ class QemuSystem: subprocess.check_call(argv, stdout=None, stderr=None) self._pid = int(wait_for(lambda: got_pid(pid_file), 1)) - logging.debug(f"started qemu-system") + logging.debug("started qemu-system") logging.debug(f" argv: {argv}") logging.debug(f" pid: {self._pid}") @@ -157,7 +157,7 @@ class QemuSystem: return True return None - return wait_for(ssh_ok, 60, sleep=5) + return wait_for(ssh_ok, 300, sleep=5) def ssh(self, argv): assert self._username is not None diff --git a/subplot/runcmd.py b/subplot/runcmd.py deleted file mode 100644 index 2bf153c..0000000 --- a/subplot/runcmd.py +++ /dev/null @@ -1,247 +0,0 @@ -import logging -import os -import re -import shlex -import subprocess - - -# -# Helper functions. -# - -# Get exit code or other stored data about the latest command run by -# runcmd_run. - - -def _runcmd_get(ctx, name): - ns = ctx.declare("_runcmd") - return ns[name] - - -def runcmd_get_exit_code(ctx): - return _runcmd_get(ctx, "exit") - - -def runcmd_get_stdout(ctx): - return _runcmd_get(ctx, "stdout") - - -def runcmd_get_stdout_raw(ctx): - return _runcmd_get(ctx, "stdout.raw") - - -def runcmd_get_stderr(ctx): - return _runcmd_get(ctx, "stderr") - - -def runcmd_get_stderr_raw(ctx): - return _runcmd_get(ctx, "stderr.raw") - - -def runcmd_get_argv(ctx): - return _runcmd_get(ctx, "argv") - - -# Run a command, given an argv and other arguments for subprocess.Popen. -# -# This is meant to be a helper function, not bound directly to a step. The -# stdout, stderr, and exit code are stored in the "_runcmd" namespace in the -# ctx context. -def runcmd_run(ctx, argv, **kwargs): - ns = ctx.declare("_runcmd") - - env = dict(os.environ) - for key, arg in kwargs.pop("env", {}).items(): - env[key] = arg - - pp = ns.get("path-prefix") - if pp: - env["PATH"] = pp + ":" + env["PATH"] - - logging.debug(f"runcmd_run") - logging.debug(f" argv: {argv}") - logging.debug(f" env: {env}") - p = subprocess.Popen( - argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, **kwargs - ) - stdout, stderr = p.communicate("") - ns["argv"] = argv - ns["stdout.raw"] = stdout - ns["stderr.raw"] = stderr - ns["stdout"] = stdout.decode("utf-8") - ns["stderr"] = stderr.decode("utf-8") - ns["exit"] = p.returncode - logging.debug(f" ctx: {ctx}") - logging.debug(f" ns: {ns}") - - -# Step: prepend srcdir to PATH whenever runcmd runs a command. -def runcmd_helper_srcdir_path(ctx): - srcdir = globals()["srcdir"] - runcmd_prepend_to_path(ctx, srcdir) - - -# Step: This creates a helper script. -def runcmd_helper_script(ctx, filename=None): - get_file = globals()["get_file"] - with open(filename, "wb") as f: - f.write(get_file(filename)) - - -# -# Step functions for running commands. -# - - -def runcmd_prepend_to_path(ctx, dirname=None): - ns = ctx.declare("_runcmd") - pp = ns.get("path-prefix", "") - if pp: - pp = f"{pp}:{dirname}" - else: - pp = dirname - ns["path-prefix"] = pp - - -def runcmd_step(ctx, argv0=None, args=None): - runcmd_try_to_run(ctx, argv0=argv0, args=args) - runcmd_exit_code_is_zero(ctx) - - -def runcmd_try_to_run(ctx, argv0=None, args=None): - argv = [shlex.quote(argv0)] + shlex.split(args) - runcmd_run(ctx, argv) - - -# -# Step functions for examining exit codes. -# - - -def runcmd_exit_code_is_zero(ctx): - runcmd_exit_code_is(ctx, exit=0) - - -def runcmd_exit_code_is(ctx, exit=None): - assert_eq = globals()["assert_eq"] - assert_eq(runcmd_get_exit_code(ctx), int(exit)) - - -def runcmd_exit_code_is_nonzero(ctx): - runcmd_exit_code_is_not(ctx, exit=0) - - -def runcmd_exit_code_is_not(ctx, exit=None): - assert_ne = globals()["assert_ne"] - assert_ne(runcmd_get_exit_code(ctx), int(exit)) - - -# -# Step functions and helpers for examining output in various ways. -# - - -def runcmd_stdout_is(ctx, text=None): - _runcmd_output_is(runcmd_get_stdout(ctx), text) - - -def runcmd_stdout_isnt(ctx, text=None): - _runcmd_output_isnt(runcmd_get_stdout(ctx), text) - - -def runcmd_stderr_is(ctx, text=None): - _runcmd_output_is(runcmd_get_stderr(ctx), text) - - -def runcmd_stderr_isnt(ctx, text=None): - _runcmd_output_isnt(runcmd_get_stderr(ctx), text) - - -def _runcmd_output_is(actual, wanted): - assert_eq = globals()["assert_eq"] - wanted = bytes(wanted, "utf8").decode("unicode_escape") - logging.debug("_runcmd_output_is:") - logging.debug(f" actual: {actual!r}") - logging.debug(f" wanted: {wanted!r}") - assert_eq(actual, wanted) - - -def _runcmd_output_isnt(actual, wanted): - assert_ne = globals()["assert_ne"] - wanted = bytes(wanted, "utf8").decode("unicode_escape") - logging.debug("_runcmd_output_isnt:") - logging.debug(f" actual: {actual!r}") - logging.debug(f" wanted: {wanted!r}") - assert_ne(actual, wanted) - - -def runcmd_stdout_contains(ctx, text=None): - _runcmd_output_contains(runcmd_get_stdout(ctx), text) - - -def runcmd_stdout_doesnt_contain(ctx, text=None): - _runcmd_output_doesnt_contain(runcmd_get_stdout(ctx), text) - - -def runcmd_stderr_contains(ctx, text=None): - _runcmd_output_contains(runcmd_get_stderr(ctx), text) - - -def runcmd_stderr_doesnt_contain(ctx, text=None): - _runcmd_output_doesnt_contain(runcmd_get_stderr(ctx), text) - - -def _runcmd_output_contains(actual, wanted): - assert_eq = globals()["assert_eq"] - wanted = bytes(wanted, "utf8").decode("unicode_escape") - logging.debug("_runcmd_output_contains:") - logging.debug(f" actual: {actual!r}") - logging.debug(f" wanted: {wanted!r}") - assert_eq(wanted in actual, True) - - -def _runcmd_output_doesnt_contain(actual, wanted): - assert_ne = globals()["assert_ne"] - wanted = bytes(wanted, "utf8").decode("unicode_escape") - logging.debug("_runcmd_output_doesnt_contain:") - logging.debug(f" actual: {actual!r}") - logging.debug(f" wanted: {wanted!r}") - assert_ne(wanted in actual, True) - - -def runcmd_stdout_matches_regex(ctx, regex=None): - _runcmd_output_matches_regex(runcmd_get_stdout(ctx), regex) - - -def runcmd_stdout_doesnt_match_regex(ctx, regex=None): - _runcmd_output_doesnt_match_regex(runcmd_get_stdout(ctx), regex) - - -def runcmd_stderr_matches_regex(ctx, regex=None): - _runcmd_output_matches_regex(runcmd_get_stderr(ctx), regex) - - -def runcmd_stderr_doesnt_match_regex(ctx, regex=None): - _runcmd_output_doesnt_match_regex(runcmd_get_stderr(ctx), regex) - - -def _runcmd_output_matches_regex(actual, regex): - assert_ne = globals()["assert_ne"] - r = re.compile(regex) - m = r.search(actual) - logging.debug("_runcmd_output_matches_regex:") - logging.debug(f" actual: {actual!r}") - logging.debug(f" regex: {regex!r}") - logging.debug(f" match: {m}") - assert_ne(m, None) - - -def _runcmd_output_doesnt_match_regex(actual, regex): - assert_eq = globals()["assert_eq"] - r = re.compile(regex) - m = r.search(actual) - logging.debug("_runcmd_output_doesnt_match_regex:") - logging.debug(f" actual: {actual!r}") - logging.debug(f" regex: {regex!r}") - logging.debug(f" match: {m}") - assert_eq(m, None) diff --git a/subplot/subplot.yaml b/subplot/subplot.yaml index 5993feb..286e878 100644 --- a/subplot/subplot.yaml +++ b/subplot/subplot.yaml @@ -1,24 +1,42 @@ - given: a host running Debian - function: create_vm - cleanup: destroy_vm + impl: + python: + function: create_vm + cleanup: destroy_vm - then: I can run /bin/true on the host - function: run_true_on_host + impl: + python: + function: run_true_on_host - when: I use role {role} - function: use_role_in_playbook + impl: + python: + function: use_role_in_playbook - when: I use variables from {filename} - function: set_vars_file + impl: + python: + function: set_vars_file + types: + filename: file - when: I run the playbook - function: run_playbook + impl: + python: + function: run_playbook - when: I try to run the playbook - function: try_playbook + impl: + python: + function: try_playbook - then: the command fails - function: command_fails + impl: + python: + function: command_fails - then: stdout contains "{text:text}" - function: xstdout_contains + impl: + python: + function: xstdout_contains |