"""Console output of a native OCI container, read from the host. ProxMenux creates every OCI container with `lxc.console.logfile` pointing at `/var/log/proxmenux/oci/.console.log`: liblxc copies the stdout and stderr of the image's entrypoint there, the way `docker logs` keeps them. The file already exists on the host, so nothing here runs inside the container. The path is derived from the VMID and confirmed against the container's own configuration; no caller can name a file. Reads are bounded — the last lines on first open, then only what was appended since the offset the viewer holds — and a file that got shorter or was replaced (logrotate's copytruncate, a container rebuilt by an update) resets the viewer instead of returning garbage. """ from __future__ import annotations import os import re LOG_DIR = "/var/log/proxmenux/oci" TAIL_READ_LIMIT = 2 * 1024 * 1024 # bytes scanned to find the last lines FOLLOW_READ_LIMIT = 512 * 1024 # bytes returned per follow request MAX_LINES = 2000 # CSI sequences (colours, cursor), OSC sequences (window titles) and the lone # ESC-letter codes some programs emit. Stripped, never rendered: the text goes # to the page as text. _ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]") _CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") def log_path(vmid: int) -> str: return os.path.join(LOG_DIR, f"{int(vmid)}.console.log") def configured_log(vmid: int) -> str | None: """The console log the container declares, when it is the ProxMenux one.""" try: with open(f"/etc/pve/lxc/{int(vmid)}.conf", encoding="utf-8") as handle: for line in handle: if line.startswith("["): break if line.startswith("lxc.console.logfile:"): value = line.split(":", 1)[1].strip() return value if value == log_path(vmid) else None except (OSError, ValueError): return None return None def terminal_state(vmid: int) -> dict: """Whether the Proxmox console of the container opens a shell.""" mode = "tty" try: with open(f"/etc/pve/lxc/{int(vmid)}.conf", encoding="utf-8") as handle: for line in handle: if line.startswith("["): break if line.startswith("cmode:"): mode = line.split(":", 1)[1].strip() except (OSError, ValueError): pass return {"cmode": mode, "shell": mode == "shell"} def clean(text: str) -> list[str]: """Readable lines from raw console output. CRLF becomes a line end. A lone carriage return is how a progress bar redraws itself in place, so only what was written after the last one on a line is kept — the state the terminal would have shown. """ text = _ANSI_RE.sub("", text).replace("\r\n", "\n") lines = [] for raw in text.split("\n"): if "\r" in raw: raw = raw.rsplit("\r", 1)[-1] lines.append(_CONTROL_RE.sub("", raw)) return lines # Written by the container's pre-start hook at every start (oci_console_mark.sh). START_MARK = "=== ProxMenux: container started " def _read_tail(handle, size: int, lines: int, since_start: bool = False) -> tuple[list[str], bool, bool]: start = max(0, size - TAIL_READ_LIMIT) handle.seek(start) data = handle.read(size - start).decode("utf-8", errors="replace") result = clean(data) if start > 0 and result: result = result[1:] # the first line was cut by the read window if result and result[-1] == "": result = result[:-1] marks = [index for index, line in enumerate(result) if line.startswith(START_MARK)] if since_start and marks: result = result[marks[-1]:] head_cut = (start > 0 and not (since_start and marks)) or len(result) > lines return result[-lines:], head_cut, bool(marks) def read(vmid: int, lines: int = 200, offset: int | None = None, inode: int | None = None, since_start: bool = False) -> dict: """The last `lines` lines, or what was appended after `offset`. `lines=0` reads nothing: it only says whether the container has a console log, which is what decides if the viewer is offered at all. """ lines = max(0, min(int(lines), MAX_LINES)) path = configured_log(vmid) base = {"ok": True, "vmid": int(vmid), **terminal_state(vmid)} if not path: return {**base, "enabled": False, "lines": [], "size": 0, "offset": 0, "inode": None} try: stat = os.stat(path) except OSError: return {**base, "enabled": True, "path": path, "lines": [], "size": 0, "offset": 0, "inode": None} size = stat.st_size base.update(enabled=True, path=path, size=size, inode=stat.st_ino) if lines == 0 and offset is None: return {**base, "lines": [], "offset": size} with open(path, "rb") as handle: replaced = inode is not None and inode != stat.st_ino if offset is None or replaced or offset > size: # First open, or the file the viewer followed is gone: rotated by # copytruncate, or recreated with the container. result, head_cut, has_marks = _read_tail(handle, size, max(lines, 1), since_start) return {**base, "lines": result, "offset": size, "reset": offset is not None, "head_truncated": head_cut, "start_marks": has_marks} handle.seek(offset) chunk = handle.read(min(size - offset, FOLLOW_READ_LIMIT)) # Only whole lines are returned; an unfinished last line is left for the # next read, where it arrives complete. cut = chunk.rfind(b"\n") if cut < 0: return {**base, "lines": [], "offset": offset, "reset": False} complete = chunk[:cut + 1] result = clean(complete.decode("utf-8", errors="replace")) if result and result[-1] == "": result = result[:-1] return {**base, "lines": result, "offset": offset + len(complete), "reset": False}