vibed-infra 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,10 +41,17 @@ Multi-app: further products’ `install-gateway.sh` only add `apps/{other}/sites
41
41
  | Path | Purpose |
42
42
  |------|---------|
43
43
  | `~/services/gateway` | Host nginx (`GATEWAY_HOME`) |
44
- | `~/services/vibed-infra/update-agent` | Serial pull queue + optional GHCR webhook |
44
+ | `~/services/vibed-infra/update-agent` | Serial pull queue; GHCR notify via GitHub Actions OIDC |
45
45
  | `~/services/vibed-infra/persist-logs` | Per-app WALs + optional R2/S3 ship |
46
46
 
47
- Auto-update cron **enqueues** work; the agent processes one job at a time. After a GHCR push, the reusable image workflow mints a GitHub Actions OIDC JWT and notifies `https://{domain}/_vibed/hooks/ghcr` (fallback `http://{publicIp}/…`) so the pull does not wait for cron. Updates prune dangling images (`DOCKER_AUTO_PRUNE=1`).
47
+ ## Image updates (docker pull)
48
+
49
+ Install registers each role in the machine **update-agent**. Two paths enqueue work; the agent runs **one pull at a time** (`update-*.sh` is digest-gated — no restart if the image digest is unchanged):
50
+
51
+ 1. **Immediate** — push `:main` with the reusable GHCR workflow (`id-token: write`). CI mints an OIDC JWT and POSTs `https://{site host}/_vibed/hooks/ghcr` (fallback `http://{publicIp}/…`). No GitHub webhook UI or `VIBED_WEBHOOK_SECRET`.
52
+ 2. **Cron** — if `*_AUTO_UPDATE=1`, periodic enqueue (backup if notify is missed).
53
+
54
+ See [`skills/infra-update-agent/SKILL.md`](skills/infra-update-agent/SKILL.md). After a successful update, dangling images are pruned (`DOCKER_AUTO_PRUNE=1`).
48
55
 
49
56
  ## Layout
50
57
 
@@ -55,7 +62,7 @@ Auto-update cron **enqueues** work; the agent processes one job at a time. After
55
62
  | `templates/update-agent/` | Queue agent + webhook |
56
63
  | `templates/persist-logs/` | Shipper install |
57
64
  | `lib/persistlog/` | Python append / seal / replay |
58
- | `skills/` | system-gateway, infra-update-agent, persist-logs, dns-configure |
65
+ | `skills/` | system-gateway, infra-update-agent, infra-cicd, infra-packager, persist-logs, dns-configure |
59
66
 
60
67
  ## Environment
61
68
 
@@ -63,8 +70,9 @@ Auto-update cron **enqueues** work; the agent processes one job at a time. After
63
70
  |----------|---------|
64
71
  | `GATEWAY_HOME` | Host gateway dir (default `~/services/gateway`) |
65
72
  | `VIBED_HOME` | Machine vibed root (default `~/services/vibed-infra`) |
73
+ | `VIBED_UPDATE_AGENT` | Override update-agent dir (default `$VIBED_HOME/update-agent`) |
66
74
  | `GATEWAY_PUBLIC_IP` | VPS IPv4 (from `gateway.publicIp`) |
67
- | `TLS_EMAIL` / `TLS_MODE` | Let’s Encrypt email; `lab` or `letsencrypt` |
75
+ | `TLS_EMAIL` / `TLS_MODE` | Let’s Encrypt email; `lab` or `letsencrypt` (docker certbot → `./certs` PEMs; optional `CERTBOT_IMAGE` / `LETSENCRYPT_HOME`) |
68
76
  | `PERSIST_LOG_DIR` | Per-service event log dir |
69
77
  | `DOCKER_AUTO_PRUNE` | Prune dangling images after update (default on) |
70
78
 
@@ -72,6 +80,7 @@ Auto-update cron **enqueues** work; the agent processes one job at a time. After
72
80
 
73
81
  ```bash
74
82
  npm test
83
+ npm run test:oidc # real GitHub OIDC mint; skips locally unless ACTIONS_ID_TOKEN_* set
75
84
  npm run test:dist
76
85
  npm run test:e2e-multi # two apps, localhost wget|bash, shared host gateway
77
86
  ```
@@ -52,9 +52,11 @@ vibed_bootstrap_host_gateway() {
52
52
  fi
53
53
  }
54
54
 
55
- # Always refresh setup-tls (idempotent re-install / multi-app)
55
+ # Always refresh gateway scripts (idempotent re-install / multi-app)
56
56
  _hg_fetch "templates/host-gateway/setup-tls.sh" "$home/setup-tls.sh"
57
- chmod +x "$home/setup-tls.sh"
57
+ _hg_fetch "templates/host-gateway/start-gateway.sh" "$home/start-gateway.sh"
58
+ _hg_fetch "templates/host-gateway/reload-gateway.sh" "$home/reload-gateway.sh"
59
+ chmod +x "$home/setup-tls.sh" "$home/start-gateway.sh" "$home/reload-gateway.sh"
58
60
 
59
61
  if [[ -f "${home}/.vibed-host-gateway" ]]; then
60
62
  echo "host gateway already present: $home"
