mesh/merge-gate head fe2040c65ea2 merged into trunk 53e51864969f — pass: builds baserow, gitea, grafana, home-assistant, icecast, influxdb, letta, mailu, n…
mesh/repo-check head fe2040c65ea2 merged into trunk 53e51864969f — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery-group group fix/342-a-secret-is-never-a-setting ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
A setting outranked the vault: baserow, grafana, letta and unifi merged a layer's
password over the ${secret:...} in their runtime config, and the gitea,
home-assistant, icecast, influxdb, mailu and nextcloud clients read the settings
file before the vault's file. A secret in a setting cannot be rotated and is read
back by the settings verb.
The four files now keep their password protected, so the controller refuses a
setting of it. The six clients ignore a secret in the settings file and say so
once, by key and never with the value; icecast reads its admin password from the
vault's file. checks/secrets refuses a mergeable file whose secret a layer could
replace; searxng's nested server.secret_key is listed as known until the
controller protects a nested key.
109 lines
4.1 KiB
TypeScript
109 lines
4.1 KiB
TypeScript
// The Home Assistant API client — home-assistant's own code, living in the module (novox/hq
|
|
// ADR 0039). Both this module's tools and its events entrypoint import it, and nothing outside
|
|
// home-assistant does. Talks to the HA REST API (/api) with a long-lived access token.
|
|
|
|
import { readFileSync } from "node:fs";
|
|
|
|
export interface HAEntityState {
|
|
entity_id: string;
|
|
state: string;
|
|
attributes: Record<string, unknown>;
|
|
last_changed?: string;
|
|
last_updated?: string;
|
|
}
|
|
|
|
export interface HAConfig {
|
|
location_name?: string;
|
|
version?: string;
|
|
components?: string[];
|
|
time_zone?: string;
|
|
state?: string;
|
|
}
|
|
|
|
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
|
|
function meshConfig(file?: string): Record<string, string> {
|
|
if (!file) return {};
|
|
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
|
catch { return {}; }
|
|
}
|
|
|
|
/**
|
|
* A password, token or API key is the vault's, never a setting (novox/hq issue 342): a value for one of
|
|
* them in the settings file is ignored, and that is said once — by its key, never with its value.
|
|
*/
|
|
const saidIgnored = new Set<string>();
|
|
function ignoreSecretSetting(cfg: Record<string, unknown>, key: string): void {
|
|
if (cfg[key] === undefined || saidIgnored.has(key)) return;
|
|
saidIgnored.add(key);
|
|
console.error(`the setting ${key} is ignored: it comes from the vault`);
|
|
}
|
|
|
|
/** Read a secret the mesh mounted at a file path (an own-secret delivered by `secret accept`);
|
|
* absent or unreadable yields undefined so callers fall back rather than crash. */
|
|
function readSecret(file?: string): string | undefined {
|
|
if (!file) return undefined;
|
|
try { return readFileSync(file, "utf8").trim(); }
|
|
catch { return undefined; }
|
|
}
|
|
|
|
export class HomeAssistantClient {
|
|
readonly baseUrl: string;
|
|
|
|
constructor(
|
|
url: string,
|
|
private readonly token: string,
|
|
) {
|
|
this.baseUrl = url.replace(/\/$/, "");
|
|
}
|
|
|
|
/**
|
|
* Build from the module's resolved environment. The URL defaults to the local server (HA runs on
|
|
* the node); the token is the long-lived access token minted in HA's profile — required, since
|
|
* every API call is Bearer-authenticated and there is nowhere to discover it from.
|
|
*/
|
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): HomeAssistantClient {
|
|
const cfg = meshConfig(env.MESH_HOMEASSISTANT_CONFIG_FILE);
|
|
const url = cfg.url ?? env.MESH_HOMEASSISTANT_URL ?? `http://127.0.0.1:${env.HOMEASSISTANT_PORT ?? "8123"}`;
|
|
ignoreSecretSetting(cfg, "token");
|
|
const token = readSecret(env.MESH_HOMEASSISTANT_TOKEN_FILE) ?? env.MESH_HOMEASSISTANT_TOKEN;
|
|
if (!token) throw new Error("no Home Assistant token — set MESH_HOMEASSISTANT_TOKEN");
|
|
return new HomeAssistantClient(url, token);
|
|
}
|
|
|
|
private async request(path: string, init?: RequestInit): Promise<unknown> {
|
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
...init,
|
|
headers: {
|
|
Authorization: `Bearer ${this.token}`,
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
...(init?.headers ?? {}),
|
|
},
|
|
});
|
|
if (!res.ok) throw new Error(`Home Assistant API ${path}: ${res.status} ${await res.text()}`);
|
|
return res.json();
|
|
}
|
|
|
|
async getConfig(): Promise<HAConfig> {
|
|
return (await this.request("/api/config")) as HAConfig;
|
|
}
|
|
|
|
/** All entity states, or one entity when an id is given. */
|
|
async getStates(): Promise<HAEntityState[]> {
|
|
return (await this.request("/api/states")) as HAEntityState[];
|
|
}
|
|
|
|
async getState(entityId: string): Promise<HAEntityState> {
|
|
return (await this.request(`/api/states/${encodeURIComponent(entityId)}`)) as HAEntityState;
|
|
}
|
|
|
|
/** Call a service (e.g. switch.turn_on) — how "turn the light on" reaches HA. Returns the states
|
|
* the call changed. */
|
|
async callService(domain: string, service: string, data: Record<string, unknown> = {}): Promise<HAEntityState[]> {
|
|
return (await this.request(`/api/services/${encodeURIComponent(domain)}/${encodeURIComponent(service)}`, {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
})) as HAEntityState[];
|
|
}
|
|
}
|