Compare commits

...

5 commits

Author SHA1 Message Date
Simon Bärlocher
c11f019aae
fix(send): assert S3 credentials when storage backend is s3
When send_storage_backend=s3 the role previously deployed the container
with whatever was in send_s3_* (often empty strings from the defaults).
The container would then start, accept uploads, and fail to persist
anything silently. Same pattern as the validate blocks in coturn,
talk, bookstack and opnform: fail fast at task time with a clear error
that points at the four missing variables.

Skipped entirely when send_storage_backend=local (the default).
2026-05-26 15:40:21 +02:00
Simon Bärlocher
a492c3ee04
docs(send): add meta/argument_specs.yml
29 typed options with full defaults coverage (no required: true marks —
the role works with an empty S3 config when storage_backend=local).
Documents the send_domains list convention, the local-vs-s3 storage
choice, the timing/size limits and the Traefik / network wiring.

Loads through ansible-core's ArgumentSpecValidator. Matches the spec
convention used by the other roles in this collection.
2026-05-26 15:38:35 +02:00
Simon Bärlocher
b19ac2270a
fix(send): use Traefik v3 OR-syntax for multi-domain Host rule
The router rule joined send_domains with ', ' which is the v2 syntax
('Host(`a`, `b`)'). Traefik v3 expects each Host() to be its own
matcher joined with the explicit '||' OR operator. With v3 the comma
form is silently ignored — only the first host actually matches.

Match the pattern already used in the authentik, drawio and nextcloud
roles in this collection.
2026-05-26 15:38:34 +02:00
Simon Bärlocher
e1d604effc
fix(send): self-review fixes (FQCN, min_ansible_version str)
* tasks/main.yml: prefix all builtin modules with ansible.builtin
  (file, template) — silences ansible-lint fqcn[action-core] and
  matches the convention used by the other roles in this collection.

* meta/main.yml: change min_ansible_version from the float 2.14 to
  the string '2.14'. ansible-galaxy's schema requires a string here
  (ansible-lint schema[meta] complains otherwise — same fix I just
  applied to the opnform role).
2026-05-26 15:38:34 +02:00
Simon Bärlocher
4655c8f037
feat(send): add role for self-hosted Send file-share service
Deploys timvisee/send with a Redis backend behind Traefik. Supports
local-disk or S3 storage (e.g. via the garage role). Uses the shared
`*_domains` list convention so the router can accept internal *.int.*
names alongside the canonical BASE_URL host.
2026-05-26 15:38:34 +02:00
8 changed files with 372 additions and 0 deletions

60
roles/send/README.md Normal file
View file

@ -0,0 +1,60 @@
Send
====
Deploys a self-hosted [Send](https://github.com/timvisee/send) instance
(timvisee fork of the discontinued Mozilla Send) with a Redis backend
behind Traefik, using Docker Compose.
Requirements
------------
- Docker + `docker compose` plugin on the target host
- Traefik (role `digitalboard.core.traefik`) reachable via an external
Docker network named `proxy` (default)
- DNS for each entry in `send_domains` pointing at the reverse proxy
- Optional: a Garage S3 bucket if `send_storage_backend: s3`
Role Variables
--------------
Important defaults (see `defaults/main.yml` for the full list):
| Variable | Default | Description |
|---|---|---|
| `send_domains` | `["send.local.test"]` | FQDNs the router accepts; first entry is the canonical BASE_URL |
| `send_image` | `registry.gitlab.com/timvisee/send:latest` | Send container image |
| `send_max_file_size` | `1073741824` | Max upload size in bytes (1 GiB) |
| `send_max_expire_seconds` | `604800` | Max share lifetime (7 d) |
| `send_storage_backend` | `local` | `local` (volume) or `s3` |
| `send_s3_*` | `""` | S3 endpoint/bucket/key/secret (when backend is `s3`) |
| `send_use_ssl` | `true` | Issue Traefik labels for the `websecure` entrypoint |
Dependencies
------------
None.
Example Playbook
----------------
```yaml
- hosts: send_servers
become: true
roles:
- digitalboard.core.send
```
With S3 (Garage) backend:
```yaml
send_storage_backend: s3
send_s3_endpoint: "http://{{ hostvars['backend']['garage_s3_domain'] }}"
send_s3_bucket: "send"
send_s3_access_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['key_id'] }}"
send_s3_secret_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['secret_key'] }}"
```
License
-------
MIT

View file

@ -0,0 +1,53 @@
#SPDX-License-Identifier: MIT-0
---
# defaults file for send
# Base directory configuration (inherited from base role or defined here)
docker_compose_base_dir: /etc/docker/compose
docker_volume_base_dir: /srv/data
# Send-specific configuration
send_service_name: send
send_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ send_service_name }}"
send_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ send_service_name }}"
# Service configuration
# FQDNs the send router accepts. The first entry is the canonical
# domain (used as BASE_URL); further entries cover internal *.int.*
# names so backend uploads can hit us without hairpinning via DMZ.
send_domains:
- "send.local.test"
send_image: "registry.gitlab.com/timvisee/send:latest"
send_port: 1443
send_extra_hosts: []
# Redis backend
send_redis_image: "redis:7-alpine"
send_redis_service_name: "send-redis"
# Send application configuration
# https://github.com/timvisee/send/blob/master/server/config.js
send_max_file_size: 1073741824 # 1 GiB in bytes
send_default_downloads: 1
send_max_downloads: 100
send_default_expire_seconds: 86400 # 24h
send_max_expire_seconds: 604800 # 7d
send_max_files_per_archive: 64
send_download_counts: "1,2,3,4,5,20,50,100"
send_expire_times_seconds: "300,3600,86400,604800"
# Storage backend: "local" (volume) or "s3"
send_storage_backend: "local"
# S3 backend (only used when send_storage_backend == "s3")
send_s3_endpoint: ""
send_s3_bucket: ""
send_s3_region: "us-east-1"
send_s3_access_key: ""
send_s3_secret_key: ""
send_s3_use_path_style: true
# Traefik configuration
send_traefik_network: "proxy"
send_internal_network: "send_internal"
send_use_ssl: true

