Compare commits

...
Sign in to create a new pull request.

19 commits

Author SHA1 Message Date
a852d7217b
feat: add lldap VIP 2026-08-24 15:41:05 +02:00
1eab068977
feat: add metallb 2026-08-24 13:23:19 +02:00
bd0c392b57
feat: update vault to 1.37.2 2026-08-24 09:40:32 +02:00
d36233e7b8
feat: add audiobooks and photos dns rewrites 2026-08-23 21:08:35 +02:00
3cee21b532 chore(deps): update helm release renovate to 46.254.* 2026-08-22 11:50:34 +00:00
09d1296c23 chore(deps): update code.forgejo.org/forgejo-helm/forgejo docker tag to v17.1.5 2026-08-21 07:03:50 +00:00
fc0146d26f chore(deps): update docker.io/library/redis docker tag to v8.10.1 2026-08-21 05:03:26 +00:00
4701fb9f9f chore(deps): update helm release pgadmin4 to v1.66.0 2026-08-20 05:34:00 +00:00
921be2e54c
feat: add authelia and audiobookshelf's chart urls to the allowed repo sources 2026-08-19 17:05:13 +02:00
9a21659127
feat: change svc ports for adguard, vault and vault-sm 2026-08-19 16:25:52 +02:00
173633c6f1
feat: add pgadmin argo app 2026-08-19 15:28:46 +02:00
18ef9d87c1
chore: fix audiobookshelf's authelia redirect_uris 2026-08-19 12:14:46 +02:00
62231689c7
feat: add scripts/add-secret.py 2026-08-19 10:15:20 +02:00
98384cc258 chore(deps): update helm release authentik to 2026.8.* 2026-08-19 04:58:59 +00:00
eb0a8d135e
feat: add audiobookshelf argo app 2026-08-18 23:08:45 +02:00
c9fcd56bda
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
2026-08-18 19:05:02 +02:00
98c165897d chore(deps): update git.roboces.dev/catalin/fukuops/lldap docker tag to v1.0.1 2026-08-18 05:21:59 +00:00
f14bb601ce
feat: add lldap chart and app 2026-08-17 09:22:08 +02:00
a006c5ceae chore(deps): update helm release meilisearch to 0.38.* 2026-08-17 05:54:11 +00:00
52 changed files with 1674 additions and 54 deletions

3
.gitignore vendored
View file

@ -21,3 +21,6 @@ AGENTS.md
k8s/charts/README.md k8s/charts/README.md
.opencode/ .opencode/
opencode.json opencode.json
*.pyc
.venv/
METALLB.md

View file

@ -7,6 +7,7 @@ yaml-files:
ignore: | ignore: |
k8s/charts/ k8s/charts/
**/sealedsecrets.yaml
extends: default extends: default

View file

