feat: add PHP ini management system and update infrastructure configs

- Add PHP ini management classes (Access, IniDirective, IniKey, PhpIni)
- Update deployment configurations (Wireguard, Traefik, Monitoring)
- Add DNS stack and Ansible role
- Add deployment debugging playbooks
- Update framework components (FilePath, RedisConnectionPool)
- Update .gitignore and documentation
This commit is contained in:
2025-11-02 15:29:41 +01:00
parent e628d30fa0
commit edcf509a4f
29 changed files with 926 additions and 39 deletions

2
.gitignore vendored
View File

@@ -58,3 +58,5 @@ cookies_new.txt
playwright-report/
test-results/
.playwright/
# WireGuard client configs (generated locally)
deployment/ansible/wireguard-clients/

View File

@@ -10,6 +10,7 @@
wireguard_config_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}.conf"
wireguard_client_configs_path: "/etc/wireguard/clients"
wireguard_local_client_configs_dir: "{{ playbook_dir }}/../wireguard-clients"
wireguard_dns_servers: []
pre_tasks:
- name: Set WireGuard network
@@ -61,6 +62,11 @@
set_fact:
server_vpn_ip: "{{ (wireguard_server_config_read.content | b64decode | regex_search('Address = ([0-9.]+)', '\\1')) | first | default('10.8.0.1') }}"
- name: Set default DNS servers if not provided
set_fact:
wireguard_dns_servers: "{{ [server_vpn_ip] }}"
when: wireguard_dns_servers | length == 0
- name: Extract WireGuard server IP octets
set_fact:
wireguard_server_ip_octets: "{{ server_vpn_ip.split('.') }}"

View File

@@ -0,0 +1,147 @@
---
- name: Check Production Server Status
hosts: production
gather_facts: yes
become: no
tasks:
- name: Check server uptime and basic info
shell: |
echo "=== Server Uptime ==="
uptime
echo ""
echo "=== Disk Space ==="
df -h
echo ""
echo "=== Memory Usage ==="
free -h
echo ""
echo "=== Docker Status ==="
docker --version || echo "Docker not found"
docker ps || echo "Docker not running"
args:
executable: /bin/bash
register: server_info
ignore_errors: yes
failed_when: false
- name: Display server info
debug:
msg: "{{ server_info.stdout_lines }}"
- name: Check all Docker stacks status
shell: |
echo "=== Traefik Stack ==="
cd ~/deployment/stacks/traefik && docker compose ps 2>&1 || echo "Traefik stack not found or not running"
echo ""
echo "=== Application Stack ==="
cd ~/deployment/stacks/application && docker compose ps 2>&1 || echo "Application stack not found or not running"
echo ""
echo "=== PostgreSQL Stack ==="
cd ~/deployment/stacks/postgresql && docker compose ps 2>&1 || echo "PostgreSQL stack not found or not running"
echo ""
echo "=== Monitoring Stack ==="
cd ~/deployment/stacks/monitoring && docker compose ps 2>&1 || echo "Monitoring stack not found or not running"
echo ""
echo "=== Gitea Stack ==="
cd ~/deployment/stacks/gitea && docker compose ps 2>&1 || echo "Gitea stack not found or not running"
echo ""
echo "=== Registry Stack ==="
cd ~/deployment/stacks/registry && docker compose ps 2>&1 || echo "Registry stack not found or not running"
args:
executable: /bin/bash
register: stacks_status
ignore_errors: yes
failed_when: false
- name: Display stacks status
debug:
msg: "{{ stacks_status.stdout_lines }}"
- name: Check Traefik logs for errors
shell: |
cd ~/deployment/stacks/traefik
echo "=== Traefik Logs (Last 30 lines) ==="
docker compose logs --tail=30 traefik 2>&1 | tail -30 || echo "Could not read Traefik logs"
args:
executable: /bin/bash
register: traefik_logs
ignore_errors: yes
failed_when: false
- name: Display Traefik logs
debug:
msg: "{{ traefik_logs.stdout_lines }}"
- name: Check Application stack logs
shell: |
cd ~/deployment/stacks/application
echo "=== Application Nginx Logs (Last 20 lines) ==="
docker compose logs --tail=20 web 2>&1 | tail -20 || echo "Could not read web logs"
echo ""
echo "=== Application PHP Logs (Last 20 lines) ==="
docker compose logs --tail=20 php 2>&1 | tail -20 || echo "Could not read PHP logs"
args:
executable: /bin/bash
register: app_logs
ignore_errors: yes
failed_when: false
- name: Display application logs
debug:
msg: "{{ app_logs.stdout_lines }}"
- name: Test HTTP connectivity
shell: |
echo "=== Testing HTTP Connectivity ==="
echo "Test 1: HTTPS to michaelschiemer.de"
curl -k -H "User-Agent: Mozilla/5.0" -s -o /dev/null -w "HTTP %{http_code}\n" https://michaelschiemer.de/health || echo "Connection failed"
echo ""
echo "Test 2: Direct localhost"
curl -k -H "User-Agent: Mozilla/5.0" -s -o /dev/null -w "HTTP %{http_code}\n" https://localhost/health || echo "Connection failed"
args:
executable: /bin/bash
register: http_tests
ignore_errors: yes
failed_when: false
- name: Display HTTP test results
debug:
msg: "{{ http_tests.stdout_lines }}"
- name: Check network connectivity
shell: |
echo "=== Network Interfaces ==="
ip addr show | grep -E "(inet |state)" | head -10
echo ""
echo "=== Docker Networks ==="
docker network ls
echo ""
echo "=== Traefik Network Connectivity ==="
docker network inspect traefik-public 2>&1 | grep -E "(Name|Subnet|Containers)" | head -10 || echo "Traefik network not found"
args:
executable: /bin/bash
register: network_info
ignore_errors: yes
failed_when: false
- name: Display network info
debug:
msg: "{{ network_info.stdout_lines }}"
- name: Check firewall status
shell: |
echo "=== Firewall Status ==="
sudo ufw status || echo "UFW not installed or not configured"
echo ""
echo "=== Listening Ports ==="
sudo netstat -tlnp | grep -E "(80|443|8080|3000)" | head -10 || ss -tlnp | grep -E "(80|443|8080|3000)" | head -10 || echo "Could not check listening ports"
args:
executable: /bin/bash
register: firewall_info
ignore_errors: yes
failed_when: false
- name: Display firewall info
debug:
msg: "{{ firewall_info.stdout_lines }}"

