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
|
sudo iptables -A INPUT -p udp --dport 51820 -j ACCEPT
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Split-Tunnel Routing & NAT Fix
|
||||||
|
|
||||||
|
### A. Quick Fix Commands (manuell auf dem Server)
|
||||||
|
```bash
|
||||||
|
WAN_IF=${WAN_IF:-eth0}
|
||||||
|
WG_IF=${WG_IF:-wg0}
|
||||||
|
WG_NET=${WG_NET:-10.8.0.0/24}
|
||||||
|
WG_PORT=${WG_PORT:-51820}
|
||||||
|
EXTRA_NETS=${EXTRA_NETS:-"192.168.178.0/24 172.20.0.0/16"}
|
||||||
|
|
||||||
|
sudo sysctl -w net.ipv4.ip_forward=1
|
||||||
|
sudo tee /etc/sysctl.d/99-${WG_IF}-forward.conf >/dev/null <<'EOF'
|
||||||
|
# WireGuard Forwarding
|
||||||
|
net.ipv4.ip_forward=1
|
||||||
|
EOF
|
||||||
|
sudo sysctl --system
|
||||||
|
|
||||||
|
# iptables Variante
|
||||||
|
sudo iptables -t nat -C POSTROUTING -s ${WG_NET} -o ${WAN_IF} -j MASQUERADE 2>/dev/null \
|
||||||
|
|| sudo iptables -t nat -A POSTROUTING -s ${WG_NET} -o ${WAN_IF} -j MASQUERADE
|
||||||
|
sudo iptables -C FORWARD -i ${WG_IF} -s ${WG_NET} -o ${WAN_IF} -j ACCEPT 2>/dev/null \
|
||||||
|
|| sudo iptables -A FORWARD -i ${WG_IF} -s ${WG_NET} -o ${WAN_IF} -j ACCEPT
|
||||||
|
sudo iptables -C FORWARD -o ${WG_IF} -d ${WG_NET} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null \
|
||||||
|
|| sudo iptables -A FORWARD -o ${WG_IF} -d ${WG_NET} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||||
|
for NET in ${EXTRA_NETS}; do
|
||||||
|
sudo iptables -C FORWARD -i ${WG_IF} -d ${NET} -j ACCEPT 2>/dev/null || sudo iptables -A FORWARD -i ${WG_IF} -d ${NET} -j ACCEPT
|
||||||
|
done
|
||||||
|
|
||||||
|
# nftables Variante
|
||||||
|
sudo nft list table inet wireguard_${WG_IF} >/dev/null 2>&1 || sudo nft add table inet wireguard_${WG_IF}
|
||||||
|
sudo nft list chain inet wireguard_${WG_IF} postrouting >/dev/null 2>&1 \
|
||||||
|
|| sudo nft add chain inet wireguard_${WG_IF} postrouting '{ type nat hook postrouting priority srcnat; }'
|
||||||
|
sudo nft list chain inet wireguard_${WG_IF} forward >/dev/null 2>&1 \
|
||||||
|
|| sudo nft add chain inet wireguard_${WG_IF} forward '{ type filter hook forward priority filter; policy accept; }'
|
||||||
|
sudo nft list chain inet wireguard_${WG_IF} postrouting | grep -q "${WAN_IF}" \
|
||||||
|
|| sudo nft add rule inet wireguard_${WG_IF} postrouting oifname "${WAN_IF}" ip saddr ${WG_NET} masquerade
|
||||||
|
sudo nft list chain inet wireguard_${WG_IF} forward | grep -q "iifname \"${WG_IF}\"" \
|
||||||
|
|| sudo nft add rule inet wireguard_${WG_IF} forward iifname "${WG_IF}" ip saddr ${WG_NET} counter accept
|
||||||
|
sudo nft list chain inet wireguard_${WG_IF} forward | grep -q "oifname \"${WG_IF}\"" \
|
||||||
|
|| sudo nft add rule inet wireguard_${WG_IF} forward oifname "${WG_IF}" ip daddr ${WG_NET} ct state established,related counter accept
|
||||||
|
for NET in ${EXTRA_NETS}; do
|
||||||
|
sudo nft list chain inet wireguard_${WG_IF} forward | grep -q "${NET}" \
|
||||||
|
|| sudo nft add rule inet wireguard_${WG_IF} forward iifname "${WG_IF}" ip daddr ${NET} counter accept
|
||||||
|
done
|
||||||
|
|
||||||
|
# Firewall Hooks
|
||||||
|
if command -v ufw >/dev/null && sudo ufw status | grep -iq "Status: active"; then
|
||||||
|
sudo sed -i 's/^DEFAULT_FORWARD_POLICY=.*/DEFAULT_FORWARD_POLICY="ACCEPT"/' /etc/default/ufw
|
||||||
|
sudo ufw allow ${WG_PORT}/udp
|
||||||
|
sudo ufw route allow in on ${WG_IF} out on ${WAN_IF} to any
|
||||||
|
fi
|
||||||
|
if command -v firewall-cmd >/dev/null && sudo firewall-cmd --state >/dev/null 2>&1; then
|
||||||
|
sudo firewall-cmd --permanent --zone=${FIREWALLD_ZONE:-public} --add-port=${WG_PORT}/udp
|
||||||
|
sudo firewall-cmd --permanent --zone=${FIREWALLD_ZONE:-public} --add-masquerade
|
||||||
|
sudo firewall-cmd --reload
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo systemctl enable --now wg-quick@${WG_IF}
|
||||||
|
sudo wg show
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. Skript: `deployment/ansible/scripts/setup-wireguard-routing.sh`
|
||||||
|
```bash
|
||||||
|
cd deployment/ansible
|
||||||
|
sudo WAN_IF=eth0 WG_IF=wg0 WG_NET=10.8.0.0/24 EXTRA_NETS="192.168.178.0/24 172.20.0.0/16" \
|
||||||
|
./scripts/setup-wireguard-routing.sh
|
||||||
|
```
|
||||||
|
*Erkennt automatisch iptables/nftables und konfiguriert optional UFW/Firewalld.*
|
||||||
|
|
||||||
|
### C. Ansible Playbook: `playbooks/wireguard-routing.yml`
|
||||||
|
```bash
|
||||||
|
cd deployment/ansible
|
||||||
|
ansible-playbook -i inventory/production.yml playbooks/wireguard-routing.yml \
|
||||||
|
-e "wg_interface=wg0 wg_addr=10.8.0.1/24 wg_net=10.8.0.0/24 wan_interface=eth0" \
|
||||||
|
-e '{"extra_nets":["192.168.178.0/24","172.20.0.0/16"],"firewall_backend":"iptables","manage_ufw":true}'
|
||||||
|
```
|
||||||
|
*Variablen:* `wg_interface`, `wg_addr`, `wg_net`, `wan_interface`, `extra_nets`, `firewall_backend` (`iptables|nftables`), `manage_ufw`, `manage_firewalld`, `firewalld_zone`.
|
||||||
|
|
||||||
|
### D. Beispiel `wg0.conf` Ausschnitt
|
||||||
|
```ini
|
||||||
|
[Interface]
|
||||||
|
Address = 10.8.0.1/24
|
||||||
|
ListenPort = 51820
|
||||||
|
PrivateKey = <ServerPrivateKey>
|
||||||
|
|
||||||
|
# iptables
|
||||||
|
PostUp = iptables -t nat -C POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE
|
||||||
|
PostUp = iptables -C FORWARD -i wg0 -s 10.8.0.0/24 -j ACCEPT 2>/dev/null || iptables -A FORWARD -i wg0 -s 10.8.0.0/24 -j ACCEPT
|
||||||
|
PostUp = iptables -C FORWARD -o wg0 -d 10.8.0.0/24 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || iptables -A FORWARD -o wg0 -d 10.8.0.0/24 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||||
|
PostDown = iptables -t nat -D POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE 2>/dev/null || true
|
||||||
|
PostDown = iptables -D FORWARD -i wg0 -s 10.8.0.0/24 -j ACCEPT 2>/dev/null || true
|
||||||
|
PostDown = iptables -D FORWARD -o wg0 -d 10.8.0.0/24 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
|
||||||
|
|
||||||
|
# nftables (stattdessen)
|
||||||
|
# PostUp = nft -f /etc/nftables.d/wireguard-wg0.nft
|
||||||
|
# PostDown = nft delete table inet wireguard_wg0 2>/dev/null || true
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = <ClientPublicKey>
|
||||||
|
AllowedIPs = 10.8.0.5/32, 192.168.178.0/24, 172.20.0.0/16
|
||||||
|
PersistentKeepalive = 25
|
||||||
|
```
|
||||||
|
|
||||||
|
### E. Windows Client (AllowedIPs & Tests)
|
||||||
|
```ini
|
||||||
|
[Interface]
|
||||||
|
Address = 10.8.0.5/32
|
||||||
|
DNS = 10.8.0.1 # optional
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = <ServerPublicKey>
|
||||||
|
Endpoint = vpn.example.com:51820
|
||||||
|
AllowedIPs = 10.8.0.0/24, 192.168.178.0/24, 172.20.0.0/16
|
||||||
|
PersistentKeepalive = 25
|
||||||
|
```
|
||||||
|
PowerShell:
|
||||||
|
```powershell
|
||||||
|
wg show
|
||||||
|
Test-Connection -Source 10.8.0.5 -ComputerName 10.8.0.1
|
||||||
|
Test-Connection 192.168.178.1
|
||||||
|
Test-NetConnection -ComputerName 192.168.178.10 -Port 22
|
||||||
|
```
|
||||||
|
Optional: `Set-DnsClientNrptRule -Namespace "internal.lan" -NameServers 10.8.0.1`.
|
||||||
|
|
||||||
|
### F. Troubleshooting & Rollback
|
||||||
|
- Checks: `ip r`, `ip route get <target>`, `iptables -t nat -S`, `nft list ruleset`, `sysctl net.ipv4.ip_forward`, `wg show`, `tcpdump -i wg0`, `tcpdump -i eth0 host 10.8.0.5`.
|
||||||
|
- Häufige Fehler: falsches WAN-Interface, Forwarding/NAT fehlt, doppelte Firewalls (iptables + nftables), Docker-NAT kollidiert, Policy-Routing aktiv.
|
||||||
|
- Rollback:
|
||||||
|
- `sudo rm /etc/sysctl.d/99-wg0-forward.conf && sudo sysctl -w net.ipv4.ip_forward=0`
|
||||||
|
- iptables: Regeln mit `iptables -D` entfernen (siehe oben).
|
||||||
|
- nftables: `sudo nft delete table inet wireguard_wg0`.
|
||||||
|
- UFW: `sudo ufw delete allow 51820/udp`, Route-Regeln entfernen, `DEFAULT_FORWARD_POLICY` zurücksetzen.
|
||||||
|
- Firewalld: `firewall-cmd --permanent --remove-port=51820/udp`, `--remove-masquerade`, `--reload`.
|
||||||
|
- Dienst: `sudo systemctl disable --now wg-quick@wg0`.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### WireGuard startet nicht
|
### WireGuard startet nicht
|
||||||
@@ -281,4 +416,4 @@ Bei Problemen:
|
|||||||
1. Prüfe Logs: `sudo journalctl -u wg-quick@wg0`
|
1. Prüfe Logs: `sudo journalctl -u wg-quick@wg0`
|
||||||
2. Prüfe Status: `sudo wg show`
|
2. Prüfe Status: `sudo wg show`
|
||||||
3. Prüfe Firewall: `sudo ufw status`
|
3. Prüfe Firewall: `sudo ufw status`
|
||||||
4. Teste Connectivity: `ping 10.8.0.1` (vom Client)
|
4. Teste Connectivity: `ping 10.8.0.1` (vom Client)
|
||||||
|
|||||||
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.entrypoints=websecure"
|
||||||
- "traefik.http.routers.prometheus.tls=true"
|
- "traefik.http.routers.prometheus.tls=true"
|
||||||
- "traefik.http.routers.prometheus.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.prometheus.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.routers.prometheus.middlewares=prometheus-auth"
|
- "traefik.http.routers.prometheus.middlewares=prometheus-auth@docker"
|
||||||
- "traefik.http.middlewares.prometheus-auth.basicauth.users=${PROMETHEUS_AUTH}"
|
- "traefik.http.middlewares.prometheus-auth.basicauth.users=${PROMETHEUS_AUTH}"
|
||||||
- "traefik.http.services.prometheus.loadbalancer.server.port=9090"
|
- "traefik.http.services.prometheus.loadbalancer.server.port=9090"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -75,9 +75,6 @@ services:
|
|||||||
- "traefik.http.routers.grafana.entrypoints=websecure"
|
- "traefik.http.routers.grafana.entrypoints=websecure"
|
||||||
- "traefik.http.routers.grafana.tls=true"
|
- "traefik.http.routers.grafana.tls=true"
|
||||||
- "traefik.http.routers.grafana.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.grafana.tls.certresolver=letsencrypt"
|
||||||
# VPN IP whitelist: Use middleware defined in Traefik dynamic config
|
|
||||||
# Middleware is defined in deployment/stacks/traefik/dynamic/middlewares.yml
|
|
||||||
- "traefik.http.routers.grafana.middlewares=grafana-vpn-only@file"
|
|
||||||
- "traefik.http.services.grafana.loadbalancer.server.port=3000"
|
- "traefik.http.services.grafana.loadbalancer.server.port=3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
prometheus:
|
prometheus:
|
||||||
|
|||||||
@@ -52,25 +52,6 @@ http:
|
|||||||
# - "127.0.0.1/32"
|
# - "127.0.0.1/32"
|
||||||
# - "10.0.0.0/8"
|
# - "10.0.0.0/8"
|
||||||
|
|
||||||
# VPN-only IP allowlist for Grafana and other monitoring services
|
|
||||||
# Restrict access strictly to the WireGuard network
|
|
||||||
# Note: ipAllowList checks the real client IP from the connection
|
|
||||||
# When connected via VPN, client IP should be from 10.8.0.0/24
|
|
||||||
# If client IP shows public IP, the traffic is NOT going through VPN
|
|
||||||
# TEMPORARY: Added public IP for testing - REMOVE after fixing VPN routing!
|
|
||||||
grafana-vpn-only:
|
|
||||||
ipAllowList:
|
|
||||||
sourceRange:
|
|
||||||
- "10.8.0.0/24" # WireGuard VPN network (10.8.0.1 = server, 10.8.0.x = clients)
|
|
||||||
- "89.246.96.244/32" # TEMPORARY: Public IP for testing - REMOVE after VPN routing is fixed!
|
|
||||||
|
|
||||||
# VPN-only IP allowlist for general use (Traefik Dashboard, etc.)
|
|
||||||
# Restrict access strictly to the WireGuard network
|
|
||||||
vpn-only:
|
|
||||||
ipAllowList:
|
|
||||||
sourceRange:
|
|
||||||
- "10.8.0.0/24" # WireGuard VPN network
|
|
||||||
|
|
||||||
# Chain multiple middlewares
|
# Chain multiple middlewares
|
||||||
default-chain:
|
default-chain:
|
||||||
chain:
|
chain:
|
||||||
|
|||||||
@@ -64,10 +64,8 @@ providers:
|
|||||||
|
|
||||||
# Forwarded Headers Configuration
|
# Forwarded Headers Configuration
|
||||||
# This ensures Traefik correctly identifies the real client IP
|
# This ensures Traefik correctly identifies the real client IP
|
||||||
# Important for VPN access where requests come from WireGuard interface
|
|
||||||
forwardedHeaders:
|
forwardedHeaders:
|
||||||
trustedIPs:
|
trustedIPs:
|
||||||
- "10.8.0.0/24" # WireGuard VPN network
|
|
||||||
- "127.0.0.1/32" # Localhost
|
- "127.0.0.1/32" # Localhost
|
||||||
- "172.17.0.0/16" # Docker bridge network
|
- "172.17.0.0/16" # Docker bridge network
|
||||||
- "172.18.0.0/16" # Docker user-defined networks
|
- "172.18.0.0/16" # Docker user-defined networks
|
||||||
|
|||||||
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)]
|
#[Route(path: '/', method: Method::GET, name: WebRoutes::HOME)]
|
||||||
public function home(HomeRequest $request, string $test = 'hallo'): ViewResult
|
public function home(HomeRequest $request, string $test = 'hallo'): ViewResult
|
||||||
{
|
{
|
||||||
|
throw new \Exception('test');
|
||||||
|
|
||||||
// Production deployment trigger - scp from /workspace/repo
|
// Production deployment trigger - scp from /workspace/repo
|
||||||
$model = new HomeViewModel('Hallo Welt!');
|
$model = new HomeViewModel('Hallo Welt!');
|
||||||
return new ViewResult(
|
return new ViewResult(
|
||||||
@@ -25,7 +27,9 @@ final readonly class ShowHome
|
|||||||
title: 'Home',
|
title: 'Home',
|
||||||
description: 'Hallo Welt!',
|
description: 'Hallo Welt!',
|
||||||
)(),
|
)(),
|
||||||
data: ['name' => 'Michael'],
|
data: [
|
||||||
|
'name' => 'Michael',
|
||||||
|
],
|
||||||
model: $model,
|
model: $model,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,11 @@ final readonly class ApiGateway
|
|||||||
connectTimeout: min(3, $timeoutSeconds), // Connect timeout max 3s or total timeout
|
connectTimeout: min(3, $timeoutSeconds), // Connect timeout max 3s or total timeout
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add authentication if present
|
||||||
|
if ($request instanceof HasAuth) {
|
||||||
|
$options = $options->with(['auth' => $request->getAuth()]);
|
||||||
|
}
|
||||||
|
|
||||||
// Use factory method for JSON requests if payload is present
|
// Use factory method for JSON requests if payload is present
|
||||||
if ($request instanceof HasPayload) {
|
if ($request instanceof HasPayload) {
|
||||||
return ClientRequest::json(
|
return ClientRequest::json(
|
||||||
|
|||||||
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_USERNAME = 'RAPIDMAIL_USERNAME';
|
||||||
case RAPIDMAIL_PASSWORD = 'RAPIDMAIL_PASSWORD';
|
case RAPIDMAIL_PASSWORD = 'RAPIDMAIL_PASSWORD';
|
||||||
case RAPIDMAIL_TEST_MODE = 'RAPIDMAIL_TEST_MODE';
|
case RAPIDMAIL_TEST_MODE = 'RAPIDMAIL_TEST_MODE';
|
||||||
|
case NETCUP_API_KEY = 'NETCUP_API_KEY';
|
||||||
|
case NETCUP_API_PASSWORD = 'NETCUP_API_PASSWORD';
|
||||||
|
|
||||||
// OAuth - Spotify
|
// OAuth - Spotify
|
||||||
case SPOTIFY_CLIENT_ID = 'SPOTIFY_CLIENT_ID';
|
case SPOTIFY_CLIENT_ID = 'SPOTIFY_CLIENT_ID';
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ final readonly class InitializerProcessor
|
|||||||
if ($returnType === null || $returnType === 'void') {
|
if ($returnType === null || $returnType === 'void') {
|
||||||
$this->container->invoker->invoke($discoveredAttribute->className, $methodName->toString());
|
$this->container->invoker->invoke($discoveredAttribute->className, $methodName->toString());
|
||||||
}
|
}
|
||||||
|
// Handle "self" return type: Replace with the declaring class
|
||||||
|
elseif ($returnType === 'self') {
|
||||||
|
$returnType = $discoveredAttribute->className->toString();
|
||||||
|
$dependencyGraph->addInitializer($returnType, $discoveredAttribute->className, $methodName);
|
||||||
|
}
|
||||||
// Service-Initializer: Konkreter Return-Type → Zum Dependency-Graph hinzufügen
|
// Service-Initializer: Konkreter Return-Type → Zum Dependency-Graph hinzufügen
|
||||||
else {
|
else {
|
||||||
$dependencyGraph->addInitializer($returnType, $discoveredAttribute->className, $methodName);
|
$dependencyGraph->addInitializer($returnType, $discoveredAttribute->className, $methodName);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use App\Framework\Core\ValueObjects\Duration;
|
|||||||
use App\Framework\DateTime\Clock;
|
use App\Framework\DateTime\Clock;
|
||||||
use App\Framework\ErrorAggregation\Storage\ErrorStorageInterface;
|
use App\Framework\ErrorAggregation\Storage\ErrorStorageInterface;
|
||||||
use App\Framework\Exception\Core\ErrorSeverity;
|
use App\Framework\Exception\Core\ErrorSeverity;
|
||||||
use App\Framework\Exception\ErrorHandlerContext;
|
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||||
use App\Framework\Logging\Logger;
|
use App\Framework\Logging\Logger;
|
||||||
use App\Framework\Queue\Queue;
|
use App\Framework\Queue\Queue;
|
||||||
|
|
||||||
@@ -35,18 +35,18 @@ final readonly class ErrorAggregator implements ErrorAggregatorInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes a new error from ErrorHandlerContext
|
* Processes a new error using unified exception pattern
|
||||||
*/
|
*/
|
||||||
public function processError(ErrorHandlerContext $context): void
|
public function processError(\Throwable $exception, ExceptionContextProvider $contextProvider, bool $isDebug = false): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$errorEvent = ErrorEvent::fromErrorHandlerContext($context, $this->clock);
|
$errorEvent = ErrorEvent::fromException($exception, $contextProvider, $this->clock, $isDebug);
|
||||||
$this->processErrorEvent($errorEvent);
|
$this->processErrorEvent($errorEvent);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
// Don't let error aggregation break the application
|
// Don't let error aggregation break the application
|
||||||
$this->logError("Failed to process error: " . $e->getMessage(), [
|
$this->logError("Failed to process error: " . $e->getMessage(), [
|
||||||
'exception' => $e,
|
'exception' => $e,
|
||||||
'context' => $context->toArray(),
|
'original_exception' => $exception,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,43 @@ final readonly class ErrorEvent
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates ErrorEvent from Exception using ExceptionContextProvider (new unified pattern)
|
||||||
|
*/
|
||||||
|
public static function fromException(\Throwable $exception, \App\Framework\ExceptionHandling\Context\ExceptionContextProvider $contextProvider, \App\Framework\DateTime\Clock $clock, bool $isDebug = false): self
|
||||||
|
{
|
||||||
|
// Retrieve context from WeakMap
|
||||||
|
$context = $contextProvider->get($exception);
|
||||||
|
|
||||||
|
// Extract ErrorCode if exception implements the interface
|
||||||
|
$errorCode = self::extractErrorCodeFromException($exception);
|
||||||
|
|
||||||
|
// Extract service name from operation or component
|
||||||
|
$service = self::extractServiceNameFromContext($context);
|
||||||
|
|
||||||
|
// Determine severity
|
||||||
|
$severity = self::determineSeverityFromException($exception, $context, $errorCode);
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
id: new Ulid($clock),
|
||||||
|
service: $service,
|
||||||
|
component: $context?->component ?? 'unknown',
|
||||||
|
operation: $context?->operation ?? 'unknown',
|
||||||
|
errorCode: $errorCode,
|
||||||
|
errorMessage: $exception->getMessage(),
|
||||||
|
severity: $severity,
|
||||||
|
occurredAt: $context?->occurredAt ?? new \DateTimeImmutable(),
|
||||||
|
context: $context?->data ?? [],
|
||||||
|
metadata: $context?->metadata ?? [],
|
||||||
|
requestId: $context?->requestId,
|
||||||
|
userId: $context?->userId,
|
||||||
|
clientIp: $context?->clientIp,
|
||||||
|
isSecurityEvent: $context?->metadata['security_event'] ?? false,
|
||||||
|
stackTrace: $isDebug ? $exception->getTraceAsString() : null,
|
||||||
|
userAgent: $context?->userAgent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts to array for storage/transmission
|
* Converts to array for storage/transmission
|
||||||
*/
|
*/
|
||||||
@@ -298,4 +335,74 @@ final readonly class ErrorEvent
|
|||||||
|
|
||||||
return $normalized;
|
return $normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract ErrorCode from exception (new unified pattern helper)
|
||||||
|
*/
|
||||||
|
private static function extractErrorCodeFromException(\Throwable $exception): ErrorCode
|
||||||
|
{
|
||||||
|
// Check if exception implements HasErrorCode interface
|
||||||
|
if ($exception instanceof \App\Framework\Exception\FrameworkException) {
|
||||||
|
$errorCode = $exception->getErrorCode();
|
||||||
|
if ($errorCode !== null) {
|
||||||
|
return $errorCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Use SystemErrorCode::RESOURCE_EXHAUSTED as generic error
|
||||||
|
return \App\Framework\Exception\Core\SystemErrorCode::RESOURCE_EXHAUSTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract service name from ExceptionContextData (new unified pattern helper)
|
||||||
|
*/
|
||||||
|
private static function extractServiceNameFromContext(?\App\Framework\ExceptionHandling\Context\ExceptionContextData $context): string
|
||||||
|
{
|
||||||
|
if ($context === null) {
|
||||||
|
return 'web';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract from operation if available (e.g., "user.create" → "user")
|
||||||
|
if ($context->operation !== null && str_contains($context->operation, '.')) {
|
||||||
|
$parts = explode('.', $context->operation);
|
||||||
|
return strtolower($parts[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract from component if available
|
||||||
|
if ($context->component !== null) {
|
||||||
|
return strtolower($context->component);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'web';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine severity from exception, context, and error code (new unified pattern helper)
|
||||||
|
*/
|
||||||
|
private static function determineSeverityFromException(
|
||||||
|
\Throwable $exception,
|
||||||
|
?\App\Framework\ExceptionHandling\Context\ExceptionContextData $context,
|
||||||
|
ErrorCode $errorCode
|
||||||
|
): ErrorSeverity {
|
||||||
|
// Security events are always critical
|
||||||
|
if ($context?->metadata['security_event'] ?? false) {
|
||||||
|
return ErrorSeverity::CRITICAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check explicit severity in metadata
|
||||||
|
if ($context !== null && isset($context->metadata['severity'])) {
|
||||||
|
$severity = ErrorSeverity::tryFrom($context->metadata['severity']);
|
||||||
|
if ($severity !== null) {
|
||||||
|
return $severity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get severity from ErrorCode
|
||||||
|
if (method_exists($errorCode, 'getSeverity')) {
|
||||||
|
return $errorCode->getSeverity();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: ERROR for all unhandled exceptions
|
||||||
|
return ErrorSeverity::ERROR;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Framework\ErrorBoundaries;
|
|||||||
use App\Framework\Core\ValueObjects\Duration;
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
use App\Framework\Core\ValueObjects\Timestamp;
|
use App\Framework\Core\ValueObjects\Timestamp;
|
||||||
use App\Framework\DateTime\Timer;
|
use App\Framework\DateTime\Timer;
|
||||||
|
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
|
||||||
use App\Framework\ErrorBoundaries\CircuitBreaker\BoundaryCircuitBreakerManager;
|
use App\Framework\ErrorBoundaries\CircuitBreaker\BoundaryCircuitBreakerManager;
|
||||||
use App\Framework\ErrorBoundaries\Events\BoundaryEventInterface;
|
use App\Framework\ErrorBoundaries\Events\BoundaryEventInterface;
|
||||||
use App\Framework\ErrorBoundaries\Events\BoundaryEventPublisher;
|
use App\Framework\ErrorBoundaries\Events\BoundaryEventPublisher;
|
||||||
@@ -16,6 +17,7 @@ use App\Framework\ErrorBoundaries\Events\BoundaryFallbackExecuted;
|
|||||||
use App\Framework\ErrorBoundaries\Events\BoundaryTimeoutOccurred;
|
use App\Framework\ErrorBoundaries\Events\BoundaryTimeoutOccurred;
|
||||||
use App\Framework\Exception\ErrorCode;
|
use App\Framework\Exception\ErrorCode;
|
||||||
use App\Framework\Exception\FrameworkException;
|
use App\Framework\Exception\FrameworkException;
|
||||||
|
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||||
use App\Framework\Logging\Logger;
|
use App\Framework\Logging\Logger;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ final readonly class ErrorBoundary
|
|||||||
private ?Logger $logger = null,
|
private ?Logger $logger = null,
|
||||||
private ?BoundaryCircuitBreakerManager $circuitBreakerManager = null,
|
private ?BoundaryCircuitBreakerManager $circuitBreakerManager = null,
|
||||||
private ?BoundaryEventPublisher $eventPublisher = null,
|
private ?BoundaryEventPublisher $eventPublisher = null,
|
||||||
|
private ?ErrorAggregatorInterface $errorAggregator = null,
|
||||||
|
private ?ExceptionContextProvider $contextProvider = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +323,29 @@ final readonly class ErrorBoundary
|
|||||||
{
|
{
|
||||||
$this->logFailure($exception, 'Operation failed, executing fallback');
|
$this->logFailure($exception, 'Operation failed, executing fallback');
|
||||||
|
|
||||||
|
// Dispatch to ErrorAggregator for centralized monitoring
|
||||||
|
if ($this->errorAggregator !== null && $this->contextProvider !== null) {
|
||||||
|
try {
|
||||||
|
// Enrich exception context with boundary metadata
|
||||||
|
$existingContext = $this->contextProvider->get($exception);
|
||||||
|
if ($existingContext !== null) {
|
||||||
|
$enrichedContext = $existingContext->withMetadata([
|
||||||
|
'error_boundary' => $this->boundaryName,
|
||||||
|
'boundary_failure' => true,
|
||||||
|
]);
|
||||||
|
$this->contextProvider->set($exception, $enrichedContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch to aggregator
|
||||||
|
$this->errorAggregator->processError($exception, $this->contextProvider, false);
|
||||||
|
} catch (Throwable $aggregationException) {
|
||||||
|
// Don't let aggregation failures break boundary resilience
|
||||||
|
$this->log('warning', 'Error aggregation failed', [
|
||||||
|
'aggregation_error' => $aggregationException->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $fallback();
|
$result = $fallback();
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ namespace App\Framework\ErrorBoundaries;
|
|||||||
use App\Framework\Core\ValueObjects\Duration;
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
use App\Framework\DateTime\SystemTimer;
|
use App\Framework\DateTime\SystemTimer;
|
||||||
use App\Framework\DateTime\Timer;
|
use App\Framework\DateTime\Timer;
|
||||||
|
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
|
||||||
use App\Framework\ErrorBoundaries\CircuitBreaker\BoundaryCircuitBreakerManager;
|
use App\Framework\ErrorBoundaries\CircuitBreaker\BoundaryCircuitBreakerManager;
|
||||||
use App\Framework\ErrorBoundaries\Events\BoundaryEventPublisher;
|
use App\Framework\ErrorBoundaries\Events\BoundaryEventPublisher;
|
||||||
use App\Framework\EventBus\EventBus;
|
use App\Framework\EventBus\EventBus;
|
||||||
|
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||||
use App\Framework\Logging\Logger;
|
use App\Framework\Logging\Logger;
|
||||||
use App\Framework\StateManagement\StateManagerFactory;
|
use App\Framework\StateManagement\StateManagerFactory;
|
||||||
|
|
||||||
@@ -25,6 +27,8 @@ final readonly class ErrorBoundaryFactory
|
|||||||
private ?Logger $logger = null,
|
private ?Logger $logger = null,
|
||||||
private ?StateManagerFactory $stateManagerFactory = null,
|
private ?StateManagerFactory $stateManagerFactory = null,
|
||||||
private ?EventBus $eventBus = null,
|
private ?EventBus $eventBus = null,
|
||||||
|
private ?ErrorAggregatorInterface $errorAggregator = null,
|
||||||
|
private ?ExceptionContextProvider $contextProvider = null,
|
||||||
array $routeConfigs = []
|
array $routeConfigs = []
|
||||||
) {
|
) {
|
||||||
$this->routeConfigs = array_merge($this->getDefaultRouteConfigs(), $routeConfigs);
|
$this->routeConfigs = array_merge($this->getDefaultRouteConfigs(), $routeConfigs);
|
||||||
@@ -101,6 +105,8 @@ final readonly class ErrorBoundaryFactory
|
|||||||
logger: $this->logger,
|
logger: $this->logger,
|
||||||
circuitBreakerManager: $circuitBreakerManager,
|
circuitBreakerManager: $circuitBreakerManager,
|
||||||
eventPublisher: $eventPublisher,
|
eventPublisher: $eventPublisher,
|
||||||
|
errorAggregator: $this->errorAggregator,
|
||||||
|
contextProvider: $this->contextProvider,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,57 @@ final readonly class ErrorReport
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from Exception with WeakMap context (unified pattern)
|
||||||
|
*
|
||||||
|
* @param Throwable $exception Exception to report
|
||||||
|
* @param \App\Framework\ExceptionHandling\Context\ExceptionContextProvider $contextProvider WeakMap context provider
|
||||||
|
* @param string $level Error level (error, warning, critical, etc.)
|
||||||
|
* @param array $additionalContext Additional context to merge with WeakMap context
|
||||||
|
* @param string|null $environment Environment name (production, staging, etc.)
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function fromException(
|
||||||
|
Throwable $exception,
|
||||||
|
\App\Framework\ExceptionHandling\Context\ExceptionContextProvider $contextProvider,
|
||||||
|
string $level = 'error',
|
||||||
|
array $additionalContext = [],
|
||||||
|
?string $environment = null
|
||||||
|
): self {
|
||||||
|
// Retrieve context from WeakMap
|
||||||
|
$context = $contextProvider->get($exception);
|
||||||
|
|
||||||
|
// Merge data from WeakMap with additional context
|
||||||
|
$mergedContext = array_merge($context?->data ?? [], $additionalContext);
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
id: self::generateId(),
|
||||||
|
timestamp: $context?->occurredAt ?? new DateTimeImmutable(),
|
||||||
|
level: $level,
|
||||||
|
message: $exception->getMessage(),
|
||||||
|
exception: $exception::class,
|
||||||
|
file: $exception->getFile(),
|
||||||
|
line: $exception->getLine(),
|
||||||
|
trace: $exception->getTraceAsString(),
|
||||||
|
context: $mergedContext,
|
||||||
|
userId: $context?->userId,
|
||||||
|
sessionId: $context?->sessionId,
|
||||||
|
requestId: $context?->requestId,
|
||||||
|
userAgent: $context?->userAgent,
|
||||||
|
ipAddress: $context?->clientIp,
|
||||||
|
tags: $context?->tags ?? [],
|
||||||
|
environment: $environment ?? 'production',
|
||||||
|
serverInfo: self::getServerInfo(),
|
||||||
|
customData: array_merge(
|
||||||
|
$context?->metadata ?? [],
|
||||||
|
array_filter([
|
||||||
|
'operation' => $context?->operation,
|
||||||
|
'component' => $context?->component,
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create from manual report
|
* Create from manual report
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -28,7 +28,21 @@ final readonly class ErrorReporter implements ErrorReporterInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Report an error from Throwable
|
* Report an error from Exception with WeakMap context (unified pattern)
|
||||||
|
*/
|
||||||
|
public function reportException(
|
||||||
|
Throwable $exception,
|
||||||
|
\App\Framework\ExceptionHandling\Context\ExceptionContextProvider $contextProvider,
|
||||||
|
string $level = 'error',
|
||||||
|
array $additionalContext = []
|
||||||
|
): string {
|
||||||
|
$report = ErrorReport::fromException($exception, $contextProvider, $level, $additionalContext);
|
||||||
|
|
||||||
|
return $this->report($report);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report an error from Throwable (legacy method)
|
||||||
*/
|
*/
|
||||||
public function reportThrowable(
|
public function reportThrowable(
|
||||||
Throwable $throwable,
|
Throwable $throwable,
|
||||||
|
|||||||
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;
|
namespace App\Framework\ExceptionHandling;
|
||||||
|
|
||||||
|
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||||
|
use App\Framework\ExceptionHandling\Renderers\ResponseErrorRenderer;
|
||||||
use App\Framework\ExceptionHandling\Reporter\LogReporter;
|
use App\Framework\ExceptionHandling\Reporter\LogReporter;
|
||||||
|
use App\Framework\Http\Response;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
final readonly class ErrorKernel
|
final readonly class ErrorKernel
|
||||||
@@ -25,5 +28,28 @@ final readonly class ErrorKernel
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create HTTP Response from exception without terminating execution
|
||||||
|
*
|
||||||
|
* This method enables middleware recovery patterns by returning a Response
|
||||||
|
* object instead of terminating the application.
|
||||||
|
*
|
||||||
|
* @param Throwable $exception Exception to render
|
||||||
|
* @param ExceptionContextProvider|null $contextProvider Optional WeakMap context provider
|
||||||
|
* @param bool $isDebugMode Enable debug information in response
|
||||||
|
* @return Response HTTP Response object (JSON for API, HTML for web)
|
||||||
|
*/
|
||||||
|
public function createHttpResponse(
|
||||||
|
Throwable $exception,
|
||||||
|
?ExceptionContextProvider $contextProvider = null,
|
||||||
|
bool $isDebugMode = false
|
||||||
|
): Response {
|
||||||
|
// Create ResponseErrorRenderer with debug mode setting
|
||||||
|
$renderer = new ResponseErrorRenderer($isDebugMode);
|
||||||
|
|
||||||
|
// Generate and return Response object
|
||||||
|
return $renderer->createResponse($exception, $contextProvider);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
final class ReedSolomonEncoder
|
||||||
{
|
{
|
||||||
// Generator polynomial coefficients for different EC codeword counts
|
// Generator polynomial coefficients for different EC codeword counts
|
||||||
|
// Format: [ecCodewords => [coefficients...]]
|
||||||
|
// Note: The first coefficient is always 0 (leading term)
|
||||||
private const GENERATOR_POLYNOMIALS = [
|
private const GENERATOR_POLYNOMIALS = [
|
||||||
7 => [0, 87, 229, 146, 149, 238, 102, 21], // EC Level M, Version 1
|
7 => [0, 87, 229, 146, 149, 238, 102, 21], // 7 EC codewords
|
||||||
10 => [0, 251, 67, 46, 61, 118, 70, 64, 94, 32, 45],
|
10 => [0, 251, 67, 46, 61, 118, 70, 64, 94, 32, 45], // Version 1, Level M (10 EC codewords)
|
||||||
13 => [0, 74, 152, 176, 100, 86, 100, 106, 104, 130, 218, 206, 140, 78],
|
13 => [0, 74, 152, 176, 100, 86, 100, 106, 104, 130, 218, 206, 140, 78],
|
||||||
15 => [0, 8, 183, 61, 91, 202, 37, 51, 58, 58, 237, 140, 124, 5, 99, 105],
|
15 => [0, 8, 183, 61, 91, 202, 37, 51, 58, 58, 237, 140, 124, 5, 99, 105],
|
||||||
16 => [0, 120, 104, 107, 109, 102, 161, 76, 3, 91, 191, 147, 169, 182, 194, 225, 120],
|
16 => [0, 120, 104, 107, 109, 102, 161, 76, 3, 91, 191, 147, 169, 182, 194, 225, 120],
|
||||||
@@ -73,15 +75,30 @@ final class ReedSolomonEncoder
|
|||||||
$generator = $this->getGeneratorPolynomial($ecCodewords);
|
$generator = $this->getGeneratorPolynomial($ecCodewords);
|
||||||
|
|
||||||
// Initialize message polynomial (data + zero padding for EC)
|
// Initialize message polynomial (data + zero padding for EC)
|
||||||
|
// This represents m(x) * x^t where t is the number of EC codewords
|
||||||
$messagePolynomial = array_merge($data, array_fill(0, $ecCodewords, 0));
|
$messagePolynomial = array_merge($data, array_fill(0, $ecCodewords, 0));
|
||||||
|
|
||||||
// Polynomial division
|
// Polynomial division: divide messagePolynomial by generator
|
||||||
|
// Standard Reed-Solomon encoding algorithm
|
||||||
|
// For stored polynomials [0, a1, a2, ..., an], the leading coefficient is implicitly 1
|
||||||
|
// So we treat it as a monic polynomial [1, a1, a2, ..., an]
|
||||||
|
$messageLength = count($messagePolynomial);
|
||||||
|
|
||||||
for ($i = 0; $i < count($data); $i++) {
|
for ($i = 0; $i < count($data); $i++) {
|
||||||
$coefficient = $messagePolynomial[$i];
|
$coefficient = $messagePolynomial[$i];
|
||||||
|
|
||||||
if ($coefficient !== 0) {
|
if ($coefficient !== 0) {
|
||||||
for ($j = 0; $j < count($generator); $j++) {
|
// Leading coefficient is implicitly 1 (monic polynomial)
|
||||||
$messagePolynomial[$i + $j] ^= $this->gfMultiply($generator[$j], $coefficient);
|
// So we clear the current position and apply generator coefficients
|
||||||
|
$messagePolynomial[$i] = 0;
|
||||||
|
|
||||||
|
// Apply generator coefficients (skip first element which is 0)
|
||||||
|
// Generator format: [0, a1, a2, ..., an] represents [1, a1, a2, ..., an]
|
||||||
|
for ($j = 1; $j < count($generator); $j++) {
|
||||||
|
$index = $i + $j;
|
||||||
|
if ($index < $messageLength) {
|
||||||
|
$messagePolynomial[$index] ^= $this->gfMultiply($generator[$j], $coefficient);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +123,8 @@ final class ReedSolomonEncoder
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate generator polynomial g(x) = (x - α^0)(x - α^1)...(x - α^(n-1))
|
* Generate generator polynomial g(x) = (x - α^0)(x - α^1)...(x - α^(n-1))
|
||||||
|
*
|
||||||
|
* Returns monic polynomial [1, a1, a2, ..., an] where leading coefficient is 1
|
||||||
*/
|
*/
|
||||||
private function generateGeneratorPolynomial(int $degree): array
|
private function generateGeneratorPolynomial(int $degree): array
|
||||||
{
|
{
|
||||||
@@ -113,6 +132,7 @@ final class ReedSolomonEncoder
|
|||||||
$polynomial = [1];
|
$polynomial = [1];
|
||||||
|
|
||||||
// Multiply by (x - α^i) for i = 0 to degree-1
|
// Multiply by (x - α^i) for i = 0 to degree-1
|
||||||
|
// (x - α^i) = x + (-α^i) = x + (α^i in GF(256))
|
||||||
for ($i = 0; $i < $degree; $i++) {
|
for ($i = 0; $i < $degree; $i++) {
|
||||||
$polynomial = $this->multiplyPolynomials(
|
$polynomial = $this->multiplyPolynomials(
|
||||||
$polynomial,
|
$polynomial,
|
||||||
|
|||||||
@@ -24,10 +24,14 @@ use App\Framework\QrCode\ValueObjects\QrCodeMatrix;
|
|||||||
* Phase 2: Full Reed-Solomon error correction with mask pattern selection
|
* Phase 2: Full Reed-Solomon error correction with mask pattern selection
|
||||||
* Generates scannable QR codes compliant with ISO/IEC 18004
|
* Generates scannable QR codes compliant with ISO/IEC 18004
|
||||||
*/
|
*/
|
||||||
final class QrCodeGenerator
|
final readonly class QrCodeGenerator
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private QrCodeRenderer $renderer
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate QR Code from data
|
* Generate QR Code from data (static method for backward compatibility)
|
||||||
*/
|
*/
|
||||||
public static function generate(string $data, ?QrCodeConfig $config = null): QrCodeMatrix
|
public static function generate(string $data, ?QrCodeConfig $config = null): QrCodeMatrix
|
||||||
{
|
{
|
||||||
@@ -68,16 +72,18 @@ final class QrCodeGenerator
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate matrix
|
// Generate matrix using temporary instance
|
||||||
$matrix = self::generateMatrix($data, $config);
|
$temporaryRenderer = new QrCodeRenderer();
|
||||||
|
$temporaryGenerator = new self($temporaryRenderer);
|
||||||
|
$matrix = $temporaryGenerator->generateMatrix($data, $config);
|
||||||
|
|
||||||
return $matrix;
|
return $matrix;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate QR Code matrix
|
* Generate QR Code matrix (instance method)
|
||||||
*/
|
*/
|
||||||
private static function generateMatrix(string $data, QrCodeConfig $config): QrCodeMatrix
|
private function generateMatrix(string $data, QrCodeConfig $config): QrCodeMatrix
|
||||||
{
|
{
|
||||||
// 1. Create empty matrix
|
// 1. Create empty matrix
|
||||||
$matrix = QrCodeMatrix::create($config->version);
|
$matrix = QrCodeMatrix::create($config->version);
|
||||||
@@ -97,7 +103,7 @@ final class QrCodeGenerator
|
|||||||
$matrix = $matrix->setModuleAt($darkModuleRow, 8, Module::dark());
|
$matrix = $matrix->setModuleAt($darkModuleRow, 8, Module::dark());
|
||||||
|
|
||||||
// 6. Encode data into codewords
|
// 6. Encode data into codewords
|
||||||
$dataCodewords = self::encodeData($data, $config);
|
$dataCodewords = $this->encodeData($data, $config);
|
||||||
|
|
||||||
// 7. Generate error correction codewords using Reed-Solomon
|
// 7. Generate error correction codewords using Reed-Solomon
|
||||||
$reedSolomon = new ReedSolomonEncoder();
|
$reedSolomon = new ReedSolomonEncoder();
|
||||||
@@ -109,7 +115,7 @@ final class QrCodeGenerator
|
|||||||
|
|
||||||
// 8. Place data and EC codewords in matrix
|
// 8. Place data and EC codewords in matrix
|
||||||
$allCodewords = array_merge($dataCodewords, $ecCodewords);
|
$allCodewords = array_merge($dataCodewords, $ecCodewords);
|
||||||
$matrix = self::placeDataCodewords($matrix, $allCodewords);
|
$matrix = $this->placeDataCodewords($matrix, $allCodewords);
|
||||||
|
|
||||||
// 9. Select best mask pattern (evaluates all 8 patterns)
|
// 9. Select best mask pattern (evaluates all 8 patterns)
|
||||||
$maskEvaluator = new MaskEvaluator();
|
$maskEvaluator = new MaskEvaluator();
|
||||||
@@ -132,7 +138,7 @@ final class QrCodeGenerator
|
|||||||
/**
|
/**
|
||||||
* Encode data into codewords (Phase 2: Byte mode with proper structure)
|
* Encode data into codewords (Phase 2: Byte mode with proper structure)
|
||||||
*/
|
*/
|
||||||
private static function encodeData(string $data, QrCodeConfig $config): array
|
private function encodeData(string $data, QrCodeConfig $config): array
|
||||||
{
|
{
|
||||||
$codewords = [];
|
$codewords = [];
|
||||||
$bits = '';
|
$bits = '';
|
||||||
@@ -186,14 +192,16 @@ final class QrCodeGenerator
|
|||||||
/**
|
/**
|
||||||
* Place data codewords in matrix using zig-zag pattern
|
* Place data codewords in matrix using zig-zag pattern
|
||||||
*/
|
*/
|
||||||
private static function placeDataCodewords(QrCodeMatrix $matrix, array $codewords): QrCodeMatrix
|
private function placeDataCodewords(QrCodeMatrix $matrix, array $codewords): QrCodeMatrix
|
||||||
{
|
{
|
||||||
$size = $matrix->getSize();
|
$size = $matrix->getSize();
|
||||||
$bitIndex = 0;
|
$bitIndex = 0;
|
||||||
|
|
||||||
// Convert codewords to bit string
|
// Convert codewords to bit string
|
||||||
|
// ISO/IEC 18004: Bits are placed MSB-first (most significant bit first)
|
||||||
$bits = '';
|
$bits = '';
|
||||||
foreach ($codewords as $codeword) {
|
foreach ($codewords as $codeword) {
|
||||||
|
// Convert byte to 8-bit binary string (MSB-first)
|
||||||
$bits .= str_pad(decbin($codeword), 8, '0', STR_PAD_LEFT);
|
$bits .= str_pad(decbin($codeword), 8, '0', STR_PAD_LEFT);
|
||||||
}
|
}
|
||||||
$totalBits = strlen($bits);
|
$totalBits = strlen($bits);
|
||||||
@@ -212,12 +220,14 @@ final class QrCodeGenerator
|
|||||||
$row = $upward ? ($size - 1 - $i) : $i;
|
$row = $upward ? ($size - 1 - $i) : $i;
|
||||||
|
|
||||||
// Place bits in both columns of the pair
|
// Place bits in both columns of the pair
|
||||||
|
// ISO/IEC 18004 Section 7.7.3: Within a column pair, place bits from RIGHT to LEFT
|
||||||
|
// Right column first (col), then left column (col-1)
|
||||||
for ($c = 0; $c < 2; $c++) {
|
for ($c = 0; $c < 2; $c++) {
|
||||||
$currentCol = $col - $c;
|
$currentCol = $col - $c;
|
||||||
$position = ModulePosition::at($row, $currentCol);
|
$position = ModulePosition::at($row, $currentCol);
|
||||||
|
|
||||||
// Skip if position is already occupied (function patterns)
|
// Skip if position is already occupied (function patterns)
|
||||||
if (self::isOccupied($matrix, $position)) {
|
if ($this->isOccupied($matrix, $position)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +251,7 @@ final class QrCodeGenerator
|
|||||||
/**
|
/**
|
||||||
* Check if position is occupied by function pattern
|
* Check if position is occupied by function pattern
|
||||||
*/
|
*/
|
||||||
private static function isOccupied(QrCodeMatrix $matrix, ModulePosition $position): bool
|
private function isOccupied(QrCodeMatrix $matrix, ModulePosition $position): bool
|
||||||
{
|
{
|
||||||
$size = $matrix->getSize();
|
$size = $matrix->getSize();
|
||||||
$row = $position->row;
|
$row = $position->row;
|
||||||
@@ -320,4 +330,98 @@ final class QrCodeGenerator
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate QR Code as SVG string
|
||||||
|
*/
|
||||||
|
public function generateSvg(
|
||||||
|
string $data,
|
||||||
|
ErrorCorrectionLevel $errorLevel = ErrorCorrectionLevel::M,
|
||||||
|
?QrCodeVersion $version = null
|
||||||
|
): string {
|
||||||
|
$config = $version !== null
|
||||||
|
? QrCodeConfig::withVersion($version->getVersionNumber(), $errorLevel)
|
||||||
|
: QrCodeConfig::autoSize($data, $errorLevel);
|
||||||
|
|
||||||
|
$matrix = $this->generateMatrix($data, $config);
|
||||||
|
|
||||||
|
return $this->renderer->renderSvg($matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate QR Code as Data URI (base64 encoded SVG)
|
||||||
|
*/
|
||||||
|
public function generateDataUri(
|
||||||
|
string $data,
|
||||||
|
ErrorCorrectionLevel $errorLevel = ErrorCorrectionLevel::M,
|
||||||
|
?QrCodeVersion $version = null
|
||||||
|
): string {
|
||||||
|
$config = $version !== null
|
||||||
|
? QrCodeConfig::withVersion($version->getVersionNumber(), $errorLevel)
|
||||||
|
: QrCodeConfig::autoSize($data, $errorLevel);
|
||||||
|
|
||||||
|
$matrix = $this->generateMatrix($data, $config);
|
||||||
|
|
||||||
|
return $this->renderer->toDataUrl($matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze data and provide QR Code recommendations
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> Analysis data with recommendations
|
||||||
|
*/
|
||||||
|
public function analyzeData(string $data): array
|
||||||
|
{
|
||||||
|
$dataLength = strlen($data);
|
||||||
|
$encodingMode = EncodingMode::BYTE; // Currently only byte mode is supported
|
||||||
|
$recommendedErrorLevel = ErrorCorrectionLevel::M; // Default
|
||||||
|
$recommendedVersion = QrCodeVersion::fromDataLength($dataLength, $encodingMode, $recommendedErrorLevel);
|
||||||
|
|
||||||
|
// Determine if data looks like a URL
|
||||||
|
$isUrl = filter_var($data, FILTER_VALIDATE_URL) !== false || str_starts_with($data, 'http://') || str_starts_with($data, 'https://');
|
||||||
|
|
||||||
|
// Determine if data looks like TOTP URI
|
||||||
|
$isTotp = str_starts_with($data, 'otpauth://totp/');
|
||||||
|
|
||||||
|
// Calculate estimated capacity
|
||||||
|
$capacity = $recommendedVersion->getDataCapacity($encodingMode, $recommendedErrorLevel);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'dataLength' => $dataLength,
|
||||||
|
'dataType' => $isTotp ? 'totp' : ($isUrl ? 'url' : 'text'),
|
||||||
|
'recommendedVersion' => $recommendedVersion->getVersionNumber(),
|
||||||
|
'recommendedErrorLevel' => $recommendedErrorLevel->value,
|
||||||
|
'encodingMode' => $encodingMode->value,
|
||||||
|
'matrixSize' => $recommendedVersion->getMatrixSize(),
|
||||||
|
'capacity' => $capacity,
|
||||||
|
'efficiency' => round(($dataLength / $capacity) * 100, 2),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate TOTP QR Code with optimized settings
|
||||||
|
*
|
||||||
|
* TOTP URIs are typically longer, so we use a higher version for better readability
|
||||||
|
*/
|
||||||
|
public function generateTotpQrCode(string $totpUri): string
|
||||||
|
{
|
||||||
|
// TOTP URIs are typically 50-100 characters, so we use version 3 for better error correction
|
||||||
|
$version = QrCodeVersion::fromNumber(3);
|
||||||
|
$errorLevel = ErrorCorrectionLevel::M; // Medium error correction for TOTP
|
||||||
|
|
||||||
|
$config = QrCodeConfig::withVersion($version->getVersionNumber(), $errorLevel);
|
||||||
|
|
||||||
|
// Validate that data fits
|
||||||
|
$dataLength = strlen($totpUri);
|
||||||
|
$capacity = $version->getDataCapacity($config->encodingMode, $errorLevel);
|
||||||
|
if ($dataLength > $capacity) {
|
||||||
|
throw FrameworkException::simple(
|
||||||
|
"TOTP URI too long: {$dataLength} bytes exceeds capacity of {$capacity} bytes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$matrix = $this->generateMatrix($totpUri, $config);
|
||||||
|
|
||||||
|
return $this->renderer->renderSvg($matrix);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
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;
|
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
||||||
|
|
||||||
use App\Framework\ApiGateway\ApiRequest;
|
use App\Framework\ApiGateway\ApiRequest;
|
||||||
|
use App\Framework\ApiGateway\HasAuth;
|
||||||
use App\Framework\ApiGateway\HasPayload;
|
use App\Framework\ApiGateway\HasPayload;
|
||||||
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
||||||
use App\Framework\Core\ValueObjects\Duration;
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
use App\Framework\Http\Headers;
|
use App\Framework\Http\Headers;
|
||||||
use App\Framework\Http\Method as HttpMethod;
|
use App\Framework\Http\Method as HttpMethod;
|
||||||
use App\Framework\Http\Url\Url;
|
use App\Framework\Http\Url\Url;
|
||||||
|
use App\Framework\HttpClient\AuthConfig;
|
||||||
use App\Framework\Retry\ExponentialBackoffStrategy;
|
use App\Framework\Retry\ExponentialBackoffStrategy;
|
||||||
use App\Framework\Retry\RetryStrategy;
|
use App\Framework\Retry\RetryStrategy;
|
||||||
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||||
@@ -31,7 +33,7 @@ use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
|||||||
*
|
*
|
||||||
* $response = $apiGateway->send($request);
|
* $response = $apiGateway->send($request);
|
||||||
*/
|
*/
|
||||||
final readonly class CreateRecipientApiRequest implements ApiRequest, HasPayload
|
final readonly class CreateRecipientApiRequest implements ApiRequest, HasPayload, HasAuth
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private RapidMailConfig $config,
|
private RapidMailConfig $config,
|
||||||
@@ -71,13 +73,14 @@ final readonly class CreateRecipientApiRequest implements ApiRequest, HasPayload
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getAuth(): AuthConfig
|
||||||
|
{
|
||||||
|
return AuthConfig::basic($this->config->username, $this->config->password);
|
||||||
|
}
|
||||||
|
|
||||||
public function getHeaders(): Headers
|
public function getHeaders(): Headers
|
||||||
{
|
{
|
||||||
// Basic Auth
|
|
||||||
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
|
|
||||||
|
|
||||||
return new Headers([
|
return new Headers([
|
||||||
'Authorization' => "Basic {$credentials}",
|
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
'Accept' => 'application/json',
|
'Accept' => 'application/json',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ declare(strict_types=1);
|
|||||||
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
||||||
|
|
||||||
use App\Framework\ApiGateway\ApiRequest;
|
use App\Framework\ApiGateway\ApiRequest;
|
||||||
|
use App\Framework\ApiGateway\HasAuth;
|
||||||
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
||||||
use App\Framework\Core\ValueObjects\Duration;
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
use App\Framework\Http\Headers;
|
use App\Framework\Http\Headers;
|
||||||
use App\Framework\Http\Method as HttpMethod;
|
use App\Framework\Http\Method as HttpMethod;
|
||||||
use App\Framework\Http\Url\Url;
|
use App\Framework\Http\Url\Url;
|
||||||
|
use App\Framework\HttpClient\AuthConfig;
|
||||||
use App\Framework\Retry\ExponentialBackoffStrategy;
|
use App\Framework\Retry\ExponentialBackoffStrategy;
|
||||||
use App\Framework\Retry\RetryStrategy;
|
use App\Framework\Retry\RetryStrategy;
|
||||||
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||||
@@ -26,7 +28,7 @@ use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
|||||||
*
|
*
|
||||||
* $response = $apiGateway->send($request);
|
* $response = $apiGateway->send($request);
|
||||||
*/
|
*/
|
||||||
final readonly class DeleteRecipientApiRequest implements ApiRequest
|
final readonly class DeleteRecipientApiRequest implements ApiRequest, HasAuth
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private RapidMailConfig $config,
|
private RapidMailConfig $config,
|
||||||
@@ -61,13 +63,14 @@ final readonly class DeleteRecipientApiRequest implements ApiRequest
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getAuth(): AuthConfig
|
||||||
|
{
|
||||||
|
return AuthConfig::basic($this->config->username, $this->config->password);
|
||||||
|
}
|
||||||
|
|
||||||
public function getHeaders(): Headers
|
public function getHeaders(): Headers
|
||||||
{
|
{
|
||||||
// Basic Auth
|
|
||||||
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
|
|
||||||
|
|
||||||
return new Headers([
|
return new Headers([
|
||||||
'Authorization' => "Basic {$credentials}",
|
|
||||||
'Accept' => 'application/json',
|
'Accept' => 'application/json',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ declare(strict_types=1);
|
|||||||
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
||||||
|
|
||||||
use App\Framework\ApiGateway\ApiRequest;
|
use App\Framework\ApiGateway\ApiRequest;
|
||||||
|
use App\Framework\ApiGateway\HasAuth;
|
||||||
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
||||||
use App\Framework\Core\ValueObjects\Duration;
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
use App\Framework\Http\Headers;
|
use App\Framework\Http\Headers;
|
||||||
use App\Framework\Http\Method as HttpMethod;
|
use App\Framework\Http\Method as HttpMethod;
|
||||||
use App\Framework\Http\Url\Url;
|
use App\Framework\Http\Url\Url;
|
||||||
|
use App\Framework\HttpClient\AuthConfig;
|
||||||
use App\Framework\Retry\ExponentialBackoffStrategy;
|
use App\Framework\Retry\ExponentialBackoffStrategy;
|
||||||
use App\Framework\Retry\RetryStrategy;
|
use App\Framework\Retry\RetryStrategy;
|
||||||
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||||
@@ -26,7 +28,7 @@ use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
|||||||
*
|
*
|
||||||
* $response = $apiGateway->send($request);
|
* $response = $apiGateway->send($request);
|
||||||
*/
|
*/
|
||||||
final readonly class GetRecipientApiRequest implements ApiRequest
|
final readonly class GetRecipientApiRequest implements ApiRequest, HasAuth
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private RapidMailConfig $config,
|
private RapidMailConfig $config,
|
||||||
@@ -61,13 +63,14 @@ final readonly class GetRecipientApiRequest implements ApiRequest
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getAuth(): AuthConfig
|
||||||
|
{
|
||||||
|
return AuthConfig::basic($this->config->username, $this->config->password);
|
||||||
|
}
|
||||||
|
|
||||||
public function getHeaders(): Headers
|
public function getHeaders(): Headers
|
||||||
{
|
{
|
||||||
// Basic Auth
|
|
||||||
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
|
|
||||||
|
|
||||||
return new Headers([
|
return new Headers([
|
||||||
'Authorization' => "Basic {$credentials}",
|
|
||||||
'Accept' => 'application/json',
|
'Accept' => 'application/json',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ declare(strict_types=1);
|
|||||||
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
||||||
|
|
||||||
use App\Framework\ApiGateway\ApiRequest;
|
use App\Framework\ApiGateway\ApiRequest;
|
||||||
|
use App\Framework\ApiGateway\HasAuth;
|
||||||
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
||||||
use App\Framework\Core\ValueObjects\Duration;
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
use App\Framework\Http\Headers;
|
use App\Framework\Http\Headers;
|
||||||
use App\Framework\Http\Method as HttpMethod;
|
use App\Framework\Http\Method as HttpMethod;
|
||||||
use App\Framework\Http\Url\Url;
|
use App\Framework\Http\Url\Url;
|
||||||
|
use App\Framework\HttpClient\AuthConfig;
|
||||||
use App\Framework\Retry\ExponentialBackoffStrategy;
|
use App\Framework\Retry\ExponentialBackoffStrategy;
|
||||||
use App\Framework\Retry\RetryStrategy;
|
use App\Framework\Retry\RetryStrategy;
|
||||||
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||||
@@ -28,7 +30,7 @@ use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
|||||||
*
|
*
|
||||||
* $response = $apiGateway->send($request);
|
* $response = $apiGateway->send($request);
|
||||||
*/
|
*/
|
||||||
final readonly class SearchRecipientsApiRequest implements ApiRequest
|
final readonly class SearchRecipientsApiRequest implements ApiRequest, HasAuth
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private RapidMailConfig $config,
|
private RapidMailConfig $config,
|
||||||
@@ -84,13 +86,14 @@ final readonly class SearchRecipientsApiRequest implements ApiRequest
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getAuth(): AuthConfig
|
||||||
|
{
|
||||||
|
return AuthConfig::basic($this->config->username, $this->config->password);
|
||||||
|
}
|
||||||
|
|
||||||
public function getHeaders(): Headers
|
public function getHeaders(): Headers
|
||||||
{
|
{
|
||||||
// Basic Auth
|
|
||||||
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
|
|
||||||
|
|
||||||
return new Headers([
|
return new Headers([
|
||||||
'Authorization' => "Basic {$credentials}",
|
|
||||||
'Accept' => 'application/json',
|
'Accept' => 'application/json',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ declare(strict_types=1);
|
|||||||
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
||||||
|
|
||||||
use App\Framework\ApiGateway\ApiRequest;
|
use App\Framework\ApiGateway\ApiRequest;
|
||||||
|
use App\Framework\ApiGateway\HasAuth;
|
||||||
use App\Framework\ApiGateway\HasPayload;
|
use App\Framework\ApiGateway\HasPayload;
|
||||||
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
||||||
use App\Framework\Core\ValueObjects\Duration;
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
use App\Framework\Http\Headers;
|
use App\Framework\Http\Headers;
|
||||||
use App\Framework\Http\Method as HttpMethod;
|
use App\Framework\Http\Method as HttpMethod;
|
||||||
use App\Framework\Http\Url\Url;
|
use App\Framework\Http\Url\Url;
|
||||||
|
use App\Framework\HttpClient\AuthConfig;
|
||||||
use App\Framework\Retry\ExponentialBackoffStrategy;
|
use App\Framework\Retry\ExponentialBackoffStrategy;
|
||||||
use App\Framework\Retry\RetryStrategy;
|
use App\Framework\Retry\RetryStrategy;
|
||||||
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||||
@@ -31,7 +33,7 @@ use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
|||||||
*
|
*
|
||||||
* $response = $apiGateway->send($request);
|
* $response = $apiGateway->send($request);
|
||||||
*/
|
*/
|
||||||
final readonly class UpdateRecipientApiRequest implements ApiRequest, HasPayload
|
final readonly class UpdateRecipientApiRequest implements ApiRequest, HasPayload, HasAuth
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private RapidMailConfig $config,
|
private RapidMailConfig $config,
|
||||||
@@ -72,13 +74,14 @@ final readonly class UpdateRecipientApiRequest implements ApiRequest, HasPayload
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getAuth(): AuthConfig
|
||||||
|
{
|
||||||
|
return AuthConfig::basic($this->config->username, $this->config->password);
|
||||||
|
}
|
||||||
|
|
||||||
public function getHeaders(): Headers
|
public function getHeaders(): Headers
|
||||||
{
|
{
|
||||||
// Basic Auth
|
|
||||||
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
|
|
||||||
|
|
||||||
return new Headers([
|
return new Headers([
|
||||||
'Authorization' => "Basic {$credentials}",
|
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
'Accept' => 'application/json',
|
'Accept' => 'application/json',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ declare(strict_types=1);
|
|||||||
namespace App\Infrastructure\Api\Shopify;
|
namespace App\Infrastructure\Api\Shopify;
|
||||||
|
|
||||||
use App\Framework\ApiGateway\ApiRequest;
|
use App\Framework\ApiGateway\ApiRequest;
|
||||||
|
use App\Framework\ApiGateway\HasAuth;
|
||||||
|
use App\Framework\ApiGateway\HasPayload;
|
||||||
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
||||||
use App\Framework\Core\ValueObjects\Duration;
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
use App\Framework\Http\Headers;
|
use App\Framework\Http\Headers;
|
||||||
use App\Framework\Http\Method as HttpMethod;
|
use App\Framework\Http\Method as HttpMethod;
|
||||||
use App\Framework\Http\Url\Url;
|
use App\Framework\Http\Url\Url;
|
||||||
|
use App\Framework\HttpClient\AuthConfig;
|
||||||
use App\Framework\Retry\RetryStrategy;
|
use App\Framework\Retry\RetryStrategy;
|
||||||
use App\Infrastructure\Api\Shopify\ValueObjects\{ShopifyApiKey, ShopifyStore};
|
use App\Infrastructure\Api\Shopify\ValueObjects\{ShopifyApiKey, ShopifyStore};
|
||||||
|
|
||||||
@@ -31,7 +34,7 @@ use App\Infrastructure\Api\Shopify\ValueObjects\{ShopifyApiKey, ShopifyStore};
|
|||||||
*
|
*
|
||||||
* $response = $apiGateway->send($request);
|
* $response = $apiGateway->send($request);
|
||||||
*/
|
*/
|
||||||
final readonly class CreateOrderApiRequest implements ApiRequest
|
final readonly class CreateOrderApiRequest implements ApiRequest, HasPayload, HasAuth
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ShopifyApiKey $apiKey,
|
private ShopifyApiKey $apiKey,
|
||||||
@@ -73,10 +76,16 @@ final readonly class CreateOrderApiRequest implements ApiRequest
|
|||||||
return $this->retryStrategy;
|
return $this->retryStrategy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getAuth(): AuthConfig
|
||||||
|
{
|
||||||
|
return AuthConfig::custom([
|
||||||
|
'X-Shopify-Access-Token' => $this->apiKey->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function getHeaders(): Headers
|
public function getHeaders(): Headers
|
||||||
{
|
{
|
||||||
return new Headers([
|
return new Headers([
|
||||||
'X-Shopify-Access-Token' => $this->apiKey->value,
|
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
'Accept' => 'application/json',
|
'Accept' => 'application/json',
|
||||||
]);
|
]);
|
||||||
|
|||||||
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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Framework\QrCode\QrCodeGenerator;
|
use App\Framework\QrCode\QrCodeGenerator;
|
||||||
|
use App\Framework\QrCode\QrCodeRenderer;
|
||||||
use App\Framework\QrCode\ValueObjects\EncodingMode;
|
use App\Framework\QrCode\ValueObjects\EncodingMode;
|
||||||
use App\Framework\QrCode\ValueObjects\ErrorCorrectionLevel;
|
use App\Framework\QrCode\ValueObjects\ErrorCorrectionLevel;
|
||||||
use App\Framework\QrCode\ValueObjects\QrCodeConfig;
|
use App\Framework\QrCode\ValueObjects\QrCodeConfig;
|
||||||
@@ -140,3 +141,103 @@ test('supports different data types', function () {
|
|||||||
->and($matrix3)->toBeInstanceOf(\App\Framework\QrCode\ValueObjects\QrCodeMatrix::class)
|
->and($matrix3)->toBeInstanceOf(\App\Framework\QrCode\ValueObjects\QrCodeMatrix::class)
|
||||||
->and($matrix4)->toBeInstanceOf(\App\Framework\QrCode\ValueObjects\QrCodeMatrix::class);
|
->and($matrix4)->toBeInstanceOf(\App\Framework\QrCode\ValueObjects\QrCodeMatrix::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Instance method tests
|
||||||
|
test('can generate SVG using instance method', function () {
|
||||||
|
$renderer = new QrCodeRenderer();
|
||||||
|
$generator = new QrCodeGenerator($renderer);
|
||||||
|
$data = 'Hello World';
|
||||||
|
|
||||||
|
$svg = $generator->generateSvg($data);
|
||||||
|
|
||||||
|
expect($svg)->toBeString()
|
||||||
|
->and($svg)->toContain('<svg')
|
||||||
|
->and($svg)->toContain('</svg>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can generate data URI using instance method', function () {
|
||||||
|
$renderer = new QrCodeRenderer();
|
||||||
|
$generator = new QrCodeGenerator($renderer);
|
||||||
|
$data = 'Hello World';
|
||||||
|
|
||||||
|
$dataUri = $generator->generateDataUri($data);
|
||||||
|
|
||||||
|
expect($dataUri)->toBeString()
|
||||||
|
->and($dataUri)->toStartWith('data:image/svg+xml;base64,');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can analyze data and get recommendations', function () {
|
||||||
|
$renderer = new QrCodeRenderer();
|
||||||
|
$generator = new QrCodeGenerator($renderer);
|
||||||
|
$data = 'Hello World';
|
||||||
|
|
||||||
|
$analysis = $generator->analyzeData($data);
|
||||||
|
|
||||||
|
expect($analysis)->toBeArray()
|
||||||
|
->and($analysis)->toHaveKey('dataLength')
|
||||||
|
->and($analysis)->toHaveKey('dataType')
|
||||||
|
->and($analysis)->toHaveKey('recommendedVersion')
|
||||||
|
->and($analysis)->toHaveKey('recommendedErrorLevel')
|
||||||
|
->and($analysis)->toHaveKey('encodingMode')
|
||||||
|
->and($analysis)->toHaveKey('matrixSize')
|
||||||
|
->and($analysis)->toHaveKey('capacity')
|
||||||
|
->and($analysis)->toHaveKey('efficiency')
|
||||||
|
->and($analysis['dataLength'])->toBe(strlen($data))
|
||||||
|
->and($analysis['recommendedVersion'])->toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analyzeData detects URL type', function () {
|
||||||
|
$renderer = new QrCodeRenderer();
|
||||||
|
$generator = new QrCodeGenerator($renderer);
|
||||||
|
$url = 'https://example.com/test';
|
||||||
|
|
||||||
|
$analysis = $generator->analyzeData($url);
|
||||||
|
|
||||||
|
expect($analysis['dataType'])->toBe('url');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analyzeData detects TOTP type', function () {
|
||||||
|
$renderer = new QrCodeRenderer();
|
||||||
|
$generator = new QrCodeGenerator($renderer);
|
||||||
|
$totpUri = 'otpauth://totp/TestApp:user@example.com?secret=JBSWY3DPEHPK3PXP';
|
||||||
|
|
||||||
|
$analysis = $generator->analyzeData($totpUri);
|
||||||
|
|
||||||
|
expect($analysis['dataType'])->toBe('totp');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can generate TOTP QR code', function () {
|
||||||
|
$renderer = new QrCodeRenderer();
|
||||||
|
$generator = new QrCodeGenerator($renderer);
|
||||||
|
$totpUri = 'otpauth://totp/TestApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=TestApp';
|
||||||
|
|
||||||
|
$svg = $generator->generateTotpQrCode($totpUri);
|
||||||
|
|
||||||
|
expect($svg)->toBeString()
|
||||||
|
->and($svg)->toContain('<svg')
|
||||||
|
->and($svg)->toContain('</svg>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateSvg with explicit version', function () {
|
||||||
|
$renderer = new QrCodeRenderer();
|
||||||
|
$generator = new QrCodeGenerator($renderer);
|
||||||
|
$data = 'Test';
|
||||||
|
$version = QrCodeVersion::fromNumber(2);
|
||||||
|
|
||||||
|
$svg = $generator->generateSvg($data, ErrorCorrectionLevel::M, $version);
|
||||||
|
|
||||||
|
expect($svg)->toBeString()
|
||||||
|
->and($svg)->toContain('<svg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateDataUri with explicit version', function () {
|
||||||
|
$renderer = new QrCodeRenderer();
|
||||||
|
$generator = new QrCodeGenerator($renderer);
|
||||||
|
$data = 'Test';
|
||||||
|
$version = QrCodeVersion::fromNumber(3);
|
||||||
|
|
||||||
|
$dataUri = $generator->generateDataUri($data, ErrorCorrectionLevel::M, $version);
|
||||||
|
|
||||||
|
expect($dataUri)->toBeString()
|
||||||
|
->and($dataUri)->toStartWith('data:image/svg+xml;base64,');
|
||||||
|
});
|
||||||
|
|||||||
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