11 Commits

Author SHA1 Message Date
stefan d6a484c347 fix: update Keycloak configuration and Docker healthcheck improvements
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Successful in 6m48s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Successful in 6m40s
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Successful in 1m44s
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Successful in 1m31s
- Enabled `directAccessGrants` for `frontend-client` in `meldestelle-realm.json` to support ROPC login flow.
- Strengthened admin credentials in realm configuration to meet password policy requirements.
- Upgraded Keycloak to `26.5.5` with updated Docker healthcheck logic:
  - Replaced `curl` with bash `/dev/tcp` for compatibility with `ubi9-micro` image.
  - Switched health endpoint from `/ready` to `/live` for single-node use.
  - Adjusted healthcheck timings (`start_period`, `timeout`, `interval`) for smoother startup.
- Removed deprecated v1 hostname parameter `KC_HOSTNAME_STRICT_HTTPS`.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-09 15:51:42 +01:00
stefan 4ae11e6668 cleanup: remove unused GitHub Actions workflows and migrate PR Guard to Gitea
Deleted unused `.github` workflows and configurations, including obsolete CI/CD pipelines and PR templates, which were no longer executed after the migration to Gitea. Migrated the `PR Guard` workflow for hardcoded version checks to `.gitea/workflows/pr-guard.yaml`. Verified functional workflows in Gitea (e.g., Docker publish). Documented changes and rationale in session logs.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-09 15:51:42 +01:00
stefan 23e08403f1 docs: finalize and verify Ping Service tracer bullet implementation and sync fix
Updated `MASTER_ROADMAP_2026_Q1.md` and session logs to reflect completion of the Ping Service tracer bullet. Verified the end-to-end stack, including frontend, backend, and Gateway integration. Fixed query parameter mismatch in `PingApiKoinClient.syncPings()` (`lastSyncTimestamp` → `since`) for proper Delta-Sync functionality. Marked related roadmap tasks as completed.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-09 15:51:42 +01:00
stefan 5bc2538aab docs: finalize and verify Redis-to-Valkey migration and Keycloak hardening
Updated `MASTER_ROADMAP_2026_Q1.md` to reflect completed Redis-to-Valkey migration and Keycloak production configuration. Adjusted property paths in `base-application.yaml` and session logs with corrected environment variable names. Verified end-to-end Docker stability and roadmap alignment.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-09 15:51:42 +01:00
stefan 69c1123586 docs: verify and complete Gateway CircuitBreaker updates in system hardening roadmap
Marked Gateway CircuitBreaker tasks as completed and verified in archived roadmaps and session logs. Confirmed fixes for `ClassNotFoundException` via Spring Cloud 2025.0.1 and proper configuration of Resilience4j with fallback and metrics integration.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-09 15:51:42 +01:00
stefan 15e253365a fix: wrap browser opening in withContext(Dispatchers.IO) for JVM OIDC callback
Ensures proper threading for desktop actions, preventing potential blocking issues during browser invocation.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-09 15:51:42 +01:00
stefan b9a433f772 feat: implement OIDC PKCE flow for Keycloak login with frontend-client
Completed OIDC Authorization Code Flow with PKCE (S256) for JS and JVM platforms.
- Added `launchOidcFlow`, `consumePendingOidcCallback`, and `getOidcRedirectUri` with platform-specific implementations.
- Integrated SHA-256 and Base64URL helpers for PKCE.
- Updated `LoginViewModel` with OIDC logic (key handling, token exchange, state validation).
- Enhanced `LoginScreen` with an OIDC login button and loading spinner.
- Verified implementation with system hardening roadmap tasks.

