feat: Implement full network functionality with HTTP and P2P modes
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:
wt committed 2025-08-03 10:46:14 +07:00
1 parent 668721ab58
commit 6b1a302c18
7 files changed
+543 -240

No files matched your search

+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")
}