diff --git a/.gitignore b/.gitignore index 0c249cf..4a20521 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ # Output directories .localgo_security/ downloads/ -scripts/*.log +*.log # Dependency directories (if vendoring) vendor/ @@ -38,7 +38,8 @@ Thumbs.db coverage.* -/config +/mise.toml +config test dist/ .coverage/ 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/cmd/localgo/cmd/config.go b/cmd/localgo/cmd/config.go index 7b75406..b2005d8 100644 --- a/cmd/localgo/cmd/config.go +++ b/cmd/localgo/cmd/config.go @@ -3,9 +3,11 @@ package cmd import ( "fmt" "os" + "path/filepath" "strconv" "strings" + "github.com/bethropolis/localgo/pkg/help" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -90,7 +92,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) } @@ -167,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/cmd/localgo/cmd/daemon_unix.go b/cmd/localgo/cmd/daemon_unix.go new file mode 100644 index 0000000..5d6fc73 --- /dev/null +++ b/cmd/localgo/cmd/daemon_unix.go @@ -0,0 +1,57 @@ +//go:build !windows + +package cmd + +import ( + "fmt" + "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" { + 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) + } + + 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/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/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/root.go b/cmd/localgo/cmd/root.go index 2fa9b44..2921bf8 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" @@ -29,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) @@ -62,6 +71,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 }, } @@ -73,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/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 9050364..4d84ed2 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) } } @@ -120,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 @@ -148,13 +134,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 { @@ -165,7 +158,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) } @@ -221,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")) @@ -276,11 +272,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/cmd/localgo/cmd/serve.go b/cmd/localgo/cmd/serve.go index 77ec048..a3f3560 100644 --- a/cmd/localgo/cmd/serve.go +++ b/cmd/localgo/cmd/serve.go @@ -25,6 +25,7 @@ var ( servealias string servedir string servequiet bool + servedaemon bool serveinterval int serveautoAccept bool servenoClipboard bool @@ -39,6 +40,20 @@ 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" { + 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 @@ -58,6 +73,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 } @@ -212,6 +232,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..4a04772 --- /dev/null +++ b/cmd/localgo/cmd/stop.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "fmt" + "os" + "strconv" + "strings" + + "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) + } + + return stopDaemonProcess(pid, pidPath) + }, +} + +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/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..ebc3c92 --- /dev/null +++ b/cmd/localgo/cmd/stop_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package cmd + +import ( + "os" + + "github.com/bethropolis/localgo/pkg/cli" +) + +func stopDaemonProcess(pid int, pidPath string) error { + 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("No running LocalGo daemon found with PID %d", pid) + } else { + cli.PrintSuccess("LocalGo daemon stopped") + } + os.Remove(pidPath) + return nil +} 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/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()`. 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/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/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/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/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/clipboard/clipboard.go b/pkg/clipboard/clipboard.go index a1bc9e4..9362f83 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() 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", provider.readCmd, err) } // Normalize Windows CRLF line endings to unix LF return strings.ReplaceAll(string(out), "\r\n", "\n"), nil @@ -59,3 +64,36 @@ 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). +// Non-empty command strings that parse to zero tokens are silently ignored. +func OverrideProvider(writeCmd, readCmd string) { + if writeCmd == "" && readCmd == "" { + return + } + p := &clipProvider{} + if writeCmd != "" { + 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 != "" { + 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 + } + if p.cmd != "" || p.readCmd != "" { + provider = p + } +} 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/config/config.go b/pkg/config/config.go index ff262f5..a7bfaa9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -45,6 +45,20 @@ 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 + 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 @@ -181,6 +195,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 +221,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/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/discovery/multicast.go b/pkg/discovery/multicast.go index d9ee982..e91df8a 100644 --- a/pkg/discovery/multicast.go +++ b/pkg/discovery/multicast.go @@ -140,20 +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.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) diff --git a/pkg/help/commands.go b/pkg/help/commands.go index 29afec5..ee26392 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", @@ -174,6 +186,27 @@ 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{}, + }, + "version": { + Name: "version", + Description: "Show version information", + Usage: "localgo version", + Examples: []string{ + "localgo version", + }, + Flags: []FlagHelp{}, + }, } return commands[commandName] diff --git a/pkg/help/help.go b/pkg/help/help.go index ae6e508..183a597 100644 --- a/pkg/help/help.go +++ b/pkg/help/help.go @@ -46,6 +46,8 @@ 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"}, + {"config", "Manage LocalGo configuration (get/set/list/path)"}, {"info", "Show device information"}, {"completion", "Generate shell completion scripts"}, {"help", "Show help information"}, diff --git a/pkg/metadata/strip.go b/pkg/metadata/strip.go index ceade8f..82f341a 100644 --- a/pkg/metadata/strip.go +++ b/pkg/metadata/strip.go @@ -9,32 +9,106 @@ 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 +} + +// 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 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 +117,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 +133,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 +151,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 +166,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 +188,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 +196,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 +204,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 +219,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/network/interfaces.go b/pkg/network/interfaces.go index 3236c2b..d2b3902 100644 --- a/pkg/network/interfaces.go +++ b/pkg/network/interfaces.go @@ -169,6 +169,97 @@ 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) +} + +// 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 +// 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 (max 1022 usable hosts) + effectiveMask := ipnet.Mask + 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 small 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() diff --git a/pkg/send/send.go b/pkg/send/send.go index 37e6f39..770d002 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() @@ -105,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")) @@ -136,10 +157,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,22 +226,54 @@ 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) } - // 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 { + 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) + } + tmpPath := tmp.Name() + tmp.Close() + os.Remove(tmpPath) + + 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) } } 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) @@ -242,12 +295,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 @@ -267,6 +314,28 @@ 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) + } + + preview := string(mf.content) + fileDto := model.FileDto{ + ID: id, + FileName: remoteName, + Size: int64(len(mf.content)), + FileType: contentType, + Preview: &preview, + } + + filesDtoMap[id] = fileDto + memReaders[id] = &memReadSeekCloser{bytes.NewReader(mf.content)} + } + infoAlias := cfg.Alias infoDeviceModel := cfg.DeviceModel infoDeviceType := cfg.DeviceType @@ -313,6 +382,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) } @@ -334,32 +410,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..83ad4c4 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,23 +40,32 @@ 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 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 { @@ -50,7 +73,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 { 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/exec.go b/pkg/server/handlers/exec.go index fa43989..d3edc60 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) { @@ -12,13 +13,25 @@ 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 runtime.GOOS == "windows" { - cmd = exec.Command("cmd", "/c", h.config.ExecHook) + if h.config.Shell != "" { + if parts := strings.Fields(h.config.Shell); len(parts) > 0 { + cmd = exec.Command(parts[0], append(parts[1:], hook)...) + } + } else if runtime.GOOS == "windows" { + 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, diff --git a/pkg/server/handlers/prompt.go b/pkg/server/handlers/prompt.go index 3961857..b2525b0 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++ } @@ -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)", 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", cli.Sanitize(alias), remoteAddr, cli.Sanitize(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 209f850..f00b2da 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -6,11 +6,14 @@ import ( "encoding/json" "net" "net/http" + "os" "os/exec" "runtime" + "strings" "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" @@ -74,15 +77,92 @@ 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) + if f.FileName == "" { + 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 + } + 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) 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. + // 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 + var clipboardFileID string + for id, f := range requestDto.Files { + if f.Preview != nil && *f.Preview != "" && strings.HasPrefix(f.FileType, "text/plain") { + 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", cli.Sanitize(requestDto.Info.Alias)) + if !h.config.AutoAccept { + h.promptMutex.Lock() + accepted := h.promptForClipboard(cli.Sanitize(requestDto.Info.Alias), r.RemoteAddr, clipboardMessage) + h.promptMutex.Unlock() + if !accepted { + httputil.RespondError(w, http.StatusForbidden, "Rejected") + return + } + } + + 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", 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 + } + } + + // Fallback: save as file (NoClipboard mode or clipboard write failed) + 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 + } + 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 + } + // --- 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)", cli.Sanitize(requestDto.Info.Alias), cli.Sanitize(f.FileName), f.Size) + httputil.RespondError(w, http.StatusBadRequest, "Invalid file size") + return + } totalSize += f.Size } @@ -91,18 +171,16 @@ 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, @@ -196,3 +274,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 || r == 0x7F { + return -1 + } + return r + }, name) +} diff --git a/pkg/server/handlers/receive_handlers_test.go b/pkg/server/handlers/receive_handlers_test.go index ee8282c..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" @@ -314,3 +315,158 @@ 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) { + if runtime.GOOS == "windows" { + t.Skip("os.Chmod permission bits are not supported on Windows") + } + 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()) + } +} + +func TestPrepareUpload_SanitizesControlChars(t *testing.T) { + 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}, + } + 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") + } + + // 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) { + 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()) + } +} diff --git a/pkg/server/handlers/receive_upload.go b/pkg/server/handlers/receive_upload.go index 14f5600..ececde7 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,36 @@ 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 + // 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 @@ -73,23 +74,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 +104,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,56 +134,59 @@ 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. - h.saveTextAsFile(session, reqSessionId, reqFileId, rawFileName, bodyReader, textBytes, modified, accessed, onProgress) + 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 + } + 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. -func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, reqSessionId, reqFileId, rawFileName string, bodyReader io.Reader, textBytes []byte, modified, accessed *string, onProgress func(int64)) { +// 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) @@ -195,20 +198,20 @@ 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, ) if savErr != nil { h.logger.Errorf("Error saving text file %s: %v", rawFileName, savErr) - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusFailed) - return + h.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 } // shutdownAwareReader aborts Read when the shutdown context is cancelled, diff --git a/pkg/server/server.go b/pkg/server/server.go index f1b5bc5..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" @@ -161,7 +164,20 @@ 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) + 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)) + } if err != nil { return fmt.Errorf("failed to load TLS key pair: %w", err) } diff --git a/pkg/server/services/receive_service.go b/pkg/server/services/receive_service.go index aefe173..0ef4986 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. @@ -148,15 +167,98 @@ func (s *ReceiveService) copySession(orig *ActiveReceiveSession) *ActiveReceiveS // CloseSession closes a specific session. func (s *ReceiveService) CloseSession(sessionID string) { + s.sessionMutex.Lock() + 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, +// 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() - if session, ok := s.sessions[sessionID]; ok { - if session.Progress != nil { - session.Progress.ForceComplete() - session.Progress.Wait() - } + + 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. diff --git a/pkg/server/services/receive_service_test.go b/pkg/server/services/receive_service_test.go index 57993f4..827b783 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,119 @@ 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) + + // 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) + + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _, err := svc.ClaimFile(sid, "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() 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 {