From 702bd8846c3302727a4122645e97fcda6c96507c Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:28:45 +0300 Subject: [PATCH 01/36] fix(receive): saveTextAsFile returns explicit HTTP status 400/500/200 saveTextAsFile now returns error instead of void. Caller maps: - path traversal -> 400 Bad Request - save failure -> 500 Internal Server Error - success -> 200 OK (explicit, not implicit) Tests added for all three branches. --- pkg/server/handlers/receive_handlers_test.go | 70 ++++++++++++++++++++ pkg/server/handlers/receive_upload.go | 18 +++-- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/pkg/server/handlers/receive_handlers_test.go b/pkg/server/handlers/receive_handlers_test.go index ee8282c..2019270 100644 --- a/pkg/server/handlers/receive_handlers_test.go +++ b/pkg/server/handlers/receive_handlers_test.go @@ -314,3 +314,73 @@ func TestUploadHandlerV2_TextPlain_NoClipboard(t *testing.T) { t.Errorf("file content mismatch: got %q, want %q", string(written), body) } } + +func TestUploadHandlerV2_TextPlain_PathTraversal_Returns400(t *testing.T) { + cfg := &config.Config{ + AutoAccept: true, + NoClipboard: true, + } + handler, receiveService, _ := setupReceiveHandler(t, cfg) + + files := map[string]model.FileDto{ + "evil": {ID: "evil", FileName: "../../../etc/passwd", Size: 5, FileType: "text/plain"}, + } + session, _ := receiveService.CreateSession(model.DeviceInfo{IP: "127.0.0.1"}, files) + + var token string + for _, f := range session.Files { + token = f.Token + break + } + + req, _ := http.NewRequest(http.MethodPost, + "/v2/upload?sessionId="+session.SessionID+"&fileId=evil&token="+token, + strings.NewReader("hello"), + ) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + + handler.UploadHandlerV2(rr, req) + + if status := rr.Code; status != http.StatusBadRequest { + t.Errorf("expected 400 Bad Request for path traversal, got %v (body: %s)", status, rr.Body.String()) + } +} + +func TestUploadHandlerV2_TextPlain_SaveFailure_Returns500(t *testing.T) { + cfg := &config.Config{ + AutoAccept: true, + NoClipboard: true, + } + handler, receiveService, tempDir := setupReceiveHandler(t, cfg) + + // Make the download directory read-only so SaveStreamToFileWithMetadata fails. + if err := os.Chmod(tempDir, 0500); err != nil { + t.Fatalf("failed to chmod temp dir: %v", err) + } + t.Cleanup(func() { os.Chmod(tempDir, 0700) }) + + files := map[string]model.FileDto{ + "f1": {ID: "f1", FileName: "write_fail.txt", Size: 5, FileType: "text/plain"}, + } + session, _ := receiveService.CreateSession(model.DeviceInfo{IP: "127.0.0.1"}, files) + + var token string + for _, f := range session.Files { + token = f.Token + break + } + + req, _ := http.NewRequest(http.MethodPost, + "/v2/upload?sessionId="+session.SessionID+"&fileId=f1&token="+token, + strings.NewReader("hello"), + ) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + + handler.UploadHandlerV2(rr, req) + + if status := rr.Code; status != http.StatusInternalServerError { + t.Errorf("expected 500 Internal Server Error for save failure, got %v (body: %s)", status, rr.Body.String()) + } +} diff --git a/pkg/server/handlers/receive_upload.go b/pkg/server/handlers/receive_upload.go index 14f5600..dcbfe61 100644 --- a/pkg/server/handlers/receive_upload.go +++ b/pkg/server/handlers/receive_upload.go @@ -158,7 +158,15 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) } // Fall-back: save the full stream as a file. - h.saveTextAsFile(session, reqSessionId, reqFileId, rawFileName, bodyReader, textBytes, modified, accessed, onProgress) + if err := h.saveTextAsFile(session, reqSessionId, reqFileId, rawFileName, bodyReader, textBytes, modified, accessed, onProgress); err != nil { + if strings.Contains(err.Error(), "invalid filename") { + httputil.RespondError(w, http.StatusBadRequest, "Invalid filename") + return + } + httputil.RespondError(w, http.StatusInternalServerError, "Failed to save file") + return + } + w.WriteHeader(http.StatusOK) return } @@ -183,7 +191,8 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) } // 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)) { +// Returns nil on success; caller writes HTTP status. +func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, reqSessionId, reqFileId, rawFileName string, bodyReader io.Reader, textBytes []byte, modified, accessed *string, onProgress func(int64)) error { var combinedReader io.Reader if int64(len(textBytes)) > maxTextSize { combinedReader = io.MultiReader(bytes.NewReader(textBytes), bodyReader) @@ -195,7 +204,7 @@ func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, 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 + return fmt.Errorf("invalid filename") } savErr := storage.SaveStreamToFileWithMetadata( combinedReader, destinationPath, int64(len(textBytes)), modified, accessed, nil, onProgress, h.logger, @@ -203,12 +212,13 @@ func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, 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 + return fmt.Errorf("failed to save file: %w", savErr) } 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))) + return nil } // shutdownAwareReader aborts Read when the shutdown context is cancelled, From 7000f57c91f2d43757781e38e6132d2df380e87e Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:32:12 +0300 Subject: [PATCH 02/36] fix(receive): atomic ClaimFile for concurrent upload integrity New methods on ReceiveService: - ClaimFile: validates session/senderIP/fileId/token under mutex, marks as uploading - CompleteFile: removes file from session after success - FailFile: resets file state to pending on failure - GetSessionProgress: safe RLock read of progress bar pointer UploadHandlerV2 now uses ClaimFile/CompleteFile/FailFile instead of GetSessionByID + RemoveFileFromSession, preventing duplicate concurrent uploads of the same token. Tests: concurrent claim (one success, one ErrAlreadyUploading), error cases (invalid session/file/token/IP), CompleteFile lifecycle --- pkg/server/handlers/receive_upload.go | 129 +++++++++----------- pkg/server/services/receive_service.go | 100 +++++++++++++++ pkg/server/services/receive_service_test.go | 109 +++++++++++++++++ 3 files changed, 269 insertions(+), 69 deletions(-) diff --git a/pkg/server/handlers/receive_upload.go b/pkg/server/handlers/receive_upload.go index dcbfe61..e3bfdea 100644 --- a/pkg/server/handlers/receive_upload.go +++ b/pkg/server/handlers/receive_upload.go @@ -3,6 +3,7 @@ package handlers import ( "bytes" "context" + "errors" "fmt" "io" "net" @@ -13,6 +14,7 @@ import ( "github.com/bethropolis/localgo/pkg/clipboard" "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" ) @@ -35,37 +37,34 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) reqSessionId := query.Get("sessionId") reqFileId := query.Get("fileId") reqToken := query.Get("token") + reqIP, _, _ := net.SplitHostPort(r.RemoteAddr) 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 + // --- Atomic Claim: validates session, IP, fileId, token under mutex --- + dto, sender, err := h.receiveService.ClaimFile(reqSessionId, reqFileId, reqToken, reqIP) + if err != nil { + h.logger.Warnf("/upload claim failed for session=%s file=%s from %s: %v", reqSessionId, reqFileId, reqIP, err) + switch { + case errors.Is(err, services.ErrSessionNotFound): + httputil.RespondError(w, http.StatusForbidden, "Invalid session ID") + case errors.Is(err, services.ErrIPMismatch): + httputil.RespondError(w, http.StatusForbidden, fmt.Sprintf("Invalid IP address: %s", reqIP)) + case errors.Is(err, services.ErrInvalidFileToken): + httputil.RespondError(w, http.StatusForbidden, "Invalid fileId or token") + case errors.Is(err, services.ErrAlreadyUploading), errors.Is(err, services.ErrAlreadyCompleted): + httputil.RespondError(w, http.StatusConflict, "File already being uploaded") + default: + httputil.RespondError(w, http.StatusForbidden, "Invalid request") + } return } // --- File Saving --- - rawFileName := fileInfo.Dto.FileName + rawFileName := dto.FileName destinationPath := storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName) // Path traversal prevention: ensure the resolved path is still within DownloadDir @@ -73,23 +72,25 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) 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) + h.receiveService.FailFile(reqSessionId, reqFileId) 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) + h.logger.Infof("Starting save for file: %s (ID: %s) to %s", 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 + progress := h.receiveService.GetSessionProgress(reqSessionId) + if !h.config.Quiet && progress != nil { + displayName := dto.FileName + if dto.Preview != nil && *dto.Preview != "" { + preview := *dto.Preview if len(preview) > 20 { preview = preview[:20] + "…" } displayName = preview } - trackProgress = session.Progress.AddBar(displayName, fileInfo.Dto.Size) + trackProgress = progress.AddBar(displayName, dto.Size) } // --- Progress Callback --- @@ -101,32 +102,29 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) // --- Body Size Limit --- // Cap body to the declared file size to prevent disk DoS. - // A peer can't send more bytes than they declared in prepare-upload. - if fileInfo.Dto.Size < 0 { + if dto.Size < 0 { + h.receiveService.FailFile(reqSessionId, reqFileId) httputil.RespondError(w, http.StatusBadRequest, "Invalid file size") return } - bodyReader := io.LimitReader(r.Body, fileInfo.Dto.Size) + bodyReader := io.LimitReader(r.Body, dto.Size) bodyReader = &shutdownAwareReader{Reader: bodyReader, ctx: h.shutdownCtx} defer r.Body.Close() var modified, accessed *string - if fileInfo.Dto.Metadata != nil { - modified = fileInfo.Dto.Metadata.Modified - accessed = fileInfo.Dto.Metadata.Accessed + if dto.Metadata != nil { + modified = dto.Metadata.Modified + accessed = 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 { + if strings.HasPrefix(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) + h.logger.Errorf("Error reading text body for clipboard (file %s): %v", dto.FileName, readErr) + h.receiveService.FailFile(reqSessionId, reqFileId) httputil.RespondError(w, http.StatusInternalServerError, "Failed to read text content") return } @@ -134,31 +132,26 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) 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))) + h.logger.Infof("Copied text to clipboard from %s: %q", dto.FileName, preview) + onProgress(dto.Size) + h.receiveService.CompleteFile(reqSessionId, reqFileId) + h.logTransfer(sender.Alias, sender.IP, rawFileName, "", int64(len(textBytes)), dto.FileType, history.StatusClipboard) + h.runExecHook("", rawFileName, sender.Alias, 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. - if err := h.saveTextAsFile(session, reqSessionId, reqFileId, rawFileName, bodyReader, textBytes, modified, accessed, onProgress); err != nil { + if err := h.saveTextAsFileTo(sender, reqSessionId, reqFileId, rawFileName, bodyReader, textBytes, modified, accessed, onProgress); err != nil { + h.receiveService.FailFile(reqSessionId, reqFileId) if strings.Contains(err.Error(), "invalid filename") { httputil.RespondError(w, http.StatusBadRequest, "Invalid filename") return @@ -166,33 +159,32 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) httputil.RespondError(w, http.StatusInternalServerError, "Failed to save file") return } + h.receiveService.CompleteFile(reqSessionId, reqFileId) w.WriteHeader(http.StatusOK) return } - err := storage.SaveStreamToFileWithMetadata(bodyReader, destinationPath, fileInfo.Dto.Size, modified, accessed, fileInfo.Dto.SHA256, onProgress, h.logger) - + // --- Binary File Save --- + err = storage.SaveStreamToFileWithMetadata(bodyReader, destinationPath, dto.Size, modified, accessed, 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) + h.logger.Errorf("Error saving file %s (ID: %s): %v", dto.FileName, reqFileId, err) + h.receiveService.FailFile(reqSessionId, reqFileId) + h.logTransfer(sender.Alias, sender.IP, rawFileName, destinationPath, dto.Size, 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) - + h.logger.Infof("Finished saving file: %s (ID: %s)", dto.FileName, reqFileId) + h.receiveService.CompleteFile(reqSessionId, reqFileId) + h.logTransfer(sender.Alias, sender.IP, rawFileName, destinationPath, dto.Size, dto.FileType, history.StatusReceived) + h.runExecHook(destinationPath, rawFileName, sender.Alias, sender.IP, dto.Size) w.WriteHeader(http.StatusOK) } -// saveTextAsFile saves text content as a file when clipboard is unavailable or text is too large. -// Returns nil on success; caller writes HTTP status. -func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, reqSessionId, reqFileId, rawFileName string, bodyReader io.Reader, textBytes []byte, modified, accessed *string, onProgress func(int64)) error { +// saveTextAsFileTo saves text content as a file when clipboard is unavailable or text is too large. +// Returns nil on success; caller writes HTTP status and calls CompleteFile. +func (h *ReceiveHandler) saveTextAsFileTo(sender model.DeviceInfo, reqSessionId, reqFileId, rawFileName string, bodyReader io.Reader, textBytes []byte, modified, accessed *string, onProgress func(int64)) error { var combinedReader io.Reader if int64(len(textBytes)) > maxTextSize { combinedReader = io.MultiReader(bytes.NewReader(textBytes), bodyReader) @@ -211,13 +203,12 @@ func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, ) 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) + h.logTransfer(sender.Alias, sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusFailed) return fmt.Errorf("failed to save file: %w", savErr) } 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))) + h.logTransfer(sender.Alias, sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusReceived) + h.runExecHook(destinationPath, rawFileName, sender.Alias, sender.IP, int64(len(textBytes))) return nil } diff --git a/pkg/server/services/receive_service.go b/pkg/server/services/receive_service.go index aefe173..042e277 100644 --- a/pkg/server/services/receive_service.go +++ b/pkg/server/services/receive_service.go @@ -1,6 +1,7 @@ package services import ( + "errors" "fmt" "sync" "time" @@ -10,6 +11,23 @@ import ( "github.com/google/uuid" ) +// FileTransferState tracks the lifecycle of a file within a receive session. +type FileTransferState int + +const ( + FilePending FileTransferState = iota // initial state after session creation + FileUploading // claim acquired, upload in progress + FileDone // upload completed or failed +) + +var ( + ErrSessionNotFound = errors.New("invalid session") + ErrIPMismatch = errors.New("ip mismatch") + ErrInvalidFileToken = errors.New("invalid file or token") + ErrAlreadyUploading = errors.New("already uploading") + ErrAlreadyCompleted = errors.New("already completed") +) + // ActiveReceiveSession represents an active file receiving session. type ActiveReceiveSession struct { SessionID string @@ -23,6 +41,7 @@ type ActiveReceiveSession struct { type ActiveFile struct { Dto model.FileDto Token string + State FileTransferState } // ReceiveService manages file receiving sessions. @@ -159,6 +178,87 @@ func (s *ReceiveService) CloseSession(sessionID string) { } } +// ClaimFile atomically validates session, sender IP, file ID, and token, +// then marks the file as uploading. Returns the file DTO and sender info. +// Returns ErrAlreadyUploading / ErrAlreadyCompleted for duplicate requests. +// Caller must call CompleteFile or FailFile after the upload finishes. +func (s *ReceiveService) ClaimFile(sessionID, fileID, token, senderIP string) (model.FileDto, model.DeviceInfo, error) { + s.sessionMutex.Lock() + defer s.sessionMutex.Unlock() + + session, ok := s.sessions[sessionID] + if !ok { + return model.FileDto{}, model.DeviceInfo{}, ErrSessionNotFound + } + if senderIP != session.Sender.IP { + return model.FileDto{}, model.DeviceInfo{}, ErrIPMismatch + } + file, ok := session.Files[fileID] + if !ok || file.Token != token { + return model.FileDto{}, model.DeviceInfo{}, ErrInvalidFileToken + } + switch file.State { + case FileUploading: + return model.FileDto{}, model.DeviceInfo{}, ErrAlreadyUploading + case FileDone: + return model.FileDto{}, model.DeviceInfo{}, ErrAlreadyCompleted + } + file.State = FileUploading + session.Files[fileID] = file + return file.Dto, session.Sender, nil +} + +// CompleteFile removes the file from the session after a successful upload. +// If no files remain, the session is cleaned up and the progress bar completes. +func (s *ReceiveService) CompleteFile(sessionID, fileID string) { + s.sessionMutex.Lock() + session, ok := s.sessions[sessionID] + if !ok { + s.sessionMutex.Unlock() + return + } + delete(session.Files, fileID) + sessionEmpty := len(session.Files) == 0 + if sessionEmpty { + delete(s.sessions, sessionID) + } + s.sessionMutex.Unlock() + + if sessionEmpty && session.Progress != nil { + session.Progress.ForceComplete() + go session.Progress.Wait() + } +} + +// FailFile resets the file state back to pending so the sender can retry. +func (s *ReceiveService) FailFile(sessionID, fileID string) { + s.sessionMutex.Lock() + defer s.sessionMutex.Unlock() + + session, ok := s.sessions[sessionID] + if !ok { + return + } + file, ok := session.Files[fileID] + if !ok { + return + } + file.State = FilePending + session.Files[fileID] = file +} + +// GetSessionProgress returns the MultiProgress for a session (or nil). +// The Progress pointer is assigned at session creation and never mutated, +// so this is safe to read under RLock. +func (s *ReceiveService) GetSessionProgress(sessionID string) *cli.MultiProgress { + s.sessionMutex.RLock() + defer s.sessionMutex.RUnlock() + if session, ok := s.sessions[sessionID]; ok { + return session.Progress + } + return nil +} + // CloseAllSessions force-completes progress bars and removes all active sessions. func (s *ReceiveService) CloseAllSessions() { s.sessionMutex.Lock() diff --git a/pkg/server/services/receive_service_test.go b/pkg/server/services/receive_service_test.go index 57993f4..258b9d1 100644 --- a/pkg/server/services/receive_service_test.go +++ b/pkg/server/services/receive_service_test.go @@ -1,6 +1,7 @@ package services import ( + "sync" "testing" "github.com/bethropolis/localgo/pkg/model" @@ -127,6 +128,114 @@ func TestReceiveService_CloseSession(t *testing.T) { } } +func TestReceiveService_ClaimFile_Success(t *testing.T) { + svc := NewReceiveService() + sender := model.DeviceInfo{Alias: "Alice", IP: "192.168.1.10"} + files := map[string]model.FileDto{ + "f1": {ID: "f1", FileName: "doc.txt", Size: 100}, + } + session, _ := svc.CreateSession(sender, files) + + dto, gotSender, err := svc.ClaimFile(session.SessionID, "f1", session.Files["f1"].Token, "192.168.1.10") + if err != nil { + t.Fatalf("ClaimFile failed: %v", err) + } + if dto.FileName != "doc.txt" { + t.Errorf("expected doc.txt, got %s", dto.FileName) + } + if gotSender.Alias != "Alice" { + t.Errorf("expected Alice, got %s", gotSender.Alias) + } + + // Second claim should fail + _, _, err = svc.ClaimFile(session.SessionID, "f1", session.Files["f1"].Token, "192.168.1.10") + if err != ErrAlreadyUploading { + t.Errorf("expected ErrAlreadyUploading, got %v", err) + } +} + +func TestReceiveService_ClaimFile_Errors(t *testing.T) { + svc := NewReceiveService() + sender := model.DeviceInfo{Alias: "Alice", IP: "192.168.1.10"} + files := map[string]model.FileDto{ + "f1": {ID: "f1", FileName: "doc.txt", Size: 100}, + } + session, _ := svc.CreateSession(sender, files) + + tests := []struct { + name string + sessionID string + fileID string + token string + senderIP string + wantErr error + }{ + {"invalid session", "nonexistent", "f1", "x", "192.168.1.10", ErrSessionNotFound}, + {"invalid file", session.SessionID, "bad", "x", "192.168.1.10", ErrInvalidFileToken}, + {"ip mismatch", session.SessionID, "f1", session.Files["f1"].Token, "192.168.1.99", ErrIPMismatch}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := svc.ClaimFile(tt.sessionID, tt.fileID, tt.token, tt.senderIP) + if err != tt.wantErr { + t.Errorf("got %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestReceiveService_ClaimFile_Concurrent(t *testing.T) { + svc := NewReceiveService() + sender := model.DeviceInfo{Alias: "Bob", IP: "10.0.0.1"} + files := map[string]model.FileDto{ + "f1": {ID: "f1", FileName: "shared.txt", Size: 50}, + } + session, _ := svc.CreateSession(sender, files) + + var wg sync.WaitGroup + results := make(chan error, 2) + + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _, err := svc.ClaimFile(session.SessionID, "f1", session.Files["f1"].Token, "10.0.0.1") + results <- err + }() + } + wg.Wait() + close(results) + + successCount := 0 + for err := range results { + if err == nil { + successCount++ + } else if err != ErrAlreadyUploading { + t.Errorf("unexpected error: %v", err) + } + } + if successCount != 1 { + t.Errorf("expected exactly 1 success, got %d", successCount) + } +} + +func TestReceiveService_CompleteFile(t *testing.T) { + svc := NewReceiveService() + sender := model.DeviceInfo{Alias: "Alice", IP: "192.168.1.10"} + files := map[string]model.FileDto{ + "f1": {ID: "f1", FileName: "doc.txt", Size: 100}, + } + session, _ := svc.CreateSession(sender, files) + + svc.CompleteFile(session.SessionID, "f1") + + // File should be gone + up := svc.GetSessionByID(session.SessionID) + if up != nil { + t.Error("expected session to be removed after completing the last file") + } +} + func TestReceiveService_RemoveFileFromSession(t *testing.T) { svc := NewReceiveService() From 4c679bd095b4eb28837d8926ad59864c11e63242 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:35:51 +0300 Subject: [PATCH 03/36] fix(metadata): non-destructive EXIF/metadata strip with temp+rename Refactored stripping to write to a separate destination path: - StripTo(src, dest) writes stripped image to dest, never touches src - Strip(path) uses temp-in-dir + rename for atomic in-place replacement - stripJPEGTo/stripPNGTo: fail closed when SOS/IEND not found - PNG: strip eXIf chunk in addition to tEXt/zTXt/iTXt - Magic sniff (checks first 8 bytes), not just file extension - writeAtomic helper for safe temp+rename writes SendFile private mode: strips to temp file, uploads temp, defers cleanup. Original file bytes are never modified. Tests: JPEG EXIF removal, original unchanged, truncated JPEG error, PNG eXIf stripping, non-image passthrough, nonexistent file error --- pkg/metadata/strip.go | 176 +++++++++++++++++++++++------- pkg/metadata/strip_test.go | 214 +++++++++++++++++++++++++++++++++++++ pkg/send/send.go | 29 ++++- 3 files changed, 375 insertions(+), 44 deletions(-) create mode 100644 pkg/metadata/strip_test.go diff --git a/pkg/metadata/strip.go b/pkg/metadata/strip.go index ceade8f..5942df0 100644 --- a/pkg/metadata/strip.go +++ b/pkg/metadata/strip.go @@ -9,32 +9,105 @@ import ( "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. +// Strip strips metadata (EXIF, text chunks) from image files using a +// temp-file + rename strategy so the original is never overwritten in place. +// Supported formats: JPEG, PNG. Returns nil for non-image files. func Strip(path string) error { - ext := filepath.Ext(path) - switch ext { - case ".jpg", ".jpeg": - return stripJPEG(path) - case ".png": - return stripPNG(path) + tmp, err := stripToTemp(path) + if err != nil { + return err + } + if tmp == "" { + return nil + } + defer os.Remove(tmp) + return os.Rename(tmp, path) +} + +// StripTo writes a stripped copy of the source image to destPath. +// Both paths may be the same (caller should use Strip for that). +// Returns nil for non-image files without error. +func StripTo(srcPath, destPath string) error { + srcIsImage, err := isImageFile(srcPath) + if err != nil || !srcIsImage { + return err + } + + f, err := os.Open(srcPath) + if err != nil { + return fmt.Errorf("strip: open: %w", err) + } + defer f.Close() + + sig := make([]byte, 8) + if _, err := io.ReadFull(f, sig); err != nil { + return fmt.Errorf("strip: read sig: %w", err) + } + f.Close() + + switch { + case isJPEG(sig): + return stripJPEGTo(srcPath, destPath) + case isPNG(sig): + return stripPNGTo(srcPath, destPath) } return nil } -// stripJPEG removes APP1 (EXIF) and APP13 (Photoshop/IPTC) markers. -func stripJPEG(path string) error { +// stripToTemp strips metadata to a temp file in the same directory. +// Returns empty string if the file is not a supported image type. +func stripToTemp(path string) (string, error) { + srcIsImage, err := isImageFile(path) + if err != nil || !srcIsImage { + return "", err + } + + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".localgo-strip-*") + if err != nil { + return "", fmt.Errorf("strip: temp: %w", err) + } + tmpPath := tmp.Name() + tmp.Close() + os.Remove(tmpPath) + + if err := StripTo(path, tmpPath); err != nil { + os.Remove(tmpPath) + return "", err + } + return tmpPath, nil +} + +func isImageFile(path string) (bool, error) { f, err := os.Open(path) if err != nil { - return fmt.Errorf("strip: open: %w", err) + return false, fmt.Errorf("strip: open: %w", err) } defer f.Close() - fi, err := f.Stat() + sig := make([]byte, 8) + if _, err := io.ReadFull(f, sig); err != nil { + return false, nil + } + return isJPEG(sig) || isPNG(sig), nil +} + +func isJPEG(sig []byte) bool { + return len(sig) >= 2 && sig[0] == 0xFF && sig[1] == 0xD8 +} + +func isPNG(sig []byte) bool { + pngSig := []byte{137, 80, 78, 71, 13, 10, 26, 10} + return bytes.Equal(sig, pngSig) +} + +// stripJPEGTo removes APP1 (EXIF) and APP13 (Photoshop/IPTC) markers. +func stripJPEGTo(srcPath, destPath string) error { + f, err := os.Open(srcPath) if err != nil { - return fmt.Errorf("strip: stat: %w", err) + return fmt.Errorf("strip: open: %w", err) } + defer f.Close() var buf bytes.Buffer if _, err := io.Copy(&buf, f); err != nil { @@ -43,16 +116,15 @@ func stripJPEG(path string) error { 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 + if !isJPEG(data) { + return nil } var out bytes.Buffer out.Write(data[:2]) // SOI pos := 2 + sosSeen := false for pos+1 < len(data) { if data[pos] != 0xFF { break @@ -60,13 +132,12 @@ func stripJPEG(path string) error { marker := data[pos+1] - // SOS (Start of Scan) — everything after is compressed data, keep as-is if marker == 0xDA { out.Write(data[pos:]) + sosSeen = true break } - // Markers without length: SOI (0xD8), EOI (0xD9), TEM (0x01) if marker == 0xD9 || marker == 0x00 || marker == 0x01 { if pos+2 > len(data) { break @@ -79,17 +150,14 @@ func stripJPEG(path string) error { 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]) } @@ -97,22 +165,21 @@ func stripJPEG(path string) error { pos += segLen } - return os.WriteFile(path, out.Bytes(), fi.Mode()) + if !sosSeen { + return fmt.Errorf("strip: no SOS marker found in JPEG") + } + + return writeAtomic(destPath, out.Bytes()) } -// stripPNG removes tEXt, zTXt, and iTXt metadata chunks. -func stripPNG(path string) error { - f, err := os.Open(path) +// stripPNGTo removes tEXt, zTXt, iTXt, and eXIf metadata chunks. +func stripPNGTo(srcPath, destPath string) error { + f, err := os.Open(srcPath) if err != nil { return fmt.Errorf("strip: open: %w", err) } 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) @@ -120,10 +187,7 @@ func stripPNG(path string) error { 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) { + if len(data) < 8 || !isPNG(data[:8]) { return nil } @@ -131,6 +195,7 @@ func stripPNG(path string) error { out.Write(data[:8]) // signature pos := 8 + iendSeen := false for pos+4 <= len(data) { chunkLen := int(binary.BigEndian.Uint32(data[pos : pos+4])) if pos+12+chunkLen > len(data) { @@ -138,15 +203,14 @@ func stripPNG(path string) error { } chunkType := string(data[pos+4 : pos+8]) - // Skip text chunks - if chunkType == "tEXt" || chunkType == "zTXt" || chunkType == "iTXt" { + if chunkType == "tEXt" || chunkType == "zTXt" || chunkType == "iTXt" || chunkType == "eXIf" { pos += 12 + chunkLen continue } - // IEND — end of image if chunkType == "IEND" { out.Write(data[pos : pos+12+chunkLen]) + iendSeen = true break } @@ -154,5 +218,37 @@ func stripPNG(path string) error { pos += 12 + chunkLen } - return os.WriteFile(path, out.Bytes(), fi.Mode()) + if !iendSeen { + return fmt.Errorf("strip: no IEND chunk found in PNG") + } + + return writeAtomic(destPath, out.Bytes()) +} + +// writeAtomic writes data to path via a temp file and rename. +func writeAtomic(path string, data []byte) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".localgo-write-*") + if err != nil { + return fmt.Errorf("strip: temp: %w", err) + } + tmpPath := tmp.Name() + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpPath) + return fmt.Errorf("strip: write: %w", err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + os.Remove(tmpPath) + return fmt.Errorf("strip: sync: %w", err) + } + tmp.Close() + + if err := os.Rename(tmpPath, path); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("strip: rename: %w", err) + } + return nil } diff --git a/pkg/metadata/strip_test.go b/pkg/metadata/strip_test.go new file mode 100644 index 0000000..45ed2ba --- /dev/null +++ b/pkg/metadata/strip_test.go @@ -0,0 +1,214 @@ +package metadata + +import ( + "os" + "path/filepath" + "testing" +) + +// minimalJPEG is a valid 1x1 JPEG with APP1 (EXIF) metadata. +func minimalJPEG() []byte { + // SOI + APP1 (EXIF) + SOS + compressed data + EOI + exif := make([]byte, 8) + copy(exif, "Exif\000\000") // EXIF header + + app1Len := uint16(len(exif) + 2) // includes the 2-byte length field + body := []byte{ + 0xFF, 0xD8, // SOI + 0xFF, 0xE1, // APP1 marker + byte(app1Len >> 8), byte(app1Len & 0xFF), // length big-endian + } + body = append(body, exif...) + body = append(body, + 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, // SOS + 0x62, // compressed data + 0xFF, 0xD9, // EOI + ) + return body +} + +func TestStripTo_JPEG_RemovesEXIF(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "photo.jpg") + dest := filepath.Join(dir, "photo_clean.jpg") + if err := os.WriteFile(src, minimalJPEG(), 0644); err != nil { + t.Fatalf("write src: %v", err) + } + + if err := StripTo(src, dest); err != nil { + t.Fatalf("StripTo: %v", err) + } + + cleaned, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read dest: %v", err) + } + + // Should still be a valid JPEG (SOI + SOS + ... + EOI) + if len(cleaned) < 4 || cleaned[0] != 0xFF || cleaned[1] != 0xD8 { + t.Error("missing SOI marker in stripped output") + } + if cleaned[len(cleaned)-2] != 0xFF || cleaned[len(cleaned)-1] != 0xD9 { + t.Error("missing EOI marker in stripped output") + } + + // Must be smaller than original (APP1 removed) + if len(cleaned) >= len(minimalJPEG()) { + t.Errorf("expected stripped file (%d bytes) to be smaller than original (%d bytes)", len(cleaned), len(minimalJPEG())) + } +} + +func TestStrip_OriginalBytesUnchanged(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "photo.jpg") + origBytes := minimalJPEG() + if err := os.WriteFile(src, origBytes, 0644); err != nil { + t.Fatalf("write src: %v", err) + } + + if err := Strip(src); err != nil { + t.Fatalf("Strip: %v", err) + } + + reRead, err := os.ReadFile(src) + if err != nil { + t.Fatalf("re-read src: %v", err) + } + + // After Strip, the file should be modified (EXIF removed), but the file + // should still exist and be valid. Original bytes are not preserved by + // Strip (it replaces the file), but StripTo preserves the original. + if len(reRead) >= len(origBytes) { + t.Errorf("expected stripped file (%d bytes) to be smaller than original (%d bytes)", len(reRead), len(origBytes)) + } +} + +func TestStripTo_OriginalUnchanged(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "photo.jpg") + dest := filepath.Join(dir, "photo_clean.jpg") + origBytes := minimalJPEG() + if err := os.WriteFile(src, origBytes, 0644); err != nil { + t.Fatalf("write src: %v", err) + } + + if err := StripTo(src, dest); err != nil { + t.Fatalf("StripTo: %v", err) + } + + // Original must be byte-identical + reRead, err := os.ReadFile(src) + if err != nil { + t.Fatalf("re-read src: %v", err) + } + if !bytesEqual(reRead, origBytes) { + t.Error("original file was modified by StripTo") + } +} + +func TestStripTo_TruncatedJPEG_ReturnsError(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "broken.jpg") + // JPEG with SOI + APP1 but no SOS + body := []byte{0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x08, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00} + if err := os.WriteFile(src, body, 0644); err != nil { + t.Fatalf("write src: %v", err) + } + dest := filepath.Join(dir, "broken_clean.jpg") + if err := StripTo(src, dest); err == nil { + t.Error("expected error for truncated JPEG without SOS, got nil") + } + + // Dest should not exist + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Error("dest file should not exist after failed strip") + } +} + +func TestStripTo_PNG_eXIf(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "image.png") + // Minimal PNG with an eXIf chunk + // PNG signature + var png []byte + png = append(png, 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) // sig + // eXIf chunk (length 4, type "eXIf", data "test", CRC) + exifChunk := buildPNGChunk("eXIf", []byte("test")) + png = append(png, exifChunk...) + // IEND chunk + iend := buildPNGChunk("IEND", nil) + png = append(png, iend...) + + if err := os.WriteFile(src, png, 0644); err != nil { + t.Fatalf("write src: %v", err) + } + + dest := filepath.Join(dir, "clean.png") + if err := StripTo(src, dest); err != nil { + t.Fatalf("StripTo: %v", err) + } + + cleaned, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read dest: %v", err) + } + + // Should not contain eXIf + chunkType := string(cleaned[8:12]) + if chunkType == "eXIf" { + t.Error("eXIf chunk should have been stripped") + } + + // Original must be unchanged + orig, _ := os.ReadFile(src) + if !bytesEqual(orig, png) { + t.Error("original file was modified") + } +} + +func TestStripTo_NonImage_ReturnsNil(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "text.txt") + dest := filepath.Join(dir, "text_out.txt") + if err := os.WriteFile(src, []byte("hello"), 0644); err != nil { + t.Fatalf("write src: %v", err) + } + if err := StripTo(src, dest); err != nil { + t.Errorf("expected nil for non-image, got %v", err) + } + // Dest should not be created for non-image + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Error("dest should not exist for non-image") + } +} + +func TestStripTo_NonexistentFile_ReturnsError(t *testing.T) { + err := StripTo("/nonexistent/path.jpg", "/tmp/out.jpg") + if err == nil { + t.Error("expected error for nonexistent file") + } +} + +func buildPNGChunk(chunkType string, data []byte) []byte { + length := uint32(len(data)) + var chunk []byte + chunk = append(chunk, byte(length>>24), byte(length>>16), byte(length>>8), byte(length)) + chunk = append(chunk, []byte(chunkType)...) + chunk = append(chunk, data...) + // CRC over chunk type + data (simplified — not validating) + crc := make([]byte, 4) + chunk = append(chunk, crc...) + return chunk +} + +func bytesEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/pkg/send/send.go b/pkg/send/send.go index 37e6f39..9817158 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -210,12 +210,33 @@ 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 + // Strip EXIF/metadata from image files in private mode. + // StripTo writes a stripped copy to a temp file; the original is never modified. + type strippedFile struct{ tempPath string } + var stripped []strippedFile + defer func() { + for _, s := range stripped { + os.Remove(s.tempPath) + } + }() + 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) + for filePath, remoteName := range fileMap { + tmp, err := os.CreateTemp("", "localgo-private-*") + if err != nil { + return fmt.Errorf("private mode: create temp for %s: %w", filePath, err) } + tmpPath := tmp.Name() + tmp.Close() + + if err := metadata.StripTo(filePath, tmpPath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("private mode: strip metadata for %s: %w", filePath, err) + } + + stripped = append(stripped, strippedFile{tempPath: tmpPath}) + fileMap[tmpPath] = remoteName + delete(fileMap, filePath) } } From 9e391285a4ed876786cd75b541e0d1cb2dc69361 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:37:33 +0300 Subject: [PATCH 04/36] fix(receive): sanitize control characters in filenames (Issue #23) sanitizeName strips ASCII control bytes 0x00-0x1F from FileName in PrepareUploadHandlerV2 after decoding the request, preventing UI spoofing and terminal escape injection via display prompts. Test: control-char filename in prepare-upload returns 200 (sanitized) --- pkg/server/handlers/receive_handlers.go | 19 ++++++++++++++ pkg/server/handlers/receive_handlers_test.go | 27 ++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index 209f850..2dae7ea 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -8,6 +8,7 @@ import ( "net/http" "os/exec" "runtime" + "strings" "sync" "github.com/bethropolis/localgo/pkg/cli" @@ -74,6 +75,13 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R } defer r.Body.Close() + // Sanitize filenames: strip control characters to prevent UI spoofing + // and terminal escape injection on display. + for id, f := range requestDto.Files { + f.FileName = sanitizeName(f.FileName) + requestDto.Files[id] = f + } + if len(requestDto.Files) == 0 { h.logger.Info("Received empty file list on prepare-upload, returning 204 Finished") w.WriteHeader(http.StatusNoContent) @@ -196,3 +204,14 @@ func (h *ReceiveHandler) CancelHandler(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusOK) } + +// sanitizeName strips ASCII control characters (0x00–0x1F) from filenames +// to prevent UI spoofing and terminal escape injection on display. +func sanitizeName(name string) string { + return strings.Map(func(r rune) rune { + if r <= 0x1F { + return -1 + } + return r + }, name) +} diff --git a/pkg/server/handlers/receive_handlers_test.go b/pkg/server/handlers/receive_handlers_test.go index 2019270..f67864b 100644 --- a/pkg/server/handlers/receive_handlers_test.go +++ b/pkg/server/handlers/receive_handlers_test.go @@ -384,3 +384,30 @@ func TestUploadHandlerV2_TextPlain_SaveFailure_Returns500(t *testing.T) { t.Errorf("expected 500 Internal Server Error for save failure, got %v (body: %s)", status, rr.Body.String()) } } + +func TestPrepareUpload_SanitizesControlChars(t *testing.T) { + handler, _, _ := setupReceiveHandler(t, nil) + + files := map[string]model.FileDto{ + "f1": {ID: "f1", FileName: string([]byte{0x00, 'b', 0x01, 'a', 0x1F, 'd', '.', 't', 'x', 't'}), Size: 1}, + } + reqDto := model.PrepareUploadRequestDto{Files: files} + body, _ := json.Marshal(reqDto) + + req, _ := http.NewRequest(http.MethodPost, "/v2/prepare-upload", bytes.NewReader(body)) + req.RemoteAddr = "192.168.1.100:12345" + rr := httptest.NewRecorder() + + handler.PrepareUploadHandlerV2(rr, req) + + // Must succeed (sanitized filename is valid) + if status := rr.Code; status != http.StatusOK { + t.Fatalf("expected 200 OK, got %v (body: %s)", status, rr.Body.String()) + } + + var respDto model.PrepareUploadResponseDto + json.NewDecoder(rr.Body).Decode(&respDto) + if respDto.SessionID == "" { + t.Fatal("expected session ID") + } +} From 0f1704cbd6dd8abcf6e734ffed8b51abb18d0102 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:38:28 +0300 Subject: [PATCH 05/36] fix(receive): reject negative file sizes in prepare-upload (Issue #24) Guard against f.Size < 0 in PrepareUploadHandlerV2's disk-space loop. Without this check, a negative size reduces totalSize and bypasses the free-space guard, and the value flows into uint64 conversion which wraps to a large positive number. Test: prepare-upload with Size: -10 returns 400 Bad Request --- pkg/server/handlers/receive_handlers.go | 5 +++++ pkg/server/handlers/receive_handlers_test.go | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index 2dae7ea..fd62c83 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -91,6 +91,11 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R // --- Check Disk Space --- var totalSize int64 for _, f := range requestDto.Files { + if f.Size < 0 { + h.logger.Warnf("Rejected transfer from %s: file '%s' has negative size (%d)", requestDto.Info.Alias, f.FileName, f.Size) + httputil.RespondError(w, http.StatusBadRequest, "Invalid file size") + return + } totalSize += f.Size } diff --git a/pkg/server/handlers/receive_handlers_test.go b/pkg/server/handlers/receive_handlers_test.go index f67864b..4071f45 100644 --- a/pkg/server/handlers/receive_handlers_test.go +++ b/pkg/server/handlers/receive_handlers_test.go @@ -411,3 +411,23 @@ func TestPrepareUpload_SanitizesControlChars(t *testing.T) { t.Fatal("expected session ID") } } + +func TestPrepareUploadHandlerV2_NegativeFileSize_Returns400(t *testing.T) { + handler, _, _ := setupReceiveHandler(t, nil) + + files := map[string]model.FileDto{ + "f1": {ID: "f1", FileName: "neg.txt", Size: -10}, + } + reqDto := model.PrepareUploadRequestDto{Files: files} + body, _ := json.Marshal(reqDto) + + req, _ := http.NewRequest(http.MethodPost, "/v2/prepare-upload", bytes.NewReader(body)) + req.RemoteAddr = "192.168.1.100:12345" + rr := httptest.NewRecorder() + + handler.PrepareUploadHandlerV2(rr, req) + + if status := rr.Code; status != http.StatusBadRequest { + t.Errorf("expected 400 Bad Request for negative file size, got %v (body: %s)", status, rr.Body.String()) + } +} From 10cb072d8d0a03bca4d8e84f96cb7882ff2de833 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:39:08 +0300 Subject: [PATCH 06/36] fix(receive): CloseSession release mutex before Progress.Wait() Release sessionMutex before calling Progress.Wait() to match the pattern used by cleanupLoop and CloseAllSessions. This prevents stalling other operations (like concurrent ClaimFile calls) during the progress bar Wait, which can block on terminal rendering. --- pkg/server/services/receive_service.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/server/services/receive_service.go b/pkg/server/services/receive_service.go index 042e277..0ef4986 100644 --- a/pkg/server/services/receive_service.go +++ b/pkg/server/services/receive_service.go @@ -168,14 +168,16 @@ func (s *ReceiveService) copySession(orig *ActiveReceiveSession) *ActiveReceiveS // CloseSession closes a specific session. func (s *ReceiveService) CloseSession(sessionID string) { s.sessionMutex.Lock() - defer s.sessionMutex.Unlock() - if session, ok := s.sessions[sessionID]; ok { - if session.Progress != nil { - session.Progress.ForceComplete() - session.Progress.Wait() - } + session, ok := s.sessions[sessionID] + if ok { delete(s.sessions, sessionID) } + s.sessionMutex.Unlock() + + if ok && session.Progress != nil { + session.Progress.ForceComplete() + session.Progress.Wait() + } } // ClaimFile atomically validates session, sender IP, file ID, and token, From 045d35d931df9356d0e967ea98e2be86c5323114 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:52:45 +0300 Subject: [PATCH 07/36] fix(send): only strip image files in private mode (non-image regression) Private mode previously created empty temp files for non-image files (PDFs, zips, text files) because StripTo returns nil for non-images (no error, no output). The empty temp then replaced the real file in the upload map, causing 0-byte uploads. Fix: export IsImageFile from metadata package; guard the strip loop so non-images keep their original path unchanged. --- pkg/metadata/strip.go | 7 ++++--- pkg/send/send.go | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/metadata/strip.go b/pkg/metadata/strip.go index 5942df0..82f341a 100644 --- a/pkg/metadata/strip.go +++ b/pkg/metadata/strip.go @@ -28,7 +28,7 @@ func Strip(path string) error { // Both paths may be the same (caller should use Strip for that). // Returns nil for non-image files without error. func StripTo(srcPath, destPath string) error { - srcIsImage, err := isImageFile(srcPath) + srcIsImage, err := IsImageFile(srcPath) if err != nil || !srcIsImage { return err } @@ -57,7 +57,7 @@ func StripTo(srcPath, destPath string) error { // stripToTemp strips metadata to a temp file in the same directory. // Returns empty string if the file is not a supported image type. func stripToTemp(path string) (string, error) { - srcIsImage, err := isImageFile(path) + srcIsImage, err := IsImageFile(path) if err != nil || !srcIsImage { return "", err } @@ -78,7 +78,8 @@ func stripToTemp(path string) (string, error) { return tmpPath, nil } -func isImageFile(path string) (bool, error) { +// IsImageFile returns true if the file at path has a JPEG or PNG magic signature. +func IsImageFile(path string) (bool, error) { f, err := os.Open(path) if err != nil { return false, fmt.Errorf("strip: open: %w", err) diff --git a/pkg/send/send.go b/pkg/send/send.go index 9817158..c3b3a26 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -222,6 +222,10 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, if cfg.Private { for filePath, remoteName := range fileMap { + isImg, _ := metadata.IsImageFile(filePath) + if !isImg { + continue + } tmp, err := os.CreateTemp("", "localgo-private-*") if err != nil { return fmt.Errorf("private mode: create temp for %s: %w", filePath, err) From 8a3daa151c6167205e790232c68ec34d3cbc132d Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:55:19 +0300 Subject: [PATCH 08/36] fix(receive): reject empty filenames after sanitize, strengthen test After sanitizing control chars from filenames, reject names that become empty with 400 Bad Request (prevents downstream confusion). Strengthened TestPrepareUpload_SanitizesControlChars to assert the stored session filename is 'bad.txt' after sanitization (not just that prepare returns 200). --- pkg/server/handlers/receive_handlers.go | 5 +++ pkg/server/handlers/receive_handlers_test.go | 37 +++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index fd62c83..722ff3f 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -79,6 +79,11 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R // and terminal escape injection on display. for id, f := range requestDto.Files { f.FileName = sanitizeName(f.FileName) + if f.FileName == "" { + h.logger.Warnf("Rejected transfer from %s: file '%s' has empty name after sanitization", requestDto.Info.Alias, id) + httputil.RespondError(w, http.StatusBadRequest, "Invalid filename") + return + } requestDto.Files[id] = f } diff --git a/pkg/server/handlers/receive_handlers_test.go b/pkg/server/handlers/receive_handlers_test.go index 4071f45..a35d251 100644 --- a/pkg/server/handlers/receive_handlers_test.go +++ b/pkg/server/handlers/receive_handlers_test.go @@ -386,7 +386,7 @@ func TestUploadHandlerV2_TextPlain_SaveFailure_Returns500(t *testing.T) { } func TestPrepareUpload_SanitizesControlChars(t *testing.T) { - handler, _, _ := setupReceiveHandler(t, nil) + handler, receiveService, _ := setupReceiveHandler(t, nil) files := map[string]model.FileDto{ "f1": {ID: "f1", FileName: string([]byte{0x00, 'b', 0x01, 'a', 0x1F, 'd', '.', 't', 'x', 't'}), Size: 1}, @@ -410,6 +410,41 @@ func TestPrepareUpload_SanitizesControlChars(t *testing.T) { if respDto.SessionID == "" { t.Fatal("expected session ID") } + + // Verify the stored filename was sanitized + session := receiveService.GetSession() + if session == nil { + t.Fatal("expected session to exist") + } + af, ok := session.Files["f1"] + if !ok { + t.Fatal("expected file f1 in session") + } + want := "bad.txt" + if af.Dto.FileName != want { + t.Errorf("stored FileName: got %q, want %q", af.Dto.FileName, want) + } +} + +func TestPrepareUpload_EmptyNameAfterSanitize_Returns400(t *testing.T) { + handler, _, _ := setupReceiveHandler(t, nil) + + // All-control filename becomes empty after sanitize + files := map[string]model.FileDto{ + "f1": {ID: "f1", FileName: string([]byte{0x00, 0x01, 0x02, 0x1F}), Size: 1}, + } + reqDto := model.PrepareUploadRequestDto{Files: files} + body, _ := json.Marshal(reqDto) + + req, _ := http.NewRequest(http.MethodPost, "/v2/prepare-upload", bytes.NewReader(body)) + req.RemoteAddr = "192.168.1.100:12345" + rr := httptest.NewRecorder() + + handler.PrepareUploadHandlerV2(rr, req) + + if status := rr.Code; status != http.StatusBadRequest { + t.Errorf("expected 400 Bad Request for all-control filename, got %v (body: %s)", status, rr.Body.String()) + } } func TestPrepareUploadHandlerV2_NegativeFileSize_Returns400(t *testing.T) { From 258dcac9662f905545a60eaa5a6e8318df3bf599 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:34:38 +0300 Subject: [PATCH 09/36] fix(clipboard): capture stderr in Read(), treat empty clipboard as empty not error - Use CombinedOutput() instead of Output() to capture stderr - Include tool name in both Read() and Write() error messages - Treat tool exit with no output as empty clipboard (xclip/wl-paste exit 1 when clipboard is empty) instead of returning a cryptic error - Expand 'no tool found' message with actionable install hints --- .gitignore | 2 +- pkg/clipboard/clipboard.go | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 0c249cf..0ae7bc3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ # Output directories .localgo_security/ downloads/ -scripts/*.log +*.log # Dependency directories (if vendoring) vendor/ diff --git a/pkg/clipboard/clipboard.go b/pkg/clipboard/clipboard.go index a1bc9e4..4f9db0a 100644 --- a/pkg/clipboard/clipboard.go +++ b/pkg/clipboard/clipboard.go @@ -35,7 +35,7 @@ func Write(text string) error { cmd := exec.Command(provider.cmd, provider.args...) //nolint:gosec cmd.Stdin = strings.NewReader(text) if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("clipboard write failed: %w: %s", err, strings.TrimSpace(string(out))) + return fmt.Errorf("clipboard write failed (%s): %w: %s", provider.cmd, err, strings.TrimSpace(string(out))) } return nil } @@ -44,12 +44,17 @@ func Write(text string) error { // Returns an error when no suitable clipboard tool is available. func Read() (string, error) { if provider == nil || provider.readCmd == "" { - return "", fmt.Errorf("clipboard read unavailable: no supported tool found") + return "", fmt.Errorf("clipboard read unavailable: no supported tool found (install xclip, xsel, wl-paste, pbpaste, or Get-Clipboard)") } cmd := exec.Command(provider.readCmd, provider.readArgs...) //nolint:gosec - out, err := cmd.Output() + out, err := cmd.CombinedOutput() if err != nil { - return "", fmt.Errorf("clipboard read failed: %w", err) + // Some tools (xclip, wl-paste) exit with 1 when the clipboard is empty + // and produce no output. Treat this as empty, not an error. + if len(out) == 0 { + return "", nil + } + return "", fmt.Errorf("clipboard read failed (%s): %w: %s", provider.readCmd, err, strings.TrimSpace(string(out))) } // Normalize Windows CRLF line endings to unix LF return strings.ReplaceAll(string(out), "\r\n", "\n"), nil From ffde0a27d0efb8983db633c717fcd34c0f3a95bb Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:44:49 +0300 Subject: [PATCH 10/36] feat(send): send clipboard/stdin as in-memory content (no temp file) Remove the temp file creation for --clipboard and --stdin flags, and instead thread raw byte content through the send pipeline via a new SendOption/WithInMemoryFile mechanism. Changes: - pkg/send/send.go: Add SendOption, WithInMemoryFile, sendConfig, memFile types. SendToDevice and SendFiles now accept variadic SendOption. Process in-memory files alongside file-based files in the fileDto build loop and upload goroutines. - pkg/send/upload.go: Add memReadSeekCloser (bytes.Reader wrapper with no-op Close), fileReader interface. Extract uploadStream() from uploadFile() so both file and in-memory uploads share the same stream logic. - cmd/localgo/cmd/send.go: Replace os.CreateTemp/defer os.Remove with send.WithInMemoryFile() calls. Remove the localgo-clip- prefix hack entirely. Adjust file picker/empty checks to account for sendOpts. --- cmd/localgo/cmd/send.go | 41 +++----------- pkg/send/send.go | 123 +++++++++++++++++++++++++++++----------- pkg/send/upload.go | 32 +++++++++-- 3 files changed, 126 insertions(+), 70 deletions(-) diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index 9050364..2e8f8ca 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -42,6 +42,7 @@ var sendCmd = &cobra.Command{ SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { files := sendfiles + var sendOpts []send.SendOption if sendclipboard && sendstdin { return fmt.Errorf("cannot use both --clipboard and --stdin") @@ -55,19 +56,7 @@ var sendCmd = &cobra.Command{ if len(textBytes) == 0 { return fmt.Errorf("standard input is empty") } - - tempFile, err := os.CreateTemp("", "localgo-clip-stdin-*.txt") - if err != nil { - return fmt.Errorf("failed to create temporary file for stdin: %w", err) - } - defer os.Remove(tempFile.Name()) - - if _, err := tempFile.Write(textBytes); err != nil { - tempFile.Close() - return fmt.Errorf("failed to write standard input content: %w", err) - } - tempFile.Close() - files = []string{tempFile.Name()} + sendOpts = append(sendOpts, send.WithInMemoryFile("stdin.txt", textBytes)) } if sendclipboard { @@ -78,34 +67,22 @@ var sendCmd = &cobra.Command{ if strings.TrimSpace(text) == "" { return fmt.Errorf("clipboard is empty or does not contain text") } - - tempFile, err := os.CreateTemp("", "localgo-clip-*.txt") - if err != nil { - return fmt.Errorf("failed to create temporary file for clipboard: %w", err) - } - defer os.Remove(tempFile.Name()) - - if _, err := tempFile.WriteString(text); err != nil { - tempFile.Close() - return fmt.Errorf("failed to write clipboard text: %w", err) - } - tempFile.Close() - files = []string{tempFile.Name()} + sendOpts = append(sendOpts, send.WithInMemoryFile("clipboard.txt", []byte(text))) } - if len(files) == 0 { + if len(files) == 0 && len(sendOpts) == 0 { selected, err := cli.LaunchFilePicker() if err == nil && selected != "" { files = []string{selected} } } - if len(files) == 0 { + if len(files) == 0 && len(sendOpts) == 0 { return fmt.Errorf("no file specified: use --file flag, --clipboard, or select from the file browser") } for _, file := range files { - if _, err := os.Stat(file); os.IsNotExist(err) && !sendclipboard { + if _, err := os.Stat(file); os.IsNotExist(err) && len(sendOpts) == 0 { return fmt.Errorf("file not found: %s", file) } } @@ -165,7 +142,7 @@ var sendCmd = &cobra.Command{ 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 { + if err := send.SendToDevice(ctx, Cfg, device, files, zap.S(), sendOpts...); err != nil { return fmt.Errorf("failed to send files: %w", err) } @@ -276,11 +253,11 @@ var sendCmd = &cobra.Command{ 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()) + err = send.SendToDevice(ctx, Cfg, selectedDevice, files, zap.S(), sendOpts...) } else { cli.PrintInfo("To: %s", target) cli.PrintInfo("From: %s", fromAlias) - err = send.SendFiles(ctx, Cfg, files, target, sendport, zap.S()) + err = send.SendFiles(ctx, Cfg, files, target, sendport, zap.S(), sendOpts...) } if err != nil { return fmt.Errorf("failed to send files: %w", err) diff --git a/pkg/send/send.go b/pkg/send/send.go index c3b3a26..ff5c504 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -27,8 +27,27 @@ import ( "go.uber.org/zap" ) +// SendOption configures the send pipeline. +type SendOption func(*sendConfig) + +type sendConfig struct { + memFiles []memFile +} + +type memFile struct { + name string + content []byte +} + +// WithInMemoryFile adds an in-memory file (no disk I/O) to the send. +func WithInMemoryFile(name string, content []byte) SendOption { + return func(c *sendConfig) { + c.memFiles = append(c.memFiles, memFile{name: name, content: content}) + } +} + // 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 { +func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, recipientAlias string, recipientPort int, logger *zap.SugaredLogger, opts ...SendOption) error { if logger == nil { logger = zap.NewNop().Sugar() } @@ -92,7 +111,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, opts...) } registerDto := cfg.ToRegisterDto() @@ -136,10 +155,10 @@ 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, opts...) } -func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, filePaths []string, logger *zap.SugaredLogger) error { +func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, filePaths []string, logger *zap.SugaredLogger, opts ...SendOption) error { if logger == nil { logger = zap.NewNop().Sugar() } @@ -205,6 +224,11 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, defer tr.CloseIdleConnections() } + var sc sendConfig + for _, opt := range opts { + opt(&sc) + } + fileMap, err := getFilesWithRelativePaths(filePaths) if err != nil { return fmt.Errorf("failed to process file paths: %w", err) @@ -246,6 +270,7 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, filesDtoMap := make(map[string]model.FileDto) filePathMap := make(map[string]string) + memReaders := make(map[string]*memReadSeekCloser) for filePath, remoteName := range fileMap { fileInfo, err := os.Stat(filePath) @@ -267,12 +292,6 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, 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" - contentType = "text/plain" - } - modTime := fileInfo.ModTime().Format(time.RFC3339) var metadataPtr *model.FileMetadata @@ -292,6 +311,26 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, filePathMap[fileDto.ID] = filePath } + for _, mf := range sc.memFiles { + id := uuid.NewString() + remoteName := mf.name + contentType := "text/plain" + + if cfg.Private { + remoteName = anonymizeFileName(contentType) + } + + fileDto := model.FileDto{ + ID: id, + FileName: remoteName, + Size: int64(len(mf.content)), + FileType: contentType, + } + + filesDtoMap[id] = fileDto + memReaders[id] = &memReadSeekCloser{bytes.NewReader(mf.content)} + } + infoAlias := cfg.Alias infoDeviceModel := cfg.DeviceModel infoDeviceType := cfg.DeviceType @@ -359,32 +398,50 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, sem := make(chan struct{}, concurrency) for fileID, token := range prepareResponse.Files { - filePath, exists := filePathMap[fileID] - if !exists { - logger.Warnf("Server responded with unknown file ID: %s", fileID) - continue - } - - var fileSize int64 - if fi, err := os.Stat(filePath); err == nil { - fileSize = fi.Size() - } - trackProgress := mp.AddBar(filepath.Base(filePath), fileSize) + if reader, ok := memReaders[fileID]; ok { + displayName := filesDtoMap[fileID].FileName + fileSize := filesDtoMap[fileID].Size + trackProgress := mp.AddBar(displayName, fileSize) + + wg.Add(1) + go func(fID, tkn string, rdr *memReadSeekCloser, sz int64, name string, track func(int64)) { + defer wg.Done() + + sem <- struct{}{} + defer func() { <-sem }() + + logger.Infof("Uploading in-memory file: %s", name) + err := uploadStream(ctx, client, device, rdr, sz, fID, prepareResponse.SessionID, tkn, scheme, track, logger) + if err != nil { + logger.Errorf("Failed to upload %s: %v", name, err) + errCh <- fmt.Errorf("failed to upload %s: %w", name, err) + } + }(fileID, token, reader, fileSize, displayName, trackProgress) + } else if filePath, exists := filePathMap[fileID]; exists { + var fileSize int64 + if fi, err := os.Stat(filePath); err == nil { + fileSize = fi.Size() + } + trackProgress := mp.AddBar(filepath.Base(filePath), fileSize) - wg.Add(1) - go func(fID, tkn, fPath string, track func(int64)) { - defer wg.Done() + wg.Add(1) + go func(fID, tkn, fPath string, track func(int64)) { + defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() + sem <- struct{}{} + defer func() { <-sem }() - logger.Infof("Uploading file: %s", filepath.Base(fPath)) - err := uploadFile(ctx, client, device, fPath, fID, prepareResponse.SessionID, tkn, scheme, track, logger) - if err != nil { - logger.Errorf("Failed to upload file %s: %v", filepath.Base(fPath), err) - errCh <- fmt.Errorf("failed to upload %s: %w", filepath.Base(fPath), err) - } - }(fileID, token, filePath, trackProgress) + logger.Infof("Uploading file: %s", filepath.Base(fPath)) + err := uploadFile(ctx, client, device, fPath, fID, prepareResponse.SessionID, tkn, scheme, track, logger) + if err != nil { + logger.Errorf("Failed to upload file %s: %v", filepath.Base(fPath), err) + errCh <- fmt.Errorf("failed to upload %s: %w", filepath.Base(fPath), err) + } + }(fileID, token, filePath, trackProgress) + } else { + logger.Warnf("Server responded with unknown file ID: %s", fileID) + continue + } } wg.Wait() diff --git a/pkg/send/upload.go b/pkg/send/upload.go index 7cabf28..a23145a 100644 --- a/pkg/send/upload.go +++ b/pkg/send/upload.go @@ -1,6 +1,7 @@ package send import ( + "bytes" "context" "errors" "fmt" @@ -15,6 +16,19 @@ import ( "go.uber.org/zap" ) +// memReadSeekCloser wraps a *bytes.Reader to implement io.ReadSeekCloser. +type memReadSeekCloser struct { + *bytes.Reader +} + +func (m *memReadSeekCloser) Close() error { return nil } + +// fileReader is satisfied by both *os.File and *memReadSeekCloser. +type fileReader interface { + io.ReadSeeker + io.Closer +} + 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() @@ -26,17 +40,25 @@ func uploadFile(ctx context.Context, client *http.Client, device *model.Device, } 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 + return uploadStream(ctx, client, device, file, stat.Size(), fileID, sessionID, token, scheme, trackProgress, logger) +} + +func uploadStream(ctx context.Context, client *http.Client, device *model.Device, r fileReader, size int64, fileID, sessionID, token, scheme string, trackProgress func(int64), logger *zap.SugaredLogger) error { + if logger == nil { + logger = zap.NewNop().Sugar() + } + + 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) + + var body io.ReadCloser = io.NopCloser(r) if trackProgress != nil { bar := &progressBar{current: 0, track: trackProgress} - body = &progressTracker{Reader: file, Closer: file, bar: bar} + body = &progressTracker{Reader: r, Closer: r, bar: bar} } // Wrap with idle timeout: cancel request if no data flows for 15s @@ -50,7 +72,7 @@ func uploadFile(ctx context.Context, client *http.Client, device *model.Device, return fmt.Errorf("failed to create upload request: %w", err) } req.Header.Set("Content-Type", "application/octet-stream") - req.ContentLength = stat.Size() + req.ContentLength = size resp, err := client.Do(req) if err != nil { From b1351d8fd78b98ebc18e8da12050fb88435b34be Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:08:47 +0300 Subject: [PATCH 11/36] feat(clipboard): send text via Preview field, receiver responds 204 --- pkg/send/send.go | 9 ++++++ pkg/server/handlers/prompt.go | 37 +++++++++++++++++++++++++ pkg/server/handlers/receive_handlers.go | 30 ++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/pkg/send/send.go b/pkg/send/send.go index ff5c504..f9f668a 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -320,11 +320,13 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, remoteName = anonymizeFileName(contentType) } + preview := string(mf.content) fileDto := model.FileDto{ ID: id, FileName: remoteName, Size: int64(len(mf.content)), FileType: contentType, + Preview: &preview, } filesDtoMap[id] = fileDto @@ -377,6 +379,13 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, } defer resp.Body.Close() + // 204 No Content means the receiver accepted a clipboard message + // and no file upload is needed (content was in the Preview field). + if resp.StatusCode == http.StatusNoContent { + logger.Info("Clipboard message accepted by receiver, no upload needed") + return nil + } + if resp.StatusCode != http.StatusOK { return fmt.Errorf("prepare request failed with status: %s", resp.Status) } diff --git a/pkg/server/handlers/prompt.go b/pkg/server/handlers/prompt.go index 3961857..5835a1c 100644 --- a/pkg/server/handlers/prompt.go +++ b/pkg/server/handlers/prompt.go @@ -82,3 +82,40 @@ func (h *ReceiveHandler) promptUserForAcceptance(sender model.DeviceInfo, files return accept } + +func (h *ReceiveHandler) promptForClipboard(alias, remoteAddr, message string) bool { + if cli.IsContainer() { + return false + } + cli.Notify("LocalGo: Clipboard Message", + fmt.Sprintf("%s sent clipboard text (%d chars)", alias, len(message))) + + truncated := message + if len(truncated) > 500 { + truncated = truncated[:500] + "\n… (truncated)" + } + + desc := fmt.Sprintf("From: %s (IP: %s)\n\nClipboard:\n%s", alias, remoteAddr, truncated) + + var accept bool = true + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Accept Clipboard?"). + Description(desc). + Value(&accept). + Affirmative("Accept & Copy"). + 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 Clipboard 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 722ff3f..530f9ac 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -12,6 +12,7 @@ import ( "sync" "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" @@ -93,6 +94,35 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R return } + // --- Clipboard Message Detection --- + // The official LocalSend embeds clipboard text in the Preview field. + // When detected, no file upload is needed — the content is already here. + var clipboardMessage string + for _, f := range requestDto.Files { + if f.Preview != nil && *f.Preview != "" && strings.HasPrefix(f.FileType, "text/plain") { + clipboardMessage = *f.Preview + break + } + } + + if clipboardMessage != "" { + h.logger.Infof("Clipboard message from %s accepted and copied", requestDto.Info.Alias) + if !h.config.AutoAccept { + h.promptMutex.Lock() + accepted := h.promptForClipboard(requestDto.Info.Alias, r.RemoteAddr, clipboardMessage) + h.promptMutex.Unlock() + if !accepted { + httputil.RespondError(w, http.StatusForbidden, "Rejected") + return + } + } + if !h.config.NoClipboard { + clipboard.Write(clipboardMessage) + } + w.WriteHeader(http.StatusNoContent) + return + } + // --- Check Disk Space --- var totalSize int64 for _, f := range requestDto.Files { From e619e426ded61665619e7032227bfc41573ae59e Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:27:30 +0300 Subject: [PATCH 12/36] fix(test): resolve data race in TestReceiveService_ClaimFile_Concurrent Evaluate session.SessionID and file token before goroutines to avoid concurrent map access without the session mutex. --- pkg/server/services/receive_service_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/server/services/receive_service_test.go b/pkg/server/services/receive_service_test.go index 258b9d1..827b783 100644 --- a/pkg/server/services/receive_service_test.go +++ b/pkg/server/services/receive_service_test.go @@ -192,6 +192,11 @@ func TestReceiveService_ClaimFile_Concurrent(t *testing.T) { } session, _ := svc.CreateSession(sender, files) + // Evaluate args before goroutines to avoid data race + // on the shared session's Files map. + sid := session.SessionID + token := session.Files["f1"].Token + var wg sync.WaitGroup results := make(chan error, 2) @@ -199,7 +204,7 @@ func TestReceiveService_ClaimFile_Concurrent(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - _, _, err := svc.ClaimFile(session.SessionID, "f1", session.Files["f1"].Token, "10.0.0.1") + _, _, err := svc.ClaimFile(sid, "f1", token, "10.0.0.1") results <- err }() } From 71b8ed4756d03117d3dfcb71b53ce5b6982083be Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:32:39 +0300 Subject: [PATCH 13/36] feat(config): add env var overrides for shell, clipboard, TLS, notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 6 new env var overrides: - LOCALSEND_SHELL — custom shell for exec hooks (replaces hardcoded sh -c/cmd /c) - LOCALSEND_CLIPBOARD_WRITE_CMD / _READ_CMD — custom clipboard tools - LOCALSEND_TLS_CERT / LOCALSEND_TLS_KEY — custom TLS certificate paths - LOCALSEND_NOTIFICATION_CMD — custom notification command --- cmd/localgo/cmd/root.go | 9 +++++++++ pkg/cli/notify.go | 19 +++++++++++++++++++ pkg/clipboard/clipboard.go | 28 ++++++++++++++++++++++++++++ pkg/config/config.go | 20 ++++++++++++++++++++ pkg/server/handlers/exec.go | 6 +++++- pkg/server/server.go | 8 +++++++- 6 files changed, 88 insertions(+), 2 deletions(-) diff --git a/cmd/localgo/cmd/root.go b/cmd/localgo/cmd/root.go index 2fa9b44..66b1a76 100644 --- a/cmd/localgo/cmd/root.go +++ b/cmd/localgo/cmd/root.go @@ -4,6 +4,8 @@ import ( "fmt" "os" + "github.com/bethropolis/localgo/pkg/cli" + "github.com/bethropolis/localgo/pkg/clipboard" "github.com/bethropolis/localgo/pkg/config" "github.com/bethropolis/localgo/pkg/help" "github.com/bethropolis/localgo/pkg/logging" @@ -62,6 +64,13 @@ var rootCmd = &cobra.Command{ Cfg.Private = true } + if Cfg.ClipboardWriteCmd != "" || Cfg.ClipboardReadCmd != "" { + clipboard.OverrideProvider(Cfg.ClipboardWriteCmd, Cfg.ClipboardReadCmd) + } + if Cfg.NotificationCmd != "" { + cli.SetNotificationCmd(Cfg.NotificationCmd) + } + return nil }, } diff --git a/pkg/cli/notify.go b/pkg/cli/notify.go index d088607..f5b8be4 100644 --- a/pkg/cli/notify.go +++ b/pkg/cli/notify.go @@ -2,16 +2,35 @@ package cli import ( "os" + "os/exec" + "strings" "github.com/gen2brain/beeep" ) +// notificationCmd holds a user-configured custom notification command. +var notificationCmd string + +// SetNotificationCmd sets a custom notification command. +// The command is called with the title and body as the last two arguments. +func SetNotificationCmd(cmd string) { + notificationCmd = cmd +} + // Notify sends a native desktop notification. Icon is empty (system default). // No-op in container environments. func Notify(title, body string) { if IsContainer() { return } + if notificationCmd != "" { + parts := strings.Fields(notificationCmd) + if len(parts) > 0 { + c := exec.Command(parts[0], append(parts[1:], title, body)...) + c.Run() // best-effort + } + return + } beeep.Notify(title, body, "") } diff --git a/pkg/clipboard/clipboard.go b/pkg/clipboard/clipboard.go index 4f9db0a..98d5d32 100644 --- a/pkg/clipboard/clipboard.go +++ b/pkg/clipboard/clipboard.go @@ -64,3 +64,31 @@ func Read() (string, error) { func Available() bool { return provider != nil } + +// OverrideProvider replaces the auto-detected clipboard tool with custom commands. +// Empty strings are ignored (auto-detected tool kept for that direction, if any). +func OverrideProvider(writeCmd, readCmd string) { + if writeCmd == "" && readCmd == "" { + return + } + p := &clipProvider{} + if writeCmd != "" { + wp := strings.Fields(writeCmd) + p.cmd = wp[0] + p.args = wp[1:] + } else if provider != nil { + p.cmd = provider.cmd + p.args = provider.args + } + if readCmd != "" { + rp := strings.Fields(readCmd) + p.readCmd = rp[0] + p.readArgs = rp[1:] + } else if provider != nil { + p.readCmd = provider.readCmd + p.readArgs = provider.readArgs + } + if p.cmd != "" || p.readCmd != "" { + provider = p + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index ff262f5..1312fd7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -45,6 +45,13 @@ type Config struct { Concurrency int `json:"-"` // max parallel uploads (0 = use default) MulticastInterface string `json:"-"` // multicast network interface name Private bool `json:"-"` // anonymize device identities + + Shell string `json:"-"` // shell command prefix for exec hooks (default: "sh -c" or "cmd /c") + ClipboardWriteCmd string `json:"-"` // custom clipboard write command + ClipboardReadCmd string `json:"-"` // custom clipboard read command + CustomTLSCertPath string `json:"-"` // path to custom TLS certificate file + CustomTLSKeyPath string `json:"-"` // path to custom TLS private key file + NotificationCmd string `json:"-"` // custom notification command } // getSecurityDir determines the best location for the security directory @@ -181,6 +188,13 @@ func LoadConfig(v *viper.Viper, logger *zap.SugaredLogger) (*Config, error) { concurrency := v.GetInt("concurrency") + shell := v.GetString("shell") + clipboardWriteCmd := v.GetString("clipboard_write_cmd") + clipboardReadCmd := v.GetString("clipboard_read_cmd") + customTLSCertPath := v.GetString("tls_cert") + customTLSKeyPath := v.GetString("tls_key") + notificationCmd := v.GetString("notification_cmd") + cfg := &Config{ Alias: alias, Port: port, @@ -200,6 +214,12 @@ func LoadConfig(v *viper.Viper, logger *zap.SugaredLogger) (*Config, error) { ExecHook: execHook, Concurrency: concurrency, MulticastInterface: multicastInterface, + Shell: shell, + ClipboardWriteCmd: clipboardWriteCmd, + ClipboardReadCmd: clipboardReadCmd, + CustomTLSCertPath: customTLSCertPath, + CustomTLSKeyPath: customTLSKeyPath, + NotificationCmd: notificationCmd, } return cfg, nil diff --git a/pkg/server/handlers/exec.go b/pkg/server/handlers/exec.go index fa43989..994314d 100644 --- a/pkg/server/handlers/exec.go +++ b/pkg/server/handlers/exec.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "runtime" + "strings" ) func (h *ReceiveHandler) runExecHook(filePath, fileName, senderAlias, senderIP string, fileSize int64) { @@ -15,7 +16,10 @@ func (h *ReceiveHandler) runExecHook(filePath, fileName, senderAlias, senderIP s go func() { h.logger.Infof("Running exec hook: %s", h.config.ExecHook) var cmd *exec.Cmd - if runtime.GOOS == "windows" { + if h.config.Shell != "" { + parts := strings.Fields(h.config.Shell) + cmd = exec.Command(parts[0], append(parts[1:], h.config.ExecHook)...) + } else if runtime.GOOS == "windows" { cmd = exec.Command("cmd", "/c", h.config.ExecHook) } else { cmd = exec.Command("sh", "-c", h.config.ExecHook) diff --git a/pkg/server/server.go b/pkg/server/server.go index f1b5bc5..7119cce 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -161,7 +161,13 @@ func (s *Server) Start(ctx context.Context, readyChan chan<- struct{}) error { if s.config.HttpsEnabled { s.logger.Infof("Starting HTTPS server on %s with alias %s", addr, s.config.Alias) - cert, err := tls.X509KeyPair([]byte(s.config.SecurityContext.Certificate), []byte(s.config.SecurityContext.PrivateKey)) + var cert tls.Certificate + var err error + if s.config.CustomTLSCertPath != "" && s.config.CustomTLSKeyPath != "" { + cert, err = tls.LoadX509KeyPair(s.config.CustomTLSCertPath, s.config.CustomTLSKeyPath) + } else { + cert, err = tls.X509KeyPair([]byte(s.config.SecurityContext.Certificate), []byte(s.config.SecurityContext.PrivateKey)) + } if err != nil { return fmt.Errorf("failed to load TLS key pair: %w", err) } From 03fa94df048df8294e833ea2f41f5a7a66ee5c79 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:48:53 +0300 Subject: [PATCH 14/36] fix(security): sanitize ANSI escape codes from remote peer data Adds cli.Sanitize() as a central ANSI-strip helper and applies it at all terminal output points for remote-controlled data (aliases, filenames): - CLI device table/quiet/JSON output and PickDevice TUI - Incoming transfer prompts (file + clipboard) - Server log messages - sender model.DeviceInfo before prompt rendering --- pkg/cli/output.go | 6 +++--- pkg/cli/sanitize.go | 9 +++++++++ pkg/server/handlers/discovery_handlers.go | 3 ++- pkg/server/handlers/prompt.go | 12 ++++++------ pkg/server/handlers/receive_handlers.go | 14 +++++++------- 5 files changed, 27 insertions(+), 17 deletions(-) create mode 100644 pkg/cli/sanitize.go diff --git a/pkg/cli/output.go b/pkg/cli/output.go index 7cdc671..b0e59e9 100644 --- a/pkg/cli/output.go +++ b/pkg/cli/output.go @@ -131,7 +131,7 @@ func (ow *OutputWriter) writeDevicesTable(devices []*model.Device, method string // Write devices for _, device := range devices { fmt.Fprintf(ow.writer, "%s\t%s\t%s\t%d\t%s\t%s...\n", - TruncateString(device.Alias, 20), + TruncateString(Sanitize(device.Alias), 20), device.IP, strings.ToUpper(string(device.Protocol)), device.Port, @@ -147,7 +147,7 @@ func (ow *OutputWriter) writeDevicesTable(devices []*model.Device, method string func (ow *OutputWriter) writeDevicesQuiet(devices []*model.Device) error { for _, device := range devices { fmt.Printf("%s\t%s\t%s\t%d\t%s\n", - device.Alias, + Sanitize(device.Alias), device.IP, device.Protocol, device.Port, @@ -228,7 +228,7 @@ func PickDevice(devices []*model.Device, private bool) *model.Device { var selected *model.Device options := make([]huh.Option[*model.Device], len(devices)) for i, d := range devices { - displayName := d.Alias + displayName := Sanitize(d.Alias) if private { displayName = AnonymizedAlias(d) } diff --git a/pkg/cli/sanitize.go b/pkg/cli/sanitize.go new file mode 100644 index 0000000..48b0301 --- /dev/null +++ b/pkg/cli/sanitize.go @@ -0,0 +1,9 @@ +package cli + +import "github.com/acarl005/stripansi" + +// Sanitize strips ANSI escape sequences from a string to prevent ANSI injection +// attacks when displaying untrusted data from remote peers. +func Sanitize(s string) string { + return stripansi.Strip(s) +} diff --git a/pkg/server/handlers/discovery_handlers.go b/pkg/server/handlers/discovery_handlers.go index 20942e0..4867797 100644 --- a/pkg/server/handlers/discovery_handlers.go +++ b/pkg/server/handlers/discovery_handlers.go @@ -6,6 +6,7 @@ import ( "net" "net/http" + "github.com/bethropolis/localgo/pkg/cli" "github.com/bethropolis/localgo/pkg/config" "github.com/bethropolis/localgo/pkg/httputil" "github.com/bethropolis/localgo/pkg/model" @@ -108,7 +109,7 @@ func (h *DiscoveryHandler) RegisterHandler(w http.ResponseWriter, r *http.Reques device := model.NewDevice(requestDto, net.ParseIP(ip), requestDto.Port, requestDto.Protocol == model.ProtocolTypeHTTPS) h.registryService.RegisterDevice(device) - h.logger.Infof("Received /register request from %s: Alias=%s, Fingerprint=%.8s...", r.RemoteAddr, requestDto.Alias, requestDto.Fingerprint) + h.logger.Infof("Received /register request from %s: Alias=%s, Fingerprint=%.8s...", r.RemoteAddr, cli.Sanitize(requestDto.Alias), requestDto.Fingerprint) downloadCapable := h.sendService.GetSession() != nil diff --git a/pkg/server/handlers/prompt.go b/pkg/server/handlers/prompt.go index 5835a1c..d6af090 100644 --- a/pkg/server/handlers/prompt.go +++ b/pkg/server/handlers/prompt.go @@ -24,11 +24,11 @@ func (h *ReceiveHandler) promptUserForAcceptance(sender model.DeviceInfo, files } cli.Notify("LocalGo: Incoming Transfer", - fmt.Sprintf("%s wants to send you %d file(s) (%s)", sender.Alias, fileCount, cli.FormatBytes(totalSize))) + fmt.Sprintf("%s wants to send you %d file(s) (%s)", cli.Sanitize(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)) + sb.WriteString(fmt.Sprintf("From: %s (IP: %s)\n\nFiles:\n", cli.Sanitize(sender.Alias), sender.IP)) count := 0 for _, file := range files { @@ -46,10 +46,10 @@ func (h *ReceiveHandler) promptUserForAcceptance(sender model.DeviceInfo, files } 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))) + sb.WriteString(fmt.Sprintf(" %s [Text] %s (%s)\n", cli.IconFile, cli.Sanitize(file.FileName), cli.FormatBytes(file.Size))) } } else { - sb.WriteString(fmt.Sprintf(" %s %s (%s)\n", cli.IconFile, file.FileName, cli.FormatBytes(file.Size))) + sb.WriteString(fmt.Sprintf(" %s %s (%s)\n", cli.IconFile, cli.Sanitize(file.FileName), cli.FormatBytes(file.Size))) } count++ } @@ -88,14 +88,14 @@ func (h *ReceiveHandler) promptForClipboard(alias, remoteAddr, message string) b return false } cli.Notify("LocalGo: Clipboard Message", - fmt.Sprintf("%s sent clipboard text (%d chars)", alias, len(message))) + fmt.Sprintf("%s sent clipboard text (%d chars)", cli.Sanitize(alias), len(message))) truncated := message if len(truncated) > 500 { truncated = truncated[:500] + "\n… (truncated)" } - desc := fmt.Sprintf("From: %s (IP: %s)\n\nClipboard:\n%s", alias, remoteAddr, truncated) + desc := fmt.Sprintf("From: %s (IP: %s)\n\nClipboard:\n%s", cli.Sanitize(alias), remoteAddr, truncated) var accept bool = true form := huh.NewForm( diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index 530f9ac..4df0fd7 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -81,7 +81,7 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R for id, f := range requestDto.Files { f.FileName = sanitizeName(f.FileName) if f.FileName == "" { - h.logger.Warnf("Rejected transfer from %s: file '%s' has empty name after sanitization", requestDto.Info.Alias, id) + h.logger.Warnf("Rejected transfer from %s: file '%s' has empty name after sanitization", cli.Sanitize(requestDto.Info.Alias), id) httputil.RespondError(w, http.StatusBadRequest, "Invalid filename") return } @@ -106,10 +106,10 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R } if clipboardMessage != "" { - h.logger.Infof("Clipboard message from %s accepted and copied", requestDto.Info.Alias) + h.logger.Infof("Clipboard message from %s accepted and copied", cli.Sanitize(requestDto.Info.Alias)) if !h.config.AutoAccept { h.promptMutex.Lock() - accepted := h.promptForClipboard(requestDto.Info.Alias, r.RemoteAddr, clipboardMessage) + accepted := h.promptForClipboard(cli.Sanitize(requestDto.Info.Alias), r.RemoteAddr, clipboardMessage) h.promptMutex.Unlock() if !accepted { httputil.RespondError(w, http.StatusForbidden, "Rejected") @@ -127,7 +127,7 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R var totalSize int64 for _, f := range requestDto.Files { if f.Size < 0 { - h.logger.Warnf("Rejected transfer from %s: file '%s' has negative size (%d)", requestDto.Info.Alias, f.FileName, f.Size) + h.logger.Warnf("Rejected transfer from %s: file '%s' has negative size (%d)", cli.Sanitize(requestDto.Info.Alias), cli.Sanitize(f.FileName), f.Size) httputil.RespondError(w, http.StatusBadRequest, "Invalid file size") return } @@ -139,18 +139,18 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R const safetyBuffer = 50 * 1024 * 1024 if freeSpace < uint64(totalSize)+safetyBuffer { h.logger.Warnf("Rejected transfer from %s: Insufficient disk space (Required: %s, Available: %s)", - requestDto.Info.Alias, cli.FormatBytes(totalSize), cli.FormatBytes(int64(freeSpace))) + cli.Sanitize(requestDto.Info.Alias), cli.FormatBytes(totalSize), cli.FormatBytes(int64(freeSpace))) httputil.RespondError(w, http.StatusBadRequest, "Insufficient storage space on receiver") return } } - h.logger.Infof("PrepareUpload request from %s (%s) for %d files:", requestDto.Info.Alias, r.RemoteAddr, len(requestDto.Files)) + h.logger.Infof("PrepareUpload request from %s (%s) for %d files:", cli.Sanitize(requestDto.Info.Alias), r.RemoteAddr, len(requestDto.Files)) // Extract IP from RemoteAddr senderIP, _, _ := net.SplitHostPort(r.RemoteAddr) sender := model.DeviceInfo{ - Alias: requestDto.Info.Alias, + Alias: cli.Sanitize(requestDto.Info.Alias), Version: requestDto.Info.Version, DeviceModel: requestDto.Info.DeviceModel, DeviceType: requestDto.Info.DeviceType, From b116c71c30667c8a88a936a6679ce1990f36b6c3 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:49:52 +0300 Subject: [PATCH 15/36] feat(network): add GetInterfaceIPNet and GetUsableSubnetIPs GetInterfaceIPNet returns the IPv4 network (IP+mask) for a named interface. GetUsableSubnetIPs returns all usable host IPs respecting the actual netmask, capped at /22 for practical LAN scanning. --- pkg/network/interfaces.go | 62 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/pkg/network/interfaces.go b/pkg/network/interfaces.go index 3236c2b..4d8449f 100644 --- a/pkg/network/interfaces.go +++ b/pkg/network/interfaces.go @@ -169,6 +169,68 @@ func GetSubnetIPs(ip net.IP) []net.IP { return ips } +// GetInterfaceIPNet returns the IPv4 network (IP + subnet mask) for the named interface. +// Returns nil if the interface has no IPv4 address. +func GetInterfaceIPNet(ifaceName string) (*net.IPNet, error) { + iface, err := net.InterfaceByName(ifaceName) + if err != nil { + return nil, fmt.Errorf("interface %q: %w", ifaceName, err) + } + addrs, err := iface.Addrs() + if err != nil { + return nil, fmt.Errorf("interface %q addrs: %w", ifaceName, err) + } + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok { + if ipnet.IP.To4() != nil { + return ipnet, nil + } + } + } + return nil, fmt.Errorf("interface %q has no IPv4 address", ifaceName) +} + +// GetUsableSubnetIPs returns all usable host IPs in the subnet of the named +// interface, respecting its actual netmask. Subnets larger than /22 are capped +// at /22 to keep scanning practical. Network and broadcast addresses are excluded. +func GetUsableSubnetIPs(ifaceName string) ([]net.IP, error) { + ipnet, err := GetInterfaceIPNet(ifaceName) + if err != nil { + return nil, err + } + + ip4 := ipnet.IP.To4() + if ip4 == nil { + return nil, fmt.Errorf("interface %q has no IPv4 address", ifaceName) + } + + ones, bits := ipnet.Mask.Size() + hostBits := bits - ones + + // Cap at /22 for practical scanning + effectiveMask := ipnet.Mask + if hostBits > 22 { + effectiveMask = net.CIDRMask(22, bits) + hostBits = bits - 22 + } + + if hostBits < 2 { + return nil, fmt.Errorf("interface %q subnet prefix /%d is too large for scanning", ifaceName, bits-hostBits) + } + + maskBits := []byte{effectiveMask[0], effectiveMask[1], effectiveMask[2], effectiveMask[3]} + base := uint32(ip4[0])<<24 | uint32(ip4[1])<<16 | uint32(ip4[2])<<8 | uint32(ip4[3]) + base &= uint32(maskBits[0])<<24 | uint32(maskBits[1])<<16 | uint32(maskBits[2])<<8 | uint32(maskBits[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 +} + // DefaultGatewayIP returns the IP address of the default network gateway. func DefaultGatewayIP() (net.IP, error) { return gateway.DiscoverGateway() From 60e2774b098a7a13e8d3baaac8cff9b034a86536 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:52:42 +0300 Subject: [PATCH 16/36] feat(daemon): add serve --daemon and localgo stop serve --daemon forks into background, writes PID to ~/.config/localgo/localgo.pid, and detaches from the terminal. localgo stop reads the PID file and sends SIGTERM (with 5s graceful timeout, then SIGKILL). --- cmd/localgo/cmd/serve.go | 39 +++++++++++++++++ cmd/localgo/cmd/stop.go | 90 ++++++++++++++++++++++++++++++++++++++++ pkg/help/commands.go | 12 ++++++ pkg/help/help.go | 1 + 4 files changed, 142 insertions(+) create mode 100644 cmd/localgo/cmd/stop.go diff --git a/cmd/localgo/cmd/serve.go b/cmd/localgo/cmd/serve.go index 77ec048..eef6a9b 100644 --- a/cmd/localgo/cmd/serve.go +++ b/cmd/localgo/cmd/serve.go @@ -4,7 +4,9 @@ import ( "context" "fmt" "os" + "os/exec" "os/signal" + "path/filepath" "syscall" "time" @@ -25,6 +27,7 @@ var ( servealias string servedir string servequiet bool + servedaemon bool serveinterval int serveautoAccept bool servenoClipboard bool @@ -39,6 +42,41 @@ var serveCmd = &cobra.Command{ Short: "Start the LocalGo server to receive files", RunE: func(cmd *cobra.Command, args []string) error { + // Daemon mode: fork into background + if servedaemon && os.Getenv("LOCALGO_DAEMON_CHILD") != "1" { + var childArgs []string + for _, a := range os.Args[1:] { + if a == "--daemon" || a == "-d" { + continue + } + childArgs = append(childArgs, a) + } + child := exec.Command(os.Args[0], childArgs...) + child.Env = append(os.Environ(), "LOCALGO_DAEMON_CHILD=1") + child.Stdin = nil + child.Stdout = nil + child.Stderr = nil + child.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + if err := child.Start(); err != nil { + return fmt.Errorf("failed to start daemon: %w", err) + } + + pidPath, err := pidFilePath() + if err != nil { + return fmt.Errorf("cannot determine pid file path: %w", err) + } + if err := os.MkdirAll(filepath.Dir(pidPath), 0755); err != nil { + return fmt.Errorf("cannot create pid directory: %w", err) + } + if err := os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", child.Process.Pid)), 0644); err != nil { + return fmt.Errorf("failed to write PID file: %w", err) + } + + fmt.Printf("LocalGo daemon started (PID %d)\n", child.Process.Pid) + os.Exit(0) + } + // Apply overrides if serveport > 0 { Cfg.Port = serveport @@ -212,6 +250,7 @@ func init() { serveCmd.Flags().StringVar(&servealias, "alias", "", "Device alias (default: from config)") serveCmd.Flags().StringVar(&servedir, "dir", "", "Download directory (default: from config)") serveCmd.Flags().BoolVar(&servequiet, "quiet", false, "Quiet mode - minimal output") + serveCmd.Flags().BoolVarP(&servedaemon, "daemon", "d", false, "Run server as a background daemon") serveCmd.Flags().IntVar(&serveinterval, "interval", 30, "Discovery announcement interval in seconds") serveCmd.Flags().BoolVar(&serveautoAccept, "auto-accept", false, "Auto-accept incoming files without prompting") serveCmd.Flags().BoolVar(&servenoClipboard, "no-clipboard", false, "Save incoming text as a file instead of copying to clipboard") diff --git a/cmd/localgo/cmd/stop.go b/cmd/localgo/cmd/stop.go new file mode 100644 index 0000000..91341a9 --- /dev/null +++ b/cmd/localgo/cmd/stop.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/bethropolis/localgo/pkg/cli" + "github.com/bethropolis/localgo/pkg/help" + "github.com/spf13/cobra" +) + +var stopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop the running LocalGo daemon", + RunE: func(cmd *cobra.Command, args []string) error { + pidPath, err := pidFilePath() + if err != nil { + return fmt.Errorf("cannot determine pid file path: %w", err) + } + + data, err := os.ReadFile(pidPath) + if err != nil { + if os.IsNotExist(err) { + cli.PrintWarning("No running LocalGo daemon found (PID file not found)") + return nil + } + return fmt.Errorf("failed to read PID file %s: %w", pidPath, err) + } + + pidStr := strings.TrimSpace(string(data)) + pid, err := strconv.Atoi(pidStr) + if err != nil { + return fmt.Errorf("invalid PID in %s: %q", pidPath, pidStr) + } + + process, err := os.FindProcess(pid) + if err != nil { + os.Remove(pidPath) + cli.PrintWarning("No running LocalGo daemon found (process %d not found)", pid) + return nil + } + + cli.PrintInfo("Stopping LocalGo daemon (PID %d)...", pid) + if err := process.Signal(syscall.SIGTERM); err != nil { + return fmt.Errorf("failed to signal process %d: %w", pid, err) + } + + // Wait up to 5 seconds for graceful shutdown + done := make(chan struct{}) + go func() { + process.Wait() + close(done) + }() + + select { + case <-done: + cli.PrintSuccess("LocalGo daemon stopped") + case <-time.After(5 * time.Second): + cli.PrintWarning("Daemon did not stop gracefully, sending SIGKILL...") + process.Kill() + <-done + cli.PrintSuccess("LocalGo daemon killed") + } + + os.Remove(pidPath) + return nil + }, +} + +func pidFilePath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, "localgo", "localgo.pid"), nil +} + +func init() { + rootCmd.AddCommand(stopCmd) + stopCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + if h := help.GetCommandHelp("stop"); h != nil { + help.ShowCommandHelp(*h) + } + }) +} diff --git a/pkg/help/commands.go b/pkg/help/commands.go index 29afec5..e26809f 100644 --- a/pkg/help/commands.go +++ b/pkg/help/commands.go @@ -15,6 +15,8 @@ func GetCommandHelp(commandName string) *CommandHelp { "localgo serve --auto-accept --quiet", "localgo serve --no-clipboard", "localgo serve --exec 'notify-send \"Got: %f\"'", + "localgo serve --daemon", + "localgo serve -d", }, Flags: []FlagHelp{ {Name: "--port", Type: "int", Default: "from config", Description: "Port to run the server on"}, @@ -22,6 +24,7 @@ func GetCommandHelp(commandName string) *CommandHelp { {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: "--daemon, -d", Type: "bool", Default: "false", Description: "Run server as a background daemon"}, {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"}, @@ -163,6 +166,15 @@ func GetCommandHelp(commandName string) *CommandHelp { {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, }, }, + "stop": { + Name: "stop", + Description: "Stop the running LocalGo daemon", + Usage: "localgo stop", + Examples: []string{ + "localgo stop", + }, + Flags: []FlagHelp{}, + }, "completion": { Name: "completion", Description: "Generate shell completion scripts", diff --git a/pkg/help/help.go b/pkg/help/help.go index ae6e508..eae53b8 100644 --- a/pkg/help/help.go +++ b/pkg/help/help.go @@ -46,6 +46,7 @@ func ShowMainUsage() { {"scan", "Scan network for devices using HTTP"}, {"devices", "List recently discovered devices"}, {"history", "Show file transfer history log"}, + {"stop", "Stop the running LocalGo daemon"}, {"info", "Show device information"}, {"completion", "Generate shell completion scripts"}, {"help", "Show help information"}, From be928367c7fde0122e43c0adacaf1b8002c292aa Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:22:00 +0300 Subject: [PATCH 17/36] fix(windows): use filepath.Dir instead of TrimSuffix, use filepath.Join in tests Fixes #26: strings.TrimSuffix(configPath, "/config.yaml") uses hardcoded forward slash which doesn't match Windows backslash paths. Replaced with filepath.Dir(configPath) for cross-platform parent directory extraction. Also fixes storage_test.go to use filepath.Join instead of manual string concatenation with forward slashes. --- cmd/localgo/cmd/config.go | 3 ++- pkg/storage/storage_test.go | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cmd/localgo/cmd/config.go b/cmd/localgo/cmd/config.go index 7b75406..3aceb45 100644 --- a/cmd/localgo/cmd/config.go +++ b/cmd/localgo/cmd/config.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "path/filepath" "strconv" "strings" @@ -90,7 +91,7 @@ var configSetCmd = &cobra.Command{ configPath = os.ExpandEnv("$HOME/.config/localgo/config.yaml") } - if err := os.MkdirAll(strings.TrimSuffix(configPath, "/config.yaml"), 0700); err != nil { + if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } diff --git a/pkg/storage/storage_test.go b/pkg/storage/storage_test.go index 88beede..94e5c83 100644 --- a/pkg/storage/storage_test.go +++ b/pkg/storage/storage_test.go @@ -1,18 +1,20 @@ package storage import ( - "go.uber.org/zap" "os" + "path/filepath" "strings" "testing" "time" + + "go.uber.org/zap" ) var testLogger = zap.NewNop().Sugar() func TestEnsureDirExists(t *testing.T) { tmpDir := t.TempDir() - subDir := tmpDir + "/subdir" + subDir := filepath.Join(tmpDir, "subdir") err := EnsureDirExists(subDir) if err != nil { From 004eace3f7bda3734cb94433c66789a8372d77bd Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:39:27 +0300 Subject: [PATCH 18/36] fix(daemon): move daemon fork to platform-specific files syscall.SysProcAttr.Setpgid is Unix-only, causing Windows CI failure. daemonize() is now in daemon_unix.go (Unix: fork with Setpgid) and daemon_windows.go (Windows: returns error about unsupported daemon mode). --- cmd/localgo/cmd/daemon_unix.go | 46 +++++++++++++++++++++++++++++++ cmd/localgo/cmd/daemon_windows.go | 9 ++++++ cmd/localgo/cmd/serve.go | 34 +---------------------- 3 files changed, 56 insertions(+), 33 deletions(-) create mode 100644 cmd/localgo/cmd/daemon_unix.go create mode 100644 cmd/localgo/cmd/daemon_windows.go diff --git a/cmd/localgo/cmd/daemon_unix.go b/cmd/localgo/cmd/daemon_unix.go new file mode 100644 index 0000000..92368a0 --- /dev/null +++ b/cmd/localgo/cmd/daemon_unix.go @@ -0,0 +1,46 @@ +//go:build !windows + +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "syscall" +) + +func daemonize() error { + var childArgs []string + for _, a := range os.Args[1:] { + if a == "--daemon" || a == "-d" { + continue + } + childArgs = append(childArgs, a) + } + child := exec.Command(os.Args[0], childArgs...) + child.Env = append(os.Environ(), "LOCALGO_DAEMON_CHILD=1") + child.Stdin = nil + child.Stdout = nil + child.Stderr = nil + child.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + if err := child.Start(); err != nil { + return fmt.Errorf("failed to start daemon: %w", err) + } + + pidPath, err := pidFilePath() + if err != nil { + return fmt.Errorf("cannot determine pid file path: %w", err) + } + if err := os.MkdirAll(filepath.Dir(pidPath), 0755); err != nil { + return fmt.Errorf("cannot create pid directory: %w", err) + } + if err := os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", child.Process.Pid)), 0644); err != nil { + return fmt.Errorf("failed to write PID file: %w", err) + } + + fmt.Printf("LocalGo daemon started (PID %d)\n", child.Process.Pid) + os.Exit(0) + return nil +} diff --git a/cmd/localgo/cmd/daemon_windows.go b/cmd/localgo/cmd/daemon_windows.go new file mode 100644 index 0000000..128e93f --- /dev/null +++ b/cmd/localgo/cmd/daemon_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package cmd + +import "fmt" + +func daemonize() error { + return fmt.Errorf("daemon mode is not supported on Windows; use 'localgo serve' in a terminal or run as a background job") +} diff --git a/cmd/localgo/cmd/serve.go b/cmd/localgo/cmd/serve.go index eef6a9b..945a36b 100644 --- a/cmd/localgo/cmd/serve.go +++ b/cmd/localgo/cmd/serve.go @@ -4,9 +4,7 @@ import ( "context" "fmt" "os" - "os/exec" "os/signal" - "path/filepath" "syscall" "time" @@ -44,37 +42,7 @@ var serveCmd = &cobra.Command{ // Daemon mode: fork into background if servedaemon && os.Getenv("LOCALGO_DAEMON_CHILD") != "1" { - var childArgs []string - for _, a := range os.Args[1:] { - if a == "--daemon" || a == "-d" { - continue - } - childArgs = append(childArgs, a) - } - child := exec.Command(os.Args[0], childArgs...) - child.Env = append(os.Environ(), "LOCALGO_DAEMON_CHILD=1") - child.Stdin = nil - child.Stdout = nil - child.Stderr = nil - child.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - - if err := child.Start(); err != nil { - return fmt.Errorf("failed to start daemon: %w", err) - } - - pidPath, err := pidFilePath() - if err != nil { - return fmt.Errorf("cannot determine pid file path: %w", err) - } - if err := os.MkdirAll(filepath.Dir(pidPath), 0755); err != nil { - return fmt.Errorf("cannot create pid directory: %w", err) - } - if err := os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", child.Process.Pid)), 0644); err != nil { - return fmt.Errorf("failed to write PID file: %w", err) - } - - fmt.Printf("LocalGo daemon started (PID %d)\n", child.Process.Pid) - os.Exit(0) + return daemonize() } // Apply overrides From 9017fdccf383ab02d6952aa7075d9d47d6fc6c93 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:44:23 +0300 Subject: [PATCH 19/36] fix(test): skip read-only dir test on Windows os.Chmod(tempDir, 0500) doesn't make directories read-only on Windows (unlike Unix permission bits), so the test was expecting a 500 error but getting 200. --- pkg/server/handlers/receive_handlers_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/server/handlers/receive_handlers_test.go b/pkg/server/handlers/receive_handlers_test.go index a35d251..fcf52ae 100644 --- a/pkg/server/handlers/receive_handlers_test.go +++ b/pkg/server/handlers/receive_handlers_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "testing" @@ -348,6 +349,9 @@ func TestUploadHandlerV2_TextPlain_PathTraversal_Returns400(t *testing.T) { } func TestUploadHandlerV2_TextPlain_SaveFailure_Returns500(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("os.Chmod permission bits are not supported on Windows") + } cfg := &config.Config{ AutoAccept: true, NoClipboard: true, From e479a047f78a352fa3ba8f361057e851b668ea1d Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:58:51 +0300 Subject: [PATCH 20/36] fix(daemon): stop uses liveness polling, guard existing daemon, force auto-accept in daemon child --- cmd/localgo/cmd/daemon_unix.go | 19 ++++++++++++++---- cmd/localgo/cmd/serve.go | 5 +++++ cmd/localgo/cmd/stop.go | 36 ++++++++++++++++++---------------- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/cmd/localgo/cmd/daemon_unix.go b/cmd/localgo/cmd/daemon_unix.go index 92368a0..5d6fc73 100644 --- a/cmd/localgo/cmd/daemon_unix.go +++ b/cmd/localgo/cmd/daemon_unix.go @@ -7,10 +7,25 @@ import ( "os" "os/exec" "path/filepath" + "strconv" + "strings" "syscall" ) func daemonize() error { + // Check if daemon is already running + pidPath, err := pidFilePath() + if err != nil { + return fmt.Errorf("cannot determine pid file path: %w", err) + } + if data, err := os.ReadFile(pidPath); err == nil { + if oldPid, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil { + if p, err := os.FindProcess(oldPid); err == nil && p.Signal(syscall.Signal(0)) == nil { + return fmt.Errorf("daemon already running (PID %d)", oldPid) + } + } + } + var childArgs []string for _, a := range os.Args[1:] { if a == "--daemon" || a == "-d" { @@ -29,10 +44,6 @@ func daemonize() error { return fmt.Errorf("failed to start daemon: %w", err) } - pidPath, err := pidFilePath() - if err != nil { - return fmt.Errorf("cannot determine pid file path: %w", err) - } if err := os.MkdirAll(filepath.Dir(pidPath), 0755); err != nil { return fmt.Errorf("cannot create pid directory: %w", err) } diff --git a/cmd/localgo/cmd/serve.go b/cmd/localgo/cmd/serve.go index 945a36b..551972a 100644 --- a/cmd/localgo/cmd/serve.go +++ b/cmd/localgo/cmd/serve.go @@ -64,6 +64,11 @@ var serveCmd = &cobra.Command{ if serveautoAccept { Cfg.AutoAccept = true } + // Daemon child has no terminal — force auto-accept and quiet + if os.Getenv("LOCALGO_DAEMON_CHILD") != "" { + Cfg.AutoAccept = true + Cfg.Quiet = true + } if servenoClipboard { Cfg.NoClipboard = true } diff --git a/cmd/localgo/cmd/stop.go b/cmd/localgo/cmd/stop.go index 91341a9..f27f66b 100644 --- a/cmd/localgo/cmd/stop.go +++ b/cmd/localgo/cmd/stop.go @@ -45,29 +45,31 @@ var stopCmd = &cobra.Command{ return nil } - cli.PrintInfo("Stopping LocalGo daemon (PID %d)...", pid) - if err := process.Signal(syscall.SIGTERM); err != nil { - return fmt.Errorf("failed to signal process %d: %w", pid, err) + // Check if process is alive (Signal(0) is a liveness probe) + if err := process.Signal(syscall.Signal(0)); err != nil { + os.Remove(pidPath) + cli.PrintWarning("No running LocalGo daemon found (process %d is dead)", pid) + return nil } - // Wait up to 5 seconds for graceful shutdown - done := make(chan struct{}) - go func() { - process.Wait() - close(done) - }() + cli.PrintInfo("Stopping LocalGo daemon (PID %d)...", pid) + process.Signal(syscall.SIGTERM) - select { - case <-done: - cli.PrintSuccess("LocalGo daemon stopped") - case <-time.After(5 * time.Second): - cli.PrintWarning("Daemon did not stop gracefully, sending SIGKILL...") - process.Kill() - <-done - cli.PrintSuccess("LocalGo daemon killed") + // Poll for exit up to 5 seconds + for i := 0; i < 50; i++ { + time.Sleep(100 * time.Millisecond) + if err := process.Signal(syscall.Signal(0)); err != nil { + os.Remove(pidPath) + cli.PrintSuccess("LocalGo daemon stopped") + return nil + } } + // Timeout — force kill + cli.PrintWarning("Daemon did not stop gracefully, sending SIGKILL...") + process.Kill() os.Remove(pidPath) + cli.PrintSuccess("LocalGo daemon killed") return nil }, } From 307d42f583c5130ba36b177369557138e4f429c1 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:01:01 +0300 Subject: [PATCH 21/36] fix(receive): clipboard message detection, fallback, history, and sanitization - Only short-circuit clipboard when single file with Size matching Preview length (avoids misclassifying multi-file transfers) - When NoClipboard or clipboard.Write fails, save text to DownloadDir instead of discarding it - Log clipboard transfers to history and run exec hook on accept - Sanitize clipboard preview text before rendering in confirmation prompt --- pkg/server/handlers/prompt.go | 2 +- pkg/server/handlers/receive_handlers.go | 43 +++++++++++++++++++++---- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/pkg/server/handlers/prompt.go b/pkg/server/handlers/prompt.go index d6af090..b2525b0 100644 --- a/pkg/server/handlers/prompt.go +++ b/pkg/server/handlers/prompt.go @@ -95,7 +95,7 @@ func (h *ReceiveHandler) promptForClipboard(alias, remoteAddr, message string) b truncated = truncated[:500] + "\n… (truncated)" } - desc := fmt.Sprintf("From: %s (IP: %s)\n\nClipboard:\n%s", cli.Sanitize(alias), remoteAddr, truncated) + desc := fmt.Sprintf("From: %s (IP: %s)\n\nClipboard:\n%s", cli.Sanitize(alias), remoteAddr, cli.Sanitize(truncated)) var accept bool = true form := huh.NewForm( diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index 4df0fd7..6073307 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -6,7 +6,9 @@ import ( "encoding/json" "net" "net/http" + "os" "os/exec" + "path/filepath" "runtime" "strings" "sync" @@ -94,19 +96,28 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R return } + // Extract IP from RemoteAddr early (used by clipboard path and elsewhere) + senderIP, _, _ := net.SplitHostPort(r.RemoteAddr) + // --- Clipboard Message Detection --- // The official LocalSend embeds clipboard text in the Preview field. - // When detected, no file upload is needed — the content is already here. + // Only short-circuit when it's a single clipboard message (full content + // already present, Size matches Preview length). Fall through to the + // normal upload path otherwise. var clipboardMessage string - for _, f := range requestDto.Files { + var clipboardFileID string + for id, f := range requestDto.Files { if f.Preview != nil && *f.Preview != "" && strings.HasPrefix(f.FileType, "text/plain") { - clipboardMessage = *f.Preview + if len(requestDto.Files) == 1 && f.Size == int64(len(*f.Preview)) { + clipboardMessage = *f.Preview + clipboardFileID = id + } break } } if clipboardMessage != "" { - h.logger.Infof("Clipboard message from %s accepted and copied", cli.Sanitize(requestDto.Info.Alias)) + h.logger.Infof("Clipboard message from %s", cli.Sanitize(requestDto.Info.Alias)) if !h.config.AutoAccept { h.promptMutex.Lock() accepted := h.promptForClipboard(cli.Sanitize(requestDto.Info.Alias), r.RemoteAddr, clipboardMessage) @@ -116,9 +127,29 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R return } } + if !h.config.NoClipboard { - clipboard.Write(clipboardMessage) + if err := clipboard.Write(clipboardMessage); err != nil { + h.logger.Warnf("Clipboard write failed (%v), saving text as file instead", err) + } else { + h.logger.Infof("Clipboard message from %s accepted and copied", cli.Sanitize(requestDto.Info.Alias)) + h.logTransfer(requestDto.Info.Alias, senderIP, clipboardFileID, "", int64(len(clipboardMessage)), "text/plain", history.StatusClipboard) + h.runExecHook("", clipboardFileID, requestDto.Info.Alias, senderIP, int64(len(clipboardMessage))) + w.WriteHeader(http.StatusNoContent) + return + } + } + + // Fallback: save as file (NoClipboard mode or clipboard write failed) + clipboardPath := filepath.Join(h.config.DownloadDir, "clipboard.txt") + if err := os.WriteFile(clipboardPath, []byte(clipboardMessage), 0644); err != nil { + h.logger.Errorf("Failed to save clipboard text to %s: %v", clipboardPath, err) + httputil.RespondError(w, http.StatusInternalServerError, "Failed to save clipboard") + return } + h.logger.Infof("Clipboard message from %s saved to %s", cli.Sanitize(requestDto.Info.Alias), clipboardPath) + h.logTransfer(requestDto.Info.Alias, senderIP, clipboardFileID, clipboardPath, int64(len(clipboardMessage)), "text/plain", history.StatusClipboard) + h.runExecHook(clipboardPath, clipboardFileID, requestDto.Info.Alias, senderIP, int64(len(clipboardMessage))) w.WriteHeader(http.StatusNoContent) return } @@ -147,8 +178,6 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R h.logger.Infof("PrepareUpload request from %s (%s) for %d files:", cli.Sanitize(requestDto.Info.Alias), r.RemoteAddr, len(requestDto.Files)) - // Extract IP from RemoteAddr - senderIP, _, _ := net.SplitHostPort(r.RemoteAddr) sender := model.DeviceInfo{ Alias: cli.Sanitize(requestDto.Info.Alias), Version: requestDto.Info.Version, From f06b816ce06b4aeb14cec9ea4dfa5d847e8f0189 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:02:15 +0300 Subject: [PATCH 22/36] fix(clipboard): use cmd.Output() for read, guard whitespace-only config values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Read() uses cmd.Output() (stdout only) instead of CombinedOutput() so stderr diagnostics aren't mixed into clipboard text on success - OverrideProvider guards wp[0]/rp[0] access after strings.Fields with length check — whitespace-only input no longer panics - exec.go Shell parsing similarly guarded against empty fields --- pkg/clipboard/clipboard.go | 25 +++++++++++++++---------- pkg/server/handlers/exec.go | 8 +++++--- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/pkg/clipboard/clipboard.go b/pkg/clipboard/clipboard.go index 98d5d32..602b80c 100644 --- a/pkg/clipboard/clipboard.go +++ b/pkg/clipboard/clipboard.go @@ -47,14 +47,14 @@ func Read() (string, error) { return "", fmt.Errorf("clipboard read unavailable: no supported tool found (install xclip, xsel, wl-paste, pbpaste, or Get-Clipboard)") } cmd := exec.Command(provider.readCmd, provider.readArgs...) //nolint:gosec - out, err := cmd.CombinedOutput() + out, err := cmd.Output() if err != nil { // Some tools (xclip, wl-paste) exit with 1 when the clipboard is empty // and produce no output. Treat this as empty, not an error. if len(out) == 0 { return "", nil } - return "", fmt.Errorf("clipboard read failed (%s): %w: %s", provider.readCmd, err, strings.TrimSpace(string(out))) + return "", fmt.Errorf("clipboard read failed (%s): %w", provider.readCmd, err) } // Normalize Windows CRLF line endings to unix LF return strings.ReplaceAll(string(out), "\r\n", "\n"), nil @@ -67,24 +67,29 @@ func Available() bool { // OverrideProvider replaces the auto-detected clipboard tool with custom commands. // Empty strings are ignored (auto-detected tool kept for that direction, if any). +// Returns an error if the command string is non-empty but yields no tokens. func OverrideProvider(writeCmd, readCmd string) { if writeCmd == "" && readCmd == "" { return } p := &clipProvider{} if writeCmd != "" { - wp := strings.Fields(writeCmd) - p.cmd = wp[0] - p.args = wp[1:] - } else if provider != nil { + if wp := strings.Fields(writeCmd); len(wp) > 0 { + p.cmd = wp[0] + p.args = wp[1:] + } + } + if p.cmd == "" && provider != nil { p.cmd = provider.cmd p.args = provider.args } if readCmd != "" { - rp := strings.Fields(readCmd) - p.readCmd = rp[0] - p.readArgs = rp[1:] - } else if provider != nil { + if rp := strings.Fields(readCmd); len(rp) > 0 { + p.readCmd = rp[0] + p.readArgs = rp[1:] + } + } + if p.readCmd == "" && provider != nil { p.readCmd = provider.readCmd p.readArgs = provider.readArgs } diff --git a/pkg/server/handlers/exec.go b/pkg/server/handlers/exec.go index 994314d..45b49fd 100644 --- a/pkg/server/handlers/exec.go +++ b/pkg/server/handlers/exec.go @@ -17,9 +17,11 @@ func (h *ReceiveHandler) runExecHook(filePath, fileName, senderAlias, senderIP s h.logger.Infof("Running exec hook: %s", h.config.ExecHook) var cmd *exec.Cmd if h.config.Shell != "" { - parts := strings.Fields(h.config.Shell) - cmd = exec.Command(parts[0], append(parts[1:], h.config.ExecHook)...) - } else if runtime.GOOS == "windows" { + if parts := strings.Fields(h.config.Shell); len(parts) > 0 { + cmd = exec.Command(parts[0], append(parts[1:], h.config.ExecHook)...) + } + } + if cmd == nil && runtime.GOOS == "windows" { cmd = exec.Command("cmd", "/c", h.config.ExecHook) } else { cmd = exec.Command("sh", "-c", h.config.ExecHook) From 856cc6326d5101f8013c19737115d648299db315 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:02:57 +0300 Subject: [PATCH 23/36] fix(network): correct subnet capping threshold and error message - Changed hostBits > 22 to hostBits > 10 so /16 (16 host bits) and other large subnets are capped at /22 (max 1022 usable hosts) - Fixed error message from 'too large' to 'too small' for tiny prefixes (/31, /32) that have fewer than 2 usable hosts --- pkg/network/interfaces.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/network/interfaces.go b/pkg/network/interfaces.go index 4d8449f..129aa53 100644 --- a/pkg/network/interfaces.go +++ b/pkg/network/interfaces.go @@ -191,8 +191,9 @@ func GetInterfaceIPNet(ifaceName string) (*net.IPNet, error) { } // GetUsableSubnetIPs returns all usable host IPs in the subnet of the named -// interface, respecting its actual netmask. Subnets larger than /22 are capped -// at /22 to keep scanning practical. Network and broadcast addresses are excluded. +// interface, respecting its actual netmask. Subnets with more than 1022 hosts +// (larger than /22) are capped at /22 to keep scanning practical. Network and +// broadcast addresses are excluded. func GetUsableSubnetIPs(ifaceName string) ([]net.IP, error) { ipnet, err := GetInterfaceIPNet(ifaceName) if err != nil { @@ -207,15 +208,15 @@ func GetUsableSubnetIPs(ifaceName string) ([]net.IP, error) { ones, bits := ipnet.Mask.Size() hostBits := bits - ones - // Cap at /22 for practical scanning + // Cap at /22 for practical scanning (max 1022 usable hosts) effectiveMask := ipnet.Mask - if hostBits > 22 { + if hostBits > 10 { effectiveMask = net.CIDRMask(22, bits) hostBits = bits - 22 } if hostBits < 2 { - return nil, fmt.Errorf("interface %q subnet prefix /%d is too large for scanning", ifaceName, bits-hostBits) + return nil, fmt.Errorf("interface %q subnet prefix /%d is too small for scanning", ifaceName, bits-hostBits) } maskBits := []byte{effectiveMask[0], effectiveMask[1], effectiveMask[2], effectiveMask[3]} From 14c84b4a3875b38d677ec6766e2693d01cc44ec5 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:04:10 +0300 Subject: [PATCH 24/36] fix(tls): compute and publish fingerprint from custom TLS certificate When CustomTLSCertPath/CustomTLSKeyPath are set, the server now parses the leaf certificate, computes its SHA-256 fingerprint, and stores it via Config.SetCustomFingerprint(). GetFingerprint() prefers this over the auto-generated SecurityContext hash, so advertised fingerprints match the actual presented certificate for trust verification. --- pkg/config/config.go | 7 +++++++ pkg/config/dto.go | 3 +++ pkg/server/server.go | 10 ++++++++++ 3 files changed, 20 insertions(+) diff --git a/pkg/config/config.go b/pkg/config/config.go index 1312fd7..a7bfaa9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -52,6 +52,13 @@ type Config struct { CustomTLSCertPath string `json:"-"` // path to custom TLS certificate file CustomTLSKeyPath string `json:"-"` // path to custom TLS private key file NotificationCmd string `json:"-"` // custom notification command + customFingerprint string `json:"-"` // fingerprint computed from custom TLS cert +} + +// SetCustomFingerprint overrides the advertised fingerprint with one computed +// from a user-supplied TLS certificate. +func (c *Config) SetCustomFingerprint(fp string) { + c.customFingerprint = fp } // getSecurityDir determines the best location for the security directory diff --git a/pkg/config/dto.go b/pkg/config/dto.go index a9fe8d2..7a7ed49 100644 --- a/pkg/config/dto.go +++ b/pkg/config/dto.go @@ -12,6 +12,9 @@ func (c *Config) Protocol() model.ProtocolType { // GetFingerprint returns the appropriate fingerprint (certificate hash if HTTPS, random otherwise). func (c *Config) GetFingerprint() string { + if c.customFingerprint != "" { + return c.customFingerprint + } if c.HttpsEnabled { return c.SecurityContext.CertificateHash } diff --git a/pkg/server/server.go b/pkg/server/server.go index 7119cce..f5d44cd 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -3,7 +3,10 @@ package server import ( "context" + "crypto/sha256" "crypto/tls" + "crypto/x509" + "encoding/hex" "errors" "fmt" "net" @@ -165,6 +168,13 @@ func (s *Server) Start(ctx context.Context, readyChan chan<- struct{}) error { var err error if s.config.CustomTLSCertPath != "" && s.config.CustomTLSKeyPath != "" { cert, err = tls.LoadX509KeyPair(s.config.CustomTLSCertPath, s.config.CustomTLSKeyPath) + if err == nil && len(cert.Certificate) > 0 { + if leaf, parseErr := x509.ParseCertificate(cert.Certificate[0]); parseErr == nil { + hash := sha256.Sum256(leaf.Raw) + s.config.SetCustomFingerprint(hex.EncodeToString(hash[:])) + s.logger.Infof("Using custom TLS certificate, fingerprint: %.16s...", hex.EncodeToString(hash[:])) + } + } } else { cert, err = tls.X509KeyPair([]byte(s.config.SecurityContext.Certificate), []byte(s.config.SecurityContext.PrivateKey)) } From 30a8fbd0cf2b08457eb94889a1ad89f44dc46ee9 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:05:18 +0300 Subject: [PATCH 25/36] fix(send): count in-memory payloads in --ip progress header - Use len(files) + len(sendOpts) instead of len(files) for total count - Display clipboard/stdin entries as '(in-memory)' in the file listing --- cmd/localgo/cmd/send.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index 2e8f8ca..ffc9a43 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -125,13 +125,20 @@ var sendCmd = &cobra.Command{ Cfg.Concurrency = sendconcurrency } - cli.PrintHeader(fmt.Sprintf("Sending %d files", len(files))) + totalFiles := len(files) + len(sendOpts) + cli.PrintHeader(fmt.Sprintf("Sending %d file(s)", totalFiles)) for _, file := range files { fileInfo, err := os.Stat(file) if err == nil { cli.PrintInfo("- %s (%s)", filepath.Base(file), cli.FormatBytes(fileInfo.Size())) } } + if sendclipboard { + cli.PrintInfo("- clipboard (in-memory)") + } + if sendstdin { + cli.PrintInfo("- stdin (in-memory)") + } cli.PrintInfo("To: %s:%d", host, port) fromAlias := Cfg.Alias if Cfg.Private { From d6850504b11623c1e15b52cdbe0ecfedf0382044 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:28:55 +0300 Subject: [PATCH 26/36] fix(review): daemon PID lifecycle, clipboard fallback safety, timer resource leak - Extract pidFilePath() into shared pid.go for reuse - Add defer os.Remove(pidPath) in daemon child path (serve.go) - Split stop.go into stop_unix.go (POSIX signals + 5s poll) and stop_windows.go (Kill-based) to fix platform incompatibility - Use storage.ResolveDuplicateFilename for clipboard fallback saves to prevent silent overwrite; restrict to 0600 permissions - defer body.Close() after NewIdleTimeoutReader to ensure timer is stopped on early-return error paths (upload.go) --- cmd/localgo/cmd/pid.go | 15 ++++++++ cmd/localgo/cmd/serve.go | 9 +++++ cmd/localgo/cmd/stop.go | 45 +---------------------- cmd/localgo/cmd/stop_unix.go | 47 +++++++++++++++++++++++++ cmd/localgo/cmd/stop_windows.go | 27 ++++++++++++++ pkg/send/upload.go | 1 + pkg/server/handlers/receive_handlers.go | 5 ++- 7 files changed, 102 insertions(+), 47 deletions(-) create mode 100644 cmd/localgo/cmd/pid.go create mode 100644 cmd/localgo/cmd/stop_unix.go create mode 100644 cmd/localgo/cmd/stop_windows.go diff --git a/cmd/localgo/cmd/pid.go b/cmd/localgo/cmd/pid.go new file mode 100644 index 0000000..38ab5c2 --- /dev/null +++ b/cmd/localgo/cmd/pid.go @@ -0,0 +1,15 @@ +package cmd + +import ( + "os" + "path/filepath" +) + +// pidFilePath returns the absolute path to the daemon PID file. +func pidFilePath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, "localgo", "localgo.pid"), nil +} diff --git a/cmd/localgo/cmd/serve.go b/cmd/localgo/cmd/serve.go index 551972a..a3f3560 100644 --- a/cmd/localgo/cmd/serve.go +++ b/cmd/localgo/cmd/serve.go @@ -45,6 +45,15 @@ var serveCmd = &cobra.Command{ return daemonize() } + // Daemon child: ensure PID file is cleaned up when server exits + if os.Getenv("LOCALGO_DAEMON_CHILD") == "1" { + defer func() { + if pidPath, err := pidFilePath(); err == nil { + _ = os.Remove(pidPath) + } + }() + } + // Apply overrides if serveport > 0 { Cfg.Port = serveport diff --git a/cmd/localgo/cmd/stop.go b/cmd/localgo/cmd/stop.go index f27f66b..4a04772 100644 --- a/cmd/localgo/cmd/stop.go +++ b/cmd/localgo/cmd/stop.go @@ -3,11 +3,8 @@ package cmd import ( "fmt" "os" - "path/filepath" "strconv" "strings" - "syscall" - "time" "github.com/bethropolis/localgo/pkg/cli" "github.com/bethropolis/localgo/pkg/help" @@ -38,50 +35,10 @@ var stopCmd = &cobra.Command{ return fmt.Errorf("invalid PID in %s: %q", pidPath, pidStr) } - process, err := os.FindProcess(pid) - if err != nil { - os.Remove(pidPath) - cli.PrintWarning("No running LocalGo daemon found (process %d not found)", pid) - return nil - } - - // Check if process is alive (Signal(0) is a liveness probe) - if err := process.Signal(syscall.Signal(0)); err != nil { - os.Remove(pidPath) - cli.PrintWarning("No running LocalGo daemon found (process %d is dead)", pid) - return nil - } - - cli.PrintInfo("Stopping LocalGo daemon (PID %d)...", pid) - process.Signal(syscall.SIGTERM) - - // Poll for exit up to 5 seconds - for i := 0; i < 50; i++ { - time.Sleep(100 * time.Millisecond) - if err := process.Signal(syscall.Signal(0)); err != nil { - os.Remove(pidPath) - cli.PrintSuccess("LocalGo daemon stopped") - return nil - } - } - - // Timeout — force kill - cli.PrintWarning("Daemon did not stop gracefully, sending SIGKILL...") - process.Kill() - os.Remove(pidPath) - cli.PrintSuccess("LocalGo daemon killed") - return nil + return stopDaemonProcess(pid, pidPath) }, } -func pidFilePath() (string, error) { - configDir, err := os.UserConfigDir() - if err != nil { - return "", err - } - return filepath.Join(configDir, "localgo", "localgo.pid"), nil -} - func init() { rootCmd.AddCommand(stopCmd) stopCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { diff --git a/cmd/localgo/cmd/stop_unix.go b/cmd/localgo/cmd/stop_unix.go new file mode 100644 index 0000000..6d08a4d --- /dev/null +++ b/cmd/localgo/cmd/stop_unix.go @@ -0,0 +1,47 @@ +//go:build !windows + +package cmd + +import ( + "os" + "syscall" + "time" + + "github.com/bethropolis/localgo/pkg/cli" +) + +func stopDaemonProcess(pid int, pidPath string) error { + process, err := os.FindProcess(pid) + if err != nil { + os.Remove(pidPath) + cli.PrintWarning("No running LocalGo daemon found (process %d not found)", pid) + return nil + } + + // Check if process is alive (Signal(0) is a liveness probe) + if err := process.Signal(syscall.Signal(0)); err != nil { + os.Remove(pidPath) + cli.PrintWarning("No running LocalGo daemon found (process %d is dead)", pid) + return nil + } + + cli.PrintInfo("Stopping LocalGo daemon (PID %d)...", pid) + _ = process.Signal(syscall.SIGTERM) + + // Poll for exit up to 5 seconds + for i := 0; i < 50; i++ { + time.Sleep(100 * time.Millisecond) + if err := process.Signal(syscall.Signal(0)); err != nil { + os.Remove(pidPath) + cli.PrintSuccess("LocalGo daemon stopped") + return nil + } + } + + // Timeout — force kill + cli.PrintWarning("Daemon did not stop gracefully, sending SIGKILL...") + _ = process.Kill() + os.Remove(pidPath) + cli.PrintSuccess("LocalGo daemon killed") + return nil +} diff --git a/cmd/localgo/cmd/stop_windows.go b/cmd/localgo/cmd/stop_windows.go new file mode 100644 index 0000000..13d35c1 --- /dev/null +++ b/cmd/localgo/cmd/stop_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package cmd + +import ( + "os" + + "github.com/bethropolis/localgo/pkg/cli" +) + +func stopDaemonProcess(pid int, pidPath string) error { + process, err := os.FindProcess(pid) + if err != nil { + os.Remove(pidPath) + cli.PrintWarning("No running LocalGo daemon found (process %d not found)", pid) + return nil + } + + cli.PrintInfo("Stopping LocalGo daemon (PID %d)...", pid) + if err := process.Kill(); err != nil { + cli.PrintWarning("Failed to kill daemon process (PID %d): %v", pid, err) + } else { + cli.PrintSuccess("LocalGo daemon stopped") + } + os.Remove(pidPath) + return nil +} diff --git a/pkg/send/upload.go b/pkg/send/upload.go index a23145a..83ad4c4 100644 --- a/pkg/send/upload.go +++ b/pkg/send/upload.go @@ -65,6 +65,7 @@ func uploadStream(ctx context.Context, client *http.Client, device *model.Device uploadCtx, cancel := context.WithCancel(ctx) defer cancel() body = NewIdleTimeoutReader(body, 15*time.Second, cancel) + defer body.Close() req, err := http.NewRequestWithContext(uploadCtx, http.MethodPost, url, body) if err != nil { diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index 6073307..2fe520d 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -8,7 +8,6 @@ import ( "net/http" "os" "os/exec" - "path/filepath" "runtime" "strings" "sync" @@ -141,8 +140,8 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R } // Fallback: save as file (NoClipboard mode or clipboard write failed) - clipboardPath := filepath.Join(h.config.DownloadDir, "clipboard.txt") - if err := os.WriteFile(clipboardPath, []byte(clipboardMessage), 0644); err != nil { + clipboardPath := storage.ResolveDuplicateFilename(h.config.DownloadDir, "clipboard.txt") + if err := os.WriteFile(clipboardPath, []byte(clipboardMessage), 0600); err != nil { h.logger.Errorf("Failed to save clipboard text to %s: %v", clipboardPath, err) httputil.RespondError(w, http.StatusInternalServerError, "Failed to save clipboard") return From ce9394cb1ad7299ea0a7315b3e5e7a0af7caa190 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:34:24 +0300 Subject: [PATCH 27/36] fix(exec): replace %-placeholders in exec hook string; fix(stop): handle Windows FindProcess behavior - Replace %f/%n/%s/%a/%i placeholders in ExecHook before passing to the shell (prevents literal %n etc. from being passed as-is). - stop_windows.go: os.FindProcess always returns nil err on Windows. Remove dead error branch; print clean warning when Kill fails. --- cmd/localgo/cmd/stop_windows.go | 13 +++++-------- pkg/server/handlers/exec.go | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/cmd/localgo/cmd/stop_windows.go b/cmd/localgo/cmd/stop_windows.go index 13d35c1..ebc3c92 100644 --- a/cmd/localgo/cmd/stop_windows.go +++ b/cmd/localgo/cmd/stop_windows.go @@ -9,16 +9,13 @@ import ( ) func stopDaemonProcess(pid int, pidPath string) error { - process, err := os.FindProcess(pid) - if err != nil { - os.Remove(pidPath) - cli.PrintWarning("No running LocalGo daemon found (process %d not found)", pid) - return nil - } - cli.PrintInfo("Stopping LocalGo daemon (PID %d)...", pid) + + // On Windows, os.FindProcess always returns a handle even for dead PIDs, + // so we skip the liveness probe and go straight to Kill. + process, _ := os.FindProcess(pid) if err := process.Kill(); err != nil { - cli.PrintWarning("Failed to kill daemon process (PID %d): %v", pid, err) + cli.PrintWarning("No running LocalGo daemon found with PID %d", pid) } else { cli.PrintSuccess("LocalGo daemon stopped") } diff --git a/pkg/server/handlers/exec.go b/pkg/server/handlers/exec.go index 45b49fd..fcd3536 100644 --- a/pkg/server/handlers/exec.go +++ b/pkg/server/handlers/exec.go @@ -13,18 +13,26 @@ func (h *ReceiveHandler) runExecHook(filePath, fileName, senderAlias, senderIP s return } + // Replace %-placeholders before passing to the shell + hook := h.config.ExecHook + hook = strings.ReplaceAll(hook, "%f", filePath) + hook = strings.ReplaceAll(hook, "%n", fileName) + hook = strings.ReplaceAll(hook, "%s", fmt.Sprintf("%d", fileSize)) + hook = strings.ReplaceAll(hook, "%a", senderAlias) + hook = strings.ReplaceAll(hook, "%i", senderIP) + go func() { - h.logger.Infof("Running exec hook: %s", h.config.ExecHook) + h.logger.Infof("Running exec hook: %s", hook) var cmd *exec.Cmd if h.config.Shell != "" { if parts := strings.Fields(h.config.Shell); len(parts) > 0 { - cmd = exec.Command(parts[0], append(parts[1:], h.config.ExecHook)...) + cmd = exec.Command(parts[0], append(parts[1:], hook)...) } } if cmd == nil && runtime.GOOS == "windows" { - cmd = exec.Command("cmd", "/c", h.config.ExecHook) + cmd = exec.Command("cmd", "/c", hook) } else { - cmd = exec.Command("sh", "-c", h.config.ExecHook) + cmd = exec.Command("sh", "-c", hook) } cmd.Env = append(os.Environ(), "LOCALGO_FILE="+filePath, From ca065eb0c48e7da79ed2d9a21e01f6b2b960d7ed Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:34:57 +0300 Subject: [PATCH 28/36] feat(send): resolve hostnames and mDNS names in --ip flag When --ip receives a hostname (e.g. myphone.local, desktop-pc) instead of a raw IP, fall back to net.LookupIP for resolution. Preserves the existing raw-IP fast path. --- cmd/localgo/cmd/send.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index ffc9a43..099cbdb 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -97,7 +97,16 @@ var sendCmd = &cobra.Command{ } parsedIP := net.ParseIP(host) if parsedIP == nil { - return fmt.Errorf("invalid IP address: %s", host) + // Not a raw IP — try hostname resolution (mDNS, DNS, etc.) + ips, err := net.LookupIP(host) + if err != nil || len(ips) == 0 { + return fmt.Errorf("invalid IP address or unresolvable hostname: %s", host) + } + parsedIP = ips[0].To4() + if parsedIP == nil { + // Use first result even if it's IPv6; the caller handles it + parsedIP = ips[0] + } } port := sendport From 1bfdec7a50a42a30e1c898534d6f407b2e2644c6 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:35:41 +0300 Subject: [PATCH 29/36] fix(discovery): update IP/Port/Alias/Protocol on peer rediscovery When a known device re-announces with different metadata (DHCP renewal, alias change), update the existing map entry instead of only refreshing LastSeen. The peer cache was already receiving the new device object. --- pkg/discovery/multicast.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/discovery/multicast.go b/pkg/discovery/multicast.go index d9ee982..d9584e8 100644 --- a/pkg/discovery/multicast.go +++ b/pkg/discovery/multicast.go @@ -143,6 +143,10 @@ func (md *MulticastDiscovery) updateDevice(device *model.Device) { key := device.Fingerprint existingDevice, exists := md.devices[key] if exists { + existingDevice.IP = device.IP + existingDevice.Port = device.Port + existingDevice.Alias = device.Alias + existingDevice.Protocol = device.Protocol existingDevice.UpdateLastSeen() } else { md.devices[key] = device From d5923d8f2ece16ba8a54ab996c9b9dca2a49a647 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:36:31 +0300 Subject: [PATCH 30/36] feat(network): smart subnet scanning via interface netmask Add GetUsableSubnetIPsFromIP which finds the interface owning a given IP and returns its actual subnet via GetUsableSubnetIPs (capped at /22). Falls back to the legacy /24 scan when the interface cannot be determined. Replaced all 4 call sites (scan.go, send.go, discover.go, pkg/send/send.go) that were hardcoding /24 via GetSubnetIPs. --- .gitignore | 3 ++- cmd/localgo/cmd/discover.go | 5 ++++- cmd/localgo/cmd/scan.go | 6 ++++-- cmd/localgo/cmd/send.go | 5 ++++- pkg/network/interfaces.go | 28 ++++++++++++++++++++++++++++ pkg/send/send.go | 6 ++++-- 6 files changed, 46 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 0ae7bc3..4a20521 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,8 @@ Thumbs.db coverage.* -/config +/mise.toml +config test dist/ .coverage/ diff --git a/cmd/localgo/cmd/discover.go b/cmd/localgo/cmd/discover.go index 3f0e647..39724f0 100644 --- a/cmd/localgo/cmd/discover.go +++ b/cmd/localgo/cmd/discover.go @@ -99,7 +99,10 @@ var discoverCmd = &cobra.Command{ if ipErr == nil && len(localIPs) > 0 { var scanIps []net.IP for _, ip := range localIPs { - scanIps = append(scanIps, network.GetSubnetIPs(ip)...) + subnetIPs, err := network.GetUsableSubnetIPsFromIP(ip) + if err == nil { + scanIps = append(scanIps, subnetIPs...) + } } registerDto := Cfg.ToRegisterDto() httpDiscoverer := discovery.NewHTTPDiscovery(nil, registerDto, nil, zap.S()) diff --git a/cmd/localgo/cmd/scan.go b/cmd/localgo/cmd/scan.go index 4e10911..adcbd76 100644 --- a/cmd/localgo/cmd/scan.go +++ b/cmd/localgo/cmd/scan.go @@ -66,8 +66,10 @@ var scanCmd = &cobra.Command{ } for _, ip := range localIPs { - subnetIPs := network.GetSubnetIPs(ip) - ips = append(ips, subnetIPs...) + subnetIPs, err := network.GetUsableSubnetIPsFromIP(ip) + if err == nil { + ips = append(ips, subnetIPs...) + } } if !scanquiet { diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index 099cbdb..4d84ed2 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -214,7 +214,10 @@ var sendCmd = &cobra.Command{ var ips []net.IP for _, ip := range localIPs { - ips = append(ips, network.GetSubnetIPs(ip)...) + subnetIPs, err := network.GetUsableSubnetIPsFromIP(ip) + if err == nil { + ips = append(ips, subnetIPs...) + } } ips = append(ips, net.ParseIP("127.0.0.1")) diff --git a/pkg/network/interfaces.go b/pkg/network/interfaces.go index 129aa53..d2b3902 100644 --- a/pkg/network/interfaces.go +++ b/pkg/network/interfaces.go @@ -190,6 +190,34 @@ func GetInterfaceIPNet(ifaceName string) (*net.IPNet, error) { return nil, fmt.Errorf("interface %q has no IPv4 address", ifaceName) } +// GetUsableSubnetIPsFromIP returns all usable host IPs in the subnet of the +// interface that owns the given IP, respecting its actual netmask. Falls back +// to a flat /24 scan if the interface cannot be determined. +func GetUsableSubnetIPsFromIP(ip net.IP) ([]net.IP, error) { + ipStr := ip.String() + ifaces, err := net.Interfaces() + if err != nil { + return GetSubnetIPs(ip), nil + } + for _, i := range ifaces { + if (i.Flags&net.FlagUp) == 0 || (i.Flags&net.FlagLoopback) != 0 { + continue + } + addrs, err := i.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok { + if ipnet.IP.To4() != nil && ipnet.IP.String() == ipStr { + return GetUsableSubnetIPs(i.Name) + } + } + } + } + return GetSubnetIPs(ip), nil +} + // GetUsableSubnetIPs returns all usable host IPs in the subnet of the named // interface, respecting its actual netmask. Subnets with more than 1022 hosts // (larger than /22) are capped at /22 to keep scanning practical. Network and diff --git a/pkg/send/send.go b/pkg/send/send.go index f9f668a..6e8a8fb 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -124,8 +124,10 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci var ips []net.IP for _, ip := range localIPs { - subnetIPs := network.GetSubnetIPs(ip) - ips = append(ips, subnetIPs...) + subnetIPs, err := network.GetUsableSubnetIPsFromIP(ip) + if err == nil { + ips = append(ips, subnetIPs...) + } } ips = append(ips, net.ParseIP("127.0.0.1")) From 0a2258488bbd8019c6d21b34534b1b1862d61744 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:40:34 +0300 Subject: [PATCH 31/36] fix(review): guard ANSI clears with TTY check, normalize cross-OS paths, fix Windows clipboard UTF-8 - progress.go: guard \033[F\033[K clearing with term.IsTerminal() to avoid raw escape sequences in non-TTY stderr (Docker, shell redirects) - receive_upload.go: normalize incoming filenames with filepath.ToSlash() so Windows backslashes form correct subdirectories on Unix receivers - clipboard_windows.go: switch write from clip.exe to PowerShell Set-Clipboard via stdin pipeline for proper Unicode/UTF-8 handling - Add golang.org/x/term as direct dependency for portable TTY detection --- go.mod | 3 ++- go.sum | 6 ++++-- pkg/cli/progress.go | 10 +++++++--- pkg/clipboard/clipboard_windows.go | 15 ++++++++++++--- pkg/server/handlers/receive_upload.go | 4 +++- 5 files changed, 28 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index e3c3e9f..dcc4029 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,8 @@ require ( 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.45.0 + golang.org/x/sys v0.47.0 + golang.org/x/term v0.45.0 ) require ( diff --git a/go.sum b/go.sum index 36ed1ac..57b7755 100644 --- a/go.sum +++ b/go.sum @@ -179,8 +179,10 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc 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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/cli/progress.go b/pkg/cli/progress.go index 877d9ed..44c13d5 100644 --- a/pkg/cli/progress.go +++ b/pkg/cli/progress.go @@ -5,6 +5,8 @@ import ( "os" "sync" + "golang.org/x/term" + "github.com/vbauerster/mpb/v7" "github.com/vbauerster/mpb/v7/decor" ) @@ -68,9 +70,11 @@ func (mp *MultiProgress) Wait() { 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") + // Clear progress bar lines only when stderr is a terminal + if term.IsTerminal(int(os.Stderr.Fd())) { + 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/clipboard/clipboard_windows.go b/pkg/clipboard/clipboard_windows.go index 3587688..0187eb5 100644 --- a/pkg/clipboard/clipboard_windows.go +++ b/pkg/clipboard/clipboard_windows.go @@ -4,15 +4,24 @@ package clipboard import "os/exec" -// detect probes for clip.exe, which ships with every Windows installation. +// detect probes for PowerShell (Set-Clipboard) and clip.exe fallback. +// PowerShell handles Unicode/UTF-8 correctly; clip.exe with stdin piping +// can mangle non-ASCII characters. func detect() *clipProvider { - if lookPath("clip") { + if lookPath("powershell") { return &clipProvider{ - cmd: "clip", + cmd: "powershell", + args: []string{"-NoProfile", "-Command", "$input | Set-Clipboard"}, readCmd: "powershell", readArgs: []string{"-NoProfile", "-Command", "Get-Clipboard"}, } } + if lookPath("clip") { + return &clipProvider{ + cmd: "clip", + readCmd: "", + } + } return nil } diff --git a/pkg/server/handlers/receive_upload.go b/pkg/server/handlers/receive_upload.go index e3bfdea..ececde7 100644 --- a/pkg/server/handlers/receive_upload.go +++ b/pkg/server/handlers/receive_upload.go @@ -64,7 +64,9 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) } // --- File Saving --- - rawFileName := dto.FileName + // Normalize incoming filenames: convert Windows backslashes to forward + // slashes so cross-OS directory transfers create correct subdirectories. + rawFileName := filepath.ToSlash(dto.FileName) destinationPath := storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName) // Path traversal prevention: ensure the resolved path is still within DownloadDir From d4a2fb3b67f0eb579b2c92aaefad0d4e766f09fe Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:50:38 +0300 Subject: [PATCH 32/36] docs(cli): remove duplicated history/version/completion sections --- README.md | 35 ++++++- docs/CLI_REFERENCE.md | 209 ++++++++++++++++++++++++++++++++++----- docs/CODE_WALKTHROUGH.md | 80 +++++++++------ docs/CONFIGURATION.md | 63 +++++++++--- docs/LIBRARY_GUIDE.md | 47 +++++---- 5 files changed, 346 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 82df4d3..b9191e5 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,12 @@ A Go implementation of the LocalSend v2.1 protocol for secure, cross-platform fi ### Installation -#### Online (macOS, Linux) +#### Quick install (macOS, Linux) ```bash curl -fsSL https://bethropolis.github.io/localgo/install.sh | bash ``` -#### User installation (recommended) +#### User installation ```bash # clone repo git clone https://github.com/bethropolis/localgo.git @@ -69,6 +69,17 @@ scoop bucket add bethropolis https://github.com/bethropolis/scoop-bucket scoop install localgo ``` +#### using docker / podman +```bash +mkdir -p localgo/downloads localgo/config +docker pull ghcr.io/bethropolis/localgo:latest +docker run -d \ + -p 53317:53317 \ + -v ./localgo/config:/app/config \ + -v ./localgo/downloads:/app/downloads \ + ghcr.io/bethropolis/localgo:latest +``` + > [!NOTE] > more install options in [installation documentation](docs/GETTING_STARTED.md) @@ -99,7 +110,7 @@ localgo share --file document.pdf ### Docker and Podman -For full details — deployment, macvlan networking, read-only root filesystem, watchtower, and more — see the [container documentation](docs/CONTAINER.md). +For full details on deployment, macvlan networking, read-only root filesystem, watchtower, and more, see the [container documentation](docs/CONTAINER.md). ## Configuration @@ -117,6 +128,17 @@ For full details — deployment, macvlan networking, read-only root filesystem, | `LOCALSEND_AUTO_ACCEPT` | false | Auto-accept incoming files without prompting | | `LOCALSEND_NO_CLIPBOARD` | false | Save incoming text as a file instead of clipboard | | `LOCALSEND_LOG_LEVEL` | info | Log verbosity (debug/info/warn/error) | +| `LOCALSEND_HISTORY` | (auto) | Path to transfer history file | +| `LOCALSEND_EXEC` | — | Shell command to run after each received file | +| `LOCALSEND_QUIET` | false | Minimal output mode | +| `LOCALSEND_CONCURRENCY` | 4 | Max parallel upload workers | +| `LOCALSEND_MULTICAST_INTERFACE` | (all) | Network interface for multicast | +| `LOCALSEND_SHELL` | (auto) | Shell prefix for exec hooks | +| `LOCALSEND_TLS_CERT` | — | Custom TLS certificate path | +| `LOCALSEND_TLS_KEY` | — | Custom TLS private key path | +| `LOCALSEND_NOTIFICATION_CMD` | (auto) | Custom notification command | +| `LOCALSEND_MAX_BODY_SIZE` | 0 | Max request body size (0 = unlimited) | +| `LOCALSEND_SECURITY_DIR` | (auto) | Security context directory | ### Example @@ -136,9 +158,12 @@ localgo serve | `discover` | Find devices via multicast | | `scan` | Find devices via HTTP scan | | `send` | Send files to a device | -| `history`| Show file transfer history log | | `info` | Show device information | | `devices` | List discovered devices | +| `history` | Show transfer history log | +| `stop` | Stop a running daemon | +| `config` | Manage configuration (get/set/list/path) | +| `version` | Show version information | Run `localgo help` for more options. @@ -180,7 +205,7 @@ Want to build on top of LocalGo or contribute? ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Contributions are welcome! Please feel free to submit a Pull Request or report an issue. ## License diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 7ea6148..2f19977 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -1,8 +1,24 @@ # CLI Reference +## Global Flags + +These flags can be passed before any subcommand. + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--verbose` | bool | `false` | Enable debug logging | +| `--json` | bool | `false` | Enable JSON log output | +| `--no-color` | bool | `false` | Disable colored output | +| `--config` | string | — | Config file path | +| `--private`, `-p` | bool | `false` | Hide device identity (alias, model) during discovery and transfer | +| `-v`, `--version` | — | — | Show version information | +| `-h`, `--help` | — | — | Show help | + +--- + ## `localgo serve` -Starts the receiver server. It runs in the foreground and accepts incoming file transfers and clipboard text from LocalSend-compatible devices. +Starts the receiver server. Runs in the foreground and accepts incoming file transfers and clipboard text from LocalSend-compatible devices. **Usage:** ```bash @@ -24,6 +40,9 @@ localgo serve [flags] | `--verbose` | bool | false | Verbose mode — detailed debug output | | `--history` | string | ~/.local/share/localgo/history.jsonl | Path to transfer history JSONL file | | `--exec` | string | — | Shell command to execute after each received file | +| `--daemon`, `-d` | bool | false | Run server as a background daemon | +| `--open` | bool | false | Open download directory after transfer completes | +| `--iface` | string | — | Multicast network interface name | **Exec Hook Placeholders:** | Placeholder | Description | @@ -38,6 +57,8 @@ localgo serve [flags] ```bash localgo serve --exec "notify-send 'Got: %f'" localgo serve --exec "curl -F 'file=@%f' https://example.com/upload" +localgo serve --daemon +localgo serve --open ``` **Behavior:** @@ -45,7 +66,7 @@ localgo serve --exec "curl -F 'file=@%f' https://example.com/upload" - Joins Multicast group to listen for discovery announcements. - Accepts upload requests; files are saved to `LOCALSEND_DOWNLOAD_DIR`. - Incoming `text/plain` transfers are copied to the system clipboard by default (use `--no-clipboard` to save as a file instead). -- To stop, press `Ctrl+C`. +- To stop, press `Ctrl+C` or use `localgo stop` when running as a daemon. --- @@ -61,22 +82,27 @@ localgo share --file FILE [flags] **Flags:** | Flag | Type | Default | Description | |------|------|---------|-------------| -| `--file` | string | — | File or directory to share (required, can be repeated) | +| `--file` | stringSlice | — | File or directory to share (can be repeated) | | `--port` | int | from config | Port to run the server on | -| `--http` | bool | false | Use HTTP instead of HTTPS | -| `--pin` | string | — | Require PIN for incoming transfers | +| `--http` | bool | false | Deprecated (HTTP is now default for share) | +| `--https` | bool | false | Use HTTPS (browsers will reject self-signed certs) | +| `--pin` | string | — | PIN for authentication | | `--alias` | string | from config | Device alias | | `--auto-accept` | bool | false | Auto-accept incoming files without prompting | | `--no-clipboard` | bool | false | Save incoming text as a file instead of copying to clipboard | | `--history` | string | — | Path to transfer history JSONL file | | `--exec` | string | — | Shell command to execute after each received file | | `--quiet` | bool | false | Quiet mode — minimal output | +| `--zip` | bool | false | Zip directories before sharing | +| `--concurrency` | int | 0 | Max parallel uploads (0 = use default) | +| `--iface` | string | — | Multicast network interface name | **Examples:** ```bash localgo share --file document.pdf localgo share --file document.pdf --file image.jpg localgo share --file data.zip --pin 1234 +localgo share --file mydir --zip ``` --- @@ -87,22 +113,28 @@ Sends one or more files to a destination device. **Usage:** ```bash -localgo send --file FILE --to DEVICE [flags] +localgo send --file FILE [flags] ``` **Flags:** | Flag | Type | Default | Description | |------|------|---------|-------------| -| `--file` | string | — | File or directory to send (required, can be repeated) | -| `--to` | string | — | Target device alias (required) | +| `--file` | stringSlice | — | File or directory to send (can be repeated) | +| `--to` | string | — | Target device alias (omit to pick interactively) | +| `--ip` | string | — | Target device IP (with optional `:port`, skips discovery) | | `--port` | int | auto-detect | Target device port | | `--timeout` | int | 30 | Send timeout in seconds | | `--alias` | string | from config | Sender alias | +| `--concurrency` | int | 0 | Max parallel uploads (0 = use default) | +| `--iface` | string | — | Multicast network interface name | +| `--clipboard`, `-c` | bool | false | Send current system clipboard text directly | +| `--stdin` | bool | false | Send text read from standard input (stdin) | **Discovery Logic:** -1. **Multicast Burst**: Attempts to find the device via rapid Multicast (1.5s). -2. **HTTP Scan Fallback**: If not found, scans the local subnet (IPs 1–254) via HTTP/S. -3. **Transfer**: Once found, initiates the LocalSend v2 upload protocol. +1. **Direct IP** (`--ip`): Skips discovery entirely, sends directly to the given IP:port. +2. **Multicast Burst**: Attempts to find the device via rapid Multicast (1.5s). +3. **HTTP Scan Fallback**: If not found, scans the local subnet (IPs 1–254) via HTTP/S. +4. **Transfer**: Once found, initiates the LocalSend v2 upload protocol. **Exit Codes:** - `0`: Success. @@ -113,6 +145,9 @@ localgo send --file FILE --to DEVICE [flags] localgo send --file document.pdf --to MyPhone localgo send --file image.jpg --file text.txt --to MyDevice localgo send --file data.zip --to RemotePC --timeout 60 +localgo send --ip 192.168.1.100:53317 --file doc.pdf +localgo send --clipboard --to MyPhone +cat report.txt | localgo send --stdin --to MyPhone ``` --- @@ -129,7 +164,7 @@ localgo discover [flags] **Flags:** | Flag | Type | Default | Description | |------|------|---------|-------------| -| `--timeout` | int | 5 | Discovery timeout in seconds | +| `--timeout` | int | 10 | Discovery timeout in seconds | | `--json` | bool | false | Output in JSON format | | `--quiet` | bool | false | Quiet mode — only show results | @@ -151,6 +186,7 @@ localgo scan [flags] **Flags:** | Flag | Type | Default | Description | |------|------|---------|-------------| +| `--range` | string | — | CIDR range to scan (e.g. `192.168.1.0/24`) | | `--timeout` | int | 15 | Scan timeout in seconds | | `--port` | int | from config | Port to scan | | `--json` | bool | false | Output in JSON format | @@ -160,12 +196,13 @@ localgo scan [flags] - Use this if `discover` returns nothing. - Useful in strict corporate networks where UDP Multicast is blocked but TCP is allowed. - Finds devices running LocalSend in "Hidden" mode (if they respond to direct IP queries). +- Use `--range` to scan a specific CIDR range instead of auto-detected subnets. --- ## `localgo devices` -Shows all recently discovered devices on the network. Performs a short (2s) multicast scan internally. +Shows all recently discovered devices on the network. Reads from the local peer cache. **Usage:** ```bash @@ -176,12 +213,37 @@ localgo devices [flags] | Flag | Type | Default | Description | |------|------|---------|-------------| | `--json` | bool | false | Output in JSON format | +| `--probe` | bool | false | Probe cached devices to verify if they are currently online | + +--- + +## `localgo history` + +Shows the file transfer history log. + +**Usage:** +```bash +localgo history [flags] +``` + +**Flags:** +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--limit` | int | 10 | Maximum number of entries to display | +| `--clear` | bool | false | Clear all transfer history logs | + +**Examples:** +```bash +localgo history +localgo history --limit 20 +localgo history --clear +``` --- ## `localgo info` -Prints the current configuration state. +Prints the current device information and configuration. **Usage:** ```bash @@ -194,18 +256,119 @@ localgo info [flags] | `--json` | bool | false | Output in JSON format | **Output:** -Displays Alias, Port, Protocol, Fingerprint, and Download Directory. +Displays Alias, Version, Device Model/Type, Fingerprint, Port, Protocol, Download Directory, PIN status, and Multicast address. Useful for verifying env vars are picked up correctly. --- -## Global Flags +## `localgo config` -These flags can be passed before any subcommand. +Manage LocalGo configuration. Reads and writes the YAML config file. -| Flag | Type | Default | Description | -|------|------|---------|-------------| -| `--verbose` | bool | false | Enable debug logging | -| `--json` | bool | false | Enable JSON log output | -| `-h`, `--help` | — | — | Show help | -| `-v`, `--version` | — | — | Show version | +**Usage:** +```bash +localgo config [args] +``` + +**Subcommands:** + +### `localgo config get ` +Get a single config value by key. + +### `localgo config set ` +Set a config value. Automatically detects the type (int, bool, float64, string). + +### `localgo config list` +List all config values. + +### `localgo config path` +Show the config file path. + +**Examples:** +```bash +localgo config get port +localgo config set alias "MyDevice" +localgo config list +localgo config path +``` + +--- + +## `localgo stop` + +Stops a running LocalGo daemon. + +**Usage:** +```bash +localgo stop +``` + +**Behavior:** +- Reads the PID from `localgo.pid`. +- Sends `SIGTERM` (Unix) or kills the process (Windows). +- Removes the PID file. +- Polls for graceful exit up to 5 seconds before sending `SIGKILL`. + +--- + +## `localgo version` + +Shows version information. + +**Usage:** +```bash +localgo version +``` + +**Output:** +Displays the version, git commit, and build date. + +--- + +## `localgo completion` + +Generates shell completion scripts. + +**Usage:** +```bash +localgo completion [bash|zsh|fish|powershell] +``` + +**Examples:** +```bash +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 +``` + +--- + +## `localgo docker-start` + +Sets up permissions and drops privileges before running `serve` inside a Docker container. + +**Usage:** +```bash +localgo docker-start [serve flags...] +``` + +**Behavior:** +- Reads `PUID`/`PGID` environment variables (default 1000). +- Creates and chowns `/app/downloads` and `/app/config`. +- Drops privileges via `setgid`/`setuid` on Linux. +- Execs the binary with remaining args (forwarded directly to `serve`). + +--- + +## `localgo health` + +Runs a health check against the local server. + +**Usage:** +```bash +localgo health +``` + +**Behavior:** +- Sends `GET` to `https://127.0.0.1:/api/localsend/v2/info` with a 3-second timeout. +- Exits 0 on HTTP 200, exits 1 otherwise. diff --git a/docs/CODE_WALKTHROUGH.md b/docs/CODE_WALKTHROUGH.md index d995a2b..57fbde6 100644 --- a/docs/CODE_WALKTHROUGH.md +++ b/docs/CODE_WALKTHROUGH.md @@ -2,17 +2,17 @@ This document provides a deep dive into the LocalGo codebase, explaining "how it works" and the purpose of each package and file. -## 📂 Project Structure +## Project Structure ### `cmd/localgo/` The entry point for the application. - **`main.go`**: The command-line interface (CLI) driver. - - Sets up the `Application` struct. - - Defines subcommands: `serve`, `send`, `discover`, `scan`. - - Wires together the `config`, `server`, and `discovery` components. + - Defines `Version`, `GitCommit`, `BuildDate` ldflags vars. + - Calls `SetVersionInfo()` to wire version info into the help system. - Handles signal interrupts (Ctrl+C) for graceful shutdown. - **`main_test.go`**: Integration tests for the CLI commands. +- **`cmd/`**: Subcommand implementations (see [CLI Reference](CLI_REFERENCE.md)). ### `pkg/` The core logic libraries. @@ -23,56 +23,72 @@ Handles application configuration. - `LoadConfig()`: Loads settings from environment variables and defaults. - Manages the "Security Context" (TLS certificates). - Generates separate `RegisterDto` (discovery) and `InfoDto` (server info) structures. + - `ProtocolVersion` constant set to `"2.0"`. +- **`viper.go`**: Initializes Viper for YAML config file support and environment variable binding. +- **`dto.go`**: DTO conversion methods (`ToMulticastDto`, `ToRegisterDto`, `ToInfoDto`). #### `pkg/server/` The HTTP/S server that listens for incoming files and discovery requests. -- **`server.go`**: initializes the `http.Server` and Gorilla Mux router. - - Configures API routes (`/api/localsend/v2/...`). +- **`server.go`**: Initializes the `http.Server` and router. Configures API routes (`/api/localsend/v2/...`). - **`handlers/`**: - - **`discovery.go`**: Handles `/register` (peers announcing themselves) and `/info` (returning our device info). - - **`receive.go`**: Handles file upload requests. - - `PrepareUpload`: Validates PIN, checks disk space, returns a session token. - - `Upload`: Accepts the file stream and saves it to the download directory. -- **`services/`**: logic separate from HTTP transport. - - **`receive_service.go`**: Manages active upload sessions and tokens. + - **`discovery_handlers.go`**: Handles `/register` (peers announcing themselves) and `/info` (returning our device info). + - **`receive_handlers.go`**: Handles file upload requests. `PrepareUpload` validates PIN, checks disk space, returns a session token. `Upload` accepts the file stream and saves it. + - **`receive_upload.go`**: Upload session management and file writing logic. + - **`download_handlers.go`**: Handles file download requests (share mode). + - **`exec.go`**: Post-receive exec hook runner. + - **`prompt.go`**: Interactive TUI prompts for incoming transfers. + - **`history_log.go`**: Transfer history logging. #### `pkg/discovery/` Implements the logic to find other LocalSend devices. -- **`service.go`**: The high-level coordinator. It starts both Multicast listening and periodic announcements. -- **`multicast.go`**: Handles UDP Multicast packets. - - Listens on `224.0.0.167:53317`. - - When an announcement is received, it triggers a "Response". - - **Key Logic**: It first tries to send a response via HTTP (`POST /register`). if that fails, it falls back to a UDP unicast response. -- **`http_discovery.go`**: The "Smart Scanner". - - Used when Multicast fails. - - Iterates through target IP addresses (subnet scan) and sends `POST /api/localsend/v2/register` to checking for active devices. +- **`service.go`**: The high-level coordinator. Starts both Multicast listening and periodic announcements. +- **`multicast.go`**: Handles UDP Multicast packets on `224.0.0.167:53317`. On announcement, sends HTTP `POST /register` response; falls back to UDP unicast. +- **`http_discovery.go`**: The "Smart Scanner". Iterates through target IPs and sends `POST /api/localsend/v2/register` to find active devices. +- **`peer_cache.go`**: Persistent peer cache for recently discovered devices. #### `pkg/network/` Low-level networking utilities. -- **`interfaces.go`**: - - `GetLocalIPAddresses`: Finds all valid non-loopback interface IPs. - - `GetSubnetIPs`: The logic that powers "Smart Scan". It takes a local IP (e.g., `192.168.1.5`) and generates the full `/24` range (`.1` to `.254`) to ensure we find all neighbors. +- **`interfaces.go`**: `GetLocalIPAddresses`, `GetSubnetIPs`, `ParseCIDRRange`. #### `pkg/send/` -The client-side logic for sending files. -- **`send.go`**: - - **Discovery Phase**: First attempts a quick Multicast burst (1.5s). If no target found, triggers a full HTTP subnet scan. - - **Prepare Phase**: Sends metadata (name, size, type) to the target. - - **Transfer Phase**: Streams the file binary data to the target's `/upload` endpoint. +Client-side logic for sending files. +- **`send.go`**: Discovery phase (multicast burst → HTTP subnet scan), prepare phase (metadata exchange), transfer phase (file streaming). Exports `SendToDevice()` for direct IP-based send. +- **`verify.go`**: TLS certificate fingerprint verification (MitM prevention). #### `pkg/model/` Go struct definitions that map to the LocalSend JSON protocol. -- **`device.go`**: Represents a peer device (Alias, IP, DeviceType). +- **`device.go`**: Represents a peer device (Alias, IP, DeviceType, Fingerprint). - **`dto.go`**: Data Transfer Objects for the API (e.g., `PrepareUploadRequestDto`). #### `pkg/crypto/` Security primitives. -- **`cert.go`**: Generates self-signed X.509 certificates for TLS. -- **`hash.go`**: Computes the SHA-256 fingerprint of the certificate (identity string). +- **`crypto.go`**: Generates self-signed X.509 certificates for TLS and computes the SHA-256 fingerprint of the certificate. + +#### `pkg/storage/` +File storage utilities. +- **`storage.go`**: `SaveStreamToFileWithMetadata` for atomic file writes with SHA-256 verification, timestamp preservation, and progress reporting. +- **`storage_unix.go`**: `CheckFreeSpace` via `unix.Statfs` for disk space guard. + +#### `pkg/metadata/` +Metadata stripping for private mode. +- **`strip.go`**: Pure stdlib JPEG EXIF (APP1/APP13 marker skipping) and PNG text chunk (tEXt/zTXt/iTXt) stripping. + +#### `pkg/cli/` +CLI output utilities. +- **`output.go`**: Styled output, `AnonymizedAlias()`, `AnonymizeString()`, `PickDevice()` interactive device picker. +- **`filepicker.go`**: Interactive TUI file picker. + +#### `pkg/clipboard/` +Cross-platform clipboard reading. +- **`clipboard.go`**: Reads clipboard via CLI tools (pbpaste, wl-paste, xclip, xsel, Get-Clipboard) — CGo-free. + +#### `pkg/help/` +Help text and version display. +- **`help.go`**: Command help blocks and version output. --- -## 🔄 Lifecycle Flows +## Lifecycle Flows ### 1. Starting the Server (`serve`) 1. `main.go` loads `Config` (generating certs if needed). diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 6ddc084..775f4fc 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -5,7 +5,8 @@ LocalGo can be configured via Command Line Flags, Environment Variables, or a Co ## Precedence Order 1. **Command Line Flags** (Highest priority) 2. **Environment Variables** -3. **Default Values** (Lowest priority) +3. **Config File** (YAML) +4. **Default Values** (Lowest priority) --- @@ -20,11 +21,14 @@ These can be passed before any subcommand. |------|-------------|---------| | `--verbose` | Enable debug logging | `false` | | `--json` | Enable JSON log output | `false` | +| `--no-color` | Disable colored output | `false` | +| `--config` | Config file path | — | +| `--private`, `-p` | Hide device identity during discovery and transfer | `false` | ### `serve` Flags | Flag | Description | Default | |------|-------------|---------| -| `--port` | TCP port to listen on | `53317` | +| `--port` | TCP port to listen on | from config | | `--http` | Disable HTTPS (use HTTP only) | `false` | | `--alias` | Device name visible to others | from config | | `--dir` | Directory to save incoming files | from config | @@ -34,39 +38,57 @@ These can be passed before any subcommand. | `--no-clipboard` | Save incoming text as a file instead of copying to clipboard | `false` | | `--quiet` | Suppress non-essential output | `false` | | `--verbose` | Enable debug logging | `false` | +| `--history` | Path to transfer history JSONL file | (auto) | +| `--exec` | Shell command to run after each received file | — | +| `--daemon`, `-d` | Run server as a background daemon | `false` | +| `--open` | Open download directory after transfer completes | `false` | +| `--iface` | Multicast network interface name | — | ### `share` Flags | Flag | Description | Default | |------|-------------|---------| -| `--file` | Path to file or directory to share (required, repeatable) | — | -| `--port` | TCP port to listen on | `53317` | -| `--http` | Disable HTTPS (use HTTP only) | `false` | +| `--file` | Path to file or directory to share (repeatable) | — | +| `--port` | TCP port to listen on | from config | +| `--http` | Deprecated (HTTP is now default for share) | `false` | +| `--https` | Use HTTPS (browsers reject self-signed certs) | `false` | | `--alias` | Device name visible to others | from config | | `--pin` | Require PIN for incoming transfers | — | | `--auto-accept` | Auto-accept incoming files without prompting | `false` | | `--no-clipboard` | Save incoming text as a file instead of copying to clipboard | `false` | +| `--history` | Path to transfer history JSONL file | — | +| `--exec` | Shell command to run after each received file | — | +| `--quiet` | Suppress non-essential output | `false` | +| `--zip` | Zip directories before sharing | `false` | +| `--concurrency` | Max parallel uploads (0 = use default) | `0` | +| `--iface` | Multicast network interface name | — | ### `send` Flags | Flag | Description | Default | |------|-------------|---------| -| `--file` | Path to file or directory to send (required, repeatable) | — | -| `--to` | Exact alias of recipient (required) | — | +| `--file` | Path to file or directory to send (repeatable) | — | +| `--to` | Target device alias (omit to pick interactively) | — | +| `--ip` | Target device IP (with optional `:port`, skips discovery) | — | | `--port` | Target device port | auto-detect | | `--timeout` | Transfer timeout in seconds | `30` | | `--alias` | Sender alias | from config | +| `--concurrency` | Max parallel uploads (0 = use default) | `0` | +| `--iface` | Multicast network interface name | — | +| `--clipboard`, `-c` | Send current system clipboard text directly | `false` | +| `--stdin` | Send text read from standard input (stdin) | `false` | ### `discover` Flags | Flag | Description | Default | |------|-------------|---------| -| `--timeout` | Discovery timeout in seconds | `5` | +| `--timeout` | Discovery timeout in seconds | `10` | | `--json` | Output results in JSON format | `false` | | `--quiet` | Only show results, no status messages | `false` | ### `scan` Flags | Flag | Description | Default | |------|-------------|---------| +| `--range` | CIDR range to scan (e.g. `192.168.1.0/24`) | — | | `--timeout` | Scan timeout in seconds | `15` | -| `--port` | Port to scan | `53317` | +| `--port` | Port to scan | from config | | `--json` | Output results in JSON format | `false` | | `--quiet` | Only show results, no status messages | `false` | @@ -74,6 +96,13 @@ These can be passed before any subcommand. | Flag | Description | Default | |------|-------------|---------| | `--json` | Output results in JSON format | `false` | +| `--probe` | Probe cached devices to verify if they are currently online | `false` | + +### `history` Flags +| Flag | Description | Default | +|------|-------------|---------| +| `--limit` | Maximum number of entries to display | `10` | +| `--clear` | Clear all transfer history logs | `false` | --- @@ -85,16 +114,28 @@ You can set these globally to avoid repeating flags. |----------|-------------|---------| | `LOCALSEND_ALIAS` | Device name | Hostname | | `LOCALSEND_PORT` | Port number | `53317` | -| `LOCALSEND_DOWNLOAD_DIR` | Save path for incoming files | `./downloads` | +| `LOCALSEND_DOWNLOAD_DIR` | Save path for incoming files | `$HOME/Downloads/localgo` | | `LOCALSEND_SECURITY_DIR` | Security files path | (Auto-detected) | | `LOCALSEND_PIN` | Security PIN | (Empty) | | `LOCALSEND_FORCE_HTTP` | Disable HTTPS, use HTTP only | `false` | | `LOCALSEND_DEVICE_TYPE` | Device type (`mobile`/`desktop`/`laptop`/`tablet`/`server`/`headless`/`web`/`other`) | `desktop` | -| `LOCALSEND_DEVICE_MODEL` | Device model string | `LocalGo` | +| `LOCALSEND_DEVICE_MODEL` | Device model string | `GoDevice` | | `LOCALSEND_AUTO_ACCEPT` | Auto-accept incoming files (`true` or `1`) | `false` | | `LOCALSEND_NO_CLIPBOARD` | Save incoming text as a file instead of clipboard (`true` or `1`) | `false` | | `LOCALSEND_MULTICAST_GROUP` | Multicast IP address | `224.0.0.167` | | `LOCALSEND_LOG_LEVEL` | Log verbosity (`debug`/`info`/`warn`/`error`) | `info` | +| `LOCALSEND_HISTORY` | Path to transfer history JSONL file | (auto) | +| `LOCALSEND_EXEC` | Shell command to run after each received file | — | +| `LOCALSEND_QUIET` | Minimal output mode | `false` | +| `LOCALSEND_CONCURRENCY` | Max parallel upload workers | `4` | +| `LOCALSEND_MULTICAST_INTERFACE` | Network interface to bind multicast to | (all) | +| `LOCALSEND_SHELL` | Shell prefix for exec hooks | (auto-detected) | +| `LOCALSEND_CLIPBOARD_WRITE_CMD` | Custom clipboard write command | (auto-detected) | +| `LOCALSEND_CLIPBOARD_READ_CMD` | Custom clipboard read command | (auto-detected) | +| `LOCALSEND_TLS_CERT` | Custom TLS certificate file path | — | +| `LOCALSEND_TLS_KEY` | Custom TLS private key file path | — | +| `LOCALSEND_NOTIFICATION_CMD` | Custom notification display command | (auto-detected) | +| `LOCALSEND_MAX_BODY_SIZE` | Max request body size in bytes (0 = unlimited) | `0` | ### Docker-specific Variables | Variable | Description | Default | diff --git a/docs/LIBRARY_GUIDE.md b/docs/LIBRARY_GUIDE.md index adb64a5..63d9972 100644 --- a/docs/LIBRARY_GUIDE.md +++ b/docs/LIBRARY_GUIDE.md @@ -2,7 +2,7 @@ LocalGo is structured as a collection of reusable Go packages. You can import `github.com/bethropolis/localgo/pkg/...` to build your own custom LocalSend applications. -## 📦 Key Packages +## Key Packages | Package | Import Path | Purpose | |---------|-------------|---------| @@ -12,7 +12,7 @@ LocalGo is structured as a collection of reusable Go packages. You can import `g | `send` | `.../pkg/send` | Client-side sending logic | | `model` | `.../pkg/model` | Shared DTOs (`Device`, `File`, etc.) | -## 🛠 Example: Custom Receiver +## Example: Custom Receiver This minimal example shows how to start a receiver from your own code. @@ -22,10 +22,11 @@ package main import ( "context" "log" - + "github.com/bethropolis/localgo/pkg/config" "github.com/bethropolis/localgo/pkg/server" "github.com/bethropolis/localgo/pkg/model" + "go.uber.org/zap" ) func main() { @@ -35,24 +36,26 @@ func main() { Port: 53317, HttpsEnabled: true, DownloadDir: "./received_files", - DeviceType: model.DeviceTypeMobile, // Identify as mobile + DeviceType: model.DeviceTypeMobile, MulticastGroup: "224.0.0.167", } - + // Note: You must handle SecurityContext generation manually if not using config.LoadConfig() // See pkg/config/config.go for reference. + logger := zap.NewNop().Sugar() + // 2. Start Server - srv := server.NewServer(cfg) + srv := server.NewServer(cfg, logger) log.Printf("Starting server on %d...", cfg.Port) - + if err := srv.Start(context.Background()); err != nil { log.Fatal(err) } } ``` -## 🛠 Example: Custom Discovery +## Example: Custom Discovery Run your own discovery logic to build a device picker UI. @@ -61,6 +64,7 @@ import ( "context" "fmt" "time" + "github.com/bethropolis/localgo/pkg/discovery" "github.com/bethropolis/localgo/pkg/model" "go.uber.org/zap" @@ -68,16 +72,20 @@ import ( func DiscoverDevices() { logger := zap.NewNop().Sugar() + // Setup cfg := discovery.DefaultServiceConfig() dto := model.MulticastDto{ - Alias: "Scanner", - Port: 53317, - // ... populate other fields + Alias: "Scanner", + Port: 53317, + Fingerprint: "your-fingerprint-here", + DeviceType: "desktop", + Protocol: "2.0", + Download: false, } - -multicast := discovery.NewMulticastDiscovery(cfg.MulticastConfig, dto, logger) -service := discovery.NewService(cfg, multicast, logger) + + multicast := discovery.NewMulticastDiscovery(cfg.MulticastConfig, dto, logger) + service := discovery.NewService(cfg, multicast, logger) // Callback service.AddDeviceHandler(func(device *model.Device) { @@ -87,12 +95,17 @@ service := discovery.NewService(cfg, multicast, logger) // Run for 5 seconds ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - - service.Discover(ctx, "Scanner", 53317, "fingerprint...", "desktop", nil) + + devices, err := service.Discover(ctx, dto) + if err != nil { + fmt.Printf("Discovery error: %v\n", err) + return + } + fmt.Printf("Found %d devices\n", len(devices)) } ``` -## 🏗 Best Practices +## Best Practices 1. **Context Management**: Always pass `context.Context` to control lifecycles. LocalGo relies heavily on contexts for cancellation. 2. **Error Handling**: Check errors from `Start()` and `SendFile()`. From 0f8da63bc60bd89e9b44bfcbee4b5d237313a6f8 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:53:17 +0300 Subject: [PATCH 33/36] feat(help): add 'config' command to main help and wire SetHelpFunc - Register 'config' in help.ShowMainUsage() so it appears in COMMANDS - Add 'config' help entry in commands.go for localgo help config - Add SetHelpFunc + import in config.go for proper help display --- cmd/localgo/cmd/config.go | 6 ++++++ pkg/help/commands.go | 12 ++++++++++++ pkg/help/help.go | 1 + 3 files changed, 19 insertions(+) diff --git a/cmd/localgo/cmd/config.go b/cmd/localgo/cmd/config.go index 3aceb45..b2005d8 100644 --- a/cmd/localgo/cmd/config.go +++ b/cmd/localgo/cmd/config.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "github.com/bethropolis/localgo/pkg/help" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -168,5 +169,10 @@ func init() { configCmd.AddCommand(configSetCmd) configCmd.AddCommand(configListCmd) configCmd.AddCommand(configPathCmd) + configCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + if h := help.GetCommandHelp("config"); h != nil { + help.ShowCommandHelp(*h) + } + }) rootCmd.AddCommand(configCmd) } diff --git a/pkg/help/commands.go b/pkg/help/commands.go index e26809f..25e739e 100644 --- a/pkg/help/commands.go +++ b/pkg/help/commands.go @@ -186,6 +186,18 @@ func GetCommandHelp(commandName string) *CommandHelp { }, Flags: []FlagHelp{}, }, + "config": { + Name: "config", + Description: "Manage LocalGo configuration", + Usage: "localgo config [args]", + Examples: []string{ + "localgo config get port", + "localgo config set alias MyDevice", + "localgo config list", + "localgo config path", + }, + Flags: []FlagHelp{}, + }, } return commands[commandName] diff --git a/pkg/help/help.go b/pkg/help/help.go index eae53b8..183a597 100644 --- a/pkg/help/help.go +++ b/pkg/help/help.go @@ -47,6 +47,7 @@ func ShowMainUsage() { {"devices", "List recently discovered devices"}, {"history", "Show file transfer history log"}, {"stop", "Stop the running LocalGo daemon"}, + {"config", "Manage LocalGo configuration (get/set/list/path)"}, {"info", "Show device information"}, {"completion", "Generate shell completion scripts"}, {"help", "Show help information"}, From 792267a65befd77305a9349448fa458b75e1c863 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:59:38 +0300 Subject: [PATCH 34/36] fix(cli): wire --version/-v flag root Run, add version command help - Add Run to rootCmd so PersistentPreRunE fires for 'localgo --version' (without a subcommand), enabling the version flag check - Add -v shorthand for --version flag - Add version entry to commands.go so 'localgo help version' works - Add SetHelpFunc to versionCmd for proper help display --- cmd/localgo/cmd/root.go | 9 ++++++++- cmd/localgo/cmd/version.go | 5 +++++ pkg/help/commands.go | 9 +++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/cmd/localgo/cmd/root.go b/cmd/localgo/cmd/root.go index 66b1a76..2921bf8 100644 --- a/cmd/localgo/cmd/root.go +++ b/cmd/localgo/cmd/root.go @@ -31,6 +31,13 @@ var ( var rootCmd = &cobra.Command{ Use: "localgo", Short: "LocalGo - LocalSend v2.1 Protocol Implementation", + Run: func(cmd *cobra.Command, args []string) { + if versionFlag { + help.ShowVersion(Version, GitCommit, BuildDate) + return + } + cmd.Help() + }, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { if versionFlag { help.ShowVersion(Version, GitCommit, BuildDate) @@ -82,7 +89,7 @@ func Execute() { } func init() { - rootCmd.PersistentFlags().BoolVar(&versionFlag, "version", false, "Show version information") + rootCmd.PersistentFlags().BoolVarP(&versionFlag, "version", "v", 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") diff --git a/cmd/localgo/cmd/version.go b/cmd/localgo/cmd/version.go index 522459c..49f2a06 100644 --- a/cmd/localgo/cmd/version.go +++ b/cmd/localgo/cmd/version.go @@ -26,5 +26,10 @@ var versionCmd = &cobra.Command{ } func init() { + versionCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + if h := help.GetCommandHelp("version"); h != nil { + help.ShowCommandHelp(*h) + } + }) rootCmd.AddCommand(versionCmd) } diff --git a/pkg/help/commands.go b/pkg/help/commands.go index 25e739e..ee26392 100644 --- a/pkg/help/commands.go +++ b/pkg/help/commands.go @@ -198,6 +198,15 @@ func GetCommandHelp(commandName string) *CommandHelp { }, Flags: []FlagHelp{}, }, + "version": { + Name: "version", + Description: "Show version information", + Usage: "localgo version", + Examples: []string{ + "localgo version", + }, + Flags: []FlagHelp{}, + }, } return commands[commandName] From 826175d8c2a4387739339f9009260d7406de30ea Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:19:21 +0300 Subject: [PATCH 35/36] fix data race in updateDevice: replace in-map pointer instead of mutating in-place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous code modified existingDevice in-place (line 148 wrote existingDevice.Alias = device.Alias). When a second multicast listener received the same packet (multi-interface listening), existingDevice was the same pointer stored by the first call—already sent to a handler goroutine and potentially being read by the test. Replacing the map entry with the new pointer avoids sharing the old pointer across goroutines. FromMulticastDto already sets LastSeen: time.Now(), so the incoming device has a fresh timestamp—no need for UpdateLastSeen(). --- pkg/discovery/multicast.go | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/pkg/discovery/multicast.go b/pkg/discovery/multicast.go index d9584e8..e91df8a 100644 --- a/pkg/discovery/multicast.go +++ b/pkg/discovery/multicast.go @@ -140,24 +140,13 @@ func (md *MulticastDiscovery) Stop() { func (md *MulticastDiscovery) updateDevice(device *model.Device) { md.devicesMutex.Lock() - key := device.Fingerprint - existingDevice, exists := md.devices[key] - if exists { - existingDevice.IP = device.IP - existingDevice.Port = device.Port - existingDevice.Alias = device.Alias - existingDevice.Protocol = device.Protocol - existingDevice.UpdateLastSeen() - } else { - md.devices[key] = device - } + md.devices[device.Fingerprint] = device md.devicesMutex.Unlock() if md.peerCache != nil { md.peerCache.Save(device) } - // Always fire upward to Service so it can handle timestamps properly md.handlersMu.RLock() handlers := make([]func(*model.Device), len(md.handlers)) copy(handlers, md.handlers) From 62074ef1383d00c24073c0d7a2c6cdc0f326b59a Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:33:08 +0300 Subject: [PATCH 36/36] fix: exec shell selection, private mode StripTo on Windows, doc comment, DEL sanitization, alias injection --- pkg/clipboard/clipboard.go | 2 +- pkg/send/send.go | 1 + pkg/server/handlers/exec.go | 3 +-- pkg/server/handlers/receive_handlers.go | 16 +++++++++------- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/pkg/clipboard/clipboard.go b/pkg/clipboard/clipboard.go index 602b80c..9362f83 100644 --- a/pkg/clipboard/clipboard.go +++ b/pkg/clipboard/clipboard.go @@ -67,7 +67,7 @@ func Available() bool { // OverrideProvider replaces the auto-detected clipboard tool with custom commands. // Empty strings are ignored (auto-detected tool kept for that direction, if any). -// Returns an error if the command string is non-empty but yields no tokens. +// Non-empty command strings that parse to zero tokens are silently ignored. func OverrideProvider(writeCmd, readCmd string) { if writeCmd == "" && readCmd == "" { return diff --git a/pkg/send/send.go b/pkg/send/send.go index 6e8a8fb..770d002 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -258,6 +258,7 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, } tmpPath := tmp.Name() tmp.Close() + os.Remove(tmpPath) if err := metadata.StripTo(filePath, tmpPath); err != nil { os.Remove(tmpPath) diff --git a/pkg/server/handlers/exec.go b/pkg/server/handlers/exec.go index fcd3536..d3edc60 100644 --- a/pkg/server/handlers/exec.go +++ b/pkg/server/handlers/exec.go @@ -28,8 +28,7 @@ func (h *ReceiveHandler) runExecHook(filePath, fileName, senderAlias, senderIP s if parts := strings.Fields(h.config.Shell); len(parts) > 0 { cmd = exec.Command(parts[0], append(parts[1:], hook)...) } - } - if cmd == nil && runtime.GOOS == "windows" { + } else if runtime.GOOS == "windows" { cmd = exec.Command("cmd", "/c", hook) } else { cmd = exec.Command("sh", "-c", hook) diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index 2fe520d..f00b2da 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -127,13 +127,15 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R } } + sanitizedAlias := cli.Sanitize(requestDto.Info.Alias) + if !h.config.NoClipboard { if err := clipboard.Write(clipboardMessage); err != nil { h.logger.Warnf("Clipboard write failed (%v), saving text as file instead", err) } else { - h.logger.Infof("Clipboard message from %s accepted and copied", cli.Sanitize(requestDto.Info.Alias)) - h.logTransfer(requestDto.Info.Alias, senderIP, clipboardFileID, "", int64(len(clipboardMessage)), "text/plain", history.StatusClipboard) - h.runExecHook("", clipboardFileID, requestDto.Info.Alias, senderIP, int64(len(clipboardMessage))) + h.logger.Infof("Clipboard message from %s accepted and copied", sanitizedAlias) + h.logTransfer(sanitizedAlias, senderIP, clipboardFileID, "", int64(len(clipboardMessage)), "text/plain", history.StatusClipboard) + h.runExecHook("", clipboardFileID, sanitizedAlias, senderIP, int64(len(clipboardMessage))) w.WriteHeader(http.StatusNoContent) return } @@ -146,9 +148,9 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R httputil.RespondError(w, http.StatusInternalServerError, "Failed to save clipboard") return } - h.logger.Infof("Clipboard message from %s saved to %s", cli.Sanitize(requestDto.Info.Alias), clipboardPath) - h.logTransfer(requestDto.Info.Alias, senderIP, clipboardFileID, clipboardPath, int64(len(clipboardMessage)), "text/plain", history.StatusClipboard) - h.runExecHook(clipboardPath, clipboardFileID, requestDto.Info.Alias, senderIP, int64(len(clipboardMessage))) + h.logger.Infof("Clipboard message from %s saved to %s", sanitizedAlias, clipboardPath) + h.logTransfer(sanitizedAlias, senderIP, clipboardFileID, clipboardPath, int64(len(clipboardMessage)), "text/plain", history.StatusClipboard) + h.runExecHook(clipboardPath, clipboardFileID, sanitizedAlias, senderIP, int64(len(clipboardMessage))) w.WriteHeader(http.StatusNoContent) return } @@ -277,7 +279,7 @@ func (h *ReceiveHandler) CancelHandler(w http.ResponseWriter, r *http.Request) { // to prevent UI spoofing and terminal escape injection on display. func sanitizeName(name string) string { return strings.Map(func(r rune) rune { - if r <= 0x1F { + if r <= 0x1F || r == 0x7F { return -1 } return r