prole/knoe/ui/screens/security.py
chrisfu a069989315 Rename prole-db to knoe-db, add knoe-auth as cluster-internal KDC
Itemized changes:

1. knoe-auth: New cluster-internal KDC and SSO gateway service
   - Created etc/init_knoe_auth.sh based on init_kdc.sh with knoe-auth naming
   - Namespace defaults to SERVICE_NAMESPACE (knoe-system)
   - ConfigMap: knoe-auth-kdc-config, Secret: knoe-auth-secrets
   - Legacy cleanup removes old auth/dog/authority deployments

2. Orchestration: knoe-auth initializes before CloudNativePG
   - Updated prole.sh to insert init_knoe_auth.sh as step 2 (before CNPG)
   - Renumbered all subsequent initialization steps

3. Kong routing: Updated init_kong.sh to route to knoe-auth in SERVICE_NAMESPACE

4. Comment/reference updates for knoe-auth
   - Updated init_common_services.sh, init_service_layer.sh, init_kerberos.sh

5. prole-db renamed to knoe-db across the entire codebase
   - Renamed prole-db/ directory to knoe-db/
   - Renamed all prole-db Kubernetes manifests (deploy/opentofu, k8s/)
   - Renamed scripts: docker-root-knoe-db.sh, docker-run-knoe-db.sh, test-cnpg-knoe-db.sh
   - Renamed etc/init_prole-db-reset.sh to etc/init_knoe-db-reset.sh
   - Renamed etc/prole-db-passwwd.sh to etc/knoe-db-passwwd.sh
   - Renamed mock_val counterparts accordingly
   - Renamed tests/etc/test_init_prole-db-reset.sh to test_init_knoe-db-reset.sh
   - Renamed docs/prole-db-documentation-mcp-architecture.md to knoe-db variant
   - Renamed modes/k3d/prole-db/ to modes/k3d/knoe-db/
   - Renamed prole-db.iml to knoe-db.iml

6. Configuration updates
   - Updated conf/dev, conf/prod, conf/test, conf/service prole.cfg files
   - Updated conf/port-mapping.cfg
   - Updated etc/prole_cfg.sh and mock_val/prole_cfg.sh
   - Updated service/prole.cfg

7. Kubernetes manifests and deploy configuration
   - Updated deploy/opentofu/k3s ArgoCD application YAMLs
   - Updated kong-configmap.yaml and kustomization.yaml
   - Updated k3s/kong-config.yml and prole-resources.yaml
   - Updated prole-mssql-db deployment YAMLs
   - Updated supabase helm render and deploy scripts

8. Infrastructure and GCP Terraform
   - Updated deploy/gcp/terraform: folders, groups, IAM, service-projects

9. Python/installer code updates
   - Updated knoe/core: actions, build_context, controller, env, milestones
   - Updated knoe/milestone.py
   - Updated knoe/ui/screens: cfg, database, database_options, deploy, docker,
     navigation, security, services, validate
   - Updated knoe.spec, status.py

10. Shell script updates
    - Updated etc/: build_db, init_cloudnative_pg, init_cnpg_backup,
      init_db_manager, init_forgejo, init_gitlab, init_monitoring, init_openbao,
      init_port_forwards, init_postgrest, init_supabase_ports, status
    - Updated mock_val/ counterparts for all above scripts
    - Updated prole-net/init-prole-dns.sh
    - Updated bin/prole-kpf.sh, gitea/deploy.sh, supabase/deploy.sh

11. Test updates
    - Updated tests/etc/: test_init_cloudnative_pg*, test_init_cnpg_backup*,
      test_init_kdc*, test_init_kerberos*, test_init_kong*, test_prole_cfg*
    - Updated tests/installer/: test_actions_helpers, test_cfg_save_kubecontext,
      test_controller, test_core_classes, test_milestones, test_milestones_extended,
      test_namespace_propagation
    - Updated tests/: test_database_options, test_navigation,
      test_render_supabase_hostname, test_docker_build_fix,
      test_all_prole_home_fixes, silent_install_test, final_test