@@ -66,11 +68,9 @@ vibed_bootstrap_host_gateway() {
66
68
  _hg_fetch "templates/host-gateway/gateway/nginx.conf" "$home/gateway/nginx.conf"
67
69
  _hg_fetch "templates/host-gateway/gateway/conf.d/00-default.conf" "$home/gateway/conf.d/00-default.conf"
68
70
  _hg_fetch "templates/host-gateway/.env.example" "$home/.env.example"
69
- _hg_fetch "templates/host-gateway/start-gateway.sh" "$home/start-gateway.sh"
70
- _hg_fetch "templates/host-gateway/reload-gateway.sh" "$home/reload-gateway.sh"
71
71
  _hg_fetch "templates/host-gateway/update-gateway.sh" "$home/update-gateway.sh"
72
72
  _hg_fetch "lib/env.sh" "$home/lib-env.sh"
73
- chmod +x "$home/start-gateway.sh" "$home/reload-gateway.sh" "$home/update-gateway.sh"
73
+ chmod +x "$home/update-gateway.sh"
74
74
 
75
75
  if [[ ! -f "$home/.env" ]]; then
76
76
  cp "$home/.env.example" "$home/.env"
package/lib/package.py CHANGED
@@ -100,8 +100,13 @@ DOCKER_PRUNE_UNTIL=72h
100
100
  def _env_gateway(meta: dict, conf: dict, sites: list) -> str:
101
101
  au = conf["profiles"]["gateway"]["autoUpdate"]
102
102
  gw = conf["profiles"]["gateway"]
103
- host = sites[0]["host"] if sites else "example.com"
104
- cert_dir = sites[0].get("tlsCertDir", f"/etc/letsencrypt/live/{host}") if sites else "/etc/letsencrypt/live/example.com"
103
+ # Host gateway stores PEMs under ./certs (setup-tls.sh). tlsCertDir is legacy/docs only
104
+ # unless it already points at a relative ./certs tree.
105
+ cert_dir = "./certs"
106
+ if sites:
107
+ raw = sites[0].get("tlsCertDir") or ""
108
+ if raw.startswith("./certs") or raw.rstrip("/") == "certs":
109
+ cert_dir = raw.rstrip("/")
105
110
  flags = au.get("flags") or ["GATEWAY_AUTO_UPDATE"]
106
111
  flag_lines = "\n".join(f"{f}=0" for f in flags)
107
112
  public_ip = gw.get("publicIp") or meta.get("publicIp") or ""
@@ -351,6 +356,10 @@ wget -qO- https://raw.githubusercontent.com/ORG/REPO/main/dist/install-gateway.s
351
356
  Network: `{meta['network']}` (shared host edge). API container: `{meta['apiContainer']}`.
352
357
  Gateway install bootstraps `~/services/gateway` once, then drops `apps/{{product}}/sites.conf`.
353
358
 
359
+ ## Image updates
360
+
361
+ wget install registers this product with the machine update-agent. A GHCR `:main` push (reusable workflow + `id-token: write`) POSTs `/_vibed/hooks/ghcr` so the VPS pulls immediately. Cron `*_AUTO_UPDATE` is a backup. See the packager’s infra-update-agent skill.
362
+
354
363
  ## DNS (AU agent)
355
364
 
356
365
  Copy [`DNS-SKILL.md`](DNS-SKILL.md) into an AU agent browser extension. Domains and `gateway.publicIp` are baked in when configured; confirm the IP with the operator.
package/lib/tls.sh CHANGED
@@ -9,22 +9,16 @@ infra_tls_check() {
9
9
 
10
10
  infra_tls_suggest_certbot() {
11
11
  local domains=("$@")
12
- local joined=""
13
- local d
14
- for d in "${domains[@]}"; do
15
- joined+=" -d $d"
16
- done
17
12
  cat <<EOF
18
13
 
19
- TLS certificates not found. Issue with certbot (port 80 must be free):
14
+ TLS certificates not found. From the host gateway directory, issue with:
20
15
 
21
- sudo certbot certonly --standalone${joined}
16
+ cd ~/services/gateway && ./setup-tls.sh --force
22
17
 
23
- Or after gateway HTTP is up (webroot):
18
+ (setup-tls.sh uses docker certbot when available — no host certbot/sudo required —
19
+ or host certbot when installed. PEMs land in ./certs/.)
24
20
 
25
- sudo certbot certonly --webroot -w /var/www/certbot${joined}
26
-
27
- Then set TLS_FULLCHAIN and TLS_PRIVKEY in .env and run ./start.sh
21
+ Or set TLS_MODE=lab for self-signed lab certs, then ./start.sh
28
22
 
29
23
  EOF
30
24
  }
@@ -41,19 +35,16 @@ infra_tls_offer_interactive() {
41
35
  if [[ ! -t 0 ]]; then
42
36
  return 0
43
37
  fi
44
- read -r -p "Run certbot --standalone now? [y/N] " ans
38
+ local gw="${GATEWAY_HOME:-${HOME:-}/services/gateway}"
39
+ read -r -p "Run setup-tls.sh in ${gw}? [y/N] " ans
45
40
  case "$ans" in
46
41
  y|Y|yes|YES)
47
- if ! command -v certbot >/dev/null 2>&1; then
48
- echo "certbot not found — install certbot first" >&2
42
+ if [[ -x "${gw}/setup-tls.sh" ]]; then
43
+ (cd "$gw" && ./setup-tls.sh --force)
44
+ else
45
+ echo "setup-tls.sh not found at ${gw} — bootstrap the host gateway first" >&2
49
46
  return 1
50
47
  fi
51
- local args=()
52
- local d
53
- for d in "${domains[@]}"; do
54
- args+=(-d "$d")
55
- done
56
- sudo certbot certonly --standalone "${args[@]}"
57
48
  ;;
58
49
  esac
59
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibed-infra",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Product-agnostic VPS packager: wget install, Docker Compose, TLS/nginx, auto-update",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,7 +27,7 @@ gateway:
27
27
  aliases: [www.app.example.com]
28
28
  healthPath: /api/health
29
29
  createPath: /api/items
30
- tlsCertDir: /etc/letsencrypt/live/app.example.com
30
+ tlsCertDir: ./certs # host gateway PEMs; legacy /etc/letsencrypt/... paths ignored in .env.gateway.example
31
31
  ```
32
32
 
33
33
  Defaults: containers `{name}-api` / `{name}-ui` / `{name}-worker`; host gateway container `vps-gateway`.
@@ -39,7 +39,7 @@ Compiled `packageconfig.yaml` also includes `webhook.url` / `fallbackUrl` (from
39
39
  | Path | Purpose |
40
40
  |------|---------|
41
41
  | `install-*.sh` | wget entrypoints |
42
- | `notify-vps-pull.py` | Called by GHCR workflow after push; uses compiled `webhook` |
42
+ | `notify-vps-pull.py` | GHCR workflow: mint OIDC JWT and POST webhook URLs from `webhook:` |
43
43
  | `DNS-SKILL.md` | Paste into AU browser agent; includes domains + `publicIp` when set |
44
44
  | `packageconfig.yaml` | compiled config |
45
45
  | `start-*.sh` / `update-*.sh` | lifecycle |
@@ -47,11 +47,15 @@ Compiled `packageconfig.yaml` also includes `webhook.url` / `fallbackUrl` (from
47
47
 
48
48
  Gateway install writes `$GATEWAY_HOME/apps/{name}/sites.conf` (host-extension mode).
49
49
 
50
- ## Auto-update
50
+ ## Auto-update and docker pull
51
51
 
52
- When `*_AUTO_UPDATE=1`, cron **enqueues** into the machine update-agent (serial pulls). Fallback: direct `update-*.sh` if agent missing. After update: dangling image prune (`DOCKER_AUTO_PRUNE`).
52
+ Install registers each role with the machine **update-agent**. Pulls are serial and digest-gated (`update-*.sh`).
53
53
 
54
- Immediate pulls: install registers the app. The reusable docker-build workflow mints a GitHub Actions OIDC JWT and POSTs to `https://{host}/_vibed/hooks/ghcr` (fallback `http://{publicIp}/_vibed/hooks/ghcr`) after pushing `:main`. The VPS verifies GitHub’s signature (`id-token: write` on the caller job). Local curl may use the compiled `webhook.token`.
54
+ - **Immediate:** reusable GHCR workflow on the default branch mints a GitHub Actions OIDC JWT and POSTs `https://{host}/_vibed/hooks/ghcr` (fallback `http://{publicIp}/_vibed/hooks/ghcr`). Caller needs `id-token: write`. See [`skills/infra-update-agent/SKILL.md`](../skills/infra-update-agent/SKILL.md).
55
+ - **Cron:** when `*_AUTO_UPDATE=1`, cron enqueues on an interval (backup if notify is missed). Fallback: direct `update-*.sh` if the agent is missing.
56
+ - After update: dangling image prune (`DOCKER_AUTO_PRUNE`).
57
+
58
+ `webhook.token` in compiled packageconfig is only for local curl; CI does not need `VIBED_WEBHOOK_SECRET`.
55
59
 
56
60
  ## Persist logs
57
61
 
@@ -63,6 +67,7 @@ Immediate pulls: install registers the app. The reusable docker-build workflow m
63
67
  |----------|---------|
64
68
  | `GATEWAY_HOME` | Host gateway (default `~/services/gateway`) |
65
69
  | `VIBED_HOME` | `~/services/vibed-infra` |
70
+ | `VIBED_UPDATE_AGENT` | Override update-agent dir (default `$VIBED_HOME/update-agent`) |
66
71
  | `GATEWAY_PUBLIC_IP` / `TLS_EMAIL` / `TLS_MODE` | Host TLS (`setup-tls.sh`) |
67
72
  | `PACKAGER_RAW` / `PRODUCT_RAW` / `PACKAGECONFIG_URL` | as before |
68
73
  | `INFRA_PROFILE` | `api`, `ui`, `nodes`, `gateway` |
@@ -2,8 +2,7 @@
2
2
  name: infra-cicd
3
3
  description: >-
4
4
  Set up GitHub Actions CI/CD for infra-packaged products: GHCR image build,
5
- package validation, and dist e2e (api/ui/nodes). Use when adding deploy
6
- pipelines for Backend/UI/worker images.
5
+ OIDC notify for immediate VPS pulls, package validation, and dist e2e.
7
6
  ---
8
7
 
9
8
  # Infra packager CI/CD
@@ -39,13 +38,14 @@ On the default branch, the reusable workflow mints a GitHub Actions OIDC JWT (au
39
38
  ## Product repo CI
40
39
 
41
40
  1. Run `./package.sh` and fail if `dist/` drifted from templates (optional `git diff --exit-code dist`).
42
- 2. Build/push images to GHCR (reusable workflow notifies the VPS automatically).
41
+ 2. Build/push images to GHCR (reusable workflow notifies the VPS; requires `id-token: write`).
43
42
  3. Optional job: `test-dist.sh --profile api` on a Docker-enabled runner.
44
43
 
45
44
  ## Checklist
46
45
 
47
46
  - [ ] Four YAML templates under `templates/` (`gateway.publicIp` / `tlsEmail` / `sites[]` set for production)
48
47
  - [ ] Committed `dist/` matches `./package.sh` output (including `DNS-SKILL.md`)
49
- - [ ] Image names in configs match GHCR
48
+ - [ ] Image names in configs match GHCR (`ghcr.io/{owner}/…` so OIDC `repository_owner` can match)
49
+ - [ ] GHCR reusable job has `permissions.id-token: write`
50
50
  - [ ] `npm test` / install dry-run passes
51
51
  - [ ] Secrets not in templates — only `.env.*.example` placeholders (publicIp/domain are OK to commit)
@@ -2,8 +2,8 @@
2
2
  name: infra-packager
3
3
  description: >-
4
4
  Wire a new product to the infra VPS packager: four YAML configs, package.sh →
5
- committed dist/, Docker images, and CI. Use when adding Backend/UI/worker
6
- deploy to a repo or migrating from ad-hoc deploy/install scripts.
5
+ committed dist/, Docker images, GHCR OIDC pulls, and CI. Use when adding
6
+ Backend/UI/worker deploy to a repo or migrating from ad-hoc install scripts.
7
7
  ---
8
8
 
9
9
  # Infra packager — onboard a product
@@ -17,7 +17,7 @@ description: >-
17
17
 
18
18
  1. **Depend on** [`vibed-infra`](https://www.npmjs.com/package/vibed-infra) or clone this repo for `package.sh`.
19
19
 
20
- 2. **Create** `templates/` with four files — see [`schema/packageconfig.md`](../../schema/packageconfig.md) and [`examples/vps-hello/templates/`](../../examples/vps-hello/templates/). Set `gateway.publicIp`, `gateway.tlsEmail`, and `gateway.sites[].host` for DNS-SKILL + HTTPS.
20
+ 2. **Create** `templates/` with four files — see [`schema/packageconfig.md`](../../schema/packageconfig.md) and [`examples/vps-hello/templates/`](../../examples/vps-hello/templates/). Set `gateway.publicIp`, `gateway.tlsEmail`, and `gateway.sites[].host` for DNS-SKILL + HTTPS (those values are also the GHCR notify URLs).
21
21
 
22
22
  3. **Add** a tiny product `package.sh`:
23
23
 
@@ -27,7 +27,7 @@ PACKAGER="$(node -e "console.log(require('path').dirname(require.resolve('vibed-
27
27
  exec bash "$PACKAGER/package.sh" --product "$ROOT" --out "$ROOT/dist"
28
28
  ```
29
29
 
30
- 4. **Build images** — Dockerfiles in `app/`; tag `:local` for dev or push to GHCR for prod; reference image names in `*-config.yaml`. The reusable GHCR workflow notifies the VPS after push via GitHub Actions OIDC (`id-token: write`).
30
+ 4. **Images** — Dockerfiles in `app/`; tag `:local` for dev; GHCR `:main` for prod (names in `*-config.yaml`). Immediate VPS pull uses the reusable GHCR workflow with `id-token: write` — see infra-cicd and infra-update-agent. No extra GitHub secret.
31
31
 
32
32
  5. **Package and commit** `dist/` (includes `DNS-SKILL.md` with domains + public IP):
33
33
 
@@ -36,9 +36,9 @@ exec bash "$PACKAGER/package.sh" --product "$ROOT" --out "$ROOT/dist"
36
36
  git add dist && git commit && git push
37
37
  ```
38
38
 
39
- 6. **CI** — `npm test` (packager validate + install dry-run); optional `test-dist.sh --profile api|ui|nodes` and `npm run test:e2e-multi`. See [`examples/vps-hello/test-dist.sh`](../../examples/vps-hello/test-dist.sh).
39
+ 6. **CI** — product repo: package drift check + GHCR build (notifies VPS). Packager repo: `npm test`, `oidc-webhook-e2e`, `test-dist.sh`, `npm run test:e2e-multi`.
40
40
 
41
- 7. **VPS** — DNS first (paste `dist/DNS-SKILL.md` into AU agent), then wget:
41
+ 7. **VPS** — DNS first (paste `dist/DNS-SKILL.md` into AU agent), then wget. Install registers the app with the update-agent so the next GHCR push can pull immediately:
42
42
 
43
43
  ```bash
44
44
  wget -qO- .../dist/install-api.sh | bash
@@ -56,3 +56,4 @@ wget -qO- .../dist/install-gateway.sh | bash # bootstraps host + setup-tls.sh
56
56
  - Gateway container names in `gateway.sites[]` must match running API/UI container names on `network.edge`.
57
57
  - UI is a separate profile; gateway is nginx-only and does not start the UI.
58
58
  - Prefer Let's Encrypt via `gateway.tlsEmail`; use `TLS_MODE=lab` only for CI/lab.
59
+ - Product GHCR workflow must set `id-token: write` or the VPS will not get an immediate pull.
@@ -3,44 +3,50 @@ name: infra-update-agent
3
3
  description: >-
4
4
  Machine-level serial Docker update queue for vibed-infra apps. Use when
5
5
  multiple products share one VPS and must not pull images in parallel; includes
6
- GHCR webhook enqueue via GitHub Actions OIDC (no extra GitHub secrets).
6
+ immediate GHCR pulls via GitHub Actions OIDC (no extra GitHub secrets).
7
7
  ---
8
8
 
9
9
  # Serial update agent
10
10
 
11
11
  ## Why
12
12
 
13
- Five apps with auto-update cron would each `docker pull` at once and overload the box. The **update-agent** is installed once; apps only **enqueue** jobs.
13
+ Five apps with auto-update cron would each `docker pull` at once and overload the box. The **update-agent** is installed once; apps only **enqueue** jobs. The agent runs `update-*.sh`, which is **digest-gated**: pull if the registry digest changed, restart only if the running container is stale.
14
14
 
15
15
  ## Layout
16
16
 
17
17
  ```
18
18
  ~/services/vibed-infra/update-agent/
19
19
  queue/ processing/ done/ failed/
20
- registry/{app}/{role}.json
21
- tokens/{app} # optional local curl; CI uses OIDC instead
20
+ registry/{app}/{role}.json # image + installDir (written at wget install)
21
+ tokens/{app} # optional local curl; CI uses OIDC
22
22
  agent.sh
23
23
  enqueue.sh
24
- webhook_server.py # 0.0.0.0:19200 (firewall: do not expose this port)
24
+ webhook_server.py # 0.0.0.0:19200 do not open this port publicly
25
25
  lib/github_oidc.py
26
- .env # WEBHOOK_PORT; optional WEBHOOK_SECRET
26
+ .env # WEBHOOK_PORT=19200
27
27
  ```
28
28
 
29
- ## Flow
29
+ Override the directory with `VIBED_UPDATE_AGENT` (wins over `VIBED_HOME`).
30
30
 
31
- 1. Install registers the app (image + install dir).
32
- 2. Cron enqueues; agent cron (`*/5`) processes **serially**.
33
- 3. Image push: reusable GHCR workflow mints a GitHub Actions OIDC JWT (audience = webhook URL) and POSTs `{package,tag}` to `/_vibed/hooks/ghcr`.
34
- 4. Webhook verifies the JWT against GitHub’s JWKS (`iss`, `aud`, `exp`, RS256), then enqueues only registry images whose name contains `repository_owner`.
35
- 5. Agent runs `update-*.sh` immediately.
31
+ ## Pull paths
36
32
 
37
- Gateway exposes `/_vibed/hooks/` on HTTP (port 80) and on each HTTPS site host.
33
+ ```
34
+ git push main
35
+ → reusable GHCR workflow builds/pushes :main
36
+ → notify-vps-pull.py mints OIDC JWT (aud = webhook URL)
37
+ → POST https://{host}/_vibed/hooks/ghcr (fallback http://{publicIp}/…)
38
+ → nginx → webhook_server (verify GitHub JWKS)
39
+ → enqueue matching registry entries (owner must appear in image name)
40
+ → agent.sh → update-{role}.sh → docker pull + restart if digest changed
41
+ ```
38
42
 
39
- ## Zero extra GitHub setup
43
+ - **Immediate:** default-branch image push. Caller job needs `id-token: write`. No GitHub Packages webhook and no `VIBED_WEBHOOK_SECRET`.
44
+ - **Cron backup:** `*_AUTO_UPDATE=1` enqueues on an interval; agent cron `*/5` drains the queue if notify was missed.
45
+ - **Manual:** `bash ~/services/vibed-infra/update-agent/enqueue.sh /path/to/api-install`
40
46
 
41
- `gateway.publicIp` + `gateway.sites[].host` already in product config are the URLs. Auth is GitHub’s signature no `VIBED_WEBHOOK_SECRET`.
47
+ Gateway HTTP (`00-default.conf`) and each HTTPS site proxy `/_vibed/hooks/` to `host.docker.internal:19200`. Keep **19200** off the public firewall; only 80/443.
42
48
 
43
- Product workflow must grant OIDC to the reusable job:
49
+ ## Product CI
44
50
 
45
51
  ```yaml
46
52
  jobs:
@@ -55,10 +61,11 @@ jobs:
55
61
  image: ghcr.io/${{ github.repository_owner }}/my-api
56
62
  ```
57
63
 
58
- Local curl can still use `X-Vibed-Secret` from `tokens/{app}` (compiled hash). Optional `WEBHOOK_SECRET` override remains.
64
+ URLs come from `gateway.publicIp` + `gateway.sites[].host` already in config (`dist/packageconfig.yaml` `webhook.url` / `fallbackUrl`). Disable notify with `notify: false`.
59
65
 
60
- ## Register / enqueue manually
66
+ Local curl can send `X-Vibed-Secret` from `tokens/{app}` (compiled hash). Optional `WEBHOOK_SECRET` still works as an override.
61
67
 
62
- ```bash
63
- bash ~/services/vibed-infra/update-agent/enqueue.sh /path/to/api-install
64
- ```
68
+ ## Tests
69
+
70
+ - `npm test` — openssl-signed JWT (same verifier) + enqueue under `VIBED_UPDATE_AGENT`
71
+ - `npm run test:oidc` / CI job `oidc-webhook-e2e` — real GitHub OIDC mint against a local webhook
@@ -55,12 +55,12 @@ cd ~/services/gateway
55
55
  ./setup-tls.sh --force # re-issue after host or IP change
56
56
  ```
57
57
 
58
- - **Production:** certbot (webroot if gateway is up, else standalone). On failure, fix DNS then re-run `setup-tls.sh`.
58
+ - **Production:** `setup-tls.sh` issues Let’s Encrypt via docker `certbot/certbot` by default (config under `$GATEWAY_HOME/letsencrypt`, PEMs copied to `$GATEWAY_HOME/certs/`). No sudo required when Docker is available. Host certbot + sudo still preferred when present (same `--config-dir` under gateway home). Webroot if gateway is up, else standalone. On failure, fix DNS then re-run `setup-tls.sh`.
59
59
  - **Lab/CI:** `TLS_MODE=lab` → multi-SAN self-signed under `./certs/`.
60
60
 
61
- ## Webhook path
61
+ ## Webhook path (immediate docker pull)
62
62
 
63
- Host `00-default.conf` (HTTP) and each app `sites.conf` (HTTPS) proxy `/_vibed/hooks/` to the update-agent on `host.docker.internal:19200`. Image builds notify this path automatically — see infra-update-agent skill.
63
+ Host `00-default.conf` (HTTP) and each app `sites.conf` (HTTPS) proxy `/_vibed/hooks/` to the update-agent on `host.docker.internal:19200`. After a GHCR `:main` push, CI POSTs a GitHub Actions OIDC JWT to `/_vibed/hooks/ghcr` — see infra-update-agent. Do not expose port 19200 on the public firewall.
64
64
 
65
65
  ## Pitfalls
66
66
 
@@ -9,6 +9,9 @@ TLS_MODE=
9
9
  TLS_EMAIL=
10
10
  GATEWAY_PUBLIC_IP=
11
11
  CERTBOT_WWW=./certbot-www
12
+ # Optional Let's Encrypt (docker certbot when host certbot/sudo unavailable)
13
+ # CERTBOT_IMAGE=certbot/certbot
14
+ # LETSENCRYPT_HOME=./letsencrypt
12
15
  HTTP_PORT=80
13
16
  HTTPS_PORT=443
14
17
  GATEWAY_MEMORY_LIMIT=64m
@@ -12,6 +12,8 @@ for arg in "$@"; do
12
12
  -h|--help)
13
13
  echo "usage: $0 [--force]"
14
14
  echo " Issues Let's Encrypt (TLS_EMAIL / TLS_MODE=letsencrypt) or lab self-signed certs."
15
+ echo " LE prefers host certbot when available (root or passwordless sudo);"
16
+ echo " otherwise uses docker run certbot/certbot (no sudo). PEMs land in ./certs/."
15
17
  echo " Re-runs when certs missing, domains/IP changed vs .vibed-tls-state, or --force."
16
18
  exit 0
17
19
  ;;
@@ -137,27 +139,104 @@ issue_letsencrypt() {
137
139
  echo "TLS_EMAIL (or gateway.tlsEmail) required for Let's Encrypt" >&2
138
140
  return 1
139
141
  fi
140
- if ! command -v certbot >/dev/null 2>&1; then
141
- echo "certbot not found — install certbot, or set TLS_MODE=lab for self-signed" >&2
142
- return 1
142
+
143
+ local LE_HOME="${LETSENCRYPT_HOME:-${SCRIPT_DIR}/letsencrypt}"
144
+ local CERTBOT_IMAGE="${CERTBOT_IMAGE:-certbot/certbot}"
145
+ mkdir -p "$LE_HOME" "$LE_HOME/work" "$LE_HOME/logs" "$CERT_DIR" "$CERTBOT_WWW"
146
+
147
+ # Absolute webroot for docker bind mounts
148
+ local CERTBOT_WWW_ABS="$CERTBOT_WWW"
149
+ [[ "$CERTBOT_WWW_ABS" != /* ]] && CERTBOT_WWW_ABS="${SCRIPT_DIR}/${CERTBOT_WWW_ABS#./}"
150
+ mkdir -p "$CERTBOT_WWW_ABS"
151
+
152
+ local use_webroot=0
153
+ if gateway_running; then
154
+ use_webroot=1
143
155
  fi
144
- local args=(certonly --non-interactive --agree-tos --email "$email" --cert-name "$primary")
156
+
157
+ local domain_args=()
145
158
  local d
146
159
  for d in "${all[@]}"; do
147
- args+=(-d "$d")
160
+ domain_args+=(-d "$d")
148
161
  done
149
- if gateway_running; then
150
- echo "Gateway running — certbot webroot ($CERTBOT_WWW)"
151
- args+=(--webroot -w "$CERTBOT_WWW")
152
- else
153
- echo "Gateway not running — certbot standalone (port 80 must be free)"
154
- args+=(--standalone)
155
- fi
162
+
163
+ local base_args=(
164
+ certonly
165
+ --agree-tos
166
+ --non-interactive
167
+ --email "$email"
168
+ --cert-name "$primary"
169
+ "${domain_args[@]}"
170
+ )
156
171
  if [[ "${CERTBOT_DRY_RUN:-0}" == "1" ]]; then
157
- args+=(--dry-run)
172
+ base_args+=(--dry-run)
173
+ fi
174
+
175
+ local ran=0
176
+ local host_can_run=0
177
+ if command -v certbot >/dev/null 2>&1; then
178
+ if [[ "$(id -u)" -eq 0 ]]; then
179
+ host_can_run=1
180
+ elif sudo -n true >/dev/null 2>&1; then
181
+ host_can_run=1
182
+ fi
183
+ fi
184
+
185
+ if [[ "$host_can_run" == "1" ]]; then
186
+ local host_args=(
187
+ "${base_args[@]}"
188
+ --config-dir "$LE_HOME"
189
+ --work-dir "$LE_HOME/work"
190
+ --logs-dir "$LE_HOME/logs"
191
+ )
192
+ if [[ "$use_webroot" == "1" ]]; then
193
+ echo "Gateway running — host certbot webroot ($CERTBOT_WWW_ABS)"
194
+ host_args+=(--webroot -w "$CERTBOT_WWW_ABS")
195
+ else
196
+ echo "Gateway not running — host certbot standalone (port 80 must be free)"
197
+ host_args+=(--standalone)
198
+ fi
199
+ local run_certbot=(certbot)
200
+ if [[ "$(id -u)" -ne 0 ]]; then
201
+ run_certbot=(sudo certbot)
202
+ fi
203
+ if "${run_certbot[@]}" "${host_args[@]}"; then
204
+ ran=1
205
+ # Best-effort: reclaim root-owned LE tree after sudo certbot
206
+ if [[ "$(id -u)" -ne 0 ]]; then
207
+ sudo -n chown -R "$(id -u):$(id -g)" "$LE_HOME" 2>/dev/null || true
208
+ fi
209
+ fi
210
+ fi
211
+
212
+ if [[ "$ran" != "1" && "$host_can_run" != "1" ]] && command -v docker >/dev/null 2>&1; then
213
+ local docker_args=(
214
+ run --rm
215
+ -v "${LE_HOME}:/etc/letsencrypt"
216
+ )
217
+ local container_args=("${base_args[@]}")
218
+ if [[ "$use_webroot" == "1" ]]; then
219
+ echo "Gateway running — docker certbot webroot ($CERTBOT_WWW_ABS)"
220
+ docker_args+=(-v "${CERTBOT_WWW_ABS}:/var/www/certbot")
221
+ container_args+=(--webroot -w /var/www/certbot)
222
+ else
223
+ echo "Gateway not running — docker certbot standalone (-p 80:80)"
224
+ docker_args+=(-p 80:80)
225
+ container_args+=(--standalone)
226
+ fi
227
+ if docker "${docker_args[@]}" "$CERTBOT_IMAGE" "${container_args[@]}"; then
228
+ ran=1
229
+ fi
158
230
  fi
159
- if ! sudo certbot "${args[@]}"; then
160
- cat <<EOF >&2
231
+
232
+ if [[ "$ran" != "1" ]]; then
233
+ if [[ "$host_can_run" != "1" ]] && ! command -v docker >/dev/null 2>&1; then
234
+ cat <<EOF >&2
235
+ Neither usable host certbot nor docker found.
236
+ Install Docker (preferred for non-root), or certbot with sudo, or set TLS_MODE=lab.
237
+ EOF
238
+ else
239
+ cat <<EOF >&2
161
240
 
162
241
  Let's Encrypt failed (often DNS not pointing here yet).
163
242
  1. Paste dist/DNS-SKILL.md into your AU DNS agent (or create the A records yourself).
@@ -165,15 +244,27 @@ Let's Encrypt failed (often DNS not pointing here yet).
165
244
  3. Re-run: cd ${SCRIPT_DIR} && ./setup-tls.sh --force
166
245
 
167
246
  EOF
247
+ fi
168
248
  return 1
169
249
  fi
170
- local live="/etc/letsencrypt/live/${primary}"
250
+
251
+ # Dry-run does not write live PEMs
252
+ if [[ "${CERTBOT_DRY_RUN:-0}" == "1" ]]; then
253
+ echo "certbot dry-run succeeded (no PEMs written)"
254
+ return 0
255
+ fi
256
+
257
+ local live="${LE_HOME}/live/${primary}"
171
258
  if [[ ! -f "${live}/fullchain.pem" || ! -f "${live}/privkey.pem" ]]; then
172
259
  echo "certbot succeeded but ${live} PEMs missing" >&2
173
260
  return 1
174
261
  fi
175
- patch_env_key TLS_FULLCHAIN "${live}/fullchain.pem"
176
- patch_env_key TLS_PRIVKEY "${live}/privkey.pem"
262
+ cp -L "${live}/fullchain.pem" "${CERT_DIR}/fullchain.pem"
263
+ cp -L "${live}/privkey.pem" "${CERT_DIR}/privkey.pem"
264
+ chmod 644 "${CERT_DIR}/fullchain.pem"
265
+ chmod 600 "${CERT_DIR}/privkey.pem"
266
+ patch_env_key TLS_FULLCHAIN "${CERT_DIR}/fullchain.pem"
267
+ patch_env_key TLS_PRIVKEY "${CERT_DIR}/privkey.pem"
177
268
  patch_env_key TLS_MODE letsencrypt
178
269
  patch_env_key TLS_EMAIL "$email"
179
270
  }
@@ -33,7 +33,7 @@ if [[ ! -f "$TLS_FULLCHAIN" || ! -f "$TLS_PRIVKEY" ]]; then
33
33
  echo "TLS certs not found:" >&2
34
34
  echo " $TLS_FULLCHAIN" >&2
35
35
  echo " $TLS_PRIVKEY" >&2
36
- echo "Run gen-dev-certs.sh into ./certs for lab, or certbot for production." >&2
36
+ echo "Run ./setup-tls.sh (lab or letsencrypt) to create ./certs PEMs." >&2
37
37
  exit 1
38
38
  fi
39
39
 
@@ -44,6 +44,10 @@ fi
44
44
 
45
45
  docker network create "$NETWORK" >/dev/null 2>&1 || true
46
46
  mkdir -p "$CERTBOT_WWW" "$APPS_DIR"
47
+ # Absolute path for live ACME webroot bind mount
48
+ CERTBOT_ABS="$CERTBOT_WWW"
49
+ [[ "$CERTBOT_ABS" != /* ]] && CERTBOT_ABS="${SCRIPT_DIR}/${CERTBOT_ABS#./}"
50
+ mkdir -p "$CERTBOT_ABS"
47
51
 
48
52
  if docker inspect "$GATEWAY_NAME" >/dev/null 2>&1; then
49
53
  echo "Removing existing container $GATEWAY_NAME ..."
@@ -58,7 +62,7 @@ STAGE="$(mktemp -d)"
58
62
  cleanup_stage() { rm -rf "$STAGE"; }
59
63
  trap cleanup_stage EXIT
60
64
 
61
- mkdir -p "$STAGE/conf.d" "$STAGE/certs" "$STAGE/certbot" "$STAGE/apps"
65
+ mkdir -p "$STAGE/conf.d" "$STAGE/certs" "$STAGE/apps"
62
66
  cp "$NGINX_CONF" "$STAGE/nginx.conf"
63
67
  cp -r "$CONF_D/." "$STAGE/conf.d/"
64
68
  cp "$TLS_FULLCHAIN" "$STAGE/certs/fullchain.pem"
@@ -75,10 +79,6 @@ if [[ -d "$APPS_DIR" ]]; then
75
79
  fi
76
80
  done
77
81
  fi
78
- if [[ -d "$CERTBOT_WWW" ]]; then
79
- cp -r "$CERTBOT_WWW/." "$STAGE/certbot/" 2>/dev/null || true
80
- fi
81
-
82
82
  tar -C "$STAGE" -cf - . | docker run --rm -i -v "${CONF_VOL}:/cfg" alpine tar -xf - -C /cfg
83
83
 
84
84
  echo "Starting $GATEWAY_NAME (HTTP ${HTTP_PORT}, HTTPS ${HTTPS_PORT}) ..."
@@ -92,6 +92,7 @@ docker run -d \
92
92
  -p "${HTTP_PORT}:80" \
93
93
  -p "${HTTPS_PORT}:443" \
94
94
  -v "${CONF_VOL}:/gateway-cfg:ro" \
95
+ -v "${CERTBOT_ABS}:/var/www/certbot" \
95
96
  "$NGINX_IMAGE" \
96
97
  sh -c 'mkdir -p /etc/nginx/certs /etc/nginx/conf.d /etc/nginx/apps /var/www/certbot && \
97
98
  cp /gateway-cfg/nginx.conf /etc/nginx/nginx.conf && \
@@ -99,7 +100,6 @@ docker run -d \
99
100
  if [ -d /gateway-cfg/apps ]; then cp -r /gateway-cfg/apps/. /etc/nginx/apps/; fi && \
100
101
  cp /gateway-cfg/certs/fullchain.pem /etc/nginx/certs/fullchain.pem && \
101
102
  cp /gateway-cfg/certs/privkey.pem /etc/nginx/certs/privkey.pem && \
102
- cp -r /gateway-cfg/certbot/. /var/www/certbot/ 2>/dev/null || true && \
103
103
  exec nginx -g "daemon off;"' >/dev/null
104
104
 
105
105
  trap - EXIT