From ebe79023623652fc52979b2b9ab0864569d39b43 Mon Sep 17 00:00:00 2001 From: jihoson Date: Mon, 16 Feb 2026 23:03:02 +0900 Subject: [PATCH] Implement reconnect, idle reporting, and security hardening --- .env.example | 5 +++ README.md | 5 +++ src/lazy_enter/__main__.py | 12 ++++++- src/lazy_enter/bridge.py | 62 +++++++++++++++++++++++++++++++++ src/lazy_enter/config.py | 23 ++++++++++++ src/lazy_enter/slack_handler.py | 30 +++++++++++++--- tests/test_config.py | 26 ++++++++++++++ tests/test_pty_manager.py | 8 ++++- 8 files changed, 165 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index c64adbf..9c3f355 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,8 @@ PTY_READ_TIMEOUT=5 # 출력 버퍼 설정 OUTPUT_BUFFER_INTERVAL=2.0 MAX_MESSAGE_LENGTH=3000 + +# 상태 보고 / 재연결 설정 +RECONNECT_DELAY_SECONDS=5.0 +OUTPUT_IDLE_REPORT_SECONDS=120 +INPUT_IDLE_REPORT_SECONDS=300 diff --git a/README.md b/README.md index 55918f4..92d4b06 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,11 @@ cp .env.example .env - `PTY_READ_TIMEOUT` (기본: `5`) - `OUTPUT_BUFFER_INTERVAL` (기본: `2.0`) - `MAX_MESSAGE_LENGTH` (기본: `3000`) +- `RECONNECT_DELAY_SECONDS` (기본: `5.0`, Socket Mode 재연결 대기 시간) +- `OUTPUT_IDLE_REPORT_SECONDS` (기본: `120`, 출력 정지 보고 임계값) +- `INPUT_IDLE_REPORT_SECONDS` (기본: `300`, 입력 정지 보고 임계값) + +`SLACK_ALLOWED_USER_ID`, `SLACK_ALLOWED_CHANNEL_ID`가 비어 있으면 실행이 중단됩니다. ## Slack 앱 설정 diff --git a/src/lazy_enter/__main__.py b/src/lazy_enter/__main__.py index c0ac639..f433761 100644 --- a/src/lazy_enter/__main__.py +++ b/src/lazy_enter/__main__.py @@ -1,10 +1,20 @@ """CLI 엔트리포인트.""" +import sys + from lazy_enter.bridge import Bridge +from lazy_enter.config import Config def main() -> None: - bridge = Bridge() + config = Config() + try: + config.validate_required_settings() + except ValueError as exc: + print(f"[LazyEnter] 설정 오류: {exc}", file=sys.stderr) + raise SystemExit(1) from exc + + bridge = Bridge(config) bridge.run() diff --git a/src/lazy_enter/bridge.py b/src/lazy_enter/bridge.py index d3c0a69..142a915 100644 --- a/src/lazy_enter/bridge.py +++ b/src/lazy_enter/bridge.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import re import threading import time @@ -23,18 +24,45 @@ class Bridge: self._output_thread: threading.Thread | None = None self._running = False self._channel: str = self.config.allowed_channel_id + self._last_input_at = time.monotonic() + self._last_output_at = time.monotonic() + self._input_idle_reported = False + self._output_idle_reported = False self.slack.on_message(self._handle_message) self.slack.on_command(self._handle_command) def _handle_message(self, text: str, channel: str) -> None: """Slack 메시지를 PTY 프로세스로 전달한다.""" + if self._is_blocked_input(text): + self.slack.send_message( + channel, ":no_entry: 차단된 명령 패턴이 감지되었습니다." + ) + return + if self.pty and self.pty.is_alive: self.pty.send(text) + self._last_input_at = time.monotonic() + self._input_idle_reported = False logger.info("입력 전달: %s", text) else: self.slack.send_message(channel, ":warning: 실행 중인 세션이 없습니다.") + @staticmethod + def _is_blocked_input(text: str) -> bool: + """치명적 쉘 명령 패턴을 단순 차단한다.""" + normalized = re.sub(r"\s+", " ", text.lower()).strip() + blocked_patterns = ( + "rm -rf /", + "rm -rf /*", + "mkfs", + ":(){:|:&};:", + "shutdown -h", + "reboot", + "poweroff", + ) + return any(pattern in normalized for pattern in blocked_patterns) + def _handle_command(self, command: str, channel: str) -> None: """슬래시 커맨드를 처리한다.""" if command == "start": @@ -54,6 +82,10 @@ class Bridge: self.pty = PtyManager(self.config.default_shell) self.pty.start() self._running = True + self._last_input_at = time.monotonic() + self._last_output_at = time.monotonic() + self._input_idle_reported = False + self._output_idle_reported = False self._output_thread = threading.Thread(target=self._poll_output, daemon=True) self._output_thread.start() @@ -71,9 +103,12 @@ class Bridge: """PTY 출력을 주기적으로 읽어 Slack으로 전송한다.""" buffer = "" while self._running and self.pty and self.pty.is_alive: + now = time.monotonic() output = self.pty.read_output(timeout=self.config.pty_read_timeout) if output: buffer += output + self._last_output_at = now + self._output_idle_reported = False if buffer: # 메시지 길이 제한 적용 @@ -83,6 +118,33 @@ class Bridge: self.slack.send_message(self._channel, f"```\n{message}\n```") buffer = "" + output_idle = now - self._last_output_at + if ( + self.config.output_idle_report_seconds > 0 + and not self._output_idle_reported + and output_idle >= self.config.output_idle_report_seconds + ): + self.slack.send_message( + self._channel, + ( + f":hourglass_flowing_sand: 출력이 {int(output_idle)}초 동안 " + "없습니다. 세션 상태를 확인해주세요." + ), + ) + self._output_idle_reported = True + + input_idle = now - self._last_input_at + if ( + self.config.input_idle_report_seconds > 0 + and not self._input_idle_reported + and input_idle >= self.config.input_idle_report_seconds + ): + self.slack.send_message( + self._channel, + f":information_source: 입력이 {int(input_idle)}초 동안 없습니다.", + ) + self._input_idle_reported = True + time.sleep(self.config.output_buffer_interval) if not self._running: diff --git a/src/lazy_enter/config.py b/src/lazy_enter/config.py index d824992..5f478b0 100644 --- a/src/lazy_enter/config.py +++ b/src/lazy_enter/config.py @@ -25,3 +25,26 @@ class Config: # Buffer output_buffer_interval: float = float(os.getenv("OUTPUT_BUFFER_INTERVAL", "2.0")) max_message_length: int = int(os.getenv("MAX_MESSAGE_LENGTH", "3000")) + + # Status reporting / reconnect + reconnect_delay_seconds: float = float(os.getenv("RECONNECT_DELAY_SECONDS", "5.0")) + output_idle_report_seconds: int = int( + os.getenv("OUTPUT_IDLE_REPORT_SECONDS", "120") + ) + input_idle_report_seconds: int = int(os.getenv("INPUT_IDLE_REPORT_SECONDS", "300")) + + def validate_required_settings(self) -> None: + """필수 설정값 누락 여부를 검증한다.""" + missing: list[str] = [] + if not self.slack_bot_token: + missing.append("SLACK_BOT_TOKEN") + if not self.slack_app_token: + missing.append("SLACK_APP_TOKEN") + if not self.allowed_user_id: + missing.append("SLACK_ALLOWED_USER_ID") + if not self.allowed_channel_id: + missing.append("SLACK_ALLOWED_CHANNEL_ID") + + if missing: + joined = ", ".join(missing) + raise ValueError(f"필수 환경 변수가 누락되었습니다: {joined}") diff --git a/src/lazy_enter/slack_handler.py b/src/lazy_enter/slack_handler.py index 26d9100..3a0ab88 100644 --- a/src/lazy_enter/slack_handler.py +++ b/src/lazy_enter/slack_handler.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import time from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler @@ -19,7 +20,8 @@ class SlackHandler: def __init__(self, config: Config) -> None: self.config = config self.app = App(token=config.slack_bot_token) - self._handler = SocketModeHandler(self.app, config.slack_app_token) + self._handler: SocketModeHandler | None = None + self._stop_requested = False self._on_message_callback: callable | None = None self._on_command_callback: callable | None = None @@ -98,11 +100,31 @@ class SlackHandler: ) def start(self) -> None: - """Socket Mode 핸들러를 시작한다.""" + """Socket Mode 핸들러를 시작한다. 연결이 끊기면 재연결한다.""" + self._stop_requested = False logger.info("Slack Socket Mode 시작") - self._handler.start() + + while not self._stop_requested: + try: + self._handler = SocketModeHandler(self.app, self.config.slack_app_token) + self._handler.start() + except Exception: + if self._stop_requested: + break + logger.exception("Socket Mode 연결이 종료되었습니다.") + + if self._stop_requested: + break + + logger.warning( + "Socket Mode 재연결 시도 (%s초 후)", + self.config.reconnect_delay_seconds, + ) + time.sleep(self.config.reconnect_delay_seconds) def stop(self) -> None: """Socket Mode 핸들러를 종료한다.""" logger.info("Slack Socket Mode 종료") - self._handler.close() + self._stop_requested = True + if self._handler is not None: + self._handler.close() diff --git a/tests/test_config.py b/tests/test_config.py index 93d0014..70931b8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,7 @@ """Config 테스트.""" +import pytest + from lazy_enter.config import Config @@ -9,3 +11,27 @@ def test_config_defaults(): assert config.pty_read_timeout == 5 assert config.output_buffer_interval == 2.0 assert config.max_message_length == 3000 + assert config.reconnect_delay_seconds == 5.0 + assert config.output_idle_report_seconds == 120 + assert config.input_idle_report_seconds == 300 + + +def test_validate_required_settings_missing() -> None: + config = Config() + config.slack_bot_token = "" + config.slack_app_token = "" + config.allowed_user_id = "" + config.allowed_channel_id = "" + + with pytest.raises(ValueError): + config.validate_required_settings() + + +def test_validate_required_settings_ok() -> None: + config = Config() + config.slack_bot_token = "xoxb-test" + config.slack_app_token = "xapp-test" + config.allowed_user_id = "U000" + config.allowed_channel_id = "C000" + + config.validate_required_settings() diff --git a/tests/test_pty_manager.py b/tests/test_pty_manager.py index 856cb75..09a184d 100644 --- a/tests/test_pty_manager.py +++ b/tests/test_pty_manager.py @@ -1,5 +1,7 @@ """PtyManager 테스트.""" +import pytest + from lazy_enter.pty_manager import PtyManager @@ -10,7 +12,11 @@ def test_initial_state(): def test_start_and_stop(): pty = PtyManager("cat") - pty.start() + try: + pty.start() + except OSError as exc: + pytest.skip(f"PTY unavailable in this environment: {exc}") + assert pty.is_alive pty.stop() assert not pty.is_alive