vaultctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vaultctl/__init__.py ADDED
File without changes
@@ -0,0 +1,15 @@
1
+ import typer
2
+
3
+ from vaultctl.commands.backup_raft_snapshot import app as backup_raft_snapshot
4
+ from vaultctl.commands.bootstrap import app as bootstrap
5
+ from vaultctl.commands.login import app as login
6
+ from vaultctl.commands.pki.rotate_issuing import app as pki
7
+ from vaultctl.commands.restore_raft_snapshot import app as restore_raft_snapshot
8
+
9
+ app = typer.Typer()
10
+
11
+ app.add_typer(bootstrap)
12
+ app.add_typer(restore_raft_snapshot)
13
+ app.add_typer(backup_raft_snapshot)
14
+ app.add_typer(login)
15
+ app.add_typer(pki)
@@ -0,0 +1,236 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import hashlib
5
+ import io
6
+ import tarfile
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import Annotated, cast
10
+
11
+ import hvac
12
+ import typer
13
+ from botocore.exceptions import ClientError
14
+ from requests import Response
15
+
16
+ from vaultctl.options import (
17
+ AddressOption,
18
+ AwsAccessKeyIdOption,
19
+ AwsEndpointUrlOption,
20
+ AwsProfileOption,
21
+ AwsRegionOption,
22
+ AwsSecretAccessKeyOption,
23
+ CACertOption,
24
+ CAPathOption,
25
+ K8sMountPointOption,
26
+ K8sRoleOption,
27
+ OutputFileOption,
28
+ S3BucketNameOption,
29
+ S3KeyPrefixOption,
30
+ SkipVerifyOption,
31
+ TimeoutOption,
32
+ TokenOption,
33
+ )
34
+ from vaultctl.utils import handle_s3_authentication, handle_vault_authentication, logger
35
+
36
+ app = typer.Typer()
37
+
38
+
39
+ class RaftUploadError(Exception):
40
+ """Raised when uploading a Raft snapshot to S3 fails."""
41
+
42
+
43
+ class S3ChecksumAlgorithm(Enum):
44
+ CRC32 = "CRC32"
45
+ CRC32C = "CRC32C"
46
+ SHA1 = "SHA1"
47
+ SHA256 = "SHA256"
48
+ CRC64NVME = "CRC64NVME"
49
+
50
+
51
+ def parse_sha256sums(content: bytes) -> dict[str, str]:
52
+ sums = {}
53
+ lines = content.strip().split(b"\n")
54
+ for line in lines:
55
+ trimmed_line = line.strip()
56
+ if not trimmed_line:
57
+ continue
58
+ parts = trimmed_line.split()
59
+ if len(parts) == 2: # noqa: PLR2004
60
+ checksum = parts[0].decode("utf-8")
61
+ filename = parts[1].decode("utf-8")
62
+ sums[filename] = checksum
63
+ return sums
64
+
65
+
66
+ def verify_internal_checksums(snapshot_data: bytes) -> None:
67
+ logger.info("Verifying snapshot integrity...")
68
+ snapshot_stream = io.BytesIO(snapshot_data)
69
+
70
+ try:
71
+ with tarfile.open(fileobj=snapshot_stream, mode="r:gz") as tar:
72
+ sha_sums_content = None
73
+ files_in_tar: dict[str, bytes] = {}
74
+
75
+ for member in tar.getmembers():
76
+ if not member.isfile():
77
+ continue
78
+
79
+ if not (f := tar.extractfile(member)):
80
+ continue
81
+
82
+ content = f.read()
83
+
84
+ if member.name == "SHA256SUMS":
85
+ sha_sums_content = content
86
+
87
+ files_in_tar[member.name] = content
88
+
89
+ if sha_sums_content is None:
90
+ msg = "SHA256SUMS file not found in the Raft snapshot archive."
91
+ raise ValueError(
92
+ msg,
93
+ )
94
+
95
+ expected_sums = parse_sha256sums(sha_sums_content)
96
+
97
+ for name, expected_sum in expected_sums.items():
98
+ content = files_in_tar.get(name)
99
+
100
+ if content is None:
101
+ msg = f"file '{name}' listed in SHA256SUMS not found in archive."
102
+ raise ValueError(
103
+ msg,
104
+ )
105
+
106
+ computed_sum = hashlib.sha256(content).hexdigest()
107
+
108
+ if computed_sum != expected_sum:
109
+ msg = f"checksum mismatch for file '{name}'. Expected: {expected_sum}, Got: {computed_sum}"
110
+ raise ValueError(
111
+ msg,
112
+ )
113
+
114
+ logger.info("Snapshot integrity verified.")
115
+
116
+ except tarfile.TarError as e:
117
+ msg = f"reading archive: {e}"
118
+ raise tarfile.TarError(msg) from e
119
+
120
+
121
+ @app.command(
122
+ help="Executes a complete workflow for obtaining a HashiCorp Vault Raft snapshot from a cluster, verifying its integrity, and uploading it securely to S3 storage. Provides flexible authentication options for both HashiCorp Vault and S3 APIs.",
123
+ )
124
+ def backup_raft_snapshot( # noqa: PLR0913
125
+ address: AddressOption,
126
+ s3_bucket_name: S3BucketNameOption = None,
127
+ output_file: OutputFileOption = None,
128
+ k8s_role: K8sRoleOption = None,
129
+ k8s_mount_point: K8sMountPointOption = "kubernetes",
130
+ token: TokenOption = None,
131
+ ca_cert: CACertOption = None,
132
+ ca_path: CAPathOption = None,
133
+ aws_profile: AwsProfileOption = None,
134
+ aws_access_key_id: AwsAccessKeyIdOption = None,
135
+ aws_secret_access_key: AwsSecretAccessKeyOption = None,
136
+ aws_endpoint_url: AwsEndpointUrlOption = None,
137
+ aws_region: AwsRegionOption = "us-east-1",
138
+ s3_key_prefix: S3KeyPrefixOption = "",
139
+ s3_checksum_algorithm: Annotated[
140
+ S3ChecksumAlgorithm,
141
+ typer.Option(help="The algorithm to use for s3 transport checksum."),
142
+ ] = S3ChecksumAlgorithm.CRC64NVME,
143
+ timeout: TimeoutOption = 30,
144
+ *,
145
+ skip_verify: SkipVerifyOption = False,
146
+ ) -> None:
147
+ if not s3_bucket_name and not output_file:
148
+ msg = "Either --s3-bucket-name or --output-file is required"
149
+ raise typer.BadParameter(msg)
150
+
151
+ vault_client = handle_vault_authentication(
152
+ hvac.Client(
153
+ url=address,
154
+ timeout=timeout,
155
+ verify=(
156
+ str(ca_cert)
157
+ if ca_cert
158
+ else str(ca_path)
159
+ if ca_path
160
+ else (not skip_verify)
161
+ ),
162
+ ),
163
+ token=token,
164
+ k8s_role=k8s_role,
165
+ k8s_mount_point=k8s_mount_point,
166
+ )
167
+
168
+ if vault_client.sys.is_sealed():
169
+ logger.error("Vault is sealed. Cannot proceed with backup.")
170
+ raise typer.Exit(code=1)
171
+
172
+ logger.info("Starting backup...")
173
+
174
+ logger.info("Requesting raft snapshot...")
175
+ response: Response = vault_client.sys.take_raft_snapshot()
176
+
177
+ if response.status_code != 200: # noqa: PLR2004
178
+ logger.error(
179
+ "Raft snapshot request failed with status code %s.",
180
+ response.status_code,
181
+ )
182
+ logger.debug("Response body: %s", response.text)
183
+ raise typer.Exit(1)
184
+
185
+ snapshot_data: bytes = response.content
186
+
187
+ logger.info("Raft snapshot retrieved.")
188
+
189
+ try:
190
+ verify_internal_checksums(snapshot_data)
191
+ except (tarfile.TarError, ValueError):
192
+ logger.exception("Verifying raft snapshot integrity")
193
+ raise typer.Exit(1) from None
194
+
195
+ if output_file:
196
+ output_path = Path(output_file)
197
+ if output_path.is_dir():
198
+ timestamp = datetime.datetime.now(tz=datetime.UTC).strftime("%Y%m%d_%H%M%S")
199
+ output_path = output_path / f"vault-snapshot-{timestamp}.snap"
200
+ output_path.write_bytes(snapshot_data)
201
+ logger.info("Snapshot written to %s", output_path)
202
+ logger.info("Backup completed")
203
+ return
204
+
205
+ s3_bucket_name = cast("str", s3_bucket_name)
206
+
207
+ s3_client = handle_s3_authentication(
208
+ bucket_name=s3_bucket_name,
209
+ aws_access_key_id=aws_access_key_id,
210
+ aws_secret_access_key=aws_secret_access_key,
211
+ aws_region=aws_region,
212
+ aws_profile=aws_profile,
213
+ endpoint_url=aws_endpoint_url,
214
+ )
215
+
216
+ timestamp = datetime.datetime.now(tz=datetime.UTC).strftime("%Y%m%d_%H%M%S")
217
+ snapshot_name = f"vault-snapshot-{timestamp}.snap"
218
+ s3_key = f"{s3_key_prefix}{snapshot_name}"
219
+
220
+ try:
221
+ logger.info("Uploading raft snapshot to %s/%s...", s3_bucket_name, s3_key)
222
+ s3_client.upload_fileobj(
223
+ io.BytesIO(snapshot_data),
224
+ s3_bucket_name,
225
+ s3_key,
226
+ ExtraArgs={
227
+ "ContentType": "application/gzip",
228
+ "ChecksumAlgorithm": s3_checksum_algorithm,
229
+ },
230
+ )
231
+ except ClientError:
232
+ logger.exception("Uploading raft snapshot")
233
+ raise typer.Exit(1) from None
234
+
235
+ logger.info("Raft snapshot uploaded.")
236
+ logger.info("Backup completed")
@@ -0,0 +1,125 @@
1
+ import time
2
+ from typing import Any
3
+
4
+ import hvac
5
+ import typer
6
+
7
+ from vaultctl.commands.restore_raft_snapshot import restore_raft_snapshot
8
+ from vaultctl.options import (
9
+ AddressOption,
10
+ AwsAccessKeyIdOption,
11
+ AwsEndpointUrlOption,
12
+ AwsProfileOption,
13
+ AwsRegionOption,
14
+ AwsSecretAccessKeyOption,
15
+ CACertOption,
16
+ CAPathOption,
17
+ S3BucketNameOption,
18
+ S3KeyPrefixOption,
19
+ SkipVerifyOption,
20
+ SnapshotFileOption,
21
+ SnapshotNameOption,
22
+ SnapshotNameRegexOption,
23
+ TimeoutOption,
24
+ )
25
+
26
+ app = typer.Typer()
27
+
28
+ AUTO_UNSEAL_MAX_ATTEMPT = 10
29
+
30
+
31
+ @app.command(
32
+ help="Init, unseal and force restore a hashicorp vault cluster from S3 storage using raft snapshots",
33
+ )
34
+ def bootstrap( # noqa: PLR0913
35
+ ctx: typer.Context,
36
+ address: AddressOption,
37
+ s3_bucket_name: S3BucketNameOption = None,
38
+ snapshot_file: SnapshotFileOption = None,
39
+ *,
40
+ ca_cert: CACertOption = None,
41
+ ca_path: CAPathOption = None,
42
+ skip_verify: SkipVerifyOption = False,
43
+ s3_key_prefix: S3KeyPrefixOption = "",
44
+ filename: SnapshotNameOption = None,
45
+ filename_regex: SnapshotNameRegexOption = None,
46
+ aws_profile: AwsProfileOption = None,
47
+ aws_access_key_id: AwsAccessKeyIdOption = None,
48
+ aws_secret_access_key: AwsSecretAccessKeyOption = None,
49
+ aws_endpoint_url: AwsEndpointUrlOption = None,
50
+ aws_region: AwsRegionOption = "us-east-1",
51
+ timeout: TimeoutOption = 30,
52
+ ) -> None:
53
+ if not s3_bucket_name and not snapshot_file:
54
+ msg = "Either --s3-bucket-name or --snapshot-file is required"
55
+ raise typer.BadParameter(msg)
56
+ client = hvac.Client(
57
+ url=address,
58
+ timeout=timeout,
59
+ verify=(
60
+ str(ca_cert) if ca_cert else str(ca_path) if ca_path else (not skip_verify)
61
+ ),
62
+ )
63
+
64
+ if not client.sys.is_initialized():
65
+ typer.echo("Vault is not initialized. Starting bootstrap procedure...")
66
+ seal_status: dict[str, Any] = client.sys.read_seal_status()
67
+ seal_type = seal_status["type"]
68
+
69
+ is_kms = seal_type != "shamir"
70
+
71
+ if is_kms:
72
+ typer.echo(
73
+ f"Detected Auto-Unseal ({seal_type}). Initializing with recovery keys...",
74
+ )
75
+ result = client.sys.initialize(recovery_shares=5, recovery_threshold=3)
76
+ typer.echo("Successfully initialized with Auto-Unseal.")
77
+ else:
78
+ typer.echo("Detected Shamir seal. Initializing with secret shares...")
79
+ result = client.sys.initialize(secret_shares=5, secret_threshold=3)
80
+ typer.echo("Successfully initialized with Shamir seal.")
81
+
82
+ root_token = result["root_token"]
83
+ client.token = root_token
84
+
85
+ if not is_kms:
86
+ typer.echo("Unsealing with Shamir keys...")
87
+ keys = result["keys"]
88
+ client.sys.submit_unseal_keys(keys)
89
+ else:
90
+ typer.echo("Waiting for Auto-Unseal to complete...")
91
+ attempts = 0
92
+ while client.sys.is_sealed() and attempts < AUTO_UNSEAL_MAX_ATTEMPT:
93
+ time.sleep(1)
94
+ attempts += 1
95
+
96
+ if client.sys.is_sealed():
97
+ typer.echo(
98
+ "Error: Vault is still sealed after Auto-Unseal init. Check Vault logs.",
99
+ )
100
+ raise typer.Exit(code=1)
101
+
102
+ typer.echo("Vault is unsealed and ready. Starting restore...")
103
+
104
+ restore_raft_snapshot(
105
+ ctx=ctx,
106
+ address=address,
107
+ ca_cert=ca_cert,
108
+ ca_path=ca_path,
109
+ skip_verify=skip_verify,
110
+ s3_bucket_name=s3_bucket_name,
111
+ snapshot_file=snapshot_file,
112
+ s3_key_prefix=s3_key_prefix,
113
+ filename=filename,
114
+ filename_regex=filename_regex,
115
+ aws_profile=aws_profile,
116
+ aws_access_key_id=aws_access_key_id,
117
+ aws_secret_access_key=aws_secret_access_key,
118
+ aws_endpoint_url=aws_endpoint_url,
119
+ aws_region=aws_region,
120
+ force_restore=True,
121
+ timeout=timeout,
122
+ token=root_token,
123
+ )
124
+ else:
125
+ typer.echo("Vault already initialized. Skipping bootstrap procedure.")
@@ -0,0 +1,122 @@
1
+ from pathlib import Path
2
+ from typing import Annotated, Literal
3
+
4
+ import hvac
5
+ import typer
6
+ from hvac.exceptions import InvalidRequest, VaultError
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from vaultctl.options import (
11
+ AddressOption,
12
+ CACertOption,
13
+ CAPathOption,
14
+ SkipVerifyOption,
15
+ )
16
+
17
+
18
+ def format_lease_duration(seconds: int) -> str:
19
+ hours, remainder = divmod(seconds, 3600)
20
+ minutes, secs = divmod(remainder, 60)
21
+ return f"{hours}h{minutes}m{secs}s"
22
+
23
+
24
+ TOKEN_FILE = Path.home() / ".vault-token"
25
+
26
+ app = typer.Typer()
27
+
28
+
29
+ @app.command(help="Authenticate with Vault and optionally save the token.")
30
+ def login( # noqa: C901, PLR0913
31
+ ctx: typer.Context, # noqa: ARG001
32
+ address: AddressOption,
33
+ *,
34
+ ca_cert: CACertOption = None,
35
+ ca_path: CAPathOption = None,
36
+ skip_verify: SkipVerifyOption = False,
37
+ method: Annotated[
38
+ Literal["token", "userpass"],
39
+ typer.Option(help="Auth method: token (default) or userpass"),
40
+ ] = "token",
41
+ no_store: Annotated[
42
+ bool,
43
+ typer.Option(help="Do not persist the token to disk", is_flag=True),
44
+ ] = False,
45
+ params: Annotated[
46
+ list[str] | None,
47
+ typer.Argument(
48
+ help="Auth parameters as key=value, like username=alice password=foo",
49
+ ),
50
+ ] = None,
51
+ ) -> None:
52
+ kv = {}
53
+ if params:
54
+ for p in params:
55
+ if "=" not in p:
56
+ msg = f"Invalid argument '{p}', expected key=value"
57
+ raise typer.BadParameter(msg)
58
+ k, v = p.split("=", 1)
59
+ kv[k] = v
60
+
61
+ client = hvac.Client(
62
+ url=address,
63
+ verify=(
64
+ str(ca_cert) if ca_cert else str(ca_path) if ca_path else (not skip_verify)
65
+ ),
66
+ )
67
+
68
+ username = kv.get("username")
69
+ password = kv.get("password")
70
+ token = kv.get("token")
71
+
72
+ if method == "userpass" and not username:
73
+ msg = "'username' must be specified"
74
+ raise typer.BadParameter(msg)
75
+
76
+ try:
77
+ if method == "token":
78
+ if not token:
79
+ token = typer.prompt(
80
+ "Token (will be hidden)",
81
+ hide_input=True,
82
+ type=str,
83
+ )
84
+ client.token = token
85
+ lookup = client.auth.token.lookup_self()
86
+ token_info = lookup["data"]
87
+
88
+ elif method == "userpass":
89
+ if not password:
90
+ password = typer.prompt(
91
+ "Password (will be hidden)",
92
+ hide_input=True,
93
+ type=str,
94
+ )
95
+ auth_resp = client.auth.userpass.login(username=username, password=password)
96
+ token_info = auth_resp["auth"]
97
+ client.token = token_info.get("client_token")
98
+
99
+ except (VaultError, InvalidRequest) as e:
100
+ typer.secho(f"Vault Error: {e}", fg="red", err=True)
101
+ raise typer.Exit(code=1) from None
102
+
103
+ token = token_info.get("client_token") or token_info.get("id")
104
+ accessor = token_info.get("accessor", "n/a")
105
+ ttl = token_info.get("lease_duration") or token_info.get("ttl", 0)
106
+ renewable = token_info.get("renewable", False)
107
+ metadata = token_info.get("metadata") or token_info.get("meta") or {}
108
+
109
+ if not no_store and client.token:
110
+ TOKEN_FILE.write_text(client.token)
111
+
112
+ console = Console()
113
+ table = Table("Key", "Value")
114
+ table.add_row("token", token)
115
+ table.add_row("token_accessor", accessor)
116
+ table.add_row("token_duration", format_lease_duration(ttl))
117
+ table.add_row("token_renewable", str(renewable))
118
+ table.add_row("token_policies", str(token_info.get("token_policies", [])))
119
+ table.add_row("identity_policies", str(token_info.get("identity_policies", [])))
120
+ table.add_row("policies", str(token_info.get("policies", [])))
121
+ table.add_row("token_meta_username", metadata.get("username", "n/a"))
122
+ console.print(table)
File without changes
@@ -0,0 +1,155 @@
1
+ from typing import TYPE_CHECKING, Annotated
2
+
3
+ import hvac
4
+ import typer
5
+ from cryptography import x509
6
+ from cryptography.hazmat.backends import default_backend
7
+ from hvac.exceptions import InvalidPath, InvalidRequest, VaultError
8
+ from requests import Response
9
+
10
+ from vaultctl.options import (
11
+ AddressOption,
12
+ CACertOption,
13
+ CAPathOption,
14
+ SkipVerifyOption,
15
+ TokenOption,
16
+ )
17
+ from vaultctl.utils import handle_vault_authentication
18
+
19
+ if TYPE_CHECKING:
20
+ from typing import Any
21
+
22
+ app = typer.Typer()
23
+
24
+
25
+ def to_dict(resp: dict[str, Any] | Response | None) -> dict[str, Any]:
26
+ if isinstance(resp, Response):
27
+ return resp.json()
28
+ if resp is None:
29
+ return {}
30
+ return resp
31
+
32
+
33
+ @app.command()
34
+ def rotate_issuing( # noqa: PLR0913
35
+ ctx: typer.Context, # noqa: ARG001
36
+ address: AddressOption,
37
+ token: TokenOption = None,
38
+ ca_cert: CACertOption = None,
39
+ ca_path: CAPathOption = None,
40
+ *,
41
+ skip_verify: SkipVerifyOption = False,
42
+ iss_mount: Annotated[
43
+ str,
44
+ typer.Option(help="Vault PKI mount path for the target issuer."),
45
+ ],
46
+ int_mount: Annotated[
47
+ str,
48
+ typer.Option(
49
+ help="Vault PKI mount path for the intermediate CA that signs the CSR.",
50
+ ),
51
+ ],
52
+ common_name: Annotated[
53
+ str,
54
+ typer.Option(help="Common name for the new certificate."),
55
+ ],
56
+ ttl: Annotated[
57
+ str,
58
+ typer.Option(help="Time-to-live for the new certificate."),
59
+ ] = "8760h",
60
+ country: Annotated[
61
+ str,
62
+ typer.Option(help="Country name for the certificate subject."),
63
+ ] = "Bulgaria",
64
+ locality: Annotated[
65
+ str,
66
+ typer.Option(help="Locality name for the certificate subject."),
67
+ ] = "Sofia",
68
+ organization: Annotated[
69
+ str,
70
+ typer.Option(help="Organization name for the certificate subject."),
71
+ ] = "DarkfellaNET",
72
+ ) -> None:
73
+ vault_client = handle_vault_authentication(
74
+ hvac.Client(
75
+ url=address,
76
+ verify=(
77
+ str(ca_cert)
78
+ if ca_cert
79
+ else str(ca_path)
80
+ if ca_path
81
+ else (not skip_verify)
82
+ ),
83
+ ),
84
+ token=token,
85
+ )
86
+
87
+ if vault_client.sys.is_sealed():
88
+ typer.secho("Vault is sealed. Cannot proceed..", fg=typer.colors.RED, bold=True)
89
+ raise typer.Exit(code=1)
90
+
91
+ try:
92
+ typer.echo("Generating CSR using existing key material...")
93
+ generate_resp = to_dict(
94
+ vault_client.write(
95
+ f"{iss_mount}/issuers/generate/intermediate/existing",
96
+ common_name=common_name,
97
+ country=country,
98
+ locality=locality,
99
+ organization=organization,
100
+ format="pem_bundle",
101
+ wrap_ttl=None,
102
+ ),
103
+ )
104
+ csr = generate_resp["data"]["csr"]
105
+ except (VaultError, InvalidRequest) as e:
106
+ typer.echo(f"Failed to generate CSR: {e}")
107
+ raise
108
+
109
+ try:
110
+ typer.echo("Signing CSR with intermediate CA...")
111
+ sign_resp = to_dict(
112
+ vault_client.write(
113
+ f"{int_mount}/root/sign-intermediate",
114
+ csr=csr,
115
+ ttl=ttl,
116
+ wrap_ttl=None,
117
+ ),
118
+ )
119
+ signed_cert = sign_resp["data"]["certificate"]
120
+ except (VaultError, InvalidRequest) as e:
121
+ typer.echo(f"Failed to sign CSR: {e}")
122
+ raise
123
+
124
+ try:
125
+ typer.echo(f"Importing signed certificate back into {iss_mount}...")
126
+ import_resp = to_dict(
127
+ vault_client.write(
128
+ f"{iss_mount}/intermediate/set-signed",
129
+ certificate=signed_cert,
130
+ wrap_ttl=None,
131
+ ),
132
+ )
133
+ imported_issuers = import_resp.get("data", {}).get("imported_issuers", [])
134
+ if not imported_issuers:
135
+ msg = "Vault did not return an imported issuer ID!"
136
+ raise RuntimeError(msg)
137
+ new_issuer_id = imported_issuers[0]
138
+
139
+ vault_client.write(
140
+ f"{iss_mount}/config/issuers",
141
+ default=new_issuer_id,
142
+ wrap_ttl=None,
143
+ )
144
+ typer.echo(f"New issuer {new_issuer_id} set as default")
145
+ except (VaultError, InvalidRequest, InvalidPath) as e:
146
+ typer.echo(f"Failed to import signed certificate: {e}")
147
+ raise
148
+
149
+ cert = x509.load_pem_x509_certificate(signed_cert.encode(), default_backend())
150
+ typer.echo("\nNew Issuing CA info:")
151
+ typer.echo(f" Subject: {cert.subject.rfc4514_string()}")
152
+ typer.echo(f" Serial: {cert.serial_number}")
153
+ typer.echo(f" Expires: {cert.not_valid_after.isoformat()} UTC")
154
+
155
+ typer.echo("Done! Issuing CA successfully reissued and set as default.")
@@ -0,0 +1,210 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+ from typing import TYPE_CHECKING, cast
4
+
5
+ import botocore.exceptions
6
+ import hvac
7
+ import typer
8
+ from dateutil.parser import parse as parse_datetime
9
+ from hvac.api.system_backend import Raft
10
+ from hvac.exceptions import InvalidRequest, VaultError
11
+
12
+ from vaultctl.options import (
13
+ AddressOption,
14
+ AwsAccessKeyIdOption,
15
+ AwsEndpointUrlOption,
16
+ AwsProfileOption,
17
+ AwsRegionOption,
18
+ AwsSecretAccessKeyOption,
19
+ CACertOption,
20
+ CAPathOption,
21
+ K8sMountPointOption,
22
+ K8sRoleOption,
23
+ S3BucketNameOption,
24
+ S3KeyPrefixOption,
25
+ SkipVerifyOption,
26
+ SnapshotFileOption,
27
+ SnapshotForceRestoreOption,
28
+ SnapshotNameOption,
29
+ SnapshotNameRegexOption,
30
+ TimeoutOption,
31
+ TokenOption,
32
+ )
33
+ from vaultctl.utils import handle_s3_authentication, handle_vault_authentication, logger
34
+
35
+ if TYPE_CHECKING:
36
+ import re
37
+ from collections.abc import Mapping, Sequence
38
+
39
+ app = typer.Typer()
40
+
41
+
42
+ def select_snapshot(
43
+ contents: Sequence[Mapping[str, object]],
44
+ filename_regex: re.Pattern | None,
45
+ ) -> str:
46
+ if filename_regex:
47
+ valid_objects = []
48
+
49
+ for o in contents:
50
+ key = o.get("Key")
51
+ if not isinstance(key, str):
52
+ continue
53
+
54
+ match = filename_regex.match(key)
55
+ if not match:
56
+ continue
57
+
58
+ ts_str = match.group(1)
59
+ try:
60
+ ts = parse_datetime(ts_str)
61
+ valid_objects.append({"Key": key, "Timestamp": ts})
62
+ except ValueError:
63
+ continue
64
+
65
+ if not valid_objects:
66
+ msg = "No valid snapshots found matching the filename regex with parseable timestamp"
67
+ raise ValueError(
68
+ msg,
69
+ )
70
+
71
+ latest_obj = max(valid_objects, key=lambda o: cast("datetime", o["Timestamp"]))
72
+ return cast("str", latest_obj["Key"])
73
+
74
+ valid_objects = [
75
+ o
76
+ for o in contents
77
+ if isinstance(o.get("Key"), str) and isinstance(o.get("LastModified"), datetime)
78
+ ]
79
+ if not valid_objects:
80
+ msg = "No valid snapshots with LastModified found"
81
+ raise RuntimeError(msg)
82
+
83
+ latest_obj = max(valid_objects, key=lambda o: cast("datetime", o["LastModified"]))
84
+ return cast("str", latest_obj["Key"])
85
+
86
+
87
+ @app.command(help="Restore a HashiCorp Vault cluster from an S3 Raft snapshot.")
88
+ def restore_raft_snapshot( # noqa: C901, PLR0912, PLR0913, PLR0915
89
+ address: AddressOption,
90
+ s3_bucket_name: S3BucketNameOption = None,
91
+ snapshot_file: SnapshotFileOption = None,
92
+ k8s_role: K8sRoleOption = None,
93
+ k8s_mount_point: K8sMountPointOption = "kubernetes",
94
+ filename: SnapshotNameOption = None,
95
+ filename_regex: SnapshotNameRegexOption = None,
96
+ aws_profile: AwsProfileOption = None,
97
+ aws_access_key_id: AwsAccessKeyIdOption = None,
98
+ aws_secret_access_key: AwsSecretAccessKeyOption = None,
99
+ aws_endpoint_url: AwsEndpointUrlOption = None,
100
+ aws_region: AwsRegionOption = "us-east-1",
101
+ s3_key_prefix: S3KeyPrefixOption = "",
102
+ token: TokenOption = None,
103
+ ca_cert: CACertOption = None,
104
+ ca_path: CAPathOption = None,
105
+ timeout: TimeoutOption = 30,
106
+ *,
107
+ force_restore: SnapshotForceRestoreOption = False,
108
+ skip_verify: SkipVerifyOption = False,
109
+ ) -> None:
110
+ if not s3_bucket_name and not snapshot_file:
111
+ msg = "Either --s3-bucket-name or --snapshot-file is required"
112
+ raise typer.BadParameter(msg)
113
+
114
+ vault_client = handle_vault_authentication(
115
+ hvac.Client(
116
+ url=address,
117
+ timeout=timeout,
118
+ verify=(
119
+ str(ca_cert)
120
+ if ca_cert
121
+ else str(ca_path)
122
+ if ca_path
123
+ else (not skip_verify)
124
+ ),
125
+ ),
126
+ token=token,
127
+ k8s_role=k8s_role,
128
+ k8s_mount_point=k8s_mount_point,
129
+ )
130
+
131
+ if vault_client.sys.is_sealed():
132
+ logger.error("Vault is sealed. Cannot proceed.")
133
+ raise typer.Exit(code=1)
134
+
135
+ if snapshot_file:
136
+ logger.info("Reading snapshot from %s...", snapshot_file)
137
+ snapshot_data = Path(snapshot_file).read_bytes()
138
+ else:
139
+ s3_bucket_name = cast("str", s3_bucket_name)
140
+ s3_client = handle_s3_authentication(
141
+ bucket_name=s3_bucket_name,
142
+ aws_access_key_id=aws_access_key_id,
143
+ aws_secret_access_key=aws_secret_access_key,
144
+ aws_region=aws_region,
145
+ aws_profile=aws_profile,
146
+ endpoint_url=aws_endpoint_url,
147
+ )
148
+
149
+ if filename:
150
+ s3_key = f"{s3_key_prefix}{filename}"
151
+ logger.info("Using snapshot: %s", s3_key)
152
+ else:
153
+ logger.info("Selecting latest snapshot from S3...")
154
+
155
+ try:
156
+ list_response = s3_client.list_objects_v2(
157
+ Bucket=s3_bucket_name,
158
+ Prefix=s3_key_prefix,
159
+ )
160
+ except botocore.exceptions.ClientError:
161
+ logger.exception("Listing S3 bucket contents")
162
+ raise typer.Exit(1) from None
163
+
164
+ if list_response.get("KeyCount", 0) == 0:
165
+ logger.error(
166
+ "No snapshots found in bucket '%s' with prefix '%s'.",
167
+ s3_bucket_name,
168
+ s3_key_prefix,
169
+ )
170
+ raise typer.Exit(1)
171
+
172
+ contents = list_response.get("Contents", [])
173
+ try:
174
+ s3_key = select_snapshot(contents, filename_regex)
175
+ except (ValueError, RuntimeError):
176
+ logger.exception("Selecting snapshot")
177
+ raise typer.Exit(1) from None
178
+
179
+ logger.info("Selected snapshot: %s", s3_key)
180
+
181
+ logger.info("Downloading snapshot from S3: %s...", s3_key)
182
+
183
+ try:
184
+ download_response = s3_client.get_object(Bucket=s3_bucket_name, Key=s3_key)
185
+ snapshot_data = download_response["Body"].read()
186
+ except botocore.exceptions.ClientError:
187
+ logger.exception("Downloading snapshot")
188
+ raise typer.Exit(1) from None
189
+
190
+ logger.info("Snapshot downloaded.")
191
+
192
+ raft = Raft(adapter=vault_client.adapter)
193
+
194
+ if force_restore:
195
+ logger.warning("Restoring snapshot with force...")
196
+ try:
197
+ raft.force_restore_raft_snapshot(snapshot_data)
198
+ except (VaultError, InvalidRequest):
199
+ logger.exception("Restoring snapshot")
200
+ raise typer.Exit(1) from None
201
+ else:
202
+ logger.info("Restoring snapshot...")
203
+ try:
204
+ raft.restore_raft_snapshot(snapshot_data)
205
+ except (VaultError, InvalidRequest):
206
+ logger.exception("Restoring snapshot")
207
+ raise typer.Exit(1) from None
208
+
209
+ logger.info("Snapshot restored.")
210
+ logger.info("Restore completed")
vaultctl/main.py ADDED
@@ -0,0 +1,21 @@
1
+ import typer
2
+
3
+ from vaultctl.commands.backup_raft_snapshot import app as backup_raft_snapshot
4
+ from vaultctl.commands.bootstrap import app as bootstrap
5
+ from vaultctl.commands.login import app as login
6
+ from vaultctl.commands.pki.rotate_issuing import app as pki
7
+ from vaultctl.commands.restore_raft_snapshot import app as restore_raft_snapshot
8
+ from vaultctl.version import app as version
9
+
10
+ app = typer.Typer(no_args_is_help=True)
11
+
12
+ app.add_typer(version)
13
+ app.add_typer(bootstrap)
14
+ app.add_typer(restore_raft_snapshot)
15
+ app.add_typer(backup_raft_snapshot)
16
+ app.add_typer(login)
17
+ app.add_typer(pki)
18
+
19
+
20
+ if __name__ == "__main__":
21
+ app()
vaultctl/options.py ADDED
@@ -0,0 +1,200 @@
1
+ import re
2
+ from pathlib import Path
3
+ from typing import Annotated
4
+
5
+ from typer import Option
6
+
7
+ from .utils import parse_regex, validate_address, validate_s3_key_prefix
8
+
9
+ AwsEndpointUrlOption = Annotated[
10
+ str | None,
11
+ Option(
12
+ envvar="AWS_ENDPOINT_URL",
13
+ help="Custom AWS endpoint URL (e.g., for MinIO or Cloudflare R2).",
14
+ ),
15
+ ]
16
+
17
+ AwsAccessKeyIdOption = Annotated[
18
+ str | None,
19
+ Option(envvar="AWS_ACCESS_KEY_ID", help="AWS Access Key ID."),
20
+ ]
21
+
22
+ AwsSecretAccessKeyOption = Annotated[
23
+ str | None,
24
+ Option(
25
+ envvar="AWS_SECRET_ACCESS_KEY",
26
+ help="AWS Secret Access Key.",
27
+ ),
28
+ ]
29
+
30
+ AwsProfileOption = Annotated[
31
+ str | None,
32
+ Option(envvar="AWS_PROFILE", help="AWS CLI profile name to use."),
33
+ ]
34
+
35
+ AwsRegionOption = Annotated[
36
+ str,
37
+ Option(envvar="AWS_REGION", help="AWS Region (e.g., us-east-1)."),
38
+ ]
39
+
40
+ S3BucketNameOption = Annotated[
41
+ str | None,
42
+ Option(
43
+ envvar="S3_BUCKET_NAME",
44
+ help="Storage bucket where snapshots are stored",
45
+ ),
46
+ ]
47
+
48
+ S3KeyPrefixOption = Annotated[
49
+ str,
50
+ Option(
51
+ callback=validate_s3_key_prefix,
52
+ ),
53
+ ]
54
+
55
+ AddressOption = Annotated[
56
+ str,
57
+ Option(
58
+ callback=validate_address,
59
+ default_factory=lambda: "https://127.0.0.1:8200",
60
+ envvar="VAULT_ADDR",
61
+ show_envvar=False,
62
+ help="Address of the Vault server. The default is https://127.0.0.1:8200. This can also be specified via the VAULT_ADDR environment variable.",
63
+ ),
64
+ ]
65
+
66
+ TokenOption = Annotated[
67
+ str | None,
68
+ Option(
69
+ envvar="VAULT_TOKEN",
70
+ show_envvar=False,
71
+ help=(
72
+ "Vault token used to authenticate with the Vault server. "
73
+ "This can also be specified via the VAULT_TOKEN environment variable."
74
+ ),
75
+ ),
76
+ ]
77
+
78
+ CACertOption = Annotated[
79
+ Path | None,
80
+ Option(
81
+ envvar="VAULT_CACERT",
82
+ show_envvar=False,
83
+ exists=True,
84
+ file_okay=True,
85
+ dir_okay=False,
86
+ readable=True,
87
+ writable=False,
88
+ resolve_path=True,
89
+ path_type=str,
90
+ help=(
91
+ "Path on the local disk to a single PEM-encoded CA certificate to verify the Vault server's SSL certificate. "
92
+ "This takes precedence over -ca-path. This can also be specified via the VAULT_CACERT environment variable."
93
+ ),
94
+ ),
95
+ ]
96
+
97
+ CAPathOption = Annotated[
98
+ Path | None,
99
+ Option(
100
+ envvar="VAULT_CAPATH",
101
+ show_envvar=False,
102
+ exists=True,
103
+ file_okay=False,
104
+ dir_okay=True,
105
+ readable=True,
106
+ writable=False,
107
+ resolve_path=True,
108
+ path_type=str,
109
+ help=(
110
+ "Path on the local disk to a directory of PEM-encoded CA certificates to verify the Vault server's SSL certificate. "
111
+ "This can also be specified via the VAULT_CAPATH environment variable."
112
+ ),
113
+ ),
114
+ ]
115
+
116
+ SkipVerifyOption = Annotated[
117
+ bool,
118
+ Option(
119
+ show_envvar=False,
120
+ envvar="VAULT_SKIP_VERIFY",
121
+ help=(
122
+ "Disable verification of TLS certificates. Using this option is highly discouraged "
123
+ "as it decreases the security of data transmissions to and from the Vault server. "
124
+ "The default is false. This can also be specified via the VAULT_SKIP_VERIFY environment variable."
125
+ ),
126
+ ),
127
+ ]
128
+
129
+ K8sRoleOption = Annotated[
130
+ str | None,
131
+ Option(envvar="VAULT_K8S_ROLE", help="Vault K8s role name."),
132
+ ]
133
+
134
+ K8sMountPointOption = Annotated[
135
+ str,
136
+ Option(envvar="VAULT_K8S_MOUNT_POINT", help="Vault K8s auth backend mount path."),
137
+ ]
138
+
139
+ SnapshotNameOption = Annotated[
140
+ str | None,
141
+ Option(
142
+ help="Name of the Vault snapshot to restore.",
143
+ ),
144
+ ]
145
+
146
+ SnapshotNameRegexOption = Annotated[
147
+ re.Pattern | None,
148
+ Option(
149
+ parser=parse_regex,
150
+ help="Regex pattern to match Vault snapshot names.",
151
+ ),
152
+ ]
153
+
154
+ SnapshotForceRestoreOption = Annotated[
155
+ bool,
156
+ Option(
157
+ help=(
158
+ "Force snapshot restore when the unseal keys or auto-unseal configuration "
159
+ "are inconsistent with the snapshot, such as when restoring data from "
160
+ "a different Vault cluster."
161
+ ),
162
+ ),
163
+ ]
164
+
165
+ TimeoutOption = Annotated[
166
+ int,
167
+ Option(
168
+ "--timeout",
169
+ "-t",
170
+ help="HTTP request timeout in seconds for Vault API calls.",
171
+ ),
172
+ ]
173
+
174
+ OutputFileOption = Annotated[
175
+ Path | None,
176
+ Option(
177
+ "--output-file",
178
+ "-o",
179
+ help="Path to write the snapshot to locally. If a directory, a timestamped filename is generated.",
180
+ dir_okay=True,
181
+ writable=True,
182
+ resolve_path=True,
183
+ path_type=str,
184
+ ),
185
+ ]
186
+
187
+ SnapshotFileOption = Annotated[
188
+ Path | None,
189
+ Option(
190
+ "--snapshot-file",
191
+ "-f",
192
+ help="Path to a local snapshot file to restore.",
193
+ exists=True,
194
+ file_okay=True,
195
+ dir_okay=False,
196
+ readable=True,
197
+ resolve_path=True,
198
+ path_type=str,
199
+ ),
200
+ ]
vaultctl/utils.py ADDED
@@ -0,0 +1,169 @@
1
+ import logging
2
+ import re
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, cast
5
+
6
+ import boto3
7
+ import botocore.exceptions
8
+ import typer
9
+ import validators
10
+ from hvac.exceptions import InvalidRequest, VaultError
11
+
12
+ if TYPE_CHECKING:
13
+ import hvac
14
+ from mypy_boto3_s3 import S3Client
15
+
16
+
17
+ def _setup_logger() -> logging.Logger:
18
+ logger = logging.getLogger("vaultctl")
19
+ logger.setLevel(logging.INFO)
20
+ if not logger.handlers:
21
+ handler = logging.StreamHandler()
22
+ handler.setFormatter(
23
+ logging.Formatter("%(asctime)s %(levelname)s %(message)s"),
24
+ )
25
+ logger.addHandler(handler)
26
+ return logger
27
+
28
+
29
+ logger = _setup_logger()
30
+
31
+
32
+ def parse_regex(value: str) -> re.Pattern:
33
+ """Parse and validate a regex pattern."""
34
+ try:
35
+ return re.compile(value)
36
+ except re.error as e:
37
+ msg = f"Invalid regex: {e}"
38
+ raise typer.BadParameter(msg) from e
39
+
40
+
41
+ def validate_s3_key_prefix(
42
+ value: str,
43
+ ) -> str:
44
+ """Validate S3 key prefix format."""
45
+ if not value:
46
+ return ""
47
+ if value.startswith("/"):
48
+ msg = "S3 key prefix must not start with '/'. Example: backups/2026/"
49
+ raise typer.BadParameter(
50
+ msg,
51
+ )
52
+
53
+ pattern = re.compile(r"^[A-Za-z0-9/_\-.]+$")
54
+ if not pattern.match(value):
55
+ msg = (
56
+ "S3 key prefix contains invalid characters. "
57
+ "Allowed: letters, numbers, '/', '-', '_', '.'"
58
+ )
59
+ raise typer.BadParameter(
60
+ msg,
61
+ )
62
+
63
+ if not value.endswith("/"):
64
+ value = value + "/"
65
+
66
+ return value
67
+
68
+
69
+ def validate_address(
70
+ ctx: typer.Context, # noqa: ARG001
71
+ param: object, # noqa: ARG001
72
+ value: str,
73
+ ) -> str:
74
+ if value == "https://127.0.0.1:8200":
75
+ logger.warning(
76
+ "VAULT_ADDR and --address unset. Defaulting to https://127.0.0.1:8200.",
77
+ )
78
+ elif not validators.url(value):
79
+ msg = f"Invalid Vault address URL: {value!r}"
80
+ raise typer.BadParameter(msg) from None
81
+ return value
82
+
83
+
84
+ def handle_vault_authentication(
85
+ client: hvac.Client,
86
+ token: str | None,
87
+ k8s_role: str | None = None,
88
+ k8s_mount_point: str = "kubernetes",
89
+ k8s_token_path: Path = Path("/var/run/secrets/kubernetes.io/serviceaccount/token"),
90
+ ) -> hvac.Client:
91
+ token_filepath = Path.home() / ".vault-token"
92
+
93
+ if token:
94
+ client.token = token
95
+ return client
96
+
97
+ if token_filepath.exists() and (saved_token := token_filepath.read_text().strip()):
98
+ client.token = saved_token
99
+ return client
100
+
101
+ if k8s_role:
102
+ logger.info("Attempting Kubernetes Auth for role: %s...", k8s_role)
103
+
104
+ if not k8s_token_path.exists():
105
+ logger.error(
106
+ "K8s token file not found at %s.",
107
+ k8s_token_path,
108
+ )
109
+ raise typer.Exit(code=1)
110
+
111
+ jwt = k8s_token_path.read_text().strip()
112
+
113
+ try:
114
+ client.auth.kubernetes.login(
115
+ role=k8s_role,
116
+ jwt=jwt,
117
+ mount_point=k8s_mount_point,
118
+ )
119
+ except InvalidRequest, VaultError:
120
+ logger.exception("Kubernetes Auth Failed")
121
+ raise typer.Exit(code=1) from None
122
+ else:
123
+ return client
124
+
125
+ logger.error("Vault client authentication failed.")
126
+ raise typer.Exit(code=1)
127
+
128
+
129
+ def handle_s3_authentication( # noqa: PLR0913
130
+ bucket_name: str,
131
+ *,
132
+ aws_access_key_id: str | None = None,
133
+ aws_secret_access_key: str | None = None,
134
+ aws_session_token: str | None = None,
135
+ aws_region: str | None = None,
136
+ aws_profile: str | None = None,
137
+ endpoint_url: str | None = None,
138
+ ) -> S3Client:
139
+ logger.info("Initializing S3 client...")
140
+
141
+ s3_client: S3Client | None = None
142
+
143
+ session = boto3.Session(
144
+ aws_access_key_id=aws_access_key_id,
145
+ aws_secret_access_key=aws_secret_access_key,
146
+ aws_session_token=aws_session_token,
147
+ region_name=aws_region,
148
+ profile_name=aws_profile,
149
+ )
150
+ s3_client = cast(
151
+ "S3Client",
152
+ session.client("s3", endpoint_url=endpoint_url),
153
+ )
154
+
155
+ try:
156
+ s3_client.head_bucket(Bucket=bucket_name)
157
+ except botocore.exceptions.ClientError as e:
158
+ msg = e.response.get("Error", {}).get("Message", "Unknown S3 Error")
159
+ logger.exception("S3 Head Bucket failed: %s", msg)
160
+ raise typer.Exit(code=1) from None
161
+ except (
162
+ botocore.exceptions.NoCredentialsError,
163
+ botocore.exceptions.PartialCredentialsError,
164
+ ):
165
+ logger.exception("S3 authentication failed")
166
+ raise typer.Exit(code=1) from None
167
+
168
+ logger.info("S3 client initialized.")
169
+ return s3_client
vaultctl/version.py ADDED
@@ -0,0 +1,8 @@
1
+ import typer
2
+
3
+ app = typer.Typer()
4
+
5
+
6
+ @app.command()
7
+ def version() -> None:
8
+ print("vaultctl 0.1.0") # noqa: T201
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: vaultctl
3
+ Version: 0.1.0
4
+ Summary: vaultctl is a command-line interface (CLI) tool designed to facilitate operations with HashiCorp Vault.
5
+ Author-email: d4rkfella <georgi.panov@darkfellanetwork.com>
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: <4.0,>=3.14
9
+ Requires-Dist: boto3==1.43.27
10
+ Requires-Dist: botocore[crt]
11
+ Requires-Dist: cryptography==48.0.1
12
+ Requires-Dist: hvac==2.4
13
+ Requires-Dist: python-dateutil==2.9.0
14
+ Requires-Dist: typer==0.26.7
15
+ Requires-Dist: validators==0.35.0
16
+ Description-Content-Type: text/markdown
17
+
18
+ vaultctl is a command-line interface (CLI) tool designed to facilitate operations with HashiCorp Vault.
@@ -0,0 +1,17 @@
1
+ vaultctl/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ vaultctl/main.py,sha256=AvjD-_aAVKZnJL47JTQHQ0TdzHMXdx2X43L-k7yYU10,626
3
+ vaultctl/options.py,sha256=qro57yji4t405wvqrEhnru5KWPVxwI117Ijuq8qhWOM,4957
4
+ vaultctl/utils.py,sha256=rzpSdSRVvInXrT6fQcR915lfqLZgD63-otqlAbqv5u0,4666
5
+ vaultctl/version.py,sha256=p8yxo-e4AKNsdRHguAP-EbLAoiTy-v0Lm8QpFPqs8G4,116
6
+ vaultctl/commands/__init__.py,sha256=ei3gcSupb8CrtiehtC1WQUlHPeiqS48Ld3IuXHkjACY,500
7
+ vaultctl/commands/backup_raft_snapshot.py,sha256=ux2HF7daEg6FQGY2f0fqwy_JMnPY23Ih8X5WQDjUIeQ,7381
8
+ vaultctl/commands/bootstrap.py,sha256=9AvZeYfT5itKDEd-ASYZr-n_Pq6-zGAAX-coMfCnZys,4153
9
+ vaultctl/commands/login.py,sha256=yA2mkGnp2FBJw3duYLr_fPVQUWr-SRPQaeb-KoSAQ7k,3902
10
+ vaultctl/commands/restore_raft_snapshot.py,sha256=AO29XiQ5MeEyRST6MNIrmTZz14fkgy8s_6meMCheEJs,6811
11
+ vaultctl/commands/pki/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ vaultctl/commands/pki/rotate_issuing.py,sha256=DOFjmbGLO-ijfXIOpHRHQo83S0UZa078sT6RYNghuL8,4736
13
+ vaultctl-0.1.0.dist-info/METADATA,sha256=sQnnKFME6ljmAJfUVqcswj3UvLlE6XCiLYkrGvNc3os,655
14
+ vaultctl-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
15
+ vaultctl-0.1.0.dist-info/entry_points.txt,sha256=Ut-6BJDScK48S6e5KxZmhqOwFs6xUNXBa9dvrjlYx7c,47
16
+ vaultctl-0.1.0.dist-info/licenses/LICENSE,sha256=di8AMq7X7mboEdzQil3E3zagCDzZbz00D30zzXmO0ZM,1069
17
+ vaultctl-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ vaultctl = vaultctl.main:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Georgi Panov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.