├── CHANGELOG.md ├── CNAME ├── roles └── mail │ ├── templates │ └── etc │ │ ├── rspamd │ │ └── local.d │ │ │ ├── redis.conf.j2 │ │ │ ├── dns.conf.j2 │ │ │ └── dkim_signing.conf.j2 │ │ ├── mail │ │ └── smtpd.conf.j2 │ │ └── dovecot │ │ └── local.conf.j2 │ ├── handlers │ └── main.yaml │ ├── defaults │ └── main.yaml │ ├── tasks │ └── main.yaml │ └── README.md ├── meta └── runtime.yml ├── .prettierrc.yaml ├── .ansible-lint ├── playbooks ├── mail.yaml ├── update.yaml ├── pkg.yaml ├── syspatch.yaml ├── sysupgrade.yaml └── python.yaml ├── inventory └── localhost.yaml ├── .vscode ├── extensions.json └── settings.json ├── pyproject.toml ├── .editorconfig ├── galaxy.yml ├── tox.ini ├── .gitignore ├── LICENSE ├── .github └── workflows │ ├── galaxy.yaml │ └── lint.yaml ├── .yamllint ├── README.md └── plugins └── modules ├── sysupgrade.py ├── syspatch.py └── pkg.py /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | openbsd.run -------------------------------------------------------------------------------- /roles/mail/templates/etc/rspamd/local.d/redis.conf.j2: -------------------------------------------------------------------------------- 1 | servers = "127.0.0.1"; 2 | -------------------------------------------------------------------------------- /roles/mail/templates/etc/rspamd/local.d/dns.conf.j2: -------------------------------------------------------------------------------- 1 | dns { 2 | nameserver = ["127.0.0.1"]; 3 | retransmits = 5; 4 | timeout = 4s; 5 | } 6 | -------------------------------------------------------------------------------- /meta/runtime.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | requires_ansible: ">=2.11.0" 6 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | options: 6 | editorconfig: true 7 | 8 | overrides: 9 | - files: "*.md" 10 | options: { tabWidth: 2 } 11 | -------------------------------------------------------------------------------- /.ansible-lint: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | exclude_paths: 6 | - .github/ 7 | - .vscode/ 8 | skip_list: 9 | - meta-runtime[unsupported-version] 10 | - no-changed-when 11 | - no-handler 12 | - no-tabs 13 | -------------------------------------------------------------------------------- /playbooks/mail.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | - name: Mail 6 | hosts: "{{ target | default('mail') }}" 7 | 8 | any_errors_fatal: true 9 | gather_facts: false 10 | 11 | roles: 12 | - role: jcmdln.openbsd.mail 13 | -------------------------------------------------------------------------------- /inventory/localhost.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | all: 6 | hosts: 7 | localhost: 8 | ansible_connection: local 9 | vars: 10 | ansible_python_interpreter: /usr/local/bin/python3 11 | 12 | mail: 13 | hosts: 14 | -------------------------------------------------------------------------------- /playbooks/update.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | - name: Python 6 | import_playbook: jcmdln.openbsd.python 7 | 8 | - name: Pkg 9 | import_playbook: jcmdln.openbsd.pkg 10 | 11 | - name: Syspatch 12 | import_playbook: jcmdln.openbsd.syspatch 13 | -------------------------------------------------------------------------------- /playbooks/pkg.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | - name: Pkg 6 | hosts: "{{ target | default('all') }}" 7 | 8 | any_errors_fatal: true 9 | gather_facts: false 10 | 11 | tasks: 12 | - name: Update packages 13 | jcmdln.openbsd.pkg: 14 | name: "*" 15 | state: latest 16 | -------------------------------------------------------------------------------- /roles/mail/templates/etc/rspamd/local.d/dkim_signing.conf.j2: -------------------------------------------------------------------------------- 1 | enable = true; 2 | 3 | domain { 4 | {{ mail_domain }} { 5 | path = "/etc/mail/dkim/{{ mail_dkim_selector }}.{{ mail_domain }}.key"; 6 | selector = "{{ mail_dkim_selector }}"; 7 | } 8 | } 9 | 10 | allow_envfrom_empty = true; 11 | allow_hdrfrom_mismatch = false; 12 | allow_hdrfrom_multiple = true; 13 | allow_username_mismatch = true; 14 | sign_authenticated = true; 15 | sign_local = true; 16 | -------------------------------------------------------------------------------- /roles/mail/handlers/main.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | - name: Restart dovecot 6 | ansible.builtin.service: 7 | name: dovecot 8 | state: restarted 9 | 10 | - name: Restart smtpd 11 | ansible.builtin.service: 12 | name: smtpd 13 | state: restarted 14 | 15 | - name: Restart spamd 16 | ansible.builtin.service: 17 | name: spamd 18 | state: restarted 19 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ISC 2 | // 3 | // Copyright (c) 2024 Johnathan C. Maudlin 4 | { 5 | "recommendations": [ 6 | "charliermarsh.ruff", 7 | "editorconfig.editorconfig", 8 | "esbenp.prettier-vscode", 9 | "ms-python.mypy-type-checker", 10 | "ms-python.python", 11 | "ms-python.vscode-pylance", 12 | "redhat.ansible", 13 | "redhat.vscode-yaml", 14 | "samuelcolvin.jinjahtml", 15 | "tamasfe.even-better-toml" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /playbooks/syspatch.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | - name: Syspatch 6 | hosts: "{{ target | default('all') }}" 7 | 8 | any_errors_fatal: true 9 | gather_facts: false 10 | 11 | handlers: 12 | - name: Reboot 13 | ansible.builtin.reboot: 14 | reboot_timeout: 600 15 | 16 | tasks: 17 | - name: Apply all available patches 18 | notify: Reboot 19 | jcmdln.openbsd.syspatch: 20 | apply: true 21 | -------------------------------------------------------------------------------- /playbooks/sysupgrade.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | - name: Sysupgrade 6 | hosts: "{{ target | default('all') }}" 7 | 8 | any_errors_fatal: true 9 | gather_facts: false 10 | 11 | handlers: 12 | - name: Reboot 13 | ansible.builtin.reboot: 14 | reboot_timeout: 600 15 | 16 | tasks: 17 | - name: Upgrade to latest release or snapshot 18 | notify: Reboot 19 | jcmdln.openbsd.sysupgrade: 20 | branch: auto 21 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | 5 | [tool.black] 6 | line-length = 100 7 | 8 | [tool.mypy] 9 | mypy_path = ["plugins/modules/"] 10 | ignore_missing_imports = true 11 | show_error_context = true 12 | strict = true 13 | strict_optional = true 14 | # Disable specific strict checks 15 | disallow_any_generics = false 16 | 17 | [tool.ruff] 18 | line-length = 100 19 | src = ["plugins/modules/"] 20 | 21 | [tool.ruff.format] 22 | docstring-code-format = true 23 | docstring-code-line-length = 80 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | 5 | root = true 6 | 7 | [*] 8 | charset = "utf-8" 9 | end_of_line = "lf" 10 | indent_size = 4 11 | indent_style = "space" 12 | insert_final_newline = true 13 | max_line_length = 100 14 | trim_trailing_whitespace = true 15 | 16 | [*.{json{,c},y{,a}ml}{,.j2}] 17 | indent_size = 2 18 | tab_width = 2 19 | 20 | [*.{ansible-,yaml}lint] 21 | indent_size = 2 22 | tab_width = 2 23 | 24 | [roles/*/templates/**/*.conf.j2] 25 | indent_size = 4 26 | indent_style = "tab" 27 | tab_width = 4 28 | -------------------------------------------------------------------------------- /galaxy.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | namespace: jcmdln 6 | name: openbsd 7 | version: 1.2.4 8 | description: "An Ansible collection for performing actions against OpenBSD hosts" 9 | 10 | authors: 11 | - "Johnathan C. Maudlin " 12 | dependencies: 13 | community.crypto: ">=1.0.0" 14 | community.general: ">=1.0.0" 15 | license_file: LICENSE 16 | readme: README.md 17 | repository: https://github.com/jcmdln/ansible-collection-openbsd 18 | tags: 19 | - openbsd 20 | - infrastructure 21 | - tools 22 | -------------------------------------------------------------------------------- /playbooks/python.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | - name: Python 6 | hosts: "{{ target | default('all') }}" 7 | 8 | any_errors_fatal: true 9 | gather_facts: false 10 | 11 | handlers: 12 | - name: Install Python 13 | ansible.builtin.raw: pkg_add python3 14 | 15 | tasks: 16 | - name: Check if Python 3.x is present 17 | changed_when: not python_check or python_check.rc > 0 18 | failed_when: false 19 | notify: Install Python 20 | register: python_check 21 | ansible.builtin.raw: command -v python3 22 | -------------------------------------------------------------------------------- /roles/mail/defaults/main.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | mail_packages: 6 | - dovecot-- 7 | - dovecot-pigeonhole-- 8 | - opensmtpd-filter-rspamd 9 | - py3-cryptography 10 | - rspamd--hyperscan 11 | 12 | # 13 | # Domain 14 | # 15 | 16 | mail_aliases_root: root 17 | mail_dkim_selector: domain 18 | mail_domain: domain.tld 19 | 20 | # 21 | # SSL 22 | # 23 | 24 | mail_csr_common_name: "{{ mail_domain }}" 25 | mail_csr_country_name: US 26 | mail_csr_email_address: "postmaster@{{ mail_domain }}" 27 | mail_csr_organization_name: "{{ mail_dkim_selector }}" 28 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | 5 | [tox] 6 | envlist = mypy, ruff, ansible-lint 7 | ignore_basepython_conflict = true 8 | isolated_build = true 9 | minversion = 3.20 10 | skip_missing_interpreters = true 11 | 12 | [testenv] 13 | skip_install = true 14 | 15 | [testenv:ansible-lint] 16 | commands = 17 | ansible-lint --version 18 | ansible-lint -v --project-dir {toxinidir} 19 | deps = ansible-lint 20 | 21 | [testenv:mypy] 22 | commands = 23 | mypy --version 24 | mypy {toxinidir}/plugins/modules/ 25 | deps = mypy 26 | 27 | [testenv:ruff] 28 | commands = 29 | ruff --version 30 | ruff check --diff {toxinidir}/plugins/modules/ 31 | deps = ruff 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | 5 | # Ignore everything 6 | * 7 | 8 | # Include directories 9 | !.github/ 10 | !.github/workflows/ 11 | !.github/workflows/*.yaml 12 | !.vscode/ 13 | !.vscode/extensions.json 14 | !.vscode/settings.json 15 | !inventory/ 16 | !inventory/localhost.yaml 17 | !meta/ 18 | !meta/runtime.yml 19 | !playbooks/ 20 | !playbooks/*.yaml 21 | !plugins/ 22 | !plugins/modules/ 23 | !plugins/modules/*.py 24 | !roles/ 25 | !roles/**/ 26 | !roles/**/*.md 27 | !roles/**/*.yaml 28 | !roles/*/templates/**/*.j2 29 | 30 | # Include files 31 | !.ansible-lint 32 | !.editorconfig 33 | !.gitignore 34 | !.prettierrc.yaml 35 | !.yamllint 36 | !CHANGELOG.md 37 | !galaxy.yaml 38 | !LICENSE 39 | !pyproject.toml 40 | !README.md 41 | !tox.ini 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2024 Johnathan C. Maudlin 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /.github/workflows/galaxy.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | name: Release 6 | 7 | on: 8 | push: 9 | tags: ["*"] 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - uses: actions/setup-python@v5 19 | with: { python-version: "3.13" } 20 | 21 | - name: Install Ansible 22 | run: pip install ansible-core 23 | 24 | - name: Build collection 25 | run: ansible-galaxy collection build 26 | 27 | - name: Publish collection to Ansible Galaxy 28 | run: > 29 | ansible-galaxy collection publish 30 | --api-key ${{ secrets.GALAXY_API_KEY }} 31 | jcmdln-openbsd-${{ github.ref_name }}.tar.gz 32 | -------------------------------------------------------------------------------- /roles/mail/templates/etc/mail/smtpd.conf.j2: -------------------------------------------------------------------------------- 1 | # {{ template_destpath }} 2 | # {{ ansible_managed }} 3 | 4 | table aliases file:/etc/mail/aliases 5 | 6 | pki "{{ mail_domain }}" cert "/etc/ssl/{{ mail_domain }}.crt" 7 | pki "{{ mail_domain }}" key "/etc/ssl/private/{{ mail_domain }}.key" 8 | 9 | filter "rspamd" proc-exec "filter-rspamd" 10 | 11 | listen on egress port 25 tls pki "{{ mail_domain }}" auth-optional filter "rspamd" 12 | listen on egress port 465 smtps pki "{{ mail_domain }}" auth filter "rspamd" 13 | listen on egress port 587 tls pki "{{ mail_domain }}" auth filter "rspamd" 14 | 15 | action "local_mail" maildir "~/Mail" alias 16 | action "dovecot" lmtp "/var/dovecot/lmtp" alias 17 | action "outbound" relay 18 | 19 | match from local for local action "local_mail" 20 | match from local auth for any action "outbound" 21 | match from any for domain "{{ mail_domain }}" action "dovecot" 22 | match from any auth for any action "outbound" 23 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | rules: 6 | braces: { max-spaces-inside: 1 } 7 | brackets: enable 8 | colons: enable 9 | commas: enable 10 | comments: 11 | level: warning 12 | min-spaces-from-content: 1 13 | comments-indentation: false 14 | document-end: disable 15 | document-start: { level: warning } 16 | empty-lines: enable 17 | empty-values: disable 18 | hyphens: enable 19 | indentation: enable 20 | key-duplicates: enable 21 | key-ordering: disable 22 | line-length: 23 | allow-non-breakable-inline-mappings: true 24 | allow-non-breakable-words: true 25 | max: 100 26 | new-line-at-end-of-file: enable 27 | new-lines: enable 28 | octal-values: 29 | forbid-implicit-octal: true 30 | forbid-explicit-octal: true 31 | quoted-strings: disable 32 | trailing-spaces: enable 33 | truthy: 34 | allowed-values: ["true", "false"] 35 | check-keys: false 36 | 37 | yaml-files: 38 | - "*.yaml" 39 | - "*.yml" 40 | - ".ansible-lint" 41 | - ".yamllint" 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This repository provides an Ansible collection for performing actions against 2 | OpenBSD hosts. 3 | 4 | If you are looking for a hosting provider that offers OpenBSD, consider using 5 | [OpenBSD.Amsterdam](https://openbsd.amsterdam) which contributes to 6 | [The OpenBSD Foundation](https://www.openbsdfoundation.org/). 7 | 8 | # Using 9 | 10 | ```sh 11 | # Install the collection 12 | ansible-galaxy collection install jcmdln.openbsd 13 | 14 | # Adhoc use of a module 15 | ansible -i $INVENTORY all -m jcmdln.openbsd.pkg -a "name=htop state=present" 16 | 17 | # Use a provided playbook to ensure Python is installed 18 | ansible-playbook -i $INVENTORY jcmdln.openbsd.python 19 | 20 | # Chain playbooks to update packages and patch hosts 21 | ansible-playbook -i $INVENTORY jcmdln.openbsd.{pkg,syspatch} 22 | ``` 23 | 24 | # Developing 25 | 26 | To avoid reinstalling the collection during each change, create a symbolic link 27 | to your user's collections path instead of installing the collection: 28 | 29 | ```sh 30 | mkdir -pv $HOME/.ansible/collections/ansible_collections/jcmdln && 31 | rm -frv $HOME/.ansible/collections/ansible_collections/jcmdln/openbsd && 32 | ln -fs $PWD $HOME/.ansible/collections/ansible_collections/jcmdln/openbsd 33 | ``` 34 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | name: lint 6 | 7 | on: 8 | pull_request: 9 | branches: ["**"] 10 | paths-ignore: ["**.md"] 11 | push: 12 | branches: ["**"] 13 | paths-ignore: ["**.md"] 14 | workflow_dispatch: 15 | 16 | jobs: 17 | lint: 18 | name: ${{ matrix.linter }}-${{ matrix.python }}-${{ matrix.os }} 19 | runs-on: ${{ matrix.os }} 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | linter: [ansible-lint, mypy, ruff] 25 | os: [ubuntu-latest] 26 | python: ["3.9", "3.10", "3.11", "3.12", "3.13"] 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | 31 | - uses: actions/setup-python@v5 32 | with: 33 | python-version: ${{ matrix.python }} 34 | cache: pip 35 | cache-dependency-path: tox.ini 36 | 37 | - name: Setup Tox cache 38 | uses: actions/cache@v4 39 | id: tox 40 | with: 41 | key: tox-${{ matrix.linter }}-${{ matrix.python }}-${{ matrix.os }}-${{ hashFiles('tox.ini') }} 42 | path: .tox/${{ matrix.linter }} 43 | 44 | - name: Install Tox 45 | run: pip install tox 46 | 47 | - name: Run ${{ matrix.linter }} 48 | run: tox -e ${{ matrix.linter }} 49 | -------------------------------------------------------------------------------- /roles/mail/templates/etc/dovecot/local.conf.j2: -------------------------------------------------------------------------------- 1 | # {{ template_destpath }} 2 | # {{ ansible_managed }} 3 | 4 | auth_username_format = %Ln 5 | hostname = {{ mail_domain }} 6 | lda_mailbox_autocreate = yes 7 | lda_mailbox_autosubscribe = yes 8 | mail_location = maildir:~/Mail 9 | mail_max_userip_connections = 100 10 | postmaster_address = postmaster@{{ mail_domain }} 11 | protocols = imap lmtp 12 | ssl = required 13 | ssl_cert = 4 | { 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnSave": true, 7 | "editor.wordWrap": "on", 8 | 9 | "files.associations": { 10 | ".ansible-lint": "yaml", 11 | "**/.github/workflows/galaxy.yaml": "yaml", 12 | "**/defaults/**/*.yaml": "ansible", 13 | "**/handlers/**/*.yaml": "ansible", 14 | "**/inventory/**/*.yaml": "ansible", 15 | "**/meta/**/*.yaml": "ansible", 16 | "**/tasks/**/*.yaml": "ansible", 17 | "**/vars/**/*.yaml": "ansible", 18 | "**/roles/*/templates/**/*.conf.j2": "jinja-properties", 19 | "galaxy.yml": "ansible", 20 | "site-*.yaml": "ansible" 21 | }, 22 | "files.exclude": { 23 | "**/__pycache__/**": true, 24 | "**/.mypy_cache/**": true, 25 | "**/.ruff_cache/**": true, 26 | "**/.pytest_cache/**": true, 27 | "**/.tox/**": true, 28 | "**/.venv/**": true 29 | }, 30 | "files.insertFinalNewline": true, 31 | "files.trimFinalNewlines": true, 32 | "files.trimTrailingWhitespace": true, 33 | 34 | // 35 | // Extensions 36 | // 37 | 38 | "ansible.python.interpreterPath": "python", 39 | 40 | "evenBetterToml.formatter.columnWidth": 100, 41 | "evenBetterToml.formatter.reorderKeys": true, 42 | "evenBetterToml.schema.enabled": false, 43 | 44 | "python.testing.pytestArgs": ["test"], 45 | "python.testing.unittestEnabled": false, 46 | "python.testing.pytestEnabled": true, 47 | 48 | "ruff.enable": true, 49 | "ruff.organizeImports": true, 50 | 51 | "yaml.schemas": { 52 | "https://raw.githubusercontent.com/ansible/schemas/main/f/ansible.json": [ 53 | "{workspaceFolder}/site-*.yml" 54 | ], 55 | "https://raw.githubusercontent.com/ansible-community/schemas/main/f/ansible-galaxy.json": [ 56 | "{workspaceFolder}/galaxy.yml" 57 | ], 58 | "https://json.schemastore.org/github-workflow.json": [ 59 | "{workspaceFolder}/.github/workflows/*.yml" 60 | ] 61 | }, 62 | 63 | // 64 | // Languages 65 | // 66 | 67 | "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, 68 | "[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, 69 | "[markdown]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, 70 | 71 | "[python]": { 72 | "editor.codeActionsOnSave": { 73 | "source.organizeImports": "explicit" 74 | }, 75 | "editor.defaultFormatter": "charliermarsh.ruff" 76 | }, 77 | 78 | "[yaml]": { "editor.defaultFormatter": "esbenp.prettier-vscode" } 79 | } 80 | -------------------------------------------------------------------------------- /plugins/modules/sysupgrade.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | 5 | from __future__ import absolute_import, annotations, division, print_function 6 | 7 | __metaclass__ = type 8 | 9 | from ansible.module_utils.basic import AnsibleModule 10 | 11 | DOCUMENTATION = r""" 12 | --- 13 | module: sysupgrade 14 | short_description: Update to the next release or snapshot with sysupgrade 15 | version_added: "1.2.0" 16 | 17 | author: Johnathan Craig Maudlin (@jcmdln) 18 | description: [] 19 | 20 | options: 21 | branch: 22 | description: 23 | default: 24 | force: 25 | description: 26 | default: 27 | keep: 28 | description: 29 | default: 30 | """ 31 | 32 | 33 | class Result: 34 | def __init__( 35 | self, 36 | changed: bool = False, 37 | command: str = "/usr/sbin/sysupgrade -n", 38 | msg: str = "no action required", 39 | rc: int = 0, 40 | reboot: bool = False, 41 | stdout: str = "", 42 | stderr: str = "", 43 | ) -> None: 44 | self.changed: bool = changed 45 | self.command: str = command 46 | self.msg: str = msg 47 | self.rc: int = rc 48 | self.reboot: bool = reboot 49 | self.stdout: str = stdout 50 | self.stderr: str = stderr 51 | 52 | 53 | def sysupgrade(module: AnsibleModule) -> Result: 54 | r: Result = Result() 55 | 56 | if module.params["branch"] == "release": 57 | r.command = f"{r.command} -r" 58 | elif module.params["branch"] == "snapshot": 59 | r.command = f"{r.command} -s" 60 | 61 | if module.params["force"]: 62 | r.command = f"{r.command} -f" 63 | 64 | r.rc, r.stdout, r.stderr = module.run_command(r.command, check_rc=False) 65 | 66 | if not r.stdout and not r.stderr: 67 | r.msg = "no actions performed" 68 | return r 69 | if "already on latest" in r.stdout.lower(): 70 | r.msg = r.stdout.split("\n")[-1].strip(".").lower() 71 | return r 72 | if "404 not found" in r.stderr.lower(): 73 | r.msg = "no newer {} available".format(module.params["branch"]) 74 | r.rc = 0 75 | return r 76 | if r.rc != 0 or "failed" in [ 77 | r.stderr.lower(), 78 | r.stdout.lower(), 79 | ]: 80 | r.msg = "failed to upgrade host" 81 | r.rc = 1 if r.rc == 0 else r.rc 82 | return r 83 | 84 | r.changed = True 85 | r.msg = "upgrade performed successfully" 86 | r.reboot = True 87 | return r 88 | 89 | 90 | def main() -> None: 91 | module: AnsibleModule = AnsibleModule( 92 | argument_spec={ 93 | "branch": { 94 | "choices": ["auto", "release", "snapshot"], 95 | "required": True, 96 | "type": "str", 97 | }, 98 | "force": {"default": False, "type": "bool"}, 99 | "keep": {"default": False, "type": "bool"}, 100 | }, 101 | supports_check_mode=False, 102 | ) 103 | 104 | result: Result = sysupgrade(module) 105 | if result.rc > 0: 106 | module.fail_json(**result.__dict__) 107 | module.exit_json(**result.__dict__) 108 | 109 | 110 | if __name__ == "__main__": 111 | main() 112 | -------------------------------------------------------------------------------- /plugins/modules/syspatch.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | 5 | from __future__ import absolute_import, annotations, division, print_function 6 | 7 | __metaclass__ = type 8 | 9 | from ansible.module_utils.basic import AnsibleModule 10 | 11 | DOCUMENTATION = r""" 12 | --- 13 | module: syspatch 14 | short_description: Apply patches using syspatch 15 | version_added: "1.2.0" 16 | 17 | author: Johnathan Craig Maudlin (@jcmdln) 18 | description: [] 19 | 20 | options: 21 | apply: 22 | description: 23 | default: 24 | list: 25 | description: 26 | default: 27 | reboot: 28 | description: 29 | default: 30 | revert: 31 | description: 32 | default: 33 | """ 34 | 35 | 36 | class Syspatch: 37 | def __init__(self, module: AnsibleModule) -> None: 38 | self.module: AnsibleModule = module 39 | 40 | # Return values 41 | self.changed: bool = False 42 | self.command: str = "/usr/sbin/syspatch" 43 | self.msg: str = "" 44 | self.rc: int = 0 45 | self.reboot: bool = False 46 | self.stdout: str = "" 47 | self.stderr: str = "" 48 | 49 | def apply(self) -> None: 50 | self.rc, self.stdout, self.stderr = self.module.run_command(self.command, check_rc=False) 51 | 52 | if self.rc == 2 or (not self.stdout and not self.stderr): 53 | self.msg = "no action performed" 54 | self.rc = 0 55 | return 56 | 57 | if self.rc != 0: 58 | self.msg = "received a non-zero exit code" 59 | return 60 | 61 | if "reboot" in self.stdout.lower(): 62 | self.reboot = True 63 | 64 | self.changed = True 65 | self.msg = "patches applied" 66 | 67 | def revert(self) -> None: 68 | if self.module.params["list"] == "all": 69 | self.command = f"{self.command} -R" 70 | 71 | if self.module.params["list"] == "latest": 72 | self.command = f"{self.command} -r" 73 | 74 | self.rc, self.stdout, self.stderr = self.module.run_command(self.command, check_rc=False) 75 | 76 | if self.rc != 0: 77 | self.msg = "received a non-zero exit code" 78 | return 79 | 80 | if not self.stdout and not self.stderr: 81 | self.msg = "no patches to revert" 82 | return 83 | 84 | if "reboot" in self.stdout.lower(): 85 | self.reboot = True 86 | 87 | self.changed = True 88 | self.msg = "patches reverted" 89 | 90 | def show(self) -> None: 91 | if self.module.params["list"].lower() == "available": 92 | self.command = f"{self.command} -c" 93 | 94 | if self.module.params["list"].lower() == "installed": 95 | self.command = f"{self.command} -l" 96 | 97 | self.rc, self.stdout, self.stderr = self.module.run_command(self.command, check_rc=False) 98 | 99 | if self.rc != 0: 100 | self.msg = "received a non-zero exit code" 101 | return 102 | 103 | if not self.stdout and not self.stderr: 104 | self.msg = "no patches to list" 105 | return 106 | 107 | self.msg = "list of available patches returned" 108 | 109 | 110 | def main() -> None: 111 | module: AnsibleModule = AnsibleModule( 112 | argument_spec={ 113 | "apply": {"type": "bool"}, 114 | "list": {"choices": ["available", "installed"], "type": "str"}, 115 | "reboot": {"type": "bool"}, 116 | "revert": {"choices": ["all", "latest"], "type": "str"}, 117 | }, 118 | required_one_of=[["apply", "list", "revert"]], 119 | mutually_exclusive=[["apply", "list", "revert"]], 120 | supports_check_mode=False, 121 | ) 122 | 123 | syspatch: Syspatch = Syspatch(module) 124 | 125 | if module.params["apply"]: 126 | syspatch.apply() 127 | elif module.params["list"]: 128 | syspatch.show() 129 | elif module.params["revert"]: 130 | syspatch.revert() 131 | 132 | result: dict[str, bool | int | str] = { 133 | "changed": syspatch.changed, 134 | "command": syspatch.command, 135 | "msg": syspatch.msg, 136 | "rc": syspatch.rc, 137 | "reboot": syspatch.reboot, 138 | "stdout": syspatch.stdout, 139 | "stderr": syspatch.stderr, 140 | } 141 | 142 | if syspatch.rc > 0: 143 | module.fail_json(**result) 144 | 145 | module.exit_json(**result) 146 | 147 | 148 | if __name__ == "__main__": 149 | main() 150 | -------------------------------------------------------------------------------- /roles/mail/tasks/main.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2024 Johnathan C. Maudlin 4 | --- 5 | - name: Install required packages 6 | jcmdln.openbsd.pkg: 7 | name: "{{ mail_packages }}" 8 | state: present 9 | 10 | # 11 | # SSL 12 | # 13 | 14 | - name: Create SSL private key for {{ mail_domain }} 15 | community.crypto.openssl_privatekey: 16 | path: "/etc/ssl/private/{{ mail_domain }}.key" 17 | type: RSA 18 | 19 | - name: Create Certificate Signing Request (CSR) for {{ mail_domain }} 20 | community.crypto.openssl_csr: 21 | path: "/etc/ssl/{{ mail_domain }}.csr" 22 | privatekey_path: "/etc/ssl/private/{{ mail_domain }}.key" 23 | common_name: "{{ mail_csr_common_name }}" 24 | country_name: "{{ mail_csr_country_name }}" 25 | email_address: "{{ mail_csr_email_address }}" 26 | organization_name: "{{ mail_csr_organization_name }}" 27 | 28 | - name: Create self-signed certificate for {{ mail_domain }} 29 | community.crypto.x509_certificate: 30 | path: "/etc/ssl/{{ mail_domain }}.crt" 31 | privatekey_path: "/etc/ssl/private/{{ mail_domain }}.key" 32 | provider: selfsigned 33 | csr_path: "/etc/ssl/{{ mail_domain }}.csr" 34 | 35 | # 36 | # DKIM 37 | # 38 | 39 | - name: Create _dkimsign system user account 40 | ansible.builtin.user: 41 | name: _dkimsign 42 | state: present 43 | system: true 44 | create_home: false 45 | 46 | - name: Create /etc/mail/dkim directory 47 | ansible.builtin.file: 48 | path: /etc/mail/dkim 49 | state: directory 50 | owner: _dkimsign 51 | group: _dkimsign 52 | mode: "0770" 53 | 54 | - name: Generate RSA2048 dkim private key 55 | community.crypto.openssl_privatekey: 56 | path: "/etc/mail/dkim/{{ mail_dkim_selector }}.private.key" 57 | size: 2048 58 | type: RSA 59 | owner: _dkimsign 60 | group: _dkimsign 61 | mode: "0600" 62 | 63 | - name: Generate RSA2048 dkim public key 64 | community.crypto.openssl_publickey: 65 | path: "/etc/mail/dkim/{{ mail_dkim_selector }}.public.key" 66 | privatekey_path: "/etc/mail/dkim/{{ mail_dkim_selector }}.private.key" 67 | owner: _dkimsign 68 | group: _dkimsign 69 | mode: "0600" 70 | 71 | # 72 | # Dovecot 73 | # 74 | 75 | - name: Enable Dovecot service 76 | ansible.builtin.service: 77 | name: dovecot 78 | enabled: true 79 | 80 | - name: Configure Dovecot 81 | notify: Restart dovecot 82 | ansible.builtin.template: 83 | backup: true 84 | dest: /etc/dovecot/local.conf 85 | group: wheel 86 | mode: "0644" 87 | owner: root 88 | src: etc/dovecot/local.conf.j2 89 | 90 | - name: Comment out ssl_cert from /etc/dovecot/conf.d/10-ssl.conf 91 | ansible.builtin.replace: 92 | path: /etc/dovecot/conf.d/10-ssl.conf 93 | regexp: "^ssl_cert" 94 | replace: "#ssl_cert" 95 | 96 | - name: Comment out ssl_key from /etc/dovecot/conf.d/10-ssl.conf 97 | ansible.builtin.replace: 98 | path: /etc/dovecot/conf.d/10-ssl.conf 99 | regexp: "^ssl_key" 100 | replace: "#ssl_key" 101 | 102 | - name: Start Dovecot service 103 | ansible.builtin.service: 104 | name: dovecot 105 | state: started 106 | 107 | # 108 | # Rspamd 109 | # 110 | 111 | - name: Enable rspamd 112 | ansible.builtin.service: 113 | name: rspamd 114 | enabled: true 115 | 116 | - name: Start rspamd 117 | ansible.builtin.service: 118 | name: rspamd 119 | state: started 120 | 121 | - name: Enable redis 122 | ansible.builtin.service: 123 | name: rspamd 124 | enabled: true 125 | 126 | - name: Start redis 127 | ansible.builtin.service: 128 | name: rspamd 129 | state: started 130 | 131 | # 132 | # OpenSMTPD 133 | # 134 | 135 | - name: Enable smtpd 136 | ansible.builtin.service: 137 | enabled: true 138 | name: smtpd 139 | 140 | - name: Configure smtpd 141 | notify: Restart smtpd 142 | ansible.builtin.template: 143 | backup: true 144 | dest: /etc/mail/smtpd.conf 145 | group: wheel 146 | mode: "0644" 147 | owner: root 148 | src: etc/mail/smtpd.conf.j2 149 | 150 | - name: Define well-known mail aliases for root@ 151 | when: 152 | - mail_aliases_root is defined 153 | - mail_aliases_root != "root" 154 | ansible.builtin.blockinfile: 155 | block: | 156 | # Well-known aliases 157 | contact: root 158 | dumper: root 159 | info: root 160 | manager: root 161 | privacy: root 162 | root: {{ mail_aliases_root }} 163 | 164 | # RFC 2142 165 | abuse: root 166 | hostmaster: root 167 | security: root 168 | webmaster: root 169 | marker: "# {mark} ANSIBLE MANAGED BLOCK: jcmdln.openbsd.mail" 170 | path: "/etc/mail/aliases" 171 | 172 | - name: Start smtpd 173 | ansible.builtin.service: 174 | name: smtpd 175 | state: started 176 | -------------------------------------------------------------------------------- /roles/mail/README.md: -------------------------------------------------------------------------------- 1 | # jcmdln.openbsd.mail 2 | 3 | Setup a simple mail server using OpenSMTPD, Dovecot and Rspamd. 4 | 5 | ## About 6 | 7 | In order to keep things simple this role uses real unix users each with their 8 | own [Maildir](https://en.wikipedia.org/wiki/Maildir) rather than use virtual 9 | users or duplicate where mail can end up by leaving an mbox around. Dovecot 10 | only handles IMAP login and redelivering filtered mail by LMTP. 11 | 12 | This may make things _too_ simple for some scenarios though the idea is that 13 | you are giving trusted users unprivileged accounts by which they are afforded 14 | system resources as well as a mailbox to get work done. 15 | 16 | ## Requirements 17 | 18 | - Dovecot 19 | - https://www.dovecot.org 20 | - https://www.rspamd.com/ 21 | - OpenBSD 22 | - https://man.openbsd.org/acme-client.1 23 | - https://man.openbsd.org/openssl.1 24 | - https://man.openbsd.org/pf.4 25 | - https://man.openbsd.org/pf.conf.5 26 | - https://man.openbsd.org/smtpd.8 27 | - https://man.openbsd.org/smtpd.conf.5 28 | 29 | ### DNS Records 30 | 31 | The table below outlines which DNS records are required (or suggested): 32 | 33 | [rfc5321]: https://www.rfc-editor.org/rfc/rfc5321 34 | [rfc6186]: https://www.rfc-editor.org/rfc/rfc6186 35 | [rfc6376]: https://www.rfc-editor.org/rfc/rfc6376 36 | [rfc7208]: https://www.rfc-editor.org/rfc/rfc7208 37 | [rfc7489]: https://www.rfc-editor.org/rfc/rfc7489 38 | [rfc8657]: https://www.rfc-editor.org/rfc/rfc8657 39 | 40 | | Host/Service | Type | TTL | Value | 41 | | --------------------- | ----- | ---- | --------------------------------- | 42 | | @ | A | 3600 | 0.0.0.0 | 43 | | @ | AAAA | 3600 | ::1 | 44 | | imap.domain.tld | CNAME | 3600 | domain.tld | 45 | | smtp.domain.tld | CNAME | 3600 | domain.tld | 46 | | [rfc5321] | 47 | | @ | MX | 3600 | 10 smtp.domain.tld. | 48 | | [rfc6186] | 49 | | \_imap.\_tcp | SRV | 3600 | 0 1 143 imap.domain.tld. | 50 | | \_imaps.\_tcp | SRV | 3600 | 0 1 993 imap.domain.tld. | 51 | | \_submission.\_tcp | SRV | 3600 | 0 1 587 smtp.domain.tld. | 52 | | \_submissions.\_tcp | SRV | 3600 | 0 1 465 smtp.domain.tld. | 53 | | [rfc7208] | 54 | | @ | TXT | 3600 | v=spf1 mx -all | 55 | | [rfc6376] | 56 | | $SELECTOR.\_domainkey | TXT | 3600 | v=DKIM1; | 57 | | | | | k=rsa; | 58 | | | | | p=$RSA_PUBLIC_KEY | 59 | | [rfc7489] | 60 | | \_dmarc | TXT | 3600 | v=DMARC1; | 61 | | | | | p=reject; | 62 | | | | | pct=100; | 63 | | | | | adkim=s; | 64 | | | | | aspf=s; | 65 | | | | | rf=afrf; | 66 | | | | | rua=mailto:hostmaster@domain.tld; | 67 | | | | | ruf=mailto:hostmaster@domain.tld | 68 | 69 | #### DNSSEC 70 | 71 | Consider enabling DNSSEC: 72 | 73 | - https://www.rfc-editor.org/rfc/rfc4035 74 | 75 | ## Sieve 76 | 77 | - https://www.rfc-editor.org/rfc/rfc5228 78 | - https://www.rfc-editor.org/rfc/rfc5233 79 | 80 | ### Examples 81 | 82 | ```ruby 83 | # ~/Mail/dovecot.sieve 84 | 85 | require ["fileinto", "mailbox"]; 86 | 87 | if exists "x-spam" { 88 | if header :contains "x-spam" "yes" { 89 | fileinto :create "Junk"; 90 | } 91 | } elsif exists "list-id" { 92 | if header :contains "list-id" "openbsd.org" { 93 | if header :contains "list-id" "advocacy" { 94 | fileinto :create "openbsd-advocacy"; 95 | } elsif header :contains "list-id" "announce" { 96 | fileinto :create "openbsd-announce"; 97 | } elsif header :contains "list-id" "bugs" { 98 | fileinto :create "openbsd-bugs"; 99 | } elsif header :contains "list-id" "misc" { 100 | fileinto :create "openbsd-misc"; 101 | } elsif header :contains "list-id" "ports" { 102 | fileinto :create "openbsd-ports"; 103 | } elsif header :contains "list-id" "source-changes" { 104 | fileinto :create "openbsd-source-changes"; 105 | } elsif header :contains "list-id" "tech" { 106 | fileinto :create "openbsd-tech"; 107 | } 108 | } 109 | } 110 | ``` 111 | 112 | ## Supplemental Reading 113 | 114 | - [DOMAIN NAMES - CONCEPTS AND FACILITIES [1987]](https://www.rfc-editor.org/rfc/rfc1034) 115 | - [Simple Mail Transfer Protocol (2008)](https://www.rfc-editor.org/rfc/rfc5321) 116 | - [Internet Message Format [2008]](https://www.rfc-editor.org/rfc/rfc5322) 117 | - [Internet Message Access Protocol (IMAP) - Version 4rev2 [2021]](https://www.rfc-editor.org/rfc/rfc9051) 118 | -------------------------------------------------------------------------------- /plugins/modules/pkg.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # 3 | # Copyright (c) 2023 Johnathan C. Maudlin 4 | 5 | 6 | from __future__ import absolute_import, annotations, division, print_function 7 | 8 | __metaclass__ = type 9 | 10 | import re 11 | 12 | from ansible.module_utils.basic import AnsibleModule 13 | 14 | DOCUMENTATION = r""" 15 | --- 16 | module: pkg 17 | short_description: "Manage packages using pkg_* suite" 18 | version_added: "1.2.0" 19 | 20 | author: Johnathan Craig Maudlin (@jcmdln) 21 | description: 22 | - Manage packages using pkg_* suite 23 | 24 | options: 25 | delete_unused: 26 | description: 27 | - Delete unused dependencies (packages that are not needed by anything tagged as installed manually). Can be used without pkgnames. If used with pkgnames, it will only delete non manual installs in the list. 28 | type: bool 29 | default: false 30 | 31 | force: 32 | description: 33 | - Force removal of the package by waiving a failsafe. 34 | - "When used with `state=absent`, only the following values are valid: `baddepend`, `checksum`, `dependencies`, `nonroot`, `scripts`" 35 | - "When used with `state=latest` or `state=present`, only the following values are valid: `allversions`, `arch`, `checksum` `dontmerge`, `donttie`, `downgrade`, `installed`, `nonroot` `repair`, `scripts` `SIGNER`, `snap`, `snapshot`, `unsigned`" 36 | type: str 37 | choices: 38 | - allversions 39 | - arch 40 | - baddepend 41 | - checksum 42 | - dependencies 43 | - dontmerge 44 | - donttie 45 | - downgrade 46 | - installed 47 | - nonroot 48 | - repair 49 | - scripts 50 | - SIGNER 51 | - snap 52 | - snapshot 53 | - unsigned 54 | 55 | name: 56 | description: 57 | - A package name or list of packages. 58 | type: list 59 | element: str 60 | default: [] 61 | 62 | replace_existing: 63 | description: 64 | - thing 65 | type: bool 66 | default: false 67 | 68 | state: 69 | description: 70 | - thing 71 | type: str 72 | required: true 73 | choices: 74 | - absent 75 | - latest 76 | - present 77 | """ 78 | 79 | EXAMPLES = r""" 80 | - name: Update all packages 81 | jcmdln.openbsd.pkg: 82 | name: "*" 83 | state: latest 84 | 85 | - name: Install a package 86 | jcmdln.openbsd.pkg: 87 | name: cmake 88 | state: present 89 | 90 | - name: Update existing packages and install missing packages 91 | jcmdln.openbsd.pkg: 92 | name: 93 | - cmake 94 | - nano 95 | state: latest 96 | 97 | - name: Delete a package 98 | jcmdln.openbsd.pkg: 99 | name: cmake 100 | state: absent 101 | 102 | - name: Delete existing packages 103 | jcmdln.openbsd.pkg: 104 | name: 105 | - cmake 106 | - nano 107 | state: absent 108 | """ 109 | 110 | 111 | class Pkg: 112 | # Results 113 | changed: bool = False 114 | command: str = "" 115 | msg: str = "no action required" 116 | rc: int = 0 117 | stdout: str = "" 118 | stderr: str = "" 119 | 120 | def __init__(self, module: AnsibleModule) -> None: 121 | self.module: AnsibleModule = module 122 | 123 | def add(self, *, force: str, name: list[str], replace_existing: bool, state: str) -> Pkg: 124 | self.command = "/usr/sbin/pkg_add -I -v -x" 125 | 126 | if force and force not in [ 127 | "allversions", 128 | "arch", 129 | "checksum", 130 | "dontmerge", 131 | "donttie", 132 | "downgrade", 133 | "installed", 134 | "nonroot", 135 | "repair", 136 | "scripts", 137 | "SIGNER", 138 | "snap", 139 | "snapshot", 140 | "unsigned", 141 | ]: 142 | self.msg = f"'{force}' is invalid when adding packages" 143 | self.rc = 1 144 | return self 145 | 146 | if "*" in name and state == "present": 147 | self.msg = "Refusing to install all packages" 148 | self.rc = 1 149 | return self 150 | 151 | if self.module.check_mode: 152 | self.command = f"{self.command} -s" 153 | if force: 154 | self.command = f"{self.command} -D {force}".format() 155 | if replace_existing: 156 | self.command = f"{self.command} -r" 157 | 158 | # When updating all packages, skip all other package semantics. 159 | if "*" in name: 160 | self.command = f"{self.command} -u" 161 | self.rc, self.stdout, self.stderr = self.module.run_command( 162 | self.command, check_rc=False 163 | ) 164 | self.changed = False if self.module.check_mode else "installing" in self.stdout 165 | self.msg = "Updated packages." 166 | return self 167 | 168 | # When ensuring packages are installed, skip all other package semantics. 169 | if state == "present": 170 | self.command = f"{self.command} {' '.join(name)}" 171 | self.rc, self.stdout, self.stderr = self.module.run_command( 172 | self.command, check_rc=False 173 | ) 174 | self.changed = False if self.module.check_mode else "installing" in self.stdout 175 | self.msg = "Installed packages." 176 | return self 177 | 178 | # Collect the dict of currently installed packages. 179 | packages: dict = self.info() 180 | 181 | # Collect to_install packages, removing matches 182 | to_install: dict[str, None] = {} 183 | for pkg in [p for p in name if not packages.get(p)]: 184 | to_install[pkg] = None 185 | name = list(set(name) - {pkg}) 186 | 187 | # Collect to_update packages, removing matches 188 | to_update: dict[str, None] = {} 189 | for pkg in name: 190 | to_update[pkg] = None 191 | name = list(set(name) - {pkg}) 192 | 193 | cmd_install: str = f"{self.command} {' '.join(to_install.keys())}" 194 | print(cmd_install) 195 | cmd_update: str = f"{self.command} -u {' '.join(to_update.keys())}" 196 | print(cmd_update) 197 | if to_install and to_update: 198 | self.command = f"{cmd_update} && {cmd_install}" 199 | self.msg = "Updated and installed packages." 200 | elif to_install: 201 | self.command = cmd_install 202 | self.msg = "Installed packages." 203 | elif to_update: 204 | self.command = cmd_update 205 | self.msg = "Updated packages." 206 | 207 | self.rc, self.stdout, self.stderr = self.module.run_command(self.command, check_rc=False) 208 | self.changed = "installing" in self.stdout 209 | return self 210 | 211 | def delete(self, *, delete_unused: bool, force: str, name: list[str], state: str) -> Pkg: 212 | self.command = "/usr/sbin/pkg_delete -I -v -x" 213 | 214 | if force and force not in ["baddepend", "checksum", "dependencies", "nonroot", "scripts"]: 215 | self.command = "" 216 | self.msg = f"'{force}' is invalid when deleting packages" 217 | self.rc = 1 218 | return self 219 | if "*" in name and state == "present": 220 | self.msg = "Refusing to delete all packages" 221 | self.rc = 1 222 | return self 223 | 224 | if self.module.check_mode: 225 | self.command = f"{self.command} -s" 226 | if delete_unused: 227 | self.command = f"{self.command} -a" 228 | if force: 229 | self.command = f"{self.command} -D {force}".format() 230 | 231 | # When deleting all unused packages, skip all other package semantics. 232 | if not name and delete_unused: 233 | self.rc, self.stdout, self.stderr = self.module.run_command( 234 | self.command, check_rc=False 235 | ) 236 | self.changed = False if self.module.check_mode else "Deleting" in self.stdout 237 | self.msg = "Deleted packages." 238 | return self 239 | 240 | # Collect the dict of currently installed packages. 241 | packages: dict = self.info() 242 | 243 | # Collect to_delete packages 244 | to_delete: dict[str, None] = {} 245 | for p in [p for p in packages if p in name or packages.get(p, {}).get("name") in name]: 246 | to_delete[p] = None 247 | if not to_delete: 248 | return self 249 | 250 | packages.values 251 | 252 | self.command = f"{self.command} {' '.join(to_delete.keys())}" 253 | self.rc, self.stdout, self.stderr = self.module.run_command(self.command, check_rc=False) 254 | self.changed = False if self.module.check_mode else "Deleting" in self.stdout 255 | self.msg = "Deleted packages." 256 | return self 257 | 258 | def info(self) -> dict: 259 | """Gather the name and version of all installed packages.""" 260 | packages: dict = {} 261 | stdout: str 262 | 263 | _, stdout, _ = self.module.run_command("/usr/sbin/pkg_info -q", check_rc=False) 264 | for pkg in [pkg for pkg in stdout.splitlines() if pkg]: 265 | name = re.sub(r"-[0-9].*$", "", pkg) 266 | version: str = v.group(1) if (v := re.search(r"-([\d.]+.*$)", pkg)) else "" 267 | packages[pkg] = {"name": name, "version": f"{version}"} 268 | 269 | return packages 270 | 271 | 272 | def main() -> None: 273 | module: AnsibleModule = AnsibleModule( 274 | argument_spec={ 275 | "delete_unused": {"default": False, "type": bool}, 276 | "force": { 277 | "choices": [ 278 | "checksum", 279 | "nonroot", 280 | "scripts", 281 | # pkg_add only 282 | "allversions", 283 | "arch", 284 | "dontmerge", 285 | "donttie", 286 | "downgrade", 287 | "installed", 288 | "repair", 289 | "SIGNER", 290 | "snap", 291 | "snapshot", 292 | "unsigned", 293 | # pkg_delete only 294 | "baddepend", 295 | "dependencies", 296 | ], 297 | "type": "str", 298 | }, 299 | "name": {"elements": "str", "type": "list"}, 300 | "replace_existing": {"default": False, "type": bool}, 301 | "state": { 302 | "choices": ["absent", "latest", "present"], 303 | "required": True, 304 | "type": "str", 305 | }, 306 | }, 307 | # required_one_of=[], 308 | supports_check_mode=True, 309 | ) 310 | 311 | # Module arguments 312 | delete_unused: bool = module.params["delete_unused"] 313 | force: str = module.params["force"] 314 | name: list[str] = ( 315 | module.params["name"] 316 | if isinstance(module.params["name"], list) 317 | else list(module.params["name"]) 318 | if isinstance(module.params["name"], str) 319 | else [] 320 | ) 321 | replace_existing: bool = module.params["replace_existing"] 322 | state: str = module.params["state"] 323 | 324 | # Results 325 | pkg: Pkg = Pkg(module) 326 | 327 | # Determine which action(s) to perform 328 | if state == "absent": 329 | if replace_existing: 330 | pkg.msg = f"cannot mix 'delete_unused' with 'state: {state}'" 331 | pkg.rc = 1 332 | else: 333 | pkg.delete( 334 | delete_unused=delete_unused, 335 | force=force, 336 | name=name, 337 | state=state, 338 | ) 339 | elif state in ["latest", "present"]: 340 | if delete_unused: 341 | pkg.msg = f"cannot mix 'delete_unused' with 'state: {state}'" 342 | pkg.rc = 1 343 | else: 344 | pkg.add( 345 | force=force, 346 | name=name, 347 | replace_existing=replace_existing, 348 | state=state, 349 | ) 350 | 351 | result: dict = { 352 | "changed": pkg.changed, 353 | "command": pkg.command, 354 | "msg": pkg.msg, 355 | "rc": pkg.rc, 356 | "stdout": pkg.stdout, 357 | "stderr": pkg.stderr, 358 | } 359 | 360 | # Handle results 361 | if pkg.rc != 0: 362 | module.fail_json(**result) 363 | module.exit_json(**result) 364 | 365 | 366 | if __name__ == "__main__": 367 | main() 368 | --------------------------------------------------------------------------------