katalon-cli 0.1.20__tar.gz → 0.2.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.
Files changed (21) hide show
  1. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/PKG-INFO +55 -3
  2. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/README.md +54 -2
  3. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/pyproject.toml +1 -1
  4. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/pyproject.toml.orig +1 -1
  5. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/src/katalon_cli/core/compose_gen.py +31 -4
  6. katalon_cli-0.2.1/src/katalon_cli/core/permissions.py +113 -0
  7. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/src/katalon_cli/core/state.py +1 -1
  8. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/src/katalon_cli/main.py +86 -7
  9. katalon_cli-0.2.1/src/katalon_cli/templates/Caddyfile.j2 +3 -0
  10. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/src/katalon_cli/templates/compose.yaml.j2 +21 -0
  11. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/src/katalon_cli/templates/nginx.conf.j2 +6 -6
  12. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/src/katalon_cli/__init__.py +0 -0
  13. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/src/katalon_cli/core/__init__.py +0 -0
  14. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/src/katalon_cli/core/backup.py +0 -0
  15. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/src/katalon_cli/core/checks.py +0 -0
  16. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/src/katalon_cli/core/docker.py +0 -0
  17. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/src/katalon_cli/core/paths.py +0 -0
  18. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/src/katalon_cli/core/reindex.py +0 -0
  19. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/src/katalon_cli/core/release.py +0 -0
  20. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/src/katalon_cli/core/selfupdate.py +0 -0
  21. {katalon_cli-0.1.20 → katalon_cli-0.2.1}/src/katalon_cli/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: katalon-cli
3
- Version: 0.1.20
3
+ Version: 0.2.1
4
4
  Summary: Installer & Updater CLI für Katalon Production-Instanzen
5
5
  Author: Karl Krägelin
6
6
  Author-email: Karl Krägelin <karlkraegelin@gmail.com>
@@ -33,7 +33,7 @@ katalon start / stop / status
33
33
  katalon check-updates # prüft nur, ob ein neues Release verfügbar ist
34
34
  katalon update # interaktives Update, Backup zuerst immer
35
35
  katalon rollback # letztes Backup einspielen
36
- katalon doctor # Docker, Diskspace, Ports prüfen
36
+ katalon doctor # Docker, Diskspace, Ports, Schreibrechte von MEDIA_ROOT prüfen
37
37
  katalon backup # manuelles Backup
38
38
  katalon logs [service]
39
39
  katalon manage <args> # katalon-manage im api-Container (z.B. reset-admin, create-user)
@@ -66,6 +66,57 @@ katalon start
66
66
  katalon status
