diff --git a/AGENTS.md b/AGENTS.md index 2bc620c..beacb59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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. diff --git a/ConfigStore.js b/ConfigStore.js index e1d2d2d..4b10e1b 100644 --- a/ConfigStore.js +++ b/ConfigStore.js @@ -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) { @@ -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, diff --git a/Connection.js b/Connection.js index fcd02aa..e1d7bf5 100644 --- a/Connection.js +++ b/Connection.js @@ -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 "" @@ -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) { diff --git a/README.md b/README.md index 0ca1485..a790e0b 100644 --- a/README.md +++ b/README.md @@ -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`, @@ -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 diff --git a/Service.qml b/Service.qml index e311835..e525b55 100644 --- a/Service.qml +++ b/Service.qml @@ -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 @@ -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(), @@ -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 } @@ -230,13 +242,32 @@ 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." @@ -244,13 +275,17 @@ QtObject { } 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 } @@ -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 @@ -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." @@ -406,6 +444,8 @@ QtObject { root.send({ op: "config", url: root.baseUrl, + localUrl: root.localUrl, + trustedNetwork: root.trustedNetwork, token: token, generation: root.connectionGeneration }) @@ -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 || []) diff --git a/Settings.qml b/Settings.qml index db1ca44..8d209ef 100644 --- a/Settings.qml +++ b/Settings.qml @@ -1,4 +1,5 @@ import Quickshell +import Quickshell.Io import Quickshell.Wayland import QtQuick import QtQuick.Controls @@ -25,8 +26,36 @@ Item { // Local until Connect, so a half-typed URL never reaches the bridge. property string urlDraft: "" + property string localUrlDraft: "" + // Required whenever localUrlDraft is non-empty: the local URL is only ever + // tried on this Wi-Fi network. See bin/hass-bridge's current_wifi_ssid. + property string trustedNetworkDraft: "" + // Collapsed unless a local URL is already saved: most people never need + // this field, and a second always-visible URL box with its own warning text + // outweighs the value of surfacing it up front. + property bool localUrlExpanded: false + // Suggestion only, for the trusted-network field — read-only, not used for + // anything security-relevant. The bridge determines the actual network + // independently in Python before ever using a local URL. + property string detectedWifiSsid: "" property string tokenDraft: "" + Process { + id: wifiSsidProbe + // --rescan no: only what NetworkManager already knows about the active + // connection is needed here, not a fresh scan of every nearby network — + // that forces nmcli to block for several seconds instead of returning + // near-instantly. + command: ["nmcli", "-t", "-f", "active,ssid", "dev", "wifi", "list", + "--rescan", "no"] + stdout: SplitParser { + onRead: function(value) { + var ssid = Connection.parseNmcliActiveSsid(value) + if (ssid) root.detectedWifiSsid = ssid + } + } + } + property string query: "" // Debounced: a burst of keystrokes costs one pass over the entities. property string appliedQuery: "" @@ -71,6 +100,11 @@ Item { } catch (e) { // Not worth refusing to open over. } + // Fresh on every open, not just the first: the answer can change between + // sessions, and a stale one from an old network would suggest the wrong + // name. + root.detectedWifiSsid = "" + if (!wifiSsidProbe.running) wifiSsidProbe.running = true Qt.callLater(function() { keyCatcher.forceActiveFocus() }) } @@ -88,6 +122,9 @@ Item { function resetDrafts() { if (!service) return root.urlDraft = service.baseUrl + root.localUrlDraft = service.localUrl + root.trustedNetworkDraft = service.trustedNetwork + root.localUrlExpanded = service.localUrl.length > 0 // The stored token never comes back to screen; blank means "keep it". root.tokenDraft = "" root.query = "" @@ -100,7 +137,8 @@ Item { function applyConnection() { if (!service) return - if (service.applyConnection(root.urlDraft.trim(), root.tokenDraft, false)) { + if (service.applyConnection(root.urlDraft.trim(), root.localUrlDraft.trim(), + root.trustedNetworkDraft.trim(), root.tokenDraft, false)) { root.tokenDraft = "" } } @@ -248,175 +286,302 @@ Item { readonly property bool needsToken: root.service ? root.service.requiresTokenFor(root.urlDraft.trim()) : true readonly property bool validUrl: Connection.normalizeOrigin(root.urlDraft) !== "" - readonly property bool canConnect: validUrl && !keyringBusy - && (!needsToken || root.tokenDraft.length > 0) - - Column { - id: connectionColumn - anchors { top: parent.top; left: parent.left; right: parent.right } - spacing: Style.spacing.xxxl + // Blank is fine — it just means no local fallback. + readonly property bool validLocalUrl: root.localUrlDraft.trim().length === 0 + || Connection.normalizeOrigin(root.localUrlDraft) !== "" + // A local URL with no trusted network to gate it would be tried on + // every Wi-Fi the laptop joins. + readonly property bool localUrlNeedsTrust: root.localUrlDraft.trim().length > 0 + && Connection.trustedNetworkList(root.trustedNetworkDraft).length === 0 + readonly property bool canConnect: validUrl && validLocalUrl && !localUrlNeedsTrust + && !keyringBusy && (!needsToken || root.tokenDraft.length > 0) + + // Flickable rather than anchoring the column straight to the card: the + // card's height is fixed (see card.preferredHeight above), and this + // form has grown past it before — the local URL toggle alone added + // enough fields to spill content out past the card's border with + // nothing to contain it. A Column that can outgrow its container has to + // scroll, not just hope it never does. + Flickable { + id: connectionFlick + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: connectionColumn.height + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } Column { - width: connectionColumn.width - spacing: Style.spacing.sm + id: connectionColumn + width: connectionFlick.width + spacing: Style.spacing.xxxl - Text { - textFormat: Text.PlainText - text: "Home Assistant URL" - color: Color.muted - font.family: root.family - font.pixelSize: Style.font.bodySmall - } - - TextField { + Column { width: connectionColumn.width - text: root.urlDraft - placeholderText: "https://homeassistant.local:8123" - onTextChanged: root.urlDraft = text + spacing: Style.spacing.sm + + Text { + textFormat: Text.PlainText + text: "Home Assistant URL" + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.bodySmall + } + + TextField { + width: connectionColumn.width + text: root.urlDraft + placeholderText: "https://homeassistant.local:8123" + onTextChanged: root.urlDraft = text + } + + Text { + textFormat: Text.PlainText + width: connectionColumn.width + visible: root.urlDraft.trim().toLowerCase().indexOf("http://") === 0 + || root.urlDraft.trim().toLowerCase().indexOf("ws://") === 0 + text: "Warning: this URL sends your long-lived access token without transport encryption. Use HTTPS unless this is a trusted local network." + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } } - Text { - textFormat: Text.PlainText + Column { width: connectionColumn.width - visible: root.urlDraft.trim().toLowerCase().indexOf("http://") === 0 - || root.urlDraft.trim().toLowerCase().indexOf("ws://") === 0 - text: "Warning: this URL sends your long-lived access token without transport encryption. Use HTTPS unless this is a trusted local network." - color: Color.muted - font.family: root.family - font.pixelSize: Style.font.caption - wrapMode: Text.WordWrap - } - } + spacing: Style.spacing.sm + + // Ui/Toggle: label + description + switch, row owns the click. + Toggle { + width: connectionColumn.width + label: "Local network URL" + description: "Try a LAN address first, e.g. your instance's local IP, before falling back to the URL above. Uses the same access token." + checked: root.localUrlExpanded + foreground: root.foreground + fontFamily: root.family + onClicked: { + root.localUrlExpanded = !root.localUrlExpanded + // Collapsing means "no local URL" — a hidden stale draft would + // otherwise still reach applyConnection. + if (!root.localUrlExpanded) { + root.localUrlDraft = "" + root.trustedNetworkDraft = "" + } + } + } - Column { - width: connectionColumn.width - spacing: Style.spacing.sm + TextField { + visible: root.localUrlExpanded + width: connectionColumn.width + text: root.localUrlDraft + placeholderText: "https://192.168.1.50:8123" + onTextChanged: root.localUrlDraft = text + } - Text { - textFormat: Text.PlainText - text: root.service && root.service.configured && !root.service.demoMode - ? "Access token · leave blank to keep the stored one" - : "Long-lived access token" - color: Color.muted - font.family: root.family - font.pixelSize: Style.font.bodySmall - } + Text { + textFormat: Text.PlainText + width: connectionColumn.width + visible: root.localUrlExpanded + && (root.localUrlDraft.trim().toLowerCase().indexOf("http://") === 0 + || root.localUrlDraft.trim().toLowerCase().indexOf("ws://") === 0) + text: "Warning: this URL sends your long-lived access token without transport encryption. Use HTTPS unless this is a trusted local network." + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } - TextField { - width: connectionColumn.width - text: root.tokenDraft - password: true - placeholderText: "Paste from your Home Assistant profile" - onTextChanged: root.tokenDraft = text + Text { + textFormat: Text.PlainText + visible: root.localUrlExpanded + text: "Trusted Wi-Fi network name(s)" + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.bodySmall + } + + TextField { + visible: root.localUrlExpanded + width: connectionColumn.width + text: root.trustedNetworkDraft + placeholderText: "Home, Home 5G" + onTextChanged: root.trustedNetworkDraft = text + } + + // A suggestion, not an autofill: it only offers the network this + // machine happens to be on right now, so it disappears the moment + // there's anything to lose by acting on it — a name typed in, or + // no detectable Wi-Fi at all. + Row { + visible: root.localUrlExpanded + && root.trustedNetworkDraft.trim().length === 0 + && root.detectedWifiSsid.length > 0 + spacing: Style.spacing.sm + + Text { + textFormat: Text.PlainText + anchors.verticalCenter: parent.verticalCenter + text: "Currently on “" + root.detectedWifiSsid + "”." + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.caption + } + + Button { + anchors.verticalCenter: parent.verticalCenter + bordered: true + text: "Use this" + foreground: root.foreground + fontFamily: root.family + onClicked: root.trustedNetworkDraft = root.detectedWifiSsid + } + } + + Text { + textFormat: Text.PlainText + width: connectionColumn.width + visible: root.localUrlExpanded + text: "Required. Comma-separated if your router has more than one (e.g. separate 2.4GHz/5GHz names). The local URL is only ever tried while connected to one of these — never on any other network, so the token can't be sent to whatever happens to answer at that address elsewhere." + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } } - Text { - textFormat: Text.PlainText + Column { width: connectionColumn.width - visible: connectionPane.needsToken && root.urlDraft.trim().length > 0 - text: "Changing the server origin requires entering its token again." - color: Color.muted - font.family: root.family - font.pixelSize: Style.font.caption - wrapMode: Text.WordWrap - } - } + spacing: Style.spacing.sm + + Text { + textFormat: Text.PlainText + text: root.service && root.service.configured && !root.service.demoMode + ? "Access token · leave blank to keep the stored one" + : "Long-lived access token" + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.bodySmall + } - Row { - spacing: Style.spacing.xl + TextField { + width: connectionColumn.width + text: root.tokenDraft + password: true + placeholderText: "Paste from your Home Assistant profile" + onTextChanged: root.tokenDraft = text + } - Button { - bordered: true // Ui/Button is flat otherwise, reading as a label - text: "Connect" - opacity: connectionPane.canConnect ? 1.0 : 0.45 - foreground: root.foreground - fontFamily: root.family - onClicked: if (connectionPane.canConnect) root.applyConnection() + Text { + textFormat: Text.PlainText + width: connectionColumn.width + visible: connectionPane.needsToken && root.urlDraft.trim().length > 0 + text: "Changing the server origin requires entering its token again." + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } } - Button { - visible: connectionPane.trying - bordered: true - text: "Cancel" - foreground: root.foreground - fontFamily: root.family - onClicked: root.service.cancelConnection() - } + Row { + spacing: Style.spacing.xl - Button { - visible: connectionPane.paused - bordered: true - text: "Retry" - foreground: root.foreground - fontFamily: root.family - onClicked: root.service.retryConnection() - } + Button { + bordered: true // Ui/Button is flat otherwise, reading as a label + text: "Connect" + opacity: connectionPane.canConnect ? 1.0 : 0.45 + foreground: root.foreground + fontFamily: root.family + onClicked: if (connectionPane.canConnect) root.applyConnection() + } - Button { - bordered: true - text: "Remove" - opacity: (root.service && root.service.configured - && !connectionPane.keyringBusy) ? 1.0 : 0.45 - foreground: root.foreground - fontFamily: root.family - onClicked: { - if (!root.service || !root.service.configured - || connectionPane.keyringBusy) return - root.service.removeConnection() - root.resetDrafts() + Button { + visible: connectionPane.trying + bordered: true + text: "Cancel" + foreground: root.foreground + fontFamily: root.family + onClicked: root.service.cancelConnection() } - } - } - PanelSeparator { width: connectionColumn.width } - - // Ui/Toggle: label + description + switch, row owns the click. - Toggle { - width: connectionColumn.width - label: "Demo mode" - description: "A fake house, so you can try the panel without an instance." - checked: root.service ? root.service.demoMode : false - foreground: root.foreground - fontFamily: root.family - onClicked: if (root.service) root.service.setDemoMode(!root.service.demoMode) - } + Button { + visible: connectionPane.paused + bordered: true + text: "Retry" + foreground: root.foreground + fontFamily: root.family + onClicked: root.service.retryConnection() + } - // From the live connection, not a probe: a probe on a different path - // can pass while the real connection fails. - Row { - spacing: Style.spacing.lg + Button { + bordered: true + text: "Remove" + opacity: (root.service && root.service.configured + && !connectionPane.keyringBusy) ? 1.0 : 0.45 + foreground: root.foreground + fontFamily: root.family + onClicked: { + if (!root.service || !root.service.configured + || connectionPane.keyringBusy) return + root.service.removeConnection() + root.resetDrafts() + } + } + } - Rectangle { - anchors.verticalCenter: parent.verticalCenter - width: Style.space(8); height: width; radius: width / 2 - color: !root.service ? Color.muted - : root.service.phase === "connected" ? "#4caf50" - : root.service.phase === "error" ? Color.urgent - : Color.muted + PanelSeparator { width: connectionColumn.width } + + // Ui/Toggle: label + description + switch, row owns the click. + Toggle { + width: connectionColumn.width + label: "Demo mode" + description: "A fake house, so you can try the panel without an instance." + checked: root.service ? root.service.demoMode : false + foreground: root.foreground + fontFamily: root.family + onClicked: if (root.service) root.service.setDemoMode(!root.service.demoMode) } - Text { - textFormat: Text.PlainText - anchors.verticalCenter: parent.verticalCenter - width: connectionColumn.width - Style.space(24) - text: { - if (!root.service) return "Service unavailable" - if (!root.service.configured) return "Not connected" - switch (root.service.phase) { - case "connected": - return (root.service.demoMode ? "Demo running · " : "Connected · ") - + Object.keys(root.service.states).length + " devices" - case "connecting": return root.service.lastError - ? "Connecting… · " + root.service.lastError - : "Connecting…" - case "error": return root.service.lastError || "Connection failed" - default: return root.service.lastError || "Idle" + // From the live connection, not a probe: a probe on a different path + // can pass while the real connection fails. + Row { + spacing: Style.spacing.lg + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: Style.space(8); height: width; radius: width / 2 + color: !root.service ? Color.muted + : root.service.phase === "connected" ? "#4caf50" + : root.service.phase === "error" ? Color.urgent + : Color.muted + } + + Text { + textFormat: Text.PlainText + anchors.verticalCenter: parent.verticalCenter + width: connectionColumn.width - Style.space(24) + text: { + if (!root.service) return "Service unavailable" + if (!root.service.configured) return "Not connected" + switch (root.service.phase) { + case "connected": + return (root.service.demoMode ? "Demo running · " + : root.service.usingLocal ? "Connected (local network) · " + : "Connected · ") + + Object.keys(root.service.states).length + " devices" + case "connecting": return root.service.lastError + ? "Connecting… · " + root.service.lastError + : "Connecting…" + case "error": return root.service.lastError || "Connection failed" + default: return root.service.lastError || "Idle" + } } + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap } - color: Color.muted - font.family: root.family - font.pixelSize: Style.font.bodySmall - wrapMode: Text.WordWrap } } } diff --git a/bin/hass-bridge b/bin/hass-bridge index ccfeb7a..3563c3e 100755 --- a/bin/hass-bridge +++ b/bin/hass-bridge @@ -12,10 +12,21 @@ writes commands on our stdin and reads events from our stdout. Commands (stdin) {"protocolVersion":1,"op":"config","generation":7, - "url":"https://ha.local:8123","token":"..."} + "url":"https://ha.local:8123","localUrl":"https://192.168.1.50:8123", + "trustedNetwork":"Home,Home 5G","token":"..."} Connect, or reconnect with new credentials. The token arrives here rather than in argv because /proc//cmdline is readable by every - process running as this user. + process running as this user. localUrl is optional: an alternate + address for the same instance (a LAN address, say). It is only ever + tried, ahead of url and with a short timeout, when trustedNetwork is + set and one of its comma-separated names matches the current Wi-Fi + network name (checked via nmcli — a router commonly broadcasts more + than one SSID, e.g. separate 2.4GHz/5GHz names, hence the list). + Otherwise every attempt goes straight to url. This is what stops the + token from being sent to whatever happens to answer at that address + on an untrusted network — localUrl shares url's token rather than + needing its own, so this check is the only thing standing between it + and an unwanted disclosure. {"op":"call_service","domain":"light","service":"turn_on", "entity_id":"light.x","data":{"brightness_pct":40},"tag":"..."} {"op":"refresh"} re-request the full state snapshot @@ -25,7 +36,9 @@ Commands (stdin) Events (stdout) Every event includes protocolVersion and generation. {"ev":"phase","phase":"idle|connecting|connected|error", - "error":"...","errorKind":"connection|synchronization|credential|protocol"} + "error":"...","errorKind":"connection|synchronization|credential|protocol", + "usingLocal":true} usingLocal is present and true only when phase is + "connected" and the connection went through localUrl {"ev":"states","entities":[...]} full snapshot, replaces the model {"ev":"state_changed","entity":{...}} single entity changed {"ev":"removed","entity_id":"..."} entity disappeared @@ -47,8 +60,10 @@ import json import os import queue import random +import re import socket import ssl +import subprocess import sys import threading import time @@ -104,6 +119,63 @@ PROTOCOL_VERSION = 1 BACKOFF_START = 1.0 BACKOFF_MAX = 30.0 +# How long a local-network candidate gets before falling back to the primary +# URL. Short: the whole point is that a laptop off the home network should not +# sit there waiting out the normal OPEN_TIMEOUT before trying the URL that can +# actually answer. A closed port refuses well inside this anyway; the timeout +# only matters for an address nothing answers at all. +LOCAL_CONNECT_TIMEOUT = 2.0 + +# Bound on the nmcli call in current_wifi_ssid(), so a hung network stack +# cannot wedge the bridge's single-threaded run loop. +NETWORK_CHECK_TIMEOUT = 2.0 + + +def current_wifi_ssid(): + """Best-effort current Wi-Fi network name, or None. + + None whenever this cannot be determined with confidence — no + NetworkManager, no active Wi-Fi, a timeout, unparseable output — not just + when nothing matches. The local URL has to fail closed here: this is the + only thing standing between it and sending a token to whatever happens to + answer at that address on a network that was never trusted. + """ + # Test-only seam. Real installs never set this; nothing in this plugin + # sets it either. It exists because tests/test_bridge.py drives the + # bridge as a real subprocess with no way to patch this function + # directly, and CI has no controllable Wi-Fi to join. + override = os.environ.get("HASS_BRIDGE_TEST_SSID") + if override is not None: + return override or None + + try: + result = subprocess.run( + # --rescan no: this only needs whatever NetworkManager already + # knows about the currently active connection, not a fresh scan + # of every nearby network. Without it, nmcli blocks on a full + # radio scan — several seconds, during which this call would + # freeze the bridge's single-threaded run loop. + ["nmcli", "-t", "-f", "active,ssid", "dev", "wifi", "list", + "--rescan", "no"], + capture_output=True, text=True, timeout=NETWORK_CHECK_TIMEOUT) + except Exception: + return None + if result.returncode != 0: + return None + + for line in result.stdout.splitlines(): + # nmcli's terse output escapes a literal ':' inside a field as '\:', + # so only an unescaped colon is a field separator. + fields = re.split(r"(?= 1, + local.connections) + finally: + bridge.stop() + local.stop() + + +def test_falls_back_to_primary_when_local_is_unreachable(): + print("local: an unreachable local URL falls back to the primary URL") + server = FakeHA() + bridge = BridgeProc(env={"HASS_BRIDGE_TEST_SSID": "Home"}) + try: + bridge.send({"op": "config", "url": server.url, + "localUrl": "http://127.0.0.1:1", "trustedNetwork": "Home", + "token": "tok"}) + connected = bridge.wait_for( + lambda e: e["ev"] == "phase" and e["phase"] == "connected") + check("reaches connected", connected is not None) + check("does not flag the connection as local", + connected is not None and connected.get("usingLocal") is False, + connected) + check("the primary server saw the connection", server.connections >= 1, + server.connections) + finally: + bridge.stop() + server.stop() + + +def test_unparseable_local_url_does_not_block_the_primary(): + print("local: a bad local URL is skipped, not fatal") + # The local URL is optional; a typo there must not stop the primary URL + # — the one the user actually confirmed — from being tried at all. + server = FakeHA() + bridge = BridgeProc(env={"HASS_BRIDGE_TEST_SSID": "Home"}) + try: + bridge.send({"op": "config", "url": server.url, + "localUrl": "https://192.168.1.50:8123]", + "trustedNetwork": "Home", "token": "tok"}) + connected = bridge.wait_for( + lambda e: e["ev"] == "phase" and e["phase"] == "connected") + check("still reaches connected via the primary URL", connected is not None) + check("does not flag the connection as local", + connected is not None and connected.get("usingLocal") is False, + connected) + finally: + bridge.stop() + server.stop() + + +def test_local_url_matches_any_of_several_trusted_networks(): + print("local: trustedNetwork accepts a comma-separated list") + # A router commonly broadcasts more than one SSID (separate 2.4GHz/5GHz + # names); the current network only has to match one of the list. + local = FakeHA() + bridge = BridgeProc(env={"HASS_BRIDGE_TEST_SSID": "Home 5G"}) + try: + bridge.send({"op": "config", "url": "http://127.0.0.1:1", + "localUrl": local.url, "trustedNetwork": "Home, Home 5G", + "token": "tok"}) + connected = bridge.wait_for( + lambda e: e["ev"] == "phase" and e["phase"] == "connected") + check("reaches connected", connected is not None) + check("flags the connection as local", + connected is not None and connected.get("usingLocal") is True, + connected) + finally: + bridge.stop() + local.stop() + + +def test_local_url_is_skipped_off_the_trusted_network(): + print("local: a reachable local URL is not used off the trusted network") + # This is the actual security property: a local URL must never be tried + # just because it's configured. Home is what's saved; the bridge reports + # being on CoffeeShop instead, so the local server must never be dialed + # even though it would happily answer. + local = FakeHA() + server = FakeHA() + bridge = BridgeProc(env={"HASS_BRIDGE_TEST_SSID": "CoffeeShop"}) + try: + bridge.send({"op": "config", "url": server.url, "localUrl": local.url, + "trustedNetwork": "Home", "token": "tok"}) + connected = bridge.wait_for( + lambda e: e["ev"] == "phase" and e["phase"] == "connected") + check("reaches connected via the primary URL", connected is not None) + check("does not flag the connection as local", + connected is not None and connected.get("usingLocal") is False, + connected) + check("the local server was never contacted", local.connections == 0, + local.connections) + finally: + bridge.stop() + local.stop() + server.stop() + + +def test_local_url_without_a_trusted_network_is_never_used(): + print("local: a local URL with no trusted network is never tried") + # Defense in depth: the settings UI and Service.applyConnection both + # refuse to save this combination, but the bridge must not rely on that. + local = FakeHA() + server = FakeHA() + bridge = BridgeProc(env={"HASS_BRIDGE_TEST_SSID": "Home"}) + try: + bridge.send({"op": "config", "url": server.url, "localUrl": local.url, + "token": "tok"}) + connected = bridge.wait_for( + lambda e: e["ev"] == "phase" and e["phase"] == "connected") + check("reaches connected via the primary URL", connected is not None) + check("the local server was never contacted", local.connections == 0, + local.connections) + finally: + bridge.stop() + local.stop() + server.stop() + + +def test_unknown_wifi_state_fails_closed(): + print("local: an undeterminable Wi-Fi state never falls back to trusted") + # HASS_BRIDGE_TEST_SSID="" stands in for current_wifi_ssid() returning + # None — no NetworkManager, no active Wi-Fi, an nmcli error or timeout. + local = FakeHA() + server = FakeHA() + bridge = BridgeProc(env={"HASS_BRIDGE_TEST_SSID": ""}) + try: + bridge.send({"op": "config", "url": server.url, "localUrl": local.url, + "trustedNetwork": "Home", "token": "tok"}) + connected = bridge.wait_for( + lambda e: e["ev"] == "phase" and e["phase"] == "connected") + check("reaches connected via the primary URL", connected is not None) + check("the local server was never contacted", local.connections == 0, + local.connections) + finally: + bridge.stop() + local.stop() + server.stop() + + def test_demo_needs_no_server(): print("demo: runs standalone and drifts on its own") bridge = BridgeProc("--demo") @@ -702,6 +858,13 @@ def main(): test_wss_certificate_policy, test_invalid_websocket_message_is_controlled, test_fragment_flood_hits_size_limit, + test_local_url_is_preferred_when_reachable, + test_falls_back_to_primary_when_local_is_unreachable, + test_unparseable_local_url_does_not_block_the_primary, + test_local_url_matches_any_of_several_trusted_networks, + test_local_url_is_skipped_off_the_trusted_network, + test_local_url_without_a_trusted_network_is_never_used, + test_unknown_wifi_state_fails_closed, test_demo_needs_no_server): test() print() diff --git a/tests/test_config.js b/tests/test_config.js index 0970c49..c8ab600 100644 --- a/tests/test_config.js +++ b/tests/test_config.js @@ -38,6 +38,8 @@ const parsed = Config.parse(JSON.stringify({ }), ["light.demo"]); eq("typed values are normalized", parsed.config, { baseUrl: "", + localUrl: "", + trustedNetwork: "", demoMode: true, favorites: ["light.a"], demoFavorites: [], @@ -48,12 +50,24 @@ eq("typed values are normalized", parsed.config, { iconOverrides: {} }); +const withLocal = Config.parse(JSON.stringify({ + baseUrl: "https://ha.example.com", + localUrl: 7, + trustedNetwork: 9 +}), []); +eq("a non-string localUrl falls back to empty", withLocal.config.localUrl, ""); +eq("a non-string trustedNetwork falls back to empty", + withLocal.config.trustedNetwork, ""); + const merged = Config.merge(parsed.config, { groupByArea: true, + localUrl: "https://192.168.1.50:8123", token: "must-not-be-serialized", unknown: "ignored" }); eq("known keys merge", merged.groupByArea, true); +eq("localUrl merges like any other known key", + merged.localUrl, "https://192.168.1.50:8123"); eq("unknown and secret keys are dropped", merged.token, undefined); eq("serialized config has one trailing newline", Config.serialize(merged).endsWith("}\n"), true); diff --git a/tests/test_connection.js b/tests/test_connection.js index 7301fef..0a774e7 100644 --- a/tests/test_connection.js +++ b/tests/test_connection.js @@ -75,6 +75,48 @@ eq("an unusable URL has no signature", Connection.signature(false, "ftp://ha.local"), ""); eq("a bracket typo has no signature", Connection.signature(false, "https://ha.local:8123]"), ""); +// The local URL shares the primary URL's credential origin — it must never +// appear as, or affect, the credential scope — but it does have to change the +// signature so reconcileConnection restarts the bridge when it is edited. +eq("a blank local URL does not change the signature", + Connection.signature(false, "https://ha.local:8123", ""), + Connection.signature(false, "https://ha.local:8123")); +eq("a local URL is folded into the signature", + Connection.signature(false, "https://ha.local:8123", "https://192.168.1.50:8123"), + "https://ha.local:8123|https://ha.local:8123|https://192.168.1.50:8123"); +eq("changing only the local URL changes the signature", + Connection.signature(false, "https://ha.local:8123", "https://192.168.1.50:8123") + === Connection.signature(false, "https://ha.local:8123", "https://192.168.1.51:8123"), + false); +eq("a blank trusted network does not change the signature", + Connection.signature(false, "https://ha.local:8123", "https://192.168.1.50:8123", ""), + Connection.signature(false, "https://ha.local:8123", "https://192.168.1.50:8123")); +eq("the trusted network is folded into the signature", + Connection.signature(false, "https://ha.local:8123", "https://192.168.1.50:8123", "Home"), + "https://ha.local:8123|https://ha.local:8123|https://192.168.1.50:8123|Home"); +eq("changing only the trusted network changes the signature", + Connection.signature(false, "https://ha.local:8123", "https://192.168.1.50:8123", "Home") + === Connection.signature(false, "https://ha.local:8123", + "https://192.168.1.50:8123", "Office"), + false); +eq("a blank trusted network list is empty", + Connection.trustedNetworkList(""), []); +eq("a single trusted network is a one-item list", + Connection.trustedNetworkList("Home"), ["Home"]); +eq("comma-separated names are split and trimmed", + Connection.trustedNetworkList(" Home , Home 5G ,Office "), + ["Home", "Home 5G", "Office"]); +eq("only commas and whitespace is still empty", + Connection.trustedNetworkList(" , , "), []); +eq("no active line means no detected ssid", + Connection.parseNmcliActiveSsid("no:Neighbor\nno:CoffeeShop"), ""); +eq("the active line's ssid is detected", + Connection.parseNmcliActiveSsid("no:Neighbor\nyes:Home\nno:CoffeeShop"), "Home"); +eq("an escaped colon in the ssid is unescaped", + Connection.parseNmcliActiveSsid("yes:Office\\:5G"), "Office:5G"); +eq("an escaped backslash in the ssid is unescaped", + Connection.parseNmcliActiveSsid("yes:Back\\\\slash"), "Back\\slash"); +eq("empty input has no detected ssid", Connection.parseNmcliActiveSsid(""), ""); eq("matching generation is accepted", Connection.acceptsGeneration(4, 4), true); eq("old generation is rejected", Connection.acceptsGeneration(5, 4), false); eq("missing generation is rejected", Connection.acceptsGeneration(5, undefined), false);