Installed Bodsch-DNS
This commit is contained in:
parent
986e5a8f3e
commit
b50065d668
|
|
@ -0,0 +1,8 @@
|
|||
download_url: https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/bodsch-core-2.10.1.tar.gz
|
||||
format_version: 1.0.0
|
||||
name: core
|
||||
namespace: bodsch
|
||||
server: https://galaxy.ansible.com/api/
|
||||
signatures: []
|
||||
version: 2.10.1
|
||||
version_url: /api/v3/plugin/ansible/content/published/collections/index/bodsch/core/versions/2.10.1/
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
download_url: https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/bodsch-dns-1.4.0.tar.gz
|
||||
format_version: 1.0.0
|
||||
name: dns
|
||||
namespace: bodsch
|
||||
server: https://galaxy.ansible.com/api/
|
||||
signatures: []
|
||||
version: 1.4.0
|
||||
version_url: /api/v3/plugin/ansible/content/published/collections/index/bodsch/dns/versions/1.4.0/
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
download_url: https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/bodsch-systemd-1.4.0.tar.gz
|
||||
format_version: 1.0.0
|
||||
name: systemd
|
||||
namespace: bodsch
|
||||
server: https://galaxy.ansible.com/api/
|
||||
signatures: []
|
||||
version: 1.4.0
|
||||
version_url: /api/v3/plugin/ansible/content/published/collections/index/bodsch/systemd/versions/1.4.0/
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
Contributing
|
||||
============
|
||||
If you want to contribute to a project and make it better, your help is very welcome.
|
||||
Contributing is also a great way to learn more about social coding on Github, new technologies and
|
||||
and their ecosystems and how to make constructive, helpful bug reports, feature requests and the
|
||||
noblest of all contributions: a good, clean pull request.
|
||||
|
||||
### How to make a clean pull request
|
||||
|
||||
Look for a project's contribution instructions. If there are any, follow them.
|
||||
|
||||
- Create a personal fork of the project on Github.
|
||||
- Clone the fork on your local machine. Your remote repo on Github is called `origin`.
|
||||
- Add the original repository as a remote called `upstream`.
|
||||
- If you created your fork a while ago be sure to pull upstream changes into your local repository.
|
||||
- Create a new branch to work on! Branch from `develop` if it exists, else from `master`.
|
||||
- Implement/fix your feature, comment your code.
|
||||
- Follow the code style of the project, including indentation.
|
||||
- If the project has tests run them!
|
||||
- Write or adapt tests as needed.
|
||||
- Add or change the documentation as needed.
|
||||
- Squash your commits into a single commit. Create a new branch if necessary.
|
||||
- Push your branch to your fork on Github, the remote `origin`.
|
||||
- From your fork open a pull request in the correct branch. Target the project's `develop` branch if there is one, else go for `master`!
|
||||
- If the maintainer requests further changes just push them to your branch. The PR will be updated automatically.
|
||||
- Once the pull request is approved and merged you can pull the changes from `upstream` to your local repo and delete
|
||||
your extra branch(es).
|
||||
|
||||
And last but not least: Always write your commit messages in the present tense.
|
||||
Your commit message should describe what the commit, when applied, does to the
|
||||
code – not what you did to the code.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,201 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"collection_info": {
|
||||
"namespace": "bodsch",
|
||||
"name": "core",
|
||||
"version": "2.10.1",
|
||||
"authors": [
|
||||
"Bodo Schulz <bodo@boone-schulz.de>"
|
||||
],
|
||||
"readme": "README.md",
|
||||
"tags": [
|
||||
"pki",
|
||||
"vpn",
|
||||
"openvpn",
|
||||
"easyrsa",
|
||||
"certificate",
|
||||
"security",
|
||||
"automation"
|
||||
],
|
||||
"description": "collection of core modules for my ansible roles",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"license_file": null,
|
||||
"dependencies": {
|
||||
"ansible.utils": "*",
|
||||
"ansible.posix": "*",
|
||||
"community.general": ">=10.5"
|
||||
},
|
||||
"repository": "https://github.com/bodsch/ansible-collection-core",
|
||||
"documentation": "https://github.com/bodsch/ansible-collection-core/README.md",
|
||||
"homepage": "https://github.com/bodsch/ansible-collection-core",
|
||||
"issues": "https://github.com/bodsch/ansible-collection-core/issues"
|
||||
},
|
||||
"file_manifest_file": {
|
||||
"name": "FILES.json",
|
||||
"ftype": "file",
|
||||
"chksum_type": "sha256",
|
||||
"chksum_sha256": "bf021256b84411724fe68c0611287919125acc1d3ea4ebc1fdef0fad58e10ced",
|
||||
"format": 1
|
||||
},
|
||||
"format": 1
|
||||
}
|
||||
|
|
@ -0,0 +1,420 @@
|
|||
# Ansible Collection - bodsch.core
|
||||
|
||||
Documentation for the collection.
|
||||
|
||||
This collection aims to offer an set of ansible modules or helper functions.
|
||||
|
||||
## supported Operating systems
|
||||
|
||||
Tested on
|
||||
|
||||
* ArchLinux
|
||||
* Debian based
|
||||
- Debian 10 / 11 / 12 / 13
|
||||
- Ubuntu 20.04 / 22.04 / 24.04
|
||||
|
||||
> **RedHat-based systems are no longer officially supported! May work, but does not have to.**
|
||||
|
||||
|
||||
## Requirements & Dependencies
|
||||
|
||||
- `dnspython`
|
||||
- `dirsync`
|
||||
- `netaddr`
|
||||
|
||||
```bash
|
||||
pip install dnspython
|
||||
pip install dirsync
|
||||
pip install netaddr
|
||||
```
|
||||
|
||||
## Included content
|
||||
|
||||
|
||||
### Roles
|
||||
|
||||
| Role | Build State | Description |
|
||||
|:---------------------------------------------------------------------------| :---------: | :---- |
|
||||
| [bodsch.core.pacman](./roles/pacman/README.md) | [][pacman] | Ansible role to configure pacman. |
|
||||
| [bodsch.core.fail2ban](./roles/fail2ban/README.md) | [][fail2ban] | Installs and configure fail2ban |
|
||||
| [bodsch.core.syslog_ng](./roles/syslog_ng/README.md) | [][syslog_ng] | Installs and configures a classic syslog-ng service for processing log files away from journald. |
|
||||
| [bodsch.core.logrotate](./roles/logrotate/README.md) | [][logrotate] | Installs logrotate and provides an easy way to setup additional logrotate scripts |
|
||||
| [bodsch.core.mount](./roles/mount/README.md) | [][mount] | Manage generic mountpoints |
|
||||
| [bodsch.core.openvpn](./roles/openvpn/README.md) | [][openvpn] | Ansible role to install and configure openvpn server. |
|
||||
| [bodsch.core.sysctl](./roles/sysctl/README.md) | [][sysctl] | Ansible role to configure sysctl. |
|
||||
| [bodsch.core.sshd](./roles/sshd/README.md) | [][sshd] | Ansible role to configure sshd. |
|
||||
|
||||
[pacman]: https://github.com/bodsch/ansible-collection-core/actions/workflows/pacman.yml
|
||||
[fail2ban]: https://github.com/bodsch/ansible-collection-core/actions/workflows/fail2ban.yml
|
||||
[snakeoil]: https://github.com/bodsch/ansible-collection-core/actions/workflows/snakeoil.yml
|
||||
[syslog_ng]: https://github.com/bodsch/ansible-collection-core/actions/workflows/syslog_ng.yml
|
||||
[logrotate]: https://github.com/bodsch/ansible-collection-core/actions/workflows/logrotate.yml
|
||||
[mount]: https://github.com/bodsch/ansible-collection-core/actions/workflows/mount.yml
|
||||
[openvpn]: https://github.com/bodsch/ansible-collection-core/actions/workflows/openvpn.yml
|
||||
[sysctl]: https://github.com/bodsch/ansible-collection-core/actions/workflows/sysctl.yml
|
||||
[sshd]: https://github.com/bodsch/ansible-collection-core/actions/workflows/sshd.yml
|
||||
|
||||
### Modules
|
||||
|
||||
| Name | Description |
|
||||
|:--------------------------|:----|
|
||||
| [bodsch.core.aur](./plugins/modules/aur.py) | Installing packages for ArchLinux with aur |
|
||||
| [bodsch.core.check_mode](./plugins/modules/check_mode.py) | Replacement for `ansible_check_mode`. |
|
||||
| [bodsch.core.facts](./plugins/modules/facts.py) | Creates a facts file for ansible. |
|
||||
| [bodsch.core.remove_ansible_backups](./plugins/modules/remove_ansible_backups.py) | Remove older backup files created by ansible |
|
||||
| [bodsch.core.package_version](./plugins/modules/package_version.py) | Attempts to determine the version of a package to be installed or already installed. |
|
||||
| [bodsch.core.sync_directory](./plugins/modules/sync_directory.py) | Syncronises directories similar to rsync |
|
||||
| [bodsch.core.easyrsa](.plugins/modules/easyrsa.py) | Manage a Public Key Infrastructure (PKI) using EasyRSA. |
|
||||
| [bodsch.core.openvpn_client_certificate](.plugins/modules/openvpn_client_certificate.py) | Manage OpenVPN client certificates using EasyRSA. |
|
||||
| [bodsch.core.openvpn_crl](.plugins/modules/openvpn_crl.py) | |
|
||||
| [bodsch.core.openvpn_ovpn](.plugins/modules/openvpn_ovpn.py) | |
|
||||
| [bodsch.core.openvpn](.plugins/modules/openvpn.py) | |
|
||||
| [bodsch.core.openvpn_version](.plugins/modules/openvpn_version.py) | |
|
||||
| [bodsch.core.pip_requirements](.plugins/modules/pip_requirements.py) | This modules creates an requirement file to install python modules via pip. |
|
||||
| [bodsch.core.syslog_cmd](.plugins/modules/syslog_cmd.py) | Run syslog-ng with arbitrary command-line parameters |
|
||||
| [bodsch.core.apt_sources](.plugins/modules/apt_sources.py) | Manage APT deb822 (.sources) repositories with repo-specific keyrings. |
|
||||
|
||||
|
||||
### Module utils
|
||||
|
||||
| Name | Description |
|
||||
|:--------------------------|:----|
|
||||
| [bodsch.core.passlib_bcrypt5_compat](./plugins/module_utils/passlib_bcrypt5_compat.py) | Compatibility helpers for using `passlib` 1.7.4 with `bcrypt` 5.x |
|
||||
|
||||
|
||||
### Actions
|
||||
|
||||
| Name | Description |
|
||||
|:--------------------------|:----|
|
||||
| [bodsch.core.deploy_and_activate](./plugins/sction/deploy_and_activate.py) | Controller-side orchestration for deploying versioned binaries and activating them via symlinks. |
|
||||
|
||||
|
||||
## Installing this collection
|
||||
|
||||
You can install the memsource collection with the Ansible Galaxy CLI:
|
||||
|
||||
```bash
|
||||
#> ansible-galaxy collection install bodsch.core
|
||||
```
|
||||
|
||||
To install directly from GitHub:
|
||||
|
||||
```bash
|
||||
#> ansible-galaxy collection install git@github.com:bodsch/ansible-collection-core.git
|
||||
```
|
||||
|
||||
|
||||
You can also include it in a `requirements.yml` file and install it with `ansible-galaxy collection install -r requirements.yml`, using the format:
|
||||
|
||||
```yaml
|
||||
---
|
||||
collections:
|
||||
- name: bodsch.core
|
||||
# version: ">=2.8.x"
|
||||
```
|
||||
|
||||
The python module dependencies are not installed by `ansible-galaxy`. They can
|
||||
be manually installed using pip:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Using this collection
|
||||
|
||||
|
||||
You can either call modules by their Fully Qualified Collection Name (FQCN), such as `bodsch.core.remove_ansible_backups`,
|
||||
or you can call modules by their short name if you list the `bodsch.core` collection in the playbook's `collections` keyword:
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
### `bodsch.core.aur`
|
||||
|
||||
```yaml
|
||||
- name: install collabora package via aur
|
||||
become: true
|
||||
become_user: aur_builder
|
||||
bodsch.core.aur:
|
||||
state: present
|
||||
name: collabora-online-server
|
||||
repository: "{{ collabora_arch.source_repository }}"
|
||||
async: 3200
|
||||
poll: 10
|
||||
register: _collabora_installed
|
||||
```
|
||||
|
||||
### `bodsch.core.check_mode`
|
||||
|
||||
```yaml
|
||||
- name: detect ansible check_mode
|
||||
bodsch.core.check_mode:
|
||||
register: _check_mode
|
||||
|
||||
- name: define check_mode
|
||||
ansible.builtin.set_fact:
|
||||
check_mode: '{{ _check_mode.check_mode }}'
|
||||
```
|
||||
|
||||
### `bodsch.core.deploy_and_activate`
|
||||
|
||||
```yaml
|
||||
- name: deploy and activate logstream_exporter version {{ logstream_exporter_version }}
|
||||
bodsch.core.deploy_and_activate:
|
||||
src_dir: "{{ logstream_exporter_local_tmp_directory }}"
|
||||
install_dir: "{{ logstream_exporter_install_path }}"
|
||||
link_dir: "/usr/bin"
|
||||
remote_src: false # "{{ 'true' if logstream_exporter_direct_download else 'false' }}"
|
||||
owner: "{{ logstream_exporter_system_user }}"
|
||||
group: "{{ logstream_exporter_system_group }}"
|
||||
mode: "0755"
|
||||
items:
|
||||
- name: "{{ logstream_exporter_release.binary }}"
|
||||
capability: "cap_net_raw+ep"
|
||||
notify:
|
||||
- restart logstream exporter
|
||||
```
|
||||
|
||||
### `bodsch.core.easyrsa`
|
||||
|
||||
```yaml
|
||||
- name: initialize easy-rsa - (this is going to take a long time)
|
||||
bodsch.core.easyrsa:
|
||||
pki_dir: '{{ openvpn_easyrsa.directory }}/pki'
|
||||
req_cn_ca: "{{ openvpn_certificate.req_cn_ca }}"
|
||||
req_cn_server: '{{ openvpn_certificate.req_cn_server }}'
|
||||
ca_keysize: 4096
|
||||
dh_keysize: "{{ openvpn_diffie_hellman_keysize }}"
|
||||
working_dir: '{{ openvpn_easyrsa.directory }}'
|
||||
force: true
|
||||
register: _easyrsa_result
|
||||
```
|
||||
|
||||
### `bodsch.core.facts`
|
||||
|
||||
```yaml
|
||||
- name: create custom facts
|
||||
bodsch.core.facts:
|
||||
state: present
|
||||
name: icinga2
|
||||
facts:
|
||||
version: "2.10"
|
||||
salt: fgmklsdfnjyxnvjksdfbkuser
|
||||
user: icinga2
|
||||
```
|
||||
|
||||
### `bodsch.core.openvpn_client_certificate`
|
||||
|
||||
```yaml
|
||||
- name: create or revoke client certificate
|
||||
bodsch.core.openvpn_client_certificate:
|
||||
clients:
|
||||
- name: molecule
|
||||
state: present
|
||||
roadrunner: false
|
||||
static_ip: 10.8.3.100
|
||||
remote: server
|
||||
port: 1194
|
||||
proto: udp
|
||||
device: tun
|
||||
ping: 20
|
||||
ping_restart: 45
|
||||
cert: molecule.crt
|
||||
key: molecule.key
|
||||
tls_auth:
|
||||
enabled: true
|
||||
- name: roadrunner_one
|
||||
state: present
|
||||
roadrunner: true
|
||||
static_ip: 10.8.3.10
|
||||
remote: server
|
||||
port: 1194
|
||||
proto: udp
|
||||
device: tun
|
||||
ping: 20
|
||||
ping_restart: 45
|
||||
cert: roadrunner_one.crt
|
||||
key: roadrunner_one.key
|
||||
tls_auth:
|
||||
enabled: true
|
||||
working_dir: /etc/easy-rsa
|
||||
```
|
||||
|
||||
### `bodsch.core.openvpn_crl`
|
||||
|
||||
```yaml
|
||||
- name: Check CRL status and include revoked certificates
|
||||
bodsch.core.openvpn_crl:
|
||||
state: status
|
||||
pki_dir: /etc/easy-rsa/pki
|
||||
list_revoked_certificates: true
|
||||
|
||||
- name: Warn if CRL expires within 14 days
|
||||
bodsch.core.openvpn_crl:
|
||||
state: status
|
||||
pki_dir: /etc/easy-rsa/pki
|
||||
warn_for_expire: true
|
||||
expire_in_days: 14
|
||||
register: crl_status
|
||||
|
||||
- name: Regenerate (renew) CRL using Easy-RSA
|
||||
bodsch.core.openvpn_crl:
|
||||
state: renew
|
||||
pki_dir: /etc/easy-rsa/pki
|
||||
working_dir: /etc/easy-rsa
|
||||
register: crl_renew
|
||||
```
|
||||
|
||||
### `bodsch.core.openvpn_ovpn`
|
||||
|
||||
```yaml
|
||||
- name: Force recreation of an existing .ovpn file
|
||||
bodsch.core.openvpn_ovpn:
|
||||
state: present
|
||||
username: carol
|
||||
destination_directory: /etc/openvpn/clients
|
||||
force: true
|
||||
```
|
||||
|
||||
### `bodsch.core.openvpn_version`
|
||||
|
||||
```yaml
|
||||
- name: Print parsed version
|
||||
ansible.builtin.debug:
|
||||
msg: "OpenVPN version: {{ openvpn.version }}"
|
||||
```
|
||||
|
||||
### `bodsch.core.openvpn`
|
||||
|
||||
```yaml
|
||||
- name: Generate tls-auth key (ta.key)
|
||||
bodsch.core.openvpn:
|
||||
state: genkey
|
||||
secret: /etc/openvpn/ta.key
|
||||
|
||||
- name: Generate tls-auth key only if marker does not exist
|
||||
bodsch.core.openvpn:
|
||||
state: genkey
|
||||
secret: /etc/openvpn/ta.key
|
||||
creates: /var/lib/openvpn/ta.key.created
|
||||
|
||||
- name: Force regeneration by removing marker first
|
||||
bodsch.core.openvpn:
|
||||
state: genkey
|
||||
secret: /etc/openvpn/ta.key
|
||||
creates: /var/lib/openvpn/ta.key.created
|
||||
force: true
|
||||
|
||||
- name: Create Easy-RSA client and write inline .ovpn
|
||||
bodsch.core.openvpn:
|
||||
state: create_user
|
||||
secret: /dev/null # required by module interface, not used here
|
||||
username: alice
|
||||
destination_directory: /etc/openvpn/clients
|
||||
chdir: /etc/easy-rsa
|
||||
|
||||
- name: Create user only if marker does not exist
|
||||
bodsch.core.openvpn:
|
||||
state: create_user
|
||||
secret: /dev/null
|
||||
username: bob
|
||||
destination_directory: /etc/openvpn/clients
|
||||
chdir: /etc/easy-rsa
|
||||
creates: /var/lib/openvpn/clients/bob.created
|
||||
```
|
||||
|
||||
### `bodsch.core.package_version`
|
||||
|
||||
```yaml
|
||||
- name: get version of available package
|
||||
bodsch.core.package_version:
|
||||
package_name: nano
|
||||
register: package_version
|
||||
```
|
||||
|
||||
### `bodsch.core.pip_requirements`
|
||||
|
||||
```yaml
|
||||
- name: create pip requirements file
|
||||
bodsch.core.pip_requirements:
|
||||
name: docker
|
||||
state: present
|
||||
requirements:
|
||||
- name: docker
|
||||
compare_direction: "=="
|
||||
version: 6.0.0
|
||||
|
||||
- name: setuptools
|
||||
version: 39.1.0
|
||||
|
||||
- name: requests
|
||||
versions:
|
||||
- ">= 2.28.0"
|
||||
- "< 2.30.0"
|
||||
- "!~ 1.1.0"
|
||||
register: pip_requirements
|
||||
```
|
||||
|
||||
### `bodsch.core.remove_ansible_backups`
|
||||
|
||||
```yaml
|
||||
---
|
||||
- name: remove older ansible backup files
|
||||
bodsch.core.remove_ansible_backups:
|
||||
path: /etc
|
||||
holds: 4
|
||||
```
|
||||
|
||||
### `bodsch.core.sync_directory`
|
||||
|
||||
```yaml
|
||||
- name: syncronize config for first run
|
||||
bodsch.core.sync_directory:
|
||||
source_directory: "{{ nextcloud_install_base_directory }}/nextcloud/{{ nextcloud_version }}/config_DIST"
|
||||
destination_directory: "{{ nextcloud_install_base_directory }}/nextcloud/config"
|
||||
arguments:
|
||||
verbose: true
|
||||
purge: false
|
||||
```
|
||||
|
||||
### `bodsch.core.syslog_cmd`
|
||||
|
||||
```yaml
|
||||
- name: detect config version
|
||||
bodsch.core.syslog_cmd:
|
||||
parameters:
|
||||
- --version
|
||||
when:
|
||||
- not running_in_check_mode
|
||||
register: _syslog_config_version
|
||||
|
||||
- name: validate syslog-ng config
|
||||
bodsch.core.syslog_cmd:
|
||||
parameters:
|
||||
- --syntax-only
|
||||
check_mode: true
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
## Contribution
|
||||
|
||||
Please read [Contribution](CONTRIBUTING.md)
|
||||
|
||||
## Development, Branches (Git Tags)
|
||||
|
||||
The `master` Branch is my *Working Horse* includes the "latest, hot shit" and can be complete broken!
|
||||
|
||||
If you want to use something stable, please use a [Tagged Version](https://github.com/bodsch/ansible-collection-core/tags)!
|
||||
|
||||
|
||||
## Author
|
||||
|
||||
- Bodo Schulz
|
||||
|
||||
## License
|
||||
|
||||
[Apache](LICENSE)
|
||||
|
||||
**FREE SOFTWARE, HELL YEAH!**
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
|
||||
requires_ansible: '>=2.12'
|
||||
|
||||
platforms:
|
||||
- name: ArchLinux
|
||||
- name: Debian
|
||||
versions:
|
||||
- bullseye
|
||||
- bookworm
|
||||
- trixie
|
||||
- name: Ubuntu
|
||||
versions:
|
||||
# 20.04
|
||||
- focal
|
||||
# 22.04
|
||||
- jammy
|
||||
# 24.04
|
||||
- noble
|
||||
# 26.04
|
||||
# - resolute
|
||||
|
||||
python_versions:
|
||||
- "3.10"
|
||||
- "3.11"
|
||||
- "3.12"
|
||||
- "3.13"
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
# Collections Plugins Directory
|
||||
|
||||
## modules
|
||||
|
||||
### remove_ansible_backups
|
||||
|
||||
```shell
|
||||
ansible-doc --type module bodsch.core.remove_ansible_backups
|
||||
> BODSCH.CORE.REMOVE_ANSIBLE_BACKUPS (./collections/ansible_collections/bodsch/core/plugins/modules/remove_ansible_backups.py)
|
||||
|
||||
Remove older backup files created by ansible
|
||||
```
|
||||
|
||||
### package_version
|
||||
|
||||
```shell
|
||||
ansible-doc --type module bodsch.core.package_version
|
||||
> BODSCH.CORE.PACKAGE_VERSION (./collections/ansible_collections/bodsch/core/plugins/modules/package_version.py)
|
||||
|
||||
Attempts to determine the version of a package to be installed or already installed. Supports apt, pacman, dnf (or yum) as
|
||||
package manager.
|
||||
```
|
||||
|
||||
### aur
|
||||
|
||||
```shell
|
||||
ansible-doc --type module bodsch.core.aur
|
||||
> BODSCH.CORE.AUR (./collections/ansible_collections/bodsch/core/plugins/modules/aur.py)
|
||||
|
||||
This modules manages packages for ArchLinux on a target with aur (like [ansible.builtin.yum], [ansible.builtin.apt], ...).
|
||||
```
|
||||
|
||||
### journalctl
|
||||
|
||||
```shell
|
||||
> BODSCH.CORE.JOURNALCTL (./collections/ansible_collections/bodsch/core/plugins/modules/journalctl.py)
|
||||
|
||||
Query the systemd journal with a very limited number of possible parameters. In certain cases there are errors that are not
|
||||
clearly traceable but are logged in the journal. This module is intended to be a tool for error analysis.
|
||||
```
|
||||
|
||||
### facts
|
||||
|
||||
```shell
|
||||
|
||||
> BODSCH.CORE.FACTS (./collections/ansible_collections/bodsch/core/plugins/modules/facts.py)
|
||||
|
||||
Write Ansible Facts
|
||||
```
|
||||
|
||||
## module_utils
|
||||
|
||||
### `checksum`
|
||||
|
||||
```python
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.checksum import Checksum
|
||||
|
||||
c = Checksum()
|
||||
|
||||
print(c.checksum("fooo"))
|
||||
print(c.checksum_from_file("/etc/fstab"))
|
||||
|
||||
# ???
|
||||
c.compare("aaa", "bbb")
|
||||
c.save("test-check", "aaa")
|
||||
c.load("test-check")
|
||||
```
|
||||
|
||||
### `file`
|
||||
|
||||
```python
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.file import remove_file, create_link
|
||||
```
|
||||
|
||||
- `create_link(source, destination, force=False)`
|
||||
- `remove_file(file_name)`
|
||||
|
||||
### `directory`
|
||||
|
||||
```python
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.directory import create_directory
|
||||
```
|
||||
|
||||
- `create_directory(directory)`
|
||||
- `permstr_to_octal(modestr, umask)`
|
||||
- `current_state(directory)`
|
||||
- `fix_ownership(directory, force_owner=None, force_group=None, force_mode=False)`
|
||||
|
||||
|
||||
### `cache`
|
||||
|
||||
```python
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.cache.cache_valid import cache_valid
|
||||
```
|
||||
|
||||
- `cache_valid(module, cache_file_name, cache_minutes=60, cache_file_remove=True)`
|
||||
|
||||
### `template`
|
||||
|
||||
## lookup
|
||||
|
||||
### `file_glob`
|
||||
|
||||
## filter
|
||||
|
||||
### `types`
|
||||
|
||||
- `type()`
|
||||
- `config_bool(data, true_as="yes", false_as="no")`
|
||||
|
||||
### `verify`
|
||||
|
||||
- `compare_list(data_list, compare_to_list)`
|
||||
- `upgrade(install_path, bin_path)`
|
||||
|
||||
### `dns`
|
||||
|
||||
- `dns_lookup(timeout=3, extern_resolver=[])`
|
||||
|
||||
### `python`
|
||||
|
||||
- `python_extra_args(python_version=ansible_python.version, extra_args=[], break_system_packages=True)`
|
||||
|
||||
### `union_by`
|
||||
|
||||
- `union(docker_defaults_python_packages, union_by='name')`
|
||||
|
||||
### - `parse_checksum`
|
||||
|
||||
- `parse_checksum('nginx-prometheus-exporter', ansible_facts.system, system_architecture)`
|
||||
|
||||
## misc
|
||||
|
||||
This directory can be used to ship various plugins inside an Ansible collection. Each plugin is placed in a folder that
|
||||
is named after the type of plugin it is in. It can also include the `module_utils` and `modules` directory that
|
||||
would contain module utils and modules respectively.
|
||||
|
||||
Here is an example directory of the majority of plugins currently supported by Ansible:
|
||||
|
||||
```
|
||||
└── plugins
|
||||
├── action
|
||||
├── become
|
||||
├── cache
|
||||
├── callback
|
||||
├── cliconf
|
||||
├── connection
|
||||
├── filter
|
||||
├── httpapi
|
||||
├── inventory
|
||||
├── lookup
|
||||
├── module_utils
|
||||
├── modules
|
||||
├── netconf
|
||||
├── shell
|
||||
├── strategy
|
||||
├── terminal
|
||||
├── test
|
||||
└── vars
|
||||
```
|
||||
|
||||
A full list of plugin types can be found at [Working With Plugins](https://docs.ansible.com/ansible-core/2.14/plugins/plugins.html).
|
||||
|
|
@ -0,0 +1,430 @@
|
|||
"""
|
||||
binary_deploy.py (action plugin)
|
||||
|
||||
Controller-aware wrapper that supports:
|
||||
- remote_src=true: src_dir is on the remote host -> use activate_version_remote to do everything in one remote call.
|
||||
- remote_src=false: src_dir is on the controller -> verify local files, create install_dir remotely, transfer files,
|
||||
then let activate_version_remote enforce caps and symlinks.
|
||||
|
||||
This collapses the common "stat + fail + stat + stat + copy + file + capabilities + link" pattern into one task.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ansible.errors import AnsibleError
|
||||
from ansible.plugins.action import ActionBase
|
||||
from ansible.utils.display import Display
|
||||
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class ActionModule(ActionBase):
|
||||
"""Deploy binaries to install_dir and activate them via symlinks."""
|
||||
|
||||
TRANSFERS_FILES = True
|
||||
|
||||
def _get_items(self, args: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
""" """
|
||||
display.v(f"ActionModule::_get_items(args: {args})")
|
||||
|
||||
items = args.get("items") or []
|
||||
if not isinstance(items, list) or not items:
|
||||
raise AnsibleError("binary_deploy: 'items' must be a non-empty list")
|
||||
return items
|
||||
|
||||
def _local_item_path(self, src_dir: str, item: Dict[str, Any]) -> Tuple[str, str]:
|
||||
"""
|
||||
Returns (local_src_path, dest_filename).
|
||||
dest_filename is always item['name'].
|
||||
local source filename is item.get('src') or item['name'].
|
||||
"""
|
||||
display.v(f"ActionModule::_local_item_path(src_dir: {src_dir}, item: {item})")
|
||||
|
||||
name = str(item["name"])
|
||||
src_name = str(item.get("src") or name)
|
||||
return os.path.join(src_dir, src_name), name
|
||||
|
||||
def _ensure_local_files_exist(self, src_dir: str, items: List[Dict[str, Any]]) -> None:
|
||||
""" """
|
||||
display.v(f"ActionModule::_ensure_local_files_exist(src_dir: {src_dir}, items: {items})")
|
||||
|
||||
for it in items:
|
||||
local_src, _ = self._local_item_path(src_dir, it)
|
||||
if not os.path.isfile(local_src):
|
||||
raise AnsibleError(f"binary_deploy: missing extracted binary on controller: {local_src}")
|
||||
|
||||
def _probe_remote(
|
||||
self,
|
||||
*,
|
||||
tmp: Optional[str],
|
||||
task_vars: Dict[str, Any],
|
||||
module_args: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
""" """
|
||||
display.v(f"ActionModule::_probe_remote(tmp: {tmp}, task_vars, module_args: {module_args})")
|
||||
|
||||
return self._execute_module(
|
||||
module_name="bodsch.core.activate_version_remote",
|
||||
module_args=module_args,
|
||||
task_vars=task_vars,
|
||||
tmp=tmp,
|
||||
)
|
||||
|
||||
def _remote_copy_from_controller(
|
||||
self,
|
||||
*,
|
||||
tmp: Optional[str],
|
||||
task_vars: Dict[str, Any],
|
||||
src_dir: str,
|
||||
install_dir: str,
|
||||
items: List[Dict[str, Any]],
|
||||
mode: str,
|
||||
owner: Optional[str],
|
||||
group: Optional[str],
|
||||
) -> bool:
|
||||
"""
|
||||
Transfer controller-local binaries to remote install_dir using ansible.builtin.copy.
|
||||
Returns True if any file changed.
|
||||
"""
|
||||
display.v(f"ActionModule::_remote_copy_from_controller(tmp: {tmp}, task_vars, src_dir: {src_dir}, install_dir: {install_dir}, items: {items}, owner: {owner}, group: {group}, mode: {mode})")
|
||||
|
||||
changed_any = False
|
||||
|
||||
for it in items:
|
||||
local_src, dest_name = self._local_item_path(src_dir, it)
|
||||
dest_path = os.path.join(install_dir, dest_name)
|
||||
|
||||
module_args: Dict[str, Any] = {
|
||||
"src": local_src,
|
||||
"dest": dest_path,
|
||||
"remote_src": False,
|
||||
"mode": mode,
|
||||
}
|
||||
if owner:
|
||||
module_args["owner"] = owner
|
||||
if group:
|
||||
module_args["group"] = group
|
||||
|
||||
res = self._execute_module(
|
||||
module_name="ansible.builtin.copy",
|
||||
module_args=module_args,
|
||||
task_vars=task_vars,
|
||||
tmp=tmp,
|
||||
)
|
||||
changed_any = changed_any or bool(res.get("changed", False))
|
||||
|
||||
return changed_any
|
||||
|
||||
def run(self, tmp: str | None = None, task_vars: Dict[str, Any] | None = None) -> Dict[str, Any]:
|
||||
""" """
|
||||
display.v(f"ActionModule::run(tmp: {tmp}, task_vars)")
|
||||
|
||||
if task_vars is None:
|
||||
task_vars = {}
|
||||
|
||||
result: Dict[str, Any] = super().run(tmp, task_vars)
|
||||
args = self._task.args.copy()
|
||||
|
||||
remote_src = bool(args.get("remote_src", False))
|
||||
install_dir = str(args["install_dir"])
|
||||
link_dir = str(args.get("link_dir", "/usr/bin"))
|
||||
src_dir = args.get("src_dir")
|
||||
mode = str(args.get("mode", "0755"))
|
||||
owner = args.get("owner")
|
||||
group = args.get("group")
|
||||
cleanup_on_failure = bool(args.get("cleanup_on_failure", True))
|
||||
activation_name = args.get("activation_name")
|
||||
|
||||
items = self._get_items(args)
|
||||
|
||||
display.v(f" - remote_src : {remote_src}")
|
||||
display.v(f" - install_dir : {install_dir}")
|
||||
display.v(f" - src_dir : {src_dir}")
|
||||
display.v(f" - link_dir : {link_dir}")
|
||||
display.v(f" - owner : {owner}")
|
||||
display.v(f" - group : {group}")
|
||||
display.v(f" - cleanup_on_failure : {cleanup_on_failure}")
|
||||
display.v(f" - activation_name : {activation_name}")
|
||||
|
||||
# --- Probe ---
|
||||
probe_args: Dict[str, Any] = {
|
||||
"install_dir": install_dir,
|
||||
"link_dir": link_dir,
|
||||
"items": items,
|
||||
"activation_name": activation_name,
|
||||
"owner": owner,
|
||||
"group": group,
|
||||
"mode": mode,
|
||||
"cleanup_on_failure": cleanup_on_failure,
|
||||
"check_only": True,
|
||||
"copy": remote_src,
|
||||
}
|
||||
|
||||
display.v(f" - probe_args : {probe_args}")
|
||||
|
||||
# IMPORTANT: when remote_src=True (copy=True), src_dir must be passed and must be remote path
|
||||
if remote_src:
|
||||
if not src_dir:
|
||||
raise AnsibleError("binary_deploy: 'src_dir' is required when remote_src=true (remote path)")
|
||||
probe_args["src_dir"] = str(src_dir)
|
||||
|
||||
probe = self._probe_remote(tmp=tmp, task_vars=task_vars, module_args=probe_args)
|
||||
|
||||
display.v(f" - probe : {probe}")
|
||||
|
||||
# Check mode: never change
|
||||
if bool(task_vars.get("ansible_check_mode", False)):
|
||||
probe["changed"] = False
|
||||
return probe
|
||||
|
||||
if not probe.get("needs_update", False):
|
||||
probe["changed"] = False
|
||||
return probe
|
||||
|
||||
# --- Apply ---
|
||||
try:
|
||||
# Ensure install_dir exists on remote
|
||||
self._execute_module(
|
||||
module_name="ansible.builtin.file",
|
||||
module_args={"path": install_dir, "state": "directory"},
|
||||
task_vars=task_vars,
|
||||
tmp=tmp,
|
||||
)
|
||||
|
||||
if remote_src:
|
||||
# Remote -> Remote copy + perms/caps/links in one remote call
|
||||
apply_args = dict(probe_args)
|
||||
apply_args["check_only"] = False
|
||||
apply_args["copy"] = True
|
||||
apply_args["src_dir"] = str(src_dir)
|
||||
|
||||
return self._probe_remote(tmp=tmp, task_vars=task_vars, module_args=apply_args)
|
||||
|
||||
# Controller -> Remote transfer
|
||||
if not src_dir:
|
||||
raise AnsibleError("binary_deploy: 'src_dir' is required when remote_src=false (controller path)")
|
||||
src_dir = str(src_dir)
|
||||
|
||||
self._ensure_local_files_exist(src_dir, items)
|
||||
|
||||
copied_any = self._remote_copy_from_controller(
|
||||
tmp=tmp,
|
||||
task_vars=task_vars,
|
||||
src_dir=src_dir,
|
||||
install_dir=install_dir,
|
||||
items=items,
|
||||
mode=mode,
|
||||
owner=owner,
|
||||
group=group,
|
||||
)
|
||||
|
||||
# Enforce perms/caps/links in one remote call (no remote copy)
|
||||
apply_args = {
|
||||
"install_dir": install_dir,
|
||||
"link_dir": link_dir,
|
||||
"items": items,
|
||||
"activation_name": activation_name,
|
||||
"owner": owner,
|
||||
"group": group,
|
||||
"mode": mode,
|
||||
"cleanup_on_failure": cleanup_on_failure,
|
||||
"check_only": False,
|
||||
"copy": False,
|
||||
}
|
||||
applied = self._probe_remote(tmp=tmp, task_vars=task_vars, module_args=apply_args)
|
||||
applied["changed"] = bool(applied.get("changed", False)) or copied_any
|
||||
return applied
|
||||
|
||||
except Exception:
|
||||
if cleanup_on_failure:
|
||||
try:
|
||||
self._execute_module(
|
||||
module_name="ansible.builtin.file",
|
||||
module_args={"path": install_dir, "state": "absent"},
|
||||
task_vars=task_vars,
|
||||
tmp=tmp,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
# -- FRIEDHOF ---
|
||||
|
||||
def run_OLD(
|
||||
self, tmp: str | None = None, task_vars: Dict[str, Any] | None = None
|
||||
) -> Dict[str, Any]:
|
||||
""" """
|
||||
display.v(f"ActionModule::run(tmp: {tmp}, task_vars: {task_vars})")
|
||||
|
||||
if task_vars is None:
|
||||
task_vars = {}
|
||||
|
||||
result: Dict[str, Any] = super().run(tmp, task_vars)
|
||||
args = self._task.args.copy()
|
||||
|
||||
remote_src = bool(args.pop("remote_src", False))
|
||||
install_dir = str(args["install_dir"])
|
||||
items: List[Dict[str, Any]] = args.get("items") or []
|
||||
if not items:
|
||||
raise AnsibleError("binary_deploy: items must not be empty")
|
||||
|
||||
src_dir = args.get("src_dir")
|
||||
link_dir = args.get("link_dir", "/usr/bin")
|
||||
owner = args.get("owner")
|
||||
group = args.get("group")
|
||||
mode = args.get("mode", "0755")
|
||||
cleanup_on_failure = bool(args.get("cleanup_on_failure", True))
|
||||
activation_name = args.get("activation_name")
|
||||
|
||||
display.v(f" - remote_src : {remote_src}")
|
||||
display.v(f" - install_dir : {install_dir}")
|
||||
display.v(f" - src_dir : {src_dir}")
|
||||
display.v(f" - link_dir : {link_dir}")
|
||||
display.v(f" - owner : {owner}")
|
||||
display.v(f" - group : {group}")
|
||||
display.v(f" - cleanup_on_failure : {cleanup_on_failure}")
|
||||
display.v(f" - activation_name : {activation_name}")
|
||||
|
||||
# 1) Check-only probe (remote): decide whether we need to do anything.
|
||||
probe_args: Dict[str, Any] = {
|
||||
"install_dir": install_dir,
|
||||
"link_dir": link_dir,
|
||||
"items": items,
|
||||
"activation_name": activation_name,
|
||||
"owner": owner,
|
||||
"group": group,
|
||||
"mode": mode,
|
||||
"cleanup_on_failure": cleanup_on_failure,
|
||||
"check_only": True,
|
||||
"copy": remote_src,
|
||||
}
|
||||
|
||||
display.v(f" - probe_args : {probe_args}")
|
||||
|
||||
if remote_src:
|
||||
if not src_dir:
|
||||
raise AnsibleError(
|
||||
"binary_deploy: src_dir is required when remote_src=true"
|
||||
)
|
||||
probe_args["src_dir"] = src_dir
|
||||
|
||||
probe = self._execute_module(
|
||||
module_name="bodsch.core.activate_version_remote",
|
||||
module_args=probe_args,
|
||||
task_vars=task_vars,
|
||||
tmp=tmp,
|
||||
)
|
||||
|
||||
# In check mode: return probe result as-is (no changes).
|
||||
if bool(task_vars.get("ansible_check_mode", False)):
|
||||
probe["changed"] = False
|
||||
return probe
|
||||
|
||||
if not probe.get("needs_update", False):
|
||||
probe["changed"] = False
|
||||
return probe
|
||||
|
||||
# 2) Apply
|
||||
try:
|
||||
if remote_src:
|
||||
apply_args = dict(probe_args)
|
||||
apply_args["check_only"] = False
|
||||
apply_args["copy"] = True
|
||||
apply_args["src_dir"] = src_dir
|
||||
|
||||
applied = self._execute_module(
|
||||
module_name="bodsch.core.activate_version_remote",
|
||||
module_args=apply_args,
|
||||
task_vars=task_vars,
|
||||
tmp=tmp,
|
||||
)
|
||||
return applied
|
||||
|
||||
# Controller-local source: verify local files exist first.
|
||||
if not src_dir:
|
||||
raise AnsibleError(
|
||||
"binary_deploy: src_dir is required when remote_src=false"
|
||||
)
|
||||
|
||||
for it in items:
|
||||
name = str(it["name"])
|
||||
src_name = str(it.get("src") or name)
|
||||
local_path = os.path.join(src_dir, src_name)
|
||||
if not os.path.isfile(local_path):
|
||||
raise AnsibleError(
|
||||
f"binary_deploy: missing extracted binary on controller: {local_path}"
|
||||
)
|
||||
|
||||
# Ensure install_dir exists remotely
|
||||
dir_res = self._execute_module(
|
||||
module_name="ansible.builtin.file",
|
||||
module_args={"path": install_dir, "state": "directory"},
|
||||
task_vars=task_vars,
|
||||
tmp=tmp,
|
||||
)
|
||||
|
||||
# Transfer binaries controller -> remote
|
||||
copied_any = False
|
||||
for it in items:
|
||||
name = str(it["name"])
|
||||
src_name = str(it.get("src") or name)
|
||||
|
||||
copy_res = self._execute_module(
|
||||
module_name="ansible.builtin.copy",
|
||||
module_args={
|
||||
"src": os.path.join(src_dir, src_name),
|
||||
"dest": os.path.join(install_dir, name),
|
||||
"mode": mode,
|
||||
"owner": owner,
|
||||
"group": group,
|
||||
"remote_src": False,
|
||||
},
|
||||
task_vars=task_vars,
|
||||
tmp=tmp,
|
||||
)
|
||||
copied_any = copied_any or bool(copy_res.get("changed", False))
|
||||
|
||||
# Enforce caps + symlinks (no remote copy; files already in install_dir)
|
||||
apply_args = {
|
||||
"install_dir": install_dir,
|
||||
"link_dir": link_dir,
|
||||
"items": items,
|
||||
"activation_name": activation_name,
|
||||
"owner": owner,
|
||||
"group": group,
|
||||
"mode": mode,
|
||||
"cleanup_on_failure": cleanup_on_failure,
|
||||
"check_only": False,
|
||||
"copy": False,
|
||||
}
|
||||
applied = self._execute_module(
|
||||
module_name="bodsch.core.activate_version_remote",
|
||||
module_args=apply_args,
|
||||
task_vars=task_vars,
|
||||
tmp=tmp,
|
||||
)
|
||||
|
||||
applied["changed"] = (
|
||||
bool(applied.get("changed", False))
|
||||
or bool(dir_res.get("changed", False))
|
||||
or copied_any
|
||||
)
|
||||
return applied
|
||||
|
||||
except Exception as exc:
|
||||
if cleanup_on_failure:
|
||||
try:
|
||||
self._execute_module(
|
||||
module_name="ansible.builtin.file",
|
||||
module_args={"path": install_dir, "state": "absent"},
|
||||
task_vars=task_vars,
|
||||
tmp=tmp,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
|
@ -0,0 +1,513 @@
|
|||
"""
|
||||
deploy_and_activate.py (action plugin)
|
||||
|
||||
Controller-side orchestration for deploying versioned binaries and activating them via symlinks.
|
||||
|
||||
This action plugin wraps a remote worker module (bodsch.core.deploy_and_activate_remote)
|
||||
and provides two operational modes:
|
||||
|
||||
1) remote_src=False (controller-local source):
|
||||
- Validate that extracted binaries exist on the Ansible controller in src_dir.
|
||||
- Stage these files onto the remote host via ActionBase._transfer_file().
|
||||
- Invoke the remote worker module to copy into install_dir and enforce perms/caps/symlinks.
|
||||
|
||||
2) remote_src=True (remote-local source):
|
||||
- Assume binaries already exist on the remote host in src_dir.
|
||||
- Invoke the remote worker module to copy into install_dir and enforce perms/caps/symlinks.
|
||||
|
||||
Implementation note:
|
||||
- Do not call ansible.builtin.copy via _execute_module() to transfer controller-local files.
|
||||
That bypasses the copy action logic and will not perform controller->remote transfer reliably.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Mapping,
|
||||
Optional,
|
||||
Sequence,
|
||||
Set,
|
||||
Tuple,
|
||||
TypedDict,
|
||||
cast,
|
||||
)
|
||||
|
||||
from ansible.errors import AnsibleError
|
||||
from ansible.plugins.action import ActionBase
|
||||
from ansible.utils.display import Display
|
||||
|
||||
display = Display()
|
||||
|
||||
REMOTE_WORKER_MODULE = "bodsch.core.deploy_and_activate_remote"
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
---
|
||||
module: deploy_and_activate
|
||||
short_description: Deploy binaries into a versioned directory and activate them via symlinks (action plugin)
|
||||
description:
|
||||
- Controller-side action plugin that orchestrates a remote worker module.
|
||||
- Supports controller-local sources (C(remote_src=false)) via controller->remote staging.
|
||||
- Supports remote-local sources (C(remote_src=true)) where binaries already exist on the target host.
|
||||
options:
|
||||
install_dir:
|
||||
description:
|
||||
- Versioned installation directory on the target host.
|
||||
type: path
|
||||
required: true
|
||||
src_dir:
|
||||
description:
|
||||
- Directory containing extracted binaries.
|
||||
- For C(remote_src=false) this path is on the controller.
|
||||
- For C(remote_src=true) this path is on the target host.
|
||||
type: path
|
||||
required: true
|
||||
remote_src:
|
||||
description:
|
||||
- If true, C(src_dir) is on the remote host (remote->remote copy).
|
||||
- If false, C(src_dir) is on the controller (controller->remote staging).
|
||||
type: bool
|
||||
default: false
|
||||
link_dir:
|
||||
description:
|
||||
- Directory where activation symlinks are created on the target host.
|
||||
type: path
|
||||
default: /usr/bin
|
||||
items:
|
||||
description:
|
||||
- List of binaries to deploy.
|
||||
- Each item supports C(name) (required), optional C(src), optional C(link_name), optional C(capability).
|
||||
type: list
|
||||
elements: dict
|
||||
required: true
|
||||
activation_name:
|
||||
description:
|
||||
- Item name or link_name used to determine "activated" status (worker module feature).
|
||||
type: str
|
||||
required: false
|
||||
owner:
|
||||
description:
|
||||
- Owner name or uid for deployed binaries.
|
||||
type: str
|
||||
required: false
|
||||
group:
|
||||
description:
|
||||
- Group name or gid for deployed binaries.
|
||||
type: str
|
||||
required: false
|
||||
mode:
|
||||
description:
|
||||
- File mode for deployed binaries (octal string).
|
||||
type: str
|
||||
default: "0755"
|
||||
cleanup_on_failure:
|
||||
description:
|
||||
- Remove install_dir if an exception occurs during apply.
|
||||
type: bool
|
||||
default: true
|
||||
author:
|
||||
- "Bodsch Core Collection"
|
||||
notes:
|
||||
- This is an action plugin. It delegates actual deployment work to C(bodsch.core.deploy_and_activate_remote).
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
- name: Deploy from controller cache (remote_src=false)
|
||||
bodsch.core.deploy_and_activate:
|
||||
remote_src: false
|
||||
src_dir: "/home/bodsch/.cache/ansible/logstream_exporter/1.0.0"
|
||||
install_dir: "/usr/local/opt/logstream_exporter/1.0.0"
|
||||
link_dir: "/usr/bin"
|
||||
owner: "logstream-exporter"
|
||||
group: "logstream-exporter"
|
||||
mode: "0755"
|
||||
items:
|
||||
- name: "logstream-exporter"
|
||||
capability: "cap_net_raw+ep"
|
||||
|
||||
- name: Deploy from remote extracted directory (remote_src=true)
|
||||
bodsch.core.deploy_and_activate:
|
||||
remote_src: true
|
||||
src_dir: "/var/cache/ansible/logstream_exporter/1.0.0"
|
||||
install_dir: "/usr/local/opt/logstream_exporter/1.0.0"
|
||||
items:
|
||||
- name: "logstream-exporter"
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
changed:
|
||||
description: Whether anything changed (as reported by the remote worker module).
|
||||
type: bool
|
||||
activated:
|
||||
description: Whether the activation symlink points into install_dir (worker module result).
|
||||
type: bool
|
||||
needs_update:
|
||||
description: Whether changes would be required (in probe/check mode output).
|
||||
type: bool
|
||||
plan:
|
||||
description: Per-item plan (in probe/check mode output).
|
||||
type: dict
|
||||
details:
|
||||
description: Per-item change details (in apply output).
|
||||
type: dict
|
||||
"""
|
||||
|
||||
|
||||
class ItemSpec(TypedDict, total=False):
|
||||
"""User-facing item specification passed to the remote worker module."""
|
||||
|
||||
name: str
|
||||
src: str
|
||||
link_name: str
|
||||
capability: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _LocalItem:
|
||||
"""Normalized local item for controller-side existence checks and staging."""
|
||||
|
||||
name: str
|
||||
src_rel: str
|
||||
local_src: str
|
||||
|
||||
|
||||
class ActionModule(ActionBase):
|
||||
"""Deploy binaries to install_dir and activate them via symlinks."""
|
||||
|
||||
TRANSFERS_FILES = True
|
||||
|
||||
def _get_items(self, args: Mapping[str, Any]) -> List[ItemSpec]:
|
||||
"""Validate and normalize the 'items' argument."""
|
||||
display.vv(f"ActionModule::_get_items(args: {dict(args)})")
|
||||
|
||||
raw_items = args.get("items") or []
|
||||
if not isinstance(raw_items, list) or not raw_items:
|
||||
raise AnsibleError("deploy_and_activate: 'items' must be a non-empty list")
|
||||
|
||||
out: List[ItemSpec] = []
|
||||
for idx, it in enumerate(raw_items):
|
||||
if not isinstance(it, dict):
|
||||
raise AnsibleError(f"deploy_and_activate: items[{idx}] must be a dict")
|
||||
if "name" not in it:
|
||||
raise AnsibleError(
|
||||
f"deploy_and_activate: items[{idx}] missing required key 'name'"
|
||||
)
|
||||
|
||||
name = str(it["name"]).strip()
|
||||
if not name:
|
||||
raise AnsibleError(
|
||||
f"deploy_and_activate: items[{idx}].name must not be empty"
|
||||
)
|
||||
|
||||
normalized: ItemSpec = cast(ItemSpec, dict(it))
|
||||
normalized["name"] = name
|
||||
out.append(normalized)
|
||||
|
||||
return out
|
||||
|
||||
def _normalize_local_items(
|
||||
self, controller_src_dir: str, items: Sequence[ItemSpec]
|
||||
) -> List[_LocalItem]:
|
||||
"""Build controller-local absolute paths for each item."""
|
||||
display.vv(
|
||||
f"ActionModule::_normalize_local_items(controller_src_dir: {controller_src_dir}, items: {list(items)})"
|
||||
)
|
||||
|
||||
out: List[_LocalItem] = []
|
||||
for it in items:
|
||||
name = str(it["name"])
|
||||
src_rel = str(it.get("src") or name)
|
||||
local_src = os.path.join(controller_src_dir, src_rel)
|
||||
out.append(_LocalItem(name=name, src_rel=src_rel, local_src=local_src))
|
||||
return out
|
||||
|
||||
def _ensure_local_files_exist(
|
||||
self, controller_src_dir: str, items: Sequence[ItemSpec]
|
||||
) -> None:
|
||||
"""Fail early if any controller-local binary is missing."""
|
||||
display.vv(
|
||||
f"ActionModule::_ensure_local_files_exist(controller_src_dir: {controller_src_dir}, items: {list(items)})"
|
||||
)
|
||||
|
||||
for it in self._normalize_local_items(controller_src_dir, items):
|
||||
display.vv(f"= local_src: {it.local_src}, src_rel: {it.src_rel}")
|
||||
if not os.path.isfile(it.local_src):
|
||||
raise AnsibleError(
|
||||
f"deploy_and_activate: missing extracted binary on controller: {it.local_src}"
|
||||
)
|
||||
|
||||
def _probe_remote(
|
||||
self,
|
||||
*,
|
||||
tmp: Optional[str],
|
||||
task_vars: Mapping[str, Any],
|
||||
module_args: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute the remote worker module and return its result."""
|
||||
display.vv(
|
||||
f"ActionModule::_probe_remote(tmp: {tmp}, task_vars, module_args: {module_args})"
|
||||
)
|
||||
|
||||
remote = self._execute_module(
|
||||
module_name=REMOTE_WORKER_MODULE,
|
||||
module_args=module_args,
|
||||
task_vars=dict(task_vars),
|
||||
tmp=tmp,
|
||||
)
|
||||
display.vv(f"= result: {remote}")
|
||||
return remote
|
||||
|
||||
def _ensure_remote_dir(
|
||||
self,
|
||||
*,
|
||||
tmp: Optional[str],
|
||||
task_vars: Mapping[str, Any],
|
||||
path: str,
|
||||
mode: str = "0700",
|
||||
) -> None:
|
||||
"""Ensure a directory exists on the remote host."""
|
||||
display.vv(
|
||||
f"ActionModule::_ensure_remote_dir(tmp: {tmp}, task_vars, path: {path}, mode: {mode})"
|
||||
)
|
||||
|
||||
self._execute_module(
|
||||
module_name="ansible.builtin.file",
|
||||
module_args={"path": path, "state": "directory", "mode": mode},
|
||||
task_vars=dict(task_vars),
|
||||
tmp=tmp,
|
||||
)
|
||||
|
||||
def _create_remote_temp_dir(
|
||||
self, *, tmp: Optional[str], task_vars: Mapping[str, Any]
|
||||
) -> str:
|
||||
"""
|
||||
Create a remote temporary directory.
|
||||
|
||||
This avoids using ActionBase._make_tmp_path(), which is not available in all Ansible versions.
|
||||
"""
|
||||
display.vv(f"ActionModule::_create_remote_temp_dir(tmp: {tmp}, task_vars)")
|
||||
|
||||
res = self._execute_module(
|
||||
module_name="ansible.builtin.tempfile",
|
||||
module_args={"state": "directory", "prefix": "deploy-and-activate-"},
|
||||
task_vars=dict(task_vars),
|
||||
tmp=tmp,
|
||||
)
|
||||
path = res.get("path")
|
||||
if not path:
|
||||
raise AnsibleError(
|
||||
"deploy_and_activate: failed to create remote temporary directory"
|
||||
)
|
||||
return str(path)
|
||||
|
||||
def _stage_files_to_remote(
|
||||
self,
|
||||
*,
|
||||
tmp: Optional[str],
|
||||
task_vars: Mapping[str, Any],
|
||||
controller_src_dir: str,
|
||||
items: Sequence[ItemSpec],
|
||||
) -> Tuple[str, bool]:
|
||||
"""
|
||||
Stage controller-local files onto the remote host via ActionBase._transfer_file().
|
||||
|
||||
Returns:
|
||||
Tuple(remote_stage_dir, created_by_us)
|
||||
"""
|
||||
normalized = self._normalize_local_items(controller_src_dir, items)
|
||||
|
||||
if tmp:
|
||||
remote_stage_dir = tmp
|
||||
created_by_us = False
|
||||
else:
|
||||
remote_stage_dir = self._create_remote_temp_dir(
|
||||
tmp=tmp, task_vars=task_vars
|
||||
)
|
||||
created_by_us = True
|
||||
|
||||
display.vv(
|
||||
f"ActionModule::_stage_files_to_remote(remote_stage_dir: {remote_stage_dir}, created_by_us: {created_by_us})"
|
||||
)
|
||||
|
||||
self._ensure_remote_dir(
|
||||
tmp=tmp, task_vars=task_vars, path=remote_stage_dir, mode="0700"
|
||||
)
|
||||
|
||||
# Create required subdirectories on remote if src_rel contains paths.
|
||||
needed_dirs: Set[str] = set()
|
||||
for it in normalized:
|
||||
rel_dir = os.path.dirname(it.src_rel)
|
||||
if rel_dir and rel_dir not in (".", "/"):
|
||||
needed_dirs.add(os.path.join(remote_stage_dir, rel_dir))
|
||||
|
||||
for d in sorted(needed_dirs):
|
||||
self._ensure_remote_dir(tmp=tmp, task_vars=task_vars, path=d, mode="0700")
|
||||
|
||||
# Transfer files.
|
||||
for it in normalized:
|
||||
remote_dst = os.path.join(remote_stage_dir, it.src_rel)
|
||||
display.vv(f"ActionModule::_transfer_file({it.local_src} -> {remote_dst})")
|
||||
self._transfer_file(it.local_src, remote_dst)
|
||||
|
||||
return remote_stage_dir, created_by_us
|
||||
|
||||
def run(
|
||||
self, tmp: str | None = None, task_vars: Dict[str, Any] | None = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Action plugin entrypoint.
|
||||
|
||||
Args:
|
||||
tmp: Remote tmp directory (may be None depending on Ansible execution path).
|
||||
task_vars: Task variables.
|
||||
|
||||
Returns:
|
||||
Result dict compatible with Ansible task output.
|
||||
"""
|
||||
display.vv(f"ActionModule::run(tmp: {tmp}, task_vars)")
|
||||
|
||||
if task_vars is None:
|
||||
task_vars = {}
|
||||
|
||||
display.vv(f" - task_vars : {task_vars}")
|
||||
|
||||
_ = super().run(tmp, task_vars)
|
||||
args: Dict[str, Any] = self._task.args.copy()
|
||||
|
||||
remote_src = bool(args.get("remote_src", False))
|
||||
install_dir = str(args["install_dir"])
|
||||
link_dir = str(args.get("link_dir", "/usr/bin"))
|
||||
src_dir = args.get("src_dir")
|
||||
mode = str(args.get("mode", "0755"))
|
||||
owner = args.get("owner")
|
||||
group = args.get("group")
|
||||
cleanup_on_failure = bool(args.get("cleanup_on_failure", True))
|
||||
activation_name = args.get("activation_name")
|
||||
|
||||
items = self._get_items(args)
|
||||
|
||||
display.vv(f" - args : {args}")
|
||||
|
||||
display.vv(f" - remote_src : {remote_src}")
|
||||
display.vv(f" - install_dir : {install_dir}")
|
||||
display.vv(f" - src_dir : {src_dir}")
|
||||
display.vv(f" - link_dir : {link_dir}")
|
||||
display.vv(f" - owner : {owner}")
|
||||
display.vv(f" - group : {group}")
|
||||
display.vv(f" - cleanup_on_failure : {cleanup_on_failure}")
|
||||
display.vv(f" - activation_name : {activation_name}")
|
||||
|
||||
# --- Probe (remote) ---
|
||||
probe_args: Dict[str, Any] = {
|
||||
"install_dir": install_dir,
|
||||
"link_dir": link_dir,
|
||||
"items": list(items),
|
||||
"activation_name": activation_name,
|
||||
"owner": owner,
|
||||
"group": group,
|
||||
"mode": mode,
|
||||
"cleanup_on_failure": cleanup_on_failure,
|
||||
"check_only": True,
|
||||
"copy": remote_src,
|
||||
}
|
||||
|
||||
if remote_src:
|
||||
if not src_dir:
|
||||
raise AnsibleError(
|
||||
"deploy_and_activate: 'src_dir' is required when remote_src=true (remote path)"
|
||||
)
|
||||
probe_args["src_dir"] = str(src_dir)
|
||||
|
||||
display.vv(f" - probe_args : {probe_args}")
|
||||
probe = self._probe_remote(tmp=tmp, task_vars=task_vars, module_args=probe_args)
|
||||
|
||||
# Check mode: never change.
|
||||
if bool(task_vars.get("ansible_check_mode", False)):
|
||||
probe["changed"] = False
|
||||
return probe
|
||||
|
||||
# Early exit if nothing to do.
|
||||
if not probe.get("needs_update", False):
|
||||
probe["changed"] = False
|
||||
return probe
|
||||
|
||||
# --- Apply ---
|
||||
stage_dir: Optional[str] = None
|
||||
stage_created_by_us = False
|
||||
|
||||
try:
|
||||
self._ensure_remote_dir(
|
||||
tmp=tmp, task_vars=task_vars, path=install_dir, mode="0755"
|
||||
)
|
||||
|
||||
if remote_src:
|
||||
apply_args = dict(probe_args)
|
||||
apply_args["check_only"] = False
|
||||
apply_args["copy"] = True
|
||||
apply_args["src_dir"] = str(src_dir)
|
||||
return self._probe_remote(
|
||||
tmp=tmp, task_vars=task_vars, module_args=apply_args
|
||||
)
|
||||
|
||||
# Controller -> Remote staging -> Remote apply(copy=True)
|
||||
if not src_dir:
|
||||
raise AnsibleError(
|
||||
"deploy_and_activate: 'src_dir' is required when remote_src=false (controller path)"
|
||||
)
|
||||
|
||||
controller_src_dir = str(src_dir)
|
||||
self._ensure_local_files_exist(controller_src_dir, items)
|
||||
|
||||
stage_dir, stage_created_by_us = self._stage_files_to_remote(
|
||||
tmp=tmp,
|
||||
task_vars=task_vars,
|
||||
controller_src_dir=controller_src_dir,
|
||||
items=items,
|
||||
)
|
||||
|
||||
apply_args = {
|
||||
"install_dir": install_dir,
|
||||
"link_dir": link_dir,
|
||||
"items": list(items),
|
||||
"activation_name": activation_name,
|
||||
"owner": owner,
|
||||
"group": group,
|
||||
"mode": mode,
|
||||
"cleanup_on_failure": cleanup_on_failure,
|
||||
"check_only": False,
|
||||
"copy": True,
|
||||
"src_dir": stage_dir,
|
||||
}
|
||||
return self._probe_remote(
|
||||
tmp=tmp, task_vars=task_vars, module_args=apply_args
|
||||
)
|
||||
|
||||
except Exception:
|
||||
if cleanup_on_failure:
|
||||
try:
|
||||
self._execute_module(
|
||||
module_name="ansible.builtin.file",
|
||||
module_args={"path": install_dir, "state": "absent"},
|
||||
task_vars=dict(task_vars),
|
||||
tmp=tmp,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
finally:
|
||||
# Best-effort cleanup of the remote staging dir only if we created it.
|
||||
if stage_dir and stage_created_by_us:
|
||||
try:
|
||||
self._execute_module(
|
||||
module_name="ansible.builtin.file",
|
||||
module_args={"path": stage_dir, "state": "absent"},
|
||||
task_vars=dict(task_vars),
|
||||
tmp=tmp,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# python 3 headers, required if submitting to Ansible
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
from ansible.utils.display import Display
|
||||
|
||||
display = Display()
|
||||
|
||||
"""
|
||||
Diese Funktion geht rekursiv durch die Struktur (ob Dictionary oder Liste) und entfernt alle Einträge,
|
||||
die entweder None, einen leeren String, ein leeres Dictionary, eine leere Liste enthalten.
|
||||
|
||||
Für Dictionaries wird jedes Schlüssel-Wert-Paar überprüft, und es wird nur gespeichert, wenn der Wert nicht leer ist.
|
||||
Für Listen werden nur nicht-leere Elemente in das Ergebnis aufgenommen.
|
||||
|
||||
Es wurde eine Hilfsfunktion `is_empty` eingeführt, die überprüft, ob ein Wert als "leer" betrachtet werden soll.
|
||||
|
||||
Diese Funktion berücksichtigt nun explizit, dass boolesche Werte (True und False) nicht als leer betrachtet werden, sondern erhalten bleiben.
|
||||
In der is_empty-Funktion wurde eine Überprüfung hinzugefügt, um sicherzustellen, dass die Zahl 0 nicht als leer betrachtet wird.
|
||||
Wenn der Wert 0 ist, wird er beibehalten.
|
||||
"""
|
||||
|
||||
|
||||
class FilterModule(object):
|
||||
""" """
|
||||
|
||||
def filters(self):
|
||||
return {
|
||||
"remove_empty_values": self.remove_empty_values,
|
||||
}
|
||||
|
||||
def remove_empty_values(self, data):
|
||||
""" """
|
||||
display.vv(f"bodsch.core.remove_empty_values(self, {data})")
|
||||
|
||||
def is_empty(value):
|
||||
"""Überprüfen, ob der Wert leer ist (ignoriere boolesche Werte)."""
|
||||
if isinstance(value, bool):
|
||||
return False # Boolesche Werte sollen erhalten bleiben
|
||||
if value == 0:
|
||||
return False # Zahl 0 soll erhalten bleiben
|
||||
|
||||
return value in [None, "", {}, [], False]
|
||||
|
||||
if isinstance(data, dict):
|
||||
# Durch alle Schlüssel-Wert-Paare iterieren
|
||||
return {
|
||||
key: self.remove_empty_values(value)
|
||||
for key, value in data.items()
|
||||
if not is_empty(value)
|
||||
}
|
||||
elif isinstance(data, list):
|
||||
# Leere Listen und leere Elemente entfernen
|
||||
return [
|
||||
self.remove_empty_values(item) for item in data if not is_empty(item)
|
||||
]
|
||||
else:
|
||||
# Andere Typen direkt zurückgeben (einschließlich boolesche Werte)
|
||||
return data
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
DOCUMENTATION = """
|
||||
name: clients_type
|
||||
author: Bodo Schulz
|
||||
version_added: "1.0.4"
|
||||
|
||||
short_description: TBD
|
||||
|
||||
description:
|
||||
- TBD
|
||||
|
||||
options: {}
|
||||
|
||||
"""
|
||||
|
||||
EXAMPLES = """
|
||||
|
||||
"""
|
||||
|
||||
RETURN = """
|
||||
|
||||
"""
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
from ansible.utils.display import Display
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.dns_lookup import dns_lookup
|
||||
|
||||
__metaclass__ = type
|
||||
display = Display()
|
||||
|
||||
|
||||
class FilterModule(object):
|
||||
def filters(self):
|
||||
return {"dns_lookup": self.lookup}
|
||||
|
||||
def lookup(self, dns_name, timeout=3, dns_resolvers=["9.9.9.9"]):
|
||||
"""
|
||||
use a simple DNS lookup, return results in a dictionary
|
||||
|
||||
similar to
|
||||
{'addrs': [], 'error': True, 'error_msg': 'No such domain instance', 'name': 'instance'}
|
||||
"""
|
||||
display.vv(f"bodsch.core.dns_lookup({dns_name}, {timeout}, {dns_resolvers})")
|
||||
|
||||
result = dns_lookup(dns_name, timeout, dns_resolvers)
|
||||
|
||||
display.vv(f"= return : {result}")
|
||||
|
||||
return result
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
# python 3 headers, required if submitting to Ansible
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
from ansible.utils.display import Display
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class FilterModule(object):
|
||||
"""
|
||||
Ansible file jinja2 tests
|
||||
"""
|
||||
|
||||
def filters(self):
|
||||
return {
|
||||
"merge_jails": self.merge_jails,
|
||||
}
|
||||
|
||||
def __merge_two_dicts(self, x, y):
|
||||
z = x.copy() # start with x's keys and values
|
||||
z.update(y) # modifies z with y's keys and values & returns None
|
||||
return z
|
||||
|
||||
def __search(self, d, name):
|
||||
res = None
|
||||
for sub in d:
|
||||
if sub["name"] == name:
|
||||
res = sub
|
||||
break
|
||||
|
||||
return res
|
||||
|
||||
def __sort_list(self, _list, _filter):
|
||||
return sorted(_list, key=lambda k: k.get(_filter))
|
||||
|
||||
def merge_jails(self, defaults, data):
|
||||
""" """
|
||||
count_defaults = len(defaults)
|
||||
count_data = len(data)
|
||||
|
||||
# display.v("defaults: ({type}) {len} - {data} entries".format(data=defaults, type=type(defaults), len=count_defaults))
|
||||
# display.vv(json.dumps(data, indent=2, sort_keys=False))
|
||||
# display.v("data : ({type}) {len} - {data} entries".format(data=data, type=type(data), len=count_data))
|
||||
|
||||
result = []
|
||||
|
||||
# short way
|
||||
if count_defaults == 0:
|
||||
return self.__sort_list(data, "name")
|
||||
|
||||
if count_data == 0:
|
||||
return self.__sort_list(defaults, "name")
|
||||
|
||||
# our new list from users input
|
||||
for d in data:
|
||||
_name = d["name"]
|
||||
# search the name in the default map
|
||||
_defaults_name = self.__search(defaults, _name)
|
||||
# when not found, put these on the new result list
|
||||
if not _defaults_name:
|
||||
result.append(_defaults_name)
|
||||
else:
|
||||
# when found, remove these entry from the defaults list, its obsolete
|
||||
for i in range(len(defaults)):
|
||||
if defaults[i]["name"] == _name:
|
||||
del defaults[i]
|
||||
break
|
||||
|
||||
# add both lists and sort
|
||||
result = self.__sort_list(data + defaults, "name")
|
||||
|
||||
return result
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
# python 3 headers, required if submitting to Ansible
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
from typing import Any, Dict, Iterable, Optional, Tuple
|
||||
|
||||
from ansible.errors import AnsibleFilterError
|
||||
from ansible.utils.display import Display
|
||||
|
||||
display = Display()
|
||||
|
||||
"""
|
||||
Ansible filter plugin: host_id
|
||||
|
||||
Resolves a stable host identifier across Ansible versions and fact-injection styles.
|
||||
|
||||
Resolution order:
|
||||
1) ansible_facts['host'] (if present)
|
||||
2) ansible_facts['hostname'] (standard setup fact)
|
||||
3) inventory_hostname (always available as magic var)
|
||||
|
||||
Usage:
|
||||
{{ (ansible_facts | default({})) | host_id(inventory_hostname) }}
|
||||
"""
|
||||
|
||||
|
||||
class FilterModule:
|
||||
""" """
|
||||
|
||||
def filters(self):
|
||||
return {
|
||||
"hostname": self.hostname,
|
||||
}
|
||||
|
||||
def hostname(
|
||||
self,
|
||||
facts: Optional[Dict[str, Any]] = None,
|
||||
inventory_hostname: Optional[str] = None,
|
||||
prefer: Optional[Iterable[str]] = None,
|
||||
default: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Return a host identifier string using a preference list over facts.
|
||||
|
||||
Args:
|
||||
facts: Typically 'ansible_facts' (may be undefined/None).
|
||||
inventory_hostname: Magic var 'inventory_hostname' as last-resort fallback.
|
||||
prefer: Iterable of fact keys to try in order (default: ('host', 'hostname')).
|
||||
default: Returned if nothing else is available.
|
||||
|
||||
Returns:
|
||||
Resolved host identifier as string.
|
||||
"""
|
||||
display.vv(
|
||||
f"bodsch.core.hostname(self, facts, inventory_hostname: '{inventory_hostname}', prefer: '{prefer}', default: '{default}')"
|
||||
)
|
||||
|
||||
facts_dict = self._as_dict(facts)
|
||||
keys: Tuple[str, ...] = (
|
||||
tuple(prefer) if prefer is not None else ("host", "hostname")
|
||||
)
|
||||
|
||||
for key in keys:
|
||||
val = facts_dict.get(key)
|
||||
if val not in (None, ""):
|
||||
display.vv(f"= result: {str(val)}")
|
||||
return str(val)
|
||||
|
||||
if inventory_hostname not in (None, ""):
|
||||
display.vv(f"= result: {str(inventory_hostname)}")
|
||||
return str(inventory_hostname)
|
||||
|
||||
display.vv(f"= result: {str(default)}")
|
||||
|
||||
return str(default)
|
||||
|
||||
def _as_dict(self, value: Any) -> Dict[str, Any]:
|
||||
""" """
|
||||
if value is None:
|
||||
return {}
|
||||
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
|
||||
raise AnsibleFilterError(
|
||||
f"hostname expects a dict-like ansible_facts, got: {type(value)!r}"
|
||||
)
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
import os
|
||||
|
||||
from ansible.utils.display import Display
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class FilterModule(object):
|
||||
def filters(self):
|
||||
return {
|
||||
"linked_version": self.linked_version,
|
||||
}
|
||||
|
||||
def linked_version(self, data: dict, install_path: str, version: str):
|
||||
"""
|
||||
check for linked version in `install_path`
|
||||
|
||||
`data` are dictionary:
|
||||
{
|
||||
'exists': True,
|
||||
''path': '/usr/bin/influxd', ...,
|
||||
'islnk': True, ...,
|
||||
'lnk_source': '/opt/influxd/2.8.0/influxd',
|
||||
'lnk_target': '/opt/influxd/2.8.0/influxd', ...
|
||||
}
|
||||
`install_path`are string and NOT the filename!
|
||||
/opt/influxd/2.8.0
|
||||
|
||||
result: TRUE, when destination is a link and the base path equal with install path
|
||||
otherwise FALSE
|
||||
"""
|
||||
display.vv(
|
||||
f"bodsch.core.linked_version(self, data: {data}, install_path: {install_path}, version: {version})"
|
||||
)
|
||||
|
||||
_is_activated = False
|
||||
|
||||
_destination_exists = data.get("exists", False)
|
||||
|
||||
display.vvv(f" - destination exists : {_destination_exists}")
|
||||
|
||||
if _destination_exists:
|
||||
_destination_islink = data.get("islnk", False)
|
||||
_destination_lnk_source = data.get("lnk_source", None)
|
||||
_destination_path = data.get("path", None)
|
||||
|
||||
if _destination_lnk_source:
|
||||
_destination_path = os.path.dirname(_destination_lnk_source)
|
||||
|
||||
display.vvv(f" - is link : {_destination_islink}")
|
||||
display.vvv(f" - link src : {_destination_lnk_source}")
|
||||
display.vvv(f" - base path : {_destination_path}")
|
||||
|
||||
_is_activated = install_path == _destination_path
|
||||
|
||||
display.vv(f"= is activated: {_is_activated}")
|
||||
|
||||
return _is_activated
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# python 3 headers, required if submitting to Ansible
|
||||
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
from ansible.utils.display import Display
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class FilterModule(object):
|
||||
"""
|
||||
Ansible file jinja2 tests
|
||||
"""
|
||||
|
||||
def filters(self):
|
||||
return {"fstypes": self.fstypes}
|
||||
|
||||
def fstypes(self, data):
|
||||
""" """
|
||||
result = []
|
||||
|
||||
display.vv(f"bodsch.core.fstypes({data}")
|
||||
|
||||
result = [d["fstype"] for d in data]
|
||||
|
||||
display.v("result {} {}".format(result, type(result)))
|
||||
|
||||
return result
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# python 3 headers, required if submitting to Ansible
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
from ansible.utils.display import Display
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class FilterModule(object):
|
||||
""" """
|
||||
|
||||
def filters(self):
|
||||
return {
|
||||
"openvpn_clients": self.openvpn_clients,
|
||||
}
|
||||
|
||||
def openvpn_clients(self, data, hostvars):
|
||||
"""
|
||||
combined_list: "{{ combined_list | default([]) + hostvars[item].openvpn_mobile_clients }}"
|
||||
"""
|
||||
display.vv(f"bodsch.core.openvpn_clients({data}, {hostvars})")
|
||||
|
||||
client = hostvars.get("openvpn_mobile_clients", None)
|
||||
if client and isinstance(client, list):
|
||||
data += client
|
||||
|
||||
return data
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
# python 3 headers, required if submitting to Ansible
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
from ansible.utils.display import Display
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class FilterModule(object):
|
||||
"""
|
||||
Ansible file jinja2 tests
|
||||
"""
|
||||
|
||||
def filters(self):
|
||||
return {
|
||||
"persistent_pool": self.persistent_pool,
|
||||
"clients_type": self.clients_type,
|
||||
}
|
||||
|
||||
def persistent_pool(self, data):
|
||||
"""
|
||||
Get the type of a variable
|
||||
"""
|
||||
result = []
|
||||
|
||||
for i in data:
|
||||
name = i.get("name")
|
||||
if i.get("static_ip", None) is not None:
|
||||
d = dict(
|
||||
name=name,
|
||||
state=i.get("state", "present"),
|
||||
static_ip=i.get("static_ip"),
|
||||
)
|
||||
result.append(d)
|
||||
|
||||
display.v(f" = result : {result}")
|
||||
return result
|
||||
|
||||
def clients_type(self, data, type="static"):
|
||||
""" """
|
||||
result = []
|
||||
|
||||
for d in data:
|
||||
roadrunner = d.get("roadrunner", False)
|
||||
|
||||
if type == "static" and not roadrunner:
|
||||
result.append(d)
|
||||
|
||||
if type == "roadrunner" and roadrunner:
|
||||
result.append(d)
|
||||
|
||||
display.v(f" = result : {result}")
|
||||
return result
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
import re
|
||||
|
||||
from ansible.utils.display import Display
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class FilterModule(object):
|
||||
def filters(self):
|
||||
return {
|
||||
"parse_checksum": self.parse_checksum,
|
||||
}
|
||||
|
||||
def parse_checksum(self, data, application, os, arch, file_extension="tar.gz"):
|
||||
"""
|
||||
parse version string
|
||||
"""
|
||||
display.vv(
|
||||
f"bodsch.core.parse_checksum(self, data, {application}, {os}, {arch})"
|
||||
)
|
||||
|
||||
checksum = None
|
||||
os = os.lower()
|
||||
display.vvv(f" data: {data}")
|
||||
display.vvv(f" os: {os}")
|
||||
display.vvv(f" arch: {arch}")
|
||||
display.vvv(f" file_extension: {file_extension}")
|
||||
|
||||
if isinstance(data, list):
|
||||
# 206cf787c01921574ca171220bb9b48b043c3ad6e744017030fed586eb48e04b alertmanager-0.25.0.linux-amd64.tar.gz
|
||||
# (?P<checksum>[a-zA-Z0-9]+).*alertmanager[-_].*linux-amd64\.tar\.gz$
|
||||
checksum = [
|
||||
x
|
||||
for x in data
|
||||
if re.search(
|
||||
rf"(?P<checksum>[a-zA-Z0-9]+).*{application}[-_].*{os}[-_]{arch}\.{file_extension}",
|
||||
x,
|
||||
)
|
||||
][0]
|
||||
|
||||
display.vvv(f" found checksum: {checksum}")
|
||||
|
||||
if isinstance(checksum, str):
|
||||
checksum = checksum.split(" ")[0]
|
||||
|
||||
display.vv(f"= checksum: {checksum}")
|
||||
|
||||
return checksum
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
from ansible.utils.display import Display
|
||||
|
||||
__metaclass__ = type
|
||||
display = Display()
|
||||
|
||||
|
||||
class FilterModule(object):
|
||||
def filters(self):
|
||||
return {"python_extra_args": self.python_extra_args}
|
||||
|
||||
def python_extra_args(
|
||||
self, data, python_version, extra_args=[], break_system_packages=True
|
||||
):
|
||||
"""
|
||||
add extra args for python pip installation
|
||||
"""
|
||||
result = list(set(extra_args))
|
||||
|
||||
python_version_major = python_version.get("major", None)
|
||||
python_version_minor = python_version.get("minor", None)
|
||||
|
||||
if (
|
||||
int(python_version_major) == 3
|
||||
and int(python_version_minor) >= 11
|
||||
and break_system_packages
|
||||
):
|
||||
result.append("--break-system-packages")
|
||||
|
||||
# deduplicate
|
||||
result = list(set(result))
|
||||
|
||||
result = " ".join(result)
|
||||
|
||||
display.vv(f"= {result}")
|
||||
return result
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
# python 3 headers, required if submitting to Ansible
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import json
|
||||
|
||||
from ansible.utils.display import Display
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class FilterModule(object):
|
||||
""" """
|
||||
|
||||
def filters(self):
|
||||
return {
|
||||
"merge_lists": self.merge_lists,
|
||||
"sshd_values": self.sshd_values,
|
||||
}
|
||||
|
||||
def merge_lists(self, defaults, data):
|
||||
""" """
|
||||
count_defaults = len(defaults)
|
||||
count_data = len(data)
|
||||
|
||||
display.vv(
|
||||
"defaults: ({type}) {len} - {data} entries".format(
|
||||
data=defaults, type=type(defaults), len=count_defaults
|
||||
)
|
||||
)
|
||||
display.vv(json.dumps(data, indent=2, sort_keys=False))
|
||||
display.vv(
|
||||
"data : ({type}) {len} - {data} entries".format(
|
||||
data=data, type=type(data), len=count_data
|
||||
)
|
||||
)
|
||||
|
||||
result = []
|
||||
|
||||
# short way
|
||||
if count_defaults == 0:
|
||||
return data
|
||||
|
||||
if count_data == 0:
|
||||
return defaults
|
||||
|
||||
# our new list from users input
|
||||
for d in data:
|
||||
_name = d["host"]
|
||||
# search the name in the default map
|
||||
_defaults_name = self.__search(defaults, _name)
|
||||
# display.vv(f" _defaults_name : {_defaults_name}")
|
||||
# when not found, put these on the new result list
|
||||
if not _defaults_name:
|
||||
result.append(_defaults_name)
|
||||
else:
|
||||
# when found, remove these entry from the defaults list, its obsolete
|
||||
for i in range(len(defaults)):
|
||||
if defaults[i]["host"] == _name:
|
||||
del defaults[i]
|
||||
break
|
||||
|
||||
# add both lists and sort
|
||||
result = data + defaults
|
||||
|
||||
display.vv(f"= result: {result}")
|
||||
|
||||
return result
|
||||
|
||||
def sshd_values(self, data):
|
||||
"""
|
||||
Ersetzt die Keys in einer YAML-Struktur basierend auf einer gegebenen Key-Map.
|
||||
|
||||
:param data: Ansible Datenkonstrukt
|
||||
:return: Ansible Datenkonstrukt mit den ersetzten Keys.
|
||||
"""
|
||||
display.vv(f"bodsch.core.sshd_values({data})")
|
||||
|
||||
# Hilfsfunktion zur Rekursion
|
||||
def replace_keys(obj):
|
||||
"""
|
||||
:param key_map: Dictionary, das alte Keys mit neuen Keys mappt.
|
||||
"""
|
||||
key_map = {
|
||||
"port": "Port",
|
||||
"address_family": "AddressFamily",
|
||||
"listen_address": "ListenAddress",
|
||||
"host_keys": "HostKey",
|
||||
"rekey_limit": "RekeyLimit",
|
||||
"syslog_facility": "SyslogFacility",
|
||||
"log_level": "LogLevel",
|
||||
"log_verbose": "LogVerbose",
|
||||
"login_grace_time": "LoginGraceTime",
|
||||
"permit_root_login": "PermitRootLogin",
|
||||
"strict_modes": "StrictModes",
|
||||
"max_auth_tries": "MaxAuthTries",
|
||||
"max_sessions": "MaxSessions",
|
||||
"pubkey_authentication": "PubkeyAuthentication",
|
||||
"authorized_keys_file": "AuthorizedKeysFile",
|
||||
"authorized_principals_file": "AuthorizedPrincipalsFile",
|
||||
"authorized_keys_command": "AuthorizedKeysCommand",
|
||||
"authorized_keys_command_user": "AuthorizedKeysCommandUser",
|
||||
"hostbased_authentication": "HostbasedAuthentication",
|
||||
"hostbased_accepted_algorithms": "HostbasedAcceptedAlgorithms",
|
||||
"host_certificate": "HostCertificate",
|
||||
"host_key": "HostKey",
|
||||
"host_key_agent": "HostKeyAgent",
|
||||
"host_key_algorithms": "HostKeyAlgorithms",
|
||||
"ignore_user_known_hosts": "IgnoreUserKnownHosts",
|
||||
"ignore_rhosts": "IgnoreRhosts",
|
||||
"password_authentication": "PasswordAuthentication",
|
||||
"permit_empty_passwords": "PermitEmptyPasswords",
|
||||
"challenge_response_authentication": "ChallengeResponseAuthentication",
|
||||
"kerberos_authentication": "KerberosAuthentication",
|
||||
"kerberos_or_local_passwd": "KerberosOrLocalPasswd",
|
||||
"kerberos_ticket_cleanup": "KerberosTicketCleanup",
|
||||
"kerberos_get_afs_token": "KerberosGetAFSToken",
|
||||
"kex_algorithms": "KexAlgorithms",
|
||||
"gss_api_authentication": "GSSAPIAuthentication",
|
||||
"gss_api_cleanup_credentials": "GSSAPICleanupCredentials",
|
||||
"gss_api_strict_acceptor_check": "GSSAPIStrictAcceptorCheck",
|
||||
"gss_api_key_exchange": "GSSAPIKeyExchange",
|
||||
"use_pam": "UsePAM",
|
||||
"allow_agent_forwarding": "AllowAgentForwarding",
|
||||
"allow_tcp_forwarding": "AllowTcpForwarding",
|
||||
"gateway_ports": "GatewayPorts",
|
||||
"x11_forwarding": "X11Forwarding",
|
||||
"x11_display_offset": "X11DisplayOffset",
|
||||
"x11_use_localhost": "X11UseLocalhost",
|
||||
"permit_tty": "PermitTTY",
|
||||
"print_motd": "PrintMotd",
|
||||
"print_last_log": "PrintLastLog",
|
||||
"tcp_keep_alive": "TCPKeepAlive",
|
||||
"permituser_environment": "PermitUserEnvironment",
|
||||
"compression": "Compression",
|
||||
"client_alive_interval": "ClientAliveInterval",
|
||||
"client_alive_count_max": "ClientAliveCountMax",
|
||||
"ciphers": "Ciphers",
|
||||
"deny_groups": "DenyGroups",
|
||||
"deny_users": "DenyUsers",
|
||||
"macs": "MACs",
|
||||
"use_dns": "UseDNS",
|
||||
"pid_file": "PidFile",
|
||||
"max_startups": "MaxStartups",
|
||||
"permit_tunnel": "PermitTunnel",
|
||||
"chroot_directory": "ChrootDirectory",
|
||||
"version_addendum": "VersionAddendum",
|
||||
"banner": "Banner",
|
||||
"accept_env": "AcceptEnv",
|
||||
"subsystem": "Subsystem",
|
||||
"match_users": "Match",
|
||||
# ssh_config
|
||||
"hash_known_hosts": "HashKnownHosts",
|
||||
"send_env": "SendEnv",
|
||||
# "": "",
|
||||
}
|
||||
|
||||
if isinstance(obj, dict):
|
||||
# Ersetze die Keys und rufe rekursiv für die Werte auf
|
||||
return {key_map.get(k, k): replace_keys(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
# Falls es eine Liste ist, rekursiv die Elemente bearbeiten
|
||||
return [replace_keys(item) for item in obj]
|
||||
else:
|
||||
return obj
|
||||
|
||||
# Ersetze die Keys im geladenen YAML
|
||||
result = replace_keys(data)
|
||||
|
||||
display.v(f"= result: {result}")
|
||||
|
||||
return result
|
||||
|
||||
def __sort_list(self, _list, _filter):
|
||||
return sorted(_list, key=lambda k: k.get(_filter))
|
||||
|
||||
def __search(self, d, name):
|
||||
res = None
|
||||
for sub in d:
|
||||
if sub["host"] == name:
|
||||
res = sub
|
||||
break
|
||||
|
||||
return res
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2022-2024, Bodo Schulz <bodo@boone-schulz.de>
|
||||
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
import os
|
||||
|
||||
from ansible.utils.display import Display
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class FilterModule(object):
|
||||
""" """
|
||||
|
||||
def filters(self):
|
||||
return {
|
||||
"support_tls": self.support_tls,
|
||||
"tls_directory": self.tls_directory,
|
||||
}
|
||||
|
||||
def support_tls(self, data):
|
||||
"""
|
||||
collabora_config:
|
||||
ssl:
|
||||
enabled: true
|
||||
cert_file: /etc/coolwsd/cert.pem
|
||||
key_file: /etc/coolwsd/key.pem
|
||||
ca_file: /etc/coolwsd/ca-chain.cert.pem
|
||||
storage:
|
||||
ssl:
|
||||
enabled: ""
|
||||
cert_file: /etc/coolwsd/cert.pem
|
||||
key_file: /etc/coolwsd/key.pem
|
||||
ca_file: /etc/coolwsd/ca-chain.cert.pem
|
||||
"""
|
||||
display.vv(f"bodsch.core.support_tls({data})")
|
||||
|
||||
ssl_data = data.get("ssl", {})
|
||||
|
||||
ssl_enabled = ssl_data.get("enabled", None)
|
||||
ssl_ca = ssl_data.get("ca_file", None)
|
||||
ssl_cert = ssl_data.get("cert_file", None)
|
||||
ssl_key = ssl_data.get("key_file", None)
|
||||
|
||||
if ssl_enabled and ssl_ca and ssl_cert and ssl_key:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def tls_directory(self, data):
|
||||
""" """
|
||||
display.vv(f"bodsch.core.tls_directory({data})")
|
||||
|
||||
directory = []
|
||||
|
||||
ssl_data = data.get("ssl", {})
|
||||
|
||||
ssl_ca = ssl_data.get("ca_file", None)
|
||||
ssl_cert = ssl_data.get("cert_file", None)
|
||||
ssl_key = ssl_data.get("key_file", None)
|
||||
|
||||
if ssl_ca and ssl_cert and ssl_key:
|
||||
directory.append(os.path.dirname(ssl_ca))
|
||||
directory.append(os.path.dirname(ssl_cert))
|
||||
directory.append(os.path.dirname(ssl_key))
|
||||
|
||||
directory = list(set(directory))
|
||||
|
||||
if len(directory) == 1:
|
||||
result = directory[0]
|
||||
|
||||
display.vv(f" = {result}")
|
||||
|
||||
return result
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
# python 3 headers, required if submitting to Ansible
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from ansible.plugins.test.core import version_compare
|
||||
from ansible.utils.display import Display
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class FilterModule(object):
|
||||
""" """
|
||||
|
||||
def filters(self):
|
||||
return {
|
||||
"get_service": self.get_service,
|
||||
"log_directories": self.log_directories,
|
||||
"syslog_network_definition": self.syslog_network_definition,
|
||||
"verify_syslog_options": self.verify_syslog_options,
|
||||
}
|
||||
|
||||
def get_service(self, data, search_for):
|
||||
""" """
|
||||
display.vv(f"bodsch.core.get_service(self, {data}, {search_for})")
|
||||
|
||||
name = None
|
||||
regex_list_compiled = re.compile(f"^{search_for}.*")
|
||||
|
||||
match = {k: v for k, v in data.items() if re.match(regex_list_compiled, k)}
|
||||
|
||||
# display.vv(f"found: {match} {type(match)} {len(match)}")
|
||||
|
||||
if isinstance(match, dict) and len(match) > 0:
|
||||
values = list(match.values())[0]
|
||||
name = values.get("name", search_for).replace(".service", "")
|
||||
|
||||
# display.vv(f"= result {name}")
|
||||
return name
|
||||
|
||||
def log_directories(self, data, base_directory):
|
||||
"""
|
||||
return a list of directories
|
||||
"""
|
||||
display.vv(f"bodsch.core.log_directories(self, {data}, {base_directory})")
|
||||
|
||||
log_dirs = []
|
||||
log_files = sorted(
|
||||
[v.get("file_name") for k, v in data.items() if v.get("file_name")]
|
||||
)
|
||||
unique = list(dict.fromkeys(log_files))
|
||||
for d in unique:
|
||||
if "/$" in d:
|
||||
clean_dir_name = d.split("/$")[0]
|
||||
log_dirs.append(clean_dir_name)
|
||||
|
||||
unique_dirs = list(dict.fromkeys(log_dirs))
|
||||
|
||||
log_dirs = []
|
||||
|
||||
for file_name in unique_dirs:
|
||||
full_file_name = os.path.join(base_directory, file_name)
|
||||
log_dirs.append(full_file_name)
|
||||
|
||||
# display.v(f"= result {log_dirs}")
|
||||
return log_dirs
|
||||
|
||||
def validate_syslog_destination(self, data):
|
||||
""" """
|
||||
pass
|
||||
|
||||
def syslog_network_definition(self, data, conf_type="source"):
|
||||
""" """
|
||||
display.vv(f"bodsch.core.syslog_network_definition({data}, {conf_type})")
|
||||
|
||||
def as_boolean(value):
|
||||
return "yes" if value else "no"
|
||||
|
||||
def as_string(value):
|
||||
return f'"{value}"'
|
||||
|
||||
def as_list(value):
|
||||
return ", ".join(value)
|
||||
|
||||
res = {}
|
||||
if isinstance(data, dict):
|
||||
|
||||
for key, value in data.items():
|
||||
if key == "ip":
|
||||
if conf_type == "source":
|
||||
res = dict(ip=f"({value})")
|
||||
else:
|
||||
res = dict(ip=f'"{value}"')
|
||||
else:
|
||||
if isinstance(value, bool):
|
||||
value = f"({as_boolean(value)})"
|
||||
elif isinstance(value, str):
|
||||
value = f"({as_string(value)})"
|
||||
elif isinstance(value, int):
|
||||
value = f"({value})"
|
||||
elif isinstance(value, list):
|
||||
value = f"({as_list(value)})"
|
||||
elif isinstance(value, dict):
|
||||
value = self.syslog_network_definition(value, conf_type)
|
||||
|
||||
res.update({key: value})
|
||||
|
||||
if isinstance(data, str):
|
||||
res = data
|
||||
|
||||
# display.v(f"= res {res}")
|
||||
return res
|
||||
|
||||
def verify_syslog_options(self, data, version):
|
||||
""" """
|
||||
display.vv(f"bodsch.core.verify_syslog_options({data}, {version})")
|
||||
|
||||
if version_compare(str(version), "4.1", ">="):
|
||||
if data.get("stats_freq") is not None:
|
||||
stats_freq = data.pop("stats_freq")
|
||||
"""
|
||||
obsoleted keyword, please update your configuration; keyword='stats_freq'
|
||||
change='Use the stats() block. E.g. stats(freq(1));
|
||||
"""
|
||||
# sicherstellen, dass 'stats' ein dict ist
|
||||
if not isinstance(data.get("stats"), dict):
|
||||
data["stats"] = {}
|
||||
|
||||
data["stats"]["freq"] = stats_freq
|
||||
|
||||
if version_compare(str(version), "4.1", "<"):
|
||||
data.pop("stats", None) # kein KeyError
|
||||
|
||||
# display.v(f"= result {data}")
|
||||
return data
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
# filter_plugins/var_type.py
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Set as ABCSet
|
||||
|
||||
from ansible.utils.display import Display
|
||||
|
||||
# optional: we vermeiden harte Abhängigkeit von Ansible, behandeln aber deren Wrapper als str
|
||||
_STR_WRAPPERS = {"AnsibleUnsafeText", "AnsibleUnicode", "AnsibleVaultEncryptedUnicode"}
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class FilterModule(object):
|
||||
def filters(self):
|
||||
return {
|
||||
"type": self.var_type,
|
||||
"config_bool": self.config_bool_as_string,
|
||||
"string_to_list": self.string_to_list,
|
||||
}
|
||||
|
||||
def var_type(self, value):
|
||||
"""
|
||||
Liefert kanonische Python-Typnamen: str, int, float, bool, list, tuple, set, dict, NoneType.
|
||||
Fällt bei fremden/Wrapper-Typen auf die jeweilige ABC-Kategorie zurück.
|
||||
"""
|
||||
# None
|
||||
if value is None:
|
||||
return "NoneType"
|
||||
|
||||
t = type(value)
|
||||
|
||||
# String-ähnliche Wrapper (z.B. AnsibleUnsafeText)
|
||||
if isinstance(value, str) or t.__name__ in _STR_WRAPPERS:
|
||||
return "string"
|
||||
|
||||
# Bytes
|
||||
if isinstance(value, bytes):
|
||||
return "bytes"
|
||||
if isinstance(value, bytearray):
|
||||
return "bytearray"
|
||||
|
||||
# Bool vor int (bool ist Subklasse von int)
|
||||
if isinstance(value, bool):
|
||||
return "bool"
|
||||
|
||||
# Grundtypen
|
||||
if isinstance(value, int):
|
||||
return "int"
|
||||
if isinstance(value, float):
|
||||
return "float"
|
||||
|
||||
# Konkrete eingebaute Container zuerst
|
||||
if isinstance(value, list):
|
||||
return "list"
|
||||
if isinstance(value, tuple):
|
||||
return "tuple"
|
||||
if isinstance(value, set):
|
||||
return "set"
|
||||
if isinstance(value, dict):
|
||||
return "dict"
|
||||
|
||||
# ABC-Fallbacks für Wrapper (z.B. _AnsibleLazyTemplateList, AnsibleMapping ...)
|
||||
if isinstance(value, Mapping):
|
||||
return "dict"
|
||||
if isinstance(value, ABCSet):
|
||||
return "set"
|
||||
if isinstance(value, Sequence) and not isinstance(
|
||||
value, (str, bytes, bytearray)
|
||||
):
|
||||
# Unbekannte sequenzartige Wrapper -> als list behandeln
|
||||
return "list"
|
||||
|
||||
# Letzter Ausweg: konkreter Klassenname
|
||||
return t.__name__
|
||||
|
||||
def config_bool_as_string(self, data, true_as="yes", false_as="no"):
|
||||
"""
|
||||
return string for boolean
|
||||
"""
|
||||
# display.vv(f"bodsch.core.config_bool({data}, {type(data)}, {true_as}, {false_as})")
|
||||
|
||||
result = false_as
|
||||
|
||||
if isinstance(data, bool):
|
||||
result = true_as if data else false_as
|
||||
|
||||
if type(data) is None:
|
||||
result = False
|
||||
elif type(data) is bool:
|
||||
result = true_as if data else false_as
|
||||
else:
|
||||
result = data
|
||||
|
||||
return result
|
||||
|
||||
def string_to_list(self, data):
|
||||
""" """
|
||||
display.vv(f"bodsch.core.string_to_list({data})")
|
||||
|
||||
result = []
|
||||
if isinstance(data, str):
|
||||
result.append(data)
|
||||
elif isinstance(data, int):
|
||||
result.append(str(data))
|
||||
elif isinstance(data, list):
|
||||
result = data
|
||||
|
||||
display.vv(f"= result: {result}")
|
||||
|
||||
return result
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
from ansible.utils.display import Display
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class FilterModule(object):
|
||||
def filters(self):
|
||||
return {
|
||||
"union_by": self.union,
|
||||
}
|
||||
|
||||
def union(self, data, defaults, union_by):
|
||||
"""
|
||||
union by ..
|
||||
"""
|
||||
result = []
|
||||
|
||||
if len(data) == 0:
|
||||
result = defaults
|
||||
else:
|
||||
for i in data:
|
||||
display.vv(f" - {i}")
|
||||
x = i.get(union_by, None)
|
||||
|
||||
if x:
|
||||
found = [d for d in defaults if d.get(union_by) == x]
|
||||
|
||||
if found:
|
||||
result.append(i)
|
||||
else:
|
||||
result.append(found[0])
|
||||
else:
|
||||
result.append(i)
|
||||
|
||||
display.vv(f"= {result}")
|
||||
return result
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
from ansible.utils.display import Display
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class FilterModule(object):
|
||||
def filters(self):
|
||||
return {
|
||||
"compare_list": self.compare_list,
|
||||
"upgrade": self.upgrade,
|
||||
}
|
||||
|
||||
def compare_list(self, data_list, compare_to_list):
|
||||
"""
|
||||
compare two lists
|
||||
"""
|
||||
display.vv(f"bodsch.core.compare_list({data_list}, {compare_to_list})")
|
||||
|
||||
result = []
|
||||
|
||||
for i in data_list:
|
||||
if i in compare_to_list:
|
||||
result.append(i)
|
||||
|
||||
display.vv(f"return : {result}")
|
||||
return result
|
||||
|
||||
def upgrade(self, install_path, bin_path):
|
||||
"""
|
||||
upgrade ...
|
||||
"""
|
||||
display.vv(f"bodsch.core.upgrade({install_path}, {bin_path})")
|
||||
|
||||
directory = None
|
||||
link_to_bin = None
|
||||
|
||||
install_path_stats = install_path.get("stat", None)
|
||||
bin_path_stats = bin_path.get("stat", None)
|
||||
install_path_exists = install_path_stats.get("exists", False)
|
||||
bin_path_exists = bin_path_stats.get("exists", False)
|
||||
|
||||
if install_path_exists:
|
||||
directory = install_path_stats.get("isdir", False)
|
||||
|
||||
if bin_path_exists:
|
||||
link_to_bin = bin_path_stats.get("islnk", False)
|
||||
|
||||
if bin_path_exists and not link_to_bin:
|
||||
result = True
|
||||
elif install_path_exists and directory:
|
||||
result = False
|
||||
else:
|
||||
result = False
|
||||
|
||||
display.vv(f"return : {result}")
|
||||
return result
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2012, Michael DeHaan <michael.dehaan@gmail.com>
|
||||
# (c) 2017 Ansible Project
|
||||
# (c) 2022-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# "MODIFED WITH https://github.com/philfry/ansible/blob/37c616dc76d9ebc3cbf0285a22e55f0e4db4185e/lib/ansible/plugins/lookup/fileglob.py"
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# from ansible.utils.listify import listify_lookup_plugin_terms as listify
|
||||
from ansible.plugins.lookup import LookupBase
|
||||
from ansible.utils.display import Display
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
DOCUMENTATION = """
|
||||
name: fileglob
|
||||
author: Bodo Schulz
|
||||
version_added: "1.0.4"
|
||||
short_description: list files matching a pattern
|
||||
description:
|
||||
- Find all files in a directory tree that match a pattern (recursively).
|
||||
options:
|
||||
_terms:
|
||||
required: False
|
||||
description: File extension on which a comparison is to take place.
|
||||
type: str
|
||||
search_path:
|
||||
required: False
|
||||
description: A list of additional directories to be searched.
|
||||
type: list
|
||||
default: []
|
||||
version_added: "1.0.4"
|
||||
notes:
|
||||
- Patterns are only supported on files, not directory/paths.
|
||||
- Matching is against local system files on the Ansible controller.
|
||||
To iterate a list of files on a remote node, use the M(ansible.builtin.find) module.
|
||||
- Returns a string list of paths joined by commas, or an empty list if no files match. For a 'true list' pass C(wantlist=True) to the lookup.
|
||||
"""
|
||||
|
||||
EXAMPLES = """
|
||||
- name: Display paths of all .tpl files
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ lookup('bodsch.core.file_glob', '.tpl') }}"
|
||||
|
||||
- name: Show paths of all .tpl files, extended by further directories
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ lookup('bodsch.core.file_glob', '.tpl') }}"
|
||||
vars:
|
||||
search_path:
|
||||
- ".."
|
||||
- "../.."
|
||||
|
||||
- name: Copy each file over that matches the given pattern
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item }}"
|
||||
dest: "/etc/fooapp/"
|
||||
owner: "root"
|
||||
mode: 0600
|
||||
with_file_glob:
|
||||
- "*.tmpl"
|
||||
|
||||
- name: Copy each template over that matches the given pattern
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item }}"
|
||||
dest: "/etc/alertmanager/templates/"
|
||||
owner: "root"
|
||||
mode: 0640
|
||||
with_file_glob:
|
||||
- ".tmpl"
|
||||
vars:
|
||||
search_path:
|
||||
- ".."
|
||||
- "../.."
|
||||
"""
|
||||
|
||||
RETURN = """
|
||||
_list:
|
||||
description:
|
||||
- list of files
|
||||
type: list
|
||||
elements: path
|
||||
"""
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class LookupModule(LookupBase):
|
||||
"""
|
||||
Ansible lookup plugin that finds files matching an extension in role
|
||||
or playbook search paths.
|
||||
|
||||
The plugin:
|
||||
* Resolves search locations based on Ansible's search paths and optional
|
||||
user-specified paths.
|
||||
* Recursively walks the "templates" and "files" directories.
|
||||
* Returns a flat list of matching file paths.
|
||||
"""
|
||||
|
||||
def __init__(self, basedir: Optional[str] = None, **kwargs: Any) -> None:
|
||||
"""
|
||||
Initialize the lookup module.
|
||||
|
||||
The base directory is stored for potential use by Ansible's lookup base
|
||||
mechanisms.
|
||||
|
||||
Args:
|
||||
basedir: Optional base directory for lookups, usually supplied by Ansible.
|
||||
**kwargs: Additional keyword arguments passed from Ansible.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
self.basedir = basedir
|
||||
|
||||
def run(
|
||||
self,
|
||||
terms: List[str],
|
||||
variables: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Execute the fileglob lookup.
|
||||
|
||||
For each term (interpreted as a file extension), this method searches
|
||||
recursively under all derived search paths and returns a flattened list
|
||||
of matching file paths.
|
||||
|
||||
Args:
|
||||
terms: A list of file extensions or patterns (e.g. ['.tpl']).
|
||||
variables: The Ansible variable context, used to determine
|
||||
- ansible_search_path
|
||||
- role_path
|
||||
- search_path (custom additional paths)
|
||||
- search_regex (optional filename regex filter)
|
||||
**kwargs: Additional lookup options, passed through to set_options().
|
||||
|
||||
Returns:
|
||||
list[str]: A list containing the full paths of all files matching
|
||||
the provided extensions within the resolved search directories.
|
||||
"""
|
||||
display.vv(f"run({terms}, variables, {kwargs})")
|
||||
self.set_options(direct=kwargs)
|
||||
|
||||
paths: List[str] = []
|
||||
ansible_search_path = variables.get("ansible_search_path", None)
|
||||
role_path = variables.get("role_path")
|
||||
lookup_search_path = variables.get("search_path", None)
|
||||
lookup_search_regex = variables.get("search_regex", None)
|
||||
|
||||
if ansible_search_path:
|
||||
paths = ansible_search_path
|
||||
else:
|
||||
paths.append(self.get_basedir(variables))
|
||||
|
||||
if lookup_search_path:
|
||||
if isinstance(lookup_search_path, list):
|
||||
for p in lookup_search_path:
|
||||
paths.append(os.path.join(role_path, p))
|
||||
|
||||
search_path = ["templates", "files"]
|
||||
|
||||
ret: List[str] = []
|
||||
found_files: List[List[str]] = []
|
||||
|
||||
for term in terms:
|
||||
""" """
|
||||
for p in paths:
|
||||
for sp in search_path:
|
||||
path = os.path.join(p, sp)
|
||||
display.vv(f" - lookup in directory: {path}")
|
||||
r = self._find_recursive(
|
||||
folder=path, extension=term, search_regex=lookup_search_regex
|
||||
)
|
||||
# display.vv(f" found: {r}")
|
||||
if len(r) > 0:
|
||||
found_files.append(r)
|
||||
|
||||
ret = self._flatten(found_files)
|
||||
|
||||
return ret
|
||||
|
||||
def _find_recursive(
|
||||
self,
|
||||
folder: str,
|
||||
extension: str,
|
||||
search_regex: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Recursively search for files in the given folder that match an extension
|
||||
and an optional regular expression.
|
||||
|
||||
Args:
|
||||
folder: The root directory to walk recursively.
|
||||
extension: The file extension to match (e.g. ".tpl").
|
||||
search_regex: Optional regular expression string. If provided, only
|
||||
filenames matching this regex are included.
|
||||
|
||||
Returns:
|
||||
list[str]: A list containing the full paths of matching files found
|
||||
under the given folder. If no files match, an empty list is returned.
|
||||
"""
|
||||
# display.vv(f"_find_recursive({folder}, {extension}, {search_regex})")
|
||||
matches: List[str] = []
|
||||
|
||||
for root, dirnames, filenames in os.walk(folder):
|
||||
for filename in filenames:
|
||||
if filename.endswith(extension):
|
||||
if search_regex:
|
||||
reg = re.compile(search_regex)
|
||||
if reg.match(filename):
|
||||
matches.append(os.path.join(root, filename))
|
||||
else:
|
||||
matches.append(os.path.join(root, filename))
|
||||
|
||||
return matches
|
||||
|
|
@ -0,0 +1,463 @@
|
|||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
"""
|
||||
Ansible lookup plugin to read secrets from Vaultwarden using the rbw CLI.
|
||||
|
||||
This module provides the `LookupModule` class, which integrates the `rbw`
|
||||
command line client into Ansible as a lookup plugin. It supports optional
|
||||
index-based lookups, JSON parsing of secrets, and on-disk caching for both
|
||||
the rbw index and retrieved secrets.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ansible.errors import AnsibleError
|
||||
from ansible.plugins.lookup import LookupBase
|
||||
from ansible.utils.display import Display
|
||||
|
||||
display = Display()
|
||||
|
||||
DOCUMENTATION = """
|
||||
lookup: rbw
|
||||
author:
|
||||
- Bodo 'bodsch' (@bodsch)
|
||||
version_added: "1.0.0"
|
||||
short_description: Read secrets from Vaultwarden via the rbw CLI
|
||||
description:
|
||||
- This lookup plugin retrieves entries from Vaultwarden using the 'rbw' CLI client.
|
||||
- It supports selecting specific fields, optional JSON parsing, and structured error handling.
|
||||
- Supports index-based lookups for disambiguation by name/folder/user.
|
||||
options:
|
||||
_terms:
|
||||
description:
|
||||
- The Vault entry to retrieve, specified by path, name, or UUID.
|
||||
required: true
|
||||
field:
|
||||
description:
|
||||
- Optional field within the entry to return (e.g., username, password).
|
||||
required: false
|
||||
type: str
|
||||
parse_json:
|
||||
description:
|
||||
- If set to true, the returned value will be parsed as JSON.
|
||||
required: false
|
||||
type: bool
|
||||
default: false
|
||||
strict_json:
|
||||
description:
|
||||
- If true and parse_json is enabled, invalid JSON will raise an error.
|
||||
- If false, invalid JSON will return an empty dictionary.
|
||||
required: false
|
||||
type: bool
|
||||
default: false
|
||||
use_index:
|
||||
description:
|
||||
- If true, the index will be used to map name/folder/user to a unique id.
|
||||
required: false
|
||||
type: bool
|
||||
default: false
|
||||
"""
|
||||
|
||||
EXAMPLES = """
|
||||
- name: Read a password from Vault by UUID
|
||||
debug:
|
||||
msg: "{{ lookup('bodsch.core.rbw', '0123-uuid-4567', field='password') }}"
|
||||
|
||||
- name: Read a password using index
|
||||
debug:
|
||||
msg: "{{ lookup('bodsch.core.rbw',
|
||||
{'name': 'expresszuschnitt.de', 'folder': '.immowelt.de', 'user': 'immo@boone-schulz.de'},
|
||||
field='password',
|
||||
use_index=True) }}"
|
||||
|
||||
- name: Multi-fetch
|
||||
set_fact:
|
||||
multi: "{{ lookup('bodsch.core.rbw',
|
||||
[{'name': 'foo', 'folder': '', 'user': ''}, 'some-uuid'],
|
||||
field='username',
|
||||
use_index=True) }}"
|
||||
"""
|
||||
|
||||
RETURN = """
|
||||
_raw:
|
||||
description:
|
||||
- The raw value from the Vault entry, either as a string or dictionary (if parse_json is true).
|
||||
type: raw
|
||||
"""
|
||||
|
||||
|
||||
class LookupModule(LookupBase):
|
||||
"""
|
||||
Ansible lookup module for retrieving secrets from Vaultwarden via the rbw CLI.
|
||||
|
||||
The plugin supports:
|
||||
* Lookup by UUID or by a combination of name, folder, and user.
|
||||
* Optional index-based resolution to derive a stable entry ID.
|
||||
* On-disk caching of both the rbw index and individual lookups.
|
||||
* Optional JSON parsing of retrieved secret values.
|
||||
|
||||
Attributes:
|
||||
CACHE_TTL (int): Time-to-live for cache entries in seconds.
|
||||
cache_directory (str): Base directory path for index and value caches.
|
||||
"""
|
||||
|
||||
CACHE_TTL = 300 # 5 Minuten
|
||||
cache_directory = f"{Path.home()}/.cache/ansible/lookup/rbw"
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""
|
||||
Initialize the lookup module and ensure the cache directory exists.
|
||||
|
||||
Args:
|
||||
*args: Positional arguments passed through to the parent class.
|
||||
**kwargs: Keyword arguments passed through to the parent class.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
super(LookupModule, self).__init__(*args, **kwargs)
|
||||
if not os.path.exists(self.cache_directory):
|
||||
os.makedirs(self.cache_directory, exist_ok=True)
|
||||
|
||||
def run(self, terms, variables=None, **kwargs) -> List[Any]:
|
||||
"""
|
||||
Execute the lookup and return the requested values.
|
||||
|
||||
This method is called by Ansible when the lookup plugin is used. It
|
||||
resolves each term into an rbw entry ID (optionally using the index),
|
||||
retrieves and caches the value, and optionally parses the value as JSON.
|
||||
|
||||
Args:
|
||||
terms: A list of lookup terms. Each term can be either:
|
||||
* A string representing an entry ID or name.
|
||||
* A dict with keys "name", "folder", and "user" for index-based lookup.
|
||||
variables: Ansible variables (unused, but part of the standard interface).
|
||||
**kwargs: Additional keyword arguments:
|
||||
* field (str): Optional field within the entry to return.
|
||||
* parse_json (bool): Whether to parse the result as JSON.
|
||||
* strict_json (bool): If True, invalid JSON raises an error.
|
||||
* use_index (bool): If True, resolve name/folder/user via rbw index.
|
||||
|
||||
Returns:
|
||||
list: A list of values corresponding to the supplied terms. Each element
|
||||
is either:
|
||||
* A string (raw secret) when parse_json is False.
|
||||
* A dict (parsed JSON) when parse_json is True.
|
||||
|
||||
Raises:
|
||||
AnsibleError: If input terms are invalid, the index lookup fails,
|
||||
the rbw command fails, or JSON parsing fails in strict mode.
|
||||
"""
|
||||
display.v(f"run(terms={terms}, kwargs={kwargs})")
|
||||
|
||||
if not terms or not isinstance(terms, list) or not terms[0]:
|
||||
raise AnsibleError("At least one Vault entry must be specified.")
|
||||
|
||||
field = kwargs.get("field", "").strip()
|
||||
parse_json = kwargs.get("parse_json", False)
|
||||
strict_json = kwargs.get("strict_json", False)
|
||||
use_index = kwargs.get("use_index", False)
|
||||
|
||||
index_data: Optional[Dict[str, Any]] = None
|
||||
if use_index:
|
||||
index_data = self._read_index()
|
||||
if index_data is None:
|
||||
index_data = self._fetch_index()
|
||||
display.v(f"Index has {len(index_data['entries'])} entries")
|
||||
|
||||
results: List[Any] = []
|
||||
|
||||
for term in terms:
|
||||
if isinstance(term, dict):
|
||||
name = term.get("name", "").strip()
|
||||
folder = term.get("folder", "").strip()
|
||||
user = term.get("user", "").strip()
|
||||
raw_entry = f"{name}|{folder}|{user}"
|
||||
else:
|
||||
name = term.strip()
|
||||
folder = ""
|
||||
user = ""
|
||||
raw_entry = name
|
||||
|
||||
if not name:
|
||||
continue
|
||||
|
||||
entry_id = name # fallback: use directly
|
||||
|
||||
if index_data:
|
||||
matches = [
|
||||
e
|
||||
for e in index_data["entries"]
|
||||
if e["name"] == name
|
||||
and (not folder or e["folder"] == folder)
|
||||
and (not user or e["user"] == user)
|
||||
]
|
||||
|
||||
if not matches:
|
||||
raise AnsibleError(
|
||||
f"No matching entry found in index for: {raw_entry}"
|
||||
)
|
||||
|
||||
if len(matches) > 1:
|
||||
raise AnsibleError(
|
||||
f"Multiple matches found in index for: {raw_entry}"
|
||||
)
|
||||
|
||||
entry_id = matches[0]["id"]
|
||||
display.v(f"Resolved {raw_entry} → id={entry_id}")
|
||||
|
||||
cache_key = self._cache_key(entry_id, field)
|
||||
cached = self._read_cache(cache_key)
|
||||
|
||||
if cached is not None:
|
||||
value = cached
|
||||
display.v(f"Cache HIT for {entry_id}")
|
||||
else:
|
||||
value = self._fetch_rbw(entry_id, field)
|
||||
self._write_cache(cache_key, value)
|
||||
display.v(f"Cache MISS for {entry_id} — fetched with rbw")
|
||||
|
||||
if parse_json:
|
||||
try:
|
||||
results.append(json.loads(value))
|
||||
except json.decoder.JSONDecodeError as e:
|
||||
if strict_json:
|
||||
raise AnsibleError(
|
||||
f"JSON parsing failed for entry '{entry_id}': {e}"
|
||||
)
|
||||
else:
|
||||
display.v(
|
||||
f"Warning: Content of '{entry_id}' is not valid JSON."
|
||||
)
|
||||
results.append({})
|
||||
except Exception as e:
|
||||
raise AnsibleError(f"Unexpected error parsing '{entry_id}': {e}")
|
||||
else:
|
||||
results.append(value)
|
||||
|
||||
return results
|
||||
|
||||
def _fetch_rbw(self, entry_id: str, field: str) -> str:
|
||||
"""
|
||||
Call the rbw CLI to retrieve a specific entry or entry field.
|
||||
|
||||
Args:
|
||||
entry_id: The rbw entry identifier (UUID or resolved ID from index).
|
||||
field: Optional field name to retrieve (e.g. "username", "password").
|
||||
If empty, the default value for the entry is returned.
|
||||
|
||||
Returns:
|
||||
str: The trimmed stdout of the rbw command, representing the secret value.
|
||||
|
||||
Raises:
|
||||
AnsibleError: If the rbw command exits with a non-zero status.
|
||||
"""
|
||||
cmd = ["rbw", "get"]
|
||||
if field:
|
||||
cmd.extend(["--field", field])
|
||||
cmd.append(entry_id)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
err_msg = e.stderr.strip() or e.stdout.strip()
|
||||
raise AnsibleError(f"Error retrieving Vault entry '{entry_id}': {err_msg}")
|
||||
|
||||
def _fetch_index(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch the rbw index and persist it in the local cache.
|
||||
|
||||
The index contains a list of entries, each with id, user, name, and folder.
|
||||
It is stored on disk together with a timestamp and used for subsequent
|
||||
lookups until it expires.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with:
|
||||
* "timestamp" (float): Unix timestamp when the index was fetched.
|
||||
* "entries" (list[dict]): List of index entries.
|
||||
|
||||
Raises:
|
||||
AnsibleError: If the rbw index command fails.
|
||||
"""
|
||||
cmd = ["rbw", "list", "--fields", "id,user,name,folder"]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
lines = [
|
||||
line.strip() for line in result.stdout.splitlines() if line.strip()
|
||||
]
|
||||
|
||||
headers = ["id", "user", "name", "folder"]
|
||||
|
||||
entries: List[Dict[str, str]] = []
|
||||
for line in lines:
|
||||
parts = line.split("\t")
|
||||
if len(parts) < len(headers):
|
||||
parts += [""] * (len(headers) - len(parts))
|
||||
entry = dict(zip(headers, parts))
|
||||
entries.append(entry)
|
||||
|
||||
index_payload: Dict[str, Any] = {
|
||||
"timestamp": time.time(),
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
self._write_index(index_payload)
|
||||
return index_payload
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
err_msg = e.stderr.strip() or e.stdout.strip()
|
||||
raise AnsibleError(f"Error retrieving rbw index: {err_msg}")
|
||||
|
||||
def _index_path(self) -> str:
|
||||
"""
|
||||
Compute the absolute file path of the index cache.
|
||||
|
||||
Returns:
|
||||
str: The full path to the index cache file.
|
||||
"""
|
||||
return os.path.join(self.cache_directory, "index.json")
|
||||
|
||||
def _read_index(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Read the rbw index from the cache if it exists and is still valid.
|
||||
|
||||
The index is considered valid if its age is less than or equal to
|
||||
CACHE_TTL. If the index is expired or cannot be read, it is removed.
|
||||
|
||||
Returns:
|
||||
dict | None: The cached index payload if available and not expired,
|
||||
otherwise None.
|
||||
|
||||
"""
|
||||
path = self._index_path()
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
age = time.time() - payload["timestamp"]
|
||||
if age <= self.CACHE_TTL:
|
||||
return payload
|
||||
else:
|
||||
os.remove(path)
|
||||
return None
|
||||
except Exception as e:
|
||||
display.v(f"Index cache read error: {e}")
|
||||
return None
|
||||
|
||||
def _write_index(self, index_payload: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Persist the rbw index payload to disk.
|
||||
|
||||
Args:
|
||||
index_payload: The payload containing the index data and timestamp.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
path = self._index_path()
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(index_payload, f)
|
||||
except Exception as e:
|
||||
display.v(f"Index cache write error: {e}")
|
||||
|
||||
def _cache_key(self, entry_id: str, field: str) -> str:
|
||||
"""
|
||||
Create a deterministic cache key for a given entry and field.
|
||||
|
||||
Args:
|
||||
entry_id: The rbw entry identifier.
|
||||
field: The requested field name. May be an empty string.
|
||||
|
||||
Returns:
|
||||
str: A SHA-256 hash hex digest representing the cache key.
|
||||
"""
|
||||
raw_key = f"{entry_id}|{field}".encode("utf-8")
|
||||
return hashlib.sha256(raw_key).hexdigest()
|
||||
|
||||
def _cache_path(self, key: str) -> str:
|
||||
"""
|
||||
Compute the absolute file path for a given cache key.
|
||||
|
||||
Args:
|
||||
key: The cache key as returned by `_cache_key`.
|
||||
|
||||
Returns:
|
||||
str: The full path to the cache file for the given key.
|
||||
"""
|
||||
return os.path.join(self.cache_directory, key + ".json")
|
||||
|
||||
def _read_cache(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Read a cached value for the given key if present and not expired.
|
||||
|
||||
The cache entry is considered valid if its age is less than or equal to
|
||||
CACHE_TTL. If the entry is expired or cannot be read, it is removed.
|
||||
|
||||
Args:
|
||||
key: The cache key as returned by `_cache_key`.
|
||||
|
||||
Returns:
|
||||
str | None: The cached value if present and not expired,
|
||||
otherwise None.
|
||||
"""
|
||||
path = self._cache_path(key)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
age = time.time() - payload["timestamp"]
|
||||
if age <= self.CACHE_TTL:
|
||||
return payload["value"]
|
||||
else:
|
||||
os.remove(path)
|
||||
return None
|
||||
except Exception as e:
|
||||
display.v(f"Cache read error for key {key}: {e}")
|
||||
return None
|
||||
|
||||
def _write_cache(self, key: str, value: str) -> None:
|
||||
"""
|
||||
Write a value to the cache using the given key.
|
||||
|
||||
Args:
|
||||
key: The cache key as returned by `_cache_key`.
|
||||
value: The value to be cached, typically the raw secret string.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
path = self._cache_path(key)
|
||||
payload = {
|
||||
"timestamp": time.time(),
|
||||
"value": value,
|
||||
}
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f)
|
||||
except Exception as e:
|
||||
display.v(f"Cache write error for key {key}: {e}")
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2025, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
import datetime
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def cache_valid_old(
|
||||
module, cache_file_name, cache_minutes=60, cache_file_remove=True
|
||||
) -> bool:
|
||||
"""
|
||||
read local file and check the creation time against local time
|
||||
|
||||
returns 'False' when cache are out of sync
|
||||
"""
|
||||
out_of_cache = False
|
||||
|
||||
if os.path.isfile(cache_file_name):
|
||||
module.debug(msg=f"read cache file '{cache_file_name}'")
|
||||
now = datetime.datetime.now()
|
||||
creation_time = datetime.datetime.fromtimestamp(
|
||||
os.path.getctime(cache_file_name)
|
||||
)
|
||||
diff = now - creation_time
|
||||
# define the difference from now to the creation time in minutes
|
||||
cached_time = diff.total_seconds() / 60
|
||||
out_of_cache = cached_time > cache_minutes
|
||||
|
||||
module.debug(msg=f" - now {now}")
|
||||
module.debug(msg=f" - creation_time {creation_time}")
|
||||
module.debug(msg=f" - cached since {cached_time}")
|
||||
module.debug(msg=f" - out of cache {out_of_cache}")
|
||||
|
||||
if out_of_cache and cache_file_remove:
|
||||
os.remove(cache_file_name)
|
||||
else:
|
||||
out_of_cache = True
|
||||
|
||||
module.debug(msg="cache is {0}valid".format("not " if out_of_cache else ""))
|
||||
|
||||
return out_of_cache
|
||||
|
||||
|
||||
def cache_valid(
|
||||
module: Any,
|
||||
cache_file_name: str,
|
||||
cache_minutes: int = 60,
|
||||
cache_file_remove: bool = True,
|
||||
) -> bool:
|
||||
"""
|
||||
Prüft, ob eine Cache-Datei älter als `cache_minutes` ist oder gar nicht existiert.
|
||||
|
||||
Gibt True zurück, wenn der Cache abgelaufen ist (oder nicht existiert) und
|
||||
ggf. gelöscht wurde (wenn cache_file_remove=True). Sonst False.
|
||||
|
||||
:param module: Ansible-Modulobjekt, um Debug-Logs zu schreiben.
|
||||
:param cache_file_name: Pfad zur Cache-Datei (String).
|
||||
:param cache_minutes: Maximales Alter in Minuten, danach gilt der Cache als ungültig.
|
||||
:param cache_file_remove: Ob abgelaufene Cache-Datei gelöscht werden soll.
|
||||
"""
|
||||
path = Path(cache_file_name)
|
||||
|
||||
# Existiert die Datei nicht? → Cache gilt sofort als ungültig
|
||||
if not path.is_file():
|
||||
module.debug(msg=f"Cache-Datei '{cache_file_name}' existiert nicht → ungültig")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Verwende mtime (Zeitpunkt der letzten Inhaltsänderung) statt ctime,
|
||||
# denn ctime kann sich auch durch Metadaten-Änderungen verschieben.
|
||||
modification_time = datetime.datetime.fromtimestamp(path.stat().st_mtime)
|
||||
except OSError as e:
|
||||
module.debug(
|
||||
msg=f"Fehler beim Lesen der Modifikationszeit von '{cache_file_name}': {e} → Cache ungültig"
|
||||
)
|
||||
return True
|
||||
|
||||
now = datetime.datetime.now()
|
||||
diff_minutes = (now - modification_time).total_seconds() / 60
|
||||
is_expired = diff_minutes > cache_minutes
|
||||
|
||||
module.debug(
|
||||
msg=f"Cache-Datei '{cache_file_name}' gefunden. Letzte Änderung: {modification_time.isoformat()}"
|
||||
)
|
||||
module.debug(msg=f" → Jetzt: {now.isoformat()}")
|
||||
module.debug(
|
||||
msg=f" → Alter: {diff_minutes:.2f} Minuten (Limit: {cache_minutes} Minuten)"
|
||||
)
|
||||
module.debug(msg=f" → Abgelaufen: {is_expired}")
|
||||
|
||||
# Wenn abgelaufen und löschen erwünscht, versuche die Datei zu entfernen
|
||||
if is_expired and cache_file_remove:
|
||||
try:
|
||||
path.unlink()
|
||||
module.debug(
|
||||
msg=f" → Alte Cache-Datei '{cache_file_name}' wurde gelöscht."
|
||||
)
|
||||
except OSError as e:
|
||||
module.debug(
|
||||
msg=f" → Fehler beim Löschen der Cache-Datei '{cache_file_name}': {e}"
|
||||
)
|
||||
|
||||
return is_expired
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
ChecksumValidationResult = Tuple[bool, str, Optional[str]]
|
||||
ChecksumValidationFromFileResult = Tuple[bool, Optional[str], str]
|
||||
|
||||
|
||||
class Checksum:
|
||||
"""
|
||||
Helper class for calculating and validating checksums.
|
||||
|
||||
This class is typically used in an Ansible-module context and keeps a reference
|
||||
to the calling module for optional logging.
|
||||
|
||||
Attributes:
|
||||
module: An Ansible-like module object. Currently only stored for potential logging.
|
||||
"""
|
||||
|
||||
def __init__(self, module: Any) -> None:
|
||||
"""
|
||||
Initialize the checksum helper.
|
||||
|
||||
Args:
|
||||
module: An Ansible-like module instance.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
self.module = module
|
||||
|
||||
def checksum(self, plaintext: Any, algorithm: str = "sha256") -> str:
|
||||
"""
|
||||
Compute a checksum for arbitrary input data.
|
||||
|
||||
The input is normalized via :meth:`_harmonize_data` and then hashed with
|
||||
the requested algorithm.
|
||||
|
||||
Args:
|
||||
plaintext: Data to hash. Commonly a string, dict, or list.
|
||||
algorithm: Hashlib algorithm name (e.g. "md5", "sha256", "sha512").
|
||||
Defaults to "sha256".
|
||||
|
||||
Returns:
|
||||
str: Hex digest of the computed checksum.
|
||||
|
||||
Raises:
|
||||
ValueError: If the hash algorithm is not supported by hashlib.
|
||||
AttributeError: If the normalized value does not support ``encode("utf-8")``.
|
||||
"""
|
||||
_data = self._harmonize_data(plaintext)
|
||||
checksum = hashlib.new(algorithm)
|
||||
checksum.update(_data.encode("utf-8"))
|
||||
|
||||
return checksum.hexdigest()
|
||||
|
||||
def validate(
|
||||
self, checksum_file: str, data: Any = None
|
||||
) -> ChecksumValidationResult:
|
||||
"""
|
||||
Validate (and optionally reset) a checksum file against given data.
|
||||
|
||||
Behavior:
|
||||
- If ``data`` is ``None`` and ``checksum_file`` exists, the checksum file is removed.
|
||||
- If ``checksum_file`` exists, its first line is treated as the previous checksum.
|
||||
- A new checksum is computed from ``data`` and compared to the previous one.
|
||||
|
||||
Args:
|
||||
checksum_file: Path to the checksum file holding a single checksum line.
|
||||
data: Input data to hash and compare. Can be string/dict/list or another type
|
||||
supported by :meth:`_harmonize_data`. If ``None``, the checksum file may be removed.
|
||||
|
||||
Returns:
|
||||
tuple[bool, str, Optional[str]]: (changed, checksum, old_checksum)
|
||||
changed: True if the checksum differs from the stored value (or no stored value exists).
|
||||
checksum: Newly computed checksum hex digest.
|
||||
old_checksum: Previously stored checksum (first line), or ``None`` if not available.
|
||||
|
||||
Raises:
|
||||
ValueError: If the hash algorithm used internally is unsupported.
|
||||
AttributeError: If the normalized data does not support ``encode("utf-8")``.
|
||||
"""
|
||||
# self.module.log(msg=f" - checksum_file '{checksum_file}'")
|
||||
old_checksum: Optional[str] = None
|
||||
|
||||
if not isinstance(data, str) or not isinstance(data, dict):
|
||||
# self.module.log(msg=f" - {type(data)} {len(data)}")
|
||||
if data is None and os.path.exists(checksum_file):
|
||||
os.remove(checksum_file)
|
||||
|
||||
if os.path.exists(checksum_file):
|
||||
with open(checksum_file, "r") as f:
|
||||
old_checksum = f.readlines()[0].strip()
|
||||
|
||||
_data = self._harmonize_data(data)
|
||||
checksum = self.checksum(_data)
|
||||
changed = not (old_checksum == checksum)
|
||||
|
||||
return (changed, checksum, old_checksum)
|
||||
|
||||
def validate_from_file(
|
||||
self, checksum_file: str, data_file: str
|
||||
) -> ChecksumValidationFromFileResult:
|
||||
"""
|
||||
Validate a checksum file against the contents of another file.
|
||||
|
||||
Behavior:
|
||||
- If ``data_file`` does not exist but ``checksum_file`` exists, the checksum file is removed.
|
||||
- If ``checksum_file`` exists, its first line is treated as the previous checksum.
|
||||
- A checksum is computed from ``data_file`` and compared to the previous one.
|
||||
|
||||
Args:
|
||||
checksum_file: Path to the checksum file holding a single checksum line.
|
||||
data_file: Path to the file whose contents should be hashed.
|
||||
|
||||
Returns:
|
||||
tuple[bool, Optional[str], str]: (changed, checksum_from_file, old_checksum)
|
||||
changed: True if the checksum differs from the stored value.
|
||||
checksum_from_file: Hex digest checksum of ``data_file`` contents, or ``None`` if
|
||||
``data_file`` is not a file.
|
||||
old_checksum: Previously stored checksum (first line), or empty string if not available.
|
||||
"""
|
||||
# self.module.log(msg=f" - checksum_file '{checksum_file}'")
|
||||
old_checksum = ""
|
||||
|
||||
if not os.path.exists(data_file) and os.path.exists(checksum_file):
|
||||
"""
|
||||
remove checksum_file, when data_file are removed
|
||||
"""
|
||||
os.remove(checksum_file)
|
||||
|
||||
if os.path.exists(checksum_file):
|
||||
with open(checksum_file, "r", encoding="utf-8") as f:
|
||||
old_checksum = f.readlines()[0].strip()
|
||||
|
||||
checksum_from_file = self.checksum_from_file(data_file)
|
||||
changed = not (old_checksum == checksum_from_file)
|
||||
|
||||
return (changed, checksum_from_file, old_checksum)
|
||||
|
||||
def checksum_from_file(
|
||||
self,
|
||||
path: str,
|
||||
read_chunksize: int = 65536,
|
||||
algorithm: str = "sha256",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Compute checksum of a file's contents.
|
||||
|
||||
The file is read in chunks to avoid loading the full file into memory.
|
||||
A small ``time.sleep(0)`` is performed per chunk (noop in most cases).
|
||||
|
||||
Args:
|
||||
path: Path to the file.
|
||||
read_chunksize: Maximum number of bytes read at once. Defaults to 65536 (64 KiB).
|
||||
algorithm: Hash algorithm name to use. Defaults to "sha256".
|
||||
|
||||
Returns:
|
||||
Optional[str]: Hex digest string of the checksum if ``path`` is a file,
|
||||
otherwise ``None``.
|
||||
|
||||
Raises:
|
||||
ValueError: If the hash algorithm is not supported by hashlib.
|
||||
OSError: If the file cannot be opened/read.
|
||||
"""
|
||||
if os.path.isfile(path):
|
||||
checksum = hashlib.new(algorithm) # Raises appropriate exceptions.
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(read_chunksize), b""):
|
||||
checksum.update(chunk)
|
||||
# Release greenthread, if greenthreads are not used it is a noop.
|
||||
time.sleep(0)
|
||||
|
||||
return checksum.hexdigest()
|
||||
|
||||
return None
|
||||
|
||||
def write_checksum(self, checksum_file: str, checksum: Any) -> None:
|
||||
"""
|
||||
Write a checksum value to disk (single line with trailing newline).
|
||||
|
||||
Args:
|
||||
checksum_file: Destination path for the checksum file.
|
||||
checksum: Checksum value to write. Only written if it is truthy and its string
|
||||
representation is not empty.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
OSError: If the file cannot be opened/written.
|
||||
"""
|
||||
if checksum and len(str(checksum)) != 0:
|
||||
with open(checksum_file, "w", encoding="utf-8") as f:
|
||||
f.write(checksum + "\n")
|
||||
|
||||
def _harmonize_data(self, data: Any) -> Any:
|
||||
"""
|
||||
Normalize data into a stable representation for hashing.
|
||||
|
||||
Rules:
|
||||
- dict: JSON serialized with sorted keys
|
||||
- list: Concatenation of stringified elements
|
||||
- str: returned as-is
|
||||
- other: returns ``data.copy()``
|
||||
|
||||
Args:
|
||||
data: Input data.
|
||||
|
||||
Returns:
|
||||
Any: Normalized representation. For typical input types (dict/list/str) this
|
||||
is a string. For other types, the return value depends on ``data.copy()``.
|
||||
|
||||
Raises:
|
||||
AttributeError: If ``data`` is not dict/list/str and does not implement ``copy()``.
|
||||
TypeError: If JSON serialization fails for dictionaries.
|
||||
"""
|
||||
# self.module.log(msg=f" - type before: '{type(data)}'")
|
||||
if isinstance(data, dict):
|
||||
_data = json.dumps(data, sort_keys=True)
|
||||
elif isinstance(data, list):
|
||||
_data = "".join(str(x) for x in data)
|
||||
elif isinstance(data, str):
|
||||
_data = data
|
||||
else:
|
||||
_data = data.copy()
|
||||
|
||||
# self.module.log(msg=f" - type after : '{type(_data)}'")
|
||||
return _data
|
||||
|
|
@ -0,0 +1,646 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
try:
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.x509.oid import ExtensionOID
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise RuntimeError(
|
||||
"The 'cryptography' Python library is required to use crypto_utils"
|
||||
) from exc
|
||||
|
||||
|
||||
class OpenSSLObjectError(Exception):
|
||||
"""
|
||||
Einfacher Fehler-Typ, um Parsing-/Krypto-Probleme konsistent zu signalisieren.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Hilfsfunktionen für Zeitverarbeitung
|
||||
# ======================================================================
|
||||
|
||||
_ASN1_TIME_FORMAT = "%Y%m%d%H%M%SZ"
|
||||
|
||||
|
||||
def _to_utc_naive(dt: datetime) -> datetime:
|
||||
"""
|
||||
Konvertiert ein datetime-Objekt nach UTC und entfernt tzinfo.
|
||||
Naive Datumswerte werden als UTC interpretiert.
|
||||
"""
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=None)
|
||||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _format_asn1_time(dt: Optional[datetime]) -> Optional[str]:
|
||||
"""
|
||||
datetime -> ASN.1 TIME (YYYYMMDDHHMMSSZ) oder None.
|
||||
"""
|
||||
if dt is None:
|
||||
return None
|
||||
dt_utc_naive = _to_utc_naive(dt)
|
||||
return dt_utc_naive.strftime(_ASN1_TIME_FORMAT)
|
||||
|
||||
|
||||
def _parse_asn1_time(value: str, input_name: str) -> datetime:
|
||||
"""
|
||||
ASN.1 TIME (YYYYMMDDHHMMSSZ) -> datetime (naiv, UTC).
|
||||
"""
|
||||
try:
|
||||
dt = datetime.strptime(value, _ASN1_TIME_FORMAT)
|
||||
except ValueError as exc:
|
||||
raise OpenSSLObjectError(
|
||||
f"{input_name!r} is not a valid ASN.1 TIME value: {value!r}"
|
||||
) from exc
|
||||
return dt
|
||||
|
||||
|
||||
def _parse_relative_spec(spec: str, input_name: str) -> timedelta:
|
||||
"""
|
||||
Parsen des relativen Formats (z.B. +32w1d2h3m4s) in ein timedelta.
|
||||
|
||||
Unterstützte Einheiten:
|
||||
- w: Wochen
|
||||
- d: Tage
|
||||
- h: Stunden
|
||||
- m: Minuten
|
||||
- s: Sekunden
|
||||
"""
|
||||
weeks = days = hours = minutes = seconds = 0
|
||||
pos = 0
|
||||
length = len(spec)
|
||||
|
||||
while pos < length:
|
||||
start = pos
|
||||
while pos < length and spec[pos].isdigit():
|
||||
pos += 1
|
||||
if start == pos:
|
||||
raise OpenSSLObjectError(
|
||||
f"Invalid relative time spec in {input_name!r}: {spec!r}"
|
||||
)
|
||||
number = int(spec[start:pos])
|
||||
|
||||
if pos >= length:
|
||||
raise OpenSSLObjectError(
|
||||
f"Missing time unit in relative time spec for {input_name!r}: {spec!r}"
|
||||
)
|
||||
|
||||
unit = spec[pos]
|
||||
pos += 1
|
||||
|
||||
if unit == "w":
|
||||
weeks += number
|
||||
elif unit == "d":
|
||||
days += number
|
||||
elif unit == "h":
|
||||
hours += number
|
||||
elif unit == "m":
|
||||
minutes += number
|
||||
elif unit == "s":
|
||||
seconds += number
|
||||
else:
|
||||
raise OpenSSLObjectError(
|
||||
f"Unknown time unit {unit!r} in relative time spec for {input_name!r}: {spec!r}"
|
||||
)
|
||||
|
||||
return timedelta(
|
||||
weeks=weeks,
|
||||
days=days,
|
||||
hours=hours,
|
||||
minutes=minutes,
|
||||
seconds=seconds,
|
||||
)
|
||||
|
||||
|
||||
def get_relative_time_option(
|
||||
value: Optional[str],
|
||||
input_name: str,
|
||||
with_timezone: bool = False,
|
||||
now: Optional[datetime] = None,
|
||||
) -> Optional[datetime]:
|
||||
"""
|
||||
Grob kompatibel zu community.crypto._time.get_relative_time_option.
|
||||
|
||||
Unterstützte Werte:
|
||||
- None / "" / "none" => None
|
||||
- ASN.1 TIME: "YYYYMMDDHHMMSSZ"
|
||||
- relative Zeiten: "[+-]timespec" mit w/d/h/m/s (z.B. "+32w1d2h")
|
||||
- "always" / "forever"
|
||||
|
||||
Hinweis:
|
||||
- with_timezone=True gibt tz-aware UTC-datetime zurück.
|
||||
- with_timezone=False (Default) gibt naives datetime zurück.
|
||||
|
||||
Rückgabe:
|
||||
- datetime (UTC, tz-aware oder naiv) oder None.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
value = str(value).strip()
|
||||
if not value or value.lower() == "none":
|
||||
return None
|
||||
|
||||
# Sonderfälle: always / forever
|
||||
if value.lower() == "always":
|
||||
dt = datetime(1970, 1, 1, 0, 0, 1, tzinfo=timezone.utc)
|
||||
return dt if with_timezone else dt.replace(tzinfo=None)
|
||||
|
||||
if value.lower() == "forever":
|
||||
dt = datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
|
||||
return dt if with_timezone else dt.replace(tzinfo=None)
|
||||
|
||||
# Relative Zeitangaben
|
||||
if value[0] in "+-":
|
||||
sign = 1 if value[0] == "+" else -1
|
||||
spec = value[1:]
|
||||
delta = _parse_relative_spec(spec, input_name)
|
||||
|
||||
if now is None:
|
||||
# wir rechnen intern in UTC
|
||||
now = datetime.utcnow().replace(tzinfo=timezone.utc)
|
||||
|
||||
dt = now + sign * delta
|
||||
|
||||
return dt if with_timezone else dt.replace(tzinfo=None)
|
||||
|
||||
# Absolute Zeit – zuerst ASN.1 TIME probieren
|
||||
try:
|
||||
dt = _parse_asn1_time(value, input_name)
|
||||
|
||||
# _parse_asn1_time gibt naiv (UTC) zurück
|
||||
if with_timezone:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
except OpenSSLObjectError:
|
||||
# als Fallback ein paar ISO-Formate unterstützen
|
||||
pass
|
||||
|
||||
# einfache ISO-Formate
|
||||
# ISO-Formate: YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS, YYYY-MM-DD HH:MM:SS
|
||||
iso_formats = [
|
||||
"%Y-%m-%d",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
]
|
||||
for fmt in iso_formats:
|
||||
try:
|
||||
dt = datetime.strptime(value, fmt)
|
||||
# interpretieren als UTC
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt if with_timezone else dt.replace(tzinfo=None)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Wenn alles scheitert, Fehler werfen
|
||||
raise OpenSSLObjectError(f"Invalid time format for {input_name!r}: {value!r}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# CRL-Parsing (Ersatz für community.crypto.module_backends.crl_info.get_crl_info)
|
||||
# ======================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class RevokedCertificateInfo:
|
||||
serial_number: int
|
||||
revocation_date: Optional[str]
|
||||
reason: Optional[str] = None
|
||||
reason_critical: Optional[bool] = None
|
||||
invalidity_date: Optional[str] = None
|
||||
invalidity_date_critical: Optional[bool] = None
|
||||
issuer: Optional[List[str]] = None
|
||||
issuer_critical: Optional[bool] = None
|
||||
|
||||
|
||||
def _load_crl_from_bytes(data: bytes) -> (x509.CertificateRevocationList, str):
|
||||
"""
|
||||
Lädt eine CRL aus PEM- oder DER-Daten und gibt (crl_obj, format) zurück.
|
||||
|
||||
format: "pem" oder "der"
|
||||
"""
|
||||
if not isinstance(data, (bytes, bytearray)):
|
||||
raise OpenSSLObjectError("CRL data must be bytes")
|
||||
|
||||
# Einfache Heuristik: BEGIN-Header => PEM
|
||||
try:
|
||||
if b"-----BEGIN" in data:
|
||||
crl = x509.load_pem_x509_crl(data, default_backend())
|
||||
return crl, "pem"
|
||||
else:
|
||||
crl = x509.load_der_x509_crl(data, default_backend())
|
||||
return crl, "der"
|
||||
|
||||
except Exception as exc:
|
||||
raise OpenSSLObjectError(f"Failed to parse CRL data: {exc}") from exc
|
||||
|
||||
|
||||
def get_crl_info(
|
||||
module,
|
||||
data: bytes,
|
||||
list_revoked_certificates: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
CRL-Informationen ähnlich zu community.crypto.module_backends.crl_info.get_crl_info.
|
||||
|
||||
Gibt ein Dict zurück mit u.a.:
|
||||
- format: "pem" | "der"
|
||||
- digest: Signaturalgorithmus (z.B. "sha256")
|
||||
- last_update: ASN.1 TIME (UTC)
|
||||
- next_update: ASN.1 TIME (UTC) oder None
|
||||
- revoked_certificates: Liste von Dicts (wenn list_revoked_certificates=True)
|
||||
"""
|
||||
crl, crl_format = _load_crl_from_bytes(data)
|
||||
|
||||
# Signaturalgorithmus
|
||||
try:
|
||||
digest = crl.signature_hash_algorithm.name
|
||||
except Exception:
|
||||
digest = None
|
||||
|
||||
# Zeitstempel
|
||||
# cryptography hat je nach Version last_update(_utc)/next_update(_utc)
|
||||
last_update_raw = getattr(
|
||||
crl,
|
||||
"last_update",
|
||||
getattr(crl, "last_update_utc", None),
|
||||
)
|
||||
next_update_raw = getattr(
|
||||
crl,
|
||||
"next_update",
|
||||
getattr(crl, "next_update_utc", None),
|
||||
)
|
||||
|
||||
last_update_asn1 = _format_asn1_time(last_update_raw) if last_update_raw else None
|
||||
next_update_asn1 = _format_asn1_time(next_update_raw) if next_update_raw else None
|
||||
|
||||
# Issuer als einfaches Dict (nicht 1:1 wie community.crypto, aber nützlich)
|
||||
issuer = {}
|
||||
try:
|
||||
for attr in crl.issuer:
|
||||
# attr.oid._name ist intern, aber meist "commonName", "organizationName", ...
|
||||
key = getattr(attr.oid, "_name", attr.oid.dotted_string)
|
||||
issuer[key] = attr.value
|
||||
except Exception:
|
||||
issuer = {}
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"format": crl_format,
|
||||
"digest": digest,
|
||||
"issuer": issuer,
|
||||
"last_update": last_update_asn1,
|
||||
"next_update": next_update_asn1,
|
||||
}
|
||||
|
||||
# Liste der widerrufenen Zertifikate
|
||||
if list_revoked_certificates:
|
||||
revoked_list: List[Dict[str, Any]] = []
|
||||
for r in crl:
|
||||
info = RevokedCertificateInfo(
|
||||
serial_number=r.serial_number,
|
||||
revocation_date=_format_asn1_time(r.revocation_date),
|
||||
)
|
||||
|
||||
# Extensions auswerten (Reason, InvalidityDate, CertificateIssuer)
|
||||
for ext in r.extensions:
|
||||
try:
|
||||
if ext.oid == ExtensionOID.CRL_REASON:
|
||||
# ext.value.reason.name ist Enum-Name (z.B. "KEY_COMPROMISE")
|
||||
info.reason = ext.value.reason.name.lower()
|
||||
info.reason_critical = ext.critical
|
||||
elif ext.oid == ExtensionOID.INVALIDITY_DATE:
|
||||
info.invalidity_date = _format_asn1_time(ext.value)
|
||||
info.invalidity_date_critical = ext.critical
|
||||
elif ext.oid == ExtensionOID.CERTIFICATE_ISSUER:
|
||||
# Liste von GeneralNames in Strings umwandeln
|
||||
info.issuer = [str(g) for g in ext.value]
|
||||
info.issuer_critical = ext.critical
|
||||
except Exception:
|
||||
# Fehler in einzelnen Extensions ignorieren, CRL trotzdem weiter auswerten
|
||||
continue
|
||||
|
||||
revoked_list.append(info.__dict__)
|
||||
|
||||
result["revoked_certificates"] = revoked_list
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Zertifikats-Parsing (Ersatz für CertificateInfoRetrieval)
|
||||
# ======================================================================
|
||||
|
||||
|
||||
def _split_pem_certificates(data: bytes) -> List[bytes]:
|
||||
"""
|
||||
Splittet ein PEM-Blob mit mehreren CERTIFICATE-Objekten in einzelne PEM-Blöcke.
|
||||
"""
|
||||
begin = b"-----BEGIN CERTIFICATE-----"
|
||||
end = b"-----END CERTIFICATE-----"
|
||||
|
||||
parts: List[bytes] = []
|
||||
while True:
|
||||
start = data.find(begin)
|
||||
if start == -1:
|
||||
break
|
||||
stop = data.find(end, start)
|
||||
if stop == -1:
|
||||
break
|
||||
stop = stop + len(end)
|
||||
block = data[start:stop]
|
||||
parts.append(block)
|
||||
data = data[stop:]
|
||||
return parts
|
||||
|
||||
|
||||
def _load_certificates(content: Union[bytes, bytearray, str]) -> List[x509.Certificate]:
|
||||
"""
|
||||
Lädt ein oder mehrere X.509-Zertifikate aus PEM oder DER.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
content_bytes = content.encode("utf-8")
|
||||
elif isinstance(content, (bytes, bytearray)):
|
||||
content_bytes = bytes(content)
|
||||
else:
|
||||
raise OpenSSLObjectError("Certificate content must be bytes or str")
|
||||
|
||||
certs: List[x509.Certificate] = []
|
||||
|
||||
try:
|
||||
if b"-----BEGIN CERTIFICATE-----" in content_bytes:
|
||||
for block in _split_pem_certificates(content_bytes):
|
||||
certs.append(x509.load_pem_x509_certificate(block, default_backend()))
|
||||
else:
|
||||
certs.append(
|
||||
x509.load_der_x509_certificate(content_bytes, default_backend())
|
||||
)
|
||||
except Exception as exc:
|
||||
raise OpenSSLObjectError(f"Failed to parse certificate(s): {exc}") from exc
|
||||
|
||||
if not certs:
|
||||
raise OpenSSLObjectError("No certificate found in content")
|
||||
|
||||
return certs
|
||||
|
||||
|
||||
def _name_to_dict_and_ordered(name: x509.Name) -> (Dict[str, str], List[List[str]]):
|
||||
"""
|
||||
Konvertiert ein x509.Name in
|
||||
- dict: {oid_name: value}
|
||||
- ordered: [[oid_name, value], ...]
|
||||
Letzte Wiederholung gewinnt im Dict (wie x509_certificate_info).
|
||||
"""
|
||||
result: Dict[str, str] = {}
|
||||
ordered: List[List[str]] = []
|
||||
|
||||
for rdn in name.rdns:
|
||||
for attr in rdn:
|
||||
key = getattr(attr.oid, "_name", attr.oid.dotted_string)
|
||||
value = attr.value
|
||||
result[key] = value
|
||||
ordered.append([key, value])
|
||||
|
||||
return result, ordered
|
||||
|
||||
|
||||
def _get_subject_alt_name(
|
||||
cert: x509.Certificate,
|
||||
) -> (Optional[List[str]], Optional[bool]):
|
||||
"""
|
||||
Liest subjectAltName und gibt (liste, critical) zurück.
|
||||
Liste-Elemente sind Strings wie "DNS:example.com", "IP:1.2.3.4".
|
||||
"""
|
||||
try:
|
||||
ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
||||
except x509.ExtensionNotFound:
|
||||
return None, None
|
||||
|
||||
values: List[str] = []
|
||||
for gn in ext.value:
|
||||
# cryptography gibt sinnvolle __str__()-Repräsentationen
|
||||
values.append(str(gn))
|
||||
|
||||
return values, ext.critical
|
||||
|
||||
|
||||
def _compute_fingerprints(cert: x509.Certificate) -> Dict[str, str]:
|
||||
"""
|
||||
Fingerprints des gesamten Zertifikats, für gängige Hashes.
|
||||
Hex mit ":" getrennt (wie community.crypto).
|
||||
"""
|
||||
algorithms = [
|
||||
("sha1", hashes.SHA1()),
|
||||
("sha224", hashes.SHA224()),
|
||||
("sha256", hashes.SHA256()),
|
||||
("sha384", hashes.SHA384()),
|
||||
("sha512", hashes.SHA512()),
|
||||
]
|
||||
result: Dict[str, str] = {}
|
||||
|
||||
for name, algo in algorithms:
|
||||
try:
|
||||
fp_bytes = cert.fingerprint(algo)
|
||||
except Exception:
|
||||
continue
|
||||
result[name] = ":".join(f"{b:02x}" for b in fp_bytes)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class CertificateInfoRetrieval:
|
||||
"""
|
||||
Ersatz für community.crypto CertificateInfoRetrieval.
|
||||
|
||||
Nutzung:
|
||||
cert_info = CertificateInfoRetrieval(
|
||||
module=module,
|
||||
content=data,
|
||||
valid_at=module.params.get("valid_at"),
|
||||
)
|
||||
info = cert_info.get_info(prefer_one_fingerprint=False)
|
||||
|
||||
Wichtige Keys im Rückgabewert:
|
||||
- not_before (ASN.1 TIME)
|
||||
- not_after (ASN.1 TIME)
|
||||
- expired (bool)
|
||||
- subject, subject_ordered
|
||||
- issuer, issuer_ordered
|
||||
- subject_alt_name
|
||||
- fingerprints
|
||||
- valid_at
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
module=None,
|
||||
content: Union[bytes, bytearray, str] = None,
|
||||
valid_at: Optional[Dict[str, str]] = None,
|
||||
) -> None:
|
||||
self.module = module
|
||||
if content is None:
|
||||
raise OpenSSLObjectError("CertificateInfoRetrieval requires 'content'")
|
||||
self._certs: List[x509.Certificate] = _load_certificates(content)
|
||||
self._valid_at_specs: Dict[str, str] = valid_at or {}
|
||||
|
||||
def _get_primary_cert(self) -> x509.Certificate:
|
||||
"""
|
||||
Für deine Nutzung reicht das erste Zertifikat (Leaf).
|
||||
"""
|
||||
return self._certs[0]
|
||||
|
||||
def _compute_valid_at(
|
||||
self,
|
||||
not_before_raw: Optional[datetime],
|
||||
not_after_raw: Optional[datetime],
|
||||
) -> Dict[str, bool]:
|
||||
"""
|
||||
Erzeugt das valid_at-Dict basierend auf self._valid_at_specs.
|
||||
Semantik: gültig, wenn
|
||||
not_before <= t <= not_after
|
||||
(alle Zeiten in UTC).
|
||||
"""
|
||||
result: Dict[str, bool] = {}
|
||||
if not self._valid_at_specs:
|
||||
return result
|
||||
|
||||
# Grenzen in UTC-aware umwandeln
|
||||
nb_utc: Optional[datetime] = None
|
||||
na_utc: Optional[datetime] = None
|
||||
|
||||
if not_before_raw is not None:
|
||||
# _to_utc_naive gibt naive UTC; hier machen wir tz-aware
|
||||
nb_utc = _to_utc_naive(not_before_raw).replace(tzinfo=timezone.utc)
|
||||
if not_after_raw is not None:
|
||||
na_utc = _to_utc_naive(not_after_raw).replace(tzinfo=timezone.utc)
|
||||
|
||||
for name, spec in self._valid_at_specs.items():
|
||||
try:
|
||||
point = get_relative_time_option(
|
||||
value=spec,
|
||||
input_name=f"valid_at[{name}]",
|
||||
with_timezone=True,
|
||||
)
|
||||
except OpenSSLObjectError:
|
||||
# ungültige Zeitangabe → False
|
||||
result[name] = False
|
||||
continue
|
||||
|
||||
if point is None:
|
||||
# None interpretieren wir als "kein Check"
|
||||
result[name] = False
|
||||
continue
|
||||
|
||||
is_valid = True
|
||||
if nb_utc is not None and point < nb_utc:
|
||||
is_valid = False
|
||||
if na_utc is not None and point > na_utc:
|
||||
is_valid = False
|
||||
|
||||
result[name] = is_valid
|
||||
|
||||
return result
|
||||
|
||||
def get_info(self, prefer_one_fingerprint: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Liefert ein Info-Dict.
|
||||
|
||||
prefer_one_fingerprint:
|
||||
- False (Default): 'fingerprints' enthält mehrere Hashes.
|
||||
- True: zusätzlich 'fingerprint' / 'public_key_fingerprint' mit bevorzugtem Algo
|
||||
(sha256, Fallback sha1).
|
||||
"""
|
||||
cert = self._get_primary_cert()
|
||||
|
||||
# Zeit
|
||||
not_before_raw = getattr(
|
||||
cert,
|
||||
"not_valid_before",
|
||||
getattr(cert, "not_valid_before_utc", None),
|
||||
)
|
||||
not_after_raw = getattr(
|
||||
cert,
|
||||
"not_valid_after",
|
||||
getattr(cert, "not_valid_after_utc", None),
|
||||
)
|
||||
|
||||
not_before_asn1 = _format_asn1_time(not_before_raw) if not_before_raw else None
|
||||
not_after_asn1 = _format_asn1_time(not_after_raw) if not_after_raw else None
|
||||
|
||||
now_utc_naive = datetime.utcnow()
|
||||
expired = False
|
||||
if not_after_raw is not None:
|
||||
expired = now_utc_naive > _to_utc_naive(not_after_raw)
|
||||
|
||||
# Subject / Issuer
|
||||
subject, subject_ordered = _name_to_dict_and_ordered(cert.subject)
|
||||
issuer, issuer_ordered = _name_to_dict_and_ordered(cert.issuer)
|
||||
|
||||
# SAN
|
||||
subject_alt_name, subject_alt_name_critical = _get_subject_alt_name(cert)
|
||||
|
||||
# Fingerprints
|
||||
fingerprints = _compute_fingerprints(cert)
|
||||
|
||||
# Optional: Public-Key-Fingerprints, wenn du sie brauchst
|
||||
public_key_fingerprints: Dict[str, str] = {}
|
||||
try:
|
||||
pk = cert.public_key()
|
||||
der = pk.public_bytes(
|
||||
encoding=x509.Encoding.DER, # type: ignore[attr-defined]
|
||||
format=x509.PublicFormat.SubjectPublicKeyInfo, # type: ignore[attr-defined]
|
||||
)
|
||||
# kleines Re-Mapping, um _compute_fingerprints wiederzuverwenden
|
||||
pk_cert = x509.load_der_x509_certificate(der, default_backend())
|
||||
public_key_fingerprints = _compute_fingerprints(pk_cert)
|
||||
except Exception:
|
||||
public_key_fingerprints = {}
|
||||
|
||||
# valid_at
|
||||
valid_at = self._compute_valid_at(not_before_raw, not_after_raw)
|
||||
|
||||
info: Dict[str, Any] = {
|
||||
"not_before": not_before_asn1,
|
||||
"not_after": not_after_asn1,
|
||||
"expired": expired,
|
||||
"subject": subject,
|
||||
"subject_ordered": subject_ordered,
|
||||
"issuer": issuer,
|
||||
"issuer_ordered": issuer_ordered,
|
||||
"subject_alt_name": subject_alt_name,
|
||||
"subject_alt_name_critical": subject_alt_name_critical,
|
||||
"fingerprints": fingerprints,
|
||||
"public_key_fingerprints": public_key_fingerprints,
|
||||
"valid_at": valid_at,
|
||||
}
|
||||
|
||||
# prefer_one_fingerprint: wähle "bevorzugten" Algo (sha256, sonst sha1)
|
||||
if prefer_one_fingerprint:
|
||||
|
||||
def _pick_fp(src: Dict[str, str]) -> Optional[str]:
|
||||
if not src:
|
||||
return None
|
||||
for algo in ("sha256", "sha1", "sha512"):
|
||||
if algo in src:
|
||||
return src[algo]
|
||||
# Fallback: irgend einen nehmen
|
||||
return next(iter(src.values()))
|
||||
|
||||
fp = _pick_fp(fingerprints)
|
||||
if fp is not None:
|
||||
info["fingerprint"] = fp
|
||||
|
||||
pk_fp = _pick_fp(public_key_fingerprints)
|
||||
if pk_fp is not None:
|
||||
info["public_key_fingerprint"] = pk_fp
|
||||
|
||||
return info
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,284 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import difflib
|
||||
import itertools
|
||||
import json
|
||||
import textwrap
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class SideBySide:
|
||||
"""
|
||||
Erlaubt nebeneinanderstehende Vergleiche (Side‐by‐Side) von zwei Text-Versionen.
|
||||
Jetzt mit Ausgabe der Zeilennummern bei Änderungen.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
module,
|
||||
left: typing.Union[str, dict, typing.List[str]],
|
||||
right: typing.Union[str, dict, typing.List[str]],
|
||||
):
|
||||
"""
|
||||
:param module: Objekt mit einer .log(...)‐Methode zum Debuggen
|
||||
:param left: Ursprünglicher Text (dict, String oder Liste von Zeilen)
|
||||
:param right: Neuer Text (dict, String oder Liste von Zeilen)
|
||||
"""
|
||||
self.module = module
|
||||
self.default_separator = " | "
|
||||
self.left = self._normalize_input(left)
|
||||
self.right = self._normalize_input(right)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_input(
|
||||
data: typing.Union[str, dict, typing.List[str]],
|
||||
) -> typing.List[str]:
|
||||
"""
|
||||
Konvertiert dict → JSON‐String, String → Liste von Zeilen (splitlines),
|
||||
Liste bleibt unverändert (kopiert).
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
data = json.dumps(data, indent=2)
|
||||
if isinstance(data, str):
|
||||
return data.splitlines()
|
||||
if isinstance(data, list):
|
||||
return data.copy()
|
||||
raise TypeError(f"Erwartet dict, str oder List[str], nicht {type(data)}")
|
||||
|
||||
@staticmethod
|
||||
def _wrap_and_flatten(lines: typing.List[str], width: int) -> typing.List[str]:
|
||||
"""
|
||||
Wrappt jede Zeile auf maximal `width` Zeichen und flacht verschachtelte Listen ab.
|
||||
Leere Zeilen bleiben als [""] erhalten.
|
||||
"""
|
||||
wrapper = textwrap.TextWrapper(
|
||||
width=width,
|
||||
break_long_words=False,
|
||||
replace_whitespace=False,
|
||||
)
|
||||
flat: typing.List[str] = []
|
||||
for line in lines:
|
||||
wrapped = wrapper.wrap(line)
|
||||
if not wrapped:
|
||||
# Wenn wrapper.wrap("") → [] → wir wollen [""] erhalten
|
||||
flat.append("")
|
||||
else:
|
||||
flat.extend(wrapped)
|
||||
return flat
|
||||
|
||||
def side_by_side(
|
||||
self,
|
||||
left: typing.List[str],
|
||||
right: typing.List[str],
|
||||
width: int = 78,
|
||||
as_string: bool = False,
|
||||
separator: typing.Optional[str] = None,
|
||||
left_title: typing.Optional[str] = None,
|
||||
right_title: typing.Optional[str] = None,
|
||||
) -> typing.Union[str, typing.List[str]]:
|
||||
"""
|
||||
Gibt nebeneinanderstehende Zeilen zurück:
|
||||
[Links-Text][Padding][separator][Rechts-Text]
|
||||
|
||||
:param left: Liste von Zeilen (bereits nummeriert/aufbereitet)
|
||||
:param right: Liste von Zeilen (bereits nummeriert/aufbereitet)
|
||||
:param width: Maximale Gesamtbreite (inkl. Separator)
|
||||
:param as_string: True → Rückgabe als einziger String mit "\n"
|
||||
:param separator: String, der links und rechts trennt (Default " | ")
|
||||
:param left_title: Überschrift ganz oben links (optional)
|
||||
:param right_title: Überschrift ganz oben rechts (optional)
|
||||
:return: Entweder List[str] oder ein einziger String
|
||||
"""
|
||||
sep = separator or self.default_separator
|
||||
# Berechne, wie viele Zeichen pro Seite bleiben:
|
||||
side_width = (width - len(sep) - (1 - width % 2)) // 2
|
||||
|
||||
# Wrap/flatten beide Seiten
|
||||
left_wrapped = self._wrap_and_flatten(left, side_width)
|
||||
right_wrapped = self._wrap_and_flatten(right, side_width)
|
||||
|
||||
# Paare bilden, fehlende Zeilen mit leerem String auffüllen
|
||||
pairs = list(itertools.zip_longest(left_wrapped, right_wrapped, fillvalue=""))
|
||||
|
||||
# Falls Überschriften angegeben, voranstellen (einschließlich Unterstreichung)
|
||||
if left_title or right_title:
|
||||
lt = left_title or ""
|
||||
rt = right_title or ""
|
||||
underline = "-" * side_width
|
||||
header = [(lt, rt), (underline, underline)]
|
||||
pairs = header + pairs
|
||||
|
||||
# Jetzt jede Zeile zusammenbauen
|
||||
lines: typing.List[str] = []
|
||||
for l_line, r_line in pairs:
|
||||
l_text = l_line or ""
|
||||
r_text = r_line or ""
|
||||
pad = " " * max(0, side_width - len(l_text))
|
||||
lines.append(f"{l_text}{pad}{sep}{r_text}")
|
||||
|
||||
return "\n".join(lines) if as_string else lines
|
||||
|
||||
def better_diff(
|
||||
self,
|
||||
left: typing.Union[str, typing.List[str]],
|
||||
right: typing.Union[str, typing.List[str]],
|
||||
width: int = 78,
|
||||
as_string: bool = True,
|
||||
separator: typing.Optional[str] = None,
|
||||
left_title: typing.Optional[str] = None,
|
||||
right_title: typing.Optional[str] = None,
|
||||
) -> typing.Union[str, typing.List[str]]:
|
||||
"""
|
||||
Gibt einen Side-by-Side-Diff mit Markierung von gleichen/entfernten/hinzugefügten Zeilen
|
||||
und zusätzlich mit den Zeilennummern in den beiden Input-Dateien.
|
||||
|
||||
Syntax der Prefixe:
|
||||
" " → Zeile vorhanden in beiden Dateien
|
||||
"- " → Zeile nur in der linken Datei
|
||||
"+ " → Zeile nur in der rechten Datei
|
||||
"? " → wird komplett ignoriert
|
||||
|
||||
Die Ausgabe hat Form:
|
||||
<LNr>: <Linke-Zeile> | <RNr>: <Rechte-Zeile>
|
||||
bzw. bei fehlender link/rechts-Zeile:
|
||||
<LNr>: <Linke-Zeile> | -
|
||||
- | <RNr>: <Rechte-Zeile>
|
||||
|
||||
:param left: Ursprungstext als String oder Liste von Zeilen
|
||||
:param right: Vergleichstext als String oder Liste von Zeilen
|
||||
:param width: Gesamtbreite inkl. Separator
|
||||
:param as_string: True, um einen einzelnen String zurückzubekommen
|
||||
:param separator: Trenner (Standard: " | ")
|
||||
:param left_title: Überschrift links (optional)
|
||||
:param right_title: Überschrift rechts (optional)
|
||||
:return: Side-by-Side-Liste oder einzelner String
|
||||
"""
|
||||
# 1) Ausgangsdaten normalisieren
|
||||
l_lines = left.splitlines() if isinstance(left, str) else left.copy()
|
||||
r_lines = right.splitlines() if isinstance(right, str) else right.copy()
|
||||
|
||||
# 2) Differenz-Berechnung
|
||||
differ = difflib.Differ()
|
||||
diffed = list(differ.compare(l_lines, r_lines))
|
||||
|
||||
# 3) Zähler für Zeilennummern
|
||||
left_lineno = 1
|
||||
right_lineno = 1
|
||||
|
||||
left_side: typing.List[str] = []
|
||||
right_side: typing.List[str] = []
|
||||
|
||||
# 4) Durchlaufe alle Diff‐Einträge
|
||||
for entry in diffed:
|
||||
code = entry[:2] # " ", "- ", "+ " oder "? "
|
||||
content = entry[2:] # Der eigentliche Text
|
||||
|
||||
if code == " ":
|
||||
# Zeile existiert in beiden Dateien
|
||||
# Linke Seite: " <LNr>: <Text>"
|
||||
# Rechte Seite: " <RNr>: <Text>"
|
||||
left_side.append(f"{left_lineno:>4}: {content}")
|
||||
right_side.append(f"{right_lineno:>4}: {content}")
|
||||
left_lineno += 1
|
||||
right_lineno += 1
|
||||
|
||||
elif code == "- ":
|
||||
# Nur in der linken Datei
|
||||
left_side.append(f"{left_lineno:>4}: {content}")
|
||||
# Rechts ein Platzhalter "-" ohne Nummer
|
||||
right_side.append(" -")
|
||||
left_lineno += 1
|
||||
|
||||
elif code == "+ ":
|
||||
# Nur in der rechten Datei
|
||||
# Links wird ein "+" angezeigt, ohne LNr
|
||||
left_side.append(" +")
|
||||
right_side.append(f"{right_lineno:>4}: {content}")
|
||||
right_lineno += 1
|
||||
|
||||
# "? " ignorieren wir komplett
|
||||
|
||||
# 5) Nun übergeben wir die nummerierten Zeilen an side_by_side()
|
||||
return self.side_by_side(
|
||||
left=left_side,
|
||||
right=right_side,
|
||||
width=width,
|
||||
as_string=as_string,
|
||||
separator=separator,
|
||||
left_title=left_title,
|
||||
right_title=right_title,
|
||||
)
|
||||
|
||||
def diff(
|
||||
self,
|
||||
width: int = 78,
|
||||
as_string: bool = True,
|
||||
separator: typing.Optional[str] = None,
|
||||
left_title: typing.Optional[str] = None,
|
||||
right_title: typing.Optional[str] = None,
|
||||
) -> typing.Union[str, typing.List[str]]:
|
||||
"""
|
||||
Führt better_diff() für die in __init__ geladenen left/right‐Strings aus.
|
||||
|
||||
:param width: Gesamtbreite inkl. Separator
|
||||
:param as_string: True, um einen einzelnen String zurückzubekommen
|
||||
:param separator: Trenner (Standard: " | ")
|
||||
:param left_title: Überschrift links (optional)
|
||||
:param right_title: Überschrift rechts (optional)
|
||||
|
||||
:return: Side-by-Side-Liste oder einzelner String
|
||||
"""
|
||||
return self.better_diff(
|
||||
left=self.left,
|
||||
right=self.right,
|
||||
width=width,
|
||||
as_string=as_string,
|
||||
separator=separator,
|
||||
left_title=left_title,
|
||||
right_title=right_title,
|
||||
)
|
||||
|
||||
def diff_between_files(
|
||||
self,
|
||||
file_1: typing.Union[str, Path],
|
||||
file_2: typing.Union[str, Path],
|
||||
) -> typing.Union[str, typing.List[str]]:
|
||||
"""
|
||||
Liest zwei Dateien ein und liefert ihren Side-by-Side‐Diff (mit Zeilennummern).
|
||||
|
||||
:param file_1: Pfad zur ersten Datei
|
||||
:param file_2: Pfad zur zweiten Datei
|
||||
:return: Liste der formatierten Zeilen oder einziger String (as_string=True)
|
||||
"""
|
||||
f1 = Path(file_1)
|
||||
f2 = Path(file_2)
|
||||
|
||||
self.module.log(f"diff_between_files({f1}, {f2})")
|
||||
|
||||
if not f1.is_file() or not f2.is_file():
|
||||
self.module.log(f" Eine oder beide Dateien existieren nicht: {f1}, {f2}")
|
||||
# Hier geben wir für den Fall „Datei fehlt“ einfach einen leeren String zurück.
|
||||
return ""
|
||||
|
||||
# Dateien in Listen von Zeilen einlesen (ohne trailing "\n")
|
||||
old_lines = f1.read_text(encoding="utf-8").splitlines()
|
||||
new_lines = f2.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
self.module.log(f" Gelesen: {len(old_lines)} Zeilen aus {f1}")
|
||||
self.module.log(f" Gelesen: {len(new_lines)} Zeilen aus {f2}")
|
||||
|
||||
diffed = self.better_diff(
|
||||
left=old_lines,
|
||||
right=new_lines,
|
||||
width=140,
|
||||
as_string=True,
|
||||
separator=self.default_separator,
|
||||
left_title=" Original",
|
||||
right_title=" Update",
|
||||
)
|
||||
|
||||
# Nur einen Auszug fürs Logging (z.B. erste 200 Zeichen)
|
||||
self.module.log(f" diffed output (gekürzt):\n{diffed[:200]}...")
|
||||
return diffed
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import grp
|
||||
import os
|
||||
import pwd
|
||||
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.lists import find_in_list
|
||||
|
||||
|
||||
def create_directory(directory, owner=None, group=None, mode=None):
|
||||
""" """
|
||||
try:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
if mode is not None:
|
||||
os.chmod(directory, int(mode, base=8))
|
||||
|
||||
if owner is not None:
|
||||
try:
|
||||
owner = pwd.getpwnam(owner).pw_uid
|
||||
except KeyError:
|
||||
owner = int(owner)
|
||||
pass
|
||||
else:
|
||||
owner = 0
|
||||
|
||||
if group is not None:
|
||||
try:
|
||||
group = grp.getgrnam(group).gr_gid
|
||||
except KeyError:
|
||||
group = int(group)
|
||||
pass
|
||||
else:
|
||||
group = 0
|
||||
|
||||
if os.path.isdir(directory) and owner and group:
|
||||
os.chown(directory, int(owner), int(group))
|
||||
|
||||
if os.path.isdir(directory):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def create_directory_tree(directory_tree, current_state):
|
||||
""" """
|
||||
for entry in directory_tree:
|
||||
""" """
|
||||
source = entry.get("source")
|
||||
source_handling = entry.get("source_handling", {})
|
||||
force_create = source_handling.get("create", None)
|
||||
force_owner = source_handling.get("owner", None)
|
||||
force_group = source_handling.get("group", None)
|
||||
force_mode = source_handling.get("mode", None)
|
||||
|
||||
curr = find_in_list(current_state, source)
|
||||
|
||||
current_owner = curr[source].get("owner")
|
||||
current_group = curr[source].get("group")
|
||||
|
||||
# create directory
|
||||
if force_create is not None and not force_create:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
os.makedirs(source, exist_ok=True)
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
# change mode
|
||||
if os.path.isdir(source) and force_mode is not None:
|
||||
if isinstance(force_mode, int):
|
||||
mode = int(str(force_mode), base=8)
|
||||
if isinstance(force_mode, str):
|
||||
mode = int(force_mode, base=8)
|
||||
|
||||
os.chmod(source, mode)
|
||||
|
||||
# change ownership
|
||||
if force_owner is not None or force_group is not None:
|
||||
""" """
|
||||
if os.path.isdir(source):
|
||||
""" """
|
||||
if force_owner is not None:
|
||||
try:
|
||||
force_owner = pwd.getpwnam(str(force_owner)).pw_uid
|
||||
except KeyError:
|
||||
force_owner = int(force_owner)
|
||||
pass
|
||||
elif current_owner is not None:
|
||||
force_owner = current_owner
|
||||
else:
|
||||
force_owner = 0
|
||||
|
||||
if force_group is not None:
|
||||
try:
|
||||
force_group = grp.getgrnam(str(force_group)).gr_gid
|
||||
except KeyError:
|
||||
force_group = int(force_group)
|
||||
pass
|
||||
elif current_group is not None:
|
||||
force_group = current_group
|
||||
else:
|
||||
force_group = 0
|
||||
|
||||
os.chown(source, int(force_owner), int(force_group))
|
||||
|
||||
|
||||
def permstr_to_octal(modestr, umask):
|
||||
"""
|
||||
Convert a Unix permission string (rw-r--r--) into a mode (0644)
|
||||
"""
|
||||
revstr = modestr[::-1]
|
||||
mode = 0
|
||||
for j in range(0, 3):
|
||||
for i in range(0, 3):
|
||||
if revstr[i + 3 * j] in ["r", "w", "x", "s", "t"]:
|
||||
mode += 2 ** (i + 3 * j)
|
||||
|
||||
return mode & ~umask
|
||||
|
||||
|
||||
def current_state(directory):
|
||||
""" """
|
||||
current_owner = None
|
||||
current_group = None
|
||||
current_mode = None
|
||||
|
||||
if os.path.isdir(directory):
|
||||
_state = os.stat(directory)
|
||||
try:
|
||||
current_owner = pwd.getpwuid(_state.st_uid).pw_uid
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
try:
|
||||
current_group = grp.getgrgid(_state.st_gid).gr_gid
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
try:
|
||||
current_mode = oct(_state.st_mode)[-4:]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return current_owner, current_group, current_mode
|
||||
|
||||
|
||||
def fix_ownership(directory, force_owner=None, force_group=None, force_mode=False):
|
||||
""" """
|
||||
changed = False
|
||||
error_msg = None
|
||||
|
||||
if os.path.isdir(directory):
|
||||
current_owner, current_group, current_mode = current_state(directory)
|
||||
|
||||
# change mode
|
||||
if force_mode is not None and force_mode != current_mode:
|
||||
try:
|
||||
if isinstance(force_mode, int):
|
||||
mode = int(str(force_mode), base=8)
|
||||
except Exception as e:
|
||||
error_msg = f" - ERROR '{e}'"
|
||||
print(error_msg)
|
||||
|
||||
try:
|
||||
if isinstance(force_mode, str):
|
||||
mode = int(force_mode, base=8)
|
||||
except Exception as e:
|
||||
error_msg = f" - ERROR '{e}'"
|
||||
print(error_msg)
|
||||
|
||||
os.chmod(directory, mode)
|
||||
|
||||
# change ownership
|
||||
if (
|
||||
force_owner is not None
|
||||
or force_group is not None
|
||||
and (force_owner != current_owner or force_group != current_group)
|
||||
):
|
||||
if force_owner is not None:
|
||||
try:
|
||||
force_owner = pwd.getpwnam(str(force_owner)).pw_uid
|
||||
except KeyError:
|
||||
force_owner = int(force_owner)
|
||||
pass
|
||||
elif current_owner is not None:
|
||||
force_owner = current_owner
|
||||
else:
|
||||
force_owner = 0
|
||||
|
||||
if force_group is not None:
|
||||
try:
|
||||
force_group = grp.getgrnam(str(force_group)).gr_gid
|
||||
except KeyError:
|
||||
force_group = int(force_group)
|
||||
pass
|
||||
elif current_group is not None:
|
||||
force_group = current_group
|
||||
else:
|
||||
force_group = 0
|
||||
|
||||
os.chown(directory, int(force_owner), int(force_group))
|
||||
|
||||
_owner, _group, _mode = current_state(directory)
|
||||
|
||||
if (
|
||||
(current_owner != _owner)
|
||||
or (current_group != _group)
|
||||
or (current_mode != _mode)
|
||||
):
|
||||
changed = True
|
||||
|
||||
return changed, error_msg
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
import dns.exception
|
||||
from dns.resolver import Resolver
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
def dns_lookup(dns_name, timeout=3, dns_resolvers=[]):
|
||||
"""
|
||||
Perform a simple DNS lookup, return results in a dictionary
|
||||
"""
|
||||
resolver = Resolver()
|
||||
resolver.timeout = float(timeout)
|
||||
resolver.lifetime = float(timeout)
|
||||
|
||||
result = {}
|
||||
|
||||
if not dns_name:
|
||||
return {
|
||||
"addrs": [],
|
||||
"error": True,
|
||||
"error_msg": "No DNS Name for resolving given",
|
||||
"name": dns_name,
|
||||
}
|
||||
|
||||
if dns_resolvers:
|
||||
resolver.nameservers = dns_resolvers
|
||||
try:
|
||||
records = resolver.resolve(dns_name)
|
||||
result = {
|
||||
"addrs": [ii.address for ii in records],
|
||||
"error": False,
|
||||
"error_msg": "",
|
||||
"name": dns_name,
|
||||
}
|
||||
except dns.resolver.NXDOMAIN:
|
||||
result = {
|
||||
"addrs": [],
|
||||
"error": True,
|
||||
"error_msg": "No such domain",
|
||||
"name": dns_name,
|
||||
}
|
||||
except dns.resolver.NoNameservers as e:
|
||||
result = {
|
||||
"addrs": [],
|
||||
"error": True,
|
||||
"error_msg": repr(e),
|
||||
"name": dns_name,
|
||||
}
|
||||
except dns.resolver.Timeout:
|
||||
result = {
|
||||
"addrs": [],
|
||||
"error": True,
|
||||
"error_msg": "Timed out while resolving",
|
||||
"name": dns_name,
|
||||
}
|
||||
except dns.resolver.NameError as e:
|
||||
result = {
|
||||
"addrs": [],
|
||||
"error": True,
|
||||
"error_msg": repr(e),
|
||||
"name": dns_name,
|
||||
}
|
||||
except dns.exception.DNSException as e:
|
||||
result = {
|
||||
"addrs": [],
|
||||
"error": True,
|
||||
"error_msg": f"Unhandled exception ({repr(e)})",
|
||||
"name": dns_name,
|
||||
}
|
||||
|
||||
return result
|
||||
|
|
@ -0,0 +1,493 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import os
|
||||
from typing import Any, List, Sequence, Tuple, Union
|
||||
|
||||
EasyRSAResult = Tuple[int, bool, Union[str, List[str]]]
|
||||
ExecResult = Tuple[int, str, str]
|
||||
|
||||
|
||||
class EasyRSA:
|
||||
"""
|
||||
Thin wrapper around the `easyrsa` CLI to manage a simple PKI lifecycle.
|
||||
|
||||
The class is designed to be used from an Ansible context (``module``),
|
||||
relying on the module to provide:
|
||||
- ``module.params`` for runtime parameters (e.g. ``force``)
|
||||
- ``module.log(...)`` for logging
|
||||
- ``module.get_bin_path("easyrsa", required=True)`` to locate the binary
|
||||
- ``module.run_command([...])`` to execute commands
|
||||
|
||||
Attributes:
|
||||
module: Ansible module-like object providing logging and command execution.
|
||||
state: Internal state placeholder (currently unused).
|
||||
force: Whether to force actions (read from ``module.params['force']``).
|
||||
pki_dir: Path to the PKI directory (commonly ``/etc/easy-rsa/pki``).
|
||||
req_cn_ca: Common name (CN) used when building the CA.
|
||||
req_cn_server: Common name (CN) used for server requests/certificates.
|
||||
ca_keysize: RSA key size for CA key generation.
|
||||
dh_keysize: DH parameter size for DH generation.
|
||||
working_dir: Working directory context (currently not used for chdir).
|
||||
easyrsa: Resolved path to the ``easyrsa`` executable.
|
||||
easyrsa_directory: Base directory used by some file existence checks
|
||||
(defaults to ``/etc/easy-rsa``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
module: Any,
|
||||
force: bool = False,
|
||||
pki_dir: str = "",
|
||||
req_cn_ca: str = "",
|
||||
req_cn_server: str = "",
|
||||
ca_keysize: int = 4086,
|
||||
dh_keysize: int = 2048,
|
||||
working_dir: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
Create an EasyRSA helper instance.
|
||||
|
||||
Args:
|
||||
module: Ansible module-like object used for logging and running commands.
|
||||
force: Optional force flag (note: the effective value is read from
|
||||
``module.params.get("force", False)``).
|
||||
pki_dir: Path to PKI directory (e.g. ``/etc/easy-rsa/pki``).
|
||||
req_cn_ca: CA request common name (CN) used for ``build-ca``.
|
||||
req_cn_server: Server common name (CN) used for ``gen-req`` and ``sign-req``.
|
||||
ca_keysize: RSA key size for the CA.
|
||||
dh_keysize: DH parameter size.
|
||||
working_dir: Intended working directory for running commands (not applied).
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
self.module = module
|
||||
|
||||
self.module.log(
|
||||
"EasyRSA::__init__("
|
||||
f"force={force}, pki_dir={pki_dir}, "
|
||||
f"req_cn_ca={req_cn_ca}, req_cn_server={req_cn_server}, "
|
||||
f"ca_keysize={ca_keysize}, dh_keysize={dh_keysize}, "
|
||||
f"working_dir={working_dir}"
|
||||
")"
|
||||
)
|
||||
|
||||
self.state = ""
|
||||
|
||||
self.force = module.params.get("force", False)
|
||||
self.pki_dir = pki_dir
|
||||
self.req_cn_ca = req_cn_ca
|
||||
self.req_cn_server = req_cn_server
|
||||
self.ca_keysize = ca_keysize
|
||||
self.dh_keysize = dh_keysize
|
||||
self.working_dir = working_dir
|
||||
|
||||
self.easyrsa = module.get_bin_path("easyrsa", True)
|
||||
|
||||
self.easyrsa_directory = "/etc/easy-rsa"
|
||||
|
||||
# ----------------------------------------------------------------------------------------------
|
||||
# Public API - create
|
||||
def create_pki(self) -> Tuple[int, bool, str]:
|
||||
"""
|
||||
Initialize the PKI directory via ``easyrsa init-pki``.
|
||||
|
||||
The method performs an idempotency check using :meth:`validate_pki` and
|
||||
returns unchanged when the PKI directory already exists.
|
||||
|
||||
Returns:
|
||||
tuple[int, bool, str]: (rc, changed, message)
|
||||
rc: 0 on success, non-zero on failure.
|
||||
changed: True if the PKI was created, False if it already existed.
|
||||
message: Human-readable status message.
|
||||
"""
|
||||
self.module.log(msg="EasyRsa::create_pki()")
|
||||
|
||||
if self.validate_pki():
|
||||
return (0, False, "PKI already created")
|
||||
|
||||
args: List[str] = []
|
||||
args.append(self.easyrsa)
|
||||
args.append("init-pki")
|
||||
|
||||
rc, out, err = self._exec(args)
|
||||
|
||||
if self.validate_pki():
|
||||
return (0, True, "The PKI was successfully created.")
|
||||
else:
|
||||
return (1, True, "An error occurred while creating the PKI.")
|
||||
|
||||
def build_ca(self) -> EasyRSAResult:
|
||||
"""
|
||||
Build a new certificate authority (CA) via ``easyrsa build-ca nopass``.
|
||||
|
||||
Performs an idempotency check using :meth:`validate_ca`. When the CA does not
|
||||
exist, this runs Easy-RSA in batch mode and checks for the existence of:
|
||||
- ``<easyrsa_directory>/pki/ca.crt``
|
||||
- ``<easyrsa_directory>/pki/private/ca.key``
|
||||
|
||||
Returns:
|
||||
tuple[int, bool, Union[str, list[str]]]: (rc, changed, output)
|
||||
rc: 0 on success; 3 if expected files were not created; otherwise
|
||||
the underlying command return code.
|
||||
changed: False if the CA already existed; True if a build was attempted.
|
||||
output: Combined stdout/stderr lines (list[str]) or a success message (str).
|
||||
"""
|
||||
if self.validate_ca():
|
||||
return (0, False, "CA already created")
|
||||
|
||||
args: List[str] = []
|
||||
args.append(self.easyrsa)
|
||||
args.append("--batch")
|
||||
# args.append(f"--pki-dir={self._pki_dir}")
|
||||
args.append(f"--req-cn={self.req_cn_ca}")
|
||||
|
||||
if self.ca_keysize:
|
||||
args.append(f"--keysize={self.ca_keysize}")
|
||||
args.append("build-ca")
|
||||
args.append("nopass")
|
||||
|
||||
rc, out, err = self._exec(args)
|
||||
_output: Union[str, List[str]] = self.result_values(out, err)
|
||||
|
||||
ca_crt_file = os.path.join(self.easyrsa_directory, "pki", "ca.crt")
|
||||
ca_key_file = os.path.join(self.easyrsa_directory, "pki", "private", "ca.key")
|
||||
|
||||
if os.path.exists(ca_crt_file) and os.path.exists(ca_key_file):
|
||||
rc = 0
|
||||
_output = "ca.crt and ca.key were successfully created."
|
||||
else:
|
||||
rc = 3
|
||||
|
||||
return (rc, True, _output)
|
||||
|
||||
def gen_crl(self) -> EasyRSAResult:
|
||||
"""
|
||||
Generate a certificate revocation list (CRL) via ``easyrsa gen-crl``.
|
||||
|
||||
Performs an idempotency check using :meth:`validate_crl` and checks for
|
||||
``<easyrsa_directory>/pki/crl.pem`` after execution.
|
||||
|
||||
Returns:
|
||||
tuple[int, bool, Union[str, list[str]]]: (rc, changed, output)
|
||||
rc: 0 on success; 3 if expected file was not created; otherwise
|
||||
the underlying command return code.
|
||||
changed: False if CRL already existed; True if generation was attempted.
|
||||
output: Combined stdout/stderr lines (list[str]) or a success message (str).
|
||||
"""
|
||||
self.module.log("EasyRSA::gen_crl()")
|
||||
|
||||
if self.validate_crl():
|
||||
return (0, False, "CRL already created")
|
||||
|
||||
args: List[str] = []
|
||||
args.append(self.easyrsa)
|
||||
# args.append(f"--pki-dir={self._pki_dir}")
|
||||
args.append("gen-crl")
|
||||
|
||||
rc, out, err = self._exec(args)
|
||||
|
||||
# self.module.log(f" rc : {rc}")
|
||||
# self.module.log(f" out: {out}")
|
||||
# self.module.log(f" err: {err}")
|
||||
|
||||
_output: Union[str, List[str]] = self.result_values(out, err)
|
||||
|
||||
crl_pem_file = os.path.join(self.easyrsa_directory, "pki", "crl.pem")
|
||||
|
||||
if os.path.exists(crl_pem_file):
|
||||
rc = 0
|
||||
_output = "crl.pem were successfully created."
|
||||
else:
|
||||
rc = 3
|
||||
|
||||
return (rc, True, _output)
|
||||
|
||||
def gen_req(self) -> EasyRSAResult:
|
||||
"""
|
||||
Generate a private key and certificate signing request (CSR) via
|
||||
``easyrsa gen-req <req_cn_server> nopass``.
|
||||
|
||||
Performs an idempotency check using :meth:`validate_req` and checks for:
|
||||
- ``<easyrsa_directory>/pki/reqs/<req_cn_server>.req`` after execution.
|
||||
|
||||
Returns:
|
||||
tuple[int, bool, Union[str, list[str]]]: (rc, changed, output)
|
||||
rc: 0 on success; 3 if expected file was not created; otherwise
|
||||
the underlying command return code.
|
||||
changed: False if request already existed; True if generation was attempted.
|
||||
output: Combined stdout/stderr lines (list[str]) or a success message (str).
|
||||
"""
|
||||
if self.validate_req():
|
||||
return (0, False, "keypair and request already created")
|
||||
|
||||
args: List[str] = []
|
||||
args.append(self.easyrsa)
|
||||
args.append("--batch")
|
||||
# args.append(f"--pki-dir={self._pki_dir}")
|
||||
if self.req_cn_ca:
|
||||
args.append(f"--req-cn={self.req_cn_ca}")
|
||||
args.append("gen-req")
|
||||
args.append(self.req_cn_server)
|
||||
args.append("nopass")
|
||||
|
||||
rc, out, err = self._exec(args)
|
||||
_output: Union[str, List[str]] = self.result_values(out, err)
|
||||
|
||||
req_file = os.path.join(
|
||||
self.easyrsa_directory, "pki", "reqs", f"{self.req_cn_server}.req"
|
||||
)
|
||||
|
||||
if os.path.exists(req_file):
|
||||
rc = 0
|
||||
_output = f"{self.req_cn_server}.req were successfully created."
|
||||
else:
|
||||
rc = 3
|
||||
|
||||
return (rc, True, _output)
|
||||
|
||||
def sign_req(self) -> EasyRSAResult:
|
||||
"""
|
||||
Sign the server request and generate a certificate via
|
||||
``easyrsa sign-req server <req_cn_server>``.
|
||||
|
||||
Performs an idempotency check using :meth:`validate_sign` and checks for:
|
||||
- ``<easyrsa_directory>/pki/issued/<req_cn_server>.crt`` after execution.
|
||||
|
||||
Returns:
|
||||
tuple[int, bool, Union[str, list[str]]]: (rc, changed, output)
|
||||
rc: 0 on success; 3 if expected file was not created; otherwise
|
||||
the underlying command return code.
|
||||
changed: False if the certificate already existed; True if signing was attempted.
|
||||
output: Combined stdout/stderr lines (list[str]) or a success message (str).
|
||||
"""
|
||||
if self.validate_sign():
|
||||
return (0, False, "certificate alread signed")
|
||||
|
||||
args: List[str] = []
|
||||
args.append(self.easyrsa)
|
||||
args.append("--batch")
|
||||
# args.append(f"--pki-dir={self._pki_dir}")
|
||||
args.append("sign-req")
|
||||
args.append("server")
|
||||
args.append(self.req_cn_server)
|
||||
|
||||
rc, out, err = self._exec(args)
|
||||
_output: Union[str, List[str]] = self.result_values(out, err)
|
||||
|
||||
crt_file = os.path.join(
|
||||
self.easyrsa_directory, "pki", "issued", f"{self.req_cn_server}.crt"
|
||||
)
|
||||
|
||||
if os.path.exists(crt_file):
|
||||
rc = 0
|
||||
_output = f"{self.req_cn_server}.crt were successfully created."
|
||||
else:
|
||||
rc = 3
|
||||
|
||||
return (rc, True, _output)
|
||||
|
||||
def gen_dh(self) -> EasyRSAResult:
|
||||
"""
|
||||
Generate Diffie-Hellman parameters via ``easyrsa gen-dh``.
|
||||
|
||||
Performs an idempotency check using :meth:`validate_dh` and checks for:
|
||||
- ``<easyrsa_directory>/pki/dh.pem`` after execution.
|
||||
|
||||
Returns:
|
||||
tuple[int, bool, Union[str, list[str]]]: (rc, changed, output)
|
||||
rc: 0 on success; 3 if expected file was not created; otherwise
|
||||
the underlying command return code.
|
||||
changed: False if DH params already existed; True if generation was attempted.
|
||||
output: Combined stdout/stderr lines (list[str]) or a success message (str).
|
||||
"""
|
||||
if self.validate_dh():
|
||||
return (0, False, "DH already created")
|
||||
|
||||
args: List[str] = []
|
||||
args.append(self.easyrsa)
|
||||
# args.append(f"--pki-dir={self._pki_dir}")
|
||||
if self.dh_keysize:
|
||||
args.append(f"--keysize={self.dh_keysize}")
|
||||
# args.append(f"--pki-dir={self._pki_dir}")
|
||||
args.append("gen-dh")
|
||||
|
||||
rc, out, err = self._exec(args)
|
||||
_output: Union[str, List[str]] = self.result_values(out, err)
|
||||
|
||||
dh_pem_file = os.path.join(self.easyrsa_directory, "pki", "dh.pem")
|
||||
|
||||
if os.path.exists(dh_pem_file):
|
||||
rc = 0
|
||||
_output = "dh.pem were successfully created."
|
||||
else:
|
||||
rc = 3
|
||||
|
||||
return (rc, True, _output)
|
||||
|
||||
# ----------------------------------------------------------------------------------------------
|
||||
# PRIVATE API - validate
|
||||
def validate_pki(self) -> bool:
|
||||
"""
|
||||
Check whether the PKI directory exists.
|
||||
|
||||
Returns:
|
||||
bool: True if ``self.pki_dir`` exists on disk, otherwise False.
|
||||
"""
|
||||
self.module.log(msg="EasyRsa::validate_pki()")
|
||||
|
||||
if os.path.exists(self.pki_dir):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def validate_ca(self) -> bool:
|
||||
"""
|
||||
Check whether the CA certificate and key exist.
|
||||
|
||||
Expected files (relative to ``self.pki_dir``):
|
||||
- ``ca.crt``
|
||||
- ``private/ca.key``
|
||||
|
||||
Returns:
|
||||
bool: True if both CA files exist, otherwise False.
|
||||
"""
|
||||
self.module.log(msg="EasyRsa::validate__ca()")
|
||||
|
||||
ca_crt_file = os.path.join(self.pki_dir, "ca.crt")
|
||||
ca_key_file = os.path.join(self.pki_dir, "private", "ca.key")
|
||||
|
||||
if os.path.exists(ca_crt_file) and os.path.exists(ca_key_file):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def validate_crl(self) -> bool:
|
||||
"""
|
||||
Check whether the CRL exists.
|
||||
|
||||
Expected file (relative to ``self.pki_dir``):
|
||||
- ``crl.pem``
|
||||
|
||||
Returns:
|
||||
bool: True if CRL exists, otherwise False.
|
||||
"""
|
||||
self.module.log(msg="EasyRsa::validate__crl()")
|
||||
|
||||
crl_pem_file = os.path.join(self.pki_dir, "crl.pem")
|
||||
|
||||
if os.path.exists(crl_pem_file):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def validate_dh(self) -> bool:
|
||||
"""
|
||||
Check whether the DH parameters file exists.
|
||||
|
||||
Expected file (relative to ``self.pki_dir``):
|
||||
- ``dh.pem``
|
||||
|
||||
Returns:
|
||||
bool: True if DH params exist, otherwise False.
|
||||
"""
|
||||
self.module.log(msg="EasyRsa::validate__dh()")
|
||||
|
||||
dh_pem_file = os.path.join(self.pki_dir, "dh.pem")
|
||||
|
||||
if os.path.exists(dh_pem_file):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def validate_req(self) -> bool:
|
||||
"""
|
||||
Check whether the server request (CSR) exists.
|
||||
|
||||
Expected file (relative to ``self.pki_dir``):
|
||||
- ``reqs/<req_cn_server>.req``
|
||||
|
||||
Returns:
|
||||
bool: True if the CSR exists, otherwise False.
|
||||
"""
|
||||
self.module.log(msg="EasyRsa::validate__req()")
|
||||
|
||||
req_file = os.path.join(self.pki_dir, "reqs", f"{self.req_cn_server}.req")
|
||||
|
||||
if os.path.exists(req_file):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def validate_sign(self) -> bool:
|
||||
"""
|
||||
Check whether the signed server certificate exists.
|
||||
|
||||
Expected file (relative to ``self.pki_dir``):
|
||||
- ``issued/<req_cn_server>.crt``
|
||||
|
||||
Returns:
|
||||
bool: True if the certificate exists, otherwise False.
|
||||
"""
|
||||
self.module.log(msg="EasyRsa::validate__sign()")
|
||||
|
||||
crt_file = os.path.join(self.pki_dir, "issued", f"{self.req_cn_server}.crt")
|
||||
|
||||
if os.path.exists(crt_file):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
# ----------------------------------------------------------------------------------------------
|
||||
|
||||
def _exec(self, commands: Sequence[str], check_rc: bool = False) -> ExecResult:
|
||||
"""
|
||||
Execute a command via the underlying Ansible module.
|
||||
|
||||
Args:
|
||||
commands: Command and arguments as a sequence of strings.
|
||||
check_rc: Passed through to ``module.run_command``; when True, the
|
||||
module may raise/fail on non-zero return codes depending on its behavior.
|
||||
|
||||
Returns:
|
||||
tuple[int, str, str]: (rc, stdout, stderr)
|
||||
rc: Process return code.
|
||||
stdout: Captured standard output.
|
||||
stderr: Captured standard error.
|
||||
"""
|
||||
self.module.log(msg=f"_exec(commands={commands}, check_rc={check_rc}")
|
||||
|
||||
rc, out, err = self.module.run_command(commands, check_rc=check_rc)
|
||||
|
||||
if int(rc) != 0:
|
||||
self.module.log(msg=f" rc : '{rc}'")
|
||||
self.module.log(msg=f" out: '{out}'")
|
||||
self.module.log(msg=f" err: '{err}'")
|
||||
|
||||
return rc, out, err
|
||||
|
||||
def result_values(self, out: str, err: str) -> List[str]:
|
||||
"""
|
||||
Merge stdout and stderr into a single list of output lines.
|
||||
|
||||
Args:
|
||||
out: Raw stdout string.
|
||||
err: Raw stderr string.
|
||||
|
||||
Returns:
|
||||
list[str]: Concatenated list of lines (stdout lines first, then stderr lines).
|
||||
"""
|
||||
_out = out.splitlines()
|
||||
_err = err.splitlines()
|
||||
_output: List[str] = []
|
||||
_output += _out
|
||||
_output += _err
|
||||
# self.module.log(msg=f"= output: {_output}")
|
||||
return _output
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def create_link(source, destination, force=False):
|
||||
"""
|
||||
create a link ..
|
||||
"""
|
||||
if force:
|
||||
os.remove(destination)
|
||||
os.symlink(source, destination)
|
||||
else:
|
||||
os.symlink(source, destination)
|
||||
|
||||
|
||||
def remove_file(file_name):
|
||||
""" """
|
||||
if os.path.exists(file_name):
|
||||
os.remove(file_name)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def chmod(file_name, mode):
|
||||
""" """
|
||||
if os.path.exists(file_name):
|
||||
if mode is not None:
|
||||
os.chmod(file_name, int(mode, base=8))
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
|
||||
def find_in_list(list, value):
|
||||
""" """
|
||||
for entry in list:
|
||||
for k, v in entry.items():
|
||||
if k == value:
|
||||
return entry
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def compare_two_lists(list1: list, list2: list, debug=False):
|
||||
"""
|
||||
Compare two lists and logs the difference.
|
||||
:param list1: first list.
|
||||
:param list2: second list.
|
||||
:return: if there is difference between both lists.
|
||||
"""
|
||||
debug_msg = []
|
||||
|
||||
diff = [x for x in list2 if x not in list1]
|
||||
|
||||
changed = not (len(diff) == 0)
|
||||
if debug:
|
||||
if not changed:
|
||||
debug_msg.append(f"There are {len(diff)} differences:")
|
||||
debug_msg.append(f" {diff[:5]}")
|
||||
|
||||
return changed, diff, debug_msg
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, Tuple
|
||||
|
||||
ResultEntry = Dict[str, Dict[str, Any]]
|
||||
ResultState = Iterable[ResultEntry]
|
||||
|
||||
ResultsReturn = Tuple[
|
||||
bool, # has_state
|
||||
bool, # has_changed
|
||||
bool, # has_failed
|
||||
Dict[str, Dict[str, Any]], # state
|
||||
Dict[str, Dict[str, Any]], # changed
|
||||
Dict[str, Dict[str, Any]], # failed
|
||||
]
|
||||
|
||||
|
||||
def results(module: Any, result_state: ResultState) -> ResultsReturn:
|
||||
"""
|
||||
Aggregate per-item module results into combined state/changed/failed maps.
|
||||
|
||||
The function expects an iterable of dictionaries, where each dictionary maps
|
||||
an item identifier (e.g. a container name) to a dict containing optional keys
|
||||
like ``state``, ``changed``, and ``failed``.
|
||||
|
||||
Example input:
|
||||
[
|
||||
{"busybox-1": {"state": "container.env written", "changed": True}},
|
||||
{"hello-world-1": {"state": "hello-world-1.properties written"}},
|
||||
{"nginx-1": {"failed": True, "msg": "..." }}
|
||||
]
|
||||
|
||||
Args:
|
||||
module: An Ansible-like module object. Currently unused (kept for API symmetry
|
||||
and optional debugging/logging).
|
||||
result_state: Iterable of per-item result dictionaries as described above.
|
||||
|
||||
Returns:
|
||||
tuple[bool, bool, bool, dict[str, dict[str, Any]], dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
|
||||
(has_state, has_changed, has_failed, state, changed, failed)
|
||||
|
||||
has_state:
|
||||
True if at least one item dict contains a truthy ``"state"`` key.
|
||||
has_changed:
|
||||
True if at least one item dict contains a truthy ``"changed"`` key.
|
||||
has_failed:
|
||||
True if at least one item dict contains a truthy ``"failed"`` key.
|
||||
state:
|
||||
Mapping of item_id -> item_dict for all items with a truthy ``"state"``.
|
||||
changed:
|
||||
Mapping of item_id -> item_dict for all items with a truthy ``"changed"``.
|
||||
failed:
|
||||
Mapping of item_id -> item_dict for all items with a truthy ``"failed"``.
|
||||
|
||||
Notes:
|
||||
If the same item_id appears multiple times in ``result_state``, later entries
|
||||
overwrite earlier ones during the merge step.
|
||||
"""
|
||||
|
||||
# module.log(msg=f"{result_state}")
|
||||
|
||||
combined_d: Dict[str, Dict[str, Any]] = {
|
||||
key: value for d in result_state for key, value in d.items()
|
||||
}
|
||||
|
||||
state: Dict[str, Dict[str, Any]] = {
|
||||
k: v for k, v in combined_d.items() if isinstance(v, dict) and v.get("state")
|
||||
}
|
||||
changed: Dict[str, Dict[str, Any]] = {
|
||||
k: v for k, v in combined_d.items() if isinstance(v, dict) and v.get("changed")
|
||||
}
|
||||
failed: Dict[str, Dict[str, Any]] = {
|
||||
k: v for k, v in combined_d.items() if isinstance(v, dict) and v.get("failed")
|
||||
}
|
||||
|
||||
has_state = len(state) > 0
|
||||
has_changed = len(changed) > 0
|
||||
has_failed = len(failed) > 0
|
||||
|
||||
return (has_state, has_changed, has_failed, state, changed, failed)
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
from __future__ import annotations
|
||||
|
||||
"""
|
||||
Compatibility helpers for using passlib 1.7.4 with bcrypt 5.x.
|
||||
|
||||
Background
|
||||
----------
|
||||
passlib 1.7.4 performs a bcrypt backend self-test during import that uses a test
|
||||
secret longer than 72 bytes. bcrypt 5.x raises a ValueError for inputs longer
|
||||
than 72 bytes instead of silently truncating. This can abort imports of
|
||||
passlib.apache (and other passlib components) even before user code runs.
|
||||
|
||||
This module applies a targeted runtime patch:
|
||||
- Patch bcrypt.hashpw/checkpw to truncate inputs to 72 bytes (bcrypt's effective
|
||||
input limit) so that passlib's self-tests do not crash.
|
||||
- Patch passlib.handlers.bcrypt.detect_wrap_bug() to handle the ValueError and
|
||||
proceed with the wraparound test.
|
||||
|
||||
The patch restores passlib importability on systems that ship passlib 1.7.4
|
||||
together with bcrypt 5.x.
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
|
||||
|
||||
def _major_version(dist_name: str) -> int | None:
|
||||
"""
|
||||
Return the major version number of an installed distribution.
|
||||
|
||||
The value is derived from ``importlib.metadata.version(dist_name)`` and then
|
||||
parsed as the leading numeric component.
|
||||
|
||||
Args:
|
||||
dist_name: The distribution name as used by importlib metadata
|
||||
(e.g. "passlib", "bcrypt").
|
||||
|
||||
Returns:
|
||||
The major version as an integer, or ``None`` if the distribution is not
|
||||
installed or the version string cannot be interpreted.
|
||||
"""
|
||||
try:
|
||||
v = importlib.metadata.version(dist_name)
|
||||
except PackageNotFoundError:
|
||||
return None
|
||||
|
||||
# Extract the first dot-separated segment and keep digits only
|
||||
# (works for typical versions like "5.0.1", "5rc1", "5.post1", etc.).
|
||||
head = v.split(".", 1)[0]
|
||||
try:
|
||||
return int("".join(ch for ch in head if ch.isdigit()) or head)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def apply_passlib_bcrypt5_compat(module) -> None:
|
||||
"""
|
||||
Apply runtime patches to make passlib 1.7.4 work with bcrypt 5.x.
|
||||
|
||||
What this does
|
||||
-------------
|
||||
1) Patches ``bcrypt.hashpw`` and ``bcrypt.checkpw`` to truncate any password
|
||||
input longer than 72 bytes to 72 bytes. This prevents bcrypt 5.x from
|
||||
raising ``ValueError`` when passlib runs its internal self-tests during
|
||||
import. The patch is applied only once per Python process.
|
||||
|
||||
2) Patches ``passlib.handlers.bcrypt.detect_wrap_bug`` to tolerate the
|
||||
bcrypt 5.x ``ValueError`` during the wraparound self-test and continue
|
||||
the test using a 72-byte truncated secret.
|
||||
|
||||
Preconditions
|
||||
-------------
|
||||
This function is a no-op unless:
|
||||
- passlib is installed and its major version is 1, and
|
||||
- bcrypt is installed and its major version is >= 5.
|
||||
|
||||
Logging
|
||||
-------
|
||||
The function uses ``module.log(...)`` for diagnostic messages. The passed
|
||||
``module`` is expected to be an AnsibleModule (or a compatible object).
|
||||
|
||||
Important
|
||||
---------
|
||||
This patch does not remove bcrypt's effective 72-byte input limit. bcrypt
|
||||
inherently only considers the first 72 bytes of a password. The patch
|
||||
merely restores the historical "truncate silently" behavior in bcrypt 5.x
|
||||
so that older passlib versions keep working.
|
||||
|
||||
Args:
|
||||
module: An object providing ``log(str)``. Typically an instance of
|
||||
``ansible.module_utils.basic.AnsibleModule``.
|
||||
|
||||
Returns:
|
||||
None. The patch is applied in-place to the imported modules.
|
||||
"""
|
||||
module.log("apply_passlib_bcrypt5_compat()")
|
||||
|
||||
passlib_major = _major_version("passlib")
|
||||
bcrypt_major = _major_version("bcrypt")
|
||||
|
||||
module.log(f" - passlib_major {passlib_major}")
|
||||
module.log(f" - bcrypt_major {bcrypt_major}")
|
||||
|
||||
if passlib_major is None or bcrypt_major is None:
|
||||
return
|
||||
if bcrypt_major < 5:
|
||||
return
|
||||
|
||||
# --- Patch 1: bcrypt itself (so passlib self-tests don't crash) ---
|
||||
import bcrypt as _bcrypt # bcrypt package
|
||||
|
||||
if not getattr(_bcrypt, "_passlib_compat_applied", False):
|
||||
_orig_hashpw = _bcrypt.hashpw
|
||||
_orig_checkpw = _bcrypt.checkpw
|
||||
|
||||
def hashpw(secret: bytes, salt: bytes) -> bytes:
|
||||
"""
|
||||
Wrapper around bcrypt.hashpw that truncates secrets to 72 bytes.
|
||||
|
||||
Args:
|
||||
secret: Password bytes to hash.
|
||||
salt: bcrypt salt/config blob.
|
||||
|
||||
Returns:
|
||||
The bcrypt hash as bytes.
|
||||
"""
|
||||
if isinstance(secret, bytearray):
|
||||
secret = bytes(secret)
|
||||
if len(secret) > 72:
|
||||
secret = secret[:72]
|
||||
return _orig_hashpw(secret, salt)
|
||||
|
||||
def checkpw(secret: bytes, hashed: bytes) -> bool:
|
||||
"""
|
||||
Wrapper around bcrypt.checkpw that truncates secrets to 72 bytes.
|
||||
|
||||
Args:
|
||||
secret: Password bytes to verify.
|
||||
hashed: Existing bcrypt hash.
|
||||
|
||||
Returns:
|
||||
True if the password matches, otherwise False.
|
||||
"""
|
||||
if isinstance(secret, bytearray):
|
||||
secret = bytes(secret)
|
||||
if len(secret) > 72:
|
||||
secret = secret[:72]
|
||||
return _orig_checkpw(secret, hashed)
|
||||
|
||||
_bcrypt.hashpw = hashpw # type: ignore[assignment]
|
||||
_bcrypt.checkpw = checkpw # type: ignore[assignment]
|
||||
_bcrypt._passlib_compat_applied = True
|
||||
|
||||
module.log(" - patched bcrypt.hashpw/checkpw for >72 truncation")
|
||||
|
||||
# --- Patch 2: passlib detect_wrap_bug() (handle bcrypt>=5 behavior) ---
|
||||
import passlib.handlers.bcrypt as pl_bcrypt # noqa: WPS433 (runtime patch)
|
||||
|
||||
if getattr(pl_bcrypt, "_bcrypt5_compat_applied", False):
|
||||
return
|
||||
|
||||
def detect_wrap_bug_patched(ident: str) -> bool:
|
||||
"""
|
||||
Replacement for passlib.handlers.bcrypt.detect_wrap_bug().
|
||||
|
||||
passlib's original implementation performs a detection routine to test
|
||||
for a historical bcrypt "wraparound" bug. The routine uses a test secret
|
||||
longer than 72 bytes. With bcrypt 5.x, this can raise ``ValueError``.
|
||||
This patched version catches that error, truncates the secret to 72
|
||||
bytes, and completes the verification checks.
|
||||
|
||||
Args:
|
||||
ident: The bcrypt identifier prefix (e.g. "$2a$", "$2b$", etc.)
|
||||
as provided by passlib.
|
||||
|
||||
Returns:
|
||||
True if the backend appears to exhibit the wraparound bug,
|
||||
otherwise False.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the backend fails the expected self-test checks.
|
||||
"""
|
||||
secret = (b"0123456789" * 26)[:255]
|
||||
|
||||
bug_hash = (
|
||||
ident.encode("ascii")
|
||||
+ b"04$R1lJ2gkNaoPGdafE.H.16.nVyh2niHsGJhayOHLMiXlI45o8/DU.6"
|
||||
)
|
||||
try:
|
||||
if pl_bcrypt.bcrypt.verify(secret, bug_hash):
|
||||
return True
|
||||
except ValueError:
|
||||
# bcrypt>=5 kann bei >72 Bytes explizit ValueError werfen
|
||||
secret = secret[:72]
|
||||
|
||||
correct_hash = (
|
||||
ident.encode("ascii")
|
||||
+ b"04$R1lJ2gkNaoPGdafE.H.16.1MKHPvmKwryeulRe225LKProWYwt9Oi"
|
||||
)
|
||||
if not pl_bcrypt.bcrypt.verify(secret, correct_hash):
|
||||
raise RuntimeError(
|
||||
f"bcrypt backend failed wraparound self-test for ident={ident!r}"
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
pl_bcrypt.detect_wrap_bug = detect_wrap_bug_patched # type: ignore[assignment]
|
||||
pl_bcrypt._bcrypt5_compat_applied = True
|
||||
|
||||
module.log(" - patched passlib.handlers.bcrypt.detect_wrap_bug")
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import json
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
# from ansible_collections.bodsch.core.plugins.module_utils.checksum import Checksum
|
||||
|
||||
|
||||
class TemplateHandler:
|
||||
""" """
|
||||
|
||||
def __init__(self, module):
|
||||
self.module = module
|
||||
|
||||
def write_template(self, file_name, template, data):
|
||||
""" """
|
||||
if isinstance(data, dict):
|
||||
"""
|
||||
sort data
|
||||
"""
|
||||
data = json.dumps(data, sort_keys=True)
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
|
||||
if isinstance(data, list):
|
||||
data = ":".join(data)
|
||||
|
||||
tm = Template(template, trim_blocks=True, lstrip_blocks=True)
|
||||
d = tm.render(item=data)
|
||||
|
||||
with open(file_name, "w") as f:
|
||||
f.write(d)
|
||||
|
||||
def write_when_changed(self, tmp_file, data_file, **kwargs):
|
||||
""" """
|
||||
self.module.log(f"write_when_changed(self, {tmp_file}, {data_file}, {kwargs})")
|
||||
|
||||
# checksum = Checksum(self.module)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# OBSOLETE, BUT STILL SUPPORTED FOR COMPATIBILITY REASONS
|
||||
def write_template(file_name, template, data):
|
||||
""" """
|
||||
if isinstance(data, dict):
|
||||
"""
|
||||
sort data
|
||||
"""
|
||||
data = json.dumps(data, sort_keys=True)
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
|
||||
if isinstance(data, list):
|
||||
data = ":".join(data)
|
||||
|
||||
tm = Template(template, trim_blocks=True, lstrip_blocks=True)
|
||||
d = tm.render(item=data)
|
||||
|
||||
with open(file_name, "w") as f:
|
||||
f.write(d)
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
|
||||
def validate(value, default=None):
|
||||
""" """
|
||||
if value:
|
||||
if isinstance(value, str) or isinstance(value, list) or isinstance(value, dict):
|
||||
if len(value) > 0:
|
||||
return value
|
||||
|
||||
if isinstance(value, int):
|
||||
return int(value)
|
||||
|
||||
if isinstance(value, bool):
|
||||
return bool(value)
|
||||
|
||||
return default
|
||||
|
|
@ -0,0 +1,741 @@
|
|||
"""
|
||||
binary_deploy_impl.py
|
||||
|
||||
Idempotent deployment helper for versioned binaries with activation symlinks.
|
||||
|
||||
This module is intended to be used from Ansible modules (and optionally an action plugin)
|
||||
to deploy one or multiple binaries into a versioned installation directory and activate
|
||||
them via symlinks (e.g. /usr/bin/<name> -> <install_dir>/<name>).
|
||||
|
||||
Key features:
|
||||
- Optional copy from a remote staging directory (remote -> remote) with atomic replacement.
|
||||
- Permission and ownership enforcement (mode/owner/group).
|
||||
- Optional Linux file capabilities via getcap/setcap with normalized, idempotent comparison.
|
||||
- Activation detection based on symlink target.
|
||||
|
||||
Public API:
|
||||
- BinaryDeploy.run(): reads AnsibleModule params and returns module JSON via exit_json/fail_json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import grp
|
||||
import hashlib
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
_CHUNK_SIZE = 1024 * 1024
|
||||
_CAP_ENTRY_RE = re.compile(r"^(cap_[a-z0-9_]+)([=+])([a-z]+)$", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BinaryItem:
|
||||
"""A single deployable binary with optional activation name and capability."""
|
||||
|
||||
name: str
|
||||
src: str
|
||||
link_name: str
|
||||
capability: Optional[str]
|
||||
|
||||
|
||||
class _PathOps:
|
||||
"""Filesystem helper methods used by the deployment logic."""
|
||||
|
||||
@staticmethod
|
||||
def sha256_file(path: str) -> str:
|
||||
"""
|
||||
Calculate the SHA-256 checksum of a file.
|
||||
|
||||
Args:
|
||||
path: Path to the file.
|
||||
|
||||
Returns:
|
||||
Hex-encoded SHA-256 digest.
|
||||
"""
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(_CHUNK_SIZE), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def files_equal(src: str, dst: str) -> bool:
|
||||
"""
|
||||
Compare two files for equality by size and SHA-256 checksum.
|
||||
|
||||
This is used to decide whether a copy is required.
|
||||
|
||||
Args:
|
||||
src: Source file path.
|
||||
dst: Destination file path.
|
||||
|
||||
Returns:
|
||||
True if both files exist and are byte-identical, otherwise False.
|
||||
"""
|
||||
if os.path.abspath(src) == os.path.abspath(dst):
|
||||
return True
|
||||
try:
|
||||
if os.path.samefile(src, dst):
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError:
|
||||
# samefile may fail on some filesystems; fall back to hashing
|
||||
pass
|
||||
|
||||
try:
|
||||
s1 = os.stat(src)
|
||||
s2 = os.stat(dst)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
if s1.st_size != s2.st_size:
|
||||
return False
|
||||
|
||||
# Hashing is the expensive path; size match is a cheap early filter.
|
||||
return _PathOps.sha256_file(src) == _PathOps.sha256_file(dst)
|
||||
|
||||
@staticmethod
|
||||
def ensure_dir(path: str) -> bool:
|
||||
"""
|
||||
Ensure a directory exists.
|
||||
|
||||
Args:
|
||||
path: Directory path.
|
||||
|
||||
Returns:
|
||||
True if the directory was created, otherwise False.
|
||||
"""
|
||||
if os.path.isdir(path):
|
||||
return False
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def safe_rmtree(path: str) -> None:
|
||||
"""
|
||||
Remove a directory tree with a minimal safety guard.
|
||||
|
||||
Args:
|
||||
path: Directory to remove.
|
||||
|
||||
Raises:
|
||||
ValueError: If the path is empty or points to '/'.
|
||||
"""
|
||||
if not path or os.path.abspath(path) in ("/",):
|
||||
raise ValueError(f"Refusing to remove unsafe path: {path}")
|
||||
shutil.rmtree(path)
|
||||
|
||||
@staticmethod
|
||||
def is_symlink_to(link_path: str, target_path: str) -> bool:
|
||||
"""
|
||||
Check whether link_path is a symlink pointing to target_path.
|
||||
|
||||
Args:
|
||||
link_path: Symlink location.
|
||||
target_path: Expected symlink target.
|
||||
|
||||
Returns:
|
||||
True if link_path is a symlink to target_path, otherwise False.
|
||||
"""
|
||||
try:
|
||||
if not os.path.islink(link_path):
|
||||
return False
|
||||
current = os.readlink(link_path)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
# Normalize relative symlinks to absolute for comparison.
|
||||
if not os.path.isabs(current):
|
||||
current = os.path.abspath(os.path.join(os.path.dirname(link_path), current))
|
||||
|
||||
return os.path.abspath(current) == os.path.abspath(target_path)
|
||||
|
||||
@staticmethod
|
||||
def ensure_symlink(link_path: str, target_path: str) -> bool:
|
||||
"""
|
||||
Ensure link_path is a symlink to target_path.
|
||||
|
||||
Args:
|
||||
link_path: Symlink location.
|
||||
target_path: Symlink target.
|
||||
|
||||
Returns:
|
||||
True if the symlink was created/updated, otherwise False.
|
||||
"""
|
||||
if _PathOps.is_symlink_to(link_path, target_path):
|
||||
return False
|
||||
|
||||
# Replace existing file/link.
|
||||
try:
|
||||
os.lstat(link_path)
|
||||
os.unlink(link_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
os.symlink(target_path, link_path)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def atomic_copy(src: str, dst: str) -> None:
|
||||
"""
|
||||
Copy a file to dst atomically (write to temp file and rename).
|
||||
|
||||
Args:
|
||||
src: Source file path.
|
||||
dst: Destination file path.
|
||||
"""
|
||||
dst_dir = os.path.dirname(dst)
|
||||
_PathOps.ensure_dir(dst_dir)
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=".ansible-binary-", dir=dst_dir)
|
||||
os.close(fd)
|
||||
try:
|
||||
shutil.copyfile(src, tmp_path)
|
||||
os.replace(tmp_path, dst)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
class _Identity:
|
||||
"""User/group resolution helpers."""
|
||||
|
||||
@staticmethod
|
||||
def resolve_uid(owner: Optional[str]) -> Optional[int]:
|
||||
"""
|
||||
Resolve a user name or uid string to a numeric uid.
|
||||
|
||||
Args:
|
||||
owner: User name or numeric uid as string.
|
||||
|
||||
Returns:
|
||||
Numeric uid, or None if owner is None.
|
||||
"""
|
||||
if owner is None:
|
||||
return None
|
||||
if owner.isdigit():
|
||||
return int(owner)
|
||||
return pwd.getpwnam(owner).pw_uid
|
||||
|
||||
@staticmethod
|
||||
def resolve_gid(group: Optional[str]) -> Optional[int]:
|
||||
"""
|
||||
Resolve a group name or gid string to a numeric gid.
|
||||
|
||||
Args:
|
||||
group: Group name or numeric gid as string.
|
||||
|
||||
Returns:
|
||||
Numeric gid, or None if group is None.
|
||||
"""
|
||||
if group is None:
|
||||
return None
|
||||
if group.isdigit():
|
||||
return int(group)
|
||||
return grp.getgrnam(group).gr_gid
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CapsValue:
|
||||
"""Normalized representation of Linux file capabilities."""
|
||||
|
||||
value: str
|
||||
|
||||
@staticmethod
|
||||
def normalize(raw: str) -> "_CapsValue":
|
||||
"""
|
||||
Normalize capability strings so that setcap-style and getcap-style
|
||||
representations compare equal.
|
||||
|
||||
Examples:
|
||||
- "cap_net_raw+ep" -> "cap_net_raw=ep"
|
||||
- "cap_net_raw=pe" -> "cap_net_raw=ep"
|
||||
- "cap_a+e, cap_b=ip" -> "cap_a=e,cap_b=ip"
|
||||
"""
|
||||
s = (raw or "").strip()
|
||||
if not s:
|
||||
return _CapsValue("")
|
||||
|
||||
entries: List[str] = []
|
||||
for part in s.split(","):
|
||||
p = part.strip()
|
||||
if not p:
|
||||
continue
|
||||
|
||||
# Remove internal whitespace.
|
||||
p = " ".join(p.split())
|
||||
|
||||
m = _CAP_ENTRY_RE.match(p)
|
||||
if not m:
|
||||
# Unknown format: keep as-is (but trimmed).
|
||||
entries.append(p)
|
||||
continue
|
||||
|
||||
cap_name, _, flags = m.group(1), m.group(2), m.group(3)
|
||||
flags_norm = "".join(sorted(flags))
|
||||
# Canonical operator is '=' (getcap output style).
|
||||
entries.append(f"{cap_name}={flags_norm}")
|
||||
|
||||
entries.sort()
|
||||
return _CapsValue(",".join(entries))
|
||||
|
||||
|
||||
class _Caps:
|
||||
"""
|
||||
Linux file capabilities helper with idempotent detection via getcap/setcap.
|
||||
|
||||
The helper normalizes both desired and current values to avoid false positives,
|
||||
e.g. comparing 'cap_net_raw+ep' (setcap style) and 'cap_net_raw=ep' (getcap style).
|
||||
"""
|
||||
|
||||
def __init__(self, module: AnsibleModule) -> None:
|
||||
self._module = module
|
||||
|
||||
def _parse_getcap_output(self, path: str, out: str) -> _CapsValue:
|
||||
"""
|
||||
Parse getcap output for a single path.
|
||||
|
||||
Supported formats:
|
||||
- "/path cap_net_raw=ep"
|
||||
- "/path = cap_net_raw=ep"
|
||||
- "/path cap_net_raw+ep" (rare, but normalize handles it)
|
||||
"""
|
||||
text = (out or "").strip()
|
||||
if not text:
|
||||
return _CapsValue("")
|
||||
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Example lines:
|
||||
# /usr/bin/ping = cap_net_raw+ep
|
||||
# /usr/bin/ping cap_net_raw=ep
|
||||
if line.startswith(path):
|
||||
_path_len = len(path)
|
||||
rest = line[_path_len:].strip()
|
||||
|
||||
# Strip optional leading '=' or split form.
|
||||
if rest.startswith("="):
|
||||
rest = rest[1:].strip()
|
||||
|
||||
tokens = rest.split()
|
||||
if tokens and tokens[0] == "=":
|
||||
rest = " ".join(tokens[1:]).strip()
|
||||
|
||||
return _CapsValue.normalize(rest)
|
||||
|
||||
# Fallback: if getcap returned a single line but path formatting differs.
|
||||
first = text.splitlines()[0].strip()
|
||||
tokens = first.split()
|
||||
if len(tokens) >= 2:
|
||||
if tokens[1] == "=" and len(tokens) >= 3:
|
||||
return _CapsValue.normalize(" ".join(tokens[2:]))
|
||||
return _CapsValue.normalize(" ".join(tokens[1:]))
|
||||
|
||||
return _CapsValue("")
|
||||
|
||||
def get_current(self, path: str) -> Optional[_CapsValue]:
|
||||
"""
|
||||
Get the current capability set for a file.
|
||||
|
||||
Returns:
|
||||
- _CapsValue("") for no capabilities
|
||||
- _CapsValue("cap_xxx=ep") for set capabilities
|
||||
- None if getcap is missing (cannot do idempotent checks)
|
||||
"""
|
||||
rc, out, err = self._module.run_command(["getcap", path])
|
||||
if rc == 127:
|
||||
return None
|
||||
if rc != 0:
|
||||
msg = (err or "").strip()
|
||||
# No capabilities can be signaled via non-zero return with empty output.
|
||||
if msg and "No such file" in msg:
|
||||
self._module.fail_json(msg=f"getcap failed: {msg}", path=path)
|
||||
return _CapsValue("")
|
||||
return self._parse_getcap_output(path, out)
|
||||
|
||||
def ensure(self, path: str, desired: str) -> bool:
|
||||
"""
|
||||
Ensure the desired capability is present on 'path'.
|
||||
|
||||
Args:
|
||||
path: File path.
|
||||
desired: Capability string (setcap/getcap style), e.g. "cap_net_raw+ep".
|
||||
|
||||
Returns:
|
||||
True if a change was applied, otherwise False.
|
||||
|
||||
Raises:
|
||||
AnsibleModule.fail_json on errors or if getcap is missing.
|
||||
"""
|
||||
desired_norm = _CapsValue.normalize(desired)
|
||||
current = self.get_current(path)
|
||||
|
||||
if current is None:
|
||||
self._module.fail_json(
|
||||
msg="getcap is required for idempotent capability management",
|
||||
hint="Install libcap tools (e.g. Debian/Ubuntu: 'libcap2-bin')",
|
||||
path=path,
|
||||
desired=desired_norm.value,
|
||||
)
|
||||
|
||||
if current.value == desired_norm.value:
|
||||
return False
|
||||
|
||||
# setcap accepts both '+ep' and '=ep', but we pass canonical '=...'.
|
||||
rc, out, err = self._module.run_command(["setcap", desired_norm.value, path])
|
||||
if rc != 0:
|
||||
msg = (err or out or "").strip() or "setcap failed"
|
||||
self._module.fail_json(msg=msg, path=path, capability=desired_norm.value)
|
||||
|
||||
verified = self.get_current(path)
|
||||
if verified is None or verified.value != desired_norm.value:
|
||||
self._module.fail_json(
|
||||
msg="capability verification failed after setcap",
|
||||
path=path,
|
||||
desired=desired_norm.value,
|
||||
current=(verified.value if verified else None),
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class BinaryDeploy:
|
||||
"""
|
||||
Deployment engine used by Ansible modules.
|
||||
|
||||
The instance consumes module parameters, plans whether an update is necessary,
|
||||
and then applies changes idempotently:
|
||||
- copy (optional)
|
||||
- permissions and ownership
|
||||
- capabilities (optional)
|
||||
- activation symlink
|
||||
"""
|
||||
|
||||
def __init__(self, module: AnsibleModule) -> None:
|
||||
self._module = module
|
||||
self._module.log("BinaryDeploy::__init__()")
|
||||
self._caps = _Caps(module)
|
||||
|
||||
@staticmethod
|
||||
def _parse_mode(mode: Any) -> int:
|
||||
"""
|
||||
Parse a file mode parameter into an int.
|
||||
|
||||
Args:
|
||||
mode: Octal mode as string (e.g. "0755") or int.
|
||||
|
||||
Returns:
|
||||
Parsed mode as int.
|
||||
"""
|
||||
if isinstance(mode, int):
|
||||
return mode
|
||||
s = str(mode).strip()
|
||||
return int(s, 8)
|
||||
|
||||
def _resolve_uid_gid(
|
||||
self, owner: Optional[str], group: Optional[str]
|
||||
) -> Tuple[Optional[int], Optional[int]]:
|
||||
"""
|
||||
Resolve owner/group into numeric uid/gid.
|
||||
|
||||
Raises:
|
||||
ValueError: If the user or group does not exist.
|
||||
"""
|
||||
try:
|
||||
return _Identity.resolve_uid(owner), _Identity.resolve_gid(group)
|
||||
except KeyError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
def _parse_items(self, raw: List[Dict[str, Any]]) -> List[BinaryItem]:
|
||||
"""
|
||||
Parse module 'items' parameter into BinaryItem objects.
|
||||
|
||||
Each raw item supports:
|
||||
- name (required)
|
||||
- src (optional, defaults to name)
|
||||
- link_name (optional, defaults to name)
|
||||
- capability (optional)
|
||||
"""
|
||||
self._module.log(f"BinaryDeploy::_parse_items(raw: {raw})")
|
||||
|
||||
items: List[BinaryItem] = []
|
||||
for it in raw:
|
||||
name = str(it["name"])
|
||||
src = str(it.get("src") or name)
|
||||
link_name = str(it.get("link_name") or name)
|
||||
cap = it.get("capability")
|
||||
items.append(
|
||||
BinaryItem(
|
||||
name=name,
|
||||
src=src,
|
||||
link_name=link_name,
|
||||
capability=str(cap) if cap else None,
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
def _plan(
|
||||
self,
|
||||
*,
|
||||
install_dir: str,
|
||||
link_dir: str,
|
||||
src_dir: Optional[str],
|
||||
do_copy: bool,
|
||||
items: List[BinaryItem],
|
||||
activation_name: str,
|
||||
owner: Optional[str],
|
||||
group: Optional[str],
|
||||
mode: int,
|
||||
) -> Tuple[bool, bool, Dict[str, Dict[str, bool]]]:
|
||||
"""
|
||||
Build an idempotent plan for all items.
|
||||
|
||||
Returns:
|
||||
Tuple of:
|
||||
- activated: whether the activation symlink points into install_dir
|
||||
- needs_update: whether any operation would be required
|
||||
- per_item_plan: dict(item.name -> {copy, perms, cap, link})
|
||||
"""
|
||||
self._module.log(
|
||||
"BinaryDeploy::_plan("
|
||||
f"install_dir: {install_dir}, link_dir: {link_dir}, src_dir: {src_dir}, "
|
||||
f"do_copy: {do_copy}, items: {items}, activation_name: {activation_name}, "
|
||||
f"owner: {owner}, group: {group}, mode: {mode})"
|
||||
)
|
||||
|
||||
activation = next(
|
||||
(
|
||||
i
|
||||
for i in items
|
||||
if i.name == activation_name or i.link_name == activation_name
|
||||
),
|
||||
items[0],
|
||||
)
|
||||
activation_target = os.path.join(install_dir, activation.name)
|
||||
activation_link = os.path.join(link_dir, activation.link_name)
|
||||
activated = os.path.isfile(activation_target) and _PathOps.is_symlink_to(
|
||||
activation_link, activation_target
|
||||
)
|
||||
|
||||
try:
|
||||
uid, gid = self._resolve_uid_gid(owner, group)
|
||||
except ValueError as exc:
|
||||
self._module.fail_json(msg=str(exc))
|
||||
|
||||
needs_update = False
|
||||
per_item: Dict[str, Dict[str, bool]] = {}
|
||||
|
||||
for item in items:
|
||||
dst = os.path.join(install_dir, item.name)
|
||||
lnk = os.path.join(link_dir, item.link_name)
|
||||
src = os.path.join(src_dir, item.src) if (do_copy and src_dir) else None
|
||||
|
||||
item_plan: Dict[str, bool] = {
|
||||
"copy": False,
|
||||
"perms": False,
|
||||
"cap": False,
|
||||
"link": False,
|
||||
}
|
||||
|
||||
if do_copy:
|
||||
if src is None:
|
||||
self._module.fail_json(
|
||||
msg="src_dir is required when copy=true", item=item.name
|
||||
)
|
||||
if not os.path.isfile(src):
|
||||
self._module.fail_json(
|
||||
msg="source binary missing on remote host",
|
||||
src=src,
|
||||
item=item.name,
|
||||
)
|
||||
if not os.path.exists(dst) or not _PathOps.files_equal(src, dst):
|
||||
item_plan["copy"] = True
|
||||
|
||||
# perms/ownership (if file missing, perms will be set later)
|
||||
try:
|
||||
st = os.stat(dst)
|
||||
if (st.st_mode & 0o7777) != mode:
|
||||
item_plan["perms"] = True
|
||||
if uid is not None and st.st_uid != uid:
|
||||
item_plan["perms"] = True
|
||||
if gid is not None and st.st_gid != gid:
|
||||
item_plan["perms"] = True
|
||||
except FileNotFoundError:
|
||||
item_plan["perms"] = True
|
||||
|
||||
if item.capability:
|
||||
desired_norm = _CapsValue.normalize(item.capability)
|
||||
|
||||
if not os.path.exists(dst):
|
||||
item_plan["cap"] = True
|
||||
else:
|
||||
current = self._caps.get_current(dst)
|
||||
if current is None:
|
||||
# getcap missing -> cannot validate, apply will fail in ensure().
|
||||
item_plan["cap"] = True
|
||||
elif current.value != desired_norm.value:
|
||||
item_plan["cap"] = True
|
||||
|
||||
if not _PathOps.is_symlink_to(lnk, dst):
|
||||
item_plan["link"] = True
|
||||
|
||||
if any(item_plan.values()):
|
||||
needs_update = True
|
||||
per_item[item.name] = item_plan
|
||||
|
||||
return activated, needs_update, per_item
|
||||
|
||||
def run(self) -> None:
|
||||
"""
|
||||
Execute the deployment based on module parameters.
|
||||
|
||||
Module parameters (expected):
|
||||
install_dir (str), link_dir (str), src_dir (optional str), copy (bool),
|
||||
items (list[dict]), activation_name (optional str),
|
||||
owner (optional str), group (optional str), mode (str),
|
||||
cleanup_on_failure (bool), check_only (bool).
|
||||
"""
|
||||
self._module.log("BinaryDeploy::run()")
|
||||
|
||||
p = self._module.params
|
||||
|
||||
install_dir: str = p["install_dir"]
|
||||
link_dir: str = p["link_dir"]
|
||||
src_dir: Optional[str] = p.get("src_dir")
|
||||
do_copy: bool = bool(p["copy"])
|
||||
cleanup_on_failure: bool = bool(p["cleanup_on_failure"])
|
||||
activation_name: str = str(p.get("activation_name") or "")
|
||||
|
||||
owner: Optional[str] = p.get("owner")
|
||||
group: Optional[str] = p.get("group")
|
||||
mode_int = self._parse_mode(p["mode"])
|
||||
|
||||
items = self._parse_items(p["items"])
|
||||
if not items:
|
||||
self._module.fail_json(msg="items must not be empty")
|
||||
|
||||
if not activation_name:
|
||||
activation_name = items[0].name
|
||||
|
||||
check_only: bool = bool(p["check_only"]) or bool(self._module.check_mode)
|
||||
|
||||
activated, needs_update, plan = self._plan(
|
||||
install_dir=install_dir,
|
||||
link_dir=link_dir,
|
||||
src_dir=src_dir,
|
||||
do_copy=do_copy,
|
||||
items=items,
|
||||
activation_name=activation_name,
|
||||
owner=owner,
|
||||
group=group,
|
||||
mode=mode_int,
|
||||
)
|
||||
|
||||
if check_only:
|
||||
self._module.exit_json(
|
||||
changed=False, activated=activated, needs_update=needs_update, plan=plan
|
||||
)
|
||||
|
||||
changed = False
|
||||
details: Dict[str, Dict[str, bool]] = {}
|
||||
|
||||
try:
|
||||
if _PathOps.ensure_dir(install_dir):
|
||||
changed = True
|
||||
|
||||
uid, gid = self._resolve_uid_gid(owner, group)
|
||||
|
||||
for item in items:
|
||||
src = os.path.join(src_dir, item.src) if (do_copy and src_dir) else None
|
||||
dst = os.path.join(install_dir, item.name)
|
||||
lnk = os.path.join(link_dir, item.link_name)
|
||||
|
||||
item_changed: Dict[str, bool] = {
|
||||
"copied": False,
|
||||
"perms": False,
|
||||
"cap": False,
|
||||
"link": False,
|
||||
}
|
||||
|
||||
if do_copy:
|
||||
if src is None:
|
||||
self._module.fail_json(
|
||||
msg="src_dir is required when copy=true", item=item.name
|
||||
)
|
||||
if not os.path.exists(dst) or not _PathOps.files_equal(src, dst):
|
||||
_PathOps.atomic_copy(src, dst)
|
||||
item_changed["copied"] = True
|
||||
changed = True
|
||||
|
||||
if not os.path.exists(dst):
|
||||
self._module.fail_json(
|
||||
msg="destination binary missing in install_dir",
|
||||
dst=dst,
|
||||
hint="In controller-local mode this indicates the transfer/copy stage did not create the file.",
|
||||
item=item.name,
|
||||
)
|
||||
|
||||
st = os.stat(dst)
|
||||
|
||||
if (st.st_mode & 0o7777) != mode_int:
|
||||
os.chmod(dst, mode_int)
|
||||
item_changed["perms"] = True
|
||||
changed = True
|
||||
|
||||
if uid is not None or gid is not None:
|
||||
new_uid = uid if uid is not None else st.st_uid
|
||||
new_gid = gid if gid is not None else st.st_gid
|
||||
if new_uid != st.st_uid or new_gid != st.st_gid:
|
||||
os.chown(dst, new_uid, new_gid)
|
||||
item_changed["perms"] = True
|
||||
changed = True
|
||||
|
||||
if item.capability:
|
||||
if self._caps.ensure(dst, item.capability):
|
||||
item_changed["cap"] = True
|
||||
changed = True
|
||||
|
||||
if _PathOps.ensure_symlink(lnk, dst):
|
||||
item_changed["link"] = True
|
||||
changed = True
|
||||
|
||||
details[item.name] = item_changed
|
||||
|
||||
except Exception as exc:
|
||||
if cleanup_on_failure:
|
||||
try:
|
||||
_PathOps.safe_rmtree(install_dir)
|
||||
except Exception:
|
||||
pass
|
||||
self._module.fail_json(msg=str(exc), exception=repr(exc))
|
||||
|
||||
activation = next(
|
||||
(
|
||||
i
|
||||
for i in items
|
||||
if i.name == activation_name or i.link_name == activation_name
|
||||
),
|
||||
items[0],
|
||||
)
|
||||
activation_target = os.path.join(install_dir, activation.name)
|
||||
activation_link = os.path.join(link_dir, activation.link_name)
|
||||
activated = os.path.isfile(activation_target) and _PathOps.is_symlink_to(
|
||||
activation_link, activation_target
|
||||
)
|
||||
|
||||
self._module.exit_json(
|
||||
changed=changed, activated=activated, needs_update=False, details=details
|
||||
)
|
||||
|
|
@ -0,0 +1,463 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2025, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
from typing import Any, Dict, List, Mapping, Optional, Protocol, Sequence, Tuple
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.deb822_repo import (
|
||||
Deb822RepoManager,
|
||||
Deb822RepoSpec,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
module: apt_sources
|
||||
version_added: '2.9.0'
|
||||
author: "Bodo Schulz (@bodsch) <bodo@boone-schulz.de>"
|
||||
|
||||
short_description: Manage APT deb822 (.sources) repositories with repo-specific keyrings.
|
||||
description:
|
||||
- Creates/removes deb822 formatted APT repository files in /etc/apt/sources.list.d.
|
||||
- Supports importing repo-specific signing keys either via downloading a key file (with optional dearmor/validation)
|
||||
or by installing a keyring .deb package (e.g. Sury keyring).
|
||||
- Optionally runs apt-get update when changes occur.
|
||||
options:
|
||||
name:
|
||||
description: Logical name of the repository (used for defaults like filename).
|
||||
type: str
|
||||
required: true
|
||||
state:
|
||||
description: Whether the repository should be present or absent.
|
||||
type: str
|
||||
choices: [present, absent]
|
||||
default: present
|
||||
dest:
|
||||
description: Full path of the .sources file. If omitted, computed from filename/name.
|
||||
type: str
|
||||
filename:
|
||||
description: Filename under /etc/apt/sources.list.d/ (must end with .sources).
|
||||
type: str
|
||||
types:
|
||||
description: Repository types (deb, deb-src).
|
||||
type: list
|
||||
elements: str
|
||||
default: ["deb"]
|
||||
uris:
|
||||
description: Base URIs of the repository.
|
||||
type: list
|
||||
elements: str
|
||||
required: true
|
||||
suites:
|
||||
description: Suites / distributions (e.g. bookworm). If suite ends with '/', Components must be omitted.
|
||||
type: list
|
||||
elements: str
|
||||
required: true
|
||||
components:
|
||||
description: Components (e.g. main, contrib). Required unless suite is a path ending in '/'.
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
architectures:
|
||||
description: Restrict repository to architectures (e.g. amd64).
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
enabled:
|
||||
description: Whether the source is enabled (Enabled: yes/no).
|
||||
type: bool
|
||||
default: true
|
||||
signed_by:
|
||||
description: Absolute path to a keyring file used as Signed-By. If omitted and key.method is download/deb, derived from key config.
|
||||
type: str
|
||||
key:
|
||||
description: Key import configuration.
|
||||
type: dict
|
||||
suboptions:
|
||||
method:
|
||||
description: How to manage keys.
|
||||
type: str
|
||||
choices: [none, download, deb]
|
||||
default: none
|
||||
url:
|
||||
description: URL to download the key (download) or keyring .deb (deb).
|
||||
type: str
|
||||
dest:
|
||||
description: Destination keyring path for method=download.
|
||||
type: str
|
||||
checksum:
|
||||
description: Optional SHA256 checksum of downloaded content (raw download). Enables strict idempotence and integrity checks.
|
||||
type: str
|
||||
dearmor:
|
||||
description: If true and downloaded key is ASCII armored, dearmor via gpg to a binary keyring.
|
||||
type: bool
|
||||
default: true
|
||||
validate:
|
||||
description: If true, validate the final key file via gpg --show-keys.
|
||||
type: bool
|
||||
default: true
|
||||
mode:
|
||||
description: File mode for key files / deb cache files.
|
||||
type: str
|
||||
default: "0644"
|
||||
deb_cache_path:
|
||||
description: Destination path for downloaded .deb when method=deb.
|
||||
type: str
|
||||
deb_keyring_path:
|
||||
description: Explicit keyring path provided by that .deb (if auto-detection is not possible).
|
||||
type: str
|
||||
update_cache:
|
||||
description: Run apt-get update if repo/key changed.
|
||||
type: bool
|
||||
default: false
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
- name: Add Sury repo via keyring deb package (Debian)
|
||||
bodsch.core.apt_sources:
|
||||
name: debsuryorg
|
||||
uris: ["https://packages.sury.org/php/"]
|
||||
suites: ["{{ ansible_facts.distribution_release }}"]
|
||||
components: ["main"]
|
||||
key:
|
||||
method: deb
|
||||
url: "https://packages.sury.org/debsuryorg-archive-keyring.deb"
|
||||
deb_cache_path: "/var/cache/apt/debsuryorg-archive-keyring.deb"
|
||||
# optional if auto-detect fails:
|
||||
# deb_keyring_path: "/usr/share/keyrings/debsuryorg-archive-keyring.gpg"
|
||||
update_cache: true
|
||||
become: true
|
||||
|
||||
- name: Add CZ.NIC repo via key download (bookworm)
|
||||
bodsch.core.apt_sources:
|
||||
name: cznic-labs-knot-resolver
|
||||
uris: ["https://pkg.labs.nic.cz/knot-resolver"]
|
||||
suites: ["bookworm"]
|
||||
components: ["main"]
|
||||
key:
|
||||
method: download
|
||||
url: "https://pkg.labs.nic.cz/gpg"
|
||||
dest: "/usr/share/keyrings/cznic-labs-pkg.gpg"
|
||||
dearmor: true
|
||||
validate: true
|
||||
update_cache: true
|
||||
become: true
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
repo_path:
|
||||
description: Path to the managed .sources file.
|
||||
returned: always
|
||||
type: str
|
||||
key_path:
|
||||
description: Path to the keyring file used as Signed-By (if managed/derived).
|
||||
returned: when key method used or signed_by provided
|
||||
type: str
|
||||
changed:
|
||||
description: Whether any change was made.
|
||||
returned: always
|
||||
type: bool
|
||||
messages:
|
||||
description: Informational messages about performed actions.
|
||||
returned: always
|
||||
type: list
|
||||
elements: str
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AnsibleModuleLike(Protocol):
|
||||
"""Minimal typing surface for the Ansible module used by this helper."""
|
||||
|
||||
params: Mapping[str, Any]
|
||||
|
||||
def get_bin_path(self, arg: str, required: bool = False) -> Optional[str]:
|
||||
"""
|
||||
Return the absolute path to an executable.
|
||||
|
||||
Args:
|
||||
arg: Program name to look up in PATH.
|
||||
required: If True, the module typically fails when the binary is not found.
|
||||
|
||||
Returns:
|
||||
Absolute path to the executable, or None if not found and not required.
|
||||
"""
|
||||
...
|
||||
|
||||
def run_command(
|
||||
self, args: Sequence[str], check_rc: bool = True
|
||||
) -> Tuple[int, str, str]:
|
||||
"""
|
||||
Execute a command on the target host.
|
||||
|
||||
Args:
|
||||
args: Argument vector (already split).
|
||||
check_rc: If True, non-zero return codes should be treated as errors.
|
||||
|
||||
Returns:
|
||||
Tuple ``(rc, stdout, stderr)``.
|
||||
"""
|
||||
...
|
||||
|
||||
def log(self, msg: str = "", **kwargs: Any) -> None:
|
||||
"""
|
||||
Write a log/debug message via the Ansible module.
|
||||
|
||||
Args:
|
||||
msg: Message text.
|
||||
**kwargs: Additional structured log fields (module dependent).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class AptSources:
|
||||
"""
|
||||
Manage APT deb822 (.sources) repositories with repo-specific keyrings.
|
||||
|
||||
This class is the orchestration layer used by the module entrypoint. It delegates the
|
||||
actual file/key handling to :class:`Deb822RepoManager` and is responsible for:
|
||||
- computing the target .sources path
|
||||
- ensuring/removing repository key material (method=download or method=deb)
|
||||
- ensuring/removing the repository file
|
||||
- optionally running ``apt-get update`` when changes occur
|
||||
"""
|
||||
|
||||
module = None
|
||||
|
||||
def __init__(self, module: AnsibleModuleLike):
|
||||
"""
|
||||
Initialize the handler and snapshot module parameters.
|
||||
|
||||
Args:
|
||||
module: An AnsibleModule-like object providing ``params``, logging and command execution.
|
||||
"""
|
||||
self.module = module
|
||||
|
||||
self.module.log("AptSources::__init__()")
|
||||
|
||||
self.name = module.params.get("name")
|
||||
self.state = module.params.get("state")
|
||||
self.destination = module.params.get("dest")
|
||||
self.filename = module.params.get("filename")
|
||||
self.types = module.params.get("types")
|
||||
self.uris = module.params.get("uris")
|
||||
self.suites = module.params.get("suites")
|
||||
self.components = module.params.get("components")
|
||||
self.architectures = module.params.get("architectures")
|
||||
self.enabled = module.params.get("enabled")
|
||||
self.update_cache = module.params.get("update_cache")
|
||||
self.signed_by = module.params.get("signed_by")
|
||||
self.keys = module.params.get("key")
|
||||
|
||||
self.option_method = self.keys.get("method")
|
||||
self.option_url = self.keys.get("url")
|
||||
self.option_dest = self.keys.get("dest")
|
||||
self.option_checksum = self.keys.get("checksum")
|
||||
self.option_dearmor = self.keys.get("dearmor")
|
||||
self.option_validate = self.keys.get("validate")
|
||||
self.option_mode = self.keys.get("mode")
|
||||
self.option_deb_cache_path = self.keys.get("deb_cache_path")
|
||||
self.option_deb_keyring_path = self.keys.get("deb_keyring_path")
|
||||
|
||||
def run(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Apply the requested repository state.
|
||||
|
||||
For ``state=present`` the method ensures the signing key (if configured) and then writes the
|
||||
deb822 repository file. For ``state=absent`` it removes the repository file and any managed
|
||||
key material.
|
||||
|
||||
Returns:
|
||||
A result dictionary intended for ``module.exit_json()``, containing:
|
||||
|
||||
- ``changed``: Whether any managed resource changed.
|
||||
- ``repo_path``: Path to the managed ``.sources`` file.
|
||||
- ``key_path``: Path to the keyring file used for ``Signed-By`` (if any).
|
||||
- ``messages``: Informational messages describing performed actions.
|
||||
|
||||
Note:
|
||||
When ``state=absent`` this method exits the module early via ``module.exit_json()``.
|
||||
"""
|
||||
self.module.log("AptSources::run()")
|
||||
|
||||
# self.module.log(f" - update_cache: {self.update_cache}")
|
||||
|
||||
mng = Deb822RepoManager(self.module)
|
||||
|
||||
repo_path = self._ensure_sources_path(
|
||||
mng, self.name, self.destination, self.filename
|
||||
)
|
||||
|
||||
changed = False
|
||||
messages: List[str] = []
|
||||
|
||||
if self.state == "absent":
|
||||
key_cfg: Dict[str, Any] = self.keys or {"method": "none"}
|
||||
|
||||
if mng.remove_file(path=repo_path, check_mode=bool(self.module.check_mode)):
|
||||
changed = True
|
||||
messages.append(f"removed repo file: {repo_path}")
|
||||
|
||||
# remove managed key material as well
|
||||
key_res = mng.remove_key(
|
||||
key_cfg=key_cfg,
|
||||
signed_by=self.signed_by,
|
||||
check_mode=bool(self.module.check_mode),
|
||||
)
|
||||
if key_res.messages:
|
||||
messages.extend(list(key_res.messages))
|
||||
if key_res.changed:
|
||||
changed = True
|
||||
|
||||
self.module.exit_json(
|
||||
changed=changed,
|
||||
repo_path=repo_path,
|
||||
key_path=(self.signed_by or key_res.key_path),
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
# present
|
||||
key_cfg: Dict[str, Any] = self.keys or {"method": "none"}
|
||||
key_res = mng.ensure_key(key_cfg=key_cfg, check_mode=self.module.check_mode)
|
||||
|
||||
# self.module.log(f" - key_res : {key_res}")
|
||||
|
||||
if key_res.messages:
|
||||
messages.extend(list(key_res.messages))
|
||||
|
||||
if key_res.changed:
|
||||
changed = True
|
||||
|
||||
signed_by: Optional[str] = self.signed_by or key_res.key_path
|
||||
|
||||
spec = Deb822RepoSpec(
|
||||
types=self.types,
|
||||
uris=self.uris,
|
||||
suites=self.suites,
|
||||
components=self.components,
|
||||
architectures=self.architectures,
|
||||
enabled=self.enabled,
|
||||
signed_by=signed_by,
|
||||
)
|
||||
|
||||
repo_mode = 0o644
|
||||
repo_res = mng.ensure_repo_file(
|
||||
repo_path=repo_path,
|
||||
spec=spec,
|
||||
mode=repo_mode,
|
||||
check_mode=self.module.check_mode,
|
||||
)
|
||||
|
||||
# self.module.log(f" - repo_res : {repo_res}")
|
||||
|
||||
if repo_res.changed:
|
||||
changed = True
|
||||
messages.append(f"updated repo file: {repo_path}")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Optionally update cache only if something changed
|
||||
if self.update_cache and (key_res.changed or repo_res.changed):
|
||||
_, out = mng.apt_update(check_mode=self.module.check_mode)
|
||||
messages.append("apt-get update executed")
|
||||
if out:
|
||||
# keep it short to avoid noisy output
|
||||
messages.append("apt-get update: ok")
|
||||
|
||||
return dict(
|
||||
changed=changed,
|
||||
repo_path=repo_path,
|
||||
key_path=signed_by,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
def _ensure_sources_path(
|
||||
self,
|
||||
manager: Deb822RepoManager,
|
||||
name: str,
|
||||
dest: Optional[str],
|
||||
filename: Optional[str],
|
||||
) -> str:
|
||||
"""
|
||||
Determine the destination path of the ``.sources`` file.
|
||||
|
||||
If ``dest`` is provided it is returned unchanged. Otherwise a filename is derived from
|
||||
``filename`` or ``name``, validated, and placed under ``/etc/apt/sources.list.d/``.
|
||||
|
||||
Args:
|
||||
manager: Repo manager used for validation.
|
||||
name: Logical repository name.
|
||||
dest: Explicit destination path (optional).
|
||||
filename: Filename (optional, must end in ``.sources``).
|
||||
|
||||
Returns:
|
||||
The absolute path of the repository file to manage.
|
||||
"""
|
||||
|
||||
if dest:
|
||||
return dest
|
||||
|
||||
fn = filename or f"{name}.sources"
|
||||
# validate filename rules and suffix
|
||||
manager.validate_filename(fn)
|
||||
return f"/etc/apt/sources.list.d/{fn}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
Entrypoint for the Ansible module.
|
||||
|
||||
Parses module arguments, executes the handler and returns the result via ``exit_json``.
|
||||
"""
|
||||
args = dict(
|
||||
name=dict(type="str", required=True),
|
||||
state=dict(type="str", choices=["present", "absent"], default="present"),
|
||||
dest=dict(type="str", required=False),
|
||||
filename=dict(type="str", required=False),
|
||||
types=dict(type="list", elements="str", default=["deb"]),
|
||||
uris=dict(type="list", elements="str", required=True),
|
||||
suites=dict(type="list", elements="str", required=True),
|
||||
components=dict(type="list", elements="str", default=[]),
|
||||
architectures=dict(type="list", elements="str", default=[]),
|
||||
enabled=dict(type="bool", default=True),
|
||||
signed_by=dict(type="str", required=False),
|
||||
key=dict(
|
||||
type="dict",
|
||||
required=False,
|
||||
options=dict(
|
||||
method=dict(
|
||||
type="str", choices=["none", "download", "deb"], default="none"
|
||||
),
|
||||
url=dict(type="str", required=False),
|
||||
dest=dict(type="str", required=False),
|
||||
checksum=dict(type="str", required=False),
|
||||
dearmor=dict(type="bool", default=True),
|
||||
validate=dict(type="bool", default=True),
|
||||
mode=dict(type="str", default="0644"),
|
||||
deb_cache_path=dict(type="str", required=False),
|
||||
deb_keyring_path=dict(type="str", required=False),
|
||||
),
|
||||
),
|
||||
update_cache=dict(type="bool", default=False),
|
||||
)
|
||||
module = AnsibleModule(
|
||||
argument_spec=args,
|
||||
supports_check_mode=False,
|
||||
)
|
||||
|
||||
handler = AptSources(module)
|
||||
result = handler.run()
|
||||
|
||||
module.log(msg=f"= result: {result}")
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,959 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tarfile
|
||||
import urllib.parse
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Mapping,
|
||||
Optional,
|
||||
Protocol,
|
||||
Sequence,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible.module_utils.urls import open_url
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
---
|
||||
module: aur
|
||||
short_description: Install or remove Arch Linux packages from the AUR
|
||||
version_added: "0.9.0"
|
||||
author:
|
||||
- Bodo Schulz (@bodsch) <bodo@boone-schulz.de>
|
||||
|
||||
description:
|
||||
- Installs packages from the Arch User Repository (AUR) by building them with C(makepkg).
|
||||
- Recommended: install from a Git repository URL (cloned into C($HOME/<name>), then updated via C(git pull)).
|
||||
- Fallback: if C(repository) is omitted, the module queries the AUR RPC API and downloads/extracts the source tarball to build it.
|
||||
- Ensures idempotency by comparing the currently installed package version with the upstream version (prefers C(.SRCINFO),
|
||||
- falls back to parsing C(PKGBUILD)); pkgrel-only updates trigger a rebuild.
|
||||
|
||||
options:
|
||||
state:
|
||||
description:
|
||||
- Whether the package should be installed or removed.
|
||||
type: str
|
||||
default: present
|
||||
choices: [present, absent]
|
||||
|
||||
name:
|
||||
description:
|
||||
- Package name to manage (pacman package name / AUR package name).
|
||||
type: str
|
||||
required: true
|
||||
|
||||
repository:
|
||||
description:
|
||||
- Git repository URL that contains the PKGBUILD (usually under U(https://aur.archlinux.org)).
|
||||
- If omitted, the module uses the AUR RPC API to download the source tarball.
|
||||
type: str
|
||||
required: false
|
||||
|
||||
extra_args:
|
||||
description:
|
||||
- Additional arguments passed to C(makepkg) (for example C(--skippgpcheck), C(--nocheck)).
|
||||
type: list
|
||||
elements: str
|
||||
required: false
|
||||
version_added: "2.2.4"
|
||||
|
||||
notes:
|
||||
- Check mode is not supported.
|
||||
- The module is expected to run as a non-root build user (e.g. via C(become_user: aur_builder)).
|
||||
- The build user must be able to install packages non-interactively (makepkg/pacman), and to remove
|
||||
- packages this module uses C(sudo pacman -R...) when C(state=absent).
|
||||
- Network access to AUR is required for repository cloning/pulling or tarball download.
|
||||
|
||||
requirements:
|
||||
- pacman
|
||||
- git (when C(repository) is used)
|
||||
- makepkg (base-devel)
|
||||
- sudo (for C(state=absent) removal path)
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
- name: Install package via AUR repository (recommended)
|
||||
become: true
|
||||
become_user: aur_builder
|
||||
bodsch.core.aur:
|
||||
state: present
|
||||
name: icinga2
|
||||
repository: https://aur.archlinux.org/icinga2.git
|
||||
|
||||
- name: Install package via AUR repository with makepkg extra arguments
|
||||
become: true
|
||||
become_user: aur_builder
|
||||
bodsch.core.aur:
|
||||
state: present
|
||||
name: php-pear
|
||||
repository: https://aur.archlinux.org/php-pear.git
|
||||
extra_args:
|
||||
- --skippgpcheck
|
||||
|
||||
- name: Install package via AUR tarball download (repository omitted)
|
||||
become: true
|
||||
become_user: aur_builder
|
||||
bodsch.core.aur:
|
||||
state: present
|
||||
name: yay
|
||||
|
||||
- name: Remove package
|
||||
become: true
|
||||
bodsch.core.aur:
|
||||
state: absent
|
||||
name: yay
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
changed:
|
||||
description:
|
||||
- Whether the module made changes.
|
||||
- C(true) when a package was installed/rebuilt/removed, otherwise C(false).
|
||||
returned: always
|
||||
type: bool
|
||||
|
||||
failed:
|
||||
description:
|
||||
- Indicates whether the module failed.
|
||||
returned: always
|
||||
type: bool
|
||||
|
||||
msg:
|
||||
description:
|
||||
- Human readable status or error message.
|
||||
- For idempotent runs, typically reports that the version is already installed.
|
||||
returned: always
|
||||
type: str
|
||||
sample:
|
||||
- "Package yay successfully installed."
|
||||
- "Package yay successfully removed."
|
||||
- "Version 1.2.3-1 is already installed."
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AnsibleModuleLike(Protocol):
|
||||
"""Minimal typing surface for the Ansible module used by this helper."""
|
||||
|
||||
params: Mapping[str, Any]
|
||||
|
||||
def get_bin_path(self, arg: str, required: bool = False) -> Optional[str]:
|
||||
"""
|
||||
Return the absolute path to an executable.
|
||||
|
||||
Args:
|
||||
arg: Program name to look up in PATH.
|
||||
required: If True, the module typically fails when the binary is not found.
|
||||
|
||||
Returns:
|
||||
Absolute path to the executable, or None if not found and not required.
|
||||
"""
|
||||
...
|
||||
|
||||
def run_command(
|
||||
self, args: Sequence[str], check_rc: bool = True
|
||||
) -> Tuple[int, str, str]:
|
||||
"""
|
||||
Execute a command on the target host.
|
||||
|
||||
Args:
|
||||
args: Argument vector (already split).
|
||||
check_rc: If True, non-zero return codes should be treated as errors.
|
||||
|
||||
Returns:
|
||||
Tuple ``(rc, stdout, stderr)``.
|
||||
"""
|
||||
...
|
||||
|
||||
def log(self, msg: str = "", **kwargs: Any) -> None:
|
||||
"""
|
||||
Write a log/debug message via the Ansible module.
|
||||
|
||||
Args:
|
||||
msg: Message text.
|
||||
**kwargs: Additional structured log fields (module dependent).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
_PACMAN_Q_RE = re.compile(r"^(?P<name>\S+)\s+(?P<ver>\S+)\s*$", re.MULTILINE)
|
||||
_PKGBUILD_PKGVER_RE = re.compile(r"^pkgver=(?P<version>.*)\s*$", re.MULTILINE)
|
||||
_PKGBUILD_EPOCH_RE = re.compile(r"^epoch=(?P<epoch>.*)\s*$", re.MULTILINE)
|
||||
_SRCINFO_PKGVER_RE = re.compile(r"^\s*pkgver\s*=\s*(?P<version>.*)\s*$", re.MULTILINE)
|
||||
_SRCINFO_EPOCH_RE = re.compile(r"^\s*epoch\s*=\s*(?P<epoch>.*)\s*$", re.MULTILINE)
|
||||
_PKGBUILD_PKGREL_RE = re.compile(r"^pkgrel=(?P<pkgrel>.*)\s*$", re.MULTILINE)
|
||||
_SRCINFO_PKGREL_RE = re.compile(r"^\s*pkgrel\s*=\s*(?P<pkgrel>.*)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
class Aur:
|
||||
"""
|
||||
Implements AUR package installation/removal.
|
||||
|
||||
Notes:
|
||||
- The module is expected to run as a non-root user that is allowed to build packages
|
||||
via makepkg (e.g. a dedicated 'aur_builder' user).
|
||||
- Repository-based installation is recommended. The tarball-based installation path
|
||||
exists as a fallback when no repository URL is provided.
|
||||
"""
|
||||
|
||||
module = None
|
||||
|
||||
def __init__(self, module: AnsibleModuleLike):
|
||||
"""
|
||||
Initialize helper state from Ansible module parameters.
|
||||
"""
|
||||
self.module = module
|
||||
self.module.log("Aur::__init__()")
|
||||
|
||||
self.state: str = module.params.get("state")
|
||||
self.name: str = module.params.get("name")
|
||||
self.repository: Optional[str] = module.params.get("repository")
|
||||
self.extra_args: Optional[List[str]] = module.params.get("extra_args")
|
||||
|
||||
# Cached state for idempotency decisions during this module run.
|
||||
self._installed_version: Optional[str] = None
|
||||
self._installed_version_full: Optional[str] = None
|
||||
|
||||
self.pacman_binary: Optional[str] = self.module.get_bin_path("pacman", True)
|
||||
self.git_binary: Optional[str] = self.module.get_bin_path("git", True)
|
||||
|
||||
def run(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute the requested state transition.
|
||||
|
||||
Returns:
|
||||
A result dictionary consumable by Ansible's exit_json().
|
||||
"""
|
||||
self.module.log("Aur::run()")
|
||||
|
||||
installed, installed_version = self.package_installed(self.name)
|
||||
|
||||
# Store installed version for use by other code paths (e.g. AUR tarball installs).
|
||||
self._installed_version = installed_version
|
||||
self._installed_version_full = (
|
||||
self._package_installed_full_version(self.name) if installed else None
|
||||
)
|
||||
|
||||
if self._installed_version_full:
|
||||
self.module.log(
|
||||
msg=f" {self.name} full version: {self._installed_version_full}"
|
||||
)
|
||||
|
||||
self.module.log(
|
||||
msg=f" {self.name} is installed: {installed} / version: {installed_version}"
|
||||
)
|
||||
|
||||
if installed and self.state == "absent":
|
||||
sudo_binary = self.module.get_bin_path("sudo", True)
|
||||
|
||||
args: List[str] = [
|
||||
sudo_binary,
|
||||
self.pacman_binary or "pacman",
|
||||
"--remove",
|
||||
"--cascade",
|
||||
"--recursive",
|
||||
"--noconfirm",
|
||||
self.name,
|
||||
]
|
||||
|
||||
rc, _, err = self._exec(args)
|
||||
|
||||
if rc == 0:
|
||||
return dict(
|
||||
changed=True, msg=f"Package {self.name} successfully removed."
|
||||
)
|
||||
return dict(
|
||||
failed=True,
|
||||
changed=False,
|
||||
msg=f"An error occurred while removing the package {self.name}: {err}",
|
||||
)
|
||||
|
||||
if self.state == "present":
|
||||
if self.repository:
|
||||
rc, out, err, changed = self.install_from_repository(installed_version)
|
||||
|
||||
if rc == 99:
|
||||
msg = out
|
||||
rc = 0
|
||||
else:
|
||||
msg = f"Package {self.name} successfully installed."
|
||||
else:
|
||||
rc, out, err, changed = self.install_from_aur()
|
||||
msg = (
|
||||
out
|
||||
if rc == 0 and out
|
||||
else f"Package {self.name} successfully installed."
|
||||
)
|
||||
|
||||
if rc == 0:
|
||||
return dict(failed=False, changed=changed, msg=msg)
|
||||
return dict(failed=True, msg=err)
|
||||
|
||||
return dict(
|
||||
failed=False,
|
||||
changed=False,
|
||||
msg="It's all right. Keep moving! There is nothing to see!",
|
||||
)
|
||||
|
||||
def package_installed(self, package: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Determine whether a package is installed and return its version key (epoch+pkgver, without pkgrel).
|
||||
|
||||
Args:
|
||||
package: Pacman package name to check.
|
||||
|
||||
Returns:
|
||||
Tuple (installed, version_string)
|
||||
- installed: True if pacman reports the package is installed.
|
||||
- version_string: comparable version key '<epoch>:<pkgver>' without pkgrel (epoch optional) or None if not installed.
|
||||
"""
|
||||
self.module.log(f"Aur::package_installed(package: {package})")
|
||||
|
||||
args: List[str] = [
|
||||
self.pacman_binary or "pacman",
|
||||
"--query",
|
||||
package,
|
||||
]
|
||||
|
||||
rc, out, _ = self._exec(args, check=False)
|
||||
|
||||
version_string: Optional[str] = None
|
||||
if out:
|
||||
m = _PACMAN_Q_RE.search(out)
|
||||
if m and m.group("name") == package:
|
||||
full_version = m.group("ver")
|
||||
# pacman prints "<epoch>:<pkgver>-<pkgrel>" (epoch optional).
|
||||
version_string = (
|
||||
full_version.rsplit("-", 1)[0]
|
||||
if "-" in full_version
|
||||
else full_version
|
||||
)
|
||||
|
||||
return (rc == 0, version_string)
|
||||
|
||||
def _package_installed_full_version(self, package: str) -> Optional[str]:
|
||||
"""
|
||||
Return the full pacman version string for an installed package.
|
||||
|
||||
The returned string includes both epoch and pkgrel if present, matching the output
|
||||
format of "pacman -Q":
|
||||
- "<epoch>:<pkgver>-<pkgrel>" (epoch optional)
|
||||
|
||||
Args:
|
||||
package: Pacman package name to check.
|
||||
|
||||
Returns:
|
||||
The full version string or None if the package is not installed.
|
||||
"""
|
||||
self.module.log(f"Aur::_package_installed_full_version(package: {package})")
|
||||
|
||||
args: List[str] = [
|
||||
self.pacman_binary or "pacman",
|
||||
"--query",
|
||||
package,
|
||||
]
|
||||
|
||||
rc, out, _ = self._exec(args, check=False)
|
||||
if rc != 0 or not out:
|
||||
return None
|
||||
|
||||
m = _PACMAN_Q_RE.search(out)
|
||||
if m and m.group("name") == package:
|
||||
return m.group("ver")
|
||||
|
||||
return None
|
||||
|
||||
def run_makepkg(self, directory: str) -> Tuple[int, str, str]:
|
||||
"""
|
||||
Run makepkg to build and install a package.
|
||||
|
||||
Args:
|
||||
directory: Directory containing the PKGBUILD.
|
||||
|
||||
Returns:
|
||||
Tuple (rc, out, err) from the makepkg execution.
|
||||
"""
|
||||
self.module.log(f"Aur::run_makepkg(directory: {directory})")
|
||||
self.module.log(f" current dir : {os.getcwd()}")
|
||||
|
||||
if not os.path.exists(directory):
|
||||
return (1, "", f"Directory '{directory}' does not exist.")
|
||||
|
||||
makepkg_binary = self.module.get_bin_path("makepkg", required=True) or "makepkg"
|
||||
|
||||
args: List[str] = [
|
||||
makepkg_binary,
|
||||
"--syncdeps",
|
||||
"--install",
|
||||
"--noconfirm",
|
||||
"--needed",
|
||||
"--clean",
|
||||
]
|
||||
|
||||
if self.extra_args:
|
||||
args += self.extra_args
|
||||
|
||||
with self._pushd(directory):
|
||||
rc, out, err = self._exec(args, check=False)
|
||||
|
||||
return (rc, out, err)
|
||||
|
||||
def install_from_aur(self) -> Tuple[int, str, str, bool]:
|
||||
"""
|
||||
Install a package by downloading its source tarball from AUR.
|
||||
|
||||
Returns:
|
||||
Tuple (rc, out, err, changed)
|
||||
"""
|
||||
self.module.log("Aur::install_from_aur()")
|
||||
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
rpc = self._aur_rpc_info(self.name)
|
||||
except Exception as exc:
|
||||
return (1, "", f"Failed to query AUR RPC API: {exc}", False)
|
||||
|
||||
if rpc.get("resultcount") != 1:
|
||||
return (1, "", f"Package '{self.name}' not found on AUR.", False)
|
||||
|
||||
result = rpc["results"][0]
|
||||
url_path = result.get("URLPath")
|
||||
if not url_path:
|
||||
return (1, "", f"AUR did not return a source URL for '{self.name}'.", False)
|
||||
|
||||
tar_url = f"https://aur.archlinux.org/{url_path}"
|
||||
self.module.log(f" tarball url {tar_url}")
|
||||
|
||||
try:
|
||||
f = open_url(tar_url)
|
||||
except Exception as exc:
|
||||
return (1, "", f"Failed to download AUR tarball: {exc}", False)
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with tarfile.open(mode="r|*", fileobj=f) as tar:
|
||||
self._safe_extract_stream(tar, tmpdir)
|
||||
|
||||
build_dir = self._find_pkgbuild_dir(tmpdir)
|
||||
if not build_dir:
|
||||
return (
|
||||
1,
|
||||
"",
|
||||
"Unable to locate PKGBUILD in extracted source tree.",
|
||||
False,
|
||||
)
|
||||
|
||||
upstream_version = self._read_upstream_version_key(build_dir)
|
||||
upstream_full_version = self._read_upstream_full_version(build_dir)
|
||||
|
||||
# Prefer comparing full versions (epoch:pkgver-pkgrel). This ensures pkgrel-only
|
||||
# bumps trigger a rebuild, matching pacman's notion of a distinct package version.
|
||||
if self._installed_version_full and upstream_full_version:
|
||||
if self._installed_version_full == upstream_full_version:
|
||||
return (
|
||||
0,
|
||||
f"Version {self._installed_version_full} is already installed.",
|
||||
"",
|
||||
False,
|
||||
)
|
||||
elif self._installed_version and upstream_version:
|
||||
if self._installed_version == upstream_version:
|
||||
return (
|
||||
0,
|
||||
f"Version {self._installed_version} is already installed.",
|
||||
"",
|
||||
False,
|
||||
)
|
||||
|
||||
rc, out, err = self.run_makepkg(build_dir)
|
||||
except Exception as exc:
|
||||
return (1, "", f"Failed to extract/build AUR source: {exc}", False)
|
||||
|
||||
return (rc, out, err, rc == 0)
|
||||
|
||||
def install_from_repository(
|
||||
self, installed_version: Optional[str]
|
||||
) -> Tuple[int, str, str, bool]:
|
||||
"""
|
||||
Install a package from a Git repository (recommended).
|
||||
|
||||
Args:
|
||||
installed_version: Currently installed version key '<epoch>:<pkgver>' without pkgrel (epoch optional) or None.
|
||||
|
||||
Returns:
|
||||
Tuple (rc, out, err, changed)
|
||||
|
||||
Special return code:
|
||||
- rc == 99 indicates "already installed / no change" (kept for backward compatibility).
|
||||
"""
|
||||
self.module.log(
|
||||
f"Aur::install_from_repository(installed_version: {installed_version})"
|
||||
)
|
||||
|
||||
base_dir = str(Path.home())
|
||||
repo_dir = os.path.join(base_dir, self.name)
|
||||
|
||||
with self._pushd(base_dir):
|
||||
if not os.path.exists(repo_dir):
|
||||
rc, out, _err = self.git_clone(repository=self.repository or "")
|
||||
if rc != 0:
|
||||
return (rc, out, "Unable to run 'git clone'.", False)
|
||||
|
||||
with self._pushd(repo_dir):
|
||||
if os.path.exists(".git"):
|
||||
rc, out, _err = self.git_pull()
|
||||
if rc != 0:
|
||||
return (rc, out, "Unable to run 'git pull'.", False)
|
||||
|
||||
with self._pushd(repo_dir):
|
||||
pkgbuild_file = "PKGBUILD"
|
||||
if not os.path.exists(pkgbuild_file):
|
||||
return (1, "", "Unable to find PKGBUILD.", False)
|
||||
|
||||
upstream_version = self._read_upstream_version_key(os.getcwd())
|
||||
upstream_full_version = self._read_upstream_full_version(os.getcwd())
|
||||
|
||||
# Prefer comparing full versions (epoch:pkgver-pkgrel). This ensures pkgrel-only bumps
|
||||
# trigger a rebuild even if pkgver stayed constant.
|
||||
if self._installed_version_full and upstream_full_version:
|
||||
if self._installed_version_full == upstream_full_version:
|
||||
return (
|
||||
99,
|
||||
f"Version {self._installed_version_full} is already installed.",
|
||||
"",
|
||||
False,
|
||||
)
|
||||
elif installed_version and upstream_version:
|
||||
if installed_version == upstream_version:
|
||||
return (
|
||||
99,
|
||||
f"Version {installed_version} is already installed.",
|
||||
"",
|
||||
False,
|
||||
)
|
||||
|
||||
self.module.log(
|
||||
msg=f"upstream version: {upstream_full_version or upstream_version}"
|
||||
)
|
||||
|
||||
rc, out, err = self.run_makepkg(repo_dir)
|
||||
|
||||
return (rc, out, err, rc == 0)
|
||||
|
||||
def git_clone(self, repository: str) -> Tuple[int, str, str]:
|
||||
"""
|
||||
Clone the repository into a local directory named after the package.
|
||||
|
||||
Returns:
|
||||
Tuple (rc, out, err)
|
||||
"""
|
||||
self.module.log(f"Aur::git_clone(repository: {repository})")
|
||||
|
||||
if not self.git_binary:
|
||||
return (1, "", "git not found")
|
||||
|
||||
args: List[str] = [
|
||||
self.git_binary,
|
||||
"clone",
|
||||
repository,
|
||||
self.name,
|
||||
]
|
||||
|
||||
rc, out, err = self._exec(args)
|
||||
return (rc, out, err)
|
||||
|
||||
def git_pull(self) -> Tuple[int, str, str]:
|
||||
"""
|
||||
Update an existing Git repository.
|
||||
|
||||
Returns:
|
||||
Tuple (rc, out, err)
|
||||
"""
|
||||
self.module.log("Aur::git_pull()")
|
||||
|
||||
if not self.git_binary:
|
||||
return (1, "", "git not found")
|
||||
|
||||
args: List[str] = [
|
||||
self.git_binary,
|
||||
"pull",
|
||||
]
|
||||
|
||||
rc, out, err = self._exec(args)
|
||||
return (rc, out, err)
|
||||
|
||||
def _exec(self, cmd: Sequence[str], check: bool = False) -> Tuple[int, str, str]:
|
||||
"""
|
||||
Execute a command via Ansible's run_command().
|
||||
|
||||
Args:
|
||||
cmd: Argument vector (already split).
|
||||
check: If True, fail the module on non-zero return code.
|
||||
|
||||
Returns:
|
||||
Tuple (rc, out, err)
|
||||
"""
|
||||
self.module.log(f"Aur::_exec(cmd: {cmd}, check: {check})")
|
||||
|
||||
rc, out, err = self.module.run_command(list(cmd), check_rc=check)
|
||||
|
||||
if rc != 0:
|
||||
self.module.log(f" rc : '{rc}'")
|
||||
self.module.log(f" out: '{out}'")
|
||||
self.module.log(f" err: '{err}'")
|
||||
|
||||
return (rc, out, err)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@contextmanager
|
||||
def _pushd(self, directory: str) -> Iterator[None]:
|
||||
"""
|
||||
Temporarily change the current working directory.
|
||||
|
||||
This avoids leaking state across module runs and improves correctness of
|
||||
commands like makepkg, git clone, and git pull.
|
||||
"""
|
||||
self.module.log(f"Aur::_pushd(directory: {directory})")
|
||||
|
||||
prev = os.getcwd()
|
||||
os.chdir(directory)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
os.chdir(prev)
|
||||
|
||||
def _aur_rpc_info(self, package: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Query the AUR RPC API for a package.
|
||||
|
||||
Returns:
|
||||
Parsed JSON dictionary.
|
||||
"""
|
||||
self.module.log(f"Aur::_aur_rpc_info(package: {package})")
|
||||
|
||||
url = "https://aur.archlinux.org/rpc/?v=5&type=info&arg=" + urllib.parse.quote(
|
||||
package
|
||||
)
|
||||
self.module.log(f" rpc url {url}")
|
||||
|
||||
resp = open_url(url)
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
def _safe_extract_stream(self, tar: tarfile.TarFile, target_dir: str) -> None:
|
||||
"""
|
||||
Safely extract a tar stream into target_dir.
|
||||
|
||||
This prevents path traversal attacks by validating each member's target path
|
||||
before extraction.
|
||||
"""
|
||||
self.module.log(
|
||||
f"Aur::_safe_extract_stream(tar: {tar}, target_dir: {target_dir})"
|
||||
)
|
||||
|
||||
target_real = os.path.realpath(target_dir)
|
||||
for member in tar:
|
||||
member_path = os.path.realpath(os.path.join(target_dir, member.name))
|
||||
if (
|
||||
not member_path.startswith(target_real + os.sep)
|
||||
and member_path != target_real
|
||||
):
|
||||
raise ValueError(f"Blocked tar path traversal attempt: {member.name}")
|
||||
tar.extract(member, target_dir)
|
||||
|
||||
def _find_pkgbuild_dir(self, root_dir: str) -> Optional[str]:
|
||||
"""
|
||||
Locate the directory that contains the PKGBUILD file inside root_dir.
|
||||
"""
|
||||
self.module.log(f"Aur::_find_pkgbuild_dir(root_dir: {root_dir})")
|
||||
|
||||
for dirpath, _, filenames in os.walk(root_dir):
|
||||
if "PKGBUILD" in filenames:
|
||||
return dirpath
|
||||
return None
|
||||
|
||||
def _read_pkgbuild_pkgver(self, pkgbuild_path: str) -> str:
|
||||
"""
|
||||
Read pkgver from a PKGBUILD file.
|
||||
|
||||
Note:
|
||||
This is a best-effort parse of 'pkgver='. It does not execute PKGBUILD code.
|
||||
"""
|
||||
self.module.log(f"Aur::_read_pkgbuild_pkgver(pkgbuild_path: {pkgbuild_path})")
|
||||
|
||||
try:
|
||||
with open(pkgbuild_path, "r", encoding="utf-8") as f:
|
||||
data = f.read()
|
||||
except OSError as exc:
|
||||
self.module.log(msg=f"Unable to read PKGBUILD: {exc}")
|
||||
return ""
|
||||
|
||||
m = _PKGBUILD_PKGVER_RE.search(data)
|
||||
return self._sanitize_scalar(m.group("version")) if m else ""
|
||||
|
||||
def _read_pkgbuild_pkgrel(self, pkgbuild_path: str) -> str:
|
||||
"""
|
||||
Read pkgrel from a PKGBUILD file.
|
||||
|
||||
Note:
|
||||
This is a best-effort parse of 'pkgrel='. It does not execute PKGBUILD code.
|
||||
"""
|
||||
self.module.log(f"Aur::_read_pkgbuild_pkgrel(pkgbuild_path: {pkgbuild_path})")
|
||||
|
||||
try:
|
||||
with open(pkgbuild_path, "r", encoding="utf-8") as f:
|
||||
data = f.read()
|
||||
except OSError as exc:
|
||||
self.module.log(msg=f"Unable to read PKGBUILD: {exc}")
|
||||
return ""
|
||||
|
||||
m = _PKGBUILD_PKGREL_RE.search(data)
|
||||
return self._sanitize_scalar(m.group("pkgrel")) if m else ""
|
||||
|
||||
def _read_pkgbuild_full_version(self, pkgbuild_path: str) -> str:
|
||||
"""
|
||||
Read epoch/pkgver/pkgrel from PKGBUILD and return a comparable full version string.
|
||||
|
||||
The returned format matches pacman's version string without architecture:
|
||||
- "<epoch>:<pkgver>-<pkgrel>" (epoch optional)
|
||||
"""
|
||||
self.module.log(
|
||||
f"Aur::_read_pkgbuild_full_version(pkgbuild_path: {pkgbuild_path})"
|
||||
)
|
||||
|
||||
pkgver = self._read_pkgbuild_pkgver(pkgbuild_path)
|
||||
pkgrel = self._read_pkgbuild_pkgrel(pkgbuild_path)
|
||||
epoch = self._read_pkgbuild_epoch(pkgbuild_path)
|
||||
|
||||
return self._make_full_version(pkgver=pkgver, pkgrel=pkgrel, epoch=epoch)
|
||||
|
||||
def _read_srcinfo_full_version(self, srcinfo_path: str) -> str:
|
||||
"""
|
||||
Read epoch/pkgver/pkgrel from a .SRCINFO file.
|
||||
"""
|
||||
self.module.log(
|
||||
f"Aur::_read_srcinfo_full_version(srcinfo_path: {srcinfo_path})"
|
||||
)
|
||||
|
||||
try:
|
||||
with open(srcinfo_path, "r", encoding="utf-8") as f:
|
||||
data = f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
pkgver_m = _SRCINFO_PKGVER_RE.search(data)
|
||||
pkgrel_m = _SRCINFO_PKGREL_RE.search(data)
|
||||
epoch_m = _SRCINFO_EPOCH_RE.search(data)
|
||||
|
||||
pkgver = self._sanitize_scalar(pkgver_m.group("version")) if pkgver_m else ""
|
||||
pkgrel = self._sanitize_scalar(pkgrel_m.group("pkgrel")) if pkgrel_m else ""
|
||||
epoch = self._sanitize_scalar(epoch_m.group("epoch")) if epoch_m else None
|
||||
|
||||
return self._make_full_version(pkgver=pkgver, pkgrel=pkgrel, epoch=epoch)
|
||||
|
||||
def _read_upstream_full_version(self, directory: str) -> str:
|
||||
"""
|
||||
Determine the upstream full version for idempotency decisions.
|
||||
|
||||
The function prefers .SRCINFO (static metadata) and falls back to PKGBUILD parsing.
|
||||
If pkgrel cannot be determined, the function may return an epoch/pkgver-only key.
|
||||
"""
|
||||
self.module.log(f"Aur::_read_upstream_full_version(directory: {directory})")
|
||||
|
||||
srcinfo_path = os.path.join(directory, ".SRCINFO")
|
||||
if os.path.exists(srcinfo_path):
|
||||
v = self._read_srcinfo_full_version(srcinfo_path)
|
||||
if v:
|
||||
return v
|
||||
|
||||
pkgbuild_path = os.path.join(directory, "PKGBUILD")
|
||||
if os.path.exists(pkgbuild_path):
|
||||
v = self._read_pkgbuild_full_version(pkgbuild_path)
|
||||
if v:
|
||||
return v
|
||||
|
||||
return ""
|
||||
|
||||
def _read_pkgbuild_version_key(self, pkgbuild_path: str) -> str:
|
||||
"""
|
||||
Read epoch/pkgver from PKGBUILD and return a comparable version key.
|
||||
"""
|
||||
self.module.log(
|
||||
f"Aur::_read_pkgbuild_version_key(pkgbuild_path: {pkgbuild_path})"
|
||||
)
|
||||
|
||||
pkgver = self._read_pkgbuild_pkgver(pkgbuild_path)
|
||||
epoch = self._read_pkgbuild_epoch(pkgbuild_path)
|
||||
|
||||
return self._make_version_key(pkgver=pkgver, epoch=epoch)
|
||||
|
||||
def _read_srcinfo_version_key(self, srcinfo_path: str) -> str:
|
||||
"""
|
||||
Read epoch/pkgver from a .SRCINFO file.
|
||||
"""
|
||||
self.module.log(f"Aur::_read_srcinfo_version_key(srcinfo_path: {srcinfo_path})")
|
||||
|
||||
try:
|
||||
with open(srcinfo_path, "r", encoding="utf-8") as f:
|
||||
data = f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
pkgver_m = _SRCINFO_PKGVER_RE.search(data)
|
||||
epoch_m = _SRCINFO_EPOCH_RE.search(data)
|
||||
|
||||
pkgver = self._sanitize_scalar(pkgver_m.group("version")) if pkgver_m else ""
|
||||
epoch = self._sanitize_scalar(epoch_m.group("epoch")) if epoch_m else None
|
||||
|
||||
return self._make_version_key(pkgver=pkgver, epoch=epoch)
|
||||
|
||||
def _read_pkgbuild_epoch(self, pkgbuild_path: str) -> Optional[str]:
|
||||
"""
|
||||
Read epoch from a PKGBUILD file.
|
||||
"""
|
||||
self.module.log(f"Aur::_read_pkgbuild_epoch(pkgbuild_path: {pkgbuild_path})")
|
||||
|
||||
try:
|
||||
with open(pkgbuild_path, "r", encoding="utf-8") as f:
|
||||
data = f.read()
|
||||
except OSError as exc:
|
||||
self.module.log(msg=f"Unable to read PKGBUILD: {exc}")
|
||||
return None
|
||||
|
||||
m = _PKGBUILD_EPOCH_RE.search(data)
|
||||
|
||||
return self._sanitize_scalar(m.group("epoch")) if m else None
|
||||
|
||||
def _read_upstream_version_key(self, directory: str) -> str:
|
||||
"""
|
||||
Determine the upstream package version key for idempotency decisions.
|
||||
|
||||
The function prefers .SRCINFO (static metadata) and falls back to PKGBUILD
|
||||
parsing if .SRCINFO is missing.
|
||||
"""
|
||||
self.module.log(f"Aur::_read_upstream_version_key(directory: {directory})")
|
||||
|
||||
srcinfo_path = os.path.join(directory, ".SRCINFO")
|
||||
if os.path.exists(srcinfo_path):
|
||||
v = self._read_srcinfo_version_key(srcinfo_path)
|
||||
if v:
|
||||
return v
|
||||
|
||||
pkgbuild_path = os.path.join(directory, "PKGBUILD")
|
||||
if os.path.exists(pkgbuild_path):
|
||||
return self._read_pkgbuild_version_key(pkgbuild_path)
|
||||
|
||||
return ""
|
||||
|
||||
def _sanitize_scalar(self, value: str) -> str:
|
||||
"""
|
||||
Sanitize a scalar value extracted from PKGBUILD/.SRCINFO.
|
||||
|
||||
This removes surrounding quotes and trims whitespace. It is intentionally conservative
|
||||
and does not attempt to evaluate shell expansions or PKGBUILD functions.
|
||||
"""
|
||||
self.module.log(f"Aur::_sanitize_scalar(value: {value})")
|
||||
|
||||
v = value.strip()
|
||||
if (v.startswith('"') and v.endswith('"')) or (
|
||||
v.startswith("'") and v.endswith("'")
|
||||
):
|
||||
v = v[1:-1].strip()
|
||||
|
||||
return v
|
||||
|
||||
def _make_version_key(self, pkgver: str, epoch: Optional[str]) -> str:
|
||||
"""
|
||||
Build a comparable version key.
|
||||
|
||||
Pacman formats versions as: '<epoch>:<pkgver>-<pkgrel>' (epoch optional).
|
||||
This module compares '<epoch>:<pkgver>' (without pkgrel).
|
||||
"""
|
||||
self.module.log(f"Aur::_make_version_key(pkgver: {pkgver}, epoch: {epoch})")
|
||||
|
||||
pv = pkgver.strip()
|
||||
ep = self._sanitize_scalar(epoch) if epoch is not None else ""
|
||||
if ep and ep != "0":
|
||||
return f"{ep}:{pv}" if pv else f"{ep}:"
|
||||
|
||||
return pv
|
||||
|
||||
def _make_full_version(self, pkgver: str, pkgrel: str, epoch: Optional[str]) -> str:
|
||||
"""
|
||||
Build a comparable full version string.
|
||||
|
||||
The returned format matches pacman's version string:
|
||||
- "<epoch>:<pkgver>-<pkgrel>" (epoch optional)
|
||||
|
||||
If pkgrel is empty, the function falls back to an epoch/pkgver-only key.
|
||||
"""
|
||||
self.module.log(
|
||||
f"Aur::_make_full_version(pkgver: {pkgver}, pkgrel: {pkgrel}, epoch: {epoch})"
|
||||
)
|
||||
|
||||
pv = pkgver.strip()
|
||||
pr = pkgrel.strip()
|
||||
ep = self._sanitize_scalar(epoch) if epoch is not None else ""
|
||||
|
||||
base = f"{ep}:{pv}" if ep and ep != "0" else pv
|
||||
if not pr:
|
||||
return base
|
||||
|
||||
return f"{base}-{pr}" if base else ""
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Module execution.
|
||||
# ===========================================
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
Entrypoint for the Ansible module.
|
||||
"""
|
||||
args = dict(
|
||||
state=dict(default="present", choices=["present", "absent"]),
|
||||
repository=dict(type="str", required=False),
|
||||
name=dict(type="str", required=True),
|
||||
extra_args=dict(type="list", required=False),
|
||||
)
|
||||
module = AnsibleModule(
|
||||
argument_spec=args,
|
||||
supports_check_mode=False,
|
||||
)
|
||||
|
||||
aur = Aur(module)
|
||||
result = aur.run()
|
||||
|
||||
module.log(msg=f"= result: {result}")
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
# import module snippets
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2024, Bodo Schulz <bodo@boone-schulz.de>
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
---
|
||||
module: check_mode
|
||||
version_added: 2.5.0
|
||||
author: "Bodo Schulz (@bodsch) <bodo@boone-schulz.de>"
|
||||
|
||||
short_description: Replacement for ansible_check_mode.
|
||||
|
||||
description:
|
||||
- Replacement for ansible_check_mode.
|
||||
- The magic variable `ansible_check_mode` was not defined with the correct value in some cases.
|
||||
|
||||
options:
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
- name: detect ansible check_mode
|
||||
bodsch.core.check_mode:
|
||||
register: _check_mode
|
||||
|
||||
- name: define check_mode
|
||||
ansible.builtin.set_fact:
|
||||
check_mode: '{{ _check_mode.check_mode }}'
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
check_mode:
|
||||
description:
|
||||
- Status for check_mode.
|
||||
type: bool
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CheckMode(object):
|
||||
""" """
|
||||
|
||||
module = None
|
||||
|
||||
def __init__(self, module):
|
||||
""" """
|
||||
self.module = module
|
||||
|
||||
def run(self):
|
||||
""" """
|
||||
result = dict(failed=False, changed=False, check_mode=False)
|
||||
|
||||
if self.module.check_mode:
|
||||
result = dict(failed=False, changed=False, check_mode=True)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
args = dict()
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=args,
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
o = CheckMode(module)
|
||||
result = o.run()
|
||||
|
||||
module.log(msg=f"= result: {result}")
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
# import module snippets
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
"""
|
||||
deploy_and_activate.py
|
||||
|
||||
Deploy versioned binaries and activate them via symlinks.
|
||||
|
||||
Note:
|
||||
- When you want to deploy binaries that exist on the controller (remote_src=false scenario),
|
||||
use the action plugin of the same name (this collection provides it).
|
||||
- This module itself can only copy from a remote src_dir to install_dir (remote -> remote).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.versioned_deployment import (
|
||||
BinaryDeploy,
|
||||
)
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
---
|
||||
module: deploy_and_activate
|
||||
short_description: Deploy versioned binaries and activate them via symlinks
|
||||
description:
|
||||
- Ensures binaries are present in a versioned install directory and activates them via symlinks.
|
||||
- Supports idempotent remote copy, permissions/ownership, Linux file capabilities, and symlink activation.
|
||||
- For controller-local sources, use the action plugin (same task name) shipped by this collection.
|
||||
options:
|
||||
install_dir:
|
||||
description:
|
||||
- Versioned installation directory (e.g. C(/opt/app/1.2.3)).
|
||||
type: path
|
||||
required: true
|
||||
link_dir:
|
||||
description:
|
||||
- Directory where activation symlinks are created (e.g. C(/usr/bin)).
|
||||
type: path
|
||||
default: /usr/bin
|
||||
src_dir:
|
||||
description:
|
||||
- Remote directory containing extracted binaries (required when C(copy=true)).
|
||||
type: path
|
||||
required: false
|
||||
copy:
|
||||
description:
|
||||
- If true, copy from C(src_dir) to C(install_dir) on the remote host (remote -> remote).
|
||||
- If false, assume binaries already exist in C(install_dir) and only enforce perms/caps/links.
|
||||
type: bool
|
||||
default: true
|
||||
items:
|
||||
description:
|
||||
- List of binaries to deploy.
|
||||
- Each item supports C(name), optional C(src), optional C(link_name), optional C(capability).
|
||||
type: list
|
||||
elements: dict
|
||||
required: true
|
||||
activation_name:
|
||||
description:
|
||||
- Item name or link_name used to determine "activated" status. Defaults to the first item.
|
||||
type: str
|
||||
required: false
|
||||
owner:
|
||||
description:
|
||||
- Owner name or uid for deployed binaries.
|
||||
type: str
|
||||
required: false
|
||||
group:
|
||||
description:
|
||||
- Group name or gid for deployed binaries.
|
||||
type: str
|
||||
required: false
|
||||
mode:
|
||||
description:
|
||||
- File mode (octal string).
|
||||
type: str
|
||||
default: "0755"
|
||||
cleanup_on_failure:
|
||||
description:
|
||||
- Remove install_dir if an error occurs during apply.
|
||||
type: bool
|
||||
default: true
|
||||
check_only:
|
||||
description:
|
||||
- If true, do not change anything; return whether an update would be needed.
|
||||
type: bool
|
||||
default: false
|
||||
author:
|
||||
- "Bodsch Core Collection"
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
- name: Deploy logstream_exporter (remote -> remote copy)
|
||||
bodsch.core.binary_deploy:
|
||||
src_dir: "/tmp/logstream_exporter"
|
||||
install_dir: "/opt/logstream_exporter/1.2.3"
|
||||
link_dir: "/usr/bin"
|
||||
copy: true
|
||||
owner: "logstream"
|
||||
group: "logstream"
|
||||
mode: "0755"
|
||||
items:
|
||||
- name: "logstream_exporter"
|
||||
capability: "cap_net_raw+ep"
|
||||
|
||||
- name: Only enforce symlinks/caps when files already exist in install_dir
|
||||
bodsch.core.binary_deploy:
|
||||
install_dir: "/opt/alertmanager/0.27.0"
|
||||
link_dir: "/usr/bin"
|
||||
copy: false
|
||||
items:
|
||||
- name: "alertmanager"
|
||||
- name: "amtool"
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
changed:
|
||||
description: Whether anything changed.
|
||||
type: bool
|
||||
activated:
|
||||
description: Whether the activation symlink points to the binary in install_dir.
|
||||
type: bool
|
||||
needs_update:
|
||||
description: In check_only/check_mode, indicates whether changes would be applied.
|
||||
type: bool
|
||||
plan:
|
||||
description: In check_only/check_mode, per-item flags for copy/perms/cap/link.
|
||||
type: dict
|
||||
results:
|
||||
description: In apply mode, per-item change information.
|
||||
type: dict
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
module = AnsibleModule(
|
||||
argument_spec={
|
||||
"install_dir": {"type": "path", "required": True},
|
||||
"link_dir": {"type": "path", "default": "/usr/bin"},
|
||||
"src_dir": {"type": "path", "required": False},
|
||||
"copy": {"type": "bool", "default": True},
|
||||
"items": {"type": "list", "elements": "dict", "required": True},
|
||||
"activation_name": {"type": "str", "required": False},
|
||||
"owner": {"type": "str", "required": False},
|
||||
"group": {"type": "str", "required": False},
|
||||
"mode": {"type": "str", "default": "0755"},
|
||||
"cleanup_on_failure": {"type": "bool", "default": True},
|
||||
"check_only": {"type": "bool", "default": False},
|
||||
},
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
BinaryDeploy(module).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"""
|
||||
binary_deploy_remote.py
|
||||
|
||||
Remote worker module for the binary_deploy action plugin.
|
||||
This module expects that src_dir (when copy=true) is available on the remote host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.versioned_deployment import (
|
||||
BinaryDeploy,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
module = AnsibleModule(
|
||||
argument_spec={
|
||||
"install_dir": {"type": "path", "required": True},
|
||||
"link_dir": {"type": "path", "default": "/usr/bin"},
|
||||
"src_dir": {"type": "path", "required": False},
|
||||
"copy": {"type": "bool", "default": True},
|
||||
"items": {"type": "list", "elements": "dict", "required": True},
|
||||
"activation_name": {"type": "str", "required": False},
|
||||
"owner": {"type": "str", "required": False},
|
||||
"group": {"type": "str", "required": False},
|
||||
"mode": {"type": "str", "default": "0755"},
|
||||
"cleanup_on_failure": {"type": "bool", "default": True},
|
||||
"check_only": {"type": "bool", "default": False},
|
||||
},
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
BinaryDeploy(module).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2022, Bodo Schulz <bodo@boone-schulz.de>
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.easyrsa import EasyRSA
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.module_results import results
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
---
|
||||
module: easyrsa
|
||||
version_added: 1.1.3
|
||||
author: "Bodo Schulz (@bodsch) <bodo@boone-schulz.de>"
|
||||
|
||||
short_description: Manage a Public Key Infrastructure (PKI) using EasyRSA.
|
||||
|
||||
description:
|
||||
- This module allows management of a PKI environment using EasyRSA.
|
||||
- It supports initialization of a PKI directory, creation of a Certificate Authority (CA),
|
||||
generation of certificate signing requests (CSR), signing of certificates, generation of
|
||||
a certificate revocation list (CRL), and generation of Diffie-Hellman (DH) parameters.
|
||||
- It is useful for automating the setup of secure communication infrastructure.
|
||||
|
||||
|
||||
options:
|
||||
pki_dir:
|
||||
description:
|
||||
- Path to the PKI directory where certificates and keys will be stored.
|
||||
required: false
|
||||
type: str
|
||||
|
||||
force:
|
||||
description:
|
||||
- If set to true, the existing PKI directory will be deleted and recreated.
|
||||
required: false
|
||||
type: bool
|
||||
default: false
|
||||
|
||||
req_cn_ca:
|
||||
description:
|
||||
- Common Name (CN) to be used for the CA certificate.
|
||||
required: false
|
||||
type: str
|
||||
|
||||
req_cn_server:
|
||||
description:
|
||||
- Common Name (CN) to be used for the server certificate request.
|
||||
required: false
|
||||
type: str
|
||||
|
||||
ca_keysize:
|
||||
description:
|
||||
- Key size (in bits) for the CA certificate.
|
||||
required: false
|
||||
type: int
|
||||
|
||||
dh_keysize:
|
||||
description:
|
||||
- Key size (in bits) for the Diffie-Hellman parameters.
|
||||
required: false
|
||||
type: int
|
||||
|
||||
working_dir:
|
||||
description:
|
||||
- Directory in which to execute the EasyRSA commands.
|
||||
- If not set, commands will be executed in the current working directory.
|
||||
required: false
|
||||
type: str
|
||||
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
- name: initialize easy-rsa - (this is going to take a long time)
|
||||
bodsch.core.easyrsa:
|
||||
pki_dir: '{{ openvpn_easyrsa.directory }}/pki'
|
||||
req_cn_ca: "{{ openvpn_certificate.req_cn_ca }}"
|
||||
req_cn_server: '{{ openvpn_certificate.req_cn_server }}'
|
||||
ca_keysize: 4096
|
||||
dh_keysize: "{{ openvpn_diffie_hellman_keysize }}"
|
||||
working_dir: '{{ openvpn_easyrsa.directory }}'
|
||||
force: true
|
||||
register: _easyrsa_result
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
changed:
|
||||
description: Indicates whether any changes were made during module execution.
|
||||
type: bool
|
||||
returned: always
|
||||
|
||||
failed:
|
||||
description: Indicates whether the module failed.
|
||||
type: bool
|
||||
returned: always
|
||||
|
||||
state:
|
||||
description: A detailed list of results from each EasyRSA operation.
|
||||
type: list
|
||||
elements: dict
|
||||
returned: always
|
||||
sample:
|
||||
- init-pki:
|
||||
failed: false
|
||||
changed: true
|
||||
msg: The PKI was successfully created.
|
||||
- build-ca:
|
||||
failed: false
|
||||
changed: true
|
||||
msg: ca.crt and ca.key were successfully created.
|
||||
- gen-crl:
|
||||
failed: false
|
||||
changed: true
|
||||
msg: crl.pem was successfully created.
|
||||
- gen-req:
|
||||
failed: false
|
||||
changed: true
|
||||
msg: server.req was successfully created.
|
||||
- sign-req:
|
||||
failed: false
|
||||
changed: true
|
||||
msg: server.crt was successfully created.
|
||||
- gen-dh:
|
||||
failed: false
|
||||
changed: true
|
||||
msg: dh.pem was successfully created.
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class EasyRsa(object):
|
||||
""" """
|
||||
|
||||
module = None
|
||||
|
||||
def __init__(self, module):
|
||||
""" """
|
||||
self.module = module
|
||||
|
||||
self.state = ""
|
||||
|
||||
self.force = module.params.get("force", False)
|
||||
self.pki_dir = module.params.get("pki_dir", None)
|
||||
self.req_cn_ca = module.params.get("req_cn_ca", None)
|
||||
self.req_cn_server = module.params.get("req_cn_server", None)
|
||||
self.ca_keysize = module.params.get("ca_keysize", None)
|
||||
self.dh_keysize = module.params.get("dh_keysize", None)
|
||||
self.working_dir = module.params.get("working_dir", None)
|
||||
|
||||
self.easyrsa = module.get_bin_path("easyrsa", True)
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
runner
|
||||
"""
|
||||
result_state = []
|
||||
|
||||
if self.working_dir:
|
||||
os.chdir(self.working_dir)
|
||||
|
||||
# self.module.log(msg=f"-> pwd : {os.getcwd()}")
|
||||
|
||||
if self.force:
|
||||
# self.module.log(msg="force mode ...")
|
||||
# self.module.log(msg=f"remove {self.pki_dir}")
|
||||
|
||||
if os.path.isdir(self.pki_dir):
|
||||
shutil.rmtree(self.pki_dir)
|
||||
|
||||
ersa = EasyRSA(
|
||||
module=self.module,
|
||||
force=self.force,
|
||||
pki_dir=self.pki_dir,
|
||||
req_cn_ca=self.req_cn_ca,
|
||||
req_cn_server=self.req_cn_server,
|
||||
ca_keysize=self.ca_keysize,
|
||||
dh_keysize=self.dh_keysize,
|
||||
working_dir=self.working_dir,
|
||||
)
|
||||
|
||||
steps = [
|
||||
("init-pki", ersa.create_pki),
|
||||
("build-ca", ersa.build_ca),
|
||||
("gen-crl", ersa.gen_crl),
|
||||
("gen-req", ersa.gen_req),
|
||||
("sign-req", ersa.sign_req),
|
||||
("gen-dh", ersa.gen_dh),
|
||||
]
|
||||
|
||||
for step_name, step_func in steps:
|
||||
self.module.log(msg=f" - {step_name}")
|
||||
rc, changed, msg = step_func()
|
||||
|
||||
result_state.append(
|
||||
{step_name: {"failed": rc != 0, "changed": changed, "msg": msg}}
|
||||
)
|
||||
if rc != 0:
|
||||
break
|
||||
|
||||
_state, _changed, _failed, state, changed, failed = results(
|
||||
self.module, result_state
|
||||
)
|
||||
|
||||
result = dict(changed=_changed, failed=failed, state=result_state)
|
||||
|
||||
return result
|
||||
|
||||
def list_files(self, startpath):
|
||||
for root, dirs, files in os.walk(startpath):
|
||||
level = root.replace(startpath, "").count(os.sep)
|
||||
indent = " " * 4 * (level)
|
||||
self.module.log(msg=f"{indent}{os.path.basename(root)}/")
|
||||
subindent = " " * 4 * (level + 1)
|
||||
for f in files:
|
||||
self.module.log(msg=f"{subindent}{f}")
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
args = dict(
|
||||
pki_dir=dict(required=False, type="str"),
|
||||
force=dict(required=False, default=False, type="bool"),
|
||||
req_cn_ca=dict(required=False),
|
||||
req_cn_server=dict(required=False),
|
||||
ca_keysize=dict(required=False, type="int"),
|
||||
dh_keysize=dict(required=False, type="int"),
|
||||
working_dir=dict(required=False),
|
||||
)
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=args,
|
||||
supports_check_mode=False,
|
||||
)
|
||||
|
||||
e = EasyRsa(module)
|
||||
result = e.run()
|
||||
|
||||
module.log(msg=f"= result: {result}")
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
# import module snippets
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.checksum import Checksum
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.directory import (
|
||||
create_directory,
|
||||
)
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.file import chmod, remove_file
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
DOCUMENTATION = """
|
||||
module: facts
|
||||
version_added: 1.0.10
|
||||
author: "Bodo Schulz (@bodsch) <bodo@boone-schulz.de>"
|
||||
|
||||
short_description: Write Ansible Facts
|
||||
|
||||
description:
|
||||
- Write Ansible Facts
|
||||
|
||||
options:
|
||||
state:
|
||||
description:
|
||||
- Whether to create (C(present)), or remove (C(absent)) a fact.
|
||||
required: false
|
||||
name:
|
||||
description:
|
||||
- The name of the fact.
|
||||
type: str
|
||||
required: true
|
||||
facts:
|
||||
description:
|
||||
- A dictionary with information to be written in the facts.
|
||||
type: dict
|
||||
required: true
|
||||
"""
|
||||
|
||||
EXAMPLES = """
|
||||
- name: create custom facts
|
||||
bodsch.core.facts:
|
||||
state: present
|
||||
name: icinga2
|
||||
facts:
|
||||
version: "2.10"
|
||||
salt: fgmklsdfnjyxnvjksdfbkuser
|
||||
user: icinga2
|
||||
"""
|
||||
|
||||
RETURN = """
|
||||
msg:
|
||||
description: Module information
|
||||
type: str
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
TPL_FACT = """#!/usr/bin/env bash
|
||||
# generated by ansible
|
||||
cat <<EOF
|
||||
{{ item | tojson(indent=2) }}
|
||||
EOF
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class AnsibleFacts(object):
|
||||
"""
|
||||
Main Class
|
||||
"""
|
||||
|
||||
module = None
|
||||
|
||||
def __init__(self, module):
|
||||
"""
|
||||
Initialize all needed Variables
|
||||
"""
|
||||
self.module = module
|
||||
|
||||
self.verbose = module.params.get("verbose")
|
||||
self.state = module.params.get("state")
|
||||
self.name = module.params.get("name")
|
||||
self.facts = module.params.get("facts")
|
||||
self.append = module.params.get("append")
|
||||
|
||||
self.cache_directory = f"/var/cache/ansible/{self.name}"
|
||||
self.checksum_file = os.path.join(self.cache_directory, "facts.checksum")
|
||||
self.json_file = os.path.join(self.cache_directory, "facts.json")
|
||||
self.facts_directory = "/etc/ansible/facts.d"
|
||||
self.facts_file = os.path.join(self.facts_directory, f"{self.name}.fact")
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
runner
|
||||
"""
|
||||
create_directory(self.cache_directory)
|
||||
create_directory(self.facts_directory, mode="0775")
|
||||
|
||||
old_facts = {}
|
||||
|
||||
_failed = False
|
||||
_changed = False
|
||||
_msg = "There are no changes."
|
||||
|
||||
checksum = None
|
||||
|
||||
if self.state == "absent":
|
||||
for f in [self.checksum_file, self.json_file, self.facts_file]:
|
||||
if os.path.exists(f):
|
||||
remove_file(f)
|
||||
_changed = True
|
||||
_msg = "The facts have been successfully removed."
|
||||
|
||||
return dict(changed=_changed, msg=_msg)
|
||||
|
||||
checksum = Checksum(self.module)
|
||||
|
||||
if not os.path.exists(self.facts_file):
|
||||
if os.path.exists(self.checksum_file):
|
||||
os.remove(self.checksum_file)
|
||||
if os.path.exists(self.json_file):
|
||||
os.remove(self.json_file)
|
||||
|
||||
if os.path.exists(self.json_file):
|
||||
with open(self.json_file) as f:
|
||||
old_facts = json.load(f)
|
||||
|
||||
# self.module.log(f" old_facts : {old_facts}")
|
||||
|
||||
old_checksum = checksum.checksum(old_facts)
|
||||
new_checksum = checksum.checksum(self.facts)
|
||||
|
||||
changed = not (old_checksum == new_checksum)
|
||||
|
||||
# self.module.log(f" changed : {changed}")
|
||||
# self.module.log(f" new_checksum : {new_checksum}")
|
||||
# self.module.log(f" old_checksum : {old_checksum}")
|
||||
|
||||
if self.append and changed:
|
||||
old_facts.update(self.facts)
|
||||
changed = True
|
||||
|
||||
# self.module.log(f" facts : {self.facts}")
|
||||
|
||||
if not changed:
|
||||
return dict(
|
||||
changed=False,
|
||||
)
|
||||
|
||||
# Serializing json
|
||||
json_object = json.dumps(self.facts, indent=2)
|
||||
|
||||
# Writing to sample.json
|
||||
with open(self.facts_file, "w") as outfile:
|
||||
outfile.write("#!/usr/bin/env bash\n# generated by ansible\ncat <<EOF\n")
|
||||
|
||||
with open(self.facts_file, "a+") as outfile:
|
||||
outfile.write(json_object + "\nEOF\n")
|
||||
|
||||
with open(self.json_file, "w") as outfile:
|
||||
outfile.write(json.dumps(self.facts))
|
||||
|
||||
# write_template(self.facts_file, TPL_FACT, self.facts)
|
||||
chmod(self.facts_file, "0775")
|
||||
|
||||
checksum.write_checksum(self.checksum_file, new_checksum)
|
||||
|
||||
return dict(
|
||||
failed=_failed,
|
||||
changed=True,
|
||||
msg="The facts have been successfully written.",
|
||||
)
|
||||
|
||||
def __has_changed(self, data_file, checksum_file, data):
|
||||
""" """
|
||||
old_checksum = ""
|
||||
|
||||
if not os.path.exists(data_file) and os.path.exists(checksum_file):
|
||||
""" """
|
||||
os.remove(checksum_file)
|
||||
|
||||
if os.path.exists(checksum_file):
|
||||
with open(checksum_file, "r") as f:
|
||||
old_checksum = f.readlines()[0]
|
||||
|
||||
if isinstance(data, str):
|
||||
_data = sorted(data.split())
|
||||
_data = "\n".join(_data)
|
||||
|
||||
checksum = self.__checksum(_data)
|
||||
changed = not (old_checksum == checksum)
|
||||
|
||||
if self.force:
|
||||
changed = True
|
||||
old_checksum = ""
|
||||
|
||||
# self.module.log(msg=f" - new checksum '{checksum}'")
|
||||
# self.module.log(msg=f" - curr checksum '{old_checksum}'")
|
||||
# self.module.log(msg=f" - changed '{changed}'")
|
||||
|
||||
return changed, checksum, old_checksum
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
args = dict(
|
||||
state=dict(
|
||||
choices=[
|
||||
"present",
|
||||
"absent",
|
||||
],
|
||||
default="present",
|
||||
),
|
||||
name=dict(
|
||||
type="str",
|
||||
required=True,
|
||||
),
|
||||
facts=dict(
|
||||
type="dict",
|
||||
required=True,
|
||||
),
|
||||
append=dict(type="bool", required=False, default=True),
|
||||
)
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=args,
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
obj = AnsibleFacts(module)
|
||||
result = obj.run()
|
||||
|
||||
module.log(msg=f"= result: {result}")
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
# import module snippets
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
DOCUMENTATION = """
|
||||
module: journalctl
|
||||
version_added: 1.0.6
|
||||
author: "Bodo Schulz (@bodsch) <bodo@boone-schulz.de>"
|
||||
|
||||
short_description: Query the systemd journal with a very limited number of possible parameters.
|
||||
|
||||
description:
|
||||
- Query the systemd journal with a very limited number of possible parameters.
|
||||
- In certain cases there are errors that are not clearly traceable but are logged in the journal.
|
||||
- This module is intended to be a tool for error analysis.
|
||||
|
||||
options:
|
||||
identifier:
|
||||
description:
|
||||
- Show entries with the specified syslog identifier
|
||||
type: str
|
||||
required: false
|
||||
unit:
|
||||
description:
|
||||
- Show logs from the specified unit
|
||||
type: str
|
||||
required: false
|
||||
lines:
|
||||
description:
|
||||
- Number of journal entries to show
|
||||
type: int
|
||||
required: false
|
||||
reverse:
|
||||
description:
|
||||
- Show the newest entries first
|
||||
type: bool
|
||||
required: false
|
||||
arguments:
|
||||
description:
|
||||
- A list of custom attributes
|
||||
type: list
|
||||
required: false
|
||||
"""
|
||||
|
||||
EXAMPLES = """
|
||||
- name: chrony entries from journalctl
|
||||
journalctl:
|
||||
identifier: chrony
|
||||
lines: 50
|
||||
register: journalctl
|
||||
when:
|
||||
- ansible_facts.service_mgr == 'systemd'
|
||||
|
||||
- name: journalctl entries from this module
|
||||
journalctl:
|
||||
identifier: ansible-journalctl
|
||||
lines: 250
|
||||
register: journalctl
|
||||
when:
|
||||
- ansible_facts.service_mgr == 'systemd'
|
||||
"""
|
||||
|
||||
RETURN = """
|
||||
rc:
|
||||
description:
|
||||
- Return Value
|
||||
type: int
|
||||
cmd:
|
||||
description:
|
||||
- journalctl with the called parameters
|
||||
type: string
|
||||
stdout:
|
||||
description:
|
||||
- The output as a list on stdout
|
||||
type: list
|
||||
stderr:
|
||||
description:
|
||||
- The output as a list on stderr
|
||||
type: list
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class JournalCtl(object):
|
||||
""" """
|
||||
|
||||
module = None
|
||||
|
||||
def __init__(self, module):
|
||||
""" """
|
||||
self.module = module
|
||||
|
||||
self._journalctl = module.get_bin_path("journalctl", True)
|
||||
|
||||
self.unit = module.params.get("unit")
|
||||
self.identifier = module.params.get("identifier")
|
||||
self.lines = module.params.get("lines")
|
||||
self.reverse = module.params.get("reverse")
|
||||
self.arguments = module.params.get("arguments")
|
||||
|
||||
# module.log(msg="----------------------------")
|
||||
# module.log(msg=f" journalctl : {self._journalctl}")
|
||||
# module.log(msg=f" unit : {self.unit}")
|
||||
# module.log(msg=f" identifier : {self.identifier}")
|
||||
# module.log(msg=f" lines : {self.lines}")
|
||||
# module.log(msg=f" reverse : {self.reverse}")
|
||||
# module.log(msg=f" arguments : {self.arguments}")
|
||||
# module.log(msg="----------------------------")
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
runner
|
||||
"""
|
||||
result = dict(
|
||||
rc=1,
|
||||
failed=True,
|
||||
changed=False,
|
||||
)
|
||||
|
||||
result = self.journalctl_lines()
|
||||
|
||||
return result
|
||||
|
||||
def journalctl_lines(self):
|
||||
"""
|
||||
journalctl --help
|
||||
journalctl [OPTIONS...] [MATCHES...]
|
||||
|
||||
Query the journal.
|
||||
"""
|
||||
args = []
|
||||
args.append(self._journalctl)
|
||||
|
||||
if self.unit:
|
||||
args.append("--unit")
|
||||
args.append(self.unit)
|
||||
|
||||
if self.identifier:
|
||||
args.append("--identifier")
|
||||
args.append(self.identifier)
|
||||
|
||||
if self.lines:
|
||||
args.append("--lines")
|
||||
args.append(str(self.lines))
|
||||
|
||||
if self.reverse:
|
||||
args.append("--reverse")
|
||||
|
||||
if len(self.arguments) > 0:
|
||||
for arg in self.arguments:
|
||||
args.append(arg)
|
||||
|
||||
# self.module.log(msg=f" - args {args}")
|
||||
|
||||
rc, out, err = self._exec(args)
|
||||
|
||||
return dict(
|
||||
rc=rc,
|
||||
cmd=" ".join(args),
|
||||
stdout=out,
|
||||
stderr=err,
|
||||
)
|
||||
|
||||
def _exec(self, args):
|
||||
""" """
|
||||
rc, out, err = self.module.run_command(args, check_rc=False)
|
||||
|
||||
if rc != 0:
|
||||
self.module.log(msg=f" rc : '{rc}'")
|
||||
self.module.log(msg=f" out: '{out}'")
|
||||
self.module.log(msg=f" err: '{err}'")
|
||||
|
||||
return rc, out, err
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Module execution.
|
||||
#
|
||||
|
||||
|
||||
def main():
|
||||
""" """
|
||||
args = dict(
|
||||
identifier=dict(required=False, type="str"),
|
||||
unit=dict(required=False, type="str"),
|
||||
lines=dict(required=False, type="int"),
|
||||
reverse=dict(required=False, default=False, type="bool"),
|
||||
arguments=dict(required=False, default=[], type=list),
|
||||
)
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=args,
|
||||
supports_check_mode=False,
|
||||
)
|
||||
|
||||
k = JournalCtl(module)
|
||||
result = k.run()
|
||||
|
||||
module.log(msg=f"= result: {result}")
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
# import module snippets
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import os
|
||||
import warnings
|
||||
|
||||
try:
|
||||
from ansible.module_utils.common.text.converters import to_native
|
||||
except ImportError: # pragma: no cover
|
||||
from ansible.module_utils._text import to_native
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible.module_utils.mysql import mysql_driver, mysql_driver_fail_msg
|
||||
from ansible.module_utils.six.moves import configparser
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
module: mysql_schema
|
||||
version_added: '1.0.15'
|
||||
author: "Bodo Schulz (@bodsch) <bodo@boone-schulz.de>"
|
||||
|
||||
short_description: check the named schema exists in a mysql.
|
||||
|
||||
description:
|
||||
- check the named schema exists in a mysql (or compatible) database.
|
||||
|
||||
options:
|
||||
login_user:
|
||||
description:
|
||||
- user name to login into database.
|
||||
type: str
|
||||
required: false
|
||||
|
||||
login_password:
|
||||
description:
|
||||
- password for user name to login into database.
|
||||
type: str
|
||||
required: false
|
||||
|
||||
login_host:
|
||||
description:
|
||||
- database hostname
|
||||
type: str
|
||||
default: 127.0.0.1
|
||||
required: false
|
||||
|
||||
login_port:
|
||||
description:
|
||||
- database port
|
||||
type: int
|
||||
default: 3306
|
||||
required: false
|
||||
|
||||
login_unix_socket:
|
||||
description:
|
||||
- database socket
|
||||
type: str
|
||||
required: false
|
||||
|
||||
database_config_file:
|
||||
description:
|
||||
- optional config file with credentials
|
||||
type: str
|
||||
required: false
|
||||
|
||||
table_schema:
|
||||
description:
|
||||
- database schema to check
|
||||
type: str
|
||||
required: true
|
||||
|
||||
table_name:
|
||||
description:
|
||||
- optional table name
|
||||
type: str
|
||||
required: false
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
- name: ensure, table_schema is present
|
||||
bodsch.core.mysql_schema:
|
||||
login_host: '::1'
|
||||
login_user: root
|
||||
login_password: password
|
||||
table_schema: icingaweb2
|
||||
|
||||
- name: ensure table_schema is created
|
||||
bodsch.core.mysql_schema:
|
||||
login_host: database
|
||||
login_user: root
|
||||
login_password: root
|
||||
table_schema: icingadb
|
||||
register: mysql_icingawebdb_schema
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
exists:
|
||||
description:
|
||||
- is the named schema present
|
||||
type: bool
|
||||
changed:
|
||||
description: TODO
|
||||
type: bool
|
||||
failed:
|
||||
description: TODO
|
||||
type: bool
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MysqlSchema(object):
|
||||
""" """
|
||||
|
||||
module = None
|
||||
|
||||
def __init__(self, module):
|
||||
""" """
|
||||
self.module = module
|
||||
|
||||
self.login_user = module.params.get("login_user")
|
||||
self.login_password = module.params.get("login_password")
|
||||
self.login_host = module.params.get("login_host")
|
||||
self.login_port = module.params.get("login_port")
|
||||
self.login_unix_socket = module.params.get("login_unix_socket")
|
||||
self.database_config_file = module.params.get("database_config_file")
|
||||
self.table_schema = module.params.get("table_schema")
|
||||
self.table_name = module.params.get("table_name")
|
||||
|
||||
self.db_connect_timeout = 30
|
||||
|
||||
def run(self):
|
||||
""" """
|
||||
if mysql_driver is None:
|
||||
self.module.fail_json(msg=mysql_driver_fail_msg)
|
||||
else:
|
||||
warnings.filterwarnings("error", category=mysql_driver.Warning)
|
||||
|
||||
if not mysql_driver:
|
||||
return dict(failed=True, error=mysql_driver_fail_msg)
|
||||
|
||||
state, error, error_message = self._information_schema()
|
||||
|
||||
if error:
|
||||
res = dict(failed=True, changed=False, msg=error_message)
|
||||
else:
|
||||
res = dict(failed=False, changed=False, exists=state)
|
||||
|
||||
return res
|
||||
|
||||
def _information_schema(self):
|
||||
"""
|
||||
get informations about schema
|
||||
|
||||
return:
|
||||
state: bool (exists or not)
|
||||
count: int
|
||||
error: boot (error or not)
|
||||
error_message string error message
|
||||
"""
|
||||
cursor, conn, error, message = self.__mysql_connect()
|
||||
|
||||
if error:
|
||||
return None, error, message
|
||||
|
||||
query = f"SELECT TABLE_SCHEMA, TABLE_NAME FROM information_schema.tables where TABLE_SCHEMA = '{self.table_schema}'"
|
||||
|
||||
try:
|
||||
cursor.execute(query)
|
||||
|
||||
except mysql_driver.ProgrammingError as e:
|
||||
errcode, message = e.args
|
||||
|
||||
message = f"Cannot execute SQL '{query}' : {to_native(e)}"
|
||||
self.module.log(msg=f"ERROR: {message}")
|
||||
|
||||
return False, True, message
|
||||
|
||||
records = cursor.fetchall()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
exists = len(records)
|
||||
|
||||
if self.table_name is not None:
|
||||
table_names = []
|
||||
for e in records:
|
||||
table_names.append(e[1])
|
||||
|
||||
if self.table_name in table_names:
|
||||
self.module.log(
|
||||
msg=f" - table name {self.table_name} exists in table schema"
|
||||
)
|
||||
|
||||
return True, False, None
|
||||
|
||||
else:
|
||||
self.module.log(msg=" - table schema exists")
|
||||
|
||||
if int(exists) >= 4:
|
||||
return True, False, None
|
||||
|
||||
return False, False, None
|
||||
|
||||
def __mysql_connect(self):
|
||||
""" """
|
||||
config = {}
|
||||
|
||||
config_file = self.database_config_file
|
||||
|
||||
if config_file and os.path.exists(config_file):
|
||||
config["read_default_file"] = config_file
|
||||
|
||||
# TODO
|
||||
# cp = self.__parse_from_mysql_config_file(config_file)
|
||||
|
||||
if self.login_unix_socket:
|
||||
config["unix_socket"] = self.login_unix_socket
|
||||
else:
|
||||
config["host"] = self.login_host
|
||||
config["port"] = self.login_port
|
||||
|
||||
# If login_user or login_password are given, they should override the
|
||||
# config file
|
||||
if self.login_user is not None:
|
||||
config["user"] = self.login_user
|
||||
if self.login_password is not None:
|
||||
config["passwd"] = self.login_password
|
||||
|
||||
if mysql_driver is None:
|
||||
self.module.fail_json(msg=mysql_driver_fail_msg)
|
||||
|
||||
try:
|
||||
db_connection = mysql_driver.connect(**config)
|
||||
|
||||
except Exception as e:
|
||||
message = "unable to connect to database. "
|
||||
message += "check login_host, login_user and login_password are correct "
|
||||
message += f"or {config_file} has the credentials. "
|
||||
message += f"Exception message: {to_native(e)}"
|
||||
|
||||
self.module.log(msg=message)
|
||||
|
||||
return (None, None, True, message)
|
||||
|
||||
return db_connection.cursor(), db_connection, False, "successful connected"
|
||||
|
||||
def __parse_from_mysql_config_file(self, cnf):
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read(cnf)
|
||||
return cp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Module execution.
|
||||
#
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
args = dict(
|
||||
login_user=dict(type="str"),
|
||||
login_password=dict(type="str", no_log=True),
|
||||
login_host=dict(type="str", default="127.0.0.1"),
|
||||
login_port=dict(type="int", default=3306),
|
||||
login_unix_socket=dict(type="str"),
|
||||
database_config_file=dict(required=False, type="path"),
|
||||
table_schema=dict(required=True, type="str"),
|
||||
table_name=dict(required=False, type="str"),
|
||||
)
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=args,
|
||||
supports_check_mode=False,
|
||||
)
|
||||
|
||||
schema = MysqlSchema(module)
|
||||
result = schema.run()
|
||||
|
||||
module.log(msg=f"= result : '{result}'")
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
# import module snippets
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2022, Bodo Schulz <bodo@boone-schulz.de>
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from ansible.module_utils import distro
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
---
|
||||
module: openvpn
|
||||
short_description: Generate OpenVPN tls-auth key or create an Easy-RSA client and inline .ovpn configuration
|
||||
version_added: "1.1.3"
|
||||
author:
|
||||
- Bodo Schulz (@bodsch) <bodsch@boone-schulz.de>
|
||||
|
||||
description:
|
||||
- Generates an OpenVPN static key (tls-auth / ta.key) using C(openvpn --genkey).
|
||||
- Creates an Easy-RSA client certificate (C(build-client-full <user> nopass)) and renders an inline client configuration
|
||||
from C(/etc/openvpn/client.ovpn.template) into C(<destination_directory>/<username>.ovpn).
|
||||
- Supports a marker file via C(creates) to make the operation idempotent.
|
||||
|
||||
options:
|
||||
state:
|
||||
description:
|
||||
- Operation mode.
|
||||
- C(genkey) generates a static key file using the OpenVPN binary.
|
||||
- C(create_user) creates an Easy-RSA client (key/cert) and generates an inline C(.ovpn) file using a template.
|
||||
type: str
|
||||
default: genkey
|
||||
choices:
|
||||
- genkey
|
||||
- create_user
|
||||
|
||||
secret:
|
||||
description:
|
||||
- Destination path for the generated OpenVPN static key when C(state=genkey).
|
||||
- Required by the module interface even if C(state=create_user).
|
||||
type: str
|
||||
required: true
|
||||
|
||||
username:
|
||||
description:
|
||||
- Client username to create when C(state=create_user).
|
||||
type: str
|
||||
required: false
|
||||
|
||||
destination_directory:
|
||||
description:
|
||||
- Target directory where the generated client configuration C(<username>.ovpn) is written when C(state=create_user).
|
||||
type: str
|
||||
required: false
|
||||
|
||||
chdir:
|
||||
description:
|
||||
- Change into this directory before executing commands and accessing the PKI structure.
|
||||
- For C(state=create_user), paths are expected relative to this working directory (e.g. C(pki/private), C(pki/issued), C(pki/reqs)).
|
||||
type: path
|
||||
required: false
|
||||
|
||||
creates:
|
||||
description:
|
||||
- If this path exists, the module returns early with no changes.
|
||||
- With C(state=genkey), the early-return message is C(tls-auth key already created).
|
||||
- With C(force=true) and C(creates) set, the marker file is removed before the check.
|
||||
type: path
|
||||
required: false
|
||||
|
||||
force:
|
||||
description:
|
||||
- If enabled and C(creates) is set, removes the marker file before checking C(creates).
|
||||
type: bool
|
||||
default: false
|
||||
|
||||
easyrsa_directory:
|
||||
description:
|
||||
- Reserved for future use (currently not used by the module implementation).
|
||||
type: str
|
||||
required: false
|
||||
|
||||
notes:
|
||||
- Check mode is not supported.
|
||||
- For Ubuntu 20.04, the module uses the legacy C(--genkey --secret <file>) variant; other systems use C(--genkey secret <file>).
|
||||
|
||||
requirements:
|
||||
- C(openvpn) binary available on the target for C(state=genkey).
|
||||
- C(easyrsa) binary and a working Easy-RSA PKI for C(state=create_user).
|
||||
- Python Jinja2 installed on the target for C(state=create_user) (template rendering).
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
- name: Generate tls-auth key (ta.key)
|
||||
bodsch.core.openvpn:
|
||||
state: genkey
|
||||
secret: /etc/openvpn/ta.key
|
||||
|
||||
- name: Generate tls-auth key only if marker does not exist
|
||||
bodsch.core.openvpn:
|
||||
state: genkey
|
||||
secret: /etc/openvpn/ta.key
|
||||
creates: /var/lib/openvpn/ta.key.created
|
||||
|
||||
- name: Force regeneration by removing marker first
|
||||
bodsch.core.openvpn:
|
||||
state: genkey
|
||||
secret: /etc/openvpn/ta.key
|
||||
creates: /var/lib/openvpn/ta.key.created
|
||||
force: true
|
||||
|
||||
- name: Create Easy-RSA client and write inline .ovpn
|
||||
bodsch.core.openvpn:
|
||||
state: create_user
|
||||
secret: /dev/null # required by module interface, not used here
|
||||
username: alice
|
||||
destination_directory: /etc/openvpn/clients
|
||||
chdir: /etc/easy-rsa
|
||||
|
||||
- name: Create user only if marker does not exist
|
||||
bodsch.core.openvpn:
|
||||
state: create_user
|
||||
secret: /dev/null
|
||||
username: bob
|
||||
destination_directory: /etc/openvpn/clients
|
||||
chdir: /etc/easy-rsa
|
||||
creates: /var/lib/openvpn/clients/bob.created
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
changed:
|
||||
description:
|
||||
- Whether the module changed anything.
|
||||
returned: always
|
||||
type: bool
|
||||
|
||||
failed:
|
||||
description:
|
||||
- Indicates failure.
|
||||
returned: always
|
||||
type: bool
|
||||
|
||||
result:
|
||||
description:
|
||||
- For C(state=genkey), contains stdout from the OpenVPN command.
|
||||
- For C(state=create_user), contains a status message (or an Easy-RSA output-derived message).
|
||||
returned: sometimes
|
||||
type: str
|
||||
sample:
|
||||
- "OpenVPN 2.x.x ...\n..." # command output example
|
||||
- "ovpn file successful written as /etc/openvpn/clients/alice.ovpn"
|
||||
- "can not find key or certfile for user alice"
|
||||
|
||||
message:
|
||||
description:
|
||||
- Status message returned by some early-exit paths (e.g. existing request file or marker file).
|
||||
returned: sometimes
|
||||
type: str
|
||||
sample:
|
||||
- "tls-auth key already created"
|
||||
- "nothing to do."
|
||||
- "cert req for user alice exists"
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OpenVPN(object):
|
||||
"""
|
||||
Main Class to implement the Icinga2 API Client
|
||||
"""
|
||||
|
||||
module = None
|
||||
|
||||
def __init__(self, module):
|
||||
"""
|
||||
Initialize all needed Variables
|
||||
"""
|
||||
self.module = module
|
||||
|
||||
self.state = module.params.get("state")
|
||||
self.force = module.params.get("force", False)
|
||||
self._secret = module.params.get("secret", None)
|
||||
self._username = module.params.get("username", None)
|
||||
|
||||
self._chdir = module.params.get("chdir", None)
|
||||
self._creates = module.params.get("creates", None)
|
||||
self._destination_directory = module.params.get("destination_directory", None)
|
||||
|
||||
self._openvpn = module.get_bin_path("openvpn", True)
|
||||
self._easyrsa = module.get_bin_path("easyrsa", True)
|
||||
|
||||
self.distribution, self.version, self.codename = distro.linux_distribution(
|
||||
full_distribution_name=False
|
||||
)
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
runner
|
||||
"""
|
||||
result = dict(failed=False, changed=False, ansible_module_results="none")
|
||||
|
||||
if self._chdir:
|
||||
os.chdir(self._chdir)
|
||||
|
||||
if self.force and self._creates:
|
||||
self.module.log(msg="force mode ...")
|
||||
if os.path.exists(self._creates):
|
||||
self.module.log(msg="remove {}".format(self._creates))
|
||||
os.remove(self._creates)
|
||||
|
||||
if self._creates:
|
||||
if os.path.exists(self._creates):
|
||||
message = "nothing to do."
|
||||
if self.state == "genkey":
|
||||
message = "tls-auth key already created"
|
||||
|
||||
return dict(changed=False, message=message)
|
||||
|
||||
args = []
|
||||
|
||||
if self.state == "genkey":
|
||||
args.append(self._openvpn)
|
||||
args.append("--genkey")
|
||||
if self.distribution.lower() == "ubuntu" and self.version == "20.04":
|
||||
# OpenVPN 2.5.5
|
||||
# ubuntu 20.04 wants `--secret`
|
||||
args.append("--secret")
|
||||
else:
|
||||
# WARNING: Using --genkey --secret filename is DEPRECATED. Use --genkey secret filename instead.
|
||||
args.append("secret")
|
||||
args.append(self._secret)
|
||||
|
||||
if self.state == "create_user":
|
||||
return self.__create_vpn_user()
|
||||
# args.append(self._easyrsa)
|
||||
# args.append("--batch")
|
||||
# args.append("build-client-full")
|
||||
# args.append(self._username)
|
||||
# args.append("nopass")
|
||||
|
||||
rc, out = self._exec(args)
|
||||
|
||||
result["result"] = "{}".format(out.rstrip())
|
||||
|
||||
if rc == 0:
|
||||
force_mode = "0600"
|
||||
if isinstance(force_mode, str):
|
||||
mode = int(force_mode, base=8)
|
||||
|
||||
os.chmod(self._secret, mode)
|
||||
|
||||
result["changed"] = True
|
||||
else:
|
||||
result["failed"] = True
|
||||
|
||||
return result
|
||||
|
||||
def __create_vpn_user(self):
|
||||
""" """
|
||||
result = dict(failed=True, changed=False, ansible_module_results="none")
|
||||
message = "init function"
|
||||
|
||||
cert_exists = self.__vpn_user_req()
|
||||
|
||||
if cert_exists:
|
||||
return dict(
|
||||
failed=False,
|
||||
changed=False,
|
||||
message="cert req for user {} exists".format(self._username),
|
||||
)
|
||||
|
||||
args = []
|
||||
|
||||
# rc = 0
|
||||
args.append(self._easyrsa)
|
||||
args.append("--batch")
|
||||
args.append("build-client-full")
|
||||
args.append(self._username)
|
||||
args.append("nopass")
|
||||
|
||||
rc, out = self._exec(args)
|
||||
|
||||
result["result"] = "{}".format(out.rstrip())
|
||||
|
||||
if rc == 0:
|
||||
""" """
|
||||
# read key file
|
||||
key_file = os.path.join("pki", "private", "{}.key".format(self._username))
|
||||
cert_file = os.path.join("pki", "issued", "{}.crt".format(self._username))
|
||||
|
||||
self.module.log(msg=" key_file : '{}'".format(key_file))
|
||||
self.module.log(msg=" cert_file: '{}'".format(cert_file))
|
||||
|
||||
if os.path.exists(key_file) and os.path.exists(cert_file):
|
||||
""" """
|
||||
with open(key_file, "r") as k_file:
|
||||
k_data = k_file.read().rstrip("\n")
|
||||
|
||||
cert = self.extract_certs_as_strings(cert_file)[0].rstrip("\n")
|
||||
|
||||
# take openvpn client template and fill
|
||||
from jinja2 import Template
|
||||
|
||||
tpl = "/etc/openvpn/client.ovpn.template"
|
||||
|
||||
with open(tpl) as file_:
|
||||
tm = Template(file_.read())
|
||||
# self.module.log(msg=json.dumps(data, sort_keys=True))
|
||||
|
||||
d = tm.render(key=k_data, cert=cert)
|
||||
|
||||
destination = os.path.join(
|
||||
self._destination_directory, "{}.ovpn".format(self._username)
|
||||
)
|
||||
|
||||
with open(destination, "w") as fp:
|
||||
fp.write(d)
|
||||
|
||||
force_mode = "0600"
|
||||
if isinstance(force_mode, str):
|
||||
mode = int(force_mode, base=8)
|
||||
|
||||
os.chmod(destination, mode)
|
||||
|
||||
result["failed"] = False
|
||||
result["changed"] = True
|
||||
message = "ovpn file successful written as {}".format(destination)
|
||||
|
||||
else:
|
||||
result["failed"] = True
|
||||
message = "can not find key or certfile for user {}".format(
|
||||
self._username
|
||||
)
|
||||
|
||||
result["result"] = message
|
||||
|
||||
return result
|
||||
|
||||
def extract_certs_as_strings(self, cert_file):
|
||||
certs = []
|
||||
with open(cert_file) as whole_cert:
|
||||
cert_started = False
|
||||
content = ""
|
||||
for line in whole_cert:
|
||||
if "-----BEGIN CERTIFICATE-----" in line:
|
||||
if not cert_started:
|
||||
content += line
|
||||
cert_started = True
|
||||
else:
|
||||
print("Error, start cert found but already started")
|
||||
sys.exit(1)
|
||||
elif "-----END CERTIFICATE-----" in line:
|
||||
if cert_started:
|
||||
content += line
|
||||
certs.append(content)
|
||||
content = ""
|
||||
cert_started = False
|
||||
else:
|
||||
print("Error, cert end found without start")
|
||||
sys.exit(1)
|
||||
elif cert_started:
|
||||
content += line
|
||||
|
||||
if cert_started:
|
||||
print("The file is corrupted")
|
||||
sys.exit(1)
|
||||
|
||||
return certs
|
||||
|
||||
def __vpn_user_req(self):
|
||||
""" """
|
||||
req_file = os.path.join("pki", "reqs", "{}.req".format(self._username))
|
||||
|
||||
if os.path.exists(req_file):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _exec(self, commands):
|
||||
"""
|
||||
execute shell program
|
||||
"""
|
||||
rc, out, err = self.module.run_command(commands, check_rc=True)
|
||||
|
||||
if int(rc) != 0:
|
||||
self.module.log(msg=f" rc : '{rc}'")
|
||||
self.module.log(msg=f" out: '{out}'")
|
||||
self.module.log(msg=f" err: '{err}'")
|
||||
|
||||
return rc, out
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Module execution.
|
||||
#
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
args = dict(
|
||||
state=dict(default="genkey", choices=["genkey", "create_user"]),
|
||||
force=dict(required=False, default=False, type="bool"),
|
||||
secret=dict(required=True, type="str"),
|
||||
username=dict(required=False, type="str"),
|
||||
easyrsa_directory=dict(required=False, type="str"),
|
||||
destination_directory=dict(required=False, type="str"),
|
||||
chdir=dict(required=False),
|
||||
creates=dict(required=False),
|
||||
)
|
||||
module = AnsibleModule(
|
||||
argument_spec=args,
|
||||
supports_check_mode=False,
|
||||
)
|
||||
|
||||
o = OpenVPN(module)
|
||||
result = o.run()
|
||||
|
||||
module.log(msg="= result: {}".format(result))
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
# import module snippets
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,469 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2022-2025, Bodo Schulz <bodo@boone-schulz.de>
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.checksum import Checksum
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.directory import (
|
||||
create_directory,
|
||||
)
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.module_results import results
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
---
|
||||
module: openvpn_client_certificate
|
||||
short_description: Manage OpenVPN client certificates using EasyRSA.
|
||||
version_added: "1.1.3"
|
||||
author: "Bodo Schulz (@bodsch) <bodo@boone-schulz.de>"
|
||||
|
||||
description:
|
||||
- This module manages OpenVPN client certificates using EasyRSA.
|
||||
- It supports the creation and revocation of client certificates.
|
||||
- Certificates are tracked via checksums to detect changes.
|
||||
- Ideal for automated PKI workflows with OpenVPN infrastructure.
|
||||
|
||||
options:
|
||||
clients:
|
||||
description:
|
||||
- A list of client definitions, each representing a certificate to be created or revoked.
|
||||
required: true
|
||||
type: list
|
||||
elements: dict
|
||||
suboptions:
|
||||
name:
|
||||
description:
|
||||
- Name of the OpenVPN client.
|
||||
required: true
|
||||
type: str
|
||||
state:
|
||||
description:
|
||||
- Whether the certificate should exist or be revoked.
|
||||
required: false
|
||||
default: present
|
||||
choices: [present, absent]
|
||||
type: str
|
||||
roadrunner:
|
||||
description:
|
||||
- Optional boolean flag, can be used for specific logic in a role.
|
||||
required: false
|
||||
type: bool
|
||||
static_ip:
|
||||
description:
|
||||
- Static IP address for the client (optional, used in templating).
|
||||
required: false
|
||||
type: str
|
||||
remote:
|
||||
description:
|
||||
- Remote server address or hostname.
|
||||
required: false
|
||||
type: str
|
||||
port:
|
||||
description:
|
||||
- Port used by OpenVPN for this client.
|
||||
required: false
|
||||
type: int
|
||||
proto:
|
||||
description:
|
||||
- Protocol to use (typically UDP or TCP).
|
||||
required: false
|
||||
type: str
|
||||
device:
|
||||
description:
|
||||
- Network device (usually tun).
|
||||
required: false
|
||||
type: str
|
||||
ping:
|
||||
description:
|
||||
- Ping interval for the client.
|
||||
required: false
|
||||
type: int
|
||||
ping_restart:
|
||||
description:
|
||||
- Time before restarting connection after ping timeout.
|
||||
required: false
|
||||
type: int
|
||||
cert:
|
||||
description:
|
||||
- Certificate file name for the client.
|
||||
required: false
|
||||
type: str
|
||||
key:
|
||||
description:
|
||||
- Key file name for the client.
|
||||
required: false
|
||||
type: str
|
||||
tls_auth:
|
||||
description:
|
||||
- TLS authentication settings.
|
||||
required: false
|
||||
type: dict
|
||||
suboptions:
|
||||
enabled:
|
||||
description:
|
||||
- Whether TLS auth is enabled.
|
||||
type: bool
|
||||
required: false
|
||||
|
||||
force:
|
||||
description:
|
||||
- If true, the client certificate will be re-created even if it already exists.
|
||||
required: false
|
||||
type: bool
|
||||
default: false
|
||||
|
||||
working_dir:
|
||||
description:
|
||||
- Path to the EasyRSA working directory.
|
||||
- All EasyRSA commands will be executed within this directory.
|
||||
required: false
|
||||
type: str
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
- name: create or revoke client certificate
|
||||
bodsch.core.openvpn_client_certificate:
|
||||
clients:
|
||||
- name: molecule
|
||||
state: present
|
||||
roadrunner: false
|
||||
static_ip: 10.8.3.100
|
||||
remote: server
|
||||
port: 1194
|
||||
proto: udp
|
||||
device: tun
|
||||
ping: 20
|
||||
ping_restart: 45
|
||||
cert: molecule.crt
|
||||
key: molecule.key
|
||||
tls_auth:
|
||||
enabled: true
|
||||
- name: roadrunner_one
|
||||
state: present
|
||||
roadrunner: true
|
||||
static_ip: 10.8.3.10
|
||||
remote: server
|
||||
port: 1194
|
||||
proto: udp
|
||||
device: tun
|
||||
ping: 20
|
||||
ping_restart: 45
|
||||
cert: roadrunner_one.crt
|
||||
key: roadrunner_one.key
|
||||
tls_auth:
|
||||
enabled: true
|
||||
working_dir: /etc/easy-rsa
|
||||
when:
|
||||
- openvpn_client_list | default([]) | count > 0
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
changed:
|
||||
description: Indicates whether any changes were made during module execution.
|
||||
type: bool
|
||||
returned: always
|
||||
failed:
|
||||
description: Indicates whether the module failed.
|
||||
type: bool
|
||||
returned: always
|
||||
state:
|
||||
description: List of results per client certificate operation.
|
||||
type: list
|
||||
elements: dict
|
||||
returned: always
|
||||
sample:
|
||||
- molecule:
|
||||
failed: false
|
||||
changed: true
|
||||
message: The client certificate has been successfully created.
|
||||
- roadrunner_one:
|
||||
failed: false
|
||||
changed: false
|
||||
message: The client certificate has already been created.
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OpenVPNClientCertificate(object):
|
||||
""" """
|
||||
|
||||
def __init__(self, module):
|
||||
""" """
|
||||
self.module = module
|
||||
|
||||
self.module.log("OpenVPNClientCertificate::__init__(module)")
|
||||
|
||||
self.state = module.params.get("state")
|
||||
self.clients = module.params.get("clients", None)
|
||||
self.force = module.params.get("force", False)
|
||||
self.working_dir = module.params.get("working_dir", None)
|
||||
|
||||
self.bin_openvpn = module.get_bin_path("openvpn", True)
|
||||
self.bin_easyrsa = module.get_bin_path("easyrsa", True)
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
runner
|
||||
"""
|
||||
self.module.log("OpenVPNClientCertificate::run()")
|
||||
|
||||
result_state = []
|
||||
|
||||
self.checksum = Checksum(self.module)
|
||||
|
||||
if self.working_dir:
|
||||
os.chdir(self.working_dir)
|
||||
|
||||
for client in self.clients:
|
||||
|
||||
self.module.log(f" - client: {client}")
|
||||
|
||||
res = {}
|
||||
username = client.get("name")
|
||||
state = client.get("state", "present")
|
||||
|
||||
self.checksum_directory = f"{Path.home()}/.ansible/cache/openvpn/{username}"
|
||||
|
||||
if state == "absent":
|
||||
res[username] = self.revoke_vpn_user(username=username)
|
||||
if state == "present":
|
||||
if self.force:
|
||||
if os.path.isdir(self.checksum_directory):
|
||||
shutil.rmtree(self.checksum_directory)
|
||||
|
||||
res[username] = self.create_vpn_user(username=username)
|
||||
|
||||
result_state.append(res)
|
||||
|
||||
_state, _changed, _failed, state, changed, failed = results(
|
||||
self.module, result_state
|
||||
)
|
||||
|
||||
result = dict(changed=_changed, failed=failed, state=result_state)
|
||||
|
||||
return result
|
||||
|
||||
def create_vpn_user(self, username: str):
|
||||
""" """
|
||||
self.module.log(msg=f"OpenVPNClientCertificate::create_vpn_user({username})")
|
||||
|
||||
self.req_file = os.path.join("pki", "reqs", f"{username}.req")
|
||||
self.key_file = os.path.join("pki", "private", f"{username}.key")
|
||||
self.crt_file = os.path.join("pki", "issued", f"{username}.crt")
|
||||
|
||||
self.req_checksum_file = os.path.join(self.checksum_directory, "req.sha256")
|
||||
self.key_checksum_file = os.path.join(self.checksum_directory, "key.sha256")
|
||||
self.crt_checksum_file = os.path.join(self.checksum_directory, "crt.sha256")
|
||||
|
||||
if not self.vpn_user_req(username=username):
|
||||
""" """
|
||||
create_directory(self.checksum_directory)
|
||||
|
||||
args = []
|
||||
|
||||
# rc = 0
|
||||
args.append(self.bin_easyrsa)
|
||||
args.append("--batch")
|
||||
args.append("build-client-full")
|
||||
args.append(username)
|
||||
args.append("nopass")
|
||||
|
||||
self.module.log(msg=f"args: {args}")
|
||||
|
||||
rc, out, err = self._exec(args)
|
||||
|
||||
if rc != 0:
|
||||
""" """
|
||||
return dict(failed=True, changed=False, message=f"{out.rstrip()}")
|
||||
else:
|
||||
self.write_checksum(
|
||||
file_name=self.req_file, checksum_file=self.req_checksum_file
|
||||
)
|
||||
self.write_checksum(
|
||||
file_name=self.key_file, checksum_file=self.key_checksum_file
|
||||
)
|
||||
self.write_checksum(
|
||||
file_name=self.crt_file, checksum_file=self.crt_checksum_file
|
||||
)
|
||||
|
||||
return dict(
|
||||
failed=False,
|
||||
changed=True,
|
||||
message="The client certificate has been successfully created.",
|
||||
)
|
||||
else:
|
||||
valid, msg = self.validate_checksums()
|
||||
|
||||
if valid:
|
||||
return dict(
|
||||
failed=False,
|
||||
changed=False,
|
||||
message="The client certificate has already been created.",
|
||||
)
|
||||
else:
|
||||
return dict(failed=True, changed=False, message=msg)
|
||||
|
||||
def revoke_vpn_user(self, username: str):
|
||||
""" """
|
||||
self.module.log(msg=f"OpenVPNClientCertificate::revoke_vpn_user({username})")
|
||||
|
||||
if not self.vpn_user_req():
|
||||
return dict(
|
||||
failed=False,
|
||||
changed=False,
|
||||
message=f"There is no certificate request for the user {username}.",
|
||||
)
|
||||
|
||||
args = []
|
||||
|
||||
# rc = 0
|
||||
args.append(self.bin_easyrsa)
|
||||
args.append("--batch")
|
||||
args.append("revoke")
|
||||
args.append(username)
|
||||
|
||||
rc, out, err = self._exec(args)
|
||||
|
||||
if rc == 0:
|
||||
# remove checksums
|
||||
os.remove(self.checksum_directory)
|
||||
# recreate CRL
|
||||
args = []
|
||||
args.append(self.bin_easyrsa)
|
||||
args.append("gen-crl")
|
||||
|
||||
if os.path.isdir(self.checksum_directory):
|
||||
shutil.rmtree(self.checksum_directory)
|
||||
|
||||
return dict(
|
||||
changed=True,
|
||||
failed=False,
|
||||
message=f"The certificate for the user {username} has been revoked successfully.",
|
||||
)
|
||||
|
||||
def vpn_user_req(self, username: str):
|
||||
""" """
|
||||
self.module.log(msg=f"OpenVPNClientCertificate::vpn_user_req({username})")
|
||||
|
||||
req_file = os.path.join("pki", "reqs", f"{username}.req")
|
||||
|
||||
if os.path.exists(req_file):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def validate_checksums(self):
|
||||
""" """
|
||||
self.module.log(msg="OpenVPNClientCertificate::validate_checksums()")
|
||||
msg = ""
|
||||
|
||||
req_changed, req_msg = self.validate(self.req_checksum_file, self.req_file)
|
||||
key_changed, key_msg = self.validate(self.req_checksum_file, self.req_file)
|
||||
crt_changed, crt_msg = self.validate(self.req_checksum_file, self.req_file)
|
||||
|
||||
if req_changed or key_changed or crt_changed:
|
||||
_msg = []
|
||||
|
||||
if req_changed:
|
||||
_msg.append(req_msg)
|
||||
if key_changed:
|
||||
_msg.append(key_msg)
|
||||
if crt_changed:
|
||||
_msg.append(crt_msg)
|
||||
|
||||
msg = ", ".join(_msg)
|
||||
valid = False
|
||||
else:
|
||||
valid = True
|
||||
msg = "All Files are valid."
|
||||
|
||||
return valid, msg
|
||||
|
||||
def validate(self, checksum_file: str, file_name: str):
|
||||
""" """
|
||||
self.module.log(
|
||||
msg=f"OpenVPNClientCertificate::validate({checksum_file}, {file_name})"
|
||||
)
|
||||
changed = False
|
||||
msg = ""
|
||||
|
||||
checksum = None
|
||||
old_checksum = None
|
||||
|
||||
changed, checksum, old_checksum = self.checksum.validate_from_file(
|
||||
checksum_file, file_name
|
||||
)
|
||||
|
||||
if os.path.exists(file_name) and not os.path.exists(checksum_file):
|
||||
self.write_checksum(file_name=file_name, checksum_file=checksum_file)
|
||||
changed = False
|
||||
|
||||
if changed:
|
||||
msg = f"{checksum_file} are changed"
|
||||
|
||||
return (changed, msg)
|
||||
|
||||
def write_checksum(self, file_name: str, checksum_file: str):
|
||||
""" """
|
||||
self.module.log(
|
||||
msg=f"OpenVPNClientCertificate::write_checksum({file_name}, {checksum_file})"
|
||||
)
|
||||
|
||||
checksum = self.checksum.checksum_from_file(file_name)
|
||||
self.checksum.write_checksum(checksum_file, checksum)
|
||||
|
||||
def _exec(self, commands, check_rc=False):
|
||||
"""
|
||||
execute shell program
|
||||
"""
|
||||
self.module.log(
|
||||
msg=f"OpenVPNClientCertificate::_exec(commands={commands}, check_rc={check_rc}"
|
||||
)
|
||||
rc, out, err = self.module.run_command(commands, check_rc=check_rc)
|
||||
return rc, out, err
|
||||
|
||||
def result_values(self, out: str, err: str) -> list:
|
||||
"""
|
||||
" """
|
||||
_out = out.splitlines()
|
||||
_err = err.splitlines()
|
||||
_output = []
|
||||
_output += _out
|
||||
_output += _err
|
||||
# self.module.log(msg=f"= output: {_output}")
|
||||
return _output
|
||||
|
||||
|
||||
def main():
|
||||
""" """
|
||||
args = dict(
|
||||
clients=dict(required=True, type="list"),
|
||||
force=dict(required=False, default=False, type="bool"),
|
||||
working_dir=dict(required=False),
|
||||
)
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=args,
|
||||
supports_check_mode=False,
|
||||
)
|
||||
|
||||
o = OpenVPNClientCertificate(module)
|
||||
result = o.run()
|
||||
|
||||
module.log(msg=f"= result: {result}")
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
# import module snippets
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,403 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2022-2025, Bodo Schulz <bodo@boone-schulz.de>
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import os
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible.module_utils.common.text.converters import to_native
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.crypto_utils import (
|
||||
OpenSSLObjectError,
|
||||
get_crl_info,
|
||||
get_relative_time_option,
|
||||
)
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.easyrsa import EasyRSA
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
---
|
||||
module: openvpn_crl
|
||||
short_description: Manage and inspect an OpenVPN Certificate Revocation List (CRL) generated by Easy-RSA
|
||||
version_added: "1.1.3"
|
||||
author:
|
||||
- Bodo Schulz (@bodsch) <bodo@boone-schulz.de>
|
||||
|
||||
description:
|
||||
- Reads and parses an existing CRL file (C(crl.pem)) from an Easy-RSA PKI directory.
|
||||
- Optionally returns the list of revoked certificates contained in the CRL.
|
||||
- Can regenerate the CRL via Easy-RSA (removes the existing C(crl.pem) and runs C(gen-crl)).
|
||||
|
||||
options:
|
||||
state:
|
||||
description:
|
||||
- Operation mode.
|
||||
- C(status) parses the CRL and returns metadata (last/next update) and optionally revoked certificates.
|
||||
- C(renew) regenerates the CRL using Easy-RSA.
|
||||
type: str
|
||||
default: status
|
||||
choices:
|
||||
- status
|
||||
- renew
|
||||
|
||||
pki_dir:
|
||||
description:
|
||||
- Path to the Easy-RSA PKI directory.
|
||||
- The module expects the CRL at C(<pki_dir>/crl.pem).
|
||||
type: str
|
||||
default: /etc/easy-rsa/pki
|
||||
|
||||
working_dir:
|
||||
description:
|
||||
- Working directory used before running Easy-RSA commands.
|
||||
- Useful when Easy-RSA expects to be executed from a specific directory.
|
||||
type: path
|
||||
required: false
|
||||
|
||||
list_revoked_certificates:
|
||||
description:
|
||||
- If enabled, include the parsed list of revoked certificates from the CRL in the result.
|
||||
type: bool
|
||||
default: false
|
||||
|
||||
warn_for_expire:
|
||||
description:
|
||||
- If enabled, calculates whether the CRL is near expiry based on C(next_update) and C(expire_in_days).
|
||||
- Adds C(expired) and (when expired) C(warn=true) to the result.
|
||||
type: bool
|
||||
default: true
|
||||
|
||||
expire_in_days:
|
||||
description:
|
||||
- Threshold in days used to determine whether the CRL is considered "near expiry".
|
||||
- If C(next_update - now <= expire_in_days), the module returns C(expired=true).
|
||||
type: int
|
||||
default: 10
|
||||
|
||||
force:
|
||||
description:
|
||||
- Reserved for future use.
|
||||
type: bool
|
||||
default: false
|
||||
|
||||
notes:
|
||||
- Check mode is not supported.
|
||||
|
||||
requirements:
|
||||
- Easy-RSA must be available for C(state=renew).
|
||||
- OpenSSL libraries/tools as required by the collection crypto utilities.
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
- name: Check CRL status (parse crl.pem)
|
||||
bodsch.core.openvpn_crl:
|
||||
state: status
|
||||
pki_dir: /etc/easy-rsa/pki
|
||||
|
||||
- name: Check CRL status and include revoked certificates
|
||||
bodsch.core.openvpn_crl:
|
||||
state: status
|
||||
pki_dir: /etc/easy-rsa/pki
|
||||
list_revoked_certificates: true
|
||||
|
||||
- name: Warn if CRL expires within 14 days
|
||||
bodsch.core.openvpn_crl:
|
||||
state: status
|
||||
pki_dir: /etc/easy-rsa/pki
|
||||
warn_for_expire: true
|
||||
expire_in_days: 14
|
||||
register: crl_status
|
||||
|
||||
- name: Regenerate (renew) CRL using Easy-RSA
|
||||
bodsch.core.openvpn_crl:
|
||||
state: renew
|
||||
pki_dir: /etc/easy-rsa/pki
|
||||
working_dir: /etc/easy-rsa
|
||||
register: crl_renew
|
||||
|
||||
- name: Show renew output
|
||||
ansible.builtin.debug:
|
||||
var: crl_renew
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
last_update:
|
||||
description:
|
||||
- CRL last update time.
|
||||
returned: when state=status and the CRL can be parsed
|
||||
type: dict
|
||||
contains:
|
||||
raw:
|
||||
description: Raw value as extracted from the CRL.
|
||||
returned: always
|
||||
type: str
|
||||
parsed:
|
||||
description: Parsed/converted representation (implementation-specific).
|
||||
returned: always
|
||||
type: raw
|
||||
|
||||
next_update:
|
||||
description:
|
||||
- CRL next update time.
|
||||
returned: when state=status and the CRL can be parsed
|
||||
type: dict
|
||||
contains:
|
||||
raw:
|
||||
description: Raw value as extracted from the CRL.
|
||||
returned: always
|
||||
type: str
|
||||
parsed:
|
||||
description: Parsed/converted representation (implementation-specific).
|
||||
returned: always
|
||||
type: raw
|
||||
|
||||
expired:
|
||||
description:
|
||||
- Indicates whether the CRL will expire within C(expire_in_days).
|
||||
returned: when state=status and warn_for_expire=true
|
||||
type: bool
|
||||
|
||||
warn:
|
||||
description:
|
||||
- Convenience flag set to C(true) when C(expired=true).
|
||||
returned: when state=status, warn_for_expire=true and expired=true
|
||||
type: bool
|
||||
|
||||
revoked_certificates:
|
||||
description:
|
||||
- List of revoked certificates parsed from the CRL.
|
||||
- The element schema depends on the underlying parser.
|
||||
returned: when state=status and list_revoked_certificates=true
|
||||
type: list
|
||||
elements: dict
|
||||
|
||||
changed:
|
||||
description:
|
||||
- Whether the module changed anything.
|
||||
- Only relevant for C(state=renew).
|
||||
returned: when state=renew
|
||||
type: bool
|
||||
|
||||
msg:
|
||||
description:
|
||||
- Human-readable result message (primarily from Easy-RSA on renew, or from error paths).
|
||||
returned: on state=renew or on failure
|
||||
type: str
|
||||
|
||||
failed:
|
||||
description:
|
||||
- Indicates failure.
|
||||
returned: always
|
||||
type: bool
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OpenVPNCrl(object):
|
||||
""" """
|
||||
|
||||
module = None
|
||||
|
||||
def __init__(self, module):
|
||||
""" """
|
||||
self.module = module
|
||||
self.state = module.params.get("state")
|
||||
self.pki_dir = module.params.get("pki_dir")
|
||||
self.working_dir = module.params.get("working_dir", None)
|
||||
self.list_revoked_certificates = module.params.get("list_revoked_certificates")
|
||||
self.warn_for_expire = module.params.get("warn_for_expire")
|
||||
self.expire_in_days = module.params.get("expire_in_days")
|
||||
|
||||
self.crl_file = f"{self.pki_dir}/crl.pem"
|
||||
|
||||
def run(self):
|
||||
""" """
|
||||
if self.state == "status":
|
||||
result = self.test_crl()
|
||||
if self.state == "renew":
|
||||
result = self.renew_crl()
|
||||
|
||||
return result
|
||||
|
||||
def test_crl(self):
|
||||
""" """
|
||||
data = None
|
||||
|
||||
if os.path.isfile(self.crl_file):
|
||||
try:
|
||||
with open(self.crl_file, "rb") as f:
|
||||
data = f.read()
|
||||
except (IOError, OSError) as e:
|
||||
msg = f"Error while reading CRL file from disk: {e}"
|
||||
self.module.log(msg)
|
||||
# self.module.fail_json(msg)
|
||||
|
||||
return dict(failed=True, msg=msg)
|
||||
if not data:
|
||||
return dict(failed=True, msg="Upps. This error should not occur.")
|
||||
|
||||
try:
|
||||
crl_info = get_crl_info(
|
||||
self.module,
|
||||
data,
|
||||
list_revoked_certificates=self.list_revoked_certificates,
|
||||
)
|
||||
|
||||
self.last_update = get_relative_time_option(
|
||||
crl_info.get("last_update"), "last_update"
|
||||
)
|
||||
self.next_update = get_relative_time_option(
|
||||
crl_info.get("next_update"), "next_update"
|
||||
)
|
||||
self.revoked_certificates = crl_info.get("revoked_certificates", [])
|
||||
|
||||
result = dict(
|
||||
failed=False,
|
||||
last_update=dict(
|
||||
raw=crl_info.get("last_update"), parsed=self.last_update
|
||||
),
|
||||
next_update=dict(
|
||||
raw=crl_info.get("next_update"), parsed=self.next_update
|
||||
),
|
||||
)
|
||||
|
||||
if self.warn_for_expire:
|
||||
expired = self.expired(self.next_update)
|
||||
|
||||
result.update({"expired": expired})
|
||||
|
||||
if expired:
|
||||
result.update({"warn": True})
|
||||
|
||||
if self.list_revoked_certificates:
|
||||
result.update({"revoked_certificates": self.revoked_certificates})
|
||||
|
||||
return result
|
||||
|
||||
except OpenSSLObjectError as e:
|
||||
msg = f"Error while decoding CRL file: {to_native(e)}"
|
||||
self.module.log(msg)
|
||||
# self.module.fail_json(msg)
|
||||
return dict(failed=True, msg=msg)
|
||||
|
||||
def renew_crl(self):
|
||||
"""
|
||||
rm '{{ openvpn_easyrsa.directory }}/pki/crl.pem'
|
||||
"""
|
||||
if self.working_dir:
|
||||
os.chdir(self.working_dir)
|
||||
|
||||
if os.path.isfile(self.crl_file):
|
||||
os.remove(self.crl_file)
|
||||
|
||||
ersa = EasyRSA(module=self.module, force=True, working_dir=self.working_dir)
|
||||
|
||||
rc, changed, msg = ersa.gen_crl()
|
||||
|
||||
if rc == 0:
|
||||
return dict(failed=False, changed=changed, msg=msg)
|
||||
else:
|
||||
return dict(failed=True, changed=changed, msg=msg)
|
||||
|
||||
return (rc, changed, msg)
|
||||
|
||||
def expired(self, next_update):
|
||||
""" """
|
||||
from datetime import datetime
|
||||
|
||||
result = False
|
||||
now = datetime.now()
|
||||
|
||||
time_diff = next_update - now
|
||||
time_diff_in_days = time_diff.days
|
||||
# self.module.log(f" - {time_diff_in_days} vs. {self.expire_in_days}")
|
||||
|
||||
if time_diff_in_days <= self.expire_in_days:
|
||||
result = True
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
args = dict(
|
||||
state=dict(default="status", choices=["status", "renew"]),
|
||||
pki_dir=dict(required=False, type="str", default="/etc/easy-rsa/pki"),
|
||||
working_dir=dict(required=False),
|
||||
list_revoked_certificates=dict(required=False, type="bool", default=False),
|
||||
warn_for_expire=dict(required=False, type="bool", default=True),
|
||||
expire_in_days=dict(required=False, type="int", default=10),
|
||||
force=dict(required=False, type="bool", default=False),
|
||||
)
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=args,
|
||||
supports_check_mode=False,
|
||||
)
|
||||
|
||||
o = OpenVPNCrl(module)
|
||||
result = o.run()
|
||||
|
||||
module.log(msg=f"= result: {result}")
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
# import module snippets
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
"""
|
||||
|
||||
# openssl crl -text -noout -in /etc/easy-rsa/pki/crl.pem
|
||||
Certificate Revocation List (CRL):
|
||||
Version 2 (0x1)
|
||||
Signature Algorithm: sha512WithRSAEncryption
|
||||
Issuer: CN = Open VPN
|
||||
Last Update: Apr 24 03:51:14 2023 GMT
|
||||
Next Update: Apr 23 03:51:14 2024 GMT
|
||||
CRL extensions:
|
||||
X509v3 Authority Key Identifier:
|
||||
keyid:35:D2:22:90:B6:82:4A:DC:64:03:6B:17:8B:B0:07:E2:52:E0:60:1E
|
||||
DirName:/CN=Open VPN
|
||||
serial:64:41:E6:07:CC:D0:8E:1F:24:A8:F9:91:FC:34:0F:4B:47:06:D1:74
|
||||
No Revoked Certificates.
|
||||
Signature Algorithm: sha512WithRSAEncryption
|
||||
Signature Value:
|
||||
d4:f4:fc:06:fa:ed:8b:cd:f4:eb:95:fb:88:c1:e8:ff:30:eb:
|
||||
e7:2a:ea:fa:8f:60:a6:07:81:a6:1a:aa:72:4b:68:7d:46:3a:
|
||||
7c:a2:3c:df:a3:7b:7b:85:0e:ba:1b:ba:06:13:04:74:0a:9c:
|
||||
27:60:ec:09:df:1a:3d:b6:3b:71:d1:20:4a:55:dc:47:e7:60:
|
||||
b5:65:82:09:ff:5d:e3:e2:4d:15:55:4f:1f:48:e8:c5:72:9b:
|
||||
a8:61:fd:17:8e:c4:4e:98:59:fa:58:6d:b1:a8:3a:b1:87:79:
|
||||
76:c2:9d:4a:3c:a3:54:e1:14:10:02:96:e9:fe:bf:6f:ab:2d:
|
||||
56:35:6e:94:9d:b1:aa:4f:d6:3c:b0:9a:29:8a:17:c6:7d:18:
|
||||
0c:15:fa:30:a9:c8:a5:22:63:79:cd:31:a0:1f:d0:38:be:93:
|
||||
c2:0f:be:73:97:2b:79:58:db:b9:bb:ec:aa:a9:f2:ac:cc:bb:
|
||||
4e:66:15:23:ae:1e:2b:86:40:79:4c:14:eb:58:e0:71:d7:3e:
|
||||
c8:93:11:e5:7a:e5:26:7a:94:c1:57:4b:75:ca:cb:92:c2:ca:
|
||||
87:a3:b8:16:7b:3d:53:13:23:70:04:c3:35:c7:41:29:06:9d:
|
||||
32:63:96:90:3d:4f:82:7a:23:08:9d:d7:85:d9:ad:9d:09:d2:
|
||||
e9:52:39:72:af:0d:4b:74:a2:39:c5:5c:80:4d:88:db:74:ae:
|
||||
87:a7:d3:cf:f3:0f:ae:44:94:bd:f8:21:c7:64:c7:bb:aa:46:
|
||||
68:ba:fb:42:37:ef:41:6f:0e:cb:c0:e9:c6:83:fb:15:8f:f0:
|
||||
a4:d4:2b:34:40:b0:89:b1:f7:d0:ce:c8:2c:3e:7d:7c:e4:37:
|
||||
c4:98:56:30:a2:42:89:36:fe:a8:3c:15:ec:fe:37:c7:a8:ba:
|
||||
78:39:70:54:c9:fc:6a:7f:05:5c:89:f3:4b:0f:c1:fe:1a:93:
|
||||
68:63:70:7b:ed:cb:82:85:3f:a2:8e:bc:d5:b7:21:b2:dc:2a:
|
||||
e9:79:a3:8f:a8:ad:9e:d4:f0:5a:13:18:2f:ea:bc:00:cf:e4:
|
||||
76:fb:fa:f4:cb:c3:b6:d4:d9:d4:b7:f1:eb:16:10:e9:69:93:
|
||||
64:fa:d3:f6:1b:9b:2f:7a:fb:6b:99:8d:7a:07:51:62:ed:fa:
|
||||
38:51:2a:e7:70:e9:a2:83:be:cf:a4:8d:5d:35:b6:49:7a:56:
|
||||
17:2a:a7:88:7d:6c:43:69:f3:67:f7:ce:69:97:5c:b8:ad:90:
|
||||
4e:9b:ab:cf:6c:52:a8:3e:54:09:61:8f:f3:7b:98:b3:a8:1f:
|
||||
75:6e:94:a1:c1:89:b8:f7:df:5c:7a:b7:13:47:c0:b1:42:03:
|
||||
c5:18:2a:77:6a:50:c9:8f
|
||||
|
||||
https://serverfault.com/questions/979826/how-to-verify-certificate-revocation-lists-against-multiple-certification-path
|
||||
"""
|
||||
|
|
@ -0,0 +1,389 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2022, Bodo Schulz <bodo@boone-schulz.de>
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
---
|
||||
module: openvpn_ovpn
|
||||
short_description: Create or remove an inline OpenVPN client configuration (.ovpn) from Easy-RSA client credentials
|
||||
version_added: "1.1.3"
|
||||
author:
|
||||
- Bodo Schulz (@bodsch) <bodo@boone-schulz.de>
|
||||
|
||||
description:
|
||||
- Creates an inline OpenVPN client configuration file (C(.ovpn)) containing embedded client key and certificate.
|
||||
- The client key and certificate are read from an Easy-RSA PKI structure (C(pki/private/<user>.key) and C(pki/issued/<user>.crt)).
|
||||
- Uses a Jinja2 template file (C(/etc/openvpn/client.ovpn.template)) to render the final config.
|
||||
- Writes a SHA256 checksum sidecar file (C(.<user>.ovpn.sha256)) to support basic change detection.
|
||||
- Can remove both the generated C(.ovpn) and checksum file.
|
||||
|
||||
options:
|
||||
state:
|
||||
description:
|
||||
- Whether the OVPN configuration should be present or absent.
|
||||
type: str
|
||||
default: present
|
||||
choices:
|
||||
- present
|
||||
- absent
|
||||
|
||||
force:
|
||||
description:
|
||||
- If enabled, removes existing destination files before (re)creating the configuration.
|
||||
- This also removes the checksum file.
|
||||
type: bool
|
||||
default: false
|
||||
|
||||
username:
|
||||
description:
|
||||
- Client name/user to build the configuration for.
|
||||
- Used to locate Easy-RSA key/certificate and to name the output files.
|
||||
type: str
|
||||
required: true
|
||||
|
||||
destination_directory:
|
||||
description:
|
||||
- Directory where the generated C(<username>.ovpn) and checksum file are written.
|
||||
- The directory must exist.
|
||||
type: str
|
||||
required: true
|
||||
|
||||
chdir:
|
||||
description:
|
||||
- Change into this directory before processing.
|
||||
- Useful if Easy-RSA PKI paths are relative to a working directory.
|
||||
type: path
|
||||
required: false
|
||||
|
||||
creates:
|
||||
description:
|
||||
- If this path exists, the module returns early with no changes.
|
||||
- When C(state=present) and C(creates) exists, the message will indicate the configuration is already created.
|
||||
type: path
|
||||
required: false
|
||||
|
||||
notes:
|
||||
- Check mode is not supported.
|
||||
- The template path is currently fixed to C(/etc/openvpn/client.ovpn.template).
|
||||
- The module expects an Easy-RSA PKI layout under the (optional) C(chdir) working directory.
|
||||
- File permissions for the generated C(.ovpn) are set to C(0600).
|
||||
|
||||
requirements:
|
||||
- Python Jinja2 must be available on the target node for C(state=present).
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
- name: Create an inline client configuration for user 'alice'
|
||||
bodsch.core.openvpn_ovpn:
|
||||
state: present
|
||||
username: alice
|
||||
destination_directory: /etc/openvpn/clients
|
||||
|
||||
- name: Create config with PKI relative to a working directory
|
||||
bodsch.core.openvpn_ovpn:
|
||||
state: present
|
||||
username: bob
|
||||
destination_directory: /etc/openvpn/clients
|
||||
chdir: /etc/easy-rsa
|
||||
|
||||
- name: Force recreation of an existing .ovpn file
|
||||
bodsch.core.openvpn_ovpn:
|
||||
state: present
|
||||
username: carol
|
||||
destination_directory: /etc/openvpn/clients
|
||||
force: true
|
||||
|
||||
- name: Skip if a marker file already exists
|
||||
bodsch.core.openvpn_ovpn:
|
||||
state: present
|
||||
username: dave
|
||||
destination_directory: /etc/openvpn/clients
|
||||
creates: /var/lib/openvpn/clients/dave.created
|
||||
|
||||
- name: Remove client configuration and checksum file
|
||||
bodsch.core.openvpn_ovpn:
|
||||
state: absent
|
||||
username: alice
|
||||
destination_directory: /etc/openvpn/clients
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
changed:
|
||||
description:
|
||||
- Whether the module changed anything.
|
||||
returned: always
|
||||
type: bool
|
||||
|
||||
failed:
|
||||
description:
|
||||
- Indicates failure.
|
||||
returned: always
|
||||
type: bool
|
||||
|
||||
message:
|
||||
description:
|
||||
- Human readable status message.
|
||||
returned: always
|
||||
type: str
|
||||
sample:
|
||||
- "ovpn file /etc/openvpn/clients/alice.ovpn exists."
|
||||
- "ovpn file successful written as /etc/openvpn/clients/alice.ovpn."
|
||||
- "ovpn file /etc/openvpn/clients/alice.ovpn successful removed."
|
||||
- "can not find key or certfile for user alice."
|
||||
- "user req already created"
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OpenVPNOvpn(object):
|
||||
"""
|
||||
Main Class to implement the Icinga2 API Client
|
||||
"""
|
||||
|
||||
module = None
|
||||
|
||||
def __init__(self, module):
|
||||
"""
|
||||
Initialize all needed Variables
|
||||
"""
|
||||
self.module = module
|
||||
|
||||
self.state = module.params.get("state")
|
||||
self.force = module.params.get("force", False)
|
||||
self._username = module.params.get("username", None)
|
||||
self._destination_directory = module.params.get("destination_directory", None)
|
||||
|
||||
self._chdir = module.params.get("chdir", None)
|
||||
self._creates = module.params.get("creates", None)
|
||||
|
||||
self._openvpn = module.get_bin_path("openvpn", True)
|
||||
self._easyrsa = module.get_bin_path("easyrsa", True)
|
||||
|
||||
self.key_file = os.path.join("pki", "private", f"{self._username}.key")
|
||||
self.crt_file = os.path.join("pki", "issued", f"{self._username}.crt")
|
||||
self.dst_file = os.path.join(
|
||||
self._destination_directory, f"{self._username}.ovpn"
|
||||
)
|
||||
|
||||
self.dst_checksum_file = os.path.join(
|
||||
self._destination_directory, f".{self._username}.ovpn.sha256"
|
||||
)
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
runner
|
||||
"""
|
||||
result = dict(failed=False, changed=False, ansible_module_results="none")
|
||||
|
||||
if self._chdir:
|
||||
os.chdir(self._chdir)
|
||||
|
||||
self.__validate_checksums()
|
||||
|
||||
if self.force:
|
||||
self.module.log(msg="force mode ...")
|
||||
if os.path.exists(self.dst_file):
|
||||
self.module.log(msg=f"remove {self.dst_file}")
|
||||
os.remove(self.dst_file)
|
||||
os.remove(self.dst_checksum_file)
|
||||
|
||||
if self._creates:
|
||||
if os.path.exists(self._creates):
|
||||
message = "nothing to do."
|
||||
if self.state == "present":
|
||||
message = "user req already created"
|
||||
|
||||
return dict(changed=False, message=message)
|
||||
|
||||
if self.state == "present":
|
||||
return self.__create_ovpn_config()
|
||||
if self.state == "absent":
|
||||
return self.__remove_ovpn_config()
|
||||
|
||||
return result
|
||||
|
||||
def __create_ovpn_config(self):
|
||||
""" """
|
||||
if os.path.exists(self.dst_file):
|
||||
return dict(
|
||||
failed=False,
|
||||
changed=False,
|
||||
message=f"ovpn file {self.dst_file} exists.",
|
||||
)
|
||||
|
||||
if os.path.exists(self.key_file) and os.path.exists(self.crt_file):
|
||||
""" """
|
||||
from jinja2 import Template
|
||||
|
||||
with open(self.key_file, "r") as k_file:
|
||||
k_data = k_file.read().rstrip("\n")
|
||||
|
||||
cert = self.__extract_certs_as_strings(self.crt_file)[0].rstrip("\n")
|
||||
|
||||
tpl = "/etc/openvpn/client.ovpn.template"
|
||||
|
||||
with open(tpl) as file_:
|
||||
tm = Template(file_.read())
|
||||
|
||||
d = tm.render(key=k_data, cert=cert)
|
||||
|
||||
with open(self.dst_file, "w") as fp:
|
||||
fp.write(d)
|
||||
|
||||
self.__create_checksum_file(self.dst_file, self.dst_checksum_file)
|
||||
|
||||
force_mode = "0600"
|
||||
if isinstance(force_mode, str):
|
||||
mode = int(force_mode, base=8)
|
||||
|
||||
os.chmod(self.dst_file, mode)
|
||||
|
||||
return dict(
|
||||
failed=False,
|
||||
changed=True,
|
||||
message=f"ovpn file successful written as {self.dst_file}.",
|
||||
)
|
||||
|
||||
else:
|
||||
return dict(
|
||||
failed=True,
|
||||
changed=False,
|
||||
message=f"can not find key or certfile for user {self._username}.",
|
||||
)
|
||||
|
||||
def __remove_ovpn_config(self):
|
||||
""" """
|
||||
if os.path.exists(self.dst_file):
|
||||
os.remove(self.dst_file)
|
||||
|
||||
if os.path.exists(self.dst_checksum_file):
|
||||
os.remove(self.dst_checksum_file)
|
||||
|
||||
if self._creates and os.path.exists(self._creates):
|
||||
os.remove(self._creates)
|
||||
|
||||
return dict(
|
||||
failed=False,
|
||||
changed=True,
|
||||
message=f"ovpn file {self.dst_file} successful removed.",
|
||||
)
|
||||
|
||||
def __extract_certs_as_strings(self, cert_file):
|
||||
""" """
|
||||
certs = []
|
||||
with open(cert_file) as whole_cert:
|
||||
cert_started = False
|
||||
content = ""
|
||||
for line in whole_cert:
|
||||
if "-----BEGIN CERTIFICATE-----" in line:
|
||||
if not cert_started:
|
||||
content += line
|
||||
cert_started = True
|
||||
else:
|
||||
print("Error, start cert found but already started")
|
||||
sys.exit(1)
|
||||
elif "-----END CERTIFICATE-----" in line:
|
||||
if cert_started:
|
||||
content += line
|
||||
certs.append(content)
|
||||
content = ""
|
||||
cert_started = False
|
||||
else:
|
||||
print("Error, cert end found without start")
|
||||
sys.exit(1)
|
||||
elif cert_started:
|
||||
content += line
|
||||
|
||||
if cert_started:
|
||||
print("The file is corrupted")
|
||||
sys.exit(1)
|
||||
|
||||
return certs
|
||||
|
||||
def __validate_checksums(self):
|
||||
""" """
|
||||
dst_checksum = None
|
||||
dst_old_checksum = None
|
||||
|
||||
if os.path.exists(self.dst_file):
|
||||
with open(self.dst_file, "r") as d:
|
||||
dst_data = d.read().rstrip("\n")
|
||||
dst_checksum = self.__checksum(dst_data)
|
||||
|
||||
if os.path.exists(self.dst_checksum_file):
|
||||
with open(self.dst_checksum_file, "r") as f:
|
||||
dst_old_checksum = f.readlines()[0]
|
||||
else:
|
||||
if dst_checksum is not None:
|
||||
dst_old_checksum = self.__create_checksum_file(
|
||||
self.dst_file, self.dst_checksum_file
|
||||
)
|
||||
|
||||
if dst_checksum is None or dst_old_checksum is None:
|
||||
valid = False
|
||||
else:
|
||||
valid = dst_checksum == dst_old_checksum
|
||||
|
||||
return valid
|
||||
|
||||
def __create_checksum_file(self, filename, checksumfile):
|
||||
""" """
|
||||
if os.path.exists(filename):
|
||||
with open(filename, "r") as d:
|
||||
_data = d.read().rstrip("\n")
|
||||
_checksum = self.__checksum(_data)
|
||||
|
||||
with open(checksumfile, "w") as f:
|
||||
f.write(_checksum)
|
||||
|
||||
return _checksum
|
||||
|
||||
def __checksum(self, plaintext):
|
||||
""" """
|
||||
_bytes = plaintext.encode("utf-8")
|
||||
_hash = hashlib.sha256(_bytes)
|
||||
return _hash.hexdigest()
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Module execution.
|
||||
#
|
||||
|
||||
|
||||
def main():
|
||||
""" """
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
state=dict(default="present", choices=["present", "absent"]),
|
||||
force=dict(required=False, default=False, type="bool"),
|
||||
username=dict(required=True, type="str"),
|
||||
destination_directory=dict(required=True, type="str"),
|
||||
chdir=dict(required=False),
|
||||
creates=dict(required=False),
|
||||
),
|
||||
supports_check_mode=False,
|
||||
)
|
||||
|
||||
o = OpenVPNOvpn(module)
|
||||
result = o.run()
|
||||
|
||||
module.log(msg=f"= result: {result}")
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
# import module snippets
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import re
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
---
|
||||
module: openvpn_version
|
||||
short_description: Read the installed OpenVPN version
|
||||
version_added: "1.1.3"
|
||||
author:
|
||||
- Bodo Schulz (@bodsch) <bodo@boone-schulz.de>
|
||||
|
||||
description:
|
||||
- Executes C(openvpn --version) on the target host.
|
||||
- Parses the semantic version (C(X.Y.Z)) from the output.
|
||||
- Returns the full stdout and stdout_lines for troubleshooting.
|
||||
|
||||
options: {}
|
||||
|
||||
notes:
|
||||
- Check mode is not supported.
|
||||
- The module fails if the C(openvpn) binary cannot be found on the target host.
|
||||
|
||||
requirements:
|
||||
- OpenVPN installed on the target host.
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
- name: Get OpenVPN version
|
||||
bodsch.core.openvpn_version:
|
||||
register: openvpn
|
||||
|
||||
- name: Print parsed version
|
||||
ansible.builtin.debug:
|
||||
msg: "OpenVPN version: {{ openvpn.version }}"
|
||||
|
||||
- name: Print raw stdout for troubleshooting
|
||||
ansible.builtin.debug:
|
||||
var: openvpn.stdout_lines
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
version:
|
||||
description:
|
||||
- Parsed OpenVPN version (C(X.Y.Z)) if found, otherwise C(unknown).
|
||||
returned: always
|
||||
type: str
|
||||
sample: "2.6.8"
|
||||
|
||||
stdout:
|
||||
description:
|
||||
- Raw stdout from C(openvpn --version).
|
||||
returned: always
|
||||
type: str
|
||||
|
||||
stdout_lines:
|
||||
description:
|
||||
- Stdout split into lines.
|
||||
returned: always
|
||||
type: list
|
||||
elements: str
|
||||
|
||||
failed:
|
||||
description:
|
||||
- Indicates whether parsing the version failed.
|
||||
returned: always
|
||||
type: bool
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OpenVPN(object):
|
||||
""" """
|
||||
|
||||
module = None
|
||||
|
||||
def __init__(self, module):
|
||||
""" """
|
||||
self.module = module
|
||||
|
||||
self._openvpn = module.get_bin_path("openvpn", True)
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
runner
|
||||
"""
|
||||
_failed = True
|
||||
_version = "unknown"
|
||||
_stdout = ""
|
||||
_stdout_lines = []
|
||||
|
||||
args = []
|
||||
|
||||
args.append(self._openvpn)
|
||||
args.append("--version")
|
||||
|
||||
rc, out = self._exec(args)
|
||||
|
||||
if "OpenVPN" in out:
|
||||
pattern = re.compile(
|
||||
r"OpenVPN (?P<version>[0-9]+\.[0-9]+\.[0-9]+).*", re.MULTILINE
|
||||
)
|
||||
found = re.search(pattern, out.rstrip())
|
||||
|
||||
if found:
|
||||
_version = found.group("version")
|
||||
_failed = False
|
||||
else:
|
||||
_failed = True
|
||||
|
||||
_stdout = f"{out.rstrip()}"
|
||||
_stdout_lines = _stdout.split("\n")
|
||||
|
||||
return dict(
|
||||
stdout=_stdout, stdout_lines=_stdout_lines, failed=_failed, version=_version
|
||||
)
|
||||
|
||||
def _exec(self, commands):
|
||||
""" """
|
||||
rc, out, err = self.module.run_command(commands, check_rc=False)
|
||||
|
||||
if int(rc) != 0:
|
||||
self.module.log(msg=f" rc : '{rc}'")
|
||||
self.module.log(msg=f" out: '{out}'")
|
||||
self.module.log(msg=f" err: '{err}'")
|
||||
|
||||
return rc, out
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Module execution.
|
||||
#
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
args = dict()
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=args,
|
||||
supports_check_mode=False,
|
||||
)
|
||||
|
||||
o = OpenVPN(module)
|
||||
result = o.run()
|
||||
|
||||
module.log(msg="= result: {}".format(result))
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
# import module snippets
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import re
|
||||
|
||||
from ansible.module_utils import distro
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
---
|
||||
module: package_version
|
||||
version_added: 0.9.0
|
||||
author: "Bodo Schulz (@bodsch) <bodo@boone-schulz.de>"
|
||||
|
||||
short_description: Attempts to determine the version of a package to be installed or already installed.
|
||||
|
||||
description:
|
||||
- Attempts to determine the version of a package to be installed or already installed.
|
||||
- Supports apt, pacman, dnf (or yum) as package manager.
|
||||
|
||||
options:
|
||||
state:
|
||||
description:
|
||||
- The status of a package.
|
||||
- Defines whether the version of an already installed (C(installed)) package or the
|
||||
version of a package available for installation (C(available)) is output.
|
||||
default: available
|
||||
required: true
|
||||
repository:
|
||||
description:
|
||||
- Name of the repository in which the search is being conducted.
|
||||
- This is only necessary for RedHat-based distributions.
|
||||
type: str
|
||||
default: ""
|
||||
required: false
|
||||
package_name:
|
||||
description:
|
||||
- Package name which is searched for in the system or via the package management.
|
||||
type: str
|
||||
required: true
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
- name: get version of available package
|
||||
bodsch.core.package_version:
|
||||
package_name: nano
|
||||
register: package_version
|
||||
|
||||
- name: get version of available mariadb-server
|
||||
bodsch.core.package_version:
|
||||
state: available
|
||||
package_name: mariadb-server
|
||||
register: package_version
|
||||
|
||||
- name: get version of installed php-fpm
|
||||
bodsch.core.package_version:
|
||||
package_name: php-fpm
|
||||
state: installed
|
||||
register: package_version
|
||||
|
||||
- name: detect available mariadb version for RedHat based
|
||||
bodsch.core.package_version:
|
||||
state: available
|
||||
package_name: mariadb-server
|
||||
repository: MariaDB
|
||||
register: package_version
|
||||
when:
|
||||
- ansible_facts.os_family | lower == 'redhat'
|
||||
- mariadb_use_external_repo
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
full_version:
|
||||
description:
|
||||
- Version String
|
||||
type: string
|
||||
platform_version:
|
||||
description:
|
||||
- Version String with major and minor Part (e.g. 8.1)
|
||||
type: string
|
||||
major_version:
|
||||
description:
|
||||
- major Version (e.g. 8)
|
||||
type: string
|
||||
version_string_compressed:
|
||||
description:
|
||||
- Compressed variant of (C(platform_version)) (e.g. 81).
|
||||
- Only needed for RedHat-based distributions.
|
||||
type: string
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PackageVersion(object):
|
||||
""" """
|
||||
|
||||
def __init__(self, module):
|
||||
|
||||
self.module = module
|
||||
|
||||
self.state = module.params.get("state")
|
||||
self.package_name = module.params.get("package_name")
|
||||
self.package_version = module.params.get("package_version")
|
||||
self.repository = module.params.get("repository")
|
||||
|
||||
self.distribution = distro.id()
|
||||
self.version = distro.version()
|
||||
self.codename = distro.codename()
|
||||
|
||||
self.module.log(
|
||||
msg=f" - pkg : {self.distribution} - {self.version} - {self.codename}"
|
||||
)
|
||||
|
||||
def run(self):
|
||||
""" """
|
||||
version = ""
|
||||
error = True
|
||||
msg = f"unknown or unsupported distribution: '{self.distribution}'"
|
||||
|
||||
if self.distribution.lower() in ["debian", "ubuntu"]:
|
||||
error, version, msg = self._search_apt()
|
||||
|
||||
if self.distribution.lower() in ["arch", "artix"]:
|
||||
error, version, msg = self._search_pacman()
|
||||
|
||||
if self.distribution.lower() in [
|
||||
"centos",
|
||||
"oracle",
|
||||
"redhat",
|
||||
"fedora",
|
||||
"rocky",
|
||||
"almalinux",
|
||||
]:
|
||||
error, version, msg = self._search_yum()
|
||||
|
||||
if error:
|
||||
return dict(failed=True, available_versions=version, msg=msg)
|
||||
|
||||
if version is not None:
|
||||
major_version = None
|
||||
minor_version = None
|
||||
platform_version = None
|
||||
|
||||
version_splitted = version.split(".")
|
||||
|
||||
# self.module.log(msg=f" - version_splitted : {version_splitted}")
|
||||
major_version = version_splitted[0]
|
||||
|
||||
if len(version_splitted) > 1:
|
||||
minor_version = version_splitted[1]
|
||||
|
||||
if minor_version:
|
||||
platform_version = ".".join([major_version, minor_version])
|
||||
else:
|
||||
platform_version = major_version
|
||||
|
||||
version = dict(
|
||||
full_version=version,
|
||||
platform_version=platform_version,
|
||||
major_version=major_version,
|
||||
version_string_compressed=version.replace(".", ""),
|
||||
)
|
||||
|
||||
result = dict(failed=error, available=version, msg=msg)
|
||||
|
||||
return result
|
||||
|
||||
def _search_apt(self):
|
||||
"""
|
||||
support apt
|
||||
"""
|
||||
import apt
|
||||
|
||||
pkg = None
|
||||
|
||||
cache = apt.cache.Cache()
|
||||
|
||||
# try:
|
||||
# cache.update()
|
||||
# except SystemError as error:
|
||||
# self.module.log(msg=f"error : {error}")
|
||||
# raise FetchFailedException(error)
|
||||
# if not res and raise_on_error:
|
||||
# self.module.log(msg="FetchFailedException()")
|
||||
# raise FetchFailedException()
|
||||
# else:
|
||||
# cache.open()
|
||||
|
||||
try:
|
||||
cache.update()
|
||||
cache.open()
|
||||
except SystemError as error:
|
||||
self.module.log(msg=f"error : {error}")
|
||||
return False, None, f"package {self.package_name} is not installed"
|
||||
except Exception as error:
|
||||
self.module.log(msg=f"error : {error}")
|
||||
|
||||
try:
|
||||
pkg = cache[self.package_name]
|
||||
version_string = None
|
||||
|
||||
# debian:10 / buster:
|
||||
# [php-fpm=2:7.3+69]
|
||||
# ubuntu:20.04 / focal
|
||||
# [php-fpm=2:7.4+75]
|
||||
# debian:9 : 1:10.4.20+maria~stretch'
|
||||
# debian 10: 1:10.4.20+maria~buster
|
||||
#
|
||||
except KeyError as error:
|
||||
self.module.log(msg=f"error : {error}")
|
||||
return False, None, f"package {self.package_name} is not installed"
|
||||
|
||||
if pkg:
|
||||
# self.module.log(msg=f" - pkg : {pkg} ({type(pkg)})")
|
||||
# self.module.log(msg=f" - installed : {pkg.is_installed}")
|
||||
# self.module.log(msg=f" - shortname : {pkg.shortname}")
|
||||
# self.module.log(msg=f" - versions : {pkg.versions}")
|
||||
# self.module.log(msg=f" - versions : {pkg.versions[0]}")
|
||||
|
||||
pkg_version = pkg.versions[0]
|
||||
version = pkg_version.version
|
||||
|
||||
if version[1] == ":":
|
||||
pattern = re.compile(r"(?<=\:)(?P<version>.*?)(?=[-+])")
|
||||
else:
|
||||
pattern = re.compile(r"(?P<version>.*?)(?=[-+])")
|
||||
|
||||
result = re.search(pattern, version)
|
||||
version_string = result.group("version")
|
||||
|
||||
# self.module.log(msg=f" - version_string : {version_string}")
|
||||
return False, version_string, ""
|
||||
|
||||
def _search_yum(self):
|
||||
"""
|
||||
support dnf and - as fallback - yum
|
||||
"""
|
||||
package_mgr = self.module.get_bin_path("dnf", False)
|
||||
|
||||
if not package_mgr:
|
||||
package_mgr = self.module.get_bin_path("yum", True)
|
||||
|
||||
if not package_mgr:
|
||||
return True, "", "no valid package manager (yum or dnf) found"
|
||||
|
||||
package_version = self.package_version
|
||||
|
||||
if package_version:
|
||||
package_version = package_version.replace(".", "")
|
||||
|
||||
args = []
|
||||
args.append(package_mgr)
|
||||
|
||||
args.append("info")
|
||||
args.append(self.package_name)
|
||||
|
||||
if self.repository:
|
||||
args.append("--disablerepo")
|
||||
args.append("*")
|
||||
args.append("--enablerepo")
|
||||
args.append(self.repository)
|
||||
|
||||
rc, out, err = self.module.run_command(args, check_rc=False)
|
||||
|
||||
version = ""
|
||||
|
||||
if rc == 0:
|
||||
versions = []
|
||||
|
||||
pattern = re.compile(r".*Version.*: (?P<version>.*)", re.MULTILINE)
|
||||
# pattern = re.compile(
|
||||
# r"^{0}[0-9+].*\.x86_64.*(?P<version>[0-9]+\.[0-9]+)\..*@(?P<repo>.*)".format(self.package_name),
|
||||
# re.MULTILINE
|
||||
# )
|
||||
|
||||
for line in out.splitlines():
|
||||
self.module.log(msg=f" line : {line}")
|
||||
for match in re.finditer(pattern, line):
|
||||
result = re.search(pattern, line)
|
||||
versions.append(result.group("version"))
|
||||
|
||||
self.module.log(msg=f"versions : '{versions}'")
|
||||
|
||||
if len(versions) == 0:
|
||||
msg = "nothing found"
|
||||
error = True
|
||||
|
||||
if len(versions) == 1:
|
||||
msg = ""
|
||||
error = False
|
||||
version = versions[0]
|
||||
|
||||
if len(versions) > 1:
|
||||
msg = "more then one result found! choose one of them!"
|
||||
error = True
|
||||
version = ", ".join(versions)
|
||||
else:
|
||||
msg = f"package {self.package_name} not found"
|
||||
error = False
|
||||
version = None
|
||||
|
||||
return error, version, msg
|
||||
|
||||
def _search_pacman(self):
|
||||
"""
|
||||
pacman support
|
||||
pacman --noconfirm --sync --search php7 | grep -E "^(extra|world)\\/php7 (.*)\\[installed\\]" | cut -d' ' -f2
|
||||
"""
|
||||
pacman_bin = self.module.get_bin_path("pacman", True)
|
||||
|
||||
version = None
|
||||
args = []
|
||||
args.append(pacman_bin)
|
||||
|
||||
if self.state == "installed":
|
||||
args.append("--query")
|
||||
else:
|
||||
args.append("--noconfirm")
|
||||
args.append("--sync")
|
||||
|
||||
args.append("--search")
|
||||
args.append(self.package_name)
|
||||
|
||||
rc, out, err = self._pacman(args)
|
||||
|
||||
if rc == 0:
|
||||
pattern = re.compile(
|
||||
# r'^(?P<repository>core|extra|community|world|local)\/{}[0-9\s]*(?P<version>\d\.\d).*-.*'.format(self.package_name),
|
||||
r"^(?P<repository>core|extra|community|world|local)\/{} (?P<version>\d+(\.\d+){{0,2}}(\.\*)?)-.*".format(
|
||||
self.package_name
|
||||
),
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
result = re.search(pattern, out)
|
||||
|
||||
msg = ""
|
||||
error = False
|
||||
version = result.group("version")
|
||||
|
||||
else:
|
||||
msg = f"package {self.package_name} not found"
|
||||
error = False
|
||||
version = None
|
||||
|
||||
return error, version, msg
|
||||
|
||||
def _pacman(self, cmd):
|
||||
"""
|
||||
support pacman
|
||||
"""
|
||||
rc, out, err = self.module.run_command(cmd, check_rc=False)
|
||||
|
||||
if rc != 0:
|
||||
self.module.log(msg=f" rc : '{rc}'")
|
||||
self.module.log(msg=f" out: '{out}'")
|
||||
self.module.log(msg=f" err: '{err}'")
|
||||
|
||||
return rc, out, err
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Module execution.
|
||||
#
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
args = dict(
|
||||
state=dict(
|
||||
choices=[
|
||||
"installed",
|
||||
"available",
|
||||
],
|
||||
default="available",
|
||||
),
|
||||
package_name=dict(required=True, type="str"),
|
||||
package_version=dict(required=False, default=""),
|
||||
repository=dict(required=False, default=""),
|
||||
)
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=args,
|
||||
supports_check_mode=False,
|
||||
)
|
||||
|
||||
result = PackageVersion(module).run()
|
||||
|
||||
module.log(msg=f"= result : '{result}'")
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
# import module snippets
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
import os
|
||||
import os.path
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.checksum import Checksum
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.directory import (
|
||||
create_directory,
|
||||
)
|
||||
from ansible_collections.bodsch.core.plugins.module_utils.template.template import (
|
||||
write_template,
|
||||
)
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
DOCUMENTATION = """
|
||||
module: pip_requirements
|
||||
version_added: 1.0.16
|
||||
author: "Bodo Schulz (@bodsch) <bodo@boone-schulz.de>"
|
||||
|
||||
short_description: This modules creates an requirement file to install python modules via pip.
|
||||
|
||||
description:
|
||||
- This modules creates an requirement file to install python modules via pip.
|
||||
|
||||
options:
|
||||
state:
|
||||
description:
|
||||
- Whether to install (C(present)), or remove (C(absent)) a package.
|
||||
required: true
|
||||
name:
|
||||
description:
|
||||
- Name of the running module.
|
||||
- Needed to create the requirements file and the checksum
|
||||
type: str
|
||||
required: true
|
||||
requirements:
|
||||
description:
|
||||
- A list with all python modules to install.
|
||||
type: list
|
||||
required: true
|
||||
"""
|
||||
|
||||
EXAMPLES = """
|
||||
- name: create pip requirements file
|
||||
bodsch.core.pip_requirements:
|
||||
name: docker
|
||||
state: present
|
||||
requirements:
|
||||
- name: docker
|
||||
compare_direction: "=="
|
||||
version: 6.0.0
|
||||
|
||||
- name: setuptools
|
||||
version: 39.1.0
|
||||
|
||||
- name: requests
|
||||
versions:
|
||||
- ">= 2.28.0"
|
||||
- "< 2.30.0"
|
||||
- "!~ 1.1.0"
|
||||
register: pip_requirements
|
||||
"""
|
||||
|
||||
RETURN = """
|
||||
pip_present:
|
||||
description:
|
||||
- true if `pip` or `pip3` binary found
|
||||
type: bool
|
||||
requirements_file:
|
||||
description:
|
||||
- the created requirements file
|
||||
type: str
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
TPL_REQUIREMENTS = """# generated by ansible
|
||||
|
||||
# # It is possible to specify requirements as plain names.
|
||||
# pytest
|
||||
# pytest-cov
|
||||
# beautifulsoup4
|
||||
#
|
||||
# # The syntax supported here is the same as that of requirement specifiers.
|
||||
# docopt == 0.6.1
|
||||
# requests [security] >= 2.8.1, == 2.8.* ; python_version < "2.7"
|
||||
|
||||
{% for k in item.split(':') %}
|
||||
{{ k }}
|
||||
{% endfor %}
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class PipRequirements:
|
||||
"""
|
||||
Main Class
|
||||
"""
|
||||
|
||||
module = None
|
||||
|
||||
def __init__(self, module):
|
||||
""" """
|
||||
self.module = module
|
||||
|
||||
self.state = module.params.get("state")
|
||||
self.name = module.params.get("name")
|
||||
self.requirements = module.params.get("requirements")
|
||||
|
||||
self.cache_directory = "/var/cache/ansible/pip_requirements"
|
||||
self.requirements_file_name = os.path.join(
|
||||
self.cache_directory, f"{self.name}.txt"
|
||||
)
|
||||
self.checksum_file_name = os.path.join(
|
||||
self.cache_directory, f"{self.name}.checksum"
|
||||
)
|
||||
|
||||
def run(self):
|
||||
""" """
|
||||
# self.module.log(msg=f"{self.name}:")
|
||||
# self.module.log(msg=f" {self.requirements}")
|
||||
|
||||
create_directory(self.cache_directory)
|
||||
|
||||
_changed = False
|
||||
_msg = "There are no changes."
|
||||
|
||||
checksum = None
|
||||
|
||||
if self.state == "absent":
|
||||
if os.path.exists(self.cache_directory):
|
||||
os.remove(self.requirements_file_name)
|
||||
os.remove(self.checksum_file_name)
|
||||
_changed = True
|
||||
_msg = "The pip requirements have been successfully removed."
|
||||
|
||||
return dict(changed=_changed, msg=_msg)
|
||||
|
||||
checksum = Checksum(self.module)
|
||||
|
||||
changed, new_checksum, old_checksum = checksum.validate(
|
||||
self.checksum_file_name, self.requirements
|
||||
)
|
||||
|
||||
# self.module.log(f" changed : {changed}")
|
||||
# self.module.log(f" new_checksum : {new_checksum}")
|
||||
# self.module.log(f" old_checksum : {old_checksum}")
|
||||
|
||||
self.pip_binary = self.module.get_bin_path("pip3", False)
|
||||
|
||||
if not self.pip_binary:
|
||||
self.pip_binary = self.module.get_bin_path("pip", False)
|
||||
|
||||
if not self.pip_binary:
|
||||
pip_present = False
|
||||
else:
|
||||
pip_present = True
|
||||
|
||||
if not changed:
|
||||
return dict(
|
||||
changed=False,
|
||||
requirements_file=self.requirements_file_name,
|
||||
pip=dict(present=pip_present, bin_path=self.pip_binary),
|
||||
)
|
||||
|
||||
req = self.pip_requirements(self.requirements)
|
||||
|
||||
write_template(self.requirements_file_name, TPL_REQUIREMENTS, req)
|
||||
|
||||
checksum.write_checksum(self.checksum_file_name, new_checksum)
|
||||
|
||||
return dict(
|
||||
changed=True,
|
||||
requirements_file=self.requirements_file_name,
|
||||
pip=dict(present=pip_present, bin_path=self.pip_binary),
|
||||
)
|
||||
|
||||
def pip_requirements(self, data):
|
||||
""" """
|
||||
result = []
|
||||
|
||||
valid_compare = [">=", "<=", ">", "<", "==", "!=", "~="]
|
||||
|
||||
if isinstance(data, list):
|
||||
for entry in data:
|
||||
name = entry.get("name")
|
||||
compare_direction = entry.get("compare_direction", None)
|
||||
version = entry.get("version", None)
|
||||
versions = entry.get("versions", [])
|
||||
url = entry.get("url", None)
|
||||
|
||||
if isinstance(version, str):
|
||||
if compare_direction and compare_direction in valid_compare:
|
||||
version = f"{compare_direction} {version}"
|
||||
else:
|
||||
version = f"== {version}"
|
||||
|
||||
result.append(f"{name} {version}")
|
||||
|
||||
elif isinstance(versions, list) and len(versions) > 0:
|
||||
valid_versions = [
|
||||
x for x in versions if x.startswith(tuple(valid_compare))
|
||||
]
|
||||
versions = ", ".join(valid_versions)
|
||||
result.append(f"{name} {versions}")
|
||||
|
||||
elif isinstance(url, str):
|
||||
result.append(f"{name} @ {url}")
|
||||
|
||||
else:
|
||||
result.append(name)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Module execution.
|
||||
#
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
args = dict(
|
||||
state=dict(
|
||||
choices=[
|
||||
"present",
|
||||
"absent",
|
||||
],
|
||||
default="present",
|
||||
),
|
||||
name=dict(
|
||||
type="str",
|
||||
required=True,
|
||||
),
|
||||
requirements=dict(
|
||||
type="list",
|
||||
required=True,
|
||||
),
|
||||
)
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=args,
|
||||
supports_check_mode=False,
|
||||
)
|
||||
|
||||
obj = PipRequirements(module)
|
||||
result = obj.run()
|
||||
|
||||
module.log(msg=f"= result: {result}")
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
# import module snippets
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
DOCUMENTATION = """
|
||||
module: remove_ansible_backups
|
||||
version_added: 0.9.0
|
||||
author: "Bodo Schulz (@bodsch) <bodo@boone-schulz.de>"
|
||||
|
||||
short_description: Remove older backup files created by ansible
|
||||
|
||||
description:
|
||||
- Remove older backup files created by ansible
|
||||
|
||||
options:
|
||||
path:
|
||||
description:
|
||||
- Path for the search for backup files
|
||||
type: str
|
||||
required: true
|
||||
hold:
|
||||
description:
|
||||
- How many backup files should be retained
|
||||
type: int
|
||||
default: 2
|
||||
required: false
|
||||
"""
|
||||
|
||||
EXAMPLES = """
|
||||
- name: remove older ansible backup files
|
||||
bodsch.core.remove_ansible_backups:
|
||||
path: /etc
|
||||
holds: 4
|
||||
"""
|
||||
|
||||
RETURN = """
|
||||
removed:
|
||||
returned: on success
|
||||
description: >
|
||||
Job's up to date information
|
||||
type: dict
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RemoveAnsibleBackups(object):
|
||||
"""
|
||||
Main Class
|
||||
"""
|
||||
|
||||
module = None
|
||||
|
||||
def __init__(self, module):
|
||||
"""
|
||||
Initialize all needed Variables
|
||||
"""
|
||||
self.module = module
|
||||
|
||||
self.verbose = module.params.get("verbose")
|
||||
self.path = module.params.get("path")
|
||||
self.hold = module.params.get("hold")
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
runner
|
||||
"""
|
||||
_failed = False
|
||||
_changed = False
|
||||
_msg = "no backups found"
|
||||
|
||||
backups = self.find_backup_files()
|
||||
removed = self.remove_backups(backups)
|
||||
|
||||
if len(removed) > 0:
|
||||
_changed = True
|
||||
_msg = removed
|
||||
|
||||
return dict(failed=_failed, changed=_changed, removed=_msg)
|
||||
|
||||
def find_backup_files(self):
|
||||
""" """
|
||||
_files = []
|
||||
_name = None
|
||||
backup_files = []
|
||||
backups = dict()
|
||||
|
||||
if os.path.isdir(self.path):
|
||||
""" """
|
||||
os.chdir(self.path)
|
||||
|
||||
# file_pattern = re.compile(r"
|
||||
# (?P<file_name>.*)\.(.*)\.(?P<year>\d{4})-(?P<month>.{2})-
|
||||
# (?P<day>\d+)@(?P<hour>\d+):(?P<minute>\d+):(?P<second>\d{2})~", re.MULTILINE)
|
||||
|
||||
file_pattern = re.compile(
|
||||
r"""
|
||||
(?P<file_name>.*)\. # Alles vor dem ersten Punkt (Dateiname)
|
||||
(.*)\. # Irgendein Teil nach dem ersten Punkt (z.B. Erweiterung)
|
||||
(?P<year>\d{4})- # Jahr (4-stellig)
|
||||
(?P<month>.{2})- # Monat (2 Zeichen – ggf. besser \d{2}?)
|
||||
(?P<day>\d+)@ # Tag, dann @
|
||||
(?P<hour>\d+): # Stunde
|
||||
(?P<minute>\d+): # Minute
|
||||
(?P<second>\d{2})~ # Sekunde, dann Tilde
|
||||
""",
|
||||
re.VERBOSE | re.MULTILINE,
|
||||
)
|
||||
|
||||
# self.module.log(msg=f"search files in {self.path}")
|
||||
|
||||
# recursive file list
|
||||
for root, dirnames, filenames in os.walk(self.path):
|
||||
for filename in filenames:
|
||||
_files.append(os.path.join(root, filename))
|
||||
|
||||
# filter file list wirth regex
|
||||
backup_files = list(filter(file_pattern.match, _files))
|
||||
backup_files.sort()
|
||||
|
||||
for f in backup_files:
|
||||
""" """
|
||||
file_name = os.path.basename(f)
|
||||
path_name = os.path.dirname(f)
|
||||
|
||||
name = re.search(file_pattern, file_name)
|
||||
|
||||
if name:
|
||||
n = name.group("file_name")
|
||||
_idx = os.path.join(path_name, n)
|
||||
|
||||
if str(n) == str(_name):
|
||||
backups[_idx].append(f)
|
||||
else:
|
||||
backups[_idx] = []
|
||||
backups[_idx].append(f)
|
||||
|
||||
_name = n
|
||||
|
||||
return backups
|
||||
|
||||
else:
|
||||
return None
|
||||
|
||||
def remove_backups(self, backups):
|
||||
""" """
|
||||
_backups = dict()
|
||||
|
||||
for k, v in backups.items():
|
||||
backup_count = len(v)
|
||||
|
||||
self.module.log(msg=f" - file: {k} has {backup_count} backup(s)")
|
||||
|
||||
if backup_count > self.hold:
|
||||
""" """
|
||||
_backups[k] = []
|
||||
|
||||
# bck_hold = v[self.hold:]
|
||||
bck_to_remove = v[: -self.hold]
|
||||
# self.module.log(msg=f" - hold backups: {bck_hold}")
|
||||
# self.module.log(msg=f" - remove backups: {bck_to_remove}")
|
||||
|
||||
for bck in bck_to_remove:
|
||||
if os.path.isfile(bck):
|
||||
if self.module.check_mode:
|
||||
self.module.log(msg=f"CHECK MODE - remove {bck}")
|
||||
else:
|
||||
self.module.log(msg=f" - remove {bck}")
|
||||
|
||||
if not self.module.check_mode:
|
||||
os.remove(bck)
|
||||
|
||||
_backups[k].append(bck)
|
||||
|
||||
return _backups
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Module execution.
|
||||
#
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
args = dict(
|
||||
verbose=dict(
|
||||
type="bool",
|
||||
required=False,
|
||||
),
|
||||
path=dict(
|
||||
type="path",
|
||||
required=True,
|
||||
),
|
||||
hold=dict(type="int", required=False, default=2),
|
||||
)
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=args,
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
postfix = RemoveAnsibleBackups(module)
|
||||
result = postfix.run()
|
||||
|
||||
module.log(msg=f"= result: {result}")
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
# import module snippets
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2021-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0/)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
ANSIBLE_METADATA = {
|
||||
"metadata_version": "0.1",
|
||||
"status": ["preview"],
|
||||
"supported_by": "community",
|
||||
}
|
||||
|
||||
|
||||
class SnakeoilOpenssl(object):
|
||||
"""
|
||||
Main Class
|
||||
"""
|
||||
|
||||
module = None
|
||||
|
||||
def __init__(self, module):
|
||||
"""
|
||||
Initialize all needed Variables
|
||||
"""
|
||||
self.module = module
|
||||
|
||||
self._openssl = module.get_bin_path("openssl", True)
|
||||
self.state = module.params.get("state")
|
||||
self.directory = module.params.get("directory")
|
||||
self.domain = module.params.get("domain")
|
||||
self.dhparam = module.params.get("dhparam")
|
||||
self.cert_life_time = module.params.get("cert_life_time")
|
||||
self.openssl_config = module.params.get("openssl_config")
|
||||
|
||||
def run(self):
|
||||
""" """
|
||||
result = dict(failed=True, changed=False, msg="failed")
|
||||
|
||||
# base_directory = os.path.join(self.directory, self.domain)
|
||||
#
|
||||
# if not os.path.isdir(base_directory):
|
||||
# return dict(
|
||||
# failed=True,
|
||||
# changed=False,
|
||||
# msg=f"missing directory {base_directory}"
|
||||
# )
|
||||
#
|
||||
# os.chdir(base_directory)
|
||||
_ssl_args = []
|
||||
|
||||
csr_file = os.path.join(self.directory, self.domain, f"{self.domain}.csr")
|
||||
crt_file = os.path.join(self.directory, self.domain, f"{self.domain}.crt")
|
||||
pem_file = os.path.join(self.directory, self.domain, f"{self.domain}.pem")
|
||||
key_file = os.path.join(self.directory, self.domain, f"{self.domain}.key")
|
||||
dh_file = os.path.join(self.directory, self.domain, "dh.pem")
|
||||
|
||||
if self.state == "csr":
|
||||
_ssl_args.append(self._openssl)
|
||||
_ssl_args.append("req")
|
||||
_ssl_args.append("-new")
|
||||
_ssl_args.append("-sha512")
|
||||
_ssl_args.append("-nodes")
|
||||
_ssl_args.append("-out")
|
||||
_ssl_args.append(csr_file)
|
||||
_ssl_args.append("-newkey")
|
||||
_ssl_args.append("rsa:4096")
|
||||
_ssl_args.append("-keyout")
|
||||
_ssl_args.append(key_file)
|
||||
_ssl_args.append("-config")
|
||||
_ssl_args.append(self.openssl_config)
|
||||
|
||||
error, msg = self._base_directory()
|
||||
|
||||
if error:
|
||||
return dict(failed=True, changed=False, msg=msg)
|
||||
|
||||
rc, out, err = self._exec(_ssl_args)
|
||||
|
||||
result = dict(failed=False, changed=True, msg="success")
|
||||
|
||||
if self.state == "crt":
|
||||
_ssl_args.append(self._openssl)
|
||||
_ssl_args.append("x509")
|
||||
_ssl_args.append("-req")
|
||||
_ssl_args.append("-in")
|
||||
_ssl_args.append(csr_file)
|
||||
_ssl_args.append("-out")
|
||||
_ssl_args.append(crt_file)
|
||||
_ssl_args.append("-signkey")
|
||||
_ssl_args.append(key_file)
|
||||
_ssl_args.append("-extfile")
|
||||
_ssl_args.append(self.openssl_config)
|
||||
_ssl_args.append("-extensions")
|
||||
_ssl_args.append("req_ext")
|
||||
_ssl_args.append("-days")
|
||||
_ssl_args.append(str(self.cert_life_time))
|
||||
|
||||
error, msg = self._base_directory()
|
||||
|
||||
if error:
|
||||
return dict(failed=True, changed=False, msg=msg)
|
||||
|
||||
rc, out, err = self._exec(_ssl_args)
|
||||
|
||||
# cat {{ domain }}.crt {{ domain }}.key >> {{ domain }}.pem
|
||||
if rc == 0:
|
||||
filenames = [crt_file, key_file]
|
||||
with open(pem_file, "w") as outfile:
|
||||
for fname in filenames:
|
||||
with open(fname) as infile:
|
||||
outfile.write(infile.read())
|
||||
|
||||
result = dict(failed=False, changed=True, msg="success")
|
||||
|
||||
if self.state == "dhparam":
|
||||
_ssl_args.append(self._openssl)
|
||||
_ssl_args.append("dhparam")
|
||||
_ssl_args.append("-5")
|
||||
_ssl_args.append("-out")
|
||||
_ssl_args.append(dh_file)
|
||||
_ssl_args.append(str(self.dhparam))
|
||||
|
||||
error, msg = self._base_directory()
|
||||
|
||||
if error:
|
||||
return dict(failed=True, changed=False, msg=msg)
|
||||
|
||||
rc, out, err = self._exec(_ssl_args)
|
||||
|
||||
result = dict(failed=False, changed=True, msg="success")
|
||||
|
||||
if self.state == "dhparam_size":
|
||||
_ssl_args.append(self._openssl)
|
||||
_ssl_args.append("dhparam")
|
||||
_ssl_args.append("-in")
|
||||
_ssl_args.append(dh_file)
|
||||
_ssl_args.append("-text")
|
||||
|
||||
error, msg = self._base_directory()
|
||||
|
||||
if error:
|
||||
return dict(failed=False, changed=False, size=int(0))
|
||||
|
||||
rc, out, err = self._exec(_ssl_args)
|
||||
|
||||
if rc == 0:
|
||||
""" """
|
||||
output_string = 0
|
||||
pattern = re.compile(r".*DH Parameters: \((?P<size>\d+) bit\).*")
|
||||
|
||||
result = re.search(pattern, out)
|
||||
if result:
|
||||
output_string = result.group("size")
|
||||
|
||||
result = dict(failed=False, changed=False, size=int(output_string))
|
||||
|
||||
return result
|
||||
|
||||
def _base_directory(self):
|
||||
""" """
|
||||
error = False
|
||||
msg = ""
|
||||
|
||||
base_directory = os.path.join(self.directory, self.domain)
|
||||
|
||||
if os.path.isdir(base_directory):
|
||||
os.chdir(base_directory)
|
||||
else:
|
||||
error = True
|
||||
msg = f"missing directory {base_directory}"
|
||||
|
||||
return (error, msg)
|
||||
|
||||
def _exec(self, args):
|
||||
""" """
|
||||
self.module.log(msg="args: {}".format(args))
|
||||
|
||||
rc, out, err = self.module.run_command(args, check_rc=True)
|
||||
self.module.log(msg=" rc : '{}'".format(rc))
|
||||
if rc != 0:
|
||||
self.module.log(msg=" out: '{}'".format(str(out)))
|
||||
self.module.log(msg=" err: '{}'".format(err))
|
||||
|
||||
return rc, out, err
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Module execution.
|
||||
#
|
||||
|
||||
|
||||
def main():
|
||||
""" """
|
||||
args = dict(
|
||||
state=dict(required=True, choose=["crt", "csr", "dhparam" "dhparam_size"]),
|
||||
directory=dict(required=True, type="path"),
|
||||
domain=dict(required=True, type="path"),
|
||||
dhparam=dict(default=2048, type="int"),
|
||||
cert_life_time=dict(default=10, type="int"),
|
||||
openssl_config=dict(required=False, type="str"),
|
||||
# openssl_params=dict(required=True, type="path"),
|
||||
)
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=args,
|
||||
supports_check_mode=False,
|
||||
)
|
||||
|
||||
openssl = SnakeoilOpenssl(module)
|
||||
result = openssl.run()
|
||||
|
||||
module.log(msg=f"= result : '{result}'")
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
# import module snippets
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2023, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# Apache-2.0 (see LICENSE or https://opensource.org/license/apache-2-0)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import collections
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import dirsync
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
---
|
||||
module: sync_directory
|
||||
version_added: 1.1.3
|
||||
author: "Bodo Schulz (@bodsch) <bodo@boone-schulz.de>"
|
||||
|
||||
short_description: Syncronises directories similar to rsync.
|
||||
|
||||
description:
|
||||
- Syncronises directories similar to rsync.
|
||||
|
||||
options:
|
||||
source_directory:
|
||||
description:
|
||||
- The source directory.
|
||||
type: str
|
||||
default: ""
|
||||
required: true
|
||||
destination_directory:
|
||||
description:
|
||||
- The destination directory.
|
||||
type: str
|
||||
default: ""
|
||||
required: true
|
||||
arguments:
|
||||
description:
|
||||
- a dictionary with custom arguments.
|
||||
type: dict
|
||||
required: false
|
||||
include_pattern:
|
||||
description:
|
||||
- a list with regex patterns to include.
|
||||
type: list
|
||||
required: false
|
||||
exclude_pattern:
|
||||
description:
|
||||
- a list with regex patterns to exclude.
|
||||
type: list
|
||||
required: false
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
- name: sync /opt/server/data to /opt/data
|
||||
bodsch.core.sync_directory:
|
||||
source_directory: /opt/server/data
|
||||
destination_directory: /opt/data
|
||||
arguments:
|
||||
verbose: true
|
||||
purge: false
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
changed:
|
||||
description:
|
||||
- changed or not
|
||||
type: bool
|
||||
msg:
|
||||
description:
|
||||
- statusinformation
|
||||
type: string
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TailLogHandler(logging.Handler):
|
||||
|
||||
def __init__(self, log_queue):
|
||||
logging.Handler.__init__(self)
|
||||
self.log_queue = log_queue
|
||||
|
||||
def emit(self, record):
|
||||
self.log_queue.append(self.format(record))
|
||||
|
||||
|
||||
class TailLogger(object):
|
||||
|
||||
def __init__(self, maxlen):
|
||||
self._log_queue = collections.deque(maxlen=maxlen)
|
||||
self._log_handler = TailLogHandler(self._log_queue)
|
||||
|
||||
def contents(self):
|
||||
return "\n".join(self._log_queue)
|
||||
|
||||
@property
|
||||
def log_handler(self):
|
||||
return self._log_handler
|
||||
|
||||
|
||||
class Sync(object):
|
||||
""" """
|
||||
|
||||
def __init__(self, module):
|
||||
""" """
|
||||
self.module = module
|
||||
|
||||
self.source_directory = module.params.get("source_directory")
|
||||
self.destination_directory = module.params.get("destination_directory")
|
||||
|
||||
self.arguments = module.params.get("arguments")
|
||||
|
||||
self.include_pattern = module.params.get("include_pattern")
|
||||
self.exclude_pattern = module.params.get("exclude_pattern")
|
||||
|
||||
def run(self):
|
||||
""" """
|
||||
_failed = False
|
||||
_changed = False
|
||||
_msg = "The directory are synchronous."
|
||||
|
||||
include_pattern = None
|
||||
exclude_pattern = None
|
||||
|
||||
tail = TailLogger(2)
|
||||
|
||||
logger = logging.getLogger("dirsync")
|
||||
formatter = logging.Formatter("%(message)s")
|
||||
|
||||
log_handler = tail.log_handler
|
||||
log_handler.setFormatter(formatter)
|
||||
logger.addHandler(log_handler)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
if self.include_pattern and len(self.include_pattern) > 0:
|
||||
include_pattern = "|".join(self.include_pattern)
|
||||
include_pattern = f".*({include_pattern}).*"
|
||||
|
||||
if self.exclude_pattern and len(self.exclude_pattern) > 0:
|
||||
exclude_pattern = "|".join(self.exclude_pattern)
|
||||
exclude_pattern = f".*({exclude_pattern}).*"
|
||||
|
||||
# self.module.log(msg=f"include_pattern: {include_pattern}")
|
||||
# include_pattern = ('^.*\\.json$',)
|
||||
|
||||
if not os.path.isdir(self.source_directory):
|
||||
return dict(failed=True, msg="The source directory does not exist.")
|
||||
|
||||
if not os.path.isdir(self.destination_directory):
|
||||
return dict(failed=True, msg="The destination directory does not exist.")
|
||||
|
||||
if self.arguments and isinstance(self.arguments, dict):
|
||||
_create = self.arguments.get("create", False)
|
||||
_verbose = self.arguments.get("verbose", False)
|
||||
_purge = self.arguments.get("purge", False)
|
||||
|
||||
args = dict(
|
||||
create=_create,
|
||||
verbose=_verbose,
|
||||
purge=_purge,
|
||||
)
|
||||
|
||||
args.update({"logger": logger})
|
||||
|
||||
else:
|
||||
args = {
|
||||
"create": "False",
|
||||
"verbose": "False",
|
||||
"purge": "False",
|
||||
"logger": logger,
|
||||
}
|
||||
|
||||
if include_pattern:
|
||||
args.update(
|
||||
{
|
||||
"include": include_pattern,
|
||||
}
|
||||
)
|
||||
if exclude_pattern:
|
||||
args.update(
|
||||
{
|
||||
"exclude": exclude_pattern,
|
||||
}
|
||||
)
|
||||
|
||||
args.update({"force": True})
|
||||
|
||||
self.module.log(msg=f"args: {args}")
|
||||
|
||||
dirsync.sync(self.source_directory, self.destination_directory, "sync", **args)
|
||||
|
||||
log_contents = tail.contents()
|
||||
|
||||
self.module.log(msg=f"log_contents: {log_contents}")
|
||||
|
||||
if len(log_contents) > 0:
|
||||
if "directories were created" in log_contents:
|
||||
pattern = re.compile(
|
||||
r"(?P<directories>\d+).*directories were created.$"
|
||||
)
|
||||
else:
|
||||
pattern = re.compile(
|
||||
r"(?P<directories>\d+).*directories parsed, (?P<files_copied>\d+) files copied"
|
||||
)
|
||||
|
||||
re_result = re.search(pattern, log_contents)
|
||||
|
||||
if re_result:
|
||||
|
||||
directories = None
|
||||
files_copied = None
|
||||
|
||||
try:
|
||||
directories = re_result.group("directories")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
files_copied = re_result.group("files_copied")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# self.module.log(msg=f"directories: {directories}")
|
||||
# self.module.log(msg=f"files_copied: {files_copied}")
|
||||
|
||||
if files_copied:
|
||||
if int(files_copied) == 0:
|
||||
_changed = False
|
||||
_msg = "The directory are synchronous."
|
||||
elif int(files_copied) > 0:
|
||||
_changed = True
|
||||
_msg = "The directory were successfully synchronised."
|
||||
elif directories:
|
||||
if int(directories) > 0:
|
||||
_changed = True
|
||||
_msg = "The directory were successfully synchronised."
|
||||
|
||||
result = dict(changed=_changed, failed=_failed, msg=_msg)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
""" """
|
||||
args = dict(
|
||||
source_directory=dict(required=True, type="str"),
|
||||
destination_directory=dict(required=True, type="str"),
|
||||
arguments=dict(required=False, type="dict"),
|
||||
include_pattern=dict(required=False, type="list"),
|
||||
exclude_pattern=dict(required=False, type="list"),
|
||||
)
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=args,
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
p = Sync(module)
|
||||
result = p.run()
|
||||
|
||||
module.log(msg=f"= result: {result}")
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2020-2022, Bodo Schulz <bodo@boone-schulz.de>
|
||||
# BSD 2-clause (see LICENSE or https://opensource.org/licenses/BSD-2-Clause)
|
||||
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
import re
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
---
|
||||
module: syslog_cmd
|
||||
version_added: "1.1.3"
|
||||
short_description: Run syslog-ng with arbitrary command-line parameters
|
||||
author:
|
||||
- "Bodo Schulz (@bodsch) <bodo@boone-schulz.de>"
|
||||
|
||||
description:
|
||||
- Executes the C(syslog-ng) binary with the given list of parameters.
|
||||
- Typical use cases are configuration syntax validation and querying the installed version.
|
||||
|
||||
requirements:
|
||||
- syslog-ng
|
||||
|
||||
options:
|
||||
parameters:
|
||||
description:
|
||||
- List of command-line parameters to pass to C(syslog-ng).
|
||||
- Each list item may contain a single parameter or a parameter with a value.
|
||||
- Items containing spaces are split into multiple arguments before execution.
|
||||
type: list
|
||||
elements: str
|
||||
required: true
|
||||
|
||||
notes:
|
||||
- The module supports check mode.
|
||||
- When used with C(--version) or C(--syntax-only) in check mode, no external command is executed and simulated results are returned.
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
- name: Validate syslog-ng configuration
|
||||
bodsch.core.syslog_cmd:
|
||||
parameters:
|
||||
- --syntax-only
|
||||
check_mode: true
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Detect syslog-ng config version
|
||||
bodsch.core.syslog_cmd:
|
||||
parameters:
|
||||
- --version
|
||||
register: _syslog_config_version
|
||||
|
||||
- name: Run syslog-ng with custom parameters
|
||||
bodsch.core.syslog_cmd:
|
||||
parameters:
|
||||
- --control
|
||||
- show-config
|
||||
register: _syslog_custom_cmd
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
rc:
|
||||
description:
|
||||
- Return code from the C(syslog-ng) command.
|
||||
returned: always
|
||||
type: int
|
||||
|
||||
failed:
|
||||
description:
|
||||
- Indicates if the module execution failed.
|
||||
returned: always
|
||||
type: bool
|
||||
|
||||
args:
|
||||
description:
|
||||
- Full command list used to invoke C(syslog-ng).
|
||||
- Will be C(None) when running in check mode.
|
||||
returned: when a supported command is executed or simulated
|
||||
type: list
|
||||
|
||||
version:
|
||||
description:
|
||||
- Detected C(syslog-ng) version string (for example C(3.38)).
|
||||
returned: when C(--version) is present in I(parameters)
|
||||
type: str
|
||||
|
||||
msg:
|
||||
description:
|
||||
- Human readable message, for example C("syntax okay") for successful syntax checks or an error description.
|
||||
returned: when available
|
||||
type: str
|
||||
|
||||
stdout:
|
||||
description:
|
||||
- Standard output from the C(syslog-ng) command.
|
||||
- In check mode with C(--syntax-only), contains a simulated message.
|
||||
returned: when C(--syntax-only) is used or on error
|
||||
type: str
|
||||
|
||||
stderr:
|
||||
description:
|
||||
- Standard error from the C(syslog-ng) command.
|
||||
returned: when C(--syntax-only) is used and the command fails
|
||||
type: str
|
||||
|
||||
ansible_module_results:
|
||||
description:
|
||||
- Internal result marker, set to C("failed") when no supported action was executed.
|
||||
returned: when no supported parameters were processed
|
||||
type: str
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SyslogNgCmd(object):
|
||||
module = None
|
||||
|
||||
def __init__(self, module):
|
||||
"""
|
||||
Initialize all needed Variables
|
||||
"""
|
||||
self.module = module
|
||||
|
||||
self._syslog_ng_bin = module.get_bin_path("syslog-ng", False)
|
||||
self.parameters = module.params.get("parameters")
|
||||
|
||||
def run(self):
|
||||
"""..."""
|
||||
result = dict(failed=True, ansible_module_results="failed")
|
||||
|
||||
parameter_list = self._flatten_parameter()
|
||||
|
||||
self.module.debug("-> {parameter_list}")
|
||||
|
||||
if self.module.check_mode:
|
||||
self.module.debug("In check mode.")
|
||||
if "--version" in parameter_list:
|
||||
return dict(rc=0, failed=False, args=None, version="1")
|
||||
if "--syntax-only" in parameter_list:
|
||||
return dict(
|
||||
rc=0,
|
||||
failed=False,
|
||||
args=None,
|
||||
stdout="In check mode.",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
if not self._syslog_ng_bin:
|
||||
return dict(rc=1, failed=True, msg="no installed syslog-ng found")
|
||||
|
||||
args = []
|
||||
args.append(self._syslog_ng_bin)
|
||||
|
||||
if len(parameter_list) > 0:
|
||||
for arg in parameter_list:
|
||||
args.append(arg)
|
||||
|
||||
self.module.log(msg=f" - args {args}")
|
||||
|
||||
rc, out, err = self._exec(args)
|
||||
|
||||
if "--version" in parameter_list:
|
||||
"""
|
||||
get version"
|
||||
"""
|
||||
pattern = re.compile(
|
||||
r".*Installer-Version: (?P<version>\d\.\d+)\.", re.MULTILINE
|
||||
)
|
||||
version = re.search(pattern, out)
|
||||
version = version.group(1)
|
||||
|
||||
self.module.log(msg=f" version: '{version}'")
|
||||
|
||||
if rc == 0:
|
||||
return dict(rc=0, failed=False, args=args, version=version)
|
||||
|
||||
if "--syntax-only" in parameter_list:
|
||||
"""
|
||||
check syntax
|
||||
"""
|
||||
# self.module.log(msg=f" rc : '{rc}'")
|
||||
# self.module.log(msg=f" out: '{out}'")
|
||||
# self.module.log(msg=f" err: '{err}'")
|
||||
|
||||
if rc == 0:
|
||||
return dict(rc=rc, failed=False, args=args, msg="syntax okay")
|
||||
else:
|
||||
return dict(
|
||||
rc=rc,
|
||||
failed=True,
|
||||
args=args,
|
||||
stdout=out,
|
||||
stderr=err,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _exec(self, args):
|
||||
""" """
|
||||
rc, out, err = self.module.run_command(args, check_rc=True)
|
||||
# self.module.log(msg=" rc : '{}'".format(rc))
|
||||
# self.module.log(msg=" out: '{}' ({})".format(out, type(out)))
|
||||
# self.module.log(msg=" err: '{}'".format(err))
|
||||
return rc, out, err
|
||||
|
||||
def _flatten_parameter(self):
|
||||
"""
|
||||
split and flatten parameter list
|
||||
|
||||
input: ['--validate', '--log-level debug']
|
||||
output: ['--validate', '--log-level', 'debug']
|
||||
"""
|
||||
parameters = []
|
||||
|
||||
for _parameter in self.parameters:
|
||||
if " " in _parameter:
|
||||
_list = _parameter.split(" ")
|
||||
for _element in _list:
|
||||
parameters.append(_element)
|
||||
else:
|
||||
parameters.append(_parameter)
|
||||
|
||||
return parameters
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Module execution.
|
||||
#
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
parameters=dict(required=True, type="list"),
|
||||
),
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
c = SyslogNgCmd(module)
|
||||
result = c.run()
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
# import module snippets
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
|
||||
skip_list:
|
||||
- name[casing]
|
||||
- name[template]
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# EditorConfig helps developers define and maintain consistent
|
||||
# coding styles between different editors and IDEs
|
||||
# https://editorconfig.org/
|
||||
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 100
|
||||
|
||||
[*.py]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
[flake8]
|
||||
|
||||
# E221 multiple spaces before operator
|
||||
# E251 unexpected spaces around keyword / parameter equals
|
||||
|
||||
ignore = E221,E251
|
||||
|
||||
exclude =
|
||||
# No need to traverse our git directory
|
||||
.git,
|
||||
# There's no value in checking cache directories
|
||||
__pycache__,
|
||||
.tox
|
||||
|
||||
# E203: https://github.com/python/black/issues/315
|
||||
# ignore = D,E741,W503,W504,H,E501,E203
|
||||
|
||||
max-line-length = 195
|
||||
|
||||
6
ansible/playbooks/collections/ansible_collections/bodsch/core/roles/fail2ban/.gitignore
vendored
Normal file
6
ansible/playbooks/collections/ansible_collections/bodsch/core/roles/fail2ban/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.tox
|
||||
.galaxy_install_info
|
||||
*kate-swp
|
||||
__pycache__
|
||||
.cache
|
||||
.directory
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
# Based on ansible-lint config
|
||||
extends: default
|
||||
|
||||
rules:
|
||||
braces:
|
||||
max-spaces-inside: 1
|
||||
level: error
|
||||
brackets:
|
||||
max-spaces-inside: 1
|
||||
level: error
|
||||
colons:
|
||||
max-spaces-after: -1
|
||||
level: error
|
||||
commas:
|
||||
max-spaces-after: -1
|
||||
level: error
|
||||
comments: disable
|
||||
comments-indentation: disable
|
||||
document-start: disable
|
||||
empty-lines:
|
||||
max: 3
|
||||
level: error
|
||||
hyphens:
|
||||
level: error
|
||||
indentation:
|
||||
spaces: 2
|
||||
key-duplicates: enable
|
||||
line-length:
|
||||
max: 195
|
||||
level: warning
|
||||
new-line-at-end-of-file: disable
|
||||
new-lines:
|
||||
type: unix
|
||||
trailing-spaces: disable
|
||||
truthy: disable
|
||||
|
||||
ignore: |
|
||||
molecule/
|
||||
.github
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
Contributing
|
||||
============
|
||||
If you want to contribute to a project and make it better, your help is very welcome.
|
||||
Contributing is also a great way to learn more about social coding on Github, new technologies and
|
||||
and their ecosystems and how to make constructive, helpful bug reports, feature requests and the
|
||||
noblest of all contributions: a good, clean pull request.
|
||||
|
||||
### How to make a clean pull request
|
||||
|
||||
Look for a project's contribution instructions. If there are any, follow them.
|
||||
|
||||
- Create a personal fork of the project on Github.
|
||||
- Clone the fork on your local machine. Your remote repo on Github is called `origin`.
|
||||
- Add the original repository as a remote called `upstream`.
|
||||
- If you created your fork a while ago be sure to pull upstream changes into your local repository.
|
||||
- Create a new branch to work on! Branch from `develop` if it exists, else from `master`.
|
||||
- Implement/fix your feature, comment your code.
|
||||
- Follow the code style of the project, including indentation.
|
||||
- If the project has tests run them!
|
||||
- Write or adapt tests as needed.
|
||||
- Add or change the documentation as needed.
|
||||
- Squash your commits into a single commit. Create a new branch if necessary.
|
||||
- Push your branch to your fork on Github, the remote `origin`.
|
||||
- From your fork open a pull request in the correct branch. Target the project's `develop` branch if there is one, else go for `master`!
|
||||
- If the maintainer requests further changes just push them to your branch. The PR will be updated automatically.
|
||||
- Once the pull request is approved and merged you can pull the changes from `upstream` to your local repo and delete
|
||||
your extra branch(es).
|
||||
|
||||
And last but not least: Always write your commit messages in the present tense.
|
||||
Your commit message should describe what the commit, when applied, does to the
|
||||
code – not what you did to the code.
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
#
|
||||
export TOX_SCENARIO ?= default
|
||||
export TOX_ANSIBLE ?= ansible_8.5
|
||||
|
||||
.PHONY: converge destroy verify test lint
|
||||
|
||||
default: converge
|
||||
|
||||
converge:
|
||||
@hooks/converge
|
||||
|
||||
destroy:
|
||||
@hooks/destroy
|
||||
|
||||
verify:
|
||||
@hooks/verify
|
||||
|
||||
test:
|
||||
@hooks/test
|
||||
|
||||
lint:
|
||||
@hooks/lint
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
|
||||
# Ansible Role: `bodsch.core.fail2ban`
|
||||
|
||||
An Ansible Role that installs and configure fail2ban 2.x on Debian/Ubuntu, ArchLinux and ArtixLinux (mabybe also on other `openrc` based Systemes).
|
||||
|
||||
## Role Variables
|
||||
|
||||
Available variables are listed below, along with default values (see `defaults/main.yaml`):
|
||||
|
||||
`fail2ban_ignoreips`
|
||||
|
||||
can be an IP address, a CIDR mask or a DNS host.
|
||||
|
||||
`fail2ban_conf`
|
||||
|
||||
`fail2ban_jail`
|
||||
|
||||
`fail2ban_path_definitions`
|
||||
|
||||
`fail2ban_jails`
|
||||
|
||||
`fail2ban_jail`
|
||||
|
||||
|
||||
## Example Playbook
|
||||
|
||||
see into [molecule test](molecule/default/converge.yml) and [configuration](molecule/default/group_vars/all/vars.yml)
|
||||
|
||||
```yaml
|
||||
fail2ban_ignoreips:
|
||||
- 127.0.0.1/8
|
||||
- 192.168.0.0/24
|
||||
|
||||
fail2ban_conf:
|
||||
default:
|
||||
loglevel: INFO
|
||||
logtarget: "/var/log/fail2ban.log"
|
||||
syslogsocket: auto
|
||||
socket: /run/fail2ban/fail2ban.sock
|
||||
pidfile: /run/fail2ban/fail2ban.pid
|
||||
dbfile: /var/lib/fail2ban/fail2ban.sqlite3
|
||||
dbpurgeage: 1d
|
||||
dbmaxmatches: 10
|
||||
definition: {}
|
||||
thread:
|
||||
stacksize: 0
|
||||
|
||||
fail2ban_jail:
|
||||
default:
|
||||
ignoreips: "{{ fail2ban_ignoreips }}"
|
||||
bantime: 600
|
||||
maxretry: 3
|
||||
findtime: 3200
|
||||
backend: auto
|
||||
usedns: warn
|
||||
logencoding: auto
|
||||
jails_enabled: false
|
||||
actions:
|
||||
destemail: root@localhost
|
||||
sender: root@localhost
|
||||
mta: sendmail
|
||||
protocol: tcp
|
||||
chain: INPUT
|
||||
banaction: iptables-multiport
|
||||
|
||||
fail2ban_jails:
|
||||
- name: ssh
|
||||
enabled: true
|
||||
port: ssh
|
||||
filter: sshd
|
||||
logpath: /var/log/authlog.log
|
||||
findtime: 3200
|
||||
bantime: 86400
|
||||
maxretry: 2
|
||||
- name: ssh-breakin
|
||||
enabled: true
|
||||
port: ssh
|
||||
filter: sshd-break-in
|
||||
logpath: /var/log/authlog.log
|
||||
maxretry: 2
|
||||
- name: ssh-ddos
|
||||
enabled: true
|
||||
port: ssh
|
||||
filter: sshd-ddos
|
||||
logpath: /var/log/authlog.log
|
||||
maxretry: 2
|
||||
```
|
||||
|
||||
## Author
|
||||
|
||||
- Bodo Schulz
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
|
||||
# "ignoreip" can be an IP address, a CIDR mask or a DNS host
|
||||
fail2ban_ignoreips:
|
||||
- 127.0.0.1/8
|
||||
|
||||
fail2ban_conf: {}
|
||||
|
||||
fail2ban_jail: {}
|
||||
|
||||
fail2ban_path_definitions:
|
||||
# ARTIX - ArchLinux based, buth without systemd
|
||||
artixlinux:
|
||||
includes:
|
||||
before: paths-common.conf
|
||||
after: paths-overrides.local
|
||||
defaults:
|
||||
syslog_mail: /var/log/mail.log
|
||||
# control the `mail.warn` setting, see `/etc/rsyslog.d/50-default.conf` (if commented `mail.*` wins).
|
||||
# syslog_mail_warn = /var/log/mail.warn
|
||||
syslog_mail_warn: '%(syslog_mail)s'
|
||||
syslog_user: /var/log/user.log
|
||||
syslog_daemon: /var/log/daemon.log
|
||||
auth_log: /var/log/auth.log
|
||||
# ARCH
|
||||
archlinux:
|
||||
includes:
|
||||
before: paths-common.conf
|
||||
after: paths-overrides.local
|
||||
defaults:
|
||||
apache_error_log: /var/log/httpd/*error_log
|
||||
apache_access_log: /var/log/httpd/*access_log
|
||||
exim_main_log: /var/log/exim/main.log
|
||||
mysql_log:
|
||||
- /var/log/mariadb/mariadb.log
|
||||
- /var/log/mysqld.log
|
||||
roundcube_errors_log: /var/log/roundcubemail/errors
|
||||
# These services will log to the journal via syslog, so use the journal by
|
||||
# default.
|
||||
syslog_backend: systemd
|
||||
sshd_backend: systemd
|
||||
dropbear_backend: systemd
|
||||
proftpd_backend: systemd
|
||||
pureftpd_backend: systemd
|
||||
wuftpd_backend: systemd
|
||||
postfix_backend: systemd
|
||||
dovecot_backend: systemd
|
||||
|
||||
fail2ban_jails: []
|
||||
|
||||
fail2ban_actions: []
|
||||
|
||||
fail2ban_filters: []
|
||||
|
||||
...
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue