feat: Implement a gatekeeper service for access control and add CMS health monitoring with a connectivity notice.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m24s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Failing after 3m8s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s

This commit is contained in:
2026-02-01 16:27:52 +01:00
parent 9e87720494
commit fcb3169d04
11 changed files with 327 additions and 6 deletions

View File

@@ -39,6 +39,10 @@ MAIL_RECIPIENTS=info@klz-cables.com
# Logging
# ────────────────────────────────────────────────────────────────────────────
LOG_LEVEL=info
GATEKEEPER_PASSWORD=klz2026
SENTRY_DSN=
# For Directus Error Tracking
# SENTRY_ENVIRONMENT is set automatically by CI
# ────────────────────────────────────────────────────────────────────────────
# Deployment Configuration (CI/CD only)

View File

@@ -193,6 +193,16 @@ jobs:
--cache-to type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max \
--push .
- name: 🏗️ Gatekeeper bauen & pushen
env:
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
run: |
docker buildx build \
--pull \
--platform linux/arm64 \
-t registry.infra.mintel.me/mintel/klz-cables-gatekeeper:$IMAGE_TAG \
--push ./gatekeeper
# ──────────────────────────────────────────────────────────────────────────────
# JOB 4: Deploy via SSH
@@ -227,6 +237,7 @@ jobs:
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || 'directus' }}
DIRECTUS_DB_PASSWORD: ${{ secrets.DIRECTUS_DB_PASSWORD }}
DIRECTUS_API_TOKEN: ${{ secrets.DIRECTUS_API_TOKEN }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -265,10 +276,12 @@ jobs:
DIRECTUS_DB_USER=$DIRECTUS_DB_USER
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
IMAGE_TAG=$IMAGE_TAG
TRAEFIK_HOST=$TRAEFIK_HOST
ENV_FILE=$ENV_FILE
AUTH_MIDDLEWARE=$( [[ "$TARGET" == "production" ]] && echo "compress" || echo "${PROJECT_NAME}-auth,compress" )
EOF
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/$ENV_FILE

View File

@@ -2,6 +2,7 @@ import Footer from '@/components/Footer';
import Header from '@/components/Header';
import JsonLd from '@/components/JsonLd';
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
import { Metadata, Viewport } from 'next';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
@@ -20,18 +21,18 @@ export const viewport: Viewport = {
viewportFit: 'cover',
themeColor: '#001a4d',
};
export default async function LocaleLayout({
children,
params: {locale}
params: { locale }
}: {
children: React.ReactNode;
params: {locale: string};
params: { locale: string };
}) {
// Providing all messages to the client
// side is the easiest way to get started
const messages = await getMessages();
return (
<html lang={locale} className="scroll-smooth overflow-x-hidden">
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
@@ -42,7 +43,8 @@ export default async function LocaleLayout({
{children}
</main>
<Footer />
<CMSConnectivityNotice />
{/* Sends pageviews for client-side navigations */}
<AnalyticsProvider />
</NextIntlClientProvider>

View File

@@ -0,0 +1,9 @@
import { checkHealth } from '@/lib/directus';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET() {
const health = await checkHealth();
return NextResponse.json(health, { status: health.status === 'ok' ? 200 : 503 });
}

View File

@@ -0,0 +1,72 @@
'use client';
import React, { useEffect, useState } from 'react';
import { AlertCircle, RefreshCw, Database } from 'lucide-react';
export default function CMSConnectivityNotice() {
const [status, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
const [errorMsg, setErrorMsg] = useState('');
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// Only show in development or if explicitly checking
const checkCMS = async () => {
try {
const response = await fetch('/api/health/cms');
const data = await response.json();
if (data.status !== 'ok') {
setStatus('error');
setErrorMsg(data.message);
setIsVisible(true);
} else {
setStatus('ok');
setIsVisible(false);
}
} catch (err) {
setStatus('error');
setErrorMsg('Could not connect to CMS health endpoint');
setIsVisible(true);
}
};
checkCMS();
}, []);
if (!isVisible) return null;
return (
<div className="fixed bottom-4 right-4 z-[9999] animate-slide-up">
<div className="bg-red-500/90 backdrop-blur-md border border-red-400 text-white p-4 rounded-2xl shadow-2xl max-w-sm">
<div className="flex items-start gap-3">
<div className="bg-white/20 p-2 rounded-lg">
<AlertCircle className="w-5 h-5" />
</div>
<div className="flex-1">
<h4 className="font-bold text-sm mb-1">CMS Issue Detected</h4>
<p className="text-xs opacity-90 leading-relaxed mb-3">
{errorMsg === 'relation "products" does not exist'
? 'The database schema is missing. Please sync your local data to this environment.'
: errorMsg || 'The application cannot connect to the Directus CMS.'}
</p>
<div className="flex gap-2">
<button
onClick={() => window.location.reload()}
className="bg-white text-red-600 text-[10px] font-bold uppercase tracking-wider px-3 py-1.5 rounded-lg flex items-center gap-2 hover:bg-neutral-100 transition-colors"
>
<RefreshCw className="w-3 h-3" />
Retry
</button>
<button
onClick={() => setIsVisible(false)}
className="bg-black/20 text-white text-[10px] font-bold uppercase tracking-wider px-3 py-1.5 rounded-lg hover:bg-black/30 transition-colors"
>
Dismiss
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -24,7 +24,33 @@ services:
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Middlewares
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${PROJECT_NAME:-klz-cables}-forward,compress"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-compress}"
# Gatekeeper Router (to show the login page)
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=Host(${TRAEFIK_HOST}) && PathPrefix(`/gatekeeper`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.service=${PROJECT_NAME:-klz-cables}-gatekeeper"
# Middleware Definitions
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/verify"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
gatekeeper:
image: registry.infra.mintel.me/mintel/klz-cables-gatekeeper:${IMAGE_TAG:-latest}
container_name: ${PROJECT_NAME:-klz-cables}-gatekeeper
restart: always
networks:
- infra
env_file:
- ${ENV_FILE:-.env}
environment:
PORT: 3000
labels:
- "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
directus:
image: directus/directus:11
@@ -46,6 +72,9 @@ services:
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
WEBSOCKETS_ENABLED: 'true'
PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com}
# Error Tracking
SENTRY_DSN: ${SENTRY_DSN}
SENTRY_ENVIRONMENT: ${TARGET:-development}
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
@@ -55,6 +84,7 @@ services:
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.middlewares=${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-compress}"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-directus.loadbalancer.server.port=8055"
directus-db:

7
gatekeeper/Dockerfile Normal file
View File

@@ -0,0 +1,7 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]

60
gatekeeper/index.js Normal file
View File

@@ -0,0 +1,60 @@
const express = require('express');
const cookieParser = require('cookie-parser');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3000;
const GATEKEEPER_PASSWORD = process.env.GATEKEEPER_PASSWORD || 'klz2026';
const AUTH_COOKIE_NAME = 'klz_gatekeeper_session';
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
// ForwardAuth check endpoint
app.get('/verify', (req, res) => {
const session = req.cookies[AUTH_COOKIE_NAME];
if (session === GATEKEEPER_PASSWORD) {
return res.status(200).send('OK');
}
// Traefik will use this to redirect if requested
const originalUrl = req.headers['x-forwarded-uri'] || '/';
const host = req.headers['x-forwarded-host'] || '';
const proto = req.headers['x-forwarded-proto'] || 'https';
// Redirect to login
res.status(401).set('X-Auth-Redirect', `${proto}://${host}/gatekeeper/login?redirect=${encodeURIComponent(originalUrl)}`).send('Unauthorized');
});
// Login page
app.get('/gatekeeper/login', (req, res) => {
res.render('login', {
error: req.query.error ? 'Invalid password' : null,
redirect: req.query.redirect || '/'
});
});
// Handle login
app.post('/gatekeeper/login', (req, res) => {
const { password, redirect } = req.body;
if (password === GATEKEEPER_PASSWORD) {
res.cookie(AUTH_COOKIE_NAME, GATEKEEPER_PASSWORD, {
httpOnly: true,
secure: true,
path: '/',
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
});
return res.redirect(redirect || '/');
}
res.redirect(`/gatekeeper/login?error=1&redirect=${encodeURIComponent(redirect || '/')}`);
});
app.listen(PORT, () => {
console.log(`Gatekeeper listening on port ${PORT}`);
});

11
gatekeeper/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "klz-gatekeeper",
"version": "1.0.0",
"description": "Simple branded gatekeeper for Traefik ForwardAuth",
"main": "index.js",
"dependencies": {
"cookie-parser": "^1.4.6",
"ejs": "^3.1.9",
"express": "^4.18.2"
}
}

View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KLZ Cables | Access Control</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
background-color: #001a4d;
color: white;
overflow: hidden;
}
.bg-grid {
background-image:
linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px);
background-size: 40px 40px;
}
.accent-glow {
box-shadow: 0 0 20px rgba(130, 237, 32, 0.4);
}
.scribble-animation {
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
animation: draw 2s ease-out forwards;
}
@keyframes draw {
to { stroke-dashoffset: 0; }
}
</style>
</head>
<body class="min-h-screen flex items-center justify-center relative">
<!-- Background Elements -->
<div class="absolute inset-0 bg-grid pointer-events-none"></div>
<div class="absolute top-0 right-0 w-96 h-96 bg-[#82ed20]/5 blur-[120px] rounded-full -translate-y-1/2 translate-x-1/2"></div>
<div class="absolute bottom-0 left-0 w-96 h-96 bg-[#82ed20]/5 blur-[120px] rounded-full translate-y-1/2 -translate-x-1/2"></div>
<div class="relative z-10 w-full max-w-md px-6">
<!-- Logo -->
<div class="flex justify-center mb-12">
<svg class="h-16 w-auto" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" rx="20" fill="#001a4d" />
<path d="M30 30L70 70" stroke="#82ed20" stroke-width="8" stroke-linecap="round" />
<path d="M70 30L30 70" stroke="#82ed20" stroke-width="8" stroke-linecap="round" />
</svg>
</div>
<div class="bg-white/5 backdrop-blur-xl border border-white/10 p-10 rounded-[40px] shadow-2xl">
<h1 class="text-3xl font-black mb-2 tracking-tighter uppercase italic">
KLZ <span class="text-[#82ed20]">Gatekeeper</span>
</h1>
<p class="text-white/60 text-sm mb-8">This environment is strictly protected.</p>
<% if (error) { %>
<div class="bg-red-500/20 border border-red-500/50 text-red-200 p-4 rounded-2xl mb-6 text-sm flex items-center gap-3">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<%= error %>
</div>
<% } %>
<form action="/gatekeeper/login" method="POST" class="space-y-6">
<input type="hidden" name="redirect" value="<%= redirect %>">
<div>
<label class="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2 ml-4">Access Password</label>
<input
type="password"
name="password"
required
autofocus
class="w-full bg-white/5 border border-white/10 rounded-2xl px-6 py-4 focus:outline-none focus:border-[#82ed20]/50 transition-all text-lg tracking-widest text-center"
placeholder="••••••••"
>
</div>
<button
type="submit"
class="w-full bg-[#82ed20] text-[#001a4d] font-black uppercase tracking-[0.2em] py-5 rounded-2xl hover:bg-[#82ed20]/90 transition-all accent-glow active:scale-[0.98]"
>
Enter Workspace &rarr;
</button>
</form>
</div>
<div class="mt-8 text-center">
<p class="text-[10px] font-bold text-white/20 uppercase tracking-[0.4em]">
&copy; 2026 KLZ Vertriebs GmbH
</p>
</div>
</div>
</body>
</html>

View File

@@ -89,4 +89,20 @@ export async function getProductBySlug(slug: string, locale: string = 'de') {
}
}
export async function checkHealth() {
try {
await ensureAuthenticated();
// Try to fetch something very simple that should exist if initialized
await client.request(readItems('directus_collections', { limit: 1 }));
return { status: 'ok', message: 'Directus is reachable and responding.' };
} catch (error: any) {
console.error('Directus health check failed:', error);
return {
status: 'error',
message: error.message || 'Unknown error',
code: error.code || 'UNKNOWN'
};
}
}
export default client;