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:
@@ -179,6 +179,141 @@ sudo ufw allow 51820/udp comment 'WireGuard VPN'
|
||||
sudo iptables -A INPUT -p udp --dport 51820 -j ACCEPT
|
||||
```
|
||||
|
||||
## Split-Tunnel Routing & NAT Fix
|
||||
|
||||
### A. Quick Fix Commands (manuell auf dem Server)
|
||||
```bash
|
||||
WAN_IF=${WAN_IF:-eth0}
|
||||
WG_IF=${WG_IF:-wg0}
|
||||
WG_NET=${WG_NET:-10.8.0.0/24}
|
||||
WG_PORT=${WG_PORT:-51820}
|
||||
EXTRA_NETS=${EXTRA_NETS:-"192.168.178.0/24 172.20.0.0/16"}
|
||||
|
||||
sudo sysctl -w net.ipv4.ip_forward=1
|
||||
sudo tee /etc/sysctl.d/99-${WG_IF}-forward.conf >/dev/null <<'EOF'
|
||||
# WireGuard Forwarding
|
||||
net.ipv4.ip_forward=1
|
||||
EOF
|
||||
sudo sysctl --system
|
||||
|
||||
# iptables Variante
|
||||
sudo iptables -t nat -C POSTROUTING -s ${WG_NET} -o ${WAN_IF} -j MASQUERADE 2>/dev/null \
|
||||
|| sudo iptables -t nat -A POSTROUTING -s ${WG_NET} -o ${WAN_IF} -j MASQUERADE
|
||||
sudo iptables -C FORWARD -i ${WG_IF} -s ${WG_NET} -o ${WAN_IF} -j ACCEPT 2>/dev/null \
|
||||
|| sudo iptables -A FORWARD -i ${WG_IF} -s ${WG_NET} -o ${WAN_IF} -j ACCEPT
|
||||
sudo iptables -C FORWARD -o ${WG_IF} -d ${WG_NET} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null \
|
||||
|| sudo iptables -A FORWARD -o ${WG_IF} -d ${WG_NET} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
for NET in ${EXTRA_NETS}; do
|
||||
sudo iptables -C FORWARD -i ${WG_IF} -d ${NET} -j ACCEPT 2>/dev/null || sudo iptables -A FORWARD -i ${WG_IF} -d ${NET} -j ACCEPT
|
||||
done
|
||||
|
||||
# nftables Variante
|
||||
sudo nft list table inet wireguard_${WG_IF} >/dev/null 2>&1 || sudo nft add table inet wireguard_${WG_IF}
|
||||
sudo nft list chain inet wireguard_${WG_IF} postrouting >/dev/null 2>&1 \
|
||||
|| sudo nft add chain inet wireguard_${WG_IF} postrouting '{ type nat hook postrouting priority srcnat; }'
|
||||
sudo nft list chain inet wireguard_${WG_IF} forward >/dev/null 2>&1 \
|
||||
|| sudo nft add chain inet wireguard_${WG_IF} forward '{ type filter hook forward priority filter; policy accept; }'
|
||||
sudo nft list chain inet wireguard_${WG_IF} postrouting | grep -q "${WAN_IF}" \
|
||||
|| sudo nft add rule inet wireguard_${WG_IF} postrouting oifname "${WAN_IF}" ip saddr ${WG_NET} masquerade
|
||||
sudo nft list chain inet wireguard_${WG_IF} forward | grep -q "iifname \"${WG_IF}\"" \
|
||||
|| sudo nft add rule inet wireguard_${WG_IF} forward iifname "${WG_IF}" ip saddr ${WG_NET} counter accept
|
||||
sudo nft list chain inet wireguard_${WG_IF} forward | grep -q "oifname \"${WG_IF}\"" \
|
||||
|| sudo nft add rule inet wireguard_${WG_IF} forward oifname "${WG_IF}" ip daddr ${WG_NET} ct state established,related counter accept
|
||||
for NET in ${EXTRA_NETS}; do
|
||||
sudo nft list chain inet wireguard_${WG_IF} forward | grep -q "${NET}" \
|
||||
|| sudo nft add rule inet wireguard_${WG_IF} forward iifname "${WG_IF}" ip daddr ${NET} counter accept
|
||||
done
|
||||
|
||||
# Firewall Hooks
|
||||
if command -v ufw >/dev/null && sudo ufw status | grep -iq "Status: active"; then
|
||||
sudo sed -i 's/^DEFAULT_FORWARD_POLICY=.*/DEFAULT_FORWARD_POLICY="ACCEPT"/' /etc/default/ufw
|
||||
sudo ufw allow ${WG_PORT}/udp
|
||||
sudo ufw route allow in on ${WG_IF} out on ${WAN_IF} to any
|
||||
fi
|
||||
if command -v firewall-cmd >/dev/null && sudo firewall-cmd --state >/dev/null 2>&1; then
|
||||
sudo firewall-cmd --permanent --zone=${FIREWALLD_ZONE:-public} --add-port=${WG_PORT}/udp
|
||||
sudo firewall-cmd --permanent --zone=${FIREWALLD_ZONE:-public} --add-masquerade
|
||||
sudo firewall-cmd --reload
|
||||
fi
|
||||
|
||||
sudo systemctl enable --now wg-quick@${WG_IF}
|
||||
sudo wg show
|
||||
```
|
||||
|
||||
### B. Skript: `deployment/ansible/scripts/setup-wireguard-routing.sh`
|
||||
```bash
|
||||
cd deployment/ansible
|
||||
sudo WAN_IF=eth0 WG_IF=wg0 WG_NET=10.8.0.0/24 EXTRA_NETS="192.168.178.0/24 172.20.0.0/16" \
|
||||
./scripts/setup-wireguard-routing.sh
|
||||
```
|
||||
*Erkennt automatisch iptables/nftables und konfiguriert optional UFW/Firewalld.*
|
||||
|
||||
### C. Ansible Playbook: `playbooks/wireguard-routing.yml`
|
||||
```bash
|
||||
cd deployment/ansible
|
||||
ansible-playbook -i inventory/production.yml playbooks/wireguard-routing.yml \
|
||||
-e "wg_interface=wg0 wg_addr=10.8.0.1/24 wg_net=10.8.0.0/24 wan_interface=eth0" \
|
||||
-e '{"extra_nets":["192.168.178.0/24","172.20.0.0/16"],"firewall_backend":"iptables","manage_ufw":true}'
|
||||
```
|
||||
*Variablen:* `wg_interface`, `wg_addr`, `wg_net`, `wan_interface`, `extra_nets`, `firewall_backend` (`iptables|nftables`), `manage_ufw`, `manage_firewalld`, `firewalld_zone`.
|
||||
|
||||
### D. Beispiel `wg0.conf` Ausschnitt
|
||||
```ini
|
||||
[Interface]
|
||||
Address = 10.8.0.1/24
|
||||
ListenPort = 51820
|
||||
PrivateKey = <ServerPrivateKey>
|
||||
|
||||
# iptables
|
||||
PostUp = iptables -t nat -C POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE
|
||||
PostUp = iptables -C FORWARD -i wg0 -s 10.8.0.0/24 -j ACCEPT 2>/dev/null || iptables -A FORWARD -i wg0 -s 10.8.0.0/24 -j ACCEPT
|
||||
PostUp = iptables -C FORWARD -o wg0 -d 10.8.0.0/24 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || iptables -A FORWARD -o wg0 -d 10.8.0.0/24 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
PostDown = iptables -t nat -D POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE 2>/dev/null || true
|
||||
PostDown = iptables -D FORWARD -i wg0 -s 10.8.0.0/24 -j ACCEPT 2>/dev/null || true
|
||||
PostDown = iptables -D FORWARD -o wg0 -d 10.8.0.0/24 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
|
||||
|
||||
# nftables (stattdessen)
|
||||
# PostUp = nft -f /etc/nftables.d/wireguard-wg0.nft
|
||||
# PostDown = nft delete table inet wireguard_wg0 2>/dev/null || true
|
||||
|
||||
[Peer]
|
||||
PublicKey = <ClientPublicKey>
|
||||
AllowedIPs = 10.8.0.5/32, 192.168.178.0/24, 172.20.0.0/16
|
||||
PersistentKeepalive = 25
|
||||
```
|
||||
|
||||
### E. Windows Client (AllowedIPs & Tests)
|
||||
```ini
|
||||
[Interface]
|
||||
Address = 10.8.0.5/32
|
||||
DNS = 10.8.0.1 # optional
|
||||
|
||||
[Peer]
|
||||
PublicKey = <ServerPublicKey>
|
||||
Endpoint = vpn.example.com:51820
|
||||
AllowedIPs = 10.8.0.0/24, 192.168.178.0/24, 172.20.0.0/16
|
||||
PersistentKeepalive = 25
|
||||
```
|
||||
PowerShell:
|
||||
```powershell
|
||||
wg show
|
||||
Test-Connection -Source 10.8.0.5 -ComputerName 10.8.0.1
|
||||
Test-Connection 192.168.178.1
|
||||
Test-NetConnection -ComputerName 192.168.178.10 -Port 22
|
||||
```
|
||||
Optional: `Set-DnsClientNrptRule -Namespace "internal.lan" -NameServers 10.8.0.1`.
|
||||
|
||||
### F. Troubleshooting & Rollback
|
||||
- Checks: `ip r`, `ip route get <target>`, `iptables -t nat -S`, `nft list ruleset`, `sysctl net.ipv4.ip_forward`, `wg show`, `tcpdump -i wg0`, `tcpdump -i eth0 host 10.8.0.5`.
|
||||
- Häufige Fehler: falsches WAN-Interface, Forwarding/NAT fehlt, doppelte Firewalls (iptables + nftables), Docker-NAT kollidiert, Policy-Routing aktiv.
|
||||
- Rollback:
|
||||
- `sudo rm /etc/sysctl.d/99-wg0-forward.conf && sudo sysctl -w net.ipv4.ip_forward=0`
|
||||
- iptables: Regeln mit `iptables -D` entfernen (siehe oben).
|
||||
- nftables: `sudo nft delete table inet wireguard_wg0`.
|
||||
- UFW: `sudo ufw delete allow 51820/udp`, Route-Regeln entfernen, `DEFAULT_FORWARD_POLICY` zurücksetzen.
|
||||
- Firewalld: `firewall-cmd --permanent --remove-port=51820/udp`, `--remove-masquerade`, `--reload`.
|
||||
- Dienst: `sudo systemctl disable --now wg-quick@wg0`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### WireGuard startet nicht
|
||||
|
||||
229
deployment/ansible/playbooks/generate-wireguard-client.yml
Normal file
229
deployment/ansible/playbooks/generate-wireguard-client.yml
Normal 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 }}"
|
||||
309
deployment/ansible/playbooks/setup-wireguard-host.yml
Normal file
309
deployment/ansible/playbooks/setup-wireguard-host.yml
Normal 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
|
||||
212
deployment/ansible/playbooks/wireguard-routing.yml
Normal file
212
deployment/ansible/playbooks/wireguard-routing.yml
Normal 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
|
||||
156
deployment/ansible/scripts/setup-wireguard-routing.sh
Executable file
156
deployment/ansible/scripts/setup-wireguard-routing.sh
Executable 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 "$@"
|
||||
50
deployment/ansible/templates/wg0.conf.j2
Normal file
50
deployment/ansible/templates/wg0.conf.j2
Normal 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 %}
|
||||
116
deployment/ansible/templates/wireguard-host-firewall.nft.j2
Normal file
116
deployment/ansible/templates/wireguard-host-firewall.nft.j2
Normal 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 %}
|
||||
15
deployment/ansible/templates/wireguard-nftables.nft.j2
Normal file
15
deployment/ansible/templates/wireguard-nftables.nft.j2
Normal 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 %}
|
||||
}
|
||||
}
|
||||
14
deployment/ansible/wireguard/configs/michael-pc.conf
Normal file
14
deployment/ansible/wireguard/configs/michael-pc.conf
Normal 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
|
||||
206
deployment/scripts/cleanup-old-wireguard.sh
Executable file
206
deployment/scripts/cleanup-old-wireguard.sh
Executable 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 ""
|
||||
282
deployment/scripts/generate-client-config.sh
Executable file
282
deployment/scripts/generate-client-config.sh
Executable 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 "$@"
|
||||
307
deployment/scripts/manual-wireguard-setup.sh
Executable file
307
deployment/scripts/manual-wireguard-setup.sh
Executable 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 ""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
- "traefik.http.routers.prometheus.entrypoints=websecure"
|
||||
- "traefik.http.routers.prometheus.tls=true"
|
||||
- "traefik.http.routers.prometheus.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.prometheus.middlewares=prometheus-auth"
|
||||
- "traefik.http.routers.prometheus.middlewares=prometheus-auth@docker"
|
||||
- "traefik.http.middlewares.prometheus-auth.basicauth.users=${PROMETHEUS_AUTH}"
|
||||
- "traefik.http.services.prometheus.loadbalancer.server.port=9090"
|
||||
healthcheck:
|
||||
@@ -75,9 +75,6 @@ services:
|
||||
- "traefik.http.routers.grafana.entrypoints=websecure"
|
||||
- "traefik.http.routers.grafana.tls=true"
|
||||
- "traefik.http.routers.grafana.tls.certresolver=letsencrypt"
|
||||
# VPN IP whitelist: Use middleware defined in Traefik dynamic config
|
||||
# Middleware is defined in deployment/stacks/traefik/dynamic/middlewares.yml
|
||||
- "traefik.http.routers.grafana.middlewares=grafana-vpn-only@file"
|
||||
- "traefik.http.services.grafana.loadbalancer.server.port=3000"
|
||||
depends_on:
|
||||
prometheus:
|
||||
|
||||
@@ -52,25 +52,6 @@ http:
|
||||
# - "127.0.0.1/32"
|
||||
# - "10.0.0.0/8"
|
||||
|
||||
# VPN-only IP allowlist for Grafana and other monitoring services
|
||||
# Restrict access strictly to the WireGuard network
|
||||
# Note: ipAllowList checks the real client IP from the connection
|
||||
# When connected via VPN, client IP should be from 10.8.0.0/24
|
||||
# If client IP shows public IP, the traffic is NOT going through VPN
|
||||
# TEMPORARY: Added public IP for testing - REMOVE after fixing VPN routing!
|
||||
grafana-vpn-only:
|
||||
ipAllowList:
|
||||
sourceRange:
|
||||
- "10.8.0.0/24" # WireGuard VPN network (10.8.0.1 = server, 10.8.0.x = clients)
|
||||
- "89.246.96.244/32" # TEMPORARY: Public IP for testing - REMOVE after VPN routing is fixed!
|
||||
|
||||
# VPN-only IP allowlist for general use (Traefik Dashboard, etc.)
|
||||
# Restrict access strictly to the WireGuard network
|
||||
vpn-only:
|
||||
ipAllowList:
|
||||
sourceRange:
|
||||
- "10.8.0.0/24" # WireGuard VPN network
|
||||
|
||||
# Chain multiple middlewares
|
||||
default-chain:
|
||||
chain:
|
||||
|
||||
@@ -64,10 +64,8 @@ providers:
|
||||
|
||||
# Forwarded Headers Configuration
|
||||
# This ensures Traefik correctly identifies the real client IP
|
||||
# Important for VPN access where requests come from WireGuard interface
|
||||
forwardedHeaders:
|
||||
trustedIPs:
|
||||
- "10.8.0.0/24" # WireGuard VPN network
|
||||
- "127.0.0.1/32" # Localhost
|
||||
- "172.17.0.0/16" # Docker bridge network
|
||||
- "172.18.0.0/16" # Docker user-defined networks
|
||||
|
||||
22
deployment/stacks/wireguard/.env.example
Normal file
22
deployment/stacks/wireguard/.env.example
Normal 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
|
||||
49
deployment/stacks/wireguard/docker-compose.yml
Normal file
49
deployment/stacks/wireguard/docker-compose.yml
Normal 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
|
||||
370
deployment/wireguard/CLIENT-IMPORT-GUIDE.md
Normal file
370
deployment/wireguard/CLIENT-IMPORT-GUIDE.md
Normal 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
|
||||
259
deployment/wireguard/INDEX.md
Normal file
259
deployment/wireguard/INDEX.md
Normal 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
|
||||
275
deployment/wireguard/INSTALLATION-LOG.md
Normal file
275
deployment/wireguard/INSTALLATION-LOG.md
Normal 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.
|
||||
194
deployment/wireguard/QUICKSTART.md
Normal file
194
deployment/wireguard/QUICKSTART.md
Normal 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`
|
||||
352
deployment/wireguard/README.md
Normal file
352
deployment/wireguard/README.md
Normal 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
11
deployment/wireguard/configs/.gitignore
vendored
Normal 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
|
||||
47
deployment/wireguard/configs/README.md
Normal file
47
deployment/wireguard/configs/README.md
Normal 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>.*
|
||||
```
|
||||
798
docs/migration/ErrorHandling-to-ExceptionHandling-Strategy.md
Normal file
798
docs/migration/ErrorHandling-to-ExceptionHandling-Strategy.md
Normal 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
BIN
public/qrcode-FINAL.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -17,6 +17,8 @@ final readonly class ShowHome
|
||||
#[Route(path: '/', method: Method::GET, name: WebRoutes::HOME)]
|
||||
public function home(HomeRequest $request, string $test = 'hallo'): ViewResult
|
||||
{
|
||||
throw new \Exception('test');
|
||||
|
||||
// Production deployment trigger - scp from /workspace/repo
|
||||
$model = new HomeViewModel('Hallo Welt!');
|
||||
return new ViewResult(
|
||||
@@ -25,7 +27,9 @@ final readonly class ShowHome
|
||||
title: 'Home',
|
||||
description: 'Hallo Welt!',
|
||||
)(),
|
||||
data: ['name' => 'Michael'],
|
||||
data: [
|
||||
'name' => 'Michael',
|
||||
],
|
||||
model: $model,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -182,6 +182,11 @@ final readonly class ApiGateway
|
||||
connectTimeout: min(3, $timeoutSeconds), // Connect timeout max 3s or total timeout
|
||||
);
|
||||
|
||||
// Add authentication if present
|
||||
if ($request instanceof HasAuth) {
|
||||
$options = $options->with(['auth' => $request->getAuth()]);
|
||||
}
|
||||
|
||||
// Use factory method for JSON requests if payload is present
|
||||
if ($request instanceof HasPayload) {
|
||||
return ClientRequest::json(
|
||||
|
||||
30
src/Framework/ApiGateway/HasAuth.php
Normal file
30
src/Framework/ApiGateway/HasAuth.php
Normal 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;
|
||||
}
|
||||
@@ -33,6 +33,8 @@ enum EnvKey: string
|
||||
case RAPIDMAIL_USERNAME = 'RAPIDMAIL_USERNAME';
|
||||
case RAPIDMAIL_PASSWORD = 'RAPIDMAIL_PASSWORD';
|
||||
case RAPIDMAIL_TEST_MODE = 'RAPIDMAIL_TEST_MODE';
|
||||
case NETCUP_API_KEY = 'NETCUP_API_KEY';
|
||||
case NETCUP_API_PASSWORD = 'NETCUP_API_PASSWORD';
|
||||
|
||||
// OAuth - Spotify
|
||||
case SPOTIFY_CLIENT_ID = 'SPOTIFY_CLIENT_ID';
|
||||
|
||||
@@ -84,6 +84,11 @@ final readonly class InitializerProcessor
|
||||
if ($returnType === null || $returnType === 'void') {
|
||||
$this->container->invoker->invoke($discoveredAttribute->className, $methodName->toString());
|
||||
}
|
||||
// Handle "self" return type: Replace with the declaring class
|
||||
elseif ($returnType === 'self') {
|
||||
$returnType = $discoveredAttribute->className->toString();
|
||||
$dependencyGraph->addInitializer($returnType, $discoveredAttribute->className, $methodName);
|
||||
}
|
||||
// Service-Initializer: Konkreter Return-Type → Zum Dependency-Graph hinzufügen
|
||||
else {
|
||||
$dependencyGraph->addInitializer($returnType, $discoveredAttribute->className, $methodName);
|
||||
|
||||
@@ -10,7 +10,7 @@ use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\ErrorAggregation\Storage\ErrorStorageInterface;
|
||||
use App\Framework\Exception\Core\ErrorSeverity;
|
||||
use App\Framework\Exception\ErrorHandlerContext;
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Queue\Queue;
|
||||
|
||||
@@ -35,18 +35,18 @@ final readonly class ErrorAggregator implements ErrorAggregatorInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a new error from ErrorHandlerContext
|
||||
* Processes a new error using unified exception pattern
|
||||
*/
|
||||
public function processError(ErrorHandlerContext $context): void
|
||||
public function processError(\Throwable $exception, ExceptionContextProvider $contextProvider, bool $isDebug = false): void
|
||||
{
|
||||
try {
|
||||
$errorEvent = ErrorEvent::fromErrorHandlerContext($context, $this->clock);
|
||||
$errorEvent = ErrorEvent::fromException($exception, $contextProvider, $this->clock, $isDebug);
|
||||
$this->processErrorEvent($errorEvent);
|
||||
} catch (\Throwable $e) {
|
||||
// Don't let error aggregation break the application
|
||||
$this->logError("Failed to process error: " . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'context' => $context->toArray(),
|
||||
'original_exception' => $exception,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,43 @@ final readonly class ErrorEvent
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates ErrorEvent from Exception using ExceptionContextProvider (new unified pattern)
|
||||
*/
|
||||
public static function fromException(\Throwable $exception, \App\Framework\ExceptionHandling\Context\ExceptionContextProvider $contextProvider, \App\Framework\DateTime\Clock $clock, bool $isDebug = false): self
|
||||
{
|
||||
// Retrieve context from WeakMap
|
||||
$context = $contextProvider->get($exception);
|
||||
|
||||
// Extract ErrorCode if exception implements the interface
|
||||
$errorCode = self::extractErrorCodeFromException($exception);
|
||||
|
||||
// Extract service name from operation or component
|
||||
$service = self::extractServiceNameFromContext($context);
|
||||
|
||||
// Determine severity
|
||||
$severity = self::determineSeverityFromException($exception, $context, $errorCode);
|
||||
|
||||
return new self(
|
||||
id: new Ulid($clock),
|
||||
service: $service,
|
||||
component: $context?->component ?? 'unknown',
|
||||
operation: $context?->operation ?? 'unknown',
|
||||
errorCode: $errorCode,
|
||||
errorMessage: $exception->getMessage(),
|
||||
severity: $severity,
|
||||
occurredAt: $context?->occurredAt ?? new \DateTimeImmutable(),
|
||||
context: $context?->data ?? [],
|
||||
metadata: $context?->metadata ?? [],
|
||||
requestId: $context?->requestId,
|
||||
userId: $context?->userId,
|
||||
clientIp: $context?->clientIp,
|
||||
isSecurityEvent: $context?->metadata['security_event'] ?? false,
|
||||
stackTrace: $isDebug ? $exception->getTraceAsString() : null,
|
||||
userAgent: $context?->userAgent,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts to array for storage/transmission
|
||||
*/
|
||||
@@ -298,4 +335,74 @@ final readonly class ErrorEvent
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ErrorCode from exception (new unified pattern helper)
|
||||
*/
|
||||
private static function extractErrorCodeFromException(\Throwable $exception): ErrorCode
|
||||
{
|
||||
// Check if exception implements HasErrorCode interface
|
||||
if ($exception instanceof \App\Framework\Exception\FrameworkException) {
|
||||
$errorCode = $exception->getErrorCode();
|
||||
if ($errorCode !== null) {
|
||||
return $errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Use SystemErrorCode::RESOURCE_EXHAUSTED as generic error
|
||||
return \App\Framework\Exception\Core\SystemErrorCode::RESOURCE_EXHAUSTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract service name from ExceptionContextData (new unified pattern helper)
|
||||
*/
|
||||
private static function extractServiceNameFromContext(?\App\Framework\ExceptionHandling\Context\ExceptionContextData $context): string
|
||||
{
|
||||
if ($context === null) {
|
||||
return 'web';
|
||||
}
|
||||
|
||||
// Extract from operation if available (e.g., "user.create" → "user")
|
||||
if ($context->operation !== null && str_contains($context->operation, '.')) {
|
||||
$parts = explode('.', $context->operation);
|
||||
return strtolower($parts[0]);
|
||||
}
|
||||
|
||||
// Extract from component if available
|
||||
if ($context->component !== null) {
|
||||
return strtolower($context->component);
|
||||
}
|
||||
|
||||
return 'web';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine severity from exception, context, and error code (new unified pattern helper)
|
||||
*/
|
||||
private static function determineSeverityFromException(
|
||||
\Throwable $exception,
|
||||
?\App\Framework\ExceptionHandling\Context\ExceptionContextData $context,
|
||||
ErrorCode $errorCode
|
||||
): ErrorSeverity {
|
||||
// Security events are always critical
|
||||
if ($context?->metadata['security_event'] ?? false) {
|
||||
return ErrorSeverity::CRITICAL;
|
||||
}
|
||||
|
||||
// Check explicit severity in metadata
|
||||
if ($context !== null && isset($context->metadata['severity'])) {
|
||||
$severity = ErrorSeverity::tryFrom($context->metadata['severity']);
|
||||
if ($severity !== null) {
|
||||
return $severity;
|
||||
}
|
||||
}
|
||||
|
||||
// Get severity from ErrorCode
|
||||
if (method_exists($errorCode, 'getSeverity')) {
|
||||
return $errorCode->getSeverity();
|
||||
}
|
||||
|
||||
// Fallback: ERROR for all unhandled exceptions
|
||||
return ErrorSeverity::ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Framework\ErrorBoundaries;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\DateTime\Timer;
|
||||
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
|
||||
use App\Framework\ErrorBoundaries\CircuitBreaker\BoundaryCircuitBreakerManager;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryEventInterface;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryEventPublisher;
|
||||
@@ -16,6 +17,7 @@ use App\Framework\ErrorBoundaries\Events\BoundaryFallbackExecuted;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryTimeoutOccurred;
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||
use App\Framework\Logging\Logger;
|
||||
use Throwable;
|
||||
|
||||
@@ -34,6 +36,8 @@ final readonly class ErrorBoundary
|
||||
private ?Logger $logger = null,
|
||||
private ?BoundaryCircuitBreakerManager $circuitBreakerManager = null,
|
||||
private ?BoundaryEventPublisher $eventPublisher = null,
|
||||
private ?ErrorAggregatorInterface $errorAggregator = null,
|
||||
private ?ExceptionContextProvider $contextProvider = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -319,6 +323,29 @@ final readonly class ErrorBoundary
|
||||
{
|
||||
$this->logFailure($exception, 'Operation failed, executing fallback');
|
||||
|
||||
// Dispatch to ErrorAggregator for centralized monitoring
|
||||
if ($this->errorAggregator !== null && $this->contextProvider !== null) {
|
||||
try {
|
||||
// Enrich exception context with boundary metadata
|
||||
$existingContext = $this->contextProvider->get($exception);
|
||||
if ($existingContext !== null) {
|
||||
$enrichedContext = $existingContext->withMetadata([
|
||||
'error_boundary' => $this->boundaryName,
|
||||
'boundary_failure' => true,
|
||||
]);
|
||||
$this->contextProvider->set($exception, $enrichedContext);
|
||||
}
|
||||
|
||||
// Dispatch to aggregator
|
||||
$this->errorAggregator->processError($exception, $this->contextProvider, false);
|
||||
} catch (Throwable $aggregationException) {
|
||||
// Don't let aggregation failures break boundary resilience
|
||||
$this->log('warning', 'Error aggregation failed', [
|
||||
'aggregation_error' => $aggregationException->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $fallback();
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ namespace App\Framework\ErrorBoundaries;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\DateTime\SystemTimer;
|
||||
use App\Framework\DateTime\Timer;
|
||||
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
|
||||
use App\Framework\ErrorBoundaries\CircuitBreaker\BoundaryCircuitBreakerManager;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryEventPublisher;
|
||||
use App\Framework\EventBus\EventBus;
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\StateManagement\StateManagerFactory;
|
||||
|
||||
@@ -25,6 +27,8 @@ final readonly class ErrorBoundaryFactory
|
||||
private ?Logger $logger = null,
|
||||
private ?StateManagerFactory $stateManagerFactory = null,
|
||||
private ?EventBus $eventBus = null,
|
||||
private ?ErrorAggregatorInterface $errorAggregator = null,
|
||||
private ?ExceptionContextProvider $contextProvider = null,
|
||||
array $routeConfigs = []
|
||||
) {
|
||||
$this->routeConfigs = array_merge($this->getDefaultRouteConfigs(), $routeConfigs);
|
||||
@@ -101,6 +105,8 @@ final readonly class ErrorBoundaryFactory
|
||||
logger: $this->logger,
|
||||
circuitBreakerManager: $circuitBreakerManager,
|
||||
eventPublisher: $eventPublisher,
|
||||
errorAggregator: $this->errorAggregator,
|
||||
contextProvider: $this->contextProvider,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -28,7 +28,21 @@ final readonly class ErrorReporter implements ErrorReporterInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an error from Throwable
|
||||
* Report an error from Exception with WeakMap context (unified pattern)
|
||||
*/
|
||||
public function reportException(
|
||||
Throwable $exception,
|
||||
\App\Framework\ExceptionHandling\Context\ExceptionContextProvider $contextProvider,
|
||||
string $level = 'error',
|
||||
array $additionalContext = []
|
||||
): string {
|
||||
$report = ErrorReport::fromException($exception, $contextProvider, $level, $additionalContext);
|
||||
|
||||
return $this->report($report);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an error from Throwable (legacy method)
|
||||
*/
|
||||
public function reportThrowable(
|
||||
Throwable $throwable,
|
||||
|
||||
309
src/Framework/ExceptionHandling/Context/ExceptionContextData.php
Normal file
309
src/Framework/ExceptionHandling/Context/ExceptionContextData.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||
use App\Framework\ExceptionHandling\Renderers\ResponseErrorRenderer;
|
||||
use App\Framework\ExceptionHandling\Reporter\LogReporter;
|
||||
use App\Framework\Http\Response;
|
||||
use Throwable;
|
||||
|
||||
final readonly class ErrorKernel
|
||||
@@ -25,5 +28,28 @@ final readonly class ErrorKernel
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HTTP Response from exception without terminating execution
|
||||
*
|
||||
* This method enables middleware recovery patterns by returning a Response
|
||||
* object instead of terminating the application.
|
||||
*
|
||||
* @param Throwable $exception Exception to render
|
||||
* @param ExceptionContextProvider|null $contextProvider Optional WeakMap context provider
|
||||
* @param bool $isDebugMode Enable debug information in response
|
||||
* @return Response HTTP Response object (JSON for API, HTML for web)
|
||||
*/
|
||||
public function createHttpResponse(
|
||||
Throwable $exception,
|
||||
?ExceptionContextProvider $contextProvider = null,
|
||||
bool $isDebugMode = false
|
||||
): Response {
|
||||
// Create ResponseErrorRenderer with debug mode setting
|
||||
$renderer = new ResponseErrorRenderer($isDebugMode);
|
||||
|
||||
// Generate and return Response object
|
||||
return $renderer->createResponse($exception, $contextProvider);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
200
src/Framework/ExceptionHandling/Factory/ExceptionFactory.php
Normal file
200
src/Framework/ExceptionHandling/Factory/ExceptionFactory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
135
src/Framework/ExceptionHandling/Scope/ErrorScope.php
Normal file
135
src/Framework/ExceptionHandling/Scope/ErrorScope.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
276
src/Framework/ExceptionHandling/Scope/ErrorScopeContext.php
Normal file
276
src/Framework/ExceptionHandling/Scope/ErrorScopeContext.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
55
src/Framework/ExceptionHandling/Scope/ErrorScopeType.php
Normal file
55
src/Framework/ExceptionHandling/Scope/ErrorScopeType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
213
src/Framework/Process/Console/AlertCommands.php
Normal file
213
src/Framework/Process/Console/AlertCommands.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
325
src/Framework/Process/Console/BackupCommands.php
Normal file
325
src/Framework/Process/Console/BackupCommands.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
233
src/Framework/Process/Console/HealthCommands.php
Normal file
233
src/Framework/Process/Console/HealthCommands.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
291
src/Framework/Process/Console/LogCommands.php
Normal file
291
src/Framework/Process/Console/LogCommands.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
211
src/Framework/Process/Console/MaintenanceCommands.php
Normal file
211
src/Framework/Process/Console/MaintenanceCommands.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
228
src/Framework/Process/Console/NetworkCommands.php
Normal file
228
src/Framework/Process/Console/NetworkCommands.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
298
src/Framework/Process/Console/ProcessCommands.php
Normal file
298
src/Framework/Process/Console/ProcessCommands.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
328
src/Framework/Process/Console/SslCommands.php
Normal file
328
src/Framework/Process/Console/SslCommands.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
214
src/Framework/Process/Console/SystemCommands.php
Normal file
214
src/Framework/Process/Console/SystemCommands.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
228
src/Framework/Process/Console/SystemdCommands.php
Normal file
228
src/Framework/Process/Console/SystemdCommands.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
150
src/Framework/Process/Services/AlertService.php
Normal file
150
src/Framework/Process/Services/AlertService.php
Normal 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'
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
82
src/Framework/Process/Services/BackupService.php
Normal file
82
src/Framework/Process/Services/BackupService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
180
src/Framework/Process/Services/MaintenanceService.php
Normal file
180
src/Framework/Process/Services/MaintenanceService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
189
src/Framework/Process/Services/ProcessMonitoringService.php
Normal file
189
src/Framework/Process/Services/ProcessMonitoringService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
250
src/Framework/Process/Services/SslCertificateService.php
Normal file
250
src/Framework/Process/Services/SslCertificateService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
219
src/Framework/Process/Services/SystemdService.php
Normal file
219
src/Framework/Process/Services/SystemdService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
101
src/Framework/Process/ValueObjects/Alert/Alert.php
Normal file
101
src/Framework/Process/ValueObjects/Alert/Alert.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
128
src/Framework/Process/ValueObjects/Alert/AlertReport.php
Normal file
128
src/Framework/Process/ValueObjects/Alert/AlertReport.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
41
src/Framework/Process/ValueObjects/Alert/AlertSeverity.php
Normal file
41
src/Framework/Process/ValueObjects/Alert/AlertSeverity.php
Normal 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 => '❌',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
70
src/Framework/Process/ValueObjects/Alert/AlertThreshold.php
Normal file
70
src/Framework/Process/ValueObjects/Alert/AlertThreshold.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,11 @@ namespace App\Framework\QrCode\ErrorCorrection;
|
||||
final class ReedSolomonEncoder
|
||||
{
|
||||
// Generator polynomial coefficients for different EC codeword counts
|
||||
// Format: [ecCodewords => [coefficients...]]
|
||||
// Note: The first coefficient is always 0 (leading term)
|
||||
private const GENERATOR_POLYNOMIALS = [
|
||||
7 => [0, 87, 229, 146, 149, 238, 102, 21], // EC Level M, Version 1
|
||||
10 => [0, 251, 67, 46, 61, 118, 70, 64, 94, 32, 45],
|
||||
7 => [0, 87, 229, 146, 149, 238, 102, 21], // 7 EC codewords
|
||||
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],
|
||||
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],
|
||||
@@ -73,15 +75,30 @@ final class ReedSolomonEncoder
|
||||
$generator = $this->getGeneratorPolynomial($ecCodewords);
|
||||
|
||||
// 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));
|
||||
|
||||
// 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++) {
|
||||
$coefficient = $messagePolynomial[$i];
|
||||
|
||||
if ($coefficient !== 0) {
|
||||
for ($j = 0; $j < count($generator); $j++) {
|
||||
$messagePolynomial[$i + $j] ^= $this->gfMultiply($generator[$j], $coefficient);
|
||||
// Leading coefficient is implicitly 1 (monic polynomial)
|
||||
// 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))
|
||||
*
|
||||
* Returns monic polynomial [1, a1, a2, ..., an] where leading coefficient is 1
|
||||
*/
|
||||
private function generateGeneratorPolynomial(int $degree): array
|
||||
{
|
||||
@@ -113,6 +132,7 @@ final class ReedSolomonEncoder
|
||||
$polynomial = [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++) {
|
||||
$polynomial = $this->multiplyPolynomials(
|
||||
$polynomial,
|
||||
|
||||
@@ -24,10 +24,14 @@ use App\Framework\QrCode\ValueObjects\QrCodeMatrix;
|
||||
* Phase 2: Full Reed-Solomon error correction with mask pattern selection
|
||||
* 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
|
||||
{
|
||||
@@ -68,16 +72,18 @@ final class QrCodeGenerator
|
||||
);
|
||||
}
|
||||
|
||||
// Generate matrix
|
||||
$matrix = self::generateMatrix($data, $config);
|
||||
// Generate matrix using temporary instance
|
||||
$temporaryRenderer = new QrCodeRenderer();
|
||||
$temporaryGenerator = new self($temporaryRenderer);
|
||||
$matrix = $temporaryGenerator->generateMatrix($data, $config);
|
||||
|
||||
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
|
||||
$matrix = QrCodeMatrix::create($config->version);
|
||||
@@ -97,7 +103,7 @@ final class QrCodeGenerator
|
||||
$matrix = $matrix->setModuleAt($darkModuleRow, 8, Module::dark());
|
||||
|
||||
// 6. Encode data into codewords
|
||||
$dataCodewords = self::encodeData($data, $config);
|
||||
$dataCodewords = $this->encodeData($data, $config);
|
||||
|
||||
// 7. Generate error correction codewords using Reed-Solomon
|
||||
$reedSolomon = new ReedSolomonEncoder();
|
||||
@@ -109,7 +115,7 @@ final class QrCodeGenerator
|
||||
|
||||
// 8. Place data and EC codewords in matrix
|
||||
$allCodewords = array_merge($dataCodewords, $ecCodewords);
|
||||
$matrix = self::placeDataCodewords($matrix, $allCodewords);
|
||||
$matrix = $this->placeDataCodewords($matrix, $allCodewords);
|
||||
|
||||
// 9. Select best mask pattern (evaluates all 8 patterns)
|
||||
$maskEvaluator = new MaskEvaluator();
|
||||
@@ -132,7 +138,7 @@ final class QrCodeGenerator
|
||||
/**
|
||||
* 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 = [];
|
||||
$bits = '';
|
||||
@@ -186,14 +192,16 @@ final class QrCodeGenerator
|
||||
/**
|
||||
* 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();
|
||||
$bitIndex = 0;
|
||||
|
||||
// Convert codewords to bit string
|
||||
// ISO/IEC 18004: Bits are placed MSB-first (most significant bit first)
|
||||
$bits = '';
|
||||
foreach ($codewords as $codeword) {
|
||||
// Convert byte to 8-bit binary string (MSB-first)
|
||||
$bits .= str_pad(decbin($codeword), 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
$totalBits = strlen($bits);
|
||||
@@ -212,12 +220,14 @@ final class QrCodeGenerator
|
||||
$row = $upward ? ($size - 1 - $i) : $i;
|
||||
|
||||
// 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++) {
|
||||
$currentCol = $col - $c;
|
||||
$position = ModulePosition::at($row, $currentCol);
|
||||
|
||||
// Skip if position is already occupied (function patterns)
|
||||
if (self::isOccupied($matrix, $position)) {
|
||||
if ($this->isOccupied($matrix, $position)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -241,7 +251,7 @@ final class QrCodeGenerator
|
||||
/**
|
||||
* 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();
|
||||
$row = $position->row;
|
||||
@@ -320,4 +330,98 @@ final class QrCodeGenerator
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
33
src/Framework/QrCode/QrCodeInitializer.php
Normal file
33
src/Framework/QrCode/QrCodeInitializer.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -152,4 +152,14 @@ final readonly class QrCodeVersion
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
116
src/Infrastructure/Api/Netcup/DnsService.php
Normal file
116
src/Infrastructure/Api/Netcup/DnsService.php
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
161
src/Infrastructure/Api/Netcup/NetcupApiClient.php
Normal file
161
src/Infrastructure/Api/Netcup/NetcupApiClient.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
25
src/Infrastructure/Api/Netcup/NetcupClient.php
Normal file
25
src/Infrastructure/Api/Netcup/NetcupClient.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
37
src/Infrastructure/Api/Netcup/NetcupClientInitializer.php
Normal file
37
src/Infrastructure/Api/Netcup/NetcupClientInitializer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
17
src/Infrastructure/Api/Netcup/NetcupConfig.php
Normal file
17
src/Infrastructure/Api/Netcup/NetcupConfig.php
Normal 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
387
src/Infrastructure/Api/Netcup/README.md
Normal file
387
src/Infrastructure/Api/Netcup/README.md
Normal 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.
|
||||
|
||||
145
src/Infrastructure/Api/Netcup/ServerService.php
Normal file
145
src/Infrastructure/Api/Netcup/ServerService.php
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
132
src/Infrastructure/Api/Netcup/ValueObjects/DnsRecord.php
Normal file
132
src/Infrastructure/Api/Netcup/ValueObjects/DnsRecord.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
47
src/Infrastructure/Api/Netcup/ValueObjects/DnsRecordType.php
Normal file
47
src/Infrastructure/Api/Netcup/ValueObjects/DnsRecordType.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ declare(strict_types=1);
|
||||
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
||||
|
||||
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\Retry\ExponentialBackoffStrategy;
|
||||
use App\Framework\Retry\RetryStrategy;
|
||||
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||
@@ -31,7 +33,7 @@ use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||
*
|
||||
* $response = $apiGateway->send($request);
|
||||
*/
|
||||
final readonly class CreateRecipientApiRequest implements ApiRequest, HasPayload
|
||||
final readonly class CreateRecipientApiRequest implements ApiRequest, HasPayload, HasAuth
|
||||
{
|
||||
public function __construct(
|
||||
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
|
||||
{
|
||||
// Basic Auth
|
||||
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
|
||||
|
||||
return new Headers([
|
||||
'Authorization' => "Basic {$credentials}",
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
|
||||
@@ -5,11 +5,13 @@ declare(strict_types=1);
|
||||
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
||||
|
||||
use App\Framework\ApiGateway\ApiRequest;
|
||||
use App\Framework\ApiGateway\HasAuth;
|
||||
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\Retry\ExponentialBackoffStrategy;
|
||||
use App\Framework\Retry\RetryStrategy;
|
||||
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||
@@ -26,7 +28,7 @@ use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||
*
|
||||
* $response = $apiGateway->send($request);
|
||||
*/
|
||||
final readonly class DeleteRecipientApiRequest implements ApiRequest
|
||||
final readonly class DeleteRecipientApiRequest implements ApiRequest, HasAuth
|
||||
{
|
||||
public function __construct(
|
||||
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
|
||||
{
|
||||
// Basic Auth
|
||||
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
|
||||
|
||||
return new Headers([
|
||||
'Authorization' => "Basic {$credentials}",
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ declare(strict_types=1);
|
||||
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
||||
|
||||
use App\Framework\ApiGateway\ApiRequest;
|
||||
use App\Framework\ApiGateway\HasAuth;
|
||||
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\Retry\ExponentialBackoffStrategy;
|
||||
use App\Framework\Retry\RetryStrategy;
|
||||
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||
@@ -26,7 +28,7 @@ use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||
*
|
||||
* $response = $apiGateway->send($request);
|
||||
*/
|
||||
final readonly class GetRecipientApiRequest implements ApiRequest
|
||||
final readonly class GetRecipientApiRequest implements ApiRequest, HasAuth
|
||||
{
|
||||
public function __construct(
|
||||
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
|
||||
{
|
||||
// Basic Auth
|
||||
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
|
||||
|
||||
return new Headers([
|
||||
'Authorization' => "Basic {$credentials}",
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ declare(strict_types=1);
|
||||
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
||||
|
||||
use App\Framework\ApiGateway\ApiRequest;
|
||||
use App\Framework\ApiGateway\HasAuth;
|
||||
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\Retry\ExponentialBackoffStrategy;
|
||||
use App\Framework\Retry\RetryStrategy;
|
||||
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||
@@ -28,7 +30,7 @@ use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||
*
|
||||
* $response = $apiGateway->send($request);
|
||||
*/
|
||||
final readonly class SearchRecipientsApiRequest implements ApiRequest
|
||||
final readonly class SearchRecipientsApiRequest implements ApiRequest, HasAuth
|
||||
{
|
||||
public function __construct(
|
||||
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
|
||||
{
|
||||
// Basic Auth
|
||||
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
|
||||
|
||||
return new Headers([
|
||||
'Authorization' => "Basic {$credentials}",
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ declare(strict_types=1);
|
||||
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
||||
|
||||
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\Retry\ExponentialBackoffStrategy;
|
||||
use App\Framework\Retry\RetryStrategy;
|
||||
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||
@@ -31,7 +33,7 @@ use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||
*
|
||||
* $response = $apiGateway->send($request);
|
||||
*/
|
||||
final readonly class UpdateRecipientApiRequest implements ApiRequest, HasPayload
|
||||
final readonly class UpdateRecipientApiRequest implements ApiRequest, HasPayload, HasAuth
|
||||
{
|
||||
public function __construct(
|
||||
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
|
||||
{
|
||||
// Basic Auth
|
||||
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
|
||||
|
||||
return new Headers([
|
||||
'Authorization' => "Basic {$credentials}",
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
|
||||
@@ -5,11 +5,14 @@ declare(strict_types=1);
|
||||
namespace App\Infrastructure\Api\Shopify;
|
||||
|
||||
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\Retry\RetryStrategy;
|
||||
use App\Infrastructure\Api\Shopify\ValueObjects\{ShopifyApiKey, ShopifyStore};
|
||||
|
||||
@@ -31,7 +34,7 @@ use App\Infrastructure\Api\Shopify\ValueObjects\{ShopifyApiKey, ShopifyStore};
|
||||
*
|
||||
* $response = $apiGateway->send($request);
|
||||
*/
|
||||
final readonly class CreateOrderApiRequest implements ApiRequest
|
||||
final readonly class CreateOrderApiRequest implements ApiRequest, HasPayload, HasAuth
|
||||
{
|
||||
public function __construct(
|
||||
private ShopifyApiKey $apiKey,
|
||||
@@ -73,10 +76,16 @@ final readonly class CreateOrderApiRequest implements ApiRequest
|
||||
return $this->retryStrategy;
|
||||
}
|
||||
|
||||
public function getAuth(): AuthConfig
|
||||
{
|
||||
return AuthConfig::custom([
|
||||
'X-Shopify-Access-Token' => $this->apiKey->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getHeaders(): Headers
|
||||
{
|
||||
return new Headers([
|
||||
'X-Shopify-Access-Token' => $this->apiKey->value,
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
|
||||
634
tests/Framework/ApiGateway/ApiGatewayTest.php
Normal file
634
tests/Framework/ApiGateway/ApiGatewayTest.php
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\QrCode\QrCodeGenerator;
|
||||
use App\Framework\QrCode\QrCodeRenderer;
|
||||
use App\Framework\QrCode\ValueObjects\EncodingMode;
|
||||
use App\Framework\QrCode\ValueObjects\ErrorCorrectionLevel;
|
||||
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($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,');
|
||||
});
|
||||
|
||||
164
tests/debug/analyze-attached-svg.php
Normal file
164
tests/debug/analyze-attached-svg.php
Normal 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
Reference in New Issue
Block a user