@ -13,3 +13,15 @@ ethtool -K eno1 tx off rx off
```bash ```bash
helm upgrade argo-cd argo/argo-cd -n argo-cd -f k8s/helm/argo-cd/values.yaml 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
```
## MetalLB and reserved VIPs
- traefik VIP: 192.168.1.126
- adguard dns VIP: 192.168.1.125
- ldap VIP: 192.168.1.124

View file

@ -41,3 +41,10 @@ spec:
service: service:
dns: dns:
type: LoadBalancer type: LoadBalancer
port: 53
annotations:
metallb.universe.tf/address-pool: adguard-dns-pool
loadBalancerIP: 192.168.1.125
web:
type: ClusterIP
port: 8006

View file

@ -0,0 +1,79 @@
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: audiobookshelf
namespace: argo-cd
spec:
destination:
name: ''
namespace: apps-roboces
server: https://kubernetes.default.svc
sources:
- chart: audiobookshelf
repoURL: https://charts.christianhuth.de
targetRevision: 2.4.0
helm:
valuesObject:
service:
type: ClusterIP
port: 8081
ingress:
enabled: true
className: traefik
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
hosts:
- host: audiobooks.roboces.dev
paths:
- path: /
pathType: Prefix
- host: audiobooks.fuku
paths:
- path: /
pathType: Prefix
tls: []
audiobookshelf:
timezone: Europe/Madrid
config:
persistence:
enabled: true
storageClassName: truenas-nfs-csi
accessModes:
- ReadWriteMany
resources:
requests:
storage: 5Gi
metadata:
persistence:
enabled: true
storageClassName: truenas-nfs-csi
accessModes:
- ReadWriteMany
resources:
requests:
storage: 20Gi
backup:
persistence:
enabled: true
storageClassName: truenas-nfs-csi
accessModes:
- ReadWriteMany
resources:
requests:
storage: 20Gi
library:
persistence:
enabled: true
storageClassName: truenas-nfs-csi
accessModes:
- ReadWriteMany
resources:
requests:
storage: 500Gi
project: roboces
syncPolicy:
automated:
prune: true
selfHeal: true

170
k8s/argo-apps/authelia.yaml Normal file
View file

@ -0,0 +1,170 @@
---
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: ClusterIP
port: 9091
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
- key: oidc.audiobookshelf.client_secret
path: oidc.audiobookshelf.client_secret
- key: identity_providers.oidc.jwks.key.pem
path: identity_providers.oidc.jwks.key.pem
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 <auth@fukurokuju.dev>
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
identity_providers:
oidc:
enabled: true
hmac_secret:
path: identity_providers.oidc.hmac.key
jwks:
- key_id: authelia
algorithm: RS256
use: sig
key:
path: /secrets/secrets-authelia/identity_providers.oidc.jwks.key.pem
clients:
- client_id: audiobookshelf
client_name: Audiobookshelf
client_secret:
path: /secrets/secrets-authelia/oidc.audiobookshelf.client_secret
public: false
authorization_policy: two_factor
require_pkce: true
pkce_challenge_method: S256
redirect_uris:
- https://audiobooks.roboces.dev/audiobookshelf/auth/openid/callback
- https://audiobooks.roboces.dev/audiobookshelf/auth/openid/mobile-redirect
- https://audiobooks.fuku/audiobookshelf/auth/openid/callback
- https://audiobooks.fuku/audiobookshelf/auth/openid/mobile-redirect
- audiobookshelf://oauth
scopes:
- openid
- profile
- groups
- email
response_types:
- code
grant_types:
- authorization_code
access_token_signed_response_alg: none
userinfo_signed_response_alg: none
token_endpoint_auth_method: client_secret_basic
- repoURL: https://git.roboces.dev/catalin/fukuops.git
path: k8s/services/authelia
targetRevision: main
project: roboces
syncPolicy:
automated:
prune: true
selfHeal: true

View file

@ -12,7 +12,7 @@ spec:
sources: sources:
- chart: authentik - chart: authentik
repoURL: https://charts.goauthentik.io/ repoURL: https://charts.goauthentik.io/
targetRevision: 2026.5.* targetRevision: 2026.8.*
helm: helm:
valuesObject: valuesObject:
authentik: authentik:
@ -63,16 +63,16 @@ spec:
enabled: true enabled: true
minAvailable: 1 minAvailable: 1
service: service:
type: LoadBalancer type: ClusterIP
servicePortHttp: 9000 servicePortHttp: 9001
servicePortHttps: 9443 servicePortHttps: 9444
annotations: annotations:
traefik.ingress.kubernetes.io/service.serversscheme: https traefik.ingress.kubernetes.io/service.serversscheme: https
traefik.ingress.kubernetes.io/service.serverstransport: apps-fuku-skipverify-authentik@kubernetescrd # yamllint disable rule:line-length traefik.ingress.kubernetes.io/service.serverstransport: apps-fuku-skipverify-authentik@kubernetescrd # yamllint disable rule:line-length
metrics: metrics:
enabled: true enabled: true
service: service:
type: LoadBalancer type: ClusterIP
serviceMonitor: serviceMonitor:
enabled: false enabled: false
ingress: ingress:

View file

@ -26,7 +26,7 @@ spec:
drop: drop:
- all - all
service: service:
type: LoadBalancer type: ClusterIP
port: 8004 port: 8004
ingress: ingress:
enabled: true enabled: true

View file

@ -14,13 +14,13 @@ spec:
sources: sources:
- chart: forgejo - chart: forgejo
repoURL: code.forgejo.org/forgejo-helm repoURL: code.forgejo.org/forgejo-helm
targetRevision: 17.1.4 targetRevision: 17.1.5
helm: helm:
valuesObject: valuesObject:
replicaCount: 1 replicaCount: 1
service: service:
http: http:
type: LoadBalancer type: ClusterIP
ssh: ssh:
type: NodePort type: NodePort
ingress: ingress:

View file

@ -25,7 +25,7 @@ spec:
targetCPUUtilizationPercentage: 80 targetCPUUtilizationPercentage: 80
targetMemoryUtilizationPercentage: 80 targetMemoryUtilizationPercentage: 80
service: service:
type: LoadBalancer type: ClusterIP
port: 8001 port: 8001
targetPort: 4000 targetPort: 4000
ingress: ingress:

View file

@ -27,7 +27,7 @@ spec:
drop: drop:
- all - all
service: service:
type: LoadBalancer type: ClusterIP
config: config:
database: database:
hostname: "192.168.1.3" hostname: "192.168.1.3"
@ -96,7 +96,7 @@ spec:
machineLearning: machineLearning:
enabled: true enabled: true
service: service:
type: LoadBalancer type: ClusterIP
secret: secret:
enabled: true enabled: true
existingSecretName: "immich-secrets" existingSecretName: "immich-secrets"

62
k8s/argo-apps/lldap.yaml Normal file
View file

@ -0,0 +1,62 @@
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: lldap
namespace: argo-cd
spec:
destination:
name: ''
namespace: apps-fuku
server: https://kubernetes.default.svc
sources:
- repoURL: git.roboces.dev/catalin/fukuops
chart: lldap
targetRevision: 1.0.2
helm:
valuesObject:
lldap:
service:
type: ClusterIP
httpPort: 17170
ldapPort: 3890
ldapService:
enabled: true
type: LoadBalancer
port: 3890
loadBalancerIP: 192.168.1.124
annotations:
metallb.universe.tf/address-pool: ldap-pool
ingress:
enabled: true
className: traefik
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
hosts:
- host: ldap.fuku
paths:
- path: /
pathType: Prefix
- host: ldap.fukurokuju.dev
paths:
- path: /
pathType: Prefix
config:
ldapBaseDn: "dc=fuku,dc=local"
httpUrl: "https://ldap.fukurokuju.dev"
persistence:
enabled: true
storageClass: "truenas-nfs-csi"
accessMode: ReadWriteMany
size: 5Gi
secret:
existingSecretName: "lldap-secrets"
podDisruptionBudget:
enabled: true
minAvailable: 1
project: fuku
syncPolicy:
automated:
prune: true
selfHeal: true

View file

@ -18,7 +18,7 @@ spec:
targetRevision: main targetRevision: main
- chart: meilisearch - chart: meilisearch
repoURL: https://meilisearch.github.io/meilisearch-kubernetes repoURL: https://meilisearch.github.io/meilisearch-kubernetes
targetRevision: 0.37.* targetRevision: 0.38.*
helm: helm:
valuesObject: valuesObject:
environment: environment:

View file

@ -0,0 +1,25 @@
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: metallb
namespace: argo-cd
spec:
destination:
name: ''
namespace: metallb-system
server: https://kubernetes.default.svc
sources:
- repoURL: https://metallb.github.io/metallb
chart: metallb
targetRevision: 0.16.*
helm:
valuesObject:
crds:
enabled: true
- repoURL: https://git.roboces.dev/catalin/fukuops.git
path: k8s/services/metallb
targetRevision: main
project: management
syncPolicy:
automated: {}

View file

@ -27,7 +27,7 @@ spec:
drop: drop:
- all - all
service: service:
type: LoadBalancer type: ClusterIP
ingress: ingress:
enabled: true enabled: true
className: "traefik" className: "traefik"

View file

@ -22,7 +22,7 @@ spec:
accessMode: ReadWriteMany accessMode: ReadWriteMany
size: 50Gi size: 50Gi
service: service:
type: LoadBalancer type: ClusterIP
config: config:
server: server:
port: 8086 port: 8086

View file

@ -0,0 +1,48 @@
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: pgadmin
namespace: argo-cd
spec:
destination:
name: ''
namespace: apps-fuku
server: https://kubernetes.default.svc
project: fuku
sources:
- chart: pgadmin4
repoURL: https://helm.runix.net
targetRevision: 1.66.0
helm:
valuesObject:
image:
tag: "9.7"
service:
type: ClusterIP
port: 8010
ingress:
enabled: true
ingressClassName: traefik
hosts:
- host: pgadmin.fuku
paths:
- path: /
pathType: Prefix
env:
email: admin@fukurokuju.dev
enhanced_cookie_protection: "False"
existingSecret: secrets-pgadmin
persistentVolume:
enabled: true
existingClaim: pgadmin-pgadmin4
accessModes:
- ReadWriteMany
size: 10Gi
storageClass: truenas-nfs-csi
test:
enabled: false
syncPolicy:
automated:
prune: true
selfHeal: true

View file

@ -19,7 +19,10 @@ spec:
helm: helm:
valuesObject: valuesObject:
service: service:
type: LoadBalancer type: NodePort
httpPort: 9002
httpsPort: 9445
edgePort: 8005
ingress: ingress:
enabled: true enabled: true
ingressClassName: traefik ingressClassName: traefik
@ -27,6 +30,7 @@ spec:
- host: containers.fukurokuju.dev - host: containers.fukurokuju.dev
paths: paths:
- path: / - path: /
port: 9002
tls: [] tls: []
persistence: persistence:
storageClass: truenas-nfs-csi storageClass: truenas-nfs-csi

View file

@ -13,7 +13,7 @@ spec:
sources: sources:
- chart: renovate - chart: renovate
repoURL: https://docs.renovatebot.com/helm-charts repoURL: https://docs.renovatebot.com/helm-charts
targetRevision: 46.251.* targetRevision: 46.254.*
helm: helm:
valuesObject: valuesObject:
renovate: renovate:

View file

@ -18,14 +18,15 @@ spec:
api: api:
enabled: true enabled: true
service: service:
type: LoadBalancer type: ClusterIP
persistence: persistence:
storageClass: truenas-nfs-csi storageClass: truenas-nfs-csi
accessMode: ReadWriteMany accessMode: ReadWriteMany
dashboard: dashboard:
enabled: true enabled: true
service: service:
type: LoadBalancer type: ClusterIP
port: 8007
ingress: ingress:
enabled: true enabled: true
className: traefik className: traefik
@ -35,7 +36,7 @@ spec:
- path: / - path: /
pathType: Prefix pathType: Prefix
backend: dashboard backend: dashboard
port: 80 port: 8007
- path: /api - path: /api
pathType: Prefix pathType: Prefix
backend: api backend: api

View file

@ -12,7 +12,7 @@ spec:
sources: sources:
- repoURL: git.roboces.dev/catalin/fukuops - repoURL: git.roboces.dev/catalin/fukuops
chart: vaultwarden chart: vaultwarden
targetRevision: 1.0.2 targetRevision: 1.0.4
helm: helm:
valuesObject: valuesObject:
vaultwarden: vaultwarden:
@ -34,7 +34,7 @@ spec:
drop: drop:
- all - all
service: service:
type: LoadBalancer type: ClusterIP
ingress: ingress:
enabled: true enabled: true
className: "traefik" className: "traefik"

View file

@ -7,7 +7,7 @@ chart:
tag: "" tag: ""
service: service:
type: LoadBalancer type: ClusterIP
port: 80 port: 80
targetPort: 80 targetPort: 80

View file

@ -12,7 +12,7 @@ dokuwiki:
securityContext: {} securityContext: {}
service: service:
type: LoadBalancer type: ClusterIP
port: 8004 port: 8004
targetPort: 8080 targetPort: 8080

View file

@ -0,0 +1,13 @@
# Patterns to ignore when building packages.
# This supports shell glob matching.
*.tgz
.env
.git
.git_cache
.idea
*.md
docs/*
tmp/*
vendor/*
.travis.yml
.testignore

View file

@ -0,0 +1,16 @@
apiVersion: v2
name: lldap
description: Light LDAP implementation for authentication
type: application
version: 1.0.2
appVersion: stable
annotations:
artifacthub.io/images: "- name: lldap\n image: lldap/lldap:stable\n"
artifacthub.io/changes: "- kind: added\n description: Add optional LDAP LoadBalancer\
\ service\n"
artifacthub.io/maintainers: "- name: catalin\n email: catalin@roboces.dev\n"
keywords:
- lldap
- ldap
- authentication
- identity

150
k8s/charts/lldap/README.md Normal file
View file

@ -0,0 +1,150 @@
# lldap
Lightweight LDAP authentication server with a web UI.
## TL;DR
```bash
helm install lldap oci://git.roboces.dev/catalin/fukuops/lldap --version 1.0.0 -n apps-roboces
```
## Prerequisites
- Kubernetes 1.19+
- Helm 3+
- A NFS storage class (default: `truenas-nfs-csi`) - only if persistence is enabled
- An existing `Secret` with credentials (see `lldap.secret.existingSecretName`)
## Configuration
All values are nested under the `lldap:` key. Example:
```yaml
lldap:
service:
type: LoadBalancer
httpPort: 17170
ldapPort: 3890
config:
ldapBaseDn: "dc=example,dc=com"
httpUrl: "https://lldap.example.com"
persistence:
enabled: true
storageClass: "truenas-nfs-csi"
accessMode: ReadWriteMany
size: 10Gi
secret:
existingSecretName: lldap-secrets
```
## Secret Requirements
The secret must contain the following keys:
- `LLDAP_JWT_SECRET` - JWT signing secret (generate with `openssl rand -base64 32`)
- `LLDAP_KEY_SEED` - Key seed for password hashing (generate with `openssl rand -base64 32`)
- `LLDAP_LDAP_USER_PASS` - Admin password for LDAP and web UI
Example secret:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: lldap-secrets
type: Opaque
stringData:
LLDAP_JWT_SECRET: <your-jwt-secret>
LLDAP_KEY_SEED: <your-key-seed>
LLDAP_LDAP_USER_PASS: <your-admin-password>
```
## Values Reference
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `lldap.replicaCount` | int | `1` | Number of replicas |
| `lldap.image.repository` | string | `lldap/lldap` | Image repository |
| `lldap.image.pullPolicy` | string | `IfNotPresent` | Image pull policy |
| `lldap.image.tag` | string | `stable` | Image tag |
| `lldap.service.type` | string | `LoadBalancer` | Service type |
| `lldap.service.httpPort` | int | `17170` | Web UI port |
| `lldap.service.ldapPort` | int | `3890` | LDAP port |
| `lldap.config.ldapBaseDn` | string | `dc=example,dc=com` | LDAP base DN |
| `lldap.config.httpUrl` | string | `https://lldap.example.com` | Public URL for web UI |
| `lldap.config.databaseUrl` | string | `""` | Database URL (empty = SQLite) |
| `lldap.config.verbose` | bool | `false` | Enable verbose logging |
| `lldap.ldaps.enabled` | bool | `false` | Enable LDAPS |
| `lldap.ldaps.certFile` | string | `""` | TLS certificate file path |
| `lldap.ldaps.keyFile` | string | `""` | TLS key file path |
| `lldap.smtp.enabled` | bool | `false` | Enable SMTP for password reset |
| `lldap.smtp.enablePasswordReset` | bool | `false` | Enable password reset via email |
| `lldap.smtp.server` | string | `""` | SMTP server |
| `lldap.smtp.port` | int | `465` | SMTP port |
| `lldap.smtp.encryption` | string | `"TLS"` | SMTP encryption (NONE, TLS, STARTTLS) |
| `lldap.smtp.user` | string | `""` | SMTP username |
| `lldap.smtp.fromAddress` | string | `""` | From email address |
| `lldap.smtp.fromName` | string | `""` | From name |
| `lldap.smtp.replyTo` | string | `""` | Reply-to address |
| `lldap.persistence.enabled` | bool | `false` | Enable persistence |
| `lldap.persistence.storageClass` | string | `truenas-nfs-csi` | Storage class |
| `lldap.persistence.accessMode` | string | `ReadWriteMany` | Access mode |
| `lldap.persistence.size` | string | `1Gi` | PVC size |
| `lldap.secret.existingSecretName` | string | `""` | Existing secret name |
| `lldap.debug.enabled` | bool | `false` | Enable debug mode |
| `lldap.resources` | object | `{}` | Container resources |
| `lldap.livenessProbe` | object | (see values.yaml) | Liveness probe |
| `lldap.readinessProbe` | object | (see values.yaml) | Readiness probe |
| `lldap.nodeSelector` | object | `{}` | Node selector |
| `lldap.tolerations` | array | `[]` | Tolerations |
| `lldap.affinity` | object | `{}` | Affinity rules |
## Database
By default, lldap uses SQLite stored in `/data`. To use an external database:
```yaml
lldap:
config:
databaseUrl: "mysql://user:password@mysql-server/my-database"
```
Supported databases: SQLite (default), MySQL/MariaDB, PostgreSQL.
## Persistence
When persistence is enabled, a PVC named `lldap-data` is created. The PVC uses the `truenas-nfs-csi` storage class by default with `ReadWriteMany` access mode.
## Troubleshooting
### Debug Mode
Enable debug mode to troubleshoot issues:
```yaml
lldap:
debug:
enabled: true
```
### Pod not starting
```bash
kubectl get pvc -n apps-roboces -l app.kubernetes.io/name=lldap
```
### Check logs
```bash
kubectl logs lldap-0 -n apps-roboces
```
### Verify configmap
```bash
kubectl get configmap lldap-config -n apps-roboces -o yaml
```
### Default Credentials
If no secret is provided, the default admin password is `password`. **Change this immediately in production.**

