Skip to content
Open
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
13 changes: 12 additions & 1 deletion keepercommander/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,17 @@ def main(from_package=False):
if from_package:
sys.excepthook = handle_exceptions

# Check if we're running a service wrapper script (for background service mode in PyInstaller executable)
# If so, execute the script directly without argument parsing
if len(sys.argv) > 1 and sys.argv[1].endswith('service_wrapper.py'):
import runpy
try:
runpy.run_path(sys.argv[1], run_name='__main__')
return
except Exception as e:
logging.error(f"Failed to run service wrapper script: {e}")
sys.exit(1)

sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
opts, flags = parser.parse_known_args(sys.argv[1:])

Expand Down Expand Up @@ -323,7 +334,7 @@ def main(from_package=False):
if isinstance(params.timedelay, int) and params.timedelay >= 1 and params.commands:
cli.runcommands(params)
else:
if opts.command in {'shell', 'login', '-'}:
if opts.command in {'shell', '-'}:
if opts.command == '-':
params.batch_mode = True
elif opts.command and os.path.isfile(opts.command):
Expand Down
18 changes: 14 additions & 4 deletions keepercommander/service/api/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,17 @@ def execute_command_direct(**kwargs) -> Tuple[Union[Response, bytes], int]:
if json_error:
return json_error

command, validation_error = RequestValidator.validate_and_escape_command(request.json)
# Get JSON data safely
request_data = request.get_json(force=True, silent=True)
if not request_data:
return jsonify({"status": "error", "error": "Invalid or empty JSON"}), 400

command, validation_error = RequestValidator.validate_and_escape_command(request_data)
if validation_error:
return validation_error

# Process file data if present
processed_command, temp_files = RequestValidator.process_file_data(request.json, command)
processed_command, temp_files = RequestValidator.process_file_data(request_data, command)

response, status_code = CommandExecutor.execute(processed_command)

Expand Down Expand Up @@ -78,12 +83,17 @@ def execute_command(**kwargs) -> Tuple[Response, int]:
if json_error:
return json_error

command, validation_error = RequestValidator.validate_and_escape_command(request.json)
# Get JSON data safely
request_data = request.get_json(force=True, silent=True)
if not request_data:
return jsonify({"status": "error", "error": "Invalid or empty JSON"}), 400

command, validation_error = RequestValidator.validate_and_escape_command(request_data)
if validation_error:
return validation_error

# Process file data if present
processed_command, temp_files = RequestValidator.process_file_data(request.json, command)
processed_command, temp_files = RequestValidator.process_file_data(request_data, command)

# Submit to queue and return request ID immediately
try:
Expand Down
100 changes: 87 additions & 13 deletions keepercommander/service/core/service_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,38 +126,112 @@ def filter(self, record):

if config_data.get("run_mode") == "background":

base_dir = os.path.dirname(os.path.abspath(__file__))
service_module = "keepercommander.service.core.service_app" # Use module path instead of file path
python_executable = sys.executable

# Create logs directory for subprocess output
log_dir = os.path.join(base_dir, "logs")

# Detect if running as PyInstaller executable
is_frozen = getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS')

# Create logs directory for subprocess output in user-writable location
# Use utils.get_default_path() to avoid permission issues in packaged apps
log_dir = os.path.join(utils.get_default_path(), "service_logs")
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, "service_subprocess.log")

try:
if sys.platform == "win32":
subprocess.DETACHED_PROCESS = 0x00000008
with open(log_file, 'w') as log_f:
python_executable = sys.executable

