diff --git a/.gitignore b/.gitignore index 4a20521..26338a0 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,7 @@ coverage.* /mise.toml -config +/config test dist/ .coverage/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index dac0b7f..d67b978 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -135,7 +135,7 @@ homebrew_casks: directory: Casks homepage: "https://github.com/bethropolis/localgo" - description: "LocalSend v2.1 protocol implementation — LAN file transfer CLI" + description: "LocalSend v2 protocol implementation. LAN file transfer CLI" license: "MIT" caveats: "Shell completions are installed automatically.\n\nTo start as a background service:\n localgo serve --quiet --auto-accept" @@ -147,7 +147,7 @@ aurs: - name: localgo-bin ids: [default] homepage: "https://github.com/bethropolis/localgo" - description: "LocalSend v2.1 protocol implementation — LAN file transfer CLI" + description: "LocalSend v2 protocol implementation. LAN file transfer CLI" maintainers: - "bethropolis " license: MIT @@ -222,7 +222,7 @@ scoops: name: github-actions[bot] email: github-actions[bot]@users.noreply.github.com homepage: "https://github.com/bethropolis/localgo" - description: "LocalSend v2.1 protocol implementation — LAN file transfer CLI" + description: "LocalSend v2 protocol implementation. LAN file transfer CLI" license: MIT skip_upload: auto @@ -236,7 +236,7 @@ nfpms: vendor: bethropolis homepage: "https://github.com/bethropolis/localgo" maintainer: "bethropolis" - description: "LocalSend v2.1 protocol implementation — LAN file transfer CLI" + description: "LocalSend v2 protocol implementation. LAN file transfer CLI" license: MIT formats: - deb diff --git a/README.md b/README.md index b9191e5..6da05f9 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,14 @@ A Go implementation of the LocalSend v2.1 protocol for secure, cross-platform fi ## Features -- **Complete LocalSend v2.1 Protocol** - Works with LocalSend apps -- **Secure** - HTTPS with certificates, optional PIN protection -- **Fast Discovery** - Multicast UDP + HTTP fallback -- **Multi-file Transfers** - Send multiple files concurrently -- **Web Share** - Share files via browser download link -- **Clipboard Integration** - Incoming text/plain transfers copied to clipboard automatically -- **Metadata Preserved** - File timestamps preserved on transfer +- **Complete LocalSend v2.1 Protocol** - Works with LocalSend apps (v3 planned) +- **Secure** - HTTPS with auto-generated certificates, PIN-protected transfers, fingerprint TOFU verification +- **Fast Discovery** - Multicast UDP + HTTP fallback + CIDR range scanning +- **Multi-file Transfers** - Send multiple files concurrently with progress bars +- **Web Share** - Share files via browser download link with QR code and one-shot mode +- **Clipboard Integration** - Incoming text/plain transfers copied to clipboard instantly; send clipboard text directly +- **Privacy Mode** - Anonymize device identity during discovery and transfer (`--private`) +- **Metadata Preserved** - File timestamps preserved on transfer; EXIF/metadata stripping in private mode - **Cross-Platform** - Linux, macOS, Windows ## Quick Start @@ -98,9 +99,24 @@ localgo send --file document.pdf --to "My Phone" # Send clipboard contents directly localgo send --clipboard --to "My Phone" +# Send with PIN authentication +localgo send --file secret.pdf --to "My Phone" --pin 1234 + +# Fast discovery (skip cache probe) +localgo send --file large.zip --quick + +# Send directly to an IP (skips discovery) +localgo send --ip 192.168.1.42:53317 --file photo.jpg + +# Scan a specific CIDR range +localgo scan --range 192.168.1.0/24 + # Inspect transfer history logs localgo history +# Share files for web download (single-use) +localgo share --file document.pdf --once + # Share files for web download localgo share --file document.pdf ``` @@ -133,12 +149,20 @@ For full details on deployment, macvlan networking, read-only root filesystem, w | `LOCALSEND_QUIET` | false | Minimal output mode | | `LOCALSEND_CONCURRENCY` | 4 | Max parallel upload workers | | `LOCALSEND_MULTICAST_INTERFACE` | (all) | Network interface for multicast | +| `LOCALSEND_MULTICAST_GROUP` | 224.0.0.167 | Multicast group address | +| `LOCALSEND_DISCOVERY_STRATEGY` | full | Discovery strategy (full/fast) | | `LOCALSEND_SHELL` | (auto) | Shell prefix for exec hooks | +| `LOCALSEND_CLIPBOARD_WRITE_CMD` | (auto) | Custom clipboard write command | +| `LOCALSEND_CLIPBOARD_READ_CMD` | (auto) | Custom clipboard read command | | `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 | +| `LOCALSEND_FILE_CONFLICT_RESOLUTION` | rename | File conflict mode (rename/overwrite/skip) | +| `LOCALSEND_BIND_ADDRESS` | (all) | Bind to specific IP address | +| `LOCALSEND_STATIC_PEERS` | — | Comma-separated list of static peer IP:port | +| `LOCALSEND_TRUSTED_FINGERPRINTS` | — | Comma-separated list of trusted device fingerprints | ### Example @@ -162,7 +186,7 @@ localgo serve | `devices` | List discovered devices | | `history` | Show transfer history log | | `stop` | Stop a running daemon | -| `config` | Manage configuration (get/set/list/path) | +| `config` | Manage configuration (get/set/add/remove/open/unset/list/path) | | `version` | Show version information | Run `localgo help` for more options. diff --git a/cmd/localgo/cmd/config.go b/cmd/localgo/cmd/config.go index b2005d8..5d92ee4 100644 --- a/cmd/localgo/cmd/config.go +++ b/cmd/localgo/cmd/config.go @@ -3,7 +3,10 @@ package cmd import ( "fmt" "os" + "os/exec" "path/filepath" + "runtime" + "sort" "strconv" "strings" @@ -12,6 +15,171 @@ import ( "github.com/spf13/viper" ) +// configKey describes a known config key with its type and valid values. +type configKey struct { + typ string // "string", "int", "bool", "enum", "slice" + enums []string // valid values for enum type + intMin int // minimum for int type + intMax int // maximum for int type + defVal interface{} // default value +} + +var knownConfigKeys = map[string]configKey{ + "alias": {typ: "string"}, + "port": {typ: "int", intMin: 1, intMax: 65535, defVal: 53317}, + "multicast_group": {typ: "string", defVal: "224.0.0.167"}, + "device_model": {typ: "string"}, + "device_type": {typ: "enum", enums: []string{"desktop", "mobile", "headless", "server"}}, + "auto_accept": {typ: "bool"}, + "no_clipboard": {typ: "bool"}, + "quiet": {typ: "bool"}, + "history": {typ: "string"}, + "exec": {typ: "string"}, + "concurrency": {typ: "int", intMin: 1, intMax: 32, defVal: 4}, + "shell": {typ: "string"}, + "multicast_interface": {typ: "string"}, + "discovery_strategy": {typ: "enum", enums: []string{"full", "fast"}}, + "file_conflict_resolution": {typ: "enum", enums: []string{"rename", "overwrite", "skip"}}, + "bind_address": {typ: "string"}, + "static_peers": {typ: "slice"}, + "trusted_fingerprints": {typ: "slice"}, + "clipboard_write_cmd": {typ: "string"}, + "clipboard_read_cmd": {typ: "string"}, + "tls_cert": {typ: "string"}, + "tls_key": {typ: "string"}, + "notification_cmd": {typ: "string"}, + "force_http": {typ: "bool"}, + "download_dir": {typ: "string"}, + "max_body_size": {typ: "int", intMin: 0, intMax: 1 << 30}, + "security_dir": {typ: "string"}, +} + +// closeMatches returns keys whose Levenshtein distance is <= 2. +func closeMatches(input string, candidates []string) []string { + var matches []string + for _, c := range candidates { + if levenshtein(input, c) <= 2 { + matches = append(matches, c) + } + } + return matches +} + +func levenshtein(a, b string) int { + la, lb := len(a), len(b) + d := make([][]int, la+1) + for i := range d { + d[i] = make([]int, lb+1) + d[i][0] = i + } + for j := 0; j <= lb; j++ { + d[0][j] = j + } + for i := 1; i <= la; i++ { + for j := 1; j <= lb; j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + d[i][j] = min3(d[i-1][j]+1, d[i][j-1]+1, d[i-1][j-1]+cost) + } + } + return d[la][lb] +} + +func min3(a, b, c int) int { + if a < b { + if a < c { + return a + } + return c + } + if b < c { + return b + } + return c +} + +func knownKeyNames() []string { + names := make([]string, 0, len(knownConfigKeys)) + for k := range knownConfigKeys { + names = append(names, k) + } + sort.Strings(names) + return names +} + +func validateKey(key string) (configKey, error) { + ck, ok := knownConfigKeys[key] + if !ok { + suggestions := closeMatches(key, knownKeyNames()) + if len(suggestions) > 0 { + return ck, fmt.Errorf("unknown config key %q; did you mean %s?", key, strings.Join(suggestions, ", ")) + } + return ck, fmt.Errorf("unknown config key %q", key) + } + return ck, nil +} + +func validateValue(ck configKey, key, raw string) (interface{}, error) { + switch ck.typ { + case "string": + return raw, nil + case "int": + val, err := strconv.Atoi(raw) + if err != nil { + return nil, fmt.Errorf("invalid integer %q", raw) + } + if val < ck.intMin || val > ck.intMax { + return nil, fmt.Errorf("value %d out of range [%d, %d]", val, ck.intMin, ck.intMax) + } + return val, nil + case "bool": + val, err := strconv.ParseBool(raw) + if err != nil { + return nil, fmt.Errorf("invalid boolean %q (use true/false)", raw) + } + return val, nil + case "enum": + for _, e := range ck.enums { + if strings.EqualFold(raw, e) { + return e, nil + } + } + return nil, fmt.Errorf("invalid value %q; valid values: %s", raw, strings.Join(ck.enums, ", ")) + case "slice": + return nil, fmt.Errorf("use 'config add %s' or 'config remove %s' to manage list values", key, key) + } + return raw, nil +} + +func newViperForConfig() *viper.Viper { + v := viper.New() + v.SetConfigName("config") + v.SetConfigType("yaml") + v.AddConfigPath("$HOME/.config/localgo/") + v.AddConfigPath("$HOME/.local/etc/localgo/") + v.SetEnvPrefix("LOCALSEND") + v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + v.AutomaticEnv() + + for key, ck := range knownConfigKeys { + if ck.defVal != nil { + v.SetDefault(key, ck.defVal) + } + } + + _ = v.ReadInConfig() + return v +} + +func getConfigPath(v *viper.Viper) string { + if p := v.ConfigFileUsed(); p != "" { + return p + } + return os.ExpandEnv("$HOME/.config/localgo/config.yaml") +} + var configCmd = &cobra.Command{ Use: "config", Short: "Manage LocalGo configuration", @@ -22,21 +190,15 @@ var configGetCmd = &cobra.Command{ Short: "Get a config value", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - v := viper.New() - v.SetConfigName("config") - v.SetConfigType("yaml") - v.AddConfigPath("$HOME/.config/localgo/") - v.AddConfigPath("$HOME/.local/etc/localgo/") - - if err := v.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - return fmt.Errorf("failed to read config: %w", err) - } + v := newViperForConfig() + key := strings.ToLower(args[0]) + + if _, err := validateKey(key); err != nil { + return err } - key := strings.ToLower(args[0]) - if !v.InConfig(key) && !v.IsSet(key) { - return fmt.Errorf("key %q not found in config", key) + if !v.IsSet(key) { + return fmt.Errorf("key %q not set", key) } fmt.Println(v.GetString(key)) @@ -49,49 +211,22 @@ var configSetCmd = &cobra.Command{ Short: "Set a config value", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - v := viper.New() - v.SetConfigName("config") - v.SetConfigType("yaml") - v.AddConfigPath("$HOME/.config/localgo/") - v.AddConfigPath("$HOME/.local/etc/localgo/") - - if err := v.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - return fmt.Errorf("failed to read config: %w", err) - } - } - + v := newViperForConfig() key := strings.ToLower(args[0]) - existingVal := v.Get(key) - switch existingVal.(type) { - case int, int64: - val, err := strconv.Atoi(args[1]) - if err != nil { - return fmt.Errorf("invalid integer value %q: %w", args[1], err) - } - v.Set(key, val) - case bool: - val, err := strconv.ParseBool(args[1]) - if err != nil { - return fmt.Errorf("invalid boolean value %q: %w", args[1], err) - } - v.Set(key, val) - case float64: - val, err := strconv.ParseFloat(args[1], 64) - if err != nil { - return fmt.Errorf("invalid float value %q: %w", args[1], err) - } - v.Set(key, val) - default: - v.Set(key, args[1]) + ck, err := validateKey(key) + if err != nil { + return err } - configPath := v.ConfigFileUsed() - if configPath == "" { - configPath = os.ExpandEnv("$HOME/.config/localgo/config.yaml") + val, err := validateValue(ck, key, args[1]) + if err != nil { + return err } + v.Set(key, val) + + configPath := getConfigPath(v) if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } @@ -100,63 +235,214 @@ var configSetCmd = &cobra.Command{ return fmt.Errorf("failed to write config: %w", err) } - fmt.Printf("Set %s = %q in %s\n", key, args[1], configPath) + fmt.Printf("Set %s = %v in %s\n", key, val, configPath) return nil }, } var configListCmd = &cobra.Command{ Use: "list", - Short: "List all config values", + Short: "List all config values with origin", RunE: func(cmd *cobra.Command, args []string) error { - v := viper.New() - v.SetConfigName("config") - v.SetConfigType("yaml") - v.AddConfigPath("$HOME/.config/localgo/") - v.AddConfigPath("$HOME/.local/etc/localgo/") - - if err := v.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - return fmt.Errorf("failed to read config: %w", err) - } - } - + v := newViperForConfig() settings := v.AllSettings() + if len(settings) == 0 { - fmt.Println("(no config file found)") + fmt.Println("(no settings)") return nil } + fmt.Printf("%-28s %-10s %s\n", "KEY", "ORIGIN", "VALUE") + fmt.Println(strings.Repeat("-", 80)) + for _, key := range v.AllKeys() { val := v.Get(key) if val == nil { continue } - fmt.Printf("%-25s %v\n", key, val) + + origin := "[env]" + if v.InConfig(key) { + origin = "[file]" + } else if _, ok := knownConfigKeys[key]; ok && knownConfigKeys[key].defVal != nil && fmt.Sprint(v.Get(key)) == fmt.Sprint(knownConfigKeys[key].defVal) { + origin = "[default]" + } else if !v.InConfig(key) { + origin = "[env]" + } + + fmt.Printf("%-28s %-10s %v\n", key, origin, val) } return nil }, } -var configPathCmd = &cobra.Command{ - Use: "path", - Short: "Show config file path", +var configUnsetCmd = &cobra.Command{ + Use: "unset ", + Short: "Remove a config key (reverts to default)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + v := newViperForConfig() + key := strings.ToLower(args[0]) + + if _, err := validateKey(key); err != nil { + return err + } + + if !v.InConfig(key) { + return fmt.Errorf("key %q is not in config file", key) + } + + settings := v.AllSettings() + delete(settings, key) + + // Rebuild the config with the key removed + for k, val := range settings { + v.Set(k, val) + } + + configPath := getConfigPath(v) + if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + if err := v.WriteConfigAs(configPath); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + fmt.Printf("Removed %s from %s\n", key, configPath) + return nil + }, +} + +var configOpenCmd = &cobra.Command{ + Use: "open", + Short: "Open config file in system editor", RunE: func(cmd *cobra.Command, args []string) error { - v := viper.New() - v.SetConfigName("config") - v.SetConfigType("yaml") - v.AddConfigPath("$HOME/.config/localgo/") - v.AddConfigPath("$HOME/.local/etc/localgo/") - - if err := v.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - return fmt.Errorf("failed to read config: %w", err) + v := newViperForConfig() + configPath := getConfigPath(v) + + if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // If the file doesn't exist yet, create it + if _, err := os.Stat(configPath); os.IsNotExist(err) { + if err := v.WriteConfigAs(configPath); err != nil { + return fmt.Errorf("failed to create config file: %w", err) } } - path := v.ConfigFileUsed() - if path == "" { - fmt.Println("$HOME/.config/localgo/config.yaml") + editor := os.Getenv("EDITOR") + if editor == "" { + switch runtime.GOOS { + case "windows": + editor = "notepad" + case "darwin": + editor = "nano" + default: + editor = "nano" + for _, e := range []string{"nvim", "vim", "micro", "vi", "nano"} { + if _, err := exec.LookPath(e); err == nil { + editor = e + break + } + } + } + } + + editorCmd := exec.Command(editor, configPath) + editorCmd.Stdin = os.Stdin + editorCmd.Stdout = os.Stdout + editorCmd.Stderr = os.Stderr + + if err := editorCmd.Run(); err != nil { + return fmt.Errorf("editor %q failed: %w", editor, err) + } + return nil + }, +} + +var configAddCmd = &cobra.Command{ + Use: "add ", + Short: "Append a value to a list config key", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + v := newViperForConfig() + key := strings.ToLower(args[0]) + + if _, err := validateKey(key); err != nil { + return err + } + + current := v.GetStringSlice(key) + current = append(current, args[1]) + v.Set(key, current) + + configPath := getConfigPath(v) + if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + if err := v.WriteConfigAs(configPath); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + fmt.Printf("Added %q to %s in %s\n", args[1], key, configPath) + return nil + }, +} + +var configRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a value from a list config key", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + v := newViperForConfig() + key := strings.ToLower(args[0]) + + if _, err := validateKey(key); err != nil { + return err + } + + current := v.GetStringSlice(key) + filtered := make([]string, 0, len(current)) + removed := false + for _, item := range current { + if item == args[1] { + removed = true + } else { + filtered = append(filtered, item) + } + } + + if !removed { + return fmt.Errorf("value %q not found in %s", args[1], key) + } + + v.Set(key, filtered) + + configPath := getConfigPath(v) + if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + if err := v.WriteConfigAs(configPath); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + fmt.Printf("Removed %q from %s in %s\n", args[1], key, configPath) + return nil + }, +} + +var configPathCmd = &cobra.Command{ + Use: "path", + Short: "Show config file path", + RunE: func(cmd *cobra.Command, args []string) error { + v := newViperForConfig() + path := getConfigPath(v) + if _, err := os.Stat(path); os.IsNotExist(err) { + fmt.Println(path + " (file does not exist yet)") } else { fmt.Println(path) } @@ -164,11 +450,34 @@ var configPathCmd = &cobra.Command{ }, } +var configShellCmds = &cobra.Command{ + Use: "shell-completions", + Short: "Print shell completion setup instructions", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("To enable shell completion for config keys, add to your shell:") + fmt.Println() + fmt.Println(" # Bash (~/.bashrc)") + fmt.Println(` complete -W "` + strings.Join(knownKeyNames(), " ") + `" localgo`) + fmt.Println() + fmt.Println(" # Zsh (~/.zshrc)") + fmt.Println(` compadd -W "` + strings.Join(knownKeyNames(), " ") + `" -- ` + "${words[2]}") + fmt.Println() + fmt.Println(" # Or use: localgo completion bash/zsh/fish") + return nil + }, +} + func init() { configCmd.AddCommand(configGetCmd) configCmd.AddCommand(configSetCmd) configCmd.AddCommand(configListCmd) + configCmd.AddCommand(configUnsetCmd) + configCmd.AddCommand(configOpenCmd) + configCmd.AddCommand(configAddCmd) + configCmd.AddCommand(configRemoveCmd) configCmd.AddCommand(configPathCmd) + configCmd.AddCommand(configShellCmds) + configCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { if h := help.GetCommandHelp("config"); h != nil { help.ShowCommandHelp(*h) diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index 4d84ed2..cc296cf 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -24,16 +24,18 @@ import ( ) var ( - sendfiles []string - sendip string - sendto string - sendport int - sendtimeout int - sendalias string - sendconcurrency int + sendfiles []string + sendip string + sendto string + sendport int + sendtimeout int + sendalias string + sendconcurrency int sendmulticastiface string - sendclipboard bool - sendstdin bool + sendclipboard bool + sendstdin bool + sendquick bool + sendpin string ) var sendCmd = &cobra.Command{ @@ -158,9 +160,23 @@ 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(), sendOpts...); err != nil { - return fmt.Errorf("failed to send files: %w", err) + // TOFU check: verify cached fingerprint matches before connecting + if device.Fingerprint != "" { + pc := discovery.NewPeerCache(zap.S()) + if err := send.VerifyDeviceFingerprint(pc, device); err != nil { + return err } + } + + if err := send.SendToDevice(ctx, Cfg, device, files, zap.S(), sendOpts...); err != nil { + return fmt.Errorf("failed to send files: %w", err) + } + + // Save fingerprint for TOFU on subsequent connections + if device.Fingerprint != "" { + pc := discovery.NewPeerCache(zap.S()) + pc.Save(device) + } cli.PrintSuccess("Files sent successfully!") return nil @@ -252,6 +268,12 @@ var sendCmd = &cobra.Command{ if sendmulticastiface != "" { Cfg.MulticastInterface = sendmulticastiface } + if sendquick { + Cfg.DiscoveryStrategy = "fast" + } + if sendpin != "" { + Cfg.PIN = sendpin + } cli.PrintHeader(fmt.Sprintf("Sending %d files", len(files))) for _, file := range files { @@ -299,6 +321,8 @@ func init() { sendCmd.Flags().StringVar(&sendmulticastiface, "iface", "", "Multicast network interface name") sendCmd.Flags().BoolVarP(&sendclipboard, "clipboard", "c", false, "Send current system clipboard text directly") sendCmd.Flags().BoolVar(&sendstdin, "stdin", false, "Send text read from standard input (stdin)") + sendCmd.Flags().BoolVarP(&sendquick, "quick", "q", false, "Skip subnet scan; use cache + multicast only") + sendCmd.Flags().StringVar(&sendpin, "pin", "", "PIN for receiver authentication") sendCmd.RegisterFlagCompletionFunc("to", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { cache := discovery.NewPeerCache(nil) diff --git a/cmd/localgo/cmd/share.go b/cmd/localgo/cmd/share.go index 37f361f..f1b5936 100644 --- a/cmd/localgo/cmd/share.go +++ b/cmd/localgo/cmd/share.go @@ -18,6 +18,7 @@ import ( "github.com/bethropolis/localgo/pkg/network" "github.com/bethropolis/localgo/pkg/server" "github.com/google/uuid" + "github.com/mdp/qrterminal/v3" "github.com/spf13/cobra" "go.uber.org/zap" ) @@ -35,8 +36,9 @@ var ( shareexecHook string sharequiet bool sharezip bool - shareconcurrency int + shareconcurrency int sharemulticastiface string + shareOnce bool ) var shareCmd = &cobra.Command{ @@ -64,7 +66,8 @@ var shareCmd = &cobra.Command{ if shareport > 0 { Cfg.Port = shareport } - // Browser download API must use HTTP (browsers reject self-signed certs) + // Default to HTTP for browser compatibility; opt into HTTPS with --https. + // Self-signed certs trigger a browser security warning that users can bypass. Cfg.HttpsEnabled = false if shareuseHTTPS { Cfg.HttpsEnabled = true @@ -96,6 +99,9 @@ var shareCmd = &cobra.Command{ if sharemulticastiface != "" { Cfg.MulticastInterface = sharemulticastiface } + if shareOnce { + Cfg.ShareOnce = true + } protocol := "HTTPS" if !Cfg.HttpsEnabled { @@ -112,6 +118,9 @@ var shareCmd = &cobra.Command{ cli.PrintInfo("Alias: %s", displayAlias) cli.PrintInfo("Protocol: %s", protocol) cli.PrintInfo("Port: %d", Cfg.Port) + if Cfg.HttpsEnabled && Cfg.SecurityContext != nil && Cfg.SecurityContext.CertificateHash != "" { + cli.PrintInfo("Fingerprint: %s...", Cfg.SecurityContext.CertificateHash[:16]) + } } // Verify and prepare files @@ -235,18 +244,29 @@ var shareCmd = &cobra.Command{ if !sharequiet { cli.PrintSuccess("Server ready! Waiting for connections...") + scheme := "http" + if Cfg.HttpsEnabled { + scheme = "https" + } + // Retrieve active network interfaces to display direct URLs localIPs, err := network.GetLocalIPAddresses() if err == nil && len(localIPs) > 0 { cli.PrintHeader("\nAccess URLs:") for _, ip := range localIPs { - scheme := "https" - if !Cfg.HttpsEnabled { - scheme = "http" - } cli.PrintInfo(" %s://%s:%d", scheme, ip.String(), Cfg.Port) } fmt.Println() + + // Terminal QR code for mobile scanning (must match server scheme) + primaryURL := fmt.Sprintf("%s://%s:%d", scheme, localIPs[0].String(), Cfg.Port) + cli.PrintHeader("Scan QR Code on Mobile:") + qrterminal.GenerateHalfBlock(primaryURL, qrterminal.M, os.Stdout) + } + + if Cfg.HttpsEnabled { + cli.PrintWarning("HTTPS notice: browsers will show a security warning for the self-signed certificate.") + cli.PrintWarning("Proceed past the warning (Advanced → Proceed) to download files.") } cli.PrintWarning("Press Ctrl+C to stop sharing") @@ -269,8 +289,8 @@ func init() { rootCmd.AddCommand(shareCmd) shareCmd.Flags().StringSliceVar(&sharefiles, "file", []string{}, "File or directory to share") shareCmd.Flags().IntVar(&shareport, "port", 0, "Port to run the server on") - shareCmd.Flags().BoolVar(&shareuseHTTP, "http", false, "Deprecated (HTTP is now default for share)") - shareCmd.Flags().BoolVar(&shareuseHTTPS, "https", false, "Use HTTPS (browsers will reject self-signed certs)") + shareCmd.Flags().BoolVar(&shareuseHTTP, "http", false, "Deprecated (HTTP is already the default for share)") + shareCmd.Flags().BoolVar(&shareuseHTTPS, "https", false, "Use HTTPS with a self-signed certificate (browsers show a security warning)") shareCmd.Flags().StringVar(&sharepin, "pin", "", "PIN for authentication") shareCmd.Flags().StringVar(&sharealias, "alias", "", "Device alias") shareCmd.Flags().BoolVar(&shareautoAccept, "auto-accept", false, "Auto-accept incoming files") @@ -281,6 +301,7 @@ func init() { shareCmd.Flags().BoolVar(&sharezip, "zip", false, "Zip directories before sharing") shareCmd.Flags().IntVar(&shareconcurrency, "concurrency", 0, "Max parallel uploads (0 = use default)") shareCmd.Flags().StringVar(&sharemulticastiface, "iface", "", "Multicast network interface name") + shareCmd.Flags().BoolVar(&shareOnce, "once", false, "Stop sharing automatically after the first download completes") shareCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { if h := help.GetCommandHelp("share"); h != nil { diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 2f19977..afd88cb 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -84,8 +84,9 @@ localgo share --file FILE [flags] |------|------|---------|-------------| | `--file` | stringSlice | — | File or directory to share (can be repeated) | | `--port` | int | from config | Port to run the server on | -| `--http` | bool | false | Deprecated (HTTP is now default for share) | -| `--https` | bool | false | Use HTTPS (browsers will reject self-signed certs) | +| `--http` | bool | false | Deprecated (HTTP is already the default for share) | +| `--https` | bool | false | Use HTTPS with a self-signed certificate (browsers show a security warning) | +| `--once` | bool | false | Stop sharing automatically after the first download completes | | `--pin` | string | — | PIN for authentication | | `--alias` | string | from config | Device alias | | `--auto-accept` | bool | false | Auto-accept incoming files without prompting | @@ -103,6 +104,8 @@ 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 +localgo share --file document.pdf --https +localgo share --file photo.jpg --once ``` --- @@ -129,6 +132,8 @@ localgo send --file FILE [flags] | `--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) | +| `--quick`, `-q` | bool | false | Fast discovery mode (skip cache probe, multicast burst only) | +| `--pin` | string | — | PIN for sender authentication | **Discovery Logic:** 1. **Direct IP** (`--ip`): Skips discovery entirely, sends directly to the given IP:port. @@ -148,6 +153,8 @@ 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 +localgo send --file large.zip --quick +localgo send --file secret.pdf --to MyPhone --pin 1234 ``` --- @@ -278,6 +285,18 @@ Get a single config value by key. ### `localgo config set ` Set a config value. Automatically detects the type (int, bool, float64, string). +### `localgo config add ` +Append a value to a list config key (e.g. `static_peers`, `trusted_fingerprints`). + +### `localgo config remove ` +Remove a value from a list config key. + +### `localgo config open` +Open the config file in the default text editor. + +### `localgo config unset ` +Unset a config key, reverting it to its default value. + ### `localgo config list` List all config values. @@ -288,6 +307,10 @@ Show the config file path. ```bash localgo config get port localgo config set alias "MyDevice" +localgo config add static_peers "10.0.0.5:53317" +localgo config remove static_peers "10.0.0.5:53317" +localgo config open +localgo config unset port localgo config list localgo config path ``` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 775f4fc..4a76e9c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -24,6 +24,8 @@ These can be passed before any subcommand. | `--no-color` | Disable colored output | `false` | | `--config` | Config file path | — | | `--private`, `-p` | Hide device identity during discovery and transfer | `false` | +| `-v`, `--version` | Show version information | — | +| `-h`, `--help` | Show help | — | ### `serve` Flags | Flag | Description | Default | @@ -75,6 +77,8 @@ These can be passed before any subcommand. | `--iface` | Multicast network interface name | — | | `--clipboard`, `-c` | Send current system clipboard text directly | `false` | | `--stdin` | Send text read from standard input (stdin) | `false` | +| `--quick`, `-q` | Fast discovery mode (skip cache probe, multicast burst only) | `false` | +| `--pin` | PIN for sender authentication | — | ### `discover` Flags | Flag | Description | Default | @@ -92,12 +96,17 @@ These can be passed before any subcommand. | `--json` | Output results in JSON format | `false` | | `--quiet` | Only show results, no status messages | `false` | -### `devices` / `info` Flags +### `devices` Flags | Flag | Description | Default | |------|-------------|---------| | `--json` | Output results in JSON format | `false` | | `--probe` | Probe cached devices to verify if they are currently online | `false` | +### `info` Flags +| Flag | Description | Default | +|------|-------------|---------| +| `--json` | Output results in JSON format | `false` | + ### `history` Flags | Flag | Description | Default | |------|-------------|---------| @@ -129,6 +138,11 @@ You can set these globally to avoid repeating flags. | `LOCALSEND_QUIET` | Minimal output mode | `false` | | `LOCALSEND_CONCURRENCY` | Max parallel upload workers | `4` | | `LOCALSEND_MULTICAST_INTERFACE` | Network interface to bind multicast to | (all) | +| `LOCALSEND_DISCOVERY_STRATEGY` | Discovery strategy (`full`/`fast`) | `full` | +| `LOCALSEND_FILE_CONFLICT_RESOLUTION` | File conflict mode (`rename`/`overwrite`/`skip`) | `rename` | +| `LOCALSEND_BIND_ADDRESS` | Bind to specific IP address | (all) | +| `LOCALSEND_STATIC_PEERS` | Comma-separated list of static peer IP:port | — | +| `LOCALSEND_TRUSTED_FINGERPRINTS` | Comma-separated list of trusted device fingerprints | — | | `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) | diff --git a/go.mod b/go.mod index dcc4029..592b69b 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/jackpal/gateway v1.2.0 + github.com/mdp/qrterminal/v3 v3.2.1 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.11.1 @@ -77,4 +78,5 @@ require ( golang.org/x/text v0.37.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 57b7755..89a9f3d 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -193,3 +195,5 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/pkg/clipboard/clipboard.go b/pkg/clipboard/clipboard.go index 9362f83..8de8f5f 100644 --- a/pkg/clipboard/clipboard.go +++ b/pkg/clipboard/clipboard.go @@ -6,9 +6,12 @@ package clipboard import ( + "context" "fmt" + "os" "os/exec" "strings" + "time" ) // provider holds the resolved clipboard commands for this run. @@ -32,10 +35,30 @@ func Write(text string) error { if provider == nil { return fmt.Errorf("clipboard unavailable: no supported tool found (install xclip, xsel, wl-copy, pbcopy, or clip.exe)") } - cmd := exec.Command(provider.cmd, provider.args...) //nolint:gosec + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, provider.cmd, provider.args...) //nolint:gosec cmd.Stdin = strings.NewReader(text) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("clipboard write failed (%s): %w: %s", provider.cmd, err, strings.TrimSpace(string(out))) + + // Do NOT use CombinedOutput() here. Tools like xclip and wl-copy fork into + // the background to hold the selection and inherit stdout/stderr write pipes. + // Go's exec pipe reader goroutines wait indefinitely for EOF on those pipes, + // causing cmd.Wait() to hang until the process or daemon is killed. + // Using os.DevNull prevents pipe creation and inheritance. + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err == nil { + cmd.Stdout = devNull + cmd.Stderr = devNull + defer devNull.Close() + } + + if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("clipboard write timed out (%s)", provider.cmd) + } + return fmt.Errorf("clipboard write failed (%s): %w", provider.cmd, err) } return nil } @@ -46,9 +69,16 @@ func Read() (string, error) { if provider == nil || provider.readCmd == "" { 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 + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, provider.readCmd, provider.readArgs...) //nolint:gosec out, err := cmd.Output() if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("clipboard read timed out (%s)", provider.readCmd) + } // 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 { diff --git a/pkg/config/config.go b/pkg/config/config.go index a7bfaa9..7650d73 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -18,7 +18,7 @@ import ( const ( DefaultPort = 53317 DefaultMulticastGroup = "224.0.0.167" - ProtocolVersion = "2.0" + ProtocolVersion = "2.1" DefaultSecurityDir = ".localgo_security" DefaultSecurityFile = "context.json" ) @@ -44,7 +44,13 @@ type Config struct { OpenDir bool `json:"-"` // open download directory after transfer Concurrency int `json:"-"` // max parallel uploads (0 = use default) MulticastInterface string `json:"-"` // multicast network interface name - Private bool `json:"-"` // anonymize device identities + Private bool `json:"-"` // anonymize device identities + DiscoveryStrategy string `json:"-"` // discovery strategy: "full" (default) or "fast" (skip subnet scan) + FileConflictResolve string `json:"-"` // conflict resolution: "rename" (default), "overwrite", "skip" + BindAddress string `json:"-"` // bind to specific interface/IP for listening + ShareOnce bool `json:"-"` // stop after first download (--once) + StaticPeers []string `json:"-"` // statically defined peer addresses for Tier 1 cache probe + TrustedFingerprints []string `json:"-"` // fingerprints that bypass the transfer acceptance prompt Shell string `json:"-"` // shell command prefix for exec hooks (default: "sh -c" or "cmd /c") ClipboardWriteCmd string `json:"-"` // custom clipboard write command @@ -201,32 +207,48 @@ func LoadConfig(v *viper.Viper, logger *zap.SugaredLogger) (*Config, error) { customTLSCertPath := v.GetString("tls_cert") customTLSKeyPath := v.GetString("tls_key") notificationCmd := v.GetString("notification_cmd") + discoveryStrategy := v.GetString("discovery_strategy") + if discoveryStrategy == "" { + discoveryStrategy = "full" + } + fileConflictResolve := v.GetString("file_conflict_resolution") + if fileConflictResolve == "" { + fileConflictResolve = "rename" + } + bindAddress := v.GetString("bind_address") + staticPeers := v.GetStringSlice("static_peers") + trustedFingerprints := v.GetStringSlice("trusted_fingerprints") cfg := &Config{ - Alias: alias, - Port: port, - MulticastGroup: multicastGroup, - HttpsEnabled: HttpsEnabled, - SecurityContext: securityContext, - SecurityPath: securityFilePath, - DeviceModel: &deviceModel, - DeviceType: deviceType, - DownloadDir: downloadDir, - AutoAccept: autoAccept, - RandomFingerprint: generateRandomID(64), - MaxBodySize: maxBodySize, - NoClipboard: noClipboard, - HistoryFile: historyFile, - Quiet: quiet, - ExecHook: execHook, - Concurrency: concurrency, + Alias: alias, + Port: port, + MulticastGroup: multicastGroup, + HttpsEnabled: HttpsEnabled, + SecurityContext: securityContext, + SecurityPath: securityFilePath, + DeviceModel: &deviceModel, + DeviceType: deviceType, + DownloadDir: downloadDir, + AutoAccept: autoAccept, + RandomFingerprint: generateRandomID(64), + MaxBodySize: maxBodySize, + NoClipboard: noClipboard, + HistoryFile: historyFile, + Quiet: quiet, + ExecHook: execHook, + Concurrency: concurrency, MulticastInterface: multicastInterface, - Shell: shell, - ClipboardWriteCmd: clipboardWriteCmd, - ClipboardReadCmd: clipboardReadCmd, - CustomTLSCertPath: customTLSCertPath, - CustomTLSKeyPath: customTLSKeyPath, - NotificationCmd: notificationCmd, + DiscoveryStrategy: discoveryStrategy, + FileConflictResolve: fileConflictResolve, + BindAddress: bindAddress, + StaticPeers: staticPeers, + TrustedFingerprints: trustedFingerprints, + Shell: shell, + ClipboardWriteCmd: clipboardWriteCmd, + ClipboardReadCmd: clipboardReadCmd, + CustomTLSCertPath: customTLSCertPath, + CustomTLSKeyPath: customTLSKeyPath, + NotificationCmd: notificationCmd, } return cfg, nil diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 5e4c428..1495262 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -2,9 +2,7 @@ package config import ( "os" - "path/filepath" "testing" - "time" "github.com/bethropolis/localgo/pkg/model" "github.com/spf13/viper" @@ -266,10 +264,7 @@ func TestConfig_Constants(t *testing.T) { t.Errorf("Expected DefaultMulticastGroup '224.0.0.167', got '%s'", DefaultMulticastGroup) } - if ProtocolVersion != "2.0" { - t.Errorf("Expected ProtocolVersion '2.0', got '%s'", ProtocolVersion) + if ProtocolVersion != "2.1" { + t.Errorf("Expected ProtocolVersion '2.1', got '%s'", ProtocolVersion) } } - -var _ = time.Now // silence unused import -var _ = filepath.Join // silence unused import diff --git a/pkg/discovery/announce.go b/pkg/discovery/announce.go index 7621f03..c92ab99 100644 --- a/pkg/discovery/announce.go +++ b/pkg/discovery/announce.go @@ -11,6 +11,12 @@ import ( "github.com/bethropolis/localgo/pkg/model" ) +// multicastBurstCount is the number of rapid UDP multicast bursts sent. +const multicastBurstCount = 3 + +// multicastBurstInterval is the delay between consecutive bursts. +const multicastBurstInterval = 30 * time.Millisecond + // SendDiscoveryAnnouncement sends a multicast announcement func (md *MulticastDiscovery) SendDiscoveryAnnouncement() error { announcementDto := md.dto @@ -48,13 +54,16 @@ func (md *MulticastDiscovery) SendDiscoveryAnnouncement() error { } defer conn.Close() - _, err = conn.Write(data) - if err != nil { - return fmt.Errorf("failed to send multicast announcement: %w", err) + for burst := 0; burst < multicastBurstCount; burst++ { + _, err = conn.Write(data) + if err != nil { + return fmt.Errorf("failed to send multicast announcement (burst %d): %w", burst, err) + } + time.Sleep(multicastBurstInterval) } - md.logger.Debugf("Sent multicast announcement as %s (fingerprint: %s) to %s", - md.dto.Alias, getShortFingerprint(md.dto.Fingerprint), md.config.MulticastAddr) + md.logger.Debugf("Sent %d multicast announcement bursts as %s (fingerprint: %s) to %s", + multicastBurstCount, md.dto.Alias, getShortFingerprint(md.dto.Fingerprint), md.config.MulticastAddr) return nil } diff --git a/pkg/discovery/http_discovery.go b/pkg/discovery/http_discovery.go index b7b68a2..76077db 100644 --- a/pkg/discovery/http_discovery.go +++ b/pkg/discovery/http_discovery.go @@ -132,13 +132,26 @@ func (hd *HTTPDiscovery) RegisterWithDevice(ctx context.Context, ip net.IP, port }, nil } +// tcpPreProbe performs a quick TCP dial to check if a host is reachable. +func tcpPreProbe(ctx context.Context, ip net.IP, port int) bool { + addr := net.JoinHostPort(ip.String(), strconv.Itoa(port)) + dialer := net.Dialer{Timeout: 150 * time.Millisecond} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return false + } + conn.Close() + return true +} + func (hd *HTTPDiscovery) ScanNetwork(ctx context.Context, ips []net.IP, port int) ([]*model.Device, error) { var devices []*model.Device + var mu sync.Mutex var wg sync.WaitGroup deviceChan := make(chan *model.Device, len(ips)) // Semaphore limits parallel pinging to prevent socket exhaustion - sem := make(chan struct{}, 100) + sem := make(chan struct{}, 256) hd.logger.Debugf("Scanning %d IPs on port %d", len(ips), port) @@ -150,6 +163,11 @@ func (hd *HTTPDiscovery) ScanNetwork(ctx context.Context, ips []net.IP, port int sem <- struct{}{} defer func() { <-sem }() + // TCP pre-probe: 150ms dial to quickly eliminate dead IPs + if !tcpPreProbe(ctx, ip, port) { + return + } + device, err := hd.RegisterWithDevice(ctx, ip, port, "https") if err != nil { device, err = hd.RegisterWithDevice(ctx, ip, port, "http") @@ -166,7 +184,9 @@ func (hd *HTTPDiscovery) ScanNetwork(ctx context.Context, ips []net.IP, port int close(deviceChan) for device := range deviceChan { + mu.Lock() devices = append(devices, device) + mu.Unlock() } return devices, nil diff --git a/pkg/discovery/peercache.go b/pkg/discovery/peercache.go index 2afe942..228430b 100644 --- a/pkg/discovery/peercache.go +++ b/pkg/discovery/peercache.go @@ -16,11 +16,18 @@ import ( "go.uber.org/zap" ) +// MaxCachedPeers is the maximum number of peers to keep in cache. +const MaxCachedPeers = 50 + +// StaleThreshold is how long without contact before a peer is evicted. +const StaleThreshold = 14 * 24 * time.Hour + // PeerCache persists discovered peers to disk and provides thread-safe access. type PeerCache struct { mu sync.RWMutex filePath string peers map[string]*model.Device + order []string // LRU order (most recent at end) logger *zap.SugaredLogger } @@ -39,35 +46,83 @@ func NewPeerCache(logger *zap.SugaredLogger) *PeerCache { pc := &PeerCache{ filePath: path, peers: make(map[string]*model.Device), + order: make([]string, 0, MaxCachedPeers), logger: logger, } pc.load() return pc } -// Save adds or updates a peer and persists atomically. +// Save adds or updates a peer, updates LRU order, evicts stale/over-limit entries, and persists. func (pc *PeerCache) Save(device *model.Device) { pc.mu.Lock() defer pc.mu.Unlock() + now := time.Now() + device.SetLastSeen(now) + + if _, exists := pc.peers[device.Fingerprint]; !exists { + pc.order = append(pc.order, device.Fingerprint) + } pc.peers[device.Fingerprint] = device + + // Evict stale peers first + staleCutoff := now.Add(-StaleThreshold) + var fresh []string + for _, fp := range pc.order { + d, ok := pc.peers[fp] + if !ok || (!d.GetLastSeen().IsZero() && d.GetLastSeen().Before(staleCutoff)) { + delete(pc.peers, fp) + continue + } + fresh = append(fresh, fp) + } + pc.order = fresh + + // LRU evict oldest entries if over cap + for len(pc.order) > MaxCachedPeers { + fp := pc.order[0] + pc.order = pc.order[1:] + delete(pc.peers, fp) + } + if err := pc.persist(); err != nil { pc.logger.Warnf("Failed to persist peer cache: %v", err) } } -// GetPeers returns a snapshot of all cached peers. +// touchLRU moves the given fingerprint to the end (most recently used). +func (pc *PeerCache) touchLRU(fingerprint string) { + for i, fp := range pc.order { + if fp == fingerprint { + pc.order = append(pc.order[:i], pc.order[i+1:]...) + pc.order = append(pc.order, fp) + break + } + } +} + +// GetPeers returns a snapshot of all cached peers (most recently seen first). func (pc *PeerCache) GetPeers() []*model.Device { pc.mu.RLock() defer pc.mu.RUnlock() - list := make([]*model.Device, 0, len(pc.peers)) - for _, d := range pc.peers { - list = append(list, d) + list := make([]*model.Device, 0, len(pc.order)) + for i := len(pc.order) - 1; i >= 0; i-- { + if d, ok := pc.peers[pc.order[i]]; ok { + list = append(list, d) + } } return list } +// GetByFingerprint returns a cached peer by fingerprint. +func (pc *PeerCache) GetByFingerprint(fp string) *model.Device { + pc.mu.RLock() + defer pc.mu.RUnlock() + return pc.peers[fp] +} + // load reads peers.json into the in-memory map. Must be called with mu held. func (pc *PeerCache) load() { pc.mu.Lock() @@ -87,22 +142,25 @@ func (pc *PeerCache) load() { return } - staleThreshold := 30 * 24 * time.Hour now := time.Now() + staleCutoff := now.Add(-StaleThreshold) evictedCount := 0 for _, d := range list { - // Evict peers not seen in the last 30 days - if !d.GetLastSeen().IsZero() && now.Sub(d.GetLastSeen()) > staleThreshold { + if !d.GetLastSeen().IsZero() && d.GetLastSeen().Before(staleCutoff) { evictedCount++ continue } + if len(pc.order) >= MaxCachedPeers { + evictedCount++ + continue + } + pc.order = append(pc.order, d.Fingerprint) pc.peers[d.Fingerprint] = d } if evictedCount > 0 { - pc.logger.Debugf("Evicted %d stale peer(s) from the local cache (older than 30 days)", evictedCount) - // Persist the cleaned cache back to disk in the background + pc.logger.Debugf("Evicted %d stale/over-limit peer(s) from the local cache (older than 14 days or >%d entries)", evictedCount, MaxCachedPeers) go func() { pc.mu.Lock() defer pc.mu.Unlock() @@ -111,12 +169,19 @@ func (pc *PeerCache) load() { } } -// persist writes the in-memory map to disk atomically via a temp file + rename. +// cachedPeer represents a peer with its LRU ordering for serialization. +type cachedPeer struct { + Device *model.Device `json:"device"` +} + +// persist writes the in-memory cache to disk atomically via a temp file + rename. // Must be called with mu held. func (pc *PeerCache) persist() error { - list := make([]*model.Device, 0, len(pc.peers)) - for _, d := range pc.peers { - list = append(list, d) + list := make([]*model.Device, 0, len(pc.order)) + for _, fp := range pc.order { + if d, ok := pc.peers[fp]; ok { + list = append(list, d) + } } data, err := json.MarshalIndent(list, "", " ") diff --git a/pkg/discovery/quick.go b/pkg/discovery/quick.go index cbca9a0..e527fa1 100644 --- a/pkg/discovery/quick.go +++ b/pkg/discovery/quick.go @@ -8,6 +8,9 @@ import ( "github.com/bethropolis/localgo/pkg/model" ) +// DiscoverDevices performs tiered discovery: cache probe, then multicast. +// The subnet scan fallback is omitted here — the caller (interactive send) +// already falls back to a subnet scan if no devices are found. func DiscoverDevices(ctx context.Context, serviceCfg *ServiceConfig, appCfg *config.Config, httpsEnabled bool) ([]*model.Device, error) { if serviceCfg == nil { serviceCfg = DefaultServiceConfig() @@ -23,8 +26,6 @@ func DiscoverDevices(ctx context.Context, serviceCfg *ServiceConfig, appCfg *con svc := NewService(serviceCfg, multicast, nil) svc.SetPeerCache(peerCache) - - if err := svc.Start(ctx, multicastDto); err != nil { return nil, err } diff --git a/pkg/help/commands.go b/pkg/help/commands.go index ee26392..300db54 100644 --- a/pkg/help/commands.go +++ b/pkg/help/commands.go @@ -47,11 +47,17 @@ func GetCommandHelp(commandName string) *CommandHelp { "localgo share --file data.zip --auto-accept", "localgo share --file report.pdf --no-clipboard", "localgo share --file doc.pdf --exec 'curl -F \"file=@%f\" https://example.com/upload'", + "localgo share --file large.zip --zip", + "localgo share --file urgent.pdf --once", + "localgo share --file document.pdf --https", + "localgo share --file photo.jpg --iface eth0", }, Flags: []FlagHelp{ {Name: "--file", Type: "string", Default: "", Description: "File or directory to share (required, can be specified multiple times)"}, {Name: "--port", Type: "int", Default: "from config", Description: "Port to run the server on"}, - {Name: "--http", Type: "bool", Default: "false", Description: "Use HTTP instead of HTTPS"}, + {Name: "--http", Type: "bool", Default: "false", Description: "Deprecated (HTTP is already the default for share)"}, + {Name: "--https", Type: "bool", Default: "false", Description: "Use HTTPS with a self-signed certificate (browsers show a security warning)"}, + {Name: "--once", Type: "bool", Default: "false", Description: "Stop sharing automatically after the first download completes"}, {Name: "--pin", Type: "string", Default: "", Description: "PIN for authentication"}, {Name: "--alias", Type: "string", Default: "from config", Description: "Device alias"}, {Name: "--auto-accept", Type: "bool", Default: "false", Description: "Auto-accept incoming files without prompting"}, @@ -59,7 +65,7 @@ func GetCommandHelp(commandName string) *CommandHelp { {Name: "--zip", Type: "bool", Default: "false", Description: "Zip directories before sharing"}, {Name: "--concurrency", Type: "int", Default: "0", Description: "Max parallel uploads (0 = use default)"}, {Name: "--history", Type: "string", Default: "", Description: "Path to transfer history JSONL file"}, - {Name: "--exec", Type: "string", Default: "", Description: "Shell command to execute after each received file"}, + {Name: "--exec", Type: "string", Default: "", Description: "Shell command to execute after each received file (use %f, %n, %s, %a, %i)"}, {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - minimal output"}, {Name: "--iface", Type: "string", Default: "", Description: "Multicast network interface name"}, }, @@ -107,18 +113,24 @@ func GetCommandHelp(commandName string) *CommandHelp { "localgo send --file document.pdf --to MyPhone", "localgo send --ip 192.168.1.42 --file document.pdf", "localgo send --ip 192.168.1.42:53317 --file document.pdf", + "localgo send --ip myphone.local --file photo.jpg", "localgo send --clipboard --to MyPhone", "localgo send -c --to MyPhone", "localgo send --stdin --to MyPhone < list.txt", "echo 'message' | localgo send --stdin --to MyPhone", + "localgo send --file large.zip --quick", + "localgo send -q --file document.pdf --to Phone", + "localgo send --file secret.pdf --to MyPhone --pin 1234", "localgo send (starts interactive clipboard or file picker if empty)", }, Flags: []FlagHelp{ {Name: "--file", Type: "string", Default: "", Description: "File or directory to send (optional, can be specified multiple times)"}, - {Name: "--ip", Type: "string", Default: "", Description: "Target device IP (with optional :port, skips discovery)"}, + {Name: "--ip", Type: "string", Default: "", Description: "Target device IP (supports hostname/mDNS, with optional :port)"}, {Name: "--to", Type: "string", Default: "", Description: "Target device alias (omit to pick interactively)"}, {Name: "--clipboard, -c", Type: "bool", Default: "false", Description: "Send current system clipboard text directly"}, {Name: "--stdin", Type: "bool", Default: "false", Description: "Send text read from standard input (stdin)"}, + {Name: "--quick, -q", Type: "bool", Default: "false", Description: "Skip subnet scan; use cache + multicast only for faster discovery"}, + {Name: "--pin", Type: "string", Default: "", Description: "PIN for receiver authentication"}, {Name: "--port", Type: "int", Default: "auto-detect", Description: "Target device port"}, {Name: "--timeout", Type: "int", Default: "30", Description: "Send timeout in seconds"}, {Name: "--alias", Type: "string", Default: "from config", Description: "Sender alias"}, @@ -150,7 +162,7 @@ func GetCommandHelp(commandName string) *CommandHelp { "localgo devices --json", }, Flags: []FlagHelp{ - {Name: "--probe, -p", Type: "bool", Default: "false", Description: "Probe cached devices to verify if they are currently online"}, + {Name: "--probe", Type: "bool", Default: "false", Description: "Probe cached devices to verify if they are currently online"}, {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, }, }, @@ -195,8 +207,21 @@ func GetCommandHelp(commandName string) *CommandHelp { "localgo config set alias MyDevice", "localgo config list", "localgo config path", + "localgo config unset port", + "localgo config open", + "localgo config add static_peers 10.0.0.5:53317", + "localgo config remove static_peers 10.0.0.5:53317", + }, + Flags: []FlagHelp{ + {Name: "get ", Type: "", Default: "", Description: "Get a config value"}, + {Name: "set ", Type: "", Default: "", Description: "Set a config value (with type/enum validation)"}, + {Name: "add ", Type: "", Default: "", Description: "Append a value to a list config key"}, + {Name: "remove ", Type: "", Default: "", Description: "Remove a value from a list config key"}, + {Name: "list", Type: "", Default: "", Description: "List all config values with origin ([file]/[env]/[default])"}, + {Name: "unset ", Type: "", Default: "", Description: "Remove a config key (reverts to default)"}, + {Name: "open", Type: "", Default: "", Description: "Open config file in system editor"}, + {Name: "path", Type: "", Default: "", Description: "Show config file path"}, }, - Flags: []FlagHelp{}, }, "version": { Name: "version", diff --git a/pkg/help/help.go b/pkg/help/help.go index 183a597..6510fea 100644 --- a/pkg/help/help.go +++ b/pkg/help/help.go @@ -47,7 +47,7 @@ func ShowMainUsage() { {"devices", "List recently discovered devices"}, {"history", "Show file transfer history log"}, {"stop", "Stop the running LocalGo daemon"}, - {"config", "Manage LocalGo configuration (get/set/list/path)"}, + {"config", "Manage LocalGo configuration (get/set/add/remove/open/unset/list/path)"}, {"info", "Show device information"}, {"completion", "Generate shell completion scripts"}, {"help", "Show help information"}, @@ -74,6 +74,7 @@ func ShowMainUsage() { {"-v, --version", "Show version"}, {"--verbose", "Enable debug logging"}, {"--json", "Enable JSON log output"}, + {"--no-color", "Disable colored output"}, {"--private, -p", "Hide device identity during discovery/transfer"}, {"--config", "Config file path"}, } @@ -100,8 +101,19 @@ func ShowMainUsage() { "localgo send --file document.pdf --to MyPhone", "localgo send --clipboard --to MyPhone", "localgo send --stdin < document.txt --to MyPhone", + "localgo send --file large.zip --quick", + "localgo send --ip 192.168.1.42:53317 --file photo.jpg", + "localgo send --file secret.pdf --to MyPhone --pin 1234", "localgo share --file document.pdf", + "localgo share --file photo.jpg --once", "localgo history --limit 20", + "localgo scan --range 192.168.1.0/24", + "localgo config set alias MyDevice", + "localgo config add static_peers 10.0.0.5:53317", + "localgo config remove static_peers 10.0.0.5:53317", + "localgo config open", + "localgo config unset port", + "localgo config list", "localgo help send", } @@ -131,6 +143,18 @@ func ShowMainUsage() { {"LOCALSEND_MULTICAST_GROUP", "Multicast group address"}, {"LOCALSEND_SECURITY_DIR", "Security directory path"}, {"LOCALSEND_LOG_LEVEL", "Log verbosity (debug/info/warn/error)"}, + {"LOCALSEND_DISCOVERY_STRATEGY", "Discovery strategy: full (default) or fast"}, + {"LOCALSEND_FILE_CONFLICT_RESOLUTION", "File conflict: rename (default), overwrite, skip"}, + {"LOCALSEND_BIND_ADDRESS", "Bind to specific IP/interface"}, + {"LOCALSEND_STATIC_PEERS", "Comma-separated list of static peer IP:port"}, + {"LOCALSEND_TRUSTED_FINGERPRINTS", "Comma-separated list of trusted device fingerprints"}, + {"LOCALSEND_CONCURRENCY", "Max parallel upload workers (default: 4)"}, + {"LOCALSEND_SHELL", "Shell prefix for exec hooks (default: sh -c)"}, + {"LOCALSEND_CLIPBOARD_WRITE_CMD", "Custom clipboard write command"}, + {"LOCALSEND_CLIPBOARD_READ_CMD", "Custom clipboard read command"}, + {"LOCALSEND_TLS_CERT", "Custom TLS certificate path"}, + {"LOCALSEND_TLS_KEY", "Custom TLS private key path"}, + {"LOCALSEND_NOTIFICATION_CMD", "Custom notification command"}, } for _, env := range envVars { diff --git a/pkg/send/send.go b/pkg/send/send.go index 770d002..b467de6 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -60,20 +60,72 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci var targetDevice *model.Device - // --- Multicast Discovery (Fast) --- - logger.Info("Sending multicast announcement...") - discoverySvcConfig := discovery.DefaultServiceConfig() discoverySvcConfig.MulticastConfig.Port = cfg.Port discoverySvcConfig.MulticastConfig.MulticastAddr = fmt.Sprintf("%s:%d", cfg.MulticastGroup, cfg.Port) discoverySvcConfig.MulticastConfig.InterfaceName = cfg.MulticastInterface multicastDto := cfg.ToMulticastDto(false) + peerCache := discovery.NewPeerCache(logger) + + // --- Tier 1: Cache Probe (400ms) --- + logger.Info("Probing cached peers...") + cacheCtx, cancelCache := context.WithTimeout(ctx, 400*time.Millisecond) + discovery.ProbeCached(cacheCtx, peerCache, func(device *model.Device) { + if device.Alias == recipientAlias && targetDevice == nil { + targetDevice = device + } + }, logger) + cancelCache() + + // --- Tier 1b: Static Peers Probe (400ms per peer) --- + if targetDevice == nil && len(cfg.StaticPeers) > 0 { + logger.Infof("Probing %d static peer(s)...", len(cfg.StaticPeers)) + registerDto := cfg.ToRegisterDto() + httpDisc := discovery.NewHTTPDiscovery(nil, registerDto, nil, logger) + for _, staticPeer := range cfg.StaticPeers { + host, portStr, err := net.SplitHostPort(staticPeer) + if err != nil { + host = staticPeer + portStr = strconv.Itoa(recipientPort) + } + port, _ := strconv.Atoi(portStr) + ip := net.ParseIP(host) + if ip == nil { + ips, lookupErr := net.LookupIP(host) + if lookupErr != nil || len(ips) == 0 { + logger.Warnf("Static peer %s: hostname lookup failed: %v", staticPeer, lookupErr) + continue + } + ip = ips[0] + } + + peerCtx, cancelPeer := context.WithTimeout(ctx, 400*time.Millisecond) + dev, fetchErr := httpDisc.FetchDeviceInfo(peerCtx, ip, port) + cancelPeer() + + if fetchErr == nil && dev != nil && dev.Alias == recipientAlias { + logger.Infof("Discovered recipient via static peer: %s (%s)", dev.Alias, staticPeer) + targetDevice = dev + break + } + } + } + + if targetDevice != nil { + logger.Infof("Discovered recipient via cache: %s (%s)", targetDevice.Alias, targetDevice.IP) + if err := VerifyDeviceFingerprint(peerCache, targetDevice); err != nil { + return err + } + return SendToDevice(ctx, cfg, targetDevice, filePaths, logger, opts...) + } + + // --- Tier 2: Multicast Discovery --- + logger.Info("Sending multicast announcement...") + multicast := discovery.NewMulticastDiscovery(discoverySvcConfig.MulticastConfig, multicastDto, logger) httpDiscoverer := discovery.NewHTTPDiscovery(nil, cfg.ToRegisterDto(), nil, logger) multicast.SetHTTPDiscoverer(httpDiscoverer) - - peerCache := discovery.NewPeerCache(logger) multicast.SetPeerCache(peerCache) discoverySvc := discovery.NewService(discoverySvcConfig, multicast, logger) @@ -89,8 +141,7 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci } }) - multicastCtx, cancelMulticast := context.WithTimeout(ctx, 1500*time.Millisecond) - defer cancelMulticast() + multicastCtx, cancelMulticast := context.WithTimeout(ctx, 1200*time.Millisecond) err := discoverySvc.Start(multicastCtx, cfg.ToMulticastDto(false)) if err != nil { @@ -103,17 +154,26 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci targetDevice = device discoverySvc.Stop() case <-multicastCtx.Done(): - logger.Info("Multicast discovery timed out, falling back to HTTP scan...") - discoverySvc.Stop() + logger.Info("Multicast discovery timed out") } + cancelMulticast() + discoverySvc.Stop() if targetDevice != nil { - if err := verifyDeviceFingerprint(peerCache, targetDevice); err != nil { + if err := VerifyDeviceFingerprint(peerCache, targetDevice); err != nil { return err } return SendToDevice(ctx, cfg, targetDevice, filePaths, logger, opts...) } + // Skip Tier 3 (subnet scan) in fast mode + if cfg.DiscoveryStrategy == "fast" { + return fmt.Errorf("recipient '%s' not found on network (cache + multicast missed, scan skipped via fast strategy)", recipientAlias) + } + + // --- Tier 3: Subnet Scan --- + logger.Info("Scanning local subnets...") + registerDto := cfg.ToRegisterDto() httpFallback := discovery.NewHTTPDiscovery(nil, registerDto, nil, logger) @@ -131,7 +191,6 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci } ips = append(ips, net.ParseIP("127.0.0.1")) - // Give the scan a proper chunk of time to test all IPs safely scanCtx, cancelScan := context.WithTimeout(ctx, 15*time.Second) defer cancelScan() @@ -148,12 +207,12 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci } if targetDevice == nil { - return fmt.Errorf("recipient '%s' not found on network after scan", recipientAlias) + return fmt.Errorf("recipient '%s' not found on network after all discovery tiers", recipientAlias) } - logger.Infof("Discovered recipient via HTTP Scan: %s (%s)", targetDevice.Alias, targetDevice.IP) + logger.Infof("Discovered recipient via subnet scan: %s (%s)", targetDevice.Alias, targetDevice.IP) - if err := verifyDeviceFingerprint(peerCache, targetDevice); err != nil { + if err := VerifyDeviceFingerprint(peerCache, targetDevice); err != nil { return err } @@ -369,7 +428,11 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, return fmt.Errorf("failed to marshal prepare dto: %w", err) } - url := fmt.Sprintf("%s://%s/api/localsend/v2/prepare-upload", scheme, net.JoinHostPort(device.IP, strconv.Itoa(device.Port))) + baseURL := fmt.Sprintf("%s://%s/api/localsend/v2/prepare-upload", scheme, net.JoinHostPort(device.IP, strconv.Itoa(device.Port))) + if cfg.PIN != "" { + baseURL += "?pin=" + cfg.PIN + } + url := baseURL req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create prepare request: %w", err) @@ -423,7 +486,7 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, 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) + err := uploadStream(ctx, client, device, rdr, sz, fID, prepareResponse.SessionID, tkn, scheme, cfg.PIN, track, logger) if err != nil { logger.Errorf("Failed to upload %s: %v", name, err) errCh <- fmt.Errorf("failed to upload %s: %w", name, err) @@ -444,7 +507,7 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, defer func() { <-sem }() logger.Infof("Uploading file: %s", filepath.Base(fPath)) - err := uploadFile(ctx, client, device, fPath, fID, prepareResponse.SessionID, tkn, scheme, track, logger) + err := uploadFile(ctx, client, device, fPath, fID, prepareResponse.SessionID, tkn, scheme, cfg.PIN, 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) diff --git a/pkg/send/upload.go b/pkg/send/upload.go index 83ad4c4..f9465cc 100644 --- a/pkg/send/upload.go +++ b/pkg/send/upload.go @@ -29,7 +29,7 @@ type fileReader interface { 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 { +func uploadFile(ctx context.Context, client *http.Client, device *model.Device, filePath, fileID, sessionID, token, scheme, pin string, trackProgress func(int64), logger *zap.SugaredLogger) error { if logger == nil { logger = zap.NewNop().Sugar() } @@ -45,15 +45,18 @@ func uploadFile(ctx context.Context, client *http.Client, device *model.Device, return fmt.Errorf("failed to get file stats: %w", err) } - return uploadStream(ctx, client, device, file, stat.Size(), fileID, sessionID, token, scheme, trackProgress, logger) + return uploadStream(ctx, client, device, file, stat.Size(), fileID, sessionID, token, scheme, pin, 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 { +func uploadStream(ctx context.Context, client *http.Client, device *model.Device, r fileReader, size int64, fileID, sessionID, token, scheme, pin 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) + if pin != "" { + url += "&pin=" + pin + } var body io.ReadCloser = io.NopCloser(r) if trackProgress != nil { diff --git a/pkg/send/verify.go b/pkg/send/verify.go index de5ec4a..d8d0f55 100644 --- a/pkg/send/verify.go +++ b/pkg/send/verify.go @@ -9,7 +9,7 @@ import ( "github.com/charmbracelet/huh" ) -func verifyDeviceFingerprint(peerCache *discovery.PeerCache, targetDevice *model.Device) error { +func VerifyDeviceFingerprint(peerCache *discovery.PeerCache, targetDevice *model.Device) error { if targetDevice == nil || targetDevice.Fingerprint == "" { return nil } diff --git a/pkg/server/handlers/download_handlers.go b/pkg/server/handlers/download_handlers.go index 0b007aa..7d1bd15 100644 --- a/pkg/server/handlers/download_handlers.go +++ b/pkg/server/handlers/download_handlers.go @@ -6,7 +6,9 @@ import ( "io" "net/http" "os" + "time" + "github.com/bethropolis/localgo/pkg/cli" "github.com/bethropolis/localgo/pkg/config" "github.com/bethropolis/localgo/pkg/httputil" "github.com/bethropolis/localgo/pkg/model" @@ -19,8 +21,15 @@ type DownloadHandler struct { config *config.Config sendService *services.SendService logger *zap.SugaredLogger + shutdownFn func() // optional; set by Server for --once support } +// SetShutdownFn registers a shutdown callback (used for --once mode). +func (h *DownloadHandler) SetShutdownFn(fn func()) { + h.shutdownFn = fn +} + + // NewDownloadHandler creates a new DownloadHandler. func NewDownloadHandler(cfg *config.Config, sendService *services.SendService, logger *zap.SugaredLogger) *DownloadHandler { return &DownloadHandler{ @@ -124,5 +133,16 @@ func (h *DownloadHandler) DownloadHandler(w http.ResponseWriter, r *http.Request h.logger.Errorf("Failed to write file to response: %v", err) } else { h.logger.Infof("Successfully sent file: %s", fileDto.FileName) + cli.PrintSuccess("Downloaded by %s: %s (%s)", r.RemoteAddr, fileDto.FileName, cli.FormatBytes(fileDto.Size)) + + if h.config.ShareOnce { + go func() { + time.Sleep(500 * time.Millisecond) + cli.PrintInfo("Download completed (--once mode). Stopping server...") + if h.shutdownFn != nil { + h.shutdownFn() + } + }() + } } } diff --git a/pkg/server/handlers/download_handlers_test.go b/pkg/server/handlers/download_handlers_test.go index 71184e2..f7b5dc7 100644 --- a/pkg/server/handlers/download_handlers_test.go +++ b/pkg/server/handlers/download_handlers_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "github.com/bethropolis/localgo/pkg/config" @@ -158,3 +159,84 @@ func TestDownloadHandler_InvalidSession(t *testing.T) { t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusNotFound) } } + +func TestWebShareHandler_RendersFiles(t *testing.T) { + handler, sendService, _ := setupDownloadHandler(t, &config.Config{Alias: "Studio-PC"}) + + files := map[string]model.FileDto{ + "b": {ID: "b", FileName: "photo.jpg", Size: 2048, FileType: "image/jpeg"}, + "a": {ID: "a", FileName: "notes.txt", Size: 12, FileType: "text/plain"}, + } + _, err := sendService.CreateSession(files, map[string]string{"a": "/tmp/a", "b": "/tmp/b"}) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + handler.WebShareHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + for _, want := range []string{ + "LocalGo Share", + "Studio-PC", + "notes.txt", + "photo.jpg", + "2 files", + `class="file-icon image"`, + "no-store", + } { + if want == "no-store" { + if rr.Header().Get("Cache-Control") != "no-store" { + t.Errorf("Cache-Control = %q, want no-store", rr.Header().Get("Cache-Control")) + } + continue + } + if !strings.Contains(body, want) { + t.Errorf("response missing %q", want) + } + } + // Sorted by name: notes before photo + if i, j := strings.Index(body, "notes.txt"), strings.Index(body, "photo.jpg"); i < 0 || j < 0 || i > j { + t.Errorf("expected notes.txt before photo.jpg in body") + } +} + +func TestWebShareHandler_PINLockAndError(t *testing.T) { + handler, sendService, _ := setupDownloadHandler(t, &config.Config{Alias: "Phone", PIN: "4242"}) + _, _ = sendService.CreateSession( + map[string]model.FileDto{"f": {ID: "f", FileName: "secret.pdf", Size: 100}}, + map[string]string{"f": "/tmp/secret.pdf"}, + ) + + // Locked without PIN + rr := httptest.NewRecorder() + handler.WebShareHandler(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + body := rr.Body.String() + if !strings.Contains(body, "PIN protected") { + t.Error("expected PIN lock screen") + } + if strings.Contains(body, "secret.pdf") { + t.Error("file name should be hidden while locked") + } + if strings.Contains(body, "Incorrect PIN") { + t.Error("should not show incorrect PIN when none was submitted") + } + + // Wrong PIN + rr = httptest.NewRecorder() + handler.WebShareHandler(rr, httptest.NewRequest(http.MethodGet, "/?pin=0000", nil)) + if !strings.Contains(rr.Body.String(), "Incorrect PIN") { + t.Error("expected incorrect PIN message") + } + + // Correct PIN + rr = httptest.NewRecorder() + handler.WebShareHandler(rr, httptest.NewRequest(http.MethodGet, "/?pin=4242", nil)) + if !strings.Contains(rr.Body.String(), "secret.pdf") { + t.Error("expected unlocked file list") + } +} diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index f00b2da..4f1f3f7 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -4,6 +4,7 @@ import ( "context" "crypto/subtle" "encoding/json" + "fmt" "net" "net/http" "os" @@ -11,6 +12,7 @@ import ( "runtime" "strings" "sync" + "time" "github.com/bethropolis/localgo/pkg/cli" "github.com/bethropolis/localgo/pkg/clipboard" @@ -95,21 +97,28 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R return } - // Extract IP from RemoteAddr early (used by clipboard path and elsewhere) + // Extract IP from RemoteAddr early senderIP, _, _ := net.SplitHostPort(r.RemoteAddr) + sender := model.DeviceInfo{ + Alias: cli.Sanitize(requestDto.Info.Alias), + Version: requestDto.Info.Version, + DeviceModel: requestDto.Info.DeviceModel, + DeviceType: requestDto.Info.DeviceType, + Fingerprint: requestDto.Info.Fingerprint, + IP: senderIP, + } + // --- 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 { + for _, 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 } @@ -118,12 +127,23 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R 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 + isTrusted := false + if sender.Fingerprint != "" && len(h.config.TrustedFingerprints) > 0 { + for _, trusted := range h.config.TrustedFingerprints { + if strings.EqualFold(trusted, sender.Fingerprint) { + isTrusted = true + break + } + } + } + if !isTrusted { + 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 + } } } @@ -134,23 +154,29 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R 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))) + fmt.Fprintln(os.Stderr, "✓ Copied to clipboard!") + os.Stderr.Sync() + h.logTransfer(sanitizedAlias, senderIP, "", "", int64(len(clipboardMessage)), "text/plain", history.StatusClipboard) + h.runExecHook("", "", sanitizedAlias, senderIP, int64(len(clipboardMessage))) + time.Sleep(1 * time.Second) 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) + // Fallback: save as file + clipboardFilePath := storage.ResolveDuplicateFilename(h.config.DownloadDir, "clipboard.txt") + if err := os.WriteFile(clipboardFilePath, []byte(clipboardMessage), 0600); err != nil { + h.logger.Errorf("Failed to save clipboard text to %s: %v", clipboardFilePath, 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))) + h.logger.Infof("Clipboard message from %s saved to %s", sanitizedAlias, clipboardFilePath) + fmt.Fprintln(os.Stderr, "✓ Saved clipboard to file") + os.Stderr.Sync() + h.logTransfer(sanitizedAlias, senderIP, "clipboard.txt", clipboardFilePath, int64(len(clipboardMessage)), "text/plain", history.StatusClipboard) + h.runExecHook(clipboardFilePath, "clipboard.txt", sanitizedAlias, senderIP, int64(len(clipboardMessage))) + time.Sleep(1 * time.Second) w.WriteHeader(http.StatusNoContent) return } @@ -179,25 +205,29 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R h.logger.Infof("PrepareUpload request from %s (%s) for %d files:", cli.Sanitize(requestDto.Info.Alias), r.RemoteAddr, len(requestDto.Files)) - sender := model.DeviceInfo{ - Alias: cli.Sanitize(requestDto.Info.Alias), - Version: requestDto.Info.Version, - DeviceModel: requestDto.Info.DeviceModel, - DeviceType: requestDto.Info.DeviceType, - Fingerprint: requestDto.Info.Fingerprint, - IP: senderIP, - } - // --- Interactive Accept/Reject Prompt --- if !h.config.AutoAccept { - h.promptMutex.Lock() - accepted := h.promptUserForAcceptance(sender, requestDto.Files) - h.promptMutex.Unlock() + // Check if sender fingerprint is in trusted_fingerprints whitelist + isTrusted := false + if sender.Fingerprint != "" && len(h.config.TrustedFingerprints) > 0 { + for _, trusted := range h.config.TrustedFingerprints { + if strings.EqualFold(trusted, sender.Fingerprint) { + isTrusted = true + break + } + } + } - if !accepted { - h.logger.Infof("Transfer rejected by user") - httputil.RespondError(w, http.StatusForbidden, "Rejected") // 403 Forbidden - return + if !isTrusted { + h.promptMutex.Lock() + accepted := h.promptUserForAcceptance(sender, requestDto.Files) + h.promptMutex.Unlock() + + if !accepted { + h.logger.Infof("Transfer rejected by user") + httputil.RespondError(w, http.StatusForbidden, "Rejected") // 403 Forbidden + return + } } } diff --git a/pkg/server/handlers/receive_upload.go b/pkg/server/handlers/receive_upload.go index ececde7..f5bcdca 100644 --- a/pkg/server/handlers/receive_upload.go +++ b/pkg/server/handlers/receive_upload.go @@ -8,6 +8,7 @@ import ( "io" "net" "net/http" + "os" "path/filepath" "strings" @@ -67,7 +68,23 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) // 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) + + // Determine destination path based on conflict resolution mode + var destinationPath string + switch h.config.FileConflictResolve { + case "skip": + destinationPath = filepath.Join(h.config.DownloadDir, rawFileName) + if _, err := os.Stat(destinationPath); err == nil { + h.logger.Infof("Skipping file transfer for existing file %s (file_conflict_resolution=skip)", rawFileName) + h.receiveService.CompleteFile(reqSessionId, reqFileId) + w.WriteHeader(http.StatusOK) + return + } + case "overwrite": + destinationPath = filepath.Join(h.config.DownloadDir, rawFileName) + default: + destinationPath = storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName) + } // Path traversal prevention: ensure the resolved path is still within DownloadDir cleanPath := filepath.Clean(destinationPath) @@ -193,7 +210,13 @@ func (h *ReceiveHandler) saveTextAsFileTo(sender model.DeviceInfo, reqSessionId, } else { combinedReader = bytes.NewReader(textBytes) } - destinationPath := storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName) + var destinationPath string + switch h.config.FileConflictResolve { + case "overwrite": + destinationPath = filepath.Join(h.config.DownloadDir, rawFileName) + default: + destinationPath = storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName) + } cleanPath := filepath.Clean(destinationPath) if !strings.HasPrefix(cleanPath, filepath.Clean(h.config.DownloadDir)+string(filepath.Separator)) && cleanPath != filepath.Clean(h.config.DownloadDir) { diff --git a/pkg/server/handlers/webshare.go b/pkg/server/handlers/webshare.go new file mode 100644 index 0000000..6f6fbe5 --- /dev/null +++ b/pkg/server/handlers/webshare.go @@ -0,0 +1,551 @@ +package handlers + +import ( + "crypto/subtle" + "html/template" + "net/http" + "path/filepath" + "sort" + "strings" + + "github.com/bethropolis/localgo/pkg/cli" + "github.com/bethropolis/localgo/pkg/model" +) + +const webShareHTML = ` + + + + + + + {{if .PinLocked}}Unlock · {{end}}LocalGo Share + + + +
+
+
+

