Compare commits
17 Commits
e4ad2e0475
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a9c18f5a5 | |||
| 98dca237f4 | |||
| f2b97d4820 | |||
| 6ff604729e | |||
| ed7c1b0a0f | |||
| ddbdf468e3 | |||
| 773d7d50bd | |||
| 0f77ad534e | |||
| 5699bf7259 | |||
| 6877bc7202 | |||
| 6ad7202fdf | |||
| 1bd000e595 | |||
| 912e85fbf7 | |||
| 5166f12775 | |||
| 6693213b02 | |||
| 0dbccee979 | |||
| b56f7def02 |
@@ -7,6 +7,24 @@
|
||||
"source": "./plugins/taiga-auto-sync",
|
||||
"description": "Injects IT Infra & Ops Taiga tracking rules on every prompt.",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
{
|
||||
"name": "openbao-session",
|
||||
"source": "./plugins/openbao-session",
|
||||
"description": "Provisions a short-lived OpenBao session token for interactive agents.",
|
||||
"version": "0.1.3"
|
||||
},
|
||||
{
|
||||
"name": "shared-vault-sync",
|
||||
"source": "./plugins/shared-vault-sync",
|
||||
"description": "Safely pulls the shared vault at session start.",
|
||||
"version": "0.1.2"
|
||||
},
|
||||
{
|
||||
"name": "infra-ops-toolkit",
|
||||
"source": "./plugins/infra-ops-toolkit",
|
||||
"description": "Topology diagram building, as-is HTML→PDF export, and Teleport resource onboarding skills.",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,12 +1,56 @@
|
||||
{
|
||||
"name": "infra-plugins",
|
||||
"interface": { "displayName": "Infra Plugins" },
|
||||
"interface": {
|
||||
"displayName": "Infra Plugins"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "taiga-auto-sync",
|
||||
"source": { "source": "local", "path": "./plugins/taiga-auto-sync" },
|
||||
"policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" },
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/taiga-auto-sync"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
},
|
||||
{
|
||||
"name": "openbao-session",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/openbao-session"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Security"
|
||||
},
|
||||
{
|
||||
"name": "shared-vault-sync",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/shared-vault-sync"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
},
|
||||
{
|
||||
"name": "infra-ops-toolkit",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/infra-ops-toolkit"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "DevOps"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
17
plugins/infra-ops-toolkit/.claude-plugin/plugin.json
Normal file
17
plugins/infra-ops-toolkit/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "infra-ops-toolkit",
|
||||
"version": "0.1.0",
|
||||
"description": "Skills for building the infra access topology diagram, exporting any HTML page to PDF as-displayed, and onboarding new resources into Teleport.",
|
||||
"author": { "name": "Senkensha" },
|
||||
"interface": {
|
||||
"displayName": "Infra Ops Toolkit",
|
||||
"shortDescription": "Topology diagram, as-is HTML→PDF export, and Teleport onboarding skills.",
|
||||
"longDescription": "Three skills distilled from real MBU Group infrastructure work: (1) topology-diagram — build/update the light-theme HTML+SVG access topology and its drawio mirror from live-verified doctl/aws/tsh data; (2) html-to-pdf — export any HTML page to a PDF that matches the live browser view exactly, using headless Chromium in screen mode with a content-sized page instead of the browser's fixed-paper print dialog; (3) teleport-onboard — onboard a new VPS, EC2 host, container-scoped app login, or database into a Teleport cluster following the team's established least-privilege patterns.",
|
||||
"developerName": "Senkensha",
|
||||
"category": "DevOps",
|
||||
"capabilities": [
|
||||
"Skills"
|
||||
],
|
||||
"defaultPrompt": "Help me update the infra topology diagram."
|
||||
}
|
||||
}
|
||||
6
plugins/infra-ops-toolkit/.codex-plugin/plugin.json
Normal file
6
plugins/infra-ops-toolkit/.codex-plugin/plugin.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "infra-ops-toolkit",
|
||||
"version": "0.1.0+codex.20260908000000",
|
||||
"description": "Skills for building the infra access topology diagram, exporting any HTML page to PDF as-displayed, and onboarding new resources into Teleport.",
|
||||
"author": { "name": "Senkensha" }
|
||||
}
|
||||
11
plugins/infra-ops-toolkit/README.md
Normal file
11
plugins/infra-ops-toolkit/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# infra-ops-toolkit
|
||||
|
||||
Three skills distilled from real infrastructure work, for anyone building infra documentation or onboarding resources into Teleport:
|
||||
|
||||
- **topology-diagram** — build/update a light-theme HTML+SVG access topology diagram (mirrored into a `.drawio.xml`), always re-verifying every IP/spec/capacity against live sources (`doctl`, `aws`, `tsh`) instead of trusting the diagram's own prior state.
|
||||
- **html-to-pdf** — export any HTML page to a PDF that matches its live browser rendering exactly, using headless Chromium in screen mode with a content-sized page, instead of fighting a browser's fixed-paper Print dialog.
|
||||
- **teleport-onboard** — onboard a new VPS, EC2 host, container-scoped app login, or database into a Teleport cluster by discovering and mirroring the team's existing patterns (forced-login-shell wrapper scripts, scoped sudoers, `tctl`-created roles) rather than inventing a new access model.
|
||||
|
||||
## Install
|
||||
|
||||
Install `infra-ops-toolkit` from the `infra-plugins` marketplace. Skills activate automatically when a prompt matches their description, or invoke directly: `/topology-diagram`, `/html-to-pdf`, `/teleport-onboard`.
|
||||
75
plugins/infra-ops-toolkit/skills/html-to-pdf/SKILL.md
Normal file
75
plugins/infra-ops-toolkit/skills/html-to-pdf/SKILL.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: html-to-pdf
|
||||
description: Export a local HTML file to a PDF that looks exactly like the live browser rendering — no shrink-to-fit distortion, no forced page splits, no vanished sticky/grid elements. Use when the user says a browser's own Print-to-PDF/Print dialog produced a broken, tiny, or wrongly-paginated result and they want the PDF "as-is / seperti yang tampil di browser" instead of a print-reflowed version.
|
||||
---
|
||||
|
||||
# HTML → PDF, exactly as displayed
|
||||
|
||||
## Why the browser's own Print dialog fails for this
|
||||
|
||||
A system print dialog always targets a **fixed paper size** (Letter/A4). For any page wider than ~800px of real content — a multi-column CSS Grid layout, a wide diagram — the browser either shrinks the *entire* page to fit one sheet (readable content becomes a postage stamp) or splits it across pages in ways that don't match what's on screen. Two extra failure modes compound this:
|
||||
- `position: sticky` elements often just vanish or mis-render under the print media type.
|
||||
- If the page has its own `@media print` rules (e.g. for a *deliberately* paginated print layout), those override the normal on-screen styling — which is the opposite of what "as-is" means here.
|
||||
|
||||
Neither "no print CSS at all" nor "add print CSS to force page-breaks" gives you the *live browser view* as a PDF. The actual fix is to render with a **page size equal to the content's own dimensions**, which no manual print dialog lets you set.
|
||||
|
||||
## The fix: headless Chromium in screen mode, content-sized page
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/pdfgen && cd /tmp/pdfgen
|
||||
npm init -y >/dev/null 2>&1
|
||||
npm install puppeteer # bundles a compatible Chromium — no separate browser install needed
|
||||
```
|
||||
|
||||
```js
|
||||
// render.js
|
||||
const puppeteer = require('puppeteer');
|
||||
const path = require('path');
|
||||
|
||||
(async () => {
|
||||
const srcPath = '/absolute/path/to/source.html';
|
||||
const outPath = '/absolute/path/to/output.pdf';
|
||||
|
||||
const browser = await puppeteer.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Match the viewport width the page's CSS was actually designed around
|
||||
// (check its max-width / .page container, not an arbitrary guess).
|
||||
await page.setViewport({ width: 1940, height: 1200, deviceScaleFactor: 2 });
|
||||
await page.goto('file://' + srcPath, { waitUntil: 'networkidle0' });
|
||||
|
||||
// The critical line: force normal on-screen styles, ignoring any
|
||||
// @media print rules the page might define for its own purposes.
|
||||
await page.emulateMediaType('screen');
|
||||
|
||||
// Measure the real rendered size so the PDF page is exactly that size —
|
||||
// one continuous page, nothing scaled, nothing split.
|
||||
const { width, height } = await page.evaluate(() => {
|
||||
const el = document.querySelector('.page') || document.body;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return { width: Math.ceil(rect.width), height: Math.ceil(document.documentElement.scrollHeight) };
|
||||
});
|
||||
|
||||
await page.pdf({
|
||||
path: outPath,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
printBackground: true, // otherwise CSS background colors silently disappear
|
||||
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
console.log('Saved:', outPath, width, 'x', height);
|
||||
})().catch(err => { console.error(err); process.exit(1); });
|
||||
```
|
||||
|
||||
```bash
|
||||
node render.js
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `printBackground: true` is easy to forget and its absence is easy to misdiagnose as "the PDF looks washed out" rather than "backgrounds are just missing."
|
||||
- If the page measures its own scroll height *before* web fonts finish loading, dimensions can be slightly off — `waitUntil: 'networkidle0'` on `goto` covers most cases; add an explicit `page.evaluateHandle('document.fonts.ready')` await first if fonts still look unloaded in the output.
|
||||
- This same technique works for any "I want the PDF to look like the page, not like a printout" request — it's not specific to any one diagram or document.
|
||||
- Verify the result before calling it done: read the generated PDF back (most tools can render/preview a PDF's first page) and compare it against the live browser view side by side, don't just trust that `page.pdf()` succeeded without error.
|
||||
144
plugins/infra-ops-toolkit/skills/teleport-onboard/SKILL.md
Normal file
144
plugins/infra-ops-toolkit/skills/teleport-onboard/SKILL.md
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
name: teleport-onboard
|
||||
description: Onboard a new resource into a Teleport cluster — a VPS/droplet, an EC2 (or any fresh VM) host, a container-scoped least-privilege app login on a multi-app host, or a database (db_service) — following an existing team's established patterns rather than inventing a new access model. Use when asked to register, join, or onboard something into Teleport, add scoped/restricted access to a specific app or container, or when Teleport resources (nodes/databases/roles) don't match what's actually running.
|
||||
---
|
||||
|
||||
# Teleport resource onboarding
|
||||
|
||||
## First: discover the existing pattern, don't assume one
|
||||
|
||||
Before creating anything, SSH into a host that's already onboarded the way you intend to onboard the new one, and read what's actually there:
|
||||
```bash
|
||||
cat /etc/passwd # look for non-standard login shells — a sign of scoped access
|
||||
sudo cat /etc/sudoers.d/<candidate-user> # the exact commands that login is allowed to run
|
||||
sudo cat /etc/teleport.yaml # or find it: /opt/teleport/config/, /etc/teleport/ — it varies
|
||||
ps aux | grep teleport # more than one teleport process on a host is a real bug (see gotcha below)
|
||||
```
|
||||
Do not invent a new access model if one already exists — mirror it exactly, including its exact `sudoers` syntax, its wrapper-script conventions, and its Teleport role shape.
|
||||
|
||||
**Where `tctl` actually runs**: the cluster's auth server is often just one specific host's Docker container (look for an image like `teleport-distroless` in `docker ps`), not a separate admin machine. `sudo docker exec teleport tctl ...` gives full cluster admin without a separate tctl binary or identity file — check for this before assuming you need new credentials.
|
||||
|
||||
## Pattern A — container-scoped forced-login-shell (multi-app host, least privilege per app)
|
||||
|
||||
Use when one host runs several unrelated apps as containers and different people need access to *only their own* app's container, never the host shell or a sibling's container.
|
||||
|
||||
1. **Wrapper script** `/usr/local/bin/enter-<app>.sh` (root:root, `0755`):
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
# Forced login shell — must never fall back to a real host shell.
|
||||
if [ ! -t 0 ]; then echo "Interactive TTY required." >&2; exit 1; fi
|
||||
echo "=== <app> - container access ==="
|
||||
echo "1) <container-a>"
|
||||
echo "2) <container-b>"
|
||||
read -rp "Pilih [1-2]: " choice
|
||||
case "$choice" in
|
||||
1) exec sudo /usr/bin/docker exec -it <container-a> sh ;;
|
||||
2) exec sudo /usr/bin/docker exec -it <container-b> sh ;;
|
||||
*) echo "Pilihan tidak valid."; exit 1 ;;
|
||||
esac
|
||||
```
|
||||
One numbered menu entry per container this login may reach. Never a generic `docker exec -it "$1"` — the container name must be hard-coded per case, or `sudoers` command-matching below becomes meaningless.
|
||||
|
||||
2. **OS user**: `useradd -m -s /usr/local/bin/enter-<app>.sh <login>` — mind the **32-character Linux username limit** (`useradd: invalid user name` is exactly that error); shorten rather than truncate blindly so the name stays meaningful.
|
||||
|
||||
3. **Sudoers**, one line per allowed container, no wildcards:
|
||||
```
|
||||
<login> ALL=(root) NOPASSWD: /usr/bin/docker exec -it <container-a> sh
|
||||
<login> ALL=(root) NOPASSWD: /usr/bin/docker exec -it <container-b> sh
|
||||
```
|
||||
Write to `/etc/sudoers.d/<login>`, `chmod 440`, and **always** `visudo -c -f <file>` before trusting it — a syntax error here can be silent until someone tries to log in, or can break sudo more broadly if written wrong.
|
||||
|
||||
4. **Teleport role** (via `tctl create -f`, from wherever the auth server actually runs):
|
||||
```yaml
|
||||
kind: role
|
||||
version: v7
|
||||
metadata:
|
||||
name: access-<app>
|
||||
spec:
|
||||
allow:
|
||||
logins: ["<login>"]
|
||||
node_labels: {"*": "*"} # or {name: ["<Exact Droplet Display Name>"]} to scope to one specific host
|
||||
```
|
||||
Check how existing analogous roles are assigned (`tctl get users --format=json`) — new roles usually get created *unassigned*, with assignment to a real person happening later as a separate, explicit step. Don't assume you should assign it to anyone.
|
||||
|
||||
5. **Verify** — don't just trust that the files look right:
|
||||
```bash
|
||||
sudo -l -U <login> # must show exactly the intended docker exec commands, nothing else
|
||||
```
|
||||
Also spot-check that `sh` actually exists in each target container image (`docker exec <container> sh -c 'echo ok'`) — a container built on a shell-less base image will make the wrapper script fail at the worst time.
|
||||
|
||||
## Pattern B — single-app host (simpler, no wrapper needed)
|
||||
|
||||
When a host only runs one app's containers, there's nothing to scope *within* the host — a plain restricted login is enough:
|
||||
```bash
|
||||
useradd -m -s /bin/bash -G docker,adm <login> # docker group for the app's containers, adm for logs
|
||||
```
|
||||
No sudoers file, no wrapper script. Confirm this is really the pattern in use (Pattern A hosts and Pattern B hosts can coexist across a fleet) before picking one over the other.
|
||||
|
||||
## Pattern C — onboarding a brand-new host (EC2 or any fresh VM)
|
||||
|
||||
1. Bootstrap plain SSH access first — the host isn't in Teleport yet, so Teleport can't get you in. Try available keys, confirm passwordless sudo:
|
||||
```bash
|
||||
ssh -i ~/.ssh/<key> <user>@<host-ip> "sudo -n true && echo ok"
|
||||
```
|
||||
2. Install the Teleport agent at the **same major.minor version as the cluster** (check with `tctl status` first):
|
||||
```bash
|
||||
curl https://apt.releases.teleport.dev/gpg -o /tmp/teleport-pubkey.asc
|
||||
sudo tee /etc/apt/keyrings/teleport-archive-keyring.asc < /tmp/teleport-pubkey.asc
|
||||
echo "deb [signed-by=/etc/apt/keyrings/teleport-archive-keyring.asc] https://apt.releases.teleport.dev/ubuntu $(. /etc/os-release; echo $VERSION_CODENAME) stable/v<MAJOR>" | sudo tee /etc/apt/sources.list.d/teleport.list
|
||||
sudo apt-get update -qq && sudo apt-get install -y teleport
|
||||
```
|
||||
3. Generate a short-lived join token from the auth server:
|
||||
```bash
|
||||
tctl tokens add --type=node,db --ttl=15m --format=json
|
||||
```
|
||||
4. Write `/etc/teleport.yaml` on the new host:
|
||||
```yaml
|
||||
version: v3
|
||||
teleport:
|
||||
nodename: <display-name>
|
||||
data_dir: /var/lib/teleport
|
||||
proxy_server: <proxy>:443
|
||||
join_params: { token_name: "<token>", method: token }
|
||||
auth_service: { enabled: "no" }
|
||||
proxy_service: { enabled: "no" }
|
||||
ssh_service:
|
||||
enabled: "yes"
|
||||
labels: { name: "<Display Name>", tier: infra, provider: <aws|...> }
|
||||
```
|
||||
Add a `db_service` block too if this host also fronts a database (see Pattern D).
|
||||
5. `sudo systemctl enable teleport && sudo systemctl start teleport`, then confirm from your own client — `tsh ls` should show the new node within seconds.
|
||||
6. Also create the broad host-level login (`devops`/whatever the fleet convention is, with passwordless sudo) on the new host so it inherits the same team-wide access as every other host — a new scoped role is **additive**, never a replacement for existing broad access. Double-check the login actually exists on the OS (a cloud image may only ship `ubuntu`, not `devops`).
|
||||
|
||||
## Pattern D — registering a database (`db_service`)
|
||||
|
||||
```yaml
|
||||
db_service:
|
||||
enabled: "yes"
|
||||
databases:
|
||||
- name: <name>
|
||||
protocol: postgres # or mysql
|
||||
uri: <host>:<port>
|
||||
tls:
|
||||
mode: verify-full # has a real, verifiable TLS cert (managed/cloud DB)
|
||||
ca_cert_file: /etc/teleport/db-ca/<name>.crt
|
||||
# OR, for a self-hosted DB with no real cert (a plain dev postgres container, etc):
|
||||
# mode: insecure
|
||||
static_labels: { env: prod|dev, tier: infra }
|
||||
```
|
||||
For AWS RDS, download the provider's CA bundle rather than guessing at `insecure` mode:
|
||||
```bash
|
||||
curl -s https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem -o /etc/teleport/db-ca/aws-rds-global-bundle.pem
|
||||
```
|
||||
Restart the agent (`systemctl restart teleport`) after any `teleport.yaml` edit. **If you're SSHed in over Teleport itself, this drops your own session** — that's expected, not a failure; reconnect and check `systemctl is-active teleport` + `journalctl -u teleport -n 40 --no-pager` for real errors.
|
||||
|
||||
## Known gotcha: orphaned teleport processes shadow new resources
|
||||
|
||||
A host can end up running **two teleport processes** — the current systemd-managed one, and an older one left over from a migration (started manually or via an old init method, its config file possibly already deleted, invisible to `systemctl`). The old process keeps heartbeating a stale copy of a resource under the same name, and the auth server can end up not surfacing your freshly-registered same-named resource in `tsh ls` / `tsh db ls` even though your new agent's own log says "started successfully."
|
||||
|
||||
Symptom: a database or node you just configured doesn't show up, no errors anywhere obvious. Before chasing TLS or RBAC theories, check for a second process:
|
||||
```bash
|
||||
ps aux | grep '[t]eleport start'
|
||||
```
|
||||
If found and clearly orphaned (no matching systemd unit, config path that no longer exists), confirm with the user, then stop it (`kill -TERM <pid>`, escalate to `-KILL` only if it doesn't exit) and restart the real service — the resource should appear cleanly.
|
||||
56
plugins/infra-ops-toolkit/skills/topology-diagram/SKILL.md
Normal file
56
plugins/infra-ops-toolkit/skills/topology-diagram/SKILL.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: topology-diagram
|
||||
description: Build or update the MBU Group SSH/database access topology diagram (a light-theme HTML+SVG page mirrored into a .drawio.xml file). Use when asked to visualize, draw, update, or "perbarui" the infra access topology, network diagram, or Teleport topology — including adding a new provider/host/database, correcting IPs or specs, or fixing arrow/layout issues. Always re-verify every value against live infra before writing it; never hand-type an IP, spec, or resource name from memory.
|
||||
---
|
||||
|
||||
# Topology Diagram
|
||||
|
||||
Two files always ship together and must stay in sync:
|
||||
- `topologi-ssh-teleport.html` — the primary, richly-styled artifact (what people actually read)
|
||||
- `topologi-ssh-teleport.drawio.xml` — an editable mirror for the team, generated from the same coordinates/content, one visual generation behind on cosmetic-only details (see "drawio limitations" below)
|
||||
|
||||
Ask the user where these live in their vault/Downloads if unknown — don't assume a path.
|
||||
|
||||
## Golden rule: verify before you draw
|
||||
|
||||
Never write an IP, spec, capacity number, or resource name from memory or from the diagram's own prior state. Before touching the file, pull fresh data from the actual source:
|
||||
- Droplets/VMs: `doctl compute droplet list --format Name,PublicIPv4,PrivateIPv4,Memory,VCPUs,Disk,Region,Status`
|
||||
- Managed databases: `doctl databases list -o json` (get `storage_size_mib`, `size` slug, `num_nodes` — the list view alone omits storage)
|
||||
- AWS compute: `aws ec2 describe-instances` (loop every region if the primary region search comes up empty — check with `aws ec2 describe-regions`)
|
||||
- AWS databases: `aws rds describe-db-instances`
|
||||
- Teleport's own view: `tsh ls`, `tsh db ls -f json`, `tsh app ls` — this is what's *actually* registered, which can differ from what a droplet list implies
|
||||
- Cluster roles/config: `tctl get roles --format=json` (see teleport-onboard skill for where tctl actually runs)
|
||||
|
||||
If a resource shows up in the diagram's current text but you can't re-confirm it from a live source, don't silently trust it — flag it as unverified in the diagram itself (dashed border + a short "perlu verifikasi manual" note) rather than deleting it or leaving it looking equally confident as verified data. This has caught real bugs: a database name that looked like a duplicate turned out to be a distinct self-hosted DB reachable only via `tsh db ls`, not `doctl`.
|
||||
|
||||
## Design system (HTML)
|
||||
|
||||
Everything is driven by CSS custom properties on `:root` — change the palette in one place:
|
||||
```css
|
||||
--bg, --surface, --line, --line-soft, --text, --text-dim, --text-faint /* neutrals */
|
||||
--accent /* the single ingress chokepoint (e.g. the proxy/gateway) — spend this color deliberately, once */
|
||||
--user, --core, --agent, --do, --db, --wire, --legacy, --aws /* one hue per semantic category, reused everywhere: node icon, node border stripe, matching edges, zone eyebrow labels */
|
||||
```
|
||||
Pick a light or dark ground deliberately; light was chosen last for readability and print/PDF friendliness. Typography: a display sans (Archivo) for titles, a mono face (JetBrains Mono) for every IP/port/command/capacity number — the mono choice is not decorative, it's because the subject is literally terminal/IP data.
|
||||
|
||||
**Node card pattern** (repeated ~30×): a `<path>` (not `<rect>`) drawing a rectangle rounded *only on the right* (left corners stay square, matching the left accent stripe):
|
||||
```
|
||||
M{x},{y} L{x2-r},{y} A{r},{r} 0 0 1 {x2},{y+r} L{x2},{y2-r} A{r},{r} 0 0 1 {x2-r},{y2} L{x},{y2} Z
|
||||
```
|
||||
plus a thin `<rect>` (`width:3-4, rx:0`) at the same x/y as the left accent stripe in the category color, plus a `<foreignObject>` holding real HTML (icon svg + `<b>` title + `.n-meta` lines, one of which is usually `.faint` for the least-important line). foreignObject is what makes rich HTML-in-SVG possible at all — don't try to hand-roll the same layout with plain `<text>` elements.
|
||||
|
||||
**Zone headers**: a small mono eyebrow label + a thin `<line>` rule spanning the full content width — not a big tinted background band (tried first, looked muddy). **Provider containers** (DigitalOcean / Biznet / AWS): white fill, 2px border in that provider's category color, not a pastel fill.
|
||||
|
||||
**Edges**: orthogonal `<path>`s in the category color of what they represent (not what they connect), each with an arrow `<marker>` in `<defs>`. Labels are centered pills: wrap the label text in a flex-centered `<div>` inside a `<foreignObject>` sized/positioned at the edge's true midpoint, styled as `border-radius:999px; background:var(--surface); border:1px solid var(--line);` — this reads as a chip floating on the line, not text mashed into the background.
|
||||
|
||||
**Layout discipline**: when adding a new column (a new provider, a new agent lane), recenter it under its own container and re-run the numbers for every sibling that shares a header divider or trunk edge — don't leave one column narrower/off-center while others got the treatment. Widen the `viewBox` and extend header `<line>` rules to the new right margin; don't leave them stopping short.
|
||||
|
||||
**Print/PDF**: add an `@media print` block (`@page{size:landscape}`, `.layout{display:block}`, `page-break-after` on the diagram frame) so a *browser's own* print dialog doesn't crush a wide multi-column layout into an unreadable thumbnail. But if the user wants a PDF that matches the *on-screen* view exactly (not a print-reflowed one), that's a different tool — see the `html-to-pdf` skill instead of fighting the print dialog further.
|
||||
|
||||
## drawio mirror
|
||||
|
||||
drawio's `mxCell` style language can't do per-corner rounding, arbitrary SVG icon paths, or CSS-variable-driven theming. Don't hand-edit 30+ `mxCell` blocks for a structural change — write a small Python generator with helper functions (`node()`, `container()`, `edge()`, `eyebrow()`) that takes the *same* coordinate/color/text data as the HTML and emits the XML. This is far less error-prone than manual edits and keeps the two files derivable from one source of truth even though they're not literally the same code.
|
||||
|
||||
Known, accepted gaps versus the HTML (state these to the user rather than silently mismatching): symmetric `arcSize` rounding on all four corners (not right-only), no custom icons (title + category color + left stripe carries the scanability instead), edge labels approximated via a child "edgeLabel" vertex with `rounded=1;arcSize=100` for the pill look.
|
||||
|
||||
After any edit to either file: validate XML with `python3 -c "import xml.etree.ElementTree as ET; ET.parse('file.xml')"`, check `<foreignObject>`/`<g>` tag balance in the HTML, then `open <path>` to eyeball it in a browser before calling it done.
|
||||
7
plugins/openbao-session/.claude-plugin/plugin.json
Normal file
7
plugins/openbao-session/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "openbao-session",
|
||||
"version": "0.1.3",
|
||||
"description": "Provisions a short-lived OpenBao session token for interactive agent sessions.",
|
||||
"author": { "name": "Senkensha" },
|
||||
"hooks": "./hooks/claude-codex-hooks.json"
|
||||
}
|
||||
20
plugins/openbao-session/.codex-plugin/plugin.json
Normal file
20
plugins/openbao-session/.codex-plugin/plugin.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "openbao-session",
|
||||
"version": "0.1.3+codex.20260901113700",
|
||||
"description": "Provisions a short-lived OpenBao session token for interactive agent sessions.",
|
||||
"author": {
|
||||
"name": "Local developer"
|
||||
},
|
||||
"hooks": "./hooks/claude-codex-hooks.json",
|
||||
"interface": {
|
||||
"displayName": "OpenBao Session",
|
||||
"shortDescription": "Automatic OpenBao session token provisioning.",
|
||||
"longDescription": "At session start, opens a loopback SSH tunnel to OpenBao and injects a short-lived, scoped session token so the agent can read only the secrets its policy permits.",
|
||||
"developerName": "Local developer",
|
||||
"category": "Security",
|
||||
"capabilities": [
|
||||
"Lifecycle hooks"
|
||||
],
|
||||
"defaultPrompt": "Help me use OpenBao Session."
|
||||
}
|
||||
}
|
||||
112
plugins/openbao-session/README.md
Normal file
112
plugins/openbao-session/README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# openbao-session
|
||||
|
||||
Hook `SessionStart` untuk Claude Code dan Codex yang menyuntikkan **token OpenBao berumur pendek** ke lingkungan sesi agent. Dengan ini agent tidak perlu memegang credential statis — setiap sesi mendapat token baru yang scoped dan kadaluarsa sendiri.
|
||||
|
||||
## Cara kerja
|
||||
|
||||
Saat sesi mulai, `hooks/bao-session.sh`:
|
||||
1. Memastikan **SSH tunnel loopback** ke listener VPC privat OpenBao hidup (`127.0.0.1:8200 → 10.104.0.12:8200` lewat jumphost `mbu-backup-jumphost`).
|
||||
2. Mengambil CA cert OpenBao (`/opt/openbao/tls/tls.crt`) dan menyimpannya lokal.
|
||||
3. Membaca **bootstrap credential** dari secrets store lokal (`manage.py get openbao bootstrap_token`).
|
||||
4. **Mint token sesi** `claude-session` (TTL `4h`, renewable) memakai bootstrap credential sebagai caller; bila `OPENBAO_SESSION_ENTITY` diset, token dikaitkan ke entity pemakai (`-entity-alias`) sehingga `{{identity.entity.id}}` resolve dan sesi dapat membaca `humans/self`.
|
||||
5. Menyuntikkan `OPENBAO_TOKEN`, `BAO_ADDR`, `BAO_CACERT`, `BAO_TLS_SERVER_NAME` ke sesi:
|
||||
|
||||
| Host | Mekanisme injeksi |
|
||||
|---|---|
|
||||
| **Claude Code** | Menulis `export OPENBAO_TOKEN=...` ke **`$CLAUDE_ENV_FILE`** — file yang di-source Claude Code ke setiap command Bash sesi itu. |
|
||||
| **Codex / host lain** | Hook Codex **tidak bisa persist env** (proses hook terpisah). Skrip menulis session env file (mode 600) dan memberitahu agent lewat `additionalContext` untuk `source` file itu — **nilai token tidak pernah masuk konteks model**. |
|
||||
|
||||
Token sesi cuma bisa membaca secret yang diizinkan policy `claude-session` (bukan semua). Semua akses tercatat di audit OpenBao.
|
||||
|
||||
## Prasyarat sekali-jalan (platform admin)
|
||||
|
||||
Dengan token admin di jumphost (lihat [OpenBao Production Operations and Recovery](../../../Areas/DevOps/Notes/Runbooks/OpenBao%20Production%20Operations%20and%20Recovery.md)):
|
||||
|
||||
```bash
|
||||
export BAO_ADDR=https://10.104.0.12:8200
|
||||
export BAO_CACERT=/opt/openbao/tls/tls.crt
|
||||
export BAO_TOKEN=<token admin Anda> # jangan di-paste ke chat
|
||||
|
||||
# 1. Policy bootstrap: bisa membuat token, TIDAK bisa membaca secret.
|
||||
# Wajib menyertakan auth/token/create/* agar pembuatan token via -role (entity-alias) diizinkan.
|
||||
cat > /tmp/openbao-session-creator.hcl <<'EOF'
|
||||
path "auth/token/create" {
|
||||
capabilities = ["create", "update", "sudo"]
|
||||
}
|
||||
path "auth/token/create/*" {
|
||||
capabilities = ["create", "update", "sudo"]
|
||||
}
|
||||
path "auth/token/renew-self" { capabilities = ["update"] }
|
||||
path "auth/token/revoke-self" { capabilities = ["update"] }
|
||||
path "auth/token/lookup-self" { capabilities = ["read"] }
|
||||
EOF
|
||||
bao policy write openbao-session-creator /tmp/openbao-session-creator.hcl
|
||||
|
||||
# 2. Policy sesi: baca-only untuk secret yang memang di-assign ke agent.
|
||||
# humans/self otomatis ter-scope ke entity pemakai via {{identity.entity.id}}
|
||||
cat > /tmp/claude-session.hcl <<'EOF'
|
||||
# Refine daftar path di sini sesuai owner/consumer assignment.
|
||||
path "shared/data/*" { capabilities = ["read", "list"] }
|
||||
path "shared/metadata/*" { capabilities = ["read", "list"] }
|
||||
path "humans/data/{{identity.entity.id}}/*" { capabilities = ["read", "list"] }
|
||||
path "humans/metadata/{{identity.entity.id}}/*" { capabilities = ["read", "list"] }
|
||||
EOF
|
||||
bao policy write claude-session /tmp/claude-session.hcl
|
||||
|
||||
# 3. Token roles (wajib utk -entity-alias). Perluas allowed_entity_aliases sesuai tim.
|
||||
bao write auth/token/roles/openbao-session \
|
||||
allowed_policies=claude-session \
|
||||
allowed_entity_aliases=adnan,rizal \
|
||||
ttl=4h renewable=true
|
||||
|
||||
# 4. Issue bootstrap token (TTL panjang, renewable) dan simpan NILAINYA ke secrets store LOKAL:
|
||||
bao token create -policy=openbao-session-creator -ttl=720h -renewable \
|
||||
-display-name=openbao-session-bootstrap -format=json
|
||||
# → ambil auth.client_token, lalu di Mac Anda:
|
||||
python3 ~/.config/devops-secrets/manage.py set openbao bootstrap_token --description "OpenBao session bootstrap (creator)"
|
||||
```
|
||||
|
||||
> Bootstrap credential ini **kuat** (bisa mint token policy apa pun) tapi **tidak bisa membaca nilai secret**.
|
||||
> Karena itu simpan hanya di secrets store lokal (`~/.config/devops-secrets`, mode 700/600), jangan di vault/repo/chat.
|
||||
> Upgrade ke AppRole atau OIDC saat identity provider disetujui.
|
||||
|
||||
## Instalasi
|
||||
|
||||
### Claude Code
|
||||
- Daftarkan marketplace (jika belum): `claude plugin marketplace add infra-plugins https://git.senkensha.space/senkensha/infra-plugins.git`
|
||||
- Pasang: `claude plugin install openbao-session@infra-plugins`
|
||||
- Konfirmasi hook aktif: buka sesi baru, lihat status message "Provisioning OpenBao session token...", lalu `echo ${OPENBAO_TOKEN:+SET}`.
|
||||
|
||||
### Codex
|
||||
- `codex plugin install openbao-session@infra-plugins` (marketplace sudah terdaftar sebagai `infra-plugins`).
|
||||
|
||||
## Konfigurasi (opsional, via env)
|
||||
|
||||
| Variable | Default | Keterangan |
|
||||
|---|---|---|
|
||||
| `OPENBAO_SESSION_CONF_DIR` | `~/.config/openbao` | dir cache CA cert + binary bao |
|
||||
| `OPENBAO_SESSION_LOCAL_PORT` | `8200` | port tunnel lokal |
|
||||
| `OPENBAO_SESSION_VPC_ADDR` | `10.104.0.12` | alamat VPC listener OpenBao |
|
||||
| `OPENBAO_SESSION_DROPLET` | `mbu-backup-jumphost` | droplet jumphost |
|
||||
| `OPENBAO_SESSION_HOST` | *(kosong)* | public IP jumphost eksplisit (default: resolve via doctl) |
|
||||
| `OPENBAO_SESSION_SSH_KEY` | `~/.ssh/id_ed25519` | key SSH ke jumphost |
|
||||
| `OPENBAO_SESSION_POLICY` | `claude-session` | policy token sesi |
|
||||
| `OPENBAO_SESSION_TTL` | `4h` | umur token sesi |
|
||||
| `OPENBAO_SESSION_ENTITY` | *(kosong)* | nama entity OpenBao pemakai (mis. `adnan`, `rizal`) → token sesi diikat ke entity (aktifkan `humans/self`) |
|
||||
| `OPENBAO_SESSION_ROLE` | *(kosong)* | token role utk sesi (mis. `openbao-session`). Wajib diisi jika `OPENBAO_SESSION_ENTITY` diset (`-entity-alias` hanya bekerja dgn `-role`) |
|
||||
| `OPENBAO_SESSION_BAO_VER` | `2.6.2` | versi binary bao (auto-download jika belum ada) |
|
||||
| `OPENBAO_SESSION_SECRETS_HELPER` | `~/.config/devops-secrets/manage.py` | helper baca bootstrap |
|
||||
| `OPENBAO_SESSION_BOOTSTRAP_TOOL` / `_KEY` | `openbao` / `bootstrap_token` | lokasi bootstrap di secrets store |
|
||||
|
||||
## Keamanan
|
||||
|
||||
- Nilai secret tidak pernah ditulis ke repo, chat, atau log; token sesi mengalir lewat `$CLAUDE_ENV_FILE` (Claude) atau session env file mode 600 (Codex), bukan argv dan bukan ke konteks model.
|
||||
- Token sesi **short-lived** (`4h`, renewable) dan **scoped** ke policy `claude-session` saja.
|
||||
- `humans/self` hanya terbaca lewat token yang **terikat entity pemakai** (`{{identity.entity.id}}`) — sesi orang lain tidak bisa membaca ruang pribadi Anda.
|
||||
- Bootstrap credential tidak bisa membaca secret, tapi bisa mint token → rotasi bila dicurigai bocor: buat bootstrap baru, `manage.py set`, revoke yang lama (`bao token revoke <accessor>`).
|
||||
- Tunnel hanya bind `127.0.0.1`; jangan pernah expose port publik atau pakai TLS skip-verify.
|
||||
|
||||
## Related
|
||||
|
||||
- [OpenBao Secret Taxonomy and Migration](../../../Areas/DevOps/Credentials/OpenBao%20Secret%20Taxonomy%20and%20Migration.md)
|
||||
- [OpenBao Production Operations and Recovery](../../../Areas/DevOps/Notes/Runbooks/OpenBao%20Production%20Operations%20and%20Recovery.md)
|
||||
173
plugins/openbao-session/hooks/bao-session.sh
Executable file
173
plugins/openbao-session/hooks/bao-session.sh
Executable file
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
# openbao-session — SessionStart hook.
|
||||
#
|
||||
# Provisions a short-lived, policy-scoped OpenBao token for this interactive
|
||||
# agent session. At session start it:
|
||||
# 1. ensures the loopback SSH tunnel to the OpenBao private VPC listener is up,
|
||||
# 2. caches the OpenBao TLS trust anchor locally,
|
||||
# 3. mints a short-lived session token from a bootstrap credential,
|
||||
# 4. makes it available to the session:
|
||||
# - Claude Code: writes `export OPENBAO_TOKEN=...` (and BAO_ADDR, BAO_CACERT,
|
||||
# BAO_TLS_SERVER_NAME) into $CLAUDE_ENV_FILE, which Claude Code sources
|
||||
# into every subsequent Bash command of the session.
|
||||
# - Other hosts (e.g. Codex), which cannot persist env from a hook: writes a
|
||||
# session-scoped env file (mode 600) and points the agent to it via
|
||||
# hookSpecificOutput.additionalContext — never the token value itself.
|
||||
#
|
||||
# Output: a single valid JSON object on stdout. On failure it still emits a
|
||||
# benign `{"continue": true, "suppressOutput": true}` and exits 0 so the session
|
||||
# starts cleanly; diagnostics go to stderr.
|
||||
#
|
||||
# One-time admin prerequisites and security notes: see README.md in this plugin.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
log() { echo "openbao-session: $*" >&2; }
|
||||
|
||||
# ---- configuration (env-overridable, defaults match the current production setup)
|
||||
CONF_DIR="${OPENBAO_SESSION_CONF_DIR:-$HOME/.config/openbao}"
|
||||
BIN_DIR="$CONF_DIR/bin"
|
||||
LOCAL_PORT="${OPENBAO_SESSION_LOCAL_PORT:-8200}"
|
||||
VPC_ADDR="${OPENBAO_SESSION_VPC_ADDR:-10.104.0.12}"
|
||||
DROPLET="${OPENBAO_SESSION_DROPLET:-mbu-backup-jumphost}"
|
||||
SSH_KEY="${OPENBAO_SESSION_SSH_KEY:-$HOME/.ssh/id_ed25519}"
|
||||
HOST="${OPENBAO_SESSION_HOST:-}" # optional explicit public IP
|
||||
TLS_NAME="${OPENBAO_SESSION_TLS_NAME:-mbu-backup-jumphost}"
|
||||
BAO_VER="${OPENBAO_SESSION_BAO_VER:-2.6.2}"
|
||||
SESSION_POLICY="${OPENBAO_SESSION_POLICY:-claude-session}"
|
||||
SESSION_TTL="${OPENBAO_SESSION_TTL:-4h}"
|
||||
SESSION_ENTITY="${OPENBAO_SESSION_ENTITY:-}" # OpenBao entity name; ties the session token to the user (enables humans/self via {{identity.entity.id}})
|
||||
SESSION_ROLE="${OPENBAO_SESSION_ROLE:-}" # token role (required when SESSION_ENTITY is set; entity-alias only works with a role)
|
||||
SECRETS_HELPER="${OPENBAO_SESSION_SECRETS_HELPER:-$HOME/.config/devops-secrets/manage.py}"
|
||||
BOOTSTRAP_TOOL="${OPENBAO_SESSION_BOOTSTRAP_TOOL:-openbao}"
|
||||
BOOTSTRAP_KEY="${OPENBAO_SESSION_BOOTSTRAP_KEY:-bootstrap_token}"
|
||||
CACERT="$CONF_DIR/tls.crt"
|
||||
BAO_BIN=""
|
||||
BAO_ADDR_URL="https://127.0.0.1:${LOCAL_PORT}"
|
||||
SESSION_TOKEN=""
|
||||
|
||||
benign_json() { printf '%s\n' '{"continue": true, "suppressOutput": true}'; }
|
||||
|
||||
ensure_bao() {
|
||||
if command -v bao >/dev/null 2>&1; then BAO_BIN="$(command -v bao)"; return 0; fi
|
||||
if [ -x "$BIN_DIR/bao" ]; then BAO_BIN="$BIN_DIR/bao"; return 0; fi
|
||||
mkdir -p "$BIN_DIR"
|
||||
local os arch asset url
|
||||
os="$(uname -s)"; arch="$(uname -m)"
|
||||
case "$os:$arch" in
|
||||
Darwin:arm64) asset="openbao_${BAO_VER}_darwin_arm64.tar.gz" ;;
|
||||
Darwin:x86_64|Darwin:amd64) asset="openbao_${BAO_VER}_darwin_amd64.tar.gz" ;;
|
||||
Linux:arm64) asset="openbao_${BAO_VER}_linux_arm64.tar.gz" ;;
|
||||
Linux:x86_64|Linux:amd64) asset="openbao_${BAO_VER}_linux_amd64.tar.gz" ;;
|
||||
*) log "unsupported platform: $os/$arch"; return 1 ;;
|
||||
esac
|
||||
url="https://github.com/openbao/openbao/releases/download/v${BAO_VER}/${asset}"
|
||||
curl -fsSL -o "$CONF_DIR/bao.tar.gz" "$url" || { log "failed to download $asset"; return 1; }
|
||||
tar -xzf "$CONF_DIR/bao.tar.gz" -C "$BIN_DIR" bao || { log "failed to extract bao"; return 1; }
|
||||
chmod +x "$BIN_DIR/bao"
|
||||
rm -f "$CONF_DIR/bao.tar.gz"
|
||||
BAO_BIN="$BIN_DIR/bao"
|
||||
}
|
||||
|
||||
resolve_host() {
|
||||
[ -n "$HOST" ] && return 0
|
||||
command -v doctl >/dev/null 2>&1 || { log "doctl missing and OPENBAO_SESSION_HOST unset"; return 1; }
|
||||
HOST="$(doctl compute droplet get "$DROPLET" --format PublicIPv4 --no-header 2>/dev/null | tr -d '[:space:]')"
|
||||
[[ "$HOST" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || { log "cannot resolve $DROPLET public IPv4"; return 1; }
|
||||
}
|
||||
|
||||
ensure_tunnel() {
|
||||
if nc -z 127.0.0.1 "$LOCAL_PORT" >/dev/null 2>&1; then return 0; fi
|
||||
[ -r "$SSH_KEY" ] || { log "SSH key not readable: $SSH_KEY"; return 1; }
|
||||
resolve_host || return 1
|
||||
ssh -f -N -i "$SSH_KEY" -o BatchMode=yes -o ExitOnForwardFailure=yes \
|
||||
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
|
||||
-o StrictHostKeyChecking=accept-new \
|
||||
-L "127.0.0.1:${LOCAL_PORT}:${VPC_ADDR}:8200" "root@${HOST}" || return 1
|
||||
sleep 1
|
||||
nc -z 127.0.0.1 "$LOCAL_PORT" >/dev/null 2>&1 || { log "tunnel did not come up"; return 1; }
|
||||
}
|
||||
|
||||
ensure_cacert() {
|
||||
[ -s "$CACERT" ] && return 0
|
||||
resolve_host || return 1
|
||||
mkdir -p "$CONF_DIR"
|
||||
scp -q -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
|
||||
"root@${HOST}:/opt/openbao/tls/tls.crt" "$CACERT" || { log "failed to fetch CA cert"; return 1; }
|
||||
}
|
||||
|
||||
read_bootstrap() {
|
||||
python3 "$SECRETS_HELPER" get "$BOOTSTRAP_TOOL" "$BOOTSTRAP_KEY" 2>/dev/null \
|
||||
|| { log "bootstrap credential not found: $BOOTSTRAP_TOOL/$BOOTSTRAP_KEY (see README, one-time admin setup)"; return 1; }
|
||||
}
|
||||
|
||||
mint_session_token() {
|
||||
local bootstrap json
|
||||
local -a create_args=()
|
||||
bootstrap="$(read_bootstrap)" || return 1
|
||||
if [ -n "$SESSION_ROLE" ]; then
|
||||
# token role governs policy + allows entity-alias (entity-alias needs a role)
|
||||
create_args+=(-role="$SESSION_ROLE")
|
||||
[ -n "$SESSION_ENTITY" ] && create_args+=(-entity-alias="$SESSION_ENTITY")
|
||||
else
|
||||
create_args+=(-policy="$SESSION_POLICY")
|
||||
fi
|
||||
json="$(BAO_ADDR="$BAO_ADDR_URL" BAO_CACERT="$CACERT" BAO_TLS_SERVER_NAME="$TLS_NAME" BAO_TOKEN="$bootstrap" \
|
||||
"$BAO_BIN" token create "${create_args[@]}" -ttl="$SESSION_TTL" -renewable \
|
||||
-display-name="openbao-session-$(hostname)" -format=json)" || { log "token create failed"; return 1; }
|
||||
SESSION_TOKEN="$(printf '%s' "$json" | jq -r '.auth.client_token // empty')"
|
||||
[ -n "$SESSION_TOKEN" ] || { log "no client_token in token create response"; return 1; }
|
||||
}
|
||||
|
||||
session_id_from_stdin() {
|
||||
local input
|
||||
input="$(cat 2>/dev/null || true)"
|
||||
[ -n "$input" ] && command -v jq >/dev/null 2>&1 \
|
||||
&& printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null
|
||||
}
|
||||
|
||||
write_env_exports() {
|
||||
printf 'export OPENBAO_TOKEN=%q\n' "$SESSION_TOKEN"
|
||||
printf 'export BAO_ADDR=%q\n' "$BAO_ADDR_URL"
|
||||
printf 'export BAO_CACERT=%q\n' "$CACERT"
|
||||
printf 'export BAO_TLS_SERVER_NAME=%q\n' "$TLS_NAME"
|
||||
}
|
||||
|
||||
inject_env() {
|
||||
if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
|
||||
# Claude Code: persist exports for every subsequent Bash command this session.
|
||||
write_env_exports >> "$CLAUDE_ENV_FILE" || { log "failed to write CLAUDE_ENV_FILE"; return 1; }
|
||||
benign_json
|
||||
return 0
|
||||
fi
|
||||
# Other hosts (Codex etc.): hooks cannot persist env; write a session-scoped
|
||||
# env file and point the agent to it. Never put the token value in context.
|
||||
local sid envfile
|
||||
sid="$(session_id_from_stdin)"
|
||||
envfile="${TMPDIR:-/tmp}/openbao-session.env.${sid:-$$}"
|
||||
write_env_exports > "$envfile" || return 1
|
||||
chmod 600 "$envfile"
|
||||
printf '{"continue":true,"suppressOutput":true,"hookSpecificOutput":{"additionalContext":"OpenBao session token ready. In a Bash tool, load it without printing the value: source %s"}}\n' "$envfile"
|
||||
}
|
||||
|
||||
run() {
|
||||
ensure_bao || return 1
|
||||
ensure_tunnel || return 1
|
||||
ensure_cacert || return 1
|
||||
mint_session_token || return 1
|
||||
inject_env || return 1
|
||||
}
|
||||
|
||||
main() {
|
||||
local out rc=0
|
||||
out="$(run)" || rc=1
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
printf '%s\n' "$out"
|
||||
else
|
||||
log "provisioning failed; session continues without an OpenBao session token"
|
||||
benign_json
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
main "$@"
|
||||
16
plugins/openbao-session/hooks/claude-codex-hooks.json
Normal file
16
plugins/openbao-session/hooks/claude-codex-hooks.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/bao-session.sh\"",
|
||||
"timeout": 30,
|
||||
"statusMessage": "Provisioning OpenBao session token..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
7
plugins/shared-vault-sync/.claude-plugin/plugin.json
Normal file
7
plugins/shared-vault-sync/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "shared-vault-sync",
|
||||
"version": "0.1.2",
|
||||
"description": "Safely pulls the shared vault at interactive session start.",
|
||||
"author": { "name": "Senkensha" },
|
||||
"hooks": "./hooks/claude-codex-hooks.json"
|
||||
}
|
||||
20
plugins/shared-vault-sync/.codex-plugin/plugin.json
Normal file
20
plugins/shared-vault-sync/.codex-plugin/plugin.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "shared-vault-sync",
|
||||
"version": "0.1.2+codex.20260901103344",
|
||||
"description": "Safely pulls the shared vault at interactive session start.",
|
||||
"author": {
|
||||
"name": "Local developer"
|
||||
},
|
||||
"hooks": "./hooks/claude-codex-hooks.json",
|
||||
"interface": {
|
||||
"displayName": "Shared Vault Sync",
|
||||
"shortDescription": "Safely pull the shared vault at session start.",
|
||||
"longDescription": "Fast-forwards a clean shared vault at session start without blocking the agent when the network is unavailable.",
|
||||
"developerName": "Local developer",
|
||||
"category": "Productivity",
|
||||
"capabilities": [
|
||||
"Lifecycle hooks"
|
||||
],
|
||||
"defaultPrompt": "Synchronize the shared vault."
|
||||
}
|
||||
}
|
||||
39
plugins/shared-vault-sync/README.md
Normal file
39
plugins/shared-vault-sync/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# shared-vault-sync
|
||||
|
||||
Pulls a clean shared vault at `SessionStart` without blocking Claude Code or Codex when the network is unavailable.
|
||||
|
||||
Target detection, in order:
|
||||
1. `SHARED_VAULT_DIR` — explicit override (e.g. the `devops-shared-vault` clone path).
|
||||
2. `$PWD` — when the session starts inside the shared vault itself.
|
||||
3. `$PWD/Areas/DevOps` — the nested shared subrepo inside the personal vault.
|
||||
|
||||
A directory is treated as the shared vault only if its `AGENTS.md` contains the marker `This is the shared persistent vault` and it is inside a git work tree.
|
||||
|
||||
The hook skips a dirty worktree and uses `git pull --ff-only`; it never rebases, merges, or pushes.
|
||||
|
||||
## Install
|
||||
|
||||
Install `shared-vault-sync` from the `infra-plugins` marketplace, then open a new session to activate its `SessionStart` hook.
|
||||
|
||||
## Auto-push (launchd)
|
||||
|
||||
`scripts/push-shared-vault.sh` mengirim commit lokal ke remote secara **commit-driven**:
|
||||
hanya jika worktree bersih **dan** branch lokal di depan `origin` (fetch dulu). Tidak pernah
|
||||
rebase, force-push, atau resolve conflict; no-op saat offline atau tidak ada yang perlu di-push.
|
||||
|
||||
Setup (macOS launchd, interval 5 menit):
|
||||
|
||||
```bash
|
||||
# 1. pastikan script tersedia di plugin (repo infra-plugins)
|
||||
# 2. buat plist ~/Library/LaunchAgents/com.mbugroup.shared-vault-push.plist:
|
||||
# - ProgramArguments: /bin/bash <infra-plugins>/plugins/shared-vault-sync/scripts/push-shared-vault.sh
|
||||
# - EnvironmentVariables: SHARED_VAULT_DIR=<path ke clone shared vault>
|
||||
# - StartInterval: 300
|
||||
# 3. muat:
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.mbugroup.shared-vault-push.plist
|
||||
launchctl kickstart -k gui/$(id -u)/com.mbugroup.shared-vault-push
|
||||
```
|
||||
|
||||
Log push: `$PUSH_SHARED_VAULT_LOG` (default `/tmp/shared-vault-push.log`), hanya timestamp + jumlah commit.
|
||||
Hook pull tetap menangani sinkronisasi masuk; agent ini menangani arah keluar. Push tetap commit-driven:
|
||||
agent tidak pernah membuat komit, hanya mengirim komit yang sudah Anda buat di `Areas/DevOps`.
|
||||
16
plugins/shared-vault-sync/hooks/claude-codex-hooks.json
Normal file
16
plugins/shared-vault-sync/hooks/claude-codex-hooks.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/pull-shared-vault.sh\"",
|
||||
"timeout": 15,
|
||||
"statusMessage": "Synchronizing shared vault..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
42
plugins/shared-vault-sync/hooks/pull-shared-vault.sh
Executable file
42
plugins/shared-vault-sync/hooks/pull-shared-vault.sh
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pull only the designated shared vault; never block a new agent session.
|
||||
set -u
|
||||
|
||||
emit() {
|
||||
printf '{"continue":true,"suppressOutput":true,"hookSpecificOutput":{"additionalContext":"%s"}}\n' "$1"
|
||||
}
|
||||
|
||||
is_shared_vault() {
|
||||
[ -f "$1/AGENTS.md" ] \
|
||||
&& grep -Fq 'This is the shared persistent vault' "$1/AGENTS.md" \
|
||||
&& git -C "$1" rev-parse --is-inside-work-tree >/dev/null 2>&1
|
||||
}
|
||||
|
||||
detect_vault() {
|
||||
# explicit override wins
|
||||
if [ -n "${SHARED_VAULT_DIR:-}" ]; then
|
||||
is_shared_vault "$SHARED_VAULT_DIR" && { printf '%s' "$SHARED_VAULT_DIR"; return 0; }
|
||||
return 1
|
||||
fi
|
||||
# session started inside the shared vault itself
|
||||
if is_shared_vault "${PWD:-}"; then printf '%s' "${PWD:-}"; return 0; fi
|
||||
# nested shared subrepo relative to the session dir (e.g. personal vault's Areas/DevOps)
|
||||
if is_shared_vault "${PWD:-}/Areas/DevOps"; then printf '%s' "${PWD:-}/Areas/DevOps"; return 0; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
vault_dir="$(detect_vault)" || {
|
||||
emit 'Shared vault sync skipped: no shared vault detected (set SHARED_VAULT_DIR, or start the session from the shared vault or its Areas/DevOps subrepo).'
|
||||
exit 0
|
||||
}
|
||||
|
||||
if [ -n "$(git -C "$vault_dir" status --porcelain)" ]; then
|
||||
emit 'Shared vault sync skipped: the worktree has local changes.'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if GIT_TERMINAL_PROMPT=0 git -C "$vault_dir" pull --ff-only --quiet; then
|
||||
emit 'Shared vault sync completed.'
|
||||
else
|
||||
emit 'Shared vault sync failed; run git pull --ff-only from the vault when it is safe to do so.'
|
||||
fi
|
||||
31
plugins/shared-vault-sync/scripts/push-shared-vault.sh
Executable file
31
plugins/shared-vault-sync/scripts/push-shared-vault.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# push-shared-vault — commit-driven auto-push for the shared vault.
|
||||
#
|
||||
# Safe by design:
|
||||
# - pushes only when the worktree is clean (no uncommitted changes),
|
||||
# - pushes only when the local branch is ahead of its remote (fetch first),
|
||||
# - never rebases, force-pushes, or resolves conflicts,
|
||||
# - silently no-ops when offline or when there is nothing to push.
|
||||
#
|
||||
# Intended to run from launchd or cron at an interval (e.g. every 5 minutes).
|
||||
# Requires: SHARED_VAULT_DIR=<path to the shared vault clone>.
|
||||
|
||||
set -u
|
||||
|
||||
VAULT_DIR="${SHARED_VAULT_DIR:-}"
|
||||
LOG="${PUSH_SHARED_VAULT_LOG:-/tmp/shared-vault-push.log}"
|
||||
|
||||
[ -n "$VAULT_DIR" ] || exit 0
|
||||
|
||||
log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG"; }
|
||||
|
||||
git -C "$VAULT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
||||
[ -z "$(git -C "$VAULT_DIR" status --porcelain)" ] || exit 0
|
||||
git -C "$VAULT_DIR" fetch --quiet origin 2>/dev/null || exit 0
|
||||
branch="$(git -C "$VAULT_DIR" rev-parse --abbrev-ref HEAD)" || exit 0
|
||||
ahead="$(git -C "$VAULT_DIR" rev-list --count "origin/$branch..HEAD" 2>/dev/null || echo 0)"
|
||||
[ "${ahead:-0}" -gt 0 ] || exit 0
|
||||
|
||||
if GIT_TERMINAL_PROMPT=0 git -C "$VAULT_DIR" push origin "$branch"; then
|
||||
log "pushed $ahead commit(s) to origin/$branch"
|
||||
fi
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "taiga-auto-sync",
|
||||
"version": "0.1.0",
|
||||
"description": "Injects IT Infra & Ops Taiga tracking rules on every Claude prompt.",
|
||||
"version": "0.1.0+codex.20260829131410",
|
||||
"description": "Automatically tracks material IT Infra & Ops work and guards Taiga writes.",
|
||||
"author": { "name": "Senkensha" },
|
||||
"hooks": "./hooks/claude-codex-hooks.json"
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
{
|
||||
"name": "taiga-auto-sync",
|
||||
"version": "0.1.0",
|
||||
"description": "Injects Taiga tracking rules on every Codex prompt.",
|
||||
"version": "0.1.0+codex.20260829131410",
|
||||
"description": "Automatically tracks material IT Infra & Ops work and guards Taiga writes.",
|
||||
"author": {
|
||||
"name": "Local developer"
|
||||
},
|
||||
"hooks": "./hooks/claude-codex-hooks.json",
|
||||
"skills": "./skills/",
|
||||
"interface": {
|
||||
"displayName": "Taiga Auto Sync",
|
||||
"shortDescription": "Automatic Taiga tracking reminder.",
|
||||
"longDescription": "Injects the IT Infra & Ops Taiga contract before each prompt.",
|
||||
"shortDescription": "Automatic Infra/Ops Taiga tracking.",
|
||||
"longDescription": "Tracks material IT Infra & Ops work by context and validates Taiga writes.",
|
||||
"developerName": "Local developer",
|
||||
"category": "Productivity",
|
||||
"capabilities": ["Lifecycle hooks"],
|
||||
"capabilities": [
|
||||
"Skills",
|
||||
"Lifecycle hooks"
|
||||
],
|
||||
"defaultPrompt": "Help me use Taiga Auto Sync."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [{
|
||||
"PreToolUse": [{
|
||||
"matcher": "^mcp__taiga__(createUserStory|createTask|createIssue|batchCreateUserStories|batchCreateTasks|batchCreateIssues)$",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/taiga-reminder.sh\"",
|
||||
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/taiga-write-guard.py\"",
|
||||
"timeout": 3,
|
||||
"statusMessage": "Checking Taiga tracking..."
|
||||
"statusMessage": "Checking Taiga item metadata..."
|
||||
}]
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
cat <<'EOF'
|
||||
TAIGA AUTO-SYNC: For material IT Infra & Ops work, create or update the matching Taiga item now through the scoped launcher. Do not track read-only/no-op work. A new User Story must use the Wiki description template; preserve its state file for progress, blocked, and verified done updates.
|
||||
EOF
|
||||
96
plugins/taiga-auto-sync/hooks/taiga-write-guard.py
Normal file
96
plugins/taiga-auto-sync/hooks/taiga-write-guard.py
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reject Taiga item creation that is missing the required tag contract.
|
||||
|
||||
Only checks tags. Owner and sprint are not creation-time fields on the
|
||||
Taiga MCP server's create tools (createUserStory/createTask/createIssue and
|
||||
their batch variants) -- they are set afterwards via assignIssue,
|
||||
assignUserStoryToSprint, or addIssueToSprint. Tasks have no assignee/sprint
|
||||
tool at all, so their owner and sprint are tracked through the parent
|
||||
User Story instead.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
AREAS = {
|
||||
"infra", "network", "cloud", "security", "backup", "monitoring",
|
||||
"access", "automation", "support", "vendor",
|
||||
}
|
||||
|
||||
# Batch-create tools nest each item's tags under one of these list keys
|
||||
# instead of exposing a top-level "tags" array.
|
||||
BATCH_LIST_KEYS = ("userStories", "tasks", "issues")
|
||||
|
||||
|
||||
def missing_tag_fields(tags):
|
||||
tags = set(tags or [])
|
||||
missing = []
|
||||
if not tags.intersection(AREAS):
|
||||
missing.append("area tag")
|
||||
if not any(tag.startswith("source:") for tag in tags):
|
||||
missing.append("source tag")
|
||||
if not any(tag.startswith(("repo:", "system:")) for tag in tags):
|
||||
missing.append("target tag")
|
||||
return missing
|
||||
|
||||
|
||||
def missing_fields(payload):
|
||||
item = payload.get("tool_input", {})
|
||||
for key in BATCH_LIST_KEYS:
|
||||
entries = item.get(key)
|
||||
if isinstance(entries, list):
|
||||
problems = []
|
||||
for index, entry in enumerate(entries):
|
||||
missing = missing_tag_fields(entry.get("tags"))
|
||||
if missing:
|
||||
label = entry.get("subject") or f"item {index}"
|
||||
problems.append(f"{label!r}: missing " + ", ".join(missing))
|
||||
return problems
|
||||
return missing_tag_fields(item.get("tags"))
|
||||
|
||||
|
||||
def result(payload):
|
||||
missing = missing_fields(payload)
|
||||
if not missing:
|
||||
return None
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": (
|
||||
"Taiga item is missing required tags: " + "; ".join(missing) + ". "
|
||||
"Apply one area tag, one source:<agent> tag, and one "
|
||||
"repo:<name>/system:<name> tag. Owner and sprint are not "
|
||||
"creation-time fields here -- set them right after creation "
|
||||
"with assignIssue / assignUserStoryToSprint / addIssueToSprint "
|
||||
"(Tasks have no assignee/sprint tool; track Task ownership and "
|
||||
"sprint through the parent User Story)."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1:] == ["--self-check"]:
|
||||
ok_tags = ["infra", "source:codex", "repo:brain"]
|
||||
|
||||
assert result({"tool_input": {"tags": ok_tags}}) is None
|
||||
assert result({"tool_input": {"tags": []}}) is not None
|
||||
|
||||
ok_batch = {"tool_input": {"issues": [{"subject": "a", "tags": ok_tags}]}}
|
||||
assert result(ok_batch) is None
|
||||
|
||||
bad_batch = {"tool_input": {"issues": [
|
||||
{"subject": "a", "tags": ok_tags},
|
||||
{"subject": "b", "tags": []},
|
||||
]}}
|
||||
assert result(bad_batch) is not None
|
||||
|
||||
print("self-check: ok")
|
||||
return
|
||||
output = result(json.load(sys.stdin))
|
||||
if output:
|
||||
print(json.dumps(output))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
17
plugins/taiga-auto-sync/skills/taiga-operations/SKILL.md
Normal file
17
plugins/taiga-auto-sync/skills/taiga-operations/SKILL.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: taiga-operations
|
||||
description: Automatically track material IT Infrastructure & Operations implementation in Taiga, including deploys, incidents, maintenance, access, backup, monitoring, and infrastructure changes. Do not use for read-only questions, exploration, status explanations, or unrelated product work.
|
||||
---
|
||||
|
||||
# Taiga Operations
|
||||
|
||||
Use this workflow automatically when the prompt requests material IT Infra & Ops work. The user does not need to ask for a Taiga update.
|
||||
|
||||
1. Do not create or update Taiga for reading, investigation without a change, planning only, no-op work, or secrets.
|
||||
2. Before creating or activating an item, find the sprint that covers today in WIB. If none exists, create the Monday–Sunday `YYYY-WNN Operations` sprint with `createMilestone`, then use it.
|
||||
3. Find an existing matching open item before creating a new one. Reuse its local state reference when available.
|
||||
4. Classify a standalone daily operation as an Issue. Use one User Story only for one outcome with two or more related Tasks; create those Tasks beneath it.
|
||||
5. Every active User Story, Task, and Issue needs one area tag, one `source:<agent>` tag, and one `repo:<name>` or `system:<name>` tag on the create call itself — the write-guard hook enforces this and blocks creation when it is missing.
|
||||
6. Owner and sprint are not creation-time fields on this Taiga MCP server. Immediately after creating a User Story or Issue, call `assignUserStoryToSprint` / `assignIssue` / `addIssueToSprint` to set the current sprint and owner before treating the item as active. Tasks have no assignee or sprint tool at all — track Task ownership and sprint through the parent User Story instead.
|
||||
7. Write all Taiga-facing natural-language text in Bahasa Indonesia: titles, descriptions, comments, summaries, verification, blockers, and handoffs. Preserve code, commands, branch/PR names, repository and service names, domains, tags, statuses, and established technical terms in English.
|
||||
8. Use the Wiki template for the selected item type. Record only material `start`, `progress`, `blocked`, and verified `done` events. Never put credentials or sensitive evidence in Taiga or local state.
|
||||
Reference in New Issue
Block a user