Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New gpg_keypair module to manage GPG keys. #743

Draft
wants to merge 78 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
f03b55e
new gpg_keypair module to manage GPG keys
austinlucaslake May 1, 2024
3ec2fa1
gpg_keypair module integration tests
austinlucaslake May 1, 2024
312d826
add diff_mode (support: none) to attributes list
austinlucaslake May 1, 2024
adab532
added version_added (2.22.0) to documentation
austinlucaslake May 1, 2024
ccc39b3
added elements qualifier to key_usage
austinlucaslake May 1, 2024
f47bb95
seperated if-else for improved readability
austinlucaslake May 1, 2024
f28ca9c
added dummy variables when extracting output from gpg command
austinlucaslake May 1, 2024
fd63e64
fixed invalid variable name when unpacking matching keys
austinlucaslake May 1, 2024
132e716
added missing punctuation in documentation
austinlucaslake May 1, 2024
f10082b
updated return conditions in documentation
austinlucaslake May 1, 2024
84277a8
removed default key_type
austinlucaslake May 2, 2024
422a248
removed type hints
austinlucaslake May 2, 2024
af95714
updated formating+documentation and added ability to specify multiple…
austinlucaslake May 2, 2024
c3660ec
fixed invalid parameter name
austinlucaslake May 2, 2024
ea6b1d7
added stricter matching passed on user input
austinlucaslake May 5, 2024
c9f89bb
added quotation marks around template expression brackets
austinlucaslake May 5, 2024
86a111a
fixing linting issues
austinlucaslake May 5, 2024
440acfd
syntax error in documentation
austinlucaslake May 5, 2024
60b2175
wrong module name
austinlucaslake May 5, 2024
d5d9c5d
fixed suboptions in documentation for subkeys parameter
austinlucaslake May 5, 2024
81166c7
removed keyserver/transient_key parameters and dependencies on Plugin…
austinlucaslake May 5, 2024
4753860
fixed linting errors
austinlucaslake May 5, 2024
8685426
updated documentation and curve requirements for ECC keys
austinlucaslake May 5, 2024
091c5d4
removed extraneous character that was causing syntax error
austinlucaslake May 6, 2024
965b667
reformated argument lists for run_module calls
austinlucaslake May 6, 2024
4891695
set subkeys parameter default to be empty list
austinlucaslake May 6, 2024
01d6ee2
updated documentation for check_mode and diff_mode attributes
austinlucaslake May 6, 2024
a70469a
fixed over-indentations
austinlucaslake May 6, 2024
90cf712
set defaults for all list-type parameters to empty list and ipdated r…
austinlucaslake May 6, 2024
4a7467a
provide bin path for gpg executable
austinlucaslake May 6, 2024
20fd381
delete key using returned fingerprint
austinlucaslake May 6, 2024
3ff3d83
fixed incorrect variable name when parsing regex
austinlucaslake May 6, 2024
899118f
added missing quotations for template expression
austinlucaslake May 6, 2024
d826d90
consolidated functions and added parameter to force new key generation
austinlucaslake May 6, 2024
9df8799
updated integration test to force new key generation
austinlucaslake May 6, 2024
c77ef5d
fixed syntax errors
austinlucaslake May 6, 2024
051b1be
fixed incorrect variable name during assertion
austinlucaslake May 6, 2024
4c32b07
fixed regex parsing for fingerprint after key generation
austinlucaslake May 6, 2024
8e00694
utilize user-id to match against for key deletion
austinlucaslake May 6, 2024
7ce40a8
removed no_log from fingerprints
austinlucaslake May 6, 2024
6edf177
adding missing extraction of capture group from fingerprint regex
austinlucaslake May 6, 2024
18fabae
updated code spacing and fixed text processing for key matching
austinlucaslake May 9, 2024
540545e
fixed secret key regex parsing and key matching for usage parameter
austinlucaslake May 9, 2024
2255f5e
fixed bad parameter matching
austinlucaslake May 9, 2024
473ee13
added more integration tests
austinlucaslake May 9, 2024
f4e06e5
capitalized GPG in documentation
austinlucaslake May 9, 2024
3bafd3f
add default attributes docs fragment
austinlucaslake May 9, 2024
baaec80
updated documentation and module parameter names
austinlucaslake May 9, 2024
aced2d3
updated filenames to use full .yaml extension and updated jinja2 temp…
austinlucaslake May 10, 2024
2d3faa1
changed parameter names for subkey suboptions and updated documentation
austinlucaslake May 10, 2024
6347b8c
added setup needed for dateutil dependency
austinlucaslake May 10, 2024
18f1c16
module will now fail safely if python-dateutil package is not found
austinlucaslake May 11, 2024
160b241
added option to automatically install python-dateutil and updated doc…
austinlucaslake May 11, 2024
b711ee4
updated parameter name and added versioning to python-dateutil
austinlucaslake May 11, 2024
758fdce
updated email in copyright statement
austinlucaslake May 13, 2024
a3c23a6
Create acme_certificate_deactivate_authz module (#741)
felixfontein May 1, 2024
6e1c1e0
Add tests for acme_certificate_deactivate_authz module. (#744)
felixfontein May 1, 2024
99521df
Refactor time code, add tests, fix bug when parsing absolute timestam…
felixfontein May 3, 2024
15ed057
Add acme_certificate_renewal_info module (#746)
felixfontein May 4, 2024
98c5c52
ACME: improve acme_certificate docs, include cert_id in acme_certific…
felixfontein May 4, 2024
f9f2231
Avoid exception if certificate has no AKI in acme_certificate. (#748)
felixfontein May 5, 2024
044a3be
Refactor and extend argument spec helper, use for ACME modules (#749)
felixfontein May 5, 2024
a147b78
ACME modules: simplify code, refactor argspec handling code, move csr…
felixfontein May 5, 2024
a7f2725
Fix documentation. (#751)
felixfontein May 5, 2024
89da989
ecs_certificate: allow to request renewal without csr (#740)
francescolovecchio May 9, 2024
8752b36
x509_certificate: fix time idempotence (#754)
felixfontein May 11, 2024
704d3ef
Revert all non-bugfixes merged since the last release.
felixfontein May 11, 2024
f9f38d4
Prepare 2.19.1 bugfix release.
felixfontein May 11, 2024
7c46bdd
Release 2.19.1.
felixfontein May 11, 2024
0021a0b
Next planned release is 2.20.0.
felixfontein May 11, 2024
ef9dbda
Revert "Revert all non-bugfixes merged since the last release."
felixfontein May 11, 2024
5809428
Pass codecov token to ansible-test-gh-action. (#755)
felixfontein May 11, 2024
f5e6a57
Make sure the ACME inspect tests run with both backends. (#758)
felixfontein May 12, 2024
2172e77
From now on automatically add period to new plugins in changelog, and…
felixfontein May 20, 2024
3c283d4
Prepare 2.20.0.
felixfontein May 20, 2024
ed3b4aa
Release 2.20.0.
felixfontein May 20, 2024
53b360b
The next expected release will be 2.21.0.
felixfontein May 20, 2024
8800e62
Remove usage of old ACME test container. (#760)
felixfontein May 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
390 changes: 390 additions & 0 deletions plugins/modules/gpg_keypair.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,390 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-

# Copyright (c) 2024, Austin Lucas Lake <[email protected]>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import absolute_import, division, print_function
__metaclass__ = type

DOCUMENTATION = '''
---
module: gpg_keypair
author: "Austin Lucas Lake (@austinlucaslake)"
short_description: Generate or delete GPG private and public keys
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
description:
- "This module allows one to generate or delete OpenSSH private and public keys using GnuPG (gpg)."
requirements:
- gpg >= 2.1
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
extends_documentation_fragment:
- ansible.builtin.files
- community.crypto.attributes
- community.crypto.attributes.files
attributes:
check_mode:
support: full
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
options:
state:
description:
- Whether the private and public keys should exist or not, taking action if the state is different from what is stated.
type: str
default: present
choices: [ present, absent ]
key_type:
description:
- "Specifies the type of key to create. By default this is V(EDDSSA) which must be used with Curve25519.
Supported key types are V(RSA), V(DSA), V(ECDSA), V(EDDSA), and V(ECDH)."
type: str
default: EDDSA
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
choices: ['RSA', 'DSA', 'ECDSA', 'EDDSA', 'ECDH']
key_length:
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
description:
- For non-ECC keys, this specifies the number of bits in the key to create.
- For RSA keys, the minimum is V(1024), the maximum is V(4096), and the default is V(3072).
- For DSA keys, the minimum is V(768), the maximum is V(3072), and the default is V(2048).
- Invalid values will automatically be saturated in the afforemented ranges for each respective key.
- For ECC keys, this parameter will be ignored.
type: int
key_curve:
description:
- For ECC keys, this specifies the curve used to generate the keys.
- Supported key curves are V(cv25519), V(nistp256), V(nistp384), V(nistp521), V(brainpoolP256r1), V(brainpoolP384r1), V(brainpoolP512r1), and V(secp256k1).
- EDDSA keys can only be used with V(cv25519).
- Only EDDSA and ECDH keys support V(cv25519), and for both, V(cv25519) is the default.
- For ECDSA and ECDH, the default is V(brainpoolP512r1).
- For non-ECC keys, this parameter with be ignored.
type: str
choices: ['cv25519', 'nistp256', 'nistp384', 'nistp521', 'brainpoolP256r1', 'brainpoolP384r1', 'brainpoolP512r1', 'secp256k1']
key_usage:
description:
- Specifies usage(s) for key.
- Support usages are V(encrypt), V(sign), V(auth), V(cert).
- V(cert) is given to all primary keys regardess, however can be used to only give V(vert) usage to a key.
- If not usage is specified, the valid usages for the given key type with be assigned.
- If O(state) is V(absent), this parameter is ignored.
type: list[str]
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
choices: ['encrypt', 'sign', 'auth', 'cert']
subkey_type:
description:
- Similar to O(key_type), but also supports V(ELG).
type: str
default: EDDSA
choices: ['RSA', 'DSA', 'ECDSA', 'EDDSA', 'ECDH', 'ELG']
subkey_length:
description:
- Similar to O(key_length).
- For ELG keys, the minimum is V(1024), the maximum is V(4096), and the default is V(3072)."
type: int
subkey_curve:
description:
- "Similar to O(key_curve)"
type: str
choices: ['cv25519', 'nistp256', 'nistp384', 'nistp521', 'brainpoolP256r1', 'brainpoolP384r1', 'brainpoolP512r1', 'secp256k1']
key_usage:
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
description:
- Similar to O(key_usage), but does not support V(cert).
type: list[str]
choices: ['encrypt', 'sign', 'auth', 'cert']
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
name:
description:
- Specifies a name for the key.
type: str
comment:
description:
- Specifies a comment for the key.
type: str
email:
description:
- Specifies an email for the key.
type: str
passphrase:
description:
- Passphrase used to decrypt an existing private key or encrypt a newly generated private key.
- If O(state) is V(absent), this parameter is ignored.
type: str
fingerprints:
description:
- Specifies keys to match against.
- Provided fingerprints will take priority over user-id "V(name) (V(comment)) <V(email)>".
- If O(state) is V(absent), keys with the provided fingerprints will be deleted if found
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
type: list[str]
keyserver:
description:
- Specifies keyserver to upload key to.
- If O(state) is V(absent), this parameter will be ignored.
type: str
transient_key:
description:
- Allows key generation to use a faster, but less secure random number generator.
type: bool
default: False
return_fingerprints:
description:
- Allows for the return of fingerprint(s) for newly created or deleted keys(s)
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
type: bool
default: False
'''

EXAMPLES = '''
- name: Generate the default GPG keypair (Ed25519)
community.crypto.gpg_keypair:

- name: Generate the default GPG keypair with a passphrase
community.crypto.gpg_keypair:
passphrase: super_secret_password

- name: Generate a RSA GPG keypair with the default RSA size (2048 bits)
community.crypto.gpg_keypair:
key_type: RSA

- name: Generate a RSA GPG keypair with custom size (4096 bits)
community.crypto.gpg_keypair:
key_type: RSA
key_length: 4096

- name: Generate an ECC GPG keypair
community.crypto.gpg_keypair:
key_type: EDDSA
key_curve: cv25519

- name: Generate a GPG keypair and with a subkey:
community.crypto.gpg_keypair:
subkey_type: ECDH
subkey_curve: cv25519

- name: Generate a GPG keypair with custom user-id:
community.crypto.gpg_keypair:
name: Your Name
comment: Interesting comment.
email: [email protected]

- name: Generate a GPG keypair and return fingerprint of new key
community.crypto.gpg_keypair:
return_fingerprints: true
register: gpg_keys

- name: Delete GPG keypair(s) matching a specified user-id:
community.crypto.gpg_keypair:
state: abscent
name: Your Name
comment: Interesting comment.
email: [email protected]

- name: Delete GPG keypair(s) matching a specified fingerprint:
community.crypto.gpg_keypair:
state: abscent
fingerprints:
- ABC123...

'''

RETURN = '''
size:
description: Size (in bits) of the SSH private key.
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
returned: changed or success
type: int
sample: 4096
fingerprints:
description: Fingerprint(s) of newly created or deleted key(s)
return: changed and `return_fingerprints`==True
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
type: list[str]
sample: [ ABC123... ]
'''

from typing import Dict, Union

import itertools
import re

from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils.gnupg.cli import GPGError
from ansible_collections.community.crypto.plugins.plugin_utils.gnupg import GPGError

def validate_params(params: Dict[str, Union[str, int]]) -> None:
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved

if params['override'] and params['present'] and not (params['fingerprint'] or params['name'] or params['comment'] or params['email']):
raise GPGError, 'To override existing keys, please provide any combination of the `fingerprint`, `name`, `comment`, and `email` parameters.'
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
keys = ['key']
if params['subkey_type']:
keys.append('subkey')
for key in keys:
if params[f'{key}_type'] == 'EDDSA':
if not params[f'{key}_usage']: params[f'{key}_usage'] = ['sign', 'auth']
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
elif params[f'{key}_usage'] not in list(itertools.combinations(['sign', 'auth'])):
raise GPGError, f'Invalid {key}_usage for {params[f"{key}_type"]} {key}.'
if not params[f'{key}_curve'] or params[f'{key}_curve'] == 'cv25519':
params[f'{key}_curve'] = 'ed25519'
elif params[f'{key}_curve'] != 'cv25519':
raise GPGError, f'Invalid {key}_curve for {params[f"{key}_type"]} {key}.'
elif params[f'{key}_type'] == 'ECDH':
if not params[f'{key}_usage']: params[f'{key}_usage'] = ['encrypt']
elif params[f'{key}_usage'] != ['encrypt']:
raise GPGError, f'Invalid {key}_usage for {params[f"{key}_type"]} {key}.'
if not params[f'{key}_curve']: params[f'{key}_curve'] = 'cv25519'
elif params[f'{key}_curve'] != 'cv25519':
raise GPGError, f'Invalid {key}_curve for {params[f"{key}_type"]} {key}.'
elif params[f'{key}_type'] == 'ECDSA':
if not params[f'{key}_usage']: params[f'{key}_usage'] = ['sign', 'auth']
elif params[f'{key}_usage'] not in list(itertools.combinations(['sign', 'auth'])):
raise GPGError, f'Invalid {key}_usage for {params[f"{key}_type"]} {key}.'
if not params[f'{key}_curve']: params[f'{key}_curve'] = 'brainpoolp521r1'
elif params[f'{key}_curve'] not in ['nistp256', 'nistp384', 'nistp521', 'brainpoolP256r1', 'brainpoolP384r1', 'brainpoolP512r1', 'secp256k1']:
raise GPGError, f'Invalid {key}_curve for {params[f"{key}_type"]} {key}.'
elif params[f'{key}_type'] == 'RSA':
if not params[f'{key}_usage']: params = ['ecrypt', 'sign', 'auth']
elif not params[f'{key}_usage'] not in list(itertools.combinatios(['ecrypt', 'sign', 'auth'])):
raise GPGError, f'Invalid {key}_usage for {params[f"{key}_type"]} {key}.'
if not params[f'{key}_length']: params[f'{key}_length'] = 3072
elif not 1024 <= params[f'{key}_length'] < 4096:
params[f'{key}_length'] = min(max(params[f'{key}_length'], 1024), 4096)
elif params[f'{key}_type'] == 'DSA':
if not params[f'{key}_usage']: params[f'{key}_usage'] = ['sign', 'auth']
elif params[f'{key}_usage'] not in list(itertools.combinations(['sign', 'auth'])):
raise GPGError, f'Invalid {key}_usage for {params[f"{key}_type"]} {key}.'
if not params[f'{key}_length']: params[f'{key}_length'] = 2048
elif not 768 <= params[f'{key}_length'] < 3072:
params[f'{key}_length'] = min(max(params[f'{key}_length'], 768), 3072)
elif params[f'{key}_type'] == 'ELG':
if params[f'{key}_type'] == params['key_type']:
raise GPGError, f'Invalid algorithm for {key}_type parameter.'
if not params[f'{key}_usage']: params[f'{key}_usage'] = ['encrypt']
elif params[f'{key}_usage'] != ['encrypt']:
raise GPGError, f'Invalid {key}_iusage for {params[f"{key}_type"]} {key}.'
if not params[f'{key}_length']: params[f'{key}_length'] = 3072
elif not 1024 <= params[f'{key}_length'] < 4096:
params[f'{key}_length'] = min(max(params[f'{key}_length'], 1024), 4096)

def list_matching_keys(name, comment, email, fingerprint):
user_id = ""
if params['name']:
user_id += f'{params["name"]} '
if params['comment']:
user_id += f'({params["comment"]}) '
if params['email']:
user_id += f'<{params["email"]}>'
if user_id:
user_id = f'"{user_id.strip()}"'

if user_id or fingerprints:
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
_, stdout, _ = gpg_runner.run_command(['gpg', '--batch', '--list-secret-keys', f'{*fingerprints if fingerprints else user_id}'])
lines = stdout.split('\n')
matching_keys = [line.strip() for line in lines if line.strip().isalnum()]
return matching_keys
return []

def delete_keypair(
gpg_runner: PluginGPGRunner,
matching_keys: List[str],
check_mode: bool = False
) -> Dict[str, Union[str, int]]:
if matching_keys:
gpg_runner.run_command([
f'{"dry-run" if check_mode else ""}',
'--batch',
'--yes',
'--delete-secret-and-public-key',
*matching_key
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
], check_rc=True)
if params['return_fingerprints']:
return dict(changed=True, fingerprints=matching_keys)
return dict(changed=True, fingerprints=[])
return dict(changed=False, fingerprints=[])

def generate_keypair(
gpg_runner: PluginGPGRunner,
params: Dict[str, Union[str, int, bool, List[str]]],
matching_keys,
check_mode: bool = False
) -> Dict[str, Union[bool, str]]:
if matching_keys:
if params['return_fingerprints']:
return dict(changed=False, fingerprints=matching_keys)
return dict(change=False, fingerprints=[])

parameters = f"""<<EOF
Key-Type: {params['key_type']}
Key-Length: {params['key_type']}
Key-Curve: {params['key_curve']}
{f'''
Subkey-Type: {params["subkey_type"]}
Subkey-Length: {params["subkey_type"]}
Subkey-Curve: {params["subkey_curve"]}
''' if params['subkey_type'] else ''}
Expire-Date: {params['expire_date']}
{f'Name-Real: {params["name"]}' if params['name'] else ''}
{f'Name-Comment: {params["comment"]}' if params['comment'] else ''}
{f'Name-Email: {params["email"]}' if params['email'] else ''}
{f'Passphrase: {params["passphrase"]}' if params['passphrase'] else '%no-protection'}
{f'Keyserver: {params["keyserver"]}' if params['keyserver'] else ''}
{'%transient-key' if params['transient_key'] else ''}
{'%dry-run' if check_mode or not params['force'] else ''}
%commit
EOF
"""

_, stdout, _ = gpg_runner.run_command([
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
f'{"dry-run" if check_mode else ""}',
'--batch',
'--log-file',
'/dev/stdout',
'--gen-key',
f'{parameters}'
])

if params['return_fingerprints']:
fingerprints = []
fingerprint = re.search(r"([a-zA-Z0-9]*)\.rev", stdout)
if fingerprint:
fingerprints.append(fingerprint)
return dict(changed=True, fingerprints=fingerprints)
return dict(changed=True, fingerprints=[])

def run_module(params: Dict[str, Union[str, int, bool, List[str]]], check_mode: bool = False):
validate_params(params)
gpg_runner = PluginGPGRunner()
matching_keys = list_matching_keys(
params["name"],
params["comment"],
params["email"],
params["fingerprints"]
)
result = generate_keypair(gpg_runner, params, matching_keys, check_mode) if params['state'] == 'present' else delete_keypair(gpg_runner, matching_keys, check_mode)
austinlucaslake marked this conversation as resolved.
Show resolved Hide resolved
return result

def main():
key_types = ['RSA', 'DSA', 'ECDH', 'ECDSA', 'EDDSA', 'ELG']
key_curves = ['cv25519', 'nistp256', 'nistp384', 'nistp521', 'brainpoolP256r1', 'brainpoolP384r1', 'brainpoolP512r1', 'secp256k1']
key_usages = ['encrypt', 'sign', 'auth', 'cert']

module = AnsibleModule(
argument_spec=
state=dict(type='str', default='present', choices=['present', 'absent']),
key_type=dict(type='str', default='EDDSA', choices=key_types[:-1]),
key_curve=dict(type='str', choices=key_curves),
key_usage=dict(type='str', choices=key_usages),
subkey_type=dict(type='str', choices=key_types),
subkey_curve=dict(type='str', choices=key_curves),
subkey_usage=dict(type='str', choices=key_usages[:-1]),
name=dict(type='str', default=None),
comment=dict(type='str', default=None),
email=dict(type='str', default=None),
passphrase=dict(type='str', default=None),
fingerprints=dict(type='str', default=None, no_log=True),
keyserver=dict(type='str', default=None)
transient_key=dict(type='bool', default=False),
return_fingerprints=dict(type='bool', default=False)
),
supports_check_mode=True
)

try:
result = run_module(module.params, check_mode)
module.exit_json(**results)
except GPGError as e:
module.fail_json(e)
except Exception as e:
module.fail_json(e)

if __name__ == '__main__':
main()
6 changes: 6 additions & 0 deletions tests/integration/targets/gpg_keypair/aliases
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Copyright (c) Ansible Project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

azp/posix/1
destructive
Loading
Loading