Add private Home Assistant credentials settings
This commit is contained in:
@@ -43,10 +43,188 @@ SettingsPage {
|
||||
return "Home Assistant is unavailable";
|
||||
}
|
||||
|
||||
function saveHomeAssistantConfig(): void {
|
||||
HomeAssistantConfig.save(
|
||||
homeUrlInput.text,
|
||||
homeEntitiesInput.text,
|
||||
homeTokenInput.text
|
||||
);
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: HomeAssistantConfig
|
||||
|
||||
function onConfigurationSaved(): void {
|
||||
homeTokenInput.clear();
|
||||
homeUrlInput.text = HomeAssistantConfig.url;
|
||||
homeEntitiesInput.text = HomeAssistantConfig.entities.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Home Assistant"
|
||||
subtitle: root.homeStatus()
|
||||
|
||||
SettingRow {
|
||||
label: "Connection"
|
||||
detail: HomeAssistantConfig.tokenConfigured
|
||||
? "A long-lived access token is stored privately"
|
||||
: "Paste a long-lived access token to connect"
|
||||
value: HomeAssistantConfig.configured ? "Configured" : "Not configured"
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Server URL"
|
||||
detail: "The local or remote address of Home Assistant"
|
||||
controlWidth: 330
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
border.width: homeUrlInput.activeFocus ? 2 : 1
|
||||
border.color: homeUrlInput.activeFocus
|
||||
? Theme.alpha(Theme.accent, 0.55) : "transparent"
|
||||
|
||||
TextInput {
|
||||
id: homeUrlInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
activeFocusOnTab: true
|
||||
text: HomeAssistantConfig.url
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||
selectedTextColor: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
clip: true
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
visible: homeUrlInput.text === ""
|
||||
text: "https://homeassistant.local:8123"
|
||||
color: Theme.fgMuted
|
||||
font: homeUrlInput.font
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Access token"
|
||||
detail: HomeAssistantConfig.tokenConfigured
|
||||
? "Stored · leave blank to keep it"
|
||||
: "Create one in your Home Assistant profile"
|
||||
controlWidth: 330
|
||||
|
||||
PasswordField {
|
||||
id: homeTokenInput
|
||||
anchors.fill: parent
|
||||
placeholder: HomeAssistantConfig.tokenConfigured
|
||||
? "Stored token" : "Long-lived access token"
|
||||
onAccepted: root.saveHomeAssistantConfig()
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 7
|
||||
topPadding: 10
|
||||
bottomPadding: 12
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Light entities"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Comma-separated entity IDs. These define the discoverable light catalog."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 72
|
||||
radius: 10
|
||||
color: Theme.alpha(Theme.fg, 0.055)
|
||||
border.width: homeEntitiesInput.activeFocus ? 2 : 1
|
||||
border.color: homeEntitiesInput.activeFocus
|
||||
? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.06)
|
||||
|
||||
TextEdit {
|
||||
id: homeEntitiesInput
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
activeFocusOnTab: true
|
||||
text: HomeAssistantConfig.entities.join(", ")
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||
selectedTextColor: Theme.fg
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: TextEdit.Wrap
|
||||
clip: true
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
visible: homeEntitiesInput.text === ""
|
||||
text: "light.living_room, light.kitchen"
|
||||
color: Theme.fgMuted
|
||||
font: homeEntitiesInput.font
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: HomeAssistantConfig.lastError !== ""
|
||||
text: HomeAssistantConfig.lastError
|
||||
color: Theme.danger
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
bottomPadding: 9
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Private configuration"
|
||||
detail: "Saved with owner-only permissions in Panama's private environment file"
|
||||
controlWidth: 216
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Clear token"
|
||||
enabled: HomeAssistantConfig.tokenConfigured && !HomeAssistantConfig.busy
|
||||
onClicked: HomeAssistantConfig.clearToken()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: HomeAssistantConfig.busy ? "Saving…" : "Save"
|
||||
tone: "accent"
|
||||
enabled: !HomeAssistantConfig.busy
|
||||
onClicked: root.saveHomeAssistantConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Light catalog"
|
||||
detail: "Panama reads light state through the Home Assistant helper."
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Read and atomically update Panama's private Home Assistant settings.
|
||||
|
||||
Secret values are accepted only as a single JSON object on stdin and are never
|
||||
returned. The command line therefore remains safe to inspect with ps(1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
|
||||
KEY_URL = "PANAMA_HOME_ASSISTANT_URL"
|
||||
KEY_TOKEN = "PANAMA_HOME_ASSISTANT_TOKEN"
|
||||
KEY_ENTITIES = "PANAMA_HOME_ASSISTANT_ENTITIES"
|
||||
TARGET_KEYS = (KEY_URL, KEY_TOKEN, KEY_ENTITIES)
|
||||
ASSIGNMENT = re.compile(
|
||||
r"^(?P<prefix>\s*(?:export\s+)?)(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=(?P<value>.*)$"
|
||||
)
|
||||
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
|
||||
DEFAULT_ENV = pathlib.Path(__file__).resolve().parents[3] / "bash/env"
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""An error code safe to display without including submitted values."""
|
||||
|
||||
|
||||
def env_path() -> pathlib.Path:
|
||||
override = os.environ.get("PANAMA_HOME_ASSISTANT_ENV_FILE", "")
|
||||
return pathlib.Path(override) if override else DEFAULT_ENV
|
||||
|
||||
|
||||
def parse_assignment(raw: str) -> str | None:
|
||||
try:
|
||||
parsed = shlex.split(raw, comments=True, posix=True)
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed[0] if len(parsed) == 1 else None
|
||||
|
||||
|
||||
def read_values(path: pathlib.Path) -> dict[str, str]:
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except OSError as error:
|
||||
raise ConfigError("read-failed") from error
|
||||
|
||||
values: dict[str, str] = {}
|
||||
for line in lines:
|
||||
match = ASSIGNMENT.match(line)
|
||||
if not match or match.group("key") not in TARGET_KEYS:
|
||||
continue
|
||||
value = parse_assignment(match.group("value"))
|
||||
if value is not None:
|
||||
values[match.group("key")] = value
|
||||
return values
|
||||
|
||||
|
||||
def normalize_url(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ConfigError("invalid-url")
|
||||
normalized = value.strip().rstrip("/")
|
||||
if not normalized:
|
||||
return ""
|
||||
parsed = urllib.parse.urlsplit(normalized)
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
):
|
||||
raise ConfigError("invalid-url")
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_token(value: Any) -> str:
|
||||
if not isinstance(value, str) or "\x00" in value or "\n" in value or "\r" in value:
|
||||
raise ConfigError("invalid-token")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def normalize_entities(value: Any) -> tuple[str, ...]:
|
||||
if isinstance(value, str):
|
||||
candidates: Sequence[Any] = re.split(r"[,\n]", value)
|
||||
elif isinstance(value, list):
|
||||
candidates = value
|
||||
else:
|
||||
raise ConfigError("invalid-entities")
|
||||
|
||||
entities: list[str] = []
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, str):
|
||||
raise ConfigError("invalid-entities")
|
||||
entity_id = candidate.strip()
|
||||
if not entity_id:
|
||||
continue
|
||||
if not ENTITY_ID.fullmatch(entity_id):
|
||||
raise ConfigError("invalid-entities")
|
||||
if entity_id not in entities:
|
||||
entities.append(entity_id)
|
||||
return tuple(entities)
|
||||
|
||||
|
||||
def public_state(values: Mapping[str, str]) -> dict[str, object]:
|
||||
try:
|
||||
url = normalize_url(values.get(KEY_URL, ""))
|
||||
entities = list(normalize_entities(values.get(KEY_ENTITIES, "")))
|
||||
error = ""
|
||||
except ConfigError as config_error:
|
||||
url = ""
|
||||
entities = []
|
||||
error = str(config_error)
|
||||
token_configured = bool(values.get(KEY_TOKEN, "").strip())
|
||||
return {
|
||||
"ok": error == "",
|
||||
"configured": bool(url and token_configured),
|
||||
"tokenConfigured": token_configured,
|
||||
"url": url,
|
||||
"entities": entities,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def render_updated(original: str, updates: Mapping[str, str]) -> str:
|
||||
lines = original.splitlines(keepends=True)
|
||||
rendered: list[str] = []
|
||||
replaced: set[str] = set()
|
||||
|
||||
for line in lines:
|
||||
content = line.rstrip("\r\n")
|
||||
ending = line[len(content) :]
|
||||
match = ASSIGNMENT.match(content)
|
||||
key = match.group("key") if match else ""
|
||||
if key not in updates:
|
||||
rendered.append(line)
|
||||
continue
|
||||
if key in replaced:
|
||||
continue
|
||||
rendered.append(f"export {key}={shlex.quote(updates[key])}{ending or os.linesep}")
|
||||
replaced.add(key)
|
||||
|
||||
if rendered and not rendered[-1].endswith(("\n", "\r")):
|
||||
rendered[-1] += os.linesep
|
||||
for key in TARGET_KEYS:
|
||||
if key in updates and key not in replaced:
|
||||
rendered.append(f"export {key}={shlex.quote(updates[key])}{os.linesep}")
|
||||
return "".join(rendered)
|
||||
|
||||
|
||||
def atomic_write(path: pathlib.Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(prefix=".env.", dir=path.parent)
|
||||
temporary = pathlib.Path(temporary_name)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||
stream.write(text)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, 0o600)
|
||||
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def read_payload() -> dict[str, Any]:
|
||||
line = sys.stdin.buffer.readline(1_048_577)
|
||||
if not line or len(line) > 1_048_576:
|
||||
raise ConfigError("invalid-payload")
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise ConfigError("invalid-payload") from error
|
||||
if not isinstance(payload, dict):
|
||||
raise ConfigError("invalid-payload")
|
||||
return payload
|
||||
|
||||
|
||||
def write_payload(path: pathlib.Path, payload: Mapping[str, Any]) -> dict[str, object]:
|
||||
allowed = {"url", "token", "entities"}
|
||||
if not set(payload).issubset(allowed) or not payload:
|
||||
raise ConfigError("invalid-payload")
|
||||
|
||||
updates: dict[str, str] = {}
|
||||
if "url" in payload:
|
||||
updates[KEY_URL] = normalize_url(payload["url"])
|
||||
if "token" in payload:
|
||||
updates[KEY_TOKEN] = normalize_token(payload["token"])
|
||||
if "entities" in payload:
|
||||
updates[KEY_ENTITIES] = ",".join(normalize_entities(payload["entities"]))
|
||||
|
||||
lock_path = path.with_name("." + path.name + ".lock")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
||||
try:
|
||||
os.fchmod(lock_fd, 0o600)
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
try:
|
||||
original = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
original = ""
|
||||
except OSError as error:
|
||||
raise ConfigError("read-failed") from error
|
||||
atomic_write(path, render_updated(original, updates))
|
||||
finally:
|
||||
os.close(lock_fd)
|
||||
|
||||
result = public_state(read_values(path))
|
||||
result["ok"] = True
|
||||
result["error"] = ""
|
||||
return result
|
||||
|
||||
|
||||
def compact_json(value: Mapping[str, object]) -> str:
|
||||
return json.dumps(value, separators=(",", ":"))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
command = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||
if len(sys.argv) > 2 or command not in {"status", "write"}:
|
||||
raise ConfigError("usage")
|
||||
path = env_path()
|
||||
result = public_state(read_values(path)) if command == "status" else write_payload(path, read_payload())
|
||||
print(compact_json(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except ConfigError as error:
|
||||
print(compact_json({"ok": False, "error": str(error)}))
|
||||
raise SystemExit(1) from error
|
||||
except OSError:
|
||||
print(compact_json({"ok": False, "error": "write-failed"}))
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,120 @@
|
||||
pragma Singleton
|
||||
|
||||
// Redacted Home Assistant configuration state. The helper is the only object
|
||||
// that touches the private env file; QML never receives the stored token.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-home-assistant-config"
|
||||
|
||||
property string url: ""
|
||||
property var entities: []
|
||||
property bool tokenConfigured: false
|
||||
property bool configured: false
|
||||
property string lastError: ""
|
||||
property string pendingPayload: ""
|
||||
readonly property bool busy: statusProc.running || writeProc.running
|
||||
|
||||
signal configurationSaved
|
||||
|
||||
function errorMessage(code: string): string {
|
||||
switch (code) {
|
||||
case "invalid-url": return "Enter an HTTP or HTTPS Home Assistant URL.";
|
||||
case "invalid-token": return "The access token contains unsupported characters.";
|
||||
case "invalid-entities": return "Entity IDs must look like light.living_room.";
|
||||
case "read-failed": return "The private configuration file could not be read.";
|
||||
case "write-failed": return "The private configuration file could not be saved.";
|
||||
case "invalid-payload": return "The configuration could not be validated.";
|
||||
default: return code ? "Home Assistant configuration is unavailable." : "";
|
||||
}
|
||||
}
|
||||
|
||||
function applyResult(text: string, saved: bool): void {
|
||||
let result = null;
|
||||
try {
|
||||
result = JSON.parse(text);
|
||||
} catch (error) {
|
||||
result = { ok: false, error: "invalid-response" };
|
||||
}
|
||||
if (result.ok !== true) {
|
||||
root.lastError = root.errorMessage(String(result.error || "invalid-response"));
|
||||
return;
|
||||
}
|
||||
root.url = String(result.url || "");
|
||||
root.entities = Array.isArray(result.entities) ? result.entities : [];
|
||||
root.tokenConfigured = result.tokenConfigured === true;
|
||||
root.configured = result.configured === true;
|
||||
root.lastError = "";
|
||||
if (saved) {
|
||||
root.configurationSaved();
|
||||
HomeAssistant.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (!statusProc.running && !writeProc.running)
|
||||
statusProc.running = true;
|
||||
}
|
||||
|
||||
// An empty token means "keep the stored token". Clearing is an explicit
|
||||
// separate action so editing the URL can never erase a secret by accident.
|
||||
function save(url: string, entitiesText: string, token: string): bool {
|
||||
if (root.busy)
|
||||
return false;
|
||||
const payload = { url: url, entities: entitiesText };
|
||||
if (token.trim() !== "")
|
||||
payload.token = token;
|
||||
return root.startWrite(payload);
|
||||
}
|
||||
|
||||
function clearToken(): bool {
|
||||
if (root.busy || !root.tokenConfigured)
|
||||
return false;
|
||||
return root.startWrite({ token: "" });
|
||||
}
|
||||
|
||||
function startWrite(payload: var): bool {
|
||||
root.lastError = "";
|
||||
root.pendingPayload = JSON.stringify(payload);
|
||||
writeProc.running = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: statusProc
|
||||
command: [root.helperPath, "status"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.applyResult(this.text, false)
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0 && root.lastError === "")
|
||||
root.lastError = "Home Assistant configuration could not be loaded.";
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: writeProc
|
||||
command: [root.helperPath, "write"]
|
||||
stdinEnabled: true
|
||||
stdout: StdioCollector {
|
||||
id: writeOutput
|
||||
onStreamFinished: root.applyResult(this.text, true)
|
||||
}
|
||||
onStarted: {
|
||||
writeProc.write(root.pendingPayload + "\n");
|
||||
root.pendingPayload = "";
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
root.pendingPayload = "";
|
||||
if (code !== 0 && root.lastError === "")
|
||||
root.lastError = "Home Assistant configuration could not be saved.";
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
Component.onCompleted: HomeAssistant.fixtureMode = true
|
||||
|
||||
IpcHandler {
|
||||
target: "home-assistant-config-test"
|
||||
|
||||
function save(url: string, entities: string): bool {
|
||||
return HomeAssistantConfig.save(url, entities, "");
|
||||
}
|
||||
|
||||
function clearToken(): bool {
|
||||
return HomeAssistantConfig.clearToken();
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
HomeAssistantConfig.refresh();
|
||||
}
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
url: HomeAssistantConfig.url,
|
||||
entities: HomeAssistantConfig.entities,
|
||||
tokenConfigured: HomeAssistantConfig.tokenConfigured,
|
||||
configured: HomeAssistantConfig.configured,
|
||||
busy: HomeAssistantConfig.busy,
|
||||
lastError: HomeAssistantConfig.lastError,
|
||||
pendingPayloadEmpty: HomeAssistantConfig.pendingPayload === ""
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-home-assistant-config"
|
||||
service="$repo_dir/config/dot/quickshell/services/HomeAssistantConfig.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml"
|
||||
harness_fixture="$repo_dir/tests/quickshell/HomeAssistantConfigHarness.qml"
|
||||
work="$(mktemp -d /tmp/panama-ha-config.XXXXXX)"
|
||||
env_file="$work/env"
|
||||
|
||||
fail() {
|
||||
printf 'Home Assistant config contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if declare -F qs_for_test >/dev/null; then
|
||||
qs_for_test kill >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$work"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
[[ -x "$helper" ]] || fail 'credential helper is missing or not executable'
|
||||
[[ -f "$service" ]] || fail 'credential service is missing'
|
||||
[[ -f "$harness_fixture" ]] || fail 'credential runtime harness is missing'
|
||||
|
||||
rg -Fq 'stdinEnabled: true' "$service" || fail 'credential writes do not use process stdin'
|
||||
rg -Fq 'writeProc.write(root.pendingPayload + "\n")' "$service" || fail 'credential payload is not written over stdin'
|
||||
rg -Fq 'root.pendingPayload = ""' "$service" || fail 'credential payload remains in service memory after write'
|
||||
if rg -q 'command:.*(token|pendingPayload)' "$service"; then
|
||||
fail 'credential data can reach a process command line'
|
||||
fi
|
||||
rg -Fq 'PasswordField {' "$page" || fail 'Home Assistant token is not entered through the masked field'
|
||||
rg -Fq 'HomeAssistantConfig.save(' "$page" || fail 'Home Assistant configuration cannot be saved from Settings'
|
||||
rg -Fq 'HomeAssistantConfig.clearToken()' "$page" || fail 'stored Home Assistant token cannot be cleared'
|
||||
|
||||
cat >"$env_file" <<'EOF'
|
||||
# Existing private shell settings must survive byte-for-byte.
|
||||
export KEEP_ME='untouched value'
|
||||
export JIRA_CREDENTIALS='unrelated-secret'
|
||||
export PANAMA_HOME_ASSISTANT_URL='https://old.example.test'
|
||||
export PANAMA_HOME_ASSISTANT_TOKEN='old-token'
|
||||
export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'
|
||||
EOF
|
||||
chmod 0644 "$env_file"
|
||||
|
||||
run_helper() {
|
||||
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" "$helper" "$@"
|
||||
}
|
||||
|
||||
status="$(run_helper status)" || fail 'status failed for a valid private env file'
|
||||
jq -e '.configured == true and .tokenConfigured == true
|
||||
and .url == "https://old.example.test"
|
||||
and .entities == ["light.old"] and (has("token") | not)' \
|
||||
<<<"$status" >/dev/null || fail "status exposed or misread credentials: $status"
|
||||
if rg -q 'old-token|unrelated-secret' <<<"$status"; then
|
||||
fail 'status output leaked a secret'
|
||||
fi
|
||||
|
||||
secret='ha-secret-must-never-appear-in-ps-or-output'
|
||||
payload="$work/payload.json"
|
||||
jq -cn --arg token "$secret" '{
|
||||
url: "https://home.example.test/",
|
||||
token: $token,
|
||||
entities: ["light.kitchen", "light.desk", "light.kitchen"]
|
||||
}' >"$payload"
|
||||
|
||||
# Keep stdin open long enough to prove the token is absent from the helper's
|
||||
# process arguments. The secret lives only in the private payload file/stdin.
|
||||
fifo="$work/input.fifo"
|
||||
mkfifo "$fifo"
|
||||
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" "$helper" write <"$fifo" >"$work/write.out" 2>"$work/write.err" &
|
||||
helper_pid=$!
|
||||
for _ in $(seq 1 30); do
|
||||
kill -0 "$helper_pid" 2>/dev/null && break
|
||||
sleep 0.05
|
||||
done
|
||||
if ps -o args= -p "$helper_pid" | rg -Fq "$secret"; then
|
||||
fail 'token appeared in the credential helper process arguments'
|
||||
fi
|
||||
cp "$payload" "$fifo"
|
||||
wait "$helper_pid" || fail 'stdin credential write failed'
|
||||
|
||||
write_result="$(cat "$work/write.out")"
|
||||
jq -e '.ok == true and .configured == true and .tokenConfigured == true
|
||||
and .url == "https://home.example.test"
|
||||
and .entities == ["light.kitchen", "light.desk"] and (has("token") | not)' \
|
||||
<<<"$write_result" >/dev/null || fail "write returned unsafe or incorrect state: $write_result"
|
||||
if rg -q "$secret|old-token|unrelated-secret" "$work/write.out" "$work/write.err"; then
|
||||
fail 'credential helper output leaked a secret'
|
||||
fi
|
||||
|
||||
[[ "$(stat -c '%a' "$env_file")" == "600" ]] || fail 'private env file is not mode 0600'
|
||||
rg -Fxq "export KEEP_ME='untouched value'" "$env_file" || fail 'unrelated env content changed'
|
||||
rg -Fxq "export JIRA_CREDENTIALS='unrelated-secret'" "$env_file" || fail 'unrelated secret changed'
|
||||
rg -Fq "$secret" "$env_file" || fail 'new token was not stored'
|
||||
|
||||
# Omitting token preserves it; an explicit empty token clears it.
|
||||
printf '%s\n' '{"url":"https://new.example.test","entities":"light.office, light.hall"}' \
|
||||
| run_helper write >/dev/null || fail 'non-secret update failed'
|
||||
rg -Fq "$secret" "$env_file" || fail 'blank token field unexpectedly erased the stored token'
|
||||
|
||||
printf '%s\n' '{"token":""}' | run_helper write >/dev/null || fail 'token clear failed'
|
||||
cleared="$(run_helper status)"
|
||||
jq -e '.configured == false and .tokenConfigured == false
|
||||
and .url == "https://new.example.test"
|
||||
and .entities == ["light.office", "light.hall"]' \
|
||||
<<<"$cleared" >/dev/null || fail "cleared state is wrong: $cleared"
|
||||
|
||||
before_hash="$(sha256sum "$env_file" | cut -d' ' -f1)"
|
||||
printf '%s\n' '{"url":"file:///etc/passwd"}' | run_helper write >/dev/null 2>&1 \
|
||||
&& fail 'invalid URL was accepted'
|
||||
after_hash="$(sha256sum "$env_file" | cut -d' ' -f1)"
|
||||
[[ "$before_hash" == "$after_hash" ]] || fail 'rejected input still modified the private env file'
|
||||
|
||||
# Exercise the actual QML Process.write() boundary with a pre-existing token.
|
||||
# The IPC carries only non-secret fields; the helper must preserve the token.
|
||||
config_path="$work/quickshell"
|
||||
harness="$config_path/home-assistant-config-harness.qml"
|
||||
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
|
||||
cp "$harness_fixture" "$harness"
|
||||
printf '%s\n' \
|
||||
"export PANAMA_HOME_ASSISTANT_URL='https://qml-old.example.test'" \
|
||||
"export PANAMA_HOME_ASSISTANT_TOKEN='qml-private-token'" \
|
||||
"export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'" >"$env_file"
|
||||
chmod 0600 "$env_file"
|
||||
|
||||
qs_for_test() {
|
||||
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" \
|
||||
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \
|
||||
qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
stop_harness() {
|
||||
qs_for_test kill >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
qs_for_test --daemonize >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
qs_for_test ipc show 2>/dev/null | rg -q '^target home-assistant-config-test$' && break
|
||||
sleep 0.1
|
||||
done
|
||||
qs_for_test ipc show 2>/dev/null | rg -q '^target home-assistant-config-test$' \
|
||||
|| fail 'credential QML harness did not start'
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
|
||||
jq -e '.busy == false and .url == "https://qml-old.example.test"' <<<"$qml_status" >/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.configured == true and .tokenConfigured == true and .pendingPayloadEmpty == true' \
|
||||
<<<"$qml_status" >/dev/null || fail "QML service did not load redacted state: $qml_status"
|
||||
|
||||
qs_for_test ipc call home-assistant-config-test save \
|
||||
https://qml-new.example.test 'light.office,light.hall' >/dev/null \
|
||||
|| fail 'QML service refused a non-secret update'
|
||||
for _ in $(seq 1 60); do
|
||||
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
|
||||
jq -e '.busy == false and .url == "https://qml-new.example.test"
|
||||
and .entities == ["light.office", "light.hall"]' <<<"$qml_status" >/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.configured == true and .tokenConfigured == true
|
||||
and .pendingPayloadEmpty == true and .lastError == ""' \
|
||||
<<<"$qml_status" >/dev/null || fail "QML stdin save did not settle safely: $qml_status"
|
||||
rg -Fq 'qml-private-token' "$env_file" || fail 'QML non-secret save erased the stored token'
|
||||
|
||||
qs_for_test ipc call home-assistant-config-test clearToken >/dev/null \
|
||||
|| fail 'QML service refused token clear'
|
||||
for _ in $(seq 1 60); do
|
||||
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
|
||||
jq -e '.busy == false and .tokenConfigured == false' <<<"$qml_status" >/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.configured == false and .tokenConfigured == false
|
||||
and .pendingPayloadEmpty == true and .lastError == ""' \
|
||||
<<<"$qml_status" >/dev/null || fail "QML token clear did not settle safely: $qml_status"
|
||||
if rg -Fq 'qml-private-token' "$env_file"; then
|
||||
fail 'QML token clear left the old token in the private env file'
|
||||
fi
|
||||
stop_harness
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'Home Assistant config contract: PASS\n'
|
||||
@@ -105,8 +105,8 @@ assert_contains 'Opens BlueBubbles' "$home_page"
|
||||
if rg -Fq 'index: model.index' "$home_page"; then
|
||||
fail 'HomePhonePage.qml reads an undefined model.index instead of the delegate index'
|
||||
fi
|
||||
if rg -qi 'token|bearer|api/states' "$home_page"; then
|
||||
fail 'HomePhonePage.qml crosses the credential or REST privacy boundary'
|
||||
if rg -qi 'bearer|api/states' "$home_page"; then
|
||||
fail 'HomePhonePage.qml crosses the Home Assistant REST boundary'
|
||||
fi
|
||||
assert_contains 'signal aliasCommitted(string id, string alias)' "$favorite_card"
|
||||
assert_contains 'signal removeRequested(string id)' "$favorite_card"
|
||||
|
||||
Reference in New Issue
Block a user