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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <deviceID>:/remote/path/file.txt
```

Copy files from a device:

```shell
qbee-cli copy <deviceID>:/remote/path/file.txt local_file.txt
```

Copy directories recursively to a device:

```shell
qbee-cli copy /local/directory/ <deviceID>:/remote/directory/
```

Copy directories recursively from a device:

```shell
qbee-cli copy <deviceID>:/remote/directory/ /local/directory/
```

Where:
- `deviceID` is the device public key digest
- Source and destination are specified as `[<deviceID>:]<path>`
- 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
Expand Down
86 changes: 86 additions & 0 deletions cmd/copy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// 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.
// 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 [<device ID>:]<sourcePath> [<device ID>:]<destinationPath>",
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"])
},
}
1 change: 1 addition & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ var Main = Command{
},
SubCommands: map[string]Command{
"connect": connectCommand,
"copy": copyCommand,
"version": versionCommand,
"login": loginCommand,
"files": filemanagerCommand,
Expand Down
38 changes: 38 additions & 0 deletions connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
32 changes: 32 additions & 0 deletions inventory.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import (
"fmt"
"net/http"
"net/url"
"strconv"
"strings"

"go.qbee.io/client/config"
"go.qbee.io/client/types"
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading