# hubmanager — AI Session Context This file provides context for AI assistants working on this project. ## Project Summary **hubmanager** is a single-file Bash CLI tool to manage Docker Registry images remotely. It supports both Docker Hub and any self-hosted Docker Registry v2 API, with automatic authentication detection (bearer token or HTTP basic auth). --- ## What Changed (Session 2026-03-01b) ### Passphrase caching via Linux keyring (v0.3.0) Added cross-invocation master passphrase caching using the Linux kernel keyring (`keyctl` from `keyutils`). `keyctl` is an **optional** dependency — if not installed, behaviour is unchanged (prompt every time). **New functions** (Encryption helpers section): | Function | Purpose | | --- | --- | | `_keyring_available()` | Returns 0 if `keyctl` is installed | | `_keyring_get()` | Read cached passphrase from user keyring; return 1 if missing/expired | | `_keyring_set(pass, ttl)` | Store passphrase in keyring with TTL | | `_keyring_clear()` | Revoke the cached key | **New subcommands**: | Command | Purpose | | --- | --- | | `unlock` | Prompt for passphrase and cache in keyring (useful before scripting) | | `lock` | Clear cached passphrase from keyring immediately | **New global option**: `--cache-timeout ` (default 300 / 5 min). Also configurable via `CACHE_TIMEOUT=` in `hubmanager.conf`. **New constants**: `HM_KEYRING_KEY`, `HM_KEYRING_DEFAULT_TIMEOUT`. **New global variable**: `HM_CACHE_TIMEOUT`. **Modified functions**: - `_prompt_master_pass()` — checks keyring before prompting; stores after prompt - `_prompt_set_master_pass()` — stores in keyring after confirmation **Version bump**: `0.2.1` → `0.3.0` --- ## What Changed (Session 2026-03-01) ### Bug fixes and improvements (v0.2.1) 1. **Global arg parsing fix** (`parse_global_args`): global flags like `--user`, `--registry`, `--password` placed after the subcommand name (e.g. `hubmanager login --user ...`) were not recognised. The `*` catch-all used `remaining+=("$@"); break` which stopped parsing. Changed to `remaining+=("$1"); shift` so parsing continues through all arguments. 2. **Subshell variable fix** (`raw_http` / `registry_request`): `HM_LAST_HTTP_CODE` and `HM_LAST_HEADERS_FILE` were set inside `raw_http`, but callers used `body=$(raw_http ...)` which runs in a subshell — variables set inside never propagated back. Fixed by adding two global temp files (`HM_HTTP_CODE_FILE`, `HM_HEADERS_REF_FILE`) that `raw_http` writes to; callers read them back after the subshell returns. 3. **Config inline-comment stripping removed** (`load_config`): the parser stripped everything after the first `#` in config values (`value="${value%%#*}"`), which could truncate base64 ciphertext containing `#`. Removed since comment-only lines are already skipped. 4. **Decrypt newline fix** (`_decrypt_value`): `printf '%s'` piped ciphertext to `openssl enc -d -a` without a trailing newline, causing `openssl` to fail with "error reading input file". Changed to `printf '%s\n'`. 5. **Config path moved**: `~/.hubmanager.conf` → `~/.config/hubmanager.conf`. `login --save` now runs `mkdir -p` on the parent directory before writing. 6. **README**: added "Cleaning up empty repositories" section explaining the server-side steps needed to remove empty repos (registry v2 API limitation). ### New global variables | Variable | Purpose | | --- | --- | | `HM_HTTP_CODE_FILE` | Temp file for HTTP code (survives subshells) | | `HM_HEADERS_REF_FILE` | Temp file for headers path (survives subshells) | --- ## What Changed (Session 2026-02-21) ### Encrypted config values (`--encrypt`) Added `openssl` AES-256-CBC encryption for passwords stored in the config file. `openssl` is an **optional** dependency — it is only required when `enc:` prefixed values are present in the config, or when `login --encrypt` is used. **New functions** (Encryption helpers section): | Function | Purpose | | --- | --- | | `_require_openssl()` | Die with a clear message if `openssl` is not installed | | `_prompt_master_pass()` | Prompt once per session via `/dev/tty`; cache in `HM_MASTER_PASS` | | `_prompt_set_master_pass()` | Prompt + confirm a new passphrase (used by `login --encrypt`) | | `_encrypt_value(plaintext)` | AES-256-CBC encrypt → base64 ciphertext (no newlines) | | `_decrypt_value(ciphertext)` | Decrypt base64 ciphertext → plaintext; die on wrong passphrase | **Passphrase security**: passed to `openssl` via a `mktemp` file (`-pass file:`) to avoid exposure in the process argument list (`ps aux`). The temp file is registered in `HM_TMPFILES` and removed on exit. **Config format**: encrypted values use an `enc:` prefix, e.g.: ```txt PASSWORD=enc:U2FsdGVkX1+...base64ciphertext... REGISTRY_PROD_PASSWORD=enc:U2FsdGVkX1+... ``` Both `load_config()` and `resolve_registry_alias()` detect the prefix and call `_decrypt_value` transparently. **Version bump**: `0.1.0` → `0.2.0` --- ## What Was Built (Session 2025-02-21) ### Primary file `hubmanager` — executable Bash script, ~680 lines, no mandatory dependencies beyond `curl`, `jq`, Bash 4+. `openssl` is required only when encrypted config values are used. ### Subcommands implemented | Command | Description | | --- | --- | | `login` | Test credentials against a registry; `--save` writes to `~/.config/hubmanager.conf` | | `list` | List repositories (`_catalog` for self-hosted; Hub REST API for Docker Hub) | | `tags` | List tags for an image with pagination | | `inspect` | Show manifest digest, size, OS/arch, layers, labels; multi-arch support | | `delete` | Resolve tag → digest → `DELETE`; requires confirmation or `--yes` | | `copy` | Copy/retag within or across registries; blob mount for same-registry retag | | `prune` | Delete outdated tags sorted by image creation date | | `unlock` | Cache master passphrase in kernel keyring (requires `keyctl`) | | `lock` | Clear cached master passphrase from keyring | ### Supporting files - `PLAN.md` — implementation plan written before coding - `README.md` — full user-facing documentation with examples - `tests/fixtures/` — sample JSON responses for testing (token, catalog, tags, manifests, config blob) --- ## Architecture ### Single-file, section-based layout ```txt #!/usr/bin/env bash set -euo pipefail # --- Constants --- # --- Global state --- # --- Output / Formatting helpers --- # --- Dependency check --- # --- Encryption helpers --- _encrypt_value(), _decrypt_value(), _prompt_master_pass() # _keyring_get(), _keyring_set(), _keyring_clear() # --- Config loading --- # --- HTTP helpers --- raw_http(), get_response_header() # --- Authentication --- probe_registry_auth(), get_bearer_token(), make_auth_header() # --- Registry request --- registry_request() ← main HTTP wrapper # --- Subcommands --- cmd_login/unlock/lock/list/tags/inspect/delete/copy/prune # --- Global arg parsing --- parse_global_args() # --- Main dispatcher --- main() main "$@" ``` ### Key design decisions 1. **`raw_http METHOD URL [curl-args]`** — thin curl wrapper. Writes headers to a temp file (`HM_LAST_HEADERS_FILE`), sets `HM_LAST_HTTP_CODE`, outputs body to stdout. 2. **`registry_request METHOD REGISTRY PATH [options]`** — higher-level wrapper that: - Probes auth mode via `probe_registry_auth` (result cached per registry) - Fetches and caches bearer tokens per `"registry|scope"` key - Retries once on 401 using the challenge scope from `WWW-Authenticate` - Calls `_handle_http_error` on non-2xx (unless `--no-die`) 3. **`make_auth_header REGISTRY SCOPE [USER] [PASS]`** — returns the correct `Authorization:` header string for either bearer or basic auth. 4. **Authentication flow**: - `GET /v2/` → parse `WWW-Authenticate` → detect `bearer` or `basic` - Bearer: `GET ?scope=S&service=S` with `-u user:pass` → cache `.token` - Basic: construct `Authorization: Basic ` directly - Tokens cached in `HM_TOKEN_CACHE["registry|scope"]` with epoch expiry 5. **Docker Hub differences**: - `list` uses `hub.docker.com/v2/repositories//` (not `_catalog`) - `delete` and `prune` are blocked (Hub v2 API doesn't support deletion) - Hub REST API uses a separate JWT from `hub.docker.com/v2/users/login` 6. **Config file** (`~/.config/hubmanager.conf`): - `KEY=VALUE` format, parsed with `while IFS='=' read` (not `source`) - Supports named registry aliases: `REGISTRY__URL/USERNAME/PASSWORD` - Aliases are resolved in `resolve_registry_alias()` before any operations 7. **Prune logic**: fetches all tags → filters by `--exclude` pattern → inspects each tag's config blob for creation date → sorts oldest-first → deletes oldest (beyond `--keep` count or before `--older-than` days). Supports `--dry-run`. 8. **Copy blob transfer**: - Same registry, different repos → attempt cross-repo blob mount (`POST ?mount=`) - Blob already at destination (`HEAD` returns 200) → skip - Otherwise → download to temp file → `POST` initiate upload → `PUT` with digest 9. **Encrypted config values** (v0.2.0): - `login --save --encrypt` prompts for a master passphrase (with confirmation), encrypts the password with `openssl enc -aes-256-cbc -pbkdf2 -a`, and writes `PASSWORD=enc:`. - Passphrase is passed to `openssl` via a temp file (`-pass file:`) — never via argv or env. - `load_config` and `resolve_registry_alias` both check for the `enc:` prefix and call `_decrypt_value`, which triggers `_prompt_master_pass` (once per session, cached in `HM_MASTER_PASS`). - `openssl` is an optional dependency: not checked at startup, only on first `enc:` encounter. 10. **Passphrase caching** (v0.3.0): - Uses Linux kernel keyring (`keyctl` from `keyutils`) to cache the master passphrase across invocations with a configurable TTL (default 300s / 5 min). - `_prompt_master_pass()` checks keyring before prompting; stores after prompt. - `keyctl` is optional: if not installed, each invocation prompts as before (no errors). - `unlock` pre-caches the passphrase; `lock` clears it immediately. - TTL configurable via `--cache-timeout ` flag or `CACHE_TIMEOUT` config key. --- ## Global Variables (key ones) | Variable | Purpose | | --- | --- | | `HM_REGISTRY` | Active registry URL (no trailing slash) | | `HM_USERNAME` / `HM_PASSWORD` | Active credentials | | `HM_CONFIG_FILE` | Path to config file | | `HM_OPT_JSON/VERBOSE/QUIET/NO_COLOR` | Output flags | | `HM_AUTH_MODE["registry"]` | `bearer`, `basic`, or `none` | | `HM_AUTH_REALM["registry"]` | Bearer token endpoint URL | | `HM_TOKEN_CACHE["registry\|scope"]` | Cached bearer token | | `HM_TOKEN_EXPIRY["registry\|scope"]` | Token expiry (epoch seconds) | | `HM_MASTER_PASS` | Master passphrase for `enc:` config values (session-cached) | | `HM_CACHE_TIMEOUT` | Keyring cache TTL in seconds (default 300) | | `HM_LAST_HTTP_CODE` | HTTP status of most recent request | | `HM_LAST_HEADERS_FILE` | Temp file path with response headers | | `HM_TMPFILES` | Array of temp files, cleaned up via `trap EXIT` | | `MANIFEST_ACCEPT_HEADERS` | Array of `-H Accept:` args for all manifest types | --- ## Adding a New Subcommand 1. Write a `cmd_()` function following the same pattern: - Parse args with a `while case` loop; handle `--help` - Set `HM_REGISTRY` default if empty - Use `registry_request` for API calls - Check `HM_OPT_JSON` and branch for table vs. JSON output 2. Add it to the `case` block in `main()`: ```bash newcmd) cmd_newcmd "$@" ;; ``` 3. Add it to `show_usage()`. 4. Run `shellcheck hubmanager` — it must pass with zero warnings. --- ## Exit Codes | Code | Meaning | | --- | --- | | 0 | Success | | 1 | General / usage error (`die`) | | 2 | Authentication failure (`die_auth`) | | 3 | Not found / 404 (`die_notfound`) | | 4 | Permission denied / 403 (`die_permission`) | | 5 | Registry server error (`die_server`) | | 6 | Network error (`die_network`) | | 7 | Operation not supported (`die_notsup`) | --- ## Known Limitations / Future Work - **Garbage collection** after `delete`/`prune`: the registry only frees disk space after running `registry garbage-collect`. This tool does not trigger GC; it must be run separately on the registry host. - **Docker Hub rate limits**: the `inspect`-per-tag loop in `prune` makes one API call per tag, which can hit pull rate limits on Docker Hub (prune is blocked on Hub anyway). - **Token scope granularity**: the retry-on-401 flow handles most scope mismatches, but registries that require compound scopes (e.g. `pull,push` together) may need explicit `--scope` passing inside `registry_request` calls. - **No `bats` tests yet**: `tests/fixtures/` contains sample API responses. Wire up `bats-core` tests that mock `curl` with a shell function and feed fixture data.