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
2 changes: 1 addition & 1 deletion keepercommander/enforcement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
119 changes: 110 additions & 9 deletions keepercommander/service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -294,7 +300,7 @@ curl -X POST 'http://localhost:<port>/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):
Expand Down Expand Up @@ -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: <UID>
• 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:
Expand Down Expand Up @@ -662,11 +758,16 @@ docker run -d -p <port>:<port> \
docker logs <container-name-or-id>
```

3. **Get API key from logs:**
Look for the API key in the container logs:
```
Generated API key: <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
Expand Down
29 changes: 21 additions & 8 deletions keepercommander/service/commands/create_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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"]
Expand All @@ -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)"""
Expand Down
56 changes: 53 additions & 3 deletions keepercommander/service/commands/service_config_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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"] = ""
Expand All @@ -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(
Expand All @@ -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'])
Expand Down
Loading