diff --git a/.gitignore b/.gitignore index 495178a..d9ad242 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,6 @@ GEMINI.md AGENTS.md AGENT.md .mcp.json + +# Local test environment +testenv/ diff --git a/README.md b/README.md index 1e847df..de5d08b 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,14 @@ A high-performance, memory-safe, zero-dependency Go CLI and client library for * - **Wire-Speed Multi-GB Streaming**: Streams 2.5 GB – 10+ GB BIG-IP ISOs, OVAs, and qcow2 images directly to disk using `io.TeeReader` with simultaneous SHA-256 verification and minimal RAM footprint (< 10 MB). - **Direct Appliance Push & Pull Engine**: Transfers software ISOs, engineering hotfixes, diagnostic QKViews, and configuration files directly between local systems and BIG-IP / BIG-IQ appliances with **automatic seamless failover** between **iControl REST chunked transfer** (HTTPS) and **native Go SCP streaming** (SSH). - **End-to-End Download & Deploy**: Downloads an image from MyF5, cryptographically verifies its SHA-256 checksum, and immediately pushes it to target appliances in a single atomic pipeline (`myf5 downloads get ... --push `). +- **Automated GeoIP Deployment Engine**: Downloads, stages, unpacks, installs, and reloads MaxMind/Digital Envoy GeoIP databases into BIG-IP TMOS in a single atomic pipeline (`myf5 geoip` or `myf5 downloads get ... --apply-geoip`). +- **Outbound Corporate & Lab Proxy Support**: Universal support for HTTP, HTTPS, SOCKS5, and SOCKS5h proxies via `--proxy` or standard environment variables (`ALL_PROXY`, `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`). Includes diagnostic probe command `myf5 proxy`. +- **Interactive Case Creation Wizard**: Step-by-step TUI wizard (`myf5 wizard`) with automatic serial and version discovery from live appliances or `.qkview` files, interactive multiline text buffer with undo (`Ctrl+Z`), and dry-run safety gates. +- **Signal Handling & Interruption Safety**: Comprehensive trap for `SIGINT` (Ctrl+C) and `SIGTERM` across all commands; ensures partial `.part` downloads are pruned, active SSH channels and TCP sockets are closed gracefully, and no orphaned processes linger. - **MyF5 Downloads (ESDP) Ready**: Targets `api.software.downloads.f5.com` without the legacy `k` query parameter, over the gateway's doubled-`v1` path prefix (`/v1/downloads-management/v1/...` — not a typo; see `pkg/downloads`), with a legacy fallback and HTTP 429 rate-limit backoff. - **Strict Zero-Secret CLI Security**: Rejects plaintext secret arguments (`--password`, `--client-secret`) with exit code `2` to protect against process table (`ps aux`) and shell history leaks. - **Multiple Credential Formats**: Automatically discovers credentials in `F5_CLIENT_ID` / `F5_CLIENT_SECRET` environment variables or `~/.f5api_credentials` (both `KEY=VALUE` and `[profile]` INI formats). -- **Interactive Console Browser**: Menu-driven terminal TUI for exploring product families, branches, release versions, and signed CDN download links. +- **Interactive Console & REPL**: Menu-driven terminal TUI for catalog exploration (`myf5 downloads browse`) and interactive CLI shell (`myf5 interactive`). - **Universal Appliance Connectivity**: Seamlessly supports standard LAN management IPs (`10.1.1.4`), custom management ports (`:8443`), and cloud/UDF access proxies (`https://*.access.udf.f5.com`). --- @@ -29,17 +33,19 @@ A high-performance, memory-safe, zero-dependency Go CLI and client library for * myf5-cli/ ├── cmd/ │ └── myf5/ -│ └── main.go # Unified CLI entrypoint & Zero-Secret validator +│ ├── main.go # Unified CLI entrypoint, flag parsing, signal handling +│ └── wizard.go # Interactive case creation wizard & multiline text editor ├── pkg/ │ ├── auth/ # OAuth2 authentication, Zero-Secret validator, credentials resolver -│ ├── client/ # Secure TLS 1.2+ HTTP transport with AEAD/PFS ciphers +│ ├── client/ # Secure TLS 1.2+ HTTP transport, SOCKS5/HTTP proxy dialer, timeouts │ ├── downloads/ # Downloads API catalog client and streaming downloader -│ ├── cases/ # MyF5 Support Case API client +│ ├── cases/ # MyF5 Support Case API client & fallback metadata schema │ ├── ihealth/ # F5 iHealth QKView multipart upload and status client │ ├── bigip/ # BIG-IP & BIG-IQ client (iControl REST chunking, bash promotion, native SCP, failover) │ │ ├── client.go # Appliance HTTP client, readiness, QKView automation, REST download │ │ ├── upload.go # iControl REST chunked file transfer & bash promotion │ │ ├── scp.go # Native Go SCP streaming (upload & download) over SSH +│ │ ├── geoip.go # TMOS GeoIP extraction, RPM installation, and reload engine │ │ └── failover.go # Dual-protocol automatic failover engine │ └── completion/ # Zero-dependency shell tab completion generator (bash, zsh, fish) ├── .github/ @@ -53,7 +59,8 @@ myf5-cli/ │ ├── downloads.md # Downloads catalog taxonomy, GeoDB automation & direct push │ ├── credentials.md # Zero-Secret policy & credential management │ ├── shell_completion.md # Shell tab autocompletion guide (zsh, bash, fish) -│ └── network_and_firewall.md # Egress firewall rules and DNS cutover documentation +│ └── network_and_firewall.md # Egress firewall rules, proxy routing, and DNS cutover documentation +├── testenv/ # Automated AWS test lab (OpenTofu / Terraform: Proxy + BIG-IP VE BYOL) ├── Makefile # Developer build, test, and release automation ├── SECURITY.md # Security & vulnerability reporting policy ├── CONTRIBUTING.md # Developer workflow & coding standards @@ -173,14 +180,18 @@ Verify API credentials, DNS resolution, and TCP port 443 egress to all F5 Cloud ./bin/myf5 cases list --all # Retrieve case creation schema metadata +# Note: Remote F5 metadata endpoint currently returns HTTP 500 (server-side bug); +# the CLI handles this gracefully, and the interactive wizard uses offline schema fallback. ./bin/myf5 cases metadata # Show one case in full, including contacts and the email thread ./bin/myf5 cases get 01287164 -# Add a note or comment to a case +# Add a note or comment to a case (supports interactive multiline editor with Ctrl+Z undo) # Case numbers are 8 digits, no prefix - exactly as MyF5 displays them. ./bin/myf5 cases comment 01287164 "Investigating packet captures." +# Or launch multiline buffer: +./bin/myf5 cases comment 01287164 # Open a case. An entitlement identifier is required and is validated against # F5's records, but WHICH one depends on the product family: BIG-IP and other @@ -197,7 +208,7 @@ Verify API credentials, DNS resolution, and TCP port 443 egress to all F5 Cloud # Alternatively, launch the interactive step-by-step case creation wizard. # Supports auto-detecting serials/versions from live BIG-IPs or .qkview files, -# auto-detects your system timezone, and includes a dry-run review safety gate: +# auto-detects your system timezone, multiline buffer with Ctrl+Z undo, and dry-run safety gates: ./bin/myf5 cases wizard # Or top-level shortcut: ./bin/myf5 wizard @@ -222,6 +233,12 @@ Works with local management IPs, lab appliances, or cloud/UDF external access pr # Check TMOS readiness (via UDF or Cloud Access Proxy) ./bin/myf5 bigip ready https://aa68f6ba-a751-4740-9382-85f7cd7940f2.access.udf.f5.com/ +# Inspect appliance version, edition, and build details +./bin/myf5 bigip info 10.1.1.4 --user admin + +# Retrieve chassis/system serial number (useful for case creation) +./bin/myf5 bigip serial 10.1.1.4 --user admin + # Trigger full, non-truncating QKView generation (-s0) with resilient download ./bin/myf5 bigip qkview 10.1.1.4 --user admin @@ -233,6 +250,11 @@ Works with local management IPs, lab appliances, or cloud/UDF external access pr # Download software image from BIG-IP appliance ./bin/myf5 bigip download 10.1.1.4 /shared/images/BIGIP-17.1.1.iso -o ./BIGIP-17.1.1.iso + +# Push and activate GeoIP database archive in one step +./bin/myf5 bigip push 10.1.1.4 /var/tmp/ip-geolocation-default.zip --apply-geoip +# Or invoke direct GeoIP installer +./bin/myf5 bigip geoip 10.1.1.4 /var/tmp/ip-geolocation-default.zip ``` ### 6. BIG-IQ Centralized Management & Image Transfer @@ -256,7 +278,54 @@ Works with local management IPs, lab appliances, or cloud/UDF external access pr ./bin/myf5 pull 10.1.1.4 /var/log/ltm -o ./ltm.log --method scp -u root -i ~/.ssh/id_ed25519 ``` -### 8. Shell Tab Autocompletion (`bash`, `zsh`, `fish`) +### 8. Automated GeoIP Deployment Engine +Automate the end-to-end lifecycle of MaxMind / Digital Envoy GeoIP updates (askF5 K11176 / K78974041): +```bash +# Download latest GeoIP update package and apply to appliance in a single pipeline: +./bin/myf5 downloads get "BIG-IP" "big-ip_v17.x" "17.1.1" "GeoLocationUpdates" \ + "ip-geolocation-default.zip" -o /var/tmp/geoip.zip \ + --push 10.1.1.4 --apply-geoip + +# Or apply an already downloaded archive directly to a BIG-IP: +./bin/myf5 geoip 10.1.1.4 /var/tmp/ip-geolocation-default.zip +``` + +### 9. Outbound Corporate & Lab Proxy Support +`myf5` supports outbound HTTP, HTTPS, SOCKS5, and SOCKS5h proxies for corporate networks and isolated lab environments: +```bash +# Probe and test proxy connectivity: +./bin/myf5 proxy +./bin/myf5 proxy --proxy socks5://proxy.corp.internal:1080 + +# Run commands through proxy via global flag: +./bin/myf5 --proxy socks5://10.0.0.1:1080 doctor +./bin/myf5 --proxy http://10.0.0.1:8080 downloads families + +# Or configure via standard environment variables: +export ALL_PROXY="socks5://proxy.corp.internal:1080" +export HTTPS_PROXY="http://proxy.corp.internal:8080" +export NO_PROXY="localhost,127.0.0.1,10.0.0.0/8,.internal" +``` + +### 10. Interactive Case Creation Wizard +Launch a guided terminal interface that validates input at every step: +```bash +./bin/myf5 wizard +# Or: +./bin/myf5 cases wizard +``` +- Auto-detects serial number and TMOS version from a live appliance IP or local `.qkview` archive. +- System timezone detection automatically maps to F5's expected string representation. +- Multiline description text buffer supports live editing with undo (`Ctrl+Z`) and redo (`Ctrl+Y`). +- Pre-submission dry-run safety review ensures you never open an accidental case against production. + +### 11. Interactive Console REPL +Launch an interactive shell session for continuous operation without repeated binary invocations: +```bash +./bin/myf5 interactive +``` + +### 12. Shell Tab Autocompletion (`bash`, `zsh`, `fish`) Enable fast tab autocompletion for commands, subcommands, flags, and product families: ```bash @@ -310,6 +379,11 @@ myf5 completion fish > ~/.config/fish/completions/myf5.fish ``` - **Masked Diagnostics:** Diagnostic utilities (`myf5 doctor`) mask all secret strings, displaying only non-sensitive metadata (e.g. `Client ID: 9x8*************************`). +### 3. Interruption Safety & Resource Cleanup (CTRL+C / SIGINT / SIGTERM) +- **Top-Level Signal Trapping:** Global context (`signal.NotifyContext`) traps interruption signals across all CLI operations, ensuring clean cancellation rather than abrupt process termination. +- **Atomic File Cleanup:** Streaming file downloads write to temporary `.part` files. If interrupted via `Ctrl+C`, the `.part` files are immediately removed from disk to prevent corrupt or incomplete files from accumulating. +- **Graceful Network & Session Teardown:** In-flight HTTP request bodies, TLS connection pools, native SSH client sessions, and SCP channels are closed cleanly upon signal receipt, preventing connection hangs and orphaned appliance processes. + --- ## 📚 Documentation & Technical Guides diff --git a/cmd/myf5/interactive.go b/cmd/myf5/interactive.go index e25177f..cacf8f3 100644 --- a/cmd/myf5/interactive.go +++ b/cmd/myf5/interactive.go @@ -5,7 +5,9 @@ import ( "fmt" "io" "os" + "os/signal" "strings" + "syscall" "unicode" "myf5-cli/pkg/client" @@ -23,7 +25,7 @@ var exitFunc = os.Exit // startInteractive launches the interactive CLI shell with command history // and tab-completion. -func startInteractive(ctx context.Context) { +func startInteractive() { fd := int(os.Stdin.Fd()) if !term.IsTerminal(fd) { fmt.Fprintln(os.Stderr, "[ERROR] Interactive mode requires a terminal TTY.") @@ -100,12 +102,12 @@ func startInteractive(ctx context.Context) { } // Execute command in-process with panic recovery for exitFunc - runSafely(ctx, args) + runSafely(args) } } -// runSafely executes a command line, recovering from exitPanic. -func runSafely(ctx context.Context, rawArgs []string) { +// runSafely executes a command line with an isolated per-command signal context, recovering from exitPanic. +func runSafely(rawArgs []string) { defer func() { if r := recover(); r != nil { if _, ok := r.(exitPanic); ok { @@ -128,7 +130,12 @@ func runSafely(ctx context.Context, rawArgs []string) { return } - executeSubcommand(ctx, args[0], args[1:]) + // Create an isolated context for this interactive command invocation. + // Hitting Ctrl+C cancels only this command's execution and cleanly returns to the shell prompt. + cmdCtx, cmdCancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cmdCancel() + + executeSubcommand(cmdCtx, args[0], args[1:]) } // splitCommandLine splits a string into arguments respecting single and double quotes. diff --git a/cmd/myf5/main.go b/cmd/myf5/main.go index 20ea412..b4bed06 100644 --- a/cmd/myf5/main.go +++ b/cmd/myf5/main.go @@ -32,7 +32,36 @@ import ( const Version = "1.1.0" func main() { - // Set up graceful SIGINT / SIGTERM context + // Enforce Zero-Secret CLI Policy + if err := auth.CheckNoCLISecrets(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "\n[SECURITY ERROR] %s\n\n", err.Error()) + exitFunc(2) + } + + // Extract global --proxy flag if present + cleanArgs, proxyFlag := extractProxyFlag(os.Args[1:]) + if proxyFlag != "" { + if err := client.SetGlobalProxy(proxyFlag); err != nil { + fmt.Fprintf(os.Stderr, "\n[ERROR] Invalid proxy URL: %v\n\n", err) + exitFunc(1) + } + } + + isInteractive := (len(cleanArgs) < 1 && term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))) || + (len(cleanArgs) > 0 && (cleanArgs[0] == "interactive" || cleanArgs[0] == "shell" || cleanArgs[0] == "repl" || cleanArgs[0] == "i")) + + if isInteractive { + startInteractive() + return + } + + if len(cleanArgs) < 1 { + printUsage() + exitFunc(1) + return + } + + // Set up graceful SIGINT / SIGTERM context for one-shot command execution ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -55,31 +84,6 @@ func main() { } }() - // Enforce Zero-Secret CLI Policy - if err := auth.CheckNoCLISecrets(os.Args[1:]); err != nil { - fmt.Fprintf(os.Stderr, "\n[SECURITY ERROR] %s\n\n", err.Error()) - exitFunc(2) - } - - // Extract global --proxy flag if present - cleanArgs, proxyFlag := extractProxyFlag(os.Args[1:]) - if proxyFlag != "" { - if err := client.SetGlobalProxy(proxyFlag); err != nil { - fmt.Fprintf(os.Stderr, "\n[ERROR] Invalid proxy URL: %v\n\n", err) - exitFunc(1) - } - } - - if len(cleanArgs) < 1 { - if term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) { - startInteractive(ctx) - return - } - printUsage() - exitFunc(1) - return - } - subcommand := cleanArgs[0] args := cleanArgs[1:] @@ -141,7 +145,7 @@ func executeSubcommand(ctx context.Context, subcommand string, args []string) { case "doctor": handleDoctor(ctx, args) case "interactive", "shell", "repl", "i": - startInteractive(ctx) + startInteractive() case "version", "--version", "-v": fmt.Printf("myf5-cli version %s (Go 1.27.1 / darwin-arm64)\n", Version) case "help", "--help", "-h": @@ -791,7 +795,7 @@ func handleDownloads(ctx context.Context, args []string) { } link := links[0] - fmt.Printf("Mirror Region: %s\n", link.GetRegion()) + fmt.Printf("Mirror Region: %s (link expires %s)\n", link.GetRegion(), link.FormattedExpires()) fmt.Printf("Target File: %s\n", outPath) if expectedHash != "" { fmt.Printf("Expected SHA: %s\n", expectedHash) @@ -1279,7 +1283,7 @@ familyLoop: } link := links[0] - fmt.Printf("Mirror URL (expires %s, Region: %s):\n%s\n\n", link.ExpiresAt, link.GetRegion(), link.Href) + fmt.Printf("Mirror URL (expires %s, Region: %s):\n%s\n\n", link.FormattedExpires(), link.GetRegion(), link.Href) targetPath := filepath.Join(getDefaultDownloadsDir(), filepath.Base(selectedTarget.file.Name)) fmt.Printf("Download this file directly to %s? [y/N]: ", targetPath) confirmInput, err := reader.ReadString('\n') @@ -2646,21 +2650,28 @@ func resolveApplianceSystemInfo(ctx context.Context, host string, port int, user ClientKeyPath: certKeyPath, }) if err == nil { - info, err := client.GetSystemInfo(ctx) - if err == nil { + var info *bigip.SystemInfo + var restErr error + info, restErr = client.GetSystemInfo(ctx) + if restErr == nil { return info, nil } if method != "auto" { - return nil, err + return nil, restErr } + err = restErr } else if method != "auto" { return nil, err } if method == "auto" { + sshPort := 22 + if port > 0 && port != 443 && port != 8443 { + sshPort = port + } opts := bigip.SCPOptions{ Host: host, - Port: port, + Port: sshPort, Username: user, Password: pw, KeyPath: keyPath, @@ -3147,6 +3158,12 @@ func handleDoctor(ctx context.Context, args []string) { fmt.Println("\n3. Network Egress & DNS Connectivity Audit (TCP 443):") for _, ep := range endpoints { + if ctx.Err() != nil { + fmt.Fprintln(os.Stderr, "\n[CANCELLED] Doctor audit aborted by user.") + exitFunc(130) + return + } + target := net.JoinHostPort(ep.fqdn, strconv.Itoa(ep.port)) dialCtx, dialCancel := context.WithTimeout(ctx, 4*time.Second) conn, err := client.DialProxy(dialCtx, "tcp", target, nil) diff --git a/docs/appliance_push.md b/docs/appliance_push.md index 2cba54b..dc52361 100644 --- a/docs/appliance_push.md +++ b/docs/appliance_push.md @@ -206,10 +206,21 @@ BIG-IP Application Security Manager (ASM / Advanced WAF), Advanced Firewall Mana Active databases reside on the appliance filesystem under `/shared/GeoIP/` (and `/shared/GeoIP/v2/`). -#### Staging the Update Archive -The packages downloaded from the MyF5 `GeoLocationUpdates` container are delivered as a compressed `.zip` archive (e.g., `ip-geolocation-default.zip` or `ip-geolocation-*.zip`) containing individual `.rpm` packages for region, ISP, and organization data. +#### Automated One-Step Push and Activation (Recommended) +`myf5` provides native automation that stages the archive, unpacks the internal `.rpm` files, executes `geoip_update_data -f` for each package, and triggers `tmsh load sys geoip` across TMOS: -Stage the archive in `/var/tmp` or `/shared/tmp`: +```bash +# Push and automatically apply GeoIP updates in a single command: +myf5 bigip push 192.0.2.10 /var/tmp/ip-geolocation-default.zip --apply-geoip + +# Or use the dedicated GeoIP command: +myf5 bigip geoip 192.0.2.10 /var/tmp/ip-geolocation-default.zip +# Top-level alias: +myf5 geoip 192.0.2.10 /var/tmp/ip-geolocation-default.zip +``` + +#### Manual Staging and TMOS Execution (Alternative) +If preferred, you can stage the archive manually in `/var/tmp` or `/shared/tmp`: ```bash # Push GeoDB update archive to appliance staging directory via iControl REST diff --git a/docs/cli.md b/docs/cli.md index 86a8de1..922421e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -25,12 +25,16 @@ In accordance with enterprise DevSecOps standards, **passing credentials or toke | :--- | :--- | | [`downloads`](#1-myf5-downloads) | Browse the MyF5 software catalog and stream multi-GB images | | [`cases`](#2-myf5-cases) | Inspect, query, and comment on MyF5 support cases | +| [`wizard`](#11-myf5-wizard) | Interactive guided support case creation wizard with live appliance auto-fill | | [`ihealth`](#3-myf5-ihealth) | Upload and audit QKView diagnostic bundles on F5 iHealth | -| [`bigip`](#4-myf5-bigip) | Orchestrate BIG-IP TMOS appliances (readiness, QKView generation, push) | +| [`bigip`](#4-myf5-bigip) | Orchestrate BIG-IP TMOS appliances (readiness, QKView generation, push, GeoIP) | | [`bigiq`](#5-myf5-bigiq) | Orchestrate BIG-IQ Centralized Management appliances (readiness, push) | | [`push`](#6-myf5-push) | Push downloads or files to BIG-IP / BIG-IQ via iControl REST or SCP | | [`pull`](#7-myf5-pull) | Pull files or diagnostics from BIG-IP / BIG-IQ via iControl REST or SCP | +| [`geoip`](#12-myf5-geoip) | Apply GeoIP updates directly to BIG-IP appliances (alias: `bigip geoip`) | +| [`proxy`](#13-myf5-proxy) | View or configure HTTP/HTTPS/SOCKS5 outbound egress proxy | | [`doctor`](#8-myf5-doctor) | Run connectivity, DNS, egress, and credential diagnostics | +| [`interactive`](#14-myf5-interactive) | Launch interactive REPL shell with tab completion (`shell`, `repl`) | | [`completion`](#9-myf5-completion) | Generate native shell autocompletion scripts (`bash`, `zsh`, `fish`) | | [`version`](#10-myf5-version) | Display application version and toolchain info | @@ -145,7 +149,7 @@ Retrieves the authoritative schema metadata and valid values for case creation. myf5 cases metadata ``` -> **Known outage (2026-10-05):** this endpoint returns +> **Known outage (re-verified 2026-10-06):** this endpoint returns > `500 "Malformed JSON: Expected '{' at the beginning of object"` for every > request, including a plain `GET` with no body. The fault is server-side at > F5 and there is no client-side workaround. It is the documented source of @@ -153,20 +157,33 @@ myf5 cases metadata > so those values currently cannot be enumerated from the API. > > Until it recovers, copy the values from an existing case with -> `myf5 cases get `, or use `--dry-run` on `cases create`, which +> `myf5 cases get `, use `myf5 cases wizard` (which incorporates +> known-valid schemas offline), or use `--dry-run` on `cases create`, which > validates your field values and reports what the API would reject without > opening a case. -#### `comment ` -Appends a status comment or investigation update to an open support case. +#### `comment [note]` +Appends a status comment or investigation update to an open support case. If `[note]` is omitted, launches an interactive multiline text buffer with editing, undo/redo (`Ctrl+Z`), and word wrapping. ```bash +# Pass comment directly myf5 cases comment 01287164 "Uploaded new diagnostic packet captures." + +# Launch interactive multiline editor +myf5 cases comment 01287164 ``` > The API reports failure in the response body (`isError`) as well as by > status code, so a `2xx` alone does not mean the comment landed. The CLI > checks both. Confirm in the MyF5 web UI when it matters. +#### `wizard` +Launches the interactive, step-by-step case creation wizard. Auto-detects system timezone, prompts for product family and entitlements, supports live auto-detection from appliances or QKViews, and provides a dry-run review before submission. +```bash +myf5 cases wizard +# Or top-level shortcut: +myf5 wizard +``` + #### `get ` Shows one case in full: status, priority, product, account, contacts, the description, and a list of the messages on the email thread. @@ -283,13 +300,29 @@ myf5 bigip ready 10.1.1.4 myf5 bigip ready https://aa68f6ba-a751-4740-9382-85f7cd7940f2.access.udf.f5.com/ ``` +#### `info [--json]` +Inspects a live BIG-IP appliance or local `.qkview` archive to extract TMOS version, build, chassis/platform model, base registration key, and chassis serial number. +```bash +# Query live appliance +myf5 bigip info 10.1.1.4 --user admin + +# Query offline QKView diagnostic archive +myf5 bigip info /path/to/diagnostic.qkview +``` + +#### `serial [--json]` +Quickly extracts and prints the hardware chassis serial number from a live appliance or offline `.qkview`. +```bash +myf5 bigip serial 10.1.1.4 +``` + #### `qkview [--user ] [--port ]` Generates a complete, non-truncating (`-s0`) diagnostic QKView archive on the BIG-IP appliance via `/mgmt/tm/util/qkview` or `/mgmt/cm/autodeploy/qkview` with live spinner status updates, then downloads it directly to local disk (falling back to native Go SCP if autodeploy download is unavailable). ```bash myf5 bigip qkview 10.1.1.4 --user admin ``` -#### `push [--method auto|icontrol|scp] [--dest ] [--user ] [--port ] [-i ]` +#### `push [--method auto|icontrol|scp] [--dest ] [--apply-geoip] [--user ] [--port ] [-i ]` Pushes a local software image, engineering hotfix, or GeoDB archive to the BIG-IP appliance with automatic seamless failover between iControl REST and SCP. ```bash # Push ISO to /shared/images with automatic failover (default) @@ -298,8 +331,14 @@ myf5 bigip push 10.1.1.4 /var/tmp/BIGIP-17.1.1.iso --dest /shared/images # Push hotfix explicitly via native Go SCP myf5 bigip push 10.1.1.4 /var/tmp/Hotfix-BIGIP-17.1.1.1.0.0.2-ENG.iso --method scp -i ~/.ssh/f5_key -# Push GeoDB update archive to /var/tmp staging directory via iControl REST -myf5 bigip push 10.1.1.4 /var/tmp/ip-geolocation-default.zip --dest /var/tmp --method icontrol +# Push GeoDB update archive and immediately extract & activate in TMM +myf5 bigip push 10.1.1.4 /var/tmp/ip-geolocation-default.zip --apply-geoip +``` + +#### `geoip [--keep-extracted] [--method auto|icontrol|scp] [--json]` +Uploads a GeoIP update ZIP archive, extracts the RPM packages on the appliance, installs them via `geoip_update_data`, and signals TMM to reload the geolocation database. +```bash +myf5 bigip geoip 10.1.1.4 /var/tmp/ip-geolocation-default.zip ``` #### `download [-o ] [--method auto|icontrol|scp] [--user ] [--port ] [-i ]` @@ -460,7 +499,94 @@ myf5 version --- -## 11. Process Exit Codes +## 11. `myf5 wizard` + +Top-level alias for `myf5 cases wizard`. Launches an interactive, multi-step terminal wizard to guide you through creating an F5 Support Case with zero memorization of product schema strings: + +```bash +myf5 wizard +``` + +### Features: +- **Automatic Environment Detection**: Reads local machine timezone and maps it to valid F5 Salesforce timezone strings automatically. +- **Live Device Autofill**: Optionally connects to a live BIG-IP appliance (`--user admin`) or parses a local `.qkview` file to extract the chassis serial number, TMOS product version, and software line automatically. +- **Multiline Text Buffer with Undo**: The description step launches an interactive multiline text buffer with editing, word wrapping, undo/redo (`Ctrl+Z`), and line navigation. +- **Bidirectional Step Navigation**: Type `back` or `b` at any prompt to re-visit previous steps and adjust earlier selections. +- **Safety Dry-Run Gate**: Displays the full JSON payload before making any network write. Requests explicit final confirmation before sending to the live API. + +--- + +## 12. `myf5 geoip` + +Top-level alias for `myf5 bigip geoip`. Uploads, unpacks, installs, and reloads F5 GeoIP database packages directly on a BIG-IP appliance in a single automated command: + +```bash +myf5 geoip [--keep-extracted] [--method auto|icontrol|scp] [--json] +``` + +### Actions: +1. Validates the local ZIP package and inspects contained GeoIP RPMs (`F5GeoIPData`, `F5GeoIPDataV6`, etc.). +2. Transfers the package to `/var/tmp` on the appliance via iControl REST or SCP. +3. Automatically unzips packages and applies updates using TMOS `geoip_update_data -f`. +4. Executes `tmsh load sys geoip-data` to activate updated geolocation routing in TMM memory without disruption. +5. Cleans up temporary files on the appliance (unless `--keep-extracted` is specified). + +--- + +## 13. `myf5 proxy` + +Manage outbound HTTP, HTTPS, and SOCKS5 proxy settings for routing API traffic through corporate forward proxies, bastion hosts, or air-gapped jump boxes. + +```bash +myf5 proxy show # Display active egress proxy configuration +myf5 proxy set # Persistently configure default proxy (http://, https://, socks5://) +myf5 proxy clear # Remove persistent proxy configuration +myf5 --proxy # Specify proxy for a single command invocation +``` + +### Supported Schemes: +- `http://[user:pass@]host:port`: Standard HTTP CONNECT proxy. +- `https://[user:pass@]host:port`: TLS CONNECT proxy. +- `socks5://[user:pass@]host:port`: RFC 1928 SOCKS5 proxy (local DNS resolution). +- `socks5h://[user:pass@]host:port`: SOCKS5 proxy with remote DNS resolution on the proxy server. + +### Environment Variable Fallback: +If no `--proxy` flag or persistent configuration is found, `myf5` respects `MYF5_PROXY`, `F5_PROXY`, `ALL_PROXY`, `HTTPS_PROXY`, and `HTTP_PROXY`. Proxy bypass rules can be specified via `NO_PROXY`. + +--- + +## 14. `myf5 interactive` (alias: `shell`, `repl`) + +Launches an interactive Read-Eval-Print-Loop (REPL) shell environment with built-in tab autocompletion, command history, and session state: + +```bash +myf5 interactive +# Or shortcuts: +myf5 shell +myf5 repl +``` + +Within the interactive shell, execute commands directly without the `myf5` prefix: +``` +myf5> doctor +myf5> downloads families +myf5> cases list +myf5> exit +``` + +--- + +## 15. Signal Handling & Interruption Safety (CTRL+C) + +All long-running network connections, streaming file transfers, and appliance operations in `myf5-cli` implement robust interrupt and cancellation handling: +- **Clean Signal Traps**: Intercepts `SIGINT` (Ctrl+C) and `SIGTERM` via isolated command contexts (`signal.NotifyContext`). +- **Safe Connection Teardown**: Immediately aborts in-flight HTTP requests, closes SOCKS5 proxy tunnels, and gracefully closes SSH/SCP channels without socket leaks or hanging child processes. +- **Atomic Partial File Cleanup**: Streaming file downloads automatically scrub and remove temporary `.part` files on disk upon interrupt, preventing corrupted or incomplete downloads from being mistaken for valid files. +- **Exit Code 130**: Returns standard POSIX exit code `130` (`128 + SIGINT`) on cancellation. + +--- + +## 16. Process Exit Codes `myf5` returns standard POSIX exit codes to support reliable shell script branching and CI/CD pipelines: @@ -470,3 +596,4 @@ myf5 version | `1` | **General Error** | Command syntax error, invalid arguments, network failure, missing credentials in non-interactive environment, or appliance error. | | `2` | **Zero-Secret Violation** | Security error triggered when prohibited credential flags (`--password`, `--client-secret`) are supplied on the command line. | | `130` | **Cancelled / Interrupted** | Execution aborted by user via `SIGINT` (Ctrl+C) or `SIGTERM`. | + diff --git a/docs/downloads.md b/docs/downloads.md index d6c16bf..7df2481 100644 --- a/docs/downloads.md +++ b/docs/downloads.md @@ -96,6 +96,7 @@ MyF5 CDN ---> io.TeeReader ---> SHA-256 Checksum Verification | `--push-user ` | `$BIGIP_USER` / `"admin"` | Appliance administrative user. | | `--push-port ` | `443` (iControl) / `22` (SCP) | Appliance management port. | | `--push-key ` | *Auto-detected* | SSH private key path for SCP authentication. | +| `--apply-geoip` | `false` | Automatically extract and apply GeoIP RPMs to TMM via `geoip_update_data` post-upload. | ### Practical Examples diff --git a/docs/network_and_firewall.md b/docs/network_and_firewall.md index 21a81d5..29b8cbc 100644 --- a/docs/network_and_firewall.md +++ b/docs/network_and_firewall.md @@ -12,7 +12,7 @@ Enterprise jump boxes, CI/CD runners, and edge orchestrators running `myf5` requ | :--- | :--- | :--- | | `idp.identity.f5.com` | F5 Identity / OAuth2 | Primary Auth0 token issuer (`POST /oauth/token`) | | `identity.account.f5.com` | F5 Identity / Okta CIAM | Dedicated CIAM authorization server (`POST /oauth2/ausp95yxb4ShxMGvW357/v1/token`, audience `api://ciam-api-myf5`, scope `myf5_scope`) | -| `api.software.downloads.f5.com` | MyF5 Downloads API | Listed in K15202 as "F5 software downloads" (`159.60.134.92`). **Currently returns 404 on every path** — see §2A | +| `api.software.downloads.f5.com` | MyF5 Downloads API | Listed in K15202 as "F5 software downloads" (`159.60.134.92`). Targets gateway prefix `/v1/downloads-management/v1/...` without `k` parameter (operational). | | `support.apis.f5.com` | MyF5 Support Case API | Support case management (`/case-management/v1/cases`) | | `ihealth2-api.f5.com` | F5 iHealth API | QKView upload & analysis (`/qkview-analyzer/api/qkviews`, `Accept: application/vnd.f5.ihealth.api`). The host used in K000130498's worked example and the client default | | `ihealth-api.f5.com` | F5 iHealth API (alternate) | Same service; K15202 lists both at `185.56.152.6` | @@ -80,3 +80,60 @@ api.software.downloads.f5.com → 159.60.134.92 (ves-io-...ac.vh.ves.io) All three resolve onto F5 Distributed Cloud (`ves.io`). K15202 lists `api.software.downloads.f5.com` at `159.60.134.92` as a published service for F5 software downloads. F5's guidance is to prefer hostname-based firewall rules; where fixed-IP rules are required, validate them against K15202 periodically, since F5 notes hosted services are not bound to a single address. + +--- + +## 3. Outbound Proxy Architecture (HTTP, HTTPS, SOCKS5) + +In air-gapped data centers, DMZs, or secured corporate networks, direct Internet egress to F5 Cloud endpoints may be blocked. `myf5-cli` provides first-class support for routing all outbound traffic through forward proxies. + +### Supported Protocols & Schemes + +| Scheme | Description | Authentication | Remote DNS | +| :--- | :--- | :--- | :--- | +| `http://` | HTTP CONNECT forward proxy (e.g. Squid, NGINX) | Basic (`user:pass@host:port`) | Resolver-dependent | +| `https://` | Secure TLS forward proxy | Basic (`user:pass@host:port`) | Resolver-dependent | +| `socks5://` | SOCKS5 proxy (RFC 1928) | RFC 1929 username/password | Local DNS resolution | +| `socks5h://` | SOCKS5 proxy with remote DNS | RFC 1929 username/password | Remote proxy DNS resolution | + +### Proxy Configuration Methods + +`myf5` resolves proxy configuration using the following precedence hierarchy: + +1. **CLI Flag (`--proxy `)**: Highest priority; sets proxy for a single command invocation. + ```bash + myf5 --proxy socks5://10.99.1.50:1080 doctor + myf5 --proxy http://proxy.corp.internal:8080 downloads families + ``` + +2. **Persistent Configuration (`myf5 proxy set`)**: Writes default proxy to `~/.f5api_credentials` or config. + ```bash + myf5 proxy set socks5://127.0.0.1:1080 + myf5 proxy show + myf5 proxy clear + ``` + +3. **Environment Variables**: + - `MYF5_PROXY` / `F5_PROXY`: Tool-specific proxy overrides. + - `ALL_PROXY`: Standard universal proxy specification (commonly used for SOCKS5). + - `HTTPS_PROXY` / `HTTP_PROXY`: Standard protocol-specific environment variables. + +### Proxy Bypass (`NO_PROXY`) + +Traffic targeting local or internal appliances can bypass the configured proxy by setting `NO_PROXY` (or `no_proxy`): +```bash +export NO_PROXY="localhost,127.0.0.1,10.0.0.0/8,192.168.0.0/16,.corp.internal" +``` +The bypass matcher supports: +- Exact IP addresses and IPv6 addresses (with automatic zone identifier stripping). +- CIDR network blocks (e.g. `10.0.0.0/8`, `172.16.0.0/12`). +- Domain suffixes (e.g. `.internal`, `f5.com`). +- Wildcards (`*`). + +### Automated Lab Testing (`testenv/`) + +An isolated OpenTofu/Terraform testing environment is available in the gitignored `testenv/` directory to stand up: +- An **NGINX HTTP forward proxy** (port `8080`) and **Dante SOCKS5 proxy** (port `1080`). +- A tiny **BIG-IP VE 17.5.x BYOL** appliance for end-to-end proxy and appliance push/pull verification. +- Enforces strict security: locked strictly to caller IP (`/32`), never opens `0.0.0.0/0`, and includes dual 4-hour automatic power-off watchdogs (in-guest cron and AWS CloudWatch alarms). + diff --git a/pkg/bigip/failover.go b/pkg/bigip/failover.go index 5785fa8..ad703c8 100644 --- a/pkg/bigip/failover.go +++ b/pkg/bigip/failover.go @@ -2,6 +2,7 @@ package bigip import ( "context" + "errors" "fmt" "os" "path" @@ -86,6 +87,9 @@ func PushFileWithFailover( if scpErr == nil { return destPath, MethodSCP, nil } + if errors.Is(scpErr, context.Canceled) || ctx.Err() != nil { + return "", MethodSCP, scpErr + } if logFn != nil { logFn("SCP upload failed (%v). Automatically falling back to iControl REST...", scpErr) @@ -104,6 +108,9 @@ func PushFileWithFailover( if icontrolErr == nil { return destPath, MethodIControl, nil } + if errors.Is(icontrolErr, context.Canceled) || ctx.Err() != nil { + return "", MethodIControl, icontrolErr + } if logFn != nil { logFn("iControl REST upload failed (%v). Automatically falling back to SCP over SSH...", icontrolErr) @@ -170,6 +177,9 @@ func DownloadFileWithFailover( if restErr == nil { return savedPath, MethodIControl, nil } + if errors.Is(restErr, context.Canceled) || ctx.Err() != nil { + return "", MethodIControl, restErr + } if logFn != nil { logFn("iControl REST download failed (%v). Automatically falling back to SCP over SSH...", restErr) @@ -188,6 +198,9 @@ func DownloadFileWithFailover( if scpErr == nil { return savedPath, MethodSCP, nil } + if errors.Is(scpErr, context.Canceled) || ctx.Err() != nil { + return "", MethodSCP, scpErr + } if logFn != nil { logFn("SCP download failed (%v). Automatically falling back to iControl REST...", scpErr) @@ -370,6 +383,9 @@ func GenerateQKViewWithFailover( if restErr == nil { return filename, MethodIControl, nil } + if errors.Is(restErr, context.Canceled) || ctx.Err() != nil { + return "", MethodIControl, restErr + } if logFn != nil { logFn("iControl REST QKView generation failed (%v). Automatically falling back to SSH...", restErr) @@ -406,6 +422,9 @@ func GenerateQKViewWithFailover( if sshErr == nil { return filename, MethodSCP, nil } + if errors.Is(sshErr, context.Canceled) || ctx.Err() != nil { + return "", MethodSCP, sshErr + } if logFn != nil { logFn("SSH QKView generation failed (%v). Automatically falling back to iControl REST...", sshErr) @@ -431,6 +450,8 @@ func DeleteQKViewWithFailover(ctx context.Context, host, remoteFilename string, client := newClientFromOptions(host, opts, 443) if err := client.DeleteQKView(ctx, remoteFilename); err == nil { return nil + } else if errors.Is(err, context.Canceled) || ctx.Err() != nil { + return err } } diff --git a/pkg/bigip/geoip.go b/pkg/bigip/geoip.go index 798fd9b..3290541 100644 --- a/pkg/bigip/geoip.go +++ b/pkg/bigip/geoip.go @@ -367,7 +367,19 @@ func applyGeoIPSSH(ctx context.Context, host, scriptB64 string, opts GeoIPOption session.Stderr = &outBuf cmd := fmt.Sprintf("echo '%s' | base64 -d | bash", scriptB64) - runErr := session.Run(cmd) + runDone := make(chan error, 1) + go func() { + runDone <- session.Run(cmd) + }() + + var runErr error + select { + case <-ctx.Done(): + _ = session.Signal(ssh.SIGINT) + _ = session.Close() + return outBuf.String(), ctx.Err() + case runErr = <-runDone: + } rawOutput := outBuf.String() if runErr != nil && !strings.Contains(rawOutput, "--- SCRIPT_EXIT_CODE:") { diff --git a/pkg/bigip/info.go b/pkg/bigip/info.go index 221ecd0..e7a0193 100644 --- a/pkg/bigip/info.go +++ b/pkg/bigip/info.go @@ -398,84 +398,65 @@ func (c *Client) GetSystemInfo(ctx context.Context) (*SystemInfo, error) { Source: fmt.Sprintf("appliance: %s (iControl REST)", c.Host), } - // 1. Hardware & Serial - hwURL := fmt.Sprintf("%s/mgmt/tm/sys/hardware", c.BaseURL()) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, hwURL, nil) - if err == nil { + doGet := func(apiPath string, maxBytes int64) ([]byte, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + u := fmt.Sprintf("%s%s", c.BaseURL(), apiPath) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } c.setAuth(req) resp, err := c.HTTPClient.Do(req) - if err == nil { - defer resp.Body.Close() - if resp.StatusCode == http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) - parseHardwareJSON(body, info) - } + if err != nil { + return nil, err } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + return io.ReadAll(io.LimitReader(resp.Body, maxBytes)) + } + + // 1. Hardware & Serial + if body, err := doGet("/mgmt/tm/sys/hardware", 1024*1024); err == nil { + parseHardwareJSON(body, info) + } else if ctx.Err() != nil { + return nil, ctx.Err() } // 2. Version & Build - verURL := fmt.Sprintf("%s/mgmt/tm/sys/version", c.BaseURL()) - req, err = http.NewRequestWithContext(ctx, http.MethodGet, verURL, nil) - if err == nil { - c.setAuth(req) - resp, err := c.HTTPClient.Do(req) - if err == nil { - defer resp.Body.Close() - if resp.StatusCode == http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) - parseVersionJSON(body, info) - } - } + if body, err := doGet("/mgmt/tm/sys/version", 512*1024); err == nil { + parseVersionJSON(body, info) + } else if ctx.Err() != nil { + return nil, ctx.Err() } // 3. License - licURL := fmt.Sprintf("%s/mgmt/tm/sys/license", c.BaseURL()) - req, err = http.NewRequestWithContext(ctx, http.MethodGet, licURL, nil) - if err == nil { - c.setAuth(req) - resp, err := c.HTTPClient.Do(req) - if err == nil { - defer resp.Body.Close() - if resp.StatusCode == http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) - parseLicenseJSON(body, info) - } - } + if body, err := doGet("/mgmt/tm/sys/license", 512*1024); err == nil { + parseLicenseJSON(body, info) + } else if ctx.Err() != nil { + return nil, ctx.Err() } // 4. Hostname - globalURL := fmt.Sprintf("%s/mgmt/tm/sys/global-settings", c.BaseURL()) - req, err = http.NewRequestWithContext(ctx, http.MethodGet, globalURL, nil) - if err == nil { - c.setAuth(req) - resp, err := c.HTTPClient.Do(req) - if err == nil { - defer resp.Body.Close() - if resp.StatusCode == http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) - var gs struct { - Hostname string `json:"hostname"` - } - if json.Unmarshal(body, &gs) == nil && gs.Hostname != "" { - info.Hostname = gs.Hostname - } - } + if body, err := doGet("/mgmt/tm/sys/global-settings", 256*1024); err == nil { + var gs struct { + Hostname string `json:"hostname"` + } + if json.Unmarshal(body, &gs) == nil && gs.Hostname != "" { + info.Hostname = gs.Hostname } + } else if ctx.Err() != nil { + return nil, ctx.Err() } // 5. Provisioned Modules - provURL := fmt.Sprintf("%s/mgmt/tm/sys/provision", c.BaseURL()) - req, err = http.NewRequestWithContext(ctx, http.MethodGet, provURL, nil) - if err == nil { - c.setAuth(req) - resp, err := c.HTTPClient.Do(req) - if err == nil { - defer resp.Body.Close() - if resp.StatusCode == http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) - parseProvisionJSON(body, info) - } - } + if body, err := doGet("/mgmt/tm/sys/provision", 256*1024); err == nil { + parseProvisionJSON(body, info) + } else if ctx.Err() != nil { + return nil, ctx.Err() } if info.ChassisSerialNumber == "" && info.RegistrationKey == "" && info.Version == "" { @@ -522,22 +503,50 @@ func GetSystemInfoSSH(ctx context.Context, opts SCPOptions) (*SystemInfo, error) sshClient := ssh.NewClient(sshConn, chans, reqs) defer sshClient.Close() - session, err := sshClient.NewSession() - if err != nil { - return nil, fmt.Errorf("failed to create SSH session: %w", err) - } - defer session.Close() + runSSHCmd := func(commandStr string) (string, string, error) { + session, err := sshClient.NewSession() + if err != nil { + return "", "", fmt.Errorf("failed to create SSH session: %w", err) + } + defer session.Close() + + var stdout, stderr bytes.Buffer + session.Stdout = &stdout + session.Stderr = &stderr + + runDone := make(chan error, 1) + go func() { + runDone <- session.Run(commandStr) + }() - var stdout, stderr bytes.Buffer - session.Stdout = &stdout - session.Stderr = &stderr + select { + case <-ctx.Done(): + _ = session.Signal(ssh.SIGINT) + _ = session.Close() + return "", "", ctx.Err() + case err := <-runDone: + return stdout.String(), stderr.String(), err + } + } cmd := `tmsh -q -c "show sys hardware; show sys version; show sys license; list sys global-settings hostname; list sys provision"` - if err := session.Run(cmd); err != nil { - return nil, fmt.Errorf("tmsh commands failed over SSH: %w (stderr: %s)", err, stderr.String()) + outStr, errStr, err := runSSHCmd(cmd) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + // If the user's login shell is tmsh (e.g. admin user), running "tmsh -q -c ..." returns + // 'Syntax Error: unexpected argument "tmsh"'. Fall back to direct tmsh command execution. + if strings.Contains(errStr, "unexpected argument \"tmsh\"") || strings.Contains(err.Error(), "unexpected argument \"tmsh\"") { + directCmd := `show sys hardware; show sys version; show sys license; list sys global-settings hostname; list sys provision` + outStr, errStr, err = runSSHCmd(directCmd) + } + if err != nil { + return nil, fmt.Errorf("tmsh commands failed over SSH: %w (stderr: %s)", err, strings.TrimSpace(errStr)) + } } - info := parseTmshOutput(stdout.String()) + info := parseTmshOutput(outStr) info.Source = fmt.Sprintf("appliance: %s (SSH)", host) info.PopulateCaseAttributes() return info, nil diff --git a/pkg/bigip/scp.go b/pkg/bigip/scp.go index 8f01bd0..d4ebc3a 100644 --- a/pkg/bigip/scp.go +++ b/pkg/bigip/scp.go @@ -247,6 +247,17 @@ func SCPUpload(ctx context.Context, localPath string, destDir string, opts SCPOp return "", fmt.Errorf("failed to create stdout pipe: %w", err) } + uploadDone := make(chan struct{}) + defer close(uploadDone) + go func() { + select { + case <-uploadDone: + case <-ctx.Done(): + _ = session.Close() + _ = sshClient.Close() + } + }() + scpCmd := fmt.Sprintf("scp -t %s", destDir) if err := session.Start(scpCmd); err != nil { return "", fmt.Errorf("failed to start scp sink on remote appliance: %w", err) @@ -399,10 +410,15 @@ func SCPDownload(ctx context.Context, remotePath, localDest string, opts SCPOpti return "", fmt.Errorf("failed to open session stdout: %w", err) } + downloadDone := make(chan struct{}) + defer close(downloadDone) go func() { - <-ctx.Done() - _ = session.Close() - _ = sshClient.Close() + select { + case <-downloadDone: + case <-ctx.Done(): + _ = session.Close() + _ = sshClient.Close() + } }() cmd := fmt.Sprintf("scp -f %s", cleanRemote) @@ -453,6 +469,13 @@ func SCPDownload(ctx context.Context, remotePath, localDest string, opts SCPOpti if err != nil { return "", fmt.Errorf("failed creating local file %s: %w", partFile, err) } + var transferSuccess bool + defer func() { + _ = outFile.Close() + if !transferSuccess { + _ = os.Remove(partFile) + } + }() buf := make([]byte, 64*1024) var transferred int64 @@ -460,8 +483,6 @@ func SCPDownload(ctx context.Context, remotePath, localDest string, opts SCPOpti for transferred < fileSize { if ctx.Err() != nil { - _ = outFile.Close() - _ = os.Remove(partFile) return "", ctx.Err() } @@ -490,20 +511,16 @@ func SCPDownload(ctx context.Context, remotePath, localDest string, opts SCPOpti } } - _ = outFile.Close() if writeErr != nil { - _ = os.Remove(partFile) return "", writeErr } // Step 4: Verify server EOF acknowledgement byte (\x00) ackByte, err := rd.ReadByte() if err != nil && err != io.EOF { - _ = os.Remove(partFile) return "", fmt.Errorf("failed reading scp completion byte: %w", err) } if ackByte != 0 && err == nil { - _ = os.Remove(partFile) return "", fmt.Errorf("server sent unexpected scp completion status: %d", ackByte) } @@ -514,14 +531,18 @@ func SCPDownload(ctx context.Context, remotePath, localDest string, opts SCPOpti if err := session.Wait(); err != nil { var exitMissing *ssh.ExitMissingError if !errors.As(err, &exitMissing) { - _ = os.Remove(partFile) return "", fmt.Errorf("scp session completed with error: %w", err) } } + if err := outFile.Close(); err != nil { + return "", fmt.Errorf("failed closing downloaded file: %w", err) + } + if err := os.Rename(partFile, cleanLocal); err != nil { return "", fmt.Errorf("failed finalizing downloaded file: %w", err) } + transferSuccess = true return cleanLocal, nil } @@ -705,5 +726,15 @@ func DeleteQKViewSSH(ctx context.Context, opts SCPOptions, filename string) erro remotePath := "/var/tmp/qkviews/" + filename cmd := fmt.Sprintf("rm -f %s", remotePath) - return session.Run(cmd) + runDone := make(chan error, 1) + go func() { + runDone <- session.Run(cmd) + }() + select { + case <-ctx.Done(): + _ = session.Close() + return ctx.Err() + case err := <-runDone: + return err + } } diff --git a/pkg/client/proxy.go b/pkg/client/proxy.go index 711bf01..ba6185c 100644 --- a/pkg/client/proxy.go +++ b/pkg/client/proxy.go @@ -305,6 +305,16 @@ func dialHTTPConnect(ctx context.Context, proxyURL *url.URL, targetAddr string) _ = conn.SetDeadline(deadline) } + handshakeDone := make(chan struct{}) + defer close(handshakeDone) + go func() { + select { + case <-handshakeDone: + case <-ctx.Done(): + _ = conn.Close() + } + }() + // Prepare HTTP CONNECT request connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\nUser-Agent: myf5-cli-tunnel/1.0\r\n", targetAddr, targetAddr) if proxyURL.User != nil { @@ -357,6 +367,16 @@ func dialSOCKS5(ctx context.Context, proxyURL *url.URL, targetAddr string) (net. _ = conn.SetDeadline(deadline) } + handshakeDone := make(chan struct{}) + defer close(handshakeDone) + go func() { + select { + case <-handshakeDone: + case <-ctx.Done(): + _ = conn.Close() + } + }() + // 1. Initial greeting: negotiate authentication method var methods []byte methods = append(methods, 0x00) // NO AUTH diff --git a/pkg/downloads/client.go b/pkg/downloads/client.go index 259c735..00082b4 100644 --- a/pkg/downloads/client.go +++ b/pkg/downloads/client.go @@ -201,7 +201,15 @@ func (c *Client) GetFileLinks(ctx context.Context, family, line, version, contai ) bodyBytes, err := c.doGetWithFallback(ctx, path) if err != nil { - return nil, err + // If cold 403, ESDP API gateway often requires an active product line session + // before issuing signed download links. Warm the session and retry once. + if strings.Contains(err.Error(), "403") { + _, _ = c.GetProductVersions(ctx, family, line) + bodyBytes, err = c.doGetWithFallback(ctx, path) + } + if err != nil { + return nil, err + } } var resp FileLinksResponse @@ -298,7 +306,12 @@ func (c *Client) doLegacyFallback(ctx context.Context, path string) ([]byte, err } if resp.StatusCode == http.StatusTooManyRequests && attempt < 2 { - time.Sleep(time.Duration(attempt+2) * time.Second) + backoff := time.Duration(attempt+2) * time.Second + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(backoff): + } continue } diff --git a/pkg/downloads/downloader.go b/pkg/downloads/downloader.go index 38f5e61..10b400b 100644 --- a/pkg/downloads/downloader.go +++ b/pkg/downloads/downloader.go @@ -11,9 +11,11 @@ import ( "hash" "io" "net/http" + "net/url" "os" "path" "path/filepath" + "strconv" "strings" "time" @@ -48,6 +50,26 @@ func DownloadFile( defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + bodySnippet, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + bodyStr := string(bodySnippet) + if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized { + if u, uErr := url.Parse(downloadURL); uErr == nil { + if expStr := u.Query().Get("Expires"); expStr != "" { + if sec, sErr := strconv.ParseInt(expStr, 10, 64); sErr == nil && sec > 0 { + expTime := time.Unix(sec, 0) + if time.Now().After(expTime) { + return fmt.Errorf("download link expired at %s: AWS CloudFront presigned links are temporary (re-run command to fetch fresh link)", expTime.Local().Format("15:04:05 MST")) + } + } + } + } + if strings.Contains(bodyStr, "Request has expired") { + return errors.New("download link expired: AWS CloudFront presigned URL has expired (re-run command to fetch fresh link)") + } + if strings.Contains(bodyStr, "AccessDenied") { + return errors.New("download access denied: signed CDN link may have expired or is invalid (re-run command to fetch fresh link)") + } + } return fmt.Errorf("server returned HTTP %d for download URL", resp.StatusCode) } diff --git a/pkg/downloads/downloader_test.go b/pkg/downloads/downloader_test.go index 520bfa4..38b6843 100644 --- a/pkg/downloads/downloader_test.go +++ b/pkg/downloads/downloader_test.go @@ -4,12 +4,15 @@ import ( "context" "crypto/sha256" "encoding/hex" + "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" + "strings" "testing" + "time" ) func TestDownloadFileWithChecksum(t *testing.T) { @@ -113,3 +116,69 @@ func TestFormatBytes(t *testing.T) { t.Errorf("FormatBytes(1MB) = %s, want 1.00 MB", FormatBytes(1024*1024)) } } + +func TestDownloadLink_Expiration(t *testing.T) { + // 1. Link with CloudFront 'Expires' parameter in past + pastLink := &DownloadLink{ + Href: "https://downloads.f5.com/esd/file.iso?Expires=1600000000&Signature=abc", + } + if !pastLink.IsExpired() { + t.Errorf("expected past link to be expired") + } + if pastLink.FormattedExpires() != "EXPIRED" { + t.Errorf("expected EXPIRED, got %s", pastLink.FormattedExpires()) + } + + // 2. Link with CloudFront 'Expires' parameter in future + futureTime := time.Now().Add(45 * time.Minute) + futureLink := &DownloadLink{ + Href: fmt.Sprintf("https://downloads.f5.com/esd/file.iso?Expires=%d&Signature=abc", futureTime.Unix()), + } + if futureLink.IsExpired() { + t.Errorf("expected future link not to be expired") + } + if !strings.Contains(futureLink.FormattedExpires(), "in 4") { + t.Errorf("expected formatted expiration to show minutes remaining, got %s", futureLink.FormattedExpires()) + } + + // 3. Link with explicit RFC3339 ExpiresAt field + rfcLink := &DownloadLink{ + ExpiresAt: "2020-01-01T00:00:00Z", + } + if !rfcLink.IsExpired() { + t.Errorf("expected rfcLink to be expired") + } + if rfcLink.FormattedExpires() != "EXPIRED" { + t.Errorf("expected EXPIRED, got %s", rfcLink.FormattedExpires()) + } + + // 4. Link with no expiration + emptyLink := &DownloadLink{ + Href: "https://downloads.f5.com/esd/file.iso", + } + if emptyLink.IsExpired() { + t.Errorf("expected emptyLink not to report expired") + } + if emptyLink.FormattedExpires() != "temporary (time unknown)" { + t.Errorf("expected 'temporary (time unknown)', got %s", emptyLink.FormattedExpires()) + } +} + +func TestDownloadFile_ExpiredLink(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("AccessDeniedRequest has expired")) + })) + defer server.Close() + + tempDir := t.TempDir() + outFile := filepath.Join(tempDir, "expired.iso") + + err := DownloadFile(context.Background(), server.URL, outFile, "", "", nil) + if err == nil { + t.Fatalf("expected error for expired download URL, got nil") + } + if !strings.Contains(err.Error(), "expired") { + t.Errorf("expected error message to mention expired, got: %v", err) + } +} diff --git a/pkg/downloads/models.go b/pkg/downloads/models.go index b7f359d..98d6646 100644 --- a/pkg/downloads/models.go +++ b/pkg/downloads/models.go @@ -2,8 +2,11 @@ package downloads import ( "encoding/json" + "fmt" + "net/url" "strconv" "strings" + "time" ) // HashItem represents a cryptographic hash for a download file. @@ -213,6 +216,74 @@ func (d *DownloadLink) GetRegion() string { return d.Location } +// ExpirationTime extracts the expiration timestamp of the signed mirror URL. +// It checks the explicit ExpiresAt field first (supporting RFC3339 or Unix timestamp formats), +// and falls back to extracting the AWS CloudFront 'Expires' query parameter from Href. +func (d *DownloadLink) ExpirationTime() (time.Time, bool) { + if d == nil { + return time.Time{}, false + } + if d.ExpiresAt != "" { + if t, err := time.Parse(time.RFC3339, d.ExpiresAt); err == nil { + return t, true + } + if t, err := time.Parse("2006-01-02T15:04:05", d.ExpiresAt); err == nil { + return t, true + } + if sec, err := strconv.ParseInt(d.ExpiresAt, 10, 64); err == nil && sec > 0 { + return time.Unix(sec, 0), true + } + } + if d.Href != "" { + if u, err := url.Parse(d.Href); err == nil { + expStr := u.Query().Get("Expires") + if expStr != "" { + if sec, err := strconv.ParseInt(expStr, 10, 64); err == nil && sec > 0 { + return time.Unix(sec, 0), true + } + } + amzDate := u.Query().Get("X-Amz-Date") + amzExp := u.Query().Get("X-Amz-Expires") + if amzDate != "" && amzExp != "" { + if t, err := time.Parse("20060102T150405Z", amzDate); err == nil { + if durSec, err := strconv.Atoi(amzExp); err == nil { + return t.Add(time.Duration(durSec) * time.Second), true + } + } + } + } + } + return time.Time{}, false +} + +// IsExpired reports whether the signed download URL has passed its expiration timestamp. +func (d *DownloadLink) IsExpired() bool { + t, ok := d.ExpirationTime() + if !ok { + return false + } + return time.Now().After(t) +} + +// FormattedExpires returns a human-readable expiration description (e.g. "in 42m (at 14:32:00 EDT)" or "EXPIRED"). +func (d *DownloadLink) FormattedExpires() string { + t, ok := d.ExpirationTime() + if !ok { + if d.ExpiresAt != "" { + return d.ExpiresAt + } + return "temporary (time unknown)" + } + rem := time.Until(t) + if rem <= 0 { + return "EXPIRED" + } + if rem < time.Hour { + return fmt.Sprintf("in %dm (at %s)", int(rem.Minutes()), t.Local().Format("15:04:05 MST")) + } + return fmt.Sprintf("in %dh%dm (at %s)", int(rem.Hours()), int(rem.Minutes())%60, t.Local().Format("15:04:05 MST")) +} + // FileLinksResponse is the API response for /downloads-management/v1/downloads/product/.../{file}. type FileLinksResponse struct { DownloadLinks []DownloadLink `json:"downloadLinks"` diff --git a/pkg/ihealth/client.go b/pkg/ihealth/client.go index e976d90..658c252 100644 --- a/pkg/ihealth/client.go +++ b/pkg/ihealth/client.go @@ -154,6 +154,13 @@ func (c *Client) UploadQKViewWithProgress(ctx context.Context, filePath, caseNum buf := make([]byte, 64*1024) var transferred int64 for { + select { + case <-ctx.Done(): + writeErr = ctx.Err() + return + default: + } + n, rErr := file.Read(buf) if n > 0 { if _, wErr := part.Write(buf[:n]); wErr != nil {