Skip to content
Merged
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
27 changes: 25 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ Canonical public repository: `https://github.com/konradk/hass`.

The plugin must remain installable without npm, pip, a virtual environment, or
first-run downloads. Python 3.11 or newer, `secret-tool`, and the vendored
`websockets` package are the runtime dependencies.
`websockets` package are the runtime dependencies. `nmcli` is an additional,
conditional one, invoked from two places: the bridge (only when a local
network URL is actually configured, to gate every connection attempt — see
the security invariant below) and the settings UI (each time it opens, to
suggest a value for the trusted-network field; read-only, never a security
decision). Its absence must degrade to "never use the local URL" for the
bridge and "no suggestion" for the UI, not an error either way.

## Architecture map

Expand Down Expand Up @@ -45,7 +51,24 @@ to `Service.qml`.
- Scope credentials to a normalized server origin. Changing origin must never
silently reuse a credential. Credential deletion must target an explicit
origin and must not remove a saved live credential as a side effect of demo
mode.
mode. The optional local-network URL (`localUrl`) is a deliberate, narrow
exception: it is an alternate address for the same instance the primary URL
already names, not a second server, so it intentionally shares the primary
origin's stored token rather than getting its own keyring entry. Do not add
separate credential storage for it, and do not let it participate in
`currentOrigin()`/`requiresTokenFor()` — those stay scoped to the primary
URL only.
- `localUrl` must never be tried unless `trustedNetwork` is set and one of its
comma-separated names matches the current Wi-Fi network name
(`current_wifi_ssid()` and `trusted_network_list()` in `bin/hass-bridge`,
checked fresh on every connection attempt — the list exists because a
router commonly broadcasts more than one SSID). This is the only thing standing
between an alternate address and sending the token to whatever happens to
answer there on a network the user never trusted — fail closed on every
path (no NetworkManager, an nmcli error or timeout, no active Wi-Fi, no
match) rather than defaulting to "trusted". Enforce this in both the bridge
and `Service.applyConnection` — the settings UI check is a fast-fail
convenience, not the security boundary.
- Treat `http://` and `ws://` as plaintext transport. Any UI path that permits
them must make the token-exposure risk explicit; never downgrade an invalid
or unknown scheme to plaintext.
Expand Down
12 changes: 10 additions & 2 deletions ConfigStore.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
.pragma library

var KEYS = [
"baseUrl", "demoMode", "favorites", "demoFavorites", "groupByArea",
"showEntityIcons", "selectedTab", "displayNameOverrides", "iconOverrides"
"baseUrl", "localUrl", "trustedNetwork", "demoMode", "favorites",
"demoFavorites", "groupByArea", "showEntityIcons", "selectedTab",
"displayNameOverrides", "iconOverrides"
]

