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.
This commit is contained in:
2025-11-05 12:48:25 +01:00
parent 7c52065aae
commit 95147ff23e
215 changed files with 29490 additions and 368 deletions

View File

@@ -179,6 +179,141 @@ sudo ufw allow 51820/udp comment 'WireGuard VPN'
sudo iptables -A INPUT -p udp --dport 51820 -j ACCEPT 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 ## Troubleshooting
### WireGuard startet nicht ### WireGuard startet nicht

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,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

@@ -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

@@ -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

@@ -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

@@ -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.entrypoints=websecure"
- "traefik.http.routers.prometheus.tls=true" - "traefik.http.routers.prometheus.tls=true"
- "traefik.http.routers.prometheus.tls.certresolver=letsencrypt" - "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.middlewares.prometheus-auth.basicauth.users=${PROMETHEUS_AUTH}"
- "traefik.http.services.prometheus.loadbalancer.server.port=9090" - "traefik.http.services.prometheus.loadbalancer.server.port=9090"
healthcheck: healthcheck:
@@ -75,9 +75,6 @@ services:
- "traefik.http.routers.grafana.entrypoints=websecure" - "traefik.http.routers.grafana.entrypoints=websecure"
- "traefik.http.routers.grafana.tls=true" - "traefik.http.routers.grafana.tls=true"
- "traefik.http.routers.grafana.tls.certresolver=letsencrypt" - "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" - "traefik.http.services.grafana.loadbalancer.server.port=3000"
depends_on: depends_on:
prometheus: prometheus:

View File

@@ -52,25 +52,6 @@ http:
# - "127.0.0.1/32" # - "127.0.0.1/32"
# - "10.0.0.0/8" # - "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 # Chain multiple middlewares
default-chain: default-chain:
chain: chain:

View File

@@ -64,10 +64,8 @@ providers:
# Forwarded Headers Configuration # Forwarded Headers Configuration
# This ensures Traefik correctly identifies the real client IP # This ensures Traefik correctly identifies the real client IP
# Important for VPN access where requests come from WireGuard interface
forwardedHeaders: forwardedHeaders:
trustedIPs: trustedIPs:
- "10.8.0.0/24" # WireGuard VPN network
- "127.0.0.1/32" # Localhost - "127.0.0.1/32" # Localhost
- "172.17.0.0/16" # Docker bridge network - "172.17.0.0/16" # Docker bridge network
- "172.18.0.0/16" # Docker user-defined networks - "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)] #[Route(path: '/', method: Method::GET, name: WebRoutes::HOME)]
public function home(HomeRequest $request, string $test = 'hallo'): ViewResult public function home(HomeRequest $request, string $test = 'hallo'): ViewResult
{ {
throw new \Exception('test');
// Production deployment trigger - scp from /workspace/repo // Production deployment trigger - scp from /workspace/repo
$model = new HomeViewModel('Hallo Welt!'); $model = new HomeViewModel('Hallo Welt!');
return new ViewResult( return new ViewResult(
@@ -25,7 +27,9 @@ final readonly class ShowHome
title: 'Home', title: 'Home',
description: 'Hallo Welt!', description: 'Hallo Welt!',
)(), )(),
data: ['name' => 'Michael'], data: [
'name' => 'Michael',
],
model: $model, model: $model,
); );
} }

View File

