diff --git a/.gitea/workflows/build-and-test.yml b/.gitea/workflows/build-and-test.yml new file mode 100644 index 0000000..ce5f8a9 --- /dev/null +++ b/.gitea/workflows/build-and-test.yml @@ -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 ./... diff --git a/README.md b/README.md index 4f37f2d..c236320 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/create.go b/cmd/create.go index e5a571d..c7fe13f 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -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") } diff --git a/cmd/restore.go b/cmd/restore.go index 50aec98..0e4f657 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -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") } diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 0000000..b1823b3 --- /dev/null +++ b/cmd/serve.go @@ -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)") +} diff --git a/cmd/utils.go b/cmd/utils.go index b99f06e..cc3340d 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -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 +} diff --git a/go.mod b/go.mod index 9e115ba..cba140d 100644 --- a/go.mod +++ b/go.mod @@ -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 )