You've already forked hubmanager
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 242eeca238 | |||
| e70596cd59 | |||
| a59e416789 |
148
CLAUDE.md
148
CLAUDE.md
@@ -10,23 +10,142 @@ 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)
|
||||||
|
|
||||||
|
### 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)
|
## What Was Built (Session 2025-02-21)
|
||||||
|
|
||||||
### Primary file
|
### Primary file
|
||||||
|
|
||||||
`hubmanager` — executable Bash script, ~600 lines, no dependencies beyond `curl`, `jq`, Bash 4+.
|
`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
|
### Subcommands implemented
|
||||||
|
|
||||||
| 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
|
||||||
|
|
||||||
@@ -47,11 +166,13 @@ set -euo pipefail
|
|||||||
# --- Global state ---
|
# --- Global state ---
|
||||||
# --- Output / Formatting helpers ---
|
# --- Output / Formatting helpers ---
|
||||||
# --- Dependency check ---
|
# --- Dependency check ---
|
||||||
|
# --- 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 "$@"
|
||||||
@@ -82,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
|
||||||
@@ -96,6 +217,23 @@ main "$@"
|
|||||||
- Blob already at destination (`HEAD` returns 200) → skip
|
- Blob already at destination (`HEAD` returns 200) → skip
|
||||||
- Otherwise → download to temp file → `POST` initiate upload → `PUT` with digest
|
- 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:<b64>`.
|
||||||
|
- 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 <seconds>` flag or `CACHE_TIMEOUT` config key.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Global Variables (key ones)
|
## Global Variables (key ones)
|
||||||
@@ -110,6 +248,8 @@ main "$@"
|
|||||||
| `HM_AUTH_REALM["registry"]` | Bearer token endpoint URL |
|
| `HM_AUTH_REALM["registry"]` | Bearer token endpoint URL |
|
||||||
| `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_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` |
|
||||||
|
|||||||
162
README.md
162
README.md
@@ -7,6 +7,8 @@ A Bash CLI tool to manage Docker Registry images remotely. Supports Docker Hub a
|
|||||||
- **Bash** 4.0+
|
- **Bash** 4.0+
|
||||||
- **curl**
|
- **curl**
|
||||||
- **jq**
|
- **jq**
|
||||||
|
- **openssl** *(optional — required only when using encrypted config values)*
|
||||||
|
- **keyctl** *(optional — from `keyutils`; enables passphrase caching across invocations)*
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -25,6 +27,10 @@ install -m 755 hubmanager ~/bin/hubmanager
|
|||||||
hubmanager login --registry https://registry.example.com \
|
hubmanager login --registry https://registry.example.com \
|
||||||
--user admin --password secret --save
|
--user admin --password secret --save
|
||||||
|
|
||||||
|
# Save with password encrypted at rest
|
||||||
|
hubmanager login --registry https://registry.example.com \
|
||||||
|
--user admin --password secret --save --encrypt
|
||||||
|
|
||||||
# List all repositories
|
# List all repositories
|
||||||
hubmanager list
|
hubmanager list
|
||||||
|
|
||||||
@@ -43,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
|
||||||
@@ -74,16 +80,72 @@ hubmanager --registry staging list
|
|||||||
hubmanager --registry hub tags myuser/myapp
|
hubmanager --registry hub tags myuser/myapp
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Encrypted credentials
|
||||||
|
|
||||||
|
Use `login --save --encrypt` to store the password encrypted with AES-256-CBC.
|
||||||
|
A master passphrase is prompted at save time (with confirmation) and on every subsequent
|
||||||
|
invocation that reads the config file. Requires `openssl`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hubmanager login --registry https://registry.example.com \
|
||||||
|
--user admin --password secret --save --encrypt
|
||||||
|
# New master passphrase: ****
|
||||||
|
# Confirm master passphrase: ****
|
||||||
|
# Login Succeeded — bearer auth, registry: https://registry.example.com
|
||||||
|
# Credentials saved to /home/user/.config/hubmanager.conf
|
||||||
|
# Password stored encrypted (AES-256-CBC). Master passphrase required on each use.
|
||||||
|
```
|
||||||
|
|
||||||
|
The config file stores an `enc:` prefixed ciphertext:
|
||||||
|
|
||||||
|
```text
|
||||||
|
REGISTRY=https://registry.example.com
|
||||||
|
USERNAME=admin
|
||||||
|
PASSWORD=enc:U2FsdGVkX1+...base64ciphertext...
|
||||||
|
```
|
||||||
|
|
||||||
|
The `enc:` prefix also works for named alias passwords (`REGISTRY_<ALIAS>_PASSWORD`).
|
||||||
|
On every command that reads the config, the master passphrase is prompted once and cached
|
||||||
|
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
|
||||||
hubmanager [OPTIONS] <command> [COMMAND OPTIONS]
|
hubmanager [OPTIONS] <command> [COMMAND OPTIONS]
|
||||||
|
|
||||||
-r, --registry <url> Registry base URL
|
-r, --registry <url> Registry base URL
|
||||||
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)
|
||||||
@@ -98,24 +160,61 @@ hubmanager [OPTIONS] <command> [COMMAND OPTIONS]
|
|||||||
|
|
||||||
### `login` — Test and save credentials
|
### `login` — Test and save credentials
|
||||||
|
|
||||||
```
|
```text
|
||||||
hubmanager login [--registry URL] [--user USER] [--password PASS] [--save]
|
hubmanager login [--registry URL] [--user USER] [--password PASS] [--save] [--encrypt]
|
||||||
```
|
```
|
||||||
|
|
||||||
Validates credentials against the registry. Use `--save` to write them to the config file.
|
Validates credentials against the registry. Use `--save` to write them to the config file.
|
||||||
|
Add `--encrypt` to store the password encrypted with AES-256-CBC (requires `openssl`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
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.
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `list` — List repositories
|
### `list` — List repositories
|
||||||
|
|
||||||
```
|
```text
|
||||||
hubmanager list [--limit N] [--last REPO]
|
hubmanager list [--limit N] [--last REPO]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -139,7 +238,7 @@ hubmanager list --json | jq '.repositories[]'
|
|||||||
|
|
||||||
### `tags` — List tags for an image
|
### `tags` — List tags for an image
|
||||||
|
|
||||||
```
|
```text
|
||||||
hubmanager tags <image> [--limit N] [--last TAG]
|
hubmanager tags <image> [--limit N] [--last TAG]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -162,7 +261,7 @@ hubmanager tags myuser/myapp --json | jq '.tags[]'
|
|||||||
|
|
||||||
### `inspect` — Show image details
|
### `inspect` — Show image details
|
||||||
|
|
||||||
```
|
```text
|
||||||
hubmanager inspect <image>:<tag|digest> [--platform OS/ARCH]
|
hubmanager inspect <image>:<tag|digest> [--platform OS/ARCH]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -201,7 +300,7 @@ hubmanager inspect myuser/myapp:latest --json | jq .
|
|||||||
|
|
||||||
### `delete` — Delete a tag or manifest
|
### `delete` — Delete a tag or manifest
|
||||||
|
|
||||||
```
|
```text
|
||||||
hubmanager delete <image>:<tag|digest> [--yes]
|
hubmanager delete <image>:<tag|digest> [--yes]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -225,9 +324,11 @@ hubmanager delete myuser/myapp@sha256:abc123... --yes
|
|||||||
|
|
||||||
### `copy` — Copy or retag an image
|
### `copy` — Copy or retag an image
|
||||||
|
|
||||||
```
|
```text
|
||||||
hubmanager copy <src-image>:<tag> <dst-image>:<tag> [options]
|
hubmanager copy <src-image>:<tag> <dst-image>:<tag> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
Options:
|
Options:
|
||||||
--src-registry URL Source registry (default: global --registry)
|
--src-registry URL Source registry (default: global --registry)
|
||||||
--dst-registry URL Destination registry (default: global --registry)
|
--dst-registry URL Destination registry (default: global --registry)
|
||||||
@@ -239,11 +340,13 @@ Options:
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Same-registry retag** — attempts cross-repo blob mount (no data transfer):
|
**Same-registry retag** — attempts cross-repo blob mount (no data transfer):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hubmanager copy myuser/myapp:v1.2.3 myuser/myapp:stable
|
hubmanager copy myuser/myapp:v1.2.3 myuser/myapp:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
**Cross-registry copy** — streams blobs from source to destination:
|
**Cross-registry copy** — streams blobs from source to destination:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hubmanager copy myuser/myapp:latest \
|
hubmanager copy myuser/myapp:latest \
|
||||||
--src-registry https://registry-1.docker.io \
|
--src-registry https://registry-1.docker.io \
|
||||||
@@ -252,6 +355,7 @@ hubmanager copy myuser/myapp:latest \
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Copy specific platform** from a multi-arch image:
|
**Copy specific platform** from a multi-arch image:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hubmanager copy nginx:latest myuser/nginx-amd64:latest --platform linux/amd64
|
hubmanager copy nginx:latest myuser/nginx-amd64:latest --platform linux/amd64
|
||||||
```
|
```
|
||||||
@@ -260,7 +364,7 @@ hubmanager copy nginx:latest myuser/nginx-amd64:latest --platform linux/amd64
|
|||||||
|
|
||||||
### `prune` — Delete outdated tags
|
### `prune` — Delete outdated tags
|
||||||
|
|
||||||
```
|
```text
|
||||||
hubmanager prune <image> [options]
|
hubmanager prune <image> [options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
@@ -311,9 +415,28 @@ Bearer tokens are cached in memory for the duration of the session and refreshed
|
|||||||
### Docker Hub notes
|
### Docker Hub notes
|
||||||
|
|
||||||
- `list` uses the Docker Hub REST API (`hub.docker.com`) because the `_catalog` endpoint is restricted on Docker Hub.
|
- `list` uses the Docker Hub REST API (`hub.docker.com`) because the `_catalog` endpoint is restricted on Docker Hub.
|
||||||
- `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
|
||||||
@@ -341,7 +464,7 @@ hubmanager prune myapp --keep 3 --yes --json
|
|||||||
## Exit Codes
|
## Exit Codes
|
||||||
|
|
||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
|------|---------|
|
| --- | --- |
|
||||||
| 0 | Success |
|
| 0 | Success |
|
||||||
| 1 | General / usage error |
|
| 1 | General / usage error |
|
||||||
| 2 | Authentication failure |
|
| 2 | Authentication failure |
|
||||||
@@ -380,12 +503,13 @@ hubmanager copy myuser/myapp:staging myuser/myapp:production
|
|||||||
## Configuration reference
|
## Configuration reference
|
||||||
|
|
||||||
| Key | Description |
|
| Key | Description |
|
||||||
|-----|-------------|
|
| --- | --- |
|
||||||
| `REGISTRY` | Default registry URL |
|
| `REGISTRY` | Default registry URL |
|
||||||
| `USERNAME` | Default username |
|
| `USERNAME` | Default username |
|
||||||
| `PASSWORD` | Default password or token |
|
| `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 |
|
| `REGISTRY_<ALIAS>_PASSWORD` | Password for a named alias (supports `enc:` prefix) |
|
||||||
|
|
||||||
Aliases are case-insensitive and treat `-` as `_`. For example, alias `my-reg` maps to `REGISTRY_MY_REG_URL`.
|
Aliases are case-insensitive and treat `-` as `_`. For example, alias `my-reg` maps to `REGISTRY_MY_REG_URL`.
|
||||||
|
|||||||
235
hubmanager
235
hubmanager
@@ -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.1.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.1.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 ---
|
||||||
@@ -34,12 +36,16 @@ declare -A HM_TOKEN_EXPIRY=() # "registry|scope" -> epoch seconds
|
|||||||
declare -A HM_CONFIG_VARS=() # raw config key/value pairs
|
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_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 ---
|
||||||
@@ -98,6 +104,107 @@ check_deps() {
|
|||||||
(( BASH_VERSINFO[0] >= 4 )) || die "Bash 4.0+ is required (found: $BASH_VERSION)."
|
(( BASH_VERSINFO[0] >= 4 )) || die "Bash 4.0+ is required (found: $BASH_VERSION)."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# --- Encryption helpers ---
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Verify openssl is available (only needed when enc: values are present)
|
||||||
|
_require_openssl() {
|
||||||
|
command -v openssl &>/dev/null || \
|
||||||
|
die "openssl is required for encrypted config values. Install openssl and retry."
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 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() {
|
||||||
|
[[ -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
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prompt and confirm a new master passphrase (used by login --encrypt)
|
||||||
|
_prompt_set_master_pass() {
|
||||||
|
_require_openssl
|
||||||
|
local pass1 pass2
|
||||||
|
printf "New master passphrase: " >/dev/tty
|
||||||
|
read -rs pass1 </dev/tty; printf "\n" >/dev/tty
|
||||||
|
printf "Confirm master passphrase: " >/dev/tty
|
||||||
|
read -rs pass2 </dev/tty; printf "\n" >/dev/tty
|
||||||
|
[[ "$pass1" == "$pass2" ]] || die "Passphrases do not match."
|
||||||
|
[[ -n "$pass1" ]] || die "Master passphrase cannot be empty."
|
||||||
|
HM_MASTER_PASS="$pass1"
|
||||||
|
_keyring_set "$HM_MASTER_PASS" "$HM_CACHE_TIMEOUT"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Encrypt PLAINTEXT using AES-256-CBC; outputs base64 ciphertext (no newlines)
|
||||||
|
_encrypt_value() {
|
||||||
|
local plaintext="$1"
|
||||||
|
_prompt_master_pass
|
||||||
|
local pass_file result
|
||||||
|
pass_file=$(mktemp); HM_TMPFILES+=("$pass_file")
|
||||||
|
printf '%s' "$HM_MASTER_PASS" > "$pass_file"
|
||||||
|
result=$(printf '%s' "$plaintext" | \
|
||||||
|
openssl enc -aes-256-cbc -pbkdf2 -a -pass "file:${pass_file}" 2>/dev/null | tr -d '\n')
|
||||||
|
[[ -n "$result" ]] || die "Encryption failed. Check that openssl is installed."
|
||||||
|
printf '%s' "$result"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decrypt base64 CIPHERTEXT produced by _encrypt_value; outputs plaintext
|
||||||
|
_decrypt_value() {
|
||||||
|
local ciphertext="$1"
|
||||||
|
_prompt_master_pass
|
||||||
|
local pass_file result
|
||||||
|
pass_file=$(mktemp); HM_TMPFILES+=("$pass_file")
|
||||||
|
printf '%s' "$HM_MASTER_PASS" > "$pass_file"
|
||||||
|
if ! result=$(printf '%s\n' "$ciphertext" | \
|
||||||
|
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?"
|
||||||
|
fi
|
||||||
|
printf '%s' "$result"
|
||||||
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# --- Config loading ---
|
# --- Config loading ---
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -127,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:]]}"}"
|
||||||
|
|
||||||
@@ -138,7 +244,18 @@ load_config() {
|
|||||||
# Apply config defaults (CLI flags already set take precedence)
|
# Apply config defaults (CLI flags already set take precedence)
|
||||||
[[ -z "$HM_REGISTRY" && -v 'HM_CONFIG_VARS[REGISTRY]' ]] && HM_REGISTRY="${HM_CONFIG_VARS[REGISTRY]}"
|
[[ -z "$HM_REGISTRY" && -v 'HM_CONFIG_VARS[REGISTRY]' ]] && HM_REGISTRY="${HM_CONFIG_VARS[REGISTRY]}"
|
||||||
[[ -z "$HM_USERNAME" && -v 'HM_CONFIG_VARS[USERNAME]' ]] && HM_USERNAME="${HM_CONFIG_VARS[USERNAME]}"
|
[[ -z "$HM_USERNAME" && -v 'HM_CONFIG_VARS[USERNAME]' ]] && HM_USERNAME="${HM_CONFIG_VARS[USERNAME]}"
|
||||||
[[ -z "$HM_PASSWORD" && -v 'HM_CONFIG_VARS[PASSWORD]' ]] && HM_PASSWORD="${HM_CONFIG_VARS[PASSWORD]}"
|
if [[ -z "$HM_PASSWORD" && -v 'HM_CONFIG_VARS[PASSWORD]' ]]; then
|
||||||
|
local pw="${HM_CONFIG_VARS[PASSWORD]}"
|
||||||
|
if [[ "$pw" == enc:* ]]; then
|
||||||
|
HM_PASSWORD=$(_decrypt_value "${pw#enc:}")
|
||||||
|
else
|
||||||
|
HM_PASSWORD="$pw"
|
||||||
|
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() {
|
||||||
@@ -153,7 +270,14 @@ resolve_registry_alias() {
|
|||||||
local pass_key="REGISTRY_${alias_upper}_PASSWORD"
|
local pass_key="REGISTRY_${alias_upper}_PASSWORD"
|
||||||
HM_REGISTRY="${HM_CONFIG_VARS[$url_key]}"
|
HM_REGISTRY="${HM_CONFIG_VARS[$url_key]}"
|
||||||
[[ -z "$HM_USERNAME" && -v "HM_CONFIG_VARS[$user_key]" ]] && HM_USERNAME="${HM_CONFIG_VARS[$user_key]}"
|
[[ -z "$HM_USERNAME" && -v "HM_CONFIG_VARS[$user_key]" ]] && HM_USERNAME="${HM_CONFIG_VARS[$user_key]}"
|
||||||
[[ -z "$HM_PASSWORD" && -v "HM_CONFIG_VARS[$pass_key]" ]] && HM_PASSWORD="${HM_CONFIG_VARS[$pass_key]}"
|
if [[ -z "$HM_PASSWORD" && -v "HM_CONFIG_VARS[$pass_key]" ]]; then
|
||||||
|
local pw="${HM_CONFIG_VARS[$pass_key]}"
|
||||||
|
if [[ "$pw" == enc:* ]]; then
|
||||||
|
HM_PASSWORD=$(_decrypt_value "${pw#enc:}")
|
||||||
|
else
|
||||||
|
HM_PASSWORD="$pw"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
# Strip trailing slash
|
# Strip trailing slash
|
||||||
@@ -200,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"
|
||||||
@@ -394,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
|
||||||
@@ -414,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
|
||||||
|
|
||||||
@@ -522,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
|
||||||
@@ -537,7 +670,9 @@ ${C_BOLD}Commands:${C_RESET}
|
|||||||
delete <image>:<ref> Delete an image tag or manifest
|
delete <image>:<ref> Delete an image tag or manifest
|
||||||
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
|
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
|
||||||
@@ -550,23 +685,73 @@ 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 ---
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
cmd_login() {
|
cmd_login() {
|
||||||
local do_save=false
|
local do_save=false
|
||||||
|
local do_encrypt=false
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--save) do_save=true; shift ;;
|
--save) do_save=true; shift ;;
|
||||||
|
--encrypt) do_encrypt=true; shift ;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
Usage: hubmanager login [--registry URL] [--user USER] [--password PASS] [--save]
|
Usage: hubmanager login [--registry URL] [--user USER] [--password PASS] [--save] [--encrypt]
|
||||||
|
|
||||||
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.
|
||||||
|
A master passphrase will be prompted; you must enter the same
|
||||||
|
passphrase on every subsequent hubmanager invocation that reads
|
||||||
|
the config file.
|
||||||
EOF
|
EOF
|
||||||
exit 0 ;;
|
exit 0 ;;
|
||||||
*) die "Unknown option: $1. Run 'hubmanager login --help'." ;;
|
*) die "Unknown option: $1. Run 'hubmanager login --help'." ;;
|
||||||
@@ -601,16 +786,29 @@ 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=""
|
||||||
|
if [[ -n "$HM_PASSWORD" ]]; then
|
||||||
|
if [[ "$do_encrypt" == true ]]; then
|
||||||
|
_prompt_set_master_pass
|
||||||
|
local ciphertext
|
||||||
|
ciphertext=$(_encrypt_value "$HM_PASSWORD")
|
||||||
|
pw_entry="PASSWORD=enc:${ciphertext}"
|
||||||
|
else
|
||||||
|
pw_entry="PASSWORD=${HM_PASSWORD}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
{
|
{
|
||||||
echo "# hubmanager configuration"
|
echo "# hubmanager configuration"
|
||||||
echo "# Generated by: hubmanager login"
|
echo "# Generated by: hubmanager login"
|
||||||
echo ""
|
echo ""
|
||||||
echo "REGISTRY=${HM_REGISTRY}"
|
echo "REGISTRY=${HM_REGISTRY}"
|
||||||
[[ -n "$HM_USERNAME" ]] && echo "USERNAME=${HM_USERNAME}"
|
[[ -n "$HM_USERNAME" ]] && echo "USERNAME=${HM_USERNAME}"
|
||||||
[[ -n "$HM_PASSWORD" ]] && echo "PASSWORD=${HM_PASSWORD}"
|
[[ -n "$pw_entry" ]] && echo "$pw_entry"
|
||||||
} > "$config"
|
} > "$config"
|
||||||
chmod 600 "$config"
|
chmod 600 "$config"
|
||||||
info "Credentials saved to $config"
|
info "Credentials saved to $config"
|
||||||
|
[[ "$do_encrypt" == true ]] && info "Password stored encrypted (AES-256-CBC). Master passphrase required on each use."
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1094,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
|
||||||
@@ -1115,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
|
||||||
@@ -1487,6 +1689,7 @@ parse_global_args() {
|
|||||||
-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 ;;
|
||||||
|
--cache-timeout) HM_CACHE_TIMEOUT="$2"; shift 2 ;;
|
||||||
--json) HM_OPT_JSON=true; shift ;;
|
--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 ;;
|
||||||
@@ -1494,8 +1697,8 @@ parse_global_args() {
|
|||||||
-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
|
||||||
|
|
||||||
@@ -1528,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 ;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
Reference in New Issue
Block a user