View File

@@ -0,0 +1,49 @@
---
- name: Check Staging 500 Error
hosts: production
gather_facts: yes
become: no
tasks:
- name: Get recent PHP errors from staging-app
shell: |
cd ~/deployment/stacks/staging
echo "=== Recent PHP errors (last 50 lines) ==="
docker compose exec -T staging-app tail -100 /var/www/html/storage/logs/php-errors.log 2>&1 | tail -50
args:
executable: /bin/bash
register: php_errors
ignore_errors: yes
failed_when: false
- name: Display PHP errors
debug:
msg: "{{ php_errors.stdout_lines }}"
- name: Get docker compose logs for staging-app
shell: |
cd ~/deployment/stacks/staging
echo "=== Recent staging-app container logs ==="
docker compose logs --tail=50 staging-app 2>&1 | tail -50
args:
executable: /bin/bash
register: container_logs
ignore_errors: yes
failed_when: false
- name: Display container logs
debug:
msg: "{{ container_logs.stdout_lines }}"
- name: Test health endpoint
shell: |
curl -H "User-Agent: Mozilla/5.0" -s https://staging.michaelschiemer.de/health 2>&1
args:
executable: /bin/bash
register: health_test
ignore_errors: yes
failed_when: false
- name: Display health endpoint result
debug:
msg: "{{ health_test.stdout }}"

View File

@@ -0,0 +1,125 @@
---
- name: Debug Grafana 403 Error
hosts: production
gather_facts: yes
become: no
# This playbook requires the production inventory file
# Run with: ansible-playbook -i ../inventory/production.yml debug-grafana-403.yml
tasks:
- name: Check Traefik logs for recent Grafana access attempts
shell: |
cd ~/deployment/stacks/traefik
echo "=== Recent Traefik Access Logs (last 50 lines with grafana) ==="
docker compose logs --tail=100 traefik 2>&1 | grep -i grafana | tail -50 || echo "No grafana entries found"
args:
executable: /bin/bash
register: traefik_logs
ignore_errors: yes
failed_when: false
- name: Display Traefik logs
debug:
msg: "{{ traefik_logs.stdout_lines }}"
- name: Check Traefik access log file
shell: |
cd ~/deployment/stacks/traefik
echo "=== Recent Traefik Access Log (last 50 lines) ==="
tail -50 logs/access.log 2>&1 | tail -50 || echo "Access log not found"
args:
executable: /bin/bash
register: access_log
ignore_errors: yes
failed_when: false
- name: Display access log
debug:
msg: "{{ access_log.stdout_lines }}"
- name: Check Grafana container status
shell: |
cd ~/deployment/stacks/monitoring
docker compose ps grafana
args:
executable: /bin/bash
register: grafana_status
ignore_errors: yes
failed_when: false
- name: Display Grafana status
debug:
msg: "{{ grafana_status.stdout_lines }}"
- name: Check Grafana Traefik labels
shell: |
cd ~/deployment/stacks/monitoring
docker compose config | grep -A 20 "grafana:" | grep -E "(ipwhitelist|middleware|sourcerange)" || echo "No IP whitelist labels found"
args:
executable: /bin/bash
register: grafana_labels
ignore_errors: yes
failed_when: false
- name: Display Grafana labels
debug:
msg: "{{ grafana_labels.stdout_lines }}"
- name: Check CoreDNS configuration
shell: |
cd ~/deployment/stacks/dns
echo "=== CoreDNS Corefile ==="
cat Corefile 2>&1 || echo "Corefile not found"
args:
executable: /bin/bash
register: coredns_config
ignore_errors: yes
failed_when: false
- name: Display CoreDNS configuration
debug:
msg: "{{ coredns_config.stdout_lines }}"
- name: Check monitoring stack environment variables
shell: |
cd ~/deployment/stacks/monitoring
echo "=== MONITORING_VPN_IP_WHITELIST ==="
grep MONITORING_VPN_IP_WHITELIST .env 2>&1 || echo "Variable not found in .env"
args:
executable: /bin/bash
register: monitoring_env
ignore_errors: yes
failed_when: false
- name: Display monitoring environment
debug:
msg: "{{ monitoring_env.stdout_lines }}"
- name: Test DNS resolution for grafana.michaelschiemer.de
shell: |
echo "=== DNS Resolution Test ==="
dig +short grafana.michaelschiemer.de @10.8.0.1 2>&1 || echo "DNS resolution failed"
args:
executable: /bin/bash
register: dns_test
ignore_errors: yes
failed_when: false
- name: Display DNS test result
debug:
msg: "{{ dns_test.stdout_lines }}"
- name: Check WireGuard interface status
shell: |
echo "=== WireGuard Interface Status ==="
sudo wg show 2>&1 || echo "WireGuard not running or no permissions"
args:
executable: /bin/bash
register: wg_status
ignore_errors: yes
failed_when: false
- name: Display WireGuard status
debug:
msg: "{{ wg_status.stdout_lines }}"

