Browse Source

initial commit

zehe 3 months ago
commit
8292e4d81c
11 changed files with 1678 additions and 0 deletions
  1. 2 0
      .gitignore
  2. 143 0
      Caddyfile.example
  3. 431 0
      README.md
  4. 20 0
      config-production.json
  5. 20 0
      config.json
  6. 79 0
      config.py
  7. 43 0
      emergency-access.service
  8. 242 0
      install.sh
  9. 264 0
      main.py
  10. 4 0
      requirements.txt
  11. 430 0
      test.py

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+venv/*
+.idea/*

+ 143 - 0
Caddyfile.example

@@ -0,0 +1,143 @@
+# Caddyfile example for Emergency Access Server
+# This configures Caddy to reverse proxy the emergency access server
+# running on localhost:1127
+
+# Example domain configuration
+emergency.example.com {
+    # Enable TLS with automatic HTTPS
+    tls your-email@example.com
+
+    # Reverse proxy to the emergency access server
+    reverse_proxy localhost:1127
+
+    # Security headers
+    header {
+        # Hide server information
+        -Server
+
+        # Security headers
+        X-Content-Type-Options nosniff
+        X-Frame-Options DENY
+        X-XSS-Protection "1; mode=block"
+        Referrer-Policy strict-origin-when-cross-origin
+
+        # Remove potentially sensitive headers
+        -X-Powered-By
+    }
+
+    # Logging for security monitoring
+    log {
+        output file /var/log/caddy/emergency-access.log {
+            roll_size 10mb
+            roll_keep 30
+        }
+        format json
+        level INFO
+    }
+
+    # Rate limiting to prevent abuse
+    rate_limit {
+        zone emergency {
+            key {remote_host}
+            events 10
+            window 1m
+        }
+    }
+}
+
+# Alternative: Using a specific path instead of subdomain
+example.com {
+    # Handle emergency access routes with specific path prefix
+    handle /emergency/* {
+        # Strip the /emergency prefix before forwarding
+        uri strip_prefix /emergency
+
+        # Forward to local server
+        reverse_proxy localhost:1127
+
+        # Additional security for emergency routes
+        header {
+            X-Content-Type-Options nosniff
+            X-Frame-Options DENY
+            Cache-Control "no-cache, no-store, must-revalidate"
+            Pragma no-cache
+            Expires 0
+        }
+
+        # More restrictive rate limiting for emergency routes
+        rate_limit {
+            zone emergency_strict {
+                key {remote_host}
+                events 5
+                window 1m
+            }
+        }
+    }
+
+    # Handle other routes normally
+    handle {
+        # Your regular website content
+        root * /var/www/html
+        file_server
+    }
+
+    # Logging
+    log {
+        output file /var/log/caddy/main.log
+        format json
+    }
+}
+
+# IP-based access (for internal use)
+:443 {
+    # Only allow specific IP addresses
+    @allowed_ips remote_ip 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
+
+    handle @allowed_ips {
+        reverse_proxy localhost:1127
+
+        header {
+            X-Internal-Access "true"
+        }
+    }
+
+    handle {
+        respond "Access Denied" 403
+    }
+
+    tls internal
+}
+
+# Development/testing configuration (HTTP only)
+# Remove in production!
+localhost:8080 {
+    reverse_proxy localhost:1127
+
+    # Development headers
+    header {
+        X-Dev-Mode "true"
+    }
+
+    log {
+        output stdout
+        format console
+        level DEBUG
+    }
+}
+
+# Global options
+{
+    # Email for Let's Encrypt
+    email your-email@example.com
+
+    # Enable experimental features if needed
+    # experimental_http3
+
+    # Security settings
+    servers {
+        protocols h1 h2 h2c
+    }
+
+    # Admin API (optional, restrict access in production)
+    admin localhost:2019
+}

+ 431 - 0
README.md

@@ -0,0 +1,431 @@
+# Emergency Access Server
+
+A fail-safe webserver that provides secure access to decryption key parts with mandatory notification system integration. Designed for emergency scenarios where key retrieval must be monitored and logged.
+
+## Features
+
+- **Fail-safe design**: All operations require successful notification delivery
+- **Dual endpoint system**: Emergency key access and health monitoring
+- **dschep/ntfy integration**: Real-time notifications via multiple backends (Pushover, Pushbullet, Slack, etc.)
+- **Configurable security**: Random endpoint paths and file locations
+- **Caddy reverse proxy ready**: Runs on localhost for secure proxy setup
+- **Systemd integration**: Automatic startup and service management
+- **Comprehensive logging**: Detailed audit trail of all operations
+
+## Architecture
+
+The system consists of two main endpoints:
+
+1. **Emergency Key Endpoint** (`/emergency-key-xyz123`): 
+   - Serves the actual decryption key part
+   - Sends critical alerts via dschep/ntfy to configured backends
+   - Fails closed if notifications cannot be sent
+
+2. **Health Check Endpoint** (`/health-check-abc456`):
+   - Serves dummy content to verify system functionality
+   - Sends health status to monitoring backends
+   - Used for regular system verification
+
+The server runs on localhost:1127 by default and is designed to be accessed through a Caddy reverse proxy for security and TLS termination.
+
+## Installation
+
+### Quick Install
+
+Run the automated installation script as root:
+
+```bash
+sudo ./install.sh
+```
+
+### Manual Installation
+
+1. **Install system dependencies**:
+   ```bash
+   # Ubuntu/Debian
+   sudo apt-get update
+   sudo apt-get install python3 python3-pip python3-venv
+
+   # RHEL/CentOS/Fedora
+   sudo dnf install python3 python3-pip python3-venv
+   ```
+
+2. **Create service user**:
+   ```bash
+   sudo groupadd --system emergency-access
+   sudo useradd --system --gid emergency-access --home-dir /opt/emergency-access \
+                --shell /bin/false emergency-access
+   ```
+
+3. **Setup directories**:
+   ```bash
+   sudo mkdir -p /opt/emergency-access /etc/emergency-access
+   sudo chown emergency-access:emergency-access /opt/emergency-access /etc/emergency-access
+   sudo chmod 755 /opt/emergency-access
+   sudo chmod 750 /etc/emergency-access
+   ```
+
+4. **Install application**:
+   ```bash
+   sudo cp *.py requirements.txt /opt/emergency-access/
+   sudo cp config.json /etc/emergency-access/
+   sudo chown -R emergency-access:emergency-access /opt/emergency-access
+   ```
+
+5. **Setup Python environment**:
+   ```bash
+   sudo -u emergency-access python3 -m venv /opt/emergency-access/venv
+   sudo -u emergency-access /opt/emergency-access/venv/bin/pip install -r /opt/emergency-access/requirements.txt
+   ```
+
+6. **Install systemd service**:
+   ```bash
+   sudo cp emergency-access.service /etc/systemd/system/
+   sudo systemctl daemon-reload
+   ```
+
+## Configuration
+
+### Main Configuration File
+
+Edit `/etc/emergency-access/config.json`:
+
+```json
+{
+  "server": {
+    "host": "127.0.0.1",
+    "port": 1127
+  },
+  "routes": {
+    "key_route": "/emergency-key-a7f9d2e1",
+    "health_route": "/health-check-b8e3f4a2"
+  },
+  "files": {
+    "key_file": "/etc/emergency-access/key-part.txt",
+    "dummy_file": "/etc/emergency-access/dummy.txt"
+  },
+  "notifications": {
+    "key_backends": ["matrix_sec", "pushover_emergency"],
+    "health_backends": ["matrix_sec"],
+    "key_message": "🚨 EMERGENCY: Decryption key accessed from server",
+    "health_message": "✅ Emergency access server health check completed"
+  }
+}
+```
+
+### Configuration Options
+
+#### Server Settings
+- `host`: Bind address (default: `127.0.0.1` for localhost only)
+- `port`: Listen port (default: `1127`)
+
+#### Route Settings
+- `key_route`: Random path for key access (e.g., `/emergency-key-a7f9d2e1`)
+- `health_route`: Path for health checks (e.g., `/health-check-b8e3f4a2`)
+
+#### File Settings
+- `key_file`: Path to the actual key part file
+- `dummy_file`: Path to dummy content for health checks
+
+#### Notification Settings
+- `key_backends`: List of backend names from your global ntfy config for key access alerts
+- `health_backends`: List of backend names from your global ntfy config for health check notifications
+- `key_message`: Message sent when key is accessed
+- `health_message`: Message sent for health checks
+
+#### Backend Name Examples
+- `matrix_sec`: Matrix backend for security alerts
+- `pushover_emergency`: Pushover backend for emergency notifications
+- `slack_critical`: Slack backend for critical alerts
+- Any backend name configured in your global `~/.ntfy.yml`
+
+### Key and Dummy Files
+
+1. **Create key file**:
+   ```bash
+   echo "YOUR_ACTUAL_KEY_PART_HERE" | sudo tee /etc/emergency-access/key-part.txt
+   sudo chown emergency-access:emergency-access /etc/emergency-access/key-part.txt
+   sudo chmod 600 /etc/emergency-access/key-part.txt
+   ```
+
+2. **Create dummy file**:
+   ```bash
+   echo "system_healthy" | sudo tee /etc/emergency-access/dummy.txt
+   sudo chown emergency-access:emergency-access /etc/emergency-access/dummy.txt
+   sudo chmod 644 /etc/emergency-access/dummy.txt
+   ```
+
+## dschep/ntfy Backend Setup
+
+The system uses your existing global ntfy configuration. Simply reference your configured backend names in the emergency access configuration.
+
+### Using Your Existing Backends
+
+1. **Check your global ntfy config** (typically `~/.ntfy.yml`):
+   ```yaml
+   backends:
+     - matrix_sec
+     - pushover_emergency
+   
+   matrix_sec:
+     backend: matrix
+     url: https://your-matrix-server.com
+     roomId: "!emergency:your-matrix-server.com"
+     userId: "@emergency-bot:your-matrix-server.com"
+     password: "your-bot-password"
+   
+   pushover_emergency:
+     backend: pushover
+     user_key: YOUR_PUSHOVER_USER_KEY
+     priority: 2
+     sound: siren
+   ```
+
+2. **Reference backend names** in emergency access config:
+   ```json
+   "notifications": {
+     "key_backends": ["matrix_sec", "pushover_emergency"],
+     "health_backends": ["matrix_sec"]
+   }
+   ```
+
+### Adding New Backends
+
+If you need additional backends for emergency access, add them to your global ntfy config:
+
+```yaml
+# Add to your ~/.ntfy.yml
+backends:
+  - matrix_sec
+  - pushover_emergency
+  - slack_critical
+
+slack_critical:
+  backend: slack
+  token: YOUR_SLACK_BOT_TOKEN
+  recipient: "#emergency-alerts"
+```
+
+Then reference them in the emergency access configuration.
+
+## Service Management
+
+### Start and Enable Service
+
+```bash
+# Start the service
+sudo systemctl start emergency-access
+
+# Enable automatic startup
+sudo systemctl enable emergency-access
+
+# Check status
+sudo systemctl status emergency-access
+```
+
+### Monitoring and Logs
+
+```bash
+# View real-time logs
+sudo journalctl -u emergency-access -f
+
+# View log file
+sudo tail -f /var/log/emergency-access.log
+
+# Check service health (through Caddy proxy)
+curl https://your-domain.com/health-check-b8e3f4a2
+
+# Or directly to local service (for testing)
+curl http://localhost:1127/health-check-b8e3f4a2
+```
+
+## Usage
+
+### Emergency Key Access
+
+```bash
+# Access the key (replace with your actual route)
+curl https://your-domain.com/emergency-key-a7f9d2e1
+
+# Expected response:
+{
+  "success": true,
+  "key_part": "YOUR_KEY_PART_HERE",
+  "timestamp": 1703123456.789,
+  "notified_backends": ["matrix_sec"]
+}
+```
+
+### Health Check
+
+```bash
+# Regular health monitoring
+curl https://your-domain.com/health-check-b8e3f4a2
+
+# Expected response:
+{
+  "status": "ok",
+  "timestamp": 1703123456.789,
+  "notified_backends": ["matrix_sec"],
+  "dummy_content_length": 14
+}
+```
+
+## Security Considerations
+
+### Caddy Reverse Proxy Setup
+
+1. **Basic Caddyfile configuration**:
+   ```caddy
+   emergency.yourdomain.com {
+       tls your-email@example.com
+       reverse_proxy localhost:1127
+       
+       header {
+           X-Content-Type-Options nosniff
+           X-Frame-Options DENY
+       }
+       
+       rate_limit {
+           zone emergency {
+               key {remote_host}
+               events 10
+               window 1m
+           }
+       }
+   }
+   ```
+
+2. **Path-based routing**:
+   ```caddy
+   yourdomain.com {
+       handle /emergency/* {
+           uri strip_prefix /emergency
+           reverse_proxy localhost:1127
+       }
+   }
+   ```
+
+3. **IP-restricted access**:
+   ```caddy
+   :443 {
+       @allowed_ips remote_ip 192.168.0.0/16
+       handle @allowed_ips {
+           reverse_proxy localhost:1127
+       }
+       handle {
+           respond "Access Denied" 403
+       }
+   }
+   ```
+
+### Network Security
+
+The service binds only to localhost (127.0.0.1:1127) and is accessed through your existing Caddy reverse proxy. No additional firewall configuration is required.
+
+### File Permissions
+
+- Key file: `600` (owner read-only)
+- Config file: `640` (owner read/write, group read)
+- Application files: `644` (standard read permissions)
+
+### Monitoring
+
+1. **Set up regular health checks**:
+   ```bash
+   # Cron job for health monitoring through Caddy
+   */5 * * * * curl -s https://yourdomain.com/health-check-b8e3f4a2 > /dev/null
+   
+   # Or direct to service for internal monitoring
+   */5 * * * * curl -s http://localhost:1127/health-check-b8e3f4a2 > /dev/null
+   ```
+
+2. **Monitor notification delivery**:
+   - Configure notification backends (Pushover, Pushbullet, etc.)
+   - Set up monitoring of notification delivery in your backends
+   - Monitor log files for errors
+
+## Troubleshooting
+
+### Common Issues
+
+1. **Service won't start**:
+   ```bash
+   sudo journalctl -u emergency-access -n 50
+   sudo systemctl status emergency-access
+   ```
+
+2. **Notification failures**:
+   ```bash
+   # Test dschep/ntfy installation and configuration
+   ntfy send "test message"
+   
+   # Check global ntfy configuration
+   cat ~/.ntfy.yml
+   
+   # Test specific backend
+   ntfy -b matrix_sec send "test message"
+   ```
+
+3. **File permission errors**:
+   ```bash
+   sudo chown -R emergency-access:emergency-access /opt/emergency-access
+   sudo chown emergency-access:emergency-access /etc/emergency-access/*
+   ```
+
+
+
+### Configuration Validation
+
+Test your setup before deployment:
+
+```bash
+# Validate configuration
+sudo -u emergency-access /opt/emergency-access/venv/bin/python /opt/emergency-access/main.py --validate
+
+# Test notifications manually with your backend
+ntfy -b matrix_sec send "Test notification"
+ntfy -b pushover_emergency send "Test emergency notification"
+```
+
+## Development
+
+### Running in Development Mode
+
+```bash
+# Create virtual environment
+python3 -m venv venv
+source venv/bin/activate
+
+# Install dependencies
+pip install -r requirements.txt
+
+# Run with development config
+EMERGENCY_CONFIG=config.json python main.py
+```
+
+### Testing
+
+```bash
+# Test key endpoint (direct to service)
+curl http://localhost:1127/emergency-key-xyz123
+
+# Test health endpoint (direct to service)
+curl http://localhost:1127/health-check
+
+# Test through Caddy proxy
+curl https://yourdomain.com/emergency-key-xyz123
+curl https://yourdomain.com/health-check
+```
+
+## License
+
+This project is designed for emergency access scenarios. Use responsibly and ensure proper security measures are in place.
+
+## Support
+
+For issues and questions:
+1. Check the logs: `/var/log/emergency-access.log`
+2. Verify configuration: `/etc/emergency-access/config.json`
+3. Test notification systems independently
+4. Monitor service status: `systemctl status emergency-access`

+ 20 - 0
config-production.json

@@ -0,0 +1,20 @@
+{
+  "server": {
+    "host": "127.0.0.1",
+    "port": 1127
+  },
+  "routes": {
+    "key_route": "/emergency-key-7a8b9c2d3e4f5g6h",
+    "health_route": "/health-check-9x8y7z6w5v4u3t2s"
+  },
+  "files": {
+    "key_file": "/etc/emergency-access/production-key-part.txt",
+    "dummy_file": "/etc/emergency-access/production-dummy.txt"
+  },
+  "notifications": {
+    "key_backends": ["matrix_sec"],
+    "health_backends": ["matrix_ntf"],
+    "key_message": "🚨 CRITICAL ALERT: Emergency decryption key accessed in PRODUCTION environment",
+    "health_message": "✅ Emergency access system health check - all systems operational"
+  }
+}

+ 20 - 0
config.json

@@ -0,0 +1,20 @@
+{
+  "server": {
+    "host": "127.0.0.1",
+    "port": 1127
+  },
+  "routes": {
+    "key_route": "/emergency-key-a7f9d2e1",
+    "health_route": "/health-check-b8e3f4a2"
+  },
+  "files": {
+    "key_file": "/etc/emergency-access/key-part.txt",
+    "dummy_file": "/etc/emergency-access/dummy.txt"
+  },
+  "notifications": {
+    "key_backends": ["matrix_sec", "pushover_emergency"],
+    "health_backends": ["matrix_health"],
+    "key_message": "🚨 EMERGENCY: Decryption key accessed from server",
+    "health_message": "✅ Emergency access server health check completed"
+  }
+}

+ 79 - 0
config.py

@@ -0,0 +1,79 @@
+import json
+import os
+import yaml
+from typing import Dict, List, Any
+
+class Config:
+    def __init__(self, config_path: str = None):
+        if config_path is None:
+            config_path = os.environ.get('EMERGENCY_CONFIG', 'config.json')
+
+        self.config_path = config_path
+        self.config = self._load_config()
+
+    def _load_config(self) -> Dict[str, Any]:
+        """Load configuration from file"""
+        try:
+            with open(self.config_path, 'r') as f:
+                if self.config_path.endswith('.yaml') or self.config_path.endswith('.yml'):
+                    return yaml.safe_load(f)
+                else:
+                    return json.load(f)
+        except FileNotFoundError:
+            raise Exception(f"Configuration file {self.config_path} not found")
+        except Exception as e:
+            raise Exception(f"Failed to load configuration: {str(e)}")
+
+    @property
+    def server_host(self) -> str:
+        return self.config.get('server', {}).get('host', '127.0.0.1')
+
+    @property
+    def server_port(self) -> int:
+        return self.config.get('server', {}).get('port', 1127)
+
+    @property
+    def key_route(self) -> str:
+        return self.config.get('routes', {}).get('key_route', '/emergency-key-xyz123')
+
+    @property
+    def health_route(self) -> str:
+        return self.config.get('routes', {}).get('health_route', '/health-check')
+
+    @property
+    def key_file_path(self) -> str:
+        key_file = self.config.get('files', {}).get('key_file')
+        if not key_file:
+            raise Exception("key_file not configured")
+        return key_file
+
+    @property
+    def dummy_file_path(self) -> str:
+        dummy_file = self.config.get('files', {}).get('dummy_file')
+        if not dummy_file:
+            raise Exception("dummy_file not configured")
+        return dummy_file
+
+    @property
+    def ntfy_backends_key(self) -> List[str]:
+        backends = self.config.get('notifications', {}).get('key_backends', [])
+        if not backends:
+            raise Exception("No notification backends configured for key access")
+        return backends
+
+    @property
+    def ntfy_backends_health(self) -> List[str]:
+        backends = self.config.get('notifications', {}).get('health_backends', [])
+        if not backends:
+            raise Exception("No notification backends configured for health check")
+        return backends
+
+
+
+    @property
+    def ntfy_key_message(self) -> str:
+        return self.config.get('notifications', {}).get('key_message', 'EMERGENCY: Decryption key accessed')
+
+    @property
+    def ntfy_health_message(self) -> str:
+        return self.config.get('notifications', {}).get('health_message', 'Health check performed')

+ 43 - 0
emergency-access.service

@@ -0,0 +1,43 @@
+[Unit]
+Description=Emergency Access Key Server
+After=network.target
+Wants=network.target
+
+[Service]
+Type=simple
+User=emergency-access
+Group=emergency-access
+WorkingDirectory=/opt/emergency-access
+Environment=EMERGENCY_CONFIG=/etc/emergency-access/config.json
+Environment=PYTHONPATH=/opt/emergency-access
+
+ExecStart=/opt/emergency-access/venv/bin/python /opt/emergency-access/main.py
+ExecReload=/bin/kill -HUP $MAINPID
+Restart=always
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+
+# Security settings
+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/var/log
+ReadOnlyPaths=/etc/emergency-access
+PrivateTmp=true
+ProtectKernelTunables=true
+ProtectKernelModules=true
+ProtectControlGroups=true
+RestrictRealtime=true
+RestrictNamespaces=true
+LockPersonality=true
+MemoryDenyWriteExecute=true
+RestrictSUIDSGID=true
+
+# Network restrictions - allow localhost only (Caddy reverse proxy)
+IPAddressDeny=any
+IPAddressAllow=localhost
+IPAddressAllow=127.0.0.0/8
+
+[Install]
+WantedBy=multi-user.target

+ 242 - 0
install.sh

@@ -0,0 +1,242 @@
+#!/bin/bash
+
+# Emergency Access Server Installation Script
+# Run as root or with sudo
+
+set -euo pipefail
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# Configuration
+SERVICE_USER="emergency-access"
+SERVICE_GROUP="emergency-access"
+INSTALL_DIR="/opt/emergency-access"
+CONFIG_DIR="/etc/emergency-access"
+LOG_FILE="/var/log/emergency-access.log"
+SERVICE_FILE="/etc/systemd/system/emergency-access.service"
+
+print_status() {
+    echo -e "${GREEN}[INFO]${NC} $1"
+}
+
+print_warning() {
+    echo -e "${YELLOW}[WARNING]${NC} $1"
+}
+
+print_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+check_root() {
+    if [[ $EUID -ne 0 ]]; then
+        print_error "This script must be run as root"
+        exit 1
+    fi
+}
+
+install_dependencies() {
+    print_status "Installing system dependencies..."
+
+    # Detect package manager
+    if command -v apt-get &> /dev/null; then
+        apt-get update
+        apt-get install -y python3 python3-pip python3-venv
+    elif command -v yum &> /dev/null; then
+        yum install -y python3 python3-pip python3-venv
+    elif command -v dnf &> /dev/null; then
+        dnf install -y python3 python3-pip python3-venv
+    elif command -v pacman &> /dev/null; then
+        pacman -S --noconfirm python python-pip python-virtualenv
+    else
+        print_error "Unsupported package manager. Please install Python 3, pip, and venv manually."
+        exit 1
+    fi
+}
+
+create_user() {
+    print_status "Creating service user and group..."
+
+    if ! getent group "$SERVICE_GROUP" > /dev/null 2>&1; then
+        groupadd --system "$SERVICE_GROUP"
+        print_status "Created group: $SERVICE_GROUP"
+    else
+        print_warning "Group $SERVICE_GROUP already exists"
+    fi
+
+    if ! getent passwd "$SERVICE_USER" > /dev/null 2>&1; then
+        useradd --system --gid "$SERVICE_GROUP" --home-dir "$INSTALL_DIR" \
+                --shell /bin/false --comment "Emergency Access Service" "$SERVICE_USER"
+        print_status "Created user: $SERVICE_USER"
+    else
+        print_warning "User $SERVICE_USER already exists"
+    fi
+}
+
+setup_directories() {
+    print_status "Setting up directories..."
+
+    # Create installation directory
+    mkdir -p "$INSTALL_DIR"
+    mkdir -p "$CONFIG_DIR"
+
+    # Set ownership
+    chown "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_DIR"
+    chown "$SERVICE_USER:$SERVICE_GROUP" "$CONFIG_DIR"
+
+    # Set permissions
+    chmod 755 "$INSTALL_DIR"
+    chmod 750 "$CONFIG_DIR"  # More restrictive for config
+
+    print_status "Created directories with proper permissions"
+}
+
+install_application() {
+    print_status "Installing application files..."
+
+    # Copy application files
+    cp main.py "$INSTALL_DIR/"
+    cp config.py "$INSTALL_DIR/"
+    cp requirements.txt "$INSTALL_DIR/"
+
+    # Copy example config if config doesn't exist
+    if [[ ! -f "$CONFIG_DIR/config.json" ]]; then
+        cp config.json "$CONFIG_DIR/"
+        print_status "Copied example configuration to $CONFIG_DIR/config.json"
+        print_warning "Please edit $CONFIG_DIR/config.json with your backend names"
+    else
+        print_warning "Configuration file already exists, skipping copy"
+    fi
+
+    # Set permissions
+    chown -R "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_DIR"
+    chown "$SERVICE_USER:$SERVICE_GROUP" "$CONFIG_DIR/config.json"
+
+    chmod 644 "$INSTALL_DIR"/*.py
+    chmod 644 "$INSTALL_DIR/requirements.txt"
+    chmod 640 "$CONFIG_DIR/config.json"  # Restrictive permissions for config
+}
+
+setup_python_environment() {
+    print_status "Setting up Python virtual environment..."
+
+    # Create virtual environment
+    sudo -u "$SERVICE_USER" python3 -m venv "$INSTALL_DIR/venv"
+
+    # Install dependencies
+    sudo -u "$SERVICE_USER" "$INSTALL_DIR/venv/bin/pip" install --upgrade pip
+    sudo -u "$SERVICE_USER" "$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt"
+
+    print_status "Python environment setup complete"
+}
+
+setup_logging() {
+    print_status "Setting up logging..."
+
+    # Create log file
+    touch "$LOG_FILE"
+    chown "$SERVICE_USER:$SERVICE_GROUP" "$LOG_FILE"
+    chmod 644 "$LOG_FILE"
+
+    # Setup log rotation
+    cat > /etc/logrotate.d/emergency-access << EOF
+$LOG_FILE {
+    daily
+    rotate 30
+    compress
+    delaycompress
+    missingok
+    notifempty
+    create 644 $SERVICE_USER $SERVICE_GROUP
+    postrotate
+        systemctl reload emergency-access.service > /dev/null 2>&1 || true
+    endscript
+}
+EOF
+
+    print_status "Logging configuration complete"
+}
+
+install_systemd_service() {
+    print_status "Installing systemd service..."
+
+    # Copy service file
+    cp emergency-access.service "$SERVICE_FILE"
+
+    # Reload systemd
+    systemctl daemon-reload
+
+    print_status "Systemd service installed"
+}
+
+create_example_files() {
+    print_status "Creating example key and dummy files..."
+
+    # Create example key file
+    if [[ ! -f "$CONFIG_DIR/key-part.txt" ]]; then
+        echo "EXAMPLE_KEY_PART_$(openssl rand -hex 16)" > "$CONFIG_DIR/key-part.txt"
+        chown "$SERVICE_USER:$SERVICE_GROUP" "$CONFIG_DIR/key-part.txt"
+        chmod 600 "$CONFIG_DIR/key-part.txt"
+        print_status "Created example key file: $CONFIG_DIR/key-part.txt"
+        print_warning "Replace this with your actual key part!"
+    fi
+
+    # Create dummy file
+    if [[ ! -f "$CONFIG_DIR/dummy.txt" ]]; then
+        echo "system_healthy_$(date +%s)" > "$CONFIG_DIR/dummy.txt"
+        chown "$SERVICE_USER:$SERVICE_GROUP" "$CONFIG_DIR/dummy.txt"
+        chmod 644 "$CONFIG_DIR/dummy.txt"
+        print_status "Created dummy file: $CONFIG_DIR/dummy.txt"
+    fi
+}
+
+
+
+print_final_instructions() {
+    print_status "Installation complete!"
+    echo
+    print_warning "IMPORTANT: Before starting the service:"
+    echo "1. Edit $CONFIG_DIR/config.json with your backend names from global ntfy config"
+    echo "2. Replace $CONFIG_DIR/key-part.txt with your actual key part"
+    echo "3. Ensure your global ntfy configuration (~/.ntfy.yml) has the required backends"
+    echo "4. Test the configuration"
+    echo
+    print_status "Service management commands:"
+    echo "  Start service:    sudo systemctl start emergency-access"
+    echo "  Enable at boot:   sudo systemctl enable emergency-access"
+    echo "  Check status:     sudo systemctl status emergency-access"
+    echo "  View logs:        sudo journalctl -u emergency-access -f"
+    echo "  View log file:    sudo tail -f $LOG_FILE"
+    echo
+    print_status "Configuration files:"
+    echo "  Service config:   $CONFIG_DIR/config.json"
+    echo "  Key file:         $CONFIG_DIR/key-part.txt"
+    echo "  Dummy file:       $CONFIG_DIR/dummy.txt"
+    echo "  Log file:         $LOG_FILE"
+    echo
+    print_warning "Security note: This server provides access to sensitive key material."
+    print_warning "Ensure proper network security and monitoring are in place."
+}
+
+main() {
+    print_status "Starting Emergency Access Server installation..."
+
+    check_root
+    install_dependencies
+    create_user
+    setup_directories
+    install_application
+    setup_python_environment
+    setup_logging
+    install_systemd_service
+    create_example_files
+    print_final_instructions
+
+    print_status "Installation completed successfully!"
+}
+
+# Run main function
+main "$@"

+ 264 - 0
main.py

@@ -0,0 +1,264 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import logging
+import time
+import tempfile
+from flask import Flask, jsonify
+from config import Config
+from typing import List, Tuple
+
+# Configure logging
+logging.basicConfig(
+    level=logging.INFO,
+    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+    handlers=[
+        logging.FileHandler('/var/log/emergency-access.log'),
+        logging.StreamHandler()
+    ]
+)
+logger = logging.getLogger(__name__)
+
+app = Flask(__name__)
+
+# Global config instance
+config = None
+
+def send_ntfy_notification(backends: List[str], message: str, title: str = None) -> Tuple[bool, List[str]]:
+    """
+    Send notification using dschep/ntfy with global config
+    Returns: (success, successful_backends)
+    """
+    successful_backends = []
+
+    for backend in backends:
+        try:
+            # Import ntfy here to avoid import issues during startup
+            import ntfy
+
+            # Send notification using the backend name from global ntfy config
+            if title:
+                ntfy.notify(message, title=title, backend=backend)
+            else:
+                ntfy.notify(message, backend=backend)
+
+            successful_backends.append(backend)
+            logger.info(f"Notification sent successfully via {backend}")
+
+        except ImportError:
+            logger.error(f"ntfy package not available for backend {backend}")
+        except Exception as e:
+            logger.error(f"Failed to send notification to {backend}: {str(e)}")
+
+    success = len(successful_backends) > 0
+    return success, successful_backends
+
+def read_file_safely(file_path: str) -> Tuple[bool, str]:
+    """
+    Safely read file content
+    Returns: (success, content)
+    """
+    try:
+        if not os.path.exists(file_path):
+            logger.error(f"File not found: {file_path}")
+            return False, f"File not found: {file_path}"
+
+        with open(file_path, 'r') as f:
+            content = f.read().strip()
+
+        if not content:
+            logger.error(f"File is empty: {file_path}")
+            return False, f"File is empty: {file_path}"
+
+        return True, content
+
+    except PermissionError:
+        logger.error(f"Permission denied reading file: {file_path}")
+        return False, f"Permission denied: {file_path}"
+    except Exception as e:
+        logger.error(f"Failed to read file {file_path}: {str(e)}")
+        return False, f"Failed to read file: {str(e)}"
+
+def get_key_part():
+    """Emergency key access endpoint"""
+    logger.warning("EMERGENCY: Key access attempt detected")
+
+    try:
+        # Send notification first - fail-safe approach
+        notification_success, successful_backends = send_ntfy_notification(
+            config.ntfy_backends_key,
+            config.ntfy_key_message,
+            "EMERGENCY ACCESS ALERT"
+        )
+
+        if not notification_success:
+            logger.error("CRITICAL: Failed to send notifications to any backend")
+            return jsonify({
+                'error': 'Notification system failure',
+                'message': 'Access denied for security reasons'
+            }), 500
+
+        logger.info(f"Notifications sent successfully to: {successful_backends}")
+
+        # Read key file
+        file_success, content = read_file_safely(config.key_file_path)
+
+        if not file_success:
+            logger.error(f"CRITICAL: Failed to read key file: {content}")
+            return jsonify({
+                'error': 'File access failure',
+                'message': 'Unable to retrieve key part'
+            }), 500
+
+        logger.warning("EMERGENCY: Key part successfully retrieved and sent")
+        return jsonify({
+            'success': True,
+            'key_part': content,
+            'timestamp': time.time(),
+            'notified_backends': successful_backends
+        })
+
+    except Exception as e:
+        logger.error(f"CRITICAL: Unexpected error in key access: {str(e)}")
+        return jsonify({
+            'error': 'System error',
+            'message': 'Internal server error'
+        }), 500
+
+def health_check():
+    """Health check endpoint with dummy file access"""
+    logger.info("Health check requested")
+
+    try:
+        # Send notification
+        notification_success, successful_backends = send_ntfy_notification(
+            config.ntfy_backends_health,
+            config.ntfy_health_message,
+            "Health Check"
+        )
+
+        if not notification_success:
+            logger.error("Health check notification failed")
+            return jsonify({
+                'status': 'error',
+                'message': 'Notification system failure'
+            }), 500
+
+        # Read dummy file
+        file_success, content = read_file_safely(config.dummy_file_path)
+
+        if not file_success:
+            logger.error(f"Health check file read failed: {content}")
+            return jsonify({
+                'status': 'error',
+                'message': 'File system failure'
+            }), 500
+
+        logger.info("Health check completed successfully")
+        return jsonify({
+            'status': 'ok',
+            'timestamp': time.time(),
+            'notified_backends': successful_backends,
+            'dummy_content_length': len(content)
+        })
+
+    except Exception as e:
+        logger.error(f"Health check error: {str(e)}")
+        return jsonify({
+            'status': 'error',
+            'message': 'System error'
+        }), 500
+
+@app.errorhandler(404)
+def not_found(error):
+    """Handle 404 errors silently for security"""
+    logger.warning(f"404 attempt: {error}")
+    return jsonify({'error': 'Not found'}), 404
+
+@app.errorhandler(500)
+def internal_error(error):
+    """Handle internal server errors"""
+    logger.error(f"Internal server error: {error}")
+    return jsonify({'error': 'Internal server error'}), 500
+
+def validate_setup():
+    """Validate system setup before starting"""
+    logger.info("Validating system setup...")
+
+    # Check config files exist
+    if not os.path.exists(config.key_file_path):
+        logger.error(f"Key file not found: {config.key_file_path}")
+        return False
+
+    if not os.path.exists(config.dummy_file_path):
+        logger.error(f"Dummy file not found: {config.dummy_file_path}")
+        return False
+
+    # Test file permissions
+    try:
+        with open(config.key_file_path, 'r') as f:
+            f.read(1)
+        with open(config.dummy_file_path, 'r') as f:
+            f.read(1)
+    except Exception as e:
+        logger.error(f"File permission test failed: {str(e)}")
+        return False
+
+    # Test notification system
+    logger.info("Testing notification system...")
+    try:
+        key_success, _ = send_ntfy_notification(
+            config.ntfy_backends_key[:1],  # Test only first backend
+            "System startup test - key notifications",
+            "Emergency Access Startup Test"
+        )
+        health_success, _ = send_ntfy_notification(
+            config.ntfy_backends_health[:1],  # Test only first backend
+            "System startup test - health notifications",
+            "Emergency Access Startup Test"
+        )
+
+        if not key_success:
+            logger.error("Key notification system test failed")
+            return False
+
+        if not health_success:
+            logger.error("Health notification system test failed")
+            return False
+    except Exception as e:
+        logger.warning(f"Notification test failed, but continuing: {str(e)}")
+
+    logger.info("System validation completed successfully")
+    return True
+
+if __name__ == '__main__':
+    try:
+        # Load configuration
+        config = Config()
+        logger.info("Configuration loaded successfully")
+
+        # Validate system setup
+        if not validate_setup():
+            logger.error("System validation failed, exiting")
+            sys.exit(1)
+
+        # Add Flask routes with config values
+        app.add_url_rule(config.key_route, 'get_key_part', get_key_part, methods=['GET'])
+        app.add_url_rule(config.health_route, 'health_check', health_check, methods=['GET'])
+
+        logger.info(f"Starting emergency access server on {config.server_host}:{config.server_port}")
+        logger.info(f"Key route: {config.key_route}")
+        logger.info(f"Health route: {config.health_route}")
+
+        # Run the server on local port for Caddy reverse proxy
+        app.run(
+            host=config.server_host,
+            port=config.server_port,
+            debug=False,
+            threaded=True
+        )
+
+    except Exception as e:
+        logger.error(f"Failed to start server: {str(e)}")
+        sys.exit(1)

+ 4 - 0
requirements.txt

@@ -0,0 +1,4 @@
+flask==2.3.3
+ntfy==2.7.0
+pyyaml==6.0.1
+gunicorn==21.2.0

+ 430 - 0
test.py

@@ -0,0 +1,430 @@
+#!/usr/bin/env python3
+
+"""
+Test script for Emergency Access Server
+Validates system configuration, file access, notifications, and endpoints
+"""
+
+import os
+import sys
+import json
+import time
+import yaml
+import tempfile
+import subprocess
+from typing import Dict, List, Tuple, Any
+from config import Config
+
+class EmergencyAccessTester:
+    def __init__(self, config_path: str = None):
+        """Initialize tester with configuration"""
+        self.config = Config(config_path)
+        self.test_results = []
+        self.server_process = None
+
+    def log_test(self, test_name: str, success: bool, message: str = ""):
+        """Log test result"""
+        status = "PASS" if success else "FAIL"
+        result = {
+            'test': test_name,
+            'status': status,
+            'message': message,
+            'timestamp': time.time()
+        }
+        self.test_results.append(result)
+
+        color = '\033[92m' if success else '\033[91m'  # Green or Red
+        reset = '\033[0m'
+        print(f"{color}[{status}]{reset} {test_name}: {message}")
+
+        return success
+
+    def test_config_loading(self) -> bool:
+        """Test configuration file loading"""
+        try:
+            # Test all required config properties
+            self.config.server_host
+            self.config.server_port
+            self.config.key_route
+            self.config.health_route
+            self.config.key_file_path
+            self.config.dummy_file_path
+            self.config.ntfy_backends_key
+            self.config.ntfy_backends_health
+
+            return self.log_test("Config Loading", True, "All config properties accessible")
+        except Exception as e:
+            return self.log_test("Config Loading", False, f"Error: {str(e)}")
+
+    def test_file_access(self) -> bool:
+        """Test access to key and dummy files"""
+        success = True
+
+        # Test key file
+        try:
+            if not os.path.exists(self.config.key_file_path):
+                self.log_test("Key File Exists", False, f"File not found: {self.config.key_file_path}")
+                success = False
+            else:
+                with open(self.config.key_file_path, 'r') as f:
+                    content = f.read().strip()
+                if not content:
+                    self.log_test("Key File Content", False, "Key file is empty")
+                    success = False
+                else:
+                    self.log_test("Key File Access", True, f"Key file readable, length: {len(content)}")
+        except Exception as e:
+            self.log_test("Key File Access", False, f"Error: {str(e)}")
+            success = False
+
+        # Test dummy file
+        try:
+            if not os.path.exists(self.config.dummy_file_path):
+                self.log_test("Dummy File Exists", False, f"File not found: {self.config.dummy_file_path}")
+                success = False
+            else:
+                with open(self.config.dummy_file_path, 'r') as f:
+                    content = f.read().strip()
+                if not content:
+                    self.log_test("Dummy File Content", False, "Dummy file is empty")
+                    success = False
+                else:
+                    self.log_test("Dummy File Access", True, f"Dummy file readable, length: {len(content)}")
+        except Exception as e:
+            self.log_test("Dummy File Access", False, f"Error: {str(e)}")
+            success = False
+
+        return success
+
+    def test_ntfy_connectivity(self) -> bool:
+        """Test ntfy backend connectivity"""
+        success = True
+
+        # Test importing ntfy
+        try:
+            import ntfy
+            self.log_test("ntfy Import", True, "dschep/ntfy package available")
+        except ImportError:
+            self.log_test("ntfy Import", False, "dschep/ntfy package not installed")
+            return False
+
+        # Test key notification backends
+        for backend in self.config.ntfy_backends_key:
+            try:
+                # Just verify the backend name is valid (assumes global ntfy config is set up)
+                # We don't actually send notifications during testing
+                if backend and isinstance(backend, str) and len(backend.strip()) > 0:
+                    self.log_test(f"Key Backend: {backend}", True, "Backend name valid (using global ntfy config)")
+                else:
+                    self.log_test(f"Key Backend: {backend}", False, "Invalid backend name")
+                    success = False
+
+            except Exception as e:
+                self.log_test(f"Key Backend: {backend}", False, f"Error: {str(e)}")
+                success = False
+
+        # Test health notification backends
+        for backend in self.config.ntfy_backends_health:
+            try:
+                # Similar validation for health backends
+                if backend and isinstance(backend, str) and len(backend.strip()) > 0:
+                    self.log_test(f"Health Backend: {backend}", True, "Backend name valid (using global ntfy config)")
+                else:
+                    self.log_test(f"Health Backend: {backend}", False, "Invalid backend name")
+                    success = False
+
+            except Exception as e:
+                self.log_test(f"Health Backend: {backend}", False, f"Error: {str(e)}")
+                success = False
+
+        return success
+
+    def start_test_server(self) -> bool:
+        """Start the server for endpoint testing"""
+        try:
+            import subprocess
+            import time
+
+            # Start server in background
+            cmd = [sys.executable, "main.py"]
+            env = os.environ.copy()
+            env['EMERGENCY_CONFIG'] = self.config.config_path
+
+            self.server_process = subprocess.Popen(
+                cmd,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.PIPE,
+                env=env
+            )
+
+            # Wait for server to start
+            time.sleep(3)
+
+            # Check if server is running
+            if self.server_process.poll() is None:
+                return self.log_test("Server Start", True, "Server started successfully")
+            else:
+                stdout, stderr = self.server_process.communicate()
+                error_msg = stderr.decode() if stderr else "Unknown error"
+                return self.log_test("Server Start", False, f"Server failed to start: {error_msg}")
+
+        except Exception as e:
+            return self.log_test("Server Start", False, f"Error: {str(e)}")
+
+    def stop_test_server(self):
+        """Stop the test server"""
+        if self.server_process and self.server_process.poll() is None:
+            self.server_process.terminate()
+            try:
+                self.server_process.wait(timeout=5)
+            except subprocess.TimeoutExpired:
+                self.server_process.kill()
+                self.server_process.wait()
+            self.log_test("Server Stop", True, "Server stopped successfully")
+
+    def test_endpoints(self) -> bool:
+        """Test server endpoints"""
+        success = True
+        base_url = f"http://{self.config.server_host}:{self.config.server_port}"
+
+        # Test health endpoint
+        try:
+            import requests
+            response = requests.get(
+                f"{base_url}{self.config.health_route}",
+                timeout=30
+            )
+
+            if response.status_code == 200:
+                data = response.json()
+                if data.get('status') == 'ok':
+                    self.log_test("Health Endpoint", True, f"Response: {response.status_code}")
+                else:
+                    self.log_test("Health Endpoint", False, f"Invalid response: {data}")
+                    success = False
+            else:
+                self.log_test("Health Endpoint", False, f"HTTP {response.status_code}")
+                success = False
+
+        except Exception as e:
+            self.log_test("Health Endpoint", False, f"Error: {str(e)}")
+            success = False
+
+        # Test key endpoint
+        try:
+            import requests
+            response = requests.get(
+                f"{base_url}{self.config.key_route}",
+                timeout=30
+            )
+
+            if response.status_code == 200:
+                data = response.json()
+                if data.get('success') and 'key_part' in data:
+                    self.log_test("Key Endpoint", True, f"Key retrieved successfully")
+                else:
+                    self.log_test("Key Endpoint", False, f"Invalid response: {data}")
+                    success = False
+            else:
+                self.log_test("Key Endpoint", False, f"HTTP {response.status_code}")
+                success = False
+
+        except Exception as e:
+            self.log_test("Key Endpoint", False, f"Error: {str(e)}")
+            success = False
+
+        # Test 404 handling
+        try:
+            import requests
+            response = requests.get(f"{base_url}/nonexistent-path", timeout=10)
+            if response.status_code == 404:
+                self.log_test("404 Handling", True, "Correctly returns 404 for invalid paths")
+            else:
+                self.log_test("404 Handling", False, f"Expected 404, got {response.status_code}")
+                success = False
+        except Exception as e:
+            self.log_test("404 Handling", False, f"Error: {str(e)}")
+            success = False
+
+        return success
+
+    def test_fail_safe_behavior(self) -> bool:
+        """Test fail-safe behavior with invalid backends"""
+        success = True
+
+        # Create temporary config with invalid backends
+        try:
+            with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
+                invalid_config = {
+                    "server": {
+                        "host": self.config.server_host,
+                        "port": self.config.server_port + 1  # Different port
+                    },
+                    "routes": {
+                        "key_route": "/test-key",
+                        "health_route": "/test-health"
+                    },
+                    "files": {
+                        "key_file": self.config.key_file_path,
+                        "dummy_file": self.config.dummy_file_path
+                    },
+                    "notifications": {
+                        "key_backends": ["invalid_backend_test"],
+                        "health_backends": ["invalid_backend_test"],
+                        "key_message": "Test message",
+                        "health_message": "Test health message"
+                    }
+                }
+                json.dump(invalid_config, f)
+                temp_config_path = f.name
+
+            # Test with invalid config
+            test_config = Config(temp_config_path)
+
+            # Start server with invalid config
+            cmd = [sys.executable, "main.py"]
+            env = os.environ.copy()
+            env['EMERGENCY_CONFIG'] = temp_config_path
+
+            test_process = subprocess.Popen(
+                cmd,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.PIPE,
+                env=env
+            )
+
+            time.sleep(3)
+
+            if test_process.poll() is None:
+                # Try to access endpoints - should fail due to notification failures
+                base_url = f"http://{test_config.server_host}:{test_config.server_port}"
+
+                try:
+                    import requests
+                    response = requests.get(f"{base_url}/test-key", timeout=15)
+                    if response.status_code == 500:
+                        self.log_test("Fail-Safe Key", True, "Correctly blocks access when notifications fail")
+                    else:
+                        self.log_test("Fail-Safe Key", False, f"Expected 500, got {response.status_code}")
+                        success = False
+                except Exception as e:
+                    self.log_test("Fail-Safe Key", False, f"Error testing fail-safe: {str(e)}")
+                    success = False
+
+                # Clean up
+                test_process.terminate()
+                try:
+                    test_process.wait(timeout=5)
+                except subprocess.TimeoutExpired:
+                    test_process.kill()
+            else:
+                self.log_test("Fail-Safe Test", False, "Test server failed to start")
+                success = False
+
+            # Clean up temp file
+            os.unlink(temp_config_path)
+
+        except Exception as e:
+            self.log_test("Fail-Safe Test", False, f"Error: {str(e)}")
+            success = False
+
+        return success
+
+    def run_all_tests(self) -> bool:
+        """Run all tests and return overall success"""
+        print("=" * 60)
+        print("Emergency Access Server Test Suite")
+        print("=" * 60)
+
+        overall_success = True
+
+        # Configuration tests
+        print("\n--- Configuration Tests ---")
+        overall_success &= self.test_config_loading()
+
+        # File system tests
+        print("\n--- File System Tests ---")
+        overall_success &= self.test_file_access()
+
+        # Network tests
+        print("\n--- Network Tests ---")
+        overall_success &= self.test_ntfy_connectivity()
+
+        # Server tests
+        print("\n--- Server Tests ---")
+        if self.start_test_server():
+            time.sleep(2)  # Give server time to fully start
+            overall_success &= self.test_endpoints()
+            self.stop_test_server()
+        else:
+            overall_success = False
+
+        # Fail-safe tests
+        print("\n--- Fail-Safe Tests ---")
+        overall_success &= self.test_fail_safe_behavior()
+
+        # Print summary
+        self.print_summary()
+
+        return overall_success
+
+    def print_summary(self):
+        """Print test summary"""
+        print("\n" + "=" * 60)
+        print("TEST SUMMARY")
+        print("=" * 60)
+
+        passed = sum(1 for r in self.test_results if r['status'] == 'PASS')
+        failed = sum(1 for r in self.test_results if r['status'] == 'FAIL')
+        total = len(self.test_results)
+
+        print(f"Total Tests: {total}")
+        print(f"Passed: {passed}")
+        print(f"Failed: {failed}")
+
+        if failed > 0:
+            print("\nFAILED TESTS:")
+            for result in self.test_results:
+                if result['status'] == 'FAIL':
+                    print(f"  - {result['test']}: {result['message']}")
+
+        overall_status = "PASS" if failed == 0 else "FAIL"
+        color = '\033[92m' if failed == 0 else '\033[91m'
+        reset = '\033[0m'
+        print(f"\n{color}Overall Status: {overall_status}{reset}")
+
+def main():
+    """Main function"""
+    import argparse
+
+    parser = argparse.ArgumentParser(description='Test Emergency Access Server')
+    parser.add_argument('--config', help='Configuration file path')
+    parser.add_argument('--quick', action='store_true', help='Run quick tests only (skip server startup)')
+
+    args = parser.parse_args()
+
+    try:
+        tester = EmergencyAccessTester(args.config)
+
+        if args.quick:
+            # Quick tests only
+            success = True
+            success &= tester.test_config_loading()
+            success &= tester.test_file_access()
+            success &= tester.test_ntfy_connectivity()
+            tester.print_summary()
+        else:
+            # Full test suite
+            success = tester.run_all_tests()
+
+        sys.exit(0 if success else 1)
+
+    except KeyboardInterrupt:
+        print("\nTest interrupted by user")
+        sys.exit(1)
+    except Exception as e:
+        print(f"Test suite failed: {str(e)}")
+        sys.exit(1)
+
+if __name__ == '__main__':
+    main()