@@ -182,6 +182,11 @@ final readonly class ApiGateway
connectTimeout: min(3, $timeoutSeconds), // Connect timeout max 3s or total timeout 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 // Use factory method for JSON requests if payload is present
if ($request instanceof HasPayload) { if ($request instanceof HasPayload) {
return ClientRequest::json( 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_USERNAME = 'RAPIDMAIL_USERNAME';
case RAPIDMAIL_PASSWORD = 'RAPIDMAIL_PASSWORD'; case RAPIDMAIL_PASSWORD = 'RAPIDMAIL_PASSWORD';
case RAPIDMAIL_TEST_MODE = 'RAPIDMAIL_TEST_MODE'; case RAPIDMAIL_TEST_MODE = 'RAPIDMAIL_TEST_MODE';
case NETCUP_API_KEY = 'NETCUP_API_KEY';
case NETCUP_API_PASSWORD = 'NETCUP_API_PASSWORD';
// OAuth - Spotify // OAuth - Spotify
case SPOTIFY_CLIENT_ID = 'SPOTIFY_CLIENT_ID'; case SPOTIFY_CLIENT_ID = 'SPOTIFY_CLIENT_ID';

View File

@@ -84,6 +84,11 @@ final readonly class InitializerProcessor
if ($returnType === null || $returnType === 'void') { if ($returnType === null || $returnType === 'void') {
$this->container->invoker->invoke($discoveredAttribute->className, $methodName->toString()); $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 // Service-Initializer: Konkreter Return-Type → Zum Dependency-Graph hinzufügen
else { else {
$dependencyGraph->addInitializer($returnType, $discoveredAttribute->className, $methodName); $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\DateTime\Clock;
use App\Framework\ErrorAggregation\Storage\ErrorStorageInterface; use App\Framework\ErrorAggregation\Storage\ErrorStorageInterface;
use App\Framework\Exception\Core\ErrorSeverity; 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\Logging\Logger;
use App\Framework\Queue\Queue; 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 { try {
$errorEvent = ErrorEvent::fromErrorHandlerContext($context, $this->clock); $errorEvent = ErrorEvent::fromException($exception, $contextProvider, $this->clock, $isDebug);
$this->processErrorEvent($errorEvent); $this->processErrorEvent($errorEvent);
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Don't let error aggregation break the application // Don't let error aggregation break the application
$this->logError("Failed to process error: " . $e->getMessage(), [ $this->logError("Failed to process error: " . $e->getMessage(), [
'exception' => $e, '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 * Converts to array for storage/transmission
*/ */
@@ -298,4 +335,74 @@ final readonly class ErrorEvent
return $normalized; 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\Duration;
use App\Framework\Core\ValueObjects\Timestamp; use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\Timer; use App\Framework\DateTime\Timer;
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
use App\Framework\ErrorBoundaries\CircuitBreaker\BoundaryCircuitBreakerManager; use App\Framework\ErrorBoundaries\CircuitBreaker\BoundaryCircuitBreakerManager;
use App\Framework\ErrorBoundaries\Events\BoundaryEventInterface; use App\Framework\ErrorBoundaries\Events\BoundaryEventInterface;
use App\Framework\ErrorBoundaries\Events\BoundaryEventPublisher; 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\ErrorBoundaries\Events\BoundaryTimeoutOccurred;
use App\Framework\Exception\ErrorCode; use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException; use App\Framework\Exception\FrameworkException;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\Logging\Logger; use App\Framework\Logging\Logger;
use Throwable; use Throwable;
@@ -34,6 +36,8 @@ final readonly class ErrorBoundary
private ?Logger $logger = null, private ?Logger $logger = null,
private ?BoundaryCircuitBreakerManager $circuitBreakerManager = null, private ?BoundaryCircuitBreakerManager $circuitBreakerManager = null,
private ?BoundaryEventPublisher $eventPublisher = 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'); $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 { try {
$result = $fallback(); $result = $fallback();

View File

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

@@ -3,7 +3,10 @@ declare(strict_types=1);
namespace App\Framework\ExceptionHandling; 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\ExceptionHandling\Reporter\LogReporter;
use App\Framework\Http\Response;
use Throwable; use Throwable;
final readonly class ErrorKernel final readonly class ErrorKernel
@@ -25,5 +28,28 @@ final readonly class ErrorKernel
return null; 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

@@ -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,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;
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\ValueObjects\Alert;
/**
* Alert Definition.
*/
final readonly class Alert
{
public function __construct(
public string $id,
public string $name,
public AlertSeverity $severity,
public string $message,
public ?string $description = null,
public ?\DateTimeImmutable $triggeredAt = null,
public bool $isActive = false,
public array $metadata = []
) {
}
/**
* Erstellt einen neuen Alert.
*/
public static function create(
string $id,
string $name,
AlertSeverity $severity,
string $message,
?string $description = null,
array $metadata = []
): self {
return new self(
id: $id,
name: $name,
severity: $severity,
message: $message,
description: $description,
triggeredAt: new \DateTimeImmutable(),
isActive: true,
metadata: $metadata
);
}
/**
* Markiert Alert als inaktiv.
*/
public function deactivate(): self
{
return new self(
id: $this->id,
name: $this->name,
severity: $this->severity,
message: $this->message,
description: $this->description,
triggeredAt: $this->triggeredAt,
isActive: false,
metadata: $this->metadata
);
}
/**
* Prüft, ob Alert kritisch ist.
*/
public function isCritical(): bool
{
return $this->severity === AlertSeverity::CRITICAL;
}
/**
* Prüft, ob Alert eine Warnung ist.
*/
public function isWarning(): bool
{
return $this->severity === AlertSeverity::WARNING;
}
/**
* Konvertiert zu Array.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'severity' => $this->severity->value,
'message' => $this->message,
'description' => $this->description,
'triggered_at' => $this->triggeredAt?->format('Y-m-d H:i:s'),
'is_active' => $this->isActive,
'is_critical' => $this->isCritical(),
'metadata' => $this->metadata,
];
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\ValueObjects\Alert;
/**
* Alert Report aggregiert alle aktiven Alerts.
*/
final readonly class AlertReport
{
/**
* @param Alert[] $alerts
*/
public function __construct(
public array $alerts,
public \DateTimeImmutable $generatedAt
) {
}
/**
* Erstellt einen Alert Report aus Alerts.
*
* @param Alert[] $alerts
*/
public static function fromAlerts(array $alerts): self
{
return new self(
alerts: $alerts,
generatedAt: new \DateTimeImmutable()
);
}
/**
* Gibt nur aktive Alerts zurück.
*
* @return Alert[]
*/
public function getActiveAlerts(): array
{
return array_filter(
$this->alerts,
fn (Alert $alert) => $alert->isActive
);
}
/**
* Gibt nur kritische Alerts zurück.
*
* @return Alert[]
*/
public function getCriticalAlerts(): array
{
return array_filter(
$this->getActiveAlerts(),
fn (Alert $alert) => $alert->isCritical()
);
}
/**
* Gibt nur Warning-Alerts zurück.
*
* @return Alert[]
*/
public function getWarningAlerts(): array
{
return array_filter(
$this->getActiveAlerts(),
fn (Alert $alert) => $alert->isWarning()
);
}
/**
* Gibt die Anzahl der Alerts pro Severity zurück.
*
* @return array{info: int, warning: int, critical: int}
*/
public function getSeverityCounts(): array
{
$counts = [
'info' => 0,
'warning' => 0,
'critical' => 0,
];
foreach ($this->getActiveAlerts() as $alert) {
$counts[$alert->severity->value]++;
}
return $counts;
}
/**
* Prüft, ob es aktive Alerts gibt.
*/
public function hasActiveAlerts(): bool
{
return count($this->getActiveAlerts()) > 0;
}
/**
* Prüft, ob es kritische Alerts gibt.
*/
public function hasCriticalAlerts(): bool
{
return count($this->getCriticalAlerts()) > 0;
}
/**
* Konvertiert zu Array.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'generated_at' => $this->generatedAt->format('Y-m-d H:i:s'),
'total_alerts' => count($this->alerts),
'active_alerts' => count($this->getActiveAlerts()),
'critical_alerts' => count($this->getCriticalAlerts()),
'warning_alerts' => count($this->getWarningAlerts()),
'severity_counts' => $this->getSeverityCounts(),
'alerts' => array_map(fn (Alert $a) => $a->toArray(), $this->alerts),
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\ValueObjects\Alert;
/**
* Alert Severity Level.
*/
enum AlertSeverity: string
{
case INFO = 'info';
case WARNING = 'warning';
case CRITICAL = 'critical';
/**
* Gibt eine menschenlesbare Beschreibung zurück.
*/
public function getDescription(): string
{
return match ($this) {
self::INFO => 'Informational alert',
self::WARNING => 'Warning alert - attention required',
self::CRITICAL => 'Critical alert - immediate action required',
};
}
/**
* Gibt ein Icon für den Severity-Level zurück.
*/
public function getIcon(): string
{
return match ($this) {
self::INFO => '',
self::WARNING => '⚠️',
self::CRITICAL => '❌',
};
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\ValueObjects\Alert;
/**
* Alert Threshold Configuration.
*/
final readonly class AlertThreshold
{
public function __construct(
public string $name,
public float $warningThreshold,
public float $criticalThreshold,
public string $unit = '',
public ?string $description = null
) {
}
/**
* Prüft, ob ein Wert den Warning-Threshold überschreitet.
*/
public function exceedsWarning(float $value): bool
{
return $value >= $this->warningThreshold;
}
/**
* Prüft, ob ein Wert den Critical-Threshold überschreitet.
*/
public function exceedsCritical(float $value): bool
{
return $value >= $this->criticalThreshold;
}
/**
* Bestimmt die Severity basierend auf dem Wert.
*/
public function getSeverity(float $value): AlertSeverity
{
if ($this->exceedsCritical($value)) {
return AlertSeverity::CRITICAL;
}
if ($this->exceedsWarning($value)) {
return AlertSeverity::WARNING;
}
return AlertSeverity::INFO;
}
/**
* Konvertiert zu Array.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'name' => $this->name,
'warning_threshold' => $this->warningThreshold,
'critical_threshold' => $this->criticalThreshold,
'unit' => $this->unit,
'description' => $this->description,
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Process\ValueObjects\ProcessDetails;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Duration;
/**
* Detailed Process Information.
*/
final readonly class ProcessDetails
{
public function __construct(
public int $pid,
public string $command,
public ?int $ppid = null,
public ?string $user = null,
public ?float $cpuPercent = null,
public ?Byte $memoryUsage = null,
public ?Duration $uptime = null,
public ?string $state = null,
public ?int $priority = null
) {
}
/**
* Konvertiert zu Array.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'pid' => $this->pid,
'command' => $this->command,
'ppid' => $this->ppid,
'user' => $this->user,
'cpu_percent' => $this->cpuPercent,
'memory_usage_bytes' => $this->memoryUsage?->toBytes(),
'memory_usage_human' => $this->memoryUsage?->toHumanReadable(),
'uptime_seconds' => $this->uptime?->toSeconds(),
'uptime_human' => $this->uptime?->toHumanReadable(),
'state' => $this->state,
'priority' => $this->priority,
];
}
}

View File

@@ -15,9 +15,11 @@ namespace App\Framework\QrCode\ErrorCorrection;
final class ReedSolomonEncoder final class ReedSolomonEncoder
{ {
// Generator polynomial coefficients for different EC codeword counts // Generator polynomial coefficients for different EC codeword counts
// Format: [ecCodewords => [coefficients...]]
// Note: The first coefficient is always 0 (leading term)
private const GENERATOR_POLYNOMIALS = [ private const GENERATOR_POLYNOMIALS = [
7 => [0, 87, 229, 146, 149, 238, 102, 21], // EC Level M, Version 1 7 => [0, 87, 229, 146, 149, 238, 102, 21], // 7 EC codewords
10 => [0, 251, 67, 46, 61, 118, 70, 64, 94, 32, 45], 10 => [0, 251, 67, 46, 61, 118, 70, 64, 94, 32, 45], // Version 1, Level M (10 EC codewords)
13 => [0, 74, 152, 176, 100, 86, 100, 106, 104, 130, 218, 206, 140, 78], 13 => [0, 74, 152, 176, 100, 86, 100, 106, 104, 130, 218, 206, 140, 78],
15 => [0, 8, 183, 61, 91, 202, 37, 51, 58, 58, 237, 140, 124, 5, 99, 105], 15 => [0, 8, 183, 61, 91, 202, 37, 51, 58, 58, 237, 140, 124, 5, 99, 105],
16 => [0, 120, 104, 107, 109, 102, 161, 76, 3, 91, 191, 147, 169, 182, 194, 225, 120], 16 => [0, 120, 104, 107, 109, 102, 161, 76, 3, 91, 191, 147, 169, 182, 194, 225, 120],
@@ -73,15 +75,30 @@ final class ReedSolomonEncoder
$generator = $this->getGeneratorPolynomial($ecCodewords); $generator = $this->getGeneratorPolynomial($ecCodewords);
// Initialize message polynomial (data + zero padding for EC) // Initialize message polynomial (data + zero padding for EC)
// This represents m(x) * x^t where t is the number of EC codewords
$messagePolynomial = array_merge($data, array_fill(0, $ecCodewords, 0)); $messagePolynomial = array_merge($data, array_fill(0, $ecCodewords, 0));
// Polynomial division // Polynomial division: divide messagePolynomial by generator
// Standard Reed-Solomon encoding algorithm
// For stored polynomials [0, a1, a2, ..., an], the leading coefficient is implicitly 1
// So we treat it as a monic polynomial [1, a1, a2, ..., an]
$messageLength = count($messagePolynomial);
for ($i = 0; $i < count($data); $i++) { for ($i = 0; $i < count($data); $i++) {
$coefficient = $messagePolynomial[$i]; $coefficient = $messagePolynomial[$i];
if ($coefficient !== 0) { if ($coefficient !== 0) {
for ($j = 0; $j < count($generator); $j++) { // Leading coefficient is implicitly 1 (monic polynomial)
$messagePolynomial[$i + $j] ^= $this->gfMultiply($generator[$j], $coefficient); // So we clear the current position and apply generator coefficients
$messagePolynomial[$i] = 0;
// Apply generator coefficients (skip first element which is 0)
// Generator format: [0, a1, a2, ..., an] represents [1, a1, a2, ..., an]
for ($j = 1; $j < count($generator); $j++) {
$index = $i + $j;
if ($index < $messageLength) {
$messagePolynomial[$index] ^= $this->gfMultiply($generator[$j], $coefficient);
}
} }
} }
} }
@@ -106,6 +123,8 @@ final class ReedSolomonEncoder
/** /**
* Generate generator polynomial g(x) = (x - α^0)(x - α^1)...(x - α^(n-1)) * Generate generator polynomial g(x) = (x - α^0)(x - α^1)...(x - α^(n-1))
*
* Returns monic polynomial [1, a1, a2, ..., an] where leading coefficient is 1
*/ */
private function generateGeneratorPolynomial(int $degree): array private function generateGeneratorPolynomial(int $degree): array
{ {
@@ -113,6 +132,7 @@ final class ReedSolomonEncoder
$polynomial = [1]; $polynomial = [1];
// Multiply by (x - α^i) for i = 0 to degree-1 // Multiply by (x - α^i) for i = 0 to degree-1
// (x - α^i) = x + (-α^i) = x + (α^i in GF(256))
for ($i = 0; $i < $degree; $i++) { for ($i = 0; $i < $degree; $i++) {
$polynomial = $this->multiplyPolynomials( $polynomial = $this->multiplyPolynomials(
$polynomial, $polynomial,

View File

@@ -24,10 +24,14 @@ use App\Framework\QrCode\ValueObjects\QrCodeMatrix;
* Phase 2: Full Reed-Solomon error correction with mask pattern selection * Phase 2: Full Reed-Solomon error correction with mask pattern selection
* Generates scannable QR codes compliant with ISO/IEC 18004 * Generates scannable QR codes compliant with ISO/IEC 18004
*/ */
final class QrCodeGenerator final readonly class QrCodeGenerator
{ {
public function __construct(
private QrCodeRenderer $renderer
) {}
/** /**
* Generate QR Code from data * Generate QR Code from data (static method for backward compatibility)
*/ */
public static function generate(string $data, ?QrCodeConfig $config = null): QrCodeMatrix public static function generate(string $data, ?QrCodeConfig $config = null): QrCodeMatrix
{ {
@@ -68,16 +72,18 @@ final class QrCodeGenerator
); );
} }
// Generate matrix // Generate matrix using temporary instance
$matrix = self::generateMatrix($data, $config); $temporaryRenderer = new QrCodeRenderer();
$temporaryGenerator = new self($temporaryRenderer);
$matrix = $temporaryGenerator->generateMatrix($data, $config);
return $matrix; return $matrix;
} }
/** /**
* Generate QR Code matrix * Generate QR Code matrix (instance method)
*/ */
private static function generateMatrix(string $data, QrCodeConfig $config): QrCodeMatrix private function generateMatrix(string $data, QrCodeConfig $config): QrCodeMatrix
{ {
// 1. Create empty matrix // 1. Create empty matrix
$matrix = QrCodeMatrix::create($config->version); $matrix = QrCodeMatrix::create($config->version);
@@ -97,7 +103,7 @@ final class QrCodeGenerator
$matrix = $matrix->setModuleAt($darkModuleRow, 8, Module::dark()); $matrix = $matrix->setModuleAt($darkModuleRow, 8, Module::dark());
// 6. Encode data into codewords // 6. Encode data into codewords
$dataCodewords = self::encodeData($data, $config); $dataCodewords = $this->encodeData($data, $config);
// 7. Generate error correction codewords using Reed-Solomon // 7. Generate error correction codewords using Reed-Solomon
$reedSolomon = new ReedSolomonEncoder(); $reedSolomon = new ReedSolomonEncoder();
@@ -109,7 +115,7 @@ final class QrCodeGenerator
// 8. Place data and EC codewords in matrix // 8. Place data and EC codewords in matrix
$allCodewords = array_merge($dataCodewords, $ecCodewords); $allCodewords = array_merge($dataCodewords, $ecCodewords);
$matrix = self::placeDataCodewords($matrix, $allCodewords); $matrix = $this->placeDataCodewords($matrix, $allCodewords);
// 9. Select best mask pattern (evaluates all 8 patterns) // 9. Select best mask pattern (evaluates all 8 patterns)
$maskEvaluator = new MaskEvaluator(); $maskEvaluator = new MaskEvaluator();
@@ -132,7 +138,7 @@ final class QrCodeGenerator
/** /**
* Encode data into codewords (Phase 2: Byte mode with proper structure) * Encode data into codewords (Phase 2: Byte mode with proper structure)
*/ */
private static function encodeData(string $data, QrCodeConfig $config): array private function encodeData(string $data, QrCodeConfig $config): array
{ {
$codewords = []; $codewords = [];
$bits = ''; $bits = '';
@@ -186,14 +192,16 @@ final class QrCodeGenerator
/** /**
* Place data codewords in matrix using zig-zag pattern * Place data codewords in matrix using zig-zag pattern
*/ */
private static function placeDataCodewords(QrCodeMatrix $matrix, array $codewords): QrCodeMatrix private function placeDataCodewords(QrCodeMatrix $matrix, array $codewords): QrCodeMatrix
{ {
$size = $matrix->getSize(); $size = $matrix->getSize();
$bitIndex = 0; $bitIndex = 0;
// Convert codewords to bit string // Convert codewords to bit string
// ISO/IEC 18004: Bits are placed MSB-first (most significant bit first)
$bits = ''; $bits = '';
foreach ($codewords as $codeword) { foreach ($codewords as $codeword) {
// Convert byte to 8-bit binary string (MSB-first)
$bits .= str_pad(decbin($codeword), 8, '0', STR_PAD_LEFT); $bits .= str_pad(decbin($codeword), 8, '0', STR_PAD_LEFT);
} }
$totalBits = strlen($bits); $totalBits = strlen($bits);
@@ -212,12 +220,14 @@ final class QrCodeGenerator
$row = $upward ? ($size - 1 - $i) : $i; $row = $upward ? ($size - 1 - $i) : $i;
// Place bits in both columns of the pair // Place bits in both columns of the pair
// ISO/IEC 18004 Section 7.7.3: Within a column pair, place bits from RIGHT to LEFT
// Right column first (col), then left column (col-1)
for ($c = 0; $c < 2; $c++) { for ($c = 0; $c < 2; $c++) {
$currentCol = $col - $c; $currentCol = $col - $c;
$position = ModulePosition::at($row, $currentCol); $position = ModulePosition::at($row, $currentCol);
// Skip if position is already occupied (function patterns) // Skip if position is already occupied (function patterns)
if (self::isOccupied($matrix, $position)) { if ($this->isOccupied($matrix, $position)) {
continue; continue;
} }
@@ -241,7 +251,7 @@ final class QrCodeGenerator
/** /**
* Check if position is occupied by function pattern * Check if position is occupied by function pattern
*/ */
private static function isOccupied(QrCodeMatrix $matrix, ModulePosition $position): bool private function isOccupied(QrCodeMatrix $matrix, ModulePosition $position): bool
{ {
$size = $matrix->getSize(); $size = $matrix->getSize();
$row = $position->row; $row = $position->row;
@@ -320,4 +330,98 @@ final class QrCodeGenerator
return false; return false;
} }
/**
* Generate QR Code as SVG string
*/
public function generateSvg(
string $data,
ErrorCorrectionLevel $errorLevel = ErrorCorrectionLevel::M,
?QrCodeVersion $version = null
): string {
$config = $version !== null
? QrCodeConfig::withVersion($version->getVersionNumber(), $errorLevel)
: QrCodeConfig::autoSize($data, $errorLevel);
$matrix = $this->generateMatrix($data, $config);
return $this->renderer->renderSvg($matrix);
}
/**
* Generate QR Code as Data URI (base64 encoded SVG)
*/
public function generateDataUri(
string $data,
ErrorCorrectionLevel $errorLevel = ErrorCorrectionLevel::M,
?QrCodeVersion $version = null
): string {
$config = $version !== null
? QrCodeConfig::withVersion($version->getVersionNumber(), $errorLevel)
: QrCodeConfig::autoSize($data, $errorLevel);
$matrix = $this->generateMatrix($data, $config);
return $this->renderer->toDataUrl($matrix);
}
/**
* Analyze data and provide QR Code recommendations
*
* @return array<string, mixed> Analysis data with recommendations
*/
public function analyzeData(string $data): array
{
$dataLength = strlen($data);
$encodingMode = EncodingMode::BYTE; // Currently only byte mode is supported
$recommendedErrorLevel = ErrorCorrectionLevel::M; // Default
$recommendedVersion = QrCodeVersion::fromDataLength($dataLength, $encodingMode, $recommendedErrorLevel);
// Determine if data looks like a URL
$isUrl = filter_var($data, FILTER_VALIDATE_URL) !== false || str_starts_with($data, 'http://') || str_starts_with($data, 'https://');
// Determine if data looks like TOTP URI
$isTotp = str_starts_with($data, 'otpauth://totp/');
// Calculate estimated capacity
$capacity = $recommendedVersion->getDataCapacity($encodingMode, $recommendedErrorLevel);
return [
'dataLength' => $dataLength,
'dataType' => $isTotp ? 'totp' : ($isUrl ? 'url' : 'text'),
'recommendedVersion' => $recommendedVersion->getVersionNumber(),
'recommendedErrorLevel' => $recommendedErrorLevel->value,
'encodingMode' => $encodingMode->value,
'matrixSize' => $recommendedVersion->getMatrixSize(),
'capacity' => $capacity,
'efficiency' => round(($dataLength / $capacity) * 100, 2),
];
}
/**
* Generate TOTP QR Code with optimized settings
*
* TOTP URIs are typically longer, so we use a higher version for better readability
*/
public function generateTotpQrCode(string $totpUri): string
{
// TOTP URIs are typically 50-100 characters, so we use version 3 for better error correction
$version = QrCodeVersion::fromNumber(3);
$errorLevel = ErrorCorrectionLevel::M; // Medium error correction for TOTP
$config = QrCodeConfig::withVersion($version->getVersionNumber(), $errorLevel);
// Validate that data fits
$dataLength = strlen($totpUri);
$capacity = $version->getDataCapacity($config->encodingMode, $errorLevel);
if ($dataLength > $capacity) {
throw FrameworkException::simple(
"TOTP URI too long: {$dataLength} bytes exceeds capacity of {$capacity} bytes"
);
}
$matrix = $this->generateMatrix($totpUri, $config);
return $this->renderer->renderSvg($matrix);
}
} }

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
/**
* QR Code Module Initializer
*
* Registriert QrCodeRenderer und QrCodeGenerator im DI-Container
*/
final readonly class QrCodeInitializer
{
#[Initializer]
public function initializeQrCode(Container $container): void
{
// QrCodeRenderer - can be used independently
$container->singleton(QrCodeRenderer::class, function () {
return new QrCodeRenderer();
});
// QrCodeGenerator - depends on QrCodeRenderer
$container->singleton(QrCodeGenerator::class, function (Container $container) {
$renderer = $container->get(QrCodeRenderer::class);
return new QrCodeGenerator($renderer);
});
}
}

View File

@@ -152,4 +152,14 @@ final readonly class QrCodeVersion
{ {
return new self(1); return new self(1);
} }
/**
* Get recommended version for TOTP URIs
*
* TOTP URIs are typically 50-100 characters, so version 3 is recommended
*/
public static function forTotp(): self
{
return new self(3);
}
} }

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Netcup;
use App\Framework\Http\Method;
use App\Infrastructure\Api\Netcup\ValueObjects\DnsRecord;
final readonly class DnsService
{
public function __construct(
private NetcupApiClient $apiClient
) {
}
/**
* Listet alle DNS-Records einer Domain auf
*
* @param string $domain Die Domain (z.B. 'example.com')
* @return array Liste der DnsRecord Value Objects
*/
public function listRecords(string $domain): array
{
$response = $this->apiClient->request(
Method::GET,
"dns/{$domain}/records"
);
$records = $response['records'] ?? $response;
if (! is_array($records)) {
return [];
}
return array_map(
fn (array $data) => DnsRecord::fromArray($data),
$records
);
}
/**
* Ruft einen einzelnen DNS-Record ab
*
* @param string $domain Die Domain
* @param string $recordId Die Record-ID
* @return DnsRecord DNS-Record Value Object
*/
public function getRecord(string $domain, string $recordId): DnsRecord
{
$response = $this->apiClient->request(
Method::GET,
"dns/{$domain}/records/{$recordId}"
);
$data = $response['record'] ?? $response;
return DnsRecord::fromArray($data);
}
/**
* Erstellt einen neuen DNS-Record
*
* @param string $domain Die Domain
* @param DnsRecord $record DNS-Record Value Object
* @return DnsRecord Erstellter DNS-Record
*/
public function createRecord(string $domain, DnsRecord $record): DnsRecord
{
$response = $this->apiClient->request(
Method::POST,
"dns/{$domain}/records",
$record->toArray()
);
$data = $response['record'] ?? $response;
return DnsRecord::fromArray($data);
}
/**
* Aktualisiert einen DNS-Record
*
* @param string $domain Die Domain
* @param string $recordId Die Record-ID
* @param DnsRecord $record DNS-Record Value Object
* @return DnsRecord Aktualisierter DNS-Record
*/
public function updateRecord(string $domain, string $recordId, DnsRecord $record): DnsRecord
{
$data = $record->toArray();
unset($data['id']); // Remove ID from update payload
$response = $this->apiClient->request(
Method::PUT,
"dns/{$domain}/records/{$recordId}",
$data
);
$responseData = $response['record'] ?? $response;
return DnsRecord::fromArray($responseData);
}
/**
* Löscht einen DNS-Record
*
* @param string $domain Die Domain
* @param string $recordId Die Record-ID
* @return void
*/
public function deleteRecord(string $domain, string $recordId): void
{
$this->apiClient->sendRawRequest(
Method::DELETE,
"dns/{$domain}/records/{$recordId}"
);
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Netcup;
use App\Framework\Api\ApiException;
use App\Framework\Http\Method;
use App\Framework\HttpClient\AuthConfig;
use App\Framework\HttpClient\ClientOptions;
use App\Framework\HttpClient\ClientRequest;
use App\Framework\HttpClient\ClientResponse;
use App\Framework\HttpClient\HttpClient;
final readonly class NetcupApiClient
{
private ClientOptions $defaultOptions;
public function __construct(
private NetcupConfig $config,
private HttpClient $httpClient
) {
$this->defaultOptions = new ClientOptions(
timeout: (int) $this->config->timeout,
auth: $this->buildAuthConfig()
);
}
/**
* Sendet eine API-Anfrage und gibt JSON-Daten zurück
*/
public function request(
Method $method,
string $endpoint,
array $data = [],
array $queryParams = []
): array {
$response = $this->sendRawRequest($method, $endpoint, $data, $queryParams);
return $this->handleResponse($response);
}
/**
* Sendet eine API-Anfrage und gibt raw Response zurück
*/
public function sendRawRequest(
Method $method,
string $endpoint,
array $data = [],
array $queryParams = []
): ClientResponse {
$baseUrl = rtrim($this->config->baseUrl, '/');
$url = $baseUrl . '/' . ltrim($endpoint, '/');
$options = $this->defaultOptions;
if (! empty($queryParams)) {
$options = $options->with(['query' => $queryParams]);
}
if (in_array($method, [Method::GET, Method::DELETE]) && ! empty($data)) {
$options = $options->with(['query' => array_merge($options->query, $data)]);
$data = [];
}
$request = empty($data)
? new ClientRequest($method, $url, options: $options)
: ClientRequest::json($method, $url, $data, $options);
$response = $this->httpClient->send($request);
if (! $response->isSuccessful()) {
$this->throwApiException($response);
}
return $response;
}
/**
* Behandelt API-Response
*/
private function handleResponse(ClientResponse $response): array
{
if (! $response->isJson()) {
throw new ApiException(
'Expected JSON response, got: ' . $response->getContentType(),
0,
$response
);
}
try {
return $response->json();
} catch (\Exception $e) {
throw new ApiException(
'Invalid JSON response: ' . $e->getMessage(),
0,
$response
);
}
}
/**
* Wirft API-Exception
*/
private function throwApiException(ClientResponse $response): never
{
$data = [];
if ($response->isJson()) {
try {
$data = $response->json();
} catch (\Exception) {
// JSON parsing failed
}
}
$message = $this->formatErrorMessage($data, $response);
throw new ApiException($message, $response->status->value, $response);
}
/**
* Formatiert Fehlermeldung
*/
private function formatErrorMessage(array $responseData, ClientResponse $response): string
{
if (isset($responseData['message'])) {
return 'Netcup API Error: ' . $responseData['message'];
}
if (isset($responseData['error'])) {
return 'Netcup API Error: ' . $responseData['error'];
}
if (isset($responseData['shortMessage'])) {
$message = 'Netcup API Error: ' . $responseData['shortMessage'];
if (isset($responseData['longMessage'])) {
$message .= ' - ' . $responseData['longMessage'];
}
return $message;
}
return "Netcup API Error (HTTP {$response->status->value}): " .
substr($response->body, 0, 200);
}
/**
* Erstellt AuthConfig basierend auf NetcupConfig
*/
private function buildAuthConfig(): AuthConfig
{
return AuthConfig::custom([
'headers' => [
'X-API-KEY' => $this->config->apiKey,
'X-API-PASSWORD' => $this->config->apiPassword,
],
]);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Netcup;
use App\Framework\HttpClient\HttpClient;
final readonly class NetcupClient
{
public DnsService $dns;
public ServerService $servers;
public function __construct(
NetcupConfig $config,
HttpClient $httpClient
) {
$apiClient = new NetcupApiClient($config, $httpClient);
$this->dns = new DnsService($apiClient);
$this->servers = new ServerService($apiClient);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Netcup;
use App\Framework\Config\Environment;
use App\Framework\Config\EnvKey;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\HttpClient\CurlHttpClient;
use App\Framework\HttpClient\HttpClient;
final readonly class NetcupClientInitializer
{
#[Initializer]
public function __invoke(Container $container): NetcupClient
{
$env = $container->get(Environment::class);
$httpClient = $container->get(HttpClient::class) ?? new CurlHttpClient();
$apiKey = $env->require(EnvKey::NETCUP_API_KEY);
$apiPassword = $env->require(EnvKey::NETCUP_API_PASSWORD);
$baseUrl = $env->get('NETCUP_BASE_URL', 'https://api.netcup.net');
$timeout = (float) $env->get('NETCUP_TIMEOUT', '30.0');
$config = new NetcupConfig(
apiKey: $apiKey,
apiPassword: $apiPassword,
baseUrl: $baseUrl,
timeout: $timeout
);
return new NetcupClient($config, $httpClient);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Netcup;
final readonly class NetcupConfig
{
public function __construct(
public string $apiKey,
public string $apiPassword,
public string $baseUrl = 'https://api.netcup.net',
public float $timeout = 30.0
) {
}
}

View File

@@ -0,0 +1,387 @@
# Netcup API Client
## Übersicht
Dieser Client bietet eine strukturierte Schnittstelle für die Kommunikation mit der Netcup REST API. Er unterstützt DNS-Management-Operationen für Domains.
## Architektur
Der Client folgt dem Service-Layer-Pattern:
- **NetcupApiClient**: Low-level API Client für HTTP-Kommunikation
- **DnsService**: DNS-Record-Verwaltung mit Value Objects
- **ServerService**: Server-Management (VServer, Root Server)
- **Value Objects**: `DnsRecord` und `DnsRecordType` für type-safe DNS-Operationen
- **NetcupClient**: Facade, die alle Services bereitstellt
## Konfiguration
### Environment Variables
```env
NETCUP_API_KEY=your_api_key
NETCUP_API_PASSWORD=your_api_password
NETCUP_BASE_URL=https://api.netcup.net
NETCUP_TIMEOUT=30.0
```
### Manuelle Konfiguration
```php
use App\Infrastructure\Api\Netcup\NetcupClient;
use App\Infrastructure\Api\Netcup\NetcupConfig;
use App\Framework\HttpClient\CurlHttpClient;
$config = new NetcupConfig(
apiKey: 'your_api_key',
apiPassword: 'your_api_password',
baseUrl: 'https://api.netcup.net',
timeout: 30.0
);
$client = new NetcupClient($config, new CurlHttpClient());
```
### Dependency Injection
Der Client wird automatisch über den DI-Container bereitgestellt:
```php
use App\Infrastructure\Api\Netcup\NetcupClient;
// Im Controller oder Service
public function __construct(
private readonly NetcupClient $netcupClient
) {
}
```
## Verwendung
### DNS-Management mit Value Objects
#### DNS-Records auflisten
```php
use App\Infrastructure\Api\Netcup\ValueObjects\DnsRecord;
use App\Infrastructure\Api\Netcup\ValueObjects\DnsRecordType;
// Gibt Array von DnsRecord Value Objects zurück
$records = $netcupClient->dns->listRecords('example.com');
foreach ($records as $record) {
echo $record->name . ' -> ' . $record->content . PHP_EOL;
}
```
#### Einzelnen DNS-Record abrufen
```php
// Gibt DnsRecord Value Object zurück
$record = $netcupClient->dns->getRecord('example.com', 'record-id');
echo $record->type->value; // 'A', 'AAAA', 'CNAME', etc.
echo $record->content;
```
#### DNS-Record erstellen
```php
use App\Infrastructure\Api\Netcup\ValueObjects\DnsRecord;
use App\Infrastructure\Api\Netcup\ValueObjects\DnsRecordType;
// Mit Value Object (empfohlen)
$newRecord = new DnsRecord(
type: DnsRecordType::A,
name: 'www',
content: '192.0.2.1',
ttl: 3600
);
$created = $netcupClient->dns->createRecord('example.com', $newRecord);
```
#### DNS-Record mit Priority erstellen (MX, SRV)
```php
// MX Record mit Priority
$mxRecord = new DnsRecord(
type: DnsRecordType::MX,
name: '@',
content: 'mail.example.com',
ttl: 3600,
priority: 10
);
$created = $netcupClient->dns->createRecord('example.com', $mxRecord);
```
#### DNS-Record aktualisieren
```php
// Hole bestehenden Record
$record = $netcupClient->dns->getRecord('example.com', 'record-id');
// Erstelle aktualisierte Version
$updated = $record->withContent('192.0.2.2')->withTtl(7200);
// Aktualisiere
$result = $netcupClient->dns->updateRecord('example.com', 'record-id', $updated);
```
#### DNS-Record löschen
```php
$netcupClient->dns->deleteRecord('example.com', 'record-id');
```
### Server-Management
#### Server auflisten
```php
$servers = $netcupClient->servers->listServers();
```
#### Server-Informationen abrufen
```php
$server = $netcupClient->servers->getServer('server-id');
```
#### Server starten/stoppen/neustarten
```php
// Server starten
$netcupClient->servers->startServer('server-id');
// Server stoppen
$netcupClient->servers->stopServer('server-id');
// Server neu starten
$netcupClient->servers->restartServer('server-id');
```
#### Snapshot-Verwaltung
```php
// Snapshots auflisten
$snapshots = $netcupClient->servers->listSnapshots('server-id');
// Snapshot erstellen
$snapshot = $netcupClient->servers->createSnapshot('server-id', 'backup-2025-01-29');
// Snapshot wiederherstellen
$netcupClient->servers->restoreSnapshot('server-id', 'snapshot-id');
// Snapshot löschen
$netcupClient->servers->deleteSnapshot('server-id', 'snapshot-id');
```
## API-Referenz
### DnsService
#### `listRecords(string $domain): array<DnsRecord>`
Listet alle DNS-Records einer Domain auf.
**Parameter:**
- `$domain`: Die Domain (z.B. 'example.com')
**Rückgabe:** Array von `DnsRecord` Value Objects
#### `getRecord(string $domain, string $recordId): DnsRecord`
Ruft einen einzelnen DNS-Record ab.
**Parameter:**
- `$domain`: Die Domain
- `$recordId`: Die Record-ID
**Rückgabe:** `DnsRecord` Value Object
#### `createRecord(string $domain, DnsRecord $record): DnsRecord`
Erstellt einen neuen DNS-Record.
**Parameter:**
- `$domain`: Die Domain
- `$record`: `DnsRecord` Value Object mit Record-Daten
**Rückgabe:** `DnsRecord` Value Object des erstellten Records
#### `updateRecord(string $domain, string $recordId, DnsRecord $record): DnsRecord`
Aktualisiert einen DNS-Record.
**Parameter:**
- `$domain`: Die Domain
- `$recordId`: Die Record-ID
- `$record`: `DnsRecord` Value Object mit aktualisierten Daten
**Rückgabe:** `DnsRecord` Value Object des aktualisierten Records
#### `deleteRecord(string $domain, string $recordId): void`
Löscht einen DNS-Record.
**Parameter:**
- `$domain`: Die Domain
- `$recordId`: Die Record-ID
### DnsRecord Value Object
#### Properties
- `DnsRecordType $type`: Der DNS-Record-Typ (A, AAAA, CNAME, MX, etc.)
- `string $name`: Der Hostname/Name des Records
- `string $content`: Der Inhalt des Records (IP-Adresse, Domain, etc.)
- `int $ttl`: Time To Live in Sekunden
- `?int $priority`: Priorität (für MX und SRV Records)
- `?string $id`: Record-ID (wenn aus API abgerufen)
#### Factory-Methoden
```php
// Aus Array erstellen (API-Response)
$record = DnsRecord::fromArray($apiResponse);
// Direkt erstellen
$record = new DnsRecord(
type: DnsRecordType::A,
name: 'www',
content: '192.0.2.1',
ttl: 3600
);
```
#### Immutable Transformation
```php
// Neuen Record mit aktualisiertem Content erstellen
$updated = $record->withContent('192.0.2.2');
// Neuen Record mit aktualisiertem TTL erstellen
$updated = $record->withTtl(7200);
// Kombiniert
$updated = $record->withContent('192.0.2.2')->withTtl(7200);
```
### DnsRecordType Enum
Unterstützte DNS-Record-Typen: `A`, `AAAA`, `CNAME`, `MX`, `TXT`, `NS`, `SRV`, `PTR`, `SOA`, `CAA`
#### Methoden
```php
// Prüfen ob Priority erforderlich ist
$type->requiresPriority(); // true für MX, SRV
// Prüfen ob es ein IP-Record ist
$type->isIpRecord(); // true für A, AAAA
```
### ServerService
#### `listServers(): array`
Listet alle Server auf.
**Rückgabe:** Array mit Server-Informationen
#### `getServer(string $serverId): array`
Ruft Server-Informationen ab.
**Parameter:**
- `$serverId`: Die Server-ID
**Rückgabe:** Array mit Server-Daten
#### `startServer(string $serverId): void`
Startet einen Server.
**Parameter:**
- `$serverId`: Die Server-ID
#### `stopServer(string $serverId): void`
Stoppt einen Server.
**Parameter:**
- `$serverId`: Die Server-ID
#### `restartServer(string $serverId): void`
Startet einen Server neu.
**Parameter:**
- `$serverId`: Die Server-ID
#### `listSnapshots(string $serverId): array`
Listet alle Snapshots eines Servers auf.
**Parameter:**
- `$serverId`: Die Server-ID
**Rückgabe:** Array mit Snapshot-Informationen
#### `createSnapshot(string $serverId, string $name): array`
Erstellt einen Snapshot.
**Parameter:**
- `$serverId`: Die Server-ID
- `$name`: Der Name des Snapshots
**Rückgabe:** Array mit Snapshot-Daten
#### `deleteSnapshot(string $serverId, string $snapshotId): void`
Löscht einen Snapshot.
**Parameter:**
- `$serverId`: Die Server-ID
- `$snapshotId`: Die Snapshot-ID
#### `restoreSnapshot(string $serverId, string $snapshotId): void`
Stellt einen Snapshot wieder her.
**Parameter:**
- `$serverId`: Die Server-ID
- `$snapshotId`: Die Snapshot-ID
## Fehlerbehandlung
Alle API-Fehler werden als `ApiException` geworfen:
```php
use App\Framework\Api\ApiException;
use App\Infrastructure\Api\Netcup\ValueObjects\DnsRecord;
use App\Infrastructure\Api\Netcup\ValueObjects\DnsRecordType;
try {
$record = new DnsRecord(
type: DnsRecordType::A,
name: 'www',
content: '192.0.2.1',
ttl: 3600
);
$created = $netcupClient->dns->createRecord('example.com', $record);
} catch (ApiException $e) {
// Fehlerbehandlung
echo $e->getMessage();
}
```
## Authentifizierung
Die Authentifizierung erfolgt über API-Key und API-Passwort, die als Custom-Header (`X-API-KEY` und `X-API-PASSWORD`) mit jeder Anfrage gesendet werden.
Die Credentials können im Netcup Customer Control Panel (CCP) unter "Stammdaten" -> "API" generiert werden.

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Netcup;
use App\Framework\Http\Method;
final readonly class ServerService
{
public function __construct(
private NetcupApiClient $apiClient
) {
}
/**
* Listet alle Server auf
*
* @return array Liste der Server
*/
public function listServers(): array
{
return $this->apiClient->request(
Method::GET,
'servers'
);
}
/**
* Ruft Server-Informationen ab
*
* @param string $serverId Die Server-ID
* @return array Server-Daten
*/
public function getServer(string $serverId): array
{
return $this->apiClient->request(
Method::GET,
"servers/{$serverId}"
);
}
/**
* Startet einen Server
*
* @param string $serverId Die Server-ID
* @return void
*/
public function startServer(string $serverId): void
{
$this->apiClient->sendRawRequest(
Method::POST,
"servers/{$serverId}/start"
);
}
/**
* Stoppt einen Server
*
* @param string $serverId Die Server-ID
* @return void
*/
public function stopServer(string $serverId): void
{
$this->apiClient->sendRawRequest(
Method::POST,
"servers/{$serverId}/stop"
);
}
/**
* Startet einen Server neu
*
* @param string $serverId Die Server-ID
* @return void
*/
public function restartServer(string $serverId): void
{
$this->apiClient->sendRawRequest(
Method::POST,
"servers/{$serverId}/restart"
);
}
/**
* Listet alle Snapshots eines Servers auf
*
* @param string $serverId Die Server-ID
* @return array Liste der Snapshots
*/
public function listSnapshots(string $serverId): array
{
return $this->apiClient->request(
Method::GET,
"servers/{$serverId}/snapshots"
);
}
/**
* Erstellt einen Snapshot
*
* @param string $serverId Die Server-ID
* @param string $name Der Name des Snapshots
* @return array Erstellter Snapshot
*/
public function createSnapshot(string $serverId, string $name): array
{
return $this->apiClient->request(
Method::POST,
"servers/{$serverId}/snapshots",
['name' => $name]
);
}
/**
* Löscht einen Snapshot
*
* @param string $serverId Die Server-ID
* @param string $snapshotId Die Snapshot-ID
* @return void
*/
public function deleteSnapshot(string $serverId, string $snapshotId): void
{
$this->apiClient->sendRawRequest(
Method::DELETE,
"servers/{$serverId}/snapshots/{$snapshotId}"
);
}
/**
* Stellt einen Snapshot wieder her
*
* @param string $serverId Die Server-ID
* @param string $snapshotId Die Snapshot-ID
* @return void
*/
public function restoreSnapshot(string $serverId, string $snapshotId): void
{
$this->apiClient->sendRawRequest(
Method::POST,
"servers/{$serverId}/snapshots/{$snapshotId}/restore"
);
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Netcup\ValueObjects;
use InvalidArgumentException;
/**
* DNS Record Value Object
*
* Immutable value object representing a DNS record with type, name, content, TTL, and optional priority.
*/
final readonly class DnsRecord
{
public function __construct(
public DnsRecordType $type,
public string $name,
public string $content,
public int $ttl,
public ?int $priority = null,
public ?string $id = null
) {
if (empty($this->name)) {
throw new InvalidArgumentException('DNS record name cannot be empty');
}
if (empty($this->content)) {
throw new InvalidArgumentException('DNS record content cannot be empty');
}
if ($this->ttl < 0) {
throw new InvalidArgumentException('DNS record TTL must be non-negative');
}
if ($this->type->requiresPriority() && $this->priority === null) {
throw new InvalidArgumentException(
"DNS record type {$this->type->value} requires a priority value"
);
}
if (! $this->type->requiresPriority() && $this->priority !== null) {
throw new InvalidArgumentException(
"DNS record type {$this->type->value} does not support priority"
);
}
if ($this->priority !== null && $this->priority < 0) {
throw new InvalidArgumentException('DNS record priority must be non-negative');
}
}
/**
* Create DnsRecord from array (API response or input data)
*/
public static function fromArray(array $data): self
{
$type = DnsRecordType::tryFrom($data['type'] ?? $data['recordtype'] ?? '')
?? throw new InvalidArgumentException("Invalid DNS record type: " . ($data['type'] ?? $data['recordtype'] ?? 'unknown'));
return new self(
type: $type,
name: $data['name'] ?? $data['hostname'] ?? '',
content: $data['content'] ?? $data['destination'] ?? $data['value'] ?? '',
ttl: isset($data['ttl']) ? (int) $data['ttl'] : 3600,
priority: isset($data['priority']) ? (int) $data['priority'] : null,
id: $data['id'] ?? $data['recordid'] ?? null
);
}
/**
* Convert to array for API requests
*/
public function toArray(): array
{
$data = [
'type' => $this->type->value,
'name' => $this->name,
'content' => $this->content,
'ttl' => $this->ttl,
];
if ($this->priority !== null) {
$data['priority'] = $this->priority;
}
if ($this->id !== null) {
$data['id'] = $this->id;
}
return $data;
}
/**
* Create a new DnsRecord with updated content
*/
public function withContent(string $content): self
{
return new self(
type: $this->type,
name: $this->name,
content: $content,
ttl: $this->ttl,
priority: $this->priority,
id: $this->id
);
}
/**
* Create a new DnsRecord with updated TTL
*/
public function withTtl(int $ttl): self
{
return new self(
type: $this->type,
name: $this->name,
content: $this->content,
ttl: $ttl,
priority: $this->priority,
id: $this->id
);
}
/**
* Check if this record has an ID (is persisted)
*/
public function hasId(): bool
{
return $this->id !== null;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Netcup\ValueObjects;
/**
* DNS Record Type
*
* Represents the type of a DNS record (A, AAAA, CNAME, MX, etc.)
*/
enum DnsRecordType: string
{
case A = 'A';
case AAAA = 'AAAA';
case CNAME = 'CNAME';
case MX = 'MX';
case TXT = 'TXT';
case NS = 'NS';
case SRV = 'SRV';
case PTR = 'PTR';
case SOA = 'SOA';
case CAA = 'CAA';
/**
* Check if this record type requires a priority value
*/
public function requiresPriority(): bool
{
return match ($this) {
self::MX, self::SRV => true,
default => false,
};
}
/**
* Check if this record type is for IP addresses
*/
public function isIpRecord(): bool
{
return match ($this) {
self::A, self::AAAA => true,
default => false,
};
}
}

View File

@@ -5,12 +5,14 @@ declare(strict_types=1);
namespace App\Infrastructure\Api\RapidMail\ApiRequests; namespace App\Infrastructure\Api\RapidMail\ApiRequests;
use App\Framework\ApiGateway\ApiRequest; use App\Framework\ApiGateway\ApiRequest;
use App\Framework\ApiGateway\HasAuth;
use App\Framework\ApiGateway\HasPayload; use App\Framework\ApiGateway\HasPayload;
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint; use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
use App\Framework\Core\ValueObjects\Duration; use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Headers; use App\Framework\Http\Headers;
use App\Framework\Http\Method as HttpMethod; use App\Framework\Http\Method as HttpMethod;
use App\Framework\Http\Url\Url; use App\Framework\Http\Url\Url;
use App\Framework\HttpClient\AuthConfig;
use App\Framework\Retry\ExponentialBackoffStrategy; use App\Framework\Retry\ExponentialBackoffStrategy;
use App\Framework\Retry\RetryStrategy; use App\Framework\Retry\RetryStrategy;
use App\Infrastructure\Api\RapidMail\RapidMailConfig; use App\Infrastructure\Api\RapidMail\RapidMailConfig;
@@ -31,7 +33,7 @@ use App\Infrastructure\Api\RapidMail\RapidMailConfig;
* *
* $response = $apiGateway->send($request); * $response = $apiGateway->send($request);
*/ */
final readonly class CreateRecipientApiRequest implements ApiRequest, HasPayload final readonly class CreateRecipientApiRequest implements ApiRequest, HasPayload, HasAuth
{ {
public function __construct( public function __construct(
private RapidMailConfig $config, private RapidMailConfig $config,
@@ -71,13 +73,14 @@ final readonly class CreateRecipientApiRequest implements ApiRequest, HasPayload
); );
} }
public function getAuth(): AuthConfig
{
return AuthConfig::basic($this->config->username, $this->config->password);
}
public function getHeaders(): Headers public function getHeaders(): Headers
{ {
// Basic Auth
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
return new Headers([ return new Headers([
'Authorization' => "Basic {$credentials}",
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'Accept' => 'application/json', 'Accept' => 'application/json',
]); ]);

View File

@@ -5,11 +5,13 @@ declare(strict_types=1);
namespace App\Infrastructure\Api\RapidMail\ApiRequests; namespace App\Infrastructure\Api\RapidMail\ApiRequests;
use App\Framework\ApiGateway\ApiRequest; use App\Framework\ApiGateway\ApiRequest;
use App\Framework\ApiGateway\HasAuth;
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint; use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
use App\Framework\Core\ValueObjects\Duration; use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Headers; use App\Framework\Http\Headers;
use App\Framework\Http\Method as HttpMethod; use App\Framework\Http\Method as HttpMethod;
use App\Framework\Http\Url\Url; use App\Framework\Http\Url\Url;
use App\Framework\HttpClient\AuthConfig;
use App\Framework\Retry\ExponentialBackoffStrategy; use App\Framework\Retry\ExponentialBackoffStrategy;
use App\Framework\Retry\RetryStrategy; use App\Framework\Retry\RetryStrategy;
use App\Infrastructure\Api\RapidMail\RapidMailConfig; use App\Infrastructure\Api\RapidMail\RapidMailConfig;
@@ -26,7 +28,7 @@ use App\Infrastructure\Api\RapidMail\RapidMailConfig;
* *
* $response = $apiGateway->send($request); * $response = $apiGateway->send($request);
*/ */
final readonly class DeleteRecipientApiRequest implements ApiRequest final readonly class DeleteRecipientApiRequest implements ApiRequest, HasAuth
{ {
public function __construct( public function __construct(
private RapidMailConfig $config, private RapidMailConfig $config,
@@ -61,13 +63,14 @@ final readonly class DeleteRecipientApiRequest implements ApiRequest
); );
} }
public function getAuth(): AuthConfig
{
return AuthConfig::basic($this->config->username, $this->config->password);
}
public function getHeaders(): Headers public function getHeaders(): Headers
{ {
// Basic Auth
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
return new Headers([ return new Headers([
'Authorization' => "Basic {$credentials}",
'Accept' => 'application/json', 'Accept' => 'application/json',
]); ]);
} }

View File

@@ -5,11 +5,13 @@ declare(strict_types=1);
namespace App\Infrastructure\Api\RapidMail\ApiRequests; namespace App\Infrastructure\Api\RapidMail\ApiRequests;
use App\Framework\ApiGateway\ApiRequest; use App\Framework\ApiGateway\ApiRequest;
use App\Framework\ApiGateway\HasAuth;
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint; use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
use App\Framework\Core\ValueObjects\Duration; use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Headers; use App\Framework\Http\Headers;
use App\Framework\Http\Method as HttpMethod; use App\Framework\Http\Method as HttpMethod;
use App\Framework\Http\Url\Url; use App\Framework\Http\Url\Url;
use App\Framework\HttpClient\AuthConfig;
use App\Framework\Retry\ExponentialBackoffStrategy; use App\Framework\Retry\ExponentialBackoffStrategy;
use App\Framework\Retry\RetryStrategy; use App\Framework\Retry\RetryStrategy;
use App\Infrastructure\Api\RapidMail\RapidMailConfig; use App\Infrastructure\Api\RapidMail\RapidMailConfig;
@@ -26,7 +28,7 @@ use App\Infrastructure\Api\RapidMail\RapidMailConfig;
* *
* $response = $apiGateway->send($request); * $response = $apiGateway->send($request);
*/ */
final readonly class GetRecipientApiRequest implements ApiRequest final readonly class GetRecipientApiRequest implements ApiRequest, HasAuth
{ {
public function __construct( public function __construct(
private RapidMailConfig $config, private RapidMailConfig $config,
@@ -61,13 +63,14 @@ final readonly class GetRecipientApiRequest implements ApiRequest
); );
} }
public function getAuth(): AuthConfig
{
return AuthConfig::basic($this->config->username, $this->config->password);
}
public function getHeaders(): Headers public function getHeaders(): Headers
{ {
// Basic Auth
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
return new Headers([ return new Headers([
'Authorization' => "Basic {$credentials}",
'Accept' => 'application/json', 'Accept' => 'application/json',
]); ]);
} }

View File

@@ -5,11 +5,13 @@ declare(strict_types=1);
namespace App\Infrastructure\Api\RapidMail\ApiRequests; namespace App\Infrastructure\Api\RapidMail\ApiRequests;
use App\Framework\ApiGateway\ApiRequest; use App\Framework\ApiGateway\ApiRequest;
use App\Framework\ApiGateway\HasAuth;
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint; use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
use App\Framework\Core\ValueObjects\Duration; use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Headers; use App\Framework\Http\Headers;
use App\Framework\Http\Method as HttpMethod; use App\Framework\Http\Method as HttpMethod;
use App\Framework\Http\Url\Url; use App\Framework\Http\Url\Url;
use App\Framework\HttpClient\AuthConfig;
use App\Framework\Retry\ExponentialBackoffStrategy; use App\Framework\Retry\ExponentialBackoffStrategy;
use App\Framework\Retry\RetryStrategy; use App\Framework\Retry\RetryStrategy;
use App\Infrastructure\Api\RapidMail\RapidMailConfig; use App\Infrastructure\Api\RapidMail\RapidMailConfig;
@@ -28,7 +30,7 @@ use App\Infrastructure\Api\RapidMail\RapidMailConfig;
* *
* $response = $apiGateway->send($request); * $response = $apiGateway->send($request);
*/ */
final readonly class SearchRecipientsApiRequest implements ApiRequest final readonly class SearchRecipientsApiRequest implements ApiRequest, HasAuth
{ {
public function __construct( public function __construct(
private RapidMailConfig $config, private RapidMailConfig $config,
@@ -84,13 +86,14 @@ final readonly class SearchRecipientsApiRequest implements ApiRequest
); );
} }
public function getAuth(): AuthConfig
{
return AuthConfig::basic($this->config->username, $this->config->password);
}
public function getHeaders(): Headers public function getHeaders(): Headers
{ {
// Basic Auth
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
return new Headers([ return new Headers([
'Authorization' => "Basic {$credentials}",
'Accept' => 'application/json', 'Accept' => 'application/json',
]); ]);
} }

View File

@@ -5,12 +5,14 @@ declare(strict_types=1);
namespace App\Infrastructure\Api\RapidMail\ApiRequests; namespace App\Infrastructure\Api\RapidMail\ApiRequests;
use App\Framework\ApiGateway\ApiRequest; use App\Framework\ApiGateway\ApiRequest;
use App\Framework\ApiGateway\HasAuth;
use App\Framework\ApiGateway\HasPayload; use App\Framework\ApiGateway\HasPayload;
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint; use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
use App\Framework\Core\ValueObjects\Duration; use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Headers; use App\Framework\Http\Headers;
use App\Framework\Http\Method as HttpMethod; use App\Framework\Http\Method as HttpMethod;
use App\Framework\Http\Url\Url; use App\Framework\Http\Url\Url;
use App\Framework\HttpClient\AuthConfig;
use App\Framework\Retry\ExponentialBackoffStrategy; use App\Framework\Retry\ExponentialBackoffStrategy;
use App\Framework\Retry\RetryStrategy; use App\Framework\Retry\RetryStrategy;
use App\Infrastructure\Api\RapidMail\RapidMailConfig; use App\Infrastructure\Api\RapidMail\RapidMailConfig;
@@ -31,7 +33,7 @@ use App\Infrastructure\Api\RapidMail\RapidMailConfig;
* *
* $response = $apiGateway->send($request); * $response = $apiGateway->send($request);
*/ */
final readonly class UpdateRecipientApiRequest implements ApiRequest, HasPayload final readonly class UpdateRecipientApiRequest implements ApiRequest, HasPayload, HasAuth
{ {
public function __construct( public function __construct(
private RapidMailConfig $config, private RapidMailConfig $config,
@@ -72,13 +74,14 @@ final readonly class UpdateRecipientApiRequest implements ApiRequest, HasPayload
); );
} }
public function getAuth(): AuthConfig
{
return AuthConfig::basic($this->config->username, $this->config->password);
}
public function getHeaders(): Headers public function getHeaders(): Headers
{ {
// Basic Auth
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
return new Headers([ return new Headers([
'Authorization' => "Basic {$credentials}",
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'Accept' => 'application/json', 'Accept' => 'application/json',
]); ]);

View File

@@ -5,11 +5,14 @@ declare(strict_types=1);
namespace App\Infrastructure\Api\Shopify; namespace App\Infrastructure\Api\Shopify;
use App\Framework\ApiGateway\ApiRequest; use App\Framework\ApiGateway\ApiRequest;
use App\Framework\ApiGateway\HasAuth;
use App\Framework\ApiGateway\HasPayload;
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint; use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
use App\Framework\Core\ValueObjects\Duration; use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Headers; use App\Framework\Http\Headers;
use App\Framework\Http\Method as HttpMethod; use App\Framework\Http\Method as HttpMethod;
use App\Framework\Http\Url\Url; use App\Framework\Http\Url\Url;
use App\Framework\HttpClient\AuthConfig;
use App\Framework\Retry\RetryStrategy; use App\Framework\Retry\RetryStrategy;
use App\Infrastructure\Api\Shopify\ValueObjects\{ShopifyApiKey, ShopifyStore}; use App\Infrastructure\Api\Shopify\ValueObjects\{ShopifyApiKey, ShopifyStore};
@@ -31,7 +34,7 @@ use App\Infrastructure\Api\Shopify\ValueObjects\{ShopifyApiKey, ShopifyStore};
* *
* $response = $apiGateway->send($request); * $response = $apiGateway->send($request);
*/ */
final readonly class CreateOrderApiRequest implements ApiRequest final readonly class CreateOrderApiRequest implements ApiRequest, HasPayload, HasAuth
{ {
public function __construct( public function __construct(
private ShopifyApiKey $apiKey, private ShopifyApiKey $apiKey,
@@ -73,10 +76,16 @@ final readonly class CreateOrderApiRequest implements ApiRequest
return $this->retryStrategy; return $this->retryStrategy;
} }
public function getAuth(): AuthConfig
{
return AuthConfig::custom([
'X-Shopify-Access-Token' => $this->apiKey->value,
]);
}
public function getHeaders(): Headers public function getHeaders(): Headers
{ {
return new Headers([ return new Headers([
'X-Shopify-Access-Token' => $this->apiKey->value,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'Accept' => 'application/json', 'Accept' => 'application/json',
]); ]);

View File

@@ -0,0 +1,634 @@
<?php
declare(strict_types=1);
use App\Framework\ApiGateway\ApiGateway;
use App\Framework\ApiGateway\ApiRequest;
use App\Framework\ApiGateway\HasAuth;
use App\Framework\ApiGateway\HasPayload;
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Headers;
use App\Framework\Http\Method as HttpMethod;
use App\Framework\Http\Url\Url;
use App\Framework\HttpClient\AuthConfig;
use App\Framework\HttpClient\ClientRequest;
use App\Framework\HttpClient\ClientResponse;
use App\Framework\HttpClient\HttpClient;
use App\Framework\HttpClient\Status;
use App\Framework\Retry\RetryStrategy;
describe('ApiGateway', function () {
beforeEach(function () {
// Track requests for assertion
$this->capturedRequest = null;
// Create mock HttpClient that captures request details
$this->httpClient = new class($this) implements HttpClient {
private $testContext;
public function __construct($testContext)
{
$this->testContext = $testContext;
}
public function send(ClientRequest $request): ClientResponse
{
// Capture request for assertions
$this->testContext->capturedRequest = $request;
// Mock successful response
return new ClientResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: '{"success": true}'
);
}
};
// Create mock dependencies for ApiGateway
// Create mock dependencies for CircuitBreakerManager
$mockCache = new class implements \App\Framework\Cache\Cache {
public function get(\App\Framework\Cache\CacheIdentifier ...$identifiers): \App\Framework\Cache\CacheResult
{
return new \App\Framework\Cache\CacheResult(hits: [], misses: $identifiers);
}
public function set(\App\Framework\Cache\CacheItem ...$items): bool { return true; }
public function has(\App\Framework\Cache\CacheIdentifier ...$identifiers): array { return []; }
public function delete(\App\Framework\Cache\CacheIdentifier ...$identifiers): bool { return true; }
public function forget(\App\Framework\Cache\CacheIdentifier ...$identifiers): bool { return true; }
public function clear(): bool { return true; }
public function flush(): bool { return true; }
public function remember(\App\Framework\Cache\CacheKey $key, callable $callback, ?\App\Framework\Core\ValueObjects\Duration $ttl = null): \App\Framework\Cache\CacheItem
{
$value = $callback();
return new \App\Framework\Cache\CacheItem($key, $value, $ttl);
}
};
$mockClock = new class implements \App\Framework\DateTime\Clock {
public function now(): \DateTimeImmutable { return new \DateTimeImmutable(); }
public function fromTimestamp(\App\Framework\Core\ValueObjects\Timestamp $timestamp): \DateTimeImmutable {
return new \DateTimeImmutable('@' . $timestamp->toUnixTimestamp());
}
public function fromString(string $dateTime, ?string $format = null): \DateTimeImmutable {
return new \DateTimeImmutable($dateTime);
}
public function today(): \DateTimeImmutable { return new \DateTimeImmutable('today'); }
public function yesterday(): \DateTimeImmutable { return new \DateTimeImmutable('yesterday'); }
public function tomorrow(): \DateTimeImmutable { return new \DateTimeImmutable('tomorrow'); }
public function time(): \App\Framework\Core\ValueObjects\Timestamp {
return \App\Framework\Core\ValueObjects\Timestamp::now();
}
};
$this->circuitBreakerManager = new \App\Framework\CircuitBreaker\CircuitBreakerManager(
cache: $mockCache,
clock: $mockClock
);
$this->metrics = new \App\Framework\ApiGateway\Metrics\ApiMetrics();
$this->operationTracker = new class implements \App\Framework\Performance\OperationTracker {
public function startOperation(
string $operationId,
\App\Framework\Performance\PerformanceCategory $category,
array $contextData = []
): \App\Framework\Performance\PerformanceSnapshot {
return new \App\Framework\Performance\PerformanceSnapshot(
operationId: $operationId,
category: $category,
startTime: microtime(true),
duration: \App\Framework\Core\ValueObjects\Duration::fromMilliseconds(10),
memoryUsed: 1024,
peakMemory: 2048,
contextData: $contextData
);
}
public function completeOperation(string $operationId): ?\App\Framework\Performance\PerformanceSnapshot {
return new \App\Framework\Performance\PerformanceSnapshot(
operationId: $operationId,
category: \App\Framework\Performance\PerformanceCategory::HTTP,
startTime: microtime(true) - 0.01,
duration: \App\Framework\Core\ValueObjects\Duration::fromMilliseconds(10),
memoryUsed: 1024,
peakMemory: 2048,
contextData: []
);
}
public function failOperation(string $operationId, \Throwable $exception): ?\App\Framework\Performance\PerformanceSnapshot {
return null;
}
};
$this->apiGateway = new ApiGateway(
$this->httpClient,
$this->circuitBreakerManager,
$this->metrics,
$this->operationTracker
);
});
describe('HasAuth Interface Integration', function () {
it('applies Basic authentication when ApiRequest implements HasAuth', function () {
$request = new class implements ApiRequest, HasAuth {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
{
return HttpMethod::GET;
}
public function getTimeout(): Duration
{
return Duration::fromSeconds(10);
}
public function getRetryStrategy(): ?RetryStrategy
{
return null;
}
public function getAuth(): AuthConfig
{
return AuthConfig::basic('testuser', 'testpass');
}
public function getHeaders(): Headers
{
return new Headers(['Accept' => 'application/json']);
}
public function getRequestName(): string
{
return 'test.basic_auth';
}
};
$response = $this->apiGateway->send($request);
expect($response->status)->toBe(Status::OK);
expect($this->capturedRequest->options->auth)->not->toBeNull();
expect($this->capturedRequest->options->auth->type)->toBe('basic');
});
it('applies custom header authentication when ApiRequest uses AuthConfig::custom()', function () {
$request = new class implements ApiRequest, HasAuth {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
{
return HttpMethod::GET;
}
public function getTimeout(): Duration
{
return Duration::fromSeconds(10);
}
public function getRetryStrategy(): ?RetryStrategy
{
return null;
}
public function getAuth(): AuthConfig
{
return AuthConfig::custom([
'X-API-Key' => 'test-api-key-123',
]);
}
public function getHeaders(): Headers
{
return new Headers(['Accept' => 'application/json']);
}
public function getRequestName(): string
{
return 'test.custom_auth';
}
};
$response = $this->apiGateway->send($request);
expect($response->status)->toBe(Status::OK);
expect($this->capturedRequest->options->auth)->not->toBeNull();
expect($this->capturedRequest->options->auth->type)->toBe('custom');
});
it('does not apply authentication when ApiRequest does not implement HasAuth', function () {
$request = new class implements ApiRequest {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
{
return HttpMethod::GET;
}
public function getTimeout(): Duration
{
return Duration::fromSeconds(10);
}
public function getRetryStrategy(): ?RetryStrategy
{
return null;
}
public function getHeaders(): Headers
{
return new Headers(['Accept' => 'application/json']);
}
public function getRequestName(): string
{
return 'test.no_auth';
}
};
$response = $this->apiGateway->send($request);
expect($response->status)->toBe(Status::OK);
// No auth should be applied
expect($this->capturedRequest->options->auth ?? null)->toBeNull();
});
});
describe('HasPayload Interface Integration', function () {
it('includes payload when ApiRequest implements HasPayload', function () {
$request = new class implements ApiRequest, HasPayload, HasAuth {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
{
return HttpMethod::POST;
}
public function getPayload(): array
{
return [
'name' => 'Test User',
'email' => 'test@example.com',
];
}
public function getTimeout(): Duration
{
return Duration::fromSeconds(10);
}
public function getRetryStrategy(): ?RetryStrategy
{
return null;
}
public function getAuth(): AuthConfig
{
return AuthConfig::basic('testuser', 'testpass');
}
public function getHeaders(): Headers
{
return new Headers([
'Content-Type' => 'application/json',
'Accept' => 'application/json',
]);
}
public function getRequestName(): string
{
return 'test.with_payload';
}
};
$response = $this->apiGateway->send($request);
expect($response->status)->toBe(Status::OK);
$bodyData = json_decode($this->capturedRequest->body, true);
expect($bodyData)->toBe([
'name' => 'Test User',
'email' => 'test@example.com',
]);
});
it('does not include payload when ApiRequest does not implement HasPayload', function () {
$request = new class implements ApiRequest, HasAuth {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
{
return HttpMethod::GET;
}
public function getTimeout(): Duration
{
return Duration::fromSeconds(10);
}
public function getRetryStrategy(): ?RetryStrategy
{
return null;
}
public function getAuth(): AuthConfig
{
return AuthConfig::basic('testuser', 'testpass');
}
public function getHeaders(): Headers
{
return new Headers(['Accept' => 'application/json']);
}
public function getRequestName(): string
{
return 'test.no_payload';
}
};
$response = $this->apiGateway->send($request);
expect($response->status)->toBe(Status::OK);
// No payload should be sent for GET request
expect($this->capturedRequest->body)->toBeEmpty();
});
});
describe('Request Name Tracking', function () {
it('uses request name from ApiRequest', function () {
$request = new class implements ApiRequest {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
{
return HttpMethod::GET;
}
public function getTimeout(): Duration
{
return Duration::fromSeconds(10);
}
public function getRetryStrategy(): ?RetryStrategy
{
return null;
}
public function getHeaders(): Headers
{
return new Headers(['Accept' => 'application/json']);
}
public function getRequestName(): string
{
return 'custom.request.name';
}
};
expect($request->getRequestName())->toBe('custom.request.name');
});
});
describe('HTTP Method Support', function () {
it('supports GET requests', function () {
$request = new class implements ApiRequest {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
{
return HttpMethod::GET;
}
public function getTimeout(): Duration
{
return Duration::fromSeconds(10);
}
public function getRetryStrategy(): ?RetryStrategy
{
return null;
}
public function getHeaders(): Headers
{
return new Headers(['Accept' => 'application/json']);
}
public function getRequestName(): string
{
return 'test.get';
}
};
$response = $this->apiGateway->send($request);
expect($response->status)->toBe(Status::OK);
expect($this->capturedRequest->method)->toBe('GET');
});
it('supports POST requests with payload', function () {
$request = new class implements ApiRequest, HasPayload {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
{
return HttpMethod::POST;
}
public function getPayload(): array
{
return ['data' => 'test'];
}
public function getTimeout(): Duration
{
return Duration::fromSeconds(10);
}
public function getRetryStrategy(): ?RetryStrategy
{
return null;
}
public function getHeaders(): Headers
{
return new Headers(['Content-Type' => 'application/json']);
}
public function getRequestName(): string
{
return 'test.post';
}
};
$response = $this->apiGateway->send($request);
expect($response->status)->toBe(Status::OK);
expect($this->capturedRequest->method)->toBe('POST');
$bodyData = json_decode($this->capturedRequest->body, true);
expect($bodyData)->toBe(['data' => 'test']);
});
it('supports DELETE requests', function () {
$request = new class implements ApiRequest, HasAuth {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test/123'));
}
public function getMethod(): HttpMethod
{
return HttpMethod::DELETE;
}
public function getTimeout(): Duration
{
return Duration::fromSeconds(10);
}
public function getRetryStrategy(): ?RetryStrategy
{
return null;
}
public function getAuth(): AuthConfig
{
return AuthConfig::basic('user', 'pass');
}
public function getHeaders(): Headers
{
return new Headers(['Accept' => 'application/json']);
}
public function getRequestName(): string
{
return 'test.delete';
}
};
$response = $this->apiGateway->send($request);
expect($response->status)->toBe(Status::OK);
expect($this->capturedRequest->method)->toBe('DELETE');
});
it('supports PATCH requests with payload', function () {
$request = new class implements ApiRequest, HasPayload, HasAuth {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test/123'));
}
public function getMethod(): HttpMethod
{
return HttpMethod::PATCH;
}
public function getPayload(): array
{
return ['name' => 'Updated'];
}
public function getTimeout(): Duration
{
return Duration::fromSeconds(10);
}
public function getRetryStrategy(): ?RetryStrategy
{
return null;
}
public function getAuth(): AuthConfig
{
return AuthConfig::basic('user', 'pass');
}
public function getHeaders(): Headers
{
return new Headers(['Content-Type' => 'application/json']);
}
public function getRequestName(): string
{
return 'test.patch';
}
};
$response = $this->apiGateway->send($request);
expect($response->status)->toBe(Status::OK);
expect($this->capturedRequest->method)->toBe('PATCH');
$bodyData = json_decode($this->capturedRequest->body, true);
expect($bodyData)->toBe(['name' => 'Updated']);
});
});
describe('Headers Configuration', function () {
it('includes custom headers from ApiRequest', function () {
$request = new class implements ApiRequest {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
{
return HttpMethod::GET;
}
public function getTimeout(): Duration
{
return Duration::fromSeconds(10);
}
public function getRetryStrategy(): ?RetryStrategy
{
return null;
}
public function getHeaders(): Headers
{
return new Headers([
'Accept' => 'application/json',
'X-Custom-Header' => 'custom-value',
'X-API-Version' => '2.0',
]);
}
public function getRequestName(): string
{
return 'test.custom_headers';
}
};
$headers = $request->getHeaders();
expect($headers->get('Accept'))->toBe(['application/json']);
expect($headers->get('X-Custom-Header'))->toBe(['custom-value']);
expect($headers->get('X-API-Version'))->toBe(['2.0']);
});
});
});

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\ExceptionHandling\Factory\ExceptionFactory;
use App\Framework\ExceptionHandling\Scope\ErrorScope;
use App\Framework\ExceptionHandling\Scope\ErrorScopeContext;
describe('Exception Context Integration', function () {
beforeEach(function () {
$this->contextProvider = ExceptionContextProvider::instance();
$this->errorScope = new ErrorScope();
$this->factory = new ExceptionFactory($this->contextProvider, $this->errorScope);
// Clear any existing contexts
$this->contextProvider->clear();
});
it('creates slim exception with external context via WeakMap', function () {
$context = ExceptionContextData::forOperation('user.create', 'UserService')
->addData(['user_id' => '123', 'email' => 'test@example.com']);
$exception = $this->factory->create(
RuntimeException::class,
'User creation failed',
$context
);
// Exception is slim (pure PHP)
expect($exception)->toBeInstanceOf(RuntimeException::class);
expect($exception->getMessage())->toBe('User creation failed');
// Context is stored externally
$storedContext = $this->contextProvider->get($exception);
expect($storedContext)->not->toBeNull();
expect($storedContext->operation)->toBe('user.create');
expect($storedContext->component)->toBe('UserService');
expect($storedContext->data)->toBe([
'user_id' => '123',
'email' => 'test@example.com'
]);
});
it('automatically enriches context from error scope', function () {
// Enter HTTP scope
$scopeContext = ErrorScopeContext::http(
request: createMockRequest(),
operation: 'api.request',
component: 'ApiController'
)->withUserId('user-456');
$this->errorScope->enter($scopeContext);
// Create exception without explicit context
$exception = $this->factory->create(
RuntimeException::class,
'API request failed'
);
// Context is enriched from scope
$storedContext = $this->contextProvider->get($exception);
expect($storedContext)->not->toBeNull();
expect($storedContext->userId)->toBe('user-456');
expect($storedContext->metadata)->toHaveKey('scope_type');
expect($storedContext->metadata['scope_type'])->toBe('http');
});
it('supports WeakMap automatic garbage collection', function () {
$exception = new RuntimeException('Test exception');
$context = ExceptionContextData::forOperation('test.operation');
$this->contextProvider->attach($exception, $context);
// Context exists
expect($this->contextProvider->has($exception))->toBeTrue();
// Unset exception reference
unset($exception);
// Force garbage collection
gc_collect_cycles();
// WeakMap automatically cleaned up (we can't directly test this,
// but stats should reflect fewer contexts after GC)
$stats = $this->contextProvider->getStats();
expect($stats)->toHaveKey('total_contexts');
});
it('enhances existing exception with additional context', function () {
$exception = new RuntimeException('Original error');
$originalContext = ExceptionContextData::forOperation('operation.1')
->addData(['step' => 1]);
$this->contextProvider->attach($exception, $originalContext);
// Enhance with additional context
$additionalContext = ExceptionContextData::empty()
->addData(['step' => 2, 'error_code' => 'E001']);
$this->factory->enhance($exception, $additionalContext);
// Context is merged
$storedContext = $this->contextProvider->get($exception);
expect($storedContext->data)->toBe([
'step' => 2, // Overwrites
'error_code' => 'E001' // Adds
]);
});
it('supports fiber-aware error scopes', function () {
// Main scope
$mainScope = ErrorScopeContext::generic(
'main',
'main.operation'
);
$this->errorScope->enter($mainScope);
// Create fiber scope
$fiber = new Fiber(function () {
$fiberScope = ErrorScopeContext::generic(
'fiber',
'fiber.operation'
);
$this->errorScope->enter($fiberScope);
$exception = $this->factory->create(
RuntimeException::class,
'Fiber error'
);
// Context from fiber scope
$context = $this->contextProvider->get($exception);
expect($context->operation)->toBe('fiber.operation');
$this->errorScope->exit();
});
$fiber->start();
// Main scope still active
$exception = $this->factory->create(
RuntimeException::class,
'Main error'
);
$context = $this->contextProvider->get($exception);
expect($context->operation)->toBe('main.operation');
});
it('creates exception with convenience factory methods', function () {
// forOperation
$exception1 = $this->factory->forOperation(
InvalidArgumentException::class,
'Invalid user data',
'user.validate',
'UserValidator',
['email' => 'invalid']
);
$context1 = $this->contextProvider->get($exception1);
expect($context1->operation)->toBe('user.validate');
expect($context1->component)->toBe('UserValidator');
expect($context1->data['email'])->toBe('invalid');
// withData
$exception2 = $this->factory->withData(
RuntimeException::class,
'Database error',
['query' => 'SELECT * FROM users']
);
$context2 = $this->contextProvider->get($exception2);
expect($context2->data['query'])->toBe('SELECT * FROM users');
});
it('handles nested error scopes correctly', function () {
// Outer scope
$outerScope = ErrorScopeContext::http(
request: createMockRequest(),
operation: 'outer.operation'
);
$this->errorScope->enter($outerScope);
// Inner scope
$innerScope = ErrorScopeContext::generic(
'inner',
'inner.operation'
);
$this->errorScope->enter($innerScope);
// Exception gets inner scope context
$exception = $this->factory->create(
RuntimeException::class,
'Inner error'
);
$context = $this->contextProvider->get($exception);
expect($context->operation)->toBe('inner.operation');
// Exit inner scope
$this->errorScope->exit();
// New exception gets outer scope context
$exception2 = $this->factory->create(
RuntimeException::class,
'Outer error'
);
$context2 = $this->contextProvider->get($exception2);
expect($context2->operation)->toBe('outer.operation');
});
});
// Helper function to create mock request
function createMockRequest(): \App\Framework\Http\HttpRequest
{
return new \App\Framework\Http\HttpRequest(
method: \App\Framework\Http\Method::GET,
path: '/test',
id: new \App\Framework\Http\RequestId('test-secret')
);
}

View File

@@ -3,6 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Framework\QrCode\QrCodeGenerator; use App\Framework\QrCode\QrCodeGenerator;
use App\Framework\QrCode\QrCodeRenderer;
use App\Framework\QrCode\ValueObjects\EncodingMode; use App\Framework\QrCode\ValueObjects\EncodingMode;
use App\Framework\QrCode\ValueObjects\ErrorCorrectionLevel; use App\Framework\QrCode\ValueObjects\ErrorCorrectionLevel;
use App\Framework\QrCode\ValueObjects\QrCodeConfig; use App\Framework\QrCode\ValueObjects\QrCodeConfig;
@@ -140,3 +141,103 @@ test('supports different data types', function () {
->and($matrix3)->toBeInstanceOf(\App\Framework\QrCode\ValueObjects\QrCodeMatrix::class) ->and($matrix3)->toBeInstanceOf(\App\Framework\QrCode\ValueObjects\QrCodeMatrix::class)
->and($matrix4)->toBeInstanceOf(\App\Framework\QrCode\ValueObjects\QrCodeMatrix::class); ->and($matrix4)->toBeInstanceOf(\App\Framework\QrCode\ValueObjects\QrCodeMatrix::class);
}); });
// Instance method tests
test('can generate SVG using instance method', function () {
$renderer = new QrCodeRenderer();
$generator = new QrCodeGenerator($renderer);
$data = 'Hello World';
$svg = $generator->generateSvg($data);
expect($svg)->toBeString()
->and($svg)->toContain('<svg')
->and($svg)->toContain('</svg>');
});
test('can generate data URI using instance method', function () {
$renderer = new QrCodeRenderer();
$generator = new QrCodeGenerator($renderer);
$data = 'Hello World';
$dataUri = $generator->generateDataUri($data);
expect($dataUri)->toBeString()
->and($dataUri)->toStartWith('data:image/svg+xml;base64,');
});
test('can analyze data and get recommendations', function () {
$renderer = new QrCodeRenderer();
$generator = new QrCodeGenerator($renderer);
$data = 'Hello World';
$analysis = $generator->analyzeData($data);
expect($analysis)->toBeArray()
->and($analysis)->toHaveKey('dataLength')
->and($analysis)->toHaveKey('dataType')
->and($analysis)->toHaveKey('recommendedVersion')
->and($analysis)->toHaveKey('recommendedErrorLevel')
->and($analysis)->toHaveKey('encodingMode')
->and($analysis)->toHaveKey('matrixSize')
->and($analysis)->toHaveKey('capacity')
->and($analysis)->toHaveKey('efficiency')
->and($analysis['dataLength'])->toBe(strlen($data))
->and($analysis['recommendedVersion'])->toBeGreaterThan(0);
});
test('analyzeData detects URL type', function () {
$renderer = new QrCodeRenderer();
$generator = new QrCodeGenerator($renderer);
$url = 'https://example.com/test';
$analysis = $generator->analyzeData($url);
expect($analysis['dataType'])->toBe('url');
});
test('analyzeData detects TOTP type', function () {
$renderer = new QrCodeRenderer();
$generator = new QrCodeGenerator($renderer);
$totpUri = 'otpauth://totp/TestApp:user@example.com?secret=JBSWY3DPEHPK3PXP';
$analysis = $generator->analyzeData($totpUri);
expect($analysis['dataType'])->toBe('totp');
});
test('can generate TOTP QR code', function () {
$renderer = new QrCodeRenderer();
$generator = new QrCodeGenerator($renderer);
$totpUri = 'otpauth://totp/TestApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=TestApp';
$svg = $generator->generateTotpQrCode($totpUri);
expect($svg)->toBeString()
->and($svg)->toContain('<svg')
->and($svg)->toContain('</svg>');
});
test('generateSvg with explicit version', function () {
$renderer = new QrCodeRenderer();
$generator = new QrCodeGenerator($renderer);
$data = 'Test';
$version = QrCodeVersion::fromNumber(2);
$svg = $generator->generateSvg($data, ErrorCorrectionLevel::M, $version);
expect($svg)->toBeString()
->and($svg)->toContain('<svg');
});
test('generateDataUri with explicit version', function () {
$renderer = new QrCodeRenderer();
$generator = new QrCodeGenerator($renderer);
$data = 'Test';
$version = QrCodeVersion::fromNumber(3);
$dataUri = $generator->generateDataUri($data, ErrorCorrectionLevel::M, $version);
expect($dataUri)->toBeString()
->and($dataUri)->toStartWith('data:image/svg+xml;base64,');
});

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
echo "=== Analyzing Attached SVG ===\n\n";
// Read the problematic SVG
$svgPath = 'c:/Users/Mike/AppData/Local/Temp/Untitled.svg';
if (!file_exists($svgPath)) {
echo "❌ SVG file not found at: {$svgPath}\n";
echo "Trying to read from workspace...\n";
// Try to read from a local copy if it exists
$localPath = __DIR__ . '/Untitled.svg';
if (file_exists($localPath)) {
$svgPath = $localPath;
echo "✅ Found local copy\n";
} else {
echo "❌ No local copy found either\n";
exit(1);
}
}
$svg = file_get_contents($svgPath);
echo "SVG file size: " . strlen($svg) . " bytes\n\n";
// Parse SVG structure
if (preg_match('/width="(\d+)" height="(\d+)"/', $svg, $sizeMatches)) {
echo "Canvas size: {$sizeMatches[1]}x{$sizeMatches[2]}\n";
}
// Count rectangles
$rectCount = substr_count($svg, '<rect');
echo "Rectangles: {$rectCount}\n\n";
// Extract all rectangles
preg_match_all('/<rect x="(\d+\.?\d*)" y="(\d+\.?\d*)" width="(\d+\.?\d*)" height="(\d+\.?\d*)" fill="(black|white)"/', $svg, $matches, PREG_SET_ORDER);
echo "First 20 rectangles:\n";
for ($i = 0; $i < min(20, count($matches)); $i++) {
$m = $matches[$i];
echo " {$i}: x={$m[1]}, y={$m[2]}, w={$m[3]}, h={$m[4]}, color={$m[5]}\n";
}
// Check module size consistency
$moduleSizes = [];
foreach ($matches as $m) {
if ($m[5] === 'black') {
$w = (float)$m[3];
$h = (float)$m[4];
$key = "{$w}x{$h}";
$moduleSizes[$key] = ($moduleSizes[$key] ?? 0) + 1;
}
}
echo "\nModule size distribution (black rectangles):\n";
foreach ($moduleSizes as $size => $count) {
echo " {$size}: {$count} rectangles\n";
}
// Check for white background
$whiteRects = array_filter($matches, fn($m) => $m[5] === 'white');
if (count($whiteRects) > 0) {
$firstWhite = $whiteRects[array_key_first($whiteRects)];
echo "\nWhite background:\n";
echo " Size: {$firstWhite[3]}x{$firstWhite[4]}\n";
echo " Position: ({$firstWhite[1]}, {$firstWhite[2]})\n";
}
// Check if coordinates are consistent
echo "\n=== Coordinate Analysis ===\n";
$xs = [];
$ys = [];
foreach ($matches as $m) {
if ($m[5] === 'black') {
$xs[(float)$m[1]] = true;
$ys[(float)$m[2]] = true;
}
}
sort($xs);
sort($ys);
echo "Unique X coordinates: " . count($xs) . "\n";
echo "Unique Y coordinates: " . count($ys) . "\n";
// Check if coordinates form a grid
$xsArray = array_keys($xs);
$ysArray = array_keys($ys);
if (count($xsArray) > 1) {
$xStep = $xsArray[1] - $xsArray[0];
echo "X step: {$xStep}\n";
// Check if all X coordinates are multiples of the step
$xErrors = 0;
foreach ($xsArray as $x) {
$remainder = fmod($x, $xStep);
if (abs($remainder) > 0.01) {
$xErrors++;
}
}
if ($xErrors === 0) {
echo "✅ X coordinates form a regular grid\n";
} else {
echo "{$xErrors} X coordinates don't align with grid\n";
}
}
if (count($ysArray) > 1) {
$yStep = $ysArray[1] - $ysArray[0];
echo "Y step: {$yStep}\n";
$yErrors = 0;
foreach ($ysArray as $y) {
$remainder = fmod($y, $yStep);
if (abs($remainder) > 0.01) {
$yErrors++;
}
}
if ($yErrors === 0) {
echo "✅ Y coordinates form a regular grid\n";
} else {
echo "{$yErrors} Y coordinates don't align with grid\n";
}
}
// Check for potential issues
echo "\n=== Potential Issues ===\n";
// Check if all black rectangles have same size
$blackSizes = [];
foreach ($matches as $m) {
if ($m[5] === 'black') {
$key = "{$m[3]}x{$m[4]}";
$blackSizes[$key] = ($blackSizes[$key] ?? 0) + 1;
}
}
if (count($blackSizes) === 1) {
echo "✅ All black modules have same size\n";
} else {
echo "❌ Black modules have different sizes!\n";
foreach ($blackSizes as $size => $count) {
echo " {$size}: {$count} rectangles\n";
}
}
// Check quiet zone
$minX = min(array_map(fn($m) => (float)$m[1], array_filter($matches, fn($m) => $m[5] === 'black')));
$minY = min(array_map(fn($m) => (float)$m[2], array_filter($matches, fn($m) => $m[5] === 'black')));
echo "\nFirst black module position: ({$minX}, {$minY})\n";
if ($minX > 0 && $minY > 0) {
echo "✅ Quiet zone present (minimum {$minX}px)\n";
} else {
echo "❌ No quiet zone! QR code starts at edge\n";
}

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