Compare commits

...

10 Commits

Author SHA1 Message Date
50e58c6ba9 docs: Update deployment status - Gitea repository created, Traefik issues fixed
Some checks failed
Security Vulnerability Scan / Check for Dependency Changes (push) Successful in 34s
Security Vulnerability Scan / Composer Security Audit (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been cancelled
🚀 Build & Deploy Image / Build Docker Image (push) Has been cancelled
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been cancelled
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been cancelled
🚀 Build & Deploy Image / Determine Build Necessity (push) Has been cancelled
2025-11-08 13:13:42 +01:00
63799a7655 test: CI/CD pipeline production test 2025-11-08 12:15:36 +01:00
7093693cfb test: CI/CD pipeline staging test 2025-11-08 11:16:01 +01:00
9e77ac3b42 feat(traefik): Add Gitea service definition for Traefik
- Add gitea-service.yml with proper timeout configuration
- Service definition required for Traefik to route to Gitea
- Replaces old gitea.yml file that was removed
2025-11-07 23:24:20 +01:00
e8a26d7807 test: CI/CD pipeline staging test - Redis aktiviert, Bad Gateway dokumentiert 2025-11-07 20:54:44 +01:00
c088d08639 test: CI/CD pipeline staging test - Repository Setup automatisiert 2025-11-07 20:17:35 +01:00
07c054b5ff test: CI/CD pipeline staging test 2025-11-07 19:52:48 +01:00
1963b10749 feat: Integrate Ansible playbooks into CI/CD workflows
- Add deploy-application-code.yml for Git-based code deployment
- Add install-composer-dependencies.yml for dependency installation
- Add deploy-image.yml for Docker image deployment
- Update build-image.yml to use Ansible playbooks
- Update manual-deploy.yml to use Ansible playbooks
- Add ANSIBLE_VAULT_PASSWORD secret handling
2025-11-07 18:14:11 +01:00
cf903f2582 fix(traefik): update local dev ports and gitea SSH IP
- Change Traefik local HTTP port from 8080 to 8081 (conflict with cadvisor)
- Change Traefik dashboard port to 8093 (conflicts with cadvisor, Hyperion)
- Update Gitea SSH service IP from 172.23.0.2 to 172.23.0.3
- Note: Gitea SSH works directly via Docker port mapping in local dev
- Traefik TCP routing only needed for production (host network mode)
2025-11-05 14:51:37 +01:00
95147ff23e refactor(deployment): Remove WireGuard VPN dependency and restore public service access
Remove WireGuard integration from production deployment to simplify infrastructure:
- Remove docker-compose-direct-access.yml (VPN-bound services)
- Remove VPN-only middlewares from Grafana, Prometheus, Portainer
- Remove WireGuard middleware definitions from Traefik
- Remove WireGuard IPs (10.8.0.0/24) from Traefik forwarded headers

All monitoring services now publicly accessible via subdomains:
- grafana.michaelschiemer.de (with Grafana native auth)
- prometheus.michaelschiemer.de (with Basic Auth)
- portainer.michaelschiemer.de (with Portainer native auth)

All services use Let's Encrypt SSL certificates via Traefik.
2025-11-05 12:48:25 +01:00
250 changed files with 31125 additions and 1157 deletions

View File

@@ -963,169 +963,57 @@ jobs:
chmod 600 ~/.ssh/production
ssh-keyscan -H ${{ env.DEPLOYMENT_HOST }} >> ~/.ssh/known_hosts
- name: Deploy to Staging Server
- name: Install Ansible
run: |
set -e
sudo apt-get update
sudo apt-get install -y ansible python3-pip
pip3 install --user ansible-core docker
DEPLOYMENT_HOST="${{ env.DEPLOYMENT_HOST }}"
REGISTRY_HOST="${{ env.REGISTRY }}"
IMAGE_NAME="${{ env.IMAGE_NAME }}"
DEFAULT_IMAGE="${REGISTRY_HOST}/${IMAGE_NAME}:latest"
# Always use latest image - if a build happened, it would have pushed to latest anyway
# Using latest ensures we always get the most recent image, whether it was just built or not
SELECTED_IMAGE="$DEFAULT_IMAGE"
STACK_PATH_DISPLAY="~/deployment/stacks/staging"
SELECTED_TAG="${SELECTED_IMAGE##*:}"
SELECTED_REPO="${SELECTED_IMAGE%:*}"
if [ -z "$SELECTED_REPO" ] || [ "$SELECTED_REPO" = "$SELECTED_IMAGE" ]; then
FALLBACK_IMAGE="$DEFAULT_IMAGE"
- name: Create Ansible Vault password file
run: |
if [ -n "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" ]; then
echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > /tmp/vault_pass
chmod 600 /tmp/vault_pass
echo "✅ Vault password file created"
else
FALLBACK_IMAGE="${SELECTED_REPO}:latest"
echo "⚠️ ANSIBLE_VAULT_PASSWORD secret not set, using empty password file"
touch /tmp/vault_pass
chmod 600 /tmp/vault_pass
fi
echo "🚀 Starting staging deployment..."
echo " Image: ${SELECTED_IMAGE}"
echo " Tag: ${SELECTED_TAG}"
echo " Host: ${DEPLOYMENT_HOST}"
echo " Stack: ${STACK_PATH_DISPLAY}"
- name: Deploy Application Code to Staging
run: |
cd /workspace/repo/deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/deploy-application-code.yml \
-e "deployment_environment=staging" \
-e "deployment_hosts=production" \
-e "git_branch=staging" \
--vault-password-file /tmp/vault_pass \
--private-key ~/.ssh/production
FULL_IMAGE_ARG=$(printf '%q' "$SELECTED_IMAGE")
FALLBACK_IMAGE_ARG=$(printf '%q' "$FALLBACK_IMAGE")
IMAGE_NAME_ARG=$(printf '%q' "$IMAGE_NAME")
REGISTRY_ARG=$(printf '%q' "$REGISTRY_HOST")
- name: Install Composer Dependencies
run: |
cd /workspace/repo/deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/install-composer-dependencies.yml \
-e "deployment_environment=staging" \
--vault-password-file /tmp/vault_pass \
--private-key ~/.ssh/production
ssh -i ~/.ssh/production \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
deploy@${DEPLOYMENT_HOST} "bash -s -- $FULL_IMAGE_ARG $FALLBACK_IMAGE_ARG $IMAGE_NAME_ARG $REGISTRY_ARG" <<'EOF'
set -e
FULL_IMAGE="$1"
FALLBACK_IMAGE="$2"
IMAGE_NAME="$3"
REGISTRY="$4"
shift 4
CURRENT_USER="$(whoami)"
USER_HOME="$(getent passwd "$CURRENT_USER" | cut -d: -f6 2>/dev/null)"
[ -z "$USER_HOME" ] && USER_HOME="$HOME"
[ -z "$USER_HOME" ] && USER_HOME="/home/$CURRENT_USER"
STACK_TARGET="${USER_HOME}/deployment/stacks/staging"
# Ensure staging stack directory exists
mkdir -p "${STACK_TARGET}"
cd "${STACK_TARGET}"
declare -a REGISTRY_TARGETS=()
if [ -n "${REGISTRY}" ]; then
REGISTRY_TARGETS+=("${REGISTRY}")
fi
for IMAGE_REF in "${FULL_IMAGE}" "${FALLBACK_IMAGE}"; do
if [ -n "${IMAGE_REF}" ]; then
HOST_PART="${IMAGE_REF%%/*}"
if [ -n "${HOST_PART}" ]; then
if ! printf '%s\n' "${REGISTRY_TARGETS[@]}" | grep -qx "${HOST_PART}"; then
REGISTRY_TARGETS+=("${HOST_PART}")
fi
fi
fi
done
for TARGET in "${REGISTRY_TARGETS[@]}"; do
[ -z "${TARGET}" ] && continue
echo "🔐 Logging in to Docker registry ${TARGET}..."
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${TARGET}" \
-u "${{ secrets.REGISTRY_USER }}" \
--password-stdin || echo "⚠️ Registry login failed for ${TARGET}, continuing..."
done
DEPLOY_IMAGE="$FULL_IMAGE"
echo "📥 Pulling image ${DEPLOY_IMAGE}..."
if ! docker pull "${DEPLOY_IMAGE}"; then
if [ -n "${FALLBACK_IMAGE}" ] && [ "${DEPLOY_IMAGE}" != "${FALLBACK_IMAGE}" ]; then
echo "⚠️ Failed to pull ${DEPLOY_IMAGE}, attempting fallback ${FALLBACK_IMAGE}"
if docker pull "${FALLBACK_IMAGE}"; then
DEPLOY_IMAGE="${FALLBACK_IMAGE}"
echo " Using fallback image ${DEPLOY_IMAGE}"
else
echo "❌ Failed to pull fallback image ${FALLBACK_IMAGE}"
exit 1
fi
else
echo "❌ Failed to pull image ${DEPLOY_IMAGE}"
exit 1
fi
fi
# Copy base and staging docker-compose files if they don't exist
if [ ! -f docker-compose.base.yml ]; then
echo "⚠️ docker-compose.base.yml not found, copying from repo..."
cp /workspace/repo/docker-compose.base.yml . || {
echo "❌ Failed to copy docker-compose.base.yml"
exit 1
}
fi
if [ ! -f docker-compose.staging.yml ]; then
echo "⚠️ docker-compose.staging.yml not found, copying from repo..."
cp /workspace/repo/docker-compose.staging.yml . || {
echo "❌ Failed to copy docker-compose.staging.yml"
exit 1
}
fi
# Update docker-compose.staging.yml with new image tag
echo "📝 Updating docker-compose.staging.yml with new image tag..."
sed -i "s|image:.*/${IMAGE_NAME}:.*|image: ${DEPLOY_IMAGE}|g" docker-compose.staging.yml
echo "✅ Updated docker-compose.staging.yml:"
grep "image:" docker-compose.staging.yml | head -5
# Ensure networks exist
echo "🔗 Ensuring Docker networks exist..."
docker network create traefik-public 2>/dev/null || true
docker network create staging-internal 2>/dev/null || true
echo "🔄 Starting/updating services..."
# Use --pull missing instead of --pull always since we already pulled the specific image
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml up -d --pull missing --force-recreate || {
echo "❌ Failed to start services"
exit 1
}
echo "⏳ Waiting for services to start..."
sleep 15
# Pull latest code from Git repository - always sync code when deploying
echo "🔄 Pulling latest code from Git repository in staging-app container..."
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml exec -T staging-app bash -c "cd /var/www/html && git -c safe.directory=/var/www/html fetch origin staging && git -c safe.directory=/var/www/html reset --hard origin/staging && git -c safe.directory=/var/www/html clean -fd" || echo "⚠️ Git pull failed, container will sync on next restart"
# Also trigger a restart to ensure entrypoint script runs
echo "🔄 Restarting staging-app to ensure all services are up-to-date..."
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml restart staging-app || echo "⚠️ Failed to restart staging-app"
# Fix nginx upstream configuration - critical fix for 502 errors
# sites-available/default uses 127.0.0.1:9000 but PHP-FPM runs in staging-app container
echo "🔧 Fixing nginx PHP-FPM upstream configuration (post-deploy fix)..."
sleep 5
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml exec -T staging-nginx sed -i '/upstream php-upstream {/,/}/s|server 127.0.0.1:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default || echo "⚠️ Upstream fix (127.0.0.1) failed"
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml exec -T staging-nginx sed -i '/upstream php-upstream {/,/}/s|server localhost:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default || echo "⚠️ Upstream fix (localhost) failed"
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml exec -T staging-nginx nginx -t && docker compose -f docker-compose.base.yml -f docker-compose.staging.yml restart staging-nginx || echo "⚠️ Nginx config test or restart failed"
echo "✅ Nginx configuration fixed and reloaded"
echo "⏳ Waiting for services to stabilize..."
sleep 10
echo "📊 Container status:"
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml ps
echo "✅ Staging deployment completed!"
EOF
- name: Deploy Docker Image to Staging
run: |
cd /workspace/repo/deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/deploy-image.yml \
-e "deployment_environment=staging" \
-e "deployment_hosts=production" \
-e "image_tag=latest" \
-e "docker_registry=${{ env.REGISTRY }}" \
-e "docker_registry_username=${{ secrets.REGISTRY_USER }}" \
-e "docker_registry_password=${{ secrets.REGISTRY_PASSWORD }}" \
--vault-password-file /tmp/vault_pass \
--private-key ~/.ssh/production
- name: Wait for deployment to stabilize
run: sleep 30
@@ -1133,16 +1021,42 @@ jobs:
- name: Health check
id: health
run: |
echo "🔍 Performing health checks..."
# Basic health check
BASIC_HEALTH_OK=false
for i in {1..10}; do
if curl -f -k https://staging.michaelschiemer.de/health; then
echo "✅ Health check passed"
exit 0
if curl -f -k -s https://staging.michaelschiemer.de/health > /dev/null 2>&1; then
echo "✅ Basic health check passed"
BASIC_HEALTH_OK=true
break
fi
echo "⏳ Waiting for staging service... (attempt $i/10)"
sleep 10
done
echo "❌ Health check failed"
exit 1
if [ "$BASIC_HEALTH_OK" != "true" ]; then
echo "❌ Basic health check failed"
exit 1
fi
# Extended health check (if available)
echo "🔍 Checking extended health status..."
HEALTH_SUMMARY=$(curl -f -k -s https://staging.michaelschiemer.de/admin/health/api/summary 2>/dev/null || echo "")
if [ -n "$HEALTH_SUMMARY" ]; then
OVERALL_STATUS=$(echo "$HEALTH_SUMMARY" | grep -o '"overall_status":"[^"]*"' | cut -d'"' -f4 || echo "unknown")
echo "📊 Overall health status: $OVERALL_STATUS"
if [ "$OVERALL_STATUS" = "unhealthy" ]; then
echo "⚠️ Extended health check shows unhealthy status"
echo " Health summary: $HEALTH_SUMMARY"
else
echo "✅ Extended health check passed"
fi
else
echo " Extended health check endpoint not available (this is OK)"
fi
echo "✅ All health checks completed"
- name: Notify deployment success
if: success()
@@ -1204,14 +1118,27 @@ jobs:
chmod 600 ~/.ssh/production
ssh-keyscan -H ${{ env.DEPLOYMENT_HOST }} >> ~/.ssh/known_hosts
- name: Deploy to Production Server
- name: Install Ansible
run: |
set -e
sudo apt-get update
sudo apt-get install -y ansible python3-pip
pip3 install --user ansible-core docker
DEPLOYMENT_HOST="${{ env.DEPLOYMENT_HOST }}"
REGISTRY="${{ env.REGISTRY }}"
IMAGE_NAME="${{ env.IMAGE_NAME }}"
- name: Create Ansible Vault password file
run: |
if [ -n "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" ]; then
echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > /tmp/vault_pass
chmod 600 /tmp/vault_pass
echo "✅ Vault password file created"
else
echo "⚠️ ANSIBLE_VAULT_PASSWORD secret not set, using empty password file"
touch /tmp/vault_pass
chmod 600 /tmp/vault_pass
fi
- name: Determine image tag
id: image_tag
run: |
# Get image tag from build job output with fallback
IMAGE_TAG="${{ needs.build.outputs.image_tag }}"
@@ -1229,76 +1156,42 @@ jobs:
fi
fi
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
STACK_PATH="~/deployment/stacks/application"
echo "IMAGE_TAG=${IMAGE_TAG}" >> $GITHUB_OUTPUT
echo "📦 Image Tag: ${IMAGE_TAG}"
echo "🚀 Starting production deployment..."
echo " Image: ${FULL_IMAGE}"
echo " Tag: ${IMAGE_TAG}"
echo " Host: ${DEPLOYMENT_HOST}"
echo " Stack: ${STACK_PATH}"
- name: Deploy Application Code to Production
run: |
cd /workspace/repo/deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/deploy-application-code.yml \
-e "deployment_environment=production" \
-e "deployment_hosts=production" \
-e "git_branch=main" \
--vault-password-file /tmp/vault_pass \
--private-key ~/.ssh/production
echo "📋 Deploying docker-compose configuration files..."
cd /workspace/repo
scp -i ~/.ssh/production \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
docker-compose.base.yml docker-compose.production.yml \
deploy@${DEPLOYMENT_HOST}:${STACK_PATH}/ || {
echo "❌ Failed to deploy docker-compose files"
exit 1
}
echo "✅ Docker Compose files deployed successfully"
- name: Install Composer Dependencies
run: |
cd /workspace/repo/deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/install-composer-dependencies.yml \
-e "deployment_environment=production" \
--vault-password-file /tmp/vault_pass \
--private-key ~/.ssh/production
ssh -i ~/.ssh/production \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
deploy@${DEPLOYMENT_HOST} <<EOF
set -e
cd ${STACK_PATH}
echo "🔐 Logging in to Docker registry..."
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${REGISTRY} \
-u "${{ secrets.REGISTRY_USER }}" \
--password-stdin || echo "⚠️ Registry login failed, continuing..."
echo "📥 Pulling image ${FULL_IMAGE}..."
docker pull ${FULL_IMAGE} || {
echo "❌ Failed to pull image ${FULL_IMAGE}"
exit 1
}
# Ensure docker-compose files exist (rsync deployment handles this)
if [ ! -f docker-compose.base.yml ] || [ ! -f docker-compose.production.yml ]; then
echo "❌ Docker Compose files not found in ${STACK_PATH}"
echo " Expected files are deployed via rsync in deployment scripts"
exit 1
fi
echo "✅ Docker Compose files present in deployment directory"
echo "📝 Updating docker-compose.production.yml with new image tag..."
sed -i "s|image:.*/${IMAGE_NAME}:.*|image: ${FULL_IMAGE}|g" docker-compose.production.yml
sed -i "s|image:.*/${IMAGE_NAME}@.*|image: ${FULL_IMAGE}|g" docker-compose.production.yml
echo "✅ Updated docker-compose.production.yml:"
grep "image:" docker-compose.production.yml | head -5
echo "🔄 Restarting services..."
# Use --pull missing instead of --pull always since we already pulled the specific image
docker compose -f docker-compose.base.yml -f docker-compose.production.yml up -d --pull missing --force-recreate || {
echo "❌ Failed to restart services"
exit 1
}
echo "⏳ Waiting for services to start..."
sleep 10
echo "📊 Container status:"
docker compose -f docker-compose.base.yml -f docker-compose.production.yml ps
echo "✅ Production deployment completed!"
EOF
- name: Deploy Docker Image to Production
run: |
cd /workspace/repo/deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/deploy-image.yml \
-e "deployment_environment=production" \
-e "deployment_hosts=production" \
-e "image_tag=${{ steps.image_tag.outputs.IMAGE_TAG }}" \
-e "docker_registry=${{ env.REGISTRY }}" \
-e "docker_registry_username=${{ secrets.REGISTRY_USER }}" \
-e "docker_registry_password=${{ secrets.REGISTRY_PASSWORD }}" \
--vault-password-file /tmp/vault_pass \
--private-key ~/.ssh/production
- name: Wait for deployment to stabilize
run: sleep 30
@@ -1306,16 +1199,42 @@ jobs:
- name: Health check
id: health
run: |
echo "🔍 Performing health checks..."
# Basic health check
BASIC_HEALTH_OK=false
for i in {1..10}; do
if curl -f -k https://michaelschiemer.de/health; then
echo "✅ Health check passed"
exit 0
if curl -f -k -s https://michaelschiemer.de/health > /dev/null 2>&1; then
echo "✅ Basic health check passed"
BASIC_HEALTH_OK=true
break
fi
echo "⏳ Waiting for production service... (attempt $i/10)"
sleep 10
done
echo "❌ Health check failed"
exit 1
if [ "$BASIC_HEALTH_OK" != "true" ]; then
echo "❌ Basic health check failed"
exit 1
fi
# Extended health check (if available)
echo "🔍 Checking extended health status..."
HEALTH_SUMMARY=$(curl -f -k -s https://michaelschiemer.de/admin/health/api/summary 2>/dev/null || echo "")
if [ -n "$HEALTH_SUMMARY" ]; then
OVERALL_STATUS=$(echo "$HEALTH_SUMMARY" | grep -o '"overall_status":"[^"]*"' | cut -d'"' -f4 || echo "unknown")
echo "📊 Overall health status: $OVERALL_STATUS"
if [ "$OVERALL_STATUS" = "unhealthy" ]; then
echo "⚠️ Extended health check shows unhealthy status"
echo " Health summary: $HEALTH_SUMMARY"
else
echo "✅ Extended health check passed"
fi
else
echo " Extended health check endpoint not available (this is OK)"
fi
echo "✅ All health checks completed"
- name: Notify deployment success
if: success()

View File

@@ -112,173 +112,57 @@ jobs:
chmod 600 ~/.ssh/production
ssh-keyscan -H ${{ env.DEPLOYMENT_HOST }} >> ~/.ssh/known_hosts
- name: Deploy to Staging Server
- name: Install Ansible
run: |
set -e
sudo apt-get update
sudo apt-get install -y ansible python3-pip
pip3 install --user ansible-core docker
DEPLOYMENT_HOST="${{ env.DEPLOYMENT_HOST }}"
REGISTRY_HOST="${{ needs.determine-image.outputs.registry_host }}"
IMAGE_NAME="${{ needs.determine-image.outputs.image_name }}"
DEPLOY_IMAGE="${{ needs.determine-image.outputs.image_url }}"
IMAGE_TAG="${{ needs.determine-image.outputs.image_tag }}"
DEFAULT_IMAGE="${REGISTRY_HOST}/${IMAGE_NAME}:latest"
FALLBACK_IMAGE="$DEFAULT_IMAGE"
SELECTED_IMAGE="$DEPLOY_IMAGE"
if [ -z "$SELECTED_IMAGE" ] || [ "$SELECTED_IMAGE" = "null" ]; then
SELECTED_IMAGE="$DEFAULT_IMAGE"
fi
STACK_PATH_DISPLAY="~/deployment/stacks/staging"
SELECTED_TAG="${SELECTED_IMAGE##*:}"
SELECTED_REPO="${SELECTED_IMAGE%:*}"
if [ -z "$SELECTED_REPO" ] || [ "$SELECTED_REPO" = "$SELECTED_IMAGE" ]; then
FALLBACK_IMAGE="$DEFAULT_IMAGE"
- name: Create Ansible Vault password file
run: |
if [ -n "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" ]; then
echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > /tmp/vault_pass
chmod 600 /tmp/vault_pass
echo "✅ Vault password file created"
else
FALLBACK_IMAGE="${SELECTED_REPO}:latest"
echo "⚠️ ANSIBLE_VAULT_PASSWORD secret not set, using empty password file"
touch /tmp/vault_pass
chmod 600 /tmp/vault_pass
fi
echo "🚀 Starting staging deployment..."
echo " Image: ${SELECTED_IMAGE}"
echo " Tag: ${SELECTED_TAG}"
echo " Host: ${DEPLOYMENT_HOST}"
echo " Stack: ${STACK_PATH_DISPLAY}"
- name: Deploy Application Code to Staging
run: |
cd /workspace/repo/deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/deploy-application-code.yml \
-e "deployment_environment=staging" \
-e "deployment_hosts=production" \
-e "git_branch=${{ steps.branch.outputs.BRANCH }}" \
--vault-password-file /tmp/vault_pass \
--private-key ~/.ssh/production
FULL_IMAGE_ARG=$(printf '%q' "$SELECTED_IMAGE")
FALLBACK_IMAGE_ARG=$(printf '%q' "$FALLBACK_IMAGE")
IMAGE_NAME_ARG=$(printf '%q' "$IMAGE_NAME")
REGISTRY_ARG=$(printf '%q' "$REGISTRY_HOST")
- name: Install Composer Dependencies
run: |
cd /workspace/repo/deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/install-composer-dependencies.yml \
-e "deployment_environment=staging" \
--vault-password-file /tmp/vault_pass \
--private-key ~/.ssh/production
ssh -i ~/.ssh/production \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
deploy@${DEPLOYMENT_HOST} "bash -s -- $FULL_IMAGE_ARG $FALLBACK_IMAGE_ARG $IMAGE_NAME_ARG $REGISTRY_ARG" <<'EOF'
set -e
FULL_IMAGE="$1"
FALLBACK_IMAGE="$2"
IMAGE_NAME="$3"
REGISTRY="$4"
shift 4
CURRENT_USER="$(whoami)"
USER_HOME="$(getent passwd "$CURRENT_USER" | cut -d: -f6 2>/dev/null)"
[ -z "$USER_HOME" ] && USER_HOME="$HOME"
[ -z "$USER_HOME" ] && USER_HOME="/home/$CURRENT_USER"
STACK_TARGET="${USER_HOME}/deployment/stacks/staging"
# Ensure staging stack directory exists
mkdir -p "${STACK_TARGET}"
cd "${STACK_TARGET}"
declare -a REGISTRY_TARGETS=()
if [ -n "${REGISTRY}" ]; then
REGISTRY_TARGETS+=("${REGISTRY}")
fi
for IMAGE_REF in "${FULL_IMAGE}" "${FALLBACK_IMAGE}"; do
if [ -n "${IMAGE_REF}" ]; then
HOST_PART="${IMAGE_REF%%/*}"
if [ -n "${HOST_PART}" ]; then
if ! printf '%s\n' "${REGISTRY_TARGETS[@]}" | grep -qx "${HOST_PART}"; then
REGISTRY_TARGETS+=("${HOST_PART}")
fi
fi
fi
done
for TARGET in "${REGISTRY_TARGETS[@]}"; do
[ -z "${TARGET}" ] && continue
echo "🔐 Logging in to Docker registry ${TARGET}..."
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${TARGET}" \
-u "${{ secrets.REGISTRY_USER }}" \
--password-stdin || echo "⚠️ Registry login failed for ${TARGET}, continuing..."
done
DEPLOY_IMAGE="$FULL_IMAGE"
echo "📥 Pulling image ${DEPLOY_IMAGE}..."
if ! docker pull "${DEPLOY_IMAGE}"; then
if [ -n "${FALLBACK_IMAGE}" ] && [ "${DEPLOY_IMAGE}" != "${FALLBACK_IMAGE}" ]; then
echo "⚠️ Failed to pull ${DEPLOY_IMAGE}, attempting fallback ${FALLBACK_IMAGE}"
if docker pull "${FALLBACK_IMAGE}"; then
DEPLOY_IMAGE="${FALLBACK_IMAGE}"
echo " Using fallback image ${DEPLOY_IMAGE}"
else
echo "❌ Failed to pull fallback image ${FALLBACK_IMAGE}"
exit 1
fi
else
echo "❌ Failed to pull image ${DEPLOY_IMAGE}"
exit 1
fi
fi
# Copy base and staging docker-compose files if they don't exist
if [ ! -f docker-compose.base.yml ]; then
echo "⚠️ docker-compose.base.yml not found, copying from repo..."
cp /workspace/repo/docker-compose.base.yml . || {
echo "❌ Failed to copy docker-compose.base.yml"
exit 1
}
fi
if [ ! -f docker-compose.staging.yml ]; then
echo "⚠️ docker-compose.staging.yml not found, copying from repo..."
cp /workspace/repo/docker-compose.staging.yml . || {
echo "❌ Failed to copy docker-compose.staging.yml"
exit 1
}
fi
# Update docker-compose.staging.yml with new image tag
echo "📝 Updating docker-compose.staging.yml with new image tag..."
sed -i "s|image:.*/${IMAGE_NAME}:.*|image: ${DEPLOY_IMAGE}|g" docker-compose.staging.yml
echo "✅ Updated docker-compose.staging.yml:"
grep "image:" docker-compose.staging.yml | head -5
# Ensure networks exist
echo "🔗 Ensuring Docker networks exist..."
docker network create traefik-public 2>/dev/null || true
docker network create staging-internal 2>/dev/null || true
echo "🔄 Starting/updating services..."
# Use --pull missing instead of --pull always since we already pulled the specific image
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml up -d --pull missing --force-recreate || {
echo "❌ Failed to start services"
exit 1
}
echo "⏳ Waiting for services to start..."
sleep 15
# Pull latest code from Git repository
echo "🔄 Pulling latest code from Git repository in staging-app container..."
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml exec -T staging-app bash -c "cd /var/www/html && git -c safe.directory=/var/www/html fetch origin staging && git -c safe.directory=/var/www/html reset --hard origin/staging && git -c safe.directory=/var/www/html clean -fd" || echo "⚠️ Git pull failed, container will sync on next restart"
# Also trigger a restart to ensure entrypoint script runs
echo "🔄 Restarting staging-app to ensure all services are up-to-date..."
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml restart staging-app || echo "⚠️ Failed to restart staging-app"
# Fix nginx upstream configuration - critical fix for 502 errors
# sites-available/default uses 127.0.0.1:9000 but PHP-FPM runs in staging-app container
echo "🔧 Fixing nginx PHP-FPM upstream configuration (post-deploy fix)..."
sleep 5
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml exec -T staging-nginx sed -i '/upstream php-upstream {/,/}/s|server 127.0.0.1:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default || echo "⚠️ Upstream fix (127.0.0.1) failed"
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml exec -T staging-nginx sed -i '/upstream php-upstream {/,/}/s|server localhost:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default || echo "⚠️ Upstream fix (localhost) failed"
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml exec -T staging-nginx nginx -t && docker compose -f docker-compose.base.yml -f docker-compose.staging.yml restart staging-nginx || echo "⚠️ Nginx config test or restart failed"
echo "✅ Nginx configuration fixed and reloaded"
echo "⏳ Waiting for services to stabilize..."
sleep 10
echo "📊 Container status:"
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml ps
echo "✅ Staging deployment completed!"
EOF
- name: Deploy Docker Image to Staging
run: |
cd /workspace/repo/deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/deploy-image.yml \
-e "deployment_environment=staging" \
-e "deployment_hosts=production" \
-e "image_tag=${{ needs.determine-image.outputs.image_tag }}" \
-e "docker_registry=${{ needs.determine-image.outputs.registry_host }}" \
-e "docker_registry_username=${{ secrets.REGISTRY_USER }}" \
-e "docker_registry_password=${{ secrets.REGISTRY_PASSWORD }}" \
--vault-password-file /tmp/vault_pass \
--private-key ~/.ssh/production
- name: Wait for deployment to stabilize
run: sleep 30
@@ -353,82 +237,57 @@ jobs:
chmod 600 ~/.ssh/production
ssh-keyscan -H ${{ env.DEPLOYMENT_HOST }} >> ~/.ssh/known_hosts
- name: Deploy to Production Server
- name: Install Ansible
run: |
set -e
sudo apt-get update
sudo apt-get install -y ansible python3-pip
pip3 install --user ansible-core docker
DEPLOYMENT_HOST="${{ env.DEPLOYMENT_HOST }}"
REGISTRY="${{ needs.determine-image.outputs.registry_host }}"
IMAGE_NAME="${{ needs.determine-image.outputs.image_name }}"
IMAGE_TAG="${{ needs.determine-image.outputs.image_tag }}"
- name: Create Ansible Vault password file
run: |
if [ -n "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" ]; then
echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > /tmp/vault_pass
chmod 600 /tmp/vault_pass
echo "✅ Vault password file created"
else
echo "⚠️ ANSIBLE_VAULT_PASSWORD secret not set, using empty password file"
touch /tmp/vault_pass
chmod 600 /tmp/vault_pass
fi
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
STACK_PATH="~/deployment/stacks/application"
- name: Deploy Application Code to Production
run: |
cd /workspace/repo/deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/deploy-application-code.yml \
-e "deployment_environment=production" \
-e "deployment_hosts=production" \
-e "git_branch=${{ steps.branch.outputs.BRANCH }}" \
--vault-password-file /tmp/vault_pass \
--private-key ~/.ssh/production
echo "🚀 Starting production deployment..."
echo " Image: ${FULL_IMAGE}"
echo " Tag: ${IMAGE_TAG}"
echo " Host: ${DEPLOYMENT_HOST}"
echo " Stack: ${STACK_PATH}"
- name: Install Composer Dependencies
run: |
cd /workspace/repo/deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/install-composer-dependencies.yml \
-e "deployment_environment=production" \
--vault-password-file /tmp/vault_pass \
--private-key ~/.ssh/production
ssh -i ~/.ssh/production \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
deploy@${DEPLOYMENT_HOST} <<EOF
set -e
cd ${STACK_PATH}
echo "🔐 Logging in to Docker registry..."
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${REGISTRY} \
-u "${{ secrets.REGISTRY_USER }}" \
--password-stdin || echo "⚠️ Registry login failed, continuing..."
echo "📥 Pulling image ${FULL_IMAGE}..."
docker pull ${FULL_IMAGE} || {
echo "❌ Failed to pull image ${FULL_IMAGE}"
exit 1
}
# Copy base and production docker-compose files if they don't exist
if [ ! -f docker-compose.base.yml ]; then
echo "⚠️ docker-compose.base.yml not found, copying from repo..."
cp /workspace/repo/docker-compose.base.yml . || {
echo "❌ Failed to copy docker-compose.base.yml"
exit 1
}
fi
if [ ! -f docker-compose.production.yml ]; then
echo "⚠️ docker-compose.production.yml not found, copying from repo..."
cp /workspace/repo/docker-compose.production.yml . || {
echo "❌ Failed to copy docker-compose.production.yml"
exit 1
}
fi
echo "📝 Updating docker-compose.production.yml with new image tag..."
sed -i "s|image:.*/${IMAGE_NAME}:.*|image: ${FULL_IMAGE}|g" docker-compose.production.yml
sed -i "s|image:.*/${IMAGE_NAME}@.*|image: ${FULL_IMAGE}|g" docker-compose.production.yml
echo "✅ Updated docker-compose.production.yml:"
grep "image:" docker-compose.production.yml | head -5
echo "🔄 Restarting services..."
# Use --pull missing instead of --pull always since we already pulled the specific image
docker compose -f docker-compose.base.yml -f docker-compose.production.yml up -d --pull missing --force-recreate || {
echo "❌ Failed to restart services"
exit 1
}
echo "⏳ Waiting for services to start..."
sleep 10
echo "📊 Container status:"
docker compose -f docker-compose.base.yml -f docker-compose.production.yml ps
echo "✅ Production deployment completed!"
EOF
- name: Deploy Docker Image to Production
run: |
cd /workspace/repo/deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/deploy-image.yml \
-e "deployment_environment=production" \
-e "deployment_hosts=production" \
-e "image_tag=${{ needs.determine-image.outputs.image_tag }}" \
-e "docker_registry=${{ needs.determine-image.outputs.registry_host }}" \
-e "docker_registry_username=${{ secrets.REGISTRY_USER }}" \
-e "docker_registry_password=${{ secrets.REGISTRY_PASSWORD }}" \
--vault-password-file /tmp/vault_pass \
--private-key ~/.ssh/production
- name: Wait for deployment to stabilize
run: sleep 30

1
.pipeline-test.txt Normal file
View File

@@ -0,0 +1 @@
# CI/CD Pipeline Test - 2025-11-07 19:52:48

View File

@@ -320,3 +320,5 @@ make setup
# Deployment (Code + Compose auf Server bringen)
make deploy
# CI/CD Pipeline Test - Fri Nov 7 08:54:41 PM CET 2025
# CI/CD Pipeline Test Production - Sat Nov 8 12:15:35 PM CET 2025

View File

@@ -92,15 +92,48 @@ deployment/
## Getting Started
### 🧪 Pipeline-Tests vorbereiten
**Vor dem ersten Deployment:**
1. **Prerequisites prüfen:**
```bash
./deployment/scripts/test-pipeline-prerequisites.sh
```
2. **Test-Anleitung lesen:**
- [Pipeline Test Checklist](docs/guides/pipeline-test-checklist.md) ⭐ - Schritt-für-Schritt Anleitung
- [Pipeline Testing Guide](docs/guides/pipeline-testing-guide.md) - Übersicht und Troubleshooting
3. **Backup-Test durchführen:**
```bash
./deployment/scripts/test-backup.sh
```
### 🚀 Quick Start: Code deployen
**Einfachste Methode:**
**Empfohlener Workflow (Staging → Production):**
1. **Push auf `staging` Branch** (Standard für Entwicklung)
```bash
git add .
git commit -m "feat: Add new feature"
git push origin main # → Automatisches Deployment!
git push origin staging # → Automatisches Deployment zu Staging
```
2. **Testen auf Staging**
- Staging URL: `https://staging.michaelschiemer.de`
- Tests durchführen und verifizieren
3. **Merge nach `main`** (nur nach erfolgreichem Test)
```bash
git checkout main
git merge staging
git push origin main # → Automatisches Deployment zu Production
```
**⚠️ Wichtig:** Niemals direkt auf `main` pushen - immer erst auf `staging` testen!
**Pipeline-Status:** `https://git.michaelschiemer.de/michael/michaelschiemer/actions`
**📖 Vollständige Anleitung:** Siehe [docs/guides/quick-start.md](docs/guides/quick-start.md) oder [docs/guides/code-change-workflow.md](docs/guides/code-change-workflow.md)
@@ -134,7 +167,26 @@ Dieses Playbook deployed alle Stacks:
- Docker Registry (Private Registry)
- Gitea (Git Server)
- Monitoring (Portainer, Grafana, Prometheus)
- **Application Stack** (PHP Application + Nginx + Redis + Queue Workers)
- **Production Stack** (PHP Application + Nginx + Redis + Queue Workers)
**Gitea Initial Setup (nach Infrastructure Deployment):**
```bash
# Automatische Initial Setup via Ansible
cd deployment/ansible
# 1. Gitea Initial Configuration (Admin-User erstellen)
ansible-playbook -i inventory/production.yml \
playbooks/setup-gitea-initial-config.yml \
--vault-password-file secrets/.vault_pass
# 2. Repository in Gitea erstellen und Git-Remote konfigurieren
ansible-playbook -i inventory/production.yml \
playbooks/setup-gitea-repository.yml \
--vault-password-file secrets/.vault_pass \
-e "repo_name=michaelschiemer" \
-e "repo_owner=michael" \
-e "repo_private=false"
```
**📖 Vollständige Setup-Anleitung:** Siehe [SETUP-GUIDE.md](SETUP-GUIDE.md)
@@ -145,33 +197,28 @@ Each stack has its own README with detailed configuration:
- [Traefik](stacks/traefik/README.md) - Reverse proxy setup
- [Gitea](stacks/gitea/README.md) - Git server configuration
- [Registry](stacks/registry/README.md) - Private registry setup
- [Application](stacks/application/README.md) - Application deployment
- [Production](stacks/production/README.md) - Production application deployment
- [PostgreSQL](stacks/postgresql/README.md) - Database configuration
- [Monitoring](stacks/monitoring/README.md) - Monitoring stack
## Deployment Commands
### Code deployen (Image-basiert)
### Automatisches Deployment (Empfohlen)
**Standard-Workflow: Staging → Production**
1. **Push auf `staging`** (Standard für Entwicklung)
```bash
cd deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/deploy-update.yml \
-e "image_tag=abc1234-1696234567"
git add .
git commit -m "feat: Add new feature"
git push origin staging # → Deployt zu Staging
```
### Code synchen (Git-basiert)
2. **Testen auf Staging**, dann **Merge nach `main`**
```bash
cd deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/sync-code.yml \
-e "git_branch=main"
```
### Rollback zu vorheriger Version
```bash
cd deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/rollback.yml
git checkout main
git merge staging
git push origin main # → Deployt zu Production
```
**📖 Vollständige Command-Referenz:** Siehe [docs/guides/deployment-commands.md](docs/guides/deployment-commands.md)
@@ -185,34 +232,54 @@ docker compose up -d
## CI/CD Pipeline
The CI/CD pipeline is defined in `.gitea/workflows/production-deploy.yml` and runs automatically on push to `main` branch.
The CI/CD pipeline is defined in `.gitea/workflows/build-image.yml` and runs automatically on push to `staging` or `main` branch.
### Quick Start: Deploy Code Changes
### Recommended Workflow: Staging → Production
**1. Push to `staging` (Standard for Development)**
```bash
# 1. Make changes locally
# Make changes locally
# ... edit files ...
# 2. Commit changes
# Commit and push to staging
git add .
git commit -m "feat: Add new feature"
# 3. Push to main → Automatic deployment starts
git push origin main
git push origin staging # → Deploys to Staging
```
**What happens automatically:**
**What happens automatically on `staging`:**
- ✅ Tests run (~2-5 min)
- ✅ Docker image is built (~3-5 min)
- ✅ Image is pushed to registry (~1-2 min)
- ✅ Ansible deployment runs (~2-4 min)
- ✅ Application stack is updated
- ✅ Deployment to Staging via SSH/SCP (~2-4 min)
- ✅ Staging stack is updated
**Total time:** ~8-15 minutes
**2. Test on Staging**
- Staging URL: `https://staging.michaelschiemer.de`
- Verify functionality and run tests
**3. Merge to `main` (Only after successful testing)**
```bash
git checkout main
git merge staging
git push origin main # → Deploys to Production
```
**What happens automatically on `main`:**
- ✅ Tests run (~2-5 min)
- ✅ Docker image is built (~3-5 min)
- ✅ Image is pushed to registry (~1-2 min)
- ✅ Deployment to Production via SSH/SCP (~2-4 min)
- ✅ Production stack is updated
**Total time per deployment:** ~8-15 minutes
**Status check:**
- Pipeline status: `https://git.michaelschiemer.de/michael/michaelschiemer/actions`
- Application status: `ssh deploy@94.16.110.151 "cd ~/deployment/stacks/application && docker compose ps"`
- Staging status: `ssh deploy@94.16.110.151 "cd ~/deployment/stacks/staging && docker compose ps"`
- Production status: `ssh deploy@94.16.110.151 "cd ~/deployment/stacks/production && docker compose ps"`
**⚠️ Important:** Never push directly to `main` - always test on `staging` first!
**📖 Vollständige Dokumentation:**
@@ -224,11 +291,17 @@ git push origin main
### Pipeline Details
The CI/CD pipeline runs on push to main branch:
The CI/CD pipeline runs on push to `staging` or `main` branch:
**On `staging` branch:**
1. **Build Stage**: Build Docker image
2. **Push Stage**: Push to private registry
3. **Deploy Stage**: Deploy to production via Ansible
3. **Deploy Stage**: Deploy to Staging via SSH/SCP
**On `main` branch:**
1. **Build Stage**: Build Docker image
2. **Push Stage**: Push to private registry
3. **Deploy Stage**: Deploy to Production via SSH/SCP
## Monitoring

View File

@@ -0,0 +1 @@
# CI/CD Pipeline Test - Sat Nov 8 11:16:01 AM CET 2025

View File

@@ -1,93 +0,0 @@
---
# Production Deployment - Centralized Variables
# These variables are used across all playbooks
# System Maintenance
system_update_packages: true
system_apt_upgrade: dist
system_enable_unattended_upgrades: true
system_enable_unattended_reboot: false
system_unattended_reboot_time: "02:00"
system_enable_unattended_timer: true
system_enable_docker_prune: false
# Deployment Paths
deploy_user_home: "/home/deploy"
stacks_base_path: "{{ deploy_user_home }}/deployment/stacks"
app_stack_path: "{{ stacks_base_path }}/application"
backups_path: "{{ deploy_user_home }}/deployment/backups"
# Docker Registry
docker_registry: "localhost:5000"
docker_registry_url: "localhost:5000"
docker_registry_external: "registry.michaelschiemer.de"
docker_registry_username_default: "admin"
# docker_registry_password_default should be set in vault as vault_docker_registry_password
# If not using vault, override via -e docker_registry_password_default="your-password"
docker_registry_password_default: ""
registry_auth_path: "{{ stacks_base_path }}/registry/auth"
# Application Configuration
app_name: "framework"
app_domain: "michaelschiemer.de"
app_image: "{{ docker_registry }}/{{ app_name }}"
app_image_external: "{{ docker_registry_external }}/{{ app_name }}"
# Domain Configuration
gitea_domain: "git.michaelschiemer.de"
# Email Configuration
mail_from_address: "noreply@{{ app_domain }}"
acme_email: "kontakt@{{ app_domain }}"
# SSL Certificate Domains
ssl_domains:
- "{{ gitea_domain }}"
- "{{ app_domain }}"
# Health Check Configuration
health_check_url: "https://{{ app_domain }}/health"
health_check_retries: 10
health_check_delay: 10
# Rollback Configuration
max_rollback_versions: 5
rollback_timeout: 300
# Wait Timeouts
wait_timeout: 60
# Git Configuration (for sync-code.yml)
git_repository_url_default: "https://{{ gitea_domain }}/michael/michaelschiemer.git"
git_branch_default: "main"
git_token: "{{ vault_git_token | default('') }}"
git_username: "{{ vault_git_username | default('') }}"
git_password: "{{ vault_git_password | default('') }}"
# Database Configuration
db_user_default: "postgres"
db_name_default: "michaelschiemer"
# MinIO Object Storage Configuration
minio_root_user: "{{ vault_minio_root_user | default('minioadmin') }}"
minio_root_password: "{{ vault_minio_root_password | default('') }}"
minio_api_domain: "minio-api.michaelschiemer.de"
minio_console_domain: "minio.michaelschiemer.de"
# WireGuard Configuration
wireguard_interface: "wg0"
wireguard_config_path: "/etc/wireguard"
wireguard_port_default: 51820
wireguard_network_default: "10.8.0.0/24"
wireguard_server_ip_default: "10.8.0.1"
wireguard_enable_ip_forwarding: true
wireguard_config_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}.conf"
wireguard_private_key_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}_private.key"
wireguard_public_key_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}_public.key"
wireguard_client_configs_path: "{{ wireguard_config_path }}/clients"
# WireGuard DNS Configuration
# DNS server for VPN clients (points to VPN server IP)
# This ensures internal services are resolved to VPN IPs
wireguard_dns_servers:
- "{{ wireguard_server_ip_default }}"

View File

@@ -179,6 +179,141 @@ sudo ufw allow 51820/udp comment 'WireGuard VPN'
sudo iptables -A INPUT -p udp --dport 51820 -j ACCEPT
```
## Split-Tunnel Routing & NAT Fix
### A. Quick Fix Commands (manuell auf dem Server)
```bash
WAN_IF=${WAN_IF:-eth0}
WG_IF=${WG_IF:-wg0}
WG_NET=${WG_NET:-10.8.0.0/24}
WG_PORT=${WG_PORT:-51820}
EXTRA_NETS=${EXTRA_NETS:-"192.168.178.0/24 172.20.0.0/16"}
sudo sysctl -w net.ipv4.ip_forward=1
sudo tee /etc/sysctl.d/99-${WG_IF}-forward.conf >/dev/null <<'EOF'
# WireGuard Forwarding
net.ipv4.ip_forward=1
EOF
sudo sysctl --system
# iptables Variante
sudo iptables -t nat -C POSTROUTING -s ${WG_NET} -o ${WAN_IF} -j MASQUERADE 2>/dev/null \
|| sudo iptables -t nat -A POSTROUTING -s ${WG_NET} -o ${WAN_IF} -j MASQUERADE
sudo iptables -C FORWARD -i ${WG_IF} -s ${WG_NET} -o ${WAN_IF} -j ACCEPT 2>/dev/null \
|| sudo iptables -A FORWARD -i ${WG_IF} -s ${WG_NET} -o ${WAN_IF} -j ACCEPT
sudo iptables -C FORWARD -o ${WG_IF} -d ${WG_NET} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null \
|| sudo iptables -A FORWARD -o ${WG_IF} -d ${WG_NET} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
for NET in ${EXTRA_NETS}; do
sudo iptables -C FORWARD -i ${WG_IF} -d ${NET} -j ACCEPT 2>/dev/null || sudo iptables -A FORWARD -i ${WG_IF} -d ${NET} -j ACCEPT
done
# nftables Variante
sudo nft list table inet wireguard_${WG_IF} >/dev/null 2>&1 || sudo nft add table inet wireguard_${WG_IF}
sudo nft list chain inet wireguard_${WG_IF} postrouting >/dev/null 2>&1 \
|| sudo nft add chain inet wireguard_${WG_IF} postrouting '{ type nat hook postrouting priority srcnat; }'
sudo nft list chain inet wireguard_${WG_IF} forward >/dev/null 2>&1 \
|| sudo nft add chain inet wireguard_${WG_IF} forward '{ type filter hook forward priority filter; policy accept; }'
sudo nft list chain inet wireguard_${WG_IF} postrouting | grep -q "${WAN_IF}" \
|| sudo nft add rule inet wireguard_${WG_IF} postrouting oifname "${WAN_IF}" ip saddr ${WG_NET} masquerade
sudo nft list chain inet wireguard_${WG_IF} forward | grep -q "iifname \"${WG_IF}\"" \
|| sudo nft add rule inet wireguard_${WG_IF} forward iifname "${WG_IF}" ip saddr ${WG_NET} counter accept
sudo nft list chain inet wireguard_${WG_IF} forward | grep -q "oifname \"${WG_IF}\"" \
|| sudo nft add rule inet wireguard_${WG_IF} forward oifname "${WG_IF}" ip daddr ${WG_NET} ct state established,related counter accept
for NET in ${EXTRA_NETS}; do
sudo nft list chain inet wireguard_${WG_IF} forward | grep -q "${NET}" \
|| sudo nft add rule inet wireguard_${WG_IF} forward iifname "${WG_IF}" ip daddr ${NET} counter accept
done
# Firewall Hooks
if command -v ufw >/dev/null && sudo ufw status | grep -iq "Status: active"; then
sudo sed -i 's/^DEFAULT_FORWARD_POLICY=.*/DEFAULT_FORWARD_POLICY="ACCEPT"/' /etc/default/ufw
sudo ufw allow ${WG_PORT}/udp
sudo ufw route allow in on ${WG_IF} out on ${WAN_IF} to any
fi
if command -v firewall-cmd >/dev/null && sudo firewall-cmd --state >/dev/null 2>&1; then
sudo firewall-cmd --permanent --zone=${FIREWALLD_ZONE:-public} --add-port=${WG_PORT}/udp
sudo firewall-cmd --permanent --zone=${FIREWALLD_ZONE:-public} --add-masquerade
sudo firewall-cmd --reload
fi
sudo systemctl enable --now wg-quick@${WG_IF}
sudo wg show
```
### B. Skript: `deployment/ansible/scripts/setup-wireguard-routing.sh`
```bash
cd deployment/ansible
sudo WAN_IF=eth0 WG_IF=wg0 WG_NET=10.8.0.0/24 EXTRA_NETS="192.168.178.0/24 172.20.0.0/16" \
./scripts/setup-wireguard-routing.sh
```
*Erkennt automatisch iptables/nftables und konfiguriert optional UFW/Firewalld.*
### C. Ansible Playbook: `playbooks/wireguard-routing.yml`
```bash
cd deployment/ansible
ansible-playbook -i inventory/production.yml playbooks/wireguard-routing.yml \
-e "wg_interface=wg0 wg_addr=10.8.0.1/24 wg_net=10.8.0.0/24 wan_interface=eth0" \
-e '{"extra_nets":["192.168.178.0/24","172.20.0.0/16"],"firewall_backend":"iptables","manage_ufw":true}'
```
*Variablen:* `wg_interface`, `wg_addr`, `wg_net`, `wan_interface`, `extra_nets`, `firewall_backend` (`iptables|nftables`), `manage_ufw`, `manage_firewalld`, `firewalld_zone`.
### D. Beispiel `wg0.conf` Ausschnitt
```ini
[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = <ServerPrivateKey>
# iptables
PostUp = iptables -t nat -C POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE
PostUp = iptables -C FORWARD -i wg0 -s 10.8.0.0/24 -j ACCEPT 2>/dev/null || iptables -A FORWARD -i wg0 -s 10.8.0.0/24 -j ACCEPT
PostUp = iptables -C FORWARD -o wg0 -d 10.8.0.0/24 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || iptables -A FORWARD -o wg0 -d 10.8.0.0/24 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
PostDown = iptables -t nat -D POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE 2>/dev/null || true
PostDown = iptables -D FORWARD -i wg0 -s 10.8.0.0/24 -j ACCEPT 2>/dev/null || true
PostDown = iptables -D FORWARD -o wg0 -d 10.8.0.0/24 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
# nftables (stattdessen)
# PostUp = nft -f /etc/nftables.d/wireguard-wg0.nft
# PostDown = nft delete table inet wireguard_wg0 2>/dev/null || true
[Peer]
PublicKey = <ClientPublicKey>
AllowedIPs = 10.8.0.5/32, 192.168.178.0/24, 172.20.0.0/16
PersistentKeepalive = 25
```
### E. Windows Client (AllowedIPs & Tests)
```ini
[Interface]
Address = 10.8.0.5/32
DNS = 10.8.0.1 # optional
[Peer]
PublicKey = <ServerPublicKey>
Endpoint = vpn.example.com:51820
AllowedIPs = 10.8.0.0/24, 192.168.178.0/24, 172.20.0.0/16
PersistentKeepalive = 25
```
PowerShell:
```powershell
wg show
Test-Connection -Source 10.8.0.5 -ComputerName 10.8.0.1
Test-Connection 192.168.178.1
Test-NetConnection -ComputerName 192.168.178.10 -Port 22
```
Optional: `Set-DnsClientNrptRule -Namespace "internal.lan" -NameServers 10.8.0.1`.
### F. Troubleshooting & Rollback
- Checks: `ip r`, `ip route get <target>`, `iptables -t nat -S`, `nft list ruleset`, `sysctl net.ipv4.ip_forward`, `wg show`, `tcpdump -i wg0`, `tcpdump -i eth0 host 10.8.0.5`.
- Häufige Fehler: falsches WAN-Interface, Forwarding/NAT fehlt, doppelte Firewalls (iptables + nftables), Docker-NAT kollidiert, Policy-Routing aktiv.
- Rollback:
- `sudo rm /etc/sysctl.d/99-wg0-forward.conf && sudo sysctl -w net.ipv4.ip_forward=0`
- iptables: Regeln mit `iptables -D` entfernen (siehe oben).
- nftables: `sudo nft delete table inet wireguard_wg0`.
- UFW: `sudo ufw delete allow 51820/udp`, Route-Regeln entfernen, `DEFAULT_FORWARD_POLICY` zurücksetzen.
- Firewalld: `firewall-cmd --permanent --remove-port=51820/udp`, `--remove-masquerade`, `--reload`.
- Dienst: `sudo systemctl disable --now wg-quick@wg0`.
## Troubleshooting
### WireGuard startet nicht

View File

@@ -0,0 +1,66 @@
# Ansible Playbooks - Übersicht
## Verfügbare Playbooks
### Infrastructure Setup
- **`setup-infrastructure.yml`** - Deployed alle Stacks (Traefik, PostgreSQL, Redis, Registry, Gitea, Monitoring, Production)
- **`setup-production-secrets.yml`** - Deployed Secrets zu Production
- **`setup-ssl-certificates.yml`** - SSL Certificate Setup
- **`setup-wireguard-host.yml`** - WireGuard VPN Setup
- **`sync-stacks.yml`** - Synchronisiert Stack-Konfigurationen zum Server
### Deployment & Updates
- **`rollback.yml`** - Rollback zu vorheriger Version
- **`backup.yml`** - Erstellt Backups von PostgreSQL, Application Data, Gitea, Registry
- **`deploy-image.yml`** - Docker Image Deployment (wird von CI/CD Workflows verwendet)
### Maintenance
- **`system-maintenance.yml`** - System-Updates, Unattended-Upgrades, Docker-Pruning
- **`troubleshoot.yml`** - Unified Troubleshooting mit Tags
- **`update-gitea-config.yml`** - Aktualisiert Gitea-Konfiguration und startet neu
### WireGuard
- **`generate-wireguard-client.yml`** - Generiert WireGuard Client-Config
- **`wireguard-routing.yml`** - Konfiguriert WireGuard Routing
### Initial Deployment
- **`sync-application-code.yml`** - Rsync-basiertes Code-Sync für Initial Deployment (synchronisiert Code vom lokalen Repository zum Server)
- **`deploy-application-code.yml`** - Git-basiertes Code-Deployment (für CI/CD und zukünftige Deployments)
- **`install-composer-dependencies.yml`** - Installiert Composer Dependencies im PHP Container
- **`build-initial-image.yml`** - Build und Push des initialen Docker Images (für erstes Deployment)
### Code Deployment
- **`sync-application-code.yml`** - Rsync-basiertes Code-Sync (Initial Deployment)
- **`deploy-application-code.yml`** - Git-basiertes Code-Deployment (CI/CD)
- **`deploy-image.yml`** - Docker Image Deployment zu Application Stack
### Troubleshooting & Diagnostics
- **`check-container-logs.yml`** - Container Logs prüfen (queue-worker, web, scheduler)
- **`check-container-status.yml`** - Container Status prüfen
- **`check-final-status.yml`** - Finale Status-Prüfung aller Container
- **`fix-container-issues.yml`** - Container-Probleme beheben (Composer Dependencies, Permissions)
- **`fix-web-container.yml`** - Web Container Permissions beheben
- **`recreate-containers-with-env.yml`** - Container mit env_file neu erstellen
- **`sync-and-recreate-containers.yml`** - Docker Compose sync und Container recreate
### CI/CD & Development
- **`setup-gitea-runner-ci.yml`** - Gitea Runner CI Setup
- **`setup-gitea-initial-config.yml`** - Gitea Initial Setup (automatisiert via app.ini + CLI)
- **`setup-gitea-repository.yml`** - Erstellt Repository in Gitea und konfiguriert Git-Remote (automatisiert via API)
- **`update-gitea-config.yml`** - Aktualisiert Gitea-Konfiguration (Cache, Connection Pooling) zur Behebung von Performance-Problemen
- **`install-docker.yml`** - Docker Installation auf Server
## Entfernte/Legacy Playbooks
Die folgenden Playbooks wurden entfernt, da sie nicht mehr benötigt werden:
- ~~`build-and-push.yml`~~ - Wird durch CI/CD Pipeline ersetzt
- ~~`remove-framework-production-stack.yml`~~ - Temporäres Playbook
- ~~`remove-temporary-grafana-ip.yml`~~ - Temporäres Playbook
## Verwendung
```bash
cd deployment/ansible
ansible-playbook -i inventory/production.yml playbooks/<playbook>.yml
```

View File

@@ -0,0 +1,123 @@
---
- name: Deploy Application Code via Git
hosts: "{{ deployment_hosts | default('production') }}"
gather_facts: yes
become: no
vars:
application_code_dest: "/home/deploy/michaelschiemer/current"
git_repository_url: "{{ git_repository_url | default('https://git.michaelschiemer.de/michael/michaelschiemer.git') }}"
# Determine branch based on environment
git_branch: >-
{%- if deployment_environment == 'staging' -%}
{{ git_branch | default('staging') }}
{%- else -%}
{{ git_branch | default('main') }}
{%- endif -%}
git_token: "{{ git_token | default('') }}"
# Deployment environment (staging or production)
deployment_environment: "{{ deployment_environment | default('production') }}"
tasks:
- name: Ensure Git is installed
ansible.builtin.apt:
name: git
state: present
update_cache: no
become: yes
- name: Ensure application code directory exists
file:
path: "{{ application_code_dest }}"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: '0755'
become: yes
- name: Check if repository already exists
stat:
path: "{{ application_code_dest }}/.git"
register: git_repo_exists
- name: Clone repository (if not exists)
ansible.builtin.git:
repo: "{{ git_repository_url }}"
dest: "{{ application_code_dest }}"
version: "{{ git_branch }}"
force: no
update: no
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
when: not git_repo_exists.stat.exists
environment:
GIT_TERMINAL_PROMPT: "0"
vars:
ansible_become: no
- name: Update repository (if exists)
ansible.builtin.git:
repo: "{{ git_repository_url }}"
dest: "{{ application_code_dest }}"
version: "{{ git_branch }}"
force: yes
update: yes
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
when: git_repo_exists.stat.exists
environment:
GIT_TERMINAL_PROMPT: "0"
vars:
ansible_become: no
- name: Ensure executable permissions on PHP scripts
file:
path: "{{ application_code_dest }}/{{ item }}"
mode: '0755'
loop:
- worker.php
- console.php
ignore_errors: yes
- name: Verify worker.php exists
stat:
path: "{{ application_code_dest }}/worker.php"
register: worker_php_stat
- name: Verify console.php exists
stat:
path: "{{ application_code_dest }}/console.php"
register: console_php_stat
- name: Verify composer.json exists
stat:
path: "{{ application_code_dest }}/composer.json"
register: composer_json_stat
- name: Get current Git commit hash
shell: |
cd {{ application_code_dest }} && git rev-parse HEAD
register: git_commit_hash
changed_when: false
when: git_repo_exists.stat.exists
- name: Display file verification results
debug:
msg: |
File Verification:
- worker.php: {{ 'EXISTS' if worker_php_stat.stat.exists else 'MISSING' }}
- console.php: {{ 'EXISTS' if console_php_stat.stat.exists else 'MISSING' }}
- composer.json: {{ 'EXISTS' if composer_json_stat.stat.exists else 'MISSING' }}
- Git Branch: {{ git_branch }}
- Git Commit: {{ git_commit_hash.stdout | default('N/A') }}
- name: Fail if critical files are missing
fail:
msg: |
Critical files are missing after Git deployment:
{% if not worker_php_stat.stat.exists %}- worker.php{% endif %}
{% if not console_php_stat.stat.exists %}- console.php{% endif %}
{% if not composer_json_stat.stat.exists %}- composer.json{% endif %}
when:
- not worker_php_stat.stat.exists or not console_php_stat.stat.exists or not composer_json_stat.stat.exists

View File

@@ -0,0 +1,142 @@
---
- name: Deploy Docker Image to Application Stack
hosts: "{{ deployment_hosts | default('production') }}"
gather_facts: yes
become: no
vars:
# Determine stack path based on environment
application_stack_dest: >-
{%- if deployment_environment == 'staging' -%}
{{ staging_stack_path | default(stacks_base_path + '/staging') }}
{%- else -%}
{{ app_stack_path | default(stacks_base_path + '/production') }}
{%- endif -%}
application_compose_suffix: >-
{%- if deployment_environment == 'staging' -%}
staging.yml
{%- else -%}
production.yml
{%- endif -%}
# Image to deploy (can be overridden via -e image_tag=...)
image_tag: "{{ image_tag | default('latest') }}"
docker_registry: "{{ docker_registry | default('registry.michaelschiemer.de') }}"
app_name: "{{ app_name | default('framework') }}"
# Full image URL
deploy_image: "{{ docker_registry }}/{{ app_name }}:{{ image_tag }}"
# Deployment environment (staging or production)
deployment_environment: "{{ deployment_environment | default('production') }}"
tasks:
- name: Determine Docker registry password from vault or extra vars
ansible.builtin.set_fact:
registry_password: >-
{%- if docker_registry_password is defined and docker_registry_password | string | trim != '' -%}
{{ docker_registry_password }}
{%- elif vault_docker_registry_password is defined and vault_docker_registry_password | string | trim != '' -%}
{{ vault_docker_registry_password }}
{%- else -%}
{{ '' }}
{%- endif -%}
no_log: yes
- name: Check if registry is accessible
ansible.builtin.uri:
url: "http://{{ docker_registry }}/v2/"
method: GET
status_code: [200, 401]
timeout: 5
register: registry_check
ignore_errors: yes
delegate_to: "{{ inventory_hostname }}"
become: no
- name: Login to Docker registry
community.docker.docker_login:
registry_url: "{{ docker_registry }}"
username: "{{ docker_registry_username | default('admin') }}"
password: "{{ registry_password }}"
when:
- registry_password | string | trim != ''
- registry_check.status | default(0) in [200, 401]
no_log: yes
ignore_errors: yes
register: docker_login_result
- name: Pull Docker image
community.docker.docker_image:
name: "{{ deploy_image }}"
source: pull
pull: true
register: image_pull_result
failed_when: image_pull_result.failed | default(false)
- name: Verify image exists locally
community.docker.docker_image_info:
name: "{{ deploy_image }}"
register: image_info
failed_when: image_info.failed | default(false)
- name: Update docker-compose file with new image tag
ansible.builtin.replace:
path: "{{ application_stack_dest }}/docker-compose.{{ application_compose_suffix }}"
regexp: '^(\s+image:\s+)({{ docker_registry }}/{{ app_name }}:)(.*)$'
replace: '\1\2{{ image_tag }}'
register: compose_update_result
failed_when: false
changed_when: compose_update_result.changed | default(false)
- name: Update docker-compose file with new image (alternative pattern)
ansible.builtin.replace:
path: "{{ application_stack_dest }}/docker-compose.{{ application_compose_suffix }}"
regexp: 'image:\s+{{ docker_registry }}/{{ app_name }}:.*'
replace: 'image: {{ deploy_image }}'
register: compose_update_alt
when: compose_update_result.changed == false
failed_when: false
changed_when: compose_update_alt.changed | default(false)
- name: Ensure Docker networks exist
community.docker.docker_network:
name: "{{ item }}"
state: present
loop:
- traefik-public
- app-internal
ignore_errors: yes
- name: Deploy application stack with new image
shell: |
cd {{ application_stack_dest }}
docker compose -f docker-compose.base.yml -f docker-compose.{{ application_compose_suffix }} up -d --pull missing --force-recreate --remove-orphans
register: compose_deploy_result
changed_when: true
- name: Wait for containers to start
ansible.builtin.pause:
seconds: 15
- name: Check container status
shell: |
cd {{ application_stack_dest }}
docker compose -f docker-compose.base.yml -f docker-compose.{{ application_compose_suffix }} ps
register: container_status
changed_when: false
- name: Display deployment summary
ansible.builtin.debug:
msg: |
========================================
Image Deployment Summary
========================================
Image: {{ deploy_image }}
Tag: {{ image_tag }}
Environment: {{ deployment_environment }}
Stack: {{ application_stack_dest }}
Status: SUCCESS
========================================
Container Status:
{{ container_status.stdout | default('Not available') }}
========================================

View File

@@ -0,0 +1,229 @@
---
# WireGuard Client Configuration Generator
# Usage: ansible-playbook playbooks/generate-wireguard-client.yml -e "client_name=michael-laptop"
- name: Generate WireGuard Client Configuration
hosts: server
become: true
gather_facts: true
vars:
# Default values (can be overridden with -e)
wireguard_config_dir: "/etc/wireguard"
wireguard_interface: "wg0"
wireguard_server_endpoint: "{{ ansible_default_ipv4.address }}"
wireguard_server_port: 51820
wireguard_vpn_network: "10.8.0.0/24"
wireguard_server_ip: "10.8.0.1"
# Client output directory (local)
client_config_dir: "{{ playbook_dir }}/../wireguard/configs"
# Required variable (must be passed via -e)
# client_name: "device-name"
tasks:
- name: Validate client_name is provided
assert:
that:
- client_name is defined
- client_name | length > 0
fail_msg: "ERROR: client_name must be provided via -e client_name=<name>"
success_msg: "Generating config for client: {{ client_name }}"
- name: Validate client_name format (alphanumeric and hyphens only)
assert:
that:
- client_name is match('^[a-zA-Z0-9-]+$')
fail_msg: "ERROR: client_name must contain only letters, numbers, and hyphens"
success_msg: "Client name format is valid"
- name: Check if WireGuard server is configured
stat:
path: "{{ wireguard_config_dir }}/{{ wireguard_interface }}.conf"
register: server_config
- name: Fail if server config doesn't exist
fail:
msg: "WireGuard server config not found. Run setup-wireguard-host.yml first."
when: not server_config.stat.exists
- name: Read server public key
slurp:
src: "{{ wireguard_config_dir }}/server_public.key"
register: server_public_key_raw
- name: Set server public key fact
set_fact:
server_public_key: "{{ server_public_key_raw.content | b64decode | trim }}"
- name: Get next available IP address
shell: |
# Parse existing peer IPs from wg0.conf
existing_ips=$(grep -oP 'AllowedIPs\s*=\s*\K[0-9.]+' {{ wireguard_config_dir }}/{{ wireguard_interface }}.conf 2>/dev/null || echo "")
# Start from .2 (server is .1)
i=2
while [ $i -le 254 ]; do
ip="10.8.0.$i"
if ! echo "$existing_ips" | grep -q "^$ip$"; then
echo "$ip"
exit 0
fi
i=$((i + 1))
done
echo "ERROR: No free IP addresses" >&2
exit 1
register: next_ip_result
changed_when: false
- name: Set client IP fact
set_fact:
client_ip: "{{ next_ip_result.stdout | trim }}"
- name: Display client IP assignment
debug:
msg: "Assigned IP for {{ client_name }}: {{ client_ip }}"
- name: Check if client already exists
shell: |
grep -q "# Client: {{ client_name }}" {{ wireguard_config_dir }}/{{ wireguard_interface }}.conf
register: client_exists
changed_when: false
failed_when: false
- name: Warn if client already exists
debug:
msg: "WARNING: Client '{{ client_name }}' already exists in server config. Creating new keys anyway."
when: client_exists.rc == 0
- name: Generate client private key
shell: wg genkey
register: client_private_key_result
changed_when: true
no_log: true
- name: Generate client public key
shell: echo "{{ client_private_key_result.stdout }}" | wg pubkey
register: client_public_key_result
changed_when: false
no_log: true
- name: Generate preshared key
shell: wg genpsk
register: preshared_key_result
changed_when: true
no_log: true
- name: Set client key facts
set_fact:
client_private_key: "{{ client_private_key_result.stdout | trim }}"
client_public_key: "{{ client_public_key_result.stdout | trim }}"
preshared_key: "{{ preshared_key_result.stdout | trim }}"
no_log: true
- name: Create client config directory on control node
delegate_to: localhost
file:
path: "{{ client_config_dir }}"
state: directory
mode: '0755'
become: false
- name: Generate client WireGuard configuration
delegate_to: localhost
copy:
content: |
[Interface]
# Client: {{ client_name }}
# Generated: {{ ansible_date_time.iso8601 }}
PrivateKey = {{ client_private_key }}
Address = {{ client_ip }}/32
DNS = 1.1.1.1, 8.8.8.8
[Peer]
# WireGuard Server
PublicKey = {{ server_public_key }}
PresharedKey = {{ preshared_key }}
Endpoint = {{ wireguard_server_endpoint }}:{{ wireguard_server_port }}
AllowedIPs = {{ wireguard_vpn_network }}
PersistentKeepalive = 25
dest: "{{ client_config_dir }}/{{ client_name }}.conf"
mode: '0600'
become: false
no_log: true
- name: Add client peer to server configuration
blockinfile:
path: "{{ wireguard_config_dir }}/{{ wireguard_interface }}.conf"
marker: "# {mark} ANSIBLE MANAGED BLOCK - Client: {{ client_name }}"
block: |
[Peer]
# Client: {{ client_name }}
PublicKey = {{ client_public_key }}
PresharedKey = {{ preshared_key }}
AllowedIPs = {{ client_ip }}/32
no_log: true
- name: Reload WireGuard configuration
shell: wg syncconf {{ wireguard_interface }} <(wg-quick strip {{ wireguard_interface }})
args:
executable: /bin/bash
- name: Generate QR code (ASCII)
delegate_to: localhost
shell: |
qrencode -t ansiutf8 < {{ client_config_dir }}/{{ client_name }}.conf > {{ client_config_dir }}/{{ client_name }}.qr.txt
become: false
changed_when: true
- name: Generate QR code (PNG)
delegate_to: localhost
shell: |
qrencode -t png -o {{ client_config_dir }}/{{ client_name }}.qr.png < {{ client_config_dir }}/{{ client_name }}.conf
become: false
changed_when: true
- name: Display QR code for mobile devices
delegate_to: localhost
shell: cat {{ client_config_dir }}/{{ client_name }}.qr.txt
register: qr_code_output
become: false
changed_when: false
- name: Client configuration summary
debug:
msg:
- "========================================="
- "WireGuard Client Configuration Created!"
- "========================================="
- ""
- "Client: {{ client_name }}"
- "IP Address: {{ client_ip }}/32"
- "Public Key: {{ client_public_key }}"
- ""
- "Configuration Files:"
- " Config: {{ client_config_dir }}/{{ client_name }}.conf"
- " QR Code (ASCII): {{ client_config_dir }}/{{ client_name }}.qr.txt"
- " QR Code (PNG): {{ client_config_dir }}/{{ client_name }}.qr.png"
- ""
- "Server Configuration:"
- " Endpoint: {{ wireguard_server_endpoint }}:{{ wireguard_server_port }}"
- " Allowed IPs: {{ wireguard_vpn_network }}"
- ""
- "Next Steps:"
- " Linux/macOS: sudo cp {{ client_config_dir }}/{{ client_name }}.conf /etc/wireguard/ && sudo wg-quick up {{ client_name }}"
- " Windows: Import {{ client_name }}.conf in WireGuard GUI"
- " iOS/Android: Scan QR code with WireGuard app"
- ""
- "Test Connection:"
- " ping {{ wireguard_server_ip }}"
- " curl -k https://{{ wireguard_server_ip }}:8080 # Traefik Dashboard"
- ""
- "========================================="
- name: Display QR code
debug:
msg: "{{ qr_code_output.stdout_lines }}"

View File

@@ -0,0 +1,78 @@
---
- name: Install Composer Dependencies in Application Container
hosts: "{{ deployment_hosts | default('production') }}"
gather_facts: no
become: no
vars:
# Determine stack path based on environment
application_stack_dest: >-
{%- if deployment_environment == 'staging' -%}
{{ staging_stack_path | default(stacks_base_path + '/staging') }}
{%- else -%}
{{ app_stack_path | default(stacks_base_path + '/production') }}
{%- endif -%}
application_compose_suffix: >-
{%- if deployment_environment == 'staging' -%}
staging.yml
{%- else -%}
production.yml
{%- endif -%}
# Deployment environment (staging or production)
deployment_environment: "{{ deployment_environment | default('production') }}"
# Service name (php for production, staging-app for staging)
php_service_name: >-
{%- if deployment_environment == 'staging' -%}
staging-app
{%- else -%}
php
{%- endif -%}
tasks:
- name: Check if composer.json exists
stat:
path: /home/deploy/michaelschiemer/current/composer.json
delegate_to: "{{ inventory_hostname }}"
register: composer_json_exists
- name: Fail if composer.json is missing
fail:
msg: "composer.json not found at /home/deploy/michaelschiemer/current/composer.json"
when: not composer_json_exists.stat.exists
- name: Install composer dependencies in PHP container
shell: |
docker compose -f {{ application_stack_dest }}/docker-compose.base.yml -f {{ application_stack_dest }}/docker-compose.{{ application_compose_suffix }} exec -T {{ php_service_name }} composer install --no-dev --optimize-autoloader --no-interaction
register: composer_install
changed_when: true
failed_when: composer_install.rc != 0
- name: Restart queue-worker and scheduler to pick up vendor directory (production only)
shell: |
docker compose -f {{ application_stack_dest }}/docker-compose.base.yml -f {{ application_stack_dest }}/docker-compose.{{ application_compose_suffix }} restart queue-worker scheduler
register: restart_workers
changed_when: true
failed_when: false
when: deployment_environment == 'production'
- name: Display composer install output
debug:
msg: |
Composer Install Output:
{{ composer_install.stdout }}
- name: Verify vendor/autoload.php exists
shell: |
docker compose -f {{ application_stack_dest }}/docker-compose.base.yml -f {{ application_stack_dest }}/docker-compose.{{ application_compose_suffix }} exec -T {{ php_service_name }} test -f /var/www/html/vendor/autoload.php && echo "EXISTS" || echo "MISSING"
register: autoload_check
changed_when: false
- name: Display autoload verification
debug:
msg: "vendor/autoload.php: {{ autoload_check.stdout.strip() }}"
- name: Fail if autoload.php is missing
fail:
msg: "vendor/autoload.php was not created after composer install"
when: "autoload_check.stdout.strip() != 'EXISTS'"

View File

@@ -0,0 +1,254 @@
---
# Ansible Playbook: Setup Gitea Repository
# Purpose: Automatically create repository in Gitea and configure Git remote
# Usage:
# ansible-playbook -i inventory/production.yml playbooks/setup-gitea-repository.yml \
# --vault-password-file secrets/.vault_pass \
# -e "repo_name=michaelschiemer" \
# -e "repo_owner=michael" \
# -e "repo_private=false"
- name: Setup Gitea Repository
hosts: production
vars:
ansible_connection: local
gitea_url: "https://{{ gitea_domain | default('git.michaelschiemer.de') }}"
gitea_admin_username: "{{ vault_gitea_admin_username | default('admin') }}"
gitea_admin_password: "{{ vault_gitea_admin_password | default('') }}"
tasks:
- name: Set repository variables from parameters
set_fact:
repo_name: "{{ repo_name | default('michaelschiemer') }}"
repo_owner: "{{ repo_owner | default('michael') }}"
repo_private: "{{ repo_private | default(false) | bool }}"
repo_description: "{{ repo_description | default('Main application repository') }}"
repo_auto_init: "{{ repo_auto_init | default(false) | bool }}"
configure_git_remote: "{{ configure_git_remote | default(true) | bool }}"
git_repo_path: "{{ git_repo_path | default('/home/michael/dev/michaelschiemer') }}"
- name: Verify Gitea is accessible
uri:
url: "{{ gitea_url }}"
method: GET
status_code: [200, 302, 502]
validate_certs: false
timeout: 10
register: gitea_health
failed_when: false
- name: Debug Gitea health status
debug:
msg: "Gitea health check returned status: {{ gitea_health.status }}"
- name: Fail if Gitea is not accessible
fail:
msg: "Gitea is not accessible at {{ gitea_url }}. Status: {{ gitea_health.status }}. Please check if Gitea is running."
when: gitea_health.status not in [200, 302, 502]
- name: Check if API token exists in vault
set_fact:
has_vault_token: "{{ vault_git_token is defined and vault_git_token | length > 0 }}"
no_log: true
- name: Get or create Gitea API token
uri:
url: "{{ gitea_url }}/api/v1/users/{{ gitea_admin_username }}/tokens"
method: POST
user: "{{ gitea_admin_username }}"
password: "{{ gitea_admin_password }}"
body_format: json
body:
name: "ansible-repo-setup-{{ ansible_date_time.epoch }}"
scopes:
- write:repository
- read:repository
- admin:repo
status_code: [201, 400, 401, 502]
validate_certs: false
force_basic_auth: yes
register: api_token_result
failed_when: false
when: not has_vault_token
no_log: true
- name: Extract API token from response
set_fact:
gitea_api_token: "{{ api_token_result.json.sha1 | default('') }}"
when:
- not has_vault_token
- api_token_result.status == 201
- api_token_result.json.sha1 is defined
no_log: true
- name: Use existing API token from vault
set_fact:
gitea_api_token: "{{ vault_git_token }}"
when: has_vault_token
no_log: true
- name: Set flag to use basic auth if token creation failed
set_fact:
use_basic_auth: "{{ gitea_api_token | default('') | length == 0 }}"
no_log: true
- name: Fail if no authentication method available
fail:
msg: "Could not create or retrieve Gitea API token, and admin credentials are not available. Please create a token manually or set vault_git_token in vault."
when:
- use_basic_auth | bool
- gitea_admin_password | default('') | length == 0
- name: Initialize repo_check variable
set_fact:
repo_check: {"status": 0}
when: repo_check is not defined
- name: Check if repository already exists (with token)
uri:
url: "{{ gitea_url }}/api/v1/repos/{{ repo_owner }}/{{ repo_name }}"
method: GET
headers:
Authorization: "token {{ gitea_api_token }}"
status_code: [200, 404, 502]
validate_certs: false
timeout: 10
register: repo_check_token
when: not use_basic_auth
failed_when: false
- name: Set repo_check from token result
set_fact:
repo_check: "{{ repo_check_token }}"
when:
- not use_basic_auth
- repo_check_token is defined
- name: Check if repository already exists (with basic auth)
uri:
url: "{{ gitea_url }}/api/v1/repos/{{ repo_owner }}/{{ repo_name }}"
method: GET
user: "{{ gitea_admin_username }}"
password: "{{ gitea_admin_password }}"
status_code: [200, 404, 502]
validate_certs: false
force_basic_auth: yes
timeout: 10
register: repo_check_basic
when: use_basic_auth
failed_when: false
no_log: true
- name: Set repo_check from basic auth result
set_fact:
repo_check: "{{ repo_check_basic }}"
when:
- use_basic_auth
- repo_check_basic is defined
- name: Debug repo_check status
debug:
msg: "Repository check - Status: {{ repo_check.status | default('undefined') }}, use_basic_auth: {{ use_basic_auth | default('undefined') }}"
- name: Create repository in Gitea (with token)
uri:
url: "{{ gitea_url }}/api/v1/user/repos"
method: POST
headers:
Authorization: "token {{ gitea_api_token }}"
Content-Type: "application/json"
body_format: json
body:
name: "{{ repo_name }}"
description: "{{ repo_description }}"
private: "{{ repo_private }}"
auto_init: "{{ repo_auto_init }}"
status_code: [201, 409, 502]
validate_certs: false
timeout: 10
register: repo_create_result
when:
- (repo_check.status | default(0)) in [404, 502, 0]
- not use_basic_auth
failed_when: false
- name: Create repository in Gitea (with basic auth)
uri:
url: "{{ gitea_url }}/api/v1/user/repos"
method: POST
user: "{{ gitea_admin_username }}"
password: "{{ gitea_admin_password }}"
body_format: json
body:
name: "{{ repo_name }}"
description: "{{ repo_description }}"
private: "{{ repo_private }}"
auto_init: "{{ repo_auto_init }}"
status_code: [201, 409]
validate_certs: false
force_basic_auth: yes
timeout: 10
register: repo_create_result
when:
- (repo_check.status | default(0)) != 200
- use_basic_auth
no_log: true
- name: Display repository creation result
debug:
msg: "Repository {{ repo_owner }}/{{ repo_name }} already exists or was created successfully"
when: repo_check.status | default(0) == 200 or (repo_create_result is defined and repo_create_result.status | default(0) == 201)
- name: Get repository clone URL
set_fact:
repo_clone_url: "{{ gitea_url | replace('https://', '') | replace('http://', '') }}/{{ repo_owner }}/{{ repo_name }}.git"
repo_https_url: "https://{{ gitea_admin_username }}:{{ gitea_api_token }}@{{ gitea_url | replace('https://', '') | replace('http://', '') }}/{{ repo_owner }}/{{ repo_name }}.git"
- name: Check if Git repository exists locally
stat:
path: "{{ git_repo_path }}/.git"
register: git_repo_exists
when: configure_git_remote | bool
- name: Configure Git remote (local)
command: >
git remote set-url origin {{ repo_clone_url }}
args:
chdir: "{{ git_repo_path }}"
register: git_remote_result
when:
- configure_git_remote | bool
- git_repo_path is defined
- git_repo_exists.stat.exists
changed_when: git_remote_result.rc == 0
failed_when: false
- name: Add Git remote if it doesn't exist
command: >
git remote add origin {{ repo_clone_url }}
args:
chdir: "{{ git_repo_path }}"
register: git_remote_add_result
when:
- configure_git_remote | bool
- git_repo_path is defined
- git_repo_exists.stat.exists
- git_remote_result.rc != 0
changed_when: git_remote_add_result.rc == 0
failed_when: false
- name: Display success message
debug:
msg:
- "✅ Repository created successfully!"
- "Repository URL: {{ gitea_url }}/{{ repo_owner }}/{{ repo_name }}"
- "Clone URL: {{ repo_clone_url }}"
- ""
- "Next steps:"
- "1. Push your code: git push -u origin staging"
- "2. Monitor pipeline: {{ gitea_url }}/{{ repo_owner }}/{{ repo_name }}/actions"
- ""
- "Note: If you need to push, you may need to authenticate with:"
- " Username: {{ gitea_admin_username }}"
- " Password: (use vault_gitea_admin_password or create a Personal Access Token)"

View File

@@ -0,0 +1,309 @@
---
# Ansible Playbook: WireGuard Host-based VPN Setup
# Purpose: Deploy minimalistic WireGuard VPN for admin access
# Architecture: Host-based (systemd), no Docker, no DNS
- name: Setup WireGuard VPN (Host-based)
hosts: all
become: yes
vars:
# WireGuard Configuration
wg_interface: wg0
wg_network: 10.8.0.0/24
wg_server_ip: 10.8.0.1
wg_netmask: 24
wg_port: 51820
# Network Configuration
wan_interface: eth0 # Change to your WAN interface (eth0, ens3, etc.)
# Admin Service Ports (VPN-only access)
admin_service_ports:
- 8080 # Traefik Dashboard
- 9090 # Prometheus
- 3001 # Grafana
- 9000 # Portainer
- 8001 # Redis Insight
# Public Service Ports
public_service_ports:
- 80 # HTTP
- 443 # HTTPS
- 22 # SSH
# Rate Limiting
wg_enable_rate_limit: true
# Paths
wg_config_dir: /etc/wireguard
wg_backup_dir: /root/wireguard-backup
nft_config_file: /etc/nftables.d/wireguard.nft
tasks:
# ========================================
# 1. Pre-flight Checks
# ========================================
- name: Check if running as root
assert:
that: ansible_user_id == 'root'
fail_msg: "This playbook must be run as root"
- name: Detect WAN interface
shell: ip route | grep default | awk '{print $5}' | head -n1
register: detected_wan_interface
changed_when: false
- name: Set WAN interface if not specified
set_fact:
wan_interface: "{{ detected_wan_interface.stdout }}"
when: wan_interface == 'eth0' and detected_wan_interface.stdout != ''
- name: Display detected network configuration
debug:
msg:
- "WAN Interface: {{ wan_interface }}"
- "VPN Network: {{ wg_network }}"
- "VPN Server IP: {{ wg_server_ip }}"
# ========================================
# 2. Backup Existing Configuration
# ========================================
- name: Create backup directory
file:
path: "{{ wg_backup_dir }}"
state: directory
mode: '0700'
- name: Backup existing WireGuard config (if exists)
shell: |
if [ -d {{ wg_config_dir }} ]; then
tar -czf {{ wg_backup_dir }}/wireguard-backup-$(date +%Y%m%d-%H%M%S).tar.gz {{ wg_config_dir }}
echo "Backup created"
else
echo "No existing config"
fi
register: backup_result
changed_when: "'Backup created' in backup_result.stdout"
# ========================================
# 3. Install WireGuard
# ========================================
- name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 3600
when: ansible_os_family == 'Debian'
- name: Install WireGuard and dependencies
apt:
name:
- wireguard
- wireguard-tools
- qrencode # For QR code generation
- nftables
state: present
when: ansible_os_family == 'Debian'
- name: Ensure WireGuard kernel module is loaded
modprobe:
name: wireguard
state: present
- name: Verify WireGuard module is available
shell: lsmod | grep -q wireguard
register: wg_module_check
failed_when: wg_module_check.rc != 0
changed_when: false
# ========================================
# 4. Generate Server Keys (if not exist)
# ========================================
- name: Create WireGuard config directory
file:
path: "{{ wg_config_dir }}"
state: directory
mode: '0700'
- name: Check if server private key exists
stat:
path: "{{ wg_config_dir }}/server_private.key"
register: server_private_key_stat
- name: Generate server private key
shell: wg genkey > {{ wg_config_dir }}/server_private.key
when: not server_private_key_stat.stat.exists
- name: Set server private key permissions
file:
path: "{{ wg_config_dir }}/server_private.key"
mode: '0600'
- name: Generate server public key
shell: cat {{ wg_config_dir }}/server_private.key | wg pubkey > {{ wg_config_dir }}/server_public.key
when: not server_private_key_stat.stat.exists
- name: Read server private key
slurp:
src: "{{ wg_config_dir }}/server_private.key"
register: server_private_key_content
- name: Read server public key
slurp:
src: "{{ wg_config_dir }}/server_public.key"
register: server_public_key_content
- name: Set server key facts
set_fact:
wg_server_private_key: "{{ server_private_key_content.content | b64decode | trim }}"
wg_server_public_key: "{{ server_public_key_content.content | b64decode | trim }}"
- name: Display server public key
debug:
msg: "Server Public Key: {{ wg_server_public_key }}"
# ========================================
# 5. Configure WireGuard
# ========================================
- name: Deploy WireGuard server configuration
template:
src: ../templates/wg0.conf.j2
dest: "{{ wg_config_dir }}/wg0.conf"
mode: '0600'
notify: restart wireguard
- name: Enable IP forwarding
sysctl:
name: net.ipv4.ip_forward
value: '1'
sysctl_set: yes
state: present
reload: yes
# ========================================
# 6. Configure nftables Firewall
# ========================================
- name: Create nftables config directory
file:
path: /etc/nftables.d
state: directory
mode: '0755'
- name: Deploy WireGuard firewall rules
template:
src: ../templates/wireguard-host-firewall.nft.j2
dest: "{{ nft_config_file }}"
mode: '0644'
notify: reload nftables
- name: Include WireGuard rules in main nftables config
lineinfile:
path: /etc/nftables.conf
line: 'include "{{ nft_config_file }}"'
create: yes
state: present
notify: reload nftables
- name: Enable nftables service
systemd:
name: nftables
enabled: yes
state: started
# ========================================
# 7. Enable and Start WireGuard
# ========================================
- name: Enable WireGuard interface
systemd:
name: wg-quick@wg0
enabled: yes
state: started
- name: Verify WireGuard is running
command: wg show wg0
register: wg_status
changed_when: false
- name: Display WireGuard status
debug:
msg: "{{ wg_status.stdout_lines }}"
# ========================================
# 8. Health Checks
# ========================================
- name: Check WireGuard interface exists
command: ip link show wg0
register: wg_interface_check
failed_when: wg_interface_check.rc != 0
changed_when: false
- name: Check firewall rules applied
command: nft list ruleset
register: nft_rules
failed_when: "'wireguard_firewall' not in nft_rules.stdout"
changed_when: false
- name: Verify admin ports are blocked from public
shell: nft list chain inet wireguard_firewall input | grep -q "admin_service_ports.*drop"
register: admin_port_block_check
failed_when: admin_port_block_check.rc != 0
changed_when: false
# ========================================
# 9. Post-Installation Summary
# ========================================
- name: Create post-installation summary
debug:
msg:
- "========================================="
- "WireGuard VPN Setup Complete!"
- "========================================="
- ""
- "Server Configuration:"
- " Interface: wg0"
- " Server IP: {{ wg_server_ip }}/{{ wg_netmask }}"
- " Listen Port: {{ wg_port }}"
- " Public Key: {{ wg_server_public_key }}"
- ""
- "Network Configuration:"
- " VPN Network: {{ wg_network }}"
- " WAN Interface: {{ wan_interface }}"
- ""
- "Admin Service Access (VPN-only):"
- " Traefik Dashboard: https://{{ wg_server_ip }}:8080"
- " Prometheus: http://{{ wg_server_ip }}:9090"
- " Grafana: https://{{ wg_server_ip }}:3001"
- " Portainer: http://{{ wg_server_ip }}:9000"
- " Redis Insight: http://{{ wg_server_ip }}:8001"
- ""
- "Next Steps:"
- " 1. Generate client config: ./scripts/generate-client-config.sh <device-name>"
- " 2. Import config on client device"
- " 3. Connect and verify access"
- ""
- "Firewall Status: ACTIVE (nftables)"
- " - Public ports: 80, 443, 22"
- " - VPN port: {{ wg_port }}"
- " - Admin services: VPN-only access"
- ""
- "========================================="
handlers:
- name: restart wireguard
systemd:
name: wg-quick@wg0
state: restarted
- name: reload nftables
systemd:
name: nftables
state: reloaded

View File

@@ -1,49 +1,134 @@
---
- name: Update Gitea Configuration and Restart
hosts: production
become: no
gather_facts: yes
# Ansible Playbook: Update Gitea Configuration
# Purpose: Update Gitea app.ini configuration to fix performance issues
# Usage:
# ansible-playbook -i inventory/production.yml playbooks/update-gitea-config.yml \
# --vault-password-file secrets/.vault_pass
- name: Update Gitea Configuration
hosts: production
vars:
gitea_stack_path: "{{ stacks_base_path }}/gitea"
gitea_url: "https://{{ gitea_domain }}"
gitea_app_ini_path: "{{ gitea_stack_path }}/app.ini"
gitea_app_ini_container_path: "/data/gitea/conf/app.ini"
tasks:
- name: Copy updated docker-compose.yml to production server
copy:
src: "{{ playbook_dir }}/../../stacks/gitea/docker-compose.yml"
dest: "{{ gitea_stack_path }}/docker-compose.yml"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: '0644'
- name: Restart Gitea stack with updated configuration
community.docker.docker_compose_v2:
project_src: "{{ gitea_stack_path }}"
state: present
pull: never
recreate: always
remove_orphans: no
register: gitea_restart
- name: Wait for Gitea to be ready
wait_for:
timeout: 60
when: gitea_restart.changed
- name: Verify Gitea Actions configuration
- name: Verify Gitea container exists
shell: |
docker exec gitea cat /data/gitea/conf/app.ini 2>/dev/null | grep -A 3 "\[actions\]" || echo "Config not accessible"
register: gitea_config
docker compose -f {{ gitea_stack_path }}/docker-compose.yml ps gitea | grep -q "gitea"
register: gitea_exists
changed_when: false
failed_when: false
- name: Fail if Gitea container does not exist
fail:
msg: "Gitea container does not exist. Please deploy Gitea stack first."
when: gitea_exists.rc != 0
- name: Get database configuration from environment
shell: |
docker compose -f {{ gitea_stack_path }}/docker-compose.yml exec -T gitea env | grep -E "^GITEA__database__" || true
register: gitea_db_env
changed_when: false
failed_when: false
- name: Parse database configuration
set_fact:
gitea_db_type: "{{ (gitea_db_env.stdout | default('') | regex_search('GITEA__database__DB_TYPE=([^\n]+)', '\\1') or ['postgres']) | first }}"
gitea_db_host: "{{ (gitea_db_env.stdout | default('') | regex_search('GITEA__database__HOST=([^\n]+)', '\\1') or ['postgres:5432']) | first }}"
gitea_db_name: "{{ (gitea_db_env.stdout | default('') | regex_search('GITEA__database__NAME=([^\n]+)', '\\1') or ['gitea']) | first }}"
gitea_db_user: "{{ (gitea_db_env.stdout | default('') | regex_search('GITEA__database__USER=([^\n]+)', '\\1') or ['gitea']) | first }}"
gitea_db_passwd: "{{ (gitea_db_env.stdout | default('') | regex_search('GITEA__database__PASSWD=([^\n]+)', '\\1') or ['gitea_password']) | first }}"
- name: Get Gitea server configuration from environment
shell: |
docker compose -f {{ gitea_stack_path }}/docker-compose.yml exec -T gitea env | grep -E "^GITEA__server__" || true
register: gitea_server_env
changed_when: false
failed_when: false
- name: Parse server configuration
set_fact:
gitea_domain_parsed: "{{ (gitea_server_env.stdout | default('') | regex_search('GITEA__server__DOMAIN=([^\n]+)', '\\1') or [gitea_domain | default('git.michaelschiemer.de')]) | first }}"
ssh_port_parsed: "{{ (gitea_server_env.stdout | default('') | regex_search('GITEA__server__SSH_PORT=([^\n]+)', '\\1') or ['2222']) | first }}"
- name: Set final configuration variables
set_fact:
gitea_domain: "{{ gitea_domain_parsed }}"
ssh_port: "{{ ssh_port_parsed }}"
ssh_listen_port: "{{ ssh_port_parsed }}"
- name: Extract database host and port
set_fact:
gitea_db_hostname: "{{ gitea_db_host.split(':')[0] }}"
gitea_db_port: "{{ (gitea_db_host.split(':')[1]) | default('5432') }}"
- name: Set Redis password
set_fact:
redis_password: "{{ vault_gitea_redis_password | default(vault_redis_password | default('gitea_redis_password')) }}"
- name: Generate app.ini from template
template:
src: ../templates/gitea-app.ini.j2
dest: "{{ gitea_app_ini_path }}"
mode: '0644'
vars:
postgres_db: "{{ gitea_db_name }}"
postgres_user: "{{ gitea_db_user }}"
postgres_password: "{{ gitea_db_passwd }}"
gitea_domain: "{{ gitea_domain }}"
ssh_port: "{{ ssh_port }}"
ssh_listen_port: "{{ ssh_listen_port }}"
disable_registration: true
redis_password: "{{ redis_password }}"
- name: Copy app.ini to Gitea container
shell: |
docker compose -f {{ gitea_stack_path }}/docker-compose.yml cp {{ gitea_app_ini_path }} gitea:{{ gitea_app_ini_container_path }}
ignore_errors: yes
- name: Display Gitea Actions configuration
- name: Wait for container to be ready for exec
shell: |
docker compose -f {{ gitea_stack_path }}/docker-compose.yml exec -T gitea true
register: container_ready
until: container_ready.rc == 0
retries: 30
delay: 2
changed_when: false
- name: Set correct permissions on app.ini in container
shell: |
docker compose -f {{ gitea_stack_path }}/docker-compose.yml exec -T --user git gitea chown 1000:1000 {{ gitea_app_ini_container_path }} && \
docker compose -f {{ gitea_stack_path }}/docker-compose.yml exec -T --user git gitea chmod 644 {{ gitea_app_ini_container_path }}
- name: Restart Gitea container
shell: |
docker compose -f {{ gitea_stack_path }}/docker-compose.yml restart gitea
- name: Wait for Gitea to be ready after restart
uri:
url: "{{ gitea_url }}/api/healthz"
method: GET
status_code: [200]
validate_certs: false
timeout: 10
register: gitea_health_after_restart
until: gitea_health_after_restart.status == 200
retries: 30
delay: 5
changed_when: false
- name: Display success message
debug:
msg:
- "=== Gitea Configuration Update Complete ==="
- "Container restarted: {{ 'Yes' if gitea_restart.changed else 'No' }}"
- ""
- "Current Actions configuration:"
- "{{ gitea_config.stdout if gitea_config.stdout else 'Could not read config (container may still be starting)' }}"
- ""
- "The DEFAULT_ACTIONS_URL should now point to your Gitea instance instead of GitHub."
msg: |
Gitea configuration has been updated successfully!
Changes applied:
- Redis cache enabled (persistent, survives container restarts)
- Redis sessions enabled (better performance and scalability)
- Redis queue enabled (persistent job processing)
- Database connection pooling configured
- Connection limits set to prevent "Connection reset by peer" errors
Gitea should now be more stable and perform better with Redis.

View File

@@ -0,0 +1,212 @@
---
- name: Configure WireGuard split tunnel routing
hosts: production
become: true
gather_facts: true
vars:
wg_interface: wg0
wg_addr: 10.8.0.1/24
wg_net: 10.8.0.0/24
wan_interface: eth0
listening_port: 51820
extra_nets:
- 192.168.178.0/24
- 172.20.0.0/16
firewall_backend: iptables # or nftables
manage_ufw: false
manage_firewalld: false
firewalld_zone: public
pre_tasks:
- name: Ensure required collections are installed (documentation note)
debug:
msg: >
Install collections if missing:
ansible-galaxy collection install ansible.posix community.general
when: false
tasks:
- name: Ensure WireGuard config directory exists
ansible.builtin.file:
path: "/etc/wireguard"
state: directory
mode: "0700"
owner: root
group: root
- name: Persist IPv4 forwarding
ansible.builtin.copy:
dest: "/etc/sysctl.d/99-{{ wg_interface }}-forward.conf"
owner: root
group: root
mode: "0644"
content: |
# Managed by Ansible - WireGuard {{ wg_interface }}
net.ipv4.ip_forward=1
- name: Enable IPv4 forwarding runtime
ansible.posix.sysctl:
name: net.ipv4.ip_forward
value: "1"
state: present
reload: true
- name: Configure MASQUERADE (iptables)
community.general.iptables:
table: nat
chain: POSTROUTING
out_interface: "{{ wan_interface }}"
source: "{{ wg_net }}"
jump: MASQUERADE
state: present
when: firewall_backend == "iptables"
- name: Allow forwarding wg -> wan (iptables)
community.general.iptables:
table: filter
chain: FORWARD
in_interface: "{{ wg_interface }}"
out_interface: "{{ wan_interface }}"
source: "{{ wg_net }}"
jump: ACCEPT
state: present
when: firewall_backend == "iptables"
- name: Allow forwarding wan -> wg (iptables)
community.general.iptables:
table: filter
chain: FORWARD
out_interface: "{{ wg_interface }}"
destination: "{{ wg_net }}"
ctstate: RELATED,ESTABLISHED
jump: ACCEPT
state: present
when: firewall_backend == "iptables"
- name: Allow forwarding to extra nets (iptables)
community.general.iptables:
table: filter
chain: FORWARD
in_interface: "{{ wg_interface }}"
destination: "{{ item }}"
jump: ACCEPT
state: present
loop: "{{ extra_nets }}"
when: firewall_backend == "iptables"
- name: Allow return from extra nets (iptables)
community.general.iptables:
table: filter
chain: FORWARD
source: "{{ item }}"
out_interface: "{{ wg_interface }}"
ctstate: RELATED,ESTABLISHED
jump: ACCEPT
state: present
loop: "{{ extra_nets }}"
when: firewall_backend == "iptables"
- name: Deploy nftables WireGuard rules
ansible.builtin.template:
src: "{{ playbook_dir }}/../templates/wireguard-nftables.nft.j2"
dest: "/etc/nftables.d/wireguard-{{ wg_interface }}.nft"
owner: root
group: root
mode: "0644"
when: firewall_backend == "nftables"
notify: Reload nftables
- name: Ensure nftables main config includes WireGuard rules
ansible.builtin.lineinfile:
path: /etc/nftables.conf
regexp: '^include "/etc/nftables.d/wireguard-{{ wg_interface }}.nft";$'
line: 'include "/etc/nftables.d/wireguard-{{ wg_interface }}.nft";'
create: true
when: firewall_backend == "nftables"
notify: Reload nftables
- name: Manage UFW forward policy
ansible.builtin.lineinfile:
path: /etc/default/ufw
regexp: '^DEFAULT_FORWARD_POLICY='
line: 'DEFAULT_FORWARD_POLICY="ACCEPT"'
when: manage_ufw
- name: Allow WireGuard port in UFW
community.general.ufw:
rule: allow
port: "{{ listening_port }}"
proto: udp
comment: "WireGuard VPN"
when: manage_ufw
- name: Allow routed traffic via UFW (wg -> wan)
ansible.builtin.command:
cmd: "ufw route allow in on {{ wg_interface }} out on {{ wan_interface }} to any"
register: ufw_route_result
changed_when: "'Skipping' not in ufw_route_result.stdout"
when: manage_ufw
- name: Allow extra nets via UFW
ansible.builtin.command:
cmd: "ufw route allow in on {{ wg_interface }} to {{ item }}"
loop: "{{ extra_nets }}"
register: ufw_extra_result
changed_when: "'Skipping' not in ufw_extra_result.stdout"
when: manage_ufw
- name: Allow WireGuard port in firewalld
ansible.posix.firewalld:
zone: "{{ firewalld_zone }}"
port: "{{ listening_port }}/udp"
permanent: true
state: enabled
when: manage_firewalld
- name: Enable firewalld masquerade
ansible.posix.firewalld:
zone: "{{ firewalld_zone }}"
masquerade: true
permanent: true
state: enabled
when: manage_firewalld
- name: Allow forwarding from WireGuard via firewalld
ansible.posix.firewalld:
permanent: true
state: enabled
immediate: false
rich_rule: 'rule family="ipv4" source address="{{ wg_net }}" accept'
when: manage_firewalld
- name: Allow extra nets via firewalld
ansible.posix.firewalld:
permanent: true
state: enabled
immediate: false
rich_rule: 'rule family="ipv4" source address="{{ item }}" accept'
loop: "{{ extra_nets }}"
when: manage_firewalld
- name: Ensure wg-quick service enabled and restarted
ansible.builtin.systemd:
name: "wg-quick@{{ wg_interface }}"
enabled: true
state: restarted
- name: Show WireGuard status
ansible.builtin.command: "wg show {{ wg_interface }}"
register: wg_status
changed_when: false
failed_when: false
- name: Render routing summary
ansible.builtin.debug:
msg: |
WireGuard routing updated for {{ wg_interface }}
{{ wg_status.stdout }}
handlers:
- name: Reload nftables
ansible.builtin.command: nft -f /etc/nftables.conf

View File

@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# setup-wireguard-routing.sh
# Idempotent WireGuard split-tunnel routing helper.
# Detects iptables/nftables and optional UFW/Firewalld to configure forwarding + NAT.
set -euo pipefail
WAN_IF=${WAN_IF:-eth0}
WG_IF=${WG_IF:-wg0}
WG_NET=${WG_NET:-10.8.0.0/24}
WG_ADDR=${WG_ADDR:-10.8.0.1/24}
WG_PORT=${WG_PORT:-51820}
EXTRA_NETS_DEFAULT="192.168.178.0/24 172.20.0.0/16"
EXTRA_NETS="${EXTRA_NETS:-$EXTRA_NETS_DEFAULT}"
FIREWALL_BACKEND=${FIREWALL_BACKEND:-auto}
FIREWALLD_ZONE=${FIREWALLD_ZONE:-public}
read -r -a EXTRA_NETS_ARRAY <<< "${EXTRA_NETS}"
abort() {
echo "Error: $1" >&2
exit 1
}
require_root() {
if [[ "${EUID}" -ne 0 ]]; then
abort "please run as root (sudo ./setup-wireguard-routing.sh)"
fi
}
detect_backend() {
case "${FIREWALL_BACKEND}" in
iptables|nftables) echo "${FIREWALL_BACKEND}"; return 0 ;;
auto)
if command -v nft >/dev/null 2>&1; then
echo "nftables"; return 0
fi
if command -v iptables >/dev/null 2>&1; then
echo "iptables"; return 0
fi
;;
esac
abort "no supported firewall backend found (install iptables or nftables)"
}
ensure_sysctl() {
local sysctl_file="/etc/sysctl.d/99-${WG_IF}-forward.conf"
cat <<EOF > "${sysctl_file}"
# Managed by setup-wireguard-routing.sh
net.ipv4.ip_forward=1
EOF
sysctl -w net.ipv4.ip_forward=1 >/dev/null
sysctl --system >/dev/null
}
apply_iptables() {
iptables -t nat -C POSTROUTING -s "${WG_NET}" -o "${WAN_IF}" -j MASQUERADE 2>/dev/null || \
iptables -t nat -A POSTROUTING -s "${WG_NET}" -o "${WAN_IF}" -j MASQUERADE
iptables -C FORWARD -i "${WG_IF}" -s "${WG_NET}" -o "${WAN_IF}" -j ACCEPT 2>/dev/null || \
iptables -A FORWARD -i "${WG_IF}" -s "${WG_NET}" -o "${WAN_IF}" -j ACCEPT
iptables -C FORWARD -o "${WG_IF}" -d "${WG_NET}" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || \
iptables -A FORWARD -o "${WG_IF}" -d "${WG_NET}" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
for net in "${EXTRA_NETS_ARRAY[@]}"; do
[[ -z "${net}" ]] && continue
iptables -C FORWARD -i "${WG_IF}" -d "${net}" -j ACCEPT 2>/dev/null || \
iptables -A FORWARD -i "${WG_IF}" -d "${net}" -j ACCEPT
iptables -C FORWARD -s "${net}" -o "${WG_IF}" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || \
iptables -A FORWARD -s "${net}" -o "${WG_IF}" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
done
}
apply_nftables() {
local table="inet wireguard_${WG_IF}"
nft list table ${table} >/dev/null 2>&1 || nft add table ${table}
nft list chain ${table} postrouting >/dev/null 2>&1 || \
nft add chain ${table} postrouting '{ type nat hook postrouting priority srcnat; }'
nft list chain ${table} forward >/dev/null 2>&1 || \
nft add chain ${table} forward '{ type filter hook forward priority filter; policy accept; }'
nft list chain ${table} postrouting | grep -q "oifname \"${WAN_IF}\" ip saddr ${WG_NET}" || \
nft add rule ${table} postrouting oifname "${WAN_IF}" ip saddr ${WG_NET} masquerade
nft list chain ${table} forward | grep -q "iifname \"${WG_IF}\" ip saddr ${WG_NET}" || \
nft add rule ${table} forward iifname "${WG_IF}" ip saddr ${WG_NET} counter accept
nft list chain ${table} forward | grep -q "oifname \"${WG_IF}\" ip daddr ${WG_NET}" || \
nft add rule ${table} forward oifname "${WG_IF}" ip daddr ${WG_NET} ct state established,related counter accept
for net in "${EXTRA_NETS_ARRAY[@]}"; do
[[ -z "${net}" ]] && continue
nft list chain ${table} forward | grep -q "iifname \"${WG_IF}\" ip daddr ${net}" || \
nft add rule ${table} forward iifname "${WG_IF}" ip daddr ${net} counter accept
done
}
configure_ufw() {
if command -v ufw >/dev/null 2>&1 && ufw status | grep -iq "Status: active"; then
sed -i 's/^DEFAULT_FORWARD_POLICY=.*/DEFAULT_FORWARD_POLICY="ACCEPT"/' /etc/default/ufw
ufw allow "${WG_PORT}"/udp >/dev/null
ufw route allow in on "${WG_IF}" out on "${WAN_IF}" to any >/dev/null 2>&1 || true
for net in "${EXTRA_NETS_ARRAY[@]}"; do
[[ -z "${net}" ]] && continue
ufw route allow in on "${WG_IF}" to "${net}" >/dev/null 2>&1 || true
done
ufw reload >/dev/null
fi
}
configure_firewalld() {
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
firewall-cmd --permanent --zone="${FIREWALLD_ZONE}" --add-port=${WG_PORT}/udp >/dev/null
firewall-cmd --permanent --zone="${FIREWALLD_ZONE}" --add-masquerade >/dev/null
firewall-cmd --permanent --direct --add-rule ipv4 filter FORWARD 0 \
"iif ${WG_IF} oif ${WAN_IF} -s ${WG_NET} -j ACCEPT" >/dev/null 2>&1 || true
firewall-cmd --permanent --direct --add-rule ipv4 filter FORWARD 0 \
"oif ${WG_IF} -d ${WG_NET} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT" >/dev/null 2>&1 || true
for net in "${EXTRA_NETS_ARRAY[@]}"; do
[[ -z "${net}" ]] && continue
firewall-cmd --permanent --direct --add-rule ipv4 filter FORWARD 0 \
"iif ${WG_IF} -d ${net} -j ACCEPT" >/dev/null 2>&1 || true
done
firewall-cmd --reload >/dev/null
fi
}
ensure_service() {
systemctl enable "wg-quick@${WG_IF}" >/dev/null
systemctl restart "wg-quick@${WG_IF}"
}
show_status() {
echo "WireGuard routing configured with ${WG_IF} (${WG_ADDR}) via ${WAN_IF}"
wg show "${WG_IF}" || true
ip route show table main | grep "${WG_NET}" || true
}
main() {
require_root
ensure_sysctl
backend=$(detect_backend)
case "${backend}" in
iptables) apply_iptables ;;
nftables) apply_nftables ;;
esac
configure_ufw
configure_firewalld
ensure_service
show_status
}
main "$@"

View File

@@ -30,6 +30,16 @@ vault_git_token: "change-me-gitea-personal-access-token"
# vault_git_username: "your-gitea-username"
# vault_git_password: "your-gitea-password"
# Gitea Admin Credentials (for initial setup)
# Required for automated Gitea initial configuration
vault_gitea_admin_username: "admin"
vault_gitea_admin_password: "change-me-secure-gitea-admin-password"
vault_gitea_admin_email: "kontakt@michaelschiemer.de"
# Gitea Redis Credentials
# Required for Redis cache, sessions, and queue
vault_gitea_redis_password: "change-me-secure-gitea-redis-password"
# Optional: Additional Secrets
vault_encryption_key: "change-me-encryption-key"
vault_session_secret: "change-me-session-secret"

View File

@@ -21,13 +21,22 @@ HTTP_ADDR = 0.0.0.0
HTTP_PORT = 3000
ROOT_URL = https://{{ gitea_domain }}/
PUBLIC_URL_DETECTION = auto
;; Performance settings for handling concurrent requests
LFS_START_SERVER = true
LFS_CONTENT_PATH = data/lfs
LFS_JWT_SECRET =
;; Increase timeouts for better stability under load
READ_TIMEOUT = 60s
WRITE_TIMEOUT = 60s
;; SSH Configuration
;; Note: SSH_LISTEN_PORT should match the port exposed in docker-compose.yml
;; If SSH is not needed, set DISABLE_SSH = true and START_SSH_SERVER = false
DISABLE_SSH = false
START_SSH_SERVER = true
START_SSH_SERVER = false
SSH_DOMAIN = {{ gitea_domain }}
SSH_PORT = 22
SSH_LISTEN_PORT = 22
SSH_PORT = {{ ssh_port | default(2222) }}
SSH_LISTEN_PORT = {{ ssh_listen_port | default(2222) }}
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Database Configuration
@@ -39,30 +48,45 @@ NAME = {{ postgres_db | default('gitea') }}
USER = {{ postgres_user | default('gitea') }}
PASSWD = {{ postgres_password | default('gitea_password') }}
SSL_MODE = disable
;; Connection Pool Settings - Prevents "Connection reset by peer" errors
;; Increased limits for handling concurrent requests
MAX_OPEN_CONNS = 200
MAX_IDLE_CONNS = 50
CONN_MAX_LIFETIME = 600
CONN_MAX_IDLE_TIME = 300
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Cache Configuration
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[cache]
ENABLED = false
ADAPTER = memory
ENABLED = true
ADAPTER = redis
HOST = redis:6379
PASSWORD = {{ redis_password | default('gitea_redis_password') }}
DB = 0
;; Redis cache for better performance and persistence
;; Cache survives container restarts
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Session Configuration
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[session]
PROVIDER = file
PROVIDER_CONFIG = data/sessions
PROVIDER = redis
PROVIDER_CONFIG = network=tcp,addr=redis:6379,password={{ redis_password | default('gitea_redis_password') }},db=0,pool_size=100,idle_timeout=180
COOKIE_SECURE = true
COOKIE_NAME = i_like_gitea
GC_INTERVAL_TIME = 86400
SESSION_LIFE_TIME = 86400
;; Redis sessions for better performance and scalability
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Queue Configuration
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[queue]
TYPE = channel
TYPE = redis
CONN_STR = redis://:{{ redis_password | default('gitea_redis_password') }}@redis:6379/0
;; Redis queue for persistent job processing
;; Jobs survive container restarts
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Service Configuration
@@ -79,3 +103,10 @@ ENABLED = true
;; Do NOT set DEFAULT_ACTIONS_URL to a custom URL - it's not supported
;; Leaving it unset or setting to "self" will use the current instance
;DEFAULT_ACTIONS_URL = self
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Security Configuration
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[security]
;; Set INSTALL_LOCK to true to skip the initial setup page
INSTALL_LOCK = true

View File

@@ -0,0 +1,50 @@
# WireGuard Server Configuration
# Interface: wg0
# Network: {{ wg_network }}
# Server IP: {{ wg_server_ip }}
[Interface]
PrivateKey = {{ wg_server_private_key }}
Address = {{ wg_server_ip }}/{{ wg_netmask }}
ListenPort = {{ wg_port | default(51820) }}
# Enable IP forwarding for VPN routing
PostUp = sysctl -w net.ipv4.ip_forward=1
# nftables: Setup VPN routing and firewall
PostUp = nft add table inet wireguard
PostUp = nft add chain inet wireguard postrouting { type nat hook postrouting priority srcnat\; }
PostUp = nft add chain inet wireguard forward { type filter hook forward priority filter\; }
# NAT for VPN traffic (masquerade to WAN)
PostUp = nft add rule inet wireguard postrouting oifname "{{ wan_interface }}" ip saddr {{ wg_network }} masquerade
# Allow VPN traffic forwarding
PostUp = nft add rule inet wireguard forward iifname "wg0" ip saddr {{ wg_network }} accept
PostUp = nft add rule inet wireguard forward oifname "wg0" ip daddr {{ wg_network }} ct state established,related accept
# Cleanup on shutdown
PostDown = nft delete table inet wireguard
# Peers (automatically managed)
# Format:
# [Peer]
# # Description: device-name
# PublicKey = peer_public_key
# PresharedKey = peer_preshared_key
# AllowedIPs = 10.8.0.X/32
# PersistentKeepalive = 25 # Optional: for clients behind NAT
{% for peer in wg_peers | default([]) %}
[Peer]
# {{ peer.name }}
PublicKey = {{ peer.public_key }}
{% if peer.preshared_key is defined %}
PresharedKey = {{ peer.preshared_key }}
{% endif %}
AllowedIPs = {{ peer.allowed_ips }}
{% if peer.persistent_keepalive | default(true) %}
PersistentKeepalive = 25
{% endif %}
{% endfor %}

View File

@@ -0,0 +1,116 @@
#!/usr/sbin/nft -f
# WireGuard VPN Firewall Rules
# Purpose: Isolate admin services behind VPN, allow public access only to ports 80, 443, 22
# Generated by Ansible - DO NOT EDIT MANUALLY
table inet wireguard_firewall {
# Define sets for easy management
set vpn_network {
type ipv4_addr
flags interval
elements = { {{ wg_network }} }
}
set admin_service_ports {
type inet_service
elements = {
8080, # Traefik Dashboard
9090, # Prometheus
3001, # Grafana
9000, # Portainer
8001, # Redis Insight
{% for port in additional_admin_ports | default([]) %}
{{ port }}, # {{ port }}
{% endfor %}
}
}
set public_service_ports {
type inet_service
elements = {
80, # HTTP
443, # HTTPS
22, # SSH
{% for port in additional_public_ports | default([]) %}
{{ port }}, # {{ port }}
{% endfor %}
}
}
# Input chain - Handle incoming traffic
chain input {
type filter hook input priority filter; policy drop;
# Allow established/related connections
ct state established,related accept
# Allow loopback
iifname "lo" accept
# Allow ICMP (ping)
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
# Allow SSH (public)
tcp dport 22 accept
# Allow WireGuard port (public)
udp dport {{ wg_port | default(51820) }} accept comment "WireGuard VPN"
# Allow public web services (HTTP/HTTPS)
tcp dport @public_service_ports accept comment "Public services"
# Allow VPN network to access admin services
ip saddr @vpn_network tcp dport @admin_service_ports accept comment "VPN admin access"
# Block public access to admin services
tcp dport @admin_service_ports counter log prefix "BLOCKED_ADMIN_SERVICE: " drop
# Log and drop all other traffic
counter log prefix "BLOCKED_INPUT: " drop
}
# Forward chain - Handle routed traffic (VPN to services)
chain forward {
type filter hook forward priority filter; policy drop;
# Allow established/related connections
ct state established,related accept
# Allow VPN clients to access local services
iifname "wg0" ip saddr @vpn_network accept comment "VPN to services"
# Allow return traffic to VPN clients
oifname "wg0" ip daddr @vpn_network ct state established,related accept
# Log and drop all other forwarded traffic
counter log prefix "BLOCKED_FORWARD: " drop
}
# Output chain - Allow all outgoing traffic
chain output {
type filter hook output priority filter; policy accept;
}
# NAT chain - Masquerade VPN traffic to WAN
chain postrouting {
type nat hook postrouting priority srcnat;
# Masquerade VPN traffic going to WAN
oifname "{{ wan_interface }}" ip saddr @vpn_network masquerade comment "VPN NAT"
}
}
# Optional: Rate limiting for VPN port (DDoS protection)
{% if wg_enable_rate_limit | default(true) %}
table inet wireguard_ratelimit {
chain input {
type filter hook input priority -10;
# Rate limit WireGuard port: 10 connections per second per IP
udp dport {{ wg_port | default(51820) }} \
meter vpn_ratelimit { ip saddr limit rate over 10/second } \
counter log prefix "VPN_RATELIMIT: " drop
}
}
{% endif %}

View File

@@ -0,0 +1,15 @@
table inet wireguard_{{ wg_interface }} {
chain postrouting {
type nat hook postrouting priority srcnat;
oifname "{{ wan_interface }}" ip saddr {{ wg_net }} masquerade
}
chain forward {
type filter hook forward priority filter;
iifname "{{ wg_interface }}" ip saddr {{ wg_net }} counter accept
oifname "{{ wg_interface }}" ip daddr {{ wg_net }} ct state established,related counter accept
{% for net in extra_nets %}
iifname "{{ wg_interface }}" ip daddr {{ net }} counter accept
{% endfor %}
}
}

View File

@@ -0,0 +1,14 @@
[Interface]
# Client: michael-pc
# Generated: 2025-11-05T01:02:14Z
PrivateKey = MHgxUzmEHQ15EB3v4TaXEcJAZNRaBd54/ZDcN6nN8lI=
Address = 10.8.0.2/32
DNS = 1.1.1.1, 8.8.8.8
[Peer]
# WireGuard Server
PublicKey = SFxxHe4bunfQ1Xid5AMXbBgY+AjlxNtRHQ5uYjSib3E=
PresharedKey = WsnvFp6WrF/y9fQwn3RgOTmwMS2UHoqIBRKrTPZ5lW8=
Endpoint = 94.16.110.151:51820
AllowedIPs = 10.8.0.0/24
PersistentKeepalive = 25

View File

@@ -1,10 +1,10 @@
# Deployment TODO - Komplette Implementierung
**Status**: ✅ ~95% Abgeschlossen - Ready for Testing
**Letzte Aktualisierung**: 2025-10-31
**Status**: ✅ ~99% Abgeschlossen - Initial Deployment erfolgreich
**Letzte Aktualisierung**: 2025-11-08
**Ziel**: Komplettes Deployment-Setup im `deployment/` Ordner
**🎯 Nächster kritischer Schritt:** Pipeline End-to-End testen!
**🎯 Nächster kritischer Schritt:** CI/CD Pipeline End-to-End testen!
---
@@ -14,7 +14,7 @@
-**Traefik** - Reverse Proxy & SSL
-**PostgreSQL** - Database Stack
-**Docker Registry** - Private Registry
-**Gitea** - Git Server + MySQL + Redis
-**Gitea** - Git Server + PostgreSQL (Initial Setup automatisiert)
-**Monitoring** - Portainer + Grafana + Prometheus
-**WireGuard VPN** - VPN Server
@@ -27,12 +27,24 @@
-`setup-production-secrets.yml` - Secrets Deployment
-`setup-ssl-certificates.yml` - SSL Certificate Setup
-`sync-stacks.yml` - Stacks synchronisieren
-`sync-application-code.yml` - Code-Sync für Initial Deployment (Rsync)
-`deploy-application-code.yml` - Code-Deployment via Git (CI/CD)
-`install-composer-dependencies.yml` - Composer Dependencies Installation
-`build-initial-image.yml` - Initial Docker Image Build
-`check-container-logs.yml` - Container Logs prüfen
-`check-container-status.yml` - Container Status prüfen
-`check-final-status.yml` - Finale Status-Prüfung
-`fix-container-issues.yml` - Container-Probleme beheben
-`fix-web-container.yml` - Web Container Permissions beheben
### Dokumentation
-`README.md` - Deployment Übersicht
-`SETUP-GUIDE.md` - Komplette Setup-Anleitung
-`DEPLOYMENT-STATUS.md` - Aktueller Status
-`docs/WIREGUARD-SETUP.md` - WireGuard Dokumentation
-`docs/guides/initial-deployment-guide.md` - Initial Deployment Anleitung
-`docs/guides/code-deployment-workflow.md` - Code Deployment Workflow
-`docs/troubleshooting/initial-deployment-issues.md` - Troubleshooting Guide
---
@@ -40,27 +52,31 @@
### 1. Application Stack Integration
**Status**: ⚠️ Fehlt in `setup-infrastructure.yml`
**Status**: ✅ Abgeschlossen - Initial Deployment erfolgreich
**Was fehlt:**
**Was erledigt:**
- [x] Application Stack zu `setup-infrastructure.yml` hinzufügen ✅
- [x] `.env` Template für Application Stack erstellen (`application.env.j2`) ✅
- [x] Ansible Playbook/Task für Application Stack Deployment ✅
- [x] Database-Migration nach Application Deployment ✅
- [x] Health-Check nach Application Deployment ✅
- [x] Code-Synchronisation (Rsync) implementiert ✅
- [x] Composer Dependencies Installation ✅
- [x] Container-Konfiguration (Entrypoint Overrides, Permissions) ✅
- [x] Environment Variables Loading (env_file mit absolutem Pfad) ✅
**Dateien:**
- `deployment/stacks/application/docker-compose.yml` ✅ Vorhanden
- `deployment/stacks/application/.env.example` ✅ Vorhanden
- `deployment/stacks/application/.env` ❌ Fehlt (muss generiert werden)
- `deployment/ansible/templates/application.env.j2` ❌ Fehlt (Template für `.env`)
- `deployment/ansible/playbooks/setup-infrastructure.yml` ⚠️ Application fehlt
- `deployment/stacks/production/docker-compose.yml` ✅ Vorhanden
- `deployment/stacks/production/.env.example` ✅ Vorhanden
- `deployment/stacks/production/.env` ✅ Wird automatisch generiert
- `deployment/ansible/templates/application.env.j2` ✅ Vorhanden
- `deployment/ansible/playbooks/setup-infrastructure.yml` Application integriert
**Nächste Schritte:**
1. Application Stack Deployment Task zu `setup-infrastructure.yml` hinzufügen
2. `.env` Template erstellen (mit Passwörtern aus Vault)
3. Database-Migration nach Application Start
4. Health-Check Integration
**Initial Deployment erfolgreich durchgeführt:**
- Code synchronisiert via `sync-application-code.yml`
- Composer Dependencies installiert
- Container laufen und sind healthy
- Alle bekannten Probleme behoben (CRLF, Permissions, Environment Variables)
---
@@ -75,37 +91,52 @@
- [x] Environment-Variablen aus Vault/Template generieren ✅
**Dateien:**
- `deployment/stacks/application/.env.example` ✅ Vorhanden (angepasst für PostgreSQL)
- `deployment/stacks/application/.env` ⚠️ Wird automatisch generiert
- `deployment/stacks/production/.env.example` ✅ Vorhanden (angepasst für PostgreSQL)
- `deployment/stacks/production/.env` ⚠️ Wird automatisch generiert
- `deployment/ansible/templates/application.env.j2` ✅ Erstellt
- `deployment/stacks/application/docker-compose.yml` ✅ Angepasst (PostgreSQL statt MySQL)
- `deployment/stacks/production/docker-compose.yml` ✅ Angepasst (PostgreSQL statt MySQL)
---
### 3. Gitea Runner Setup abschließen
### 3. Gitea Initial Setup
**Status**: ⏳ Wartet auf Registration Token
**Status**: ✅ Automatisiert - Vollständig implementiert
**Was fehlt:**
- [ ] Gitea Admin Panel öffnen: https://git.michaelschiemer.de/admin/actions/runners
- [ ] Actions in Gitea aktivieren (falls noch nicht geschehen)
- [ ] Registration Token abrufen
- [ ] Token in `.env` eintragen
- [ ] Runner registrieren und starten
**Was erledigt:**
- [x] Ansible Playbook für automatische Initial Setup erstellt ✅
- [x] `app.ini` Template mit `INSTALL_LOCK = true`
- [x] Admin-Benutzer wird automatisch über CLI erstellt ✅
- [x] Vault-Variablen für Gitea Admin-Credentials ✅
- [x] Playbook getestet und funktioniert ✅
**Dateien:**
- `deployment/gitea-runner/.env` ⚠️ Vorhanden, aber Token fehlt
- `deployment/gitea-runner/.env.example` ✅ Vorhanden
- `deployment/ansible/playbooks/setup-gitea-initial-config.yml`
- `deployment/ansible/templates/gitea-app.ini.j2` ✅ (erweitert mit `[security]` Sektion)
- `deployment/ansible/secrets/production.vault.yml.example` ✅ (Gitea-Variablen dokumentiert)
**Nächste Schritte:**
1. Gitea Actions aktivieren (Admin Panel)
2. Runner Registration Token generieren
3. Token in `.env` eintragen
4. Runner starten: `cd deployment/gitea-runner && docker compose up -d`
**Verwendung:**
```bash
cd deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/setup-gitea-initial-config.yml \
--vault-password-file secrets/.vault_pass
```
### 4. Gitea Runner Setup abschließen
**Status**: ✅ Abgeschlossen
**Was erledigt:**
- [x] Gitea Admin Panel erreichbar ✅
- [x] Actions in Gitea aktiviert ✅
- [x] Registration Token abgerufen ✅
- [x] Token in `deployment/gitea-runner/.env` eingetragen ✅
- [x] Runner registriert ✅
- [x] Runner läuft ✅
---
### 4. CI/CD Pipeline finalisieren
### 5. CI/CD Pipeline finalisieren
**Status**: ✅ Vollständig konfiguriert - Bereit zum Testen
@@ -198,18 +229,25 @@ ansible-playbook -i inventory/production.yml playbooks/<playbook>.yml
### 7. Dokumentation vervollständigen
**Status**: ⚠️ Gut, aber einige Updates nötig
**Status**: ✅ Abgeschlossen - Alle Initial Deployment Dokumentation erstellt
**Was fehlt:**
- [ ] `DEPLOYMENT-STATUS.md` aktualisieren (Application Stack Status)
- [ ] `README.md` aktualisieren (Application Stack Deployment)
- [ ] `SETUP-GUIDE.md` aktualisieren (Application Stack Phase)
- [ ] Troubleshooting Guide für Application Stack
**Was erledigt:**
- [x] Initial Deployment Guide erstellt ✅
- [x] Code Deployment Workflow dokumentiert ✅
- [x] Troubleshooting Guide für Initial Deployment erstellt ✅
- [x] Application Stack Dokumentation aktualisiert ✅
- [x] Deployment Commands aktualisiert ✅
- [x] Quick Start Guide aktualisiert ✅
- [x] Ansible Playbooks README aktualisiert ✅
**Dateien:**
- `deployment/README.md` ⚠️ Muss aktualisiert werden
- `deployment/SETUP-GUIDE.md` ⚠️ Muss aktualisiert werden
- `deployment/DEPLOYMENT-STATUS.md` ⚠️ Muss aktualisiert werden
- `deployment/docs/guides/initial-deployment-guide.md` ✅ Neu erstellt
- `deployment/docs/guides/code-deployment-workflow.md` ✅ Neu erstellt
- `deployment/docs/troubleshooting/initial-deployment-issues.md` ✅ Neu erstellt
- `deployment/docs/reference/application-stack.md` ✅ Aktualisiert
- `deployment/docs/guides/deployment-commands.md` ✅ Aktualisiert
- `deployment/docs/guides/quick-start.md` ✅ Aktualisiert
- `deployment/ansible/playbooks/README.md` ✅ Aktualisiert
---
@@ -229,22 +267,36 @@ ansible-playbook -i inventory/production.yml playbooks/<playbook>.yml
### ✅ Phase 2: CI/CD Setup - ABGESCHLOSSEN
3.**Gitea Runner Setup abschließen**
3.**Gitea Initial Setup automatisiert**
- ✅ Playbook erstellt (`setup-gitea-initial-config.yml`)
-`app.ini` Template mit `INSTALL_LOCK = true`
- ✅ Admin-Benutzer wird automatisch erstellt
- ✅ Getestet und funktioniert
4.**Gitea Runner Setup abschließen**
- ✅ Token konfiguriert
- ✅ Runner läuft und ist registriert
4.**CI/CD Pipeline finalisieren**
5.**CI/CD Pipeline finalisieren**
- ✅ Secrets in Gitea konfiguriert
- ⚠️ **Pipeline testen** - NÄCHSTER SCHRITT
### ⚠️ Phase 3: Testing & Finalisierung (NÄCHSTER SCHRITT)
### ⚠️ Phase 3: Testing & Finalisierung (IN ARBEIT)
5. **Pipeline End-to-End testen** ⚠️ **KRITISCH**
- Test-Commit pushen oder Workflow manuell triggern
- Alle Jobs verifizieren (Tests, Build, Deploy)
- Deployment auf Production verifizieren
- Health-Check erfolgreich
- Fehler beheben falls notwendig
5. **Pipeline End-to-End testen** ⚠️ **IN ARBEIT**
- ✅ Gitea Caching aktiviert (docker-compose.yml angepasst)
- ✅ Prerequisites geprüft
- ✅ Gitea Secrets verifiziert (laut Dokumentation konfiguriert)
- ✅ Gitea Runner Status geprüft (registriert, aber nicht gestartet - korrekt)
- ✅ Test-Commit erstellt
- ✅ Traefik-Passwort zurückgesetzt
- ✅ Traefik File-Provider-Konfiguration deaktiviert (gitea-service.yml entfernt - löste 504-Fehler)
- ✅ Gitea app.ini aktualisiert (Cache-Sektion entfernt, keine 127.0.0.1-Werte mehr)
- ✅ Gitea Repository erstellt (michael/michaelschiemer)
- ✅ Git Remote konfiguriert
- ⏳ Pipeline End-to-End testen (Push und Deployment)
- ⏳ Staging Pipeline testen (nach erfolgreichem Push)
- ⏳ Production Pipeline testen (nach erfolgreichem Staging-Test)
### Phase 3: Backup & Scripts
@@ -273,20 +325,32 @@ ansible-playbook -i inventory/production.yml playbooks/<playbook>.yml
### CI/CD
- [x] Gitea Runner Token konfigurieren ✅
- [x] Runner starten
- [x] Secrets in Gitea konfigurieren
- [ ] Pipeline testen ⚠️ **NÄCHSTER SCHRITT**
- [x] Runner registriert (nicht gestartet - korrekt, da Token konfiguriert)
- [x] Secrets in Gitea konfiguriert
- [x] Gitea Caching aktiviert (Konfiguration angepasst) ✅
- [x] Traefik File-Provider-Konfiguration deaktiviert (gitea-service.yml entfernt) ✅
- [x] Gitea app.ini bereinigt (Cache-Sektion entfernt) ✅
- [x] Gitea Repository erstellt (michael/michaelschiemer) ✅
- [x] Git Remote konfiguriert ✅
- [ ] Pipeline End-to-End testen ⚠️ **NÄCHSTER SCHRITT**
### Scripts & Backup
- [ ] Backup-Playbook erstellen
- [ ] Rollback testen
- [ ] Deployment-Scripts finalisieren
- [x] Backup-Playbook erstellen ✅ (`backup.yml` vorhanden)
- [ ] Rollback testen (Playbook vorhanden, muss getestet werden)
- [x] Deployment-Scripts finalisieren ✅ (Ansible Playbooks in Workflows integriert)
### Dokumentation
- [ ] README aktualisieren
- [ ] SETUP-GUIDE aktualisieren
- [ ] DEPLOYMENT-STATUS aktualisieren
- [x] Initial Deployment Dokumentation erstellt ✅
- [x] CI/CD Workflow Dokumentation erstellt ✅
- [x] Backup & Rollback Dokumentation erstellt ✅
- [x] CI/CD Status aktualisiert ✅
- [x] Deployment TODO aktualisiert ✅
---
**Nächster Schritt**: Application Stack zu `setup-infrastructure.yml` hinzufügen und `.env` Template erstellen
**Nächster Schritt**: CI/CD Pipeline End-to-End testen
**Test-Ressourcen:**
- [Pipeline Test Checklist](../guides/pipeline-test-checklist.md) ⭐ - Detaillierte Schritt-für-Schritt Anleitung
- [Pipeline Testing Guide](../guides/pipeline-testing-guide.md) - Übersicht und Troubleshooting
- `deployment/scripts/test-pipeline-prerequisites.sh` - Prüft alle Voraussetzungen automatisch

View File

@@ -0,0 +1,206 @@
#!/bin/bash
# Cleanup Old WireGuard Docker Setup
# Purpose: Remove old WireGuard Docker stack and CoreDNS before migrating to host-based setup
# WARNING: This will stop and remove the old VPN setup!
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# ========================================
# Configuration
# ========================================
DEPLOYMENT_DIR="/home/michael/dev/michaelschiemer/deployment"
WIREGUARD_STACK_DIR="${DEPLOYMENT_DIR}/stacks/wireguard"
COREDNS_STACK_DIR="${DEPLOYMENT_DIR}/stacks/coredns"
ARCHIVE_DIR="${DEPLOYMENT_DIR}/wireguard-docker-archive-$(date +%Y%m%d)"
# ========================================
# Pre-flight Checks
# ========================================
print_info "WireGuard Docker Setup Cleanup Script"
echo ""
print_warning "This script will:"
echo " - Stop WireGuard Docker container"
echo " - Stop CoreDNS container (if exists)"
echo " - Archive old configuration"
echo " - Remove Docker stacks"
echo ""
print_warning "VPN access will be lost until new host-based setup is deployed!"
echo ""
read -p "Continue? (type 'yes' to proceed): " -r
if [[ ! $REPLY == "yes" ]]; then
print_info "Aborted by user"
exit 0
fi
# ========================================
# Stop Docker Containers
# ========================================
print_info "Stopping WireGuard Docker container..."
if [ -d "$WIREGUARD_STACK_DIR" ]; then
cd "$WIREGUARD_STACK_DIR"
if [ -f "docker-compose.yml" ]; then
docker-compose down || print_warning "WireGuard container already stopped or not found"
fi
else
print_warning "WireGuard stack directory not found: $WIREGUARD_STACK_DIR"
fi
print_info "Stopping CoreDNS Docker container (if exists)..."
if [ -d "$COREDNS_STACK_DIR" ]; then
cd "$COREDNS_STACK_DIR"
if [ -f "docker-compose.yml" ]; then
docker-compose down || print_warning "CoreDNS container already stopped or not found"
fi
else
print_info "CoreDNS stack directory not found (may not have existed)"
fi
# ========================================
# Archive Old Configuration
# ========================================
print_info "Creating archive of old configuration..."
mkdir -p "$ARCHIVE_DIR"
# Archive WireGuard stack
if [ -d "$WIREGUARD_STACK_DIR" ]; then
print_info "Archiving WireGuard stack..."
cp -r "$WIREGUARD_STACK_DIR" "$ARCHIVE_DIR/wireguard-stack"
print_success "WireGuard stack archived to: $ARCHIVE_DIR/wireguard-stack"
fi
# Archive CoreDNS stack
if [ -d "$COREDNS_STACK_DIR" ]; then
print_info "Archiving CoreDNS stack..."
cp -r "$COREDNS_STACK_DIR" "$ARCHIVE_DIR/coredns-stack"
print_success "CoreDNS stack archived to: $ARCHIVE_DIR/coredns-stack"
fi
# Archive old Ansible files
print_info "Archiving old Ansible playbooks..."
if [ -d "${DEPLOYMENT_DIR}/wireguard-old" ]; then
cp -r "${DEPLOYMENT_DIR}/wireguard-old" "$ARCHIVE_DIR/ansible-old"
fi
# Archive nftables templates
if [ -f "${DEPLOYMENT_DIR}/ansible/templates/wireguard-nftables.nft.j2" ]; then
mkdir -p "$ARCHIVE_DIR/ansible-templates"
cp "${DEPLOYMENT_DIR}/ansible/templates/wireguard-nftables.nft.j2" "$ARCHIVE_DIR/ansible-templates/"
fi
# Create archive summary
cat > "$ARCHIVE_DIR/ARCHIVE_INFO.txt" <<EOF
WireGuard Docker Setup Archive
Created: $(date)
This archive contains the old WireGuard Docker-based setup that was replaced
with a host-based WireGuard configuration.
Contents:
- wireguard-stack/: Docker Compose stack for WireGuard
- coredns-stack/: Docker Compose stack for CoreDNS (if existed)
- ansible-old/: Old Ansible playbooks and configs
- ansible-templates/: Old nftables templates
To restore old setup (NOT RECOMMENDED):
1. Stop new host-based WireGuard: systemctl stop wg-quick@wg0
2. Copy stacks back: cp -r wireguard-stack ../stacks/
3. Start container: cd ../stacks/wireguard && docker-compose up -d
For new host-based setup, see:
- deployment/wireguard/README.md
- deployment/ansible/playbooks/setup-wireguard-host.yml
EOF
print_success "Archive created at: $ARCHIVE_DIR"
# ========================================
# Remove Docker Stacks
# ========================================
print_info "Removing old Docker stacks..."
read -p "Remove WireGuard Docker stack directory? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
if [ -d "$WIREGUARD_STACK_DIR" ]; then
rm -rf "$WIREGUARD_STACK_DIR"
print_success "WireGuard Docker stack removed"
fi
fi
read -p "Remove CoreDNS Docker stack directory? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
if [ -d "$COREDNS_STACK_DIR" ]; then
rm -rf "$COREDNS_STACK_DIR"
print_success "CoreDNS Docker stack removed"
fi
fi
# ========================================
# Clean up Docker Resources
# ========================================
print_info "Cleaning up Docker resources..."
# Remove WireGuard network
docker network rm wireguard-net 2>/dev/null || print_info "WireGuard network already removed"
# Remove unused volumes
print_info "Removing unused Docker volumes..."
docker volume prune -f || print_warning "Could not prune volumes"
# ========================================
# Summary
# ========================================
echo ""
print_success "=========================================="
print_success "Cleanup Complete!"
print_success "=========================================="
echo ""
echo "Archive Location: $ARCHIVE_DIR"
echo ""
print_info "Next Steps:"
echo " 1. Deploy host-based WireGuard:"
echo " cd ${DEPLOYMENT_DIR}/ansible"
echo " ansible-playbook playbooks/setup-wireguard-host.yml"
echo ""
echo " 2. Generate client configs:"
echo " cd ${DEPLOYMENT_DIR}/scripts"
echo " sudo ./generate-client-config.sh <device-name>"
echo ""
echo " 3. Verify new setup:"
echo " sudo wg show wg0"
echo " sudo systemctl status wg-quick@wg0"
echo ""
print_warning "Old Docker-based VPN is now inactive!"
print_info "VPN access will be restored after deploying host-based setup"
echo ""

View File

@@ -0,0 +1,282 @@
#!/bin/bash
# WireGuard Client Configuration Generator
# Purpose: Generate client configs with QR codes for easy mobile import
# Usage: ./generate-client-config.sh <client-name>
set -euo pipefail
# ========================================
# Configuration
# ========================================
WG_CONFIG_DIR="/etc/wireguard"
CLIENT_CONFIG_DIR="$(dirname "$0")/../wireguard/configs"
WG_INTERFACE="wg0"
WG_SERVER_CONFIG="${WG_CONFIG_DIR}/${WG_INTERFACE}.conf"
WG_NETWORK="10.8.0.0/24"
WG_SERVER_IP="10.8.0.1"
WG_PORT="51820"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# ========================================
# Helper Functions
# ========================================
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
check_root() {
if [ "$EUID" -ne 0 ]; then
print_error "This script must be run as root (for server config modifications)"
exit 1
fi
}
check_dependencies() {
local deps=("wg" "wg-quick" "qrencode")
local missing=()
for dep in "${deps[@]}"; do
if ! command -v "$dep" &> /dev/null; then
missing+=("$dep")
fi
done
if [ ${#missing[@]} -ne 0 ]; then
print_error "Missing dependencies: ${missing[*]}"
print_info "Install with: apt install wireguard wireguard-tools qrencode"
exit 1
fi
}
get_next_client_ip() {
# Find highest used IP in last octet and add 1
if [ ! -f "$WG_SERVER_CONFIG" ]; then
echo "10.8.0.2"
return
fi
local last_octet=$(grep -oP 'AllowedIPs\s*=\s*10\.8\.0\.\K\d+' "$WG_SERVER_CONFIG" 2>/dev/null | sort -n | tail -1)
if [ -z "$last_octet" ]; then
echo "10.8.0.2"
else
echo "10.8.0.$((last_octet + 1))"
fi
}
get_server_public_key() {
if [ ! -f "${WG_CONFIG_DIR}/server_public.key" ]; then
print_error "Server public key not found at ${WG_CONFIG_DIR}/server_public.key"
print_info "Run the Ansible playbook first: ansible-playbook setup-wireguard-host.yml"
exit 1
fi
cat "${WG_CONFIG_DIR}/server_public.key"
}
get_server_endpoint() {
# Try to detect public IP
local public_ip
public_ip=$(curl -s -4 ifconfig.me 2>/dev/null || curl -s -4 icanhazip.com 2>/dev/null || echo "YOUR_SERVER_IP")
echo "${public_ip}:${WG_PORT}"
}
# ========================================
# Main Script
# ========================================
main() {
print_info "WireGuard Client Configuration Generator"
echo ""
# Validate input
if [ $# -ne 1 ]; then
print_error "Usage: $0 <client-name>"
echo ""
echo "Example:"
echo " $0 michael-laptop"
echo " $0 iphone"
echo " $0 office-desktop"
exit 1
fi
local client_name="$1"
# Validate client name (alphanumeric + dash/underscore only)
if ! [[ "$client_name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
print_error "Client name must contain only alphanumeric characters, dashes, and underscores"
exit 1
fi
# Pre-flight checks
check_root
check_dependencies
# Create client config directory
mkdir -p "$CLIENT_CONFIG_DIR"
# Check if client already exists
if [ -f "${CLIENT_CONFIG_DIR}/${client_name}.conf" ]; then
print_warning "Client config for '${client_name}' already exists"
read -p "Regenerate? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_info "Aborted"
exit 0
fi
fi
print_info "Generating configuration for client: ${client_name}"
echo ""
# Generate client keys
print_info "Generating client keys..."
local client_private_key=$(wg genkey)
local client_public_key=$(echo "$client_private_key" | wg pubkey)
local client_preshared_key=$(wg genpsk)
# Get server information
print_info "Reading server configuration..."
local server_public_key=$(get_server_public_key)
local server_endpoint=$(get_server_endpoint)
# Assign client IP
local client_ip=$(get_next_client_ip)
print_info "Assigned client IP: ${client_ip}"
# Create client config file
print_info "Creating client configuration file..."
cat > "${CLIENT_CONFIG_DIR}/${client_name}.conf" <<EOF
[Interface]
# Client: ${client_name}
# Generated: $(date)
PrivateKey = ${client_private_key}
Address = ${client_ip}/32
# DNS: Use Cloudflare/Quad9 (change if needed)
DNS = 1.1.1.1, 9.9.9.9
[Peer]
# Server
PublicKey = ${server_public_key}
PresharedKey = ${client_preshared_key}
Endpoint = ${server_endpoint}
# Route only VPN network through tunnel (split-tunnel)
AllowedIPs = ${WG_NETWORK}
# Keep connection alive (NAT traversal)
PersistentKeepalive = 25
EOF
chmod 600 "${CLIENT_CONFIG_DIR}/${client_name}.conf"
# Add peer to server configuration
print_info "Adding peer to server configuration..."
# Check if peer already exists in server config
if grep -q "# ${client_name}" "$WG_SERVER_CONFIG" 2>/dev/null; then
print_info "Removing old peer entry..."
# Remove old peer entry (from comment to next empty line or end of file)
sed -i "/# ${client_name}/,/^$/d" "$WG_SERVER_CONFIG"
fi
# Append new peer
cat >> "$WG_SERVER_CONFIG" <<EOF
[Peer]
# ${client_name}
PublicKey = ${client_public_key}
PresharedKey = ${client_preshared_key}
AllowedIPs = ${client_ip}/32
PersistentKeepalive = 25
EOF
# Reload WireGuard to apply changes
print_info "Reloading WireGuard configuration..."
systemctl reload wg-quick@${WG_INTERFACE}
# Verify peer is active
sleep 1
if wg show ${WG_INTERFACE} | grep -q "$client_public_key"; then
print_success "Peer successfully added to server"
else
print_warning "Peer added to config but not yet active (will activate when client connects)"
fi
# Generate QR code for mobile devices
print_info "Generating QR code for mobile import..."
qrencode -t ansiutf8 < "${CLIENT_CONFIG_DIR}/${client_name}.conf" > "${CLIENT_CONFIG_DIR}/${client_name}.qr.txt"
qrencode -t png -o "${CLIENT_CONFIG_DIR}/${client_name}.qr.png" < "${CLIENT_CONFIG_DIR}/${client_name}.conf"
# Display success summary
echo ""
print_success "=========================================="
print_success "Client Configuration Created!"
print_success "=========================================="
echo ""
echo "Client Name: ${client_name}"
echo "Client IP: ${client_ip}"
echo "Config File: ${CLIENT_CONFIG_DIR}/${client_name}.conf"
echo "QR Code (text): ${CLIENT_CONFIG_DIR}/${client_name}.qr.txt"
echo "QR Code (PNG): ${CLIENT_CONFIG_DIR}/${client_name}.qr.png"
echo ""
echo "Server Endpoint: ${server_endpoint}"
echo "VPN Network: ${WG_NETWORK}"
echo ""
print_info "=========================================="
print_info "Import Instructions:"
print_info "=========================================="
echo ""
echo "Desktop (Linux/macOS):"
echo " sudo cp ${CLIENT_CONFIG_DIR}/${client_name}.conf /etc/wireguard/"
echo " sudo wg-quick up ${client_name}"
echo ""
echo "Desktop (Windows):"
echo " 1. Open WireGuard GUI"
echo " 2. Click 'Import tunnel(s) from file'"
echo " 3. Select: ${CLIENT_CONFIG_DIR}/${client_name}.conf"
echo ""
echo "Mobile (iOS/Android):"
echo " 1. Open WireGuard app"
echo " 2. Tap '+' > 'Create from QR code'"
echo " 3. Scan QR code below or from: ${CLIENT_CONFIG_DIR}/${client_name}.qr.png"
echo ""
print_info "QR Code (scan with phone):"
echo ""
cat "${CLIENT_CONFIG_DIR}/${client_name}.qr.txt"
echo ""
print_info "=========================================="
print_info "Verify Connection:"
print_info "=========================================="
echo ""
echo "After connecting:"
echo " ping ${WG_SERVER_IP}"
echo " curl -k https://${WG_SERVER_IP}:8080 # Traefik Dashboard"
echo ""
print_success "Configuration complete! Client is ready to connect."
}
# Run main function
main "$@"

View File

@@ -0,0 +1,307 @@
#!/bin/bash
# Manual WireGuard Setup Script
# Purpose: Step-by-step WireGuard installation and configuration
# This script shows what needs to be done - review before executing!
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
print_step() {
echo -e "${BLUE}[STEP]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# ========================================
# Configuration
# ========================================
WG_INTERFACE="wg0"
WG_NETWORK="10.8.0.0/24"
WG_SERVER_IP="10.8.0.1"
WG_PORT="51820"
WG_CONFIG_DIR="/etc/wireguard"
WAN_INTERFACE="eth0" # ANPASSEN an dein System!
# ========================================
# Pre-flight Checks
# ========================================
print_step "Pre-flight Checks"
if [ "$EUID" -ne 0 ]; then
print_error "This script must be run as root"
exit 1
fi
# Check if WireGuard is installed
if ! command -v wg &> /dev/null; then
print_error "WireGuard is not installed"
echo "Install with: apt update && apt install -y wireguard wireguard-tools qrencode nftables"
exit 1
fi
print_success "Pre-flight checks passed"
# ========================================
# Step 1: Create WireGuard Directory
# ========================================
print_step "Creating WireGuard directory"
mkdir -p ${WG_CONFIG_DIR}
chmod 700 ${WG_CONFIG_DIR}
print_success "Directory created: ${WG_CONFIG_DIR}"
# ========================================
# Step 2: Generate Server Keys
# ========================================
print_step "Generating server keys"
cd ${WG_CONFIG_DIR}
if [ ! -f server_private.key ]; then
wg genkey | tee server_private.key | wg pubkey > server_public.key
chmod 600 server_private.key
chmod 644 server_public.key
print_success "Server keys generated"
else
print_warning "Server keys already exist - skipping generation"
fi
SERVER_PRIVATE_KEY=$(cat server_private.key)
SERVER_PUBLIC_KEY=$(cat server_public.key)
echo ""
echo "Server Public Key: ${SERVER_PUBLIC_KEY}"
echo ""
# ========================================
# Step 3: Create WireGuard Configuration
# ========================================
print_step "Creating WireGuard configuration"
cat > ${WG_CONFIG_DIR}/${WG_INTERFACE}.conf <<EOF
[Interface]
# Server Configuration
PrivateKey = ${SERVER_PRIVATE_KEY}
Address = ${WG_SERVER_IP}/24
ListenPort = ${WG_PORT}
# Enable IP forwarding
PostUp = sysctl -w net.ipv4.ip_forward=1
# NAT Configuration with nftables
PostUp = nft add table inet wireguard
PostUp = nft add chain inet wireguard postrouting { type nat hook postrouting priority srcnat\; }
PostUp = nft add rule inet wireguard postrouting oifname "${WAN_INTERFACE}" ip saddr ${WG_NETWORK} masquerade
# Cleanup on shutdown
PostDown = nft delete table inet wireguard
# Peers will be added here via generate-client-config.sh
EOF
chmod 600 ${WG_CONFIG_DIR}/${WG_INTERFACE}.conf
print_success "Configuration created: ${WG_CONFIG_DIR}/${WG_INTERFACE}.conf"
# ========================================
# Step 4: Create nftables Firewall Rules
# ========================================
print_step "Creating nftables firewall rules"
cat > /etc/nftables.d/wireguard.nft <<'EOF'
#!/usr/sbin/nft -f
# WireGuard Host-based Firewall Configuration
# Purpose: Secure VPN access with admin service protection
table inet wireguard_firewall {
# Define sets for efficient rule matching
set vpn_network {
type ipv4_addr
flags interval
elements = { 10.8.0.0/24 }
}
set admin_service_ports {
type inet_service
elements = {
8080, # Traefik Dashboard
9090, # Prometheus
3001, # Grafana
9000, # Portainer
8001, # Redis Insight
}
}
set public_service_ports {
type inet_service
elements = {
80, # HTTP
443, # HTTPS
22, # SSH
}
}
# Input chain - Control incoming connections
chain input {
type filter hook input priority filter; policy drop;
# Allow established/related connections
ct state established,related accept
# Allow loopback
iif lo accept
# Allow ICMP (ping)
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
# Allow WireGuard port
udp dport 51820 accept
# Allow VPN network to access admin services
ip saddr @vpn_network tcp dport @admin_service_ports accept
# Allow public access to public services
tcp dport @public_service_ports accept
# Block public access to admin services (with logging)
tcp dport @admin_service_ports counter log prefix "BLOCKED_ADMIN_SERVICE: " drop
# Rate limit SSH to prevent brute force
tcp dport 22 ct state new limit rate 10/minute accept
# Drop everything else
counter log prefix "BLOCKED_INPUT: " drop
}
# Forward chain - Control packet forwarding
chain forward {
type filter hook forward priority filter; policy drop;
# Allow established/related connections
ct state established,related accept
# Allow VPN network to forward
ip saddr @vpn_network accept
# Drop everything else
counter log prefix "BLOCKED_FORWARD: " drop
}
# Output chain - Allow all outgoing by default
chain output {
type filter hook output priority filter; policy accept;
}
}
EOF
chmod 755 /etc/nftables.d/wireguard.nft
print_success "Firewall rules created: /etc/nftables.d/wireguard.nft"
# ========================================
# Step 5: Enable IP Forwarding
# ========================================
print_step "Enabling IP forwarding"
echo "net.ipv4.ip_forward=1" > /etc/sysctl.d/99-wireguard.conf
sysctl -p /etc/sysctl.d/99-wireguard.conf
print_success "IP forwarding enabled"
# ========================================
# Step 6: Apply nftables Rules
# ========================================
print_step "Applying nftables firewall rules"
if [ -f /etc/nftables.d/wireguard.nft ]; then
nft -f /etc/nftables.d/wireguard.nft
print_success "Firewall rules applied"
else
print_error "Firewall rules file not found"
exit 1
fi
# ========================================
# Step 7: Enable and Start WireGuard
# ========================================
print_step "Enabling and starting WireGuard service"
systemctl enable wg-quick@${WG_INTERFACE}
systemctl start wg-quick@${WG_INTERFACE}
print_success "WireGuard service enabled and started"
# ========================================
# Step 8: Verify Installation
# ========================================
print_step "Verifying installation"
echo ""
echo "WireGuard Status:"
wg show ${WG_INTERFACE}
echo ""
echo "Service Status:"
systemctl status wg-quick@${WG_INTERFACE} --no-pager
echo ""
echo "nftables Rules:"
nft list table inet wireguard_firewall
# ========================================
# Summary
# ========================================
echo ""
print_success "=========================================="
print_success "WireGuard Installation Complete!"
print_success "=========================================="
echo ""
echo "Server IP: ${WG_SERVER_IP}"
echo "Listen Port: ${WG_PORT}"
echo "VPN Network: ${WG_NETWORK}"
echo "Interface: ${WG_INTERFACE}"
echo ""
print_step "Next Steps:"
echo " 1. Generate client configs:"
echo " cd /home/michael/dev/michaelschiemer/deployment/scripts"
echo " sudo ./generate-client-config.sh <client-name>"
echo ""
echo " 2. Import client config on your device"
echo ""
echo " 3. Connect and test access to admin services:"
echo " - Traefik Dashboard: https://10.8.0.1:8080"
echo " - Prometheus: http://10.8.0.1:9090"
echo " - Grafana: https://10.8.0.1:3001"
echo " - Portainer: http://10.8.0.1:9000"
echo " - Redis Insight: http://10.8.0.1:8001"
echo ""

View File

@@ -1,14 +0,0 @@
services:
coredns:
image: coredns/coredns:1.11.1
container_name: coredns
restart: unless-stopped
network_mode: host
command: -conf /etc/coredns/Corefile
volumes:
- ./Corefile:/etc/coredns/Corefile:ro
healthcheck:
# Disable healthcheck - CoreDNS is a minimal image without shell
# CoreDNS runs fine (verified by DNS queries working correctly)
# If needed, health can be checked externally via dig
disable: true

View File

@@ -86,6 +86,39 @@ docker compose ps
### First Time Configuration
**Option 1: Automated Setup (Recommended)**
The Gitea initial setup can be automated using Ansible:
```bash
cd deployment/ansible
# 1. Set Gitea admin credentials in vault
ansible-vault edit secrets/production.vault.yml --vault-password-file secrets/.vault_pass
# Add these variables:
# vault_gitea_admin_username: "admin"
# vault_gitea_admin_password: "your-secure-password"
# vault_gitea_admin_email: "kontakt@michaelschiemer.de"
# 2. Run the setup playbook
ansible-playbook -i inventory/production.yml \
playbooks/setup-gitea-initial-config.yml \
--vault-password-file secrets/.vault_pass
```
The playbook will:
- Check if Gitea is already configured
- Generate `app.ini` with `INSTALL_LOCK = true` to skip the initial setup page
- Copy the configuration file to the Gitea container
- Create admin user via Gitea CLI with credentials from vault
- Use database settings from environment variables
**How it works:**
The playbook creates a complete `app.ini` configuration file with `INSTALL_LOCK = true` in the `[security]` section. This tells Gitea to skip the initial setup wizard. The admin user is then created using the `gitea admin user create` command.
**Option 2: Manual Setup**
1. **Access Gitea**: https://git.michaelschiemer.de
2. **Initial Setup Wizard**:
@@ -107,6 +140,28 @@ docker compose ps
### Creating a Repository
**Option 1: Automated Setup (Recommended)**
Use Ansible to automatically create the repository and configure Git remote:
```bash
cd deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/setup-gitea-repository.yml \
--vault-password-file secrets/.vault_pass \
-e "repo_name=michaelschiemer" \
-e "repo_owner=michael" \
-e "repo_private=false"
```
The playbook will:
- Create repository in Gitea via API
- Configure Git remote automatically
- Use credentials from Ansible Vault
**Option 2: Manual Setup**
1. Log in to https://git.michaelschiemer.de
2. Click "+" → "New Repository"
3. Fill in repository details
@@ -356,17 +411,54 @@ docker compose logs redis
### Performance Issues
```bash
# Check MySQL slow queries
docker exec gitea-mysql tail -100 /var/log/mysql/slow-queries.log
**If Gitea has frequent outages or connection issues:**
# Analyze MySQL performance
docker exec gitea-mysql mysql -u root -p$MYSQL_ROOT_PASSWORD \
-e "SHOW PROCESSLIST;"
1. **Update Gitea Configuration** (Recommended):
```bash
cd deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/update-gitea-config.yml \
--vault-password-file secrets/.vault_pass
```
# Check Redis memory usage
docker exec gitea-redis redis-cli -a $REDIS_PASSWORD INFO memory
```
This playbook will:
- Enable Redis cache for better performance and persistence
- Configure database connection pooling
- Set connection limits to prevent "Connection reset by peer" errors
2. **Manual Troubleshooting**:
```bash
# Check PostgreSQL slow queries
docker exec gitea-postgres psql -U gitea -d gitea -c "SELECT * FROM pg_stat_activity;"
# Check container resource usage
docker stats gitea gitea-postgres gitea-redis
# Check Gitea logs for errors
docker compose logs --tail 100 gitea | grep -i error
# Check Redis connection
docker exec gitea-redis redis-cli -a $REDIS_PASSWORD ping
```
### Known Issues
**Bad Gateway after many rapid requests (15-20 reloads):**
- **Status**: Known issue, non-critical
- **Symptoms**: Gitea returns "Bad Gateway" after 15-20 rapid page reloads, recovers after a few seconds
- **Impact**: Low - Gitea is functional for normal usage
- **Possible causes**:
- Container restart during high load
- Connection pool exhaustion (mitigated with increased limits)
- Traefik service discovery delay in host network mode
- **Workarounds**:
- Wait a few seconds and retry
- Use Redis cache (already enabled) for better performance
- Consider adding rate limiting if needed (see Traefik middlewares)
- **Future improvements**:
- Monitor and optimize connection pool usage
- Consider adding rate limiting middleware for Gitea
- Investigate Traefik service discovery in host network mode
### Reset Admin Password

View File

@@ -5,6 +5,7 @@ services:
restart: unless-stopped
depends_on:
- postgres
- redis
networks:
- traefik-public
- gitea-internal
@@ -18,10 +19,14 @@ services:
- GITEA__database__NAME=${POSTGRES_DB:-gitea}
- GITEA__database__USER=${POSTGRES_USER:-gitea}
- GITEA__database__PASSWD=${POSTGRES_PASSWORD:-gitea_password}
- GITEA__cache__ENABLED=false
- GITEA__cache__ADAPTER=memory
- GITEA__session__PROVIDER=file
- GITEA__queue__TYPE=channel
- GITEA__cache__ENABLED=true
- GITEA__cache__ADAPTER=redis
- GITEA__cache__HOST=redis:6379
- GITEA__cache__PASSWORD=${REDIS_PASSWORD:-gitea_redis_password}
- GITEA__session__PROVIDER=redis
- GITEA__session__PROVIDER_CONFIG=network=tcp,addr=redis:6379,password=${REDIS_PASSWORD:-gitea_redis_password},db=0,pool_size=100,idle_timeout=180
- GITEA__queue__TYPE=redis
- GITEA__queue__CONN_STR=redis://:${REDIS_PASSWORD:-gitea_redis_password}@redis:6379/0
- GITEA__server__DOMAIN=${GITEA_DOMAIN:-git.michaelschiemer.de}
- GITEA__server__ROOT_URL=https://${GITEA_DOMAIN:-git.michaelschiemer.de}/
- GITEA__server__SSH_DOMAIN=${GITEA_DOMAIN:-git.michaelschiemer.de}
@@ -32,8 +37,6 @@ services:
- gitea-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "2222:22" # SSH for Git operations
labels:
- "traefik.enable=true"
@@ -47,6 +50,8 @@ services:
# Service
- "traefik.http.services.gitea.loadbalancer.server.port=3000"
# Use container name explicitly for host network mode
- "traefik.http.services.gitea.loadbalancer.server.scheme=http"
# Middleware
- "traefik.http.routers.gitea.middlewares=default-chain@file"
@@ -68,6 +73,7 @@ services:
- POSTGRES_DB=gitea
- POSTGRES_USER=gitea
- POSTGRES_PASSWORD=gitea_password
command: postgres -c max_connections=300
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
@@ -77,32 +83,36 @@ services:
retries: 3
start_period: 30s
# redis (disabled for now; Gitea configured to not use redis)
# redis:
# image: redis:7
# container_name: gitea-redis
# restart: unless-stopped
# networks:
# - gitea-internal
# environment:
# - TZ=Europe/Berlin
# volumes:
# - redis-data:/data
# command: redis-server --appendonly yes
# healthcheck:
# test: ["CMD", "redis-cli", "ping"]
# interval: 30s
# timeout: 10s
# retries: 3
# start_period: 10s
redis:
image: redis:7-alpine
container_name: gitea-redis
restart: unless-stopped
networks:
- gitea-internal
environment:
- TZ=Europe/Berlin
command: >
redis-server
--requirepass ${REDIS_PASSWORD:-gitea_redis_password}
--appendonly yes
--maxmemory 512mb
--maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
gitea-data:
name: gitea-data
postgres-data:
name: gitea-postgres-data
# redis-data:
# name: gitea-redis-data
redis-data:
name: gitea-redis-data
networks:
traefik-public:

View File

@@ -1,141 +0,0 @@
services:
portainer:
image: portainer/portainer-ce:latest
container_name: portainer
restart: unless-stopped
# DIRECT ACCESS: Bind only to VPN gateway IP
ports:
- "10.8.0.1:9002:9000" # Port 9002 to avoid conflict with MinIO (port 9000)
networks:
- monitoring-internal
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- portainer-data:/data
# Removed Traefik labels - direct access only
prometheus:
image: prom/prometheus:latest
container_name: prometheus
restart: unless-stopped
user: "65534:65534"
# DIRECT ACCESS: Bind only to VPN gateway IP
ports:
- "10.8.0.1:9090:9090"
networks:
- monitoring-internal
- app-internal
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
- prometheus-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=30d'
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
- '--web.console.templates=/usr/share/prometheus/consoles'
- '--web.enable-lifecycle'
# Removed Traefik labels - direct access only
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/-/healthy"]
interval: 30s
timeout: 10s
retries: 3
grafana:
image: grafana/grafana:latest
container_name: grafana
restart: unless-stopped
# DIRECT ACCESS: Bind only to VPN gateway IP
ports:
- "10.8.0.1:3001:3000"
networks:
- monitoring-internal
- app-internal
environment:
# Updated root URL for direct IP access
- GF_SERVER_ROOT_URL=http://10.8.0.1:3001
- GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER}
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
- GF_USERS_ALLOW_SIGN_UP=false
- GF_INSTALL_PLUGINS=${GRAFANA_PLUGINS}
- GF_LOG_LEVEL=info
- GF_ANALYTICS_REPORTING_ENABLED=false
# Performance: Disable external connections to grafana.com
- GF_PLUGIN_GRAFANA_COM_URL=
- GF_CHECK_FOR_UPDATES=false
- GF_CHECK_FOR_PLUGIN_UPDATES=false
# Disable background plugin installer completely
- GF_FEATURE_TOGGLES_ENABLE=disablePluginInstaller
- GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS=
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/grafana.ini:/etc/grafana/grafana.ini:ro
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
# Removed Traefik labels - direct access only
depends_on:
prometheus:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
node-exporter:
image: prom/node-exporter:latest
container_name: node-exporter
restart: unless-stopped
networks:
- app-internal
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9100/metrics"]
interval: 30s
timeout: 10s
retries: 3
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
container_name: cadvisor
restart: unless-stopped
privileged: true
networks:
- app-internal
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
devices:
- /dev/kmsg
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
volumes:
portainer-data:
name: portainer-data
prometheus-data:
name: prometheus-data
grafana-data:
name: grafana-data
networks:
# New internal network for monitoring services
monitoring-internal:
name: monitoring-internal
driver: bridge
app-internal:
external: true

View File

@@ -41,7 +41,7 @@ services:
- "traefik.http.routers.prometheus.entrypoints=websecure"
- "traefik.http.routers.prometheus.tls=true"
- "traefik.http.routers.prometheus.tls.certresolver=letsencrypt"
- "traefik.http.routers.prometheus.middlewares=prometheus-auth"
- "traefik.http.routers.prometheus.middlewares=prometheus-auth@docker"
- "traefik.http.middlewares.prometheus-auth.basicauth.users=${PROMETHEUS_AUTH}"
- "traefik.http.services.prometheus.loadbalancer.server.port=9090"
healthcheck:
@@ -75,9 +75,6 @@ services:
- "traefik.http.routers.grafana.entrypoints=websecure"
- "traefik.http.routers.grafana.tls=true"
- "traefik.http.routers.grafana.tls.certresolver=letsencrypt"
# VPN IP whitelist: Use middleware defined in Traefik dynamic config
# Middleware is defined in deployment/stacks/traefik/dynamic/middlewares.yml
- "traefik.http.routers.grafana.middlewares=grafana-vpn-only@file"
- "traefik.http.services.grafana.loadbalancer.server.port=3000"
depends_on:
prometheus:

View File

@@ -20,8 +20,8 @@ services:
# For local development, we only use HTTP (no HTTPS needed)
# Note: 8443:443 is used by the web container
ports:
- "8080:80" # HTTP on port 80 (mapped to host port 8080)
- "8080:8080" # Traefik API entrypoint (for api.insecure=true dashboard)
- "8081:80" # HTTP on port 80 (mapped to host port 8081)
- "8093:8080" # Traefik API entrypoint (for api.insecure=true dashboard)
environment:
- TZ=Europe/Berlin
volumes:

View File

@@ -0,0 +1,20 @@
# Gitea Service Configuration
# Service definition with proper timeouts for slow responses
http:
services:
gitea:
loadBalancer:
servers:
- url: http://gitea:3000
# Health check configuration
healthCheck:
path: /api/healthz
interval: 30s
timeout: 10s
scheme: http
# Response forwarding - flush immediately to stream responses
responseForwarding:
flushInterval: 0s
# Pass host header to backend
passHostHeader: true

View File

@@ -14,7 +14,7 @@ tcp:
loadBalancer:
servers:
# Gitea container SSH service
# Note: Using container IP from traefik-public network
# Note: Using container IP from gitea_gitea-internal network
# Traefik runs in host network mode, so we need the actual container IP
# IP address: 172.23.0.2 (traefik-public network)
- address: "172.23.0.2:22"
# IP address: 172.23.0.3 (gitea_gitea-internal network)
- address: "172.23.0.3:22"

View File

@@ -1,15 +1,20 @@
http:
routers:
gitea:
rule: Host(`git.michaelschiemer.de`)
entrypoints:
- websecure
service: gitea
tls:
certResolver: letsencrypt
priority: 100
services:
gitea:
loadBalancer:
servers:
- url: http://gitea:3000
# Gitea configuration is now handled via Docker labels in docker-compose.yml
# This file is kept for reference but is not used
# Traefik will automatically discover Gitea via Docker labels and use the container IP
# when running in host network mode
#
# http:
# routers:
# gitea:
# rule: Host(`git.michaelschiemer.de`)
# entrypoints:
# - websecure
# service: gitea
# tls:
# certResolver: letsencrypt
# priority: 100
# services:
# gitea:
# loadBalancer:
# servers:
# - url: http://gitea:3000

View File

@@ -52,25 +52,6 @@ http:
# - "127.0.0.1/32"
# - "10.0.0.0/8"
# VPN-only IP allowlist for Grafana and other monitoring services
# Restrict access strictly to the WireGuard network
# Note: ipAllowList checks the real client IP from the connection
# When connected via VPN, client IP should be from 10.8.0.0/24
# If client IP shows public IP, the traffic is NOT going through VPN
# TEMPORARY: Added public IP for testing - REMOVE after fixing VPN routing!
grafana-vpn-only:
ipAllowList:
sourceRange:
- "10.8.0.0/24" # WireGuard VPN network (10.8.0.1 = server, 10.8.0.x = clients)
- "89.246.96.244/32" # TEMPORARY: Public IP for testing - REMOVE after VPN routing is fixed!
# VPN-only IP allowlist for general use (Traefik Dashboard, etc.)
# Restrict access strictly to the WireGuard network
vpn-only:
ipAllowList:
sourceRange:
- "10.8.0.0/24" # WireGuard VPN network
# Chain multiple middlewares
default-chain:
chain:

View File

@@ -64,10 +64,8 @@ providers:
# Forwarded Headers Configuration
# This ensures Traefik correctly identifies the real client IP
# Important for VPN access where requests come from WireGuard interface
forwardedHeaders:
trustedIPs:
- "10.8.0.0/24" # WireGuard VPN network
- "127.0.0.1/32" # Localhost
- "172.17.0.0/16" # Docker bridge network
- "172.18.0.0/16" # Docker user-defined networks

View File

@@ -0,0 +1,22 @@
# WireGuard VPN Configuration
# Server endpoint (auto-detected or set manually)
SERVERURL=auto
# WireGuard port
SERVERPORT=51820
# VPN network subnet
INTERNAL_SUBNET=10.8.0.0/24
# Allowed IPs (VPN network only - no split tunneling)
ALLOWEDIPS=10.8.0.0/24
# DNS configuration (use host DNS)
PEERDNS=auto
# Timezone
TZ=Europe/Berlin
# Peers (managed manually)
PEERS=0

View File

@@ -0,0 +1,49 @@
services:
wireguard:
image: linuxserver/wireguard:1.0.20210914
container_name: wireguard
restart: unless-stopped
cap_add:
- NET_ADMIN
- SYS_MODULE
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Berlin
- SERVERURL=auto
- SERVERPORT=51820
- PEERS=0 # Managed manually via config files
- PEERDNS=auto # Use host DNS
- INTERNAL_SUBNET=10.8.0.0/24
- ALLOWEDIPS=10.8.0.0/24 # VPN network only
- LOG_CONFS=true
volumes:
- ./config:/config
- /lib/modules:/lib/modules:ro
ports:
- "51820:51820/udp"
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
healthcheck:
test: ["CMD", "bash", "-c", "wg show wg0 | grep -q interface"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
default:
name: wireguard-net
driver: bridge

View File

@@ -0,0 +1,370 @@
# WireGuard Client Import & Connection Guide
Anleitung zum Importieren und Verbinden der generierten WireGuard Client-Konfiguration.
## Generierte Konfiguration
**Client Name**: michael-pc
**Config File**: `/home/michael/dev/michaelschiemer/deployment/ansible/wireguard/configs/michael-pc.conf`
**Client IP**: 10.8.0.2/32
**Server Endpoint**: 94.16.110.151:51820
**VPN Network**: 10.8.0.0/24
---
## Import auf verschiedenen Plattformen
### Linux (Ubuntu/Debian)
```bash
# 1. Konfiguration nach /etc/wireguard/ kopieren
sudo cp /home/michael/dev/michaelschiemer/deployment/ansible/wireguard/configs/michael-pc.conf /etc/wireguard/
# 2. Berechtigungen setzen
sudo chmod 600 /etc/wireguard/michael-pc.conf
# 3. VPN-Verbindung starten
sudo wg-quick up michael-pc
# 4. Status prüfen
sudo wg show michael-pc
# 5. Bei Boot automatisch starten (optional)
sudo systemctl enable wg-quick@michael-pc
```
**Verbindung trennen**:
```bash
sudo wg-quick down michael-pc
```
---
### macOS
```bash
# 1. WireGuard installieren (falls nicht vorhanden)
brew install wireguard-tools
# 2. Konfiguration importieren
sudo cp /home/michael/dev/michaelschiemer/deployment/ansible/wireguard/configs/michael-pc.conf /etc/wireguard/
# 3. VPN starten
sudo wg-quick up michael-pc
# 4. Status prüfen
sudo wg show michael-pc
```
**Alternative**: WireGuard GUI App für macOS verwenden
- Download: https://apps.apple.com/app/wireguard/id1451685025
- "Add Tunnel from File" → `michael-pc.conf` auswählen
- Verbindung aktivieren
---
### Windows
**Via WireGuard GUI** (empfohlen):
1. **WireGuard GUI installieren**:
- Download: https://www.wireguard.com/install/
- Installer ausführen
2. **Konfiguration importieren**:
- WireGuard GUI öffnen
- "Import tunnel(s) from file"
- `michael-pc.conf` auswählen
3. **Verbindung aktivieren**:
- Tunnel "michael-pc" in der Liste anklicken
- "Activate" Button drücken
4. **Status prüfen**:
- Status sollte "Active" zeigen
- Transfer-Statistiken werden angezeigt
---
### Android
**Via WireGuard App**:
1. **WireGuard App installieren**:
- Google Play Store: "WireGuard"
2. **Konfiguration importieren**:
- Option 1: `michael-pc.conf` auf Gerät übertragen und importieren
- Option 2: QR Code scannen (falls generiert)
3. **Verbindung aktivieren**:
- Tunnel antippen
- Toggle aktivieren
---
### iOS
**Via WireGuard App**:
1. **WireGuard App installieren**:
- App Store: "WireGuard"
2. **Konfiguration importieren**:
- Option 1: `michael-pc.conf` via AirDrop/iCloud übertragen
- Option 2: QR Code scannen (falls generiert)
3. **Verbindung aktivieren**:
- Tunnel antippen
- Toggle aktivieren
---
## Konnektivitätstest
Nach erfolgreicher Verbindung:
### 1. VPN Gateway Ping
```bash
ping 10.8.0.1
```
**Erwartete Ausgabe**:
```
PING 10.8.0.1 (10.8.0.1) 56(84) bytes of data.
64 bytes from 10.8.0.1: icmp_seq=1 ttl=64 time=1.23 ms
64 bytes from 10.8.0.1: icmp_seq=2 ttl=64 time=1.15 ms
```
**Erfolg**: VPN-Verbindung funktioniert
---
### 2. Admin Services Zugriff
**Traefik Dashboard** (HTTPS):
```bash
curl -k https://10.8.0.1:8080
```
**Prometheus** (HTTP):
```bash
curl http://10.8.0.1:9090
```
**Grafana** (HTTPS):
```bash
curl -k https://10.8.0.1:3001
```
**Portainer** (HTTP):
```bash
curl http://10.8.0.1:9000
```
**Redis Insight** (HTTP):
```bash
curl http://10.8.0.1:8001
```
**Browser-Zugriff**:
- Traefik: https://10.8.0.1:8080
- Prometheus: http://10.8.0.1:9090
- Grafana: https://10.8.0.1:3001
- Portainer: http://10.8.0.1:9000
- Redis Insight: http://10.8.0.1:8001
---
## Troubleshooting
### Problem: Keine Verbindung zum Server
**Symptome**:
- `ping 10.8.0.1` timeout
- WireGuard Status zeigt "Handshake failed"
**Lösungen**:
1. **Server Endpoint prüfen**:
```bash
# Prüfe ob Server erreichbar ist
ping 94.16.110.151
# Prüfe ob Port 51820 offen ist
nc -zvu 94.16.110.151 51820
```
2. **Firewall auf Server prüfen**:
```bash
# Auf Server ausführen
sudo nft list ruleset | grep 51820
```
3. **WireGuard Server Status prüfen**:
```bash
# Auf Server ausführen
sudo systemctl status wg-quick@wg0
sudo wg show wg0
```
---
### Problem: VPN verbindet, aber kein Zugriff auf Admin Services
**Symptome**:
- `ping 10.8.0.1` funktioniert
- `curl http://10.8.0.1:9090` timeout
**Lösungen**:
1. **Routing prüfen**:
```bash
# Auf Client
ip route | grep 10.8.0
```
2. **Firewall-Rules auf Server prüfen**:
```bash
# Auf Server
sudo nft list table inet wireguard_firewall
```
3. **Service-Status prüfen**:
```bash
# Auf Server - Services sollten laufen
docker ps | grep prometheus
docker ps | grep grafana
```
---
### Problem: DNS funktioniert nicht
**Symptome**:
- Kann keine Domains auflösen
**Lösung**:
```bash
# DNS-Server in Client-Config prüfen
grep DNS /etc/wireguard/michael-pc.conf
# Sollte sein: DNS = 1.1.1.1, 8.8.8.8
# DNS-Resolver testen
nslookup google.com 1.1.1.1
```
---
### Problem: Verbindung bricht ständig ab
**Symptome**:
- Verbindung disconnected nach einigen Minuten
**Lösungen**:
1. **PersistentKeepalive prüfen**:
```bash
grep PersistentKeepalive /etc/wireguard/michael-pc.conf
# Sollte sein: PersistentKeepalive = 25
```
2. **NAT/Router-Timeout**:
- PersistentKeepalive verhindert NAT-Timeout
- Wert auf 25 Sekunden gesetzt
---
## Firewall-Validierung
### Public Access sollte blockiert sein
**Von außerhalb des VPNs testen** (z.B. vom Internet):
```bash
# Diese Requests sollten FEHLSCHLAGEN (timeout oder connection refused):
curl --max-time 5 http://94.16.110.151:9090 # Prometheus
curl --max-time 5 http://94.16.110.151:8080 # Traefik Dashboard
curl --max-time 5 http://94.16.110.151:9000 # Portainer
# Nur Public Services sollten erreichbar sein:
curl http://94.16.110.151:80 # HTTP (funktioniert)
curl https://94.16.110.151:443 # HTTPS (funktioniert)
```
**Erwartetes Ergebnis**:
- ❌ Admin-Ports (8080, 9090, 3001, 9000, 8001): Timeout oder Connection Refused
- ✅ Public-Ports (80, 443): Erreichbar
### Firewall-Logs prüfen
**Auf Server**:
```bash
# Geblockte Zugriffe auf Admin-Services loggen
sudo journalctl -k | grep "BLOCKED_ADMIN_SERVICE"
# Beispiel-Ausgabe:
# [ 123.456] BLOCKED_ADMIN_SERVICE: IN=eth0 OUT= SRC=203.0.113.42 DST=94.16.110.151 PROTO=TCP DPT=8080
```
---
## Sicherheitshinweise
### ✅ Best Practices
1. **Private Keys schützen**:
- Niemals Private Keys committen oder teilen
- Berechtigungen: `chmod 600` für .conf Dateien
2. **Regelmäßige Key-Rotation**:
- Empfohlen: Jährlich neue Keys generieren
- Bei Kompromittierung: Sofort neue Keys erstellen
3. **Client-Zugriff widerrufen**:
```bash
# Auf Server: Peer aus Konfiguration entfernen
sudo nano /etc/wireguard/wg0.conf
# [Peer]-Block für michael-pc entfernen
# WireGuard neu laden
sudo wg syncconf wg0 <(wg-quick strip wg0)
```
4. **VPN-Monitoring**:
```bash
# Aktive Verbindungen prüfen
sudo wg show wg0
# Letzte Handshake-Zeit prüfen
sudo wg show wg0 latest-handshakes
```
---
## Nächste Schritte
Nach erfolgreicher VPN-Verbindung:
1. ✅ **VPN-Zugriff verifizieren**: Gateway ping + Admin Services Zugriff
2. ✅ **Firewall-Rules validieren**: Public Access blockiert, VPN Access erlaubt
3. ⏭️ **Weitere Clients hinzufügen** (optional):
```bash
ansible-playbook playbooks/generate-wireguard-client.yml -e "client_name=laptop"
ansible-playbook playbooks/generate-wireguard-client.yml -e "client_name=phone"
```
4. ⏭️ **Backup der Client-Configs**:
```bash
# Configs sind in .gitignore - manuelles Backup notwendig
tar -czf wireguard-client-configs-backup-$(date +%Y%m%d).tar.gz \
/home/michael/dev/michaelschiemer/deployment/ansible/wireguard/configs/
```
---
**Erstellt**: 2025-11-05
**Client Config**: michael-pc (10.8.0.2/32)
**Server Endpoint**: 94.16.110.151:51820
**VPN Network**: 10.8.0.0/24

View File

@@ -0,0 +1,259 @@
# WireGuard Setup - Dokumentations-Index
Kompletter Index aller Dokumentation und Scripts für das minimalistic WireGuard Setup.
## 📚 Dokumentation
### Haupt-Dokumentation
| Datei | Zweck | Zielgruppe |
|-------|-------|------------|
| **README.md** | Vollständige Dokumentation mit Architektur, Setup, Troubleshooting | Alle Nutzer |
| **QUICKSTART.md** | 5-Minuten Quick Start Guide | Neue Nutzer |
| **INSTALLATION-LOG.md** | Schritt-für-Schritt Installations-Log | Systemadministratoren |
| **INDEX.md** (diese Datei) | Übersicht aller Dateien | Navigation |
### Client-Dokumentation
| Datei | Zweck |
|-------|-------|
| **configs/README.md** | Client Config Verzeichnis Dokumentation und Sicherheitshinweise |
| **configs/.gitignore** | Verhindert Commit von sensitiven Client Configs |
## 🛠️ Scripts
### Setup Scripts
| Script | Zweck | Ausführung |
|--------|-------|------------|
| **scripts/manual-wireguard-setup.sh** | Manuelles Setup-Script für Host-Installation | `sudo ./manual-wireguard-setup.sh` |
| **scripts/generate-client-config.sh** | Client Config Generator mit QR Codes | `sudo ./generate-client-config.sh <client-name>` |
| **scripts/cleanup-old-wireguard.sh** | Cleanup des alten Docker-basierten Setups | `sudo ./cleanup-old-wireguard.sh` |
### Ansible Automation
| Datei | Zweck |
|-------|-------|
| **ansible/playbooks/setup-wireguard-host.yml** | Vollständiges Ansible Playbook für automatisches Deployment |
| **ansible/templates/wg0.conf.j2** | WireGuard Server Config Template |
| **ansible/templates/wireguard-host-firewall.nft.j2** | nftables Firewall Rules Template |
## 🚀 Quick Start - Welche Datei nutzen?
### Für Anfänger: QUICKSTART.md
```bash
cat deployment/wireguard/QUICKSTART.md
```
- 5-Minuten Setup
- Einfache Schritt-für-Schritt Anleitung
- Für Linux, Windows, macOS, iOS, Android
### Für Erfahrene: README.md
```bash
cat deployment/wireguard/README.md
```
- Vollständige Architektur-Übersicht
- Detaillierte Konfigurationsoptionen
- Troubleshooting-Guide
- Sicherheits-Best-Practices
### Für Automatisierung: Ansible
```bash
cd deployment/ansible
ansible-playbook playbooks/setup-wireguard-host.yml
```
- Vollautomatisches Deployment
- Idempotent und wiederholbar
- Backup und Rollback-Support
### Für manuelle Installation: manual-wireguard-setup.sh
```bash
cd deployment/scripts
sudo ./manual-wireguard-setup.sh
```
- Interaktives Setup
- Zeigt alle Schritte
- Verifikation nach jedem Schritt
## 📋 Installations-Workflow
### Methode 1: Automatisiert (Empfohlen)
```bash
# 1. Cleanup altes Setup (falls vorhanden)
cd deployment/scripts
sudo ./cleanup-old-wireguard.sh
# 2. Automatisches Deployment
cd ../ansible
ansible-playbook playbooks/setup-wireguard-host.yml
# 3. Client Config generieren
cd ../scripts
sudo ./generate-client-config.sh michael-laptop
# 4. Client verbinden und testen
# (Siehe QUICKSTART.md)
```
### Methode 2: Manuell
```bash
# 1. Setup-Script ausführen
cd deployment/scripts
sudo ./manual-wireguard-setup.sh
# 2. INSTALLATION-LOG.md durchgehen
cat ../wireguard/INSTALLATION-LOG.md
# 3. Client Config generieren
sudo ./generate-client-config.sh michael-laptop
# 4. Client verbinden und testen
# (Siehe QUICKSTART.md)
```
## 🔍 Nach Installation
### Verifikation
```bash
# WireGuard Status
sudo wg show wg0
# Service Status
sudo systemctl status wg-quick@wg0
# Firewall Rules
sudo nft list table inet wireguard_firewall
# IP Forwarding
cat /proc/sys/net/ipv4/ip_forward
```
### Client Zugriff testen
Nach VPN-Verbindung:
```bash
# VPN-Gateway ping
ping 10.8.0.1
# Admin Services
curl -k https://10.8.0.1:8080 # Traefik Dashboard
curl http://10.8.0.1:9090 # Prometheus
curl https://10.8.0.1:3001 # Grafana
curl http://10.8.0.1:9000 # Portainer
curl http://10.8.0.1:8001 # Redis Insight
```
## 🛡️ Sicherheit
### Vor Deployment lesen
1. **README.md → Security Architecture**
- Defense in Depth Strategie
- Zero Trust Network Prinzipien
- Moderne Kryptographie
2. **README.md → Security Best Practices**
- Key Rotation
- Client Config Sicherung
- Firewall Monitoring
3. **configs/.gitignore**
- Client Configs NIEMALS committen
- Private Keys schützen
## 📊 Monitoring & Troubleshooting
### Logs überwachen
```bash
# WireGuard Service Logs
sudo journalctl -u wg-quick@wg0 -f
# Firewall Block Logs
sudo journalctl -k | grep "BLOCKED"
# System Logs
sudo dmesg | grep wireguard
```
### Häufige Probleme
Siehe **README.md → Troubleshooting Section** für:
- Connection refused
- Firewall blockiert Zugriff
- Routing-Probleme
- Performance-Issues
## 🔄 Wartung
### Regelmäßige Tasks
```bash
# Client Config generieren (neue Geräte)
cd deployment/scripts
sudo ./generate-client-config.sh <device-name>
# Client revoken
# (Siehe README.md → Revoke Client Access)
# Backup durchführen
tar -czf wireguard-backup-$(date +%Y%m%d).tar.gz /etc/wireguard/
# Firewall Rules updaten
# (Siehe README.md → Firewall Configuration)
```
### Updates
```bash
# WireGuard Update
sudo apt update && sudo apt upgrade wireguard wireguard-tools
# Konfiguration reload
sudo systemctl reload wg-quick@wg0
# Oder restart
sudo systemctl restart wg-quick@wg0
```
## 📖 Weitere Ressourcen
### Externe Dokumentation
- [WireGuard Official Docs](https://www.wireguard.com/)
- [nftables Wiki](https://wiki.nftables.org/)
- [systemd Documentation](https://www.freedesktop.org/software/systemd/man/)
### Framework Integration
- **Event System**: WireGuard-Events können über Framework Event System geloggt werden
- **Monitoring**: Integration mit Framework Performance Monitoring
- **Alerts**: Benachrichtigungen bei VPN-Problemen über Framework Alert System
## 🎯 Nächste Schritte (Phase 2 - Optional)
Falls DNS gewünscht:
1. **CoreDNS Minimal Setup**
- Siehe User's CoreDNS Konfigurationsbeispiel
- Integration mit WireGuard
- `.internal` Domain für Services
2. **Service Discovery**
- Automatische DNS-Einträge für Docker Services
- Load Balancing über DNS
3. **Monitoring**
- DNS Query Logs
- Performance Metriken
---
**Erstellt**: 2025-11-05
**Framework Version**: 2.x
**WireGuard Version**: 1.0.20210914
**Zielplattform**: Debian/Ubuntu Linux mit systemd

View File

@@ -0,0 +1,275 @@
# WireGuard Installation Log
Dokumentation der manuellen WireGuard Installation auf dem Host-System.
## Systemumgebung
```bash
# System prüfen
uname -a
# Linux hostname 6.6.87.2-microsoft-standard-WSL2 #1 SMP ...
# WireGuard Version
wg --version
# wireguard-tools v1.0.20210914
# Netzwerk Interface
ip addr show
# Haupt-Interface für WAN: eth0
```
## Installation durchgeführt am
**Datum**: [WIRD BEIM AUSFÜHREN GESETZT]
**Benutzer**: root (via sudo)
**Methode**: Manual Setup Script
## Installationsschritte
### ✅ Schritt 1: Verzeichnis erstellen
```bash
sudo mkdir -p /etc/wireguard
sudo chmod 700 /etc/wireguard
```
**Status**: Bereit für Ausführung
**Zweck**: Sicheres Verzeichnis für WireGuard-Konfiguration
### ✅ Schritt 2: Server Keys generieren
```bash
cd /etc/wireguard
sudo wg genkey | sudo tee server_private.key | sudo wg pubkey | sudo tee server_public.key
sudo chmod 600 server_private.key
sudo chmod 644 server_public.key
```
**Status**: Bereit für Ausführung
**Zweck**: Kryptographische Schlüssel für Server generieren
**Ausgabe**:
- `server_private.key` - Privater Schlüssel (geheim!)
- `server_public.key` - Öffentlicher Schlüssel (für Clients)
### ✅ Schritt 3: WireGuard Konfiguration erstellen
**Datei**: `/etc/wireguard/wg0.conf`
```ini
[Interface]
# Server Configuration
PrivateKey = [GENERATED_SERVER_PRIVATE_KEY]
Address = 10.8.0.1/24
ListenPort = 51820
# Enable IP forwarding
PostUp = sysctl -w net.ipv4.ip_forward=1
# NAT Configuration with nftables
PostUp = nft add table inet wireguard
PostUp = nft add chain inet wireguard postrouting { type nat hook postrouting priority srcnat\; }
PostUp = nft add rule inet wireguard postrouting oifname "eth0" ip saddr 10.8.0.0/24 masquerade
# Cleanup on shutdown
PostDown = nft delete table inet wireguard
# Peers will be added here via generate-client-config.sh
```
**Status**: Template erstellt
**Permissions**: `chmod 600 /etc/wireguard/wg0.conf`
### ✅ Schritt 4: nftables Firewall Rules
**Datei**: `/etc/nftables.d/wireguard.nft`
Features:
- VPN Network Set: `10.8.0.0/24`
- Admin Service Ports: `8080, 9090, 3001, 9000, 8001`
- Public Service Ports: `80, 443, 22`
- Rate Limiting für SSH: `10/minute`
- Logging für blockierte Zugriffe
**Status**: Template erstellt
**Anwendung**: `sudo nft -f /etc/nftables.d/wireguard.nft`
### ✅ Schritt 5: IP Forwarding aktivieren
```bash
echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-wireguard.conf
sudo sysctl -p /etc/sysctl.d/99-wireguard.conf
```
**Status**: Bereit für Ausführung
**Zweck**: Ermöglicht Paket-Weiterleitung zwischen VPN und Host-Netzwerk
### ✅ Schritt 6: WireGuard Service aktivieren
```bash
sudo systemctl enable wg-quick@wg0
sudo systemctl start wg-quick@wg0
```
**Status**: Bereit für Ausführung
**Zweck**: WireGuard als systemd Service starten und bei Boot aktivieren
## Verifikation
### WireGuard Status prüfen
```bash
sudo wg show wg0
# Erwartete Ausgabe:
# interface: wg0
# public key: [SERVER_PUBLIC_KEY]
# private key: (hidden)
# listening port: 51820
```
### Service Status prüfen
```bash
sudo systemctl status wg-quick@wg0
# Erwartete Ausgabe:
# ● wg-quick@wg0.service - WireGuard via wg-quick(8) for wg0
# Loaded: loaded
# Active: active (exited) since ...
```
### nftables Rules prüfen
```bash
sudo nft list table inet wireguard_firewall
# Sollte alle Rules anzeigen
```
### Netzwerk-Konnektivität prüfen
```bash
# Interface prüfen
ip addr show wg0
# Sollte 10.8.0.1/24 zeigen
# Routing prüfen
ip route | grep wg0
# Sollte Route für 10.8.0.0/24 zeigen
# Firewall prüfen
sudo nft list ruleset | grep wireguard
```
## Nächste Schritte
### 1. Client-Konfiguration generieren
```bash
cd /home/michael/dev/michaelschiemer/deployment/scripts
sudo ./generate-client-config.sh michael-laptop
```
### 2. Client-Config importieren
- **Linux/macOS**: Copy `.conf` file to `/etc/wireguard/`
- **Windows**: Import via WireGuard GUI
- **iOS/Android**: Scan QR code
### 3. Verbindung testen
```bash
# Vom Client aus:
ping 10.8.0.1
# Admin-Services testen:
curl -k https://10.8.0.1:8080 # Traefik Dashboard
curl http://10.8.0.1:9090 # Prometheus
```
## Troubleshooting
### WireGuard startet nicht
```bash
# Logs prüfen
sudo journalctl -u wg-quick@wg0 -f
# Konfiguration prüfen
sudo wg-quick up wg0
```
### Keine Verbindung möglich
```bash
# Port prüfen
sudo ss -ulnp | grep 51820
# Firewall prüfen
sudo nft list ruleset | grep 51820
# IP Forwarding prüfen
cat /proc/sys/net/ipv4/ip_forward
# Sollte "1" sein
```
### Client kann keine Admin-Services erreichen
```bash
# nftables Rules prüfen
sudo nft list table inet wireguard_firewall
# VPN-Routing prüfen
ip route show table main | grep wg0
# NAT prüfen
sudo nft list chain inet wireguard postrouting
```
## Rollback-Prozedur
Falls etwas schiefgeht:
```bash
# WireGuard stoppen
sudo systemctl stop wg-quick@wg0
sudo systemctl disable wg-quick@wg0
# nftables Rules entfernen
sudo nft delete table inet wireguard_firewall
sudo nft delete table inet wireguard
# Konfiguration entfernen
sudo rm -rf /etc/wireguard/*
sudo rm /etc/nftables.d/wireguard.nft
# IP Forwarding zurücksetzen
sudo rm /etc/sysctl.d/99-wireguard.conf
sudo sysctl -p
```
## Sicherheitshinweise
- ✅ Private Keys niemals committen oder teilen
- ✅ Regelmäßige Key-Rotation (empfohlen: jährlich)
- ✅ Client-Configs nach Generierung sicher speichern
- ✅ Firewall-Logs regelmäßig überprüfen
- ✅ VPN-Zugriffe monitoren
## Performance-Metriken
Nach Installation zu überwachen:
- CPU-Auslastung: WireGuard ist sehr effizient (<5% bei normaler Last)
- Netzwerk-Durchsatz: Nahezu Leitungsgeschwindigkeit
- Latenz: Minimal (+1-2ms Overhead)
- Speicher: ~10MB RAM für WireGuard-Prozess
## Status
**Installation Status**: ⏳ BEREIT FÜR AUSFÜHRUNG
**Nächster Schritt**: Script ausführen mit:
```bash
cd /home/michael/dev/michaelschiemer/deployment/scripts
sudo ./manual-wireguard-setup.sh
```
**Oder manuell durchführen**: Jeden Schritt einzeln wie oben dokumentiert ausführen.

View File

@@ -0,0 +1,194 @@
# WireGuard VPN - Quick Start Guide
Minimalistisches Host-based WireGuard Setup in 5 Minuten.
## Prerequisites
- Debian/Ubuntu Server mit Root-Zugriff
- Public IP oder DynDNS
- Ports 51820/udp offen in Firewall/Router
## Installation (Server)
### Option 1: Automated (Ansible) - Empfohlen
```bash
# 1. Cleanup altes Docker-Setup (falls vorhanden)
cd /home/michael/dev/michaelschiemer/deployment/scripts
sudo ./cleanup-old-wireguard.sh
# 2. Deploy WireGuard Host-based
cd /home/michael/dev/michaelschiemer/deployment/ansible
ansible-playbook playbooks/setup-wireguard-host.yml
# 3. Verify Installation
sudo wg show wg0
sudo systemctl status wg-quick@wg0
```
### Option 2: Manual Installation
```bash
# Install WireGuard
sudo apt update
sudo apt install wireguard wireguard-tools qrencode nftables
# Generate Server Keys
cd /etc/wireguard
sudo wg genkey | sudo tee server_private.key | wg pubkey | sudo tee server_public.key
# Create Config (replace YOUR_SERVER_IP)
sudo tee /etc/wireguard/wg0.conf <<EOF
[Interface]
PrivateKey = $(sudo cat server_private.key)
Address = 10.8.0.1/24
ListenPort = 51820
PostUp = sysctl -w net.ipv4.ip_forward=1
EOF
# Enable and Start
sudo systemctl enable wg-quick@wg0
sudo systemctl start wg-quick@wg0
# Apply Firewall
# See: deployment/ansible/templates/wireguard-host-firewall.nft.j2
```
## Client Setup
### Generate Client Config
```bash
# On server
cd /home/michael/dev/michaelschiemer/deployment/scripts
sudo ./generate-client-config.sh michael-laptop
# Script outputs:
# - Config file: ../wireguard/configs/michael-laptop.conf
# - QR code (text): ../wireguard/configs/michael-laptop.qr.txt
# - QR code (PNG): ../wireguard/configs/michael-laptop.qr.png
```
### Import on Client
**Linux/macOS:**
```bash
# Copy config to client
scp server:/path/to/michael-laptop.conf /etc/wireguard/
# Connect
sudo wg-quick up michael-laptop
# Verify
ping 10.8.0.1
curl -k https://10.8.0.1:8080 # Traefik Dashboard
```
**Windows:**
1. Download WireGuard from https://www.wireguard.com/install/
2. Open WireGuard GUI
3. Click "Import tunnel(s) from file"
4. Select `michael-laptop.conf`
5. Click "Activate"
**iOS/Android:**
1. Install WireGuard app from App Store/Play Store
2. Tap "+" → "Create from QR code"
3. Scan QR code (shown in terminal or PNG file)
4. Tap "Activate"
## Service Access
Nach VPN-Verbindung sind folgende Services erreichbar:
| Service | URL | Purpose |
|---------|-----|---------|
| Traefik Dashboard | https://10.8.0.1:8080 | Reverse Proxy Management |
| Prometheus | http://10.8.0.1:9090 | Metrics Collection |
| Grafana | https://10.8.0.1:3001 | Monitoring Dashboards |
| Portainer | http://10.8.0.1:9000 | Docker Management |
| Redis Insight | http://10.8.0.1:8001 | Redis Debugging |
## Verification
```bash
# On Client after connecting VPN
# Test VPN connectivity
ping 10.8.0.1
# Test service access
curl -k https://10.8.0.1:8080 # Traefik Dashboard (should return HTML)
curl http://10.8.0.1:9090 # Prometheus (should return HTML)
# Check routing
ip route | grep 10.8.0.0
# Verify WireGuard interface
sudo wg show
```
## Troubleshooting
### Cannot connect to VPN
```bash
# On Server
sudo wg show wg0 # Check if interface exists
sudo systemctl status wg-quick@wg0 # Check if service running
sudo ss -ulnp | grep 51820 # Check if listening on port
# Check firewall allows WireGuard port
sudo nft list ruleset | grep 51820
# View logs
sudo journalctl -u wg-quick@wg0 -f
```
### VPN connected but cannot access services
```bash
# On Client
ping 10.8.0.1 # Should work
# On Server
sudo nft list ruleset | grep "10.8.0.0" # Check VPN network allowed
# Check service is listening
sudo ss -tlnp | grep 8080 # Traefik Dashboard
sudo docker ps # Check containers running
```
### Slow connection
```bash
# Check MTU settings (on client)
sudo wg show michael-laptop
# Try reducing MTU if packet loss
# Edit config: MTU = 1420 (in [Interface] section)
```
## Security
- ✅ All admin services **only** accessible via VPN
- ✅ Public ports limited to 80, 443, 22
- ✅ Modern crypto (ChaCha20, Poly1305)
- ✅ Preshared keys for quantum resistance
- ✅ nftables firewall with explicit rules
## Next Steps
- [ ] Add more clients: `sudo ./generate-client-config.sh <device-name>`
- [ ] Setup monitoring alerts for VPN
- [ ] Optional: Add minimal CoreDNS for `.internal` domains
- [ ] Schedule key rotation (recommended: annually)
## Support
Full documentation: `deployment/wireguard/README.md`
For issues, check:
- `sudo journalctl -u wg-quick@wg0`
- `sudo dmesg | grep wireguard`
- `sudo nft list ruleset`

View File

@@ -0,0 +1,352 @@
# Minimalistic WireGuard VPN Setup
**Purpose**: Secure admin access to internal services (Traefik Dashboard, Prometheus, Grafana, etc.)
**Architecture**: Host-based WireGuard with IP-based service access (no DNS required)
## Overview
```
Public Internet
┌─────────────────────────────────────────┐
│ Server (Public IP) │
│ │
│ Public Ports: │
│ 80/443 → Traefik (Public Apps) │
│ 22 → SSH │
│ 51820 → WireGuard │
│ │
│ VPN Network (10.8.0.0/24): │
│ 10.8.0.1 → Server (VPN Gateway) │
│ │
│ Admin Services (VPN-only): │
│ https://10.8.0.1:8080 → Traefik │
│ http://10.8.0.1:9090 → Prometheus │
│ https://10.8.0.1:3001 → Grafana │
│ http://10.8.0.1:9000 → Portainer │
│ http://10.8.0.1:8001 → Redis Insight│
│ │
└─────────────────────────────────────────┘
```
## Components
### 1. WireGuard (Host-based)
- **Interface**: wg0
- **Server IP**: 10.8.0.1/24
- **Port**: 51820/udp
- **Management**: systemd + wg-quick
### 2. nftables Firewall
- **VPN Access**: 10.8.0.0/24 → All admin services
- **Public Access**: Only ports 80, 443, 22
- **Default Policy**: DROP all other traffic
### 3. Service Access (IP-based)
| Service | URL | Purpose |
|---------|-----|---------|
| Traefik Dashboard | https://10.8.0.1:8080 | Reverse Proxy Management |
| Prometheus | http://10.8.0.1:9090 | Metrics Collection |
| Grafana | https://10.8.0.1:3001 | Monitoring Dashboards |
| Portainer | http://10.8.0.1:9000 | Docker Management |
| Redis Insight | http://10.8.0.1:8001 | Redis Debugging |
## Quick Start
### Server Setup (Automated)
```bash
# Deploy WireGuard + Firewall
cd deployment/ansible
ansible-playbook playbooks/setup-wireguard-host.yml
```
### Client Setup
```bash
# Generate new client config
cd deployment/scripts
./generate-client-config.sh michael-laptop
# Import config (Linux/macOS)
sudo wg-quick up ./configs/michael-laptop.conf
# Import config (Windows)
# 1. Open WireGuard GUI
# 2. Import Tunnel from File
# 3. Select ./configs/michael-laptop.conf
# Import config (iOS/Android)
# Scan QR code generated by script
```
### Verify Connection
```bash
# Check VPN connection
ping 10.8.0.1
# Access Traefik Dashboard
curl -k https://10.8.0.1:8080
```
## Manual Server Setup
If you prefer manual installation:
### 1. Install WireGuard
```bash
# Ubuntu/Debian
sudo apt update
sudo apt install wireguard wireguard-tools qrencode
# Check kernel module
sudo modprobe wireguard
lsmod | grep wireguard
```
### 2. Generate Server Keys
```bash
# Create config directory
sudo mkdir -p /etc/wireguard
cd /etc/wireguard
# Generate keys
umask 077
wg genkey | tee server_private.key | wg pubkey > server_public.key
# Save keys
SERVER_PRIVATE_KEY=$(cat server_private.key)
SERVER_PUBLIC_KEY=$(cat server_public.key)
```
### 3. Create Server Config
```bash
sudo tee /etc/wireguard/wg0.conf <<EOF
[Interface]
PrivateKey = $SERVER_PRIVATE_KEY
Address = 10.8.0.1/24
ListenPort = 51820
# Firewall: Allow VPN traffic forwarding
PostUp = nft add table inet wireguard
PostUp = nft add chain inet wireguard postrouting { type nat hook postrouting priority srcnat\; }
PostUp = nft add chain inet wireguard forward { type filter hook forward priority filter\; }
PostUp = nft add rule inet wireguard postrouting oifname "eth0" ip saddr 10.8.0.0/24 masquerade
PostUp = nft add rule inet wireguard forward iifname "wg0" accept
PostUp = nft add rule inet wireguard forward oifname "wg0" ct state established,related accept
PostDown = nft delete table inet wireguard
# Peers will be added here
EOF
# Secure permissions
sudo chmod 600 /etc/wireguard/wg0.conf
```
### 4. Enable WireGuard
```bash
# Enable IP forwarding
echo "net.ipv4.ip_forward = 1" | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
# Start WireGuard
sudo systemctl enable wg-quick@wg0
sudo systemctl start wg-quick@wg0
# Check status
sudo wg show
sudo systemctl status wg-quick@wg0
```
### 5. Apply Firewall Rules
See `deployment/ansible/templates/wireguard-firewall.nft.j2` for complete firewall configuration.
```bash
# Allow WireGuard port
sudo nft add rule inet filter input udp dport 51820 accept
# Allow VPN network to access admin services
sudo nft add rule inet filter input ip saddr 10.8.0.0/24 tcp dport { 8080, 9090, 3001, 9000, 8001 } accept
# Block public access to admin services
sudo nft add rule inet filter input tcp dport { 8080, 9090, 3001, 9000, 8001 } drop
```
## Client Configuration
### Generate Client
```bash
# Generate client keys
umask 077
wg genkey | tee client_private.key | wg pubkey > client_public.key
wg genpsk > client_preshared.key
CLIENT_PRIVATE_KEY=$(cat client_private.key)
CLIENT_PUBLIC_KEY=$(cat client_public.key)
CLIENT_PSK=$(cat client_preshared.key)
```
### Add Client to Server
```bash
# Add peer to server config
sudo tee -a /etc/wireguard/wg0.conf <<EOF
[Peer]
# michael-laptop
PublicKey = $CLIENT_PUBLIC_KEY
PresharedKey = $CLIENT_PSK
AllowedIPs = 10.8.0.2/32
EOF
# Reload WireGuard
sudo systemctl reload wg-quick@wg0
```
### Create Client Config File
```bash
# Create client config
cat > michael-laptop.conf <<EOF
[Interface]
PrivateKey = $CLIENT_PRIVATE_KEY
Address = 10.8.0.2/32
DNS = 1.1.1.1, 9.9.9.9
[Peer]
PublicKey = $SERVER_PUBLIC_KEY
PresharedKey = $CLIENT_PSK
Endpoint = YOUR_SERVER_IP:51820
AllowedIPs = 10.8.0.0/24
PersistentKeepalive = 25
EOF
```
## Troubleshooting
### VPN Not Connecting
```bash
# Check WireGuard status
sudo wg show
# Check firewall
sudo nft list ruleset | grep 51820
# Check logs
sudo journalctl -u wg-quick@wg0 -f
# Test connectivity
ping 10.8.0.1 # From client
```
### Cannot Access Services
```bash
# Verify firewall allows VPN network
sudo nft list ruleset | grep "10.8.0.0"
# Check service is listening
sudo ss -tlnp | grep 8080 # Traefik Dashboard
# Test from VPN
curl -k https://10.8.0.1:8080 # From client
```
### Key Rotation
Recommended: Rotate keys annually
```bash
# Generate new server keys
cd /etc/wireguard
wg genkey | tee server_private_new.key | wg pubkey > server_public_new.key
# Update server config
# ... update PrivateKey in wg0.conf
# Regenerate all client configs with new server PublicKey
# ... update clients
# Restart WireGuard
sudo systemctl restart wg-quick@wg0
```
## Security Best Practices
### 1. Strong Cryptography
- ✅ WireGuard uses modern crypto (ChaCha20, Poly1305, Curve25519)
- ✅ Preshared keys for quantum resistance
- ✅ Perfect forward secrecy
### 2. Firewall Isolation
- ✅ Admin services only accessible via VPN
- ✅ Explicit ALLOW rules, default DROP
- ✅ Rate limiting on VPN port (optional)
### 3. Key Management
- ✅ Private keys never leave server/client
- ✅ Preshared keys for each peer
- ✅ Annual key rotation recommended
### 4. Monitoring
- ✅ Log all VPN connections
- ✅ Alert on unusual traffic patterns
- ✅ Regular security audits
## Performance
- **Latency Overhead**: <1ms (kernel-native)
- **Throughput**: Near-native (minimal encryption overhead)
- **Concurrent Peers**: 10-20 recommended
- **Keepalive**: 25 seconds (NAT traversal)
## Maintenance
### Add New Client
```bash
./deployment/scripts/generate-client-config.sh new-device-name
```
### Remove Client
```bash
# Edit server config
sudo nano /etc/wireguard/wg0.conf
# Remove [Peer] section
# Reload
sudo systemctl reload wg-quick@wg0
```
### Backup Configuration
```bash
# Backup keys and configs
sudo tar -czf wireguard-backup-$(date +%Y%m%d).tar.gz /etc/wireguard/
```
## Next Steps
- [ ] Deploy WireGuard on server
- [ ] Generate client configs for all devices
- [ ] Test VPN connectivity
- [ ] Verify admin service access
- [ ] Optional: Add minimal CoreDNS for `.internal` domains (Phase 2)
## Support
- **WireGuard Docs**: https://www.wireguard.com/quickstart/
- **nftables Wiki**: https://wiki.nftables.org/
- **Framework Issues**: https://github.com/your-repo/issues

11
deployment/wireguard/configs/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# WireGuard Client Configurations
# These contain private keys and should NEVER be committed!
*.conf
*.key
*.qr.txt
*.qr.png
# Allow README
!README.md
!.gitignore

View File

@@ -0,0 +1,47 @@
# WireGuard Client Configurations
This directory stores generated client configuration files.
## Security Notice
⚠️ **NEVER commit client configs to Git!**
Client configs contain:
- Private keys
- Preshared keys
- Network topology information
`.gitignore` is configured to exclude all `.conf`, `.key`, `.qr.txt`, and `.qr.png` files.
## Generate New Client
```bash
cd ../../scripts
sudo ./generate-client-config.sh <device-name>
```
Configs will be created here:
- `<device-name>.conf` - WireGuard configuration
- `<device-name>.qr.txt` - QR code (ASCII)
- `<device-name>.qr.png` - QR code (PNG)
## Backup Client Configs
```bash
# Securely backup configs (encrypted)
tar -czf - *.conf | gpg --symmetric --cipher-algo AES256 -o wireguard-clients-backup-$(date +%Y%m%d).tar.gz.gpg
```
## Revoke Client Access
```bash
# On server
sudo nano /etc/wireguard/wg0.conf
# Remove [Peer] section for client
# Reload WireGuard
sudo systemctl reload wg-quick@wg0
# Delete client config
rm <device-name>.*
```

View File

@@ -0,0 +1,798 @@
# ErrorHandling → ExceptionHandling Migration Strategy
**Status:** Task 13 Phase 5 - Migration Planning
**Date:** 2025-11-05
**Phase 4 Completion:** All legacy files examined, incompatibilities documented
## Executive Summary
The legacy `ErrorHandling` module cannot be removed until **5 critical incompatibilities** are resolved. This document provides implementation strategies for each blocker.
## Critical Blockers
| # | Blocker | Severity | Location | Impact |
|---|---------|----------|----------|---------|
| 1 | ErrorAggregator signature mismatch | 🔴 CRITICAL | ErrorHandler.php:128 | Prevents error aggregation |
| 2 | ExceptionHandlingMiddleware unreachable code | 🔴 URGENT | ExceptionHandlingMiddleware.php:32-37 | Broken error recovery |
| 3 | SecurityEventLogger old types | 🔴 HIGH | SecurityEventLogger.php:28-52 | Breaks DDoS logging |
| 4 | Missing CLI error rendering | 🔴 HIGH | AppBootstrapper.php:155-163 | No CLI error handling |
| 5 | Missing HTTP Response generation | 🔴 HIGH | Multiple locations | No middleware recovery |
---
## Strategy 1: Fix ErrorAggregator Signature Mismatch
### Current State (BROKEN)
**Location:** `src/Framework/ErrorHandling/ErrorHandler.php:127-128`
```php
// BROKEN: OLD signature call
$this->errorAggregator->processError($errorHandlerContext);
```
**NEW signature requires:**
```php
public function processError(
\Throwable $exception,
ExceptionContextProvider $contextProvider,
bool $isDebug = false
): void
```
### Migration Strategy
**Option A: Minimal Change (Recommended)**
Create adapter method in ErrorHandler that converts ErrorHandlerContext to ExceptionContextProvider:
```php
// Add to ErrorHandler.php
private function dispatchToErrorAggregator(
\Throwable $exception,
ErrorHandlerContext $errorHandlerContext
): void {
// Create ExceptionContextProvider instance
$contextProvider = $this->container->get(ExceptionContextProvider::class);
// Convert ErrorHandlerContext to ExceptionContextData
$contextData = ExceptionContextData::create(
operation: $errorHandlerContext->exception->operation ?? null,
component: $errorHandlerContext->exception->component ?? null,
userId: $errorHandlerContext->request->userId,
sessionId: $errorHandlerContext->request->sessionId,
requestId: $errorHandlerContext->request->requestId,
clientIp: $errorHandlerContext->request->clientIp,
userAgent: $errorHandlerContext->request->userAgent,
occurredAt: new \DateTimeImmutable(),
tags: $errorHandlerContext->exception->tags ?? [],
metadata: $errorHandlerContext->exception->metadata ?? [],
data: $errorHandlerContext->metadata
);
// Store in WeakMap
$contextProvider->set($exception, $contextData);
// Call ErrorAggregator with NEW signature
$this->errorAggregator->processError(
$exception,
$contextProvider,
$this->isDebugMode()
);
}
```
**Change at line 127:**
```php
// BEFORE (BROKEN)
$this->errorAggregator->processError($errorHandlerContext);
// AFTER (FIXED)
$this->dispatchToErrorAggregator($exception, $errorHandlerContext);
```
**Files to modify:**
- ✏️ `src/Framework/ErrorHandling/ErrorHandler.php` (add adapter method)
**Testing:**
- Trigger error that calls ErrorAggregator
- Verify context data preserved in WeakMap
- Check error aggregation dashboard shows correct context
---
## Strategy 2: Fix ExceptionHandlingMiddleware Unreachable Code
### Current State (BROKEN)
**Location:** `src/Framework/Http/Middlewares/ExceptionHandlingMiddleware.php:26-39`
```php
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
try {
return $next($context);
} catch (\Throwable $e) {
$error = new ErrorKernel();
$error->handle($e); // ← Calls exit() - terminates PHP
// UNREACHABLE CODE - execution never reaches here
$response = $this->errorHandler->createHttpResponse($e, $context);
return $context->withResponse($response);
}
}
```
**Problem:** ErrorKernel.handle() calls exit(), making recovery impossible.
### Migration Strategy
**Solution: Add non-terminal mode to ErrorKernel**
**Step 1: Add createHttpResponse() to ErrorKernel**
```php
// Add to src/Framework/ExceptionHandling/ErrorKernel.php
/**
* Create HTTP Response without terminating execution
* (for middleware recovery pattern)
*/
public function createHttpResponse(\Throwable $exception): Response
{
// Initialize context if not already done
if ($this->contextProvider === null) {
$this->initializeContext($exception);
}
// Enrich context from request globals
$this->enrichContextFromRequest($exception);
// Create Response using renderer chain
$response = $this->createResponseFromException($exception);
// Log error (without terminating)
$this->logError($exception);
// Dispatch to aggregator
$this->dispatchToErrorAggregator($exception);
return $response;
}
/**
* Extract response creation from handle()
*/
private function createResponseFromException(\Throwable $exception): Response
{
// Try framework exception handler
if ($exception instanceof FrameworkException) {
return $this->handleFrameworkException($exception);
}
// Try specialized handlers
if ($this->exceptionHandlerManager !== null) {
$response = $this->exceptionHandlerManager->handle($exception);
if ($response !== null) {
return $response;
}
}
// Fallback to renderer chain
return $this->rendererChain->render($exception, $this->contextProvider);
}
```
**Step 2: Update ExceptionHandlingMiddleware**
```php
// Update src/Framework/Http/Middlewares/ExceptionHandlingMiddleware.php
use App\Framework\ExceptionHandling\ErrorKernel;
final readonly class ExceptionHandlingMiddleware
{
public function __construct(
private ErrorKernel $errorKernel, // ← Inject ErrorKernel
private Logger $logger
) {}
public function __invoke(
MiddlewareContext $context,
Next $next,
RequestStateManager $stateManager
): MiddlewareContext {
try {
return $next($context);
} catch (\Throwable $e) {
// Log error
$this->logger->error('[Middleware] Exception caught', [
'exception' => get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine()
]);
// Create recovery response (non-terminal)
$response = $this->errorKernel->createHttpResponse($e);
// Return context with error response
return $context->withResponse($response);
}
}
}
```
**Files to modify:**
- ✏️ `src/Framework/ExceptionHandling/ErrorKernel.php` (add createHttpResponse() method)
- ✏️ `src/Framework/Http/Middlewares/ExceptionHandlingMiddleware.php` (fix catch block)
**Testing:**
- Throw exception in middleware chain
- Verify Response returned (no exit())
- Check error logged and aggregated
- Verify subsequent middleware not executed
---
## Strategy 3: Migrate SecurityEventLogger to ExceptionContextProvider
### Current State (OLD Architecture)
**Location:** `src/Framework/ErrorHandling/SecurityEventLogger.php:28-52`
```php
public function logSecurityEvent(
SecurityException $exception,
ErrorHandlerContext $context // ← OLD architecture
): void
```
**Dependencies:** Used by DDoS system (AdaptiveResponseSystem.php:244-250, 371-379)
### Migration Strategy
**Solution: Create bridge adapter that converts ExceptionContextProvider to old format**
**Step 1: Add WeakMap support to SecurityEventLogger**
```php
// Update src/Framework/ErrorHandling/SecurityEventLogger.php
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
final readonly class SecurityEventLogger
{
public function __construct(
private Logger $logger,
private AppConfig $appConfig,
private ?ExceptionContextProvider $contextProvider = null // ← NEW
) {}
/**
* NEW signature - preferred for new code
*/
public function logSecurityEventFromException(
SecurityException $exception
): void {
if ($this->contextProvider === null) {
throw new \RuntimeException('ExceptionContextProvider required for new logging');
}
// Retrieve context from WeakMap
$exceptionContext = $this->contextProvider->get($exception);
if ($exceptionContext === null) {
// Fallback: Create minimal context
$exceptionContext = ExceptionContextData::create();
}
// Convert to OWASP format
$owaspLog = $this->createOWASPLogFromWeakMap($exception, $exceptionContext);
// Log via framework logger
$this->logToFramework($exception, $owaspLog);
}
/**
* LEGACY signature - kept for backward compatibility
* @deprecated Use logSecurityEventFromException() instead
*/
public function logSecurityEvent(
SecurityException $exception,
ErrorHandlerContext $context
): void {
// Keep existing implementation for backward compatibility
$owaspLog = $this->createOWASPLog($exception, $context);
$this->logToFramework($exception, $owaspLog);
}
private function createOWASPLogFromWeakMap(
SecurityException $exception,
ExceptionContextData $context
): array {
$securityEvent = $exception->getSecurityEvent();
return [
'datetime' => date('c'),
'appid' => $this->appConfig->name,
'event' => $securityEvent->getEventIdentifier(),
'level' => $securityEvent->getLogLevel()->value,
'description' => $securityEvent->getDescription(),
'useragent' => $context->userAgent,
'source_ip' => $context->clientIp,
'host_ip' => $_SERVER['SERVER_ADDR'] ?? 'unknown',
'hostname' => $_SERVER['SERVER_NAME'] ?? 'unknown',
'protocol' => $_SERVER['SERVER_PROTOCOL'] ?? 'unknown',
'port' => $_SERVER['SERVER_PORT'] ?? 'unknown',
'request_uri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
'category' => $securityEvent->getCategory(),
'requires_alert' => $securityEvent->requiresAlert(),
];
}
private function logToFramework(
SecurityException $exception,
array $owaspLog
): void {
$securityEvent = $exception->getSecurityEvent();
$frameworkLogLevel = $this->mapSecurityLevelToFrameworkLevel(
$securityEvent->getLogLevel()
);
$this->logger->log(
$frameworkLogLevel,
$securityEvent->getDescription(),
[
'security_event' => $securityEvent->getEventIdentifier(),
'security_category' => $securityEvent->getCategory(),
'requires_alert' => $securityEvent->requiresAlert(),
'owasp_format' => $owaspLog,
]
);
}
}
```
**Step 2: Update DDoS system to use array logging (no SecurityException needed)**
```php
// Update src/Framework/DDoS/Response/AdaptiveResponseSystem.php
// CURRENT (line 244-250):
$this->securityLogger->logSecurityEvent([
'event_type' => 'ddos_enhanced_monitoring',
'client_ip' => $assessment->clientIp->value,
// ...
]);
// AFTER: Keep as-is - this is array-based logging, not SecurityException
// No changes needed here
```
**Files to modify:**
- ✏️ `src/Framework/ErrorHandling/SecurityEventLogger.php` (add WeakMap support)
**Files unchanged:**
-`src/Framework/DDoS/Response/AdaptiveResponseSystem.php` (already uses array logging)
**Testing:**
- Trigger DDoS detection
- Verify OWASP logs generated
- Check both old and new signatures work
---
## Strategy 4: Create CLI Error Rendering for ErrorKernel
### Current State
**Location:** `src/Framework/Core/AppBootstrapper.php:155-163`
```php
private function registerCliErrorHandler(): void
{
$output = $this->container->has(ConsoleOutput::class)
? $this->container->get(ConsoleOutput::class)
: new ConsoleOutput();
$cliErrorHandler = new CliErrorHandler($output); // ← Legacy
$cliErrorHandler->register();
}
```
**Legacy CliErrorHandler features:**
- Colored console output (ConsoleColor enum)
- Exit(1) on fatal errors
- Stack trace formatting
### Migration Strategy
**Solution: Create CliErrorRenderer for ErrorKernel renderer chain**
**Step 1: Create CliErrorRenderer**
```php
// Create src/Framework/ExceptionHandling/Renderers/CliErrorRenderer.php
namespace App\Framework\ExceptionHandling\Renderers;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ConsoleColor;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
final readonly class CliErrorRenderer implements ErrorRenderer
{
public function __construct(
private ConsoleOutput $output
) {}
public function canRender(\Throwable $exception): bool
{
// Render in CLI context only
return PHP_SAPI === 'cli';
}
public function render(
\Throwable $exception,
?ExceptionContextProvider $contextProvider = null
): void {
$this->output->writeLine(
"❌ Uncaught " . get_class($exception) . ": " . $exception->getMessage(),
ConsoleColor::BRIGHT_RED
);
$this->output->writeLine(
" File: " . $exception->getFile() . ":" . $exception->getLine(),
ConsoleColor::RED
);
if ($exception->getPrevious()) {
$this->output->writeLine(
" Caused by: " . $exception->getPrevious()->getMessage(),
ConsoleColor::YELLOW
);
}
$this->output->writeLine(" Stack trace:", ConsoleColor::GRAY);
foreach (explode("\n", $exception->getTraceAsString()) as $line) {
$this->output->writeLine(" " . $line, ConsoleColor::GRAY);
}
// Context information if available
if ($contextProvider !== null) {
$context = $contextProvider->get($exception);
if ($context !== null && $context->operation !== null) {
$this->output->writeLine(
" Operation: " . $context->operation,
ConsoleColor::CYAN
);
}
}
}
}
```
**Step 2: Register CLI renderer in ErrorKernel**
```php
// Update src/Framework/ExceptionHandling/ErrorKernel.php initialization
private function initializeRendererChain(): void
{
$renderers = [];
// CLI renderer (highest priority in CLI context)
if (PHP_SAPI === 'cli' && $this->container->has(ConsoleOutput::class)) {
$renderers[] = new CliErrorRenderer(
$this->container->get(ConsoleOutput::class)
);
}
// HTTP renderers
$renderers[] = new HtmlErrorRenderer($this->container);
$renderers[] = new JsonErrorRenderer();
$this->rendererChain = new ErrorRendererChain($renderers);
}
```
**Step 3: Update AppBootstrapper to use ErrorKernel in CLI**
```php
// Update src/Framework/Core/AppBootstrapper.php
private function registerCliErrorHandler(): void
{
// NEW: Use ErrorKernel for CLI (unified architecture)
new ExceptionHandlerManager();
// ErrorKernel will detect CLI context and use CliErrorRenderer
// via its renderer chain
}
```
**Files to modify:**
- ✏️ Create `src/Framework/ExceptionHandling/Renderers/CliErrorRenderer.php`
- ✏️ `src/Framework/ExceptionHandling/ErrorKernel.php` (register CLI renderer)
- ✏️ `src/Framework/Core/AppBootstrapper.php` (use ErrorKernel in CLI)
**Files to delete (after migration):**
- 🗑️ `src/Framework/ErrorHandling/CliErrorHandler.php` (replaced by CliErrorRenderer)
**Testing:**
- Run console command that throws exception
- Verify colored output in terminal
- Check stack trace formatting
- Verify exit(1) called
---
## Strategy 5: Create HTTP Response Generation for ErrorKernel
### Current State
Legacy ErrorHandler.createHttpResponse() pattern (lines 71-86, 115-145) provides:
- Response generation without terminating
- ErrorResponseFactory for API/HTML rendering
- Middleware recovery pattern support
### Migration Strategy
**Solution: Extract ErrorResponseFactory pattern into ErrorKernel**
**Step 1: Create ResponseErrorRenderer**
```php
// Create src/Framework/ExceptionHandling/Renderers/ResponseErrorRenderer.php
namespace App\Framework\ExceptionHandling\Renderers;
use App\Framework\Http\Response;
use App\Framework\Http\Status;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\Template\TemplateRenderer;
final readonly class ResponseErrorRenderer
{
public function __construct(
private ?TemplateRenderer $templateRenderer = null
) {}
public function createResponse(
\Throwable $exception,
?ExceptionContextProvider $contextProvider = null
): Response {
// Determine if API or HTML response needed
$isApiRequest = $this->isApiRequest();
if ($isApiRequest) {
return $this->createApiResponse($exception, $contextProvider);
}
return $this->createHtmlResponse($exception, $contextProvider);
}
private function createApiResponse(
\Throwable $exception,
?ExceptionContextProvider $contextProvider
): Response {
$statusCode = $this->getHttpStatusCode($exception);
$body = json_encode([
'error' => [
'message' => $exception->getMessage(),
'type' => get_class($exception),
'code' => $exception->getCode(),
]
]);
return new Response(
status: Status::from($statusCode),
body: $body,
headers: ['Content-Type' => 'application/json']
);
}
private function createHtmlResponse(
\Throwable $exception,
?ExceptionContextProvider $contextProvider
): Response {
$statusCode = $this->getHttpStatusCode($exception);
if ($this->templateRenderer !== null) {
$body = $this->templateRenderer->render('errors/exception', [
'exception' => $exception,
'context' => $contextProvider?->get($exception),
'statusCode' => $statusCode
]);
} else {
$body = $this->createFallbackHtml($exception, $statusCode);
}
return new Response(
status: Status::from($statusCode),
body: $body,
headers: ['Content-Type' => 'text/html']
);
}
private function isApiRequest(): bool
{
// Check Accept header or URL prefix
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
$uri = $_SERVER['REQUEST_URI'] ?? '';
return str_contains($accept, 'application/json')
|| str_starts_with($uri, '/api/');
}
private function getHttpStatusCode(\Throwable $exception): int
{
// Map exception types to HTTP status codes
return match (true) {
$exception instanceof \InvalidArgumentException => 400,
$exception instanceof \UnauthorizedException => 401,
$exception instanceof \ForbiddenException => 403,
$exception instanceof \NotFoundException => 404,
default => 500
};
}
private function createFallbackHtml(\Throwable $exception, int $statusCode): string
{
return <<<HTML
<!DOCTYPE html>
<html>
<head>
<title>Error {$statusCode}</title>
</head>
<body>
<h1>Error {$statusCode}</h1>
<p>{$exception->getMessage()}</p>
</body>
</html>
HTML;
}
}
```
**Step 2: Integrate into ErrorKernel.createHttpResponse()**
```php
// Update src/Framework/ExceptionHandling/ErrorKernel.php
private ResponseErrorRenderer $responseRenderer;
private function initializeResponseRenderer(): void
{
$templateRenderer = $this->container->has(TemplateRenderer::class)
? $this->container->get(TemplateRenderer::class)
: null;
$this->responseRenderer = new ResponseErrorRenderer($templateRenderer);
}
public function createHttpResponse(\Throwable $exception): Response
{
// Initialize context
if ($this->contextProvider === null) {
$this->initializeContext($exception);
}
// Enrich from request
$this->enrichContextFromRequest($exception);
// Create Response
$response = $this->responseRenderer->createResponse(
$exception,
$this->contextProvider
);
// Log error (without terminating)
$this->logError($exception);
// Dispatch to aggregator
$this->dispatchToErrorAggregator($exception);
return $response;
}
```
**Files to modify:**
- ✏️ Create `src/Framework/ExceptionHandling/Renderers/ResponseErrorRenderer.php`
- ✏️ `src/Framework/ExceptionHandling/ErrorKernel.php` (add createHttpResponse())
**Testing:**
- Throw exception in middleware
- Verify JSON response for /api/* routes
- Verify HTML response for web routes
- Check status codes correct
---
## Migration Execution Plan
### Phase 5a: Preparation (Current Phase)
- ✅ Document all 5 strategies
- ⏳ Review strategies with team
- ⏳ Create feature branch: `feature/migrate-errorhandling-module`
### Phase 5b: Implementation Order
**Week 1: Foundation**
1. Strategy 5: HTTP Response generation (enables middleware recovery)
2. Strategy 2: Fix ExceptionHandlingMiddleware (depends on Strategy 5)
**Week 2: Compatibility**
3. Strategy 1: ErrorAggregator signature fix (critical for logging)
4. Strategy 3: SecurityEventLogger migration (preserves DDoS logging)
**Week 3: CLI Support**
5. Strategy 4: CLI error rendering (replaces CliErrorHandler)
**Week 4: Cleanup**
6. Remove legacy ErrorHandling module
7. Update all import statements
8. Run full test suite
### Testing Strategy
**Per-Strategy Testing:**
- Unit tests for new components
- Integration tests for error flows
- Manual testing in development environment
**Final Integration Testing:**
- Trigger errors in web context → verify HTTP Response
- Trigger errors in CLI context → verify colored output
- Trigger security events → verify OWASP logs
- Trigger DDoS detection → verify adaptive response
- Check ErrorAggregator dashboard → verify context preserved
### Rollback Plan
Each strategy is independent and can be rolled back:
- Strategy 1: Remove adapter method
- Strategy 2: Revert middleware catch block
- Strategy 3: Remove WeakMap support from SecurityEventLogger
- Strategy 4: Keep CliErrorHandler active
- Strategy 5: Don't use createHttpResponse()
---
## Success Criteria
- ✅ All 5 blockers resolved
- ✅ Zero breaking changes to public APIs
- ✅ DDoS system continues functioning
- ✅ CLI error handling preserved
- ✅ Middleware recovery pattern works
- ✅ ErrorAggregator receives correct context
- ✅ All tests passing
- ✅ Legacy ErrorHandling module deleted
---
## Next Actions
**Immediate (Phase 5b start):**
1. Create feature branch: `git checkout -b feature/migrate-errorhandling-module`
2. Implement Strategy 5 (HTTP Response generation)
3. Implement Strategy 2 (Fix middleware)
4. Run tests and verify middleware recovery
**This Week:**
- Complete Strategies 1-2
- Manual testing in development
**Next Week:**
- Complete Strategies 3-5
- Integration testing
- Code review
**Final Week:**
- Remove legacy module
- Documentation updates
- Production deployment

BIN
public/qrcode-FINAL.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -17,6 +17,8 @@ final readonly class ShowHome
#[Route(path: '/', method: Method::GET, name: WebRoutes::HOME)]
public function home(HomeRequest $request, string $test = 'hallo'): ViewResult
{
throw new \Exception('test');
// Production deployment trigger - scp from /workspace/repo
$model = new HomeViewModel('Hallo Welt!');
return new ViewResult(
@@ -25,7 +27,9 @@ final readonly class ShowHome
title: 'Home',
description: 'Hallo Welt!',
)(),
data: ['name' => 'Michael'],
data: [
'name' => 'Michael',
],
model: $model,
);
}

View File

@@ -182,6 +182,11 @@ final readonly class ApiGateway
connectTimeout: min(3, $timeoutSeconds), // Connect timeout max 3s or total timeout
);
// Add authentication if present
if ($request instanceof HasAuth) {
$options = $options->with(['auth' => $request->getAuth()]);
}
// Use factory method for JSON requests if payload is present
if ($request instanceof HasPayload) {
return ClientRequest::json(

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\ApiGateway;
use App\Framework\HttpClient\AuthConfig;
/**
* Marker interface for API requests that require authentication
*
* Requests implementing this interface provide AuthConfig
* instead of manually building Authorization headers.
*
* Example:
* final readonly class AuthenticatedApiRequest implements ApiRequest, HasAuth
* {
* public function getAuth(): AuthConfig
* {
* return AuthConfig::basic($username, $password);
* }
* }
*/
interface HasAuth
{
/**
* Get authentication configuration
*/
public function getAuth(): AuthConfig;
}

View File

@@ -33,6 +33,8 @@ enum EnvKey: string
case RAPIDMAIL_USERNAME = 'RAPIDMAIL_USERNAME';
case RAPIDMAIL_PASSWORD = 'RAPIDMAIL_PASSWORD';
case RAPIDMAIL_TEST_MODE = 'RAPIDMAIL_TEST_MODE';
case NETCUP_API_KEY = 'NETCUP_API_KEY';
case NETCUP_API_PASSWORD = 'NETCUP_API_PASSWORD';
// OAuth - Spotify
case SPOTIFY_CLIENT_ID = 'SPOTIFY_CLIENT_ID';

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Framework\Console\Animation\Types;
/**
* Direction enum for slide animations
*/
enum SlideDirection: string
{
case LEFT = 'left';
case RIGHT = 'right';
case UP = 'up';
case DOWN = 'down';
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Framework\Console\Ansi;
/**
* Truecolor Style Value Object
*/
final readonly class TruecolorStyle
{
public function __construct(
private ?RgbColor $foreground = null,
private ?RgbColor $background = null,
private ?\App\Framework\Console\ConsoleFormat $format = null
) {}
/**
* Create truecolor style
*/
public static function create(
?RgbColor $foreground = null,
?RgbColor $background = null,
?\App\Framework\Console\ConsoleFormat $format = null
): self
{
return new self($foreground, $background, $format);
}
/**
* Apply style to text with automatic fallback
*/
public function apply(
string $text,
AnsiSequenceGenerator $generator,
TerminalCapabilities $capabilities
): string
{
$codes = [];
if ($this->format !== null) {
$codes[] = $this->format->value;
}
if ($this->foreground !== null) {
if ($capabilities->supportsTruecolor()) {
$codes[] = "38;2;{$this->foreground->r};{$this->foreground->g};{$this->foreground->b}";
} else {
// Fallback to standard color
$fallback = $this->foreground->toNearestColor();
$codes[] = $fallback->value;
}
}
if ($this->background !== null) {
if ($capabilities->supportsTruecolor()) {
$codes[] = "48;2;{$this->background->r};{$this->background->g};{$this->background->b}";
} else {
// Fallback to standard background color
$fallback = $this->background->toNearestBackgroundColor();
$codes[] = $fallback->value;
}
}
if (empty($codes)) {
return $text;
}
$ansi = "\033[" . implode(';', $codes) . 'm';
$reset = ConsoleColor::RESET->toAnsi();
return $ansi . $text . $reset;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Framework\Console\Layout\Components;
/**
* Status type enum
*/
enum StatusType: string
{
case SUCCESS = 'success';
case ERROR = 'error';
case WARNING = 'warning';
case INFO = 'info';
case PENDING = 'pending';
case LOADING = 'loading';
}

View File

@@ -84,6 +84,11 @@ final readonly class InitializerProcessor
if ($returnType === null || $returnType === 'void') {
$this->container->invoker->invoke($discoveredAttribute->className, $methodName->toString());
}
// Handle "self" return type: Replace with the declaring class
elseif ($returnType === 'self') {
$returnType = $discoveredAttribute->className->toString();
$dependencyGraph->addInitializer($returnType, $discoveredAttribute->className, $methodName);
}
// Service-Initializer: Konkreter Return-Type → Zum Dependency-Graph hinzufügen
else {
$dependencyGraph->addInitializer($returnType, $discoveredAttribute->className, $methodName);

View File

@@ -10,7 +10,7 @@ use App\Framework\Core\ValueObjects\Duration;
use App\Framework\DateTime\Clock;
use App\Framework\ErrorAggregation\Storage\ErrorStorageInterface;
use App\Framework\Exception\Core\ErrorSeverity;
use App\Framework\Exception\ErrorHandlerContext;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Queue;
@@ -35,18 +35,18 @@ final readonly class ErrorAggregator implements ErrorAggregatorInterface
}
/**
* Processes a new error from ErrorHandlerContext
* Processes a new error using unified exception pattern
*/
public function processError(ErrorHandlerContext $context): void
public function processError(\Throwable $exception, ExceptionContextProvider $contextProvider, bool $isDebug = false): void
{
try {
$errorEvent = ErrorEvent::fromErrorHandlerContext($context, $this->clock);
$errorEvent = ErrorEvent::fromException($exception, $contextProvider, $this->clock, $isDebug);
$this->processErrorEvent($errorEvent);
} catch (\Throwable $e) {
// Don't let error aggregation break the application
$this->logError("Failed to process error: " . $e->getMessage(), [
'exception' => $e,
'context' => $context->toArray(),
'original_exception' => $exception,
]);
}
}

View File

@@ -59,6 +59,43 @@ final readonly class ErrorEvent
);
}
/**
* Creates ErrorEvent from Exception using ExceptionContextProvider (new unified pattern)
*/
public static function fromException(\Throwable $exception, \App\Framework\ExceptionHandling\Context\ExceptionContextProvider $contextProvider, \App\Framework\DateTime\Clock $clock, bool $isDebug = false): self
{
// Retrieve context from WeakMap
$context = $contextProvider->get($exception);
// Extract ErrorCode if exception implements the interface
$errorCode = self::extractErrorCodeFromException($exception);
// Extract service name from operation or component
$service = self::extractServiceNameFromContext($context);
// Determine severity
$severity = self::determineSeverityFromException($exception, $context, $errorCode);
return new self(
id: new Ulid($clock),
service: $service,
component: $context?->component ?? 'unknown',
operation: $context?->operation ?? 'unknown',
errorCode: $errorCode,
errorMessage: $exception->getMessage(),
severity: $severity,
occurredAt: $context?->occurredAt ?? new \DateTimeImmutable(),
context: $context?->data ?? [],
metadata: $context?->metadata ?? [],
requestId: $context?->requestId,
userId: $context?->userId,
clientIp: $context?->clientIp,
isSecurityEvent: $context?->metadata['security_event'] ?? false,
stackTrace: $isDebug ? $exception->getTraceAsString() : null,
userAgent: $context?->userAgent,
);
}
/**
* Converts to array for storage/transmission
*/
@@ -298,4 +335,74 @@ final readonly class ErrorEvent
return $normalized;
}
/**
* Extract ErrorCode from exception (new unified pattern helper)
*/
private static function extractErrorCodeFromException(\Throwable $exception): ErrorCode
{
// Check if exception implements HasErrorCode interface
if ($exception instanceof \App\Framework\Exception\FrameworkException) {
$errorCode = $exception->getErrorCode();
if ($errorCode !== null) {
return $errorCode;
}
}
// Fallback: Use SystemErrorCode::RESOURCE_EXHAUSTED as generic error
return \App\Framework\Exception\Core\SystemErrorCode::RESOURCE_EXHAUSTED;
}
/**
* Extract service name from ExceptionContextData (new unified pattern helper)
*/
private static function extractServiceNameFromContext(?\App\Framework\ExceptionHandling\Context\ExceptionContextData $context): string
{
if ($context === null) {
return 'web';
}
// Extract from operation if available (e.g., "user.create" → "user")
if ($context->operation !== null && str_contains($context->operation, '.')) {
$parts = explode('.', $context->operation);
return strtolower($parts[0]);
}
// Extract from component if available
if ($context->component !== null) {
return strtolower($context->component);
}
return 'web';
}
/**
* Determine severity from exception, context, and error code (new unified pattern helper)
*/
private static function determineSeverityFromException(
\Throwable $exception,
?\App\Framework\ExceptionHandling\Context\ExceptionContextData $context,
ErrorCode $errorCode
): ErrorSeverity {
// Security events are always critical
if ($context?->metadata['security_event'] ?? false) {
return ErrorSeverity::CRITICAL;
}
// Check explicit severity in metadata
if ($context !== null && isset($context->metadata['severity'])) {
$severity = ErrorSeverity::tryFrom($context->metadata['severity']);
if ($severity !== null) {
return $severity;
}
}
// Get severity from ErrorCode
if (method_exists($errorCode, 'getSeverity')) {
return $errorCode->getSeverity();
}
// Fallback: ERROR for all unhandled exceptions
return ErrorSeverity::ERROR;
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Framework\ErrorBoundaries;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\Timer;
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
use App\Framework\ErrorBoundaries\CircuitBreaker\BoundaryCircuitBreakerManager;
use App\Framework\ErrorBoundaries\Events\BoundaryEventInterface;
use App\Framework\ErrorBoundaries\Events\BoundaryEventPublisher;
@@ -16,6 +17,7 @@ use App\Framework\ErrorBoundaries\Events\BoundaryFallbackExecuted;
use App\Framework\ErrorBoundaries\Events\BoundaryTimeoutOccurred;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\Logging\Logger;
use Throwable;
@@ -34,6 +36,8 @@ final readonly class ErrorBoundary
private ?Logger $logger = null,
private ?BoundaryCircuitBreakerManager $circuitBreakerManager = null,
private ?BoundaryEventPublisher $eventPublisher = null,
private ?ErrorAggregatorInterface $errorAggregator = null,
private ?ExceptionContextProvider $contextProvider = null,
) {
}
@@ -319,6 +323,29 @@ final readonly class ErrorBoundary
{
$this->logFailure($exception, 'Operation failed, executing fallback');
// Dispatch to ErrorAggregator for centralized monitoring
if ($this->errorAggregator !== null && $this->contextProvider !== null) {
try {
// Enrich exception context with boundary metadata
$existingContext = $this->contextProvider->get($exception);
if ($existingContext !== null) {
$enrichedContext = $existingContext->withMetadata([
'error_boundary' => $this->boundaryName,
'boundary_failure' => true,
]);
$this->contextProvider->set($exception, $enrichedContext);
}
// Dispatch to aggregator
$this->errorAggregator->processError($exception, $this->contextProvider, false);
} catch (Throwable $aggregationException) {
// Don't let aggregation failures break boundary resilience
$this->log('warning', 'Error aggregation failed', [
'aggregation_error' => $aggregationException->getMessage(),
]);
}
}
try {
$result = $fallback();

View File

@@ -7,9 +7,11 @@ namespace App\Framework\ErrorBoundaries;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\DateTime\SystemTimer;
use App\Framework\DateTime\Timer;
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
use App\Framework\ErrorBoundaries\CircuitBreaker\BoundaryCircuitBreakerManager;
use App\Framework\ErrorBoundaries\Events\BoundaryEventPublisher;
use App\Framework\EventBus\EventBus;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\Logging\Logger;
use App\Framework\StateManagement\StateManagerFactory;
@@ -25,6 +27,8 @@ final readonly class ErrorBoundaryFactory
private ?Logger $logger = null,
private ?StateManagerFactory $stateManagerFactory = null,
private ?EventBus $eventBus = null,
private ?ErrorAggregatorInterface $errorAggregator = null,
private ?ExceptionContextProvider $contextProvider = null,
array $routeConfigs = []
) {
$this->routeConfigs = array_merge($this->getDefaultRouteConfigs(), $routeConfigs);
@@ -101,6 +105,8 @@ final readonly class ErrorBoundaryFactory
logger: $this->logger,
circuitBreakerManager: $circuitBreakerManager,
eventPublisher: $eventPublisher,
errorAggregator: $this->errorAggregator,
contextProvider: $this->contextProvider,
);
}

View File

@@ -66,6 +66,57 @@ final readonly class ErrorReport
);
}
/**
* Create from Exception with WeakMap context (unified pattern)
*
* @param Throwable $exception Exception to report
* @param \App\Framework\ExceptionHandling\Context\ExceptionContextProvider $contextProvider WeakMap context provider
* @param string $level Error level (error, warning, critical, etc.)
* @param array $additionalContext Additional context to merge with WeakMap context
* @param string|null $environment Environment name (production, staging, etc.)
* @return self
*/
public static function fromException(
Throwable $exception,
\App\Framework\ExceptionHandling\Context\ExceptionContextProvider $contextProvider,
string $level = 'error',
array $additionalContext = [],
?string $environment = null
): self {
// Retrieve context from WeakMap
$context = $contextProvider->get($exception);
// Merge data from WeakMap with additional context
$mergedContext = array_merge($context?->data ?? [], $additionalContext);
return new self(
id: self::generateId(),
timestamp: $context?->occurredAt ?? new DateTimeImmutable(),
level: $level,
message: $exception->getMessage(),
exception: $exception::class,
file: $exception->getFile(),
line: $exception->getLine(),
trace: $exception->getTraceAsString(),
context: $mergedContext,
userId: $context?->userId,
sessionId: $context?->sessionId,
requestId: $context?->requestId,
userAgent: $context?->userAgent,
ipAddress: $context?->clientIp,
tags: $context?->tags ?? [],
environment: $environment ?? 'production',
serverInfo: self::getServerInfo(),
customData: array_merge(
$context?->metadata ?? [],
array_filter([
'operation' => $context?->operation,
'component' => $context?->component,
])
)
);
}
/**
* Create from manual report
*/

View File

@@ -28,7 +28,21 @@ final readonly class ErrorReporter implements ErrorReporterInterface
}
/**
* Report an error from Throwable
* Report an error from Exception with WeakMap context (unified pattern)
*/
public function reportException(
Throwable $exception,
\App\Framework\ExceptionHandling\Context\ExceptionContextProvider $contextProvider,
string $level = 'error',
array $additionalContext = []
): string {
$report = ErrorReport::fromException($exception, $contextProvider, $level, $additionalContext);
return $this->report($report);
}
/**
* Report an error from Throwable (legacy method)
*/
public function reportThrowable(
Throwable $throwable,

View File

@@ -0,0 +1,309 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Context;
use DateTimeImmutable;
/**
* Exception Context Data
*
* Immutable value object containing rich exception context.
* Stored externally via ExceptionContextProvider - never embedded in exceptions.
*
* PHP 8.5+ readonly class with asymmetric visibility for extensibility.
*/
final readonly class ExceptionContextData
{
public readonly DateTimeImmutable $occurredAt;
/**
* @param string|null $operation Operation being performed (e.g., 'user.create', 'payment.process')
* @param string|null $component Component where error occurred (e.g., 'UserService', 'PaymentGateway')
* @param array<string, mixed> $data Domain data (e.g., user_id, order_id, amount)
* @param array<string, mixed> $debug Debug data (queries, traces, internal state)
* @param array<string, mixed> $metadata Additional metadata (tags, severity, fingerprint)
* @param DateTimeImmutable|null $occurredAt When the exception occurred
* @param string|null $userId User ID if available
* @param string|null $requestId Request ID for tracing
* @param string|null $sessionId Session ID if available
* @param string|null $clientIp Client IP address for HTTP requests
* @param string|null $userAgent User agent string for HTTP requests
* @param array<string> $tags Tags for categorization (e.g., ['payment', 'external_api'])
*/
public function __construct(
public ?string $operation = null,
public ?string $component = null,
public array $data = [],
public array $debug = [],
public array $metadata = [],
?DateTimeImmutable $occurredAt = null,
public ?string $userId = null,
public ?string $requestId = null,
public ?string $sessionId = null,
public ?string $clientIp = null,
public ?string $userAgent = null,
public array $tags = [],
) {
$this->occurredAt ??= new DateTimeImmutable();
}
/**
* Create empty context
*/
public static function empty(): self
{
return new self();
}
/**
* Create context with operation
*/
public static function forOperation(string $operation, ?string $component = null): self
{
return new self(
operation: $operation,
component: $component
);
}
/**
* Create context with data
*/
public static function withData(array $data): self
{
return new self(data: $data);
}
/**
* Create new instance with operation
*/
public function withOperation(string $operation, ?string $component = null): self
{
return new self(
operation: $operation,
component: $component ?? $this->component,
data: $this->data,
debug: $this->debug,
metadata: $this->metadata,
occurredAt: $this->occurredAt,
userId: $this->userId,
requestId: $this->requestId,
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: $this->tags
);
}
/**
* Add data to context
*/
public function addData(array $data): self
{
return new self(
operation: $this->operation,
component: $this->component,
data: array_merge($this->data, $data),
debug: $this->debug,
metadata: $this->metadata,
occurredAt: $this->occurredAt,
userId: $this->userId,
requestId: $this->requestId,
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: $this->tags
);
}
/**
* Add debug information
*/
public function addDebug(array $debug): self
{
return new self(
operation: $this->operation,
component: $this->component,
data: $this->data,
debug: array_merge($this->debug, $debug),
metadata: $this->metadata,
occurredAt: $this->occurredAt,
userId: $this->userId,
requestId: $this->requestId,
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: $this->tags
);
}
/**
* Add metadata
*/
public function addMetadata(array $metadata): self
{
return new self(
operation: $this->operation,
component: $this->component,
data: $this->data,
debug: $this->debug,
metadata: array_merge($this->metadata, $metadata),
occurredAt: $this->occurredAt,
userId: $this->userId,
requestId: $this->requestId,
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: $this->tags
);
}
/**
* Add user ID
*/
public function withUserId(string $userId): self
{
return new self(
operation: $this->operation,
component: $this->component,
data: $this->data,
debug: $this->debug,
metadata: $this->metadata,
occurredAt: $this->occurredAt,
userId: $userId,
requestId: $this->requestId,
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: $this->tags
);
}
/**
* Add request ID
*/
public function withRequestId(string $requestId): self
{
return new self(
operation: $this->operation,
component: $this->component,
data: $this->data,
debug: $this->debug,
metadata: $this->metadata,
occurredAt: $this->occurredAt,
userId: $this->userId,
requestId: $requestId,
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: $this->tags
);
}
/**
* Add session ID
*/
public function withSessionId(string $sessionId): self
{
return new self(
operation: $this->operation,
component: $this->component,
data: $this->data,
debug: $this->debug,
metadata: $this->metadata,
occurredAt: $this->occurredAt,
userId: $this->userId,
requestId: $this->requestId,
sessionId: $sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: $this->tags
);
}
/**
* Add client IP
*/
public function withClientIp(string $clientIp): self
{
return new self(
operation: $this->operation,
component: $this->component,
data: $this->data,
debug: $this->debug,
metadata: $this->metadata,
occurredAt: $this->occurredAt,
userId: $this->userId,
requestId: $this->requestId,
sessionId: $this->sessionId,
clientIp: $clientIp,
userAgent: $this->userAgent,
tags: $this->tags
);
}
/**
* Add user agent
*/
public function withUserAgent(string $userAgent): self
{
return new self(
operation: $this->operation,
component: $this->component,
data: $this->data,
debug: $this->debug,
metadata: $this->metadata,
occurredAt: $this->occurredAt,
userId: $this->userId,
requestId: $this->requestId,
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $userAgent,
tags: $this->tags
);
}
/**
* Add tags
*/
public function withTags(string ...$tags): self
{
return new self(
operation: $this->operation,
component: $this->component,
data: $this->data,
debug: $this->debug,
metadata: $this->metadata,
occurredAt: $this->occurredAt,
userId: $this->userId,
requestId: $this->requestId,
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: array_merge($this->tags, $tags)
);
}
/**
* Convert to array for serialization
*/
public function toArray(): array
{
return [
'operation' => $this->operation,
'component' => $this->component,
'data' => $this->data,
'debug' => $this->debug,
'metadata' => $this->metadata,
'occurred_at' => $this->occurredAt?->format('Y-m-d H:i:s.u'),
'user_id' => $this->userId,
'request_id' => $this->requestId,
'session_id' => $this->sessionId,
'client_ip' => $this->clientIp,
'user_agent' => $this->userAgent,
'tags' => $this->tags,
];
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Context;
use WeakMap;
/**
* Exception Context Provider
*
* Manages exception context externally using WeakMap for automatic garbage collection.
* Context is automatically cleaned up when the exception is garbage collected.
*
* PHP 8.5+ WeakMap-based implementation - no memory leaks possible.
*/
final class ExceptionContextProvider
{
/** @var WeakMap<\Throwable, ExceptionContextData> */
private WeakMap $contexts;
private static ?self $instance = null;
private function __construct()
{
$this->contexts = new WeakMap();
}
/**
* Get singleton instance
*
* Singleton pattern ensures consistent context across the application
*/
public static function instance(): self
{
return self::$instance ??= new self();
}
/**
* Attach context to exception
*
* @param \Throwable $exception The exception to attach context to
* @param ExceptionContextData $context The context data
*/
public function attach(\Throwable $exception, ExceptionContextData $context): void
{
$this->contexts[$exception] = $context;
}
/**
* Get context for exception
*
* @param \Throwable $exception The exception to get context for
* @return ExceptionContextData|null The context data or null if not found
*/
public function get(\Throwable $exception): ?ExceptionContextData
{
return $this->contexts[$exception] ?? null;
}
/**
* Check if exception has context
*
* @param \Throwable $exception The exception to check
* @return bool True if context exists
*/
public function has(\Throwable $exception): bool
{
return isset($this->contexts[$exception]);
}
/**
* Remove context from exception
*
* Note: Usually not needed due to WeakMap automatic cleanup,
* but provided for explicit control if needed.
*
* @param \Throwable $exception The exception to remove context from
*/
public function detach(\Throwable $exception): void
{
unset($this->contexts[$exception]);
}
/**
* Get statistics about context storage
*
* @return array{total_contexts: int}
*/
public function getStats(): array
{
// WeakMap doesn't provide count(), so we iterate
$count = 0;
foreach ($this->contexts as $_) {
$count++;
}
return [
'total_contexts' => $count,
];
}
/**
* Clear all contexts
*
* Mainly for testing purposes
*/
public function clear(): void
{
$this->contexts = new WeakMap();
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Framework\ExceptionHandling;
interface ErrorHandlerInterface
{
}

View File

@@ -3,7 +3,10 @@ declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\ExceptionHandling\Renderers\ResponseErrorRenderer;
use App\Framework\ExceptionHandling\Reporter\LogReporter;
use App\Framework\Http\Response;
use Throwable;
final readonly class ErrorKernel
@@ -25,5 +28,28 @@ final readonly class ErrorKernel
return null;
}
/**
* Create HTTP Response from exception without terminating execution
*
* This method enables middleware recovery patterns by returning a Response
* object instead of terminating the application.
*
* @param Throwable $exception Exception to render
* @param ExceptionContextProvider|null $contextProvider Optional WeakMap context provider
* @param bool $isDebugMode Enable debug information in response
* @return Response HTTP Response object (JSON for API, HTML for web)
*/
public function createHttpResponse(
Throwable $exception,
?ExceptionContextProvider $contextProvider = null,
bool $isDebugMode = false
): Response {
// Create ResponseErrorRenderer with debug mode setting
$renderer = new ResponseErrorRenderer($isDebugMode);
// Generate and return Response object
return $renderer->createResponse($exception, $contextProvider);
}
}

View File

@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\DI\Initializer;
use Fiber;
final class ErrorScope
{
private array $stack = [];
#[Initializer]
public static function initialize(): ErrorScope
{
return new self;
}
public function enter(ErrorScopeContext $context): int
{
$id = $this->fiberId();
$this->stack[$id] ??= [];
$this->stack[$id][] = $context;
return count($this->stack[$id]);
}
public function current(): ?ErrorScopeContext
{
$id = $this->fiberId();
$stack = $this->stack[$id] ?? [];
return end($stack) ?? null;
}
public function leave(int $token): void
{
$id = $this->fiberId();
if(!isset($this->stack[$id])) {
return;
}
while(!empty($this->stack[$id]) && count($this->stack[$id]) >= $token) {
array_pop($this->stack[$id]);
}
if(empty($this->stack[$id])) {
unset($this->stack[$id]);
}
}
private function fiberId(): int
{
$fiber = Fiber::getCurrent();
return $fiber ? spl_object_id($fiber) : 0;
}
}

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Factory;
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\ExceptionHandling\Scope\ErrorScope;
use Throwable;
/**
* Exception Factory
*
* Helper for creating exceptions with external context.
* Integrates with ErrorScope for automatic context enrichment.
*
* PHP 8.5+ with WeakMap-based context management.
*/
final readonly class ExceptionFactory
{
public function __construct(
private ExceptionContextProvider $contextProvider,
private ErrorScope $errorScope
) {}
/**
* Create exception with context
*
* @template T of Throwable
* @param class-string<T> $exceptionClass
* @param string $message
* @param ExceptionContextData|null $context
* @param Throwable|null $previous
* @return T
*/
public function create(
string $exceptionClass,
string $message,
?ExceptionContextData $context = null,
?\Throwable $previous = null
): Throwable {
// Create slim exception (pure PHP)
$exception = new $exceptionClass($message, 0, $previous);
// Enrich context from current scope
$enrichedContext = $this->enrichContext($context);
// Attach context externally via WeakMap
$this->contextProvider->attach($exception, $enrichedContext);
return $exception;
}
/**
* Enhance existing exception with context
*
* Useful for rethrowing exceptions with additional context
*
* @param Throwable $exception
* @param ExceptionContextData $additionalContext
* @return Throwable
*/
public function enhance(
Throwable $exception,
ExceptionContextData $additionalContext
): Throwable {
// Get existing context if any
$existingContext = $this->contextProvider->get($exception);
// Merge contexts
$mergedContext = $existingContext
? $existingContext
->addData($additionalContext->data)
->addDebug($additionalContext->debug)
->addMetadata($additionalContext->metadata)
: $additionalContext;
// Enrich from scope
$enrichedContext = $this->enrichContext($mergedContext);
// Update context
$this->contextProvider->attach($exception, $enrichedContext);
return $exception;
}
/**
* Create exception with operation context
*
* Convenience method for common use case
*
* @template T of Throwable
* @param class-string<T> $exceptionClass
* @param string $message
* @param string $operation
* @param string|null $component
* @param array<string, mixed> $data
* @param Throwable|null $previous
* @return T
*/
public function forOperation(
string $exceptionClass,
string $message,
string $operation,
?string $component = null,
array $data = [],
?\Throwable $previous = null
): Throwable {
$context = ExceptionContextData::forOperation($operation, $component)
->addData($data);
return $this->create($exceptionClass, $message, $context, $previous);
}
/**
* Create exception with data
*
* Convenience method for exceptions with data payload
*
* @template T of Throwable
* @param class-string<T> $exceptionClass
* @param string $message
* @param array<string, mixed> $data
* @param Throwable|null $previous
* @return T
*/
public function withData(
string $exceptionClass,
string $message,
array $data,
?\Throwable $previous = null
): Throwable {
$context = ExceptionContextData::withData($data);
return $this->create($exceptionClass, $message, $context, $previous);
}
/**
* Enrich context from current error scope
*
* @param ExceptionContextData|null $context
* @return ExceptionContextData
*/
private function enrichContext(?ExceptionContextData $context): ExceptionContextData
{
$scopeContext = $this->errorScope->current();
if ($scopeContext === null) {
return $context ?? ExceptionContextData::empty();
}
// Start with provided context or empty
$enriched = $context ?? ExceptionContextData::empty();
// Enrich with scope data
$enriched = $enriched
->addMetadata([
'scope_type' => $scopeContext->type->value,
'scope_id' => $scopeContext->scopeId,
]);
// Add operation/component from scope if not already set
if ($enriched->operation === null && $scopeContext->operation !== null) {
$enriched = $enriched->withOperation(
$scopeContext->operation,
$scopeContext->component
);
}
// Add user/request/session IDs from scope
if ($scopeContext->userId !== null) {
$enriched = $enriched->withUserId($scopeContext->userId);
}
if ($scopeContext->requestId !== null) {
$enriched = $enriched->withRequestId($scopeContext->requestId);
}
if ($scopeContext->sessionId !== null) {
$enriched = $enriched->withSessionId($scopeContext->sessionId);
}
// Extract HTTP fields from scope metadata (for HTTP scopes)
if (isset($scopeContext->metadata['ip'])) {
$enriched = $enriched->withClientIp($scopeContext->metadata['ip']);
}
if (isset($scopeContext->metadata['user_agent'])) {
$enriched = $enriched->withUserAgent($scopeContext->metadata['user_agent']);
}
// Add scope tags
if (!empty($scopeContext->tags)) {
$enriched = $enriched->withTags(...$scopeContext->tags);
}
return $enriched;
}
}

View File

@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Renderer;
use App\Framework\ExceptionHandling\ErrorRenderer;
final class HtmlErrorRenderer implements ErrorRenderer
{
public function render(): void
{
echo '<html lang="en"><body><h1>500 Internal Server Error</h1></body></html>';
}
}

View File

@@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Renderers;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\Http\Response;
use App\Framework\Http\Status;
/**
* HTTP Response factory for API and HTML error pages
*
* Extracts Response generation logic from ErrorKernel for reuse
* in middleware recovery patterns.
*/
final readonly class ResponseErrorRenderer
{
public function __construct(
private bool $isDebugMode = false
) {}
/**
* Create HTTP Response from exception
*
* @param \Throwable $exception Exception to render
* @param ExceptionContextProvider|null $contextProvider Optional context provider
* @return Response HTTP Response object
*/
public function createResponse(
\Throwable $exception,
?ExceptionContextProvider $contextProvider = null
): Response {
// Determine if API or HTML response needed
$isApiRequest = $this->isApiRequest();
if ($isApiRequest) {
return $this->createApiResponse($exception, $contextProvider);
}
return $this->createHtmlResponse($exception, $contextProvider);
}
/**
* Create JSON API error response
*/
private function createApiResponse(
\Throwable $exception,
?ExceptionContextProvider $contextProvider
): Response {
$statusCode = $this->getHttpStatusCode($exception);
$errorData = [
'error' => [
'message' => $this->isDebugMode
? $exception->getMessage()
: 'An error occurred while processing your request.',
'type' => $this->isDebugMode ? get_class($exception) : 'ServerError',
'code' => $exception->getCode(),
]
];
// Add debug information if enabled
if ($this->isDebugMode) {
$errorData['error']['file'] = $exception->getFile();
$errorData['error']['line'] = $exception->getLine();
$errorData['error']['trace'] = $this->formatStackTrace($exception);
// Add context from WeakMap if available
if ($contextProvider !== null) {
$context = $contextProvider->get($exception);
if ($context !== null) {
$errorData['context'] = [
'operation' => $context->operation,
'component' => $context->component,
'request_id' => $context->requestId,
'occurred_at' => $context->occurredAt?->format('Y-m-d H:i:s'),
];
}
}
}
$body = json_encode($errorData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
return new Response(
status: Status::from($statusCode),
body: $body,
headers: [
'Content-Type' => 'application/json',
'X-Content-Type-Options' => 'nosniff',
]
);
}
/**
* Create HTML error page response
*/
private function createHtmlResponse(
\Throwable $exception,
?ExceptionContextProvider $contextProvider
): Response {
$statusCode = $this->getHttpStatusCode($exception);
$html = $this->generateErrorHtml(
$exception,
$contextProvider,
$statusCode
);
return new Response(
status: Status::from($statusCode),
body: $html,
headers: [
'Content-Type' => 'text/html; charset=utf-8',
'X-Content-Type-Options' => 'nosniff',
]
);
}
/**
* Generate HTML error page
*/
private function generateErrorHtml(
\Throwable $exception,
?ExceptionContextProvider $contextProvider,
int $statusCode
): string {
$title = $this->getErrorTitle($statusCode);
$message = $this->isDebugMode
? $exception->getMessage()
: 'An error occurred while processing your request.';
$debugInfo = '';
if ($this->isDebugMode) {
$debugInfo = $this->generateDebugSection($exception, $contextProvider);
}
return <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{$title}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background: #f5f5f5;
}
.error-container {
background: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #d32f2f;
margin-top: 0;
}
.error-message {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 1rem;
margin: 1rem 0;
}
.debug-info {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 1rem;
margin-top: 2rem;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
}
.debug-info pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.context-item {
margin: 0.5rem 0;
}
.context-label {
font-weight: bold;
color: #666;
}
</style>
</head>
<body>
<div class="error-container">
<h1>{$title}</h1>
<div class="error-message">
<p>{$message}</p>
</div>
{$debugInfo}
</div>
</body>
</html>
HTML;
}
/**
* Generate debug information section
*/
private function generateDebugSection(
\Throwable $exception,
?ExceptionContextProvider $contextProvider
): string {
$exceptionClass = get_class($exception);
$file = $exception->getFile();
$line = $exception->getLine();
$trace = $this->formatStackTrace($exception);
$contextHtml = '';
if ($contextProvider !== null) {
$context = $contextProvider->get($exception);
if ($context !== null) {
$contextHtml = <<<HTML
<div class="context-item">
<span class="context-label">Operation:</span> {$context->operation}
</div>
<div class="context-item">
<span class="context-label">Component:</span> {$context->component}
</div>
<div class="context-item">
<span class="context-label">Request ID:</span> {$context->requestId}
</div>
<div class="context-item">
<span class="context-label">Occurred At:</span> {$context->occurredAt?->format('Y-m-d H:i:s')}
</div>
HTML;
}
}
return <<<HTML
<div class="debug-info">
<h3>Debug Information</h3>
<div class="context-item">
<span class="context-label">Exception:</span> {$exceptionClass}
</div>
<div class="context-item">
<span class="context-label">File:</span> {$file}:{$line}
</div>
{$contextHtml}
<h4>Stack Trace:</h4>
<pre>{$trace}</pre>
</div>
HTML;
}
/**
* Determine if current request is API request
*/
private function isApiRequest(): bool
{
// Check for JSON Accept header
$acceptHeader = $_SERVER['HTTP_ACCEPT'] ?? '';
if (str_contains($acceptHeader, 'application/json')) {
return true;
}
// Check for API path prefix
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
if (str_starts_with($requestUri, '/api/')) {
return true;
}
// Check for AJAX requests
$requestedWith = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
if (strtolower($requestedWith) === 'xmlhttprequest') {
return true;
}
return false;
}
/**
* Get HTTP status code from exception
*/
private function getHttpStatusCode(\Throwable $exception): int
{
// Use exception code if it's a valid HTTP status code
$code = $exception->getCode();
if ($code >= 400 && $code < 600) {
return $code;
}
// Map common exceptions to status codes
return match (true) {
$exception instanceof \InvalidArgumentException => 400,
$exception instanceof \RuntimeException => 500,
$exception instanceof \LogicException => 500,
default => 500,
};
}
/**
* Get user-friendly error title from status code
*/
private function getErrorTitle(int $statusCode): string
{
return match ($statusCode) {
400 => 'Bad Request',
401 => 'Unauthorized',
403 => 'Forbidden',
404 => 'Not Found',
405 => 'Method Not Allowed',
429 => 'Too Many Requests',
500 => 'Internal Server Error',
502 => 'Bad Gateway',
503 => 'Service Unavailable',
default => "Error {$statusCode}",
};
}
/**
* Format stack trace for display
*/
private function formatStackTrace(\Throwable $exception): string
{
$trace = $exception->getTraceAsString();
// Limit trace depth in production
if (!$this->isDebugMode) {
$lines = explode("\n", $trace);
$trace = implode("\n", array_slice($lines, 0, 5));
}
return htmlspecialchars($trace, ENT_QUOTES, 'UTF-8');
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Scope;
use App\Framework\DI\Initializer;
use Fiber;
/**
* Error Scope Stack Manager
*
* Manages fiber-aware scope stack for error context enrichment.
* Each fiber has its own isolated scope stack.
*
* PHP 8.5+ with fiber isolation and automatic cleanup.
*/
final class ErrorScope
{
/** @var array<int, array<ErrorScopeContext>> Fiber-specific scope stacks */
private array $stack = [];
#[Initializer]
public static function initialize(): self
{
return new self();
}
/**
* Enter a new error scope
*
* @param ErrorScopeContext $context Scope context to enter
* @return int Token for leaving this scope (stack depth)
*/
public function enter(ErrorScopeContext $context): int
{
$id = $this->fiberId();
$this->stack[$id] ??= [];
$this->stack[$id][] = $context;
return count($this->stack[$id]);
}
/**
* Exit error scope(s)
*
* @param int $token Token from enter() - exits all scopes until this depth
*/
public function exit(int $token = 0): void
{
$id = $this->fiberId();
if (!isset($this->stack[$id])) {
return;
}
if ($token === 0) {
// Exit only the most recent scope
array_pop($this->stack[$id]);
} else {
// Exit all scopes until token depth
while (!empty($this->stack[$id]) && count($this->stack[$id]) >= $token) {
array_pop($this->stack[$id]);
}
}
// Cleanup empty stack
if (empty($this->stack[$id])) {
unset($this->stack[$id]);
}
}
/**
* Get current error scope context
*
* @return ErrorScopeContext|null Current scope or null if no scope active
*/
public function current(): ?ErrorScopeContext
{
$id = $this->fiberId();
$stack = $this->stack[$id] ?? [];
$current = end($stack);
return $current !== false ? $current : null;
}
/**
* Check if any scope is active
*/
public function hasScope(): bool
{
$id = $this->fiberId();
return !empty($this->stack[$id]);
}
/**
* Get scope depth (number of nested scopes)
*/
public function depth(): int
{
$id = $this->fiberId();
return count($this->stack[$id] ?? []);
}
/**
* Get fiber ID for isolation
*
* Returns 0 for main fiber, unique ID for each Fiber
*/
private function fiberId(): int
{
$fiber = Fiber::getCurrent();
return $fiber ? spl_object_id($fiber) : 0;
}
/**
* Clear all scopes (for testing/cleanup)
*/
public function clear(): void
{
$this->stack = [];
}
/**
* Get statistics for monitoring
*/
public function getStats(): array
{
return [
'active_fibers' => count($this->stack),
'total_scopes' => array_sum(array_map('count', $this->stack)),
'max_depth' => !empty($this->stack) ? max(array_map('count', $this->stack)) : 0,
];
}
}

View File

@@ -0,0 +1,276 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Scope;
use App\Framework\Http\Request;
/**
* Error Scope Context
*
* Rich context for error scopes (HTTP, Console, Job, CLI, etc.).
* Works with ErrorScope for fiber-aware scope stack management.
*
* PHP 8.5+ readonly class with factory methods for different scope types.
*/
final readonly class ErrorScopeContext
{
/**
* @param ErrorScopeType $type Scope type (HTTP, Console, Job, etc.)
* @param string $scopeId Unique scope identifier
* @param string|null $operation Operation being performed
* @param string|null $component Component executing the operation
* @param array<string, mixed> $metadata Additional metadata
* @param string|null $userId User ID if authenticated
* @param string|null $requestId Request ID for HTTP scopes
* @param string|null $sessionId Session ID if available
* @param string|null $jobId Job ID for background job scopes
* @param string|null $commandName Console command name
* @param array<string> $tags Tags for categorization
*/
public function __construct(
public ErrorScopeType $type,
public string $scopeId,
public ?string $operation = null,
public ?string $component = null,
public array $metadata = [],
public ?string $userId = null,
public ?string $requestId = null,
public ?string $sessionId = null,
public ?string $jobId = null,
public ?string $commandName = null,
public array $tags = [],
) {}
/**
* Create HTTP scope from request
*/
public static function http(
Request $request,
?string $operation = null,
?string $component = null
): self {
return new self(
type: ErrorScopeType::HTTP,
scopeId: $request->headers->getFirst('X-Request-ID')
?? uniqid('http_', true),
operation: $operation,
component: $component,
metadata: [
'method' => $request->method->value,
'path' => $request->path,
'ip' => $request->server->getRemoteAddr(),
'user_agent' => $request->server->getUserAgent(),
],
requestId: $request->headers->getFirst('X-Request-ID'),
sessionId: property_exists($request, 'session') ? $request->session?->getId() : null,
userId: property_exists($request, 'user') ? ($request->user?->id ?? null) : null,
tags: ['http', 'web']
);
}
/**
* Create console scope
*/
public static function console(
string $commandName,
?string $operation = null,
?string $component = null
): self {
return new self(
type: ErrorScopeType::CONSOLE,
scopeId: uniqid('console_', true),
operation: $operation ?? "console.{$commandName}",
component: $component,
metadata: [
'command' => $commandName,
'argv' => $_SERVER['argv'] ?? [],
'cwd' => getcwd(),
],
commandName: $commandName,
tags: ['console', 'cli']
);
}
/**
* Create background job scope
*/
public static function job(
string $jobId,
string $jobClass,
?string $operation = null,
?string $component = null
): self {
return new self(
type: ErrorScopeType::JOB,
scopeId: $jobId,
operation: $operation ?? "job.{$jobClass}",
component: $component ?? $jobClass,
metadata: [
'job_class' => $jobClass,
'job_id' => $jobId,
],
jobId: $jobId,
tags: ['job', 'background', 'async']
);
}
/**
* Create CLI scope
*/
public static function cli(
string $script,
?string $operation = null,
?string $component = null
): self {
return new self(
type: ErrorScopeType::CLI,
scopeId: uniqid('cli_', true),
operation: $operation ?? "cli.{$script}",
component: $component,
metadata: [
'script' => $script,
'argv' => $_SERVER['argv'] ?? [],
],
tags: ['cli', 'script']
);
}
/**
* Create test scope
*/
public static function test(
string $testName,
?string $operation = null,
?string $component = null
): self {
return new self(
type: ErrorScopeType::TEST,
scopeId: uniqid('test_', true),
operation: $operation ?? "test.{$testName}",
component: $component,
metadata: [
'test_name' => $testName,
],
tags: ['test', 'testing']
);
}
/**
* Create generic scope
*/
public static function generic(
string $scopeId,
?string $operation = null,
?string $component = null
): self {
return new self(
type: ErrorScopeType::GENERIC,
scopeId: $scopeId,
operation: $operation,
component: $component,
tags: ['generic']
);
}
/**
* Add operation
*/
public function withOperation(string $operation, ?string $component = null): self
{
return new self(
type: $this->type,
scopeId: $this->scopeId,
operation: $operation,
component: $component ?? $this->component,
metadata: $this->metadata,
userId: $this->userId,
requestId: $this->requestId,
sessionId: $this->sessionId,
jobId: $this->jobId,
commandName: $this->commandName,
tags: $this->tags
);
}
/**
* Add metadata
*/
public function addMetadata(array $metadata): self
{
return new self(
type: $this->type,
scopeId: $this->scopeId,
operation: $this->operation,
component: $this->component,
metadata: array_merge($this->metadata, $metadata),
userId: $this->userId,
requestId: $this->requestId,
sessionId: $this->sessionId,
jobId: $this->jobId,
commandName: $this->commandName,
tags: $this->tags
);
}
/**
* Add user ID
*/
public function withUserId(string $userId): self
{
return new self(
type: $this->type,
scopeId: $this->scopeId,
operation: $this->operation,
component: $this->component,
metadata: $this->metadata,
userId: $userId,
requestId: $this->requestId,
sessionId: $this->sessionId,
jobId: $this->jobId,
commandName: $this->commandName,
tags: $this->tags
);
}
/**
* Add tags
*/
public function withTags(string ...$tags): self
{
return new self(
type: $this->type,
scopeId: $this->scopeId,
operation: $this->operation,
component: $this->component,
metadata: $this->metadata,
userId: $this->userId,
requestId: $this->requestId,
sessionId: $this->sessionId,
jobId: $this->jobId,
commandName: $this->commandName,
tags: array_merge($this->tags, $tags)
);
}
/**
* Convert to array
*/
public function toArray(): array
{
return [
'type' => $this->type->value,
'scope_id' => $this->scopeId,
'operation' => $this->operation,
'component' => $this->component,
'metadata' => $this->metadata,
'user_id' => $this->userId,
'request_id' => $this->requestId,
'session_id' => $this->sessionId,
'job_id' => $this->jobId,
'command_name' => $this->commandName,
'tags' => $this->tags,
];
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Scope;
/**
* Error Scope Type
*
* Defines the different types of error scopes in the application.
*/
enum ErrorScopeType: string
{
case HTTP = 'http';
case CONSOLE = 'console';
case JOB = 'job';
case CLI = 'cli';
case TEST = 'test';
case GENERIC = 'generic';
/**
* Check if scope is web-based
*/
public function isWeb(): bool
{
return $this === self::HTTP;
}
/**
* Check if scope is CLI-based
*/
public function isCli(): bool
{
return match ($this) {
self::CONSOLE, self::CLI => true,
default => false
};
}
/**
* Check if scope is async/background
*/
public function isAsync(): bool
{
return $this === self::JOB;
}
/**
* Check if scope is for testing
*/
public function isTest(): bool
{
return $this === self::TEST;
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Framework\ExceptionHandling;
interface ShutdownHandlerInterface
{
public function handle(): void;
}

View File

@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Console;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ExitCode;
use App\Framework\Process\Services\AlertService;
/**
* Alert Console Commands.
*/
final readonly class AlertCommands
{
public function __construct(
private AlertService $alertService
) {
}
#[ConsoleCommand('alert:check', 'Check all active alerts')]
public function check(ConsoleInput $input): int
{
echo "Checking system alerts...\n\n";
$report = $this->alertService->checkAlerts();
echo "╔════════════════════════════════════════════════════════════╗\n";
echo "║ ALERT CHECK REPORT ║\n";
echo "╚════════════════════════════════════════════════════════════╝\n\n";
echo "┌─ SUMMARY ────────────────────────────────────────────────┐\n";
$counts = $report->getSeverityCounts();
echo "│ Total Alerts: " . count($report->alerts) . "\n";
echo "│ Active Alerts: " . count($report->getActiveAlerts()) . "\n";
echo "│ Critical Alerts: {$counts['critical']}\n";
echo "│ Warning Alerts: {$counts['warning']}\n";
echo "│ Info Alerts: {$counts['info']}\n";
echo "│ Generated At: {$report->generatedAt->format('Y-m-d H:i:s')}\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
$activeAlerts = $report->getActiveAlerts();
if (empty($activeAlerts)) {
echo "✅ No active alerts!\n";
return ExitCode::SUCCESS;
}
// Group by severity
$criticalAlerts = $report->getCriticalAlerts();
$warningAlerts = $report->getWarningAlerts();
if (! empty($criticalAlerts)) {
echo "┌─ CRITICAL ALERTS ───────────────────────────────────────┐\n";
foreach ($criticalAlerts as $alert) {
echo "{$alert->severity->getIcon()} {$alert->name}\n";
echo "{$alert->message}\n";
if ($alert->description !== null) {
echo "{$alert->description}\n";
}
if ($alert->triggeredAt !== null) {
echo "│ Triggered: {$alert->triggeredAt->format('Y-m-d H:i:s')}\n";
}
echo "\n";
}
echo "└─────────────────────────────────────────────────────────┘\n\n";
}
if (! empty($warningAlerts)) {
echo "┌─ WARNING ALERTS ────────────────────────────────────────┐\n";
foreach ($warningAlerts as $alert) {
echo "{$alert->severity->getIcon()} {$alert->name}\n";
echo "{$alert->message}\n";
if ($alert->description !== null) {
echo "{$alert->description}\n";
}
if ($alert->triggeredAt !== null) {
echo "│ Triggered: {$alert->triggeredAt->format('Y-m-d H:i:s')}\n";
}
echo "\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
}
if ($report->hasCriticalAlerts()) {
return ExitCode::FAILURE;
}
if (! empty($warningAlerts)) {
return ExitCode::WARNING;
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('alert:list', 'Show alert history')]
public function list(ConsoleInput $input): int
{
$limit = (int) ($input->getOption('limit') ?? 50);
echo "Retrieving alert history (limit: {$limit})...\n\n";
$report = $this->alertService->checkAlerts();
$allAlerts = $report->alerts;
// Sort by triggered date (newest first)
usort($allAlerts, function ($a, $b) {
if ($a->triggeredAt === null && $b->triggeredAt === null) {
return 0;
}
if ($a->triggeredAt === null) {
return 1;
}
if ($b->triggeredAt === null) {
return -1;
}
return $b->triggeredAt <=> $a->triggeredAt;
});
$displayAlerts = array_slice($allAlerts, 0, $limit);
echo "╔════════════════════════════════════════════════════════════╗\n";
echo "║ ALERT HISTORY ║\n";
echo "╚════════════════════════════════════════════════════════════╝\n\n";
if (empty($displayAlerts)) {
echo " No alerts found.\n";
return ExitCode::SUCCESS;
}
echo "┌─ ALERTS ──────────────────────────────────────────────────┐\n";
foreach ($displayAlerts as $alert) {
$statusIcon = $alert->isActive ? '🔴' : '⚪';
echo "{$statusIcon} {$alert->severity->getIcon()} {$alert->name}\n";
echo "{$alert->message}\n";
if ($alert->triggeredAt !== null) {
echo "│ Triggered: {$alert->triggeredAt->format('Y-m-d H:i:s')}\n";
}
echo "\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
return ExitCode::SUCCESS;
}
#[ConsoleCommand('alert:config', 'Configure alert thresholds')]
public function config(ConsoleInput $input): int
{
echo "╔════════════════════════════════════════════════════════════╗\n";
echo "║ ALERT CONFIGURATION ║\n";
echo "╚════════════════════════════════════════════════════════════╝\n\n";
$defaultThresholds = AlertService::getDefaultThresholds();
echo "┌─ DEFAULT THRESHOLDS ──────────────────────────────────────┐\n";
foreach ($defaultThresholds as $threshold) {
echo "{$threshold->name}\n";
echo "│ Warning: {$threshold->warningThreshold} {$threshold->unit}\n";
echo "│ Critical: {$threshold->criticalThreshold} {$threshold->unit}\n";
if ($threshold->description !== null) {
echo "│ Description: {$threshold->description}\n";
}
echo "\n";
}
echo "└─────────────────────────────────────────────────────────┘\n\n";
echo " Alert thresholds are currently configured in code.\n";
echo " To customize thresholds, modify AlertService::getDefaultThresholds()\n";
return ExitCode::SUCCESS;
}
#[ConsoleCommand('alert:test', 'Test alert system')]
public function test(ConsoleInput $input): int
{
echo "Testing alert system...\n\n";
$report = $this->alertService->checkAlerts();
echo "┌─ TEST RESULTS ────────────────────────────────────────────┐\n";
echo "│ Alert System: ✅ Operational\n";
echo "│ Health Checks: ✅ Connected\n";
echo "│ Active Alerts: " . count($report->getActiveAlerts()) . "\n";
echo "│ Critical Alerts: " . count($report->getCriticalAlerts()) . "\n";
echo "│ Warning Alerts: " . count($report->getWarningAlerts()) . "\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
if ($report->hasActiveAlerts()) {
echo "⚠️ Active alerts detected. Run 'alert:check' for details.\n";
return ExitCode::WARNING;
}
echo "✅ No active alerts. System is healthy.\n";
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,325 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Console;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ExitCode;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Process\Services\BackupService;
use App\Framework\Process\Services\BackupVerificationService;
/**
* Backup Console Commands.
*/
final readonly class BackupCommands
{
public function __construct(
private BackupVerificationService $backupVerification,
private BackupService $backupService
) {
}
#[ConsoleCommand('backup:list', 'List all backup files in a directory')]
public function list(ConsoleInput $input): int
{
$directory = $input->getArgument('directory');
if ($directory === null) {
echo "❌ Please provide a directory path.\n";
echo "Usage: php console.php backup:list <directory> [--pattern=*.sql]\n";
return ExitCode::FAILURE;
}
try {
$dir = FilePath::create($directory);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid directory path: {$directory}\n";
echo "Error: {$e->getMessage()}\n";
return ExitCode::FAILURE;
}
if (! $dir->exists() || ! $dir->isDirectory()) {
echo "❌ Directory does not exist or is not a directory: {$directory}\n";
return ExitCode::FAILURE;
}
$pattern = $input->getOption('pattern') ?? '*.sql';
echo "Searching for backup files matching '{$pattern}' in: {$dir->toString()}\n\n";
$result = $this->backupVerification->verify($dir, $pattern);
if (empty($result->backups)) {
echo " No backup files found matching pattern '{$pattern}'.\n";
return ExitCode::SUCCESS;
}
echo "╔════════════════════════════════════════════════════════════╗\n";
echo "║ BACKUP FILES ║\n";
echo "╚════════════════════════════════════════════════════════════╝\n\n";
echo "┌─ SUMMARY ───────────────────────────────────────────────┐\n";
echo "│ Total Backups: {$result->totalCount}\n";
echo "│ Fresh Backups: {$result->getFreshBackupCount()}\n";
echo "│ Old Backups: {$result->getOldBackupCount()}\n";
if ($result->latestBackupDate !== null) {
echo "│ Latest Backup: {$result->latestBackupDate->format('Y-m-d H:i:s')}\n";
}
echo "└─────────────────────────────────────────────────────────┘\n\n";
echo "┌─ BACKUP FILES ────────────────────────────────────────────┐\n";
foreach ($result->backups as $backup) {
$age = $backup->getAge()->toHumanReadable();
$freshIcon = $backup->isFresh() ? '✅' : '⏰';
echo "{$freshIcon} {$backup->name}\n";
echo "│ Size: {$backup->size->toHumanReadable()}\n";
echo "│ Created: {$backup->createdAt->format('Y-m-d H:i:s')}\n";
echo "│ Age: {$age}\n";
echo "│ Path: {$backup->path->toString()}\n";
echo "\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
return ExitCode::SUCCESS;
}
#[ConsoleCommand('backup:verify', 'Verify backup files in a directory')]
public function verify(ConsoleInput $input): int
{
$directory = $input->getArgument('directory');
if ($directory === null) {
echo "❌ Please provide a directory path.\n";
echo "Usage: php console.php backup:verify <directory> [--pattern=*.sql]\n";
return ExitCode::FAILURE;
}
try {
$dir = FilePath::create($directory);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid directory path: {$directory}\n";
echo "Error: {$e->getMessage()}\n";
return ExitCode::FAILURE;
}
if (! $dir->exists() || ! $dir->isDirectory()) {
echo "❌ Directory does not exist or is not a directory: {$directory}\n";
return ExitCode::FAILURE;
}
$pattern = $input->getOption('pattern') ?? '*.sql';
echo "Verifying backup files matching '{$pattern}' in: {$dir->toString()}\n\n";
$result = $this->backupVerification->verify($dir, $pattern);
echo "╔════════════════════════════════════════════════════════════╗\n";
echo "║ BACKUP VERIFICATION ║\n";
echo "╚════════════════════════════════════════════════════════════╝\n\n";
echo "┌─ VERIFICATION RESULTS ───────────────────────────────────┐\n";
echo "│ Total Backups: {$result->totalCount}\n";
echo "│ Fresh Backups: {$result->getFreshBackupCount()}\n";
echo "│ Old Backups: {$result->getOldBackupCount()}\n";
if ($result->latestBackupDate !== null) {
$latestAge = (new \DateTimeImmutable())->getTimestamp() - $result->latestBackupDate->getTimestamp();
$latestAgeDays = (int) floor($latestAge / 86400);
echo "│ Latest Backup: {$result->latestBackupDate->format('Y-m-d H:i:s')} ({$latestAgeDays} days ago)\n";
}
if ($result->hasFreshBackup()) {
echo "│ Status: ✅ Fresh backups available\n";
} elseif ($result->latestBackupDate !== null) {
echo "│ Status: ⚠️ No fresh backups (latest is older than 24h)\n";
} else {
echo "│ Status: ❌ No backups found\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
if (empty($result->backups)) {
return ExitCode::FAILURE;
}
if (! $result->hasFreshBackup()) {
return ExitCode::WARNING;
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('backup:check', 'Check integrity of a backup file')]
public function check(ConsoleInput $input): int
{
$filePath = $input->getArgument('file');
if ($filePath === null) {
echo "❌ Please provide a backup file path.\n";
echo "Usage: php console.php backup:check <file>\n";
return ExitCode::FAILURE;
}
try {
$file = FilePath::create($filePath);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid file path: {$filePath}\n";
echo "Error: {$e->getMessage()}\n";
return ExitCode::FAILURE;
}
if (! $file->exists() || ! $file->isFile()) {
echo "❌ File does not exist or is not a file: {$filePath}\n";
return ExitCode::FAILURE;
}
// Create BackupFile from path
$size = $file->getSize();
$modifiedTime = $file->getModifiedTime();
$backupFile = new \App\Framework\Process\ValueObjects\Backup\BackupFile(
path: $file,
size: $size,
createdAt: new \DateTimeImmutable('@' . $modifiedTime),
name: $file->getFilename()
);
echo "Checking integrity of: {$file->toString()}\n\n";
$isValid = $this->backupVerification->checkIntegrity($backupFile);
echo "┌─ INTEGRITY CHECK ──────────────────────────────────────┐\n";
echo "│ File: {$backupFile->name}\n";
echo "│ Size: {$backupFile->size->toHumanReadable()}\n";
echo "│ Created: {$backupFile->createdAt->format('Y-m-d H:i:s')}\n";
if ($isValid) {
echo "│ Integrity: ✅ File is valid\n";
} else {
echo "│ Integrity: ❌ File is corrupted or invalid\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
return $isValid ? ExitCode::SUCCESS : ExitCode::FAILURE;
}
#[ConsoleCommand('backup:create', 'Create a backup (database, files, or full)')]
public function create(ConsoleInput $input): int
{
$type = $input->getOption('type') ?? 'database';
echo "Creating {$type} backup...\n\n";
return match ($type) {
'database' => $this->createDatabaseBackup($input),
'files' => $this->createFileBackup($input),
'full' => $this->createFullBackup($input),
default => ExitCode::FAILURE,
};
}
private function createDatabaseBackup(ConsoleInput $input): int
{
$database = $input->getOption('database') ?? 'default';
$username = $input->getOption('username') ?? 'root';
$password = $input->getOption('password') ?? '';
$output = $input->getOption('output');
if ($output === null) {
$output = sys_get_temp_dir() . "/backup_{$database}_" . date('Y-m-d_H-i-s') . '.sql';
}
try {
$outputFile = FilePath::create($output);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid output file: {$output}\n";
return ExitCode::FAILURE;
}
if ($this->backupService->createDatabaseBackup($database, $username, $password, $outputFile)) {
echo "✅ Database backup created: {$outputFile->toString()}\n";
return ExitCode::SUCCESS;
}
echo "❌ Failed to create database backup.\n";
return ExitCode::FAILURE;
}
private function createFileBackup(ConsoleInput $input): int
{
$source = $input->getOption('source') ?? '/var/www';
$output = $input->getOption('output');
if ($output === null) {
$output = sys_get_temp_dir() . '/files_backup_' . date('Y-m-d_H-i-s') . '.tar.gz';
}
try {
$sourceDir = FilePath::create($source);
$outputFile = FilePath::create($output);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid path: {$e->getMessage()}\n";
return ExitCode::FAILURE;
}
if ($this->backupService->createFileBackup($sourceDir, $outputFile)) {
echo "✅ File backup created: {$outputFile->toString()}\n";
return ExitCode::SUCCESS;
}
echo "❌ Failed to create file backup.\n";
return ExitCode::FAILURE;
}
private function createFullBackup(ConsoleInput $input): int
{
$database = $input->getOption('database') ?? 'default';
$username = $input->getOption('username') ?? 'root';
$password = $input->getOption('password') ?? '';
$source = $input->getOption('source') ?? '/var/www';
$output = $input->getOption('output') ?? sys_get_temp_dir();
try {
$sourceDir = FilePath::create($source);
$outputDir = FilePath::create($output);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid path: {$e->getMessage()}\n";
return ExitCode::FAILURE;
}
if ($this->backupService->createFullBackup($database, $username, $password, $sourceDir, $outputDir)) {
echo "✅ Full backup created in: {$outputDir->toString()}\n";
return ExitCode::SUCCESS;
}
echo "❌ Failed to create full backup.\n";
return ExitCode::FAILURE;
}
}

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Console;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ExitCode;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Url\Url;
use App\Framework\Process\Services\SystemHealthCheckService;
use App\Framework\Process\Services\UrlHealthCheckService;
/**
* Health Console Commands.
*/
final readonly class HealthCommands
{
public function __construct(
private SystemHealthCheckService $systemHealthCheck,
private UrlHealthCheckService $urlHealthCheck
) {
}
#[ConsoleCommand('health:check', 'Run system health check')]
public function check(ConsoleInput $input): int
{
echo "Running system health check...\n\n";
$report = ($this->systemHealthCheck)();
echo "╔════════════════════════════════════════════════════════════╗\n";
echo "║ SYSTEM HEALTH CHECK ║\n";
echo "╚════════════════════════════════════════════════════════════╝\n\n";
$overallStatus = $report->overallStatus;
echo "┌─ OVERALL STATUS ────────────────────────────────────────┐\n";
$statusIcon = match ($overallStatus->value) {
'healthy' => '✅',
'degraded' => '⚠️',
'unhealthy' => '❌',
default => '❓',
};
echo "│ Status: {$statusIcon} {$overallStatus->value}\n";
echo "│ Description: {$overallStatus->getDescription()}\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
echo "┌─ HEALTH CHECKS ─────────────────────────────────────────┐\n";
foreach ($report->checks as $check) {
$icon = match ($check->status->value) {
'healthy' => '✅',
'degraded' => '⚠️',
'unhealthy' => '❌',
default => '❓',
};
echo "{$icon} {$check->name}\n";
echo "{$check->message}\n";
echo "│ Value: {$check->value} {$check->unit} (Threshold: {$check->threshold} {$check->unit})\n";
echo "\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
if (! empty($report->getUnhealthyChecks())) {
return ExitCode::FAILURE;
}
if (! empty($report->getDegradedChecks())) {
return ExitCode::WARNING;
}
return ExitCode::SUCCESS->value;
}
#[ConsoleCommand('health:url', 'Check health of a single URL')]
public function url(ConsoleInput $input): int
{
$urlString = $input->getArgument('url');
if ($urlString === null) {
echo "❌ Please provide a URL to check.\n";
echo "Usage: php console.php health:url <url> [--timeout=5]\n";
return ExitCode::FAILURE;
}
try {
$url = Url::parse($urlString);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid URL: {$urlString}\n";
echo "Error: {$e->getMessage()}\n";
return ExitCode::FAILURE;
}
$timeoutSeconds = (int) ($input->getOption('timeout') ?? 5);
$timeout = Duration::fromSeconds($timeoutSeconds);
echo "Checking URL: {$url->toString()}...\n\n";
$result = $this->urlHealthCheck->checkUrl($url, $timeout);
echo "┌─ URL HEALTH CHECK ──────────────────────────────────────┐\n";
echo "│ URL: {$result->url->toString()}\n";
if ($result->isAccessible) {
$statusIcon = $result->isSuccessful() ? '✅' : '⚠️';
echo "│ Status: {$statusIcon} {$result->status->value} {$result->status->getDescription()}\n";
echo "│ Response Time: {$result->responseTime->toHumanReadable()}\n";
if ($result->redirectUrl !== null) {
echo "│ Redirect: → {$result->redirectUrl->toString()}\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
return $result->isSuccessful() ? ExitCode::SUCCESS : ExitCode::WARNING;
}
echo "│ Status: ❌ Not accessible\n";
echo "│ Error: {$result->error}\n";
echo "└─────────────────────────────────────────────────────────┘\n";
return ExitCode::FAILURE;
}
#[ConsoleCommand('health:urls', 'Check health of multiple URLs')]
public function urls(ConsoleInput $input): int
{
$urlsOption = $input->getOption('urls');
if ($urlsOption === null) {
echo "❌ Please provide URLs to check.\n";
echo "Usage: php console.php health:urls --urls=https://example.com,https://google.com [--timeout=5]\n";
return ExitCode::FAILURE;
}
$urlStrings = explode(',', $urlsOption);
$timeoutSeconds = (int) ($input->getOption('timeout') ?? 5);
$timeout = Duration::fromSeconds($timeoutSeconds);
$urls = [];
foreach ($urlStrings as $urlString) {
$urlString = trim($urlString);
if (empty($urlString)) {
continue;
}
try {
$urls[] = Url::parse($urlString);
} catch (\InvalidArgumentException $e) {
echo "⚠️ Invalid URL skipped: {$urlString}\n";
}
}
if (empty($urls)) {
echo "❌ No valid URLs provided.\n";
return ExitCode::FAILURE;
}
echo "Checking " . count($urls) . " URL(s)...\n\n";
$results = $this->urlHealthCheck->checkMultipleUrls($urls, $timeout);
echo "┌─ URL HEALTH CHECKS ─────────────────────────────────────┐\n";
$allSuccessful = true;
foreach ($results as $result) {
if ($result->isAccessible) {
$statusIcon = $result->isSuccessful() ? '✅' : '⚠️';
echo "{$statusIcon} {$result->url->toString()}\n";
echo "│ Status: {$result->status->value} ({$result->responseTime->toHumanReadable()})\n";
if (! $result->isSuccessful()) {
$allSuccessful = false;
}
} else {
echo "│ ❌ {$result->url->toString()}\n";
echo "│ Error: {$result->error}\n";
$allSuccessful = false;
}
echo "\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
return $allSuccessful ? ExitCode::SUCCESS : ExitCode::FAILURE;
}
#[ConsoleCommand('health:services', 'Check health of common internet services')]
public function services(ConsoleInput $input): int
{
echo "Checking common internet services...\n\n";
$results = $this->urlHealthCheck->checkCommonServices();
echo "┌─ COMMON SERVICES HEALTH CHECK ──────────────────────────┐\n";
$allSuccessful = true;
foreach ($results as $result) {
if ($result->isAccessible) {
$statusIcon = $result->isSuccessful() ? '✅' : '⚠️';
echo "{$statusIcon} {$result->url->toString()}\n";
echo "│ Status: {$result->status->value} ({$result->responseTime->toHumanReadable()})\n";
if (! $result->isSuccessful()) {
$allSuccessful = false;
}
} else {
echo "│ ❌ {$result->url->toString()}\n";
echo "│ Error: {$result->error}\n";
$allSuccessful = false;
}
echo "\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
return $allSuccessful ? ExitCode::SUCCESS : ExitCode::FAILURE;
}
}

View File

@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Console;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ExitCode;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Process\Services\LogAnalysisService;
/**
* Log Console Commands.
*/
final readonly class LogCommands
{
public function __construct(
private LogAnalysisService $logAnalysis
) {
}
#[ConsoleCommand('log:tail', 'Display last N lines of a log file')]
public function tail(ConsoleInput $input): int
{
$filePath = $input->getArgument('file');
if ($filePath === null) {
echo "❌ Please provide a log file path.\n";
echo "Usage: php console.php log:tail <file> [--lines=100]\n";
return ExitCode::FAILURE;
}
try {
$file = FilePath::create($filePath);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid file path: {$filePath}\n";
echo "Error: {$e->getMessage()}\n";
return ExitCode::FAILURE;
}
if (! $file->exists() || ! $file->isFile()) {
echo "❌ File does not exist or is not a file: {$filePath}\n";
return ExitCode::FAILURE;
}
$lines = (int) ($input->getOption('lines') ?? 100);
echo "Showing last {$lines} lines of: {$file->toString()}\n\n";
echo "--- LOG OUTPUT ---\n\n";
$output = $this->logAnalysis->tail($file, $lines);
echo $output;
if (empty($output)) {
echo "(No content)\n";
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('log:errors', 'Find errors in a log file')]
public function errors(ConsoleInput $input): int
{
$filePath = $input->getArgument('file');
if ($filePath === null) {
echo "❌ Please provide a log file path.\n";
echo "Usage: php console.php log:errors <file> [--lines=1000]\n";
return ExitCode::FAILURE;
}
try {
$file = FilePath::create($filePath);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid file path: {$filePath}\n";
echo "Error: {$e->getMessage()}\n";
return ExitCode::FAILURE;
}
if (! $file->exists() || ! $file->isFile()) {
echo "❌ File does not exist or is not a file: {$filePath}\n";
return ExitCode::FAILURE;
}
$lines = (int) ($input->getOption('lines') ?? 1000);
echo "Searching for errors in: {$file->toString()} (last {$lines} lines)...\n\n";
$result = $this->logAnalysis->findErrors($file, $lines);
if (empty($result->entries)) {
echo "✅ No errors found!\n";
return ExitCode::SUCCESS;
}
echo "┌─ ERRORS FOUND ──────────────────────────────────────────┐\n";
echo "│ Total Errors: {$result->getErrorCount()}\n";
echo "│ Total Lines: {$result->totalLines}\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
echo "Error Entries:\n";
foreach ($result->entries as $entry) {
$timestamp = $entry->timestamp?->format('Y-m-d H:i:s') ?? 'N/A';
echo " [{$timestamp}] {$entry->level}: {$entry->message}\n";
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('log:warnings', 'Find warnings in a log file')]
public function warnings(ConsoleInput $input): int
{
$filePath = $input->getArgument('file');
if ($filePath === null) {
echo "❌ Please provide a log file path.\n";
echo "Usage: php console.php log:warnings <file> [--lines=1000]\n";
return ExitCode::FAILURE;
}
try {
$file = FilePath::create($filePath);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid file path: {$filePath}\n";
echo "Error: {$e->getMessage()}\n";
return ExitCode::FAILURE;
}
if (! $file->exists() || ! $file->isFile()) {
echo "❌ File does not exist or is not a file: {$filePath}\n";
return ExitCode::FAILURE;
}
$lines = (int) ($input->getOption('lines') ?? 1000);
echo "Searching for warnings in: {$file->toString()} (last {$lines} lines)...\n\n";
$result = $this->logAnalysis->findWarnings($file, $lines);
if (empty($result->entries)) {
echo "✅ No warnings found!\n";
return ExitCode::SUCCESS;
}
echo "┌─ WARNINGS FOUND ────────────────────────────────────────┐\n";
echo "│ Total Warnings: {$result->getWarningCount()}\n";
echo "│ Total Lines: {$result->totalLines}\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
echo "Warning Entries:\n";
foreach ($result->entries as $entry) {
$timestamp = $entry->timestamp?->format('Y-m-d H:i:s') ?? 'N/A';
echo " [{$timestamp}] {$entry->level}: {$entry->message}\n";
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('log:search', 'Search for a pattern in a log file')]
public function search(ConsoleInput $input): int
{
$filePath = $input->getArgument('file');
$pattern = $input->getArgument('pattern');
if ($filePath === null || $pattern === null) {
echo "❌ Please provide a log file path and search pattern.\n";
echo "Usage: php console.php log:search <file> <pattern> [--lines=1000]\n";
return ExitCode::FAILURE;
}
try {
$file = FilePath::create($filePath);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid file path: {$filePath}\n";
echo "Error: {$e->getMessage()}\n";
return ExitCode::FAILURE;
}
if (! $file->exists() || ! $file->isFile()) {
echo "❌ File does not exist or is not a file: {$filePath}\n";
return ExitCode::FAILURE;
}
$lines = (int) ($input->getOption('lines') ?? 1000);
echo "Searching for '{$pattern}' in: {$file->toString()} (last {$lines} lines)...\n\n";
$result = $this->logAnalysis->search($file, $pattern, $lines);
if (empty($result->entries)) {
echo " No matches found for pattern: {$pattern}\n";
return ExitCode::SUCCESS;
}
echo "┌─ SEARCH RESULTS ────────────────────────────────────────┐\n";
echo "│ Pattern: {$pattern}\n";
echo "│ Matches: " . count($result->entries) . "\n";
echo "│ Total Lines: {$result->totalLines}\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
echo "Matching Entries:\n";
foreach ($result->entries as $entry) {
$timestamp = $entry->timestamp?->format('Y-m-d H:i:s') ?? 'N/A';
echo " [{$timestamp}] {$entry->level}: {$entry->message}\n";
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('log:stats', 'Show statistics for a log file')]
public function stats(ConsoleInput $input): int
{
$filePath = $input->getArgument('file');
if ($filePath === null) {
echo "❌ Please provide a log file path.\n";
echo "Usage: php console.php log:stats <file> [--lines=1000]\n";
return ExitCode::FAILURE;
}
try {
$file = FilePath::create($filePath);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid file path: {$filePath}\n";
echo "Error: {$e->getMessage()}\n";
return ExitCode::FAILURE;
}
if (! $file->exists() || ! $file->isFile()) {
echo "❌ File does not exist or is not a file: {$filePath}\n";
return ExitCode::FAILURE;
}
$lines = (int) ($input->getOption('lines') ?? 1000);
echo "Analyzing log file: {$file->toString()} (last {$lines} lines)...\n\n";
$stats = $this->logAnalysis->getStatistics($file, $lines);
echo "╔════════════════════════════════════════════════════════════╗\n";
echo "║ LOG STATISTICS ║\n";
echo "╚════════════════════════════════════════════════════════════╝\n\n";
echo "┌─ OVERVIEW ──────────────────────────────────────────────┐\n";
echo "│ Total Lines: {$stats['total_lines']}\n";
echo "│ Errors: {$stats['error_count']}\n";
echo "│ Warnings: {$stats['warning_count']}\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
if (! empty($stats['level_distribution'])) {
echo "┌─ LEVEL DISTRIBUTION ───────────────────────────────────┐\n";
foreach ($stats['level_distribution'] as $level => $count) {
echo "{$level}: {$count}\n";
}
echo "└─────────────────────────────────────────────────────────┘\n\n";
}
if (! empty($stats['top_errors'])) {
echo "┌─ TOP ERRORS ──────────────────────────────────────────┐\n";
$rank = 1;
foreach ($stats['top_errors'] as $message => $count) {
echo "{$rank}. ({$count}x) {$message}\n";
$rank++;
}
echo "└─────────────────────────────────────────────────────────┘\n";
}
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Console;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ExitCode;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Process\Services\MaintenanceService;
/**
* Maintenance Console Commands.
*/
final readonly class MaintenanceCommands
{
public function __construct(
private MaintenanceService $maintenance
) {
}
#[ConsoleCommand('maintenance:clean-temp', 'Clean old temporary files')]
public function cleanTemp(ConsoleInput $input): int
{
$days = (int) ($input->getOption('days') ?? 7);
$olderThan = Duration::fromDays($days);
echo "Cleaning temporary files older than {$days} days...\n";
$deleted = $this->maintenance->cleanTempFiles($olderThan);
if ($deleted > 0) {
echo "✅ Cleaned {$deleted} temporary file(s).\n";
return ExitCode::SUCCESS;
}
echo " No temporary files to clean.\n";
return ExitCode::SUCCESS;
}
#[ConsoleCommand('maintenance:clean-logs', 'Clean old log files')]
public function cleanLogs(ConsoleInput $input): int
{
$directory = $input->getArgument('directory') ?? '/var/log';
$days = (int) ($input->getOption('days') ?? 30);
try {
$logDir = FilePath::create($directory);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid directory: {$directory}\n";
return ExitCode::FAILURE;
}
$olderThan = Duration::fromDays($days);
echo "Cleaning log files older than {$days} days in: {$logDir->toString()}\n";
$cleaned = $this->maintenance->cleanLogFiles($logDir, $olderThan);
echo "✅ Cleaned {$cleaned} log file(s).\n";
return ExitCode::SUCCESS;
}
#[ConsoleCommand('maintenance:clean-cache', 'Clean cache directories')]
public function cleanCache(ConsoleInput $input): int
{
$directory = $input->getArgument('directory') ?? '/tmp/cache';
try {
$cacheDir = FilePath::create($directory);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid directory: {$directory}\n";
return ExitCode::FAILURE;
}
echo "Cleaning cache directory: {$cacheDir->toString()}\n";
if ($this->maintenance->cleanCache($cacheDir)) {
echo "✅ Cache cleaned successfully!\n";
return ExitCode::SUCCESS;
}
echo "❌ Failed to clean cache.\n";
return ExitCode::FAILURE;
}
#[ConsoleCommand('maintenance:clean-old-backups', 'Clean old backup files')]
public function cleanOldBackups(ConsoleInput $input): int
{
$directory = $input->getArgument('directory');
$days = (int) ($input->getOption('days') ?? 90);
if ($directory === null) {
echo "❌ Please provide a backup directory.\n";
echo "Usage: php console.php maintenance:clean-old-backups <directory> [--days=90]\n";
return ExitCode::FAILURE;
}
try {
$backupDir = FilePath::create($directory);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid directory: {$directory}\n";
return ExitCode::FAILURE;
}
$olderThan = Duration::fromDays($days);
echo "Cleaning backup files older than {$days} days in: {$backupDir->toString()}\n";
$cleaned = $this->maintenance->cleanOldBackups($backupDir, $olderThan);
echo "✅ Cleaned {$cleaned} backup file(s).\n";
return ExitCode::SUCCESS;
}
#[ConsoleCommand('maintenance:disk-space', 'Show largest directories')]
public function diskSpace(ConsoleInput $input): int
{
$directory = $input->getArgument('directory') ?? '/';
$limit = (int) ($input->getOption('limit') ?? 10);
try {
$dir = FilePath::create($directory);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid directory: {$directory}\n";
return ExitCode::FAILURE;
}
echo "Finding largest directories in: {$dir->toString()}\n\n";
$directories = $this->maintenance->findLargestDirectories($dir, $limit);
if (empty($directories)) {
echo " No directories found.\n";
return ExitCode::SUCCESS;
}
echo "┌─ LARGEST DIRECTORIES ────────────────────────────────────┐\n";
$rank = 1;
foreach ($directories as $path => $size) {
echo "{$rank}. {$path} ({$size})\n";
$rank++;
}
echo "└─────────────────────────────────────────────────────────┘\n";
return ExitCode::SUCCESS;
}
#[ConsoleCommand('maintenance:find-duplicates', 'Find duplicate files')]
public function findDuplicates(ConsoleInput $input): int
{
$directory = $input->getArgument('directory');
if ($directory === null) {
echo "❌ Please provide a directory to search.\n";
echo "Usage: php console.php maintenance:find-duplicates <directory>\n";
return ExitCode::FAILURE;
}
try {
$dir = FilePath::create($directory);
} catch (\InvalidArgumentException $e) {
echo "❌ Invalid directory: {$directory}\n";
return ExitCode::FAILURE;
}
echo "Searching for duplicate files in: {$dir->toString()}\n\n";
$duplicates = $this->maintenance->findDuplicateFiles($dir);
if (empty($duplicates)) {
echo "✅ No duplicate files found!\n";
return ExitCode::SUCCESS;
}
echo "┌─ DUPLICATE FILES ────────────────────────────────────────┐\n";
echo "│ Found " . count($duplicates) . " duplicate group(s)\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
foreach ($duplicates as $hash => $files) {
echo "┌─ Hash: {$hash} ────────────────────────────────────────┐\n";
foreach ($files as $file) {
echo "│ - {$file}\n";
}
echo "└─────────────────────────────────────────────────────────┘\n\n";
}
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Console;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ExitCode;
use App\Framework\Process\Services\NetworkDiagnosticsService;
use App\Framework\Process\Services\TcpPortCheckService;
/**
* Network Console Commands.
*/
final readonly class NetworkCommands
{
public function __construct(
private NetworkDiagnosticsService $networkDiagnostics,
private TcpPortCheckService $tcpPortCheck
) {
}
#[ConsoleCommand('network:ping', 'Ping a host to check connectivity')]
public function ping(ConsoleInput $input): int
{
$host = $input->getArgument('host');
if ($host === null) {
echo "❌ Please provide a host to ping.\n";
echo "Usage: php console.php network:ping <host> [--count=4]\n";
return ExitCode::FAILURE;
}
$count = (int) ($input->getOption('count') ?? 4);
echo "Pinging {$host} ({$count} packets)...\n\n";
$result = $this->networkDiagnostics->ping($host, $count);
if ($result->isReachable) {
echo "✅ Host is reachable!\n\n";
echo "┌─ PING RESULTS ────────────────────────────────────────┐\n";
echo "│ Host: {$result->host}\n";
echo "│ Latency: {$result->latency->toHumanReadable()}\n";
echo "│ Packets Sent: {$result->packetsSent}\n";
echo "│ Packets Received: {$result->packetsReceived}\n";
if ($result->packetLoss !== null) {
echo "│ Packet Loss: {$result->packetLoss}%\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
return ExitCode::SUCCESS;
}
echo "❌ Host is not reachable: {$result->host}\n";
return ExitCode::FAILURE;
}
#[ConsoleCommand('network:dns', 'Perform DNS lookup for a hostname')]
public function dns(ConsoleInput $input): int
{
$hostname = $input->getArgument('hostname');
if ($hostname === null) {
echo "❌ Please provide a hostname to resolve.\n";
echo "Usage: php console.php network:dns <hostname>\n";
return ExitCode::FAILURE;
}
echo "Performing DNS lookup for {$hostname}...\n\n";
$result = $this->networkDiagnostics->dnsLookup($hostname);
if ($result->resolved) {
echo "✅ DNS resolution successful!\n\n";
echo "┌─ DNS RESULTS ────────────────────────────────────────┐\n";
echo "│ Hostname: {$result->hostname}\n";
echo "│ Resolved: ✅ Yes\n";
echo "│ Addresses: " . count($result->addresses) . "\n\n";
if (! empty($result->addresses)) {
echo "│ IP Addresses:\n";
foreach ($result->addresses as $address) {
$type = $address->isV4() ? 'IPv4' : 'IPv6';
echo "│ - {$address->value} ({$type})\n";
}
}
echo "└─────────────────────────────────────────────────────────┘\n";
return ExitCode::SUCCESS;
}
echo "❌ DNS resolution failed for: {$result->hostname}\n";
return ExitCode::FAILURE;
}
#[ConsoleCommand('network:port', 'Check if a TCP port is open on a host')]
public function port(ConsoleInput $input): int
{
$host = $input->getArgument('host');
$port = $input->getArgument('port');
if ($host === null || $port === null) {
echo "❌ Please provide both host and port.\n";
echo "Usage: php console.php network:port <host> <port>\n";
return ExitCode::FAILURE;
}
$portNumber = (int) $port;
if ($portNumber < 1 || $portNumber > 65535) {
echo "❌ Port must be between 1 and 65535.\n";
return ExitCode::FAILURE;
}
echo "Checking port {$portNumber} on {$host}...\n\n";
// Use both services - NetworkDiagnosticsService for detailed info, TcpPortCheckService for simple check
$portStatus = $this->networkDiagnostics->checkPort($host, $portNumber);
$isOpen = $this->tcpPortCheck->isPortOpen($host, $portNumber);
echo "┌─ PORT CHECK RESULTS ─────────────────────────────────────┐\n";
echo "│ Host: {$host}\n";
echo "│ Port: {$portNumber}\n";
if ($portStatus->isOpen) {
echo "│ Status: ✅ Open\n";
if (! empty($portStatus->service)) {
echo "│ Service: {$portStatus->service}\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
return ExitCode::SUCCESS;
}
echo "│ Status: ❌ Closed\n";
echo "└─────────────────────────────────────────────────────────┘\n";
return ExitCode::FAILURE;
}
#[ConsoleCommand('network:scan', 'Scan multiple ports on a host')]
public function scan(ConsoleInput $input): int
{
$host = $input->getArgument('host');
if ($host === null) {
echo "❌ Please provide a host to scan.\n";
echo "Usage: php console.php network:scan <host> [--ports=80,443,22]\n";
return ExitCode::FAILURE;
}
$portsOption = $input->getOption('ports') ?? '80,443,22,21,25,3306,5432';
$ports = array_map('intval', explode(',', $portsOption));
echo "Scanning ports on {$host}...\n\n";
$results = $this->networkDiagnostics->scanPorts($host, $ports);
echo "┌─ PORT SCAN RESULTS ─────────────────────────────────────┐\n";
echo "│ Host: {$host}\n";
echo "│ Ports Scanned: " . count($ports) . "\n\n";
$openPorts = array_filter($results, fn ($r) => $r->isOpen);
$closedPorts = array_filter($results, fn ($r) => ! $r->isOpen);
if (! empty($openPorts)) {
echo "│ Open Ports:\n";
foreach ($openPorts as $portStatus) {
$service = ! empty($portStatus->service) ? " ({$portStatus->service})" : '';
echo "│ ✅ {$portStatus->port}{$service}\n";
}
echo "\n";
}
if (! empty($closedPorts)) {
echo "│ Closed Ports:\n";
foreach ($closedPorts as $portStatus) {
echo "│ ❌ {$portStatus->port}\n";
}
}
echo "└─────────────────────────────────────────────────────────┘\n";
return ! empty($openPorts) ? ExitCode::SUCCESS : ExitCode::FAILURE;
}
#[ConsoleCommand('network:connectivity', 'Check connectivity to common internet services')]
public function connectivity(ConsoleInput $input): int
{
echo "Checking connectivity to common internet services...\n\n";
$results = $this->networkDiagnostics->checkConnectivity();
echo "┌─ CONNECTIVITY CHECK ─────────────────────────────────────┐\n";
$allReachable = true;
foreach ($results as $host => $pingResult) {
if ($pingResult->isReachable) {
$latency = $pingResult->latency?->toHumanReadable() ?? 'N/A';
echo "│ ✅ {$host}: Reachable (Latency: {$latency})\n";
} else {
echo "│ ❌ {$host}: Not reachable\n";
$allReachable = false;
}
}
echo "└─────────────────────────────────────────────────────────┘\n";
return $allReachable ? ExitCode::SUCCESS : ExitCode::FAILURE;
}
}

View File

@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Console;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ExitCode;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Process\Process;
use App\Framework\Process\Services\ProcessMonitoringService;
use App\Framework\Process\ValueObjects\Command;
/**
* Process Console Commands.
*/
final readonly class ProcessCommands
{
public function __construct(
private ProcessMonitoringService $processMonitoring,
private Process $process
) {
}
#[ConsoleCommand('process:list', 'List running processes')]
public function list(ConsoleInput $input): int
{
$filter = $input->getOption('filter');
echo "Listing processes" . ($filter ? " (filter: {$filter})" : '') . "...\n\n";
$processes = $this->processMonitoring->listProcesses($filter);
if (empty($processes)) {
echo " No processes found.\n";
return ExitCode::SUCCESS;
}
echo "┌─ RUNNING PROCESSES ───────────────────────────────────────┐\n";
echo "│ Found " . count($processes) . " process(es)\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
echo "┌─ PROCESSES ──────────────────────────────────────────────┐\n";
foreach ($processes as $proc) {
echo "│ PID: {$proc->pid} | {$proc->command}\n";
if ($proc->user !== null) {
echo "│ User: {$proc->user}\n";
}
if ($proc->cpuPercent !== null) {
echo "│ CPU: {$proc->cpuPercent}%\n";
}
if ($proc->memoryUsage !== null) {
echo "│ Memory: {$proc->memoryUsage->toHumanReadable()}\n";
}
if ($proc->state !== null) {
echo "│ State: {$proc->state}\n";
}
echo "\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
return ExitCode::SUCCESS;
}
#[ConsoleCommand('process:find', 'Find processes by name')]
public function find(ConsoleInput $input): int
{
$name = $input->getArgument('name');
if ($name === null) {
echo "❌ Please provide a process name to search for.\n";
echo "Usage: php console.php process:find <name>\n";
return ExitCode::FAILURE;
}
echo "Searching for processes matching: {$name}\n\n";
$processes = $this->processMonitoring->findProcesses($name);
if (empty($processes)) {
echo " No processes found matching '{$name}'.\n";
return ExitCode::SUCCESS;
}
echo "┌─ FOUND PROCESSES ────────────────────────────────────────┐\n";
echo "│ Found " . count($processes) . " process(es) matching '{$name}'\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
foreach ($processes as $proc) {
echo "┌─ {$proc->command} (PID: {$proc->pid}) ────────────────────────────────┐\n";
echo "│ PID: {$proc->pid}\n";
echo "│ Command: {$proc->command}\n";
if ($proc->user !== null) {
echo "│ User: {$proc->user}\n";
}
if ($proc->cpuPercent !== null) {
echo "│ CPU: {$proc->cpuPercent}%\n";
}
if ($proc->memoryUsage !== null) {
echo "│ Memory: {$proc->memoryUsage->toHumanReadable()}\n";
}
if ($proc->state !== null) {
echo "│ State: {$proc->state}\n";
}
if ($proc->priority !== null) {
echo "│ Priority: {$proc->priority}\n";
}
echo "└─────────────────────────────────────────────────────────┘\n\n";
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('process:kill', 'Kill a process by PID')]
public function kill(ConsoleInput $input): int
{
$pid = $input->getArgument('pid');
if ($pid === null) {
echo "❌ Please provide a process ID to kill.\n";
echo "Usage: php console.php process:kill <pid> [--force]\n";
return ExitCode::FAILURE;
}
$pidInt = (int) $pid;
$force = $input->hasOption('force');
// Check if process exists
if (! $this->processMonitoring->isProcessRunning($pidInt)) {
echo "❌ Process with PID {$pidInt} is not running.\n";
return ExitCode::FAILURE;
}
$procDetails = $this->processMonitoring->getProcessDetails($pidInt);
if ($procDetails !== null) {
echo "⚠️ About to kill process:\n";
echo " PID: {$procDetails->pid}\n";
echo " Command: {$procDetails->command}\n";
if (! $force) {
echo "\n⚠️ Use --force to proceed without confirmation.\n";
echo " (In production, you should add confirmation prompts)\n";
return ExitCode::FAILURE;
}
}
$signal = $force ? 'SIGKILL' : 'SIGTERM';
$command = Command::fromArray([
'kill',
$force ? '-9' : '-15',
(string) $pidInt,
]);
echo "Sending {$signal} to PID {$pidInt}...\n";
$result = $this->process->run($command);
if ($result->isSuccess()) {
echo "✅ Process {$pidInt} terminated successfully.\n";
return ExitCode::SUCCESS;
}
echo "❌ Failed to kill process {$pidInt}.\n";
echo " Error: {$result->stderr}\n";
return ExitCode::FAILURE;
}
#[ConsoleCommand('process:tree', 'Show process tree')]
public function tree(ConsoleInput $input): int
{
echo "Building process tree...\n\n";
$treeData = $this->processMonitoring->getProcessTree();
echo "┌─ PROCESS TREE ────────────────────────────────────────────┐\n";
echo "│ Total Processes: " . count($treeData['tree']) . "\n";
echo "│ Root Processes: " . count($treeData['roots']) . "\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
// Display tree (simplified version)
foreach ($treeData['roots'] as $rootPid) {
if (! isset($treeData['tree'][$rootPid])) {
continue;
}
$this->displayTreeNode($treeData['tree'], $rootPid, 0);
}
return ExitCode::SUCCESS;
}
/**
* Zeigt einen Prozess-Knoten rekursiv an.
*
* @param array<int, array{pid: int, command: string, children: array}> $tree
*/
private function displayTreeNode(array $tree, int $pid, int $depth): void
{
if (! isset($tree[$pid])) {
return;
}
$node = $tree[$pid];
$indent = str_repeat(' ', $depth);
$prefix = $depth > 0 ? '└─ ' : '';
echo "{$indent}{$prefix}[{$node['pid']}] {$node['command']}\n";
foreach ($node['children'] as $childPid) {
$this->displayTreeNode($tree, $childPid, $depth + 1);
}
}
#[ConsoleCommand('process:watch', 'Watch a process in real-time')]
public function watch(ConsoleInput $input): int
{
$pid = $input->getArgument('pid');
if ($pid === null) {
echo "❌ Please provide a process ID to watch.\n";
echo "Usage: php console.php process:watch <pid> [--interval=2]\n";
return ExitCode::FAILURE;
}
$pidInt = (int) $pid;
$interval = (int) ($input->getOption('interval') ?? 2);
echo "Watching process {$pidInt} (refresh every {$interval} seconds)...\n";
echo "Press Ctrl+C to stop.\n\n";
$maxIterations = 10; // Limit iterations for safety
for ($i = 0; $i < $maxIterations; $i++) {
$procDetails = $this->processMonitoring->getProcessDetails($pidInt);
if ($procDetails === null) {
echo "\n⚠️ Process {$pidInt} no longer exists.\n";
return ExitCode::SUCCESS;
}
echo "\n┌─ PROCESS {$pidInt} ─────────────────────────────────────────────┐\n";
echo "│ Command: {$procDetails->command}\n";
if ($procDetails->user !== null) {
echo "│ User: {$procDetails->user}\n";
}
if ($procDetails->cpuPercent !== null) {
echo "│ CPU: {$procDetails->cpuPercent}%\n";
}
if ($procDetails->memoryUsage !== null) {
echo "│ Memory: {$procDetails->memoryUsage->toHumanReadable()}\n";
}
if ($procDetails->state !== null) {
echo "│ State: {$procDetails->state}\n";
}
echo "│ Time: " . date('Y-m-d H:i:s') . "\n";
echo "└─────────────────────────────────────────────────────────┘\n";
if ($i < $maxIterations - 1) {
sleep($interval);
}
}
echo "\n✅ Monitoring stopped after {$maxIterations} iterations.\n";
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Console;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ExitCode;
use App\Framework\Process\Services\SslCertificateService;
/**
* SSL Certificate Console Commands.
*/
final readonly class SslCommands
{
public function __construct(
private SslCertificateService $sslService
) {
}
#[ConsoleCommand('ssl:check', 'Check SSL certificate of a domain')]
public function check(ConsoleInput $input): int
{
$domain = $input->getArgument('domain');
if ($domain === null) {
echo "❌ Please provide a domain to check.\n";
echo "Usage: php console.php ssl:check <domain> [--port=443]\n";
return ExitCode::FAILURE;
}
$port = (int) ($input->getOption('port') ?? 443);
echo "Checking SSL certificate for: {$domain}:{$port}\n\n";
$result = $this->sslService->checkCertificate($domain, $port);
echo "┌─ SSL CERTIFICATE CHECK ──────────────────────────────────┐\n";
echo "│ Domain: {$result->hostname}\n";
if ($result->isValid) {
$cert = $result->certificateInfo;
if ($cert !== null) {
echo "│ Status: ✅ Valid\n";
echo "│ Subject: {$cert->subject}\n";
echo "│ Issuer: {$cert->issuer}\n";
echo "│ Valid From: {$cert->validFrom->format('Y-m-d H:i:s')}\n";
echo "│ Valid To: {$cert->validTo->format('Y-m-d H:i:s')}\n";
$daysUntilExpiry = $cert->getDaysUntilExpiry();
echo "│ Days Until Expiry: {$daysUntilExpiry}\n";
if ($cert->isExpiringSoon(30)) {
echo "│ ⚠️ WARNING: Certificate expires soon!\n";
}
if ($cert->isSelfSigned) {
echo "│ ⚠️ WARNING: Certificate is self-signed\n";
}
if (! empty($cert->subjectAltNames)) {
echo "│ Subject Alt Names:\n";
foreach ($cert->subjectAltNames as $san) {
echo "│ - {$san}\n";
}
}
if ($cert->serialNumber !== null) {
echo "│ Serial Number: {$cert->serialNumber}\n";
}
if ($cert->signatureAlgorithm !== null) {
echo "│ Signature Alg: {$cert->signatureAlgorithm}\n";
}
}
} else {
echo "│ Status: ❌ Invalid\n";
if (! empty($result->errors)) {
echo "│ Errors:\n";
foreach ($result->errors as $error) {
echo "│ - {$error}\n";
}
}
}
if ($result->hasWarnings()) {
echo "│ Warnings:\n";
foreach ($result->warnings as $warning) {
echo "│ - {$warning}\n";
}
}
echo "└─────────────────────────────────────────────────────────┘\n";
return $result->isValid ? ExitCode::SUCCESS : ExitCode::FAILURE;
}
#[ConsoleCommand('ssl:verify', 'Detailed SSL certificate verification')]
public function verify(ConsoleInput $input): int
{
$domain = $input->getArgument('domain');
if ($domain === null) {
echo "❌ Please provide a domain to verify.\n";
echo "Usage: php console.php ssl:verify <domain> [--port=443]\n";
return ExitCode::FAILURE;
}
$port = (int) ($input->getOption('port') ?? 443);
echo "Verifying SSL certificate for: {$domain}:{$port}\n\n";
$result = $this->sslService->checkCertificate($domain, $port);
echo "╔════════════════════════════════════════════════════════════╗\n";
echo "║ SSL CERTIFICATE VERIFICATION ║\n";
echo "╚════════════════════════════════════════════════════════════╝\n\n";
echo "┌─ VERIFICATION RESULTS ───────────────────────────────────┐\n";
echo "│ Domain: {$result->hostname}\n";
echo "│ Port: {$port}\n";
$statusIcon = $result->isValid ? '✅' : '❌';
echo "│ Overall Status: {$statusIcon} " . ($result->isValid ? 'Valid' : 'Invalid') . "\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
if ($result->certificateInfo !== null) {
$cert = $result->certificateInfo;
echo "┌─ CERTIFICATE DETAILS ───────────────────────────────────┐\n";
echo "│ Subject: {$cert->subject}\n";
echo "│ Issuer: {$cert->issuer}\n";
echo "│ Valid From: {$cert->validFrom->format('Y-m-d H:i:s')}\n";
echo "│ Valid To: {$cert->validTo->format('Y-m-d H:i:s')}\n";
echo "│ Days Until Expiry: {$cert->getDaysUntilExpiry()}\n";
echo "│ Is Self-Signed: " . ($cert->isSelfSigned ? 'Yes' : 'No') . "\n";
if ($cert->serialNumber !== null) {
echo "│ Serial Number: {$cert->serialNumber}\n";
}
if ($cert->signatureAlgorithm !== null) {
echo "│ Signature Alg: {$cert->signatureAlgorithm}\n";
}
if (! empty($cert->subjectAltNames)) {
echo "\n│ Subject Alternative Names:\n";
foreach ($cert->subjectAltNames as $san) {
echo "│ - {$san}\n";
}
}
echo "└─────────────────────────────────────────────────────────┘\n\n";
// Validation checks
echo "┌─ VALIDATION CHECKS ────────────────────────────────────┐\n";
$checks = [
'Certificate is valid' => $cert->isValid(),
'Certificate is not expired' => ! $cert->isExpired(),
'Certificate is not expiring soon (30 days)' => ! $cert->isExpiringSoon(30),
'Certificate is not self-signed' => ! $cert->isSelfSigned,
];
foreach ($checks as $check => $passed) {
$icon = $passed ? '✅' : '❌';
echo "{$icon} {$check}\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
}
if (! empty($result->errors)) {
echo "\n┌─ ERRORS ───────────────────────────────────────────────┐\n";
foreach ($result->errors as $error) {
echo "│ ❌ {$error}\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
}
if ($result->hasWarnings()) {
echo "\n┌─ WARNINGS ─────────────────────────────────────────────┐\n";
foreach ($result->warnings as $warning) {
echo "│ ⚠️ {$warning}\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
}
return $result->isValid && ! $result->hasWarnings() ? ExitCode::SUCCESS : ExitCode::WARNING;
}
#[ConsoleCommand('ssl:expiring', 'List domains with expiring certificates')]
public function expiring(ConsoleInput $input): int
{
$domainsOption = $input->getOption('domains');
$threshold = (int) ($input->getOption('threshold') ?? 30);
if ($domainsOption === null) {
echo "❌ Please provide domains to check.\n";
echo "Usage: php console.php ssl:expiring --domains=example.com,google.com [--threshold=30]\n";
return ExitCode::FAILURE;
}
$domains = array_map('trim', explode(',', $domainsOption));
$domains = array_filter($domains);
if (empty($domains)) {
echo "❌ No valid domains provided.\n";
return ExitCode::FAILURE;
}
echo "Checking {$threshold} days threshold for " . count($domains) . " domain(s)...\n\n";
$results = $this->sslService->findExpiringCertificates($domains, $threshold);
if (empty($results)) {
echo "✅ No certificates expiring within {$threshold} days!\n";
return ExitCode::SUCCESS;
}
echo "┌─ EXPIRING CERTIFICATES ───────────────────────────────────┐\n";
echo "│ Found " . count($results) . " certificate(s) expiring within {$threshold} days:\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
foreach ($results as $result) {
$cert = $result->certificateInfo;
if ($cert === null) {
continue;
}
$daysUntilExpiry = $cert->getDaysUntilExpiry();
echo "┌─ {$result->hostname} ─────────────────────────────────────────────┐\n";
echo "│ Days Until Expiry: {$daysUntilExpiry}\n";
echo "│ Valid To: {$cert->validTo->format('Y-m-d H:i:s')}\n";
echo "│ Subject: {$cert->subject}\n";
echo "│ Issuer: {$cert->issuer}\n";
if (! empty($result->warnings)) {
echo "│ Warnings:\n";
foreach ($result->warnings as $warning) {
echo "│ - {$warning}\n";
}
}
echo "└─────────────────────────────────────────────────────────┘\n\n";
}
return ExitCode::WARNING;
}
#[ConsoleCommand('ssl:info', 'Show detailed SSL certificate information')]
public function info(ConsoleInput $input): int
{
$domain = $input->getArgument('domain');
if ($domain === null) {
echo "❌ Please provide a domain to check.\n";
echo "Usage: php console.php ssl:info <domain> [--port=443]\n";
return ExitCode::FAILURE;
}
$port = (int) ($input->getOption('port') ?? 443);
echo "Retrieving SSL certificate information for: {$domain}:{$port}\n\n";
$cert = $this->sslService->getCertificateInfo($domain, $port);
if ($cert === null) {
echo "❌ Could not retrieve certificate information for {$domain}:{$port}\n";
return ExitCode::FAILURE;
}
echo "╔════════════════════════════════════════════════════════════╗\n";
echo "║ SSL CERTIFICATE INFORMATION ║\n";
echo "╚════════════════════════════════════════════════════════════╝\n\n";
echo "┌─ CERTIFICATE INFORMATION ────────────────────────────────┐\n";
echo "│ Subject: {$cert->subject}\n";
echo "│ Issuer: {$cert->issuer}\n";
echo "│ Valid From: {$cert->validFrom->format('Y-m-d H:i:s')}\n";
echo "│ Valid To: {$cert->validTo->format('Y-m-d H:i:s')}\n";
echo "│ Days Until Expiry: {$cert->getDaysUntilExpiry()}\n";
echo "│ Is Self-Signed: " . ($cert->isSelfSigned ? 'Yes' : 'No') . "\n";
if ($cert->serialNumber !== null) {
echo "│ Serial Number: {$cert->serialNumber}\n";
}
if ($cert->signatureAlgorithm !== null) {
echo "│ Signature Alg: {$cert->signatureAlgorithm}\n";
}
echo "└─────────────────────────────────────────────────────────┘\n\n";
echo "┌─ VALIDITY STATUS ─────────────────────────────────────────┐\n";
$validIcon = $cert->isValid() ? '✅' : '❌';
echo "│ Is Valid: {$validIcon} " . ($cert->isValid() ? 'Yes' : 'No') . "\n";
$expiredIcon = $cert->isExpired() ? '❌' : '✅';
echo "│ Is Expired: {$expiredIcon} " . ($cert->isExpired() ? 'Yes' : 'No') . "\n";
$expiringIcon = $cert->isExpiringSoon(30) ? '⚠️' : '✅';
echo "│ Expiring Soon: {$expiringIcon} " . ($cert->isExpiringSoon(30) ? 'Yes (within 30 days)' : 'No') . "\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
if (! empty($cert->subjectAltNames)) {
echo "┌─ SUBJECT ALTERNATIVE NAMES ─────────────────────────────┐\n";
foreach ($cert->subjectAltNames as $san) {
echo "│ - {$san}\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
}
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Console;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ExitCode;
use App\Framework\Process\Services\SystemHealthCheckService;
use App\Framework\Process\Services\SystemInfoService;
/**
* System Console Commands.
*/
final readonly class SystemCommands
{
public function __construct(
private SystemInfoService $systemInfo,
private SystemHealthCheckService $healthCheck
) {
}
#[ConsoleCommand('system:info', 'Display system information (uptime, load, memory, disk, CPU)')]
public function info(ConsoleInput $input, ConsoleOutput $output): int
{
$info = ($this->systemInfo)();
echo "╔════════════════════════════════════════════════════════════╗\n";
echo "║ SYSTEM INFORMATION ║\n";
echo "╚════════════════════════════════════════════════════════════╝\n\n";
// Uptime
echo "┌─ UPTIME ────────────────────────────────────────────────┐\n";
echo "│ Boot Time: {$info->uptime->getBootTimeFormatted()}\n";
echo "│ Uptime: {$info->uptime->uptime->toHumanReadable()} ({$info->uptime->getUptimeDays()} days)\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
// Load Average
echo "┌─ LOAD AVERAGE ──────────────────────────────────────────┐\n";
$util = round($info->load->getUtilization($info->cpu->cores) * 100, 1);
echo "│ 1 min: {$info->load->oneMinute}\n";
echo "│ 5 min: {$info->load->fiveMinutes}\n";
echo "│ 15 min: {$info->load->fifteenMinutes}\n";
echo "│ CPU Usage: {$util}%\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
// CPU
echo "┌─ CPU ───────────────────────────────────────────────────┐\n";
echo "│ Cores: {$info->cpu->cores}\n";
echo "│ Model: {$info->cpu->getShortModel()}\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
// Memory
echo "┌─ MEMORY ────────────────────────────────────────────────┐\n";
echo "│ Total: {$info->memory->getTotal()->toHumanReadable()}\n";
echo "│ Used: {$info->memory->getUsed()->toHumanReadable()} ({$info->memory->getUsagePercentage()}%)\n";
echo "│ Free: {$info->memory->getFree()->toHumanReadable()}\n";
echo "│ Available: {$info->memory->getAvailable()->toHumanReadable()}\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
// Disk
echo "┌─ DISK ({$info->disk->mountPoint}) ─────────────────────────────────────────────┐\n";
echo "│ Total: {$info->disk->getTotal()->toHumanReadable()}\n";
echo "│ Used: {$info->disk->getUsed()->toHumanReadable()} ({$info->disk->getUsagePercentage()}%)\n";
echo "│ Available: {$info->disk->getAvailable()->toHumanReadable()}\n";
if ($info->disk->isAlmostFull()) {
echo "│ ⚠️ WARNING: Disk is almost full!\n";
}
echo "└─────────────────────────────────────────────────────────┘\n\n";
// Processes
echo "┌─ PROCESSES ─────────────────────────────────────────────┐\n";
echo "│ Total: {$info->processes->total}\n";
echo "│ Running: {$info->processes->running}\n";
echo "│ Sleeping: {$info->processes->sleeping}\n";
echo "│ Other: {$info->processes->getOther()}\n";
echo "└─────────────────────────────────────────────────────────┘\n";
return ExitCode::SUCCESS->value;
}
#[ConsoleCommand('system:health', 'Display system health check report')]
public function health(ConsoleInput $input, ConsoleOutput $output): int
{
$report = ($this->healthCheck)();
echo "╔════════════════════════════════════════════════════════════╗\n";
echo "║ SYSTEM HEALTH CHECK ║\n";
echo "╚════════════════════════════════════════════════════════════╝\n\n";
$overallStatus = $report->overallStatus;
echo "┌─ OVERALL STATUS ────────────────────────────────────────┐\n";
$statusIcon = match ($overallStatus->value) {
'healthy' => '✅',
'degraded' => '⚠️',
'unhealthy' => '❌',
default => '❓',
};
echo "│ Status: {$statusIcon} {$overallStatus->value}\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
echo "┌─ HEALTH CHECKS ─────────────────────────────────────────┐\n";
foreach ($report->checks as $check) {
$icon = match ($check->status->value) {
'healthy' => '✅',
'degraded' => '⚠️',
'unhealthy' => '❌',
default => '❓',
};
echo "{$icon} {$check->name}\n";
echo "{$check->message}\n";
echo "│ Value: {$check->value} {$check->unit} (Threshold: {$check->threshold} {$check->unit})\n";
echo "\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
if (! empty($report->getUnhealthyChecks())) {
return ExitCode::FAILURE;
}
if (! empty($report->getDegradedChecks())) {
return ExitCode::WARNING;
}
return ExitCode::SUCCESS->value;
}
#[ConsoleCommand('system:uptime', 'Display system uptime information')]
public function uptime(ConsoleInput $input, ConsoleOutput $output): int
{
$uptime = $this->systemInfo->getUptime();
echo "╔════════════════════════════════════════════════════════════╗\n";
echo "║ SYSTEM UPTIME ║\n";
echo "╚════════════════════════════════════════════════════════════╝\n\n";
echo "┌─ UPTIME ────────────────────────────────────────────────┐\n";
echo "│ Boot Time: {$uptime->getBootTimeFormatted()}\n";
echo "│ Uptime: {$uptime->uptime->toHumanReadable()}\n";
echo "│ Days: {$uptime->getUptimeDays()} days\n";
echo "│ Hours: " . round($uptime->uptime->toHours(), 1) . " hours\n";
echo "│ Minutes: " . round($uptime->uptime->toMinutes(), 1) . " minutes\n";
echo "│ Seconds: " . round($uptime->uptime->toSeconds(), 1) . " seconds\n";
echo "└─────────────────────────────────────────────────────────┘\n";
return ExitCode::SUCCESS->value;
}
#[ConsoleCommand('system:memory', 'Display system memory information')]
public function memory(ConsoleInput $input, ConsoleOutput $output): int
{
$memory = $this->systemInfo->getMemoryInfo();
echo "╔════════════════════════════════════════════════════════════╗\n";
echo "║ MEMORY INFORMATION ║\n";
echo "╚════════════════════════════════════════════════════════════╝\n\n";
echo "┌─ MEMORY ────────────────────────────────────────────────┐\n";
echo "│ Total: {$memory->getTotal()->toHumanReadable()}\n";
echo "│ Used: {$memory->getUsed()->toHumanReadable()} ({$memory->getUsagePercentage()}%)\n";
echo "│ Free: {$memory->getFree()->toHumanReadable()}\n";
echo "│ Available: {$memory->getAvailable()->toHumanReadable()}\n";
$usage = $memory->getUsagePercentage();
if ($usage >= 90) {
echo "│ ❌ CRITICAL: Memory usage is {$usage}%!\n";
} elseif ($usage >= 80) {
echo "│ ⚠️ WARNING: Memory usage is {$usage}%!\n";
} else {
echo "│ ✅ Memory usage is normal\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
return ExitCode::SUCCESS->value;
}
#[ConsoleCommand('system:disk', 'Display system disk information')]
public function disk(ConsoleInput $input, ConsoleOutput $output): int
{
$disk = $this->systemInfo->getDiskInfo();
echo "╔════════════════════════════════════════════════════════════╗\n";
echo "║ DISK INFORMATION ║\n";
echo "╚════════════════════════════════════════════════════════════╝\n\n";
echo "┌─ DISK ({$disk->mountPoint}) ─────────────────────────────────────────────┐\n";
echo "│ Total: {$disk->getTotal()->toHumanReadable()}\n";
echo "│ Used: {$disk->getUsed()->toHumanReadable()} ({$disk->getUsagePercentage()}%)\n";
echo "│ Available: {$disk->getAvailable()->toHumanReadable()}\n";
$usage = $disk->getUsagePercentage();
if ($disk->isAlmostFull()) {
echo "│ ❌ CRITICAL: Disk is almost full ({$usage}%)!\n";
} elseif ($usage >= 80) {
echo "│ ⚠️ WARNING: Disk usage is {$usage}%!\n";
} else {
echo "│ ✅ Disk space is sufficient\n";
}
echo "└─────────────────────────────────────────────────────────┘\n";
return ExitCode::SUCCESS->value;
}
}

View File

@@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Console;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ExitCode;
use App\Framework\Process\Services\SystemInfoService;
/**
* Zeigt System-Informationen an.
*/
final readonly class SystemInfoCommand
{
public function __construct(
private SystemInfoService $systemInfo
) {
}
#[ConsoleCommand('system:info', 'Display system information (uptime, load, memory, disk, CPU)')]
public function execute(ConsoleInput $input, ConsoleOutput $output): int
{
$info = ($this->systemInfo)();
echo "╔════════════════════════════════════════════════════════════╗\n";
echo "║ SYSTEM INFORMATION ║\n";
echo "╚════════════════════════════════════════════════════════════╝\n\n";
// Uptime
echo "┌─ UPTIME ────────────────────────────────────────────────┐\n";
echo "│ Boot Time: {$info->uptime->getBootTimeFormatted()}\n";
echo "│ Uptime: {$info->uptime->uptime->toHumanReadable()} ({$info->uptime->getUptimeDays()} days)\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
// Load Average
echo "┌─ LOAD AVERAGE ──────────────────────────────────────────┐\n";
$util = round($info->load->getUtilization($info->cpu->cores) * 100, 1);
echo "│ 1 min: {$info->load->oneMinute}\n";
echo "│ 5 min: {$info->load->fiveMinutes}\n";
echo "│ 15 min: {$info->load->fifteenMinutes}\n";
echo "│ CPU Usage: {$util}%\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
// CPU
echo "┌─ CPU ───────────────────────────────────────────────────┐\n";
echo "│ Cores: {$info->cpu->cores}\n";
echo "│ Model: {$info->cpu->getShortModel()}\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
// Memory
echo "┌─ MEMORY ────────────────────────────────────────────────┐\n";
echo "│ Total: {$info->memory->getTotal()->toHumanReadable()}\n";
echo "│ Used: {$info->memory->getUsed()->toHumanReadable()} ({$info->memory->getUsagePercentage()}%)\n";
echo "│ Free: {$info->memory->getFree()->toHumanReadable()}\n";
echo "│ Available: {$info->memory->getAvailable()->toHumanReadable()}\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
// Disk
echo "┌─ DISK ({$info->disk->mountPoint}) ─────────────────────────────────────────────┐\n";
echo "│ Total: {$info->disk->getTotal()->toHumanReadable()}\n";
echo "│ Used: {$info->disk->getUsed()->toHumanReadable()} ({$info->disk->getUsagePercentage()}%)\n";
echo "│ Available: {$info->disk->getAvailable()->toHumanReadable()}\n";
if ($info->disk->isAlmostFull()) {
echo "│ ⚠️ WARNING: Disk is almost full!\n";
}
echo "└─────────────────────────────────────────────────────────┘\n\n";
// Processes
echo "┌─ PROCESSES ─────────────────────────────────────────────┐\n";
echo "│ Total: {$info->processes->total}\n";
echo "│ Running: {$info->processes->running}\n";
echo "│ Sleeping: {$info->processes->sleeping}\n";
echo "│ Other: {$info->processes->getOther()}\n";
echo "└─────────────────────────────────────────────────────────┘\n";
return ExitCode::SUCCESS->value;
}
}

View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Console;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ExitCode;
use App\Framework\Process\Services\SystemdService;
/**
* Systemd Console Commands.
*/
final readonly class SystemdCommands
{
public function __construct(
private SystemdService $systemdService
) {
}
#[ConsoleCommand('systemd:list', 'List all systemd services')]
public function list(ConsoleInput $input): int
{
$all = $input->hasOption('all') || $input->hasOption('a');
echo "Listing systemd services" . ($all ? ' (including inactive)' : '') . "...\n\n";
$services = $this->systemdService->listServices($all);
if (empty($services)) {
echo " No services found.\n";
return ExitCode::SUCCESS;
}
echo "┌─ SYSTEMD SERVICES ───────────────────────────────────────┐\n";
echo "│ Found " . count($services) . " service(s)\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
foreach ($services as $service) {
$statusIcon = $service['active'] ? '✅' : '⏸️';
echo "{$statusIcon} {$service['name']} ({$service['status']})\n";
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('systemd:status', 'Show status of a systemd service')]
public function status(ConsoleInput $input): int
{
$service = $input->getArgument('service');
if ($service === null) {
echo "❌ Please provide a service name.\n";
echo "Usage: php console.php systemd:status <service>\n";
return ExitCode::FAILURE;
}
$status = $this->systemdService->getServiceStatus($service);
if ($status === null) {
echo "❌ Could not retrieve status for service: {$service}\n";
return ExitCode::FAILURE;
}
echo "┌─ SERVICE STATUS ────────────────────────────────────────┐\n";
echo "│ Service: {$status['name']}\n";
echo "│ Active: " . ($status['active'] ? '✅ Yes' : '❌ No') . "\n";
echo "│ Enabled: " . ($status['enabled'] ? '✅ Yes' : '❌ No') . "\n";
echo "└─────────────────────────────────────────────────────────┘\n";
return ExitCode::SUCCESS;
}
#[ConsoleCommand('systemd:start', 'Start a systemd service')]
public function start(ConsoleInput $input): int
{
$service = $input->getArgument('service');
if ($service === null) {
echo "❌ Please provide a service name.\n";
echo "Usage: php console.php systemd:start <service>\n";
return ExitCode::FAILURE;
}
echo "Starting service: {$service}...\n";
if ($this->systemdService->startService($service)) {
echo "✅ Service started successfully!\n";
return ExitCode::SUCCESS;
}
echo "❌ Failed to start service.\n";
return ExitCode::FAILURE;
}
#[ConsoleCommand('systemd:stop', 'Stop a systemd service')]
public function stop(ConsoleInput $input): int
{
$service = $input->getArgument('service');
if ($service === null) {
echo "❌ Please provide a service name.\n";
echo "Usage: php console.php systemd:stop <service>\n";
return ExitCode::FAILURE;
}
echo "Stopping service: {$service}...\n";
if ($this->systemdService->stopService($service)) {
echo "✅ Service stopped successfully!\n";
return ExitCode::SUCCESS;
}
echo "❌ Failed to stop service.\n";
return ExitCode::FAILURE;
}
#[ConsoleCommand('systemd:restart', 'Restart a systemd service')]
public function restart(ConsoleInput $input): int
{
$service = $input->getArgument('service');
if ($service === null) {
echo "❌ Please provide a service name.\n";
echo "Usage: php console.php systemd:restart <service>\n";
return ExitCode::FAILURE;
}
echo "Restarting service: {$service}...\n";
if ($this->systemdService->restartService($service)) {
echo "✅ Service restarted successfully!\n";
return ExitCode::SUCCESS;
}
echo "❌ Failed to restart service.\n";
return ExitCode::FAILURE;
}
#[ConsoleCommand('systemd:enable', 'Enable a systemd service')]
public function enable(ConsoleInput $input): int
{
$service = $input->getArgument('service');
if ($service === null) {
echo "❌ Please provide a service name.\n";
echo "Usage: php console.php systemd:enable <service>\n";
return ExitCode::FAILURE;
}
echo "Enabling service: {$service}...\n";
if ($this->systemdService->enableService($service)) {
echo "✅ Service enabled successfully!\n";
return ExitCode::SUCCESS;
}
echo "❌ Failed to enable service.\n";
return ExitCode::FAILURE;
}
#[ConsoleCommand('systemd:disable', 'Disable a systemd service')]
public function disable(ConsoleInput $input): int
{
$service = $input->getArgument('service');
if ($service === null) {
echo "❌ Please provide a service name.\n";
echo "Usage: php console.php systemd:disable <service>\n";
return ExitCode::FAILURE;
}
echo "Disabling service: {$service}...\n";
if ($this->systemdService->disableService($service)) {
echo "✅ Service disabled successfully!\n";
return ExitCode::SUCCESS;
}
echo "❌ Failed to disable service.\n";
return ExitCode::FAILURE;
}
#[ConsoleCommand('systemd:failed', 'List failed systemd services')]
public function failed(ConsoleInput $input): int
{
echo "Checking for failed services...\n\n";
$failed = $this->systemdService->getFailedServices();
if (empty($failed)) {
echo "✅ No failed services found!\n";
return ExitCode::SUCCESS;
}
echo "┌─ FAILED SERVICES ───────────────────────────────────────┐\n";
echo "│ Found " . count($failed) . " failed service(s):\n";
echo "└─────────────────────────────────────────────────────────┘\n\n";
foreach ($failed as $service) {
echo "{$service}\n";
}
return ExitCode::WARNING;
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Services;
use App\Framework\Process\ValueObjects\Alert\Alert;
use App\Framework\Process\ValueObjects\Alert\AlertReport;
use App\Framework\Process\ValueObjects\Alert\AlertSeverity;
use App\Framework\Process\ValueObjects\Alert\AlertThreshold;
use App\Framework\Process\ValueObjects\Health\HealthReport;
/**
* Alert Service.
*
* Verwaltet System-Alerts basierend auf Health Checks und Thresholds.
*/
final readonly class AlertService
{
/**
* @param AlertThreshold[] $thresholds
*/
public function __construct(
private SystemHealthCheckService $healthCheck,
private array $thresholds = []
) {
}
/**
* Prüft alle Alerts und gibt einen Report zurück.
*/
public function checkAlerts(): AlertReport
{
$healthReport = ($this->healthCheck)();
$alerts = [];
// Check health checks for alerts
foreach ($healthReport->checks as $check) {
$threshold = $this->findThreshold($check->name);
if ($threshold === null) {
// Use default thresholds based on health check status
if ($check->status->value === 'unhealthy') {
$alerts[] = Alert::create(
id: $this->generateAlertId($check->name),
name: $check->name,
severity: AlertSeverity::CRITICAL,
message: $check->message,
description: "Value: {$check->value} {$check->unit} exceeds critical threshold",
metadata: [
'value' => $check->value,
'unit' => $check->unit,
'threshold' => $check->threshold,
]
);
} elseif ($check->status->value === 'degraded') {
$alerts[] = Alert::create(
id: $this->generateAlertId($check->name),
name: $check->name,
severity: AlertSeverity::WARNING,
message: $check->message,
description: "Value: {$check->value} {$check->unit} exceeds warning threshold",
metadata: [
'value' => $check->value,
'unit' => $check->unit,
'threshold' => $check->threshold,
]
);
}
} else {
// Use configured threshold
$severity = $threshold->getSeverity($check->value);
if ($severity !== AlertSeverity::INFO) {
$alerts[] = Alert::create(
id: $this->generateAlertId($check->name),
name: $check->name,
severity: $severity,
message: $check->message,
description: "Value: {$check->value} {$check->unit} exceeds {$severity->value} threshold",
metadata: [
'value' => $check->value,
'unit' => $check->unit,
'threshold' => $threshold->criticalThreshold,
'warning_threshold' => $threshold->warningThreshold,
]
);
}
}
}
return AlertReport::fromAlerts($alerts);
}
/**
* Findet Threshold für einen Check-Namen.
*/
private function findThreshold(string $checkName): ?AlertThreshold
{
foreach ($this->thresholds as $threshold) {
if ($threshold->name === $checkName) {
return $threshold;
}
}
return null;
}
/**
* Generiert eine eindeutige Alert-ID.
*/
private function generateAlertId(string $checkName): string
{
return 'alert_' . md5($checkName . time());
}
/**
* Gibt die Standard-Thresholds zurück.
*
* @return AlertThreshold[]
*/
public static function getDefaultThresholds(): array
{
return [
new AlertThreshold(
name: 'Memory Usage',
warningThreshold: 80.0,
criticalThreshold: 90.0,
unit: '%',
description: 'Memory usage percentage'
),
new AlertThreshold(
name: 'Disk Usage',
warningThreshold: 80.0,
criticalThreshold: 90.0,
unit: '%',
description: 'Disk usage percentage'
),
new AlertThreshold(
name: 'System Load',
warningThreshold: 80.0,
criticalThreshold: 120.0,
unit: '%',
description: 'System load percentage'
),
];
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Services;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Process\Process;
use App\Framework\Process\ValueObjects\Command;
/**
* Backup Service.
*
* Erstellt Backups (Database, Files, Full).
*/
final readonly class BackupService
{
public function __construct(
private Process $process
) {
}
/**
* Erstellt ein Database-Backup.
*/
public function createDatabaseBackup(
string $database,
string $username,
string $password,
FilePath $outputFile
): bool {
$command = Command::fromArray([
'mysqldump',
'-u',
$username,
"-p{$password}",
$database,
'>',
$outputFile->toString(),
]);
$result = $this->process->run($command);
return $result->isSuccess();
}
/**
* Erstellt ein File-Backup (tar).
*/
public function createFileBackup(FilePath $sourceDirectory, FilePath $outputFile): bool
{
$command = Command::fromString(
"tar -czf {$outputFile->toString()} -C {$sourceDirectory->toString()} ."
);
$result = $this->process->run($command);
return $result->isSuccess();
}
/**
* Erstellt ein Full-Backup (Database + Files).
*/
public function createFullBackup(
string $database,
string $dbUsername,
string $dbPassword,
FilePath $sourceDirectory,
FilePath $outputDirectory
): bool {
$timestamp = date('Y-m-d_H-i-s');
$dbFile = FilePath::create($outputDirectory->toString() . "/db_backup_{$timestamp}.sql");
$filesFile = FilePath::create($outputDirectory->toString() . "/files_backup_{$timestamp}.tar.gz");
$dbSuccess = $this->createDatabaseBackup($database, $dbUsername, $dbPassword, $dbFile);
$filesSuccess = $this->createFileBackup($sourceDirectory, $filesFile);
return $dbSuccess && $filesSuccess;
}
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Services;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Process\Process;
use App\Framework\Process\ValueObjects\Command;
/**
* Maintenance Service.
*
* Führt Wartungs- und Cleanup-Operationen durch.
*/
final readonly class MaintenanceService
{
public function __construct(
private Process $process
) {
}
/**
* Löscht alte temporäre Dateien.
*
* @return int Anzahl gelöschter Dateien
*/
public function cleanTempFiles(Duration $olderThan): int
{
$tempDir = sys_get_temp_dir();
$days = (int) ceil($olderThan->toDays());
$command = Command::fromString(
"find {$tempDir} -type f -mtime +{$days} -delete"
);
$result = $this->process->run($command);
// Count deleted files (approximate)
return $result->isSuccess() ? 1 : 0;
}
/**
* Rotiert alte Log-Dateien.
*
* @return int Anzahl rotierter Dateien
*/
public function cleanLogFiles(FilePath $logDirectory, Duration $olderThan): int
{
if (! $logDirectory->isDirectory()) {
return 0;
}
$days = (int) ceil($olderThan->toDays());
$command = Command::fromString(
"find {$logDirectory->toString()} -name '*.log' -type f -mtime +{$days} -delete"
);
$result = $this->process->run($command);
return $result->isSuccess() ? 1 : 0;
}
/**
* Leert Cache-Verzeichnisse.
*/
public function cleanCache(FilePath $cacheDirectory): bool
{
if (! $cacheDirectory->isDirectory()) {
return false;
}
$command = Command::fromString(
"rm -rf {$cacheDirectory->toString()}/*"
);
$result = $this->process->run($command);
return $result->isSuccess();
}
/**
* Löscht alte Backups.
*
* @return int Anzahl gelöschter Backups
*/
public function cleanOldBackups(FilePath $backupDirectory, Duration $olderThan): int
{
if (! $backupDirectory->isDirectory()) {
return 0;
}
$days = (int) ceil($olderThan->toDays());
$command = Command::fromString(
"find {$backupDirectory->toString()} -type f -name '*.sql' -o -name '*.sql.gz' -mtime +{$days} -delete"
);
$result = $this->process->run($command);
return $result->isSuccess() ? 1 : 0;
}
/**
* Findet die größten Verzeichnisse.
*
* @return array<string, int> Verzeichnis => Größe in Bytes
*/
public function findLargestDirectories(FilePath $directory, int $limit = 10): array
{
if (! $directory->isDirectory()) {
return [];
}
$command = Command::fromString(
"du -h -d 1 {$directory->toString()} 2>/dev/null | sort -hr | head -{$limit}"
);
$result = $this->process->run($command);
if (! $result->isSuccess()) {
return [];
}
$directories = [];
$lines = explode("\n", trim($result->stdout));
foreach ($lines as $line) {
$parts = preg_split('/\s+/', $line, 2);
if (count($parts) === 2) {
$directories[$parts[1]] = $parts[0]; // Size as string (human-readable)
}
}
return $directories;
}
/**
* Findet doppelte Dateien.
*
* @return array<string, array<string>> Hash => Dateipfade
*/
public function findDuplicateFiles(FilePath $directory): array
{
if (! $directory->isDirectory()) {
return [];
}
$command = Command::fromString(
"find {$directory->toString()} -type f -exec md5sum {} \\; | sort | uniq -d -w 32"
);
$result = $this->process->run($command);
if (! $result->isSuccess()) {
return [];
}
$duplicates = [];
$lines = explode("\n", trim($result->stdout));
foreach ($lines as $line) {
$parts = preg_split('/\s+/', $line, 2);
if (count($parts) === 2) {
$hash = $parts[0];
$file = $parts[1];
if (! isset($duplicates[$hash])) {
$duplicates[$hash] = [];
}
$duplicates[$hash][] = $file;
}
}
return $duplicates;
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Services;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Process\Process;
use App\Framework\Process\ValueObjects\Command;
use App\Framework\Process\ValueObjects\ProcessDetails\ProcessDetails;
/**
* Process Monitoring Service.
*
* Bietet erweiterte Prozess-Überwachung und -Verwaltung.
*/
final readonly class ProcessMonitoringService
{
public function __construct(
private Process $process
) {
}
/**
* Listet alle laufenden Prozesse.
*
* @return ProcessDetails[]
*/
public function listProcesses(?string $filter = null): array
{
$command = Command::fromArray([
'ps',
'aux',
]);
$result = $this->process->run($command);
if (! $result->isSuccess()) {
return [];
}
$processes = [];
$lines = explode("\n", trim($result->stdout));
// Skip header line
array_shift($lines);
foreach ($lines as $line) {
if (empty(trim($line))) {
continue;
}
$parts = preg_split('/\s+/', $line, 11);
if (count($parts) < 11) {
continue;
}
$pid = (int) $parts[1];
$cpuPercent = (float) $parts[2];
$memoryPercent = (float) $parts[3];
$command = $parts[10] ?? '';
// Apply filter if provided
if ($filter !== null && stripos($command, $filter) === false) {
continue;
}
// Get memory usage (convert from percentage to bytes - approximate)
// This is a simplified approach; real memory would require /proc/[pid]/status
$memoryUsage = null; // Will be null for now, can be enhanced later
$processes[] = new ProcessDetails(
pid: $pid,
command: $command,
user: $parts[0] ?? null,
cpuPercent: $cpuPercent,
memoryUsage: $memoryUsage,
state: $parts[7] ?? null,
priority: isset($parts[5]) ? (int) $parts[5] : null
);
}
return $processes;
}
/**
* Findet Prozesse nach Name.
*
* @return ProcessDetails[]
*/
public function findProcesses(string $name): array
{
return $this->listProcesses($name);
}
/**
* Gibt die Prozess-Hierarchie zurück.
*
* @return array<int, array{pid: int, command: string, children: array}>
*/
public function getProcessTree(): array
{
$processes = $this->listProcesses();
$tree = [];
// Build tree structure
foreach ($processes as $proc) {
$tree[$proc->pid] = [
'pid' => $proc->pid,
'command' => $proc->command,
'children' => [],
];
}
// Build parent-child relationships
foreach ($processes as $proc) {
if ($proc->ppid !== null && isset($tree[$proc->ppid])) {
$tree[$proc->ppid]['children'][] = $proc->pid;
}
}
// Find root processes (ppid = 1 or null)
$roots = [];
foreach ($processes as $proc) {
if ($proc->ppid === null || $proc->ppid === 1) {
$roots[] = $proc->pid;
}
}
return [
'tree' => $tree,
'roots' => $roots,
];
}
/**
* Gibt Details eines spezifischen Prozesses zurück.
*/
public function getProcessDetails(int $pid): ?ProcessDetails
{
$command = Command::fromArray([
'ps',
'-p',
(string) $pid,
'-o',
'pid,comm,user,cpu,mem,etime,stat,pri',
]);
$result = $this->process->run($command);
if (! $result->isSuccess()) {
return null;
}
$lines = explode("\n", trim($result->stdout));
if (count($lines) < 2) {
return null;
}
// Skip header
$data = preg_split('/\s+/', trim($lines[1]));
if (count($data) < 8) {
return null;
}
return new ProcessDetails(
pid: (int) $data[0],
command: $data[1] ?? '',
user: $data[2] ?? null,
cpuPercent: isset($data[3]) ? (float) $data[3] : null,
memoryUsage: null, // Would need additional parsing
state: $data[6] ?? null,
priority: isset($data[7]) ? (int) $data[7] : null
);
}
/**
* Prüft, ob ein Prozess läuft.
*/
public function isProcessRunning(int $pid): bool
{
return $this->getProcessDetails($pid) !== null;
}
}

View File

@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Services;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Process\Process;
use App\Framework\Process\ValueObjects\Command;
use App\Framework\Process\ValueObjects\Ssl\CertificateInfo;
use App\Framework\Process\ValueObjects\Ssl\CertificateValidationResult;
/**
* SSL Certificate Service.
*
* Prüft SSL-Zertifikate von Domains und gibt strukturierte Informationen zurück.
*/
final readonly class SslCertificateService
{
public function __construct(
private Process $process
) {
}
/**
* Prüft SSL-Zertifikat eines Domains.
*/
public function checkCertificate(string $domain, int $port = 443): CertificateValidationResult
{
$certInfo = $this->getCertificateInfo($domain, $port);
if ($certInfo === null) {
return CertificateValidationResult::failed($domain, [
'Could not retrieve certificate information',
]);
}
$errors = [];
$warnings = [];
// Check if certificate is valid
if (! $certInfo->isValid()) {
$errors[] = 'Certificate is not valid (expired or not yet valid)';
}
// Check if certificate is expiring soon
if ($certInfo->isExpiringSoon(30)) {
$daysUntilExpiry = $certInfo->getDaysUntilExpiry();
$warnings[] = "Certificate expires in {$daysUntilExpiry} days";
}
// Check if certificate is self-signed
if ($certInfo->isSelfSigned) {
$warnings[] = 'Certificate is self-signed';
}
$result = CertificateValidationResult::success($domain, $certInfo);
if (! empty($warnings)) {
$result = $result->withWarnings($warnings);
}
if (! empty($errors)) {
return CertificateValidationResult::failed($domain, $errors);
}
return $result;
}
/**
* Gibt detaillierte Zertifikats-Informationen zurück.
*/
public function getCertificateInfo(string $domain, int $port = 443): ?CertificateInfo
{
$command = Command::fromArray([
'openssl',
's_client',
'-servername',
$domain,
'-connect',
"{$domain}:{$port}",
'-showcerts',
]);
$result = $this->process->run(
command: $command,
timeout: Duration::fromSeconds(10)
);
if (! $result->isSuccess()) {
return null;
}
// Extract certificate from output
$certStart = strpos($result->stdout, '-----BEGIN CERTIFICATE-----');
if ($certStart === false) {
return null;
}
$certEnd = strpos($result->stdout, '-----END CERTIFICATE-----', $certStart);
if ($certEnd === false) {
return null;
}
$certPem = substr($result->stdout, $certStart, $certEnd - $certStart + strlen('-----END CERTIFICATE-----'));
// Parse certificate using openssl
return $this->parseCertificate($certPem);
}
/**
* Parst Zertifikat aus PEM-Format.
*/
private function parseCertificate(string $certPem): ?CertificateInfo
{
// Use openssl to parse certificate details
$tempFile = tempnam(sys_get_temp_dir(), 'ssl_cert_');
if ($tempFile === false) {
return null;
}
try {
file_put_contents($tempFile, $certPem);
// Get certificate dates
$datesResult = $this->process->run(
Command::fromArray(['openssl', 'x509', '-in', $tempFile, '-noout', '-dates'])
);
// Get certificate subject
$subjectResult = $this->process->run(
Command::fromArray(['openssl', 'x509', '-in', $tempFile, '-noout', '-subject'])
);
// Get certificate issuer
$issuerResult = $this->process->run(
Command::fromArray(['openssl', 'x509', '-in', $tempFile, '-noout', '-issuer'])
);
// Get subject alternative names
$sanResult = $this->process->run(
Command::fromArray(['openssl', 'x509', '-in', $tempFile, '-noout', '-text'])
);
// Get serial number
$serialResult = $this->process->run(
Command::fromArray(['openssl', 'x509', '-in', $tempFile, '-noout', '-serial'])
);
// Get signature algorithm
$sigAlgResult = $this->process->run(
Command::fromArray(['openssl', 'x509', '-in', $tempFile, '-noout', '-signature'])
);
if (! $datesResult->isSuccess() || ! $subjectResult->isSuccess() || ! $issuerResult->isSuccess()) {
return null;
}
// Parse dates
$validFrom = null;
$validTo = null;
if (preg_match('/notBefore=(.+)/', $datesResult->stdout, $matches)) {
$validFrom = new \DateTimeImmutable(trim($matches[1]));
}
if (preg_match('/notAfter=(.+)/', $datesResult->stdout, $matches)) {
$validTo = new \DateTimeImmutable(trim($matches[1]));
}
if ($validFrom === null || $validTo === null) {
return null;
}
// Parse subject
$subject = '';
if (preg_match('/subject=(.+)/', $subjectResult->stdout, $matches)) {
$subject = trim($matches[1]);
}
// Parse issuer
$issuer = '';
if (preg_match('/issuer=(.+)/', $issuerResult->stdout, $matches)) {
$issuer = trim($matches[1]);
}
// Check if self-signed
$isSelfSigned = $subject === $issuer;
// Parse subject alternative names
$subjectAltNames = [];
if (preg_match('/Subject Alternative Name:\s*(.+)/', $sanResult->stdout, $matches)) {
$sanString = trim($matches[1]);
// Parse DNS: entries
if (preg_match_all('/DNS:([^,]+)/', $sanString, $sanMatches)) {
$subjectAltNames = $sanMatches[1];
}
}
// Parse serial number
$serialNumber = null;
if (preg_match('/serial=(.+)/', $serialResult->stdout, $matches)) {
$serialNumber = trim($matches[1]);
}
// Parse signature algorithm (from text output)
$signatureAlgorithm = null;
if (preg_match('/Signature Algorithm:\s*([^\n]+)/', $sanResult->stdout, $matches)) {
$signatureAlgorithm = trim($matches[1]);
}
return new CertificateInfo(
subject: $subject,
issuer: $issuer,
validFrom: $validFrom,
validTo: $validTo,
subjectAltNames: $subjectAltNames,
isSelfSigned: $isSelfSigned,
serialNumber: $serialNumber,
signatureAlgorithm: $signatureAlgorithm
);
} finally {
@unlink($tempFile);
}
}
/**
* Prüft mehrere Domains auf ablaufende Zertifikate.
*
* @param string[] $domains
* @param int $daysThreshold Tage vor Ablauf
* @return CertificateValidationResult[]
*/
public function findExpiringCertificates(array $domains, int $daysThreshold = 30): array
{
$results = [];
foreach ($domains as $domain) {
$result = $this->checkCertificate($domain);
if ($result->certificateInfo !== null && $result->certificateInfo->isExpiringSoon($daysThreshold)) {
$results[] = $result;
}
}
return $results;
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\Services;
use App\Framework\Process\Process;
use App\Framework\Process\ValueObjects\Command;
/**
* Systemd Service.
*
* Verwaltet Systemd-Services über systemctl.
*/
final readonly class SystemdService
{
public function __construct(
private Process $process
) {
}
/**
* Listet alle Services.
*
* @return array<string, array{name: string, status: string, active: bool}>
*/
public function listServices(bool $all = false): array
{
$command = Command::fromArray([
'systemctl',
'list-units',
'--type=service',
'--no-pager',
'--no-legend',
]);
if ($all) {
$command = Command::fromArray([
'systemctl',
'list-units',
'--type=service',
'--all',
'--no-pager',
'--no-legend',
]);
}
$result = $this->process->run($command);
if (! $result->isSuccess()) {
return [];
}
$services = [];
$lines = explode("\n", trim($result->stdout));
foreach ($lines as $line) {
if (empty(trim($line))) {
continue;
}
$parts = preg_split('/\s+/', $line, 6);
if (count($parts) < 5) {
continue;
}
$name = $parts[0];
$status = $parts[3] ?? 'unknown';
$active = ($parts[3] ?? '') === 'active';
$services[] = [
'name' => $name,
'status' => $status,
'active' => $active,
];
}
return $services;
}
/**
* Gibt den Status eines Services zurück.
*/
public function getServiceStatus(string $service): ?array
{
$command = Command::fromArray([
'systemctl',
'status',
$service,
'--no-pager',
]);
$result = $this->process->run($command);
if (! $result->isSuccess()) {
return null;
}
// Parse status output
$lines = explode("\n", $result->stdout);
$status = [
'name' => $service,
'active' => false,
'enabled' => false,
];
foreach ($lines as $line) {
if (strpos($line, 'Active:') !== false) {
$status['active'] = strpos($line, 'active') !== false;
}
if (strpos($line, 'Loaded:') !== false) {
$status['enabled'] = strpos($line, 'enabled') !== false;
}
}
return $status;
}
/**
* Startet einen Service.
*/
public function startService(string $service): bool
{
$result = $this->process->run(
Command::fromArray(['systemctl', 'start', $service])
);
return $result->isSuccess();
}
/**
* Stoppt einen Service.
*/
public function stopService(string $service): bool
{
$result = $this->process->run(
Command::fromArray(['systemctl', 'stop', $service])
);
return $result->isSuccess();
}
/**
* Startet einen Service neu.
*/
public function restartService(string $service): bool
{
$result = $this->process->run(
Command::fromArray(['systemctl', 'restart', $service])
);
return $result->isSuccess();
}
/**
* Aktiviert einen Service.
*/
public function enableService(string $service): bool
{
$result = $this->process->run(
Command::fromArray(['systemctl', 'enable', $service])
);
return $result->isSuccess();
}
/**
* Deaktiviert einen Service.
*/
public function disableService(string $service): bool
{
$result = $this->process->run(
Command::fromArray(['systemctl', 'disable', $service])
);
return $result->isSuccess();
}
/**
* Gibt fehlgeschlagene Services zurück.
*
* @return array<string>
*/
public function getFailedServices(): array
{
$command = Command::fromArray([
'systemctl',
'list-units',
'--type=service',
'--state=failed',
'--no-pager',
'--no-legend',
]);
$result = $this->process->run($command);
if (! $result->isSuccess()) {
return [];
}
$failed = [];
$lines = explode("\n", trim($result->stdout));
foreach ($lines as $line) {
if (empty(trim($line))) {
continue;
}
$parts = preg_split('/\s+/', $line);
if (! empty($parts[0])) {
$failed[] = $parts[0];
}
}
return $failed;
}
}

Some files were not shown because too many files have changed in this diff Show More