View file

@ -0,0 +1,37 @@
{{/* Expand the name of the chart. */}}
{{- define "name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/* Create a default fully qualified app name. */}}
{{- define "fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/* Common labels */}}
{{- define "labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{ include "selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/* Selector labels */}}
{{- define "selectorLabels" -}}
app.kubernetes.io/name: {{ include "name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/* Chart name as the PVC name */}}
{{- define "pvcName" -}}
{{- .Values.lldap.persistence.name | default (printf "%s-data" (include "name" .)) }}
{{- end }}

View file

@ -0,0 +1,33 @@
{{- if .Values.lldap.config }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "fullname" . }}-config
labels:
{{- include "labels" . | nindent 4 }}
data:
LLDAP_LDAP_BASE_DN: {{ .Values.lldap.config.ldapBaseDn | quote }}
LLDAP_HTTP_URL: {{ .Values.lldap.config.httpUrl | quote }}
LLDAP_LDAP_OPTIONS__PORT: {{ .Values.lldap.config.ldapPort | quote }}
LLDAP_HTTP_OPTIONS__PORT: {{ .Values.lldap.config.httpPort | quote }}
LLDAP_VERBOSE: {{ .Values.lldap.config.verbose | quote }}
{{- if .Values.lldap.config.databaseUrl }}
LLDAP_DATABASE_URL: {{ .Values.lldap.config.databaseUrl | quote }}
{{- end }}
{{- if .Values.lldap.ldaps.enabled }}
LLDAP_LDAPS_OPTIONS__ENABLED: "true"
LLDAP_LDAPS_OPTIONS__CERT_FILE: {{ .Values.lldap.ldaps.certFile | quote }}
LLDAP_LDAPS_OPTIONS__KEY_FILE: {{ .Values.lldap.ldaps.keyFile | quote }}
{{- end }}
{{- if .Values.lldap.smtp.enabled }}
LLDAP_SMTP_OPTIONS__ENABLE_PASSWORD_RESET: {{ .Values.lldap.smtp.enablePasswordReset | quote }}
LLDAP_SMTP_OPTIONS__SERVER: {{ .Values.lldap.smtp.server | quote }}
LLDAP_SMTP_OPTIONS__PORT: {{ .Values.lldap.smtp.port | quote }}
LLDAP_SMTP_OPTIONS__SMTP_ENCRYPTION: {{ .Values.lldap.smtp.encryption | quote }}
LLDAP_SMTP_OPTIONS__USER: {{ .Values.lldap.smtp.user | quote }}
LLDAP_SMTP_OPTIONS__FROM: {{ printf "%s <%s>" .Values.lldap.smtp.fromName .Values.lldap.smtp.fromAddress | quote }}
{{- if .Values.lldap.smtp.replyTo }}
LLDAP_SMTP_OPTIONS__TO: {{ .Values.lldap.smtp.replyTo | quote }}
{{- end }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,111 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "fullname" . }}
labels:
{{- include "labels" . | nindent 4 }}
spec:
replicas: {{ .Values.lldap.replicaCount }}
selector:
matchLabels:
{{- include "selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.lldap.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.lldap.debug.enabled }}
initContainers:
- name: debug
image: alpine:3.19
command: ["sleep", "infinity"]
envFrom:
{{- if .Values.lldap.config }}
- configMapRef:
name: {{ include "fullname" . }}-config
{{- end }}
{{- if .Values.lldap.secret.existingSecretName }}
- secretRef:
name: {{ .Values.lldap.secret.existingSecretName }}
{{- end }}
{{- if .Values.lldap.persistence.enabled }}
volumeMounts:
- name: {{ include "pvcName" . }}
mountPath: /data
{{- end }}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop:
- all
{{- end }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.lldap.image.repository }}:{{ .Values.lldap.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.lldap.image.pullPolicy }}
{{- with .Values.lldap.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.lldap.service.httpPort }}
protocol: TCP
- name: ldap
containerPort: {{ .Values.lldap.service.ldapPort }}
protocol: TCP
envFrom:
{{- if .Values.lldap.config }}
- configMapRef:
name: {{ include "fullname" . }}-config
{{- end }}
{{- if .Values.lldap.secret.existingSecretName }}
- secretRef:
name: {{ .Values.lldap.secret.existingSecretName }}
{{- end }}
{{- if .Values.lldap.persistence.enabled }}
volumeMounts:
- name: {{ include "pvcName" . }}
mountPath: /data
{{- end }}
{{- with .Values.lldap.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if not .Values.lldap.debug.enabled }}
{{- with .Values.lldap.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.lldap.readinessProbe }}
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}
{{- if .Values.lldap.persistence.enabled }}
volumes:
- name: {{ include "pvcName" . }}
persistentVolumeClaim:
claimName: {{ include "pvcName" . }}
{{- else }}
volumes:
- name: {{ include "pvcName" . }}
emptyDir: {}
{{- end }}
{{- with .Values.lldap.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.lldap.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.lldap.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}

View file

@ -0,0 +1,41 @@
{{- if .Values.lldap.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "fullname" . }}
labels:
{{- include "labels" . | nindent 4 }}
{{- with .Values.lldap.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.lldap.ingress.className }}
ingressClassName: {{ .Values.lldap.ingress.className }}
{{- end }}
{{- if .Values.lldap.ingress.tls }}
tls:
{{- range .Values.lldap.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.lldap.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType | default "Prefix" }}
backend:
service:
name: {{ include "fullname" $ }}
port:
number: {{ $.Values.lldap.service.httpPort }}
{{- end }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,18 @@
{{- if .Values.lldap.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "fullname" . }}
labels:
{{- include "labels" . | nindent 4 }}
spec:
{{- if .Values.lldap.podDisruptionBudget.minAvailable }}
minAvailable: {{ .Values.lldap.podDisruptionBudget.minAvailable }}
{{- end }}
{{- if .Values.lldap.podDisruptionBudget.maxUnavailable }}
maxUnavailable: {{ .Values.lldap.podDisruptionBudget.maxUnavailable }}
{{- end }}
selector:
matchLabels:
{{- include "selectorLabels" . | nindent 6 }}
{{- end }}

View file

@ -0,0 +1,17 @@
{{- if .Values.lldap.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "pvcName" . }}
labels:
{{- include "labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.lldap.persistence.accessMode | default "ReadWriteMany" }}
{{- if .Values.lldap.persistence.storageClass }}
storageClassName: {{ .Values.lldap.persistence.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.lldap.persistence.size | default "1Gi" }}
{{- end }}

View file

@ -0,0 +1,24 @@
{{- if .Values.lldap.ldapService.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "fullname" . }}-ldap
labels:
{{- include "labels" . | nindent 4 }}
{{- with .Values.lldap.ldapService.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.lldap.ldapService.type }}
{{- if .Values.lldap.ldapService.loadBalancerIP }}
loadBalancerIP: {{ .Values.lldap.ldapService.loadBalancerIP }}
{{- end }}
ports:
- port: {{ .Values.lldap.ldapService.port }}
targetPort: ldap
protocol: TCP
name: ldap
selector:
{{- include "selectorLabels" . | nindent 4 }}
{{- end }}

View file

@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "fullname" . }}
labels:
{{- include "labels" . | nindent 4 }}
spec:
type: {{ .Values.lldap.service.type }}
ports:
- port: {{ .Values.lldap.service.httpPort }}
targetPort: http
protocol: TCP
name: http
- port: {{ .Values.lldap.service.ldapPort }}
targetPort: ldap
protocol: TCP
name: ldap
selector:
{{- include "selectorLabels" . | nindent 4 }}

View file

@ -0,0 +1,97 @@
lldap:
replicaCount: 1
image:
repository: lldap/lldap
pullPolicy: IfNotPresent
tag: "stable"
service:
type: ClusterIP
httpPort: 17170
ldapPort: 3890
ldapService:
enabled: false
type: LoadBalancer
port: 3890
loadBalancerIP: ""
annotations: {}
ingress:
enabled: false
className: "traefik"
annotations: {}
hosts:
- host: lldap.example.com
paths:
- path: /
pathType: Prefix
tls: []
config:
ldapBaseDn: "dc=example,dc=com"
httpUrl: "https://lldap.example.com"
databaseUrl: ""
verbose: false
ldapPort: 3890
httpPort: 17170
ldaps:
enabled: false
certFile: ""
keyFile: ""
smtp:
enabled: false
enablePasswordReset: false
server: ""
port: 465
encryption: "TLS"
user: ""
fromAddress: ""
fromName: ""
replyTo: ""
persistence:
enabled: false
storageClass: "truenas-nfs-csi"
accessMode: ReadWriteMany
size: 1Gi
resources: {}
livenessProbe:
tcpSocket:
port: http
initialDelaySeconds: 30
timeoutSeconds: 15
successThreshold: 1
failureThreshold: 3
periodSeconds: 10
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 15
timeoutSeconds: 2
successThreshold: 1
failureThreshold: 3
periodSeconds: 10
nodeSelector: {}
tolerations: []
affinity: {}
secret:
existingSecretName: ""
podSecurityContext: {}
securityContext: {}
podDisruptionBudget:
enabled: false
minAvailable: 1
debug:
enabled: false

View file

@ -8,7 +8,7 @@ oxicloud:
tag: "" tag: ""
service: service:
type: LoadBalancer type: ClusterIP
port: 8086 port: 8086
ingress: ingress:

View file

@ -2,7 +2,7 @@ apiVersion: v2
name: vaultwarden name: vaultwarden
description: A Helm chart for Vaultwarden password manager description: A Helm chart for Vaultwarden password manager
type: application type: application
version: 1.0.2 version: 1.0.4
appVersion: 1.37.1 appVersion: 1.37.2
annotations: annotations:
artifacthub.io/images: "- name: vaultwarden\n image: vaultwarden/server:1.37.1-alpine\n" artifacthub.io/images: "- name: vaultwarden\n image: vaultwarden/server:1.37.2-alpine\n"

View file

@ -13,7 +13,7 @@ vaultwarden:
service: service:
type: ClusterIP type: ClusterIP
port: 80 port: 8008
targetPort: 80 targetPort: 80
ingress: ingress:

View file

@ -8,14 +8,14 @@ configs:
server: server:
service: service:
type: LoadBalancer type: ClusterIP
servicePortHttp: 8002 servicePortHttp: 8002
servicePortHttps: 8003 servicePortHttps: 8003
redis: redis:
image: image:
repository: docker.io/library/redis repository: docker.io/library/redis
tag: 8.10.0-alpine tag: 8.10.1-alpine
repoServer: repoServer:
livenessProbe: livenessProbe:

View file

@ -25,4 +25,8 @@ spec:
- ghcr.io/antoniolago/charts - ghcr.io/antoniolago/charts
- https://repo.helmforge.dev - https://repo.helmforge.dev
- https://harish2k01.github.io/helm-charts/ - https://harish2k01.github.io/helm-charts/
- https://helm.runix.net
- git.roboces.dev/catalin/fukuops - git.roboces.dev/catalin/fukuops
- https://kubereboot.github.io/charts
- https://charts.goauthentik.io/
- https://git.roboces.dev/catalin/fukuops

View file

@ -18,6 +18,8 @@ spec:
server: https://kubernetes.default.svc server: https://kubernetes.default.svc
- namespace: argo-workflows - namespace: argo-workflows
server: https://kubernetes.default.svc server: https://kubernetes.default.svc
- namespace: metallb-system
server: https://kubernetes.default.svc
clusterResourceWhitelist: clusterResourceWhitelist:
- group: '*' - group: '*'
kind: '*' kind: '*'
@ -25,3 +27,5 @@ spec:
- https://git.roboces.dev/catalin/fukuops.git - https://git.roboces.dev/catalin/fukuops.git
- https://democratic-csi.github.io/charts/ - https://democratic-csi.github.io/charts/
- https://bitnami-labs.github.io/sealed-secrets - https://bitnami-labs.github.io/sealed-secrets
- https://kubernetes-sigs.github.io/descheduler
- https://metallb.github.io/metallb

View file

@ -16,4 +16,7 @@ spec:
sourceRepos: sourceRepos:
- code.forgejo.org/forgejo-helm - code.forgejo.org/forgejo-helm
- git.roboces.dev/catalin/fukuops - git.roboces.dev/catalin/fukuops
- https://git.roboces.dev/catalin/fukuops.git
- https://git.roboces.dev/catalin/huesoporro.git - https://git.roboces.dev/catalin/huesoporro.git
- https://charts.christianhuth.de
- https://charts.authelia.com

View file

@ -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

View file

@ -0,0 +1,21 @@
---
kind: SealedSecret
apiVersion: bitnami.com/v1alpha1
metadata:
name: secrets-authelia
namespace: apps-roboces
spec:
template:
metadata:
name: secrets-authelia
namespace: apps-roboces
type: Opaque
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_providers.oidc.hmac.key: AgCMb1NgRFW7jB8xAXDzTDK336k1w63FNJGJPj9fUrkM/5QT6WuAAPYPeD/3PWG/+0XjUycU4Q2PK/ei8rK6BJ1jwdSRQqBe/ybdxl3WCWGmjB2/jKLIAFQonJdgIzZiY4rUHUepQqhva/wA0eM1nCV/KYuOCIIzdNAGj3E+zyP9yquZcDxknadLuvY6XbTTCdsdwYgFP9qYy8+Vg/K8c2GGubuU34uLkfCG3afow9/gvtSDU5lsZwGtZPcYibOVULQ/CEiGiXBXs7g5W1HjqcJdPaJZDfkG2hPWh4aWMxMkjORt/9NrNmBi70e4LW5cq32wX0Mz+UblDf66Qz3wnGmZ93owAw1N0MtlI/V0W2XvSnxcXvmloHGfbll2rhFiUFPFmWq71Uv52Qc8rAMOeuJgRnrlG3QHxLFkTonMYcasQrfm8MjHpaNkemsdA6XO9VvrRVxLyN31lV+2bbeqh2wHZgCRIbU4gUyApi69Rd1TyEYVW9QSxWbPkX5B5eaCqDCZLX9aQocfElrKk/zzvY2J53s07AkvsowgwbWhwpzwAYB2ajBndAcNhzS311ve1kzZlp498ePGCdpcyuTJfVWTKoXdmJu3xSg0P7Czts/zstQYGSpp+0I4gwzYTtSuf1M841Obc78l2PaUqN5P3YIHdq4ZiMTWPHhqgerobyf6XxtXcO5iEf7zSGYexRwaTroMp/Sqx9P0eTX1rufwkmvMlPH6mClr3RMVGno7uFuC1SkRM3Ox0a4wJ0Y2B5IX/7C+Pd68Z1VQX+wJeson0X2K
identity_providers.oidc.jwks.key.pem: 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
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==
oidc.audiobookshelf.client_secret: AgB5usnmgdDGm4MjtMTZDxhGb6qCxoMqx/2f3tZ7JYDgoVEspeL2tFBxXBOUDamCxpnuSp12O1ZcUpOw+9XAj4079vG7sWHAzjX0nIIJCNIjH3Y6+rhGh7J7nnWc9UJqzeWM6T4k9Dntyhsn96Xp5RDSUqhH68/u4OeqwzRJJofHlbqLmYq6+bq4MZfv/wZIsqfA02aWFJk7ST2qqD1Sc+oz0HkP8jXk9WwCCnRCuZoZgYUTHbPZAgOLMMRTlqTF4jHjFQ5B+IQTh2z53Agq6G8QLmU49J7RtPIunG+2Gf71KYXIeKktH8QwI9nBof1PXGYMmOvCCzqoBLsIBqYg/iAQ+kjlkbJov/3SBk+uwdj/wvG99+AJPVB2VHr8rinW0WXptzUATqYrHg0ufBxX4lKvVhqdkY7AaHPoI5ZwKi7AYftuo0IxDHGYC4GXVg9mWY7T6nNcvqB6s1epBNDjQnsCk+krxfZ83OecHUA4apwrSX4/Ytc+GkuQ/eJcdr9SoMXW7yzFfOLMh4+8syH7xigLwszw0t6lxJ37DnVbuYewhvazIU2TBWyTtQYnBeqVm2Wllh6xZiP5PyJ+VEi2fwGZVW6NX0Hh0gVmYNduj8xAJS4GRLwE2cOrN99kTMIXBdqmjXdYuAQKYDMlWU8QtTqtsoa4Y3UV/s7f7DdVtW+nIwxHFStNSQE4kOqPtUpgKbJPpPTjlPmIhW5Zm1gj3eOWIRLRV6UAkiJ2t6T2BEh+uqhcf26pbSjbfnqSEPVhfAzarnFV32Dkgue0I6rka/GPlf4fSK89JuXT9Igwdm0cKwOepYynZIf0NSSB1bWcgTTuL3N4CjteWc/XzacFkXvWuNJ3/WgN/brGZb2nRmmHEA==
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==

View file

@ -0,0 +1,19 @@
---
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: adguard-dns-pool
namespace: metallb-system
spec:
addresses:
- 192.168.1.125/32
autoAssign: false
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: adguard-dns-l2
namespace: metallb-system
spec:
ipAddressPools:
- adguard-dns-pool

View file

@ -0,0 +1,19 @@
---
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: ldap-pool
namespace: metallb-system
spec:
addresses:
- 192.168.1.124/32
autoAssign: false
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: ldap-l2
namespace: metallb-system
spec:
ipAddressPools:
- ldap-pool

View file

@ -0,0 +1,21 @@
---
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: ingress-pool
namespace: metallb-system
spec:
addresses:
- 192.168.1.126/32
# Only Traefik should get this IP. Other LoadBalancer services that are
# not yet migrated will stay pending instead of stealing the single VIP.
autoAssign: false
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: ingress-l2
namespace: metallb-system
spec:
ipAddressPools:
- ingress-pool

View file

@ -0,0 +1,5 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: metallb-system

212
scripts/add-secret.py Executable file
View file

@ -0,0 +1,212 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["typer", "ruamel.yaml"]
# ///
import json
import shutil
import subprocess
import sys
from difflib import unified_diff
from pathlib import Path
from typing import Annotated, Any, Optional
import typer
from ruamel.yaml import YAML
app = typer.Typer(help="Add or update a secret field in an existing SealedSecret")
def resolve_file(file: Path) -> Path:
if file.is_dir():
candidate = file / "sealedsecrets.yaml"
if not candidate.is_file():
raise typer.BadParameter(f"{file} is a directory and contains no sealedsecrets.yaml")
return candidate
if not file.is_file():
raise typer.BadParameter(f"{file} does not exist")
return file
def looks_like_json(text: str) -> bool:
for char in text:
if char in (" ", "\t", "\n", "\r"):
continue
return char == "{"
return False
def extract_header_comments(text: str) -> tuple[str, str]:
"""Split leading comments/blank lines from the rest of the file."""
lines = text.splitlines(keepends=True)
idx = 0
while idx < len(lines):
stripped = lines[idx].strip()
if stripped == "" or stripped.startswith("#"):
idx += 1
else:
break
return "".join(lines[:idx]), "".join(lines[idx:])
def load_documents(text: str) -> list[Any]:
if looks_like_json(text):
# JSON is valid YAML, but parse it explicitly so we can normalize it to YAML output.
return [json.loads(text)]
yaml = YAML(typ="rt")
yaml.preserve_quotes = True
return list(yaml.load_all(text))
def _represent_none(representer, data):
return representer.represent_scalar("tag:yaml.org,2002:null", "null")
def dump_documents(docs: list[Any]) -> str:
yaml = YAML(typ="rt")
yaml.default_flow_style = False
yaml.preserve_quotes = True
yaml.width = 4096
yaml.explicit_start = True
yaml.representer.add_representer(type(None), _represent_none)
from ruamel.yaml.compat import StringIO
stream = StringIO()
yaml.dump_all(docs, stream)
return stream.getvalue()
def detect_scope(doc: Any) -> str:
annotations = doc.get("metadata", {}).get("annotations", {}) or {}
for key, value in annotations.items():
if key == "sealedsecrets.bitnami.com/cluster-wide" and str(value).lower() == "true":
return "cluster-wide"
if key == "sealedsecrets.bitnami.com/namespace-wide" and str(value).lower() == "true":
return "namespace-wide"
return "strict"
def seal_value(value: str, doc: Any, cert: Optional[Path]) -> str:
metadata = doc.get("metadata", {})
spec = doc.get("spec", {})
name = metadata.get("name")
if not name:
raise typer.BadParameter("SealedSecret has no metadata.name")
namespace = metadata.get("namespace") or spec.get("template", {}).get("metadata", {}).get("namespace")
if not namespace:
raise typer.BadParameter("SealedSecret has no namespace in metadata or spec.template.metadata")
cmd = ["kubeseal", "--raw", "--from-file=/dev/stdin"]
if cert:
cmd.extend(["--cert", str(cert)])
scope = detect_scope(doc)
if scope == "cluster-wide":
cmd.extend(["--scope", "cluster-wide"])
elif scope == "namespace-wide":
cmd.extend(["--namespace", namespace, "--scope", "namespace-wide"])
else:
cmd.extend(["--namespace", namespace, "--name", name])
result = subprocess.run(
cmd,
input=value,
text=True,
capture_output=True,
)
if result.returncode != 0:
typer.echo(f"kubeseal failed:\n{result.stderr}", err=True)
raise typer.Exit(result.returncode)
return result.stdout.strip()
def find_target_document(docs: list[Any], key: str, secret_name: Optional[str]) -> Any:
if secret_name:
for doc in docs:
if doc.get("metadata", {}).get("name") == secret_name:
return doc
raise typer.BadParameter(f"No SealedSecret named '{secret_name}' found in file")
if len(docs) == 1:
return docs[0]
candidates = [
doc for doc in docs
if key in (doc.get("spec", {}).get("encryptedData", {}) or {})
]
if len(candidates) == 1:
return candidates[0]
if len(candidates) > 1:
names = [doc.get("metadata", {}).get("name", "<unnamed>") for doc in candidates]
raise typer.BadParameter(
f"Key '{key}' exists in multiple SealedSecrets ({', '.join(names)}). "
"Use --secret-name to choose one."
)
names = [doc.get("metadata", {}).get("name", "<unnamed>") for doc in docs]
raise typer.BadParameter(
f"File contains multiple SealedSecrets and key '{key}' does not exist yet. "
f"Available: {', '.join(names)}. Use --secret-name to choose one."
)
@app.command()
def add(
name: Annotated[str, typer.Option("--name", help="Key name in SealedSecret encryptedData")],
secret: Annotated[str, typer.Option("--secret", help="Plaintext secret value")],
file: Annotated[Path, typer.Option("--file", help="Path to sealedsecrets.yaml or its directory")],
secret_name: Annotated[
Optional[str],
typer.Option("--secret-name", help="Name of the SealedSecret resource to update (required for multi-secret files when the key is new)"),
] = None,
cert: Annotated[
Optional[Path],
typer.Option("--cert", help="Path to a kubeseal certificate for offline sealing"),
] = None,
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show diff without writing")] = False,
) -> None:
target_path = resolve_file(file)
original_text = target_path.read_text()
header, body = extract_header_comments(original_text)
docs = load_documents(body)
if not docs:
raise typer.BadParameter(f"{target_path} contains no documents")
target = find_target_document(docs, name, secret_name)
sealed = seal_value(secret, target, cert)
if "spec" not in target or target["spec"] is None:
target["spec"] = {}
if "encryptedData" not in target["spec"] or target["spec"]["encryptedData"] is None:
target["spec"]["encryptedData"] = {}
old_value = target["spec"]["encryptedData"].get(name)
action = "updated" if old_value is not None else "added"
target["spec"]["encryptedData"][name] = sealed
new_text = header + dump_documents(docs)
if dry_run:
diff = unified_diff(
original_text.splitlines(keepends=True),
new_text.splitlines(keepends=True),
fromfile=str(target_path),
tofile=str(target_path),
)
sys.stdout.writelines(diff)
raise typer.Exit()
backup_path = target_path.with_suffix(target_path.suffix + ".bak")
shutil.copy2(target_path, backup_path)
target_path.write_text(new_text)
typer.echo(f"{action} '{name}' in SealedSecret '{target.get('metadata', {}).get('name')}' ({target_path})")
typer.echo(f"backup saved to {backup_path}")
if __name__ == "__main__":
app()

View file

@ -92,6 +92,19 @@ fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
K8S_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" K8S_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
extract_lb_port() {
local file="$1"
local field="$2"
local selector="$3"
local value
value=$(yq -r '.. | objects | select(.type == "LoadBalancer") | '"$selector"' // empty' "$file" 2>/dev/null || true)
if [[ -n "$value" ]] && [[ "$value" != "null" ]] && [[ "$value" =~ ^[0-9]+$ ]]; then
PORTS+=("$value")
[[ $VERBOSE -eq 1 ]] && echo "[DEBUG] Found $field $value in $file"
fi
}
collect_ports() { collect_ports() {
PORTS=() PORTS=()
@ -104,29 +117,15 @@ collect_ports() {
continue continue
fi fi
lb_port=$(yq -r '.. | objects | select(.type == "LoadBalancer") | select(.port != null) | .port // empty' "$file" 2>/dev/null || true) extract_lb_port "$file" ".port" ".port"
if [[ -n "$lb_port" ]] && [[ "$lb_port" != "null" ]] && [[ "$lb_port" =~ ^[0-9]+$ ]]; then extract_lb_port "$file" "servicePortHttp" ".servicePortHttp"
PORTS+=("$lb_port") extract_lb_port "$file" "servicePortHttps" ".servicePortHttps"
[[ $VERBOSE -eq 1 ]] && echo "[DEBUG] Found .port $lb_port in $file" extract_lb_port "$file" "dns.port" ".dns.port"
fi extract_lb_port "$file" "httpPort" ".httpPort"
extract_lb_port "$file" "httpsPort" ".httpsPort"
service_port_http=$(yq -r '.. | objects | select(.type == "LoadBalancer") | .servicePortHttp // empty' "$file" 2>/dev/null || true) extract_lb_port "$file" "edgePort" ".edgePort"
if [[ -n "$service_port_http" ]] && [[ "$service_port_http" != "null" ]] && [[ "$service_port_http" =~ ^[0-9]+$ ]]; then extract_lb_port "$file" "ldapPort" ".ldapPort"
PORTS+=("$service_port_http") extract_lb_port "$file" "servicePort" ".servicePort"
[[ $VERBOSE -eq 1 ]] && echo "[DEBUG] Found servicePortHttp $service_port_http in $file"
fi
service_port_https=$(yq -r '.. | objects | select(.type == "LoadBalancer") | .servicePortHttps // empty' "$file" 2>/dev/null || true)
if [[ -n "$service_port_https" ]] && [[ "$service_port_https" != "null" ]] && [[ "$service_port_https" =~ ^[0-9]+$ ]]; then
PORTS+=("$service_port_https")
[[ $VERBOSE -eq 1 ]] && echo "[DEBUG] Found servicePortHttps $service_port_https in $file"
fi
service_dns_port=$(yq -r '.. | objects | select(.type == "LoadBalancer") | .dns.port // empty' "$file" 2>/dev/null || true)
if [[ -n "$service_dns_port" ]] && [[ "$service_dns_port" != "null" ]] && [[ "$service_dns_port" =~ ^[0-9]+$ ]]; then
PORTS+=("$service_dns_port")
[[ $VERBOSE -eq 1 ]] && echo "[DEBUG] Found dns.port $service_dns_port in $file"
fi
done < <(find "$K8S_DIR/k8s" -name "*.yaml" -type f 2>/dev/null) || true done < <(find "$K8S_DIR/k8s" -name "*.yaml" -type f 2>/dev/null) || true
if command -v kubectl &>/dev/null && kubectl cluster-info &>/dev/null 2>&1; then if command -v kubectl &>/dev/null && kubectl cluster-info &>/dev/null 2>&1; then

293
scripts/users.py Executable file
View file

@ -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()

View file

@ -114,12 +114,31 @@ resource "adguard_rewrite" "vault" {
answer = "192.168.1.12" answer = "192.168.1.12"
} }
resource "adguard_rewrite" "pocketid" { resource "adguard_rewrite" "authelia-roboces" {
domain = "auth.roboces.dev" domain = "auth.roboces.dev"
answer = "192.168.1.12" answer = "192.168.1.12"
} }
resource "adguard_rewrite" "authelia" {
domain = "auth.fuku"
answer = "192.168.1.12"
}
resource "adguard_rewrite" "dns" { resource "adguard_rewrite" "dns" {
domain = "dns.fuku" domain = "dns.fuku"
answer = "192.168.1.12" answer = "192.168.1.12"
} }
resource "adguard_rewrite" "ldap" {
domain = "ldap.fuku"
answer = "192.168.1.12"
}
resource "adguard_rewrite" "audiobooks" {
domain = "audiobooks.roboces.dev"
answer = "192.168.1.12"
}
resource "adguard_rewrite" "immich" {
domain = "photos.roboces.dev"
answer = "192.168.1.12"
}