Skip to content
Closed
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
9 changes: 6 additions & 3 deletions DETAIL.md
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,7 @@ Current important `.env` values include:

Current `.bootstrap` values include:
- `INSTALL_ID`
- optional `TELEMETRY=false`
- `TELEMETRY` (`false` by default; set `true` to opt in)

## CLI Command Reference

Expand Down Expand Up @@ -1192,10 +1192,13 @@ Current identity model:
- `.env` stores a rotating `TC_CONFIGURE_ID`

Current transport behavior:
- events are sent to the configured HTTPS telemetry endpoint
- telemetry is **opt-in**: disabled unless `.bootstrap` contains `TELEMETRY=true`
- `bootstrap` prompts once on first run; `tcapsule set-telemetry --enable/--disable/--status` changes it later
- when enabled, events are sent to the configured HTTPS telemetry endpoint
- started events are sent asynchronously
- finished events are sent synchronously so they are not lost at process exit
- if `.bootstrap` contains `TELEMETRY=false`, telemetry is disabled
- payloads are scrubbed before send: LAN IPv4/IPv6 addresses, SSH/SMB host targets, and local `/Volumes`, `/mnt`, `/Users`, `/home`, `/private` paths are dropped or redacted
- `TCAPSULE_TELEMETRY_ANONYMOUS=1` replaces the stable `INSTALL_ID` with a per-run id and drops `TC_CONFIGURE_ID`

## Uninstall

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,9 @@ This should be treated as a LAN-only setup. Do not expose this SMB service direc

Also note that the current auth model maps SMB access to `root` internally on the Time Capsule. That is a deliberate compatibility choice for this old firmware, as the version of NetBSD 6 running on the Time Capsule errors when Samba tries to switch users.

The commands have logging and telemetry enabled by default. Errors and exceptions are logged so they can be easily investigated later.
The commands log errors and exceptions locally so they can be investigated later.

Anonymous usage telemetry is **off by default**. Nothing is sent to a remote endpoint unless you opt in during `bootstrap` or with `tcapsule set-telemetry --enable` (disable again with `--disable`). Even when enabled, payloads never include your device LAN IP, SSH/SMB host targets, local filesystem paths, or the device password.

## For Developers And Maintainers

Expand Down
46 changes: 43 additions & 3 deletions src/timecapsulesmb/cli/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@

from timecapsulesmb.cli.context import CommandContext
from timecapsulesmb.cli.util import color_red
from timecapsulesmb.identity import ensure_install_id
from timecapsulesmb.identity import (
default_bootstrap_path,
ensure_install_id,
load_install_identity,
set_telemetry_enabled,
)
from timecapsulesmb.services.runtime import load_optional_env_config
from timecapsulesmb.telemetry import TelemetryClient
from timecapsulesmb.telemetry import DEFAULT_TELEMETRY_URL, TelemetryClient
from timecapsulesmb.transport.local import find_command


Expand Down Expand Up @@ -407,12 +412,47 @@ def install_required_host_tools() -> None:
print(f"Installed required host tools: {_format_tools(missing_tools)}", flush=True)


TELEMETRY_DISCLOSURE = (
"\nOptional anonymous usage telemetry\n"
" What: install id, CLI/Samba version, host OS, device model, command + result.\n"
f" Where: {DEFAULT_TELEMETRY_URL} (override with TCAPSULE_TELEMETRY_URL)\n"
" Default: OFF. Change any time with `tcapsule set-telemetry --enable/--disable`.\n"
)


def _prompt_telemetry_choice(no_input: bool) -> bool:
if no_input or not sys.stdin.isatty():
return False
print(TELEMETRY_DISCLOSURE, flush=True)
try:
answer = input("Enable telemetry to help the project? [y/N]: ").strip().lower()
except EOFError:
return False
return answer in {"y", "yes"}


