From c9fcd56bdaa285cc6693d7bb63f7de8e44a138d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?c=C4=83t=C4=83lin?= Date: Tue, 18 Aug 2026 19:05:02 +0200 Subject: [PATCH] feat: add k8s/argo-apps/authelia - add scripts/users.py to manage users against lldap - add smtp values for lldap so it can send emails --- .gitignore | 1 + .yamllint.yaml | 1 + README.md | 6 + k8s/argo-apps/authelia.yaml | 128 ++++++++++ k8s/services/authelia/pvc.yaml | 13 + k8s/services/authelia/sealedsecrets.yaml | 17 ++ scripts/users.py | 293 +++++++++++++++++++++++ tofu/adguard/main.tf | 7 +- 8 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 k8s/argo-apps/authelia.yaml create mode 100644 k8s/services/authelia/pvc.yaml create mode 100644 k8s/services/authelia/sealedsecrets.yaml create mode 100755 scripts/users.py diff --git a/.gitignore b/.gitignore index 7d11c76..e645ace 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ AGENTS.md k8s/charts/README.md .opencode/ opencode.json +*.pyc diff --git a/.yamllint.yaml b/.yamllint.yaml index 4ffc165..f10a375 100644 --- a/.yamllint.yaml +++ b/.yamllint.yaml @@ -7,6 +7,7 @@ yaml-files: ignore: | k8s/charts/ + **/sealedsecrets.yaml extends: default diff --git a/README.md b/README.md index ac42e9c..aff389b 100644 --- a/README.md +++ b/README.md @@ -13,3 +13,9 @@ ethtool -K eno1 tx off rx off ```bash helm upgrade argo-cd argo/argo-cd -n argo-cd -f k8s/helm/argo-cd/values.yaml ``` + +- create a new user and send them an email + +```bash +scripts/users.py add -u catalin -n cătălin -e catalin@roboces.dev +``` diff --git a/k8s/argo-apps/authelia.yaml b/k8s/argo-apps/authelia.yaml new file mode 100644 index 0000000..5b36d05 --- /dev/null +++ b/k8s/argo-apps/authelia.yaml @@ -0,0 +1,128 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: authelia + namespace: argo-cd +spec: + destination: + name: '' + namespace: apps-roboces + server: https://kubernetes.default.svc + sources: + - chart: authelia + repoURL: https://charts.authelia.com + targetRevision: 0.11.6 + helm: + valuesObject: + + enabled: true + pod: + autoscaling: + enabled: true + kind: Deployment + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 1 + memory: 1024Mi + service: + type: LoadBalancer + ingress: + enabled: true + className: traefik + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + secret: + existingSecret: secrets-authelia + mountPath: /secrets + additionalSecrets: + secrets-authelia: + path: secrets-authelia + items: + - key: authentication.ldap.password.txt + path: authentication.ldap.password.txt + - key: identity_validation.reset_password.jwt.hmac.key + path: identity_validation.reset_password.jwt.hmac.key + - key: session.encryption.key + path: session.encryption.key + - key: smtp_password.txt + path: smtp_password.txt + - key: storage.encryption.key + path: storage.encryption.key + persistence: + enabled: true + existingClaim: authelia-data + configMap: + log: + level: info + session: + name: authelia_session + same_site: lax + expiration: 1 hour + inactivity: 5 minutes + remember_me: 1 month + cookies: + - domain: roboces.dev + subdomain: auth + - domain: auth.fuku + subdomain: '' + authentication_backend: + ldap: + enabled: true + implementation: lldap + address: ldap://lldap.apps-fuku.svc.cluster.local:3890 + base_dn: dc=fuku,dc=local + additional_users_dn: ou=people + users_filter: (&({username_attribute}={input})(objectClass=person)) + additional_groups_dn: ou=groups + groups_filter: (member={dn}) + user: uid=admin,ou=people,dc=fuku,dc=local + attributes: + distinguished_name: dn + username: uid + display_name: cn + mail: mail + member_of: memberOf + group_name: cn + password: + disabled: false + secret_name: secrets-authelia + path: authentication.ldap.password.txt + storage: + local: + enabled: true + path: /config/db.sqlite3 + notifier: + smtp: + enabled: true + address: submissions://mail.fukurokuju.dev:465 + sender: Authelia + subject: "Your verification code is {code}" + identifier: auth.fuku + startup_check_address: test@authelia.com + disable_html_emails: false + disable_require_tls: false + disable_starttls: false + username: auth@fukurokuju.dev + password: + disabled: false + secret_name: secrets-authelia + path: smtp_password.txt + tls: + server_name: mail.fukurokuju.dev + skip_verify: false + minimum_version: TLS1.2 + access_control: + default_policy: two_factor + - repoURL: https://git.roboces.dev/catalin/fukuops.git + path: k8s/services/authelia + targetRevision: feat/authelia + project: roboces + syncPolicy: + automated: + prune: true + selfHeal: true diff --git a/k8s/services/authelia/pvc.yaml b/k8s/services/authelia/pvc.yaml new file mode 100644 index 0000000..84f9fee --- /dev/null +++ b/k8s/services/authelia/pvc.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: authelia-data + namespace: apps-roboces +spec: + accessModes: + - ReadWriteMany + storageClassName: truenas-nfs-csi + resources: + requests: + storage: 1Gi diff --git a/k8s/services/authelia/sealedsecrets.yaml b/k8s/services/authelia/sealedsecrets.yaml new file mode 100644 index 0000000..e6d58d7 --- /dev/null +++ b/k8s/services/authelia/sealedsecrets.yaml @@ -0,0 +1,17 @@ +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: secrets-authelia + namespace: apps-roboces +spec: + encryptedData: + authentication.ldap.password.txt: AgBwsqdgEOQ8r/yUOD82Cwr44jxqV9RPDTzMYju8IfWx7b4iDKtZVidLK4RxBCp1+MPyWmLs7H4jec3Xsr10da/V+LaS1Elib+Sp2CkFZPGj1/XAcu5w3k/BnMcsRWJEJtzU251NVon6IVEzszcGOaVsPqf6aIvnKgOJEmp1boMlnemkvtaeXVS0TuSLXCQEMDfGMh9X8D9je315Sdqpczo8YLz6jyj6RxlgXeygRXkXANd2KFOyfLNUAnzm4Tl6PMjULnXvsxLhlc35+am6IsZGVLcdRMPY12UpHeZyVIgJBZSdo/gLTJS/hLhL2sPJv8HksL6YfvDnIWqu3PxVwI3BGtW2znBQaEIFXtOIXL9EDG0NMAZSBYCRpjrPNHP7xj7ruqjg4eU1iUj6yJpIv9322L/qhFWViK9wXc3+0L2n2q4qVBgrZxcacgqtHrCYj5Uo9iluVPWwagcKFYRXBupqaUUTso7LtSsv4WbOrJzYzE9mZk+feH1ZNvBp13JfWNdRiso5rpl0tZFJ7oW4G/CJuWg2MmL3UNViYB/ntwFWZWT8dxCEq2LI3UxeawHntzvk82kFyZduLUwhLvY4WfQB4yb//gBOpc0OyHa+djwECIyy3Jh+70LG+1YeVc8yQm52eu9+mzRSwDlqspKZYfwbMPCCiQj6AiSfg9Ho5+ITq1PgEt3oo9b0JkYFmEAgT88Y6z0DRQrM9YOXCfazMg== + identity_validation.reset_password.jwt.hmac.key: AgAQRknTOoC5tbCitLZkRtcIs3VOaubEvlhRmGtHh/OS0v6lgU2Gcl5C6jisu/+MqQba4euzMACLVgSMiEkERY2GrMijecfVf5LOkKKwQ0Xjiw4a+MlSVs5KR1Ovlz414//3GfUCttunnDRnH6xQbsQjWLCI6lv6Umb40TQHOnRmb83wmSkBa/P9WR35EQ6IcES3q7Ad4ZpNtLaavEnB8NuLZg3yaJpN+1yuZPeMgTPUtTG/e9FLdBs3S8AYFIcrUnDLViUo9DfXumMs1DlTsmABpjyIgjIeywOmCxMYZYvQCJrRGYlKZRdoCoafhAPxt67hdUYOe56cq3kqlTNTdURMbYup7cIkW2hQ8ZiyPvYk++RIhV/cPWOSjh6CLxMq26Si1c2Lg3aAVFxQcDiXbdprjHzbKzFfKO/5LVnhT7O651BqkCguPC2vVoaeuxblHjqouBD7ZmPazIQ9J/uDUmvm5UPjEN+Ln+enrLXdoJN9HMor410/PJ+9xPAHXhdrXRS6j5TlN5q1pz0gj2Qmnv3cunsyLo+ZJ97F3EkSYPVoK3sMXGn2fdKy/lc9vPMMvtH/tLdhWefxd25APahStZHv4Mb7O6Z+A4uFiP9Qao64jeLj08LWBbJPefT7Y+KthugN6cW4y/xQN4UFvpPHhGB36yb+45eIBzOJHltOqZ+wldjUHeBgMdkODAQYYbCzhDuVVm2iMGQqUi/Vk+EayaV70prcAELgRskseME2MwfrhYMAKS91tfMbL2dGTQ== + session.encryption.key: AgCjw3K7solE1INH+VIwEeP2U70HCaY25Iu6sq5C7j8nuW8JzbSCBPeY+qTS76l4sIyLlLekk7ke5kCPpZo+Jsz62UQJMGaSASRy0FKQIxgn5UIkObB+qziGjC8WNj6y/JmbvPNiIp+aw0Nd3OmVLRkvIjJDkyMf/iW1oGeLctwRop9Y+wi7lDPyEHH+CaNPCLYidhAJFG785qibqY8jTxtHsEts2T+hSXz75v5abYS+lTFtFV7RDWudkWG6dPjUKwYGw7l/yQm5p2qzzvhoyCrQItdNuEvGrZQsnXZlq4ik0C7t977uiU+fumYKFQvHzVuQTMjjo9G5AlhUG/4w2olMqOMZoJ0u+hHFBX1K1uYCUr1hd2343jd+y86iObZzTazlTGdP142lXk9+CT+akpQIVX0Mk5mwtemCYG+64ebSQm+SVB9Ffgxx3Ips6BOhqwIO0fLB1iz4BFVUp+87juyHZOP/5uC+KeV3+DHaQDrHRo6DxF5D5CRXBCcHXeWBLkDXSENgD4kvRee7fEQf60ajvi9VFTPjk86KXCbOZVlZPPrqWG7Dh+kaYOjWCQXQvFzYAq6/Dr5ZDRPJ/X989zHjPFFGRQ7Ol9uyjT/7I/2IBfdQB1Ep5BWty50PBVIqbYKRzQN0g0x5BAj5/2J9mOX/0BNGExVCNn53rfc8yDurfP0q1nbYk1t+fv0T9oI7RTPYIVWtUnshG8fwYK1PARNYBbKrGwjWtTdVxzV+ocunmmOHQhvOFrTnsfupGQ== + smtp_password.txt: AgB8pRbQxScSBRty04Z5oen7F52Y7PnwF9gJ6fdbUTRobE7j2fWiHMQk4l1FTztW3Muz3B/MQZBgBEzulpkDJ1TrvTe5I7JFdANr3lmlKjy0CG2NcQ7Vma7Bqu2SVnL8wxi/3IBTGxVa6bplGrkUCsIr/qWLhkVknOpAaUvOUuECT8PVxUZq/s4UMddkEf4N3IQW7j/60xwzomMp4e5eZ8YTH0/ZGcuw9JOQSOoJDzvJ5B+wZF7q7j9LQc4gBPHQiFFMVlZrhHA0fdjfPxbb7QEiGdMBxeZ0JqaaFBnr4FG7hRY5/H9RS9iZCFH9QY6X86ul9ZgiJYVyrvZFKSBsW+THlLZ7MACLvOs/zdXhpc2SthccoK8ykGC/inWTnfodQpH7N4zJq7akXxFy9/em4ssjWul4coeLNc+xhyi5VauLQQfRpgijc1kVJeQbrUrMe+aE1tlRdgRx76OKiFym1t47+zN3eqRf+NegNBGPSdAtKEBKfNPrcFQmlZVR6fRZqiBxG0wE90nfq9fuw3RDnVU/0LsuxwTz99gWMWl8ySuo+EAhf+JmB26/Pi1hnByeNeD8FsVkUiC6XnwAVC+ytnaVEfatrKmSJyzRhTSuFUoFxD5YV0aQgILks4+CpQwn300s0bqkLOP0etigZCCPq4cR+t+FFYAkuVjr+xPsoww0UOgokHsZKoXYi0n96UjbT0w+zoHhqQTBrPPWhPjDogZBKBCnth5eUtgrhSA2 + storage.encryption.key: AgBt8lTRtRqn+PiTNjgmJ97/rEqKEE70KZLuVtyvBM2ek70E555vx16BsCN3ATvwcr5dw+nAwYKpHJzS+mgTloLWmz6L2HqtxhlnaKC7elFeOza7tPKmKeKV3zXQ8YEA9eWjKdUN2TzO5s10iPCp4esUy5asO5DS/QVyPHMUfufu3iwHCDgnDEGlQ0pO1LMT2FwJ2QYX4Cub+OjUVrkBm8VbNdFqIw3OBtahRX2gx+o1d55psfi08ylEMKUwkHXYtkBheDvYL7SRv8xHKHGrN1loWHZOby1GOTzOnOpL6mjJoU+4nlvEzVwxzuPOHDq24cfey7engu0IfrZ+9Fs+icFjInY7cpowlNl0CXIWQm++ljtKVOUGUB2Bp6IwY1iwu3NVlwYVoZRCfSw5rLhccXnODYjXGMAwHF1cau4K8mkx1mD48dJnQE+y6AfrOPH+tCJ0dSDyuebNNlcE9HDmJseIGDP37Z3E/S9CCGprvLCdUYeOhXERyTevm5VJoboYq1/mhonPvY0bU4w3zVV3mXgr3Xi4SOupneUL+XfuQASRz1eGoQpAGxI8h4qWkgA66f8uPTh0hdM1JfLus9oXCtFsiYJzd+VtIZE+0JtI4ztGtm2JtgH80t2eiJTQolQihfpAXazg9fQSPH6NHsFcRplqC7emxdBZnI2F+TSjqcoX+RA78z+J2OP4ISs4ngF8USmm9/rJIRUq26XNLMjktSREBh/IwrpZWgiZqU/QzMhNriClH3n4vDHWJ03Xiw== + template: + metadata: + name: secrets-authelia + namespace: apps-roboces + type: Opaque diff --git a/scripts/users.py b/scripts/users.py new file mode 100755 index 0000000..eb49ca3 --- /dev/null +++ b/scripts/users.py @@ -0,0 +1,293 @@ +#!/usr/bin/env -S uv run --script +# /// script +# dependencies = ["ldap3", "typer", "httpx"] +# /// + +import base64 +import os +import re +import secrets +import subprocess +import sys + +import httpx +import typer +from ldap3 import ALL, Connection, Server, SUBTREE + +LLDAP_NAMESPACE = "apps-fuku" +LLDAP_SECRET = "lldap-secrets" +LLDAP_SERVICE = "lldap.apps-fuku.svc.cluster.local" +LLDAP_LDAP_PORT = 3890 +LLDAP_BASE_DN = "dc=fuku,dc=local" +LLDAP_ADMIN_USER = "uid=admin,ou=people,dc=fuku,dc=local" +LLDAP_URL = "https://ldap.fukurokuju.dev" + +app = typer.Typer(help="Manage Authelia users in the LLDAP backend.") + + +def fail(msg: str) -> None: + print(f"ERROR: {msg}", file=sys.stderr) + raise typer.Exit(1) + + +def run_kubectl(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["kubectl", *args], + capture_output=True, + text=True, + check=True, + ) + + +def get_secret_value(key: str) -> str: + result = run_kubectl( + [ + "get", + "secret", + LLDAP_SECRET, + "-n", + LLDAP_NAMESPACE, + "-o", + f"jsonpath={{.data.{key}}}", + ] + ) + return base64.b64decode(result.stdout).decode() + + +def get_configmap_value(key: str) -> str: + result = run_kubectl( + [ + "get", + "configmap", + "lldap-config", + "-n", + LLDAP_NAMESPACE, + "-o", + f"jsonpath={{.data.{key}}}", + ] + ) + return result.stdout.strip() + + +def get_lldap_password() -> str: + return get_secret_value("LLDAP_LDAP_USER_PASS") + + +def get_lldap_hosts() -> list[str]: + result = run_kubectl( + [ + "get", + "svc", + "lldap", + "-n", + LLDAP_NAMESPACE, + "-o", + "jsonpath={.status.loadBalancer.ingress[*].ip}", + ] + ) + ips = [ip.strip() for ip in result.stdout.split() if ip.strip()] + if not ips: + fail("could not discover lldap LoadBalancer IPs") + return ips + + +def lldap_connection() -> Connection: + password = get_lldap_password() + hosts = get_lldap_hosts() + last_error = None + for host in hosts: + try: + server = Server(host, port=LLDAP_LDAP_PORT, use_ssl=False, get_info=ALL) + conn = Connection( + server, + user=LLDAP_ADMIN_USER, + password=password, + auto_bind=True, + read_only=False, + ) + return conn + except Exception as e: + last_error = e + continue + fail(f"could not connect to any lldap endpoint: {last_error}") + + +def validate_username(username: str) -> None: + if not re.match(r"^[a-zA-Z0-9_.-]+$", username): + fail(f"invalid username: {username} (allowed: a-z, 0-9, _, ., -)") + + +def trigger_password_reset(username: str) -> None: + url = f"{LLDAP_URL}/auth/reset/step1/{username}" + try: + response = httpx.post(url, timeout=30.0) + response.raise_for_status() + except httpx.HTTPError as e: + fail(f"failed to trigger password reset email: {e}") + + print(f"Password reset email triggered for '{username}'.") + + +def generate_random_password() -> str: + return secrets.token_urlsafe(32) + + +def get_group_id(conn: Connection, group_name: str) -> int | None: + conn.search( + f"ou=groups,{LLDAP_BASE_DN}", + f"(cn={group_name})", + SUBTREE, + attributes=["uid"], + ) + if not conn.entries: + return None + uid = conn.entries[0].uid.value + try: + return int(uid) + except (TypeError, ValueError): + return None + + +def user_exists(conn: Connection, username: str) -> bool: + conn.search( + f"ou=people,{LLDAP_BASE_DN}", + f"(uid={username})", + SUBTREE, + attributes=["uid"], + ) + return bool(conn.entries) + + +def email_in_use(conn: Connection, email: str) -> bool: + conn.search( + f"ou=people,{LLDAP_BASE_DN}", + f"(mail={email})", + SUBTREE, + attributes=["uid"], + ) + return bool(conn.entries) + + +@app.command("list", help="List existing users ") +def cmd_list() -> None: + with lldap_connection() as conn: + conn.search( + f"ou=people,{LLDAP_BASE_DN}", + "(objectClass=person)", + SUBTREE, + attributes=["uid", "cn", "mail", "memberOf"], + ) + if not conn.entries: + print("No users found.") + return + print(f"{'USERNAME':<20} {'DISPLAY NAME':<30} {'EMAIL':<30} GROUPS") + for entry in conn.entries: + uid = entry.uid.value if entry.uid else "" + cn = entry.cn.value if entry.cn else "" + mail = entry.mail.value if entry.mail else "" + groups = ",".join( + g.split(",")[0].replace("cn=", "") for g in entry.memberOf.values + ) if entry.memberOf else "" + print(f"{uid:<20} {cn:<30} {mail:<30} {groups}") + + +@app.command("add", help="Add a user. Omit --password to send an invite email") +def cmd_add( + username: str = typer.Option(..., "--username", "-u", help="Username"), + displayname: str = typer.Option(..., "--displayname", "-n", help="Display name"), + password: str | None = typer.Option(None, "--password", "-p", help="Password"), + email: str = typer.Option("", "--email", "-e", help="Email address"), + groups: list[str] = typer.Option( + ["users"], "--group", "-g", help="Group to add the user to (repeatable)" + ), +) -> None: + validate_username(username) + + invite_mode = password is None + if invite_mode and not email: + fail("--email is required when --password is omitted") + + effective_email = email or f"{username}@roboces.dev" + + with lldap_connection() as conn: + if user_exists(conn, username): + fail(f"user '{username}' already exists") + if email_in_use(conn, effective_email): + fail(f"email '{effective_email}' is already in use") + + if invite_mode: + password = generate_random_password() + + user_dn = f"uid={username},ou=people,{LLDAP_BASE_DN}" + attrs = { + "objectClass": ["inetOrgPerson", "posixAccount", "mailAccount", "person"], + "uid": username, + "cn": displayname, + "mail": effective_email, + } + if not conn.add(user_dn, attributes=attrs): + fail(f"failed to create user: {conn.result}") + + if not conn.extend.standard.modify_password(user=user_dn, new_password=password): + fail(f"failed to set password: {conn.result}") + + for group_name in groups: + group_id = get_group_id(conn, group_name) + if group_id is None: + print(f" WARNING: group '{group_name}' not found, skipping") + continue + if not conn.add_user_to_group(user_dn, group_id): + print(f" WARNING: could not add to group '{group_name}': {conn.result}") + else: + print(f" Added to group '{group_name}'.") + + if invite_mode: + trigger_password_reset(username) + print(f"User '{username}' created and invite email sent to '{effective_email}'.") + else: + print(f"User '{username}' created.") + + +@app.command("remove", help="Remove a user") +def cmd_remove( + username: str = typer.Option(..., "--username", "-u", help="Username"), +) -> None: + validate_username(username) + with lldap_connection() as conn: + user_dn = f"uid={username},ou=people,{LLDAP_BASE_DN}" + conn.search(user_dn, "(objectClass=*)", SUBTREE, attributes=["uid"]) + if not conn.entries: + fail(f"user '{username}' not found") + if not conn.delete(user_dn): + fail(f"failed to delete user: {conn.result}") + print(f"User '{username}' removed from LLDAP.") + + +@app.command("reset-password", help="Trigger a password reset email for an existing user") +def cmd_reset_password( + username: str = typer.Option(..., "--username", "-u", help="Username"), + confirm: bool = typer.Option( + True, + "--confirm/--no-confirm", + help="Prompt for confirmation before sending the reset email", + ), +) -> None: + validate_username(username) + with lldap_connection() as conn: + if not user_exists(conn=conn, username=username): + fail(f"user '{username}' not found") + + if confirm: + typer.confirm( + f"Send password reset email to '{username}'?", + abort=True, + ) + + trigger_password_reset(username) + print(f"Password reset email triggered for '{username}'.") + + +if __name__ == "__main__": + os.environ.setdefault("PYTHONUNBUFFERED", "1") + sys.stdout.reconfigure(line_buffering=True) + app() diff --git a/tofu/adguard/main.tf b/tofu/adguard/main.tf index 47f397b..7c40871 100644 --- a/tofu/adguard/main.tf +++ b/tofu/adguard/main.tf @@ -114,11 +114,16 @@ resource "adguard_rewrite" "vault" { answer = "192.168.1.12" } -resource "adguard_rewrite" "pocketid" { +resource "adguard_rewrite" "authelia-roboces" { domain = "auth.roboces.dev" answer = "192.168.1.12" } +resource "adguard_rewrite" "authelia" { + domain = "auth.fuku" + answer = "192.168.1.12" +} + resource "adguard_rewrite" "dns" { domain = "dns.fuku" answer = "192.168.1.12"