View File

@@ -0,0 +1,142 @@
---
- name: Fix Traefik Configuration
hosts: production
gather_facts: no
become: no
tasks:
- name: Backup current traefik.yml
shell: |
cd ~/deployment/stacks/traefik
cp traefik.yml traefik.yml.backup.$(date +%Y%m%d_%H%M%S)
args:
executable: /bin/bash
- name: Create correct traefik.yml
copy:
content: |
# Static Configuration for Traefik
# Global Configuration
global:
checkNewVersion: true
sendAnonymousUsage: false
# API and Dashboard
# Note: insecure: false means API is only accessible via HTTPS (through Traefik itself)
# No port 8080 needed - dashboard accessible via HTTPS at traefik.michaelschiemer.de
api:
dashboard: true
insecure: false
# Dashboard accessible via HTTPS router (no separate HTTP listener needed)
# Entry Points
entryPoints:
web:
address: ":80"
# No global redirect - ACME challenges need HTTP access
# Redirects are handled per-router via middleware
websecure:
address: ":443"
http:
tls:
certResolver: letsencrypt
domains:
- main: michaelschiemer.de
sans:
- "*.michaelschiemer.de"
# Certificate Resolvers
certificatesResolvers:
letsencrypt:
acme:
email: kontakt@michaelschiemer.de
storage: /acme.json
caServer: https://acme-v02.api.letsencrypt.org/directory
# Use HTTP-01 challenge (requires port 80 accessible)
httpChallenge:
entryPoint: web
# Uncomment for DNS challenge (requires DNS provider)
# dnsChallenge:
# provider: cloudflare
# delayBeforeCheck: 30
# Providers
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
# Network mode is 'host', so we don't specify a network here
# Traefik can reach containers directly via their IPs in host network mode
watch: true
file:
directory: /dynamic
watch: true
# 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
insecure: false
# Logging
log:
level: INFO
filePath: /logs/traefik.log
format: json
# Access Logs
accessLog:
filePath: /logs/access.log
format: json
bufferingSize: 100
filters:
statusCodes:
- "400-499"
- "500-599"
# Metrics
metrics:
prometheus:
addEntryPointsLabels: true
addRoutersLabels: true
addServicesLabels: true
# Ping
ping:
entryPoint: web
dest: ~/deployment/stacks/traefik/traefik.yml
mode: '0644'
- name: Validate YAML syntax
command: python3 -c "import yaml; yaml.safe_load(open('traefik.yml')); print('YAML valid')"
args:
chdir: ~/deployment/stacks/traefik
changed_when: false
- name: Restart Traefik
command: docker compose up -d traefik
args:
chdir: ~/deployment/stacks/traefik
register: traefik_restart
- name: Wait for Traefik to start
pause:
seconds: 5
- name: Check Traefik status
command: docker compose ps traefik
args:
chdir: ~/deployment/stacks/traefik
register: traefik_status
- name: Display Traefik status
debug:
msg: "{{ traefik_status.stdout_lines }}"

View File

@@ -59,17 +59,22 @@
import_role:
name: registry
# 4. Deploy MinIO (Object Storage)
# 4. Deploy DNS (CoreDNS for WireGuard clients)
- name: Deploy DNS stack
import_role:
name: dns
# 5. Deploy MinIO (Object Storage)
- name: Deploy MinIO stack
import_role:
name: minio
# 5. Deploy Gitea (CRITICAL - Git Server + MySQL + Redis)
# 6. Deploy Gitea (CRITICAL - Git Server + MySQL + Redis)
- name: Deploy Gitea stack
import_role:
name: gitea
# 6. Deploy Monitoring (Portainer + Grafana + Prometheus)
# 7. Deploy Monitoring (Portainer + Grafana + Prometheus)
- name: Deploy Monitoring stack
import_role:
name: monitoring
@@ -98,7 +103,7 @@
debug:
msg: "Gitea HTTPS check: {{ 'SUCCESS' if gitea_http_check.status == 200 else 'FAILED - Status: ' + (gitea_http_check.status|string) }}"
# 7. Deploy Application Stack
# 8. Deploy Application Stack
- name: Deploy Application Stack
import_role:
name: application
@@ -131,6 +136,7 @@
- "Traefik: {{ 'Deployed' if traefik_stack_changed else 'Already running' }}"
- "PostgreSQL: {{ 'Deployed' if postgresql_stack_changed else 'Already running' }}"
- "Docker Registry: {{ 'Deployed' if registry_stack_changed else 'Already running' }}"
- "DNS: {{ 'Deployed' if dns_stack_changed else 'Already running' }}"
- "MinIO: {{ 'Deployed' if minio_stack_changed else 'Already running' }}"
- "Gitea: {{ 'Deployed' if gitea_stack_changed else 'Already running' }}"
- "Monitoring: {{ 'Deployed' if monitoring_stack_changed else 'Already running' }}"

