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.
@@ -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,9 @@
1
+ # ign8vault
2
+
3
+ Spin up HashiCorp Vault + Consul on Hetzner Cloud in one command.
4
+
5
+ ```bash
6
+ pip install -e .
7
+ cp .env.example .env # fill in credentials
8
+ ign8vault up
9
+ ```
@@ -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