diff --git a/discourse-cli/validate.sh b/discourse-cli/validate.sh index 93d4d84..f13e64b 100755 --- a/discourse-cli/validate.sh +++ b/discourse-cli/validate.sh @@ -10,9 +10,11 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$SCRIPT_DIR" -# Ensure .env exists -if [ ! -f .env ]; then - echo "FAIL: .env not found. Copy .env.example to .env and fill in real values." +ENV_FILE="${DISCOURSE_ENV_FILE:-/home/reachableceo/.creds/discourse.env}" + +# Ensure env file exists +if [ ! -f "$ENV_FILE" ]; then + echo "FAIL: env file not found: $ENV_FILE" exit 1 fi @@ -36,9 +38,9 @@ echo "" echo "--- [0] Credential check (raw HTTP via containerized curl) ---" # Load env vars from .env for the raw test -DISCOURSE_URL=$(grep -E '^DISCOURSE_URL=' .env | cut -d= -f2-) -DISCOURSE_API_KEY=$(grep -E '^DISCOURSE_API_KEY=' .env | cut -d= -f2-) -DISCOURSE_API_USERNAME=$(grep -E '^DISCOURSE_API_USERNAME=' .env | cut -d= -f2-) +DISCOURSE_URL=$(grep -E '^DISCOURSE_URL=' "$ENV_FILE" | cut -d= -f2-) +DISCOURSE_API_KEY=$(grep -E '^DISCOURSE_API_KEY=' "$ENV_FILE" | cut -d= -f2-) +DISCOURSE_API_USERNAME=$(grep -E '^DISCOURSE_API_USERNAME=' "$ENV_FILE" | cut -d= -f2-) HTTP_CODE=$(docker run --rm curlimages/curl:latest \ -s -o /dev/null -w '%{http_code}' \ @@ -67,7 +69,7 @@ echo "" # 1. whoami (live connection test) # ---------------------------------------------------------------- # echo "--- [1] whoami ---" -OUT=$(docker run --rm --env-file .env "${IMAGE}" whoami 2>&1) || true +OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" whoami 2>&1) || true if echo "$OUT" | grep -qE '^username:'; then ok "whoami returned user info" echo " $OUT" | head -5 @@ -81,7 +83,7 @@ echo "" # 2. categories (live read) # ---------------------------------------------------------------- # echo "--- [2] categories ---" -OUT=$(docker run --rm --env-file .env "${IMAGE}" categories 2>&1) || true +OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" categories 2>&1) || true if echo "$OUT" | grep -qE '^[0-9]+ category'; then ok "categories returned data" echo " $OUT" | head -6 @@ -98,7 +100,7 @@ echo "" # 3. topics / ls (live read) # ---------------------------------------------------------------- # echo "--- [3] topics (latest) ---" -OUT=$(docker run --rm --env-file .env "${IMAGE}" ls 2>&1) || true +OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" ls 2>&1) || true if echo "$OUT" | grep -qE 'topic\(s\)'; then ok "topics list returned data" echo " $OUT" | head -6 @@ -115,7 +117,7 @@ echo "" # ---------------------------------------------------------------- # if [ -n "${TOPIC_ID:-}" ]; then echo "--- [4] show topic ${TOPIC_ID} ---" - OUT=$(docker run --rm --env-file .env "${IMAGE}" show "${TOPIC_ID}" 2>&1) || true + OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" show "${TOPIC_ID}" 2>&1) || true if echo "$OUT" | grep -qE '^#'; then ok "show topic ${TOPIC_ID} returned content" echo " $OUT" | head -8 @@ -132,7 +134,7 @@ echo "" # 5. search (live read) # ---------------------------------------------------------------- # echo "--- [5] search ---" -OUT=$(docker run --rm --env-file .env "${IMAGE}" search "test" 2>&1) || true +OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" search "test" 2>&1) || true if echo "$OUT" | grep -qE 'result\(s\)'; then ok "search returned results" echo " $OUT" | head -5 @@ -147,7 +149,7 @@ echo "" # ---------------------------------------------------------------- # echo "--- [6] create + reply + update + delete (write cycle) ---" -CREATE_OUT=$(docker run --rm --env-file .env "${IMAGE}" create \ +CREATE_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" create \ ${CAT_ID:+-c "$CAT_ID"} \ -t "discourse-cli validation test $(date +%s)" \ -b "This is an automated validation test post. It will be cleaned up shortly after creation." \ @@ -159,7 +161,7 @@ if [ -n "${NEW_TOPIC_ID:-}" ]; then echo " $CREATE_OUT" # reply - REPLY_OUT=$(docker run --rm --env-file .env "${IMAGE}" reply "${NEW_TOPIC_ID}" \ + REPLY_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" reply "${NEW_TOPIC_ID}" \ -b "Validation reply test." 2>&1) || true POST_ID=$(echo "$REPLY_OUT" | grep -oE 'id=[0-9]+' | grep -oE '[0-9]+') if echo "$REPLY_OUT" | grep -qE 'Posted reply'; then @@ -172,7 +174,7 @@ if [ -n "${NEW_TOPIC_ID:-}" ]; then # update if [ -n "${POST_ID:-}" ]; then - UPD_OUT=$(docker run --rm --env-file .env "${IMAGE}" update "${POST_ID}" \ + UPD_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" update "${POST_ID}" \ -b "Updated validation reply test." 2>&1) || true if echo "$UPD_OUT" | grep -qE 'Updated post'; then ok "update post ${POST_ID} succeeded" @@ -182,7 +184,7 @@ if [ -n "${NEW_TOPIC_ID:-}" ]; then fi # delete - DEL_OUT=$(docker run --rm --env-file .env "${IMAGE}" delete "${POST_ID}" 2>&1) || true + DEL_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" delete "${POST_ID}" 2>&1) || true if echo "$DEL_OUT" | grep -qE 'Deleted post'; then ok "delete post ${POST_ID} succeeded" else diff --git a/docker-compose.yml b/docker-compose.yml index e9c50eb..4c328e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -282,10 +282,8 @@ services: dockerfile: ../../dockerfiles/discourse-mcp/Dockerfile container_name: kneldevstack-aimiddleware-discourse-mcp restart: unless-stopped - environment: - - DISCOURSE_URL=${DISCOURSE_URL} - - DISCOURSE_API_KEY=${DISCOURSE_API_KEY} - - DISCOURSE_API_USERNAME=${DISCOURSE_API_USERNAME} + env_file: + - /home/reachableceo/.creds/discourse.env profiles: - ops @@ -398,10 +396,10 @@ services: dockerfile: ../../dockerfiles/mcp-redmine/Dockerfile container_name: kneldevstack-aimiddleware-mcp-redmine restart: "no" + env_file: + - /home/reachableceo/.creds/redmine.env environment: - PYTHONUNBUFFERED=1 - - REDMINE_URL=${REDMINE_URL} - - REDMINE_API_KEY=${REDMINE_API_KEY} profiles: - ops @@ -609,11 +607,10 @@ services: dockerfile: ../../dockerfiles/beszel-mcp/Dockerfile container_name: kneldevstack-aimiddleware-beszel-mcp restart: "no" + env_file: + - /home/reachableceo/.creds/beszel.env environment: - PYTHONUNBUFFERED=1 - - BESZEL_URL=${BESZEL_URL} - - BESZEL_USERNAME=${BESZEL_USERNAME} - - BESZEL_PASSWORD=${BESZEL_PASSWORD} profiles: - ops diff --git a/mcp-beszel-wrapper.sh b/mcp-beszel-wrapper.sh index 23bfeb1..78f1811 100755 --- a/mcp-beszel-wrapper.sh +++ b/mcp-beszel-wrapper.sh @@ -14,6 +14,14 @@ if docker ps -a --filter "name=${CONTAINER_NAME}" --format '{{.Names}}' | grep - done fi +# Load credentials from central store +CREDS_FILE="/home/reachableceo/.creds/beszel.env" +if [ -f "$CREDS_FILE" ]; then + set -a + . "$CREDS_FILE" + set +a +fi + # Start MCP server with explicit name and environment exec docker run -i --rm --name "${CONTAINER_NAME}" \ -e BESZEL_URL="${BESZEL_URL}" \ diff --git a/mcp-discourse-wrapper.sh b/mcp-discourse-wrapper.sh index e8ca12e..40f4f67 100755 --- a/mcp-discourse-wrapper.sh +++ b/mcp-discourse-wrapper.sh @@ -14,6 +14,14 @@ if docker ps -a --filter "name=${CONTAINER_NAME}" --format '{{.Names}}' 2>/dev/n done fi +# Load credentials from central store +CREDS_FILE="/home/reachableceo/.creds/discourse.env" +if [ -f "$CREDS_FILE" ]; then + set -a + . "$CREDS_FILE" + set +a +fi + # Start MCP server with explicit name and environment variables exec docker run -i --rm --name "${CONTAINER_NAME}" \ -e "DISCOURSE_URL=${DISCOURSE_URL:-}" \ diff --git a/mcp-redmine-wrapper.sh b/mcp-redmine-wrapper.sh index dc0cb17..3ca7c0d 100755 --- a/mcp-redmine-wrapper.sh +++ b/mcp-redmine-wrapper.sh @@ -14,6 +14,14 @@ if docker ps -a --filter "name=${CONTAINER_NAME}" --format '{{.Names}}' 2>/dev/n done fi +# Load credentials from central store +CREDS_FILE="/home/reachableceo/.creds/redmine.env" +if [ -f "$CREDS_FILE" ]; then + set -a + . "$CREDS_FILE" + set +a +fi + # Start MCP server with explicit name and environment variables exec docker run -i --rm --name "${CONTAINER_NAME}" \ -e "REDMINE_URL=${REDMINE_URL:-}" \ diff --git a/tooling-cli/KNELCredsManager/Dockerfile b/tooling-cli/KNELCredsManager/Dockerfile new file mode 100644 index 0000000..3d4fd31 --- /dev/null +++ b/tooling-cli/KNELCredsManager/Dockerfile @@ -0,0 +1,6 @@ +FROM node:22-slim + +ARG BW_CLI_VERSION=2026.7.0 +RUN npm install -g @bitwarden/cli@${BW_CLI_VERSION} && npm cache clean --force + +ENTRYPOINT ["bw"] diff --git a/tooling-cli/KNELCredsManager/README.md b/tooling-cli/KNELCredsManager/README.md new file mode 100644 index 0000000..3649a7d --- /dev/null +++ b/tooling-cli/KNELCredsManager/README.md @@ -0,0 +1,87 @@ +# KNELCredsManager + +Centralized credential management for KNEL infrastructure. Stores service +credentials on disk in `~/.creds/` and provides a containerized Bitwarden CLI +for future migration to a password manager. + +## Credential store layout + +All credentials live in `~/.creds/` as flat `.env` files, one per service: + +``` +~/.creds/ +├── beszel.env # Beszel monitoring (hub URL set, auth TBD — see #406) +├── discourse.env # Discourse forum API keys + admin key +├── phpipam.env # phpIPAM app_id + app_code +├── redmine.env # Redmine REST API key +├── technitium.env # Technitium DNS API key + admin password +└── uptime-kuma.env # Uptime Kuma push/API key +``` + +Permissions: directory `700`, files `600` (owner read/write only). + +### Consumers + +All credential consumers source from `~/.creds/`: + +| Service | Wrapper / consumer | Mechanism | +|---|---|---| +| Redmine | `~/daytoday/redmine/bin/redmine` | `--env-file ~/.creds/redmine.env` | +| Redmine MCP | `mcp-redmine-wrapper.sh` | `set -a; . ~/.creds/redmine.env; set +a` | +| Discourse | `~/daytoday/discourse/bin/discourse` | `--env-file ~/.creds/discourse.env` | +| Discourse MCP | `mcp-discourse-wrapper.sh` | `set -a; . ~/.creds/discourse.env; set +a` | +| Beszel MCP | `mcp-beszel-wrapper.sh` | `set -a; . ~/.creds/beszel.env; set +a` | +| Uptime Kuma | (no active consumer yet) | Direct env reference | +| Technitium | (no active consumer yet) | Direct env reference | +| phpIPAM | (no active consumer yet) | Direct env reference | + +## Bitwarden CLI + +The Bitwarden CLI runs in a pinned Docker container — no host installation +required. Built from this directory's Dockerfile. + +### Build + +```bash +docker build -t reachableceo-bw-cli:2026.7.0 . +``` + +### Wrapper + +```bash +# Symlink the wrapper onto PATH +ln -sf ~/projects/KNEL-AIMiddleware/tooling-cli/KNELCredsManager/scripts/bw ~/.local/bin/bw +``` + +### Usage + +```bash +bw login # interactive first-time login +export BW_SESSION=$(bw unlock --raw) # unlock and capture session +bw sync # sync vault +bw list items # list all vault items +bw get item # get a specific item +bw status # check auth/session status +``` + +Session state persists in `~/.local/share/bw-cli/` across container runs. + +### MCP integration (machine-to-machine) + +For agent automation without interactive login, the KNEL-AIMiddleware fleet +includes `mcp-bitwarden-wrapper.sh` which uses `BITWARDEN_CLIENT_ID` / +`BITWARDEN_CLIENT_SECRET` / `BITWARDEN_PASSWORD` env vars (machine account +auth). That is a separate integration from this CLI wrapper. + +## Build arguments + +| Arg | Default | Description | +|---|---|---| +| `BW_CLI_VERSION` | `2026.7.0` | Pinned @bitwarden/cli npm version | + +## Environment variables + +| Variable | Default | Description | +|---|---|---| +| `BW_CLI_IMAGE` | `reachableceo-bw-cli:2026.7.0` | Override image tag | +| `BW_SESSION` | (unset) | Session key from `bw unlock` | diff --git a/tooling-cli/KNELCredsManager/scripts/bw b/tooling-cli/KNELCredsManager/scripts/bw new file mode 100755 index 0000000..b677d1a --- /dev/null +++ b/tooling-cli/KNELCredsManager/scripts/bw @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# bw — Bitwarden CLI wrapper (Docker containerized, host stays clean). +# +# Usage: +# bw login (interactive — prompts for email/master password/2FA) +# bw unlock (interactive — prints export BW_SESSION=... line) +# bw unlock --raw (prints only the session key, for scripting) +# bw list items +# bw get item +# bw sync +# bw status +# +# Session management: +# After `bw unlock`, capture the session key: +# export BW_SESSION=$(bw unlock --raw) +# The wrapper passes BW_SESSION through automatically if already set. +# +# Data persistence: +# BW data.json lives at ~/.local/share/bw-cli/ mounted into the container, +# so login state persists across invocations. +set -euo pipefail + +IMAGE="${BW_CLI_IMAGE:-reachableceo-bw-cli:2026.7.0}" +DATA_DIR="${HOME}/.local/share/bw-cli" + +mkdir -p "$DATA_DIR" + +# Detect TTY for interactive commands (login, unlock) +INTERACTIVE="" +if [ -t 0 ] && [ -t 1 ]; then + INTERACTIVE="-it" +fi + +# Pass BW_SESSION through if set +SESSION_ARGS=() +if [ -n "${BW_SESSION:-}" ]; then + SESSION_ARGS+=( -e "BW_SESSION=${BW_SESSION}" ) +fi + +CONTAINER_NAME="reachableceo-bw-cli-$(date +%s)" + +exec docker run --rm $INTERACTIVE \ + --user "$(id -u):$(id -g)" \ + --name "$CONTAINER_NAME" \ + -e HOME=/home/bw \ + -v "${DATA_DIR}:/home/bw/.config" \ + "${SESSION_ARGS[@]}" \ + "$IMAGE" "$@"