Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion keepercommander/command_categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
},

Expand Down
3 changes: 3 additions & 0 deletions keepercommander/commands/start_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand All @@ -40,6 +42,7 @@ def register_command_info(aliases, command_info):
StopService,
ServiceStatus,
ServiceDockerSetupCommand,
TerraformAppSetupCommand,
SlackAppSetupCommand,
TeamsAppSetupCommand,
SailPointAppSetupCommand,
Expand Down
155 changes: 7 additions & 148 deletions keepercommander/service/commands/service_docker_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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"""
Expand Down Expand Up @@ -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: <number>/<period> (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': ''}
145 changes: 145 additions & 0 deletions keepercommander/service/commands/terraform_app_setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# _ __
# | |/ /___ ___ _ __ ___ _ _ ®
# | ' </ -_) -_) '_ \/ -_) '_|
# |_|\_\___\___| .__/\___|_|
# |_|
#
# Keeper Commander
# Copyright 2026 Keeper Security Inc.
# Contact: commander@keepersecurity.com
#

"""Terraform provider Docker service mode setup command."""

import argparse
from dataclasses import asdict

from ...commands.base import raise_parse_exception, suppress_exit
from ...error import CommandError
from ..config.service_config import ServiceConfig as RuntimeServiceConfig
from ..docker import (
DockerComposeBuilder,
DockerSetupConstants,
DockerSetupPrinter,
ServiceConfig as DockerServiceConfig,
SetupResult,
)
from ..util.exceptions import ValidationError
from .service_docker_setup import ServiceDockerSetupCommand


class TerraformSetupConstants:
"""Defaults and allowlist for terraform-app-setup."""
DEFAULT_FOLDER_NAME = 'Commander Service Mode - Terraform'
DEFAULT_APP_NAME = 'Commander Service Mode - Terraform KSM App'
DEFAULT_RECORD_NAME = 'Commander Service Mode Terraform Config'
DEFAULT_TIMEOUT = DockerSetupConstants.DEFAULT_TIMEOUT
COMMANDER_SERVICE_NAME = 'commander-terraform'
COMMANDER_CONTAINER_NAME = 'keeper-service-terraform'

SERVICE_COMMANDS_LIST = (
'this-device', 'sync-down', 'switch-to-mc', 'switch-to-msp',
'msp-add', 'msp-down', 'msp-info', 'msp-remove', 'msp-update',
'enterprise-info', 'enterprise-node', 'enterprise-user', 'enterprise-role',
'enterprise-team', 'enterprise-down', 'enterprise-push', 'team-approve',
'record-add', 'record-update', 'rm', 'get', 'list', 'record-type-info',
'share-folder', 'rmdir', 'rndir', 'mkdir', 'epm', 'scim', 'mv', 'pam',
'secrets-manager', 'ln', 'share-record',
'nsf-mkdir', 'nsf-get', 'nsf-rmdir', 'nsf-record-add', 'nsf-record-update',
'nsf-rm', 'nsf-rndir', 'nsf-share-folder', 'nsf-share-record', 'nsf-ln',
)
SERVICE_COMMANDS = ','.join(SERVICE_COMMANDS_LIST)


terraform_app_setup_parser = argparse.ArgumentParser(
prog='terraform-app-setup',
description=(
'Automate Docker service mode setup for the Terraform provider '
'(API v2 queue always enabled)'
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
terraform_app_setup_parser.add_argument(
'--folder-name', dest='folder_name', type=str,
default=TerraformSetupConstants.DEFAULT_FOLDER_NAME,
help=f'Name for the shared folder '
f'(default: "{TerraformSetupConstants.DEFAULT_FOLDER_NAME}")',
)
terraform_app_setup_parser.add_argument(
'--app-name', dest='app_name', type=str,
default=TerraformSetupConstants.DEFAULT_APP_NAME,
help=f'Name for the secrets manager app '
f'(default: "{TerraformSetupConstants.DEFAULT_APP_NAME}")',
)
terraform_app_setup_parser.add_argument(
'--record-name', dest='record_name', type=str,
default=TerraformSetupConstants.DEFAULT_RECORD_NAME,
help=f'Name for the config record '
f'(default: "{TerraformSetupConstants.DEFAULT_RECORD_NAME}")',
)
terraform_app_setup_parser.add_argument(
'--config-path', dest='config_path', type=str,
help='Path to config.json file (default: active session config file)',
)
terraform_app_setup_parser.add_argument(
'--timeout', dest='timeout', type=str,
default=TerraformSetupConstants.DEFAULT_TIMEOUT,
help=f'Device timeout setting (default: {TerraformSetupConstants.DEFAULT_TIMEOUT})',
)
terraform_app_setup_parser.add_argument(
'--skip-device-setup', dest='skip_device_setup', action='store_true',
help='Skip device registration and setup if already configured',
)
terraform_app_setup_parser.error = raise_parse_exception
terraform_app_setup_parser.exit = suppress_exit


class TerraformAppSetupCommand(ServiceDockerSetupCommand):
"""service-docker-setup flow with Terraform defaults: always queue, fixed allowlist."""

def get_parser(self):
return terraform_app_setup_parser

def execute(self, params, **kwargs):
# setdefault covers programmatic execute() without argparse defaults.
kwargs.setdefault('folder_name', TerraformSetupConstants.DEFAULT_FOLDER_NAME)
kwargs.setdefault('app_name', TerraformSetupConstants.DEFAULT_APP_NAME)
kwargs.setdefault('record_name', TerraformSetupConstants.DEFAULT_RECORD_NAME)
kwargs.setdefault('timeout', TerraformSetupConstants.DEFAULT_TIMEOUT)
# Fail closed before any setup; _get_commands_config re-validates locally.
self._validate_terraform_commands(params)
return super().execute(params, **kwargs)

def _validate_terraform_commands(self, params) -> 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()
Loading