ign8vault 1.0.0__tar.gz
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.
- ign8vault-1.0.0/PKG-INFO +36 -0
- ign8vault-1.0.0/README.md +9 -0
- ign8vault-1.0.0/pyproject.toml +48 -0
- ign8vault-1.0.0/src/ign8vault/__init__.py +0 -0
- ign8vault-1.0.0/src/ign8vault/cli.py +238 -0
- ign8vault-1.0.0/src/ign8vault/config.py +40 -0
- ign8vault-1.0.0/src/ign8vault/dns/__init__.py +0 -0
- ign8vault-1.0.0/src/ign8vault/dns/cloudflare.py +42 -0
- ign8vault-1.0.0/src/ign8vault/infra/__init__.py +0 -0
- ign8vault-1.0.0/src/ign8vault/infra/hetzner.py +109 -0
- ign8vault-1.0.0/src/ign8vault/keys.py +62 -0
- ign8vault-1.0.0/src/ign8vault/setup/__init__.py +0 -0
- ign8vault-1.0.0/src/ign8vault/setup/server.py +387 -0
ign8vault-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ign8vault
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Spin up a production HashiCorp Vault + Consul stack in minutes — Hetzner Cloud, Cloudflare DNS, automated backups to Hetzner StorageBox.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: cloudflare (>=3)
|
|
15
|
+
Requires-Dist: cryptography (>=42)
|
|
16
|
+
Requires-Dist: hcloud (>=2)
|
|
17
|
+
Requires-Dist: mypy (>=1.10) ; extra == "dev"
|
|
18
|
+
Requires-Dist: paramiko (>=3)
|
|
19
|
+
Requires-Dist: pydantic (>=2)
|
|
20
|
+
Requires-Dist: pydantic-settings (>=2)
|
|
21
|
+
Requires-Dist: pytest (>=8) ; extra == "dev"
|
|
22
|
+
Requires-Dist: rich (>=13)
|
|
23
|
+
Requires-Dist: ruff (>=0.4) ; extra == "dev"
|
|
24
|
+
Requires-Dist: typer (>=0.12)
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# ign8vault
|
|
28
|
+
|
|
29
|
+
Spin up HashiCorp Vault + Consul on Hetzner Cloud in one command.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install -e .
|
|
33
|
+
cp .env.example .env # fill in credentials
|
|
34
|
+
ign8vault up
|
|
35
|
+
```
|
|
36
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["poetry-core>=2.0.0"]
|
|
3
|
+
build-backend = "poetry.core.masonry.api"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ign8vault"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Spin up a production HashiCorp Vault + Consul stack in minutes — Hetzner Cloud, Cloudflare DNS, automated backups to Hetzner StorageBox."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = [
|
|
13
|
+
"typer>=0.12",
|
|
14
|
+
"rich>=13",
|
|
15
|
+
"hcloud>=2",
|
|
16
|
+
"cloudflare>=3",
|
|
17
|
+
"paramiko>=3",
|
|
18
|
+
"pydantic>=2",
|
|
19
|
+
"pydantic-settings>=2",
|
|
20
|
+
"cryptography>=42",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
ign8vault = "ign8vault.cli:app"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = [
|
|
28
|
+
"pytest>=8",
|
|
29
|
+
"ruff>=0.4",
|
|
30
|
+
"mypy>=1.10",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.poetry]
|
|
34
|
+
packages = [{include = "ign8vault", from = "src"}]
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
src = ["src"]
|
|
38
|
+
line-length = 100
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = ["E", "F", "I", "UP"]
|
|
42
|
+
|
|
43
|
+
[tool.mypy]
|
|
44
|
+
strict = true
|
|
45
|
+
mypy_path = "src"
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""CLI entry point: `ign8vault up` provisions Vault + Consul on Hetzner."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
import urllib.request
|
|
14
|
+
|
|
15
|
+
from ign8vault.config import Config
|
|
16
|
+
from ign8vault.dns.cloudflare import CloudflareDNS
|
|
17
|
+
from ign8vault.infra import hetzner
|
|
18
|
+
from ign8vault.keys import ensure_ed25519_keypair, save_keypair
|
|
19
|
+
from ign8vault.setup.server import VaultSetup
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(name="ign8vault", help="Ignite a production Vault + Consul stack in minutes.", invoke_without_command=True)
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.callback(invoke_without_command=True)
|
|
26
|
+
def _main(
|
|
27
|
+
ctx: typer.Context,
|
|
28
|
+
env_file: Path = typer.Option(Path(".env"), "--env-file", hidden=True),
|
|
29
|
+
) -> None:
|
|
30
|
+
# Load .env into os.environ so all envvar= options pick it up
|
|
31
|
+
if env_file.exists():
|
|
32
|
+
import os
|
|
33
|
+
for line in env_file.read_text().splitlines():
|
|
34
|
+
line = line.strip()
|
|
35
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
36
|
+
continue
|
|
37
|
+
k, _, v = line.partition("=")
|
|
38
|
+
os.environ.setdefault(k.strip(), v.strip())
|
|
39
|
+
if ctx.invoked_subcommand is None:
|
|
40
|
+
_print_intro()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def up(
|
|
45
|
+
domain: str = typer.Option(..., envvar="IGN8_DOMAIN", help="Base domain (e.g. example.com)"),
|
|
46
|
+
admin_email: str = typer.Option(..., envvar="IGN8_ADMIN_EMAIL", help="Admin / certbot email"),
|
|
47
|
+
hetzner_token: str = typer.Option(..., envvar="IGN8_HETZNER_TOKEN", help="Hetzner Cloud API token"),
|
|
48
|
+
cloudflare_token: str = typer.Option(..., envvar="IGN8_CLOUDFLARE_TOKEN", help="Cloudflare API token"),
|
|
49
|
+
cloudflare_zone_id: str = typer.Option(..., envvar="IGN8_CLOUDFLARE_ZONE_ID", help="Cloudflare Zone ID"),
|
|
50
|
+
cloudflare_email: str = typer.Option("", envvar="IGN8_CLOUDFLARE_EMAIL", help="Cloudflare account email (Global API Key only)"),
|
|
51
|
+
storagebox_host: str = typer.Option("", envvar="IGN8_STORAGEBOX_HOST", help="Hetzner StorageBox hostname"),
|
|
52
|
+
storagebox_user: str = typer.Option("", envvar="IGN8_STORAGEBOX_USER", help="StorageBox username (defaults to first part of host)"),
|
|
53
|
+
storagebox_password: str = typer.Option("", envvar="IGN8_STORAGEBOX_PASSWORD", help="StorageBox password"),
|
|
54
|
+
backup_password: str = typer.Option("", envvar="IGN8_BACKUP_PASSWORD", help="Restic encryption password (auto-generated if empty)"),
|
|
55
|
+
work_dir: Path = typer.Option(Path(".ign8vault"), help="Directory for keys and state"),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Provision Vault + Consul: Hetzner VM + Cloudflare DNS + TLS + backups."""
|
|
58
|
+
|
|
59
|
+
cfg = Config(
|
|
60
|
+
domain=domain,
|
|
61
|
+
admin_email=admin_email, # type: ignore[arg-type]
|
|
62
|
+
hetzner_token=hetzner_token,
|
|
63
|
+
cloudflare_token=cloudflare_token,
|
|
64
|
+
cloudflare_zone_id=cloudflare_zone_id,
|
|
65
|
+
cloudflare_email=cloudflare_email,
|
|
66
|
+
storagebox_host=storagebox_host,
|
|
67
|
+
storagebox_user=storagebox_user,
|
|
68
|
+
storagebox_password=storagebox_password,
|
|
69
|
+
backup_password=backup_password,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Auto-generate backup password if not supplied
|
|
73
|
+
effective_backup_password = cfg.backup_password or secrets.token_hex(24)
|
|
74
|
+
|
|
75
|
+
console.print(Panel(f"[bold]ign8vault[/bold] — igniting [cyan]{cfg.domain}[/cyan]"))
|
|
76
|
+
|
|
77
|
+
# 1. SSH keypair
|
|
78
|
+
console.print("[1/5] Generating SSH keypair...")
|
|
79
|
+
keys_dir = work_dir / "keys"
|
|
80
|
+
priv_path, pub_path = save_keypair(keys_dir)
|
|
81
|
+
public_key_openssh = pub_path.read_text().strip()
|
|
82
|
+
|
|
83
|
+
# 2. Hetzner server
|
|
84
|
+
console.print("[2/5] Provisioning Hetzner server...")
|
|
85
|
+
outputs = hetzner.provision(cfg, public_key_openssh, work_dir / "state.json")
|
|
86
|
+
ipv4: str = outputs["ipv4"]
|
|
87
|
+
ipv6: str = outputs["ipv6"]
|
|
88
|
+
console.print(f" Server IPv4: [green]{ipv4}[/green]")
|
|
89
|
+
if ipv6:
|
|
90
|
+
console.print(f" Server IPv6: [green]{ipv6}[/green]")
|
|
91
|
+
|
|
92
|
+
# 3. Cloudflare DNS
|
|
93
|
+
console.print("[3/5] Creating Cloudflare DNS records...")
|
|
94
|
+
dns = CloudflareDNS(cfg.cloudflare_token, cfg.cloudflare_zone_id, cfg.domain, cfg.cloudflare_email)
|
|
95
|
+
dns.provision(ipv4, ipv6)
|
|
96
|
+
console.print(f" vault.{cfg.domain} → {ipv4}")
|
|
97
|
+
console.print(f" consul.{cfg.domain} → {ipv4}")
|
|
98
|
+
|
|
99
|
+
# 4. Server setup
|
|
100
|
+
console.print("[4/5] Setting up server (Consul + Vault + nginx + TLS)...")
|
|
101
|
+
console.print(" Connecting via SSH (may take up to 5 minutes for boot)...")
|
|
102
|
+
setup = VaultSetup(cfg, ipv4, str(priv_path))
|
|
103
|
+
setup.connect(retries=30, delay=10)
|
|
104
|
+
try:
|
|
105
|
+
init_data = setup.run(effective_backup_password)
|
|
106
|
+
finally:
|
|
107
|
+
setup.disconnect()
|
|
108
|
+
|
|
109
|
+
# 5. Save credentials locally
|
|
110
|
+
console.print("[5/5] Saving credentials...")
|
|
111
|
+
init_path = work_dir / "vault-init.json"
|
|
112
|
+
saved: dict[str, object] = {
|
|
113
|
+
"vault_url": f"https://vault.{cfg.domain}",
|
|
114
|
+
"consul_url": f"https://consul.{cfg.domain}",
|
|
115
|
+
"backup_password": effective_backup_password,
|
|
116
|
+
}
|
|
117
|
+
if init_data:
|
|
118
|
+
saved.update(init_data)
|
|
119
|
+
|
|
120
|
+
init_path.write_text(json.dumps(saved, indent=2))
|
|
121
|
+
init_path.chmod(0o600)
|
|
122
|
+
|
|
123
|
+
_print_summary(cfg, saved, init_data)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command()
|
|
127
|
+
def destroy(
|
|
128
|
+
domain: str = typer.Option(..., envvar="IGN8_DOMAIN", help="Base domain"),
|
|
129
|
+
hetzner_token: str = typer.Option(..., envvar="IGN8_HETZNER_TOKEN", help="Hetzner Cloud API token"),
|
|
130
|
+
cloudflare_token: str = typer.Option("", envvar="IGN8_CLOUDFLARE_TOKEN", help="Cloudflare API token"),
|
|
131
|
+
cloudflare_zone_id: str = typer.Option("", envvar="IGN8_CLOUDFLARE_ZONE_ID", help="Cloudflare Zone ID"),
|
|
132
|
+
cloudflare_email: str = typer.Option("", envvar="IGN8_CLOUDFLARE_EMAIL"),
|
|
133
|
+
work_dir: Path = typer.Option(Path(".ign8vault"), help="Directory for keys and state"),
|
|
134
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Tear down the Vault + Consul server and DNS records."""
|
|
137
|
+
if not yes:
|
|
138
|
+
typer.confirm(f"Destroy all ign8vault resources for {domain}?", abort=True)
|
|
139
|
+
|
|
140
|
+
console.print(Panel(f"[bold red]ign8vault destroy[/bold red] — tearing down [cyan]{domain}[/cyan]"))
|
|
141
|
+
|
|
142
|
+
console.print("[1/2] Deleting Hetzner server...")
|
|
143
|
+
hetzner.destroy(work_dir / "state.json", hetzner_token)
|
|
144
|
+
|
|
145
|
+
if cloudflare_token and cloudflare_zone_id:
|
|
146
|
+
console.print("[2/2] Removing DNS records...")
|
|
147
|
+
dns = CloudflareDNS(cloudflare_token, cloudflare_zone_id, domain, cloudflare_email)
|
|
148
|
+
dns.destroy()
|
|
149
|
+
else:
|
|
150
|
+
console.print("[2/2] Skipping DNS cleanup (no Cloudflare credentials)")
|
|
151
|
+
|
|
152
|
+
console.print("[green]Done.[/green] DNS may take a few minutes to propagate removal.")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _print_summary(cfg: Config, saved: dict[str, object], init_data: dict[str, object]) -> None:
|
|
156
|
+
console.print()
|
|
157
|
+
console.print(Panel("[bold green]Vault stack is live![/bold green]"))
|
|
158
|
+
|
|
159
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
160
|
+
table.add_row("[dim]Vault UI[/dim]", f"[cyan]https://vault.{cfg.domain}[/cyan]")
|
|
161
|
+
table.add_row("[dim]Consul UI[/dim]", f"[cyan]https://consul.{cfg.domain}[/cyan]")
|
|
162
|
+
|
|
163
|
+
if init_data:
|
|
164
|
+
root_token = str(init_data.get("root_token", ""))
|
|
165
|
+
table.add_row("[dim]Root token[/dim]", f"[yellow]{root_token}[/yellow]")
|
|
166
|
+
keys = init_data.get("unseal_keys_b64", [])
|
|
167
|
+
for i, k in enumerate(keys, 1): # type: ignore[union-attr]
|
|
168
|
+
table.add_row(f"[dim]Unseal key {i}[/dim]", str(k))
|
|
169
|
+
|
|
170
|
+
backup_pw = str(saved.get("backup_password", ""))
|
|
171
|
+
if backup_pw:
|
|
172
|
+
table.add_row("[dim]Backup password[/dim]", f"[yellow]{backup_pw}[/yellow]")
|
|
173
|
+
|
|
174
|
+
console.print(table)
|
|
175
|
+
console.print()
|
|
176
|
+
console.print(
|
|
177
|
+
f"[dim]Credentials saved to[/dim] [bold].ign8vault/vault-init.json[/bold]\n"
|
|
178
|
+
f"[dim]Keep unseal keys safe — you need 3 of 5 to unseal after a reboot.[/dim]"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command()
|
|
183
|
+
def sign(
|
|
184
|
+
vault_addr: str = typer.Option(..., envvar="IGN8_VAULT_ADDR", help="Vault address"),
|
|
185
|
+
vault_token: str = typer.Option(..., envvar="IGN8_VAULT_TOKEN", help="Vault token"),
|
|
186
|
+
principals: str = typer.Option("root", help="Comma-separated list of valid principals"),
|
|
187
|
+
role: str = typer.Option("signer", help="Vault SSH signing role"),
|
|
188
|
+
work_dir: Path = typer.Option(Path(".ign8vault"), help="Directory for keys and state"),
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Create (if absent) the signssh keypair and sign it, saving the cert as signedssh."""
|
|
191
|
+
keys_dir = work_dir / "keys"
|
|
192
|
+
|
|
193
|
+
# 1. Create keypair if it doesn't exist yet
|
|
194
|
+
priv_path, pub_path = ensure_ed25519_keypair(keys_dir, "signssh")
|
|
195
|
+
public_key = pub_path.read_text().strip()
|
|
196
|
+
console.print(f"[dim]Key:[/dim] {pub_path}")
|
|
197
|
+
|
|
198
|
+
# 2. Sign via Vault SSH API
|
|
199
|
+
payload = json.dumps({
|
|
200
|
+
"public_key": public_key,
|
|
201
|
+
"valid_principals": principals,
|
|
202
|
+
"cert_type": "user",
|
|
203
|
+
}).encode()
|
|
204
|
+
|
|
205
|
+
req = urllib.request.Request(
|
|
206
|
+
f"{vault_addr.rstrip('/')}/v1/ssh/sign/{role}",
|
|
207
|
+
data=payload,
|
|
208
|
+
headers={"X-Vault-Token": vault_token, "Content-Type": "application/json"},
|
|
209
|
+
method="POST",
|
|
210
|
+
)
|
|
211
|
+
with urllib.request.urlopen(req) as resp:
|
|
212
|
+
data = json.loads(resp.read())
|
|
213
|
+
|
|
214
|
+
signed_key: str = data["data"]["signed_key"]
|
|
215
|
+
|
|
216
|
+
# 3. Save certificate as signedssh
|
|
217
|
+
cert_path = keys_dir / "signedssh"
|
|
218
|
+
cert_path.write_text(signed_key + "\n")
|
|
219
|
+
cert_path.chmod(0o600)
|
|
220
|
+
|
|
221
|
+
console.print(f"[green]Signed certificate saved to[/green] {cert_path}")
|
|
222
|
+
console.print(f"[dim]Serial:[/dim] {data['data']['serial_number']}")
|
|
223
|
+
|
|
224
|
+
# Print usage hint
|
|
225
|
+
console.print(
|
|
226
|
+
f"\n[dim]Use it:[/dim]\n"
|
|
227
|
+
f" ssh -i {priv_path} -i {cert_path} <host>"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _print_intro() -> None:
|
|
232
|
+
console.print(Panel(
|
|
233
|
+
"[bold]ign8vault[/bold] — HashiCorp Vault + Consul on Hetzner\n\n"
|
|
234
|
+
" [cyan]ign8vault up[/cyan] provision a complete stack\n"
|
|
235
|
+
" [cyan]ign8vault sign[/cyan] create/sign the signssh keypair\n"
|
|
236
|
+
" [cyan]ign8vault destroy[/cyan] tear it all down",
|
|
237
|
+
title="ign8vault",
|
|
238
|
+
))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from pydantic import EmailStr, field_validator, model_validator
|
|
2
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Config(BaseSettings):
|
|
6
|
+
model_config = SettingsConfigDict(env_prefix="IGN8_", env_file=".env", extra="ignore")
|
|
7
|
+
|
|
8
|
+
# Required
|
|
9
|
+
domain: str
|
|
10
|
+
admin_email: EmailStr
|
|
11
|
+
hetzner_token: str
|
|
12
|
+
cloudflare_token: str
|
|
13
|
+
cloudflare_zone_id: str
|
|
14
|
+
cloudflare_email: str = ""
|
|
15
|
+
|
|
16
|
+
# Server
|
|
17
|
+
server_location: str = "nbg1"
|
|
18
|
+
server_type: str = "cpx22"
|
|
19
|
+
server_image: str = "ubuntu-24.04"
|
|
20
|
+
|
|
21
|
+
# StorageBox backup
|
|
22
|
+
storagebox_host: str = ""
|
|
23
|
+
storagebox_user: str = ""
|
|
24
|
+
storagebox_password: str = ""
|
|
25
|
+
backup_password: str = "" # auto-generated if empty
|
|
26
|
+
|
|
27
|
+
# Vault (for sign command)
|
|
28
|
+
vault_addr: str = ""
|
|
29
|
+
vault_token: str = ""
|
|
30
|
+
|
|
31
|
+
@field_validator("domain")
|
|
32
|
+
@classmethod
|
|
33
|
+
def strip_trailing_dot(cls, v: str) -> str:
|
|
34
|
+
return v.rstrip(".")
|
|
35
|
+
|
|
36
|
+
@model_validator(mode="after")
|
|
37
|
+
def _derive_storagebox_user(self) -> "Config":
|
|
38
|
+
if self.storagebox_host and not self.storagebox_user:
|
|
39
|
+
self.storagebox_user = self.storagebox_host.split(".")[0]
|
|
40
|
+
return self
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Create DNS records for vault.DOMAIN and consul.DOMAIN."""
|
|
2
|
+
|
|
3
|
+
import cloudflare
|
|
4
|
+
from cloudflare.types.dns import RecordCreateParams
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CloudflareDNS:
|
|
8
|
+
def __init__(self, token: str, zone_id: str, domain: str, email: str = "") -> None:
|
|
9
|
+
if email:
|
|
10
|
+
self._cf = cloudflare.Cloudflare(api_email=email, api_key=token)
|
|
11
|
+
else:
|
|
12
|
+
self._cf = cloudflare.Cloudflare(api_token=token)
|
|
13
|
+
self._zone = zone_id
|
|
14
|
+
self._domain = domain
|
|
15
|
+
|
|
16
|
+
def provision(self, ipv4: str, ipv6: str) -> None:
|
|
17
|
+
records: list[RecordCreateParams] = [
|
|
18
|
+
{"type": "A", "name": f"vault.{self._domain}", "content": ipv4, "proxied": False, "ttl": 1},
|
|
19
|
+
{"type": "A", "name": f"consul.{self._domain}", "content": ipv4, "proxied": False, "ttl": 1},
|
|
20
|
+
]
|
|
21
|
+
if ipv6:
|
|
22
|
+
records += [
|
|
23
|
+
{"type": "AAAA", "name": f"vault.{self._domain}", "content": ipv6, "proxied": False, "ttl": 1},
|
|
24
|
+
{"type": "AAAA", "name": f"consul.{self._domain}", "content": ipv6, "proxied": False, "ttl": 1},
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
names = {r["name"] for r in records} # type: ignore[index]
|
|
28
|
+
for existing in self._cf.dns.records.list(zone_id=self._zone).result or []:
|
|
29
|
+
if existing.name in names:
|
|
30
|
+
self._cf.dns.records.delete(existing.id, zone_id=self._zone)
|
|
31
|
+
|
|
32
|
+
for record in records:
|
|
33
|
+
self._cf.dns.records.create(zone_id=self._zone, **record) # type: ignore[arg-type]
|
|
34
|
+
|
|
35
|
+
def destroy(self) -> None:
|
|
36
|
+
targets = {
|
|
37
|
+
f"vault.{self._domain}",
|
|
38
|
+
f"consul.{self._domain}",
|
|
39
|
+
}
|
|
40
|
+
for existing in self._cf.dns.records.list(zone_id=self._zone).result or []:
|
|
41
|
+
if existing.name in targets:
|
|
42
|
+
self._cf.dns.records.delete(existing.id, zone_id=self._zone)
|
|
File without changes
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Hetzner Cloud provisioner: SSH key, firewall, and server via the hcloud SDK."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from hcloud import Client
|
|
7
|
+
from hcloud.firewalls.domain import FirewallRule
|
|
8
|
+
from hcloud.images import Image
|
|
9
|
+
from hcloud.locations import Location
|
|
10
|
+
from hcloud.server_types import ServerType
|
|
11
|
+
|
|
12
|
+
from ign8vault.config import Config
|
|
13
|
+
|
|
14
|
+
_NAME = "ign8vault"
|
|
15
|
+
_SSH_KEY_NAME = "ign8vault-key"
|
|
16
|
+
_FW_NAME = "ign8vault-fw"
|
|
17
|
+
|
|
18
|
+
_INBOUND_PORTS = ["22", "80", "443"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def provision(cfg: Config, public_key: str, state_path: Path) -> dict[str, str]:
|
|
22
|
+
client = Client(token=cfg.hetzner_token)
|
|
23
|
+
|
|
24
|
+
ssh_key = _ensure_ssh_key(client, public_key)
|
|
25
|
+
firewall = _ensure_firewall(client)
|
|
26
|
+
server = _ensure_server(client, cfg, ssh_key, firewall)
|
|
27
|
+
|
|
28
|
+
import ipaddress as _ipaddress
|
|
29
|
+
_ipv4 = server.public_net.ipv4.ip if server.public_net.ipv4 else ""
|
|
30
|
+
_ipv6 = ""
|
|
31
|
+
if server.public_net.ipv6:
|
|
32
|
+
_net = _ipaddress.IPv6Network(server.public_net.ipv6.ip)
|
|
33
|
+
_ipv6 = str(_net.network_address + 1)
|
|
34
|
+
|
|
35
|
+
_hostname = f"vault.{cfg.domain}"
|
|
36
|
+
if _ipv4:
|
|
37
|
+
server.change_dns_ptr(ip=_ipv4, dns_ptr=_hostname).wait_until_finished()
|
|
38
|
+
if _ipv6:
|
|
39
|
+
server.change_dns_ptr(ip=_ipv6, dns_ptr=_hostname).wait_until_finished()
|
|
40
|
+
|
|
41
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
state_path.write_text(json.dumps({
|
|
43
|
+
"server_id": server.id,
|
|
44
|
+
"ssh_key_id": ssh_key.id,
|
|
45
|
+
"firewall_id": firewall.id,
|
|
46
|
+
"ipv4": _ipv4,
|
|
47
|
+
"ipv6": _ipv6,
|
|
48
|
+
}, indent=2))
|
|
49
|
+
|
|
50
|
+
return {"ipv4": _ipv4, "ipv6": _ipv6}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def destroy(state_path: Path, hetzner_token: str) -> None:
|
|
54
|
+
client = Client(token=hetzner_token)
|
|
55
|
+
|
|
56
|
+
for server in client.servers.get_all(name=_NAME):
|
|
57
|
+
server.delete().wait_until_finished()
|
|
58
|
+
|
|
59
|
+
for fw in client.firewalls.get_all(name=_FW_NAME):
|
|
60
|
+
fw.delete()
|
|
61
|
+
|
|
62
|
+
for key in client.ssh_keys.get_all(name=_SSH_KEY_NAME):
|
|
63
|
+
key.delete()
|
|
64
|
+
|
|
65
|
+
state_path.unlink(missing_ok=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _ensure_ssh_key(client: Client, public_key: str): # type: ignore[return]
|
|
69
|
+
existing = client.ssh_keys.get_all(name=_SSH_KEY_NAME)
|
|
70
|
+
if existing:
|
|
71
|
+
key = existing[0]
|
|
72
|
+
if key.public_key.strip() != public_key.strip():
|
|
73
|
+
key.delete()
|
|
74
|
+
else:
|
|
75
|
+
return key
|
|
76
|
+
return client.ssh_keys.create(name=_SSH_KEY_NAME, public_key=public_key)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ensure_firewall(client: Client): # type: ignore[return]
|
|
80
|
+
existing = client.firewalls.get_all(name=_FW_NAME)
|
|
81
|
+
if existing:
|
|
82
|
+
return existing[0]
|
|
83
|
+
rules = [
|
|
84
|
+
FirewallRule(
|
|
85
|
+
direction="in",
|
|
86
|
+
protocol="tcp",
|
|
87
|
+
port=port,
|
|
88
|
+
source_ips=["0.0.0.0/0", "::/0"],
|
|
89
|
+
)
|
|
90
|
+
for port in _INBOUND_PORTS
|
|
91
|
+
]
|
|
92
|
+
return client.firewalls.create(name=_FW_NAME, rules=rules).firewall
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _ensure_server(client: Client, cfg: Config, ssh_key: object, firewall: object): # type: ignore[return]
|
|
96
|
+
existing = client.servers.get_all(name=_NAME)
|
|
97
|
+
if existing:
|
|
98
|
+
return existing[0]
|
|
99
|
+
response = client.servers.create(
|
|
100
|
+
name=_NAME,
|
|
101
|
+
server_type=ServerType(name=cfg.server_type),
|
|
102
|
+
image=Image(name=cfg.server_image),
|
|
103
|
+
location=Location(name=cfg.server_location),
|
|
104
|
+
ssh_keys=[ssh_key],
|
|
105
|
+
firewalls=[firewall],
|
|
106
|
+
labels={"managed-by": "ign8vault", "domain": cfg.domain},
|
|
107
|
+
)
|
|
108
|
+
response.action.wait_until_finished()
|
|
109
|
+
return response.server
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from cryptography.hazmat.primitives import serialization
|
|
4
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def generate_rsa_keypair(bits: int = 4096) -> tuple[str, str]:
|
|
8
|
+
"""Return (private_key_pem, public_key_openssh)."""
|
|
9
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=bits)
|
|
10
|
+
private_pem = private_key.private_bytes(
|
|
11
|
+
encoding=serialization.Encoding.PEM,
|
|
12
|
+
format=serialization.PrivateFormat.OpenSSH,
|
|
13
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
14
|
+
).decode()
|
|
15
|
+
public_openssh = private_key.public_key().public_bytes(
|
|
16
|
+
encoding=serialization.Encoding.OpenSSH,
|
|
17
|
+
format=serialization.PublicFormat.OpenSSH,
|
|
18
|
+
).decode()
|
|
19
|
+
return private_pem, public_openssh
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def generate_ed25519_keypair() -> tuple[str, str]:
|
|
23
|
+
"""Return (private_key_pem_openssh, public_key_openssh)."""
|
|
24
|
+
private_key = ed25519.Ed25519PrivateKey.generate()
|
|
25
|
+
private_pem = private_key.private_bytes(
|
|
26
|
+
encoding=serialization.Encoding.PEM,
|
|
27
|
+
format=serialization.PrivateFormat.OpenSSH,
|
|
28
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
29
|
+
).decode()
|
|
30
|
+
public_openssh = private_key.public_key().public_bytes(
|
|
31
|
+
encoding=serialization.Encoding.OpenSSH,
|
|
32
|
+
format=serialization.PublicFormat.OpenSSH,
|
|
33
|
+
).decode()
|
|
34
|
+
return private_pem, public_openssh
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save_keypair(directory: Path, name: str = "ign8vault") -> tuple[Path, Path]:
|
|
38
|
+
"""Generate RSA keypair if absent, return (private_path, public_path)."""
|
|
39
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
priv_path = directory / name
|
|
41
|
+
pub_path = directory / f"{name}.pub"
|
|
42
|
+
|
|
43
|
+
if not priv_path.exists():
|
|
44
|
+
private_pem, public_openssh = generate_rsa_keypair()
|
|
45
|
+
priv_path.write_text(private_pem)
|
|
46
|
+
priv_path.chmod(0o600)
|
|
47
|
+
pub_path.write_text(public_openssh)
|
|
48
|
+
return priv_path, pub_path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ensure_ed25519_keypair(directory: Path, name: str) -> tuple[Path, Path]:
|
|
52
|
+
"""Create ed25519 keypair if absent, return (private_path, public_path)."""
|
|
53
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
priv_path = directory / name
|
|
55
|
+
pub_path = directory / f"{name}.pub"
|
|
56
|
+
|
|
57
|
+
if not priv_path.exists():
|
|
58
|
+
private_pem, public_openssh = generate_ed25519_keypair()
|
|
59
|
+
priv_path.write_text(private_pem)
|
|
60
|
+
priv_path.chmod(0o600)
|
|
61
|
+
pub_path.write_text(public_openssh)
|
|
62
|
+
return priv_path, pub_path
|
|
File without changes
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""SSH into the provisioned server and install Consul + Vault + nginx + backup."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import paramiko
|
|
8
|
+
|
|
9
|
+
from ign8vault.config import Config
|
|
10
|
+
|
|
11
|
+
_PACKAGES = "curl gnupg nginx certbot python3-certbot-nginx restic sshpass lsb-release"
|
|
12
|
+
|
|
13
|
+
_CONSUL_CONFIG = """\
|
|
14
|
+
datacenter = "dc1"
|
|
15
|
+
data_dir = "/opt/consul/data"
|
|
16
|
+
server = true
|
|
17
|
+
bootstrap_expect = 1
|
|
18
|
+
bind_addr = "127.0.0.1"
|
|
19
|
+
client_addr = "127.0.0.1"
|
|
20
|
+
log_level = "INFO"
|
|
21
|
+
|
|
22
|
+
ui_config {
|
|
23
|
+
enabled = true
|
|
24
|
+
}
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
_VAULT_CONFIG_TMPL = """\
|
|
28
|
+
storage "consul" {{
|
|
29
|
+
address = "127.0.0.1:8500"
|
|
30
|
+
path = "vault/"
|
|
31
|
+
}}
|
|
32
|
+
|
|
33
|
+
listener "tcp" {{
|
|
34
|
+
address = "127.0.0.1:8200"
|
|
35
|
+
tls_disable = true
|
|
36
|
+
}}
|
|
37
|
+
|
|
38
|
+
api_addr = "https://vault.{domain}"
|
|
39
|
+
ui = true
|
|
40
|
+
log_level = "INFO"
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
_NGINX_ACME_TMPL = """\
|
|
44
|
+
server {{
|
|
45
|
+
listen 80;
|
|
46
|
+
server_name vault.{domain} consul.{domain};
|
|
47
|
+
root /var/www/html;
|
|
48
|
+
location /.well-known/acme-challenge/ {{ try_files $uri =404; }}
|
|
49
|
+
location / {{ return 200 'ok'; add_header Content-Type text/plain; }}
|
|
50
|
+
}}
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
_NGINX_FINAL_TMPL = """\
|
|
54
|
+
server {{
|
|
55
|
+
listen 80;
|
|
56
|
+
server_name vault.{domain} consul.{domain};
|
|
57
|
+
location /.well-known/acme-challenge/ {{ root /var/www/html; }}
|
|
58
|
+
location / {{ return 301 https://$host$request_uri; }}
|
|
59
|
+
}}
|
|
60
|
+
|
|
61
|
+
server {{
|
|
62
|
+
listen 443 ssl http2;
|
|
63
|
+
server_name vault.{domain};
|
|
64
|
+
|
|
65
|
+
ssl_certificate /etc/letsencrypt/live/vault.{domain}/fullchain.pem;
|
|
66
|
+
ssl_certificate_key /etc/letsencrypt/live/vault.{domain}/privkey.pem;
|
|
67
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
68
|
+
ssl_prefer_server_ciphers on;
|
|
69
|
+
|
|
70
|
+
# Allow Vault's large request headers
|
|
71
|
+
proxy_buffer_size 128k;
|
|
72
|
+
proxy_buffers 4 256k;
|
|
73
|
+
proxy_busy_buffers_size 256k;
|
|
74
|
+
|
|
75
|
+
location / {{
|
|
76
|
+
proxy_pass http://127.0.0.1:8200;
|
|
77
|
+
proxy_set_header Host $host;
|
|
78
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
79
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
80
|
+
proxy_set_header X-Forwarded-Proto https;
|
|
81
|
+
proxy_read_timeout 300s;
|
|
82
|
+
}}
|
|
83
|
+
}}
|
|
84
|
+
|
|
85
|
+
server {{
|
|
86
|
+
listen 443 ssl http2;
|
|
87
|
+
server_name consul.{domain};
|
|
88
|
+
|
|
89
|
+
ssl_certificate /etc/letsencrypt/live/vault.{domain}/fullchain.pem;
|
|
90
|
+
ssl_certificate_key /etc/letsencrypt/live/vault.{domain}/privkey.pem;
|
|
91
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
92
|
+
ssl_prefer_server_ciphers on;
|
|
93
|
+
|
|
94
|
+
location / {{
|
|
95
|
+
proxy_pass http://127.0.0.1:8500;
|
|
96
|
+
proxy_set_header Host $host;
|
|
97
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
98
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
99
|
+
proxy_set_header X-Forwarded-Proto https;
|
|
100
|
+
}}
|
|
101
|
+
}}
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
_BACKUP_SERVICE_TMPL = """\
|
|
105
|
+
[Unit]
|
|
106
|
+
Description=Vault/Consul backup via restic
|
|
107
|
+
After=network.target
|
|
108
|
+
|
|
109
|
+
[Service]
|
|
110
|
+
Type=oneshot
|
|
111
|
+
Environment="RESTIC_PASSWORD={restic_password}"
|
|
112
|
+
ExecStart=/usr/bin/restic -r sftp:storagebox:/vault-backup backup /opt/consul/data
|
|
113
|
+
ExecStart=/usr/bin/restic -r sftp:storagebox:/vault-backup forget --keep-daily 7 --keep-weekly 4 --prune
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
_BACKUP_TIMER = """\
|
|
117
|
+
[Unit]
|
|
118
|
+
Description=Daily Vault/Consul backup
|
|
119
|
+
|
|
120
|
+
[Timer]
|
|
121
|
+
OnCalendar=daily
|
|
122
|
+
RandomizedDelaySec=1800
|
|
123
|
+
Persistent=true
|
|
124
|
+
|
|
125
|
+
[Install]
|
|
126
|
+
WantedBy=timers.target
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class VaultSetup:
|
|
131
|
+
def __init__(self, cfg: Config, host: str, private_key_path: str) -> None:
|
|
132
|
+
self._cfg = cfg
|
|
133
|
+
self._host = host
|
|
134
|
+
self._key_path = private_key_path
|
|
135
|
+
self._ssh: paramiko.SSHClient | None = None
|
|
136
|
+
self._known_hosts_path = Path(private_key_path).parent.parent / "known_hosts"
|
|
137
|
+
|
|
138
|
+
def connect(self, retries: int = 30, delay: int = 10) -> None:
|
|
139
|
+
client = paramiko.SSHClient()
|
|
140
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
141
|
+
key = paramiko.RSAKey.from_private_key_file(self._key_path)
|
|
142
|
+
|
|
143
|
+
for attempt in range(retries):
|
|
144
|
+
try:
|
|
145
|
+
client.connect(self._host, username="root", pkey=key, timeout=15)
|
|
146
|
+
self._ssh = client
|
|
147
|
+
return
|
|
148
|
+
except Exception:
|
|
149
|
+
if attempt == retries - 1:
|
|
150
|
+
raise
|
|
151
|
+
time.sleep(delay)
|
|
152
|
+
|
|
153
|
+
def disconnect(self) -> None:
|
|
154
|
+
if self._ssh:
|
|
155
|
+
self._ssh.close()
|
|
156
|
+
self._ssh = None
|
|
157
|
+
|
|
158
|
+
def run(self, backup_password: str) -> dict[str, object]:
|
|
159
|
+
"""Full server setup. Returns vault init data (unseal keys + root token)."""
|
|
160
|
+
assert self._ssh, "Call connect() first"
|
|
161
|
+
|
|
162
|
+
self._install_packages()
|
|
163
|
+
self._install_hashicorp()
|
|
164
|
+
self._setup_consul()
|
|
165
|
+
self._setup_vault()
|
|
166
|
+
self._setup_nginx_acme()
|
|
167
|
+
self._get_tls_certs()
|
|
168
|
+
self._setup_nginx_final()
|
|
169
|
+
|
|
170
|
+
init_data = self._init_vault()
|
|
171
|
+
|
|
172
|
+
if self._cfg.storagebox_host:
|
|
173
|
+
self._setup_backup(backup_password)
|
|
174
|
+
|
|
175
|
+
return init_data
|
|
176
|
+
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
# Installation
|
|
179
|
+
|
|
180
|
+
def _install_packages(self) -> None:
|
|
181
|
+
self._exec("apt-get update -qq")
|
|
182
|
+
self._exec(f"DEBIAN_FRONTEND=noninteractive apt-get install -y -qq {_PACKAGES}")
|
|
183
|
+
|
|
184
|
+
def _install_hashicorp(self) -> None:
|
|
185
|
+
already = self._exec("dpkg -l consul 2>/dev/null | grep -c '^ii' || true").strip()
|
|
186
|
+
if already == "1":
|
|
187
|
+
return
|
|
188
|
+
self._exec(
|
|
189
|
+
"curl -fsSL https://apt.releases.hashicorp.com/gpg"
|
|
190
|
+
" | gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg"
|
|
191
|
+
)
|
|
192
|
+
self._exec(
|
|
193
|
+
'echo "deb [arch=$(dpkg --print-architecture)'
|
|
194
|
+
" signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg]"
|
|
195
|
+
' https://apt.releases.hashicorp.com $(lsb_release -cs) main"'
|
|
196
|
+
" > /etc/apt/sources.list.d/hashicorp.list"
|
|
197
|
+
)
|
|
198
|
+
self._exec("apt-get update -qq")
|
|
199
|
+
self._exec("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq consul vault")
|
|
200
|
+
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
# Consul
|
|
203
|
+
|
|
204
|
+
def _setup_consul(self) -> None:
|
|
205
|
+
self._exec("mkdir -p /opt/consul/data && chown -R consul:consul /opt/consul/data")
|
|
206
|
+
self._write_remote("/etc/consul.d/consul.hcl", _CONSUL_CONFIG)
|
|
207
|
+
self._exec("systemctl enable consul")
|
|
208
|
+
# restart may time out waiting for sd_notify even though consul is healthy
|
|
209
|
+
try:
|
|
210
|
+
self._exec("systemctl restart consul")
|
|
211
|
+
except RuntimeError as e:
|
|
212
|
+
if "timeout" not in str(e).lower() and "timed out" not in str(e).lower():
|
|
213
|
+
raise
|
|
214
|
+
# Wait until Consul API returns a leader
|
|
215
|
+
for _ in range(30):
|
|
216
|
+
try:
|
|
217
|
+
out = self._exec("curl -sf http://127.0.0.1:8500/v1/status/leader || true").strip()
|
|
218
|
+
if out and out != '""':
|
|
219
|
+
return
|
|
220
|
+
except RuntimeError:
|
|
221
|
+
pass
|
|
222
|
+
time.sleep(2)
|
|
223
|
+
raise RuntimeError("Consul API did not become healthy within 60s")
|
|
224
|
+
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
# Vault
|
|
227
|
+
|
|
228
|
+
def _setup_vault(self) -> None:
|
|
229
|
+
self._exec("mkdir -p /opt/vault/data && chown -R vault:vault /opt/vault/data")
|
|
230
|
+
vault_cfg = _VAULT_CONFIG_TMPL.format(domain=self._cfg.domain)
|
|
231
|
+
self._write_remote("/etc/vault.d/vault.hcl", vault_cfg)
|
|
232
|
+
self._exec("systemctl enable vault")
|
|
233
|
+
try:
|
|
234
|
+
self._exec("systemctl restart vault")
|
|
235
|
+
except RuntimeError as e:
|
|
236
|
+
if "timeout" not in str(e).lower() and "timed out" not in str(e).lower():
|
|
237
|
+
raise
|
|
238
|
+
# Wait until Vault API responds (501=not init, 429=sealed, 200=ok)
|
|
239
|
+
for _ in range(30):
|
|
240
|
+
code = self._exec(
|
|
241
|
+
"curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8200/v1/sys/health || true"
|
|
242
|
+
).strip()
|
|
243
|
+
if code in ("200", "429", "472", "473", "501"):
|
|
244
|
+
return
|
|
245
|
+
time.sleep(2)
|
|
246
|
+
raise RuntimeError("Vault API did not become healthy within 60s")
|
|
247
|
+
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
# nginx
|
|
250
|
+
|
|
251
|
+
def _setup_nginx_acme(self) -> None:
|
|
252
|
+
self._exec("mkdir -p /var/www/html")
|
|
253
|
+
self._exec("rm -f /etc/nginx/sites-enabled/default")
|
|
254
|
+
acme_cfg = _NGINX_ACME_TMPL.format(domain=self._cfg.domain)
|
|
255
|
+
self._write_remote("/etc/nginx/sites-available/ign8vault", acme_cfg)
|
|
256
|
+
self._exec("ln -sf /etc/nginx/sites-available/ign8vault /etc/nginx/sites-enabled/ign8vault")
|
|
257
|
+
self._exec("nginx -t && systemctl reload nginx")
|
|
258
|
+
|
|
259
|
+
def _get_tls_certs(self) -> None:
|
|
260
|
+
domain = self._cfg.domain
|
|
261
|
+
self._exec(
|
|
262
|
+
f"certbot certonly --webroot -w /var/www/html"
|
|
263
|
+
f" -d vault.{domain} -d consul.{domain}"
|
|
264
|
+
f" --non-interactive --agree-tos -m {self._cfg.admin_email}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def _setup_nginx_final(self) -> None:
|
|
268
|
+
final_cfg = _NGINX_FINAL_TMPL.format(domain=self._cfg.domain)
|
|
269
|
+
self._write_remote("/etc/nginx/sites-available/ign8vault", final_cfg)
|
|
270
|
+
self._exec("nginx -t && systemctl reload nginx")
|
|
271
|
+
|
|
272
|
+
# ------------------------------------------------------------------
|
|
273
|
+
# Vault init & unseal
|
|
274
|
+
|
|
275
|
+
def _init_vault(self) -> dict[str, object]:
|
|
276
|
+
"""Initialize Vault if needed, unseal it, return init data."""
|
|
277
|
+
status_raw = self._exec(
|
|
278
|
+
"VAULT_ADDR=http://127.0.0.1:8200 vault status -format=json 2>&1 || true"
|
|
279
|
+
).strip()
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
status = json.loads(status_raw)
|
|
283
|
+
already_initialized = status.get("initialized", False)
|
|
284
|
+
except (json.JSONDecodeError, ValueError):
|
|
285
|
+
already_initialized = False
|
|
286
|
+
|
|
287
|
+
if already_initialized:
|
|
288
|
+
# Vault already initialized — unseal if sealed
|
|
289
|
+
if status.get("sealed", True):
|
|
290
|
+
raise RuntimeError(
|
|
291
|
+
"Vault is already initialized but sealed. "
|
|
292
|
+
"Retrieve your unseal keys from .ign8vault/vault-init.json and unseal manually."
|
|
293
|
+
)
|
|
294
|
+
return {}
|
|
295
|
+
|
|
296
|
+
init_raw = self._exec(
|
|
297
|
+
"VAULT_ADDR=http://127.0.0.1:8200"
|
|
298
|
+
" vault operator init -key-shares=5 -key-threshold=3 -format=json"
|
|
299
|
+
).strip()
|
|
300
|
+
|
|
301
|
+
init_data: dict[str, object] = json.loads(init_raw)
|
|
302
|
+
|
|
303
|
+
# Save on the server (root-only)
|
|
304
|
+
self._write_remote("/root/vault-init.json", init_raw)
|
|
305
|
+
self._exec("chmod 600 /root/vault-init.json")
|
|
306
|
+
|
|
307
|
+
# Unseal using the first 3 keys (threshold=3)
|
|
308
|
+
keys = init_data.get("unseal_keys_b64", [])
|
|
309
|
+
for key in keys[:3]:
|
|
310
|
+
self._exec(
|
|
311
|
+
f"VAULT_ADDR=http://127.0.0.1:8200 vault operator unseal {key}"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
return init_data
|
|
315
|
+
|
|
316
|
+
# ------------------------------------------------------------------
|
|
317
|
+
# Backup
|
|
318
|
+
|
|
319
|
+
def _setup_backup(self, restic_password: str) -> None:
|
|
320
|
+
cfg = self._cfg
|
|
321
|
+
|
|
322
|
+
# Generate dedicated backup SSH key on the server (idempotent)
|
|
323
|
+
self._exec(
|
|
324
|
+
"test -f /root/.ssh/backup_key"
|
|
325
|
+
" || ssh-keygen -t ed25519 -f /root/.ssh/backup_key -N '' -C 'vault-backup'"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# Authorise the key on the storage box via sshpass (port 23)
|
|
329
|
+
self._exec(
|
|
330
|
+
f"sshpass -p '{cfg.storagebox_password}'"
|
|
331
|
+
f" ssh-copy-id -p 23 -i /root/.ssh/backup_key.pub"
|
|
332
|
+
f" -o StrictHostKeyChecking=no"
|
|
333
|
+
f" {cfg.storagebox_user}@{cfg.storagebox_host}"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# SSH client config for the storagebox alias
|
|
337
|
+
ssh_snippet = (
|
|
338
|
+
f"Host storagebox\n"
|
|
339
|
+
f" HostName {cfg.storagebox_host}\n"
|
|
340
|
+
f" User {cfg.storagebox_user}\n"
|
|
341
|
+
f" Port 23\n"
|
|
342
|
+
f" IdentityFile /root/.ssh/backup_key\n"
|
|
343
|
+
f" StrictHostKeyChecking no\n"
|
|
344
|
+
)
|
|
345
|
+
self._exec(
|
|
346
|
+
f"grep -q 'Host storagebox' /root/.ssh/config 2>/dev/null"
|
|
347
|
+
f" || printf '{ssh_snippet}' >> /root/.ssh/config"
|
|
348
|
+
)
|
|
349
|
+
self._exec("chmod 600 /root/.ssh/config")
|
|
350
|
+
|
|
351
|
+
# Init restic repo (idempotent — fails gracefully if already initialised)
|
|
352
|
+
self._exec(
|
|
353
|
+
f"RESTIC_PASSWORD='{restic_password}'"
|
|
354
|
+
" restic -r sftp:storagebox:/vault-backup init 2>&1"
|
|
355
|
+
" | grep -v 'already initialized' || true"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# Systemd service + timer
|
|
359
|
+
svc = _BACKUP_SERVICE_TMPL.format(restic_password=restic_password)
|
|
360
|
+
self._write_remote("/etc/systemd/system/vault-backup.service", svc)
|
|
361
|
+
self._write_remote("/etc/systemd/system/vault-backup.timer", _BACKUP_TIMER)
|
|
362
|
+
self._exec(
|
|
363
|
+
"systemctl daemon-reload"
|
|
364
|
+
" && systemctl enable vault-backup.timer"
|
|
365
|
+
" && systemctl start vault-backup.timer"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# ------------------------------------------------------------------
|
|
369
|
+
# Helpers
|
|
370
|
+
|
|
371
|
+
def _write_remote(self, remote_path: str, content: str) -> None:
|
|
372
|
+
assert self._ssh
|
|
373
|
+
sftp = self._ssh.open_sftp()
|
|
374
|
+
try:
|
|
375
|
+
import io
|
|
376
|
+
sftp.putfo(io.BytesIO(content.encode()), remote_path)
|
|
377
|
+
finally:
|
|
378
|
+
sftp.close()
|
|
379
|
+
|
|
380
|
+
def _exec(self, cmd: str) -> str:
|
|
381
|
+
assert self._ssh
|
|
382
|
+
_, stdout, stderr = self._ssh.exec_command(cmd)
|
|
383
|
+
exit_code = stdout.channel.recv_exit_status()
|
|
384
|
+
output = stdout.read().decode()
|
|
385
|
+
if exit_code != 0:
|
|
386
|
+
raise RuntimeError(f"Command failed ({exit_code}): {cmd}\n{stderr.read().decode()}")
|
|
387
|
+
return output
|