-
Notifications
You must be signed in to change notification settings - Fork 18
feat: add operator CLI example and fix local two-stack observability setup #385
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,7 +11,7 @@ scrape_configs: | |
| # guardian-server running on the host instead (e.g. `cargo run`), point | ||
| # this at `host.docker.internal:9464` and add an `extra_hosts` mapping | ||
| # for it on this service. | ||
| - targets: ["server:9464"] | ||
| - targets: ["host.docker.internal:9464"] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -C 4 \
'9464|host\.docker\.internal|extra_hosts|ports:' \
docs/guides/miden-dashboard/docker-compose.yml \
docs/guides/observability/docker-compose.yml \
docs/guides/observability/prometheus/prometheus.ymlRepository: OpenZeppelin/guardian Length of output: 7961 🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '1,120p' docs/guides/observability/docker-compose.yml
printf '\n--- prometheus config ---\n'
cat -n docs/guides/observability/prometheus/prometheus.yml
printf '\n--- miden dashboard relevant sections ---\n'
sed -n '1,110p' docs/guides/miden-dashboard/docker-compose.ymlRepository: OpenZeppelin/guardian Length of output: 7810 🌐 Web query:
💡 Result: To access the host machine or services running on it from a container managed by Docker Compose, use the Citations:
🌐 Web query:
💡 Result: On Linux, you can enable the use of host.docker.internal to reach the host machine by adding the flag --add-host=host.docker.internal:host-gateway to your docker run command [1][2][3], or by adding extra_hosts: - "host.docker.internal:host-gateway" to your service definition in a docker-compose.yml file [4][2][5]. This feature is available in Docker Engine 20.10 and later [2][3]. Even when this is configured, there are two common reasons why a container might fail to reach a service running on the host: 1. Service Binding: The service on the host must be configured to listen on the host's IP address (e.g., the IP of the docker0 interface, often 172.17.0.1) or on all interfaces (0.0.0.0) [1][2]. If the service is bound only to the local loopback interface (127.0.0.1), it will not accept connections from the Docker network, as the container's loopback address is distinct from the host's loopback address [4][6]. 2. Firewall Configuration: Linux host firewalls (such as UFW or iptables) often restrict traffic between the Docker bridge (e.g., docker0) and the host [1][4]. Because traffic from a container enters the host via the bridge interface, the firewall may block these incoming connections if rules are not explicitly set to permit them [1]. Troubleshooting often involves temporarily disabling the firewall to verify connectivity [1] or adjusting net.bridge.bridge-nf-call-iptables sysctl settings if traffic is being dropped by bridge-level rules [4]. Alternatively, for situations where you need full access to the host's network namespace, you can run the container with the host networking driver using the --network host flag, which allows the container to share the host's network stack directly (including access to services bound to localhost) [5][7]. Citations:
🌐 Web query:
💡 Result: To access a service running on your host machine from inside a Docker Compose container, you must navigate the fact that localhost inside a container refers to the container itself, not the host machine [1][2][3]. The standard, recommended approach for enabling cross-platform host access is using the host-gateway feature [4][5]. Configuration in Docker Compose You can enable this by adding an extra_hosts entry to your service definition in your docker-compose.yml file [6][4]: services: app: image: myapp:latest extra_hosts: - "host.docker.internal:host-gateway" How it works 1. The host-gateway value is a special instruction that tells Docker to resolve the hostname (host.docker.internal) to the host machine's internal IP address on the default bridge network [7][8]. 2. When this entry is present, the container's /etc/hosts file is updated to map host.docker.internal to that IP [4][5][2]. 3. Applications inside the container can then connect to services on the host using the address http://host.docker.internal:[PORT] instead of localhost [9][10][1]. Important requirements * Host Service Binding: For the container to successfully connect, the service on the host must be listening on all interfaces (0.0.0.0) or specifically on the IP address assigned to the Docker bridge network [9][11]. If the host service is strictly bound to 127.0.0.1, it may reject connections coming from the container's IP [9]. * Compatibility: This method works on Linux (Docker Engine 20.10+) and is automatically available on Docker Desktop (Mac and Windows), where host.docker.internal is provided by default [6][10][11]. Including the extra_hosts configuration is considered a best practice for portability, as it is harmless on systems that already provide it and necessary on Linux [8][1]. * Alternative (Host Networking): If you prefer to bypass Docker networking entirely, you can use network_mode: host [12][1][2]. This allows the container to share the host's network namespace, meaning localhost inside the container will then correctly refer to the host's localhost [10][2]. Note that this has significant implications for network isolation and is typically reserved for specialized use cases [12][2]. Citations:
🌐 Web query:
💡 Result: To publish a container port only to the host's loopback interface (localhost) using Docker Compose, you must specify the IP address in the Citations:
Use
🤖 Prompt for AI Agents |
||
| # Matches GUARDIAN_METRICS_BEARER_TOKEN on the server. `devtoken` is a | ||
| # throwaway for this local stack — in production use `credentials_file:` | ||
| # pointing at a mounted secret instead of an inline value, and never | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| node_modules/ | ||
| operator-key.bin | ||
| package-lock.json |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,126 @@ | ||||||||||||||||||
| /** | ||||||||||||||||||
| * Guardian operator CLI demo — authenticate and list accounts. | ||||||||||||||||||
| * | ||||||||||||||||||
| * First run (no key file): generates a Falcon keypair, writes the secret key | ||||||||||||||||||
| * to ./operator-key.txt, prints the operators.json entry, and exits. | ||||||||||||||||||
| * | ||||||||||||||||||
| * Subsequent runs: reads the key from ./operator-key.txt and authenticates. | ||||||||||||||||||
|
Comment on lines
+4
to
+7
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Correct the key file name in the header. Lines 5 and 7 specify Proposed fix- * to ./operator-key.txt, prints the operators.json entry, and exits.
+ * to ./operator-key.bin, prints the operators.json entry, and exits.
...
- * Subsequent runs: reads the key from ./operator-key.txt and authenticates.
+ * Subsequent runs: reads the key from ./operator-key.bin and authenticates.📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| * | ||||||||||||||||||
| * Usage: | ||||||||||||||||||
| * npx tsx list-accounts.ts | ||||||||||||||||||
| * | ||||||||||||||||||
| * Point at a different instance: | ||||||||||||||||||
| * GUARDIAN_URL=https://guardian.example npx tsx list-accounts.ts | ||||||||||||||||||
| */ | ||||||||||||||||||
|
|
||||||||||||||||||
| import { readFileSync, writeFileSync, existsSync } from 'fs'; | ||||||||||||||||||
| import { GuardianOperatorHttpClient } from '@openzeppelin/guardian-operator-client'; | ||||||||||||||||||
| import { AuthSecretKey, Word, getNativeModule } from '@miden-sdk/miden-sdk'; | ||||||||||||||||||
|
|
||||||||||||||||||
| const GUARDIAN_URL = process.env.GUARDIAN_URL ?? 'http://127.0.0.1:3000'; | ||||||||||||||||||
| const KEY_FILE = './operator-key.bin'; | ||||||||||||||||||
|
|
||||||||||||||||||
| function bytesToHex(b: Uint8Array): string { | ||||||||||||||||||
| return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Session cookies are not persisted automatically by Node.js fetch — wire them up manually. | ||||||||||||||||||
| function makeCookieFetch(): typeof fetch { | ||||||||||||||||||
| const jar: Record<string, string> = {}; | ||||||||||||||||||
| return async (input, init) => { | ||||||||||||||||||
| const headers = new Headers(init?.headers); | ||||||||||||||||||
| const cookieHeader = Object.entries(jar) | ||||||||||||||||||
| .map(([k, v]) => `${k}=${v}`) | ||||||||||||||||||
| .join('; '); | ||||||||||||||||||
| if (cookieHeader) headers.set('cookie', cookieHeader); | ||||||||||||||||||
|
|
||||||||||||||||||
| const res = await fetch(input, { ...init, headers }); | ||||||||||||||||||
|
|
||||||||||||||||||
| // getSetCookie() returns each Set-Cookie header as a separate string (Node 18+). | ||||||||||||||||||
| const setCookies = | ||||||||||||||||||
| typeof res.headers.getSetCookie === 'function' | ||||||||||||||||||
| ? res.headers.getSetCookie() | ||||||||||||||||||
| : [res.headers.get('set-cookie') ?? ''].filter(Boolean); | ||||||||||||||||||
|
|
||||||||||||||||||
| for (const raw of setCookies) { | ||||||||||||||||||
| const [pair] = raw.split(';'); | ||||||||||||||||||
| const eq = pair?.indexOf('=') ?? -1; | ||||||||||||||||||
| if (eq > 0) jar[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim(); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| return res; | ||||||||||||||||||
| }; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| async function main() { | ||||||||||||||||||
| if (!existsSync(KEY_FILE)) { | ||||||||||||||||||
| // First run: generate a keypair, save the secret key to a file, exit. | ||||||||||||||||||
| const secretKey = AuthSecretKey.rpoFalconWithRNG(undefined); | ||||||||||||||||||
| writeFileSync(KEY_FILE, secretKey.serialize()); | ||||||||||||||||||
|
Comment on lines
+57
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Restrict permissions on the generated secret key.
Proposed fix- writeFileSync(KEY_FILE, secretKey.serialize());
+ writeFileSync(KEY_FILE, secretKey.serialize(), { mode: 0o600 });📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| // toHex() already includes the 0x prefix | ||||||||||||||||||
| const commitment = secretKey.publicKey().toCommitment().toHex(); | ||||||||||||||||||
| const pubKeyHex = '0x' + bytesToHex(secretKey.publicKey().serialize().slice(1)); | ||||||||||||||||||
|
|
||||||||||||||||||
| const entry = JSON.stringify({ public_key: pubKeyHex, permissions: ['dashboard:read', 'accounts:pause'] }, null, 2); | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Do not grant Line 65 generates an operator entry with mutation permission, but this CLI only calls Proposed fix- const entry = JSON.stringify({ public_key: pubKeyHex, permissions: ['dashboard:read', 'accounts:pause'] }, null, 2);
+ const entry = JSON.stringify({ public_key: pubKeyHex, permissions: ['dashboard:read'] }, null, 2);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| console.log(`\nNo key file found — generated a new Falcon keypair → ${KEY_FILE}\n`); | ||||||||||||||||||
| console.log('1. Add this entry to docs/guides/miden-dashboard/operators.json:\n'); | ||||||||||||||||||
| console.log(entry.split('\n').map((l) => ` ${l}`).join('\n')); | ||||||||||||||||||
| console.log(`\n Commitment (for reference): ${commitment}\n`); | ||||||||||||||||||
| console.log('2. Re-run (key loads automatically from operator-key.bin):\n'); | ||||||||||||||||||
| console.log(' npx tsx list-accounts.ts\n'); | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // The napi compat layer wrongly converts Buffer→Array for deserialize, so bypass it. | ||||||||||||||||||
| const secretKey = getNativeModule().AuthSecretKey.deserialize(readFileSync(KEY_FILE)) as AuthSecretKey; | ||||||||||||||||||
| // toHex() already includes the 0x prefix | ||||||||||||||||||
| const commitment = secretKey.publicKey().toCommitment().toHex(); | ||||||||||||||||||
|
|
||||||||||||||||||
| const client = new GuardianOperatorHttpClient({ | ||||||||||||||||||
| baseUrl: GUARDIAN_URL, | ||||||||||||||||||
| fetch: makeCookieFetch(), | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Step 1: request challenge | ||||||||||||||||||
| process.stdout.write(`Authenticating ${commitment.slice(0, 20)}… `); | ||||||||||||||||||
| const { challenge } = await client.challenge(commitment); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Step 2: sign the challenge digest with the Falcon key | ||||||||||||||||||
| const sig = secretKey.sign(Word.fromHex(challenge.signingDigest)); | ||||||||||||||||||
| const sigHex = bytesToHex(sig.serialize().slice(1)); // drop leading scheme byte | ||||||||||||||||||
|
|
||||||||||||||||||
| // Step 3: verify → server sets session cookie | ||||||||||||||||||
| const { operatorId } = await client.verify({ | ||||||||||||||||||
| commitment, | ||||||||||||||||||
| signature: sigHex, | ||||||||||||||||||
| }); | ||||||||||||||||||
| console.log(`ok (operator: ${operatorId})\n`); | ||||||||||||||||||
|
|
||||||||||||||||||
| // List accounts | ||||||||||||||||||
| const { items, nextCursor } = await client.listAccounts({ limit: 50 }); | ||||||||||||||||||
| const totalLabel = `${items.length}${nextCursor ? '+' : ''}`; | ||||||||||||||||||
|
|
||||||||||||||||||
| console.log(`Accounts on ${GUARDIAN_URL} (${totalLabel} total)`); | ||||||||||||||||||
|
Comment on lines
+102
to
+105
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Do not label the first page as the total.
🤖 Prompt for AI Agents |
||||||||||||||||||
| console.log('─'.repeat(80)); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (items.length === 0) { | ||||||||||||||||||
| console.log(' No accounts registered yet.'); | ||||||||||||||||||
| } else { | ||||||||||||||||||
| for (const a of items) { | ||||||||||||||||||
| const status = a.pausedAt ? 'PAUSED' : 'active'; | ||||||||||||||||||
| console.log( | ||||||||||||||||||
| ` [${status.padEnd(6)}] ${a.accountId}` + | ||||||||||||||||||
| ` scheme=${a.authScheme}` + | ||||||||||||||||||
| ` signers=${a.authorizedSignerCount}`, | ||||||||||||||||||
| ); | ||||||||||||||||||
| if (a.pausedReason) console.log(` reason: ${a.pausedReason}`); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| main().catch((e) => { | ||||||||||||||||||
| console.error('\n' + (e instanceof Error ? e.message : String(e))); | ||||||||||||||||||
| process.exit(1); | ||||||||||||||||||
| }); | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| { | ||
| "name": "guardian-operator-cli", | ||
| "version": "0.1.0", | ||
| "private": true, | ||
| "type": "module", | ||
| "scripts": { | ||
| "start": "tsx list-accounts.ts", | ||
| "typecheck": "tsc --noEmit" | ||
| }, | ||
| "dependencies": { | ||
| "@miden-sdk/miden-sdk": "0.15.7", | ||
| "@openzeppelin/guardian-operator-client": "file:../../packages/guardian-operator-client" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "^22.10.1", | ||
| "tsx": "^4.19.2", | ||
| "typescript": "^5.4.0" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| { | ||
| "compilerOptions": { | ||
| "target": "ES2022", | ||
| "lib": ["ES2022"], | ||
| "allowJs": false, | ||
| "skipLibCheck": true, | ||
| "esModuleInterop": true, | ||
| "allowSyntheticDefaultImports": true, | ||
| "strict": true, | ||
| "forceConsistentCasingInFileNames": true, | ||
| "module": "ESNext", | ||
| "moduleResolution": "Bundler", | ||
| "resolveJsonModule": true, | ||
| "isolatedModules": true, | ||
| "noEmit": true | ||
| }, | ||
| "include": ["*.ts"] | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Update the Grafana port references.
Grafana now binds to
127.0.0.1:3002:3000, but Lines 73 and 80 still refer to port3001. Users following the instructions will open the wrong URL. Change those references to3002.🤖 Prompt for AI Agents