diff --git a/ansible.cfg b/ansible.cfg index 60ebecc..2710040 100755 --- a/ansible.cfg +++ b/ansible.cfg @@ -3,3 +3,4 @@ inventory = hosts.yml roles_path = ./roles vault_password_file = ~/.homelab-ansible-vault-pass vars_files="group_vars/secrets.yml" +lookup_plugins=./lookup_plugins diff --git a/assets/Caddyfile b/assets/Caddyfile new file mode 100644 index 0000000..54842bb --- /dev/null +++ b/assets/Caddyfile @@ -0,0 +1,450 @@ +# DO NOT EDIT THIS FILE -- OPNsense auto-generated file + + +# caddy_user=root + +# Global Options +{ + log { + include http.log.access.1b833379-a450-474a-ad74-2aee6a5d836a + include http.log.access.46a2fd7c-cbe2-4e99-9d10-9c10a4aa2e92 + include http.log.access.7293a3a7-ca31-4d7e-be90-780cb4240e6b + include http.log.access.5efd6136-26c6-4af7-9404-75201f69b046 + output net unixgram//var/run/caddy/log.sock { + } + format json { + time_format rfc3339 + } + } + + servers { + protocols h1 h2 + } + + dynamic_dns { + provider cloudflare 0zKl_ezOn2j4HHjS6ew3k3KTqH4rLIYHiV-TDYxe + domains { + thatshit.live * + thatshit.live draw + thatshit.live checkif + thatshit.live is + thatshit.live paste + thatshit.live crop + thatshit.live blaze + thatshit.live do + thatshit.live upload + blinker.club * + blinker.club wiki + blinker.club kuma + blinker.club pass + blinker.club pdf + blinker.club tables + blinker.club linkwarden + blinker.club auth + blinker.club readeck + blinker.club watch + blinker.club mediarequest + blinker.club wizarr + blinker.club memos + blinker.club vaultwarden + blinker.club fileshare + blinker.club @ + club blinker + thegrind.dev * + thegrind.dev gist + thegrind.dev tools + thegrind.dev wiki + thegrind.dev auth + thegrind.dev blog + thegrind.dev tunnel + thegrind.dev plane + thegrind.dev tasks + thegrind.dev tianji + usefor.dev * + } + } + + grace_period 10s + import /usr/local/etc/caddy/caddy.d/*.global +} + +# Reverse Proxy Configuration + + +# Reverse Proxy Domain: "1b833379-a450-474a-ad74-2aee6a5d836a" +*.thatshit.live { + log 1b833379-a450-474a-ad74-2aee6a5d836a + tls { + issuer acme { + dns cloudflare 0zKl_ezOn2j4HHjS6ew3k3KTqH4rLIYHiV-TDYxe + } + } + + @60825f6e-1b8f-4d29-9af3-19572e830eb2 { + host draw.thatshit.live + } + handle @60825f6e-1b8f-4d29-9af3-19572e830eb2 { + handle { + reverse_proxy 10.89.0.101:5001 { + transport http { + } + } + } + } + @8d17c8c4-d282-4922-acc7-3635d24b2eba { + host checkif.thatshit.live + } + handle @8d17c8c4-d282-4922-acc7-3635d24b2eba { + } + @7c9ccb9d-c8f6-4392-a032-d7a1fcf16bca { + host is.thatshit.live + } + handle @7c9ccb9d-c8f6-4392-a032-d7a1fcf16bca { + handle { + reverse_proxy 10.89.0.100 { + transport http { + } + } + } + } + @ceb5e51a-9b6d-4931-ae38-249fdfbab0dc { + host paste.thatshit.live + } + handle @ceb5e51a-9b6d-4931-ae38-249fdfbab0dc { + handle { + reverse_proxy 10.89.0.101:5009 { + transport http { + } + } + } + } + @36f95298-290c-4ed9-bac4-e657e7f12bfa { + host crop.thatshit.live + } + handle @36f95298-290c-4ed9-bac4-e657e7f12bfa { + handle { + reverse_proxy 10.89.0.101:6354 { + transport http { + } + } + } + } + @55a3bfea-48b4-44cf-ad4c-e4457fa04a1c { + host blaze.thatshit.live + } + handle @55a3bfea-48b4-44cf-ad4c-e4457fa04a1c { + } + @54e6acf9-1a0e-41f2-b31f-1e99ac35eab1 { + host do.thatshit.live + } + handle @54e6acf9-1a0e-41f2-b31f-1e99ac35eab1 { + handle { + reverse_proxy 10.89.0.108:7076 { + } + } + } + @815a9cab-b1f2-4256-9cd0-9569b23c3f77 { + host upload.thatshit.live + } + handle @815a9cab-b1f2-4256-9cd0-9569b23c3f77 { + handle { + reverse_proxy 10.89.0.108:7077 { + } + } + } + + @85d4c638-68d0-4f44-84fb-a51e71695d2e_thatshitlive { + client_ip 10.0.0.0/8 + } + handle @85d4c638-68d0-4f44-84fb-a51e71695d2e_thatshitlive { + abort + } +} +# Reverse Proxy Domain: "46a2fd7c-cbe2-4e99-9d10-9c10a4aa2e92" +*.blinker.club { + log 46a2fd7c-cbe2-4e99-9d10-9c10a4aa2e92 + tls { + issuer acme { + dns cloudflare 0zKl_ezOn2j4HHjS6ew3k3KTqH4rLIYHiV-TDYxe + } + } + + @de74e403-15ae-4c45-ac05-c9785dd31ab6 { + host wiki.blinker.club + } + handle @de74e403-15ae-4c45-ac05-c9785dd31ab6 { + handle { + reverse_proxy 10.89.0.100 { + transport http { + } + } + } + } + @6f0c960c-a8b7-4fa8-9168-cf0a5551be56 { + host kuma.blinker.club + } + handle @6f0c960c-a8b7-4fa8-9168-cf0a5551be56 { + handle { + reverse_proxy 10.89.0.100 { + transport http { + } + } + } + } + @b714662c-6abf-4b15-9b33-7c6387d18506 { + host pass.blinker.club + } + handle @b714662c-6abf-4b15-9b33-7c6387d18506 { + handle { + reverse_proxy 10.89.0.101:5004 { + transport http { + } + } + } + } + @b36e8ae9-b645-4e9f-b927-ee2bb7dfe40e { + host pdf.blinker.club + } + handle @b36e8ae9-b645-4e9f-b927-ee2bb7dfe40e { + handle { + reverse_proxy /outpost.goauthentik.io/* http://10.89.0.101:4501 { + } + forward_auth http://10.89.0.101:4501 { + uri /outpost.goauthentik.io/auth/caddy + copy_headers X-Authentik-Username + copy_headers X-Authentik-Groups + copy_headers X-Authentik-Email + copy_headers X-Authentik-Name + copy_headers X-Authentik-Uid + copy_headers X-Authentik-Jwt + copy_headers X-Authentik-Meta-Jwks + copy_headers X-Authentik-Meta-Outpost + copy_headers X-Authentik-Meta-Provider + copy_headers X-Authentik-Meta-App + copy_headers X-Authentik-Meta-Version + } + reverse_proxy 10.89.0.108:7075 { + } + } + } + @91587ab9-67e9-4678-9cb8-e8dc8ed89efd { + host tables.blinker.club + } + handle @91587ab9-67e9-4678-9cb8-e8dc8ed89efd { + handle { + reverse_proxy 10.89.0.101:5005 { + transport http { + } + } + } + } + @adea5e03-ec48-4fe5-ad9b-80e35c7de2f9 { + host linkwarden.blinker.club + } + handle @adea5e03-ec48-4fe5-ad9b-80e35c7de2f9 { + handle { + reverse_proxy 10.89.0.101:5010 { + } + } + } + @d7ffda69-ace3-4dcd-b766-ec3655de2e63 { + host auth.blinker.club + } + handle @d7ffda69-ace3-4dcd-b766-ec3655de2e63 { + handle { + reverse_proxy 10.89.0.101:4501 { + } + } + } + @3e2f0689-8e96-426b-bfc1-d50adbca5290 { + host readeck.blinker.club + } + handle @3e2f0689-8e96-426b-bfc1-d50adbca5290 { + handle { + reverse_proxy 10.89.0.103:5001 { + } + } + } + @db876ae0-c7d6-401f-bdda-85531d1d30d2 { + host watch.blinker.club + } + handle @db876ae0-c7d6-401f-bdda-85531d1d30d2 { + handle { + reverse_proxy 10.89.0.106:5001 { + } + } + } + @23bc0bb3-7e8b-4b05-b7f2-8e139c38b23d { + host mediarequest.blinker.club + } + handle @23bc0bb3-7e8b-4b05-b7f2-8e139c38b23d { + handle { + reverse_proxy 10.89.0.106:5002 { + } + } + } + @27847df4-83a6-4695-a87b-2a51e187225a { + host wizarr.blinker.club + } + handle @27847df4-83a6-4695-a87b-2a51e187225a { + handle { + reverse_proxy 10.89.0.106:5003 { + } + } + } + @4387e47a-3cd5-4209-a351-afb5d683c688 { + host memos.blinker.club + } + handle @4387e47a-3cd5-4209-a351-afb5d683c688 { + handle { + reverse_proxy 10.89.0.108:7071 { + } + } + } + @80736838-c5db-4c49-a7eb-439ef8a4835e { + host vaultwarden.blinker.club + } + handle @80736838-c5db-4c49-a7eb-439ef8a4835e { + handle { + reverse_proxy 10.89.0.108:7072 { + } + } + } + @075fb390-8759-48df-a196-c2b41794bba3 { + host fileshare.blinker.club + } + handle @075fb390-8759-48df-a196-c2b41794bba3 { + handle { + reverse_proxy 10.89.0.108:7073 { + } + } + } +} +# Reverse Proxy Domain: "7293a3a7-ca31-4d7e-be90-780cb4240e6b" +blinker.club { + log 7293a3a7-ca31-4d7e-be90-780cb4240e6b + tls { + issuer acme { + dns cloudflare 0zKl_ezOn2j4HHjS6ew3k3KTqH4rLIYHiV-TDYxe + } + } + + @a9fe8c37-91be-4c0d-a363-ee49dd020790 { + host blinker.club + } + handle @a9fe8c37-91be-4c0d-a363-ee49dd020790 { + handle { + reverse_proxy 10.89.0.101:7575 { + transport http { + } + } + } + } +} +# Reverse Proxy Domain: "5efd6136-26c6-4af7-9404-75201f69b046" +*.thegrind.dev { + log 5efd6136-26c6-4af7-9404-75201f69b046 + tls { + issuer acme { + dns cloudflare 0zKl_ezOn2j4HHjS6ew3k3KTqH4rLIYHiV-TDYxe + } + } + + @42e9f10e-4e8f-428b-8609-15a4ae8eed2e { + host gist.thegrind.dev + } + handle @42e9f10e-4e8f-428b-8609-15a4ae8eed2e { + handle { + reverse_proxy 10.89.0.101:5006 { + } + } + } + @470fb753-2bbc-4560-b448-a8dbb6d9a8b2 { + host tools.thegrind.dev + } + handle @470fb753-2bbc-4560-b448-a8dbb6d9a8b2 { + handle { + reverse_proxy 10.89.0.101:8989 { + } + } + } + @c549d42a-99c8-4995-912d-4c45814da111 { + host wiki.thegrind.dev + } + handle @c549d42a-99c8-4995-912d-4c45814da111 { + handle { + reverse_proxy 10.89.0.101:5002 { + } + } + } + @9d44d816-4e06-4592-a595-3060d3e128b5 { + host auth.thegrind.dev + } + handle @9d44d816-4e06-4592-a595-3060d3e128b5 { + handle { + reverse_proxy 10.89.0.101:4501 { + } + } + } + @c73d8643-fb52-43a0-ad06-ea800f6e90f8 { + host blog.thegrind.dev + } + handle @c73d8643-fb52-43a0-ad06-ea800f6e90f8 { + handle { + reverse_proxy 10.89.0.101:5007 { + transport http { + } + } + } + } + @69e141fe-1031-4dfd-a9dd-e7013f518f65 { + host tunnel.thegrind.dev + } + handle @69e141fe-1031-4dfd-a9dd-e7013f518f65 { + } + @70467ce5-1d6a-45fc-a81b-42b7aa40f7ae { + host plane.thegrind.dev + } + handle @70467ce5-1d6a-45fc-a81b-42b7aa40f7ae { + handle { + reverse_proxy 10.89.0.104:80 { + } + } + } + @183b97ca-18ac-4478-89aa-d7e79f82969a { + host tasks.thegrind.dev + } + handle @183b97ca-18ac-4478-89aa-d7e79f82969a { + handle { + reverse_proxy 10.89.0.108:7070 { + } + } + } + @c58cfb1f-66ef-4f74-87f5-58186668dcd6 { + host tianji.thegrind.dev + } + handle @c58cfb1f-66ef-4f74-87f5-58186668dcd6 { + handle { + reverse_proxy 10.89.0.108:7074 { + } + } + } +} +# Reverse Proxy Domain: "ec02b95f-dda1-44dd-966d-1636595ab192" +*.usefor.dev { + tls { + issuer acme { + dns cloudflare 0zKl_ezOn2j4HHjS6ew3k3KTqH4rLIYHiV-TDYxe + } + } + + handle { + reverse_proxy 10.89.0.101:5008 { + transport http { + } + } + } +} + +import /usr/local/etc/caddy/caddy.d/*.conf + diff --git a/docker/kan/docker-compose.yml b/docker/kan/docker-compose.yml deleted file mode 100644 index 117b566..0000000 --- a/docker/kan/docker-compose.yml +++ /dev/null @@ -1,12 +0,0 @@ -services: - web: - image: ghcr.io/kanbn/kan:latest - container_name: kan-web - ports: - - "7070:3000" - environment: - NEXT_PUBLIC_BASE_URL: http://localhost:3000 - BETTER_AUTH_SECRET: your_auth_secret - POSTGRES_URL: postgresql://kan:password@10.89.0.102:5432/kan - NEXT_PUBLIC_ALLOW_CREDENTIALS: true - restart: unless-stopped \ No newline at end of file diff --git a/lookup_plugins/__pycache__/hostip.cpython-312.pyc b/lookup_plugins/__pycache__/hostip.cpython-312.pyc new file mode 100644 index 0000000..0826cf3 Binary files /dev/null and b/lookup_plugins/__pycache__/hostip.cpython-312.pyc differ diff --git a/lookup_plugins/hostip.py b/lookup_plugins/hostip.py new file mode 100644 index 0000000..753fa9a --- /dev/null +++ b/lookup_plugins/hostip.py @@ -0,0 +1,23 @@ +from ansible.plugins.lookup import LookupBase +from ansible.errors import AnsibleError + +class LookupModule(LookupBase): + def run(self, terms, variables=None, **kwargs): + if not variables or 'hostvars' not in variables: + raise AnsibleError("hostvars is not available in this context") + + results = [] + + for term in terms: + if term not in variables['hostvars']: + raise AnsibleError(f"Host '{term}' not found in hostvars") + + host = variables['hostvars'][term] + ip = host.get('ansible_host') or host.get('ansible_default_ipv4', {}).get('address') + + if not ip: + raise AnsibleError(f"No IP found for host '{term}'") + + results.append(ip) + + return results diff --git a/playbooks/nodes/apps.yml b/playbooks/nodes/apps.yml index 8620dbe..ed9ab3c 100644 --- a/playbooks/nodes/apps.yml +++ b/playbooks/nodes/apps.yml @@ -28,4 +28,12 @@ - role: apps/dumbware-drop vars: port: 7077 - pin: "8989" \ No newline at end of file + pin: "8989" + - role: apps/filebrowser + vars: + port: 7078 + directory: "fb-file-sharing" + container_name: "filebrowser-sharing" + - role: apps/reubah + vars: + port: 7079 \ No newline at end of file diff --git a/playbooks/proxy/external.yml b/playbooks/proxy/external.yml index 496f4b9..134dbad 100644 --- a/playbooks/proxy/external.yml +++ b/playbooks/proxy/external.yml @@ -1,5 +1,5 @@ --- -- name: Set up the reverse proxy for internal only services +- name: Set up the reverse proxy for external only services hosts: caddy_external become: true roles: @@ -8,26 +8,82 @@ vars: domains: - name: "thatshit.live" + dynamic_dns: true sites: - - name: "whale" - host: 10.89.0.101 - port: 9443 - https: true - transport_opts: - - tls_insecure_skip_verify + - name: "draw" + host: "{{ lookup('hostip', 'portainer_main') }}" + port: 5001 + - name: "paste" + host: "{{ lookup('hostip', 'portainer_main') }}" + port: 5009 + - name: "do" + host: "{{ lookup('hostip', 'apps') }}" + port: 7076 + - name: "upload" + host: "{{ lookup('hostip', 'apps') }}" + port: 7077 + - name: "drop" + host: "{{ lookup('hostip', 'apps') }}" + port: 7077 + - name: "share" + host: "{{ lookup('hostip', 'apps') }}" + port: 7078 + - name: "convert" + host: "{{ lookup('hostip', 'apps') }}" + port: 7079 - name: "blinker.club" + host: "{{ lookup('hostip', 'portainer_main') }}" + port: 7575 + dynamic_dns: true sites: - - name: "whale" - host: 10.89.0.101 - port: 9443 - https: true - transport_opts: - - tls_insecure_skip_verify + - name: "pass" + host: "{{ lookup('hostip', 'portainer_main') }}" + port: 5004 + - name: "tables" + host: "{{ lookup('hostip', 'portainer_main') }}" + port: 5005 + - name: "watch" + host: "{{ lookup('hostip', 'streaming_services') }}" + port: 5001 + - name: "memos" + host: "{{ lookup('hostip', 'apps') }}" + port: 7071 + - name: "auth" + host: "{{ lookup('hostip', 'portainer_main') }}" + port: 4501 + - name: "pdf" + host: "{{ lookup('hostip', 'apps') }}" + port: 7075 + - name: "linkwarden" + host: "{{ lookup('hostip', 'portainer_main') }}" + port: 5010 + - name: "vaultwarden" + host: "{{ lookup('hostip', 'apps') }}" + port: 7072 + - name: "mediarequest" + host: "{{ lookup('hostip', 'streaming_services') }}" + port: 5002 + - name: "fileshare" + host: "{{ lookup('hostip', 'apps') }}" + port: 7073 - name: "thegrind.dev" + dynamic_dns: true sites: - - name: "whale" - host: 10.89.0.101 - port: 9443 - https: true - transport_opts: - - tls_insecure_skip_verify \ No newline at end of file + - name: "blog" + host: "{{ lookup('hostip', 'portainer_main') }}" + port: 5007 + - name: "tools" + host: "{{ lookup('hostip', 'portainer_main') }}" + port: 8989 + - name: "auth" + host: "{{ lookup('hostip', 'portainer_main') }}" + port: 4501 + - name: "gist" + host: "{{ lookup('hostip', 'portainer_main') }}" + port: 5006 + - name: "tianji" + host: "{{ lookup('hostip', 'apps') }}" + port: 7074 + - name: "tasks" + host: "{{ lookup('hostip', 'apps') }}" + port: 7070 \ No newline at end of file diff --git a/roles/apps/filebrowser/defaults/main.yml b/roles/apps/filebrowser/defaults/main.yml new file mode 100644 index 0000000..e69de29 diff --git a/roles/apps/filebrowser/tasks/main.yml b/roles/apps/filebrowser/tasks/main.yml new file mode 100644 index 0000000..095c86d --- /dev/null +++ b/roles/apps/filebrowser/tasks/main.yml @@ -0,0 +1,37 @@ +- name: Mount the container data folder + ansible.builtin.include_role: + role: docker/container-data + vars: + dir_name: "{{ directory }}" + +- name: Create files folder + ansible.builtin.file: + dest: "/home/docker/container-data/{{ directory }}/files" + state: directory + mode: '0777' + +- name: Create config folder + ansible.builtin.file: + dest: "/home/docker/container-data/{{ directory }}/config" + state: directory + mode: '0777' + +- name: Create config folder + ansible.builtin.file: + dest: "/home/docker/container-data/{{ directory }}/database" + state: directory + mode: '0777' + +- name: Deploy filebrowser container + community.docker.docker_container: + name: "{{ container_name }}" + pull: true + state: started + restart_policy: unless-stopped + image: filebrowser/filebrowser + ports: + - '{{ port }}:80' + volumes: + - '/home/docker/container-data/{{ directory }}/config:/config' + - '/home/docker/container-data/{{ directory }}/database:/database' + - '/home/docker/container-data/{{ directory }}/files:/srv' \ No newline at end of file diff --git a/roles/apps/reubah/defaults/main.yml b/roles/apps/reubah/defaults/main.yml new file mode 100644 index 0000000..e69de29 diff --git a/roles/apps/reubah/tasks/main.yml b/roles/apps/reubah/tasks/main.yml new file mode 100644 index 0000000..660f289 --- /dev/null +++ b/roles/apps/reubah/tasks/main.yml @@ -0,0 +1,19 @@ +- name: Make mount folder + ansible.builtin.file: + dest: "/home/docker/reubah" + mode: '0777' + state: directory + +- name: Put up reubah container + community.docker.docker_container: + name: reubah + image: 'ghcr.io/dendianugerah/reubah:latest' + pull: true + state: started + restart_policy: unless-stopped + env: + PORT=8081 + volumes: + - '/home/docker/reubah:/tmp' + ports: + - '{{ port }}:8081' diff --git a/roles/caddy/install/tasks/main.yml b/roles/caddy/install/tasks/main.yml index a6d0f5d..4f8c391 100644 --- a/roles/caddy/install/tasks/main.yml +++ b/roles/caddy/install/tasks/main.yml @@ -1,9 +1,17 @@ - name: Install dependencies apt: - name: apt-transport-https + name: + - apt-transport-https + - golang state: present update_cache: true +- name: Make config directory + ansible.builtin.file: + dest: "/etc/caddy" + state: directory + mode: '0777' + - name: Download and install XCaddy GPG key ansible.builtin.shell: cmd: > @@ -33,6 +41,7 @@ ansible.builtin.shell: | xcaddy build \ --with github.com/caddy-dns/cloudflare \ + --with github.com/mholt/caddy-dynamicdns \ --output /usr/local/bin/caddy args: creates: /usr/local/bin/caddy @@ -71,6 +80,20 @@ ansible.builtin.systemd: daemon_reload: true +- name: Set resolv.conf DNS + ansible.builtin.copy: + dest: /etc/resolv.conf + content: | + nameserver 1.1.1.1 + nameserver 8.8.8.8 + force: true + +- name: Restart systemd-resolved + systemd: + name: systemd-resolved + state: restarted + enabled: true + - name: Enable and start Caddy service ansible.builtin.systemd: name: caddy diff --git a/roles/caddy/proxy/tasks/main.yml b/roles/caddy/proxy/tasks/main.yml index 65dd788..b295f8e 100644 --- a/roles/caddy/proxy/tasks/main.yml +++ b/roles/caddy/proxy/tasks/main.yml @@ -6,6 +6,10 @@ group: root mode: '0644' +- name: Format config in the server + ansible.builtin.command: + caddy fmt --overwrite --config /etc/caddy/Caddyfile + - name: Reload Caddy ansible.builtin.systemd: name: caddy diff --git a/roles/caddy/proxy/templates/Caddyfile.j2 b/roles/caddy/proxy/templates/Caddyfile.j2 index 8fb31b5..da74eec 100644 --- a/roles/caddy/proxy/templates/Caddyfile.j2 +++ b/roles/caddy/proxy/templates/Caddyfile.j2 @@ -1,5 +1,32 @@ +{ + servers { + protocols h1 h2 + } + + dynamic_dns { + provider cloudflare {{ cloudflare_api_key }} + domains { + {% for domain in domains %} + {% set base_domain = domain.name.lstrip('*.') %} + {% if (domain.dynamic_dns | default(false)) %} + {{ base_domain }} * + {{ base_domain }} @ + {% for site in domain.sites %} + {{ base_domain }} {{ site.name }} + {% endfor %} + {% endif %} + {% endfor %} + } + } + + grace_period 10s +} + {% for domain in domains %} {% set base_domain = domain.name.lstrip('*.') %} +{% set domain_var_name = domain.name.replace('.', '') %} + +# Subdomain domain proxy for {{ base_domain }} *.{{ base_domain }} { tls { issuer acme { @@ -20,4 +47,32 @@ } {% endfor %} } + +# Base domain proxy for {{ base_domain }} +{% if domain.host is defined and domain.port is defined %} +{{base_domain}} { + tls { + issuer acme { + dns cloudflare {{ cloudflare_api_key }} + } + } + + @{{ domain_var_name }} { + host {{ base_domain}} + } + + handle @{{ domain_var_name }} { + handle { + reverse_proxy {{ domain.host }}:{{ domain.port }} { + transport http { + {% for opt in (domain.transport_opts | default([])) %} + {{ opt }} + {% endfor %} + } + } + } + } + +} +{% endif %} {% endfor %} \ No newline at end of file