From 50e0bb43c73af69f5c1f6870cf5dbc4e0dec96d0 Mon Sep 17 00:00:00 2001 From: Felipe Fontoura Date: Thu, 4 Jun 2026 12:38:28 -0300 Subject: [PATCH 01/72] refactor(stacks): reorganize into per-stack directories with parametrized envs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each stack moves from a flat .yml file to // with compose.yml, manifest.json, and optional install.sh. Hostnames, secrets, and DB connections all use ${VAR} placeholders so the bento installer can substitute them via Portainer's API at deploy time. Postgres no longer creates per-app databases automatically — each app's install.sh now calls ensure_database via lib/install-helpers.sh. Removes the postgres-init service. Chatwoot's rails db:chatwoot_prepare migration moves into chatwoot/install.sh. paperclip.Dockerfile moves into paperclip/ and its OCI source label is updated to felipefontoura/bento. --- .../{chatwoot.yml => chatwoot/compose.yml} | 30 ++------- stacks/app/chatwoot/install.sh | 21 ++++++ stacks/app/chatwoot/manifest.json | 12 ++++ .../compose.yml} | 0 stacks/app/cli-proxy-api/manifest.json | 7 ++ .../compose.yml} | 14 ++-- stacks/app/evolution-api/install.sh | 4 ++ stacks/app/evolution-api/manifest.json | 26 ++++++++ .../app/{n8n-mcp.yml => n8n-mcp/compose.yml} | 8 +-- stacks/app/n8n-mcp/manifest.json | 24 +++++++ stacks/app/{n8n.yml => n8n/compose.yml} | 14 ++-- stacks/app/n8n/install.sh | 4 ++ stacks/app/n8n/manifest.json | 13 ++++ .../Dockerfile} | 10 +-- .../{paperclip.yml => paperclip/compose.yml} | 16 ++--- stacks/app/paperclip/manifest.json | 13 ++++ stacks/app/{plunk.yml => plunk/compose.yml} | 20 +++--- stacks/app/plunk/install.sh | 8 +++ stacks/app/plunk/manifest.json | 44 +++++++++++++ .../{rabbitmq.yml => rabbitmq/compose.yml} | 10 +-- stacks/app/rabbitmq/manifest.json | 33 ++++++++++ .../app/{typebot.yml => typebot/compose.yml} | 12 ++-- stacks/app/typebot/install.sh | 4 ++ stacks/app/typebot/manifest.json | 30 +++++++++ stacks/db/postgres.yml | 65 ------------------- stacks/db/postgres/compose.yml | 39 +++++++++++ stacks/db/postgres/manifest.json | 13 ++++ stacks/db/{redis.yml => redis/compose.yml} | 0 stacks/db/redis/manifest.json | 7 ++ .../{portainer.yml => portainer/compose.yml} | 5 +- .../{traefik.yml => traefik/compose.yml} | 8 +-- 31 files changed, 363 insertions(+), 151 deletions(-) rename stacks/app/{chatwoot.yml => chatwoot/compose.yml} (91%) create mode 100755 stacks/app/chatwoot/install.sh create mode 100644 stacks/app/chatwoot/manifest.json rename stacks/app/{cli-proxy-api.yml => cli-proxy-api/compose.yml} (100%) create mode 100644 stacks/app/cli-proxy-api/manifest.json rename stacks/app/{evolution-api.yml => evolution-api/compose.yml} (94%) create mode 100755 stacks/app/evolution-api/install.sh create mode 100644 stacks/app/evolution-api/manifest.json rename stacks/app/{n8n-mcp.yml => n8n-mcp/compose.yml} (94%) create mode 100644 stacks/app/n8n-mcp/manifest.json rename stacks/app/{n8n.yml => n8n/compose.yml} (96%) create mode 100755 stacks/app/n8n/install.sh create mode 100644 stacks/app/n8n/manifest.json rename stacks/app/{paperclip.Dockerfile => paperclip/Dockerfile} (94%) rename stacks/app/{paperclip.yml => paperclip/compose.yml} (93%) create mode 100644 stacks/app/paperclip/manifest.json rename stacks/app/{plunk.yml => plunk/compose.yml} (83%) create mode 100755 stacks/app/plunk/install.sh create mode 100644 stacks/app/plunk/manifest.json rename stacks/app/{rabbitmq.yml => rabbitmq/compose.yml} (85%) create mode 100644 stacks/app/rabbitmq/manifest.json rename stacks/app/{typebot.yml => typebot/compose.yml} (92%) create mode 100755 stacks/app/typebot/install.sh create mode 100644 stacks/app/typebot/manifest.json delete mode 100644 stacks/db/postgres.yml create mode 100644 stacks/db/postgres/compose.yml create mode 100644 stacks/db/postgres/manifest.json rename stacks/db/{redis.yml => redis/compose.yml} (100%) create mode 100644 stacks/db/redis/manifest.json rename stacks/infra/{portainer.yml => portainer/compose.yml} (93%) rename stacks/infra/{traefik.yml => traefik/compose.yml} (94%) diff --git a/stacks/app/chatwoot.yml b/stacks/app/chatwoot/compose.yml similarity index 91% rename from stacks/app/chatwoot.yml rename to stacks/app/chatwoot/compose.yml index 60dd686..ec3572b 100644 --- a/stacks/app/chatwoot.yml +++ b/stacks/app/chatwoot/compose.yml @@ -35,7 +35,7 @@ x-chatwoot-common: &chatwoot-common # ============================================================================= # Rails secret key base. MUST remain stable across restarts/upgrades. - - SECRET_KEY_BASE=secret + - SECRET_KEY_BASE=${CHATWOOT_SECRET_KEY_BASE} # Force HTTPS redirects. - FORCE_SSL=true @@ -45,7 +45,7 @@ x-chatwoot-common: &chatwoot-common # ============================================================================= # Public-facing URL used for links and callbacks. - - FRONTEND_URL=https://chatwoot.website.com + - FRONTEND_URL=https://${CHATWOOT_HOST} # ============================================================================= # Localization @@ -76,7 +76,7 @@ x-chatwoot-common: &chatwoot-common - POSTGRES_USERNAME=postgres # Database password. - - POSTGRES_PASSWORD=secret + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} # ============================================================================= # Cache / Queue (Redis) @@ -187,24 +187,7 @@ x-chatwoot-common: &chatwoot-common ############# services: - # Database Initialization Service (run once) - chatwoot_init: - <<: *chatwoot-common - command: bundle exec rails db:chatwoot_prepare - deploy: - mode: replicated - replicas: 1 # Set to 1 to run initialization, then scale back to 0 - placement: - constraints: - - node.role == manager - resources: - limits: - cpus: "0.5" - memory: 512M - restart_policy: - condition: none - - # Main Web Application + # Database preparation is handled by stacks/app/chatwoot/install.sh after deploy. chatwoot_web: <<: *chatwoot-common entrypoint: docker/entrypoints/rails.sh @@ -228,7 +211,7 @@ services: labels: # Traefik Configuration - traefik.enable=true - - traefik.http.routers.chatwoot_web.rule=Host(`chatwoot.website.com`) + - traefik.http.routers.chatwoot_web.rule=Host(`${CHATWOOT_HOST}`) - traefik.http.routers.chatwoot_web.entrypoints=websecure - traefik.http.routers.chatwoot_web.tls.certresolver=letsencryptresolver - traefik.http.routers.chatwoot_web.priority=2 @@ -262,8 +245,7 @@ services: volumes: chatwoot_data: - external: true - name: chatwoot_data + driver: local networks: network_public: diff --git a/stacks/app/chatwoot/install.sh b/stacks/app/chatwoot/install.sh new file mode 100755 index 0000000..f6aa085 --- /dev/null +++ b/stacks/app/chatwoot/install.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Chatwoot post-deploy bootstrap: +# 1. Ensure the 'chatwoot' database exists. +# 2. Wait for chatwoot_web to be running. +# 3. Run `rails db:chatwoot_prepare` to migrate + seed. + +set -euo pipefail +source "${BENTO_REPO_ROOT}/lib/install-helpers.sh" + +ensure_database chatwoot + +echo "Waiting for chatwoot_web to come up…" +wait_for_service chatwoot_chatwoot_web 240 || { + echo "chatwoot_web did not become healthy; skipping rails db:chatwoot_prepare." >&2 + exit 1 +} + +cid=$(_find_container 'chatwoot_chatwoot_web') +echo "Running rails db:chatwoot_prepare inside $cid…" +sudo docker exec "$cid" bundle exec rails db:chatwoot_prepare +echo "Chatwoot database prepared." diff --git a/stacks/app/chatwoot/manifest.json b/stacks/app/chatwoot/manifest.json new file mode 100644 index 0000000..8feb2aa --- /dev/null +++ b/stacks/app/chatwoot/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "chatwoot", + "category": "app", + "description": "Customer support platform (Rails + Sidekiq)", + "depends_on": ["postgres", "redis"], + "env": [ + { "name": "CHATWOOT_HOST", "default": "chatwoot.${BASE_DOMAIN}", "prompt": "Chatwoot hostname" }, + { "name": "CHATWOOT_SECRET_KEY_BASE", "generate": "openssl rand -hex 64", "hide": true }, + { "name": "POSTGRES_PASSWORD", "from_state": "POSTGRES_PASSWORD" } + ], + "post_deploy_url": "https://${CHATWOOT_HOST}" +} diff --git a/stacks/app/cli-proxy-api.yml b/stacks/app/cli-proxy-api/compose.yml similarity index 100% rename from stacks/app/cli-proxy-api.yml rename to stacks/app/cli-proxy-api/compose.yml diff --git a/stacks/app/cli-proxy-api/manifest.json b/stacks/app/cli-proxy-api/manifest.json new file mode 100644 index 0000000..42aa023 --- /dev/null +++ b/stacks/app/cli-proxy-api/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "cli-proxy-api", + "category": "app", + "description": "OpenAI-compatible proxy in front of CLI-based AI providers", + "depends_on": [], + "env": [] +} diff --git a/stacks/app/evolution-api.yml b/stacks/app/evolution-api/compose.yml similarity index 94% rename from stacks/app/evolution-api.yml rename to stacks/app/evolution-api/compose.yml index 0810946..cca66fe 100644 --- a/stacks/app/evolution-api.yml +++ b/stacks/app/evolution-api/compose.yml @@ -12,7 +12,7 @@ services: # ============================================================================= # Public-facing base URL of the API. - - SERVER_URL=https://api.evolution.website.com + - SERVER_URL=https://${EVOLUTION_HOST} # Internal HTTP server port. - SERVER_PORT=8080 @@ -80,7 +80,7 @@ services: - AUTHENTICATION_TYPE=apikey # API key used when AUTHENTICATION_TYPE=apikey. - - AUTHENTICATION_API_KEY=secret + - AUTHENTICATION_API_KEY=${EVOLUTION_API_KEY} # Expose the API key in the fetch-instances response. - AUTHENTICATION_EXPOSE_IN_FETCH_INSTANCES=true @@ -113,7 +113,7 @@ services: - DATABASE_PROVIDER=postgresql # Full database connection URI. - - DATABASE_CONNECTION_URI=postgresql://evolution:secret@postgres:5432/evolution?schema=public + - DATABASE_CONNECTION_URI=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/evolution?schema=public # Persist instance metadata to the database. - DATABASE_SAVE_DATA_INSTANCE=true @@ -161,7 +161,7 @@ services: memory: 256M labels: - traefik.enable=true - - traefik.http.routers.evolution.rule=Host(`api.evolution.website.com`) + - traefik.http.routers.evolution.rule=Host(`${EVOLUTION_HOST}`) - traefik.http.routers.evolution.entrypoints=websecure - traefik.http.routers.evolution.tls.certresolver=letsencryptresolver - traefik.http.routers.evolution.priority=2 @@ -170,11 +170,9 @@ services: - traefik.http.services.evolution.loadbalancer.passHostHeader=true volumes: evolution_instances: - external: true - name: evolution_instances + driver: local evolution_store: - external: true - name: evolution_store + driver: local networks: network_public: name: network_public diff --git a/stacks/app/evolution-api/install.sh b/stacks/app/evolution-api/install.sh new file mode 100755 index 0000000..678f51f --- /dev/null +++ b/stacks/app/evolution-api/install.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +source "${BENTO_REPO_ROOT}/lib/install-helpers.sh" +ensure_database evolution diff --git a/stacks/app/evolution-api/manifest.json b/stacks/app/evolution-api/manifest.json new file mode 100644 index 0000000..bb2c5ce --- /dev/null +++ b/stacks/app/evolution-api/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "evolution-api", + "category": "app", + "description": "WhatsApp API gateway (Evolution API)", + "depends_on": [ + "postgres", + "redis" + ], + "env": [ + { + "name": "EVOLUTION_HOST", + "default": "evolution.${BASE_DOMAIN}", + "prompt": "Evolution API hostname" + }, + { + "name": "EVOLUTION_API_KEY", + "generate": "openssl rand -hex 32", + "hide": true + }, + { + "name": "POSTGRES_PASSWORD", + "from_state": "POSTGRES_PASSWORD" + } + ], + "post_deploy_url": "https://${EVOLUTION_HOST}" +} diff --git a/stacks/app/n8n-mcp.yml b/stacks/app/n8n-mcp/compose.yml similarity index 94% rename from stacks/app/n8n-mcp.yml rename to stacks/app/n8n-mcp/compose.yml index cfa5e0f..5a47317 100644 --- a/stacks/app/n8n-mcp.yml +++ b/stacks/app/n8n-mcp/compose.yml @@ -52,7 +52,7 @@ services: # n8n API key generated in: # n8n UI -> Settings -> n8n API -> Create an API key - - N8N_API_KEY=secret + - N8N_API_KEY=${N8N_MCP_N8N_API_KEY} # n8n API request timeout in milliseconds. - N8N_API_TIMEOUT=30000 @@ -69,11 +69,11 @@ services: # Bearer token used by MCP clients. # Generate with: openssl rand -hex 32 - - MCP_AUTH_TOKEN=secret + - MCP_AUTH_TOKEN=${N8N_MCP_AUTH_TOKEN} # Required by the official HTTP mode compose. # Keep equal to MCP_AUTH_TOKEN. - - AUTH_TOKEN=secret + - AUTH_TOKEN=${N8N_MCP_AUTH_TOKEN} # ============================================================================= # Runtime / Docker @@ -100,7 +100,7 @@ services: memory: 512M labels: - traefik.enable=true - - traefik.http.routers.n8n_mcp.rule=Host(`mcp.n8n.website.com`) + - traefik.http.routers.n8n_mcp.rule=Host(`${N8N_MCP_HOST}`) - traefik.http.routers.n8n_mcp.entrypoints=websecure - traefik.http.routers.n8n_mcp.tls.certresolver=letsencryptresolver - traefik.http.routers.n8n_mcp.priority=2 diff --git a/stacks/app/n8n-mcp/manifest.json b/stacks/app/n8n-mcp/manifest.json new file mode 100644 index 0000000..4fece0f --- /dev/null +++ b/stacks/app/n8n-mcp/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "n8n-mcp", + "category": "app", + "description": "Model Context Protocol server for n8n", + "depends_on": [], + "env": [ + { + "name": "N8N_MCP_HOST", + "default": "mcp.n8n.${BASE_DOMAIN}", + "prompt": "n8n MCP hostname" + }, + { + "name": "N8N_MCP_AUTH_TOKEN", + "generate": "openssl rand -hex 32", + "hide": true + }, + { + "name": "N8N_MCP_N8N_API_KEY", + "prompt": "n8n API key (create in n8n → Settings → n8n API)", + "hide": true + } + ], + "post_deploy_url": "https://${N8N_MCP_HOST}" +} diff --git a/stacks/app/n8n.yml b/stacks/app/n8n/compose.yml similarity index 96% rename from stacks/app/n8n.yml rename to stacks/app/n8n/compose.yml index 815b163..ae202cc 100644 --- a/stacks/app/n8n.yml +++ b/stacks/app/n8n/compose.yml @@ -9,7 +9,7 @@ x-n8n-common: &n8n-common # Encryption key used to secure credentials in the database. # MUST remain stable across restarts/upgrades. - - N8N_ENCRYPTION_KEY=secret + - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} # Runtime environment. - NODE_ENV=production @@ -52,7 +52,7 @@ x-n8n-common: &n8n-common - DB_POSTGRESDB_PORT=5432 - DB_POSTGRESDB_DATABASE=n8n - DB_POSTGRESDB_USER=postgres - - DB_POSTGRESDB_PASSWORD=secret + - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD} # ============================================================================= # Public URLs / Reverse Proxy / Webhooks @@ -62,18 +62,18 @@ x-n8n-common: &n8n-common - N8N_PORT=5678 # Public editor hostname. - - N8N_HOST=editor.n8n.website.com + - N8N_HOST=${N8N_HOST} # Public editor URL used for OAuth callbacks and links. # Avoid trailing slash. - - N8N_EDITOR_BASE_URL=https://editor.n8n.website.com + - N8N_EDITOR_BASE_URL=https://${N8N_HOST} # Public protocol. - N8N_PROTOCOL=https # Public webhook base URL. # Avoid trailing slash. - - WEBHOOK_URL=https://webhooks.n8n.website.com + - WEBHOOK_URL=https://${N8N_WEBHOOK_HOST} # Webhook endpoint path. - N8N_ENDPOINT_WEBHOOK=webhook @@ -271,7 +271,7 @@ services: memory: 256M labels: - traefik.enable=true - - traefik.http.routers.n8n_editor.rule=Host(`editor.n8n.website.com`) + - traefik.http.routers.n8n_editor.rule=Host(`${N8N_HOST}`) - traefik.http.routers.n8n_editor.entrypoints=websecure - traefik.http.routers.n8n_editor.tls.certresolver=letsencryptresolver - traefik.http.routers.n8n_editor.priority=2 @@ -304,7 +304,7 @@ services: memory: 256M labels: - traefik.enable=true - - traefik.http.routers.n8n_webhook.rule=Host(`webhooks.n8n.website.com`) + - traefik.http.routers.n8n_webhook.rule=Host(`${N8N_WEBHOOK_HOST}`) - traefik.http.routers.n8n_webhook.entrypoints=websecure - traefik.http.routers.n8n_webhook.tls.certresolver=letsencryptresolver - traefik.http.routers.n8n_webhook.priority=2 diff --git a/stacks/app/n8n/install.sh b/stacks/app/n8n/install.sh new file mode 100755 index 0000000..07ca9e1 --- /dev/null +++ b/stacks/app/n8n/install.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +source "${BENTO_REPO_ROOT}/lib/install-helpers.sh" +ensure_database n8n diff --git a/stacks/app/n8n/manifest.json b/stacks/app/n8n/manifest.json new file mode 100644 index 0000000..666c062 --- /dev/null +++ b/stacks/app/n8n/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "n8n", + "category": "app", + "description": "Workflow automation (editor + worker + webhook)", + "depends_on": ["postgres", "redis"], + "env": [ + { "name": "N8N_HOST", "default": "n8n.${BASE_DOMAIN}", "prompt": "n8n editor hostname" }, + { "name": "N8N_WEBHOOK_HOST", "default": "webhooks.${BASE_DOMAIN}","prompt": "n8n webhook hostname" }, + { "name": "N8N_ENCRYPTION_KEY","generate": "openssl rand -hex 32", "hide": true }, + { "name": "POSTGRES_PASSWORD", "from_state": "POSTGRES_PASSWORD" } + ], + "post_deploy_url": "https://${N8N_HOST}" +} diff --git a/stacks/app/paperclip.Dockerfile b/stacks/app/paperclip/Dockerfile similarity index 94% rename from stacks/app/paperclip.Dockerfile rename to stacks/app/paperclip/Dockerfile index 05a70bc..392b9ee 100644 --- a/stacks/app/paperclip.Dockerfile +++ b/stacks/app/paperclip/Dockerfile @@ -36,14 +36,14 @@ ARG GEMINI_CLI_VERSION ARG PI_CLI_VERSION ARG GROK_INSTALL_URL -LABEL org.opencontainers.image.source="https://github.com/felipefontoura/quickstack" \ +LABEL org.opencontainers.image.source="https://github.com/felipefontoura/bento" \ org.opencontainers.image.title="paperclip-custom" \ org.opencontainers.image.description="Paperclip + Hermes + Gemini + Pi + Grok bundled agent runtime" \ org.opencontainers.image.licenses="MIT" \ - org.quickstack.paperclip-version="${PAPERCLIP_VERSION}" \ - org.quickstack.hermes-version="${HERMES_VERSION}" \ - org.quickstack.gemini-cli-version="${GEMINI_CLI_VERSION}" \ - org.quickstack.pi-cli-version="${PI_CLI_VERSION}" + org.bento.paperclip-version="${PAPERCLIP_VERSION}" \ + org.bento.hermes-version="${HERMES_VERSION}" \ + org.bento.gemini-cli-version="${GEMINI_CLI_VERSION}" \ + org.bento.pi-cli-version="${PI_CLI_VERSION}" USER root diff --git a/stacks/app/paperclip.yml b/stacks/app/paperclip/compose.yml similarity index 93% rename from stacks/app/paperclip.yml rename to stacks/app/paperclip/compose.yml index f42f469..4301231 100644 --- a/stacks/app/paperclip.yml +++ b/stacks/app/paperclip/compose.yml @@ -1,17 +1,17 @@ x-paperclip-common: &paperclip-common - # Custom Paperclip image built from paperclip.Dockerfile. - # Adds hermes/gemini/pi/grok to the official base. See paperclip.Dockerfile. + # Custom Paperclip image built from this directory's Dockerfile. + # Adds hermes/gemini/pi/grok to the official base. # # Build first: - # docker compose -f stacks/app/paperclip.yml build + # docker compose -f stacks/app/paperclip/compose.yml build # Then deploy (Swarm ignores `build:` and uses the local image): - # docker stack deploy -c stacks/app/paperclip.yml app + # docker stack deploy -c stacks/app/paperclip/compose.yml app # Override the image to pull from a registry: # PAPERCLIP_CUSTOM_IMAGE=felipefontoura/paperclip-custom:1.2.3 docker stack deploy ... image: ${PAPERCLIP_CUSTOM_IMAGE:-paperclip-custom:latest} build: context: . - dockerfile: paperclip.Dockerfile + dockerfile: Dockerfile args: PAPERCLIP_VERSION: latest HERMES_VERSION: latest @@ -33,7 +33,7 @@ x-paperclip-common: &paperclip-common # Public URL / Reverse Proxy # ============================================================================= - - PAPERCLIP_PUBLIC_URL=https://paperclip.website.com + - PAPERCLIP_PUBLIC_URL=https://${PAPERCLIP_HOST} - PAPERCLIP_DEPLOYMENT_MODE=authenticated - PAPERCLIP_DEPLOYMENT_EXPOSURE=private @@ -43,7 +43,7 @@ x-paperclip-common: &paperclip-common # Generate with: # openssl rand -hex 32 - - BETTER_AUTH_SECRET=secret + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} # After first setup, keep signups disabled. # If onboarding fails, temporarily set this to false. @@ -168,7 +168,7 @@ services: memory: 256M labels: - traefik.enable=true - - traefik.http.routers.paperclip.rule=Host(`paperclip.website.com`) + - traefik.http.routers.paperclip.rule=Host(`${PAPERCLIP_HOST}`) - traefik.http.routers.paperclip.entrypoints=websecure - traefik.http.routers.paperclip.tls.certresolver=letsencryptresolver - traefik.http.routers.paperclip.priority=2 diff --git a/stacks/app/paperclip/manifest.json b/stacks/app/paperclip/manifest.json new file mode 100644 index 0000000..a9d6822 --- /dev/null +++ b/stacks/app/paperclip/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "paperclip", + "category": "app", + "description": "AI agent orchestration; custom image bundles Hermes/Gemini/Pi/Grok with Claude Code/Codex/OpenCode", + "depends_on": [], + "env": [ + { "name": "PAPERCLIP_HOST", "default": "paperclip.${BASE_DOMAIN}", "prompt": "Paperclip hostname" }, + { "name": "BETTER_AUTH_SECRET", "generate": "openssl rand -hex 32", "hide": true }, + { "name": "PAPERCLIP_CUSTOM_IMAGE","default": "paperclip-custom:latest" }, + { "name": "OPENAI_API_KEY", "prompt": "OpenAI API key (optional)", "hide": true } + ], + "post_deploy_url": "https://${PAPERCLIP_HOST}" +} diff --git a/stacks/app/plunk.yml b/stacks/app/plunk/compose.yml similarity index 83% rename from stacks/app/plunk.yml rename to stacks/app/plunk/compose.yml index 1b54b41..0e05721 100644 --- a/stacks/app/plunk.yml +++ b/stacks/app/plunk/compose.yml @@ -10,20 +10,20 @@ services: # ============================================================================= # Base application URI. - - APP_URI=https://plunk.website.com + - APP_URI=https://${PLUNK_HOST} # API endpoint URI. - - API_URI=https://plunk.website.com/api + - API_URI=https://${PLUNK_HOST}/api # Public-facing API URI exposed to the frontend. - - NEXT_PUBLIC_API_URI=https://plunk.website.com/api + - NEXT_PUBLIC_API_URI=https://${PLUNK_HOST}/api # ============================================================================= # Database (PostgreSQL) # ============================================================================= # Full database connection URL. - - DATABASE_URL=postgresql://plunk:secret@postgres:5432/plunk + - DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/plunk # ============================================================================= # Cache / Queue (Redis) @@ -37,23 +37,23 @@ services: # ============================================================================= # JWT signing secret. MUST remain stable across restarts/upgrades. - - JWT_SECRET=secret + - JWT_SECRET=${PLUNK_JWT_SECRET} # ============================================================================= # Email (AWS SES) # ============================================================================= # AWS region where SES is configured. - - AWS_REGION=us-east-1 + - AWS_REGION=${AWS_REGION:-us-east-1} # AWS access key for SES. - - AWS_ACCESS_KEY_ID=access-key + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} # AWS secret key for SES. - - AWS_SECRET_ACCESS_KEY=secret-key + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} # AWS SES configuration set name. - - AWS_SES_CONFIGURATION_SET=config-set + - AWS_SES_CONFIGURATION_SET=${AWS_SES_CONFIGURATION_SET} # ============================================================================= # Account Management @@ -83,7 +83,7 @@ services: memory: 512M labels: - traefik.enable=true - - traefik.http.routers.plunk.rule=Host(`plunk.website.com`) + - traefik.http.routers.plunk.rule=Host(`${PLUNK_HOST}`) - traefik.http.routers.plunk.entrypoints=websecure - traefik.http.routers.plunk.tls.certresolver=letsencryptresolver - traefik.http.routers.plunk.priority=2 diff --git a/stacks/app/plunk/install.sh b/stacks/app/plunk/install.sh new file mode 100755 index 0000000..ae2e982 --- /dev/null +++ b/stacks/app/plunk/install.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Post-deploy bootstrap for the plunk stack. +# Ensures the 'plunk' database exists in the shared postgres service. + +set -euo pipefail +source "${BENTO_REPO_ROOT}/lib/install-helpers.sh" + +ensure_database plunk diff --git a/stacks/app/plunk/manifest.json b/stacks/app/plunk/manifest.json new file mode 100644 index 0000000..519081b --- /dev/null +++ b/stacks/app/plunk/manifest.json @@ -0,0 +1,44 @@ +{ + "name": "plunk", + "category": "app", + "description": "Open-source transactional email platform", + "depends_on": [ + "postgres", + "redis" + ], + "env": [ + { + "name": "PLUNK_HOST", + "default": "plunk.${BASE_DOMAIN}", + "prompt": "Plunk hostname" + }, + { + "name": "PLUNK_JWT_SECRET", + "generate": "openssl rand -hex 32", + "hide": true + }, + { + "name": "POSTGRES_PASSWORD", + "from_state": "POSTGRES_PASSWORD" + }, + { + "name": "AWS_REGION", + "default": "us-east-1", + "prompt": "AWS SES region" + }, + { + "name": "AWS_ACCESS_KEY_ID", + "prompt": "AWS SES access key (skip if not using email)" + }, + { + "name": "AWS_SECRET_ACCESS_KEY", + "prompt": "AWS SES secret key", + "hide": true + }, + { + "name": "AWS_SES_CONFIGURATION_SET", + "prompt": "AWS SES configuration set name" + } + ], + "post_deploy_url": "https://${PLUNK_HOST}" +} diff --git a/stacks/app/rabbitmq.yml b/stacks/app/rabbitmq/compose.yml similarity index 85% rename from stacks/app/rabbitmq.yml rename to stacks/app/rabbitmq/compose.yml index ae5106f..a69653c 100644 --- a/stacks/app/rabbitmq.yml +++ b/stacks/app/rabbitmq/compose.yml @@ -17,20 +17,20 @@ services: # Shared secret used for node authentication in a cluster. # MUST be identical across all nodes in the same cluster. - - RABBITMQ_ERLANG_COOKIE=secret + - RABBITMQ_ERLANG_COOKIE=${RABBITMQ_ERLANG_COOKIE} # ============================================================================= # Virtual Host / Default User # ============================================================================= # Default virtual host created on first startup. - - RABBITMQ_DEFAULT_VHOST=default + - RABBITMQ_DEFAULT_VHOST=${RABBITMQ_DEFAULT_VHOST:-default} # Default admin user. - - RABBITMQ_DEFAULT_USER=rabbitmq + - RABBITMQ_DEFAULT_USER=${RABBITMQ_DEFAULT_USER:-rabbitmq} # Default admin password. - - RABBITMQ_DEFAULT_PASS=secret + - RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS} healthcheck: test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] interval: 15s @@ -49,7 +49,7 @@ services: memory: 256M labels: - traefik.enable=true - - traefik.http.routers.rabbitmq.rule=Host(`rabbitmq.website.com`) + - traefik.http.routers.rabbitmq.rule=Host(`${RABBITMQ_HOST}`) - traefik.http.routers.rabbitmq.entrypoints=websecure - traefik.http.routers.rabbitmq.tls.certresolver=letsencryptresolver - traefik.http.routers.rabbitmq.priority=2 diff --git a/stacks/app/rabbitmq/manifest.json b/stacks/app/rabbitmq/manifest.json new file mode 100644 index 0000000..45bf6b1 --- /dev/null +++ b/stacks/app/rabbitmq/manifest.json @@ -0,0 +1,33 @@ +{ + "name": "rabbitmq", + "category": "app", + "description": "RabbitMQ message broker with management UI", + "depends_on": [], + "env": [ + { + "name": "RABBITMQ_HOST", + "default": "rabbitmq.${BASE_DOMAIN}", + "prompt": "RabbitMQ management hostname" + }, + { + "name": "RABBITMQ_DEFAULT_USER", + "default": "rabbitmq", + "prompt": "Default admin username" + }, + { + "name": "RABBITMQ_DEFAULT_PASS", + "generate": "openssl rand -base64 18 | tr -d '\\n=/+' | head -c 24", + "hide": true + }, + { + "name": "RABBITMQ_DEFAULT_VHOST", + "default": "default" + }, + { + "name": "RABBITMQ_ERLANG_COOKIE", + "generate": "openssl rand -hex 24", + "hide": true + } + ], + "post_deploy_url": "https://${RABBITMQ_HOST}" +} diff --git a/stacks/app/typebot.yml b/stacks/app/typebot/compose.yml similarity index 92% rename from stacks/app/typebot.yml rename to stacks/app/typebot/compose.yml index 453688d..0632e7e 100644 --- a/stacks/app/typebot.yml +++ b/stacks/app/typebot/compose.yml @@ -7,7 +7,7 @@ x-typebot-common: &typebot-common # ============================================================================= # Full database connection URL. - - DATABASE_URL=postgresql://typebot:secret@postgres:5432/typebot + - DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/typebot # ============================================================================= # Core Security @@ -15,17 +15,17 @@ x-typebot-common: &typebot-common # 256-bit key used to encrypt sensitive data. # MUST remain stable across restarts/upgrades. - - ENCRYPTION_SECRET=secret + - ENCRYPTION_SECRET=${TYPEBOT_ENCRYPTION_SECRET} # ============================================================================= # Public URLs / Reverse Proxy # ============================================================================= # Builder publicly accessible URL (used for OAuth callbacks). - - NEXTAUTH_URL=https://builder.typebot.website.com + - NEXTAUTH_URL=https://${TYPEBOT_BUILDER_HOST} # Viewer publicly accessible URL. - - NEXT_PUBLIC_VIEWER_URL=https://typebot.website.com + - NEXT_PUBLIC_VIEWER_URL=https://${TYPEBOT_VIEWER_HOST} # Internal URL used by the builder for server-side requests. - NEXTAUTH_URL_INTERNAL=http://localhost:3000 @@ -106,7 +106,7 @@ services: memory: 256M labels: - traefik.enable=true - - traefik.http.routers.typebot_builder.rule=Host(`builder.typebot.website.com`) + - traefik.http.routers.typebot_builder.rule=Host(`${TYPEBOT_BUILDER_HOST}`) - traefik.http.routers.typebot_builder.entrypoints=websecure - traefik.http.routers.typebot_builder.tls.certresolver=letsencryptresolver - traefik.http.routers.typebot_builder.priority=2 @@ -136,7 +136,7 @@ services: <<: *typebot-deploy labels: - traefik.enable=true - - traefik.http.routers.typebot_viewer.rule=Host(`typebot.website.com`) + - traefik.http.routers.typebot_viewer.rule=Host(`${TYPEBOT_VIEWER_HOST}`) - traefik.http.routers.typebot_viewer.entrypoints=websecure - traefik.http.routers.typebot_viewer.tls.certresolver=letsencryptresolver - traefik.http.routers.typebot_viewer.priority=2 diff --git a/stacks/app/typebot/install.sh b/stacks/app/typebot/install.sh new file mode 100755 index 0000000..9b051db --- /dev/null +++ b/stacks/app/typebot/install.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +source "${BENTO_REPO_ROOT}/lib/install-helpers.sh" +ensure_database typebot diff --git a/stacks/app/typebot/manifest.json b/stacks/app/typebot/manifest.json new file mode 100644 index 0000000..a48dab0 --- /dev/null +++ b/stacks/app/typebot/manifest.json @@ -0,0 +1,30 @@ +{ + "name": "typebot", + "category": "app", + "description": "Open-source chatbot builder (builder + viewer)", + "depends_on": [ + "postgres" + ], + "env": [ + { + "name": "TYPEBOT_BUILDER_HOST", + "default": "builder.typebot.${BASE_DOMAIN}", + "prompt": "Typebot builder hostname" + }, + { + "name": "TYPEBOT_VIEWER_HOST", + "default": "typebot.${BASE_DOMAIN}", + "prompt": "Typebot viewer hostname" + }, + { + "name": "TYPEBOT_ENCRYPTION_SECRET", + "generate": "openssl rand -base64 24 | tr -d '\\n=/+' | head -c 32", + "hide": true + }, + { + "name": "POSTGRES_PASSWORD", + "from_state": "POSTGRES_PASSWORD" + } + ], + "post_deploy_url": "https://${TYPEBOT_BUILDER_HOST}" +} diff --git a/stacks/db/postgres.yml b/stacks/db/postgres.yml deleted file mode 100644 index 8f87b0c..0000000 --- a/stacks/db/postgres.yml +++ /dev/null @@ -1,65 +0,0 @@ -x-postgres-common: &postgres-common - image: postgres:15 - networks: - - network_public - environment: - - POSTGRES_PASSWORD=secret - - POSTGRES_INITDB_ARGS="--auth-host=scram-sha-256" - - POSTGRES_INIT_DATABASES=sandbox,n8n,typebot,plunk,chatwoot,evolution,paperclip - deploy: &postgres-deploy - mode: replicated - replicas: 1 - placement: - constraints: - - node.role == manager - -services: - postgres: - <<: *postgres-common - entrypoint: docker-entrypoint.sh - command: [postgres, --max_connections=200] - ports: - - 5432:5432 - volumes: - - postgres-data:/var/lib/postgresql/data - healthcheck: - test: - ["CMD-SHELL", "pg_isready -U postgres -h 127.0.0.1 -p 5432 || exit 1"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 20s - deploy: - <<: *postgres-deploy - resources: - limits: - cpus: "0.5" - memory: 256M - postgres-init: - <<: *postgres-common - entrypoint: ["sh", "-c"] - command: - - | - export PGPASSWORD="$${POSTGRES_PASSWORD}" - until pg_isready -h postgres -U postgres; do - echo "Waiting for postgres..." - sleep 2 - done - IFS="," read -ra DBS <<< "$${POSTGRES_INIT_DATABASES}" - for db in "$${DBS[@]}"; do - echo "Ensuring database exists: $$db" - psql -h postgres -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = '$$db'" | grep -q 1 || psql -h postgres -U postgres -c "CREATE DATABASE \"$$db\"" - done - echo "All databases ready" - deploy: - <<: *postgres-deploy - restart_policy: - condition: on-failure - -networks: - network_public: - external: true - name: network_public -volumes: - postgres-data: - driver: local diff --git a/stacks/db/postgres/compose.yml b/stacks/db/postgres/compose.yml new file mode 100644 index 0000000..4c78901 --- /dev/null +++ b/stacks/db/postgres/compose.yml @@ -0,0 +1,39 @@ +services: + postgres: + image: postgres:15 + entrypoint: docker-entrypoint.sh + command: [postgres, --max_connections=200] + networks: + - network_public + ports: + - 5432:5432 + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_INITDB_ARGS="--auth-host=scram-sha-256" + healthcheck: + test: + ["CMD-SHELL", "pg_isready -U postgres -h 127.0.0.1 -p 5432 || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.role == manager + resources: + limits: + cpus: "0.5" + memory: 256M + +networks: + network_public: + external: true + name: network_public +volumes: + postgres-data: + driver: local diff --git a/stacks/db/postgres/manifest.json b/stacks/db/postgres/manifest.json new file mode 100644 index 0000000..5761ed0 --- /dev/null +++ b/stacks/db/postgres/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "postgres", + "category": "db", + "description": "PostgreSQL 15 — apps create their own databases via install scripts", + "depends_on": [], + "env": [ + { + "name": "POSTGRES_PASSWORD", + "generate": "openssl rand -base64 24 | tr -d '\\n=/+' | head -c 32", + "hide": true + } + ] +} diff --git a/stacks/db/redis.yml b/stacks/db/redis/compose.yml similarity index 100% rename from stacks/db/redis.yml rename to stacks/db/redis/compose.yml diff --git a/stacks/db/redis/manifest.json b/stacks/db/redis/manifest.json new file mode 100644 index 0000000..65fd339 --- /dev/null +++ b/stacks/db/redis/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "redis", + "category": "db", + "description": "Redis 7 with appendonly persistence", + "depends_on": [], + "env": [] +} diff --git a/stacks/infra/portainer.yml b/stacks/infra/portainer/compose.yml similarity index 93% rename from stacks/infra/portainer.yml rename to stacks/infra/portainer/compose.yml index 98dc638..7a2e9cf 100644 --- a/stacks/infra/portainer.yml +++ b/stacks/infra/portainer/compose.yml @@ -45,7 +45,7 @@ services: labels: - "traefik.enable=true" - "traefik.docker.network=network_public" - - "traefik.http.routers.portainer.rule=Host(`portainer.website.com`)" + - "traefik.http.routers.portainer.rule=Host(`${PORTAINER_HOST}`)" - "traefik.http.routers.portainer.entrypoints=websecure" - "traefik.http.routers.portainer.priority=1" - "traefik.http.routers.portainer.tls.certresolver=letsencryptresolver" @@ -58,5 +58,4 @@ networks: name: network_public volumes: portainer_data: - external: true - name: portainer_data + driver: local diff --git a/stacks/infra/traefik.yml b/stacks/infra/traefik/compose.yml similarity index 94% rename from stacks/infra/traefik.yml rename to stacks/infra/traefik/compose.yml index a54ce6f..fbb0430 100644 --- a/stacks/infra/traefik.yml +++ b/stacks/infra/traefik/compose.yml @@ -14,7 +14,7 @@ services: - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.letsencryptresolver.acme.httpchallenge=true" - "--certificatesresolvers.letsencryptresolver.acme.httpchallenge.entrypoint=web" - - "--certificatesresolvers.letsencryptresolver.acme.email=example@example.com" + - "--certificatesresolvers.letsencryptresolver.acme.email=${TRAEFIK_ACME_EMAIL}" - "--certificatesresolvers.letsencryptresolver.acme.storage=/etc/traefik/letsencrypt/acme.json" - "--log.level=DEBUG" - "--log.format=common" @@ -57,12 +57,8 @@ services: published: 443 mode: host volumes: - vol_shared: - external: true - name: volume_swarm_shared vol_certificates: - external: true - name: volume_swarm_certificates + driver: local networks: network_public: external: true From 69227aadb4605a245ffe213f826b8eb40bdb059c Mon Sep 17 00:00:00 2001 From: Felipe Fontoura Date: Thu, 4 Jun 2026 12:38:40 -0300 Subject: [PATCH 02/72] feat(installer): add bento boot.sh + install.sh + lib/ modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit boot.sh is the curl|bash entry point: validates apt-get exists, installs git, clones the repo into ~/.local/share/bento, and sources install.sh. install.sh is the gum-driven main menu. After a one-time bootstrap that captures BASE_DOMAIN + ADMIN_EMAIL + ADVERTISE_ADDR, it walks the user through Step 1 (Harden), Step 2 (Infra), and Step 3 (Apps), with extra Settings, Status, and Update menu items. Status indicators come from ~/.config/bento/state.json. lib/ holds the stateless modules: - banner.sh + ui.sh — themed bento banner and gum wrappers. - state.sh — schema-versioned JSON state with migrate-on-read. - deps.sh — apt validation + gum/jq/envsubst install with binary fallback. - hardening.sh — adapted from felipefontoura/ubinkaze. - infra.sh — swarm init + network + Traefik + Portainer deploy + admin init. - portainer.sh — REST API client (auth, stacks CRUD, redeploy). - stacks.sh — manifest discovery, env resolution, deploy-via-API, hooks. - install-helpers.sh — helpers (ensure_database, wait_for_service) for per-stack install.sh scripts. --- boot.sh | 78 +++++++ install.sh | 304 +++++++++++++++++++++++++ lib/banner.sh | 40 ++++ lib/deps.sh | 80 +++++++ lib/hardening.sh | 501 +++++++++++++++++++++++++++++++++++++++++ lib/infra.sh | 150 ++++++++++++ lib/install-helpers.sh | 97 ++++++++ lib/portainer.sh | 226 +++++++++++++++++++ lib/stacks.sh | 310 +++++++++++++++++++++++++ lib/state.sh | 98 ++++++++ lib/ui.sh | 169 ++++++++++++++ 11 files changed, 2053 insertions(+) create mode 100644 boot.sh create mode 100644 install.sh create mode 100644 lib/banner.sh create mode 100644 lib/deps.sh create mode 100644 lib/hardening.sh create mode 100644 lib/infra.sh create mode 100644 lib/install-helpers.sh create mode 100644 lib/portainer.sh create mode 100644 lib/stacks.sh create mode 100644 lib/state.sh create mode 100644 lib/ui.sh diff --git a/boot.sh b/boot.sh new file mode 100644 index 0000000..e207df7 --- /dev/null +++ b/boot.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# bento — curl|bash entry point +# +# Usage: +# bash <(curl -sSL https://raw.githubusercontent.com/felipefontoura/bento/stable/boot.sh) +# +# Optional env vars: +# BENTO_REF — git ref to check out (default: stable) +# BENTO_HOME — clone destination (default: ~/.local/share/bento) + +set -euo pipefail + +BENTO_REF="${BENTO_REF:-stable}" +BENTO_HOME="${BENTO_HOME:-$HOME/.local/share/bento}" +BENTO_REPO_URL="${BENTO_REPO_URL:-https://github.com/felipefontoura/bento.git}" + +readonly RED='\033[0;31m' +readonly YELLOW='\033[1;33m' +readonly GREEN='\033[0;32m' +readonly NC='\033[0m' + +say() { printf '%b\n' "${GREEN}▸${NC} $*"; } +warn() { printf '%b\n' "${YELLOW}⚠${NC} $*" >&2; } +die() { printf '%b\n' "${RED}✗${NC} $*" >&2; exit 1; } + +# ----------------------------------------------------------------------------- +# Pre-flight (mínimo viável) +# ----------------------------------------------------------------------------- +if (( EUID == 0 )); then + die "bento espera ser executado como um usuário regular com sudo. Não rode como root." +fi + +if ! command -v apt-get >/dev/null 2>&1; then + distro="desconhecida" + if [[ -r /etc/os-release ]]; then + distro=$(. /etc/os-release && printf '%s' "$PRETTY_NAME") + fi + die "bento precisa de uma distro com apt-get (Ubuntu, Debian e derivados). Detectei: $distro" +fi + +if ! command -v sudo >/dev/null 2>&1; then + die "bento precisa de sudo instalado." +fi + +if ! ping -c1 -W2 github.com >/dev/null 2>&1; then + warn "github.com inalcançável — bento precisa de internet." +fi + +# Espaço em disco mínimo (~5 GB para Docker images + paperclip-custom). +disk_free_gb=$(df -BG --output=avail / | tail -1 | tr -d 'G ') +if (( disk_free_gb < 5 )); then + warn "Apenas ${disk_free_gb}GB livres em /. Recomendado: 20+GB." +fi + +# ----------------------------------------------------------------------------- +# Garantir git +# ----------------------------------------------------------------------------- +if ! command -v git >/dev/null 2>&1; then + say "Instalando git…" + sudo apt-get update -qq + sudo apt-get install -y -qq git +fi + +# ----------------------------------------------------------------------------- +# Clone (ou re-clone) do repo +# ----------------------------------------------------------------------------- +if [[ -d "$BENTO_HOME" ]]; then + say "Atualizando bento em $BENTO_HOME …" + rm -rf "$BENTO_HOME" +fi +mkdir -p "$(dirname "$BENTO_HOME")" +git clone --quiet --depth 1 --branch "$BENTO_REF" "$BENTO_REPO_URL" "$BENTO_HOME" + +# ----------------------------------------------------------------------------- +# Source install.sh +# ----------------------------------------------------------------------------- +# shellcheck disable=SC1091 +source "$BENTO_HOME/install.sh" diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..45a313f --- /dev/null +++ b/install.sh @@ -0,0 +1,304 @@ +#!/bin/bash +# bento — main interactive menu +# +# Sourced by boot.sh. Can also be run standalone after cloning. + +set -uo pipefail + +BENTO_REPO_ROOT="${BENTO_HOME:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" +export BENTO_REPO_ROOT + +# ----------------------------------------------------------------------------- +# Load libraries +# ----------------------------------------------------------------------------- +# shellcheck source=lib/deps.sh +source "${BENTO_REPO_ROOT}/lib/deps.sh" + +# Ensure gum + jq + envsubst are installed before sourcing UI modules. +if ! deps_ensure_all; then + echo "Failed to install bento dependencies." >&2 + exit 1 +fi + +# shellcheck source=lib/ui.sh +source "${BENTO_REPO_ROOT}/lib/ui.sh" +# shellcheck source=lib/banner.sh +source "${BENTO_REPO_ROOT}/lib/banner.sh" +# shellcheck source=lib/state.sh +source "${BENTO_REPO_ROOT}/lib/state.sh" +# shellcheck source=lib/portainer.sh +source "${BENTO_REPO_ROOT}/lib/portainer.sh" +# shellcheck source=lib/infra.sh +source "${BENTO_REPO_ROOT}/lib/infra.sh" +# shellcheck source=lib/stacks.sh +source "${BENTO_REPO_ROOT}/lib/stacks.sh" + +state_init + +# ----------------------------------------------------------------------------- +# Bootstrap inicial (single prompt screen with BASE_DOMAIN + ADMIN_EMAIL + IP) +# ----------------------------------------------------------------------------- +DOMAIN_REGEX='^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$' +EMAIL_REGEX='^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' +IP_REGEX='^([0-9]{1,3}\.){3}[0-9]{1,3}$' + +bootstrap_prompt_once() { + if state_has '.bootstrap.base_domain' \ + && state_has '.bootstrap.admin_email' \ + && state_has '.bootstrap.advertise_addr'; then + return 0 + fi + + ui_section "First-time setup" + ui_subtle "These values seed every stack you'll deploy. They're written to ~/.config/bento/state.json." + + local base_domain admin_email advertise_addr detected + while true; do + base_domain="$(ui_input "Base domain (e.g. mydomain.com)" "mydomain.com")" + if [[ "$base_domain" =~ $DOMAIN_REGEX ]]; then + break + fi + ui_warn "That doesn't look like a domain. Try again." + done + + while true; do + admin_email="$(ui_input "Admin email (Let's Encrypt + alerts)" "admin@${base_domain}")" + if [[ "$admin_email" =~ $EMAIL_REGEX ]]; then + break + fi + ui_warn "That doesn't look like an email. Try again." + done + + detected="$(curl -fsSL --max-time 5 https://ifconfig.me 2>/dev/null || true)" + while true; do + advertise_addr="$(ui_input "VPS public IP" "$detected" "$detected")" + if [[ "$advertise_addr" =~ $IP_REGEX ]]; then + break + fi + ui_warn "That doesn't look like an IPv4 address. Try again." + done + + ui_format_md <&1 | tee "$log_file"; then + state_set '.steps.hardening' "done" + else + state_set '.steps.hardening' "failed" + ui_error "Hardening failed — see $log_file" + return 1 + fi + + # Foundation tail: swarm + network. + infra_run_step1_tail || return 1 + + if [[ -f /var/lib/bento/reboot-required ]]; then + ui_boxed_warn "$(cat </dev/null 2>&1; then + local distro="unknown" + if [[ -r /etc/os-release ]]; then + # shellcheck disable=SC1091 + distro=$(. /etc/os-release && printf '%s' "$PRETTY_NAME") + fi + cat >&2 </dev/null 2>&1; then + return 0 + fi + + sudo mkdir -p /etc/apt/keyrings + if ! curl -fsSL "$BENTO_CHARM_KEY_URL" \ + | sudo gpg --dearmor --batch --yes -o /etc/apt/keyrings/charm.gpg; then + echo "Failed to fetch Charm signing key, falling back to release binary." >&2 + deps_install_gum_binary + return $? + fi + + echo "$BENTO_CHARM_REPO" | sudo tee /etc/apt/sources.list.d/charm.list >/dev/null + sudo apt-get update -qq + if ! sudo apt-get install -y -qq gum; then + deps_install_gum_binary + fi +} + +# Fallback: download the gum binary directly from a GitHub release. +deps_install_gum_binary() { + local arch tmpdir url + case "$(uname -m)" in + x86_64) arch="x86_64" ;; + aarch64) arch="arm64" ;; + *) echo "Unsupported arch $(uname -m) for gum binary fallback." >&2; return 1 ;; + esac + tmpdir=$(mktemp -d) + url="https://github.com/charmbracelet/gum/releases/latest/download/gum_Linux_${arch}.tar.gz" + curl -fsSL "$url" -o "$tmpdir/gum.tar.gz" || return 1 + tar -xzf "$tmpdir/gum.tar.gz" -C "$tmpdir" || return 1 + sudo install -m 0755 "$tmpdir"/gum_*/gum /usr/local/bin/gum + rm -rf "$tmpdir" +} + +deps_ensure_all() { + deps_check_apt || return 1 + deps_install_base || return 1 + deps_install_gum || return 1 +} diff --git a/lib/hardening.sh b/lib/hardening.sh new file mode 100644 index 0000000..e7bae69 --- /dev/null +++ b/lib/hardening.sh @@ -0,0 +1,501 @@ +#!/bin/bash +# bento — system hardening +# +# Adapted from https://github.com/felipefontoura/ubinkaze/blob/stable/install.sh +# Original by Felipe Fontoura, based on https://gist.github.com/rameerez/238927b78f9108a71a77aed34208de11 +# +# Differences from upstream ubinkaze: +# - Distro check relaxed: any apt-get-capable system passes (Ubuntu, Debian, +# Mint, Pop!_OS, etc.) instead of strict Ubuntu 26.04. +# - Drops a reboot sentinel at /var/lib/bento/reboot-required so the +# parent install.sh can detect and prompt cleanly. + +set -euo pipefail +IFS=$'\n\t' + +# --- Constants --- +MIN_RAM_MB=1024 +MIN_DISK_GB=20 +BENTO_REBOOT_SENTINEL=/var/lib/bento/reboot-required + +# --- Aesthetics --- +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +ICON='\xF0\x9F\x8C\x80' +NC='\033[0m' + +# --- Functions --- +print_message() { + local color=$1 + local message=$2 + echo -e "${color}${ICON} ${message}${NC}" +} + +print_error() { + print_message "${RED}" "ERROR: $1" +} + +print_warning() { + print_message "${YELLOW}" "WARNING: $1" +} + +print_success() { + print_message "${GREEN}" "SUCCESS: $1" +} + +check_root() { + if [[ $EUID -ne 0 ]]; then + print_error "This script must be run as root" + exit 1 + fi +} + +check_distro() { + if ! command -v apt-get >/dev/null 2>&1; then + print_error "bento hardening requires an apt-based distro (Ubuntu, Debian, Mint, Pop!_OS, etc.)" + exit 1 + fi + + local distro="unknown" + if [[ -r /etc/os-release ]]; then + # shellcheck disable=SC1091 + distro=$(. /etc/os-release && printf '%s' "$PRETTY_NAME") + fi + print_message "${GREEN}" "Detected: $distro" +} + +check_resources() { + local total_ram_mb=$(free -m | awk '/^Mem:/{print $2}') + local total_disk_gb=$(df -BG / | awk 'NR==2 {print $4}' | sed 's/G//') + + if ((total_ram_mb < MIN_RAM_MB)); then + print_error "Insufficient RAM. Required: ${MIN_RAM_MB}MB, Found: ${total_ram_mb}MB" + exit 1 + fi + + if ((total_disk_gb < MIN_DISK_GB)); then + print_error "Insufficient disk space. Required: ${MIN_DISK_GB}GB, Found: ${total_disk_gb}GB" + exit 1 + fi +} + +verify_security_settings() { + local failed=0 + + # Check kernel parameters + local params=( + "kernel.unprivileged_bpf_disabled=1" + "net.ipv4.conf.all.log_martians=0" + "net.ipv4.ip_forward=1" + "fs.protected_hardlinks=1" + "fs.protected_symlinks=1" + ) + + for param in "${params[@]}"; do + local name=${param%=*} + local expected=${param#*=} + local actual=$(sysctl -n "$name" 2>/dev/null || echo "NOT_FOUND") + + if [[ "$actual" != "$expected" ]]; then + print_error "Kernel parameter $name = $actual (expected $expected)" + failed=1 + fi + done + + # Check Docker settings + if ! docker info 2>/dev/null | grep -q "Cgroup Driver: systemd"; then + print_error "Docker is not using systemd cgroup driver" + failed=1 + fi + + if [[ "$(stat -c %a /var/run/docker.sock)" != "660" ]]; then + print_error "Docker socket has incorrect permissions" + failed=1 + fi + + # Check services + local services=( + "docker" + "fail2ban" + "ufw" + "auditd" + "chrony" + ) + + for service in "${services[@]}"; do + if ! systemctl is-active --quiet "$service"; then + print_error "Service $service is not running" + failed=1 + fi + done + + # Check AIDE database + if [ ! -f /var/lib/aide/aide.db ]; then + print_error "AIDE database not initialized" + failed=1 + fi + + # Check Chrony sync + if ! chronyc tracking &>/dev/null; then + print_error "Chrony is not syncing time" + failed=1 + fi + + # Additional security checks + if ! ufw status | grep -q "Status: active"; then + print_error "UFW firewall is not active" + failed=1 + fi + + if ! apparmor_status | grep -q "apparmor module is loaded."; then + print_error "AppArmor is not loaded" + failed=1 + fi + + return $failed +} + +handle_error() { + local line_number=$1 + print_error "Script failed on line ${line_number}" + print_error "Please check the logs above for more information" + exit 1 +} + +# Set up error handling +trap 'handle_error ${LINENO}' ERR + +# --- Pre-flight Checks --- +print_message "${YELLOW}" "Performing pre-flight checks..." +check_root +check_distro +check_resources + +# --- System Updates --- +print_message "${YELLOW}" "Updating system packages..." +apt-get update +DEBIAN_FRONTEND=noninteractive apt-get upgrade -y + +# --- Essential Packages --- +print_message "${YELLOW}" "Installing essential packages..." +DEBIAN_FRONTEND=noninteractive apt-get install -y \ + ufw \ + fail2ban \ + curl \ + wget \ + gnupg \ + lsb-release \ + ca-certificates \ + apt-transport-https \ + software-properties-common \ + sysstat \ + auditd \ + audispd-plugins \ + unattended-upgrades \ + acl \ + apparmor \ + apparmor-utils \ + aide \ + rkhunter \ + logwatch \ + git \ + python3-pyinotify + +# --- Time Synchronization --- +print_message "${YELLOW}" "Configuring time synchronization..." +systemctl stop systemd-timesyncd || true +systemctl disable systemd-timesyncd || true +apt-get remove -y systemd-timesyncd || true +DEBIAN_FRONTEND=noninteractive apt-get install -y chrony +if systemctl -q is-enabled systemd-timesyncd 2>/dev/null; then + systemctl disable systemd-timesyncd + systemctl stop systemd-timesyncd +fi +systemctl enable chrony.service || true # use .service to avoid alias issues +systemctl start chrony.service + +# --- System Hardening --- +print_message "${YELLOW}" "Configuring system security..." + +# Configure AppArmor +systemctl enable apparmor +systemctl start apparmor + +# Initialize AIDE +aide --config=/etc/aide/aide.conf --init +mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db + +# Configure kernel parameters +cat </etc/sysctl.d/99-security.conf +# Network security +net.ipv4.conf.all.send_redirects = 0 +net.ipv4.conf.default.send_redirects = 0 +net.ipv4.conf.all.accept_redirects = 0 +net.ipv4.conf.default.accept_redirects = 0 +net.ipv4.conf.all.rp_filter = 1 +net.ipv4.conf.default.rp_filter = 1 +net.ipv4.icmp_echo_ignore_broadcasts = 1 +net.ipv4.icmp_ignore_bogus_error_responses = 1 +net.ipv4.conf.all.accept_source_route = 0 +net.ipv4.conf.default.accept_source_route = 0 +net.ipv6.conf.all.accept_source_route = 0 +net.ipv6.conf.default.accept_source_route = 0 +net.ipv4.tcp_syncookies = 1 +net.ipv4.tcp_max_syn_backlog = 2048 +net.ipv4.tcp_synack_retries = 2 +net.ipv4.tcp_syn_retries = 5 + +# Docker needs IPv4 forwarding +net.ipv4.ip_forward = 1 + +# System limits +fs.file-max = 1048576 +kernel.pid_max = 65536 +net.ipv4.ip_local_port_range = 1024 65000 +net.ipv4.tcp_tw_reuse = 1 +vm.max_map_count = 262144 +kernel.kptr_restrict = 2 +kernel.dmesg_restrict = 1 +kernel.perf_event_paranoid = 3 +kernel.unprivileged_bpf_disabled = 1 +net.core.bpf_jit_harden = 2 +kernel.yama.ptrace_scope = 2 + +# File system hardening +fs.protected_hardlinks = 1 +fs.protected_symlinks = 1 +fs.suid_dumpable = 0 + +# Additional network hardening +net.ipv4.conf.all.log_martians = 0 +net.ipv4.conf.default.log_martians = 0 +net.ipv6.conf.all.accept_ra = 0 +net.ipv6.conf.default.accept_ra = 0 +EOF + +sysctl -p /etc/sysctl.d/99-security.conf +sysctl --system + +# Configure system limits +cat </etc/security/limits.d/docker.conf +* soft nproc 10000 +* hard nproc 10000 +* soft nofile 1048576 +* hard nofile 1048576 +* soft core 0 +* hard core 0 +* soft stack 8192 +* hard stack 8192 +EOF + +# --- Docker Installation --- +print_message "${YELLOW}" "Installing Docker..." +curl -fsSL https://get.docker.com -o get-docker.sh +sh get-docker.sh +rm get-docker.sh + +# --- Docker Configuration --- +print_message "${YELLOW}" "Configuring Docker..." +mkdir -p /etc/docker +cat </etc/docker/daemon.json +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + }, + "icc": true, + "live-restore": false, + "userland-proxy": false, + "no-new-privileges": true, + "default-ulimits": { + "nofile": { + "Name": "nofile", + "Hard": 64000, + "Soft": 64000 + } + }, + "features": { + "buildkit": true + }, + "experimental": false, + "default-runtime": "runc", + "storage-driver": "overlay2", + "metrics-addr": "127.0.0.1:9323", + "builder": { + "gc": { + "enabled": true, + "defaultKeepStorage": "20GB" + } + } +} +EOF + +# After Docker daemon.json configuration +print_message "${YELLOW}" "Testing Docker configuration..." +if ! docker info &>/dev/null; then + print_error "Docker failed to start. Checking configuration..." + journalctl -u docker.service --no-pager | tail -n 50 + exit 1 +fi + +systemctl enable docker +systemctl restart docker || { + print_error "Docker failed to start. Logs:" + journalctl -u docker.service --no-pager | tail -n 50 + exit 1 +} + +# Verify Docker configuration +print_message "${YELLOW}" "Verifying Docker configuration..." +docker info | grep -E "Cgroup Driver|Storage Driver|Logging Driver" + +# --- User Setup --- +print_message "${YELLOW}" "Creating docker user..." +if ! id -u docker >/dev/null 2>&1; then + adduser --system --group --shell /bin/bash --home /home/docker --disabled-password docker +fi +usermod -aG docker docker + +# --- SSH Configuration --- +print_message "${YELLOW}" "Configuring SSH..." +mkdir -p /home/docker/.ssh +chown -R docker:docker /home/docker +chmod 755 /home/docker + +# Copy root's authorized keys to docker user if they exist +if [ -f /root/.ssh/authorized_keys ]; then + cp /root/.ssh/authorized_keys /home/docker/.ssh/authorized_keys + chown -R docker:docker /home/docker/.ssh + chmod 700 /home/docker/.ssh + chmod 600 /home/docker/.ssh/authorized_keys +fi + +# --- Firewall Configuration --- +print_message "${YELLOW}" "Configuring firewall..." +ufw --force reset +ufw default deny incoming +ufw default allow outgoing +ufw allow ssh +ufw allow http +ufw allow https +ufw --force enable + +# --- fail2ban Configuration --- +print_message "${YELLOW}" "Configuring fail2ban..." + +cat </etc/fail2ban/filter.d/docker.conf +[Definition] +failregex = failed login attempt from +ignoreregex = +EOF + +cat </etc/fail2ban/jail.local +[DEFAULT] +bantime = 3600 +findtime = 600 +maxretry = 10 +banaction = ufw +banaction_allports = ufw + +[sshd] +enabled = true +port = ssh +filter = sshd +logpath = /var/log/auth.log +maxretry = 10 +bantime = 3600 + +[docker] +enabled = true +filter = docker +logpath = /var/log/auth.log +maxretry = 5 +bantime = 3600 +EOF + +# --- Enable and Start Services --- +print_message "${YELLOW}" "Enabling services..." +systemctl enable docker fail2ban auditd chrony +systemctl restart docker fail2ban auditd chrony + +# --- Verify Setup --- +print_message "${YELLOW}" "Verifying security settings..." +if verify_security_settings; then + print_success "Security verification passed" +else + print_warning "Some security checks failed. Please review the warnings above." +fi + +# Add logging configuration +print_message "${YELLOW}" "Configuring system logging..." +cat </etc/logrotate.d/docker-logs +/var/lib/docker/containers/*/*.log { + rotate 7 + daily + compress + size=100M + missingok + delaycompress + copytruncate +} +EOF + +# Automated cleanup to prevent residual files +print_message "${YELLOW}" "Setting up maintenance tasks..." +cat </etc/cron.daily/docker-cleanup +#!/bin/bash +docker system prune -af --volumes +docker builder prune -af --keep-storage=20GB +EOF +chmod +x /etc/cron.daily/docker-cleanup + +# Configure auditd +cat </etc/audit/rules.d/audit.rules +# Docker daemon configuration +-w /usr/bin/dockerd -k docker +-w /var/lib/docker -k docker +-w /etc/docker -k docker +-w /usr/lib/systemd/system/docker.service -k docker +-w /etc/default/docker -k docker +-w /etc/docker/daemon.json -k docker +-w /usr/bin/docker -k docker-bin +EOF + +# Reload audit rules +auditctl -R /etc/audit/rules.d/audit.rules + +# Configure unattended-upgrades for automated security updates +print_message "${YELLOW}" "Configuring automatic updates..." +cat </etc/apt/apt.conf.d/50unattended-upgrades +Unattended-Upgrade::Allowed-Origins { + "\${distro_id}:\${distro_codename}-security"; + "\${distro_id}ESMApps:\${distro_codename}-apps-security"; + "\${distro_id}ESM:\${distro_codename}-infra-security"; +}; +Unattended-Upgrade::Remove-Unused-Dependencies "true"; +Unattended-Upgrade::Automatic-Reboot "false"; +EOF + +# --- Final Cleanup --- +apt-get autoremove -y +apt-get clean + +# Drop reboot sentinel so the parent install.sh can detect. +mkdir -p "$(dirname "$BENTO_REBOOT_SENTINEL")" +date -Iseconds > "$BENTO_REBOOT_SENTINEL" + +print_success "Setup complete! System hardening successful." +print_message "${YELLOW}" "Important next steps:" +print_message "${YELLOW}" "1. Add your SSH public key to /home/docker/.ssh/authorized_keys" +print_message "${YELLOW}" "2. REBOOT THE SYSTEM to apply all security settings" +print_message "${YELLOW}" "3. After reboot, re-run bento to continue with infra setup" + +# Additional verification info +print_message "${GREEN}" "System Information:" +echo "Docker Version: $(docker --version)" +echo "Kernel Version: $(uname -r)" +echo "AppArmor Status: $(aa-status --enabled && echo 'Enabled' || echo 'Disabled')" +echo "UFW Status: $(ufw status | grep Status)" +echo "fail2ban Status: $(fail2ban-client status | grep "Number of jail:")" diff --git a/lib/infra.sh b/lib/infra.sh new file mode 100644 index 0000000..b6da729 --- /dev/null +++ b/lib/infra.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# bento — infra (Swarm + network + Traefik + Portainer) +# +# Step 1 tail: swarm + network (right after Docker is installed). +# Step 2: Deploy Traefik + Portainer, init Portainer admin. + +# shellcheck source=lib/ui.sh +source "$(dirname "${BASH_SOURCE[0]}")/ui.sh" +# shellcheck source=lib/state.sh +source "$(dirname "${BASH_SOURCE[0]}")/state.sh" +# shellcheck source=lib/portainer.sh +source "$(dirname "${BASH_SOURCE[0]}")/portainer.sh" + +readonly BENTO_REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly BENTO_INFRA_STACK_NAME="infra" + +# ----------------------------------------------------------------------------- +# Docker foundation (called as tail of Step 1). +# ----------------------------------------------------------------------------- +infra_is_swarm_active() { + docker info 2>/dev/null | grep -q "Swarm: active" +} + +infra_ensure_swarm() { + local advertise_addr + advertise_addr="$(state_get '.bootstrap.advertise_addr')" + + if infra_is_swarm_active; then + ui_info "Docker Swarm already active." + state_set '.foundation.swarm' "active" + return 0 + fi + + if [[ -z "$advertise_addr" ]]; then + ui_error "advertise_addr missing from state — re-run bootstrap." + return 1 + fi + + ui_spin "Initializing Docker Swarm…" \ + sudo docker swarm init --advertise-addr="$advertise_addr" + + state_set '.foundation.swarm' "active" + ui_success "Swarm active (advertising $advertise_addr)." +} + +infra_ensure_network() { + if sudo docker network inspect network_public >/dev/null 2>&1; then + ui_info "Network 'network_public' already exists." + state_set '.foundation.network_public' "ready" + return 0 + fi + + ui_spin "Creating overlay network 'network_public'…" \ + sudo docker network create --driver=overlay --attachable network_public + state_set '.foundation.network_public' "ready" + ui_success "Overlay network ready." +} + +# ----------------------------------------------------------------------------- +# Step 2 — deploy Traefik + Portainer. +# ----------------------------------------------------------------------------- +infra_deploy_stack_file() { + # Substitute env vars in a YAML and pipe to docker stack deploy as a single + # multi-service stack named 'infra'. + local yml="$1" + local stack_name="$2" + envsubst < "$yml" | sudo docker stack deploy \ + --with-registry-auth \ + --resolve-image always \ + -c - "$stack_name" +} + +infra_deploy_traefik_and_portainer() { + # Export everything envsubst needs. + export TRAEFIK_ACME_EMAIL + export PORTAINER_HOST + TRAEFIK_ACME_EMAIL="$(state_get '.bootstrap.admin_email')" + PORTAINER_HOST="portainer.$(state_get '.bootstrap.base_domain')" + + state_set '.bootstrap.portainer_host' "$PORTAINER_HOST" + state_set '.bootstrap.portainer_url' "http://127.0.0.1:9000" + + ui_section "Deploying Traefik" + infra_deploy_stack_file \ + "${BENTO_REPO_ROOT}/stacks/infra/traefik/compose.yml" \ + "$BENTO_INFRA_STACK_NAME" + + ui_section "Deploying Portainer" + infra_deploy_stack_file \ + "${BENTO_REPO_ROOT}/stacks/infra/portainer/compose.yml" \ + "$BENTO_INFRA_STACK_NAME" +} + +# Wait for Portainer to be reachable, then initialize the admin user. +infra_init_portainer_admin() { + ui_spin "Waiting for Portainer to come up…" \ + bash -c 'source "$1" && portainer_wait_ready "" 240' _ \ + "${BENTO_REPO_ROOT}/lib/portainer.sh" + + if ! portainer_wait_ready "" 30; then + ui_error "Portainer did not become ready in time." + return 1 + fi + + if [[ -f "${BENTO_STATE_DIR}/portainer.json" ]]; then + ui_info "Portainer admin already initialized." + return 0 + fi + + local password + password=$(openssl rand -base64 24 | tr -d '\n=' | head -c 32) + if ! portainer_init_admin "admin" "$password"; then + ui_error "Portainer admin init failed." + return 1 + fi + + state_set '.bootstrap.portainer_admin_user' "admin" + state_set '.foundation.portainer' "ready" + + local public_url + public_url="https://$(state_get '.bootstrap.portainer_host')" + + ui_boxed_success "$(cat <.install.sh sources this file and uses the helpers +# below to (re-)create databases, users, or whatever else needs bootstrapping +# AFTER `docker stack deploy` returns successfully. +# +# Available env vars (exported by lib/stacks.sh before invoking the script): +# BENTO_REPO_ROOT — absolute path to the bento clone +# BENTO_STACK_KEY — the manifest's "name" field (e.g. "plunk") +# BENTO_STATE_FILE — ~/.config/bento/state.json +# POSTGRES_PASSWORD — superuser password if postgres stack is deployed + +set -euo pipefail + +# ----------------------------------------------------------------------------- +# Container discovery +# ----------------------------------------------------------------------------- +_find_container() { + local pattern="$1" + local cid + cid=$(sudo docker ps --filter "name=$pattern" --format '{{.ID}}' | head -1) + if [[ -z "$cid" ]]; then + echo "Could not find a running container matching '$pattern'." >&2 + return 1 + fi + printf '%s' "$cid" +} + +postgres_container() { + _find_container 'db_postgres' +} + +# ----------------------------------------------------------------------------- +# Postgres helpers +# ----------------------------------------------------------------------------- +psql_exec() { + local sql="$1" + local cid + cid=$(postgres_container) || return 1 + sudo docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$cid" \ + psql -U postgres -tA -c "$sql" +} + +ensure_database() { + local db_name="$1" + if psql_exec "SELECT 1 FROM pg_database WHERE datname='${db_name}'" | grep -q 1; then + echo "Database '${db_name}' already exists." + return 0 + fi + psql_exec "CREATE DATABASE \"${db_name}\"" + echo "Created database '${db_name}'." +} + +ensure_db_user() { + # ensure_db_user + local user="$1" + local password="$2" + if psql_exec "SELECT 1 FROM pg_roles WHERE rolname='${user}'" | grep -q 1; then + psql_exec "ALTER ROLE \"${user}\" WITH LOGIN PASSWORD '${password}'" + echo "Updated password for role '${user}'." + else + psql_exec "CREATE ROLE \"${user}\" WITH LOGIN PASSWORD '${password}'" + echo "Created role '${user}'." + fi +} + +grant_db_ownership() { + # grant_db_ownership + local db="$1" + local user="$2" + psql_exec "ALTER DATABASE \"${db}\" OWNER TO \"${user}\"" + echo "Database '${db}' is now owned by '${user}'." +} + +# ----------------------------------------------------------------------------- +# Health waiters +# ----------------------------------------------------------------------------- +wait_for_service() { + # wait_for_service [timeout-seconds] + local svc="$1" + local timeout="${2:-120}" + local elapsed=0 desired actual + while (( elapsed < timeout )); do + desired=$(sudo docker service inspect "$svc" \ + --format '{{.Spec.Mode.Replicated.Replicas}}' 2>/dev/null || echo 0) + actual=$(sudo docker service ls --filter "name=$svc" \ + --format '{{.Replicas}}' | awk -F/ '{print $1}') + if [[ "$desired" != "0" && "$actual" == "$desired" ]]; then + return 0 + fi + sleep 3 + elapsed=$((elapsed + 3)) + done + echo "Service $svc not healthy within ${timeout}s." >&2 + return 1 +} diff --git a/lib/portainer.sh b/lib/portainer.sh new file mode 100644 index 0000000..5c092ae --- /dev/null +++ b/lib/portainer.sh @@ -0,0 +1,226 @@ +#!/bin/bash +# bento — Portainer REST API wrappers +# +# Every call here is logged when BENTO_VERBOSE=1. +# Auth state (credentials + JWT) lives in ~/.config/bento/portainer.json. + +# shellcheck source=lib/state.sh +source "$(dirname "${BASH_SOURCE[0]}")/state.sh" + +readonly BENTO_PORTAINER_CREDS="${BENTO_STATE_DIR}/portainer.json" + +portainer_base_url() { + state_get '.bootstrap.portainer_url' "http://127.0.0.1:9000" +} + +# Curl wrapper — adds verbose logging when BENTO_VERBOSE=1. +portainer_curl() { + if [[ "${BENTO_VERBOSE:-0}" == "1" ]]; then + printf '→ curl %s\n' "$*" >&2 + fi + curl --silent --show-error "$@" +} + +# Poll /api/system/status until Portainer responds (or timeout). +portainer_wait_ready() { + local base="${1:-$(portainer_base_url)}" + local max_seconds="${2:-180}" + local elapsed=0 + + while (( elapsed < max_seconds )); do + if portainer_curl -fsS "${base}/api/system/status" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) + done + return 1 +} + +# Initialize the very first admin. Body: { Username, Password }. +# Returns 0 on success (admin created or already exists). +portainer_init_admin() { + local username="$1" + local password="$2" + local base + base="$(portainer_base_url)" + + local body http_code + body=$(jq -n --arg u "$username" --arg p "$password" \ + '{Username: $u, Password: $p}') + + http_code=$(portainer_curl -o /tmp/bento-portainer-init.json -w '%{http_code}' \ + -X POST "${base}/api/users/admin/init" \ + -H 'Content-Type: application/json' \ + -d "$body") + + case "$http_code" in + 200|204) + portainer_persist_creds "$username" "$password" + return 0 ;; + 409) + # Admin already exists — that's fine if we already have creds. + [[ -f "$BENTO_PORTAINER_CREDS" ]] + return $? ;; + *) + echo "Portainer admin init failed (HTTP $http_code):" >&2 + cat /tmp/bento-portainer-init.json >&2 || true + return 1 ;; + esac +} + +portainer_persist_creds() { + local username="$1" + local password="$2" + jq -n --arg u "$username" --arg p "$password" \ + '{username: $u, password: $p}' > "$BENTO_PORTAINER_CREDS" + chmod 600 "$BENTO_PORTAINER_CREDS" +} + +# POST /api/auth — returns a fresh JWT. +portainer_login() { + local base body http_code username password + base="$(portainer_base_url)" + username=$(jq -r '.username' "$BENTO_PORTAINER_CREDS") + password=$(jq -r '.password' "$BENTO_PORTAINER_CREDS") + + body=$(jq -n --arg u "$username" --arg p "$password" \ + '{username: $u, password: $p}') + + http_code=$(portainer_curl -o /tmp/bento-portainer-auth.json -w '%{http_code}' \ + -X POST "${base}/api/auth" \ + -H 'Content-Type: application/json' \ + -d "$body") + + if [[ "$http_code" != "200" ]]; then + echo "Portainer auth failed (HTTP $http_code)." >&2 + return 1 + fi + + jq -r '.jwt' /tmp/bento-portainer-auth.json +} + +# Auth header builder — caches the JWT in BENTO_PORTAINER_JWT for the session. +portainer_auth_header() { + if [[ -z "${BENTO_PORTAINER_JWT:-}" ]]; then + BENTO_PORTAINER_JWT=$(portainer_login) + export BENTO_PORTAINER_JWT + fi + printf 'Authorization: Bearer %s' "$BENTO_PORTAINER_JWT" +} + +# Get the default endpoint ID (usually 1 in a single-node Swarm). +portainer_endpoint_id() { + local base auth + base="$(portainer_base_url)" + auth="$(portainer_auth_header)" + + portainer_curl -fsS "${base}/api/endpoints" \ + -H "$auth" \ + | jq -r '.[0].Id' +} + +# Get the Swarm ID for the default endpoint. +portainer_swarm_id() { + local base auth endpoint_id + base="$(portainer_base_url)" + auth="$(portainer_auth_header)" + endpoint_id="$(portainer_endpoint_id)" + + portainer_curl -fsS "${base}/api/endpoints/${endpoint_id}/docker/swarm" \ + -H "$auth" \ + | jq -r '.ID' +} + +# Create a Swarm stack from a Git repository. +# Args: +# $1 — stack name +# $2 — compose file path inside the repo +# $3 — JSON array of {name, value} env vars +# $4 — repo URL (default felipefontoura/bento) +# $5 — git ref (default refs/heads/main) +portainer_create_stack_from_git() { + local stack_name="$1" + local compose_path="$2" + local env_json="$3" + local repo_url="${4:-https://github.com/felipefontoura/bento}" + local ref="${5:-refs/heads/main}" + + local base auth endpoint_id swarm_id body http_code + base="$(portainer_base_url)" + auth="$(portainer_auth_header)" + endpoint_id="$(portainer_endpoint_id)" + swarm_id="$(portainer_swarm_id)" + + body=$(jq -n \ + --arg name "$stack_name" \ + --arg swarm "$swarm_id" \ + --arg url "$repo_url" \ + --arg ref "$ref" \ + --arg compose "$compose_path" \ + --argjson env "$env_json" \ + '{ + name: $name, + swarmID: $swarm, + repositoryURL: $url, + repositoryReferenceName: $ref, + composeFile: $compose, + env: $env + }') + + http_code=$(portainer_curl -o /tmp/bento-portainer-stack.json -w '%{http_code}' \ + -X POST "${base}/api/stacks/create/swarm/repository?endpointId=${endpoint_id}" \ + -H "$auth" \ + -H 'Content-Type: application/json' \ + -d "$body") + + if [[ "$http_code" != "200" ]]; then + echo "Portainer stack create failed (HTTP $http_code):" >&2 + cat /tmp/bento-portainer-stack.json >&2 || true + return 1 + fi + + jq -r '.Id' /tmp/bento-portainer-stack.json +} + +# List all stacks. +portainer_list_stacks() { + local base auth + base="$(portainer_base_url)" + auth="$(portainer_auth_header)" + portainer_curl -fsS "${base}/api/stacks" -H "$auth" +} + +# Get details for one stack. +portainer_get_stack() { + local stack_id="$1" + local base auth + base="$(portainer_base_url)" + auth="$(portainer_auth_header)" + portainer_curl -fsS "${base}/api/stacks/${stack_id}" -H "$auth" +} + +# Redeploy a Git-backed stack — pulls latest commit + new images. +portainer_redeploy_stack() { + local stack_id="$1" + local env_json="${2:-[]}" + local base auth endpoint_id body http_code + base="$(portainer_base_url)" + auth="$(portainer_auth_header)" + endpoint_id="$(portainer_endpoint_id)" + + body=$(jq -n --argjson env "$env_json" \ + '{env: $env, prune: false, pullImage: true}') + + http_code=$(portainer_curl -o /tmp/bento-portainer-redeploy.json -w '%{http_code}' \ + -X PUT "${base}/api/stacks/${stack_id}/git/redeploy?endpointId=${endpoint_id}" \ + -H "$auth" \ + -H 'Content-Type: application/json' \ + -d "$body") + + if [[ "$http_code" != "200" ]]; then + echo "Portainer redeploy failed (HTTP $http_code):" >&2 + cat /tmp/bento-portainer-redeploy.json >&2 || true + return 1 + fi +} diff --git a/lib/stacks.sh b/lib/stacks.sh new file mode 100644 index 0000000..9cedb58 --- /dev/null +++ b/lib/stacks.sh @@ -0,0 +1,310 @@ +#!/bin/bash +# bento — app stacks (manifest + deploy via Portainer API) +# +# Reads per-stack manifests (stacks/*/.manifest.json), resolves envs in +# the documented order (state → from_state → default → generate → prompt), +# deploys via Portainer's "create stack from Git repository" endpoint, then +# runs the optional per-stack install.sh post-deploy hook. + +# shellcheck source=lib/ui.sh +source "$(dirname "${BASH_SOURCE[0]}")/ui.sh" +# shellcheck source=lib/state.sh +source "$(dirname "${BASH_SOURCE[0]}")/state.sh" +# shellcheck source=lib/portainer.sh +source "$(dirname "${BASH_SOURCE[0]}")/portainer.sh" + +readonly BENTO_REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly BENTO_REPO_URL="https://github.com/felipefontoura/bento" +readonly BENTO_REPO_REF="refs/heads/main" + +# ----------------------------------------------------------------------------- +# Manifest discovery +# +# Layout: stacks///{compose.yml, manifest.json, install.sh} +# Stack key == directory name. +# ----------------------------------------------------------------------------- +stacks_list_manifests() { + find "${BENTO_REPO_ROOT}/stacks" -mindepth 3 -maxdepth 3 -type f -name 'manifest.json' | sort +} + +stacks_list_app_manifests() { + find "${BENTO_REPO_ROOT}/stacks/app" -mindepth 2 -maxdepth 2 -type f -name 'manifest.json' 2>/dev/null | sort +} + +stacks_manifest_for_key() { + local key="$1" + local hit + hit=$(find "${BENTO_REPO_ROOT}/stacks" -mindepth 3 -maxdepth 3 -type f -name 'manifest.json' -path "*/${key}/manifest.json" | head -1) + [[ -n "$hit" ]] && printf '%s' "$hit" +} + +# Convention: compose lives next to manifest as compose.yml. +stacks_compose_path_for_manifest() { + local manifest_path="$1" + local override + override=$(jq -r '.compose_path // empty' "$manifest_path") + if [[ -n "$override" ]]; then + printf '%s' "$override" + else + # Path relative to repo root. + local dir="$(dirname "$manifest_path")" + printf '%s' "${dir#${BENTO_REPO_ROOT}/}/compose.yml" + fi +} + +# Convention: install.sh lives next to manifest. Optional. +stacks_install_script_for_manifest() { + local manifest_path="$1" + local override + override=$(jq -r '.install_script // empty' "$manifest_path") + if [[ -n "$override" ]]; then + printf '%s' "$override" + return 0 + fi + local dir="$(dirname "$manifest_path")" + if [[ -x "$dir/install.sh" ]]; then + printf '%s' "${dir#${BENTO_REPO_ROOT}/}/install.sh" + fi +} + +# ----------------------------------------------------------------------------- +# Template substitution — replaces ${VAR} from state.bootstrap.* + envs already +# resolved for this stack. +# ----------------------------------------------------------------------------- +stacks_substitute_template() { + local template="$1" + local base_domain admin_email + base_domain="$(state_get '.bootstrap.base_domain')" + admin_email="$(state_get '.bootstrap.admin_email')" + + BASE_DOMAIN="$base_domain" \ + ADMIN_EMAIL="$admin_email" \ + envsubst <<< "$template" +} + +# ----------------------------------------------------------------------------- +# Env resolution: implements the documented order from the plan. +# 1. state.envs[][] → reuse +# 2. manifest.from_state → pull another state var +# 3. manifest.default → use as default in prompt +# 4. manifest.generate → run command, mark hide +# 5. manifest.prompt (required) → must ask +# 6. manifest.prompt (optional) → ask, skippable +# ----------------------------------------------------------------------------- +stacks_resolve_env() { + local stack_key="$1" + local env_spec="$2" # single JSON object from manifest.env array + + local var_name from_state default_tpl generate_cmd prompt required hide existing + var_name=$(jq -r '.name' <<< "$env_spec") + from_state=$(jq -r '.from_state // empty' <<< "$env_spec") + default_tpl=$(jq -r '.default // empty' <<< "$env_spec") + generate_cmd=$(jq -r '.generate // empty' <<< "$env_spec") + prompt=$(jq -r '.prompt // empty' <<< "$env_spec") + required=$(jq -r '.required // false' <<< "$env_spec") + hide=$(jq -r '.hide // false' <<< "$env_spec") + + # 1. Existing state. + existing="$(state_get ".envs[\"$stack_key\"][\"$var_name\"]")" + if [[ -n "$existing" ]]; then + printf '%s' "$existing" + return 0 + fi + + # 2. from_state. + if [[ -n "$from_state" ]]; then + local sourced + sourced="$(state_get ".envs[\"global\"][\"$from_state\"]")" + if [[ -z "$sourced" ]]; then + sourced="$(state_get ".bootstrap.$from_state")" + fi + if [[ -n "$sourced" ]]; then + state_set ".envs[\"$stack_key\"][\"$var_name\"]" "$sourced" + printf '%s' "$sourced" + return 0 + fi + fi + + # 4. generate (handled before prompt so we don't ask for things we make). + if [[ -n "$generate_cmd" ]]; then + local generated + generated=$(bash -c "$generate_cmd") + state_set ".envs[\"$stack_key\"][\"$var_name\"]" "$generated" + printf '%s' "$generated" + return 0 + fi + + # 3. default template (substituted). + local default_value="" + if [[ -n "$default_tpl" ]]; then + default_value="$(stacks_substitute_template "$default_tpl")" + fi + + # 5/6. prompt the user. + if [[ -n "$prompt" ]]; then + local answer prompt_label + prompt_label="$var_name — $prompt" + if [[ "$hide" == "true" ]]; then + answer="$(ui_password "$prompt_label")" + else + answer="$(ui_input "$prompt_label" "$default_value" "$default_value")" + fi + if [[ -z "$answer" && "$required" == "true" ]]; then + ui_error "$var_name is required." + return 1 + fi + state_set ".envs[\"$stack_key\"][\"$var_name\"]" "$answer" + printf '%s' "$answer" + return 0 + fi + + # No prompt — just use the default if there is one. + if [[ -n "$default_value" ]]; then + state_set ".envs[\"$stack_key\"][\"$var_name\"]" "$default_value" + printf '%s' "$default_value" + fi +} + +# Build the env array Portainer expects ([{name, value}, …]). +stacks_build_env_payload() { + local stack_key="$1" + local manifest_path="$2" + + local env_entries=() + local env_spec value var_name + + while IFS= read -r env_spec; do + var_name=$(jq -r '.name' <<< "$env_spec") + value="$(stacks_resolve_env "$stack_key" "$env_spec")" || return 1 + env_entries+=("$(jq -n --arg n "$var_name" --arg v "$value" '{name: $n, value: $v}')") + done < <(jq -c '.env[]?' "$manifest_path") + + # Always add the tracking labels so bento knows which stacks it owns. + local git_sha + git_sha=$(git -C "$BENTO_REPO_ROOT" rev-parse HEAD 2>/dev/null || echo "unknown") + + env_entries+=("$(jq -n '{name: "BENTO_MANAGED", value: "true"}')") + env_entries+=("$(jq -n --arg v "$git_sha" '{name: "BENTO_DEPLOYED_REF", value: $v}')") + env_entries+=("$(jq -n --arg v "$stack_key" '{name: "BENTO_STACK_KEY", value: $v}')") + + printf '[' + local first=1 + for e in "${env_entries[@]}"; do + if (( first )); then first=0; else printf ','; fi + printf '%s' "$e" + done + printf ']' +} + +# ----------------------------------------------------------------------------- +# Deploy +# ----------------------------------------------------------------------------- +stacks_deploy() { + local manifest_path="$1" + local stack_key compose_path stack_name + stack_key=$(jq -r '.name' "$manifest_path") + compose_path="$(stacks_compose_path_for_manifest "$manifest_path")" + stack_name="$stack_key" + + ui_section "Deploying $stack_key" + + local env_payload stack_id + env_payload="$(stacks_build_env_payload "$stack_key" "$manifest_path")" || return 1 + + stack_id=$(portainer_create_stack_from_git \ + "$stack_name" \ + "$compose_path" \ + "$env_payload" \ + "$BENTO_REPO_URL" \ + "$BENTO_REPO_REF") || return 1 + + state_set ".stacks[\"$stack_key\"].stack_id" "$stack_id" + state_set ".stacks[\"$stack_key\"].deployed_ref" "$(git -C "$BENTO_REPO_ROOT" rev-parse HEAD 2>/dev/null || echo unknown)" + + ui_success "$stack_key deployed (Portainer stack #$stack_id)." + + # Run optional post-deploy hook. Install scripts get a known set of env + # vars so they can call helpers from lib/install-helpers.sh. + local install_script + install_script="$(stacks_install_script_for_manifest "$manifest_path")" + if [[ -n "$install_script" && -x "${BENTO_REPO_ROOT}/${install_script}" ]]; then + ui_info "Running post-deploy script: $install_script" + local pg_pass + pg_pass="$(state_get '.envs["postgres"]["POSTGRES_PASSWORD"]')" + BENTO_REPO_ROOT="$BENTO_REPO_ROOT" \ + BENTO_STACK_KEY="$stack_key" \ + BENTO_STATE_FILE="$BENTO_STATE_FILE" \ + POSTGRES_PASSWORD="$pg_pass" \ + "${BENTO_REPO_ROOT}/${install_script}" + fi + + # Print URL if manifest declares post_deploy_url. + local url_tpl resolved_url + url_tpl=$(jq -r '.post_deploy_url // empty' "$manifest_path") + if [[ -n "$url_tpl" ]]; then + resolved_url="$(stacks_substitute_template_with_stack_envs "$stack_key" "$url_tpl")" + ui_boxed_success "$stack_key is ready at: $resolved_url" + fi +} + +# Substitute template using both bootstrap envs AND the stack's resolved envs. +stacks_substitute_template_with_stack_envs() { + local stack_key="$1" + local template="$2" + + local env_kv envs_args=() + while IFS= read -r env_kv; do + envs_args+=("$env_kv") + done < <(jq -r ".envs[\"$stack_key\"] // {} | to_entries[] | \"\(.key)=\(.value)\"" "$BENTO_STATE_FILE") + + local base_domain admin_email + base_domain="$(state_get '.bootstrap.base_domain')" + admin_email="$(state_get '.bootstrap.admin_email')" + + env -i \ + BASE_DOMAIN="$base_domain" \ + ADMIN_EMAIL="$admin_email" \ + "${envs_args[@]}" \ + envsubst <<< "$template" +} + +# ----------------------------------------------------------------------------- +# Menu — Step 3 +# ----------------------------------------------------------------------------- +stacks_step3_menu() { + local manifests=() + while IFS= read -r m; do manifests+=("$m"); done < <(stacks_list_app_manifests) + + if (( ${#manifests[@]} == 0 )); then + ui_warn "No app stack manifests found yet." + return 0 + fi + + # Build "name — description" labels for gum choose. + local labels=() label name desc + for m in "${manifests[@]}"; do + name=$(jq -r '.name' "$m") + desc=$(jq -r '.description // ""' "$m") + labels+=("${name} — ${desc}") + done + + local picks + picks="$(printf '%s\n' "${labels[@]}" | ui_choose_multi)" + [[ -z "$picks" ]] && return 0 + + while IFS= read -r picked; do + local picked_name + picked_name="${picked%% — *}" + local m + m=$(stacks_manifest_for_key "$picked_name") + if [[ -n "$m" ]]; then + stacks_deploy "$m" || ui_error "Deploy of $picked_name failed; continuing." + fi + done <<< "$picks" + + state_set '.steps.apps' "done" +} + +stacks_is_apps_done() { + [[ "$(state_get '.steps.apps')" == "done" ]] +} diff --git a/lib/state.sh b/lib/state.sh new file mode 100644 index 0000000..5427b71 --- /dev/null +++ b/lib/state.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# bento — state management +# +# State lives in ~/.config/bento/state.json. Schema is versioned; on read, +# state_migrate runs migrations to the current schema version. +# +# Functions: +# state_init — create dir + empty state if missing +# state_get — read a value with jq +# state_set — write a value (atomic via tmpfile + mv) +# state_has — exit 0 if path exists with non-null value +# state_migrate — bump schema_version if needed +# +# Convention: jq paths use jq syntax, e.g. ".bootstrap.base_domain" + +readonly BENTO_STATE_DIR="${HOME}/.config/bento" +readonly BENTO_STATE_FILE="${BENTO_STATE_DIR}/state.json" +readonly BENTO_STATE_HISTORY_DIR="${BENTO_STATE_DIR}/history" +readonly BENTO_LOG_DIR="${HOME}/.local/state/bento/logs" +readonly BENTO_STATE_SCHEMA=1 + +state_init() { + mkdir -p "$BENTO_STATE_DIR" "$BENTO_STATE_HISTORY_DIR" "$BENTO_LOG_DIR" + chmod 700 "$BENTO_STATE_DIR" + if [[ ! -f "$BENTO_STATE_FILE" ]]; then + printf '{"schema_version": %d}\n' "$BENTO_STATE_SCHEMA" > "$BENTO_STATE_FILE" + chmod 600 "$BENTO_STATE_FILE" + fi + state_migrate +} + +state_migrate() { + local current + current=$(jq -r '.schema_version // 0' "$BENTO_STATE_FILE" 2>/dev/null || echo 0) + if (( current < BENTO_STATE_SCHEMA )); then + # Future migrations: case statement per version. + state_set '.schema_version' "$BENTO_STATE_SCHEMA" + fi +} + +state_get() { + local path="$1" + local default="${2:-}" + local result + result=$(jq -r "${path} // empty" "$BENTO_STATE_FILE" 2>/dev/null) + if [[ -z "$result" || "$result" == "null" ]]; then + printf '%s' "$default" + else + printf '%s' "$result" + fi +} + +state_set() { + local path="$1" + local value="$2" + local tmp + tmp=$(mktemp "${BENTO_STATE_FILE}.XXXXXX") + + if [[ "$value" =~ ^(true|false|null)$ || "$value" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then + # numeric or boolean — pass raw + jq "${path} = ${value}" "$BENTO_STATE_FILE" > "$tmp" + else + # string — quote + jq --arg v "$value" "${path} = \$v" "$BENTO_STATE_FILE" > "$tmp" + fi + mv "$tmp" "$BENTO_STATE_FILE" + chmod 600 "$BENTO_STATE_FILE" +} + +# Set a raw JSON value (object, array, complex). Caller must provide valid JSON. +state_set_json() { + local path="$1" + local json="$2" + local tmp + tmp=$(mktemp "${BENTO_STATE_FILE}.XXXXXX") + jq --argjson v "$json" "${path} = \$v" "$BENTO_STATE_FILE" > "$tmp" + mv "$tmp" "$BENTO_STATE_FILE" + chmod 600 "$BENTO_STATE_FILE" +} + +state_has() { + local path="$1" + local result + result=$(jq -r "${path} // empty" "$BENTO_STATE_FILE" 2>/dev/null) + [[ -n "$result" && "$result" != "null" ]] +} + +# Snapshot the current state to history before destructive operations. +state_snapshot() { + local ts + ts=$(date +%Y%m%d-%H%M%S) + cp "$BENTO_STATE_FILE" "${BENTO_STATE_HISTORY_DIR}/state-${ts}.json" + chmod 600 "${BENTO_STATE_HISTORY_DIR}/state-${ts}.json" +} + +state_path() { + printf '%s' "$BENTO_STATE_FILE" +} diff --git a/lib/ui.sh b/lib/ui.sh new file mode 100644 index 0000000..4441638 --- /dev/null +++ b/lib/ui.sh @@ -0,0 +1,169 @@ +#!/bin/bash +# bento — UI helpers (gum wrappers + palette) +# +# All terminal styling routes through here so the look stays consistent. +# Requires gum (installed by lib/deps.sh). + +# ----------------------------------------------------------------------------- +# Palette — bento bento bento. Light on salmon, accents on wasabi. +# ----------------------------------------------------------------------------- +readonly BENTO_COLOR_SALMON="#FF6B6B" # primary accent (salmão do sushi) +readonly BENTO_COLOR_WASABI="#06D6A0" # info / progress +readonly BENTO_COLOR_RICE="#FAF3DD" # neutral foreground on dark bg +readonly BENTO_COLOR_NORI="#293241" # background for boxes +readonly BENTO_COLOR_SUCCESS="#06D6A0" +readonly BENTO_COLOR_WARNING="#FFD166" +readonly BENTO_COLOR_DANGER="#EF476F" +readonly BENTO_COLOR_MUTED="240" + +# ----------------------------------------------------------------------------- +# Status indicators (used in menu + status table) +# ----------------------------------------------------------------------------- +ui_status_icon() { + case "$1" in + done) printf '✓' ;; + pending) printf '⏵' ;; + running) printf '…' ;; + failed) printf '✗' ;; + locked) printf '🔒' ;; + *) printf '·' ;; + esac +} + +ui_status_color() { + case "$1" in + done) printf '%s' "$BENTO_COLOR_SUCCESS" ;; + pending) printf '%s' "$BENTO_COLOR_SALMON" ;; + running) printf '%s' "$BENTO_COLOR_WASABI" ;; + failed) printf '%s' "$BENTO_COLOR_DANGER" ;; + locked) printf '%s' "$BENTO_COLOR_MUTED" ;; + *) printf '%s' "$BENTO_COLOR_MUTED" ;; + esac +} + +# ----------------------------------------------------------------------------- +# Headers, sections, dividers +# ----------------------------------------------------------------------------- +ui_section() { + gum style \ + --foreground="$BENTO_COLOR_SALMON" \ + --bold \ + --margin="1 0 0 0" \ + "▸ $1" +} + +ui_subtle() { + gum style --foreground="$BENTO_COLOR_MUTED" --italic "$1" +} + +ui_divider() { + gum style --foreground="$BENTO_COLOR_MUTED" "$(printf '─%.0s' $(seq 1 60))" +} + +# ----------------------------------------------------------------------------- +# Status messages — wrap gum log + add icons +# ----------------------------------------------------------------------------- +ui_info() { + gum style --foreground="$BENTO_COLOR_WASABI" "ℹ $*" +} + +ui_success() { + gum style --foreground="$BENTO_COLOR_SUCCESS" --bold "✓ $*" +} + +ui_warn() { + gum style --foreground="$BENTO_COLOR_WARNING" "⚠ $*" +} + +ui_error() { + gum style --foreground="$BENTO_COLOR_DANGER" --bold "✗ $*" >&2 +} + +# Boxed success — for "you're done, here are the credentials" moments. +ui_boxed_success() { + gum style \ + --border="rounded" \ + --border-foreground="$BENTO_COLOR_SUCCESS" \ + --padding="1 2" \ + --margin="1 0" \ + --foreground="$BENTO_COLOR_RICE" \ + "$@" +} + +ui_boxed_warn() { + gum style \ + --border="rounded" \ + --border-foreground="$BENTO_COLOR_WARNING" \ + --padding="1 2" \ + --margin="1 0" \ + --foreground="$BENTO_COLOR_RICE" \ + "$@" +} + +# ----------------------------------------------------------------------------- +# Prompts — thin wrappers around gum so callers don't repeat styling. +# ----------------------------------------------------------------------------- +ui_input() { + local prompt="$1" + local placeholder="${2:-}" + local default="${3:-}" + gum input \ + --prompt="$prompt " \ + --prompt.foreground="$BENTO_COLOR_SALMON" \ + --placeholder="$placeholder" \ + --value="$default" \ + --width=60 +} + +ui_password() { + local prompt="$1" + gum input \ + --prompt="$prompt " \ + --prompt.foreground="$BENTO_COLOR_SALMON" \ + --password \ + --width=60 +} + +ui_confirm() { + gum confirm \ + --selected.background="$BENTO_COLOR_SALMON" \ + --prompt.foreground="$BENTO_COLOR_RICE" \ + "$@" +} + +ui_choose() { + gum choose \ + --cursor.foreground="$BENTO_COLOR_SALMON" \ + --selected.foreground="$BENTO_COLOR_WASABI" \ + "$@" +} + +ui_choose_multi() { + gum choose --no-limit \ + --cursor.foreground="$BENTO_COLOR_SALMON" \ + --selected.foreground="$BENTO_COLOR_WASABI" \ + --cursor-prefix="◯ " \ + --selected-prefix="◉ " \ + --unselected-prefix="◯ " \ + "$@" +} + +ui_spin() { + local title="$1" + shift + gum spin \ + --spinner=dot \ + --title="$title" \ + --spinner.foreground="$BENTO_COLOR_WASABI" \ + --title.foreground="$BENTO_COLOR_RICE" \ + -- "$@" +} + +ui_format_md() { + gum format --type=markdown "$@" +} + +ui_pause() { + gum style --foreground="$BENTO_COLOR_MUTED" "Press enter to continue..." + read -r _ +} From 7cc0343496728058f9d851a4645c0663ce8a3432 Mon Sep 17 00:00:00 2001 From: Felipe Fontoura Date: Thu, 4 Jun 2026 12:39:29 -0300 Subject: [PATCH 03/72] docs: add CLAUDE.md maintainer guide and add-app-stack skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md is the canonical maintainer guide loaded automatically by Claude Code (and equally usable by humans): explains the bento mental model, the Bento ↔ Portainer ownership boundary, the per-stack directory convention, manifest schema, env resolution order, code style, and a step-by-step "how to add a new application stack" recipe with n8n called out as the gold-standard quality bar. .claude/skills/add-app-stack/SKILL.md operationalizes the recipe for AI assistants: requires fetching the upstream project's docker-compose.yml, .env.example, and latest release tag via gh api BEFORE writing anything, so envs and image tags come from the project's own truth instead of model training data. --- .claude/skills/add-app-stack/SKILL.md | 257 +++++++++++ CLAUDE.md | 603 ++++++++++++++++++++++++++ 2 files changed, 860 insertions(+) create mode 100644 .claude/skills/add-app-stack/SKILL.md create mode 100644 CLAUDE.md diff --git a/.claude/skills/add-app-stack/SKILL.md b/.claude/skills/add-app-stack/SKILL.md new file mode 100644 index 0000000..7a1b47f --- /dev/null +++ b/.claude/skills/add-app-stack/SKILL.md @@ -0,0 +1,257 @@ +--- +name: add-app-stack +description: Scaffold a new application stack in the bento repo following the established conventions (per-stack directory with compose.yml + manifest.json + optional install.sh) +--- + +# add-app-stack + +Use this skill when the user asks to add a new application stack to the +bento repo. Always start by reading `CLAUDE.md` for current conventions — +prefer its rules over anything stated here if they ever drift. + +## When to invoke + +Trigger phrases include: +- "add a new stack for " +- "I want to bundle into bento" +- "create a stack: " +- "scaffold " + +If the user is just asking to **deploy** an existing stack via the menu, +that is not this skill — point them at `Step 3` in the install menu. + +## Required inputs + +Ask for these up front if missing: + +1. **Stack key** — short, kebab-case, lowercase (e.g. `n8n`, `cli-proxy-api`). + Will be the directory name and the user-visible label in the menu. +2. **Upstream GitHub repo** — `/` (e.g. `n8n-io/n8n`). This is + non-negotiable: the skill fetches the project's own reference + `docker-compose.yml` and `.env.example` before writing anything. +3. **Public-facing host** — does this app expose an HTTP UI/API behind + Traefik? If yes, the default host pattern is `.${BASE_DOMAIN}`. + If no, no Traefik labels are needed. + +Everything else (image tag, env vars, secrets, dependencies, ports, +healthcheck endpoint) is **derived from the upstream repo** in step 1 +below — do not invent any of it from training data. + +## Execution + +Always run these steps **in order** with `TaskCreate` so progress is visible: + +### 1. Fetch the upstream reference (mandatory) + +Use `gh` to pull the project's own docker artifacts. **Do not guess env +vars or image tags from training data** — open-source projects keep these +in the repo, and that is the source of truth. + +```bash +OWNER_REPO="/" + +# Latest stable release tag — pin this in compose.yml, not :latest. +gh api "repos/${OWNER_REPO}/releases/latest" --jq '.tag_name' + +# Reference docker-compose. Try the common paths. +for path in docker-compose.yml docker-compose.yaml compose.yml \ + compose.yaml docker/docker-compose.yml; do + gh api "repos/${OWNER_REPO}/contents/${path}" --jq '.content // empty' \ + 2>/dev/null | base64 -d && echo "--- from ${path}" && break +done + +# Env example. +gh api "repos/${OWNER_REPO}/contents/.env.example" --jq '.content' \ + 2>/dev/null | base64 -d + +# README, especially the "Docker" / "Self-hosting" section. +gh api "repos/${OWNER_REPO}/readme" --jq '.content' | base64 -d +``` + +If `gh api contents/` returns nothing, search: + +```bash +gh api search/code -X GET \ + -f q="repo:${OWNER_REPO} filename:docker-compose" +``` + +For projects whose docs live outside the GitHub repo (their own website, +Notion, etc.), use `WebFetch` against the documented deployment page. + +From the upstream artifacts, extract: + +- **Image and tag** — pin the latest stable release. +- **All env vars** with purpose + default + whether secret. +- **Required ports** + healthcheck endpoint. +- **Dependencies** — Postgres? Redis? S3-compatible? Mail relay? +- **Volumes** the app expects. +- **Multi-service shape** — does it deploy a separate worker/webhook/UI? + +### 2. Read the closest existing bento stack + +Pick the analogue and read its three files. **n8n is the gold standard for +parametrization quality** — copy its env-block layout (commented +categories, one-line WHY per var) unless the app is genuinely simpler. + +- **Gold standard / categorized env block / multi-service**: `stacks/app/n8n/`. +- **Custom-built image**: `stacks/app/paperclip/`. +- **Rails-style with DB migrations**: `stacks/app/chatwoot/`. +- **Genuinely tiny (no meaningful knobs)**: `stacks/app/cli-proxy-api/`. + +Mirror the patterns exactly. Do not invent new structure or naming. + +### 3. Create the directory + +`stacks/app//` + +### 4. Write `compose.yml` + +Use the upstream reference as the base, then translate to bento +conventions. Aim for n8n's quality bar (see CLAUDE.md → Quality bar). + +Parametrize everything that varies per deployment: + +| Was hardcoded | Replace with | +|---|---| +| `Host(\`xxx.website.com\`)` | `` Host(`${KEY_HOST}`) `` | +| `APP_URI=https://xxx.website.com` | `APP_URI=https://${KEY_HOST}` | +| `JWT_SECRET=secret` | `JWT_SECRET=${KEY_JWT_SECRET}` | +| `postgresql://app:secret@postgres:5432/db` | `postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/db` | +| `external: true` (volumes other than `network_public`) | `driver: local` | + +Always include a healthcheck for long-running services. Use `network_public` +external and `network_public` as the only network attached. + +**Env block layout** — group variables into commented sections with the +same pattern n8n uses: + +```yaml +environment: + # ============================================================================= + # Core Application + # ============================================================================= + + # Brief WHY this matters or what it controls. + - SOME_VAR=${SOME_VAR} + + # ============================================================================= + # Database + # ============================================================================= + + # … +``` + +Categories that recur across stacks: `Core Application`, +`Observability / Logging`, `Public URLs / Reverse Proxy`, +`Database (PostgreSQL)`, `Cache / Queue (Redis)`, `Security`, `Email (SMTP)`, +`OAuth / Authentication`, `Storage (S3)`, `Workers / Queue Mode`, `Timezone`. + +### 5. Write `manifest.json` + +Required keys: `name`, `category` (`"app"`), `description`, `env[]`. + +For each env in the compose, add a corresponding entry. Pick the right +resolution mode: + +- `default: ".${BASE_DOMAIN}"` + `prompt` — for hostnames the user may + want to override. +- `generate: "openssl rand -hex 32"` + `hide: true` — for secrets. +- `from_state: "POSTGRES_PASSWORD"` — to reuse the postgres password + without re-prompting. +- `prompt: "…"` + `hide: true` — for sensitive user-supplied values (API keys). +- `prompt: "…"` only — for non-sensitive optional values. + +Set `depends_on: ["postgres"]` (or `["postgres", "redis"]`) if applicable. +Add `post_deploy_url: "https://${KEY_HOST}"` so the menu prints the right +URL at the end. + +Validate with `jq -e .` before moving on. + +### 6. Write `install.sh` ONLY if needed + +Decide: +- **No install.sh** if the app self-bootstraps on first browser visit + (n8n, plunk, paperclip, typebot pattern). +- **Yes install.sh** if you need to: create a Postgres DB, run migrations, + seed initial data, bootstrap an admin user via internal CLI/exec. + +Template: + +```bash +#!/bin/bash +set -euo pipefail +source "${BENTO_REPO_ROOT}/lib/install-helpers.sh" + +ensure_database +``` + +For Rails-style migrations, model on `stacks/app/chatwoot/install.sh`: +wait for the web service to be healthy, then `docker exec` the migration +command. + +Make it executable: `chmod +x stacks/app//install.sh`. + +### 7. Update README + +Insert an alphabetical entry under `### Applications`: + +```markdown +- **[](stacks/app/):** . +``` + +### 8. Smoke verify + +Run all of these from the repo root and ensure each passes: + +```bash +bash -n stacks/app//install.sh # if it exists +jq -e . stacks/app//manifest.json +docker compose -f stacks/app//compose.yml config >/dev/null +grep -rn 'website\.com\|=secret$' stacks/app// || echo "clean" +``` + +If any check fails, fix before reporting back to the user. + +### 9. Commit + +Single atomic commit: + +``` +feat(): add stack +``` + +If the install script is non-trivial, consider a follow-up commit: + +``` +feat(): bootstrap database on first deploy +``` + +## Failure modes to watch for + +- **Skipping the upstream fetch and inventing env vars** — the most common + cause of a broken stack. Always fetch first. +- **Pinning `:latest` when the upstream has tagged releases** — defeats + reproducibility. Use the tag from `releases/latest`. +- **Stack key mismatches directory name** — `manifest.json` `name` field + must equal the directory name. +- **Forgetting `chmod +x` on install.sh** — `lib/stacks.sh` checks `-x` + before running it, so a non-executable script is silently skipped. +- **Using `${VAR:?…}` for a default-able value** — that aborts with no + default; prefer `${VAR:-default}` or proper manifest entries. +- **Flat env block without categories** — fails the quality bar. Group + with comment headers like n8n does. +- **Custom Dockerfile path drift** — when adding a Dockerfile, the compose + build context should be `.` (the stack directory) and the dockerfile + `Dockerfile`. See `stacks/app/paperclip/`. + +## Reporting back + +After finishing, tell the user: +- Upstream sources consulted (which `docker-compose.yml`, `.env.example`, + README sections you read). +- Stack key chosen and directory created. +- Image tag pinned and what release it came from. +- Which env vars will be auto-generated vs prompted vs reused from state. +- Whether an install script was needed and what it does. +- The smoke checks that passed. +- Where the post-deploy URL will land. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..18c96b6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,603 @@ +# CLAUDE.md + +Maintainer guide for the `bento` repository. Read this before touching +anything. It is loaded automatically by Claude Code when you work in this +repo and is meant to be equally useful for any human contributor. + +--- + +## What this repo is + +`bento` is an all-in-one installer that takes a fresh Ubuntu/Debian VPS to +"production-ready apps running" through a guided interactive menu. It is +**not** a generic Docker Swarm stack collection — it is an opinionated +platform where every stack is parametrized, generated, and deployed through +a single tool. + +A user runs **one** `curl | bash` command, then a guided 3-step menu +(`Harden → Infra → Apps`) leaves them with a hardened host, Traefik + +Portainer with TLS, and the apps they picked, each ready for first login. + +## Why it exists + +Beginners with a brand-new VPS shouldn't need to: + +- Read five docs to understand Docker Swarm bootstrap. +- Manually substitute env vars in YAMLs and SSH into Portainer to edit them. +- Wire TLS, networks, and ownership themselves. +- Track which secrets they generated and which they copied from examples. + +`bento` collapses all of that into a guided flow that is also idempotent and +restartable. + +--- + +## Architecture in 60 seconds + +``` +curl | bash + │ + ▼ +boot.sh ─ validates apt-get exists, installs git, clones repo, sources install.sh + │ + ▼ +install.sh + │ + ├── lib/deps.sh ensures gum + jq + envsubst + curl (idempotent) + ├── lib/banner.sh themed ASCII bento banner + ├── lib/state.sh reads/writes ~/.config/bento/state.json (schema versioned) + ├── lib/ui.sh gum wrappers + bento color palette + ├── lib/hardening.sh copied from felipefontoura/ubinkaze + ├── lib/infra.sh swarm init, network, deploy Traefik + Portainer, init admin + ├── lib/portainer.sh REST API client (auth, stacks CRUD) + ├── lib/stacks.sh manifest discovery + env resolution + deploy via API + └── lib/install-helpers.sh helpers used by per-stack install.sh scripts + │ + ▼ +Bootstrap (one-time): asks BASE_DOMAIN + ADMIN_EMAIL + ADVERTISE_ADDR, persists to state + │ + ▼ +Main menu loop: Step 1 → Step 2 → Step 3, with Settings / Status / Update +``` + +- **State**: `~/.config/bento/state.json` (`chmod 600`, schema versioned, migrate-on-read). +- **Portainer creds**: `~/.config/bento/portainer.json` (`chmod 600`). +- **Logs**: `~/.local/state/bento/logs/`. +- **Repo clone**: `~/.local/share/bento/`. + +## Bento ↔ Portainer ownership + +This is the most important mental model in the repo. Same split as +Helm + kubectl or Terraform + the cloud console. + +| Concern | bento | Portainer | +|---|---|---| +| Declarative state (what should run, with which envs) | owner | viewer | +| First deploy + bulk updates | owner (via API) | executor | +| Day-to-day ops (logs, restart, scale, exec) | redirect | owner | +| Stacks added directly in Portainer (no `BENTO_MANAGED` label) | ignored | full owner | + +The discriminator is the **`BENTO_MANAGED=true`** env var that `lib/stacks.sh` +injects on every `create_stack_from_git` call. Anything without it is +invisible to bento updates. Anything with it is bento's to reconcile. + +--- + +## Repo layout + +``` +bento/ +├── boot.sh # curl|bash entry point +├── install.sh # main menu loop +├── README.md # user-facing quickstart +├── CLAUDE.md # this file +├── .claude/ +│ └── skills/ # task-specific playbooks for AI agents +│ └── add-app-stack/ +│ └── SKILL.md +├── lib/ +│ ├── banner.sh +│ ├── deps.sh +│ ├── hardening.sh # copied from ubinkaze +│ ├── infra.sh +│ ├── install-helpers.sh # helpers for per-stack install.sh scripts +│ ├── portainer.sh +│ ├── stacks.sh +│ ├── state.sh +│ └── ui.sh +└── stacks/ + ├── infra/ + │ ├── traefik/compose.yml + │ └── portainer/compose.yml + ├── db/ + │ ├── postgres/{compose.yml, manifest.json} + │ └── redis/{compose.yml, manifest.json} + └── app/ + ├── chatwoot/{compose.yml, manifest.json, install.sh} + ├── n8n/{compose.yml, manifest.json, install.sh} + ├── paperclip/{compose.yml, Dockerfile, manifest.json} + └── … +``` + +## Stack layout (the convention) + +**Every stack lives under `stacks///`** where `` is the +stack name. The directory name **is** the stack identity — it appears in +Portainer, in env labels, in URLs. + +Files inside: + +| File | Required? | Purpose | +|---|---|---| +| `compose.yml` | yes | Docker Compose YAML, parametrized with `${VAR}` | +| `manifest.json` | yes | env spec + metadata | +| `install.sh` | optional | post-deploy bootstrap (DB create, migrations, seed) | +| `Dockerfile` | rarely | only when the stack ships a custom image (e.g. paperclip) | + +Discovery in `lib/stacks.sh` globs `stacks/*/*/manifest.json` so adding a new +stack means just creating its directory — no central registry to edit. + +--- + +## Manifest schema + +`stacks///manifest.json` declares everything bento needs to +know about the stack at deploy time. + +```json +{ + "name": "n8n", + "category": "app", + "description": "Workflow automation (editor + worker + webhook)", + "depends_on": ["postgres", "redis"], + "env": [ + { + "name": "N8N_HOST", + "default": "n8n.${BASE_DOMAIN}", + "prompt": "n8n editor hostname" + }, + { + "name": "N8N_ENCRYPTION_KEY", + "generate": "openssl rand -hex 32", + "hide": true + }, + { + "name": "POSTGRES_PASSWORD", + "from_state": "POSTGRES_PASSWORD" + } + ], + "post_deploy_url": "https://${N8N_HOST}" +} +``` + +| Field | Required | Meaning | +|---|---|---| +| `name` | yes | Stack key (must match directory name). | +| `category` | yes | One of `infra`, `db`, `app`. | +| `description` | yes | One-line description shown in the menu. | +| `depends_on` | no | Array of stack keys to ensure are deployed first. | +| `env[]` | yes | List of env vars; see resolution rules below. | +| `post_deploy_url` | no | URL printed at the end (template-substituted). | +| `compose_path` | no | Override the auto-derived `compose.yml`. Rarely needed. | +| `install_script` | no | Override the auto-derived `install.sh`. Rarely needed. | + +### Env entry fields + +| Field | Effect | +|---|---| +| `name` | Variable name. Must match `${NAME}` in compose.yml. | +| `default` | Template string used as the prompt default (`${BASE_DOMAIN}`, `${ADMIN_EMAIL}` expanded). | +| `prompt` | Human label shown above the input field. If absent and no other source provides a value, the var is skipped. | +| `required` | If `true`, an empty answer is rejected. | +| `generate` | Shell command whose stdout becomes the value. Implies the var is auto-generated, no prompt. | +| `from_state` | Pull the value from another state path (e.g. `POSTGRES_PASSWORD` reads `state.envs.postgres.POSTGRES_PASSWORD`). | +| `hide` | Set to `true` for secrets — the prompt masks input and the value is never echoed in summaries. | + +### Resolution order + +`lib/stacks.sh:stacks_resolve_env` walks this list in order. The first match +wins: + +1. **Existing state** — if `state.envs..` already has a value, reuse it (no prompt). +2. **`from_state`** — pull from another state path. +3. **`generate`** — run the command, persist the result. +4. **`default`** + `prompt` — show the prompt with the substituted default. +5. **`required`** — empty answer rejected. + +This means: **the user only sees prompts for the values they actually have +to type.** Hostnames default to `.${BASE_DOMAIN}` and just need an +Enter; secrets are generated; DB passwords are reused from the postgres +stack. + +--- + +## Quality bar — what "production-ready" means here + +Before any stack gets merged, it should clear the same bar that `n8n/` +already clears. Look at `stacks/app/n8n/compose.yml` and use it as the +benchmark. Concretely: + +| Aspect | Minimum | Gold-standard (n8n level) | +|---|---|---| +| Image tag | `:latest` accepted only if upstream is stable | Pin to a real release (`v1.x.y`) and document bump cadence | +| Env vars | All deployment-variable values use `${VAR}` | Grouped into commented categories with a one-line WHY per variable, mirroring upstream's docs | +| Hostnames | At least the primary hostname is parametrized | All public surfaces (editor + webhook + builder + viewer if applicable) | +| Healthcheck | Every long-running service has one | Tuned `interval`/`timeout`/`retries`/`start_period` per service shape | +| Deploy block | `replicas`, `placement` constraints | Adds `update_config` (parallelism, delay, order, failure_action) and `restart_policy` | +| Resources | None acceptable for tiny services | `limits` + `reservations` for CPU and memory | +| Network | `network_public` only | Same — apps connect via service name | +| Volumes | `driver: local` for anything app-private | Same; no `external: true` outside `network_public` | +| Secrets | Generated via manifest, never literal `secret` | Same | +| Comments | Section dividers (`# ====`) | Section dividers + per-var WHY comments + examples for commented-out alternatives (SMTP, OAuth, S3) | + +A stack that only ticks "Minimum" is acceptable for genuinely simple +services (e.g. `cli-proxy-api`). Anything with non-trivial configuration +must aim for "Gold-standard" — copy n8n's env block layout literally. + +## How to add a new application stack + +This is the most common contribution. Follow it step by step. + +### 1. Pick a key + +Lowercase, kebab-case, matches what you want the user to see in menus and +URLs. Examples: `n8n`, `cli-proxy-api`, `paperclip`. + +### 2. Create the directory + +```bash +mkdir stacks/app/ +``` + +### 3. Fetch the upstream reference first + +**Do not write `compose.yml` from memory.** Every serious open-source +project publishes a reference `docker-compose.yml` and an env example. +Pull them and use them as the source of truth: + +```bash +# Replace / with the upstream project. +gh api repos///contents/docker-compose.yml --jq '.content' | base64 -d +gh api repos///contents/.env.example --jq '.content' | base64 -d +gh api repos///releases/latest --jq '.tag_name' +``` + +If the project uses different paths (`docker/compose.yaml`, `docs/`, +multi-file setups), search: + +```bash +gh api search/code -X GET -f q="repo:/ filename:docker-compose" +``` + +For things gh can't reach (deployment docs, third-party guides), use +`WebFetch` against the project's README or docs page. + +From the upstream artifacts you extract: + +- Every required env var with its purpose, default, and whether it's secret. +- The recommended image and the **latest stable** release tag (pin it, do + not use `latest`). +- Required ports and healthcheck patterns. +- Service dependencies (Postgres, Redis, MinIO, etc.). + +### 4. Write `compose.yml` — n8n is the gold standard + +Once you have the upstream reference, model the file on the closest +existing stack: + +- **Gold-standard reference**: `stacks/app/n8n/compose.yml`. Its env block + is grouped into commented categories (Core, Observability, Database, + Public URLs, Queue Mode, Execution Control, UI, Persistence, Code Node, + Workers, Security, Binary Data, Email), every var has a one-line WHY + comment, and the deploy block has `update_config` + `restart_policy`. + **Always aim for this depth** unless the app is genuinely simpler. +- **Multi-service** (editor + worker + webhook pattern): same n8n. +- **Custom-built image**: `stacks/app/paperclip/`. +- **Rails-style with DB migrations**: `stacks/app/chatwoot/`. +- **Genuinely tiny**: `stacks/app/cli-proxy-api/` — only when there are no + meaningful knobs to document. + +Replace any string that varies between deployments with `${VAR}`: + +- Hostnames in Traefik labels: `` Host(`${YOUR_HOST}`) `` +- Public URL envs: `APP_URI=https://${YOUR_HOST}` +- Secrets / API keys / JWT signing: `JWT_SECRET=${YOUR_JWT_SECRET}` +- Database connection strings: `postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/` + +**Volumes**: prefer `driver: local`. Only `network_public` should be +`external` (it's created during Step 1). + +**Healthchecks**: always include one for long-running services. Common +patterns: `wget -qO- http://127.0.0.1:/health`, `pg_isready`, +`redis-cli ping`. + +**Traefik labels** (paste & adapt): + +```yaml +- traefik.enable=true +- traefik.http.routers..rule=Host(`${YOUR_HOST}`) +- traefik.http.routers..entrypoints=websecure +- traefik.http.routers..tls.certresolver=letsencryptresolver +- traefik.http.routers..priority=2 +- traefik.http.routers..service= +- traefik.http.services..loadbalancer.server.port= +- traefik.http.services..loadbalancer.passHostHeader=true +``` + +### 5. Write `manifest.json` + +Template: + +```json +{ + "name": "", + "category": "app", + "description": "", + "depends_on": [], + "env": [ + { + "name": "_HOST", + "default": ".${BASE_DOMAIN}", + "prompt": " hostname" + } + ], + "post_deploy_url": "https://${_HOST}" +} +``` + +Add entries to `env[]` for: + +- **Hostnames** — `default: ".${BASE_DOMAIN}"`, with `prompt`. +- **Secrets** — `generate: "openssl rand -hex 32"`, `hide: true`. Pick a + length that matches the app's expectation (32 chars for hex, 24 base64, + 64 hex for Rails secret key base). +- **DB password reuse** — `from_state: "POSTGRES_PASSWORD"`. +- **External API keys (optional)** — `prompt: "Optional: API key"`, + `hide: true` if secret. + +Add `"depends_on": ["postgres"]` if the app needs Postgres, +`["postgres", "redis"]` for both. + +Validate: `jq -e . stacks/app//manifest.json`. + +### 6. Write `install.sh` (only when needed) + +**Write one if** your app needs anything between `docker stack deploy` and +first login: DB creation, migrations, seed data, admin user via API. + +**Skip it if** the app self-bootstraps on first browser visit (n8n, plunk, +paperclip, typebot). + +Template (Postgres database + done): + +```bash +#!/bin/bash +set -euo pipefail +source "${BENTO_REPO_ROOT}/lib/install-helpers.sh" + +ensure_database +``` + +Template (DB + migration via Rails-style exec): + +```bash +#!/bin/bash +set -euo pipefail +source "${BENTO_REPO_ROOT}/lib/install-helpers.sh" + +ensure_database + +wait_for_service _ 240 || { + echo " did not become healthy; skipping bootstrap." >&2 + exit 1 +} + +cid=$(_find_container '_') +sudo docker exec "$cid" +``` + +Always `chmod +x stacks/app//install.sh`. + +Env vars exported when `lib/stacks.sh` calls your script: + +- `BENTO_REPO_ROOT` — absolute path to the bento clone. +- `BENTO_STACK_KEY` — your stack's key. +- `BENTO_STATE_FILE` — path to `state.json`. +- `POSTGRES_PASSWORD` — postgres superuser password (if postgres is deployed). + +Helpers in `lib/install-helpers.sh`: + +| Helper | Use | +|---|---| +| `ensure_database ` | Creates the DB if missing (idempotent). | +| `ensure_db_user ` | Creates/updates a Postgres role with LOGIN. | +| `grant_db_ownership ` | Reassigns DB owner. | +| `psql_exec ''` | Run arbitrary SQL as the postgres superuser. | +| `_find_container ` | First running container matching the name pattern. | +| `wait_for_service [timeout]` | Block until replicas match desired. | + +### 7. Add to `README.md` + +Under `### Applications`, alphabetically: + +```markdown +- **[](stacks/app/):** . +``` + +### 8. Smoke verify + +From the repo root: + +```bash +# Syntax of every shell script +find . -name '*.sh' -not -path './.git/*' -exec bash -n {} \; + +# Validate every manifest +find stacks -name manifest.json -exec jq -e . {} \; >/dev/null + +# Validate compose +docker compose -f stacks/app//compose.yml config >/dev/null + +# Check there are no residual website.com or =secret literals +grep -rn 'website\.com\|=secret$' stacks// || echo "clean" +``` + +### 9. Commit and push + +Atomic commit per logical unit: + +``` +feat(): add stack +``` + +If you also added a DB dependency: separate commit `feat(): add post-deploy DB init`. + +--- + +## Code style + +### Shell scripts (`*.sh`) + +- `set -euo pipefail` at the top, `IFS=$'\n\t'` where it matters. +- Source dependencies via `$(dirname "${BASH_SOURCE[0]}")` for portability. +- Function names: `snake_case` with a module prefix (`portainer_login`, + `stacks_deploy`). +- Use `local` for every function-scoped variable. +- Errors go to stderr (`>&2`). Exit codes matter — don't swallow them with + `|| true` unless you mean it. +- `bash -n` must pass. Aim for `shellcheck` clean too. +- No comments restating what the code does; comment only the WHY when + non-obvious. + +### Compose YAML (`compose.yml`) + +- 2-space indent. +- `${VAR}` for anything that varies per deployment. +- `${VAR:-default}` for optional with sensible defaults. +- `${VAR:?Missing X}` only when there is genuinely no useful default and the + value must come from the user. +- Group envs into commented sections (see existing stacks for the + established pattern). + +### Manifests (`manifest.json`) + +- Pretty-printed, 2-space indent. +- Lowercase field names. No trailing whitespace. +- `jq -e .` must succeed. +- Required fields: `name`, `category`, `description`, `env[]`. + +--- + +## Common operations + +### Reset all bento state on a VPS (clean re-test) + +```bash +rm -rf ~/.config/bento ~/.local/state/bento ~/.local/share/bento +# Then in Portainer or via docker: remove stacks tagged BENTO_MANAGED=true, +# and the infra stack: +sudo docker stack rm infra +sudo docker stack ls +sudo docker volume prune -f +# Re-run boot.sh from scratch. +``` + +### Run the install menu locally without re-cloning + +```bash +cd ~/.local/share/bento +bash install.sh +``` + +### Verbose Portainer API calls (for debugging deploys) + +```bash +BENTO_VERBOSE=1 bash install.sh +``` + +Every `curl` issued by `lib/portainer.sh` is logged to stderr. + +### Bump a base image version for paperclip-custom + +```bash +docker compose -f stacks/app/paperclip/compose.yml build --pull \ + --build-arg HERMES_VERSION=v2026.X.Y \ + --build-arg PAPERCLIP_VERSION=v2026.A.B +``` + +--- + +## State file shape + +`~/.config/bento/state.json`: + +```json +{ + "schema_version": 1, + "bootstrap": { + "base_domain": "mydomain.com", + "admin_email": "admin@mydomain.com", + "advertise_addr": "198.51.100.42", + "portainer_host": "portainer.mydomain.com", + "portainer_url": "http://127.0.0.1:9000", + "portainer_admin_user": "admin" + }, + "foundation": { + "swarm": "active", + "network_public": "ready", + "portainer": "ready" + }, + "steps": { + "hardening": "done", + "infra": "done", + "apps": "done" + }, + "envs": { + "postgres": { "POSTGRES_PASSWORD": "…" }, + "n8n": { "N8N_HOST": "n8n.mydomain.com", "N8N_ENCRYPTION_KEY": "…", … } + }, + "stacks": { + "n8n": { "stack_id": 4, "deployed_ref": "abc1234" } + } +} +``` + +Read with `state_get '.bootstrap.base_domain'`, write with +`state_set '.foundation.swarm' "active"`. Always go through `lib/state.sh` +so the schema migration runs. + +--- + +## What lives in `.claude/` + +- `.claude/skills//SKILL.md` — task-specific playbooks Claude Code + loads as slash commands when working in this repo. Currently: + - `add-app-stack` — scaffolds a new stack following the convention above. + +Skills are optional sugar — every step is also written out in plain prose +above. A human can follow the recipe without Claude. + +--- + +## External attributions + +- **Hardening script** — adapted from + [felipefontoura/ubinkaze](https://github.com/felipefontoura/ubinkaze) + (`stable` branch). Copied as `lib/hardening.sh`; OS check relaxed to any + `apt-get`-capable distro. +- **UI** — Charm's [`gum`](https://github.com/charmbracelet/gum), installed + from the Charm apt repo or downloaded as a release binary. +- **Paperclip custom image** — `paperclip-custom` extends + `ghcr.io/paperclipai/paperclip` with Hermes (from + `nousresearch/hermes-agent`), Gemini CLI, Pi, and Grok Build CLIs. + +--- + +## When in doubt + +- Read an existing stack with similar shape (`plunk` for simple, + `n8n` for multi-service, `paperclip` for custom build, `chatwoot` for + Rails-style migration bootstrap). +- Smoke-verify before committing: `bash -n`, `jq -e .`, + `docker compose config`. +- Commit atomically. Each commit should leave the repo in a coherent state. From 00aa18aaa1f19f05c8d96d8a22a314d4ed174ab0 Mon Sep 17 00:00:00 2001 From: Felipe Fontoura Date: Thu, 4 Jun 2026 12:39:39 -0300 Subject: [PATCH 04/72] docs(readme): rebrand to bento with logo, Hetzner section, and Best-README structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the README in the Best-README-Template pattern: centered transparent PNG logo, tagline, quick badges (License/Bash/Docker/distro), table of contents, and a back-to-top link. All existing content stays — only the presentation changes. Adds a prominent "Get a VPS (recommended: Hetzner)" section right before Quickstart. The Hetzner link is an affiliate referral with a clear, upfront disclosure that the commission funds new stacks and that the installer works identically on any apt-based VPS for those who'd rather skip it. Generalizes Ubuntu version references throughout (README + lib/hardening.sh comment): "latest Ubuntu LTS" instead of pinning to a specific dot-release, so the docs stay correct as new LTSs land. --- .assets/bento-banner.png | Bin 0 -> 20528 bytes README.md | 292 +++++++++++++++++++++++++++++++-------- lib/hardening.sh | 2 +- 3 files changed, 232 insertions(+), 62 deletions(-) create mode 100644 .assets/bento-banner.png diff --git a/.assets/bento-banner.png b/.assets/bento-banner.png new file mode 100644 index 0000000000000000000000000000000000000000..908ca536676885a863a9d6fe24e97346c93c38c1 GIT binary patch literal 20528 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 NT~%A9>X!Ad{|j*pXkh>V literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 096a4ac..b789206 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,278 @@ -# QuickStack - -Docker Swarm Stacks is a collection of pre-configured stack files designed to simplify the deployment of various services in a Docker Swarm cluster. + + + +
+
+ + bento + + +

