2 Commits
v0.2.0 ... dev

Author SHA1 Message Date
242eeca238 feat: add cross-invocation passphrase caching via Linux keyring (v0.3.0)
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>
2026-03-01 05:04:07 +01:00
e70596cd59 fix: resolve arg parsing, subshell variable loss, and decrypt issues (v0.2.1)
- Global flags (--user, --registry, --password) now work after subcommand name
- Fix raw_http variables lost in subshells by persisting to temp files
- Remove config inline-comment stripping that truncated base64 ciphertext
- Add trailing newline to openssl decrypt pipe input
- Move config path to ~/.config/hubmanager.conf
- Add "Cleaning up empty repositories" section to README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 04:49:50 +01:00
3 changed files with 312 additions and 23 deletions

View File

@@ -10,6 +10,86 @@ 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)
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) ## What Changed (Session 2026-02-21)
### Encrypted config values (`--encrypt`) ### Encrypted config values (`--encrypt`)
@@ -57,13 +137,15 @@ Both `load_config()` and `resolve_registry_alias()` detect the prefix and call
| Command | Description | | Command | Description |
| --- | --- | | --- | --- |
| `login` | Test credentials against a registry; `--save` writes to `~/.hubmanager.conf` | | `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) | | `list` | List repositories (`_catalog` for self-hosted; Hub REST API for Docker Hub) |
| `tags` | List tags for an image with pagination | | `tags` | List tags for an image with pagination |
| `inspect` | Show manifest digest, size, OS/arch, layers, labels; multi-arch support | | `inspect` | Show manifest digest, size, OS/arch, layers, labels; multi-arch support |
| `delete` | Resolve tag → digest → `DELETE`; requires confirmation or `--yes` | | `delete` | Resolve tag → digest → `DELETE`; requires confirmation or `--yes` |
| `copy` | Copy/retag within or across registries; blob mount for same-registry retag | | `copy` | Copy/retag within or across registries; blob mount for same-registry retag |
| `prune` | Delete outdated tags sorted by image creation date | | `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 ### Supporting files
@@ -85,11 +167,12 @@ set -euo pipefail
# --- Output / Formatting helpers --- # --- Output / Formatting helpers ---
# --- Dependency check --- # --- Dependency check ---
# --- Encryption helpers --- _encrypt_value(), _decrypt_value(), _prompt_master_pass() # --- Encryption helpers --- _encrypt_value(), _decrypt_value(), _prompt_master_pass()
# _keyring_get(), _keyring_set(), _keyring_clear()
# --- Config loading --- # --- Config loading ---
# --- HTTP helpers --- raw_http(), get_response_header() # --- HTTP helpers --- raw_http(), get_response_header()
# --- Authentication --- probe_registry_auth(), get_bearer_token(), make_auth_header() # --- Authentication --- probe_registry_auth(), get_bearer_token(), make_auth_header()
# --- Registry request --- registry_request() ← main HTTP wrapper # --- Registry request --- registry_request() ← main HTTP wrapper
# --- Subcommands --- cmd_login/list/tags/inspect/delete/copy/prune # --- Subcommands --- cmd_login/unlock/lock/list/tags/inspect/delete/copy/prune
# --- Global arg parsing --- parse_global_args() # --- Global arg parsing --- parse_global_args()
# --- Main dispatcher --- main() # --- Main dispatcher --- main()
main "$@" main "$@"
@@ -120,7 +203,7 @@ main "$@"
- `delete` and `prune` are blocked (Hub v2 API doesn't support deletion) - `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` - Hub REST API uses a separate JWT from `hub.docker.com/v2/users/login`
6. **Config file** (`~/.hubmanager.conf`): 6. **Config file** (`~/.config/hubmanager.conf`):
- `KEY=VALUE` format, parsed with `while IFS='=' read` (not `source`) - `KEY=VALUE` format, parsed with `while IFS='=' read` (not `source`)
- Supports named registry aliases: `REGISTRY_<ALIAS>_URL/USERNAME/PASSWORD` - Supports named registry aliases: `REGISTRY_<ALIAS>_URL/USERNAME/PASSWORD`
- Aliases are resolved in `resolve_registry_alias()` before any operations - Aliases are resolved in `resolve_registry_alias()` before any operations
@@ -143,6 +226,14 @@ main "$@"
`HM_MASTER_PASS`). `HM_MASTER_PASS`).
- `openssl` is an optional dependency: not checked at startup, only on first `enc:` encounter. - `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 <seconds>` flag or `CACHE_TIMEOUT` config key.
--- ---
## Global Variables (key ones) ## Global Variables (key ones)
@@ -158,6 +249,7 @@ main "$@"
| `HM_TOKEN_CACHE["registry\|scope"]` | Cached bearer token | | `HM_TOKEN_CACHE["registry\|scope"]` | Cached bearer token |
| `HM_TOKEN_EXPIRY["registry\|scope"]` | Token expiry (epoch seconds) | | `HM_TOKEN_EXPIRY["registry\|scope"]` | Token expiry (epoch seconds) |
| `HM_MASTER_PASS` | Master passphrase for `enc:` config values (session-cached) | | `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_HTTP_CODE` | HTTP status of most recent request |
| `HM_LAST_HEADERS_FILE` | Temp file path with response headers | | `HM_LAST_HEADERS_FILE` | Temp file path with response headers |
| `HM_TMPFILES` | Array of temp files, cleaned up via `trap EXIT` | | `HM_TMPFILES` | Array of temp files, cleaned up via `trap EXIT` |