12. Documentation updates
    - Updated docs/: DOCKER-BUILD-FIX, PROLE-CFG-SECRETS, PROLE-HOME-DIRECTORY,
      build-system, patent
    - Updated scan/network_description.txt
    - Updated pom.xml

13. Miscellaneous script updates
    - Updated root-level: _adopt_replica_pvcs, _fix_replica_merlin, _import_pi,
      _patch_cluster, _prebind_pvcs, _rebind_d002, _rebind_d002b, test_resolve
    - Updated scripts/generate_spec.py

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-22 22:16:21 -07:00

706 lines
24 KiB
Python

"""Kerberos configuration, secret management and Ansible vault."""
import configparser
import os
import shutil
import subprocess
import tempfile
import threading
from pathlib import Path
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from knoe import screen as ui
from knoe.core.env import (
PROJECT_ROOT,
_bool_str,
_deployment_target_label,
_ensure_ansible_vault_credentials,
)
from knoe.config import (
SECRET_KEY_SPECS,
_decrypt_prole_secret,
_is_openbao_ref,
_is_prole_secret,
)
class SecurityScreenMixin:
"""Kerberos configuration, secret management and Ansible vault."""
def _render_kerberos_config_page(self):
self._kerberos_success = False
initial_status = "Ready" if self.kerberos_enabled.get() else "Disabled"
self._kerberos_status_var = tk.StringVar(value=initial_status)
# Letterhead at top right
content_width = self.bg_canvas.winfo_width() or 975
right_margin = content_width - 48
ui.canvas_text(
self,
right_margin,
40,
"Prole",
fill="#6e6e73",
font=("SF Pro Text", 32, "bold"),
anchor="ne",
)
ui.canvas_text(
self,
right_margin,
85,
"Infrastructure Automated.",
fill="#6e6e73",
font=("SF Pro Text", 18),
anchor="ne",
)
self._render_title("Kerberos Authentication", y=150)
self._render_paragraph(
"Configure Kerberos authentication for Prole and the PostgreSQL database. Tests run inside the Kubernetes namespace to validate against your realm.",
y=200,
)
y = 280
# Use tk.Checkbutton on canvas
enable_cb = tk.Checkbutton(
self.bg_canvas,
text="Enable Kerberos Authentication",
variable=self.kerberos_enabled,
command=self._on_kerberos_toggle,
bg="white",
fg="black",
activebackground="white",
selectcolor="white",
font=("SF Pro Text", 11),
)
cb_window = self.bg_canvas.create_window(48, y, window=enable_cb, anchor="nw")
self._canvas_items.append(cb_window)
self._overlay_widgets.append(enable_cb)
status_item = ui.canvas_text(
self,
250,
y + 6,
f"Status: {initial_status}",
fill="black",
font=("SF Pro Text", 11),
)
def _update_status_text(*_):
try:
self.bg_canvas.itemconfig(
status_item, text=f"Status: {self._kerberos_status_var.get()}"
)
except Exception:
pass
self._kerberos_status_var.trace_add("write", _update_status_text)
left_col = content_width // 3
right_col_x = left_col + 16
right_col_w = content_width - right_col_x - 48
x_field = 160
field_w = left_col - x_field - 8
y += 60
console_y = y
ui.canvas_text(
self, 48, y, "Kerberos Realm:", fill="black", font=("SF Pro Text", 12)
)
realm_entry = tk.Entry(
self.bg_canvas,
textvariable=self.kerberos_realm,
bg="white",
fg="black",
insertbackground="black",
highlightbackground="#CCCCCC",
highlightthickness=1,
relief="flat",
font=("SF Pro Text", 11),
)
realm_window = self.bg_canvas.create_window(
x_field, y - 4, window=realm_entry, anchor="nw", width=field_w, height=32
)
self._canvas_items.append(realm_window)
self._overlay_widgets.append(realm_entry)
y += 48
ui.canvas_text(
self, 48, y, "KDC Host/IP:", fill="black", font=("SF Pro Text", 12)
)
kdc_entry = tk.Entry(
self.bg_canvas,
textvariable=self.kerberos_kdc,
bg="white",
fg="black",
insertbackground="black",
highlightbackground="#CCCCCC",
highlightthickness=1,
relief="flat",
font=("SF Pro Text", 11),
)
kdc_window = self.bg_canvas.create_window(
x_field, y - 4, window=kdc_entry, anchor="nw", width=field_w, height=32
)
self._canvas_items.append(kdc_window)
self._overlay_widgets.append(kdc_entry)
y += 48
ui.canvas_text(self, 48, y, "Username:", fill="black", font=("SF Pro Text", 12))
user_entry = tk.Entry(
self.bg_canvas,
textvariable=self.kerberos_user,
bg="white",
fg="black",
insertbackground="black",
highlightbackground="#CCCCCC",
highlightthickness=1,
relief="flat",
font=("SF Pro Text", 11),
)
user_window = self.bg_canvas.create_window(
x_field, y - 4, window=user_entry, anchor="nw", width=field_w, height=32
)
self._canvas_items.append(user_window)
self._overlay_widgets.append(user_entry)
y += 48
ui.canvas_text(self, 48, y, "Password:", fill="black", font=("SF Pro Text", 12))
pass_entry = tk.Entry(
self.bg_canvas,
textvariable=self.kerberos_password,
show="*",
bg="white",
fg="black",
insertbackground="black",
highlightbackground="#CCCCCC",
highlightthickness=1,
relief="flat",
font=("SF Pro Text", 11),
)
pass_window = self.bg_canvas.create_window(
x_field, y - 4, window=pass_entry, anchor="nw", width=field_w, height=32
)
self._canvas_items.append(pass_window)
self._overlay_widgets.append(pass_entry)
y += 60
# Use tk.Button on canvas
test_btn = tk.Button(
self.bg_canvas,
text="Test Connection",
command=self.test_kerberos_connection,
bg="#F5F5DC",
fg="black",
activebackground="#E5E5D5",
highlightbackground="#F5F5DC",
highlightthickness=0,
relief="flat",
font=("SF Pro Text", 11),
padx=16,
pady=8,
)
btn_window = self.bg_canvas.create_window(48, y, window=test_btn, anchor="nw")
self._canvas_items.append(btn_window)
self._overlay_widgets.append(test_btn)
self._kerberos_console = self._create_console_output(
x=right_col_x, y=console_y, title="Kerberos Output",
width=right_col_w, height=350
)
def test_kerberos_connection(self):
self._action_flags["kerberos_config.test_connection"] = True
realm = self.kerberos_realm.get().strip()
user = self.kerberos_user.get().strip()
password = self.kerberos_password.get().strip()
kdc = self.kerberos_kdc.get().strip()
if not realm or not user or not password or not kdc:
messagebox.showerror(
"Error", "Please fill in realm, KDC host, username, and password."
)
return
try:
self.prole_cfg_data["Kerberos Authentication"]["ENABLED"] = str(
self.kerberos_enabled.get()
)
self.prole_cfg_data["Kerberos Authentication"]["REALM"] = realm
self.prole_cfg_data["Kerberos Authentication"]["KDC"] = kdc
self.prole_cfg_data["Kerberos Authentication"]["SERVER"] = kdc
self.prole_cfg_data["Kerberos Authentication"]["USER"] = user
self.prole_cfg_data["Kerberos Authentication"]["PASSWORD"] = password
self.prole_cfg_data["Optional Features"]["KERBEROS_ENABLED"] = str(
self.kerberos_enabled.get()
)
self.prole_cfg_data["Kerberos Authentication"]["AD_PORT_FORWARD"] = (
os.environ.get("KRB5_AD_PORT_FORWARD", "1")
)
self.prole_cfg_data["Kerberos Authentication"]["AD_TCP_PORTS"] = (
os.environ.get("KRB5_AD_TCP_PORTS", "88 389 445 464 636")
)
self.prole_cfg_data["Kerberos Authentication"]["AD_UDP_PORTS"] = (
os.environ.get("KRB5_AD_UDP_PORTS", "88 464")
)
self.prole_cfg_data["Kerberos Authentication"]["AD_PROXY_HOST_NETWORK"] = (
os.environ.get("KRB5_AD_PROXY_HOST_NETWORK", "1")
)
self.prole_cfg_data["Kerberos Authentication"]["AD_PROXY_IMAGE"] = (
os.environ.get("KRB5_AD_PROXY_IMAGE", "alpine/socat")
)
self.prole_cfg_data["Kerberos Authentication"]["AD_PROXY_SERVICE"] = (
os.environ.get("KRB5_AD_SERVICE_NAME", "prole-kerberos-ad-dc")
)
self._save_prole_cfg()
except Exception:
pass
try:
self._kerberos_console.clear()
self._kerberos_console.write(
f"Testing {user}@{realm} inside the cluster...\n"
)
except Exception:
pass
def worker():
try:
env = os.environ.copy()
env["PROLE_HOME"] = str(PROJECT_ROOT)
env["PROLE_SERVICE"] = str(PROJECT_ROOT)
env["NAMESPACE"] = (self.db_namespace.get() or "").strip()
env["SERVICE_NAMESPACE"] = self._get_service_namespace()
env["KRB5_REALM"] = realm
env["REALM"] = realm
env["DOMAIN"] = realm.lower()
env["KRB5_KDC"] = kdc
env["KRB5_ADMIN"] = kdc
env["KRB5_USER"] = user
env["KRB5_PASSWORD"] = password
env["KERBEROS_ENABLED"] = _bool_str(self.kerberos_enabled.get())
env["ENABLED"] = env["KERBEROS_ENABLED"]
mode = self._deployment_mode()
if mode == "k3s":
env.setdefault("KRB5_AD_PORT_FORWARD", "0")
if mode:
env["PROLE_MODE"] = mode
env["DEPLOYMENT_MODE"] = mode
env["DEPLOYMENT_TARGET"] = _deployment_target_label(
self.cluster_env.get()
)
mode_args = ["--mode", mode] if mode else []
def write_line(line):
try:
self._kerberos_console.write(line)
except Exception:
pass
write_line("\n==> init_kerberos.sh test\n")
rc2 = self.controller.run_script(
"init_kerberos.sh",
args=mode_args + ["test"],
env=env,
on_line=write_line,
)
if rc2 == 0:
write_line("\nKerberos test completed successfully.\n")
self.safe_after(
lambda: (
self._kerberos_status_var.set("Test Passed"),
self.next_button.configure(state="normal"),
)
)
else:
write_line(f"\nKerberos test failed with code {rc2}\n")
self.safe_after(
lambda: self._kerberos_status_var.set(
f"Test Failed (Code {rc2})"
)
)
except Exception as e:
try:
self._kerberos_console.write(f"Error: {e}\n")
except Exception:
pass
threading.Thread(target=worker, daemon=True).start()
def _secret_namespace(self) -> str:
ns = ""
try:
ns = (self.db_namespace.get() or "").strip()
except Exception:
ns = ""
if not ns:
ns = os.environ.get("NAMESPACE", "") or "default"
return ns
@staticmethod
def _yaml_scalar(content: str, key: str) -> str:
prefix = f"{key}:"
for line in content.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if not stripped.startswith(prefix):
continue
value = stripped[len(prefix) :].strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
return value
return ""
@staticmethod
def _vault_password_files() -> list[Path]:
files: list[Path] = []
env_pw = os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE", "").strip()
if env_pw:
files.append(Path(env_pw))
for base in (Path.cwd(), PROJECT_ROOT):
files.append(base / ".vault_pass")
seen: set[Path] = set()
unique: list[Path] = []
for item in files:
if item in seen:
continue
seen.add(item)
unique.append(item)
return unique
def _resolve_samba_admin_password_from_vault(self) -> str:
vault_path = (
PROJECT_ROOT
/ "infrastructure"
/ "inventory"
/ "group_vars"
/ "ad_dc"
/ "vault.yml"
)
if not vault_path.exists():
return ""
try:
raw = vault_path.read_text(encoding="utf-8")
except Exception:
raw = ""
if raw and "$ANSIBLE_VAULT;" not in raw:
return self._yaml_scalar(raw, "vault_samba_dns_admin_pass")
if shutil.which("ansible-vault") is None:
return ""
_ensure_ansible_vault_credentials(prompt_ui=False)
for password_file in self._vault_password_files():
if not password_file.is_file():
continue
try:
res = subprocess.run(
[
"ansible-vault",
"view",
str(vault_path),
"--vault-password-file",
str(password_file),
],
capture_output=True,
text=True,
check=False,
)
except Exception:
continue
if res.returncode != 0:
continue
value = self._yaml_scalar(res.stdout or "", "vault_samba_dns_admin_pass")
if value:
return value
return ""
def _load_secret_cache_from_cfg(self):
try:
if self._cfg_path_override is not None:
cfg_path = self._cfg_path_override
if cfg_path.is_dir():
cfg_path = cfg_path / "prole.cfg"
else:
conf_dir = self._resolve_prole_conf_dir()
cfg_path = conf_dir / "prole.cfg"
except Exception:
return
if not cfg_path.exists():
return
cfg = configparser.ConfigParser(interpolation=None)
cfg.optionxform = str
cfg.read(cfg_path)
for (section, key), _spec in SECRET_KEY_SPECS.items():
if cfg.has_option(section, key):
val = cfg.get(section, key, fallback="").strip()
if val:
self._cfg_secret_cache[(section, key)] = val
if _is_openbao_ref(val):
self._secrets_finalized = True
def _resolve_cached(pairs: list[tuple[str, str]]) -> str:
for pair in pairs:
val = self._cfg_secret_cache.get(pair, "")
if val:
return val
return ""
db_val = _resolve_cached(
[
("Inputs", "init_password.db_password"),
("Global", "DB_PASSWORD"),
]
)
if db_val and _is_prole_secret(db_val):
db_plain = _decrypt_prole_secret(db_val)
elif db_val and _is_openbao_ref(db_val):
# Attempt to resolve from OpenBao for the UI
try:
db_plain = self._resolve_openbao_ref(db_val)
if db_plain == db_val: # Resolution failed
db_plain = ""
except Exception:
db_plain = ""
elif db_val:
db_plain = db_val
else:
db_plain = ""
if db_plain:
try:
self.db_password.set(db_plain)
self.db_password_confirm.set(db_plain)
except Exception:
pass
krb_val = _resolve_cached(
[
("Inputs", "kerberos_config.password"),
("Kerberos Authentication", "PASSWORD"),
]
)
if krb_val and _is_prole_secret(krb_val):
krb_plain = _decrypt_prole_secret(krb_val)
elif krb_val and _is_openbao_ref(krb_val):
# Attempt to resolve from OpenBao for the UI
try:
krb_plain = self._resolve_openbao_ref(krb_val)
if krb_plain == krb_val: # Resolution failed
krb_plain = ""
except Exception:
krb_plain = ""
elif krb_val:
krb_plain = krb_val
else:
krb_plain = ""
if not krb_plain:
krb_plain = self._resolve_samba_admin_password_from_vault()
if krb_plain:
try:
self.kerberos_password.set(krb_plain)
except Exception:
pass
def _kerberos_script_env(self):
env = os.environ.copy()
env["PROLE_HOME"] = str(PROJECT_ROOT)
env["PROLE_SERVICE"] = str(PROJECT_ROOT)
env["KNOE_DB_USER"] = self.db_username.get()
env["DB_PASSWORD"] = self.db_password.get()
env["NAMESPACE"] = (self.db_namespace.get() or "").strip() or "default"
env["SERVICE_NAMESPACE"] = self._get_service_namespace()
realm = self.kerberos_realm.get().strip()
if realm:
env["KRB5_REALM"] = realm
env["REALM"] = realm
env["DOMAIN"] = realm.lower()
kdc = self.kerberos_kdc.get().strip()
if kdc:
env["KRB5_KDC"] = kdc
env["KRB5_ADMIN"] = kdc
user = self.kerberos_user.get().strip()
if user:
env["KRB5_USER"] = user
password = self.kerberos_password.get().strip()
if password:
env["KRB5_PASSWORD"] = password
enabled = _bool_str(self.kerberos_enabled.get())
env["KERBEROS_ENABLED"] = enabled
env["ENABLED"] = enabled
mode = self._deployment_mode()
mode_args = ["--mode", mode] if mode else []
if mode:
env["PROLE_MODE"] = mode
env["DEPLOYMENT_MODE"] = mode
env["DEPLOYMENT_TARGET"] = _deployment_target_label(
self.cluster_env.get()
)
return env, mode_args
def _run_kerberos_init(self):
if getattr(self, "_kerberos_init_running", False):
return
if not self.kerberos_enabled.get():
self._kerberos_status_var.set("Disabled")
return
self._kerberos_init_running = True
self._kerberos_status_var.set("Initializing...")
try:
self._kerberos_console.clear()
self._kerberos_console.write("Running init_kerberos.sh initialize...\n")
except Exception:
pass
self.update_footer()
def worker():
env, mode_args = self._kerberos_script_env()
script_path = PROJECT_ROOT / "etc" / "init_kerberos.sh"
if not script_path.exists():
try:
self._kerberos_console.write(
f"Error: {script_path} not found.\n"
)
self._kerberos_status_var.set("Failed (Script not found)")
except Exception:
pass
self._kerberos_init_running = False
self.safe_after(self.update_footer)
return
rc = self.controller.run_script(
"init_kerberos.sh",
args=mode_args + ["initialize"],
env=env,
on_line=lambda l: self._kerberos_console.write(l),
)
if rc == 0:
self._kerberos_success = True
self.safe_after(
lambda: self._kerberos_status_var.set("Initialized")
)
try:
self._kerberos_console.write(
"\nKerberos initialization completed successfully.\n"
)
except Exception:
pass
else:
self._kerberos_success = False
self.safe_after(
lambda: self._kerberos_status_var.set(f"Failed (Code {rc})")
)
try:
self._kerberos_console.write(
f"\nKerberos initialization failed with exit code {rc}.\n"
)
except Exception:
pass
self._kerberos_init_running = False
self.safe_after(self.update_footer)
threading.Thread(target=worker, daemon=True).start()
def _on_kerberos_toggle(self):
enabled = self.kerberos_enabled.get()
try:
self.prole_cfg_data["Optional Features"]["KERBEROS_ENABLED"] = str(enabled)
self._save_prole_cfg()
except Exception:
pass
if not enabled:
self._kerberos_success = False
self._kerberos_status_var.set("Disabled")
try:
self._kerberos_console.write("Kerberos disabled. Skipping init.\n")
except Exception:
pass
self.update_footer()
return
self._kerberos_status_var.set("Enabled")
try:
self._kerberos_console.write("Kerberos enabled. Starting initialization...\n")
except Exception:
pass
self.update_footer()
self.safe_after(self._run_kerberos_init)
def _save_ansible_prole_vault(self, root_password: str):
if not root_password:
return
vault_path = (
PROJECT_ROOT
/ "infrastructure"
/ "inventory"
/ "group_vars"
/ "all"
/ "prole_vault.yml"
)
_ensure_ansible_vault_credentials(prompt_ui=False)
vault_pass_file = os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE")
if not vault_pass_file or not Path(vault_pass_file).exists():
for base in (Path.cwd(), PROJECT_ROOT):
candidate = base / ".vault_pass"
if candidate.is_file():
vault_pass_file = str(candidate)
break
if not vault_pass_file or not Path(vault_pass_file).exists():
return
if shutil.which("ansible-vault") is None:
return
try:
# We must decrypt if it is a prole secret
plain_pass = root_password
if _is_prole_secret(root_password):
try:
plain_pass = _decrypt_prole_secret(root_password)
except Exception:
pass
content = f'prole_root_password: "{plain_pass}"\n'
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write(content)
tmp_name = f.name
try:
os.chmod(tmp_name, 0o600)
subprocess.run(
[
"ansible-vault",
"encrypt",
tmp_name,
"--vault-password-file",
vault_pass_file,
],
check=True,
capture_output=True,
)
shutil.move(tmp_name, str(vault_path))
os.chmod(str(vault_path), 0o600)
finally:
if os.path.exists(tmp_name):
try:
os.unlink(tmp_name)
except:
pass
except Exception as e:
print(f"[ERROR] Failed to save ansible vault: {e}")