67
67
  ```
68
68
 
69
+ ## API-Adresse
70
+
71
+ Die REST-API liegt direkt unter der Base-URL, ohne `/api`-Präfix: `<KATALON_BASE_URL>/v1/...`
72
+ (z. B. Login unter `http://127.0.0.1:8080/v1/...` bei `behind-proxy`). Unter `/api/` liegen nur
73
+ die OpenAPI-Dokumentation (`/api/docs`, `/api/openapi.json`) und interne Endpunkte;
74
+ `/api/v1/...` liefert für normale API-Aufrufe 404.
75
+
76
+ ## Medienverzeichnis und Rechte
77
+
78
+ API und Worker laufen im Container als unprivilegierter User (`USER` im Image). `MEDIA_ROOT`
79
+ ist ein Bind-Mount vom Host und muss für diesen User beschreibbar sein. `katalon start`
80
+ (auch beim Install), `update` und `rollback` lesen UID/GID per `id` aus dem api-Container und
81
+ setzen in einem kurzlebigen root-Container desselben Images den Eigentümer aller Einträge
82
+ unter `MEDIA_ROOT`, die noch einem anderen Eigentümer gehören. Dateiinhalte werden nicht
83
+ verändert; ein erneuter Lauf ändert nichts. `katalon doctor` meldet ein nicht beschreibbares
84
+ `MEDIA_ROOT`, bevor Uploads mit HTTP 500 scheitern.
85
+
86
+ ## Domain und HTTPS
87
+
88
+ Für eine lokale Installation bleibt `http://localhost` mit TLS-Modus `none` der Standard.
89
+ Bei einer öffentlichen Domain schlägt der Setup-Wizard `caddy` vor. Du kannst auch nur
90
+ `sammlung.example.org` eingeben; mit Caddy wird daraus `https://sammlung.example.org`.
91
+
92
+ Vor der Installation müssen die DNS-Einträge wirksam sein: Der A-Record zeigt auf die öffentliche
93
+ IPv4-Adresse des Servers. Ein vorhandener AAAA-Record muss auf dessen erreichbare IPv6-Adresse
94
+ zeigen. TCP-Ports 80 und 443 müssen frei und öffentlich erreichbar sein, auch durch Server- und
95
+ Provider-Firewalls sowie gegebenenfalls Portweiterleitungen. Docker und Docker Compose müssen
96
+ installiert sein. Die CLI prüft freie Ports lokal, aber keine öffentliche DNS-Auflösung oder
97
+ Erreichbarkeit von außen.
98
+
99
+ Die TLS-Modi:
100
+
101
+ - `caddy`: Caddy läuft als Docker-Container vor dem internen nginx, belegt Ports 80 und 443,
102
+ beantragt öffentlich vertrauenswürdige Zertifikate und erneuert sie automatisch.
103
+ HTTP wird auf HTTPS umgeleitet. Zertifikate und Zustand liegen dauerhaft in den Docker-Volumes
104
+ `caddy_data` und `caddy_config`, die bei Neustarts, Updates und Rollbacks erhalten bleiben.
105
+ Dafür muss Caddy weiterlaufen, DNS muss stimmen und der Server erreichbar bleiben.
106
+ - `standalone`: nginx verwendet ein selbstsigniertes Zertifikat. Browser zeigen eine Warnung;
107
+ es gibt keine automatische Erneuerung. Eigene Zertifikate können unter `<dir>/certs/`
108
+ als `fullchain.pem` und `privkey.pem` abgelegt werden. Ihre Erneuerung und das anschließende
109
+ Neuladen von nginx musst du selbst einrichten.
110
+ - `behind-proxy`: Ein eigener Reverse-Proxy übernimmt HTTPS und Zertifikate.
111
+ Katalon ist standardmäßig nur auf `127.0.0.1:8080` erreichbar.
112
+ - `none`: HTTP ohne TLS, für lokale Tests oder IP-Adressen.
113
+
114
+ Die erste Zertifikatsausstellung kann nach dem Start kurz dauern. Bei HTTPS-Problemen:
115
+ `katalon logs caddy`. Caddy-Konfiguration und Zertifikatsverwaltung sind in der
116
+ [Caddy-Dokumentation](https://caddyserver.com/docs/automatic-https) beschrieben.
117
+ Die CLI erzeugt `<dir>/Caddyfile` bei Installation, Update und Rollback neu;
118
+ instanzspezifische Compose-Anpassungen gehören in `compose.override.yaml`.
119
+
69
120
  ## Entwicklung
70
121
 
71
122
  ```bash
@@ -83,7 +134,8 @@ uv run pytest
83
134
  nach (`--yes` bricht dann ab), tragen neue optionale Variablen auskommentiert in `.env` ein und
84
135
  warnen vor veralteten Variablen, die noch in der `.env` stehen.
85
136
  - `core/compose_gen.py` + `templates/compose.yaml.j2` + `templates/nginx.conf.j2` — rendert `compose.yaml`
86
- + `nginx.conf` aus Version + TLS-Modus; erzeugt bei `tls_mode=standalone` ein selbstsigniertes
137
+ + `nginx.conf` und bei `tls_mode=caddy` das `Caddyfile` aus Version + TLS-Modus;
138
+ erzeugt bei `tls_mode=standalone` ein selbstsigniertes
87
139
  Zertifikat unter `<dir>/certs/` (eigenes Zertifikat dort ablegen, um es zu ersetzen).
88
140
  `install` fragt zusätzlich `MEDIA_ROOT` ab und legt `<dir>/compose.override.yaml.example` an
89
141
  (instanzspezifische Overrides — umbenennen zu `compose.override.yaml`, wird automatisch eingebunden
@@ -19,7 +19,7 @@ katalon start / stop / status
19
19
  katalon check-updates # prüft nur, ob ein neues Release verfügbar ist
20
20
  katalon update # interaktives Update, Backup zuerst immer
21
21
  katalon rollback # letztes Backup einspielen
22
- katalon doctor # Docker, Diskspace, Ports prüfen
22
+ katalon doctor # Docker, Diskspace, Ports, Schreibrechte von MEDIA_ROOT prüfen
23
23
  katalon backup # manuelles Backup
24
24
  katalon logs [service]
25
25
  katalon manage <args> # katalon-manage im api-Container (z.B. reset-admin, create-user)
@@ -52,6 +52,57 @@ katalon start
52
52
  katalon status
53
53
  ```
54
54
 
55
+ ## API-Adresse
56
+
57
+ Die REST-API liegt direkt unter der Base-URL, ohne `/api`-Präfix: `<KATALON_BASE_URL>/v1/...`
58
+ (z. B. Login unter `http://127.0.0.1:8080/v1/...` bei `behind-proxy`). Unter `/api/` liegen nur
59
+ die OpenAPI-Dokumentation (`/api/docs`, `/api/openapi.json`) und interne Endpunkte;
60
+ `/api/v1/...` liefert für normale API-Aufrufe 404.
61
+
62
+ ## Medienverzeichnis und Rechte
63
+
64
+ API und Worker laufen im Container als unprivilegierter User (`USER` im Image). `MEDIA_ROOT`
65
+ ist ein Bind-Mount vom Host und muss für diesen User beschreibbar sein. `katalon start`
66
+ (auch beim Install), `update` und `rollback` lesen UID/GID per `id` aus dem api-Container und
67
+ setzen in einem kurzlebigen root-Container desselben Images den Eigentümer aller Einträge
68
+ unter `MEDIA_ROOT`, die noch einem anderen Eigentümer gehören. Dateiinhalte werden nicht
69
+ verändert; ein erneuter Lauf ändert nichts. `katalon doctor` meldet ein nicht beschreibbares
70
+ `MEDIA_ROOT`, bevor Uploads mit HTTP 500 scheitern.
71
+
72
+ ## Domain und HTTPS
73
+
74
+ Für eine lokale Installation bleibt `http://localhost` mit TLS-Modus `none` der Standard.
75
+ Bei einer öffentlichen Domain schlägt der Setup-Wizard `caddy` vor. Du kannst auch nur
76
+ `sammlung.example.org` eingeben; mit Caddy wird daraus `https://sammlung.example.org`.
77
+
78
+ Vor der Installation müssen die DNS-Einträge wirksam sein: Der A-Record zeigt auf die öffentliche
79
+ IPv4-Adresse des Servers. Ein vorhandener AAAA-Record muss auf dessen erreichbare IPv6-Adresse
80
+ zeigen. TCP-Ports 80 und 443 müssen frei und öffentlich erreichbar sein, auch durch Server- und
81
+ Provider-Firewalls sowie gegebenenfalls Portweiterleitungen. Docker und Docker Compose müssen
82
+ installiert sein. Die CLI prüft freie Ports lokal, aber keine öffentliche DNS-Auflösung oder
83
+ Erreichbarkeit von außen.
84
+
85
+ Die TLS-Modi:
86
+
87
+ - `caddy`: Caddy läuft als Docker-Container vor dem internen nginx, belegt Ports 80 und 443,
88
+ beantragt öffentlich vertrauenswürdige Zertifikate und erneuert sie automatisch.
89
+ HTTP wird auf HTTPS umgeleitet. Zertifikate und Zustand liegen dauerhaft in den Docker-Volumes
90
+ `caddy_data` und `caddy_config`, die bei Neustarts, Updates und Rollbacks erhalten bleiben.
91
+ Dafür muss Caddy weiterlaufen, DNS muss stimmen und der Server erreichbar bleiben.
92
+ - `standalone`: nginx verwendet ein selbstsigniertes Zertifikat. Browser zeigen eine Warnung;
93
+ es gibt keine automatische Erneuerung. Eigene Zertifikate können unter `<dir>/certs/`
94
+ als `fullchain.pem` und `privkey.pem` abgelegt werden. Ihre Erneuerung und das anschließende
95
+ Neuladen von nginx musst du selbst einrichten.
96
+ - `behind-proxy`: Ein eigener Reverse-Proxy übernimmt HTTPS und Zertifikate.
97
+ Katalon ist standardmäßig nur auf `127.0.0.1:8080` erreichbar.
98
+ - `none`: HTTP ohne TLS, für lokale Tests oder IP-Adressen.
99
+
100
+ Die erste Zertifikatsausstellung kann nach dem Start kurz dauern. Bei HTTPS-Problemen:
101
+ `katalon logs caddy`. Caddy-Konfiguration und Zertifikatsverwaltung sind in der
102
+ [Caddy-Dokumentation](https://caddyserver.com/docs/automatic-https) beschrieben.
103
+ Die CLI erzeugt `<dir>/Caddyfile` bei Installation, Update und Rollback neu;
104
+ instanzspezifische Compose-Anpassungen gehören in `compose.override.yaml`.
105
+
55
106
  ## Entwicklung
56
107
 
57
108
  ```bash
@@ -69,7 +120,8 @@ uv run pytest
69
120
  nach (`--yes` bricht dann ab), tragen neue optionale Variablen auskommentiert in `.env` ein und
70
121
  warnen vor veralteten Variablen, die noch in der `.env` stehen.
71
122
  - `core/compose_gen.py` + `templates/compose.yaml.j2` + `templates/nginx.conf.j2` — rendert `compose.yaml`
72
- + `nginx.conf` aus Version + TLS-Modus; erzeugt bei `tls_mode=standalone` ein selbstsigniertes
123
+ + `nginx.conf` und bei `tls_mode=caddy` das `Caddyfile` aus Version + TLS-Modus;
124
+ erzeugt bei `tls_mode=standalone` ein selbstsigniertes
73
125
  Zertifikat unter `<dir>/certs/` (eigenes Zertifikat dort ablegen, um es zu ersetzen).
74
126
  `install` fragt zusätzlich `MEDIA_ROOT` ab und legt `<dir>/compose.override.yaml.example` an
75
127
  (instanzspezifische Overrides — umbenennen zu `compose.override.yaml`, wird automatisch eingebunden
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "katalon-cli"
3
- version = "0.1.20"
3
+ version = "0.2.1"
4
4
  description = "Installer & Updater CLI für Katalon Production-Instanzen"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "katalon-cli"
3
- version = "0.1.20"
3
+ version = "0.2.1"
4
4
  description = "Installer & Updater CLI für Katalon Production-Instanzen"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -2,8 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import re
5
6
  import subprocess
7
+ from ipaddress import ip_address
6
8
  from pathlib import Path
9
+ from urllib.parse import urlparse
7
10
 
8
11
  from jinja2 import Environment, PackageLoader, select_autoescape
9
12
 
@@ -22,8 +25,8 @@ def render_compose(
22
25
  tls_mode: str,
23
26
  registry: str = "ghcr.io/katalon-collections/katalon",
24
27
  ) -> str:
25
- from urllib.parse import urlparse
26
-
28
+ if tls_mode == "caddy":
29
+ caddy_host(base_url)
27
30
  parsed_port = urlparse(base_url).port
28
31
  template = _env.get_template("compose.yaml.j2")
29
32
  return template.render(
@@ -47,6 +50,10 @@ def write_compose(instance_dir: Path, **kwargs) -> Path:
47
50
  nginx_path = instance_dir / "nginx.conf"
48
51
  nginx_path.write_text(render_nginx_conf(tls_mode=kwargs["tls_mode"]))
49
52
 
53
+ if kwargs["tls_mode"] == "caddy":
54
+ template = _env.get_template("Caddyfile.j2")
55
+ (instance_dir / "Caddyfile").write_text(template.render(host=caddy_host(kwargs["base_url"])))
56
+
50
57
  if kwargs["tls_mode"] == "standalone":
51
58
  ensure_self_signed_cert(instance_dir, host=_host_of(kwargs["base_url"]))
52
59
 
@@ -79,7 +86,27 @@ def ensure_self_signed_cert(instance_dir: Path, *, host: str) -> None:
79
86
  )
80
87
 
81
88
 
82
- def _host_of(base_url: str) -> str:
83
- from urllib.parse import urlparse
89
+ def caddy_host(base_url: str) -> str:
90
+ """Validiert die öffentliche HTTPS-Adresse vor dem Rendern der Caddy-Konfiguration."""
91
+ parsed = urlparse(base_url)
92
+ host = (parsed.hostname or "").encode("idna").decode("ascii")
93
+ try:
94
+ ip_address(host)
95
+ is_ip = True
96
+ except ValueError:
97
+ is_ip = False
98
+ label = r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
99
+ if (
100
+ parsed.scheme != "https" or parsed.port not in (None, 443)
101
+ or parsed.username is not None or parsed.password is not None
102
+ or parsed.path not in ("", "/") or parsed.query or parsed.fragment
103
+ or is_ip or len(host) > 253
104
+ or not re.fullmatch(rf"{label}(?:\.{label})+", host)
105
+ or host.endswith((".localhost", ".local"))
106
+ ):
107
+ raise ValueError("Caddy benötigt eine öffentliche Domain als https://domain.tld ohne Pfad (Port 443).")
108
+ return host
84
109
 
110
+
111
+ def _host_of(base_url: str) -> str:
85
112
  return urlparse(base_url).hostname or "localhost"
@@ -0,0 +1,113 @@
1
+ """Schreibrechte für Bind-Mounts, in die API und Worker schreiben.
2
+
3
+ API- und Worker-Image laufen als unprivilegierter User (Dockerfile `USER`). Ein vom
4
+ Installer oder per Hand angelegtes Host-Verzeichnis gehört dagegen meist root. Die
5
+ UID/GID wird nicht im CLI hinterlegt, sondern zur Laufzeit aus dem Container gelesen
6
+ (`id` im api-Service, inkl. eventuellem `user:` aus compose.override.yaml).
7
+
8
+ Die Korrektur läuft als root in einem Wegwerf-Container desselben Images — dadurch
9
+ funktioniert sie auch, wenn die CLI selbst nicht als root läuft. Sie ändert nur Eigentümer
10
+ von Einträgen mit falschem Eigentümer, nie Inhalte, und ist damit idempotent.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from . import docker
20
+
21
+ # Container-Pfad des MEDIA_ROOT-Bind-Mounts (compose.yaml.j2, api + worker).
22
+ CONTAINER_MEDIA_ROOT = "/var/lib/katalon/media"
23
+ # Services, die in CONTAINER_MEDIA_ROOT schreiben.
24
+ WRITER_SERVICES = ("api", "worker")
25
+ # Service, dessen Image-User als Eigentümer gesetzt wird.
26
+ OWNER_SERVICE = "api"
27
+
28
+
29
+ class PermissionFixError(RuntimeError):
30
+ pass
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ContainerUser:
35
+ uid: int
36
+ gid: int
37
+ name: str = ""
38
+
39
+ def __str__(self) -> str:
40
+ label = f"{self.uid}:{self.gid}"
41
+ return f"{label} ({self.name})" if self.name else label
42
+
43
+
44
+ _ID_RE = re.compile(r"uid=(\d+)(?:\(([^)]*)\))?\s+gid=(\d+)")
45
+
46
+
47
+ def parse_id_output(output: str) -> ContainerUser:
48
+ match = _ID_RE.search(output)
49
+ if not match:
50
+ raise PermissionFixError(f"Unerwartete Ausgabe von `id`: {output.strip()!r}")
51
+ return ContainerUser(uid=int(match.group(1)), gid=int(match.group(3)), name=match.group(2) or "")
52
+
53
+
54
+ def _run(instance_dir: Path, service: str, *args: str, root: bool = False):
55
+ user = ["--user", "0:0"] if root else []
56
+ return docker.compose(
57
+ instance_dir,
58
+ "run", "--rm", "--no-deps", "-T", *user, "--entrypoint", args[0], service, *args[1:],
59
+ check=False,
60
+ capture=True,
61
+ )
62
+
63
+
64
+ def container_user(instance_dir: Path, service: str = OWNER_SERVICE) -> ContainerUser:
65
+ """UID/GID, unter der `service` tatsächlich läuft (Image-USER bzw. compose `user:`)."""
66
+ result = _run(instance_dir, service, "id")
67
+ if result.returncode != 0:
68
+ raise PermissionFixError(
69
+ f"UID des {service}-Containers nicht ermittelbar: {(result.stderr or result.stdout).strip()}"
70
+ )
71
+ return parse_id_output(result.stdout)
72
+
73
+
74
+ def chown_args(path: str, user: ContainerUser) -> list[str]:
75
+ """`find`-Aufruf, der nur Einträge mit falschem Eigentümer umstellt (Inhalte bleiben unberührt)."""
76
+ owner = f"{user.uid}:{user.gid}"
77
+ return [
78
+ "find", path, "-xdev",
79
+ "(", "!", "-user", str(user.uid), "-o", "!", "-group", str(user.gid), ")",
80
+ "-exec", "chown", "-h", owner, "{}", "+",
81
+ ]
82
+
83
+
84
+ def is_writable(instance_dir: Path, service: str) -> bool:
85
+ result = _run(instance_dir, service, "test", "-w", CONTAINER_MEDIA_ROOT)
86
+ return result.returncode == 0
87
+
88
+
89
+ def unwritable_services(instance_dir: Path) -> list[str]:
90
+ return [service for service in WRITER_SERVICES if not is_writable(instance_dir, service)]
91
+
92
+
93
+ def fix_media_permissions(instance_dir: Path) -> ContainerUser:
94
+ """Setzt den Eigentümer von MEDIA_ROOT auf den Container-User und prüft danach die Schreibrechte."""
95
+ user = container_user(instance_dir)
96
+ result = _run(instance_dir, OWNER_SERVICE, *chown_args(CONTAINER_MEDIA_ROOT, user), root=True)
97
+ if result.returncode != 0:
98
+ raise PermissionFixError(
99
+ f"Eigentümer von MEDIA_ROOT konnte nicht auf {user} gesetzt werden: "
100
+ f"{(result.stderr or result.stdout).strip()}"
101
+ )
102
+ failing = unwritable_services(instance_dir)
103
+ if failing:
104
+ raise PermissionFixError(
105
+ f"MEDIA_ROOT ist nach der Korrektur für {', '.join(failing)} nicht beschreibbar "
106
+ f"(Eigentümer jetzt {user})."
107
+ )
108
+ return user
109
+
110
+
111
+ def manual_fix_hint(media_root: str, user: ContainerUser | None) -> str:
112
+ owner = f"{user.uid}:{user.gid}" if user else "<UID>:<GID>"
113
+ return f"sudo chown -R {owner} {media_root}"
@@ -24,7 +24,7 @@ class InstallationState(BaseModel):
24
24
  version: str
25
25
  compose_revision: int
26
26
  base_url: str
27
- tls_mode: str # "standalone" | "behind-proxy" | "none"
27
+ tls_mode: str # "caddy" | "standalone" | "behind-proxy" | "none"
28
28
  installed_at: datetime
29
29
  history: list[HistoryEntry] = Field(default_factory=list)
30
30
 
@@ -28,9 +28,9 @@ from rich.prompt import Confirm, Prompt
28
28
  from rich.table import Table
29
29
 
30
30
  from .core import backup as backup_mod
31
- from .core import checks, docker, release, selfupdate
31
+ from .core import checks, docker, permissions, release, selfupdate
32
32
  from .core import reindex as reindex_mod
33
- from .core.compose_gen import write_compose
33
+ from .core.compose_gen import caddy_host, write_compose
34
34
  from .core.paths import default_instance_dir, save_instance_dir
35
35
  from .core.state import InstallationState, instance_dir_or_raise
36
36
 
@@ -57,8 +57,9 @@ def _main(
57
57
  pass
58
58
 
59
59
  TLS_CHOICES = {
60
- "standalone": "Standalone — eigenes nginx mit selbstsigniertem Zertifikat (Ports 80+443)",
61
- "behind-proxy": "Hinter eigenem Reverse-Proxy — kein TLS hier",
60
+ "caddy": "Caddy – automatische HTTPS-Zertifikate und Erneuerung (Ports 80+443)",
61
+ "standalone": "Selbstsigniert – eigenes nginx, Browserwarnung, keine automatische Erneuerung (Ports 80+443)",
62
+ "behind-proxy": "Hinter eigenem Reverse-Proxy – Zertifikate dort verwalten",
62
63
  "none": "Kein TLS (lokal / IP)",
63
64
  }
64
65
 
@@ -247,6 +248,10 @@ def _run_checks(dir: Path, ports: list[int] | None = None, need_openssl: bool =
247
248
  def _resolve_ports_and_base_url(
248
249
  base_url: str, tls_mode: str, is_localhost: bool
249
250
  ) -> tuple[str, list[int]]:
251
+ if tls_mode == "caddy":
252
+ base_url = urlparse(base_url)._replace(scheme="https").geturl()
253
+ caddy_host(base_url)
254
+ return base_url, [80, 443]
250
255
  if tls_mode == "standalone":
251
256
  return base_url, [80, 443]
252
257
 
@@ -308,16 +313,37 @@ def install(
308
313
  "sonst echte Domain (dann folgt TLS-Auswahl)."
309
314
  )
310
315
  base_url = Prompt.ask("KATALON_BASE_URL", default="http://localhost")
316
+ if "://" not in base_url:
317
+ base_url = f"http://{base_url}"
311
318
 
312
319
  console.print()
313
320
  console.print("MEDIA_ROOT — Verzeichnis auf dem Host, in dem Katalon Mediendateien ablegt.")
314
321
  media_root = Prompt.ask("MEDIA_ROOT", default=str(dir / "media"))
315
322
 
316
323
  is_localhost = urlparse(base_url).hostname in (None, "localhost", "127.0.0.1")
324
+ try:
325
+ caddy_host(f"https://{urlparse(base_url).hostname or ''}")
326
+ is_domain = True
327
+ except ValueError:
328
+ is_domain = False
317
329
  console.print()
318
- for key, label in TLS_CHOICES.items():
319
- console.print(f" [cyan]{key}[/] — {label}")
320
- tls_mode = Prompt.ask("TLS-Modus", choices=list(TLS_CHOICES), default="none" if is_localhost else "standalone")
330
+ if is_domain:
331
+ console.print(
332
+ "Für öffentliches HTTPS: Der A-Record muss auf die öffentliche IPv4-Adresse dieses Servers zeigen. "
333
+ "Ein vorhandener AAAA-Record muss auf seine erreichbare IPv6-Adresse zeigen. "
334
+ "DNS-Änderungen müssen bereits wirksam sein."
335
+ )
336
+ console.print(
337
+ "Für Caddy müssen TCP-Ports 80 und 443 öffentlich erreichbar sein "
338
+ "(Server-/Provider-Firewall, ggf. Portweiterleitung). "
339
+ "Caddy beantragt und erneuert Zertifikate automatisch, solange es läuft und erreichbar bleibt. "
340
+ "Die CLI prüft freie Ports lokal; öffentliche DNS-Auflösung und Erreichbarkeit musst du sicherstellen."
341
+ )
342
+ choices = [key for key in TLS_CHOICES if key != "caddy" or is_domain]
343
+ for key in choices:
344
+ console.print(f" [cyan]{key}[/] – {TLS_CHOICES[key]}")
345
+ default_tls = "none" if is_localhost else ("caddy" if is_domain else "standalone")
346
+ tls_mode = Prompt.ask("TLS-Modus", choices=choices, default=default_tls)
321
347
 
322
348
  console.print()
323
349
  base_url, ports = _resolve_ports_and_base_url(base_url, tls_mode, is_localhost)
@@ -403,6 +429,7 @@ def _print_next_steps(dir: Path, base_url: str, show_credentials: bool = False)
403
429
  console.print()
404
430
  console.print(f"Portal: [bold]{base_url}/[/]")
405
431
  console.print(f"Admin: [bold]{base_url}/admin/[/]")
432
+ console.print(f"API: [bold]{base_url}/v1/[/] (OpenAPI-Doku: {base_url}/api/docs)")
406
433
  creds = _first_run_credentials(dir) if show_credentials else None
407
434
  if not show_credentials:
408
435
  console.print("Admin-Passwort vergessen? [bold]katalon manage reset-admin[/]")
@@ -423,10 +450,40 @@ def _print_next_steps(dir: Path, base_url: str, show_credentials: bool = False)
423
450
  console.print("Docs: [bold]https://katalon-collections.github.io/katalon-docs/[/]")
424
451
 
425
452
 
453
+ def _media_root(dir: Path) -> str:
454
+ value = _env_value(dir, "MEDIA_ROOT") or "./media"
455
+ path = Path(value)
456
+ return str(path if path.is_absolute() else dir / path)
457
+
458
+
459
+ def _ensure_media_permissions(dir: Path) -> bool:
460
+ """Macht MEDIA_ROOT für den Container-User von API/Worker beschreibbar (idempotent).
461
+
462
+ Fehler brechen nicht ab: Der Stack startet trotzdem, nur Uploads schlagen fehl."""
463
+ try:
464
+ user = permissions.fix_media_permissions(dir)
465
+ except permissions.PermissionFixError as exc:
466
+ console.print(f"[red]✖ MEDIA_ROOT {_media_root(dir)}: {exc}[/]")
467
+ console.print(
468
+ " Uploads schlagen sonst mit HTTP 500 fehl. Manuell beheben: "
469
+ f"[bold]{permissions.manual_fix_hint(_media_root(dir), None)}[/] "
470
+ "(UID/GID: `katalon doctor`)"
471
+ )
472
+ return False
473
+ console.print(f"[green]✔[/] MEDIA_ROOT beschreibbar für Container-User {user}.")
474
+ return True
475
+
476
+
426
477
  def _start(dir: Path, show_credentials: bool = False) -> None:
478
+ _ensure_media_permissions(dir)
427
479
  docker.compose(dir, "up", "-d")
428
480
  console.print("[green]✔[/] Stack gestartet.")
429
481
  state = InstallationState.load(dir)
482
+ if state.tls_mode == "caddy":
483
+ console.print(
484
+ "Caddy beantragt beim ersten Start das HTTPS-Zertifikat; das kann kurz dauern. "
485
+ "Bei HTTPS-Problemen: [bold]katalon logs caddy[/]."
486
+ )
430
487
  _print_next_steps(dir, state.base_url, show_credentials=show_credentials)
431
488
 
432
489
 
@@ -537,8 +594,28 @@ def doctor(dir: Path | None = typer.Option(None, "--dir")):
537
594
  "[yellow]⚠ Hinweis: Port 443 ist belegt. Falls Browser http://localhost/admin/ "
538
595
  "auf https:// umleiten, Base-URL auf z. B. http://localhost:8080 anpassen.[/]"
539
596
  )
597
+ _check_media_writable(dir)
540
598
  docker.compose(dir, "ps")
541
599
 
600
+
601
+ def _check_media_writable(dir: Path) -> bool:
602
+ media_root = _media_root(dir)
603
+ try:
604
+ user = permissions.container_user(dir)
605
+ failing = permissions.unwritable_services(dir)
606
+ except (permissions.PermissionFixError, docker.DockerError) as exc:
607
+ console.print(f"[yellow]⚠[/] MEDIA_ROOT: Prüfung nicht möglich — {exc}")
608
+ return False
609
+ if failing:
610
+ console.print(
611
+ f"[red]✖[/] MEDIA_ROOT: {media_root} ist für {', '.join(failing)} "
612
+ f"(Container-User {user}) nicht beschreibbar — Medien-Uploads scheitern mit HTTP 500. "
613
+ f"Beheben: [bold]katalon start[/] oder [bold]{permissions.manual_fix_hint(media_root, user)}[/]"
614
+ )
615
+ return False
616
+ console.print(f"[green]✔[/] MEDIA_ROOT: {media_root} beschreibbar für Container-User {user}")
617
+ return True
618
+
542
619
  @app.command()
543
620
  def check_updates(dir: Path | None = typer.Option(None, "--dir")):
544
621
  """Prüft, ob eine neue Katalon-Version verfügbar ist."""
@@ -620,6 +697,7 @@ def update(
620
697
  lambda: write_compose(dir, version=meta.version, base_url=state.base_url, tls_mode=state.tls_mode),
621
698
  ),
622
699
  ("Images pullen", lambda: docker.compose(dir, "pull")),
700
+ ("Medienrechte prüfen", lambda: _ensure_media_permissions(dir)),
623
701
  # DB-Migration läuft automatisch im api-Container-Entrypoint beim Start.
624
702
  ("Container neu starten", lambda: docker.compose(dir, "up", "-d")),
625
703
  ]
@@ -703,6 +781,7 @@ def rollback(
703
781
  write_compose(dir, version=previous.version, base_url=state.base_url, tls_mode=state.tls_mode)
704
782
  docker.compose(dir, "pull")
705
783
 
784
+ _ensure_media_permissions(dir)
706
785
  docker.compose(dir, "up", "-d")
707
786
  console.print("[green]✔[/] Rollback abgeschlossen.")
708
787
 
@@ -0,0 +1,3 @@
1
+ {{ host }} {
2
+ reverse_proxy nginx:80
3
+ }
@@ -159,6 +159,7 @@ services:
159
159
  {% if tls_mode == "standalone" %}
160
160
  - ./certs:/etc/nginx/certs:ro
161
161
  {% endif %}
162
+ {% if tls_mode != "caddy" %}
162
163
  ports:
163
164
  {% if tls_mode == "standalone" %}
164
165
  - "80:80"
@@ -167,12 +168,32 @@ services:
167
168
  - "127.0.0.1:{{ port or 8080 }}:80"
168
169
  {% else %}
169
170
  - "{{ port or 80 }}:80"
171
+ {% endif %}
170
172
  {% endif %}
171
173
  depends_on:
172
174
  api: { condition: service_healthy }
173
175
  admin: { condition: service_healthy }
174
176
  portal: { condition: service_healthy }
175
177
 
178
+ {% if tls_mode == "caddy" %}
179
+ caddy:
180
+ image: caddy:2-alpine
181
+ restart: unless-stopped
182
+ ports:
183
+ - "80:80"
184
+ - "443:443"
185
+ volumes:
186
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
187
+ - caddy_data:/data
188
+ - caddy_config:/config
189
+ depends_on:
190
+ nginx: { condition: service_started }
191
+
192
+ {% endif %}
176
193
  volumes:
177
194
  db_data:
178
195
  es_data:
196
+ {% if tls_mode == "caddy" %}
197
+ caddy_data:
198
+ caddy_config:
199
+ {% endif %}
@@ -23,7 +23,7 @@ server {
23
23
  proxy_set_header Host $host;
24
24
  proxy_set_header X-Real-IP $remote_addr;
25
25
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
26
- proxy_set_header X-Forwarded-Proto $scheme;
26
+ proxy_set_header X-Forwarded-Proto {{ '$http_x_forwarded_proto' if tls_mode == 'caddy' else '$scheme' }};
27
27
  }
28
28
 
29
29
  location /v1/ {
@@ -32,7 +32,7 @@ server {
32
32
  proxy_set_header Host $host;
33
33
  proxy_set_header X-Real-IP $remote_addr;
34
34
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
35
- proxy_set_header X-Forwarded-Proto $scheme;
35
+ proxy_set_header X-Forwarded-Proto {{ '$http_x_forwarded_proto' if tls_mode == 'caddy' else '$scheme' }};
36
36
  }
37
37
 
38
38
  location /portal/v1/ {
@@ -41,7 +41,7 @@ server {
41
41
  proxy_set_header Host $host;
42
42
  proxy_set_header X-Real-IP $remote_addr;
43
43
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
44
- proxy_set_header X-Forwarded-Proto $scheme;
44
+ proxy_set_header X-Forwarded-Proto {{ '$http_x_forwarded_proto' if tls_mode == 'caddy' else '$scheme' }};
45
45
  }
46
46
 
47
47
  location = /health {
@@ -50,7 +50,7 @@ server {
50
50
  proxy_set_header Host $host;
51
51
  proxy_set_header X-Real-IP $remote_addr;
52
52
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
53
- proxy_set_header X-Forwarded-Proto $scheme;
53
+ proxy_set_header X-Forwarded-Proto {{ '$http_x_forwarded_proto' if tls_mode == 'caddy' else '$scheme' }};
54
54
  }
55
55
 
56
56
  location = /oai {
@@ -59,7 +59,7 @@ server {
59
59
  proxy_set_header Host $host;
60
60
  proxy_set_header X-Real-IP $remote_addr;
61
61
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
62
- proxy_set_header X-Forwarded-Proto $scheme;
62
+ proxy_set_header X-Forwarded-Proto {{ '$http_x_forwarded_proto' if tls_mode == 'caddy' else '$scheme' }};
63
63
  }
64
64
 
65
65
  location ^~ /ark:/ {
@@ -68,7 +68,7 @@ server {
68
68
  proxy_set_header Host $host;
69
69
  proxy_set_header X-Real-IP $remote_addr;
70
70
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
71
- proxy_set_header X-Forwarded-Proto $scheme;
71
+ proxy_set_header X-Forwarded-Proto {{ '$http_x_forwarded_proto' if tls_mode == 'caddy' else '$scheme' }};
72
72
  }
73
73
 
74
74
  location /iiif/ {