From 3184cb63d30f27d2ed69680c3f77401a31784eb8 Mon Sep 17 00:00:00 2001 From: Chris Berry Date: Sun, 16 Aug 2026 20:39:45 -0700 Subject: [PATCH] Use self-contained private switch config --- .secrets.baseline | 2 +- AGENTS.md | 3 ++- README.md | 32 +++++++++++++++----------------- SKILL.md | 4 ++-- config/switches.example.json | 2 +- docs/protocol.md | 4 ++-- docs/roadmap.md | 2 +- docs/troubleshooting.md | 8 ++++---- runbooks/tuya-local-setup.md | 13 ++++++------- script/install | 17 +++++++++++++---- switchctl/config.py | 2 +- switchctl/models.py | 9 +-------- tests/test_backends.py | 1 - tests/test_config.py | 19 ++++++++++++++----- 14 files changed, 63 insertions(+), 55 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index ccb5b66..9e864be 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -135,5 +135,5 @@ } ], "results": {}, - "generated_at": "2026-08-16T20:45:51Z" + "generated_at": "2026-08-17T03:34:09Z" } diff --git a/AGENTS.md b/AGENTS.md index 3450fe1..8aacdfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,4 +38,5 @@ rendering, and CLI dispatch separate. Public files may contain documentation-only values such as `192.0.2.10` and generic example names. Site inventory belongs in the private `home-ops` repo. -Secrets remain in a password manager or local mode-`0600` files, never Git. +Secrets may live in the private `home-ops` configuration deployed mode `0600`, +but never in this public repository, logs, issues, or command output. diff --git a/README.md b/README.md index b47e3e0..16d06b1 100644 --- a/README.md +++ b/README.md @@ -29,15 +29,14 @@ Python 3.11 or newer is required by the current implementation. ## Install ```bash -git clone https://github.com/cnberry/switchctl.git -cd switchctl -./script/install +cd /path/to/private/home-ops +./bin/bootstrap-ctls switchctl ``` -`script/install` is the stable repository contract used by private deployment -automation. Today it installs the Python package with `pipx`; it can be replaced -by a Rust or binary installer later without changing callers. `just install` -uses the same contract. +The private `home-ops` bootstrap is the canonical installer: it populates the +real switch inventory, calls this repository's stable `script/install` +contract, and creates `/usr/local/bin/switchctl` backed by an isolated system +environment under `/usr/local/lib/home-ops/ctls`. ## Configure private switches @@ -45,8 +44,8 @@ Install the sanitized example outside the repository, then replace it with your own endpoint inventory: ```bash -mkdir -p ~/.config/switchctl -install -m 600 config/switches.example.json ~/.config/switchctl/config.json +sudo install -d -m 700 /usr/local/config/switchctl +sudo install -m 600 config/switches.example.json /usr/local/config/switchctl/config.json ``` Set `SWITCHCTL_CONFIG=/path/to/config.json` or pass global `--config PATH` to @@ -54,18 +53,18 @@ select another private file. Host addresses, device IDs, backend IDs, rooms, names, notes, and tags are private deployment data and belong in a private configuration repository. -Tuya local keys should not be committed even to a private repository. Use a -named environment reference in config: +Store each Tuya local key directly in the private mode-`0600` configuration: ```json { - "local_key_env": "SWITCHCTL_EXAMPLE_OUTLET_LOCAL_KEY" + "local_key": "replace-with-local-key" } ``` -Then supply that variable from a password manager before running the command. -The legacy inline `local_key` field remains supported for a tightly permissioned -local config file. +The public repository and command output must never contain or print real keys. +The private `home-ops` repository is the deployment source of truth and its +bootstrap installs the complete configuration without runtime environment +variables. ## Inspect state @@ -99,10 +98,9 @@ targets fan out only after that explicit guard. See | Data | Default path | Git policy | | --- | --- | --- | -| Switch inventory | `~/.config/switchctl/config.json` | Private config repo; no keys | +| Switch inventory and local keys | `/usr/local/config/switchctl/config.json` | Private config repo only | | Manual state | `~/.local/state/switchctl/manual-state.json` | Never commit | | Tuya cloud extraction config | `~/.config/switchctl/tinytuya.json` | Never commit | -| Tuya local keys | Named environment variables or local config | Never commit | Generated configuration and manual state files use mode `0600`. diff --git a/SKILL.md b/SKILL.md index bd00b67..8609842 100644 --- a/SKILL.md +++ b/SKILL.md @@ -15,8 +15,8 @@ command already exists. - Read state before a write when the exact endpoint or current state is unclear. - Use `--yes` only after the selector, resulting target set, and action are clear. - Report the post-write state returned by the backend. -- Never print or request a local key in chat; use the configured environment - variable or local mode-`0600` config. +- Never print or request a local key in chat; use the deployed mode-`0600` + private config. - Follow the Tuya onboarding runbook instead of improvising cloud extraction. ## Commands diff --git a/config/switches.example.json b/config/switches.example.json index 7ac273e..313c4f6 100644 --- a/config/switches.example.json +++ b/config/switches.example.json @@ -18,7 +18,7 @@ "host": "192.0.2.10", "port": 6668, "device_id": "replace-with-device-id", - "local_key_env": "SWITCHCTL_EXAMPLE_OUTLET_LOCAL_KEY", + "local_key": "replace-with-local-key", "switch_dp": "1", "protocol_version": "3.4", "notes": ["Use documentation-only values in public examples."] diff --git a/docs/protocol.md b/docs/protocol.md index e97a254..c5c5f5f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -14,8 +14,8 @@ network hardware. The Tuya backend uses `tinytuya` in-process for LAN status and setpoint calls. Required private fields are host, device ID, local key, and switch datapoint; -protocol version defaults to 3.3. Local keys may come from an environment name -declared by `local_key_env`, keeping the secret out of tracked configuration. +protocol version defaults to 3.3. Local keys are read from the private +mode-`0600` configuration and are never included in command output. The backend checks TCP reachability before protocol calls. A set operation reads status again and returns the state from that response. Device families and diff --git a/docs/roadmap.md b/docs/roadmap.md index 1d54219..6b54f2b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,7 +19,7 @@ ## Installer direction Deployment automation calls `script/install`, never a language-specific package -manager. The current script uses `pipx`; a Rust migration should replace it with +manager. The current script creates a system venv; a Rust migration should replace it with a verified binary or `cargo` installation while preserving that entry point. ## Out of scope by default diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ad0d9d3..47a6cc9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2,15 +2,15 @@ ## Config is not found -Create `~/.config/switchctl/config.json` from the public example, install the +Create `/usr/local/config/switchctl/config.json` from the public example, install the private home-ops inventory, or set `SWITCHCTL_CONFIG`. Repository-local runtime configuration is intentionally unsupported. ## A local key is missing -If the target uses `local_key_env`, load that exact environment variable from a -password manager in the process running `switchctl`. Inline keys remain -supported only for local mode-`0600` configuration. +Add the target's `local_key` to the private source configuration, run the +`home-ops` bootstrap, and confirm `/usr/local/config/switchctl/config.json` is +owned by the operating user with mode `0600`. Never paste the key into logs. ## A device is unreachable diff --git a/runbooks/tuya-local-setup.md b/runbooks/tuya-local-setup.md index 7f3fad6..40710cd 100644 --- a/runbooks/tuya-local-setup.md +++ b/runbooks/tuya-local-setup.md @@ -57,24 +57,23 @@ If an allowlisted IPv4 project is reached over IPv6 and the provider rejects the request, constrain the extraction client to IPv4 using current TinyTuya guidance rather than copying an environment-specific script into public documentation. -## 5. Record non-secret inventory +## 5. Record private inventory Add the endpoint to private `home-ops` switch configuration with its meaningful -local name, role, room, host, device ID, protocol version, and datapoint. Replace -the local key with an environment reference such as: +local name, role, room, host, device ID, local key, protocol version, and +datapoint: ```json { - "local_key_env": "SWITCHCTL_EXAMPLE_OUTLET_LOCAL_KEY" + "local_key": "replace-with-local-key" } ``` -Store the actual variable value in a password manager on each authorized -machine. +Keep that repository private and deploy the config with mode `0600`. ## 6. Validate local control -Install the private config, load the key environment variable, and run: +Install the complete private config and run: ```bash switchctl status example-outlet diff --git a/script/install b/script/install index c95f029..c3e9af5 100755 --- a/script/install +++ b/script/install @@ -2,11 +2,20 @@ set -eu repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +name=switchctl +install_prefix=${CTL_INSTALL_PREFIX:-/usr/local} +venv_root=${CTL_VENV_ROOT:-$install_prefix/lib/home-ops/ctls} +bin_dir=${CTL_BIN_DIR:-$install_prefix/bin} +venv="$venv_root/$name" +python=${PYTHON:-python3} -if ! command -v pipx >/dev/null 2>&1; then - echo "switchctl: the current Python implementation requires pipx" >&2 - echo "switchctl: install pipx, or replace script/install when a binary implementation ships" >&2 +if ! command -v "$python" >/dev/null 2>&1; then + echo "$name: Python 3 is required" >&2 exit 1 fi -exec pipx install --force "$repo_root" +install -d -m 755 "$install_prefix/lib" "$install_prefix/lib/home-ops" "$venv_root" "$bin_dir" +"$python" -m venv --clear "$venv" +"$venv/bin/python" -m pip install --disable-pip-version-check "$repo_root" +chmod -R a+rX "$venv" +ln -sfn "$venv/bin/$name" "$bin_dir/$name" diff --git a/switchctl/config.py b/switchctl/config.py index 008c3be..1c6298e 100644 --- a/switchctl/config.py +++ b/switchctl/config.py @@ -10,7 +10,7 @@ from switchctl.models import SwitchTarget ENV_CONFIG_PATH = "SWITCHCTL_CONFIG" -DEFAULT_CONFIG_PATH = Path.home() / ".config" / "switchctl" / "config.json" +DEFAULT_CONFIG_PATH = Path("/usr/local/config/switchctl/config.json") DEFAULT_MANUAL_STATE_PATH = Path.home() / ".local" / "state" / "switchctl" / "manual-state.json" ROOT = Path(__file__).resolve().parent.parent EXAMPLE_CONFIG_PATH = ROOT / "config" / "switches.example.json" diff --git a/switchctl/models.py b/switchctl/models.py index 43d90f8..cdcc7c2 100644 --- a/switchctl/models.py +++ b/switchctl/models.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os from dataclasses import asdict, dataclass, field from typing import Any @@ -19,16 +18,11 @@ class SwitchTarget: port: int | None = None device_id: str | None = None local_key: str | None = None - local_key_env: str | None = None switch_dp: str | int | None = None protocol_version: str | None = None @classmethod def from_dict(cls, data: dict[str, Any]) -> "SwitchTarget": - local_key_env = data.get("local_key_env") - local_key = data.get("local_key") - if not local_key and local_key_env: - local_key = os.environ.get(local_key_env) return cls( id=data["id"], name=data.get("name", data["id"]), @@ -41,8 +35,7 @@ def from_dict(cls, data: dict[str, Any]) -> "SwitchTarget": host=data.get("host"), port=data.get("port"), device_id=data.get("device_id"), - local_key=local_key, - local_key_env=local_key_env, + local_key=data.get("local_key"), switch_dp=data.get("switch_dp"), protocol_version=data.get("protocol_version"), ) diff --git a/tests/test_backends.py b/tests/test_backends.py index 541e13e..9634bef 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -17,7 +17,6 @@ def make_target(**overrides): "port": None, "device_id": None, "local_key": None, - "local_key_env": None, "switch_dp": None, "protocol_version": None, "notes": [], diff --git a/tests/test_config.py b/tests/test_config.py index 7d4facc..aa078ba 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,16 @@ import stat -from switchctl.config import default_config, load_targets, redacted_config, write_default_config +from switchctl.config import ( + DEFAULT_CONFIG_PATH, + default_config, + load_targets, + redacted_config, + write_default_config, +) + + +def test_system_config_path_is_the_default(): + assert DEFAULT_CONFIG_PATH.as_posix() == "/usr/local/config/switchctl/config.json" def test_default_config_has_expected_targets(tmp_path): @@ -18,17 +28,16 @@ def test_default_config_shape(): assert len(config["targets"]) == 1 -def test_local_key_can_come_from_named_environment(monkeypatch, tmp_path): - monkeypatch.setenv("SWITCHCTL_EXAMPLE_OUTLET_LOCAL_KEY", "environment-only") +def test_local_key_comes_from_private_config(tmp_path): path = tmp_path / "config.json" path.write_text( '{"targets": [{"id": "example-outlet", "name": "Example", ' '"role": "outlet", "backend": "tuya-local", ' - '"local_key_env": "SWITCHCTL_EXAMPLE_OUTLET_LOCAL_KEY"}]}' + '"local_key": "private-config-value"}]}' ) _, targets = load_targets(path) outlet = next(target for target in targets if target.id == "example-outlet") - assert outlet.local_key == "environment-only" + assert outlet.local_key == "private-config-value" def test_redacted_config_hides_private_fields():