Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/timecapsulesmb/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import os
import shlex
import tempfile
import unittest
Expand All @@ -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,
Expand Down Expand Up @@ -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"
Expand Down