From 9f5d55a287dbeee0d28d23e9fea737d6ec30e0e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Buli=C5=84ski?= Date: Tue, 5 May 2026 12:27:32 +0200 Subject: [PATCH 1/2] Add direct file transfer support Co-authored-by: Copilot --- README.md | 36 ++++++++++ cmd/copy.go | 86 ++++++++++++++++++++++ cmd/main.go | 1 + connect.go | 38 ++++++++++ go.mod | 8 +-- go.sum | 16 ++--- inventory.go | 32 +++++++++ inventory_test.go | 178 ++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 383 insertions(+), 12 deletions(-) create mode 100644 cmd/copy.go create mode 100644 inventory_test.go diff --git a/README.md b/README.md index e98088c..c43f511 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,42 @@ Where: - `remoteHost` must be set to _localhost_ - `remotePort` is the remote port on which tunnel will connect to on the device +## Copy files directly to or from a device + +Copy files to a device: + +```shell +qbee-cli copy local_file.txt :/remote/path/file.txt +``` + +Copy files from a device: + +```shell +qbee-cli copy :/remote/path/file.txt local_file.txt +``` + +Copy directories recursively to a device: + +```shell +qbee-cli copy /local/directory/ :/remote/directory/ +``` + +Copy directories recursively from a device: + +```shell +qbee-cli copy :/remote/directory/ /local/directory/ +``` + +Where: +- `deviceID` is the device public key digest +- Source and destination are specified as `[:]` +- Exactly one of source or destination must be remote (on a device) +- **Files**: Individual files are copied as-is +- **Directories**: Entire directory trees are copied recursively, preserving the directory structure +- **Requirements**: Device must be running agent version 2026.19 or higher + + + # Use as a Go module ```go diff --git a/cmd/copy.go b/cmd/copy.go new file mode 100644 index 0000000..205aa45 --- /dev/null +++ b/cmd/copy.go @@ -0,0 +1,86 @@ +// Copyright 2024 qbee.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "go.qbee.io/client" +) + +var copyCommand = Command{ + Description: "Copy files to or from a device", + Usage: "copy [:] [:]", + OptionsHandler: func(opts Options) error { + if len(os.Args) != 4 { + return fmt.Errorf("invalid arguments") + } + + source, destination := os.Args[2], os.Args[3] + + sourceDevice, sourcePath, isRemote := strings.Cut(source, ":") + if isRemote { + if !client.IsValidDeviceID(sourceDevice) { + return fmt.Errorf("invalid source device ID %s", sourceDevice) + } + } else { + sourcePath = source + sourceDevice = "" + } + + destinationDevice, destinationPath, isRemote := strings.Cut(destination, ":") + if isRemote { + if sourceDevice != "" { + return fmt.Errorf("both source and destination cannot be remote") + } + + if !client.IsValidDeviceID(destinationDevice) { + return fmt.Errorf("invalid destination device ID %s", destinationDevice) + } + } else { + destinationPath = destination + destinationDevice = "" + } + + if sourceDevice == "" && destinationDevice == "" { + return fmt.Errorf("either source or destination must be remote") + } + + opts["sourceDevice"] = sourceDevice + opts["sourcePath"] = sourcePath + opts["destinationDevice"] = destinationDevice + opts["destinationPath"] = destinationPath + + return nil + }, + Target: func(opts Options) error { + ctx := context.Background() + cli, err := client.LoginGetAuthenticatedClient(ctx) + if err != nil { + return err + } + + if opts["sourceDevice"] != "" { + return cli.DownloadFileFromDevice(ctx, opts["sourceDevice"], opts["sourcePath"], opts["destinationPath"]) + } + + return cli.UploadFileToDevice(ctx, opts["destinationDevice"], opts["sourcePath"], opts["destinationPath"]) + }, +} diff --git a/cmd/main.go b/cmd/main.go index 23b141a..aa701fa 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -52,6 +52,7 @@ var Main = Command{ }, SubCommands: map[string]Command{ "connect": connectCommand, + "copy": copyCommand, "version": versionCommand, "login": loginCommand, "files": filemanagerCommand, diff --git a/connect.go b/connect.go index 9cfa34e..a381cf0 100644 --- a/connect.go +++ b/connect.go @@ -498,3 +498,41 @@ func readerLoop(in io.Reader, out io.Writer, errChan chan error) { } } + +// UploadFileToDevice uploads a file directly to a remote device. +func (cli *Client) UploadFileToDevice(ctx context.Context, deviceID, localPath, remotePath string) error { + deviceInfo, err := cli.GetDeviceInventory(ctx, deviceID) + if err != nil { + return err + } + + if !deviceInfo.IsMinimumAgentVersion("2026.19") { + return fmt.Errorf("file upload requires agent version 2026.19 or higher") + } + + client, err := cli.getConnectClient(ctx, deviceID) + if err != nil { + return err + } + + return client.UploadFile(ctx, localPath, remotePath) +} + +// DownloadFileFromDevice downloads a file directly from a remote device. +func (cli *Client) DownloadFileFromDevice(ctx context.Context, deviceID, remotePath, localPath string) error { + deviceInfo, err := cli.GetDeviceInventory(ctx, deviceID) + if err != nil { + return err + } + + if !deviceInfo.IsMinimumAgentVersion("2026.19") { + return fmt.Errorf("file download requires agent version 2026.19 or higher") + } + + client, err := cli.getConnectClient(ctx, deviceID) + if err != nil { + return err + } + + return client.DownloadFile(ctx, remotePath, localPath) +} diff --git a/go.mod b/go.mod index 1daed03..81bde94 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module go.qbee.io/client go 1.25.9 require ( - github.com/xtaci/smux v1.5.55 - go.qbee.io/transport v1.25.28 - golang.org/x/term v0.39.0 + github.com/xtaci/smux v1.5.57 + go.qbee.io/transport v1.26.18 + golang.org/x/term v0.42.0 ) -require golang.org/x/sys v0.40.0 // indirect +require golang.org/x/sys v0.43.0 // indirect diff --git a/go.sum b/go.sum index cdc566c..37e167c 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ -github.com/xtaci/smux v1.5.55 h1:BdOj0tHZmiZOeZ8VQaOKpBcuL2MIMed5Ubhn5G3xDlo= -github.com/xtaci/smux v1.5.55/go.mod h1:IGQ9QYrBphmb/4aTnLEcJby0TNr3NV+OslIOMrX825Q= -go.qbee.io/transport v1.25.28 h1:S6X91ORNaRrPsNn3x80gQWAnX4jIyVXG76R8u6dLkKw= -go.qbee.io/transport v1.25.28/go.mod h1:SBtAKI5/BjXrXYGRDT04gSjyZxQiuvRApSDeFWeelh4= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +github.com/xtaci/smux v1.5.57 h1:N72VbGoSYxgcm6mPOYX0QzEZNVD3UI/JlVvAtXF+WrY= +github.com/xtaci/smux v1.5.57/go.mod h1:IGQ9QYrBphmb/4aTnLEcJby0TNr3NV+OslIOMrX825Q= +go.qbee.io/transport v1.26.18 h1:ARoJhCBRjdQM9WTW2T6ia/pmmBfhCt0nUkSZlpvCykg= +go.qbee.io/transport v1.26.18/go.mod h1:SBtAKI5/BjXrXYGRDT04gSjyZxQiuvRApSDeFWeelh4= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= diff --git a/inventory.go b/inventory.go index a3d93e8..5a9a2b7 100644 --- a/inventory.go +++ b/inventory.go @@ -22,6 +22,8 @@ import ( "fmt" "net/http" "net/url" + "strconv" + "strings" "go.qbee.io/client/config" "go.qbee.io/client/types" @@ -208,6 +210,36 @@ type DeviceInventory struct { PushedConfig config.Pushed `json:"pushed_config"` } +// IsMinimumAgentVersion checks if the device is running at least the specified agent version. +func (di DeviceInventory) IsMinimumAgentVersion(requiredVersion string) bool { + requiredVersionParts := strings.Split(requiredVersion, ".") + agentVersionParts := strings.Split(di.SystemInfo.AgentVersion, ".") + + for i := range requiredVersionParts { + if i >= len(agentVersionParts) { + return false + } + + if requiredVersionParts[i] == agentVersionParts[i] { + continue + } + + requiredVersionPartInt, err := strconv.Atoi(requiredVersionParts[i]) + if err != nil { + return false + } + + agentVersionPartInt, err := strconv.Atoi(agentVersionParts[i]) + if err != nil { + return false + } + + return agentVersionPartInt > requiredVersionPartInt + } + + return true +} + // InventoryListSearch defines search parameters for InventoryListQuery. type InventoryListSearch struct { // NodeID - device public key digest (hex-encoded SHA256) diff --git a/inventory_test.go b/inventory_test.go new file mode 100644 index 0000000..1e82370 --- /dev/null +++ b/inventory_test.go @@ -0,0 +1,178 @@ +// Copyright 2023 qbee.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package client_test + +import ( + "testing" + + "go.qbee.io/client" +) + +func TestDeviceInventory_IsMinimumAgentVersion(t *testing.T) { + tests := []struct { + name string + agentVersion string + requiredVersion string + expected bool + }{ + // Agent version is greater + { + name: "agent version major higher", + agentVersion: "2.0.0", + requiredVersion: "1.0.0", + expected: true, + }, + { + name: "agent version minor higher", + agentVersion: "1.5.0", + requiredVersion: "1.4.0", + expected: true, + }, + { + name: "agent version patch higher", + agentVersion: "1.0.5", + requiredVersion: "1.0.3", + expected: true, + }, + + // Agent version is lower + { + name: "agent version major lower", + agentVersion: "0.9.9", + requiredVersion: "1.0.0", + expected: false, + }, + { + name: "agent version minor lower", + agentVersion: "1.3.9", + requiredVersion: "1.4.0", + expected: false, + }, + { + name: "agent version patch lower", + agentVersion: "1.0.2", + requiredVersion: "1.0.3", + expected: false, + }, + + // Exact match + { + name: "versions are equal", + agentVersion: "1.2.3", + requiredVersion: "1.2.3", + expected: true, + }, + { + name: "single part versions equal", + agentVersion: "5", + requiredVersion: "5", + expected: true, + }, + + // Agent has fewer parts + { + name: "agent has fewer parts, required higher", + agentVersion: "1.0", + requiredVersion: "1.0.5", + expected: false, + }, + { + name: "agent has single part, required multi-part", + agentVersion: "1", + requiredVersion: "1.0.1", + expected: false, + }, + + // Agent has more parts (should still work) + { + name: "agent has more parts", + agentVersion: "1.0.0.5", + requiredVersion: "1.0.0", + expected: true, + }, + + // Multiple part comparison + { + name: "multi-part versions, agent higher", + agentVersion: "1.2.3.4", + requiredVersion: "1.2.3.3", + expected: true, + }, + { + name: "multi-part versions, agent lower", + agentVersion: "1.2.3.2", + requiredVersion: "1.2.3.3", + expected: false, + }, + + // Invalid/non-numeric versions + { + name: "invalid agent version", + agentVersion: "1.a.3", + requiredVersion: "1.0.0", + expected: false, + }, + { + name: "invalid required version", + agentVersion: "1.0.0", + requiredVersion: "1.b.0", + expected: false, + }, + { + name: "both invalid", + agentVersion: "a.b.c", + requiredVersion: "x.y.z", + expected: false, + }, + + // Edge cases + { + name: "empty agent version", + agentVersion: "", + requiredVersion: "1.0.0", + expected: false, + }, + { + name: "empty required version", + agentVersion: "1.0.0", + requiredVersion: "", + expected: false, + }, + { + name: "both empty", + agentVersion: "", + requiredVersion: "", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + di := client.DeviceInventory{ + SystemInfo: client.SystemInfo{ + AgentVersion: tt.agentVersion, + }, + } + + result := di.IsMinimumAgentVersion(tt.requiredVersion) + if result != tt.expected { + t.Errorf("IsMinimumAgentVersion(%q) with agent version %q = %v, want %v", + tt.requiredVersion, tt.agentVersion, result, tt.expected) + } + }) + } +} From c39090a0f7da7de389024bc29b366ef301774de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Buli=C5=84ski?= Date: Tue, 5 May 2026 12:29:33 +0200 Subject: [PATCH 2/2] Fix copyright --- cmd/copy.go | 2 +- inventory_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/copy.go b/cmd/copy.go index 205aa45..2c03275 100644 --- a/cmd/copy.go +++ b/cmd/copy.go @@ -1,4 +1,4 @@ -// Copyright 2024 qbee.io +// Copyright 2026 qbee.io // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/inventory_test.go b/inventory_test.go index 1e82370..b68ebef 100644 --- a/inventory_test.go +++ b/inventory_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 qbee.io +// Copyright 2026 qbee.io // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License.