View file

@ -0,0 +1,9 @@
#SPDX-License-Identifier: MIT-0
---
# handlers file for send
- name: restart send
community.docker.docker_compose_v2:
project_src: "{{ send_docker_compose_dir }}"
state: present
recreate: always

View file

@ -0,0 +1,122 @@
---
argument_specs:
main:
short_description: Deploy timvisee/send (file-sharing) with a Redis backend via Docker Compose.
description:
- Renders a Compose stack with the C(timvisee/send) container and a
Redis companion behind Traefik. Storage can be local-disk or any
S3-compatible backend (e.g. the C(garage) role).
- Uses the shared C(*_domains) list convention so the router can
accept internal C(*.int.*) hostnames alongside the canonical
BASE_URL host.
options:
docker_compose_base_dir:
type: path
default: /etc/docker/compose
docker_volume_base_dir:
type: path
default: /srv/data
send_service_name:
type: str
default: send
send_docker_compose_dir:
type: path
send_docker_volume_dir:
type: path
send_domains:
type: list
elements: str
default: ['send.local.test']
description:
- FQDNs the router accepts. First entry is the canonical hostname
and is used as C(BASE_URL). Further entries cover internal
C(*.int.*) names so backend uploads can hit Send without
hairpinning via the DMZ.
send_image:
type: str
default: "registry.gitlab.com/timvisee/send:latest"
send_port:
type: int
default: 1443
send_extra_hosts:
type: list
elements: str
default: []
description: C(extra_hosts) entries injected into the send container (Docker C(host:ip) syntax).
send_redis_image:
type: str
default: "redis:7-alpine"
send_redis_service_name:
type: str
default: send-redis
send_max_file_size:
type: int
default: 1073741824
description: Max upload size in bytes. Default is 1 GiB.
send_default_downloads:
type: int
default: 1
send_max_downloads:
type: int
default: 100
send_default_expire_seconds:
type: int
default: 86400
description: Default share lifetime in seconds (24 h).
send_max_expire_seconds:
type: int
default: 604800
description: Maximum share lifetime in seconds (7 d).
send_max_files_per_archive:
type: int
default: 64
send_download_counts:
type: str
default: "1,2,3,4,5,20,50,100"
description: Comma-separated list of download-count options shown in the UI.
send_expire_times_seconds:
type: str
default: "300,3600,86400,604800"
description: Comma-separated list of expire-time options (seconds) shown in the UI.
send_storage_backend:
type: str
choices: [local, s3]
default: local
description:
- C(local) keeps uploads in a host volume. C(s3) uses an
S3-compatible backend (any of the C(send_s3_*) variables is
required when this is set).
send_s3_endpoint:
type: str
default: ''
send_s3_bucket:
type: str
default: ''
send_s3_region:
type: str
default: us-east-1
send_s3_access_key:
type: str
default: ''
send_s3_secret_key:
type: str
default: ''
send_s3_use_path_style:
type: bool
default: true
description: Required for most non-AWS S3-compatible backends (Garage, MinIO).
send_traefik_network:
type: str
default: proxy
send_internal_network:
type: str
default: send_internal
send_use_ssl:
type: bool
default: true

14
roles/send/meta/main.yml Normal file
View file