if is_frozen:
# Running as PyInstaller executable - create a wrapper script and execute it
# PyInstaller executables can execute Python files, but we need to use the right approach
wrapper_script = os.path.join(log_dir, "service_wrapper.py")
wrapper_content = """# Service wrapper for PyInstaller executable
import sys
import os

# Set flag to indicate we're running in service mode (bypass main entry point)
os.environ['KEEPER_SERVICE_BACKGROUND'] = '1'

# Ensure we can import the service module
if hasattr(sys, '_MEIPASS'):
if sys._MEIPASS not in sys.path:
sys.path.insert(0, sys._MEIPASS)

# Clear sys.argv to avoid argument parsing issues
sys.argv = [sys.argv[0]]

# Import and run the service module directly
from keepercommander.service.app import create_app
from keepercommander.service.config.service_config import ServiceConfig
from keepercommander.service.core.service_manager import ServiceManager

flask_app = create_app()

service_config = ServiceConfig()
config_data = service_config.load_config()

try:
from keepercommander.service.core.globals import ensure_params_loaded
print("Pre-loading Keeper parameters for background mode...")
ensure_params_loaded()
print("Keeper parameters loaded successfully")
except Exception as e:
print("Warning: Failed to pre-load parameters during startup: " + str(e))
print("Parameters will be loaded on first API call if needed")

if not (port := config_data.get("port")):
print("Error: Service configuration is incomplete. Please configure the service port in service_config")
sys.exit(1)

ssl_context = ServiceManager.get_ssl_context(config_data)

flask_app.run(
host='0.0.0.0',
port=port,
ssl_context=ssl_context
)
"""
with open(wrapper_script, 'w') as f:
f.write(wrapper_content)
# Use the executable to run the Python script directly
# PyInstaller executables can execute .py files if we use the right method
cmd = [python_executable, wrapper_script]
else:
# Running as Python script - use -m flag
cmd = [python_executable, '-m', service_module]

# Open log file in append mode and keep it open for the subprocess
# Use 'a' mode to append, and don't close the file handle immediately
log_f = open(log_file, 'a', buffering=1) # Line buffering

try:
if sys.platform == "win32":
subprocess.DETACHED_PROCESS = 0x00000008
cls = subprocess.Popen(
[python_executable, '-m', service_module],
cmd,
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
stdout=log_f,
stderr=subprocess.STDOUT, # Combine stderr with stdout
stdin=subprocess.DEVNULL, # Close stdin to avoid issues
cwd=os.getcwd(), # Use current working directory to access config files
env=os.environ.copy() # Inherit environment variables
)
else:
# For macOS and Linux - improved subprocess handling
with open(log_file, 'w') as log_f:
else:
# For macOS and Linux - improved subprocess handling
cls = subprocess.Popen(
[python_executable, '-m', service_module],
cmd,
stdout=log_f,
stderr=subprocess.STDOUT, # Combine stderr with stdout
stdin=subprocess.DEVNULL, # Close stdin to avoid issues
preexec_fn=os.setpgrp,
cwd=os.getcwd(), # Use current working directory to access config files
env=os.environ.copy() # Inherit environment variables
)
# Don't close log_f here - let the subprocess handle it
# The file will be closed when the subprocess exits
except Exception:
# Only close on error
log_f.close()
raise

logger.debug(f"Service subprocess logs available at: {log_file}")
print(f"Commander Service started with PID: {cls.pid}")
Expand Down
11 changes: 8 additions & 3 deletions keepercommander/service/util/request_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,13 @@ def validate_request_json() -> Optional[Tuple]:
logger.info("Request validation failed: Content-Type must be application/json")
return jsonify({"status": "error", "error": "Content-Type must be application/json"}), 400

if not request.json:
logger.info("Request validation failed: Invalid or empty JSON")
return jsonify({"status": "error", "error": "Invalid or empty JSON"}), 400
try:
json_data = request.get_json(force=True, silent=False)
if json_data is None:
logger.info("Request validation failed: Invalid or empty JSON")
return jsonify({"status": "error", "error": "Invalid or empty JSON"}), 400
except Exception as e:
logger.warning(f"Request validation failed: JSON parsing error - {e}")
return jsonify({"status": "error", "error": f"Invalid JSON format: {str(e)}"}), 400

return None