View File

@@ -8,6 +8,7 @@ A Bash CLI tool to manage Docker Registry images remotely. Supports Docker Hub a
- **curl** - **curl**
- **jq** - **jq**
- **openssl** *(optional — required only when using encrypted config values)* - **openssl** *(optional — required only when using encrypted config values)*
- **keyctl** *(optional — from `keyutils`; enables passphrase caching across invocations)*
## Installation ## Installation
@@ -48,12 +49,12 @@ hubmanager prune myuser/myapp --keep 3 --dry-run
## Configuration ## Configuration
Credentials and registry settings are stored in `~/.hubmanager.conf`. Credentials and registry settings are stored in `~/.config/hubmanager.conf`.
The file uses a simple `KEY=VALUE` format: The file uses a simple `KEY=VALUE` format:
```bash ```bash
# ~/.hubmanager.conf # ~/.config/hubmanager.conf
# chmod 600 ~/.hubmanager.conf # chmod 600 ~/.config/hubmanager.conf
# Default registry and credentials # Default registry and credentials
REGISTRY=https://registry.example.com REGISTRY=https://registry.example.com
@@ -91,7 +92,7 @@ hubmanager login --registry https://registry.example.com \
# New master passphrase: **** # New master passphrase: ****
# Confirm master passphrase: **** # Confirm master passphrase: ****
# Login Succeeded — bearer auth, registry: https://registry.example.com # Login Succeeded — bearer auth, registry: https://registry.example.com
# Credentials saved to /home/user/.hubmanager.conf # Credentials saved to /home/user/.config/hubmanager.conf
# Password stored encrypted (AES-256-CBC). Master passphrase required on each use. # Password stored encrypted (AES-256-CBC). Master passphrase required on each use.
``` ```
@@ -107,6 +108,33 @@ The `enc:` prefix also works for named alias passwords (`REGISTRY_<ALIAS>_PASSWO
On every command that reads the config, the master passphrase is prompted once and cached On every command that reads the config, the master passphrase is prompted once and cached
for the duration of the session. for the duration of the session.
### Passphrase caching across invocations
If `keyctl` (from the `keyutils` package) is installed, the master passphrase is
automatically cached in the Linux kernel keyring for 5 minutes. Subsequent commands
within that window will not re-prompt.
```bash
# First command prompts for passphrase, caches it for 5 min
hubmanager list
# Runs without prompting (within cache window)
hubmanager tags myuser/myapp
# Pre-cache before a scripted batch
hubmanager unlock
hubmanager list && hubmanager tags myuser/myapp && hubmanager inspect myuser/myapp:latest
# Clear cache immediately
hubmanager lock
# Custom timeout (10 minutes)
hubmanager unlock --cache-timeout 600
```
If `keyctl` is not installed, passphrase caching is silently skipped — each invocation
prompts as before.
## Global Options ## Global Options
```text ```text
@@ -116,7 +144,8 @@ hubmanager [OPTIONS] <command> [COMMAND OPTIONS]
Default: https://registry-1.docker.io Default: https://registry-1.docker.io
-u, --user <username> Username (overrides config file) -u, --user <username> Username (overrides config file)
-p, --password <pass> Password or token (overrides config file) -p, --password <pass> Password or token (overrides config file)
--config <file> Config file path (default: ~/.hubmanager.conf) --config <file> Config file path (default: ~/.config/hubmanager.conf)
--cache-timeout <s> Passphrase cache TTL in seconds (default: 300)
--json Output raw JSON (pipe-friendly) --json Output raw JSON (pipe-friendly)
--no-color Disable ANSI color --no-color Disable ANSI color
-v, --verbose Show HTTP request details (with auth redacted) -v, --verbose Show HTTP request details (with auth redacted)
@@ -142,7 +171,43 @@ Add `--encrypt` to store the password encrypted with AES-256-CBC (requires `open
hubmanager login --registry https://registry.example.com \ hubmanager login --registry https://registry.example.com \
--user admin --password secret --save --user admin --password secret --save
# Login Succeeded — bearer auth, registry: https://registry.example.com # Login Succeeded — bearer auth, registry: https://registry.example.com
# Credentials saved to /home/user/.hubmanager.conf # Credentials saved to /home/user/.config/hubmanager.conf
```
---
### `unlock` — Cache master passphrase
```text
hubmanager unlock [--cache-timeout SECONDS]
```
Prompts for the master passphrase and stores it in the Linux kernel keyring
for the configured timeout (default: 300 seconds / 5 minutes). Useful before
running a batch of commands. Requires `keyctl` (keyutils package).
```bash
hubmanager unlock
# hubmanager master passphrase: ****
# Passphrase cached for 300s.
# Custom timeout
hubmanager unlock --cache-timeout 600
```
---
### `lock` — Clear cached passphrase
```text
hubmanager lock
```
Immediately revokes the cached passphrase from the kernel keyring.
```bash
hubmanager lock
# Passphrase cache cleared.
``` ```
--- ---
@@ -353,6 +418,25 @@ Bearer tokens are cached in memory for the duration of the session and refreshed
- `delete` is not supported via the v2 API on Docker Hub. Use the web UI at <https://hub.docker.com>. - `delete` is not supported via the v2 API on Docker Hub. Use the web UI at <https://hub.docker.com>.
- `prune` is not supported on Docker Hub for the same reason. - `prune` is not supported on Docker Hub for the same reason.
### Cleaning up empty repositories
The Docker Registry v2 API does not provide an endpoint to delete repositories.
After deleting all tags from a repository, it will still appear in `hubmanager list`.
This is a limitation of the registry spec, not of hubmanager.
To remove empty repositories, you need direct access to the registry host:
```bash
# 1. Delete the repository directory from storage
rm -rf /var/lib/registry/docker/registry/v2/repositories/<repo-name>
# 2. Run garbage collection to reclaim disk space
docker exec <registry-container> registry garbage-collect /etc/docker/registry/config.yml
# 3. Restart the registry so the catalog refreshes
docker restart <registry-container>
```
--- ---
## JSON output ## JSON output
@@ -423,6 +507,7 @@ hubmanager copy myuser/myapp:staging myuser/myapp:production
| `REGISTRY` | Default registry URL | | `REGISTRY` | Default registry URL |
| `USERNAME` | Default username | | `USERNAME` | Default username |
| `PASSWORD` | Default password or token; prefix with `enc:` for encrypted values | | `PASSWORD` | Default password or token; prefix with `enc:` for encrypted values |
| `CACHE_TIMEOUT` | Passphrase keyring cache TTL in seconds (default: 300) |
| `REGISTRY_<ALIAS>_URL` | URL for a named registry alias | | `REGISTRY_<ALIAS>_URL` | URL for a named registry alias |
| `REGISTRY_<ALIAS>_USERNAME` | Username for a named alias | | `REGISTRY_<ALIAS>_USERNAME` | Username for a named alias |
| `REGISTRY_<ALIAS>_PASSWORD` | Password for a named alias (supports `enc:` prefix) | | `REGISTRY_<ALIAS>_PASSWORD` | Password for a named alias (supports `enc:` prefix) |

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# hubmanager - Manage Docker Registry images remotely # hubmanager - Manage Docker Registry images remotely
# Version: 0.2.0 # Version: 0.3.0
# Dependencies: curl, jq, bash 4+ # Dependencies: curl, jq, bash 4+
# Usage: hubmanager --help # Usage: hubmanager --help
@@ -9,10 +9,12 @@ set -euo pipefail
# ============================================================================= # =============================================================================
# --- Constants --- # --- Constants ---
# ============================================================================= # =============================================================================
readonly HM_VERSION="0.2.0" readonly HM_VERSION="0.3.0"
readonly HM_DEFAULT_REGISTRY="https://registry-1.docker.io" readonly HM_DEFAULT_REGISTRY="https://registry-1.docker.io"
readonly HM_DEFAULT_CONFIG="${HOME}/.hubmanager.conf" readonly HM_DEFAULT_CONFIG="${HOME}/.config/hubmanager.conf"
readonly HM_DOCKERHUB_API="https://hub.docker.com" readonly HM_DOCKERHUB_API="https://hub.docker.com"
readonly HM_KEYRING_KEY="hubmanager:master"
readonly HM_KEYRING_DEFAULT_TIMEOUT=300 # seconds (5 minutes)
# ============================================================================= # =============================================================================
# --- Global state --- # --- Global state ---
@@ -35,12 +37,15 @@ declare -A HM_CONFIG_VARS=() # raw config key/value pairs
HM_HUB_TOKEN="" # Docker Hub REST API token (JWT) HM_HUB_TOKEN="" # Docker Hub REST API token (JWT)
HM_MASTER_PASS="" # Master passphrase for encrypted config values (session-scoped) HM_MASTER_PASS="" # Master passphrase for encrypted config values (session-scoped)
HM_CACHE_TIMEOUT="$HM_KEYRING_DEFAULT_TIMEOUT" # Keyring cache TTL in seconds
HM_TMPFILES=() HM_TMPFILES=()
HM_LAST_HTTP_CODE="" HM_LAST_HTTP_CODE=""
HM_LAST_HEADERS_FILE="" HM_LAST_HEADERS_FILE=""
HM_HTTP_CODE_FILE=$(mktemp)
HM_HEADERS_REF_FILE=$(mktemp)
trap '_hm_cleanup' EXIT INT TERM trap '_hm_cleanup' EXIT INT TERM
_hm_cleanup() { rm -f "${HM_TMPFILES[@]}" 2>/dev/null || true; } _hm_cleanup() { rm -f "${HM_TMPFILES[@]}" "$HM_HTTP_CODE_FILE" "$HM_HEADERS_REF_FILE" 2>/dev/null || true; }
# ============================================================================= # =============================================================================
# --- Output / Formatting helpers --- # --- Output / Formatting helpers ---
@@ -109,14 +114,54 @@ _require_openssl() {
die "openssl is required for encrypted config values. Install openssl and retry." die "openssl is required for encrypted config values. Install openssl and retry."
} }
# Prompt once per session; result cached in HM_MASTER_PASS # --- Keyring helpers (optional; keyctl from keyutils) ---
# Check if keyctl is available
_keyring_available() { command -v keyctl &>/dev/null; }
# Try to read the cached passphrase from the user session keyring.
# Outputs the passphrase on stdout; returns 1 if not found or expired.
_keyring_get() {
_keyring_available || return 1
local key_id
key_id=$(keyctl request user "$HM_KEYRING_KEY" 2>/dev/null) || return 1
keyctl pipe "$key_id" 2>/dev/null || return 1
}
# Store passphrase in the user session keyring with a TTL.
# Args: <passphrase> [timeout_seconds]
_keyring_set() {
_keyring_available || return 0
local pass="$1"
local ttl="${2:-$HM_CACHE_TIMEOUT}"
local key_id
key_id=$(keyctl add user "$HM_KEYRING_KEY" "$pass" @u 2>/dev/null) || return 0
keyctl timeout "$key_id" "$ttl" 2>/dev/null || true
}
# Revoke (clear) the cached passphrase from the keyring.
_keyring_clear() {
_keyring_available || return 0
local key_id
key_id=$(keyctl request user "$HM_KEYRING_KEY" 2>/dev/null) || return 0
keyctl revoke "$key_id" 2>/dev/null || true
}
# Prompt once per session; result cached in HM_MASTER_PASS and keyring
_prompt_master_pass() { _prompt_master_pass() {
[[ -n "$HM_MASTER_PASS" ]] && return 0 [[ -n "$HM_MASTER_PASS" ]] && return 0
# Try keyring cache first
local cached
if cached=$(_keyring_get); then
HM_MASTER_PASS="$cached"
return 0
fi
_require_openssl _require_openssl
printf "hubmanager master passphrase: " >/dev/tty printf "hubmanager master passphrase: " >/dev/tty
read -rs HM_MASTER_PASS </dev/tty read -rs HM_MASTER_PASS </dev/tty
printf "\n" >/dev/tty printf "\n" >/dev/tty
[[ -n "$HM_MASTER_PASS" ]] || die "Master passphrase cannot be empty." [[ -n "$HM_MASTER_PASS" ]] || die "Master passphrase cannot be empty."
_keyring_set "$HM_MASTER_PASS" "$HM_CACHE_TIMEOUT"
} }
# Prompt and confirm a new master passphrase (used by login --encrypt) # Prompt and confirm a new master passphrase (used by login --encrypt)
@@ -130,6 +175,7 @@ _prompt_set_master_pass() {
[[ "$pass1" == "$pass2" ]] || die "Passphrases do not match." [[ "$pass1" == "$pass2" ]] || die "Passphrases do not match."
[[ -n "$pass1" ]] || die "Master passphrase cannot be empty." [[ -n "$pass1" ]] || die "Master passphrase cannot be empty."
HM_MASTER_PASS="$pass1" HM_MASTER_PASS="$pass1"
_keyring_set "$HM_MASTER_PASS" "$HM_CACHE_TIMEOUT"
} }
# Encrypt PLAINTEXT using AES-256-CBC; outputs base64 ciphertext (no newlines) # Encrypt PLAINTEXT using AES-256-CBC; outputs base64 ciphertext (no newlines)
@@ -152,7 +198,7 @@ _decrypt_value() {
local pass_file result local pass_file result
pass_file=$(mktemp); HM_TMPFILES+=("$pass_file") pass_file=$(mktemp); HM_TMPFILES+=("$pass_file")
printf '%s' "$HM_MASTER_PASS" > "$pass_file" printf '%s' "$HM_MASTER_PASS" > "$pass_file"
if ! result=$(printf '%s' "$ciphertext" | \ if ! result=$(printf '%s\n' "$ciphertext" | \
openssl enc -d -aes-256-cbc -pbkdf2 -a -pass "file:${pass_file}" 2>/dev/null); then openssl enc -d -aes-256-cbc -pbkdf2 -a -pass "file:${pass_file}" 2>/dev/null); then
die "Failed to decrypt config value. Wrong master passphrase?" die "Failed to decrypt config value. Wrong master passphrase?"
fi fi
@@ -188,8 +234,7 @@ load_config() {
key="${key%"${key##*[![:space:]]}"}" key="${key%"${key##*[![:space:]]}"}"
[[ -z "$key" ]] && continue [[ -z "$key" ]] && continue
# Strip inline comments and trim whitespace from value # Trim whitespace from value
value="${value%%#*}"
value="${value#"${value%%[![:space:]]*}"}" value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}" value="${value%"${value##*[![:space:]]}"}"
@@ -207,6 +252,10 @@ load_config() {
HM_PASSWORD="$pw" HM_PASSWORD="$pw"
fi fi
fi fi
# Cache timeout (only override if not set via CLI)
if [[ "$HM_CACHE_TIMEOUT" == "$HM_KEYRING_DEFAULT_TIMEOUT" && -v 'HM_CONFIG_VARS[CACHE_TIMEOUT]' ]]; then
HM_CACHE_TIMEOUT="${HM_CONFIG_VARS[CACHE_TIMEOUT]}"
fi
} }
resolve_registry_alias() { resolve_registry_alias() {
@@ -275,6 +324,10 @@ raw_http() {
"$url" \ "$url" \
2>/dev/null) || HM_LAST_HTTP_CODE="000" 2>/dev/null) || HM_LAST_HTTP_CODE="000"
# Persist code and headers path to files so they survive subshells
printf '%s' "$HM_LAST_HTTP_CODE" > "$HM_HTTP_CODE_FILE"
printf '%s' "$header_file" > "$HM_HEADERS_REF_FILE"
verbose "<<< HTTP $HM_LAST_HTTP_CODE" verbose "<<< HTTP $HM_LAST_HTTP_CODE"
[[ "$method" != "HEAD" ]] && cat "$body_file" [[ "$method" != "HEAD" ]] && cat "$body_file"
@@ -469,6 +522,8 @@ registry_request() {
local body local body
body=$(raw_http "$method" "$url" "${auth_args[@]}" "${headers[@]}" "${extra[@]}") body=$(raw_http "$method" "$url" "${auth_args[@]}" "${headers[@]}" "${extra[@]}")
HM_LAST_HTTP_CODE=$(<"$HM_HTTP_CODE_FILE")
HM_LAST_HEADERS_FILE=$(<"$HM_HEADERS_REF_FILE")
# On 401: refresh token using the challenge scope and retry once # On 401: refresh token using the challenge scope and retry once
if [[ "$HM_LAST_HTTP_CODE" == "401" ]]; then if [[ "$HM_LAST_HTTP_CODE" == "401" ]]; then
@@ -489,6 +544,8 @@ registry_request() {
[[ -n "$retry_auth" ]] && retry_auth_args=("-H" "$retry_auth") [[ -n "$retry_auth" ]] && retry_auth_args=("-H" "$retry_auth")
body=$(raw_http "$method" "$url" "${retry_auth_args[@]}" "${headers[@]}" "${extra[@]}") body=$(raw_http "$method" "$url" "${retry_auth_args[@]}" "${headers[@]}" "${extra[@]}")
HM_LAST_HTTP_CODE=$(<"$HM_HTTP_CODE_FILE")
HM_LAST_HEADERS_FILE=$(<"$HM_HEADERS_REF_FILE")
fi fi
fi fi
@@ -597,7 +654,8 @@ ${C_BOLD}Options:${C_RESET}
-r, --registry <url> Registry URL (default: https://registry-1.docker.io) -r, --registry <url> Registry URL (default: https://registry-1.docker.io)
-u, --user <username> Username -u, --user <username> Username
-p, --password <pass> Password or token -p, --password <pass> Password or token
--config <file> Config file (default: ~/.hubmanager.conf) --config <file> Config file (default: ~/.config/hubmanager.conf)
--cache-timeout <s> Passphrase cache TTL in seconds (default: 300)
--json Output raw JSON --json Output raw JSON
--no-color Disable color output --no-color Disable color output
-v, --verbose Verbose mode -v, --verbose Verbose mode
@@ -613,6 +671,8 @@ ${C_BOLD}Commands:${C_RESET}
copy <src> <dst> Copy/retag an image copy <src> <dst> Copy/retag an image
prune <image> Delete outdated tags for an image prune <image> Delete outdated tags for an image
login Test credentials and optionally save to config (--save [--encrypt]) login Test credentials and optionally save to config (--save [--encrypt])
unlock Cache master passphrase in kernel keyring (requires keyctl)
lock Clear cached master passphrase
Run ${C_BOLD}hubmanager <command> --help${C_RESET} for command-specific options. Run ${C_BOLD}hubmanager <command> --help${C_RESET} for command-specific options.
EOF EOF
@@ -625,6 +685,50 @@ cmd_help() { show_usage; }
# ============================================================================= # =============================================================================
cmd_version() { echo "hubmanager $HM_VERSION"; } cmd_version() { echo "hubmanager $HM_VERSION"; }
# =============================================================================
# --- Subcommand: unlock ---
# =============================================================================
cmd_unlock() {
if [[ "${1:-}" == "--help" ]]; then
cat <<EOF
${C_BOLD}Usage:${C_RESET} hubmanager unlock [--timeout <seconds>]
Cache the master passphrase in the Linux kernel keyring so subsequent
commands do not re-prompt. Requires ${C_BOLD}keyctl${C_RESET} (keyutils package).
--timeout <sec> Cache duration (default: ${HM_KEYRING_DEFAULT_TIMEOUT}s / 5 min)
EOF
return 0
fi
_keyring_available || die "keyctl (keyutils) is required for passphrase caching. Install keyutils and retry."
_require_openssl
# Force a fresh prompt even if already cached
HM_MASTER_PASS=""
printf "hubmanager master passphrase: " >/dev/tty
read -rs HM_MASTER_PASS </dev/tty
printf "\n" >/dev/tty
[[ -n "$HM_MASTER_PASS" ]] || die "Master passphrase cannot be empty."
_keyring_set "$HM_MASTER_PASS" "$HM_CACHE_TIMEOUT"
info "Passphrase cached for ${HM_CACHE_TIMEOUT}s."
}
# =============================================================================
# --- Subcommand: lock ---
# =============================================================================
cmd_lock() {
if [[ "${1:-}" == "--help" ]]; then
cat <<EOF
${C_BOLD}Usage:${C_RESET} hubmanager lock
Clear the cached master passphrase from the Linux kernel keyring.
EOF
return 0
fi
_keyring_clear
HM_MASTER_PASS=""
info "Passphrase cache cleared."
}
# ============================================================================= # =============================================================================
# --- Subcommand: login --- # --- Subcommand: login ---
# ============================================================================= # =============================================================================
@@ -643,7 +747,7 @@ Usage: hubmanager login [--registry URL] [--user USER] [--password PASS] [--save
Test credentials against the registry. Test credentials against the registry.
Options: Options:
--save Write validated credentials to the config file (~/.hubmanager.conf) --save Write validated credentials to the config file (~/.config/hubmanager.conf)
--encrypt Encrypt the password in the config file with AES-256-CBC. --encrypt Encrypt the password in the config file with AES-256-CBC.
A master passphrase will be prompted; you must enter the same A master passphrase will be prompted; you must enter the same
passphrase on every subsequent hubmanager invocation that reads passphrase on every subsequent hubmanager invocation that reads
@@ -682,6 +786,7 @@ EOF
if [[ "$do_save" == true ]]; then if [[ "$do_save" == true ]]; then
local config="$HM_CONFIG_FILE" local config="$HM_CONFIG_FILE"
mkdir -p "$(dirname "$config")"
local pw_entry="" local pw_entry=""
if [[ -n "$HM_PASSWORD" ]]; then if [[ -n "$HM_PASSWORD" ]]; then
if [[ "$do_encrypt" == true ]]; then if [[ "$do_encrypt" == true ]]; then
@@ -1187,6 +1292,8 @@ EOF
local manifest_body local manifest_body
manifest_body=$(raw_http GET "${src_registry}/v2/${src_image}/manifests/${src_tag}" \ manifest_body=$(raw_http GET "${src_registry}/v2/${src_image}/manifests/${src_tag}" \
"${src_auth[@]}" "${MANIFEST_ACCEPT_HEADERS[@]}") "${src_auth[@]}" "${MANIFEST_ACCEPT_HEADERS[@]}")
HM_LAST_HTTP_CODE=$(<"$HM_HTTP_CODE_FILE")
HM_LAST_HEADERS_FILE=$(<"$HM_HEADERS_REF_FILE")
[[ "$HM_LAST_HTTP_CODE" == "200" ]] || die "Failed to fetch source manifest (HTTP $HM_LAST_HTTP_CODE)" [[ "$HM_LAST_HTTP_CODE" == "200" ]] || die "Failed to fetch source manifest (HTTP $HM_LAST_HTTP_CODE)"
local content_type local content_type
@@ -1208,6 +1315,8 @@ EOF
manifest_body=$(raw_http GET "${src_registry}/v2/${src_image}/manifests/${plat_digest}" \ manifest_body=$(raw_http GET "${src_registry}/v2/${src_image}/manifests/${plat_digest}" \
"${src_auth[@]}" "${MANIFEST_ACCEPT_HEADERS[@]}") "${src_auth[@]}" "${MANIFEST_ACCEPT_HEADERS[@]}")
HM_LAST_HTTP_CODE=$(<"$HM_HTTP_CODE_FILE")
HM_LAST_HEADERS_FILE=$(<"$HM_HEADERS_REF_FILE")
[[ "$HM_LAST_HTTP_CODE" == "200" ]] || die "Failed to fetch platform manifest (HTTP $HM_LAST_HTTP_CODE)" [[ "$HM_LAST_HTTP_CODE" == "200" ]] || die "Failed to fetch platform manifest (HTTP $HM_LAST_HTTP_CODE)"
content_type=$(get_response_header "content-type") || true content_type=$(get_response_header "content-type") || true
fi fi
@@ -1579,16 +1688,17 @@ parse_global_args() {
-r|--registry) HM_REGISTRY="$2"; shift 2 ;; -r|--registry) HM_REGISTRY="$2"; shift 2 ;;
-u|--user) HM_USERNAME="$2"; shift 2 ;; -u|--user) HM_USERNAME="$2"; shift 2 ;;
-p|--password) HM_PASSWORD="$2"; shift 2 ;; -p|--password) HM_PASSWORD="$2"; shift 2 ;;
--config) HM_CONFIG_FILE="$2"; shift 2 ;; --config) HM_CONFIG_FILE="$2"; shift 2 ;;
--json) HM_OPT_JSON=true; shift ;; --cache-timeout) HM_CACHE_TIMEOUT="$2"; shift 2 ;;
--json) HM_OPT_JSON=true; shift ;;
--no-color) HM_OPT_NO_COLOR=true; shift ;; --no-color) HM_OPT_NO_COLOR=true; shift ;;
-v|--verbose) HM_OPT_VERBOSE=true; shift ;; -v|--verbose) HM_OPT_VERBOSE=true; shift ;;
-q|--quiet) HM_OPT_QUIET=true; shift ;; -q|--quiet) HM_OPT_QUIET=true; shift ;;
-h|--help) show_usage; exit 0 ;; -h|--help) show_usage; exit 0 ;;
--version) cmd_version; exit 0 ;; --version) cmd_version; exit 0 ;;
--) shift; remaining+=("$@"); break ;; --) shift; remaining+=("$@"); break ;;
# Stop global parsing at first non-flag arg or unknown flag # Pass non-global args through, but keep parsing remaining args
*) remaining+=("$@"); break ;; *) remaining+=("$1"); shift ;;
esac esac
done done
@@ -1621,6 +1731,8 @@ main() {
copy) cmd_copy "$@" ;; copy) cmd_copy "$@" ;;
prune) cmd_prune "$@" ;; prune) cmd_prune "$@" ;;
login) cmd_login "$@" ;; login) cmd_login "$@" ;;
unlock) cmd_unlock "$@" ;;
lock) cmd_lock "$@" ;;
help|-h|--help) cmd_help ;; help|-h|--help) cmd_help ;;
version|--version) cmd_version ;; version|--version) cmd_version ;;
*) *)