/.well-known/caldav answered 307 to https://<domain>/dav/cal, and that path was portal's catch-all - so a client that did the discovery correctly followed it into an HTML page. Stalwart builds the Location from the forwarded host rather than the Host this route rewrites, so rather than fight that, /dav/* is served here too and the client stays on one origin for the whole exchange. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
166 lines
7.5 KiB
YAML
166 lines
7.5 KiB
YAML
name: Deploy instance
|
|
|
|
# This content repo owns its portal instance: which portal version runs,
|
|
# the service env, the systemd unit, and the Caddy route. The portal repo
|
|
# only publishes versioned release artifacts (uhhm/portal's Publish
|
|
# workflow); PORTAL_RELEASE below pins the one this site runs.
|
|
#
|
|
# Rolling out a new portal version = bumping PORTAL_RELEASE (a commit,
|
|
# so every rollout is auditable and revertable). Content-only changes
|
|
# never come through here - lint-and-reload hot-swaps those into the
|
|
# running instance over NATS.
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
push:
|
|
branches: [main]
|
|
paths:
|
|
- .gitea/workflows/deploy.yml
|
|
|
|
env:
|
|
PORTAL_RELEASE: v0.6.21
|
|
INSTANCE: uhhm-portal
|
|
|
|
jobs:
|
|
deploy:
|
|
runs-on: bare
|
|
steps:
|
|
# Instance config comes from THIS repo's Actions variables/secrets
|
|
# (Settings -> Actions) - not the portal repo's. Guard before
|
|
# touching anything on the host: a missing secret must fail the
|
|
# run, not silently write an empty value into the live env file.
|
|
- name: Check instance secrets are configured
|
|
run: |
|
|
set -eu
|
|
[ -n "${{ secrets.NATS_URL }}" ] || { echo "missing secret NATS_URL"; exit 1; }
|
|
[ -n "${{ secrets.OAUTH2_CLIENT_SECRET }}" ] || { echo "missing secret OAUTH2_CLIENT_SECRET"; exit 1; }
|
|
[ -n "${{ secrets.AUTOMATION_READ_TOKEN }}" ] || { echo "missing secret AUTOMATION_READ_TOKEN"; exit 1; }
|
|
[ -n "${{ secrets.PORTAL_GITEA_API_TOKEN }}" ] || { echo "missing secret PORTAL_GITEA_API_TOKEN"; exit 1; }
|
|
|
|
# uhhm/portal is public, so the asset download is anonymous. The
|
|
# app@ template's ExecStart is /srv/app/%i/current/%i - the shipped
|
|
# binary is named "portal", so link the instance name to it.
|
|
- name: Ship pinned portal release
|
|
run: |
|
|
set -euo pipefail
|
|
api="${{ github.server_url }}/api/v1/repos/uhhm/portal"
|
|
url=$(curl -sf "$api/releases/tags/$PORTAL_RELEASE" | jq -r '.assets[0].browser_download_url')
|
|
rel="/srv/app/$INSTANCE/releases/$PORTAL_RELEASE"
|
|
rm -rf "$rel"
|
|
mkdir -p "$rel"
|
|
curl -sfL "$url" | tar -xz -C "$rel"
|
|
ln -sfn portal "$rel/$INSTANCE"
|
|
|
|
- name: Write service env
|
|
run: |
|
|
cat > /etc/app/$INSTANCE.env <<EOF
|
|
NATS_URL=${{ secrets.NATS_URL }}
|
|
KANIDM_URL=${{ vars.KANIDM_URL }}
|
|
OAUTH2_CLIENT_ID=${{ vars.OAUTH2_CLIENT_ID }}
|
|
OAUTH2_CLIENT_SECRET=${{ secrets.OAUTH2_CLIENT_SECRET }}
|
|
# Onboarding from a desk (portal 0.3.40+, `invite:` alternatives): a
|
|
# Kanidm service-account token in idm_people_on_boarding. Empty
|
|
# means invites are not configured on this instance.
|
|
KANIDM_API_TOKEN=${{ secrets.KANIDM_API_TOKEN }}
|
|
PUBLIC_URL=${{ vars.PUBLIC_URL }}
|
|
COOKIE_SECURE=true
|
|
# This repo is its own instance's content source.
|
|
CONTENT_REPO=${{ github.server_url }}/${{ github.repository }}
|
|
CONTENT_BRANCH=main
|
|
SITE_NAME=${{ vars.SITE_NAME }}
|
|
LEPTOS_SITE_ADDR=0.0.0.0:3010
|
|
# The release tarball carries the site bundle at site/ (no
|
|
# target/ prefix), so override Cargo.toml's build-time path.
|
|
LEPTOS_SITE_ROOT=site
|
|
# Portal ships content-hashed pkg files (portal.<hash>.js) with
|
|
# a hash.txt in the site root; this makes the server reference
|
|
# them, so a stale cached bundle can never pair with new wasm.
|
|
LEPTOS_HASH_FILES=true
|
|
AUTOMATION_READ_TOKEN=${{ secrets.AUTOMATION_READ_TOKEN }}
|
|
# Read-only (read:user,read:repository,read:organization),
|
|
# used only by the Gitea resource sources (portal
|
|
# src/resource.rs) - content loading stays anonymous.
|
|
GITEA_API_TOKEN=${{ secrets.PORTAL_GITEA_API_TOKEN }}
|
|
EOF
|
|
|
|
# Activate only after the release and env are fully written, so a
|
|
# failed download or missing config never takes the site down.
|
|
#
|
|
# No sudo: the runner's unit sets NoNewPrivileges=yes - systemctl
|
|
# talks to PID1 over D-Bus, authorized by the polkit rule scoped
|
|
# to deploy-runner + the app@* unit pattern.
|
|
# enable: the unit must come back after a host reboot (2026-08-30 a
|
|
# reboot left both portal instances down - deploys had only ever
|
|
# started them). Needs the manage-unit-files polkit grant; until
|
|
# that's on the host the enable is reported and skipped, never a
|
|
# failed deploy.
|
|
- name: Activate and restart
|
|
run: |
|
|
ln -sfn "/srv/app/$INSTANCE/releases/$PORTAL_RELEASE" /srv/app/$INSTANCE/current
|
|
systemctl enable app@$INSTANCE.service \
|
|
|| echo "::warning::could not enable app@$INSTANCE (polkit) - unit will not survive a reboot"
|
|
systemctl restart app@$INSTANCE.service
|
|
|
|
# Apex and www are separate cookie scopes (no shared Domain
|
|
# attribute on the session cookie), but Kanidm's redirect_uri is
|
|
# fixed to PUBLIC_URL - redirecting www to the naked domain keeps
|
|
# every visit on one canonical host. The runner is in the docker
|
|
# group, so no sudo here either.
|
|
- name: Update Caddy routing
|
|
run: |
|
|
cat > /etc/caddy/services.d/$INSTANCE.caddy <<'EOF'
|
|
www.{$DOMAIN} {
|
|
redir https://{$DOMAIN}{uri} permanent
|
|
}
|
|
|
|
{$DOMAIN} {
|
|
# Mail clients look for their configuration on the bare domain -
|
|
# Thunderbird at /.well-known/autoconfig, Outlook by POSTing to
|
|
# /Autodiscover. Stalwart already generates both, templated on the
|
|
# address asked about, so these two paths go to it instead of being
|
|
# copied into this repo: one source of truth, and no second
|
|
# hostname to certify. Host is rewritten to the name Stalwart
|
|
# answers for; it never sees a request for the bare domain
|
|
# otherwise. (MTA-STS cannot be served here - RFC 8461 requires
|
|
# mta-sts.<domain> - so it keeps its own record and route.)
|
|
handle /.well-known/autoconfig/* {
|
|
reverse_proxy stalwart:8080 {
|
|
header_up Host mail.{$DOMAIN}
|
|
}
|
|
}
|
|
|
|
# CalDAV and CardDAV discovery (RFC 6764): a client looks for
|
|
# these on the domain its address is in, and Stalwart answers
|
|
# each with a 307 to the collection. iOS uses these even though
|
|
# Apple Mail ignores autoconfig and Autodiscover entirely.
|
|
# The collections the two redirects above point at. Stalwart builds
|
|
# those Location headers from the forwarded host, not from the Host
|
|
# this route rewrites, so they land on the bare domain - which means
|
|
# the collections have to be reachable here too, or a client follows
|
|
# a 307 into portal's catch-all. Keeping them on one host is the
|
|
# better answer anyway: the client never changes origin.
|
|
@dav path /.well-known/caldav /.well-known/carddav /dav/*
|
|
handle @dav {
|
|
reverse_proxy stalwart:8080 {
|
|
header_up Host mail.{$DOMAIN}
|
|
}
|
|
}
|
|
|
|
@autodiscover path /Autodiscover/* /autodiscover/* /AutoDiscover/*
|
|
handle @autodiscover {
|
|
reverse_proxy stalwart:8080 {
|
|
header_up Host mail.{$DOMAIN}
|
|
}
|
|
}
|
|
|
|
handle {
|
|
reverse_proxy host.docker.internal:3010
|
|
}
|
|
|
|
log {
|
|
output file /var/log/caddy/www.log
|
|
}
|
|
}
|
|
EOF
|
|
docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|