From 6f8a9cc708dde4a22e6c86fc0d2a33ed041bcc87 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Fri, 29 May 2026 10:23:45 -0400 Subject: [PATCH 01/45] feat: add private mode, progress bar fixes, metadata stripping, and core improvements --- cmd/localgo/cmd/devices.go | 4 + cmd/localgo/cmd/discover.go | 8 +- cmd/localgo/cmd/history.go | 6 +- cmd/localgo/cmd/root.go | 10 +- cmd/localgo/cmd/send.go | 8 +- cmd/localgo/cmd/serve.go | 18 ++- cmd/localgo/cmd/share.go | 7 +- cmd/localgo/cmd/utils.go | 14 +++ pkg/cli/output.go | 28 ++++- pkg/cli/progress.go | 16 +++ pkg/config/config.go | 31 +++-- pkg/config/dto.go | 14 ++- pkg/metadata/strip.go | 145 ++++++++++++++++++++++ pkg/send/send.go | 91 ++++++++++++-- pkg/send/send_error_test.go | 6 +- pkg/send/send_test.go | 4 +- pkg/server/handlers/discovery_handlers.go | 30 ++++- pkg/server/handlers/receive_handlers.go | 10 +- pkg/server/services/receive_service.go | 6 + 19 files changed, 412 insertions(+), 44 deletions(-) create mode 100644 pkg/metadata/strip.go diff --git a/cmd/localgo/cmd/devices.go b/cmd/localgo/cmd/devices.go index 0f295d0..20ebb62 100644 --- a/cmd/localgo/cmd/devices.go +++ b/cmd/localgo/cmd/devices.go @@ -50,6 +50,10 @@ var devicesCmd = &cobra.Command{ return displayDevices(peers, true, false, "cache") } + if Cfg != nil && Cfg.Private { + peers = anonymizeDeviceSlice(peers) + } + for i := 0; i < len(peers); i++ { for j := i + 1; j < len(peers); j++ { if peers[i].LastSeen.Before(peers[j].LastSeen) { diff --git a/cmd/localgo/cmd/discover.go b/cmd/localgo/cmd/discover.go index 579ff5e..aa41b37 100644 --- a/cmd/localgo/cmd/discover.go +++ b/cmd/localgo/cmd/discover.go @@ -61,8 +61,12 @@ var discoverCmd = &cobra.Command{ discoverySvc.AddDeviceHandler(func(device *model.Device) { if !discoverquiet { - zap.S().Infof("Found: %s (%s) [%s] Port: %d", device.Alias, device.IP, device.Protocol, device.Port) - cli.PrintSuccess("Found: %s (%s) [%s] Port: %d", device.Alias, device.IP, device.Protocol, device.Port) + alias := device.Alias + if Cfg.Private { + alias = cli.AnonymizedAlias(device) + } + zap.S().Infof("Found: %s (%s) [%s] Port: %d", alias, device.IP, device.Protocol, device.Port) + cli.PrintSuccess("Found: %s (%s) [%s] Port: %d", alias, device.IP, device.Protocol, device.Port) } }) diff --git a/cmd/localgo/cmd/history.go b/cmd/localgo/cmd/history.go index 0dc65a4..3b2772e 100644 --- a/cmd/localgo/cmd/history.go +++ b/cmd/localgo/cmd/history.go @@ -97,6 +97,10 @@ var historyCmd = &cobra.Command{ fmt.Println(mutedStyle.Render(strings.Repeat("-", 80))) for _, entry := range displayEntries { + senderAlias := entry.SenderAlias + if Cfg.Private { + senderAlias = cli.AnonymizeString(entry.SenderAlias) + } tStr := entry.Timestamp.Local().Format("01-02 15:04") statusColored := entry.Status @@ -111,7 +115,7 @@ var historyCmd = &cobra.Command{ fmt.Printf("%s %s %s %s %s\n", padRight(mutedStyle.Render(tStr), colWidths[0]), - padRight(rowStyle.Render(cli.TruncateString(entry.SenderAlias, 14)), colWidths[1]), + padRight(rowStyle.Render(cli.TruncateString(senderAlias, 14)), colWidths[1]), padRight(rowStyle.Render(cli.TruncateString(entry.FileName, 23)), colWidths[2]), padRight(rowStyle.Render(cli.FormatBytes(entry.FileSize)), colWidths[3]), padRight(statusColored, colWidths[4]), diff --git a/cmd/localgo/cmd/root.go b/cmd/localgo/cmd/root.go index aff110e..745fa1f 100644 --- a/cmd/localgo/cmd/root.go +++ b/cmd/localgo/cmd/root.go @@ -12,7 +12,10 @@ import ( "go.uber.org/zap" ) -var versionFlag bool +var ( + versionFlag bool + privateMode bool +) var ( cfgFile string @@ -50,6 +53,10 @@ var rootCmd = &cobra.Command{ return fmt.Errorf("security context is missing after loading config") } + if privateMode { + Cfg.Private = true + } + return nil }, } @@ -62,6 +69,7 @@ func Execute() { func init() { rootCmd.PersistentFlags().BoolVar(&versionFlag, "version", false, "Show version information") + rootCmd.PersistentFlags().BoolVarP(&privateMode, "private", "p", false, "Hide device identity (alias, model) during discovery and transfer") rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/localgo/config.yaml)") rootCmd.PersistentFlags().BoolVar(&Verbose, "verbose", false, "Enable debug logging") rootCmd.PersistentFlags().BoolVar(&JSONOutput, "json", false, "Enable JSON log output") diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index ba87fc4..ef23a45 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -152,7 +152,7 @@ var sendCmd = &cobra.Command{ return fmt.Errorf("no devices found on the network via multicast or subnet scan") } - selected := cli.PickDevice(devices) + selected := cli.PickDevice(devices, Cfg.Private) if selected == nil { return fmt.Errorf("no device selected") } @@ -178,7 +178,11 @@ var sendCmd = &cobra.Command{ } } cli.PrintInfo("To: %s", target) - cli.PrintInfo("From: %s", Cfg.Alias) + fromAlias := Cfg.Alias + if Cfg.Private { + fromAlias = "Anonymous" + } + cli.PrintInfo("From: %s", fromAlias) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(sendtimeout)*time.Second) defer cancel() diff --git a/cmd/localgo/cmd/serve.go b/cmd/localgo/cmd/serve.go index 929075c..f6b343c 100644 --- a/cmd/localgo/cmd/serve.go +++ b/cmd/localgo/cmd/serve.go @@ -87,12 +87,18 @@ var serveCmd = &cobra.Command{ protocol = "HTTP" } + displayAlias := Cfg.Alias + if Cfg.Private { + displayAlias = "Anonymous" + } + zap.S().Infof("Starting LocalGo server") - zap.S().Infof("Alias: %s", Cfg.Alias) + zap.S().Infof("Alias: %s", displayAlias) zap.S().Infof("Protocol: %s", protocol) + if !servequiet { cli.PrintHeader("Starting LocalGo server") - cli.PrintInfo("Alias: %s", Cfg.Alias) + cli.PrintInfo("Alias: %s", displayAlias) cli.PrintInfo("Protocol: %s", protocol) cli.PrintInfo("Port: %d", Cfg.Port) cli.PrintInfo("Download Directory: %s", Cfg.DownloadDir) @@ -131,8 +137,12 @@ var serveCmd = &cobra.Command{ discoverySvc.AddDeviceHandler(func(device *model.Device) { if !servequiet { - zap.S().Infof("Device discovered: %s (%s)", device.Alias, device.IP) - cli.PrintSuccess("Device discovered: %s (%s)", device.Alias, device.IP) + alias := device.Alias + if Cfg.Private { + alias = cli.AnonymizedAlias(device) + } + zap.S().Infof("Device discovered: %s (%s)", alias, device.IP) + cli.PrintSuccess("Device discovered: %s (%s)", alias, device.IP) } }) diff --git a/cmd/localgo/cmd/share.go b/cmd/localgo/cmd/share.go index 20d34ef..3f31a2c 100644 --- a/cmd/localgo/cmd/share.go +++ b/cmd/localgo/cmd/share.go @@ -90,9 +90,14 @@ var shareCmd = &cobra.Command{ protocol = "HTTP" } + displayAlias := Cfg.Alias + if Cfg.Private { + displayAlias = "Anonymous" + } + if !sharequiet { cli.PrintHeader("Starting LocalGo Web Share") - cli.PrintInfo("Alias: %s", Cfg.Alias) + cli.PrintInfo("Alias: %s", displayAlias) cli.PrintInfo("Protocol: %s", protocol) cli.PrintInfo("Port: %d", Cfg.Port) } diff --git a/cmd/localgo/cmd/utils.go b/cmd/localgo/cmd/utils.go index 53e8401..353aa58 100644 --- a/cmd/localgo/cmd/utils.go +++ b/cmd/localgo/cmd/utils.go @@ -18,7 +18,21 @@ func padRight(str string, length int) string { return str + strings.Repeat(" ", length-len(plain)) } +// anonymizeDeviceSlice returns a copy of devices with anonymized aliases for private mode. +func anonymizeDeviceSlice(devices []*model.Device) []*model.Device { + out := make([]*model.Device, len(devices)) + for i, d := range devices { + copy := *d + copy.Alias = cli.AnonymizedAlias(d) + out[i] = © + } + return out +} + func displayDevices(devices []*model.Device, jsonOutput bool, quiet bool, method string) error { + if Cfg != nil && Cfg.Private { + devices = anonymizeDeviceSlice(devices) + } format := cli.FormatTable if jsonOutput { format = cli.FormatJSON diff --git a/pkg/cli/output.go b/pkg/cli/output.go index 01a09be..eff89af 100644 --- a/pkg/cli/output.go +++ b/pkg/cli/output.go @@ -1,6 +1,7 @@ package cli import ( + "crypto/sha256" "encoding/json" "fmt" "os" @@ -211,6 +212,24 @@ func (ow *OutputWriter) writeJSON(data interface{}) error { // Helper functions +// AnonymizedAlias returns a stable "Device #XXXXXXXX" identifier from a device's fingerprint. +func AnonymizedAlias(device *model.Device) string { + if device == nil || device.Fingerprint == "" { + return "Device #00000000" + } + h := sha256.Sum256([]byte(device.Fingerprint)) + return fmt.Sprintf("Device #%08x", h[:4]) +} + +// AnonymizeString returns a stable "Device #XXXXXXXX" identifier from any string. +func AnonymizeString(s string) string { + if s == "" { + return "Device #00000000" + } + h := sha256.Sum256([]byte(s)) + return fmt.Sprintf("Device #%08x", h[:4]) +} + // TruncateString truncates a string to maxLen characters func TruncateString(s string, maxLen int) string { if len(s) <= maxLen { @@ -335,7 +354,8 @@ func IsContainer() bool { } // PickDevice presents an interactive TUI to select a device. Returns the selected device or nil if canceled. -func PickDevice(devices []*model.Device) *model.Device { +// When private is true, device aliases are anonymized in the selection list. +func PickDevice(devices []*model.Device, private bool) *model.Device { if IsContainer() { return nil } @@ -349,9 +369,13 @@ func PickDevice(devices []*model.Device) *model.Device { var selected *model.Device options := make([]huh.Option[*model.Device], len(devices)) for i, d := range devices { + displayName := d.Alias + if private { + displayName = AnonymizedAlias(d) + } protocol := strings.ToUpper(string(d.Protocol)) options[i] = huh.NewOption( - fmt.Sprintf("%s %s:%d [%s]", d.Alias, d.IP, d.Port, protocol), + fmt.Sprintf("%s %s:%d [%s]", displayName, d.IP, d.Port, protocol), d, ) } diff --git a/pkg/cli/progress.go b/pkg/cli/progress.go index 581e70e..7c1d982 100644 --- a/pkg/cli/progress.go +++ b/pkg/cli/progress.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "os" + "sync" "github.com/vbauerster/mpb/v7" "github.com/vbauerster/mpb/v7/decor" @@ -11,6 +12,8 @@ import ( type MultiProgress struct { pool *mpb.Progress barCount int64 + bars []*mpb.Bar + mu sync.Mutex } func NewMultiProgress(totalFiles int64) *MultiProgress { @@ -36,12 +39,25 @@ func (mp *MultiProgress) AddBar(name string, size int64) func(int64) { decor.Percentage(decor.WC{W: 5}), ), ) + bar.EnableTriggerComplete() + + mp.mu.Lock() + mp.bars = append(mp.bars, bar) + mp.mu.Unlock() return func(current int64) { bar.SetCurrent(current) } } +func (mp *MultiProgress) ForceComplete() { + mp.mu.Lock() + defer mp.mu.Unlock() + for _, bar := range mp.bars { + bar.SetTotal(0, true) + } +} + func (mp *MultiProgress) Wait() { mp.pool.Wait() // Clear progress bar lines from scrollback diff --git a/pkg/config/config.go b/pkg/config/config.go index 703b40a..20fb6c7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -43,6 +43,7 @@ type Config struct { OpenDir bool `json:"-"` // open download directory after transfer Concurrency int `json:"-"` // max parallel uploads (0 = use default) MulticastInterface string `json:"-"` // multicast network interface name + Private bool `json:"-"` // anonymize device identities } // getSecurityDir determines the best location for the security directory @@ -239,11 +240,19 @@ func (c *Config) ToRegisterDto() model.RegisterDto { protocol = model.ProtocolTypeHTTPS fingerprint = c.SecurityContext.CertificateHash } + alias := c.Alias + deviceModel := c.DeviceModel + deviceType := c.DeviceType + if c.Private { + alias = "Anonymous" + deviceModel = nil + deviceType = model.DeviceTypeOther + } return model.RegisterDto{ - Alias: c.Alias, - Version: ProtocolVersion, // Use constant from this package - DeviceModel: c.DeviceModel, - DeviceType: c.DeviceType, + Alias: alias, + Version: ProtocolVersion, + DeviceModel: deviceModel, + DeviceType: deviceType, Fingerprint: fingerprint, Port: c.Port, Protocol: protocol, @@ -257,11 +266,19 @@ func (c *Config) ToInfoDto() model.InfoDto { if c.HttpsEnabled { fingerprint = c.SecurityContext.CertificateHash } + alias := c.Alias + deviceModel := c.DeviceModel + deviceType := c.DeviceType + if c.Private { + alias = "Anonymous" + deviceModel = nil + deviceType = model.DeviceTypeOther + } return model.InfoDto{ - Alias: c.Alias, + Alias: alias, Version: ProtocolVersion, - DeviceModel: c.DeviceModel, - DeviceType: c.DeviceType, + DeviceModel: deviceModel, + DeviceType: deviceType, Fingerprint: fingerprint, Download: true, } diff --git a/pkg/config/dto.go b/pkg/config/dto.go index f890d9d..2a2b9e4 100644 --- a/pkg/config/dto.go +++ b/pkg/config/dto.go @@ -20,11 +20,19 @@ func (c *Config) GetFingerprint() string { // ToMulticastDto creates a MulticastDto from the current configuration. func (c *Config) ToMulticastDto(download bool) model.MulticastDto { + alias := c.Alias + deviceModel := c.DeviceModel + deviceType := c.DeviceType + if c.Private { + alias = "Anonymous" + deviceModel = nil + deviceType = model.DeviceTypeOther + } return model.MulticastDto{ - Alias: c.Alias, + Alias: alias, Version: ProtocolVersion, - DeviceModel: c.DeviceModel, - DeviceType: c.DeviceType, + DeviceModel: deviceModel, + DeviceType: deviceType, Fingerprint: c.GetFingerprint(), Port: c.Port, Protocol: c.Protocol(), diff --git a/pkg/metadata/strip.go b/pkg/metadata/strip.go new file mode 100644 index 0000000..15c626e --- /dev/null +++ b/pkg/metadata/strip.go @@ -0,0 +1,145 @@ +package metadata + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "os" + "path/filepath" +) + +// Strip strips metadata (EXIF, text chunks) from image files in place +// by writing a stripped copy to a temp file and replacing the original. +// Supported formats: JPEG, PNG. +func Strip(path string) error { + ext := filepath.Ext(path) + switch ext { + case ".jpg", ".jpeg": + return stripJPEG(path) + case ".png": + return stripPNG(path) + } + return nil +} + +// stripJPEG removes APP1 (EXIF) and APP13 (Photoshop/IPTC) markers. +func stripJPEG(path string) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("strip: open: %w", err) + } + defer f.Close() + + var buf bytes.Buffer + if _, err := io.Copy(&buf, f); err != nil { + return fmt.Errorf("strip: read: %w", err) + } + f.Close() + + data := buf.Bytes() + + // Must start with SOI marker 0xFFD8 + if len(data) < 2 || data[0] != 0xFF || data[1] != 0xD8 { + return nil // not a valid JPEG + } + + var out bytes.Buffer + out.Write(data[:2]) // SOI + + pos := 2 + for pos < len(data) { + if data[pos] != 0xFF { + break + } + + marker := data[pos+1] + + // SOS (Start of Scan) — everything after is compressed data, keep as-is + if marker == 0xDA { + out.Write(data[pos:]) + break + } + + // Markers without length: SOI (0xD8), EOI (0xD9), TEM (0x01) + if marker == 0xD9 || marker == 0x00 || marker == 0x01 { + out.Write(data[pos : pos+2]) + pos += 2 + if marker == 0xD9 { + break + } + continue + } + + // All other markers have a 2-byte length (big-endian, includes itself) + if pos+3 >= len(data) { + break + } + segLen := int(binary.BigEndian.Uint16(data[pos+2:pos+4])) + 2 + + if pos+segLen > len(data) { + break + } + + // Skip APP1 (EXIF, 0xFFE1) and APP13 (Photoshop/IPTC, 0xFFED) + if marker != 0xE1 && marker != 0xED { + out.Write(data[pos : pos+segLen]) + } + + pos += segLen + } + + return os.WriteFile(path, out.Bytes(), 0644) +} + +// stripPNG removes tEXt, zTXt, and iTXt metadata chunks. +func stripPNG(path string) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("strip: open: %w", err) + } + defer f.Close() + + var buf bytes.Buffer + if _, err := io.Copy(&buf, f); err != nil { + return fmt.Errorf("strip: read: %w", err) + } + f.Close() + + data := buf.Bytes() + + // Must be a valid PNG: 8-byte signature + pngSig := []byte{137, 80, 78, 71, 13, 10, 26, 10} + if len(data) < 8 || !bytes.Equal(data[:8], pngSig) { + return nil + } + + var out bytes.Buffer + out.Write(data[:8]) // signature + + pos := 8 + for pos+4 <= len(data) { + chunkLen := int(binary.BigEndian.Uint32(data[pos : pos+4])) + if pos+12+chunkLen > len(data) { + break + } + chunkType := string(data[pos+4 : pos+8]) + + // Skip text chunks + if chunkType == "tEXt" || chunkType == "zTXt" || chunkType == "iTXt" { + pos += 12 + chunkLen + continue + } + + // IEND — end of image + if chunkType == "IEND" { + out.Write(data[pos : pos+12+chunkLen]) + break + } + + out.Write(data[pos : pos+12+chunkLen]) + pos += 12 + chunkLen + } + + return os.WriteFile(path, out.Bytes(), 0644) +} diff --git a/pkg/send/send.go b/pkg/send/send.go index ace99b2..306c611 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -20,6 +20,7 @@ import ( "github.com/bethropolis/localgo/pkg/cli" "github.com/bethropolis/localgo/pkg/config" "github.com/bethropolis/localgo/pkg/discovery" + "github.com/bethropolis/localgo/pkg/metadata" "github.com/bethropolis/localgo/pkg/model" "github.com/bethropolis/localgo/pkg/network" "github.com/charmbracelet/huh" @@ -127,7 +128,7 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci if err := verifyDeviceFingerprint(peerCache, targetDevice); err != nil { return err } - return sendToDevice(ctx, cfg, targetDevice, filePaths, logger) + return SendToDevice(ctx, cfg, targetDevice, filePaths, logger) } registerDto := model.RegisterDto{ @@ -180,7 +181,7 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci return err } - return sendToDevice(ctx, cfg, targetDevice, filePaths, logger) + return SendToDevice(ctx, cfg, targetDevice, filePaths, logger) } // verifyDeviceFingerprint checks if a cached fingerprint differs from the target's @@ -217,7 +218,53 @@ func verifyDeviceFingerprint(peerCache *discovery.PeerCache, targetDevice *model return nil } -func sendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, filePaths []string, logger *zap.SugaredLogger) error { +// anonymizeFileName maps a MIME type to a generic filename for private mode. +func anonymizeFileName(contentType string) string { + switch { + case strings.HasPrefix(contentType, "image/jpeg"): + return "image.jpg" + case strings.HasPrefix(contentType, "image/png"): + return "image.png" + case strings.HasPrefix(contentType, "image/webp"): + return "image.webp" + case strings.HasPrefix(contentType, "image/"): + return "image.bin" + case strings.HasPrefix(contentType, "video/mp4"): + return "video.mp4" + case strings.HasPrefix(contentType, "video/webm"): + return "video.webm" + case strings.HasPrefix(contentType, "video/x-matroska"): + return "video.mkv" + case strings.HasPrefix(contentType, "video/quicktime"): + return "video.mov" + case strings.HasPrefix(contentType, "video/"): + return "video.bin" + case strings.HasPrefix(contentType, "audio/"): + return "audio.mp3" + case strings.HasPrefix(contentType, "text/plain"): + return "document.txt" + case strings.HasPrefix(contentType, "text/html"): + return "document.html" + case strings.HasPrefix(contentType, "text/"): + return "document.txt" + case contentType == "application/pdf": + return "document.pdf" + case strings.HasPrefix(contentType, "application/zip"): + return "archive.zip" + case strings.HasPrefix(contentType, "application/gzip"): + return "archive.tar.gz" + case strings.HasPrefix(contentType, "application/x-tar"): + return "archive.tar" + case strings.HasPrefix(contentType, "application/x-"): + return "archive.bin" + case strings.HasPrefix(contentType, "application/"): + return "document.bin" + default: + return "file.bin" + } +} + +func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, filePaths []string, logger *zap.SugaredLogger) error { if logger == nil { logger = zap.NewNop().Sugar() } @@ -239,6 +286,15 @@ func sendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, return fmt.Errorf("failed to process file paths: %w", err) } + // Strip EXIF/metadata from image files in private mode + if cfg.Private { + for filePath := range fileMap { + if err := metadata.Strip(filePath); err != nil { + logger.Warnf("Failed to strip metadata from %s: %v", filePath, err) + } + } + } + filesDtoMap := make(map[string]model.FileDto) filePathMap := make(map[string]string) @@ -258,6 +314,10 @@ func sendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, file.Close() contentType := http.DetectContentType(buffer[:n]) + if cfg.Private { + remoteName = anonymizeFileName(contentType) + } + // If this is a temporary clipboard file, sanitize display name to text_transfer.txt if strings.HasPrefix(filepath.Base(filePath), "localgo-clip-") { remoteName = "text_transfer.txt" @@ -266,26 +326,38 @@ func sendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, modTime := fileInfo.ModTime().Format(time.RFC3339) + var metadataPtr *model.FileMetadata + if !cfg.Private { + metadataPtr = &model.FileMetadata{Modified: &modTime} + } + fileDto := model.FileDto{ ID: uuid.NewString(), FileName: remoteName, Size: fileInfo.Size(), FileType: contentType, - Metadata: &model.FileMetadata{ - Modified: &modTime, - }, + Metadata: metadataPtr, } filesDtoMap[fileDto.ID] = fileDto filePathMap[fileDto.ID] = filePath } + infoAlias := cfg.Alias + infoDeviceModel := cfg.DeviceModel + infoDeviceType := cfg.DeviceType + if cfg.Private { + infoAlias = "Anonymous" + infoDeviceModel = nil + infoDeviceType = model.DeviceTypeOther + } + prepareDto := model.PrepareUploadRequestDto{ Info: model.InfoDto{ - Alias: cfg.Alias, + Alias: infoAlias, Version: config.ProtocolVersion, - DeviceModel: cfg.DeviceModel, - DeviceType: cfg.DeviceType, + DeviceModel: infoDeviceModel, + DeviceType: infoDeviceType, Fingerprint: cfg.SecurityContext.CertificateHash, Download: true, }, @@ -360,6 +432,7 @@ func sendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, } wg.Wait() + mp.ForceComplete() mp.Wait() close(errCh) diff --git a/pkg/send/send_error_test.go b/pkg/send/send_error_test.go index 126a59f..32e5f25 100644 --- a/pkg/send/send_error_test.go +++ b/pkg/send/send_error_test.go @@ -57,9 +57,9 @@ func TestSendFiles_UploadRejection(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - err := sendToDevice(ctx, cfg, device, []string{filePath}, testLoggerSendErrors) + err := SendToDevice(ctx, cfg, device, []string{filePath}, testLoggerSendErrors) if err == nil { - t.Fatalf("expected sendToDevice to fail on rejection, but it succeeded") + t.Fatalf("expected SendToDevice to fail on rejection, but it succeeded") } if !strings.Contains(err.Error(), "403 Forbidden") { @@ -122,7 +122,7 @@ func TestSendFiles_PartialUploadError(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - err := sendToDevice(ctx, cfg, device, []string{filePath1, filePath2}, testLoggerSendErrors) + err := SendToDevice(ctx, cfg, device, []string{filePath1, filePath2}, testLoggerSendErrors) if err == nil { t.Fatalf("expected upload to fail, but it succeeded") } diff --git a/pkg/send/send_test.go b/pkg/send/send_test.go index c456e0b..39248a0 100644 --- a/pkg/send/send_test.go +++ b/pkg/send/send_test.go @@ -109,8 +109,8 @@ func TestSendFiles_HappyPath(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - err := sendToDevice(ctx, cfg, device, []string{filePath}, testLoggerSend) + err := SendToDevice(ctx, cfg, device, []string{filePath}, testLoggerSend) if err != nil { - t.Fatalf("sendToDevice failed: %v", err) + t.Fatalf("SendToDevice failed: %v", err) } } diff --git a/pkg/server/handlers/discovery_handlers.go b/pkg/server/handlers/discovery_handlers.go index e211b79..70386d3 100644 --- a/pkg/server/handlers/discovery_handlers.go +++ b/pkg/server/handlers/discovery_handlers.go @@ -48,11 +48,20 @@ func (h *DiscoveryHandler) InfoHandler(w http.ResponseWriter, r *http.Request) { downloadCapable := h.sendService.GetSession() != nil // True if we have an active send session + alias := h.config.Alias + deviceModel := h.config.DeviceModel + deviceType := h.config.DeviceType + if h.config.Private { + alias = "Anonymous" + deviceModel = nil + deviceType = model.DeviceTypeOther + } + dto := model.InfoDto{ - Alias: h.config.Alias, + Alias: alias, Version: config.ProtocolVersion, - DeviceModel: h.config.DeviceModel, - DeviceType: h.config.DeviceType, + DeviceModel: deviceModel, + DeviceType: deviceType, Fingerprint: h.config.SecurityContext.CertificateHash, Download: downloadCapable, } @@ -101,11 +110,20 @@ func (h *DiscoveryHandler) RegisterHandler(w http.ResponseWriter, r *http.Reques downloadCapable := h.sendService.GetSession() != nil + respAlias := h.config.Alias + respDeviceModel := h.config.DeviceModel + respDeviceType := h.config.DeviceType + if h.config.Private { + respAlias = "Anonymous" + respDeviceModel = nil + respDeviceType = model.DeviceTypeOther + } + responseDto := model.InfoDto{ - Alias: h.config.Alias, + Alias: respAlias, Version: config.ProtocolVersion, - DeviceModel: h.config.DeviceModel, - DeviceType: h.config.DeviceType, + DeviceModel: respDeviceModel, + DeviceType: respDeviceType, Fingerprint: h.config.SecurityContext.CertificateHash, Download: downloadCapable, } diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index c788b13..df13aff 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -213,7 +213,15 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) var trackProgress func(int64) if !h.config.Quiet && session.Progress != nil { - trackProgress = session.Progress.AddBar(fileInfo.Dto.FileName, fileInfo.Dto.Size) + displayName := fileInfo.Dto.FileName + if fileInfo.Dto.Preview != nil && *fileInfo.Dto.Preview != "" { + preview := *fileInfo.Dto.Preview + if len(preview) > 20 { + preview = preview[:20] + "…" + } + displayName = preview + } + trackProgress = session.Progress.AddBar(displayName, fileInfo.Dto.Size) } // --- Progress Callback --- diff --git a/pkg/server/services/receive_service.go b/pkg/server/services/receive_service.go index 05ce17e..9a65fe8 100644 --- a/pkg/server/services/receive_service.go +++ b/pkg/server/services/receive_service.go @@ -46,6 +46,10 @@ func (s *ReceiveService) cleanupLoop() { s.sessionMutex.Lock() for id, session := range s.sessions { if time.Since(session.CreatedAt) > 10*time.Minute { + if session.Progress != nil { + session.Progress.ForceComplete() + go session.Progress.Wait() + } delete(s.sessions, id) } } @@ -126,6 +130,7 @@ func (s *ReceiveService) CloseSession(sessionID string) { defer s.sessionMutex.Unlock() if session, ok := s.sessions[sessionID]; ok { if session.Progress != nil { + session.Progress.ForceComplete() session.Progress.Wait() } delete(s.sessions, sessionID) @@ -149,6 +154,7 @@ func (s *ReceiveService) RemoveFileFromSession(sessionID, fileID string) { // Gracefully stop the progress bar rendering goroutine when the session ends if sessionEmpty && session.Progress != nil { + session.Progress.ForceComplete() go session.Progress.Wait() } } From be29c69684609ed5705e335446890ab8d7f82c51 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Fri, 29 May 2026 14:47:12 -0400 Subject: [PATCH 02/45] feat: add send --ip, scan --range flags, ParseCIDRRange, export SendToDevice --- cmd/localgo/cmd/scan.go | 44 +++++++++++++++++--------- cmd/localgo/cmd/send.go | 66 +++++++++++++++++++++++++++++++++++++++ pkg/help/help.go | 5 +++ pkg/network/interfaces.go | 29 +++++++++++++++++ 4 files changed, 130 insertions(+), 14 deletions(-) diff --git a/cmd/localgo/cmd/scan.go b/cmd/localgo/cmd/scan.go index 3b9d2c8..637bc43 100644 --- a/cmd/localgo/cmd/scan.go +++ b/cmd/localgo/cmd/scan.go @@ -17,6 +17,7 @@ import ( ) var ( + scanrange string scantimeout int scanport int scanjsonOutput bool @@ -39,22 +40,36 @@ var scanCmd = &cobra.Command{ scanPort = scanport } - // Get local IPs - localIPs, err := network.GetLocalIPAddresses() - if err != nil { - return fmt.Errorf("failed to get local network IPs: %w", err) - } - var ips []net.IP - for _, ip := range localIPs { - subnetIPs := network.GetSubnetIPs(ip) - ips = append(ips, subnetIPs...) - } - if !scanquiet { - cli.PrintHeader(fmt.Sprintf("Scanning network on port %d (timeout: %ds)...", scanPort, scanTimeout)) - cli.PrintInfo("Scanning %d IP addresses (derived from %d local interfaces)...", len(ips), len(localIPs)) - cli.PrintInfo("Protocols: HTTPS first, then HTTP fallback") + if scanrange != "" { + parsedIPs, err := network.ParseCIDRRange(scanrange) + if err != nil { + return fmt.Errorf("invalid --range CIDR: %w", err) + } + ips = parsedIPs + if !scanquiet { + cli.PrintHeader(fmt.Sprintf("Scanning CIDR range %s on port %d (timeout: %ds)...", scanrange, scanPort, scanTimeout)) + cli.PrintInfo("Scanning %d IP addresses...", len(ips)) + cli.PrintInfo("Protocols: HTTPS first, then HTTP fallback") + } + } else { + // Get local IPs + localIPs, err := network.GetLocalIPAddresses() + if err != nil { + return fmt.Errorf("failed to get local network IPs: %w", err) + } + + for _, ip := range localIPs { + subnetIPs := network.GetSubnetIPs(ip) + ips = append(ips, subnetIPs...) + } + + if !scanquiet { + cli.PrintHeader(fmt.Sprintf("Scanning network on port %d (timeout: %ds)...", scanPort, scanTimeout)) + cli.PrintInfo("Scanning %d IP addresses (derived from %d local interfaces)...", len(ips), len(localIPs)) + cli.PrintInfo("Protocols: HTTPS first, then HTTP fallback") + } } // Initialize HTTP discovery @@ -94,6 +109,7 @@ var scanCmd = &cobra.Command{ func init() { rootCmd.AddCommand(scanCmd) + scanCmd.Flags().StringVar(&scanrange, "range", "", "CIDR range to scan (e.g. 192.168.1.0/24)") scanCmd.Flags().IntVar(&scantimeout, "timeout", 15, "Scan timeout in seconds") scanCmd.Flags().IntVar(&scanport, "port", 0, "Port to scan") scanCmd.Flags().BoolVar(&scanjsonOutput, "json", false, "Output in JSON format") diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index ef23a45..2bf4204 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -6,6 +6,7 @@ import ( "net" "os" "path/filepath" + "strconv" "strings" "time" @@ -24,6 +25,7 @@ import ( var ( sendfiles []string + sendip string sendto string sendport int sendtimeout int @@ -99,6 +101,69 @@ var sendCmd = &cobra.Command{ } } + // Direct send via --ip: skip discovery entirely + if sendip != "" { + host, portStr, err := net.SplitHostPort(sendip) + if err != nil { + // No port specified; treat whole string as host + host = sendip + portStr = "" + } + parsedIP := net.ParseIP(host) + if parsedIP == nil { + return fmt.Errorf("invalid IP address: %s", host) + } + + port := sendport + if portStr != "" { + p, err := strconv.Atoi(portStr) + if err != nil { + return fmt.Errorf("invalid port in --ip: %w", err) + } + port = p + } + if port == 0 { + port = Cfg.Port + } + + device := &model.Device{ + Alias: host, + IP: parsedIP.String(), + Port: port, + } + + if sendalias != "" { + Cfg.Alias = sendalias + } + if sendconcurrency > 0 { + Cfg.Concurrency = sendconcurrency + } + + cli.PrintHeader(fmt.Sprintf("Sending %d files", len(files))) + for _, file := range files { + fileInfo, err := os.Stat(file) + if err == nil { + cli.PrintInfo("- %s (%s)", filepath.Base(file), cli.FormatBytes(fileInfo.Size())) + } + } + cli.PrintInfo("To: %s:%d", host, port) + fromAlias := Cfg.Alias + if Cfg.Private { + fromAlias = "Anonymous" + } + cli.PrintInfo("From: %s", fromAlias) + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(sendtimeout)*time.Second) + defer cancel() + + if err := send.SendToDevice(ctx, Cfg, device, files, zap.S()); err != nil { + return fmt.Errorf("failed to send files: %w", err) + } + + cli.PrintSuccess("Files sent successfully!") + return nil + } + target := sendto if target == "" { sendConfig := discovery.DefaultServiceConfig() @@ -200,6 +265,7 @@ var sendCmd = &cobra.Command{ func init() { rootCmd.AddCommand(sendCmd) sendCmd.Flags().StringSliceVar(&sendfiles, "file", []string{}, "File or directory to send") + sendCmd.Flags().StringVar(&sendip, "ip", "", "Target device IP (with optional :port, skips discovery)") sendCmd.Flags().StringVar(&sendto, "to", "", "Target device alias (omit to pick interactively)") sendCmd.Flags().IntVar(&sendport, "port", 0, "Target device port") sendCmd.Flags().IntVar(&sendtimeout, "timeout", 30, "Send timeout in seconds") diff --git a/pkg/help/help.go b/pkg/help/help.go index ca592b7..91bc3c6 100644 --- a/pkg/help/help.go +++ b/pkg/help/help.go @@ -231,8 +231,10 @@ func GetCommandHelp(commandName string) *CommandHelp { "localgo scan --port 8080 --timeout 30", "localgo scan --json", "localgo scan --quiet", + "localgo scan --range 192.168.1.0/24", }, Flags: []FlagHelp{ + {Name: "--range", Type: "string", Default: "", Description: "CIDR range to scan (e.g. 192.168.1.0/24)"}, {Name: "--timeout", Type: "int", Default: "15", Description: "Scan timeout in seconds"}, {Name: "--port", Type: "int", Default: "from config", Description: "Port to scan"}, {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, @@ -245,12 +247,15 @@ func GetCommandHelp(commandName string) *CommandHelp { Usage: "localgo send [OPTIONS]", Examples: []string{ "localgo send --file document.pdf --to MyPhone", + "localgo send --ip 192.168.1.42 --file document.pdf", + "localgo send --ip 192.168.1.42:53317 --file document.pdf", "localgo send --clipboard --to MyPhone", "localgo send -c --to MyPhone", "localgo send (starts interactive clipboard or file picker if empty)", }, Flags: []FlagHelp{ {Name: "--file", Type: "string", Default: "", Description: "File or directory to send (optional, can be specified multiple times)"}, + {Name: "--ip", Type: "string", Default: "", Description: "Target device IP (with optional :port, skips discovery)"}, {Name: "--to", Type: "string", Default: "", Description: "Target device alias (omit to pick interactively)"}, {Name: "--clipboard, -c", Type: "bool", Default: "false", Description: "Send current system clipboard text directly"}, {Name: "--port", Type: "int", Default: "auto-detect", Description: "Target device port"}, diff --git a/pkg/network/interfaces.go b/pkg/network/interfaces.go index ce48802..99dab9f 100644 --- a/pkg/network/interfaces.go +++ b/pkg/network/interfaces.go @@ -115,6 +115,35 @@ func GetPreferredOutboundIP() (net.IP, error) { return localAddr.IP, nil } +// ParseCIDRRange parses a CIDR notation (e.g. "192.168.1.0/24") and returns +// all usable host IPs in that range (network and broadcast addresses excluded). +func ParseCIDRRange(cidr string) ([]net.IP, error) { + _, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err) + } + + ip4 := ipnet.IP.To4() + if ip4 == nil { + return nil, fmt.Errorf("CIDR %q is not an IPv4 range", cidr) + } + + ones, bits := ipnet.Mask.Size() + hostBits := bits - ones + if hostBits < 2 || hostBits > 30 { + return nil, fmt.Errorf("CIDR %q prefix length must be /8–/30", cidr) + } + + base := uint32(ip4[0])<<24 | uint32(ip4[1])<<16 | uint32(ip4[2])<<8 | uint32(ip4[3]) + totalHosts := (1 << hostBits) - 2 + var ips []net.IP + for i := 1; i <= totalHosts; i++ { + addr := base + uint32(i) + ips = append(ips, net.IPv4(byte(addr>>24), byte(addr>>16), byte(addr>>8), byte(addr))) + } + return ips, nil +} + // GetSubnetIPs returns all IP addresses in the same /24 subnet as the given IP func GetSubnetIPs(ip net.IP) []net.IP { ip4 := ip.To4() From c01ef5801e02720da0d7106fe54ccef5c298e2c2 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:34:58 +0300 Subject: [PATCH 03/45] fix: docker-start passes CMD args correctly (no double localgo) --- cmd/localgo/cmd/docker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/localgo/cmd/docker.go b/cmd/localgo/cmd/docker.go index 4b33f62..d700d2f 100644 --- a/cmd/localgo/cmd/docker.go +++ b/cmd/localgo/cmd/docker.go @@ -56,7 +56,7 @@ execs serve directly without any permission changes.`, func execServe(args []string) error { serveArgv := []string{"localgo", "serve"} if len(args) > 0 { - serveArgv = append([]string{"localgo"}, args...) + serveArgv = args } bin, err := os.Executable() From 37be6e8dc0feb9b3c85e03ca3421a7f89d81f369 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:51:14 +0300 Subject: [PATCH 04/45] fix(scratch): set LOCALSEND_DOWNLOAD_DIR and LOCALSEND_SECURITY_DIR env vars --- Dockerfile.scratch | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Dockerfile.scratch b/Dockerfile.scratch index b7e7391..e1415c7 100644 --- a/Dockerfile.scratch +++ b/Dockerfile.scratch @@ -14,9 +14,14 @@ WORKDIR /app ARG TARGETPLATFORM COPY $TARGETPLATFORM/localgo /localgo +# Expose ports (TCP and UDP for discovery) EXPOSE 53317/tcp EXPOSE 53317/udp +# Point config and downloads to mounted volumes +ENV LOCALSEND_DOWNLOAD_DIR="/app/downloads" \ + LOCALSEND_SECURITY_DIR="/app/config" + # OCI labels LABEL org.opencontainers.image.title="LocalGo" LABEL org.opencontainers.image.description="LocalSend v2.1 Protocol Implementation in Go" From b013c88624b85db2821871411a651d958e284384 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:41:05 +0300 Subject: [PATCH 05/45] fix: create discovery DTOs after server binds port --- cmd/localgo/cmd/serve.go | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/cmd/localgo/cmd/serve.go b/cmd/localgo/cmd/serve.go index f6b343c..a162fe4 100644 --- a/cmd/localgo/cmd/serve.go +++ b/cmd/localgo/cmd/serve.go @@ -112,7 +112,24 @@ var serveCmd = &cobra.Command{ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - // Initialize discovery service + // Start server first to determine the actual port + srv := server.NewServer(Cfg, zap.S()) + + serverErrChan := make(chan error, 1) + serverReadyChan := make(chan struct{}, 1) + go func() { + serverErrChan <- srv.Start(ctx, serverReadyChan) + }() + + // Wait for server to be ready (server.Start waits for port bind) + select { + case err := <-serverErrChan: + return fmt.Errorf("server failed: %w", err) + case <-serverReadyChan: + } + + // Initialize discovery service AFTER server is ready (Cfg.Port may have + // changed if the configured port was busy) discoverySvcConfig := discovery.DefaultServiceConfig() discoverySvcConfig.MulticastConfig.Port = Cfg.Port discoverySvcConfig.MulticastConfig.MulticastAddr = fmt.Sprintf("%s:%d", Cfg.MulticastGroup, Cfg.Port) @@ -121,9 +138,8 @@ var serveCmd = &cobra.Command{ if serveinterval > 0 { discoverySvcConfig.AnnounceInterval = time.Duration(serveinterval) * time.Second } - multicastDto := Cfg.ToMulticastDto(false) - multicast := discovery.NewMulticastDiscovery(discoverySvcConfig.MulticastConfig, multicastDto, zap.S()) + multicast := discovery.NewMulticastDiscovery(discoverySvcConfig.MulticastConfig, Cfg.ToMulticastDto(false), zap.S()) // Create HTTPDiscoverer for backchannel (HTTP response to multicast) httpDiscoverer := discovery.NewHTTPDiscovery(nil, Cfg.ToRegisterDto(), nil, zap.S()) @@ -146,23 +162,7 @@ var serveCmd = &cobra.Command{ } }) - // Start server first - srv := server.NewServer(Cfg, zap.S()) - - serverErrChan := make(chan error, 1) - serverReadyChan := make(chan struct{}, 1) - go func() { - serverErrChan <- srv.Start(ctx, serverReadyChan) - }() - - // Wait for server to be ready (server.Start waits for port bind) - select { - case err := <-serverErrChan: - return fmt.Errorf("server failed: %w", err) - case <-serverReadyChan: - } - - // Start discovery AFTER server is ready + // Start discovery err := discoverySvc.Start(ctx, Cfg.Alias, Cfg.Port, Cfg.SecurityContext.CertificateHash, Cfg.DeviceType, Cfg.DeviceModel, Cfg.HttpsEnabled) if err != nil { return fmt.Errorf("discovery service failed: %w", err) From f6ed6a54a32b514304b8882679afc529d8685dd2 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:45:42 +0300 Subject: [PATCH 06/45] fix(scratch): add LOCALSEND_AUTO_ACCEPT=true env var --- Dockerfile.scratch | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile.scratch b/Dockerfile.scratch index e1415c7..2f0a080 100644 --- a/Dockerfile.scratch +++ b/Dockerfile.scratch @@ -20,7 +20,8 @@ EXPOSE 53317/udp # Point config and downloads to mounted volumes ENV LOCALSEND_DOWNLOAD_DIR="/app/downloads" \ - LOCALSEND_SECURITY_DIR="/app/config" + LOCALSEND_SECURITY_DIR="/app/config" \ + LOCALSEND_AUTO_ACCEPT="true" # OCI labels LABEL org.opencontainers.image.title="LocalGo" From 2a8a00b1ad04d0064855c93b2537a5ce27ffaa9b Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:55:23 +0300 Subject: [PATCH 07/45] fix(scratch): add XDG_CACHE_HOME so peer cache is writable --- Dockerfile.scratch | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile.scratch b/Dockerfile.scratch index 2f0a080..a2feedf 100644 --- a/Dockerfile.scratch +++ b/Dockerfile.scratch @@ -21,7 +21,8 @@ EXPOSE 53317/udp # Point config and downloads to mounted volumes ENV LOCALSEND_DOWNLOAD_DIR="/app/downloads" \ LOCALSEND_SECURITY_DIR="/app/config" \ - LOCALSEND_AUTO_ACCEPT="true" + LOCALSEND_AUTO_ACCEPT="true" \ + XDG_CACHE_HOME="/app/config/cache" # OCI labels LABEL org.opencontainers.image.title="LocalGo" From 9144f426c5b25b24bc3eef6e8b65e1d4e5bc67a1 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:31:32 +0300 Subject: [PATCH 08/45] fix(security): PIN constant-time compare, server timeouts, private mode DTO bypass, strip JPEG bounds --- pkg/discovery/quick.go | 34 +++++----------- pkg/discovery/service.go | 51 +++++++----------------- pkg/metadata/strip.go | 19 +++++++-- pkg/send/send.go | 23 ++--------- pkg/server/handlers/download_handlers.go | 3 +- pkg/server/server.go | 7 +++- 6 files changed, 52 insertions(+), 85 deletions(-) diff --git a/pkg/discovery/quick.go b/pkg/discovery/quick.go index 5484234..84bc772 100644 --- a/pkg/discovery/quick.go +++ b/pkg/discovery/quick.go @@ -4,45 +4,33 @@ import ( "context" "time" + "github.com/bethropolis/localgo/pkg/config" "github.com/bethropolis/localgo/pkg/model" ) -func DiscoverDevices(ctx context.Context, cfg *ServiceConfig, alias string, port int, fingerprint string, deviceModel *string, httpsEnabled bool) ([]*model.Device, error) { - if cfg == nil { - cfg = DefaultServiceConfig() +func DiscoverDevices(ctx context.Context, serviceCfg *ServiceConfig, appCfg *config.Config, httpsEnabled bool) ([]*model.Device, error) { + if serviceCfg == nil { + serviceCfg = DefaultServiceConfig() } - protocol := model.ProtocolTypeHTTP - if httpsEnabled { - protocol = model.ProtocolTypeHTTPS - } - - multicastDto := model.MulticastDto{ - Alias: alias, - Version: "2.1", - DeviceModel: deviceModel, - DeviceType: model.DeviceTypeDesktop, - Fingerprint: fingerprint, - Port: port, - Protocol: protocol, - Download: false, - Announce: true, - } + multicastDto := appCfg.ToMulticastDto(false) - multicast := NewMulticastDiscovery(cfg.MulticastConfig, multicastDto, nil) + multicast := NewMulticastDiscovery(serviceCfg.MulticastConfig, multicastDto, nil) peerCache := NewPeerCache(nil) multicast.SetPeerCache(peerCache) - svc := NewService(cfg, multicast, nil) + svc := NewService(serviceCfg, multicast, nil) svc.SetPeerCache(peerCache) - if err := svc.Start(ctx, alias, port, fingerprint, model.DeviceTypeDesktop, deviceModel, httpsEnabled); err != nil { + + + if err := svc.Start(ctx, multicastDto); err != nil { return nil, err } scanCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - return svc.Discover(scanCtx, alias, port, fingerprint, model.DeviceTypeDesktop, deviceModel, httpsEnabled, false) + return svc.Discover(scanCtx, multicastDto) } diff --git a/pkg/discovery/service.go b/pkg/discovery/service.go index 5158b3a..976c210 100644 --- a/pkg/discovery/service.go +++ b/pkg/discovery/service.go @@ -7,7 +7,6 @@ import ( "sync" "time" - "github.com/bethropolis/localgo/pkg/config" "github.com/bethropolis/localgo/pkg/model" "go.uber.org/zap" ) @@ -22,6 +21,8 @@ type Service struct { handlersMutex sync.RWMutex announceTimer *time.Timer peerCache *PeerCache + stopCh chan struct{} + stopOnce sync.Once logger *zap.SugaredLogger } @@ -56,6 +57,7 @@ func NewService(config *ServiceConfig, multicast MulticastDiscoverer, logger *za config: config, devices: make(map[string]*model.Device), multicast: multicast, + stopCh: make(chan struct{}), logger: logger, } @@ -78,24 +80,8 @@ func (s *Service) SetPeerCache(cache *PeerCache) { } // Start initializes and starts the discovery service for listening and periodic announcements -func (s *Service) Start(ctx context.Context, alias string, port int, fingerprint string, deviceType model.DeviceType, deviceModel *string, httpsEnabled bool) error { - protocol := model.ProtocolTypeHTTP - if httpsEnabled { - protocol = model.ProtocolTypeHTTPS - } - multicastDto := model.MulticastDto{ - Alias: alias, - Version: config.ProtocolVersion, - DeviceModel: deviceModel, - DeviceType: deviceType, - Fingerprint: fingerprint, - Port: port, - Protocol: protocol, - Download: true, - Announce: true, - } - - s.multicast.SetDto(multicastDto) +func (s *Service) Start(ctx context.Context, dto model.MulticastDto) error { + s.multicast.SetDto(dto) if err := s.multicast.StartListening(ctx); err != nil { return fmt.Errorf("failed to start multicast discovery: %w", err) @@ -126,6 +112,9 @@ func (s *Service) Start(ctx context.Context, alias string, port int, fingerprint // Stop stops the discovery service func (s *Service) Stop() { s.logger.Debugf("Stopping discovery service...") + s.stopOnce.Do(func() { + close(s.stopCh) + }) if s.multicast != nil { s.multicast.Stop() } @@ -137,26 +126,10 @@ func (s *Service) Stop() { } // Discover performs a discovery scan and returns found devices. -func (s *Service) Discover(ctx context.Context, alias string, port int, fingerprint string, deviceType model.DeviceType, deviceModel *string, httpsEnabled bool, isDownloadServer bool) ([]*model.Device, error) { +func (s *Service) Discover(ctx context.Context, dto model.MulticastDto) ([]*model.Device, error) { s.logger.Debugf("Performing one-off discovery scan...") - protocol := model.ProtocolTypeHTTP - if httpsEnabled { - protocol = model.ProtocolTypeHTTPS - } - multicastDto := model.MulticastDto{ - Alias: alias, - Version: config.ProtocolVersion, - DeviceModel: deviceModel, - DeviceType: deviceType, - Fingerprint: fingerprint, - Port: port, - Protocol: protocol, - Download: isDownloadServer, - Announce: true, - } - - s.multicast.SetDto(multicastDto) + s.multicast.SetDto(dto) // MUST be listening to receive multicast responses if err := s.multicast.StartListening(ctx); err != nil { @@ -254,6 +227,10 @@ func (s *Service) startAnnouncementLoop(ctx context.Context) { for { select { case <-ctx.Done(): + s.announceTimer.Stop() + return + case <-s.stopCh: + s.announceTimer.Stop() return case <-s.announceTimer.C: if err := s.multicast.SendDiscoveryAnnouncement(); err != nil { diff --git a/pkg/metadata/strip.go b/pkg/metadata/strip.go index 15c626e..ceade8f 100644 --- a/pkg/metadata/strip.go +++ b/pkg/metadata/strip.go @@ -31,6 +31,11 @@ func stripJPEG(path string) error { } defer f.Close() + fi, err := f.Stat() + if err != nil { + return fmt.Errorf("strip: stat: %w", err) + } + var buf bytes.Buffer if _, err := io.Copy(&buf, f); err != nil { return fmt.Errorf("strip: read: %w", err) @@ -48,7 +53,7 @@ func stripJPEG(path string) error { out.Write(data[:2]) // SOI pos := 2 - for pos < len(data) { + for pos+1 < len(data) { if data[pos] != 0xFF { break } @@ -63,6 +68,9 @@ func stripJPEG(path string) error { // Markers without length: SOI (0xD8), EOI (0xD9), TEM (0x01) if marker == 0xD9 || marker == 0x00 || marker == 0x01 { + if pos+2 > len(data) { + break + } out.Write(data[pos : pos+2]) pos += 2 if marker == 0xD9 { @@ -89,7 +97,7 @@ func stripJPEG(path string) error { pos += segLen } - return os.WriteFile(path, out.Bytes(), 0644) + return os.WriteFile(path, out.Bytes(), fi.Mode()) } // stripPNG removes tEXt, zTXt, and iTXt metadata chunks. @@ -100,6 +108,11 @@ func stripPNG(path string) error { } defer f.Close() + fi, err := f.Stat() + if err != nil { + return fmt.Errorf("strip: stat: %w", err) + } + var buf bytes.Buffer if _, err := io.Copy(&buf, f); err != nil { return fmt.Errorf("strip: read: %w", err) @@ -141,5 +154,5 @@ func stripPNG(path string) error { pos += 12 + chunkLen } - return os.WriteFile(path, out.Bytes(), 0644) + return os.WriteFile(path, out.Bytes(), fi.Mode()) } diff --git a/pkg/send/send.go b/pkg/send/send.go index 306c611..33aef9b 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -109,7 +109,7 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci multicastCtx, cancelMulticast := context.WithTimeout(ctx, 1500*time.Millisecond) defer cancelMulticast() - err := discoverySvc.Start(multicastCtx, cfg.Alias, cfg.Port, cfg.SecurityContext.CertificateHash, cfg.DeviceType, cfg.DeviceModel, cfg.HttpsEnabled) + err := discoverySvc.Start(multicastCtx, cfg.ToMulticastDto(false)) if err != nil { logger.Warnf("Multicast start failed: %v", err) } @@ -131,16 +131,7 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci return SendToDevice(ctx, cfg, targetDevice, filePaths, logger) } - registerDto := model.RegisterDto{ - Alias: cfg.Alias, - Version: config.ProtocolVersion, - DeviceModel: cfg.DeviceModel, - DeviceType: cfg.DeviceType, - Fingerprint: cfg.SecurityContext.CertificateHash, - Port: cfg.Port, - Protocol: model.ProtocolTypeHTTP, - } - + registerDto := cfg.ToRegisterDto() httpFallback := discovery.NewHTTPDiscovery(nil, registerDto, nil, logger) localIPs, err := network.GetLocalIPAddresses() @@ -470,7 +461,7 @@ func uploadFile(ctx context.Context, client *http.Client, device *model.Device, var body io.ReadCloser = file if trackProgress != nil { bar := &progressBar{current: 0, track: trackProgress} - body = &progressTracker{Reader: file, bar: bar} + body = &progressTracker{Reader: file, Closer: file, bar: bar} } // Wrap with idle timeout: cancel request if no data flows for 15s @@ -543,6 +534,7 @@ type progressBar struct { type progressTracker struct { io.Reader + io.Closer bar *progressBar } @@ -554,10 +546,3 @@ func (pt *progressTracker) Read(p []byte) (int, error) { } return n, err } - -func (pt *progressTracker) Close() error { - if f, ok := pt.Reader.(*os.File); ok { - return f.Close() - } - return nil -} diff --git a/pkg/server/handlers/download_handlers.go b/pkg/server/handlers/download_handlers.go index 47dbc31..4a4693b 100644 --- a/pkg/server/handlers/download_handlers.go +++ b/pkg/server/handlers/download_handlers.go @@ -1,6 +1,7 @@ package handlers import ( + "crypto/subtle" "fmt" "io" "net/http" @@ -36,7 +37,7 @@ func (h *DownloadHandler) PrepareDownloadHandler(w http.ResponseWriter, r *http. // --- PIN Check --- if h.config.PIN != "" { pin := r.URL.Query().Get("pin") - if pin != h.config.PIN { + if subtle.ConstantTimeCompare([]byte(pin), []byte(h.config.PIN)) != 1 { httputil.RespondError(w, http.StatusUnauthorized, "Invalid PIN") return } diff --git a/pkg/server/server.go b/pkg/server/server.go index 1a3b390..bf905b2 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -96,8 +96,8 @@ func (s *Server) Start(ctx context.Context, readyChan chan<- struct{}) error { s.httpServer = &http.Server{ Addr: addr, Handler: s.muxRouter, - ReadTimeout: 0, - WriteTimeout: 0, + ReadTimeout: 30 * time.Second, + WriteTimeout: 300 * time.Second, ReadHeaderTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, } @@ -178,6 +178,9 @@ func (s *Server) Shutdown(ctx context.Context) error { } s.logger.Info("Server stopped.") s.httpServer = nil + if s.receiveService != nil { + s.receiveService.Close() + } if s.historyLog != nil { if err := s.historyLog.Close(); err != nil { s.logger.Warnf("Failed to close history log: %v", err) From 64be12d96cd789a55de69a748b95085dd6a111cf Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:31:41 +0300 Subject: [PATCH 09/45] fix(logic): config set parsing, scan/discover timeouts, share port order, CIDR range, RNG fallback --- cmd/localgo/cmd/config.go | 19 ++++++++++--- cmd/localgo/cmd/discover.go | 16 ++++------- cmd/localgo/cmd/scan.go | 12 +++------ cmd/localgo/cmd/serve.go | 2 +- cmd/localgo/cmd/share.go | 54 ++++++++++++++++++------------------- pkg/config/config.go | 25 +++++++++++------ pkg/discovery/peercache.go | 13 ++++----- pkg/storage/storage.go | 5 +++- 8 files changed, 80 insertions(+), 66 deletions(-) diff --git a/cmd/localgo/cmd/config.go b/cmd/localgo/cmd/config.go index 3973a77..7b75406 100644 --- a/cmd/localgo/cmd/config.go +++ b/cmd/localgo/cmd/config.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "strconv" "strings" "github.com/spf13/cobra" @@ -63,11 +64,23 @@ var configSetCmd = &cobra.Command{ existingVal := v.Get(key) switch existingVal.(type) { case int, int64: - v.Set(key, v.GetInt(key)) + val, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("invalid integer value %q: %w", args[1], err) + } + v.Set(key, val) case bool: - v.Set(key, v.GetBool(key)) + val, err := strconv.ParseBool(args[1]) + if err != nil { + return fmt.Errorf("invalid boolean value %q: %w", args[1], err) + } + v.Set(key, val) case float64: - v.Set(key, v.GetFloat64(key)) + val, err := strconv.ParseFloat(args[1], 64) + if err != nil { + return fmt.Errorf("invalid float value %q: %w", args[1], err) + } + v.Set(key, val) default: v.Set(key, args[1]) } diff --git a/cmd/localgo/cmd/discover.go b/cmd/localgo/cmd/discover.go index aa41b37..0e4b568 100644 --- a/cmd/localgo/cmd/discover.go +++ b/cmd/localgo/cmd/discover.go @@ -25,15 +25,9 @@ var discoverCmd = &cobra.Command{ Short: "Discover LocalGo devices on the network using multicast", RunE: func(cmd *cobra.Command, args []string) error { - // Increase default timeout for better reliability - discoverTimeout := discovertimeout - if discoverTimeout < 10 { - discoverTimeout = 10 - } - if !discoverquiet { cli.PrintHeader("Discovering devices") - cli.PrintInfo("Timeout: %ds", discoverTimeout) + cli.PrintInfo("Timeout: %ds", discovertimeout) cli.PrintInfo("Multicast group: %s", Cfg.MulticastGroup) cli.PrintInfo("Port: %d", Cfg.Port) cli.PrintInfo("Protocol: %s", func() string { @@ -71,7 +65,7 @@ var discoverCmd = &cobra.Command{ }) // Perform discovery - discoverCtx, cancel := context.WithTimeout(context.Background(), time.Duration(discoverTimeout)*time.Second) + discoverCtx, cancel := context.WithTimeout(context.Background(), time.Duration(discovertimeout)*time.Second) defer cancel() var foundDevices []*model.Device @@ -81,11 +75,11 @@ var discoverCmd = &cobra.Command{ _ = spinner.New(). Title(fmt.Sprintf("Searching for devices on multicast group %s...", Cfg.MulticastGroup)). Action(func() { - foundDevices, discErr = discoverySvc.Discover(discoverCtx, Cfg.Alias, Cfg.Port, Cfg.SecurityContext.CertificateHash, Cfg.DeviceType, Cfg.DeviceModel, Cfg.HttpsEnabled, false) + foundDevices, discErr = discoverySvc.Discover(discoverCtx, Cfg.ToMulticastDto(false)) }). Run() } else { - foundDevices, discErr = discoverySvc.Discover(discoverCtx, Cfg.Alias, Cfg.Port, Cfg.SecurityContext.CertificateHash, Cfg.DeviceType, Cfg.DeviceModel, Cfg.HttpsEnabled, false) + foundDevices, discErr = discoverySvc.Discover(discoverCtx, Cfg.ToMulticastDto(false)) } if discErr != nil && !discoverquiet { @@ -104,7 +98,7 @@ var discoverCmd = &cobra.Command{ func init() { rootCmd.AddCommand(discoverCmd) - discoverCmd.Flags().IntVar(&discovertimeout, "timeout", 5, "Discovery timeout in seconds") + discoverCmd.Flags().IntVar(&discovertimeout, "timeout", 10, "Discovery timeout in seconds") discoverCmd.Flags().BoolVar(&discoverjsonOutput, "json", false, "Output in JSON format") discoverCmd.Flags().BoolVar(&discoverquiet, "quiet", false, "Quiet mode") diff --git a/cmd/localgo/cmd/scan.go b/cmd/localgo/cmd/scan.go index 637bc43..313241d 100644 --- a/cmd/localgo/cmd/scan.go +++ b/cmd/localgo/cmd/scan.go @@ -29,12 +29,6 @@ var scanCmd = &cobra.Command{ Short: "Scan the network for LocalGo devices using HTTP", RunE: func(cmd *cobra.Command, args []string) error { - // Increase default timeout for better reliability - scanTimeout := scantimeout - if scanTimeout < 15 { - scanTimeout = 15 - } - scanPort := Cfg.Port if scanport > 0 { scanPort = scanport @@ -49,7 +43,7 @@ var scanCmd = &cobra.Command{ } ips = parsedIPs if !scanquiet { - cli.PrintHeader(fmt.Sprintf("Scanning CIDR range %s on port %d (timeout: %ds)...", scanrange, scanPort, scanTimeout)) + cli.PrintHeader(fmt.Sprintf("Scanning CIDR range %s on port %d (timeout: %ds)...", scanrange, scanPort, scantimeout)) cli.PrintInfo("Scanning %d IP addresses...", len(ips)) cli.PrintInfo("Protocols: HTTPS first, then HTTP fallback") } @@ -66,7 +60,7 @@ var scanCmd = &cobra.Command{ } if !scanquiet { - cli.PrintHeader(fmt.Sprintf("Scanning network on port %d (timeout: %ds)...", scanPort, scanTimeout)) + cli.PrintHeader(fmt.Sprintf("Scanning network on port %d (timeout: %ds)...", scanPort, scantimeout)) cli.PrintInfo("Scanning %d IP addresses (derived from %d local interfaces)...", len(ips), len(localIPs)) cli.PrintInfo("Protocols: HTTPS first, then HTTP fallback") } @@ -76,7 +70,7 @@ var scanCmd = &cobra.Command{ httpDiscoverer := discovery.NewHTTPDiscovery(nil, Cfg.ToRegisterDto(), nil, zap.S()) // Perform scan - scanCtx, cancel := context.WithTimeout(context.Background(), time.Duration(scanTimeout)*time.Second) + scanCtx, cancel := context.WithTimeout(context.Background(), time.Duration(scantimeout)*time.Second) defer cancel() var foundDevices []*model.Device diff --git a/cmd/localgo/cmd/serve.go b/cmd/localgo/cmd/serve.go index a162fe4..77ec048 100644 --- a/cmd/localgo/cmd/serve.go +++ b/cmd/localgo/cmd/serve.go @@ -163,7 +163,7 @@ var serveCmd = &cobra.Command{ }) // Start discovery - err := discoverySvc.Start(ctx, Cfg.Alias, Cfg.Port, Cfg.SecurityContext.CertificateHash, Cfg.DeviceType, Cfg.DeviceModel, Cfg.HttpsEnabled) + err := discoverySvc.Start(ctx, Cfg.ToMulticastDto(false)) if err != nil { return fmt.Errorf("discovery service failed: %w", err) } diff --git a/cmd/localgo/cmd/share.go b/cmd/localgo/cmd/share.go index 3f31a2c..0242b8f 100644 --- a/cmd/localgo/cmd/share.go +++ b/cmd/localgo/cmd/share.go @@ -144,7 +144,7 @@ var shareCmd = &cobra.Command{ displayName := filepath.Base(file) if sharezip && strings.HasSuffix(file, ".zip") { - displayName = filepath.Base(file[:len(file)-4]) + ".zip" + displayName = strings.TrimSuffix(filepath.Base(file), ".zip") + ".zip" } fileDto := model.FileDto{ @@ -183,7 +183,21 @@ var shareCmd = &cobra.Command{ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - // Initialize discovery service with Download: true + // Start server first to determine the actual port + serverErrChan := make(chan error, 1) + serverReadyChan := make(chan struct{}, 1) + go func() { + serverErrChan <- srv.Start(ctx, serverReadyChan) + }() + + // Wait for server to be ready + select { + case err := <-serverErrChan: + return fmt.Errorf("server failed: %w", err) + case <-serverReadyChan: + } + + // Initialize discovery service AFTER server is ready (Cfg.Port may have changed if port was busy) discoverySvcConfig := discovery.DefaultServiceConfig() discoverySvcConfig.MulticastConfig.Port = Cfg.Port discoverySvcConfig.MulticastConfig.MulticastAddr = fmt.Sprintf("%s:%d", Cfg.MulticastGroup, Cfg.Port) @@ -200,22 +214,8 @@ var shareCmd = &cobra.Command{ discoverySvc := discovery.NewService(discoverySvcConfig, multicast, zap.S()) discoverySvc.SetPeerCache(peerCache) - // Start server first - serverErrChan := make(chan error, 1) - serverReadyChan := make(chan struct{}, 1) - go func() { - serverErrChan <- srv.Start(ctx, serverReadyChan) - }() - - // Wait for server to be ready - select { - case err := <-serverErrChan: - return fmt.Errorf("server failed: %w", err) - case <-serverReadyChan: - } - // Start discovery AFTER server is ready - err = discoverySvc.Start(ctx, Cfg.Alias, Cfg.Port, Cfg.SecurityContext.CertificateHash, Cfg.DeviceType, Cfg.DeviceModel, Cfg.HttpsEnabled) + err = discoverySvc.Start(ctx, Cfg.ToMulticastDto(true)) if err != nil { return fmt.Errorf("discovery service failed: %w", err) } @@ -281,19 +281,12 @@ func zipDirToTemp(dir string) (string, error) { if baseName == "." || baseName == "/" { baseName = "archive" } - zipPath, err := os.CreateTemp("", "localgo-"+baseName+"-*.zip") + zipFile, err := os.CreateTemp("", "localgo-"+baseName+"-*.zip") if err != nil { return "", fmt.Errorf("failed to create temp zip: %w", err) } - zipPath.Close() - zipPathName := zipPath.Name() - - fZ, err := os.OpenFile(zipPathName, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600) - if err != nil { - os.Remove(zipPathName) - return "", fmt.Errorf("failed to reopen temp zip: %w", err) - } - zipWriter := zip.NewWriter(fZ) + zipPathName := zipFile.Name() + zipWriter := zip.NewWriter(zipFile) err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -329,11 +322,18 @@ func zipDirToTemp(dir string) (string, error) { }) if err != nil { zipWriter.Close() + zipFile.Close() os.Remove(zipPathName) return "", err } if err := zipWriter.Close(); err != nil { + zipFile.Close() + os.Remove(zipPathName) + return "", err + } + + if err := zipFile.Close(); err != nil { os.Remove(zipPathName) return "", err } diff --git a/pkg/config/config.go b/pkg/config/config.go index 20fb6c7..95a603e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,16 +1,17 @@ package config import ( - "github.com/spf13/viper" "crypto/rand" "fmt" "os" "path/filepath" "strconv" - "time" + + mathrand "math/rand/v2" "github.com/bethropolis/localgo/pkg/crypto" "github.com/bethropolis/localgo/pkg/model" + "github.com/spf13/viper" "go.uber.org/zap" ) @@ -217,12 +218,12 @@ func generateRandomID(length int) string { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" result := make([]byte, length) if _, err := rand.Read(result); err != nil { - timeBasedID := strconv.FormatInt(time.Now().UnixNano(), 10) - if len(timeBasedID) >= length { - return timeBasedID[:length] - } - for i := 0; i < length; i++ { - result[i] = chars[time.Now().UnixNano()%int64(len(chars))] + // If crypto/rand fails, use math/rand/v2 seeded from crypto/rand + var seed [32]byte + rand.Read(seed[:]) // best-effort seed + r := newRandFromSeed(seed) + for i := range result { + result[i] = chars[r.IntN(len(chars))] } return string(result) } @@ -232,6 +233,14 @@ func generateRandomID(length int) string { return string(result) } +func newRandFromSeed(seed [32]byte) *mathrand.Rand { + var rngSeed uint64 + for i := 0; i < 8 && i < len(seed); i++ { + rngSeed |= uint64(seed[i]) << (i * 8) + } + return mathrand.New(mathrand.NewPCG(rngSeed, uint64(seed[0]))) +} + // ToRegisterDto converts Config to model.RegisterDto for discovery requests func (c *Config) ToRegisterDto() model.RegisterDto { protocol := model.ProtocolTypeHTTP diff --git a/pkg/discovery/peercache.go b/pkg/discovery/peercache.go index 77f0b55..2afe942 100644 --- a/pkg/discovery/peercache.go +++ b/pkg/discovery/peercache.go @@ -93,7 +93,7 @@ func (pc *PeerCache) load() { for _, d := range list { // Evict peers not seen in the last 30 days - if !d.LastSeen.IsZero() && now.Sub(d.LastSeen) > staleThreshold { + if !d.GetLastSeen().IsZero() && now.Sub(d.GetLastSeen()) > staleThreshold { evictedCount++ continue } @@ -205,11 +205,12 @@ func ProbeCached(ctx context.Context, cache *PeerCache, onFound func(*model.Devi if resp.StatusCode == http.StatusOK { now := time.Now() - d.LastSeen = now - if logger != nil { - logger.Debugf("Cached peer %s (%s:%d) responded", d.Alias, d.IP, d.Port) - } - onFound(d) + d.SetLastSeen(now) + cache.Save(d) // persist updated LastSeen to disk + if logger != nil { + logger.Debugf("Cached peer %s (%s:%d) responded", d.Alias, d.IP, d.Port) + } + onFound(d) } }(device) } diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index eaaea8e..df69d33 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -201,7 +201,10 @@ func ResolveDuplicateFilename(dir, baseName string) string { // Fallback to avoid silent overwrite if (1) through (999) are all taken randomBytes := make([]byte, 3) - rand.Read(randomBytes) + if _, err := rand.Read(randomBytes); err != nil { + newName := fmt.Sprintf("%s_%d%s", nameWithoutExt, time.Now().UnixNano(), ext) + return filepath.Join(dir, newName) + } newName := fmt.Sprintf("%s_%s%s", nameWithoutExt, hex.EncodeToString(randomBytes), ext) return filepath.Join(dir, newName) } From ad832f9c6f68b906ff5183fcfff16714a5d6c577 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:31:48 +0300 Subject: [PATCH 10/45] fix(concurrency): Device mutex for LastSeen/Available, ReceiveService ticker goroutine leak --- pkg/model/device.go | 37 ++++++++++++++++++++++---- pkg/server/services/receive_service.go | 34 ++++++++++++++++------- 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/pkg/model/device.go b/pkg/model/device.go index 99cfbb6..6d9d3b1 100644 --- a/pkg/model/device.go +++ b/pkg/model/device.go @@ -3,16 +3,18 @@ package model import ( "fmt" "net" + "sync" "time" ) // Device represents a peer device. type Device struct { - IP string `json:"ip"` - Version string `json:"version"` // LocalSend protocol version - Port int `json:"port"` - Alias string `json:"alias"` - Protocol ProtocolType `json:"protocol"` + mu sync.RWMutex + IP string `json:"ip"` + Version string `json:"version"` // LocalSend protocol version + Port int `json:"port"` + Alias string `json:"alias"` + Protocol ProtocolType `json:"protocol"` Fingerprint string `json:"fingerprint"` DeviceModel *string `json:"deviceModel"` // nullable @@ -51,12 +53,37 @@ func NewDevice(info RegisterDto, ip net.IP, detectedPort int, detectedHttps bool // UpdateLastSeen updates the last seen timestamp for a device func (d *Device) UpdateLastSeen() { + d.mu.Lock() d.LastSeen = time.Now() d.Available = true + d.mu.Unlock() +} + +// GetLastSeen returns the last seen timestamp (thread-safe). +func (d *Device) GetLastSeen() time.Time { + d.mu.RLock() + defer d.mu.RUnlock() + return d.LastSeen +} + +// GetAvailable returns whether the device is available (thread-safe). +func (d *Device) GetAvailable() bool { + d.mu.RLock() + defer d.mu.RUnlock() + return d.Available +} + +// SetLastSeen sets the last seen timestamp (thread-safe). +func (d *Device) SetLastSeen(t time.Time) { + d.mu.Lock() + d.LastSeen = t + d.mu.Unlock() } // IsStale checks if a device hasn't been seen recently func (d *Device) IsStale(staleThreshold time.Duration) bool { + d.mu.RLock() + defer d.mu.RUnlock() return time.Since(d.LastSeen) > staleThreshold } diff --git a/pkg/server/services/receive_service.go b/pkg/server/services/receive_service.go index 9a65fe8..87cd1d6 100644 --- a/pkg/server/services/receive_service.go +++ b/pkg/server/services/receive_service.go @@ -28,32 +28,48 @@ type ActiveFile struct { type ReceiveService struct { sessions map[string]*ActiveReceiveSession sessionMutex sync.RWMutex + stopCh chan struct{} + closeOnce sync.Once } // NewReceiveService creates a new ReceiveService. func NewReceiveService() *ReceiveService { s := &ReceiveService{ sessions: make(map[string]*ActiveReceiveSession), + stopCh: make(chan struct{}), } go s.cleanupLoop() return s } +// Close stops the cleanup loop and releases resources. +func (s *ReceiveService) Close() { + s.closeOnce.Do(func() { + close(s.stopCh) + }) +} + // cleanupLoop periodically checks and expires stale sessions func (s *ReceiveService) cleanupLoop() { ticker := time.NewTicker(1 * time.Minute) - for range ticker.C { - s.sessionMutex.Lock() - for id, session := range s.sessions { - if time.Since(session.CreatedAt) > 10*time.Minute { - if session.Progress != nil { - session.Progress.ForceComplete() - go session.Progress.Wait() + defer ticker.Stop() + for { + select { + case <-s.stopCh: + return + case <-ticker.C: + s.sessionMutex.Lock() + for id, session := range s.sessions { + if time.Since(session.CreatedAt) > 10*time.Minute { + if session.Progress != nil { + session.Progress.ForceComplete() + go session.Progress.Wait() + } + delete(s.sessions, id) } - delete(s.sessions, id) } + s.sessionMutex.Unlock() } - s.sessionMutex.Unlock() } } From 413bcd18c2d8625fdabb80e50fc591492982d2ac Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:31:55 +0300 Subject: [PATCH 11/45] refactor(code quality): SortFunc, mutex-safe anonymize, saveTextAsFile, interfaces, tests --- cmd/localgo/cmd/devices.go | 15 +++---- cmd/localgo/cmd/history.go | 2 +- cmd/localgo/cmd/utils.go | 14 ++++-- cmd/localgo/main_test.go | 19 ++++++++ pkg/discovery/service_test.go | 11 ++++- pkg/logging/logging.go | 2 +- pkg/network/interfaces.go | 4 +- pkg/server/handlers/receive_handlers.go | 58 +++++++++++++------------ 8 files changed, 80 insertions(+), 45 deletions(-) diff --git a/cmd/localgo/cmd/devices.go b/cmd/localgo/cmd/devices.go index 20ebb62..46f4e48 100644 --- a/cmd/localgo/cmd/devices.go +++ b/cmd/localgo/cmd/devices.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "slices" "strings" "time" @@ -54,13 +55,9 @@ var devicesCmd = &cobra.Command{ peers = anonymizeDeviceSlice(peers) } - for i := 0; i < len(peers); i++ { - for j := i + 1; j < len(peers); j++ { - if peers[i].LastSeen.Before(peers[j].LastSeen) { - peers[i], peers[j] = peers[j], peers[i] - } - } - } + slices.SortFunc(peers, func(a, b *model.Device) int { + return b.GetLastSeen().Compare(a.GetLastSeen()) + }) titleStyle := cli.HeaderStyle.Padding(0, 1).MarginBottom(1) headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) @@ -83,8 +80,8 @@ var devicesCmd = &cobra.Command{ now := time.Now() for _, d := range peers { lastSeenStr := "Unknown" - if !d.LastSeen.IsZero() { - diff := now.Sub(d.LastSeen) + if !d.GetLastSeen().IsZero() { + diff := now.Sub(d.GetLastSeen()) if diff < 1*time.Minute { lastSeenStr = cli.SuccessStyle.Render("Online") } else if diff < 1*time.Hour { diff --git a/cmd/localgo/cmd/history.go b/cmd/localgo/cmd/history.go index 3b2772e..f9828b7 100644 --- a/cmd/localgo/cmd/history.go +++ b/cmd/localgo/cmd/history.go @@ -98,7 +98,7 @@ var historyCmd = &cobra.Command{ for _, entry := range displayEntries { senderAlias := entry.SenderAlias - if Cfg.Private { + if Cfg.Private && senderAlias != "Anonymous" { senderAlias = cli.AnonymizeString(entry.SenderAlias) } tStr := entry.Timestamp.Local().Format("01-02 15:04") diff --git a/cmd/localgo/cmd/utils.go b/cmd/localgo/cmd/utils.go index 353aa58..9388136 100644 --- a/cmd/localgo/cmd/utils.go +++ b/cmd/localgo/cmd/utils.go @@ -22,9 +22,17 @@ func padRight(str string, length int) string { func anonymizeDeviceSlice(devices []*model.Device) []*model.Device { out := make([]*model.Device, len(devices)) for i, d := range devices { - copy := *d - copy.Alias = cli.AnonymizedAlias(d) - out[i] = © + out[i] = &model.Device{ + IP: d.IP, + Version: d.Version, + Port: d.Port, + Alias: cli.AnonymizedAlias(d), + Protocol: d.Protocol, + Fingerprint: d.Fingerprint, + DeviceModel: d.DeviceModel, + DeviceType: d.DeviceType, + Download: d.Download, + } } return out } diff --git a/cmd/localgo/main_test.go b/cmd/localgo/main_test.go index 06ab7d0..7ae2c49 100644 --- a/cmd/localgo/main_test.go +++ b/cmd/localgo/main_test.go @@ -1 +1,20 @@ package main + +import ( + "testing" +) + +func TestVersionVars(t *testing.T) { + if Version == "" { + t.Error("Version should not be empty") + } +} + +func TestBuildVars(t *testing.T) { + if GitCommit == "" { + t.Error("GitCommit should not be empty") + } + if BuildDate == "" { + t.Error("BuildDate should not be empty") + } +} diff --git a/pkg/discovery/service_test.go b/pkg/discovery/service_test.go index 1680613..376a18e 100644 --- a/pkg/discovery/service_test.go +++ b/pkg/discovery/service_test.go @@ -49,7 +49,16 @@ func TestService_Start(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() - err := service.Start(ctx, "test-alias", 12345, "test-fingerprint", model.DeviceTypeDesktop, nil, false) + dto := model.MulticastDto{ + Alias: "test-alias", + Version: "2.1", + Fingerprint: "test-fingerprint", + Port: 12345, + DeviceType: model.DeviceTypeDesktop, + Protocol: model.ProtocolTypeHTTP, + Announce: true, + } + err := service.Start(ctx, dto) assert.NoError(t, err) assert.True(t, multicast.startListeningCalled) diff --git a/pkg/logging/logging.go b/pkg/logging/logging.go index 40ecad4..5074037 100644 --- a/pkg/logging/logging.go +++ b/pkg/logging/logging.go @@ -117,7 +117,7 @@ func Init(verbose, jsonFmt bool) *zap.SugaredLogger { if verbose { opts = append(opts, zap.AddStacktrace(zapcore.ErrorLevel)) } else { - opts = []zap.Option{zap.AddCallerSkip(0)} // Minimal options for non-verbose + opts = []zap.Option{} // Minimal options for non-verbose } logger := zap.New(core, opts...) diff --git a/pkg/network/interfaces.go b/pkg/network/interfaces.go index 99dab9f..0954b9e 100644 --- a/pkg/network/interfaces.go +++ b/pkg/network/interfaces.go @@ -35,8 +35,8 @@ func GetLocalIPAddresses() ([]net.IP, error) { } for _, i := range ifaces { - // Skip down, loopback, and non-multicast interfaces - if (i.Flags&net.FlagUp) == 0 || (i.Flags&net.FlagLoopback) != 0 || (i.Flags&net.FlagMulticast) == 0 { + // Skip down and loopback interfaces + if (i.Flags&net.FlagUp) == 0 || (i.Flags&net.FlagLoopback) != 0 { continue } diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index df13aff..e1fad75 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -287,34 +287,7 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) } // Fall-back: save the full stream as a file. - var combinedReader io.Reader - if int64(len(textBytes)) > maxTextSize { - // Re-combine the already-read prefix with the remaining socket stream. - combinedReader = io.MultiReader(bytes.NewReader(textBytes), bodyReader) - } else { - combinedReader = bytes.NewReader(textBytes) - } - destinationPath = storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName) - cleanPath = filepath.Clean(destinationPath) - if !strings.HasPrefix(cleanPath, filepath.Clean(h.config.DownloadDir)+string(filepath.Separator)) && - cleanPath != filepath.Clean(h.config.DownloadDir) { - httputil.RespondError(w, http.StatusBadRequest, "Invalid filename") - return - } - savErr := storage.SaveStreamToFileWithMetadata( - combinedReader, destinationPath, fileInfo.Dto.Size, modified, accessed, fileInfo.Dto.SHA256, onProgress, h.logger, - ) - if savErr != nil { - h.logger.Errorf("Error saving text file %s: %v", fileInfo.Dto.FileName, savErr) - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), fileInfo.Dto.FileType, history.StatusFailed) - httputil.RespondError(w, http.StatusInternalServerError, "Failed to save file") - return - } - h.logger.Infof("Saved text as file: %s", destinationPath) - h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), fileInfo.Dto.FileType, history.StatusReceived) - h.runExecHook(destinationPath, rawFileName, session.Sender.Alias, session.Sender.IP, int64(len(textBytes))) - httputil.RespondJSON(w, http.StatusOK, struct{}{}) + h.saveTextAsFile(session, reqSessionId, reqFileId, rawFileName, bodyReader, textBytes, modified, accessed, onProgress) return } @@ -462,6 +435,35 @@ func (h *ReceiveHandler) runExecHook(filePath, fileName, senderAlias, senderIP s }() } +// saveTextAsFile saves text content as a file when clipboard is unavailable or text is too large. +func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, reqSessionId, reqFileId, rawFileName string, bodyReader io.Reader, textBytes []byte, modified, accessed *string, onProgress func(int64)) { + var combinedReader io.Reader + if int64(len(textBytes)) > maxTextSize { + combinedReader = io.MultiReader(bytes.NewReader(textBytes), bodyReader) + } else { + combinedReader = bytes.NewReader(textBytes) + } + destinationPath := storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName) + cleanPath := filepath.Clean(destinationPath) + if !strings.HasPrefix(cleanPath, filepath.Clean(h.config.DownloadDir)+string(filepath.Separator)) && + cleanPath != filepath.Clean(h.config.DownloadDir) { + h.logger.Errorf("Path traversal attempt detected in text fallback: %s", rawFileName) + return + } + savErr := storage.SaveStreamToFileWithMetadata( + combinedReader, destinationPath, int64(len(textBytes)), modified, accessed, nil, onProgress, h.logger, + ) + if savErr != nil { + h.logger.Errorf("Error saving text file %s: %v", rawFileName, savErr) + h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusFailed) + return + } + h.logger.Infof("Saved text as file: %s", destinationPath) + h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) + h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusReceived) + h.runExecHook(destinationPath, rawFileName, session.Sender.Alias, session.Sender.IP, int64(len(textBytes))) +} + // CancelHandler handles POST /v2/cancel requests. func (h *ReceiveHandler) CancelHandler(w http.ResponseWriter, r *http.Request) { h.logger.Info("Received /cancel request") From 8bfafe2219e4cada0290035af715c5267bd916a6 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:32:25 +0300 Subject: [PATCH 12/45] fix(security): bypass DiscoverDevices private mode in cmd/send.go --- cmd/localgo/cmd/send.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index 2bf4204..ed2be51 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -178,8 +178,8 @@ var sendCmd = &cobra.Command{ devices, discErr = discovery.DiscoverDevices( context.Background(), sendConfig, - Cfg.Alias, Cfg.Port, Cfg.SecurityContext.CertificateHash, - Cfg.DeviceModel, Cfg.HttpsEnabled, + Cfg, + Cfg.HttpsEnabled, ) }). Run() From 138952b6491817695fc3ba77b3db344eca400585 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:57:30 +0300 Subject: [PATCH 13/45] fix(help): correct discover --timeout default from 5 to 10 --- pkg/help/help.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/help/help.go b/pkg/help/help.go index 91bc3c6..9f949f9 100644 --- a/pkg/help/help.go +++ b/pkg/help/help.go @@ -217,7 +217,7 @@ func GetCommandHelp(commandName string) *CommandHelp { "localgo discover --quiet", }, Flags: []FlagHelp{ - {Name: "--timeout", Type: "int", Default: "5", Description: "Discovery timeout in seconds"}, + {Name: "--timeout", Type: "int", Default: "10", Description: "Discovery timeout in seconds"}, {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - only show results"}, }, From 97a0c4aef117151b2c583a27de2b2df852bdfa7d Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:58:00 +0300 Subject: [PATCH 14/45] docs(help): add completion cmd, missing flags for serve/share/send, --private/--config options --- pkg/help/help.go | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/pkg/help/help.go b/pkg/help/help.go index 9f949f9..b137a02 100644 --- a/pkg/help/help.go +++ b/pkg/help/help.go @@ -45,6 +45,7 @@ func ShowMainUsage() { {"devices", "List recently discovered devices"}, {"history", "Show file transfer history log"}, {"info", "Show device information"}, + {"completion", "Generate shell completion scripts"}, {"help", "Show help information"}, {"version", "Show version information"}, } @@ -57,7 +58,9 @@ func ShowMainUsage() { fmt.Printf(" %s Show help\n", cli.InfoStyle.Render("-h, --help")) fmt.Printf(" %s Show version\n", cli.InfoStyle.Render("-v, --version")) fmt.Printf(" %s Enable debug logging\n", cli.InfoStyle.Render("--verbose")) - fmt.Printf(" %s Enable JSON log output\n\n", cli.InfoStyle.Render("--json")) + fmt.Printf(" %s Enable JSON log output\n", cli.InfoStyle.Render("--json")) + fmt.Printf(" %s Hide device identity during discovery/transfer\n", cli.InfoStyle.Render("--private, -p")) + fmt.Printf(" %s Config file path\n\n", cli.InfoStyle.Render("--config")) fmt.Printf("%s\n", cli.WarningStyle.Render("EXAMPLES:")) examples := []string{ @@ -175,10 +178,12 @@ func GetCommandHelp(commandName string) *CommandHelp { {Name: "--interval", Type: "int", Default: "30", Description: "Discovery announcement interval in seconds"}, {Name: "--auto-accept", Type: "bool", Default: "false", Description: "Auto-accept incoming files without prompting"}, {Name: "--no-clipboard", Type: "bool", Default: "false", Description: "Save incoming text as a file instead of copying to clipboard"}, + {Name: "--open", Type: "bool", Default: "false", Description: "Open download directory after transfer completes"}, {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - minimal output"}, {Name: "--verbose", Type: "bool", Default: "false", Description: "Verbose mode - detailed output"}, {Name: "--history", Type: "string", Default: "~/.local/share/localgo/history.jsonl", Description: "Path to transfer history JSONL file"}, {Name: "--exec", Type: "string", Default: "", Description: "Shell command to execute after each received file (use %f, %n, %s, %a, %i)"}, + {Name: "--iface", Type: "string", Default: "", Description: "Multicast network interface name"}, }, }, "share": { @@ -201,9 +206,12 @@ func GetCommandHelp(commandName string) *CommandHelp { {Name: "--alias", Type: "string", Default: "from config", Description: "Device alias"}, {Name: "--auto-accept", Type: "bool", Default: "false", Description: "Auto-accept incoming files without prompting"}, {Name: "--no-clipboard", Type: "bool", Default: "false", Description: "Save incoming text as a file instead of copying to clipboard"}, + {Name: "--zip", Type: "bool", Default: "false", Description: "Zip directories before sharing"}, + {Name: "--concurrency", Type: "int", Default: "0", Description: "Max parallel uploads (0 = use default)"}, {Name: "--history", Type: "string", Default: "", Description: "Path to transfer history JSONL file"}, {Name: "--exec", Type: "string", Default: "", Description: "Shell command to execute after each received file"}, {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - minimal output"}, + {Name: "--iface", Type: "string", Default: "", Description: "Multicast network interface name"}, }, }, "discover": { @@ -261,6 +269,8 @@ func GetCommandHelp(commandName string) *CommandHelp { {Name: "--port", Type: "int", Default: "auto-detect", Description: "Target device port"}, {Name: "--timeout", Type: "int", Default: "30", Description: "Send timeout in seconds"}, {Name: "--alias", Type: "string", Default: "from config", Description: "Sender alias"}, + {Name: "--concurrency", Type: "int", Default: "0", Description: "Max parallel uploads (0 = use default)"}, + {Name: "--iface", Type: "string", Default: "", Description: "Multicast network interface name"}, }, }, "history": { @@ -303,6 +313,17 @@ func GetCommandHelp(commandName string) *CommandHelp { {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, }, }, + "completion": { + Name: "completion", + Description: "Generate shell completion scripts", + Usage: "localgo completion [bash|zsh|fish|powershell]", + Examples: []string{ + "localgo completion bash > /etc/bash_completion.d/localgo", + "localgo completion zsh > /usr/local/share/zsh/site-functions/_localgo", + "localgo completion fish > ~/.config/fish/completions/localgo.fish", + }, + Flags: []FlagHelp{}, + }, } return commands[commandName] From 7aaf291b3ac9cb1167900aa366e26e66796b5ea2 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 21 Jun 2026 01:25:55 +0300 Subject: [PATCH 15/45] feat(cli): add --no-color flag, respect NO_COLOR env in logging Init --- cmd/localgo/cmd/root.go | 8 +++++++- pkg/logging/logging.go | 9 +++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/cmd/localgo/cmd/root.go b/cmd/localgo/cmd/root.go index 745fa1f..2fa9b44 100644 --- a/cmd/localgo/cmd/root.go +++ b/cmd/localgo/cmd/root.go @@ -15,6 +15,7 @@ import ( var ( versionFlag bool privateMode bool + noColor bool ) var ( @@ -34,7 +35,11 @@ var rootCmd = &cobra.Command{ os.Exit(0) } - logger := logging.Init(Verbose, JSONOutput) + if noColor || os.Getenv("NO_COLOR") != "" { + noColor = true + } + + logger := logging.Init(Verbose, JSONOutput, noColor) ViperCfg = config.InitViper() if cfgFile != "" { @@ -73,6 +78,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/localgo/config.yaml)") rootCmd.PersistentFlags().BoolVar(&Verbose, "verbose", false, "Enable debug logging") rootCmd.PersistentFlags().BoolVar(&JSONOutput, "json", false, "Enable JSON log output") + rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "Disable colored output") rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { help.ShowMainUsage() diff --git a/pkg/logging/logging.go b/pkg/logging/logging.go index 5074037..449a145 100644 --- a/pkg/logging/logging.go +++ b/pkg/logging/logging.go @@ -49,7 +49,8 @@ func timeEncoder(t zapcore.TimeEncoder) zapcore.TimeEncoder { // // - verbose: enable debug-level output // - jsonFmt: output newline-delimited JSON instead of human-readable text -func Init(verbose, jsonFmt bool) *zap.SugaredLogger { +// - noColor: disable ANSI color escape sequences in log output +func Init(verbose, jsonFmt, noColor bool) *zap.SugaredLogger { level := zapcore.InfoLevel if verbose { level = zapcore.DebugLevel @@ -92,6 +93,10 @@ func Init(verbose, jsonFmt bool) *zap.SugaredLogger { var core zapcore.Core if verbose { // Also log to stdout + levelEnc := zapcore.LevelEncoder(colourLevelEncoder) + if noColor { + levelEnc = zapcore.CapitalLevelEncoder + } stdoutEncCfg := zapcore.EncoderConfig{ TimeKey: "T", LevelKey: "L", @@ -100,7 +105,7 @@ func Init(verbose, jsonFmt bool) *zap.SugaredLogger { MessageKey: "M", StacktraceKey: "S", LineEnding: zapcore.DefaultLineEnding, - EncodeLevel: colourLevelEncoder, + EncodeLevel: levelEnc, EncodeTime: zapcore.TimeEncoderOfLayout("15:04:05"), EncodeDuration: zapcore.StringDurationEncoder, EncodeCaller: zapcore.ShortCallerEncoder, From 5f13a84cd39303a31c8d567ba54d5d9018bd2e5a Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 21 Jun 2026 01:25:59 +0300 Subject: [PATCH 16/45] feat(freebsd): enable clipboard support via clipboard_unix.go (linux||freebsd) --- pkg/clipboard/clipboard_other.go | 2 +- pkg/clipboard/{clipboard_linux.go => clipboard_unix.go} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename pkg/clipboard/{clipboard_linux.go => clipboard_unix.go} (89%) diff --git a/pkg/clipboard/clipboard_other.go b/pkg/clipboard/clipboard_other.go index c3c92bd..b8bced5 100644 --- a/pkg/clipboard/clipboard_other.go +++ b/pkg/clipboard/clipboard_other.go @@ -1,4 +1,4 @@ -//go:build !linux && !darwin && !windows +//go:build !linux && !darwin && !windows && !freebsd package clipboard diff --git a/pkg/clipboard/clipboard_linux.go b/pkg/clipboard/clipboard_unix.go similarity index 89% rename from pkg/clipboard/clipboard_linux.go rename to pkg/clipboard/clipboard_unix.go index 830bf2f..b42e4e0 100644 --- a/pkg/clipboard/clipboard_linux.go +++ b/pkg/clipboard/clipboard_unix.go @@ -1,10 +1,10 @@ -//go:build linux +//go:build linux || freebsd package clipboard import "os/exec" -// detect probes for available clipboard tools on Linux. +// detect probes for available clipboard tools on Linux and FreeBSD. // Prefers Wayland (wl-copy) when WAYLAND_DISPLAY is set, then X11 tools. func detect() *clipProvider { // Wayland From 359989116f86f4394d7825be65a9c7f4af4ddc54 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 21 Jun 2026 01:26:07 +0300 Subject: [PATCH 17/45] feat(freebsd): add rc.d init script for localgo service --- scripts/rc.d/localgo | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100755 scripts/rc.d/localgo diff --git a/scripts/rc.d/localgo b/scripts/rc.d/localgo new file mode 100755 index 0000000..554eaf0 --- /dev/null +++ b/scripts/rc.d/localgo @@ -0,0 +1,21 @@ +#!/bin/sh + +# PROVIDE: localgo +# REQUIRE: NETWORKING +# KEYWORD: shutdown + +. /etc/rc.subr + +name="localgo" +rcvar="localgo_enable" + +load_rc_config $name + +: ${localgo_enable:="NO"} +: ${localgo_user:="nobody"} + +command="/usr/local/bin/localgo" +command_args="serve --quiet" +pidfile="/var/run/localgo.pid" + +run_rc_command "$1" From 47f61e245a65cba5644dbb9873a0f622a68d3e1f Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 21 Jun 2026 01:26:08 +0300 Subject: [PATCH 18/45] fix: check xdg-open availability before opening download directory --- pkg/server/handlers/receive_handlers.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index e1fad75..85396fa 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -492,9 +492,12 @@ func (h *ReceiveHandler) CancelHandler(w http.ResponseWriter, r *http.Request) { } else if runtime.GOOS == "darwin" { cmd = "open" args = []string{h.config.DownloadDir} - } else { + } else if _, err := exec.LookPath("xdg-open"); err == nil { cmd = "xdg-open" args = []string{h.config.DownloadDir} + } else { + h.logger.Debugf("xdg-open not found in PATH, skip opening download dir") + return } exec.Command(cmd, args...).Run() }() From 32a628de04d189fcdeef22f4a07b232f8e75c627 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 21 Jun 2026 01:39:53 +0300 Subject: [PATCH 19/45] feat(network): add gateway-based LAN subnet prioritization for scan and send --- cmd/localgo/cmd/scan.go | 10 ++++++ cmd/localgo/cmd/send.go | 67 ++++++++++++++++++++++++++++++++++++++- go.mod | 14 ++++---- go.sum | 18 +++++++---- pkg/network/interfaces.go | 14 ++++++++ 5 files changed, 110 insertions(+), 13 deletions(-) diff --git a/cmd/localgo/cmd/scan.go b/cmd/localgo/cmd/scan.go index 313241d..40b3ab9 100644 --- a/cmd/localgo/cmd/scan.go +++ b/cmd/localgo/cmd/scan.go @@ -54,6 +54,16 @@ var scanCmd = &cobra.Command{ return fmt.Errorf("failed to get local network IPs: %w", err) } + // Prioritize the subnet connected to the default gateway + if gwIP, err := network.PrimaryLANIP(); err == nil { + for i, ip := range localIPs { + if ip.Equal(gwIP) && i > 0 { + localIPs[0], localIPs[i] = localIPs[i], localIPs[0] + break + } + } + } + for _, ip := range localIPs { subnetIPs := network.GetSubnetIPs(ip) ips = append(ips, subnetIPs...) diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index ed2be51..561f041 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -17,6 +17,8 @@ import ( "github.com/bethropolis/localgo/pkg/model" "github.com/bethropolis/localgo/pkg/network" "github.com/bethropolis/localgo/pkg/send" + "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbles/filepicker" "github.com/charmbracelet/huh" "github.com/charmbracelet/huh/spinner" "github.com/spf13/cobra" @@ -35,6 +37,42 @@ var ( sendclipboard bool ) +// filePickerModel wraps bubbles/filepicker.Model as a tea.Model for TUI file selection. +type filePickerModel struct { + fp filepicker.Model + file string + quit bool +} + +func (m filePickerModel) Init() tea.Cmd { + return m.fp.Init() +} + +func (m filePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "esc", "ctrl+c": + m.quit = true + return m, tea.Quit + } + } + var cmd tea.Cmd + m.fp, cmd = m.fp.Update(msg) + if m.fp.Path != "" { + m.file = m.fp.Path + return m, tea.Quit + } + return m, cmd +} + +func (m filePickerModel) View() string { + if m.quit { + return "" + } + return m.fp.View() +} + var sendCmd = &cobra.Command{ Use: "send", Short: "Send a file to another LocalGo device", @@ -92,7 +130,24 @@ var sendCmd = &cobra.Command{ } if len(files) == 0 { - return fmt.Errorf("no file specified: use positional args, --file flag, or --clipboard") + // Launch interactive TUI file picker + fp := filepicker.New() + fp.DirAllowed = true + fp.FileAllowed = true + fp.ShowHidden = false + + m := filePickerModel{fp: fp} + p := tea.NewProgram(m) + result, err := p.Run() + if err == nil { + if picked, ok := result.(filePickerModel); ok && picked.file != "" { + files = []string{picked.file} + } + } + } + + if len(files) == 0 { + return fmt.Errorf("no file specified: use --file flag, --clipboard, or select from the file browser") } for _, file := range files { @@ -192,6 +247,16 @@ var sendCmd = &cobra.Command{ if len(devices) == 0 { localIPs, ipErr := network.GetLocalIPAddresses() if ipErr == nil && len(localIPs) > 0 { + // Prioritize the subnet connected to the default gateway + if gwIP, err := network.PrimaryLANIP(); err == nil { + for i, ip := range localIPs { + if ip.Equal(gwIP) && i > 0 { + localIPs[0], localIPs[i] = localIPs[i], localIPs[0] + break + } + } + } + _ = spinner.New(). Title("No devices via multicast. Scanning local subnets..."). Action(func() { diff --git a/go.mod b/go.mod index f2af99d..7b4b15e 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,24 @@ module github.com/bethropolis/localgo -go 1.26 +go 1.26.2 require ( github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b github.com/charmbracelet/lipgloss v1.1.0 github.com/gen2brain/beeep v0.11.2 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 + github.com/jackpal/gateway v1.2.0 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.19.0 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/vbauerster/mpb/v7 v7.5.3 go.uber.org/zap v1.27.1 - golang.org/x/sys v0.38.0 + golang.org/x/sys v0.41.0 ) require ( @@ -24,8 +27,6 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/bubbles v1.0.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect @@ -71,7 +72,8 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/text v0.34.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7388c2f..59f9582 100644 --- a/go.sum +++ b/go.sum @@ -85,6 +85,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= +github.com/jackpal/gateway v1.2.0 h1:euPRe4t7JfTaqC5Lr78HXl2wSHo54XndTtiAcIxkb5g= +github.com/jackpal/gateway v1.2.0/go.mod h1:/jchvRi4HukAqV24da70iaBMFcSrX3rNWdR5K9VHd0A= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -147,12 +149,14 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= @@ -169,14 +173,16 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/network/interfaces.go b/pkg/network/interfaces.go index 0954b9e..3236c2b 100644 --- a/pkg/network/interfaces.go +++ b/pkg/network/interfaces.go @@ -6,6 +6,8 @@ import ( "fmt" "net" "strings" + + "github.com/jackpal/gateway" ) // GetLocalIP returns the primary non-loopback IP address of the machine @@ -166,3 +168,15 @@ func GetSubnetIPs(ip net.IP) []net.IP { } return ips } + +// DefaultGatewayIP returns the IP address of the default network gateway. +func DefaultGatewayIP() (net.IP, error) { + return gateway.DiscoverGateway() +} + +// PrimaryLANIP returns the local IP address on the interface that owns +// the default gateway. This is useful for prioritizing the real LAN +// subnet when scanning, rather than scanning Docker/VPN subnets. +func PrimaryLANIP() (net.IP, error) { + return gateway.DiscoverInterface() +} From 8d35b6c9573ce04509bcb3702cbbf207ef5d0dbd Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 00:35:27 +0300 Subject: [PATCH 20/45] fix(discovery): send multicast response via multicast addr instead of unicast back --- pkg/discovery/multicast.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/discovery/multicast.go b/pkg/discovery/multicast.go index 6e5b9d9..e998e68 100644 --- a/pkg/discovery/multicast.go +++ b/pkg/discovery/multicast.go @@ -224,7 +224,7 @@ func (md *MulticastDiscovery) SendDiscoveryResponse(targetAddr *net.UDPAddr, tar } } - // 2. Fallback to UDP + // 2. Fallback to UDP — send via multicast so every listener sees the response responseDto := md.dto responseDto.Announce = false @@ -233,7 +233,12 @@ func (md *MulticastDiscovery) SendDiscoveryResponse(targetAddr *net.UDPAddr, tar return fmt.Errorf("failed to marshal response: %w", err) } - conn, err := net.DialUDP("udp4", nil, targetAddr) + respAddr, err := net.ResolveUDPAddr("udp4", md.config.MulticastAddr) + if err != nil { + return fmt.Errorf("failed to resolve multicast address: %w", err) + } + + conn, err := net.DialUDP("udp4", nil, respAddr) if err != nil { return fmt.Errorf("failed to create UDP connection: %w", err) } @@ -244,7 +249,7 @@ func (md *MulticastDiscovery) SendDiscoveryResponse(targetAddr *net.UDPAddr, tar return fmt.Errorf("failed to send discovery response: %w", err) } - md.logger.Debugf("Sent discovery response via UDP to %s", targetAddr) + md.logger.Debugf("Sent discovery response via multicast to %s", md.config.MulticastAddr) return nil } From de481d09be3785587c3e7cc713c849f5752a90cb Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:01:50 +0300 Subject: [PATCH 21/45] fix(scan): filter local machine out of HTTP scan results --- cmd/localgo/cmd/scan.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cmd/localgo/cmd/scan.go b/cmd/localgo/cmd/scan.go index 40b3ab9..4e10911 100644 --- a/cmd/localgo/cmd/scan.go +++ b/cmd/localgo/cmd/scan.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net" + "slices" "time" "github.com/bethropolis/localgo/pkg/cli" @@ -102,6 +103,17 @@ var scanCmd = &cobra.Command{ cli.PrintWarning("Scan completed with warnings: %v", scanErr) } + // Filter out the local machine from results + localIPs, _ := network.GetLocalIPAddresses() + foundDevices = slices.DeleteFunc(foundDevices, func(d *model.Device) bool { + for _, local := range localIPs { + if d.IP == local.String() { + return true + } + } + return false + }) + if !scanquiet && len(foundDevices) == 0 { zap.S().Warnf("No devices found during scan") cli.PrintWarning("No devices found during scan. Check your firewall or network.") From cf37d4676d638e702bf1c79b582d5632205bf3c0 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:01:50 +0300 Subject: [PATCH 22/45] fix(discover): fall back to HTTP subnet scan when multicast returns nothing --- cmd/localgo/cmd/discover.go | 45 ++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/cmd/localgo/cmd/discover.go b/cmd/localgo/cmd/discover.go index 0e4b568..3f0e647 100644 --- a/cmd/localgo/cmd/discover.go +++ b/cmd/localgo/cmd/discover.go @@ -3,12 +3,15 @@ package cmd import ( "context" "fmt" + "net" + "slices" "time" + "github.com/bethropolis/localgo/pkg/cli" "github.com/bethropolis/localgo/pkg/discovery" "github.com/bethropolis/localgo/pkg/help" - "github.com/bethropolis/localgo/pkg/cli" "github.com/bethropolis/localgo/pkg/model" + "github.com/bethropolis/localgo/pkg/network" "github.com/charmbracelet/huh/spinner" "github.com/spf13/cobra" "go.uber.org/zap" @@ -87,6 +90,46 @@ var discoverCmd = &cobra.Command{ cli.PrintWarning("Discovery completed with warnings: %v", discErr) } + // Fallback: if multicast finds nothing, try HTTP subnet scan + if len(foundDevices) == 0 { + if !discoverquiet { + cli.PrintInfo("Multicast returned no devices. Falling back to HTTP subnet scan...") + } + localIPs, ipErr := network.GetLocalIPAddresses() + if ipErr == nil && len(localIPs) > 0 { + var scanIps []net.IP + for _, ip := range localIPs { + scanIps = append(scanIps, network.GetSubnetIPs(ip)...) + } + registerDto := Cfg.ToRegisterDto() + httpDiscoverer := discovery.NewHTTPDiscovery(nil, registerDto, nil, zap.S()) + + scanCtx, scanCancel := context.WithTimeout(context.Background(), time.Duration(discovertimeout)*time.Second) + defer scanCancel() + + var scanDevices []*model.Device + _ = spinner.New(). + Title(fmt.Sprintf("Scanning %d IP addresses on port %d...", len(scanIps), Cfg.Port)). + Action(func() { + scanDevices, _ = httpDiscoverer.ScanNetwork(scanCtx, scanIps, Cfg.Port) + }). + Run() + + foundDevices = scanDevices + } + + // Filter out the local machine from results + localIPs, _ = network.GetLocalIPAddresses() + foundDevices = slices.DeleteFunc(foundDevices, func(d *model.Device) bool { + for _, local := range localIPs { + if d.IP == local.String() { + return true + } + } + return false + }) + } + if !discoverquiet && len(foundDevices) == 0 { zap.S().Warnf("No devices discovered") cli.PrintWarning("No devices discovered. Check your firewall or network.") From 2f4767520d38720e488da0448f8ac95c00f2957b Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:11:43 +0300 Subject: [PATCH 23/45] fix(send): remove interactive clipboard prompt, filepicker is the default TUI fallback --- cmd/localgo/cmd/send.go | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index 561f041..0dd3fbc 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -19,7 +19,6 @@ import ( "github.com/bethropolis/localgo/pkg/send" "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/bubbles/filepicker" - "github.com/charmbracelet/huh" "github.com/charmbracelet/huh/spinner" "github.com/spf13/cobra" "go.uber.org/zap" @@ -79,33 +78,6 @@ var sendCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { files := sendfiles - // Interactive fallback: if no files specified, check clipboard for text content - if len(files) == 0 && !sendclipboard { - if clipboard.Available() { - text, err := clipboard.Read() - if err == nil && strings.TrimSpace(text) != "" { - preview := strings.ReplaceAll(text, "\n", " ") - if len(preview) > 50 { - preview = preview[:50] + "…" - } - - var useClip bool = true - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("No files specified. Send clipboard content instead?"). - Description(fmt.Sprintf("Current clipboard: %q", preview)). - Value(&useClip), - ), - ).WithTheme(huh.ThemeCharm()) - - if err := form.Run(); err == nil && useClip { - sendclipboard = true - } - } - } - } - if sendclipboard { text, err := clipboard.Read() if err != nil { From c5b3a8da9ab763ca364d405719c38a161a6a48a0 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:31:26 +0300 Subject: [PATCH 24/45] fix(protocol): change ProtocolVersion from '2.1' to '2.0' to match spec The protocol field 'version' in all payloads must be '2.0' (major.minor), regardless of the spec document version (v2.1). --- pkg/config/config.go | 2 +- pkg/config/config_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 95a603e..075e1ae 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -18,7 +18,7 @@ import ( const ( DefaultPort = 53317 DefaultMulticastGroup = "224.0.0.167" - ProtocolVersion = "2.1" + ProtocolVersion = "2.0" DefaultSecurityDir = ".localgo_security" DefaultSecurityFile = "context.json" ) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 70ced83..5e4c428 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -266,8 +266,8 @@ func TestConfig_Constants(t *testing.T) { t.Errorf("Expected DefaultMulticastGroup '224.0.0.167', got '%s'", DefaultMulticastGroup) } - if ProtocolVersion != "2.1" { - t.Errorf("Expected ProtocolVersion '2.1', got '%s'", ProtocolVersion) + if ProtocolVersion != "2.0" { + t.Errorf("Expected ProtocolVersion '2.0', got '%s'", ProtocolVersion) } } From 01be941f8feb4dd377ff50fc083af5e396ec34d9 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:32:28 +0300 Subject: [PATCH 25/45] fix(protocol): select correct fingerprint in HTTP mode (random string, not cert hash) When encryption is off (HTTP), the fingerprint must be RandomFingerprint, not CertificateHash. Fixes send.go prepare-upload, info handler, and register handler response. --- pkg/send/send.go | 7 ++++++- pkg/server/handlers/discovery_handlers.go | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/pkg/send/send.go b/pkg/send/send.go index 33aef9b..4732d29 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -343,13 +343,18 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, infoDeviceType = model.DeviceTypeOther } + fingerprint := cfg.RandomFingerprint + if cfg.HttpsEnabled { + fingerprint = cfg.SecurityContext.CertificateHash + } + prepareDto := model.PrepareUploadRequestDto{ Info: model.InfoDto{ Alias: infoAlias, Version: config.ProtocolVersion, DeviceModel: infoDeviceModel, DeviceType: infoDeviceType, - Fingerprint: cfg.SecurityContext.CertificateHash, + Fingerprint: fingerprint, Download: true, }, Files: filesDtoMap, diff --git a/pkg/server/handlers/discovery_handlers.go b/pkg/server/handlers/discovery_handlers.go index 70386d3..5da41cb 100644 --- a/pkg/server/handlers/discovery_handlers.go +++ b/pkg/server/handlers/discovery_handlers.go @@ -48,6 +48,11 @@ func (h *DiscoveryHandler) InfoHandler(w http.ResponseWriter, r *http.Request) { downloadCapable := h.sendService.GetSession() != nil // True if we have an active send session + fingerprint := h.config.RandomFingerprint + if h.config.HttpsEnabled { + fingerprint = h.config.SecurityContext.CertificateHash + } + alias := h.config.Alias deviceModel := h.config.DeviceModel deviceType := h.config.DeviceType @@ -62,7 +67,7 @@ func (h *DiscoveryHandler) InfoHandler(w http.ResponseWriter, r *http.Request) { Version: config.ProtocolVersion, DeviceModel: deviceModel, DeviceType: deviceType, - Fingerprint: h.config.SecurityContext.CertificateHash, + Fingerprint: fingerprint, Download: downloadCapable, } @@ -110,6 +115,11 @@ func (h *DiscoveryHandler) RegisterHandler(w http.ResponseWriter, r *http.Reques downloadCapable := h.sendService.GetSession() != nil + fingerprint := h.config.RandomFingerprint + if h.config.HttpsEnabled { + fingerprint = h.config.SecurityContext.CertificateHash + } + respAlias := h.config.Alias respDeviceModel := h.config.DeviceModel respDeviceType := h.config.DeviceType @@ -124,7 +134,7 @@ func (h *DiscoveryHandler) RegisterHandler(w http.ResponseWriter, r *http.Reques Version: config.ProtocolVersion, DeviceModel: respDeviceModel, DeviceType: respDeviceType, - Fingerprint: h.config.SecurityContext.CertificateHash, + Fingerprint: fingerprint, Download: downloadCapable, } From a08245a83abf644712425d31f32e18a8ebfa9a8c Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:33:12 +0300 Subject: [PATCH 26/45] fix(security): use constant-time PIN comparison in DownloadHandler DownloadHandler was using a direct string comparison (timing attack vulnerable). PrepareDownloadHandler already used subtle.ConstantTimeCompare. --- pkg/server/handlers/download_handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/server/handlers/download_handlers.go b/pkg/server/handlers/download_handlers.go index 4a4693b..c4740fa 100644 --- a/pkg/server/handlers/download_handlers.go +++ b/pkg/server/handlers/download_handlers.go @@ -68,7 +68,7 @@ func (h *DownloadHandler) DownloadHandler(w http.ResponseWriter, r *http.Request // --- PIN Check --- if h.config.PIN != "" { pin := r.URL.Query().Get("pin") - if pin != h.config.PIN { + if subtle.ConstantTimeCompare([]byte(pin), []byte(h.config.PIN)) != 1 { httputil.RespondError(w, http.StatusUnauthorized, "Invalid PIN") return } From beb362949eb0a41e6a5ddbea635bf51dfed44078 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:34:19 +0300 Subject: [PATCH 27/45] fix(discovery): use POST /register instead of deprecated GET /info for HTTP subnet scan Per spec section 3.2, legacy HTTP discovery must use POST /api/localsend/v2/register, not GET /api/localsend/v2/info (which is deprecated and for debugging only). --- pkg/discovery/http_discovery.go | 53 +++------------------------------ 1 file changed, 4 insertions(+), 49 deletions(-) diff --git a/pkg/discovery/http_discovery.go b/pkg/discovery/http_discovery.go index e79a0a0..b7b68a2 100644 --- a/pkg/discovery/http_discovery.go +++ b/pkg/discovery/http_discovery.go @@ -71,55 +71,10 @@ func NewHTTPDiscovery(config *HTTPDiscoveryConfig, dto model.RegisterDto, handle } } -func (hd *HTTPDiscovery) fetchDeviceInfo(ctx context.Context, ip net.IP, port int, scheme string) (*model.Device, error) { - url := fmt.Sprintf("%s://%s/api/localsend/v2/info", scheme, net.JoinHostPort(ip.String(), strconv.Itoa(port))) - - hd.logger.Debugf("HTTPDiscovery: Fetching device info from URL: %s", url) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := hd.client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - var infoDto model.InfoDto - if err := json.Unmarshal(body, &infoDto); err != nil { - return nil, fmt.Errorf("failed to parse response body: %w", err) - } - - return &model.Device{ - IP: ip.String(), - Version: infoDto.Version, - Protocol: model.ProtocolType(scheme), - Port: port, - Alias: infoDto.Alias, - Fingerprint: infoDto.Fingerprint, - DeviceModel: infoDto.DeviceModel, - DeviceType: infoDto.DeviceType, - Download: infoDto.Download, - LastSeen: time.Now(), - Available: true, - }, nil -} - func (hd *HTTPDiscovery) FetchDeviceInfo(ctx context.Context, ip net.IP, port int) (*model.Device, error) { - device, err := hd.fetchDeviceInfo(ctx, ip, port, "https") + device, err := hd.RegisterWithDevice(ctx, ip, port, "https") if err != nil { - device, err = hd.fetchDeviceInfo(ctx, ip, port, "http") + device, err = hd.RegisterWithDevice(ctx, ip, port, "http") } return device, err } @@ -195,9 +150,9 @@ func (hd *HTTPDiscovery) ScanNetwork(ctx context.Context, ips []net.IP, port int sem <- struct{}{} defer func() { <-sem }() - device, err := hd.fetchDeviceInfo(ctx, ip, port, "https") + device, err := hd.RegisterWithDevice(ctx, ip, port, "https") if err != nil { - device, err = hd.fetchDeviceInfo(ctx, ip, port, "http") + device, err = hd.RegisterWithDevice(ctx, ip, port, "http") if err != nil { return } From 261b904297a805903e67b561b97e83b37b1fcec8 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:36:15 +0300 Subject: [PATCH 28/45] fix(protocol): implement session blocking, return 409 for concurrent sessions Per spec section 4.1, a 409 'Blocked by another session' must be returned when another session is active. CreateSession now rejects new sessions if any session already exists (fixes dead code path in handler). --- pkg/server/handlers/receive_handlers.go | 3 --- pkg/server/handlers/receive_handlers_test.go | 15 ++++----------- pkg/server/services/receive_service.go | 6 ++++++ pkg/server/services/receive_service_test.go | 19 ++++++++++++++----- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index 85396fa..ad9d54c 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -69,9 +69,6 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R } } - // --- Basic Session Check --- - // Concurrent sessions are now supported, so we no longer block if a session exists. - // --- Decode Request --- // Limit request body to prevent memory exhaustion from massive JSON (1 MB limit) decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024*1024)) diff --git a/pkg/server/handlers/receive_handlers_test.go b/pkg/server/handlers/receive_handlers_test.go index d57a56d..11797e0 100644 --- a/pkg/server/handlers/receive_handlers_test.go +++ b/pkg/server/handlers/receive_handlers_test.go @@ -113,11 +113,11 @@ func TestPrepareUploadHandlerV2_PINValidation(t *testing.T) { } } -func TestPrepareUploadHandlerV2_ConcurrentSessions(t *testing.T) { +func TestPrepareUploadHandlerV2_RejectsConcurrentSessions(t *testing.T) { handler, receiveService, _ := setupReceiveHandler(t, nil) // Create an active session - session1, _ := receiveService.CreateSession(model.DeviceInfo{IP: "192.168.1.100"}, map[string]model.FileDto{"f": {ID: "f"}}) + receiveService.CreateSession(model.DeviceInfo{IP: "192.168.1.100"}, map[string]model.FileDto{"f": {ID: "f"}}) reqDto := model.PrepareUploadRequestDto{ Files: map[string]model.FileDto{"file1": {ID: "file1", FileName: "test.txt", Size: 10}}, @@ -129,15 +129,8 @@ func TestPrepareUploadHandlerV2_ConcurrentSessions(t *testing.T) { handler.PrepareUploadHandlerV2(rr, req) - if status := rr.Code; status != http.StatusOK { - t.Errorf("handler returned wrong status code for concurrent session: got %v want %v", status, http.StatusOK) - } - - var respDto model.PrepareUploadResponseDto - json.NewDecoder(rr.Body).Decode(&respDto) - - if respDto.SessionID == "" || respDto.SessionID == session1.SessionID { - t.Errorf("expected new session to be created") + if status := rr.Code; status != http.StatusConflict { + t.Errorf("handler returned wrong status code for concurrent session: got %v want %v", status, http.StatusConflict) } } diff --git a/pkg/server/services/receive_service.go b/pkg/server/services/receive_service.go index 87cd1d6..a4439fb 100644 --- a/pkg/server/services/receive_service.go +++ b/pkg/server/services/receive_service.go @@ -1,6 +1,7 @@ package services import ( + "fmt" "sync" "time" @@ -74,10 +75,15 @@ func (s *ReceiveService) cleanupLoop() { } // CreateSession creates a new receive session. +// Returns an error if another session is already active (409 Blocked by another session). func (s *ReceiveService) CreateSession(sender model.DeviceInfo, files map[string]model.FileDto) (*ActiveReceiveSession, error) { s.sessionMutex.Lock() defer s.sessionMutex.Unlock() + if len(s.sessions) > 0 { + return nil, fmt.Errorf("another session is already active") + } + sessionId := uuid.NewString() sessionFiles := make(map[string]ActiveFile) for fileId, fileDto := range files { diff --git a/pkg/server/services/receive_service_test.go b/pkg/server/services/receive_service_test.go index 41dd22e..57993f4 100644 --- a/pkg/server/services/receive_service_test.go +++ b/pkg/server/services/receive_service_test.go @@ -44,7 +44,7 @@ func TestReceiveService_CreateSession(t *testing.T) { } } -func TestReceiveService_CreateSession_MultipleSessions(t *testing.T) { +func TestReceiveService_CreateSession_BlocksConcurrent(t *testing.T) { svc := NewReceiveService() sender := model.DeviceInfo{ @@ -57,11 +57,20 @@ func TestReceiveService_CreateSession_MultipleSessions(t *testing.T) { "file1": {ID: "file1", FileName: "test.txt", Size: 1024}, } - createdSession1, _ := svc.CreateSession(sender, files) - createdSession2, _ := svc.CreateSession(sender, files) + first, err := svc.CreateSession(sender, files) + if err != nil { + t.Fatalf("First CreateSession should succeed: %v", err) + } + if first == nil { + t.Fatal("Expected non-nil session") + } - if createdSession1.SessionID == createdSession2.SessionID { - t.Error("Expected different session IDs for concurrent sessions") + second, err := svc.CreateSession(sender, files) + if err == nil { + t.Error("Expected error for second concurrent session, got nil") + } + if second != nil { + t.Error("Expected nil session for blocked concurrent session") } } From fd653576327fd9f8bd021f3ecca20ceb2754b2ce Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:37:10 +0300 Subject: [PATCH 29/45] fix(protocol): validate ?sessionId in PrepareDownloadHandler Per spec section 5.2, if ?sessionId is provided, the handler should validate it matches the actual session (use GetSessionByID). If omitted, fall back to GetSession() as before. --- pkg/server/handlers/download_handlers.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/server/handlers/download_handlers.go b/pkg/server/handlers/download_handlers.go index c4740fa..0b007aa 100644 --- a/pkg/server/handlers/download_handlers.go +++ b/pkg/server/handlers/download_handlers.go @@ -43,7 +43,12 @@ func (h *DownloadHandler) PrepareDownloadHandler(w http.ResponseWriter, r *http. } } - session := h.sendService.GetSession() + var session *services.ActiveSendSession + if sessionID := r.URL.Query().Get("sessionId"); sessionID != "" { + session = h.sendService.GetSessionByID(sessionID) + } else { + session = h.sendService.GetSession() + } if session == nil { httputil.RespondError(w, http.StatusNotFound, "No active sharing session") return From 0c4ea8065f65714998e0c07b116fef2e4fec3713 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:38:47 +0300 Subject: [PATCH 30/45] fix(protocol): use valid deviceType 'headless' in private mode, return no body on upload/cancel - Private mode now uses 'headless' (valid spec enum) instead of 'other' - Upload and cancel success responses return no body (w.WriteHeader) per spec --- pkg/config/config.go | 4 ++-- pkg/config/dto.go | 2 +- pkg/send/send.go | 2 +- pkg/server/handlers/discovery_handlers.go | 4 ++-- pkg/server/handlers/receive_handlers.go | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 075e1ae..ff262f5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -255,7 +255,7 @@ func (c *Config) ToRegisterDto() model.RegisterDto { if c.Private { alias = "Anonymous" deviceModel = nil - deviceType = model.DeviceTypeOther + deviceType = model.DeviceTypeHeadless } return model.RegisterDto{ Alias: alias, @@ -281,7 +281,7 @@ func (c *Config) ToInfoDto() model.InfoDto { if c.Private { alias = "Anonymous" deviceModel = nil - deviceType = model.DeviceTypeOther + deviceType = model.DeviceTypeHeadless } return model.InfoDto{ Alias: alias, diff --git a/pkg/config/dto.go b/pkg/config/dto.go index 2a2b9e4..a9fe8d2 100644 --- a/pkg/config/dto.go +++ b/pkg/config/dto.go @@ -26,7 +26,7 @@ func (c *Config) ToMulticastDto(download bool) model.MulticastDto { if c.Private { alias = "Anonymous" deviceModel = nil - deviceType = model.DeviceTypeOther + deviceType = model.DeviceTypeHeadless } return model.MulticastDto{ Alias: alias, diff --git a/pkg/send/send.go b/pkg/send/send.go index 4732d29..ab735e0 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -340,7 +340,7 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, if cfg.Private { infoAlias = "Anonymous" infoDeviceModel = nil - infoDeviceType = model.DeviceTypeOther + infoDeviceType = model.DeviceTypeHeadless } fingerprint := cfg.RandomFingerprint diff --git a/pkg/server/handlers/discovery_handlers.go b/pkg/server/handlers/discovery_handlers.go index 5da41cb..a60a476 100644 --- a/pkg/server/handlers/discovery_handlers.go +++ b/pkg/server/handlers/discovery_handlers.go @@ -59,7 +59,7 @@ func (h *DiscoveryHandler) InfoHandler(w http.ResponseWriter, r *http.Request) { if h.config.Private { alias = "Anonymous" deviceModel = nil - deviceType = model.DeviceTypeOther + deviceType = model.DeviceTypeHeadless } dto := model.InfoDto{ @@ -126,7 +126,7 @@ func (h *DiscoveryHandler) RegisterHandler(w http.ResponseWriter, r *http.Reques if h.config.Private { respAlias = "Anonymous" respDeviceModel = nil - respDeviceType = model.DeviceTypeOther + respDeviceType = model.DeviceTypeHeadless } responseDto := model.InfoDto{ diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index ad9d54c..117a4db 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -276,7 +276,7 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, "", int64(len(textBytes)), fileInfo.Dto.FileType, history.StatusClipboard) h.runExecHook("", rawFileName, session.Sender.Alias, session.Sender.IP, int64(len(textBytes))) - httputil.RespondJSON(w, http.StatusOK, struct{}{}) + w.WriteHeader(http.StatusOK) return } else { // Clipboard unavailable — fall back to file. @@ -305,7 +305,7 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, fileInfo.Dto.Size, fileInfo.Dto.FileType, history.StatusReceived) h.runExecHook(destinationPath, rawFileName, session.Sender.Alias, session.Sender.IP, fileInfo.Dto.Size) - httputil.RespondJSON(w, http.StatusOK, struct{}{}) + w.WriteHeader(http.StatusOK) } // PrepareUploadHandlerV1 handles POST /v1/prepare-upload requests (older protocol). @@ -505,5 +505,5 @@ func (h *ReceiveHandler) CancelHandler(w http.ResponseWriter, r *http.Request) { // successful transfer, so this is the normal post-upload flow — return 200. h.logger.Infof("/cancel received for already-closed session %s — treating as success.", reqSessionId) } - httputil.RespondJSON(w, http.StatusOK, struct{}{}) + w.WriteHeader(http.StatusOK) } From 4825c46dec47f2ff2849d6508653fc1170e8fc8b Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:41:18 +0300 Subject: [PATCH 31/45] refactor(dto): remove spec-noncompliant extra fields from DTO structs PrepareUploadRequestDto: removed sendZipped, forceBulk, targetPath, keepFolders, token (extensions not in spec section 4.1) FileDto: removed legacy field PrepareUploadResponseDto: removed token field DeviceType: removed laptop/tablet/other (not in spec section 7.1) --- pkg/model/dto.go | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/pkg/model/dto.go b/pkg/model/dto.go index e3f8977..f09f276 100644 --- a/pkg/model/dto.go +++ b/pkg/model/dto.go @@ -12,9 +12,6 @@ const ( DeviceTypeWeb DeviceType = "web" DeviceTypeHeadless DeviceType = "headless" DeviceTypeServer DeviceType = "server" - DeviceTypeLaptop DeviceType = "laptop" - DeviceTypeTablet DeviceType = "tablet" - DeviceTypeOther DeviceType = "other" ) // ProtocolType defines the protocol type. @@ -127,13 +124,8 @@ type MulticastDto struct { // PrepareUploadRequestDto is sent to prepare file uploads type PrepareUploadRequestDto struct { - Info InfoDto `json:"info"` - Files map[string]FileDto `json:"files"` - SendZipped bool `json:"sendZipped"` - ForceBulk bool `json:"forceBulk"` - TargetPath string `json:"targetPath"` - KeepFolders bool `json:"keepFolders"` - Token string `json:"token,omitempty"` + Info InfoDto `json:"info"` + Files map[string]FileDto `json:"files"` } // FileDto contains information about a file being uploaded @@ -145,7 +137,6 @@ type FileDto struct { SHA256 *string `json:"sha256,omitempty"` // Use pointer for nullable Preview *string `json:"preview,omitempty"` // Use pointer for nullable Metadata *FileMetadata `json:"metadata,omitempty"` // Use pointer for nullable - Legacy bool `json:"legacy,omitempty"` // Added from Dart code } // FileMetadata holds optional file metadata (added in v2.1) @@ -158,7 +149,6 @@ type FileMetadata struct { type PrepareUploadResponseDto struct { SessionID string `json:"sessionId"` Files map[string]string `json:"files"` - Token string `json:"token,omitempty"` } // ReceiveRequestResponseDto is returned for download preparations From 52f39a853239b95d9f29a68190c864295c296320 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:53:39 +0300 Subject: [PATCH 32/45] fix(protocol): add port/protocol to prepare-upload info block Per spec section 4.1, prepare-upload requests require port and protocol in the info block. Added fields to InfoDto with omitempty so /info and /prepare-download responses remain clean. --- pkg/model/dto.go | 15 +++++++++------ pkg/send/send.go | 2 ++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/pkg/model/dto.go b/pkg/model/dto.go index f09f276..a8f44b9 100644 --- a/pkg/model/dto.go +++ b/pkg/model/dto.go @@ -88,13 +88,16 @@ func (d *DeviceInfo) ToMulticastDto(announce bool) MulticastDto { } // InfoDto represents the response for /info and /register endpoints. +// Also used as the info block in prepare-upload requests (port + protocol required). type InfoDto struct { - Alias string `json:"alias"` - Version string `json:"version"` - DeviceModel *string `json:"deviceModel"` // nullable - DeviceType DeviceType `json:"deviceType"` - Fingerprint string `json:"fingerprint"` - Download bool `json:"download"` + Alias string `json:"alias"` + Version string `json:"version"` + DeviceModel *string `json:"deviceModel"` // nullable + DeviceType DeviceType `json:"deviceType"` + Fingerprint string `json:"fingerprint"` + Port int `json:"port,omitempty"` + Protocol ProtocolType `json:"protocol,omitempty"` + Download bool `json:"download"` } // RegisterDto represents the request body for /register endpoint (sent by the discoverer). diff --git a/pkg/send/send.go b/pkg/send/send.go index ab735e0..3ca2d56 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -355,6 +355,8 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, DeviceModel: infoDeviceModel, DeviceType: infoDeviceType, Fingerprint: fingerprint, + Port: device.Port, + Protocol: model.ProtocolType(scheme), Download: true, }, Files: filesDtoMap, From 221bfda12d836eb916d72546596ea031153cab09 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:55:01 +0300 Subject: [PATCH 33/45] fix(security): verify TLS certificate fingerprint during file transfer Adds a VerifyConnection callback that SHA-256 hashes the peer certificate and compares it against the device's advertised fingerprint, preventing LAN MitM attacks. InsecureSkipVerify remains true (self-signed LAN certs) but the fingerprint check ensures the connection matches the discovered device. --- pkg/send/send.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/pkg/send/send.go b/pkg/send/send.go index 3ca2d56..acd01fb 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -3,7 +3,9 @@ package send import ( "bytes" "context" + "crypto/sha256" "crypto/tls" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -265,8 +267,23 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, if device.Protocol == model.ProtocolTypeHTTPS { scheme = "https" + expectedFingerprint := device.Fingerprint tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + VerifyConnection: func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return fmt.Errorf("no peer certificates presented") + } + cert := state.PeerCertificates[0] + hash := sha256.Sum256(cert.Raw) + actual := hex.EncodeToString(hash[:]) + if actual != expectedFingerprint { + return fmt.Errorf("TLS certificate fingerprint mismatch") + } + return nil + }, + }, } client.Transport = tr defer tr.CloseIdleConnections() From d1af3c17707b749e8b7e8367102ab3ffd67d1be1 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:57:33 +0300 Subject: [PATCH 34/45] fix(protocol): force HTTP for share command (browser download API) Per spec section 5, the browser download API must use HTTP because browsers reject self-signed certificates. share command now forces Cfg.HttpsEnabled = false unconditionally. Added --https flag for users who explicitly want HTTPS (with warning in description). The --http flag is now a deprecated no-op. --- cmd/localgo/cmd/share.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cmd/localgo/cmd/share.go b/cmd/localgo/cmd/share.go index 0242b8f..5aa6211 100644 --- a/cmd/localgo/cmd/share.go +++ b/cmd/localgo/cmd/share.go @@ -28,6 +28,7 @@ var ( sharefiles []string shareport int shareuseHTTP bool + shareuseHTTPS bool sharepin string sharealias string shareautoAccept bool @@ -54,8 +55,10 @@ var shareCmd = &cobra.Command{ if shareport > 0 { Cfg.Port = shareport } - if shareuseHTTP { - Cfg.HttpsEnabled = false + // Browser download API must use HTTP (browsers reject self-signed certs) + Cfg.HttpsEnabled = false + if shareuseHTTPS { + Cfg.HttpsEnabled = true } if sharepin != "" { Cfg.PIN = sharepin @@ -257,7 +260,8 @@ func init() { rootCmd.AddCommand(shareCmd) shareCmd.Flags().StringSliceVar(&sharefiles, "file", []string{}, "File or directory to share") shareCmd.Flags().IntVar(&shareport, "port", 0, "Port to run the server on") - shareCmd.Flags().BoolVar(&shareuseHTTP, "http", false, "Use HTTP instead of HTTPS") + shareCmd.Flags().BoolVar(&shareuseHTTP, "http", false, "Deprecated (HTTP is now default for share)") + shareCmd.Flags().BoolVar(&shareuseHTTPS, "https", false, "Use HTTPS (browsers will reject self-signed certs)") shareCmd.Flags().StringVar(&sharepin, "pin", "", "PIN for authentication") shareCmd.Flags().StringVar(&sharealias, "alias", "", "Device alias") shareCmd.Flags().BoolVar(&shareautoAccept, "auto-accept", false, "Auto-accept incoming files") From 68d35a9af55a4fd8dff72a0d452fa76f1bdc9c92 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:41:33 +0300 Subject: [PATCH 35/45] fix: improve TLS error diag, always prompt device picker, silence usage on errors - Include both expected and actual fingerprints in TLS mismatch error - PickDevice no longer auto-selects when only 1 device is found - send command sets SilenceUsage to hide help text on errors --- cmd/localgo/cmd/send.go | 5 +++-- pkg/cli/output.go | 3 --- pkg/send/send.go | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index 0dd3fbc..7605534 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -73,8 +73,9 @@ func (m filePickerModel) View() string { } var sendCmd = &cobra.Command{ - Use: "send", - Short: "Send a file to another LocalGo device", + Use: "send", + Short: "Send a file to another LocalGo device", + SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { files := sendfiles diff --git a/pkg/cli/output.go b/pkg/cli/output.go index eff89af..7374bc7 100644 --- a/pkg/cli/output.go +++ b/pkg/cli/output.go @@ -362,9 +362,6 @@ func PickDevice(devices []*model.Device, private bool) *model.Device { if len(devices) == 0 { return nil } - if len(devices) == 1 { - return devices[0] - } var selected *model.Device options := make([]huh.Option[*model.Device], len(devices)) diff --git a/pkg/send/send.go b/pkg/send/send.go index acd01fb..4d82246 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -279,7 +279,7 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, hash := sha256.Sum256(cert.Raw) actual := hex.EncodeToString(hash[:]) if actual != expectedFingerprint { - return fmt.Errorf("TLS certificate fingerprint mismatch") + return fmt.Errorf("TLS certificate fingerprint mismatch: expected %s, got %s", expectedFingerprint, actual) } return nil }, From 0f2c8ce0de8c2c29c5f39b99d6b857b03824ce88 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:41:46 +0300 Subject: [PATCH 36/45] chore: final state after protocol audit fixes From c0edea8f10e80c6cc1d4951099b87573a3cc5da1 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:48:58 +0300 Subject: [PATCH 37/45] fix: case-insensitive TLS fingerprint comparison hex.EncodeToString produces lowercase, but old security context files may have uppercase CertificateHash. Use strings.EqualFold for the comparison. --- pkg/send/send.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/send/send.go b/pkg/send/send.go index 4d82246..bb4591d 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -278,7 +278,7 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, cert := state.PeerCertificates[0] hash := sha256.Sum256(cert.Raw) actual := hex.EncodeToString(hash[:]) - if actual != expectedFingerprint { + if !strings.EqualFold(actual, expectedFingerprint) { return fmt.Errorf("TLS certificate fingerprint mismatch: expected %s, got %s", expectedFingerprint, actual) } return nil From 3d9c9bbc0b996865daba5b1055a828e09e994a54 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:54:31 +0300 Subject: [PATCH 38/45] fix: bug fix --- scripts/install.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 14899dc..c0eee04 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -587,25 +587,25 @@ main() { fi # Run installation steps sequentially with progress counters - section "1/7" "Verifying system prerequisites" + section "1" "Verifying system prerequisites" check_prerequisites - section "2/7" "Compiling LocalGo static binary" + section "2" "Compiling LocalGo static binary" build_binary - section "3/7" "Creating configuration directories" + section "3" "Creating configuration directories" create_directories - section "4/7" "Installing executable binary" + section "4" "Installing executable binary" install_binary - section "5/7" "Deploying configuration environment" + section "5" "Deploying configuration environment" install_configuration - section "6/7" "Configuring systemd background services" + section "6" "Configuring systemd background services" install_service - section "7/7" "Generating shell auto-completions" + section "7" "Generating shell auto-completions" install_completion echo From 53ffe3d7805c1677e08f4170e606c342d337fca5 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 03:06:04 +0300 Subject: [PATCH 39/45] feat(share): add TUI file picker, extract shared picker to pkg/cli - Extract FilePickerModel and LaunchFilePicker to pkg/cli/filepicker.go - send command now uses cli.LaunchFilePicker instead of local model - share command launches file picker when --file is omitted - share auto-enables --zip when a directory is selected via picker --- cmd/localgo/cmd/send.go | 54 ++-------------------------------- cmd/localgo/cmd/share.go | 13 ++++++++- pkg/cli/filepicker.go | 62 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 52 deletions(-) create mode 100644 pkg/cli/filepicker.go diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index 7605534..d42540a 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -17,8 +17,6 @@ import ( "github.com/bethropolis/localgo/pkg/model" "github.com/bethropolis/localgo/pkg/network" "github.com/bethropolis/localgo/pkg/send" - "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/bubbles/filepicker" "github.com/charmbracelet/huh/spinner" "github.com/spf13/cobra" "go.uber.org/zap" @@ -36,42 +34,6 @@ var ( sendclipboard bool ) -// filePickerModel wraps bubbles/filepicker.Model as a tea.Model for TUI file selection. -type filePickerModel struct { - fp filepicker.Model - file string - quit bool -} - -func (m filePickerModel) Init() tea.Cmd { - return m.fp.Init() -} - -func (m filePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "q", "esc", "ctrl+c": - m.quit = true - return m, tea.Quit - } - } - var cmd tea.Cmd - m.fp, cmd = m.fp.Update(msg) - if m.fp.Path != "" { - m.file = m.fp.Path - return m, tea.Quit - } - return m, cmd -} - -func (m filePickerModel) View() string { - if m.quit { - return "" - } - return m.fp.View() -} - var sendCmd = &cobra.Command{ Use: "send", Short: "Send a file to another LocalGo device", @@ -103,19 +65,9 @@ var sendCmd = &cobra.Command{ } if len(files) == 0 { - // Launch interactive TUI file picker - fp := filepicker.New() - fp.DirAllowed = true - fp.FileAllowed = true - fp.ShowHidden = false - - m := filePickerModel{fp: fp} - p := tea.NewProgram(m) - result, err := p.Run() - if err == nil { - if picked, ok := result.(filePickerModel); ok && picked.file != "" { - files = []string{picked.file} - } + selected, err := cli.LaunchFilePicker() + if err == nil && selected != "" { + files = []string{selected} } } diff --git a/cmd/localgo/cmd/share.go b/cmd/localgo/cmd/share.go index 5aa6211..ed9a452 100644 --- a/cmd/localgo/cmd/share.go +++ b/cmd/localgo/cmd/share.go @@ -48,7 +48,18 @@ var shareCmd = &cobra.Command{ files := sharefiles if len(files) == 0 { - return fmt.Errorf("file parameter is required (use --file)") + selected, err := cli.LaunchFilePicker() + if err != nil { + return fmt.Errorf("file picker failed: %w", err) + } + if selected == "" { + return fmt.Errorf("no file selected") + } + files = []string{selected} + // Auto-enable zipping if the user selected a directory without --zip + if info, statErr := os.Stat(selected); statErr == nil && info.IsDir() && !sharezip { + sharezip = true + } } // Apply overrides diff --git a/pkg/cli/filepicker.go b/pkg/cli/filepicker.go new file mode 100644 index 0000000..18481d2 --- /dev/null +++ b/pkg/cli/filepicker.go @@ -0,0 +1,62 @@ +package cli + +import ( + "github.com/charmbracelet/bubbles/filepicker" + tea "github.com/charmbracelet/bubbletea" +) + +// FilePickerModel wraps bubbles/filepicker.Model as a tea.Model for TUI file selection. +type FilePickerModel struct { + fp filepicker.Model + File string + quit bool +} + +func (m FilePickerModel) Init() tea.Cmd { + return m.fp.Init() +} + +func (m FilePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "esc", "ctrl+c": + m.quit = true + return m, tea.Quit + } + } + var cmd tea.Cmd + m.fp, cmd = m.fp.Update(msg) + if m.fp.Path != "" { + m.File = m.fp.Path + return m, tea.Quit + } + return m, cmd +} + +func (m FilePickerModel) View() string { + if m.quit { + return "" + } + return m.fp.View() +} + +// LaunchFilePicker opens an interactive TUI file browser and returns the selected file path. +// Returns empty string if the user cancelled. +func LaunchFilePicker() (string, error) { + fp := filepicker.New() + fp.DirAllowed = true + fp.FileAllowed = true + fp.ShowHidden = false + + m := FilePickerModel{fp: fp} + p := tea.NewProgram(m) + result, err := p.Run() + if err != nil { + return "", err + } + if picked, ok := result.(FilePickerModel); ok { + return picked.File, nil + } + return "", nil +} From 51de7a2d6455dcbf3be25d9c32336427e3576dde Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 22 Jun 2026 03:09:16 +0300 Subject: [PATCH 40/45] fix: remove duplicate -p shorthand in devices command --probe used -p which conflicted with the global --private/-p flag, causing a panic when Cobra merged flagsets. --- cmd/localgo/cmd/devices.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/localgo/cmd/devices.go b/cmd/localgo/cmd/devices.go index 46f4e48..44b7b06 100644 --- a/cmd/localgo/cmd/devices.go +++ b/cmd/localgo/cmd/devices.go @@ -114,7 +114,7 @@ var devicesCmd = &cobra.Command{ func init() { rootCmd.AddCommand(devicesCmd) devicesCmd.Flags().BoolVar(&devicesjsonOutput, "json", false, "Output in JSON format") - devicesCmd.Flags().BoolVarP(&devicesProbe, "probe", "p", false, "Probe cached devices to verify if they are currently online") + devicesCmd.Flags().BoolVar(&devicesProbe, "probe", false, "Probe cached devices to verify if they are currently online") devicesCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { if h := help.GetCommandHelp("devices"); h != nil { From 16da01b3c4bd6c7c1c205fb588288bc88f1e7191 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:59:32 +0300 Subject: [PATCH 41/45] fix: stability fixes and enhancements --- cmd/localgo/cmd/send.go | 16 +++++++-- cmd/localgo/cmd/share.go | 13 +++++--- pkg/help/help.go | 43 +++++++++++++++++++++---- pkg/server/handlers/receive_handlers.go | 3 +- 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index d42540a..e6ad8fe 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -145,6 +145,8 @@ var sendCmd = &cobra.Command{ } target := sendto + var selectedDevice *model.Device + if target == "" { sendConfig := discovery.DefaultServiceConfig() sendConfig.MulticastConfig.InterfaceName = Cfg.MulticastInterface @@ -213,6 +215,7 @@ var sendCmd = &cobra.Command{ } target = selected.Alias sendport = selected.Port + selectedDevice = selected } if sendalias != "" { @@ -232,17 +235,24 @@ var sendCmd = &cobra.Command{ cli.PrintInfo("- %s (%s)", filepath.Base(file), cli.FormatBytes(fileInfo.Size())) } } - cli.PrintInfo("To: %s", target) fromAlias := Cfg.Alias if Cfg.Private { fromAlias = "Anonymous" } - cli.PrintInfo("From: %s", fromAlias) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(sendtimeout)*time.Second) defer cancel() - err := send.SendFiles(ctx, Cfg, files, target, sendport, zap.S()) + var err error + if selectedDevice != nil { + cli.PrintInfo("To: %s (%s:%d)", selectedDevice.Alias, selectedDevice.IP, selectedDevice.Port) + cli.PrintInfo("From: %s", fromAlias) + err = send.SendToDevice(ctx, Cfg, selectedDevice, files, zap.S()) + } else { + cli.PrintInfo("To: %s", target) + cli.PrintInfo("From: %s", fromAlias) + err = send.SendFiles(ctx, Cfg, files, target, sendport, zap.S()) + } if err != nil { return fmt.Errorf("failed to send files: %w", err) } diff --git a/cmd/localgo/cmd/share.go b/cmd/localgo/cmd/share.go index ed9a452..ccb7a44 100644 --- a/cmd/localgo/cmd/share.go +++ b/cmd/localgo/cmd/share.go @@ -327,12 +327,15 @@ func zipDirToTemp(dir string) (string, error) { if err != nil { return err } - f, err := os.Open(path) - if err != nil { + err = func() error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(w, f) return err - } - _, err = io.Copy(w, f) - f.Close() + }() return err }) if err != nil { diff --git a/pkg/help/help.go b/pkg/help/help.go index b137a02..753b454 100644 --- a/pkg/help/help.go +++ b/pkg/help/help.go @@ -2,8 +2,10 @@ package help import ( "fmt" + "strings" "github.com/bethropolis/localgo/pkg/cli" + "github.com/charmbracelet/lipgloss" ) // CommandHelp represents help information for a command @@ -50,17 +52,44 @@ func ShowMainUsage() { {"version", "Show version information"}, } + maxCmdWidth := 0 for _, cmd := range commands { - fmt.Printf(" %-12s %s\n", cli.SuccessStyle.Render(cmd.name), cmd.desc) + if w := lipgloss.Width(cmd.name); w > maxCmdWidth { + maxCmdWidth = w + } + } + cmdPad := maxCmdWidth + 2 + + for _, cmd := range commands { + styledName := cli.SuccessStyle.Render(cmd.name) + padding := cmdPad - lipgloss.Width(cmd.name) + fmt.Printf(" %s%s%s\n", styledName, strings.Repeat(" ", padding), cmd.desc) } fmt.Printf("\n%s\n", cli.WarningStyle.Render("OPTIONS:")) - fmt.Printf(" %s Show help\n", cli.InfoStyle.Render("-h, --help")) - fmt.Printf(" %s Show version\n", cli.InfoStyle.Render("-v, --version")) - fmt.Printf(" %s Enable debug logging\n", cli.InfoStyle.Render("--verbose")) - fmt.Printf(" %s Enable JSON log output\n", cli.InfoStyle.Render("--json")) - fmt.Printf(" %s Hide device identity during discovery/transfer\n", cli.InfoStyle.Render("--private, -p")) - fmt.Printf(" %s Config file path\n\n", cli.InfoStyle.Render("--config")) + options := []struct{ flag, desc string }{ + {"-h, --help", "Show help"}, + {"-v, --version", "Show version"}, + {"--verbose", "Enable debug logging"}, + {"--json", "Enable JSON log output"}, + {"--private, -p", "Hide device identity during discovery/transfer"}, + {"--config", "Config file path"}, + } + + maxOptWidth := 0 + for _, opt := range options { + if w := lipgloss.Width(opt.flag); w > maxOptWidth { + maxOptWidth = w + } + } + optPad := maxOptWidth + 2 + + for _, opt := range options { + styledFlag := cli.InfoStyle.Render(opt.flag) + padding := optPad - lipgloss.Width(opt.flag) + fmt.Printf(" %s%s%s\n", styledFlag, strings.Repeat(" ", padding), opt.desc) + } + fmt.Println() fmt.Printf("%s\n", cli.WarningStyle.Render("EXAMPLES:")) examples := []string{ diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index 117a4db..db2c961 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -82,7 +82,8 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R defer r.Body.Close() if len(requestDto.Files) == 0 { - httputil.RespondError(w, http.StatusBadRequest, "Request must contain at least one file") + h.logger.Info("Received empty file list on prepare-upload, returning 204 Finished") + w.WriteHeader(http.StatusNoContent) return } From b43e4230b33cb63ac213e9c44828d7c734f50fc7 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:52:45 +0300 Subject: [PATCH 42/45] feat: add GitHub Pages docs site and online installer - docs/index.html: docsify-based documentation site with search - docs/_coverpage.md: landing page with short install one-liner - .github/workflows/deploy-docs.yml: CI to deploy docs/ to Pages - scripts/online-install.sh: zero-dependency curl|bash installer from GitHub Releases The short URL https://bethropolis.github.io/localgo/install.sh replaces the long raw.githubusercontent.com URL for one-liner installs. --- .github/workflows/deploy-docs.yml | 43 +++ docs/_coverpage.md | 11 + docs/index.html | 31 ++ scripts/online-install.sh | 514 ++++++++++++++++++++++++++++++ 4 files changed, 599 insertions(+) create mode 100644 .github/workflows/deploy-docs.yml create mode 100644 docs/_coverpage.md create mode 100644 docs/index.html create mode 100644 scripts/online-install.sh diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..5e0a6c5 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,43 @@ +name: Deploy Docs + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - name: Prepare docs directory + run: | + cp README.md docs/ + # Fix relative links for Pages (docs/X.md → X.md at root) + sed -i 's|(docs/|(|g' docs/README.md + cp scripts/online-install.sh docs/install.sh + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/docs/_coverpage.md b/docs/_coverpage.md new file mode 100644 index 0000000..8ada183 --- /dev/null +++ b/docs/_coverpage.md @@ -0,0 +1,11 @@ +# LocalGo + +**LocalSend v2.1 Protocol — LAN file transfer CLI** + +One-line installation: + +```bash +curl -fsSL https://bethropolis.github.io/localgo/install.sh | bash +``` + +[Get Started](README.md) diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..98d4e91 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,31 @@ + + + + + + + LocalGo — LocalSend v2.1 CLI + + + +
+ + + + + diff --git a/scripts/online-install.sh b/scripts/online-install.sh new file mode 100644 index 0000000..52e7b3d --- /dev/null +++ b/scripts/online-install.sh @@ -0,0 +1,514 @@ +#!/bin/bash +# +# LocalGo Online Installer +# Downloads and installs the latest pre-built LocalGo binary from GitHub Releases. +# No Go toolchain required. Works on Linux (amd64/arm64) and macOS (amd64/arm64). +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/bethropolis/localgo/main/scripts/online-install.sh | bash +# curl -fsSL ... | bash -s -- --mode system +# curl -fsSL ... | bash -s -- --mode system --service --completion +# curl -fsSL ... | bash -s -- --version 0.5.5 +# + +set -euo pipefail + +# ── Constants ────────────────────────────────────────────────────── +BINARY_NAME="localgo" +GH_OWNER="bethropolis" +GH_REPO="localgo" +GH_URL="https://github.com/$GH_OWNER/$GH_REPO" +GH_API="https://api.github.com/repos/$GH_OWNER/$GH_REPO" + +USER_BIN_DIR="$HOME/.local/bin" +USER_CONFIG_DIR="$HOME/.config/localgo" +USER_SERVICE_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" + +SYSTEM_BIN_DIR="/usr/local/bin" +SYSTEM_CONFIG_DIR="/etc/localgo" +SYSTEM_SERVICE_DIR="/etc/systemd/system" + +# ── Flags (defaults — all extras opt-in) ─────────────────────────── +INSTALL_MODE="user" +INSTALL_SERVICE=false +INSTALL_COMPLETION=false +INSTALL_CONFIG=false +ASSUME_YES=false +DRY_RUN=false +PINNED_VERSION="" + +# ── Runtime ──────────────────────────────────────────────────────── +OS="" +ARCH="" +VERSION="" +TAG="" +TMPDIR="" +BINARY_PATH="" + +# ── Colors ───────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +NC='\033[0m' + +# ── Output helpers ───────────────────────────────────────────────── +info() { echo -e " ${BLUE}ℹ${NC} $*"; } +ok() { echo -e " ${GREEN}✔${NC} $*"; } +warn() { echo -e " ${YELLOW}⚠${NC} $*"; } +err() { echo -e " ${RED}✖${NC} $*" >&2; } +header(){ echo -e "\n ${MAGENTA}◆${NC} $*"; } + +# ── Usage ───────────────────────────────────────────────────────── +usage() { + cat </dev/null || true) + + if [[ -z "$latest_url" || "$latest_url" == *"/releases/latest" ]]; then + # Fallback: GitHub JSON API + info "Redirect resolution failed, falling back to GitHub API..." + latest_url=$(curl -sL "$GH_API/releases/latest" 2>/dev/null \ + | grep '"tag_name":' | sed -E 's/.*"tag_name": "([^"]+)".*/\1/' || true) + [[ -z "$latest_url" ]] && die "Failed to resolve latest version. Use --version to specify one." + TAG="$latest_url" + else + TAG=$(echo "$latest_url" | sed 's|.*/||') + fi + + VERSION="${TAG#v}" + ok "Latest release: $TAG" +} + +# ── Step 3: Print Plan ───────────────────────────────────────────── +print_plan() { + local dest + if [[ "$INSTALL_MODE" == "system" ]]; then + dest="$SYSTEM_BIN_DIR/$BINARY_NAME" + else + dest="$USER_BIN_DIR/$BINARY_NAME" + fi + + echo + echo " ┌─ Installation Plan ──────────────────────────────────┐" + printf " │ %-20s %-30s │\n" "Version:" "$TAG" + printf " │ %-20s %-30s │\n" "Platform:" "${OS}_${ARCH}" + printf " │ %-20s %-30s │\n" "Destination:" "$dest" + printf " │ %-20s %-30s │\n" "Service:" "$( [[ $INSTALL_SERVICE == true ]] && echo yes || echo no )" + printf " │ %-20s %-30s │\n" "Completions:" "$( [[ $INSTALL_COMPLETION == true ]] && echo yes || echo no )" + printf " │ %-20s %-30s │\n" "Config:" "$( [[ $INSTALL_CONFIG == true ]] && echo yes || echo no )" + echo " └──────────────────────────────────────────────────────┘" + echo + + if [[ "$INSTALL_MODE" == "system" && $EUID -ne 0 ]]; then + info "System mode: sudo will be used for file operations." + fi +} + +# ── Step 4: Download + Verify ────────────────────────────────────── +download_and_verify() { + header "Downloading LocalGo $TAG..." + + TMPDIR=$(mktemp -d) + local archive_name="localgo_${VERSION}_${OS}_${ARCH}.tar.gz" + local archive_url="$GH_URL/releases/download/$TAG/$archive_name" + local checksum_url="$GH_URL/releases/download/$TAG/checksums.txt" + + info "Downloading archive: $archive_name" + + if command -v curl &>/dev/null; then + curl -fsSL "$archive_url" -o "$TMPDIR/$archive_name" || die "Download failed: $archive_url" + curl -fsSL "$checksum_url" -o "$TMPDIR/checksums.txt" 2>/dev/null || warn "Checksums file not found, skipping verification" + elif command -v wget &>/dev/null; then + wget -qO "$TMPDIR/$archive_name" "$archive_url" || die "Download failed: $archive_url" + wget -qO "$TMPDIR/checksums.txt" "$checksum_url" 2>/dev/null || warn "Checksums file not found, skipping verification" + else + die "Neither curl nor wget found. Install one of them and retry." + fi + + ok "Archive downloaded" + + # ── Verify Checksum ── + if [[ -f "$TMPDIR/checksums.txt" ]]; then + local expected_hash + expected_hash=$(grep "$archive_name" "$TMPDIR/checksums.txt" | awk '{print $1}') + if [[ -n "$expected_hash" ]]; then + local actual_hash="" + if command -v sha256sum &>/dev/null; then + actual_hash=$(sha256sum "$TMPDIR/$archive_name" | awk '{print $1}') + elif command -v shasum &>/dev/null; then + actual_hash=$(shasum -a 256 "$TMPDIR/$archive_name" | awk '{print $1}') + fi + if [[ -n "$actual_hash" ]]; then + if [[ "$expected_hash" == "$actual_hash" ]]; then + ok "Checksum verified (SHA-256)" + else + die "Checksum mismatch! Expected: $expected_hash, Got: $actual_hash" + fi + fi + else + warn "No checksum entry for $archive_name, skipping verification" + fi + fi + + # ── Extract ── + info "Extracting archive..." + tar -xzf "$TMPDIR/$archive_name" -C "$TMPDIR" || die "Failed to extract archive" + ok "Archive extracted" + + # ── Locate Binary (handle both wrapped and flat archives) ── + BINARY_PATH=$(find "$TMPDIR" -maxdepth 3 -type f -name "$BINARY_NAME" 2>/dev/null | head -1) + [[ -z "$BINARY_PATH" ]] && die "Binary not found in archive" + ok "Binary located: $(basename "$BINARY_PATH") v$VERSION" +} + +# ── Step 5: Install Binary ───────────────────────────────────────── +install_binary() { + header "Installing binary..." + + local dest_dir + [[ "$INSTALL_MODE" == "system" ]] && dest_dir="$SYSTEM_BIN_DIR" || dest_dir="$USER_BIN_DIR" + + sudo_cmd mkdir -p "$dest_dir" + sudo_cmd cp "$BINARY_PATH" "$dest_dir/$BINARY_NAME" + sudo_cmd chmod 755 "$dest_dir/$BINARY_NAME" + + ok "Binary installed to $dest_dir/$BINARY_NAME" +} + +# ── Helper: locate asset dir (scripts/ in archive) ───────────────── +find_asset_dir() { + local dir + dir=$(dirname "$BINARY_PATH") + [[ -d "$dir/scripts" ]] && echo "$dir/scripts" && return + dir=$(dirname "$dir") + [[ -d "$dir/scripts" ]] && echo "$dir/scripts" && return + echo "" +} + +# ── Opt-in: Completions ──────────────────────────────────────────── +install_completions() { + header "Installing shell completions..." + + local scripts_dir + scripts_dir=$(find_asset_dir) + [[ -z "$scripts_dir" ]] && { warn "Completions not found in archive, skipping"; return; } + + local count=0 + + if [[ -f "$scripts_dir/bash_completion.sh" ]] && command -v bash &>/dev/null; then + if [[ "$INSTALL_MODE" == "system" ]]; then + sudo mkdir -p /usr/share/bash-completion/completions + sudo cp "$scripts_dir/bash_completion.sh" "/usr/share/bash-completion/completions/$BINARY_NAME" + else + mkdir -p "$HOME/.local/share/bash-completion/completions" + cp "$scripts_dir/bash_completion.sh" "$HOME/.local/share/bash-completion/completions/$BINARY_NAME" + fi + ok "Bash completions installed" + ((count++)) + fi + + if [[ -f "$scripts_dir/zsh_completion.zsh" ]] && command -v zsh &>/dev/null; then + if [[ "$INSTALL_MODE" == "system" ]]; then + sudo mkdir -p /usr/share/zsh/site-functions + sudo cp "$scripts_dir/zsh_completion.zsh" "/usr/share/zsh/site-functions/_$BINARY_NAME" + else + mkdir -p "$HOME/.local/share/zsh/site-functions" + cp "$scripts_dir/zsh_completion.zsh" "$HOME/.local/share/zsh/site-functions/_$BINARY_NAME" + fi + ok "Zsh completions installed" + ((count++)) + fi + + if [[ -f "$scripts_dir/fish_completion.fish" ]] && command -v fish &>/dev/null; then + if [[ "$INSTALL_MODE" == "system" ]]; then + sudo mkdir -p /usr/share/fish/vendor_completions.d + sudo cp "$scripts_dir/fish_completion.fish" "/usr/share/fish/vendor_completions.d/$BINARY_NAME.fish" + else + mkdir -p "$HOME/.config/fish/completions" + cp "$scripts_dir/fish_completion.fish" "$HOME/.config/fish/completions/$BINARY_NAME.fish" + fi + ok "Fish completions installed" + ((count++)) + fi + + [[ $count -eq 0 ]] && warn "No compatible shell found for completions" +} + +# ── Opt-in: Service (Linux only, systemd required) ───────────────── +install_service() { + header "Installing systemd service..." + + [[ "$OS" != "linux" ]] && { warn "systemd not available on macOS, skipping"; return; } + command -v systemctl &>/dev/null || { warn "systemctl not found, skipping"; return; } + + local scripts_dir + scripts_dir=$(find_asset_dir) + [[ -z "$scripts_dir" ]] && { warn "Service file not found in archive, skipping"; return; } + + local service_src="" + [[ -f "$scripts_dir/localgo-pkg.service" ]] && service_src="$scripts_dir/localgo-pkg.service" + [[ -z "$service_src" && -f "$scripts_dir/localgo.service" ]] && service_src="$scripts_dir/localgo.service" + [[ -z "$service_src" ]] && { warn "Service file not found in archive, skipping"; return; } + + local bin_path + [[ "$INSTALL_MODE" == "system" ]] && bin_path="$SYSTEM_BIN_DIR/$BINARY_NAME" || bin_path="$USER_BIN_DIR/$BINARY_NAME" + + if [[ "$INSTALL_MODE" == "system" ]]; then + local svc_dest="$SYSTEM_SERVICE_DIR/$BINARY_NAME.service" + sudo mkdir -p "$SYSTEM_SERVICE_DIR" + sudo cp "$service_src" "$svc_dest" + sudo sed -i "s|ExecStart=.*|ExecStart=$bin_path serve --quiet --auto-accept|g" "$svc_dest" 2>/dev/null || true + sudo sed -i "/^EnvironmentFile=/d" "$svc_dest" 2>/dev/null || true + sudo systemctl daemon-reload 2>/dev/null || true + ok "System service installed: $svc_dest" + else + local svc_dest="$USER_SERVICE_DIR/$BINARY_NAME.service" + mkdir -p "$USER_SERVICE_DIR" + cp "$service_src" "$svc_dest" + sed -i "s|ExecStart=.*|ExecStart=$bin_path serve --quiet --auto-accept|g" "$svc_dest" 2>/dev/null || true + sed -i "/^EnvironmentFile=/d" "$svc_dest" 2>/dev/null || true + sed -i "/^User=/d" "$svc_dest" 2>/dev/null || true + sed -i "/^Group=/d" "$svc_dest" 2>/dev/null || true + systemctl --user daemon-reload 2>/dev/null || true + ok "User service installed: $svc_dest" + fi +} + +# ── Opt-in: Config ───────────────────────────────────────────────── +install_config() { + header "Installing configuration..." + + local scripts_dir + scripts_dir=$(find_asset_dir) + [[ -z "$scripts_dir" ]] && { warn "Config template not found in archive, skipping"; return; } + + local env_src="$scripts_dir/localgo.env.example" + [[ ! -f "$env_src" ]] && { warn "Config template not found in archive, skipping"; return; } + + if [[ "$INSTALL_MODE" == "system" ]]; then + sudo mkdir -p "$SYSTEM_CONFIG_DIR" + if [[ ! -f "$SYSTEM_CONFIG_DIR/localgo.env" ]]; then + sudo cp "$env_src" "$SYSTEM_CONFIG_DIR/localgo.env" + sudo chmod 644 "$SYSTEM_CONFIG_DIR/localgo.env" + ok "Config installed to $SYSTEM_CONFIG_DIR/localgo.env" + else + info "Config already exists at $SYSTEM_CONFIG_DIR/localgo.env, skipping" + fi + else + mkdir -p "$USER_CONFIG_DIR" + if [[ ! -f "$USER_CONFIG_DIR/localgo.env" ]]; then + cp "$env_src" "$USER_CONFIG_DIR/localgo.env" + chmod 600 "$USER_CONFIG_DIR/localgo.env" + ok "Config installed to $USER_CONFIG_DIR/localgo.env" + else + info "Config already exists at $USER_CONFIG_DIR/localgo.env, skipping" + fi + fi +} + +# ── Verify Installation ──────────────────────────────────────────── +verify_installation() { + local bin_path + [[ "$INSTALL_MODE" == "system" ]] && bin_path="$SYSTEM_BIN_DIR/$BINARY_NAME" || bin_path="$USER_BIN_DIR/$BINARY_NAME" + + if [[ ! -x "$bin_path" ]]; then + die "Binary not executable at $bin_path" + fi + + local installed_ver + installed_ver=$("$bin_path" version 2>/dev/null | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' || echo "$VERSION") + ok "LocalGo v$installed_ver verified at $bin_path" +} + +# ── Post-Install Summary ─────────────────────────────────────────── +print_summary() { + local bin_path + [[ "$INSTALL_MODE" == "system" ]] && bin_path="$SYSTEM_BIN_DIR/$BINARY_NAME" || bin_path="$USER_BIN_DIR/$BINARY_NAME" + + echo + echo " ┌─ Installation Complete ──────────────────────────┐" + printf " │ LocalGo v%-30s │\n" "$VERSION" + echo " └──────────────────────────────────────────────────┘" + echo + + info "Binary: $bin_path" + + if [[ "$INSTALL_MODE" == "user" && ":$PATH:" != *":$USER_BIN_DIR:"* ]]; then + warn "Add $USER_BIN_DIR to your PATH:" + case "${SHELL:-}" in + *fish) echo " fish_add_path $USER_BIN_DIR" ;; + *zsh) echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc && source ~/.zshrc" ;; + *) echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.bashrc && source ~/.bashrc" ;; + esac + echo + fi + + info "Quick start:" + echo " $BINARY_NAME help # Show commands" + echo " $BINARY_NAME info # Device info" + echo " $BINARY_NAME discover # Find peers" + echo " $BINARY_NAME send --help # Send files" + echo + + if [[ "$INSTALL_SERVICE" == true && "$OS" == "linux" ]]; then + info "Service management:" + if [[ "$INSTALL_MODE" == "system" ]]; then + echo " sudo systemctl enable --now $BINARY_NAME" + echo " sudo journalctl -u $BINARY_NAME -f" + else + echo " systemctl --user enable --now $BINARY_NAME" + echo " journalctl --user -u $BINARY_NAME -f" + echo + info "To keep service alive after logout:" + echo " loginctl enable-linger $USER" + fi + echo + fi +} + +# ── Main ─────────────────────────────────────────────────────────── +main() { + echo " ◆ LocalGo Online Installer" + echo " LocalSend v2.1 Protocol CLI" + echo " ─────────────────────────────────" + echo + + # ── Parse args ── + while [[ $# -gt 0 ]]; do + case $1 in + -v|--version) + PINNED_VERSION="$2" + shift 2 + ;; + --mode) + INSTALL_MODE="$2" + shift 2 + ;; + --service) INSTALL_SERVICE=true; shift ;; + --completion) INSTALL_COMPLETION=true; shift ;; + --config) INSTALL_CONFIG=true; shift ;; + -y|--yes) ASSUME_YES=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + -h|--help) usage ;; + *) die "Unknown option: $1 (use --help for usage)" ;; + esac + done + + [[ "$INSTALL_MODE" != "user" && "$INSTALL_MODE" != "system" ]] \ + && die "Invalid mode: $INSTALL_MODE (use user or system)" + + detect_platform + resolve_version + print_plan + + [[ "$DRY_RUN" == "true" ]] && { info "Dry run — exiting."; exit 0; } + + # Auto-yes when piped (non-TTY stdin) + [[ ! -t 0 ]] && ASSUME_YES=true + confirm_or_skip + + download_and_verify + install_binary + verify_installation + + [[ "$INSTALL_COMPLETION" == "true" ]] && install_completions + [[ "$INSTALL_SERVICE" == "true" ]] && install_service + [[ "$INSTALL_CONFIG" == "true" ]] && install_config + + print_summary +} + +main "$@" From 0348ddb9e03d32d35b527f8c53a99788e89d0cbe Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:57:41 +0300 Subject: [PATCH 43/45] =?UTF-8?q?chore:=20stable=20release=20prep=20?= =?UTF-8?q?=E2=80=94=20bugs,=20atomic=20writes,=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - progress.go: fix scrollback erasure overshoot (use actual bar count) - storage_windows.go: lazy DLL loading (NewLazyDLL) to avoid runtime panics - storage.go: atomic file transfers via .tmp rename pattern - output.go: bounds-safe FormatBytes (no panic on >EB sizes) - README: add short one-line install URL --- README.md | 5 ++++ pkg/cli/output.go | 5 ++-- pkg/cli/progress.go | 19 ++++++++------- pkg/storage/storage.go | 42 +++++++++++++++++++--------------- pkg/storage/storage_windows.go | 10 ++++---- 5 files changed, 48 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 41a371a..82df4d3 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ A Go implementation of the LocalSend v2.1 protocol for secure, cross-platform fi ### Installation +#### Online (macOS, Linux) +```bash +curl -fsSL https://bethropolis.github.io/localgo/install.sh | bash +``` + #### User installation (recommended) ```bash # clone repo diff --git a/pkg/cli/output.go b/pkg/cli/output.go index 7374bc7..fc714e4 100644 --- a/pkg/cli/output.go +++ b/pkg/cli/output.go @@ -244,12 +244,13 @@ func FormatBytes(bytes int64) string { if bytes < unit { return fmt.Sprintf("%d B", bytes) } + suffixes := []string{"KB", "MB", "GB", "TB", "PB", "EB"} div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { + for n := bytes / unit; n >= unit && exp < len(suffixes)-1; n /= unit { div *= unit exp++ } - return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) + return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), suffixes[exp]) } // FormatDuration formats duration in human readable format diff --git a/pkg/cli/progress.go b/pkg/cli/progress.go index 7c1d982..b195261 100644 --- a/pkg/cli/progress.go +++ b/pkg/cli/progress.go @@ -10,18 +10,16 @@ import ( ) type MultiProgress struct { - pool *mpb.Progress - barCount int64 - bars []*mpb.Bar - mu sync.Mutex + pool *mpb.Progress + bars []*mpb.Bar + mu sync.Mutex } -func NewMultiProgress(totalFiles int64) *MultiProgress { +func NewMultiProgress(_ int64) *MultiProgress { return &MultiProgress{ pool: mpb.New( mpb.WithOutput(os.Stderr), ), - barCount: totalFiles, } } @@ -60,8 +58,13 @@ func (mp *MultiProgress) ForceComplete() { func (mp *MultiProgress) Wait() { mp.pool.Wait() - // Clear progress bar lines from scrollback - for i := int64(0); i < mp.barCount; i++ { + + mp.mu.Lock() + barsRendered := len(mp.bars) + mp.mu.Unlock() + + // Clear only the lines with actual rendered progress bars + for i := 0; i < barsRendered; i++ { fmt.Fprintf(os.Stderr, "\033[F\033[K") } fmt.Fprintf(os.Stderr, "%s Files transferred successfully\n", IconCheck) diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index df69d33..c660b77 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -66,11 +66,20 @@ func SaveStreamToFileWithMetadata(stream io.Reader, filePath string, fileSize in return err } - outFile, err := os.Create(filePath) + // Write to a temporary file first, then atomically rename on success + tempPath := filePath + ".tmp" + outFile, err := os.Create(tempPath) if err != nil { - return fmt.Errorf("failed to create file %s: %w", filePath, err) + return fmt.Errorf("failed to create temp file: %w", err) } - defer outFile.Close() + + cleanup := true + defer func() { + outFile.Close() + if cleanup { + _ = os.Remove(tempPath) + } + }() progressWriter := &ProgressWriter{ Writer: outFile, @@ -95,29 +104,17 @@ func SaveStreamToFileWithMetadata(stream io.Reader, filePath string, fileSize in _, err = io.CopyBuffer(progressWriter, hashingReader, *bufPtr) if err != nil { - outFile.Close() - if removeErr := os.Remove(filePath); removeErr != nil { - if logger != nil { - logger.Warnw("Failed to remove partially written file", "path", filePath, "error", removeErr) - } - } - return fmt.Errorf("failed to copy stream to file %s: %w", filePath, err) + return fmt.Errorf("failed to copy stream: %w", err) } - if closeErr := outFile.Close(); closeErr != nil { - if removeErr := os.Remove(filePath); removeErr != nil && logger != nil { - logger.Warnw("Failed to remove incomplete file after close error", "path", filePath, "error", removeErr) - } - return fmt.Errorf("failed to close and flush file %s: %w", filePath, closeErr) + if err := outFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) } // Verify SHA-256 checksum if the sender provided one if hasher != nil { calculatedHash := hex.EncodeToString(hasher.Sum(nil)) if calculatedHash != *expectedSha256 { - if removeErr := os.Remove(filePath); removeErr != nil && logger != nil { - logger.Warnw("Failed to remove corrupted file", "path", filePath, "error", removeErr) - } return fmt.Errorf("integrity violation: SHA-256 mismatch (got %s, expected %s)", calculatedHash, *expectedSha256) } if logger != nil { @@ -125,6 +122,7 @@ func SaveStreamToFileWithMetadata(stream io.Reader, filePath string, fileSize in } } + // Apply timestamps to the temp file before promotion if modified != nil || accessed != nil { mtime := time.Now() atime := time.Now() @@ -151,13 +149,19 @@ func SaveStreamToFileWithMetadata(stream io.Reader, filePath string, fileSize in } } - if err := os.Chtimes(filePath, atime, mtime); err != nil { + if err := os.Chtimes(tempPath, atime, mtime); err != nil { if logger != nil { logger.Warnw("Failed to apply timestamps", "path", filePath, "error", err) } } } + // Atomically promote temp file to final path + if err := os.Rename(tempPath, filePath); err != nil { + return fmt.Errorf("failed to finalize transfer: %w", err) + } + cleanup = false + if logger != nil { logger.Infow("Successfully saved stream", "path", filePath) } diff --git a/pkg/storage/storage_windows.go b/pkg/storage/storage_windows.go index d17a234..3b357d7 100644 --- a/pkg/storage/storage_windows.go +++ b/pkg/storage/storage_windows.go @@ -7,10 +7,12 @@ import ( "unsafe" ) -func getAvailableBytes(path string) (uint64, error) { - h := syscall.MustLoadDLL("kernel32.dll") - c := h.MustFindProc("GetDiskFreeSpaceExW") +var ( + modkernel32 = syscall.NewLazyDLL("kernel32.dll") + procGetDiskFreeSpace = modkernel32.NewProc("GetDiskFreeSpaceExW") +) +func getAvailableBytes(path string) (uint64, error) { var freeBytes int64 pathPtr, err := syscall.UTF16PtrFromString(path) @@ -18,7 +20,7 @@ func getAvailableBytes(path string) (uint64, error) { return 0, err } - r, _, err := c.Call( + r, _, err := procGetDiskFreeSpace.Call( uintptr(unsafe.Pointer(pathPtr)), uintptr(unsafe.Pointer(&freeBytes)), 0, From 814b5fdf6b61384c0aedc81c805ee499aa9d30e2 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:59:07 +0300 Subject: [PATCH 44/45] refactor: split 6 large files into 19 single-responsibility units Split files exceeding 300 LOC: pkg/send/send.go -> send.go + filepath.go + verify.go + anonymize.go + upload.go pkg/server/handlers/... -> receive_handlers.go + receive_upload.go + prompt.go + exec.go + history_log.go pkg/cli/output.go -> output.go + format.go + notify.go + print.go pkg/discovery/multicast.go -> multicast.go + announce.go + config.go cmd/localgo/cmd/share.go -> share.go + share_zip.go pkg/help/help.go -> help.go + commands.go --- cmd/localgo/cmd/share.go | 68 ----- cmd/localgo/cmd/share_zip.go | 77 ++++++ pkg/cli/format.go | 64 +++++ pkg/cli/notify.go | 27 ++ pkg/cli/output.go | 146 ----------- pkg/cli/print.go | 23 ++ pkg/discovery/announce.go | 173 +++++++++++++ pkg/discovery/config.go | 22 ++ pkg/discovery/multicast.go | 183 -------------- pkg/help/commands.go | 177 +++++++++++++ pkg/help/help.go | 174 ------------- pkg/send/anonymize.go | 49 ++++ pkg/send/filepath.go | 40 +++ pkg/send/send.go | 223 ----------------- pkg/send/upload.go | 124 ++++++++++ pkg/send/verify.go | 42 ++++ pkg/server/handlers/exec.go | 37 +++ pkg/server/handlers/history_log.go | 23 ++ pkg/server/handlers/prompt.go | 84 +++++++ pkg/server/handlers/receive_handlers.go | 315 ------------------------ pkg/server/handlers/receive_upload.go | 202 +++++++++++++++ 21 files changed, 1164 insertions(+), 1109 deletions(-) create mode 100644 cmd/localgo/cmd/share_zip.go create mode 100644 pkg/cli/format.go create mode 100644 pkg/cli/notify.go create mode 100644 pkg/cli/print.go create mode 100644 pkg/discovery/announce.go create mode 100644 pkg/discovery/config.go create mode 100644 pkg/help/commands.go create mode 100644 pkg/send/anonymize.go create mode 100644 pkg/send/filepath.go create mode 100644 pkg/send/upload.go create mode 100644 pkg/send/verify.go create mode 100644 pkg/server/handlers/exec.go create mode 100644 pkg/server/handlers/history_log.go create mode 100644 pkg/server/handlers/prompt.go create mode 100644 pkg/server/handlers/receive_upload.go diff --git a/cmd/localgo/cmd/share.go b/cmd/localgo/cmd/share.go index ccb7a44..37f361f 100644 --- a/cmd/localgo/cmd/share.go +++ b/cmd/localgo/cmd/share.go @@ -1,10 +1,8 @@ package cmd import ( - "archive/zip" "context" "fmt" - "io" "net/http" "os" "os/signal" @@ -291,70 +289,4 @@ func init() { }) } -func zipDirToTemp(dir string) (string, error) { - baseName := filepath.Base(dir) - if baseName == "." || baseName == "/" { - baseName = "archive" - } - zipFile, err := os.CreateTemp("", "localgo-"+baseName+"-*.zip") - if err != nil { - return "", fmt.Errorf("failed to create temp zip: %w", err) - } - zipPathName := zipFile.Name() - zipWriter := zip.NewWriter(zipFile) - - err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - rel, err := filepath.Rel(dir, path) - if err != nil { - rel = info.Name() - } - rel = filepath.ToSlash(rel) - - header, err := zip.FileInfoHeader(info) - if err != nil { - return err - } - header.Name = rel - header.Method = zip.Deflate - w, err := zipWriter.CreateHeader(header) - if err != nil { - return err - } - err = func() error { - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - _, err = io.Copy(w, f) - return err - }() - return err - }) - if err != nil { - zipWriter.Close() - zipFile.Close() - os.Remove(zipPathName) - return "", err - } - - if err := zipWriter.Close(); err != nil { - zipFile.Close() - os.Remove(zipPathName) - return "", err - } - - if err := zipFile.Close(); err != nil { - os.Remove(zipPathName) - return "", err - } - - return zipPathName, nil -} diff --git a/cmd/localgo/cmd/share_zip.go b/cmd/localgo/cmd/share_zip.go new file mode 100644 index 0000000..0eaaac5 --- /dev/null +++ b/cmd/localgo/cmd/share_zip.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" +) + +func zipDirToTemp(dir string) (string, error) { + baseName := filepath.Base(dir) + if baseName == "." || baseName == "/" { + baseName = "archive" + } + zipFile, err := os.CreateTemp("", "localgo-"+baseName+"-*.zip") + if err != nil { + return "", fmt.Errorf("failed to create temp zip: %w", err) + } + zipPathName := zipFile.Name() + zipWriter := zip.NewWriter(zipFile) + + err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + rel, err := filepath.Rel(dir, path) + if err != nil { + rel = info.Name() + } + rel = filepath.ToSlash(rel) + + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = rel + header.Method = zip.Deflate + + w, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + err = func() error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(w, f) + return err + }() + return err + }) + if err != nil { + zipWriter.Close() + zipFile.Close() + os.Remove(zipPathName) + return "", err + } + + if err := zipWriter.Close(); err != nil { + zipFile.Close() + os.Remove(zipPathName) + return "", err + } + + if err := zipFile.Close(); err != nil { + os.Remove(zipPathName) + return "", err + } + + return zipPathName, nil +} diff --git a/pkg/cli/format.go b/pkg/cli/format.go new file mode 100644 index 0000000..b12e336 --- /dev/null +++ b/pkg/cli/format.go @@ -0,0 +1,64 @@ +package cli + +import ( + "crypto/sha256" + "fmt" + "time" + + "github.com/bethropolis/localgo/pkg/model" +) + +// AnonymizedAlias returns a stable "Device #XXXXXXXX" identifier from a device's fingerprint. +func AnonymizedAlias(device *model.Device) string { + if device == nil || device.Fingerprint == "" { + return "Device #00000000" + } + h := sha256.Sum256([]byte(device.Fingerprint)) + return fmt.Sprintf("Device #%08x", h[:4]) +} + +// AnonymizeString returns a stable "Device #XXXXXXXX" identifier from any string. +func AnonymizeString(s string) string { + if s == "" { + return "Device #00000000" + } + h := sha256.Sum256([]byte(s)) + return fmt.Sprintf("Device #%08x", h[:4]) +} + +// TruncateString truncates a string to maxLen characters +func TruncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +// FormatBytes formats bytes in human readable format +func FormatBytes(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + suffixes := []string{"KB", "MB", "GB", "TB", "PB", "EB"} + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit && exp < len(suffixes)-1; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), suffixes[exp]) +} + +// FormatDuration formats duration in human readable format +func FormatDuration(d time.Duration) string { + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + if d < time.Minute { + return fmt.Sprintf("%.1fs", d.Seconds()) + } + if d < time.Hour { + return fmt.Sprintf("%.1fm", d.Minutes()) + } + return fmt.Sprintf("%.1fh", d.Hours()) +} diff --git a/pkg/cli/notify.go b/pkg/cli/notify.go new file mode 100644 index 0000000..d088607 --- /dev/null +++ b/pkg/cli/notify.go @@ -0,0 +1,27 @@ +package cli + +import ( + "os" + + "github.com/gen2brain/beeep" +) + +// Notify sends a native desktop notification. Icon is empty (system default). +// No-op in container environments. +func Notify(title, body string) { + if IsContainer() { + return + } + beeep.Notify(title, body, "") +} + +// IsContainer returns true if LocalGo is running inside a Docker/Podman container. +func IsContainer() bool { + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + if os.Getenv("container") != "" { + return true + } + return false +} diff --git a/pkg/cli/output.go b/pkg/cli/output.go index fc714e4..5600e3a 100644 --- a/pkg/cli/output.go +++ b/pkg/cli/output.go @@ -1,7 +1,6 @@ package cli import ( - "crypto/sha256" "encoding/json" "fmt" "os" @@ -12,7 +11,6 @@ import ( "github.com/bethropolis/localgo/pkg/model" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" - "github.com/gen2brain/beeep" ) // OutputFormat represents the output format type @@ -210,150 +208,6 @@ func (ow *OutputWriter) writeJSON(data interface{}) error { return encoder.Encode(data) } -// Helper functions - -// AnonymizedAlias returns a stable "Device #XXXXXXXX" identifier from a device's fingerprint. -func AnonymizedAlias(device *model.Device) string { - if device == nil || device.Fingerprint == "" { - return "Device #00000000" - } - h := sha256.Sum256([]byte(device.Fingerprint)) - return fmt.Sprintf("Device #%08x", h[:4]) -} - -// AnonymizeString returns a stable "Device #XXXXXXXX" identifier from any string. -func AnonymizeString(s string) string { - if s == "" { - return "Device #00000000" - } - h := sha256.Sum256([]byte(s)) - return fmt.Sprintf("Device #%08x", h[:4]) -} - -// TruncateString truncates a string to maxLen characters -func TruncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen-3] + "..." -} - -// FormatBytes formats bytes in human readable format -func FormatBytes(bytes int64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%d B", bytes) - } - suffixes := []string{"KB", "MB", "GB", "TB", "PB", "EB"} - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit && exp < len(suffixes)-1; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), suffixes[exp]) -} - -// FormatDuration formats duration in human readable format -func FormatDuration(d time.Duration) string { - if d < time.Second { - return fmt.Sprintf("%dms", d.Milliseconds()) - } - if d < time.Minute { - return fmt.Sprintf("%.1fs", d.Seconds()) - } - if d < time.Hour { - return fmt.Sprintf("%.1fm", d.Minutes()) - } - return fmt.Sprintf("%.1fh", d.Hours()) -} - -// Notify sends a native desktop notification. Icon is empty (system default). -// No-op in container environments. -func Notify(title, body string) { - if IsContainer() { - return - } - beeep.Notify(title, body, "") -} - -// ProgressBar represents a simple progress bar -type ProgressBar struct { - total int64 - current int64 - width int - prefix string -} - -// NewProgressBar creates a new progress bar -func NewProgressBar(total int64, prefix string) *ProgressBar { - return &ProgressBar{ - total: total, - width: 50, - prefix: prefix, - } -} - -// Update updates the progress bar -func (pb *ProgressBar) Update(current int64) { - pb.current = current - pb.render() -} - -// Finish completes the progress bar -func (pb *ProgressBar) Finish() { - pb.current = pb.total - pb.render() - fmt.Println() -} - -// render renders the progress bar -func (pb *ProgressBar) render() { - percent := float64(pb.current) / float64(pb.total) - filled := int(percent * float64(pb.width)) - - bar := strings.Repeat("█", filled) + strings.Repeat("░", pb.width-filled) - - fmt.Printf("\r%s [%s] %.1f%% (%s/%s)", - pb.prefix, - bar, - percent*100, - FormatBytes(pb.current), - FormatBytes(pb.total)) -} - -// Standalone Print helpers - -func PrintSuccess(format string, a ...any) { - fmt.Println(SuccessStyle.Render(IconCheck + " " + fmt.Sprintf(format, a...))) -} - -func PrintError(format string, a ...any) { - fmt.Println(ErrorStyle.Render(IconCross + " " + fmt.Sprintf(format, a...))) -} - -func PrintWarning(format string, a ...any) { - fmt.Println(WarningStyle.Render(IconWarning + " " + fmt.Sprintf(format, a...))) -} - -func PrintInfo(format string, a ...any) { - fmt.Println(InfoStyle.Render(IconInfo + " " + fmt.Sprintf(format, a...))) -} - -func PrintHeader(text string) { - fmt.Println(HeaderStyle.Render(text)) -} - -// IsContainer returns true if LocalGo is running inside a Docker/Podman container. -func IsContainer() bool { - if _, err := os.Stat("/.dockerenv"); err == nil { - return true - } - if os.Getenv("container") != "" { - return true - } - return false -} - // PickDevice presents an interactive TUI to select a device. Returns the selected device or nil if canceled. // When private is true, device aliases are anonymized in the selection list. func PickDevice(devices []*model.Device, private bool) *model.Device { diff --git a/pkg/cli/print.go b/pkg/cli/print.go new file mode 100644 index 0000000..ace48f2 --- /dev/null +++ b/pkg/cli/print.go @@ -0,0 +1,23 @@ +package cli + +import "fmt" + +func PrintSuccess(format string, a ...any) { + fmt.Println(SuccessStyle.Render(IconCheck + " " + fmt.Sprintf(format, a...))) +} + +func PrintError(format string, a ...any) { + fmt.Println(ErrorStyle.Render(IconCross + " " + fmt.Sprintf(format, a...))) +} + +func PrintWarning(format string, a ...any) { + fmt.Println(WarningStyle.Render(IconWarning + " " + fmt.Sprintf(format, a...))) +} + +func PrintInfo(format string, a ...any) { + fmt.Println(InfoStyle.Render(IconInfo + " " + fmt.Sprintf(format, a...))) +} + +func PrintHeader(text string) { + fmt.Println(HeaderStyle.Render(text)) +} diff --git a/pkg/discovery/announce.go b/pkg/discovery/announce.go new file mode 100644 index 0000000..7621f03 --- /dev/null +++ b/pkg/discovery/announce.go @@ -0,0 +1,173 @@ +package discovery + +import ( + "context" + "encoding/json" + "fmt" + "net" + "strings" + "time" + + "github.com/bethropolis/localgo/pkg/model" +) + +// SendDiscoveryAnnouncement sends a multicast announcement +func (md *MulticastDiscovery) SendDiscoveryAnnouncement() error { + announcementDto := md.dto + announcementDto.Announce = true + + data, err := json.Marshal(announcementDto) + if err != nil { + return fmt.Errorf("failed to marshal announcement: %w", err) + } + + addr, err := net.ResolveUDPAddr("udp4", md.config.MulticastAddr) + if err != nil { + return fmt.Errorf("failed to resolve multicast address: %w", err) + } + + var localAddr *net.UDPAddr + if md.config.InterfaceName != "" { + iface, err := net.InterfaceByName(md.config.InterfaceName) + if err == nil { + addrs, err := iface.Addrs() + if err == nil { + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil { + localAddr = &net.UDPAddr{IP: ipnet.IP} + break + } + } + } + } + } + + conn, err := net.DialUDP("udp4", localAddr, addr) + if err != nil { + return fmt.Errorf("failed to create UDP connection: %w", err) + } + defer conn.Close() + + _, err = conn.Write(data) + if err != nil { + return fmt.Errorf("failed to send multicast announcement: %w", err) + } + + md.logger.Debugf("Sent multicast announcement as %s (fingerprint: %s) to %s", + md.dto.Alias, getShortFingerprint(md.dto.Fingerprint), md.config.MulticastAddr) + return nil +} + +// SendDiscoveryResponse sends a response to a specific address +func (md *MulticastDiscovery) SendDiscoveryResponse(targetAddr *net.UDPAddr, targetDevice *model.Device) error { + // 1. Try HTTP Response first + if md.httpDiscoverer != nil && targetDevice != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + scheme := "http" + if targetDevice.Protocol == model.ProtocolTypeHTTPS { + scheme = "https" + } + + _, err := md.httpDiscoverer.RegisterWithDevice(ctx, net.ParseIP(targetDevice.IP), targetDevice.Port, scheme) + if err == nil { + md.logger.Debugf("Sent discovery response via HTTP to %s:%d", targetDevice.IP, targetDevice.Port) + return nil + } + } + + // 2. Fallback to UDP — send via multicast so every listener sees the response + responseDto := md.dto + responseDto.Announce = false + + data, err := json.Marshal(responseDto) + if err != nil { + return fmt.Errorf("failed to marshal response: %w", err) + } + + respAddr, err := net.ResolveUDPAddr("udp4", md.config.MulticastAddr) + if err != nil { + return fmt.Errorf("failed to resolve multicast address: %w", err) + } + + conn, err := net.DialUDP("udp4", nil, respAddr) + if err != nil { + return fmt.Errorf("failed to create UDP connection: %w", err) + } + defer conn.Close() + + _, err = conn.Write(data) + if err != nil { + return fmt.Errorf("failed to send discovery response: %w", err) + } + + md.logger.Debugf("Sent discovery response via multicast to %s", md.config.MulticastAddr) + return nil +} + +func (md *MulticastDiscovery) listenLoop(ctx context.Context, conn net.PacketConn) { + buffer := make([]byte, 2048) + + for { + select { + case <-ctx.Done(): + return + default: + } + + if md.closed.Load() { + return + } + + if err := conn.SetReadDeadline(time.Now().Add(md.config.ListenTimeout)); err != nil { + md.logger.Warnf("Failed to set read deadline: %v", err) + } + + n, addr, err := conn.ReadFrom(buffer) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + if strings.Contains(err.Error(), "use of closed network connection") { + return + } + continue + } + + if err := md.handlePacket(buffer[:n], addr); err != nil { + md.logger.Warnf("Failed to handle multicast packet: %v", err) + } + } +} + +func (md *MulticastDiscovery) handlePacket(data []byte, addr net.Addr) error { + var dto model.MulticastDto + if err := json.Unmarshal(data, &dto); err != nil { + return fmt.Errorf("failed to unmarshal packet: %w", err) + } + + if dto.Fingerprint == md.dto.Fingerprint { + return nil + } + + udpAddr, ok := addr.(*net.UDPAddr) + if !ok { + return fmt.Errorf("unexpected address type: %T", addr) + } + + device := model.FromMulticastDto(dto, udpAddr.IP) + + md.logger.Debugf("Discovered raw device via multicast: %s (%s) at %s:%d", + device.Alias, getShortFingerprint(device.Fingerprint), device.IP, device.Port) + + md.updateDevice(device) + + if dto.Announce { + if err := md.SendDiscoveryResponse(udpAddr, device); err != nil { + md.logger.Warnf("Failed to send discovery response: %v", err) + } + } + + return nil +} diff --git a/pkg/discovery/config.go b/pkg/discovery/config.go new file mode 100644 index 0000000..2497766 --- /dev/null +++ b/pkg/discovery/config.go @@ -0,0 +1,22 @@ +package discovery + +import "time" + +// MulticastConfig contains settings for multicast discovery +type MulticastConfig struct { + MulticastAddr string + Port int + InterfaceName string + AnnounceTimeout time.Duration + ListenTimeout time.Duration +} + +// DefaultMulticastConfig returns a default configuration +func DefaultMulticastConfig() *MulticastConfig { + return &MulticastConfig{ + MulticastAddr: "224.0.0.167:53317", + Port: 53317, + AnnounceTimeout: 2 * time.Second, + ListenTimeout: 5 * time.Second, + } +} diff --git a/pkg/discovery/multicast.go b/pkg/discovery/multicast.go index e998e68..e639da2 100644 --- a/pkg/discovery/multicast.go +++ b/pkg/discovery/multicast.go @@ -3,13 +3,10 @@ package discovery import ( "context" - "encoding/json" "fmt" "net" - "strings" "sync" "sync/atomic" - "time" "github.com/bethropolis/localgo/pkg/model" "go.uber.org/zap" @@ -31,25 +28,6 @@ type MulticastDiscovery struct { logger *zap.SugaredLogger } -// MulticastConfig contains settings for multicast discovery -type MulticastConfig struct { - MulticastAddr string - Port int - InterfaceName string - AnnounceTimeout time.Duration - ListenTimeout time.Duration -} - -// DefaultMulticastConfig returns a default configuration -func DefaultMulticastConfig() *MulticastConfig { - return &MulticastConfig{ - MulticastAddr: "224.0.0.167:53317", - Port: 53317, - AnnounceTimeout: 2 * time.Second, - ListenTimeout: 5 * time.Second, - } -} - // NewMulticastDiscovery creates a new multicast discovery instance func NewMulticastDiscovery(config *MulticastConfig, dto model.MulticastDto, logger *zap.SugaredLogger) *MulticastDiscovery { if config == nil { @@ -158,167 +136,6 @@ func (md *MulticastDiscovery) Stop() { md.connsMu.Unlock() } -// SendDiscoveryAnnouncement sends a multicast announcement -func (md *MulticastDiscovery) SendDiscoveryAnnouncement() error { - announcementDto := md.dto - announcementDto.Announce = true - - data, err := json.Marshal(announcementDto) - if err != nil { - return fmt.Errorf("failed to marshal announcement: %w", err) - } - - addr, err := net.ResolveUDPAddr("udp4", md.config.MulticastAddr) - if err != nil { - return fmt.Errorf("failed to resolve multicast address: %w", err) - } - - var localAddr *net.UDPAddr - if md.config.InterfaceName != "" { - iface, err := net.InterfaceByName(md.config.InterfaceName) - if err == nil { - addrs, err := iface.Addrs() - if err == nil { - for _, a := range addrs { - if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil { - localAddr = &net.UDPAddr{IP: ipnet.IP} - break - } - } - } - } - } - - conn, err := net.DialUDP("udp4", localAddr, addr) - if err != nil { - return fmt.Errorf("failed to create UDP connection: %w", err) - } - defer conn.Close() - - _, err = conn.Write(data) - if err != nil { - return fmt.Errorf("failed to send multicast announcement: %w", err) - } - - md.logger.Debugf("Sent multicast announcement as %s (fingerprint: %s) to %s", - md.dto.Alias, getShortFingerprint(md.dto.Fingerprint), md.config.MulticastAddr) - return nil -} - -// SendDiscoveryResponse sends a response to a specific address -func (md *MulticastDiscovery) SendDiscoveryResponse(targetAddr *net.UDPAddr, targetDevice *model.Device) error { - // 1. Try HTTP Response first - if md.httpDiscoverer != nil && targetDevice != nil { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - scheme := "http" - if targetDevice.Protocol == model.ProtocolTypeHTTPS { - scheme = "https" - } - - _, err := md.httpDiscoverer.RegisterWithDevice(ctx, net.ParseIP(targetDevice.IP), targetDevice.Port, scheme) - if err == nil { - md.logger.Debugf("Sent discovery response via HTTP to %s:%d", targetDevice.IP, targetDevice.Port) - return nil - } - } - - // 2. Fallback to UDP — send via multicast so every listener sees the response - responseDto := md.dto - responseDto.Announce = false - - data, err := json.Marshal(responseDto) - if err != nil { - return fmt.Errorf("failed to marshal response: %w", err) - } - - respAddr, err := net.ResolveUDPAddr("udp4", md.config.MulticastAddr) - if err != nil { - return fmt.Errorf("failed to resolve multicast address: %w", err) - } - - conn, err := net.DialUDP("udp4", nil, respAddr) - if err != nil { - return fmt.Errorf("failed to create UDP connection: %w", err) - } - defer conn.Close() - - _, err = conn.Write(data) - if err != nil { - return fmt.Errorf("failed to send discovery response: %w", err) - } - - md.logger.Debugf("Sent discovery response via multicast to %s", md.config.MulticastAddr) - return nil -} - -func (md *MulticastDiscovery) listenLoop(ctx context.Context, conn net.PacketConn) { - buffer := make([]byte, 2048) - - for { - select { - case <-ctx.Done(): - return - default: - } - - if md.closed.Load() { - return - } - - if err := conn.SetReadDeadline(time.Now().Add(md.config.ListenTimeout)); err != nil { - md.logger.Warnf("Failed to set read deadline: %v", err) - } - - n, addr, err := conn.ReadFrom(buffer) - if err != nil { - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - continue - } - if strings.Contains(err.Error(), "use of closed network connection") { - return - } - continue - } - - if err := md.handlePacket(buffer[:n], addr); err != nil { - md.logger.Warnf("Failed to handle multicast packet: %v", err) - } - } -} - -func (md *MulticastDiscovery) handlePacket(data []byte, addr net.Addr) error { - var dto model.MulticastDto - if err := json.Unmarshal(data, &dto); err != nil { - return fmt.Errorf("failed to unmarshal packet: %w", err) - } - - if dto.Fingerprint == md.dto.Fingerprint { - return nil - } - - udpAddr, ok := addr.(*net.UDPAddr) - if !ok { - return fmt.Errorf("unexpected address type: %T", addr) - } - - device := model.FromMulticastDto(dto, udpAddr.IP) - - md.logger.Debugf("Discovered raw device via multicast: %s (%s) at %s:%d", - device.Alias, getShortFingerprint(device.Fingerprint), device.IP, device.Port) - - md.updateDevice(device) - - if dto.Announce { - if err := md.SendDiscoveryResponse(udpAddr, device); err != nil { - md.logger.Warnf("Failed to send discovery response: %v", err) - } - } - - return nil -} - func (md *MulticastDiscovery) updateDevice(device *model.Device) { md.devicesMutex.Lock() key := device.Fingerprint diff --git a/pkg/help/commands.go b/pkg/help/commands.go new file mode 100644 index 0000000..c4b641d --- /dev/null +++ b/pkg/help/commands.go @@ -0,0 +1,177 @@ +package help + +// GetCommandHelp returns help information for built-in commands +func GetCommandHelp(commandName string) *CommandHelp { + commands := map[string]*CommandHelp{ + "serve": { + Name: "serve", + Description: "Start the LocalGo server to receive files", + Usage: "localgo serve [OPTIONS]", + Examples: []string{ + "localgo serve", + "localgo serve --port 8080 --http", + "localgo serve --pin 123456 --alias MyDevice", + "localgo serve --dir /tmp/downloads --verbose", + "localgo serve --auto-accept --quiet", + "localgo serve --no-clipboard", + "localgo serve --exec 'notify-send \"Got: %f\"'", + }, + Flags: []FlagHelp{ + {Name: "--port", Type: "int", Default: "from config", Description: "Port to run the server on"}, + {Name: "--http", Type: "bool", Default: "false", Description: "Use HTTP instead of HTTPS"}, + {Name: "--pin", Type: "string", Default: "", Description: "PIN for authentication"}, + {Name: "--alias", Type: "string", Default: "from config", Description: "Device alias"}, + {Name: "--dir", Type: "string", Default: "from config", Description: "Download directory"}, + {Name: "--interval", Type: "int", Default: "30", Description: "Discovery announcement interval in seconds"}, + {Name: "--auto-accept", Type: "bool", Default: "false", Description: "Auto-accept incoming files without prompting"}, + {Name: "--no-clipboard", Type: "bool", Default: "false", Description: "Save incoming text as a file instead of copying to clipboard"}, + {Name: "--open", Type: "bool", Default: "false", Description: "Open download directory after transfer completes"}, + {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - minimal output"}, + {Name: "--verbose", Type: "bool", Default: "false", Description: "Verbose mode - detailed output"}, + {Name: "--history", Type: "string", Default: "~/.local/share/localgo/history.jsonl", Description: "Path to transfer history JSONL file"}, + {Name: "--exec", Type: "string", Default: "", Description: "Shell command to execute after each received file (use %f, %n, %s, %a, %i)"}, + {Name: "--iface", Type: "string", Default: "", Description: "Multicast network interface name"}, + }, + }, + "share": { + Name: "share", + Description: "Share files so other devices can download them", + Usage: "localgo share --file FILE [OPTIONS]", + Examples: []string{ + "localgo share --file document.pdf", + "localgo share --file image.jpg --file text.txt", + "localgo share --file data.zip --pin 1234", + "localgo share --file data.zip --auto-accept", + "localgo share --file report.pdf --no-clipboard", + "localgo share --file doc.pdf --exec 'curl -F \"file=@%f\" https://example.com/upload'", + }, + Flags: []FlagHelp{ + {Name: "--file", Type: "string", Default: "", Description: "File or directory to share (required, can be specified multiple times)"}, + {Name: "--port", Type: "int", Default: "from config", Description: "Port to run the server on"}, + {Name: "--http", Type: "bool", Default: "false", Description: "Use HTTP instead of HTTPS"}, + {Name: "--pin", Type: "string", Default: "", Description: "PIN for authentication"}, + {Name: "--alias", Type: "string", Default: "from config", Description: "Device alias"}, + {Name: "--auto-accept", Type: "bool", Default: "false", Description: "Auto-accept incoming files without prompting"}, + {Name: "--no-clipboard", Type: "bool", Default: "false", Description: "Save incoming text as a file instead of copying to clipboard"}, + {Name: "--zip", Type: "bool", Default: "false", Description: "Zip directories before sharing"}, + {Name: "--concurrency", Type: "int", Default: "0", Description: "Max parallel uploads (0 = use default)"}, + {Name: "--history", Type: "string", Default: "", Description: "Path to transfer history JSONL file"}, + {Name: "--exec", Type: "string", Default: "", Description: "Shell command to execute after each received file"}, + {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - minimal output"}, + {Name: "--iface", Type: "string", Default: "", Description: "Multicast network interface name"}, + }, + }, + "discover": { + Name: "discover", + Description: "Discover LocalGo devices on the network using multicast", + Usage: "localgo discover [OPTIONS]", + Examples: []string{ + "localgo discover", + "localgo discover --timeout 10", + "localgo discover --json", + "localgo discover --quiet", + }, + Flags: []FlagHelp{ + {Name: "--timeout", Type: "int", Default: "10", Description: "Discovery timeout in seconds"}, + {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, + {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - only show results"}, + }, + }, + "scan": { + Name: "scan", + Description: "Scan the network for LocalGo devices using HTTP", + Usage: "localgo scan [OPTIONS]", + Examples: []string{ + "localgo scan", + "localgo scan --port 8080 --timeout 30", + "localgo scan --json", + "localgo scan --quiet", + "localgo scan --range 192.168.1.0/24", + }, + Flags: []FlagHelp{ + {Name: "--range", Type: "string", Default: "", Description: "CIDR range to scan (e.g. 192.168.1.0/24)"}, + {Name: "--timeout", Type: "int", Default: "15", Description: "Scan timeout in seconds"}, + {Name: "--port", Type: "int", Default: "from config", Description: "Port to scan"}, + {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, + {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - only show results"}, + }, + }, + "send": { + Name: "send", + Description: "Send a file or clipboard text to another LocalGo device", + Usage: "localgo send [OPTIONS]", + Examples: []string{ + "localgo send --file document.pdf --to MyPhone", + "localgo send --ip 192.168.1.42 --file document.pdf", + "localgo send --ip 192.168.1.42:53317 --file document.pdf", + "localgo send --clipboard --to MyPhone", + "localgo send -c --to MyPhone", + "localgo send (starts interactive clipboard or file picker if empty)", + }, + Flags: []FlagHelp{ + {Name: "--file", Type: "string", Default: "", Description: "File or directory to send (optional, can be specified multiple times)"}, + {Name: "--ip", Type: "string", Default: "", Description: "Target device IP (with optional :port, skips discovery)"}, + {Name: "--to", Type: "string", Default: "", Description: "Target device alias (omit to pick interactively)"}, + {Name: "--clipboard, -c", Type: "bool", Default: "false", Description: "Send current system clipboard text directly"}, + {Name: "--port", Type: "int", Default: "auto-detect", Description: "Target device port"}, + {Name: "--timeout", Type: "int", Default: "30", Description: "Send timeout in seconds"}, + {Name: "--alias", Type: "string", Default: "from config", Description: "Sender alias"}, + {Name: "--concurrency", Type: "int", Default: "0", Description: "Max parallel uploads (0 = use default)"}, + {Name: "--iface", Type: "string", Default: "", Description: "Multicast network interface name"}, + }, + }, + "history": { + Name: "history", + Description: "Show file transfer history log", + Usage: "localgo history [OPTIONS]", + Examples: []string{ + "localgo history", + "localgo history --limit 20", + "localgo history --clear", + }, + Flags: []FlagHelp{ + {Name: "--limit", Type: "int", Default: "10", Description: "Maximum number of entries to display"}, + {Name: "--clear", Type: "bool", Default: "false", Description: "Clear all transfer history logs"}, + }, + }, + "devices": { + Name: "devices", + Description: "List recently discovered devices on the network", + Usage: "localgo devices [OPTIONS]", + Examples: []string{ + "localgo devices", + "localgo devices --probe", + "localgo devices --json", + }, + Flags: []FlagHelp{ + {Name: "--probe, -p", Type: "bool", Default: "false", Description: "Probe cached devices to verify if they are currently online"}, + {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, + }, + }, + "info": { + Name: "info", + Description: "Show device information and configuration", + Usage: "localgo info [OPTIONS]", + Examples: []string{ + "localgo info", + "localgo info --json", + }, + Flags: []FlagHelp{ + {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, + }, + }, + "completion": { + Name: "completion", + Description: "Generate shell completion scripts", + Usage: "localgo completion [bash|zsh|fish|powershell]", + Examples: []string{ + "localgo completion bash > /etc/bash_completion.d/localgo", + "localgo completion zsh > /usr/local/share/zsh/site-functions/_localgo", + "localgo completion fish > ~/.config/fish/completions/localgo.fish", + }, + Flags: []FlagHelp{}, + }, + } + + return commands[commandName] +} diff --git a/pkg/help/help.go b/pkg/help/help.go index 753b454..2ab7e67 100644 --- a/pkg/help/help.go +++ b/pkg/help/help.go @@ -182,178 +182,4 @@ func ShowVersion(version, commit, date string) { cli.SuccessStyle.Render("LocalSend v2.1")) } -// GetCommandHelp returns help information for built-in commands -func GetCommandHelp(commandName string) *CommandHelp { - commands := map[string]*CommandHelp{ - "serve": { - Name: "serve", - Description: "Start the LocalGo server to receive files", - Usage: "localgo serve [OPTIONS]", - Examples: []string{ - "localgo serve", - "localgo serve --port 8080 --http", - "localgo serve --pin 123456 --alias MyDevice", - "localgo serve --dir /tmp/downloads --verbose", - "localgo serve --auto-accept --quiet", - "localgo serve --no-clipboard", - "localgo serve --exec 'notify-send \"Got: %f\"'", - }, - Flags: []FlagHelp{ - {Name: "--port", Type: "int", Default: "from config", Description: "Port to run the server on"}, - {Name: "--http", Type: "bool", Default: "false", Description: "Use HTTP instead of HTTPS"}, - {Name: "--pin", Type: "string", Default: "", Description: "PIN for authentication"}, - {Name: "--alias", Type: "string", Default: "from config", Description: "Device alias"}, - {Name: "--dir", Type: "string", Default: "from config", Description: "Download directory"}, - {Name: "--interval", Type: "int", Default: "30", Description: "Discovery announcement interval in seconds"}, - {Name: "--auto-accept", Type: "bool", Default: "false", Description: "Auto-accept incoming files without prompting"}, - {Name: "--no-clipboard", Type: "bool", Default: "false", Description: "Save incoming text as a file instead of copying to clipboard"}, - {Name: "--open", Type: "bool", Default: "false", Description: "Open download directory after transfer completes"}, - {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - minimal output"}, - {Name: "--verbose", Type: "bool", Default: "false", Description: "Verbose mode - detailed output"}, - {Name: "--history", Type: "string", Default: "~/.local/share/localgo/history.jsonl", Description: "Path to transfer history JSONL file"}, - {Name: "--exec", Type: "string", Default: "", Description: "Shell command to execute after each received file (use %f, %n, %s, %a, %i)"}, - {Name: "--iface", Type: "string", Default: "", Description: "Multicast network interface name"}, - }, - }, - "share": { - Name: "share", - Description: "Share files so other devices can download them", - Usage: "localgo share --file FILE [OPTIONS]", - Examples: []string{ - "localgo share --file document.pdf", - "localgo share --file image.jpg --file text.txt", - "localgo share --file data.zip --pin 1234", - "localgo share --file data.zip --auto-accept", - "localgo share --file report.pdf --no-clipboard", - "localgo share --file doc.pdf --exec 'curl -F \"file=@%f\" https://example.com/upload'", - }, - Flags: []FlagHelp{ - {Name: "--file", Type: "string", Default: "", Description: "File or directory to share (required, can be specified multiple times)"}, - {Name: "--port", Type: "int", Default: "from config", Description: "Port to run the server on"}, - {Name: "--http", Type: "bool", Default: "false", Description: "Use HTTP instead of HTTPS"}, - {Name: "--pin", Type: "string", Default: "", Description: "PIN for authentication"}, - {Name: "--alias", Type: "string", Default: "from config", Description: "Device alias"}, - {Name: "--auto-accept", Type: "bool", Default: "false", Description: "Auto-accept incoming files without prompting"}, - {Name: "--no-clipboard", Type: "bool", Default: "false", Description: "Save incoming text as a file instead of copying to clipboard"}, - {Name: "--zip", Type: "bool", Default: "false", Description: "Zip directories before sharing"}, - {Name: "--concurrency", Type: "int", Default: "0", Description: "Max parallel uploads (0 = use default)"}, - {Name: "--history", Type: "string", Default: "", Description: "Path to transfer history JSONL file"}, - {Name: "--exec", Type: "string", Default: "", Description: "Shell command to execute after each received file"}, - {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - minimal output"}, - {Name: "--iface", Type: "string", Default: "", Description: "Multicast network interface name"}, - }, - }, - "discover": { - Name: "discover", - Description: "Discover LocalGo devices on the network using multicast", - Usage: "localgo discover [OPTIONS]", - Examples: []string{ - "localgo discover", - "localgo discover --timeout 10", - "localgo discover --json", - "localgo discover --quiet", - }, - Flags: []FlagHelp{ - {Name: "--timeout", Type: "int", Default: "10", Description: "Discovery timeout in seconds"}, - {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, - {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - only show results"}, - }, - }, - "scan": { - Name: "scan", - Description: "Scan the network for LocalGo devices using HTTP", - Usage: "localgo scan [OPTIONS]", - Examples: []string{ - "localgo scan", - "localgo scan --port 8080 --timeout 30", - "localgo scan --json", - "localgo scan --quiet", - "localgo scan --range 192.168.1.0/24", - }, - Flags: []FlagHelp{ - {Name: "--range", Type: "string", Default: "", Description: "CIDR range to scan (e.g. 192.168.1.0/24)"}, - {Name: "--timeout", Type: "int", Default: "15", Description: "Scan timeout in seconds"}, - {Name: "--port", Type: "int", Default: "from config", Description: "Port to scan"}, - {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, - {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - only show results"}, - }, - }, - "send": { - Name: "send", - Description: "Send a file or clipboard text to another LocalGo device", - Usage: "localgo send [OPTIONS]", - Examples: []string{ - "localgo send --file document.pdf --to MyPhone", - "localgo send --ip 192.168.1.42 --file document.pdf", - "localgo send --ip 192.168.1.42:53317 --file document.pdf", - "localgo send --clipboard --to MyPhone", - "localgo send -c --to MyPhone", - "localgo send (starts interactive clipboard or file picker if empty)", - }, - Flags: []FlagHelp{ - {Name: "--file", Type: "string", Default: "", Description: "File or directory to send (optional, can be specified multiple times)"}, - {Name: "--ip", Type: "string", Default: "", Description: "Target device IP (with optional :port, skips discovery)"}, - {Name: "--to", Type: "string", Default: "", Description: "Target device alias (omit to pick interactively)"}, - {Name: "--clipboard, -c", Type: "bool", Default: "false", Description: "Send current system clipboard text directly"}, - {Name: "--port", Type: "int", Default: "auto-detect", Description: "Target device port"}, - {Name: "--timeout", Type: "int", Default: "30", Description: "Send timeout in seconds"}, - {Name: "--alias", Type: "string", Default: "from config", Description: "Sender alias"}, - {Name: "--concurrency", Type: "int", Default: "0", Description: "Max parallel uploads (0 = use default)"}, - {Name: "--iface", Type: "string", Default: "", Description: "Multicast network interface name"}, - }, - }, - "history": { - Name: "history", - Description: "Show file transfer history log", - Usage: "localgo history [OPTIONS]", - Examples: []string{ - "localgo history", - "localgo history --limit 20", - "localgo history --clear", - }, - Flags: []FlagHelp{ - {Name: "--limit", Type: "int", Default: "10", Description: "Maximum number of entries to display"}, - {Name: "--clear", Type: "bool", Default: "false", Description: "Clear all transfer history logs"}, - }, - }, - "devices": { - Name: "devices", - Description: "List recently discovered devices on the network", - Usage: "localgo devices [OPTIONS]", - Examples: []string{ - "localgo devices", - "localgo devices --probe", - "localgo devices --json", - }, - Flags: []FlagHelp{ - {Name: "--probe, -p", Type: "bool", Default: "false", Description: "Probe cached devices to verify if they are currently online"}, - {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, - }, - }, - "info": { - Name: "info", - Description: "Show device information and configuration", - Usage: "localgo info [OPTIONS]", - Examples: []string{ - "localgo info", - "localgo info --json", - }, - Flags: []FlagHelp{ - {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, - }, - }, - "completion": { - Name: "completion", - Description: "Generate shell completion scripts", - Usage: "localgo completion [bash|zsh|fish|powershell]", - Examples: []string{ - "localgo completion bash > /etc/bash_completion.d/localgo", - "localgo completion zsh > /usr/local/share/zsh/site-functions/_localgo", - "localgo completion fish > ~/.config/fish/completions/localgo.fish", - }, - Flags: []FlagHelp{}, - }, - } - return commands[commandName] -} diff --git a/pkg/send/anonymize.go b/pkg/send/anonymize.go new file mode 100644 index 0000000..558b1f5 --- /dev/null +++ b/pkg/send/anonymize.go @@ -0,0 +1,49 @@ +package send + +import "strings" + +// anonymizeFileName maps a MIME type to a generic filename for private mode. +func anonymizeFileName(contentType string) string { + switch { + case strings.HasPrefix(contentType, "image/jpeg"): + return "image.jpg" + case strings.HasPrefix(contentType, "image/png"): + return "image.png" + case strings.HasPrefix(contentType, "image/webp"): + return "image.webp" + case strings.HasPrefix(contentType, "image/"): + return "image.bin" + case strings.HasPrefix(contentType, "video/mp4"): + return "video.mp4" + case strings.HasPrefix(contentType, "video/webm"): + return "video.webm" + case strings.HasPrefix(contentType, "video/x-matroska"): + return "video.mkv" + case strings.HasPrefix(contentType, "video/quicktime"): + return "video.mov" + case strings.HasPrefix(contentType, "video/"): + return "video.bin" + case strings.HasPrefix(contentType, "audio/"): + return "audio.mp3" + case strings.HasPrefix(contentType, "text/plain"): + return "document.txt" + case strings.HasPrefix(contentType, "text/html"): + return "document.html" + case strings.HasPrefix(contentType, "text/"): + return "document.txt" + case contentType == "application/pdf": + return "document.pdf" + case strings.HasPrefix(contentType, "application/zip"): + return "archive.zip" + case strings.HasPrefix(contentType, "application/gzip"): + return "archive.tar.gz" + case strings.HasPrefix(contentType, "application/x-tar"): + return "archive.tar" + case strings.HasPrefix(contentType, "application/x-"): + return "archive.bin" + case strings.HasPrefix(contentType, "application/"): + return "document.bin" + default: + return "file.bin" + } +} diff --git a/pkg/send/filepath.go b/pkg/send/filepath.go new file mode 100644 index 0000000..0c93750 --- /dev/null +++ b/pkg/send/filepath.go @@ -0,0 +1,40 @@ +package send + +import ( + "os" + "path/filepath" +) + +func getFilesWithRelativePaths(paths []string) (map[string]string, error) { + result := make(map[string]string) + for _, p := range paths { + p = filepath.Clean(p) + info, err := os.Stat(p) + if err != nil { + return nil, err + } + if info.IsDir() { + baseDir := filepath.Dir(p) + err = filepath.Walk(p, func(path string, fInfo os.FileInfo, err error) error { + if err != nil { + return err + } + if !fInfo.IsDir() { + rel, err := filepath.Rel(baseDir, path) + if err == nil { + result[path] = filepath.ToSlash(rel) + } else { + result[path] = filepath.Base(path) + } + } + return nil + }) + if err != nil { + return nil, err + } + } else { + result[p] = filepath.Base(p) + } + } + return result, nil +} diff --git a/pkg/send/send.go b/pkg/send/send.go index bb4591d..aa22f53 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -7,9 +7,7 @@ import ( "crypto/tls" "encoding/hex" "encoding/json" - "errors" "fmt" - "io" "net" "net/http" "os" @@ -25,46 +23,10 @@ import ( "github.com/bethropolis/localgo/pkg/metadata" "github.com/bethropolis/localgo/pkg/model" "github.com/bethropolis/localgo/pkg/network" - "github.com/charmbracelet/huh" "github.com/google/uuid" "go.uber.org/zap" ) -// getFilesWithRelativePaths recursively flattens directories while preserving relative structure -func getFilesWithRelativePaths(paths []string) (map[string]string, error) { - result := make(map[string]string) - for _, p := range paths { - p = filepath.Clean(p) - info, err := os.Stat(p) - if err != nil { - return nil, err - } - if info.IsDir() { - baseDir := filepath.Dir(p) - err = filepath.Walk(p, func(path string, fInfo os.FileInfo, err error) error { - if err != nil { - return err - } - if !fInfo.IsDir() { - rel, err := filepath.Rel(baseDir, path) - if err == nil { - result[path] = filepath.ToSlash(rel) - } else { - result[path] = filepath.Base(path) - } - } - return nil - }) - if err != nil { - return nil, err - } - } else { - result[p] = filepath.Base(p) - } - } - return result, nil -} - // SendFiles sends files or directories to a recipient. func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, recipientAlias string, recipientPort int, logger *zap.SugaredLogger) error { if logger == nil { @@ -177,86 +139,6 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci return SendToDevice(ctx, cfg, targetDevice, filePaths, logger) } -// verifyDeviceFingerprint checks if a cached fingerprint differs from the target's -// and prompts the user to trust the updated fingerprint before proceeding. -func verifyDeviceFingerprint(peerCache *discovery.PeerCache, targetDevice *model.Device) error { - if targetDevice == nil || targetDevice.Fingerprint == "" { - return nil - } - - cachedPeers := peerCache.GetPeers() - for _, cached := range cachedPeers { - if cached.Alias == targetDevice.Alias && cached.Fingerprint != targetDevice.Fingerprint { - cli.PrintWarning("The security fingerprint for '%s' has changed!", targetDevice.Alias) - - var trust bool - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Trust this new device fingerprint and update cache?"). - Value(&trust). - Affirmative("Trust & Save"). - Negative("Abort"), - ), - ).WithTheme(huh.ThemeCharm()) - - if err := form.Run(); err != nil || !trust { - return fmt.Errorf("security verification failed: untrusted certificate hash change") - } - - peerCache.Save(targetDevice) - break - } - } - return nil -} - -// anonymizeFileName maps a MIME type to a generic filename for private mode. -func anonymizeFileName(contentType string) string { - switch { - case strings.HasPrefix(contentType, "image/jpeg"): - return "image.jpg" - case strings.HasPrefix(contentType, "image/png"): - return "image.png" - case strings.HasPrefix(contentType, "image/webp"): - return "image.webp" - case strings.HasPrefix(contentType, "image/"): - return "image.bin" - case strings.HasPrefix(contentType, "video/mp4"): - return "video.mp4" - case strings.HasPrefix(contentType, "video/webm"): - return "video.webm" - case strings.HasPrefix(contentType, "video/x-matroska"): - return "video.mkv" - case strings.HasPrefix(contentType, "video/quicktime"): - return "video.mov" - case strings.HasPrefix(contentType, "video/"): - return "video.bin" - case strings.HasPrefix(contentType, "audio/"): - return "audio.mp3" - case strings.HasPrefix(contentType, "text/plain"): - return "document.txt" - case strings.HasPrefix(contentType, "text/html"): - return "document.html" - case strings.HasPrefix(contentType, "text/"): - return "document.txt" - case contentType == "application/pdf": - return "document.pdf" - case strings.HasPrefix(contentType, "application/zip"): - return "archive.zip" - case strings.HasPrefix(contentType, "application/gzip"): - return "archive.tar.gz" - case strings.HasPrefix(contentType, "application/x-tar"): - return "archive.tar" - case strings.HasPrefix(contentType, "application/x-"): - return "archive.bin" - case strings.HasPrefix(contentType, "application/"): - return "document.bin" - default: - return "file.bin" - } -} - func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, filePaths []string, logger *zap.SugaredLogger) error { if logger == nil { logger = zap.NewNop().Sugar() @@ -464,109 +346,4 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, return nil } -func uploadFile(ctx context.Context, client *http.Client, device *model.Device, filePath, fileID, sessionID, token, scheme string, trackProgress func(int64), logger *zap.SugaredLogger) error { - if logger == nil { - logger = zap.NewNop().Sugar() - } - - file, err := os.Open(filePath) - if err != nil { - return fmt.Errorf("failed to open file: %w", err) - } - defer file.Close() - - url := fmt.Sprintf("%s://%s/api/localsend/v2/upload?sessionId=%s&fileId=%s&token=%s", scheme, net.JoinHostPort(device.IP, strconv.Itoa(device.Port)), sessionID, fileID, token) - - stat, err := file.Stat() - if err != nil { - return fmt.Errorf("failed to get file stats: %w", err) - } - - var body io.ReadCloser = file - if trackProgress != nil { - bar := &progressBar{current: 0, track: trackProgress} - body = &progressTracker{Reader: file, Closer: file, bar: bar} - } - - // Wrap with idle timeout: cancel request if no data flows for 15s - uploadCtx, cancel := context.WithCancel(ctx) - body = NewIdleTimeoutReader(body, 15*time.Second, cancel) - - req, err := http.NewRequestWithContext(uploadCtx, http.MethodPost, url, body) - if err != nil { - cancel() - return fmt.Errorf("failed to create upload request: %w", err) - } - req.Header.Set("Content-Type", "application/octet-stream") - req.ContentLength = stat.Size() - - resp, err := client.Do(req) - if err != nil { - if errors.Is(err, context.Canceled) { - return fmt.Errorf("upload stalled: no data transmitted for 15s") - } - return fmt.Errorf("failed to send upload request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("upload request failed with status: %s", resp.Status) - } - - return nil -} - -// IdleTimeoutReader wraps an io.ReadCloser and cancels the context if no data -// is read within the configured idle duration. -type IdleTimeoutReader struct { - r io.ReadCloser - idleTimeout time.Duration - timer *time.Timer - cancel func() -} - -func NewIdleTimeoutReader(r io.ReadCloser, timeout time.Duration, cancel func()) *IdleTimeoutReader { - tr := &IdleTimeoutReader{ - r: r, - idleTimeout: timeout, - cancel: cancel, - } - tr.timer = time.AfterFunc(timeout, func() { - tr.cancel() - }) - return tr -} - -func (tr *IdleTimeoutReader) Read(p []byte) (int, error) { - tr.timer.Reset(tr.idleTimeout) - n, err := tr.r.Read(p) - if err != nil { - tr.timer.Stop() - } - return n, err -} -func (tr *IdleTimeoutReader) Close() error { - tr.timer.Stop() - return tr.r.Close() -} - -type progressBar struct { - current int64 - track func(int64) -} - -type progressTracker struct { - io.Reader - io.Closer - bar *progressBar -} - -func (pt *progressTracker) Read(p []byte) (int, error) { - n, err := pt.Reader.Read(p) - if n > 0 && pt.bar != nil { - pt.bar.current += int64(n) - pt.bar.track(pt.bar.current) - } - return n, err -} diff --git a/pkg/send/upload.go b/pkg/send/upload.go new file mode 100644 index 0000000..d1eda76 --- /dev/null +++ b/pkg/send/upload.go @@ -0,0 +1,124 @@ +package send + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "strconv" + "time" + + "github.com/bethropolis/localgo/pkg/model" + "go.uber.org/zap" +) + +func uploadFile(ctx context.Context, client *http.Client, device *model.Device, filePath, fileID, sessionID, token, scheme string, trackProgress func(int64), logger *zap.SugaredLogger) error { + if logger == nil { + logger = zap.NewNop().Sugar() + } + + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + url := fmt.Sprintf("%s://%s/api/localsend/v2/upload?sessionId=%s&fileId=%s&token=%s", scheme, net.JoinHostPort(device.IP, strconv.Itoa(device.Port)), sessionID, fileID, token) + + stat, err := file.Stat() + if err != nil { + return fmt.Errorf("failed to get file stats: %w", err) + } + + var body io.ReadCloser = file + if trackProgress != nil { + bar := &progressBar{current: 0, track: trackProgress} + body = &progressTracker{Reader: file, Closer: file, bar: bar} + } + + // Wrap with idle timeout: cancel request if no data flows for 15s + uploadCtx, cancel := context.WithCancel(ctx) + body = NewIdleTimeoutReader(body, 15*time.Second, cancel) + + req, err := http.NewRequestWithContext(uploadCtx, http.MethodPost, url, body) + if err != nil { + cancel() + return fmt.Errorf("failed to create upload request: %w", err) + } + req.Header.Set("Content-Type", "application/octet-stream") + req.ContentLength = stat.Size() + + resp, err := client.Do(req) + if err != nil { + if errors.Is(err, context.Canceled) { + return fmt.Errorf("upload stalled: no data transmitted for 15s") + } + return fmt.Errorf("failed to send upload request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("upload request failed with status: %s", resp.Status) + } + + return nil +} + +// IdleTimeoutReader wraps an io.ReadCloser and cancels the context if no data +// is read within the configured idle duration. +type IdleTimeoutReader struct { + r io.ReadCloser + idleTimeout time.Duration + timer *time.Timer + cancel func() +} + +// NewIdleTimeoutReader creates an IdleTimeoutReader. +func NewIdleTimeoutReader(r io.ReadCloser, timeout time.Duration, cancel func()) *IdleTimeoutReader { + tr := &IdleTimeoutReader{ + r: r, + idleTimeout: timeout, + cancel: cancel, + } + tr.timer = time.AfterFunc(timeout, func() { + tr.cancel() + }) + return tr +} + +func (tr *IdleTimeoutReader) Read(p []byte) (int, error) { + tr.timer.Reset(tr.idleTimeout) + n, err := tr.r.Read(p) + if err != nil { + tr.timer.Stop() + } + return n, err +} + +func (tr *IdleTimeoutReader) Close() error { + tr.timer.Stop() + return tr.r.Close() +} + +type progressBar struct { + current int64 + track func(int64) +} + +type progressTracker struct { + io.Reader + io.Closer + bar *progressBar +} + +func (pt *progressTracker) Read(p []byte) (int, error) { + n, err := pt.Reader.Read(p) + if n > 0 && pt.bar != nil { + pt.bar.current += int64(n) + pt.bar.track(pt.bar.current) + } + return n, err +} diff --git a/pkg/send/verify.go b/pkg/send/verify.go new file mode 100644 index 0000000..de5ec4a --- /dev/null +++ b/pkg/send/verify.go @@ -0,0 +1,42 @@ +package send + +import ( + "fmt" + + "github.com/bethropolis/localgo/pkg/cli" + "github.com/bethropolis/localgo/pkg/discovery" + "github.com/bethropolis/localgo/pkg/model" + "github.com/charmbracelet/huh" +) + +func verifyDeviceFingerprint(peerCache *discovery.PeerCache, targetDevice *model.Device) error { + if targetDevice == nil || targetDevice.Fingerprint == "" { + return nil + } + + cachedPeers := peerCache.GetPeers() + for _, cached := range cachedPeers { + if cached.Alias == targetDevice.Alias && cached.Fingerprint != targetDevice.Fingerprint { + cli.PrintWarning("The security fingerprint for '%s' has changed!", targetDevice.Alias) + + var trust bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Trust this new device fingerprint and update cache?"). + Value(&trust). + Affirmative("Trust & Save"). + Negative("Abort"), + ), + ).WithTheme(huh.ThemeCharm()) + + if err := form.Run(); err != nil || !trust { + return fmt.Errorf("security verification failed: untrusted certificate hash change") + } + + peerCache.Save(targetDevice) + break + } + } + return nil +} diff --git a/pkg/server/handlers/exec.go b/pkg/server/handlers/exec.go new file mode 100644 index 0000000..fa43989 --- /dev/null +++ b/pkg/server/handlers/exec.go @@ -0,0 +1,37 @@ +package handlers + +import ( + "fmt" + "os" + "os/exec" + "runtime" +) + +func (h *ReceiveHandler) runExecHook(filePath, fileName, senderAlias, senderIP string, fileSize int64) { + if h.config.ExecHook == "" { + return + } + + go func() { + h.logger.Infof("Running exec hook: %s", h.config.ExecHook) + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/c", h.config.ExecHook) + } else { + cmd = exec.Command("sh", "-c", h.config.ExecHook) + } + cmd.Env = append(os.Environ(), + "LOCALGO_FILE="+filePath, + "LOCALGO_NAME="+fileName, + fmt.Sprintf("LOCALGO_SIZE=%d", fileSize), + "LOCALGO_ALIAS="+senderAlias, + "LOCALGO_IP="+senderIP, + ) + output, err := cmd.CombinedOutput() + if err != nil { + h.logger.Errorf("Exec hook failed: %v, output: %s", err, string(output)) + } else { + h.logger.Debugf("Exec hook completed, output: %s", string(output)) + } + }() +} diff --git a/pkg/server/handlers/history_log.go b/pkg/server/handlers/history_log.go new file mode 100644 index 0000000..cb64f05 --- /dev/null +++ b/pkg/server/handlers/history_log.go @@ -0,0 +1,23 @@ +package handlers + +import ( + "github.com/bethropolis/localgo/pkg/history" +) + +func (h *ReceiveHandler) logTransfer(senderAlias, senderIP, fileName, filePath string, size int64, fileType, status string) { + if h.historyLog == nil { + return + } + entry := history.Entry{ + SenderAlias: senderAlias, + SenderIP: senderIP, + FileName: fileName, + FilePath: filePath, + FileSize: size, + FileType: fileType, + Status: status, + } + if err := h.historyLog.Log(entry); err != nil { + h.logger.Errorf("Failed to log transfer history: %v", err) + } +} diff --git a/pkg/server/handlers/prompt.go b/pkg/server/handlers/prompt.go new file mode 100644 index 0000000..3961857 --- /dev/null +++ b/pkg/server/handlers/prompt.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/bethropolis/localgo/pkg/cli" + "github.com/bethropolis/localgo/pkg/model" + "github.com/charmbracelet/huh" +) + +func (h *ReceiveHandler) promptUserForAcceptance(sender model.DeviceInfo, files map[string]model.FileDto) bool { + if cli.IsContainer() { + return false + } + + fileCount := len(files) + var totalSize int64 + for _, f := range files { + totalSize += f.Size + } + + cli.Notify("LocalGo: Incoming Transfer", + fmt.Sprintf("%s wants to send you %d file(s) (%s)", sender.Alias, fileCount, cli.FormatBytes(totalSize))) + + // Build a structured summary of the incoming files + var sb strings.Builder + sb.WriteString(fmt.Sprintf("From: %s (IP: %s)\n\nFiles:\n", sender.Alias, sender.IP)) + + count := 0 + for _, file := range files { + if count >= 5 { + sb.WriteString(fmt.Sprintf(" ... and %d more files\n", fileCount-5)) + break + } + isText := strings.HasPrefix(file.FileType, "text/plain") + if isText { + preview := "" + if file.Preview != nil && *file.Preview != "" { + preview = *file.Preview + if len(preview) > 50 { + preview = preview[:50] + "…" + } + sb.WriteString(fmt.Sprintf(" %s [Text] %q\n", cli.IconFile, preview)) + } else { + sb.WriteString(fmt.Sprintf(" %s [Text] %s (%s)\n", cli.IconFile, file.FileName, cli.FormatBytes(file.Size))) + } + } else { + sb.WriteString(fmt.Sprintf(" %s %s (%s)\n", cli.IconFile, file.FileName, cli.FormatBytes(file.Size))) + } + count++ + } + + if totalSize > 0 { + sb.WriteString(fmt.Sprintf("\nTotal Size: %s", cli.FormatBytes(totalSize))) + } + + var accept bool = true + + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Accept Incoming File Transfer?"). + Description(sb.String()). + Value(&accept). + Affirmative("Accept"). + Negative("Reject"), + ), + ).WithTheme(huh.ThemeCharm()) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := form.RunWithContext(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "\n%s Transfer automatically rejected.\n", cli.WarningStyle.Render(cli.IconWarning)) + return false + } + + return accept +} diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index db2c961..f6593c0 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -1,31 +1,21 @@ package handlers import ( - "bytes" - "context" "crypto/subtle" "encoding/json" - "fmt" - "io" "net" "net/http" - "os" "os/exec" - "path/filepath" "runtime" - "strings" "sync" - "time" "github.com/bethropolis/localgo/pkg/cli" - "github.com/bethropolis/localgo/pkg/clipboard" "github.com/bethropolis/localgo/pkg/config" "github.com/bethropolis/localgo/pkg/history" "github.com/bethropolis/localgo/pkg/httputil" "github.com/bethropolis/localgo/pkg/model" "github.com/bethropolis/localgo/pkg/server/services" "github.com/bethropolis/localgo/pkg/storage" - "github.com/charmbracelet/huh" "go.uber.org/zap" ) @@ -152,316 +142,11 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R httputil.RespondJSON(w, http.StatusOK, responseDto) } -// UploadHandlerV2 handles POST /v2/upload requests. -func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) { - h.logger.Info("Received /upload request") - if r.Method != http.MethodPost { - httputil.RespondError(w, http.StatusMethodNotAllowed, "Method Not Allowed") - return - } - - // --- Get Query Params --- - query := r.URL.Query() - reqSessionId := query.Get("sessionId") - reqFileId := query.Get("fileId") - reqToken := query.Get("token") - - if reqSessionId == "" || reqFileId == "" || reqToken == "" { - httputil.RespondError(w, http.StatusBadRequest, "Missing query parameters (sessionId, fileId, token)") - return - } - - // --- Validate Session and Token --- - session := h.receiveService.GetSessionByID(reqSessionId) - if session == nil { - h.logger.Warnf("Invalid sessionId '%s' for /upload", reqSessionId) - httputil.RespondError(w, http.StatusForbidden, "Invalid session ID") // 403 Forbidden - return - } - - // Validate sender IP matches the one from prepare-upload - reqIP, _, _ := net.SplitHostPort(r.RemoteAddr) - if reqIP != session.Sender.IP { - h.logger.Warnf("IP mismatch for /upload: request from %s, expected %s", reqIP, session.Sender.IP) - httputil.RespondError(w, http.StatusForbidden, fmt.Sprintf("Invalid IP address: %s", reqIP)) // 403 Forbidden - return - } - - fileInfo, ok := session.Files[reqFileId] - if !ok || fileInfo.Token != reqToken { - h.logger.Warnf("Invalid fileId '%s' or token '%s' for session '%s'", reqFileId, reqToken, reqSessionId) - httputil.RespondError(w, http.StatusForbidden, "Invalid fileId or token") // 403 Forbidden - return - } - - // --- File Saving --- - rawFileName := fileInfo.Dto.FileName - destinationPath := storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName) - - // Path traversal prevention: ensure the resolved path is still within DownloadDir - cleanPath := filepath.Clean(destinationPath) - if !strings.HasPrefix(cleanPath, filepath.Clean(h.config.DownloadDir)+string(filepath.Separator)) && - cleanPath != filepath.Clean(h.config.DownloadDir) { - h.logger.Errorf("Path traversal attempt detected: %s -> %s", rawFileName, cleanPath) - httputil.RespondError(w, http.StatusBadRequest, "Invalid filename") - return - } - - h.logger.Infof("Starting save for file: %s (ID: %s) to %s", fileInfo.Dto.FileName, reqFileId, destinationPath) - - var trackProgress func(int64) - if !h.config.Quiet && session.Progress != nil { - displayName := fileInfo.Dto.FileName - if fileInfo.Dto.Preview != nil && *fileInfo.Dto.Preview != "" { - preview := *fileInfo.Dto.Preview - if len(preview) > 20 { - preview = preview[:20] + "…" - } - displayName = preview - } - trackProgress = session.Progress.AddBar(displayName, fileInfo.Dto.Size) - } - - // --- Progress Callback --- - onProgress := func(bytesWritten int64) { - if trackProgress != nil { - trackProgress(bytesWritten) - } - } - - // --- Body Size Limit --- - maxBodySize := h.config.MaxBodySize - if maxBodySize <= 0 { - maxBodySize = 100 * 1024 * 1024 * 1024 // 100GB default - } - bodyReader := http.MaxBytesReader(w, r.Body, maxBodySize) - defer r.Body.Close() - - var modified, accessed *string - if fileInfo.Dto.Metadata != nil { - modified = fileInfo.Dto.Metadata.Modified - accessed = fileInfo.Dto.Metadata.Accessed - } - - // --- Text/Clipboard Handling --- - // When the incoming transfer is plain text and clipboard is not disabled, - // try to copy the content directly to the system clipboard instead of writing - // to disk. On failure (headless / no display server) fall through to the - // normal file-save path so the content is never lost. - if strings.HasPrefix(fileInfo.Dto.FileType, "text/plain") && !h.config.NoClipboard { - limited := io.LimitReader(bodyReader, maxTextSize+1) - textBytes, readErr := io.ReadAll(limited) - - if readErr != nil { - h.logger.Errorf("Error reading text body for clipboard (file %s): %v", fileInfo.Dto.FileName, readErr) - httputil.RespondError(w, http.StatusInternalServerError, "Failed to read text content") - return - } - - text := string(textBytes) - - if int64(len(textBytes)) > maxTextSize { - // Text is too large for clipboard; save to file instead. - h.logger.Warnf("Text transfer too large for clipboard (%d bytes), saving to file", len(textBytes)) - } else if clipErr := clipboard.Write(text); clipErr == nil { - // Successfully copied to clipboard. - preview := text - if len(preview) > 80 { - preview = preview[:80] + "…" - } - h.logger.Infof("Copied text to clipboard from %s: %q", fileInfo.Dto.FileName, preview) - - // Mark the progress bar as completed since no file write occurs - onProgress(fileInfo.Dto.Size) - - h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, "", int64(len(textBytes)), fileInfo.Dto.FileType, history.StatusClipboard) - h.runExecHook("", rawFileName, session.Sender.Alias, session.Sender.IP, int64(len(textBytes))) - w.WriteHeader(http.StatusOK) - return - } else { - // Clipboard unavailable — fall back to file. - h.logger.Warnf("Clipboard unavailable (%v), saving text as file instead", clipErr) - } - - // Fall-back: save the full stream as a file. - h.saveTextAsFile(session, reqSessionId, reqFileId, rawFileName, bodyReader, textBytes, modified, accessed, onProgress) - return - } - - err := storage.SaveStreamToFileWithMetadata(bodyReader, destinationPath, fileInfo.Dto.Size, modified, accessed, fileInfo.Dto.SHA256, onProgress, h.logger) - - if err != nil { - h.logger.Errorf("Error saving file %s (ID: %s): %v", fileInfo.Dto.FileName, reqFileId, err) - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, fileInfo.Dto.Size, fileInfo.Dto.FileType, history.StatusFailed) - httputil.RespondError(w, http.StatusInternalServerError, "Failed to save file") - return - } - - // --- Success --- - h.logger.Infof("Finished saving file: %s (ID: %s)", fileInfo.Dto.FileName, reqFileId) - - h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) - - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, fileInfo.Dto.Size, fileInfo.Dto.FileType, history.StatusReceived) - h.runExecHook(destinationPath, rawFileName, session.Sender.Alias, session.Sender.IP, fileInfo.Dto.Size) - - w.WriteHeader(http.StatusOK) -} - // PrepareUploadHandlerV1 handles POST /v1/prepare-upload requests (older protocol). func (h *ReceiveHandler) PrepareUploadHandlerV1(w http.ResponseWriter, r *http.Request) { - // This is a simplified version for V1. It will be removed in the future. h.PrepareUploadHandlerV2(w, r) } -func (h *ReceiveHandler) promptUserForAcceptance(sender model.DeviceInfo, files map[string]model.FileDto) bool { - if cli.IsContainer() { - return false - } - - fileCount := len(files) - var totalSize int64 - for _, f := range files { - totalSize += f.Size - } - - cli.Notify("LocalGo: Incoming Transfer", - fmt.Sprintf("%s wants to send you %d file(s) (%s)", sender.Alias, fileCount, cli.FormatBytes(totalSize))) - - // Build a structured summary of the incoming files - var sb strings.Builder - sb.WriteString(fmt.Sprintf("From: %s (IP: %s)\n\nFiles:\n", sender.Alias, sender.IP)) - - count := 0 - for _, file := range files { - if count >= 5 { - sb.WriteString(fmt.Sprintf(" ... and %d more files\n", fileCount-5)) - break - } - isText := strings.HasPrefix(file.FileType, "text/plain") - if isText { - preview := "" - if file.Preview != nil && *file.Preview != "" { - preview = *file.Preview - if len(preview) > 50 { - preview = preview[:50] + "…" - } - sb.WriteString(fmt.Sprintf(" %s [Text] %q\n", cli.IconFile, preview)) - } else { - sb.WriteString(fmt.Sprintf(" %s [Text] %s (%s)\n", cli.IconFile, file.FileName, cli.FormatBytes(file.Size))) - } - } else { - sb.WriteString(fmt.Sprintf(" %s %s (%s)\n", cli.IconFile, file.FileName, cli.FormatBytes(file.Size))) - } - count++ - } - - if totalSize > 0 { - sb.WriteString(fmt.Sprintf("\nTotal Size: %s", cli.FormatBytes(totalSize))) - } - - var accept bool = true - - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Accept Incoming File Transfer?"). - Description(sb.String()). - Value(&accept). - Affirmative("Accept"). - Negative("Reject"), - ), - ).WithTheme(huh.ThemeCharm()) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - err := form.RunWithContext(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "\n%s Transfer automatically rejected.\n", cli.WarningStyle.Render(cli.IconWarning)) - return false - } - - return accept -} - -func (h *ReceiveHandler) logTransfer(senderAlias, senderIP, fileName, filePath string, size int64, fileType, status string) { - if h.historyLog == nil { - return - } - entry := history.Entry{ - SenderAlias: senderAlias, - SenderIP: senderIP, - FileName: fileName, - FilePath: filePath, - FileSize: size, - FileType: fileType, - Status: status, - } - if err := h.historyLog.Log(entry); err != nil { - h.logger.Errorf("Failed to log transfer history: %v", err) - } -} - -func (h *ReceiveHandler) runExecHook(filePath, fileName, senderAlias, senderIP string, fileSize int64) { - if h.config.ExecHook == "" { - return - } - - go func() { - h.logger.Infof("Running exec hook: %s", h.config.ExecHook) - var cmd *exec.Cmd - if runtime.GOOS == "windows" { - cmd = exec.Command("cmd", "/c", h.config.ExecHook) - } else { - cmd = exec.Command("sh", "-c", h.config.ExecHook) - } - cmd.Env = append(os.Environ(), - "LOCALGO_FILE="+filePath, - "LOCALGO_NAME="+fileName, - fmt.Sprintf("LOCALGO_SIZE=%d", fileSize), - "LOCALGO_ALIAS="+senderAlias, - "LOCALGO_IP="+senderIP, - ) - output, err := cmd.CombinedOutput() - if err != nil { - h.logger.Errorf("Exec hook failed: %v, output: %s", err, string(output)) - } else { - h.logger.Debugf("Exec hook completed, output: %s", string(output)) - } - }() -} - -// saveTextAsFile saves text content as a file when clipboard is unavailable or text is too large. -func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, reqSessionId, reqFileId, rawFileName string, bodyReader io.Reader, textBytes []byte, modified, accessed *string, onProgress func(int64)) { - var combinedReader io.Reader - if int64(len(textBytes)) > maxTextSize { - combinedReader = io.MultiReader(bytes.NewReader(textBytes), bodyReader) - } else { - combinedReader = bytes.NewReader(textBytes) - } - destinationPath := storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName) - cleanPath := filepath.Clean(destinationPath) - if !strings.HasPrefix(cleanPath, filepath.Clean(h.config.DownloadDir)+string(filepath.Separator)) && - cleanPath != filepath.Clean(h.config.DownloadDir) { - h.logger.Errorf("Path traversal attempt detected in text fallback: %s", rawFileName) - return - } - savErr := storage.SaveStreamToFileWithMetadata( - combinedReader, destinationPath, int64(len(textBytes)), modified, accessed, nil, onProgress, h.logger, - ) - if savErr != nil { - h.logger.Errorf("Error saving text file %s: %v", rawFileName, savErr) - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusFailed) - return - } - h.logger.Infof("Saved text as file: %s", destinationPath) - h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusReceived) - h.runExecHook(destinationPath, rawFileName, session.Sender.Alias, session.Sender.IP, int64(len(textBytes))) -} - // CancelHandler handles POST /v2/cancel requests. func (h *ReceiveHandler) CancelHandler(w http.ResponseWriter, r *http.Request) { h.logger.Info("Received /cancel request") diff --git a/pkg/server/handlers/receive_upload.go b/pkg/server/handlers/receive_upload.go new file mode 100644 index 0000000..73f8928 --- /dev/null +++ b/pkg/server/handlers/receive_upload.go @@ -0,0 +1,202 @@ +package handlers + +import ( + "bytes" + "fmt" + "io" + "net" + "net/http" + "path/filepath" + "strings" + + "github.com/bethropolis/localgo/pkg/clipboard" + "github.com/bethropolis/localgo/pkg/history" + "github.com/bethropolis/localgo/pkg/httputil" + "github.com/bethropolis/localgo/pkg/server/services" + "github.com/bethropolis/localgo/pkg/storage" +) + +func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) { + h.logger.Info("Received /upload request") + if r.Method != http.MethodPost { + httputil.RespondError(w, http.StatusMethodNotAllowed, "Method Not Allowed") + return + } + + // --- Get Query Params --- + query := r.URL.Query() + reqSessionId := query.Get("sessionId") + reqFileId := query.Get("fileId") + reqToken := query.Get("token") + + if reqSessionId == "" || reqFileId == "" || reqToken == "" { + httputil.RespondError(w, http.StatusBadRequest, "Missing query parameters (sessionId, fileId, token)") + return + } + + // --- Validate Session and Token --- + session := h.receiveService.GetSessionByID(reqSessionId) + if session == nil { + h.logger.Warnf("Invalid sessionId '%s' for /upload", reqSessionId) + httputil.RespondError(w, http.StatusForbidden, "Invalid session ID") // 403 Forbidden + return + } + + // Validate sender IP matches the one from prepare-upload + reqIP, _, _ := net.SplitHostPort(r.RemoteAddr) + if reqIP != session.Sender.IP { + h.logger.Warnf("IP mismatch for /upload: request from %s, expected %s", reqIP, session.Sender.IP) + httputil.RespondError(w, http.StatusForbidden, fmt.Sprintf("Invalid IP address: %s", reqIP)) // 403 Forbidden + return + } + + fileInfo, ok := session.Files[reqFileId] + if !ok || fileInfo.Token != reqToken { + h.logger.Warnf("Invalid fileId '%s' or token '%s' for session '%s'", reqFileId, reqToken, reqSessionId) + httputil.RespondError(w, http.StatusForbidden, "Invalid fileId or token") // 403 Forbidden + return + } + + // --- File Saving --- + rawFileName := fileInfo.Dto.FileName + destinationPath := storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName) + + // Path traversal prevention: ensure the resolved path is still within DownloadDir + cleanPath := filepath.Clean(destinationPath) + if !strings.HasPrefix(cleanPath, filepath.Clean(h.config.DownloadDir)+string(filepath.Separator)) && + cleanPath != filepath.Clean(h.config.DownloadDir) { + h.logger.Errorf("Path traversal attempt detected: %s -> %s", rawFileName, cleanPath) + httputil.RespondError(w, http.StatusBadRequest, "Invalid filename") + return + } + + h.logger.Infof("Starting save for file: %s (ID: %s) to %s", fileInfo.Dto.FileName, reqFileId, destinationPath) + + var trackProgress func(int64) + if !h.config.Quiet && session.Progress != nil { + displayName := fileInfo.Dto.FileName + if fileInfo.Dto.Preview != nil && *fileInfo.Dto.Preview != "" { + preview := *fileInfo.Dto.Preview + if len(preview) > 20 { + preview = preview[:20] + "…" + } + displayName = preview + } + trackProgress = session.Progress.AddBar(displayName, fileInfo.Dto.Size) + } + + // --- Progress Callback --- + onProgress := func(bytesWritten int64) { + if trackProgress != nil { + trackProgress(bytesWritten) + } + } + + // --- Body Size Limit --- + maxBodySize := h.config.MaxBodySize + if maxBodySize <= 0 { + maxBodySize = 100 * 1024 * 1024 * 1024 // 100GB default + } + bodyReader := http.MaxBytesReader(w, r.Body, maxBodySize) + defer r.Body.Close() + + var modified, accessed *string + if fileInfo.Dto.Metadata != nil { + modified = fileInfo.Dto.Metadata.Modified + accessed = fileInfo.Dto.Metadata.Accessed + } + + // --- Text/Clipboard Handling --- + // When the incoming transfer is plain text and clipboard is not disabled, + // try to copy the content directly to the system clipboard instead of writing + // to disk. On failure (headless / no display server) fall through to the + // normal file-save path so the content is never lost. + if strings.HasPrefix(fileInfo.Dto.FileType, "text/plain") && !h.config.NoClipboard { + limited := io.LimitReader(bodyReader, maxTextSize+1) + textBytes, readErr := io.ReadAll(limited) + + if readErr != nil { + h.logger.Errorf("Error reading text body for clipboard (file %s): %v", fileInfo.Dto.FileName, readErr) + httputil.RespondError(w, http.StatusInternalServerError, "Failed to read text content") + return + } + + text := string(textBytes) + + if int64(len(textBytes)) > maxTextSize { + // Text is too large for clipboard; save to file instead. + h.logger.Warnf("Text transfer too large for clipboard (%d bytes), saving to file", len(textBytes)) + } else if clipErr := clipboard.Write(text); clipErr == nil { + // Successfully copied to clipboard. + preview := text + if len(preview) > 80 { + preview = preview[:80] + "…" + } + h.logger.Infof("Copied text to clipboard from %s: %q", fileInfo.Dto.FileName, preview) + + // Mark the progress bar as completed since no file write occurs + onProgress(fileInfo.Dto.Size) + + h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) + h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, "", int64(len(textBytes)), fileInfo.Dto.FileType, history.StatusClipboard) + h.runExecHook("", rawFileName, session.Sender.Alias, session.Sender.IP, int64(len(textBytes))) + w.WriteHeader(http.StatusOK) + return + } else { + // Clipboard unavailable — fall back to file. + h.logger.Warnf("Clipboard unavailable (%v), saving text as file instead", clipErr) + } + + // Fall-back: save the full stream as a file. + h.saveTextAsFile(session, reqSessionId, reqFileId, rawFileName, bodyReader, textBytes, modified, accessed, onProgress) + return + } + + err := storage.SaveStreamToFileWithMetadata(bodyReader, destinationPath, fileInfo.Dto.Size, modified, accessed, fileInfo.Dto.SHA256, onProgress, h.logger) + + if err != nil { + h.logger.Errorf("Error saving file %s (ID: %s): %v", fileInfo.Dto.FileName, reqFileId, err) + h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, fileInfo.Dto.Size, fileInfo.Dto.FileType, history.StatusFailed) + httputil.RespondError(w, http.StatusInternalServerError, "Failed to save file") + return + } + + // --- Success --- + h.logger.Infof("Finished saving file: %s (ID: %s)", fileInfo.Dto.FileName, reqFileId) + + h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) + + h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, fileInfo.Dto.Size, fileInfo.Dto.FileType, history.StatusReceived) + h.runExecHook(destinationPath, rawFileName, session.Sender.Alias, session.Sender.IP, fileInfo.Dto.Size) + + w.WriteHeader(http.StatusOK) +} + +// saveTextAsFile saves text content as a file when clipboard is unavailable or text is too large. +func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, reqSessionId, reqFileId, rawFileName string, bodyReader io.Reader, textBytes []byte, modified, accessed *string, onProgress func(int64)) { + var combinedReader io.Reader + if int64(len(textBytes)) > maxTextSize { + combinedReader = io.MultiReader(bytes.NewReader(textBytes), bodyReader) + } else { + combinedReader = bytes.NewReader(textBytes) + } + destinationPath := storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName) + cleanPath := filepath.Clean(destinationPath) + if !strings.HasPrefix(cleanPath, filepath.Clean(h.config.DownloadDir)+string(filepath.Separator)) && + cleanPath != filepath.Clean(h.config.DownloadDir) { + h.logger.Errorf("Path traversal attempt detected in text fallback: %s", rawFileName) + return + } + savErr := storage.SaveStreamToFileWithMetadata( + combinedReader, destinationPath, int64(len(textBytes)), modified, accessed, nil, onProgress, h.logger, + ) + if savErr != nil { + h.logger.Errorf("Error saving text file %s: %v", rawFileName, savErr) + h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusFailed) + return + } + h.logger.Infof("Saved text as file: %s", destinationPath) + h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) + h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusReceived) + h.runExecHook(destinationPath, rawFileName, session.Sender.Alias, session.Sender.IP, int64(len(textBytes))) +} From 582c35d69e8aad4798c2ca45926de337e8b68a1c Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:59:50 +0300 Subject: [PATCH 45/45] docs: add v0.6.0 changelog entry --- CHANGELOG.md | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e5852..116cc05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,115 @@ All notable changes to this project are documented in this file. +## v0.6.0 - 2026-06-24 + +### Highlights +- **Protocol audit**: full spec compliance pass — `ProtocolVersion` 2.1→2.0, session blocking (409), `POST /register`, constant-time PIN, correct fingerprint selection, DTO field cleanup, `Port`/`Protocol` in InfoDto, and more +- **Modularisation**: 6 files exceeding 300 LOC split into 19 single-responsibility units for maintainability +- **FreeBSD support**: rc.d init script and clipboard integration (`clipboard_unix.go` with `linux||freebsd` build tag) +- **`--no-color` flag** and automatic `NO_COLOR` env var detection in logging +- **Direct send & CIDR scan**: `localgo send --ip
` and `localgo scan --range ` flags +- **TUI file picker**: `localgo share` now opens an interactive file picker via `huh.FilePicker` +- **Gateway-based subnet prioritization**: smarter LAN discovery and scanning +- **GitHub Pages docs site** and online one-liner installer (`get-localgo.sh`) +- **Scratch Docker image hardened**: CMD args fixed, env vars set for writable peer cache + +### Added +- `--no-color`/`--no-colour` global flag, `NO_COLOR` env support (`pkg/logging`) +- FreeBSD rc.d init script for `localgo serve` as a service +- FreeBSD clipboard support via `clipboard_unix.go` (`linux || freebsd`) +- `send --ip
` flag for direct IP-based send (skips discovery) +- `scan --range ` flag for CIDR-based subnet scanning +- `ParseCIDRRange()` exported from `pkg/network/interfaces.go` +- `SendToDevice()` exported from `pkg/send/send.go` for programmatic use +- Gateway-based LAN subnet prioritization for scan and send +- Interactive TUI file picker in `share` command (extracted shared picker to `pkg/cli`) +- GitHub Pages docs site (`gh-pages` branch) and online installer +- `XDG_CACHE_HOME` env var for writable peer cache in scratch Docker +- `LOCALSEND_AUTO_ACCEPT=true` env var for scratch Docker image +- Homebrew cask support via goreleaser `homebrew_casks` + +### Fixed (Protocol Audit) +- `ProtocolVersion` correctly set to `"2.0"` (was `"2.1"`) to match the LocalSend spec +- Session blocking: return 409 Conflict for concurrent sessions on same device +- Validate `?sessionId` in `PrepareDownloadHandler` +- Use `POST /register` instead of deprecated `GET /info` for HTTP subnet scan +- Constant-time PIN comparison in `DownloadHandler` +- Correct fingerprint selection in HTTP mode (random string, not certificate hash) +- Add `Port`/`Protocol` to prepare-upload `InfoDto` per spec section 4.1 +- Use valid `deviceType "headless"` in private mode +- Remove spec-noncompliant extra fields from DTO structs +- Return no body on upload/cancel responses +- Force HTTP for `share` command (browser download API compatibility) +- Verify TLS certificate fingerprint during file transfer (MitM prevention) + +### Fixed (Other) +- Case-insensitive TLS fingerprint comparison +- Remove duplicate `-p` shorthand in `devices` command +- Clipboard prompt removed from `send`; filepicker is the default TUI fallback +- HTTP subnet scan fallback when multicast returns 0 devices +- Filter local machine out of HTTP scan results +- Send multicast response via multicast address instead of unicast +- Check `xdg-open` availability before opening download directory +- Scratch Docker: CMD args pass-through (no double `"localgo"`), `LOCALSEND_DOWNLOAD_DIR` and `LOCALSEND_SECURITY_DIR` env vars +- `DiscoverDevices` private mode bypass in `cmd/send.go` +- Device mutex for `LastSeen`/`Available`, `ReceiveService` ticker goroutine leak +- Config set parsing, scan/discover timeouts, share port order, CIDR range, RNG fallback +- PIN constant-time compare, server timeouts, private mode DTO bypass, JPEG bounds strip +- Progress bar scrollback erasure fix, bounds-safe `FormatBytes` (no panic on >EB sizes) +- Storage: atomic file writes via `.tmp` rename pattern; Windows: lazy DLL loading (`NewLazyDLL`) + +### Refactored +- 6 files exceeding 300 LOC split into 19 smaller single-responsibility units +- Shared TUI file picker extracted to `pkg/cli` +- Code quality: `SortFunc`, mutex-safe anonymize, `saveTextAsFile`, interface extraction, tests + +### Commits (v0.5.10..v0.6.0) +- `814b5fd` refactor: split 6 large files into 19 single-responsibility units +- `0348ddb` chore: stable release prep — bugs, atomic writes, safety +- `b43e423` feat: add GitHub Pages docs site and online installer +- `16da01b` fix: stability fixes and enhancements +- `51de7a2` fix: remove duplicate -p shorthand in devices command +- `53ffe3d` feat(share): add TUI file picker, extract shared picker to pkg/cli +- `3d9c9bb` fix: bug fix +- `c0edea8` fix: case-insensitive TLS fingerprint comparison +- `0f2c8ce` chore: final state after protocol audit fixes +- `68d35a9` fix: improve TLS error diag, always prompt device picker, silence usage on errors +- `d1af3c1` fix(protocol): force HTTP for share command (browser download API) +- `221bfda` fix(security): verify TLS certificate fingerprint during file transfer +- `52f39a8` fix(protocol): add port/protocol to prepare-upload info block +- `4825c46` refactor(dto): remove spec-noncompliant extra fields from DTO structs +- `0c4ea80` fix(protocol): use valid deviceType 'headless' in private mode, return no body on upload/cancel +- `fd65357` fix(protocol): validate ?sessionId in PrepareDownloadHandler +- `261b904` fix(protocol): implement session blocking, return 409 for concurrent sessions +- `beb3629` fix(discovery): use POST /register instead of deprecated GET /info for HTTP subnet scan +- `a08245a` fix(security): use constant-time PIN comparison in DownloadHandler +- `01be941` fix(protocol): select correct fingerprint in HTTP mode (random string, not cert hash) +- `c5b3a8d` fix(protocol): change ProtocolVersion from '2.1' to '2.0' to match spec +- `2f47675` fix(send): remove interactive clipboard prompt, filepicker is the default TUI fallback +- `cf37d46` fix(discover): fall back to HTTP subnet scan when multicast returns nothing +- `de481d0` fix(scan): filter local machine out of HTTP scan results +- `8d35b6c` fix(discovery): send multicast response via multicast addr instead of unicast back +- `32a628d` feat(network): add gateway-based LAN subnet prioritization for scan and send +- `47f61e2` fix: check xdg-open availability before opening download directory +- `3599891` feat(freebsd): add rc.d init script for localgo service +- `5f13a84` feat(freebsd): enable clipboard support via clipboard_unix.go (linux||freebsd) +- `7aaf291` feat(cli): add --no-color flag, respect NO_COLOR env in logging Init +- `97a0c4a` docs(help): add completion cmd, missing flags for serve/share/send, --private/--config options +- `138952b` fix(help): correct discover --timeout default from 5 to 10 +- `8bfafe2` fix(security): bypass DiscoverDevices private mode in cmd/send.go +- `413bcd1` refactor(code quality): SortFunc, mutex-safe anonymize, saveTextAsFile, interfaces, tests +- `ad832f9` fix(concurrency): Device mutex for LastSeen/Available, ReceiveService ticker goroutine leak +- `64be12d` fix(logic): config set parsing, scan/discover timeouts, share port order, CIDR range, RNG fallback +- `9144f42` fix(security): PIN constant-time compare, server timeouts, private mode DTO bypass, strip JPEG bounds +- `2a8a00b` fix(scratch): add XDG_CACHE_HOME so peer cache is writable +- `f6ed6a5` fix(scratch): add LOCALSEND_AUTO_ACCEPT=true env var +- `b013c88` fix: create discovery DTOs after server binds port +- `37be6e8` fix(scratch): set LOCALSEND_DOWNLOAD_DIR and LOCALSEND_SECURITY_DIR env vars +- `c01ef58` fix: docker-start passes CMD args correctly (no double localgo) +- `be29c69` feat: add send --ip, scan --range flags, ParseCIDRRange, export SendToDevice +- `6f8a9cc` feat: add private mode, progress bar fixes, metadata stripping, and core improvements + ## v0.4.0 - 2026-05-11 ### Highlights