Implement dual-registry container publishing to both GHCR and Docker Hub
for maximum distribution reach. Add emergency security reset endpoint
("break-glass" mechanism) to recover from ACL lockout situations.
Key changes:
Docker Hub + GHCR dual publishing with Cosign signing and SBOM
Emergency reset endpoint POST /api/v1/emergency/security-reset
Token-based authentication bypasses Cerberus middleware
Rate limited (5/hour) with audit logging
30 new security enforcement E2E tests covering ACL, WAF, CrowdSec,
Rate Limiting, Security Headers, and Combined scenarios
Fixed container startup permission issue (tmpfs directory ownership)
Playwright config updated with testIgnore for browser projects
Security: Token via CHARON_EMERGENCY_TOKEN env var (32+ chars recommended)
Tests: 689 passed, 86% backend coverage, 85% frontend coverage
354 lines
16 KiB
Bash
Executable File
354 lines
16 KiB
Bash
Executable File
#!/bin/sh
|
|
set -e
|
|
|
|
# Entrypoint script to run both Caddy and Charon in a single container
|
|
# This simplifies deployment for home users
|
|
|
|
echo "Starting Charon with integrated Caddy..."
|
|
|
|
is_root() {
|
|
[ "$(id -u)" -eq 0 ]
|
|
}
|
|
|
|
run_as_charon() {
|
|
if is_root; then
|
|
gosu charon "$@"
|
|
else
|
|
"$@"
|
|
fi
|
|
}
|
|
|
|
# ============================================================================
|
|
# Volume Permission Handling for Non-Root User
|
|
# ============================================================================
|
|
# When running as non-root user (charon), mounted volumes may have incorrect
|
|
# permissions. This section ensures the application can write to required paths.
|
|
# Note: This runs as the charon user, so we can only fix owned directories.
|
|
|
|
# Ensure /app/data exists and is writable (primary data volume)
|
|
if [ ! -w "/app/data" ] 2>/dev/null; then
|
|
echo "Warning: /app/data is not writable. Please ensure volume permissions are correct."
|
|
echo " Run: docker run ... -v charon_data:/app/data ..."
|
|
echo " Or fix permissions: chown -R 1000:1000 /path/to/volume"
|
|
fi
|
|
|
|
# Ensure /config exists and is writable (Caddy config volume)
|
|
if [ ! -w "/config" ] 2>/dev/null; then
|
|
echo "Warning: /config is not writable. Please ensure volume permissions are correct."
|
|
fi
|
|
|
|
# Create required subdirectories in writable volumes
|
|
mkdir -p /app/data/caddy 2>/dev/null || true
|
|
mkdir -p /app/data/crowdsec 2>/dev/null || true
|
|
mkdir -p /app/data/geoip 2>/dev/null || true
|
|
|
|
# Fix ownership for directories created as root
|
|
if is_root; then
|
|
chown -R charon:charon /app/data/caddy 2>/dev/null || true
|
|
chown -R charon:charon /app/data/crowdsec 2>/dev/null || true
|
|
chown -R charon:charon /app/data/geoip 2>/dev/null || true
|
|
fi
|
|
|
|
# ============================================================================
|
|
# Plugin Directory Permission Verification
|
|
# ============================================================================
|
|
# The PluginLoaderService requires the plugin directory to NOT be world-writable
|
|
# (mode 0002 bit must not be set). This is a security requirement to prevent
|
|
# malicious plugin injection.
|
|
PLUGINS_DIR="${CHARON_PLUGINS_DIR:-/app/plugins}"
|
|
if [ -d "$PLUGINS_DIR" ]; then
|
|
# Check if directory is world-writable (security risk)
|
|
# Using find -perm -0002 is more robust than stat regex - handles sticky/setgid bits correctly
|
|
if find "$PLUGINS_DIR" -maxdepth 0 -perm -0002 -print -quit 2>/dev/null | grep -q .; then
|
|
echo "⚠️ WARNING: Plugin directory $PLUGINS_DIR is world-writable!"
|
|
echo " This is a security risk - plugins could be injected by any user."
|
|
echo " Attempting to fix permissions (removing world-writable bit)..."
|
|
# Use chmod o-w to only remove world-writable, preserving sticky/setgid bits
|
|
if chmod o-w "$PLUGINS_DIR" 2>/dev/null; then
|
|
echo " ✓ Fixed: Plugin directory world-writable permission removed"
|
|
else
|
|
echo " ✗ ERROR: Cannot fix permissions. Please run: chmod o-w $PLUGINS_DIR"
|
|
echo " Plugin loading may fail due to insecure permissions."
|
|
fi
|
|
else
|
|
echo "✓ Plugin directory permissions OK: $PLUGINS_DIR"
|
|
fi
|
|
else
|
|
echo "Note: Plugin directory $PLUGINS_DIR does not exist (plugins disabled)"
|
|
fi
|
|
|
|
# ============================================================================
|
|
# Docker Socket Permission Handling
|
|
# ============================================================================
|
|
# The Docker integration feature requires access to the Docker socket.
|
|
# If the container runs as root, we can auto-align group membership with the
|
|
# socket GID. If running non-root (default), we cannot modify groups; users
|
|
# can enable Docker integration by using a compatible GID / --group-add.
|
|
|
|
if [ -S "/var/run/docker.sock" ] && is_root; then
|
|
DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null || echo "")
|
|
if [ -n "$DOCKER_SOCK_GID" ] && [ "$DOCKER_SOCK_GID" != "0" ]; then
|
|
# Check if a group with this GID exists
|
|
if ! getent group "$DOCKER_SOCK_GID" >/dev/null 2>&1; then
|
|
echo "Docker socket detected (gid=$DOCKER_SOCK_GID) - creating docker group and adding charon user..."
|
|
# Create docker group with the socket's GID
|
|
groupadd -g "$DOCKER_SOCK_GID" docker 2>/dev/null || true
|
|
# Add charon user to the docker group
|
|
usermod -aG docker charon 2>/dev/null || true
|
|
echo "Docker integration enabled for charon user"
|
|
else
|
|
# Group exists, just add charon to it
|
|
GROUP_NAME=$(getent group "$DOCKER_SOCK_GID" | cut -d: -f1)
|
|
echo "Docker socket detected (gid=$DOCKER_SOCK_GID, group=$GROUP_NAME) - adding charon user..."
|
|
usermod -aG "$GROUP_NAME" charon 2>/dev/null || true
|
|
echo "Docker integration enabled for charon user"
|
|
fi
|
|
fi
|
|
elif [ -S "/var/run/docker.sock" ]; then
|
|
echo "Note: Docker socket mounted but container is running non-root; skipping docker.sock group setup."
|
|
echo " If Docker discovery is needed, run with matching group permissions (e.g., --group-add)"
|
|
else
|
|
echo "Note: Docker socket not found. Docker container discovery will be unavailable."
|
|
fi
|
|
|
|
# ============================================================================
|
|
# CrowdSec Initialization
|
|
# ============================================================================
|
|
# Note: CrowdSec agent is not auto-started. Lifecycle is GUI-controlled via backend handlers.
|
|
|
|
# Initialize CrowdSec configuration if cscli is present
|
|
if command -v cscli >/dev/null; then
|
|
echo "Initializing CrowdSec configuration..."
|
|
|
|
# Define persistent paths
|
|
CS_PERSIST_DIR="/app/data/crowdsec"
|
|
CS_CONFIG_DIR="$CS_PERSIST_DIR/config"
|
|
CS_DATA_DIR="$CS_PERSIST_DIR/data"
|
|
CS_LOG_DIR="/var/log/crowdsec"
|
|
|
|
# Ensure persistent directories exist (within writable volume)
|
|
mkdir -p "$CS_CONFIG_DIR" 2>/dev/null || echo "Warning: Cannot create $CS_CONFIG_DIR"
|
|
mkdir -p "$CS_DATA_DIR" 2>/dev/null || echo "Warning: Cannot create $CS_DATA_DIR"
|
|
mkdir -p "$CS_PERSIST_DIR/hub_cache"
|
|
# Log directories are created at build time with correct ownership
|
|
# Only attempt to create if they don't exist (first run scenarios)
|
|
mkdir -p /var/log/crowdsec 2>/dev/null || true
|
|
mkdir -p /var/log/caddy 2>/dev/null || true
|
|
|
|
# Initialize persistent config if key files are missing
|
|
if [ ! -f "$CS_CONFIG_DIR/config.yaml" ]; then
|
|
echo "Initializing persistent CrowdSec configuration..."
|
|
if [ -d "/etc/crowdsec.dist" ] && [ -n "$(ls -A /etc/crowdsec.dist 2>/dev/null)" ]; then
|
|
cp -r /etc/crowdsec.dist/* "$CS_CONFIG_DIR/" || {
|
|
echo "ERROR: Failed to copy config from /etc/crowdsec.dist"
|
|
exit 1
|
|
}
|
|
echo "Successfully initialized config from .dist directory"
|
|
elif [ -d "/etc/crowdsec" ] && [ ! -L "/etc/crowdsec" ] && [ -n "$(ls -A /etc/crowdsec 2>/dev/null)" ]; then
|
|
cp -r /etc/crowdsec/* "$CS_CONFIG_DIR/" || {
|
|
echo "ERROR: Failed to copy config from /etc/crowdsec"
|
|
exit 1
|
|
}
|
|
echo "Successfully initialized config from /etc/crowdsec"
|
|
else
|
|
echo "ERROR: No config source found (neither .dist nor /etc/crowdsec available)"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Verify symlink exists (created at build time)
|
|
# Note: Symlink is created in Dockerfile as root before switching to non-root user
|
|
# Non-root users cannot create symlinks in /etc, so this must be done at build time
|
|
if [ -L "/etc/crowdsec" ]; then
|
|
echo "CrowdSec config symlink verified: /etc/crowdsec -> $CS_CONFIG_DIR"
|
|
else
|
|
echo "WARNING: /etc/crowdsec symlink not found. This may indicate a build issue."
|
|
echo "Expected: /etc/crowdsec -> /app/data/crowdsec/config"
|
|
# Try to continue anyway - config may still work if CrowdSec uses CFG env var
|
|
fi
|
|
|
|
# Create/update acquisition config for Caddy logs
|
|
if [ ! -f "/etc/crowdsec/acquis.yaml" ] || [ ! -s "/etc/crowdsec/acquis.yaml" ]; then
|
|
echo "Creating acquisition configuration for Caddy logs..."
|
|
cat > /etc/crowdsec/acquis.yaml << 'ACQUIS_EOF'
|
|
# Caddy access logs acquisition
|
|
# CrowdSec will monitor these files for security events
|
|
source: file
|
|
filenames:
|
|
- /var/log/caddy/access.log
|
|
- /var/log/caddy/*.log
|
|
labels:
|
|
type: caddy
|
|
ACQUIS_EOF
|
|
fi
|
|
|
|
# Ensure hub directory exists in persistent storage
|
|
mkdir -p /etc/crowdsec/hub
|
|
|
|
# Perform variable substitution
|
|
export CFG=/etc/crowdsec
|
|
export DATA="$CS_DATA_DIR"
|
|
export PID=/var/run/crowdsec.pid
|
|
export LOG="$CS_LOG_DIR/crowdsec.log"
|
|
|
|
# Process config.yaml and user.yaml with envsubst
|
|
# We use a temp file to avoid issues with reading/writing same file
|
|
for file in /etc/crowdsec/config.yaml /etc/crowdsec/user.yaml; do
|
|
if [ -f "$file" ]; then
|
|
envsubst < "$file" > "$file.tmp" && mv "$file.tmp" "$file"
|
|
chown charon:charon "$file" 2>/dev/null || true
|
|
fi
|
|
done
|
|
|
|
# Configure CrowdSec LAPI to use port 8085 to avoid conflict with Charon (port 8080)
|
|
if [ -f "/etc/crowdsec/config.yaml" ]; then
|
|
sed -i 's|listen_uri: 127.0.0.1:8080|listen_uri: 127.0.0.1:8085|g' /etc/crowdsec/config.yaml
|
|
sed -i 's|listen_uri: 0.0.0.0:8080|listen_uri: 127.0.0.1:8085|g' /etc/crowdsec/config.yaml
|
|
fi
|
|
|
|
# Update local_api_credentials.yaml to use correct port
|
|
if [ -f "/etc/crowdsec/local_api_credentials.yaml" ]; then
|
|
sed -i 's|url: http://127.0.0.1:8080|url: http://127.0.0.1:8085|g' /etc/crowdsec/local_api_credentials.yaml
|
|
sed -i 's|url: http://localhost:8080|url: http://127.0.0.1:8085|g' /etc/crowdsec/local_api_credentials.yaml
|
|
fi
|
|
|
|
# Fix log directory path (ensure it points to /var/log/crowdsec/ not /var/log/)
|
|
sed -i 's|log_dir: /var/log/$|log_dir: /var/log/crowdsec/|g' "$CS_CONFIG_DIR/config.yaml"
|
|
# Also handle case where it might be without trailing slash
|
|
sed -i 's|log_dir: /var/log$|log_dir: /var/log/crowdsec|g' "$CS_CONFIG_DIR/config.yaml"
|
|
|
|
# Verify LAPI configuration was applied correctly
|
|
if grep -q "listen_uri:.*:8085" "$CS_CONFIG_DIR/config.yaml"; then
|
|
echo "✓ CrowdSec LAPI configured for port 8085"
|
|
else
|
|
echo "✗ WARNING: LAPI port configuration may be incorrect"
|
|
fi
|
|
|
|
# Update hub index to ensure CrowdSec can start
|
|
if [ ! -f "/etc/crowdsec/hub/.index.json" ]; then
|
|
echo "Updating CrowdSec hub index..."
|
|
timeout 60s cscli hub update 2>/dev/null || echo "⚠️ Hub update timed out or failed, continuing..."
|
|
fi
|
|
|
|
# Ensure local machine is registered (auto-heal for volume/config mismatch)
|
|
# We force registration because we just restored configuration (and likely credentials)
|
|
echo "Registering local machine..."
|
|
cscli machines add -a --force 2>/dev/null || echo "Warning: Machine registration may have failed"
|
|
|
|
# Install hub items (parsers, scenarios, collections) if local mode enabled
|
|
if [ "$SECURITY_CROWDSEC_MODE" = "local" ]; then
|
|
echo "Installing CrowdSec hub items..."
|
|
if [ -x /usr/local/bin/install_hub_items.sh ]; then
|
|
/usr/local/bin/install_hub_items.sh 2>/dev/null || echo "Warning: Some hub items may not have installed"
|
|
fi
|
|
fi
|
|
|
|
# Fix ownership AFTER cscli commands (they run as root and create root-owned files)
|
|
echo "Fixing CrowdSec file ownership..."
|
|
if is_root; then
|
|
chown -R charon:charon /var/lib/crowdsec 2>/dev/null || true
|
|
chown -R charon:charon /app/data/crowdsec 2>/dev/null || true
|
|
chown -R charon:charon /var/log/crowdsec 2>/dev/null || true
|
|
fi
|
|
fi
|
|
|
|
# CrowdSec Lifecycle Management:
|
|
# CrowdSec configuration is initialized above (symlinks, directories, hub updates)
|
|
# However, the CrowdSec agent is NOT auto-started in the entrypoint.
|
|
# Instead, CrowdSec lifecycle is managed by the backend handlers via GUI controls.
|
|
# This makes CrowdSec consistent with other security features (WAF, ACL, Rate Limiting).
|
|
# Users enable/disable CrowdSec using the Security dashboard toggle, which calls:
|
|
# - POST /api/v1/admin/crowdsec/start (to start the agent)
|
|
# - POST /api/v1/admin/crowdsec/stop (to stop the agent)
|
|
# This approach provides:
|
|
# - Consistent user experience across all security features
|
|
# - No environment variable dependency
|
|
# - Real-time control without container restart
|
|
# - Proper integration with Charon's security orchestration
|
|
echo "CrowdSec configuration initialized. Agent lifecycle is GUI-controlled."
|
|
|
|
# Start Caddy in the background with initial empty config
|
|
# Run Caddy as charon user for security
|
|
echo '{"admin":{"listen":"0.0.0.0:2019"},"apps":{}}' > /config/caddy.json
|
|
# Use JSON config directly; no adapter needed
|
|
run_as_charon caddy run --config /config/caddy.json &
|
|
CADDY_PID=$!
|
|
echo "Caddy started (PID: $CADDY_PID)"
|
|
|
|
# Wait for Caddy to be ready
|
|
echo "Waiting for Caddy admin API..."
|
|
i=1
|
|
while [ "$i" -le 30 ]; do
|
|
if curl -sf http://127.0.0.1:2019/config/ > /dev/null 2>&1; then
|
|
echo "Caddy is ready!"
|
|
break
|
|
fi
|
|
i=$((i+1))
|
|
sleep 1
|
|
done
|
|
|
|
# Start Charon management application
|
|
# Drop privileges to charon user before starting the application
|
|
# This maintains security while allowing Docker socket access via group membership
|
|
# Note: When running as root, we use gosu; otherwise we run directly.
|
|
echo "Starting Charon management application..."
|
|
DEBUG_FLAG=${CHARON_DEBUG:-$CPMP_DEBUG}
|
|
DEBUG_PORT=${CHARON_DEBUG_PORT:-${CPMP_DEBUG_PORT:-2345}}
|
|
|
|
# Determine binary path
|
|
bin_path=/app/charon
|
|
if [ ! -f "$bin_path" ]; then
|
|
bin_path=/app/cpmp
|
|
fi
|
|
|
|
if [ "$DEBUG_FLAG" = "1" ]; then
|
|
# Check if binary has debug symbols (required for Delve)
|
|
# objdump -h lists section headers; .debug_info is present if DWARF symbols exist
|
|
if command -v objdump >/dev/null 2>&1; then
|
|
if ! objdump -h "$bin_path" 2>/dev/null | grep -q '\.debug_info'; then
|
|
echo "⚠️ WARNING: Binary lacks debug symbols (DWARF info stripped)."
|
|
echo " Delve debugging will NOT work with this binary."
|
|
echo " To fix, rebuild with: docker build --build-arg BUILD_DEBUG=1 ..."
|
|
echo " Falling back to normal execution (without debugger)."
|
|
run_as_charon "$bin_path" &
|
|
else
|
|
echo "✓ Debug symbols detected. Running Charon under Delve (port $DEBUG_PORT)"
|
|
run_as_charon /usr/local/bin/dlv exec "$bin_path" --headless --listen=":$DEBUG_PORT" --api-version=2 --accept-multiclient --continue --log -- &
|
|
fi
|
|
else
|
|
# objdump not available, try to run Delve anyway with a warning
|
|
echo "Note: Cannot verify debug symbols (objdump not found). Attempting Delve..."
|
|
run_as_charon /usr/local/bin/dlv exec "$bin_path" --headless --listen=":$DEBUG_PORT" --api-version=2 --accept-multiclient --continue --log -- &
|
|
fi
|
|
else
|
|
run_as_charon "$bin_path" &
|
|
fi
|
|
APP_PID=$!
|
|
echo "Charon started (PID: $APP_PID)"
|
|
shutdown() {
|
|
echo "Shutting down..."
|
|
kill -TERM "$APP_PID" 2>/dev/null || true
|
|
kill -TERM "$CADDY_PID" 2>/dev/null || true
|
|
# Note: CrowdSec process lifecycle is managed by backend handlers
|
|
# The backend will handle graceful CrowdSec shutdown when the container stops
|
|
wait "$APP_PID" 2>/dev/null || true
|
|
wait "$CADDY_PID" 2>/dev/null || true
|
|
exit 0
|
|
}
|
|
|
|
# Trap signals for graceful shutdown
|
|
trap 'shutdown' TERM INT
|
|
|
|
echo "Charon is running!"
|
|
echo " - Management UI: http://localhost:8080"
|
|
echo " - Caddy Proxy: http://localhost:80, https://localhost:443"
|
|
echo " - Caddy Admin API: http://localhost:2019"
|
|
|
|
# Wait loop: exit when either process dies, then shutdown the other
|
|
while kill -0 "$APP_PID" 2>/dev/null && kill -0 "$CADDY_PID" 2>/dev/null; do
|
|
sleep 1
|
|
done
|
|
|
|
echo "A process exited, initiating shutdown..."
|
|
shutdown
|