Keep every password, token and API key the vault's, never a setting (hq issue 342)
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
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.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/secrets
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,4 @@
|
||||
# Secrets nested below a key a mergeable file takes for its other values, which the controller cannot
|
||||
# protect alone yet (it protects top-level keys): "<module> <resource> <dotted key>". This list only shrinks:
|
||||
# a line that no longer names a finding is refused (novox/hq issue 342).
|
||||
searxng settings server.secret_key
|
||||
@@ -0,0 +1,211 @@
|
||||
// Command secrets refuses, in every module's manifest, a mergeable file that a setting could fill with a
|
||||
// password, a token or an API key in place of the vault's (novox/hq issue 342).
|
||||
//
|
||||
// A password is not a setting: it comes from the key vault, and it can be rotated there. A file that says
|
||||
// `"merge": "json"` takes the keys of the mesh-wide and the node's layers over its own content, so a key the
|
||||
// content fills with `${secret:<name>}` is one a layer could replace, and the vault's value would lose to it
|
||||
// until somebody removed it by hand. The controller refuses a setting of a key the file keeps as `protected`,
|
||||
// naming the layer and the key, never the value. This check holds every such file to that: each top-level key
|
||||
// whose value holds `${secret:…}`, anywhere below it, is either listed in the file's `protected`, or left out
|
||||
// of the file's `takes-settings` (novox/hq ADR 0326), so that no layer reaches it.
|
||||
//
|
||||
// A secret nested below a key the file takes for its other values (`server.secret_key` below `server`) cannot
|
||||
// be protected alone today: the controller protects top-level keys. Such a file is named in the file `known`
|
||||
// beside this check, one line per key, until the controller refuses a nested key; a line there that no longer
|
||||
// names a finding is refused too, so the list only shrinks.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Finding is one key of one mergeable file a layer could fill in place of the vault.
|
||||
type Finding struct {
|
||||
Module, ID, Key string
|
||||
}
|
||||
|
||||
func (f Finding) String() string { return f.Module + " " + f.ID + " " + f.Key }
|
||||
|
||||
// Check reads every manifest under root/modules and answers each secret-bearing key a layer could replace.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
var out []Finding
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how == "" {
|
||||
continue
|
||||
}
|
||||
id, _ := r["id"].(string)
|
||||
content, _ := r["content"].(string)
|
||||
var doc map[string]any
|
||||
if strings.TrimSpace(content) == "" {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &doc); err != nil {
|
||||
return nil, fmt.Errorf("%s: %s merges as JSON and its content is not JSON: %w", m.Module, id, err)
|
||||
}
|
||||
protected := set(r["protected"])
|
||||
takes, names := r["takes-settings"]
|
||||
taken := set(takes)
|
||||
for _, key := range sortedKeys(doc) {
|
||||
for _, at := range secretsBelow(key, doc[key]) {
|
||||
switch {
|
||||
case protected[key]:
|
||||
case names && !taken[key]:
|
||||
default:
|
||||
out = append(out, Finding{m.Module, id, at})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// secretsBelow answers the dotted path of every string at or below value that names a vault secret.
|
||||
func secretsBelow(at string, value any) []string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if strings.Contains(v, "${secret:") {
|
||||
return []string{at}
|
||||
}
|
||||
case map[string]any:
|
||||
var out []string
|
||||
for _, k := range sortedKeys(v) {
|
||||
out = append(out, secretsBelow(at+"."+k, v[k])...)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
var out []string
|
||||
for i, e := range v {
|
||||
out = append(out, secretsBelow(fmt.Sprintf("%s[%d]", at, i), e)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func set(v any) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
list, _ := v.([]any)
|
||||
for _, e := range list {
|
||||
if s, ok := e.(string); ok {
|
||||
out[s] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// Known reads the lines of the known list: "<module> <resource> <key>", # comments and blank lines skipped.
|
||||
func Known(path string) (map[string]bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
out := map[string]bool{}
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
out[strings.Join(strings.Fields(line), " ")] = true
|
||||
}
|
||||
return out, s.Err()
|
||||
}
|
||||
|
||||
// Judge splits the findings into those refused and the known lines that no longer name one.
|
||||
func Judge(found []Finding, known map[string]bool) (refused []Finding, stale []string) {
|
||||
seen := map[string]bool{}
|
||||
for _, f := range found {
|
||||
seen[f.String()] = true
|
||||
if !known[f.String()] {
|
||||
refused = append(refused, f)
|
||||
}
|
||||
}
|
||||
for line := range known {
|
||||
if !seen[line] {
|
||||
stale = append(stale, line)
|
||||
}
|
||||
}
|
||||
sort.Strings(stale)
|
||||
return refused, stale
|
||||
}
|
||||
|
||||
// remedy says how a finding is put right.
|
||||
func remedy(key string) string {
|
||||
top, _, nested := strings.Cut(key, ".")
|
||||
if !nested {
|
||||
return fmt.Sprintf("list %q in the file's \"protected\", so the controller refuses a setting of it", top)
|
||||
}
|
||||
return fmt.Sprintf("the controller protects a top-level key only; list %q in the file's \"protected\" if "+
|
||||
"nothing else below it is the operator's, or leave it out of the file's \"takes-settings\"", top)
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
known, err := Known("known")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
refused, stale := Judge(found, known)
|
||||
for _, f := range refused {
|
||||
fmt.Printf("%s: %s fills %s with a vault secret, and a setting could replace it (novox/hq issue 342): %s\n",
|
||||
f.Module, f.ID, f.Key, remedy(f.Key))
|
||||
}
|
||||
for _, line := range stale {
|
||||
fmt.Printf("checks/secrets/known names %q, which no longer holds a secret a setting could replace: "+
|
||||
"remove the line\n", line)
|
||||
}
|
||||
if len(refused) > 0 || len(stale) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("secrets: no setting can replace a vault secret in a mergeable file (%d known, nested)\n", len(known))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func manifest(t *testing.T, root, module, resources string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", module)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"module":"` + module + `","resources":[` + resources + `]}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASecretASettingCouldReplaceIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Refused: the key holds the vault's secret and nothing keeps a layer off it.
|
||||
manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","content":"{\"password\": \"${secret:admin}\"}"}`)
|
||||
// Refused: nested below a key the file takes.
|
||||
manifest(t, root, "b", `{"id":"cfg","type":"file","merge":"json","takes-settings":["server"],`+
|
||||
`"content":"{\"server\": {\"secret_key\": \"${secret:s}\", \"port\": 1}}"}`)
|
||||
// Kept: protected.
|
||||
manifest(t, root, "c", `{"id":"cfg","type":"file","merge":"json","protected":["password"],`+
|
||||
`"content":"{\"password\": \"${secret:admin}\", \"user\": \"admin\"}"}`)
|
||||
// Kept: the file names its keys, and not this one.
|
||||
manifest(t, root, "d", `{"id":"cfg","type":"file","merge":"json","takes-settings":["ui"],`+
|
||||
`"content":"{\"token\": \"${secret:t}\", \"ui\": {}}"}`)
|
||||
// Kept: a file that does not merge takes no setting.
|
||||
manifest(t, root, "e", `{"id":"cfg","type":"file","content":"{\"token\": \"${secret:t}\"}"}`)
|
||||
// Refused: protecting another key does not protect this one; a list element counts too.
|
||||
manifest(t, root, "f", `{"id":"cfg","type":"file","merge":"json","protected":["user"],`+
|
||||
`"content":"{\"users\": [{\"password\": \"${secret:p}\"}]}"}`)
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"a cfg password", "b cfg server.secret_key", "f cfg users[0].password"}
|
||||
if len(found) != len(want) {
|
||||
t.Fatalf("found %v, want %v", found, want)
|
||||
}
|
||||
for i, f := range found {
|
||||
if f.String() != want[i] {
|
||||
t.Fatalf("found %v, want %v", found, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheKnownListOnlyShrinks(t *testing.T) {
|
||||
found := []Finding{{"b", "cfg", "server.secret_key"}, {"a", "cfg", "password"}}
|
||||
refused, stale := Judge(found, map[string]bool{"b cfg server.secret_key": true, "gone cfg token": true})
|
||||
if len(refused) != 1 || refused[0].String() != "a cfg password" {
|
||||
t.Fatalf("refused %v", refused)
|
||||
}
|
||||
if len(stale) != 1 || stale[0] != "gone cfg token" {
|
||||
t.Fatalf("stale %v", stale)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCatalogueHoldsNoSecretASettingCouldReplace(t *testing.T) {
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
known, err := Known("known")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refused, stale := Judge(found, known)
|
||||
if len(refused) > 0 || len(stale) > 0 {
|
||||
t.Fatalf("refused %v, stale %v", refused, stale)
|
||||
}
|
||||
}
|
||||
@@ -30,11 +30,16 @@
|
||||
# 5. the agent's home (novox/hq ADR 0266): no module unpacks an archive, or writes into a file, under
|
||||
# ${machine:agent-home} — the node-engine does both after checking the path, and the agent account owns
|
||||
# that home and could swap a link in between.
|
||||
# 6. the secrets (novox/hq issue 342): no mergeable file fills a key with a vault secret that a setting could
|
||||
# replace — the key is `protected`, or left out of the file's `takes-settings`. checks/secrets/known names
|
||||
# the nested ones the controller cannot protect alone yet, and only shrinks.
|
||||
set -eu
|
||||
|
||||
(cd checks/words && go test -count=1 . && go run . ../..)
|
||||
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
|
||||
(cd checks/agenthome && go test -count=1 . && go run . ../..)
|
||||
# The secrets (novox/hq issue 342): a password is not a setting, so no setting can replace the vault's.
|
||||
(cd checks/secrets && go test -count=1 . && go run . ../..)
|
||||
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
|
||||
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
|
||||
base_lists=""
|
||||
|
||||
@@ -103,7 +103,8 @@
|
||||
"path": "${dir:mesh-state}/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
|
||||
"merge": "json"
|
||||
"merge": "json",
|
||||
"protected": ["password"]
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
|
||||
+15
-2
@@ -138,6 +138,17 @@ function meshConfig(file?: string): 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`);
|
||||
}
|
||||
|
||||
export class GiteaClient {
|
||||
readonly baseUrl: string;
|
||||
private readonly tokens: TokenSource;
|
||||
@@ -151,7 +162,8 @@ export class GiteaClient {
|
||||
/**
|
||||
* Build from the module's resolved environment. The URL comes from MESH_GITEA_URL (the mesh's own
|
||||
* name), falling back to the bare GITEA_URL and to the forge's loopback port. The token, in order:
|
||||
* one configured in settings or the environment (MESH_GITEA_TOKEN / GITEA_TOKEN), which wins; else
|
||||
* one configured in the environment (MESH_GITEA_TOKEN / GITEA_TOKEN), which wins — never a setting, which is
|
||||
* ignored and said (hq issue 342); else
|
||||
* one the module mints for itself with the admin account the vault delivered and keeps in its own
|
||||
* state (token.ts; hq issue 100). Throws only when neither is possible, naming what is missing,
|
||||
* rather than hand back a client that fails on first use.
|
||||
@@ -159,7 +171,8 @@ export class GiteaClient {
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaClient {
|
||||
const cfg = meshConfig(env.MESH_GITEA_CONFIG_FILE);
|
||||
const url = cfg.url ?? env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`;
|
||||
const configured = cfg.token ?? env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN;
|
||||
ignoreSecretSetting(cfg, "token");
|
||||
const configured = env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN;
|
||||
if (configured) return new GiteaClient(url, new ConfiguredToken(configured));
|
||||
return new GiteaClient(url, MintedToken.fromEnv(url, env));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// A password, a token or an API key is the vault's, never a setting (novox/hq issue 342): a value for it in
|
||||
// the settings file the mesh merges is ignored, the vault's is used, and that is said once, by the key alone.
|
||||
// Run against the compiled module, the way the runtime loads it.
|
||||
|
||||
import { test, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { GiteaClient } from "../dist/client.js";
|
||||
import { ConfiguredToken, MintedToken } from "../dist/token.js";
|
||||
|
||||
const SETTING = "one-a-setting-carried";
|
||||
const VAULT = "the-vault-made-this";
|
||||
|
||||
function delivered(settings: Record<string, string>): Record<string, string> {
|
||||
const dir = mkdtempSync(join(tmpdir(), "gitea-342-"));
|
||||
writeFileSync(join(dir, "config.json"), JSON.stringify(settings));
|
||||
writeFileSync(join(dir, "vault.secret"), VAULT + "\n");
|
||||
return { dir, config: join(dir, "config.json"), vault: join(dir, "vault.secret") };
|
||||
}
|
||||
|
||||
test("the vault's token is used, a setting of it is ignored and said once, without its value", () => {
|
||||
const said: string[] = [];
|
||||
const err = mock.method(console, "error", (...args: unknown[]) => { said.push(args.join(" ")); });
|
||||
try {
|
||||
const at = delivered({ token: SETTING });
|
||||
const env = { MESH_GITEA_CONFIG_FILE: at.config, MESH_GITEA_URL: "http://127.0.0.1:1", MESH_GITEA_ADMIN_USER: "mesh-admin", MESH_GITEA_ADMIN_PASSWORD_FILE: at.vault, MESH_GITEA_STATE_DIR: at.dir };
|
||||
const first = GiteaClient.fromEnv(env);
|
||||
const second = GiteaClient.fromEnv(env);
|
||||
// No token from the setting: the module mints its own with the vault's admin account.
|
||||
assert.ok((first as any).tokens instanceof MintedToken);
|
||||
assert.ok((second as any).tokens instanceof MintedToken);
|
||||
assert.ok(!((first as any).tokens instanceof ConfiguredToken));
|
||||
assert.deepEqual(said, ["the setting token is ignored: it comes from the vault"]);
|
||||
assert.ok(!said.join("\n").includes(SETTING), "the setting's value is never said");
|
||||
} finally {
|
||||
err.mock.restore();
|
||||
}
|
||||
});
|
||||
@@ -141,7 +141,8 @@
|
||||
"path": "${dir:mesh-state}/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{\n \"user\": \"admin\",\n \"password\": \"${secret:admin}\"\n}\n",
|
||||
"merge": "json"
|
||||
"merge": "json",
|
||||
"protected": ["password"]
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
|
||||
@@ -27,6 +27,17 @@ function meshConfig(file?: string): 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 {
|
||||
@@ -53,7 +64,8 @@ export class HomeAssistantClient {
|
||||
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"}`;
|
||||
const token = cfg.token ?? readSecret(env.MESH_HOMEASSISTANT_TOKEN_FILE) ?? env.MESH_HOMEASSISTANT_TOKEN;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc client.ts index.ts tools/index.ts provisions/hass.ts provisions/probe.ts provisions/connections.ts provisions/mesh.ts provisions/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// A password, a token or an API key is the vault's, never a setting (novox/hq issue 342): a value for it in
|
||||
// the settings file the mesh merges is ignored, the vault's is used, and that is said once, by the key alone.
|
||||
// Run against the compiled module, the way the runtime loads it.
|
||||
|
||||
import { test, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { HomeAssistantClient } from "../dist/client.js";
|
||||
|
||||
const SETTING = "one-a-setting-carried";
|
||||
const VAULT = "the-vault-made-this";
|
||||
|
||||
function delivered(settings: Record<string, string>): Record<string, string> {
|
||||
const dir = mkdtempSync(join(tmpdir(), "home-assistant-342-"));
|
||||
writeFileSync(join(dir, "config.json"), JSON.stringify(settings));
|
||||
writeFileSync(join(dir, "vault.secret"), VAULT + "\n");
|
||||
return { dir, config: join(dir, "config.json"), vault: join(dir, "vault.secret") };
|
||||
}
|
||||
|
||||
test("the vault's token is used, a setting of it is ignored and said once, without its value", () => {
|
||||
const said: string[] = [];
|
||||
const err = mock.method(console, "error", (...args: unknown[]) => { said.push(args.join(" ")); });
|
||||
try {
|
||||
const at = delivered({ token: SETTING });
|
||||
const env = { MESH_HOMEASSISTANT_CONFIG_FILE: at.config, MESH_HOMEASSISTANT_TOKEN_FILE: at.vault };
|
||||
const first = HomeAssistantClient.fromEnv(env);
|
||||
const second = HomeAssistantClient.fromEnv(env);
|
||||
assert.equal((first as any).token, VAULT);
|
||||
assert.equal((second as any).token, VAULT);
|
||||
assert.deepEqual(said, ["the setting token is ignored: it comes from the vault"]);
|
||||
assert.ok(!said.join("\n").includes(SETTING), "the setting's value is never said");
|
||||
} finally {
|
||||
err.mock.restore();
|
||||
}
|
||||
});
|
||||
@@ -42,6 +42,24 @@ function meshConfig(file?: string): 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`);
|
||||
}
|
||||
|
||||
/** A secret delivered as a file, trimmed; undefined when there is none. */
|
||||
function readSecret(path: string | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
try { return readFileSync(path, "utf8").trim() || undefined; }
|
||||
catch { return undefined; }
|
||||
}
|
||||
|
||||
export class IcecastClient {
|
||||
readonly baseUrl: string;
|
||||
private readonly authHeader?: string;
|
||||
@@ -57,7 +75,9 @@ export class IcecastClient {
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): IcecastClient {
|
||||
const cfg = meshConfig(env.MESH_ICECAST_CONFIG_FILE);
|
||||
const url = cfg.url ?? (env.MESH_ICECAST_URL ?? `http://127.0.0.1:${env.ICECAST_PORT ?? "8000"}`);
|
||||
return new IcecastClient(url, cfg.user ?? env.MESH_ICECAST_ADMIN_USER, cfg.password ?? env.MESH_ICECAST_ADMIN_PASSWORD);
|
||||
ignoreSecretSetting(cfg, "password");
|
||||
const password = readSecret(env.MESH_ICECAST_ADMIN_PASSWORD_FILE) ?? env.MESH_ICECAST_ADMIN_PASSWORD;
|
||||
return new IcecastClient(url, cfg.user ?? env.MESH_ICECAST_ADMIN_USER ?? "admin", password);
|
||||
}
|
||||
|
||||
async getStatus(): Promise<IcecastStatus> {
|
||||
|
||||
@@ -120,7 +120,8 @@
|
||||
],
|
||||
"env": {
|
||||
"MESH_ICECAST_URL": "http://127.0.0.1:${port:8000}",
|
||||
"MESH_ICECAST_CONFIG_FILE": "${dir:mesh-state}/config.json"
|
||||
"MESH_ICECAST_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||
"MESH_ICECAST_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// A password, a token or an API key is the vault's, never a setting (novox/hq issue 342): a value for it in
|
||||
// the settings file the mesh merges is ignored, the vault's is used, and that is said once, by the key alone.
|
||||
// Run against the compiled module, the way the runtime loads it.
|
||||
|
||||
import { test, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { IcecastClient } from "../dist/client.js";
|
||||
|
||||
const SETTING = "one-a-setting-carried";
|
||||
const VAULT = "the-vault-made-this";
|
||||
|
||||
function delivered(settings: Record<string, string>): Record<string, string> {
|
||||
const dir = mkdtempSync(join(tmpdir(), "icecast-342-"));
|
||||
writeFileSync(join(dir, "config.json"), JSON.stringify(settings));
|
||||
writeFileSync(join(dir, "vault.secret"), VAULT + "\n");
|
||||
return { dir, config: join(dir, "config.json"), vault: join(dir, "vault.secret") };
|
||||
}
|
||||
|
||||
test("the vault's password is used, a setting of it is ignored and said once, without its value", () => {
|
||||
const said: string[] = [];
|
||||
const err = mock.method(console, "error", (...args: unknown[]) => { said.push(args.join(" ")); });
|
||||
try {
|
||||
const at = delivered({ password: SETTING });
|
||||
const env = { MESH_ICECAST_CONFIG_FILE: at.config, MESH_ICECAST_ADMIN_PASSWORD_FILE: at.vault };
|
||||
const first = IcecastClient.fromEnv(env);
|
||||
const second = IcecastClient.fromEnv(env);
|
||||
const basic = "Basic " + Buffer.from(`admin:${VAULT}`).toString("base64");
|
||||
assert.equal((first as any).authHeader, basic);
|
||||
assert.equal((second as any).authHeader, basic);
|
||||
assert.deepEqual(said, ["the setting password is ignored: it comes from the vault"]);
|
||||
assert.ok(!said.join("\n").includes(SETTING), "the setting's value is never said");
|
||||
} finally {
|
||||
err.mock.restore();
|
||||
}
|
||||
});
|
||||
@@ -43,6 +43,17 @@ function meshConfig(file?: string): 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`);
|
||||
}
|
||||
|
||||
/** A secret delivered as a file, trimmed; undefined when there is none, so the caller can fall back. */
|
||||
function tokenFromFile(file?: string): string | undefined {
|
||||
if (!file) return undefined;
|
||||
@@ -71,7 +82,8 @@ export class InfluxDBClient {
|
||||
const url = cfg.url ?? env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`;
|
||||
// The token reaches the process as a file (novox/hq ADR 0086); the environment variable stays
|
||||
// only for a workstation running the tools by hand.
|
||||
const token = cfg.token ?? tokenFromFile(env.MESH_INFLUXDB_TOKEN_FILE) ?? env.MESH_INFLUXDB_TOKEN;
|
||||
ignoreSecretSetting(cfg, "token");
|
||||
const token = tokenFromFile(env.MESH_INFLUXDB_TOKEN_FILE) ?? env.MESH_INFLUXDB_TOKEN;
|
||||
if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN_FILE");
|
||||
const org = cfg.org ?? env.MESH_INFLUXDB_ORG ?? "mesh";
|
||||
return new InfluxDBClient(url, token, org);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// A password, a token or an API key is the vault's, never a setting (novox/hq issue 342): a value for it in
|
||||
// the settings file the mesh merges is ignored, the vault's is used, and that is said once, by the key alone.
|
||||
// Run against the compiled module, the way the runtime loads it.
|
||||
|
||||
import { test, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { InfluxDBClient } from "../dist/client.js";
|
||||
|
||||
const SETTING = "one-a-setting-carried";
|
||||
const VAULT = "the-vault-made-this";
|
||||
|
||||
function delivered(settings: Record<string, string>): Record<string, string> {
|
||||
const dir = mkdtempSync(join(tmpdir(), "influxdb-342-"));
|
||||
writeFileSync(join(dir, "config.json"), JSON.stringify(settings));
|
||||
writeFileSync(join(dir, "vault.secret"), VAULT + "\n");
|
||||
return { dir, config: join(dir, "config.json"), vault: join(dir, "vault.secret") };
|
||||
}
|
||||
|
||||
test("the vault's token is used, a setting of it is ignored and said once, without its value", () => {
|
||||
const said: string[] = [];
|
||||
const err = mock.method(console, "error", (...args: unknown[]) => { said.push(args.join(" ")); });
|
||||
try {
|
||||
const at = delivered({ token: SETTING });
|
||||
const env = { MESH_INFLUXDB_CONFIG_FILE: at.config, MESH_INFLUXDB_TOKEN_FILE: at.vault };
|
||||
const first = InfluxDBClient.fromEnv(env);
|
||||
const second = InfluxDBClient.fromEnv(env);
|
||||
assert.equal((first as any).token, VAULT);
|
||||
assert.equal((second as any).token, VAULT);
|
||||
assert.deepEqual(said, ["the setting token is ignored: it comes from the vault"]);
|
||||
assert.ok(!said.join("\n").includes(SETTING), "the setting's value is never said");
|
||||
} finally {
|
||||
err.mock.restore();
|
||||
}
|
||||
});
|
||||
@@ -114,7 +114,8 @@
|
||||
"path": "${dir:mesh-state}/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{\n \"password\": \"${secret:server-password}\"\n}\n",
|
||||
"merge": "json"
|
||||
"merge": "json",
|
||||
"protected": ["password"]
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
|
||||
+13
-1
@@ -52,6 +52,17 @@ function meshConfig(file?: string): 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 from the file the mesh mounted it at (an own-secret), if the pointing env is set. */
|
||||
function readSecret(path: string | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
@@ -81,7 +92,8 @@ export class MailuClient {
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): MailuClient {
|
||||
const cfg = meshConfig(env.MESH_MAILU_CONFIG_FILE);
|
||||
const url = cfg.url ?? env.MESH_MAILU_URL;
|
||||
const apiKey = cfg.apiKey ?? readSecret(env.MESH_MAILU_API_KEY_FILE) ?? env.MESH_MAILU_API_KEY;
|
||||
ignoreSecretSetting(cfg, "apiKey");
|
||||
const apiKey = readSecret(env.MESH_MAILU_API_KEY_FILE) ?? env.MESH_MAILU_API_KEY;
|
||||
if (!url || !apiKey) {
|
||||
throw new Error("Mailu is not configured — set MESH_MAILU_URL and the API token own-secret");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// A password, a token or an API key is the vault's, never a setting (novox/hq issue 342): a value for it in
|
||||
// the settings file the mesh merges is ignored, the vault's is used, and that is said once, by the key alone.
|
||||
// Run against the compiled module, the way the runtime loads it.
|
||||
|
||||
import { test, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { MailuClient } from "../dist/client.js";
|
||||
|
||||
const SETTING = "one-a-setting-carried";
|
||||
const VAULT = "the-vault-made-this";
|
||||
|
||||
function delivered(settings: Record<string, string>): Record<string, string> {
|
||||
const dir = mkdtempSync(join(tmpdir(), "mailu-342-"));
|
||||
writeFileSync(join(dir, "config.json"), JSON.stringify(settings));
|
||||
writeFileSync(join(dir, "vault.secret"), VAULT + "\n");
|
||||
return { dir, config: join(dir, "config.json"), vault: join(dir, "vault.secret") };
|
||||
}
|
||||
|
||||
test("the vault's apiKey is used, a setting of it is ignored and said once, without its value", () => {
|
||||
const said: string[] = [];
|
||||
const err = mock.method(console, "error", (...args: unknown[]) => { said.push(args.join(" ")); });
|
||||
try {
|
||||
const at = delivered({ apiKey: SETTING });
|
||||
const env = { MESH_MAILU_CONFIG_FILE: at.config, MESH_MAILU_URL: "http://127.0.0.1:1/api/v1", MESH_MAILU_API_KEY_FILE: at.vault };
|
||||
const first = MailuClient.fromEnv(env);
|
||||
const second = MailuClient.fromEnv(env);
|
||||
assert.equal((first as any).apiKey, VAULT);
|
||||
assert.equal((second as any).apiKey, VAULT);
|
||||
assert.deepEqual(said, ["the setting apiKey is ignored: it comes from the vault"]);
|
||||
assert.ok(!said.join("\n").includes(SETTING), "the setting's value is never said");
|
||||
} finally {
|
||||
err.mock.restore();
|
||||
}
|
||||
});
|
||||
@@ -32,6 +32,17 @@ function meshConfig(file?: string): 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`);
|
||||
}
|
||||
|
||||
export class NextcloudClient {
|
||||
constructor(
|
||||
private readonly container: string,
|
||||
@@ -53,7 +64,8 @@ export class NextcloudClient {
|
||||
const ocsUrl = cfg.url ?? env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`;
|
||||
const adminUser = cfg.user ?? env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin";
|
||||
const passwordFile = env.MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE;
|
||||
const adminPassword = cfg.password ?? env.MESH_NEXTCLOUD_ADMIN_PASSWORD
|
||||
ignoreSecretSetting(cfg, "password");
|
||||
const adminPassword = env.MESH_NEXTCLOUD_ADMIN_PASSWORD
|
||||
?? (passwordFile ? readFileSync(passwordFile, "utf8").trim() : undefined);
|
||||
if (!adminPassword) {
|
||||
throw new Error("no Nextcloud admin password — set MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE " +
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// A password, a token or an API key is the vault's, never a setting (novox/hq issue 342): a value for it in
|
||||
// the settings file the mesh merges is ignored, the vault's is used, and that is said once, by the key alone.
|
||||
// Run against the compiled module, the way the runtime loads it.
|
||||
|
||||
import { test, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { NextcloudClient } from "../dist/client.js";
|
||||
|
||||
const SETTING = "one-a-setting-carried";
|
||||
const VAULT = "the-vault-made-this";
|
||||
|
||||
function delivered(settings: Record<string, string>): Record<string, string> {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nextcloud-342-"));
|
||||
writeFileSync(join(dir, "config.json"), JSON.stringify(settings));
|
||||
writeFileSync(join(dir, "vault.secret"), VAULT + "\n");
|
||||
return { dir, config: join(dir, "config.json"), vault: join(dir, "vault.secret") };
|
||||
}
|
||||
|
||||
test("the vault's password is used, a setting of it is ignored and said once, without its value", () => {
|
||||
const said: string[] = [];
|
||||
const err = mock.method(console, "error", (...args: unknown[]) => { said.push(args.join(" ")); });
|
||||
try {
|
||||
const at = delivered({ password: SETTING });
|
||||
const env = { MESH_NEXTCLOUD_CONFIG_FILE: at.config, MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE: at.vault };
|
||||
const first = NextcloudClient.fromEnv(env);
|
||||
const second = NextcloudClient.fromEnv(env);
|
||||
assert.equal((first as any).adminPassword, VAULT);
|
||||
assert.equal((second as any).adminPassword, VAULT);
|
||||
assert.deepEqual(said, ["the setting password is ignored: it comes from the vault"]);
|
||||
assert.ok(!said.join("\n").includes(SETTING), "the setting's value is never said");
|
||||
} finally {
|
||||
err.mock.restore();
|
||||
}
|
||||
});
|
||||
@@ -131,7 +131,8 @@
|
||||
"path": "${dir:mesh-state}/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{\n \"site\": \"default\",\n \"password\": \"${secret:controller}\"\n}\n",
|
||||
"merge": "json"
|
||||
"merge": "json",
|
||||
"protected": ["password"]
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
|
||||
Reference in New Issue
Block a user