diff --git a/README.md b/README.md index ceb5fdc5..a16857bb 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ At the start of `configure`, the tool first tries to discover your Time Capsule `configure` also checks whether SSH is reachable. If SSH is closed, it enables SSH using the built-in Python 3 ACP client, reboots the device, waits for SSH to come up, and then continues the normal probing flow. If the password is wrong, it asks again instead of writing a broken `.env` file. -The password you enter here is stored locally as `TC_PASSWORD` so the tool can keep using SSH and ACP. The managed Samba runtime reads the current device password on the Time Capsule at boot. In other words, after setup, you normally connect with: +The password you enter here is stored locally as `TC_PASSWORD` so the tool can keep using SSH and ACP. The `.env` file is written owner-only (`0600`) by default; if you need a different mode (for example a shared Docker or multi-user setup), set `TCAPSULE_ENV_FILE_MODE` to an octal mode like `640`. The managed Samba runtime reads the current device password on the Time Capsule at boot. In other words, after setup, you normally connect with: - username: `admin` (or any other password) - password: the same Time Capsule password you entered during configuration diff --git a/src/timecapsulesmb/core/config.py b/src/timecapsulesmb/core/config.py index 4f56bdae..ec56bf19 100644 --- a/src/timecapsulesmb/core/config.py +++ b/src/timecapsulesmb/core/config.py @@ -3,6 +3,7 @@ import shlex from dataclasses import dataclass from pathlib import Path +from collections.abc import Mapping from typing import Callable, Optional import os import re @@ -648,8 +649,27 @@ def preserved_env_file_values(values: dict[str, str]) -> dict[str, str]: return {key: value for key, value in values.items() if key not in ENV_FILE_OMIT_KEYS} +ENV_FILE_MODE_ENV = "TCAPSULE_ENV_FILE_MODE" +DEFAULT_ENV_FILE_MODE = 0o600 + + +def env_file_target_mode(env: Mapping[str, str] | None = None) -> int: + environ = os.environ if env is None else env + raw = environ.get(ENV_FILE_MODE_ENV, "").strip() + if not raw: + return DEFAULT_ENV_FILE_MODE + try: + mode = int(raw, 8) + except ValueError: + return DEFAULT_ENV_FILE_MODE + if 0 <= mode <= 0o777: + return mode + return DEFAULT_ENV_FILE_MODE + + def write_env_file(path: Path, values: dict[str, str]) -> None: text = render_env_text(values) + mode = env_file_target_mode() tmp_name: str | None = None try: with tempfile.NamedTemporaryFile( @@ -664,6 +684,7 @@ def write_env_file(path: Path, values: dict[str, str]) -> None: tmp.write(text) tmp.flush() os.fsync(tmp.fileno()) + os.chmod(tmp_name, mode) os.replace(tmp_name, path) finally: if tmp_name is not None: diff --git a/tests/test_config.py b/tests/test_config.py index d1b23277..ba8d8d53 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import shlex import tempfile import unittest @@ -19,7 +20,9 @@ ConfigError, ConfigValidationError, build_mdns_device_model_txt, + DEFAULT_ENV_FILE_MODE, DEFAULTS, + env_file_target_mode, load_app_config, parse_bool, parse_env_file, @@ -205,6 +208,37 @@ def test_validate_bool_accepts_true_false_and_missing_default(self) -> None: self.assertIsNone(validate_bool("", "Flag")) self.assertEqual(validate_bool("yes", "Flag"), "Flag must be true or false.") + def test_env_file_target_mode_defaults_and_overrides(self) -> None: + self.assertEqual(env_file_target_mode({}), DEFAULT_ENV_FILE_MODE) + self.assertEqual(env_file_target_mode({}), 0o600) + self.assertEqual(env_file_target_mode({"TCAPSULE_ENV_FILE_MODE": "640"}), 0o640) + self.assertEqual(env_file_target_mode({"TCAPSULE_ENV_FILE_MODE": "0640"}), 0o640) + self.assertEqual(env_file_target_mode({"TCAPSULE_ENV_FILE_MODE": "0o640"}), 0o640) + # Invalid or out-of-range values fall back to the safe default. + self.assertEqual(env_file_target_mode({"TCAPSULE_ENV_FILE_MODE": "not-octal"}), 0o600) + self.assertEqual(env_file_target_mode({"TCAPSULE_ENV_FILE_MODE": "99999"}), 0o600) + + @unittest.skipUnless(os.name == "posix", "POSIX file modes only") + def test_write_env_file_defaults_to_0600(self) -> None: + values = dict(DEFAULTS) + values["TC_PASSWORD"] = "secret" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ".env" + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("TCAPSULE_ENV_FILE_MODE", None) + write_env_file(path, values) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + + @unittest.skipUnless(os.name == "posix", "POSIX file modes only") + def test_write_env_file_honors_mode_override(self) -> None: + values = dict(DEFAULTS) + values["TC_PASSWORD"] = "secret" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ".env" + with mock.patch.dict(os.environ, {"TCAPSULE_ENV_FILE_MODE": "640"}): + write_env_file(path, values) + self.assertEqual(path.stat().st_mode & 0o777, 0o640) + def test_write_env_file_omits_mdns_device_model(self) -> None: values = dict(DEFAULTS) values["TC_PASSWORD"] = "secret"