LocalGo Share

+

Direct from {{.Alias}} — same network, no upload.

+ {{if not .PinLocked}} +
+ + + {{.FileCount}} {{if eq .FileCount 1}}file{{else}}files{{end}} + + + + {{formatBytes .TotalSize}} + +
+ {{end}} +
+ +
+ {{if .PinLocked}} +
+ +

PIN protected

+

Enter the PIN from the sender to view and download shared files.

+ {{if .PinError}} + + {{end}} +
+ + +
+
+ {{else if eq .FileCount 0}} +
No files are currently shared.
+ {{else}} +
    + {{range .Files}} +
  • + +
    +
    {{.FileName}}
    +
    + {{formatBytes .Size}} + {{if .Ext}}{{.Ext}}{{end}} +
    +
    + + + Download + +
  • + {{end}} +
+ {{end}} +
+ +
+ + + Local network transfer + + Powered by LocalGo +
+
+
+ +` + +// WebShareFile is one entry on the landing page file list. +type WebShareFile struct { + ID string + FileName string + Size int64 + Ext string + Kind string +} + +// WebShareData holds template data for the web landing page. +type WebShareData struct { + Alias string + SessionID string + Files []WebShareFile + FileCount int + TotalSize int64 + PinLocked bool + PinError bool + PIN string +} + +func fileKind(name, mime string) string { + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")) + mime = strings.ToLower(mime) + + switch { + case strings.HasPrefix(mime, "image/"), ext == "png", ext == "jpg", ext == "jpeg", ext == "gif", ext == "webp", ext == "svg", ext == "heic", ext == "avif": + return "image" + case strings.HasPrefix(mime, "video/"), ext == "mp4", ext == "mov", ext == "mkv", ext == "webm", ext == "avi": + return "video" + case strings.HasPrefix(mime, "audio/"), ext == "mp3", ext == "wav", ext == "flac", ext == "ogg", ext == "m4a", ext == "aac": + return "audio" + case ext == "zip", ext == "tar", ext == "gz", ext == "tgz", ext == "bz2", ext == "xz", ext == "7z", ext == "rar", ext == "zst": + return "archive" + case ext == "go", ext == "rs", ext == "py", ext == "js", ext == "ts", ext == "tsx", ext == "jsx", ext == "java", ext == "c", ext == "cpp", ext == "h", ext == "json", ext == "yaml", ext == "yml", ext == "toml", ext == "xml", ext == "html", ext == "css", ext == "sh", ext == "md": + return "code" + default: + return "file" + } +} + +func buildWebShareFiles(files map[string]model.FileDto) ([]WebShareFile, int64) { + out := make([]WebShareFile, 0, len(files)) + var total int64 + for id, f := range files { + ext := strings.ToUpper(strings.TrimPrefix(filepath.Ext(f.FileName), ".")) + out = append(out, WebShareFile{ + ID: id, + FileName: f.FileName, + Size: f.Size, + Ext: ext, + Kind: fileKind(f.FileName, f.FileType), + }) + total += f.Size + } + sort.Slice(out, func(i, j int) bool { + return strings.ToLower(out[i].FileName) < strings.ToLower(out[j].FileName) + }) + return out, total +} + +const webShareEmptyHTML = ` + + + + + + LocalGo Share + + + +
+

