Merge pull request 'feat: Implement full network functionality with HTTP and P2P modes' (#1) from develop into main
Go Build and Test / build-and-test (push) Successful in 23s

Reviewed-on: https://gitea.home.com:3000/TolaMironcenko/backup/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
wt
2025-08-03 10:46:54 +07:00
7 changed files with 543 additions and 240 deletions
+41
View File
@@ -0,0 +1,41 @@
name: Go Build and Test
# This workflow runs on pushes and pull requests to the main branches.
on:
push:
branches:
- main
- master
pull_request:
branches:
- main
- master
jobs:
build-and-test:
# You might need to change 'ubuntu-latest' to a tag that matches your self-hosted Gitea runner, e.g., 'linux', 'go', etc.
runs-on: ubuntu-latest
steps:
# Step 1: Check out the repository code
- name: Checkout
uses: actions/checkout@v4
# Step 2: Set up the Go environment
- name: Set up Go
uses: actions/setup-go@v5
with:
# Use a Go version that matches your project's go.mod file
go-version: '1.22'
# Step 3: Download Go module dependencies
- name: Tidy and Vendor
run: go mod tidy && go mod vendor
# Step 4: Build the application to ensure it compiles
- name: Build
run: go build -v ./...
# Step 5: Run tests to check for regressions
# This step will pass if no tests are found, which is fine for now.
- name: Test
run: go test -v ./...
+84 -44
View File
@@ -1,57 +1,56 @@
# Go Backup Utility
A simple yet powerful command-line utility written in Go for creating and restoring backups of files, directories, and block devices (like disks). It supports standard `gzip` compression for files/disks and `tar.gz` for directories.
A simple yet powerful command-line utility written in Go for creating, restoring, and serving backups of files, directories, and block devices. It supports `gzip` compression for files/disks and `tar.gz` for directories, with multiple modes for network operations.
The key feature of this tool is the ability to stream backups directly over the network, allowing you to create a backup on one machine and restore it on another without storing intermediate files.
The key feature of this tool is the ability to handle backups over the network, allowing you to create a backup on one machine and restore it on another without storing intermediate files, either via a direct connection or through a central backup server.
## Features
- **File, Disk, and Directory Backup**: Create backups of individual files, entire directories, or full block devices (e.g., `/dev/sda`).
- **Network Streaming**: Directly stream backups between two machines over a TCP connection.
- `create` acts as a network client, sending data.
- `restore` acts as a network server, receiving data.
- **Multiple Network Modes**:
1. **HTTP Server Mode**: Run a dedicated server using the `serve` command to upload and download backup files.
2. **Peer-to-Peer (P2P) Mode**: Directly stream a backup from a machine running `create` to another running `restore`.
- **Compression**: Uses `gzip` for efficient compression. Directories are archived using `tar` before compression.
- **Interactive Progress Bars**: Displays a real-time progress bar for both creating and restoring, so you always know the status of the operation.
- **Interactive Progress Bars**: Displays a real-time progress bar for creating and restoring, so you always know the status of the operation.
- **Cross-Platform**: Written in Go, it can be compiled for Linux, macOS, and Windows.
## Installation
To build the utility from source, you need to have Go installed on your system.
To build the utility from source, you need to have Go and the `git` client installed.
1. Clone the repository (or download the source code).
2. Navigate to the project directory.
3. Build the binary:
1. Clone the repository:
```sh
git clone https://github.com/your-username/backup-utility.git
cd backup-utility
```
2. Build the binary:
```sh
go build
```
4. This will create an executable file named `backup` (or `backup.exe` on Windows) in the current directory. You can move this file to a directory in your system's `PATH` (e.g., `/usr/local/bin`) to make it accessible from anywhere.
3. This will create an executable file named `backup` (or `backup.exe` on Windows). You can move this file to a directory in your system's `PATH` (e.g., `/usr/local/bin`) to make it globally accessible.
## Usage
The utility has two main commands: `create` and `restore`.
The utility has three main commands: `create`, `restore`, and `serve`.
### Local Backup and Restore
### 1. Local Backup and Restore
#### 1. Backup a Directory
This will create a `tar.gz` archive of the specified source directory.
This mode works entirely on your local filesystem.
#### Backup a Directory
Creates a `tar.gz` archive of the source directory.
```sh
./backup create -s /path/to/source/directory -t /path/to/backup.tar.gz
```
#### 2. Restore a Directory
This will extract the contents of a `tar.gz` archive into the specified target directory. If the target directory is omitted, it defaults to the current working directory.
#### Restore a Directory
Extracts a `tar.gz` archive. If the target directory is omitted, it defaults to the current working directory.
```sh
./backup restore -s /path/to/backup.tar.gz -t /path/to/restore/location
```
#### 3. Backup a File or Disk
This will create a compressed `gz` file from a source file or a block device.
#### Backup a File or Disk
Creates a compressed `.gz` file from a source file or block device.
```sh
# Backup a file
./backup create -s /path/to/largefile.log -t /path/to/largefile.log.gz
@@ -60,10 +59,8 @@ This will create a compressed `gz` file from a source file or a block device.
./backup create -s /dev/sdb1 -t /path/to/sdb1_backup.img.gz
```
#### 4. Restore a File or Disk
This will decompress a `.gz` file to a target file or write the uncompressed data to a block device.
#### Restore a File or Disk
Decompresses a `.gz` file or writes the uncompressed data to a block device.
```sh
# Restore a file
./backup restore -s /path/to/largefile.log.gz -t /path/to/restored_largefile.log
@@ -72,40 +69,83 @@ This will decompress a `.gz` file to a target file or write the uncompressed dat
./backup restore -s /path/to/sdb1_backup.img.gz -t /dev/sdb1
```
### Network Backup and Restore
---
This is the most powerful feature. It allows you to pipe a backup from a source machine (client) to a target machine (server).
### 2. HTTP Server Mode
**Step 1: On the receiving machine (Server)**
This mode is ideal for managing backups in a centralized location. One machine runs `serve`, and other machines can upload (`create`) or download (`restore`) backups from it.
First, run the `restore` command. It will start listening on the specified IP address and port, waiting for a connection. Use `0.0.0.0` to listen on all available network interfaces.
**Step 1: Start the Backup Server**
On your server machine, run the `serve` command. This will start an HTTP server that listens for backup requests.
```sh
# The server waits for data on port 8080 and will restore it to /mnt/data
# Run the server, listening on port 8080
# Backups will be stored in the default directory (~/backups)
./backup serve -a 0.0.0.0:8080
# Specify a custom directory for backups
./backup serve -a 0.0.0.0:8080 -d /mnt/backups
```
The server is now running and waiting for client connections.
**Step 2 (Client): Create and Upload a Backup**
On a client machine, use the `create` command and point the target (`-t`) to the server's address, followed by a `/` and the desired filename for the backup.
```sh
# Back up a directory and upload it to the server as "my_project_backup.tar.gz"
./backup create -s ./my_project -t 192.168.1.100:8080/my_project_backup.tar.gz
# Back up a disk and upload it
./backup create -s /dev/sdc -t 192.168.1.100:8080/sdc_image.img.gz
```
**Step 3 (Client): Download and Restore a Backup**
To restore, use the `restore` command and point the source (`-s`) to the server URL.
```sh
# Download and restore a directory backup from the server
./backup restore -s 192.168.1.100:8080/my_project_backup.tar.gz -t ./restored_project
# Download and restore a disk image
./backup restore -s 192.168.1.100:8080/sdc_image.img.gz -t /dev/sdd
```
---
### 3. Peer-to-Peer (P2P) Mode
This mode streams a backup directly from one machine to another without a central server. It's useful for one-off migrations or direct transfers.
**Step 1: On the receiving machine (Server-like role)**
Run the `restore` command with a listening address (`ip:port`) as the source (`-s`). Use `0.0.0.0` to listen on all network interfaces.
```sh
# The machine waits for data on port 8080 and will restore it to /mnt/data
./backup restore -s 0.0.0.0:8080 -t /mnt/data
```
> The command will print `Waiting for connection on 0.0.0.0:8080...` and will pause until the client connects.
The command will print `Listening on 0.0.0.0:8080...` and will pause until the client connects.
**Step 2: On the sending machine (Client)**
Now, run the `create` command, pointing the target (`-t`) to the IP address and port of the server machine.
**Step 2: On the sending machine (Client-like role)**
Run the `create` command, pointing the target (`-t`) to the IP address and port of the receiving machine.
```sh
# This will back up the /home/user/documents directory and send it directly
# to the server at 192.168.1.100 on port 8080.
./backup create -s /home/user/documents -t 192.168.1.100:8080
# to the machine at 192.168.1.200 on port 8080.
./backup create -s /home/user/documents -t 192.168.1.200:8080
```
The backup process will start, and you will see a progress bar on both the client and server terminals.
The backup will start immediately, and you will see a progress bar on both terminals.
## Command-line Flags
- `create`:
- `-s, --source`: The source file, directory, or disk to back up (required).
- `-t, --target`: The target backup file or network address (`ip:port`) (required).
- `-t, --target`: The target. Can be a local file path, a P2P address (`ip:port`), or an HTTP server URL (`ip:port/filename`) (required).
- `restore`:
- `-s, --source`: The source archive file or network address (`ip:port`) to restore from (required).
- `-t, --target`: The target file, disk, or directory. Required for network sources and file/disk restores. Defaults to the current directory for local archive restores.
- `-s, --source`: The source. Can be a local archive, a P2P listening address (`ip:port`), or an HTTP server URL (`ip:port/filename`) (required).
- `-t, --target`: The target file, disk, or directory. Required for network restores and local file/disk restores.
- `serve`:
- `-a, --address`: The address and port for the server to listen on (defaults to `localhost:8080`).
- `-d, --directory`: The directory to store backups in (defaults to `~/backups`).
## License
+93 -84
View File
@@ -7,9 +7,11 @@ import (
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"unsafe"
@@ -46,31 +48,30 @@ var createCmd = &cobra.Command{
}
type model struct {
progress progress.Model
source string
target string
totalBytes int64
processed int64
done bool
err error
startTime time.Time
progressCh chan int64
isDirectory bool
isNetworkTarget bool
progress progress.Model
source string
target string
totalBytes int64
processed int64
done bool
err error
startTime time.Time
progressCh chan int64
isDirectory bool
httpTargetInfo *ServerTargetInfo
isP2PTarget bool // Флаг для старого P2P-режима
}
type progressMsg int64
type doneMsg struct{}
type errorMsg struct{ err error }
// getPathSize возвращает размер файла, диска или директории
func getPathSize(path string) (int64, error) {
fileInfo, err := os.Stat(path)
if err != nil {
return 0, err
}
// Для Linux: проверка, является ли источник блочным устройством
if runtime.GOOS == "linux" {
stat, ok := fileInfo.Sys().(*syscall.Stat_t)
if ok && (stat.Mode&syscall.S_IFMT) == syscall.S_IFBLK {
@@ -94,7 +95,6 @@ func getPathSize(path string) (int64, error) {
}
}
// Для директорий
if fileInfo.IsDir() {
var total int64
err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
@@ -109,7 +109,6 @@ func getPathSize(path string) (int64, error) {
return total, err
}
// Для обычных файлов
return fileInfo.Size(), nil
}
@@ -129,15 +128,30 @@ func initialModel(src, dst string) (*model, error) {
return nil, err
}
isDirectory := srcInfo.IsDir()
networkTarget := isNetworkAddress(dst)
targetInfo, isHttp := parseServerTarget(dst)
isP2P := false
targetPath := dst
if !networkTarget {
if isHttp {
// Новый HTTP-режим
if isDirectory && !strings.HasSuffix(targetInfo.Filename, ".tar.gz") {
targetInfo.Filename += ".tar.gz"
} else if !isDirectory && !strings.HasSuffix(targetInfo.Filename, ".gz") {
targetInfo.Filename += ".gz"
}
targetInfo.URL = fmt.Sprintf("http://%s/backup/%s", targetInfo.Address, targetInfo.Filename)
targetPath = targetInfo.URL
} else if isNetworkAddress(dst) {
// Старый P2P-режим
isP2P = true
} else {
// Локальный файл
if isDirectory {
if filepath.Ext(targetPath) != ".tar.gz" {
targetPath += ".tar.gz"
}
} else { // Это файл или диск
} else {
if filepath.Ext(targetPath) != ".gz" {
targetPath += ".gz"
}
@@ -145,14 +159,15 @@ func initialModel(src, dst string) (*model, error) {
}
return &model{
progress: p,
source: src,
target: targetPath,
totalBytes: totalBytes,
startTime: time.Now(),
progressCh: make(chan int64, 100),
isDirectory: isDirectory,
isNetworkTarget: networkTarget,
progress: p,
source: src,
target: targetPath,
totalBytes: totalBytes,
startTime: time.Now(),
progressCh: make(chan int64, 100),
isDirectory: isDirectory,
httpTargetInfo: targetInfo,
isP2PTarget: isP2P,
}, nil
}
@@ -163,21 +178,47 @@ func (m *model) Init() tea.Cmd {
)
}
// getTargetWriter создает io.WriteCloser для файла или сетевого подключения
func (m *model) getTargetWriter() (io.WriteCloser, error) {
if m.isNetworkTarget {
if m.httpTargetInfo != nil {
// Новый HTTP-режим
pipeReader, pipeWriter := io.Pipe()
req, err := http.NewRequest("POST", m.httpTargetInfo.URL, pipeReader)
if err != nil {
return nil, fmt.Errorf("failed to create http request: %w", err)
}
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = -1 // Stream upload
go func() {
client := &http.Client{} // No timeout for uploads
resp, err := client.Do(req)
if err != nil {
pipeWriter.CloseWithError(fmt.Errorf("http request failed: %w", err))
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
err := fmt.Errorf("server returned non-200 status: %s\n%s", resp.Status, string(bodyBytes))
pipeWriter.CloseWithError(err)
}
}()
return pipeWriter, nil
} else if m.isP2PTarget {
// Старый P2P-режим
conn, err := net.DialTimeout("tcp", m.target, 10*time.Second)
if err != nil {
return nil, fmt.Errorf("failed to connect to %s: %w", m.target, err)
}
// Протокол: сначала отправляем 8 байт (размер) и 1 байт (тип)
// 1. Общий размер (int64)
// Отправляем бинарный заголовок (размер + тип)
if err := binary.Write(conn, binary.BigEndian, m.totalBytes); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to send backup size: %w", err)
}
// 2. Тип бэкапа (1 = директория, 0 = файл/диск)
var typeByte byte = 0
if m.isDirectory {
typeByte = 1
@@ -186,10 +227,10 @@ func (m *model) getTargetWriter() (io.WriteCloser, error) {
conn.Close()
return nil, fmt.Errorf("failed to send backup type: %w", err)
}
return conn, nil
}
// Логика для локального файла
// Локальный файл
return os.Create(m.target)
}
@@ -216,7 +257,7 @@ func (m *model) startBackup() tea.Msg {
return
}
m.progressCh <- -2 // Сигнал завершения
m.progressCh <- -2
}()
return nil
@@ -229,30 +270,17 @@ func (m *model) backupFileOrDisk(w io.Writer) error {
}
defer srcFile.Close()
gzipWriter := gzip.NewWriter(w)
progressWriter := &progressTracker{Writer: w, progressCh: m.progressCh}
gzipWriter := gzip.NewWriter(progressWriter)
defer gzipWriter.Close()
buf := make([]byte, 32*1024) // 32KB buffer
for {
n, err := srcFile.Read(buf)
if n > 0 {
if _, writeErr := gzipWriter.Write(buf[:n]); writeErr != nil {
return writeErr
}
m.progressCh <- int64(n)
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return nil
_, err = io.Copy(gzipWriter, srcFile)
return err
}
func (m *model) backupDirectory(w io.Writer) error {
gzipWriter := gzip.NewWriter(w)
progressWriter := &progressTracker{Writer: w, progressCh: m.progressCh}
gzipWriter := gzip.NewWriter(progressWriter)
defer gzipWriter.Close()
tarWriter := tar.NewWriter(gzipWriter)
@@ -288,39 +316,22 @@ func (m *model) backupDirectory(w io.Writer) error {
}
defer srcFile.Close()
buf := make([]byte, 32*1024)
for {
n, err := srcFile.Read(buf)
if n > 0 {
if _, err := tarWriter.Write(buf[:n]); err != nil {
return err
}
m.progressCh <- int64(n)
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return nil
_, err = io.Copy(tarWriter, srcFile)
return err
})
}
func (m *model) progressListener() tea.Msg {
select {
case n := <-m.progressCh:
switch {
case n == -1: // Ошибка
n := <-m.progressCh
if n < 0 {
if n == -1 {
return errorMsg{m.err}
case n == -2: // Завершение
return doneMsg{}
default: // Прогресс
m.processed += n
return progressMsg(m.processed)
}
return doneMsg{}
}
m.processed += n
return progressMsg(m.processed)
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -352,7 +363,6 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = msg.err
return m, tea.Quit
}
return m, nil
}
@@ -363,12 +373,11 @@ func (m *model) View() string {
if m.done {
duration := time.Since(m.startTime)
// ratio := float64(m.processed) / float64(m.totalBytes) * 100
backupType := "File/disk"
if m.isDirectory {
backupType = "Directory"
}
if m.isNetworkTarget {
if m.httpTargetInfo != nil || m.isP2PTarget {
backupType += " network"
}
@@ -386,7 +395,7 @@ func (m *model) View() string {
if m.isDirectory {
operation = "Archiving"
}
if m.isNetworkTarget {
if m.httpTargetInfo != nil || m.isP2PTarget {
operation = "Sending"
}
@@ -408,7 +417,7 @@ func (m *model) View() string {
func init() {
rootCmd.AddCommand(createCmd)
createCmd.Flags().StringVarP(&source, "source", "s", "", "Source file, directory or disk (e.g., /dev/sda) (required)")
createCmd.Flags().StringVarP(&target, "target", "t", "", "Target backup file or network address (e.g., /path/to/backup.tar.gz or 127.0.0.1:8080) (required)")
createCmd.Flags().StringVarP(&target, "target", "t", "", "Target backup file or network address (e.g., /path/to/backup.tar.gz, 127.0.0.1:8080/backup.gz, or 127.0.0.1:8080) (required)")
createCmd.MarkFlagRequired("source")
createCmd.MarkFlagRequired("target")
}
+147 -111
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
@@ -28,7 +29,7 @@ var (
var restoreCmd = &cobra.Command{
Use: "restore",
Short: "Restore a backup",
Long: "Restore a backup from a local archive or network stream to a file, disk, or directory.",
Long: "Restore a backup from a local archive, P2P stream, or HTTP server.",
Run: func(cmd *cobra.Command, args []string) {
model, err := initialRestoreModel(restoreSource, restoreTarget)
if err != nil {
@@ -45,18 +46,27 @@ var restoreCmd = &cobra.Command{
}
type restoreModel struct {
progress progress.Model
source string
target string
totalBytes int64
processed int64
done bool
err error
startTime time.Time
progressCh chan int64
isDirectory bool
isNetworkSource bool
statusMessage string
progress progress.Model
source string
target string
totalBytes int64
processed int64
done bool
err error
startTime time.Time
progressCh chan int64
isDirectory bool
httpSourceInfo *ServerTargetInfo
isP2PSource bool
statusMessage string
}
// restoreSourceReadyMsg is sent when the source is ready to be read.
// It carries the reader, the total size, and whether it's a directory backup.
type restoreSourceReadyMsg struct {
reader io.ReadCloser
totalBytes int64
isDir bool
}
type restoreProgressMsg int64
@@ -64,21 +74,24 @@ type restoreDoneMsg struct{}
type restoreErrorMsg struct{ err error }
func initialRestoreModel(src, dst string) (*restoreModel, error) {
isNetwork := isNetworkAddress(src)
p := progress.New(
progress.WithDefaultGradient(),
progress.WithWidth(40),
)
var totalBytes int64 = 1 // Placeholder for network source, to avoid division by zero.
var isDir bool
var totalBytes int64 = 1 // Placeholder, will be updated.
var isDir, isP2P bool
targetPath := dst
status := "Initializing..."
if isNetwork {
if targetPath == "" {
return nil, fmt.Errorf("target flag -t is required for a network source")
}
httpInfo, isHttp := parseServerTarget(src)
if isHttp {
isDir = strings.HasSuffix(httpInfo.Filename, ".tar.gz")
status = fmt.Sprintf("Connecting to %s...", httpInfo.Address)
src = httpInfo.URL
} else if isNetworkAddress(src) {
isP2P = true
status = fmt.Sprintf("Listening on %s...", src)
} else {
fileInfo, err := os.Stat(src)
@@ -87,89 +100,96 @@ func initialRestoreModel(src, dst string) (*restoreModel, error) {
}
totalBytes = fileInfo.Size()
isDir = strings.HasSuffix(src, ".tar.gz")
}
if isDir && targetPath == "" {
targetPath, err = os.Getwd()
if err != nil {
return nil, fmt.Errorf("failed to get current directory: %w", err)
}
} else if !isDir && targetPath == "" {
return nil, fmt.Errorf("target flag -t is required for file or disk restoration")
if isDir && targetPath == "" {
// For directory restores, we can default to the current directory.
// We do this check here, but also again after getting the P2P type.
var err error
targetPath, err = os.Getwd()
if err != nil {
return nil, fmt.Errorf("failed to get current directory: %w", err)
}
} else if !isDir && targetPath == "" && !isP2P {
// Target is mandatory for file/disk restores (except P2P, where we can't know the type yet).
return nil, fmt.Errorf("target flag -t is required for this restoration")
}
return &restoreModel{
progress: p,
source: src,
target: targetPath,
totalBytes: totalBytes,
startTime: time.Now(),
progressCh: make(chan int64, 100),
isDirectory: isDir,
isNetworkSource: isNetwork,
statusMessage: status,
progress: p,
source: src,
target: targetPath,
totalBytes: totalBytes,
startTime: time.Now(),
progressCh: make(chan int64, 100),
isDirectory: isDir,
httpSourceInfo: httpInfo,
isP2PSource: isP2P,
statusMessage: status,
}, nil
}
func (m *restoreModel) Init() tea.Cmd {
return tea.Batch(
m.startRestore,
m.restoreProgressListener,
)
return m.fetchSourceCmd
}
func (m *restoreModel) getSourceReader() (io.ReadCloser, error) {
if m.isNetworkSource {
func (m *restoreModel) fetchSourceCmd() tea.Msg {
if m.httpSourceInfo != nil {
// New HTTP Mode
client := &http.Client{}
resp, err := client.Get(m.source)
if err != nil {
return restoreErrorMsg{fmt.Errorf("failed to connect to server: %w", err)}
}
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return restoreErrorMsg{fmt.Errorf("server returned non-200 status: %s\n%s", resp.Status, string(bodyBytes))}
}
return restoreSourceReadyMsg{reader: resp.Body, totalBytes: resp.ContentLength, isDir: m.isDirectory}
} else if m.isP2PSource {
// Old P2P Mode
listener, err := net.Listen("tcp", m.source)
if err != nil {
return nil, fmt.Errorf("failed to listen on %s: %w", m.source, err)
return restoreErrorMsg{fmt.Errorf("failed to listen on %s: %w", m.source, err)}
}
defer listener.Close() // Close listener after accepting one connection
m.statusMessage = fmt.Sprintf("Waiting for connection on %s", m.source)
// This is a blocking call
// Accept is blocking, so it must be in a command.
conn, err := listener.Accept()
listener.Close() // Close listener after one connection.
if err != nil {
return nil, fmt.Errorf("failed to accept connection: %w", err)
return restoreErrorMsg{fmt.Errorf("failed to accept connection: %w", err)}
}
m.statusMessage = "Client connected. Receiving data..."
// Protocol: Read 8 bytes for size, 1 byte for type
if err := binary.Read(conn, binary.BigEndian, &m.totalBytes); err != nil {
var size int64
if err := binary.Read(conn, binary.BigEndian, &size); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to read backup size: %w", err)
return restoreErrorMsg{fmt.Errorf("failed to read backup size: %w", err)}
}
if m.totalBytes == 0 {
m.totalBytes = 1 // Avoid division by zero for empty files
}
typeByte := make([]byte, 1)
if _, err := io.ReadFull(conn, typeByte); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to read backup type: %w", err)
return restoreErrorMsg{fmt.Errorf("failed to read backup type: %w", err)}
}
m.isDirectory = (typeByte[0] == 1)
return conn, nil
return restoreSourceReadyMsg{reader: conn, totalBytes: size, isDir: typeByte[0] == 1}
}
// Logic for local file
m.statusMessage = fmt.Sprintf("Opening archive %s", m.source)
return os.Open(m.source)
// Local File Mode
fileInfo, err := os.Stat(m.source)
if err != nil {
return restoreErrorMsg{err}
}
file, err := os.Open(m.source)
if err != nil {
return restoreErrorMsg{err}
}
return restoreSourceReadyMsg{reader: file, totalBytes: fileInfo.Size(), isDir: m.isDirectory}
}
func (m *restoreModel) startRestore() tea.Msg {
go func() {
sourceReader, err := m.getSourceReader()
if err != nil {
m.err = err
m.progressCh <- -1
return
}
defer sourceReader.Close()
progressReader := &restoreProgressReader{
reader: sourceReader,
func (m *restoreModel) processRestoreCmd(r io.ReadCloser) tea.Cmd {
return func() tea.Msg {
defer r.Close()
progressReader := &progressTracker{
Reader: r,
progressCh: m.progressCh,
}
@@ -183,12 +203,11 @@ func (m *restoreModel) startRestore() tea.Msg {
if restoreErr != nil {
m.err = restoreErr
m.progressCh <- -1
return
} else {
m.progressCh <- -2 // Done signal
}
m.progressCh <- -2 // Done signal
}()
return nil
return nil
}
}
func (m *restoreModel) restoreFileOrDisk(r io.Reader) error {
@@ -212,7 +231,8 @@ func (m *restoreModel) restoreFileOrDisk(r io.Reader) error {
}
defer dstFile.Close()
if _, err = io.Copy(dstFile, gzipReader); err != nil {
_, err = io.Copy(dstFile, gzipReader)
if err != nil && err != io.EOF {
return err
}
return dstFile.Sync()
@@ -264,37 +284,19 @@ func (m *restoreModel) restoreDirectory(r io.Reader) error {
return nil
}
type restoreProgressReader struct {
reader io.Reader
progressCh chan int64
read int64
}
func (r *restoreProgressReader) Read(p []byte) (int, error) {
n, err := r.reader.Read(p)
if n > 0 {
r.read += int64(n)
r.progressCh <- r.read
}
return n, err
}
func (m *restoreModel) restoreProgressListener() tea.Msg {
select {
case n := <-m.progressCh:
switch {
case n == -1: // Ошибка
n := <-m.progressCh
if n < 0 {
if n == -1 {
if m.err == nil {
m.err = fmt.Errorf("unknown restoration error")
}
return restoreErrorMsg{m.err}
case n == -2: // Завершение
return restoreDoneMsg{}
default: // Прогресс
m.processed = n
return restoreProgressMsg(n)
}
return restoreDoneMsg{}
}
m.processed = n
return restoreProgressMsg(n)
}
func (m *restoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -309,6 +311,31 @@ func (m *restoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.progress.Width = msg.Width - 4
return m, nil
case restoreSourceReadyMsg:
m.totalBytes = msg.totalBytes
if m.totalBytes <= 0 {
m.totalBytes = 1
}
m.isDirectory = msg.isDir // Update directory type, especially for P2P.
// Final check for target directory now that we know the type for sure.
if m.isDirectory && m.target == "" {
wd, err := os.Getwd()
if err != nil {
return m, func() tea.Msg { return restoreErrorMsg{err} }
}
m.target = wd
} else if !m.isDirectory && m.target == "" {
err := fmt.Errorf("target flag -t is required for file/disk restoration")
return m, func() tea.Msg { return restoreErrorMsg{err} }
}
m.statusMessage = "Receiving data..."
return m, tea.Batch(
m.processRestoreCmd(msg.reader),
m.restoreProgressListener,
)
case restoreProgressMsg:
progressVal := float64(msg) / float64(m.totalBytes)
if progressVal > 1.0 {
@@ -340,6 +367,9 @@ func (m *restoreModel) View() string {
if m.isDirectory {
restoreType = "Directory"
}
if m.httpSourceInfo != nil || m.isP2PSource {
restoreType += " network"
}
return fmt.Sprintf("\n✅ %s restoration complete!\n\n"+
"Source: %s\n"+
"Target: %s\n"+
@@ -348,12 +378,18 @@ func (m *restoreModel) View() string {
duration.Round(time.Millisecond))
}
// Show status message before progress starts
if m.processed == 0 && (m.isNetworkSource || m.statusMessage != "") {
if m.processed == 0 {
return m.statusMessage + "\n\nPress Ctrl+C to cancel"
}
title := fmt.Sprintf("Restoring from %s → %s", m.source, m.target)
operation := "Restoring"
if m.httpSourceInfo != nil {
operation = "Downloading"
} else if m.isP2PSource {
operation = "Receiving"
}
title := fmt.Sprintf("%s from %s → %s", operation, m.source, m.target)
progressVal := float64(m.processed) / float64(m.totalBytes)
progressView := m.progress.ViewAs(progressVal)
stats := fmt.Sprintf("%s / %s (%.1f%%)", formatBytes(m.processed), formatBytes(m.totalBytes), progressVal*100)
@@ -385,7 +421,7 @@ func isBlockDevice(path string) bool {
func init() {
rootCmd.AddCommand(restoreCmd)
restoreCmd.Flags().StringVarP(&restoreSource, "source", "s", "", "Source archive or network address (e.g., /path/to/archive.tar.gz or 0.0.0.0:8080) (required)")
restoreCmd.Flags().StringVarP(&restoreTarget, "target", "t", "", "Target file, disk, or directory. Required for network sources.")
restoreCmd.Flags().StringVarP(&restoreSource, "source", "s", "", "Source archive, P2P address (e.g., 0.0.0.0:8080), or HTTP URL (e.g., 127.0.0.1:8080/backup.gz) (required)")
restoreCmd.Flags().StringVarP(&restoreTarget, "target", "t", "", "Target file, disk, or directory. Required for non-directory restores.")
restoreCmd.MarkFlagRequired("source")
}
+96
View File
@@ -0,0 +1,96 @@
package cmd
import (
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"github.com/gorilla/mux"
"github.com/spf13/cobra"
)
var (
serveAddress string
serveDirectory string
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Run a backup server",
Long: "Run an HTTP server to upload and download backups.",
Run: func(cmd *cobra.Command, args []string) {
if serveDirectory == "" {
home, err := os.UserHomeDir()
if err != nil {
log.Fatalf("Failed to get user home directory: %v", err)
}
serveDirectory = filepath.Join(home, "backups")
}
if err := os.MkdirAll(serveDirectory, 0755); err != nil {
log.Fatalf("Failed to create backup directory: %v", err)
}
r := mux.NewRouter()
r.HandleFunc("/backup/{filename}", uploadHandler).Methods("POST")
r.HandleFunc("/backup/{filename}", downloadHandler).Methods("GET")
log.Printf("Starting server on %s", serveAddress)
log.Printf("Using backup directory: %s", serveDirectory)
if err := http.ListenAndServe(serveAddress, r); err != nil {
log.Fatalf("Server failed: %v", err)
}
},
}
func uploadHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
filename := vars["filename"]
filePath := filepath.Join(serveDirectory, filename)
file, err := os.Create(filePath)
if err != nil {
http.Error(w, "Failed to create file", http.StatusInternalServerError)
log.Printf("Error creating file %s: %v", filename, err)
return
}
defer file.Close()
_, err = io.Copy(file, r.Body)
if err != nil {
http.Error(w, "Failed to write to file", http.StatusInternalServerError)
log.Printf("Error writing to file %s: %v", filename, err)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "File %s uploaded successfully.", filename)
log.Printf("Uploaded %s", filename)
}
func downloadHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
filename := vars["filename"]
filePath := filepath.Join(serveDirectory, filename)
// Проверяем, существует ли файл, перед отправкой
if _, err := os.Stat(filePath); os.IsNotExist(err) {
http.NotFound(w, r)
log.Printf("File not found: %s", filename)
return
}
// http.ServeFile - это идиоматический способ отправки файлов в Go.
// Он автоматически устанавливает Content-Type, Content-Length и другие заголовки.
http.ServeFile(w, r, filePath)
log.Printf("Downloaded %s", filename)
}
func init() {
rootCmd.AddCommand(serveCmd)
serveCmd.Flags().StringVarP(&serveAddress, "address", "a", "localhost:8080", "Address and port for the server")
serveCmd.Flags().StringVarP(&serveDirectory, "directory", "d", "", "Directory to store backups (defaults to ~/backups)")
}
+80
View File
@@ -2,6 +2,8 @@ package cmd
import (
"fmt"
"io"
"net"
"strings"
)
@@ -22,3 +24,81 @@ func formatBytes(b int64) string {
}
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
}
// ServerTargetInfo holds the parsed information from a server target string.
type ServerTargetInfo struct {
// Full HTTP URL for the request, e.g., http://127.0.0.1:8080/backup/mybackup.tar.gz
URL string
// The address of the server, e.g., 127.0.0.1:8080
Address string
// The filename for the backup, e.g., mybackup.tar.gz
Filename string
}
// parseServerTarget parses a target string like `127.0.0.1:8080/backup.gz`.
// It returns a struct with the full URL and filename, or an error if the format is invalid.
func parseServerTarget(target string) (*ServerTargetInfo, bool) {
if !strings.Contains(target, "/") || !strings.Contains(target, ":") {
return nil, false
}
parts := strings.SplitN(target, "/", 2)
if len(parts) != 2 {
return nil, false // Invalid format
}
address := parts[0]
filename := parts[1]
if filename == "" {
return nil, false // Filename cannot be empty
}
// Validate that the first part is a host:port
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, false // Not a valid host:port
}
if host == "" || port == "" {
return nil, false
}
// It looks like a valid server target.
fullURL := fmt.Sprintf("http://%s/backup/%s", address, filename)
return &ServerTargetInfo{
URL: fullURL,
Address: address,
Filename: filename,
}, true
}
// progressTracker реализует io.Reader и io.Writer для отслеживания прогресса
type progressTracker struct {
Reader io.Reader
Writer io.Writer
progressCh chan int64
// Для restore, где io.Copy может вызываться много раз (в tar),
// нам нужно отслеживать общий прогресс.
processed int64
}
// Write отслеживает прогресс записи (для create)
func (pt *progressTracker) Write(p []byte) (int, error) {
n, err := pt.Writer.Write(p)
if n > 0 {
pt.progressCh <- int64(n) // Отправляем дельту
}
return n, err
}
// Read отслеживает прогресс чтения (для restore)
func (pt *progressTracker) Read(p []byte) (int, error) {
n, err := pt.Reader.Read(p)
if n > 0 {
pt.processed += int64(n)
pt.progressCh <- pt.processed // Отправляем общий обработанный объем
}
return n, err
}
+2 -1
View File
@@ -6,7 +6,9 @@ require (
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.4
github.com/charmbracelet/lipgloss v1.1.0
github.com/gorilla/mux v1.8.1
github.com/spf13/cobra v1.9.1
golang.org/x/sys v0.30.0
)
require (
@@ -29,6 +31,5 @@ require (
github.com/spf13/pflag v1.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.3.8 // indirect
)