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'); +?> + + + +
+
+

t("Config file"); ?>

+

Please create a writeable config.php file here: .

+
+
+ + + + +
+
+ + +
+

Data storage

+ +

+ Please create a folder for storing Group-Office files and a temporary files folder and make sure the webserver user can write to these folders. +

+ + isWritable()) { + echo '

Not writable

'; + } + ?> + +

+ + +

+ + + isWritable()) { + echo '

Not writable

'; + } + ?> +

+ + +

+
+ +
+

Configure the database

+ + '.$dbConnectError.'

'; + } + ?> +

+ + " required /> +

+

+ + " required /> +

+

+ + " required /> +

+

+ + " required /> +

+

+ + +

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