From 3f6f14bd133f5feb5ef91f5a76d7e471fbd18f3b Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Wed, 21 Jan 2026 16:36:59 +0530 Subject: [PATCH] Add tunneling options to slack-app-setup and enhance service mode configuration (#1772) * Add tunnelling to setup commands and adv. security configuration in streamline service-create * Update Readme and unit-tests * Remove -enc flag and update default KSM app name * Fix review comments, pentest port expose issue, remove tls from setup commands, remove api-key from docker and UI print messages --- keepercommander/enforcement.py | 2 +- keepercommander/service/README.md | 119 ++++++++++- .../service/commands/create_service.py | 29 ++- .../commands/service_config_handlers.py | 56 +++++- .../service/commands/service_docker_setup.py | 184 +++++++++++------- .../service/commands/slack_app_setup.py | 54 ++--- .../service/config/command_validator.py | 2 +- .../service/config/config_validation.py | 5 + .../service/config/record_handler.py | 28 ++- .../service/config/service_config.py | 6 +- .../service/decorators/security.py | 24 +-- .../service/docker/compose_builder.py | 60 +++--- keepercommander/service/docker/models.py | 25 ++- keepercommander/service/docker/setup_base.py | 95 ++++++++- unit-tests/service/test_config_validation.py | 4 + unit-tests/service/test_create_service.py | 52 +++-- unit-tests/service/test_service_config.py | 4 +- 17 files changed, 555 insertions(+), 194 deletions(-) diff --git a/keepercommander/enforcement.py b/keepercommander/enforcement.py index 2e0a91338..2ab093115 100644 --- a/keepercommander/enforcement.py +++ b/keepercommander/enforcement.py @@ -51,7 +51,7 @@ def requires_master_password_reentry(cls, params: KeeperParams, operation: str = operation = operation.strip()[:100] # Limit length and strip whitespace # Bypass enforcement when running in service mode if params and hasattr(params, 'service_mode') and params.service_mode: - logging.info(f"Bypassing master password enforcement for operation '{operation}' - running in service mode") + logging.debug(f"Bypassing master password enforcement for operation '{operation}' - running in service mode") return False if not params or not params.enforcements: diff --git a/keepercommander/service/README.md b/keepercommander/service/README.md index 8077e3bef..bfb70e26b 100644 --- a/keepercommander/service/README.md +++ b/keepercommander/service/README.md @@ -18,6 +18,8 @@ The Service Mode module for Keeper Commander enables REST API integration by pro | `service-stop` | Gracefully stop the running service | | `service-status` | Display current service status | | `service-config-add` | Add new API configuration and command access settings | +| `service-docker-setup` | Automated Docker service mode setup with KSM configuration | +| `slack-app-setup` | Automated Slack App integration setup with Commander Service Mode | ### Security Features - API key authentication @@ -90,6 +92,9 @@ Parameters: - `-q, --queue_enabled`: Enable request queue (y/n) - `-dip, --deniedip`: Denied IP list to access service - `-aip, --allowedip`: Allowed IP list to access service +- `-rl, --ratelimit`: Rate limit (e.g., "10/minute") +- `-ek, --encryption_key`: Encryption key for response encryption (automatically enables encryption) +- `-te, --token_expiration`: Token expiration time (e.g., "30m", "24h", "7d") ### Service Management @@ -208,7 +213,8 @@ result_retention: 3600 # Result retention (1 hour) #### Rate Limiting - **Default limits**: 60/minute, 600/hour, 6000/day -- **Example**: Setting `"20/minute"` effectively provides ~20 requests per minute across all endpoints +- **Per-endpoint tracking**: Each API endpoint has independent rate limit counters +- **Example**: Setting `"20/minute"` provides 20 requests per minute per endpoint per IP address #### Error Responses @@ -294,7 +300,7 @@ curl -X POST 'http://localhost:/api/v2/executecommand-async' \ The service configuration is stored as an attachment to a vault record in JSON/YAML format and includes: -- **Service Title**: Identifier for the service configuration +- **Service Title**: Identifier for the service configuration (default: "Commander Service Mode Config") - **Port Number**: Port for the API server - **Run Mode**: Service execution mode (foreground/background) - **Ngrok Configuration** (optional): @@ -401,9 +407,99 @@ Verify the image was pulled: docker images | grep keeper/commander ``` -### Authentication Methods +### Quick Setup with service-docker-setup (Recommended) -The Docker container supports four authentication methods: +If you have Keeper Secrets Manager (KSM) activated in your account, you can use the `service-docker-setup` command for automated Docker deployment setup: + +**Prerequisites:** +- Active Keeper vault with KSM enabled +- Docker installed and image pulled + +**Setup Steps:** + +1. **Login to Keeper:** + ```bash + keeper shell + My Vault> login user@example.com + ``` + +2. **Run automated setup:** + ```bash + My Vault> service-docker-setup + ``` + + This command will automatically: + - Register your device and enable persistent login + - Create a shared folder ("Commander Service Mode - Docker") + - Create a config record with `config.json` attachment + - Create a KSM application + - Share the folder with the KSM app + - Generate a KSM client device with base64 config + - Generate `docker-compose.yml` with the complete configuration + +3. **Interactive Configuration:** + + You'll be prompted to configure: + - **Port**: Service port (default: 8900) + - **Commands**: Allowed commands (default: tree,ls) + - **Queue Mode**: Enable async API v2 (default: yes) + - **Ngrok Tunneling** (optional): Public URL via ngrok + - **Cloudflare Tunneling** (optional): Public URL via Cloudflare + - **Advanced Security** (optional): + - IP filtering (allowed/denied lists) + - Rate limiting + - Response encryption + - Token expiration + +4. **Deploy:** + ```bash + My Vault> quit + $ rm ~/.keeper/config.json # Prevent device token conflicts + $ docker compose up -d + ``` + +**Example Output:** +``` +Resources Created: + • Shared Folder: Commander Service Mode - Docker + • KSM App: Commander Service Mode - KSM App + • Config Record: + • KSM Base64 Config: ✓ Generated +``` + +The generated `docker-compose.yml` includes all your configuration and can be customized before deployment. + +### Slack App Integration Setup + +For integrating Commander Service Mode with Slack, use the `slack-app-setup` command: + +```bash +My Vault> slack-app-setup +``` + +This automates the complete setup for Slack App integration: +- **Phase 1**: Runs Docker setup (same as `service-docker-setup`) +- **Phase 2**: Configures Slack App integration + - Collects Slack tokens (App Token, Bot Token, Signing Secret) + - Creates Slack configuration record + - Updates `docker-compose.yml` with Slack App service + - Supports optional PEDM and Device Approval integrations + +**Configuration Options:** +- Port selection (default: 8900) +- Ngrok/Cloudflare tunneling for public URL exposure +- Slack App credentials +- Approvals channel ID +- Optional PEDM integration +- Optional SSO Cloud Device Approval + +The command generates a complete `docker-compose.yml` with both Commander service and Slack App service configured. + +--- + +### Manual Authentication Methods (Alternative) + +If you prefer manual setup or don't have KSM activated, the Docker container supports four authentication methods: #### Method 1: Using KSM Config File Use Keeper Secrets Manager (KSM) config file to download the `config.json` configuration from a Keeper record. The container will: @@ -662,11 +758,16 @@ docker run -d -p : \ docker logs ``` -3. **Get API key from logs:** - Look for the API key in the container logs: - ``` - Generated API key: - ``` +3. **Get API key from logs or vault:** + - **Docker mode**: The API key is redacted in logs for security (only last 4 characters shown) with the vault record UID displayed: + ``` + Generated API key: ****nQ= (stored in vault record: I2eqTs5efnJ_iqbtSuEagQ) + ``` + Retrieve the full key from your Keeper vault using the record UID. + - **Direct service-create**: The full API key is displayed in the output for immediate use: + ``` + Generated API key: H4uyn0L-_QJL-o_UBMbs7DESA13ZgdJ_ea2bnQ= + ``` 4. **Follow logs in real-time:** ```bash diff --git a/keepercommander/service/commands/create_service.py b/keepercommander/service/commands/create_service.py index c77c062c0..cca65e787 100644 --- a/keepercommander/service/commands/create_service.py +++ b/keepercommander/service/commands/create_service.py @@ -29,11 +29,14 @@ class StreamlineArgs: cloudflare: Optional[str] cloudflare_custom_domain: Optional[str] certfile: Optional[str] - certpassword : Optional[str] - fileformat : Optional[str] + certpassword: Optional[str] + fileformat: Optional[str] run_mode: Optional[str] queue_enabled: Optional[str] update_vault_record: Optional[str] + ratelimit: Optional[str] + encryption_key: Optional[str] + token_expiration: Optional[str] class CreateService(Command): """Command to create a new service configuration.""" @@ -74,6 +77,9 @@ def get_parser(self): parser.add_argument('-rm', '--run_mode', type=str, help='run mode') parser.add_argument('-q', '--queue_enabled', type=str, help='enable request queue (y/n)') parser.add_argument('-ur', '--update-vault-record', dest='update_vault_record', type=str, help='CSMD Config record UID to update with service metadata (Docker mode)') + parser.add_argument('-rl', '--ratelimit', type=str, help='rate limit (e.g., 10/minute, 100/hour)') + parser.add_argument('-ek', '--encryption_key', type=str, help='encryption key for response encryption (32 alphanumeric characters)') + parser.add_argument('-te', '--token_expiration', type=str, help='API token expiration (e.g., 30m, 24h, 7d)') return parser def execute(self, params: KeeperParams, **kwargs) -> None: @@ -88,7 +94,7 @@ def execute(self, params: KeeperParams, **kwargs) -> None: config_data = self.service_config.create_default_config() - filtered_kwargs = {k: v for k, v in kwargs.items() if k in ['port', 'allowedip', 'deniedip', 'commands', 'ngrok', 'ngrok_custom_domain', 'cloudflare', 'cloudflare_custom_domain', 'certfile', 'certpassword', 'fileformat', 'run_mode', 'queue_enabled', 'update_vault_record']} + filtered_kwargs = {k: v for k, v in kwargs.items() if k in ['port', 'allowedip', 'deniedip', 'commands', 'ngrok', 'ngrok_custom_domain', 'cloudflare', 'cloudflare_custom_domain', 'certfile', 'certpassword', 'fileformat', 'run_mode', 'queue_enabled', 'update_vault_record', 'ratelimit', 'encryption', 'encryption_key', 'token_expiration']} args = StreamlineArgs(**filtered_kwargs) self._handle_configuration(config_data, params, args) api_key = self._create_and_save_record(config_data, params, args) @@ -118,7 +124,7 @@ def _create_and_save_record(self, config_data: Dict[str, Any], params: KeeperPar if args.port is None: self.config_handler._configure_run_mode(config_data) - record = self.service_config.create_record(config_data["is_advanced_security_enabled"], params, args.commands) + record = self.service_config.create_record(config_data["is_advanced_security_enabled"], params, args.commands, args.token_expiration, args.update_vault_record) config_data["records"] = [record] if config_data.get("fileformat"): format_type = config_data["fileformat"] @@ -138,17 +144,24 @@ def _upload_and_start_service(self, params: KeeperParams) -> None: ServiceManager.start_service() def _get_service_url(self, config_data: Dict[str, Any]) -> str: - """Determine the actual service URL (ngrok, cloudflare, or localhost)""" + """Determine the actual service URL (ngrok, cloudflare, or localhost) with API version path""" + # Determine API version based on queue_enabled + queue_enabled = config_data.get("queue_enabled", "y") + api_path = "/api/v2" if queue_enabled == "y" else "/api/v1" + # Priority: ngrok > cloudflare > localhost + base_url = "" if config_data.get("ngrok_public_url"): - return config_data["ngrok_public_url"] + base_url = config_data["ngrok_public_url"] elif config_data.get("cloudflare_public_url"): - return config_data["cloudflare_public_url"] + base_url = config_data["cloudflare_public_url"] else: # Fallback to localhost with correct protocol port = config_data.get("port", 8080) protocol = "https" if config_data.get("tls_certificate") == "y" else "http" - return f"{protocol}://localhost:{port}" + base_url = f"{protocol}://localhost:{port}" + + return f"{base_url}{api_path}" def _update_vault_record_with_metadata(self, params: KeeperParams, record_uid: str, service_url: str, api_key: str) -> None: """Update CSMD Config vault record with service URL and API key as custom fields (Docker mode only)""" diff --git a/keepercommander/service/commands/service_config_handlers.py b/keepercommander/service/commands/service_config_handlers.py index 51a6dea43..c63a01072 100644 --- a/keepercommander/service/commands/service_config_handlers.py +++ b/keepercommander/service/commands/service_config_handlers.py @@ -65,6 +65,9 @@ def handle_streamlined_config(self, config_data: Dict[str, Any], args, params: K cloudflare_enabled = "y" if args.cloudflare else "n" # Implement the same logic as interactive mode + ngrok_public_url = "" + cloudflare_public_url = "" + if ngrok_enabled == "y": # ngrok enabled → disable cloudflare and TLS cloudflare_enabled = "n" @@ -73,6 +76,14 @@ def handle_streamlined_config(self, config_data: Dict[str, Any], args, params: K tls_enabled = "n" certfile = "" certpassword = "" + # Construct ngrok public URL from custom domain + if args.ngrok_custom_domain: + ngrok_domain = args.ngrok_custom_domain.strip() + # If it's just a subdomain (no dots), append .ngrok.io + if '.' not in ngrok_domain: + ngrok_public_url = f"https://{ngrok_domain}.ngrok.io" + else: + ngrok_public_url = f"https://{ngrok_domain}" logger.debug("Ngrok enabled - disabling cloudflare and TLS") elif cloudflare_enabled == "y": # cloudflare enabled → disable TLS, but validate required fields @@ -86,6 +97,8 @@ def handle_streamlined_config(self, config_data: Dict[str, Any], args, params: K certpassword = "" cloudflare_token = self.service_config.validator.validate_cloudflare_token(args.cloudflare) cloudflare_domain = self.service_config.validator.validate_domain(args.cloudflare_custom_domain) + # Construct cloudflare public URL from custom domain + cloudflare_public_url = f"https://{cloudflare_domain}" logger.debug("Cloudflare enabled - disabling TLS") else: # Both ngrok and cloudflare disabled → allow TLS @@ -96,6 +109,21 @@ def handle_streamlined_config(self, config_data: Dict[str, Any], args, params: K cloudflare_domain = "" logger.debug("No tunnels enabled - TLS configuration allowed") + # Handle advanced security options + rate_limiting = "" + if args.ratelimit: + rate_limiting = self.service_config.validator.validate_rate_limit(args.ratelimit) + + encryption_enabled = "n" + encryption_key = "" + if args.encryption_key: + encryption_enabled = "y" + encryption_key = self.service_config.validator.validate_encryption_key(args.encryption_key) + + # Validate token expiration format if provided (actual usage is in record creation) + if args.token_expiration: + self.service_config.validator.parse_expiration_time(args.token_expiration) + config_data.update({ "port": self.service_config.validator.validate_port(args.port), "ip_allowed_list": self.service_config.validator.validate_ip_list(args.allowedip), @@ -106,15 +134,20 @@ def handle_streamlined_config(self, config_data: Dict[str, Any], args, params: K if ngrok_enabled == "y" else "" ), "ngrok_custom_domain": args.ngrok_custom_domain if ngrok_enabled == "y" else "", + "ngrok_public_url": ngrok_public_url, "cloudflare": cloudflare_enabled, "cloudflare_tunnel_token": cloudflare_token, "cloudflare_custom_domain": cloudflare_domain, + "cloudflare_public_url": cloudflare_public_url, "tls_certificate": tls_enabled, "certfile": certfile, "certpassword": certpassword, "fileformat": args.fileformat, # Keep original logic - can be None "run_mode": run_mode, - "queue_enabled": queue_enabled + "queue_enabled": queue_enabled, + "rate_limiting": rate_limiting, + "encryption": encryption_enabled, + "encryption_private_key": encryption_key }) @debug_decorator @@ -150,6 +183,7 @@ def _configure_tunneling_and_tls(self, config_data: Dict[str, Any]) -> None: config_data["cloudflare"] = "n" config_data["cloudflare_tunnel_token"] = "" config_data["cloudflare_custom_domain"] = "" + config_data["cloudflare_public_url"] = "" config_data["tls_certificate"] = "n" config_data["certfile"] = "" config_data["certpassword"] = "" @@ -174,13 +208,23 @@ def _configure_ngrok(self, config_data: Dict[str, Any]) -> None: try: token = input(self.messages['ngrok_token_prompt']) config_data["ngrok_auth_token"] = self.service_config.validator.validate_ngrok_token(token) - config_data["ngrok_custom_domain"] = input(self.messages['ngrok_custom_domain_prompt']) - # print(f"ngrok custom domain >> "+{config_data["ngrok_custom_domain"]}) + config_data["ngrok_custom_domain"] = input(self.messages['ngrok_custom_domain_prompt']) + # Construct ngrok public URL from custom domain + if config_data["ngrok_custom_domain"]: + ngrok_domain = config_data["ngrok_custom_domain"].strip() + # If it's just a subdomain (no dots), append .ngrok.io + if '.' not in ngrok_domain: + config_data["ngrok_public_url"] = f"https://{ngrok_domain}.ngrok.io" + else: + config_data["ngrok_public_url"] = f"https://{ngrok_domain}" + else: + config_data["ngrok_public_url"] = "" break except ValidationError as e: print(f"{self.validation_messages['invalid_ngrok_token']} {str(e)}") else: config_data["ngrok_auth_token"] = "" + config_data["ngrok_public_url"] = "" def _configure_cloudflare(self, config_data: Dict[str, Any]) -> None: config_data["cloudflare"] = self.service_config._get_yes_no_input( @@ -201,9 +245,15 @@ def _configure_cloudflare(self, config_data: Dict[str, Any]) -> None: error_key='invalid_cloudflare_domain', required=True ) + # Construct cloudflare public URL from custom domain + if config_data["cloudflare_custom_domain"]: + config_data["cloudflare_public_url"] = f"https://{config_data['cloudflare_custom_domain']}" + else: + config_data["cloudflare_public_url"] = "" else: config_data["cloudflare_tunnel_token"] = "" config_data["cloudflare_custom_domain"] = "" + config_data["cloudflare_public_url"] = "" def _configure_tls(self, config_data: Dict[str, Any]) -> None: config_data["tls_certificate"] = self.service_config._get_yes_no_input(self.messages['tls_certificate']) diff --git a/keepercommander/service/commands/service_docker_setup.py b/keepercommander/service/commands/service_docker_setup.py index aec0dd2a7..9f58d871a 100644 --- a/keepercommander/service/commands/service_docker_setup.py +++ b/keepercommander/service/commands/service_docker_setup.py @@ -117,17 +117,14 @@ def get_service_configuration(self, params) -> ServiceConfig: if not ngrok_config['ngrok_enabled']: cloudflare_config = self._get_cloudflare_config() - - # TLS only if no tunneling - if not cloudflare_config['cloudflare_enabled']: - tls_config = self._get_tls_config() - else: - tls_config = {'tls_enabled': False, 'cert_file': '', 'cert_password': ''} else: cloudflare_config = { - 'cloudflare_enabled': False, 'cloudflare_tunnel_token': '', 'cloudflare_custom_domain': '' + 'cloudflare_enabled': False, 'cloudflare_tunnel_token': '', + 'cloudflare_custom_domain': '', 'cloudflare_public_url': '' } - tls_config = {'tls_enabled': False, 'cert_file': '', 'cert_password': ''} + + # Advanced security options + security_config = self._get_advanced_security_config() return ServiceConfig( port=port, @@ -136,12 +133,17 @@ def get_service_configuration(self, params) -> ServiceConfig: ngrok_enabled=ngrok_config['ngrok_enabled'], ngrok_auth_token=ngrok_config['ngrok_auth_token'], ngrok_custom_domain=ngrok_config['ngrok_custom_domain'], + ngrok_public_url=ngrok_config.get('ngrok_public_url', ''), cloudflare_enabled=cloudflare_config['cloudflare_enabled'], cloudflare_tunnel_token=cloudflare_config['cloudflare_tunnel_token'], cloudflare_custom_domain=cloudflare_config['cloudflare_custom_domain'], - tls_enabled=tls_config['tls_enabled'], - cert_file=tls_config['cert_file'], - cert_password=tls_config['cert_password'] + cloudflare_public_url=cloudflare_config.get('cloudflare_public_url', ''), + allowed_ip=security_config['allowed_ip'], + denied_ip=security_config['denied_ip'], + rate_limit=security_config['rate_limit'], + encryption_enabled=security_config['encryption_enabled'], + encryption_key=security_config['encryption_key'], + token_expiration=security_config['token_expiration'] ) def generate_docker_compose_yaml(self, setup_result: SetupResult, config: ServiceConfig) -> str: @@ -214,98 +216,136 @@ def _get_queue_config(self) -> bool: queue_input = input(f"{bcolors.OKBLUE}Enable queue mode? [Press Enter for Yes] (y/n):{bcolors.ENDC} ").strip().lower() return queue_input != 'n' - def _get_ngrok_config(self) -> Dict[str, Any]: - """Get ngrok configuration""" - print(f"\n{bcolors.BOLD}Ngrok Tunneling (optional):{bcolors.ENDC}") - print(f" Generate a public URL for your service using ngrok") - use_ngrok = input(f"{bcolors.OKBLUE}Enable ngrok? [Press Enter for No] (y/n):{bcolors.ENDC} ").strip().lower() == 'y' + + def _get_advanced_security_config(self) -> Dict[str, Any]: + """Get advanced security configuration""" + print(f"\n{bcolors.BOLD}Advanced Security (optional):{bcolors.ENDC}") + print(f" Configure IP filtering, rate limiting, and response encryption") + enable_advanced = input(f"{bcolors.OKBLUE}Enable advanced security? [Press Enter for No] (y/n):{bcolors.ENDC} ").strip().lower() == 'y' - config = {'ngrok_enabled': use_ngrok, 'ngrok_auth_token': '', 'ngrok_custom_domain': ''} + config = { + 'allowed_ip': '0.0.0.0/0,::/0', + 'denied_ip': '', + 'rate_limit': '', + 'encryption_enabled': False, + 'encryption_key': '', + 'token_expiration': '' + } - if use_ngrok: + if enable_advanced: + # IP Allowed List + config.update(self._get_ip_allowed_config()) + + # IP Denied List + config.update(self._get_ip_denied_config()) + + # Rate Limiting + config.update(self._get_rate_limit_config()) + + # Encryption + config.update(self._get_encryption_config()) + + # Token Expiration + config.update(self._get_token_expiration_config()) + + return config + + def _get_ip_allowed_config(self) -> Dict[str, str]: + """Get allowed IP configuration""" + print(f"\n{bcolors.BOLD}IP Allowed List:{bcolors.ENDC}") + print(f" Comma-separated IPs or CIDR ranges (e.g., 192.168.1.0/24,10.0.0.1)") + + ip_list = input(f"{bcolors.OKBLUE}Allowed IPs [Press Enter for all]:{bcolors.ENDC} ").strip() + + if ip_list: while True: - token = input(f"{bcolors.OKBLUE}Ngrok auth token:{bcolors.ENDC} ").strip() try: - config['ngrok_auth_token'] = ConfigValidator.validate_ngrok_token(token) - break + return {'allowed_ip': ConfigValidator.validate_ip_list(ip_list)} except ValidationError as e: print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") - - # Validate custom domain if provided (ngrok allows subdomain prefixes) - domain = input(f"{bcolors.OKBLUE}Ngrok custom domain [Press Enter to skip]:{bcolors.ENDC} ").strip() - if domain: - while True: - try: - config['ngrok_custom_domain'] = ConfigValidator.validate_domain(domain, require_tld=False) + ip_list = input(f"{bcolors.OKBLUE}Allowed IPs [Press Enter for all]:{bcolors.ENDC} ").strip() + if not ip_list: break - except ValidationError as e: - print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") - domain = input(f"{bcolors.OKBLUE}Ngrok custom domain [Press Enter to skip]:{bcolors.ENDC} ").strip() - if not domain: - break - return config + return {'allowed_ip': '0.0.0.0/0,::/0'} - def _get_cloudflare_config(self) -> Dict[str, Any]: - """Get Cloudflare configuration""" - print(f"\n{bcolors.BOLD}Cloudflare Tunneling (optional):{bcolors.ENDC}") - print(f" Generate a public URL for your service using Cloudflare") - use_cloudflare = input(f"{bcolors.OKBLUE}Enable Cloudflare? [Press Enter for No] (y/n):{bcolors.ENDC} ").strip().lower() == 'y' + def _get_ip_denied_config(self) -> Dict[str, str]: + """Get denied IP configuration""" + print(f"\n{bcolors.BOLD}IP Denied List:{bcolors.ENDC}") + print(f" Comma-separated IPs or CIDR ranges to block") - config = {'cloudflare_enabled': use_cloudflare, 'cloudflare_tunnel_token': '', 'cloudflare_custom_domain': ''} + ip_list = input(f"{bcolors.OKBLUE}Denied IPs [Press Enter to skip]:{bcolors.ENDC} ").strip() - if use_cloudflare: + if ip_list: while True: - token = input(f"{bcolors.OKBLUE}Cloudflare tunnel token:{bcolors.ENDC} ").strip() try: - config['cloudflare_tunnel_token'] = ConfigValidator.validate_cloudflare_token(token) - break + return {'denied_ip': ConfigValidator.validate_ip_list(ip_list)} except ValidationError as e: print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") - + ip_list = input(f"{bcolors.OKBLUE}Denied IPs [Press Enter to skip]:{bcolors.ENDC} ").strip() + if not ip_list: + break + + return {'denied_ip': ''} + + def _get_rate_limit_config(self) -> Dict[str, str]: + """Get rate limiting configuration""" + print(f"\n{bcolors.BOLD}Rate Limiting:{bcolors.ENDC}") + print(f" Format: / (e.g., 10/minute, 100/hour, 1000/day)") + + rate_limit = input(f"{bcolors.OKBLUE}Rate limit [Press Enter to skip]:{bcolors.ENDC} ").strip() + + if rate_limit: while True: - domain = input(f"{bcolors.OKBLUE}Cloudflare custom domain:{bcolors.ENDC} ").strip() try: - config['cloudflare_custom_domain'] = ConfigValidator.validate_domain(domain) + return {'rate_limit': ConfigValidator.validate_rate_limit(rate_limit)} + except ValidationError as e: + print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") + rate_limit = input(f"{bcolors.OKBLUE}Rate limit [Press Enter to skip]:{bcolors.ENDC} ").strip() + if not rate_limit: + break + + return {'rate_limit': ''} + + def _get_encryption_config(self) -> Dict[str, Any]: + """Get encryption configuration""" + print(f"\n{bcolors.BOLD}Response Encryption:{bcolors.ENDC}") + print(f" Enable AES-256 encryption for API responses") + enable_encryption = input(f"{bcolors.OKBLUE}Enable encryption? [Press Enter for No] (y/n):{bcolors.ENDC} ").strip().lower() == 'y' + + config = {'encryption_enabled': enable_encryption, 'encryption_key': ''} + + if enable_encryption: + print(f" Encryption key must be exactly 32 alphanumeric characters") + while True: + key = input(f"{bcolors.OKBLUE}Encryption key (32 chars):{bcolors.ENDC} ").strip() + try: + config['encryption_key'] = ConfigValidator.validate_encryption_key(key) break except ValidationError as e: print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") return config - def _get_tls_config(self) -> Dict[str, Any]: - """Get TLS configuration""" - print(f"\n{bcolors.BOLD}TLS Certificate (optional):{bcolors.ENDC}") - print(f" Use custom TLS certificate for HTTPS") - use_tls = input(f"{bcolors.OKBLUE}Enable TLS? [Press Enter for No] (y/n):{bcolors.ENDC} ").strip().lower() == 'y' + def _get_token_expiration_config(self) -> Dict[str, str]: + """Get token expiration configuration""" + print(f"\n{bcolors.BOLD}API Token Expiration:{bcolors.ENDC}") + print(f" Format: Xm (minutes), Xh (hours), Xd (days) - e.g., 30m, 24h, 7d") - config = {'tls_enabled': use_tls, 'cert_file': '', 'cert_password': ''} + expiration = input(f"{bcolors.OKBLUE}Token expiration [Press Enter for never]:{bcolors.ENDC} ").strip() - if use_tls: + if expiration: while True: - cert_file = input(f"{bcolors.OKBLUE}Certificate file path:{bcolors.ENDC} ").strip() try: - if cert_file and os.path.exists(cert_file): - config['cert_file'] = ConfigValidator.validate_cert_file(cert_file) - break - print(f"{bcolors.FAIL}Error: Certificate file not found{bcolors.ENDC}") + ConfigValidator.parse_expiration_time(expiration) + return {'token_expiration': expiration} except ValidationError as e: print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") - - # Certificate password validation (optional) - cert_password = input(f"{bcolors.OKBLUE}Certificate password:{bcolors.ENDC} ").strip() - if cert_password: - while True: - try: - config['cert_password'] = ConfigValidator.validate_certpassword(cert_password) + expiration = input(f"{bcolors.OKBLUE}Token expiration [Press Enter for never]:{bcolors.ENDC} ").strip() + if not expiration: break - except ValidationError as e: - print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") - cert_password = input(f"{bcolors.OKBLUE}Certificate password:{bcolors.ENDC} ").strip() - if not cert_password: - break - return config + return {'token_expiration': ''} def _get_config_path(self, config_path: str = None) -> str: """Get and validate config file path""" diff --git a/keepercommander/service/commands/slack_app_setup.py b/keepercommander/service/commands/slack_app_setup.py index e8f4a7dcc..e8d15ff93 100644 --- a/keepercommander/service/commands/slack_app_setup.py +++ b/keepercommander/service/commands/slack_app_setup.py @@ -22,7 +22,7 @@ from ..config.config_validation import ConfigValidator, ValidationError from ..docker import ( SetupResult, DockerSetupPrinter, DockerSetupConstants, - ServiceConfig, SlackConfig, DockerComposeBuilder + ServiceConfig, SlackConfig, DockerComposeBuilder, DockerSetupBase ) slack_app_setup_parser = argparse.ArgumentParser( @@ -31,8 +31,8 @@ formatter_class=argparse.RawDescriptionHelpFormatter ) slack_app_setup_parser.add_argument( - '--folder-name', dest='folder_name', type=str, default=DockerSetupConstants.DEFAULT_FOLDER_NAME, - help=f'Name for the shared folder (default: "{DockerSetupConstants.DEFAULT_FOLDER_NAME}")' + '--folder-name', dest='folder_name', type=str, default=DockerSetupConstants.DEFAULT_SLACK_FOLDER_NAME, + help=f'Name for the shared folder (default: "{DockerSetupConstants.DEFAULT_SLACK_FOLDER_NAME}")' ) slack_app_setup_parser.add_argument( '--app-name', dest='app_name', type=str, default=DockerSetupConstants.DEFAULT_APP_NAME, @@ -62,7 +62,7 @@ slack_app_setup_parser.exit = suppress_exit -class SlackAppSetupCommand(Command): +class SlackAppSetupCommand(Command, DockerSetupBase): """Automated Slack App integration setup command""" def get_parser(self): @@ -76,7 +76,7 @@ def execute(self, params, **kwargs): setup_result, service_config, config_path = self._run_base_docker_setup(params, kwargs) DockerSetupPrinter.print_completion("Service Mode Configuration Complete!") - + # Phase 2: Slack-specific setup print(f"\n{bcolors.BOLD}Phase 2: Slack App Integration Setup{bcolors.ENDC}") @@ -86,10 +86,10 @@ def execute(self, params, **kwargs): service_config, kwargs.get('slack_record_name', DockerSetupConstants.DEFAULT_SLACK_RECORD_NAME) ) - + # Print consolidated success message self._print_success_message(setup_result, service_config, slack_record_uid, slack_config, config_path) - + return def _run_base_docker_setup(self, params, kwargs: Dict[str, Any]) -> Tuple[SetupResult, Dict[str, Any], str]: @@ -103,14 +103,14 @@ def _run_base_docker_setup(self, params, kwargs: Dict[str, Any]) -> Tuple[SetupR config_path = kwargs.get('config_path') or os.path.expanduser('~/.keeper/config.json') if not os.path.isfile(config_path): raise CommandError('slack-app-setup', f'Config file not found: {config_path}') - + # Print header DockerSetupPrinter.print_header("Docker Setup") - + # Run core setup steps (Steps 1-7) setup_result = docker_cmd.run_setup_steps( params=params, - folder_name=kwargs.get('folder_name', DockerSetupConstants.DEFAULT_FOLDER_NAME), + folder_name=kwargs.get('folder_name', DockerSetupConstants.DEFAULT_SLACK_FOLDER_NAME), app_name=kwargs.get('app_name', DockerSetupConstants.DEFAULT_APP_NAME), record_name=kwargs.get('config_record_name', DockerSetupConstants.DEFAULT_RECORD_NAME), config_path=config_path, @@ -129,10 +129,10 @@ def _run_base_docker_setup(self, params, kwargs: Dict[str, Any]) -> Tuple[SetupR return setup_result, service_config, config_path def _get_slack_service_configuration(self) -> ServiceConfig: - """Get simplified service configuration for Slack App (only port needed)""" + """Get service configuration for Slack App (port + tunneling options)""" DockerSetupPrinter.print_header("Service Mode Configuration") - - # Only ask for port with validation + + # Port configuration print(f"{bcolors.BOLD}Port:{bcolors.ENDC}") print(f" The port on which Commander Service will listen") while True: @@ -143,20 +143,28 @@ def _get_slack_service_configuration(self) -> ServiceConfig: except ValidationError as e: print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") - # Fixed configuration for Slack App + # Get tunneling configuration + ngrok_config = self._get_ngrok_config() + + # Only ask for Cloudflare if ngrok is not enabled + if not ngrok_config['ngrok_enabled']: + cloudflare_config = self._get_cloudflare_config() + else: + cloudflare_config = {'cloudflare_enabled': False, 'cloudflare_tunnel_token': '', + 'cloudflare_custom_domain': '', 'cloudflare_public_url': ''} + return ServiceConfig( port=port, commands='search,share-record,share-folder,record-add,one-time-share,pedm,device-approve,get', queue_enabled=True, # Always enable queue mode (v2 API) - ngrok_enabled=False, - ngrok_auth_token='', - ngrok_custom_domain='', - cloudflare_enabled=False, - cloudflare_tunnel_token='', - cloudflare_custom_domain='', - tls_enabled=False, - cert_file='', - cert_password='' + ngrok_enabled=ngrok_config['ngrok_enabled'], + ngrok_auth_token=ngrok_config['ngrok_auth_token'], + ngrok_custom_domain=ngrok_config['ngrok_custom_domain'], + ngrok_public_url=ngrok_config.get('ngrok_public_url', ''), + cloudflare_enabled=cloudflare_config['cloudflare_enabled'], + cloudflare_tunnel_token=cloudflare_config['cloudflare_tunnel_token'], + cloudflare_custom_domain=cloudflare_config['cloudflare_custom_domain'], + cloudflare_public_url=cloudflare_config.get('cloudflare_public_url', '') ) def _run_slack_setup(self, params, setup_result: SetupResult, service_config: ServiceConfig, diff --git a/keepercommander/service/config/command_validator.py b/keepercommander/service/config/command_validator.py index 522cfaf0b..24f5a2907 100644 --- a/keepercommander/service/config/command_validator.py +++ b/keepercommander/service/config/command_validator.py @@ -139,7 +139,7 @@ def validate_command_list(self, commands: str, valid_commands: Set) -> str: else: invalid_commands.append(cmd) - return ", ".join(validated_commands), invalid_commands + return ",".join(validated_commands), invalid_commands def generate_command_error_message(self, invalid_commands: List[str], command_info: Dict[str, Any]) -> str: """Generate helpful error message for invalid commands.""" diff --git a/keepercommander/service/config/config_validation.py b/keepercommander/service/config/config_validation.py index e82b3ec04..c4cf07c6c 100644 --- a/keepercommander/service/config/config_validation.py +++ b/keepercommander/service/config/config_validation.py @@ -174,6 +174,11 @@ def validate_rate_limit(rate_limit: str) -> str: msg = ("Invalid rate limit format. Use formats like 'X/minute', 'X/hour', 'X/day', " "'X per minute', 'X per hour', or 'X per day'.") raise ValidationError(msg) + + # Extract the numeric value and check if it's 0 + numeric_value = int(re.match(r'^\d+', rate_limit).group()) + if numeric_value == 0: + raise ValidationError("Rate limit value cannot be 0. Please specify a positive number.") logger.debug("Rate limit validation successful") return rate_limit diff --git a/keepercommander/service/config/record_handler.py b/keepercommander/service/config/record_handler.py index 51ff416ba..f93f4efb5 100644 --- a/keepercommander/service/config/record_handler.py +++ b/keepercommander/service/config/record_handler.py @@ -26,16 +26,26 @@ def __init__(self): self.cli_handler = CommandHandler() @debug_decorator - def create_record(self, is_advanced_security_enabled: str, commands: str) -> Dict[str, Any]: + def create_record(self, is_advanced_security_enabled: str, commands: str, token_expiration: str = None, record_uid: str = None) -> Dict[str, Any]: """Create a new configuration record.""" api_key = generate_api_key() record = self._create_base_record(api_key, commands) - if is_advanced_security_enabled == "y": + # Handle token expiration - either from CLI arg (streamlined) or interactive prompt + if token_expiration: + # Streamlined mode - use provided expiration + self._set_expiration_from_string(record, token_expiration) + elif is_advanced_security_enabled == "y": + # Interactive mode - prompt for expiration logger.debug("Adding expiration to record (advanced security enabled)") self._add_expiration_to_record(record) - print(f'Generated API key: {api_key}') + # Docker mode: redact API key and show vault record UID + if record_uid: + redacted_key = f"****{api_key[-4:]}" if len(api_key) >= 4 else "****" + print(f'Generated API key: {redacted_key} (stored in vault record: {record_uid})') + else: + print(f'Generated API key: {api_key}') return record def update_or_add_record(self, params: KeeperParams, title: str, config_path: Path) -> None: @@ -113,23 +123,27 @@ def _create_base_record(self, api_key: str, commands: str) -> Dict[str, Any]: @debug_decorator def _add_expiration_to_record(self, record: Dict[str, Any]) -> None: - """Add expiration details to the record.""" + """Add expiration details to the record via interactive prompt.""" expiration_str = input( "Token Expiration Time (Xm, Xh, Xd) or empty for no expiration: " ).strip() if not expiration_str: - #record["expiration_of_token"] = "" record["expiration_timestamp"] = datetime(9999, 12, 31, 23, 59, 59).isoformat() print("API key set to never expire") return + if not self._set_expiration_from_string(record, expiration_str): + self._add_expiration_to_record(record) + + def _set_expiration_from_string(self, record: Dict[str, Any], expiration_str: str) -> bool: + """Set expiration timestamp from expiration string (e.g., 5m, 24h, 7d). Returns True on success.""" try: expiration_delta = self.validator.parse_expiration_time(expiration_str) expiration_time = datetime.now() + expiration_delta - #record["expiration_of_token"] = expiration_str record["expiration_timestamp"] = expiration_time.isoformat() print(f"API key will expire at: {record['expiration_timestamp']}") + return True except ValidationError as e: print(f"Error: {str(e)}") - self._add_expiration_to_record(record) \ No newline at end of file + return False \ No newline at end of file diff --git a/keepercommander/service/config/service_config.py b/keepercommander/service/config/service_config.py index b72f16b6c..35f1d0e89 100644 --- a/keepercommander/service/config/service_config.py +++ b/keepercommander/service/config/service_config.py @@ -27,7 +27,7 @@ VALID_CERT_EXTENSIONS = {".pem", ".crt", ".cer", ".key"} class ServiceConfig: - def __init__(self, title: str = 'Commander Service Mode'): + def __init__(self, title: str = 'Commander Service Mode Config'): self.title = title self.config = ConfigParser() @@ -300,10 +300,10 @@ def _get_validated_commands(self, params: KeeperParams) -> str: print(f"\nError: {str(e)}") print("\nPlease try again with valid commands.") - def create_record(self, is_advanced_security_enabled: str, params: KeeperParams, commands: Optional[str] = None) -> Dict[str, Any]: + def create_record(self, is_advanced_security_enabled: str, params: KeeperParams, commands: Optional[str] = None, token_expiration: str = None, record_uid: Optional[str] = None) -> Dict[str, Any]: """Create a new configuration record.""" commands = self.validate_command_list(commands, params) if commands else self._get_validated_commands(params) - return self.record_handler.create_record(is_advanced_security_enabled, commands) + return self.record_handler.create_record(is_advanced_security_enabled, commands, token_expiration, record_uid) def update_or_add_record(self, params: KeeperParams) -> None: """Update existing record or add new one.""" diff --git a/keepercommander/service/decorators/security.py b/keepercommander/service/decorators/security.py index 15ce97dbb..c588256c6 100644 --- a/keepercommander/service/decorators/security.py +++ b/keepercommander/service/decorators/security.py @@ -79,23 +79,17 @@ def is_ip_in_range(ip, ip_range): except ValueError: return False +def get_rate_limit(): + """Get configured rate limit""" + return ConfigReader.read_config("rate_limiting") or "60/minute" + +def get_rate_limit_key(): + """Generate rate limit key per IP + endpoint for separate limits per endpoint""" + return f"{get_remote_address()}:{request.endpoint}" + def security_check(fn): @wraps(fn) - def get_multiplied_rate_limit(): - """Get rate limit with appropriate multiplier based on API version""" - from flask import request - base_limit = ConfigReader.read_config("rate_limiting") - if base_limit: - import re - match = re.match(r'(\d+)(/\w+)', base_limit) - if match: - number, unit = match.groups() - # v2 API has 4 endpoints sharing the limit, v1 API has only 1 - multiplier = 1 if request.path.startswith('/api/v1') else 4 - return f"{int(number) * multiplier}{unit}" - return base_limit - - @limiter.limit(get_multiplied_rate_limit) + @limiter.limit(get_rate_limit, key_func=get_rate_limit_key) def wrapper(*args, **kwargs): client_ip = request.remote_addr try: diff --git a/keepercommander/service/docker/compose_builder.py b/keepercommander/service/docker/compose_builder.py index 66fc2ec20..f7498608f 100644 --- a/keepercommander/service/docker/compose_builder.py +++ b/keepercommander/service/docker/compose_builder.py @@ -78,7 +78,7 @@ def _build_commander_service(self) -> Dict[str, Any]: service = { 'container_name': 'keeper-service', - 'ports': [f"{self.config['port']}:{self.config['port']}"], + 'ports': [f"127.0.0.1:{self.config['port']}:{self.config['port']}"], 'image': 'keeper/commander:latest', 'command': ' '.join(self._service_cmd_parts), 'healthcheck': self._build_healthcheck(), @@ -121,36 +121,50 @@ def _build_service_command(self) -> None: f"-q {'y' if queue_enabled else 'n'}" ] + self._add_security_options() self._add_tunneling_options() - self._add_tls_options() self._add_docker_options() + def _add_security_options(self) -> None: + """Add advanced security options (IP filtering, rate limiting, encryption)""" + # IP allowed list (only add if not default) + allowed_ip = self.config.get('allowed_ip', '0.0.0.0/0,::/0') + if allowed_ip and allowed_ip != '0.0.0.0/0,::/0': + self._service_cmd_parts.append(f"-aip '{allowed_ip}'") + + # IP denied list + denied_ip = self.config.get('denied_ip', '') + if denied_ip: + self._service_cmd_parts.append(f"-dip '{denied_ip}'") + + # Rate limiting + rate_limit = self.config.get('rate_limit', '') + if rate_limit: + self._service_cmd_parts.append(f"-rl '{rate_limit}'") + + # Encryption (automatically enabled if encryption_key is provided) + encryption_key = self.config.get('encryption_key', '') + if encryption_key: + self._service_cmd_parts.append(f"-ek '{encryption_key}'") + + # Token expiration + token_expiration = self.config.get('token_expiration', '') + if token_expiration: + self._service_cmd_parts.append(f"-te '{token_expiration}'") + def _add_tunneling_options(self) -> None: """Add ngrok and Cloudflare tunneling options""" # Ngrok configuration - if self.config.get('ngrok_enabled') and self.config.get('ngrok_token'): - self._service_cmd_parts.append(f"-ng {self.config['ngrok_token']}") - if self.config.get('ngrok_domain'): - self._service_cmd_parts.append(f"-cd {self.config['ngrok_domain']}") + if self.config.get('ngrok_enabled') and self.config.get('ngrok_auth_token'): + self._service_cmd_parts.append(f"-ng {self.config['ngrok_auth_token']}") + if self.config.get('ngrok_custom_domain'): + self._service_cmd_parts.append(f"-cd {self.config['ngrok_custom_domain']}") # Cloudflare configuration - if self.config.get('cloudflare_enabled') and self.config.get('cloudflare_token'): - self._service_cmd_parts.append(f"-cf {self.config['cloudflare_token']}") - if self.config.get('cloudflare_domain'): - self._service_cmd_parts.append(f"-cfd {self.config['cloudflare_domain']}") - - def _add_tls_options(self) -> None: - """Add TLS certificate options and volumes""" - if self.config.get('tls_enabled') and self.config.get('cert_file'): - cert_file = self.config['cert_file'] - cert_basename = os.path.basename(cert_file) - - self._service_cmd_parts.append(f"-crtf /certs/{cert_basename}") - if self.config.get('cert_password'): - self._service_cmd_parts.append(f"-crtp {self.config['cert_password']}") - - # Add volume mount for certificate - self._volumes.append(f"{cert_file}:/certs/{cert_basename}:ro") + if self.config.get('cloudflare_enabled') and self.config.get('cloudflare_tunnel_token'): + self._service_cmd_parts.append(f"-cf {self.config['cloudflare_tunnel_token']}") + if self.config.get('cloudflare_custom_domain'): + self._service_cmd_parts.append(f"-cfd {self.config['cloudflare_custom_domain']}") def _add_docker_options(self) -> None: """Add Docker-specific parameters (KSM config, record UIDs)""" diff --git a/keepercommander/service/docker/models.py b/keepercommander/service/docker/models.py index 44824f9a2..04a5d74b3 100644 --- a/keepercommander/service/docker/models.py +++ b/keepercommander/service/docker/models.py @@ -23,11 +23,15 @@ class DockerSetupConstants: """Constants for Docker setup command""" - # Default resource names - DEFAULT_FOLDER_NAME = 'CSMD Folder' - DEFAULT_APP_NAME = 'CSMD KSM App' - DEFAULT_RECORD_NAME = 'CSMD Config' - DEFAULT_SLACK_RECORD_NAME = 'CSMD Slack Config' + # Default resource names for service-docker-setup + DEFAULT_FOLDER_NAME = 'Commander Service Mode - Docker' + DEFAULT_APP_NAME = 'Commander Service Mode - KSM App' + DEFAULT_RECORD_NAME = 'Commander Service Mode Docker Config' + DEFAULT_CLIENT_NAME = 'Commander Service Mode - KSM App Client' + + # Default resource names for slack-app-setup + DEFAULT_SLACK_FOLDER_NAME = 'Commander Service Mode - Slack App' + DEFAULT_SLACK_RECORD_NAME = 'Commander Service Mode Slack App Config' # Default service configuration DEFAULT_PORT = 8900 @@ -82,9 +86,14 @@ class ServiceConfig: cloudflare_enabled: bool cloudflare_tunnel_token: str cloudflare_custom_domain: str - tls_enabled: bool - cert_file: str - cert_password: str + allowed_ip: str = '0.0.0.0/0,::/0' + denied_ip: str = '' + rate_limit: str = '' + encryption_enabled: bool = False + encryption_key: str = '' + token_expiration: str = '' + ngrok_public_url: str = '' + cloudflare_public_url: str = '' @dataclass diff --git a/keepercommander/service/docker/setup_base.py b/keepercommander/service/docker/setup_base.py index 15b9a4c3f..c87a544ba 100644 --- a/keepercommander/service/docker/setup_base.py +++ b/keepercommander/service/docker/setup_base.py @@ -18,17 +18,21 @@ import io import json +import logging import os import sys import tempfile +from typing import Dict, Any from ...commands.folder import FolderMakeCommand from ...commands.ksm import KSMCommand from ... import api, vault, utils, attachment, record_management, loginv3 +from ...display import bcolors from ...error import CommandError -from .models import SetupResult, SetupStep +from .models import SetupResult, SetupStep, DockerSetupConstants from .printer import DockerSetupPrinter +from ..config.config_validation import ConfigValidator, ValidationError class DockerSetupBase: @@ -112,7 +116,13 @@ def _setup_device(self, params, timeout: str) -> None: # Timeout DockerSetupPrinter.print_success(f"Setting logout timeout to {timeout}...") - ThisDeviceCommand().execute(params, ops=['timeout', timeout]) + # Suppress command output + old_stdout = sys.stdout + sys.stdout = io.StringIO() + try: + ThisDeviceCommand().execute(params, ops=['timeout', timeout]) + finally: + sys.stdout = old_stdout except Exception as e: raise CommandError('docker-setup', f'Device setup failed: {str(e)}') @@ -272,9 +282,12 @@ def _share_folder_with_app(self, params, app_uid: str, folder_uid: str) -> None: if not app_rec: raise CommandError('docker-setup', 'App not found') - # Suppress output + # Suppress all output (stdout and logging) old_stdout = sys.stdout + old_log_level = logging.root.level + sys.stdout = io.StringIO() + logging.root.setLevel(logging.CRITICAL + 1) # Disable all logging try: KSMCommand.add_app_share( params, @@ -284,15 +297,15 @@ def _share_folder_with_app(self, params, app_uid: str, folder_uid: str) -> None: ) finally: sys.stdout = old_stdout - - DockerSetupPrinter.print_success("Folder shared with app successfully") + logging.root.setLevel(old_log_level) + DockerSetupPrinter.print_success("Folder shared with app") except Exception as e: raise CommandError('docker-setup', f'Failed to share folder with app: {str(e)}') def _create_client_device(self, params, app_uid: str, app_name: str) -> str: """Create client device and return b64 config""" try: - client_name = f"{app_name} Docker Client" + client_name = DockerSetupConstants.DEFAULT_CLIENT_NAME tokens_and_devices = KSMCommand.add_client( params=params, @@ -316,3 +329,73 @@ def _create_client_device(self, params, app_uid: str, app_name: str) -> str: except Exception as e: raise CommandError('docker-setup', f'Failed to create client device: {str(e)}') + # ======================== + # Shared Configuration Methods + # ======================== + + def _get_ngrok_config(self) -> Dict[str, Any]: + """Get ngrok configuration""" + print(f"\n{bcolors.BOLD}Ngrok Tunneling (optional):{bcolors.ENDC}") + print(f" Generate a public URL for your service using ngrok") + use_ngrok = input(f"{bcolors.OKBLUE}Enable ngrok? [Press Enter for No] (y/n):{bcolors.ENDC} ").strip().lower() == 'y' + + config = {'ngrok_enabled': use_ngrok, 'ngrok_auth_token': '', 'ngrok_custom_domain': '', 'ngrok_public_url': ''} + + if use_ngrok: + while True: + token = input(f"{bcolors.OKBLUE}Ngrok auth token:{bcolors.ENDC} ").strip() + try: + config['ngrok_auth_token'] = ConfigValidator.validate_ngrok_token(token) + break + except ValidationError as e: + print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") + + # Validate custom domain if provided (ngrok allows subdomain prefixes) + domain = input(f"{bcolors.OKBLUE}Ngrok custom domain [Press Enter to skip]:{bcolors.ENDC} ").strip() + if domain: + while True: + try: + config['ngrok_custom_domain'] = ConfigValidator.validate_domain(domain, require_tld=False) + # Construct ngrok public URL + if '.' not in config['ngrok_custom_domain']: + config['ngrok_public_url'] = f"https://{config['ngrok_custom_domain']}.ngrok.io" + else: + config['ngrok_public_url'] = f"https://{config['ngrok_custom_domain']}" + break + except ValidationError as e: + print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") + domain = input(f"{bcolors.OKBLUE}Ngrok custom domain [Press Enter to skip]:{bcolors.ENDC} ").strip() + if not domain: + break + + return config + + def _get_cloudflare_config(self) -> Dict[str, Any]: + """Get Cloudflare configuration""" + print(f"\n{bcolors.BOLD}Cloudflare Tunneling (optional):{bcolors.ENDC}") + print(f" Generate a public URL for your service using Cloudflare") + use_cloudflare = input(f"{bcolors.OKBLUE}Enable Cloudflare? [Press Enter for No] (y/n):{bcolors.ENDC} ").strip().lower() == 'y' + + config = {'cloudflare_enabled': use_cloudflare, 'cloudflare_tunnel_token': '', + 'cloudflare_custom_domain': '', 'cloudflare_public_url': ''} + + if use_cloudflare: + while True: + token = input(f"{bcolors.OKBLUE}Cloudflare tunnel token:{bcolors.ENDC} ").strip() + try: + config['cloudflare_tunnel_token'] = ConfigValidator.validate_cloudflare_token(token) + break + except ValidationError as e: + print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") + + while True: + domain = input(f"{bcolors.OKBLUE}Cloudflare custom domain:{bcolors.ENDC} ").strip() + try: + config['cloudflare_custom_domain'] = ConfigValidator.validate_domain(domain) + # Construct cloudflare public URL + config['cloudflare_public_url'] = f"https://{config['cloudflare_custom_domain']}" + break + except ValidationError as e: + print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") + + return config diff --git a/unit-tests/service/test_config_validation.py b/unit-tests/service/test_config_validation.py index f1482d090..77b28aa9e 100644 --- a/unit-tests/service/test_config_validation.py +++ b/unit-tests/service/test_config_validation.py @@ -83,6 +83,10 @@ def test_validate_rate_limit_invalid(self): 'abc', '10/second', '100 by hour', + '0/minute', + '0/hour', + '0/day', + '0 per minute', ] for limit in invalid_limits: with self.subTest(limit=limit): diff --git a/unit-tests/service/test_create_service.py b/unit-tests/service/test_create_service.py index 4c61c839a..a81017ea8 100644 --- a/unit-tests/service/test_create_service.py +++ b/unit-tests/service/test_create_service.py @@ -41,7 +41,7 @@ def test_execute_service_already_running(self, mock_service_manager): def test_handle_configuration_streamlined(self): """Test streamlined configuration handling.""" config_data = self.command.service_config.create_default_config() - args = StreamlineArgs(port=8080, commands='record-list', ngrok=None, allowedip='0.0.0.0' ,deniedip='', ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None, certfile='', certpassword='', fileformat='json', run_mode='foreground', queue_enabled='y', update_vault_record=None) + args = StreamlineArgs(port=8080, commands='record-list', ngrok=None, allowedip='0.0.0.0' ,deniedip='', ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None, certfile='', certpassword='', fileformat='json', run_mode='foreground', queue_enabled='y', update_vault_record=None, ratelimit=None, encryption_key=None, token_expiration=None) with patch.object(self.command.config_handler, 'handle_streamlined_config') as mock_streamlined: self.command._handle_configuration(config_data, self.params, args) @@ -50,7 +50,7 @@ def test_handle_configuration_streamlined(self): def test_handle_configuration_interactive(self): """Test interactive configuration handling.""" config_data = self.command.service_config.create_default_config() - args = StreamlineArgs(port=None, commands=None, ngrok=None, allowedip='' ,deniedip='', ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None, certfile='', certpassword='', fileformat='json', run_mode='foreground', queue_enabled=None, update_vault_record=None) + args = StreamlineArgs(port=None, commands=None, ngrok=None, allowedip='' ,deniedip='', ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None, certfile='', certpassword='', fileformat='json', run_mode='foreground', queue_enabled=None, update_vault_record=None, ratelimit=None, encryption_key=None, token_expiration=None) with patch.object(self.command.config_handler, 'handle_interactive_config') as mock_interactive, \ patch.object(self.command.security_handler, 'configure_security') as mock_security: @@ -61,7 +61,7 @@ def test_handle_configuration_interactive(self): def test_create_and_save_record(self): """Test record creation and saving.""" config_data = self.command.service_config.create_default_config() - args = StreamlineArgs(port=8080, commands='record-list', ngrok=None, allowedip='0.0.0.0' ,deniedip='', ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None, certfile='', certpassword='', fileformat='json', run_mode='foreground', queue_enabled='y', update_vault_record=None) + args = StreamlineArgs(port=8080, commands='record-list', ngrok=None, allowedip='0.0.0.0' ,deniedip='', ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None, certfile='', certpassword='', fileformat='json', run_mode='foreground', queue_enabled='y', update_vault_record=None, ratelimit=None, encryption_key=None, token_expiration=None) with patch.object(self.command.service_config, 'create_record') as mock_create_record, \ patch.object(self.command.service_config, 'save_config') as mock_save_config: @@ -72,7 +72,9 @@ def test_create_and_save_record(self): mock_create_record.assert_called_once_with( config_data["is_advanced_security_enabled"], self.params, - args.commands + args.commands, + args.token_expiration, + None # record_uid (update_vault_record is None) ) if(args.fileformat): config_data["fileformat"]= args.fileformat @@ -81,7 +83,7 @@ def test_create_and_save_record(self): def test_validation_error_handling(self): """Test handling of validation errors during execution.""" - args = StreamlineArgs(port=-1, commands='record-list', ngrok=None, allowedip='0.0.0.0' ,deniedip='', ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None, certfile='', certpassword='', fileformat='json', run_mode='foreground', queue_enabled='y', update_vault_record=None) + args = StreamlineArgs(port=-1, commands='record-list', ngrok=None, allowedip='0.0.0.0' ,deniedip='', ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None, certfile='', certpassword='', fileformat='json', run_mode='foreground', queue_enabled='y', update_vault_record=None, ratelimit=None, encryption_key=None, token_expiration=None) with patch('builtins.print') as mock_print: with patch.object(self.command.service_config, 'create_default_config') as mock_create_config: @@ -107,7 +109,10 @@ def test_cloudflare_streamlined_configuration(self): fileformat='json', run_mode='foreground', queue_enabled='y', - update_vault_record=None + update_vault_record=None, + ratelimit=None, + encryption_key=None, + token_expiration=None ) with patch.object(self.command.config_handler, 'handle_streamlined_config') as mock_streamlined: @@ -130,7 +135,10 @@ def test_cloudflare_validation_missing_token(self): fileformat='json', run_mode='foreground', queue_enabled='y', - update_vault_record=None + update_vault_record=None, + ratelimit=None, + encryption_key=None, + token_expiration=None ) with patch('builtins.print') as mock_print: @@ -155,7 +163,10 @@ def test_cloudflare_validation_missing_domain(self): fileformat='json', run_mode='foreground', queue_enabled='y', - update_vault_record=None + update_vault_record=None, + ratelimit=None, + encryption_key=None, + token_expiration=None ) with patch('builtins.print') as mock_print: @@ -180,7 +191,10 @@ def test_cloudflare_and_ngrok_mutual_exclusion(self): fileformat='json', run_mode='foreground', queue_enabled='y', - update_vault_record=None + update_vault_record=None, + ratelimit=None, + encryption_key=None, + token_expiration=None ) with patch('builtins.print') as mock_print: @@ -216,7 +230,10 @@ def test_cloudflare_tunnel_startup_success(self, mock_cloudflare_configure): fileformat='json', run_mode='foreground', queue_enabled='y', - update_vault_record=None + update_vault_record=None, + ratelimit=None, + encryption_key=None, + token_expiration=None ) with patch.object(self.command.config_handler, 'handle_streamlined_config') as mock_streamlined: @@ -263,7 +280,10 @@ def test_cloudflare_tunnel_startup_failure(self, mock_get_status, mock_start_ser fileformat='json', run_mode='foreground', queue_enabled='y', - update_vault_record=None + update_vault_record=None, + ratelimit=None, + encryption_key=None, + token_expiration=None ) # Verify that the error was printed @@ -286,7 +306,10 @@ def test_cloudflare_token_validation(self): fileformat='json', run_mode='foreground', queue_enabled='y', - update_vault_record=None + update_vault_record=None, + ratelimit=None, + encryption_key=None, + token_expiration=None ) with patch.object(self.command.config_handler, 'handle_streamlined_config') as mock_streamlined: @@ -311,7 +334,10 @@ def test_cloudflare_domain_validation(self): fileformat='json', run_mode='foreground', queue_enabled='y', - update_vault_record=None + update_vault_record=None, + ratelimit=None, + encryption_key=None, + token_expiration=None ) with patch.object(self.command.config_handler, 'handle_streamlined_config') as mock_streamlined: diff --git a/unit-tests/service/test_service_config.py b/unit-tests/service/test_service_config.py index 2dc97b9f3..4a99d6b12 100644 --- a/unit-tests/service/test_service_config.py +++ b/unit-tests/service/test_service_config.py @@ -35,7 +35,7 @@ def setUp(self): def test_create_default_config(self): """Test creation of default configuration.""" config = self.service_config.create_default_config() - self.assertEqual(config["title"], "Commander Service Mode") + self.assertEqual(config["title"], "Commander Service Mode Config") self.assertIsNone(config["port"]) self.assertEqual(config["ngrok"], "n") self.assertEqual(config["ngrok_auth_token"], "") @@ -111,7 +111,7 @@ def test_validate_command_list_valid(self, mock_cli_handler): """ params = MagicMock(spec=KeeperParams) result = self.service_config.validate_command_list("ls, get", params) - self.assertEqual(result, "ls, get") + self.assertEqual(result, "ls,get") @patch.object(ServiceConfig, 'cli_handler') def test_validate_command_list_invalid(self, mock_cli_handler):