""" log_manager.py — Log File Management Module Provides log file listing, secure download archive creation, and automatic compression for /opt/log/dpworldapp. """ import os import re import tarfile import tempfile import time import shutil import threading import logging LOG_DIR = os.environ.get("LOG_DIR", "/opt/log/dpworldapp") # v1.4.6.6: systemd ProtectSystem=strict + ReadWritePaths 화이트리스트가 /opt/log 부재로 # `/opt/log/log-download-*` mkdtemp 차단 (.56 라이브 OSError 30). PrivateTmp=yes 격리된 # tmpfs /tmp로 default 변경. 운영자가 다른 경로 원하면 LOG_DOWNLOAD_TEMP_PARENT env로 override. _DOWNLOAD_TEMP_PARENT = os.environ.get("LOG_DOWNLOAD_TEMP_PARENT", "/tmp") _DOWNLOAD_TEMP_PREFIX = "log-download-" MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100MB download limit ALLOWED_EXTENSIONS = frozenset({'.log', '.tar.gz', '.gz', '.txt'}) # Uncompressed rotation suffixes produced by logrotate on devices without # the auto-compress daemon — e.g. dpworldapp_2026-05-25.log.0 / .log.1 _ROTATION_SUFFIX_RE = re.compile(r'\.log\.\d+$') COMPRESS_CHECK_INTERVAL = 5 * 60 # 5 minutes # Log-management settings live in their own `log_config` board_config key — # NOT in `device_config`. The legacy Java app overwrites device_config with # its own model and silently drops these Python-only fields. LOG_CONFIG_KEYS = ( "log_auto_compress", "log_auto_cleanup", "log_cleanup_max_files", "log_cleanup_max_size_mb", # v1.4.6.7 C-1: Python 전용 compress threshold (Java schema 부재 → device_config에서 wipe 위험). # .56 라이브 monitoring Phase 1에서 device_config로 잘못 진입 노출 (state.js convertNestedToFlat이 # 8개 log_* 키 모두 emit인데 LOG_CONFIG_KEYS가 4개만 분리 — 누락 2개). "log_compress_size_mb", "log_compress_age_days", ) LOG_CONFIG_DEFAULTS = { "log_auto_compress": "off", "log_auto_cleanup": "off", "log_cleanup_max_files": 50, "log_cleanup_max_size_mb": 200, # v1.4.6.7 C-1: state.js convertNestedToFlat과 default 일치 "log_compress_size_mb": 50, "log_compress_age_days": 7, } logger = logging.getLogger(__name__) # Matches .log and rotated files like .log.0, .log.1, .log.2, etc. _LOG_FILE_RE = re.compile(r'\.log(\.\d+)?$') _compression_lock = threading.Lock() class CompressionAlreadyRunning(RuntimeError): """Raised when a second compression request starts before the first finishes.""" def _is_log_file(name): """Check if filename is a log file (including rotated ones like .log.0, .log.1).""" return bool(_LOG_FILE_RE.search(name)) def _has_allowed_extension(name): """True if `name`'s extension is in ALLOWED_EXTENSIONS, or matches a logrotate numeric suffix like '.log.0' / '.log.1'.""" if any(name.endswith(ext) for ext in ALLOWED_EXTENSIONS): return True return bool(_ROTATION_SUFFIX_RE.search(name)) def _make_tar_name(name, mtime): """ Archive name for a log file: ``..tar.gz``. The full original name is kept (so ``.log`` and rotated ``.log.N`` are treated identically) and the log's mtime is embedded — making the name unique, so a log filename reused over time never collides with a prior archive. Example: ``dpworldapp_2026-05-21.log.20260521-164801.tar.gz``. """ ts = time.strftime('%Y%m%d-%H%M%S', time.localtime(mtime)) return f"{name}.{ts}.tar.gz" def _verify_archive(tar_path, expected_name): """Return True if tar_path is a valid .tar.gz holding exactly expected_name.""" try: with tarfile.open(tar_path, 'r:gz') as tar: members = tar.getmembers() return len(members) == 1 and members[0].name == expected_name except Exception: return False def _safe_remove(path): """Remove a file, ignoring any error.""" try: os.remove(path) except OSError: pass def list_log_files(): """ List log files in LOG_DIR with metadata. Returns dict: { files: [...], total_size: int } """ if not os.path.isdir(LOG_DIR): return {"files": [], "total_size": 0} files = [] total_size = 0 try: names = os.listdir(LOG_DIR) except OSError as e: raise RuntimeError(f"Failed to read log directory: {e}") for name in names: filepath = os.path.join(LOG_DIR, name) try: if not os.path.isfile(filepath): continue stat = os.stat(filepath) except OSError: # File disappeared between listdir and stat (compress daemon race). continue size = stat.st_size mtime = stat.st_mtime # Determine file type if name.endswith('.tar.gz'): ftype = 'tar.gz' elif name.endswith('.gz'): ftype = 'gz' elif _is_log_file(name): ftype = 'log' elif name.endswith('.txt'): ftype = 'txt' else: ftype = 'other' files.append({ "name": name, "size": size, "modified": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(mtime)), "type": ftype, }) total_size += size # Sort by modified descending (newest first) files.sort(key=lambda f: f["modified"], reverse=True) return {"files": files, "total_size": total_size} def validate_filenames(filenames): """ Validate a list of filenames for security. Blocks path traversal, absolute paths, and missing files. Returns (valid_paths, error_message). """ if not filenames or not isinstance(filenames, list): return None, "No files specified" valid_paths = [] for name in filenames: if not isinstance(name, str) or not name.strip(): return None, f"Invalid filename: empty or non-string" # Block path traversal characters if '..' in name or '/' in name or '\\' in name: return None, f"Invalid filename: {name}" # Extension whitelist — block .partial, .sh, etc. # Also permit uncompressed logrotate suffixes: *.log.0, *.log.1, … if not _has_allowed_extension(name): return None, f"File extension not allowed: {name}" # Resolve symlinks and verify it stays within LOG_DIR filepath = os.path.realpath(os.path.join(LOG_DIR, name)) if not filepath.startswith(os.path.realpath(LOG_DIR) + os.sep): return None, f"Invalid filename: {name}" if not os.path.isfile(filepath): return None, f"File not found: {name}" valid_paths.append(filepath) # Check total size limit total = sum(os.path.getsize(p) for p in valid_paths) if total > MAX_DOWNLOAD_SIZE: return None, f"Total size exceeds {MAX_DOWNLOAD_SIZE // (1024*1024)}MB limit" return valid_paths, None def create_download_archive(valid_paths): """ Create a tar.gz archive on disk under _DOWNLOAD_TEMP_PARENT. Returns the absolute archive path. Caller is responsible for removing os.path.dirname(archive_path) after streaming. Per-file FileNotFoundError is logged and skipped (resilient to the auto-compress daemon renaming/deleting files during the request). v1.4.6.5 L-D: If ALL files vanish before being added, the workspace is cleaned up and FileNotFoundError is raised so the caller can return HTTP 404 instead of an empty tar.gz with HTTP 200. """ os.makedirs(_DOWNLOAD_TEMP_PARENT, exist_ok=True) workspace = tempfile.mkdtemp(prefix=_DOWNLOAD_TEMP_PREFIX, dir=_DOWNLOAD_TEMP_PARENT) try: timestamp = time.strftime("%Y-%m-%d_%H%M%S") archive_path = os.path.join(workspace, f"logs_{timestamp}.tar.gz") added_count = 0 with tarfile.open(archive_path, mode='w:gz') as tar: for filepath in valid_paths: arcname = os.path.basename(filepath) try: tar.add(filepath, arcname=arcname) added_count += 1 except FileNotFoundError: # Daemon may have renamed/deleted between validate and archive. # Skip; client just gets a smaller archive. continue if added_count == 0: shutil.rmtree(workspace, ignore_errors=True) raise FileNotFoundError( "All requested files disappeared between validation and archive" ) return archive_path except Exception: shutil.rmtree(workspace, ignore_errors=True) raise def sweep_stale_download_temp_dirs(): """Remove leftover log-download temp dirs from prior server crashes.""" if not os.path.isdir(_DOWNLOAD_TEMP_PARENT): return 0 removed = 0 for name in os.listdir(_DOWNLOAD_TEMP_PARENT): if not name.startswith(_DOWNLOAD_TEMP_PREFIX): continue path = os.path.join(_DOWNLOAD_TEMP_PARENT, name) if not os.path.isdir(path): continue try: shutil.rmtree(path) removed += 1 except OSError: pass return removed def get_active_log_file(): """ Return the filename of the most recently modified log file (the active one). DPWORLDAPP writes to one log file at a time; completed files should be compressed. Considers .log and rotated .log.0, .log.1, etc. Returns None if no log files exist. """ if not os.path.isdir(LOG_DIR): return None latest_name = None latest_mtime = 0 try: for name in os.listdir(LOG_DIR): if not _is_log_file(name): continue filepath = os.path.join(LOG_DIR, name) if not os.path.isfile(filepath): continue mtime = os.stat(filepath).st_mtime if mtime > latest_mtime: latest_mtime = mtime latest_name = name except OSError: pass return latest_name def get_log_stats(): """ Return disk usage statistics for the log directory. Returns dict with compressed/uncompressed counts, sizes, active file, total size, and partition capacity (total/used/free bytes for the filesystem hosting LOG_DIR). """ # Partition capacity — fall back to root if LOG_DIR is absent try: probe = LOG_DIR if os.path.isdir(LOG_DIR) else "/" usage = shutil.disk_usage(probe) partition_total = usage.total partition_used = usage.used partition_free = usage.free except (OSError, AttributeError): partition_total = partition_used = partition_free = 0 if not os.path.isdir(LOG_DIR): return { "total_size": 0, "compressed_count": 0, "compressed_size": 0, "uncompressed_count": 0, "uncompressed_size": 0, "active_file": None, "partition_total": partition_total, "partition_used": partition_used, "partition_free": partition_free, } active = get_active_log_file() compressed_count = 0 compressed_size = 0 uncompressed_count = 0 uncompressed_size = 0 total_size = 0 try: for name in os.listdir(LOG_DIR): filepath = os.path.join(LOG_DIR, name) if not os.path.isfile(filepath): continue size = os.stat(filepath).st_size total_size += size if name.endswith('.tar.gz') or name.endswith('.gz'): compressed_count += 1 compressed_size += size else: uncompressed_count += 1 uncompressed_size += size except OSError: pass return { "total_size": total_size, "compressed_count": compressed_count, "compressed_size": compressed_size, "uncompressed_count": uncompressed_count, "uncompressed_size": uncompressed_size, "active_file": active, "partition_total": partition_total, "partition_used": partition_used, "partition_free": partition_free, } def _compress_non_active_logs(): if not _compression_lock.acquire(blocking=False): raise CompressionAlreadyRunning("Compression already running") try: return _compress_non_active_logs_unlocked() finally: _compression_lock.release() def _compress_non_active_logs_unlocked(): """ Compress every log file except the active (most recently modified) one. Handles ``.log`` and rotated ``.log.0``, ``.log.1``, etc. Each archive is named ``..tar.gz`` (see _make_tar_name) — unique, so a reused log filename never collides with a prior, possibly stale, archive. Archives are written to a ``.partial`` temp file and atomically renamed, so an interrupted run never leaves a corrupt archive that shadows the log. Returns the number of files compressed. """ if not os.path.isdir(LOG_DIR): return 0 try: names = os.listdir(LOG_DIR) except OSError as e: logger.warning(f"Error listing log directory: {e}") return 0 # Clear leftover .partial archives from any previously interrupted run. for name in names: if name.endswith('.tar.gz.partial'): _safe_remove(os.path.join(LOG_DIR, name)) active = get_active_log_file() compressed_count = 0 for name in names: if not _is_log_file(name): continue # Skip the active file if name == active: continue filepath = os.path.join(LOG_DIR, name) if not os.path.isfile(filepath): continue try: mtime = os.stat(filepath).st_mtime except OSError: continue tar_name = _make_tar_name(name, mtime) tar_path = os.path.join(LOG_DIR, tar_name) # This exact version (same name + mtime) already archived? if os.path.exists(tar_path): if _verify_archive(tar_path, name): # Archive is good — drop the now-redundant original. _safe_remove(filepath) continue # Corrupt/partial archive at the final name — discard, recompress. _safe_remove(tar_path) tmp_path = tar_path + '.partial' try: with tarfile.open(tmp_path, 'w:gz') as tar: tar.add(filepath, arcname=name) if not _verify_archive(tmp_path, name): _safe_remove(tmp_path) logger.warning(f"Archive verification failed for {name}") continue os.rename(tmp_path, tar_path) # atomic publish os.remove(filepath) compressed_count += 1 logger.info(f"Compressed: {name} -> {tar_name}") except Exception as e: logger.warning(f"Failed to compress {name}: {e}") _safe_remove(tmp_path) return compressed_count def _run_cleanup(max_files, max_size_mb): """ Delete oldest compressed (.tar.gz / .gz) archives until both thresholds are met: - file count <= max_files - total compressed size <= max_size_mb (MB) Uncompressed and active log files are never touched. Returns the number of files deleted. """ if not os.path.isdir(LOG_DIR): return 0 # v1.11.8 B: defensive count floor — a corrupted/zero max_files from the DB # must never delete EVERY archive. Keep at least one. (The size threshold is # independent and can still bring the set down if the operator set it tiny.) try: max_files = max(1, int(max_files)) except (TypeError, ValueError): max_files = 1 max_size_bytes = max_size_mb * 1024 * 1024 archives = [] try: for name in os.listdir(LOG_DIR): if not (name.endswith('.tar.gz') or name.endswith('.gz')): continue filepath = os.path.join(LOG_DIR, name) if not os.path.isfile(filepath): continue try: stat = os.stat(filepath) archives.append((stat.st_mtime, stat.st_size, name, filepath)) except OSError: continue except OSError as e: logger.warning(f"Cleanup: error listing log directory: {e}") return 0 # Oldest first archives.sort(key=lambda a: a[0]) deleted = 0 total_size = sum(a[1] for a in archives) while archives and (len(archives) > max_files or total_size > max_size_bytes): entry = archives.pop(0) mtime, size, name, path = entry try: os.remove(path) total_size -= size deleted += 1 logger.info( f"Auto-cleanup: deleted {name} (mtime={mtime:.0f}, size={size})" ) except OSError as e: # #24 fix: the file is still on disk — re-append it (so the count is # honest) and stop. Continuing would drop it from `archives`, shrinking # len(archives) toward the threshold and exiting the count-based loop # one early while the file remains. We can't make progress past an # un-removable oldest file, so break to avoid a no-progress spin. logger.warning(f"Cleanup: failed to delete {name}: {e}") archives.append(entry) break return deleted def migrate_log_config(db): """ Ensure the `log_config` board_config key exists. Log-management settings used to live in `device_config`, which the legacy Java app overwrites with its own model — silently dropping them. They now live in their own key. This seeds `log_config` once, from any values still present in `device_config`, otherwise from defaults. Idempotent. """ try: if db.config_exists("log_config"): return device = db.get_config("device_config") or {} seeded = { key: device.get(key, LOG_CONFIG_DEFAULTS[key]) for key in LOG_CONFIG_KEYS } db.save_config("log_config", seeded) logger.info("Migrated log-management settings into the log_config key") except Exception as e: logger.warning(f"log_config migration error: {e}") def run_auto_compression(db): """ Compress ALL non-active .log files if auto_compress is enabled. Reads settings from the log_config key in DB. """ try: config = db.get_config("log_config") if not isinstance(config, dict) or not config: return # Check if auto compress is enabled auto_compress = config.get("log_auto_compress", "off") if auto_compress != "on": return try: count = _compress_non_active_logs() if count > 0: logger.info(f"Auto-compressed {count} file(s)") except CompressionAlreadyRunning: logger.info("Auto compression skipped: compression already running") except Exception as e: logger.warning(f"Auto compression error: {e}") def run_auto_cleanup(db): """ Delete oldest compressed log archives if log_auto_cleanup is enabled and either threshold (max_files or max_size_mb) is exceeded. Reads settings from the log_config key in DB. Uses safe defaults when keys missing. """ try: config = db.get_config("log_config") if not isinstance(config, dict) or not config: return if config.get("log_auto_cleanup", "off") != "on": return try: max_files = int(config.get("log_cleanup_max_files", 50)) except (TypeError, ValueError): max_files = 50 try: max_size_mb = int(config.get("log_cleanup_max_size_mb", 200)) except (TypeError, ValueError): max_size_mb = 200 count = _run_cleanup(max_files, max_size_mb) if count > 0: logger.info(f"Auto-cleanup deleted {count} archive(s)") except Exception as e: logger.warning(f"Auto cleanup error: {e}") def run_manual_compression(): """ Manually compress ALL non-active .log files. Always runs regardless of auto_compress setting. Returns dict with result info. """ try: count = _compress_non_active_logs() return { "success": True, "compressed": count, "message": f"Compressed {count} file(s)" if count > 0 else "No files to compress", } except CompressionAlreadyRunning: logger.info("Manual compression skipped: compression already running") return { "success": False, "compressed": 0, "message": "Compression already running", } except Exception as e: logger.warning(f"Manual compression error: {e}") return { "success": False, "compressed": 0, "message": f"Compression failed: {str(e)}", } def delete_log_files(filenames): """ Delete specified log files with security validation. Returns dict with result info. """ if not filenames or not isinstance(filenames, list): return {"success": False, "deleted": 0, "message": "No files specified"} deleted = 0 errors = [] for name in filenames: if not isinstance(name, str) or not name.strip(): errors.append(f"Invalid filename: empty or non-string") continue # Block path traversal if '..' in name or '/' in name or '\\' in name: errors.append(f"Invalid filename: {name}") continue filepath = os.path.realpath(os.path.join(LOG_DIR, name)) if not filepath.startswith(os.path.realpath(LOG_DIR) + os.sep): errors.append(f"Invalid filename: {name}") continue # v1.3.1: enforce extension whitelist — same set used by validate_filenames if not _has_allowed_extension(name): errors.append(f"Unsupported extension: {name}") continue if not os.path.isfile(filepath): errors.append(f"File not found: {name}") continue try: os.remove(filepath) deleted += 1 logger.info(f"Deleted log file: {name}") except OSError as e: errors.append(f"Failed to delete {name}: {e}") message = f"Deleted {deleted} file(s)" if errors: message += f"; errors: {'; '.join(errors)}" return { "success": deleted > 0 or len(errors) == 0, "deleted": deleted, "message": message, } def start_auto_compress_daemon(db): """Start a daemon thread that runs auto-compression and auto-cleanup periodically.""" def _worker(): while True: try: run_auto_compression(db) run_auto_cleanup(db) except Exception as e: logger.warning(f"Auto compress/cleanup daemon error: {e}") time.sleep(COMPRESS_CHECK_INTERVAL) thread = threading.Thread(target=_worker, daemon=True, name="log-auto-compress") thread.start() logger.info("Auto-compress/cleanup daemon started (interval: 5min)") return thread