ign8saas 0.1.1__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,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: ign8saas
3
+ Version: 0.1.1
4
+ Summary: Ignite a multi-tenant SaaS control plane (Vaultwarden + wg-portal) fronted by Caddy on Hetzner Cloud.
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Provides-Extra: dev
16
+ Requires-Dist: cloudflare (>=3)
17
+ Requires-Dist: cryptography (>=42)
18
+ Requires-Dist: hcloud (>=2)
19
+ Requires-Dist: mypy (>=1.10) ; extra == "dev"
20
+ Requires-Dist: paramiko (>=3)
21
+ Requires-Dist: pydantic (>=2)
22
+ Requires-Dist: pydantic-settings (>=2)
23
+ Requires-Dist: pytest (>=8) ; extra == "dev"
24
+ Requires-Dist: rich (>=13)
25
+ Requires-Dist: ruff (>=0.4) ; extra == "dev"
26
+ Requires-Dist: typer (>=0.12)
27
+ Description-Content-Type: text/markdown
28
+
29
+ # ign8saas
30
+
31
+ Ignite a multi-tenant SaaS control plane on Hetzner Cloud. One VPS runs
32
+ **Caddy** (TLS termination + wildcard certs via Cloudflare DNS-01),
33
+ **Vaultwarden** (Bitwarden-compatible password vault) and **wg-portal**
34
+ (WireGuard admin UI) — reachable at three subdomains of your parent zone.
35
+
36
+ ## Quickstart
37
+
38
+ ```bash
39
+ cp .env.example .env # fill in Hetzner + Cloudflare credentials
40
+ ign8saas up # provision server + DNS + docker stack
41
+ ```
42
+
43
+ ## What gets provisioned
44
+
45
+ For `IGN8_DOMAIN=traeck.it` you get:
46
+
47
+ | Subdomain | Purpose |
48
+ |-------------------------------|------------------------------------------------|
49
+ | `saas.traeck.it` | landing page ("SaaS control plane — coming soon") |
50
+ | `*.vaultwarden.traeck.it` | Vaultwarden (vanity slug redirect on subdomain) |
51
+ | `*.vpn.traeck.it` | wg-portal (WireGuard admin UI) |
52
+
53
+ Underneath, on a single Hetzner `cax11` VPS in `fsn1`:
54
+
55
+ - Docker + Compose plugin (installed via `get.docker.com`)
56
+ - `caddy` (`ghcr.io/caddybuilds/caddy-cloudflare:latest`) — ports 80/443, wildcard certs via DNS-01
57
+ - `vaultwarden` (`vaultwarden/server:latest`) — signups disabled, invitations enabled
58
+ - `wg-portal` (`ghcr.io/h44z/wg-portal:latest`) — port 51820/udp exposed for WireGuard
59
+ - Hetzner firewall: 22 / 80 / 443 / 51820-udp only
60
+
61
+ ## State
62
+
63
+ Local state lives under `.ign8saas/` (gitignored):
64
+
65
+ - `.ign8saas/keys/ign8saas` — SSH private key for server access
66
+ - `.ign8saas/state.json` — Hetzner IDs, IPv4
67
+ - `.ign8saas/vaultwarden-init.json` — Vaultwarden admin token + wg-portal admin creds (chmod 600, **keep safe**)
68
+
69
+ ## Status
70
+
71
+ This scaffold delivers the **shared infra** only. Per-customer onboarding
72
+ (`add-customer`, `del-customer`) is stubbed — see `CLAUDE.md` for the design
73
+ notes and the roadmap.
74
+
75
+ See [CLAUDE.md](CLAUDE.md) for architecture details.
76
+
@@ -0,0 +1,47 @@
1
+ # ign8saas
2
+
3
+ Ignite a multi-tenant SaaS control plane on Hetzner Cloud. One VPS runs
4
+ **Caddy** (TLS termination + wildcard certs via Cloudflare DNS-01),
5
+ **Vaultwarden** (Bitwarden-compatible password vault) and **wg-portal**
6
+ (WireGuard admin UI) — reachable at three subdomains of your parent zone.
7
+
8
+ ## Quickstart
9
+
10
+ ```bash
11
+ cp .env.example .env # fill in Hetzner + Cloudflare credentials
12
+ ign8saas up # provision server + DNS + docker stack
13
+ ```
14
+
15
+ ## What gets provisioned
16
+
17
+ For `IGN8_DOMAIN=traeck.it` you get:
18
+
19
+ | Subdomain | Purpose |
20
+ |-------------------------------|------------------------------------------------|
21
+ | `saas.traeck.it` | landing page ("SaaS control plane — coming soon") |
22
+ | `*.vaultwarden.traeck.it` | Vaultwarden (vanity slug redirect on subdomain) |
23
+ | `*.vpn.traeck.it` | wg-portal (WireGuard admin UI) |
24
+
25
+ Underneath, on a single Hetzner `cax11` VPS in `fsn1`:
26
+
27
+ - Docker + Compose plugin (installed via `get.docker.com`)
28
+ - `caddy` (`ghcr.io/caddybuilds/caddy-cloudflare:latest`) — ports 80/443, wildcard certs via DNS-01
29
+ - `vaultwarden` (`vaultwarden/server:latest`) — signups disabled, invitations enabled
30
+ - `wg-portal` (`ghcr.io/h44z/wg-portal:latest`) — port 51820/udp exposed for WireGuard
31
+ - Hetzner firewall: 22 / 80 / 443 / 51820-udp only
32
+
33
+ ## State
34
+
35
+ Local state lives under `.ign8saas/` (gitignored):
36
+
37
+ - `.ign8saas/keys/ign8saas` — SSH private key for server access
38
+ - `.ign8saas/state.json` — Hetzner IDs, IPv4
39
+ - `.ign8saas/vaultwarden-init.json` — Vaultwarden admin token + wg-portal admin creds (chmod 600, **keep safe**)
40
+
41
+ ## Status
42
+
43
+ This scaffold delivers the **shared infra** only. Per-customer onboarding
44
+ (`add-customer`, `del-customer`) is stubbed — see `CLAUDE.md` for the design
45
+ notes and the roadmap.
46
+
47
+ See [CLAUDE.md](CLAUDE.md) for architecture details.
@@ -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 = "ign8saas"
7
+ version = "0.1.1"
8
+ description = "Ignite a multi-tenant SaaS control plane (Vaultwarden + wg-portal) fronted by Caddy on Hetzner Cloud."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
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
+ ign8saas = "ign8saas.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 = "ign8saas", 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"]
@@ -0,0 +1 @@
1
+ """ign8saas — multi-tenant SaaS control plane provisioner."""
@@ -0,0 +1,179 @@
1
+ """CLI entry point: `ign8saas up` provisions the shared SaaS control plane."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+
12
+ from ign8saas.config import Config
13
+ from ign8saas.dns.cloudflare import CloudflareDNS
14
+ from ign8saas.infra import hetzner
15
+ from ign8saas.keys import save_keypair
16
+ from ign8saas.saas.setup import SaaSSetup
17
+
18
+ app = typer.Typer(
19
+ name="ign8saas",
20
+ help="Ignite a multi-tenant SaaS control plane (Vaultwarden + wg-portal).",
21
+ invoke_without_command=True,
22
+ )
23
+ console = Console()
24
+
25
+ _STATE_DIR = Path(".ign8saas")
26
+ _KEYS_DIR = _STATE_DIR / "keys"
27
+ _STATE_FILE = _STATE_DIR / "state.json"
28
+ _INIT_FILE = _STATE_DIR / "vaultwarden-init.json"
29
+
30
+
31
+ @app.callback(invoke_without_command=True)
32
+ def _main(
33
+ ctx: typer.Context,
34
+ env_file: Path = typer.Option(Path(".env"), "--env-file", hidden=True),
35
+ ) -> None:
36
+ if env_file.exists():
37
+ for line in env_file.read_text().splitlines():
38
+ line = line.strip()
39
+ if not line or line.startswith("#") or "=" not in line:
40
+ continue
41
+ k, _, v = line.partition("=")
42
+ os.environ.setdefault(k.strip(), v.strip())
43
+ if ctx.invoked_subcommand is None:
44
+ _print_status()
45
+
46
+
47
+ @app.command()
48
+ def up(
49
+ domain: str = typer.Option("traeck.it", envvar="IGN8_DOMAIN"),
50
+ admin_email: str = typer.Option(..., envvar="IGN8_ADMIN_EMAIL"),
51
+ hetzner_token: str = typer.Option(..., envvar="IGN8_HETZNER_TOKEN"),
52
+ hetzner_server_type: str = typer.Option("cax11", envvar="IGN8_HETZNER_SERVER_TYPE"),
53
+ hetzner_location: str = typer.Option("fsn1", envvar="IGN8_HETZNER_LOCATION"),
54
+ hetzner_image: str = typer.Option("ubuntu-24.04", envvar="IGN8_HETZNER_IMAGE"),
55
+ cloudflare_email: str = typer.Option(..., envvar="IGN8_CLOUDFLARE_EMAIL"),
56
+ cloudflare_api_key: str = typer.Option(..., envvar="IGN8_CLOUDFLARE_API_KEY"),
57
+ cloudflare_zone_id: str = typer.Option(..., envvar="IGN8_CLOUDFLARE_ZONE_ID"),
58
+ cloudflare_dns_edit_token: str = typer.Option(
59
+ "", envvar="IGN8_CLOUDFLARE_DNS_EDIT_TOKEN"
60
+ ),
61
+ ) -> None:
62
+ """Provision Hetzner + Cloudflare + Docker stack for the SaaS control plane."""
63
+ cfg = Config(
64
+ domain=domain,
65
+ admin_email=admin_email,
66
+ hetzner_token=hetzner_token,
67
+ hetzner_server_type=hetzner_server_type,
68
+ hetzner_location=hetzner_location,
69
+ hetzner_image=hetzner_image,
70
+ cloudflare_email=cloudflare_email,
71
+ cloudflare_api_key=cloudflare_api_key,
72
+ cloudflare_zone_id=cloudflare_zone_id,
73
+ cloudflare_dns_edit_token=cloudflare_dns_edit_token,
74
+ )
75
+
76
+ console.print("[bold green]→[/] Generating SSH keypair...")
77
+ priv_path, pub_path = save_keypair(_KEYS_DIR)
78
+ public_key = pub_path.read_text().strip()
79
+
80
+ console.print("[bold green]→[/] Provisioning Hetzner server...")
81
+ ips = hetzner.provision(cfg, public_key, _STATE_FILE)
82
+ ipv4 = ips["ipv4"]
83
+ console.print(f" Server IP: [cyan]{ipv4}[/]")
84
+
85
+ console.print("[bold green]→[/] Creating Cloudflare DNS records...")
86
+ dns = CloudflareDNS(
87
+ cfg.cloudflare_email, cfg.cloudflare_api_key, cfg.cloudflare_zone_id, cfg.domain
88
+ )
89
+ dns.provision(ipv4)
90
+ for label in ("saas", "*.vaultwarden", "*.vpn"):
91
+ console.print(f" [dim]A[/] {label}.{cfg.domain} → [cyan]{ipv4}[/]")
92
+
93
+ console.print("[bold green]→[/] Connecting to server and installing SaaS stack...")
94
+ setup = SaaSSetup(cfg, ipv4, priv_path).connect()
95
+ try:
96
+ creds = setup.run()
97
+ finally:
98
+ setup.disconnect()
99
+
100
+ _INIT_FILE.parent.mkdir(parents=True, exist_ok=True)
101
+ _INIT_FILE.write_text(json.dumps(creds, indent=2))
102
+ _INIT_FILE.chmod(0o600)
103
+
104
+ console.print(
105
+ Panel(
106
+ f"[bold]Landing:[/] https://saas.{cfg.domain}\n"
107
+ f"[bold]Vaultwarden:[/] https://vaultwarden.{cfg.domain}\n"
108
+ f"[bold]wg-portal:[/] https://vpn.{cfg.domain}\n\n"
109
+ f"Credentials saved to [cyan]{_INIT_FILE}[/] (chmod 600).\n"
110
+ "Certificates may take a minute to issue via Cloudflare DNS-01.",
111
+ title="[green]SaaS control plane ready[/]",
112
+ )
113
+ )
114
+
115
+
116
+ @app.command()
117
+ def destroy(
118
+ hetzner_token: str = typer.Option(..., envvar="IGN8_HETZNER_TOKEN"),
119
+ cloudflare_email: str = typer.Option(..., envvar="IGN8_CLOUDFLARE_EMAIL"),
120
+ cloudflare_api_key: str = typer.Option(..., envvar="IGN8_CLOUDFLARE_API_KEY"),
121
+ cloudflare_zone_id: str = typer.Option(..., envvar="IGN8_CLOUDFLARE_ZONE_ID"),
122
+ domain: str = typer.Option("traeck.it", envvar="IGN8_DOMAIN"),
123
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
124
+ ) -> None:
125
+ """(TODO) Destroy the Hetzner server and remove DNS records."""
126
+ raise NotImplementedError(
127
+ "destroy is not implemented yet; delete the Hetzner server and Cloudflare"
128
+ " records manually for now."
129
+ )
130
+
131
+
132
+ @app.command(name="add-customer")
133
+ def add_customer(
134
+ slug: str = typer.Argument(..., help="Customer slug (used as vanity subdomain)"),
135
+ email: str = typer.Option(..., "--email", help="Customer email address"),
136
+ ) -> None:
137
+ """(TODO) Onboard a new customer (Vaultwarden invite + wg-portal peer)."""
138
+ raise NotImplementedError(
139
+ "add-customer is not implemented yet; use the Vaultwarden admin UI and"
140
+ " wg-portal admin UI directly for now."
141
+ )
142
+
143
+
144
+ @app.command(name="del-customer")
145
+ def del_customer(
146
+ slug: str = typer.Argument(..., help="Customer slug to remove"),
147
+ ) -> None:
148
+ """(TODO) Off-board a customer (revoke vault user + delete wg peer)."""
149
+ raise NotImplementedError(
150
+ "del-customer is not implemented yet; use the Vaultwarden admin UI and"
151
+ " wg-portal admin UI directly for now."
152
+ )
153
+
154
+
155
+ def _load_state() -> dict[str, Any]:
156
+ if not _STATE_FILE.exists():
157
+ console.print("[red]No state found. Run `ign8saas up` first.[/]")
158
+ raise typer.Exit(1)
159
+ return json.loads(_STATE_FILE.read_text()) # type: ignore[no-any-return]
160
+
161
+
162
+ def _print_status() -> None:
163
+ if _STATE_FILE.exists():
164
+ state = json.loads(_STATE_FILE.read_text())
165
+ console.print(
166
+ Panel(
167
+ f"[bold]Server:[/] {state.get('ipv4', '?')}\n"
168
+ f"[bold]Domain:[/] {state.get('domain', '?')}\n"
169
+ f"[bold]Customers:[/] {len(state.get('customers') or {})}",
170
+ title="ign8saas status",
171
+ )
172
+ )
173
+ else:
174
+ console.print(
175
+ Panel(
176
+ "No SaaS control plane provisioned. Run [cyan]ign8saas up[/] to get started.",
177
+ title="ign8saas",
178
+ )
179
+ )
@@ -0,0 +1,33 @@
1
+ from pydantic import field_validator
2
+ from pydantic_settings import BaseSettings, SettingsConfigDict
3
+
4
+
5
+ class Config(BaseSettings):
6
+ model_config = SettingsConfigDict(
7
+ env_prefix="IGN8_",
8
+ env_file=".env",
9
+ extra="ignore",
10
+ )
11
+
12
+ # Required — parent domain (three records are created underneath it)
13
+ domain: str = "traeck.it"
14
+ admin_email: str
15
+
16
+ # Hetzner Cloud
17
+ hetzner_token: str
18
+ hetzner_server_type: str = "cax11"
19
+ hetzner_location: str = "fsn1"
20
+ hetzner_image: str = "ubuntu-24.04"
21
+
22
+ # Cloudflare (Global API Key auth — scoped tokens don't cover traeck.it)
23
+ cloudflare_email: str
24
+ cloudflare_api_key: str
25
+ cloudflare_zone_id: str
26
+
27
+ # Separate scoped token with Zone/DNS Edit for Caddy's DNS-01 challenge
28
+ cloudflare_dns_edit_token: str = ""
29
+
30
+ @field_validator("domain")
31
+ @classmethod
32
+ def strip_trailing_dot(cls, v: str) -> str:
33
+ return v.rstrip(".")
File without changes
@@ -0,0 +1,55 @@
1
+ """Create DNS A records for the SaaS control plane in the parent zone.
2
+
3
+ Three records are managed idempotently:
4
+ - saas.<domain> → ipv4
5
+ - *.vaultwarden.<domain> → ipv4
6
+ - *.vpn.<domain> → ipv4
7
+
8
+ Uses the Cloudflare Global API Key (X-Auth-Email + X-Auth-Key) because our
9
+ default scoped tokens don't cover the traeck.it zone.
10
+ """
11
+
12
+ from typing import Any
13
+
14
+ import cloudflare
15
+
16
+
17
+ class CloudflareDNS:
18
+ def __init__(self, email: str, api_key: str, zone_id: str, domain: str) -> None:
19
+ # Global API Key auth. cloudflare-python accepts (api_email, api_key).
20
+ self._cf = cloudflare.Cloudflare(api_email=email, api_key=api_key)
21
+ self._zone = zone_id
22
+ self._domain = domain
23
+
24
+ def _record_names(self) -> list[str]:
25
+ d = self._domain
26
+ return [f"saas.{d}", f"*.vaultwarden.{d}", f"*.vpn.{d}"]
27
+
28
+ def provision(self, ipv4: str) -> None:
29
+ targets = self._record_names()
30
+ existing_by_name: dict[str, Any] = {}
31
+ for existing in self._cf.dns.records.list(zone_id=self._zone).result or []:
32
+ if existing.name in targets:
33
+ # Keep the last one seen — duplicates are removed on update below.
34
+ existing_by_name.setdefault(existing.name, existing)
35
+
36
+ for name in targets:
37
+ payload: dict[str, Any] = {
38
+ "type": "A",
39
+ "name": name,
40
+ "content": ipv4,
41
+ "proxied": False,
42
+ "ttl": 1,
43
+ }
44
+ if name in existing_by_name:
45
+ self._cf.dns.records.update(
46
+ existing_by_name[name].id, zone_id=self._zone, **payload
47
+ )
48
+ else:
49
+ self._cf.dns.records.create(zone_id=self._zone, **payload)
50
+
51
+ def destroy(self) -> None:
52
+ targets = set(self._record_names())
53
+ for existing in self._cf.dns.records.list(zone_id=self._zone).result or []:
54
+ if existing.name in targets:
55
+ self._cf.dns.records.delete(existing.id, zone_id=self._zone)
File without changes
@@ -0,0 +1,116 @@
1
+ """Hetzner Cloud provisioner: SSH key, firewall, and server via the hcloud SDK."""
2
+
3
+ import ipaddress
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from hcloud import Client
9
+ from hcloud.firewalls.domain import FirewallRule
10
+ from hcloud.images import Image
11
+ from hcloud.locations import Location
12
+ from hcloud.server_types import ServerType
13
+
14
+ from ign8saas.config import Config
15
+
16
+ _SSH_KEY_NAME = "ign8saas-key"
17
+ _FW_NAME = "ign8saas-fw"
18
+
19
+
20
+ def _server_name(domain: str) -> str:
21
+ """`saas-<first-label>`; e.g. traeck.it → saas-traeck."""
22
+ first = (domain.split(".") or ["default"])[0] or "default"
23
+ return f"saas-{first}"
24
+
25
+
26
+ def provision(cfg: Config, public_key: str, state_path: Path) -> dict[str, str]:
27
+ client = Client(token=cfg.hetzner_token)
28
+
29
+ ssh_key = _ensure_ssh_key(client, public_key)
30
+ firewall = _ensure_firewall(client)
31
+ server = _ensure_server(client, cfg, ssh_key, firewall)
32
+
33
+ ipv4 = server.public_net.ipv4.ip if server.public_net.ipv4 else ""
34
+ ipv6 = ""
35
+ if server.public_net.ipv6:
36
+ net = ipaddress.IPv6Network(server.public_net.ipv6.ip)
37
+ ipv6 = str(net.network_address + 1)
38
+
39
+ hostname = f"saas.{cfg.domain}"
40
+ if ipv4:
41
+ server.change_dns_ptr(ip=ipv4, dns_ptr=hostname).wait_until_finished()
42
+
43
+ state: dict[str, object] = {
44
+ "server_id": server.id,
45
+ "server_name": _server_name(cfg.domain),
46
+ "ssh_key_id": ssh_key.id,
47
+ "firewall_id": firewall.id,
48
+ "ipv4": ipv4,
49
+ "ipv6": ipv6,
50
+ "domain": cfg.domain,
51
+ "customers": {},
52
+ }
53
+ state_path.parent.mkdir(parents=True, exist_ok=True)
54
+ state_path.write_text(json.dumps(state, indent=2))
55
+
56
+ return {"ipv4": ipv4, "ipv6": ipv6}
57
+
58
+
59
+ def destroy(state_path: Path, hetzner_token: str, domain: str) -> None:
60
+ client = Client(token=hetzner_token)
61
+ name = _server_name(domain)
62
+
63
+ for server in client.servers.get_all(name=name):
64
+ server.delete().wait_until_finished()
65
+
66
+ for fw in client.firewalls.get_all(name=_FW_NAME):
67
+ fw.delete()
68
+
69
+ for key in client.ssh_keys.get_all(name=_SSH_KEY_NAME):
70
+ key.delete()
71
+
72
+ state_path.unlink(missing_ok=True)
73
+
74
+
75
+ def _ensure_ssh_key(client: Client, public_key: str) -> Any:
76
+ existing = client.ssh_keys.get_all(name=_SSH_KEY_NAME)
77
+ if existing:
78
+ key = existing[0]
79
+ if (key.public_key or "").strip() != public_key.strip():
80
+ key.delete()
81
+ else:
82
+ return key
83
+ return client.ssh_keys.create(name=_SSH_KEY_NAME, public_key=public_key)
84
+
85
+
86
+ def _ensure_firewall(client: Client) -> Any:
87
+ existing = client.firewalls.get_all(name=_FW_NAME)
88
+ if existing:
89
+ return existing[0]
90
+ rules = [
91
+ FirewallRule(direction="in", protocol="tcp", port="22", source_ips=["0.0.0.0/0", "::/0"]),
92
+ FirewallRule(direction="in", protocol="tcp", port="80", source_ips=["0.0.0.0/0", "::/0"]),
93
+ FirewallRule(direction="in", protocol="tcp", port="443", source_ips=["0.0.0.0/0", "::/0"]),
94
+ FirewallRule(
95
+ direction="in", protocol="udp", port="51820", source_ips=["0.0.0.0/0", "::/0"]
96
+ ),
97
+ ]
98
+ return client.firewalls.create(name=_FW_NAME, rules=rules).firewall
99
+
100
+
101
+ def _ensure_server(client: Client, cfg: Config, ssh_key: Any, firewall: Any) -> Any:
102
+ name = _server_name(cfg.domain)
103
+ existing = client.servers.get_all(name=name)
104
+ if existing:
105
+ return existing[0]
106
+ response = client.servers.create(
107
+ name=name,
108
+ server_type=ServerType(name=cfg.hetzner_server_type),
109
+ image=Image(name=cfg.hetzner_image),
110
+ location=Location(name=cfg.hetzner_location),
111
+ ssh_keys=[ssh_key],
112
+ firewalls=[firewall],
113
+ labels={"managed-by": "ign8saas", "domain": cfg.domain},
114
+ )
115
+ response.action.wait_until_finished()
116
+ return response.server
@@ -0,0 +1,50 @@
1
+ import base64
2
+ from pathlib import Path
3
+
4
+ from cryptography.hazmat.primitives import serialization
5
+ from cryptography.hazmat.primitives.asymmetric import rsa
6
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
7
+
8
+
9
+ def save_keypair(directory: Path, name: str = "ign8saas") -> tuple[Path, Path]:
10
+ """Generate RSA SSH keypair if absent, return (private_path, public_path)."""
11
+ directory.mkdir(parents=True, exist_ok=True)
12
+ priv_path = directory / name
13
+ pub_path = directory / f"{name}.pub"
14
+
15
+ if not priv_path.exists():
16
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
17
+ priv_path.write_text(
18
+ private_key.private_bytes(
19
+ serialization.Encoding.PEM,
20
+ serialization.PrivateFormat.OpenSSH,
21
+ serialization.NoEncryption(),
22
+ ).decode()
23
+ )
24
+ priv_path.chmod(0o600)
25
+ pub_path.write_text(
26
+ private_key.public_key().public_bytes(
27
+ serialization.Encoding.OpenSSH,
28
+ serialization.PublicFormat.OpenSSH,
29
+ ).decode()
30
+ )
31
+ return priv_path, pub_path
32
+
33
+
34
+ def generate_wg_keypair() -> tuple[str, str]:
35
+ """Return (private_key_b64, public_key_b64) for a WireGuard peer."""
36
+ private_key = X25519PrivateKey.generate()
37
+ private_b64 = base64.b64encode(
38
+ private_key.private_bytes(
39
+ serialization.Encoding.Raw,
40
+ serialization.PrivateFormat.Raw,
41
+ serialization.NoEncryption(),
42
+ )
43
+ ).decode()
44
+ public_b64 = base64.b64encode(
45
+ private_key.public_key().public_bytes(
46
+ serialization.Encoding.Raw,
47
+ serialization.PublicFormat.Raw,
48
+ )
49
+ ).decode()
50
+ return private_b64, public_b64
File without changes
@@ -0,0 +1,266 @@
1
+ """SSH into the provisioned server and install Docker + the SaaS compose stack.
2
+
3
+ Deploys three containers behind Caddy:
4
+ - vaultwarden — Bitwarden-compatible password vault
5
+ - wg-portal — WireGuard admin UI (exposes 51820/udp publicly)
6
+ - caddy — TLS termination + reverse proxy + wildcard certs (DNS-01)
7
+ """
8
+
9
+ import io
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ import paramiko # type: ignore[import-untyped]
15
+
16
+ from ign8saas.config import Config
17
+
18
+ _COMPOSE_YML_TMPL = """\
19
+ services:
20
+ caddy:
21
+ image: ghcr.io/caddybuilds/caddy-cloudflare:latest
22
+ restart: unless-stopped
23
+ ports:
24
+ - "0.0.0.0:80:80"
25
+ - "0.0.0.0:443:443"
26
+ - "0.0.0.0:443:443/udp"
27
+ environment:
28
+ # Scoped token when set; otherwise the plugin falls back to the
29
+ # Global API Key (email + key) form written into the Caddyfile.
30
+ CLOUDFLARE_API_TOKEN: "{cloudflare_dns_edit_token}"
31
+ CLOUDFLARE_EMAIL: "{cloudflare_email}"
32
+ CLOUDFLARE_API_KEY: "{cloudflare_api_key}"
33
+ ACME_EMAIL: "{admin_email}"
34
+ volumes:
35
+ - /opt/saas/Caddyfile:/etc/caddy/Caddyfile:ro
36
+ - caddy_data:/data
37
+ - caddy_config:/config
38
+ depends_on:
39
+ - vaultwarden
40
+ - wg-portal
41
+
42
+ vaultwarden:
43
+ image: vaultwarden/server:latest
44
+ restart: unless-stopped
45
+ environment:
46
+ DOMAIN: "https://vaultwarden.{domain}"
47
+ SIGNUPS_ALLOWED: "false"
48
+ INVITATIONS_ALLOWED: "true"
49
+ ADMIN_TOKEN: "{vaultwarden_admin_token}"
50
+ volumes:
51
+ - vaultwarden_data:/data
52
+ ports:
53
+ - "127.0.0.1:11080:80"
54
+
55
+ wg-portal:
56
+ image: ghcr.io/h44z/wg-portal:latest
57
+ restart: unless-stopped
58
+ cap_add:
59
+ - NET_ADMIN
60
+ environment:
61
+ WG_PORTAL_ADMIN_USER: "{wg_portal_admin_user}"
62
+ WG_PORTAL_ADMIN_PASSWORD: "{wg_portal_admin_password}"
63
+ EXTERNAL_URL: "https://vpn.{domain}"
64
+ volumes:
65
+ - wgportal_data:/app/data
66
+ - wgportal_config:/app/config
67
+ ports:
68
+ - "127.0.0.1:11081:8888"
69
+ - "51820:51820/udp"
70
+
71
+ volumes:
72
+ caddy_data:
73
+ caddy_config:
74
+ vaultwarden_data:
75
+ wgportal_data:
76
+ wgportal_config:
77
+ """
78
+
79
+ # Caddy config. The vanity-slug matcher on `*.vaultwarden.<domain>` redirects to
80
+ # the canonical vault so a link like `alice-at-example-com.vaultwarden.<domain>`
81
+ # opens the login page with the email prefilled.
82
+ _CADDYFILE_TMPL = """\
83
+ {{
84
+ email {admin_email}
85
+ }}
86
+
87
+ saas.{domain} {{
88
+ respond "SaaS control plane — coming soon" 200
89
+ tls {{
90
+ {dns_auth}
91
+ }}
92
+ }}
93
+
94
+ vaultwarden.{domain}, *.vaultwarden.{domain} {{
95
+ tls {{
96
+ {dns_auth}
97
+ }}
98
+
99
+ @vanity host_regexp vanity ^(?P<slug>(?!vaultwarden$)[^.]+)\\.vaultwarden\\.{domain_re}$
100
+ redir @vanity https://vaultwarden.{domain}/#/login?email={{re.vanity.slug}} 302
101
+
102
+ reverse_proxy vaultwarden:80
103
+ }}
104
+
105
+ vpn.{domain}, *.vpn.{domain} {{
106
+ tls {{
107
+ {dns_auth}
108
+ }}
109
+ reverse_proxy wg-portal:8888
110
+ }}
111
+ """
112
+
113
+
114
+ class SaaSSetup:
115
+ def __init__(self, cfg: Config, host: str, private_key_path: Path) -> None:
116
+ self._cfg = cfg
117
+ self._host = host
118
+ self._key_path = str(private_key_path)
119
+ self._ssh: Optional[paramiko.SSHClient] = None
120
+
121
+ # ------------------------------------------------------------------
122
+ # Public API
123
+
124
+ def connect(self, retries: int = 30, delay: int = 10) -> "SaaSSetup":
125
+ """Retry SSH for up to ~5 minutes while cloud-init finishes booting."""
126
+ client = paramiko.SSHClient()
127
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
128
+ key = paramiko.RSAKey.from_private_key_file(self._key_path)
129
+ for attempt in range(retries):
130
+ try:
131
+ client.connect(self._host, username="root", pkey=key, timeout=15)
132
+ self._ssh = client
133
+ return self
134
+ except Exception:
135
+ if attempt == retries - 1:
136
+ raise
137
+ time.sleep(delay)
138
+ return self
139
+
140
+ def disconnect(self) -> None:
141
+ if self._ssh:
142
+ self._ssh.close()
143
+ self._ssh = None
144
+
145
+ def run(self) -> dict[str, str]:
146
+ """Install stack; return generated credentials for cli.py to persist."""
147
+ assert self._ssh, "Call connect() first"
148
+
149
+ self._install_base_packages()
150
+ self._install_docker()
151
+
152
+ vaultwarden_admin_token = self._rand_token(48)
153
+ wg_portal_admin_user = "admin"
154
+ wg_portal_admin_password = self._rand_token(24)
155
+
156
+ self._write_stack(
157
+ vaultwarden_admin_token=vaultwarden_admin_token,
158
+ wg_portal_admin_user=wg_portal_admin_user,
159
+ wg_portal_admin_password=wg_portal_admin_password,
160
+ )
161
+ self._compose_up()
162
+ self._wait_for_vaultwarden()
163
+
164
+ return {
165
+ "vaultwarden_admin_token": vaultwarden_admin_token,
166
+ "wg_portal_admin_user": wg_portal_admin_user,
167
+ "wg_portal_admin_password": wg_portal_admin_password,
168
+ }
169
+
170
+ # ------------------------------------------------------------------
171
+ # Steps
172
+
173
+ def _install_base_packages(self) -> None:
174
+ self._exec("apt-get update -qq")
175
+ self._exec(
176
+ "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq"
177
+ " ca-certificates curl gnupg"
178
+ )
179
+
180
+ def _install_docker(self) -> None:
181
+ already = self._exec("command -v docker >/dev/null 2>&1 && echo yes || echo no").strip()
182
+ if already != "yes":
183
+ # Official convenience script — installs engine + compose plugin.
184
+ self._exec("curl -fsSL https://get.docker.com | sh")
185
+ self._exec("systemctl enable --now docker")
186
+
187
+ def _write_stack(
188
+ self,
189
+ vaultwarden_admin_token: str,
190
+ wg_portal_admin_user: str,
191
+ wg_portal_admin_password: str,
192
+ ) -> None:
193
+ self._exec("mkdir -p /opt/saas")
194
+
195
+ compose = _COMPOSE_YML_TMPL.format(
196
+ domain=self._cfg.domain,
197
+ admin_email=self._cfg.admin_email,
198
+ cloudflare_dns_edit_token=self._cfg.cloudflare_dns_edit_token,
199
+ cloudflare_email=self._cfg.cloudflare_email,
200
+ cloudflare_api_key=self._cfg.cloudflare_api_key,
201
+ vaultwarden_admin_token=vaultwarden_admin_token,
202
+ wg_portal_admin_user=wg_portal_admin_user,
203
+ wg_portal_admin_password=wg_portal_admin_password,
204
+ )
205
+ self._write_remote("/opt/saas/docker-compose.yml", compose)
206
+ self._exec("chmod 600 /opt/saas/docker-compose.yml")
207
+
208
+ # Escape the domain for use inside a Caddy regex (dots become literal).
209
+ domain_re = self._cfg.domain.replace(".", r"\.")
210
+ # Pick the DNS-01 auth form: scoped token if configured, else the
211
+ # Global API Key fallback the plugin also accepts.
212
+ if self._cfg.cloudflare_dns_edit_token:
213
+ dns_auth = "dns cloudflare {env.CLOUDFLARE_API_TOKEN}"
214
+ else:
215
+ dns_auth = "dns cloudflare {env.CLOUDFLARE_EMAIL} {env.CLOUDFLARE_API_KEY}"
216
+ caddyfile = _CADDYFILE_TMPL.format(
217
+ domain=self._cfg.domain,
218
+ domain_re=domain_re,
219
+ admin_email=self._cfg.admin_email,
220
+ dns_auth=dns_auth,
221
+ )
222
+ self._write_remote("/opt/saas/Caddyfile", caddyfile)
223
+
224
+ def _compose_up(self) -> None:
225
+ self._exec("cd /opt/saas && docker compose pull")
226
+ self._exec("cd /opt/saas && docker compose up -d")
227
+
228
+ def _wait_for_vaultwarden(self, timeout_s: int = 60) -> None:
229
+ deadline = time.time() + timeout_s
230
+ last: str = ""
231
+ while time.time() < deadline:
232
+ last = self._exec(
233
+ "curl -s -o /dev/null -w '%{http_code}'"
234
+ " http://127.0.0.1:11080/alive || true"
235
+ ).strip()
236
+ if last == "200":
237
+ return
238
+ time.sleep(3)
239
+ raise RuntimeError(
240
+ f"Vaultwarden /alive did not return 200 within {timeout_s}s (last: {last!r})"
241
+ )
242
+
243
+ # ------------------------------------------------------------------
244
+ # Helpers
245
+
246
+ def _rand_token(self, byte_len: int) -> str:
247
+ # Generated on the server to avoid dragging OpenSSL requirements into the
248
+ # local process; matches the spec ("openssl rand -base64 48").
249
+ return self._exec(f"openssl rand -base64 {byte_len}").strip()
250
+
251
+ def _write_remote(self, remote_path: str, content: str) -> None:
252
+ assert self._ssh
253
+ sftp = self._ssh.open_sftp()
254
+ try:
255
+ sftp.putfo(io.BytesIO(content.encode()), remote_path)
256
+ finally:
257
+ sftp.close()
258
+
259
+ def _exec(self, cmd: str) -> str:
260
+ assert self._ssh
261
+ _, stdout, stderr = self._ssh.exec_command(cmd)
262
+ exit_code = stdout.channel.recv_exit_status()
263
+ output: str = stdout.read().decode()
264
+ if exit_code != 0:
265
+ raise RuntimeError(f"Command failed ({exit_code}): {cmd}\n{stderr.read().decode()}")
266
+ return output