From 99536c53555a02185366e5805a0eee10806a58bc Mon Sep 17 00:00:00 2001
From: il
Date: Wed, 9 Sep 2026 23:13:28 +0900
Subject: [PATCH] 2026-SEP-09
---
ansible/inventory/group_vars/all.yaml | 135 +++-
ansible/inventory/host_vars/auth.yaml | 2 +-
ansible/playbooks/app/site.yaml | 70 +-
ansible/roles/app/handlers/main.yaml | 55 ++
.../app/tasks/services/set_groupoffice.yaml | 118 +++
.../roles/app/tasks/services/set_immich.yaml | 8 -
.../roles/app/tasks/services/set_kaneo.yaml | 84 ++
.../roles/app/tasks/services/set_llama.yaml | 90 +++
.../app/tasks/services/set_openwebui.yaml | 84 ++
.../tasks/services/set_stablediffusion.yaml | 91 +++
config/node/fw/nftables.conf.j2 | 2 +-
config/secrets/secrets.yaml | 34 +-
.../app/collabora/collabora.container.j2 | 1 +
.../groupoffice/config/Authenticator.php.j2 | 519 ++++++++++++
.../app/groupoffice/config/Connection.php.j2 | 736 ++++++++++++++++++
.../app/groupoffice/config/config.php.j2 | 36 +
.../app/groupoffice/config/configfile.php.j2 | 252 ++++++
.../groupoffice/config/groupoffice-ca.ini.j2 | 1 +
.../app/groupoffice/groupoffice.container.j2 | 41 +
.../services/groupoffice-cron.service | 9 +
.../services/groupoffice-cron.timer | 10 +
.../app/homepage/config/services.yaml.j2 | 3 +-
.../containers/app/kaneo/kaneo.container.j2 | 48 ++
.../containers/app/llama/config/models.ini.j2 | 13 +
.../containers/app/llama/llama.container.j2 | 34 +
.../app/openwebui/openwebui.container.j2 | 84 ++
.../stablediffusion.container.j2 | 36 +
.../containers/app/sure/sure-web.container.j2 | 11 +
.../app/sure/sure-worker.container.j2 | 10 +
.../auth/authelia/config/authelia.yaml.j2 | 46 +-
.../containers/common/caddy/etc/Caddyfile.j2 | 4 +
.../services/app/ovms/group_vars.yaml | 8 +
.../services/app/ovms/ovms.container.j2 | 35 +
docs/archives/services/app/ovms/set_ovms.yaml | 106 +++
34 files changed, 2779 insertions(+), 37 deletions(-)
create mode 100644 ansible/roles/app/tasks/services/set_groupoffice.yaml
create mode 100644 ansible/roles/app/tasks/services/set_kaneo.yaml
create mode 100644 ansible/roles/app/tasks/services/set_llama.yaml
create mode 100644 ansible/roles/app/tasks/services/set_openwebui.yaml
create mode 100644 ansible/roles/app/tasks/services/set_stablediffusion.yaml
create mode 100644 config/services/containers/app/groupoffice/config/Authenticator.php.j2
create mode 100644 config/services/containers/app/groupoffice/config/Connection.php.j2
create mode 100644 config/services/containers/app/groupoffice/config/config.php.j2
create mode 100644 config/services/containers/app/groupoffice/config/configfile.php.j2
create mode 100644 config/services/containers/app/groupoffice/config/groupoffice-ca.ini.j2
create mode 100644 config/services/containers/app/groupoffice/groupoffice.container.j2
create mode 100644 config/services/containers/app/groupoffice/services/groupoffice-cron.service
create mode 100644 config/services/containers/app/groupoffice/services/groupoffice-cron.timer
create mode 100644 config/services/containers/app/kaneo/kaneo.container.j2
create mode 100644 config/services/containers/app/llama/config/models.ini.j2
create mode 100644 config/services/containers/app/llama/llama.container.j2
create mode 100644 config/services/containers/app/openwebui/openwebui.container.j2
create mode 100644 config/services/containers/app/stablediffusion/stablediffusion.container.j2
create mode 100644 docs/archives/services/app/ovms/group_vars.yaml
create mode 100644 docs/archives/services/app/ovms/ovms.container.j2
create mode 100644 docs/archives/services/app/ovms/set_ovms.yaml
diff --git a/ansible/inventory/group_vars/all.yaml b/ansible/inventory/group_vars/all.yaml
index 1062359..fb3a2f0 100644
--- a/ansible/inventory/group_vars/all.yaml
+++ b/ansible/inventory/group_vars/all.yaml
@@ -160,6 +160,84 @@ services:
http: "3000" # app
dependency:
web: "app-public"
+ openwebui:
+ domain:
+ public: "openwebui"
+ internal: "openwebui.app"
+ ports:
+ http: "8002"
+ dependency:
+ web: "app-public"
+ db: "postgresql"
+ llama:
+ ports:
+ http: "10000"
+ dependency:
+ models:
+ default: |
+ n-gpu-layers = all
+ kv-unified = true
+ cache-prompt = true
+ cache-type-k = q8_0
+ cache-type-v = q8_0
+ flash-attn = auto
+ reasoning = off
+ jinja = true
+ lazy-mode = off
+ Nemotron3.5-Lightning-30B-A3B: |
+ model = /models/Nemotron3.5-Lightning-30B-A3B/Nemotron-3.5-Lightning-30B-A3B-Heretic-Uncensored-Q4_K_M-GGUF.gguf
+ c = 65536
+ parallel = 2
+ load-on-startup = true
+ # version: mradermacher/LFM2-24B-A2B-heretic-ara-GGUF
+ # LFM2-24B-A2B: |
+ # model = /models/LFM2-24B-A2B/LFM2-24B-A2B-heretic-ara.Q4_K_M.gguf
+ # c = 65536
+ # parallel = 2
+ # load-on-startup = true
+ # # version: SC117/LFM2.5-VL-3B-Uncensored-GGUF
+ # LFM2-VL-3B: |
+ # model = /models/LFM2-VL-3B/LFM2.5-VL-3B-Uncensored-Q4_K_M.gguf
+ # mmproj = /models/LFM2-VL-3B/mmproj-LFM2.5-VL-3B-BF16.gguf
+ # c = 65536
+ # parallel = 1
+ # load-on-startup = true
+ # version: HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M
+ # Qwen3.6: |
+ # model = /models/Qwen3.6-35B-A3B/Qwen3.6-35B-A3B-Q4_K_M.gguf
+ # mmproj = /models/Qwen3.6-35B-A3B/mmproj-Qwen3.6-35B-BF16.gguf
+ # c = 65536
+ # parallel = 2
+ # load-on-startup = true
+ # image-min-tokens = 1024
+ # # version: HauhauCS/Qwen3.5-2B-Uncensored-HauhauCS-Aggressiv-Q8_0
+ # Qwen3.5-image: |
+ # model = /models/Qwen3.5-2B/Qwen3.5-2B-Q8_0.gguf
+ # c = 32768
+ # parallel = 1
+ # load-on-startup = true
+ # version: LiquidAI/LFM2.5-2.6B-GGUF
+ # LFM2.5: |
+ # model = /models/LFM2.5-2.6B/LFM2.5-2.6B-Q8_0.gguf
+ # c = 32768
+ # parallel = 2
+ # load-on-startup = true
+ stablediffusion:
+ ports:
+ http: "10001"
+ dependency:
+ models:
+ active: "zImage"
+ zImage: |
+ --diffusion-model /models/zImage/moodyRealMix_zitV7Global-Q8_0.gguf \
+ --llm /models/zImage/Qwen3-4B-Instruct-2507-Q4_K_M.gguf \
+ --vae /models/zImage/ae.safetensors \
+ --backend gpu \
+ --cfg-scale 1.0 \
+ --steps 9 \
+ --vae-tiling \
+ --vae-tile-size 32x32 \
+ --vae-tile-overlap 0.5
snappymail:
domain:
public: "webmail"
@@ -212,6 +290,28 @@ services:
dependency:
web: "app-public"
db: "postgresql"
+ groupoffice:
+ domain:
+ public: "groupoffice"
+ internal: "groupoffice.app"
+ ports:
+ http: "8005"
+ subuid: "100032"
+ route:
+ dav: true
+ dependency:
+ web: "app-public"
+ db: "mariadb"
+ kaneo:
+ domain:
+ public: "kaneo"
+ internal: "kaneo.app"
+ ports:
+ http: "8006"
+ subuid: "101000"
+ dependency:
+ web: "app-public"
+ db: "postgresql"
nextcloud:
domain:
public: "nextcloud"
@@ -318,9 +418,9 @@ version:
# infra
ca: "0.30.2"
## Postgresql
- postgresql: "18.4"
+ postgresql: "18.6"
## Mariadb
- mariadb: "12.3.2"
+ mariadb: "12.3.3"
### For immich - https://github.com/immich-app/base-images/blob/main/postgres/versions.yaml
vectorchord: "1.1.1"
## lldap
@@ -328,25 +428,34 @@ version:
## maddy
maddy: "0.9.5"
x509-exporter: "4.1.0"
- prometheus: "v3.13.2"
- loki: "3.7.4"
- grafana: "13.1.1"
+ prometheus: "v3.14.0"
+ loki: "3.7.7"
+ grafana: "13.2.1"
# Auth
authelia: "4.39.20"
# App
- vaultwarden: "1.37.1"
- gitea: "1.27.2"
- redis: "8.10.0"
+ vaultwarden: "1.37.2"
+ gitea: "1.27.3"
+ redis: "8.10.1"
immich: "v3.1.0"
nextcloud: "34.0.3"
- collabora: "26.04.3.1.1"
- sure: "0.7.3"
- outline: "1.9.2"
+ collabora: "26.04.3.2.1"
+ sure: "0.7.4"
+ outline: "1.10.0"
homebox: "0.26.2"
homepage: "v2.0.0"
snappymail: "v2.38.2"
- mastodon: "v4.6.6"
+ mastodon: "v4.6.7"
papra: "26.6.1-rootless"
tandoor: "2.6.13"
- bookstack: "26.5.3"
+ bookstack: "26.5.4"
linkding: "1.46.2"
+ openwebui: "v0.11.3"
+ # https://github.com/ggml-org/llama.cpp/pkgs/container/llama.cpp/versions
+ llama: "server-vulkan-b10830"
+ # https://github.com/leejet/stable-diffusion.cpp/pkgs/container/stable-diffusion.cpp
+ # master-sycl@sha256:f27ebe3fea7dcb5978241425d3a115ba6736cfc33c42e75233f22f7411449e9c has problem in it, it cannot get vRAM from UMA properly
+ # Pin due to a SYCL/UMA false-OOM regression in newer stable-diffusion.cpp ModelManager memory accounting.
+ stablediffusion: "master-sycl@sha256:67ccb5d11bf55578f7938daeb7c9fee1d0dd1344c089c090b9065714d7960fd5"
+ groupoffice: "26.0.43"
+ kaneo: "2.23.2"
diff --git a/ansible/inventory/host_vars/auth.yaml b/ansible/inventory/host_vars/auth.yaml
index 50abbff..68521da 100644
--- a/ansible/inventory/host_vars/auth.yaml
+++ b/ansible/inventory/host_vars/auth.yaml
@@ -16,7 +16,7 @@ vm:
shares: 1024
- memory: 2
+ memory: 1
storage: 32
lan_mac: "0a:49:6e:4d:02:00"
lan_net: "lan-net"
diff --git a/ansible/playbooks/app/site.yaml b/ansible/playbooks/app/site.yaml
index e3c660b..a654204 100644
--- a/ansible/playbooks/app/site.yaml
+++ b/ansible/playbooks/app/site.yaml
@@ -103,6 +103,7 @@
- "netcat-openbsd"
- "dbus-user-session"
- "ksmtuned"
+ - "rsync"
state: "present"
become: true
tags: ["init", "site", "install-packages"]
@@ -216,16 +217,24 @@
name: "app"
tasks_from: "services/set_immich"
apply:
- tags: ["site", "immich"]
- tags: ["site", "immich"]
+ tags: ["site", "immich", "redis"]
+ tags: ["site", "immich", "redis"]
- name: Set nextcloud
ansible.builtin.include_role:
name: "app"
tasks_from: "services/set_nextcloud"
apply:
- tags: ["site", "nextcloud"]
- tags: ["site", "nextcloud"]
+ tags: ["site", "nextcloud", "redis"]
+ tags: ["site", "nextcloud", "redis"]
+
+ - name: Set groupoffice
+ ansible.builtin.include_role:
+ name: "app"
+ tasks_from: "services/set_groupoffice"
+ apply:
+ tags: ["site", "groupoffice"]
+ tags: ["site", "groupoffice"]
- name: Set collabora
ansible.builtin.include_role:
@@ -235,21 +244,29 @@
tags: ["site", "collabora"]
tags: ["site", "collabora"]
+ - name: Set kaneo
+ ansible.builtin.include_role:
+ name: "app"
+ tasks_from: "services/set_kaneo"
+ apply:
+ tags: ["site", "kaneo"]
+ tags: ["site", "kaneo"]
+
- name: Set sure
ansible.builtin.include_role:
name: "app"
tasks_from: "services/set_sure"
apply:
- tags: ["site", "sure"]
- tags: ["site", "sure"]
+ tags: ["site", "sure", "redis"]
+ tags: ["site", "sure", "redis"]
- name: Set outline
ansible.builtin.include_role:
name: "app"
tasks_from: "services/set_outline"
apply:
- tags: ["site", "outline"]
- tags: ["site", "outline"]
+ tags: ["site", "outline", "redis"]
+ tags: ["site", "outline", "redis"]
- name: Set homebox
ansible.builtin.include_role:
@@ -264,8 +281,8 @@
name: "app"
tasks_from: "services/set_mastodon"
apply:
- tags: ["site", "mastodon"]
- tags: ["site", "mastodon"]
+ tags: ["site", "mastodon", "redis"]
+ tags: ["site", "mastodon", "redis"]
- name: Set papra
ansible.builtin.include_role:
@@ -299,6 +316,30 @@
tags: ["site", "linkding"]
tags: ["site", "linkding"]
+ - name: Set llama
+ ansible.builtin.include_role:
+ name: "app"
+ tasks_from: "services/set_llama"
+ apply:
+ tags: ["site", "llama"]
+ tags: ["site", "llama"]
+
+ - name: Set stablediffusion
+ ansible.builtin.include_role:
+ name: "app"
+ tasks_from: "services/set_stablediffusion"
+ apply:
+ tags: ["site", "stablediffusion"]
+ tags: ["site", "stablediffusion"]
+
+ - name: Set openwebui
+ ansible.builtin.include_role:
+ name: "app"
+ tasks_from: "services/set_openwebui"
+ apply:
+ tags: ["site", "openwebui"]
+ tags: ["site", "openwebui"]
+
- name: Set homepage
ansible.builtin.include_role:
name: "app"
@@ -320,10 +361,19 @@
state: "present"
become: true
notify:
+ - "notification_add_user_in_group"
- "notification_update_initramfs"
- "notification_reboot_app"
tags: ["init"]
handlers:
+ - name: Add user in video, render group
+ ansible.builtin.user:
+ name: "{{ ansible_user }}"
+ state: "present"
+ groups: "video, render"
+ append: true
+ become: true
+ listen: "notification_add_user_in_group"
- name: Update initramfs
ansible.builtin.command:
update-initramfs -u
diff --git a/ansible/roles/app/handlers/main.yaml b/ansible/roles/app/handlers/main.yaml
index 490f45a..e06bb70 100644
--- a/ansible/roles/app/handlers/main.yaml
+++ b/ansible/roles/app/handlers/main.yaml
@@ -183,3 +183,58 @@
changed_when: false
listen: "notification_restart_linkding"
ignore_errors: true # noqa: ignore-errors
+
+- name: Restart openwebui
+ ansible.builtin.systemd:
+ name: "openwebui.service"
+ state: "restarted"
+ enabled: true
+ scope: "user"
+ daemon_reload: true
+ changed_when: false
+ listen: "notification_restart_openwebui"
+ ignore_errors: true # noqa: ignore-errors
+
+- name: Restart llama
+ ansible.builtin.systemd:
+ name: "llama.service"
+ state: "restarted"
+ enabled: true
+ scope: "user"
+ daemon_reload: true
+ changed_when: false
+ listen: "notification_restart_llama"
+ ignore_errors: true # noqa: ignore-errors
+
+- name: Restart stablediffusion
+ ansible.builtin.systemd:
+ name: "stablediffusion.service"
+ state: "restarted"
+ enabled: true
+ scope: "user"
+ daemon_reload: true
+ changed_when: false
+ listen: "notification_restart_stablediffusion"
+ ignore_errors: true # noqa: ignore-errors
+
+- name: Restart groupoffice
+ ansible.builtin.systemd:
+ name: "groupoffice.service"
+ state: "restarted"
+ enabled: true
+ scope: "user"
+ daemon_reload: true
+ changed_when: false
+ listen: "notification_restart_groupoffice"
+ ignore_errors: true # noqa: ignore-errors
+
+- name: Restart kaneo
+ ansible.builtin.systemd:
+ name: "kaneo.service"
+ state: "restarted"
+ enabled: true
+ scope: "user"
+ daemon_reload: true
+ changed_when: false
+ listen: "notification_restart_kaneo"
+ ignore_errors: true # noqa: ignore-errors
diff --git a/ansible/roles/app/tasks/services/set_groupoffice.yaml b/ansible/roles/app/tasks/services/set_groupoffice.yaml
new file mode 100644
index 0000000..22c7810
--- /dev/null
+++ b/ansible/roles/app/tasks/services/set_groupoffice.yaml
@@ -0,0 +1,118 @@
+---
+- name: Create groupoffice directory
+ ansible.builtin.file:
+ path: "{{ node['home_path'] }}/{{ item }}"
+ state: "directory"
+ owner: "{{ services['groupoffice']['subuid'] }}"
+ group: "{{ node['group'] }}"
+ mode: "0770"
+ loop:
+ - "data/containers/groupoffice"
+ - "data/containers/groupoffice/data"
+ - "containers/groupoffice"
+ - "containers/groupoffice/ssl"
+ - "containers/groupoffice/config"
+ become: true
+
+- name: Deploy root certificate
+ ansible.builtin.copy:
+ content: |
+ {{ hostvars['console']['ca']['root']['crt'] }}
+ dest: "{{ node['home_path'] }}/containers/groupoffice/ssl/{{ root_cert_filename }}"
+ owner: "{{ services['groupoffice']['subuid'] }}"
+ group: "{{ node['group'] }}"
+ mode: "0440"
+ become: true
+ notify: "notification_restart_groupoffice"
+ no_log: true
+
+- name: Register secret value to podman secret
+ containers.podman.podman_secret:
+ name: "{{ item.name }}"
+ data: "{{ item.value }}"
+ state: "present"
+ force: true
+ loop:
+ - name: "GROUPOFFICE_DB_PASSWORD"
+ value: "{{ hostvars['console']['mariadb']['password']['groupoffice'] }}"
+ notify: "notification_restart_groupoffice"
+ no_log: true
+
+- name: Deploy config files
+ ansible.builtin.template:
+ src: "{{ hostvars['console']['node']['config_path'] }}/services/containers/app/groupoffice/config/{{ item }}.j2"
+ dest: "{{ node['home_path'] }}/containers/groupoffice/config/{{ item }}"
+ owner: "{{ services['groupoffice']['subuid'] }}"
+ group: "{{ node['group'] }}"
+ mode: "0640"
+ loop:
+ - "config.php"
+ - "groupoffice-ca.ini"
+ - "Connection.php"
+ - "Authenticator.php"
+ - "configfile.php"
+ become: true
+ notify: "notification_restart_groupoffice"
+
+- name: Pull image
+ containers.podman.podman_image:
+ name: "docker.io/intermesh/groupoffice"
+ tag: "{{ version['containers']['groupoffice'] }}"
+ state: "present"
+ pull: true
+
+- name: Deploy groupoffice.container file
+ ansible.builtin.template:
+ src: "{{ hostvars['console']['node']['config_path'] }}/services/containers/app/groupoffice/groupoffice.container.j2"
+ dest: "{{ node['home_path'] }}/.config/containers/systemd/groupoffice.container"
+ owner: "{{ ansible_user }}"
+ group: "{{ node['group'] }}"
+ mode: "0644"
+ notify: "notification_restart_groupoffice"
+
+- name: Deploy groupoffice-cron service
+ ansible.builtin.copy:
+ src: "{{ hostvars['console']['node']['config_path'] }}/services/containers/app/groupoffice/services/{{ item }}"
+ dest: "{{ node['home_path'] }}/.config/systemd/user/{{ item }}"
+ owner: "{{ ansible_user }}"
+ group: "{{ node['group'] }}"
+ mode: "0644"
+ loop:
+ - "groupoffice-cron.service"
+ - "groupoffice-cron.timer"
+
+- name: Enable groupoffice.service
+ ansible.builtin.systemd:
+ name: "groupoffice.service"
+ state: "started"
+ enabled: true
+ daemon_reload: true
+ scope: "user"
+
+- name: Enable groupoffice-cron.timer
+ ansible.builtin.systemd:
+ name: "groupoffice-cron.timer"
+ state: "started"
+ enabled: true
+ daemon_reload: true
+ scope: "user"
+
+- name: Check container archive images
+ ansible.builtin.stat:
+ path: "{{ node['home_path'] }}/archives/containers/docker.io_intermesh_groupoffice_{{ version['containers']['groupoffice'] }}.tar"
+ register: container_archive_images
+
+- name: Save container archive images
+ containers.podman.podman_save:
+ image:
+ - "docker.io/intermesh/groupoffice:{{ version['containers']['groupoffice'] }}"
+ dest: "{{ node['home_path'] }}/archives/containers/docker.io_intermesh_groupoffice_{{ version['containers']['groupoffice'] }}.tar"
+ format: "oci-archive"
+ force: false
+ when: not container_archive_images.stat.exists
+
+- name: Fetch container archive images
+ ansible.builtin.fetch:
+ src: "{{ node['home_path'] }}/archives/containers/docker.io_intermesh_groupoffice_{{ version['containers']['groupoffice'] }}.tar"
+ dest: "{{ hostvars['console']['node']['data_path'] }}/images/containers/"
+ flat: true
diff --git a/ansible/roles/app/tasks/services/set_immich.yaml b/ansible/roles/app/tasks/services/set_immich.yaml
index ef1d696..a0b9346 100644
--- a/ansible/roles/app/tasks/services/set_immich.yaml
+++ b/ansible/roles/app/tasks/services/set_immich.yaml
@@ -43,14 +43,6 @@
scope: "user"
when: is_redis_conf.changed or is_redis_containerfile.changed # noqa: no-handler
-- name: Add user in video, render group
- ansible.builtin.user:
- name: "{{ ansible_user }}"
- state: "present"
- groups: "video, render"
- append: true
- become: true
-
- name: Create immich directory
ansible.builtin.file:
path: "{{ node['home_path'] }}/{{ item }}"
diff --git a/ansible/roles/app/tasks/services/set_kaneo.yaml b/ansible/roles/app/tasks/services/set_kaneo.yaml
new file mode 100644
index 0000000..76ede12
--- /dev/null
+++ b/ansible/roles/app/tasks/services/set_kaneo.yaml
@@ -0,0 +1,84 @@
+---
+- name: Create kaneo directory
+ ansible.builtin.file:
+ path: "{{ node['home_path'] }}/{{ item }}"
+ state: "directory"
+ owner: "{{ services['kaneo']['subuid'] }}"
+ group: "{{ node['group'] }}"
+ mode: "0770"
+ loop:
+ - "containers/kaneo"
+ - "containers/kaneo/ssl"
+ become: true
+
+- name: Deploy root certificate
+ ansible.builtin.copy:
+ content: |
+ {{ hostvars['console']['ca']['root']['crt'] }}
+ dest: "{{ node['home_path'] }}/containers/kaneo/ssl/{{ root_cert_filename }}"
+ owner: "{{ services['kaneo']['subuid'] }}"
+ group: "{{ node['group'] }}"
+ mode: "0440"
+ become: true
+ notify: "notification_restart_kaneo"
+ no_log: true
+
+- name: Register secret value to podman secret
+ containers.podman.podman_secret:
+ name: "{{ item.name }}"
+ data: "{{ item.value }}"
+ state: "present"
+ force: true
+ loop:
+ - name: "KANEO_AUTH_SECRET"
+ value: "{{ hostvars['console']['kaneo']['secret_key'] }}"
+ - name: "KANEO_DB_PASSWORD"
+ value: "{{ hostvars['console']['postgresql']['password']['kaneo'] }}"
+ - name: "KANEO_OIDC_CLIENT_SECRET"
+ value: "{{ hostvars['console']['kaneo']['oidc']['secret'] }}"
+ notify: "notification_restart_kaneo"
+ no_log: true
+
+- name: Pull image
+ containers.podman.podman_image:
+ name: "ghcr.io/usekaneo/kaneo"
+ tag: "{{ version['containers']['kaneo'] }}"
+ state: "present"
+ pull: true
+
+- name: Deploy kaneo.container file
+ ansible.builtin.template:
+ src: "{{ hostvars['console']['node']['config_path'] }}/services/containers/app/kaneo/kaneo.container.j2"
+ dest: "{{ node['home_path'] }}/.config/containers/systemd/kaneo.container"
+ owner: "{{ ansible_user }}"
+ group: "{{ node['group'] }}"
+ mode: "0644"
+ notify: "notification_restart_kaneo"
+
+- name: Enable kaneo.service
+ ansible.builtin.systemd:
+ name: "kaneo.service"
+ state: "started"
+ enabled: true
+ daemon_reload: true
+ scope: "user"
+
+- name: Check container archive images
+ ansible.builtin.stat:
+ path: "{{ node['home_path'] }}/archives/containers/ghcr.io_usekaneo_kane_{{ version['containers']['kaneo'] }}.tar"
+ register: container_archive_images
+
+- name: Save container archive images
+ containers.podman.podman_save:
+ image:
+ - "ghcr.io/usekaneo/kaneo:{{ version['containers']['kaneo'] }}"
+ dest: "{{ node['home_path'] }}/archives/containers/ghcr.io_usekaneo_kane_{{ version['containers']['kaneo'] }}.tar"
+ format: "oci-archive"
+ force: false
+ when: not container_archive_images.stat.exists
+
+- name: Fetch container archive images
+ ansible.builtin.fetch:
+ src: "{{ node['home_path'] }}/archives/containers/ghcr.io_usekaneo_kane_{{ version['containers']['kaneo'] }}.tar"
+ dest: "{{ hostvars['console']['node']['data_path'] }}/images/containers/"
+ flat: true
diff --git a/ansible/roles/app/tasks/services/set_llama.yaml b/ansible/roles/app/tasks/services/set_llama.yaml
new file mode 100644
index 0000000..2fb4224
--- /dev/null
+++ b/ansible/roles/app/tasks/services/set_llama.yaml
@@ -0,0 +1,90 @@
+---
+- name: Check model directory in data volume
+ ansible.builtin.find:
+ path: "{{ node['home_path'] }}/data/containers/llama/models"
+ file_type: "file"
+ patterns: "*.gguf"
+ hidden: true
+ recurse: true
+ register: llama_models
+
+- name: Failure when models directory in data volume
+ ansible.builtin.fail:
+ msg: "There are no models in data directory, please ensure download some model files for llama.cpp"
+ when: llama_models.matched == 0
+
+- name: Create llama directory
+ ansible.builtin.file:
+ path: "{{ node['home_path'] }}/{{ item }}"
+ state: "directory"
+ owner: "{{ ansible_user }}"
+ group: "{{ node['group'] }}"
+ mode: "0770"
+ loop:
+ - "containers/llama"
+ - "containers/llama/models"
+ - "containers/llama/config"
+
+- name: Sync model data (--delete=true)
+ delegate_to: "{{ inventory_hostname }}"
+ ansible.posix.synchronize:
+ src: "{{ node['home_path'] }}/data/containers/llama/models/"
+ dest: "{{ node['home_path'] }}/containers/llama/models/"
+ owner: true
+ group: true
+ perms: true
+ delete: true
+ notify: "notification_restart_llama"
+
+- name: Deploy configuration file
+ ansible.builtin.template:
+ src: "{{ hostvars['console']['node']['config_path'] }}/services/containers/app/llama/config/models.ini.j2"
+ dest: "{{ node['home_path'] }}/containers/llama/config/models.ini"
+ owner: "{{ ansible_user }}"
+ group: "{{ node['group'] }}"
+ mode: "0640"
+ notify: "notification_restart_llama"
+
+- name: Pull image
+ containers.podman.podman_image:
+ name: "ghcr.io/ggml-org/llama.cpp"
+ tag: "{{ version['containers']['llama'] }}"
+ state: "present"
+ pull: true
+
+- name: Deploy container file
+ ansible.builtin.template:
+ src: "{{ hostvars['console']['node']['config_path'] }}/services/containers/app/llama/llama.container.j2"
+ dest: "{{ node['home_path'] }}/.config/containers/systemd/llama.container"
+ owner: "{{ ansible_user }}"
+ group: "{{ node['group'] }}"
+ mode: "0644"
+ notify: "notification_restart_llama"
+
+- name: Enable llama.service
+ ansible.builtin.systemd:
+ name: "llama.service"
+ state: "started"
+ enabled: true
+ daemon_reload: true
+ scope: "user"
+
+- name: Check container archive images
+ ansible.builtin.stat:
+ path: "{{ node['home_path'] }}/archives/containers/ghcr.io_ggml-org_llama.cpp_{{ version['containers']['llama'] }}.tar"
+ register: container_archive_images
+
+- name: Save container archive images
+ containers.podman.podman_save:
+ image:
+ - "ghcr.io/ggml-org/llama.cpp:{{ version['containers']['llama'] }}"
+ dest: "{{ node['home_path'] }}/archives/containers/ghcr.io_ggml-org_llama.cpp_{{ version['containers']['llama'] }}.tar"
+ format: "oci-archive"
+ force: false
+ when: not container_archive_images.stat.exists
+
+- name: Fetch container archive images
+ ansible.builtin.fetch:
+ src: "{{ node['home_path'] }}/archives/containers/ghcr.io_ggml-org_llama.cpp_{{ version['containers']['llama'] }}.tar"
+ dest: "{{ hostvars['console']['node']['data_path'] }}/images/containers/"
+ flat: true
diff --git a/ansible/roles/app/tasks/services/set_openwebui.yaml b/ansible/roles/app/tasks/services/set_openwebui.yaml
new file mode 100644
index 0000000..b671af4
--- /dev/null
+++ b/ansible/roles/app/tasks/services/set_openwebui.yaml
@@ -0,0 +1,84 @@
+---
+- name: Create openwebui directory
+ ansible.builtin.file:
+ path: "{{ node['home_path'] }}/{{ item }}"
+ state: "directory"
+ owner: "{{ ansible_user }}"
+ group: "{{ node['group'] }}"
+ mode: "0770"
+ loop:
+ - "data/containers/openwebui"
+ - "data/containers/openwebui/data"
+ - "containers/openwebui"
+ - "containers/openwebui/ssl"
+
+- name: Deploy root certificate
+ ansible.builtin.copy:
+ content: |
+ {{ hostvars['console']['ca']['root']['crt'] }}
+ dest: "{{ node['home_path'] }}/containers/openwebui/ssl/{{ root_cert_filename }}"
+ owner: "{{ ansible_user }}"
+ group: "{{ node['group'] }}"
+ mode: "0440"
+ notify: "notification_restart_openwebui"
+ no_log: true
+
+- name: Register secret value to podman secret
+ containers.podman.podman_secret:
+ name: "{{ item.name }}"
+ data: "{{ item.value }}"
+ state: "present"
+ force: true
+ loop:
+ - name: "OPEN_WEBUI_SECRET_KEY"
+ value: "{{ hostvars['console']['openwebui']['secret_key'] }}"
+ - name: "OPEN_WEBUI_POSTGRES_PASSWORD"
+ value: "{{ hostvars['console']['postgresql']['password']['openwebui'] }}"
+ - name: "OPEN_WEBUI_OAUTH_CLIENT_SECRET"
+ value: "{{ hostvars['console']['openwebui']['oidc']['secret'] }}"
+ notify: "notification_restart_openwebui"
+ no_log: true
+
+- name: Pull image
+ containers.podman.podman_image:
+ name: "ghcr.io/open-webui/open-webui"
+ tag: "{{ version['containers']['openwebui'] }}"
+ state: "present"
+ pull: true
+
+- name: Deploy container file
+ ansible.builtin.template:
+ src: "{{ hostvars['console']['node']['config_path'] }}/services/containers/app/openwebui/openwebui.container.j2"
+ dest: "{{ node['home_path'] }}/.config/containers/systemd/openwebui.container"
+ owner: "{{ ansible_user }}"
+ group: "{{ node['group'] }}"
+ mode: "0644"
+ notify: "notification_restart_openwebui"
+
+- name: Enable openwebui.service
+ ansible.builtin.systemd:
+ name: "openwebui.service"
+ state: "started"
+ enabled: true
+ daemon_reload: true
+ scope: "user"
+
+- name: Check container archive images
+ ansible.builtin.stat:
+ path: "{{ node['home_path'] }}/archives/containers/ghcr.io_open-webui_open-webui_{{ version['containers']['openwebui'] }}.tar"
+ register: container_archive_images
+
+- name: Save container archive images
+ containers.podman.podman_save:
+ image:
+ - "ghcr.io/open-webui/open-webui:{{ version['containers']['openwebui'] }}"
+ dest: "{{ node['home_path'] }}/archives/containers/ghcr.io_open-webui_open-webui_{{ version['containers']['openwebui'] }}.tar"
+ format: "oci-archive"
+ force: false
+ when: not container_archive_images.stat.exists
+
+- name: Fetch container archive images
+ ansible.builtin.fetch:
+ src: "{{ node['home_path'] }}/archives/containers/ghcr.io_open-webui_open-webui_{{ version['containers']['openwebui'] }}.tar"
+ dest: "{{ hostvars['console']['node']['data_path'] }}/images/containers/"
+ flat: true
diff --git a/ansible/roles/app/tasks/services/set_stablediffusion.yaml b/ansible/roles/app/tasks/services/set_stablediffusion.yaml
new file mode 100644
index 0000000..ade0fae
--- /dev/null
+++ b/ansible/roles/app/tasks/services/set_stablediffusion.yaml
@@ -0,0 +1,91 @@
+---
+- name: Check model directory in data volume
+ ansible.builtin.find:
+ path: "{{ node['home_path'] }}/data/containers/stablediffusion/models"
+ file_type: "file"
+ patterns:
+ - "*.gguf"
+ - "*.safetensors"
+ hidden: true
+ recurse: true
+ register: stablediffusion_models
+
+- name: Failure when models directory in data volume
+ ansible.builtin.fail:
+ msg: "There are no models in data directory, please ensure download some model files for stable-diffusion.cpp"
+ when: stablediffusion_models.matched == 0
+
+- name: Create stablediffusion directory
+ ansible.builtin.file:
+ path: "{{ node['home_path'] }}/{{ item }}"
+ state: "directory"
+ owner: "{{ ansible_user }}"
+ group: "{{ node['group'] }}"
+ mode: "0770"
+ loop:
+ - "containers/stablediffusion"
+ - "containers/stablediffusion/models"
+
+- name: Sync model data (--delete=true)
+ delegate_to: "{{ inventory_hostname }}"
+ ansible.posix.synchronize:
+ src: "{{ node['home_path'] }}/data/containers/stablediffusion/models/"
+ dest: "{{ node['home_path'] }}/containers/stablediffusion/models/"
+ owner: true
+ group: true
+ perms: true
+ delete: true
+ notify: "notification_restart_stablediffusion"
+
+- name: Set active model
+ ansible.builtin.set_fact:
+ stabeldiffusion_active_model: "{{ services['stablediffusion']['dependency']['models']['active'] }}"
+
+- name: Pull image
+ containers.podman.podman_image:
+ name: "ghcr.io/leejet/stable-diffusion.cpp"
+ tag: "{{ version['containers']['stablediffusion'] }}"
+ state: "present"
+ pull: true
+
+- name: Deploy container file
+ ansible.builtin.template:
+ src: "{{ hostvars['console']['node']['config_path'] }}/services/containers/app/stablediffusion/stablediffusion.container.j2"
+ dest: "{{ node['home_path'] }}/.config/containers/systemd/stablediffusion.container"
+ owner: "{{ ansible_user }}"
+ group: "{{ node['group'] }}"
+ mode: "0644"
+ notify: "notification_restart_stablediffusion"
+
+- name: Enable stablediffusion.service
+ ansible.builtin.systemd:
+ name: "stablediffusion.service"
+ state: "started"
+ enabled: true
+ daemon_reload: true
+ scope: "user"
+
+- name: Set stable-diffusion archive version
+ ansible.builtin.set_fact:
+ stablediffusion_archive_version: >-
+ {{ version['containers']['stablediffusion'].split('@sha256:')[0] }}@{{ version['containers']['stablediffusion'].split('@sha256:')[1][0:7] }}
+
+- name: Check container archive images
+ ansible.builtin.stat:
+ path: "{{ node['home_path'] }}/archives/containers/ghcr.io_leejet_stable-diffusion.cpp_{{ stablediffusion_archive_version }}.tar"
+ register: container_archive_images
+
+- name: Save container archive images
+ containers.podman.podman_save:
+ image:
+ - "ghcr.io/leejet/stable-diffusion.cpp:{{ version['containers']['stablediffusion'] }}"
+ dest: "{{ node['home_path'] }}/archives/containers/ghcr.io_leejet_stable-diffusion.cpp_{{ stablediffusion_archive_version }}.tar"
+ format: "oci-archive"
+ force: false
+ when: not container_archive_images.stat.exists
+
+- name: Fetch container archive images
+ ansible.builtin.fetch:
+ src: "{{ node['home_path'] }}/archives/containers/ghcr.io_leejet_stable-diffusion.cpp_{{ stablediffusion_archive_version }}.tar"
+ dest: "{{ hostvars['console']['node']['data_path'] }}/images/containers/"
+ flat: true
diff --git a/config/node/fw/nftables.conf.j2 b/config/node/fw/nftables.conf.j2
index e0f01bd..133c99f 100644
--- a/config/node/fw/nftables.conf.j2
+++ b/config/node/fw/nftables.conf.j2
@@ -147,7 +147,7 @@ table inet filter {
oifname $IF_SERVER ip saddr $HOSTS4_CONSOLE ip daddr { $HOSTS4_BK, $HOSTS4_INFRA, $HOSTS4_APP } tcp dport $PORTS_WEB accept comment "allow ipv4 web connection: CONSOLE > FW > SERVER BK/INFRA/APP"
oifname $IF_SERVER ip6 saddr $HOSTS6_CONSOLE ip6 daddr { $HOSTS6_BK, $HOSTS6_INFRA, $HOSTS6_APP } tcp dport $PORTS_WEB accept comment "allow ipv6 web connection: CONSOLE > FW > SERVER BK/INFRA/APP"
# Printer
- oifname $IF_CLIENT ip saddr $HOSTS4_CONSOLE ip daddr $HOSTS4_PRINTER accept comment "allow ipv4 printer connection: CONSOLE > FW > PRINTER"
+ ip saddr $HOSTS4_CONSOLE ip daddr $HOSTS4_PRINTER accept comment "allow ipv4 printer connection: CONSOLE > FW > PRINTER"
iifname $IF_WAN jump wan comment "set WAN interface rules"
iifname $IF_CLIENT jump client comment "set CLIENT interface rules"
diff --git a/config/secrets/secrets.yaml b/config/secrets/secrets.yaml
index fbe141f..187a173 100644
--- a/config/secrets/secrets.yaml
+++ b/config/secrets/secrets.yaml
@@ -139,6 +139,8 @@ postgresql:
mastodon: ENC[AES256_GCM,data:hq1ETBK9iUI7kWFO+9pZwMQMbmyLExGk7/VzBwDdLFbzptXL2KEi6oYRCS0=,iv:hF1C8lsnP2i3LxDIuOA6op4qKXQKjJ7gqznDst7hVAU=,tag:maR7Jvsv3DJGrO77EzmBDA==,type:str]
tandoor: ENC[AES256_GCM,data:+5g/jzRMQoxocZntoE+1BU0MS00yEp0tyVNxriEIJ0pW2V1bsLhUAkwQ748=,iv:feNbpOhkdjmKS7Ovy0KGioOTyjQ4sflfH+50niP7Y8Y=,tag:51/u+EE3Pp5o7jnlj0AWIw==,type:str]
linkding: ENC[AES256_GCM,data:+diOqVjK5RkDOpxCrb0M0sLWEufXEwhTNo0x5qH4FkteN8zaUAZfO8lD13I=,iv:jHQANXC1tamiphm33RpTKpEB3niLFx6IKzcdq8/KNCg=,tag:NwkkmUPOS9K0IKtUBVRWqg==,type:str]
+ openwebui: ENC[AES256_GCM,data:QNeBJjdLIzoI2h9e3me6A/xZ03YpBeLpKK0Yya5Wi5vryv1ZwnHHZwOjmuA=,iv:qLpsxlwpE+V+qACkfOee/ExFIyVV+KsXzuT3VDiJ/w4=,tag:YsmBhmEkqYcSmIIX7R2nTw==,type:str]
+ kaneo: ENC[AES256_GCM,data:Q2rg4/0wAgWDzB8Aj5B6i851FZN3eGR7BbX2l3Y5+Ij47//TtKGzdq6cxNg=,iv:Mbp/S7Lc0tLJAT+QFMVB3a86S+mqoQmZDZQ7Agf8rU8=,tag:BkerNmfeeOicsQAwUo4dtQ==,type:str]
#ENC[AES256_GCM,data:ODXFUxxxdQ==,iv:s9zJVx6wo6x517tbNvC+FZ0dFzqbjqeLI6rXBq72hQA=,tag:bXoV2I3LbpmQyddJrtS3Qg==,type:comment]
#
#
@@ -148,6 +150,7 @@ mariadb:
crt: ENC[AES256_GCM,data:aCrAXf5n6vIyEIbpwyrmVvjPa6gmVrAU213SnqHO3/Mem4MgfDjmh9Q1tQbvXKSJT4tcecp08sCY1Y+AKN26lvqFd1Y8b50dNVLXuBRd0saLDwyvi2/w7a9B5sinKLCtKfvOc2JOdoYVHQKcyQXG6uHxSpZPf2+j8s7KUrgTNAjKM6aOQaUYydbxcD0pQ9BgJxCCM88+que+HtX0FdLYr/ImF+dvpMRE5RlvWlWB4bae6A/xPRWH8ZHj4ZwO4uMSQARu5w3kWDngcE/CnEhC1JQvKLJ1gKzkaUF3/Vn8kkxIITGddw0+2kQyGCJ9a5ZXpzr+AJX1OuFE83YDZRRXbhmEuSZiNQmLaw7ImSjNI5uZ4MNyKUuq9t+xqSgBj64dhn4egHaAb9fzZo9lV9/2kAchnidWBzn9EtAacYsO0lf7i7OYuSwCjKaTBvO8DwPudSsWi3CYYx2NlQXP5PvElJi/7aasWcF3ohmhMUew0ATBdgxMKvl3MENzi66WuTb2uzcryMQf5M6lIceXYa3RLQQzJXkBUe4vBg5aQBiKAYqURvxmvgb+j/YMLHrXGj8bkkV/iTdiI5oyeE9+si6Tw+f6Gj/E++KUWSR8AIrNbAHgJ1dtPd/oHt8CW8X9USJd56L3tOAA0r5SDnHhnZ/sfumuyz3H34RYjPxfKu0FCQhsEofDrDc4DnypD6s1RgzJM+esvHRLb2k4+X1CN5Lf04pTis+GshRtWxRClycrwLjcyaCZ/JN5+4sAHazr2nEQ8C+RQ9NuVRK2enFVcfTCf+2Sk6pN8ltHcmrYH1Xx1B88gsAPL8eWmeGLiTbzCqRAkkZ2EvbOAu4qZteLC0qRan1jYQs3C8zjwfQKf8QpEY1EYqNYaNkqDPpZcxeRMoNX5vlS9n5W3qoX+qyBxUKKNl6O3WH6JNnODkJMwnFhaSE8HSTr7rbBjqc1XaM0,iv:lCxYy8LkovdX1dIIoS0NXe5YbUT/ctPK1eS1pSU1z9c=,tag:Pads/Okb8YVZH19ODUThHw==,type:str]
password:
bookstack: ENC[AES256_GCM,data:kFQ47qbE+D89Pjr4B6WMAcCHQ8deCyzaPmNspI5TvqwvN5RIaKOWU1uT4jU=,iv:3OiYzBod2xxKMt/5Q9uazalld1NkUNun+DJipC3Ts+w=,tag:orCNWYMAZ/5yi7c6apXVJg==,type:str]
+ groupoffice: ENC[AES256_GCM,data:s4jO9jrn15tPcStDYUTNLgvCJvtlfp641nU3EzYmPvIzk4sQIQZRuGsODaU=,iv:OBpzPkW0bMWL9da2/vL7GgOTqaKSvYamapaK5AMZeZE=,tag:CYuoBHO5Wt0VBL32YKrd5A==,type:str]
#ENC[AES256_GCM,data:ODXFUxxxdQ==,iv:s9zJVx6wo6x517tbNvC+FZ0dFzqbjqeLI6rXBq72hQA=,tag:bXoV2I3LbpmQyddJrtS3Qg==,type:comment]
#
#
@@ -352,6 +355,33 @@ linkding:
#ENC[AES256_GCM,data:ODXFUxxxdQ==,iv:s9zJVx6wo6x517tbNvC+FZ0dFzqbjqeLI6rXBq72hQA=,tag:bXoV2I3LbpmQyddJrtS3Qg==,type:comment]
#
#
+#ENC[AES256_GCM,data:aFaqLiw+m7M5KEBCH06w2Cgasw==,iv:Yq1tY7lNxW6UuY+77OQEmWwzW9vnJpaZL/bI650wg18=,tag:e9Fozat8Pf3h/BXUNZ0fHQ==,type:comment]
+openwebui:
+ secret_key: ENC[AES256_GCM,data:dAUmbxfOGo684M7Mk7VAv+mZTLT2qi83Nvf6fOR6M/Ale/2n1D4Q8jIl0C4=,iv:uNNQmXTIUACVyguvPBhLzv9ir3WXlPFacgqMhrNScKM=,tag:h3plKpikh7UqHIUO9fyZiQ==,type:str]
+ oidc:
+ secret: ENC[AES256_GCM,data:1PZgSzT28FMuClh7W21j9zjHn/YAPiY7iBF8Hid2FCDMzC2jHaObQdXM5H4=,iv:mhNxP0So9JjG1Hgtvt8OpdzIcfp7SZZGPiX0wj0ZP20=,tag:xvKiHjeVJDz0uSkZXpuKcA==,type:str]
+ hash: ENC[AES256_GCM,data:XIHS9JJVGy3qKFiBIPETou+79vMV4wjYXW9Grv4Hx1zqjqZ4onJ/JiMVcYmtBm5CAp2hsrouIdw9gjW344pnjVg5DORMz6P+/95Yu3OlYmOEj+Cl1iLqgN/q5qc3XAXzQaOylwdbp6UMR+z7PmqJzY+z1h/qMqlSIYGtRAVo4BgNBng=,iv:sK8HTn0z/kzYB8Db+zrKmlk2DxcH3DcoQkPRJtlIvSY=,tag:5hmbItxT4bRlGaM5z2kQkg==,type:str]
+#ENC[AES256_GCM,data:ODXFUxxxdQ==,iv:s9zJVx6wo6x517tbNvC+FZ0dFzqbjqeLI6rXBq72hQA=,tag:bXoV2I3LbpmQyddJrtS3Qg==,type:comment]
+#
+#
+#ENC[AES256_GCM,data:AzckMweK6MUIX9VYy96juNpiGfU=,iv:4/j/5QgJy9OsxZ1dNN16TKR93/9mCmOuaqLrab3ymLo=,tag:4wrv96l2BI09Afc7HaQiRA==,type:comment]
+groupoffice:
+ admin:
+ id: ENC[AES256_GCM,data:WafJHpvJ3MmpfA8=,iv:i9KbnMoMG8EnaryPAQ+3xYvUwFYKQDnzGmMkSOKllTs=,tag:uH/ksrBdK1M++rVyMvxcRQ==,type:str]
+ email: ENC[AES256_GCM,data:AfAQUHankHwMGutLM/LZWJEimCiDFzyDp+GsPg==,iv:atCWo/TTshDGGwtu42ZddILK45eMIg5oYy82m4TAzUU=,tag:0X+bhJ05/1b7DS9wb22A9w==,type:str]
+ password: ENC[AES256_GCM,data:V0CJ8KAoHguWrW9+/j12xjbqqn/5nKAqbYYHZ1UxqAubpD0fQDLTjrZRTuQ=,iv:MJinVDkLBaSNAwM8qCj0WaWl26vTYDoHQqcmdlyNjR4=,tag:7P1UQZd197aJlFREiJP2ow==,type:str]
+#ENC[AES256_GCM,data:ODXFUxxxdQ==,iv:s9zJVx6wo6x517tbNvC+FZ0dFzqbjqeLI6rXBq72hQA=,tag:bXoV2I3LbpmQyddJrtS3Qg==,type:comment]
+#
+#
+#ENC[AES256_GCM,data:zEb2WvNmwVIqKcKesJk=,iv:G/CP+ZrjF9uJNTRC0CVY1cRM1gPL8Ud2GHAmHkkSM78=,tag:lXIH+YF2dCdIzI/ImhQRig==,type:comment]
+kaneo:
+ secret_key: ENC[AES256_GCM,data:SeMvcW+SZABzQKnTsd1dOFbKklfqqTEDM6q9pqd1SS9hig/XuW4lDzS8q0k=,iv:3Djzn0VGasdxyoZo8zMx4G8sIOGBhRt/1cq3LuabUzA=,tag:FCbSB7A/PowGPz8URFm/eA==,type:str]
+ oidc:
+ secret: ENC[AES256_GCM,data:AFjQeEv3DMFZCpObgOR69i/Tavp3lcYUsaUqZ/X4pQYcXCNAE1gRHwkk//8=,iv:nEsQOT5AZnHHHMfrfcCIClw3Dwg+O2ykPFjIlcl8Wrc=,tag:J+E50bmw6pEXSVkAOwfuWQ==,type:str]
+ hash: ENC[AES256_GCM,data:47WzIRYnXn/Jz01BwSVjtff2cTl16vVWh1YypJLmg/GJjXNj+XcghAnw4j7hFf8YhIyyCgWHHm0wkgqxpjPXN03wDIP8Djxg3K5ubUPXZJZWYVTdwcc1Ebdi/3x/DijMLqSJxbRATYSXijBF1o6t1uv0GfGHXTt7M50vdsB8MNjBkds=,iv:MRj+G3gpr8tQdwAxxZlojknUeQ67WY17OAbu04v2sxY=,tag:avz6caNLAi/bGvkfheGJCw==,type:str]
+#ENC[AES256_GCM,data:ODXFUxxxdQ==,iv:s9zJVx6wo6x517tbNvC+FZ0dFzqbjqeLI6rXBq72hQA=,tag:bXoV2I3LbpmQyddJrtS3Qg==,type:comment]
+#
+#
#ENC[AES256_GCM,data:T4Wtn49AAxPd2QUFTR+q,iv:bH5goGWBDqumAat9dUv2OwfCUJUpuVqncTMqMBZUXhI=,tag:G+W6hHA+yftQ+4RJpXrxHg==,type:comment]
switch:
admin:
@@ -368,7 +398,7 @@ sops:
k+uyWPPi+enFkVaceSwMFrA66uaWWrwAj11sXEB7yzvGFPrnAGezjQ==
-----END AGE ENCRYPTED FILE-----
recipient: age120wuwcmsm845ztsvsz46pswj5je53uc2n35vadklrfqudu6cxuusxetk7y
- lastmodified: "2026-08-27T14:09:38Z"
- mac: ENC[AES256_GCM,data:K+SwIrdo62opVGnsrMH7FysC8GtMN1RGPGmtUxRKLoNy/Yi/tKxjVH1xiu1ePCWXWqpGjuC7btJdel3SZmwDi4jMhN87DadOh1Kf0sIGDe8+Ff1LrLLl0I4+NWpu/RJicXwxOXkZUwdEZ0sqTW2Qm1S7aknPpSoD9vc44epv1AA=,iv:npBPZiCeb3+7s+xHd/XhOhEnxvAJzeZWGFzW98uFSK0=,tag:KhQcf6/SVDtbgpiZO/7VNA==,type:str]
+ lastmodified: "2026-09-08T03:23:38Z"
+ mac: ENC[AES256_GCM,data:+mTAe1NwSbQHsmaVxKrtIDeRyyKbDVHSRCkji5+hAByS/GS4OzK+SwdyGYa5Ihv+g2J/7Pechf44GY8mv0lirWcOTGxqzvMDiRSXNty+aQVzOn9sXbfCvWXBUqDLNOiNISN0RfF1eVlG12D8wNfFEXFbs6lv81IMIN9ctdvTN5c=,iv:CG6Px0p/EomUafLody2kJOR8AzdmPTqe1ry1DZUhNSc=,tag:ClSWU401Jt6PzhcSCNNvbg==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
diff --git a/config/services/containers/app/collabora/collabora.container.j2 b/config/services/containers/app/collabora/collabora.container.j2
index 6030f23..bdb1252 100644
--- a/config/services/containers/app/collabora/collabora.container.j2
+++ b/config/services/containers/app/collabora/collabora.container.j2
@@ -13,6 +13,7 @@ PublishPort={{ services['collabora']['ports']['http'] }}:9980/tcp
Environment="TZ={{ timezone }}"
Environment="aliasgroup1=https://{{ services['nextcloud']['domain']['public'] }}.{{ domain['public'] }}"
+Environment="aliasgroup2=https://{{ services['groupoffice']['domain']['public'] }}.{{ domain['public'] }}"
# Environment="aliasgroup2=other_server_FQDN"
Environment="extra_params=--o:ssl.enable=false --o:ssl.termination=true --o:server_name={{ services['collabora']['domain']['public'] }}.{{ domain['public'] }} --o:admin_console.enable=false"
diff --git a/config/services/containers/app/groupoffice/config/Authenticator.php.j2 b/config/services/containers/app/groupoffice/config/Authenticator.php.j2
new file mode 100644
index 0000000..66f15ff
--- /dev/null
+++ b/config/services/containers/app/groupoffice/config/Authenticator.php.j2
@@ -0,0 +1,519 @@
+ username = foo
+ * -> email = foo@example.com
+ *
+ * Short username:
+ * foo
+ * -> allowed only when exactly one concrete IMAP domain is configured
+ * -> username = foo
+ * -> email = foo@configured-domain
+ *
+ * Wildcard (*) domains are not used for short-name inference.
+ *
+ * @param string $login
+ * @return array{username:string,email:string}|null
+ */
+ private static function resolveIdentity(string $login): ?array
+ {
+ $login = trim($login);
+
+ if($login === '') {
+ return null;
+ }
+
+ $atPos = strpos($login, '@');
+
+ if($atPos !== false) {
+ if($atPos === 0 || $atPos === strlen($login) - 1) {
+ return null;
+ }
+
+ return [
+ 'username' => substr($login, 0, $atPos),
+ 'email' => $login
+ ];
+ }
+
+ /*
+ * For a short username, infer the mail domain from the
+ * configured IMAP authenticator domains.
+ *
+ * This is only unambiguous when exactly one concrete
+ * domain is configured.
+ */
+ $stmt = go()->getDbConnection()->query(
+ "SELECT DISTINCT `name`
+ FROM `imapauth_server_domain`
+ WHERE `name` <> '*'
+ AND `name` <> ''
+ ORDER BY `name`"
+ );
+
+ $domains = $stmt->fetchAll(\PDO::FETCH_COLUMN);
+
+ if(count($domains) !== 1) {
+ return null;
+ }
+
+ return [
+ 'username' => $login,
+ 'email' => $login . '@' . $domains[0]
+ ];
+ }
+
+ public static function isAvailableFor(string $username): bool
+ {
+ $identity = static::resolveIdentity($username);
+
+ if(!$identity) {
+ return false;
+ }
+
+ /*
+ * Existing Group-Office users with a local password must be
+ * authenticated locally.
+ *
+ * This keeps local/break-glass administrator accounts such as
+ * "local_admin" out of the IMAP authentication flow.
+ *
+ * IMAP-managed users have their local password cleared after
+ * successful IMAP authentication, so they continue to use IMAP.
+ */
+ $localUser = User::find()
+ ->where([
+ 'username' => $identity['username']
+ ])
+ ->single();
+
+ if($localUser && $localUser->hasPassword()) {
+ return false;
+ }
+
+ return static::findServer($identity['email']) !== false;
+ }
+
+ /**
+ * Find the IMAP authentication server for an e-mail address.
+ *
+ * @param string $email
+ * @return Server|boolean
+ */
+ private static function findServer($email)
+ {
+ $atPos = strpos($email, '@');
+
+ if(!$atPos) {
+ return false;
+ }
+
+ $domain = substr($email, $atPos + 1);
+
+ return Server::find()
+ ->join(
+ 'imapauth_server_domain',
+ 'd',
+ 's.id = d.serverId'
+ )
+ ->where([
+ 'd.name' => $domain
+ ])
+ ->orWhere([
+ 'd.name' => '*'
+ ])
+ ->single();
+ }
+
+ public function authenticate(
+ string $username,
+ string $password
+ ): bool|User
+ {
+ $this->fireEvent(
+ 'beforeauthenticate',
+ [$username]
+ );
+
+ $identity = static::resolveIdentity($username);
+
+ if(!$identity) {
+ return false;
+ }
+
+ $localUsername = $identity['username'];
+ $email = $identity['email'];
+
+ $server = static::findServer($email);
+
+ if(!$server) {
+ return false;
+ }
+
+ go()->debug(
+ "Attempting IMAP authentication on " .
+ $server->imapHostname
+ );
+
+ $connection = new Connection();
+
+ if(!$connection->connect(
+ $server->imapHostname,
+ $server->imapPort,
+ $server->imapEncryption == 'ssl',
+ 10,
+ !$server->imapValidateCertificate
+ )) {
+ throw new Exception(
+ "Could not connect to IMAP server"
+ );
+ }
+
+ if($server->imapEncryption == 'tls') {
+ if(!$connection->startTLS()) {
+ throw new Exception(
+ "Could not enable TLS on " .
+ $server->imapHostname .
+ " at port " .
+ $server->imapPort
+ );
+ }
+ }
+
+ /*
+ * Username sent to the IMAP server.
+ *
+ * Remove domain from username = No
+ * -> foo@example.com
+ *
+ * Remove domain from username = Yes
+ * -> foo
+ */
+ $imapUsername = $server->removeDomainFromUsername
+ ? $localUsername
+ : $email;
+
+ if(!$connection->authenticate(
+ $imapUsername,
+ $password
+ )) {
+ return false;
+ }
+
+ /*
+ * Group-Office username is the local-part.
+ */
+ $user = User::find()
+ ->where([
+ 'username' => $localUsername
+ ])
+ ->single();
+
+ /*
+ * Prevent the same short username from being associated
+ * with two different e-mail addresses.
+ */
+ if(
+ $user &&
+ strcasecmp(
+ (string) $user->email,
+ $email
+ ) !== 0
+ ) {
+ throw new Exception(
+ "Username '" .
+ $localUsername .
+ "' already belongs to another e-mail address"
+ );
+ }
+
+ /*
+ * Migrate an account previously created by the stock
+ * IMAP authenticator:
+ *
+ * username = foo@example.com
+ * email = foo@example.com
+ *
+ * to:
+ *
+ * username = foo
+ * email = foo@example.com
+ *
+ * The existing User ID is preserved.
+ */
+ if(!$user) {
+ $user = User::find()
+ ->where([
+ 'username' => $email
+ ])
+ ->single();
+
+ if(!$user) {
+ $user = User::find()
+ ->where([
+ 'email' => $email
+ ])
+ ->single();
+ }
+
+ if($user) {
+ $user->username = $localUsername;
+ }
+ }
+
+ if(!$user) {
+ $user = $this->createUser(
+ $localUsername,
+ $email
+ );
+ } elseif($user->hasPassword()) {
+ $user->clearPassword();
+ }
+
+ foreach($server->groups as $group) {
+ $user->addGroup($group->groupId);
+ }
+
+ if($user->isModified()) {
+ if(!$user->save()) {
+ throw new Exception(
+ "Could not save user"
+ );
+ }
+ }
+
+ /*
+ * Synchronize the user's actual Email Account with the
+ * global IMAP Authenticator server profile on every
+ * successful login.
+ */
+ $this->setEmailAccount(
+ $imapUsername,
+ $password,
+ $email,
+ $server,
+ $user
+ );
+
+ return $user;
+ }
+
+ /**
+ * Create a new Group-Office user.
+ *
+ * @throws Exception
+ */
+ private function createUser(
+ string $username,
+ string $email
+ ): User
+ {
+ $user = new User();
+
+ $user->displayName = $username;
+ $user->username = $username;
+ $user->email = $email;
+ $user->recoveryEmail = $email;
+
+ if(!$user->save()) {
+ throw new Exception(
+ "Could not save user after successful IMAP login"
+ );
+ }
+
+ return $user;
+ }
+
+ /**
+ * Synchronize the user's Email Account with the global
+ * IMAP Authenticator server profile on every successful login.
+ *
+ * The following settings are authoritative:
+ *
+ * - IMAP host
+ * - IMAP port
+ * - IMAP encryption
+ * - IMAP certificate validation
+ * - IMAP username
+ * - IMAP password
+ * - SMTP host
+ * - SMTP port
+ * - SMTP encryption
+ * - SMTP certificate validation
+ * - SMTP authentication credentials
+ *
+ * User-specific mailbox, alias and other unrelated settings
+ * are preserved.
+ *
+ * @throws Exception
+ */
+ private function setEmailAccount(
+ $username,
+ $password,
+ $email,
+ Server $server,
+ User $user
+ ) {
+ if(!$user->hasModule('legacy', 'email')) {
+ return;
+ }
+
+ /*
+ * Find all existing e-mail accounts belonging to this user.
+ *
+ * Do not use the configured IMAP hostname as an identifier.
+ * The administrator may change the global hostname later,
+ * and in that case we want to update the existing account
+ * instead of creating a duplicate.
+ */
+ $accounts = Account::model()
+ ->findByAttributes([
+ 'user_id' => $user->id
+ ])
+ ->fetchAll();
+
+ $account = null;
+
+ /*
+ * Prefer an account whose IMAP username matches either
+ * the current IMAP login username or the full e-mail
+ * address.
+ */
+ foreach($accounts as $candidate) {
+ if(
+ $candidate->username === $username ||
+ $candidate->username === $email
+ ) {
+ $account = $candidate;
+ break;
+ }
+ }
+
+ /*
+ * If this user has exactly one e-mail account, treat that
+ * account as the one managed by the IMAP authenticator.
+ *
+ * This allows changes to username/hostname configuration
+ * without accidentally creating a duplicate account.
+ */
+ if(!$account && count($accounts) === 1) {
+ $account = $accounts[0];
+ }
+
+ $wasNew = false;
+
+ if(!$account) {
+ $account = new Account();
+ $account->user_id = $user->id;
+ $account->mbroot = "";
+
+ $wasNew = true;
+ }
+
+ /*
+ * IMAP Authenticator server profile is authoritative.
+ *
+ * Refresh IMAP configuration on every successful login.
+ */
+ $account->host =
+ $server->imapHostname;
+
+ $account->port =
+ $server->imapPort;
+
+ $account->username =
+ $username;
+
+ $account->password =
+ $password;
+
+ $account->imap_encryption =
+ $server->imapEncryption ?? "";
+
+ $account->imap_allow_self_signed =
+ !$server->imapValidateCertificate;
+
+ /*
+ * Refresh SMTP configuration from the same global
+ * authentication server profile.
+ */
+ $account->smtp_host =
+ $server->smtpHostname;
+
+ $account->smtp_port =
+ $server->smtpPort;
+
+ $account->smtp_encryption =
+ $server->smtpEncryption ?? "";
+
+ $account->smtp_allow_self_signed =
+ !$server->smtpValidateCertificate;
+
+ /*
+ * SMTP authentication.
+ */
+ if($server->smtpUseUserCredentials) {
+ /*
+ * Use the same credentials that successfully
+ * authenticated against IMAP.
+ */
+ $account->smtp_username =
+ $username;
+
+ $account->smtp_password =
+ $password;
+ } else {
+ /*
+ * Use static SMTP credentials configured in the
+ * global IMAP Authenticator server profile.
+ */
+ $account->smtp_username =
+ $server->smtpUsername;
+
+ $account->smtp_password =
+ $server->getSmtpPassword();
+ }
+
+ /*
+ * Verify the resulting IMAP configuration while saving.
+ */
+ $account->checkImapConnectionOnSave = true;
+
+ if(!$account->save(true)) {
+ throw new Exception(
+ "Could not save e-mail account: " .
+ implode(
+ "\n",
+ $account->getValidationErrors()
+ )
+ );
+ }
+
+ if($wasNew) {
+ $account->addAlias(
+ $email,
+ $user->displayName
+ );
+ }
+ }
+}
diff --git a/config/services/containers/app/groupoffice/config/Connection.php.j2 b/config/services/containers/app/groupoffice/config/Connection.php.j2
new file mode 100644
index 0000000..3d9e7f5
--- /dev/null
+++ b/config/services/containers/app/groupoffice/config/Connection.php.j2
@@ -0,0 +1,736 @@
+getDbConnection();
+ * ````````````````````````````````````````````````````````````````````````````
+ *
+ * @copyright (c) 2014, Intermesh BV http://www.intermesh.nl
+ * @author Merijn Schering
+ * @license http://www.gnu.org/licenses/agpl-3.0.html AGPLv3
+ */
+class Connection {
+
+ private $dsn;
+ private $username;
+ private $password;
+ private $options;
+
+ /**
+ *
+ * @var PDO
+ */
+ private $pdo;
+
+ /**
+ * Output all SQL to the debugger
+ *
+ * @var bool
+ */
+ public $debug = false;
+
+ public function __construct($dsn, $username, $password) {
+
+ $initCmd = "SET character_set_client = utf8mb4,
+ character_set_connection = utf8mb4,
+ character_set_results = utf8mb4,
+ sql_mode = 'STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION',
+ time_zone = '+00:00',
+ lc_messages = 'en_US'";
+
+ $this->dsn = $dsn;
+ $this->username = $username;
+ $this->password = $password;
+ $this->options = [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_PERSISTENT => false, // Unit test on closing DB connection fails. We need this to work for long running processes
+ PDO::ATTR_EMULATE_PREPARES => false, //for native data types int, bool etc.
+ PDO::ATTR_STRINGIFY_FETCHES => false,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
+ ];
+
+ if(version_compare(PHP_VERSION, '8.5.0') >= 0) {
+ $this->options[PDO\Mysql::ATTR_USE_BUFFERED_QUERY] = true;
+ $this->options[PDO\Mysql::ATTR_INIT_COMMAND] = $initCmd;
+ } else {
+ $this->options[PDO::MYSQL_ATTR_USE_BUFFERED_QUERY] = true;
+ $this->options[PDO::MYSQL_ATTR_INIT_COMMAND] = $initCmd;
+ }
+
+ /*
+ * Database TLS
+ *
+ * SSL attributes must be passed when PDO is constructed.
+ */
+ if (defined('GO_DB_SSL_CA') && GO_DB_SSL_CA !== '') {
+
+ $verifyServerCert =
+ defined('GO_DB_SSL_VERIFY_SERVER_CERT')
+ ? GO_DB_SSL_VERIFY_SERVER_CERT
+ : true;
+
+ if(version_compare(PHP_VERSION, '8.5.0') >= 0) {
+ $this->options[PDO\Mysql::ATTR_SSL_CA] =
+ GO_DB_SSL_CA;
+
+ $this->options[PDO\Mysql::ATTR_SSL_VERIFY_SERVER_CERT] =
+ $verifyServerCert;
+ } else {
+ $this->options[PDO::MYSQL_ATTR_SSL_CA] =
+ GO_DB_SSL_CA;
+
+ $this->options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] =
+ $verifyServerCert;
+ }
+ }
+ }
+
+ public function getDsn() {
+ return $this->dsn;
+ }
+
+ /**
+ * Gets the global database connection object.
+ *
+ * {@link http://php.net/manual/en/pdo.construct.php}
+ *
+ * @return PDO Database connection object
+ */
+ public function getPDO(): PDO
+ {
+ if (!isset($this->pdo)) {
+ $this->setPDO();
+ }
+ return $this->pdo;
+ }
+
+ private $database;
+
+ /**
+ * Get the database instance
+ *
+ * @return Database
+ */
+ public function getDatabase(): Database
+ {
+ if(!isset($this->database)) {
+ $this->database = new Database($this);
+ }
+
+ return $this->database;
+ }
+
+ /**
+ * Close the database connection. Beware that all active PDO statements must be set to null too
+ * in the current scope.
+ *
+ * Weird things happen when using fsockopen. This test case leaves the conneciton open. When removing the fputs call it seems to work.
+ *
+ *
+
+ $settings = \GO\Sync\Model\Settings::model()->findForUser(\go\core\App::get()->user());
+ $account = \GO\Email\Model\Account::model()->findByPk($settings->account_id);
+
+
+ $handle = stream_socket_client("tcp://localhost:143");
+ $login = 'A1 LOGIN "admin@intermesh.dev" "admin"'."\r\n";
+ fputs($handle, $login);
+ fclose($handle);
+ $handle=null;
+
+ echo "Test\n";
+
+ \go\core\App::get()->unsetDbConnection();
+ sleep(10);
+ */
+ public function disconnect(): void
+ {
+ $this->pdo = null;
+
+ Property::clearCachedRelationStmts();
+ Property::$lastDeleteStmt = null;
+ self::$cachedStatements = [];
+ }
+
+
+ /**
+ * Checks if connection still exists
+ *
+ * @param int $id
+ * @return bool
+ * @throws DbException
+ */
+ public static function exists(int $id) : bool {
+
+ $dsn = go()->getDbConnection()->getDsn();
+ $config = go()->getConfig();
+
+ $watchConn = new Connection(
+ $dsn, $config['db_user'], $config['db_pass']
+ );
+ $processes = $watchConn->query("SHOW PROCESSLIST")->fetchAll(\PDO::FETCH_ASSOC);
+
+ foreach($processes as $process) {
+ if($process['Id'] == $id) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static array $cachedStatements = [];
+
+ /**
+ * Register a cached statement that will be unset when we want to disconnect from the database.
+ * If you don't unset these statement the connection will be kept open.
+ * @param string $name
+ * @param Statement $statement
+ * @return void
+ */
+ public function cacheStatement(string $name, Statement $statement): void
+ {
+ self::$cachedStatements[$name] = $statement;
+ }
+
+ public function getCachedStatment(string $name) : ?Statement {
+ return self::$cachedStatements[$name] ?? null;
+ }
+
+ /**
+ * Set's a new PDO object base on the current connection settings
+ */
+ private function setPDO() {
+ $this->pdo = null;
+ $this->pdo = new PDO($this->dsn, $this->username, $this->password, $this->options);
+ // go()->debug("PDO Driver: " . $this->pdo->getAttribute(PDO::ATTR_CLIENT_VERSION));
+ // if (strpos($this->pdo->getAttribute(PDO::ATTR_CLIENT_VERSION), 'mysqlnd') !== false) {
+ // echo 'PDO MySQLnd enabled!';
+ // }
+ }
+
+
+ /**
+ * Get MySQL connection ID
+ *
+ * @return int
+ */
+ public function getId() : int {
+ return (int) $this->query("select connection_id()")->fetch(PDO::FETCH_COLUMN, 0);
+ }
+
+ /**
+ * Execute an SQL string
+ *
+ * Should be properly escaped!
+ * {@link http://php.net/manual/en/pdo.query.php}
+ *
+ * @param string $sql
+ * @return PDOStatement
+ * @throws DbException
+ */
+ public function query(string $sql): PDOStatement
+ {
+ if($this->debug) {
+ go()->getDebugger()->debug($sql, 1 ,false);
+ }
+ try {
+ return $this->getPdo()->query($sql);
+ }
+ catch(PDOException $e) {
+ throw new DbException($e, $sql);
+ }
+ }
+
+ /**
+ * Execute an SQL statement and return the number of affected rows
+ * PDO::exec() executes an SQL statement in a single function call, returning the number of rows affected by the statement.
PDO::exec() does not return results from a SELECT statement. For a SELECT statement that you only need to issue once during your program, consider issuing PDO::query(). For a statement that you need to issue multiple times, prepare a PDOStatement object with PDO::prepare() and issue the statement with PDOStatement::execute().
+ * @param string $sql The SQL statement to prepare and execute.
Data inside the query should be properly escaped.
+ * @return int PDO::exec() returns the number of rows that were modified or deleted by the SQL statement you issued. If no rows were affected, PDO::exec() returns 0 .
Warning
This function may return Boolean FALSE , but may also return a non-Boolean value which evaluates to FALSE . Please read the section on Booleans for more information. Use the === operator for testing the return value of this function.
The following example incorrectly relies on the return value of PDO::exec() , wherein a statement that affected 0 rows results in a call to die():
<?php $db->exec() or die(print_r($db->errorInfo(), true)); // incorrect ?>
+ * @link http://php.net/manual/en/pdo.exec.php
+ *
+ * @throws DbException
+ */
+ public function exec(string $sql): int
+ {
+ if($this->debug) {
+ go()->getDebugger()->debug($sql, 1, true);
+ }
+ try {
+ return $this->getPdo()->exec($sql);
+ }
+ catch(PDOException $e) {
+ throw new DbException($e, $sql);
+ }
+ }
+
+
+ /**
+ * UNLOCK TABLES explicitly releases any table locks held by the current session
+ */
+ public function unlockTables(): bool
+ {
+ return $this->getPdo()->exec("UNLOCK TABLES") !== false;
+ }
+
+ private $transactionSavePointLevel = 0;
+
+ /**
+ * Start a database transation
+ *
+ * @return boolean
+ */
+ public function beginTransaction(): bool
+ {
+ if($this->transactionSavePointLevel == 0) {
+ $ret = $this->getPdo()->beginTransaction();
+
+ }else
+ {
+ $ret = true;
+ }
+
+ $this->transactionSavePointLevel++;
+
+ if($this->debug) {
+ go()->debug("beginTransaction " . $this->transactionSavePointLevel, 1);
+ }
+
+ return $ret;
+ }
+
+ private $resumeLevels = 0;
+
+ public function isPaused(): bool
+ {
+ return $this->resumeLevels > 0;
+ }
+
+ /**
+ * Commits the transaction but remembers the nesting of transaction nesting level. Must be resumed with resumeTransactions().
+ * This is used by custom fields that do database structure changes. MySQL commits transactions automatically when the database
+ * structure changes.
+ *
+ */
+ public function pauseTransactions() {
+ if($this->isPaused()) {
+ return;
+ }
+
+ $this->resumeLevels = $this->transactionSavePointLevel;
+ while($this->transactionSavePointLevel > 0) {
+ $this->commit();
+ }
+ }
+
+ /**
+ * @see pauseTransactions()
+ */
+ public function resumeTransactions() {
+ while($this->resumeLevels > 0) {
+ $this->beginTransaction();
+ $this->resumeLevels--;
+ }
+ }
+
+ /**
+ * Rollback the DB transaction
+ *
+ * @return boolean
+ */
+ public function rollBack(): bool
+ {
+ if($this->transactionSavePointLevel == 0) {
+ throw new LogicException("Not in transaction!");
+ }
+
+ $this->transactionSavePointLevel--;
+
+ if($this->debug) {
+ go()->warn("ROLLBACK DB TRANSACTION " . $this->transactionSavePointLevel, 1);
+ }
+
+ if($this->transactionSavePointLevel == 0) {
+ return $this->getPdo()->rollBack();
+ }else
+ {
+ return true;
+ }
+ }
+
+ /**
+ * Commit the database transaction
+ *
+ * @return boolean
+ * @throws PDOException
+ */
+ public function commit(): bool
+ {
+ if($this->transactionSavePointLevel == 0) {
+ throw new PDOException("Not in transaction!");
+ }
+
+ $this->transactionSavePointLevel--;
+
+ if($this->debug) {
+ go()->debug("COMMIT DB TRANSACTION " . $this->transactionSavePointLevel, 1);
+ }
+
+
+ if($this->transactionSavePointLevel == 0) {
+ return $this->getPdo()->commit();
+ }else
+ {
+ return true;
+ }
+ }
+
+ /**
+ * Check if a transaction is active
+ *
+ * @return boolean
+ */
+ public function inTransaction(): bool
+ {
+ return $this->getPdo()->inTransaction();
+ }
+
+ /**
+ * Lock the table of this model and also for the 't' alias.
+ *
+ * The locks array should be indexed by model name and the value is an array with two optional values.
+ * THe first is a boolean that enables a write-lock and the second is a table alias.
+ *
+ * @param array $locks eg. [go\cores\Users\Model\User::tableName() => [true, 't']]
+ *
+ * @throws PDOException
+ */
+ public function lock(array $locks)
+ {
+ $sql = "LOCK TABLES ";
+
+ foreach ($locks as $tableName => $lockInfo) {
+ $sql .= $tableName . " ";
+
+ if (isset($lockInfo[1])) {
+ $sql .= ' AS ' . $lockInfo[1] . ' ';
+ }
+ $sql .= empty($lockInfo[0]) ? 'READ' : 'WRITE';
+
+ $sql .= ', ';
+ }
+
+ $sql = rtrim($sql, ', ');
+
+ App::get()->getDbConnection()->exec($sql);
+ }
+
+ /**
+ * Create a delete statement
+ *
+ * @param string $tableName
+ * @param Query|array|string|null $query
+ * @return Statement
+ * @example
+ * ```
+ * $success = App::get()
+ * ->getDbConnection()
+ * ->delete("test_a", ['id' => 1])
+ * ->execute();
+ * ```
+ */
+ public function delete(string $tableName, $query = null): Statement
+ {
+ $query = Query::normalize($query);
+
+ $queryBuilder = new QueryBuilder($this);
+ $build = $queryBuilder->buildDelete($tableName, $query);
+
+ return $this->createStatement($build);
+ }
+
+ /**
+ * Create an insert statement
+ *
+ * @param string $tableName
+ * @param array|Query $data Key value array, a numeric array with key value arrays or select query
+ * @param string[] $columns If $data is a query object then you can supply the
+ * selected columns with this parameter. If not given all columns must be
+ * selected in the correct order.
+ *
+ * @return Statement
+ * @throws PDOException
+ * @example Single record
+ * ```
+ * $data = [
+ * "propA" => "string 1",
+ * "createdAt" => new \DateTime(),
+ * "modifiedAt" => new \DateTime()
+ * ];
+ *
+ * $result = App::get()
+ * ->getDbConnection()
+ * ->insert("test_a", $data)
+ * ->execute();
+ * ```
+ *
+ * Get the ID if it has an auto increment column:
+ * ```
+ * $id = App::get()->getDbConnection()->getPDO()->lastInsertId();
+ * ```
+ *
+ *
+ * @example Multiple records
+ * ```
+ * $data = [[
+ * "propA" => "string 1",
+ * "createdAt" => new \DateTime(),
+ * "modifiedAt" => new \DateTime()
+ * ],[
+ * "propA" => "string 2",
+ * "createdAt" => new \DateTime(),
+ * "modifiedAt" => new \DateTime()
+ * ]];
+ *
+ * $result = App::get()
+ * ->getDbConnection()
+ * ->insert("test_a", $data)
+ * ->execute();
+ * ```
+ *
+ * Or with an expression:
+ *
+ * ```
+ * App::get()->getDbConnection()
+ * ->update("core_state", new \go\core\db\Expression("highestModSeq = highestModSeq + 1"), $query);
+ * ```
+ *
+ */
+ public function insert(string $tableName, $data, array $columns = []): Statement
+ {
+
+ $queryBuilder = new QueryBuilder($this);
+ $build = $queryBuilder->buildInsert($tableName, $data, $columns);
+
+ return $this->createStatement($build);
+ }
+
+ /**
+ * Insert data in the database and ignore if the records exist
+ *
+ * @param string $tableName
+ * @param array|Query $data Key value array or select query
+ * @param string[] $columns If $data is a query object then you can supply the
+ * selected columns with this parameter. If not given all columns must be
+ * selected in the correct order.
+ *
+ * @return Statement
+ * @throws DbException
+ * @see insert()
+ */
+ public function insertIgnore(string $tableName, Query|array $data, array $columns = []): Statement
+ {
+
+ $queryBuilder = new QueryBuilder($this);
+ $build = $queryBuilder->buildInsert($tableName, $data, $columns, "INSERT IGNORE");
+
+ return $this->createStatement($build);
+ }
+
+ /**
+ * Replace data in the database
+ *
+ * @param string $tableName
+ * @param array|Query $data Key value array, array of key value arrays for multi insert or select query
+ * @param string[] $columns If $data is a query object then you can supply the
+ * selected columns with this parameter. If not given all columns must be
+ * selected in the correct order.
+ *
+ * @return Statement
+ * @throws Throwable
+ * @see insert()
+ */
+ public function replace(string $tableName, Query|array $data, array $columns = []): Statement
+ {
+
+ $queryBuilder = new QueryBuilder($this);
+ $build = $queryBuilder->buildInsert($tableName, $data, $columns, "REPLACE");
+
+ return $this->createStatement($build);
+ }
+
+ /**
+ * Create an update statement
+ *
+ * @param string $tableName
+ * @param $data
+ * @param Query|string|array|null $query {@see Query::normalize()}
+ * @return Statement
+ * @throws PDOException
+ * @example with join
+ * ```
+ * go()->getDbConnection()->update(
+ * 'core_acl',
+ * [
+ * 'acl.entityTypeId' => $entityTypeId,
+ * 'acl.entityId' => new Expression('ab.id')], // Use go\core\db\Expression for references to tables
+ * (new Query())
+ * ->tableAlias('acl') // set alias for core_acl table
+ * ->join('addressbook_addressbook, 'ab', 'ab.aclId = acl.id'))
+ * ->execute();
+ * ```
+ *
+ *
+ * @example
+ * ```
+ * $data = [
+ * "propA" => "string 3",
+ * "count" => new \go\core\db\Expression('count + 1'), //Example for expression
+ * ];
+ *
+ * $stmt = App::get()->getDbConnection()->update("test_a", $data, ['id' => 1]);
+ * $stmt->execute();
+ * ```
+ *
+ */
+ public function update(string $tableName, $data, Criteria|array|string|null $query = null): Statement
+ {
+ $query = Query::normalize($query);
+
+ $queryBuilder = new QueryBuilder($this);
+ $build = $queryBuilder->buildUpdate($tableName, $data, $query);
+
+ return $this->createStatement($build);
+ }
+
+ /**
+ * Update but with ignore
+ *
+ * @param $tableName
+ * @param $data
+ * @param Criteria|array|string|null $query
+ * @return Statement
+ * @throws Exception
+ * @see update()
+ */
+ public function updateIgnore($tableName, $data, Criteria|array|string|null $query = null): Statement
+ {
+ $query = Query::normalize($query);
+
+ $queryBuilder = new QueryBuilder($this);
+ $build = $queryBuilder->buildUpdate($tableName, $data, $query, "UPDATE IGNORE");
+
+ return $this->createStatement($build);
+ }
+
+ /**
+ * Create a select statement.
+ *
+ * @param string|string[] $select
+ * @return Query
+ * @example
+ * ```
+ * $query = go()->getDbConnection()
+ * ->select('*')
+ * ->from('test_a')
+ * ->where('id', '=', 1);
+ *
+ * $stmt = $query->execute();
+ *
+ * ```
+ *
+ * @see Query
+ */
+ public function select(array|string $select = "*"): Query
+ {
+ $query = new Query();
+ return $query->setDbConnection($this)->select($select);
+ }
+
+ /**
+ * Select a single column or count(*) for example.
+ *
+ * Shortcut for:
+ * $query->fetchMode(\PDO::FETCH_COLUMN,0)->select($select)
+ *
+ * @param string $select
+ * @return Query
+ */
+ public function selectSingleValue(string $select): Query
+ {
+ $query = new Query();
+ return $query->setDbConnection($this)->selectSingleValue($select);
+ }
+
+ /**
+ * Create a statement from a QueryBuilder result.
+ *
+ * For internal use of the API.
+ *
+ * @param array $build
+ * @return Statement
+ * @throws Exception
+ */
+ public function createStatement(array &$build): Statement
+ {
+ try {
+ if($this->debug) {
+ $build['start'] = go()->getDebugger()->getMicroTime();
+ }
+
+ $pdoStmt = $this->getPDO()->prepare($build['sql']);
+
+ $stmt = new Statement($pdoStmt);
+ $stmt->setBuild($build);
+
+ for ($i =0, $l = count($build['params']); $i < $l; $i++) {
+ $stmt->bindValue($i + 1, $build['params'][$i]['value'], $build['params'][$i]['pdoType']);
+ }
+
+ return $stmt;
+ } catch(PDOException $e) {
+ throw new DbException($e, go()->getDebugger()->enabled ? QueryBuilder::debugBuild($build) : null);
+ } catch(Exception $e) {
+ if(go()->getDebugger()->enabled) {
+ go()->debug(QueryBuilder::debugBuild($build));
+ }
+ throw $e;
+ }
+ }
+
+
+ /**
+ * Get or set foreign key checks
+ *
+ * @param bool|null $value Provide to set new value
+ * @return bool The current or old value
+ * @throws DbException
+ */
+ public function foreignKeyChecks(bool|null $value = null): bool
+ {
+ $stmt = $this->query("SELECT @@SESSION.foreign_key_checks");
+ $current = !!$stmt->fetch(PDO::FETCH_COLUMN, 0);
+
+ if(isset($value) && $value != $current) {
+ $this->exec("set foreign_key_checks = " . ($value ? "1" : "0") . ";");
+ }
+
+ return $current;
+ }
+
+
+}
diff --git a/config/services/containers/app/groupoffice/config/config.php.j2 b/config/services/containers/app/groupoffice/config/config.php.j2
new file mode 100644
index 0000000..4b3b9cf
--- /dev/null
+++ b/config/services/containers/app/groupoffice/config/config.php.j2
@@ -0,0 +1,36 @@
+
+ array (
+ 'package' => 'studio',
+ )
+);
diff --git a/config/services/containers/app/groupoffice/config/configfile.php.j2 b/config/services/containers/app/groupoffice/config/configfile.php.j2
new file mode 100644
index 0000000..ac8311c
--- /dev/null
+++ b/config/services/containers/app/groupoffice/config/configfile.php.j2
@@ -0,0 +1,252 @@
+createDsn($config['db_name'] ?? "groupoffice", $config);
+ /*
+ * Database TLS
+ */
+ $options = [];
+
+ if (!empty($config['db_ssl_ca'])) {
+ $verifyServerCert =
+ $config['db_ssl_verify_server_cert'] ?? true;
+
+ if (version_compare(PHP_VERSION, '8.5.0') >= 0) {
+ $options[PDO\Mysql::ATTR_SSL_CA] =
+ $config['db_ssl_ca'];
+
+ $options[PDO\Mysql::ATTR_SSL_VERIFY_SERVER_CERT] =
+ $verifyServerCert;
+ } else {
+ $options[PDO::MYSQL_ATTR_SSL_CA] =
+ $config['db_ssl_ca'];
+
+ $options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] =
+ $verifyServerCert;
+ }
+ }
+
+
+ try {
+ $pdo = new PDO(
+ $dsn,
+ $config['db_user'],
+ $config['db_pass'],
+ $options
+ );
+ }
+ /*
+ * Database TLS
+ */
+ catch(Exception $e){
+ $dbConnectError = "Could not connect to the database with DSN = '" . $dsn . "' with username '" . ($config['db_user'] ?? "groupoffice") . " and " . (empty($config['db_pass']) ? "NOT using password" : "USING a password") . ". The database returned this error: ".$e->getMessage();
+ }
+
+ if($pdo) {
+ $clientVersion = $pdo->getAttribute(PDO::ATTR_CLIENT_VERSION);
+ if (strpos($clientVersion, 'mysqlnd') === false) {
+ $dbConnectError = "PDO is not using the mysqlnd driver. Please make sure PDO uses mysqlnd. It's now using: " . $clientVersion;
+ $pdo = null;
+ }
+ }
+
+ return $pdo;
+}
+
+
+
+$configFile = App::findConfigFile();
+if(!$configFile) {
+ $configFile = '/etc/groupoffice/config.php';
+} else
+{
+ require($configFile);
+}
+
+$mustSave = false;
+
+if($_SERVER['REQUEST_METHOD'] != 'POST')
+{
+
+ $config['file_storage_path'] = $config['file_storage_path'] ?? "/var/lib/groupoffice";
+ $config['tmpdir'] = $config['tmpdir'] ?? "/tmp/groupoffice";
+
+ //check if config.ini is already prefilled with usable data (Docker)
+ $tmpFolder = new Folder($config['tmpdir']);
+ $dataFolder = new Folder($config['file_storage_path']);
+ if(!$dataFolder->isWritable() && isset($_SERVER['DOCUMENT_ROOT'])) {
+ $dataFolder = new Folder(dirname($_SERVER['DOCUMENT_ROOT']) . '/groupoffice');
+ $mustSave = true;
+ }
+
+ if(dbConnect($config) && $dataFolder->isWritable() && $tmpFolder->isWritable() && !$mustSave) {
+ header("Location: install.php");
+ exit();
+ }
+
+ $_POST['dbPort'] = $config['db_port'] ?? 3306;
+ $_POST['dbHostname'] = $config['db_host'] ?? 'localhost';
+ $_POST['dbName'] = $config['db_name'] ?? "groupoffice";
+
+ $_POST['dbUsername'] = $config['db_user'] ?? "groupoffice";
+ //$_POST['dbPassword'] = $config['db']['password'];
+} else
+{
+// $config['general']['dataPath'] = $_POST['dataPath'];
+// $config['general']['tmpPath'] = $_POST['tmpPath'];
+// $config['db']['dsn'] = 'mysql:host='.$_POST['dbHostname'].';port='.$_POST['dbPort'].';dbname='.$_POST['dbName'];
+// $config['db']['username'] = $_POST['dbUsername'];
+// $config['db']['password'] = $_POST['dbPassword'];
+
+ $config['file_storage_path'] = $_POST['dataPath'];
+ $config['tmpdir'] = $_POST['tmpPath'];
+ $config['db_host'] =$_POST['dbHostname'];
+ $config['db_name'] =$_POST['dbName'];
+ $config['db_user'] =$_POST['dbUsername'];
+ $config['db_pass'] =$_POST['dbPassword'];
+ $config['db_port'] =$_POST['dbPort'];
+
+ $tmpFolder = new Folder($config['tmpdir']);
+ $dataFolder = new Folder($config['file_storage_path']);
+
+
+ if($dataFolder->isWritable() && $tmpFolder->isWritable() && dbConnect($config)) {
+ $cFile = new \go\core\fs\File($configFile);
+
+ if(!$cFile->putContents("getPath());
+ }
+
+ header('Location: install.php');
+ exit();
+ }
+ }
+}
+
+
+
+
+require('header.php');
+?>
+
+
+
+
+
+ = go()->t("Config file"); ?>
+ Please create a writeable config.php file here: = $configFile; ?>.
+
+
+
+
+
+
+
';
+ }
+ ?>
+
+ Database user
+ " required />
+
+
+ Database password
+ " required />
+
+
+ Database name
+ " required />
+
+
+ Database hostname
+ " required />
+
+
+ Database port
+
+
+
+ = go()->t('Continue'); ?>
+
+
+
+
+
+
+
+
+
diff --git a/config/services/containers/app/groupoffice/config/groupoffice-ca.ini.j2 b/config/services/containers/app/groupoffice/config/groupoffice-ca.ini.j2
new file mode 100644
index 0000000..095b3bb
--- /dev/null
+++ b/config/services/containers/app/groupoffice/config/groupoffice-ca.ini.j2
@@ -0,0 +1 @@
+openssl.cafile=/etc/ssl/groupoffice/{{ root_cert_filename }}
diff --git a/config/services/containers/app/groupoffice/groupoffice.container.j2 b/config/services/containers/app/groupoffice/groupoffice.container.j2
new file mode 100644
index 0000000..1dc26f9
--- /dev/null
+++ b/config/services/containers/app/groupoffice/groupoffice.container.j2
@@ -0,0 +1,41 @@
+[Quadlet]
+DefaultDependencies=false
+
+[Unit]
+Description=GroupOffice
+
+[Container]
+Image=docker.io/intermesh/groupoffice:{{ version['containers']['groupoffice'] }}
+
+ContainerName=groupoffice
+HostName=groupoffice
+
+PublishPort={{ services['groupoffice']['ports']['http'] }}:80/tcp
+
+Volume=%h/data/containers/groupoffice/data:/var/lib/groupoffice:rw
+Volume=%h/containers/groupoffice/ssl/{{ root_cert_filename }}:/etc/ssl/groupoffice/{{ root_cert_filename }}:ro
+# Keep the official image intact.
+Volume=%h/containers/groupoffice/config/config.php:/etc/groupoffice/config.php:ro
+Volume=%h/containers/groupoffice/config/groupoffice-ca.ini:/usr/local/etc/php/conf.d/99-groupoffice-ca.ini:ro
+Volume=%h/containers/groupoffice/config/Authenticator.php:/usr/local/share/groupoffice/go/modules/community/imapauthenticator/model/Authenticator.php:ro
+Volume=%h/containers/groupoffice/config/db_Connection.php:/usr/local/share/groupoffice/go/core/db/Connection.php:ro
+Volume=%h/containers/groupoffice/config/configfile.php:/usr/local/share/groupoffice/install/configfile.php:ro
+
+Environment="TZ={{ timezone }}"
+
+# GroupOffice official Docker DB configuration
+Environment="MYSQL_HOST={{ services['mariadb']['domain']['internal'] }}.{{ domain['internal'] }}"
+Environment="MYSQL_DATABASE=groupoffice_db"
+Environment="MYSQL_USER=groupoffice"
+Secret=GROUPOFFICE_DB_PASSWORD,type=env,target=MYSQL_PASSWORD
+
+[Service]
+ExecStartPre=/usr/bin/nc -zv {{ services['mariadb']['domain']['internal'] }}.{{ domain['internal'] }} {{ services['mariadb']['ports']['tcp'] }}
+ExecStartPre=/usr/bin/nc -zv {{ services['maddy']['domain']['internal'] }}.{{ domain['internal'] }} 993
+ExecStartPost=-/usr/bin/podman exec -u www-data groupoffice php /usr/local/share/groupoffice/cli.php core/System/upgrade
+Restart=always
+RestartSec=10s
+TimeoutStopSec=120
+
+[Install]
+WantedBy=default.target
diff --git a/config/services/containers/app/groupoffice/services/groupoffice-cron.service b/config/services/containers/app/groupoffice/services/groupoffice-cron.service
new file mode 100644
index 0000000..2c28d82
--- /dev/null
+++ b/config/services/containers/app/groupoffice/services/groupoffice-cron.service
@@ -0,0 +1,9 @@
+[Unit]
+Description=GroupOffice cron.php
+Requires=groupoffice.service
+After=groupoffice.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/podman exec -u www-data groupoffice \
+ php /usr/local/share/groupoffice/cron.php
diff --git a/config/services/containers/app/groupoffice/services/groupoffice-cron.timer b/config/services/containers/app/groupoffice/services/groupoffice-cron.timer
new file mode 100644
index 0000000..f23556a
--- /dev/null
+++ b/config/services/containers/app/groupoffice/services/groupoffice-cron.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=Run GroupOffice cron every 1 minutes
+
+[Timer]
+OnBootSec=1min
+OnUnitActiveSec=1min
+Unit=groupoffice-cron.service
+
+[Install]
+WantedBy=timers.target
diff --git a/config/services/containers/app/homepage/config/services.yaml.j2 b/config/services/containers/app/homepage/config/services.yaml.j2
index 5d7f040..3cf4089 100644
--- a/config/services/containers/app/homepage/config/services.yaml.j2
+++ b/config/services/containers/app/homepage/config/services.yaml.j2
@@ -1,6 +1,7 @@
{% set icon_map = {
+ 'ldap': 'lldap',
'tandoor': 'tandoor-recipes',
- 'ldap': 'lldap'
+ 'openwebui': 'open-webui'
} %}
- Services:
diff --git a/config/services/containers/app/kaneo/kaneo.container.j2 b/config/services/containers/app/kaneo/kaneo.container.j2
new file mode 100644
index 0000000..6d36e72
--- /dev/null
+++ b/config/services/containers/app/kaneo/kaneo.container.j2
@@ -0,0 +1,48 @@
+[Quadlet]
+DefaultDependencies=false
+
+[Unit]
+Description=Kaneo
+
+[Container]
+Image=ghcr.io/usekaneo/kaneo:{{ version['containers']['kaneo'] }}
+
+ContainerName=kaneo
+HostName=kaneo
+
+PublishPort={{ services['kaneo']['ports']['http'] }}:5173/tcp
+
+Volume=%h/containers/kaneo/ssl:/etc/ssl/kaneo:ro
+
+Environment="TZ={{ timezone }}"
+Environment="DISABLE_PASSWORD_REGISTRATION=true"
+Environment="DISABLE_REGISTRATION=false"
+Environment="DISABLE_GUEST_ACCESS=true"
+Environment="KANEO_CLIENT_URL=https://{{ services['kaneo']['domain']['public'] }}.{{ domain['public'] }}"
+Secret=KANEO_AUTH_SECRET,type=env,target=AUTH_SECRET
+
+# PostgreSQL
+Environment="POSTGRES_HOST={{ services['postgresql']['domain']['internal'] }}.{{ domain['internal'] }}"
+Environment="POSTGRES_PORT={{ services['postgresql']['ports']['tcp'] }}"
+Environment="POSTGRES_DB=kaneo_db"
+Environment="POSTGRES_USER=kaneo"
+Environment="PGSSLMODE=verify-full"
+Environment="NODE_EXTRA_CA_CERTS=/etc/ssl/kaneo/{{ root_cert_filename }}"
+Secret=KANEO_DB_PASSWORD,type=env,target=POSTGRES_PASSWORD
+
+Environment="CUSTOM_OAUTH_CLIENT_ID=kaneo"
+Environment="CUSTOM_OAUTH_DISCOVERY_URL=https://{{ services['authelia']['domain']['public'] }}.{{ domain['public'] }}/.well-known/openid-configuration"
+Environment="CUSTOM_OAUTH_SCOPES=openid,profile,email"
+Environment="CUSTOM_AUTH_PKCE=true"
+Secret=KANEO_OIDC_CLIENT_SECRET,type=env,target=CUSTOM_OAUTH_CLIENT_SECRET
+
+
+[Service]
+ExecStartPre=/usr/bin/nc -zv {{ services['postgresql']['domain']['internal'] }}.{{ domain['internal'] }} {{ services['postgresql']['ports']['tcp'] }}
+
+Restart=always
+RestartSec=10s
+TimeoutStopSec=120
+
+[Install]
+WantedBy=default.target
diff --git a/config/services/containers/app/llama/config/models.ini.j2 b/config/services/containers/app/llama/config/models.ini.j2
new file mode 100644
index 0000000..5be2350
--- /dev/null
+++ b/config/services/containers/app/llama/config/models.ini.j2
@@ -0,0 +1,13 @@
+version = 1
+
+{% for models in (services['llama']['dependency']['models'] | dict2items | list) %}
+{% if models['key'] == 'default' %}
+[*]
+{{ models['value'] }}
+
+{% else %}
+[{{ models['key'] }}]
+{{ models['value'] }}
+
+{% endif %}
+{% endfor %}{{ '' }}
diff --git a/config/services/containers/app/llama/llama.container.j2 b/config/services/containers/app/llama/llama.container.j2
new file mode 100644
index 0000000..d8a8b84
--- /dev/null
+++ b/config/services/containers/app/llama/llama.container.j2
@@ -0,0 +1,34 @@
+[Quadlet]
+DefaultDependencies=false
+
+[Unit]
+Description=llama.cpp Model Server
+
+[Container]
+Image=ghcr.io/ggml-org/llama.cpp:{{ version['containers']['llama'] }}
+
+ContainerName=llama
+HostName=llama
+PublishPort={{ services['llama']['ports']['http'] }}:9931/tcp
+
+# iGPU access for Vulkan
+AddDevice=/dev/dri:/dev/dri
+PodmanArgs=--group-add keep-groups
+
+Volume=%h/containers/llama/models:/models:ro
+Volume=%h/containers/llama/config:/config:ro
+
+Environment="TZ={{ timezone }}"
+
+Exec=--models-preset /config/models.ini \
+ --models-max 2 \
+ --host 0.0.0.0 \
+ --port 9931
+
+[Service]
+Restart=always
+RestartSec=10s
+TimeoutStopSec=300
+
+[Install]
+WantedBy=default.target
diff --git a/config/services/containers/app/openwebui/openwebui.container.j2 b/config/services/containers/app/openwebui/openwebui.container.j2
new file mode 100644
index 0000000..80f6c5c
--- /dev/null
+++ b/config/services/containers/app/openwebui/openwebui.container.j2
@@ -0,0 +1,84 @@
+[Quadlet]
+DefaultDependencies=false
+
+[Unit]
+Description=Open WebUI
+
+[Container]
+Image=ghcr.io/open-webui/open-webui:{{ version['containers']['openwebui'] }}
+ContainerName=openwebui
+HostName=openwebui
+PublishPort={{ services['openwebui']['ports']['http'] }}:8080/tcp
+
+Volume=%h/data/containers/openwebui/data:/app/backend/data:rw
+Volume=%h/containers/openwebui/ssl:/etc/ssl/openwebui:ro
+
+# General
+Environment="TZ={{ timezone }}"
+Environment="WEBUI_URL=https://{{ services['openwebui']['domain']['public'] }}.{{ domain['public'] }}"
+Environment="CORS_ALLOW_ORIGIN=https://{{ services['openwebui']['domain']['public'] }}.{{ domain['public'] }}"
+Environment="ENABLE_PERSISTENT_CONFIG=false"
+Environment="ENABLE_SIGNUP=false"
+Environment="ENABLE_LOGIN_FORM=false"
+Environment="ENABLE_PASSWORD_AUTH=false"
+Environment="ENABLE_PASSWORD_CHANGE_FORM=false"
+Secret=OPEN_WEBUI_SECRET_KEY,type=env,target=WEBUI_SECRET_KEY
+# We use OpenAI-compatible backends, not Ollama
+Environment="ENABLE_OLLAMA_API=false"
+
+# PostgreSQL
+Environment="DATABASE_TYPE=postgresql"
+Environment="DATABASE_HOST={{ services['postgresql']['domain']['internal'] }}.{{ domain['internal'] }}"
+Environment="DATABASE_PORT={{ services['postgresql']['ports']['tcp'] }}"
+Environment="DATABASE_NAME=openwebui_db"
+Environment="DATABASE_USER=openwebui"
+Environment="PGSSLMODE=verify-full"
+Environment="PGSSLROOTCERT=/etc/ssl/openwebui/{{ root_cert_filename }}"
+Environment="PGSSLCERTMODE=disable"
+Secret=OPEN_WEBUI_POSTGRES_PASSWORD,type=env,target=DATABASE_PASSWORD
+
+# OIDC
+Environment="ENABLE_OAUTH_PERSISTENT_CONFIG=false"
+Environment="ENABLE_OAUTH_SIGNUP=true"
+Environment="OAUTH_AUTO_REDIRECT=false"
+Environment="OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true"
+Environment="OAUTH_CLIENT_ID=openwebui"
+Environment="OPENID_PROVIDER_URL=https://{{ services['authelia']['domain']['public'] }}.{{ domain['public'] }}/.well-known/openid-configuration"
+Environment="OAUTH_PROVIDER_NAME=Authelia"
+Environment="OAUTH_SCOPES=openid email profile groups"
+Environment="ENABLE_OAUTH_ROLE_MANAGEMENT=true"
+Environment="OAUTH_ALLOWED_ROLES=admins,users"
+Environment="OAUTH_ADMIN_ROLES=admins"
+Environment="OAUTH_ROLES_CLAIM=groups"
+Environment="OAUTH_CODE_CHALLENGE_METHOD=S256"
+Secret=OPEN_WEBUI_OAUTH_CLIENT_SECRET,type=env,target=OAUTH_CLIENT_SECRET
+
+# AI backend server
+# llama.cpp
+Environment="OPENAI_API_BASE_URL=http://host.containers.internal:{{ services['llama']['ports']['http'] }}/v1"
+Environment="OPENAI_API_KEY=none"
+# stable-diffusion.cpp
+Environment="ENABLE_IMAGE_GENERATION=true"
+Environment="IMAGE_GENERATION_ENGINE=openai"
+Environment="IMAGES_OPENAI_API_BASE_URL=http://host.containers.internal:{{ services['stablediffusion']['ports']['http'] }}/v1"
+Environment="IMAGES_OPENAI_API_KEY=none"
+Environment="IMAGE_GENERATION_MODEL=sd-cpp-local"
+Environment="IMAGE_SIZE=1024x1024"
+Environment="AIOHTTP_CLIENT_TIMEOUT=3600"
+# Disable background LLM tasks
+Environment="ENABLE_TITLE_GENERATION=false"
+Environment="ENABLE_TAGS_GENERATION=false"
+Environment="ENABLE_FOLLOW_UP_GENERATION=false"
+Environment="ENABLE_AUTOCOMPLETE_GENERATION=false"
+Environment="DEFAULT_MODEL_METADATA={\"builtinTools\":{\"user_input\":false}}"
+
+
+[Service]
+MemoryKSM=yes
+ExecStartPre=/usr/bin/nc -zv {{ services['postgresql']['domain']['internal'] }}.{{ domain['internal'] }} {{ services['postgresql']['ports']['tcp'] }}
+Restart=always
+RestartSec=10s
+TimeoutStopSec=120
+
+[Install]
+WantedBy=default.target
diff --git a/config/services/containers/app/stablediffusion/stablediffusion.container.j2 b/config/services/containers/app/stablediffusion/stablediffusion.container.j2
new file mode 100644
index 0000000..a81bdc3
--- /dev/null
+++ b/config/services/containers/app/stablediffusion/stablediffusion.container.j2
@@ -0,0 +1,36 @@
+[Quadlet]
+DefaultDependencies=false
+
+[Unit]
+Description=stable-diffusion.cpp Image Generation Server
+
+[Container]
+Image=ghcr.io/leejet/stable-diffusion.cpp:{{ version['containers']['stablediffusion'] }}
+
+ContainerName=stablediffusion
+HostName=stablediffusion
+PublishPort={{ services['stablediffusion']['ports']['http'] }}:1234/tcp
+
+# iGPU access for container
+AddDevice=/dev/dri:/dev/dri
+PodmanArgs=--group-add keep-groups
+
+Volume=%h/containers/stablediffusion/models:/models:ro
+
+# General
+Environment="TZ={{ timezone }}"
+# Prevent Fence expiration time out f9r CYCL
+Environment="SYCL_PI_LEVEL_ZERO_USE_IMMEDIATE_COMMANDLISTS=1"
+
+Entrypoint=/sd-server
+Exec=--listen-ip 0.0.0.0 \
+ --listen-port 1234 \
+ {{ services['stablediffusion']['dependency']['models'][stabeldiffusion_active_model] }}
+
+[Service]
+Restart=always
+RestartSec=10s
+TimeoutStopSec=300
+
+[Install]
+WantedBy=default.target
diff --git a/config/services/containers/app/sure/sure-web.container.j2 b/config/services/containers/app/sure/sure-web.container.j2
index 3be2a4b..1329e77 100644
--- a/config/services/containers/app/sure/sure-web.container.j2
+++ b/config/services/containers/app/sure/sure-web.container.j2
@@ -57,6 +57,17 @@ Environment="WEBAUTHN_ALLOWED_ORIGINS=https://{{ services['sure']['domain']['pub
Environment="EXCHANGE_RATE_PROVIDER=yahoo_finance"
Environment="SECURITIES_PROVIDER=yahoo_finance"
+# AI backend
+Environment="ASSISTANT_TYPE=builtin"
+Environment="OPENAI_URI_BASE=http://host.containers.internal:10000/v1"
+Environment="OPENAI_ACCESS_TOKEN=local"
+Environment="OPENAI_MODEL={{ services['llama']['dependency']['models'] | dict2items | rejectattr('key', 'equalto', 'default') | map(attribute='key') | last }}"
+Environment="LLM_CONTEXT_WINDOW=8192"
+Environment="OPENAI_REQUEST_TIMEOUT=600"
+Environment="ASSISTANT_MAX_TOOL_CALL_ITERATIONS=2"
+Environment="AI_RESPONSE_TIMEOUT=2400"
+
+
[Service]
MemoryKSM=yes
ExecStartPre=/usr/bin/nc -zv {{ services['postgresql']['domain']['internal'] }}.{{ domain['internal'] }} {{ services['postgresql']['ports']['tcp'] }}
diff --git a/config/services/containers/app/sure/sure-worker.container.j2 b/config/services/containers/app/sure/sure-worker.container.j2
index 73a0b36..6555498 100644
--- a/config/services/containers/app/sure/sure-worker.container.j2
+++ b/config/services/containers/app/sure/sure-worker.container.j2
@@ -33,6 +33,16 @@ Secret=SURE_POSTGRES_PASSWORD,type=env,target=POSTGRES_PASSWORD
# Redis
Environment="REDIS_URL=redis://host.containers.internal:{{ services['sure']['ports']['redis'] }}/1"
+# AI backend
+Environment="ASSISTANT_TYPE=builtin"
+Environment="OPENAI_URI_BASE=http://host.containers.internal:10000/v1"
+Environment="OPENAI_ACCESS_TOKEN=local"
+Environment="OPENAI_MODEL={{ services['llama']['dependency']['models'] | dict2items | rejectattr('key', 'equalto', 'default') | map(attribute='key') | last }}"
+Environment="LLM_CONTEXT_WINDOW=8192"
+Environment="OPENAI_REQUEST_TIMEOUT=600"
+Environment="ASSISTANT_MAX_TOOL_CALL_ITERATIONS=2"
+Environment="AI_RESPONSE_TIMEOUT=2400"
+
# Provider
## Currency
Environment="EXCHANGE_RATE_PROVIDER=yahoo_finance"
diff --git a/config/services/containers/auth/authelia/config/authelia.yaml.j2 b/config/services/containers/auth/authelia/config/authelia.yaml.j2
index 4bce4f9..e20b439 100644
--- a/config/services/containers/auth/authelia/config/authelia.yaml.j2
+++ b/config/services/containers/auth/authelia/config/authelia.yaml.j2
@@ -361,4 +361,48 @@ identity_providers:
- 'profile'
access_token_signed_response_alg: 'none'
userinfo_signed_response_alg: 'none'
- token_endpoint_auth_method: 'client_secret_post'
\ No newline at end of file
+ token_endpoint_auth_method: 'client_secret_post'
+ # https://www.authelia.com/integration/openid-connect/clients/open-webui/
+ - client_id: 'openwebui'
+ client_name: 'openwebui'
+ consent_mode: 'implicit'
+ client_secret: '{{ hostvars['console']['openwebui']['oidc']['hash'] }}'
+ public: false
+ authorization_policy: 'one_factor'
+ require_pkce: true
+ pkce_challenge_method: 'S256'
+ redirect_uris:
+ - 'https://{{ services['openwebui']['domain']['public'] }}.{{ domain['public'] }}/oauth/oidc/callback'
+ 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'
+ - client_id: 'kaneo'
+ client_name: 'kaneo'
+ consent_mode: 'implicit'
+ client_secret: '{{ hostvars['console']['kaneo']['oidc']['hash'] }}'
+ public: false
+ authorization_policy: 'one_factor'
+ require_pkce: true
+ pkce_challenge_method: 'S256'
+ redirect_uris:
+ - 'https://{{ services['kaneo']['domain']['public'] }}.{{ domain['public'] }}/api/auth/oauth2/callback/custom'
+ scopes:
+ - 'openid'
+ - 'profile'
+ - '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_post'
diff --git a/config/services/containers/common/caddy/etc/Caddyfile.j2 b/config/services/containers/common/caddy/etc/Caddyfile.j2
index d9f3765..fbf0c04 100644
--- a/config/services/containers/common/caddy/etc/Caddyfile.j2
+++ b/config/services/containers/common/caddy/etc/Caddyfile.j2
@@ -60,6 +60,10 @@
import crowdsec_log
route {
crowdsec
+{% if service['route'] is defined and service['route']['dav'] is defined and service['route']['dav'] %}
+ redir /.well-known/caldav /dav 301
+ redir /.well-known/carddav /dav 301
+{% endif %}
{% if web_type == 'auth-public' %}
reverse_proxy host.containers.internal:{{ service['ports']['http'] }}
{% else %}
diff --git a/docs/archives/services/app/ovms/group_vars.yaml b/docs/archives/services/app/ovms/group_vars.yaml
new file mode 100644
index 0000000..2e42495
--- /dev/null
+++ b/docs/archives/services/app/ovms/group_vars.yaml
@@ -0,0 +1,8 @@
+services:
+ ovms:
+ ports:
+ http: "5000"
+ subuid: "104999"
+version:
+ containers:
+ ovms: "2026.3-gpu"
diff --git a/docs/archives/services/app/ovms/ovms.container.j2 b/docs/archives/services/app/ovms/ovms.container.j2
new file mode 100644
index 0000000..9732907
--- /dev/null
+++ b/docs/archives/services/app/ovms/ovms.container.j2
@@ -0,0 +1,35 @@
+[Quadlet]
+DefaultDependencies=false
+
+[Unit]
+Description=OpenVINO Model Server
+
+[Container]
+Image=docker.io/openvino/model_server:{{ version['containers']['ovms'] }}
+
+ContainerName=ovms
+HostName=ovms
+PublishPort={{ services['ovms']['ports']['http'] }}:8000/tcp
+
+# iGPU access for OpenVINO
+AddDevice=/dev/dri:/dev/dri
+PodmanArgs=--group-add keep-groups
+
+Volume=%h/containers/ovms/models:/models:ro
+Volume=%h/containers/ovms/config:/config:ro
+Volume=%h/containers/ovms/cache:/opt/cache:rw
+
+Environment="TZ={{ timezone }}"
+
+Exec=--rest_port 8000 \
+ --config_path /config/config.json \
+ --cache_dir /opt/cache \
+ --file_system_poll_wait_seconds 0
+
+[Service]
+Restart=always
+RestartSec=10s
+TimeoutStopSec=300
+
+[Install]
+WantedBy=default.target
diff --git a/docs/archives/services/app/ovms/set_ovms.yaml b/docs/archives/services/app/ovms/set_ovms.yaml
new file mode 100644
index 0000000..8efdc12
--- /dev/null
+++ b/docs/archives/services/app/ovms/set_ovms.yaml
@@ -0,0 +1,106 @@
+---
+- name: Add user in video, render group
+ ansible.builtin.user:
+ name: "{{ ansible_user }}"
+ state: "present"
+ groups: "video, render"
+ append: true
+ become: true
+
+- name: Check model directory in data volume
+ ansible.builtin.find:
+ path: "{{ node['home_path'] }}/data/containers/ovms/models"
+ file_type: any
+ hidden: true
+ register: ovms_models
+
+- name: Check config directory in data volume
+ ansible.builtin.stat:
+ path: "{{ node['home_path'] }}/data/containers/ovms/config/config.json"
+ register: ovms_config
+
+- name: Failure when models directory or config file in data volume
+ ansible.builtin.fail:
+ msg: "There are no models or config file in data directory, please prepare the models and config before set the ovms"
+ when: (ovms_models.matched == 0) or (not ovms_config.stat.exists)
+
+- name: Create ovms directory
+ ansible.builtin.file:
+ path: "{{ node['home_path'] }}/{{ item }}"
+ state: "directory"
+ owner: "{{ services['ovms']['subuid'] }}"
+ group: "{{ node['group'] }}"
+ mode: "0770"
+ loop:
+ - "containers/ovms"
+ - "containers/ovms/models"
+ - "containers/ovms/config"
+ - "containers/ovms/cache"
+ become: true
+
+- name: Sync model data (--delete=true)
+ delegate_to: "{{ inventory_hostname }}"
+ ansible.posix.synchronize:
+ src: "{{ node['home_path'] }}/data/containers/ovms/models/"
+ dest: "{{ node['home_path'] }}/containers/ovms/models/"
+ owner: true
+ group: true
+ perms: true
+ delete: true
+ become: true
+ notify: "notification_restart_ovms"
+
+- name: Copy configuration file
+ ansible.builtin.copy:
+ remote_src: true
+ src: "{{ node['home_path'] }}/data/containers/ovms/config/config.json"
+ dest: "{{ node['home_path'] }}/containers/ovms/config/config.json"
+ owner: "{{ services['ovms']['subuid'] }}"
+ group: "{{ node['group'] }}"
+ mode: "0640"
+ become: true
+ notify: "notification_restart_ovms"
+
+- name: Pull image
+ containers.podman.podman_image:
+ name: "docker.io/openvino/model_server"
+ tag: "{{ version['containers']['ovms'] }}"
+ state: "present"
+ pull: true
+
+- name: Deploy container file
+ ansible.builtin.template:
+ src: "{{ hostvars['console']['node']['config_path'] }}/services/containers/app/ovms/ovms.container.j2"
+ dest: "{{ node['home_path'] }}/.config/containers/systemd/ovms.container"
+ owner: "{{ ansible_user }}"
+ group: "{{ node['group'] }}"
+ mode: "0644"
+ notify: "notification_restart_ovms"
+
+- name: Enable ovms.service
+ ansible.builtin.systemd:
+ name: "ovms.service"
+ state: "started"
+ enabled: true
+ daemon_reload: true
+ scope: "user"
+
+- name: Check container archive images
+ ansible.builtin.stat:
+ path: "{{ node['home_path'] }}/archives/containers/docker.io_openvino_model_server_{{ version['containers']['ovms'] }}.tar"
+ register: container_archive_images
+
+- name: Save container archive images
+ containers.podman.podman_save:
+ image:
+ - "docker.io/openvino/model_server:{{ version['containers']['ovms'] }}"
+ dest: "{{ node['home_path'] }}/archives/containers/docker.io_openvino_model_server_{{ version['containers']['ovms'] }}.tar"
+ format: "oci-archive"
+ force: false
+ when: not container_archive_images.stat.exists
+
+- name: Fetch container archive images
+ ansible.builtin.fetch:
+ src: "{{ node['home_path'] }}/archives/containers/docker.io_openvino_model_server_{{ version['containers']['ovms'] }}.tar"
+ dest: "{{ hostvars['console']['node']['data_path'] }}/images/containers/"
+ flat: true