From 58d2d0e78a675317248e3a9233524d7a51f11354 Mon Sep 17 00:00:00 2001 From: "D. Scott Boggs" Date: Sat, 16 Sep 2023 14:52:56 -0400 Subject: [PATCH] Enable Collabora/WOPI/office suite --- .gitignore | 8 +- README.md | 2 + dns.sh | 76 +++++++++++++++++++ docker-compose.yml | 99 +++++++++++++++++++++++- gen-secrets.sh | 61 +++++++++++++++ mounts/config/app-registry.yaml | 60 +++++++++++++++ mounts/wopi/entrypoint.sh | 20 +++++ mounts/wopi/wopiserver.conf | 129 ++++++++++++++++++++++++++++++++ shell.nix | 11 +++ 9 files changed, 463 insertions(+), 3 deletions(-) create mode 100644 dns.sh create mode 100644 gen-secrets.sh create mode 100644 mounts/config/app-registry.yaml create mode 100644 mounts/wopi/entrypoint.sh create mode 100644 mounts/wopi/wopiserver.conf create mode 100644 shell.nix diff --git a/.gitignore b/.gitignore index 3b0b69d..8724019 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ -mounts/ -.env* \ No newline at end of file +mounts/data +mounts/config/ocis.yaml + +.env* +**/*.secret +**/*.pw diff --git a/README.md b/README.md index 1c40e50..4db960d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ If you've just cloned this to a new machine, you have to bootstrap the service: ```console $ mkdir -p mounts/{config,data} $ docker compose run init +$ sh gen-secrets.sh +$ sh dns.sh # requires `doctl auth init` be run on the machine once before ``` ## Subsequent runs diff --git a/dns.sh b/dns.sh new file mode 100644 index 0000000..4bf900c --- /dev/null +++ b/dns.sh @@ -0,0 +1,76 @@ +#!/bin/sh + +set -e + +source ./.env + +domain=$(python -c "print('.'.join('${PUBLIC_URL}'.split('.')[-2:]))") +baseSubdomain=$(python -c "url='${PUBLIC_URL}'; print('.'.join(url.split('.')[:-2]) if url.count('.') > 1 else '@')") + +domainList=$(doctl compute domain records list "$domain" --output json) +ttl=600 + +function recordID() { + subdomain=$1 + domain=$2 + echo $domainList | jq -r '.[] | select(.type == "A" and .name == "'"$subdomain"'") | .id' +} + +function ipOfRecord() { + recID=$1 + echo $domainList | jq -r '.[] | select(.id == '"$recID"') | .data' +} + +function _currentIP() { + ipData=$(curl -s https://am.i.mullvad.net/json) + if test 'true' = `echo $ipData | jq .mullvad_exit_ip` + then + echo "error: connected to anonymizing VPN. This won't work." + exit 1 + else + echo $ipData | jq -r .ip + fi +} + +currentIP=`_currentIP` + +function checkRecord() { + subdomain="$1" + domain="$2" + fullDomain="$(test "$subdomain" = @ && printf "" || printf "${subdomain}.")$domain" + echo checking $fullDomain + recID=`recordID $subdomain $domain` + if test -n "$recID" + then # record exists! + printf "found record $recID " + recIP=`ipOfRecord $recID` + echo with IP $recIP + if test "$recIP" = "$currentIP" + then + echo $fullDomain already set to correct IP $currentIP + else + echo $fullDomain IP set to $recIP when it should be $currentIP, updating... + doctl compute domain records update $domain --record-id $recID --record-data $currentIP + echo ...done + fi + else + echo no record yet for $fullDomain, setting to $currentIP... + doctl compute domain records create $domain \ + --record-ttl $ttl \ + --record-type A \ + --record-name $subdomain \ + --record-data $currentIP + echo ...done + fi +} + +checkRecord $baseSubdomain $domain + +if test $baseSubdomain = @ +then + checkRecord wopi $domain + checkRecord office $domain +else + checkRecord wopi.$baseSubdomain $domain + checkRecord office.$baseSubdomain $domain +fi \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index ef6f9be..6bcfeae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ version: '3.5' services: ocis: image: owncloud/ocis - expose: [ 9200 ] + expose: [ 9200, 9142 ] volumes: - type: bind source: ./mounts/config @@ -16,27 +16,124 @@ services: PROXY_HTTP_ADDR: 0.0.0.0:9200 PROXY_TLS: false OCIS_URL: https://${PUBLIC_URL} + # fulltext search SEARCH_EXTRACTOR_TYPE: tika SEARCH_EXTRACTOR_TIKA_TIKA_URL: http://search-engine:9998 FRONTEND_FULL_TEXT_SEARCH_ENABLED: "true" + + # another obviously good choice for $MICRO_REGISTRY if mdns becomes + # problematic is "etcd", by adding an etcd service and setting + # $MICRO_REGISTRY_ADDRESS, $ETCD_USERNAME, and $ETCD_PASSWORD. + # https://github.com/owncloud/ocis/blob/b0ac9840dff00a2527b2e8df86bebcd12632104c/ocis/README.md?plain=1#L18 + MICRO_REGISTRY: mdns + # https://github.com/owncloud/ocis/blob/master/deployments/examples/ocis_wopi/docker-compose.yml#L67 + GATEWAY_GRPC_ADDR: 0.0.0.0:9142 # make the REVA gateway accessible to the app drivers networks: - web - internal + - app-provider-net labels: traefik.http.routers.ocis.rule: Host(`${PUBLIC_URL}`) + traefik.http.routers.ocis.service: ocis + traefik.http.services.ocis.loadbalancer.server.port: 9200 traefik.http.routers.ocis.tls: true traefik.http.routers.ocis.tls.certresolver: letsencrypt traefik.enable: true depends_on: [ search-engine ] + restart: unless-stopped search-engine: image: apache/tika:latest-full networks: [ internal ] restart: always + app-provider: + image: owncloud/ocis + networks: [ app-provider-net ] + command: app-provider server + environment: + # use the internal service name of the gateway https://github.com/owncloud/ocis/blob/b0ac9840dff00a2527b2e8df86bebcd12632104c/deployments/examples/ocis_wopi/docker-compose.yml#L109-L110C37 + REVA_GATEWAY: com.owncloud.api.gateway + APP_PROVIDER_GRPC_ADDR: 0.0.0.0:9164 + # use the internal service name + APP_PROVIDER_EXTERNAL_ADDR: com.owncloud.api.app-provider-collabora + APP_PROVIDER_DRIVER: wopi + APP_PROVIDER_WOPI_APP_NAME: Collabora + APP_PROVIDER_WOPI_APP_ICON_URI: https://office.${PUBLIC_URL}/favicon.ico + APP_PROVIDER_WOPI_APP_URL: https://office.${PUBLIC_URL} + APP_PROVIDER_WOPI_WOPI_SERVER_EXTERNAL_URL: https://wopi.${PUBLIC_URL} + APP_PROVIDER_WOPI_FOLDER_URL_BASE_URL: https://${PUBLIC_URL} + # share the registry with the ocis container + MICRO_REGISTRY: "mdns" + volumes: + - type: bind + source: ./mounts/config + target: /etc/ocis + restart: unless-stopped + depends_on: [ ocis ] + + wopiserver: + image: cs3org/wopiserver + networks: + - web + - app-provider-net + environment: + PUBLIC_URL: ${PUBLIC_URL} + entrypoint: + - /bin/sh + - /entrypoint-override.sh + volumes: + - type: bind + source: ./mounts/wopi/wopiserver.conf + target: /etc/wopi/wopiserver.conf.dist + read_only: true + - type: bind + source: ./mounts/wopi/entrypoint.sh + target: /entrypoint-override.sh + read_only: true + - type: bind + source: ./mounts/wopi/wopi.secret + target: /etc/wopi/wopi.secret + read_only: true + - type: bind + source: ./mounts/wopi/recovery + target: /var/spool/wopirecovery + labels: + traefik.enable: true + traefik.http.routers.wopiserver.entrypoints: websecure + traefik.http.routers.wopiserver.rule: Host(`wopi.${PUBLIC_URL}`) + traefik.http.routers.wopiserver.tls.certresolver: letsencrypt + traefik.http.routers.wopiserver.service: wopiserver + traefik.http.services.wopiserver.loadbalancer.server.port: 8880 + restart: unless-stopped + + collabora: + image: collabora/code + networks: + - web + - app-provider-net + environment: + aliasgroup1: https://wopi.${PUBLIC_URL}:443 + DONT_GEN_SSL_CERT: "YES" + extra_params: --o:ssl.enable=false --o:ssl.termination=true --o:welcome.enable=false --o:net.frame_ancestors=${PUBLIC_URL} + username: ${COLLABORA_ADMIN_USER} + password: ${COLLABORA_ADMIN_PASSWORD} + cap_add: + - MKNOD + labels: + traefik.enable: true + traefik.http.routers.collabora.entrypoints: websecure + traefik.http.routers.collabora.rule: Host(`office.${PUBLIC_URL}`) + traefik.http.routers.collabora.tls.certresolver: letsencrypt + traefik.http.routers.collabora.service: collabora + traefik.http.services.collabora.loadbalancer.server.port: 9980 + restart: unless-stopped + networks: web: external: true internal: internal: true + # The app-provider needs to be able to reach out to the internet + app-provider-net: diff --git a/gen-secrets.sh b/gen-secrets.sh new file mode 100644 index 0000000..4614616 --- /dev/null +++ b/gen-secrets.sh @@ -0,0 +1,61 @@ +#!/bin/sh + +script_name=$0 +force=$(test "$1" = force && echo true || echo false) + +function usage() { + if test $# -gt 0 + then + echo "$@" + echo + echo --------- + echo + fi + echo "$script_name [force]" + echo generates secrets + echo overwrites files if "force" is passed as an argument + exit 1 +} + +# Generate a random base64 string of $1 random bytes. The length argument is the +# number of bytes the base64 string represents, not the length of the string. +function randomString() { + openssl rand -base64 ${1:-36} +} + +openssl version > /dev/null || usage error: openssl is not installed + +if test -s mounts/wopi/wopi.secret && test $force = false +then + echo WOPI secret already exists, not generating a new secret +else + randomString 48 > mounts/wopi/wopi.secret +fi + +source ./.env + +if test -z "$COLLABORA_ADMIN_USER" +then + echo COLLABORA_ADMIN_USER=$(randomString 24) >> .env +else + if test $force = false + then + echo Collabora username already generated, not overwriting + else + echo Collabora username was already $COLLABORA_ADMIN_USER, overwriting + sed -i "s/^(COLLABORA_ADMIN_USER=).+\$/\1$(randomString 24)/" .env + fi +fi + +if test -z "$COLLABORA_ADMIN_PASSWORD" +then + echo COLLABORA_ADMIN_PASSWORD=$(randomString 36) >> .env +else + if test $force = false + then + echo Collabora password already generated, not overwriting + else + echo Collabora password was already $COLLABORA_ADMIN_PASSWORD, overwriting + sed -i "s/^(COLLABORA_ADMIN_PASSWORD=).+\$/\1$(randomString 24)/" .env + fi +fi \ No newline at end of file diff --git a/mounts/config/app-registry.yaml b/mounts/config/app-registry.yaml new file mode 100644 index 0000000..28395f0 --- /dev/null +++ b/mounts/config/app-registry.yaml @@ -0,0 +1,60 @@ +# From https://github.com/owncloud/ocis/raw/master/deployments/examples/ocis_wopi/config/ocis/app-registry.yaml + +app_registry: + mimetypes: + - mime_type: application/pdf + extension: pdf + name: PDF + description: PDF document + icon: '' + default_app: '' + allow_creation: false + - mime_type: application/vnd.oasis.opendocument.text + extension: odt + name: OpenDocument + description: OpenDocument text document + icon: '' + default_app: Collabora + allow_creation: true + - mime_type: application/vnd.oasis.opendocument.spreadsheet + extension: ods + name: OpenSpreadsheet + description: OpenDocument spreadsheet document + icon: '' + default_app: Collabora + allow_creation: true + - mime_type: application/vnd.oasis.opendocument.presentation + extension: odp + name: OpenPresentation + description: OpenDocument presentation document + icon: '' + default_app: Collabora + allow_creation: true + - mime_type: application/vnd.openxmlformats-officedocument.wordprocessingml.document + extension: docx + name: Microsoft Word + description: Microsoft Word document + icon: '' + default_app: OnlyOffice + allow_creation: true + - mime_type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + extension: xlsx + name: Microsoft Excel + description: Microsoft Excel document + icon: '' + default_app: OnlyOffice + allow_creation: true + - mime_type: application/vnd.openxmlformats-officedocument.presentationml.presentation + extension: pptx + name: Microsoft PowerPoint + description: Microsoft PowerPoint document + icon: '' + default_app: OnlyOffice + allow_creation: true + - mime_type: application/vnd.jupyter + extension: ipynb + name: Jupyter Notebook + description: Jupyter Notebook + icon: '' + default_app: '' + allow_creation: true diff --git a/mounts/wopi/entrypoint.sh b/mounts/wopi/entrypoint.sh new file mode 100644 index 0000000..e1ebdc4 --- /dev/null +++ b/mounts/wopi/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Based on https://github.com/owncloud/ocis/blob/master/deployments/examples/ocis_wopi/config/wopiserver/entrypoint-override.sh +set -e + +if test "${WOPI_SECRET:-undefined}" != undefined +then + echo "$WOPI_SECRET" > /etc/wopi/wopi.secret +elif test "${WOPI_SECRET_FILE:-undefined}" != undefined +then + ln -s "$WOPI_SECRET_FILE" /etc/wopi/wopi.secret +# or, preferrably, just mount /etc/wopi/wopi.secret as a read-only bind mount +fi + +sed "s/PUBLIC_URL/$PUBLIC_URL/g" < /etc/wopi/wopiserver.conf.dist > /etc/wopi/wopiserver.conf + +if [ "$WOPISERVER_INSECURE" == "true" ]; then + sed -i 's/sslverify\s=\sTrue/sslverify = False/g' /etc/wopi/wopiserver.conf +fi + +exec /app/wopiserver.py diff --git a/mounts/wopi/wopiserver.conf b/mounts/wopi/wopiserver.conf new file mode 100644 index 0000000..46f63f8 --- /dev/null +++ b/mounts/wopi/wopiserver.conf @@ -0,0 +1,129 @@ +# +# This config is based on https://github.com/owncloud/ocis/blob/master/deployments/examples/ocis_wopi/config/wopiserver/wopiserver.conf.dist +# which in turn is based on https://github.com/cs3org/wopiserver/blob/master/wopiserver.conf +# +# wopiserver.conf +# +# Default configuration file for the WOPI server for oCIS +# +############################################################## + +[general] +# Storage access layer to be loaded in order to operate this WOPI server +# only "cs3" is supported with oCIS +storagetype = cs3 + +# Port where to listen for WOPI requests +port = 8880 + +# Logging level. Debug enables the Flask debug mode as well. +# Valid values are: Debug, Info, Warning, Error. +loglevel = Error +loghandler = stream +logdest = stdout + +# URL of your WOPI server or your HA proxy in front of it +wopiurl = https://wopi.PUBLIC_URL + +# URL for direct download of files. The complete URL that is sent +# to clients will include the access_token argument +downloadurl = https://wopi.PUBLIC_URL/wopi/iop/download + +# The internal server engine to use (defaults to flask). +# Set to waitress for production installations. +internalserver = waitress + +# List of file extensions deemed incompatible with LibreOffice: +# interoperable locking will be disabled for such files +nonofficetypes = .md .zmd .txt .epd + +# List of file extensions to be supported by Collabora (deprecated) +codeofficetypes = .odt .ott .ods .ots .odp .otp .odg .otg .doc .dot .xls .xlt .xlm .ppt .pot .pps .vsd .dxf .wmf .cdr .pages .number .key + +# WOPI access token expiration time [seconds] +tokenvalidity = 86400 + +# WOPI lock expiration time [seconds] +wopilockexpiration = 3600 + +# WOPI lock strict check: if True (default), WOPI locks will be compared according to specs, +# that is their representation must match. False allows for a more relaxed comparison, +# which compensates incorrect lock requests from Microsoft Office Online 2016-2018 +# on-premise setups. +#wopilockstrictcheck = True + +# Enable support of rename operations from WOPI apps. This is currently +# disabled by default as it has been observed that both MS Office and Collabora +# Online do not play well with this feature. +# Not supported with oCIS, must always be set to "False" +enablerename = False + +# Detection of external Microsoft Office or LibreOffice locks. By default, lock files +# compatible with Office for Desktop applications are detected, assuming that the +# underlying storage can be mounted as a remote filesystem: in this case, WOPI GetLock +# and SetLock operations return such locks and prevent online apps from entering edit mode. +# This feature can be disabled in order to operate a pure WOPI server for online apps. +# Not supported with oCIS, must always be set to "False" +detectexternallocks = False + +# Location of the webconflict files. By default, such files are stored in the same path +# as the original file. If that fails (e.g. because of missing permissions), +# an attempt is made to store such files in this path if specified, otherwise +# the system falls back to the recovery space (cf. io|recoverypath). +# The keywords and are replaced with the actual username's +# initial letter and the actual username, respectively, so you can use e.g. +# /your_storage/home/user_initial/username +#conflictpath = / + +# ownCloud's WOPI proxy configuration. Disabled by default. +#wopiproxy = https://external-wopi-proxy.com +#wopiproxysecretfile = /path/to/your/shared-key-file +#proxiedappname = Name of your proxied app + +[security] +# Location of the secret files. Requires a restart of the +# WOPI server when either the files or their content change. +wopisecretfile = /etc/wopi/wopi.secret +# iop secret is not used for cs3 storage type +#iopsecretfile = /etc/wopi/iopsecret + +# Use https as opposed to http (requires certificate) +usehttps = no + +# Certificate and key for https. Requires a restart +# to apply a change. +wopicert = /etc/grid-security/host.crt +wopikey = /etc/grid-security/host.key + +[bridge] +# SSL certificate check for the connected apps +sslverify = True + +# Minimal time interval between two consecutive save operations [seconds] +#saveinterval = 200 + +# Minimal time interval before a closed file is WOPI-unlocked [seconds] +#unlockinterval = 90 + +# CodiMD: disable creating zipped bundles when files contain pictures +#disablezip = False + +[io] +# Size used for buffered reads [bytes] +chunksize = 4194304 + +# Path to a recovery space in case of I/O errors when reaching to the remote storage. +# This is expected to be a local path, and it is provided in order to ease user support. +# Defaults to the indicated spool folder. +recoverypath = /var/spool/wopirecovery + +[cs3] +# Host and port of the Reva(-like) CS3-compliant GRPC gateway endpoint +revagateway = ocis:9142 + +# Reva/gRPC authentication token expiration time [seconds] +# The default value matches Reva's default +authtokenvalidity = 3600 + +# SSL certificate check for Reva +sslverify = True diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..8ab1980 --- /dev/null +++ b/shell.nix @@ -0,0 +1,11 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + name = "OCIS deployment helpers"; + nativeBuildInputs = with pkgs.buildPackages; [ + openssl + python3 + jq + doctl + ]; +}