diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b1646f161..2a290f67f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -27,3 +27,24 @@ updates: update-types: - minor - patch + + # Docker images in the admin-manual compose example. Only literal image tags + # are updated here (owncloud/server uses ${OWNCLOUD_IMAGE} interpolation, which + # Dependabot cannot resolve, so it is intentionally left for manual bumps). + - package-ecosystem: docker + directories: + - "/modules/admin_manual/examples/installation/docker" + # The compose smoke-test harness. It inherits the example's image tags, but + # is listed so any image pinned here in future is tracked too. + - "/tests/docker-compose" + schedule: + interval: weekly + day: sunday + time: '22:00' + timezone: Etc/UTC + open-pull-requests-limit: 5 + groups: + minor-and-patch: + update-types: + - minor + - patch diff --git a/.github/workflows/compose-test.yml b/.github/workflows/compose-test.yml new file mode 100644 index 000000000..31a57980d --- /dev/null +++ b/.github/workflows/compose-test.yml @@ -0,0 +1,32 @@ +name: docker-compose example + +# Lints and smoke-tests the ownCloud + Collabora CODE docker-compose example +# that ships in the admin manual. Runs only when the example, the test harness, +# or this workflow change. +on: + push: + branches: + - master + paths: + - 'modules/admin_manual/examples/installation/docker/**' + - 'tests/docker-compose/**' + - '.github/workflows/compose-test.yml' + pull_request: + branches: + - master + paths: + - 'modules/admin_manual/examples/installation/docker/**' + - 'tests/docker-compose/**' + - '.github/workflows/compose-test.yml' + +jobs: + smoke-test: + name: Lint and smoke-test the compose example + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # smoke-test.sh validates the merged compose config as its first action, + # then boots the stack and asserts on it. + - name: Boot the stack and run smoke tests + run: tests/docker-compose/smoke-test.sh diff --git a/modules/admin_manual/examples/installation/docker/docker-compose.yml b/modules/admin_manual/examples/installation/docker/docker-compose.yml index a43762511..a93cfc7cf 100644 --- a/modules/admin_manual/examples/installation/docker/docker-compose.yml +++ b/modules/admin_manual/examples/installation/docker/docker-compose.yml @@ -1,3 +1,11 @@ +# Fixes the Compose project name, so the generated resource names (containers, +# networks, volumes) do not depend on the directory the stack is launched from. +# The "frontend" network at the bottom therefore always becomes +# "owncloud_frontend", which is the name the traefik.docker.network label on the +# ownCloud service refers to. If you run several copies of this stack on one +# host, change this and update that label to match "_frontend". +name: owncloud + # This oc-common template is a compose extension for ownCloud. # It is used for starting the main ownCloud instance and reused e.g. when defining listeners for WND. # This eases compose maintenance a lot. @@ -14,7 +22,7 @@ x-owncloud: &oc-common - OWNCLOUD_DB_TYPE=mysql - OWNCLOUD_DB_NAME=owncloud - OWNCLOUD_DB_USERNAME=owncloud - - OWNCLOUD_DB_PASSWORD=owncloud + - OWNCLOUD_DB_PASSWORD=${MYSQL_PASSWORD} - OWNCLOUD_DB_HOST=mariadb - OWNCLOUD_ADMIN_USERNAME=${ADMIN_USERNAME} - OWNCLOUD_ADMIN_PASSWORD=${ADMIN_PASSWORD} @@ -26,7 +34,10 @@ x-owncloud: &oc-common source: oc_files target: /mnt/data networks: - - oc_network + # ownCloud needs both tiers: the proxy reaches it on "frontend", and it + # reaches MariaDB/Redis on "backend". + - frontend + - backend services: @@ -34,8 +45,24 @@ services: # Start the main ownCloud instance <<: *oc-common container_name: owncloud_server - ports: - - ${HTTP_PORT}:8080 + # No ports are published directly. The Traefik reverse proxy (see the "proxy" + # service below) terminates TLS and forwards HTTPS traffic to port 8080. + labels: + - traefik.enable=true + - traefik.http.routers.owncloud.rule=Host(`${OWNCLOUD_DOMAIN}`) + - traefik.http.routers.owncloud.entrypoints=websecure + - traefik.http.routers.owncloud.tls.certresolver=letsencrypt + - traefik.http.services.owncloud.loadbalancer.server.port=8080 + # ownCloud is attached to two networks (frontend + backend). Tell Traefik + # which one to use to reach it, otherwise it picks one at random and may + # choose "backend", which the proxy cannot reach — breaking all routing. + - traefik.docker.network=owncloud_frontend + # Enable HSTS on ownCloud only. Do NOT add frame-ancestors/X-Frame-Options + # here or on Collabora: ownCloud embeds the Collabora editor in an iframe and + # a restrictive framing policy would break document editing. + - traefik.http.routers.owncloud.middlewares=owncloud-hsts + - traefik.http.middlewares.owncloud-hsts.headers.stsSeconds=15552000 + - traefik.http.middlewares.owncloud-hsts.headers.stsIncludeSubdomains=true # owncloud-wnd-1: # # Enterprise only: uncomment and configure it accordingly if WND is used ! @@ -45,35 +72,125 @@ services: # container_name: owncloud-wnd-1 # command: ["/usr/bin/owncloud", "occ", "wnd:listen", "myhost", "myshare", "workgroup\myuser", "password"] + collabora: + # Collabora Online Development Edition (CODE): the self-hosted office server + # that ownCloud connects to as a WOPI client. See the "Enabling Collabora + # Online (CODE)" section of the documentation for the required occ wiring. + # Image tag pinned here (not in .env) so Dependabot keeps it current — see + # .github/dependabot.yml. Do not re-parameterize it: Dependabot cannot read + # ${VAR} interpolation. + image: collabora/code:24.04.13.2.1 + container_name: owncloud_collabora + restart: always + environment: + # "domain" is a regex of the ownCloud host(s) CODE accepts as WOPI host. + # Dots must be escaped for the regex (e.g. owncloud\.example\.com). + - domain=${OWNCLOUD_DOMAIN_REGEX} + - aliasgroup1=https://${OWNCLOUD_DOMAIN}:443 + - username=${COLLABORA_ADMIN_USERNAME} + - password=${COLLABORA_ADMIN_PASSWORD} + # TLS is terminated at the reverse proxy, so CODE itself speaks plain HTTP. + - extra_params=--o:ssl.enable=false --o:ssl.termination=true + cap_add: + - MKNOD + labels: + - traefik.enable=true + # Public WOPI router: serves the document editor to ownCloud users. + - traefik.http.routers.collabora.rule=Host(`${COLLABORA_DOMAIN}`) + - traefik.http.routers.collabora.entrypoints=websecure + - traefik.http.routers.collabora.tls.certresolver=letsencrypt + - traefik.http.services.collabora.loadbalancer.server.port=9980 + # Pinned low, so the admin router below always wins regardless of how long + # COLLABORA_DOMAIN is (see the note there). + - traefik.http.routers.collabora.priority=100 + # Admin console router (higher priority): the CODE admin console and its + # WebSocket must NOT be reachable from the internet. Restrict them to + # trusted source ranges. WOPI clients never need these paths. + - traefik.http.routers.collabora-admin.rule=Host(`${COLLABORA_DOMAIN}`) && (PathPrefix(`/browser/dist/admincontrol`) || PathPrefix(`/cool/adminws`)) + - traefik.http.routers.collabora-admin.entrypoints=websecure + - traefik.http.routers.collabora-admin.tls.certresolver=letsencrypt + # Priority must beat the "collabora" router above. Traefik auto-computes an + # unpinned router's priority from its rule-string length, so a long enough + # COLLABORA_DOMAIN would out-rank a small fixed number here and steal these + # paths — bypassing the allowlist. Pinning both routers removes the + # dependency on the domain length entirely. + - traefik.http.routers.collabora-admin.priority=200 + - traefik.http.routers.collabora-admin.service=collabora + - traefik.http.routers.collabora-admin.middlewares=collabora-admin-allowlist + - traefik.http.middlewares.collabora-admin-allowlist.ipallowlist.sourcerange=127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 + networks: + - frontend + + proxy: + # Traefik terminates TLS (Let's Encrypt) and routes the two hostnames to the + # ownCloud and Collabora containers. Routing/TLS is driven by the labels on + # each service above; WebSocket upgrades (needed by CODE) are handled + # transparently by Traefik. + # Pinned tag maintained by Dependabot (see .github/dependabot.yml). + # Must be >= v3.6: the Docker provider in earlier releases probes the daemon + # with API version 1.24 before negotiating, which Docker Engine >= 29 rejects + # ("client version 1.24 is too old"), leaving Traefik with no routing config. + image: traefik:v3.7 + container_name: owncloud_proxy + restart: always + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + # Redirect all plain HTTP traffic to HTTPS. + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL} + - --certificatesresolvers.letsencrypt.acme.storage=/acme/acme.json + - --certificatesresolvers.letsencrypt.acme.tlschallenge=true + ports: + - "80:80" + - "443:443" + volumes: + # NOTE: mounting the Docker socket gives Traefik full control of the Docker + # daemon, which is equivalent to root on the host. The ":ro" flag only makes + # the socket file read-only; it does NOT restrict the Docker API. For a + # hardened setup, put a scoped docker-socket-proxy in front of it instead. + - /var/run/docker.sock:/var/run/docker.sock:ro + - acme:/acme + networks: + # The proxy only needs the frontend tier; it must not reach MariaDB/Redis. + - frontend + mariadb: - image: mariadb:${MARIADB_IMAGE} + # Pinned tag maintained by Dependabot (see .github/dependabot.yml). + image: mariadb:10.11 container_name: owncloud_mariadb restart: always - ports: - - "3306:3306" + # No host ports are published. MariaDB is reached only by ownCloud over the + # internal "backend" network. Publishing 3306 to the host would expose the + # database to the internet on the public host this stack is designed for. environment: - - MYSQL_ROOT_PASSWORD=owncloud + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} - MYSQL_USER=owncloud - - MYSQL_PASSWORD=owncloud + - MYSQL_PASSWORD=${MYSQL_PASSWORD} - MYSQL_DATABASE=owncloud - MARIADB_AUTO_UPGRADE=1 command: ["--max-allowed-packet=128M", "--innodb-log-file-size=64M"] healthcheck: - test: ["CMD", "mysqladmin", "ping", "-u", "root", "--password=owncloud"] + test: ["CMD", "mysqladmin", "ping", "-u", "root", "--password=${MYSQL_ROOT_PASSWORD}"] interval: 10s timeout: 5s retries: 5 volumes: - mysql:/var/lib/mysql networks: - - oc_network + - backend redis: - image: redis:${REDIS_IMAGE} + # Pinned tag maintained by Dependabot (see .github/dependabot.yml). + image: redis:7 container_name: owncloud_redis restart: always - ports: - - "6379:6379" + # No host ports are published. Redis is reached only by ownCloud over the + # internal "backend" network. An unauthenticated Redis published to the host + # would be directly exploitable on a public server. command: ["--databases", "1"] healthcheck: test: ["CMD", "redis-cli", "ping"] @@ -83,7 +200,7 @@ services: volumes: - redis:/data networks: - - oc_network + - backend # As alternative to Redis, you can enable Memcached. # See the caching configuration documentation for more details. @@ -92,10 +209,8 @@ services: # image: memcached:latest # container_name: owncloud_memcached # restart: always -# ports: -# - "11211:11211" # networks: -# - oc_network +# - backend volumes: oc_files: @@ -110,6 +225,18 @@ volumes: # Use the above options when configuring bind mounts redis: driver: local + acme: + # Stores the Let's Encrypt certificates issued for the reverse proxy. + driver: local networks: - oc_network: + # "frontend" carries public traffic (proxy <-> ownCloud/Collabora). + # "backend" is internal only (ownCloud <-> MariaDB/Redis) so the internet-facing + # proxy and Collabora cannot reach the data tier directly. + # These are project-scoped: the top-level "name: owncloud" above fixes the + # prefix, so they materialize as "owncloud_frontend" / "owncloud_backend". + # Traefik's traefik.docker.network label must name the *real* network; given a + # short name it warns and then silently falls back to the container's first + # network, which may be "backend" — unreachable from the proxy. + frontend: + backend: diff --git a/modules/admin_manual/examples/installation/docker/dot.env b/modules/admin_manual/examples/installation/docker/dot.env index 3e0ed5b46..da41e9650 100644 --- a/modules/admin_manual/examples/installation/docker/dot.env +++ b/modules/admin_manual/examples/installation/docker/dot.env @@ -1,7 +1,27 @@ +# IMPORTANT: every credential below is a placeholder. Replace every CHANGE_ME_* +# value with a strong, unique secret before exposing this stack to the internet. OWNCLOUD_IMAGE={latest-server-download-version} -OWNCLOUD_DOMAIN=localhost -OWNCLOUD_TRUSTED_DOMAINS=localhost -OWNCLOUD_OVERWRITE_CLI_URL=http://localhost -HTTP_PORT={std-port-http} -MARIADB_IMAGE=10.11 -REDIS_IMAGE=7 +# The public hostnames must resolve (DNS) to the host running this stack, so the +# reverse proxy can obtain Let's Encrypt certificates for them. +OWNCLOUD_DOMAIN=owncloud.example.com +OWNCLOUD_TRUSTED_DOMAINS=owncloud.example.com +OWNCLOUD_OVERWRITE_CLI_URL=https://owncloud.example.com + +# Initial ownCloud web UI admin account (used for first setup only). +ADMIN_USERNAME=admin +ADMIN_PASSWORD=CHANGE_ME_ADMIN + +# Database credentials. Use distinct, strong values for the root and app users. +MYSQL_ROOT_PASSWORD=CHANGE_ME_DB_ROOT +MYSQL_PASSWORD=CHANGE_ME_DB_APP + +# Collabora Online Development Edition (CODE) +COLLABORA_DOMAIN=collabora.example.com +# Regex of the ownCloud host CODE accepts as WOPI host. Escape every dot with a +# backslash so it is not treated as a regex wildcard. +OWNCLOUD_DOMAIN_REGEX=owncloud\.example\.com +COLLABORA_ADMIN_USERNAME=admin +COLLABORA_ADMIN_PASSWORD=CHANGE_ME_COLLABORA + +# Reverse proxy (Traefik) and Let's Encrypt +LETSENCRYPT_EMAIL=admin@example.com diff --git a/modules/admin_manual/pages/installation/installing_with_docker.adoc b/modules/admin_manual/pages/installation/installing_with_docker.adoc index 4a4bcc118..ccb44642c 100644 --- a/modules/admin_manual/pages/installation/installing_with_docker.adoc +++ b/modules/admin_manual/pages/installation/installing_with_docker.adoc @@ -110,13 +110,46 @@ When running `docker ps` again, the entry for `oc-eval` should be gone. The example configuration: -* Exposes ports {std-port-http}, allowing for HTTP connections. +* Runs behind a https://traefik.io/traefik[Traefik] reverse proxy that terminates TLS and exposes ports `80` and `443`, redirecting HTTP to HTTPS. * Uses separate _MariaDB_ and _Redis_ containers. * Provides mounts plus a volume bind configuration example. +* Includes a https://www.collaboraonline.com/code/[Collabora Online Development Edition (CODE)] container so documents can be edited in the browser. See xref:#enabling-collabora-online-code[Enabling Collabora Online (CODE)] below for the ownCloud-side wiring. + +[NOTE] +==== +Because the Collabora editor is embedded into the ownCloud web UI in an iframe and communicates over a WebSocket, both ownCloud and Collabora *must* be served over HTTPS on their own hostnames. Browsers block the mixed HTTP/HTTPS content that a plain-HTTP setup would produce. For this reason the example is designed for a production-style deployment with a reverse proxy and real, publicly resolvable domains, rather than the `localhost` HTTP flow used in the xref:#quick-evaluation[Quick Evaluation] section. +==== === Compose Setup -The following instructions assume you install locally. For remote access, the value of xref:#env-file[OWNCLOUD_DOMAIN] and xref:#env-file[OWNCLOUD_TRUSTED_DOMAINS] must be updated to represent the hostname(s) and/or IP addresses that the server is reachable at. +The following instructions require two DNS names — one for ownCloud (`OWNCLOUD_DOMAIN`) and one for Collabora (`COLLABORA_DOMAIN`) — that both resolve to the host running this stack. Traefik uses them to obtain Let's Encrypt certificates automatically, so the host must be reachable from the internet on ports `80` and `443`. The value of xref:#env-file[OWNCLOUD_DOMAIN] and xref:#env-file[OWNCLOUD_TRUSTED_DOMAINS] must represent the hostname(s) that the server is reachable at. + +[NOTE] +.Internal-only, offline, or externally proxied deployments +==== +Let's Encrypt validates a domain over the public internet, so the example's default configuration needs publicly resolvable names. Adapt it as follows if that does not describe your setup: + +* *No public DNS / air-gapped:* replace the ACME configuration on the `proxy` service with your own certificate. Drop the three `--certificatesresolvers.letsencrypt.*` flags, mount your certificate and key into the container, point a https://doc.traefik.io/traefik/https/tls/[TLS store or dynamic configuration file] at them, and replace `traefik.http.routers..tls.certresolver=letsencrypt` with `traefik.http.routers..tls=true` on both the `owncloud` and `collabora` services. A self-signed or internal-CA certificate works, but every browser and desktop/mobile client must trust it — Collabora is loaded in an iframe, and clients reject untrusted certificates. +* *Plain IP address or a single host name:* not supported together with Collabora. The editor requires two distinct HTTPS host names, and a certificate cannot be issued for a bare IP address. Use hostnames from a domain you control, internal DNS plus an internal CA, or omit the `collabora` service. +* *Your own reverse proxy in front:* see the tip below on publishing a port instead of using the bundled proxy. +==== + +[TIP] +.Fronting the stack with an existing reverse proxy +==== +If you already terminate TLS elsewhere (for example an existing nginx, HAProxy, or a hardware load balancer), you do not need the bundled `proxy` service. Remove it, then publish ownCloud's port on the host and forward to it from your own proxy: + +[source,yml] +---- + owncloud: + <<: *oc-common + container_name: owncloud_server + ports: + - "8080:8080" +---- + +Bind to a specific interface (for example `127.0.0.1:8080:8080`) if the proxy runs on the same host, so the unencrypted port is not reachable from the network. Your proxy must forward the `X-Forwarded-*` headers and be listed in the xref:configuration/server/reverse_proxy_configuration.adoc[reverse proxy configuration], and Collabora still needs its own HTTPS host name routed to port `9980` of the `collabora` container. +==== . Create a new project directory at a location of choice: + @@ -134,6 +167,17 @@ cd owncloud-server-docker ---- include::example$installation/docker/docker-compose.yml[] ---- ++ +[IMPORTANT] +==== +This example is a *starting point*, not a hardened deployment. Before you expose it to the internet, review the following: + +* *Change every credential.* All passwords in the `.env` file are placeholders. Replace every `CHANGE_ME_*` value (database, ownCloud admin, Collabora admin) with a strong, unique secret. Remove the `ADMIN_*` variables after the first logon as described below. +* *Keep the data tier private.* MariaDB and Redis are intentionally *not* published to the host and live on an internal `backend` network. Do not add `ports:` entries for them — an exposed, weakly-authenticated database or an unauthenticated Redis on a public host is trivially exploitable. +* *Do not expose the Collabora admin console.* The example restricts the CODE admin console (`/browser/dist/admincontrol`, `/cool/adminws`) to private source ranges via a Traefik `ipallowlist` middleware. Adjust the ranges to match your network; never open these paths to the internet. +* *Docker socket exposure.* The Traefik proxy mounts the Docker socket so it can read container labels. This grants control of the Docker daemon — effectively root on the host — and the `:ro` flag does *not* restrict the Docker API. For a hardened setup, place a scoped https://github.com/Tecnativa/docker-socket-proxy[docker-socket-proxy] in front of it. +* *Pin image versions.* The MariaDB, Redis, Collabora and Traefik images are pinned to specific tags directly in the `docker-compose.yml` file (kept current automatically via Dependabot in the docs repository); the ownCloud version is set with `OWNCLOUD_IMAGE` in the `.env` file. Keep them pinned rather than tracking a floating `latest` tag, so upgrades are deliberate, reproducible, and auditable (see xref:#upgrading-owncloud[Upgrading ownCloud]). +==== . Create an `.env` configuration file in the same directory, which contains the required configuration settings. + @@ -175,8 +219,8 @@ See the config description for more details. | `my_host,my_ip,my_domain` a| xref:{config-sample}#override-cli-url[OWNCLOUD_OVERWRITE_CLI_URL] -| See the config description for more details. -| `\http://localhost` +| See the config description for more details. With the reverse proxy this must be the public HTTPS URL. +| `\https://owncloud.example.com` | `ADMIN_USERNAME` | The webUI admin username @@ -184,19 +228,36 @@ a| xref:{config-sample}#override-cli-url[OWNCLOUD_OVERWRITE_CLI_URL] | `ADMIN_PASSWORD` | The webUI admin user’s password -| `admin` +| `CHANGE_ME_ADMIN` + +| `MYSQL_ROOT_PASSWORD` +| The MariaDB root password +| `CHANGE_ME_DB_ROOT` -| `HTTP_PORT` -| The HTTP port to bind to -| `{std-port-http}` +| `MYSQL_PASSWORD` +| The MariaDB `owncloud` user password (also used by ownCloud to connect) +| `CHANGE_ME_DB_APP` -| `MARIADB_IMAGE` -| The mariadb version -| `10.11` +| `COLLABORA_DOMAIN` +| The public hostname Collabora Online (CODE) is served at +| `collabora.example.com` -| `REDIS_IMAGE` -| The redis version -| `7` +| `OWNCLOUD_DOMAIN_REGEX` +a| The ownCloud host that CODE accepts as WOPI host, as a regular expression. + +Escape every dot with a backslash. +| `owncloud\.example\.com` + +| `COLLABORA_ADMIN_USERNAME` +| The Collabora admin console username +| `admin` + +| `COLLABORA_ADMIN_PASSWORD` +| The Collabora admin console password +| `CHANGE_ME_COLLABORA` + +| `LETSENCRYPT_EMAIL` +| The email address used for Let's Encrypt certificate registration +| `admin@example.com` |=== + [NOTE] @@ -250,10 +311,20 @@ Check that all the containers have successfully started, by running `docker comp | owncloud_server | /usr/bin/entrypoint /usr/b ... | Up (healthy) -| 0.0.0.0:{std-port-http}->{std-port-http}/tcp +| 8080/tcp + +| owncloud_collabora +| /start-collabora-online.sh ... +| Up +| 9980/tcp + +| owncloud_proxy +| /entrypoint.sh --provider ... +| Up +| 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp |=== -Here you can see, that the database, ownCloud and Redis containers are running, and that ownCloud is accessible via port {std-port-http} on the host machine. +Here you can see that the database, ownCloud, Redis and Collabora containers are running, and that only the Traefik `proxy` container publishes ports (`80` and `443`) to the host. MariaDB and Redis expose their ports on the internal network only, never to the host. ownCloud and Collabora are reachable over HTTPS at their respective domains through the proxy. [TIP] ==== @@ -275,7 +346,7 @@ IMPORTANT: Any apps that came with the image will reappear when the container is === Logging In -To log in to the ownCloud UI, open `pass:a[http://localhost:{std-port-http}]` (or the URL defined) in your browser of choice, where you see the standard ownCloud login screen as in the image below. +To log in to the ownCloud UI, open `pass:a[https://owncloud.example.com]` (the `OWNCLOUD_DOMAIN` you defined) in your browser of choice, where you see the standard ownCloud login screen as in the image below. image::docker/owncloud-ui-login.png[The ownCloud UI via Docker,width=300] @@ -306,6 +377,33 @@ docker compose exec owncloud occ IMPORTANT: Don't use the `php` command prefix, this leads to several errors and is not intended to run in docker environments. +=== Enabling Collabora Online (CODE) + +The `collabora` service in the compose file runs a self-hosted Collabora Online Development Edition (CODE) server. This is the office server that ownCloud connects to; once the containers are up, ownCloud still needs to be told where to reach it. + +ownCloud integrates with Collabora through the `richdocuments` app, acting as the WOPI host while CODE acts as the WOPI client. To wire them together: + +. Enable the `richdocuments` app: ++ +[source,docker,subs="attributes+"] +---- +{occ-command-example-prefix-docker} app:enable richdocuments +---- + +. Point the app at the public HTTPS URL of the CODE server (`COLLABORA_DOMAIN`): ++ +[source,docker,subs="attributes+"] +---- +{occ-command-example-prefix-docker} config:app:set richdocuments wopi_url --value='https://collabora.example.com' +---- + +After this, opening a supported document (for example a `.docx`) in the ownCloud web UI loads the Collabora editor in an iframe over HTTPS. + +[NOTE] +==== +The `richdocuments` app and its Secure View capabilities are an Enterprise feature and its OC11 availability is subject to change. The `collabora` (CODE) container itself is edition-independent, but the ownCloud-side integration depends on the app being available in your installation. For the full list of `richdocuments` settings and the Secure View feature, see xref:enterprise/collaboration/collabora_secure_view.adoc[Collabora Online / Secure View] and the xref:configuration/server/occ_command.adoc#collabora-online-secure-view[Collabora related occ command set]. +==== + === Configuration For more configuration options, please refer to the xref:installation/configuration_notes.adoc[Configuration Notes] documentation. diff --git a/modules/admin_manual/pages/maintenance/backup_and_restore/backup.adoc b/modules/admin_manual/pages/maintenance/backup_and_restore/backup.adoc index 44c1f7d2c..25a39b6b1 100644 --- a/modules/admin_manual/pages/maintenance/backup_and_restore/backup.adoc +++ b/modules/admin_manual/pages/maintenance/backup_and_restore/backup.adoc @@ -98,6 +98,21 @@ sudo mysqldump \ owncloud > owncloud-dbbackup_`date +"%Y%m%d"`.bak ---- +[NOTE] +.Docker: the database is not reachable from the host +==== +In the xref:installation/installing_with_docker.adoc[Docker Compose setup], MariaDB deliberately publishes no host port — it is only reachable on the stack's internal network, so `-h localhost` finds nothing on the host. Run the dump inside the database container instead, from the directory holding your `docker-compose.yml`: + +[source,bash] +---- +docker compose exec -T mariadb \ + mysqldump --single-transaction -u root -p"$MYSQL_ROOT_PASSWORD" \ + owncloud > owncloud-dbbackup_`date +"%Y%m%d"`.bak +---- + +`-T` disables pseudo-TTY allocation, without which the redirected dump would be corrupted by carriage returns. Do not add a `ports:` entry for MariaDB just to enable a host-side backup; that exposes the database to the network. +==== + === SQLite [source,bash] diff --git a/package.json b/package.json index 80dc21c6a..1fd477653 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "antora-bundle": "antora --stacktrace --cache-dir cache --redirect-facility static --clean --fetch --ui-bundle-url ../docs-ui/build/ui-bundle.zip --url https://doc.staging.owncloud.com site.yml", "antora-dev-local": "antora --stacktrace --cache-dir cache --redirect-facility static --clean --fetch --url http://localhost:8080 site-dev.yml", "antora-dev-bundle": "antora --stacktrace --cache-dir cache --redirect-facility static --clean --fetch --ui-bundle-url ../docs-ui/build/ui-bundle.zip --url http://localhost:8080 site-dev.yml", - "serve": "http-server public/ -d -i" + "serve": "http-server public/ -d -i", + "test:compose": "tests/docker-compose/smoke-test.sh" }, "dependencies": { "antora": "^3.1.15", diff --git a/tests/docker-compose/README.md b/tests/docker-compose/README.md new file mode 100644 index 000000000..9d5affed0 --- /dev/null +++ b/tests/docker-compose/README.md @@ -0,0 +1,54 @@ +# docker-compose smoke test + +QA harness for the ownCloud + Collabora CODE docker-compose example that ships in +the admin manual +(`modules/admin_manual/examples/installation/docker/docker-compose.yml`). + +The example is written for a **production, internet-facing** deployment: it pulls +an image tag that Antora fills in at build time and it obtains real Let's Encrypt +certificates, which needs public DNS. Neither works on a throwaway machine, so +this harness layers two small files on top of the shipped example to make it +bootable **locally and in CI without changing the example itself**: + +| File | Purpose | +| --- | --- | +| `docker-compose.override.yml` | Blanks each router's ACME `certresolver` label so Traefik serves its built-in self-signed certificate. Everything else — the Traefik version and its `command:` flags, split networks, no DB/Redis host ports, Collabora admin allowlist, HSTS — is inherited unchanged, so CI exercises the version and configuration the docs actually ship. | +| `test.env` | Concrete image pins, `*.localhost` hostnames, and throwaway credentials. | +| `smoke-test.sh` | Boots the merged stack, waits for health, and asserts the endpoints and the security invariants. | + +## Running + +``` +npm run test:compose +``` + +or directly: + +``` +tests/docker-compose/smoke-test.sh +``` + +Requires Docker with the Compose plugin, plus `curl` and `bash`. The script +always tears the stack down (`docker compose down -v`) on exit, including on +failure, and dumps container status + logs when something fails. + +## What it asserts + +1. **ownCloud is up** — `https://owncloud.localhost/status.php` returns JSON with + `"installed":true`. +2. **Collabora is up** — `https://collabora.localhost/hosting/discovery` returns a + WOPI `` document. +3. **Data tier is private (security regression guard)** — the merged + `docker compose config` publishes no `3306`/`6379` host binding, and neither + port accepts a TCP connection on `127.0.0.1`. This guards the hardening that + removed the MariaDB/Redis host ports from the example. The probe is a raw + `/dev/tcp` connect, not `curl`: MariaDB and Redis do not speak HTTP, so curl + never exits `0` against them even when the port is wide open, which would make + a curl-based check pass unconditionally. + +Because the harness reuses the example's fixed Compose project name (needed so +the `traefik.docker.network` label resolves to the same network the example +names), it cannot run alongside a real deployment of the example on one host. + +TLS is served with Traefik's self-signed certificate, so all requests use +`curl -k --resolve :443:127.0.0.1`. diff --git a/tests/docker-compose/docker-compose.override.yml b/tests/docker-compose/docker-compose.override.yml new file mode 100644 index 000000000..2156c3f7c --- /dev/null +++ b/tests/docker-compose/docker-compose.override.yml @@ -0,0 +1,43 @@ +# LOCAL TEST OVERRIDE — do NOT use in production. +# +# This file layers on top of the shipped example +# (modules/admin_manual/examples/installation/docker/docker-compose.yml) so the +# stack can be booted locally / in CI without public DNS or Let's Encrypt. +# +# It changes exactly one thing versus the shipped file: which certificate the +# TLS routers serve. The production example obtains real certificates from +# Let's Encrypt via the ACME TLS-ALPN-01 challenge, which requires the public +# hostnames to resolve to this host — not possible on a throwaway CI runner. +# Here each router's `tls.certresolver` label (set by the base file) is blanked +# and `tls=true` is pinned, so the routers stay TLS-enabled but serve Traefik's +# built-in self-signed certificate. (A router that still references a certresolver +# it cannot satisfy would fail to obtain a certificate.) +# +# Deliberately NOT overridden here: +# +# * Traefik's `command:` list. Compose *replaces* list values rather than +# merging them, so re-listing the flags would freeze a copy of the base +# file's Traefik configuration and silently stop future flag changes from +# reaching the CI-tested config. The base file's ACME flags are therefore +# inherited as-is: defining a certresolver is harmless when no router +# references it — Traefik never places an ACME order, and the routers above +# serve TRAEFIK DEFAULT CERT. +# +# * The Traefik `image:` tag. CI must exercise the same version the docs ship. +# +# The security-hardened topology (split networks, no DB/Redis host ports, +# Collabora admin allowlist, HSTS) is inherited unchanged from the base file. +# Compose merges these list-form labels by key, so the entries below override +# only the matching keys from the base file and leave the rest intact. +services: + owncloud: + labels: + - traefik.http.routers.owncloud.tls=true + - traefik.http.routers.owncloud.tls.certresolver= + + collabora: + labels: + - traefik.http.routers.collabora.tls=true + - traefik.http.routers.collabora.tls.certresolver= + - traefik.http.routers.collabora-admin.tls=true + - traefik.http.routers.collabora-admin.tls.certresolver= diff --git a/tests/docker-compose/smoke-test.sh b/tests/docker-compose/smoke-test.sh new file mode 100755 index 000000000..0af3e252e --- /dev/null +++ b/tests/docker-compose/smoke-test.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# +# Smoke test for the shipped ownCloud + Collabora CODE docker-compose example. +# +# It boots the REAL example file +# (modules/admin_manual/examples/installation/docker/docker-compose.yml) with a +# local override + test env (self-signed TLS, pinned images, *.localhost hosts), +# waits for the containers to become healthy, and asserts that: +# 1. ownCloud answers on https://owncloud.localhost/status.php with installed=true +# 2. Collabora answers on https://collabora.localhost/hosting/discovery (WOPI XML) +# 3. MariaDB (3306) and Redis (6379) are NOT reachable on the host (security +# regression guard — the hardened compose must not publish them) +# +# The stack is always torn down (docker compose down -v) on exit. +# +# Usage: tests/docker-compose/smoke-test.sh +# Requires: docker (with the compose plugin) and curl. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +EXAMPLE_DIR="${REPO_ROOT}/modules/admin_manual/examples/installation/docker" + +BASE_COMPOSE="${EXAMPLE_DIR}/docker-compose.yml" +OVERRIDE_COMPOSE="${SCRIPT_DIR}/docker-compose.override.yml" +ENV_FILE="${SCRIPT_DIR}/test.env" + +# Must match the top-level "name:" in the shipped compose file. Compose derives +# the network names from the project name ("_frontend"), and the +# ownCloud service's traefik.docker.network label refers to "owncloud_frontend" +# by that exact name — a different project name would break proxy routing here +# while leaving the shipped example working, i.e. testing a different stack. +# Consequence: this harness reuses the example's container/network names, so it +# cannot run next to a real deployment of the same example on one host. +PROJECT_NAME="owncloud" + +# How long to wait for every container to report healthy. +HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-300}" +# How long to wait for the ownCloud first-run install to finish (status.php). +INSTALL_TIMEOUT="${INSTALL_TIMEOUT:-300}" + +OWNCLOUD_HOST="owncloud.localhost" +COLLABORA_HOST="collabora.localhost" + +compose() { + docker compose \ + --project-name "${PROJECT_NAME}" \ + --env-file "${ENV_FILE}" \ + -f "${BASE_COMPOSE}" \ + -f "${OVERRIDE_COMPOSE}" \ + "$@" +} + +log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; } +pass() { printf '\033[1;32m ✓\033[0m %s\n' "$*"; } +fail() { printf '\033[1;31m ✗\033[0m %s\n' "$*" >&2; } + +cleanup() { + local rc=$? + if [ "${rc}" -ne 0 ]; then + log "FAILURE (exit ${rc}) — dumping container status and logs" + compose ps || true + compose logs --no-color --tail 100 || true + fi + log "Tearing down the stack" + compose down --volumes --remove-orphans || true + exit "${rc}" +} +trap cleanup EXIT + +# Poll until "$1" (a shell snippet) succeeds, or "$2" seconds elapse. +# poll_until [on_timeout] +# Shared by the health / status.php / WOPI-discovery waits below so the +# deadline+sleep scaffolding exists once. +# +# and are `eval`ed, so callers pass them SINGLE-quoted +# on purpose: they must be re-evaluated on each iteration / at timeout. Double +# quoting would expand them once at call time and freeze the then-empty values +# into the diagnostics. Hence the `shellcheck disable=SC2016` markers at the +# call sites. +poll_until() { + local condition="$1" timeout="$2" description="$3" on_timeout="${4:-}" + local deadline=$(( $(date +%s) + timeout )) + while true; do + if eval "${condition}"; then + return 0 + fi + if [ "$(date +%s)" -ge "${deadline}" ]; then + fail "timed out after ${timeout}s waiting for ${description}" + [ -n "${on_timeout}" ] && eval "${on_timeout}" + return 1 + fi + sleep 5 + done +} + +# --------------------------------------------------------------------------- + +log "Validating the merged compose configuration" +compose config >/dev/null +pass "compose config is valid" + +# Clear any stale state from a previous run that died without its EXIT trap +# firing (CI OOM/timeout, SIGKILL). A leftover MariaDB volume still holds the +# old root password, so the healthcheck below would fail for reasons that have +# nothing to do with the compose example under test. +log "Removing any leftovers from a previous run" +compose down --volumes --remove-orphans + +log "Booting the stack" +compose up -d + +# Reports the containers that declare a healthcheck but are not yet healthy. +# One `docker inspect` call per container, emitting " " pairs. +unhealthy_containers() { + local cids + cids=$(compose ps -q) + [ -n "${cids}" ] || return 0 + # shellcheck disable=SC2086 + docker inspect \ + -f '{{if .State.Health}}{{printf "%s(%s)" .Name .State.Health.Status}}{{end}}' \ + ${cids} | sed 's|^/||' | grep -v '(healthy)$' || true +} + +log "Waiting for containers to become healthy (timeout ${HEALTH_TIMEOUT}s)" +# shellcheck disable=SC2016 # deferred expansion is intended — see poll_until +poll_until '[ -z "$(unhealthy_containers)" ]' "${HEALTH_TIMEOUT}" \ + "containers to report healthy" \ + 'printf " still not healthy: %s\n" "$(unhealthy_containers | tr "\n" " ")"' \ + || exit 1 +pass "all containers with a healthcheck are healthy" + +# Fetches an HTTPS URL through the proxy and asserts the body contains a marker. +# `last_body` is kept for the timeout diagnostics. +# https_body_contains [max_time] +last_body="" +https_body_contains() { + local host="$1" path="$2" marker="$3" max_time="${4:-10}" + last_body=$(curl -sk --max-time "${max_time}" \ + --resolve "${host}:443:127.0.0.1" "https://${host}${path}" || true) + # Pure-bash substring match: do NOT pipe into `grep -q`. Under `set -o + # pipefail`, grep -q closes the pipe on the first match, the writer then dies + # with SIGPIPE, and the pipeline reports failure even though the match succeeded. + [[ "${last_body}" == *"${marker}"* ]] +} + +# ownCloud status.php — poll until first-run install completes. +log "Checking ownCloud https://${OWNCLOUD_HOST}/status.php" +# shellcheck disable=SC2016 # deferred expansion is intended — see poll_until +poll_until "https_body_contains '${OWNCLOUD_HOST}' /status.php '\"installed\":true'" \ + "${INSTALL_TIMEOUT}" "status.php to report installed=true" \ + 'printf " last body: %s\n" "${last_body}"' \ + || exit 1 +pass "status.php reports installed=true" +printf ' %s\n' "${last_body}" + +# Collabora WOPI discovery. CODE has no healthcheck (so the health-wait above +# does not cover it) and, while it is still starting, the proxy returns 404/502 +# for this path. Poll until the discovery document is served or we time out. +log "Checking Collabora https://${COLLABORA_HOST}/hosting/discovery" +# shellcheck disable=SC2016 # deferred expansion is intended — see poll_until +poll_until "https_body_contains '${COLLABORA_HOST}' /hosting/discovery '' 15" \ + "${INSTALL_TIMEOUT}" "Collabora to serve a WOPI discovery document" \ + 'printf " last body: %s\n" "${last_body}"' \ + || exit 1 +pass "Collabora returned a WOPI discovery document" + +# Security regression guard: the data tier must NOT be published to the host. +# Capture the config first, then match — piping `compose config` straight into +# `grep -Eq` is unsafe under `set -o pipefail`: a match makes grep close the +# pipe, compose dies with SIGPIPE, and the non-zero pipeline status inverts the +# result, silently hiding a real port exposure. +log "Asserting MariaDB/Redis are not published to the host" +merged_config=$(compose config) +if [[ "${merged_config}" =~ published:[[:space:]]*\"?(3306|6379)\"? ]]; then + fail "compose config publishes a data-tier port (3306/6379) to the host" + exit 1 +fi +pass "no data-tier host port bindings in the merged config" + +# Probe with a raw TCP connect, NOT curl: MariaDB and Redis do not speak HTTP, +# so `curl http://127.0.0.1:` never exits 0 even when the port is wide open +# (it returns 52 "empty reply" for an open port vs 7 "refused" for a closed one). +# A curl-based guard therefore always passes and could never catch the very +# regression it exists to catch. /dev/tcp succeeds on connect alone. +for port in 3306 6379; do + if (exec 3<>"/dev/tcp/127.0.0.1/${port}") 2>/dev/null; then + fail "port ${port} accepted a TCP connection on 127.0.0.1 — it must not be exposed" + exit 1 + fi +done +pass "ports 3306 and 6379 refuse connections on the host" + +log "All smoke-test assertions passed" diff --git a/tests/docker-compose/test.env b/tests/docker-compose/test.env new file mode 100644 index 000000000..6435cfb78 --- /dev/null +++ b/tests/docker-compose/test.env @@ -0,0 +1,38 @@ +# LOCAL TEST ENV — do NOT use in production. Throwaway credentials only. +# +# Concrete values that make the shipped example bootable locally / in CI: +# - real image tags (the docs ship an Antora build-time placeholder for +# OWNCLOUD_IMAGE, which is not a valid tag on its own); +# - *.localhost hostnames (resolve to 127.0.0.1, no DNS needed); +# - test credentials (never reused anywhere real). + +# Pinned so a run is reproducible and CI is not surprised by a new release. +# Only OWNCLOUD_IMAGE is set here: the base compose file uses +# owncloud/server:${OWNCLOUD_IMAGE} (its version tracks the docs release), while +# the MariaDB, Redis, Collabora and Traefik tags are literal in the base file and +# are used here exactly as shipped. +OWNCLOUD_IMAGE=10.15.0 + +# *.localhost always resolves to 127.0.0.1; the smoke test uses curl --resolve. +OWNCLOUD_DOMAIN=owncloud.localhost +OWNCLOUD_TRUSTED_DOMAINS=owncloud.localhost +OWNCLOUD_OVERWRITE_CLI_URL=https://owncloud.localhost + +# Initial ownCloud admin account. +ADMIN_USERNAME=admin +ADMIN_PASSWORD=test-admin-pw + +# Database credentials (throwaway). +MYSQL_ROOT_PASSWORD=test-db-root-pw +MYSQL_PASSWORD=test-db-app-pw + +# Collabora CODE. +COLLABORA_DOMAIN=collabora.localhost +# Regex of the ownCloud host CODE accepts as WOPI host (dots escaped). +OWNCLOUD_DOMAIN_REGEX=owncloud\.localhost +COLLABORA_ADMIN_USERNAME=admin +COLLABORA_ADMIN_PASSWORD=test-collabora-pw + +# Unused locally (ACME is disabled in the override) but referenced by the base +# compose command list, so it must be defined to avoid an interpolation warning. +LETSENCRYPT_EMAIL=test@owncloud.localhost