From e598309c48d811b7d6652452f7b2220095728cb2 Mon Sep 17 00:00:00 2001 From: Michael Schiemer Date: Sun, 2 Nov 2025 03:29:23 +0100 Subject: [PATCH] feat: improve WireGuard client management and framework initialization - Improve WireGuard client IP calculation logic (find next available IP) - Add local wireguard-clients directory for storing client configs - Integrate Redis pool into CacheInitializer - Improve ContainerBootstrapper with better imports and Redis pool - Add monitoring role tags for better task organization - Update WireGuard documentation - Store generated WireGuard client configs locally --- .../playbooks/add-wireguard-client.yml | 59 +++++++++++++++---- .../roles/monitoring/defaults/main.yml | 1 + .../ansible/roles/monitoring/tasks/main.yml | 22 +++++++ .../ansible/templates/monitoring.env.j2 | 5 +- .../wireguard-clients/grafana-test.conf | 27 +++++++++ .../ansible/wireguard-clients/mikepc.conf | 27 +++++++++ .../stacks/monitoring/docker-compose.yml | 2 + docs/deployment/WIREGUARD-FUTURE-SECURITY.md | 20 ++++++- docs/deployment/WIREGUARD-SETUP.md | 6 +- src/Framework/Cache/CacheInitializer.php | 14 ++--- src/Framework/Core/ContainerBootstrapper.php | 30 ++++++---- .../Http/Session/SessionInitializer.php | 7 ++- 12 files changed, 183 insertions(+), 37 deletions(-) create mode 100644 deployment/ansible/wireguard-clients/grafana-test.conf create mode 100644 deployment/ansible/wireguard-clients/mikepc.conf diff --git a/deployment/ansible/playbooks/add-wireguard-client.yml b/deployment/ansible/playbooks/add-wireguard-client.yml index 3d0f39ab..63362e01 100755 --- a/deployment/ansible/playbooks/add-wireguard-client.yml +++ b/deployment/ansible/playbooks/add-wireguard-client.yml @@ -9,6 +9,7 @@ wireguard_config_path: "/etc/wireguard" wireguard_config_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}.conf" wireguard_client_configs_path: "/etc/wireguard/clients" + wireguard_local_client_configs_dir: "{{ playbook_dir }}/../wireguard-clients" pre_tasks: - name: Set WireGuard network @@ -60,14 +61,28 @@ set_fact: server_vpn_ip: "{{ (wireguard_server_config_read.content | b64decode | regex_search('Address = ([0-9.]+)', '\\1')) | first | default('10.8.0.1') }}" - - name: Count existing clients in config + - name: Extract WireGuard server IP octets set_fact: - existing_clients_count: "{{ (wireguard_server_config_read.content | b64decode | regex_findall('\\[Peer\\]') | length) }}" + wireguard_server_ip_octets: "{{ server_vpn_ip.split('.') }}" + when: client_ip == "" + + - name: Gather existing client addresses + set_fact: + existing_client_ips: "{{ (wireguard_server_config_read.content | b64decode | regex_findall('AllowedIPs = ([0-9A-Za-z.]+)/32', '\\1')) }}" when: client_ip == "" - name: Calculate client IP if not provided + vars: + existing_last_octets: "{{ (existing_client_ips | default([])) | map('regex_replace', '^(?:\\d+\\.\\d+\\.\\d+\\.)', '') | select('match', '^[0-9]+$') | map('int') | list }}" + server_last_octet: "{{ wireguard_server_ip_octets[3] | int }}" + next_octet_candidate: "{{ (existing_last_octets + [server_last_octet]) | map('int') | list | max + 1 if (existing_last_octets + [server_last_octet]) else server_last_octet + 1 }}" set_fact: - client_ip: "{{ server_vpn_ip | regex_replace('^(\\d+\\.\\d+\\.\\d+\\.)\\d+$', '\\1') }}{{ (server_vpn_ip | regex_replace('^(\\d+\\.\\d+\\.\\d+\\.)(\\d+)', '\\2') | int) + (existing_clients_count | default(1)) | int }}" + client_ip: "{{ [ + wireguard_server_ip_octets[0], + wireguard_server_ip_octets[1], + wireguard_server_ip_octets[2], + next_octet_candidate + ] | join('.') }}" when: client_ip == "" - name: Generate client private key @@ -84,12 +99,6 @@ changed_when: false no_log: yes - - name: Check if client already exists in config - shell: "grep -q '{{ client_name }}' {{ wireguard_config_file }} || echo 'not found'" - register: client_exists_check - changed_when: false - failed_when: false - - name: Add client to WireGuard server config blockinfile: path: "{{ wireguard_config_file }}" @@ -99,7 +108,7 @@ PublicKey = {{ client_public_key.stdout }} AllowedIPs = {{ client_ip }}/32 marker: "# {mark} ANSIBLE MANAGED BLOCK - Client: {{ client_name }}" - when: "'not found' in client_exists_check.stdout" + register: wireguard_client_block - name: Ensure client configs directory exists file: @@ -109,6 +118,15 @@ owner: root group: root + - name: Ensure local client configs directory exists + file: + path: "{{ wireguard_local_client_configs_dir }}" + state: directory + mode: '0700' + delegate_to: localhost + become: no + run_once: true + - name: Get server public key shell: "cat {{ wireguard_config_path }}/{{ wireguard_interface }}_private.key | wg pubkey" register: server_public_key_cmd @@ -124,6 +142,20 @@ owner: root group: root + - name: Download client configuration to control machine + fetch: + src: "{{ wireguard_client_configs_path }}/{{ client_name }}.conf" + dest: "{{ wireguard_local_client_configs_dir }}/{{ client_name }}.conf" + flat: yes + mode: '0600' + + - name: Ensure local client configuration has strict permissions + file: + path: "{{ wireguard_local_client_configs_dir }}/{{ client_name }}.conf" + mode: '0600' + delegate_to: localhost + become: no + - name: Read WireGuard server config to find server IP slurp: src: "{{ wireguard_config_file }}" @@ -133,7 +165,7 @@ systemd: name: "wg-quick@{{ wireguard_interface }}" state: restarted - when: "'not found' in client_exists_check.stdout" + when: wireguard_client_block.changed - name: Display client configuration debug: @@ -144,6 +176,9 @@ Client Configuration File: {{ wireguard_client_configs_path }}/{{ client_name }}.conf + + Local Copy: + {{ wireguard_local_client_configs_dir }}/{{ client_name }}.conf Client IP: {{ client_ip }} Server Endpoint: {{ server_external_ip_content }}:{{ wireguard_port }} @@ -166,4 +201,4 @@ - name: Display QR code debug: msg: "{{ qr_code.stdout }}" - when: qr_code.rc == 0 \ No newline at end of file + when: qr_code.rc == 0 diff --git a/deployment/ansible/roles/monitoring/defaults/main.yml b/deployment/ansible/roles/monitoring/defaults/main.yml index 5420d9f3..a25845e8 100644 --- a/deployment/ansible/roles/monitoring/defaults/main.yml +++ b/deployment/ansible/roles/monitoring/defaults/main.yml @@ -3,3 +3,4 @@ monitoring_stack_path: "{{ stacks_base_path }}/monitoring" monitoring_wait_timeout: "{{ wait_timeout | default(60) }}" monitoring_env_template: "{{ role_path }}/../../templates/monitoring.env.j2" monitoring_vault_file: "{{ role_path }}/../../secrets/production.vault.yml" +monitoring_vpn_ip_whitelist: "{{ wireguard_network_default | default('10.8.0.0/24') }}" diff --git a/deployment/ansible/roles/monitoring/tasks/main.yml b/deployment/ansible/roles/monitoring/tasks/main.yml index 146c58a6..1a6663dc 100644 --- a/deployment/ansible/roles/monitoring/tasks/main.yml +++ b/deployment/ansible/roles/monitoring/tasks/main.yml @@ -5,6 +5,8 @@ delegate_to: localhost register: monitoring_vault_stat become: no + tags: + - monitoring - name: Optionally load monitoring secrets from vault include_vars: @@ -13,16 +15,22 @@ no_log: yes delegate_to: localhost become: no + tags: + - monitoring - name: Set Grafana admin password from vault or generate set_fact: grafana_admin_password: "{{ vault_grafana_admin_password | default(lookup('password', '/dev/null length=25 chars=ascii_letters,digits')) }}" no_log: yes + tags: + - monitoring - name: Set Prometheus password from vault or generate set_fact: prometheus_password: "{{ vault_prometheus_password | default(lookup('password', '/dev/null length=25 chars=ascii_letters,digits')) }}" no_log: yes + tags: + - monitoring - name: Generate Prometheus BasicAuth hash shell: | @@ -30,17 +38,23 @@ register: prometheus_auth_hash changed_when: false no_log: yes + tags: + - monitoring - name: Set Prometheus BasicAuth string set_fact: prometheus_auth: "admin:{{ prometheus_auth_hash.stdout }}" no_log: yes + tags: + - monitoring - name: Ensure monitoring stack directory exists file: path: "{{ monitoring_stack_path }}" state: directory mode: '0755' + tags: + - monitoring - name: Create monitoring stack .env file template: @@ -50,6 +64,8 @@ group: "{{ ansible_user }}" mode: '0600' no_log: yes + tags: + - monitoring - name: Deploy Monitoring stack community.docker.docker_compose_v2: @@ -57,12 +73,18 @@ state: present pull: always register: monitoring_compose_result + tags: + - monitoring - name: Wait for Monitoring to be ready wait_for: timeout: "{{ monitoring_wait_timeout }}" when: monitoring_compose_result.changed + tags: + - monitoring - name: Record monitoring deployment facts set_fact: monitoring_stack_changed: "{{ monitoring_compose_result.changed | default(false) }}" + tags: + - monitoring diff --git a/deployment/ansible/templates/monitoring.env.j2 b/deployment/ansible/templates/monitoring.env.j2 index 39594abf..789c5cd8 100644 --- a/deployment/ansible/templates/monitoring.env.j2 +++ b/deployment/ansible/templates/monitoring.env.j2 @@ -4,6 +4,9 @@ # Domain Configuration DOMAIN={{ app_domain }} +# VPN Access Control +MONITORING_VPN_IP_WHITELIST={{ monitoring_vpn_ip_whitelist }} + # Grafana Configuration GRAFANA_ADMIN_USER={{ grafana_admin_user | default('admin') }} GRAFANA_ADMIN_PASSWORD={{ grafana_admin_password }} @@ -18,4 +21,4 @@ GRAFANA_PLUGINS={{ grafana_plugins | default('') }} # Prometheus BasicAuth # Format: username:hashed_password # Note: Dollar signs are escaped for Docker Compose ($$ becomes $) -PROMETHEUS_AUTH={{ prometheus_auth | replace('$', '$$') }} \ No newline at end of file +PROMETHEUS_AUTH={{ prometheus_auth | replace('$', '$$') }} diff --git a/deployment/ansible/wireguard-clients/grafana-test.conf b/deployment/ansible/wireguard-clients/grafana-test.conf new file mode 100644 index 00000000..73dcfc9f --- /dev/null +++ b/deployment/ansible/wireguard-clients/grafana-test.conf @@ -0,0 +1,27 @@ +# WireGuard Client Configuration for grafana-test +# Generated by Ansible - DO NOT EDIT MANUALLY + +[Interface] +# Client private key +PrivateKey = uMhNKh+Wi0aykTnazfSJD6l7Wc2V1Pe+7rFtFcnfynw= + +# Client IP address in VPN network +Address = 10.8.0.4/24 + +# DNS server (optional) +DNS = 1.1.1.1, 8.8.8.8 + +[Peer] +# Server public key +PublicKey = hT3OCWZ6ElX79YdAdexSsZnbWLzRM/5szk+XNEBUaS8= + +# Server endpoint +Endpoint = 94.16.110.151:51820 + +# Allowed IPs (routes through VPN) +# IMPORTANT: Only VPN network is routed through VPN by default +# SSH access via normal IP (94.16.110.151) remains available +AllowedIPs = 10.8.0.0/24 + +# Keep connection alive +PersistentKeepalive = 25 \ No newline at end of file diff --git a/deployment/ansible/wireguard-clients/mikepc.conf b/deployment/ansible/wireguard-clients/mikepc.conf new file mode 100644 index 00000000..c7e1464d --- /dev/null +++ b/deployment/ansible/wireguard-clients/mikepc.conf @@ -0,0 +1,27 @@ +# WireGuard Client Configuration for mikepc +# Generated by Ansible - DO NOT EDIT MANUALLY + +[Interface] +# Client private key +PrivateKey = wFxqFHe4R8IVzkAQSHaAwVfwQ2rfm5vCySZMpvPsVUQ= + +# Client IP address in VPN network +Address = 10.8.0.3/24 + +# DNS server (optional) +DNS = 1.1.1.1, 8.8.8.8 + +[Peer] +# Server public key +PublicKey = hT3OCWZ6ElX79YdAdexSsZnbWLzRM/5szk+XNEBUaS8= + +# Server endpoint +Endpoint = 94.16.110.151:51820 + +# Allowed IPs (routes through VPN) +# IMPORTANT: Only VPN network is routed through VPN by default +# SSH access via normal IP (94.16.110.151) remains available +AllowedIPs = 10.8.0.0/24 + +# Keep connection alive +PersistentKeepalive = 25 \ No newline at end of file diff --git a/deployment/stacks/monitoring/docker-compose.yml b/deployment/stacks/monitoring/docker-compose.yml index 2eed2740..542d7645 100644 --- a/deployment/stacks/monitoring/docker-compose.yml +++ b/deployment/stacks/monitoring/docker-compose.yml @@ -75,6 +75,8 @@ services: - "traefik.http.routers.grafana.entrypoints=websecure" - "traefik.http.routers.grafana.tls=true" - "traefik.http.routers.grafana.tls.certresolver=letsencrypt" + - "traefik.http.middlewares.grafana-vpn-only.ipwhitelist.sourcerange=${MONITORING_VPN_IP_WHITELIST:-10.8.0.0/24}" + - "traefik.http.routers.grafana.middlewares=grafana-vpn-only" - "traefik.http.services.grafana.loadbalancer.server.port=3000" depends_on: prometheus: diff --git a/docs/deployment/WIREGUARD-FUTURE-SECURITY.md b/docs/deployment/WIREGUARD-FUTURE-SECURITY.md index 07239d22..ec99a069 100644 --- a/docs/deployment/WIREGUARD-FUTURE-SECURITY.md +++ b/docs/deployment/WIREGUARD-FUTURE-SECURITY.md @@ -38,6 +38,24 @@ Folgende Dienste sollen später nur noch über VPN erreichbar sein: - Aktuell: `https://portainer.michaelschiemer.de` (öffentlich) - Zukünftig: Nur `https://10.8.0.1:9443` über VPN +## Aktueller Test: Grafana nur per VPN + +- Realisiert über Traefik-IP-Whitelist (`grafana-vpn-only` Middleware) +- Whitelist-Wert: `MONITORING_VPN_IP_WHITELIST` in `deployment/stacks/monitoring/.env` (Ansible default `monitoring_vpn_ip_whitelist`) +- Ziel: Erst Grafana absichern, später Prometheus/Portainer nachziehen + +### Rollout-Schritte +1. Neue Stacks auf den Server syncen: \ + `ansible-playbook -i inventory/production.yml playbooks/sync-stacks.yml` +2. Monitoring-Rolle komplett laufen lassen (die `.env` muss generiert werden): \ + `ansible-playbook -i inventory/production.yml playbooks/setup-infrastructure.yml --tags monitoring` +3. Prüfen, dass `monitoring_stack_changed` in der Zusammenfassung `true` ist oder der Grafana-Container neu gestartet wurde + +### Verifikation +- Ohne VPN (öffentliche IP): `curl -I https://grafana.michaelschiemer.de` → `403 Forbidden` +- Mit WireGuard (`10.8.0.x` Client): Grafana im Browser laden, Login funktioniert +- Optional: Traefik-Logs prüfen (`docker compose -f ~/deployment/stacks/traefik/docker-compose.yml logs -f traefik`) für geblockte IPs + --- ## Geplante Implementierungsschritte @@ -210,4 +228,4 @@ Nach der Umsetzung sollte aktualisiert werden: --- -**Hinweis**: Diese Härtung wird erst durchgeführt, wenn das VPN stabil läuft und alle notwendigen Clients konfiguriert sind. \ No newline at end of file +**Hinweis**: Diese Härtung wird erst durchgeführt, wenn das VPN stabil läuft und alle notwendigen Clients konfiguriert sind. diff --git a/docs/deployment/WIREGUARD-SETUP.md b/docs/deployment/WIREGUARD-SETUP.md index 7eaa68a6..2371bc9f 100644 --- a/docs/deployment/WIREGUARD-SETUP.md +++ b/docs/deployment/WIREGUARD-SETUP.md @@ -488,6 +488,10 @@ ansible-playbook -i inventory/production.yml playbooks/add-wireguard-client.yml **Dokumentation**: Siehe `deployment/ansible/playbooks/README-WIREGUARD.md` +> Nach dem Lauf liegt die Client-Konfiguration zusätzlich lokal im Repository unter +> `deployment/ansible/wireguard-clients/.conf` – ideal zum direkten +> Import auf deinem Admin-Rechner (Datei bleibt mit `chmod 600` geschützt). + --- ## Verzeichnisstruktur @@ -555,4 +559,4 @@ Bei Problemen: --- **Zuletzt aktualisiert**: 2025-10-31 -**Version**: 1.0 \ No newline at end of file +**Version**: 1.0 diff --git a/src/Framework/Cache/CacheInitializer.php b/src/Framework/Cache/CacheInitializer.php index 62378e73..de4b3204 100644 --- a/src/Framework/Cache/CacheInitializer.php +++ b/src/Framework/Cache/CacheInitializer.php @@ -19,6 +19,7 @@ use App\Framework\DI\Initializer; use App\Framework\Performance\Contracts\PerformanceCollectorInterface; use App\Framework\Redis\RedisConfig; use App\Framework\Redis\RedisConnection; +use App\Framework\Redis\RedisConnectionPool; use App\Framework\Serializer\Json\JsonSerializer; use App\Framework\Serializer\Php\PhpSerializer; @@ -27,10 +28,9 @@ final readonly class CacheInitializer public function __construct( private PerformanceCollectorInterface $performanceCollector, private Container $container, - private ?AsyncService $asyncService = null, #private CacheMetricsInterface $cacheMetrics, - private string $redisHost = 'redis', - private int $redisPort = 6379, + private RedisConnectionPool $redisConnectionPool, + private ?AsyncService $asyncService = null, private int $compressionLevel = -1, private int $minCompressionLength = 1024, private bool $enableAsync = true @@ -61,12 +61,8 @@ final readonly class CacheInitializer throw new \RuntimeException('Redis extension is not loaded. Please install php-redis extension or use alternative cache drivers.'); } - $redisConfig = new RedisConfig( - host: $this->redisHost, - port: $this->redisPort, - database: 1 // Use DB 1 for cache - ); - $redisConnection = new RedisConnection($redisConfig, 'cache'); + $redisConnection = $this->redisConnectionPool->getCacheConnection(); + #$redisConnection = new RedisConnection($redisConfig, 'cache'); $redisCache = new GeneralCache(new RedisCache($redisConnection), $serializer, $compression); } catch (\Throwable $e) { // Fallback to file cache if Redis is not available diff --git a/src/Framework/Core/ContainerBootstrapper.php b/src/Framework/Core/ContainerBootstrapper.php index 0cba988f..5cdcb503 100644 --- a/src/Framework/Core/ContainerBootstrapper.php +++ b/src/Framework/Core/ContainerBootstrapper.php @@ -13,12 +13,21 @@ use App\Framework\DI\ContainerCompiler; use App\Framework\DI\DefaultContainer; use App\Framework\DI\DependencyResolver; use App\Framework\Discovery\DiscoveryServiceBootstrapper; +use App\Framework\Http\Parser\HttpRequestParser; +use App\Framework\Http\Parser\ParserCache; +use App\Framework\Http\Request; +use App\Framework\Http\RequestFactory; use App\Framework\Http\ResponseEmitter; use App\Framework\Logging\DefaultLogger; +use App\Framework\Logging\Formatter\DevelopmentFormatter; +use App\Framework\Logging\Handlers\ConsoleHandler; use App\Framework\Logging\Logger; use App\Framework\Logging\LoggerInitializer; +use App\Framework\Logging\LogLevel; use App\Framework\Performance\Contracts\PerformanceCollectorInterface; use App\Framework\Reflection\CachedReflectionProvider; +use App\Framework\Redis\RedisPoolInitializer; +use App\Framework\Config\Environment; final readonly class ContainerBootstrapper { @@ -149,19 +158,20 @@ final readonly class ContainerBootstrapper $container->instance(Logger::class, $logger); $container->instance(PerformanceCollectorInterface::class, $collector); - $container->instance(Cache::class, new CacheInitializer($collector, $container)()); + $pool = new RedisPoolInitializer($container, $container->get(Environment::class))->initialize(); + $container->instance(Cache::class, new CacheInitializer($collector, $container, $pool)()); $container->instance(ResponseEmitter::class, new ResponseEmitter()); // TEMPORARY FIX: Manual RequestFactory binding until Discovery issue is resolved - $container->singleton(\App\Framework\Http\Request::class, function ($container) { + $container->singleton(Request::class, function ($container) { error_log("ContainerBootstrapper: Creating Request singleton"); // Get Cache from container (it was just registered above) - $frameworkCache = $container->get(\App\Framework\Cache\Cache::class); - $parserCache = new \App\Framework\Http\Parser\ParserCache($frameworkCache); - $parser = new \App\Framework\Http\Parser\HttpRequestParser($parserCache); - $factory = new \App\Framework\Http\RequestFactory($parser); + $frameworkCache = $container->get(Cache::class); + $parserCache = new ParserCache($frameworkCache); + $parser = new HttpRequestParser($parserCache); + $factory = new RequestFactory($parser); error_log("ContainerBootstrapper: About to call factory->createFromGlobals()"); $request = $factory->createFromGlobals(); @@ -232,15 +242,15 @@ final readonly class ContainerBootstrapper $handlers = $isMcpMode ? [new \App\Framework\Logging\Handlers\NullHandler()] : [ - new \App\Framework\Logging\Handlers\ConsoleHandler( - new \App\Framework\Logging\Formatter\DevelopmentFormatter(), - \App\Framework\Logging\LogLevel::DEBUG + new ConsoleHandler( + new DevelopmentFormatter(), + LogLevel::DEBUG ) ]; $logger = new \App\Framework\Logging\DefaultLogger( clock: $clock, - minLevel: \App\Framework\Logging\LogLevel::DEBUG, + minLevel: LogLevel::DEBUG, handlers: $handlers, processorManager: new \App\Framework\Logging\ProcessorManager(), contextManager: new \App\Framework\Logging\LogContextManager() diff --git a/src/Framework/Http/Session/SessionInitializer.php b/src/Framework/Http/Session/SessionInitializer.php index b711a28b..702bbaec 100644 --- a/src/Framework/Http/Session/SessionInitializer.php +++ b/src/Framework/Http/Session/SessionInitializer.php @@ -15,12 +15,14 @@ use App\Framework\Random\RandomGenerator; use App\Framework\Random\SecureRandomGenerator; use App\Framework\Redis\RedisConfig; use App\Framework\Redis\RedisConnection; +use App\Framework\Redis\RedisConnectionPool; use App\Framework\Security\CsrfTokenGenerator; final readonly class SessionInitializer { public function __construct( - private Container $container + private Container $container, + private RedisConnectionPool $redisConnectionPool, ) { } @@ -47,8 +49,7 @@ final readonly class SessionInitializer throw new \RuntimeException('Redis extension not loaded'); } - $redisConfig = new RedisConfig(host: 'redis', database: 3); - $redisConnection = new RedisConnection($redisConfig, 'session'); + $redisConnection = $this->redisConnectionPool->getSessionConnection(); $storage = new RedisSessionStorage($redisConnection); } catch (\Throwable $e) { // Fallback to file-based storage if Redis is not available