+ Your VPS, served on a tray. +
+
+ Quickstart » + · + For maintainers + · + Report a bug +

+ +

+ License: MIT + Made with Bash + Docker Swarm + Apt-based +

+
+ +
+ +Bento takes a freshly-installed Ubuntu (or any Debian-family) VPS and walks +you through a guided terminal menu until you have a hardened host, Docker +Swarm bootstrapped, Traefik + Portainer running with TLS, and your chosen +applications deployed and ready to log into. --- ## Table of Contents -- [Installation](#installation) -- [Usage](#usage) -- [Stacks Available](#stacks-available) +- [Get a VPS (recommended: Hetzner)](#get-a-vps-recommended-hetzner) +- [Quickstart](#quickstart) +- [What bento does](#what-bento-does) + - [Step 1 — Harden the system](#step-1--harden-the-system) + - [Step 2 — Install infrastructure](#step-2--install-infrastructure) + - [Step 3 — Install applications](#step-3--install-applications) +- [Ownership: bento vs Portainer](#ownership-bento-vs-portainer) +- [Updating later](#updating-later) +- [Stacks available](#stacks-available) +- [Manual install](#manual-install-if-curlbash-is-not-your-thing) +- [Requirements](#requirements) +- [State and configuration](#state-and-configuration) +- [For maintainers](#for-maintainers) - [Contributing](#contributing) - [License](#license) --- -## Installation + + +## Get a VPS (recommended: Hetzner) + +> **Don't have a server yet?** Bento is built and tested on **[Hetzner Cloud](https://hetzner.cloud/?ref=YOUR_REF)** — affordable, fast, and Ubuntu-default. A **CX22** (~€4/month) comfortably runs hardening + Traefik + Portainer + a couple of apps. +> +> **[Sign up here](https://hetzner.cloud/?ref=YOUR_REF)** — gets you free +> Hetzner credit on signup to test bento for free. + +**Why we recommend Hetzner specifically** + +- We've run every release of bento on it; it is the only provider we + actively validate against. +- The current Ubuntu LTS is the default image at Hetzner — exactly what + `boot.sh` expects. +- The CX22 SKU has enough RAM/disk for the full stack list. + +**Full disclosure — this is an affiliate link** -1. Clone the repository: +The link above is an affiliate referral. If you sign up through it and +use paid resources, Hetzner pays us a small commission. That revenue is +**the main thing that funds new stacks, bug fixes, and keeping bento free +and open source**. There is zero pressure to use it — bento works +identically on any Ubuntu/Debian VPS (DigitalOcean, OVH, Vultr, your own +hardware…). If you'd rather pay no referral, just sign up directly at +[hetzner.com](https://hetzner.com) and the installer works the same way. - ```bash - git clone https://github.com/felipefontoura/quickstack.git - cd quickstack - ``` +After signing up: create a **CX22** (or larger) with the **latest Ubuntu +LTS**, add your SSH key, and continue with the Quickstart below. -2. Initialize Docker Swarm (use the node's reachable address): +--- + +## Quickstart + +One command, anywhere on a fresh Ubuntu/Debian VPS: - ```bash - docker swarm init --advertise-addr="" - ``` +```bash +bash <(curl -sSL https://raw.githubusercontent.com/felipefontoura/bento/stable/boot.sh) +``` -3. Create the shared overlay network used by every stack: +You will be asked once for: - ```bash - docker network create --driver=overlay network_public - ``` +- **Base domain** (e.g. `mydomain.com`) — every subsequent service gets a + subdomain (`portainer.mydomain.com`, `n8n.mydomain.com`, etc.). +- **Admin email** — for Let's Encrypt + service-level admin contact. +- **VPS public IP** — auto-detected, confirm or override. + +Then bento drops you into an interactive menu with three steps to follow in +order. Each step is idempotent and can be re-run safely. --- -## Usage +## What bento does + +### Step 1 — Harden the system + +Runs the [ubinkaze](https://github.com/felipefontoura/ubinkaze) hardening +script (copied in as `lib/hardening.sh`): -1. Navigate to project path: +- Installs Docker via the official installer +- Applies kernel sysctl hardening (BPF, IP forwarding, source-route protections) +- Enables UFW (ssh/http/https only), fail2ban, AppArmor, AIDE, auditd, chrony +- Creates a `docker` user with your SSH keys +- Configures unattended security upgrades, log rotation, daily cleanup cron - ```bash - cd quickstack - ``` +When hardening finishes, Step 1 immediately initializes the Docker Swarm +using the IP you confirmed and creates the shared overlay network +`network_public`. A reboot is required before continuing. -2. Adjust stack file: +### Step 2 — Install infrastructure - ```bash - nano stacks//.yml - ``` +No prompts. Bento takes the values from the bootstrap and: -2. Run the docker +- Deploys **Traefik** with HTTPS redirects + Let's Encrypt via your email +- Deploys **Portainer** at `portainer.` +- Generates a strong admin password and initializes Portainer's admin via API +- Shows the URL, username, and password once on screen - ```bash - docker stack deploy --prune --resolve-image always --compose-file .//.yml stack - ``` +After Step 2 you have a working production-grade router and a UI to inspect +everything bento later deploys. -3. Monitor your services: +### Step 3 — Install applications - ```bash - docker service ls - ``` +Pick from the menu what you want. For each selected stack bento: + +1. Prompts for missing required env vars (defaults derived from your base domain). +2. Generates strong secrets where the manifest declares `generate`. +3. Calls Portainer's API to create the stack from this Git repo (so Portainer + becomes the source of truth for the running spec). +4. Runs the optional per-stack `install.sh` to bootstrap the app (e.g. create + its Postgres database, run Rails migrations, etc.). +5. Prints the URL — you open it and log in. --- -## Stacks Available +## Ownership: bento vs Portainer + +Bento and Portainer split responsibilities cleanly. This is the same model as +Helm + kubectl or Terraform + the cloud console. -### Infrastructure +| Concern | bento | Portainer | +| ---------------------------------------------------- | -------- | ---------- | +| Declarative state (what should run) | owner | viewer | +| First deploy + bulk updates | owner | executor | +| Day-to-day ops (logs, restart, scale, exec) | redirect | owner | +| Stacks created directly in Portainer (outside bento) | ignored | full owner | -- **[Traefik](stacks/infra/traefik.yml):** Application proxy and load balancer. -- **[Portainer](stacks/infra/portainer.yml):** Platform manager for Docker and Swarm. +Every bento-deployed stack carries the env var `BENTO_MANAGED=true` plus the +deployed Git commit, so bento can spot drift and offer to reconcile during +**Update**. -### Databases +--- -- **[PostgreSQL](stacks/db/postgres.yml):** Relational database with advanced features. -- **[Redis](stacks/db/redis.yml):** In-memory key-value store for caching and real-time analytics. +## Updating later -### Applications +Just re-run the same one-liner — `boot.sh` re-clones the repo. Or, from the +menu, pick **Update** to: -- **[Chatwoot](stacks/app/chatwoot.yml):** The modern customer support tool for your business. -- **[CLI Proxy API](stacks/app/cli-proxy-api.yml):** OpenAI-compatible proxy in front of CLI-based AI providers (Gemini, Codex, etc.). -- **[Evolution API](stacks/app/evolution-api.yml):** API framework for evolutionary development. -- **[N8n](stacks/app/n8n.yml):** Workflow automation tool. -- **[N8n MCP](stacks/app/n8n-mcp.yml):** Model Context Protocol server exposing n8n nodes and workflows to AI coding agents (Claude Code, Cursor, opencode). -- **[Paperclip](stacks/app/paperclip.yml):** AI agent orchestration platform; custom image bundles Hermes Agent, Gemini CLI, Pi, and Grok Build alongside the official Claude Code, Codex, and OpenCode CLIs. -- **[Plunk](stacks/app/plunk.yml):** The Open-Source email platform. -- **[RabbitMQ](stacks/app/rabbitmq.yml):** Message broker for distributed systems, ideal for asynchronous communication and message queuing. -- **[Typebot](stacks/app/typebot.yml):** Chatbot builder for interactive conversations. +- Pull the latest bento code (`git fetch + reset --hard`). +- Re-deploy any stacks where the YAML or manifest changed since the deployed + Git commit (via `POST /api/stacks//git/redeploy`). --- -## Contributing +## Stacks available + +Layout: each stack is a directory with `compose.yml`, `manifest.json`, and +optionally `install.sh`. + +### Infrastructure (deployed by Step 2) -Contributions are welcome! If you want to contribute: +- **[Traefik](stacks/infra/traefik):** reverse proxy with Let's Encrypt. +- **[Portainer](stacks/infra/portainer):** stack manager UI. -1. Fork the repository. -2. Add or update a stack. -3. Submit a pull request. +### Databases (deployed on demand by Step 3 when an app depends on them) -For major changes, please open an issue first to discuss the proposal. +- **[PostgreSQL](stacks/db/postgres):** every app creates its own database + via its `install.sh`. +- **[Redis](stacks/db/redis):** in-memory cache. + +### Applications (Step 3) + +- **[Chatwoot](stacks/app/chatwoot):** customer support platform. +- **[CLI Proxy API](stacks/app/cli-proxy-api):** OpenAI-compatible proxy. +- **[Evolution API](stacks/app/evolution-api):** WhatsApp gateway. +- **[N8n](stacks/app/n8n):** workflow automation. +- **[N8n MCP](stacks/app/n8n-mcp):** MCP server for n8n. +- **[Paperclip](stacks/app/paperclip):** AI agent orchestration (custom + image bundling Hermes, Gemini, Pi, Grok alongside Claude Code, Codex, + OpenCode). +- **[Plunk](stacks/app/plunk):** open-source email platform. +- **[RabbitMQ](stacks/app/rabbitmq):** message broker. +- **[Typebot](stacks/app/typebot):** chatbot builder. + +--- + +## Manual install (if curl|bash is not your thing) + +```bash +git clone --branch stable https://github.com/felipefontoura/bento ~/.local/share/bento +cd ~/.local/share/bento +bash install.sh +``` + +Same menu, no curl required. + +--- + +## Requirements + +- Latest Ubuntu LTS (or any apt-based distro: Debian, Mint, Pop!_OS, etc.) +- Non-root user with `sudo` access +- 1+ GB RAM +- 20+ GB free disk +- Public IPv4 +- Domain with A records pointing to the VPS (`*.mydomain.com` → VPS IP) + +--- + +## State and configuration + +- Bento state: `~/.config/bento/state.json` (chmod 600) +- Portainer admin credentials: `~/.config/bento/portainer.json` (chmod 600) +- Hardening logs: `~/.local/state/bento/logs/` + +These survive `bento update`. Schema is versioned and migrated automatically. + +--- + +## For maintainers + +Adding a new stack, debugging the installer, or changing conventions? +Read **[CLAUDE.md](CLAUDE.md)** first. It is the canonical guide to: + +- The Bento ↔ Portainer ownership model. +- Stack directory layout and manifest schema. +- The env resolution order. +- Step-by-step recipe for adding a new application stack. +- Code style for shell, YAML, and JSON. +- Helpers available to per-stack `install.sh` scripts. + +CLAUDE.md is loaded automatically by Claude Code (or other coding agents +like OpenCode) when working in this repo; it is also a complete +human-readable maintainer guide. + +There is also a `add-app-stack` skill in `.claude/skills/` that automates +the new-stack scaffold for AI-assisted contributions. + +--- + +## Contributing + +Pull requests welcome. Open an issue first for anything beyond a small fix. --- ## License -This repository is licensed under the [MIT License](https://choosealicense.com/licenses/mit/). Use, modify, and distribute freely! +Distributed under the MIT License. + +

