diff --git a/keepercommander/command_categories.py b/keepercommander/command_categories.py index c2a6f6b54..b0ae463de 100644 --- a/keepercommander/command_categories.py +++ b/keepercommander/command_categories.py @@ -83,7 +83,8 @@ # Service Mode REST API 'Service Mode REST API': { 'service-create', 'service-add-config', 'service-start', 'service-stop', 'service-status', - 'service-config-add', 'service-docker-setup', 'slack-app-setup', 'teams-app-setup', + 'service-config-add', 'service-docker-setup', 'terraform-app-setup', + 'slack-app-setup', 'teams-app-setup', 'sailpoint-app-setup', 'gchat-app-setup' }, diff --git a/keepercommander/commands/start_service.py b/keepercommander/commands/start_service.py index 0d7eaded9..4037acc38 100644 --- a/keepercommander/commands/start_service.py +++ b/keepercommander/commands/start_service.py @@ -13,6 +13,7 @@ from ..service.commands.config_operation import AddConfigService from ..service.commands.handle_service import StartService, StopService, ServiceStatus from ..service.commands.service_docker_setup import ServiceDockerSetupCommand +from ..service.commands.terraform_app_setup import TerraformAppSetupCommand from ..service.commands.integrations import ( GChatAppSetupCommand, SlackAppSetupCommand, @@ -27,6 +28,7 @@ def register_commands(commands): commands['service-stop'] = StopService() commands['service-status'] = ServiceStatus() commands['service-docker-setup'] = ServiceDockerSetupCommand() + commands['terraform-app-setup'] = TerraformAppSetupCommand() commands['slack-app-setup'] = SlackAppSetupCommand() commands['teams-app-setup'] = TeamsAppSetupCommand() commands['sailpoint-app-setup'] = SailPointAppSetupCommand() @@ -40,6 +42,7 @@ def register_command_info(aliases, command_info): StopService, ServiceStatus, ServiceDockerSetupCommand, + TerraformAppSetupCommand, SlackAppSetupCommand, TeamsAppSetupCommand, SailPointAppSetupCommand, diff --git a/keepercommander/service/commands/service_docker_setup.py b/keepercommander/service/commands/service_docker_setup.py index f3795b1ef..e94d6a835 100644 --- a/keepercommander/service/commands/service_docker_setup.py +++ b/keepercommander/service/commands/service_docker_setup.py @@ -16,11 +16,9 @@ import argparse import os from dataclasses import asdict -from typing import Dict, Any from ...commands.base import Command, raise_parse_exception, suppress_exit from ...display import bcolors -from ...error import CommandError from ..config.config_validation import ConfigValidator, ValidationError from ..docker import ( DockerSetupBase, DockerSetupConstants, DockerSetupPrinter, @@ -69,19 +67,17 @@ def get_parser(self): def execute(self, params, **kwargs): """Main execution flow for standalone command""" - self._require_file_based_config(params, 'service-docker-setup') + command_name = self.get_parser().prog + self._require_file_based_config(params, command_name) - # Parse arguments config_path = self._require_commander_config_file( - 'service-docker-setup', + command_name, kwargs.get('config_path'), params, ) - - # Print header + DockerSetupPrinter.print_header("Docker Setup") - - # Run core setup steps (inherited from DockerSetupBase) + setup_result = self.run_setup_steps( params=params, folder_name=kwargs.get('folder_name', DockerSetupConstants.DEFAULT_FOLDER_NAME), @@ -91,19 +87,13 @@ def execute(self, params, **kwargs): timeout=kwargs.get('timeout', DockerSetupConstants.DEFAULT_TIMEOUT), skip_device_setup=kwargs.get('skip_device_setup', False) ) - - # Get service configuration + DockerSetupPrinter.print_completion("Docker Setup Complete!") service_config = self.get_service_configuration(params) - - # Generate docker-compose.yml + self.generate_and_save_docker_compose(setup_result, service_config) DockerSetupPrinter.print_completion("Service Mode Configuration Complete!") - - # Print success message self.print_standalone_success_message(setup_result, service_config, config_path) - - return def get_service_configuration(self, params) -> ServiceConfig: """Interactively get service configuration from user""" @@ -221,134 +211,3 @@ def _get_queue_config(self) -> bool: print(f" Queue mode enables async API (v2) for better performance") 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_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 = { - 'allowed_ip': '0.0.0.0/0,::/0', - 'denied_ip': '', - 'rate_limit': '', - 'encryption_enabled': False, - 'encryption_key': '', - 'token_expiration': '' - } - - 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: - try: - return {'allowed_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}Allowed IPs [Press Enter for all]:{bcolors.ENDC} ").strip() - if not ip_list: - break - - return {'allowed_ip': '0.0.0.0/0,::/0'} - - 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") - - ip_list = input(f"{bcolors.OKBLUE}Denied IPs [Press Enter to skip]:{bcolors.ENDC} ").strip() - - if ip_list: - while True: - try: - 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: - try: - 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_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") - - expiration = input(f"{bcolors.OKBLUE}Token expiration [Press Enter for never]:{bcolors.ENDC} ").strip() - - if expiration: - while True: - try: - ConfigValidator.parse_expiration_time(expiration) - return {'token_expiration': expiration} - except ValidationError as e: - print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") - expiration = input(f"{bcolors.OKBLUE}Token expiration [Press Enter for never]:{bcolors.ENDC} ").strip() - if not expiration: - break - - return {'token_expiration': ''} diff --git a/keepercommander/service/commands/terraform_app_setup.py b/keepercommander/service/commands/terraform_app_setup.py new file mode 100644 index 000000000..6d4cefe74 --- /dev/null +++ b/keepercommander/service/commands/terraform_app_setup.py @@ -0,0 +1,145 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + try: + return RuntimeServiceConfig().validate_command_list( + TerraformSetupConstants.SERVICE_COMMANDS, params + ) + except ValidationError as e: + raise CommandError( + self.get_parser().prog, + f'Terraform command allowlist validation failed: {e}', + ) + + def _get_commands_config(self, params) -> str: + return self._validate_terraform_commands(params) + + def _get_queue_config(self) -> bool: + return True + + def generate_docker_compose_yaml(self, setup_result: SetupResult, config: DockerServiceConfig) -> str: + builder = DockerComposeBuilder( + setup_result, + asdict(config), + commander_service_name=TerraformSetupConstants.COMMANDER_SERVICE_NAME, + commander_container_name=TerraformSetupConstants.COMMANDER_CONTAINER_NAME, + ) + return builder.build() + + def _print_next_steps(self, config: DockerServiceConfig, config_path: str) -> None: + DockerSetupPrinter.print_common_deployment_steps( + str(config.port), + config_path, + container_name=TerraformSetupConstants.COMMANDER_CONTAINER_NAME, + ) + print() diff --git a/keepercommander/service/decorators/min_commander_version.py b/keepercommander/service/decorators/min_commander_version.py new file mode 100644 index 000000000..3b3068f17 --- /dev/null +++ b/keepercommander/service/decorators/min_commander_version.py @@ -0,0 +1,98 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[Version]: + if not version_str or len(version_str) > 64: + return None + try: + return Version(version_str.lstrip('vV')) + except InvalidVersion: + return None + + +_RUNNING_VERSION_RAW = str(commander_version).strip() +_RUNNING_VERSION = _parse_version(_RUNNING_VERSION_RAW) + + +def _read_min_commander_version_header() -> Optional[str]: + value = request.headers.get(MIN_COMMANDER_VERSION_HEADER) + if value is None: + return None + value = value.strip() + return value or None + + +def check_min_commander_version() -> Optional[Tuple[dict, int]]: + """ + If Min-Commander-Version is present, require running Commander >= that version. + + Missing header: no-op. + Invalid header: 400. + Too old: 426 with upgrade guidance. + """ + required_raw = _read_min_commander_version_header() + if required_raw is None: + return None + + required = _parse_version(required_raw) + if required is None: + return { + 'status': 'error', + 'error': ( + f'Invalid {MIN_COMMANDER_VERSION_HEADER} header. ' + 'Expected a dotted version such as 18.1.0.' + ), + }, 400 + + if _RUNNING_VERSION is None: + logger.error('Unable to parse running Commander version') + return { + 'status': 'error', + 'error': 'Unable to determine running Commander version.', + }, 500 + + if _RUNNING_VERSION >= required: + return None + + message = ( + f'Commander version {_RUNNING_VERSION_RAW} is below the required minimum {required_raw}. ' + f'Please update Keeper Commander to >= {required_raw} and retry.' + ) + logger.info(message) + return {'status': 'error', 'error': message}, 426 + + +def min_commander_version_check(fn): + """Run after auth: enforce Min-Commander-Version when the header is present.""" + + @wraps(fn) + def wrapper(*args, **kwargs): + version_error = check_min_commander_version() + if version_error: + return version_error + return fn(*args, **kwargs) + + return wrapper diff --git a/keepercommander/service/decorators/unified.py b/keepercommander/service/decorators/unified.py index dc87e14d3..873d3cc3c 100644 --- a/keepercommander/service/decorators/unified.py +++ b/keepercommander/service/decorators/unified.py @@ -15,6 +15,7 @@ from .api_logging import api_log_handler from .security import security_check from .auth import auth_check, policy_check +from .min_commander_version import min_commander_version_check def unified_api_decorator() -> Callable: def decorator(f: Callable) -> Callable: @@ -22,10 +23,11 @@ def decorator(f: Callable) -> Callable: @api_log_handler @security_check @auth_check + @min_commander_version_check @policy_check @catch_all @debug_decorator def wrapped_function(*args, **kwargs): return f(*args, **kwargs) return wrapped_function - return decorator \ No newline at end of file + return decorator diff --git a/keepercommander/service/docker/__init__.py b/keepercommander/service/docker/__init__.py index c30acd0e6..cc03a716c 100644 --- a/keepercommander/service/docker/__init__.py +++ b/keepercommander/service/docker/__init__.py @@ -20,8 +20,9 @@ """ from .models import ( - DockerSetupConstants, SetupResult, ServiceConfig, SlackConfig, TeamsConfig, - SailPointConfig, GChatConfig, GChatConstants, SetupStep, ApproverTeam, ApprovalsConfig, + DockerSetupConstants, SetupResult, ServiceConfig, + SlackConfig, TeamsConfig, SailPointConfig, GChatConfig, GChatConstants, + SetupStep, ApproverTeam, ApprovalsConfig, ) from .printer import DockerSetupPrinter from .setup_base import DockerSetupBase diff --git a/keepercommander/service/docker/printer.py b/keepercommander/service/docker/printer.py index 224e44c30..564e298f7 100644 --- a/keepercommander/service/docker/printer.py +++ b/keepercommander/service/docker/printer.py @@ -61,7 +61,8 @@ def print_phase1_resources(setup_result: SetupResult, indent: str = " ") -> Non print(f"{indent}• KSM Base64 Config: {bcolors.OKGREEN}✓ Generated{bcolors.ENDC}") @staticmethod - def print_common_deployment_steps(port: str, config_path: str = None) -> None: + def print_common_deployment_steps(port: str, config_path: str = None, + container_name: str = 'keeper-service') -> None: """Print common deployment steps (header + steps 1-5)""" DockerSetupPrinter.print_header("Next Steps to Deploy") @@ -82,6 +83,6 @@ def print_common_deployment_steps(port: str, config_path: str = None) -> None: print(f"\n{bcolors.BOLD}Step 5: Check services health{bcolors.ENDC}") print(f" {bcolors.OKGREEN}docker ps{bcolors.ENDC} - View container status") - print(f" {bcolors.OKGREEN}docker logs keeper-service{bcolors.ENDC} - View Commander logs") + print(f" {bcolors.OKGREEN}docker logs {container_name}{bcolors.ENDC} - View Commander logs") print(f" {bcolors.OKGREEN}curl http://localhost:{port}/health{bcolors.ENDC} - Test health endpoint") diff --git a/keepercommander/service/docker/setup_base.py b/keepercommander/service/docker/setup_base.py index 48fbb07bb..d43eea9a6 100644 --- a/keepercommander/service/docker/setup_base.py +++ b/keepercommander/service/docker/setup_base.py @@ -584,3 +584,138 @@ def _get_cloudflare_config(self) -> Dict[str, Any]: print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") return config + + def _get_advanced_security_config(self) -> Dict[str, Any]: + """Get advanced security configuration (IP filter, rate limit, encryption, token expiry).""" + 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 = { + 'allowed_ip': '0.0.0.0/0,::/0', + 'denied_ip': '', + 'rate_limit': '', + 'encryption_enabled': False, + 'encryption_key': '', + 'token_expiration': '' + } + + if enable_advanced: + config.update(self._get_ip_allowed_config()) + config.update(self._get_ip_denied_config()) + config.update(self._get_rate_limit_config()) + config.update(self._get_encryption_config()) + 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: + try: + return {'allowed_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}Allowed IPs [Press Enter for all]:{bcolors.ENDC} " + ).strip() + if not ip_list: + break + + return {'allowed_ip': '0.0.0.0/0,::/0'} + + 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") + + ip_list = input(f"{bcolors.OKBLUE}Denied IPs [Press Enter to skip]:{bcolors.ENDC} ").strip() + + if ip_list: + while True: + try: + 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: + try: + 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_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") + + expiration = input( + f"{bcolors.OKBLUE}Token expiration [Press Enter for never]:{bcolors.ENDC} " + ).strip() + + if expiration: + while True: + try: + ConfigValidator.parse_expiration_time(expiration) + return {'token_expiration': expiration} + except ValidationError as e: + print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") + expiration = input( + f"{bcolors.OKBLUE}Token expiration [Press Enter for never]:{bcolors.ENDC} " + ).strip() + if not expiration: + break + + return {'token_expiration': ''} diff --git a/setup.cfg b/setup.cfg index 51de347d0..6b0f81bbc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,6 +43,7 @@ install_requires = prompt_toolkit protobuf>=5.29.6,<6 googleapis-common-protos + packaging psutil pycryptodomex>=3.20.0 pyngrok diff --git a/unit-tests/service/test_min_commander_version.py b/unit-tests/service/test_min_commander_version.py new file mode 100644 index 000000000..2a0b415e1 --- /dev/null +++ b/unit-tests/service/test_min_commander_version.py @@ -0,0 +1,173 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' = 18.1.0 and retry.', body['error']) + + @mock.patch( + 'keepercommander.service.decorators.min_commander_version._RUNNING_VERSION', + Version('18.1.0'), + ) + def test_rejects_invalid_header(self): + with self.app.test_request_context( + '/api/v2/executecommand-async', + method='POST', + headers={MIN_COMMANDER_VERSION_HEADER: 'not-a-version'}, + ): + body, status = check_min_commander_version() + self.assertEqual(status, 400) + self.assertEqual(body['status'], 'error') + self.assertIn('Invalid', body['error']) + + @mock.patch( + 'keepercommander.service.decorators.min_commander_version._RUNNING_VERSION', + None, + ) + def test_rejects_when_running_version_unparseable(self): + with self.app.test_request_context( + '/api/v2/executecommand-async', + method='POST', + headers={MIN_COMMANDER_VERSION_HEADER: '18.1.0'}, + ): + body, status = check_min_commander_version() + self.assertEqual(status, 500) + self.assertEqual(body['status'], 'error') + self.assertIn('Unable to determine running Commander version', body['error']) + + @mock.patch( + 'keepercommander.service.decorators.min_commander_version._RUNNING_VERSION', + Version('17.0.0'), + ) + @mock.patch( + 'keepercommander.service.decorators.min_commander_version._RUNNING_VERSION_RAW', + '17.0.0', + ) + def test_decorator_blocks_handler(self): + called = {'value': False} + + @min_commander_version_check + def handler(): + called['value'] = True + return {'status': 'success'}, 200 + + with self.app.test_request_context( + '/test', + method='POST', + headers={MIN_COMMANDER_VERSION_HEADER: '99.0.0'}, + ): + body, status = handler() + self.assertEqual(status, 426) + self.assertFalse(called['value']) + self.assertEqual(body['status'], 'error') diff --git a/unit-tests/service/test_terraform_app_setup.py b/unit-tests/service/test_terraform_app_setup.py new file mode 100644 index 000000000..64ee5e667 --- /dev/null +++ b/unit-tests/service/test_terraform_app_setup.py @@ -0,0 +1,161 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | '