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 +0 -0
- vaultctl/commands/__init__.py +15 -0
- vaultctl/commands/backup_raft_snapshot.py +236 -0
- vaultctl/commands/bootstrap.py +125 -0
- vaultctl/commands/login.py +122 -0
- vaultctl/commands/pki/__init__.py +0 -0
- vaultctl/commands/pki/rotate_issuing.py +155 -0
- vaultctl/commands/restore_raft_snapshot.py +210 -0
- vaultctl/main.py +21 -0
- vaultctl/options.py +200 -0
- vaultctl/utils.py +169 -0
- vaultctl/version.py +8 -0
- vaultctl-0.1.0.dist-info/METADATA +18 -0
- vaultctl-0.1.0.dist-info/RECORD +17 -0
- vaultctl-0.1.0.dist-info/WHEEL +4 -0
- vaultctl-0.1.0.dist-info/entry_points.txt +2 -0
- vaultctl-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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,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.
|