diff --git a/decnet/composer.py b/decnet/composer.py index 3208c70..08607bd 100644 --- a/decnet/composer.py +++ b/decnet/composer.py @@ -16,13 +16,35 @@ from decnet.config import DecnetConfig from decnet.network import MACVLAN_NETWORK_NAME from decnet.services.registry import get_service +_CONTAINER_LOG_DIR = "/var/log/decnet" + _LOG_NETWORK = "decnet_logs" +def _resolve_log_file(log_file: str) -> tuple[str, str]: + """ + Return (host_dir, container_log_path) for a user-supplied log file path. + + The host path is resolved to absolute so Docker can bind-mount it. + All containers share the same host directory, mounted at _CONTAINER_LOG_DIR. + """ + host_path = Path(log_file).resolve() + host_dir = str(host_path.parent) + container_path = f"{_CONTAINER_LOG_DIR}/{host_path.name}" + return host_dir, container_path + + def generate_compose(config: DecnetConfig) -> dict: """Build and return the full docker-compose data structure.""" services: dict = {} + log_host_dir: str | None = None + log_container_path: str | None = None + if config.log_file: + log_host_dir, log_container_path = _resolve_log_file(config.log_file) + # Ensure the host log directory exists so Docker doesn't create it as root-owned + Path(log_host_dir).mkdir(parents=True, exist_ok=True) + for decky in config.deckies: base_key = decky.name # e.g. "decky-01" @@ -58,8 +80,12 @@ def generate_compose(config: DecnetConfig) -> dict: fragment.setdefault("environment", {}) fragment["environment"]["HOSTNAME"] = decky.hostname - if config.log_file: - fragment["environment"]["DECNET_LOG_FILE"] = config.log_file + if log_host_dir and log_container_path: + fragment["environment"]["DECNET_LOG_FILE"] = log_container_path + fragment.setdefault("volumes", []) + mount = f"{log_host_dir}:{_CONTAINER_LOG_DIR}" + if mount not in fragment["volumes"]: + fragment["volumes"].append(mount) # Share the base container's network — no own IP needed fragment["network_mode"] = f"service:{base_key}" diff --git a/test-scan b/test-scan new file mode 100644 index 0000000..2456525 --- /dev/null +++ b/test-scan @@ -0,0 +1,98 @@ +# Nmap 7.92 scan initiated Sat Apr 4 04:36:10 2026 as: nmap -sS -sV -O -A -oN test-scan 192.168.1.110-119 +Nmap scan report for 192.168.1.110 +Host is up (0.000044s latency). +Not shown: 996 closed tcp ports (reset) +PORT STATE SERVICE VERSION +25/tcp open smtp Postfix smtpd +|_smtp-commands: decky-webmail, PIPELINING, SIZE 10240000, VRFY, ETRN, AUTH PLAIN LOGIN, ENHANCEDSTATUSCODES, 8BITMIME, DSN +80/tcp open http Apache httpd 2.4.54 ((Debian)) +|_http-title: WordPress › Error +|_http-server-header: Werkzeug/3.1.8 Python/3.11.2 +110/tcp open pop3 +| fingerprint-strings: +| GenericLines, HTTPOptions: +| +OK decky-webmail POP3 server ready +| -ERR Unknown command +| -ERR Unknown command +| NULL: +|_ +OK decky-webmail POP3 server ready +|_pop3-capabilities: USER +143/tcp open imap +| fingerprint-strings: +| GenericLines, NULL: +| * OK [decky-webmail] IMAP4rev1 Service Ready +| GetRequest: +| * OK [decky-webmail] IMAP4rev1 Service Ready +|_ Command not recognized +|_imap-capabilities: AUTH=LOGINA0001 OK IMAP4rev1 completed CAPABILITY AUTH=PLAIN +2 services unrecognized despite returning data. If you know the service/version, please submit the following fingerprints at https://nmap.org/cgi-bin/submit.cgi?new-service : +==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)============== +SF-Port110-TCP:V=7.92%I=7%D=4/4%Time=69D0BF72%P=x86_64-redhat-linux-gnu%r( +SF:NULL,25,"\+OK\x20decky-webmail\x20POP3\x20server\x20ready\r\n")%r(Gener +SF:icLines,51,"\+OK\x20decky-webmail\x20POP3\x20server\x20ready\r\n-ERR\x2 +SF:0Unknown\x20command\r\n-ERR\x20Unknown\x20command\r\n")%r(HTTPOptions,5 +SF:1,"\+OK\x20decky-webmail\x20POP3\x20server\x20ready\r\n-ERR\x20Unknown\ +SF:x20command\r\n-ERR\x20Unknown\x20command\r\n"); +==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)============== +SF-Port143-TCP:V=7.92%I=7%D=4/4%Time=69D0BF72%P=x86_64-redhat-linux-gnu%r( +SF:NULL,2E,"\*\x20OK\x20\[decky-webmail\]\x20IMAP4rev1\x20Service\x20Ready +SF:\r\n")%r(GetRequest,4E,"\*\x20OK\x20\[decky-webmail\]\x20IMAP4rev1\x20S +SF:ervice\x20Ready\r\nGET\x20BAD\x20Command\x20not\x20recognized\r\n")%r(G +SF:enericLines,2E,"\*\x20OK\x20\[decky-webmail\]\x20IMAP4rev1\x20Service\x +SF:20Ready\r\n"); +MAC Address: DE:A7:41:91:07:8A (Unknown) +Device type: general purpose +Running: Linux 5.X +OS CPE: cpe:/o:linux:linux_kernel:5 +OS details: Linux 5.3 - 5.4 +Network Distance: 1 hop +Service Info: Host: decky-webmail + +TRACEROUTE +HOP RTT ADDRESS +1 0.04 ms 192.168.1.110 + +Nmap scan report for 192.168.1.111 +Host is up (0.000015s latency). +Not shown: 998 closed tcp ports (reset) +PORT STATE SERVICE VERSION +21/tcp open ftp vsftpd (before 2.0.8) or WU-FTPD +445/tcp open microsoft-ds +| fingerprint-strings: +| SMBProgNeg: +| SMBr +|_ "3DUfw +1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service : +SF-Port445-TCP:V=7.92%I=7%D=4/4%Time=69D0BF77%P=x86_64-redhat-linux-gnu%r( +SF:SMBProgNeg,51,"\0\0\0M\xffSMBr\0\0\0\0\x80\0\xc0\0\0\0\0\0\0\0\0\0\0\0\ +SF:0\0\0@\x06\0\0\x01\0\x11\x07\0\x03\x01\0\x01\0\0\xfa\0\0\0\0\x01\0\0\0\ +SF:0\0p\0\0\0\0\0\0\0\0\0\0\0\0\0\x08\x08\0\x11\"3DUfw\x88"); +MAC Address: 4A:96:18:DB:DA:38 (Unknown) +Device type: general purpose +Running: Linux 5.X +OS CPE: cpe:/o:linux:linux_kernel:5 +OS details: Linux 5.3 - 5.4 +Network Distance: 1 hop +Service Info: Host: Twisted + +Host script results: +| smb2-security-mode: +| 2.0.2: +|_ Message signing enabled but not required +| smb2-time: +| date: 2026-04-04T07:36:29 +|_ start_date: 2026-04-04T07:36:29 +| smb-security-mode: +| account_used: guest +| authentication_level: user +| challenge_response: supported +|_ message_signing: disabled (dangerous, but default) +|_ms-sql-info: ERROR: Script execution failed (use -d to debug) +|_clock-skew: mean: -77660d15h48m16s, deviation: 109828d18h09m25s, median: -155321d07h36m32s + +TRACEROUTE +HOP RTT ADDRESS +1 0.01 ms 192.168.1.111 + +OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ . +# Nmap done at Sat Apr 4 04:36:38 2026 -- 10 IP addresses (2 hosts up) scanned in 28.29 seconds diff --git a/tests/test_log_file_mount.py b/tests/test_log_file_mount.py new file mode 100644 index 0000000..6b74bee --- /dev/null +++ b/tests/test_log_file_mount.py @@ -0,0 +1,97 @@ +"""Tests for log_file volume mount in compose generation.""" + +from pathlib import Path + +import pytest + +from decnet.composer import _CONTAINER_LOG_DIR, _resolve_log_file, generate_compose +from decnet.config import DeckyConfig, DecnetConfig +from decnet.distros import DISTROS + + +def _make_config(log_file: str | None = None) -> DecnetConfig: + profile = DISTROS["debian"] + decky = DeckyConfig( + name="decky-01", + ip="10.0.0.10", + services=["http"], + distro="debian", + base_image=profile.image, + build_base=profile.build_base, + hostname="test-host", + ) + return DecnetConfig( + mode="unihost", + interface="eth0", + subnet="10.0.0.0/24", + gateway="10.0.0.1", + deckies=[decky], + log_file=log_file, + ) + + +class TestResolveLogFile: + def test_absolute_path(self, tmp_path): + log_path = str(tmp_path / "decnet.log") + host_dir, container_path = _resolve_log_file(log_path) + assert host_dir == str(tmp_path) + assert container_path == f"{_CONTAINER_LOG_DIR}/decnet.log" + + def test_relative_path_resolves_to_absolute(self): + host_dir, container_path = _resolve_log_file("decnet.log") + assert Path(host_dir).is_absolute() + assert container_path == f"{_CONTAINER_LOG_DIR}/decnet.log" + + def test_nested_filename_preserved(self, tmp_path): + log_path = str(tmp_path / "logs" / "honeypot.log") + _, container_path = _resolve_log_file(log_path) + assert container_path.endswith("honeypot.log") + + +class TestComposeLogFileMount: + def test_no_log_file_no_volume(self): + config = _make_config(log_file=None) + compose = generate_compose(config) + fragment = compose["services"]["decky-01-http"] + assert "DECNET_LOG_FILE" not in fragment.get("environment", {}) + volumes = fragment.get("volumes", []) + assert not any(_CONTAINER_LOG_DIR in v for v in volumes) + + def test_log_file_sets_env_var(self, tmp_path): + config = _make_config(log_file=str(tmp_path / "decnet.log")) + compose = generate_compose(config) + fragment = compose["services"]["decky-01-http"] + env = fragment["environment"] + assert "DECNET_LOG_FILE" in env + assert env["DECNET_LOG_FILE"].startswith(_CONTAINER_LOG_DIR) + assert env["DECNET_LOG_FILE"].endswith("decnet.log") + + def test_log_file_adds_volume_mount(self, tmp_path): + config = _make_config(log_file=str(tmp_path / "decnet.log")) + compose = generate_compose(config) + fragment = compose["services"]["decky-01-http"] + volumes = fragment.get("volumes", []) + assert any(_CONTAINER_LOG_DIR in v for v in volumes) + + def test_volume_mount_format(self, tmp_path): + config = _make_config(log_file=str(tmp_path / "decnet.log")) + compose = generate_compose(config) + fragment = compose["services"]["decky-01-http"] + mount = next(v for v in fragment["volumes"] if _CONTAINER_LOG_DIR in v) + host_part, container_part = mount.split(":") + assert Path(host_part).is_absolute() + assert container_part == _CONTAINER_LOG_DIR + + def test_host_log_dir_created(self, tmp_path): + log_dir = tmp_path / "newdir" + config = _make_config(log_file=str(log_dir / "decnet.log")) + generate_compose(config) + assert log_dir.exists() + + def test_volume_not_duplicated(self, tmp_path): + """Same mount must not appear twice even if fragment already has volumes.""" + config = _make_config(log_file=str(tmp_path / "decnet.log")) + compose = generate_compose(config) + fragment = compose["services"]["decky-01-http"] + log_mounts = [v for v in fragment["volumes"] if _CONTAINER_LOG_DIR in v] + assert len(log_mounts) == 1