Nothing shared right now

+

This LocalGo share session has no files available. Ask the sender to start sharing again.

+
+ +` + +// WebShareHandler serves the root web landing page for browser access. +func (h *DownloadHandler) WebShareHandler(w http.ResponseWriter, r *http.Request) { + session := h.sendService.GetSession() + if session == nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(webShareEmptyHTML)) + return + } + + pinLocked := false + pinError := false + pin := r.URL.Query().Get("pin") + if h.config.PIN != "" { + if subtle.ConstantTimeCompare([]byte(pin), []byte(h.config.PIN)) != 1 { + pinLocked = true + pinError = pin != "" + } + } + + funcMap := template.FuncMap{ + "formatBytes": cli.FormatBytes, + } + + tmpl, err := template.New("webshare").Funcs(funcMap).Parse(webShareHTML) + if err != nil { + http.Error(w, "Template error", http.StatusInternalServerError) + return + } + + alias := h.config.Alias + if h.config.Private { + alias = "Anonymous" + } + + files, totalSize := buildWebShareFiles(session.Files) + data := WebShareData{ + Alias: alias, + SessionID: session.SessionID, + Files: files, + FileCount: len(files), + TotalSize: totalSize, + PinLocked: pinLocked, + PinError: pinError, + PIN: pin, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = tmpl.Execute(w, data) +} diff --git a/pkg/server/server.go b/pkg/server/server.go index f5d44cd..cf4e52f 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -64,7 +64,7 @@ func securityMiddleware(next http.Handler) http.Handler { w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-XSS-Protection", "1; mode=block") - w.Header().Set("Content-Security-Policy", "default-src 'self'") + w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'") w.Header().Set("Referrer-Policy", "no-referrer") // Block cross-origin requests from external websites. @@ -119,9 +119,20 @@ func (s *Server) configureRoutes() { // Download Handlers downloadHandler := handlers.NewDownloadHandler(s.config, s.sendService, s.logger) + downloadHandler.SetShutdownFn(func() { + go func() { + time.Sleep(200 * time.Millisecond) + if s.httpServer != nil { + s.httpServer.Close() + } + }() + }) apiRouter.HandleFunc("/v2/prepare-download", downloadHandler.PrepareDownloadHandler).Methods("POST") apiRouter.HandleFunc("/v2/download", downloadHandler.DownloadHandler).Methods("GET") + // Root web landing page for browser access (fixes 404 on http://IP:PORT) + s.muxRouter.HandleFunc("/", downloadHandler.WebShareHandler).Methods("GET") + s.logger.Info("Configured API routes.") } @@ -129,7 +140,12 @@ func (s *Server) configureRoutes() { func (s *Server) Start(ctx context.Context, readyChan chan<- struct{}) error { s.configureRoutes() - addr := fmt.Sprintf("0.0.0.0:%d", s.config.Port) + bindHost := "0.0.0.0" + if s.config.BindAddress != "" { + bindHost = s.config.BindAddress + } + + addr := fmt.Sprintf("%s:%d", bindHost, s.config.Port) s.httpServer = &http.Server{ Addr: addr, Handler: s.muxRouter, @@ -147,7 +163,7 @@ func (s *Server) Start(ctx context.Context, readyChan chan<- struct{}) error { cli.Notify("LocalGo: Port Changed", fmt.Sprintf("Port %d was busy. Now running on a different port.", s.config.Port)) - addr = "0.0.0.0:0" + addr = fmt.Sprintf("%s:0", bindHost) ln, err = net.Listen("tcp", addr) if err != nil { return fmt.Errorf("failed to bind port: %w", err) @@ -155,7 +171,7 @@ func (s *Server) Start(ctx context.Context, readyChan chan<- struct{}) error { actualPort := ln.Addr().(*net.TCPAddr).Port s.config.Port = actualPort - addr = fmt.Sprintf("0.0.0.0:%d", actualPort) + addr = fmt.Sprintf("%s:%d", bindHost, actualPort) s.httpServer.Addr = addr s.logger.Infof("Server bound to port %d", actualPort) }