feat: Implement full network functionality with HTTP and P2P modes
Go Build and Test / build-and-test (pull_request) Successful in 37s
Go Build and Test / build-and-test (pull_request) Successful in 37s
This major update introduces a robust, multi-faceted networking capability, allowing backups to be managed through a central server or via direct peer-to-peer connections. - **Add `serve` command**: Implemented a new `serve` command that runs a persistent HTTP server. This allows for a centralized backup management workflow where multiple clients can upload and download archives. - **HTTP Client Mode**: The `create` and `restore` commands can now function as HTTP clients, targeting a `serve` instance using the `ip:port/filename` address format. - **Restore P2P Mode**: The original peer-to-peer streaming functionality (where `restore` acts as a TCP server and `create` as a client) has been fully restored and integrated into the new, more robust command architecture. - **Accurate Network Progress**: Fixed a critical bug where network progress bars would not display correctly. The `serve` command now sends a `Content-Length` header, and `restore` is refactored to handle asynchronous state updates correctly. - **Smart Address Parsing**: The application now automatically distinguishes between HTTP mode (`ip:port/filename`) and P2P mode (`ip:port`), providing a seamless user experience. - **Update `README.md`**: The project's README has been completely rewritten to document all three operating modes (Local, HTTP Server, P2P) with clear usage examples. - **Add Gitea Actions Workflow**: A new CI pipeline (`.gitea/workflows/build-and-test.yml`) has been added to automatically build and test the application on every push and pull request.
This commit is contained in:
1 parent
668721ab58
commit
6b1a302c18
7 files changed
+543
-240
No files matched your search
+93
-84
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user