diff --git a/dissect/target/loader.py b/dissect/target/loader.py index 591b8d56c..2502279ce 100644 --- a/dissect/target/loader.py +++ b/dissect/target/loader.py @@ -206,4 +206,5 @@ def open(item: Union[str, Path], *args, **kwargs) -> Loader: register("smb", "SmbLoader") register("cb", "CbLoader") register("cyber", "CyberLoader") +register("proxmox", "ProxmoxLoader") register("multiraw", "MultiRawLoader") # Should be last diff --git a/dissect/target/loaders/proxmox.py b/dissect/target/loaders/proxmox.py new file mode 100644 index 000000000..9d8dd2f80 --- /dev/null +++ b/dissect/target/loaders/proxmox.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from dissect.target import container +from dissect.target.loader import Loader +from dissect.target.target import Target + +RE_VOLUME_ID = re.compile(r"(?:file=)?([^:]+):([^,]+)") + + +class ProxmoxLoader(Loader): + """Loader for Proxmox VM configuration files. + + Proxmox uses volume identifiers in the format of ``storage_id:volume_id``. The ``storage_id`` maps to a + storage configuration in ``/etc/pve/storage.cfg``. The ``volume_id`` is the name of the volume within + that configuration. + + This loader currently does not support parsing the storage configuration, so it will attempt to open the + volume directly from the same directory as the configuration file, or from ``/dev/pve/`` (default LVM config). + If the volume is not found, it will log a warning. + """ + + def __init__(self, path: Path, **kwargs): + path = path.resolve() + super().__init__(path) + self.base_dir = path.parent + + @staticmethod + def detect(path: Path) -> bool: + if path.suffix.lower() != ".conf": + return False + + with path.open("rb") as fh: + lines = fh.read(512).split(b"\n") + needles = [b"cpu:", b"memory:", b"name:"] + return all(any(needle in line for line in lines) for needle in needles) + + def map(self, target: Target) -> None: + with self.path.open("rt") as fh: + for line in fh: + if not (line := line.strip()): + continue + + key, value = line.split(":", 1) + value = value.strip() + + if key.startswith(("scsi", "sata", "ide", "virtio")) and key[-1].isdigit(): + # https://pve.proxmox.com/wiki/Storage + if match := RE_VOLUME_ID.match(value): + storage_id, volume_id = match.groups() + + # TODO: parse the storage information from /etc/pve/storage.cfg + # For now, let's try a few assumptions + disk_path = None + if (path := self.base_dir.joinpath(volume_id)).exists(): + disk_path = path + elif (path := self.base_dir.joinpath("/dev/pve/").joinpath(volume_id)).exists(): + disk_path = path + + if disk_path: + try: + target.disks.add(container.open(disk_path)) + except Exception: + target.log.exception("Failed to open disk: %s", disk_path) + else: + target.log.warning("Unable to find disk: %s:%s", storage_id, volume_id) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index 0f87b41bd..44fd1682a 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -57,17 +57,18 @@ class OperatingSystem(StrEnum): - LINUX = "linux" - WINDOWS = "windows" - ESXI = "esxi" + ANDROID = "android" BSD = "bsd" + CITRIX = "citrix-netscaler" + ESXI = "esxi" + FORTIOS = "fortios" + IOS = "ios" + LINUX = "linux" OSX = "osx" + PROXMOX = "proxmox" UNIX = "unix" - ANDROID = "android" VYOS = "vyos" - IOS = "ios" - FORTIOS = "fortios" - CITRIX = "citrix-netscaler" + WINDOWS = "windows" def export(*args, **kwargs) -> Callable: diff --git a/dissect/target/plugins/child/proxmox.py b/dissect/target/plugins/child/proxmox.py new file mode 100644 index 000000000..914fc789c --- /dev/null +++ b/dissect/target/plugins/child/proxmox.py @@ -0,0 +1,23 @@ +from typing import Iterator + +from dissect.target.exceptions import UnsupportedPluginError +from dissect.target.helpers.record import ChildTargetRecord +from dissect.target.plugin import ChildTargetPlugin + + +class ProxmoxChildTargetPlugin(ChildTargetPlugin): + """Child target plugin that yields from the VM listing.""" + + __type__ = "proxmox" + + def check_compatible(self) -> None: + if self.target.os != "proxmox": + raise UnsupportedPluginError("Not a Proxmox operating system") + + def list_children(self) -> Iterator[ChildTargetRecord]: + for vm in self.target.vmlist(): + yield ChildTargetRecord( + type=self.__type__, + path=vm.path, + _target=self.target, + ) diff --git a/dissect/target/plugins/os/unix/_os.py b/dissect/target/plugins/os/unix/_os.py index c41c89476..e6bfc98c1 100644 --- a/dissect/target/plugins/os/unix/_os.py +++ b/dissect/target/plugins/os/unix/_os.py @@ -20,6 +20,7 @@ class UnixPlugin(OSPlugin): def __init__(self, target: Target): super().__init__(target) self._add_mounts() + self._add_devices() self._hostname_dict = self._parse_hostname_string() self._hosts_dict = self._parse_hosts_string() self._os_release = self._parse_os_release() @@ -244,6 +245,17 @@ def _add_mounts(self) -> None: self.target.log.debug("Mounting %s (%s) at %s", fs, fs.volume, mount_point) self.target.fs.mount(mount_point, fs) + def _add_devices(self) -> None: + """Add some virtual block devices to the target. + + Currently only adds LVM devices. + """ + vfs = self.target.fs.append_layer() + + for volume in self.target.volumes: + if volume.vs and volume.vs.__type__ == "lvm": + vfs.map_file_fh(f"/dev/{volume.raw.vg.name}/{volume.raw.name}", volume) + def _parse_os_release(self, glob: Optional[str] = None) -> dict[str, str]: """Parse files containing Unix version information. diff --git a/dissect/target/plugins/os/unix/linux/debian/proxmox/__init__.py b/dissect/target/plugins/os/unix/linux/debian/proxmox/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/dissect/target/plugins/os/unix/linux/debian/proxmox/_os.py b/dissect/target/plugins/os/unix/linux/debian/proxmox/_os.py new file mode 100644 index 000000000..a96f1f7b5 --- /dev/null +++ b/dissect/target/plugins/os/unix/linux/debian/proxmox/_os.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import stat +from io import BytesIO +from typing import BinaryIO + +from dissect.sql import sqlite3 +from dissect.util.stream import BufferedStream + +from dissect.target.filesystem import ( + Filesystem, + VirtualDirectory, + VirtualFile, + VirtualFilesystem, +) +from dissect.target.helpers import fsutil +from dissect.target.plugins.os.unix._os import OperatingSystem, export +from dissect.target.plugins.os.unix.linux.debian._os import DebianPlugin +from dissect.target.target import Target + + +class ProxmoxPlugin(DebianPlugin): + @classmethod + def detect(cls, target: Target) -> Filesystem | None: + for fs in target.filesystems: + if fs.exists("/etc/pve") or fs.exists("/var/lib/pve"): + return fs + + return None + + @classmethod + def create(cls, target: Target, sysvol: Filesystem) -> ProxmoxPlugin: + obj = super().create(target, sysvol) + + if (config_db := target.fs.path("/var/lib/pve-cluster/config.db")).exists(): + with config_db.open("rb") as fh: + vfs = _create_pmxcfs(fh, obj.hostname) + + target.fs.mount("/etc/pve", vfs) + + return obj + + @export(property=True) + def version(self) -> str: + """Returns Proxmox VE version with underlying OS release.""" + + for pkg in self.target.dpkg.status(): + if pkg.name == "proxmox-ve": + distro_name = self._os_release.get("PRETTY_NAME", "") + return f"{pkg.name} {pkg.version} ({distro_name})" + + @export(property=True) + def os(self) -> str: + return OperatingSystem.PROXMOX.value + + +DT_DIR = 4 +DT_REG = 8 + + +def _create_pmxcfs(fh: BinaryIO, hostname: str | None = None) -> VirtualFilesystem: + # https://pve.proxmox.com/wiki/Proxmox_Cluster_File_System_(pmxcfs) + db = sqlite3.SQLite3(fh) + + entries = {row.inode: row for row in db.table("tree")} + + vfs = VirtualFilesystem() + for entry in entries.values(): + if entry.type == DT_DIR: + cls = ProxmoxConfigDirectoryEntry + elif entry.type == DT_REG: + cls = ProxmoxConfigFileEntry + else: + raise ValueError(f"Unknown pmxcfs file type: {entry.type}") + + parts = [] + current = entry + while current.parent != 0: + parts.append(current.name) + current = entries[current.parent] + parts.append(current.name) + + path = "/".join(parts[::-1]) + vfs.map_file_entry(path, cls(vfs, path, entry)) + + if hostname: + node_root = vfs.path(f"nodes/{hostname}") + vfs.symlink(str(node_root), "local") + vfs.symlink(str(node_root / "lxc"), "lxc") + vfs.symlink(str(node_root / "openvz"), "openvz") + vfs.symlink(str(node_root / "qemu-server"), "qemu-server") + + # TODO: .version, .members, .vmlist, maybe .clusterlog and .rrd? + + return vfs + + +class ProxmoxConfigFileEntry(VirtualFile): + def open(self) -> BinaryIO: + return BufferedStream(BytesIO(self.entry.data or b"")) + + def lstat(self) -> fsutil.stat_result: + # ['mode', 'addr', 'dev', 'nlink', 'uid', 'gid', 'size', 'atime', 'mtime', 'ctime'] + return fsutil.stat_result( + [ + stat.S_IFREG | 0o640, + self.entry.inode, + id(self.fs), + 1, + 0, + 0, + len(self.entry.data) if self.entry.data else 0, + 0, + self.entry.mtime, + 0, + ] + ) + + +class ProxmoxConfigDirectoryEntry(VirtualDirectory): + def __init__(self, fs: VirtualFilesystem, path: str, entry: sqlite3.Row): + super().__init__(fs, path) + self.entry = entry + + def lstat(self) -> fsutil.stat_result: + """Return the stat information of the given path, without resolving links.""" + # ['mode', 'addr', 'dev', 'nlink', 'uid', 'gid', 'size', 'atime', 'mtime', 'ctime'] + return fsutil.stat_result( + [ + stat.S_IFDIR | 0o755, + self.entry.inode, + id(self.fs), + 1, + 0, + 0, + 0, + 0, + self.entry.mtime, + 0, + ] + ) diff --git a/dissect/target/plugins/os/unix/linux/debian/proxmox/vm.py b/dissect/target/plugins/os/unix/linux/debian/proxmox/vm.py new file mode 100644 index 000000000..29c72b4c9 --- /dev/null +++ b/dissect/target/plugins/os/unix/linux/debian/proxmox/vm.py @@ -0,0 +1,29 @@ +from typing import Iterator + +from dissect.target.exceptions import UnsupportedPluginError +from dissect.target.helpers.record import TargetRecordDescriptor +from dissect.target.plugin import Plugin, export + +VirtualMachineRecord = TargetRecordDescriptor( + "proxmox/vm", + [ + ("string", "path"), + ], +) + + +class VirtualMachinePlugin(Plugin): + """Plugin to list Proxmox virtual machines.""" + + def check_compatible(self) -> None: + if self.target.os != "proxmox": + raise UnsupportedPluginError("Not a Proxmox operating system") + + @export(record=VirtualMachineRecord) + def vmlist(self) -> Iterator[VirtualMachineRecord]: + """List Proxmox virtual machines on this node.""" + for config in self.target.fs.path("/etc/pve/qemu-server").iterdir(): + yield VirtualMachineRecord( + path=config, + _target=self.target, + ) diff --git a/tests/_data/plugins/os/unix/linux/debian/proxmox/_os/config.db b/tests/_data/plugins/os/unix/linux/debian/proxmox/_os/config.db new file mode 100644 index 000000000..793bb301a --- /dev/null +++ b/tests/_data/plugins/os/unix/linux/debian/proxmox/_os/config.db @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e96dcf15741eeab70e3df48ca90b3e8ea2971c5d9916d38f2eba4c7a34536d7f +size 40960 diff --git a/tests/plugins/os/unix/linux/debian/proxmox/__init__.py b/tests/plugins/os/unix/linux/debian/proxmox/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/plugins/os/unix/linux/debian/proxmox/test__os.py b/tests/plugins/os/unix/linux/debian/proxmox/test__os.py new file mode 100644 index 000000000..07abfe9ec --- /dev/null +++ b/tests/plugins/os/unix/linux/debian/proxmox/test__os.py @@ -0,0 +1,79 @@ +from io import BytesIO + +from dissect.target.filesystem import VirtualFilesystem +from dissect.target.plugins.os.unix.linux.debian.proxmox._os import ProxmoxPlugin +from dissect.target.target import Target +from tests._utils import absolute_path + + +def test_proxmox_os(target_bare: Target) -> None: + fs = VirtualFilesystem() + + fs.map_file_fh("/etc/hostname", BytesIO(b"pve")) + fs.map_file( + "/var/lib/pve-cluster/config.db", absolute_path("_data/plugins/os/unix/linux/debian/proxmox/_os/config.db") + ) + fs.makedirs("/etc/pve") + fs.makedirs("/var/lib/pve") + + target_bare.filesystems.add(fs) + + assert ProxmoxPlugin.detect(target_bare) + target_bare._os_plugin = ProxmoxPlugin + target_bare.apply() + + assert target_bare.os == "proxmox" + assert sorted(list(map(str, target_bare.fs.path("/etc/pve").rglob("*")))) == [ + "/etc/pve/__version__", + "/etc/pve/authkey.pub", + "/etc/pve/authkey.pub.old", + "/etc/pve/corosync.conf", + "/etc/pve/datacenter.cfg", + "/etc/pve/firewall", + "/etc/pve/ha", + "/etc/pve/local", + "/etc/pve/lxc", + "/etc/pve/mapping", + "/etc/pve/nodes", + "/etc/pve/nodes/pve", + "/etc/pve/nodes/pve-btrfs", + "/etc/pve/nodes/pve-btrfs/lrm_status", + "/etc/pve/nodes/pve-btrfs/lrm_status.tmp.971", + "/etc/pve/nodes/pve-btrfs/lxc", + "/etc/pve/nodes/pve-btrfs/openvz", + "/etc/pve/nodes/pve-btrfs/priv", + "/etc/pve/nodes/pve-btrfs/pve-ssl.key", + "/etc/pve/nodes/pve-btrfs/pve-ssl.pem", + "/etc/pve/nodes/pve-btrfs/qemu-server", + "/etc/pve/nodes/pve-btrfs/ssh_known_hosts", + "/etc/pve/nodes/pve/lrm_status", + "/etc/pve/nodes/pve/lxc", + "/etc/pve/nodes/pve/openvz", + "/etc/pve/nodes/pve/priv", + "/etc/pve/nodes/pve/pve-ssl.key", + "/etc/pve/nodes/pve/pve-ssl.pem", + "/etc/pve/nodes/pve/qemu-server", + "/etc/pve/nodes/pve/qemu-server/100.conf", + "/etc/pve/nodes/pve/ssh_known_hosts", + "/etc/pve/openvz", + "/etc/pve/priv", + "/etc/pve/priv/acme", + "/etc/pve/priv/authkey.key", + "/etc/pve/priv/authorized_keys", + "/etc/pve/priv/known_hosts", + "/etc/pve/priv/lock", + "/etc/pve/priv/pve-root-ca.key", + "/etc/pve/priv/pve-root-ca.srl", + "/etc/pve/pve-root-ca.pem", + "/etc/pve/pve-www.key", + "/etc/pve/qemu-server", + "/etc/pve/sdn", + "/etc/pve/storage.cfg", + "/etc/pve/user.cfg", + "/etc/pve/virtual-guest", + "/etc/pve/vzdump.cron", + ] + + vmlist = list(target_bare.vmlist()) + assert len(vmlist) == 1 + assert vmlist[0].path == "/etc/pve/qemu-server/100.conf"