diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 76268be..b368591 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: operator: ${{ steps.diff.outputs.operator }} website: ${{ steps.diff.outputs.website }} platform_api: ${{ steps.diff.outputs.platform_api }} + zpush: ${{ steps.diff.outputs.zpush }} infra: ${{ steps.diff.outputs.infra }} deploy_any: ${{ steps.diff.outputs.deploy_any }} steps: @@ -62,8 +63,9 @@ jobs: hit '^apps/operator/' && out operator true || out operator false hit '^apps/website/' && out website true || out website false hit '^services/platform-api/' && out platform_api true || out platform_api false + hit '^services/zpush/' && out zpush true || out zpush false hit '^infrastructure/production/fleet/apps/' && out infra true || out infra false - if hit '^apps/(portal|booking|operator)/|^services/platform-api/|^infrastructure/production/fleet/apps/'; then + if hit '^apps/(portal|booking|operator)/|^services/(platform-api|zpush)/|^infrastructure/production/fleet/apps/'; then out deploy_any true else out deploy_any false @@ -212,12 +214,28 @@ jobs: docker push "$IMG:latest" docker push "$IMG:${{ github.sha }}" + # zpush is PHP (Z-Push EAS gateway) — no typecheck/test stage; the docker + # build is the only gate. + build_zpush: + runs-on: ubuntu-latest + needs: [changes] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.zpush == 'true' + steps: + - uses: actions/checkout@v4 + - name: Build + push + run: | + echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.lastcloud.io -u "${{ github.actor }}" --password-stdin + IMG=git.lastcloud.io/ronnibaslund/dezky/zpush + docker build -t "$IMG:latest" -t "$IMG:${{ github.sha }}" services/zpush + docker push "$IMG:latest" + docker push "$IMG:${{ github.sha }}" + # ── Deploy (only what changed) ───────────────────────────────────────────── # always() so skipped builds don't block it; the explicit result checks make # any FAILED build (or its typecheck/test, transitively) abort the deploy. deploy: runs-on: ubuntu-latest - needs: [changes, build_portal, build_booking, build_operator, build_platform_api] + needs: [changes, build_portal, build_booking, build_operator, build_platform_api, build_zpush] if: >- always() && github.event_name == 'push' && github.ref == 'refs/heads/main' && @@ -225,7 +243,8 @@ jobs: needs.build_portal.result != 'failure' && needs.build_portal.result != 'cancelled' && needs.build_booking.result != 'failure' && needs.build_booking.result != 'cancelled' && needs.build_operator.result != 'failure' && needs.build_operator.result != 'cancelled' && - needs.build_platform_api.result != 'failure' && needs.build_platform_api.result != 'cancelled' + needs.build_platform_api.result != 'failure' && needs.build_platform_api.result != 'cancelled' && + needs.build_zpush.result != 'failure' && needs.build_zpush.result != 'cancelled' steps: - uses: actions/checkout@v4 @@ -246,6 +265,7 @@ jobs: BUILT_BOOKING: ${{ needs.build_booking.result }} BUILT_OPERATOR: ${{ needs.build_operator.result }} BUILT_PLATFORM_API: ${{ needs.build_platform_api.result }} + BUILT_ZPUSH: ${{ needs.build_zpush.result }} run: | export KUBECONFIG=/tmp/kubeconfig echo "$KUBECONFIG_B64" | base64 -d > "$KUBECONFIG" @@ -256,12 +276,14 @@ jobs: [booking]=${{ needs.changes.outputs.booking }} [operator]=${{ needs.changes.outputs.operator }} [platform-api]=${{ needs.changes.outputs.platform_api }} + [zpush]=${{ needs.changes.outputs.zpush }} ) declare -A BUILT=( [portal]=$BUILT_PORTAL [booking]=$BUILT_BOOKING [operator]=$BUILT_OPERATOR [platform-api]=$BUILT_PLATFORM_API + [zpush]=$BUILT_ZPUSH ) # Deployable = changed AND its image was actually built this run. deployable() { [ "${CHANGED[$1]}" = "true" ] && [ "${BUILT[$1]}" = "success" ]; } @@ -271,7 +293,7 @@ jobs: # app to its CURRENT live image (or this push's sha if it was # rebuilt) so the apply never resets anything to :latest. cd infrastructure/production/fleet/apps - for app in portal booking platform-api operator; do + for app in portal booking platform-api operator zpush; do if deployable "$app"; then IMG="$REG/$app:$SHA" else @@ -280,10 +302,10 @@ jobs: kustomize edit set image "$REG/$app=$IMG" done kubectl apply -k . - ROLL="portal booking platform-api operator" + ROLL="portal booking platform-api operator zpush" else # App-only change: roll just the changed apps; nothing else moves. - for app in portal booking platform-api operator; do + for app in portal booking platform-api operator zpush; do if deployable "$app"; then kubectl -n dezky-apps set image "deploy/$app" "$app=$REG/$app:$SHA" ROLL="$ROLL $app" diff --git a/infrastructure/docker-compose/docker-compose.yml b/infrastructure/docker-compose/docker-compose.yml index fb6ec25..f075fcb 100644 --- a/infrastructure/docker-compose/docker-compose.yml +++ b/infrastructure/docker-compose/docker-compose.yml @@ -29,6 +29,7 @@ volumes: ocis_config: ocis_data: ocis_audit_log: + zpush_state: portal_node_modules: platform_api_node_modules: operator_node_modules: @@ -67,6 +68,7 @@ services: - api.dezky.local - files.dezky.local - mail.dezky.local + - autodiscover.dezky.local - office.dezky.local - collaboration.dezky.local labels: @@ -277,7 +279,8 @@ services: condition: service_healthy # ───────────────────────────────────────────────────────────────── - # Stalwart Mail — Mail server (SMTP/IMAP/JMAP/CalDAV/CardDAV/ActiveSync) + # Stalwart Mail — Mail server (SMTP/IMAP/JMAP/CalDAV/CardDAV; + # ActiveSync comes from the zpush gateway above) # ───────────────────────────────────────────────────────────────── stalwart: image: stalwartlabs/stalwart:v0.16 @@ -309,6 +312,43 @@ services: - traefik.http.routers.stalwart.tls=true - traefik.http.services.stalwart.loadbalancer.server.port=8080 + # ───────────────────────────────────────────────────────────────── + # Z-Push — Exchange ActiveSync gateway in front of Stalwart. Gives + # "Exchange" accounts on iOS/Android native Mail/Calendar two-way + # mail+calendar+contacts sync (EAS 14.1 — NOT the Outlook mobile app, + # which requires EAS 16.1, and not new Outlook for Windows). Stateless + # wrt credentials: passes the client's Basic auth straight to Stalwart. + # ───────────────────────────────────────────────────────────────── + zpush: + build: ../../services/zpush + container_name: dezky-zpush + restart: unless-stopped + environment: + CALDAV_SERVER: stalwart + IMAP_SERVER: stalwart + # Hostname devices get pointed at by EAS autodiscover responses. + ZPUSH_HOST: mail.dezky.local + # Where outlook-schema (mail) autodiscover POSTs are proxied to. + MAIL_AUTODISCOVER_UPSTREAM: http://stalwart:8080 + volumes: + - zpush_state:/var/lib/z-push + networks: [dezky] + depends_on: + - stalwart + labels: + - traefik.enable=true + # More-specific rule outranks the plain Host(`mail.dezky.local`) + # stalwart router; priority pins it explicitly. + - traefik.http.routers.zpush.rule=Host(`mail.dezky.local`) && PathPrefix(`/Microsoft-Server-ActiveSync`) + - traefik.http.routers.zpush.priority=100 + - traefik.http.routers.zpush.tls=true + - traefik.http.services.zpush.loadbalancer.server.port=80 + # EAS autodiscover for dev. The same dispatcher proxies outlook-schema + # mail autodiscover through to Stalwart. + - traefik.http.routers.zpush-autodiscover.rule=Host(`autodiscover.dezky.local`) + - traefik.http.routers.zpush-autodiscover.tls=true + - traefik.http.routers.zpush-autodiscover.service=zpush + # ───────────────────────────────────────────────────────────────── # OCIS — File storage with S3-compatible backend # ───────────────────────────────────────────────────────────────── diff --git a/infrastructure/production/fleet/apps/kustomization.yaml b/infrastructure/production/fleet/apps/kustomization.yaml index c2d0531..024cb6f 100644 --- a/infrastructure/production/fleet/apps/kustomization.yaml +++ b/infrastructure/production/fleet/apps/kustomization.yaml @@ -8,6 +8,7 @@ resources: - namespace.yaml - redirect-middleware.yaml - mail-autodiscovery.yaml + - zpush.yaml - platform-api-config.yaml - platform-api.yaml - portal.yaml diff --git a/infrastructure/production/fleet/apps/mail-autodiscovery.yaml b/infrastructure/production/fleet/apps/mail-autodiscovery.yaml index 9cc5ec4..f153b04 100644 --- a/infrastructure/production/fleet/apps/mail-autodiscovery.yaml +++ b/infrastructure/production/fleet/apps/mail-autodiscovery.yaml @@ -1,9 +1,13 @@ -# Mail-client autodiscovery for dezky.eu — routes ONLY the discovery paths -# through Traefik to host-Stalwart's HTTP listener (10.42.0.1:8080): +# Mail-client autodiscovery for dezky.eu — routes ONLY the discovery paths: # -# autodiscover.dezky.eu POST /autodiscover/autodiscover.xml (Outlook) +# autodiscover.dezky.eu POST /autodiscover/autodiscover.xml (Outlook + EAS) # autoconfig.dezky.eu GET /mail/config-v1.1.xml (Thunderbird) # +# autodiscover.dezky.eu goes to the zpush dispatcher (zpush.yaml), which +# answers mobilesync-schema requests itself (Exchange ActiveSync devices) +# and proxies outlook-schema (mail) requests through to host-Stalwart's +# HTTP listener unchanged. autoconfig.dezky.eu still hits Stalwart directly. +# # Everything else on these hostnames (Stalwart's /admin, /login, /jmap …) # falls through to Traefik's 404 — the management surface stays internal. # No HTTPS-redirect middleware on purpose: Thunderbird probes plain HTTP and @@ -44,6 +48,59 @@ subsets: - name: http port: 8080 --- +# IMAPS + SMTPS for the zpush EAS gateway (zpush.yaml) — same +# selectorless-Service-to-host pattern as stalwart-http above. Host-Stalwart +# exposes ONLY the implicit-TLS variants (993/465; no plain 143/587 — +# verified on node1 2026-06-12), all bound to the host wildcard, so the +# cni0 gateway address reaches them just like :8080. +apiVersion: v1 +kind: Service +metadata: + name: stalwart-imaps + labels: + app.kubernetes.io/name: stalwart-imaps + app.kubernetes.io/part-of: dezky +spec: + ports: + - name: imaps + port: 993 + targetPort: 993 +--- +apiVersion: v1 +kind: Endpoints +metadata: + name: stalwart-imaps +subsets: + - addresses: + - ip: 10.42.0.1 + ports: + - name: imaps + port: 993 +--- +apiVersion: v1 +kind: Service +metadata: + name: stalwart-smtps + labels: + app.kubernetes.io/name: stalwart-smtps + app.kubernetes.io/part-of: dezky +spec: + ports: + - name: smtps + port: 465 + targetPort: 465 +--- +apiVersion: v1 +kind: Endpoints +metadata: + name: stalwart-smtps +subsets: + - addresses: + - ip: 10.42.0.1 + ports: + - name: smtps + port: 465 +--- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: @@ -62,13 +119,14 @@ spec: - host: autodiscover.dezky.eu http: paths: - # Outlook probes both capitalizations. + # Outlook probes both capitalizations. zpush dispatches by schema: + # mobilesync → Z-Push, outlook (mail) → proxied to Stalwart. - path: /autodiscover/autodiscover.xml pathType: Exact - backend: { service: { name: stalwart-http, port: { number: 8080 } } } + backend: { service: { name: zpush, port: { number: 80 } } } - path: /Autodiscover/Autodiscover.xml pathType: Exact - backend: { service: { name: stalwart-http, port: { number: 8080 } } } + backend: { service: { name: zpush, port: { number: 80 } } } - host: autoconfig.dezky.eu http: paths: diff --git a/infrastructure/production/fleet/apps/zpush.yaml b/infrastructure/production/fleet/apps/zpush.yaml new file mode 100644 index 0000000..14270ac --- /dev/null +++ b/infrastructure/production/fleet/apps/zpush.yaml @@ -0,0 +1,150 @@ +# zpush — Exchange ActiveSync gateway (Z-Push, AGPLv3) in front of host- +# Stalwart. Gives "Exchange" accounts on iOS/Android native Mail/Calendar +# two-way mail+calendar+contacts sync. EAS 14.1: covers native mobile +# clients, NOT the Outlook mobile app (requires EAS 16.1) and not new +# Outlook for Windows (no EAS at all). Credentials pass through to Stalwart +# per-request (mailbox password or app password) — the pod stores only sync +# state, which is disposable (devices resync after a wipe). +# +# replicas MUST stay 1: Z-Push's FILE state machine is not multi-writer-safe +# (same constraint family as the portal/operator session pinning). +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: zpush-state + namespace: dezky-apps + labels: + app.kubernetes.io/name: zpush + app.kubernetes.io/part-of: dezky +spec: + accessModes: [ReadWriteOnce] + storageClassName: local-path + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: zpush + namespace: dezky-apps + labels: + app.kubernetes.io/name: zpush + app.kubernetes.io/part-of: dezky +spec: + replicas: 1 + # Recreate, not RollingUpdate: the state PVC is RWO and the state machine + # must never have two writers. + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: zpush + template: + metadata: + labels: + app.kubernetes.io/name: zpush + spec: + containers: + - name: zpush + # CI pins this to the commit SHA at deploy time; :latest is the fallback. + image: git.lastcloud.io/ronnibaslund/dezky/zpush:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 80 + env: + # Host-Stalwart reached via the selectorless Services pinning the + # cni0 gateway address (see mail-autodiscovery.yaml). + - name: CALDAV_SERVER + value: stalwart-http.dezky-apps.svc.cluster.local + - name: CALDAV_PORT + value: "8080" + # Host-Stalwart only exposes implicit-TLS IMAPS/SMTPS (no plain + # 143/587) — hence /ssl with novalidate-cert (the cert names + # mail.dezky.eu, not the cluster service) and the ssl:// prefix. + - name: IMAP_SERVER + value: stalwart-imaps.dezky-apps.svc.cluster.local + - name: IMAP_PORT + value: "993" + - name: IMAP_OPTIONS + value: /ssl/novalidate-cert + - name: SMTP_SERVER + value: ssl://stalwart-smtps.dezky-apps.svc.cluster.local + - name: SMTP_PORT + value: "465" + # Hostname EAS autodiscover responses point devices at. + - name: ZPUSH_HOST + value: mail.dezky.eu + # Outlook-schema (mail) autodiscover POSTs proxied to Stalwart. + - name: MAIL_AUTODISCOVER_UPSTREAM + value: http://stalwart-http.dezky-apps.svc.cluster.local:8080 + volumeMounts: + - name: state + mountPath: /var/lib/z-push + resources: + requests: + cpu: 100m + memory: 192Mi + limits: + memory: 512Mi + readinessProbe: + tcpSocket: + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + livenessProbe: + tcpSocket: + port: http + initialDelaySeconds: 15 + periodSeconds: 30 + volumes: + - name: state + persistentVolumeClaim: + claimName: zpush-state +--- +apiVersion: v1 +kind: Service +metadata: + name: zpush + namespace: dezky-apps + labels: + app.kubernetes.io/name: zpush +spec: + selector: + app.kubernetes.io/name: zpush + ports: + - name: http + port: 80 + targetPort: http +--- +# EAS endpoint on mail.dezky.eu. Path-scoped: everything else on the host +# (CalDAV well-knowns, /dav) keeps routing to Stalwart via the mail-dav +# Ingress. NO redirect middleware — EAS is POST-heavy and devices don't +# follow 301s (same reasoning as the autodiscover Ingress). No cert-manager +# annotation either: the mail-dav Ingress already owns the Certificate that +# keeps mail-dezky-eu-traefik-tls fresh; we only reference the secret. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: zpush-eas + namespace: dezky-apps + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure +spec: + ingressClassName: traefik + tls: + - hosts: + - mail.dezky.eu + secretName: mail-dezky-eu-traefik-tls + rules: + - host: mail.dezky.eu + http: + paths: + - path: /Microsoft-Server-ActiveSync + pathType: Prefix + backend: + service: + name: zpush + port: + number: 80 diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 73c2373..ca0d9d7 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -181,6 +181,7 @@ HOSTS_ENTRIES=( "operator.dezky.local" "auth.dezky.local" "mail.dezky.local" + "autodiscover.dezky.local" "files.dezky.local" "office.dezky.local" "meet.dezky.local" diff --git a/services/zpush/Dockerfile b/services/zpush/Dockerfile new file mode 100644 index 0000000..a13b92b --- /dev/null +++ b/services/zpush/Dockerfile @@ -0,0 +1,76 @@ +# dezky EAS gateway — Z-Push (AGPLv3) wrapping Stalwart in Exchange +# ActiveSync so "Exchange" accounts on iOS/Android native Mail/Calendar get +# two-way mail + calendar + contacts sync. Z-Push talks to Stalwart with the +# client's own Basic credentials (mailbox password or app password) over +# IMAP/CalDAV/CardDAV — this container stores no secrets, only sync state. +# +# Z-Push 2.6.x speaks EAS up to 14.1. That covers native mobile clients but +# NOT the Outlook mobile app (Microsoft enforces EAS >= 16.1 there since +# March 2026) and not new Outlook for Windows (no EAS at all). See +# LICENSE-NOTES.md for the AGPL source-offer obligation. + +ARG ZPUSH_VERSION=2.6.4 + +FROM alpine/git AS source +ARG ZPUSH_VERSION +RUN git clone --depth 1 --branch ${ZPUSH_VERSION} \ + https://github.com/EGroupware/z-push.git /z-push + +# php:8.2 — the imap extension lives in PHP core through 8.3 and moved to +# PECL in 8.4; stay on a version where docker-php-ext-install still works. +# Pinned to the bookworm base: Debian trixie dropped the (upstream-dead) +# uw-imap libc-client packages the imap extension compiles against. +FROM php:8.2-apache-bookworm +ARG ZPUSH_VERSION + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libc-client2007e-dev libkrb5-dev libicu-dev \ + && docker-php-ext-configure imap --with-kerberos --with-imap-ssl \ + && docker-php-ext-install -j"$(nproc)" imap intl sysvshm sysvsem \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=source /z-push/src/ /usr/share/z-push/ + +# Main config: keep the 50+ upstream defaults, patch only what we change. +# The greps make the build fail loudly if an upstream config rename ever +# makes a sed miss instead of shipping a silently unconfigured gateway. +# (USE_FULLEMAIL_FOR_LOGIN is already true in the EGroupware fork.) +RUN sed -i \ + -e "s|define('TIMEZONE', '');|define('TIMEZONE', 'Europe/Copenhagen');|" \ + -e "s|define('BACKEND_PROVIDER', '');|define('BACKEND_PROVIDER', 'BackendCombined');|" \ + /usr/share/z-push/config.php \ + && grep -q "define('BACKEND_PROVIDER', 'BackendCombined')" /usr/share/z-push/config.php \ + && grep -q "define('USE_FULLEMAIL_FOR_LOGIN', true)" /usr/share/z-push/config.php + +# PHP 8 fix (upstream Z-Push 2.6.4 bug): sem_get()/shm_attach() return +# objects instead of resources since PHP 8.0 and this debug log line +# sprintf's them — a fatal TypeError on every request. The rest of the +# provider only compares the handles against false, which is PHP 8-safe. +RUN sed -i \ + 's|sprintf("%s(): Initialized mutexid %s and memid %s.", $class, $this->mutexid, $this->memid)|sprintf("%s(): Initialized shared memory mutex and segment.", $class)|' \ + /usr/share/z-push/backend/ipcsharedmemory/ipcsharedmemoryprovider.php \ + && grep -q "Initialized shared memory mutex and segment" \ + /usr/share/z-push/backend/ipcsharedmemory/ipcsharedmemoryprovider.php + +# Backend + autodiscover configs are small files — full replacements. +COPY config/caldav.config.php /usr/share/z-push/backend/caldav/config.php +COPY config/carddav.config.php /usr/share/z-push/backend/carddav/config.php +COPY config/imap.config.php /usr/share/z-push/backend/imap/config.php +COPY config/combined.config.php /usr/share/z-push/backend/combined/config.php +COPY config/autodiscover.config.php /usr/share/z-push/autodiscover/config.php + +# Schema-sniffing autodiscover dispatcher (mobilesync → Z-Push, outlook +# schema → proxied to Stalwart). Lives inside autodiscover/ because +# autodiscover.php resolves its requires relative to that directory. +COPY autodiscover-router.php /usr/share/z-push/autodiscover/router.php + +COPY apache/zpush.conf /etc/apache2/conf-available/zpush.conf +COPY php/zpush.ini /usr/local/etc/php/conf.d/zpush.ini +RUN a2enconf zpush \ + && mkdir -p /var/lib/z-push/state /var/log/z-push \ + && chown -R www-data:www-data /var/lib/z-push /var/log/z-push \ + && echo "${ZPUSH_VERSION}" > /usr/share/z-push/DEZKY_PINNED_VERSION + +VOLUME /var/lib/z-push +EXPOSE 80 diff --git a/services/zpush/LICENSE-NOTES.md b/services/zpush/LICENSE-NOTES.md new file mode 100644 index 0000000..ca0e1c1 --- /dev/null +++ b/services/zpush/LICENSE-NOTES.md @@ -0,0 +1,37 @@ +# Z-Push licensing notes (AGPLv3) + +This image bundles [Z-Push](https://github.com/EGroupware/z-push) (EGroupware +fork), licensed under the **GNU Affero General Public License v3**. + +## Why this doesn't affect dezky's own code + +Z-Push runs as an **isolated network service**. dezky components talk to it +only over network protocols (HTTPS for EAS clients; Z-Push itself talks to +Stalwart over IMAP/CalDAV/CardDAV). Nothing links against Z-Push code, so the +AGPL's copyleft does not extend to the portal, platform-api, or any other +dezky service. + +## What the AGPL obliges us to do + +Because users interact with Z-Push over a network, AGPL §13 requires that we +offer them the **corresponding source of the exact version we run, including +our modifications**. Our modifications are: + +- the pinned upstream version (see `ZPUSH_VERSION` in the `Dockerfile` and + `/usr/share/z-push/DEZKY_PINNED_VERSION` in the image) +- the replaced config files in `config/` +- the autodiscover dispatcher `autodiscover-router.php` +- two sed-patched values in the main `config.php` (TIMEZONE, + BACKEND_PROVIDER — see `Dockerfile`) +- a one-line PHP 8 fix in + `backend/ipcsharedmemory/ipcsharedmemoryprovider.php` (a debug sprintf of + SysV handles that are objects, not resources, since PHP 8.0 — see + `Dockerfile`) + +Everything lives self-contained in this directory. **Compliance action:** the +client-setup/help page that documents Exchange account setup must link to +(a) the upstream tag on GitHub and (b) this directory's contents (or a +published copy of them). Keep that link in step with `ZPUSH_VERSION` bumps. + +Z-Push's own license text ships in the image at `/usr/share/z-push` (see the +upstream `LICENSE` file in the cloned repository root). diff --git a/services/zpush/apache/zpush.conf b/services/zpush/apache/zpush.conf new file mode 100644 index 0000000..f776f37 --- /dev/null +++ b/services/zpush/apache/zpush.conf @@ -0,0 +1,21 @@ +# Z-Push EAS endpoints. EAS device ids arrive URL-encoded in the query +# string and may contain encoded slashes. +AllowEncodedSlashes On + +Alias /Microsoft-Server-ActiveSync /usr/share/z-push/index.php + +# All capitalizations clients probe. The router answers mobilesync-schema +# requests itself and proxies outlook-schema (mail) requests to Stalwart. +Alias /autodiscover/autodiscover.xml /usr/share/z-push/autodiscover/router.php +Alias /Autodiscover/Autodiscover.xml /usr/share/z-push/autodiscover/router.php +Alias /AutoDiscover/AutoDiscover.xml /usr/share/z-push/autodiscover/router.php + + + Options -Indexes + AllowOverride None + Require all granted + + +# Expose the raw Authorization header to PHP so the autodiscover proxy can +# forward it upstream verbatim (mod_php only decodes Basic into PHP_AUTH_*). +SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 diff --git a/services/zpush/autodiscover-router.php b/services/zpush/autodiscover-router.php new file mode 100644 index 0000000..80f907c --- /dev/null +++ b/services/zpush/autodiscover-router.php @@ -0,0 +1,60 @@ + $_SERVER['REQUEST_METHOD'], + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, +)); +$response = curl_exec($ch); + +if ($response === false) { + http_response_code(502); + header('Content-Type: text/plain'); + echo "autodiscover upstream unreachable\n"; + curl_close($ch); + exit; +} + +$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); +$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); +curl_close($ch); + +http_response_code($status ?: 502); +header('Content-Type: ' . ($contentType ?: 'application/xml')); +echo $response; diff --git a/services/zpush/config/autodiscover.config.php b/services/zpush/config/autodiscover.config.php new file mode 100644 index 0000000..c3b1f3a --- /dev/null +++ b/services/zpush/config/autodiscover.config.php @@ -0,0 +1,32 @@ +/Microsoft-Server-ActiveSync. +define('ZPUSH_HOST', getenv('ZPUSH_HOST') ?: 'mail.dezky.eu'); + +define('TIMEZONE', 'Europe/Copenhagen'); +define('BASE_PATH', dirname($_SERVER['SCRIPT_FILENAME']) . '/'); + +// Devices authenticate as the full mail address — matches the main config +// and what the portal tells users. +define('USE_FULLEMAIL_FOR_LOGIN', true); +define('AUTODISCOVER_LOGIN_TYPE', AUTODISCOVER_LOGIN_EMAIL); + +// Autodiscover authenticates the requesting user through the same backend +// stack as sync does (ZPush::GetBackend() reads this constant from THIS +// file, not from the main config.php). +define('BACKEND_PROVIDER', 'BackendCombined'); + +define('LOGBACKEND', 'filelog'); +define('LOGFILEDIR', '/var/log/z-push/'); +define('LOGFILE', LOGFILEDIR . 'autodiscover.log'); +define('LOGERRORFILE', LOGFILEDIR . 'autodiscover-error.log'); +define('LOGLEVEL', LOGLEVEL_INFO); +define('LOGUSERLEVEL', LOGLEVEL_DEVICEID); + +// The logger passes this global straight to SetSpecialLogUsers(array) — +// it must exist even when no per-user WBXML debugging is wanted. +$specialLogUsers = array(); diff --git a/services/zpush/config/caldav.config.php b/services/zpush/config/caldav.config.php new file mode 100644 index 0000000..7e0df2f --- /dev/null +++ b/services/zpush/config/caldav.config.php @@ -0,0 +1,23 @@ +/ where +// is the Stalwart account name — the LOCAL PART of the mail +// address (platform-api creates mailboxes with name=localPart, see +// services/platform-api/src/integrations/stalwart.client.ts). Logins are +// full emails (USE_FULLEMAIL_FOR_LOGIN), so the path uses %l, Z-Push's +// local-part placeholder, not %u. + +define('CALDAV_PROTOCOL', 'http'); +define('CALDAV_SERVER', getenv('CALDAV_SERVER') ?: 'stalwart'); +define('CALDAV_PORT', getenv('CALDAV_PORT') ?: '8080'); +define('CALDAV_PATH', '/dav/cal/%l/'); + +// Stalwart auto-creates a calendar named "default" for every account. +define('CALDAV_PERSONAL', 'default'); + +// sync-collection REPORT (RFC 6578). Start with the safe full-comparison +// mode; flip to true once proven against Stalwart's DAV implementation. +define('CALDAV_SUPPORTS_SYNC', false); +define('CALDAV_MAX_SYNC_PERIOD', 2147483647); diff --git a/services/zpush/config/carddav.config.php b/services/zpush/config/carddav.config.php new file mode 100644 index 0000000..1bce1be --- /dev/null +++ b/services/zpush/config/carddav.config.php @@ -0,0 +1,21 @@ +/ with +// an auto-created address book named "default". + +define('CARDDAV_PROTOCOL', 'http'); +define('CARDDAV_SERVER', getenv('CARDDAV_SERVER') ?: (getenv('CALDAV_SERVER') ?: 'stalwart')); +define('CARDDAV_PORT', getenv('CARDDAV_PORT') ?: '8080'); +define('CARDDAV_PATH', '/dav/card/%l/'); +define('CARDDAV_DEFAULT_PATH', '/dav/card/%l/default/'); + +// CARDDAV_GAL_PATH deliberately NOT defined — no global address list in +// Stalwart; the backend disables GAL search when the constant is absent. +define('CARDDAV_GAL_MIN_LENGTH', 5); + +define('CARDDAV_CONTACTS_FOLDER_NAME', '%u Addressbook'); +// Safe full-comparison mode first — same reasoning as CALDAV_SUPPORTS_SYNC. +define('CARDDAV_SUPPORTS_SYNC', false); +define('CARDDAV_SUPPORTS_FN_SEARCH', false); +define('CARDDAV_URL_VCARD_EXTENSION', '.vcf'); diff --git a/services/zpush/config/combined.config.php b/services/zpush/config/combined.config.php new file mode 100644 index 0000000..643fce8 --- /dev/null +++ b/services/zpush/config/combined.config.php @@ -0,0 +1,44 @@ + array( + 'i' => array('name' => 'BackendIMAP'), + 'c' => array('name' => 'BackendCalDAV'), + 'd' => array('name' => 'BackendCardDAV'), + ), + 'delimiter' => '/', + 'folderbackend' => array( + SYNC_FOLDER_TYPE_INBOX => 'i', + SYNC_FOLDER_TYPE_DRAFTS => 'i', + SYNC_FOLDER_TYPE_WASTEBASKET => 'i', + SYNC_FOLDER_TYPE_SENTMAIL => 'i', + SYNC_FOLDER_TYPE_OUTBOX => 'i', + SYNC_FOLDER_TYPE_OTHER => 'i', + SYNC_FOLDER_TYPE_USER_MAIL => 'i', + SYNC_FOLDER_TYPE_APPOINTMENT => 'c', + SYNC_FOLDER_TYPE_USER_APPOINTMENT => 'c', + SYNC_FOLDER_TYPE_TASK => 'c', + SYNC_FOLDER_TYPE_USER_TASK => 'c', + SYNC_FOLDER_TYPE_CONTACT => 'd', + SYNC_FOLDER_TYPE_USER_CONTACT => 'd', + // No notes/journal store in Stalwart — let mail own the rest + // so folder creation never lands on a DAV backend by surprise. + SYNC_FOLDER_TYPE_NOTE => 'i', + SYNC_FOLDER_TYPE_USER_NOTE => 'i', + SYNC_FOLDER_TYPE_JOURNAL => 'i', + SYNC_FOLDER_TYPE_USER_JOURNAL => 'i', + SYNC_FOLDER_TYPE_UNKNOWN => 'i', + ), + 'rootcreatefolderbackend' => 'i', + ); + } +} diff --git a/services/zpush/config/imap.config.php b/services/zpush/config/imap.config.php new file mode 100644 index 0000000..d52b7b3 --- /dev/null +++ b/services/zpush/config/imap.config.php @@ -0,0 +1,48 @@ + getenv('SMTP_SERVER') ?: (getenv('IMAP_SERVER') ?: 'stalwart'), + 'port' => (int) (getenv('SMTP_PORT') ?: 587), + 'auth' => true, + 'username' => 'imap_username', + 'password' => 'imap_password', + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, +); diff --git a/services/zpush/php/zpush.ini b/services/zpush/php/zpush.ini new file mode 100644 index 0000000..077880a --- /dev/null +++ b/services/zpush/php/zpush.ini @@ -0,0 +1,11 @@ +; Z-Push runtime tuning. EAS Ping requests are long-poll (Z-Push's own +; SCRIPT_TIMEOUT handles per-command limits) and Sync payloads can carry +; attachments, hence the raised execution time and body sizes. +memory_limit = 256M +max_execution_time = 900 +post_max_size = 32M +upload_max_filesize = 32M +log_errors = On +error_log = /dev/stderr +display_errors = Off +expose_php = Off