def _apply_first_run_telemetry_choice(no_input: bool) -> None:
try:
bootstrap_path = default_bootstrap_path()
first_run = load_install_identity(bootstrap_path).install_id is None
except Exception:
ensure_install_id()
return
if first_run:
# Prompt before writing .bootstrap so an interrupted first run can be
# retried and re-prompted instead of being silently left disabled.
set_telemetry_enabled(_prompt_telemetry_choice(no_input), bootstrap_path)
Comment on lines +434 to +444

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In the current implementation, ensure_install_id(bootstrap_path) is called on line 441 before prompting the user for their telemetry choice on line 443.

If the user interrupts the bootstrap process (e.g., via Ctrl+C / KeyboardInterrupt) during the telemetry prompt, the .bootstrap file will have already been written with a generated INSTALL_ID and TELEMETRY=false. On any subsequent run, first_run will evaluate to False, meaning the user will never be prompted again, and telemetry will remain silently disabled without their explicit choice.

By checking first_run and prompting the user before writing the bootstrap file, we ensure that an interrupted first run can be safely retried and prompted again on the next run.

Suggested change
def _apply_first_run_telemetry_choice(no_input: bool) -> None:
try:
bootstrap_path = default_bootstrap_path()
first_run = load_install_identity(bootstrap_path).install_id is None
except Exception:
ensure_install_id()
return
ensure_install_id(bootstrap_path)
if first_run:
set_telemetry_enabled(_prompt_telemetry_choice(no_input), bootstrap_path)
def _apply_first_run_telemetry_choice(no_input: bool) -> None:
try:
bootstrap_path = default_bootstrap_path()
identity = load_install_identity(bootstrap_path)
first_run = identity.install_id is None
except Exception:
ensure_install_id()
return
if first_run:
choice = _prompt_telemetry_choice(no_input)
set_telemetry_enabled(choice, bootstrap_path)
else:
ensure_install_id(bootstrap_path)

else:
ensure_install_id(bootstrap_path)


def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Prepare the local host for TimeCapsuleSMB user workflows.")
parser.add_argument("--python", default=sys.executable or "python3", help="Python interpreter to use for the repo .venv")
parser.add_argument("--no-input", action="store_true", help="Do not prompt; leave telemetry disabled")
args = parser.parse_args(argv)