(back to top)

diff --git a/lib/hardening.sh b/lib/hardening.sh index e7bae69..7292f99 100644 --- a/lib/hardening.sh +++ b/lib/hardening.sh @@ -6,7 +6,7 @@ # # Differences from upstream ubinkaze: # - Distro check relaxed: any apt-get-capable system passes (Ubuntu, Debian, -# Mint, Pop!_OS, etc.) instead of strict Ubuntu 26.04. +# Mint, Pop!_OS, etc.) instead of the strict upstream Ubuntu LTS check. # - Drops a reboot sentinel at /var/lib/bento/reboot-required so the # parent install.sh can detect and prompt cleanly. From 1cb7ea5bc3fe05440f5375f3a91c7af154c9f084 Mon Sep 17 00:00:00 2001 From: Felipe Fontoura Date: Thu, 4 Jun 2026 12:47:25 -0300 Subject: [PATCH 05/72] feat(cloudflare): add optional DNS sync via API token with template URL flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/cloudflare.sh wraps the Cloudflare API for the records bento needs (token verify, zone lookup, idempotent ensure-A-record for *. and root). install.sh's bootstrap now asks the user to set up Cloudflare DNS and, when they accept, opens a Cloudflare template URL that pre-fills the token creation form with DNS:Edit already selected — so the user lands on the review screen instead of hunting through menus, clicks Create, and pastes the token back. Step 2 (lib/infra.sh) now runs an infra_ensure_dns check before deploying Traefik. With a Cloudflare token, the wildcard and root A records are synced via API. Without one, bento prints the records the user must create manually and won't proceed until they confirm DNS is in place, since Let's Encrypt would otherwise fail at deploy time. Closest equivalent to "OAuth consent flow" Cloudflare exposes for third-party tools today: see https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/ --- install.sh | 63 ++++++++++++++++++++ lib/cloudflare.sh | 143 ++++++++++++++++++++++++++++++++++++++++++++++ lib/infra.sh | 43 ++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 lib/cloudflare.sh diff --git a/install.sh b/install.sh index 45a313f..e3f764f 100644 --- a/install.sh +++ b/install.sh @@ -28,6 +28,8 @@ source "${BENTO_REPO_ROOT}/lib/banner.sh" source "${BENTO_REPO_ROOT}/lib/state.sh" # shellcheck source=lib/portainer.sh source "${BENTO_REPO_ROOT}/lib/portainer.sh" +# shellcheck source=lib/cloudflare.sh +source "${BENTO_REPO_ROOT}/lib/cloudflare.sh" # shellcheck source=lib/infra.sh source "${BENTO_REPO_ROOT}/lib/infra.sh" # shellcheck source=lib/stacks.sh @@ -95,6 +97,67 @@ EOF state_set '.bootstrap.base_domain' "$base_domain" state_set '.bootstrap.admin_email' "$admin_email" state_set '.bootstrap.advertise_addr' "$advertise_addr" + + bootstrap_prompt_cloudflare +} + +# Optional Cloudflare DNS integration. Skippable. If the user provides a +# token with Zone:DNS:Edit on $BASE_DOMAIN, bento will keep the wildcard + +# root A records in sync automatically. Otherwise we print manual steps in +# Step 2. +bootstrap_prompt_cloudflare() { + if state_has '.bootstrap.cloudflare_api_token' \ + || state_has '.bootstrap.cloudflare_skipped'; then + return 0 + fi + + ui_section "Cloudflare DNS (optional)" + ui_format_md < + +That link drops you directly on Cloudflare's token review screen with +**DNS → Edit** permission already selected. Just: + +1. (Optional) narrow **Zone Resources** to your domain only. +2. Click **Continue to summary**, then **Create Token**. +3. Copy the token Cloudflare displays once and paste it below. + +Otherwise you'll create the DNS records by hand before Step 2. +EOF + + if ! ui_confirm "Configure Cloudflare DNS automatically?"; then + state_set '.bootstrap.cloudflare_skipped' "true" + return 0 + fi + + local token + token="$(ui_password "Cloudflare API token (Zone:DNS:Edit)")" + if [[ -z "$token" ]]; then + ui_warn "Empty token — skipping Cloudflare integration." + state_set '.bootstrap.cloudflare_skipped' "true" + return 0 + fi + + state_set '.bootstrap.cloudflare_api_token' "$token" + chmod 600 "$(state_path)" + + if ui_spin "Verifying Cloudflare token…" bash -c \ + 'source "$1" && source "$2" && cloudflare_verify_token' _ \ + "${BENTO_REPO_ROOT}/lib/state.sh" \ + "${BENTO_REPO_ROOT}/lib/cloudflare.sh"; then + ui_success "Cloudflare token verified." + else + ui_error "Token verification failed. Token cleared; you can re-run bootstrap via Settings." + state_set '.bootstrap.cloudflare_api_token' "" + state_set '.bootstrap.cloudflare_skipped' "true" + fi } # ----------------------------------------------------------------------------- diff --git a/lib/cloudflare.sh b/lib/cloudflare.sh new file mode 100644 index 0000000..50683c3 --- /dev/null +++ b/lib/cloudflare.sh @@ -0,0 +1,143 @@ +#!/bin/bash +# bento — Cloudflare DNS API wrappers +# +# Optional integration. When the user provides a Cloudflare API token with +# Zone:DNS:Edit permission on the BASE_DOMAIN zone, bento can auto-create +# the wildcard + root A records that Traefik needs for Let's Encrypt to +# succeed in Step 2. +# +# Token scope (minimum): +# - Token type: Custom (or template "Edit zone DNS") +# - Permissions: Zone → DNS → Edit +# - Zone Resources: Include → Specific zone → +# +# Endpoints used: +# GET /user/tokens/verify +# GET /zones?name= +# GET /zones//dns_records?type=A&name= +# POST /zones//dns_records +# PUT /zones//dns_records/ + +readonly BENTO_CF_API="https://api.cloudflare.com/client/v4" + +# Template URL that pre-fills the Cloudflare token creation form with the +# exact permission bento needs (DNS:Edit). The user lands directly on the +# review screen with the right scope already selected — they just click +# "Continue to summary" → "Create Token" → copy. +# +# Docs: +# https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/ +readonly BENTO_CF_TOKEN_TEMPLATE_URL='https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&zoneId=all&name=Bento%20DNS' + +# Shared curl wrapper — adds Authorization, accepts trailing API path args. +cloudflare_api() { + local method="$1" + local path="$2" + shift 2 + local token + token="$(state_get '.bootstrap.cloudflare_api_token')" + if [[ -z "$token" ]]; then + echo "Cloudflare API token missing from state." >&2 + return 1 + fi + if [[ "${BENTO_VERBOSE:-0}" == "1" ]]; then + printf '→ cloudflare %s %s\n' "$method" "$path" >&2 + fi + curl --silent --show-error -X "$method" "${BENTO_CF_API}${path}" \ + -H "Authorization: Bearer ${token}" \ + -H "Content-Type: application/json" \ + "$@" +} + +# Returns 0 if the token is active. Echoes nothing on success. +cloudflare_verify_token() { + local resp + resp="$(cloudflare_api GET /user/tokens/verify)" || return 1 + [[ "$(jq -r '.success' <<< "$resp")" == "true" ]] +} + +# Looks up the zone ID for a domain. Echoes the ID on success. +cloudflare_zone_id() { + local domain="$1" + local resp + resp="$(cloudflare_api GET "/zones?name=${domain}")" || return 1 + if [[ "$(jq -r '.success' <<< "$resp")" != "true" ]]; then + echo "Cloudflare zone lookup failed for ${domain}:" >&2 + jq -r '.errors // empty' <<< "$resp" >&2 + return 1 + fi + local id + id="$(jq -r '.result[0].id // empty' <<< "$resp")" + if [[ -z "$id" ]]; then + echo "Domain ${domain} is not in any zone reachable by this token." >&2 + return 1 + fi + printf '%s' "$id" +} + +# Echoes the DNS record ID if a record matching + exists in +# . Exit 1 (silent) if missing. +cloudflare_record_id() { + local zone_id="$1" + local type="$2" + local name="$3" + local resp id + resp="$(cloudflare_api GET "/zones/${zone_id}/dns_records?type=${type}&name=${name}")" \ + || return 1 + id="$(jq -r '.result[0].id // empty' <<< "$resp")" + [[ -n "$id" ]] && printf '%s' "$id" +} + +# Creates or updates an A record so in points to . +# Idempotent: re-running with the same args is a no-op (record exists with +# the desired content). Wildcard names (`*.domain.com`) are accepted. +cloudflare_ensure_a_record() { + local zone_id="$1" + local name="$2" + local content="$3" + local ttl="${4:-1}" # 1 = Cloudflare's "Auto" TTL + local proxied="${5:-false}" + + local record_id current_content + record_id="$(cloudflare_record_id "$zone_id" A "$name")" + + local payload + payload=$(jq -n \ + --arg t A \ + --arg n "$name" \ + --arg c "$content" \ + --argjson ttl "$ttl" \ + --argjson proxied "$proxied" \ + '{type: $t, name: $n, content: $c, ttl: $ttl, proxied: $proxied}') + + if [[ -n "$record_id" ]]; then + # Skip the network round-trip if content already matches. + current_content="$(cloudflare_api GET \ + "/zones/${zone_id}/dns_records/${record_id}" \ + | jq -r '.result.content // empty')" + if [[ "$current_content" == "$content" ]]; then + return 0 + fi + cloudflare_api PUT "/zones/${zone_id}/dns_records/${record_id}" \ + --data "$payload" > /dev/null + else + cloudflare_api POST "/zones/${zone_id}/dns_records" \ + --data "$payload" > /dev/null + fi +} + +# Ensures the two records bento needs: +# *. A +# A +# Caller must have set BASE_DOMAIN and ADVERTISE_ADDR in state. +cloudflare_sync_required_records() { + local domain advertise zone_id + domain="$(state_get '.bootstrap.base_domain')" + advertise="$(state_get '.bootstrap.advertise_addr')" + + zone_id="$(cloudflare_zone_id "$domain")" || return 1 + state_set '.bootstrap.cloudflare_zone_id' "$zone_id" + + cloudflare_ensure_a_record "$zone_id" "*.${domain}" "$advertise" || return 1 + cloudflare_ensure_a_record "$zone_id" "$domain" "$advertise" || return 1 +} diff --git a/lib/infra.sh b/lib/infra.sh index b6da729..cda69e5 100644 --- a/lib/infra.sh +++ b/lib/infra.sh @@ -139,12 +139,55 @@ infra_run_step1_tail() { } infra_run_step2() { + infra_ensure_dns || return 1 infra_deploy_traefik_and_portainer || return 1 infra_init_portainer_admin || return 1 state_set '.steps.infra' "done" ui_success "Step 2 complete — infra is up." } +# Ensures wildcard + root A records exist before Traefik tries to obtain +# Let's Encrypt certs. If Cloudflare is configured we sync via API; +# otherwise we print manual instructions and require explicit confirmation. +infra_ensure_dns() { + local base advertise + base="$(state_get '.bootstrap.base_domain')" + advertise="$(state_get '.bootstrap.advertise_addr')" + + if state_has '.bootstrap.cloudflare_api_token'; then + ui_section "Cloudflare — syncing DNS records" + if ui_spin "Creating *.${base} and ${base} → ${advertise}…" bash -c \ + 'source "$1" && source "$2" && cloudflare_sync_required_records' _ \ + "${BENTO_REPO_ROOT}/lib/state.sh" \ + "${BENTO_REPO_ROOT}/lib/cloudflare.sh"; then + ui_success "Cloudflare DNS in sync." + return 0 + fi + ui_error "Cloudflare sync failed. Falling back to manual check." + fi + + ui_section "DNS check" + ui_format_md < Date: Thu, 4 Jun 2026 12:47:38 -0300 Subject: [PATCH 06/72] feat(hardening): rate-limit SSH and permit ICMP in UFW MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches `ufw allow ssh` to `ufw limit ssh`, which drops brute-force attempts at 6 connections/30s at the OS firewall — complementing fail2ban (longer window, more aggressive). Adds `ufw allow proto icmp` so `ping` keeps working for connectivity debugging from outside; the kernel sysctl net.ipv4.icmp_echo_ignore_broadcasts already blocks broadcast pings. Pairs with the Hetzner Cloud Firewall documentation in the README — these are layered defenses: Hetzner blocks at the edge, UFW + fail2ban handle anything that gets through. --- lib/hardening.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/hardening.sh b/lib/hardening.sh index 7292f99..d2e05dd 100644 --- a/lib/hardening.sh +++ b/lib/hardening.sh @@ -377,9 +377,14 @@ print_message "${YELLOW}" "Configuring firewall..." ufw --force reset ufw default deny incoming ufw default allow outgoing -ufw allow ssh +# Rate-limit SSH at the OS level (fail2ban handles repeated offenders +# at a longer window; ufw limit drops brute-force attempts at 6 conns/30s). +ufw limit ssh ufw allow http ufw allow https +# ICMP echo helps with debugging from `ping`. The kernel sysctl +# net.ipv4.icmp_echo_ignore_broadcasts=1 still drops broadcast pings. +ufw allow proto icmp ufw --force enable # --- fail2ban Configuration --- From c06dbeb8cd408796239e895b267680b4c811a729 Mon Sep 17 00:00:00 2001 From: Felipe Fontoura Date: Thu, 4 Jun 2026 12:47:38 -0300 Subject: [PATCH 07/72] docs: document Cloudflare DNS automation and Hetzner Cloud Firewall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README gets two new sections after "Get a VPS": 1. DNS (recommended: Cloudflare) — explains why we recommend it (free tier, robust API), explicitly notes there is NO affiliate program for individuals so this is a pure technical recommendation, walks through the one-click token template flow as Option A and a manual DNS table as Option B. 2. Network firewall (Hetzner Cloud Firewall) — explains the layered model (Hetzner edge + UFW + fail2ban), documents the recommended Hetzner panel ruleset, and lists the UFW rules bento already applies. CLAUDE.md updates the architecture diagram and the lib/ tree to mention lib/cloudflare.sh. --- CLAUDE.md | 2 + README.md | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 18c96b6..948d3f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,7 @@ install.sh ├── lib/hardening.sh copied from felipefontoura/ubinkaze ├── lib/infra.sh swarm init, network, deploy Traefik + Portainer, init admin ├── lib/portainer.sh REST API client (auth, stacks CRUD) + ├── lib/cloudflare.sh optional DNS sync via Cloudflare API token ├── lib/stacks.sh manifest discovery + env resolution + deploy via API └── lib/install-helpers.sh helpers used by per-stack install.sh scripts │ @@ -97,6 +98,7 @@ bento/ │ └── SKILL.md ├── lib/ │ ├── banner.sh +│ ├── cloudflare.sh # optional DNS sync via Cloudflare API │ ├── deps.sh │ ├── hardening.sh # copied from ubinkaze │ ├── infra.sh diff --git a/README.md b/README.md index b789206..194d606 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ applications deployed and ready to log into. ## Table of Contents - [Get a VPS (recommended: Hetzner)](#get-a-vps-recommended-hetzner) +- [DNS (recommended: Cloudflare)](#dns-recommended-cloudflare) +- [Network firewall (Hetzner Cloud Firewall)](#network-firewall-hetzner-cloud-firewall) - [Quickstart](#quickstart) - [What bento does](#what-bento-does) - [Step 1 — Harden the system](#step-1--harden-the-system) @@ -83,7 +85,110 @@ hardware…). If you'd rather pay no referral, just sign up directly at [hetzner.com](https://hetzner.com) and the installer works the same way. After signing up: create a **CX22** (or larger) with the **latest Ubuntu -LTS**, add your SSH key, and continue with the Quickstart below. +LTS**, add your SSH key, and continue with the next section. + +--- + +## DNS (recommended: Cloudflare) + +> **Bento expects a wildcard A record.** Every stack gets its own +> subdomain (`portainer.mydomain.com`, `n8n.mydomain.com`, etc.), and +> Traefik asks Let's Encrypt for certs on each one. Without DNS in place +> Step 2 will fail. +> +> **[Cloudflare](https://www.cloudflare.com/)** is our recommended DNS +> provider for two reasons: the free tier is genuinely free (no credit +> card), and it ships a clean API that lets bento configure the records +> for you in one click. + +**Not an affiliate.** Cloudflare doesn't run a public referral program +for individuals, so this is a pure technical recommendation. + +### Option A — Let bento manage your DNS (recommended) + +Bento uses a **Cloudflare token template link** so you never have to hunt +through dashboards or pick permissions yourself. The flow: + +1. Move your domain's nameservers to Cloudflare once (their UI walks you through it). +2. When bento asks during bootstrap, open this link in your browser: + [**Create the Bento DNS token →**](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&zoneId=all&name=Bento%20DNS) +3. You land on Cloudflare's token review page with **DNS → Edit** already + selected. Optionally narrow the zone to your domain. Click **Continue + to summary** → **Create Token** → copy. +4. Paste the token back into bento. It verifies the token and creates + the wildcard + root A records itself. + +The token is stored in `~/.config/bento/state.json` (chmod 600). You can +revoke or rotate it any time in the Cloudflare dashboard. + +> **Why a token instead of OAuth?** Cloudflare doesn't expose an OAuth +> consent flow for third-party tools today. The template URL is the +> closest equivalent: it eliminates permission guesswork, but the user +> still has to click "Create" and copy the value once. + +### Option B — Configure DNS manually + +Anywhere you host DNS, create these two records pointing at your VPS IP: + +| Type | Name | Content | TTL | +| ---- | ---------------- | -------------- | ---- | +| A | `*.mydomain.com` | ``| Auto | +| A | `mydomain.com` | ``| Auto | + +Verify before running Step 2: + +```bash +dig +short A portainer.mydomain.com +# should print your VPS IP +``` + +--- + +## Network firewall (Hetzner Cloud Firewall) + +You get **two layers of firewall** when you run bento on Hetzner: + +1. **Hetzner Cloud Firewall** — runs at Hetzner's network edge, *before* + packets reach your VPS. Configured in the Hetzner panel. +2. **OS-level UFW** — runs on the VPS itself. Configured automatically by + `lib/hardening.sh` during Step 1. + +You don't need both, but layered defense is cheap and Hetzner's edge firewall +is free. + +### What bento's UFW already does + +`lib/hardening.sh` resets and re-enables UFW with this policy: + +- `default deny incoming`, `default allow outgoing` +- `limit ssh` — drops brute-force attempts at 6 connections/30s +- `allow http`, `allow https` — ports 80/443 for Traefik +- `allow proto icmp` — keeps `ping` working for debugging + +Combined with `fail2ban` (also installed during hardening), SSH brute-force +is shut down within seconds. + +### Recommended Hetzner Cloud Firewall ruleset + +In the Hetzner Cloud console: **Firewalls → Create Firewall → Apply to your server**. + +| Direction | Source | Port | Protocol | Why | +| --------- | -------------- | ------- | -------- | -------------------------------- | +| Inbound | Your home IPv4 | 22 | TCP | SSH (lock down further later) | +| Inbound | `0.0.0.0/0` | 80 | TCP | Let's Encrypt HTTP-01 + redirect | +| Inbound | `0.0.0.0/0` | 443 | TCP | HTTPS | +| Inbound | `0.0.0.0/0` | (any) | ICMP | `ping` debugging (optional) | +| Outbound | `0.0.0.0/0` | all | all | Default — keep open | + +**Important:** if you set the SSH rule to your home IP only and that IP +changes (mobile, ISP renewal, etc.), you'll lose access and have to use +Hetzner's web console to fix it. For a starter setup, leaving SSH open to +the world but enforcing `ufw limit` + `fail2ban` (both already on) is a +reasonable trade-off. + +> **Future**: bento may add a Hetzner Cloud Firewall sync via the +> `hcloud` API token, similar to the Cloudflare flow. For now it's a +> one-time manual configuration in the panel. --- From a16e51645541848083d849a25ea0bbb5ebcc3b69 Mon Sep 17 00:00:00 2001 From: Felipe Fontoura Date: Thu, 4 Jun 2026 12:49:16 -0300 Subject: [PATCH 08/72] revert(cloudflare): drop API token integration; keep manual DNS only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per follow-up: the Cloudflare token flow (even with the template URL) still pushed friction onto a beginner — sign in to Cloudflare, click through, copy a value, paste it. The manual path is simpler and works for every DNS host, not just Cloudflare. Bento now only prints the wildcard + root A records the user must create in their DNS provider, waits for explicit confirmation, then proceeds. No tokens, no API calls, no stored credentials. Removes: - lib/cloudflare.sh - install.sh's bootstrap_prompt_cloudflare + the Cloudflare source line - The Cloudflare branch in lib/infra.sh's infra_ensure_dns - README's "Option A / Option B" split — collapses into a single records-to-create table - CLAUDE.md references to lib/cloudflare.sh Cloudflare is still the recommended DNS host on technical merits; the removal is only about how bento talks to it. --- CLAUDE.md | 2 - README.md | 40 ++++--------- install.sh | 63 -------------------- lib/cloudflare.sh | 143 ---------------------------------------------- lib/infra.sh | 31 ++++------ 5 files changed, 22 insertions(+), 257 deletions(-) delete mode 100644 lib/cloudflare.sh diff --git a/CLAUDE.md b/CLAUDE.md index 948d3f7..18c96b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,6 @@ install.sh ├── lib/hardening.sh copied from felipefontoura/ubinkaze ├── lib/infra.sh swarm init, network, deploy Traefik + Portainer, init admin ├── lib/portainer.sh REST API client (auth, stacks CRUD) - ├── lib/cloudflare.sh optional DNS sync via Cloudflare API token ├── lib/stacks.sh manifest discovery + env resolution + deploy via API └── lib/install-helpers.sh helpers used by per-stack install.sh scripts │ @@ -98,7 +97,6 @@ bento/ │ └── SKILL.md ├── lib/ │ ├── banner.sh -│ ├── cloudflare.sh # optional DNS sync via Cloudflare API │ ├── deps.sh │ ├── hardening.sh # copied from ubinkaze │ ├── infra.sh diff --git a/README.md b/README.md index 194d606..7c332d9 100644 --- a/README.md +++ b/README.md @@ -104,36 +104,15 @@ LTS**, add your SSH key, and continue with the next section. **Not an affiliate.** Cloudflare doesn't run a public referral program for individuals, so this is a pure technical recommendation. -### Option A — Let bento manage your DNS (recommended) +### Records you need to create -Bento uses a **Cloudflare token template link** so you never have to hunt -through dashboards or pick permissions yourself. The flow: +Anywhere you host DNS — Cloudflare's dashboard, your registrar, Route 53, +whatever — create these two records pointing at your VPS IP: -1. Move your domain's nameservers to Cloudflare once (their UI walks you through it). -2. When bento asks during bootstrap, open this link in your browser: - [**Create the Bento DNS token →**](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&zoneId=all&name=Bento%20DNS) -3. You land on Cloudflare's token review page with **DNS → Edit** already - selected. Optionally narrow the zone to your domain. Click **Continue - to summary** → **Create Token** → copy. -4. Paste the token back into bento. It verifies the token and creates - the wildcard + root A records itself. - -The token is stored in `~/.config/bento/state.json` (chmod 600). You can -revoke or rotate it any time in the Cloudflare dashboard. - -> **Why a token instead of OAuth?** Cloudflare doesn't expose an OAuth -> consent flow for third-party tools today. The template URL is the -> closest equivalent: it eliminates permission guesswork, but the user -> still has to click "Create" and copy the value once. - -### Option B — Configure DNS manually - -Anywhere you host DNS, create these two records pointing at your VPS IP: - -| Type | Name | Content | TTL | -| ---- | ---------------- | -------------- | ---- | -| A | `*.mydomain.com` | ``| Auto | -| A | `mydomain.com` | ``| Auto | +| Type | Name | Content | TTL | +| ---- | ---------------- | --------------- | ---- | +| A | `*.mydomain.com` | `` | Auto | +| A | `mydomain.com` | `` | Auto | Verify before running Step 2: @@ -142,6 +121,11 @@ dig +short A portainer.mydomain.com # should print your VPS IP ``` +Bento will print these same records during Step 2 and wait for you to +confirm they resolve — there is no API integration to set up, no token +to manage. Pick whatever DNS host you prefer; we just recommend +Cloudflare for the speed and zero-cost free tier. + --- ## Network firewall (Hetzner Cloud Firewall) diff --git a/install.sh b/install.sh index e3f764f..45a313f 100644 --- a/install.sh +++ b/install.sh @@ -28,8 +28,6 @@ source "${BENTO_REPO_ROOT}/lib/banner.sh" source "${BENTO_REPO_ROOT}/lib/state.sh" # shellcheck source=lib/portainer.sh source "${BENTO_REPO_ROOT}/lib/portainer.sh" -# shellcheck source=lib/cloudflare.sh -source "${BENTO_REPO_ROOT}/lib/cloudflare.sh" # shellcheck source=lib/infra.sh source "${BENTO_REPO_ROOT}/lib/infra.sh" # shellcheck source=lib/stacks.sh @@ -97,67 +95,6 @@ EOF state_set '.bootstrap.base_domain' "$base_domain" state_set '.bootstrap.admin_email' "$admin_email" state_set '.bootstrap.advertise_addr' "$advertise_addr" - - bootstrap_prompt_cloudflare -} - -# Optional Cloudflare DNS integration. Skippable. If the user provides a -# token with Zone:DNS:Edit on $BASE_DOMAIN, bento will keep the wildcard + -# root A records in sync automatically. Otherwise we print manual steps in -# Step 2. -bootstrap_prompt_cloudflare() { - if state_has '.bootstrap.cloudflare_api_token' \ - || state_has '.bootstrap.cloudflare_skipped'; then - return 0 - fi - - ui_section "Cloudflare DNS (optional)" - ui_format_md < - -That link drops you directly on Cloudflare's token review screen with -**DNS → Edit** permission already selected. Just: - -1. (Optional) narrow **Zone Resources** to your domain only. -2. Click **Continue to summary**, then **Create Token**. -3. Copy the token Cloudflare displays once and paste it below. - -Otherwise you'll create the DNS records by hand before Step 2. -EOF - - if ! ui_confirm "Configure Cloudflare DNS automatically?"; then - state_set '.bootstrap.cloudflare_skipped' "true" - return 0 - fi - - local token - token="$(ui_password "Cloudflare API token (Zone:DNS:Edit)")" - if [[ -z "$token" ]]; then - ui_warn "Empty token — skipping Cloudflare integration." - state_set '.bootstrap.cloudflare_skipped' "true" - return 0 - fi - - state_set '.bootstrap.cloudflare_api_token' "$token" - chmod 600 "$(state_path)" - - if ui_spin "Verifying Cloudflare token…" bash -c \ - 'source "$1" && source "$2" && cloudflare_verify_token' _ \ - "${BENTO_REPO_ROOT}/lib/state.sh" \ - "${BENTO_REPO_ROOT}/lib/cloudflare.sh"; then - ui_success "Cloudflare token verified." - else - ui_error "Token verification failed. Token cleared; you can re-run bootstrap via Settings." - state_set '.bootstrap.cloudflare_api_token' "" - state_set '.bootstrap.cloudflare_skipped' "true" - fi } # ----------------------------------------------------------------------------- diff --git a/lib/cloudflare.sh b/lib/cloudflare.sh deleted file mode 100644 index 50683c3..0000000 --- a/lib/cloudflare.sh +++ /dev/null @@ -1,143 +0,0 @@ -#!/bin/bash -# bento — Cloudflare DNS API wrappers -# -# Optional integration. When the user provides a Cloudflare API token with -# Zone:DNS:Edit permission on the BASE_DOMAIN zone, bento can auto-create -# the wildcard + root A records that Traefik needs for Let's Encrypt to -# succeed in Step 2. -# -# Token scope (minimum): -# - Token type: Custom (or template "Edit zone DNS") -# - Permissions: Zone → DNS → Edit -# - Zone Resources: Include → Specific zone → -# -# Endpoints used: -# GET /user/tokens/verify -# GET /zones?name= -# GET /zones//dns_records?type=A&name= -# POST /zones//dns_records -# PUT /zones//dns_records/ - -readonly BENTO_CF_API="https://api.cloudflare.com/client/v4" - -# Template URL that pre-fills the Cloudflare token creation form with the -# exact permission bento needs (DNS:Edit). The user lands directly on the -# review screen with the right scope already selected — they just click -# "Continue to summary" → "Create Token" → copy. -# -# Docs: -# https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/ -readonly BENTO_CF_TOKEN_TEMPLATE_URL='https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&zoneId=all&name=Bento%20DNS' - -# Shared curl wrapper — adds Authorization, accepts trailing API path args. -cloudflare_api() { - local method="$1" - local path="$2" - shift 2 - local token - token="$(state_get '.bootstrap.cloudflare_api_token')" - if [[ -z "$token" ]]; then - echo "Cloudflare API token missing from state." >&2 - return 1 - fi - if [[ "${BENTO_VERBOSE:-0}" == "1" ]]; then - printf '→ cloudflare %s %s\n' "$method" "$path" >&2 - fi - curl --silent --show-error -X "$method" "${BENTO_CF_API}${path}" \ - -H "Authorization: Bearer ${token}" \ - -H "Content-Type: application/json" \ - "$@" -} - -# Returns 0 if the token is active. Echoes nothing on success. -cloudflare_verify_token() { - local resp - resp="$(cloudflare_api GET /user/tokens/verify)" || return 1 - [[ "$(jq -r '.success' <<< "$resp")" == "true" ]] -} - -# Looks up the zone ID for a domain. Echoes the ID on success. -cloudflare_zone_id() { - local domain="$1" - local resp - resp="$(cloudflare_api GET "/zones?name=${domain}")" || return 1 - if [[ "$(jq -r '.success' <<< "$resp")" != "true" ]]; then - echo "Cloudflare zone lookup failed for ${domain}:" >&2 - jq -r '.errors // empty' <<< "$resp" >&2 - return 1 - fi - local id - id="$(jq -r '.result[0].id // empty' <<< "$resp")" - if [[ -z "$id" ]]; then - echo "Domain ${domain} is not in any zone reachable by this token." >&2 - return 1 - fi - printf '%s' "$id" -} - -# Echoes the DNS record ID if a record matching + exists in -# . Exit 1 (silent) if missing. -cloudflare_record_id() { - local zone_id="$1" - local type="$2" - local name="$3" - local resp id - resp="$(cloudflare_api GET "/zones/${zone_id}/dns_records?type=${type}&name=${name}")" \ - || return 1 - id="$(jq -r '.result[0].id // empty' <<< "$resp")" - [[ -n "$id" ]] && printf '%s' "$id" -} - -# Creates or updates an A record so in points to . -# Idempotent: re-running with the same args is a no-op (record exists with -# the desired content). Wildcard names (`*.domain.com`) are accepted. -cloudflare_ensure_a_record() { - local zone_id="$1" - local name="$2" - local content="$3" - local ttl="${4:-1}" # 1 = Cloudflare's "Auto" TTL - local proxied="${5:-false}" - - local record_id current_content - record_id="$(cloudflare_record_id "$zone_id" A "$name")" - - local payload - payload=$(jq -n \ - --arg t A \ - --arg n "$name" \ - --arg c "$content" \ - --argjson ttl "$ttl" \ - --argjson proxied "$proxied" \ - '{type: $t, name: $n, content: $c, ttl: $ttl, proxied: $proxied}') - - if [[ -n "$record_id" ]]; then - # Skip the network round-trip if content already matches. - current_content="$(cloudflare_api GET \ - "/zones/${zone_id}/dns_records/${record_id}" \ - | jq -r '.result.content // empty')" - if [[ "$current_content" == "$content" ]]; then - return 0 - fi - cloudflare_api PUT "/zones/${zone_id}/dns_records/${record_id}" \ - --data "$payload" > /dev/null - else - cloudflare_api POST "/zones/${zone_id}/dns_records" \ - --data "$payload" > /dev/null - fi -} - -# Ensures the two records bento needs: -# *. A -# A -# Caller must have set BASE_DOMAIN and ADVERTISE_ADDR in state. -cloudflare_sync_required_records() { - local domain advertise zone_id - domain="$(state_get '.bootstrap.base_domain')" - advertise="$(state_get '.bootstrap.advertise_addr')" - - zone_id="$(cloudflare_zone_id "$domain")" || return 1 - state_set '.bootstrap.cloudflare_zone_id' "$zone_id" - - cloudflare_ensure_a_record "$zone_id" "*.${domain}" "$advertise" || return 1 - cloudflare_ensure_a_record "$zone_id" "$domain" "$advertise" || return 1 -} diff --git a/lib/infra.sh b/lib/infra.sh index cda69e5..9de0ff1 100644 --- a/lib/infra.sh +++ b/lib/infra.sh @@ -146,38 +146,27 @@ infra_run_step2() { ui_success "Step 2 complete — infra is up." } -# Ensures wildcard + root A records exist before Traefik tries to obtain -# Let's Encrypt certs. If Cloudflare is configured we sync via API; -# otherwise we print manual instructions and require explicit confirmation. +# Prints the DNS records Traefik needs and waits for the user to confirm +# they exist. Bento does not write to any DNS provider — that step is +# entirely manual (Cloudflare, Route 53, registrar dashboard, whatever). infra_ensure_dns() { local base advertise base="$(state_get '.bootstrap.base_domain')" advertise="$(state_get '.bootstrap.advertise_addr')" - if state_has '.bootstrap.cloudflare_api_token'; then - ui_section "Cloudflare — syncing DNS records" - if ui_spin "Creating *.${base} and ${base} → ${advertise}…" bash -c \ - 'source "$1" && source "$2" && cloudflare_sync_required_records' _ \ - "${BENTO_REPO_ROOT}/lib/state.sh" \ - "${BENTO_REPO_ROOT}/lib/cloudflare.sh"; then - ui_success "Cloudflare DNS in sync." - return 0 - fi - ui_error "Cloudflare sync failed. Falling back to manual check." - fi - ui_section "DNS check" ui_format_md < Date: Thu, 4 Jun 2026 12:52:14 -0300 Subject: [PATCH 09/72] feat(dns): surface Cloudflare DNS-records deep link in install + README For users on Cloudflare, jumping to the DNS records page still takes 3-4 clicks of navigation in the dashboard. Cloudflare exposes a generic deep link pattern (`dash.cloudflare.com/?to=/:account/:zone/dns`) that routes the user through account + zone pickers and lands them on the DNS records page for the selected zone. It does not pre-fill the record form (Cloudflare reserves that for official partners like Microsoft 365 via Domain Connect), so the wildcard + root A records still come from the table bento prints. The deep link just trims navigation. lib/infra.sh's Step 2 DNS prompt now shows the link alongside the records table. README adds the same link with a short note explaining why it isn't a full one-click flow. --- README.md | 18 ++++++++++++++---- lib/infra.sh | 7 ++++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7c332d9..0570395 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,16 @@ whatever — create these two records pointing at your VPS IP: | A | `*.mydomain.com` | `` | Auto | | A | `mydomain.com` | `` | Auto | +**Using Cloudflare?** This deep link skips the navigation and lands you +directly on your zone's DNS records page — Cloudflare will prompt you to +pick the account and zone first: + +[**Open the Cloudflare DNS records page →**](https://dash.cloudflare.com/?to=/:account/:zone/dns) + +Cloudflare does not expose a public "click and create the record" flow +for third parties, so the values still come from the table above. The +deep link just saves a few clicks of navigation. + Verify before running Step 2: ```bash @@ -121,10 +131,10 @@ dig +short A portainer.mydomain.com # should print your VPS IP ``` -Bento will print these same records during Step 2 and wait for you to -confirm they resolve — there is no API integration to set up, no token -to manage. Pick whatever DNS host you prefer; we just recommend -Cloudflare for the speed and zero-cost free tier. +Bento will print these same records (and the deep link) during Step 2 +and wait for you to confirm they resolve — there is no API integration +to set up, no token to manage. Pick whatever DNS host you prefer; we +just recommend Cloudflare for the speed and zero-cost free tier. --- diff --git a/lib/infra.sh b/lib/infra.sh index 9de0ff1..79344fa 100644 --- a/lib/infra.sh +++ b/lib/infra.sh @@ -165,7 +165,12 @@ provider (Cloudflare, Route 53, your registrar, etc.): | A | \`*.${base}\` | \`${advertise}\` | | A | \`${base}\` | \`${advertise}\` | -Verify with: +**Using Cloudflare?** Open this link on the browser where you're signed in +— it jumps straight to your zone's DNS records page: + + + +Verify after creating them with: \`\`\` dig +short A portainer.${base} From 3a40897a3f5db1220df33130eb40d84ac8dc35cc Mon Sep 17 00:00:00 2001 From: Felipe Fontoura Date: Thu, 4 Jun 2026 12:57:30 -0300 Subject: [PATCH 10/72] feat(report): generate handoff HTML at end of Step 3 and from menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Step 3 finishes, bento auto-generates a self-contained HTML report at ~/.local/share/bento/reports/handoff-.html (chmod 600) that the operator can hand to the client. The report covers: - VPS overview (public IP, domain, admin email, SSH hint) - Traefik (ACME email, exposed ports) - Portainer (URL, admin user, masked password) - One card per bento-deployed application stack with URL + every resolved env, with secrets masked behind a click-to-reveal toggle (read from each manifest's `hide: true` flag) The HTML inlines its own CSS + a tiny JS toggle; no external assets, so the file works offline and prints cleanly. A print stylesheet auto-reveals all secrets so the PDF is a complete record. A new "Report — handoff HTML" item in the main menu lets the operator regenerate the file at any time, e.g. after rotating credentials. --- install.sh | 31 +++++ lib/report.sh | 352 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 lib/report.sh diff --git a/install.sh b/install.sh index 45a313f..cc4c963 100644 --- a/install.sh +++ b/install.sh @@ -32,6 +32,8 @@ source "${BENTO_REPO_ROOT}/lib/portainer.sh" source "${BENTO_REPO_ROOT}/lib/infra.sh" # shellcheck source=lib/stacks.sh source "${BENTO_REPO_ROOT}/lib/stacks.sh" +# shellcheck source=lib/report.sh +source "${BENTO_REPO_ROOT}/lib/report.sh" state_init @@ -187,6 +189,9 @@ step3_run() { return 0 fi stacks_step3_menu + if stacks_is_apps_done; then + report_run "auto" + fi } # ----------------------------------------------------------------------------- @@ -234,6 +239,30 @@ status_run() { ui_pause } +report_run() { + local trigger="${1:-manual}" + ui_section "Handoff report" + if [[ "$trigger" == "auto" ]]; then + ui_info "Generating a handoff HTML for $(state_get '.bootstrap.base_domain')…" + fi + local path + path=$(report_generate) || { + ui_error "Failed to generate the report." + return 1 + } + ui_boxed_success "$(cat <