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.
- ign8saas-0.1.1/PKG-INFO +76 -0
- ign8saas-0.1.1/README.md +47 -0
- ign8saas-0.1.1/pyproject.toml +48 -0
- ign8saas-0.1.1/src/ign8saas/__init__.py +1 -0
- ign8saas-0.1.1/src/ign8saas/cli.py +179 -0
- ign8saas-0.1.1/src/ign8saas/config.py +33 -0
- ign8saas-0.1.1/src/ign8saas/dns/__init__.py +0 -0
- ign8saas-0.1.1/src/ign8saas/dns/cloudflare.py +55 -0
- ign8saas-0.1.1/src/ign8saas/infra/__init__.py +0 -0
- ign8saas-0.1.1/src/ign8saas/infra/hetzner.py +116 -0
- ign8saas-0.1.1/src/ign8saas/keys.py +50 -0
- ign8saas-0.1.1/src/ign8saas/saas/__init__.py +0 -0
- ign8saas-0.1.1/src/ign8saas/saas/setup.py +266 -0
ign8saas-0.1.1/PKG-INFO
ADDED
|
@@ -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
|
+
|
ign8saas-0.1.1/README.md
ADDED
|
@@ -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
|