ensure_install_id()
_apply_first_run_telemetry_choice(args.no_input)
config = load_optional_env_config()
telemetry = TelemetryClient.from_config(config)
with CommandContext(
Expand Down
3 changes: 2 additions & 1 deletion src/timecapsulesmb/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import sys
from typing import Optional

from . import activate, api, bootstrap, configure, deploy, discover, doctor, flash, fsck, paths, set_ssh, repair_xattrs, uninstall, validate_install
from . import activate, api, bootstrap, configure, deploy, discover, doctor, flash, fsck, paths, set_ssh, set_telemetry, repair_xattrs, uninstall, validate_install
from timecapsulesmb.core.paths import DistributionRootError
from timecapsulesmb.services.version_check import check_client_version, render_version_block_message

Expand All @@ -21,6 +21,7 @@
"fsck": fsck.main,
"paths": paths.main,
"set-ssh": set_ssh.main,
"set-telemetry": set_telemetry.main,
"repair-xattrs": repair_xattrs.main,
"uninstall": uninstall.main,
"validate-install": validate_install.main,
Expand Down
28 changes: 28 additions & 0 deletions src/timecapsulesmb/cli/set_telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from __future__ import annotations

import argparse
from typing import Optional

from timecapsulesmb.identity import default_bootstrap_path, load_install_identity, set_telemetry_enabled


def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Enable or disable anonymous usage telemetry.")
group = parser.add_mutually_exclusive_group()
group.add_argument("--enable", action="store_true", help="Enable telemetry")
group.add_argument("--disable", action="store_true", help="Disable telemetry")
group.add_argument("--status", action="store_true", help="Only report the current setting")
args = parser.parse_args(argv)

path = default_bootstrap_path()
if args.status:
identity = load_install_identity(path)
print(f"telemetry: {'enabled' if identity.telemetry_enabled else 'disabled'}")
return 0

if not args.enable and not args.disable:
parser.error("select --enable, --disable, or --status")
Comment on lines +11 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of manually checking if none of the arguments were provided on lines 23-24, you can make the mutually exclusive group required by passing required=True to add_mutually_exclusive_group(). This allows argparse to automatically handle the validation and print a standard, user-friendly error message, simplifying the code.

Suggested change
group = parser.add_mutually_exclusive_group()
group.add_argument("--enable", action="store_true", help="Enable telemetry")
group.add_argument("--disable", action="store_true", help="Disable telemetry")
group.add_argument("--status", action="store_true", help="Only report the current setting")
args = parser.parse_args(argv)
path = default_bootstrap_path()
if args.status:
identity = load_install_identity(path)
print(f"telemetry: {'enabled' if identity.telemetry_enabled else 'disabled'}")
return 0
if not args.enable and not args.disable:
parser.error("select --enable, --disable, or --status")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--enable', action='store_true', help='Enable telemetry')
group.add_argument('--disable', action='store_true', help='Disable telemetry')
group.add_argument('--status', action='store_true', help='Only report the current setting')
args = parser.parse_args(argv)
path = default_bootstrap_path()
if args.status:
identity = load_install_identity(path)
print(f"telemetry: {'enabled' if identity.telemetry_enabled else 'disabled'}")
return 0


identity = set_telemetry_enabled(bool(args.enable), path)
print(f"telemetry: {'enabled' if identity.telemetry_enabled else 'disabled'}")
return 0
54 changes: 54 additions & 0 deletions src/timecapsulesmb/core/redaction.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,66 @@
from __future__ import annotations

import re
from collections.abc import Mapping
from pathlib import Path


SENSITIVE_KEY_PARTS = ("credentials", "password", "secret", "token")
REDACTED = "<redacted>"

TELEMETRY_DROP_KEYS = frozenset({
"host",
"ssh_host",
"smb_host",
"mountpoint",
"fsck_mountpoint",
"root",
"path",
"backup_dir",
})

_SENSITIVE_PATH_PREFIXES = ("/Volumes", "/mnt", "/Users", "/home", "/private")
_IPV4_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
_IPV6_RE = re.compile(r"\b(?:[0-9a-fA-F]{1,4}:){2,}[0-9a-fA-F]{1,4}\b")
_ABS_PATH_RE = re.compile(r"(?:/Volumes|/mnt|/Users|/home|/private)(?:/[^\s\"']*)?")


def scrub_network_and_paths(text: str) -> str:
scrubbed = _ABS_PATH_RE.sub(REDACTED, text)
scrubbed = _IPV6_RE.sub(REDACTED, scrubbed)
scrubbed = _IPV4_RE.sub(REDACTED, scrubbed)
return scrubbed


def scrub_telemetry_value(value: object) -> object:
if isinstance(value, Mapping):
scrubbed: dict[str, object] = {}
for key, item in value.items():
key_text = str(key)
if key_text.lower() in TELEMETRY_DROP_KEYS:
continue
scrubbed[key_text] = scrub_telemetry_value(item)
return scrubbed
if isinstance(value, (list, tuple, set)):
return [scrub_telemetry_value(item) for item in value]
if isinstance(value, Path):
path_str = str(value)
if path_str.startswith(_SENSITIVE_PATH_PREFIXES):
return REDACTED
return scrub_network_and_paths(path_str)
if isinstance(value, str):
return scrub_network_and_paths(value)
if value is None or isinstance(value, (bool, int, float)):
return value
return scrub_network_and_paths(str(value))


def scrub_telemetry_mapping(mapping: Mapping[str, object]) -> dict[str, object]:
scrubbed = scrub_telemetry_value(dict(mapping))
if isinstance(scrubbed, dict):
return scrubbed
return {}


def redact_sensitive_fields(value: object) -> object:
if isinstance(value, Mapping):
Expand Down
16 changes: 10 additions & 6 deletions src/timecapsulesmb/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,25 @@ def parse_bootstrap_values(path: Path | None = None) -> dict[str, str]:
return values


TELEMETRY_ENABLED_VALUES = frozenset({"true", "1", "yes", "on"})


def load_install_identity(path: Path | None = None) -> InstallIdentity:
values = parse_bootstrap_values(path)
telemetry_raw = values.get("TELEMETRY", "").strip().lower()
telemetry_enabled = telemetry_raw != "false"
telemetry_enabled = telemetry_raw in TELEMETRY_ENABLED_VALUES
return InstallIdentity(
install_id=values.get("INSTALL_ID") or None,
telemetry_enabled=telemetry_enabled,
)


def render_bootstrap_text(install_id: str, *, telemetry_enabled: bool = True) -> str:
lines = [f"INSTALL_ID={install_id}"]
if not telemetry_enabled:
lines.append("TELEMETRY=false")
lines.append("")
def render_bootstrap_text(install_id: str, *, telemetry_enabled: bool = False) -> str:
lines = [
f"INSTALL_ID={install_id}",
f"TELEMETRY={'true' if telemetry_enabled else 'false'}",
"",
]
return "\n".join(lines)


Expand Down
12 changes: 10 additions & 2 deletions src/timecapsulesmb/telemetry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from pathlib import Path

from timecapsulesmb.core.config import AppConfig
from timecapsulesmb.core.redaction import scrub_telemetry_mapping
from timecapsulesmb.core.release import CLI_VERSION, RELEASE_TAG, SAMBA_VERSION
from timecapsulesmb.identity import load_install_identity

Expand All @@ -21,6 +22,7 @@
DEFAULT_TELEMETRY_URL = "https://timecapsulesmb.jamesyc.com/v1/events"
TELEMETRY_URL_ENV = "TCAPSULE_TELEMETRY_URL"
TELEMETRY_TOKEN_ENV = "TCAPSULE_TELEMETRY_TOKEN"
TELEMETRY_ANONYMOUS_ENV = "TCAPSULE_TELEMETRY_ANONYMOUS"
DEFAULT_TELEMETRY_TOKEN = "d65373762e893ae18c8aaa95a8f1b3a3464611f33b30983909543535fa8b0733"
REQUEST_TIMEOUT_SECONDS = 10.0
MAX_SEND_ATTEMPTS = 2
Expand Down Expand Up @@ -61,14 +63,15 @@ def from_config(
token = os.getenv(TELEMETRY_TOKEN_ENV, DEFAULT_TELEMETRY_TOKEN).strip() or None
if not identity.install_id:
return cls(endpoint=endpoint, token=token, context=None, enabled=False)
anonymous = _anonymous_mode_enabled()
context = TelemetryContext(
install_id=identity.install_id,
install_id=str(uuid.uuid4()) if anonymous else identity.install_id,
cli_version=CLI_VERSION,
release_tag=RELEASE_TAG,
samba_version=SAMBA_VERSION,
host_os=detect_host_os(),
host_os_version=detect_host_os_version(),
configure_id=config.get("TC_CONFIGURE_ID") or None,
configure_id=None if anonymous else (config.get("TC_CONFIGURE_ID") or None),
device_model=None,
device_syap=None,
nbns_enabled=nbns_enabled,
Expand Down Expand Up @@ -132,6 +135,7 @@ def emit(
for key, value in fields.items():
if value is not None:
payload[key] = value
payload = scrub_telemetry_mapping(payload)
if synchronous:
self._send_payload(payload)
return
Expand Down Expand Up @@ -171,6 +175,10 @@ def _send_payload(self, payload: dict[str, object]) -> None:
return


def _anonymous_mode_enabled() -> bool:
return os.getenv(TELEMETRY_ANONYMOUS_ENV, "").strip().lower() in {"true", "1", "yes", "on"}


def build_device_os_version(os_name: str | None, os_release: str | None, arch: str | None) -> str | None:
if not os_name or not os_release or not arch:
return None
Expand Down
6 changes: 3 additions & 3 deletions src/timecapsulesmb/telemetry/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from pathlib import Path
from typing import Optional

from timecapsulesmb.core.redaction import SENSITIVE_KEY_PARTS
from timecapsulesmb.core.redaction import SENSITIVE_KEY_PARTS, scrub_telemetry_mapping
from timecapsulesmb.telemetry import TelemetryClient


Expand Down Expand Up @@ -171,7 +171,7 @@ def telemetry_options_from_params(params: Mapping[str, object]) -> dict[str, obj
value = params.get(key)
if value is not None:
options[key] = _jsonable(value)
return options
return scrub_telemetry_mapping(options)


def telemetry_options_from_args(args: object | None) -> dict[str, object]:
Expand All @@ -192,7 +192,7 @@ def telemetry_details_from_payload(
payload: object | None,
) -> dict[str, object]:
extractor = DETAIL_EXTRACTORS.get(operation, _details_common)
return extractor(params, payload)
return scrub_telemetry_mapping(extractor(params, payload))


DetailExtractor = Callable[[Mapping[str, object], Optional[object]], dict[str, object]]
Expand Down
2 changes: 1 addition & 1 deletion tests/test_app_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1177,7 +1177,7 @@ def run_fsck(params, context):
self.assertEqual(finished.kwargs["risk"], "destructive")
self.assertEqual(finished.kwargs["details"]["volume"], "Data")
self.assertEqual(finished.kwargs["details"]["fsck_device"], "/dev/dk2")
self.assertEqual(finished.kwargs["details"]["fsck_mountpoint"], "/Volumes/Data")
self.assertNotIn("fsck_mountpoint", finished.kwargs["details"])
self.assertEqual(finished.kwargs["details"]["returncode"], 0)
self.assertTrue(finished.kwargs["details"]["reboot_requested"])
self.assertTrue(finished.kwargs["details"]["waited"])
Expand Down
17 changes: 15 additions & 2 deletions tests/test_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,20 @@ def test_ensure_install_id_creates_bootstrap_file(self) -> None:
self.assertTrue(install_id)
values = parse_bootstrap_values(path)
self.assertEqual(values["INSTALL_ID"], install_id)
self.assertNotIn("TELEMETRY", values)
self.assertEqual(values["TELEMETRY"], "false")

def test_default_install_has_telemetry_disabled(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / ".bootstrap"
ensure_install_id(path)
self.assertFalse(load_install_identity(path).telemetry_enabled)

def test_telemetry_only_enabled_by_explicit_true(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
for raw, expected in (("true", True), ("1", True), ("yes", True), ("on", True), ("", False), ("nope", False)):
path = Path(tmp) / f".bootstrap-{raw or 'blank'}"
path.write_text(f"INSTALL_ID=install\nTELEMETRY={raw}\n")
self.assertEqual(load_install_identity(path).telemetry_enabled, expected)

def test_ensure_install_id_preserves_telemetry_false(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
Expand All @@ -48,7 +61,7 @@ def test_set_telemetry_enabled_preserves_install_id_and_updates_preference(self)
enabled = set_telemetry_enabled(True, path)
self.assertEqual(enabled.install_id, "install-one")
self.assertTrue(enabled.telemetry_enabled)
self.assertEqual(parse_bootstrap_values(path), {"INSTALL_ID": "install-one"})
self.assertEqual(parse_bootstrap_values(path), {"INSTALL_ID": "install-one", "TELEMETRY": "true"})


if __name__ == "__main__":
Expand Down
Loading