Compare commits
5 Commits
74a0e15f0d
...
f42ab47972
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f42ab47972 | ||
|
|
41ff618a61 | ||
|
|
9b9455e339 | ||
|
|
8ae873ae19 | ||
|
|
7f863d8226 |
@@ -22,9 +22,6 @@ func (m *Manager) UpdateConfig(instanceName, serviceName string, update contract
|
||||
}
|
||||
|
||||
namespace := manifest.Namespace
|
||||
if deployment, ok := serviceDeployments[serviceName]; ok {
|
||||
namespace = deployment.namespace
|
||||
}
|
||||
|
||||
// 2. Load instance config
|
||||
configPath := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
||||
|
||||
@@ -21,9 +21,6 @@ func (m *Manager) GetLogs(instanceName, serviceName string, opts contracts.Servi
|
||||
}
|
||||
|
||||
namespace := manifest.Namespace
|
||||
if deployment, ok := serviceDeployments[serviceName]; ok {
|
||||
namespace = deployment.namespace
|
||||
}
|
||||
|
||||
// 2. Get kubeconfig path
|
||||
kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName)
|
||||
@@ -125,9 +122,6 @@ func (m *Manager) StreamLogs(instanceName, serviceName string, opts contracts.Se
|
||||
}
|
||||
|
||||
namespace := manifest.Namespace
|
||||
if deployment, ok := serviceDeployments[serviceName]; ok {
|
||||
namespace = deployment.namespace
|
||||
}
|
||||
|
||||
// 2. Get kubeconfig path
|
||||
kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName)
|
||||
|
||||
@@ -15,7 +15,9 @@ import (
|
||||
type ServiceManifest struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Version string `yaml:"version,omitempty" json:"version,omitempty"`
|
||||
Namespace string `yaml:"namespace" json:"namespace"`
|
||||
DeploymentName string `yaml:"deploymentName,omitempty" json:"deploymentName,omitempty"` // Optional: defaults to Name
|
||||
Category string `yaml:"category,omitempty" json:"category,omitempty"`
|
||||
Dependencies []string `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
|
||||
ConfigReferences []string `yaml:"configReferences,omitempty" json:"configReferences,omitempty"`
|
||||
@@ -87,10 +89,11 @@ func LoadAllManifests(servicesDir string) (map[string]*ServiceManifest, error) {
|
||||
}
|
||||
|
||||
// GetDeploymentName returns the primary deployment name for this service
|
||||
// Uses name as the deployment name by default
|
||||
// Uses DeploymentName if set, otherwise defaults to Name
|
||||
func (m *ServiceManifest) GetDeploymentName() string {
|
||||
// For now, assume deployment name matches service name
|
||||
// This can be made configurable if needed
|
||||
if m.DeploymentName != "" {
|
||||
return m.DeploymentName
|
||||
}
|
||||
return m.Name
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,9 @@ func NewManager(dataDir string) *Manager {
|
||||
manifests[serviceName] = &ServiceManifest{
|
||||
Name: manifest.Name,
|
||||
Description: manifest.Description,
|
||||
Version: manifest.Version,
|
||||
Namespace: manifest.Namespace,
|
||||
DeploymentName: manifest.DeploymentName,
|
||||
Category: manifest.Category,
|
||||
Dependencies: manifest.Dependencies,
|
||||
ConfigReferences: manifest.ConfigReferences,
|
||||
@@ -86,27 +88,8 @@ var BaseServices = []string{
|
||||
"longhorn", // Storage
|
||||
}
|
||||
|
||||
// serviceDeployments maps service directory names to their actual namespace and deployment name
|
||||
var serviceDeployments = map[string]struct {
|
||||
namespace string
|
||||
deploymentName string
|
||||
}{
|
||||
"cert-manager": {"cert-manager", "cert-manager"},
|
||||
"coredns": {"kube-system", "coredns"},
|
||||
"docker-registry": {"docker-registry", "docker-registry"},
|
||||
"externaldns": {"externaldns", "external-dns"},
|
||||
"kubernetes-dashboard": {"kubernetes-dashboard", "kubernetes-dashboard"},
|
||||
"longhorn": {"longhorn-system", "longhorn-ui"},
|
||||
"metallb": {"metallb-system", "controller"},
|
||||
"nfs": {"nfs-system", "nfs-server"},
|
||||
"node-feature-discovery": {"node-feature-discovery", "node-feature-discovery-master"},
|
||||
"nvidia-device-plugin": {"nvidia-device-plugin", "nvidia-device-plugin-daemonset"},
|
||||
"smtp": {"smtp-system", "smtp"},
|
||||
"traefik": {"traefik", "traefik"},
|
||||
"utils": {"utils-system", "utils"},
|
||||
}
|
||||
|
||||
// checkServiceStatus checks if a service is deployed
|
||||
// checkServiceStatus checks the deployment status of a service
|
||||
// Returns: "not-deployed", "deployed", "degraded", or "progressing"
|
||||
func (m *Manager) checkServiceStatus(instanceName, serviceName string) string {
|
||||
kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName)
|
||||
|
||||
@@ -127,26 +110,38 @@ func (m *Manager) checkServiceStatus(instanceName, serviceName string) string {
|
||||
return "not-deployed"
|
||||
}
|
||||
|
||||
var namespace, deploymentName string
|
||||
|
||||
// Check hardcoded map first for deployment name (has correct names)
|
||||
if deployment, ok := serviceDeployments[serviceName]; ok {
|
||||
namespace = deployment.namespace
|
||||
deploymentName = deployment.deploymentName
|
||||
} else if manifest, ok := m.manifests[serviceName]; ok {
|
||||
// Fall back to manifest if not in hardcoded map
|
||||
namespace = manifest.Namespace
|
||||
deploymentName = manifest.GetDeploymentName()
|
||||
} else {
|
||||
// Service not found anywhere, assume not deployed
|
||||
// Special case: SMTP is configuration-only, no deployment to check
|
||||
if serviceName == "smtp" {
|
||||
return "not-deployed"
|
||||
}
|
||||
|
||||
if kubectl.DeploymentExists(deploymentName, namespace) {
|
||||
return "deployed"
|
||||
manifest, ok := m.manifests[serviceName]
|
||||
if !ok {
|
||||
return "not-deployed"
|
||||
}
|
||||
|
||||
return "not-deployed"
|
||||
namespace := manifest.Namespace
|
||||
deploymentName := manifest.GetDeploymentName()
|
||||
|
||||
// Get deployment info to check health status
|
||||
deploymentInfo, err := kubectl.GetDeployment(deploymentName, namespace)
|
||||
if err != nil {
|
||||
return "not-deployed"
|
||||
}
|
||||
|
||||
// Determine deployment status based on replica counts
|
||||
if deploymentInfo.Ready == deploymentInfo.Desired && deploymentInfo.Desired > 0 {
|
||||
return "deployed"
|
||||
} else if deploymentInfo.Ready < deploymentInfo.Desired {
|
||||
if deploymentInfo.Current > deploymentInfo.Desired {
|
||||
return "progressing"
|
||||
}
|
||||
return "degraded"
|
||||
} else if deploymentInfo.Desired == 0 {
|
||||
return "deployed" // Scaled to zero is still "deployed"
|
||||
}
|
||||
|
||||
return "deployed"
|
||||
}
|
||||
|
||||
// List returns all base services and their status
|
||||
@@ -169,15 +164,12 @@ func (m *Manager) List(instanceName string) ([]Service, error) {
|
||||
if manifest, ok := m.manifests[name]; ok {
|
||||
namespace = manifest.Namespace
|
||||
description = manifest.Description
|
||||
version = manifest.Category // Using category as version for now
|
||||
version = manifest.Version
|
||||
dependencies = manifest.Dependencies
|
||||
hasConfig = len(manifest.ServiceConfig) > 0
|
||||
} else {
|
||||
// Fall back to hardcoded map
|
||||
namespace = name + "-system" // default
|
||||
if deployment, ok := serviceDeployments[name]; ok {
|
||||
namespace = deployment.namespace
|
||||
}
|
||||
// Service not in manifests, skip
|
||||
continue
|
||||
}
|
||||
|
||||
service := Service{
|
||||
@@ -198,16 +190,15 @@ func (m *Manager) List(instanceName string) ([]Service, error) {
|
||||
|
||||
// Get returns a specific service
|
||||
func (m *Manager) Get(instanceName, serviceName string) (*Service, error) {
|
||||
// Get the correct namespace from the map
|
||||
namespace := serviceName + "-system" // default
|
||||
if deployment, ok := serviceDeployments[serviceName]; ok {
|
||||
namespace = deployment.namespace
|
||||
manifest, ok := m.manifests[serviceName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("service not found: %s", serviceName)
|
||||
}
|
||||
|
||||
service := &Service{
|
||||
Name: serviceName,
|
||||
Status: m.checkServiceStatus(instanceName, serviceName),
|
||||
Namespace: namespace,
|
||||
Namespace: manifest.Namespace,
|
||||
}
|
||||
|
||||
return service, nil
|
||||
@@ -284,15 +275,14 @@ func (m *Manager) Delete(instanceName, serviceName string) error {
|
||||
|
||||
// GetStatus returns detailed status for a service
|
||||
func (m *Manager) GetStatus(instanceName, serviceName string) (*Service, error) {
|
||||
// Get the correct namespace from the map
|
||||
namespace := serviceName + "-system" // default
|
||||
if deployment, ok := serviceDeployments[serviceName]; ok {
|
||||
namespace = deployment.namespace
|
||||
manifest, ok := m.manifests[serviceName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("service not found: %s", serviceName)
|
||||
}
|
||||
|
||||
service := &Service{
|
||||
Name: serviceName,
|
||||
Namespace: namespace,
|
||||
Namespace: manifest.Namespace,
|
||||
Status: m.checkServiceStatus(instanceName, serviceName),
|
||||
}
|
||||
|
||||
|
||||
@@ -23,12 +23,6 @@ func (m *Manager) GetDetailedStatus(instanceName, serviceName string) (*contract
|
||||
namespace := manifest.Namespace
|
||||
deploymentName := manifest.GetDeploymentName()
|
||||
|
||||
// Check hardcoded map for correct deployment name
|
||||
if deployment, ok := serviceDeployments[serviceName]; ok {
|
||||
namespace = deployment.namespace
|
||||
deploymentName = deployment.deploymentName
|
||||
}
|
||||
|
||||
// 2. Get kubeconfig path
|
||||
kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName)
|
||||
if !storage.FileExists(kubeconfigPath) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
name: cert-manager
|
||||
description: X.509 certificate management for Kubernetes
|
||||
version: v1.17.2
|
||||
namespace: cert-manager
|
||||
category: infrastructure
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
name: coredns
|
||||
description: DNS server for internal cluster DNS resolution
|
||||
version: v1.12.0
|
||||
namespace: kube-system
|
||||
category: infrastructure
|
||||
|
||||
|
||||
119
api/internal/setup/cluster-services/crowdsec/README.md
Normal file
119
api/internal/setup/cluster-services/crowdsec/README.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# CrowdSec Security Service
|
||||
|
||||
CrowdSec is an open-source security engine that analyzes traffic patterns and blocks malicious actors. This service integrates CrowdSec with Traefik to provide automatic threat detection and rate limiting for all Wild Cloud ingresses.
|
||||
|
||||
## Components
|
||||
|
||||
- **CrowdSec Agent**: Analyzes traffic patterns, maintains decision lists, and connects to the CrowdSec threat intelligence network
|
||||
- **Traefik Bouncer**: Integrates with Traefik via ForwardAuth to enforce CrowdSec decisions
|
||||
- **Security Middlewares**: Traefik middleware for rate limiting and security headers
|
||||
|
||||
## Default Protection
|
||||
|
||||
After installation, **all ingresses are automatically protected** with:
|
||||
- Threat detection (blocks known malicious IPs and attack patterns)
|
||||
- Rate limiting (100 requests per minute per IP)
|
||||
- Security headers (HSTS, XSS protection, content-type sniffing prevention)
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is stored in `config.yaml` under `cluster.crowdsec`:
|
||||
|
||||
```yaml
|
||||
cluster:
|
||||
crowdsec:
|
||||
bouncerReplicas: 2 # Number of bouncer pods for HA
|
||||
rateLimitAverage: 100 # Requests per minute
|
||||
rateLimitBurst: 100 # Burst allowance
|
||||
```
|
||||
|
||||
## Secrets
|
||||
|
||||
Secrets are stored in `secrets.yaml` under `cluster.crowdsec`:
|
||||
|
||||
```yaml
|
||||
cluster:
|
||||
crowdsec:
|
||||
agentPassword: <auto-generated>
|
||||
bouncerApiKey: <auto-generated>
|
||||
```
|
||||
|
||||
## Opting Out
|
||||
|
||||
To disable CrowdSec protection for a specific ingress (e.g., webhooks, health checks):
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: ""
|
||||
```
|
||||
|
||||
## Using Only Rate Limiting
|
||||
|
||||
To use rate limiting without threat detection:
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: crowdsec-rate-limit@kubernetescrd
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
View active decisions (blocked IPs):
|
||||
```bash
|
||||
kubectl exec -n crowdsec deploy/crowdsec -- cscli decisions list
|
||||
```
|
||||
|
||||
View registered bouncers:
|
||||
```bash
|
||||
kubectl exec -n crowdsec deploy/crowdsec -- cscli bouncers list
|
||||
```
|
||||
|
||||
View alerts:
|
||||
```bash
|
||||
kubectl exec -n crowdsec deploy/crowdsec -- cscli alerts list
|
||||
```
|
||||
|
||||
View metrics (Prometheus format):
|
||||
```bash
|
||||
kubectl port-forward -n crowdsec svc/crowdsec-lapi 6060:6060
|
||||
curl http://localhost:6060/metrics
|
||||
```
|
||||
|
||||
## Threat Intelligence
|
||||
|
||||
CrowdSec includes these detection collections:
|
||||
- `crowdsecurity/traefik` - Traefik-specific detections
|
||||
- `crowdsecurity/http-cve` - Known HTTP CVE exploits
|
||||
- `crowdsecurity/whitelist-good-actors` - Whitelist for known good actors (search engines, etc.)
|
||||
|
||||
Enabled scenarios:
|
||||
- HTTP probing and path traversal detection
|
||||
- Bad user agent detection
|
||||
- Sensitive file access attempts
|
||||
- HTTP crawling detection
|
||||
- SSH brute force (if exposed)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Bouncer not connecting to agent:**
|
||||
```bash
|
||||
kubectl logs -n crowdsec deploy/traefik-crowdsec-bouncer
|
||||
kubectl exec -n crowdsec deploy/crowdsec -- cscli bouncers list
|
||||
```
|
||||
|
||||
**Check if middleware is applied:**
|
||||
```bash
|
||||
kubectl get middleware -n crowdsec
|
||||
kubectl describe ingressroute -n <app-namespace> <route-name>
|
||||
```
|
||||
|
||||
**View CrowdSec logs:**
|
||||
```bash
|
||||
kubectl logs -n crowdsec deploy/crowdsec
|
||||
```
|
||||
125
api/internal/setup/cluster-services/crowdsec/install.sh
Executable file
125
api/internal/setup/cluster-services/crowdsec/install.sh
Executable file
@@ -0,0 +1,125 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
# Ensure WILD_INSTANCE is set
|
||||
if [ -z "${WILD_INSTANCE}" ]; then
|
||||
echo "ERROR: WILD_INSTANCE is not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure WILD_API_DATA_DIR is set
|
||||
if [ -z "${WILD_API_DATA_DIR}" ]; then
|
||||
echo "ERROR: WILD_API_DATA_DIR is not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure KUBECONFIG is set
|
||||
if [ -z "${KUBECONFIG}" ]; then
|
||||
echo "ERROR: KUBECONFIG is not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INSTANCE_DIR="${WILD_API_DATA_DIR}/instances/${WILD_INSTANCE}"
|
||||
CLUSTER_SETUP_DIR="${INSTANCE_DIR}/setup/cluster-services"
|
||||
CROWDSEC_DIR="${CLUSTER_SETUP_DIR}/crowdsec"
|
||||
SECRETS_FILE="${INSTANCE_DIR}/secrets.yaml"
|
||||
|
||||
echo "=== Setting up CrowdSec Security Engine ==="
|
||||
echo ""
|
||||
|
||||
# Check traefik dependency
|
||||
echo "Verifying Traefik is ready (required for CrowdSec bouncer)..."
|
||||
kubectl wait --for=condition=Available deployment/traefik -n traefik --timeout=60s 2>/dev/null || {
|
||||
echo "WARNING: Traefik not ready, but continuing with CrowdSec installation"
|
||||
echo "Note: CrowdSec bouncer will not work until Traefik is available"
|
||||
}
|
||||
|
||||
# Templates should already be compiled
|
||||
echo "Using pre-compiled CrowdSec templates..."
|
||||
if [ ! -d "${CROWDSEC_DIR}/kustomize" ]; then
|
||||
echo "ERROR: Compiled templates not found at ${CROWDSEC_DIR}/kustomize"
|
||||
echo "Templates should be compiled before deployment."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Apply CrowdSec manifests using kustomize
|
||||
echo "Deploying CrowdSec..."
|
||||
kubectl apply -k ${CROWDSEC_DIR}/kustomize
|
||||
|
||||
# Setup CrowdSec agent secret
|
||||
echo "Creating CrowdSec agent secret..."
|
||||
AGENT_PASSWORD=$(yq '.cluster.crowdsec.agentPassword' "$SECRETS_FILE" 2>/dev/null | tr -d '"')
|
||||
|
||||
if [ -z "$AGENT_PASSWORD" ] || [ "$AGENT_PASSWORD" = "null" ]; then
|
||||
echo "Generating new agent password..."
|
||||
AGENT_PASSWORD=$(openssl rand -base64 32)
|
||||
# Note: The API should have already set this in secrets.yaml during config phase
|
||||
echo "WARNING: Agent password not found in secrets.yaml"
|
||||
echo "Using generated password - you may want to persist this"
|
||||
fi
|
||||
|
||||
kubectl create secret generic crowdsec-agent-secret \
|
||||
--namespace crowdsec \
|
||||
--from-literal=password="${AGENT_PASSWORD}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
# Wait for CrowdSec agent to be ready
|
||||
echo "Waiting for CrowdSec agent to be ready..."
|
||||
kubectl rollout status deployment/crowdsec -n crowdsec --timeout=120s
|
||||
|
||||
# Register bouncer with CrowdSec agent and create bouncer secret
|
||||
echo "Registering bouncer with CrowdSec agent..."
|
||||
BOUNCER_API_KEY=$(yq '.cluster.crowdsec.bouncerApiKey' "$SECRETS_FILE" 2>/dev/null | tr -d '"')
|
||||
|
||||
if [ -z "$BOUNCER_API_KEY" ] || [ "$BOUNCER_API_KEY" = "null" ]; then
|
||||
echo "Generating new bouncer API key from CrowdSec agent..."
|
||||
# Remove existing bouncer if it exists
|
||||
kubectl exec -n crowdsec deploy/crowdsec -- cscli bouncers delete traefik-bouncer 2>/dev/null || true
|
||||
# Add new bouncer and capture the key
|
||||
BOUNCER_API_KEY=$(kubectl exec -n crowdsec deploy/crowdsec -- cscli bouncers add traefik-bouncer -o raw)
|
||||
echo "Generated bouncer API key - you may want to persist this in secrets.yaml"
|
||||
fi
|
||||
|
||||
kubectl create secret generic crowdsec-bouncer-secret \
|
||||
--namespace crowdsec \
|
||||
--from-literal=api-key="${BOUNCER_API_KEY}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
# Restart bouncer to pick up the secret
|
||||
echo "Restarting bouncer deployment..."
|
||||
kubectl rollout restart deployment/traefik-crowdsec-bouncer -n crowdsec
|
||||
|
||||
# Wait for bouncer to be ready
|
||||
echo "Waiting for CrowdSec bouncer to be ready..."
|
||||
kubectl rollout status deployment/traefik-crowdsec-bouncer -n crowdsec --timeout=60s
|
||||
|
||||
# Patch Traefik to use CrowdSec middleware by default on websecure entrypoint
|
||||
echo "Configuring Traefik to use CrowdSec security chain by default..."
|
||||
kubectl patch deployment traefik -n traefik --type='json' -p='[
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/spec/template/spec/containers/0/args/-",
|
||||
"value": "--entryPoints.websecure.http.middlewares=crowdsec-security-chain@kubernetescrd"
|
||||
}
|
||||
]' 2>/dev/null || {
|
||||
echo "Note: Traefik may already have middleware configured or patch failed"
|
||||
echo "You can manually configure default middleware if needed"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "CrowdSec installed successfully"
|
||||
echo ""
|
||||
echo "All ingresses are now protected by default with:"
|
||||
echo " - Threat detection (CrowdSec)"
|
||||
echo " - Rate limiting (100 req/min)"
|
||||
echo " - Security headers (HSTS, XSS protection, etc.)"
|
||||
echo ""
|
||||
echo "To verify the installation:"
|
||||
echo " kubectl get pods -n crowdsec"
|
||||
echo " kubectl exec -n crowdsec deploy/crowdsec -- cscli bouncers list"
|
||||
echo " kubectl exec -n crowdsec deploy/crowdsec -- cscli decisions list"
|
||||
echo ""
|
||||
echo "To opt-out a specific ingress from CrowdSec protection:"
|
||||
echo " Add annotation: traefik.ingress.kubernetes.io/router.middlewares: \"\""
|
||||
echo ""
|
||||
@@ -0,0 +1,61 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: traefik-crowdsec-bouncer
|
||||
namespace: crowdsec
|
||||
labels:
|
||||
app: traefik-crowdsec-bouncer
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
spec:
|
||||
replicas: {{ .cluster.crowdsec.bouncerReplicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: traefik-crowdsec-bouncer
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: traefik-crowdsec-bouncer
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
spec:
|
||||
containers:
|
||||
- name: traefik-crowdsec-bouncer
|
||||
image: fbonalair/traefik-crowdsec-bouncer:latest
|
||||
env:
|
||||
- name: CROWDSEC_BOUNCER_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: crowdsec-bouncer-secret
|
||||
key: api-key
|
||||
- name: CROWDSEC_AGENT_HOST
|
||||
value: "crowdsec-lapi.crowdsec.svc.cluster.local:8080"
|
||||
- name: CROWDSEC_BOUNCER_LOG_LEVEL
|
||||
value: "info"
|
||||
- name: GIN_MODE
|
||||
value: "release"
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/ping
|
||||
port: 8080
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/ping
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 128Mi
|
||||
@@ -0,0 +1,20 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: traefik-crowdsec-bouncer
|
||||
namespace: crowdsec
|
||||
labels:
|
||||
app: traefik-crowdsec-bouncer
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: traefik-crowdsec-bouncer
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: 8080
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,42 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: crowdsec-config
|
||||
namespace: crowdsec
|
||||
labels:
|
||||
app: crowdsec
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
data:
|
||||
acquis.yaml: |
|
||||
# Minimal acquisition config for CrowdSec
|
||||
# The Traefik bouncer will send data directly via the Local API
|
||||
source: file
|
||||
filename: /dev/null
|
||||
labels:
|
||||
type: traefik
|
||||
profiles.yaml: |
|
||||
name: default_ip_remediation
|
||||
debug: false
|
||||
filters:
|
||||
- Alert.Remediation == true && Alert.GetScope() == "Ip"
|
||||
decisions:
|
||||
- type: ban
|
||||
duration: 4h
|
||||
on_success: break
|
||||
---
|
||||
name: default_range_remediation
|
||||
debug: false
|
||||
filters:
|
||||
- Alert.Remediation == true && Alert.GetScope() == "Range"
|
||||
decisions:
|
||||
- type: ban
|
||||
duration: 4h
|
||||
scope: Range
|
||||
on_success: break
|
||||
postoverflows.yaml: |
|
||||
# Post-overflow configuration for crowdsec
|
||||
name: "rdns"
|
||||
debug: false
|
||||
filter: "evt.Enriched.IsoCode != ''"
|
||||
# Add reverse DNS enrichment
|
||||
@@ -0,0 +1,110 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: crowdsec
|
||||
namespace: crowdsec
|
||||
labels:
|
||||
app: crowdsec
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: crowdsec
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: crowdsec
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
spec:
|
||||
serviceAccountName: crowdsec
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
runAsNonRoot: false
|
||||
fsGroup: 0
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: crowdsec
|
||||
image: crowdsecurity/crowdsec:v1.6.3
|
||||
env:
|
||||
- name: COLLECTIONS
|
||||
value: "crowdsecurity/traefik crowdsecurity/http-cve crowdsecurity/whitelist-good-actors crowdsecurity/iptables crowdsecurity/linux"
|
||||
- name: PARSERS
|
||||
value: "crowdsecurity/traefik-logs crowdsecurity/http-logs crowdsecurity/nginx-logs"
|
||||
- name: SCENARIOS
|
||||
value: "crowdsecurity/http-crawl-non_statics crowdsecurity/http-probing crowdsecurity/http-sensitive-files crowdsecurity/http-bad-user-agent crowdsecurity/http-path-traversal-probing crowdsecurity/ssh-bf crowdsecurity/ssh-slow-bf"
|
||||
- name: POSTOVERFLOWS
|
||||
value: "crowdsecurity/rdns crowdsecurity/cdn-whitelist"
|
||||
- name: GID
|
||||
value: "1000"
|
||||
- name: LEVEL_TRACE
|
||||
value: "false"
|
||||
- name: LEVEL_DEBUG
|
||||
value: "false"
|
||||
- name: LEVEL_INFO
|
||||
value: "true"
|
||||
- name: AGENT_USERNAME
|
||||
value: "kubernetes-cluster"
|
||||
- name: AGENT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: crowdsec-agent-secret
|
||||
key: password
|
||||
ports:
|
||||
- name: lapi
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
- name: prometheus
|
||||
containerPort: 6060
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 200Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
runAsNonRoot: false
|
||||
volumeMounts:
|
||||
- name: crowdsec-config
|
||||
mountPath: /etc/crowdsec/acquis.yaml
|
||||
subPath: acquis.yaml
|
||||
readOnly: true
|
||||
- name: crowdsec-config
|
||||
mountPath: /etc/crowdsec/profiles.yaml
|
||||
subPath: profiles.yaml
|
||||
readOnly: true
|
||||
- name: crowdsec-data
|
||||
mountPath: /var/lib/crowdsec/data
|
||||
- name: crowdsec-config-dir
|
||||
mountPath: /etc/crowdsec/config
|
||||
volumes:
|
||||
- name: crowdsec-config
|
||||
configMap:
|
||||
name: crowdsec-config
|
||||
- name: crowdsec-data
|
||||
emptyDir: {}
|
||||
- name: crowdsec-config-dir
|
||||
emptyDir: {}
|
||||
@@ -0,0 +1,24 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: crowdsec-lapi
|
||||
namespace: crowdsec
|
||||
labels:
|
||||
app: crowdsec
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: crowdsec
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
ports:
|
||||
- name: lapi
|
||||
port: 8080
|
||||
targetPort: 8080
|
||||
protocol: TCP
|
||||
- name: prometheus
|
||||
port: 6060
|
||||
targetPort: 6060
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: crowdsec
|
||||
commonLabels:
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- serviceaccount.yaml
|
||||
- configmap.yaml
|
||||
- crowdsec-deployment.yaml
|
||||
- crowdsec-service.yaml
|
||||
- bouncer-deployment.yaml
|
||||
- bouncer-service.yaml
|
||||
- middleware.yaml
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: crowdsec-bouncer
|
||||
namespace: crowdsec
|
||||
labels:
|
||||
app: crowdsec
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
spec:
|
||||
forwardAuth:
|
||||
address: http://traefik-crowdsec-bouncer.crowdsec.svc.cluster.local:8080/api/v1/forwardAuth
|
||||
authResponseHeaders:
|
||||
- X-Crowdsec-Decision
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: rate-limit
|
||||
namespace: crowdsec
|
||||
labels:
|
||||
app: crowdsec
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
spec:
|
||||
rateLimit:
|
||||
average: {{ .cluster.crowdsec.rateLimitAverage }}
|
||||
burst: {{ .cluster.crowdsec.rateLimitBurst }}
|
||||
period: 1m
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: security-headers
|
||||
namespace: crowdsec
|
||||
labels:
|
||||
app: crowdsec
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
spec:
|
||||
headers:
|
||||
browserXssFilter: true
|
||||
contentTypeNosniff: true
|
||||
forceSTSHeader: true
|
||||
frameDeny: true
|
||||
sslRedirect: true
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
stsSeconds: 31536000
|
||||
addVaryHeader: true
|
||||
accessControlAllowMethods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
accessControlAllowOriginList:
|
||||
- "*"
|
||||
accessControlMaxAge: 100
|
||||
customRequestHeaders:
|
||||
X-Forwarded-Proto: https
|
||||
customResponseHeaders:
|
||||
Server: ""
|
||||
X-Robots-Tag: noindex,nofollow,nosnippet,noarchive,notranslate,noimageindex
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: security-chain
|
||||
namespace: crowdsec
|
||||
labels:
|
||||
app: crowdsec
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
spec:
|
||||
chain:
|
||||
middlewares:
|
||||
- name: security-headers
|
||||
namespace: crowdsec
|
||||
- name: rate-limit
|
||||
namespace: crowdsec
|
||||
- name: crowdsec-bouncer
|
||||
namespace: crowdsec
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: crowdsec
|
||||
labels:
|
||||
app: crowdsec
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: crowdsec
|
||||
namespace: crowdsec
|
||||
labels:
|
||||
app: crowdsec
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
@@ -0,0 +1,28 @@
|
||||
name: crowdsec
|
||||
description: CrowdSec security engine with Traefik bouncer for threat detection and rate limiting
|
||||
version: v1.6.3
|
||||
namespace: crowdsec
|
||||
category: infrastructure
|
||||
|
||||
dependencies:
|
||||
- traefik
|
||||
|
||||
configReferences:
|
||||
- cloud.domain
|
||||
|
||||
serviceConfig:
|
||||
bouncerReplicas:
|
||||
path: cluster.crowdsec.bouncerReplicas
|
||||
prompt: "Number of bouncer replicas for high availability"
|
||||
default: "2"
|
||||
type: string
|
||||
rateLimitAverage:
|
||||
path: cluster.crowdsec.rateLimitAverage
|
||||
prompt: "Rate limit average requests per minute"
|
||||
default: "100"
|
||||
type: string
|
||||
rateLimitBurst:
|
||||
path: cluster.crowdsec.rateLimitBurst
|
||||
prompt: "Rate limit burst requests per minute"
|
||||
default: "100"
|
||||
type: string
|
||||
@@ -20,9 +20,21 @@ spec:
|
||||
labels:
|
||||
app: docker-registry
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- image: registry:3.0.0
|
||||
name: docker-registry
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
protocol: TCP
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
name: docker-registry
|
||||
description: Private Docker image registry for cluster
|
||||
version: "3.0.0"
|
||||
namespace: docker-registry
|
||||
category: infrastructure
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
name: externaldns
|
||||
description: Automatically configures DNS records for services
|
||||
version: v0.13.4
|
||||
namespace: externaldns
|
||||
deploymentName: external-dns
|
||||
category: infrastructure
|
||||
|
||||
configReferences:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
name: kubernetes-dashboard
|
||||
description: Web-based Kubernetes user interface
|
||||
version: v7.10.0
|
||||
namespace: kubernetes-dashboard
|
||||
category: infrastructure
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
name: longhorn
|
||||
description: Cloud-native distributed block storage for Kubernetes
|
||||
version: v1.8.1
|
||||
namespace: longhorn-system
|
||||
deploymentName: longhorn-ui
|
||||
category: infrastructure
|
||||
|
||||
dependencies:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
name: metallb
|
||||
description: Bare metal load-balancer for Kubernetes
|
||||
version: v0.15.0
|
||||
namespace: metallb-system
|
||||
deploymentName: controller
|
||||
category: infrastructure
|
||||
|
||||
configReferences:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: nfs
|
||||
description: NFS client provisioner for external NFS storage
|
||||
namespace: nfs-system
|
||||
version: v4.0.18
|
||||
namespace: nfs
|
||||
category: infrastructure
|
||||
|
||||
serviceConfig:
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
name: node-feature-discovery
|
||||
description: Detects hardware features available on each node
|
||||
version: v0.17.3
|
||||
namespace: node-feature-discovery
|
||||
deploymentName: node-feature-discovery-master
|
||||
category: infrastructure
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
name: nvidia-device-plugin
|
||||
description: NVIDIA device plugin for Kubernetes
|
||||
namespace: nvidia-device-plugin
|
||||
version: v0.17.1
|
||||
namespace: kube-system
|
||||
deploymentName: nvidia-device-plugin-daemonset
|
||||
category: infrastructure
|
||||
|
||||
dependencies:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: smtp
|
||||
description: SMTP relay service for cluster applications
|
||||
namespace: smtp-system
|
||||
namespace: smtp
|
||||
category: infrastructure
|
||||
|
||||
serviceConfig:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
name: traefik
|
||||
description: Cloud-native reverse proxy and ingress controller
|
||||
version: v3.4
|
||||
namespace: traefik
|
||||
category: infrastructure
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: debug
|
||||
labels:
|
||||
pod-security.kubernetes.io/enforce: privileged
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: utils
|
||||
description: Utility tools and scripts for cluster administration
|
||||
namespace: utils-system
|
||||
namespace: debug
|
||||
deploymentName: netdebug
|
||||
category: infrastructure
|
||||
|
||||
@@ -24,6 +24,7 @@ type ServiceManifest struct {
|
||||
Version string `yaml:"version"`
|
||||
Category string `yaml:"category"`
|
||||
Namespace string `yaml:"namespace"`
|
||||
DeploymentName string `yaml:"deploymentName,omitempty"` // Optional: defaults to Name
|
||||
Dependencies []string `yaml:"dependencies,omitempty"`
|
||||
ConfigReferences []string `yaml:"configReferences,omitempty"`
|
||||
ServiceConfig map[string]ConfigDefinition `yaml:"serviceConfig,omitempty"`
|
||||
|
||||
@@ -2,11 +2,12 @@ import { useState } from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Container, Shield, Network, Database, CheckCircle, AlertCircle, Terminal, BookOpen, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { Container, Shield, Network, Database, CheckCircle, AlertCircle, Terminal, BookOpen, ExternalLink, Loader2, Activity, FileText, Settings, Trash2, Download } from 'lucide-react';
|
||||
import { useInstanceContext } from '../hooks/useInstanceContext';
|
||||
import { useServices } from '../hooks/useServices';
|
||||
import type { Service } from '../services/api';
|
||||
import { ServiceDetailModal } from './services/ServiceDetailModal';
|
||||
import { ServiceStatusDialog } from './services/ServiceStatusDialog';
|
||||
import { ServiceLogsDialog } from './services/ServiceLogsDialog';
|
||||
import { ServiceConfigEditor } from './services/ServiceConfigEditor';
|
||||
import { Dialog, DialogContent } from './ui/dialog';
|
||||
import { usePageHelp } from '../hooks/usePageHelp';
|
||||
@@ -25,7 +26,8 @@ export function ClusterServicesComponent() {
|
||||
isDeleting
|
||||
} = useServices(currentInstance);
|
||||
|
||||
const [selectedService, setSelectedService] = useState<string | null>(null);
|
||||
const [statusService, setStatusService] = useState<string | null>(null);
|
||||
const [logsService, setLogsService] = useState<string | null>(null);
|
||||
const [configService, setConfigService] = useState<string | null>(null);
|
||||
|
||||
usePageHelp({
|
||||
@@ -83,9 +85,11 @@ export function ClusterServicesComponent() {
|
||||
available: 'secondary',
|
||||
deploying: 'default',
|
||||
installing: 'default',
|
||||
progressing: 'default',
|
||||
running: 'success',
|
||||
ready: 'success',
|
||||
deployed: 'success',
|
||||
degraded: 'destructive',
|
||||
error: 'destructive',
|
||||
};
|
||||
|
||||
@@ -94,8 +98,10 @@ export function ClusterServicesComponent() {
|
||||
available: 'Available',
|
||||
deploying: 'Deploying',
|
||||
installing: 'Installing',
|
||||
progressing: 'Progressing',
|
||||
running: 'Running',
|
||||
ready: 'Ready',
|
||||
degraded: 'Degraded',
|
||||
error: 'Error',
|
||||
deployed: 'Deployed',
|
||||
};
|
||||
@@ -210,73 +216,90 @@ export function ClusterServicesComponent() {
|
||||
<p className="text-muted-foreground">Loading services...</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{services.map((service) => (
|
||||
<div key={service.name}>
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg border bg-card">
|
||||
<Card key={service.name} className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
{getServiceIcon(service.name)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium">{service.name}</h3>
|
||||
<h3 className="font-medium truncate">{service.name}</h3>
|
||||
{service.version && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{service.version}
|
||||
</Badge>
|
||||
)}
|
||||
{getStatusIcon(typeof service.status === 'string' ? service.status : service.status?.status)}
|
||||
{getStatusBadge(service)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{service.description}</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">{service.description}</p>
|
||||
{typeof service.status === 'object' && service.status?.message && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{service.status.message}</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">{service.status.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusBadge(service)}
|
||||
{((typeof service.status === 'string' && service.status === 'not-deployed') ||
|
||||
(!service.status || service.status === 'not-deployed') ||
|
||||
(typeof service.status === 'object' && service.status?.status === 'not-deployed')) && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleInstallService(service.name)}
|
||||
disabled={isInstalling}
|
||||
>
|
||||
{isInstalling ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Install'}
|
||||
</Button>
|
||||
)}
|
||||
{((typeof service.status === 'string' && service.status === 'deployed') ||
|
||||
(typeof service.status === 'object' && service.status?.status === 'deployed')) && (
|
||||
<>
|
||||
|
||||
{/* Action buttons - horizontal layout with responsive icons */}
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{((typeof service.status === 'string' && service.status === 'not-deployed') ||
|
||||
(!service.status || (typeof service.status === 'string' && service.status === 'not-deployed')) ||
|
||||
(typeof service.status === 'object' && service.status?.status === 'not-deployed')) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedService(service.name)}
|
||||
onClick={() => handleInstallService(service.name)}
|
||||
disabled={isInstalling}
|
||||
>
|
||||
View
|
||||
{isInstalling ? <Loader2 className="h-4 w-4 animate-spin sm:mr-1" /> : <Download className="h-4 w-4 sm:mr-1" />}
|
||||
<span className="hidden sm:inline">Install</span>
|
||||
</Button>
|
||||
{service.hasConfig && (
|
||||
)}
|
||||
{((typeof service.status === 'string' && ['deployed', 'degraded', 'progressing'].includes(service.status)) ||
|
||||
(typeof service.status === 'object' && ['deployed', 'degraded', 'progressing'].includes(service.status?.status || ''))) && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConfigService(service.name)}
|
||||
onClick={() => setStatusService(service.name)}
|
||||
title="Status"
|
||||
>
|
||||
Configure
|
||||
<Activity className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Status</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteService(service.name)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Remove'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setLogsService(service.name)}
|
||||
title="Logs"
|
||||
>
|
||||
<FileText className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Logs</span>
|
||||
</Button>
|
||||
{service.hasConfig && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConfigService(service.name)}
|
||||
title="Configure"
|
||||
>
|
||||
<Settings className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Configure</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteService(service.name)}
|
||||
disabled={isDeleting}
|
||||
title="Remove"
|
||||
>
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{services.length === 0 && (
|
||||
@@ -292,34 +315,21 @@ export function ClusterServicesComponent() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-medium mb-4">Cluster Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="font-medium mb-2">Control Plane</div>
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
<div>• API Server: https://cluster.wildcloud.local:6443</div>
|
||||
<div>• Nodes: 1 controller, 2 workers</div>
|
||||
<div>• Version: Kubernetes v1.29.0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium mb-2">Network Configuration</div>
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
<div>• Pod CIDR: 10.244.0.0/16</div>
|
||||
<div>• Service CIDR: 10.96.0.0/12</div>
|
||||
<div>• CNI: Cilium v1.14.5</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{selectedService && (
|
||||
<ServiceDetailModal
|
||||
{statusService && (
|
||||
<ServiceStatusDialog
|
||||
instanceName={currentInstance}
|
||||
serviceName={selectedService}
|
||||
open={!!selectedService}
|
||||
onClose={() => setSelectedService(null)}
|
||||
serviceName={statusService}
|
||||
open={!!statusService}
|
||||
onClose={() => setStatusService(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{logsService && (
|
||||
<ServiceLogsDialog
|
||||
instanceName={currentInstance}
|
||||
serviceName={logsService}
|
||||
open={!!logsService}
|
||||
onClose={() => setLogsService(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
244
web/src/components/services/ServiceLogsDialog.tsx
Normal file
244
web/src/components/services/ServiceLogsDialog.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { ServiceStatusBadge } from './ServiceStatusBadge';
|
||||
import { useServiceStatus } from '@/hooks/useServices';
|
||||
import { servicesApi } from '@/services/api';
|
||||
import { Copy, Download, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface ServiceLogsDialogProps {
|
||||
instanceName: string;
|
||||
serviceName: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ServiceLogsDialog({
|
||||
instanceName,
|
||||
serviceName,
|
||||
open,
|
||||
onClose,
|
||||
}: ServiceLogsDialogProps) {
|
||||
const { data: status } = useServiceStatus(instanceName, serviceName);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [follow, setFollow] = useState(false);
|
||||
const [tail, setTail] = useState(100);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
const logsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
// Scroll to bottom when logs change and autoScroll is enabled
|
||||
useEffect(() => {
|
||||
if (autoScroll && logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
// Fetch initial buffered logs
|
||||
const fetchLogs = useCallback(async () => {
|
||||
if (!open) return;
|
||||
try {
|
||||
const url = servicesApi.getLogsUrl(instanceName, serviceName, tail, false);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch logs: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
// API returns { lines: string[] }
|
||||
if (data.lines && Array.isArray(data.lines)) {
|
||||
setLogs(data.lines);
|
||||
} else {
|
||||
setLogs([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
setLogs([`Error: ${error instanceof Error ? error.message : 'Failed to fetch logs'}`]);
|
||||
}
|
||||
}, [instanceName, serviceName, tail, open]);
|
||||
|
||||
// Set up SSE streaming when follow is enabled
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (follow) {
|
||||
const url = servicesApi.getLogsUrl(instanceName, serviceName, tail, true);
|
||||
const eventSource = new EventSource(url);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const line = event.data;
|
||||
if (line && line.trim() !== '') {
|
||||
setLogs((prev) => [...prev, line]);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
eventSource.close();
|
||||
setFollow(false);
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
} else {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [follow, instanceName, serviceName, tail, open]);
|
||||
|
||||
// Fetch initial logs on mount and when parameters change
|
||||
useEffect(() => {
|
||||
if (open && !follow) {
|
||||
fetchLogs();
|
||||
}
|
||||
}, [fetchLogs, follow, open]);
|
||||
|
||||
// Clean up on close
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setFollow(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleCopyLogs = () => {
|
||||
const text = logs.join('\n');
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const handleDownloadLogs = () => {
|
||||
const text = logs.join('\n');
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${serviceName}-logs.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleClearLogs = () => {
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLogs([]);
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
{serviceName}
|
||||
{status && <ServiceStatusBadge status={status.deploymentStatus} />}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Service logs</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="tail-select">Lines:</Label>
|
||||
<Select value={tail.toString()} onValueChange={(v) => setTail(Number(v))}>
|
||||
<SelectTrigger id="tail-select" className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="200">200</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
<SelectItem value="1000">1000</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="follow-checkbox"
|
||||
checked={follow}
|
||||
onChange={(e) => setFollow(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="follow-checkbox">Follow</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoscroll-checkbox"
|
||||
checked={autoScroll}
|
||||
onChange={(e) => setAutoScroll(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="autoscroll-checkbox">Auto-scroll</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={follow}>
|
||||
<RefreshCw className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Refresh</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleCopyLogs}>
|
||||
<Copy className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Copy</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDownloadLogs}>
|
||||
<Download className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Download</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleClearLogs}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="flex-1 overflow-hidden">
|
||||
<CardContent className="p-0 h-full">
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="h-[400px] overflow-y-auto bg-slate-950 dark:bg-slate-900 p-4 font-mono text-xs text-green-400"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-slate-500">No logs available</div>
|
||||
) : (
|
||||
logs.map((line, index) => (
|
||||
<div key={index} className="whitespace-pre-wrap break-all">
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
197
web/src/components/services/ServiceStatusDialog.tsx
Normal file
197
web/src/components/services/ServiceStatusDialog.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ServiceStatusBadge } from './ServiceStatusBadge';
|
||||
import { useServiceStatus } from '@/hooks/useServices';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
interface ServiceStatusDialogProps {
|
||||
instanceName: string;
|
||||
serviceName: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ServiceStatusDialog({
|
||||
instanceName,
|
||||
serviceName,
|
||||
open,
|
||||
onClose,
|
||||
}: ServiceStatusDialogProps) {
|
||||
const { data: status, isLoading, refetch } = useServiceStatus(instanceName, serviceName);
|
||||
|
||||
const getPodStatusColor = (status: string) => {
|
||||
if (status.toLowerCase().includes('running')) return 'text-green-600 dark:text-green-400';
|
||||
if (status.toLowerCase().includes('pending')) return 'text-yellow-600 dark:text-yellow-400';
|
||||
if (status.toLowerCase().includes('failed')) return 'text-red-600 dark:text-red-400';
|
||||
return 'text-muted-foreground';
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
{serviceName}
|
||||
{status && <ServiceStatusBadge status={status.deploymentStatus} />}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Service status and details</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
) : status ? (
|
||||
<>
|
||||
{/* Status Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Status Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Service Name</p>
|
||||
<p className="text-sm">{status.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Namespace</p>
|
||||
<p className="text-sm">{status.namespace}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status.replicas && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Replicas</p>
|
||||
<div className="grid grid-cols-4 gap-2 text-sm">
|
||||
<div className="bg-muted rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Desired</p>
|
||||
<p className="font-semibold">{status.replicas.desired}</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Current</p>
|
||||
<p className="font-semibold">{status.replicas.current}</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Ready</p>
|
||||
<p className="font-semibold">{status.replicas.ready}</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Available</p>
|
||||
<p className="font-semibold">{status.replicas.available}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pods Section */}
|
||||
{status.pods && status.pods.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Pods</CardTitle>
|
||||
<CardDescription>{status.pods.length} pod(s)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{status.pods.map((pod) => (
|
||||
<div
|
||||
key={pod.name}
|
||||
className="border rounded-lg p-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{pod.name}</p>
|
||||
{pod.node && (
|
||||
<p className="text-xs text-muted-foreground">Node: {pod.node}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 ml-2">
|
||||
<Badge variant="outline" className={getPodStatusColor(pod.status)}>
|
||||
{pod.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Ready:</span>{' '}
|
||||
<span className="font-medium">{pod.ready}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Restarts:</span>{' '}
|
||||
<span className="font-medium">{pod.restarts}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Age:</span>{' '}
|
||||
<span className="font-medium">{pod.age}</span>
|
||||
</div>
|
||||
</div>
|
||||
{pod.ip && (
|
||||
<div className="text-xs mt-1">
|
||||
<span className="text-muted-foreground">IP:</span>{' '}
|
||||
<span className="font-mono">{pod.ip}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Configuration Preview */}
|
||||
{status.config && Object.keys(status.config).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Current Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(status.config).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between text-sm">
|
||||
<span className="font-medium text-muted-foreground">{key}:</span>
|
||||
<span className="font-mono text-xs">
|
||||
{typeof value === 'object' && value !== null
|
||||
? JSON.stringify(value, null, 2)
|
||||
: String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">No status information available</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,5 @@ export { ServiceStatusBadge } from './ServiceStatusBadge';
|
||||
export { ServiceLogViewer } from './ServiceLogViewer';
|
||||
export { ServiceConfigEditor } from './ServiceConfigEditor';
|
||||
export { ServiceDetailModal } from './ServiceDetailModal';
|
||||
export { ServiceStatusDialog } from './ServiceStatusDialog';
|
||||
export { ServiceLogsDialog } from './ServiceLogsDialog';
|
||||
|
||||
Reference in New Issue
Block a user