mirror of
https://github.com/ansible-collections/community.crypto.git
synced 2026-05-06 13:22:58 +00:00
Make all module_utils and plugin_utils private (#887)
* Add leading underscore. Remove deprecated module utils. * Document module and plugin utils as private. Add changelog fragment. * Convert relative to absolute imports. * Remove unnecessary imports.
This commit is contained in:
456
plugins/module_utils/_openssh/backends/common.py
Normal file
456
plugins/module_utils/_openssh/backends/common.py
Normal file
@@ -0,0 +1,456 @@
|
||||
# Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com>
|
||||
# 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
|
||||
|
||||
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
|
||||
# Do not use this from other collections or standalone plugins/modules!
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import os
|
||||
import stat
|
||||
import traceback
|
||||
import typing as t
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils._openssh.utils import (
|
||||
parse_openssh_version,
|
||||
)
|
||||
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible_collections.community.crypto.plugins.module_utils._openssh.certificate import (
|
||||
OpensshCertificateTimeParameters,
|
||||
)
|
||||
from cryptography.hazmat.primitives.asymmetric.types import (
|
||||
CertificateIssuerPrivateKeyTypes,
|
||||
PrivateKeyTypes,
|
||||
)
|
||||
|
||||
Param = t.ParamSpec("Param")
|
||||
|
||||
|
||||
def restore_on_failure(
|
||||
f: t.Callable[t.Concatenate[AnsibleModule, str | os.PathLike, Param], None],
|
||||
) -> t.Callable[t.Concatenate[AnsibleModule, str | os.PathLike, Param], None]:
|
||||
def backup_and_restore(
|
||||
module: AnsibleModule, path: str | os.PathLike, *args, **kwargs
|
||||
) -> None:
|
||||
backup_file = module.backup_local(path) if os.path.exists(path) else None
|
||||
|
||||
try:
|
||||
f(module, path, *args, **kwargs)
|
||||
except Exception:
|
||||
if backup_file is not None:
|
||||
module.atomic_move(os.path.abspath(backup_file), os.path.abspath(path))
|
||||
raise
|
||||
else:
|
||||
module.add_cleanup_file(backup_file)
|
||||
|
||||
return backup_and_restore
|
||||
|
||||
|
||||
@restore_on_failure
|
||||
def safe_atomic_move(
|
||||
module: AnsibleModule, path: str | os.PathLike, destination: str | os.PathLike
|
||||
) -> None:
|
||||
module.atomic_move(os.path.abspath(path), os.path.abspath(destination))
|
||||
|
||||
|
||||
def _restore_all_on_failure(
|
||||
f: t.Callable[
|
||||
t.Concatenate[
|
||||
OpensshModule, list[tuple[str | os.PathLike, str | os.PathLike]], Param
|
||||
],
|
||||
None,
|
||||
],
|
||||
) -> t.Callable[
|
||||
t.Concatenate[
|
||||
OpensshModule, list[tuple[str | os.PathLike, str | os.PathLike]], Param
|
||||
],
|
||||
None,
|
||||
]:
|
||||
def backup_and_restore(
|
||||
self: OpensshModule,
|
||||
sources_and_destinations: list[tuple[str | os.PathLike, str | os.PathLike]],
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
backups = [
|
||||
(d, self.module.backup_local(d))
|
||||
for s, d in sources_and_destinations
|
||||
if os.path.exists(d)
|
||||
]
|
||||
|
||||
try:
|
||||
f(self, sources_and_destinations, *args, **kwargs)
|
||||
except Exception:
|
||||
for destination, backup in backups:
|
||||
self.module.atomic_move(
|
||||
os.path.abspath(backup), os.path.abspath(destination)
|
||||
)
|
||||
raise
|
||||
else:
|
||||
for destination, backup in backups:
|
||||
self.module.add_cleanup_file(backup)
|
||||
|
||||
return backup_and_restore
|
||||
|
||||
|
||||
class OpensshModule(metaclass=abc.ABCMeta):
|
||||
def __init__(self, module: AnsibleModule) -> None:
|
||||
self.module = module
|
||||
|
||||
self.changed: bool = False
|
||||
self.check_mode: bool = self.module.check_mode
|
||||
|
||||
def execute(self) -> t.NoReturn:
|
||||
try:
|
||||
self._execute()
|
||||
except Exception as e:
|
||||
self.module.fail_json(
|
||||
msg=f"unexpected error occurred: {e}",
|
||||
exception=traceback.format_exc(),
|
||||
)
|
||||
|
||||
self.module.exit_json(**self.result)
|
||||
|
||||
@abc.abstractmethod
|
||||
def _execute(self) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def result(self) -> dict[str, t.Any]:
|
||||
result = self._result
|
||||
|
||||
result["changed"] = self.changed
|
||||
|
||||
if self.module._diff:
|
||||
result["diff"] = self.diff
|
||||
|
||||
return result
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def _result(self) -> dict[str, t.Any]:
|
||||
pass
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def diff(self) -> dict[str, t.Any]:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def skip_if_check_mode(f: t.Callable[Param, None]) -> t.Callable[Param, None]:
|
||||
def wrapper(self, *args, **kwargs) -> None:
|
||||
if not self.check_mode:
|
||||
f(self, *args, **kwargs)
|
||||
|
||||
return wrapper # type: ignore
|
||||
|
||||
@staticmethod
|
||||
def trigger_change(f: t.Callable[Param, None]) -> t.Callable[Param, None]:
|
||||
def wrapper(self, *args, **kwargs) -> None:
|
||||
f(self, *args, **kwargs)
|
||||
self.changed = True
|
||||
|
||||
return wrapper # type: ignore
|
||||
|
||||
def _check_if_base_dir(self, path: str | os.PathLike) -> None:
|
||||
base_dir = os.path.dirname(path) or "."
|
||||
if not os.path.isdir(base_dir):
|
||||
self.module.fail_json(
|
||||
name=base_dir,
|
||||
msg=f"The directory {base_dir} does not exist or the file is not a directory",
|
||||
)
|
||||
|
||||
def _get_ssh_version(self) -> str | None:
|
||||
ssh_bin = self.module.get_bin_path("ssh")
|
||||
if not ssh_bin:
|
||||
return None
|
||||
return parse_openssh_version(
|
||||
self.module.run_command([ssh_bin, "-V", "-q"], check_rc=True)[2].strip()
|
||||
)
|
||||
|
||||
@_restore_all_on_failure
|
||||
def _safe_secure_move(
|
||||
self,
|
||||
sources_and_destinations: list[tuple[str | os.PathLike, str | os.PathLike]],
|
||||
) -> None:
|
||||
"""Moves a list of files from 'source' to 'destination' and restores 'destination' from backup upon failure.
|
||||
If 'destination' does not already exist, then 'source' permissions are preserved to prevent
|
||||
exposing protected data ('atomic_move' uses the 'destination' base directory mask for
|
||||
permissions if 'destination' does not already exists).
|
||||
"""
|
||||
for source, destination in sources_and_destinations:
|
||||
if os.path.exists(destination):
|
||||
self.module.atomic_move(
|
||||
os.path.abspath(source), os.path.abspath(destination)
|
||||
)
|
||||
else:
|
||||
self.module.preserved_copy(source, destination)
|
||||
|
||||
def _update_permissions(self, path: str | os.PathLike) -> None:
|
||||
file_args = self.module.load_file_common_arguments(self.module.params)
|
||||
file_args["path"] = path
|
||||
|
||||
if not self.module.check_file_absent_if_check_mode(path):
|
||||
self.changed = self.module.set_fs_attributes_if_different(
|
||||
file_args, self.changed
|
||||
)
|
||||
else:
|
||||
self.changed = True
|
||||
|
||||
|
||||
class KeygenCommand:
|
||||
def __init__(self, module: AnsibleModule) -> None:
|
||||
self._bin_path = module.get_bin_path("ssh-keygen", True)
|
||||
self._run_command = module.run_command
|
||||
|
||||
def generate_certificate(
|
||||
self,
|
||||
certificate_path: str,
|
||||
identifier: str,
|
||||
options: list[str] | None,
|
||||
pkcs11_provider: str | None,
|
||||
principals: list[str] | None,
|
||||
serial_number: int | None,
|
||||
signature_algorithm: str | None,
|
||||
signing_key_path: str,
|
||||
type: t.Literal["host", "user"] | None,
|
||||
time_parameters: OpensshCertificateTimeParameters,
|
||||
use_agent: bool,
|
||||
**kwargs,
|
||||
) -> tuple[int, str, str]:
|
||||
args = [self._bin_path, "-s", signing_key_path, "-P", "", "-I", identifier]
|
||||
|
||||
if options:
|
||||
for option in options:
|
||||
args.extend(["-O", option])
|
||||
if pkcs11_provider:
|
||||
args.extend(["-D", pkcs11_provider])
|
||||
if principals:
|
||||
args.extend(["-n", ",".join(principals)])
|
||||
if serial_number is not None:
|
||||
args.extend(["-z", str(serial_number)])
|
||||
if type == "host":
|
||||
args.extend(["-h"])
|
||||
if use_agent:
|
||||
args.extend(["-U"])
|
||||
if time_parameters.validity_string:
|
||||
args.extend(["-V", time_parameters.validity_string])
|
||||
if signature_algorithm:
|
||||
args.extend(["-t", signature_algorithm])
|
||||
args.append(certificate_path)
|
||||
|
||||
return self._run_command(args, **kwargs)
|
||||
|
||||
def generate_keypair(
|
||||
self, private_key_path: str, size: int, type: str, comment: str | None, **kwargs
|
||||
) -> tuple[int, str, str]:
|
||||
args = [
|
||||
self._bin_path,
|
||||
"-q",
|
||||
"-N",
|
||||
"",
|
||||
"-b",
|
||||
str(size),
|
||||
"-t",
|
||||
type,
|
||||
"-f",
|
||||
private_key_path,
|
||||
"-C",
|
||||
comment or "",
|
||||
]
|
||||
|
||||
# "y" must be entered in response to the "overwrite" prompt
|
||||
data = "y" if os.path.exists(private_key_path) else None
|
||||
|
||||
return self._run_command(args, data=data, **kwargs)
|
||||
|
||||
def get_certificate_info(
|
||||
self, certificate_path: str, **kwargs
|
||||
) -> tuple[int, str, str]:
|
||||
return self._run_command(
|
||||
[self._bin_path, "-L", "-f", certificate_path], **kwargs
|
||||
)
|
||||
|
||||
def get_matching_public_key(
|
||||
self, private_key_path: str, **kwargs
|
||||
) -> tuple[int, str, str]:
|
||||
return self._run_command(
|
||||
[self._bin_path, "-P", "", "-y", "-f", private_key_path], **kwargs
|
||||
)
|
||||
|
||||
def get_private_key(self, private_key_path: str, **kwargs) -> tuple[int, str, str]:
|
||||
return self._run_command(
|
||||
[self._bin_path, "-l", "-f", private_key_path], **kwargs
|
||||
)
|
||||
|
||||
def update_comment(
|
||||
self,
|
||||
private_key_path: str,
|
||||
comment: str,
|
||||
force_new_format: bool = True,
|
||||
**kwargs,
|
||||
) -> tuple[int, str, str]:
|
||||
if os.path.exists(private_key_path) and not os.access(
|
||||
private_key_path, os.W_OK
|
||||
):
|
||||
try:
|
||||
os.chmod(private_key_path, stat.S_IWUSR + stat.S_IRUSR)
|
||||
except (IOError, OSError) as e:
|
||||
raise ValueError(
|
||||
f"The private key at {private_key_path} is not writeable preventing a comment update ({e})"
|
||||
)
|
||||
|
||||
command = [self._bin_path, "-q"]
|
||||
if force_new_format:
|
||||
command.append("-o")
|
||||
command.extend(["-c", "-C", comment, "-f", private_key_path])
|
||||
return self._run_command(command, **kwargs)
|
||||
|
||||
|
||||
_PrivateKey = t.TypeVar("_PrivateKey", bound="PrivateKey")
|
||||
|
||||
|
||||
class PrivateKey:
|
||||
def __init__(
|
||||
self, size: int, key_type: str, fingerprint: str, format: str = ""
|
||||
) -> None:
|
||||
self._size = size
|
||||
self._type = key_type
|
||||
self._fingerprint = fingerprint
|
||||
self._format = format
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return self._size
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return self._type
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
return self._fingerprint
|
||||
|
||||
@property
|
||||
def format(self) -> str:
|
||||
return self._format
|
||||
|
||||
@classmethod
|
||||
def from_string(cls: t.Type[_PrivateKey], string: str) -> _PrivateKey:
|
||||
properties = string.split()
|
||||
|
||||
return cls(
|
||||
size=int(properties[0]),
|
||||
key_type=properties[-1][1:-1].lower(),
|
||||
fingerprint=properties[1],
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, t.Any]:
|
||||
return {
|
||||
"size": self._size,
|
||||
"type": self._type,
|
||||
"fingerprint": self._fingerprint,
|
||||
"format": self._format,
|
||||
}
|
||||
|
||||
|
||||
_PublicKey = t.TypeVar("_PublicKey", bound="PublicKey")
|
||||
|
||||
|
||||
class PublicKey:
|
||||
def __init__(self, type_string: str, data: str, comment: str | None) -> None:
|
||||
self._type_string = type_string
|
||||
self._data = data
|
||||
self._comment = comment
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, type(self)):
|
||||
return NotImplemented
|
||||
|
||||
return all(
|
||||
[
|
||||
self._type_string == other._type_string,
|
||||
self._data == other._data,
|
||||
(
|
||||
(self._comment == other._comment)
|
||||
if self._comment is not None and other._comment is not None
|
||||
else True
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def __ne__(self, other: object) -> bool:
|
||||
return not self == other
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self._type_string} {self._data}"
|
||||
|
||||
@property
|
||||
def comment(self) -> str | None:
|
||||
return self._comment
|
||||
|
||||
@comment.setter
|
||||
def comment(self, value: str | None) -> None:
|
||||
self._comment = value
|
||||
|
||||
@property
|
||||
def data(self) -> str:
|
||||
return self._data
|
||||
|
||||
@property
|
||||
def type_string(self) -> str:
|
||||
return self._type_string
|
||||
|
||||
@classmethod
|
||||
def from_string(cls: t.Type[_PublicKey], string: str) -> _PublicKey:
|
||||
properties = string.strip("\n").split(" ", 2)
|
||||
|
||||
return cls(
|
||||
type_string=properties[0],
|
||||
data=properties[1],
|
||||
comment=properties[2] if len(properties) > 2 else "",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load(cls: t.Type[_PublicKey], path: str | os.PathLike) -> _PublicKey | None:
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
properties = f.read().strip(" \n").split(" ", 2)
|
||||
except (IOError, OSError):
|
||||
raise
|
||||
|
||||
if len(properties) < 2:
|
||||
return None
|
||||
|
||||
return cls(
|
||||
type_string=properties[0],
|
||||
data=properties[1],
|
||||
comment="" if len(properties) <= 2 else properties[2],
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, t.Any]:
|
||||
return {
|
||||
"comment": self._comment,
|
||||
"public_key": self._data,
|
||||
}
|
||||
|
||||
|
||||
def parse_private_key_format(
|
||||
path: str | os.PathLike,
|
||||
) -> t.Literal["SSH", "PKCS8", "PKCS1", ""]:
|
||||
with open(path, "r") as file:
|
||||
header = file.readline().strip()
|
||||
|
||||
if header == "-----BEGIN OPENSSH PRIVATE KEY-----":
|
||||
return "SSH"
|
||||
elif header == "-----BEGIN PRIVATE KEY-----":
|
||||
return "PKCS8"
|
||||
elif header == "-----BEGIN RSA PRIVATE KEY-----":
|
||||
return "PKCS1"
|
||||
|
||||
return ""
|
||||
585
plugins/module_utils/_openssh/backends/keypair_backend.py
Normal file
585
plugins/module_utils/_openssh/backends/keypair_backend.py
Normal file
@@ -0,0 +1,585 @@
|
||||
# Copyright (c) 2018, David Kainz <dkainz@mgit.at> <dave.jokain@gmx.at>
|
||||
# Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com>
|
||||
# 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
|
||||
|
||||
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
|
||||
# Do not use this from other collections or standalone plugins/modules!
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import os
|
||||
import typing as t
|
||||
|
||||
from ansible.module_utils.basic import missing_required_lib
|
||||
from ansible.module_utils.common.text.converters import to_bytes, to_text
|
||||
from ansible_collections.community.crypto.plugins.module_utils._cryptography_dep import (
|
||||
COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION,
|
||||
)
|
||||
from ansible_collections.community.crypto.plugins.module_utils._openssh.backends.common import (
|
||||
KeygenCommand,
|
||||
OpensshModule,
|
||||
PrivateKey,
|
||||
PublicKey,
|
||||
parse_private_key_format,
|
||||
)
|
||||
from ansible_collections.community.crypto.plugins.module_utils._openssh.cryptography import (
|
||||
CRYPTOGRAPHY_VERSION,
|
||||
HAS_OPENSSH_SUPPORT,
|
||||
InvalidCommentError,
|
||||
InvalidPassphraseError,
|
||||
InvalidPrivateKeyFileError,
|
||||
OpenSSHError,
|
||||
OpensshKeypair,
|
||||
)
|
||||
from ansible_collections.community.crypto.plugins.module_utils._openssh.utils import (
|
||||
any_in,
|
||||
file_mode,
|
||||
secure_write,
|
||||
)
|
||||
from ansible_collections.community.crypto.plugins.module_utils._version import (
|
||||
LooseVersion,
|
||||
)
|
||||
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from cryptography.hazmat.primitives.asymmetric.types import (
|
||||
CertificateIssuerPrivateKeyTypes,
|
||||
PrivateKeyTypes,
|
||||
)
|
||||
|
||||
|
||||
class KeypairBackend(OpensshModule, metaclass=abc.ABCMeta):
|
||||
|
||||
def __init__(self, module: AnsibleModule) -> None:
|
||||
super(KeypairBackend, self).__init__(module)
|
||||
|
||||
self.comment: str | None = self.module.params["comment"]
|
||||
self.private_key_path: str = self.module.params["path"]
|
||||
self.public_key_path = self.private_key_path + ".pub"
|
||||
self.regenerate: t.Literal[
|
||||
"never", "fail", "partial_idempotence", "full_idempotence", "always"
|
||||
] = (
|
||||
self.module.params["regenerate"]
|
||||
if not self.module.params["force"]
|
||||
else "always"
|
||||
)
|
||||
self.state: t.Literal["present", "absent"] = self.module.params["state"]
|
||||
self.type: t.Literal["rsa", "dsa", "rsa1", "ecdsa", "ed25519"] = (
|
||||
self.module.params["type"]
|
||||
)
|
||||
|
||||
self.size: int = self._get_size(self.module.params["size"])
|
||||
self._validate_path()
|
||||
|
||||
self.original_private_key: PrivateKey | None = None
|
||||
self.original_public_key: PublicKey | None = None
|
||||
self.private_key: PrivateKey | None = None
|
||||
self.public_key: PublicKey | None = None
|
||||
|
||||
def _get_size(self, size: int | None) -> int:
|
||||
if self.type in ("rsa", "rsa1"):
|
||||
result = 4096 if size is None else size
|
||||
if result < 1024:
|
||||
return self.module.fail_json(
|
||||
msg="For RSA keys, the minimum size is 1024 bits and the default is 4096 bits. "
|
||||
+ "Attempting to use bit lengths under 1024 will cause the module to fail."
|
||||
)
|
||||
elif self.type == "dsa":
|
||||
result = 1024 if size is None else size
|
||||
if result != 1024:
|
||||
return self.module.fail_json(
|
||||
msg="DSA keys must be exactly 1024 bits as specified by FIPS 186-2."
|
||||
)
|
||||
elif self.type == "ecdsa":
|
||||
result = 256 if size is None else size
|
||||
if result not in (256, 384, 521):
|
||||
return self.module.fail_json(
|
||||
msg="For ECDSA keys, size determines the key length by selecting from one of "
|
||||
+ "three elliptic curve sizes: 256, 384 or 521 bits. "
|
||||
+ "Attempting to use bit lengths other than these three values for ECDSA keys will "
|
||||
+ "cause this module to fail."
|
||||
)
|
||||
elif self.type == "ed25519":
|
||||
# User input is ignored for `key size` when `key type` is ed25519
|
||||
result = 256
|
||||
else:
|
||||
return self.module.fail_json(
|
||||
msg=f"{self.type} is not a valid value for key type"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _validate_path(self) -> None:
|
||||
self._check_if_base_dir(self.private_key_path)
|
||||
|
||||
if os.path.isdir(self.private_key_path):
|
||||
self.module.fail_json(
|
||||
msg=f"{self.private_key_path} is a directory. Please specify a path to a file."
|
||||
)
|
||||
|
||||
def _execute(self) -> None:
|
||||
self.original_private_key = self._load_private_key()
|
||||
self.original_public_key = self._load_public_key()
|
||||
|
||||
if self.state == "present":
|
||||
self._validate_key_load()
|
||||
|
||||
if self._should_generate():
|
||||
self._generate()
|
||||
elif not self._public_key_valid():
|
||||
self._restore_public_key()
|
||||
|
||||
self.private_key = self._load_private_key()
|
||||
self.public_key = self._load_public_key()
|
||||
|
||||
for path in (self.private_key_path, self.public_key_path):
|
||||
self._update_permissions(path)
|
||||
else:
|
||||
if self._should_remove():
|
||||
self._remove()
|
||||
|
||||
def _load_private_key(self) -> PrivateKey | None:
|
||||
result = None
|
||||
if self._private_key_exists():
|
||||
try:
|
||||
result = self._get_private_key()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def _private_key_exists(self) -> bool:
|
||||
return os.path.exists(self.private_key_path)
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_private_key(self) -> PrivateKey:
|
||||
pass
|
||||
|
||||
def _load_public_key(self) -> PublicKey | None:
|
||||
result = None
|
||||
if self._public_key_exists():
|
||||
try:
|
||||
result = PublicKey.load(self.public_key_path)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
return result
|
||||
|
||||
def _public_key_exists(self) -> bool:
|
||||
return os.path.exists(self.public_key_path)
|
||||
|
||||
def _validate_key_load(self) -> None:
|
||||
if (
|
||||
self._private_key_exists()
|
||||
and self.regenerate in ("never", "fail", "partial_idempotence")
|
||||
and (self.original_private_key is None or not self._private_key_readable())
|
||||
):
|
||||
self.module.fail_json(
|
||||
msg="Unable to read the key. The key is protected with a passphrase or broken. "
|
||||
+ "Will not proceed. To force regeneration, call the module with `generate` "
|
||||
+ "set to `full_idempotence` or `always`, or with `force=true`."
|
||||
)
|
||||
|
||||
@abc.abstractmethod
|
||||
def _private_key_readable(self) -> bool:
|
||||
pass
|
||||
|
||||
def _should_generate(self) -> bool:
|
||||
if self.original_private_key is None:
|
||||
return True
|
||||
elif self.regenerate == "never":
|
||||
return False
|
||||
elif self.regenerate == "fail":
|
||||
if not self._private_key_valid():
|
||||
self.module.fail_json(
|
||||
msg="Key has wrong type and/or size. Will not proceed. "
|
||||
+ "To force regeneration, call the module with `generate` set to "
|
||||
+ "`partial_idempotence`, `full_idempotence` or `always`, or with `force=true`."
|
||||
)
|
||||
return False
|
||||
elif self.regenerate in ("partial_idempotence", "full_idempotence"):
|
||||
return not self._private_key_valid()
|
||||
else:
|
||||
return True
|
||||
|
||||
def _private_key_valid(self) -> bool:
|
||||
if self.original_private_key is None:
|
||||
return False
|
||||
|
||||
return all(
|
||||
[
|
||||
self.size == self.original_private_key.size,
|
||||
self.type == self.original_private_key.type,
|
||||
self._private_key_valid_backend(self.original_private_key),
|
||||
]
|
||||
)
|
||||
|
||||
@abc.abstractmethod
|
||||
def _private_key_valid_backend(self, original_private_key: PrivateKey) -> bool:
|
||||
pass
|
||||
|
||||
@OpensshModule.trigger_change
|
||||
@OpensshModule.skip_if_check_mode
|
||||
def _generate(self) -> None:
|
||||
temp_private_key, temp_public_key = self._generate_temp_keypair()
|
||||
|
||||
try:
|
||||
self._safe_secure_move(
|
||||
[
|
||||
(temp_private_key, self.private_key_path),
|
||||
(temp_public_key, self.public_key_path),
|
||||
]
|
||||
)
|
||||
except OSError as e:
|
||||
self.module.fail_json(msg=str(e))
|
||||
|
||||
def _generate_temp_keypair(self) -> tuple[str, str]:
|
||||
temp_private_key = os.path.join(
|
||||
self.module.tmpdir, os.path.basename(self.private_key_path)
|
||||
)
|
||||
temp_public_key = temp_private_key + ".pub"
|
||||
|
||||
try:
|
||||
self._generate_keypair(temp_private_key)
|
||||
except (IOError, OSError) as e:
|
||||
self.module.fail_json(msg=str(e))
|
||||
|
||||
for f in (temp_private_key, temp_public_key):
|
||||
self.module.add_cleanup_file(f)
|
||||
|
||||
return temp_private_key, temp_public_key
|
||||
|
||||
@abc.abstractmethod
|
||||
def _generate_keypair(self, private_key_path: str) -> None:
|
||||
pass
|
||||
|
||||
def _public_key_valid(self) -> bool:
|
||||
if self.original_public_key is None:
|
||||
return False
|
||||
|
||||
valid_public_key = self._get_public_key()
|
||||
if valid_public_key:
|
||||
valid_public_key.comment = self.comment
|
||||
|
||||
return self.original_public_key == valid_public_key
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_public_key(self) -> PublicKey | t.Literal[""]:
|
||||
pass
|
||||
|
||||
@OpensshModule.trigger_change
|
||||
@OpensshModule.skip_if_check_mode
|
||||
def _restore_public_key(self) -> None:
|
||||
try:
|
||||
temp_public_key = self._create_temp_public_key(
|
||||
str(self._get_public_key()) + "\n"
|
||||
)
|
||||
self._safe_secure_move([(temp_public_key, self.public_key_path)])
|
||||
except (IOError, OSError):
|
||||
self.module.fail_json(
|
||||
msg="The public key is missing or does not match the private key. "
|
||||
+ "Unable to regenerate the public key."
|
||||
)
|
||||
|
||||
if self.comment:
|
||||
self._update_comment()
|
||||
|
||||
def _create_temp_public_key(self, content: str | bytes) -> str:
|
||||
temp_public_key = os.path.join(
|
||||
self.module.tmpdir, os.path.basename(self.public_key_path)
|
||||
)
|
||||
|
||||
default_permissions = 0o644
|
||||
existing_permissions = file_mode(self.public_key_path)
|
||||
|
||||
try:
|
||||
secure_write(
|
||||
temp_public_key,
|
||||
existing_permissions or default_permissions,
|
||||
to_bytes(content),
|
||||
)
|
||||
except (IOError, OSError) as e:
|
||||
self.module.fail_json(msg=str(e))
|
||||
self.module.add_cleanup_file(temp_public_key)
|
||||
|
||||
return temp_public_key
|
||||
|
||||
@abc.abstractmethod
|
||||
def _update_comment(self) -> None:
|
||||
pass
|
||||
|
||||
def _should_remove(self) -> bool:
|
||||
return self._private_key_exists() or self._public_key_exists()
|
||||
|
||||
@OpensshModule.trigger_change
|
||||
@OpensshModule.skip_if_check_mode
|
||||
def _remove(self) -> None:
|
||||
try:
|
||||
if self._private_key_exists():
|
||||
os.remove(self.private_key_path)
|
||||
if self._public_key_exists():
|
||||
os.remove(self.public_key_path)
|
||||
except (IOError, OSError) as e:
|
||||
self.module.fail_json(msg=str(e))
|
||||
|
||||
@property
|
||||
def _result(self) -> dict[str, t.Any]:
|
||||
private_key = self.private_key or self.original_private_key
|
||||
public_key = self.public_key or self.original_public_key
|
||||
|
||||
return {
|
||||
"size": self.size,
|
||||
"type": self.type,
|
||||
"filename": self.private_key_path,
|
||||
"fingerprint": private_key.fingerprint if private_key else "",
|
||||
"public_key": str(public_key) if public_key else "",
|
||||
"comment": public_key.comment if public_key else "",
|
||||
}
|
||||
|
||||
@property
|
||||
def diff(self) -> dict[str, t.Any]:
|
||||
before = (
|
||||
self.original_private_key.to_dict() if self.original_private_key else {}
|
||||
)
|
||||
before.update(
|
||||
self.original_public_key.to_dict() if self.original_public_key else {}
|
||||
)
|
||||
|
||||
after = self.private_key.to_dict() if self.private_key else {}
|
||||
after.update(self.public_key.to_dict() if self.public_key else {})
|
||||
|
||||
return {
|
||||
"before": before,
|
||||
"after": after,
|
||||
}
|
||||
|
||||
|
||||
class KeypairBackendOpensshBin(KeypairBackend):
|
||||
def __init__(self, module: AnsibleModule) -> None:
|
||||
super(KeypairBackendOpensshBin, self).__init__(module)
|
||||
|
||||
if self.module.params["private_key_format"] != "auto":
|
||||
self.module.fail_json(
|
||||
msg="'auto' is the only valid option for 'private_key_format' when 'backend' is not 'cryptography'"
|
||||
)
|
||||
|
||||
self.ssh_keygen = KeygenCommand(self.module)
|
||||
|
||||
def _generate_keypair(self, private_key_path: str) -> None:
|
||||
self.ssh_keygen.generate_keypair(
|
||||
private_key_path, self.size, self.type, self.comment, check_rc=True
|
||||
)
|
||||
|
||||
def _get_private_key(self) -> PrivateKey:
|
||||
rc, private_key_content, err = self.ssh_keygen.get_private_key(
|
||||
self.private_key_path, check_rc=False
|
||||
)
|
||||
if rc != 0:
|
||||
raise ValueError(err)
|
||||
return PrivateKey.from_string(private_key_content)
|
||||
|
||||
def _get_public_key(self) -> PublicKey | t.Literal[""]:
|
||||
public_key_content = self.ssh_keygen.get_matching_public_key(
|
||||
self.private_key_path, check_rc=True
|
||||
)[1]
|
||||
return PublicKey.from_string(public_key_content)
|
||||
|
||||
def _private_key_readable(self) -> bool:
|
||||
rc, stdout, stderr = self.ssh_keygen.get_matching_public_key(
|
||||
self.private_key_path, check_rc=False
|
||||
)
|
||||
return not (
|
||||
rc == 255
|
||||
or any_in(
|
||||
stderr,
|
||||
"is not a public key file",
|
||||
"incorrect passphrase",
|
||||
"load failed",
|
||||
)
|
||||
)
|
||||
|
||||
def _update_comment(self) -> None:
|
||||
try:
|
||||
ssh_version = self._get_ssh_version() or "7.8"
|
||||
force_new_format = (
|
||||
LooseVersion("6.5") <= LooseVersion(ssh_version) < LooseVersion("7.8")
|
||||
)
|
||||
self.ssh_keygen.update_comment(
|
||||
self.private_key_path,
|
||||
self.comment or "",
|
||||
force_new_format=force_new_format,
|
||||
check_rc=True,
|
||||
)
|
||||
except (IOError, OSError) as e:
|
||||
self.module.fail_json(msg=str(e))
|
||||
|
||||
def _private_key_valid_backend(self, original_private_key: PrivateKey) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class KeypairBackendCryptography(KeypairBackend):
|
||||
def __init__(self, module: AnsibleModule) -> None:
|
||||
super(KeypairBackendCryptography, self).__init__(module)
|
||||
|
||||
if self.type == "rsa1":
|
||||
self.module.fail_json(
|
||||
msg="RSA1 keys are not supported by the cryptography backend"
|
||||
)
|
||||
|
||||
self.passphrase = (
|
||||
to_bytes(module.params["passphrase"])
|
||||
if module.params["passphrase"]
|
||||
else None
|
||||
)
|
||||
key_format: t.Literal["auto", "pkcs1", "pkcs8", "ssh"] = module.params[
|
||||
"private_key_format"
|
||||
]
|
||||
self.private_key_format = self._get_key_format(key_format)
|
||||
|
||||
def _get_key_format(
|
||||
self, key_format: t.Literal["auto", "pkcs1", "pkcs8", "ssh"]
|
||||
) -> t.Literal["SSH", "PKCS1", "PKCS8"]:
|
||||
result: t.Literal["SSH", "PKCS1", "PKCS8"] = "SSH"
|
||||
|
||||
if key_format == "auto":
|
||||
# Default to OpenSSH 7.8 compatibility when OpenSSH is not installed
|
||||
ssh_version = self._get_ssh_version() or "7.8"
|
||||
|
||||
if (
|
||||
LooseVersion(ssh_version) < LooseVersion("7.8")
|
||||
and self.type != "ed25519"
|
||||
):
|
||||
# OpenSSH made SSH formatted private keys available in version 6.5,
|
||||
# but still defaulted to PKCS1 format with the exception of ed25519 keys
|
||||
result = "PKCS1"
|
||||
else:
|
||||
result = key_format.upper() # type: ignore
|
||||
|
||||
return result
|
||||
|
||||
def _generate_keypair(self, private_key_path: str) -> None:
|
||||
assert self.type != "rsa1"
|
||||
keypair = OpensshKeypair.generate(
|
||||
keytype=self.type,
|
||||
size=self.size,
|
||||
passphrase=self.passphrase,
|
||||
comment=self.comment or "",
|
||||
)
|
||||
|
||||
encoded_private_key = OpensshKeypair.encode_openssh_privatekey(
|
||||
keypair.asymmetric_keypair, self.private_key_format
|
||||
)
|
||||
secure_write(private_key_path, 0o600, encoded_private_key)
|
||||
|
||||
public_key_path = private_key_path + ".pub"
|
||||
secure_write(public_key_path, 0o644, keypair.public_key)
|
||||
|
||||
def _get_private_key(self) -> PrivateKey:
|
||||
keypair = OpensshKeypair.load(
|
||||
path=self.private_key_path, passphrase=self.passphrase, no_public_key=True
|
||||
)
|
||||
|
||||
return PrivateKey(
|
||||
size=keypair.size,
|
||||
key_type=keypair.key_type,
|
||||
fingerprint=keypair.fingerprint,
|
||||
format=parse_private_key_format(self.private_key_path),
|
||||
)
|
||||
|
||||
def _get_public_key(self) -> PublicKey | t.Literal[""]:
|
||||
try:
|
||||
keypair = OpensshKeypair.load(
|
||||
path=self.private_key_path,
|
||||
passphrase=self.passphrase,
|
||||
no_public_key=True,
|
||||
)
|
||||
except OpenSSHError:
|
||||
# Simulates the null output of ssh-keygen
|
||||
return ""
|
||||
|
||||
return PublicKey.from_string(to_text(keypair.public_key))
|
||||
|
||||
def _private_key_readable(self) -> bool:
|
||||
try:
|
||||
OpensshKeypair.load(
|
||||
path=self.private_key_path,
|
||||
passphrase=self.passphrase,
|
||||
no_public_key=True,
|
||||
)
|
||||
except (InvalidPrivateKeyFileError, InvalidPassphraseError):
|
||||
return False
|
||||
|
||||
# Cryptography >= 3.0 uses a SSH key loader which does not raise an exception when a passphrase is provided
|
||||
# when loading an unencrypted key
|
||||
if self.passphrase:
|
||||
try:
|
||||
OpensshKeypair.load(
|
||||
path=self.private_key_path, passphrase=None, no_public_key=True
|
||||
)
|
||||
except (InvalidPrivateKeyFileError, InvalidPassphraseError):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _update_comment(self) -> None:
|
||||
keypair = OpensshKeypair.load(
|
||||
path=self.private_key_path, passphrase=self.passphrase, no_public_key=True
|
||||
)
|
||||
try:
|
||||
keypair.comment = self.comment
|
||||
except InvalidCommentError as e:
|
||||
self.module.fail_json(msg=str(e))
|
||||
|
||||
try:
|
||||
temp_public_key = self._create_temp_public_key(keypair.public_key + b"\n")
|
||||
self._safe_secure_move([(temp_public_key, self.public_key_path)])
|
||||
except (IOError, OSError) as e:
|
||||
self.module.fail_json(msg=str(e))
|
||||
|
||||
def _private_key_valid_backend(self, original_private_key: PrivateKey) -> bool:
|
||||
# avoids breaking behavior and prevents
|
||||
# automatic conversions with OpenSSH upgrades
|
||||
if self.module.params["private_key_format"] == "auto":
|
||||
return True
|
||||
|
||||
return self.private_key_format == original_private_key.format
|
||||
|
||||
|
||||
def select_backend(
|
||||
module: AnsibleModule, backend: t.Literal["auto", "opensshbin", "cryptography"]
|
||||
) -> KeypairBackend:
|
||||
can_use_cryptography = HAS_OPENSSH_SUPPORT and LooseVersion(
|
||||
CRYPTOGRAPHY_VERSION
|
||||
) >= LooseVersion(COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION)
|
||||
can_use_opensshbin = bool(module.get_bin_path("ssh-keygen"))
|
||||
|
||||
if backend == "auto":
|
||||
if can_use_opensshbin and not module.params["passphrase"]:
|
||||
backend = "opensshbin"
|
||||
elif can_use_cryptography:
|
||||
backend = "cryptography"
|
||||
else:
|
||||
module.fail_json(
|
||||
msg=(
|
||||
"Cannot find either the OpenSSH binary in the PATH "
|
||||
f"or cryptography >= {COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION} installed on this system"
|
||||
)
|
||||
)
|
||||
|
||||
if backend == "opensshbin":
|
||||
if not can_use_opensshbin:
|
||||
module.fail_json(msg="Cannot find the OpenSSH binary in the PATH")
|
||||
return KeypairBackendOpensshBin(module)
|
||||
if backend == "cryptography":
|
||||
if not can_use_cryptography:
|
||||
module.fail_json(
|
||||
msg=missing_required_lib(
|
||||
f"cryptography >= {COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION}"
|
||||
)
|
||||
)
|
||||
return KeypairBackendCryptography(module)
|
||||
raise ValueError(f"Unsupported value for backend: {backend}")
|
||||
Reference in New Issue
Block a user