Compare commits

...
Author SHA1 Message Date
jochen 2f55adc57c Name the setting keys searxng, keycloak and betterbird files take (hq issue 168, ADR 0326)
mesh/merge-gate fail: builds betterbird, keycloak, searxng → ace; no bus step; 2 wait(s) for a person; 3 problem(s) the change brings; the first: a machine…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery rejected: the gate failed or could not run, or the repository's own check failed
Every setting reached every mergeable file: searxng's endpoints landed in
its settings.yml, keycloak's issuer in its tools' config, betterbird's
prefs would land in its accounts file. Each file now names the keys it
reads, so the controller lays only those over it and calls the rest
stray. Merge only after the controller that knows takes-settings
(mesh-controller #272) has walked: an older one hands the field to the
node-engine, which refuses it.
2026-10-12 17:47:27 +02:00
mesh-admin cd2b6aacd8 Merge pull request 'mesh-reads: a node's files and accounts read as an account that holds no secret (hq ADR 0310, issue 418)' (#247) from fix/418-node-reads-2 into main 2026-10-12 14:52:18 +00:00
jochen 994cab533b mesh-reads: read a node's files and accounts as an account that holds no secret (hq ADR 0310, issue 418)
mesh/merge-gate pass: builds new: modules/mesh-reads, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
Reading a file, searching one, listing a directory, the owner and mode of each
component of a path and looking up an account went through the login shell's
execute as the operator's account, which sees the operator's keys and the
agent's credentials. This module holds node-reads on every node and runs as
mesh-reads (no login, no group beside its own). It refuses the node's secret
places by name, judging the file actually opened rather than the name asked,
so a flipped link reaches nothing refused; withholds any line shaped like a
secret through mesh-sdk's outward rule and every line of a BEGIN/END block;
and bounds every answer. Replaces mesh-catalog #244 (reviews 55), whose first
commit carried a built binary.
2026-10-12 14:32:22 +02:00
13 changed files with 2145 additions and 1 deletions
+2
View File
@@ -19,3 +19,5 @@ modules/mesh-issues/cmd/mesh-issues/mesh-issues
modules/mounts/cmd/mounts/mounts
modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
modules/mesh-knowledge/mesh-knowledge
modules/mesh-reads/mesh-reads
modules/mesh-reads/cmd/mesh-reads/mesh-reads
+3
View File
@@ -52,6 +52,9 @@
"owner": "${machine:account}",
"mode": "0600",
"merge": "json",
"takes-settings": [
"accounts"
],
"content": "{\n \"accounts\": {}\n}\n",
"trusted": true
},
+7 -1
View File
@@ -162,7 +162,13 @@
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
"merge": "json",
"takes-settings": [
"url",
"user",
"realm",
"issuer"
]
}
],
"build": {
+63
View File
@@ -0,0 +1,63 @@
# mesh-reads
A node's files and accounts, read through the mesh (novox/hq ADR 0310, extending ADR 0268 §5; hq to-be 41
WP8; issue 418). It holds the node seat `node-reads` on every node, the control-node included, so reading a
file, searching one, listing a directory, the owner and mode of each component of a path and looking up an
account or a group never go through the login shell's `execute`, and an approval is asked for an act, never
for a read.
## Who it reads as
The account `mesh-reads`, which this module makes on every node (`runs-as`, ADR 0259 §8): no login shell, a
home of its own under `/var/lib`, and no group beside its own. The tool runner serves it in a runtime of its
own, on its own bus credential, never the machine's runtime, which runs as the operator's account. So the
node's file modes bound what it sees: a file only the operator, root or a module's own account may read is
refused by its mode, and the answer says so. It refuses every read when it finds itself running as root.
## What it refuses by name
Before any read, whatever the file modes would allow (ADR 0310 §5):
- **The places the mesh keeps a secret on this node**, which the controller composes from the manifests
and gives it as `MESH_SECRET_PLACES`: the directory of every module's own secret (its bus credential
among them), every credential file and grant directory, the vault's directories, and where the
operator-sealed secrets are kept. Without that list the module does not start.
- **The known credential files of a machine's programs** (`refuse.go`): an account's `.ssh`, `.gnupg`,
`.aws`, `.kube`; the agent's `.credentials.json`, `.netrc`, `.pgpass`, `.git-credentials`; private key
names (`id_ed25519`, `*.key`, `ssh_host_*_key`); `/etc/shadow`; `/etc/ssl/private`, `/etc/wireguard`
and the network manager's connections.
- **Histories and profiles**: shell and program histories (`.zsh_history`, `.bash_history`, `.psql_history`,
`.viminfo` …), browser and mail profiles (`.mozilla`, `.config/google-chrome`, `.config/chromium` …), a
process's `environ`, `mem`, `fd`, `map_files`, `root` and `cwd` under `/proc`, and `/etc/rancher` and
`/etc/kubernetes/admin.conf`.
**What was opened is judged, not the name.** The type is checked before the open, so a device or a fifo is
never opened; the file is opened non-blocking (in a search without following a link), checked again on its
descriptor, and the path the kernel gives for that descriptor is judged by name like any path asked. A link
flipped between the check and the open therefore reaches nothing a name refuses, and a deleted file is refused.
**Limit:** a hard link to a secret file under another name is not seen by the name rule; the file's own mode,
and the kernel's protected hard links, are what bound it. `stat` and `readlink` say a secret place's owner
and mode, and mark it, because those are not its content; `list`, `read` and `search` refuse it.
## The content rule
Every answer passes the content rule's secret class, from `mesh-sdk`'s `outward` package (ADR 0310 §4): a
line of a file or a matching line shaped like a secret is withheld and its number said, as is a link target
or an account's words. Every line of a `BEGIN … END` block — a private key's or a certificate's — is withheld whole, because one
base64 line of a key can pass the rule alone. A path and an entry's name are not judged: they are what the caller named or what the
file system calls a thing, and the rule would take a temporary directory's or a container layer's name for a
token.
## Its verbs and their bounds
| verb | replaces | bounds |
|---|---|---|
| `stat` | `stat`, `ls -ld`, `namei -l`, `findmnt -T` | 200 paths, 64 components each; links followed only with `follow` |
| `readlink` | `readlink`, `readlink -f`, `realpath` | one path |
| `list` | `ls`, `ls -la` | 2000 entries, cut and said |
| `read` | `cat`, `head`, `tail`, `less` | 64 KiB, or up to 256 KiB when asked; cut at a line and said; text only |
| `search` | `grep`, `grep -r`, `grep -F` | a fixed string; 100 matches (500 asked), 4 deep (8 asked), 5000 files, 4 MiB a file, 20 seconds (checked inside a file too); the named path's file system only, the others named; the bound met is said |
| `account` | `getent passwd`, `getent group`, `id`, `groups` | 2000 accounts and groups; never a password field |
No answer is larger than 1 MiB. Nothing here writes a file.
+204
View File
@@ -0,0 +1,204 @@
// mesh-reads (novox/hq ADR 0310, to-be 41 WP8): the holder of node-reads. A node's files and accounts read
// through the mesh — the owner and mode of each component of a path, a link, a directory, a text file, a search
// by fixed string, an account or a group — so these reads never go through the login shell's execute.
//
// It runs as an account of its own, mesh-reads, served by the tool runner in a runtime of its own (ADR 0259
// §8): in no group that holds a secret, with no login shell and no home of the operator's, so the node's file
// modes bound what it sees. On top, it refuses by name the places a secret is kept (refuse.go) and withholds
// any piece of an answer shaped like a secret (mesh-sdk's outward). Read-only: nothing here writes a file.
//
// stdout is the MCP channel; what this module says, it says on stderr.
package main
import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Seat is the role this module holds.
const Seat = "node-reads"
// PlacesWord is where the controller names this node's secret places (novox/hq ADR 0310 §5).
const PlacesWord = "MESH_SECRET_PLACES"
func main() {
places, err := placesFrom(os.Getenv(PlacesWord))
if err != nil {
fmt.Fprintf(os.Stderr, "[mesh-reads] %v\n", err)
os.Exit(1)
}
if err := stdio.Serve("", tools(newReader(places))); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-reads] %v\n", err)
os.Exit(1)
}
}
// placesFrom reads the controller's list. Absent is refused: without it, a module's own-secret directory
// would be read wherever the account's modes allow, and a refusal the decision promises would be silent.
func placesFrom(raw string) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return nil, fmt.Errorf("%s is not given, so this node's secret places are unknown and nothing would be "+
"refused by them (novox/hq ADR 0310 §5): a controller that knows node-reads composes it", PlacesWord)
}
var places []string
if err := json.Unmarshal([]byte(raw), &places); err != nil {
return nil, fmt.Errorf("%s is not a list of paths: %v", PlacesWord, err)
}
return places, nil
}
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
func arg(a map[string]any, k string) string {
switch v := a[k].(type) {
case string:
return strings.TrimSpace(v)
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
case bool:
return strconv.FormatBool(v)
}
return ""
}
func number(a map[string]any, k string) (int, error) {
s := arg(a, k)
if s == "" {
return 0, nil
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("%s is a whole number, and %q was given", k, s)
}
return n, nil
}
// pathsArg is the paths given, one per line — or, from a caller that sends a list, the list.
func pathsArg(a map[string]any) []string {
var raw []string
switch v := a["paths"].(type) {
case string:
raw = strings.Split(v, "\n")
case []any:
for _, p := range v {
if s, ok := p.(string); ok {
raw = append(raw, s)
}
}
}
var out []string
for _, p := range raw {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// bounded is the last bound (novox/hq ADR 0310): whatever a verb made, no answer is larger than MaxAnswer.
func bounded(answer any, err error) (any, error) {
if err != nil {
return nil, err
}
body, err := json.Marshal(answer)
if err != nil {
return nil, err
}
if len(body) > MaxAnswer {
return nil, fmt.Errorf("the answer is %d bytes, and an answer of node-reads is at most %d: ask for less", len(body), MaxAnswer)
}
return answer, nil
}
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
// The descriptions are the controller's (internal/catalogue/reads.go), said again here for a caller of the
// module's own address.
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
Run: func(a map[string]any) (any, error) { return bounded(run(a)) }}
}
func object(props map[string]any, required ...string) map[string]any {
if required == nil {
required = []string{}
}
return map[string]any{"type": "object", "properties": props, "required": required}
}
func tools(r Reader) []stdio.Tool {
return []stdio.Tool{
verb("stat", "Each component of one path or several, from / down: its type, owner and group (name and "+
"number), mode, whether it is a link and to what, and whether it is a mount point and of which file "+
"system. A link is not followed unless follow is \"true\". Read as the account mesh-reads, which holds no "+
"secret: a component it may not reach says so. At most 200 paths in one call.",
object(map[string]any{"paths": str("one absolute path, or several separated by line breaks"),
"follow": str("\"true\": also say where the path leads once every link is followed")}, "paths"),
func(a map[string]any) (any, error) { return r.Stat(pathsArg(a), arg(a, "follow") == "true") }),
verb("readlink", "What one link points to, as written, and where the path leads once every link is "+
"followed, and whether that exists.",
object(map[string]any{"path": str("an absolute path")}, "path"),
func(a map[string]any) (any, error) { return r.Readlink(arg(a, "path")) }),
verb("list", "Every entry of one directory with its type, owner, group, mode, size and, for a link, its "+
"target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is "+
"refused by name.",
object(map[string]any{"path": str("an absolute path to a directory")}, "path"),
func(a map[string]any) (any, error) { return r.List(arg(a, "path")) }),
verb("read", "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more "+
"is asked), cut at a line and said so. A negative from counts from the end, as tail does. A binary file "+
"is refused; a place a secret is kept, or a known credential file, is refused by name; a line shaped "+
"like a secret is withheld and its number said. /proc and /sys are read as far as mesh-reads may.",
object(map[string]any{"path": str("an absolute path to a file"),
"from": str("the first line to answer, counted from 1; negative: that many lines from the end (optional)"),
"lines": str("how many lines to answer (optional)"),
"bytes": str("the most bytes to answer, up to 262144 (optional, 65536 unless given)")}, "path"),
func(a map[string]any) (any, error) {
var ask ReadAsk
var err error
if ask.From, err = number(a, "from"); err != nil {
return nil, err
}
if ask.Lines, err = number(a, "lines"); err != nil {
return nil, err
}
if ask.Bytes, err = number(a, "bytes"); err != nil {
return nil, err
}
return r.Read(arg(a, "path"), ask)
}),
verb("search", "Find a fixed string (never a pattern) in one file or under one directory: every matching "+
"line with its file and number. Bounded: at most 500 matches (100 unless more is asked), 8 directories "+
"deep (4 unless more is asked), 5000 files, 4 MiB per file and 20 seconds, on the file system of the path "+
"named; links are not followed and binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like "+
"a secret is withheld. The answer says which bound it met.",
object(map[string]any{"text": str("the fixed string to find"),
"path": str("an absolute path to a file or a directory"),
"depth": str("how many directories deep, up to 8 (optional)"),
"matches": str("the most matches to answer, up to 500 (optional)"),
"case": str("\"false\": match whatever the case (optional)")}, "text", "path"),
func(a map[string]any) (any, error) {
text, _ := a["text"].(string) // as given: a space at either end is part of what is found
ask := SearchAsk{Text: text, Path: arg(a, "path"), CaseInsensitive: arg(a, "case") == "false"}
var err error
if ask.Depth, err = number(a, "depth"); err != nil {
return nil, err
}
if ask.Matches, err = number(a, "matches"); err != nil {
return nil, err
}
return r.Search(ask)
}),
verb("account", "One account or group by name or number, as this node's account files say: an account's "+
"number, group, home, shell and every group it is in; a group's number and members. With neither, every "+
"account and group. Never a password field.",
object(map[string]any{"name": str("an account's name or number (optional)"),
"group": str("a group's name or number (optional)")}),
func(a map[string]any) (any, error) { return r.Account(arg(a, "name"), arg(a, "group")) }),
}
}
@@ -0,0 +1,59 @@
package main
import (
"encoding/json"
"os"
"strings"
"testing"
)
// ADR 0310 §3: the account the reads run as is the module's own, made by the mesh, with no login shell, no
// home of the operator's and no group beside its own, and the module runs as it.
func TestTheAccountHoldsNoSecretsGroupAndNoLogin(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
RunsAs string `json:"runs-as"`
SecretsOwner string `json:"secrets-owner"`
Claims []map[string]any `json:"claims"`
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if m.RunsAs != "mesh-reads" || m.SecretsOwner != "mesh-reads" {
t.Errorf("runs as %q, secrets owned by %q", m.RunsAs, m.SecretsOwner)
}
if len(m.Claims) != 1 || m.Claims[0]["name"] != Seat {
t.Errorf("claims %v", m.Claims)
}
found := false
for _, r := range m.Resources {
if r["type"] != "user" {
continue
}
found = true
if r["name"] != m.RunsAs {
t.Errorf("a user other than its own: %v", r)
}
if r["shell"] != "/usr/bin/nologin" {
t.Errorf("the account has a login shell: %v", r["shell"])
}
if home, _ := r["home"].(string); !strings.HasPrefix(home, "/var/lib/") {
t.Errorf("the account's home is %q, not one of the mesh's", home)
}
if _, grouped := r["groups"]; grouped {
t.Errorf("the account is put in groups: %v", r["groups"])
}
}
if !found {
t.Error("the module makes no account of its own")
}
for _, r := range m.Resources {
if r["type"] != "user" && r["type"] != "directory" {
t.Errorf("a read-only holder declares a %v: %v", r["type"], r)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,562 @@
package main
import (
crand "crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"testing"
"time"
)
// A token shaped as a secret, written into files the tests read.
const aToken = "ghp_abcdefghijklmnopqrstuvwxyz0123456789"
func testReader(t *testing.T, places ...string) (Reader, string) {
t.Helper()
dir := t.TempDir()
passwd := filepath.Join(dir, "passwd")
group := filepath.Join(dir, "group")
mountinfo := filepath.Join(dir, "mountinfo")
write(t, passwd, "root:x:0:0::/root:/usr/bin/bash\nops:x:1000:1000:The Operator:/home/ops:/usr/bin/zsh\n"+
"mesh-reads:x:961:961::/var/lib/mesh-reads:/usr/bin/nologin\n")
write(t, group, "root:x:0:\nwheel:x:998:ops\nops:x:1000:\nmesh-reads:x:961:\ndocker:x:970:ops,builder\n")
data := filepath.Join(dir, "data")
write(t, mountinfo, "22 1 0:21 / / rw,relatime - btrfs /dev/nvme0n1p2 rw\n"+
"40 22 0:40 / "+strings.ReplaceAll(data, " ", `\040`)+" rw - nfs4 home-server:/tank/data rw\n")
r := Reader{Refuser: Refuser{Places: places}, Passwd: passwd, Group: group, MountInfo: mountinfo,
Euid: func() int { return 961 }, Now: time.Now}
return r, dir
}
func write(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// ADR 0310 §3: run as root, every read is refused, loudly — its bound is an account of its own.
func TestEveryReadIsRefusedAsRoot(t *testing.T) {
r, dir := testReader(t)
r.Euid = func() int { return 0 }
write(t, filepath.Join(dir, "f"), "x\n")
if _, err := r.Read(filepath.Join(dir, "f"), ReadAsk{}); err == nil || !strings.Contains(err.Error(), "root") {
t.Errorf("a read as root: %v", err)
}
if _, err := r.Stat([]string{dir}, false); err == nil {
t.Error("a stat as root was answered")
}
if _, err := r.Account("ops", ""); err == nil {
t.Error("an account lookup as root was answered")
}
}
// ADR 0310 §5: without the controller's list of secret places it does not start: a refusal it promises would be
// silent.
func TestItDoesNotStartWithoutTheNodesSecretPlaces(t *testing.T) {
if _, err := placesFrom(""); err == nil || !strings.Contains(err.Error(), PlacesWord) {
t.Errorf("no places: %v", err)
}
if p, err := placesFrom(`["/var/lib/mesh-vault"]`); err != nil || len(p) != 1 {
t.Errorf("a list: %v %v", p, err)
}
if p, err := placesFrom(`[]`); err != nil || len(p) != 0 {
t.Errorf("an empty list is a node with no secret place: %v %v", p, err)
}
}
// ADR 0310 §5: a path under the vault's directory, a module's own-secret directory and a known credential file
// are each refused by name before any read — though the account's modes would allow it — and a link to one is
// refused as the place is.
func TestSecretPlacesAreRefusedByNameBeforeAnyRead(t *testing.T) {
r0, dir := testReader(t)
vault := filepath.Join(dir, "var/lib/mesh-vault")
own := filepath.Join(dir, "var/lib/desk-channel")
r, _ := r0, dir
r.Places = []string{vault, own}
for _, p := range []string{
filepath.Join(vault, "ledger/entries.json"),
filepath.Join(own, "broker"),
filepath.Join(own, "settings.json"),
filepath.Join(dir, "home/ops/.claude/.credentials.json"),
filepath.Join(dir, "home/ops/.ssh/config"),
filepath.Join(dir, "home/ops/.pgpass"),
filepath.Join(dir, "etc/ssh/ssh_host_ed25519_key"),
filepath.Join(dir, "etc/shadow"),
filepath.Join(dir, "srv/tls/server.key"),
} {
write(t, p, "harmless words\n")
if _, err := r.Read(p, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "refused by name") {
t.Errorf("%s was read: %v", p, err)
}
}
if _, err := r.List(vault); err == nil || !strings.Contains(err.Error(), "refused by name") {
t.Errorf("the vault's directory was listed: %v", err)
}
link := filepath.Join(dir, "tmp/innocent")
write(t, filepath.Join(dir, "tmp/x"), "")
if err := os.Symlink(filepath.Join(own, "broker"), link); err != nil {
t.Fatal(err)
}
if _, err := r.Read(link, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "leads to") {
t.Errorf("a link to a secret place was read: %v", err)
}
// A search under a directory holding a secret place skips it and names it.
write(t, filepath.Join(dir, "home/ops/notes.txt"), "harmless words\n")
a, err := r.Search(SearchAsk{Text: "harmless", Path: filepath.Join(dir, "home"), Depth: 8})
if err != nil {
t.Fatal(err)
}
if len(a.Matches) != 1 || !strings.HasSuffix(a.Matches[0].File, "notes.txt") || len(a.Skipped) != 3 {
t.Errorf("search: matches %v, skipped %v", a.Matches, a.Skipped)
}
// What a public key or a certificate is, is not refused.
for _, p := range []string{filepath.Join(dir, "etc/ssh/ssh_host_ed25519_key.pub"), filepath.Join(dir, "etc/ssl/certs/ca.pem")} {
write(t, p, "harmless words\n")
if _, err := r.Read(p, ReadAsk{}); err != nil {
t.Errorf("%s was refused: %v", p, err)
}
}
}
// ADR 0310 §3: what the account's file modes refuse is refused — the first bound, below the names.
func TestAFileTheAccountMayNotReadIsRefusedByItsMode(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("run as root, a file's mode refuses nothing")
}
r, dir := testReader(t)
p := filepath.Join(dir, "operators-only")
write(t, p, "the operator's words\n")
if err := os.Chmod(p, 0o000); err != nil {
t.Fatal(err)
}
if _, err := r.Read(p, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "may not reach it") {
t.Errorf("a file of mode 0000: %v", err)
}
closed := filepath.Join(dir, "closed")
write(t, filepath.Join(closed, "inside"), "x")
if err := os.Chmod(closed, 0o700&^0o100); err != nil {
t.Fatal(err)
}
defer os.Chmod(closed, 0o755)
a, err := r.Stat([]string{filepath.Join(closed, "inside")}, false)
if err != nil {
t.Fatal(err)
}
if got := a.Paths[0]; !strings.Contains(got.Stopped, "may not reach") {
t.Errorf("a path through a directory it may not search: %+v", got)
}
}
// ADR 0310 §2 and the bounds: a read over the size bound is cut at a line and says so; more than the most is
// refused; lines and a tail are answered as asked.
func TestAReadIsBounded(t *testing.T) {
r, dir := testReader(t)
p := filepath.Join(dir, "big")
var b strings.Builder
for i := 1; i <= 20000; i++ {
fmt.Fprintf(&b, "line %d of the file\n", i)
}
write(t, p, b.String())
a, err := r.Read(p, ReadAsk{})
if err != nil {
t.Fatal(err)
}
if len(a.Text) > DefaultReadSize || !strings.Contains(a.Cut, fmt.Sprint(DefaultReadSize)) || a.From != 1 {
t.Errorf("a default read: %d bytes, cut %q", len(a.Text), a.Cut)
}
if _, err := r.Read(p, ReadAsk{Bytes: MaxReadSize + 1}); err == nil {
t.Error("more than the most a read answers was answered")
}
a, err = r.Read(p, ReadAsk{From: 10, Lines: 2})
if err != nil || a.Text != "line 10 of the file\nline 11 of the file\n" || a.From != 10 || a.Cut != "" {
t.Errorf("lines 10 and 11: %+v %v", a, err)
}
a, err = r.Read(p, ReadAsk{From: -2})
if err != nil || a.Text != "line 19999 of the file\nline 20000 of the file\n" || a.From != 19999 {
t.Errorf("the last two lines: %q from %d, %v", a.Text, a.From, err)
}
if _, err := r.Read(p, ReadAsk{From: 30000}); err == nil || !strings.Contains(err.Error(), "20000 lines") {
t.Errorf("a line past the end: %v", err)
}
}
// A binary file, a directory and a fifo are refused, and the fifo without waiting for a writer.
func TestOnlyATextFileIsRead(t *testing.T) {
r, dir := testReader(t)
bin := filepath.Join(dir, "bin")
write(t, bin, "ELF\x00\x01\x02")
if _, err := r.Read(bin, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "binary") {
t.Errorf("a binary file: %v", err)
}
if _, err := r.Read(dir, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "directory") {
t.Errorf("a directory: %v", err)
}
fifo := filepath.Join(dir, "fifo")
if err := syscall.Mkfifo(fifo, 0o644); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { _, err := r.Read(fifo, ReadAsk{}); done <- err }()
select {
case err := <-done:
if err == nil || !strings.Contains(err.Error(), "fifo") {
t.Errorf("a fifo: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("a read of a fifo waits for a writer")
}
}
// ADR 0310 §4: a line shaped like a secret is withheld, its number said, and the rest is answered.
func TestALineShapedLikeASecretIsWithheld(t *testing.T) {
r, dir := testReader(t)
p := filepath.Join(dir, "app.conf")
write(t, p, "listen = /run/app.sock\ntoken = "+aToken+"\nuser = app\n")
a, err := r.Read(p, ReadAsk{})
if err != nil {
t.Fatal(err)
}
if strings.Contains(a.Text, aToken) || !strings.Contains(a.Text, "user = app") ||
!strings.Contains(a.Text, "listen = /run/app.sock") || fmt.Sprint(a.Withheld) != "[2]" {
t.Errorf("read: %q withheld %v", a.Text, a.Withheld)
}
m, err := r.Search(SearchAsk{Text: "token", Path: dir})
if err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(m)
if strings.Contains(string(body), aToken) || len(m.Matches) != 1 || len(m.Withheld) != 1 {
t.Errorf("search: %s", body)
}
}
// The bounds of a search: matches, depth, the most asked, and time — each met is said.
func TestASearchIsBounded(t *testing.T) {
r, dir := testReader(t)
for i := 0; i < 30; i++ {
write(t, filepath.Join(dir, fmt.Sprintf("f%02d", i)), "needle\nneedle\n")
}
a, err := r.Search(SearchAsk{Text: "needle", Path: dir, Matches: 7})
if err != nil {
t.Fatal(err)
}
if len(a.Matches) != 7 || !strings.HasPrefix(a.Bound, "matches") {
t.Errorf("seven matches asked: %d, bound %q", len(a.Matches), a.Bound)
}
if _, err := r.Search(SearchAsk{Text: "needle", Path: dir, Matches: MaxMatches + 1}); err == nil {
t.Error("more matches than the most were asked and answered")
}
if _, err := r.Search(SearchAsk{Text: "needle", Path: dir, Depth: MaxDepth + 1}); err == nil {
t.Error("deeper than the most was asked and answered")
}
deep := t.TempDir()
write(t, filepath.Join(deep, "a/b/c/shallow"), "needle\n")
write(t, filepath.Join(deep, "a/b/c/d/e/deep"), "needle\n")
a, err = r.Search(SearchAsk{Text: "needle", Path: deep, Depth: 3})
if err != nil {
t.Fatal(err)
}
if len(a.Matches) != 1 || !strings.HasSuffix(a.Matches[0].File, "shallow") {
t.Errorf("three deep: %+v", a.Matches)
}
// Case: as asked.
write(t, filepath.Join(deep, "case"), "NEEDLE\n")
if a, _ := r.Search(SearchAsk{Text: "needle", Path: filepath.Join(deep, "case")}); len(a.Matches) != 0 {
t.Error("a match whatever the case, unasked")
}
if a, _ := r.Search(SearchAsk{Text: "needle", Path: filepath.Join(deep, "case"), CaseInsensitive: true}); len(a.Matches) != 1 {
t.Error("no match whatever the case, asked")
}
// Time: a clock past the bound stops the walk and says so.
start := time.Now()
calls := 0
r.Now = func() time.Time { calls++; return start.Add(time.Duration(calls) * SearchTime / 3) }
a, err = r.Search(SearchAsk{Text: "needle", Path: dir})
if err != nil || !strings.HasPrefix(a.Bound, "time") {
t.Errorf("the time bound: %q %v", a.Bound, err)
}
// A fixed string: a pattern's characters are themselves.
write(t, filepath.Join(deep, "pattern"), "a.c\nabc\n")
if a, _ := r.Search(SearchAsk{Text: "a.c", Path: filepath.Join(deep, "pattern")}); len(a.Matches) != 1 || a.Matches[0].Line != 1 {
t.Errorf("a.c as a fixed string: %+v", a.Matches)
}
}
// The survey issue 418 was asked for: each component of a path with its owner, group, mode, whether it is a
// link and to what, and whether it is a mount point — the link not followed unless asked.
func TestStatSaysEachComponent(t *testing.T) {
r, dir := testReader(t)
data := filepath.Join(dir, "data")
write(t, filepath.Join(data, "real/file"), "x")
if err := os.Chmod(filepath.Join(data, "real"), os.ModeSetgid|0o750); err != nil {
t.Fatal(err)
}
if err := os.Symlink("real", filepath.Join(data, "link")); err != nil {
t.Fatal(err)
}
a, err := r.Stat([]string{filepath.Join(data, "link"), filepath.Join(data, "real/file")}, false)
if err != nil {
t.Fatal(err)
}
link := a.Paths[0]
last := link.Components[len(link.Components)-1]
if link.Components[0].Path != "/" || last.Type != "link" || last.Link != "real" || link.LeadsTo != "" {
t.Errorf("the link, not followed: %+v", link)
}
var dataC, realC Component
for _, c := range a.Paths[1].Components {
switch c.Path {
case data:
dataC = c
case filepath.Join(data, "real"):
realC = c
}
}
if dataC.Mount == nil || dataC.Mount.FS != "nfs4" {
t.Errorf("the mount point is not said: %+v", dataC)
}
if realC.Mode != "2750" || realC.Type != "directory" || realC.UID == nil || realC.Perms == "" {
t.Errorf("a component's mode: %+v", realC)
}
if uid := *realC.UID; uid == 1000 && realC.Owner != "ops" {
t.Errorf("owner %d named %q", uid, realC.Owner)
}
a, err = r.Stat([]string{filepath.Join(data, "link")}, true)
if err != nil {
t.Fatal(err)
}
if a.Paths[0].LeadsTo != filepath.Join(data, "real") || a.Paths[0].Leads == nil || a.Paths[0].Leads.Type != "directory" {
t.Errorf("followed: %+v", a.Paths[0])
}
a, err = r.Stat([]string{filepath.Join(dir, "missing/deeper")}, false)
if err != nil || !strings.Contains(a.Paths[0].Stopped, "does not exist") {
t.Errorf("a missing path: %+v %v", a, err)
}
if _, err := r.Stat([]string{"relative/path"}, false); err == nil {
t.Error("a relative path was answered")
}
many := make([]string, MaxPaths+1)
for i := range many {
many[i] = "/"
}
if _, err := r.Stat(many, false); err == nil {
t.Error("more paths than the most were answered")
}
}
func TestReadlinkSaysTheTargetAndWhereItLeads(t *testing.T) {
r, dir := testReader(t)
write(t, filepath.Join(dir, "target"), "")
if err := os.Symlink("target", filepath.Join(dir, "l1")); err != nil {
t.Fatal(err)
}
if err := os.Symlink("l1", filepath.Join(dir, "l2")); err != nil {
t.Fatal(err)
}
if err := os.Symlink("nowhere", filepath.Join(dir, "dangling")); err != nil {
t.Fatal(err)
}
a, err := r.Readlink(filepath.Join(dir, "l2"))
if err != nil || !a.IsLink || a.Link != "l1" || a.LeadsTo != filepath.Join(dir, "target") || !a.Exists {
t.Errorf("l2: %+v %v", a, err)
}
a, err = r.Readlink(filepath.Join(dir, "dangling"))
if err != nil || !a.IsLink || a.Exists || !strings.Contains(a.Stopped, "does not exist") {
t.Errorf("dangling: %+v %v", a, err)
}
}
func TestListSaysEveryEntry(t *testing.T) {
r, dir := testReader(t)
write(t, filepath.Join(dir, "d/a"), "abc")
if err := os.Symlink("a", filepath.Join(dir, "d/b")); err != nil {
t.Fatal(err)
}
a, err := r.List(filepath.Join(dir, "d"))
if err != nil {
t.Fatal(err)
}
if a.Count != 2 || a.Entries[0].Name != "a" || *a.Entries[0].Size != 3 || a.Entries[1].Type != "link" || a.Entries[1].Link != "a" {
t.Errorf("list: %+v", a)
}
for i := 0; i < MaxEntries+5; i++ {
write(t, filepath.Join(dir, "many", fmt.Sprintf("e%05d", i)), "")
}
a, err = r.List(filepath.Join(dir, "many"))
if err != nil || len(a.Entries) != MaxEntries || a.Count != MaxEntries+5 || a.Cut == "" {
t.Errorf("a long directory: %d of %d, cut %q, %v", len(a.Entries), a.Count, a.Cut, err)
}
}
// getent passwd, getent group and id: an account with every group it is in, a group with its members, never a
// password field.
func TestAccountAndGroupLookup(t *testing.T) {
r, _ := testReader(t)
a, err := r.Account("ops", "")
if err != nil {
t.Fatal(err)
}
u := a.Accounts[0]
sort.Strings(u.Groups)
if u.UID != 1000 || u.Group != "ops" || u.Shell != "/usr/bin/zsh" || fmt.Sprint(u.Groups) != "[docker ops wheel]" {
t.Errorf("ops: %+v", u)
}
if a, err := r.Account("961", ""); err != nil || a.Accounts[0].Name != "mesh-reads" {
t.Errorf("by number: %+v %v", a, err)
}
g, err := r.Account("", "docker")
if err != nil || g.Groups[0].GID != 970 || fmt.Sprint(g.Groups[0].Members) != "[ops builder]" {
t.Errorf("docker: %+v %v", g, err)
}
if _, err := r.Account("nobody-here", ""); err == nil {
t.Error("a missing account was answered")
}
all, err := r.Account("", "")
if err != nil || len(all.Accounts) != 3 || len(all.Groups) != 5 {
t.Errorf("all: %+v %v", all, err)
}
body, _ := json.Marshal(all)
if strings.Contains(string(body), `"x"`) || strings.Contains(string(body), "password") {
t.Errorf("a password field is answered: %s", body)
}
}
// No answer is larger than the bound, whatever a verb made.
func TestNoAnswerIsLargerThanTheBound(t *testing.T) {
if _, err := bounded(strings.Repeat("x", MaxAnswer+1), nil); err == nil {
t.Error("an answer over the bound was given")
}
if _, err := bounded("small", nil); err != nil {
t.Error(err)
}
}
// The seat's six verbs, as the controller defines them (mesh-controller internal/catalogue/reads.go).
func TestItServesTheSeatsVerbs(t *testing.T) {
r, _ := testReader(t)
var names []string
for _, tool := range tools(r) {
names = append(names, tool.Name)
}
if fmt.Sprint(names) != "[node-reads.stat node-reads.readlink node-reads.list node-reads.read node-reads.search node-reads.account]" {
t.Errorf("verbs: %v", names)
}
}
// The review of #244 (required 1): a link flipped between a harmless file and a secret place, while it is read
// and searched, never answers the secret place's content — what was opened is judged, not the name.
func TestALinkFlippedDuringAReadNeverReadsASecretPlace(t *testing.T) {
r, dir := testReader(t)
secret := filepath.Join(dir, "var/lib/mesh-reads")
r.Places = []string{secret}
write(t, filepath.Join(secret, "broker"), "THE-BROKER-CREDENTIAL\n")
write(t, filepath.Join(dir, "plain"), "THE-PLAIN-WORDS\n")
link := filepath.Join(dir, "shared/link")
if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
t.Fatal(err)
}
stop := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
targets := []string{filepath.Join(dir, "plain"), filepath.Join(secret, "broker")}
for i := 0; ; i++ {
select {
case <-stop:
return
default:
}
tmp := link + ".new"
os.Remove(tmp)
if os.Symlink(targets[i%2], tmp) == nil {
os.Rename(tmp, link)
}
}
}()
leaked := 0
for i := 0; i < 5000; i++ {
if a, err := r.Read(link, ReadAsk{}); err == nil && strings.Contains(a.Text, "BROKER") {
leaked++
}
if a, err := r.Search(SearchAsk{Text: "BROKER", Path: filepath.Dir(link)}); err == nil && len(a.Matches) > 0 {
leaked++
}
}
close(stop)
<-done
if leaked > 0 {
t.Errorf("a secret place was answered %d times through a flipped link", leaked)
}
}
// The review of #244 (required 2): every line of a private key's block is withheld, in a read and in a search,
// though the file's name is not refused.
func TestEveryLineOfAKeyBlockIsWithheld(t *testing.T) {
r, dir := testReader(t)
key, err := rsa.GenerateKey(crand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
body := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
p := filepath.Join(dir, "server.pem")
write(t, p, "# the server's key\n"+body+"after = the block\n")
a, err := r.Read(p, ReadAsk{})
if err != nil {
t.Fatal(err)
}
lines := strings.Count(body, "\n")
for _, l := range strings.Split(strings.TrimSpace(body), "\n")[1:] {
if strings.Contains(a.Text, l) && !strings.HasPrefix(l, "-----END") {
t.Fatalf("a line of the key was answered: %q", l)
}
}
if len(a.Withheld) != lines || !strings.Contains(a.Text, "after = the block") || !strings.Contains(a.Text, "the server's key") {
t.Errorf("withheld %d of the block's %d lines; text:\n%s", len(a.Withheld), lines, a.Text)
}
// A search for a character every base64 line holds answers none of the key.
m, err := r.Search(SearchAsk{Text: "A", Path: p, Matches: MaxMatches})
if err != nil {
t.Fatal(err)
}
for _, x := range m.Matches {
if !strings.HasPrefix(x.Text, "‹withheld") {
t.Errorf("a search answered a line of the key: %q", x.Text)
}
}
}
// The review of #244 (required 3 and should): histories, browser profiles, a process's private entries and the
// cluster credentials are refused by name.
func TestHistoriesProfilesAndProcessesAreRefusedByName(t *testing.T) {
var r Refuser
for _, p := range []string{"/home/ops/.zsh_history", "/home/ops/.bash_history", "/root/.psql_history",
"/home/ops/.viminfo", "/home/ops/.mozilla/firefox/x.default/logins.json",
"/home/ops/.config/google-chrome/Default/Cookies", "/home/ops/.config/chromium/Default/Login Data",
"/proc/self/environ", "/proc/1234/mem", "/proc/self/fd/3", "/proc/thread-self/root/etc/shadow",
"/etc/rancher/k3s/k3s.yaml", "/etc/kubernetes/admin.conf"} {
if r.Refused(p) == "" {
t.Errorf("%s is not refused", p)
}
}
for _, p := range []string{"/proc/mdstat", "/proc/self/status", "/home/ops/.config/i3/config", "/etc/hosts"} {
if why := r.Refused(p); why != "" {
t.Errorf("%s is refused: %s", p, why)
}
}
}
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"path/filepath"
"strings"
"git.novox.be/novox/mesh-sdk/go/outward"
)
// What is refused by name, before any read (novox/hq ADR 0310 §5), and what an answer withholds (§4).
//
// **Two lists of places.** The places the mesh knows a secret is kept on this node — every module's own-secret
// directory, every credential file and grant directory, the vault's directories — come from the controller,
// which composes them from the manifests (MESH_SECRET_PLACES). The known credential files of the programs
// on a machine are this module's list, below: the mesh does not write them, so nothing else names them. Both
// hold whatever the account's file modes would allow: the file modes are the first bound, the names the second.
// knownDirs are directories that hold nothing but credentials, wherever they are: an account's keys.
var knownDirs = map[string]bool{
".ssh": true, ".gnupg": true, ".password-store": true, ".aws": true, ".kube": true, ".azure": true,
".mozilla": true, ".thunderbird": true, // browser and mail profiles: saved logins and cookies
}
// knownNames are file names that are a credential whatever directory they are in.
var knownNames = map[string]bool{
".credentials.json": true, // the agent's credentials (Claude Code)
".netrc": true, ".pgpass": true, ".git-credentials": true, ".npmrc": true, ".pypirc": true,
".my.cnf": true, ".vault-token": true, "credentials": true, "credentials.json": true,
"id_rsa": true, "id_dsa": true, "id_ecdsa": true, "id_ed25519": true, "id_ecdsa_sk": true, "id_ed25519_sk": true,
"shadow": true, "gshadow": true, "shadow-": true, "gshadow-": true, "opasswd": true,
"broker": true, // the mesh's name for a module's bus credential
// Shell and program histories: where a token is typed.
".bash_history": true, ".zsh_history": true, ".histfile": true, ".python_history": true, ".psql_history": true,
".mysql_history": true, ".lesshst": true, ".viminfo": true, ".node_repl_history": true, ".sqlite_history": true,
".rediscli_history": true,
}
// knownSuffixes are file name endings a credential has.
var knownSuffixes = []string{".key", ".p12", ".pfx", ".jks", ".keystore", ".creds", ".nk", ".kdbx", "_key"}
// knownPaths are the places on a machine that hold credentials and are not a module's.
var knownPaths = []string{
"/etc/ssl/private", "/etc/wireguard", "/etc/NetworkManager/system-connections", "/etc/ipsec.d/private",
"/var/lib/iwd", "/etc/docker/key.json", "/root/.docker/config.json", "/var/lib/sss/secrets",
"/etc/rancher", "/etc/kubernetes/admin.conf",
}
// knownProfiles are directories under an account's .config that hold a browser's saved logins and cookies.
var knownProfiles = map[string]bool{"google-chrome": true, "chromium": true, "BraveSoftware": true, "vivaldi": true,
"microsoft-edge": true}
// procPrivate are the entries of a process under /proc that reach its memory, its environment or its files.
var procPrivate = map[string]bool{"environ": true, "mem": true, "fd": true, "map_files": true, "root": true,
"cwd": true}
// Refuser says whether a path is a place a secret is kept.
type Refuser struct {
// Places are the mesh's, from the controller: refused with everything beneath them.
Places []string
}
// Refused says why a path may not be read, or "" when it may. The path is clean and absolute.
func (r Refuser) Refused(path string) string {
path = filepath.Clean(path)
for _, p := range r.Places {
if within(path, p) {
return "it is a place the mesh keeps a secret (" + p + ")"
}
}
for _, p := range knownPaths {
if within(path, p) {
return "it is a place a machine keeps credentials (" + p + ")"
}
}
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
if len(parts) >= 3 && parts[0] == "proc" && procPrivate[parts[2]] {
return "it reaches a process's " + parts[2]
}
for i, part := range parts {
if i > 0 && parts[i-1] == ".config" && knownProfiles[part] {
return "it is " + part + "'s profile, which keeps saved logins"
}
if knownDirs[part] {
return "it is in " + part + ", which holds an account's credentials"
}
if i == len(parts)-1 {
if knownNames[part] {
return part + " is a known credential file"
}
for _, s := range knownSuffixes {
if strings.HasSuffix(part, s) && len(part) > len(s) {
return part + " is named as a key or a credential (" + s + ")"
}
}
if strings.HasSuffix(part, ".pem") && (strings.Contains(part, "key") || strings.Contains(part, "private")) {
return part + " is named as a private key"
}
if part == "config.json" && i > 0 && parts[i-1] == ".docker" {
return "a container registry login is kept there"
}
}
}
return ""
}
func within(path, place string) bool {
return path == place || strings.HasPrefix(path, strings.TrimSuffix(place, "/")+"/")
}
// Withheld is one piece of an answer the content rule kept back: where it was, and the class it carried.
type Withheld struct {
At string `json:"at"`
Class string `json:"why"`
}
// clean is the content rule's secret class (mesh-sdk outward, novox/hq ADR 0310 §4) over one piece of an
// answer: the piece, or a mark in its place when it is shaped like a secret.
//
// **What a file holds, not what the file system names.** It judges a file's lines, a matching line, a link's
// target, a mount's source and an account's words. A path and an entry's name are not judged: they are what
// the caller named or the file system calls a thing, and the rule's random-run test takes a temporary
// directory's or a container layer's name for a token, which would withhold the very survey asked for. A
// place where a secret sits by name is refused before any read instead (Refuser).
func clean(s string) (string, string) {
if refusal, ok := outward.CheckSecret(s); !ok {
return "‹withheld: " + refusal.What + "›", refusal.What
}
return s, ""
}
+5
View File
@@ -0,0 +1,5 @@
module mesh-reads
go 1.25.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2 h1:1E5iapAO1o4vkwV/8CUBQvNfL3OjXRtiQGBaKHXJVW8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+54
View File
@@ -0,0 +1,54 @@
{
"module": "mesh-reads",
"version": "1",
"runs-as": "mesh-reads",
"claims": [
{
"name": "node-reads",
"scope": "node",
"serves": [
"stat",
"readlink",
"list",
"read",
"search",
"account"
]
}
],
"own-secrets": {
"broker": "${dir:state}/broker"
},
"secrets-owner": "mesh-reads",
"resources": [
{
"id": "account",
"type": "user",
"name": "mesh-reads",
"shell": "/usr/bin/nologin",
"home": "/var/lib/mesh-reads"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "mesh-reads",
"place": "."
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-reads",
"binary": "mesh-reads",
"loads": [
"mesh-reads"
]
}
]
}
}
+12
View File
@@ -76,6 +76,18 @@
"path": "${dir:state}/settings.yml",
"mode": "0600",
"merge": "json",
"takes-settings": [
"general",
"brand",
"search",
"server",
"ui",
"outgoing",
"engines",
"plugins",
"categories_as_tabs",
"preferences"
],
"content": "{\n \"use_default_settings\": true,\n \"server\": {\n \"secret_key\": \"${secret:secret}\",\n \"base_url\": false,\n \"limiter\": false,\n \"image_proxy\": false,\n \"public_instance\": false\n },\n \"search\": {\n \"formats\": [\"html\", \"json\"]\n },\n \"valkey\": {\n \"url\": \"valkey://valkey:6379/0\"\n }\n}\n"
},
{