@ -0,0 +1,14 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: digitalboard
description: Deploy a self-hosted Send (timvisee fork) instance with Redis via Docker Compose
license: MIT
min_ansible_version: "2.14"
galaxy_tags:
- send
- filesharing
- docker
dependencies: []

42
roles/send/tasks/main.yml Normal file
View file

@ -0,0 +1,42 @@
#SPDX-License-Identifier: MIT-0
---
# tasks file for send
- name: Assert S3 backend configuration when enabled
ansible.builtin.assert:
that:
- send_s3_endpoint | length > 0
- send_s3_bucket | length > 0
- send_s3_access_key | length > 0
- send_s3_secret_key | length > 0
fail_msg: >-
send_storage_backend is 's3' but one or more of send_s3_endpoint,
send_s3_bucket, send_s3_access_key, send_s3_secret_key is unset.
Provide via OpenBao, Ansible Vault or extra-vars — or switch
send_storage_backend to 'local'.
when: send_storage_backend == "s3"
- name: Create docker compose directory
ansible.builtin.file:
path: "{{ send_docker_compose_dir }}"
state: directory
mode: '0755'
- name: Create local upload directory
ansible.builtin.file:
path: "{{ send_docker_volume_dir }}/uploads"
state: directory
mode: '0755'
when: send_storage_backend == "local"
- name: Create docker-compose file for send
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ send_docker_compose_dir }}/docker-compose.yml"
mode: '0644'
notify: restart send
- name: Start send container
community.docker.docker_compose_v2:
project_src: "{{ send_docker_compose_dir }}"
state: present

View file

@ -0,0 +1,69 @@
services:
{{ send_service_name }}:
image: {{ send_image }}
container_name: {{ send_service_name }}
restart: unless-stopped
depends_on:
- {{ send_redis_service_name }}
networks:
- {{ send_traefik_network }}
- {{ send_internal_network }}
{% if send_extra_hosts is defined and send_extra_hosts | length > 0 %}
extra_hosts:
{% for host in send_extra_hosts %}
- "{{ host }}"
{% endfor %}
{% endif %}
environment:
{% if send_use_ssl %}
BASE_URL: "https://{{ send_domains[0] }}"
{% else %}
BASE_URL: "http://{{ send_domains[0] }}"
{% endif %}
REDIS_HOST: "{{ send_redis_service_name }}"
REDIS_PORT: "6379"
MAX_FILE_SIZE: "{{ send_max_file_size }}"
DEFAULT_DOWNLOADS: "{{ send_default_downloads }}"
MAX_DOWNLOADS: "{{ send_max_downloads }}"
DEFAULT_EXPIRE_SECONDS: "{{ send_default_expire_seconds }}"
MAX_EXPIRE_SECONDS: "{{ send_max_expire_seconds }}"
MAX_FILES_PER_ARCHIVE: "{{ send_max_files_per_archive }}"
DOWNLOAD_COUNTS: "{{ send_download_counts }}"
EXPIRE_TIMES_SECONDS: "{{ send_expire_times_seconds }}"
{% if send_storage_backend == "s3" %}
S3_BUCKET: "{{ send_s3_bucket }}"
S3_ENDPOINT: "{{ send_s3_endpoint }}"
S3_USE_PATH_STYLE_ENDPOINT: "{{ 'true' if send_s3_use_path_style else 'false' }}"
AWS_ACCESSKEYID: "{{ send_s3_access_key }}"
AWS_SECRETACCESSKEY: "{{ send_s3_secret_key }}"
AWS_REGION: "{{ send_s3_region }}"
{% else %}
FILE_DIR: "/uploads"
volumes:
- {{ send_docker_volume_dir }}/uploads:/uploads
{% endif %}
labels:
- traefik.enable=true
- traefik.docker.network={{ send_traefik_network }}
- traefik.http.routers.{{ send_service_name }}.rule={% for d in send_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
- traefik.http.services.{{ send_service_name }}.loadbalancer.server.port={{ send_port }}
{% if send_use_ssl %}
- traefik.http.routers.{{ send_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ send_service_name }}.tls=true
{% else %}
- traefik.http.routers.{{ send_service_name }}.entrypoints=web
{% endif %}
{{ send_redis_service_name }}:
image: {{ send_redis_image }}
container_name: {{ send_redis_service_name }}
restart: unless-stopped
networks:
- {{ send_internal_network }}
volumes:
- {{ send_docker_volume_dir }}/redis:/data
networks:
{{ send_internal_network }}:
{{ send_traefik_network }}:
external: true

3
roles/send/vars/main.yml Normal file
View file

@ -0,0 +1,3 @@
#SPDX-License-Identifier: MIT-0
---
# vars file for send