From 10778efbeedd259ede1e4c21f8e99150f805a016 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:13:24 +0300 Subject: [PATCH 01/14] Tiered discovery: cache probe, multicast bursts, TCP pre-filter, --quick flag - PeerCache: LRU 50-entry cap, 14-day stale eviction, auto-cache on --ip - SendDiscoveryAnnouncement: 3 UDP bursts 30ms apart to reduce Wi-Fi loss - ScanNetwork: 150ms TCP pre-probe before HTTP register, semaphore 256 - SendFiles: cache probe (400ms) -> multicast (1.2s) -> subnet scan cascade with fast mode skipping the scan tier - DiscoveryStrategy config field, --quick/-q flag on send command --- cmd/localgo/cmd/send.go | 23 ++++---- pkg/config/config.go | 63 +++++++++++++--------- pkg/discovery/announce.go | 19 +++++-- pkg/discovery/http_discovery.go | 22 +++++++- pkg/discovery/peercache.go | 93 ++++++++++++++++++++++++++++----- pkg/discovery/quick.go | 5 +- pkg/send/send.go | 49 ++++++++++++----- 7 files changed, 207 insertions(+), 67 deletions(-) diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index 4d84ed2..259e342 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -24,16 +24,17 @@ 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 ) var sendCmd = &cobra.Command{ @@ -252,6 +253,9 @@ var sendCmd = &cobra.Command{ if sendmulticastiface != "" { Cfg.MulticastInterface = sendmulticastiface } + if sendquick { + Cfg.DiscoveryStrategy = "fast" + } cli.PrintHeader(fmt.Sprintf("Sending %d files", len(files))) for _, file := range files { @@ -299,6 +303,7 @@ 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.RegisterFlagCompletionFunc("to", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { cache := discovery.NewPeerCache(nil) diff --git a/pkg/config/config.go b/pkg/config/config.go index a7bfaa9..2a13ce9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -44,7 +44,10 @@ 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 Shell string `json:"-"` // shell command prefix for exec hooks (default: "sh -c" or "cmd /c") ClipboardWriteCmd string `json:"-"` // custom clipboard write command @@ -201,32 +204,44 @@ 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") 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, + Shell: shell, + ClipboardWriteCmd: clipboardWriteCmd, + ClipboardReadCmd: clipboardReadCmd, + CustomTLSCertPath: customTLSCertPath, + CustomTLSKeyPath: customTLSKeyPath, + NotificationCmd: notificationCmd, } return cfg, nil 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/send/send.go b/pkg/send/send.go index 770d002..de34fbb 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -60,20 +60,38 @@ 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() + + 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 +107,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,9 +120,10 @@ 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 { @@ -114,6 +132,14 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci 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 +157,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,10 +173,10 @@ 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 { return err From d05ddb46b02b75abf5babfa6342cf5705af27abf Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:16:29 +0300 Subject: [PATCH 02/14] Web share: landing page, QR code, download notifications, --once flag - Root GET / handler with embedded HTML template fixes browser 404 - Terminal QR code generation via qrterminal for mobile scanning - Download success notification printed to terminal - --once flag stops server automatically after first download - HTTPS default guidance notice for self-signed certs --- cmd/localgo/cmd/share.go | 24 ++++- go.mod | 2 + go.sum | 4 + pkg/config/config.go | 1 + pkg/server/handlers/download_handlers.go | 121 +++++++++++++++++++++++ pkg/server/server.go | 11 +++ 6 files changed, 162 insertions(+), 1 deletion(-) diff --git a/cmd/localgo/cmd/share.go b/cmd/localgo/cmd/share.go index 37f361f..08b2596 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{ @@ -96,6 +98,9 @@ var shareCmd = &cobra.Command{ if sharemulticastiface != "" { Cfg.MulticastInterface = sharemulticastiface } + if shareOnce { + Cfg.ShareOnce = true + } protocol := "HTTPS" if !Cfg.HttpsEnabled { @@ -247,6 +252,22 @@ var shareCmd = &cobra.Command{ cli.PrintInfo(" %s://%s:%d", scheme, ip.String(), Cfg.Port) } fmt.Println() + + // Terminal QR code for mobile scanning + primaryURL := fmt.Sprintf("http://%s:%d", localIPs[0].String(), Cfg.Port) + cli.PrintHeader("Scan QR Code on Mobile:") + cfg := qrterminal.Config{ + Level: qrterminal.M, + Writer: os.Stdout, + HalfBlocks: true, + BlackChar: qrterminal.BLACK, + WhiteChar: qrterminal.WHITE, + } + qrterminal.GenerateWithConfig(primaryURL, cfg) + } + + if Cfg.HttpsEnabled { + cli.PrintWarning("HTTPS notice: browsers will show a security warning for self-signed certificates") } cli.PrintWarning("Press Ctrl+C to stop sharing") @@ -281,6 +302,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/go.mod b/go.mod index dcc4029..e64cac9 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mdp/qrterminal/v3 v3.2.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect @@ -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/config/config.go b/pkg/config/config.go index 2a13ce9..51d661c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -48,6 +48,7 @@ type Config struct { 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) Shell string `json:"-"` // shell command prefix for exec hooks (default: "sh -c" or "cmd /c") ClipboardWriteCmd string `json:"-"` // custom clipboard write command diff --git a/pkg/server/handlers/download_handlers.go b/pkg/server/handlers/download_handlers.go index 0b007aa..79c7b88 100644 --- a/pkg/server/handlers/download_handlers.go +++ b/pkg/server/handlers/download_handlers.go @@ -3,10 +3,13 @@ package handlers import ( "crypto/subtle" "fmt" + "html/template" "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,6 +22,113 @@ 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 +} + +const webShareHTML = ` + + + + + LocalGo File Share + + + +
+

LocalGo File Share

+
Shared by {{.Alias}}
+ {{if .PinLocked}} +

This share is PIN protected.

+
+ + +
+ {{else}} + + {{end}} +
+ +` + +// WebShareData holds template data for the web landing page. +type WebShareData struct { + Alias string + SessionID string + Files map[string]model.FileDto + PinLocked bool + PIN string +} + +// 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 { + http.Error(w, "No files are currently being shared.", http.StatusNotFound) + return + } + + pinLocked := false + pin := r.URL.Query().Get("pin") + if h.config.PIN != "" { + if subtle.ConstantTimeCompare([]byte(pin), []byte(h.config.PIN)) != 1 { + pinLocked = true + } + } + + 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" + } + + data := WebShareData{ + Alias: alias, + SessionID: session.SessionID, + Files: session.Files, + PinLocked: pinLocked, + PIN: pin, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = tmpl.Execute(w, data) } // NewDownloadHandler creates a new DownloadHandler. @@ -124,5 +234,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/server.go b/pkg/server/server.go index f5d44cd..9e4e10b 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -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.") } From 27c3c80db463c6c5bd13f15e14ee96c9eb48dab5 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:18:19 +0300 Subject: [PATCH 03/14] Config UX: key validation, unset, open, add/remove subcommands, origin tracking - Known config key schema with type, range, and enum validation - Typo detection with Levenshtein 'did you mean?' suggestions - config unset removes a key from config file - config open opens config in system editor (nvim) - config add/remove for list-type config values - config list shows origin tags [file]/[env]/[default] - Shell completion helper --- cmd/localgo/cmd/config.go | 467 +++++++++++++++++++++++++++++++------- 1 file changed, 386 insertions(+), 81 deletions(-) diff --git a/cmd/localgo/cmd/config.go b/cmd/localgo/cmd/config.go index b2005d8..ff26d4c 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,167 @@ 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" + 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"}, + "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, 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, ", ")) + } + 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 +186,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 +207,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, 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 +231,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 +446,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) From 7b46ad0e97ed1bb34ce205a1016e7e74e10ad359 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:20:46 +0300 Subject: [PATCH 04/14] Protocol 2.1, HTTPS fingerprint TOFU for --ip, export VerifyDeviceFingerprint - ProtocolVersion bumped 2.0 -> 2.1 - send --ip now stores and verifies fingerprints via PeerCache for TOFU - Exported VerifyDeviceFingerprint for use by CLI commands --- cmd/localgo/cmd/send.go | 18 ++++++++++++++++-- pkg/config/config.go | 2 +- pkg/config/config_test.go | 9 ++------- pkg/send/send.go | 6 +++--- pkg/send/verify.go | 2 +- 5 files changed, 23 insertions(+), 14 deletions(-) diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index 259e342..76f65c8 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -159,9 +159,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 diff --git a/pkg/config/config.go b/pkg/config/config.go index 51d661c..a31a000 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" ) 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/send/send.go b/pkg/send/send.go index de34fbb..d093dcf 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -80,7 +80,7 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci if targetDevice != nil { logger.Infof("Discovered recipient via cache: %s (%s)", targetDevice.Alias, targetDevice.IP) - if err := verifyDeviceFingerprint(peerCache, targetDevice); err != nil { + if err := VerifyDeviceFingerprint(peerCache, targetDevice); err != nil { return err } return SendToDevice(ctx, cfg, targetDevice, filePaths, logger, opts...) @@ -126,7 +126,7 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci 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...) @@ -178,7 +178,7 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci 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 } 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 } From f069b375f2dfa60b240e6f422f61565ee5caff99 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:23:04 +0300 Subject: [PATCH 05/14] Send PIN: --pin flag for sender authentication - send --pin adds PIN to prepare-upload and upload URLs - Receiver already validates PIN on prepare-upload - Works for both --ip and discovery-based send --- cmd/localgo/cmd/send.go | 8 ++++++++ pkg/send/upload.go | 9 ++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index 76f65c8..f8d23f7 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -35,6 +35,7 @@ var ( sendclipboard bool sendstdin bool sendquick bool + sendpin string ) var sendCmd = &cobra.Command{ @@ -270,6 +271,12 @@ var sendCmd = &cobra.Command{ if sendquick { Cfg.DiscoveryStrategy = "fast" } + if sendpin != "" { + Cfg.PIN = sendpin + } + if sendpin != "" { + Cfg.PIN = sendpin + } cli.PrintHeader(fmt.Sprintf("Sending %d files", len(files))) for _, file := range files { @@ -318,6 +325,7 @@ func init() { 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/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 { From 598eabc1c7fe97c6e2313eb4f5498a0697d8642b Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:23:32 +0300 Subject: [PATCH 06/14] Fix .gitignore config entry, README clipboard env vars, send.go PIN URL - .gitignore: narrow 'config' to '/config' so pkg/config/ is not ignored - README: add LOCALSEND_CLIPBOARD_READ/WRITE_CMD env vars - send.go: add PIN query param to prepare-upload URL --- .gitignore | 2 +- README.md | 2 ++ pkg/send/send.go | 10 +++++++--- 3 files changed, 10 insertions(+), 4 deletions(-) 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/README.md b/README.md index b9191e5..f0142df 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,8 @@ For full details on deployment, macvlan networking, read-only root filesystem, w | `LOCALSEND_CONCURRENCY` | 4 | Max parallel upload workers | | `LOCALSEND_MULTICAST_INTERFACE` | (all) | Network interface for multicast | | `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 | diff --git a/pkg/send/send.go b/pkg/send/send.go index d093dcf..616fede 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -394,7 +394,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) @@ -448,7 +452,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) @@ -469,7 +473,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) From 9a5ec6d0b4d9ff0be1377517b201c152c22a9148 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:47:39 +0300 Subject: [PATCH 07/14] Fix QR code rendering: use GenerateHalfBlock helper - qrterminal.BLACK/WHITE are ANSI full-block sequences, not compatible with HalfBlocks mode - Switched to GenerateHalfBlock() which sets correct unicode characters --- cmd/localgo/cmd/share.go | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/cmd/localgo/cmd/share.go b/cmd/localgo/cmd/share.go index 08b2596..7b24a84 100644 --- a/cmd/localgo/cmd/share.go +++ b/cmd/localgo/cmd/share.go @@ -256,14 +256,7 @@ var shareCmd = &cobra.Command{ // Terminal QR code for mobile scanning primaryURL := fmt.Sprintf("http://%s:%d", localIPs[0].String(), Cfg.Port) cli.PrintHeader("Scan QR Code on Mobile:") - cfg := qrterminal.Config{ - Level: qrterminal.M, - Writer: os.Stdout, - HalfBlocks: true, - BlackChar: qrterminal.BLACK, - WhiteChar: qrterminal.WHITE, - } - qrterminal.GenerateWithConfig(primaryURL, cfg) + qrterminal.GenerateHalfBlock(primaryURL, qrterminal.M, os.Stdout) } if Cfg.HttpsEnabled { From f2e6a588218608eac5e597ee2e227442c2328a7d Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:55:59 +0300 Subject: [PATCH 08/14] Update help: document new flags, examples, config subcommands, env vars - share: --once, --https, --once examples, --iface - send: --quick/-q, --pin, mDNS --ip example, --iface - config: subcommand listing (get/set/add/remove/list/unset/open/path) - Main help: new examples for --quick, --ip, --once, config set/list - Env vars: DISCOVERY_STRATEGY, FILE_CONFLICT_RESOLUTION, BIND_ADDRESS, CONCURRENCY, SHELL, CLIPBOARD_READ/WRITE_CMD, TLS_CERT/KEY, NOTIFICATION_CMD --- pkg/help/commands.go | 31 ++++++++++++++++++++++++++++--- pkg/help/help.go | 15 +++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/pkg/help/commands.go b/pkg/help/commands.go index ee26392..9735463 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: "--https", Type: "bool", Default: "false", Description: "Use HTTPS (browsers will show self-signed cert 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"}, @@ -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..ac75574 100644 --- a/pkg/help/help.go +++ b/pkg/help/help.go @@ -100,8 +100,13 @@ 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 share --file document.pdf", + "localgo share --file photo.jpg --once", "localgo history --limit 20", + "localgo config set alias MyDevice", + "localgo config list", "localgo help send", } @@ -131,6 +136,16 @@ 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_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 { From a4fd3218d0191dda0b6d878a4e6b717fe7063541 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:00:11 +0300 Subject: [PATCH 09/14] Wire config fields into runtime (BindAddress, FileConflictResolve, StaticPeers, TrustedFingerprints) - BindAddress: server.go binds to configured address instead of 0.0.0.0 - FileConflictResolve: receive_upload.go supports skip/overwrite/rename - StaticPeers: Config field + viper load + Tier 1 probe in send.go (400ms per peer) - TrustedFingerprints: Config field + viper load + bypass transfer prompt on match - Fix duplicate sendpin check in cmd/send.go - Extract web share code to webshare.go with green monospace theme - Fix CSP to allow inline styles for web share page - QR scheme follows actual http/https, HTTPS fingerprint shown in share output - Add web share tests --- cmd/localgo/cmd/config.go | 2 + cmd/localgo/cmd/send.go | 3 - cmd/localgo/cmd/share.go | 26 +- docs/CLI_REFERENCE.md | 7 +- pkg/config/config.go | 6 + pkg/help/commands.go | 4 +- pkg/send/send.go | 34 ++ pkg/server/handlers/download_handlers.go | 101 ---- pkg/server/handlers/download_handlers_test.go | 82 +++ pkg/server/handlers/receive_handlers.go | 27 +- pkg/server/handlers/receive_upload.go | 27 +- pkg/server/handlers/webshare.go | 551 ++++++++++++++++++ pkg/server/server.go | 13 +- 13 files changed, 752 insertions(+), 131 deletions(-) create mode 100644 pkg/server/handlers/webshare.go diff --git a/cmd/localgo/cmd/config.go b/cmd/localgo/cmd/config.go index ff26d4c..5c4ad0e 100644 --- a/cmd/localgo/cmd/config.go +++ b/cmd/localgo/cmd/config.go @@ -41,6 +41,8 @@ var knownConfigKeys = map[string]configKey{ "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: "string"}, + "trusted_fingerprints": {typ: "string"}, "clipboard_write_cmd": {typ: "string"}, "clipboard_read_cmd": {typ: "string"}, "tls_cert": {typ: "string"}, diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index f8d23f7..cc296cf 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -274,9 +274,6 @@ var sendCmd = &cobra.Command{ if sendpin != "" { Cfg.PIN = sendpin } - if sendpin != "" { - Cfg.PIN = sendpin - } cli.PrintHeader(fmt.Sprintf("Sending %d files", len(files))) for _, file := range files { diff --git a/cmd/localgo/cmd/share.go b/cmd/localgo/cmd/share.go index 7b24a84..f1b5936 100644 --- a/cmd/localgo/cmd/share.go +++ b/cmd/localgo/cmd/share.go @@ -66,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 @@ -117,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 @@ -240,27 +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 - primaryURL := fmt.Sprintf("http://%s:%d", localIPs[0].String(), Cfg.Port) + // 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 self-signed certificates") + 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") @@ -283,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") diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 2f19977..ba09dc6 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 ``` --- diff --git a/pkg/config/config.go b/pkg/config/config.go index a31a000..7650d73 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -49,6 +49,8 @@ type Config struct { 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 @@ -214,6 +216,8 @@ func LoadConfig(v *viper.Viper, logger *zap.SugaredLogger) (*Config, error) { fileConflictResolve = "rename" } bindAddress := v.GetString("bind_address") + staticPeers := v.GetStringSlice("static_peers") + trustedFingerprints := v.GetStringSlice("trusted_fingerprints") cfg := &Config{ Alias: alias, @@ -237,6 +241,8 @@ func LoadConfig(v *viper.Viper, logger *zap.SugaredLogger) (*Config, error) { DiscoveryStrategy: discoveryStrategy, FileConflictResolve: fileConflictResolve, BindAddress: bindAddress, + StaticPeers: staticPeers, + TrustedFingerprints: trustedFingerprints, Shell: shell, ClipboardWriteCmd: clipboardWriteCmd, ClipboardReadCmd: clipboardReadCmd, diff --git a/pkg/help/commands.go b/pkg/help/commands.go index 9735463..5002eed 100644 --- a/pkg/help/commands.go +++ b/pkg/help/commands.go @@ -55,8 +55,8 @@ func GetCommandHelp(commandName string) *CommandHelp { 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: "--https", Type: "bool", Default: "false", Description: "Use HTTPS (browsers will show self-signed cert warning)"}, + {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"}, diff --git a/pkg/send/send.go b/pkg/send/send.go index 616fede..b467de6 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -78,6 +78,40 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci }, 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 { diff --git a/pkg/server/handlers/download_handlers.go b/pkg/server/handlers/download_handlers.go index 79c7b88..7d1bd15 100644 --- a/pkg/server/handlers/download_handlers.go +++ b/pkg/server/handlers/download_handlers.go @@ -3,7 +3,6 @@ package handlers import ( "crypto/subtle" "fmt" - "html/template" "io" "net/http" "os" @@ -30,106 +29,6 @@ func (h *DownloadHandler) SetShutdownFn(fn func()) { h.shutdownFn = fn } -const webShareHTML = ` - - - - - LocalGo File Share - - - -
-

LocalGo File Share

-
Shared by {{.Alias}}
- {{if .PinLocked}} -

This share is PIN protected.

-
- - -
- {{else}} -
    - {{range $id, $f := .Files}} -
  • -
    -
    {{$f.FileName}}
    - {{formatBytes $f.Size}} -
    - Download -
  • - {{end}} -
- {{end}} -
- -` - -// WebShareData holds template data for the web landing page. -type WebShareData struct { - Alias string - SessionID string - Files map[string]model.FileDto - PinLocked bool - PIN string -} - -// 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 { - http.Error(w, "No files are currently being shared.", http.StatusNotFound) - return - } - - pinLocked := false - pin := r.URL.Query().Get("pin") - if h.config.PIN != "" { - if subtle.ConstantTimeCompare([]byte(pin), []byte(h.config.PIN)) != 1 { - pinLocked = true - } - } - - 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" - } - - data := WebShareData{ - Alias: alias, - SessionID: session.SessionID, - Files: session.Files, - PinLocked: pinLocked, - PIN: pin, - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = tmpl.Execute(w, data) -} // NewDownloadHandler creates a new DownloadHandler. func NewDownloadHandler(cfg *config.Config, sendService *services.SendService, logger *zap.SugaredLogger) *DownloadHandler { 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..a3b9043 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -190,14 +190,27 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R // --- 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 9e4e10b..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. @@ -140,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, @@ -158,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) @@ -166,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) } From 65acc1b56dcad53c84860916d1d724df83f2b9b0 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:33:06 +0300 Subject: [PATCH 10/14] Add 'slice' type to configKey validation; change static_peers/trusted_fingerprints to typ:slice --- cmd/localgo/cmd/config.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cmd/localgo/cmd/config.go b/cmd/localgo/cmd/config.go index 5c4ad0e..ae497d8 100644 --- a/cmd/localgo/cmd/config.go +++ b/cmd/localgo/cmd/config.go @@ -17,7 +17,7 @@ import ( // configKey describes a known config key with its type and valid values. type configKey struct { - typ string // "string", "int", "bool", "enum" + 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 @@ -41,8 +41,8 @@ var knownConfigKeys = map[string]configKey{ "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: "string"}, - "trusted_fingerprints": {typ: "string"}, + "static_peers": {typ: "slice"}, + "trusted_fingerprints": {typ: "slice"}, "clipboard_write_cmd": {typ: "string"}, "clipboard_read_cmd": {typ: "string"}, "tls_cert": {typ: "string"}, @@ -147,6 +147,8 @@ func validateValue(ck configKey, raw string) (interface{}, error) { } } 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", ck.typ, ck.typ) } return raw, nil } From 40a3e8e8013ff6c037f3bf9e4ca98978f46da1fe Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:22:20 +0300 Subject: [PATCH 11/14] Fix clipboard send: remove 204 No Content early return in prepare-upload The prepare-upload handler was short-circuiting clipboard messages with a 204 No Content response after writing to clipboard. The official LocalSend mobile app does not handle 204 and remains in a waiting state. Fix: remove the early return and let clipboard messages fall through to the normal create-session + upload flow. The upload handler already writes to clipboard or saves as file when it receives the text/plain upload. --- pkg/server/handlers/receive_handlers.go | 77 ++++++++++--------------- 1 file changed, 30 insertions(+), 47 deletions(-) diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index a3b9043..f59134e 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -6,14 +6,12 @@ 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" @@ -95,21 +93,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,41 +123,28 @@ 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 + } + } } - } - - 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 + 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 + } } } - - // 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 + // Fall through to normal create-session + upload flow. + // The upload handler will write to clipboard or save as file + // when the sender completes the upload. } // --- Check Disk Space --- @@ -179,15 +171,6 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R h.logger.Infof("PrepareUpload request from %s (%s) for %d files:", cli.Sanitize(requestDto.Info.Alias), r.RemoteAddr, len(requestDto.Files)) - 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 { // Check if sender fingerprint is in trusted_fingerprints whitelist From db7325f9de6a5122417f72bfafd2e2826af83d26 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:07:06 +0300 Subject: [PATCH 12/14] Fix clipboard send: write immediately in prepare-upload, return 204 Clipboard send was broken by two issues: 1. prepare-upload handler fell through to create-session + upload flow, causing the mobile app to wait for an upload that never came and a double-prompt on the receiver. 2. clipboard.Write() used CombinedOutput() which created stdout/stderr pipes. xclip/wl-copy fork a background daemon that inherits those pipes, causing Go's cmd.Wait() to hang forever. The 204 response was never sent until Ctrl+C killed the daemon. Fix: - Write clipboard content immediately in prepare-upload (after prompt acceptance), print confirmation to stderr, pause 1s for client timing, then return 204 No Content. The mobile app receives 204 and closes the session immediately. No session is created for clipboard messages. - Replace CombinedOutput() with CommandContext(3s timeout) + /dev/null for stdout/stderr + Run(). The xclip daemon no longer inherits pipes, so clipboard.Write returns in <10ms. - Same timeout guard applied to Read() for consistency. --- pkg/clipboard/clipboard.go | 38 ++++++++++++++++++++--- pkg/server/handlers/receive_handlers.go | 40 +++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 7 deletions(-) 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/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index f59134e..4f1f3f7 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -4,14 +4,18 @@ import ( "context" "crypto/subtle" "encoding/json" + "fmt" "net" "net/http" + "os" "os/exec" "runtime" "strings" "sync" + "time" "github.com/bethropolis/localgo/pkg/cli" + "github.com/bethropolis/localgo/pkg/clipboard" "github.com/bethropolis/localgo/pkg/config" "github.com/bethropolis/localgo/pkg/history" "github.com/bethropolis/localgo/pkg/httputil" @@ -142,9 +146,39 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R } } } - // Fall through to normal create-session + upload flow. - // The upload handler will write to clipboard or save as file - // when the sender completes the upload. + + 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) + 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 + 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, 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 } // --- Check Disk Space --- From eca4d8b9448aa32bfd72cca508d1108e8b565d7f Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:10:10 +0300 Subject: [PATCH 13/14] Fix go.mod: run go mod tidy (move qrterminal to direct deps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go mod tidy moves github.com/mdp/qrterminal/v3 from // indirect to direct dependency. Also fix protocol description in goreleaser.yaml and README (v2.1 → v2 per spec). --- .goreleaser.yaml | 8 ++++---- README.md | 2 +- go.mod | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) 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 f0142df..89f34a8 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ A Go implementation of the LocalSend v2.1 protocol for secure, cross-platform fi ## Features -- **Complete LocalSend v2.1 Protocol** - Works with LocalSend apps +- **Complete LocalSend v2.1 Protocol** - Works with LocalSend apps (V3 planned) - **Secure** - HTTPS with certificates, optional PIN protection - **Fast Discovery** - Multicast UDP + HTTP fallback - **Multi-file Transfers** - Send multiple files concurrently diff --git a/go.mod b/go.mod index e64cac9..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 @@ -51,7 +52,6 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/mdp/qrterminal/v3 v3.2.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect From 0fed8de1e37183f1efd91398ca732e63cf7f7b6a Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:33:32 +0300 Subject: [PATCH 14/14] docs: update CLI reference, config docs, and help text for v0.6.5 - Expand CLI_REFERENCE.md with --quick, --pin, config add/remove/open/unset - Add missing env vars to README.md and CONFIGURATION.md - Fix --probe flag (no -p short flag, conflicts with --private) - Fix config.go error message showing internal type instead of key name - Add --no-color, --version to docs, --once/--range examples --- README.md | 38 ++++++++++++++++++++++++++++++-------- cmd/localgo/cmd/config.go | 6 +++--- docs/CLI_REFERENCE.md | 20 ++++++++++++++++++++ docs/CONFIGURATION.md | 16 +++++++++++++++- pkg/help/commands.go | 2 +- pkg/help/help.go | 11 ++++++++++- 6 files changed, 79 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 89f34a8..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 (V3 planned) -- **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,6 +149,8 @@ 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 | @@ -141,6 +159,10 @@ For full details on deployment, macvlan networking, read-only root filesystem, w | `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 @@ -164,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 ae497d8..5d92ee4 100644 --- a/cmd/localgo/cmd/config.go +++ b/cmd/localgo/cmd/config.go @@ -121,7 +121,7 @@ func validateKey(key string) (configKey, error) { return ck, nil } -func validateValue(ck configKey, raw string) (interface{}, error) { +func validateValue(ck configKey, key, raw string) (interface{}, error) { switch ck.typ { case "string": return raw, nil @@ -148,7 +148,7 @@ func validateValue(ck configKey, raw string) (interface{}, error) { } 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", ck.typ, ck.typ) + return nil, fmt.Errorf("use 'config add %s' or 'config remove %s' to manage list values", key, key) } return raw, nil } @@ -219,7 +219,7 @@ var configSetCmd = &cobra.Command{ return err } - val, err := validateValue(ck, args[1]) + val, err := validateValue(ck, key, args[1]) if err != nil { return err } diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index ba09dc6..afd88cb 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -132,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. @@ -151,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 ``` --- @@ -281,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. @@ -291,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/pkg/help/commands.go b/pkg/help/commands.go index 5002eed..300db54 100644 --- a/pkg/help/commands.go +++ b/pkg/help/commands.go @@ -162,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"}, }, }, diff --git a/pkg/help/help.go b/pkg/help/help.go index ac75574..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"}, } @@ -102,10 +103,16 @@ func ShowMainUsage() { "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", } @@ -139,6 +146,8 @@ func ShowMainUsage() { {"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"},