You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

705 lines
23 KiB

"""
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))
# B12: sentinel returned by _resolve_log_path when the extension check fails.
# Callers use different wording for this case ("File extension not allowed" vs
# "Unsupported extension") so they format the message themselves.
_EXT_DENIED = object()
def _resolve_log_path(name):
"""Perform the 5-step security gate for a single filename.
Returns (resolved_path, None) on success.
Returns (None, _EXT_DENIED) if the extension is not whitelisted callers
must produce their own error message for this case.
Returns (None, error_str) for all other failures.
"""
if not isinstance(name, str) or not name.strip():
return None, "Invalid filename: empty or non-string"
if '..' in name or '/' in name or '\\' in name:
return None, f"Invalid filename: {name}"
if not _has_allowed_extension(name):
return None, _EXT_DENIED
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}"
return filepath, None
def _make_tar_name(name, mtime):
"""
Archive name for a log file: ``<original name>.<log-mtime>.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:
filepath, err = _resolve_log_path(name)
if err is _EXT_DENIED:
return None, f"File extension not allowed: {name}"
if err is not None:
return None, err
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 ``<logname>.<log-mtime>.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 len(archives) > 1 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
}
# B11: clamp integer fields to the same hard floors enforced by
# validate_log_config_hard — a legacy device_config with sub-floor
# values (e.g. log_cleanup_max_size_mb=0) must not be seeded as-is.
_seed_floors = {
"log_cleanup_max_files": 5,
"log_cleanup_max_size_mb": 50,
"log_compress_size_mb": 1,
"log_compress_age_days": 1,
}
for key, floor in _seed_floors.items():
v = seeded.get(key)
try:
seeded[key] = max(floor, int(v))
except (TypeError, ValueError):
seeded[key] = LOG_CONFIG_DEFAULTS[key]
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:
filepath, err = _resolve_log_path(name)
if err is _EXT_DENIED:
errors.append(f"Unsupported extension: {name}")
continue
if err is not None:
errors.append(err)
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