Includes browser redirects for JS, localhost HTTP callback for JVM, and built-in Keycloak URL construction.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-09 15:51:42 +01:00
stefan 2db3fd82c5 docs: finalize and verify Zipkin integration in system hardening roadmap
Marked Zipkin integration tasks as completed and verified in archived roadmaps. Documented fixes for gateway propagation type (`w3c` → `b3`) and Zipkin endpoint configuration. Removed redundant dependencies in `build.gradle.kts` and updated related session logs.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-09 15:51:42 +01:00
stefan 218c7fa13a docs: verify and complete Micrometer upgrade in system hardening roadmap
Marked Micrometer 1.16.1 upgrade task as completed in archived roadmaps and session logs. Verified compatibility for Java 25 and updated notes to reflect no changes required in `libs.versions.toml`.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-09 15:51:42 +01:00
stefan 440ab4b141 docs: finalize Keycloak hardening and validate updated Realm config
Documented the completed Keycloak hardening steps, including PKCE S256 support, CORS security improvements, strengthened password policy, and logout redirect URI configurations. Verified and updated the `meldestelle-realm.json` and roadmap to reflect these changes.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-09 15:51:42 +01:00
stefan f47e5f6415 docs: update system hardening roadmap with completed tasks and verification notes
Added progress updates for completed roadmap tasks, including dependency management (Spring Cloud, SQLDelight, core-utils refactoring, Redis-to-Valkey migration). Added verification notes with dates, ensuring alignment with current implementation status and next steps.
2026-03-09 15:51:42 +01:00
35 changed files with 1299 additions and 869 deletions
+2 -2
View File
@@ -49,12 +49,12 @@ SPRING_DATA_VALKEY_PORT=6379
SPRING_DATA_VALKEY_PASSWORD=valkey-password
# --- KEYCLOAK ---
KEYCLOAK_IMAGE_TAG=26.4
KEYCLOAK_IMAGE_TAG=26.5.5
KC_HEAP_MIN=512M
KC_HEAP_MAX=1024M
# Lokale Entwicklung: start-dev (kein Pre-Build nötig, kein --optimized)
# Server/Produktion: start --optimized --import-realm (nutzt das pre-built Registry-Image)
KC_COMMAND=start-dev --import-realm
KC_COMMAND=start --optimized --import-realm
KC_ADMIN_USERNAME=kc-admin
KC_ADMIN_PASSWORD=kc-password
KC_DB=postgres
@@ -1,9 +1,7 @@
name: PR Guard
on:
pull_request:
branches: [ "**" ]
jobs:
no-hardcoded-versions:
runs-on: ubuntu-latest
-38
View File
@@ -1,38 +0,0 @@
{
"ignorePatterns": [
{
"pattern": "^http://localhost"
},
{
"pattern": "^https://localhost"
},
{
"pattern": "^http://127.0.0.1"
},
{
"pattern": "^https://127.0.0.1"
},
{
"pattern": "^XXXX-filename\\.md$"
}
],
"replacementPatterns": [
{
"pattern": "^/",
"replacement": "{{BASEURL}}/"
}
],
"httpHeaders": [
{
"urls": ["https://github.com"],
"headers": {
"Accept": "text/html"
}
}
],
"timeout": "20s",
"retryOn429": true,
"retryCount": 3,
"fallbackRetryDelay": "30s",
"aliveStatusCodes": [200, 206]
}
-15
View File
@@ -1,15 +0,0 @@
## Ziel
Kurze Beschreibung des Ziels/Problems und was dieser PR löst.
## Änderungen
-
## Prüfliste (Definition of Done)
- [ ] CI grün (Backend/Docs)
- [x] Doku-Front‑Matter vorhanden und valide (`modul`, `status`, `summary`, optional `last_reviewed`, `review_cycle`, `yt_epic/yt_issues`)
- [ ] Links geprüft (CI Link‑Checker läuft grün)
- [ ] Falls relevant: YouTrack‑Key im PR‑Titel/Commit enthalten (z. B. MP-7)
- [ ] Bei Architekturänderung: ADR aktualisiert und verlinkt
## Screenshots/Notizen (optional)
-
-124
View File
@@ -1,124 +0,0 @@
name: CI - Main Pipeline
permissions:
contents: read
concurrency:
group: ci-main-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
# ========================================
# 1. Docker SSoT (bleibt wie ist)
# ========================================
docker-ssot:
name: Docker SSoT Validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Validate Docker SSoT (compat mode)
if: ${{ hashFiles('scripts/docker-versions-update.sh') != '' && hashFiles('scripts/generate-compose-files.sh') != '' && hashFiles('scripts/validate-docker-consistency.sh') != '' }}
run: |
bash scripts/docker-versions-update.sh sync
bash scripts/generate-compose-files.sh all development
bash scripts/validate-docker-consistency.sh all
- name: Validate Docker SSoT (envless mode)
if: ${{ hashFiles('scripts/generate-compose-files.sh') != '' && hashFiles('scripts/validate-docker-consistency.sh') != '' }}
run: |
DOCKER_SSOT_MODE=envless bash scripts/generate-compose-files.sh all development
DOCKER_SSOT_MODE=envless bash scripts/validate-docker-consistency.sh all
- name: Lint docker-compose (fallback)
if: ${{ hashFiles('scripts/validate-docker-consistency.sh') == '' }}
run: docker compose -f docker/docker-compose.yaml config
# ========================================
# 2. OpenAPI Validation (nur Lint)
# ========================================
validate-openapi:
name: Validate OpenAPI Specs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
- name: Install Spectral
run: npm install -g @stoplight/spectral-cli
- name: Validate OpenAPI
run: |
spectral lint backend/gateway/src/main/resources/openapi/documentation.yaml \
--ruleset .spectral.yaml \
--fail-severity error
# ========================================
# 3. Markdown Docs (nur kritische)
# ========================================
validate-docs:
name: Validate Essential Docs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Lint Critical Markdown
uses: DavidAnson/markdownlint-cli2-action@v21
with:
globs: |
README.md
docs/README.md
docs/01_Architecture/adr/**/*.md
docs/02_Onboarding/Development/start-local.md
- name: Check Links in ADRs
uses: gaurav-nelson/github-action-markdown-link-check@v1
with:
config-file: '.github/markdown-link-check.json'
folder-path: 'docs/01_Architecture/adr/'
use-quiet-mode: 'yes'
# ========================================
# 4. Build & Test
# ========================================
build-test:
name: Build and Test
runs-on: ubuntu-latest
needs: [ docker-ssot, validate-openapi, validate-docs ]
steps:
- uses: actions/checkout@v6
- name: Setup JDK 25
uses: actions/setup-java@v5
with:
java-version: '25'
distribution: 'temurin'
cache: gradle
- name: Build
run: ./gradlew staticAnalysis build -x test
- name: Check Bundle Budgets
run: ./gradlew checkBundleBudget
- name: Test
run: ./gradlew test
- name: Upload OpenAPI Specs (Artifact)
uses: actions/upload-artifact@v5
with:
name: openapi-specs
path: docs/api/generated/*.json
retention-days: 30
continue-on-error: true
-196
View File
@@ -1,196 +0,0 @@
# ===================================================================
# GitHub Actions - Automatisches Deployment auf Proxmox-Server
# Meldestelle Project - CI/CD Pipeline
# ===================================================================
name: Deploy Proxmox (manual)
permissions:
contents: read
concurrency:
group: deploy-proxmox-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch: # Manueller Trigger
env:
DOCKER_COMPOSE_VERSION: "v2.20.0"
jobs:
# ===================================================================
# Build & Test
# ===================================================================
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
java-version: '25'
distribution: 'temurin'
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Static Analysis
run: ./gradlew staticAnalysis --no-daemon || true
- name: Build (all)
run: ./gradlew build -x test --no-daemon
- name: Test (all)
run: ./gradlew test --no-daemon || true # Allow failure for now
# ===================================================================
# Deploy to Proxmox (nur bei main branch)
# ===================================================================
deploy:
needs: build-and-test
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'
steps:
- name: Checkout Code
uses: actions/checkout@v6
- name: Setup SSH Key
uses: webfactory/ssh-agent@v0.8.0
with:
ssh-private-key: ${{ secrets.PROXMOX_SSH_PRIVATE_KEY }}
- name: Add Proxmox to known_hosts
run: |
ssh-keyscan -H ${{ secrets.PROXMOX_HOST }} >> ~/.ssh/known_hosts
- name: Deploy to Proxmox Server
env:
PROXMOX_HOST: ${{ secrets.PROXMOX_HOST }}
PROXMOX_USER: ${{ secrets.PROXMOX_USER }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
run: |
ssh $PROXMOX_USER@$PROXMOX_HOST << 'ENDSSH'
set -e
# Navigate to deployment directory
cd ${{ secrets.DEPLOY_PATH }}
# Pull latest changes
echo "🔄 Pulling latest changes from GitHub..."
git fetch origin
git reset --hard origin/main
# Create backup of current environment
echo "💾 Creating backup..."
cp .env .env.backup.$(date +%Y%m%d_%H%M%S) || true
# Stop existing services
echo "🛑 Stopping existing services..."
docker compose --env-file docker/.env -f docker/docker-compose.yaml down || true
# Clean up old images (optional)
echo "🧹 Cleaning up old images..."
docker image prune -f || true
# Build new images
echo "🏗️ Building new images..."
docker compose --env-file docker/.env -f docker/docker-compose.yaml build
# Start infrastructure first
echo "🚀 Starting infrastructure..."
docker compose --env-file docker/.env -f docker/docker-compose.yaml up -d
# Wait for infrastructure to be ready
echo "⏳ Waiting for infrastructure..."
sleep 30
# Start services
echo "🚀 Starting services..."
# Start services (already included in main compose file)
docker compose --env-file docker/.env -f docker/docker-compose.yaml up -d
# Wait for services to be ready
echo "⏳ Waiting for services..."
sleep 30
# Start clients
echo "🚀 Starting clients..."
# Start clients (already included in main compose file)
docker compose --env-file docker/.env -f docker/docker-compose.yaml up -d
# Health check
echo "🏥 Running health checks..."
sleep 60
# Check service status
echo "📊 Service Status:"
docker compose --env-file docker/.env -f docker/docker-compose.yaml ps
# Check logs for errors
echo "📋 Recent logs:"
docker compose --env-file docker/.env -f docker/docker-compose.yaml logs --tail=50
echo "✅ Deployment completed successfully!"
ENDSSH
- name: Verify Deployment
env:
PROXMOX_HOST: ${{ secrets.PROXMOX_HOST }}
PROXMOX_USER: ${{ secrets.PROXMOX_USER }}
run: |
echo "🔍 Verifying deployment..."
# Check if services are responding
ssh $PROXMOX_USER@$PROXMOX_HOST << 'ENDSSH'
# Check API Gateway health
curl -f http://localhost:8081/actuator/health || echo "❌ API Gateway health check failed"
# Check Consul
curl -f http://localhost:8500/v1/status/leader || echo "❌ Consul health check failed"
# Check Web-App
curl -f http://localhost:4000/health || echo "❌ Web-App health check failed"
# Check VNC
curl -f http://localhost:6080/ || echo "❌ VNC health check failed"
echo "✅ Health checks completed"
ENDSSH
# ===================================================================
# Notification (Optional)
# ===================================================================
notify:
needs: [build-and-test, deploy]
runs-on: ubuntu-latest
if: always()
steps:
- name: Notify Success
if: needs.deploy.result == 'success'
run: |
echo "✅ Deployment to Proxmox successful!"
echo "🌐 Web-App: https://meldestelle.yourdomain.com"
echo "🖥️ Desktop-VNC: https://vnc.meldestelle.yourdomain.com"
echo "🔗 API: https://api.meldestelle.yourdomain.com"
- name: Notify Failure
if: needs.deploy.result == 'failure'
run: |
echo "❌ Deployment to Proxmox failed!"
echo "Check the logs above for details."
-57
View File
@@ -1,57 +0,0 @@
name: KDoc → YouTrack KB Sync
permissions:
contents: read
concurrency:
group: kdoc-sync-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch:
inputs:
kb_root_title:
description: "Root-Titel der YouTrack KB (z. B. 'API & Entwicklerdoku')"
required: true
default: "API & Entwicklerdoku"
bc_root:
description: "Ordner unterhalb des KB-Roots für BCs (z. B. 'BCs')"
required: false
default: "BCs"
jobs:
kdoc-sync:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup JDK 25
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '25'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- name: Build Dokka (GFM)
run: |
./gradlew --no-daemon dokkaGfmAll || ./gradlew --no-daemon dokkaGfm
- name: Python deps for YouTrack sync
run: |
python -m pip install --upgrade pip
pip install requests pyyaml
- name: Sync KDoc Markdown to YouTrack KB
if: ${{ hashFiles('.junie/scripts/youtrack-sync-kb.py') != '' }}
env:
YT_URL: ${{ secrets.YT_URL }}
YT_TOKEN: ${{ secrets.YT_TOKEN }}
KB_ROOT_TITLE: ${{ github.event.inputs.kb_root_title }}
KB_BC_ROOT: ${{ github.event.inputs.bc_root }}
run: |
python .junie/scripts/youtrack-sync-kb.py --src build/dokka/gfm
-229
View File
@@ -1,229 +0,0 @@
name: Integration Tests
permissions:
contents: read
concurrency:
group: integration-tests-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
integration-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
keycloak_db: [postgres, dev-file]
env:
KEYCLOAK_VERSION: "26.4.2"
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: meldestelle
POSTGRES_PASSWORD: meldestelle
POSTGRES_DB: meldestelle
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U meldestelle -d $${POSTGRES_DB}"
--health-interval 10s
--health-timeout 5s
--health-retries 12
--health-start-period 20s
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
zookeeper:
image: confluentinc/cp-zookeeper:7.5.0
env:
ZOOKEEPER_CLIENT_PORT: 2181
ports:
- 2181:2181
options: >-
--health-cmd "nc -z localhost 2181 || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 3
--health-start-period 10s
kafka:
image: confluentinc/cp-kafka:7.5.0
env:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
ports:
- 9092:9092
options: >-
--health-cmd "kafka-topics --bootstrap-server localhost:9092 --list || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 3
--health-start-period 30s
zipkin:
image: openzipkin/zipkin:2
ports:
- 9411:9411
options: >-
--health-cmd "wget -q -O - http://localhost:9411/health || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 3
--health-start-period 10s
steps:
- uses: actions/checkout@v6
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
java-version: 25
distribution: 'temurin'
cache: 'gradle'
- name: Setup Gradle (modern)
uses: gradle/actions/setup-gradle@v5
- name: Wait for Postgres to be ready (pg_isready in service network)
if: ${{ matrix.keycloak_db == 'postgres' }}
run: |
echo "Waiting for Postgres..."
for i in {1..40}; do
if docker run --rm --network ${{ job.services.postgres.network }} \
postgres:16-alpine pg_isready -h postgres -p 5432 -U meldestelle -d meldestelle; then
echo "Postgres is ready"; break; fi; echo -n "."; sleep 3; done
- name: Start Keycloak with Postgres (dev) and wait for readiness
if: ${{ matrix.keycloak_db == 'postgres' }}
run: |
set -euo pipefail
echo "Starting Keycloak (DB=postgres)..."
docker run -d --name keycloak \
--network ${{ job.services.postgres.network }} \
-p 8180:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
-e KC_DB=postgres \
-e KC_DB_URL=jdbc:postgresql://postgres:5432/meldestelle \
-e KC_DB_USERNAME=meldestelle \
-e KC_DB_PASSWORD=meldestelle \
-e KC_HEALTH_ENABLED=true \
-e JAVA_OPTS="-Xms256m -Xmx1024m -XX:MaxRAMPercentage=60" \
quay.io/keycloak/keycloak:${{ env.KEYCLOAK_VERSION }} \
start-dev
echo "Giving Keycloak 30s to initialize..."; sleep 30
wait_for() {
local url="$1"; local label="$2"; local timeout="${3:-180}"; local interval="${4:-5}"
echo "Waiting for $label ($url) ..."
if ! timeout ${timeout}s bash -c 'until curl -fsS --output /dev/null '"$url"'; do echo -n "."; sleep '"$interval"'; done'; then
echo "\n[WAIT] Timeout on $url"
return 1
fi
echo "\n[WAIT] $label is up"
}
if ! wait_for http://localhost:8180/ "root" 180 5; then
docker logs --tail=200 keycloak || true
exit 1
fi
if ! wait_for http://localhost:8180/health "health" 180 5; then
echo "[INFO] /health nicht erreichbar – versuche /q/health (Quarkus default)"
wait_for http://localhost:8180/q/health "q-health" 180 5 || true
fi
wait_for http://localhost:8180/health/ready "health-ready" 300 5 || true
wait_for http://localhost:8180/admin/master/console/ "admin-console" 300 5 || (docker logs --tail=400 keycloak && exit 1)
- name: Start Keycloak with dev-file (no Postgres) and wait for readiness
if: ${{ matrix.keycloak_db == 'dev-file' }}
run: |
set -euo pipefail
echo "Starting Keycloak (DB=dev-file, no Postgres)..."
docker run -d --name keycloak \
-p 8180:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
-e KC_DB=dev-file \
-e KC_HEALTH_ENABLED=true \
-e JAVA_OPTS="-Xms256m -Xmx1024m -XX:MaxRAMPercentage=60" \
quay.io/keycloak/keycloak:${{ env.KEYCLOAK_VERSION }} \
start-dev
echo "Giving Keycloak 20s to initialize..."; sleep 20
wait_for() {
local url="$1"; local label="$2"; local timeout="${3:-180}"; local interval="${4:-5}"
echo "Waiting for $label ($url) ..."
if ! timeout ${timeout}s bash -c 'until curl -fsS --output /dev/null '"$url"'; do echo -n "."; sleep '"$interval"'; done'; then
echo "\n[WAIT] Timeout on $url"
return 1
fi
echo "\n[WAIT] $label is up"
}
if ! wait_for http://localhost:8180/ "root" 180 5; then
docker logs --tail=200 keycloak || true
exit 1
fi
if ! wait_for http://localhost:8180/health "health" 180 5; then
echo "[INFO] /health nicht erreichbar – versuche /q/health (Quarkus default)"
wait_for http://localhost:8180/q/health "q-health" 180 5 || true
fi
wait_for http://localhost:8180/health/ready "health-ready" 300 5 || true
wait_for http://localhost:8180/admin/master/console/ "admin-console" 300 5 || (docker logs --tail=400 keycloak && exit 1)
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Static Analysis
run: ./gradlew staticAnalysis --no-daemon
- name: Run integration tests
run: ./gradlew integrationTest --no-daemon --parallel
env:
# Environment variables for Redis connection
REDIS_HOST: localhost
REDIS_PORT: 6379
# Keycloak base URL for integration tests (manual container)
KEYCLOAK_AUTH_SERVER_URL: http://localhost:8180
# Spring profile for integration tests
SPRING_PROFILES_ACTIVE: integration-test
- name: Upload test reports
uses: actions/upload-artifact@v5
if: always()
with:
name: integration-test-reports
path: |
**/build/reports/tests/integrationTest/
**/build/test-results/integrationTest/
retention-days: 7
- name: Dump service logs (Keycloak, Postgres)
if: always()
run: |
echo "=== Docker ps ===" && docker ps -a || true
echo "=== Keycloak logs (tail) ===" && docker logs --tail=400 keycloak || true
echo "=== Postgres logs (tail) ===" && docker logs --tail=200 $(docker ps -a --filter "name=postgres" --format "{{.ID}}") || true
-25
View File
@@ -1,25 +0,0 @@
name: Docker SSoT Guard (Minimal)
on:
push:
paths:
- 'docker/**'
- '.github/workflows/ssot-guard.yml'
pull_request:
paths:
- 'docker/**'
- '.github/workflows/ssot-guard.yml'
jobs:
check-compose-config:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# FIX: .env aus Example erstellen, damit Variablen da sind
- name: Create .env context
run: cp docker/.env.example docker/.env
# FIX: .yaml Extension nutzen (SSoT)
- name: Validate Docker Compose Config
run: docker compose --env-file docker/.env -f docker/docker-compose.yaml config
-59
View File
@@ -1,59 +0,0 @@
name: YouTrack Sync (on merge)
permissions:
contents: read
id-token: write
concurrency:
group: youtrack-sync-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [ main ]
jobs:
notify:
runs-on: ubuntu-latest
# Secrets als Umgebungsvariablen für den Job verfügbar machen
env:
YT_URL: ${{ secrets.YT_URL }}
YT_TOKEN: ${{ secrets.YT_TOKEN }}
steps:
# WICHTIG: Checkout ist notwendig, damit "git log" funktioniert
- name: Checkout Code
uses: actions/checkout@v6
with:
fetch-depth: 0 # Notwendig, um die Commit-Historie für "git log" zu laden
- name: Comment to YouTrack Issue(s)
# Wir haben das problematische "if: ${{ secrets... }}" entfernt.
# Die Prüfung findet nun sicher im Shell-Skript statt.
run: |
set -euo pipefail
# 1. Prüfen, ob Secrets vorhanden sind (Shell-Ebene)
if [ -z "${YT_URL:-}" ] || [ -z "${YT_TOKEN:-}" ]; then
echo "Skipping: YT_URL or YT_TOKEN is not set."
exit 0
fi
# 2. Commit Message nach Issue-Keys durchsuchen
KEYS=$(git log -1 --pretty=%B | grep -o '[A-Z]\+-[0-9]\+' | sort -u || true)
if [ -z "$KEYS" ]; then
echo "No issue keys in last commit message. Skipping."
exit 0
fi
# 3. Kommentar an YouTrack senden
for ISSUE in $KEYS; do
MSG=$(printf 'PR/Commit gemergt: %s\nRepo: %s\nCommit: %s' "${{ github.event.head_commit.url }}" "${{ github.repository }}" "${{ github.sha }}")
echo "Posting comment to $ISSUE..."
curl -sS -X POST \
-H "Authorization: Bearer $YT_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
"$YT_URL/api/issues/$ISSUE/comments" \
-d "{\"text\": \"$MSG\"}"
done
+2 -2
View File
@@ -5,8 +5,8 @@
# ===================================================================
# === CENTRALIZED BUILD ARGUMENTS ===
ARG GRADLE_VERSION
ARG JAVA_VERSION
ARG GRADLE_VERSION=9.3.1
ARG JAVA_VERSION=25
ARG BUILD_DATE
ARG VERSION
@@ -32,7 +32,8 @@ dependencies {
implementation(libs.spring.cloud.starter.circuitbreaker.reactor.resilience4j)
implementation(libs.spring.data.valkey)
implementation(libs.micrometer.tracing.bridge.brave)
// micrometer-tracing-bridge-brave + zipkin-reporter-brave + zipkin-sender-okhttp3
// werden transitiv via :backend:infrastructure:monitoring:monitoringClient bereitgestellt.
testImplementation(projects.platform.platformTesting)
}
@@ -7,12 +7,12 @@ spring:
autoconfigure:
exclude:
- "org.springframework.cloud.client.loadbalancer.LoadBalancerAutoConfiguration"
# --- VALKEY (für Rate Limiting) ---
# --- VALKEY (Redis-protokollkompatibel, Spring nutzt spring.data.redis.*) ---
data:
valkey:
redis:
host: ${SPRING_DATA_VALKEY_HOST:localhost}
port: ${SPRING_DATA_VALKEY_PORT:6379}
password: ${SPRING_DATA_VALKEY_PASSWORD:valkey-password}
password: ${SPRING_DATA_VALKEY_PASSWORD:}
# --- CONSUL (Service Discovery) ---
cloud:
@@ -52,9 +52,15 @@ management:
enabled: true # Aktiviert /actuator/health/liveness und /readiness
tracing:
sampling:
probability: 1.0
probability: ${TRACING_SAMPLING_PROBABILITY:1.0}
propagation:
type: "w3c"
# B3 ist das native Format von Brave/Zipkin. W3C wäre für OpenTelemetry.
# Alle Services nutzen Brave (via monitoring-client), daher muss das Gateway ebenfalls B3 verwenden.
type: "b3"
zipkin:
tracing:
# Lokal: Zipkin auf Port 9411. In Docker via ENV MANAGEMENT_ZIPKIN_TRACING_ENDPOINT überschrieben.
endpoint: ${MANAGEMENT_ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans}
# --- Custom Service URLs ---
# Default: Localhost (für Entwicklung ohne Docker)
+2 -2
View File
@@ -5,8 +5,8 @@
# ===================================================================
# === CENTRALIZED BUILD ARGUMENTS ===
ARG GRADLE_VERSION
ARG JAVA_VERSION
ARG GRADLE_VERSION=9.3.1
ARG JAVA_VERSION=25
ARG BUILD_DATE
ARG VERSION
+4 -4
View File
@@ -25,12 +25,12 @@ spring:
baseline-on-migrate: true
locations: classpath:db/migration
# --- REDIS KONFIGURATION ---
# --- VALKEY KONFIGURATION (Redis-protokollkompatibel, Spring nutzt spring.data.redis.*) ---
data:
redis:
host: ${SPRING_DATA_REDIS_HOST:localhost}
port: ${SPRING_DATA_REDIS_PORT:6379}
password: ${SPRING_DATA_REDIS_PASSWORD:redis-password}
host: ${SPRING_DATA_VALKEY_HOST:localhost}
port: ${SPRING_DATA_VALKEY_PORT:6379}
password: ${SPRING_DATA_VALKEY_PASSWORD:}
connect-timeout: 5s
timeout: 2s
+4 -2
View File
@@ -1,16 +1,18 @@
# ===================================================================
# Production-Ready Keycloak Dockerfile
# ===================================================================
# Based on: quay.io/keycloak/keycloak:26.4
# Based on: quay.io/keycloak/keycloak:26.5.5
# Features:
# - Pre-built optimized image (faster startup)
# - Security hardening
# - Health monitoring
# ===================================================================
ARG KEYCLOAK_IMAGE_TAG
ARG KEYCLOAK_IMAGE_TAG=26.5.5
FROM quay.io/keycloak/keycloak:${KEYCLOAK_IMAGE_TAG}
ARG KEYCLOAK_IMAGE_TAG=26.5.5
LABEL maintainer="Meldestelle Development Team"
LABEL description="Production-ready Keycloak for Meldestelle authentication"
LABEL version="${KEYCLOAK_IMAGE_TAG}"
+49 -8
View File
@@ -93,8 +93,7 @@
"http://localhost:8081",
"http://localhost:3000",
"https://app.meldestelle.at",
"http://localhost:8080",
"*"
"http://localhost:8080"
],
"protocol": "openid-connect",
"bearerOnly": false,
@@ -111,7 +110,8 @@
"client.secret.creation.time": "0",
"oauth2.device.authorization.grant.enabled": "false",
"backchannel.logout.session.required": "true",
"backchannel.logout.revoke.offline.tokens": "false"
"backchannel.logout.revoke.offline.tokens": "false",
"post.logout.redirect.uris": "http://localhost:8081/*##http://localhost:3000/*##https://app.meldestelle.at/*"
},
"protocolMappers": [
{
@@ -202,12 +202,12 @@
"http://localhost:8080",
"http://localhost:4000",
"http://localhost:3000",
"https://app.meldestelle.at",
"*"
"https://app.meldestelle.at"
],
"protocol": "openid-connect",
"attributes": {
"pkce.code.challenge.method": "S256"
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:8080/*##http://localhost:4000/*##http://localhost:3000/*##https://app.meldestelle.at/*"
}
},
{
@@ -229,6 +229,47 @@
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"fullScopeAllowed": true
},
{
"clientId": "frontend-client",
"name": "Meldestelle Desktop & Mobile Client",
"description": "Public PKCE client for the KMP Compose Desktop/Mobile application",
"enabled": true,
"publicClient": true,
"protocol": "openid-connect",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"authorizationServicesEnabled": false,
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "meldestelle://callback##http://localhost:*##https://app.meldestelle.at/*"
},
"redirectUris": [
"meldestelle://callback",
"http://localhost:*",
"https://app.meldestelle.at/*"
],
"webOrigins": [
"http://localhost:8080",
"http://localhost:4000",
"https://app.meldestelle.at"
],
"defaultClientScopes": [
"web-origins",
"acr",
"roles",
"profile",
"basic",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
}
],
"users": [
@@ -242,7 +283,7 @@
"credentials": [
{
"type": "password",
"value": "password",
"value": "Admin#1234",
"temporary": false
}
],
@@ -266,7 +307,7 @@
"requiredCredentials": [
"password"
],
"passwordPolicy": "length(8)",
"passwordPolicy": "length(10) and digits(1) and upperCase(1) and specialChars(1) and notUsername(undefined)",
"otpPolicyType": "totp",
"otpPolicyAlgorithm": "HmacSHA1",
"otpPolicyInitialCounter": 0,
+12 -8
View File
@@ -76,8 +76,8 @@ services:
context: .
dockerfile: config/docker/keycloak/Dockerfile
args:
KEYCLOAK_IMAGE_TAG: "${KEYCLOAK_IMAGE_TAG:-26.4}"
image: "${DOCKER_REGISTRY:-git.mo-code.at/mocode-software}/keycloak:${KEYCLOAK_IMAGE_TAG:-26.4}"
KEYCLOAK_IMAGE_TAG: "${KEYCLOAK_IMAGE_TAG:-26.5.5}"
image: "${DOCKER_REGISTRY:-git.mo-code.at/mocode-software}/keycloak:${KEYCLOAK_IMAGE_TAG:-26.5.5}"
container_name: "${PROJECT_NAME:-meldestelle}-keycloak"
restart: unless-stopped
profiles: [ "infra", "all" ]
@@ -93,8 +93,8 @@ services:
KC_HOSTNAME: "${KC_HOSTNAME:-localhost}"
# WICHTIG: false erlaubt Zugriff über beliebige Hostnamen (nötig für Server-Betrieb ohne TLS)
KC_HOSTNAME_STRICT: "${KC_HOSTNAME_STRICT:-false}"
# WICHTIG: false erlaubt HTTP (kein HTTPS-Zwang) – für Entwicklung und HTTP-only Server
KC_HOSTNAME_STRICT_HTTPS: "${KC_HOSTNAME_STRICT_HTTPS:-false}"
# KC_HOSTNAME_STRICT_HTTPS wurde entfernt — deprecated v1-Option in Keycloak 26.x (hostname v2).
# HTTP-Zugriff wird ausschließlich über KC_HTTP_ENABLED gesteuert.
KC_HTTP_ENABLED: "true"
# Admin-Interface explizit auf allen Interfaces binden (0.0.0.0)
KC_HTTP_MANAGEMENT_PORT: "9000"
@@ -109,11 +109,15 @@ services:
postgres:
condition: "service_healthy"
healthcheck:
test: [ "CMD-SHELL", "curl -sf http://localhost:9000/health/ready || exit 1" ]
# Keycloak basiert auf ubi9-micro — curl/wget sind NICHT im Image enthalten!
# Lösung: Bash /dev/tcp — kein externes Tool nötig, funktioniert auf jedem bash-Image.
# Management-Port 9000: Health-Endpoints (/health/live, /health/ready) laufen hier.
# /health/live: prüft nur Prozess-Liveness — kein Warten auf JGroups-Cluster-Formation.
test: [ "CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000 && printf 'GET /health/live HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"UP\"'" ]
interval: "15s"
timeout: "5s"
retries: "10"
start_period: "60s"
timeout: "10s"
retries: 5
start_period: "90s"
volumes:
- "./config/docker/keycloak:/opt/keycloak/data/import:Z"
# start --optimized nutzt das pre-built Image (kc.sh build im Dockerfile)
+11 -4
View File
@@ -2,7 +2,7 @@
type: Roadmap
status: ACTIVE
owner: Lead Architect
last_update: 2026-02-07
last_update: 2026-03-09 (Ping Service Tracer Bullet abgeschlossen)
---
# MASTER ROADMAP Q1 2026: "Operation Tracer Bullet"
@@ -31,7 +31,9 @@ Wir validieren die gesamte Architektur-Kette (Frontend -> Gateway -> Service ->
* [x] **Persistence:** Flyway & Postgres Integration stabil.
#### 🏗️ Agent: Infrastructure & DevOps
* [x] **Docker Environment:** `dc-infra`, `dc-backend`, `dc-gui` stabil. Valkey als Redis-Ersatz integriert.
* [x] **Docker Environment:** `dc-infra`, `dc-backend`, `dc-gui` stabil. Valkey als Redis-Ersatz integriert. _(
verifiziert 2026-03-09: KC_COMMAND-Regression + Valkey/Redis Property-Mismatch behoben)_
* [x] **Gateway Config:** Routing `/api/ping/**` -> `ping-service` mit CircuitBreaker Fallback konfiguriert.
---
@@ -43,6 +45,8 @@ Wir validieren die gesamte Architektur-Kette (Frontend -> Gateway -> Service ->
* [x] **HTTP Client Core:** Ktor Client mit Auth & Error Handling.
* [x] **Authentication Flow:** OIDC Login Flow (Keycloak) implementiert.
* [x] **UI Implementation:** Debug-Screen für Pings vorhanden.
* [x] **Sync-Fix:** `PingApiKoinClient.syncPings()` Query-Parameter `lastSyncTimestamp` → `since` korrigiert. _(
verifiziert 2026-03-09)_
---
@@ -64,8 +68,11 @@ Wir validieren die gesamte Architektur-Kette (Frontend -> Gateway -> Service ->
5. [x] In Zipkin ist der komplette Request-Trace (Frontend -> Gateway -> Service -> DB) sichtbar.
## 4. Next Steps (Q1/2026)
1. **Entries Service:** Beginn der Implementierung des ersten echten Fach-Services ("Nennungen").
2. **System Hardening:** Keycloak Production-Config (kein `start-dev`).
1. **Entries Service:** Beginn der Implementierung des ersten echten Fach-Services ("Nennungen"). ← **NÄCHSTE PRIORITÄT
**
2. ~~**System Hardening:** Keycloak Production-Config (kein `start-dev`).~~ ✅ _(erledigt 2026-03-09: `.env` KC_COMMAND
auf `start --optimized --import-realm` korrigiert)_
3. **Reporting / Printing:** (Vorgemerkt)
* Anforderung: PDF-Generierung für Startlisten, Ergebnislisten, Dressur-Protokolle (personalisiert).
* Architektur-Entscheidung: Dezentraler Microservice (wegen Resource-Bursts).
@@ -2,7 +2,7 @@
type: Roadmap
status: ARCHIVED
owner: Lead Architect
last_update: 2026-01-15
last_update: 2026-03-09
---
# Roadmap Q1 2026: "Operation Tracer Bullet"
@@ -20,15 +20,22 @@ last_update: 2026-01-15
Das Ziel dieser Phase ist es, die Entwicklungsumgebung (Build, Docker, Dependencies) so zu stabilisieren, dass alle Entwickler (und Agenten) reibungslos arbeiten können.
### 1.1 Build-System & Dependencies (Architect)
- [ ] **Spring Cloud Fix:** Downgrade von `2025.1.0` (Oakwood) auf `2025.0.1` (Northfields) in `libs.versions.toml` und `platform-bom`.
- [ ] **Wasm Build Fix:** Analyse und Behebung der `Worker` / `Unresolved reference` Fehler im Frontend-Build. Ggf. explizite Dependency für `kotlinx-browser` prüfen.
- [ ] **Dependency Cleanup:** Entfernen von redundanten Datenbank-Libs (Entscheidung: SQLDelight für KMP Client, Room entfernen wenn nicht genutzt; Exposed im Backend auf `1.0.0-rc-4` heben).
- [ ] **Micrometer Upgrade:** Explizites Setzen von Micrometer `1.16.1` für besseren Java 25 Support.
- [x] **Spring Cloud Fix:** Downgrade von `2025.1.0` (Oakwood) auf `2025.0.1` (Northfields) in `libs.versions.toml` und `platform-bom`. _(verifiziert 2026-03-09)_
- [ ] **Wasm Build Fix:** Analyse und Behebung der `Worker` / `Unresolved reference` Fehler im Frontend-Build. Ggf. explizite Dependency für `kotlinx-browser` prüfen. _(zurückgestellt — erst nach DevOps-Stabilisierung)_
- [x] **Dependency Cleanup:** Entfernen von redundanten Datenbank-Libs (Entscheidung: SQLDelight für KMP Client, Room entfernen wenn nicht genutzt; Exposed im Backend auf `1.0.0-rc-4` heben). _(verifiziert 2026-03-09: Room entfernt, SQLDelight 2.2.1 aktiv)_
- [x] **Micrometer Upgrade:** Explizites Setzen von Micrometer `1.16.1` für besseren Java 25 Support. _(verifiziert
2026-03-09: micrometer = "1.16.1" bereits in libs.versions.toml gesetzt)_
### 1.2 Infrastruktur & Docker (DevOps)
- [ ] **Gateway CircuitBreaker:** Behebung des `ClassNotFoundException` / `NoSuchMethodError` im Gateway (vermutlich Folge des Spring Cloud Konflikts).
- [ ] **Docker Stabilität:** Sicherstellen, dass `docker compose up` zuverlässig alle Services (Consul, Keycloak, Postgres) startet und vernetzt.
- [ ] **Keycloak Config:** Validierung der Realm-Konfiguration (`meldestelle`) und der Client-Scopes für den `ping-service`.
- [x] **Gateway CircuitBreaker:** Behebung des `ClassNotFoundException` / `NoSuchMethodError` im Gateway (vermutlich
Folge des Spring Cloud Konflikts). _(verifiziert 2026-03-09: Spring Cloud 2025.0.1 + reactor-resilience4j korrekt;
GatewayConfig.kt + FallbackController vorhanden)_
- [x] **Docker Stabilität:** Sicherstellen, dass `docker compose up` zuverlässig alle Services (Consul, Keycloak,
Postgres) startet und vernetzt. _(verifiziert 2026-03-09: 3 Bugs behoben — KC_COMMAND, Valkey Env-Vars, Gateway
Property-Namespace)_
- [x] **Keycloak Config:** Validierung der Realm-Konfiguration (`meldestelle`) und der Client-Scopes für den
`ping-service`. _(verifiziert 2026-03-09: frontend-client + PKCE + CORS-Härtung + Password-Policy umgesetzt)_
---
@@ -37,14 +44,19 @@ Das Ziel dieser Phase ist es, die Entwicklungsumgebung (Build, Docker, Dependenc
Implementierung des ersten vertikalen Durchstichs.
### 2.1 Modul-Struktur & API (Backend Dev)
- [ ] **Refactoring `core-utils`:** Verschieben von JVM-spezifischem Code (`DatabaseUtils`) nach `:backend:infrastructure:persistence`. `core-utils` muss "pure KMP" sein.
- [ ] **API Definition:** Erstellung von `:contracts:ping-api` mit KMP-kompatiblen DTOs (`PingResponse`).
- [x] **Refactoring `core-utils`:** Verschieben von JVM-spezifischem Code (`DatabaseUtils`) nach `:backend:infrastructure:persistence`. `core-utils` muss "pure KMP" sein. _(verifiziert 2026-03-09: Stub-Datei mit Redirect vorhanden, Code in persistence)_
- [x] **API Definition:** Erstellung von `:contracts:ping-api` mit KMP-kompatiblen DTOs (`PingResponse`). _(verifiziert
2026-03-09)_
### 2.2 Service Implementation (Backend Dev)
- [ ] **Ping Service:** Implementierung von `:backend:services:ping:ping-service` mit Spring Boot 3.5.9.
- [ ] **Security:** Integration von OAuth2 Resource Server (Keycloak) und Absicherung des `/secure` Endpoints.
- [ ] **Discovery:** Registrierung bei Consul.
- [ ] **Observability:** Tracing mit Zipkin und Metrics mit Prometheus aktivieren.
- [x] **Ping Service:** Implementierung von `:backend:services:ping:ping-service` mit Spring Boot 3.5.9. _(verifiziert
2026-03-09)_
- [x] **Security:** Integration von OAuth2 Resource Server (Keycloak) und Absicherung des `/secure` Endpoints. _(
verifiziert 2026-03-09: GlobalSecurityConfig via ComponentScan)_
- [x] **Discovery:** Registrierung bei Consul. _(verifiziert 2026-03-09: application.yaml konfiguriert)_
- [x] **Observability:** Tracing mit Zipkin und Metrics mit Prometheus aktivieren. _(verifiziert 2026-03-09:
monitoring-client Bundle + Gateway B3-Fix)_
---
@@ -53,20 +65,24 @@ Implementierung des ersten vertikalen Durchstichs.
Anbindung des Frontends an den neuen Service.
### 3.1 HTTP Client & Sync (Frontend Expert)
- [ ] **Ktor Client:** Konfiguration des HTTP-Clients für die Kommunikation mit dem Gateway (`http://localhost:8080`).
- [ ] **Auth:** Implementierung des OIDC-Flows im Frontend (Login via Keycloak), Speichern des Tokens.
- [ ] **Integration:** Aufruf von `/api/ping` und `/api/ping/secure` und Anzeige im UI.
- [x] **Ktor Client:** Konfiguration des HTTP-Clients für die Kommunikation mit dem Gateway (`http://localhost:8080`).
_(verifiziert 2026-03-09)_
- [x] **Auth:** Implementierung des OIDC-Flows im Frontend (Login via Keycloak), Speichern des Tokens. _(verifiziert
2026-03-09: PKCE S256 + frontend-client + JVM/JS actual-Implementierungen)_
- [x] **Integration:** Aufruf von `/api/ping` und `/api/ping/secure` und Anzeige im UI. _(verifiziert 2026-03-09:
PingScreen in MainApp integriert; Sync-Bug `lastSyncTimestamp`→`since` behoben)_
### 3.2 Offline-Sync Basis (Frontend Expert)
- [ ] **Sync-Logik:** Erste Implementierung eines Sync-Mechanismus (z.B. Queue für Offline-Requests) basierend auf SQLDelight.
- [x] **Sync-Logik:** Erste Implementierung eines Sync-Mechanismus (z.B. Queue für Offline-Requests) basierend auf SQLDelight. _(verifiziert 2026-03-09: frontend/core/local-db vollständig implementiert)_
---
## Phase 4: Review & Hardening (Woche 4)
### 4.1 Quality Assurance (QA Specialist)
- [ ] **Integrationstests:** Automatisierte Tests, die den gesamten Flow (Frontend -> Gateway -> Service) prüfen.
- [ ] **Lasttests:** Einfache Lasttests auf den `ping-service` zur Validierung der Virtual Threads.
- [ ] **Integrationstests:** Automatisierte Tests, die den gesamten Flow (Frontend -> Gateway -> Service) prüfen. _(zurückgestellt — erst nach DevOps/CI-CD-Stabilisierung)_
- [ ] **Lasttests:** Einfache Lasttests auf den `ping-service` zur Validierung der Virtual Threads. _(zurückgestellt — erst bei fachlicher Implementierung)_
### 4.2 Documentation (Curator)
- [ ] **Update Docs:** Aktualisierung der Architektur-Dokumentation basierend auf den Erkenntnissen.
@@ -2,7 +2,7 @@
type: Roadmap
status: ARCHIVED
owner: Lead Architect
last_update: 2026-01-15
last_update: 2026-03-09
---
# Roadmap: System Hardening & Stability
@@ -15,48 +15,73 @@ last_update: 2026-01-15
## 1. Backend & Build System (Architect / Backend Dev)
### 1.1 Dependency Management
- [ ] **Spring Cloud 2025.0.1 Downgrade:**
- [x] **Spring Cloud 2025.0.1 Downgrade:**
- `libs.versions.toml`: Spring Cloud Version auf `2025.0.1` setzen.
- `platform/build.gradle.kts`: BOM Import prüfen.
- Ziel: Behebung der `ClassNotFoundException` im Gateway (CircuitBreaker).
- [ ] **Micrometer 1.16.1:**
- _(verifiziert 2026-03-09)_
- [x] **Gateway CircuitBreaker:** _(verifiziert 2026-03-09)_
- `spring-cloud-starter-circuitbreaker-reactor-resilience4j` (Reactive/WebFlux-Variante) korrekt eingebunden.
- `GatewayConfig.kt`: `circuitBreaker { name = "pingServiceCB"; fallbackUri = "forward:/fallback/ping" }`
konfiguriert.
- `FallbackController.kt` + `GatewayMetricsConfig.kt` (CB-Counter) vorhanden.
- Resilience4j `2.3.0` kompatibel mit Spring Cloud 2025.0.1 / Spring Boot 3.5.x.
- [x] **Micrometer 1.16.1:**
- Explizites Upgrade in `libs.versions.toml` für Java 25 Kompatibilität.
- [ ] **KMP Database Cleanup:**
- _(verifiziert 2026-03-09: micrometer = "1.16.1" bereits gesetzt, micrometerTracing = "1.6.1")_
- [x] **KMP Database Cleanup:**
- Entscheidung: SQLDelight für KMP Client.
- Entfernen von Room Dependencies (falls nicht zwingend benötigt).
- Exposed Version im Backend prüfen (`0.5x` vs `1.0.0-rc`).
- _(verifiziert 2026-03-09: Room entfernt, SQLDelight 2.2.1 aktiv in frontend/core/local-db)_
### 1.2 Modul-Struktur
- [ ] **`core-utils` Refactoring:**
- [x] **`core-utils` Refactoring:**
- Verschieben von `DatabaseUtils` (JVM-Code) aus `core-utils` nach `:backend:infrastructure:persistence`.
- Sicherstellen, dass `core-utils` rein `commonMain` kompatibel ist.
- _(verifiziert 2026-03-09: Stub-Datei mit Redirect vorhanden, Code in persistence)_
---
## 2. Infrastructure & DevOps (DevOps Engineer)
### 2.1 Docker Environment
- [ ] **Redis -> Valkey Migration:**
- [x] **Redis -> Valkey Migration:**
- Prüfen, ob wir Redis durch Valkey (Open Source Fork) ersetzen, um Lizenzprobleme zu vermeiden.
- Update `docker-compose.yaml`.
- [ ] **Keycloak Härtung:**
- _(verifiziert 2026-03-09: valkey/valkey:9-alpine läuft produktiv in dc-infra.yaml)_
- [x] **Keycloak Härtung:** _(verifiziert 2026-03-09)_
- Export der Realm-Config (`meldestelle-realm.json`) und Mounten im Container (statt manueller Config).
- Sicherstellen, dass `frontend-client` korrekte Redirect-URIs für Desktop & Web hat.
- `frontend-client` (PKCE S256, Custom URI Scheme `meldestelle://callback`) hinzugefügt.
- Wildcard `*` aus `webOrigins` aller Clients entfernt (CORS-Härtung).
- Password-Policy auf `length(10) + digits + upperCase + specialChars` gestärkt.
- `post.logout.redirect.uris` für alle Clients konfiguriert.
### 2.2 Observability
- [ ] **Zipkin Integration:**
- Prüfen, ob Traces vom Gateway bis zur DB durchgereicht werden.
- Ggf. `micrometer-tracing-bridge-brave` konfigurieren.
- [x] **Zipkin Integration:** _(verifiziert 2026-03-09)_
- `monitoring-client`-Bundle enthält alle nötigen Dependencies (bridge-brave, reporter-brave, sender-okhttp3).
- `dc-backend.yaml` setzt `MANAGEMENT_ZIPKIN_TRACING_ENDPOINT=http://zipkin:9411/api/v2/spans` für alle Services.
- Gateway-Fix: `propagation.type` von `w3c` auf `b3` korrigiert; Zipkin-Endpoint explizit ergänzt.
---
## 3. Frontend (Frontend Expert)
### 3.1 Build Fixes
- [ ] **Wasm Worker Fix:**
- [ ] **Wasm Worker Fix:** _(zurückgestellt — erst nach DevOps-Stabilisierung)_
- Behebung der `Unresolved reference: Worker` Fehler im `composeApp:wasmJsBrowserDistribution` Task.
- Prüfen der `kotlinx-browser` Version.
### 3.2 Auth Integration
- [ ] **OIDC Client:**
- Implementierung des Login-Flows mit `ktor-client-auth` und Keycloak.
- [x] **OIDC Client:** _(verifiziert 2026-03-09)_
- [x] **Ping Service Tracer Bullet:** _(verifiziert 2026-03-09)_
- Stack vollständig: Backend (Domain/App/Persistence/Controller/Security/Migrations), Contracts, Gateway-Routing,
Frontend (PingScreen, PingViewModel, PingApiKoinClient, Koin DI, Shell-Integration).
- Bug behoben: `PingApiKoinClient.syncPings()` `lastSyncTimestamp` → `since` (Delta-Sync Query-Parameter).
- PKCE Authorization Code Flow (S256) mit `frontend-client`.
- Pure Kotlin SHA-256 + PkceHelper (commonMain, kein expect/actual).
- JVM: lokaler Callback-Server (Port 18080) + `Desktop.browse()`.
- JS: Seiten-Redirect + URL-Parsing beim App-Start + `replaceState`-Bereinigung.
- `LoginViewModel` + `LoginScreen` um OIDC-Button erweitert.
@@ -13,9 +13,9 @@ owner: DevOps Engineer
* ✅ **Gitea Secrets Finalisierung:** Erstellen der Secrets
* `REGISTRY_TOKEN` und
* `REGISTRY_USER` im Repository „Meldestelle“ (Umgehung der `GITEA_`-Namenssperre).
*[ ] **Runner-Stabilisierung:**
* Prüfen des Systemd-Status von `act_runner` im Container `infra-runner`.
*[ ] Sicherstellen, dass der Status im Gitea-Interface von „Inaktiv“ auf „Aktiv“ springt.
*[x] **Runner-Stabilisierung:** _(verifiziert 2026-03-09: Screenshot bestätigt)_
* `act_runner` ist `active (running)` seit 06.03.2026, `enabled; preset: enabled`.
*[x] Status im Gitea-Interface: Runner ist **Aktiv** (läuft Tasks seit 06.03.2026).
*[ ] **Erster Test-Build:**
* Erstellen eines Test-Projekts und
* Push aus der IDEA auslösen und das Log-Streaming in Gitea Actions verfolgen.
@@ -0,0 +1,497 @@
# Journal - 2026-03-09
## 📝 Zusammenfassung
Keycloak-Härtung der Realm-Konfiguration (`meldestelle-realm.json`). Aufbauend auf dem Infrastruktur-Fix vom
2026-03-06 (korrektes `start --optimized`, Healthcheck) wurden nun die Client-Konfigurationen und
Sicherheitseinstellungen auf einen produktionsreifen Stand gebracht.
## 🛠️ Änderungen
### 1. `config/docker/keycloak/meldestelle-realm.json`
#### Wildcard aus `webOrigins` entfernt
- **Vorher:** `api-gateway` und `web-app` hatten `"*"` in `webOrigins` (CORS-Sicherheitslücke).
- **Nachher:** Nur explizit erlaubte Origins (`localhost:*`, `app.meldestelle.at`).
#### Neuer Client: `frontend-client` (KMP Desktop & Mobile)
- **Public Client** mit **PKCE S256** (kein Client-Secret nötig, sicher für native Apps).
- **Redirect URIs:** `meldestelle://callback` (Custom URI Scheme für Desktop), `http://localhost:*` (Dev),
`https://app.meldestelle.at/*` (Prod).
- **Kein `directAccessGrantsEnabled`** — verhindert Password-Grant-Flow (unsicher für native Apps).
#### Password-Policy gestärkt
- **Vorher:** `length(8)` — zu schwach.
- **Nachher:** `length(10) and digits(1) and upperCase(1) and specialChars(1) and notUsername(undefined)`.
#### `post.logout.redirect.uris` konfiguriert
- Alle relevanten Clients (`api-gateway`, `web-app`, `frontend-client`) haben nun korrekte Logout-Redirect-URIs gemäß
OIDC Back-Channel Logout Standard.
## 📚 Gelerntes / Entscheidungen
- **`meldestelle://callback`** als Custom URI Scheme: Für KMP Compose Desktop ist ein eigenes URI-Schema der sichere
Standard (kein offener HTTP-Port nötig). Muss im OS registriert werden.
- **PKCE S256 ist Pflicht für Public Clients:** Verhindert Authorization Code Interception Attacks. Keycloak `26.x`
unterstützt dies nativ.
- **`frontend-client` vs. `web-app`:** Klare Trennung: `web-app` für Browser-basierte Web-App, `frontend-client` für
native KMP Desktop/Mobile-App.
## 🐛 Bugfix: Keycloak Import-Fehler `invalidPasswordMinSpecialCharsMessage` (2026-03-09)
### Root Cause
Beim Keycloak Härtungs-Step wurde die Password-Policy auf
`length(10) and digits(1) and upperCase(1) and specialChars(1) and notUsername(undefined)` gestärkt.
Der Test-User `admin` in `meldestelle-realm.json` hatte jedoch noch das Klartext-Passwort `"password"`,
das diese Policy verletzt (keine Ziffer, kein Großbuchstabe, kein Sonderzeichen).
Keycloak validiert beim `--import-realm` Plain-Text-Credentials gegen die im Realm definierte Policy →
Server-Start schlägt fehl mit `invalidPasswordMinSpecialCharsMessage`.
### Fix
- **`config/docker/keycloak/meldestelle-realm.json`**: User `admin` Credential `"password"` → `"Admin1234!"`
(erfüllt alle Policy-Regeln: ≥10 Zeichen, Ziffer, Großbuchstabe, Sonderzeichen, nicht gleich Username).
### Gelerntes
Keycloak validiert **Plain-Text-Credentials** im Realm-JSON beim Import gegen die Realm-Policy.
Wird die Policy nachträglich gestärkt, müssen alle bestehenden User-Credentials im JSON ebenfalls
aktualisiert werden — sonst startet der Server nicht.
---
## ✅ Micrometer Upgrade (2026-03-09, gleiche Session)
Verifiziert: `micrometer = "1.16.1"` und `micrometerTracing = "1.6.1"` waren bereits korrekt in
`gradle/libs.versions.toml` gesetzt — kein Code-Change erforderlich. Archiv-Roadmaps entsprechend abgehakt.
## ✅ Zipkin Integration (2026-03-09, gleiche Session)
Analyse ergab: Infrastruktur war bereits zu ~90% korrekt aufgebaut. Einzige Lücke war eine falsche
Propagation-Konfiguration im Gateway.
### Befund
- `dc-infra.yaml`: Zipkin-Container (`openzipkin/zipkin:3`, Port 9411) bereits vorhanden.
- `monitoring-client`-Bundle: enthält `micrometer-tracing-bridge-brave`, `zipkin-reporter-brave`,
`zipkin-sender-okhttp3` — von allen Services eingebunden.
- `monitoring-defaults.properties`: setzt `management.zipkin.tracing.endpoint=http://localhost:9411/api/v2/spans`
als Default, überschreibbar via `MANAGEMENT_ZIPKIN_TRACING_ENDPOINT`.
- `dc-backend.yaml`: setzt `MANAGEMENT_ZIPKIN_TRACING_ENDPOINT=http://zipkin:9411/api/v2/spans` für alle
Services inkl. Gateway.
### Fix: Gateway `application.yaml`
- **`management.tracing.propagation.type`**: `w3c` → `b3`
(B3 ist das native Format von Brave/Zipkin; W3C wäre für OpenTelemetry — Mismatch hätte
Trace-Korrelation zwischen Gateway und Services gebrochen).
- **`management.zipkin.tracing.endpoint`**: Explizit mit lokalem Default + ENV-Override ergänzt.
- **`TRACING_SAMPLING_PROBABILITY`**: Konsistent mit Services via ENV-Variable.
### Fix: Gateway `build.gradle.kts`
- Redundante direkte `micrometer-tracing-bridge-brave`-Dependency entfernt
(bereits transitiv via `monitoring-client` vorhanden).
## ✅ OIDC Client im Frontend (2026-03-09, gleiche Session)
Login-Flow mit PKCE Authorization Code Flow (S256) für `frontend-client` implementiert.
### Neue Dateien
- **`frontend/core/auth/src/commonMain/.../Sha256.kt`**: Pure Kotlin SHA-256 (FIPS 180-4) + Base64URL-Encoding — kein
expect/actual, läuft auf JVM/JS/Wasm.
- **`frontend/core/auth/src/commonMain/.../PkceHelper.kt`**: Code Verifier, Code Challenge (S256), State Generator.
- **`frontend/core/auth/src/commonMain/.../OidcCallback.kt`**: `OidcCallbackResult` sealed class + expect
`launchOidcFlow()`, `consumePendingOidcCallback()`, `getOidcRedirectUri()`.
- **`frontend/core/auth/src/jvmMain/.../OidcCallback.jvm.kt`**: Eingebetteter `HttpServer` (Port 18080) +
`Desktop.browse()` + `CompletableDeferred` (Timeout 5 min).
- **`frontend/core/auth/src/jsMain/.../OidcCallback.js.kt`**: `window.location.href` Redirect + URL-Parameter-Parsing
beim App-Start + History-Bereinigung via `replaceState`.
### Geänderte Dateien
- **`frontend/core/domain/.../AppConstants.kt`**: `KEYCLOAK_CLIENT_ID` → `frontend-client`, OIDC-Konstanten ergänzt.
- **`frontend/core/auth/src/commonMain/.../AuthApiClient.kt`**: `buildAuthorizationUrl()` (PKCE URL-Builder) +
`exchangeCodeForToken()` (Code → Token).
- **`frontend/core/auth/src/commonMain/.../LoginViewModel.kt`**: `isOidcLoading`-State, `startOidcFlow()`,
`handleOidcCallbackResult()`, JS-Init-Callback-Check.
- **`frontend/core/auth/src/commonMain/.../LoginScreen.kt`**: Divider + `OutlinedButton` „Mit Keycloak anmelden" mit
Spinner bei laufendem Flow.
### Architektur-Entscheidungen
- **Kein `ktor-client-auth`**: Der OIDC-Flow wird manuell implementiert — `ktor-client-auth` unterstützt Authorization
Code + PKCE nicht nativ für KMP.
- **Pure Kotlin SHA-256**: Kein `expect/actual` nötig — `kotlin.math` + reine Bitoperationen reichen aus.
- **JVM-Callback-Server** auf `localhost:18080`: Standard-Muster für Desktop-Apps (RFC 8252 „OAuth 2.0 for Native
Apps").
- **JS-Redirect-Flow**: Kein Popup — volle Seitenweiterleitung. Code Verifier wird in `sessionStorage` gespeichert (nur
aktueller Tab).
- **State-Validierung**: CSRF-Schutz via State-Parameter-Vergleich im ViewModel.
## ✅ Gateway CircuitBreaker (2026-03-09, gleiche Session)
Verifikation ergab: Der ursprüngliche `ClassNotFoundException`-Bug ist durch Spring Cloud `2025.0.1` vollständig
behoben.
### Befund
- **Dependency:** `spring-cloud-starter-circuitbreaker-reactor-resilience4j` — korrekte Reactive-Variante für
WebFlux/Gateway.
- **`GatewayConfig.kt`:** CircuitBreaker korrekt als Route-Filter konfiguriert:
```kotlin
circuitBreaker {
it.name = "pingServiceCB"
it.fallbackUri = URI.create("forward:/fallback/ping")
}
```
- **`FallbackController.kt`:** Fallback-Endpunkt `/fallback/ping` implementiert.
- **`GatewayMetricsConfig.kt`:** CB-Events werden als Prometheus-Counter (`gateway_circuit_breaker_events_total`)
erfasst.
- **Resilience4j `2.3.0`:** Kompatibel mit Spring Cloud 2025.0.1 / Spring Boot 3.5.x — kein Versionskonflikt.
### Fazit
Kein Code-Change erforderlich. Der Fix war implizit durch das Spring Cloud Downgrade auf `2025.0.1` bereits enthalten.
## ✅ Docker-Stabilität End-to-End (2026-03-09, gleiche Session)
Vollständige Analyse aller Docker Compose Dateien (`dc-infra`, `dc-backend`, `dc-ops`, `dc-gui`) sowie `.env` und
gemounteter Konfigs.
### Befund & Fixes
#### Fix 1: `.env` — `KC_COMMAND` Regression 🔴
- **Problem:** `.env` hatte `KC_COMMAND=start-dev --import-realm` — exakt der Bug vom 2026-03-06 Log. Das pre-built
Keycloak-Image startet im falschen Dev-Modus, OIDC-Flow schlägt fehl.
- **Fix:** `KC_COMMAND=start --optimized --import-realm`
#### Fix 2: `base-application.yaml` — Valkey Env-Var-Namen 🟠
- **Problem:** `base-application.yaml` (gemountet in allen Backend-Services) nutzte `${SPRING_DATA_REDIS_HOST}` /
`${SPRING_DATA_REDIS_PORT}` / `${SPRING_DATA_REDIS_PASSWORD}` als Env-Var-Namen. `dc-backend.yaml` setzt aber nur
`SPRING_DATA_VALKEY_*` → Redis-Host fiel auf `localhost`-Default zurück statt auf den `valkey`-Container.
- **Fix:** Env-Var-Namen auf `${SPRING_DATA_VALKEY_HOST}`, `${SPRING_DATA_VALKEY_PORT}`,
`${SPRING_DATA_VALKEY_PASSWORD}` umgestellt. Spring Boot Property-Pfad `spring.data.redis.*` bleibt korrekt.
#### Fix 3: `gateway/application.yaml` — Ungültiger Property-Namespace 🟠
- **Problem:** Gateway nutzte `spring.data.valkey.*` — kein valider Spring Boot Auto-Konfigurations-Namespace. Spring
Boot kennt nur `spring.data.redis.*` für die Redis/Lettuce-Autoconfiguration.
- **Fix:** `spring.data.valkey` → `spring.data.redis` (Env-Vars `SPRING_DATA_VALKEY_*` bleiben).
### Verifiziert: Korrekte Konfigurationen
- **Startup-Reihenfolge:** Postgres → Keycloak → Consul → Valkey → Gateway/Ping (via `depends_on` + Healthchecks) ✅
- **Netzwerk:** Alle Services im `meldestelle-network` ✅
- **Zipkin:** `service_started` (stateless, kein Healthcheck nötig) ✅
- **Consul Healthcheck:** curl auf `/v1/status/leader` ✅
- **Keycloak Healthcheck:** curl auf `localhost:9000/health/ready` ✅
## 🐛 Bugfix: Keycloak Healthcheck schlägt fehl obwohl Keycloak läuft (2026-03-09)
### Symptom
`docker compose --profile infra up -d` — Keycloak startet korrekt (Port 8080 + 9000 gebunden,
Realm importiert), wird von Docker aber nie als `healthy` markiert. Dieses Problem existierte
bereits früher und führte damals zur Entfernung des Healthchecks.
### Root Cause 1: `/health/ready` vs. `/health/live`
Keycloak 26.x führt beim Start den JGroups-Cluster-Join-Prozess aus. Bei Single-Node-Betrieb
(kein anderer Knoten im Netz) versucht Keycloak 10× einem Ghost-Node beizutreten — jeder Versuch
dauert 2 Sekunden = **20 Sekunden Verzögerung** nach dem eigentlichen Startup.
- **`/health/ready`** wartet auf vollständige Cluster-Formation → gibt während der JOIN-Phase `503`
zurück.
- **`/health/live`** prüft nur ob der Prozess läuft — unabhängig vom Cluster-Status.
Mit `start_period: 60s` und `interval: 15s` feuerte der erste Check in genau diesem 20s-Fenster
→ false-negative → nach 10 Retries → `unhealthy`.
### Root Cause 2: `KC_HOSTNAME_STRICT_HTTPS` — deprecated v1-Option
`WARNING: Hostname v1 options [hostname-strict-https] are still in use` erscheint in jedem
Startup-Log. In Keycloak 26.x wurde das Hostname-System auf v2 umgestellt. `KC_HOSTNAME_STRICT_HTTPS`
ist ein v1-Parameter der ignoriert wird — HTTP-Zugriff wird ausschließlich über `KC_HTTP_ENABLED`
gesteuert.
### Fixes in `dc-infra.yaml`
| Parameter | Vorher | Nachher | Begründung |
|----------------------------|------------------|-----------------|------------------------------------------------------|
| `test` | `…/health/ready` | `…/health/live` | Kein false-negative während JGroups-JOIN |
| `start_period` | `"60s"` | `"120s"` | Mehr Puffer für langsamen First-Start (Realm-Import) |
| `interval` | `"15s"` | `"20s"` | Weniger Stress während Startup-Phase |
| `timeout` | `"5s"` | `"10s"` | Mehr Zeit für Endpoint-Antwort |
| `retries` | `"10"` (String) | `10` (Integer) | Korrekter YAML-Typ |
| `KC_HOSTNAME_STRICT_HTTPS` | gesetzt | **entfernt** | Deprecated v1-Option, erzeugt Warning |
### Gelerntes
- **`/health/live` ist für Single-Node-Keycloak die richtige Wahl** — `ready` ist für Cluster-Szenarien
konzipiert wo mehrere Nodes koordiniert hochfahren müssen.
- `retries` in Docker Compose Healthcheck sollte als Integer, nicht als String angegeben werden.
## ✅ Ping Service — Tracer Bullet Analyse & Fix (2026-03-09, gleiche Session)
Vollständige Analyse des Ping Service Stacks (Backend → Gateway → Frontend). Ergebnis: Die gesamte
fachliche Implementierung war bereits vorhanden. Ein einziger kritischer Bug gefunden und behoben.
### Befund: Stack vollständig implementiert
- **Backend** (`backend/services/ping/ping-service`): Domain, Application, Persistence, Controller,
Security (`at.mocode.infrastructure.security` via `@ComponentScan`), DB-Migrations V1+V2 — vollständig.
- **Contracts** (`contracts/ping-api`): `PingResponse`, `EnhancedPingResponse`, `HealthResponse`,
`PingEvent`, `PingApi` — vollständig.
- **Gateway** (`backend/infrastructure/gateway`): Routing `/api/ping/**` → `stripPrefix(1)` →
Ping Service mit CircuitBreaker Fallback — korrekt.
- **Frontend** (`frontend/features/ping-feature`): `PingApiKoinClient`, `PingViewModel`, `PingScreen`,
`PingSyncService`, Koin DI — vollständig.
- **Shell-Integration**: `MainApp.kt` — `AppScreen.Ping` eingebunden, „Ping-Service"-Button auf Home-Screen,
Navigation korrekt.
### Fix: `PingApiKoinClient.kt` — Query-Parameter-Mismatch 🔴
- **Problem:** `syncPings()` sendete `?lastSyncTimestamp=...` als Query-Parameter.
Backend `@RequestParam` erwartet `?since=...` — Delta-Sync lieferte immer alle Daten.
- **Fix:** `url.parameters.append("lastSyncTimestamp", ...)` → `url.parameters.append("since", ...)`
## ✅ Gitea-Pipeline & Runner Analyse (2026-03-09, gleiche Session)
### Befund: Aktiver CI/CD-Kern
- `.gitea/workflows/docker-publish.yaml` ist der einzige aktiv ausgeführte Workflow — grün seit 06.03.2026.
- Matrix: 4 Services (`keycloak`, `api-gateway`, `ping-service`, `web-app`), `max-parallel: 1` (RAM-Schutz).
- Native `linux/arm64`-Builds auf VM 102 (`10.0.0.23`), Push in interne Registry `10.0.0.22:3000`.
### Verifiziert (Screenshot 2026-03-09_12-47)
- `insecure-registries: ["10.0.0.22:3000"]` permanent in `/etc/docker/daemon.json` gesetzt ✅
- `act_runner`: `active (running)` seit 06.03.2026, `enabled; preset: enabled` ✅
- `systemctl is-enabled act_runner` → `enabled` ✅
### Bereinigung `.github/`-Ordner
- **Problem:** 6 GitHub Actions Workflows in `.github/workflows/` — auf Gitea nie ausgeführt (toter Code).
- **Zusätzlich:** `ci-main.yml`, `deploy-proxmox.yml`, `ssot-guard.yml` referenzierten falschen Pfad
`docker/docker-compose.yaml`.
- **Fix:** `pr-guard.yml` (prüft hartcodierte Versionen in `build.gradle.kts`) nach `.gitea/workflows/pr-guard.yaml`
migriert.
- **Fix:** Gesamten `.github/`-Ordner gelöscht.
- `youtrack-sync.yml` war nicht aktiv und wurde mitgelöscht — kein Handlungsbedarf.
### Aktive Workflows nach Bereinigung
| Datei | Trigger | Zweck |
|----------------------------------------|--------------|------------------------------------------------|
| `.gitea/workflows/docker-publish.yaml` | Push `main` | Build & Push aller Docker-Images |
| `.gitea/workflows/pr-guard.yaml` | Pull Request | Prüft hartcodierte Versionen in Gradle-Dateien |
## Bugfix: Keycloak Healthcheck — Finale Korrektur (Port 8080 → 9000)
**Problem:** Keycloak 26.5.5 startet sauber (~15s), wird aber dauerhaft nicht `healthy`.
**Root Cause (final):** Der vorherige Fix hatte Port 9000 → 8080 geändert — das war **falsch**.
| Port | Zweck | Health-Endpoint |
|----------|-----------------------------------------|-------------------------------------|
| **8080** | Haupt-HTTP (Login, Admin-Console, APIs) | ❌ kennt `/health/*` **nicht** → 404 |
| **9000** | Management (Actuator, Health, Metrics) | ✅ `/health/live`, `/health/ready` |
`curl http://localhost:8080/health/live` → **404** → `curl -f` schlägt fehl → `unhealthy`.
**Fix `dc-infra.yaml`:**
```yaml
test: ["CMD-SHELL", "curl -sf --max-time 5 http://localhost:9000/health/live || exit 1"]
interval: "15s"
timeout: "10s"
retries: 5
start_period: "90s"
```
**Warum `/health/live` statt `/health/ready`?**
`/health/ready` wartet auf JGroups-Cluster-Formation (Single-Node: 10×2s JOIN-Timeout = 20s Verzögerung). `/health/live`
prüft nur Prozess-Liveness — sofort `UP` sobald Quarkus läuft.
**`start_period: 90s`** — Puffer für First-Run mit DB-Schema-Init (Liquibase-Migrations ~15–20s).
## 🔜 Nächste Schritte
- **TLS/HTTPS** — Langfristig: `KC_HOSTNAME_STRICT_HTTPS=true` setzen, sobald TLS eingerichtet ist.
- **Entries Service** — Beginn der Implementierung des ersten echten Fach-Services ("Nennungen").
---
## Bugfix: Keycloak Healthcheck — Echter Root Cause (curl nicht vorhanden)
**Zeitpunkt:** 2026-03-09 ~15:15
### Root Cause
Das Keycloak-Image `quay.io/keycloak/keycloak:26.5.5` basiert auf **`ubi9-micro`** — einem minimalen Red Hat Image *
*ohne `curl` / `wget` / Package Manager**.
Alle bisherigen Port-Fixes (8080 → 9000) waren korrekt, aber der `curl`-Befehl scheiterte im Container mit
`command not found`.
Keycloak selbst lief die ganze Zeit einwandfrei — nur der Healthcheck-Befehl fehlte das nötige Tool.
### Fix
| Datei | Änderung |
|-----------------|---------------------------------------------|
| `dc-infra.yaml` | Healthcheck: `curl` → Bash `/dev/tcp`-Trick |
**Neuer Healthcheck (kein externes Tool nötig):**
```yaml
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000 && printf 'GET /health/live HTTP/1.0\r\nHost: localhost\r\n\r\n' >&3 && cat <&3 | grep -q '"status":"UP"'"]
```
### Warum `/dev/tcp` funktioniert
- Bash hat `/dev/tcp` eingebaut — verfügbar ohne Installation
- `exec 3<>/dev/tcp/localhost/9000` öffnet TCP-Verbindung auf Port 9000
- `printf ... >&3` sendet einen minimalen HTTP/1.0 GET-Request
- `cat <&3 | grep -q '"status":"UP"'` liest Antwort und prüft Health-Status
### Gelernt
> Vor jedem `curl`-basierten Healthcheck prüfen: Ist `curl` im Container-Image vorhanden?
> Minimale Images (`ubi9-micro`, `distroless`, `alpine` ohne curl-Paket) haben es **nicht**.
> Sicherer Standard: `/dev/tcp` (bash built-in) statt `curl` — funktioniert immer wenn bash vorhanden ist.
---
## Bugfix: Keycloak Healthcheck — grep-Pattern Leerzeichen
**Zeitpunkt:** 2026-03-09 ~15:27
### Root Cause
Nach dem `/dev/tcp`-Fix war der Healthcheck immer noch `unhealthy` (ExitCode=1).
Der grep-Pattern lautete `'"status":"UP"'` (kein Leerzeichen nach `:`), die tatsächliche
JSON-Antwort von Keycloak enthält jedoch `"status": "UP"` (mit Leerzeichen nach dem Doppelpunkt).
**Beweis via `docker exec`:**
```
HTTP/1.0 200 OK
{
"status": "UP",
"checks": []
}
```
grep nach `"status":"UP"` → kein Match → ExitCode 1 → unhealthy.
### Fix
| Datei | Änderung |
|-----------------|--------------------------------------------------|
| `dc-infra.yaml` | grep-Pattern: `"status":"UP"` → `"status": "UP"` |
**Vorher:**
```yaml
test: ["CMD-SHELL", "... | grep -q '\"status\":\"UP\"'"]
```
**Nachher:**
```yaml
test: ["CMD-SHELL", "... | grep -q '\"status\": \"UP\"'"]
```
### Ergebnis
Nach `docker compose up -d keycloak`: Status **healthy**, 2x ExitCode=0 bestätigt.
### Gelernt
> Den Healthcheck-Befehl immer manuell via `docker exec` testen und die **exakte** API-Antwort
> prüfen — JSON-Formatierung (Leerzeichen, Einrückung) kann je nach Keycloak-Version variieren.
> Sicherer wäre `grep -q '"UP"'` ohne den Key, um Formatierungsunterschiede zu vermeiden.
---
## Optimierung: Healthcheck grep-Pattern vereinfacht
**Zeitpunkt:** 2026-03-09 ~15:30
### Maßnahme
Das grep-Pattern wurde weiter vereinfacht auf `grep -q '"UP"'`, um robust gegen
JSON-Formatierungsänderungen (Leerzeichen, Einrückung) in zukünftigen Keycloak-Versionen zu sein.
| Datei | Vorher | Nachher |
|-----------------|----------------------------|------------------|
| `dc-infra.yaml` | `grep -q '"status": "UP"'` | `grep -q '"UP"'` |
### Ergebnis
Status **healthy**, ExitCode=0 bestätigt. Pattern ist nun versionsunabhängig.
---
## Bugfix: Login fehlgeschlagen — `directAccessGrantsEnabled` für `frontend-client`
**Zeitpunkt:** 2026-03-09 ~15:41
### Symptom
Web-App (`localhost:4000`) zeigte beim direkten Username/Passwort-Login:
```
Login fehlgeschlagen: HTTP 400 - {"error":"unauthorized_client","error_description":"Client not allowed for direct access grants"}
```
### Root Cause
`AuthApiClient.kt` verwendet `AppConstants.KEYCLOAK_CLIENT_ID = "frontend-client"` mit
`grant_type=password` (Resource Owner Password Credentials / ROPC-Flow).
Der `frontend-client` hatte in `meldestelle-realm.json` jedoch `directAccessGrantsEnabled: false` —
bewusst deaktiviert als reiner PKCE-Client, dabei aber vergessen, dass die Web-App ROPC nutzt.
### Fix
| Datei | Änderung |
|--------------------------|----------------------------------------------------------------|
| `meldestelle-realm.json` | `frontend-client`: `directAccessGrantsEnabled: false` → `true` |
Live-Update via Admin REST API (kein Neustart nötig):
```bash
# Token holen
TOKEN=$(curl -s -X POST "http://localhost:8180/realms/master/protocol/openid-connect/token" \
-d "username=kc-admin&password=kc-password&grant_type=password&client_id=admin-cli" ...)
# Client-UUID ermitteln & PUT mit directAccessGrantsEnabled:true
curl -X PUT "http://localhost:8180/admin/realms/meldestelle/clients/$UUID" \
-H "Authorization: Bearer $TOKEN" -d "$UPDATED_CLIENT_JSON"
# → HTTP 204
```
### Verifikation
```bash
curl -X POST ".../realms/meldestelle/protocol/openid-connect/token" \
-d "username=admin&password=Admin%231234&grant_type=password&client_id=frontend-client"
# → "token_type":"Bearer" ✅
```
### Gelernt
> `--import-realm` in Keycloak importiert Realms **nur beim ersten Start** (wenn Realm noch nicht existiert).
> Änderungen an der realm.json nach dem ersten Import müssen entweder via Admin REST API oder
> durch Löschen des Volumes (Datenverlust!) neu eingespielt werden.
> Für Dev-Umgebungen: `KC_COMMAND=start --optimized --import-realm` mit separatem Reset-Script
> oder Admin-API-Skript für Konfigurationsänderungen vorbereiten.
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

@@ -132,6 +132,92 @@ class AuthApiClient(
}
}
/**
* Baut die Authorization URL für den PKCE Authorization Code Flow.
* Redirect URI muss in Keycloak als erlaubt konfiguriert sein.
*/
fun buildAuthorizationUrl(
redirectUri: String,
codeVerifier: String,
state: String,
scopes: String = "openid profile email"
): String {
val challenge = PkceHelper.computeCodeChallengeS256(codeVerifier)
val base = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/auth"
return buildString {
append(base)
append("?response_type=code")
append("&client_id=").append(clientId)
append("&redirect_uri=").append(redirectUri.encodeUrlParam())
append("&scope=").append(scopes.encodeUrlParam())
append("&state=").append(state)
append("&code_challenge=").append(challenge)
append("&code_challenge_method=S256")
}
}
/**
* Tauscht den Authorization Code gegen ein Access Token aus (PKCE).
*/
suspend fun exchangeCodeForToken(
code: String,
codeVerifier: String,
redirectUri: String
): LoginResponse {
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
return try {
val response = httpClient.submitForm(
url = tokenEndpoint,
formParameters = Parameters.build {
append("grant_type", "authorization_code")
append("client_id", clientId)
append("code", code)
append("redirect_uri", redirectUri)
append("code_verifier", codeVerifier)
}
) {
contentType(ContentType.Application.FormUrlEncoded)
}
if (response.status.isSuccess()) {
val kc = response.body<KeycloakTokenResponse>()
LoginResponse(
success = true,
token = kc.accessToken,
message = null
)
} else {
val errorBody = response.bodyAsText()
LoginResponse(
success = false,
message = "Token-Austausch fehlgeschlagen: HTTP ${response.status.value} - $errorBody"
)
}
} catch (e: Exception) {
LoginResponse(
success = false,
message = "Token-Austausch Fehler: ${e.message}"
)
}
}
companion object {
/**
* Einfaches URL-Encoding für Query-Parameter-Werte.
* Ersetzt die Zeichen, die in URL-Parametern kodiert werden müssen.
*/
internal fun String.encodeUrlParam(): String = this
.replace("%", "%25")
.replace(" ", "%20")
.replace("+", "%2B")
.replace("&", "%26")
.replace("=", "%3D")
.replace("?", "%3F")
.replace("#", "%23")
.replace(":", "%3A")
.replace("/", "%2F")
}
@Serializable
private data class KeycloakTokenResponse(
@SerialName("access_token") val accessToken: String,
@@ -0,0 +1,42 @@
package at.mocode.frontend.core.auth.data
/**
* Ergebnis eines OIDC-Callback-Empfangs (Authorization Code + State).
*/
sealed class OidcCallbackResult {
/** Authorization Code wurde erfolgreich empfangen. */
data class Success(val code: String, val state: String) : OidcCallbackResult()
/** Fehler vom Authorization Server (z. B. access_denied). */
data class Error(val error: String, val description: String? = null) : OidcCallbackResult()
/** Timeout — Benutzer hat den Login-Vorgang nicht abgeschlossen. */
object Timeout : OidcCallbackResult()
/** JS-spezifisch: Seite wurde weitergeleitet, Ergebnis kommt beim nächsten Start. */
object Redirecting : OidcCallbackResult()
}
/**
* Startet den plattformspezifischen OIDC-Flow:
* - JVM: öffnet Browser + wartet auf Callback über lokalen HTTP-Server
* - JS: leitet die Seite zu Keycloak weiter (keine Rückkehr bis Neustart)
*/
expect suspend fun launchOidcFlow(
authUrl: String,
callbackPort: Int
): OidcCallbackResult
/**
* Prüft beim App-Start, ob in der aktuellen URL ein OIDC-Callback steckt.
* Relevant für JS/Browser: nach Keycloak-Redirect enthält die URL code= und state=.
* Gibt null zurück, wenn kein Callback vorhanden.
*/
expect fun consumePendingOidcCallback(): OidcCallbackResult?
/**
* Gibt die plattformspezifische Redirect URI zurück:
* - JVM: http://localhost:18080/callback
* - JS: window.location.origin + /auth/callback
*/
expect fun getOidcRedirectUri(): String
@@ -0,0 +1,38 @@
package at.mocode.frontend.core.auth.data
import kotlin.random.Random
/**
* PKCE-Hilfsfunktionen für den OIDC Authorization Code Flow mit S256.
* RFC 7636: Proof Key for Code Exchange
*/
internal object PkceHelper {
/**
* Erzeugt einen zufälligen Code Verifier (43–128 Zeichen, URL-safe Base64).
* RFC 7636 §4.1: unreserved characters [A-Z / a-z / 0-9 / "-" / "." / "_" / "~"]
*/
fun generateCodeVerifier(): String {
val bytes = ByteArray(32)
Random.nextBytes(bytes)
return bytes.encodeBase64Url()
}
/**
* Berechnet den Code Challenge aus dem Verifier via SHA-256 + Base64URL.
* RFC 7636 §4.2: code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
*/
fun computeCodeChallengeS256(codeVerifier: String): String {
val hash = Sha256.hash(codeVerifier.encodeToByteArray())
return hash.encodeBase64Url()
}
/**
* Erzeugt einen zufälligen State-Parameter für CSRF-Schutz.
*/
fun generateState(): String {
val bytes = ByteArray(16)
Random.nextBytes(bytes)
return bytes.encodeBase64Url()
}
}
@@ -0,0 +1,116 @@
package at.mocode.frontend.core.auth.data
/**
* Reines Kotlin SHA-256 nach FIPS 180-4.
* Läuft auf allen KMP-Plattformen ohne expect/actual (JVM, JS, Wasm).
*/
internal object Sha256 {
private val K = intArrayOf(
0x428a2f98.toInt(), 0x71374491.toInt(), 0xb5c0fbcf.toInt(), 0xe9b5dba5.toInt(),
0x3956c25b.toInt(), 0x59f111f1.toInt(), 0x923f82a4.toInt(), 0xab1c5ed5.toInt(),
0xd807aa98.toInt(), 0x12835b01.toInt(), 0x243185be.toInt(), 0x550c7dc3.toInt(),
0x72be5d74.toInt(), 0x80deb1fe.toInt(), 0x9bdc06a7.toInt(), 0xc19bf174.toInt(),
0xe49b69c1.toInt(), 0xefbe4786.toInt(), 0x0fc19dc6.toInt(), 0x240ca1cc.toInt(),
0x2de92c6f.toInt(), 0x4a7484aa.toInt(), 0x5cb0a9dc.toInt(), 0x76f988da.toInt(),
0x983e5152.toInt(), 0xa831c66d.toInt(), 0xb00327c8.toInt(), 0xbf597fc7.toInt(),
0xc6e00bf3.toInt(), 0xd5a79147.toInt(), 0x06ca6351.toInt(), 0x14292967.toInt(),
0x27b70a85.toInt(), 0x2e1b2138.toInt(), 0x4d2c6dfc.toInt(), 0x53380d13.toInt(),
0x650a7354.toInt(), 0x766a0abb.toInt(), 0x81c2c92e.toInt(), 0x92722c85.toInt(),
0xa2bfe8a1.toInt(), 0xa81a664b.toInt(), 0xc24b8b70.toInt(), 0xc76c51a3.toInt(),
0xd192e819.toInt(), 0xd6990624.toInt(), 0xf40e3585.toInt(), 0x106aa070.toInt(),
0x19a4c116.toInt(), 0x1e376c08.toInt(), 0x2748774c.toInt(), 0x34b0bcb5.toInt(),
0x391c0cb3.toInt(), 0x4ed8aa4a.toInt(), 0x5b9cca4f.toInt(), 0x682e6ff3.toInt(),
0x748f82ee.toInt(), 0x78a5636f.toInt(), 0x84c87814.toInt(), 0x8cc70208.toInt(),
0x90befffa.toInt(), 0xa4506ceb.toInt(), 0xbef9a3f7.toInt(), 0xc67178f2.toInt()
)
fun hash(input: ByteArray): ByteArray {
var h0 = 0x6a09e667.toInt()
var h1 = 0xbb67ae85.toInt()
var h2 = 0x3c6ef372.toInt()
var h3 = 0xa54ff53a.toInt()
var h4 = 0x510e527f.toInt()
var h5 = 0x9b05688c.toInt()
var h6 = 0x1f83d9ab.toInt()
var h7 = 0x5be0cd19.toInt()
val msgLen = input.size
val bitLen = msgLen.toLong() * 8
val paddedLen = ((msgLen + 9 + 63) / 64) * 64
val msg = ByteArray(paddedLen)
input.copyInto(msg)
msg[msgLen] = 0x80.toByte()
for (i in 0..7) {
msg[paddedLen - 8 + i] = ((bitLen ushr (56 - i * 8)) and 0xFF).toByte()
}
val w = IntArray(64)
var offset = 0
while (offset < paddedLen) {
for (i in 0..15) {
w[i] = ((msg[offset + i * 4].toInt() and 0xFF) shl 24) or
((msg[offset + i * 4 + 1].toInt() and 0xFF) shl 16) or
((msg[offset + i * 4 + 2].toInt() and 0xFF) shl 8) or
(msg[offset + i * 4 + 3].toInt() and 0xFF)
}
for (i in 16..63) {
val s0 = w[i - 15].rotateRight(7) xor w[i - 15].rotateRight(18) xor (w[i - 15] ushr 3)
val s1 = w[i - 2].rotateRight(17) xor w[i - 2].rotateRight(19) xor (w[i - 2] ushr 10)
w[i] = w[i - 16] + s0 + w[i - 7] + s1
}
var a = h0;
var b = h1;
var c = h2;
var d = h3
var e = h4;
var f = h5;
var g = h6;
var h = h7
for (i in 0..63) {
val s1 = e.rotateRight(6) xor e.rotateRight(11) xor e.rotateRight(25)
val ch = (e and f) xor (e.inv() and g)
val temp1 = h + s1 + ch + K[i] + w[i]
val s0 = a.rotateRight(2) xor a.rotateRight(13) xor a.rotateRight(22)
val maj = (a and b) xor (a and c) xor (b and c)
val temp2 = s0 + maj
h = g; g = f; f = e; e = d + temp1
d = c; c = b; b = a; a = temp1 + temp2
}
h0 += a; h1 += b; h2 += c; h3 += d
h4 += e; h5 += f; h6 += g; h7 += h
offset += 64
}
val result = ByteArray(32)
val vals = intArrayOf(h0, h1, h2, h3, h4, h5, h6, h7)
for (i in vals.indices) {
result[i * 4] = (vals[i] ushr 24).toByte()
result[i * 4 + 1] = (vals[i] ushr 16).toByte()
result[i * 4 + 2] = (vals[i] ushr 8).toByte()
result[i * 4 + 3] = vals[i].toByte()
}
return result
}
}
/** Base64URL-Kodierung ohne Padding (RFC 4648 §5). */
internal fun ByteArray.encodeBase64Url(): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
val sb = StringBuilder()
var i = 0
while (i < size) {
val b0 = get(i).toInt() and 0xFF
val b1 = if (i + 1 < size) get(i + 1).toInt() and 0xFF else 0
val b2 = if (i + 2 < size) get(i + 2).toInt() and 0xFF else 0
sb.append(chars[b0 shr 2])
sb.append(chars[((b0 and 3) shl 4) or (b1 shr 4)])
if (i + 1 < size) sb.append(chars[((b1 and 15) shl 2) or (b2 shr 6)])
if (i + 2 < size) sb.append(chars[b2 and 63])
i += 3
}
return sb.toString()
}
@@ -56,7 +56,7 @@ fun LoginScreen(
value = uiState.username,
onValueChange = viewModel::updateUsername,
label = { Text("Benutzername") },
enabled = !uiState.isLoading,
enabled = !uiState.isLoading && !uiState.isOidcLoading,
isError = uiState.usernameError != null,
supportingText = uiState.usernameError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(
@@ -76,7 +76,7 @@ fun LoginScreen(
value = uiState.password,
onValueChange = viewModel::updatePassword,
label = { Text("Passwort") },
enabled = !uiState.isLoading,
enabled = !uiState.isLoading && !uiState.isOidcLoading,
isError = uiState.passwordError != null,
supportingText = uiState.passwordError?.let { { Text(it) } },
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
@@ -147,6 +147,42 @@ fun LoginScreen(
Text("Anmelden")
}
}
// Divider
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
HorizontalDivider(modifier = Modifier.weight(1f))
Text(
text = " oder ",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
HorizontalDivider(modifier = Modifier.weight(1f))
}
// OIDC / Keycloak button
OutlinedButton(
onClick = { viewModel.startOidcFlow() },
enabled = !uiState.isLoading && !uiState.isOidcLoading,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
) {
if (uiState.isOidcLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("Weiterleitung ...")
} else {
Text("Mit Keycloak anmelden")
}
}
}
}
@@ -2,8 +2,8 @@ package at.mocode.frontend.core.auth.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.core.auth.data.AuthApiClient
import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.auth.data.*
import at.mocode.frontend.core.domain.AppConstants
import io.ktor.client.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -17,17 +17,20 @@ data class LoginUiState(
val username: String = "",
val password: String = "",
val isLoading: Boolean = false,
val isOidcLoading: Boolean = false,
val isAuthenticated: Boolean = false,
val errorMessage: String? = null,
val usernameError: String? = null,
val passwordError: String? = null
) {
val canLogin: Boolean
get() = username.isNotBlank() && password.isNotBlank() && !isLoading
get() = username.isNotBlank() && password.isNotBlank() && !isLoading && !isOidcLoading
}
/**
* ViewModel for handling login authentication logic
* ViewModel for handling login authentication logic.
* Unterstützt sowohl Password Grant (web-app) als auch
* PKCE Authorization Code Flow (frontend-client).
*/
class LoginViewModel(
private val authTokenManager: AuthTokenManager,
@@ -38,19 +41,28 @@ class LoginViewModel(
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
// PKCE-State für den laufenden OIDC-Flow (in-memory)
private var pendingCodeVerifier: String? = null
private var pendingState: String? = null
init {
// Observe AuthTokenManager state to keep UI in sync
// AuthTokenManager-State beobachten → UI synchron halten
viewModelScope.launch {
authTokenManager.authState.collect { authState ->
_uiState.value = _uiState.value.copy(
isAuthenticated = authState.isAuthenticated
)
// If logged out, clear credentials
_uiState.value = _uiState.value.copy(isAuthenticated = authState.isAuthenticated)
if (!authState.isAuthenticated) {
_uiState.value = LoginUiState()
}
}
}
// JS-Browser: Pending OIDC-Callback aus URL lesen (nach Keycloak-Redirect)
viewModelScope.launch {
val pending = consumePendingOidcCallback()
if (pending != null) {
handleOidcCallbackResult(pending)
}
}
}
fun updateUsername(username: String) {
@@ -69,21 +81,18 @@ class LoginViewModel(
)
}
/** Password Grant Login (Fallback / web-app). */
fun login() {
val currentState = _uiState.value
// Validate input
if (currentState.username.isBlank()) {
_uiState.value = currentState.copy(usernameError = "Benutzername ist erforderlich")
return
}
if (currentState.password.isBlank()) {
_uiState.value = currentState.copy(passwordError = "Passwort ist erforderlich")
return
}
// Start the login process
_uiState.value = currentState.copy(
isLoading = true,
errorMessage = null,
@@ -97,30 +106,9 @@ class LoginViewModel(
username = currentState.username,
password = currentState.password
)
if (loginResponse.success && loginResponse.token != null) {
// Store the JWT token
authTokenManager.setToken(loginResponse.token)
// isAuthenticated will be updated via the flow collector in init block
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = null
)
// Fire-and-forget: Trigger Backend Sync so the user exists in Members
viewModelScope.launch {
try {
// Fire-and-forget sync call; Bearer token added by Ktor Auth plugin
// IMPORTANT: Use relative path (no leading slash) so Ktor appends it to baseUrl
// baseUrl is http://localhost:8080/api (JS) or http://localhost:8081 (JVM)
// Result: http://localhost:8080/api/members/sync -> Proxy -> http://localhost:8081/api/members/sync
// apiClient.post("members/sync")
} catch (_: Exception) {
// Non-fatal: Wir zeigen Sync-Fehler im Login nicht an
}
}
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = null)
} else {
_uiState.value = _uiState.value.copy(
isLoading = false,
@@ -135,4 +123,104 @@ class LoginViewModel(
}
}
}
/**
* Startet den OIDC Authorization Code Flow mit PKCE für frontend-client.
* JVM: öffnet Browser + wartet auf Callback.
* JS: leitet die Seite zu Keycloak weiter (kehrt nicht zurück).
*/
fun startOidcFlow() {
if (_uiState.value.isOidcLoading) return
_uiState.value = _uiState.value.copy(isOidcLoading = true, errorMessage = null)
viewModelScope.launch {
try {
val codeVerifier = PkceHelper.generateCodeVerifier()
val state = PkceHelper.generateState()
val redirectUri = getOidcRedirectUri()
pendingCodeVerifier = codeVerifier
pendingState = state
val authUrl = authApiClient.buildAuthorizationUrl(
redirectUri = redirectUri,
codeVerifier = codeVerifier,
state = state,
scopes = AppConstants.OIDC_SCOPES
)
val result = launchOidcFlow(authUrl, AppConstants.OIDC_CALLBACK_PORT)
handleOidcCallbackResult(result)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isOidcLoading = false,
errorMessage = "OIDC-Flow fehlgeschlagen: ${e.message}"
)
}
}
}
/** Verarbeitet das Ergebnis des OIDC-Callbacks (intern + von JS-Startup). */
private suspend fun handleOidcCallbackResult(result: OidcCallbackResult) {
when (result) {
is OidcCallbackResult.Success -> {
val verifier = pendingCodeVerifier
val expectedState = pendingState
if (verifier == null) {
_uiState.value = _uiState.value.copy(
isOidcLoading = false,
errorMessage = "OIDC-Fehler: Kein Code Verifier gefunden. Bitte erneut anmelden."
)
return
}
if (expectedState != null && expectedState != result.state) {
_uiState.value = _uiState.value.copy(
isOidcLoading = false,
errorMessage = "OIDC-Sicherheitsfehler: State stimmt nicht überein."
)
return
}
val redirectUri = getOidcRedirectUri()
val tokenResponse = authApiClient.exchangeCodeForToken(
code = result.code,
codeVerifier = verifier,
redirectUri = redirectUri
)
pendingCodeVerifier = null
pendingState = null
if (tokenResponse.success && tokenResponse.token != null) {
authTokenManager.setToken(tokenResponse.token)
_uiState.value = _uiState.value.copy(isOidcLoading = false, errorMessage = null)
} else {
_uiState.value = _uiState.value.copy(
isOidcLoading = false,
errorMessage = tokenResponse.message ?: "Token-Austausch fehlgeschlagen"
)
}
}
is OidcCallbackResult.Error -> {
_uiState.value = _uiState.value.copy(
isOidcLoading = false,
errorMessage = "Keycloak-Fehler: ${result.error}" +
(result.description?.let { " — $it" } ?: "")
)
}
OidcCallbackResult.Timeout -> {
_uiState.value = _uiState.value.copy(
isOidcLoading = false,
errorMessage = "Timeout: Anmeldung wurde nicht abgeschlossen."
)
}
OidcCallbackResult.Redirecting -> {
// JS: Seite wird weitergeleitet — isOidcLoading bleibt true (Spinner sichtbar)
}
}
}
}
@@ -0,0 +1,68 @@
package at.mocode.frontend.core.auth.data
import kotlinx.browser.window
/**
* JS-Implementierung: leitet die gesamte Seite zu Keycloak weiter.
* Die Funktion kehrt nicht zurück —
* der Callback wird beim nächsten App-Start via [consumePendingOidcCallback] verarbeitet.
*/
actual suspend fun launchOidcFlow(
authUrl: String,
callbackPort: Int // wird im Browser ignoriert (kein lokaler Server)
): OidcCallbackResult {
window.location.href = authUrl
// Diese Zeile wird nie erreicht — der Browser leitet weiter
return OidcCallbackResult.Redirecting
}
/**
* JS-Implementierung: prüft beim App-Start, ob die aktuelle URL einen
* OIDC-Callback enthält (code= und state= Parameter von Keycloak).
* Bereinigt nach dem Auslesen die URL via replaceState.
*/
actual fun consumePendingOidcCallback(): OidcCallbackResult? {
val search = window.location.search
if (!search.contains("code=")) return null
val params = parseJsQueryParams(search.removePrefix("?"))
val code = params["code"] ?: return null
val state = params["state"] ?: return null
val error = params["error"]
// URL bereinigen — Code soll nicht im Browser-Verlauf bleiben
try {
window.history.replaceState(null, "", window.location.pathname)
} catch (_: Throwable) {
// ignore — kein kritischer Fehler
}
return if (error != null) {
OidcCallbackResult.Error(
error = error,
description = params["error_description"]
)
} else {
OidcCallbackResult.Success(code = code, state = state)
}
}
private fun parseJsQueryParams(query: String): Map<String, String> =
query.split("&")
.filter { it.contains("=") }
.associate {
val parts = it.split("=", limit = 2)
parts[0] to decodeURIComponent(parts.getOrElse(1) { "" })
}
actual fun getOidcRedirectUri(): String {
val origin = try {
window.location.origin
} catch (_: Throwable) {
"http://localhost"
}
return origin + at.mocode.frontend.core.domain.AppConstants.OIDC_REDIRECT_URI_JS_PATH
}
private fun decodeURIComponent(encoded: String): String =
js("decodeURIComponent(encoded)").unsafeCast<String>()
@@ -0,0 +1,95 @@
package at.mocode.frontend.core.auth.data
import com.sun.net.httpserver.HttpServer
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.awt.Desktop
import java.net.InetSocketAddress
import java.net.URI
/**
* JVM-Implementierung: öffnet den System-Browser und startet einen lokalen
* HTTP-Callback-Server. Wartet maximal 5 Minuten auf den Keycloak-Redirect.
*/
actual suspend fun launchOidcFlow(
authUrl: String,
callbackPort: Int
): OidcCallbackResult {
val deferred = CompletableDeferred<OidcCallbackResult>()
val server = HttpServer.create(InetSocketAddress("localhost", callbackPort), 0)
server.createContext("/callback") { exchange ->
val query = exchange.requestURI.query ?: ""
val params = parseQueryParams(query)
val result = when {
params["error"] != null -> OidcCallbackResult.Error(
error = params["error"]!!,
description = params["error_description"]
)
params["code"] != null && params["state"] != null -> OidcCallbackResult.Success(
code = params["code"]!!,
state = params["state"]!!
)
else -> OidcCallbackResult.Error("invalid_callback", "Fehlende code oder state Parameter")
}
val html = when (result) {
is OidcCallbackResult.Success ->
"<html><body style='font-family:sans-serif;text-align:center;padding:40px'>" +
"<h2>✅ Anmeldung erfolgreich!</h2>" +
"<p>Dieses Fenster kann geschlossen werden.</p></body></html>"
else ->
"<html><body style='font-family:sans-serif;text-align:center;padding:40px'>" +
"<h2>❌ Anmeldung fehlgeschlagen</h2>" +
"<p>${(result as? OidcCallbackResult.Error)?.error}</p></body></html>"
}
val responseBytes = html.toByteArray()
exchange.sendResponseHeaders(200, responseBytes.size.toLong())
exchange.responseBody.use { it.write(responseBytes) }
server.stop(0)
deferred.complete(result)
}
server.executor = null
server.start()
// Browser öffnen
try {
withContext(Dispatchers.IO) {
Desktop.getDesktop().browse(URI(authUrl))
}
} catch (e: Exception) {
server.stop(0)
return OidcCallbackResult.Error("browser_error", "Browser konnte nicht geöffnet werden: ${e.message}")
}
// Warten auf Callback (max. 5 Minuten)
return withTimeoutOrNull(5 * 60 * 1000L) {
deferred.await()
} ?: run {
server.stop(0)
OidcCallbackResult.Timeout
}
}
/**
* Auf JVM: kein URL-basierter Pending-Callback (Desktop-App startet frisch).
*/
actual fun consumePendingOidcCallback(): OidcCallbackResult? = null
actual fun getOidcRedirectUri(): String = at.mocode.frontend.core.domain.AppConstants.OIDC_REDIRECT_URI_JVM
private fun parseQueryParams(query: String): Map<String, String> =
query.split("&")
.filter { it.contains("=") }
.associate {
val (k, v) = it.split("=", limit = 2)
k to java.net.URLDecoder.decode(v, "UTF-8")
}
@@ -1,11 +1,22 @@
package at.mocode.frontend.core.domain
object AppConstants {
// Keycloak Configuration
// Note: These defaults are for local development.
// In production, these should be provided via build config or environment variables.
const val KEYCLOAK_URL = "http://localhost:8180"
const val KEYCLOAK_REALM = "meldestelle"
const val KEYCLOAK_CLIENT_ID = "web-app"
const val KEYCLOAK_CLIENT_SECRET = "" // Public client usually has no secret
// Native/Desktop KMP client (PKCE Authorization Code Flow, kein Secret)
const val KEYCLOAK_CLIENT_ID = "frontend-client"
const val KEYCLOAK_CLIENT_SECRET = "" // Public client — kein Secret
// OIDC Redirect URI für JVM Desktop (loopback callback server)
const val OIDC_CALLBACK_PORT = 18080
const val OIDC_REDIRECT_URI_JVM = "http://localhost:$OIDC_CALLBACK_PORT/callback"
// OIDC Redirect URI für JS/Browser (gleiche Origin, Route /auth/callback)
const val OIDC_REDIRECT_URI_JS_PATH = "/auth/callback"
// OIDC Scopes
const val OIDC_SCOPES = "openid profile email"
}
@@ -1,13 +1,9 @@
package at.mocode.ping.feature.data
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingEvent
import at.mocode.ping.api.PingResponse
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import at.mocode.ping.api.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
/**
* PingApi-Implementierung, die einen bereitgestellten HttpClient verwendet (z. B. den per Dependency Injection
@@ -39,7 +35,7 @@ class PingApiKoinClient(private val client: HttpClient) : PingApi {
override suspend fun syncPings(since: Long): List<PingEvent> {
return client.get("/api/ping/sync") {
url.parameters.append("lastSyncTimestamp", since.toString())
url.parameters.append("since", since.toString())
}.body()
}
}