View File

@@ -0,0 +1,9 @@
---
dns_stack_path: "{{ stacks_base_path }}/dns"
dns_corefile_template: "{{ role_path }}/../../templates/dns-Corefile.j2"
dns_forwarders:
- 1.1.1.1
- 8.8.8.8
dns_records:
- host: "grafana.{{ app_domain }}"
address: "{{ wireguard_server_ip_default | default('10.8.0.1') }}"

View File

@@ -0,0 +1,33 @@
---
- name: Ensure DNS stack directory exists
file:
path: "{{ dns_stack_path }}"
state: directory
mode: '0755'
tags:
- dns
- name: Render CoreDNS configuration
template:
src: "{{ dns_corefile_template }}"
dest: "{{ dns_stack_path }}/Corefile"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: '0644'
tags:
- dns
- name: Deploy DNS stack
community.docker.docker_compose_v2:
project_src: "{{ dns_stack_path }}"
state: present
pull: always
register: dns_compose_result
tags:
- dns
- name: Record DNS deployment facts
set_fact:
dns_stack_changed: "{{ dns_compose_result.changed | default(false) }}"
tags:
- dns

View File

@@ -3,4 +3,5 @@ monitoring_stack_path: "{{ stacks_base_path }}/monitoring"
monitoring_wait_timeout: "{{ wait_timeout | default(60) }}"
monitoring_env_template: "{{ role_path }}/../../templates/monitoring.env.j2"
monitoring_vault_file: "{{ role_path }}/../../secrets/production.vault.yml"
monitoring_vpn_ip_whitelist: "{{ wireguard_network_default | default('10.8.0.0/24') }}"
# VPN IP whitelist: Allow WireGuard VPN network only (override via extra vars if needed)
monitoring_vpn_ip_whitelist: "{{ monitoring_vpn_ip_whitelist_ranges | default([wireguard_network_default | default('10.8.0.0/24')]) | join(',') }}"

View File

@@ -15,6 +15,7 @@
no_log: yes
delegate_to: localhost
become: no
ignore_errors: yes
tags:
- monitoring
@@ -48,6 +49,36 @@
tags:
- monitoring
- name: Build VPN IP whitelist with endpoints
set_fact:
monitoring_vpn_ip_whitelist_ranges: "{{ [wireguard_network_default | default('10.8.0.0/24')] }}"
tags:
- monitoring
- name: Set VPN IP whitelist for monitoring
set_fact:
monitoring_vpn_ip_whitelist: "{{ monitoring_vpn_ip_whitelist_ranges | join(',') }}"
tags:
- monitoring
- name: Set Traefik stack path
set_fact:
traefik_stack_path: "{{ stacks_base_path }}/traefik"
tags:
- monitoring
- name: Update Traefik middleware with dynamic VPN IPs
template:
src: "{{ role_path }}/../../templates/traefik-middlewares.yml.j2"
dest: "{{ traefik_stack_path }}/dynamic/middlewares.yml"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: '0644'
vars:
vpn_network: "{{ wireguard_network_default | default('10.8.0.0/24') }}"
tags:
- monitoring
- name: Ensure monitoring stack directory exists
file:
path: "{{ monitoring_stack_path }}"

View File

@@ -0,0 +1,15 @@
. {
log
errors
health :8053
hosts {
{% for record in dns_records %}
{{ record.address }} {{ record.host }}
{% endfor %}
fallthrough
}
{% if dns_forwarders | length > 0 %}
forward . {{ dns_forwarders | join(' ') }}
{% endif %}
cache 30
}

View File

