Skip to content
Closed
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
587 changes: 311 additions & 276 deletions cmd/myf5/main.go

Large diffs are not rendered by default.

52 changes: 45 additions & 7 deletions pkg/bigip/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,23 +336,23 @@ func (c *Client) BaseURL() string {
return fmt.Sprintf("%s://%s:%d", scheme, host, c.Port)
}

// CheckReady queries /mgmt/tm/sys/ready (or /mgmt/shared/echo for BIG-IQ) to verify appliance availability.
func (c *Client) CheckReady(ctx context.Context) (bool, error) {
// CheckReadyWithKind queries /mgmt/tm/sys/ready (or /mgmt/shared/echo for BIG-IQ) to verify appliance availability and returns the appliance kind.
func (c *Client) CheckReadyWithKind(ctx context.Context) (string, error) {
url := fmt.Sprintf("%s/mgmt/tm/sys/ready", c.BaseURL())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false, err
return "", err
}
c.setAuth(req)

resp, err := c.HTTPClient.Do(req)
if err != nil {
return false, fmt.Errorf("device reachability check failed: %w", err)
return "", fmt.Errorf("device reachability check failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusOK {
return true, nil
return "BIG-IP", nil
}

// Fallback to /mgmt/shared/echo for BIG-IQ and appliances without sys ready
Expand All @@ -363,12 +363,23 @@ func (c *Client) CheckReady(ctx context.Context) (bool, error) {
c.setAuth(echoReq)
if echoResp, doErr := c.HTTPClient.Do(echoReq); doErr == nil {
defer echoResp.Body.Close()
return echoResp.StatusCode == http.StatusOK, nil
if echoResp.StatusCode == http.StatusOK {
return "BIG-IQ", nil
}
}
}
}

return false, nil
return "", nil
}

// CheckReady queries /mgmt/tm/sys/ready (or /mgmt/shared/echo for BIG-IQ) to verify appliance availability.
func (c *Client) CheckReady(ctx context.Context) (bool, error) {
kind, err := c.CheckReadyWithKind(ctx)
if err != nil {
return false, err
}
return kind != "", nil
}

var validQKViewFilenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-\.]+$`)
Expand Down Expand Up @@ -609,6 +620,33 @@ func (c *Client) GenerateQKViewWithStatus(ctx context.Context, filename string,
}
defer res.resp.Body.Close()

if res.resp.StatusCode == http.StatusNotFound {
// Fallback to bash for BIG-IQ where /mgmt/tm/util/qkview may return 404
bashArgs := fmt.Sprintf("-c \"mkdir -p /var/tmp/qkviews && /usr/bin/qkview %s-f /var/tmp/qkviews/%s && cp /var/tmp/qkviews/%s /var/config/rest/downloads/\"", map[bool]string{true: "-s0 ", false: ""}[complete], filename, filename)
bashPayload := map[string]string{
"command": "run",
"utilCmdArgs": bashArgs,
}
bashBytes, _ := json.Marshal(bashPayload)
bashURL := fmt.Sprintf("%s/mgmt/tm/util/bash", c.BaseURL())
bashReq, err := http.NewRequestWithContext(ctx, http.MethodPost, bashURL, bytes.NewReader(bashBytes))
if err != nil {
return "", err
}
c.setAuth(bashReq)
bashReq.Header.Set("Content-Type", "application/json")
bashResp, err := c.HTTPClient.Do(bashReq)
if err != nil {
return "", fmt.Errorf("bash fallback for qkview failed: %w", err)
}
defer bashResp.Body.Close()
if bashResp.StatusCode < 200 || bashResp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(bashResp.Body, 1024*1024))
return "", fmt.Errorf("bash qkview generation failed with HTTP %d: %s", bashResp.StatusCode, string(b))
}
return filename, nil
}

if res.resp.StatusCode < 200 || res.resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(res.resp.Body, 1024*1024))
return "", fmt.Errorf("qkview generation failed with HTTP %d: %s", res.resp.StatusCode, string(b))
Expand Down
88 changes: 84 additions & 4 deletions pkg/bigip/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ import (

func TestCheckReady(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/mgmt/tm/sys/ready" {
t.Errorf("unexpected path: %s", r.URL.Path)
if r.URL.Path == "/mgmt/tm/sys/ready" {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"kind": "tm:sys:ready"}`))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"kind": "tm:sys:ready"}`))
t.Errorf("unexpected path: %s", r.URL.Path)
}))
defer server.Close()

Expand All @@ -38,6 +39,44 @@ func TestCheckReady(t *testing.T) {
if !ready {
t.Errorf("expected appliance to be ready")
}

kind, err := c.CheckReadyWithKind(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != "BIG-IP" {
t.Errorf("expected BIG-IP, got %q", kind)
}
}

func TestCheckReadyWithKind_BIGIQ(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/mgmt/tm/sys/ready" {
w.WriteHeader(http.StatusNotFound)
return
}
if r.URL.Path == "/mgmt/shared/echo" {
w.WriteHeader(http.StatusOK)
return
}
t.Errorf("unexpected path: %s", r.URL.Path)
}))
defer server.Close()

u, _ := url.Parse(server.URL)
port, _ := strconv.Atoi(u.Port())

c := NewClient(u.Hostname(), port, "admin", "secret")
c.Scheme = "http"
c.HTTPClient = server.Client()

kind, err := c.CheckReadyWithKind(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != "BIG-IQ" {
t.Errorf("expected BIG-IQ, got %q", kind)
}
}

func TestBaseURLAndURLNormalization(t *testing.T) {
Expand Down Expand Up @@ -195,6 +234,47 @@ func TestGenerateQKViewDefaultFilenameISO8601(t *testing.T) {
}
}

func TestGenerateQKViewBIGIQFallback(t *testing.T) {
bashCalled := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/mgmt/tm/util/qkview" {
w.WriteHeader(http.StatusNotFound)
return
}
if r.URL.Path == "/mgmt/tm/util/bash" {
bashCalled = true
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"commandResult":"success"}`))
return
}
if r.URL.Path == "/mgmt/cm/autodeploy/qkview" {
w.WriteHeader(http.StatusNotFound)
return
}
}))
defer server.Close()

u, _ := url.Parse(server.URL)
port, _ := strconv.Atoi(u.Port())

c := NewClient(u.Hostname(), port, "admin", "secret")
c.Scheme = "http"
c.HTTPClient = server.Client()

filename, err := c.GenerateQKViewWithStatus(context.Background(), "test.qkview", true, nil)
if err != nil {
t.Fatalf("GenerateQKViewWithStatus failed: %v", err)
}

if filename != "test.qkview" {
t.Errorf("expected test.qkview, got %q", filename)
}
if !bashCalled {
t.Errorf("expected bash fallback to be called for BIG-IQ")
}
}

