Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 90 additions & 23 deletions cmd/myf5/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -625,14 +625,16 @@ func handleDownloads(ctx context.Context, args []string) {
fmt.Fprintf(os.Stderr, "[ERROR] %v\n", err)
exitFunc(1)
}
token, err := auth.Authenticate(ctx, "", "", creds.ClientID, creds.ClientSecret, "myf5_scope")
defer creds.Zero()
token, err := auth.AuthenticateWithSecretBytes(ctx, nil, "", "", creds.ClientID, creds.SecretBytes, "myf5_scope")
checkErr(ctx, err, "Authentication failed")
return downloads.NewClientWithKey(token, "", creds.APIKey)
}

switch action {
case "families", "products", "list-products", "list-families":
client := getDownloadsClient()
defer client.Zero()
meta, err := client.GetMetadata(ctx)
checkErr(ctx, err, "Failed to load product families")
fmt.Println("Available Product Families:")
Expand All @@ -651,6 +653,7 @@ func handleDownloads(ctx context.Context, args []string) {
}
targetFamily := remaining[0]
client := getDownloadsClient()
defer client.Zero()
meta, err := client.GetMetadata(ctx)
checkErr(ctx, err, "Failed to load product lines")
for _, fam := range meta.Data.ProductFamilies {
Expand All @@ -675,6 +678,7 @@ func handleDownloads(ctx context.Context, args []string) {
exitFunc(1)
}
client := getDownloadsClient()
defer client.Zero()
resp, err := client.GetProductVersions(ctx, remaining[0], remaining[1])
checkErr(ctx, err, "Failed to load versions")
versions := resp.GetVersions()
Expand All @@ -692,6 +696,7 @@ func handleDownloads(ctx context.Context, args []string) {
exitFunc(1)
}
client := getDownloadsClient()
defer client.Zero()
resp, err := client.GetProductVersions(ctx, remaining[0], remaining[1])
checkErr(ctx, err, "Failed to load containers")
targetVer := ""
Expand Down Expand Up @@ -723,6 +728,7 @@ func handleDownloads(ctx context.Context, args []string) {
exitFunc(1)
}
client := getDownloadsClient()
defer client.Zero()
resp, err := client.GetProductVersions(ctx, remaining[0], remaining[1])
checkErr(ctx, err, "Failed to load files")
targetVer := ""
Expand Down Expand Up @@ -776,6 +782,7 @@ func handleDownloads(ctx context.Context, args []string) {
}

client := getDownloadsClient()
defer client.Zero()
fmt.Printf("Fetching download links for %s...\n", fileName)
linksResp, err := client.GetFileLinks(ctx, family, line, ver, cont, fileName, "english")
checkErr(ctx, err, "Failed to retrieve download link")
Expand Down Expand Up @@ -824,7 +831,9 @@ func handleDownloads(ctx context.Context, args []string) {
}

case "browse":
runInteractiveBrowser(ctx, getDownloadsClient())
client := getDownloadsClient()
defer client.Zero()
runInteractiveBrowser(ctx, client)

default:
printDownloadsUsage()
Expand Down Expand Up @@ -1348,21 +1357,26 @@ func handleCases(ctx context.Context, args []string) {
if action == "wizard" || action == "new" || (action == "create" && len(args) == 1) {
creds, err := auth.ResolveCredentials("default")
if err == nil {
token, aErr := auth.Authenticate(ctx, "", "", creds.ClientID, creds.ClientSecret, "myf5_scope")
token, aErr := auth.AuthenticateWithSecretBytes(ctx, nil, "", "", creds.ClientID, creds.SecretBytes, "myf5_scope")
if aErr == nil {
client = cases.NewClientWithKey(token, "", creds.APIKey)
}
creds.Zero()
}
} else {
creds, err := auth.ResolveCredentials("default")
if err != nil {
fmt.Fprintf(os.Stderr, "[ERROR] %v\n", err)
exitFunc(1)
}
token, err := auth.Authenticate(ctx, "", "", creds.ClientID, creds.ClientSecret, "myf5_scope")
defer creds.Zero()
token, err := auth.AuthenticateWithSecretBytes(ctx, nil, "", "", creds.ClientID, creds.SecretBytes, "myf5_scope")
checkErr(ctx, err, "Authentication failed")
client = cases.NewClientWithKey(token, "", creds.APIKey)
}
if client != nil {
defer client.Zero()
}

switch action {
case "wizard", "new":
Expand Down Expand Up @@ -1508,8 +1522,9 @@ func printCaseDetail(d *cases.CaseDetail) {
if n := d.AlternateContact.DisplayName(); n != "" {
fmt.Printf(" Alternate : %s <%s>\n", n, d.AlternateContact.Email)
}
// dateOpened is deliberately not printed: the detail endpoint returns the
// priority string in that field. See CaseDetail.
if d.DateOpened != "" {
fmt.Printf(" Opened : %s\n", d.DateOpened)
}
for _, l := range d.Links {
if l.Rel == "case-detail" {
fmt.Printf(" Web : %s\n", l.Href)
Expand Down Expand Up @@ -1737,9 +1752,11 @@ func handleIHealth(ctx context.Context, args []string) {
fmt.Fprintf(os.Stderr, "[ERROR] %v\n", err)
exitFunc(1)
}
token, err := auth.Authenticate(ctx, "", "", creds.ClientID, creds.ClientSecret, "ihealth")
defer creds.Zero()
token, err := auth.AuthenticateWithSecretBytes(ctx, nil, "", "", creds.ClientID, creds.SecretBytes, "ihealth")
checkErr(ctx, err, "Authentication failed")
client := ihealth.NewClient(token, "")
defer client.Zero()

switch action {
case "list":
Expand Down Expand Up @@ -2240,16 +2257,22 @@ func handleAppliance(ctx context.Context, targetKind string, args []string) {
exitFunc(1)
}

var pwBytes []byte
if pw != "" {
pwBytes = []byte(pw)
}
client, err := bigip.NewClientWithOptions(bigip.ClientOptions{
Host: host,
Port: port,
Username: user,
Password: pw,
PasswordBytes: pwBytes,
SkipTLSVerify: true,
ClientCertPath: certPath,
ClientKeyPath: certKeyPath,
})
checkErr(ctx, err, "Failed to initialize appliance client")
defer client.Zero()
ready, err := client.CheckReady(ctx)
checkErr(ctx, err, "Ready check failed")
if ready {
Expand Down Expand Up @@ -2367,16 +2390,22 @@ func handleAppliance(ctx context.Context, targetKind string, args []string) {
spinner.Start()
}

var pwBytes []byte
if pw != "" {
pwBytes = []byte(pw)
}
geoOpts := bigip.GeoIPOptions{
Port: port,
Username: user,
Password: pw,
PasswordBytes: pwBytes,
IdentityFile: keyPath,
ClientCertPath: certPath,
ClientKeyPath: certKeyPath,
Method: bigip.TransferMethod(method),
KeepExtracted: keepExtracted,
}
defer geoOpts.Zero()

statusCb := func(status string) {
if spinner != nil {
Expand Down Expand Up @@ -2477,17 +2506,23 @@ func handleAppliance(ctx context.Context, targetKind string, args []string) {
transferMethod = bigip.MethodAuto
}

var pwBytes []byte
if pw != "" {
pwBytes = []byte(pw)
}
qkOpts := bigip.TransferOptions{
TargetType: targetKind,
Method: transferMethod,
Username: user,
Password: pw,
PasswordBytes: pwBytes,
Port: port,
IdentityFile: keyPath,
ClientCertPath: certPath,
ClientKeyPath: certKeyPath,
Timeout: 30 * time.Second,
}
defer qkOpts.Zero()

spinner := progress.NewSpinner(fmt.Sprintf("Generating diagnostic QKView on %s", host))
spinner.Start()
Expand Down Expand Up @@ -2636,21 +2671,28 @@ func getSystemInfoTarget(ctx context.Context, target, targetKind, user string, p
}
}

info, err := resolveApplianceSystemInfo(ctx, host, port, user, pw, keyPath, method, targetKind, certPath, certKeyPath)
var pwBytes []byte
if pw != "" {
pwBytes = []byte(pw)
}

info, err := resolveApplianceSystemInfo(ctx, host, port, user, pw, pwBytes, keyPath, method, targetKind, certPath, certKeyPath)
checkErr(ctx, err, fmt.Sprintf("Failed to retrieve system info from %s", host))
return info
}

func resolveApplianceSystemInfo(ctx context.Context, host string, port int, user, pw, keyPath, method, targetKind, certPath, certKeyPath string) (*bigip.SystemInfo, error) {
func resolveApplianceSystemInfo(ctx context.Context, host string, port int, user, pw string, pwBytes []byte, keyPath, method, targetKind, certPath, certKeyPath string) (*bigip.SystemInfo, error) {
if method == "scp" || method == "ssh" {
opts := bigip.SCPOptions{
Host: host,
Port: port,
Username: user,
Password: pw,
KeyPath: keyPath,
Timeout: 30 * time.Second,
}
Host: host,
Port: port,
Username: user,
Password: pw,
PasswordBytes: pwBytes,
KeyPath: keyPath,
Timeout: 30 * time.Second,
}
defer opts.Zero()
return bigip.GetSystemInfoSSH(ctx, opts)
}

Expand All @@ -2659,11 +2701,13 @@ func resolveApplianceSystemInfo(ctx context.Context, host string, port int, user
Port: port,
Username: user,
Password: pw,
PasswordBytes: pwBytes,
SkipTLSVerify: true,
ClientCertPath: certPath,
ClientKeyPath: certKeyPath,
})
if err == nil {
defer client.Zero()
var info *bigip.SystemInfo
var restErr error
info, restErr = client.GetSystemInfo(ctx)
Expand All @@ -2684,13 +2728,15 @@ func resolveApplianceSystemInfo(ctx context.Context, host string, port int, user
sshPort = port
}
opts := bigip.SCPOptions{
Host: host,
Port: sshPort,
Username: user,
Password: pw,
KeyPath: keyPath,
Timeout: 30 * time.Second,
}
Host: host,
Port: sshPort,
Username: user,
Password: pw,
PasswordBytes: pwBytes,
KeyPath: keyPath,
Timeout: 30 * time.Second,
}
defer opts.Zero()
sshInfo, sshErr := bigip.GetSystemInfoSSH(ctx, opts)
if sshErr == nil {
return sshInfo, nil
Expand Down Expand Up @@ -2807,18 +2853,25 @@ func executePushToAppliance(
}
}

var pwBytes []byte
if pw != "" {
pwBytes = []byte(pw)
}

opts := bigip.TransferOptions{
TargetType: targetKind,
Method: bigip.TransferMethod(method),
Username: user,
Password: pw,
PasswordBytes: pwBytes,
Port: port,
IdentityFile: keyPath,
ClientCertPath: certPath,
ClientKeyPath: certKeyPath,
DestDir: destPath,
Timeout: 30 * time.Second,
}
defer opts.Zero()

var bar *progress.Bar
var renderProgress func(transferred, total int64)
Expand Down Expand Up @@ -2900,16 +2953,22 @@ func executePushToAppliance(
spinner.Start()
}

var geoPwBytes []byte
if pw != "" {
geoPwBytes = []byte(pw)
}
geoOpts := bigip.GeoIPOptions{
Port: port,
Username: user,
Password: pw,
PasswordBytes: geoPwBytes,
IdentityFile: keyPath,
ClientCertPath: certPath,
ClientKeyPath: certKeyPath,
Method: bigip.TransferMethod(method),
KeepExtracted: keepExtracted,
}
defer geoOpts.Zero()

statusCb := func(status string) {
if spinner != nil {
Expand Down Expand Up @@ -3047,17 +3106,24 @@ func executeDownloadFromAppliance(
}
}

var pwBytes []byte
if pw != "" {
pwBytes = []byte(pw)
}

opts := bigip.TransferOptions{
TargetType: targetKind,
Method: bigip.TransferMethod(method),
Username: user,
Password: pw,
PasswordBytes: pwBytes,
Port: port,
IdentityFile: keyPath,
ClientCertPath: certPath,
ClientKeyPath: certKeyPath,
Timeout: 30 * time.Second,
}
defer opts.Zero()

fmt.Printf("Downloading %s from %s (%s) via %s...\n", remotePath, targetKind, host, strings.ToUpper(method))

Expand Down Expand Up @@ -3129,6 +3195,7 @@ func handleDoctor(ctx context.Context, args []string) {
fmt.Printf(" [FAIL] Credentials: %v\n", err)
hasError = true
} else {
defer creds.Zero()
fmt.Printf(" [PASS] Found Client ID: %s\n", creds.ClientID)
fmt.Println(" [PASS] Found Client Secret: [REDACTED] (Zero CLI Exposure)")
}
Expand Down
25 changes: 6 additions & 19 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,23 +144,14 @@ myf5 cases list --all
```

#### `metadata`
Retrieves the authoritative schema metadata and valid values for case creation.
Retrieves the authoritative schema metadata and valid taxonomy values for case creation from the live MyF5 API.
```bash
myf5 cases metadata
```

> **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
> valid `productFamily`, `priority`, `timezone` and `reasonForContact` values,
> so those values currently cannot be enumerated from the API.
>
> Until it recovers, copy the values from an existing case with
> `myf5 cases get <case-number>`, 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.
Returns the full taxonomy schema containing 10 categories: `prodFamilies` (with nested `products` and supported `versions`), `priorities`, `reasonsForContact`, `preferredContactMethods`, `timeZones`, `accounts`, `cloudProviders`, `linuxDistributions`, `nginxPlusVersions`, and `status`.

> **Status:** Operational. A server-side 500 deserialization outage observed from 2026-10-05 was resolved by F5 Engineering on 2026-10-08.

#### `comment <case-number> [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.
Expand All @@ -185,16 +176,12 @@ myf5 wizard
```

#### `get <case-number>`
Shows one case in full: status, priority, product, account, contacts, the
description, and a list of the messages on the email thread.
Shows one case in full: status, priority, product, version, opened timestamp (`dateOpened`),
account, contacts, the description, and a list of the messages on the email thread.
```bash
myf5 cases get 01287164
```

> `dateOpened` is deliberately not shown. The detail endpoint returns the
> priority string in that field (a server-side defect); `cases list` reports
> the real timestamp.

#### `create [flags]`
Opens a support case. Run with no flags for the full list.
```bash
Expand Down
Loading
Loading