@@ -0,0 +1,82 @@
# Dynamic Middleware Configuration
http:
middlewares:
# Security headers for all services
security-headers-global:
headers:
frameDeny: true
contentTypeNosniff: true
browserXssFilter: true
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
forceSTSHeader: true
customFrameOptionsValue: "SAMEORIGIN"
contentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';"
referrerPolicy: "strict-origin-when-cross-origin"
permissionsPolicy: "geolocation=(), microphone=(), camera=()"
# Compression for better performance
gzip-compression:
compress:
excludedContentTypes:
- text/event-stream
# Rate limiting - strict
rate-limit-strict:
rateLimit:
average: 50
burst: 25
period: 1s
# Rate limiting - moderate
rate-limit-moderate:
rateLimit:
average: 100
burst: 50
period: 1s
# Rate limiting - lenient
rate-limit-lenient:
rateLimit:
average: 200
burst: 100
period: 1s
# IP whitelist for admin services (example)
# Uncomment and adjust for production
# admin-whitelist:
# ipWhiteList:
# sourceRange:
# - "127.0.0.1/32"
# - "10.0.0.0/8"
# VPN-only IP whitelist for Grafana and other monitoring services
# Restrict access strictly to the WireGuard VPN network
grafana-vpn-only:
ipWhiteList:
sourceRange:
- "{{ vpn_network }}" # WireGuard VPN network
# VPN-only IP whitelist for general use (Traefik Dashboard, etc.)
# Restrict access strictly to the WireGuard network
vpn-only:
ipWhiteList:
sourceRange:
- "{{ vpn_network }}" # WireGuard VPN network
# Chain multiple middlewares
default-chain:
chain:
middlewares:
- security-headers-global
- gzip-compression
admin-chain:
chain:
middlewares:
- security-headers-global
- gzip-compression
- rate-limit-strict
# - admin-whitelist # Uncomment for IP whitelisting

View File

@@ -8,8 +8,8 @@ PrivateKey = {{ client_private_key.stdout }}
# Client IP address in VPN network
Address = {{ client_ip }}/24
# DNS server (optional)
DNS = 1.1.1.1, 8.8.8.8
# DNS server (VPN internal resolver)
DNS = {{ wireguard_dns_servers | join(', ') }}
[Peer]
# Server public key
@@ -24,4 +24,4 @@ Endpoint = {{ server_external_ip_content }}:{{ wireguard_port }}
AllowedIPs = {{ allowed_ips }}
# Keep connection alive
PersistentKeepalive = 25
PersistentKeepalive = 25

View File

@@ -3,13 +3,13 @@
[Interface]
# Client private key
PrivateKey = uMhNKh+Wi0aykTnazfSJD6l7Wc2V1Pe+7rFtFcnfynw=
PrivateKey = sE81MBr64fP8YBDlhRWngwHHmlrVzIhs9NT7Dh7XbVs=
# Client IP address in VPN network
Address = 10.8.0.4/24
Address = 10.8.0.7/24
# DNS server (optional)
DNS = 1.1.1.1, 8.8.8.8
# DNS server (VPN internal resolver)
DNS = 10.8.0.1
[Peer]
# Server public key
@@ -24,4 +24,4 @@ Endpoint = 94.16.110.151:51820
AllowedIPs = 10.8.0.0/24
# Keep connection alive
PersistentKeepalive = 25
PersistentKeepalive = 25

View File

@@ -0,0 +1,14 @@
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

@@ -75,8 +75,9 @@ services:
- "traefik.http.routers.grafana.entrypoints=websecure"
- "traefik.http.routers.grafana.tls=true"
- "traefik.http.routers.grafana.tls.certresolver=letsencrypt"
- "traefik.http.middlewares.grafana-vpn-only.ipwhitelist.sourcerange=${MONITORING_VPN_IP_WHITELIST:-10.8.0.0/24}"
- "traefik.http.routers.grafana.middlewares=grafana-vpn-only"
# 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

@@ -11,7 +11,10 @@ Traefik acts as the central reverse proxy for all services, handling:
## Services
- **traefik.michaelschiemer.de** - Traefik Dashboard (BasicAuth protected)
- **traefik.michaelschiemer.de** - Traefik Dashboard (VPN-only + BasicAuth protected)
- ?? **Nur ?ber WireGuard VPN erreichbar** (10.8.0.0/24)
- Zus?tzlich durch BasicAuth gesch?tzt
- ?ffentlicher Zugriff ist blockiert
## Prerequisites
@@ -126,6 +129,16 @@ labels:
- "traefik.http.routers.myapp.middlewares=gzip-compression@file"
```
### VPN-Only Access (WireGuard Network)
```yaml
labels:
# Restrict access to WireGuard VPN network only (10.8.0.0/24)
- "traefik.http.routers.myapp.middlewares=vpn-only@file"
# Combined: VPN-only + BasicAuth (order matters - VPN check first, then BasicAuth)
- "traefik.http.routers.myapp.middlewares=vpn-only@file,traefik-auth"
```
### Middleware Chains
```yaml
labels:

View File

@@ -5,11 +5,14 @@ services:
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- traefik-public
ports:
- "80:80"
- "443:443"
# Use host network mode to correctly identify client IPs from WireGuard
# Without this, Traefik sees Docker bridge IPs instead of real client IPs (10.8.0.x)
network_mode: host
# When using host network mode, we don't bind ports in docker-compose
# Traefik listens directly on host ports 80 and 443
# ports:
# - "80:80"
# - "443:443"
environment:
- TZ=Europe/Berlin
volumes:
@@ -27,13 +30,15 @@ services:
# Enable Traefik for itself
- "traefik.enable=true"
# Dashboard
# Dashboard - VPN-only access (WireGuard network required)
# Accessible only from WireGuard VPN network (10.8.0.0/24)
- "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.michaelschiemer.de`)"
- "traefik.http.routers.traefik-dashboard.entrypoints=websecure"
- "traefik.http.routers.traefik-dashboard.tls=true"
- "traefik.http.routers.traefik-dashboard.tls.certresolver=letsencrypt"
- "traefik.http.routers.traefik-dashboard.service=api@internal"
- "traefik.http.routers.traefik-dashboard.middlewares=traefik-auth"
# VPN-only + BasicAuth protection (order: vpn-only first, then BasicAuth)
- "traefik.http.routers.traefik-dashboard.middlewares=vpn-only@file,traefik-auth"
# BasicAuth for dashboard (user: admin, password: generate with htpasswd)
# htpasswd -nb admin your_password
@@ -73,6 +78,5 @@ services:
retries: 3
start_period: 10s
networks:
traefik-public:
external: true
# Note: network_mode: host is used, so we don't define networks here
# Traefik still discovers services via Docker labels using the Docker socket