function stringList(value, fallback) {
Expand Down Expand Up @@ -48,6 +49,13 @@ function parse(text, demoDefaults) {
error: error,
config: {
baseUrl: typeof raw.baseUrl === "string" ? raw.baseUrl : "",
// Optional. An alternate address for the same Home Assistant instance
// — a LAN address, say — tried first, but only on trustedNetwork. It
// shares baseUrl's credential; it is never a separate keyring origin.
localUrl: typeof raw.localUrl === "string" ? raw.localUrl : "",
// The Wi-Fi network name localUrl requires a match against before it is
// ever tried. See bin/hass-bridge's current_wifi_ssid.
trustedNetwork: typeof raw.trustedNetwork === "string" ? raw.trustedNetwork : "",
demoMode: raw.demoMode === true,
favorites: stringList(raw.favorites, []),
demoFavorites: stringList(raw.demoFavorites,
Expand Down
48 changes: 46 additions & 2 deletions Connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,38 @@
// outside Service.qml makes the security boundary testable with Node as well
// as usable by the QML service.

// Mirrors bin/hass-bridge's Bridge.trusted_network_list: a comma-separated
// list of Wi-Fi network names, since a router commonly broadcasts more than
// one (separate 2.4GHz/5GHz SSIDs). Kept here so the settings UI's notion of
// "is a trusted network actually configured" cannot drift from the bridge's
// — a field containing only commas or whitespace must count as empty in both.
function trustedNetworkList(value) {
return String(value || "").split(",")
.map(function(name) { return name.trim() })
.filter(function(name) { return name.length > 0 })
}

// Mirrors bin/hass-bridge's current_wifi_ssid line parsing: nmcli -t's terse
// output is "active:ssid" per line, with a literal ':' inside a field escaped
// as '\:'. Settings.qml uses this only to suggest a value for the trusted
// network field — the bridge is the actual security boundary and re-checks
// the current network independently in Python before ever using a local URL.
function parseNmcliActiveSsid(text) {
var lines = String(text || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i]
var splitAt = -1
for (var c = 0; c < line.length; c++) {
if (line[c] === ":" && line[c - 1] !== "\\") { splitAt = c; break }
}
if (splitAt === -1) continue
if (line.slice(0, splitAt) !== "yes") continue
var ssid = line.slice(splitAt + 1).replace(/\\:/g, ":").replace(/\\\\/g, "\\")
if (ssid) return ssid
}
return ""
}

function preparedUrl(value) {
var text = String(value || "").trim()
if (!text) return ""
Expand Down Expand Up @@ -76,10 +108,22 @@ function normalizeOrigin(value) {
//
// Empty when the URL cannot be normalized; callers treat that as invalid
// rather than as a connection worth starting.
function signature(demoMode, value) {
//
// localValue is optional: an alternate address for the same instance (a LAN
// address, only ever used on trustedNetwork — see bin/hass-bridge). It shares
// value's credential origin, so both are folded into the signature only to
// restart the bridge when either changes — neither contributes an origin of
// its own.
function signature(demoMode, value, localValue, trustedNetwork) {
if (demoMode) return "demo"
var origin = normalizeOrigin(value)
return origin ? origin + "|" + String(value || "").trim() : ""
if (!origin) return ""
var text = origin + "|" + String(value || "").trim()
var local = String(localValue || "").trim()
if (local) text += "|" + local
var trust = String(trustedNetwork || "").trim()
if (trust) text += "|" + trust
return text
}

function acceptsGeneration(activeGeneration, eventGeneration) {
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ omarchy-shell hass devices # device picker
- Omarchy 4 (`schemaVersion: 1` plugin API)
- Python 3.11 or newer
- `secret-tool` (libsecret) with a running keyring daemon
- `nmcli` (NetworkManager), only if you use a local network URL

The pure-Python runtime of `websockets` 17.0.1 is bundled with the plugin and
loaded from `vendor/`. Users don't need `python-websockets`, `qt6-websockets`,
Expand Down Expand Up @@ -86,6 +87,19 @@ your profile → Security), or flip on **Demo mode** to try the panel against a
built-in fake house with no instance at all. Then switch to **Devices** and
star the ones you want in the panel.

Optionally, turn on **Local network URL** to add your instance's LAN address.
It's the same Home Assistant instance reached by a different address, so it
reuses the one access token above rather than needing its own. It also asks
for the name of your trusted Wi-Fi network (comma-separate more than one, for
example if your router has separate 2.4GHz/5GHz names): the local URL is only
ever tried while connected to one of those, and the URL above is used
everywhere else. If that field is empty, it'll offer the network you're
currently on as a one-click suggestion.
This matters because the local URL is plaintext-friendly on the assumption
that your home network is trustworthy — without the network-name check, a
laptop that later joins some other Wi-Fi with something answering on that
same address would send it your token.

## Debugging

```bash
Expand Down
51 changes: 46 additions & 5 deletions Service.qml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ QtObject {
property bool configured: false
property bool demoMode: false
property string baseUrl: ""
// Optional alternate address for the same instance — a LAN address, say —
// the bridge tries first, but only on trustedNetwork. Shares baseUrl's
// credential; never its own keyring origin. See Connection.signature and
// CredentialManager.
property string localUrl: ""
// The Wi-Fi network name localUrl requires a match against before the
// bridge will ever try it. See bin/hass-bridge's current_wifi_ssid.
property string trustedNetwork: ""
// True only while connected through localUrl rather than baseUrl.
property bool usingLocal: false
property int connectionGeneration: 0
property bool connectionSuppressed: false

Expand Down Expand Up @@ -82,6 +92,8 @@ QtObject {
function currentConfig() {
return {
baseUrl: root.baseUrl,
localUrl: root.localUrl,
trustedNetwork: root.trustedNetwork,
demoMode: root.demoMode,
favorites: root.liveFavorites.slice(),
demoFavorites: root.demoFavorites.slice(),
Expand Down Expand Up @@ -197,7 +209,7 @@ QtObject {
function finishRemoveConnection() {
root.connectionSuppressed = false
root.saveConfig({
baseUrl: "", demoMode: false, favorites: [],
baseUrl: "", localUrl: "", trustedNetwork: "", demoMode: false, favorites: [],
displayNameOverrides: {}, iconOverrides: {}, selectedTab: "favorites"
}) // demoFavorites untouched: not part of the connection
}
Expand Down Expand Up @@ -230,27 +242,50 @@ QtObject {
root.reconcileConnection()
}

function applyConnection(url, token, demo) {
function applyConnection(url, localUrl, trustedNetwork, token, demo) {
var origin = demo ? "demo" : Connection.normalizeOrigin(url)
if (!origin) {
root.phase = "error"
root.lastError = "Enter a valid http(s) or ws(s) Home Assistant URL."
return false
}
// Optional, and validated the same way, but blank is always fine — it
// just means no local fallback.
var trimmedLocal = String(localUrl || "").trim()
if (!demo && trimmedLocal && !Connection.normalizeOrigin(trimmedLocal)) {
root.phase = "error"
root.lastError = "Enter a valid http(s) or ws(s) local network URL, or leave it blank."
return false
}
// A local URL with no trusted network to gate it would otherwise be tried
// on every Wi-Fi the laptop ever joins, sending the token to whatever
// happens to answer at that address. The bridge enforces this too — this
// check exists to fail fast with a clear message instead of a silently
// inert field.
var trimmedTrust = String(trustedNetwork || "").trim()
if (!demo && trimmedLocal && Connection.trustedNetworkList(trimmedTrust).length === 0) {
root.phase = "error"
root.lastError = "Enter at least one trusted Wi-Fi network name for the local URL, or leave the local URL blank."
return false
}
if (!demo && !token && root.requiresTokenFor(url)) {
root.phase = "error"
root.lastError = "A new Home Assistant origin requires a new token."
return false
}
root.connectionSuppressed = false
// Start the serialized write before applyConfig runs so reconciliation
// cannot race a lookup of the previous credential.
// cannot race a lookup of the previous credential. The local URL is never
// its own keyring origin: it shares whatever is stored for `origin`.
if (!demo && token.length > 0 && !credentials.store(token, origin)) {
root.phase = "error"
root.lastError = "Could not start token storage while the keyring is busy."
return false
}
root.saveConfig({ baseUrl: url, demoMode: demo })
root.saveConfig({
baseUrl: url, localUrl: demo ? "" : trimmedLocal,
trustedNetwork: demo ? "" : trimmedTrust, demoMode: demo
})
return true
}

Expand All @@ -275,6 +310,8 @@ QtObject {

root.demoMode = config.demoMode
root.baseUrl = config.baseUrl
root.localUrl = config.localUrl
root.trustedNetwork = config.trustedNetwork
root.liveFavorites = config.favorites
root.demoFavorites = config.demoFavorites
root.displayNameOverrides = config.displayNameOverrides
Expand Down Expand Up @@ -328,7 +365,8 @@ QtObject {

// Connection.js owns this rule, so the definition of "same connection"
// cannot drift from the one the tests pin.
var signature = Connection.signature(root.demoMode, root.baseUrl)
var signature = Connection.signature(
root.demoMode, root.baseUrl, root.localUrl, root.trustedNetwork)
if (!signature) {
root.phase = "error"
root.lastError = "Home Assistant URL is invalid."
Expand Down Expand Up @@ -406,6 +444,8 @@ QtObject {
root.send({
op: "config",
url: root.baseUrl,
localUrl: root.localUrl,
trustedNetwork: root.trustedNetwork,
token: token,
generation: root.connectionGeneration
})
Expand Down Expand Up @@ -654,6 +694,7 @@ QtObject {
root.phase = transition.state.phase
root.lastError = transition.state.error
root.lastErrorKind = transition.state.errorKind
root.usingLocal = transition.state.phase === "connected" && event.usingLocal === true
break
case "states":
root.applyStates(event.entities || [])
Expand Down
Loading
Loading