diff --git a/app/Console/Commands/PollMetrics.php b/app/Console/Commands/PollMetrics.php index 58c58dd..d5c82b0 100644 --- a/app/Console/Commands/PollMetrics.php +++ b/app/Console/Commands/PollMetrics.php @@ -36,10 +36,22 @@ class PollMetrics extends Command $clients = []; do { - $servers = Server::has('credential')->get(); + // Only servers with a PRESENT, non-disabled credential. A credential disabled + // between ticks drops the server here, so its cached connection is no longer polled + // (the vault refuses a fresh connect, but a reused long-lived session would not + // re-check disabled_at). Prune any cached client whose server just left the set so + // the revoked server's open session is closed promptly, not left idle until exit. + $servers = Server::withActiveCredential()->get(); + $activeIds = $servers->pluck('id')->all(); + foreach (array_keys($clients) as $id) { + if (! in_array($id, $activeIds, true)) { + $clients[$id]->disconnect(); + unset($clients[$id]); + } + } if ($servers->isEmpty()) { - $this->warn('Keine Server mit Credential — nichts zu pollen.'); + $this->warn('Keine Server mit aktivem Credential — nichts zu pollen.'); } foreach ($servers as $server) { diff --git a/app/Models/Server.php b/app/Models/Server.php index ed2aa6e..21965d1 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Str; @@ -30,4 +31,15 @@ class Server extends Model { return $this->hasOne(SshCredential::class); } + + /** + * Servers whose stored SSH credential is present AND not disabled — the live poll set. + * A disabled ("gesperrt") credential is refused by CredentialVault::resolve() at connect + * time, but the poller reuses a long-lived connection, so revocation only takes effect if + * the server also drops OUT of this query on the next tick. + */ + public function scopeWithActiveCredential(Builder $query): void + { + $query->whereHas('credential', fn (Builder $q) => $q->whereNull('disabled_at')); + } } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 154f129..29a8716 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -23,6 +23,15 @@ x-image: &image APP_DEBUG: "false" networks: - clusev + # Defense-in-depth (applies to app/reverb/queue/scheduler via <<: *image). no-new-privileges + # blocks privilege escalation through setuid binaries — the image's drop-privileges startup only + # LOWERS privileges, so it stays compatible. pids_limit is a fork-bomb backstop, set generously so + # php-fpm/queue workers never hit it under normal load. cap_drop:[ALL] and read_only are + # deliberately NOT set here: they need a per-service prod smoke test first (nginx :80 bind needs + # NET_BIND_SERVICE, php-fpm/nginx/supervisor write pid/sockets/logs), so enable them after testing. + security_opt: + - "no-new-privileges:true" + pids_limit: 1024 services: app: @@ -97,6 +106,9 @@ services: APP_INTERNAL_URL: "http://app:80" expose: - "3000" + security_opt: + - "no-new-privileges:true" + pids_limit: 512 networks: - clusev @@ -113,8 +125,10 @@ services: # Domain is dashboard-driven via on-demand TLS (Caddy asks app:/_caddy/ask), so # no SITE_ADDRESS is needed. Only the ACME contact comes from .env. ACME_EMAIL: "${ACME_EMAIL:-admin@localhost}" - # External reverse-proxy TLS: set to the upstream proxy's CIDR so Caddy trusts - # its X-Forwarded-Proto. Leave as 127.0.0.1/32 when Caddy terminates TLS itself. + # External reverse-proxy TLS: set to the upstream proxy's EXACT address (a /32, or its + # tightest CIDR) so Caddy trusts its X-Forwarded-Proto/-For. Leave as 127.0.0.1/32 when + # Caddy terminates TLS itself. SECURITY: never a broad range / 0.0.0.0/0 — a too-wide value + # lets any client forge X-Forwarded-For and defeat the IP-keyed throttles + brute-force ban. TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-127.0.0.1/32}" volumes: # Mount the DIRECTORY (not the single Caddyfile): a single-file bind mount keeps the @@ -124,6 +138,9 @@ services: - ./docker/caddy:/etc/caddy:ro - caddy-data:/data # ACME account + certs — MUST persist across recreate - caddy-config:/config + security_opt: + - "no-new-privileges:true" + pids_limit: 512 depends_on: - app - reverb @@ -147,6 +164,9 @@ services: interval: 10s timeout: 5s retries: 30 + security_opt: + - "no-new-privileges:true" + pids_limit: 1024 networks: - clusev # no host ports in prod — DB is reachable only on the internal clusev network @@ -158,6 +178,9 @@ services: command: redis-server --appendonly yes --requirepass "${REDIS_PASSWORD:?set REDIS_PASSWORD in .env}" volumes: - redis-data:/data + security_opt: + - "no-new-privileges:true" + pids_limit: 512 networks: - clusev diff --git a/docker/caddy/Caddyfile b/docker/caddy/Caddyfile index 07fd505..6abfa07 100644 --- a/docker/caddy/Caddyfile +++ b/docker/caddy/Caddyfile @@ -26,7 +26,12 @@ auto_https disable_redirects # Honor X-Forwarded-* from a trusted upstream proxy (external-TLS mode). Defaults to - # 127.0.0.1/32 (trust nothing external); set TRUSTED_PROXY_CIDR to the proxy's address. + # 127.0.0.1/32 (trust nothing external); set TRUSTED_PROXY_CIDR to the upstream proxy's + # EXACT address (a /32, or its tightest CIDR). SECURITY: never a broad range and never + # 0.0.0.0/0 — a too-wide value lets any client forge X-Forwarded-For, which then drives + # request()->ip() in the app and defeats the IP-keyed login/2FA throttles + the honeypot + # brute-force ban (an attacker can spoof a victim's IP to get it banned, or a whitelisted + # IP to evade the ban entirely). servers { trusted_proxies static {$TRUSTED_PROXY_CIDR:127.0.0.1/32} } diff --git a/tests/Feature/PollMetricsCredentialTest.php b/tests/Feature/PollMetricsCredentialTest.php new file mode 100644 index 0000000..7034adb --- /dev/null +++ b/tests/Feature/PollMetricsCredentialTest.php @@ -0,0 +1,46 @@ + $name, 'ip' => $ip, 'ssh_port' => 22, 'status' => 'online']); + $server->credential()->create([ + 'username' => 'root', + 'auth_type' => 'password', + 'secret' => 'x', + 'disabled_at' => $disabledAt, + ]); + + return $server; + } + + public function test_only_servers_with_an_active_credential_are_pollable(): void + { + $enabled = $this->serverWithCredential('enabled', '10.0.0.1', null); + $disabled = $this->serverWithCredential('disabled', '10.0.0.2', now()->toDateTimeString()); + $noCredential = Server::create(['name' => 'bare', 'ip' => '10.0.0.3', 'ssh_port' => 22, 'status' => 'online']); + + $ids = Server::withActiveCredential()->pluck('id'); + + $this->assertTrue($ids->contains($enabled->id)); // enabled credential → polled + $this->assertFalse($ids->contains($disabled->id)); // disabled (revoked) credential → excluded + $this->assertFalse($ids->contains($noCredential->id)); // no credential → excluded + } +}