View File

@@ -51,6 +51,20 @@ http:
# sourceRange:
# - "127.0.0.1/32"
# - "10.0.0.0/8"
# VPN-only IP whitelist for Grafana and other monitoring services
# Restrict access strictly to the WireGuard network
grafana-vpn-only:
ipWhiteList:
sourceRange:
- "10.8.0.0/24" # WireGuard VPN network
# VPN-only IP whitelist for general use (Traefik Dashboard, etc.)
# Restrict access strictly to the WireGuard network
vpn-only:
ipWhiteList:
sourceRange:
- "10.8.0.0/24" # WireGuard VPN network
# Chain multiple middlewares
default-chain:

View File

@@ -6,9 +6,12 @@ global:
sendAnonymousUsage: false
# API and Dashboard
# Note: insecure: false means API is only accessible via HTTPS (through Traefik itself)
# No port 8080 needed - dashboard accessible via HTTPS at traefik.michaelschiemer.de
api:
dashboard: true
insecure: false
# Dashboard accessible via HTTPS router (no separate HTTP listener needed)
# Entry Points
entryPoints:
@@ -26,9 +29,6 @@ entryPoints:
- main: michaelschiemer.de
sans:
- "*.michaelschiemer.de"
middlewares:
- security-headers@docker
- compression@docker
# Certificate Resolvers
certificatesResolvers:
@@ -50,13 +50,25 @@ providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: traefik-public
# Network mode is 'host', so we don't specify a network here
# Traefik can reach containers directly via their IPs in host network mode
watch: true
file:
directory: /dynamic
watch: true
# 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
insecure: false
# Logging
log:
level: INFO

View File

@@ -12,7 +12,9 @@ Dieses Dokument beschreibt die geplante Sicherheitshärtung für Backend-Dienste
**Aktueller Zustand:**
- ✅ WireGuard VPN ist installiert und funktionsfähig
-Backend-Dienste sind öffentlich über Traefik erreichbar
-Traefik-Dashboard ist jetzt VPN-only (nur über WireGuard erreichbar)
- ✅ Grafana ist VPN-only (nur über WireGuard erreichbar)
- ✅ Andere Backend-Dienste sind noch öffentlich über Traefik erreichbar
- ✅ VPN-Zugriff funktioniert parallel
**Zielzustand:**
@@ -38,11 +40,19 @@ Folgende Dienste sollen später nur noch über VPN erreichbar sein:
- Aktuell: `https://portainer.michaelschiemer.de` (öffentlich)
- Zukünftig: Nur `https://10.8.0.1:9443` über VPN
## Aktueller Test: Grafana nur per VPN
## Aktuell implementiert: VPN-only Services
- Realisiert über Traefik-IP-Whitelist (`grafana-vpn-only` Middleware)
- Whitelist-Wert: `MONITORING_VPN_IP_WHITELIST` in `deployment/stacks/monitoring/.env` (Ansible default `monitoring_vpn_ip_whitelist`)
- Ziel: Erst Grafana absichern, später Prometheus/Portainer nachziehen
### Traefik-Dashboard
- ✅ Realisiert über Traefik-IP-Whitelist (`vpn-only` Middleware)
- ✅ Zusätzlich durch BasicAuth geschützt
- ✅ Whitelist umfasst standardmäßig ausschließlich das WireGuard-Netz `10.8.0.0/24`
- ✅ Öffentlicher Zugriff ist blockiert (403 Forbidden)
### Grafana
- ✅ Realisiert über Traefik-IP-Whitelist (`grafana-vpn-only` Middleware)
- ✅ Whitelist umfasst standardmäßig ausschließlich das WireGuard-Netz `10.8.0.0/24`
- ✅ Anpassung via Ansible-Extra-Var `monitoring_vpn_ip_whitelist_ranges` möglich (falls weitere Netze freigeschaltet werden sollen)
- ✅ CoreDNS-Override aktiv, damit `grafana.michaelschiemer.de` im VPN auf `10.8.0.1` zeigt
### Rollout-Schritte
1. Neue Stacks auf den Server syncen: \
@@ -55,6 +65,8 @@ Folgende Dienste sollen später nur noch über VPN erreichbar sein:
- Ohne VPN (öffentliche IP): `curl -I https://grafana.michaelschiemer.de``403 Forbidden`
- Mit WireGuard (`10.8.0.x` Client): Grafana im Browser laden, Login funktioniert
- Optional: Traefik-Logs prüfen (`docker compose -f ~/deployment/stacks/traefik/docker-compose.yml logs -f traefik`) für geblockte IPs
- DNS-Check im VPN: `dig grafana.michaelschiemer.de @10.8.0.1``10.8.0.1`
- WireGuard-Client bekommt automatisch `DNS = 10.8.0.1`, solange `wireguard_dns_servers` nicht überschrieben wird (ansonsten manuell auf internen Resolver setzen)
---

