diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6f67a37..ec0008f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -49,7 +49,9 @@ archives: - LICENSE - packaging/kcd.example.toml - packaging/kcd-user.service + - packaging/kcd-user.socket - packaging/kcd-pkg.service + - packaging/kcd-pkg.socket - packaging/kcd-system.service - packaging/ufw-kcd - packaging/firewalld-kcd.xml @@ -91,6 +93,7 @@ aurs: install -Dm755 "./kcd" "${pkgdir}/usr/bin/kcd" install -Dm644 "./packaging/kcd-pkg.service" "${pkgdir}/usr/lib/systemd/user/kcd.service" + install -Dm644 "./packaging/kcd-pkg.socket" "${pkgdir}/usr/lib/systemd/user/kcd.socket" install -Dm644 "./packaging/kcd-system.service" "${pkgdir}/usr/lib/systemd/system/kcd@.service" install -Dm644 "./packaging/kcd.example.toml" "${pkgdir}/usr/share/doc/kcd/kcd.example.toml" @@ -135,8 +138,6 @@ homebrew_casks: owner: bethropolis name: homebrew-tap token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" - url: - verified: github.com/bethropolis/kcd binaries: - kcd completions: @@ -170,6 +171,9 @@ nfpms: - src: packaging/kcd-pkg.service dst: /usr/lib/systemd/user/kcd.service type: config|noreplace + - src: packaging/kcd-pkg.socket + dst: /usr/lib/systemd/user/kcd.socket + type: config|noreplace - src: packaging/kcd-system.service dst: /usr/lib/systemd/system/kcd@.service type: config|noreplace @@ -255,6 +259,7 @@ release: **Arch Linux:** ```bash yay -S kcd-bin + systemctl --user enable --now kcd.socket ``` **Debian / Ubuntu:** diff --git a/AGENTS.md b/AGENTS.md index b9cb4a9..0e0ac4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ These structural constraints must hold at all times: | `internal/config/` only imports `github.com/BurntSushi/toml` | Config must stay lean and cycle-free | | `internal/plugin/plugin.go` only imports `internal/protocol` and `internal/device` | Plugins never import each other | | Plugins are registered in `daemon.go`, never in their own `init()` | Explicit, ordered, conditional on config | -| `pkg/client/` only imports `internal/ipc` from the `internal/` tree | Public client API must not depend on internals beyond the IPC protocol | +| `pkg/client/` only imports `internal/device`, `internal/events`, `internal/ipc`, and `internal/plugins/contacts` from the `internal/` tree | Public client API must not depend on internals beyond the IPC protocol and the types it surfaces | | All binaries built with `CGO_ENABLED=0` | Required for static distribution | --- @@ -97,13 +97,14 @@ error message may be confusing. |---|---| | Battery | `battery.NewBatteryPlugin(cfg config.BatteryConfig, bus *events.Bus, logger *zap.Logger) *BatteryPlugin` | | Notification | `notification.NewNotificationPlugin(cfg config.NotificationPluginConfig, bus *events.Bus, tlsConfig *tls.Config, logger *zap.Logger) *NotificationPlugin` | -| Share | `share.NewSharePlugin(cfg config.ShareConfig, tlsConfig *tls.Config, logger *zap.Logger) *SharePlugin` | +| Share | `share.NewSharePlugin(downloadDir string, cfg config.ShareConfig, tlsConfig *tls.Config, bus *events.Bus, logger *zap.Logger) *SharePlugin` | | SFTP | `sftp.NewSftpPlugin(cfg config.SFTPConfig, bus *events.Bus, logger *zap.Logger) *SftpPlugin` | | Ping | `ping.NewPingPlugin(cfg config.PingConfig, bus *events.Bus, logger *zap.Logger) *PingPlugin` | -| Pair | `pair.NewPairPlugin(devices *device.Registry, localCert *x509.Certificate, autoAccept bool, cfg config.PairingConfig, onStateChanged func(), bus *events.Bus, logger *zap.Logger) *PairPlugin` | +| Pair | `pair.NewPairPlugin(devices *device.Registry, localCert *x509.Certificate, cfg config.PairingConfig, onStateChanged func(), bus *events.Bus, logger *zap.Logger) *PairPlugin` | | Mousepad | `mousepad.NewMousepadPlugin(cfg config.MousepadConfig, logger *zap.Logger) *MousepadPlugin` | | SystemVolume | `systemvolume.NewSystemVolumePlugin(bus *events.Bus, logger *zap.Logger) *SystemVolumePlugin` | | SMS | `sms.NewSMSPlugin(cfg config.SMSConfig, bus *events.Bus, tlsConfig *tls.Config, logger *zap.Logger) *SMSPlugin` | +| Contacts | `contacts.NewContactsPlugin(bus *events.Bus, logger *zap.Logger) *ContactsPlugin` | ### Interface @@ -248,7 +249,16 @@ Discovery is dual-mode and runs concurrently. Both paths call the same `onDevice | UDP broadcast | Sends identity to `255.255.255.255:1716` + per-interface directed broadcasts | Same LAN, simple home networks | | mDNS / Zeroconf | Registers `_kdeconnect._udp.local.` via `libp2p/zeroconf/v2`; browses for peers | Restricted networks, Docker, corporate Wi-Fi, newer Android | -The broadcast interval is adaptive. When `shouldReduce()` returns true (all paired devices already connected), the UDP interval increases to 60 seconds. Setting `enable_broadcast = false` in config disables UDP entirely — the daemon reaches 0.0% idle CPU. Paired phones reconnect automatically via remembered IP. +mDNS advertisement runs for the daemon lifetime (responder-only, negligible +idle cost). UDP broadcast is on demand and reference-counted per owner +(`pairing` for `kcd pair` listen mode, `reconnect` for offline pairs): +it runs while any paired device is disconnected so roamed phones can find +us back, and stops fully when all pairs are connected — connected steady +state keeps zero timers. The 30s/60s interval only ticks while a reconnect +is actually wanted. Dial targets (`LastIP`/`LastPort`) persist in +`devices.json`; paired sightings dial the sighted address (authenticated +post-connect by CN + pinned fingerprint), with `LastIP` as the fallback +for silent peers. --- diff --git a/README.md b/README.md index 5ff54b8..5fa1c02 100644 --- a/README.md +++ b/README.md @@ -41,26 +41,22 @@ Discovery is dual-mode: **UDP broadcast** (port 1716) and **mDNS/Zeroconf** (`_k ## Installation - -### From source (Recommended) -```bash -git clone https://github.com/bethropolis/kcd.git -cd kcd -./scripts/install.sh -``` - - ### Arch Linux Install from the AUR using your preferred helper: ```bash yay -S kcd-bin -``` -### Container +systemctl --user enable --now kcd.socket +``` -Multi-arch images on GHCR: [`docs/CONTAINER.md`](docs/CONTAINER.md) +### From source +```bash +git clone https://github.com/bethropolis/kcd.git +cd kcd +./scripts/install.sh +``` ### Binary releases @@ -109,12 +105,15 @@ sudo ufw allow 1739:1764/tcp ### 1. Start the daemon If you installed via the script, `.deb`, `.rpm`, or AUR, the systemd user service -is already set up — enable and start it: +is already set up, you need to enable the socket and the daemon starts on first use: ```bash -systemctl --user enable --now kcd +systemctl --user enable --now kcd.socket ``` +Run any `kcd` command once after login to wake the daemon; from then on +a paired phone reconnects by itself within seconds. + Check that it's running: ```bash @@ -277,11 +276,11 @@ kcd fits into WM setups without pulling in KDE Plasma — single binary, systemd user unit alongside your compositor. All `mpris` commands auto- discover your phone, no device ID needed. -**Hyprland** (`~/.config/hypr/hyprland.conf`): -``` -bind = SUPER, F9, exec, kcd mpris toggle -bind = SUPER, F10, exec, kcd mpris previous -bind = SUPER, F11, exec, kcd mpris next +**Hyprland** (`~/.config/hypr/hyprland.lua` personal overrides file): +```lua +hl.bind("SUPER + F9", hl.dsp.exec_cmd("kcd mpris toggle"), { description = "MPRIS toggle" }) +hl.bind("SUPER + F10", hl.dsp.exec_cmd("kcd mpris previous"), { description = "MPRIS previous" }) +hl.bind("SUPER + F11", hl.dsp.exec_cmd("kcd mpris next"), { description = "MPRIS next" }) ``` **Sway / i3** (`~/.config/sway/config` or `~/.config/i3/config`): @@ -319,6 +318,7 @@ done | Event type | Payload fields | Description | |---|---|---| +| `state.snapshot` | `devices[]` (enriched summaries, online and offline) | Full-state bootstrap, once per watch connection | | `device.added` | `name`, `type` | New device seen for the first time | | `device.removed` | — | Device unpaired and removed | | `device.connected` | `name`, `type` | TCP connection established | @@ -339,7 +339,7 @@ done | `telephony.missed` | `contactName`, `phoneNumber` | Missed call | | `telephony.canceled` | — | Call ended | | `connectivity.update` | `signal`, `networkType` | Signal strength report | -| `mpris.update` | `player`, `title`, `artist`, `album`, `isPlaying`, `pos`, `length`, `volume` | Phone now playing state changed | +| `mpris.update` | `player`, `title`, `artist`, `album`, `isPlaying`, `pos`, `posAnchorMs`, `length`, `volume`, `albumArtUrl`, `artPending` | Phone now playing state changed | | `volume.update` | `name`, `volume`, `muted` | Desktop volume changed from phone | | `sftp.mount` | `uri`, `ip`, `port`, `user`, `password`, `path` | SFTP credentials received | diff --git a/cmd/kcd/cli_battery.go b/cmd/kcd/cli_battery.go index f6cfa6a..1158398 100644 --- a/cmd/kcd/cli_battery.go +++ b/cmd/kcd/cli_battery.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "github.com/urfave/cli/v2" @@ -10,6 +11,12 @@ var batteryCmd = &cli.Command{ Name: "battery", Usage: "Fetch battery level and charging status", ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "json", + Usage: "Output raw JSON", + }, + }, Action: func(c *cli.Context) error { if c.NArg() < 1 { return fmt.Errorf("missing device ID") @@ -22,6 +29,15 @@ var batteryCmd = &cli.Command{ if err != nil { return err } + if c.Bool("json") { + out, _ := json.Marshal(map[string]interface{}{ + "deviceId": c.Args().First(), + "charge": charge, + "charging": charging, + }) + fmt.Println(string(out)) + return nil + } state := "discharging" if charging { state = "charging" diff --git a/cmd/kcd/cli_clipboard.go b/cmd/kcd/cli_clipboard.go index 9c2bf99..c171769 100644 --- a/cmd/kcd/cli_clipboard.go +++ b/cmd/kcd/cli_clipboard.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" + "github.com/bethropolis/kcd/internal/device" "github.com/urfave/cli/v2" ) @@ -39,13 +40,13 @@ var clipboardCmd = &cli.Command{ return err } for _, d := range devs { - if d.Connected { + if d.Connected && d.State == device.StatePaired { targetID = d.ID break } } if targetID == "" { - return fmt.Errorf("no connected devices found") + return fmt.Errorf("no paired connected devices found") } } diff --git a/cmd/kcd/cli_connectivity.go b/cmd/kcd/cli_connectivity.go new file mode 100644 index 0000000..290099d --- /dev/null +++ b/cmd/kcd/cli_connectivity.go @@ -0,0 +1,115 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/bethropolis/kcd/internal/device" + "github.com/bethropolis/kcd/internal/plugins/connectivity" + "github.com/urfave/cli/v2" +) + +var connectivityCmd = &cli.Command{ + Name: "connectivity", + Usage: "Show cellular signal strength and network type", + ArgsUsage: "[device-id]", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "json", + Usage: "Output the raw report as JSON", + }, + }, + Action: func(c *cli.Context) error { + cl, err := getClient(c) + if err != nil { + return err + } + + targetID := c.Args().First() + if targetID == "" { + devs, err := cl.Devices() + if err != nil { + return err + } + for _, d := range devs { + if d.Connected && d.State == device.StatePaired { + targetID = d.ID + break + } + } + if targetID == "" { + return fmt.Errorf("no paired connected devices found") + } + } + + raw, err := cl.Connectivity(targetID) + if err != nil { + return err + } + if c.Bool("json") { + fmt.Println(string(raw)) + return nil + } + + var body connectivity.ConnectivityBody + if err := json.Unmarshal(raw, &body); err != nil { + return fmt.Errorf("decode connectivity report: %w", err) + } + for _, line := range formatConnectivity(body) { + fmt.Println(line) + } + return nil + }, +} + +// formatConnectivity renders one line per SIM, e.g. "LTE [███░] (3/4)". +// The primary SIM ("0", else lowest key) comes first; keys are sorted for +// stable output. Level is clamped to 0-4 so the bar always parses. +func formatConnectivity(body connectivity.ConnectivityBody) []string { + if len(body.SignalStrengths) == 0 { + return []string{"No signal data reported"} + } + keys := make([]string, 0, len(body.SignalStrengths)) + for k := range body.SignalStrengths { + keys = append(keys, k) + } + sort.Strings(keys) + // Primary SIM first. + if _, ok := body.SignalStrengths["0"]; ok { + ordered := []string{"0"} + for _, k := range keys { + if k != "0" { + ordered = append(ordered, k) + } + } + keys = ordered + } + + lines := make([]string, 0, len(keys)) + for _, k := range keys { + sig := body.SignalStrengths[k] + level := sig.SignalStrength + if level < 0 { + level = 0 + } + if level > 4 { + level = 4 + } + netType := sig.NetworkDetailedType + if netType == "" { + netType = sig.NetworkType + } + if netType == "" { + netType = "CELL" + } + bar := strings.Repeat("█", level) + strings.Repeat("░", 4-level) + if len(keys) == 1 { + lines = append(lines, fmt.Sprintf("%s [%s] (%d/4)", strings.ToUpper(netType), bar, level)) + } else { + lines = append(lines, fmt.Sprintf("SIM %s: %s [%s] (%d/4)", k, strings.ToUpper(netType), bar, level)) + } + } + return lines +} diff --git a/cmd/kcd/cli_connectivity_test.go b/cmd/kcd/cli_connectivity_test.go new file mode 100644 index 0000000..7d62c0b --- /dev/null +++ b/cmd/kcd/cli_connectivity_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "testing" + + "github.com/bethropolis/kcd/internal/plugins/connectivity" +) + +func TestFormatConnectivity(t *testing.T) { + cases := []struct { + name string + body connectivity.ConnectivityBody + want []string + }{ + { + name: "empty", + body: connectivity.ConnectivityBody{}, + want: []string{"No signal data reported"}, + }, + { + name: "single sim detailed type", + body: connectivity.ConnectivityBody{SignalStrengths: map[string]connectivity.SignalStrength{ + "0": {NetworkType: "LTE", NetworkDetailedType: "LTE", SignalStrength: 3}, + }}, + want: []string{"LTE [███░] (3/4)"}, + }, + { + name: "falls back to network type", + body: connectivity.ConnectivityBody{SignalStrengths: map[string]connectivity.SignalStrength{ + "0": {NetworkType: "5G", SignalStrength: 4}, + }}, + want: []string{"5G [████] (4/4)"}, + }, + { + name: "clamps out of range", + body: connectivity.ConnectivityBody{SignalStrengths: map[string]connectivity.SignalStrength{ + "0": {NetworkType: "GSM", SignalStrength: 9}, + }}, + want: []string{"GSM [████] (4/4)"}, + }, + { + name: "dual sim primary first", + body: connectivity.ConnectivityBody{SignalStrengths: map[string]connectivity.SignalStrength{ + "1": {NetworkType: "EDGE", SignalStrength: 2}, + "0": {NetworkType: "LTE", SignalStrength: 4}, + }}, + want: []string{"SIM 0: LTE [████] (4/4)", "SIM 1: EDGE [██░░] (2/4)"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := formatConnectivity(tc.body) + if len(got) != len(tc.want) { + t.Fatalf("got %q, want %q", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("line %d: got %q, want %q", i, got[i], tc.want[i]) + } + } + }) + } +} diff --git a/cmd/kcd/cli_contacts.go b/cmd/kcd/cli_contacts.go new file mode 100644 index 0000000..8d810b2 --- /dev/null +++ b/cmd/kcd/cli_contacts.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/urfave/cli/v2" +) + +var contactsCmd = &cli.Command{ + Name: "contacts", + Usage: "Sync and browse the phone address book", + Subcommands: []*cli.Command{ + { + Name: "sync", + Usage: "Request a contacts sync from a device", + ArgsUsage: "", + Action: func(c *cli.Context) error { + if c.NArg() < 1 { + return fmt.Errorf("missing device ID") + } + cl, err := getClient(c) + if err != nil { + return err + } + if err := cl.ContactsSync(c.Args().Get(0)); err != nil { + return err + } + fmt.Println("Contacts sync requested. Use `kcd watch --events contacts.updated` to see results.") + return nil + }, + }, + { + Name: "list", + Usage: "List cached contacts for a device", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "json", + Usage: "Output raw JSON", + }, + }, + Action: func(c *cli.Context) error { + if c.NArg() < 1 { + return fmt.Errorf("missing device ID") + } + cl, err := getClient(c) + if err != nil { + return err + } + list, err := cl.ContactsList(c.Args().Get(0)) + if err != nil { + return err + } + if len(list) == 0 { + fmt.Println("No cached contacts. Run `kcd contacts sync ` first.") + return nil + } + if c.Bool("json") { + out, _ := json.MarshalIndent(list, "", " ") + fmt.Println(string(out)) + return nil + } + for _, ct := range list { + phones := strings.Join(ct.Phones, ", ") + fmt.Printf("%-30s %s\n", ct.Name, phones) + } + return nil + }, + }, + }, +} diff --git a/cmd/kcd/cli_devices.go b/cmd/kcd/cli_devices.go index 2bd9515..cf21d05 100644 --- a/cmd/kcd/cli_devices.go +++ b/cmd/kcd/cli_devices.go @@ -5,10 +5,13 @@ import ( "fmt" "os" "os/signal" + "strings" "syscall" + "github.com/bethropolis/kcd/internal/config" "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/ipc" + "github.com/bethropolis/kcd/internal/protocol" "github.com/urfave/cli/v2" ) @@ -28,7 +31,7 @@ var devicesCmd = &cli.Command{ }, &cli.BoolFlag{ Name: "connected", - Usage: "Only show currently connected devices", + Usage: "Only show paired devices (including offline ones)", }, }, Action: func(c *cli.Context) error { @@ -46,7 +49,10 @@ var devicesCmd = &cli.Command{ if c.Bool("connected") { filtered := make([]device.DeviceInfo, 0, len(devices)) for _, d := range devices { - if d.Connected { + // Paired devices show even when offline — the CONNECTED + // column carries liveness. Unpaired strangers (which may + // briefly hold a raw TCP connection) are hidden. + if d.State == device.StatePaired { filtered = append(filtered, d) } } @@ -65,12 +71,26 @@ var devicesCmd = &cli.Command{ var pairCmd = &cli.Command{ Name: "pair", - Usage: "Initiate pairing with a remote device", - Description: `With a device ID: send a pair request to that device. + Usage: "Initiate pairing or accept incoming requests", + Description: `With a device ID: send a pair request to that device (or accept if they already requested). -Without a device ID: enter listen mode. Waits for any incoming pair -request and auto-accepts it. Press Ctrl+C to cancel.`, +Without a device ID: enter listen mode to receive and verify incoming pairing requests.`, ArgsUsage: "[device-id]", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically accept incoming requests without confirmation (headless mode)", + }, + &cli.StringFlag{ + Name: "expected-fingerprint", + Usage: "Only accept a candidate whose TLS cert fingerprint matches (hex, colons optional)", + }, + &cli.BoolFlag{ + Name: "known-only", + Usage: "Only accept candidates already recorded in the known-devices file", + }, + }, Action: func(c *cli.Context) error { cl, err := getClient(c) if err != nil { @@ -78,16 +98,29 @@ request and auto-accepts it. Press Ctrl+C to cancel.`, } if c.NArg() >= 1 { - if err := cl.Pair(c.Args().First()); err != nil { + targetID := c.Args().First() + if err := cl.Pair(targetID); err != nil { return err } - fmt.Println("Pairing request sent") + fmt.Printf("Pair request sent / accepted for %s\n", targetID) return nil } // Listen mode — wait for any incoming pair request fmt.Println("Listening for pair requests… (Ctrl+C to cancel)") + if c.Bool("yes") && c.String("expected-fingerprint") == "" && !c.Bool("known-only") { + fmt.Fprintln(os.Stderr, "WARNING: auto-accepting pairing requests from ANY device on the local network.") + fmt.Fprintln(os.Stderr, "Use --expected-fingerprint or --known-only to restrict which device may pair.") + } + + // Snapshot of previously seen devices for --known-only. A stranger + // that was never recorded in the state file is never auto-accepted. + var known map[string]bool + if c.Bool("known-only") { + known = loadKnownDeviceIDs() + } + if err := cl.BroadcastStart(); err != nil { return fmt.Errorf("failed to start broadcast: %w", err) } @@ -104,25 +137,74 @@ request and auto-accepts it. Press Ctrl+C to cancel.`, result *ipc.PairListenResult err error } - resultCh := make(chan listenResult, 1) - go func() { - r, err := cl.PairListen() - resultCh <- listenResult{r, err} - }() - select { - case <-sigCh: - fmt.Println("\nCancelled") - return nil - case r := <-resultCh: - if r.err != nil { - return r.err - } - fmt.Printf("Paired with %s (%s)\n", r.result.DeviceName, r.result.DeviceID) - if r.result.VerificationKey != "" { - fmt.Printf("Verification code: %s\n", r.result.VerificationKey) + // Keep waiting past the daemon's 60s pair_listen timeout so a slow + // phone-side accept doesn't force a restart. Ctrl+C cancels. + for { + resultCh := make(chan listenResult, 1) + go func() { + r, err := cl.PairListen() + resultCh <- listenResult{r, err} + }() + + select { + case <-sigCh: + fmt.Println("\nCancelled") + return nil + case r := <-resultCh: + if r.err != nil { + if strings.Contains(r.err.Error(), "timed out") { + fmt.Println("No pair requests yet, still listening… (Ctrl+C to cancel)") + continue + } + return r.err + } + + fmt.Printf("\nIncoming pair request from:\n") + fmt.Printf(" Device: %s (%s)\n", protocol.DisplayName(r.result.DeviceName), r.result.DeviceID) + if r.result.VerificationKey != "" { + fmt.Printf(" Verification code: %s\n", r.result.VerificationKey) + } + + // Headless / auto-accept flag + if c.Bool("yes") { + if err := checkPairCandidate(c.String("expected-fingerprint"), c.Bool("known-only"), r.result, known); err != nil { + fmt.Printf("Refusing candidate: %s\n", err) + _ = cl.Unpair(r.result.DeviceID) + fmt.Println("Still listening… (Ctrl+C to cancel)") + continue + } + if err := cl.Pair(r.result.DeviceID); err != nil { + return fmt.Errorf("failed to accept pairing: %w", err) + } + fmt.Printf("Paired with %s (%s)\n", protocol.DisplayName(r.result.DeviceName), r.result.DeviceID) + return nil + } + + // Interactive prompt (default: reject) + fmt.Print("\nAccept pairing? [y/N]: ") + var response string + fmt.Scanln(&response) + + response = strings.TrimSpace(strings.ToLower(response)) + if response == "y" || response == "yes" { + if err := checkPairCandidate(c.String("expected-fingerprint"), c.Bool("known-only"), r.result, known); err != nil { + fmt.Printf("Refusing candidate: %s\n", err) + _ = cl.Unpair(r.result.DeviceID) + return nil + } + if err := cl.Pair(r.result.DeviceID); err != nil { + return fmt.Errorf("failed to accept pairing: %w", err) + } + fmt.Printf("Paired with %s (%s)\n", protocol.DisplayName(r.result.DeviceName), r.result.DeviceID) + return nil + } + + // User rejected: reject and cancel request + _ = cl.Unpair(r.result.DeviceID) + fmt.Printf("Rejected pairing with %s\n", protocol.DisplayName(r.result.DeviceName)) + return nil } - return nil } }, } @@ -148,6 +230,43 @@ var unpairCmd = &cli.Command{ }, } +// normalizeFingerprint strips separators and lowercases a hex fingerprint so +// user-supplied values (aa:bb:.., AA BB ..) compare against daemon hex. +func normalizeFingerprint(fp string) string { + fp = strings.ReplaceAll(fp, ":", "") + fp = strings.ReplaceAll(fp, " ", "") + return strings.ToLower(fp) +} + +// loadKnownDeviceIDs returns the set of device IDs recorded in the daemon's +// persisted state file. Missing or unreadable state means nothing is known. +func loadKnownDeviceIDs() map[string]bool { + known := make(map[string]bool) + infos, err := device.LoadDevices(config.StatePath()) + if err != nil { + return known + } + for _, info := range infos { + known[info.ID] = true + } + return known +} + +// checkPairCandidate enforces the --expected-fingerprint and --known-only +// constraints on a pairing candidate. Fail-closed: a candidate without a +// reported fingerprint never satisfies an expected fingerprint. +func checkPairCandidate(expectedFP string, knownOnly bool, result *ipc.PairListenResult, known map[string]bool) error { + if expectedFP != "" { + if got := normalizeFingerprint(result.Fingerprint); got == "" || got != normalizeFingerprint(expectedFP) { + return fmt.Errorf("candidate fingerprint does not match --expected-fingerprint") + } + } + if knownOnly && !known[result.DeviceID] { + return fmt.Errorf("candidate %s is not in the known-devices file (--known-only)", result.DeviceID) + } + return nil +} + func printDeviceTable(devices []device.DeviceInfo) { if len(devices) == 0 { fmt.Println("No devices found.") @@ -156,6 +275,6 @@ func printDeviceTable(devices []device.DeviceInfo) { fmt.Printf("%-36s %-20s %-10s %-10s %s\n", "DEVICE ID", "NAME", "TYPE", "STATE", "CONNECTED") fmt.Println("---------------------------------------------------------------------------------------------------") for _, d := range devices { - fmt.Printf("%-36s %-20s %-10s %-10s %v\n", d.ID, d.Name, d.Type, d.State, d.Connected) + fmt.Printf("%-36s %-20s %-10s %-10s %v\n", d.ID, protocol.DisplayName(d.Name), d.Type, d.State, d.Connected) } } diff --git a/cmd/kcd/cli_findmyphone.go b/cmd/kcd/cli_findmyphone.go index b94870b..b213ee0 100644 --- a/cmd/kcd/cli_findmyphone.go +++ b/cmd/kcd/cli_findmyphone.go @@ -3,6 +3,7 @@ package main import ( "fmt" + "github.com/bethropolis/kcd/internal/device" "github.com/urfave/cli/v2" ) @@ -23,13 +24,13 @@ var findmyphoneCmd = &cli.Command{ return err } for _, d := range devs { - if d.Connected { + if d.Connected && d.State == device.StatePaired { targetID = d.ID break } } if targetID == "" { - return fmt.Errorf("no connected devices found") + return fmt.Errorf("no paired connected devices found") } } diff --git a/cmd/kcd/cli_pair_test.go b/cmd/kcd/cli_pair_test.go new file mode 100644 index 0000000..e21e7fc --- /dev/null +++ b/cmd/kcd/cli_pair_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "testing" + + "github.com/bethropolis/kcd/internal/ipc" +) + +func TestNormalizeFingerprint(t *testing.T) { + cases := []struct{ in, want string }{ + {"abcdef1234", "abcdef1234"}, + {"AB:CD:EF:12:34", "abcdef1234"}, + {"ab cd ef 12 34", "abcdef1234"}, + {"", ""}, + } + for _, tc := range cases { + if got := normalizeFingerprint(tc.in); got != tc.want { + t.Errorf("normalizeFingerprint(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestCheckPairCandidate(t *testing.T) { + fp := "aa:bb:cc:dd" + candidate := &ipc.PairListenResult{DeviceID: "dev1", Fingerprint: "aabbccdd"} + known := map[string]bool{"dev1": true} + + if err := checkPairCandidate("", false, candidate, nil); err != nil { + t.Errorf("no constraints should accept: %v", err) + } + if err := checkPairCandidate(fp, false, candidate, nil); err != nil { + t.Errorf("matching fingerprint should accept: %v", err) + } + if err := checkPairCandidate("00:11:22:33", false, candidate, nil); err == nil { + t.Error("mismatched fingerprint should refuse") + } + if err := checkPairCandidate(fp, false, &ipc.PairListenResult{DeviceID: "dev1"}, nil); err == nil { + t.Error("missing candidate fingerprint should refuse when one is expected") + } + if err := checkPairCandidate("", true, candidate, known); err != nil { + t.Errorf("known device should accept with known-only: %v", err) + } + if err := checkPairCandidate("", true, &ipc.PairListenResult{DeviceID: "stranger"}, known); err == nil { + t.Error("unknown device should refuse with known-only") + } + // Nil known set behaves as "nothing known". + if err := checkPairCandidate("", true, candidate, nil); err == nil { + t.Error("empty known set should refuse with known-only") + } +} diff --git a/cmd/kcd/cli_sftp.go b/cmd/kcd/cli_sftp.go index 7ef2e46..dc8eb73 100644 --- a/cmd/kcd/cli_sftp.go +++ b/cmd/kcd/cli_sftp.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "github.com/urfave/cli/v2" @@ -37,6 +38,12 @@ The device responds with connection credentials on 'kcd watch'.`, ArgsUsage: "", Description: `Display the cached SFTP server credentials (IP, port, user, volumes). Use 'kcd sftp request' first to populate the cache.`, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "json", + Usage: "Output raw JSON", + }, + }, Action: func(c *cli.Context) error { if c.NArg() < 1 { return fmt.Errorf("missing device ID") @@ -49,6 +56,11 @@ Use 'kcd sftp request' first to populate the cache.`, if err != nil { return err } + if c.Bool("json") { + out, _ := json.Marshal(info) + fmt.Println(string(out)) + return nil + } fmt.Printf("IP: %s\n", info.IP) fmt.Printf("Port: %s\n", info.Port) fmt.Printf("User: %s\n", info.User) @@ -69,6 +81,12 @@ Use 'kcd sftp request' first to populate the cache.`, ArgsUsage: "", Description: `Show the browsable storage roots exposed by the device. Uses the multiPaths/pathNames fields from the cached SFTP credentials.`, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "json", + Usage: "Output raw JSON", + }, + }, Action: func(c *cli.Context) error { if c.NArg() < 1 { return fmt.Errorf("missing device ID") @@ -81,6 +99,11 @@ Uses the multiPaths/pathNames fields from the cached SFTP credentials.`, if err != nil { return err } + if c.Bool("json") { + out, _ := json.Marshal(volumes) + fmt.Println(string(out)) + return nil + } if len(volumes) == 0 { fmt.Println("No volumes available. Try 'kcd sftp request' first.") return nil diff --git a/cmd/kcd/cli_volume.go b/cmd/kcd/cli_volume.go index fd1cc58..e076af4 100644 --- a/cmd/kcd/cli_volume.go +++ b/cmd/kcd/cli_volume.go @@ -16,6 +16,12 @@ var volumeCmd = &cli.Command{ Name: "list", Usage: "List audio sinks on a remote device", ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "json", + Usage: "Output raw JSON", + }, + }, Action: func(c *cli.Context) error { if c.NArg() < 1 { return fmt.Errorf("missing device ID") @@ -28,6 +34,10 @@ var volumeCmd = &cli.Command{ if err != nil { return err } + if c.Bool("json") { + fmt.Println(string(data)) + return nil + } var sinks []remotesystemvolume.SinkInfo if err := json.Unmarshal(data, &sinks); err != nil { return fmt.Errorf("decode sinks: %w", err) diff --git a/cmd/kcd/main.go b/cmd/kcd/main.go index 2d4e07b..05665b1 100644 --- a/cmd/kcd/main.go +++ b/cmd/kcd/main.go @@ -113,6 +113,7 @@ func main() { unpairCmd, pingCmd, batteryCmd, + connectivityCmd, watchCmd, sftpCmd, replyCmd, @@ -124,6 +125,7 @@ func main() { clipboardCmd, runCmd, smsCmd, + contactsCmd, mprisCmd, volumeCmd, { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2bc7de0..19026dd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -58,6 +58,19 @@ Devices are found via two parallel mechanisms that run concurrently: The broadcast interval is adaptive: when `shouldReduce()` returns true (all known devices already connected) the interval steps up to 60 seconds. Broadcast is controlled by a `BroadcasterController` which is off by default — it only starts during `kcd pair` (listen mode) and stops when pairing completes. The UDP **listener** is always active, so paired devices reconnect without any broadcast. +### Ephemeral discovery dials + +Hearing a device is not enough for the phone to list the PC — the TCP +identity exchange is what makes both sides visible. So an unpaired +stranger gets exactly **one ephemeral dial per unpaired era**: the socket +is closed again on the next sighting while it is still unpaired, and the +marker (`Device.ephemeralDialed`) suppresses further re-dials, so there is +no connect/disconnect churn and no timers involved. Bypassed (dial and +stay) by: paired state, pairing mode, and explicit `kcd pair ` intent. +Explicit unpair/reject clears the marker, making the device eligible again. +When broadcast stops (`kcd pair` exits), still-connected unpaired devices +with no pair in flight are disconnected immediately. + ### mDNS / Zeroconf (`_kdeconnect._udp`) At startup the `Broadcaster` registers the local device as a Zeroconf service with the `libp2p/zeroconf/v2` library. TXT records carry `id`, `name`, `type`, and `protocol` fields per the KDE Connect spec. @@ -284,6 +297,7 @@ kcd pair [] — pair: with an ID sends a request; without one, listen mode kcd unpair — revoke trust kcd ping — send a ping kcd battery — fetch battery status +kcd connectivity [id] — show cellular signal/network type kcd share — send a file kcd clipboard [id] — push local clipboard to phone kcd sftp request — request SFTP credentials diff --git a/docs/CLI.md b/docs/CLI.md index b219266..12a1efd 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -28,7 +28,11 @@ kcd daemon [--config ] [--log-level ] ``` The daemon: -- Listens for device announcements via UDP and mDNS (always on) +- Listens for device announcements via UDP and mDNS (always on). + Unpaired strangers get one ephemeral TCP handshake so both sides list + each other, closed again on the next sighting — no lingering, no + re-dial churn. Persistent connections are for paired devices, active + `kcd pair` listen mode, or explicit `kcd pair ` / `kcd connect`. - Broadcasts its own identity only during `kcd pair` (listen mode) - Accepts inbound TCP connections on port 1716 - Runs all enabled plugins @@ -39,10 +43,16 @@ The daemon: ```bash cp packaging/kcd-user.service ~/.config/systemd/user/kcd.service +cp packaging/kcd-user.socket ~/.config/systemd/user/kcd.socket systemctl --user daemon-reload -systemctl --user enable --now kcd +systemctl --user enable --now kcd.socket ``` +The socket unit listens on the IPC socket and starts the daemon on the +first client connection — no manual start needed, cold commands just work. +(`install.sh` sets this up automatically.) Only the default socket path +activates this way; a custom `socket_path` in `kcd.toml` self-binds. + Check status: ```bash systemctl --user status kcd @@ -108,6 +118,7 @@ kcd devices [--json] |---|---| | `--json` | Output as a JSON array | | `--watch`, `-w` | Stream device changes live (clears screen on each change) | +| `--connected` | Only show paired devices, including offline ones (unpaired strangers are hidden; check the `CONNECTED` column for liveness) | **Example output** @@ -118,10 +129,11 @@ a1b2c3d4_e5f6_7890_abcd_ef1234567890 Pixel 8 Pro phone Paired tru b9e1f234_0000_1111_2222_333344445555 Galaxy Tab S9 tablet Unpaired false ``` -**JSON output** +**JSON output** (enriched with cached battery/media/signal; sections omitted +when the device never reported them): ```bash -kcd devices --json | jq '.[0].ID' +kcd devices --json | jq '.[0] | {name, battery: .battery.charge}' ``` ```json @@ -174,7 +186,7 @@ Pair with a device. Two modes depending on whether you provide a device ID. kcd pair ``` -If the device has already sent a pair request to `kcd` (state `PairRequestedByPeer`), this accepts it. Otherwise, it sends a new pair request — accept on your phone. +If the device has already sent a pair request to `kcd` (state `PairRequestedByPeer`), this accepts it. Otherwise, it connects to the device on demand (using its last-seen discovery address) and sends a new pair request — accept on your phone. ### Listen mode (headless / server) @@ -182,16 +194,38 @@ If the device has already sent a pair request to `kcd` (state `PairRequestedByPe kcd pair ``` -No arguments = listen mode. Broadcast is started automatically so the phone can discover the PC. The CLI waits for any incoming pair request and accepts it immediately, printing the verification code: +No arguments = listen mode. Broadcast is started automatically so the phone can discover the PC. The CLI shows the incoming request with its verification code and asks for confirmation: ``` Listening for pair requests… (Ctrl+C to cancel) -Paired with Pixel 8 Pro (a1b2c3d4...) -Verification code: 3a8f + +Incoming pair request from: + Device: Pixel 8 Pro (a1b2c3d4...) + Verification code: 3a8f12bc + +Accept pairing? [y/N]: +``` + +Answer `y` to pair, anything else (default) to reject. For headless systems and scripts, pass `-y` / `--yes` to accept without prompting: + +```bash +kcd pair --yes ``` Broadcast stops when pairing completes or you press Ctrl+C. +> **Constraining auto-accept:** bare `--yes` accepts the first device that +> asks, which is risky on shared networks (a warning is printed). Pin the +> expected peer instead: +> +> ```bash +> kcd pair --yes --expected-fingerprint "aa:bb:cc:..." # exact cert match (colons optional) +> kcd pair --yes --known-only # only devices already in the known-devices file +> ``` +> +> Non-matching candidates are rejected and listening continues. Both flags +> also apply to the interactive confirmation prompt. + --- ## unpair @@ -221,7 +255,7 @@ kcd ping Fetch the current battery level and charging state of a device. ``` -kcd battery +kcd battery [--json] ``` **Example output** @@ -231,10 +265,38 @@ Battery: 74% (charging) Battery: 31% (discharging) ``` +`--json` prints `{"deviceId":"...","charge":74,"charging":true}` for scripting. + > For continuous monitoring, use `kcd watch --events=battery.update` instead. --- +## connectivity + +Show the phone's cellular signal strength and network type (5G/LTE/…). + +``` +kcd connectivity [device-id] [--json] +``` + +If `device-id` is omitted, `kcd` automatically targets the first paired and connected device. + +**Example output** + +``` +LTE [███░] (3/4) +``` + +Dual-SIM phones print one line per SIM (`SIM 0: …`), primary first. +`--json` prints the raw report (same shape as `connectivity.update` event +payloads) for scripting. Exits non-zero with `no connectivity data` when +the device is offline or never reported — reports are requested fresh on +every connect. + +> For continuous monitoring, use `kcd watch --events=connectivity.update` instead. + +--- + ## clipboard Push the local clipboard content to a device. @@ -243,7 +305,7 @@ Push the local clipboard content to a device. kcd clipboard [device-id] ``` -If `device-id` is omitted, `kcd` automatically targets the first connected device. +If `device-id` is omitted, `kcd` automatically targets the first paired and connected device. **Clipboard backend detection** @@ -543,7 +605,7 @@ kcd watch --json --events=sftp.mount | jq -r 'select(.type=="sftp.mount") | .pay Show cached SFTP connection details for a paired device, including available storage volumes: ``` -kcd sftp info +kcd sftp info [--json] ``` **Example output** @@ -567,7 +629,7 @@ If the phone returned an error (e.g. storage permission not granted), the `error List available storage volumes without the full info output: ``` -kcd sftp volumes +kcd sftp volumes [--json] ``` **Example output** @@ -728,6 +790,32 @@ kcd sms attachment --- +## contacts + +Sync and browse the phone address book. The phone gates this on +`READ_CONTACTS` plus per-device opt-in — unanswered syncs simply produce +no events. Unpairing wipes the cached address book. + +### contacts sync + +Request a sync round (UID/timestamp list, then vCards for new or changed +contacts). Progress arrives as `contacts.updated` events (counts only). + +``` +kcd contacts sync +``` + +### contacts list + +List cached contact summaries (empty when never synced — absent means +unknown). + +``` +kcd contacts list [--json] +``` + +--- + ## volume Control the remote device's audio volume (requires `remotesystemvolume` plugin). @@ -737,7 +825,7 @@ Control the remote device's audio volume (requires `remotesystemvolume` plugin). List audio sinks on a remote device and their current volume/mute state. ``` -kcd volume list +kcd volume list [--json] ``` **Example output** @@ -897,10 +985,20 @@ done ## Tips -**Get the first connected device ID** +Fire-and-forget commands (`ping`, `lock`, `share`, `reply`, …) print a +human ack and exit 0 — only commands that return data offer `--json`. +Errors are always structured (`daemon error: …` on stderr/exit code). + +**Get the first paired device ID (works offline too)** + +```bash +kcd devices --json | jq -r '[.[] | select(.State=="PAIRED")] | .[0].ID' +``` + +**Get the first online (connected) paired device ID** ```bash -kcd devices --json | jq -r '[.[] | select(.Connected)] | .[0].ID' +kcd devices --json | jq -r '[.[] | select(.Connected and .State=="PAIRED")] | .[0].ID' ``` **Send a file from a Nautilus script** diff --git a/docs/CLIENT_GUIDE.md b/docs/CLIENT_GUIDE.md index 2a52fca..efee2ec 100644 --- a/docs/CLIENT_GUIDE.md +++ b/docs/CLIENT_GUIDE.md @@ -88,6 +88,23 @@ for dev in resp["data"]: States: `UNPAIRED`, `PAIR_REQUESTED`, `PAIR_REQUESTED_BY_PEER`, `PAIRED`. +> **Auto-device selection:** `connected: true` only means a raw TCP socket +> is open — unpaired strangers on the LAN also appear connected. Never +> auto-select the first entry with `connected == true`. Always prefer a +> device with `connected and state == "PAIRED"`, falling back to any +> `state == "PAIRED"` device, and to nothing otherwise: +> +> ```python +> def pick_auto_device(devices): +> for d in devices or []: +> if d.get("connected") and d.get("state") == "PAIRED": +> return d +> for d in devices or []: +> if d.get("state") == "PAIRED": +> return d +> return None # don't bind to unpaired stranger devices +> ``` + ### 3.2 Pairing Flow Pairing requires a persistent watch connection to receive the pairing request @@ -133,6 +150,10 @@ if resp["ok"]: print("Paired successfully!") ``` +> `pair_listen` never accepts on its own — it only returns the candidate. +> Your client must ask the user and then call `pair` (accept) or `unpair` +> (reject), mirroring `kcd pair` / `kcd pair --yes`. + ### 3.3 Unpairing ```python @@ -260,6 +281,7 @@ except KeyboardInterrupt: | Filter string | When it fires | |---|---| +| `state.snapshot` | Once per watch connection, right after the ack — full state for all known devices (online and offline); sent regardless of filters | | `device.connected` | TCP connection established | | `device.disconnected` | TCP connection lost | | `battery.update` | Battery level or charging state changed | @@ -268,6 +290,7 @@ except KeyboardInterrupt: | `share.complete` | File transfer finished | | `mpris.update` | Now-playing state changed (deduplicated — only on real changes) | | `sms.incoming` | SMS/MMS received | +| `contacts.updated` | Contacts sync progress (counts only; call `contacts_list` for data) | | `pair.requested` | Remote device wants to pair | | `ping.received` | Ping from device | @@ -283,10 +306,13 @@ except KeyboardInterrupt: > **Album art:** `mpris.update` payloads (and `kcd mpris status --json`) > expose `albumArtUrl` as a loadable `file://` path once the daemon has -> fetched the art from the phone into `$XDG_CACHE_HOME/kcd/art/`. If the -> art is still being fetched or fails, the raw `kdeconnect:/artUri?...` -> URI is emitted instead — treat anything that isn't `http(s)://` or -> `file://` as "no art available" and show a placeholder. +> fetched the art from the phone into `$XDG_CACHE_HOME/kcd/art/`. While the +> fetch is in flight the payload carries `"albumArtUrl": ""` with +> `"artPending": true` — render a placeholder whenever the URL is empty. + +> **Position:** payloads stamp `posAnchorMs` (Unix millis when `pos` was +> sampled). Live position is `pos + (nowMs - posAnchorMs)` while playing, +> frozen otherwise — no client-side timers needed. See [`IPC_PROTOCOL.md §5`](IPC_PROTOCOL.md#5-event-types) for the full list. @@ -347,6 +373,19 @@ ipc_request(sock, "findmyphone", {"deviceId": dev_id}) # or: ipc_request(sock, "ring", {"deviceId": dev_id}) ``` +### 5.7 Sync Contacts + +```python +ipc_request(sock, "contacts_sync", {"deviceId": dev_id}) +# progress arrives as contacts.updated events (counts only) + +resp = ipc_request(sock, "contacts_list", {"deviceId": dev_id}) +if resp["ok"]: + for c in resp["data"]: + print(c["name"], c.get("phones", [])) +# empty list = never synced (unknown, not zero contacts) +``` + ### 5.7 Lock/Unlock ```python @@ -449,8 +488,11 @@ Each event type carries a different payload shape. Here are the common ones: "album": "Album", "isPlaying": true, "pos": 45000, + "posAnchorMs": 1712345678901, "length": 240000, "volume": 80, + "albumArtUrl": "", + "artPending": true, "canControl": true, "shuffle": false, "loopStatus": "None" diff --git a/docs/IPC_PROTOCOL.md b/docs/IPC_PROTOCOL.md index 4250b19..aa52927 100644 --- a/docs/IPC_PROTOCOL.md +++ b/docs/IPC_PROTOCOL.md @@ -94,10 +94,13 @@ Fields: | `id` | string | Permanent device identifier | | `name` | string | Human-readable device name | | `type` | string | `"phone"`, `"tablet"`, `"laptop"`, `"desktop"` | -| `state` | string | `"UNPAIRED"`, `"PAIR_REQUESTED"`, `"PAIR_REQUESTED_BY_PEER"`, `"PAIRED"`, `"UNKNOWN"` | +| `state` | string | `"UNPAIRED"`, `"PAIR_REQUESTED"`, `"PAIR_REQUESTED_BY_PEER"`, `"PAIRED"` (`"UNKNOWN"` may appear in state files written by older versions and means unpaired) | | `cert_fp` | string | Not populated in this response (empty) | -| `last_seen` | string (RFC3339) | Not populated in this response (zero time) | -| `connected` | bool | Whether the device currently has an active TCP connection | +| `last_seen` | string (RFC3339) | Last time the device was seen (announcement or connection) | +| `connected` | bool | Whether the device currently has an active TCP connection. Note: `connected: true` alone does **not** mean usable — a stranger on the LAN can hold a raw connection while `state` is `UNPAIRED`. Clients must check `state == "PAIRED"` before sending commands or auto-selecting a device. | +| `battery` | object (optional) | `{"charge": 85, "charging": true, "batteryAgeMs": 1234}` — cached battery state; absent when the device never reported | +| `media` | object (optional) | Cached `NowPlaying` plus `mediaAgeMs` (ms since the phone reported); absent when the device never reported media | +| `signal` | object (optional) | Cached connectivity report (`{"signalStrengths": {...}}`); absent when never reported | #### `pair` @@ -118,6 +121,9 @@ Optional fields: - If neither `accept` nor `reject` is set, sends a pair request to the device. - If `accept: true`, accepts an incoming pair request from the device. - If `reject: true`, rejects or unpairs. +- If the device has no active connection, the daemon dials it on demand + using its last-seen discovery address (background auto-dial no longer + connects to unpaired devices), then sends the pair request. **Response data:** none (`{"ok": true}`) @@ -144,6 +150,10 @@ The handler blocks until a pair request arrives or the context is cancelled. The 16-character verification key should be displayed to the user to verify identity match on both sides. +> The daemon only **reports** the candidate — it does not accept it. +> Accept explicitly with `pair`, reject with `unpair`. This keeps stale +> requests (e.g. leftovers from tests) from pairing silently. + #### `unpair` Remove a paired device. @@ -225,13 +235,47 @@ Request battery state from a device. {"deviceId": "a1b2c3d4e5f6_..."} ``` -**Response data:** `{"charge": 85, "charging": true}` (the daemon waits for the -device to respond and returns the value). +**Response data:** `{"charge": 85, "charging": true, "batteryAgeMs": 1234}` (the +last cached reading plus its age in ms; the daemon does not live-query the +device). Returns `{"ok": false, "error": "no battery reading yet"}` when no +`kdeconnect.battery` packet was ever received — the `battery` object is +likewise absent from summaries until the first real packet, so clients must +treat absent as unknown, not 0%. | Field | Type | Description | |---|---|---| | `charge` | number | Battery percentage (0–100) | | `charging` | bool | Whether the device is currently charging | +| `batteryAgeMs` | number | ms since the phone reported (mirrors `mediaAgeMs`) | + +#### `connectivity` + +Return the last cellular connectivity report cached for a device (same +shape as `connectivity.update` event payloads). + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** + +```json +{"signalStrengths": {"0": {"networkType": "LTE", "networkDetailedType": "LTE", "signalStrength": 4}}} +``` + +| Field | Type | Description | +|---|---|---| +| `signalStrengths` | object | Map of SIM subscription ID → signal info (dual-SIM aware) | +| `networkType` | string | Network generation (`5G`, `LTE`, `GSM`, …) | +| `networkDetailedType` | string | Finer-grained type when reported (may be absent) | +| `signalStrength` | number | Level 0 (no signal) – 4 (full) | + +Errors: `device not found`, `connectivity plugin not enabled`, +`no connectivity data (device offline or never reported)`. Reports are +requested fresh on every connect; `kcd watch` also emits a cached +`connectivity.update` on subscribe so clients never boot blind. #### `clipboard_push` @@ -315,6 +359,37 @@ Request an MMS attachment file from a device. **Response data:** none (attachment arrives via side-channel transfer, emitted as `sms.attachment` event). +#### `contacts_sync` + +Request a contacts sync round from a device (UID/timestamp list, then +vCards for new or changed contacts). + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** none (progress arrives as `contacts.updated` events; +requires a connected device). + +#### `contacts_list` + +List cached contact summaries for a device. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** array of `{"uid", "name", "phones"?, "emails"?, "timestamp"}`. +Empty when never synced — absent means unknown. Example: + +```json +[{"uid": "1", "name": "Ada Lovelace", "phones": ["+1-555-0100"], "timestamp": 973486597}] +``` + #### `call_mute` Mute an incoming phone call. @@ -648,16 +723,27 @@ are delivered. accepting the watch request. The client must read (and discard) this line before processing events. -2. **State dump:** For each **connected** device, in arbitrary order: +2. **`state.snapshot`:** One event covering **all known devices** (online + and offline) with cached battery/media/signal, so clients boot with full + state from this single connection: + ```json + {"type":"state.snapshot","timestamp":"2026-05-27T10:00:00Z","payload":{"devices":[{...DeviceSummary...}]}} + ``` + Media entries carry `mediaAgeMs` (ms since the phone last reported) with + no freshness gate — clients apply their own staleness rules. This event + is sent regardless of `events` filters. + +3. **State dump:** For each **connected** device, in arbitrary order: **2a. `device.connected`:** ```json {"type":"device.connected","deviceId":"...","timestamp":"2026-05-27T10:00:00Z","payload":{"id":"...","name":"Pixel 9","type":"phone"}} ``` - **2b. `battery.update`:** + **2b. `battery.update`** (only if the device already reported battery — + no reading yet means no event, never a zero-value): ```json - {"type":"battery.update","deviceId":"...","timestamp":"...","payload":{"charge":85,"charging":true}} + {"type":"battery.update","deviceId":"...","timestamp":"...","payload":{"charge":85,"charging":true,"batteryAgeMs":1234}} ``` **2c. `mpris.update`** (only if MPRIS plugin is registered AND the cached @@ -741,6 +827,18 @@ A TCP connection was lost. **Payload:** none (`null`) +#### `state.snapshot` + +Full-state bootstrap sent once per `watch` connection, right after the +ack and regardless of `events` filters. Covers **all known devices** +(online and offline) with the same enriched shape as `devices`. + +**Payload:** + +```json +{"devices": [{...DeviceSummary (see `devices`)...}]} +``` + ### 5.2 Pairing Events #### `pair.requested` @@ -780,18 +878,20 @@ A pairing request was rejected, or a device was unpaired. #### `battery.update` -Battery state changed or was requested. +Battery state changed or was requested. Never emitted with zero values for +a device that never reported — absent reading means no event. **Payload:** ```json -{"charge": 85, "charging": true} +{"charge": 85, "charging": true, "batteryAgeMs": 1234} ``` | Field | Type | Description | |---|---|---| | `charge` | number | Battery percentage (0–100) | | `charging` | bool | Whether the device is currently charging | +| `batteryAgeMs` | number | ms since the phone reported (cached/dump events only) | #### `battery.threshold` @@ -1069,7 +1169,26 @@ An MMS attachment has been downloaded. {"filename": "image.jpg", "path": "/tmp/kcd-sms-attachment-...", "thread_id": 42} ``` -### 5.12 Ring Events +### 5.12 Contacts Events + +#### `contacts.updated` + +A contacts sync round made progress. Counts only — no contact content on +the event stream; call `contacts_list` for data. + +**Payload** (uids round): + +```json +{"phase": "uids", "added": 3, "updated": 1, "deleted": 0, "pending": 4} +``` + +**Payload** (vCards round): + +```json +{"phase": "vcards", "stored": 4, "skipped": 0} +``` + +### 5.13 Ring Events #### `ring.received` @@ -1078,7 +1197,7 @@ this daemon to ring). **Payload:** none (`null`) -### 5.13 MPRIS Events +### 5.14 MPRIS Events #### `mpris.update` @@ -1086,11 +1205,12 @@ Now-playing state from a device's media player. > **Album art:** when the phone advertises a `kdeconnect:/artUri?...` URI, > the daemon requests the art bytes over a side channel and caches them to -> `$XDG_CACHE_HOME/kcd/art/.`. Once fetched, `albumArtUrl` -> is emitted as a loadable `file://` path. A second `mpris.update` event is -> published when the art arrives. If the fetch is still in flight or fails, -> the original URI is left untouched — clients should fall back to a -> placeholder. +> `$XDG_CACHE_HOME/kcd/art/.`. While the fetch is in +> flight, events carry `"albumArtUrl": ""` with `"artPending": true` — never +> an unloadable URI, so clients can render a placeholder with no special +> casing. Once fetched, `albumArtUrl` is emitted as a loadable `file://` +> path in a second `mpris.update`. If the fetch fails, the pending flag +> clears on the next state change. > **Freshness:** the daemon re-requests now-playing from every connected > device with an **actively-playing** player every 5 seconds @@ -1102,6 +1222,11 @@ Now-playing state from a device's media player. > player keeps its last pushed track (so it can be resumed), but stops being > refreshed and ages out of the 10-second initial-dump gate. +> **Position:** every event stamps `posAnchorMs` (Unix millis when `pos` +> was sampled). Clients compute live position drift-free as +> `pos + (nowMs - posAnchorMs)` while `isPlaying` (rate 1), frozen +> otherwise — no local timers needed. + > **Session teardown:** when the phone reports a `playerList` that no longer > contains the device's currently-tracked player (the media session was > destroyed, e.g. the app was swiped away), the daemon drops the cached @@ -1122,6 +1247,7 @@ Now-playing state from a device's media player. "url": "spotify:track:...", "length": 240000, "pos": 45000, + "posAnchorMs": 1712345678901, "isPlaying": true, "volume": 80, "canControl": true, @@ -1170,6 +1296,8 @@ who may want to implement a full network-level implementation. | `kdeconnect.sms.request_conversations` | SMS | Request conversation list | | `kdeconnect.sms.request_conversation` | SMS | Request a specific thread's messages | | `kdeconnect.sms.request_attachment` | SMS | Request an MMS attachment file | +| `kdeconnect.contacts.request_all_uids_timestamps` | Contacts | Request all contact UIDs + timestamps (empty body) | +| `kdeconnect.contacts.request_vcards_by_uid` | Contacts | Request vCards (`{"uids": [...]}`) | | `kdeconnect.telephony.request_mute` | Telephony | Mute incoming call ringer | | `kdeconnect.systemvolume` | SystemVolume | Push local sink list to phone | | `kdeconnect.systemvolume.request` | RemoteSystemVolume | Set phone volume/mute or request sink list | @@ -1198,6 +1326,8 @@ plugin processes it and a link to the body struct definition. | `kdeconnect.telephony` | Telephony | `TelephonyBody{Event, ContactName, PhoneNumber, IsCancel}` | | `kdeconnect.sms.messages` | SMS | `SMSMessagesPacket{Version, Messages []SMSMessage}` | | `kdeconnect.sms.attachment_file` | SMS | `AttachmentFileBody{Filename, ThreadID}` | +| `kdeconnect.contacts.response_uids_timestamps` | Contacts | `{"uids": [...], "": }` | +| `kdeconnect.contacts.response_vcards` | Contacts | `{"uids": [...], "": ""}` | | `kdeconnect.findmyphone.request` | FindMyPhone | (empty, triggers ring event) | | `kdeconnect.connectivity_report` | Connectivity | `ConnectivityBody{SignalStrengths map[string]SignalStrength}` | | `kdeconnect.clipboard` | Clipboard | `ClipboardBody{Content string, Timestamp int64}` | diff --git a/internal/cert/cert.go b/internal/cert/cert.go index 0b376b0..ce98e5a 100644 --- a/internal/cert/cert.go +++ b/internal/cert/cert.go @@ -16,6 +16,7 @@ import ( "fmt" "math/big" "os" + "strings" "time" ) @@ -128,6 +129,33 @@ func Fingerprint(cert *x509.Certificate) string { return hex.EncodeToString(hash[:]) } +// PinnedFingerprint returns the hex fingerprint of a pinned peer certificate, +// or "" when no certificate is available (unpaired or legacy device). Callers +// must log and proceed unverified in that case. +func PinnedFingerprint(peer *x509.Certificate) string { + if peer == nil { + return "" + } + return Fingerprint(peer) +} + +// VerifySideChannelPeer checks the certificate presented on a side-channel +// TLS connection against the fingerprint pinned when the transfer was +// requested. An empty expected fingerprint skips verification (the caller +// logs a warning) so unpaired devices keep working. +func VerifySideChannelPeer(state tls.ConnectionState, expectedFP string) error { + if len(state.PeerCertificates) == 0 { + return fmt.Errorf("cert: side-channel peer presented no certificate") + } + if expectedFP == "" { + return nil + } + if actual := Fingerprint(state.PeerCertificates[0]); !strings.EqualFold(actual, expectedFP) { + return fmt.Errorf("cert: side-channel peer fingerprint mismatch") + } + return nil +} + // VerificationKey generates a verification fingerprint used for out-of-band pairing verification. // It creates a SHA256 hash of the concatenated public keys to display a fingerprint. func VerificationKey(localCert, remoteCert *x509.Certificate) string { diff --git a/internal/cert/cert_test.go b/internal/cert/cert_test.go index a0a8a64..cf82f82 100644 --- a/internal/cert/cert_test.go +++ b/internal/cert/cert_test.go @@ -1,6 +1,7 @@ package cert import ( + "crypto/tls" "crypto/x509" "os" "path/filepath" @@ -77,3 +78,48 @@ func TestLoadOrGenerate(t *testing.T) { t.Error("loaded certificate mismatch with generated one") } } + +func TestPinnedFingerprint(t *testing.T) { + if got := PinnedFingerprint(nil); got != "" { + t.Errorf("expected empty fingerprint for nil cert, got %q", got) + } + + c, err := GenerateSelfSigned("pinned_fp_test") + if err != nil { + t.Fatal(err) + } + x509Cert, _ := x509.ParseCertificate(c.Certificate[0]) + if got := PinnedFingerprint(x509Cert); got != Fingerprint(x509Cert) { + t.Error("PinnedFingerprint mismatch with Fingerprint") + } +} + +func TestVerifySideChannelPeer(t *testing.T) { + c, err := GenerateSelfSigned("verify_peer_test") + if err != nil { + t.Fatal(err) + } + x509Cert, _ := x509.ParseCertificate(c.Certificate[0]) + fp := Fingerprint(x509Cert) + + other, err := GenerateSelfSigned("verify_peer_other") + if err != nil { + t.Fatal(err) + } + otherCert, _ := x509.ParseCertificate(other.Certificate[0]) + + state := tls.ConnectionState{PeerCertificates: []*x509.Certificate{x509Cert}} + + if err := VerifySideChannelPeer(state, fp); err != nil { + t.Errorf("matching fingerprint rejected: %v", err) + } + if err := VerifySideChannelPeer(state, Fingerprint(otherCert)); err == nil { + t.Error("mismatched fingerprint accepted") + } + if err := VerifySideChannelPeer(state, ""); err != nil { + t.Errorf("empty expected fingerprint should skip verification: %v", err) + } + if err := VerifySideChannelPeer(tls.ConnectionState{}, fp); err == nil { + t.Error("missing peer certificate accepted") + } +} diff --git a/internal/config/plugins.go b/internal/config/plugins.go index 2323e8d..994b5b6 100644 --- a/internal/config/plugins.go +++ b/internal/config/plugins.go @@ -22,6 +22,7 @@ type PluginConfig struct { SystemVolume bool `toml:"systemvolume"` PauseMusic bool `toml:"pausemusic"` SMS bool `toml:"sms"` + Contacts bool `toml:"contacts"` Presenter bool `toml:"presenter"` FindThisDevice bool `toml:"findthisdevice"` RemoteSystemVolume bool `toml:"remotesystemvolume"` @@ -113,6 +114,7 @@ func (p *PluginConfig) Defaults() { p.SystemVolume = true p.PauseMusic = true p.SMS = true + p.Contacts = true p.Presenter = true p.FindThisDevice = true p.RemoteSystemVolume = true diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 2365d4c..e0ccee4 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -28,6 +28,19 @@ import ( // version is set via ldflags at build time. var version = "dev" +// syncReconnectBroadcast starts UDP broadcast (reconnect owner) while any +// paired device is offline, and withdraws it otherwise. Pure state, no +// timers — callers invoke it from event handlers and startup. +func syncReconnectBroadcast(ctx context.Context, devices *device.Registry, bc *discovery.BroadcasterController) { + for _, dev := range devices.List() { + if dev.State() == device.StatePaired && !dev.IsConnected() { + bc.StartOwned(ctx, discovery.OwnerReconnect) + return + } + } + bc.StopOwned(discovery.OwnerReconnect) +} + // Run starts the core daemon lifecycle. func Run(ctx context.Context, cfg *config.Config) error { startedAt := time.Now() @@ -77,10 +90,26 @@ func Run(ctx context.Context, cfg *config.Config) error { statePath := config.StatePath() if loaded, err := device.LoadDevices(statePath); err == nil { for _, info := range loaded { + // Migrate pre-fix state: devices persisted as UNKNOWN (the old + // zero value) are simply unpaired. + if info.State == device.StateUnknown { + info.State = device.StateUnpaired + } + // Migrate pre-fix names: some senders transmit decimal byte + // escapes (e.g. "Caf\\195\\169"); decode to real UTF-8. + // (SanitizeDeviceName already decodes; don't double-decode.) + info.Name = protocol.SanitizeDeviceName(info.Name) dev := device.NewDevice(info.ID, info.Name, info.Type, logger) dev.SetState(info.State) dev.CertFP = info.CertFP dev.SetLastSeen(info.LastSeen) + // Restore the dial target so paired auto-dial works immediately + // after a restart (validation inside DialTarget — the file is + // user-editable). + if ip, port := info.DialTarget(); ip != nil { + dev.SetLastIP(ip) + dev.SetLastPort(port) + } devices.Add(dev) } } else { @@ -98,14 +127,20 @@ func Run(ctx context.Context, cfg *config.Config) error { devs := devices.List() infos := make([]device.DeviceInfo, 0, len(devs)) for _, dev := range devs { - infos = append(infos, device.DeviceInfo{ + info := device.DeviceInfo{ ID: dev.ID(), Name: dev.Name(), Type: dev.Type, State: dev.State(), CertFP: dev.CertFP, LastSeen: dev.LastSeen(), - }) + LastPort: dev.LastPort(), + } + // net.IP.String() on nil renders "" — store empty instead. + if ip := dev.LastIP(); ip != nil { + info.LastIP = ip.String() + } + infos = append(infos, info) } _ = device.SaveDevices(statePath, infos) } @@ -130,11 +165,79 @@ func Run(ctx context.Context, cfg *config.Config) error { bc := discovery.NewBroadcasterController(identity, 30*time.Second, logger, devices.AllPairedDevicesConnected) + // mDNS advertisement is always on: unlike UDP broadcast it is + // responder-only (zero idle timers), so phones keep a standing + // discovery path even while UDP broadcast is stopped. + go discovery.AdvertiseMDNS(ctx, identity, logger) + + // Advertise over UDP while a paired device is offline so it can find + // us back (DHCP roam, restart, mutual loss). Fully stopped otherwise — + // connected steady state keeps zero timers. Ownership is tracked, so + // this neither starts pairing broadcasts nor stops `kcd pair`'s. The + // subscription is channel-driven (no polling); the initial sync covers + // restarts with offline pairs. + go func() { + sub := bus.Subscribe(0, events.TypeDeviceConnected, events.TypeDeviceDisconnected) + defer sub.Close() + sync := func() { syncReconnectBroadcast(ctx, devices, bc) } + sync() + for range sub.C { + sync() + } + }() + // 5. IPC Server handler := ipc.NewHandler(devices, plugins, pairPlugin, statePath, bus, pruneThreshold) registerIPCRoutes(handler, cfg, devices, plugins, bc, ctx, tlsCfg, logger, startedAt) + // Explicit `kcd pair ` dials on demand (background auto-dial no + // longer touches unpaired devices). Marks a one-shot dial flag so the + // next discovery announcement connects, and kicks an immediate dial if + // we already know where the device was last seen. + handler.SetPairDialHook(func(deviceID string) error { + dev, ok := devices.Get(deviceID) + if !ok { + return fmt.Errorf("device not found") + } + if dev.State() == device.StatePaired { + return fmt.Errorf("device already paired") + } + dev.RequestPairDial() + ip, port := dev.DiscoveryAddr() + if ip == nil { + // No address yet — the flagged dial fires on the next + // broadcast from the phone; tell the user to retry then. + return fmt.Errorf("device address unknown yet, wait for discovery and retry") + } + if port == 0 { + port = 1716 + } + go func() { + DialDevice(ctx, ip, port, deviceID, protocol.ProtocolVersion, identity, tlsCfg, devices, plugins, cfg.DeviceID, logger) + if !dev.IsConnected() { + logger.Warn("on-demand pair dial failed", zap.String("device_id", deviceID)) + return + } + // The immediate dial succeeded: consume the one-shot trigger so + // the next discovery announcement doesn't spawn a duplicate + // dial. The keep-alive intent (PairDialActive) stays armed until + // pairing starts, ends, or expires. On failure the trigger is + // left for the next sighting to retry. + dev.ConsumePairDial() + if dev.State() == device.StatePairRequestedByPeer { + if err := pairPlugin.AcceptPairing(dev); err != nil { + logger.Warn("auto-accept on pair dial failed", zap.Error(err)) + } + } else if dev.State() != device.StatePaired { + if err := pairPlugin.RequestPairing(dev); err != nil { + logger.Warn("pair request on dial failed", zap.Error(err)) + } + } + }() + return nil + }) + ipcServer := ipc.NewServer(cfg.SocketPath, handler, logger) // Start IPC in background diff --git a/internal/daemon/ipc_routes.go b/internal/daemon/ipc_routes.go index 2757671..30e8d8d 100644 --- a/internal/daemon/ipc_routes.go +++ b/internal/daemon/ipc_routes.go @@ -29,9 +29,15 @@ func registerIPCRoutes(handler *ipc.Handler, cfg *config.Config, devices *device if cfg.Plugins.Battery { registerBatteryRoutes(handler, devices) } + if cfg.Plugins.Connectivity { + registerConnectivityRoutes(handler, devices, plugins) + } if cfg.Plugins.Clipboard { registerClipboardRoutes(handler, devices, plugins) } + if cfg.Plugins.Contacts { + registerContactsRoutes(handler, devices, plugins) + } if cfg.Plugins.RunCommand { registerRunCommandRoutes(handler, devices) } @@ -78,6 +84,18 @@ func registerIPCRoutes(handler *ipc.Handler, cfg *config.Config, devices *device }) handler.Register(ipc.CmdBroadcastStop, func(req ipc.Request) ipc.Response { bc.Stop() + // Pairing window closed: drop discovery connections that never led + // to pairing so strangers don't linger. Paired devices, pair + // requests in flight, and explicit pair intents (kept alive until + // pairing starts, ends, or expires) are left alone. + for _, dev := range devices.List() { + if !dev.IsConnected() || dev.State() != device.StateUnpaired || dev.PairDialActive() { + continue + } + logger.Debug("broadcast stopped, dropping unpaired discovery connection", + zap.String("device_id", dev.ID())) + dev.Disconnect() + } return ipc.Response{OK: true} }) diff --git a/internal/daemon/ipc_routes_battery.go b/internal/daemon/ipc_routes_battery.go index 2fe8a54..dab0fc1 100644 --- a/internal/daemon/ipc_routes_battery.go +++ b/internal/daemon/ipc_routes_battery.go @@ -17,10 +17,16 @@ func registerBatteryRoutes(handler *ipc.Handler, devices *device.Registry) { if !ok { return ipc.Response{OK: false, Error: "device not found"} } + // Fail closed when no packet was ever received: returning the + // zero values would be indistinguishable from a real 0% reading. + if !dev.HasBattery() { + return ipc.Response{OK: false, Error: "no battery reading yet"} + } charge, charging := dev.GetBattery() data, _ := json.Marshal(map[string]interface{}{ - "charge": charge, - "charging": charging, + "charge": charge, + "charging": charging, + "batteryAgeMs": dev.BatteryAge().Milliseconds(), }) return ipc.Response{OK: true, Data: data} }) diff --git a/internal/daemon/ipc_routes_connectivity.go b/internal/daemon/ipc_routes_connectivity.go new file mode 100644 index 0000000..ccfde31 --- /dev/null +++ b/internal/daemon/ipc_routes_connectivity.go @@ -0,0 +1,33 @@ +package daemon + +import ( + "encoding/json" + + "github.com/bethropolis/kcd/internal/device" + "github.com/bethropolis/kcd/internal/ipc" + "github.com/bethropolis/kcd/internal/plugin" + "github.com/bethropolis/kcd/internal/plugins/connectivity" +) + +func registerConnectivityRoutes(handler *ipc.Handler, devices *device.Registry, plugins *plugin.Registry) { + handler.Register(ipc.CmdConnectivity, func(req ipc.Request) ipc.Response { + var p ipc.DevicePayload + if err := json.Unmarshal(req.Payload, &p); err != nil { + return ipc.Response{OK: false, Error: "invalid payload"} + } + dev, ok := devices.Get(p.DeviceID) + if !ok { + return ipc.Response{OK: false, Error: "device not found"} + } + pl, ok := plugins.GetByName("Connectivity") + if !ok { + return ipc.Response{OK: false, Error: "connectivity plugin not enabled"} + } + report, ok := pl.(*connectivity.ConnectivityPlugin).Report(dev.ID()) + if !ok { + return ipc.Response{OK: false, Error: "no connectivity data (device offline or never reported)"} + } + data, _ := json.Marshal(report) + return ipc.Response{OK: true, Data: data} + }) +} diff --git a/internal/daemon/ipc_routes_contacts.go b/internal/daemon/ipc_routes_contacts.go new file mode 100644 index 0000000..0834aba --- /dev/null +++ b/internal/daemon/ipc_routes_contacts.go @@ -0,0 +1,51 @@ +package daemon + +import ( + "encoding/json" + + "github.com/bethropolis/kcd/internal/device" + "github.com/bethropolis/kcd/internal/ipc" + "github.com/bethropolis/kcd/internal/plugin" + "github.com/bethropolis/kcd/internal/plugins/contacts" +) + +func registerContactsRoutes(handler *ipc.Handler, devices *device.Registry, plugins *plugin.Registry) { + handler.Register(ipc.CmdContactsSync, func(req ipc.Request) ipc.Response { + var p ipc.DevicePayload + if err := json.Unmarshal(req.Payload, &p); err != nil { + return ipc.Response{OK: false, Error: "invalid payload"} + } + pl, ok := plugins.GetByName("Contacts") + if !ok { + return ipc.Response{OK: false, Error: "contacts plugin not enabled"} + } + dev, ok := devices.Get(p.DeviceID) + if !ok { + return ipc.Response{OK: false, Error: "device not found"} + } + if !dev.IsConnected() { + return ipc.Response{OK: false, Error: "device not connected"} + } + if err := pl.(*contacts.ContactsPlugin).RequestSync(dev); err != nil { + return ipc.Response{OK: false, Error: err.Error()} + } + return ipc.Response{OK: true} + }) + + handler.Register(ipc.CmdContactsList, func(req ipc.Request) ipc.Response { + var p ipc.DevicePayload + if err := json.Unmarshal(req.Payload, &p); err != nil { + return ipc.Response{OK: false, Error: "invalid payload"} + } + pl, ok := plugins.GetByName("Contacts") + if !ok { + return ipc.Response{OK: false, Error: "contacts plugin not enabled"} + } + list := pl.(*contacts.ContactsPlugin).List(p.DeviceID) + if list == nil { + list = []contacts.ContactSummary{} + } + data, _ := json.Marshal(list) + return ipc.Response{OK: true, Data: data} + }) +} diff --git a/internal/daemon/plugins.go b/internal/daemon/plugins.go index 1d66d40..9eaedf9 100644 --- a/internal/daemon/plugins.go +++ b/internal/daemon/plugins.go @@ -11,6 +11,7 @@ import ( "github.com/bethropolis/kcd/internal/plugins/battery" "github.com/bethropolis/kcd/internal/plugins/clipboard" "github.com/bethropolis/kcd/internal/plugins/connectivity" + "github.com/bethropolis/kcd/internal/plugins/contacts" "github.com/bethropolis/kcd/internal/plugins/findmyphone" "github.com/bethropolis/kcd/internal/plugins/findthisdevice" "github.com/bethropolis/kcd/internal/plugins/lockdevice" @@ -84,6 +85,9 @@ func setupPlugins(cfg *config.Config, bus *events.Bus, tlsCfg *tls.Config, logge if cfg.Plugins.SMS { plugins.Register(sms.NewSMSPlugin(cfg.SMS, bus, tlsCfg, logger)) } + if cfg.Plugins.Contacts { + plugins.Register(contacts.NewContactsPlugin(bus, logger)) + } if cfg.Plugins.RemoteSystemVolume { plugins.Register(remotesystemvolume.NewRemoteSystemVolumePlugin(bus, logger)) } diff --git a/internal/daemon/transport.go b/internal/daemon/transport.go index f36c80b..5e1ce3e 100644 --- a/internal/daemon/transport.go +++ b/internal/daemon/transport.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net" + "sync" "time" "github.com/bethropolis/kcd/internal/cert" @@ -23,8 +24,22 @@ import ( // escalating instead of resetting to the 2s floor on every drop. const reconnectFlapThreshold = 15 * time.Second +// validDialPort reports whether a discovery-advertised TCP port is usable. +// Port 0 and out-of-range values come from malformed or hostile packets and +// must never reach the dialer (port 0 would dial ":0"). +func validDialPort(port int) bool { + return port > 0 && port <= 65535 +} + // DialDevice manually connects to a device at the given IP and port. func DialDevice(ctx context.Context, targetIP net.IP, targetPort int, targetID string, targetProto int, identity *protocol.Packet, cfg *tls.Config, devices *device.Registry, plugins *plugin.Registry, localDeviceID string, logger *zap.Logger) { + if targetIP == nil || !validDialPort(targetPort) { + logger.Debug("refusing to dial invalid target", + zap.String("device_id", targetID), + zap.String("ip", targetIP.String()), + zap.Int("port", targetPort)) + return + } addr := fmt.Sprintf("%s:%d", targetIP, targetPort) logger.Debug("dialing discovered device", zap.String("device_id", targetID), zap.String("addr", addr)) @@ -40,6 +55,11 @@ func DialDevice(ctx context.Context, targetIP net.IP, targetPort int, targetID s var myID protocol.IdentityBody json.Unmarshal(identity.Body, &myID) + // Clamp the echoed protocol version: discovery bodies are unauthenticated + // and a garbage value here would only confuse the peer. + if targetProto <= 0 || targetProto > protocol.ProtocolVersion { + targetProto = protocol.ProtocolVersion + } preTlsId := protocol.IdentityBody{ DeviceID: myID.DeviceID, DeviceName: myID.DeviceName, @@ -74,7 +94,30 @@ func DialDevice(ctx context.Context, targetIP net.IP, targetPort int, targetID s } } -func runTransport(ctx context.Context, cfg *tls.Config, _ *discovery.BroadcasterController, identity *protocol.Packet, devices *device.Registry, plugins *plugin.Registry, localDeviceID string, logger *zap.Logger) { +// shouldEphemeralClose reports whether an active connection that exists +// only for the discovery handshake should be closed on a fresh sighting. +// Connections are kept when pairing mode is active, when the device is +// paired, when a pair request is in flight either way, or when the user +// explicitly asked to pair with the device. +func shouldEphemeralClose(dev *device.Device, pairingMode bool) bool { + if pairingMode { + return false + } + if !dev.EphemeralDialed() { + return false + } + if dev.State() != device.StateUnpaired { + return false + } + // An explicit `kcd pair ` intent outlives the one-shot dial trigger + // (consumed on first sighting) until pairing starts, ends, or expires. + if dev.PairDialActive() { + return false + } + return true +} + +func runTransport(ctx context.Context, cfg *tls.Config, bc *discovery.BroadcasterController, identity *protocol.Packet, devices *device.Registry, plugins *plugin.Registry, localDeviceID string, logger *zap.Logger) { // TCP Listener tcpListener, err := transport.Listen(ctx, ":1716") if err != nil { @@ -87,6 +130,44 @@ func runTransport(ctx context.Context, cfg *tls.Config, _ *discovery.Broadcaster // The controller is started in stopped state. // UDP/mDNS Listener (onDeviceFound) + // + // Discovery is event-driven with no timers or polling: + // - Paired devices, pairing mode (`kcd pair` listen), and explicit + // `kcd pair ` intent dial and keep the connection. + // - An unpaired stranger gets exactly one ephemeral dial per unpaired + // era so both sides can list each other (the TCP identity exchange + // is what makes the PC appear on the phone). The next sighting + // closes the socket again while it is still unpaired. + // Ephemeral-dial rate limiting: a hostile or buggy peer minting fresh + // device IDs per broadcast could otherwise spawn an unbounded dial per + // announcement. Stranger dials are throttled globally (1/s) and per + // announcer IP (1/5s). Paired and explicit-intent dials bypass this — + // paired dials have their own per-device throttle below and intent + // dials are user-initiated. + var dialMu sync.Mutex + lastEphemeralGlobal := time.Now().Add(-time.Minute) + lastEphemeralByIP := map[string]time.Time{} + allowEphemeralDial := func(ip net.IP) bool { + dialMu.Lock() + defer dialMu.Unlock() + now := time.Now() + if now.Sub(lastEphemeralGlobal) < time.Second { + return false + } + if last, ok := lastEphemeralByIP[ip.String()]; ok && now.Sub(last) < 5*time.Second { + return false + } + // Opportunistic prune so spoofed source IPs can't grow the map. + for k, v := range lastEphemeralByIP { + if now.Sub(v) > time.Minute { + delete(lastEphemeralByIP, k) + } + } + lastEphemeralGlobal = now + lastEphemeralByIP[ip.String()] = now + return true + } + onDeviceFound := func(ip net.IP, tcpPort int, peerIdentity *protocol.Packet) { var body protocol.IdentityBody if err := json.Unmarshal(peerIdentity.Body, &body); err != nil { @@ -97,14 +178,86 @@ func runTransport(ctx context.Context, cfg *tls.Config, _ *discovery.Broadcaster return } - if dev, ok := devices.Get(body.DeviceID); ok && dev.IsConnected() { + // Never dial garbage ports from unauthenticated announcements. + if !validDialPort(tcpPort) { + logger.Debug("ignoring discovery with invalid tcpPort", + zap.String("device_id", body.DeviceID), + zap.Int("port", tcpPort)) + return + } + + pairingMode := bc != nil && bc.IsRunning() + dev, known := devices.Get(body.DeviceID) + + if known && dev.IsConnected() { + // Fresh sighting of a connected device: close it again if it + // exists only for the discovery handshake. + if shouldEphemeralClose(dev, pairingMode) { + logger.Debug("closing ephemeral discovery connection", + zap.String("device_id", body.DeviceID)) + dev.Disconnect() + } + return + } + + if known && dev.State() == device.StatePaired { + // A sighting proves the peer is alive at the sighted address, + // so dial it: whoever answers must present the paired + // certificate (CN + pinned fingerprint are verified in + // handleNewConnection) or setup fails. A spoofed sighting can + // only cost a throttled dial, never a session. The persisted + // LastIP remains the fallback for a silent (non-broadcasting) + // peer via the backoff loop. + dev.SetLastSeen(time.Now()) + if !dev.IsConnected() { + // New information beats old backoff: a sighting from an + // address other than the failing target proves the peer + // roamed, so later cycles restart escalation at the floor. + // (The throttled dial below is what hurries this cycle; + // an in-flight backoff keeps its own counter.) Same-address + // sightings leave the counter alone, preserving flap + // protection for a dying peer that keeps announcing. + if lastIP := dev.LastIP(); lastIP == nil || !lastIP.Equal(ip) { + dev.ResetReconnectAttempt() + } + if dev.ShouldDiscoveryDial(10 * time.Second) { + go DialDevice(ctx, ip, tcpPort, body.DeviceID, body.ProtocolVersion, identity, cfg, devices, plugins, localDeviceID, logger) + } + } + return + } + + safeName := protocol.SanitizeDeviceName(body.DeviceName) + if !known { + dev = device.NewDevice(body.DeviceID, safeName, body.DeviceType, logger) + devices.Add(dev) + } else { + dev.SetName(safeName) + } + dev.SetDiscoveryAddr(ip, tcpPort) + dev.SetLastSeen(time.Now()) + + if pairingMode || dev.ConsumePairDial() { + // Spawn goroutine to prevent blocking the discovery listener + go func(targetIP net.IP, targetPort int, targetID string, targetProto int) { + DialDevice(ctx, targetIP, targetPort, targetID, targetProto, identity, cfg, devices, plugins, localDeviceID, logger) + }(ip, tcpPort, body.DeviceID, body.ProtocolVersion) return } - // Spawn goroutine to prevent blocking the discovery listener - go func(targetIP net.IP, targetPort int, targetID string, targetProto int) { - DialDevice(ctx, targetIP, targetPort, targetID, targetProto, identity, cfg, devices, plugins, localDeviceID, logger) - }(ip, tcpPort, body.DeviceID, body.ProtocolVersion) + if !dev.EphemeralDialed() { + dev.MarkEphemeralDialed() + if !allowEphemeralDial(ip) { + return + } + // Spawn goroutine to prevent blocking the discovery listener + go func(targetIP net.IP, targetPort int, targetID string, targetProto int) { + DialDevice(ctx, targetIP, targetPort, targetID, targetProto, identity, cfg, devices, plugins, localDeviceID, logger) + }(ip, tcpPort, body.DeviceID, body.ProtocolVersion) + } + // Otherwise the device already had its ephemeral dial for this + // unpaired era: stay silent instead of redialling on every + // announcement. Pairing mode and explicit `kcd pair ` bypass. } udpListener := discovery.NewListener(1716, localDeviceID, onDeviceFound, logger) @@ -206,6 +359,13 @@ func handleNewConnection(ctx context.Context, conn *transport.Conn, identity *pr dev.CertFP = certFP } + // Remember the peer's listening port from the authenticated exchange so + // paired auto-dials use a known-good target instead of trusting future + // (unauthenticated) discovery announcements. + if validDialPort(peerBody.TCPPort) { + dev.SetLastPort(peerBody.TCPPort) + } + dev.IncomingCaps = peerBody.IncomingCapabilities dev.OutgoingCaps = peerBody.OutgoingCapabilities diff --git a/internal/daemon/transport_test.go b/internal/daemon/transport_test.go new file mode 100644 index 0000000..f03487d --- /dev/null +++ b/internal/daemon/transport_test.go @@ -0,0 +1,193 @@ +package daemon + +import ( + "context" + "testing" + "time" + + "github.com/bethropolis/kcd/internal/device" + "github.com/bethropolis/kcd/internal/discovery" + "github.com/bethropolis/kcd/internal/protocol" + "go.uber.org/zap" +) + +func ephemeralDevice(state device.PairingState, markEphemeral, pairIntent bool) *device.Device { + dev := device.NewDevice("test-id", "Test", "phone", zap.NewNop()) + dev.SetState(state) + if markEphemeral { + dev.MarkEphemeralDialed() + } + if pairIntent { + dev.RequestPairDial() + } + return dev +} + +func TestShouldEphemeralClose(t *testing.T) { + cases := []struct { + name string + dev *device.Device + pairingMode bool + want bool + }{ + {"pairing mode keeps", ephemeralDevice(device.StateUnpaired, true, false), true, false}, + {"never dialled keeps", ephemeralDevice(device.StateUnpaired, false, false), false, false}, + {"paired keeps", ephemeralDevice(device.StatePaired, true, false), false, false}, + {"pair requested keeps", ephemeralDevice(device.StatePairRequested, true, false), false, false}, + {"pair requested by peer keeps", ephemeralDevice(device.StatePairRequestedByPeer, true, false), false, false}, + {"explicit intent keeps", ephemeralDevice(device.StateUnpaired, true, true), false, false}, + {"idle stranger closes", ephemeralDevice(device.StateUnpaired, true, false), false, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shouldEphemeralClose(tc.dev, tc.pairingMode); got != tc.want { + t.Errorf("shouldEphemeralClose() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestEphemeralMarkerReset(t *testing.T) { + dev := device.NewDevice("test-id", "Test", "phone", zap.NewNop()) + if dev.EphemeralDialed() { + t.Fatal("new device must not be ephemeral-marked") + } + dev.MarkEphemeralDialed() + if !dev.EphemeralDialed() { + t.Fatal("marker not set") + } + dev.ClearEphemeral() + if dev.EphemeralDialed() { + t.Fatal("marker not cleared") + } + if dev.PairDialPending() { + t.Fatal("new device must have no pair intent") + } + dev.RequestPairDial() + if !dev.PairDialPending() { + t.Fatal("pair intent not visible") + } + if !dev.ConsumePairDial() { + t.Fatal("pair intent not consumed") + } + if dev.PairDialPending() { + t.Fatal("pair intent not cleared after consume") + } +} + +func TestPairDialIntentLifetime(t *testing.T) { + dev := device.NewDevice("test-id", "Test", "phone", zap.NewNop()) + dev.MarkEphemeralDialed() + + if dev.PairDialActive() { + t.Fatal("new device must have no active pair intent") + } + + dev.RequestPairDial() + if !dev.PairDialActive() { + t.Fatal("pair intent not active after request") + } + + // Consuming the one-shot dial trigger must NOT clear the keep-alive + // intent: a slow phone-side accept must not downgrade into an + // ephemeral-close flap. + if !dev.ConsumePairDial() { + t.Fatal("pair intent not consumed") + } + if !dev.PairDialActive() { + t.Fatal("keep-alive intent lost after one-shot consume") + } + if shouldEphemeralClose(dev, false) { + t.Fatal("explicit intent must pin the connection after consume") + } + + dev.ClearPairDial() + if dev.PairDialActive() { + t.Fatal("pair intent not cleared") + } + if !shouldEphemeralClose(dev, false) { + t.Fatal("idle stranger should close once intent is cleared") + } +} + +func TestLastPortRoundTrip(t *testing.T) { + dev := device.NewDevice("test-id", "Test", "phone", zap.NewNop()) + if dev.LastPort() != 0 { + t.Fatal("new device must have unknown port") + } + dev.SetLastPort(1716) + if dev.LastPort() != 1716 { + t.Fatalf("LastPort() = %d, want 1716", dev.LastPort()) + } +} + +func TestShouldDiscoveryDialThrottle(t *testing.T) { + dev := device.NewDevice("test-id", "Test", "phone", zap.NewNop()) + if !dev.ShouldDiscoveryDial(10 * time.Second) { + t.Fatal("first dial must be allowed") + } + if dev.ShouldDiscoveryDial(10 * time.Second) { + t.Fatal("immediate redial must be throttled") + } + if !dev.ShouldDiscoveryDial(0) { + t.Fatal("zero interval must always allow") + } +} + +func newTestIdentity() (*protocol.Packet, error) { + return protocol.NewIdentityPacket("test-id", "Test", "desktop", 1716, nil, nil) +} + +func TestSyncReconnectBroadcast(t *testing.T) { + logger := zap.NewNop() + ctx := context.Background() + identity, err := newTestIdentity() + if err != nil { + t.Fatalf("identity: %v", err) + } + newBC := func() *discovery.BroadcasterController { + return discovery.NewBroadcasterController(identity, time.Hour, logger, nil) + } + + // Offline paired device starts the reconnect broadcast. + reg := device.NewRegistry(nil) + offline := device.NewDevice("off", "Phone", "phone", logger) + offline.SetState(device.StatePaired) + reg.Add(offline) + bc := newBC() + syncReconnectBroadcast(ctx, reg, bc) + if !bc.IsRunning() { + t.Error("offline paired device must start reconnect broadcast") + } + + // Unpaired strangers never trigger it. + reg2 := device.NewRegistry(nil) + stranger := device.NewDevice("str", "Stranger", "phone", logger) + reg2.Add(stranger) + bc2 := newBC() + syncReconnectBroadcast(ctx, reg2, bc2) + if bc2.IsRunning() { + t.Error("unpaired devices must not start reconnect broadcast") + bc2.StopOwned(discovery.OwnerReconnect) + } + + // Clearing the registry withdraws the owner again. + reg.Remove("off") + syncReconnectBroadcast(ctx, reg, bc) + if bc.IsRunning() { + t.Error("no offline pairs must stop reconnect broadcast") + bc.StopOwned(discovery.OwnerReconnect) + } + + // Pairing-owned broadcast survives the sync withdrawing reconnect. + bc3 := newBC() + bc3.Start(ctx) // pairing owner + syncReconnectBroadcast(ctx, reg2, bc3) + if !bc3.IsRunning() { + t.Error("sync must not stop pairing-owned broadcast") + } + bc3.Stop() + if bc3.IsRunning() { + t.Error("pairing stop must end an unowned loop") + } +} diff --git a/internal/device/device.go b/internal/device/device.go index 9afdac7..bb6c2a1 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -28,6 +28,45 @@ type Device struct { lastSeen time.Time lastIP net.IP // cached from last successful connection; survives Disconnect + // lastPort is the tcpPort the peer last advertised over the authenticated + // (post-TLS) identity exchange. Used with lastIP as the dial target for + // paired devices so unauthenticated discovery packets can never redirect + // a paired auto-dial. Zero means unknown (fall back to 1716). + lastPort int + + // discoveryIP/discoveryPort remember where a device was last seen + // announcing itself (UDP/mDNS), even if we never opened a TCP + // connection to it. Used to dial on explicit user request + // (e.g. `kcd pair `) without auto-dialling strangers. + discoveryIP net.IP + discoveryPort int + + // pairDialRequested is the one-shot outbound dial trigger for an explicit + // `kcd pair ` request. It is consumed by the first discovery + // announcement after the request so the pair request can be delivered. + pairDialRequested atomic.Bool + + // pairIntentUntil is the Unix-nano deadline until which an explicit pair + // intent keeps a connection alive. Unlike pairDialRequested (consumed on + // first sighting), the intent survives dial/connect cycles until pairing + // starts, is rejected, succeeds, or the deadline (pairDialIntentTTL) + // expires — so a slow phone-side accept can't downgrade into an + // ephemeral-close flap. + pairIntentUntil atomic.Int64 + + // lastDiscoveryDial is when onDeviceFound last spawned a dial for this + // device. It throttles sighting-triggered redials so announcements + // (or a spoofed broadcast storm) can't cause a dial per packet. + lastDiscoveryDial time.Time + + // ephemeralDialed marks that this device already received its one + // ephemeral discovery dial for the current unpaired era. Ephemeral + // dials let a stranger complete the TCP identity exchange (so both + // sides list each other) without staying connected: the next sighting + // closes the socket again while the device is still unpaired. The + // marker is cleared when the device is explicitly unpaired/rejected, + // making it eligible again. Paired devices and pairing mode bypass it. + ephemeralDialed bool conn *transport.Conn sendChan chan *protocol.Packet // buffered 32 @@ -37,6 +76,15 @@ type Device struct { BatteryCharge int IsCharging bool + // batterySeen marks that at least one kdeconnect.battery packet was + // received. Until then the zero values above are not measurements — + // they must not be published (a fresh pair would otherwise report a + // stable, bogus 0% that no later packet corrects at steady charge). + batterySeen bool + // lastBatteryAt is when the last battery packet arrived, so clients + // can apply their own staleness rules (mirrors mediaAgeMs). + lastBatteryAt time.Time + mu sync.RWMutex // reconnecting is an atomic flag preventing multiple concurrent @@ -64,11 +112,13 @@ type Device struct { } // NewDevice creates a new disconnected device instance. +// New devices start as Unpaired (not Unknown) so listings are unambiguous. func NewDevice(id, name, dtype string, logger *zap.Logger) *Device { return &Device{ id: id, name: name, Type: dtype, + state: StateUnpaired, sendChan: make(chan *protocol.Packet, 32), done: make(chan struct{}), logger: logger.With(zap.String("device_id", id)), @@ -213,6 +263,8 @@ func (d *Device) UpdateBattery(charge int, charging bool) { d.mu.Lock() d.BatteryCharge = charge d.IsCharging = charging + d.batterySeen = true + d.lastBatteryAt = time.Now() bus := d.bus id := d.id d.mu.Unlock() @@ -232,6 +284,25 @@ func (d *Device) GetBattery() (int, bool) { return d.BatteryCharge, d.IsCharging } +// HasBattery reports whether at least one battery packet was received. +// Until then the charge values are zero-value defaults, not measurements. +func (d *Device) HasBattery() bool { + d.mu.RLock() + defer d.mu.RUnlock() + return d.batterySeen +} + +// BatteryAge returns how long ago the last battery packet arrived, or a +// negative duration when no packet was ever received. +func (d *Device) BatteryAge() time.Duration { + d.mu.RLock() + defer d.mu.RUnlock() + if !d.batterySeen { + return -1 + } + return time.Since(d.lastBatteryAt) +} + // HasCapability checks if the device has a particular capability (incoming or outgoing). func (d *Device) HasCapability(cap string) bool { d.mu.RLock() @@ -319,6 +390,120 @@ func (d *Device) SetLastSeen(t time.Time) { d.lastSeen = t } +// SetDiscoveryAddr records where the device was last seen announcing itself. +func (d *Device) SetDiscoveryAddr(ip net.IP, port int) { + d.mu.Lock() + defer d.mu.Unlock() + d.discoveryIP = ip + d.discoveryPort = port +} + +// DiscoveryAddr returns the last-seen announcement address, or nil if unknown. +func (d *Device) DiscoveryAddr() (net.IP, int) { + d.mu.RLock() + defer d.mu.RUnlock() + return d.discoveryIP, d.discoveryPort +} + +// pairDialIntentTTL bounds how long an explicit `kcd pair ` intent pins +// a connection while pairing hasn't started yet. +const pairDialIntentTTL = 5 * time.Minute + +// RequestPairDial marks the device for a one-shot outbound dial on its next +// discovery announcement and arms the keep-alive intent until pairing starts, +// is rejected, succeeds, or the TTL expires. Used when the user explicitly +// runs `kcd pair ` for a device with no active connection. +func (d *Device) RequestPairDial() { + d.pairDialRequested.Store(true) + d.pairIntentUntil.Store(time.Now().Add(pairDialIntentTTL).UnixNano()) +} + +// ConsumePairDial reports and clears a pending explicit pair-dial request. +func (d *Device) ConsumePairDial() bool { + return d.pairDialRequested.CompareAndSwap(true, false) +} + +// PairDialPending reports whether an explicit pair-dial was requested, +// without clearing it. +func (d *Device) PairDialPending() bool { + return d.pairDialRequested.Load() +} + +// PairDialActive reports whether an explicit pair intent is still keeping +// the connection alive: requested and neither cleared nor expired. Unlike +// PairDialPending (the one-shot dial trigger, consumed on first sighting), +// this survives dial/connect cycles until pairing starts or ends. +func (d *Device) PairDialActive() bool { + return d.pairIntentUntil.Load() > time.Now().UnixNano() +} + +// ClearPairDial drops both the one-shot trigger and the keep-alive intent. +// Call it when pairing completes, is rejected/cancelled, or is unpaired. +func (d *Device) ClearPairDial() { + d.pairDialRequested.Store(false) + d.pairIntentUntil.Store(0) +} + +// LastPort returns the last authenticated tcpPort advertised by the peer, +// or 0 if unknown. +func (d *Device) LastPort() int { + d.mu.RLock() + defer d.mu.RUnlock() + return d.lastPort +} + +// SetLastPort records the peer's advertised listening port after a +// successful authenticated exchange. Callers must pass a validated port. +func (d *Device) SetLastPort(port int) { + d.mu.Lock() + defer d.mu.Unlock() + d.lastPort = port +} + +// SetLastIP records a dial target, used when restoring persisted state. +// A nil IP clears the target. +func (d *Device) SetLastIP(ip net.IP) { + d.mu.Lock() + defer d.mu.Unlock() + d.lastIP = ip +} + +// ShouldDiscoveryDial reports whether enough time has passed since the last +// discovery-triggered dial for this device, and marks this dial if so. It +// bounds redial storms to a stale LastIP (DHCP roam) or spoofed sightings. +func (d *Device) ShouldDiscoveryDial(minInterval time.Duration) bool { + d.mu.Lock() + defer d.mu.Unlock() + if time.Since(d.lastDiscoveryDial) < minInterval { + return false + } + d.lastDiscoveryDial = time.Now() + return true +} + +// MarkEphemeralDialed records that the one ephemeral discovery dial for the +// current unpaired era has been used. +func (d *Device) MarkEphemeralDialed() { + d.mu.Lock() + defer d.mu.Unlock() + d.ephemeralDialed = true +} + +// EphemeralDialed reports whether the ephemeral discovery dial was used. +func (d *Device) EphemeralDialed() bool { + d.mu.RLock() + defer d.mu.RUnlock() + return d.ephemeralDialed +} + +// ClearEphemeral makes the device eligible for a fresh ephemeral discovery +// dial (e.g. after an explicit unpair or rejection). +func (d *Device) ClearEphemeral() { + d.mu.Lock() + defer d.mu.Unlock() + d.ephemeralDialed = false +} + // TryReconnect attempts to mark the device as reconnecting. // Returns true if this goroutine should proceed; false if another // reconnect goroutine is already running. diff --git a/internal/device/device_test.go b/internal/device/device_test.go index 5c3b2df..e483814 100644 --- a/internal/device/device_test.go +++ b/internal/device/device_test.go @@ -106,3 +106,88 @@ func TestDevice_ConnectionAge(t *testing.T) { } d.Disconnect() } + +func TestDevice_BatterySeen(t *testing.T) { + logger := zaptest.NewLogger(t) + d := NewDevice("bat-seen", "Phone", "phone", logger) + + if d.HasBattery() { + t.Error("fresh device must report no battery reading") + } + if got := d.BatteryAge(); got >= 0 { + t.Errorf("fresh device battery age must be negative, got %v", got) + } + + d.UpdateBattery(80, true) + if !d.HasBattery() { + t.Error("device must report a reading after UpdateBattery") + } + if charge, charging := d.GetBattery(); charge != 80 || !charging { + t.Errorf("GetBattery = (%d, %v), want (80, true)", charge, charging) + } + if got := d.BatteryAge(); got < 0 { + t.Errorf("battery age must be non-negative after a reading, got %v", got) + } + + // A true zero is a real reading, not an unknown. + d2 := NewDevice("bat-zero", "Phone", "phone", logger) + d2.UpdateBattery(0, false) + if !d2.HasBattery() { + t.Error("true 0% reading must count as seen") + } +} + +func TestDeviceInfoDialTargetRoundTrip(t *testing.T) { + dir := t.TempDir() + path := dir + "/devices.json" + + infos := []DeviceInfo{ + {ID: "paired-1", Name: "Phone", Type: "phone", State: StatePaired, LastIP: "192.168.1.20", LastPort: 1716}, + {ID: "fresh-1", Name: "New", Type: "phone", State: StateUnpaired}, + } + if err := SaveDevices(path, infos); err != nil { + t.Fatalf("SaveDevices failed: %v", err) + } + loaded, err := LoadDevices(path) + if err != nil { + t.Fatalf("LoadDevices failed: %v", err) + } + if len(loaded) != 2 { + t.Fatalf("expected 2 devices, got %d", len(loaded)) + } + + ip, port := loaded[0].DialTarget() + if ip == nil || ip.String() != "192.168.1.20" || port != 1716 { + t.Errorf("dial target not preserved: ip=%v port=%d", ip, port) + } + if ip, port := loaded[1].DialTarget(); ip != nil || port != 0 { + t.Errorf("device without target must yield nil/0, got %v/%d", ip, port) + } +} + +func TestDeviceInfoDialTargetRejectsGarbage(t *testing.T) { + cases := []struct { + name string + info DeviceInfo + wantIP bool + wantPort int + }{ + {"valid", DeviceInfo{LastIP: "10.0.0.5", LastPort: 1716}, true, 1716}, + {"garbage ip", DeviceInfo{LastIP: "not-an-ip", LastPort: 1716}, false, 0}, + {"empty ip", DeviceInfo{LastPort: 1716}, false, 0}, + {"hostname not ip", DeviceInfo{LastIP: "phone.local", LastPort: 1716}, false, 0}, + {"zero port falls back", DeviceInfo{LastIP: "10.0.0.5"}, true, 0}, + {"port out of range", DeviceInfo{LastIP: "10.0.0.5", LastPort: 99999}, true, 0}, + {"negative port", DeviceInfo{LastIP: "10.0.0.5", LastPort: -1}, true, 0}, + {"ipv6", DeviceInfo{LastIP: "fd00::5", LastPort: 1716}, true, 1716}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ip, port := tc.info.DialTarget() + if (ip != nil) != tc.wantIP || port != tc.wantPort { + t.Errorf("DialTarget() = (%v, %d), want ip-present=%v port=%d", + ip, port, tc.wantIP, tc.wantPort) + } + }) + } +} diff --git a/internal/device/state.go b/internal/device/state.go index 200f23b..a0886f8 100644 --- a/internal/device/state.go +++ b/internal/device/state.go @@ -4,6 +4,7 @@ package device import ( "encoding/json" "fmt" + "net" "os" "path/filepath" "time" @@ -81,6 +82,26 @@ type DeviceInfo struct { CertFP string `json:"cert_fp"` LastSeen time.Time `json:"last_seen"` Connected bool `json:"connected"` + // LastIP/LastPort is the dial target from the most recent successful + // connection, so paired auto-dial survives daemon restarts. Both are + // validated on load (the file is user-editable); garbage is dropped. + LastIP string `json:"last_ip,omitempty"` + LastPort int `json:"last_port,omitempty"` +} + +// DialTarget returns the persisted dial target for auto-dial after a +// restart. A garbage IP yields nil (no target); an out-of-range port +// yields 0 and callers fall back to the default port. The state file is +// user-editable, so nothing here is trusted blindly. +func (info DeviceInfo) DialTarget() (net.IP, int) { + ip := net.ParseIP(info.LastIP) + if ip == nil { + return nil, 0 + } + if info.LastPort <= 0 || info.LastPort > 65535 { + return ip, 0 + } + return ip, info.LastPort } // LoadDevices loads known devices from the given JSON file path. diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go index 5f4911a..8b79fb8 100644 --- a/internal/discovery/discovery.go +++ b/internal/discovery/discovery.go @@ -14,6 +14,14 @@ import ( "go.uber.org/zap" ) +// Broadcast ownership: pairing mode (`kcd pair`) and the reconnect +// watcher share one loop but must not cancel each other, so starts are +// reference-counted per owner. The loop runs while any owner holds it. +const ( + OwnerPairing = "pairing" + OwnerReconnect = "reconnect" +) + // BroadcasterController manages the broadcast lifecycle — start/stop on demand. // Starts in stopped state. Broadcast is only active while Start() is in effect. type BroadcasterController struct { @@ -25,6 +33,7 @@ type BroadcasterController struct { mu sync.Mutex running bool cancel context.CancelFunc + owners map[string]struct{} } // NewBroadcasterController creates a controller that starts in stopped state. @@ -34,15 +43,28 @@ func NewBroadcasterController(identity *protocol.Packet, interval time.Duration, interval: interval, shouldReduce: shouldReduce, logger: logger.With(zap.String("component", "broadcaster")), + owners: make(map[string]struct{}), } } -// Start launches the UDP broadcaster loop in a background goroutine using a -// child of parentCtx. No-op if already running. +// Start launches the UDP broadcaster loop for the pairing owner. +// No-op if already running (ownership is still recorded). func (bc *BroadcasterController) Start(parentCtx context.Context) { + bc.StartOwned(parentCtx, OwnerPairing) +} + +// Stop withdraws the pairing owner. The loop stops only when no owners +// remain, so a reconnect-driven broadcast survives `kcd pair` exiting. +func (bc *BroadcasterController) Stop() { + bc.StopOwned(OwnerPairing) +} + +// StartOwned launches the loop (if needed) and records owner as needing it. +func (bc *BroadcasterController) StartOwned(parentCtx context.Context, owner string) { bc.mu.Lock() defer bc.mu.Unlock() + bc.owners[owner] = struct{}{} if bc.running { return } @@ -64,12 +86,16 @@ func (bc *BroadcasterController) Start(parentCtx context.Context) { }() } -// Stop cancels the broadcast loop. No-op if not running. -func (bc *BroadcasterController) Stop() { +// StopOwned withdraws owner's need. No-op if the owner holds nothing. +func (bc *BroadcasterController) StopOwned(owner string) { bc.mu.Lock() defer bc.mu.Unlock() - if !bc.running || bc.cancel == nil { + if _, ok := bc.owners[owner]; !ok { + return + } + delete(bc.owners, owner) + if len(bc.owners) > 0 || !bc.running || bc.cancel == nil { return } bc.cancel() @@ -84,6 +110,41 @@ func (bc *BroadcasterController) IsRunning() bool { return bc.running } +// AdvertiseMDNS registers the local identity as _kdeconnect._udp until +// ctx ends. Unlike UDP broadcast this is responder-only (it wakes on +// incoming queries), so it stays up for the daemon lifetime at negligible +// idle cost and gives phones a standing discovery path even while UDP +// broadcast is stopped. +func AdvertiseMDNS(ctx context.Context, identityPacket *protocol.Packet, logger *zap.Logger) { + var idBody protocol.IdentityBody + if err := json.Unmarshal(identityPacket.Body, &idBody); err != nil { + logger.Warn("failed to parse identity for mDNS", zap.Error(err)) + return + } + server, err := zeroconf.Register( + idBody.DeviceName, + "_kdeconnect._udp", + "local.", + idBody.TCPPort, + []string{ + "id=" + idBody.DeviceID, + "name=" + idBody.DeviceName, + "type=" + idBody.DeviceType, + "protocol=8", + }, + nil, + ) + if err != nil { + logger.Warn("failed to register mDNS service", zap.Error(err)) + return + } + go func() { + <-ctx.Done() + server.Shutdown() + logger.Info("mDNS service shut down") + }() +} + // Broadcaster sends identity packets over UDP to advertise the local device. type Broadcaster struct { identityPacket *protocol.Packet @@ -103,36 +164,8 @@ func NewBroadcaster(identity *protocol.Packet, interval time.Duration, logger *z // Run periodically sends the identity packet to 255.255.255.255:1716. // If shouldReduce is provided and returns true, the broadcast frequency // is reduced to 60 seconds to save CPU and network resources while idle. +// (mDNS advertisement is no longer tied to this loop — see AdvertiseMDNS.) func (b *Broadcaster) Run(ctx context.Context, shouldReduce func() bool) { - // Register mDNS - var idBody protocol.IdentityBody - if err := json.Unmarshal(b.identityPacket.Body, &idBody); err == nil { - server, err := zeroconf.Register( - idBody.DeviceName, - "_kdeconnect._udp", - "local.", - idBody.TCPPort, - []string{ - "id=" + idBody.DeviceID, - "name=" + idBody.DeviceName, - "type=" + idBody.DeviceType, - "protocol=8", - }, - nil, - ) - if err != nil { - b.logger.Warn("failed to register mDNS service", zap.Error(err)) - } else { - go func() { - <-ctx.Done() - server.Shutdown() - b.logger.Info("mDNS service shut down") - }() - } - } else { - b.logger.Warn("failed to parse identity for mDNS", zap.Error(err)) - } - normalInterval := b.interval reducedInterval := 60 * time.Second diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go new file mode 100644 index 0000000..5011171 --- /dev/null +++ b/internal/discovery/discovery_test.go @@ -0,0 +1,52 @@ +package discovery + +import ( + "context" + "testing" + "time" + + "github.com/bethropolis/kcd/internal/protocol" + "go.uber.org/zap" +) + +func testIdentity(t *testing.T) *protocol.Packet { + t.Helper() + pkt, err := protocol.NewIdentityPacket("test-id", "Test", "desktop", 1716, nil, nil) + if err != nil { + t.Fatalf("NewIdentityPacket failed: %v", err) + } + return pkt +} + +// Pairing and reconnect needs share one loop but must not cancel each +// other: withdrawing one owner leaves the loop up while the other holds it, +// and the loop stops only when the last owner withdraws. +func TestBroadcasterOwners(t *testing.T) { + logger := zap.NewNop() + bc := NewBroadcasterController(testIdentity(t), time.Hour, logger, nil) + ctx := context.Background() + + if bc.IsRunning() { + t.Fatal("controller must start stopped") + } + + bc.Start(ctx) // pairing owner + if !bc.IsRunning() { + t.Fatal("Start must run the loop") + } + bc.StopOwned(OwnerReconnect) // never held: no-op + if !bc.IsRunning() { + t.Error("withdrawing a non-owner must not stop the loop") + } + + bc.StartOwned(ctx, OwnerReconnect) + bc.Stop() // pairing withdraws; reconnect still holds + if !bc.IsRunning() { + t.Error("loop must survive while reconnect owner holds it") + } + + bc.StopOwned(OwnerReconnect) // last owner out + if bc.IsRunning() { + t.Error("loop must stop when no owners remain") + } +} diff --git a/internal/events/bus.go b/internal/events/bus.go index 0efa0b1..448cbf2 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -38,7 +38,9 @@ const ( TypeSMSIncoming EventType = "sms.incoming" TypeSMSAttachment EventType = "sms.attachment" TypeRingReceived EventType = "ring.received" + TypeContactsUpdated EventType = "contacts.updated" TypeMprisUpdate EventType = "mpris.update" + TypeStateSnapshot EventType = "state.snapshot" ) const ( diff --git a/internal/ipc/handler.go b/internal/ipc/handler.go index fdded29..2e320bc 100644 --- a/internal/ipc/handler.go +++ b/internal/ipc/handler.go @@ -7,6 +7,7 @@ import ( "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/events" "github.com/bethropolis/kcd/internal/plugin" + "github.com/bethropolis/kcd/internal/plugins/contacts" "github.com/bethropolis/kcd/internal/plugins/pair" "github.com/bethropolis/kcd/internal/protocol" ) @@ -20,6 +21,11 @@ type Handler struct { bus *events.Bus routes map[string]func(Request) Response pruneThreshold time.Duration + // pairDialHook, when set, dials a disconnected device on explicit user + // pair request (`kcd pair `). The daemon wires this to DialDevice + // using the device's last-seen discovery address, so pairing an unpaired + // device doesn't depend on background auto-dial. + pairDialHook func(deviceID string) error } // NewHandler creates a new IPC command handler. @@ -40,6 +46,12 @@ func (h *Handler) Register(command string, fn func(Request) Response) { h.routes[command] = fn } +// SetPairDialHook sets the hook used to connect to a disconnected device on +// explicit user pair request. See the pairDialHook field for details. +func (h *Handler) SetPairDialHook(fn func(deviceID string) error) { + h.pairDialHook = fn +} + // HandleRequest processes an incoming IPC request and returns a response. func (h *Handler) HandleRequest(req Request) Response { if fn, ok := h.routes[req.Command]; ok { @@ -63,17 +75,12 @@ func (h *Handler) HandleRequest(req Request) Response { } func (h *Handler) handleDevices() Response { - // To convert the internal representation to JSON, we construct DeviceInfo structs + // Enriched summaries (battery/media/signal embedded, omitempty). + // Base identity fields are unchanged, so old clients keep working. devs := h.devices.List() - infos := make([]device.DeviceInfo, 0, len(devs)) + infos := make([]DeviceSummary, 0, len(devs)) for _, dev := range devs { - infos = append(infos, device.DeviceInfo{ - ID: dev.ID(), - Name: dev.Name(), - Type: dev.Type, - State: dev.State(), - Connected: dev.IsConnected(), - }) + infos = append(infos, SummarizeDevice(dev, h.plugins)) } data, err := json.Marshal(infos) @@ -91,13 +98,18 @@ func (h *Handler) saveDevices() { devs := h.devices.List() infos := make([]device.DeviceInfo, 0, len(devs)) for _, dev := range devs { - infos = append(infos, device.DeviceInfo{ - ID: dev.ID(), - Name: dev.Name(), - Type: dev.Type, - State: dev.State(), - CertFP: dev.CertFP, - }) + info := device.DeviceInfo{ + ID: dev.ID(), + Name: dev.Name(), + Type: dev.Type, + State: dev.State(), + CertFP: dev.CertFP, + LastPort: dev.LastPort(), + } + if ip := dev.LastIP(); ip != nil { + info.LastIP = ip.String() + } + infos = append(infos, info) } _ = device.SaveDevices(h.statePath, infos) } @@ -113,6 +125,18 @@ func (h *Handler) handlePair(payload []byte) Response { return Response{OK: false, Error: "device not found"} } + // Pairing needs an active connection (pair packets go over TLS). + // Background auto-dial no longer connects to unpaired strangers, so an + // explicit `kcd pair ` triggers the on-demand dial hook when one is + // wired (the daemon always sets it). Without a hook, fall through to the + // legacy paths below. + if !dev.IsConnected() && h.pairDialHook != nil { + if err := h.pairDialHook(dev.ID()); err != nil { + return Response{OK: false, Error: err.Error()} + } + return Response{OK: true} + } + // Use the pair plugin to handle pairing properly if h.pairPlugin != nil { state := dev.State() @@ -159,6 +183,7 @@ func (h *Handler) handleUnpair(payload []byte) Response { return Response{OK: false, Error: "failed to unpair: " + err.Error()} } dev.Disconnect() + h.forgetContacts(p.DeviceID) return Response{OK: true} } @@ -167,16 +192,36 @@ func (h *Handler) handleUnpair(payload []byte) Response { _ = dev.Send(pkt) dev.Disconnect() h.devices.Remove(p.DeviceID) + h.forgetContacts(p.DeviceID) h.saveDevices() return Response{OK: true} } +// forgetContacts drops a device's cached contacts on unpair: revoked trust +// drops the address book. Absent plugin or cache is a no-op. +func (h *Handler) forgetContacts(deviceID string) { + if h.plugins == nil { + return + } + pl, ok := h.plugins.GetByName("Contacts") + if !ok { + return + } + if cpl, ok := pl.(*contacts.ContactsPlugin); ok { + _ = cpl.ForgetDevice(deviceID) + } +} + func (h *Handler) handlePairListen() Response { - // Check for any device already in StatePairRequestedByPeer + // Report any device already in StatePairRequestedByPeer WITHOUT + // accepting it. The caller (CLI / GUI / script) inspects the candidate + // and decides: accept via CmdPair, reject via CmdUnpair. Auto-accepting + // here would pair with stale requests (e.g. leftovers from tests) + // before the user ever sees a prompt. for _, dev := range h.devices.List() { if dev.State() == device.StatePairRequestedByPeer { - return h.pairAcceptResult(dev, "") + return h.pairListenResult(dev, "") } } @@ -202,20 +247,21 @@ func (h *Handler) handlePairListen() Response { vKey = k } } - return h.pairAcceptResult(dev, vKey) + return h.pairListenResult(dev, vKey) case <-time.After(60 * time.Second): return Response{OK: false, Error: "timed out waiting for pair request (60s)"} } } -func (h *Handler) pairAcceptResult(dev *device.Device, vKey string) Response { - if err := h.pairPlugin.AcceptPairing(dev); err != nil { - return Response{OK: false, Error: "failed to accept pairing: " + err.Error()} - } +// pairListenResult returns the candidate device info without accepting it. +// The caller (CLI / GUI / script) decides whether to accept via CmdPair or +// reject via CmdUnpair. +func (h *Handler) pairListenResult(dev *device.Device, vKey string) Response { data, _ := json.Marshal(PairListenResult{ DeviceID: dev.ID(), DeviceName: dev.Name(), VerificationKey: vKey, + Fingerprint: dev.CertFP, }) return Response{OK: true, Data: data} } diff --git a/internal/ipc/proto.go b/internal/ipc/proto.go index 44e2c1f..e0adfb2 100644 --- a/internal/ipc/proto.go +++ b/internal/ipc/proto.go @@ -12,6 +12,7 @@ const ( CmdUnpair = "unpair" CmdPing = "ping" CmdBattery = "battery" + CmdConnectivity = "connectivity" CmdClipboardPush = "clipboard_push" CmdRunList = "run_list" CmdRunExec = "run_exec" @@ -32,6 +33,8 @@ const ( CmdSmsRequestConvs = "sms_request_conversations" CmdSmsRequestConv = "sms_request_conversation" CmdSmsRequestAttachment = "sms_request_attachment" + CmdContactsSync = "contacts_sync" + CmdContactsList = "contacts_list" CmdSftpMountLocal = "sftp_mount_local" CmdSftpUnmount = "sftp_unmount" CmdStatus = "status" @@ -72,6 +75,9 @@ type PairListenResult struct { DeviceID string `json:"deviceId"` DeviceName string `json:"deviceName"` VerificationKey string `json:"verificationKey,omitempty"` + // Fingerprint is the SHA256 hex of the candidate's live TLS + // certificate, so callers can pin the expected peer. + Fingerprint string `json:"fingerprint,omitempty"` } // DevicePayload is sent in requests requiring a device ID (like pair/unpair/ping). diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 61ac1e6..d28b9ca 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -8,9 +8,12 @@ import ( "net" "os" "path/filepath" + "strconv" "time" + "github.com/bethropolis/kcd/internal/config" "github.com/bethropolis/kcd/internal/events" + "github.com/bethropolis/kcd/internal/plugins/connectivity" "github.com/bethropolis/kcd/internal/plugins/mpris" "go.uber.org/zap" ) @@ -31,8 +34,42 @@ func NewServer(path string, handler *Handler, logger *zap.Logger) *Server { } } +// activatedListener adopts a Unix listener passed by systemd socket +// activation (fd 3+), or returns nil when not running socket-activated. +// Stdlib only: LISTEN_PID must match our pid and LISTEN_FDS must offer at +// least one socket. The adopted fd is dup'd by net.FileListener, so the +// *os.File wrapper is not retained. +func activatedListener() net.Listener { + if os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) { + return nil + } + n, err := strconv.Atoi(os.Getenv("LISTEN_FDS")) + if err != nil || n < 1 { + return nil + } + f := os.NewFile(3, "kcd-socket-activated") + l, err := net.FileListener(f) + if err != nil { + _ = f.Close() + return nil + } + // Children (sshfs, notify-send, …) must not inherit the activation + // environment and mistake it for sockets passed to them. + _ = os.Unsetenv("LISTEN_PID") + _ = os.Unsetenv("LISTEN_FDS") + return l +} + // Listen starts listening on the Unix socket and processes incoming connections. +// When running under systemd socket activation with the default socket path, +// the passed listener is adopted instead of binding cfg.SocketPath. func (s *Server) Listen(ctx context.Context) error { + if s.path == config.DefaultSocketPath() { + if l := activatedListener(); l != nil { + return s.serve(ctx, l, true) + } + } + dir := filepath.Dir(s.path) if err := os.MkdirAll(dir, 0700); err != nil { return err @@ -53,13 +90,22 @@ func (s *Server) Listen(ctx context.Context) error { return fmt.Errorf("failed to chmod ipc socket: %w", err) } + return s.serve(ctx, l, false) +} + +func (s *Server) serve(ctx context.Context, l net.Listener, activated bool) error { go func() { <-ctx.Done() l.Close() - os.Remove(s.path) + if !activated { + os.Remove(s.path) + } }() - s.logger.Info("ipc server started", zap.String("path", s.path)) + s.logger.Info("ipc server started", + zap.String("path", s.path), + zap.Bool("socket_activated", activated), + ) for { conn, err := l.Accept() @@ -126,6 +172,21 @@ func (s *Server) handleWatch(conn net.Conn, payload []byte) { // Send OK response to indicate stream is starting s.writeResponse(conn, Response{OK: true}) + // Full-state snapshot first: every known device (online AND offline) + // with cached battery/media/signal, so clients boot with complete + // state from this single connection — no auxiliary bootstrap calls, + // no hydration races. Sent regardless of event filters. + snapEv := map[string]interface{}{ + "type": events.TypeStateSnapshot, + "timestamp": time.Now().UTC(), + "payload": BuildSnapshot(s.handler.devices, s.handler.plugins), + } + data, _ := json.Marshal(snapEv) + data = append(data, '\n') + if _, err := conn.Write(data); err != nil { + return + } + // Initial State Dump // For each connected device, we emit device.connected and battery.update. devs := s.handler.devices.Connected() @@ -143,27 +204,52 @@ func (s *Server) handleWatch(conn net.Conn, payload []byte) { "timestamp": time.Now().UTC(), "payload": devData, } - data, _ := json.Marshal(initEv) + data, _ = json.Marshal(initEv) data = append(data, '\n') if _, err := conn.Write(data); err != nil { return } - // Send initial battery event - charge, charging := dev.GetBattery() - batEv := map[string]interface{}{ - "type": "battery.update", - "deviceId": dev.ID(), - "timestamp": time.Now().UTC(), - "payload": map[string]interface{}{ - "charge": charge, - "charging": charging, - }, + // Send initial battery event, but only when the daemon actually + // has a reading. Emitting zero values for a fresh pair would + // publish a bogus stable 0% (see Device.HasBattery) — same + // skip-if-absent rule as connectivity below. + if dev.HasBattery() { + charge, charging := dev.GetBattery() + batEv := map[string]interface{}{ + "type": "battery.update", + "deviceId": dev.ID(), + "timestamp": time.Now().UTC(), + "payload": map[string]interface{}{ + "charge": charge, + "charging": charging, + "batteryAgeMs": dev.BatteryAge().Milliseconds(), + }, + } + data, _ = json.Marshal(batEv) + data = append(data, '\n') + if _, err := conn.Write(data); err != nil { + return + } } - data, _ = json.Marshal(batEv) - data = append(data, '\n') - if _, err := conn.Write(data); err != nil { - return + + // Send initial connectivity state if the device already reported. + // Unlike battery there is no meaningful zero value, so devices + // without a report are skipped instead of emitting empty data. + if pl, ok := s.handler.plugins.GetByName("Connectivity"); ok { + if report, ok := pl.(*connectivity.ConnectivityPlugin).Report(dev.ID()); ok { + connEv := map[string]interface{}{ + "type": "connectivity.update", + "deviceId": dev.ID(), + "timestamp": time.Now().UTC(), + "payload": report, + } + data, _ = json.Marshal(connEv) + data = append(data, '\n') + if _, err := conn.Write(data); err != nil { + return + } + } } // Send initial mpris state if recently updated. diff --git a/internal/ipc/snapshot.go b/internal/ipc/snapshot.go new file mode 100644 index 0000000..89038ad --- /dev/null +++ b/internal/ipc/snapshot.go @@ -0,0 +1,107 @@ +package ipc + +import ( + "time" + + "github.com/bethropolis/kcd/internal/device" + "github.com/bethropolis/kcd/internal/plugin" + "github.com/bethropolis/kcd/internal/plugins/connectivity" + "github.com/bethropolis/kcd/internal/plugins/mpris" +) + +// BatteryStatus mirrors the battery state for embedding in summaries. +type BatteryStatus struct { + Charge int `json:"charge"` + Charging bool `json:"charging"` + BatteryAgeMs int64 `json:"batteryAgeMs"` +} + +// MediaState is the cached now-playing state plus its age, so clients can +// apply their own staleness rules instead of the daemon hiding paused media. +type MediaState struct { + mpris.NowPlaying + MediaAgeMs int64 `json:"mediaAgeMs"` +} + +// DeviceSummary enriches DeviceInfo with cached sub-states for clients. +// It is IPC-only: DeviceInfo stays the on-disk shape, so persisted state +// never grows these fields. All sub-states are omitempty. +type DeviceSummary struct { + device.DeviceInfo + Battery *BatteryStatus `json:"battery,omitempty"` + Media *MediaState `json:"media,omitempty"` + Signal *connectivity.ConnectivityBody `json:"signal,omitempty"` +} + +// SummarizeDevice builds the enriched view of one device. Absent plugins +// or missing caches simply omit their section. +func SummarizeDevice(dev *device.Device, plugins *plugin.Registry) DeviceSummary { + // A connected device is seen right now by definition: last_seen is only + // stamped at connect time and on discovery sightings (which skip + // connected devices), so without this it goes stale for the whole + // session and clients show "Xm ago" for a live phone. + lastSeen := dev.LastSeen() + if dev.IsConnected() { + lastSeen = time.Now() + } + sum := DeviceSummary{ + DeviceInfo: device.DeviceInfo{ + ID: dev.ID(), + Name: dev.Name(), + Type: dev.Type, + State: dev.State(), + LastSeen: lastSeen, + Connected: dev.IsConnected(), + }, + } + + // Omit battery until the first real packet: the zero values are not + // a 0% measurement (see Device.HasBattery). + if dev.HasBattery() { + charge, charging := dev.GetBattery() + sum.Battery = &BatteryStatus{ + Charge: charge, + Charging: charging, + BatteryAgeMs: dev.BatteryAge().Milliseconds(), + } + } + + if plugins == nil { + return sum + } + if pl, ok := plugins.GetByName("MPRIS"); ok { + mp := pl.(*mpris.MPRISPlugin) + if state := mp.RemoteState(dev.ID()); state != nil { + sum.Media = &MediaState{ + NowPlaying: *state, + MediaAgeMs: mp.RemoteStateAge(dev.ID()).Milliseconds(), + } + } + } + if pl, ok := plugins.GetByName("Connectivity"); ok { + if report, ok := pl.(*connectivity.ConnectivityPlugin).Report(dev.ID()); ok { + r := report + sum.Signal = &r + } + } + return sum +} + +// SnapshotPayload is the state.snapshot event payload: every known device +// (online and offline) with its cached sub-states, so clients boot with +// full state from a single watch connection. +type SnapshotPayload struct { + Devices []DeviceSummary `json:"devices"` +} + +// BuildSnapshot assembles the full-state payload for the state.snapshot event. +func BuildSnapshot(devices *device.Registry, plugins *plugin.Registry) SnapshotPayload { + snap := SnapshotPayload{Devices: []DeviceSummary{}} + if devices == nil { + return snap + } + for _, dev := range devices.List() { + snap.Devices = append(snap.Devices, SummarizeDevice(dev, plugins)) + } + return snap +} diff --git a/internal/ipc/snapshot_test.go b/internal/ipc/snapshot_test.go new file mode 100644 index 0000000..05bc9a3 --- /dev/null +++ b/internal/ipc/snapshot_test.go @@ -0,0 +1,176 @@ +package ipc_test + +import ( + "context" + "crypto/tls" + "encoding/json" + "net" + "testing" + "time" + + "github.com/bethropolis/kcd/internal/device" + "github.com/bethropolis/kcd/internal/ipc" + "github.com/bethropolis/kcd/internal/plugin" + "github.com/bethropolis/kcd/internal/transport" + "go.uber.org/zap/zaptest" +) + +func TestBuildSnapshotCoversOfflineDevices(t *testing.T) { + logger := zaptest.NewLogger(t) + devReg := device.NewRegistry(nil) + pluginReg := plugin.NewRegistry(logger) + + online := device.NewDevice("dev-online", "Online Phone", "phone", logger) + online.UpdateBattery(50, true) + online.SetState(device.StatePaired) + devReg.Add(online) + + offline := device.NewDevice("dev-off", "Offline Phone", "phone", logger) + offline.UpdateBattery(24, false) + offline.SetState(device.StatePaired) + devReg.Add(offline) + + stranger := device.NewDevice("dev-stranger", "Stranger", "phone", logger) + devReg.Add(stranger) + + unseen := device.NewDevice("dev-unseen", "Fresh Pair", "phone", logger) + unseen.SetState(device.StatePaired) + devReg.Add(unseen) + + snap := ipc.BuildSnapshot(devReg, pluginReg) + if len(snap.Devices) != 4 { + t.Fatalf("expected all 4 devices in snapshot, got %d", len(snap.Devices)) + } + + byID := make(map[string]ipc.DeviceSummary) + for _, d := range snap.Devices { + byID[d.ID] = d + } + + if byID["dev-online"].Battery == nil || byID["dev-online"].Battery.Charge != 50 { + t.Errorf("online battery missing: %+v", byID["dev-online"].Battery) + } + if byID["dev-off"].Battery == nil { + t.Error("offline paired device should still carry battery (dump parity)") + } + if byID["dev-unseen"].Battery != nil { + t.Errorf("device with no reading must omit battery, got %+v", byID["dev-unseen"].Battery) + } + if byID["dev-stranger"].Media != nil || byID["dev-stranger"].Signal != nil { + t.Error("absent plugins must omit media/signal sections") + } + + // Identity fields survive the enrichment untouched. + raw, _ := json.Marshal(snap) + var decoded struct { + Devices []map[string]interface{} `json:"devices"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + for _, d := range decoded.Devices { + for _, k := range []string{"id", "name", "type", "state", "connected"} { + if _, ok := d[k]; !ok { + t.Errorf("summary missing base field %q: %v", k, d) + } + } + if d["id"] == "dev-unseen" { + if _, ok := d["battery"]; ok { + t.Errorf("unseen device must serialize without a battery key: %v", d) + } + } + } +} + +// Battery omission contract: a fresh device serializes with no battery key +// (unknown, not 0%); one packet makes the key appear with the sent values; +// a real 0% packet is preserved as a present zero, not collapsed to unknown. +func TestSummarizeDeviceBatteryOmission(t *testing.T) { + logger := zaptest.NewLogger(t) + pluginReg := plugin.NewRegistry(logger) + + fresh := device.NewDevice("dev-fresh", "Fresh", "phone", logger) + sum := ipc.SummarizeDevice(fresh, pluginReg) + if sum.Battery != nil { + t.Fatalf("fresh device must omit battery, got %+v", sum.Battery) + } + raw, _ := json.Marshal(sum) + var decoded map[string]interface{} + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if _, ok := decoded["battery"]; ok { + t.Errorf("fresh device must serialize without battery key: %s", raw) + } + + charged := device.NewDevice("dev-charged", "Charged", "phone", logger) + charged.UpdateBattery(80, true) + sum = ipc.SummarizeDevice(charged, pluginReg) + if sum.Battery == nil { + t.Fatal("device with a reading must carry battery") + } + if sum.Battery.Charge != 80 || !sum.Battery.Charging { + t.Errorf("battery values wrong: %+v", sum.Battery) + } + if sum.Battery.BatteryAgeMs < 0 { + t.Errorf("battery age must be non-negative after a reading: %+v", sum.Battery) + } + + dead := device.NewDevice("dev-dead", "Dead", "phone", logger) + dead.UpdateBattery(0, false) + sum = ipc.SummarizeDevice(dead, pluginReg) + if sum.Battery == nil { + t.Fatal("true 0% reading must still be published") + } + if sum.Battery.Charge != 0 || sum.Battery.Charging { + t.Errorf("true zero values wrong: %+v", sum.Battery) + } + raw, _ = json.Marshal(sum) + decoded = nil + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + bat, ok := decoded["battery"].(map[string]interface{}) + if !ok { + t.Fatalf("true 0%% must serialize a battery key: %s", raw) + } + if bat["charge"] != float64(0) { + t.Errorf("true 0%% charge wrong: %v", bat) + } +} + +// A connected device is seen now by definition: last_seen is stamped at +// connect time and discovery sightings skip connected devices, so the raw +// stamp goes stale for the whole session. The summary must report now for +// connected devices and preserve the stored stamp for offline ones. +func TestSummarizeDeviceConnectedMeansSeenNow(t *testing.T) { + logger := zaptest.NewLogger(t) + stale := time.Now().Add(-53 * time.Minute) + + offline := device.NewDevice("dev-off", "Offline Phone", "phone", logger) + offline.SetLastSeen(stale) + offlineSum := ipc.SummarizeDevice(offline, nil) + if !offlineSum.LastSeen.Equal(stale) { + t.Errorf("offline summary must preserve stored last_seen, got %v", offlineSum.LastSeen) + } + + online := device.NewDevice("dev-on", "Online Phone", "phone", logger) + online.SetLastSeen(stale) + left, right := net.Pipe() + conn := transport.NewConn(tls.Client(left, &tls.Config{InsecureSkipVerify: true})) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + online.Connect(ctx, conn, nil, nil, nil) + + onlineSum := ipc.SummarizeDevice(online, nil) + if time.Since(onlineSum.LastSeen) > time.Minute { + t.Errorf("connected summary last_seen must be now, got %v", onlineSum.LastSeen) + } + + _ = right.Close() + deadline := time.Now().Add(2 * time.Second) + for online.IsConnected() && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + online.Disconnect() +} diff --git a/internal/plugins/clipboard/clipboard.go b/internal/plugins/clipboard/clipboard.go index 4ab36eb..e779e0b 100644 --- a/internal/plugins/clipboard/clipboard.go +++ b/internal/plugins/clipboard/clipboard.go @@ -16,6 +16,7 @@ import ( "sync" "time" + "github.com/bethropolis/kcd/internal/cert" "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/protocol" "go.uber.org/zap" @@ -322,6 +323,7 @@ func (p *ClipboardPlugin) handleClipboardFile(ctx context.Context, dev device.Se payloadSize := pkt.PayloadSize payloadPort := pkt.PayloadTransferInfo.Port filename := body.Filename + expectedFP := cert.PinnedFingerprint(dev.PeerCert()) go func() { // Download to a temp file @@ -334,7 +336,7 @@ func (p *ClipboardPlugin) handleClipboardFile(ctx context.Context, dev device.Se tmpFile.Close() defer os.Remove(tmpPath) - if err := downloadToFile(ctx, remoteIP, payloadPort, payloadSize, tmpPath, p.tlsConfig, p.logger); err != nil { + if err := downloadToFile(ctx, remoteIP, payloadPort, payloadSize, tmpPath, p.tlsConfig, expectedFP, p.logger); err != nil { p.logger.Error("clipboard file: download failed", zap.Error(err)) return } @@ -369,7 +371,7 @@ func (p *ClipboardPlugin) handleClipboardFile(ctx context.Context, dev device.Se } // downloadToFile dials a TLS side-channel and streams the payload to dest. -func downloadToFile(ctx context.Context, ip net.IP, port int, size int64, dest string, tlsConfig *tls.Config, _ *zap.Logger) error { +func downloadToFile(ctx context.Context, ip net.IP, port int, size int64, dest string, tlsConfig *tls.Config, expectedFP string, logger *zap.Logger) error { addr := fmt.Sprintf("%s:%d", ip.String(), port) dialer := &tls.Dialer{ NetDialer: &net.Dialer{ @@ -384,6 +386,15 @@ func downloadToFile(ctx context.Context, ip net.IP, port int, size int64, dest s } defer conn.Close() + if tlsConn, ok := conn.(*tls.Conn); !ok { + return fmt.Errorf("clipboard: side-channel connection is not TLS") + } else if expectedFP == "" { + logger.Warn("clipboard: no pinned peer fingerprint, skipping side-channel verification", + zap.String("remote_addr", addr)) + } else if err := cert.VerifySideChannelPeer(tlsConn.ConnectionState(), expectedFP); err != nil { + return fmt.Errorf("clipboard: side-channel peer verification failed: %w", err) + } + f, err := os.Create(dest) if err != nil { return fmt.Errorf("clipboard: create file %s: %w", dest, err) diff --git a/internal/plugins/connectivity/connectivity.go b/internal/plugins/connectivity/connectivity.go index d64e0b5..8015a54 100644 --- a/internal/plugins/connectivity/connectivity.go +++ b/internal/plugins/connectivity/connectivity.go @@ -89,6 +89,20 @@ func (p *ConnectivityPlugin) OnConnect(dev device.Sender) { dev.Send(pkt) } +// Report returns the last connectivity report received from a device. +// The second return value is false when the device never reported (or the +// entry was cleared on disconnect). Used for watch initial-state dumps and +// on-demand CLI queries. +func (p *ConnectivityPlugin) Report(deviceID string) (ConnectivityBody, bool) { + p.mu.Lock() + defer p.mu.Unlock() + if p.lastReports == nil { + return ConnectivityBody{}, false + } + body, ok := p.lastReports[deviceID] + return body, ok +} + func (p *ConnectivityPlugin) OnDisconnect(dev device.Sender) { p.mu.Lock() defer p.mu.Unlock() diff --git a/internal/plugins/connectivity/connectivity_test.go b/internal/plugins/connectivity/connectivity_test.go index 24b322d..c7c8776 100644 --- a/internal/plugins/connectivity/connectivity_test.go +++ b/internal/plugins/connectivity/connectivity_test.go @@ -97,3 +97,41 @@ func expectNoEvent(t *testing.T, sub *events.Subscriber) { case <-time.After(50 * time.Millisecond): } } + +func TestReportCachesLastHandle(t *testing.T) { + plugin := NewConnectivityPlugin(nil) + dev := testSender{id: "device-1"} + + if _, ok := plugin.Report("device-1"); ok { + t.Fatal("expected miss before any report") + } + + body := ConnectivityBody{ + SignalStrengths: map[string]SignalStrength{ + "0": {NetworkType: "LTE", NetworkDetailedType: "LTE", SignalStrength: 4}, + }, + } + pkt, err := protocol.NewPacket("kdeconnect.connectivity_report", body) + if err != nil { + t.Fatal(err) + } + if err := plugin.Handle(context.Background(), dev, pkt); err != nil { + t.Fatal(err) + } + + got, ok := plugin.Report("device-1") + if !ok { + t.Fatal("expected hit after Handle") + } + if got.SignalStrengths["0"].SignalStrength != 4 { + t.Errorf("wrong cached level: %+v", got) + } + if _, ok := plugin.Report("unknown"); ok { + t.Error("expected miss for unknown device") + } + + plugin.OnDisconnect(dev) + if _, ok := plugin.Report("device-1"); ok { + t.Error("expected miss after disconnect clears the cache") + } +} diff --git a/internal/plugins/contacts/contacts.go b/internal/plugins/contacts/contacts.go new file mode 100644 index 0000000..8c5cf21 --- /dev/null +++ b/internal/plugins/contacts/contacts.go @@ -0,0 +1,586 @@ +package contacts + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/bethropolis/kcd/internal/device" + "github.com/bethropolis/kcd/internal/events" + "github.com/bethropolis/kcd/internal/protocol" + "go.uber.org/zap" +) + +// KDE Connect contacts packet types (see kdeconnect-kde +// plugins/contacts/contactsplugin.h and the Android ContactsPlugin). +const ( + PacketTypeContactsRequestUIDs = "kdeconnect.contacts.request_all_uids_timestamps" + PacketTypeContactsRequestVCards = "kdeconnect.contacts.request_vcards_by_uid" + PacketTypeContactsResponseUIDs = "kdeconnect.contacts.response_uids_timestamps" + PacketTypeContactsResponseVCards = "kdeconnect.contacts.response_vcards" +) + +const ( + // maxContactUIDs bounds a single uids response (address books are in + // the hundreds; this is two orders of magnitude of headroom). + maxContactUIDs = 20000 + // maxVCardBytes caps one vCard (text contact cards are ~1KB). + maxVCardBytes = 64 * 1024 + // maxSyncBytes caps a whole vcards response before anything hits disk. + maxSyncBytes = 64 * 1024 * 1024 + // vcardChunkSize bounds our outbound request packets. + vcardChunkSize = 100 + // maxDisplayLen truncates contact fields shown to clients. + maxDisplayLen = 256 +) + +// uidSafeChars restricts phone-provided contact UIDs (Android LOOKUP_KEYs) +// to filename-safe characters so they can't escape the cache directory. +var uidSafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +// sanitizeUID strips everything but alphanumerics, dot, underscore and +// hyphen. Empty results are rejected by the caller. +func sanitizeUID(uid string) string { + return uidSafeChars.ReplaceAllString(uid, "_") +} + +// ContactSummary is the parsed, display-safe view of one contact. +type ContactSummary struct { + UID string `json:"uid"` + Name string `json:"name"` + Phones []string `json:"phones,omitempty"` + Emails []string `json:"emails,omitempty"` + Timestamp int64 `json:"timestamp"` +} + +// indexEntry is the persisted per-contact record (index.json sidecar, so +// listing never reparses hundreds of vCards). Fetched marks that the vCard +// round completed; entries with only a timestamp are pending fetch. +type indexEntry struct { + Timestamp int64 `json:"timestamp"` + Fetched bool `json:"fetched"` + Name string `json:"name"` + Phones []string `json:"phones,omitempty"` + Emails []string `json:"emails,omitempty"` +} + +// ContactsPlugin syncs the phone address book: it requests UID/timestamp +// lists, fetches vCards for new or changed contacts, caches them per +// device, and deletes stale entries. +type ContactsPlugin struct { + bus *events.Bus + logger *zap.Logger + baseDir string + + // mu serializes sync processing across devices. Syncs are rare; + // one lock avoids per-device lock bookkeeping. + mu sync.Mutex +} + +// NewContactsPlugin creates a contacts plugin caching under +// $XDG_DATA_HOME/kcd/contacts (0600 files, 0700 dirs). +func NewContactsPlugin(bus *events.Bus, logger *zap.Logger) *ContactsPlugin { + dataHome := os.Getenv("XDG_DATA_HOME") + if dataHome == "" { + home, _ := os.UserHomeDir() + dataHome = filepath.Join(home, ".local", "share") + } + baseDir := filepath.Join(dataHome, "kcd", "contacts") + _ = os.MkdirAll(baseDir, 0700) + + return &ContactsPlugin{ + bus: bus, + logger: logger.With(zap.String("plugin", "contacts")), + baseDir: baseDir, + } +} + +func (p *ContactsPlugin) Name() string { return "Contacts" } + +func (p *ContactsPlugin) Timeout() time.Duration { return 5 * time.Second } + +func (p *ContactsPlugin) IncomingTypes() []string { + return []string{PacketTypeContactsResponseUIDs, PacketTypeContactsResponseVCards} +} + +func (p *ContactsPlugin) OutgoingTypes() []string { + return []string{PacketTypeContactsRequestUIDs, PacketTypeContactsRequestVCards} +} + +// OnConnect starts a sync for freshly connected paired devices, mirroring +// upstream's connected() -> synchronizeRemoteWithLocal(). +func (p *ContactsPlugin) OnConnect(dev device.Sender) { + if dev.State() != device.StatePaired { + return + } + if err := p.RequestSync(dev); err != nil { + p.logger.Debug("contacts: initial sync request failed", zap.Error(err)) + } +} + +func (p *ContactsPlugin) OnDisconnect(dev device.Sender) {} + +// Handle routes response packets; all parsing and disk I/O runs in a +// worker goroutine so Handle returns immediately (rule 9). +func (p *ContactsPlugin) Handle(ctx context.Context, dev device.Sender, pkt *protocol.Packet) error { + switch pkt.Type { + case PacketTypeContactsResponseUIDs: + body := append([]byte(nil), pkt.Body...) + go p.handleUIDsResponse(dev, body) + return nil + case PacketTypeContactsResponseVCards: + body := append([]byte(nil), pkt.Body...) + devID := dev.ID() + go p.handleVCardsResponse(devID, body) + return nil + default: + return nil + } +} + +// RequestSync asks the phone for all contact UIDs and timestamps, which +// starts the sync round trips. Responses arrive async via Handle. +func (p *ContactsPlugin) RequestSync(dev device.Sender) error { + pkt, err := protocol.NewPacket(PacketTypeContactsRequestUIDs, nil) + if err != nil { + return err + } + return dev.Send(pkt) +} + +// requestVCards asks for vCards of the given UIDs, chunked to bound +// outbound packet size. +func (p *ContactsPlugin) requestVCards(dev device.Sender, uids []string) error { + for _, chunk := range chunkUIDs(uids, vcardChunkSize) { + body := map[string]any{"uids": chunk} + pkt, err := protocol.NewPacket(PacketTypeContactsRequestVCards, body) + if err != nil { + return err + } + if err := dev.Send(pkt); err != nil { + return err + } + } + return nil +} + +func chunkUIDs(uids []string, size int) [][]string { + var chunks [][]string + for len(uids) > 0 { + n := size + if len(uids) < n { + n = len(uids) + } + chunks = append(chunks, uids[:n]) + uids = uids[n:] + } + return chunks +} + +// cacheDir resolves the cache dir for a device without creating it. +// The device ID comes from our own registry, but confine defensively. +func (p *ContactsPlugin) cacheDir(deviceID string) (string, error) { + safe := sanitizeUID(deviceID) + if safe == "" { + return "", fmt.Errorf("contacts: unusable device id") + } + dir := filepath.Join(p.baseDir, safe) + if rel, err := filepath.Rel(p.baseDir, dir); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("contacts: device dir escapes cache") + } + return dir, nil +} + +// deviceDir returns the cache dir for a device, creating it (0700). +func (p *ContactsPlugin) deviceDir(deviceID string) (string, error) { + dir, err := p.cacheDir(deviceID) + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0700); err != nil { + return "", fmt.Errorf("contacts: create dir: %w", err) + } + return dir, nil +} + +// contactPath resolves the vcf file for a UID inside dir, confined to dir. +func contactPath(dir, uid string) (string, error) { + safe := sanitizeUID(uid) + if safe == "" { + return "", fmt.Errorf("contacts: unusable uid") + } + path := filepath.Join(dir, safe+".vcf") + if rel, err := filepath.Rel(dir, path); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("contacts: uid escapes cache") + } + return path, nil +} + +// loadIndex reads the sidecar index (missing file = empty cache, not error). +func loadIndex(dir string) map[string]indexEntry { + idx := make(map[string]indexEntry) + data, err := os.ReadFile(filepath.Join(dir, "index.json")) + if err != nil { + return idx + } + _ = json.Unmarshal(data, &idx) + if idx == nil { + idx = make(map[string]indexEntry) + } + return idx +} + +// saveIndex persists the sidecar index (0600). +func saveIndex(dir string, idx map[string]indexEntry) error { + data, err := json.MarshalIndent(idx, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "index.json"), data, 0600) +} + +// coerceTimestamp parses Android's string-encoded timestamps as well as +// plain JSON numbers (the header doc shows ints). Garbage yields 0, which +// forces a re-fetch — the safe direction, never a false "unchanged". +func coerceTimestamp(raw json.RawMessage) int64 { + var asAny any + if err := json.Unmarshal(raw, &asAny); err != nil { + return 0 + } + switch v := asAny.(type) { + case string: + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return 0 + } + return n + case float64: + if v < 0 { + return 0 + } + return int64(v) + default: + return 0 + } +} + +// handleUIDsResponse diffs the phone's UID/timestamp list against the local +// cache, deletes stale entries, and requests vCards for new/changed ones. +// dev is the live sender (device.Send is channel-safe across goroutines). +func (p *ContactsPlugin) handleUIDsResponse(dev device.Sender, body []byte) { + deviceID := dev.ID() + + // Cache diff under lock; the vCard request round below does network + // I/O and must not hold it. + toFetch, added, updated, deleted := func() ([]string, int, int, int) { + p.mu.Lock() + defer p.mu.Unlock() + + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + p.logger.Debug("contacts: malformed uids response", zap.Error(err)) + return nil, 0, 0, 0 + } + uidsRaw, ok := raw["uids"] + if !ok { + p.logger.Debug("contacts: uids response without uids key") + return nil, 0, 0, 0 + } + var uids []string + if err := json.Unmarshal(uidsRaw, &uids); err != nil { + p.logger.Debug("contacts: malformed uids list", zap.Error(err)) + return nil, 0, 0, 0 + } + if len(uids) > maxContactUIDs { + p.logger.Warn("contacts: uids response exceeds cap, refusing", + zap.Int("count", len(uids))) + return nil, 0, 0, 0 + } + + dir, err := p.deviceDir(deviceID) + if err != nil { + p.logger.Warn("contacts: bad device dir", zap.Error(err)) + return nil, 0, 0, 0 + } + idx := loadIndex(dir) + + seen := make(map[string]bool, len(uids)) + var toFetch []string + var added, updated int + for _, uid := range uids { + if uid == "" { + continue + } + seen[uid] = true + ts := coerceTimestamp(raw[uid]) + entry, known := idx[uid] + if !known { + added++ + toFetch = append(toFetch, uid) + } else if entry.Timestamp != ts { + updated++ + toFetch = append(toFetch, uid) + } + // Record the authoritative timestamp now (the vCards round + // carries no stamps); the summary fields arrive with the vCard. + entry.Timestamp = ts + idx[uid] = entry + } + + // Delete locally-known contacts the phone no longer reports — but + // never on an empty list: a buggy/empty response must not wipe + // the cache. + var deleted int + if len(uids) > 0 { + for uid := range idx { + if !seen[uid] { + if path, err := contactPath(dir, uid); err == nil { + _ = os.Remove(path) + } + delete(idx, uid) + deleted++ + } + } + } + if err := saveIndex(dir, idx); err != nil { + p.logger.Warn("contacts: failed to save index", zap.Error(err)) + } + return toFetch, added, updated, deleted + }() + + if len(toFetch) > 0 { + if err := p.requestVCards(dev, toFetch); err != nil { + p.logger.Warn("contacts: failed to request vcards", + zap.String("device_id", deviceID), + zap.Error(err)) + } + } + p.emit(deviceID, map[string]any{ + "phase": "uids", + "added": added, + "updated": updated, + "deleted": deleted, + "pending": len(toFetch), + }) +} + +// handleVCardsResponse stores one .vcf per UID, refreshes the index, and +// reports counts (never contact content) on the event bus. +func (p *ContactsPlugin) handleVCardsResponse(deviceID string, body []byte) { + p.mu.Lock() + defer p.mu.Unlock() + + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + p.logger.Debug("contacts: malformed vcards response", zap.Error(err)) + return + } + uidsRaw, ok := raw["uids"] + if !ok { + p.logger.Debug("contacts: vcards response without uids key") + return + } + var uids []string + if err := json.Unmarshal(uidsRaw, &uids); err != nil { + p.logger.Debug("contacts: malformed vcards uids list", zap.Error(err)) + return + } + if len(uids) > maxContactUIDs { + p.logger.Warn("contacts: vcards response exceeds cap, refusing", + zap.Int("count", len(uids))) + return + } + + dir, err := p.deviceDir(deviceID) + if err != nil { + p.logger.Warn("contacts: bad device dir", zap.Error(err)) + return + } + idx := loadIndex(dir) + + var stored, skipped int + var total int64 + for _, uid := range uids { + if uid == "" { + continue + } + vRaw, ok := raw[uid] + if !ok { + continue + } + var vcard string + if err := json.Unmarshal(vRaw, &vcard); err != nil { + p.logger.Debug("contacts: non-string vcard, skipping", + zap.String("uid", sanitizeUID(uid))) + skipped++ + continue + } + if len(vcard) > maxVCardBytes { + p.logger.Warn("contacts: oversized vcard, skipping", + zap.String("uid", sanitizeUID(uid)), + zap.Int("bytes", len(vcard))) + skipped++ + continue + } + total += int64(len(vcard)) + if total > maxSyncBytes { + p.logger.Warn("contacts: sync exceeds total cap, stopping") + break + } + path, err := contactPath(dir, uid) + if err != nil { + skipped++ + continue + } + if err := os.WriteFile(path, []byte(vcard), 0600); err != nil { + p.logger.Warn("contacts: failed to store vcard", zap.Error(err)) + skipped++ + continue + } + name, phones, emails := parseVCard(vcard) + // The vCards round carries no per-UID stamps — keep the timestamp + // established by the uids round and mark the entry fetched. + entry := idx[uid] + entry.Fetched = true + entry.Name = name + entry.Phones = phones + entry.Emails = emails + idx[uid] = entry + stored++ + } + if err := saveIndex(dir, idx); err != nil { + p.logger.Warn("contacts: failed to save index", zap.Error(err)) + } + + p.emit(deviceID, map[string]any{ + "phase": "vcards", + "stored": stored, + "skipped": skipped, + }) +} + +// List returns cached contact summaries sorted by name. Entries whose +// vCard hasn't arrived yet are skipped; empty when never synced — absent +// means unknown, never a fabricated entry. +func (p *ContactsPlugin) List(deviceID string) []ContactSummary { + p.mu.Lock() + defer p.mu.Unlock() + + dir, err := p.cacheDir(deviceID) + if err != nil { + return nil + } + idx := loadIndex(dir) + out := make([]ContactSummary, 0, len(idx)) + for uid, entry := range idx { + if !entry.Fetched { + continue + } + out = append(out, ContactSummary{ + UID: uid, + Name: entry.Name, + Phones: entry.Phones, + Emails: entry.Emails, + Timestamp: entry.Timestamp, + }) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Name != out[j].Name { + return out[i].Name < out[j].Name + } + return out[i].UID < out[j].UID + }) + return out +} + +// ForgetDevice deletes a device's cached contacts. Called on unpair: +// revoked trust drops the address book; re-pair re-syncs from scratch. +func (p *ContactsPlugin) ForgetDevice(deviceID string) error { + p.mu.Lock() + defer p.mu.Unlock() + + dir, err := p.cacheDir(deviceID) + if err != nil { + return err + } + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("contacts: forget device: %w", err) + } + return nil +} + +func (p *ContactsPlugin) emit(deviceID string, payload map[string]any) { + if p.bus == nil { + return + } + p.bus.Publish(events.TypeContactsUpdated, deviceID, payload) +} + +// cleanDisplayValue strips control characters (terminal-escape injection +// via contact names is a classic) and truncates overlong fields. +func cleanDisplayValue(s string) string { + s = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, s) + s = strings.TrimSpace(s) + if len(s) > maxDisplayLen { + s = s[:maxDisplayLen] + } + return s +} + +// parseVCard extracts display fields from vCard 3.0 text with a stdlib +// line scan: unfold continuations, split Name:Value, drop parameters +// (TEL;TYPE=CELL:...). Returns the first FN and all TEL/EMAIL values. +func parseVCard(vcard string) (name string, phones, emails []string) { + // Normalize newlines, then unfold continuation lines (leading SP/HT). + raw := strings.ReplaceAll(vcard, "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + var lines []string + for _, line := range strings.Split(raw, "\n") { + if line == "" { + continue + } + if (line[0] == ' ' || line[0] == '\t') && len(lines) > 0 { + lines[len(lines)-1] += line[1:] + continue + } + lines = append(lines, line) + } + for _, line := range lines { + sep := strings.IndexByte(line, ':') + if sep < 0 { + continue + } + field := strings.ToUpper(line[:sep]) + if i := strings.IndexByte(field, ';'); i >= 0 { + field = field[:i] + } + value := cleanDisplayValue(line[sep+1:]) + if value == "" { + continue + } + switch field { + case "FN": + if name == "" { + name = value + } + case "TEL": + phones = append(phones, value) + case "EMAIL": + emails = append(emails, value) + } + } + return name, phones, emails +} diff --git a/internal/plugins/contacts/contacts_test.go b/internal/plugins/contacts/contacts_test.go new file mode 100644 index 0000000..8a1e17e --- /dev/null +++ b/internal/plugins/contacts/contacts_test.go @@ -0,0 +1,300 @@ +package contacts + +import ( + "crypto/x509" + "encoding/json" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/bethropolis/kcd/internal/device" + "github.com/bethropolis/kcd/internal/protocol" + "go.uber.org/zap/zaptest" +) + +// fakeSender captures outbound packets for round-trip tests. +type fakeSender struct { + id string + sent []*protocol.Packet +} + +func (f *fakeSender) ID() string { return f.id } +func (f *fakeSender) Name() string { return "Fake" } +func (f *fakeSender) SetName(name string) {} +func (f *fakeSender) State() device.PairingState { return device.StatePaired } +func (f *fakeSender) SetState(state device.PairingState) {} +func (f *fakeSender) Send(p *protocol.Packet) error { f.sent = append(f.sent, p); return nil } +func (f *fakeSender) IsConnected() bool { return true } +func (f *fakeSender) RemoteIP() net.IP { return nil } +func (f *fakeSender) PeerCert() *x509.Certificate { return nil } +func (f *fakeSender) HasCapability(cap string) bool { return false } +func (f *fakeSender) UpdateBattery(charge int, ch bool) {} +func (f *fakeSender) GetBattery() (int, bool) { return 0, false } + +func testPlugin(t *testing.T) *ContactsPlugin { + t.Helper() + t.Setenv("XDG_DATA_HOME", t.TempDir()) + return NewContactsPlugin(nil, zaptest.NewLogger(t)) +} + +func TestParseVCard(t *testing.T) { + vcard := "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nTEL;TYPE=CELL:+1-555-0100\r\nTEL;TYPE=HOME:+1-555-0101\r\nEMAIL:ada@example.com\r\nREV:973486597\r\nEND:VCARD" + name, phones, emails := parseVCard(vcard) + if name != "Ada Lovelace" { + t.Errorf("name = %q", name) + } + if len(phones) != 2 || phones[0] != "+1-555-0100" { + t.Errorf("phones = %v", phones) + } + if len(emails) != 1 || emails[0] != "ada@example.com" { + t.Errorf("emails = %v", emails) + } +} + +func TestParseVCardFoldingAndControls(t *testing.T) { + // Folded FN line + control char + overlong value. + vcard := "BEGIN:VCARD\nFN:John\x00 Smi\n th\nTEL:123\nEND:VCARD" + name, phones, _ := parseVCard(vcard) + if name != "John Smith" { + t.Errorf("folded/controls name = %q", name) + } + if len(phones) != 1 { + t.Errorf("phones = %v", phones) + } + + long := "FN:" + strings.Repeat("x", 500) + name, _, _ = parseVCard("BEGIN:VCARD\n" + long + "\nEND:VCARD") + if len(name) != maxDisplayLen { + t.Errorf("name not truncated: len=%d", len(name)) + } +} + +func TestCoerceTimestamp(t *testing.T) { + cases := []struct { + name string + raw string + want int64 + }{ + {"string", `"973486597"`, 973486597}, + {"string spaces", `" 42 "`, 42}, + {"int", `973486597`, 973486597}, + {"garbage string", `"soon"`, 0}, + {"bool", `true`, 0}, + {"null", `null`, 0}, + {"negative float", `-5`, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := coerceTimestamp(json.RawMessage(tc.raw)); got != tc.want { + t.Errorf("coerceTimestamp(%s) = %d, want %d", tc.raw, got, tc.want) + } + }) + } +} + +func TestSanitizeUID(t *testing.T) { + if got := sanitizeUID("1234iabc"); got != "1234iabc" { + t.Errorf("plain uid mangled: %q", got) + } + if got := sanitizeUID("../../etc"); strings.ContainsAny(got, "/\\") { + t.Errorf("traversal not neutralized: %q", got) + } + if sanitizeUID("") != "" { + t.Error("empty uid must stay empty (caller rejects)") + } +} + +func TestContactPathConfined(t *testing.T) { + dir := t.TempDir() + if _, err := contactPath(dir, "../evil"); err == nil { + // sanitized to plain underscores — must still resolve inside dir + t.Log("traversal neutralized by sanitizer") + } + p, err := contactPath(dir, "abc123") + if err != nil { + t.Fatalf("valid uid rejected: %v", err) + } + if filepath.Dir(p) != dir { + t.Errorf("path escapes dir: %q", p) + } + if _, err := contactPath(dir, ""); err == nil { + t.Error("empty uid must be rejected") + } +} + +func TestChunkUIDs(t *testing.T) { + var uids []string + for i := 0; i < 250; i++ { + uids = append(uids, string(rune('a'+i%26))+strings.Repeat("x", i%5)) + } + chunks := chunkUIDs(uids, vcardChunkSize) + if len(chunks) != 3 { + t.Fatalf("expected 3 chunks, got %d", len(chunks)) + } + for _, c := range chunks { + if len(c) > vcardChunkSize { + t.Errorf("chunk exceeds size: %d", len(c)) + } + } + if len(chunkUIDs(nil, vcardChunkSize)) != 0 { + t.Error("nil must yield no chunks") + } +} + +// Full round trip: uids response -> vcard request emitted -> vcards +// response -> files + index + List output. +func TestSyncRoundTrip(t *testing.T) { + p := testPlugin(t) + dev := &fakeSender{id: "dev1"} + + uidsBody, _ := json.Marshal(map[string]any{ + "uids": []string{"u1", "u2"}, + "u1": "100", + "u2": 200, + }) + p.handleUIDsResponse(dev, uidsBody) + + if len(dev.sent) != 1 { + t.Fatalf("expected 1 vcard request, got %d", len(dev.sent)) + } + if dev.sent[0].Type != PacketTypeContactsRequestVCards { + t.Errorf("wrong request type: %s", dev.sent[0].Type) + } + + vcardsBody, _ := json.Marshal(map[string]any{ + "uids": []string{"u1", "u2"}, + "u1": "BEGIN:VCARD\nFN:Alice\nTEL:111\nEND:VCARD", + "u2": "BEGIN:VCARD\nFN:Bob\nEMAIL:bob@x.y\nEND:VCARD", + }) + p.handleVCardsResponse("dev1", vcardsBody) + + list := p.List("dev1") + if len(list) != 2 { + t.Fatalf("expected 2 contacts, got %d", len(list)) + } + // Sorted by name. + if list[0].Name != "Alice" || list[1].Name != "Bob" { + t.Errorf("bad sort/content: %+v", list) + } + if len(list[0].Phones) != 1 || list[0].Phones[0] != "111" { + t.Errorf("phones wrong: %+v", list[0]) + } + // Timestamps preserved from the uids round (string and int forms). + if list[0].Timestamp != 100 || list[1].Timestamp != 200 { + t.Errorf("timestamps wrong: %+v", list) + } + + // Files on disk, 0600. + for _, uid := range []string{"u1", "u2"} { + path, err := contactPath(filepath.Join(p.baseDir, "dev1"), uid) + if err != nil { + t.Fatal(err) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatalf("missing vcf for %s: %v", uid, err) + } + if fi.Mode().Perm() != 0600 { + t.Errorf("vcf perms = %o, want 600", fi.Mode().Perm()) + } + } +} + +// Second sync with changed timestamp refetches; unchanged contacts don't. +func TestSyncDiff(t *testing.T) { + p := testPlugin(t) + dev := &fakeSender{id: "dev1"} + + first, _ := json.Marshal(map[string]any{"uids": []string{"u1"}, "u1": "100"}) + p.handleUIDsResponse(dev, first) + if len(dev.sent) != 1 { + t.Fatalf("first sync must request vcard, sent=%d", len(dev.sent)) + } + vcards, _ := json.Marshal(map[string]any{ + "uids": []string{"u1"}, + "u1": "BEGIN:VCARD\nFN:Alice\nEND:VCARD", + }) + p.handleVCardsResponse("dev1", vcards) + + dev.sent = nil + same, _ := json.Marshal(map[string]any{"uids": []string{"u1"}, "u1": "100"}) + p.handleUIDsResponse(dev, same) + if len(dev.sent) != 0 { + t.Error("unchanged contact must not be refetched") + } + + dev.sent = nil + changed, _ := json.Marshal(map[string]any{"uids": []string{"u1"}, "u1": "101"}) + p.handleUIDsResponse(dev, changed) + if len(dev.sent) != 1 { + t.Error("changed timestamp must trigger refetch") + } +} + +// Stale entries are deleted on non-empty responses, never on empty ones. +func TestSyncDeleteGuard(t *testing.T) { + p := testPlugin(t) + dev := &fakeSender{id: "dev1"} + + seed, _ := json.Marshal(map[string]any{"uids": []string{"u1"}, "u1": "1"}) + p.handleUIDsResponse(dev, seed) + vcards, _ := json.Marshal(map[string]any{ + "uids": []string{"u1"}, + "u1": "BEGIN:VCARD\nFN:Gone\nEND:VCARD", + }) + p.handleVCardsResponse("dev1", vcards) + if len(p.List("dev1")) != 1 { + t.Fatal("seed failed") + } + + // Empty response must not wipe. + empty, _ := json.Marshal(map[string]any{"uids": []string{}}) + p.handleUIDsResponse(dev, empty) + if len(p.List("dev1")) != 1 { + t.Error("empty uids response must not delete cache") + } + + // Non-empty response without u1 deletes it. + without, _ := json.Marshal(map[string]any{"uids": []string{"u2"}, "u2": "2"}) + p.handleUIDsResponse(dev, without) + for _, ct := range p.List("dev1") { + if ct.UID == "u1" { + t.Error("stale contact must be deleted on non-empty response") + } + } +} + +func TestForgetDevice(t *testing.T) { + p := testPlugin(t) + dev := &fakeSender{id: "dev1"} + + seed, _ := json.Marshal(map[string]any{"uids": []string{"u1"}, "u1": "1"}) + p.handleUIDsResponse(dev, seed) + vcards, _ := json.Marshal(map[string]any{ + "uids": []string{"u1"}, + "u1": "BEGIN:VCARD\nFN:Gone\nEND:VCARD", + }) + p.handleVCardsResponse("dev1", vcards) + if len(p.List("dev1")) != 1 { + t.Fatal("seed failed") + } + + if err := p.ForgetDevice("dev1"); err != nil { + t.Fatalf("ForgetDevice failed: %v", err) + } + if len(p.List("dev1")) != 0 { + t.Error("cache must be empty after forget") + } + if _, err := os.Stat(filepath.Join(p.baseDir, "dev1")); !os.IsNotExist(err) { + t.Error("device dir must be removed") + } +} + +func TestListEmptyWhenNeverSynced(t *testing.T) { + p := testPlugin(t) + if list := p.List("ghost"); len(list) != 0 { + t.Errorf("never-synced device must list empty, got %v", list) + } +} diff --git a/internal/plugins/mousepad/mousepad.go b/internal/plugins/mousepad/mousepad.go index f113816..a7b7388 100644 --- a/internal/plugins/mousepad/mousepad.go +++ b/internal/plugins/mousepad/mousepad.go @@ -304,8 +304,10 @@ func (p *MousepadPlugin) handleEvent(body MousepadBody) { } else if body.Key != "" { // Unicode strings cannot be typed via uinput directly. // Use display-server tool for text input regardless of uinput backend. + // "--" ends option parsing (supported by both wtype and xdotool) + // so phone-provided text starting with '-' can't be misparsed. if p.isWayland { - p.runCmd("wtype", body.Key) + p.runCmd("wtype", "--", body.Key) } else { p.runCmd("xdotool", "type", "--", body.Key) } diff --git a/internal/plugins/mpris/mpris.go b/internal/plugins/mpris/mpris.go index 327d93a..40438cb 100644 --- a/internal/plugins/mpris/mpris.go +++ b/internal/plugins/mpris/mpris.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/bethropolis/kcd/internal/cert" "github.com/bethropolis/kcd/internal/config" "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/events" @@ -160,14 +161,19 @@ type MPRISRequest struct { } type NowPlaying struct { - Player string `json:"player"` - Title string `json:"title"` - Artist string `json:"artist"` - Album string `json:"album"` - AlbumArtUrl string `json:"albumArtUrl"` - Url string `json:"url,omitempty"` - Length int64 `json:"length"` - Pos int64 `json:"pos,omitempty"` + Player string `json:"player"` + Title string `json:"title"` + Artist string `json:"artist"` + Album string `json:"album"` + AlbumArtUrl string `json:"albumArtUrl"` + ArtPending bool `json:"artPending,omitempty"` + Url string `json:"url,omitempty"` + Length int64 `json:"length"` + Pos int64 `json:"pos,omitempty"` + // PosAnchorMs is the wall-clock time (Unix millis) at which Pos was + // sampled. Clients compute the live position drift-free as + // Pos + (nowMs - PosAnchorMs) * (isPlaying ? 1 : 0). + PosAnchorMs int64 `json:"posAnchorMs,omitempty"` IsPlaying bool `json:"isPlaying"` Volume int `json:"volume,omitempty"` CanControl bool `json:"canControl"` @@ -312,6 +318,7 @@ func (p *MPRISPlugin) Handle(ctx context.Context, dev device.Sender, pkt *protoc tracker.lastPosition = body.Pos tracker.lastPositionAt = time.Now() tracker.playing = body.IsPlaying + state.PosAnchorMs = tracker.lastPositionAt.UnixMilli() shouldPublish := shouldPublishRemoteState(p.remoteStates[dev.ID()], state) p.remoteStates[dev.ID()] = state p.remoteStateTimes[dev.ID()] = time.Now() @@ -328,6 +335,12 @@ func (p *MPRISPlugin) Handle(ctx context.Context, dev device.Sender, pkt *protoc if p.artCache != nil { if resolved := p.artCache.Resolve(pub.AlbumArtUrl); resolved != "" { pub.AlbumArtUrl = resolved + } else if pub.AlbumArtUrl != "" && strings.HasPrefix(pub.AlbumArtUrl, "kdeconnect:") { + // Art still downloading: publish an empty URL with the + // pending flag instead of an unloadable kdeconnect:/ + // URI. The arrival re-publish carries file://. + pub.AlbumArtUrl = "" + pub.ArtPending = true } } p.bus.Publish(events.TypeMprisUpdate, dev.ID(), pub) @@ -514,7 +527,7 @@ func (p *MPRISPlugin) sendAlbumArt(ctx context.Context, dev device.Sender, playe } go func() { - _ = share.AcceptAndSend(ln, filePath, p.tlsConfig, dev.ID(), 10*time.Second, nil, p.logger) + _ = share.AcceptAndSend(ln, filePath, p.tlsConfig, dev.ID(), cert.PinnedFingerprint(dev.PeerCert()), 10*time.Second, nil, p.logger) }() pkt, err := protocol.NewPacket("kdeconnect.mpris", map[string]interface{}{ @@ -587,7 +600,7 @@ func (p *MPRISPlugin) receiveAlbumArt(_ context.Context, dev device.Sender, play // independent context so the side-channel dial isn't aborted. dlCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if err := share.ReceiveSideChannel(dlCtx, remoteIP, port, size, tmpPath, p.tlsConfig, nil, p.logger); err != nil { + if err := share.ReceiveSideChannel(dlCtx, remoteIP, port, size, tmpPath, p.tlsConfig, cert.PinnedFingerprint(dev.PeerCert()), nil, p.logger); err != nil { p.logger.Warn("mpris: album art transfer failed", zap.Error(err)) return } @@ -598,15 +611,27 @@ func (p *MPRISPlugin) receiveAlbumArt(_ context.Context, dev device.Sender, play return } + p.stampAlbumArt(dev.ID(), player, artUrl, fileURL) +} + +// stampAlbumArt publishes a resolved file:// URL for a completed album-art +// fetch — but only if the device's current track still wants exactly this +// art. The side-channel download can take up to 30s, during which the track +// (or the active player) may have changed; stamping blindly would show the +// old track's cover on the new track. On mismatch the bytes stay in the art +// cache and the new track's own art request fulfills it. +func (p *MPRISPlugin) stampAlbumArt(deviceID, player, artUrl, fileURL string) { p.mu.Lock() - state := p.remoteStates[dev.ID()] - if state != nil { - state = state.DeepCopy() - state.AlbumArtUrl = fileURL + state := p.remoteStates[deviceID] + if state == nil || state.AlbumArtUrl != artUrl || state.Player != player { + p.mu.Unlock() + return } + state = state.DeepCopy() + state.AlbumArtUrl = fileURL p.mu.Unlock() - if state != nil && p.bus != nil { - p.bus.Publish(events.TypeMprisUpdate, dev.ID(), state) + if p.bus != nil { + p.bus.Publish(events.TypeMprisUpdate, deviceID, state) } } @@ -842,6 +867,16 @@ func (p *MPRISPlugin) RequestState(dev device.Sender, player string) error { } // RemoteState returns the last known NowPlaying state for a remote device, +// markArtPending empties unloadable kdeconnect:/ art URIs and flags them, +// so serving paths (status, snapshot, summaries) agree with published +// events: art is either a loadable URL or "" with ArtPending set. +func markArtPending(np *NowPlaying) { + if np.AlbumArtUrl != "" && strings.HasPrefix(np.AlbumArtUrl, "kdeconnect:") { + np.AlbumArtUrl = "" + np.ArtPending = true + } +} + // with position extrapolated from the last update time if playing. func (p *MPRISPlugin) RemoteState(deviceID string) *NowPlaying { p.mu.RLock() @@ -852,6 +887,7 @@ func (p *MPRISPlugin) RemoteState(deviceID string) *NowPlaying { } copy := state.DeepCopy() copy.AlbumArtUrl = p.resolveArtURL(copy.AlbumArtUrl) + markArtPending(copy) if tracker, ok := p.positionTrackers[deviceID]; ok && tracker.playing { elapsed := time.Since(tracker.lastPositionAt).Milliseconds() copy.Pos = tracker.lastPosition + elapsed @@ -883,6 +919,7 @@ func (p *MPRISPlugin) RemoteStates() map[string]*NowPlaying { } copy := state.DeepCopy() copy.AlbumArtUrl = p.resolveArtURL(copy.AlbumArtUrl) + markArtPending(copy) if tracker, ok := p.positionTrackers[id]; ok && tracker.playing { elapsed := time.Since(tracker.lastPositionAt).Milliseconds() copy.Pos = tracker.lastPosition + elapsed diff --git a/internal/plugins/mpris/mpris_test.go b/internal/plugins/mpris/mpris_test.go index 0c00d1c..7b7d284 100644 --- a/internal/plugins/mpris/mpris_test.go +++ b/internal/plugins/mpris/mpris_test.go @@ -207,6 +207,84 @@ func TestHandleIgnoresEmptyAlbumArtPayload(t *testing.T) { time.Sleep(50 * time.Millisecond) // let any goroutine finish } +func TestStampAlbumArtMatchesCurrentTrack(t *testing.T) { + bus := events.NewBus(zap.NewNop()) + sub := bus.Subscribe(4, events.TypeMprisUpdate) + defer sub.Close() + + plugin := NewMPRISPlugin(nil, bus, false, zap.NewNop()) + if plugin.watchCancel != nil { + defer plugin.watchCancel() + } + + const urlA = "kdeconnect:/artUri?title=Song+A&kdeArtHash=111" + const urlB = "kdeconnect:/artUri?title=Song+B&kdeArtHash=222" + + seed := func(player, title, art string) { + plugin.mu.Lock() + plugin.remoteStates["dev-art-race"] = &NowPlaying{Player: player, Title: title, AlbumArtUrl: art} + plugin.mu.Unlock() + } + storedArt := func() string { + plugin.mu.Lock() + defer plugin.mu.Unlock() + return plugin.remoteStates["dev-art-race"].AlbumArtUrl + } + + // Positive: art arrives for the track still current — publish file:// + // against that track's metadata. + seed("Spotify", "Song A", urlA) + plugin.stampAlbumArt("dev-art-race", "Spotify", urlA, "file:///cache/a.jpg") + select { + case ev := <-sub.C: + got, ok := ev.Payload.(*NowPlaying) + if !ok { + t.Fatalf("expected *NowPlaying payload, got %T", ev.Payload) + } + if got.Title != "Song A" || got.AlbumArtUrl != "file:///cache/a.jpg" { + t.Fatalf("wrong attribution: %+v", got) + } + case <-time.After(time.Second): + t.Fatal("expected mpris update for matching art") + } + if got := storedArt(); got != urlA { + t.Fatalf("stored state must keep the kdeconnect: URL, got %q", got) + } + + // Negative: track advanced to B while A's fetch was in flight — drop + // A's bytes (the cache keeps them) and publish nothing. + seed("Spotify", "Song B", urlB) + plugin.stampAlbumArt("dev-art-race", "Spotify", urlA, "file:///cache/a.jpg") + select { + case ev := <-sub.C: + t.Fatalf("stale art must not publish, got %#v", ev) + case <-time.After(50 * time.Millisecond): + } + if got := storedArt(); got != urlB { + t.Fatalf("B's art URL must be untouched, got %q", got) + } + + // Negative: same art URL but a different player took over — drop. + seed("YouTube", "Song A", urlA) + plugin.stampAlbumArt("dev-art-race", "Spotify", urlA, "file:///cache/a.jpg") + select { + case ev := <-sub.C: + t.Fatalf("other-player art must not publish, got %#v", ev) + case <-time.After(50 * time.Millisecond): + } + + // Negative: no state at all — drop without panicking. + plugin.mu.Lock() + delete(plugin.remoteStates, "dev-art-race") + plugin.mu.Unlock() + plugin.stampAlbumArt("dev-art-race", "Spotify", urlA, "file:///cache/a.jpg") + select { + case ev := <-sub.C: + t.Fatalf("art with no state must not publish, got %#v", ev) + case <-time.After(50 * time.Millisecond): + } +} + func TestPollRemoteStatesOnlyTargetsKnownPlayers(t *testing.T) { plugin := NewMPRISPlugin(nil, events.NewBus(zap.NewNop()), false, zap.NewNop()) if plugin.watchCancel != nil { @@ -374,3 +452,54 @@ func TestHandleKeepsListedPlayer(t *testing.T) { } expectNoEvent(t, sub) // no spurious empty update } + +func TestPublishedEventHasAnchorAndPendingArt(t *testing.T) { + bus := events.NewBus(zap.NewNop()) + sub := bus.Subscribe(4, events.TypeMprisUpdate) + defer sub.Close() + + plugin := NewMPRISPlugin(nil, bus, false, zap.NewNop()) + if plugin.watchCancel != nil { + defer plugin.watchCancel() + } + dev := testSender{id: "device-anchor"} + + before := time.Now().UnixMilli() + state := MPRISRequest{ + Player: "Metrolist", + Title: "Anchor Test", + Pos: 42000, + IsPlaying: true, + PlaybackStatus: "Playing", + AlbumArtUrl: "kdeconnect:/artUri?title=Anchor+Test&kdeArtHash=424242", + } + if err := plugin.Handle(context.Background(), dev, newMPRISPacket(t, state)); err != nil { + t.Fatal(err) + } + + var ev events.Event + select { + case ev = <-sub.C: + case <-time.After(time.Second): + t.Fatal("expected event") + } + pub, ok := ev.Payload.(*NowPlaying) + if !ok { + t.Fatalf("expected *NowPlaying payload, got %T", ev.Payload) + } + if pub.PosAnchorMs < before { + t.Errorf("PosAnchorMs %d older than push start %d", pub.PosAnchorMs, before) + } + if pub.AlbumArtUrl != "" || !pub.ArtPending { + t.Errorf("expected empty URL with ArtPending, got url=%q pending=%v", pub.AlbumArtUrl, pub.ArtPending) + } + + // Serving paths agree with the published event. + rs := plugin.RemoteState(dev.ID()) + if rs == nil || rs.AlbumArtUrl != "" || !rs.ArtPending { + t.Errorf("RemoteState should hide unresolved art, got %+v", rs) + } + if rs.PosAnchorMs != pub.PosAnchorMs { + t.Errorf("anchor mismatch: published %d, served %d", pub.PosAnchorMs, rs.PosAnchorMs) + } +} diff --git a/internal/plugins/notification/notification.go b/internal/plugins/notification/notification.go index 991481f..a510634 100644 --- a/internal/plugins/notification/notification.go +++ b/internal/plugins/notification/notification.go @@ -16,6 +16,7 @@ import ( "sync" "time" + "github.com/bethropolis/kcd/internal/cert" "github.com/bethropolis/kcd/internal/config" "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/events" @@ -229,12 +230,15 @@ func (p *NotificationPlugin) Handle(ctx context.Context, dev device.Sender, pkt payloadSize = pkt.PayloadSize payloadPort int remoteIP net.IP + expectedFP string ) if hasIcon { payloadPort = pkt.PayloadTransferInfo.Port remoteIP = dev.RemoteIP() if remoteIP == nil { hasIcon = false + } else { + expectedFP = cert.PinnedFingerprint(dev.PeerCert()) } } @@ -242,7 +246,7 @@ func (p *NotificationPlugin) Handle(ctx context.Context, dev device.Sender, pkt go func() { var iconPath string if p.cfg.ShowIcons { - iconPath = p.fetchIcon(ctx, appName, body.ID, remoteIP, payloadPort, payloadSize, hasIcon) + iconPath = p.fetchIcon(ctx, appName, body.ID, remoteIP, payloadPort, payloadSize, hasIcon, expectedFP) } p.sendDesktopNotification(dev.ID(), appName, body.ID, body.Title, text, iconPath) }() @@ -250,6 +254,20 @@ func (p *NotificationPlugin) Handle(ctx context.Context, dev device.Sender, pkt return nil } +// notifIDChars restricts phone-provided notification IDs to filename-safe +// characters when they're embedded in icon cache paths. +var notifIDChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +// sanitizeNotifID strips everything but alphanumerics, dot, underscore and +// hyphen from a notification ID so it can't escape the icon cache directory +// via path separators. Empty results fall back to "default". +func sanitizeNotifID(id string) string { + if safe := notifIDChars.ReplaceAllString(id, "_"); safe != "" { + return safe + } + return "default" +} + // notifKey scopes a notification id to its device so two paired phones with // colliding Android notification keys don't replace each other's popups. func (p *NotificationPlugin) notifKey(devID, id string) string { @@ -265,6 +283,7 @@ func (p *NotificationPlugin) fetchIcon( port int, size int64, hasIcon bool, + expectedFP string, ) string { if !p.cfg.FetchIcons || p.tlsConfig == nil || p.iconDir == "" { // Fall back to icon name derived from app name. @@ -273,8 +292,19 @@ func (p *NotificationPlugin) fetchIcon( // Use the notification ID as the filename so the same app reuses the // cached icon rather than downloading it on every notification. + // The ID comes from the phone, so restrict it to filename-safe chars + // (no separators) — otherwise ../ in an ID would escape the icon dir. safeName := nonAlphaNumeric.ReplaceAllString(appName, "_") - iconPath := filepath.Join(p.iconDir, fmt.Sprintf("%s-%s.png", safeName, notifID)) + safeID := sanitizeNotifID(notifID) + iconPath := filepath.Join(p.iconDir, fmt.Sprintf("%s-%s.png", safeName, safeID)) + + // Belt and braces: confine the result to the icon dir even if the + // sanitizer above ever regresses. + if rel, err := filepath.Rel(p.iconDir, iconPath); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + p.logger.Warn("notification: icon path escapes cache dir, refusing", + zap.String("id", notifID)) + return "" + } // Reuse the cached icon even when the phone re-posts the notification // without an icon payload (Android only sends the bytes when the icon @@ -300,6 +330,16 @@ func (p *NotificationPlugin) fetchIcon( } defer conn.Close() + if tlsConn, ok := conn.(*tls.Conn); !ok { + p.logger.Debug("notification: icon connection is not TLS") + return "" + } else if expectedFP == "" { + p.logger.Warn("notification: no pinned peer fingerprint, skipping icon verification") + } else if err := cert.VerifySideChannelPeer(tlsConn.ConnectionState(), expectedFP); err != nil { + p.logger.Warn("notification: icon peer verification failed, refusing download", zap.Error(err)) + return "" + } + f, err := os.Create(iconPath) if err != nil { return "" @@ -377,9 +417,12 @@ func (p *NotificationPlugin) sendDesktopNotification(devID, appName, id, title, } } } - args = append(args, "--print-id", title, text) + // "--" ends option parsing so a phone-provided title/body starting with + // '-' can't be misparsed as a notify-send flag (notify-send uses + // GOption, which honors the POSIX separator). + args = append(args, "--print-id", "--", title, text) } else { - args = append(args, title, text) + args = append(args, "--", title, text) } out, err := p.newExec(context.Background(), "notify-send", args...).Output() diff --git a/internal/plugins/notification/notification_test.go b/internal/plugins/notification/notification_test.go index bcf81e9..8f66d11 100644 --- a/internal/plugins/notification/notification_test.go +++ b/internal/plugins/notification/notification_test.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" "strconv" + "strings" "sync" "testing" "time" @@ -111,6 +112,8 @@ func (f *fakeNotifier) command(name string, args ...string) *exec.Cmd { } func (f *fakeNotifier) argFor(call int, flag string) string { + f.mu.Lock() + defer f.mu.Unlock() for i, a := range f.calls[call] { if a == flag { if i+1 < len(f.calls[call]) { @@ -121,6 +124,19 @@ func (f *fakeNotifier) argFor(call int, flag string) string { return "" } +// lastCall returns a copy of the most recent recorded invocation, or nil +// if none has arrived yet. The plugin invokes notify-send from a goroutine +// (Handle must return immediately), so tests must poll this under the lock +// instead of reading f.calls directly. +func (f *fakeNotifier) lastCall() []string { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.calls) == 0 { + return nil + } + return append([]string(nil), f.calls[len(f.calls)-1]...) +} + func newFakePlugin(t *testing.T, replace bool) (*NotificationPlugin, *fakeNotifier) { t.Helper() logger := zaptest.NewLogger(t) @@ -206,26 +222,103 @@ func TestNotificationPlugin_FetchIconReusesCacheOnRepost(t *testing.T) { id := "0|com.arn.scrobble|0|com.msob7y.namida|10247" // Seed the cached icon for this (app, id) — simulates the first post - // having downloaded the phone's icon. + // having downloaded the phone's icon. The seed uses the same + // sanitization as fetchIcon. cachedPath := filepath.Join(p.iconDir, fmt.Sprintf("%s-%s.png", - nonAlphaNumeric.ReplaceAllString(app, "_"), id)) + nonAlphaNumeric.ReplaceAllString(app, "_"), sanitizeNotifID(id))) if err := os.WriteFile(cachedPath, []byte("png"), 0o600); err != nil { t.Fatal(err) } // Re-post without an icon payload must reuse the cached file so the // popup keeps the real app icon instead of a placeholder. - got := p.fetchIcon(context.Background(), app, id, nil, 0, 0, false) + got := p.fetchIcon(context.Background(), app, id, nil, 0, 0, false, "") if got != cachedPath { t.Fatalf("expected cached icon reuse %q, got %q", cachedPath, got) } // Unknown id, no payload, no cache → empty (theme fallback downstream). - if got := p.fetchIcon(context.Background(), app, "unknown-id", nil, 0, 0, false); got != "" { + if got := p.fetchIcon(context.Background(), app, "unknown-id", nil, 0, 0, false, ""); got != "" { t.Fatalf("expected empty icon for uncached payload-less repost, got %q", got) } } +func TestSanitizeNotifID(t *testing.T) { + cases := []struct{ in, want string }{ + {"0|com.arn.scrobble|10247", "0_com.arn.scrobble_10247"}, + {"plain-id_1.2", "plain-id_1.2"}, + {"../../../etc/cron.d/job", ".._.._.._etc_cron.d_job"}, + {"/abs/path", "_abs_path"}, + {"a/b\\c", "a_b_c"}, + {"", "default"}, + } + for _, tc := range cases { + if got := sanitizeNotifID(tc.in); got != tc.want { + t.Errorf("sanitizeNotifID(%q) = %q, want %q", tc.in, got, tc.want) + } + if strings.ContainsAny(sanitizeNotifID(tc.in), `/\`) { + t.Errorf("sanitizeNotifID(%q) keeps separators", tc.in) + } + } +} + +func TestFetchIconRefusesTraversal(t *testing.T) { + p := newPlugin(t) + p.tlsConfig = &tls.Config{} + p.iconDir = t.TempDir() + + // A traversal ID must resolve inside the icon dir. With no payload and + // no cache it returns "" — assert nothing escapes to the parent. + canary := filepath.Join(filepath.Dir(p.iconDir), "kcd-traversal-canary.png") + _ = os.Remove(canary) + traversal := "../" + filepath.Base(canary) + if got := p.fetchIcon(context.Background(), "App", traversal, nil, 0, 0, false, ""); got != "" { + t.Fatalf("expected empty icon for traversal id, got %q", got) + } + if _, err := os.Stat(canary); !os.IsNotExist(err) { + t.Fatalf("traversal escaped icon dir: %s exists (%v)", canary, err) + } +} + +func TestNotifySendEndsOptions(t *testing.T) { + p, f := newFakePlugin(t, true) + dev := device.NewDevice("dev1", "Test", "phone", zaptest.NewLogger(t)) + + // A title starting with '-' must be passed after "--" so notify-send + // can't misparse it as a flag. + body := NotificationBody{ID: "dash", AppName: "App", Title: "-u", Text: "-t evil"} + pkt, _ := protocol.NewPacket("kdeconnect.notification", body) + if err := p.Handle(context.Background(), dev, pkt); err != nil { + t.Fatal(err) + } + time.Sleep(100 * time.Millisecond) // sendDesktopNotification runs async + var call []string + waitFor(t, "notify-send invocation", func() bool { + call = f.lastCall() + return call != nil + }) + sep := -1 + for i, a := range call { + if a == "--" { + sep = i + } + } + if sep < 0 { + t.Fatalf("expected -- separator in notify-send args: %q", call) + } + for _, want := range []string{"-u", "-t evil"} { + found := false + for _, a := range call[sep+1:] { + if a == want { + found = true + } + } + if !found { + t.Errorf("expected %q after -- in %q", want, call) + } + } +} + func TestNotificationPlugin_ShowIconsGating(t *testing.T) { dev := device.NewDevice("dev1", "Test", "phone", zaptest.NewLogger(t)) diff --git a/internal/plugins/pair/pair.go b/internal/plugins/pair/pair.go index 633d55d..dee2cff 100644 --- a/internal/plugins/pair/pair.go +++ b/internal/plugins/pair/pair.go @@ -173,18 +173,24 @@ func (p *PairPlugin) handleUnpairRequest(_ context.Context, dev *device.Device) // We requested, they rejected p.logger.Info("pair request rejected by peer", zap.String("device_id", dev.ID())) dev.SetState(device.StateUnpaired) + dev.ClearEphemeral() + dev.ClearPairDial() p.emit(events.TypePairRejected, dev, "") case device.StatePairRequestedByPeer: // They requested, then cancelled p.logger.Info("pair request cancelled by peer", zap.String("device_id", dev.ID())) dev.SetState(device.StateUnpaired) + dev.ClearEphemeral() + dev.ClearPairDial() p.emit(events.TypePairRejected, dev, "") case device.StatePaired: // Unpair request p.logger.Info("unpair request received", zap.String("device_id", dev.ID())) dev.SetState(device.StateUnpaired) + dev.ClearEphemeral() + dev.ClearPairDial() case device.StateUnpaired, device.StateUnknown: // Already unpaired, ignore @@ -213,6 +219,8 @@ func (p *PairPlugin) AcceptPairing(dev *device.Device) error { if err := dev.Send(pkt); err != nil { p.logger.Error("failed to send pair accept", zap.Error(err)) dev.SetState(device.StateUnpaired) + dev.ClearEphemeral() + dev.ClearPairDial() return err } @@ -277,6 +285,8 @@ func (p *PairPlugin) RejectPairing(dev *device.Device) error { dev.Send(pkt) // best effort dev.SetState(device.StateUnpaired) + dev.ClearEphemeral() + dev.ClearPairDial() p.mu.Lock() delete(p.pairingTimestamp, dev.ID()) @@ -299,6 +309,8 @@ func (p *PairPlugin) Unpair(dev *device.Device) error { dev.Send(pkt) // best effort dev.SetState(device.StateUnpaired) + dev.ClearEphemeral() + dev.ClearPairDial() p.mu.Lock() delete(p.pairingTimestamp, dev.ID()) @@ -314,6 +326,7 @@ func (p *PairPlugin) Unpair(dev *device.Device) error { func (p *PairPlugin) pairingDone(dev *device.Device) { dev.SetState(device.StatePaired) + dev.ClearPairDial() p.mu.Lock() delete(p.pairingTimestamp, dev.ID()) diff --git a/internal/plugins/sftp/sftp.go b/internal/plugins/sftp/sftp.go index 3080ce7..cee18b7 100644 --- a/internal/plugins/sftp/sftp.go +++ b/internal/plugins/sftp/sftp.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "fmt" + "net" "os" "os/exec" "path/filepath" + "regexp" "strconv" "strings" "sync" @@ -269,6 +271,68 @@ func (p *SftpPlugin) MountLocally(ctx context.Context, deviceID string) (string, return p.mountWithBody(ctx, deviceID, body, "") } +// sshUserPattern allows the generated Android SFTP usernames (alphanumerics, +// underscore, dot, hyphen) while rejecting anything starting with '-' — +// sshfs would parse that as an option flag (e.g. -oProxyCommand=...), +// yielding local command execution. +var sshUserPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]*$`) + +// sshHostPattern allows IPs (validated separately) and plain hostnames +// (.local, LAN names). Anything else — flags, spaces, shell metachars, +// userinfo (@) — is rejected. +var sshHostPattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$`) + +// buildSSHFSArgs validates the phone-provided (or IPC-provided) remote +// parameters and constructs the sshfs argv. Validation (not a "--" +// separator — unsupported by older sshfs 2.x) is what prevents option +// injection: no validated value can begin with '-', so sshfs/fuse option +// parsing can never reinterpret remoteRoot as a flag like -oProxyCommand. +func buildSSHFSArgs(body SftpBody, remotePath, mountPoint string, uid, gid int, keepaliveInterval, keepaliveCount int, extraOpts []string) ([]string, error) { + if !sshUserPattern.MatchString(body.User) || len(body.User) > 64 { + return nil, fmt.Errorf("sftp: refusing suspicious ssh user %q", body.User) + } + if body.IP == "" || strings.HasPrefix(body.IP, "-") { + return nil, fmt.Errorf("sftp: refusing suspicious ssh host %q", body.IP) + } + if net.ParseIP(body.IP) == nil && (!sshHostPattern.MatchString(body.IP) || len(body.IP) > 253) { + return nil, fmt.Errorf("sftp: refusing invalid ssh host %q", body.IP) + } + port, err := strconv.Atoi(strings.TrimSpace(body.Port.String())) + if err != nil || port < 1 || port > 65535 { + return nil, fmt.Errorf("sftp: refusing invalid ssh port %q", body.Port.String()) + } + if remotePath == "" || strings.HasPrefix(remotePath, "-") { + return nil, fmt.Errorf("sftp: refusing suspicious remote path %q", remotePath) + } + remotePath = filepath.Clean(remotePath) + remoteRoot := fmt.Sprintf("%s@%s:%s", body.User, body.IP, remotePath) + + args := []string{ + remoteRoot, + mountPoint, + "-p", strconv.Itoa(port), + "-s", + "-F", "/dev/null", + "-o", "password_stdin", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "reconnect", + "-o", "ServerAliveInterval=" + strconv.Itoa(keepaliveInterval), + "-o", "ServerAliveCountMax=" + strconv.Itoa(keepaliveCount), + "-o", "auto_cache", + "-o", "kernel_cache", + "-o", "uid=" + strconv.Itoa(uid), + "-o", "gid=" + strconv.Itoa(gid), + } + + // ExtraSshfsOpts comes from the local operator config, not the phone — + // passed through as-is. + for _, opt := range extraOpts { + args = append(args, "-o", opt) + } + return args, nil +} + // mountWithBody performs the sshfs mount and returns the local browse path. // volumePath specifies which storage volume to mount. If empty, the first // available volume is selected automatically. @@ -297,30 +361,10 @@ func (p *SftpPlugin) mountWithBody(ctx context.Context, deviceID string, body Sf remotePath = body.Path } } - remoteRoot := fmt.Sprintf("%s@%s:%s", body.User, body.IP, remotePath) - - args := []string{ - remoteRoot, - mountPoint, - "-p", body.Port.String(), - "-s", - "-F", "/dev/null", - "-o", "password_stdin", - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "reconnect", - "-o", "ServerAliveInterval=" + strconv.Itoa(p.cfg.KeepaliveIntervalSecs), - "-o", "ServerAliveCountMax=" + strconv.Itoa(p.cfg.KeepaliveCount), - "-o", "auto_cache", - "-o", "kernel_cache", - "-o", "uid=" + strconv.Itoa(os.Getuid()), - "-o", "gid=" + strconv.Itoa(os.Getgid()), - } - - if len(p.cfg.ExtraSshfsOpts) > 0 { - for _, opt := range p.cfg.ExtraSshfsOpts { - args = append(args, "-o", opt) - } + args, err := buildSSHFSArgs(body, remotePath, mountPoint, os.Getuid(), os.Getgid(), p.cfg.KeepaliveIntervalSecs, p.cfg.KeepaliveCount, p.cfg.ExtraSshfsOpts) + if err != nil { + _ = os.Remove(mountPoint) + return "", err } cmd := exec.CommandContext(ctx, "sshfs", args...) diff --git a/internal/plugins/sftp/sftp_test.go b/internal/plugins/sftp/sftp_test.go index f514d76..c581ef8 100644 --- a/internal/plugins/sftp/sftp_test.go +++ b/internal/plugins/sftp/sftp_test.go @@ -91,3 +91,73 @@ func TestRemoteRoot_PathIsSlash(t *testing.T) { t.Errorf("remoteRoot = %q, want %q", got, want) } } + +func validBody() SftpBody { + return SftpBody{ + IP: "192.168.1.42", + Port: "1776", + User: "u0_a123", + Password: "sekret", + Path: "/storage/emulated/0", + } +} + +func TestBuildSSHFSArgs_Valid(t *testing.T) { + args, err := buildSSHFSArgs(validBody(), "/storage/emulated/0", "/mnt/kcd", 1000, 1000, 15, 3, nil) + if err != nil { + t.Fatalf("buildSSHFSArgs returned error: %v", err) + } + if len(args) < 3 || args[0] != "u0_a123@192.168.1.42:/storage/emulated/0" || args[1] != "/mnt/kcd" { + t.Fatalf("unexpected remote/mount args: %q", args) + } + for i, a := range args { + if i > 1 && a == "1776" { + break + } + if i == len(args)-1 { + t.Errorf("validated port missing from args: %q", args) + } + } + for _, a := range args { + if a == "-oProxyCommand=x" { + t.Errorf("injected option leaked into args: %q", args) + } + } +} + +func TestBuildSSHFSArgs_RejectsInjection(t *testing.T) { + cases := []struct { + name string + body SftpBody + path string + }{ + {"user flag", SftpBody{IP: "192.168.1.42", Port: "22", User: "-oProxyCommand=evil", Path: "/x"}, "/x"}, + {"user lone dash", SftpBody{IP: "192.168.1.42", Port: "22", User: "-", Path: "/x"}, "/x"}, + {"empty user", SftpBody{IP: "192.168.1.42", Port: "22", Path: "/x"}, "/x"}, + {"host flag", SftpBody{IP: "-oFoo", Port: "22", User: "u", Path: "/x"}, "/x"}, + {"host spaces", SftpBody{IP: "a b", Port: "22", User: "u", Path: "/x"}, "/x"}, + {"host at", SftpBody{IP: "a@b", Port: "22", User: "u", Path: "/x"}, "/x"}, + {"port zero", SftpBody{IP: "192.168.1.42", Port: "0", User: "u", Path: "/x"}, "/x"}, + {"port huge", SftpBody{IP: "192.168.1.42", Port: "99999", User: "u", Path: "/x"}, "/x"}, + {"port alpha", SftpBody{IP: "192.168.1.42", Port: "abc", User: "u", Path: "/x"}, "/x"}, + {"path flag", SftpBody{IP: "192.168.1.42", Port: "22", User: "u", Path: "/x"}, "-oFoo"}, + {"empty path", SftpBody{IP: "192.168.1.42", Port: "22", User: "u", Path: "/x"}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := buildSSHFSArgs(tc.body, tc.path, "/mnt/kcd", 1000, 1000, 15, 3, nil); err == nil { + t.Errorf("buildSSHFSArgs accepted %q / %q, want error", tc.body, tc.path) + } + }) + } +} + +func TestBuildSSHFSArgs_Hostnames(t *testing.T) { + for _, host := range []string{"phone.local", "android-1", "192.168.1.42", "::1"} { + body := validBody() + body.IP = host + if _, err := buildSSHFSArgs(body, "/x", "/mnt/kcd", 1000, 1000, 15, 3, nil); err != nil { + t.Errorf("buildSSHFSArgs(%q) = %v, want nil", host, err) + } + } +} diff --git a/internal/plugins/share/sanitize.go b/internal/plugins/share/sanitize.go index 5ed8eff..adb2864 100644 --- a/internal/plugins/share/sanitize.go +++ b/internal/plugins/share/sanitize.go @@ -2,6 +2,7 @@ package share import ( "fmt" + "net/url" "os" "path/filepath" "strings" @@ -28,6 +29,39 @@ func SanitizeFilename(name string) string { return name } +// isOpenableURL reports whether a phone-shared URL may be handed to +// xdg-open. Only http(s) with a host qualifies: other schemes (file://, +// smb:, mailto:, custom app handlers) would dispatch phone-influenced input +// to unrelated local programs. +func isOpenableURL(raw string) bool { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return false + } + if u.Scheme != "http" && u.Scheme != "https" { + return false + } + return u.Host != "" +} + +// blockedAutoOpenExts are file types that must never be auto-opened after +// download. Handing them to the desktop handler can execute code (notably +// .desktop files, which most environments treat as launchers). +var blockedAutoOpenExts = map[string]struct{}{ + ".desktop": {}, ".sh": {}, ".bin": {}, ".run": {}, ".jar": {}, + ".py": {}, ".pl": {}, ".rb": {}, ".php": {}, ".js": {}, + ".exe": {}, ".msi": {}, ".bat": {}, ".cmd": {}, ".com": {}, + ".scr": {}, ".ps1": {}, ".vbs": {}, ".vbe": {}, ".jse": {}, + ".wsf": {}, ".wsh": {}, ".hta": {}, ".lnk": {}, ".gadget": {}, +} + +// autoOpenBlocked reports whether a downloaded file must be excluded from +// auto-open because of its extension. +func autoOpenBlocked(path string) bool { + _, blocked := blockedAutoOpenExts[strings.ToLower(filepath.Ext(path))] + return blocked +} + // EnsureUnique finds a non-conflicting filename in the destination directory. func EnsureUnique(dir, name string) (string, error) { ext := filepath.Ext(name) diff --git a/internal/plugins/share/sanitize_test.go b/internal/plugins/share/sanitize_test.go index d471f92..7239a03 100644 --- a/internal/plugins/share/sanitize_test.go +++ b/internal/plugins/share/sanitize_test.go @@ -28,6 +28,61 @@ func TestSanitizeFilename(t *testing.T) { } } +func TestIsOpenableURL(t *testing.T) { + open := []string{ + "https://example.com", + "http://example.com/path?q=1", + "HTTPS://EXAMPLE.COM", + " https://example.com/pad ", + } + for _, u := range open { + if !isOpenableURL(u) { + t.Errorf("isOpenableURL(%q) = false, want true", u) + } + } + blocked := []string{ + "", + "file:///etc/passwd", + "ftp://example.com/x", + "smb://server/share", + "mailto:foo@example.com", + "javascript:alert(1)", + "data:text/html,hi", + "kdeconnect:/something", + "myapp://action", + "http://", + "https://", + "example.com/no-scheme", + "/just/a/path", + "http:noslashes", + } + for _, u := range blocked { + if isOpenableURL(u) { + t.Errorf("isOpenableURL(%q) = true, want false", u) + } + } +} + +func TestAutoOpenBlocked(t *testing.T) { + blocked := []string{ + "evil.desktop", "run.sh", "payload.bin", "setup.run", "app.jar", + "x.py", "X.PY", "s.pl", "r.rb", "p.php", "a.exe", "b.msi", + "c.bat", "d.cmd", "e.com", "f.scr", "g.ps1", "h.vbs", "i.lnk", + "EVIL.Desktop", + } + for _, n := range blocked { + if !autoOpenBlocked(n) { + t.Errorf("autoOpenBlocked(%q) = false, want true", n) + } + } + allowed := []string{"photo.jpg", "song.mp3", "doc.pdf", "movie.mp4", "notes.txt", "archive.zip", "noext"} + for _, n := range allowed { + if autoOpenBlocked(n) { + t.Errorf("autoOpenBlocked(%q) = true, want false", n) + } + } +} + func TestEnsureUnique(t *testing.T) { dir := t.TempDir() name := "test.txt" diff --git a/internal/plugins/share/share.go b/internal/plugins/share/share.go index 00ad040..9b793a2 100644 --- a/internal/plugins/share/share.go +++ b/internal/plugins/share/share.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "github.com/bethropolis/kcd/internal/cert" "github.com/bethropolis/kcd/internal/config" "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/events" @@ -134,7 +135,16 @@ func (p *SharePlugin) Handle(ctx context.Context, dev device.Sender, pkt *protoc if p.bus != nil { p.bus.Publish(events.TypeShareURL, dev.ID(), map[string]string{"url": body.Url}) } - plugin.RunCommandAsync(p.Logger, "xdg-open", body.Url) + // xdg-open dispatches on URI scheme to arbitrary desktop handlers, + // so only http(s) may reach it: file://, smb:, mailto: and custom + // app schemes would hand phone-influenced input to unrelated local + // handlers. The URL event above still reaches clients either way. + if isOpenableURL(body.Url) { + plugin.RunCommandAsync(p.Logger, "xdg-open", body.Url) + } else { + p.Logger.Warn("share: refusing to open non-http(s) URL", + zap.String("url", body.Url)) + } return nil } @@ -163,6 +173,7 @@ func (p *SharePlugin) Handle(ctx context.Context, dev device.Sender, pkt *protoc payloadSize := pkt.PayloadSize payloadPort := pkt.PayloadTransferInfo.Port + expectedFP := cert.PinnedFingerprint(dev.PeerCert()) go func() { defer debug.FreeOSMemory() @@ -173,7 +184,7 @@ func (p *SharePlugin) Handle(ctx context.Context, dev device.Sender, pkt *protoc onProgress = throttle.Update } - err := ReceiveSideChannel(context.Background(), remoteIP, payloadPort, payloadSize, destPath, p.TLSConfig, onProgress, p.Logger) + err := ReceiveSideChannel(context.Background(), remoteIP, payloadPort, payloadSize, destPath, p.TLSConfig, expectedFP, onProgress, p.Logger) if err != nil { p.Logger.Error("share receive failed", zap.Error(err)) if p.bus != nil { @@ -198,15 +209,23 @@ func (p *SharePlugin) Handle(ctx context.Context, dev device.Sender, pkt *protoc }) } if p.cfg.AutoOpen { - cmd := p.cfg.OpenCommand - if cmd == "" { - cmd = "xdg-open" - } - absPath, err := filepath.Abs(destPath) - if err != nil { - absPath = destPath + // Never auto-open executable content: handing .desktop files + // (or scripts) to the desktop handler can execute code. The + // file itself is still saved and announced — open it manually. + if autoOpenBlocked(destPath) { + p.Logger.Warn("share: refusing to auto-open executable file", + zap.String("file", destPath)) + } else { + cmd := p.cfg.OpenCommand + if cmd == "" { + cmd = "xdg-open" + } + absPath, err := filepath.Abs(destPath) + if err != nil { + absPath = destPath + } + plugin.RunCommandAsync(p.Logger, cmd, absPath) } - plugin.RunCommandAsync(p.Logger, cmd, absPath) } } }() @@ -240,6 +259,7 @@ func (p *SharePlugin) SendFile(ctx context.Context, dev device.Sender, filePath throttle := newProgressThrottle(p.bus, dev.ID(), filepath.Base(filePath), stat.Size()) onProgress = throttle.Update } + expectedFP := cert.PinnedFingerprint(dev.PeerCert()) // Handle the transfer in the background so IPC returns instantly go func() { @@ -249,7 +269,7 @@ func (p *SharePlugin) SendFile(ctx context.Context, dev device.Sender, filePath if timeout == 0 { timeout = 2 * time.Minute } - err := AcceptAndSend(ln, filePath, p.TLSConfig, dev.ID(), timeout, onProgress, p.Logger) + err := AcceptAndSend(ln, filePath, p.TLSConfig, dev.ID(), expectedFP, timeout, onProgress, p.Logger) if err != nil { p.Logger.Error("share: send failed", diff --git a/internal/plugins/share/share_test.go b/internal/plugins/share/share_test.go index ae7e1c5..07d3b55 100644 --- a/internal/plugins/share/share_test.go +++ b/internal/plugins/share/share_test.go @@ -3,9 +3,11 @@ package share import ( "context" "crypto/tls" + "crypto/x509" "net" "os" "path/filepath" + "strings" "testing" "time" @@ -39,6 +41,12 @@ func TestSharePlugin_SideChannelRoundTrip(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + leaf, err := x509.ParseCertificate(tlsCert.Certificate[0]) + if err != nil { + t.Fatalf("failed to parse test cert: %v", err) + } + fp := cert.Fingerprint(leaf) + // 1. Start Sender cfg := config.ShareConfig{} cfg.Defaults() @@ -46,16 +54,28 @@ func TestSharePlugin_SideChannelRoundTrip(t *testing.T) { if err != nil { t.Fatalf("ListenSideChannel failed: %v", err) } + serverDone := make(chan error, 1) go func() { - _ = AcceptAndSend(ln, sourcePath, tlsConfig, "test_device_share", 2*time.Second, func(c, t int64) {}, logger) + serverDone <- AcceptAndSend(ln, sourcePath, tlsConfig, "test_device_share", fp, 2*time.Second, func(c, t int64) {}, logger) }() // 2. Run Receiver (dial loopback) - err = ReceiveSideChannel(ctx, net.ParseIP("127.0.0.1"), port, int64(len(content)), destPath, tlsConfig, nil, logger) + err = ReceiveSideChannel(ctx, net.ParseIP("127.0.0.1"), port, int64(len(content)), destPath, tlsConfig, fp, nil, logger) if err != nil { t.Fatalf("ReceiveSideChannel failed: %v", err) } + // Join the server goroutine before returning: it holds the zaptest + // logger, which panics on use after the test completes. + select { + case err := <-serverDone: + if err != nil { + t.Fatalf("AcceptAndSend failed: %v", err) + } + case <-time.After(15 * time.Second): + t.Fatal("timed out waiting for side-channel server to finish") + } + // 3. Verify got, err := os.ReadFile(destPath) if err != nil { @@ -66,3 +86,65 @@ func TestSharePlugin_SideChannelRoundTrip(t *testing.T) { t.Errorf("content mismatch") } } + +func TestSharePlugin_SideChannelRejectsWrongFingerprint(t *testing.T) { + logger := zaptest.NewLogger(t) + dir := t.TempDir() + sourcePath := filepath.Join(dir, "source.bin") + destPath := filepath.Join(dir, "dest.bin") + + content := []byte("fingerprint mismatch test") + if err := os.WriteFile(sourcePath, content, 0644); err != nil { + t.Fatalf("failed to write source: %v", err) + } + + tlsCert, err := cert.GenerateSelfSigned("test_device_share") + if err != nil { + t.Fatalf("failed to generate cert: %v", err) + } + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{*tlsCert}, + InsecureSkipVerify: true, + ClientAuth: tls.RequireAnyClientCert, + } + leaf, err := x509.ParseCertificate(tlsCert.Certificate[0]) + if err != nil { + t.Fatalf("failed to parse test cert: %v", err) + } + fp := cert.Fingerprint(leaf) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cfg := config.ShareConfig{} + cfg.Defaults() + // Isolate from the round-trip test's ports: both bind from PortMin + // upward, and a lingering listener would otherwise collide. + cfg.PortMin = 1762 + cfg.PortMax = 1764 + ln, port, err := ListenSideChannel(ctx, cfg, tlsConfig) + if err != nil { + t.Fatalf("ListenSideChannel failed: %v", err) + } + serverDone := make(chan error, 1) + go func() { + serverDone <- AcceptAndSend(ln, sourcePath, tlsConfig, "test_device_share", fp, 2*time.Second, nil, logger) + }() + + // Dial with a wrong expected fingerprint: the receiver must refuse + // before writing anything. + err = ReceiveSideChannel(ctx, net.ParseIP("127.0.0.1"), port, int64(len(content)), destPath, tlsConfig, strings.Repeat("0", 64), nil, logger) + if err == nil || !strings.Contains(err.Error(), "verification failed") { + t.Fatalf("expected fingerprint verification failure, got: %v", err) + } + + // Join the server goroutine before returning: it holds the zaptest + // logger, which panics on use after the test completes. The server + // side is expected to fail streaming to the aborted client — any + // outcome is fine; the assertion above is on the receiver side. + select { + case <-serverDone: + case <-time.After(15 * time.Second): + t.Fatal("timed out waiting for side-channel server to finish") + } +} diff --git a/internal/plugins/share/transfer.go b/internal/plugins/share/transfer.go index 43f2fbf..9f35f75 100644 --- a/internal/plugins/share/transfer.go +++ b/internal/plugins/share/transfer.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/bethropolis/kcd/internal/cert" "github.com/bethropolis/kcd/internal/config" "go.uber.org/zap" ) @@ -29,7 +30,7 @@ func (pw *progressWriter) Write(p []byte) (int, error) { return n, nil } -func ReceiveSideChannel(ctx context.Context, ip net.IP, port int, size int64, dest string, tlsConfig *tls.Config, onProgress func(int64, int64), logger *zap.Logger) error { +func ReceiveSideChannel(ctx context.Context, ip net.IP, port int, size int64, dest string, tlsConfig *tls.Config, expectedFP string, onProgress func(int64, int64), logger *zap.Logger) error { if size < 0 { return fmt.Errorf("share: indefinite payload sizes (-1) are not supported") } @@ -49,6 +50,23 @@ func ReceiveSideChannel(ctx context.Context, ip net.IP, port int, size int64, de } defer conn.Close() + // The phone's TLS cert isn't verified against the paired fingerprint by + // default (self-signed), so confirm the peer is the device we expect + // before pulling bytes from it. + if tlsConn, ok := conn.(*tls.Conn); !ok { + return fmt.Errorf("share: side-channel connection is not TLS") + } else if expectedFP == "" { + logger.Warn("share: no pinned peer fingerprint, skipping side-channel verification", + zap.String("remote_addr", addr)) + } else if err := cert.VerifySideChannelPeer(tlsConn.ConnectionState(), expectedFP); err != nil { + logger.Error("share: side-channel peer verification failed", + zap.String("remote_addr", addr), + zap.String("expected_fp", expectedFP), + zap.Error(err), + ) + return fmt.Errorf("share: side-channel peer verification failed: %w", err) + } + f, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { return fmt.Errorf("share: create file %s: %w", dest, err) @@ -91,7 +109,7 @@ func ListenSideChannel(ctx context.Context, cfg config.ShareConfig, tlsConfig *t } // AcceptAndSend waits for the phone to connect, performs the TLS handshake, and streams the file. -func AcceptAndSend(ln net.Listener, filePath string, tlsConfig *tls.Config, expectedDeviceID string, timeout time.Duration, onProgress func(int64, int64), logger *zap.Logger) error { +func AcceptAndSend(ln net.Listener, filePath string, tlsConfig *tls.Config, expectedDeviceID, expectedFP string, timeout time.Duration, onProgress func(int64, int64), logger *zap.Logger) error { defer ln.Close() addr := ln.Addr().String() @@ -163,6 +181,20 @@ func AcceptAndSend(ln net.Listener, filePath string, tlsConfig *tls.Config, expe ) return fmt.Errorf("share: cert CN mismatch: expected device %s, got %s", expectedDeviceID, certCN) } + // The CN check above only confirms the device ID claim; pin the actual + // paired certificate fingerprint so a holder of a different cert minted + // with the same CN can't pull the file. + if expectedFP == "" { + logger.Warn("share: no pinned peer fingerprint, skipping side-channel verification", + zap.String("expected_device", expectedDeviceID)) + } else if err := cert.VerifySideChannelPeer(state, expectedFP); err != nil { + logger.Error("share: side-channel peer verification failed", + zap.String("expected_device", expectedDeviceID), + zap.String("expected_fp", expectedFP), + zap.Error(err), + ) + return fmt.Errorf("share: side-channel peer verification failed: %w", err) + } logger.Info("share: TLS OK, streaming file", zap.String("file", filePath), diff --git a/internal/plugins/sms/sms.go b/internal/plugins/sms/sms.go index afda7f6..75bdeee 100644 --- a/internal/plugins/sms/sms.go +++ b/internal/plugins/sms/sms.go @@ -10,8 +10,11 @@ import ( "os" "path/filepath" "strconv" + "strings" "time" + "unicode" + "github.com/bethropolis/kcd/internal/cert" "github.com/bethropolis/kcd/internal/config" "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/events" @@ -29,6 +32,11 @@ const ( PacketTypeSMSAttachmentFile = "kdeconnect.sms.attachment_file" maxSMSMessages = 1000 // safety limit to prevent OOM from malicious payload + + // maxSMSAttachmentBytes caps a single MMS attachment download (mirrors + // the clipboard 50MB safety limit). Anything larger is refused before + // any bytes hit disk. + maxSMSAttachmentBytes = 50 * 1024 * 1024 ) // SMSPlugin implements SMS sending, receiving, conversation browsing, and MMS @@ -218,9 +226,10 @@ func (p *SMSPlugin) handleAttachmentFile(ctx context.Context, dev device.Sender, port := pkt.PayloadTransferInfo.Port payloadSize := pkt.PayloadSize + expectedFP := cert.PinnedFingerprint(dev.PeerCert()) go func() { - if err := p.receiveAttachment(ctx, remoteIP, port, payloadSize, destPath); err != nil { + if err := p.receiveAttachment(ctx, remoteIP, port, payloadSize, destPath, expectedFP); err != nil { p.logger.Error("sms: attachment download failed", zap.Error(err)) return } @@ -241,8 +250,13 @@ func (p *SMSPlugin) handleAttachmentFile(ctx context.Context, dev device.Sender, } // receiveAttachment connects to the phone's side-channel port and downloads -// the attachment file over TLS. -func (p *SMSPlugin) receiveAttachment(ctx context.Context, ip net.IP, port int, _ int64, destPath string) error { +// the attachment file over TLS. The stream is capped at the declared +// payload size (itself bounded by maxSMSAttachmentBytes) so a malicious +// peer can't fill the disk with an unbounded stream. +func (p *SMSPlugin) receiveAttachment(ctx context.Context, ip net.IP, port int, size int64, destPath string, expectedFP string) error { + if size <= 0 || size > maxSMSAttachmentBytes { + return fmt.Errorf("sms: refusing attachment with invalid size %d (limit %d)", size, maxSMSAttachmentBytes) + } addr := net.JoinHostPort(ip.String(), strconv.Itoa(port)) dialer := &tls.Dialer{ @@ -258,13 +272,22 @@ func (p *SMSPlugin) receiveAttachment(ctx context.Context, ip net.IP, port int, } defer conn.Close() + if tlsConn, ok := conn.(*tls.Conn); !ok { + return fmt.Errorf("sms: attachment side-channel is not TLS") + } else if expectedFP == "" { + p.logger.Warn("sms: no pinned peer fingerprint, skipping side-channel verification", + zap.String("remote_addr", addr)) + } else if err := cert.VerifySideChannelPeer(tlsConn.ConnectionState(), expectedFP); err != nil { + return fmt.Errorf("sms: side-channel peer verification failed: %w", err) + } + f, err := os.Create(destPath) if err != nil { return fmt.Errorf("sms: create attachment file: %w", err) } defer f.Close() - _, err = io.Copy(f, conn) + _, err = io.Copy(f, io.LimitReader(conn, size)) if err != nil { return fmt.Errorf("sms: receive attachment data: %w", err) } @@ -330,11 +353,25 @@ func (p *SMSPlugin) RequestAttachment(dev device.Sender, partID int64, uniqueIde return dev.Send(pkt) } +// maxFilenameLength caps attachment filenames to keep them manageable. +const maxFilenameLength = 128 + // cleanFilename strips path components to prevent directory traversal in -// attachment file paths. +// attachment file paths. Backslashes are normalized first (Windows-style +// paths), control characters dropped, and overlong names truncated. func cleanFilename(name string) string { + name = strings.ReplaceAll(name, "\\", "/") name = filepath.Base(name) - if name == "." || name == ".." || name == "" { + name = strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, name) + if len(name) > maxFilenameLength { + name = strings.ToValidUTF8(name[:maxFilenameLength], "") + } + if name == "." || name == ".." || name == "/" || name == "" { return "downloaded_attachment" } return name diff --git a/internal/plugins/sms/sms_test.go b/internal/plugins/sms/sms_test.go new file mode 100644 index 0000000..796bfa4 --- /dev/null +++ b/internal/plugins/sms/sms_test.go @@ -0,0 +1,43 @@ +package sms + +import ( + "context" + "strings" + "testing" + + "github.com/bethropolis/kcd/internal/config" + "go.uber.org/zap" +) + +func TestCleanFilename(t *testing.T) { + cases := []struct{ in, want string }{ + {"photo.jpg", "photo.jpg"}, + {"../../etc/passwd", "passwd"}, + {"/etc/passwd", "passwd"}, + {`..\..\evil.jpg`, "evil.jpg"}, + {`a\b\c.png`, "c.png"}, + {"", "downloaded_attachment"}, + {".", "downloaded_attachment"}, + {"..", "downloaded_attachment"}, + {"a\x00b.jpg", "ab.jpg"}, + {"a\nb.jpg", "ab.jpg"}, + } + for _, tc := range cases { + if got := cleanFilename(tc.in); got != tc.want { + t.Errorf("cleanFilename(%q) = %q, want %q", tc.in, got, tc.want) + } + } + if got := cleanFilename(strings.Repeat("a", 200) + ".jpg"); len(got) > maxFilenameLength+4 { + t.Errorf("long filename not truncated: %d bytes", len(got)) + } +} + +func TestReceiveAttachmentRejectsBadSize(t *testing.T) { + p := NewSMSPlugin(config.SMSConfig{}, nil, nil, zap.NewNop()) + for _, size := range []int64{0, -1, -999, maxSMSAttachmentBytes + 1} { + err := p.receiveAttachment(context.Background(), nil, 0, size, "/nonexistent/x", "") + if err == nil { + t.Errorf("receiveAttachment(size=%d) = nil, want error", size) + } + } +} diff --git a/internal/protocol/identity.go b/internal/protocol/identity.go index e8b21ec..2a607f7 100644 --- a/internal/protocol/identity.go +++ b/internal/protocol/identity.go @@ -2,6 +2,9 @@ package protocol import ( "regexp" + "strings" + "unicode" + "unicode/utf8" ) // ProtocolVersion is the KDE Connect protocol version we advertise. @@ -11,20 +14,119 @@ const ProtocolVersion = 8 // TypeIdentity is the packet type for identity exchange. const TypeIdentity = "kdeconnect.identity" -var invalidNameChars = regexp.MustCompile(`['";:.!?()\[\]<>]`) +// invalidNameChars strips shell metacharacters and markup that could be +// abused via copy-paste, unquoted shell expansion, or log/terminal rendering. +// Control and format characters (NUL, newlines, ANSI, bidi overrides) are +// dropped separately by stripControlRunes below. +var invalidNameChars = regexp.MustCompile("[`'\";:.!?()\\[\\]<>$&|\\\\*~#%^{}=/]") const MaxDeviceNameLength = 32 // SanitizeDeviceName strips potential terminal injection and XSS characters // and truncates the name to a maximum length per the KDE Connect specification. +// +// Some senders (notably phones with non-ASCII names) transmit the name with +// decimal byte escapes already applied (e.g. "Caf\195\169" instead of +// "Café"). Decode those first so listings show real UTF-8. +// Truncation is rune-aware so multi-byte characters are never split. func SanitizeDeviceName(name string) string { - clean := invalidNameChars.ReplaceAllString(name, "") + clean := DecodeDeviceName(name) + clean = stripControlRunes(clean) + clean = invalidNameChars.ReplaceAllString(clean, "") if len(clean) > MaxDeviceNameLength { - clean = clean[:MaxDeviceNameLength] + clean = truncateRunes(clean, MaxDeviceNameLength) } return clean } +// stripControlRunes drops Unicode control (Cc, incl. NUL, CR, LF, ESC) and +// format (Cf, incl. bidi overrides U+202A-U+202E, U+2066-U+2069) runes. +// These are invisible, break C-backed consumers (NUL truncation), enable +// log/terminal forgery (newlines, ESC sequences), and visual spoofing +// (bidi overrides) — none are legitimate in a display name. +func stripControlRunes(s string) string { + return strings.Map(func(r rune) rune { + if unicode.Is(unicode.Cc, r) || unicode.Is(unicode.Cf, r) { + return -1 + } + return r + }, s) +} + +// ansiEscape matches terminal escape sequences: CSI (ESC [ ... letter), +// OSC (ESC ] ... BEL or ESC \), and lone ESC / two-byte sequences. +var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b\\][^\x07\x1b]*(?:\x07|\x1b\\\\)|\x1b[@-_]") + +// DisplayName renders a stored device name for terminal or notification +// sinks: ANSI escapes are removed and CR/LF/TAB become spaces so a hostile +// name can't forge log lines, break CLI tables, or inject terminal codes. +// Storage and JSON keep the sanitized (not display) form. +func DisplayName(name string) string { + s := ansiEscape.ReplaceAllString(name, "") + return strings.Map(func(r rune) rune { + if r == '\r' || r == '\n' || r == '\t' { + return ' ' + } + return r + }, s) +} + +// decimalEscape matches a backslash followed by exactly three decimal +// digits, e.g. "\195". Some senders emit non-ASCII name bytes this way +// ("Caf\195\169" for "Café") instead of raw UTF-8. +var decimalEscape = regexp.MustCompile(`\\([0-9]{3})`) + +// DecodeDeviceName converts decimal byte escapes (\DDD) back to the bytes +// they represent, yielding real UTF-8. Strings without backslashes — the +// common case — are returned untouched, as are strings with no valid +// escape runs (e.g. a literal "back\slash"). If decoding leaves a broken +// tail (the old byte-based truncation could chop an escape in half), the +// invalid bytes are dropped rather than keeping mojibake. +func DecodeDeviceName(name string) string { + if name == "" || !containsBackslash(name) { + return name + } + decoded := decimalEscape.ReplaceAllStringFunc(name, func(m string) string { + d := m[1:] + v := int(d[0]-'0')*100 + int(d[1]-'0')*10 + int(d[2]-'0') + if v > 255 { + return m + } + return string([]byte{byte(v)}) + }) + if decoded == name { + return name + } + if !utf8.ValidString(decoded) { + decoded = strings.ToValidUTF8(decoded, "") + } + return decoded +} + +func containsBackslash(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] == '\\' { + return true + } + } + return false +} + +// truncateRunes cuts s to at most maxBytes without splitting a UTF-8 rune. +func truncateRunes(s string, maxBytes int) string { + if len(s) <= maxBytes { + return s + } + cut := 0 + for i := range s { + if i > maxBytes { + break + } + cut = i + } + return s[:cut] +} + // IdentityBody contains the fields of an identity packet body. type IdentityBody struct { DeviceID string `json:"deviceId"` diff --git a/internal/protocol/identity_test.go b/internal/protocol/identity_test.go new file mode 100644 index 0000000..3812476 --- /dev/null +++ b/internal/protocol/identity_test.go @@ -0,0 +1,104 @@ +package protocol + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestDecodeDeviceName(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + {"plain ascii", "Pixel 8 Pro", "Pixel 8 Pro"}, + {"real utf8 untouched", "Café", "Café"}, + {"decimal escapes", `Caf\195\169`, "Café"}, + {"leading decimal escape", `\195\169clair`, "éclair"}, + {"decimal escape mid-string", `T\195\169st`, "Tést"}, + {"truncated tail escape dropped", `Phone-\195`, "Phone-"}, + {"out of range kept", `val\999end`, `val\999end`}, + {"invalid escape kept", `back\slash`, `back\slash`}, + {"empty", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := DecodeDeviceName(tc.input); got != tc.want { + t.Errorf("DecodeDeviceName(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestSanitizeDeviceName(t *testing.T) { + if got := SanitizeDeviceName(`Caf\195\169`); got != "Café" { + t.Errorf("escaped name not decoded: got %q", got) + } + if got := SanitizeDeviceName("Phone (Work); rm -rf"); got != "Phone Work rm -rf" { + t.Errorf("injection chars not stripped: got %q", got) + } + // Shell metacharacters must not survive sanitization. + for _, tc := range []struct{ in, want string }{ + {"`id`", "id"}, + {"${HOME}", "HOME"}, + {"$(whoami)", "whoami"}, + {"a|cat /etc/passwd", "acat etcpasswd"}, + {"a & rm", "a rm"}, + {`back\slash`, "backslash"}, + {"a*b?c", "abc"}, + } { + if got := SanitizeDeviceName(tc.in); got != tc.want { + t.Errorf("SanitizeDeviceName(%q) = %q, want %q", tc.in, got, tc.want) + } + } + // Control characters (NUL, newlines, ESC, bidi overrides) are dropped. + for _, in := range []string{ + "A\x00B", + "Phone\nFAKE-INFO", + "Phone\r\nspam", + "\x1b]0;pwned\x07Phone", + "ab\u202ec routine", + "tab\there", + } { + got := SanitizeDeviceName(in) + for _, r := range got { + if r == 0 || r == '\n' || r == '\r' || r == '\x1b' || r == '\u202e' || r == '\t' { + t.Errorf("SanitizeDeviceName(%q) keeps control rune: got %q", in, got) + } + } + if !utf8.ValidString(got) { + t.Errorf("SanitizeDeviceName(%q) invalid UTF-8: %q", in, got) + } + } + // Multibyte truncation must never split a rune nor exceed the cap. + long := strings.Repeat("Ö", 40) // 80 bytes + got := SanitizeDeviceName(long) + if len(got) > MaxDeviceNameLength { + t.Errorf("truncated name exceeds cap: %d bytes", len(got)) + } + if !utf8.ValidString(got) { + t.Errorf("truncated name is invalid UTF-8: %q", got) + } + if n := len([]rune(got)); n != MaxDeviceNameLength/2 { + t.Errorf("expected 16 Ö runes (32 bytes), got %d runes", n) + } +} + +func TestDisplayName(t *testing.T) { + cases := []struct{ in, want string }{ + {"Pixel 8 Pro", "Pixel 8 Pro"}, + {"Café", "Café"}, + {"A\nB", "A B"}, + {"A\r\nB", "A B"}, + {"A\tB", "A B"}, + {"\x1b[2JPhone", "Phone"}, + {"\x1b[31mRed\x1b[0m", "Red"}, + {"\x1b]0;title\x07Phone", "Phone"}, + } + for _, tc := range cases { + if got := DisplayName(tc.in); got != tc.want { + t.Errorf("DisplayName(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/packaging/kcd-bin.install b/packaging/kcd-bin.install deleted file mode 100644 index e46e7c8..0000000 --- a/packaging/kcd-bin.install +++ /dev/null @@ -1,21 +0,0 @@ -post_install() { - echo "=================================================================" - echo " kcd installed successfully." - echo "=================================================================" - echo "" - echo " To enable the user-level service (recommended), run:" - echo " systemctl --user enable --now kcd" - echo "" - echo " To enable the system-level service template, run:" - echo " sudo systemctl enable --now kcd@\$USER" - echo "" - echo " If you use a firewall, allow the KDE Connect service by name:" - echo " UFW: sudo ufw allow kcd" - echo " Firewalld: sudo firewall-cmd --permanent --add-service=kcd" - echo " sudo firewall-cmd --reload" - echo "=================================================================" -} - -post_upgrade() { - post_install -} diff --git a/packaging/kcd-pkg.service b/packaging/kcd-pkg.service index 4f83e96..fbaa1c9 100644 --- a/packaging/kcd-pkg.service +++ b/packaging/kcd-pkg.service @@ -23,9 +23,12 @@ TimeoutStopSec=10 Restart=on-failure RestartSec=5 -# IPC socket directory, created automatically by systemd under $XDG_RUNTIME_DIR. -RuntimeDirectory=kcd -RuntimeDirectoryMode=0700 +# NOTE: no RuntimeDirectory= here on purpose. The kcd.socket unit owns +# $XDG_RUNTIME_DIR/kcd (creating it is required before the socket can be +# bound), and systemd tears a RuntimeDirectory down when its unit stops — +# if both units declared it, stopping the service would delete the socket +# file out from under the still-active socket unit. In non-socket mode the +# daemon creates the directory itself (ipc server MkdirAll, mode 0700). # ── Process hardening ───────────────────────────────────────────────────────── # Namespace-based hardening (ProtectSystem, PrivateTmp, etc.) is intentionally diff --git a/packaging/kcd-pkg.socket b/packaging/kcd-pkg.socket new file mode 100644 index 0000000..c48f888 --- /dev/null +++ b/packaging/kcd-pkg.socket @@ -0,0 +1,41 @@ +# kcd.socket — on-demand activation for the kcd daemon (distro packages). +# +# Installed by distro packages as /usr/lib/systemd/user/kcd.socket +# (see dist/config.yaml: nfpms contents + AUR package body). +# Enable once for login-persistent on-demand startup: +# +# systemctl --user enable --now kcd.socket +# +# The first client connection to the IPC socket starts kcd.service +# automatically; the daemon adopts this listener (see LISTEN_FDS handling +# in internal/ipc/server.go) instead of binding the path itself. Clients +# block briefly on a cold start, then behave identically — no reconnect +# machinery needed to summon the daemon. +# +# Only applies to the default socket path ($XDG_RUNTIME_DIR/kcd/kcd.sock). +# A custom socket_path in kcd.toml falls back to daemon-side self-bind. +# +# NOTE: unlike packaging/kcd-user.socket this unit has no PartOf= +# graphical-session.target — distro packages serve headless servers and +# minimal window managers too, so the socket follows the default target +# graph like packaging/kcd-pkg.service does. + +[Unit] +Description=kcd — IPC socket (on-demand daemon activation) +Documentation=https://github.com/bethropolis/kcd +Before=sockets.target + +[Socket] +# Must match the daemon's default socket path. %t is $XDG_RUNTIME_DIR. +ListenStream=%t/kcd/kcd.sock +# Mirror the daemon's self-bind permissions (0600) — without this the +# systemd-created socket would be world-accessible under a lax umask. +SocketMode=0600 +# Create $XDG_RUNTIME_DIR/kcd (mode 0700) so the socket path exists. +RuntimeDirectory=kcd +RuntimeDirectoryMode=0700 +# Single-instance stream socket (Accept=false default): systemd spawns one +# kcd.service and hands it every connection. Default trigger limits apply. + +[Install] +WantedBy=sockets.target diff --git a/packaging/kcd-user.service b/packaging/kcd-user.service index 7a69bd2..e32cd97 100644 --- a/packaging/kcd-user.service +++ b/packaging/kcd-user.service @@ -28,9 +28,12 @@ TimeoutStopSec=10 Restart=on-failure RestartSec=5 -# IPC socket directory, created automatically by systemd under $XDG_RUNTIME_DIR. -RuntimeDirectory=kcd -RuntimeDirectoryMode=0700 +# NOTE: no RuntimeDirectory= here on purpose. The kcd.socket unit owns +# $XDG_RUNTIME_DIR/kcd (creating it is required before the socket can be +# bound), and systemd tears a RuntimeDirectory down when its unit stops — +# if both units declared it, stopping the service would delete the socket +# file out from under the still-active socket unit. In non-socket mode the +# daemon creates the directory itself (ipc server MkdirAll, mode 0700). # ── Process hardening ───────────────────────────────────────────────────────── # Namespace-based hardening (ProtectSystem, PrivateTmp, etc.) is intentionally diff --git a/packaging/kcd-user.socket b/packaging/kcd-user.socket new file mode 100644 index 0000000..718dad5 --- /dev/null +++ b/packaging/kcd-user.socket @@ -0,0 +1,37 @@ +# kcd.socket — on-demand activation for the kcd daemon. +# +# Installed by scripts/install.sh as ~/.config/systemd/user/kcd.socket. +# Enable once for login-persistent on-demand startup: +# +# systemctl --user enable --now kcd.socket +# +# The first client connection to the IPC socket starts kcd.service +# automatically; the daemon adopts this listener (see LISTEN_FDS handling +# in internal/ipc/server.go) instead of binding the path itself. Clients +# block briefly on a cold start, then behave identically — no reconnect +# machinery needed to summon the daemon. +# +# Only applies to the default socket path ($XDG_RUNTIME_DIR/kcd/kcd.sock). +# A custom socket_path in kcd.toml falls back to daemon-side self-bind. + +[Unit] +Description=kcd — IPC socket (on-demand daemon activation) +Documentation=https://github.com/bethropolis/kcd +# The service stops at session end; the socket goes with it. +PartOf=graphical-session.target +Before=sockets.target + +[Socket] +# Must match the daemon's default socket path. %t is $XDG_RUNTIME_DIR. +ListenStream=%t/kcd/kcd.sock +# Mirror the daemon's self-bind permissions (0600) — without this the +# systemd-created socket would be world-accessible under a lax umask. +SocketMode=0600 +# Create $XDG_RUNTIME_DIR/kcd (mode 0700) so the socket path exists. +RuntimeDirectory=kcd +RuntimeDirectoryMode=0700 +# Single-instance stream socket (Accept=false default): systemd spawns one +# kcd.service and hands it every connection. Default trigger limits apply. + +[Install] +WantedBy=sockets.target diff --git a/packaging/kcd.example.toml b/packaging/kcd.example.toml index c6f7334..dfff97c 100644 --- a/packaging/kcd.example.toml +++ b/packaging/kcd.example.toml @@ -21,9 +21,11 @@ # Default: 1716 # tcp_port = 1716 -# Broadcast is disabled by default. It starts automatically when you run -# `kcd pair` (listen mode) and stops when pairing completes. -# Paired phones reconnect automatically via the last known IP. +# Broadcast is on demand. mDNS advertisement runs always (responder-only, +# negligible cost); UDP broadcast starts automatically when you run +# `kcd pair` (listen mode) and while any paired device is offline, and +# stops otherwise. Paired phones reconnect via the last known address, +# refreshed from sightings and persisted across restarts. # ─── Pairing ────────────────────────────────────────────────────────────────── @@ -146,6 +148,15 @@ systemvolume = true # See the [sms] section below for notification settings. sms = true +# Sync the phone address book (vCards cached under +# ~/.local/share/kcd/contacts//). +# Commands: +# kcd contacts sync — Request a sync (results via `kcd watch --events contacts.updated`) +# kcd contacts list [--json] — List cached contacts +# The phone gates this on READ_CONTACTS plus per-device opt-in; unpairing +# wipes the cached address book. +contacts = true + # Presenter remote — control slideshow presentations from the phone. # Gyroscope-based pointer: requires xdotool (X11) or ydotool (Wayland). # Screen size detection: requires xdpyinfo (X11) or wlr-randr (Wayland). diff --git a/packaging/kcd.fish-completion b/packaging/kcd.fish-completion index ff37d5b..d0159f1 100644 --- a/packaging/kcd.fish-completion +++ b/packaging/kcd.fish-completion @@ -34,7 +34,7 @@ function __kcd_at_arg end # All top-level subcommands (for "don't complete subcommands twice" guards) -set -l __kcd_cmds daemon devices connect pair unpair ping battery watch \ +set -l __kcd_cmds daemon devices connect pair unpair ping battery connectivity watch \ sftp reply call findmyphone lock unlock share clipboard run sms mpris doctor status # --------------------------------------------------------------------------- @@ -68,6 +68,8 @@ complete -c kcd -n "not __fish_seen_subcommand_from $__kcd_cmds" \ -a ping -d 'Send a ping notification to a device' complete -c kcd -n "not __fish_seen_subcommand_from $__kcd_cmds" \ -a battery -d 'Fetch battery level and charging status' +complete -c kcd -n "not __fish_seen_subcommand_from $__kcd_cmds" \ + -a connectivity -d 'Show cellular signal strength and network type' complete -c kcd -n "not __fish_seen_subcommand_from $__kcd_cmds" \ -a watch -d 'Monitor real-time events from the daemon' complete -c kcd -n "not __fish_seen_subcommand_from $__kcd_cmds" \ @@ -105,7 +107,7 @@ complete -c kcd -n "__fish_seen_subcommand_from devices" \ complete -c kcd -n "__fish_seen_subcommand_from devices" \ -l watch -s w -d 'Stream device changes in real time' complete -c kcd -n "__fish_seen_subcommand_from devices" \ - -l connected -d 'Only show currently connected devices' + -l connected -d 'Only show paired devices (including offline)' # --------------------------------------------------------------------------- # connect @@ -120,13 +122,21 @@ complete -c kcd -n "__fish_seen_subcommand_from devices" \ complete -c kcd -n "__fish_seen_subcommand_from pair && __kcd_at_arg 2" \ -a '(__kcd_devices)' -d 'Device ID' -# unpair / ping / battery / findmyphone / lock / unlock / clipboard +# unpair / ping / battery / connectivity / findmyphone / lock / unlock / clipboard # — only make sense with connected devices -for _cmd in unpair ping battery findmyphone lock unlock clipboard +for _cmd in unpair ping battery connectivity findmyphone lock unlock clipboard complete -c kcd -n "__fish_seen_subcommand_from $_cmd && __kcd_at_arg 2" \ -a '(__kcd_connected_devices)' -d 'Device ID' end +# --------------------------------------------------------------------------- +# battery / connectivity (just --json flags; device-id handled above) +# --------------------------------------------------------------------------- +complete -c kcd -n "__fish_seen_subcommand_from battery" \ + -l json -d 'Output raw JSON' +complete -c kcd -n "__fish_seen_subcommand_from connectivity" \ + -l json -d 'Output the raw report as JSON' + # --------------------------------------------------------------------------- # clipboard (just --watch flag; device-id handled above) # --------------------------------------------------------------------------- @@ -187,6 +197,11 @@ for _sub in request info volumes mount unmount -a '(__kcd_connected_devices)' -d 'Device ID' end +complete -c kcd -n "__fish_seen_subcommand_from sftp && __fish_seen_subcommand_from info" \ + -l json -d 'Output raw JSON' +complete -c kcd -n "__fish_seen_subcommand_from sftp && __fish_seen_subcommand_from volumes" \ + -l json -d 'Output raw JSON' + # --------------------------------------------------------------------------- # reply # --------------------------------------------------------------------------- diff --git a/pkg/client/client.go b/pkg/client/client.go index 5444af8..9968929 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -12,6 +12,7 @@ import ( "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/events" "github.com/bethropolis/kcd/internal/ipc" + "github.com/bethropolis/kcd/internal/plugins/contacts" ) // Client connects to the kcd daemon via Unix socket. @@ -155,6 +156,16 @@ func (c *Client) ClipboardPush(deviceID string) error { return err } +// Connectivity returns the last raw connectivity report for a device. +// Callers decode it (same shape as connectivity.update event payloads). +func (c *Client) Connectivity(deviceID string) (json.RawMessage, error) { + res, err := c.Call(ipc.CmdConnectivity, ipc.DevicePayload{DeviceID: deviceID}) + if err != nil { + return nil, err + } + return res.Data, nil +} + // RunList requests the remote device to send its command list. func (c *Client) RunList(deviceID string) error { _, err := c.Call(ipc.CmdRunList, ipc.DevicePayload{DeviceID: deviceID}) @@ -338,6 +349,27 @@ func (c *Client) SmsRequestAttachment(deviceID string, partID int64, uniqueIdent return err } +// ContactsSync asks the daemon to start a contacts sync round with a device. +// Results arrive async via the contacts.updated event. +func (c *Client) ContactsSync(deviceID string) error { + _, err := c.Call(ipc.CmdContactsSync, ipc.DevicePayload{DeviceID: deviceID}) + return err +} + +// ContactsList returns cached contact summaries for a device (empty when +// never synced — absent means unknown). +func (c *Client) ContactsList(deviceID string) ([]contacts.ContactSummary, error) { + res, err := c.Call(ipc.CmdContactsList, ipc.DevicePayload{DeviceID: deviceID}) + if err != nil { + return nil, err + } + var list []contacts.ContactSummary + if err := json.Unmarshal(res.Data, &list); err != nil { + return nil, fmt.Errorf("decode contacts: %w", err) + } + return list, nil +} + // MprisStatus returns MPRIS plugin debug information. func (c *Client) MprisStatus() (*ipc.MprisStatusResponse, error) { res, err := c.Call(ipc.CmdMprisStatus, nil) diff --git a/scripts/install.sh b/scripts/install.sh index 6000ba6..ae08d3a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -89,6 +89,12 @@ if systemctl --user is-active --quiet kcd.service 2>/dev/null; then success "Service stopped" fi fi +if systemctl --user is-active --quiet kcd.socket 2>/dev/null; then + step "Stopping existing kcd socket" + if try systemctl --user stop kcd.socket; then + success "Socket stopped" + fi +fi # ── Build ───────────────────────────────────────────────────────────────────── step "Building static binary" @@ -184,25 +190,44 @@ if [[ "${INSTALL_SERVICE}" == true ]]; then warn "Could not install service file — skipping service setup" _systemd_ok=false fi + + # Socket unit for on-demand activation: the first client connection + # starts the daemon automatically. The service file stays installed + # (activation needs it) but is not enabled directly. + if ! try install -m 644 "${REPO_ROOT}/packaging/kcd-user.socket" "${SYSTEMD_DIR}/kcd.socket"; then + warn "Could not install socket file — continuing without on-demand activation" + fi fi if [[ "${_systemd_ok}" == true ]]; then try systemctl --user daemon-reload \ || warn "daemon-reload failed — service file may not be recognised yet" - try systemctl --user enable kcd.service \ - || warn "Could not enable kcd.service — you may need to enable it manually" - - try systemctl --user start kcd.service \ - || warn "Could not start kcd.service — check 'journalctl --user -u kcd -n 30'" - - # Wait briefly and check it actually started - sleep 2 - if systemctl --user is-active --quiet kcd.service 2>/dev/null; then - success "kcd.service enabled and running" + # Prefer socket activation: cold client commands summon the daemon. + if [[ -f "${SYSTEMD_DIR}/kcd.socket" ]] && try systemctl --user enable --now kcd.socket; then + try systemctl --user disable kcd.service 2>/dev/null + sleep 2 + if systemctl --user is-active --quiet kcd.socket 2>/dev/null; then + success "kcd.socket enabled and listening (daemon starts on first use)" + else + warn "kcd.socket is installed but does not appear to be listening." + fi else - warn "kcd.service is installed but does not appear to be running." - printf " Run ${BOLD}journalctl --user -u kcd -n 30${RESET} to see why.\n" + warn "Could not enable kcd.socket — falling back to persistent service" + try systemctl --user enable kcd.service \ + || warn "Could not enable kcd.service — you may need to enable it manually" + + try systemctl --user start kcd.service \ + || warn "Could not start kcd.service — check 'journalctl --user -u kcd -n 30'" + + # Wait briefly and check it actually started + sleep 2 + if systemctl --user is-active --quiet kcd.service 2>/dev/null; then + success "kcd.service enabled and running" + else + warn "kcd.service is installed but does not appear to be running." + printf " Run ${BOLD}journalctl --user -u kcd -n 30${RESET} to see why.\n" + fi fi fi fi diff --git a/scripts/postinstall.sh b/scripts/postinstall.sh index 39aca5f..0034736 100755 --- a/scripts/postinstall.sh +++ b/scripts/postinstall.sh @@ -7,8 +7,11 @@ echo "=================================================================" echo "kcd installed successfully." echo "=================================================================" echo "" -echo "To enable the user service, run:" -echo " systemctl --user enable --now kcd" +echo "To enable on-demand startup (recommended), run:" +echo " systemctl --user enable --now kcd.socket" +echo "" +echo "The daemon then starts automatically on first use — run any kcd" +echo "command once after login and a paired phone reconnects by itself." echo "" echo "To enable the system-level service (per-user), run:" echo " sudo systemctl enable --now kcd@\$USER" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 27d1165..5805291 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -52,7 +52,7 @@ printf "${BOLD}${RED}│${RESET} ${BOLD}kcd${RESET} — Uninstall printf "${BOLD}${RED}└─────────────────────────────────────────┘${RESET}\n" printf "\n" -# ── Stop and disable service ────────────────────────────────────────────────── +# ── Stop and disable service + socket ───────────────────────────────────────── step "systemd service" if systemctl --user is-active --quiet kcd.service 2>/dev/null; then @@ -63,11 +63,24 @@ else skip "kcd.service is not running" fi +if systemctl --user is-active --quiet kcd.socket 2>/dev/null; then + info "Stopping kcd.socket …" + systemctl --user stop kcd.socket + success "Socket stopped" +else + skip "kcd.socket is not listening" +fi + if systemctl --user is-enabled --quiet kcd.service 2>/dev/null; then systemctl --user disable kcd.service success "Service disabled" fi +if systemctl --user is-enabled --quiet kcd.socket 2>/dev/null; then + systemctl --user disable kcd.socket + success "Socket disabled" +fi + if [[ -f "${SYSTEMD_DIR}/kcd.service" ]]; then rm "${SYSTEMD_DIR}/kcd.service" systemctl --user daemon-reload @@ -76,6 +89,14 @@ else skip "No service file found at ${SYSTEMD_DIR}/kcd.service" fi +if [[ -f "${SYSTEMD_DIR}/kcd.socket" ]]; then + rm "${SYSTEMD_DIR}/kcd.socket" + systemctl --user daemon-reload + success "Removed ${SYSTEMD_DIR}/kcd.socket" +else + skip "No socket file found at ${SYSTEMD_DIR}/kcd.socket" +fi + # ── Remove binary ───────────────────────────────────────────────────────────── step "Binary"