Thunderbird asks <domain>/.well-known/autoconfig, Outlook POSTs to <domain>/Autodiscover, and both fell into portal's catch-all, which answers 200 with an HTML page - so a client got markup where it expected XML and went back to guessing. Both prefixes now go to Stalwart, which already generates the answers, templated on the address asked about. No static copy to drift out of date and no hostname of their own to certify. Host is rewritten to the name Stalwart has always answered for, and all three spellings of /Autodiscover are matched because Outlook is not consistent about it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
149 lines
6.6 KiB
YAML
149 lines
6.6 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.15
|
|
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}
|
|
}
|
|
}
|
|
|
|
@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
|