View File

@@ -38,10 +38,15 @@ hT3OCWZ6ElX79YdAdexSsZnbWLzRM/5szk+XNEBUaS8=
- ✅ Normale Internet-Routen werden nicht geändert
- ✅ Firewall-Regeln für SSH (Port 22) werden NICHT entfernt oder blockiert
**Aktuelle Sicherheitshärtung:**
- 🔒 **Traefik-Dashboard** ist jetzt nur noch über WireGuard VPN erreichbar (VPN-only + BasicAuth)
- 🔒 **Grafana** ist nur noch über WireGuard VPN erreichbar (VPN-only Middleware)
- ✅ Hauptanwendung (`michaelschiemer.de`) bleibt öffentlich erreichbar (wie gewünscht)
- 🔒 Traefik arbeitet hinter WireGuard für interne Services (Dashboard, Monitoring)
**Zukünftige Sicherheitshärtung (geplant):**
- 🔒 Backend-Dienste (Prometheus, Grafana, Portainer) sollen später nur noch über VPN erreichbar sein
- 🔒 Aktuell sind alle Dienste noch öffentlich erreichbar (für einfachere Einrichtung)
- 🔒 Firewall-Regeln können später angepasst werden, um nur VPN-Zugriff zu erlauben
- 🔒 Weitere Backend-Dienste (Prometheus, Portainer) können später nur noch über VPN erreichbar sein
- 🔒 Firewall-Regeln können bei Bedarf weiter angepasst werden
---
@@ -485,6 +490,7 @@ ansible-playbook -i inventory/production.yml playbooks/add-wireguard-client.yml
- `client_name`: Name des Clients (erforderlich)
- `client_ip`: Spezifische Client-IP (Standard: automatisch berechnet)
- `allowed_ips`: Erlaubte IP-Ranges (Standard: 10.8.0.0/24)
- `wireguard_dns_servers`: Liste interner Resolver (Default: WireGuard-Server-IP, z.B. `["10.8.0.1"]`)
**Dokumentation**: Siehe `deployment/ansible/playbooks/README-WIREGUARD.md`
@@ -492,6 +498,33 @@ ansible-playbook -i inventory/production.yml playbooks/add-wireguard-client.yml
> `deployment/ansible/wireguard-clients/<client_name>.conf` ideal zum direkten
> Import auf deinem Admin-Rechner (Datei bleibt mit `chmod 600` geschützt).
### Interner DNS (CoreDNS) für VPN-Clients
**Datei**: `deployment/ansible/playbooks/setup-infrastructure.yml` (Tag `dns`)
**Verwendung:**
```bash
cd deployment/ansible
ANSIBLE_VAULT_PASSWORD_FILE=secrets/.vault_pass \
ansible-playbook -i inventory/production.yml playbooks/setup-infrastructure.yml --tags dns
```
**Konfiguration:**
- DNS-Records definieren sich über `dns_records` (Default in `group_vars/production.yml` → `grafana.<app_domain>` → `10.8.0.1`)
- Zusätzliche Einträge können per Override vor dem Playbook-Lauf gesetzt werden, z.B.:
```bash
ansible-playbook ... --tags dns \
-e 'dns_records=[{"host":"grafana.michaelschiemer.de","address":"10.8.0.1"},{"host":"prometheus.michaelschiemer.de","address":"10.8.0.1"}]'
```
- Upstream-Resolver werden über `dns_forwarders` gesteuert (Standard: `1.1.1.1`, `8.8.8.8`)
**Ergebnis:**
- CoreDNS läuft auf dem Server via `network_mode: host` (UDP/TCP 53)
- Alle VPN-Clients können `grafana.michaelschiemer.de` (und weitere Overrides) direkt auf `10.8.0.1` auflösen
- Nicht hinterlegte Hostnamen werden dank `fallthrough` automatisch an die definierten Upstream-Resolver weitergereicht
- Traefik-Middleware `grafana-vpn-only` akzeptiert ausschließlich Verbindungen aus dem WireGuard-Netz `10.8.0.0/24`
- HTTP-Aufrufe zu Grafana laufen so automatisch durch den WireGuard-Tunnel
---
## Verzeichnisstruktur

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\System\Ini;
enum Access: string
{
case USER = "USER";
case PERDIR = "Per Directory";
case SYSTEM = "System";
case ALL = "All";
public static function fromBitmask(int $bitmask): self
{
return match ($bitmask) {
INI_USER => self::USER,
INI_PERDIR => self::PERDIR,
INI_SYSTEM => self::SYSTEM,
INI_ALL => self::ALL,
default => throw new \InvalidArgumentException("Invalid bitmask value: {$bitmask}")
};
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\System\Ini;
final class IniDirective
{
public function __construct(
public string $name,
public string $value,
public string $global,
private int $accessMask,
) {}
public function getAccess(): int
{
$access = Access::fromBitmask($this->accessMask);
return $this->accessMask;
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\System\Ini;
enum IniKey: string
{
case ALLOW_URL_INCLUDE = "allow_url_include";
case ALLOW_URL_FOPEN = "allow_url_fopen";
case ALLOW_URL_FOPEN_UPLOAD = "allow_url_fopen_upload";
case ALLOW_URL_STREAM = "allow_url_stream";
case ALLOW_URL_STREAM_WRAPPER = "allow_url_stream_wrapper";
case ALLOW_URL_WRAPPER = "allow_url_wrapper";
case DEFAULT_SOCKET_TIMEOUT = "default_socket_timeout";
case DISABLE_FUNCTIONS = "disable_functions";
case DISABLE_CLASSES = "disable_classes";
case DISABLE_CLASSES_REFLECTION = "disable_classes_reflection";
case DISABLE_CONSTANTS = "disable_constants";
case DISABLE_ERRORS = "disable_errors";
case DISABLE_INCLUDE_PATH = "disable_include_path";
case DISABLE_PATH_INJECTION = "disable_path_injection";
case DISABLE_PHP = "disable_php";
case DISABLE_REFLECTION = "disable_reflection";
case ENABLE_DL = "enable_dl";
case ENABLE_POST_DATA_BEING_SENT = "enable_post_data_being_sent";
case ENABLE_SESSION = "enable_session";
case ERROR_REPORTING = "error_reporting";
case HTML_ERRORS = "html_errors";
case HTML_ERRORS_404 = "html_errors_404";
case HTML_ERRORS_404_LOG = "html_errors_404_log";
case HTML_ERRORS_404_SKIP = "html_errors_404_skip";
case HTML_ERRORS_404_TITLE = "html_errors_404_title";
case HTML_ERRORS_500 = "html_errors_500";
case HTML_ERRORS_500_LOG = "html_errors_500_log";
case HTML_ERRORS_500_SKIP = "html_errors_500_skip";
case HTML_ERRORS_500_TITLE = "html_errors_500_title";
case HTML_ERRORS_LOG = "html_errors_log";
case HTML_ERRORS_SKIP = "html_errors_skip";
case HTML_ERRORS_TITLE = "html_errors_title";
case HTML_ERRORS_TYPE = "html_errors_type";
case HTML_ERRORS_USE_INCLUDE_PATH = "html_errors_use_include_path";
case HTML_SAFE_EMAILS = "html_safe_emails";
case HTML_SAFE_URLS = "html_safe_urls";
case IGNORE_REPEATED_ERRORS = "ignore_repeated_errors";
case IGNORE_REPEATED_SOURCE = "ignore_repeated_source";
case IGNORE_USER_ABORT = "ignore_user_abort";
case LOG_ERRORS = "log_errors";
case LOG_ERRORS_MAX_LEN = "log_errors_max_len";
case LOG_ERRORS_MSG = "log_errors_msg";
case LOG_ERRORS_TO_STDOUT = "log_errors_to_stdout";
case LOG_ERRORS_USE_INCLUDE_PATH = "log_errors_use_include_path";
case MEMORY_LIMIT = "memory_limit";
case OPCACHE_ENABLE = "opcache.enable";
case OPCACHE_ENABLE_CLI = "opcache.enable_cli";
case OPCACHE_ENABLE_FILE_OVERRIDE = "opcache.enable_file_override";
case OPCACHE_ENABLE_FILE_OVERRIDE_IF_EXISTS = "opcache.enable_file_override_if_exists";
case OPCACHE_ENABLE_FILE_OVERRIDE_FROM_INDEX = "opcache.enable_file_override_from_index";
case OPCACHE_ENABLE_FILE_OVERRIDE_FROM_INDEX_IF_EXISTS = "opcache.enable_file_override_from_index_if_exists";
case OPCACHE_ENABLE_FILE_OVERRIDE_FROM_INDEX_IF_EXISTS_IF_EMPTY = "opcache.enable_file_override_from_index_if_exists_if_empty";
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\System;
use Stringable;
final readonly class PhpIni implements Stringable
{
public string $path;
public function __construct(
) {
$path = php_ini_loaded_file();
if($path === false) {
$path = "";
}
$this->path = $path;
}
public function isLoaded(): bool
{
return $this->path !== "";
}
public function __toString(): string
{
return $this->path;
}
}

View File

@@ -453,7 +453,7 @@ final readonly class FilePath implements Stringable
// Check for suspicious patterns (basic path traversal)
if (str_contains($path, '..')) {
// Allow .. in normalized paths, but check final result doesn't escape intended boundaries
// Allow .. in normalized paths, but check that the final result doesn't escape intended boundaries
// This is a basic check - more sophisticated validation can be added
}
}

View File

@@ -23,6 +23,7 @@ final class RedisConnectionPool
*/
public function registerConnection(string $name, RedisConfig $config): void
{
var_dump("<pre>", $config);
$this->configs[$name] = $config;
}