func TestValidateHost(t *testing.T) {
tests := []struct {
name string
Expand Down
115 changes: 39 additions & 76 deletions pkg/bigip/failover.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,16 +183,10 @@ func DownloadFileWithFailover(
return savedPath, MethodSCP, nil

case MethodAuto:
// Try iControl REST first if password is provided or file looks like an autodeploy resource (/shared/images or /var/tmp/qkviews)
cleanRemote := path.Clean(remotePath)
isRestFriendly := strings.HasPrefix(cleanRemote, "/shared/images") ||
strings.HasPrefix(cleanRemote, "/var/tmp/qkviews") ||
strings.HasPrefix(cleanRemote, "/mgmt/") ||
strings.HasSuffix(cleanRemote, ".iso") ||
strings.HasSuffix(cleanRemote, ".qkview")

// If an explicit SSH identity key (-i) was provided, prefer SCP first.
// Otherwise, try iControl REST first when REST credentials (password/cert) are available.
hasRestAuth := opts.Password != "" || len(opts.PasswordBytes) > 0 || opts.ClientCertPath != "" || os.Getenv("BIGIP_PASSWORD") != "" || os.Getenv("BIGIQ_PASSWORD") != ""
tryRestFirst := hasRestAuth && (isRestFriendly || opts.IdentityFile == "")
tryRestFirst := opts.IdentityFile == "" && hasRestAuth

if tryRestFirst {
savedPath, restErr := downloadViaIControl(ctx, host, remotePath, localDest, opts, progressCb)
Expand Down Expand Up @@ -249,12 +243,17 @@ func newClientFromOptions(host string, opts TransferOptions, defaultPort int) *C
if user == "" {
user = "admin"
}
var pwBytes []byte
if len(opts.PasswordBytes) > 0 {
pwBytes = make([]byte, len(opts.PasswordBytes))
copy(pwBytes, opts.PasswordBytes)
}
c, err := NewClientWithOptions(ClientOptions{
Host: host,
Port: port,
Username: user,
Password: opts.Password,
PasswordBytes: opts.PasswordBytes,
PasswordBytes: pwBytes,
SkipTLSVerify: true,
ClientCertPath: opts.ClientCertPath,
ClientKeyPath: opts.ClientKeyPath,
Expand Down Expand Up @@ -296,23 +295,37 @@ func pushViaIControl(
return remoteUploaded, nil
}

func pushViaSCP(
ctx context.Context,
host, localFile, destDir string,
opts TransferOptions,
progressCb func(transferred, total int64),
) (string, error) {
scpOpts := SCPOptions{
func newSCPOptions(host string, opts TransferOptions) SCPOptions {
var pwBytes []byte
if len(opts.PasswordBytes) > 0 {
pwBytes = make([]byte, len(opts.PasswordBytes))
copy(pwBytes, opts.PasswordBytes)
}
var passBytes []byte
if len(opts.SSHPassphraseBytes) > 0 {
passBytes = make([]byte, len(opts.SSHPassphraseBytes))
copy(passBytes, opts.SSHPassphraseBytes)
}
return SCPOptions{
Host: host,
Port: opts.Port,
Username: opts.Username,
Password: opts.Password,
PasswordBytes: opts.PasswordBytes,
PasswordBytes: pwBytes,
KeyPath: opts.IdentityFile,
Passphrase: opts.SSHPassphrase,
PassphraseBytes: opts.SSHPassphraseBytes,
PassphraseBytes: passBytes,
Timeout: opts.Timeout,
}
}

func pushViaSCP(
ctx context.Context,
host, localFile, destDir string,
opts TransferOptions,
progressCb func(transferred, total int64),
) (string, error) {
scpOpts := newSCPOptions(host, opts)
defer scpOpts.Zero()
return SCPUpload(ctx, localFile, destDir, scpOpts, progressCb)
}
Expand Down Expand Up @@ -348,17 +361,7 @@ func downloadViaSCP(
opts TransferOptions,
progressCb func(transferred, total int64),
) (string, error) {
scpOpts := SCPOptions{
Host: host,
Port: opts.Port,
Username: opts.Username,
Password: opts.Password,
PasswordBytes: opts.PasswordBytes,
KeyPath: opts.IdentityFile,
Passphrase: opts.SSHPassphrase,
PassphraseBytes: opts.SSHPassphraseBytes,
Timeout: opts.Timeout,
}
scpOpts := newSCPOptions(host, opts)
defer scpOpts.Zero()
return SCPDownload(ctx, remotePath, localDest, scpOpts, progressCb)
}
Expand Down Expand Up @@ -390,17 +393,7 @@ func GenerateQKViewWithFailover(
return filename, MethodIControl, nil

case MethodSCP:
scpOpts := SCPOptions{
Host: host,
Port: opts.Port,
Username: opts.Username,
Password: opts.Password,
PasswordBytes: opts.PasswordBytes,
KeyPath: opts.IdentityFile,
Passphrase: opts.SSHPassphrase,
PassphraseBytes: opts.SSHPassphraseBytes,
Timeout: opts.Timeout,
}
scpOpts := newSCPOptions(host, opts)
defer scpOpts.Zero()
filename, err := GenerateQKViewSSH(ctx, scpOpts, requestedFilename, complete, statusCb)
if err != nil {
Expand All @@ -410,7 +403,7 @@ func GenerateQKViewWithFailover(

case MethodAuto:
// If password or client certificate is provided and no key, try iControl REST first
tryRestFirst := opts.Password != "" || len(opts.PasswordBytes) > 0 || opts.ClientCertPath != "" || (opts.IdentityFile == "" && os.Getenv("BIGIP_PASSWORD") != "")
tryRestFirst := opts.IdentityFile == "" && (opts.Password != "" || len(opts.PasswordBytes) > 0 || opts.ClientCertPath != "" || os.Getenv("BIGIP_PASSWORD") != "")

if tryRestFirst {
client := newClientFromOptions(host, opts, 443)
Expand All @@ -427,17 +420,7 @@ func GenerateQKViewWithFailover(
logFn("iControl REST QKView generation failed (%v). Automatically falling back to SSH...", restErr)
}

scpOpts := SCPOptions{
Host: host,
Port: opts.Port,
Username: opts.Username,
Password: opts.Password,
PasswordBytes: opts.PasswordBytes,
KeyPath: opts.IdentityFile,
Passphrase: opts.SSHPassphrase,
PassphraseBytes: opts.SSHPassphraseBytes,
Timeout: opts.Timeout,
}
scpOpts := newSCPOptions(host, opts)
defer scpOpts.Zero()
filename, sshErr := GenerateQKViewSSH(ctx, scpOpts, requestedFilename, complete, statusCb)
if sshErr == nil {
Expand All @@ -448,17 +431,7 @@ func GenerateQKViewWithFailover(
}

// Try SSH first
scpOpts := SCPOptions{
Host: host,
Port: opts.Port,
Username: opts.Username,
Password: opts.Password,
PasswordBytes: opts.PasswordBytes,
KeyPath: opts.IdentityFile,
Passphrase: opts.SSHPassphrase,
PassphraseBytes: opts.SSHPassphraseBytes,
Timeout: opts.Timeout,
}
scpOpts := newSCPOptions(host, opts)
filename, sshErr := GenerateQKViewSSH(ctx, scpOpts, requestedFilename, complete, statusCb)
scpOpts.Zero()
if sshErr == nil {
Expand Down Expand Up @@ -489,7 +462,7 @@ func GenerateQKViewWithFailover(
// DeleteQKViewWithFailover deletes a generated QKView archive on the appliance via REST or SSH.
func DeleteQKViewWithFailover(ctx context.Context, host, remoteFilename string, opts TransferOptions) error {
method := opts.Method
if method == MethodIControl || (method == MethodAuto && (opts.Password != "" || len(opts.PasswordBytes) > 0 || opts.ClientCertPath != "")) {
if method == MethodIControl || (method == MethodAuto && opts.IdentityFile == "" && (opts.Password != "" || len(opts.PasswordBytes) > 0 || opts.ClientCertPath != "")) {
client := newClientFromOptions(host, opts, 443)
defer client.Zero()
if err := client.DeleteQKView(ctx, remoteFilename); err == nil {
Expand All @@ -499,17 +472,7 @@ func DeleteQKViewWithFailover(ctx context.Context, host, remoteFilename string,
}
}

scpOpts := SCPOptions{
Host: host,
Port: opts.Port,
Username: opts.Username,
Password: opts.Password,
PasswordBytes: opts.PasswordBytes,
KeyPath: opts.IdentityFile,
Passphrase: opts.SSHPassphrase,
PassphraseBytes: opts.SSHPassphraseBytes,
Timeout: opts.Timeout,
}
scpOpts := newSCPOptions(host, opts)
defer scpOpts.Zero()
return DeleteQKViewSSH(ctx, scpOpts, remoteFilename)
}
Loading
Loading