From edcf509a4fdb0be3b96d255d185759f813e219d7 Mon Sep 17 00:00:00 2001 From: Michael Schiemer Date: Sun, 2 Nov 2025 15:29:41 +0100 Subject: [PATCH] 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 --- .gitignore | 2 + .../playbooks/add-wireguard-client.yml | 6 + .../playbooks/check-production-status.yml | 147 ++++++++++++++++++ .../playbooks/check-staging-500-error.yml | 49 ++++++ .../ansible/playbooks/debug-grafana-403.yml | 125 +++++++++++++++ .../ansible/playbooks/fix-traefik-config.yml | 142 +++++++++++++++++ .../playbooks/setup-infrastructure.yml | 14 +- .../ansible/roles/dns/defaults/main.yml | 9 ++ deployment/ansible/roles/dns/tasks/main.yml | 33 ++++ .../roles/monitoring/defaults/main.yml | 3 +- .../ansible/roles/monitoring/tasks/main.yml | 31 ++++ deployment/ansible/templates/dns-Corefile.j2 | 15 ++ .../templates/traefik-middlewares.yml.j2 | 82 ++++++++++ .../templates/wireguard-client.conf.j2 | 6 +- .../wireguard-clients/grafana-test.conf | 10 +- deployment/stacks/dns/docker-compose.yml | 14 ++ .../stacks/monitoring/docker-compose.yml | 5 +- deployment/stacks/traefik/README.md | 15 +- deployment/stacks/traefik/docker-compose.yml | 24 +-- .../stacks/traefik/dynamic/middlewares.yml | 14 ++ deployment/stacks/traefik/traefik.yml | 20 ++- docs/deployment/WIREGUARD-FUTURE-SECURITY.md | 22 ++- docs/deployment/WIREGUARD-SETUP.md | 39 ++++- src/Framework/Core/System/Ini/Access.php | 23 +++ .../Core/System/Ini/IniDirective.php | 20 +++ src/Framework/Core/System/Ini/IniKey.php | 62 ++++++++ src/Framework/Core/System/PhpIni.php | 30 ++++ .../Filesystem/ValueObjects/FilePath.php | 2 +- src/Framework/Redis/RedisConnectionPool.php | 1 + 29 files changed, 926 insertions(+), 39 deletions(-) create mode 100644 deployment/ansible/playbooks/check-production-status.yml create mode 100644 deployment/ansible/playbooks/check-staging-500-error.yml create mode 100644 deployment/ansible/playbooks/debug-grafana-403.yml create mode 100644 deployment/ansible/playbooks/fix-traefik-config.yml create mode 100644 deployment/ansible/roles/dns/defaults/main.yml create mode 100644 deployment/ansible/roles/dns/tasks/main.yml create mode 100644 deployment/ansible/templates/dns-Corefile.j2 create mode 100644 deployment/ansible/templates/traefik-middlewares.yml.j2 create mode 100644 deployment/stacks/dns/docker-compose.yml create mode 100644 src/Framework/Core/System/Ini/Access.php create mode 100644 src/Framework/Core/System/Ini/IniDirective.php create mode 100644 src/Framework/Core/System/Ini/IniKey.php create mode 100644 src/Framework/Core/System/PhpIni.php diff --git a/.gitignore b/.gitignore index e3869ad6..3e8a6a9c 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ cookies_new.txt playwright-report/ test-results/ .playwright/ +# WireGuard client configs (generated locally) +deployment/ansible/wireguard-clients/ diff --git a/deployment/ansible/playbooks/add-wireguard-client.yml b/deployment/ansible/playbooks/add-wireguard-client.yml index 63362e01..6730fefe 100755 --- a/deployment/ansible/playbooks/add-wireguard-client.yml +++ b/deployment/ansible/playbooks/add-wireguard-client.yml @@ -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('.') }}" diff --git a/deployment/ansible/playbooks/check-production-status.yml b/deployment/ansible/playbooks/check-production-status.yml new file mode 100644 index 00000000..c71cdb23 --- /dev/null +++ b/deployment/ansible/playbooks/check-production-status.yml @@ -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 }}" diff --git a/deployment/ansible/playbooks/check-staging-500-error.yml b/deployment/ansible/playbooks/check-staging-500-error.yml new file mode 100644 index 00000000..28c8fbcd --- /dev/null +++ b/deployment/ansible/playbooks/check-staging-500-error.yml @@ -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 }}" diff --git a/deployment/ansible/playbooks/debug-grafana-403.yml b/deployment/ansible/playbooks/debug-grafana-403.yml new file mode 100644 index 00000000..99769236 --- /dev/null +++ b/deployment/ansible/playbooks/debug-grafana-403.yml @@ -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 }}" diff --git a/deployment/ansible/playbooks/fix-traefik-config.yml b/deployment/ansible/playbooks/fix-traefik-config.yml new file mode 100644 index 00000000..27053c9c --- /dev/null +++ b/deployment/ansible/playbooks/fix-traefik-config.yml @@ -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 }}" diff --git a/deployment/ansible/playbooks/setup-infrastructure.yml b/deployment/ansible/playbooks/setup-infrastructure.yml index 46962bda..1bd1a533 100644 --- a/deployment/ansible/playbooks/setup-infrastructure.yml +++ b/deployment/ansible/playbooks/setup-infrastructure.yml @@ -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' }}" diff --git a/deployment/ansible/roles/dns/defaults/main.yml b/deployment/ansible/roles/dns/defaults/main.yml new file mode 100644 index 00000000..27216149 --- /dev/null +++ b/deployment/ansible/roles/dns/defaults/main.yml @@ -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') }}" diff --git a/deployment/ansible/roles/dns/tasks/main.yml b/deployment/ansible/roles/dns/tasks/main.yml new file mode 100644 index 00000000..42c9cdbb --- /dev/null +++ b/deployment/ansible/roles/dns/tasks/main.yml @@ -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 diff --git a/deployment/ansible/roles/monitoring/defaults/main.yml b/deployment/ansible/roles/monitoring/defaults/main.yml index a25845e8..cd3f8fdc 100644 --- a/deployment/ansible/roles/monitoring/defaults/main.yml +++ b/deployment/ansible/roles/monitoring/defaults/main.yml @@ -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(',') }}" diff --git a/deployment/ansible/roles/monitoring/tasks/main.yml b/deployment/ansible/roles/monitoring/tasks/main.yml index 1a6663dc..79499c69 100644 --- a/deployment/ansible/roles/monitoring/tasks/main.yml +++ b/deployment/ansible/roles/monitoring/tasks/main.yml @@ -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 }}" diff --git a/deployment/ansible/templates/dns-Corefile.j2 b/deployment/ansible/templates/dns-Corefile.j2 new file mode 100644 index 00000000..d8b4381c --- /dev/null +++ b/deployment/ansible/templates/dns-Corefile.j2 @@ -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 +} diff --git a/deployment/ansible/templates/traefik-middlewares.yml.j2 b/deployment/ansible/templates/traefik-middlewares.yml.j2 new file mode 100644 index 00000000..3d737a18 --- /dev/null +++ b/deployment/ansible/templates/traefik-middlewares.yml.j2 @@ -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 diff --git a/deployment/ansible/templates/wireguard-client.conf.j2 b/deployment/ansible/templates/wireguard-client.conf.j2 index 25ecfa0b..95906d02 100644 --- a/deployment/ansible/templates/wireguard-client.conf.j2 +++ b/deployment/ansible/templates/wireguard-client.conf.j2 @@ -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 \ No newline at end of file +PersistentKeepalive = 25 diff --git a/deployment/ansible/wireguard-clients/grafana-test.conf b/deployment/ansible/wireguard-clients/grafana-test.conf index 73dcfc9f..c3336154 100644 --- a/deployment/ansible/wireguard-clients/grafana-test.conf +++ b/deployment/ansible/wireguard-clients/grafana-test.conf @@ -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 \ No newline at end of file +PersistentKeepalive = 25 diff --git a/deployment/stacks/dns/docker-compose.yml b/deployment/stacks/dns/docker-compose.yml new file mode 100644 index 00000000..9b2d7490 --- /dev/null +++ b/deployment/stacks/dns/docker-compose.yml @@ -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 diff --git a/deployment/stacks/monitoring/docker-compose.yml b/deployment/stacks/monitoring/docker-compose.yml index 542d7645..864dbff7 100644 --- a/deployment/stacks/monitoring/docker-compose.yml +++ b/deployment/stacks/monitoring/docker-compose.yml @@ -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: diff --git a/deployment/stacks/traefik/README.md b/deployment/stacks/traefik/README.md index d1b5db85..5f11572b 100644 --- a/deployment/stacks/traefik/README.md +++ b/deployment/stacks/traefik/README.md @@ -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: diff --git a/deployment/stacks/traefik/docker-compose.yml b/deployment/stacks/traefik/docker-compose.yml index 2b32a293..a6c40fbb 100644 --- a/deployment/stacks/traefik/docker-compose.yml +++ b/deployment/stacks/traefik/docker-compose.yml @@ -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 diff --git a/deployment/stacks/traefik/dynamic/middlewares.yml b/deployment/stacks/traefik/dynamic/middlewares.yml index c47ca978..f511942b 100644 --- a/deployment/stacks/traefik/dynamic/middlewares.yml +++ b/deployment/stacks/traefik/dynamic/middlewares.yml @@ -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: diff --git a/deployment/stacks/traefik/traefik.yml b/deployment/stacks/traefik/traefik.yml index e5803f08..2b1870ba 100644 --- a/deployment/stacks/traefik/traefik.yml +++ b/deployment/stacks/traefik/traefik.yml @@ -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 diff --git a/docs/deployment/WIREGUARD-FUTURE-SECURITY.md b/docs/deployment/WIREGUARD-FUTURE-SECURITY.md index ec99a069..f5741848 100644 --- a/docs/deployment/WIREGUARD-FUTURE-SECURITY.md +++ b/docs/deployment/WIREGUARD-FUTURE-SECURITY.md @@ -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) --- diff --git a/docs/deployment/WIREGUARD-SETUP.md b/docs/deployment/WIREGUARD-SETUP.md index 2371bc9f..0ba33177 100644 --- a/docs/deployment/WIREGUARD-SETUP.md +++ b/docs/deployment/WIREGUARD-SETUP.md @@ -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/.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.` → `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 diff --git a/src/Framework/Core/System/Ini/Access.php b/src/Framework/Core/System/Ini/Access.php new file mode 100644 index 00000000..77216224 --- /dev/null +++ b/src/Framework/Core/System/Ini/Access.php @@ -0,0 +1,23 @@ + self::USER, + INI_PERDIR => self::PERDIR, + INI_SYSTEM => self::SYSTEM, + INI_ALL => self::ALL, + default => throw new \InvalidArgumentException("Invalid bitmask value: {$bitmask}") + }; + } +} diff --git a/src/Framework/Core/System/Ini/IniDirective.php b/src/Framework/Core/System/Ini/IniDirective.php new file mode 100644 index 00000000..c23e8fb8 --- /dev/null +++ b/src/Framework/Core/System/Ini/IniDirective.php @@ -0,0 +1,20 @@ +accessMask); + return $this->accessMask; + } +} diff --git a/src/Framework/Core/System/Ini/IniKey.php b/src/Framework/Core/System/Ini/IniKey.php new file mode 100644 index 00000000..024cb94e --- /dev/null +++ b/src/Framework/Core/System/Ini/IniKey.php @@ -0,0 +1,62 @@ +path = $path; + } + + public function isLoaded(): bool + { + return $this->path !== ""; + } + + public function __toString(): string + { + return $this->path; + } +} diff --git a/src/Framework/Filesystem/ValueObjects/FilePath.php b/src/Framework/Filesystem/ValueObjects/FilePath.php index 5865615d..d11bedc3 100644 --- a/src/Framework/Filesystem/ValueObjects/FilePath.php +++ b/src/Framework/Filesystem/ValueObjects/FilePath.php @@ -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 } } diff --git a/src/Framework/Redis/RedisConnectionPool.php b/src/Framework/Redis/RedisConnectionPool.php index 77f81139..887db76c 100644 --- a/src/Framework/Redis/RedisConnectionPool.php +++ b/src/Framework/Redis/RedisConnectionPool.php @@ -23,6 +23,7 @@ final class RedisConnectionPool */ public function registerConnection(string $name, RedisConfig $config): void { + var_dump("
", $config);
         $this->configs[$name] = $config;
     }