Use keyctl (keyutils) to cache the master passphrase in the kernel keyring with a configurable TTL (default 5 min). New unlock/lock subcommands for manual cache control. keyctl is optional — silently skipped if not installed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
13 KiB
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 <seconds> (default 300 / 5 min).
Also configurable via CACHE_TIMEOUT=<seconds> 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)
-
Global arg parsing fix (
parse_global_args): global flags like--user,--registry,--passwordplaced after the subcommand name (e.g.hubmanager login --user ...) were not recognised. The*catch-all usedremaining+=("$@"); breakwhich stopped parsing. Changed toremaining+=("$1"); shiftso parsing continues through all arguments. -
Subshell variable fix (
raw_http/registry_request):HM_LAST_HTTP_CODEandHM_LAST_HEADERS_FILEwere set insideraw_http, but callers usedbody=$(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) thatraw_httpwrites to; callers read them back after the subshell returns. -
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. -
Decrypt newline fix (
_decrypt_value):printf '%s'piped ciphertext toopenssl enc -d -awithout a trailing newline, causingopensslto fail with "error reading input file". Changed toprintf '%s\n'. -
Config path moved:
~/.hubmanager.conf→~/.config/hubmanager.conf.login --savenow runsmkdir -pon the parent directory before writing. -
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.:
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 codingREADME.md— full user-facing documentation with examplestests/fixtures/— sample JSON responses for testing (token, catalog, tags, manifests, config blob)
Architecture
Single-file, section-based layout
#!/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
-
raw_http METHOD URL [curl-args]— thin curl wrapper. Writes headers to a temp file (HM_LAST_HEADERS_FILE), setsHM_LAST_HTTP_CODE, outputs body to stdout. -
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_erroron non-2xx (unless--no-die)
- Probes auth mode via
-
make_auth_header REGISTRY SCOPE [USER] [PASS]— returns the correctAuthorization:header string for either bearer or basic auth. -
Authentication flow:
GET /v2/→ parseWWW-Authenticate→ detectbearerorbasic- Bearer:
GET <realm>?scope=S&service=Swith-u user:pass→ cache.token - Basic: construct
Authorization: Basic <base64>directly - Tokens cached in
HM_TOKEN_CACHE["registry|scope"]with epoch expiry
-
Docker Hub differences:
listuseshub.docker.com/v2/repositories/<user>/(not_catalog)deleteandpruneare blocked (Hub v2 API doesn't support deletion)- Hub REST API uses a separate JWT from
hub.docker.com/v2/users/login
-
Config file (
~/.config/hubmanager.conf):KEY=VALUEformat, parsed withwhile IFS='=' read(notsource)- Supports named registry aliases:
REGISTRY_<ALIAS>_URL/USERNAME/PASSWORD - Aliases are resolved in
resolve_registry_alias()before any operations
-
Prune logic: fetches all tags → filters by
--excludepattern → inspects each tag's config blob for creation date → sorts oldest-first → deletes oldest (beyond--keepcount or before--older-thandays). Supports--dry-run. -
Copy blob transfer:
- Same registry, different repos → attempt cross-repo blob mount (
POST ?mount=) - Blob already at destination (
HEADreturns 200) → skip - Otherwise → download to temp file →
POSTinitiate upload →PUTwith digest
- Same registry, different repos → attempt cross-repo blob mount (
-
Encrypted config values (v0.2.0):
login --save --encryptprompts for a master passphrase (with confirmation), encrypts the password withopenssl enc -aes-256-cbc -pbkdf2 -a, and writesPASSWORD=enc:<b64>.- Passphrase is passed to
opensslvia a temp file (-pass file:) — never via argv or env. load_configandresolve_registry_aliasboth check for theenc:prefix and call_decrypt_value, which triggers_prompt_master_pass(once per session, cached inHM_MASTER_PASS).opensslis an optional dependency: not checked at startup, only on firstenc:encounter.
-
Passphrase caching (v0.3.0):
- Uses Linux kernel keyring (
keyctlfromkeyutils) 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.keyctlis optional: if not installed, each invocation prompts as before (no errors).unlockpre-caches the passphrase;lockclears it immediately.- TTL configurable via
--cache-timeout <seconds>flag orCACHE_TIMEOUTconfig key.
- Uses Linux kernel keyring (
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
-
Write a
cmd_<name>()function following the same pattern:- Parse args with a
while caseloop; handle--help - Set
HM_REGISTRYdefault if empty - Use
registry_requestfor API calls - Check
HM_OPT_JSONand branch for table vs. JSON output
- Parse args with a
-
Add it to the
caseblock inmain():newcmd) cmd_newcmd "$@" ;; -
Add it to
show_usage(). -
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 runningregistry 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 inprunemakes 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,pushtogether) may need explicit--scopepassing insideregistry_requestcalls. - No
batstests yet:tests/fixtures/contains sample API responses. Wire upbats-coretests that mockcurlwith a shell function and feed fixture data.