Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ type DHCPConfig struct {
type RouteConfig struct {
SetDefaultRoute bool `toml:"set_default_route"`
RouteMetric int `toml:"route_metric"`
// WatchdogIntervalS: how often the in-daemon config watchdog verifies that
// the injected route/DNS/interface config are still in place and re-applies
// them if macOS removed them (e.g. after standby). Default 10s when unset.
WatchdogIntervalS int `toml:"watchdog_interval_s"`
}

type LoggingConfig struct {
Expand All @@ -51,7 +55,7 @@ type LoggingConfig struct {
// Default paths for DroidTether config files on macOS.
var DefaultConfigPaths = []string{
"/etc/droidtether/droidtether.toml",
"/usr/local/etc/droidtether/droidtether.toml", // Intel Homebrew
"/usr/local/etc/droidtether/droidtether.toml", // Intel Homebrew
"/opt/homebrew/etc/droidtether/droidtether.toml", // ARM Homebrew
}

Expand Down
92 changes: 91 additions & 1 deletion internal/daemon/daemon.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package daemon

import (
"context"
"fmt"
"os"
"os/exec"
"os/signal"
"strings"
"sync"
"syscall"
"time"
Expand Down Expand Up @@ -43,33 +46,88 @@ func (d *Daemon) Run() error {

watcher := usb.NewWatcher(pollInterval)

// Serialize device sessions. OnAttach fires from the watcher's poll loop and
// previously did no bookkeeping, so a dying session's USB claim was still
// held when the next attach arrived. The new session then failed for ~8s
// with "libusb: bad access [code -3]" before the old one finished cleaning
// up, and each retry armed another watchdog. One session at a time; a new
// attach waits for the previous one to finish releasing the device.
var sessionMu sync.Mutex
// Consecutive RNDIS handshake failures. Sessions are serialized by
// sessionMu, so a plain counter is enough.
var handshakeFails int

watcher.OnAttach(func(dev *usb.Device) {
sessionMu.Lock()
defer sessionMu.Unlock()

d.wg.Add(1)
defer d.wg.Done()
log.Info().
Str("component", "daemon").
Msg("Android RNDIS device connected!")

// Always release the USB interfaces this session claimed. Without
// this, a relay that ends on a libusb error path leaves the claim
// dangling and the next attach cannot claim the interface.
defer func() {
if err := dev.Close(); err != nil {
log.Warn().Str("component", "daemon").Err(err).Msg("Failed to close USB device")
} else {
log.Debug().Str("component", "daemon").Msg("USB device released")
}
}()

session := rndis.NewSession(dev)
phoneMAC, err := session.Handshake()
if err != nil {
log.Error().Str("component", "daemon").Err(err).Msg("RNDIS Handshake failed")
handshakeFails++
log.Error().Str("component", "daemon").Err(err).
Int("consecutive", handshakeFails).
Msg("RNDIS Handshake failed")
// After a Mac sleep Android typically disables USB
// tethering itself: the phone still enumerates the RNDIS
// config but its gadget never answers INIT. Only the user
// can switch it back on, so tell them, and back off —
// the 1s reconnect storm against a surprise-removed
// device is what poisoned the libusb context (-99).
if handshakeFails == 5 {
go notifyPhoneTetheringOff()
}
backoff := time.Duration(handshakeFails) * 2 * time.Second
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
select {
case <-time.After(backoff):
case <-watcher.Context().Done():
}
return
}
handshakeFails = 0

iface, err := tun.OpenUTUN(0)
if err != nil {
log.Error().Str("component", "daemon").Err(err).Msg("Failed to create utun interface")
return
}

// Session-scoped context: the config watchdog must stop when this
// device session ends, not linger across reconnects.
sessionCtx, cancelSession := context.WithCancel(watcher.Context())
defer cancelSession()

// Ensure everything closes when watcher is stopped
go func() {
<-watcher.Context().Done()
iface.Close()
}()
defer iface.Close()

// The watchdog is started once per session; DHCP renewals may re-fire
// OnDHCP, but the watchdog must not stack.
var watchdogStarted sync.Once

relay, err := NewRelay(watcher.Context(), dev, iface, phoneMAC)
if err != nil {
log.Error().Str("component", "daemon").Err(err).Msg("Failed to initialize Relay")
Expand Down Expand Up @@ -97,11 +155,27 @@ func (d *Daemon) Run() error {
log.Warn().Str("component", "daemon").Err(err).Msg("Failed to set default route")
}

// Make droidtether a real configd service so the
// primary election picks it and the derived DNS
// resolver gets RequestA (mDNSResponder otherwise
// suppresses every A/AAAA query).
if err := iface.RegisterService(client, gateway); err != nil {
log.Warn().Str("component", "daemon").Err(err).Msg("Failed to register service with configd")
}

// Set DNS to Google (Primary) and phone gateway (Secondary)
log.Info().Str("component", "daemon").Msg("Setting system DNS to 8.8.8.8 (Google)...")
if err := iface.SetDNS([]string{"8.8.8.8", gateway}); err != nil {
log.Warn().Str("component", "daemon").Err(err).Msg("Failed to set DNS")
}

// macOS standby can silently remove everything we just
// injected while the USB session survives (no DHCP
// re-fire). Arm the watchdog that re-applies on drift.
watchdogStarted.Do(func() {
d.startConfigWatchdog(sessionCtx, iface, client, gateway, mtuStr)
go d.startLinkWatchdog(sessionCtx, relay, gateway, client)
})
}
}
}
Expand Down Expand Up @@ -153,6 +227,22 @@ func (d *Daemon) Run() error {
return nil
}

// notifyPhoneTetheringOff shows a macOS user notification through the
// console user's GUI session (the daemon runs as root). Best-effort:
// every failure is ignored.
func notifyPhoneTetheringOff() {
out, err := exec.Command("stat", "-f", "%u", "/dev/console").Output()
if err != nil {
return
}
uid := strings.TrimSpace(string(out))
if uid == "" || uid == "0" {
return
}
_ = exec.Command("launchctl", "asuser", uid, "osascript", "-e",
`display notification "USB tethering is disabled on the phone. Enable it in Settings → Network & internet → Hotspot & tethering → USB tethering." with title "DroidTether" sound name "Glass"`).Run()
}

func (d *Daemon) setupLogging() {
// Level
level, err := zerolog.ParseLevel(d.cfg.Logging.Level)
Expand Down
154 changes: 143 additions & 11 deletions internal/daemon/relay.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package daemon

import (
"bytes"
"context"
"encoding/binary"
"encoding/hex"
Expand All @@ -17,6 +18,19 @@ import (
"github.com/rs/zerolog/log"
)

// clientMAC is the source MAC we stamp on every frame we send to the phone.
// The phone is expected to send return traffic addressed to it. It is a
// constant (not learned) so we can recognise and drop anything that comes back
// carrying it — see the loop guard in the phone->Mac direction.
var clientMAC = []byte{0x02, 0x00, 0x00, 0x00, 0x00, 0x01}

// macCandidate tracks a possible new phone MAC. A single packet is not enough
// evidence: a phone attached both by USB and Wi-Fi hotspot sends frames from
// several interfaces, and adopting the wrong one mis-addresses every frame we
// send (see loop guard). Require macAdoptThreshold consecutive sightings from
// the same address before switching.
const macAdoptThreshold = 8

// Relay handles the packet shuttle between USB Bulk and Tunnel interface.
type Relay struct {
dev *usb.Device
Expand All @@ -34,6 +48,12 @@ type Relay struct {
mu sync.Mutex
clientIP net.IP

// Candidate for a phone MAC change: consecutive sightings of candMAC.
candMAC []byte
candCount int
loopDrops uint64
macChanges uint64

// Traffic stats
sentBytes uint64
recvBytes uint64
Expand Down Expand Up @@ -106,7 +126,7 @@ func (r *Relay) Start() error {
eth := make([]byte, 14+len(rawIP))
copy(eth[0:6], r.phoneMAC)
r.mu.Unlock()
copy(eth[6:12], []byte{0x02, 0x00, 0x00, 0x00, 0x00, 0x01})
copy(eth[6:12], clientMAC)
binary.BigEndian.PutUint16(eth[12:14], 0x0800)
copy(eth[14:], rawIP)

Expand Down Expand Up @@ -169,6 +189,34 @@ func (r *Relay) Start() error {
if msgType == rndis.MsgPacket && msgLen > 44 {
ethPkt, err := rndis.DecapsulatePacket(msg[:msgLen])
if err == nil && len(ethPkt) > 14 {
// Loop guard: drop anything carrying our own source
// MAC. Such a frame is our own outbound traffic that
// the phone routed back to us (it happens when the
// phone is also serving a Wi-Fi hotspot and the
// destination MAC was wrong). Injecting it again
// would re-enter the tunnel and amplify into a
// runaway loop that burned metered data at ~30 MB/s.
if r.isLoopFrame(ethPkt) {
r.mu.Lock()
r.loopDrops++
drops := r.loopDrops
r.mu.Unlock()
if drops <= 5 || drops%1000 == 0 {
log.Warn().Str("component", "relay").
Int("dropped", int(drops)).
Msg("Dropped reflected frame with our own source MAC (loop guard)")
}
offset += msgLen
continue
}

// DHCP is the phone's own server answering us: its
// source MAC is authoritative for the tethering
// interface, so adopt it immediately (the 8-packet
// threshold never sees enough DHCP frames).
if isDHCPResponse(ethPkt) {
r.adoptMAC(ethPkt[6:12])
}
// Auto-detect phone's real MAC from received traffic.
// Samsung devices randomize the active MAC on the tethering
// interface, so the RNDIS-queried address may not match.
Expand Down Expand Up @@ -249,21 +297,105 @@ func (r *Relay) Start() error {
}
}

// autoDetectMAC updates phoneMAC if the received source MAC differs (Samsung MAC randomization workaround).
// autoDetectMAC considers a new phone MAC learned from received traffic
// (Samsung devices randomize the active MAC on the tethering interface, so the
// RNDIS-queried address may not match).
//
// Two safety rules, both learned the hard way on a phone attached as BOTH a USB
// tether and a Wi-Fi hotspot:
//
// 1. Never adopt our own clientMAC. A frame echoing back with our source MAC
// is our own traffic being reflected, and adopting it would make us address
// every subsequent frame to ourselves — a self-sustaining loop that moved
// 1.4 GB inbound against 79 KB outbound on a metered connection.
// 2. Require macAdoptThreshold *consecutive* sightings of the same address.
// One packet is not evidence: a dual-attached phone emits frames from
// several interfaces, and a single stray frame must not re-address the
// whole relay.
func (r *Relay) autoDetectMAC(srcMAC []byte) {
r.mu.Lock()
defer r.mu.Unlock()

for i := 0; i < 6; i++ {
if r.phoneMAC[i] != srcMAC[i] {
log.Warn().Str("component", "relay").
Str("old_mac", hex.EncodeToString(r.phoneMAC)).
Str("real_mac", hex.EncodeToString(srcMAC)).
Msg("Phone MAC mismatch detected — updating to real MAC")
copy(r.phoneMAC, srcMAC)
return
}
// Rule 1: never learn our own address.
if bytes.Equal(srcMAC, clientMAC) {
return
}

// Already the current MAC: nothing to do, and it cancels any candidate.
if bytes.Equal(srcMAC, r.phoneMAC) {
r.candMAC = nil
r.candCount = 0
return
}

// Track consecutive sightings of a candidate.
if !bytes.Equal(srcMAC, r.candMAC) {
r.candMAC = append([]byte(nil), srcMAC...)
r.candCount = 1
return
}

r.candCount++
if r.candCount < macAdoptThreshold {
return
}

old := hex.EncodeToString(r.phoneMAC)
new := hex.EncodeToString(srcMAC)
r.macChanges++
copy(r.phoneMAC, srcMAC)
r.candMAC = nil
r.candCount = 0

log.Warn().Str("component", "relay").
Str("old_mac", old).
Str("new_mac", new).
Int("confirmed_packets", macAdoptThreshold).
Msg("Phone MAC change confirmed — switching destination MAC")
}

// isDHCPResponse reports whether an Ethernet frame carries a UDP 67→68
// (server-to-client) DHCP message — the offer/ack that proves the sender is
// the phone's own tethering interface.
func isDHCPResponse(ethPkt []byte) bool {
if binary.BigEndian.Uint16(ethPkt[12:14]) != 0x0800 {
return false
}
rawIP := ethPkt[14:]
if len(rawIP) < 28 || rawIP[9] != 17 {
return false
}
return binary.BigEndian.Uint16(rawIP[20:22]) == 67 && binary.BigEndian.Uint16(rawIP[22:24]) == 68
}

// adoptMAC unconditionally re-points the relay's destination MAC to srcMAC
// (skipping the consecutive-sighting threshold), used only when the frame is
// self-authenticating evidence of the phone's tethering interface.
func (r *Relay) adoptMAC(srcMAC []byte) {
r.mu.Lock()
if bytes.Equal(srcMAC, clientMAC) || bytes.Equal(srcMAC, r.phoneMAC) {
r.mu.Unlock()
return
}
old := hex.EncodeToString(r.phoneMAC)
r.macChanges++
copy(r.phoneMAC, srcMAC)
r.candMAC = nil
r.candCount = 0
r.mu.Unlock()
log.Warn().Str("component", "relay").
Str("old_mac", old).
Str("new_mac", hex.EncodeToString(srcMAC)).
Msg("Adopted phone MAC from DHCP response — destination re-pointed")
}

// isLoopFrame reports whether an inbound frame is our own traffic being
// reflected back at us, which must never be injected into the tunnel.
func (r *Relay) isLoopFrame(ethPkt []byte) bool {
if len(ethPkt) < 14 {
return false
}
return bytes.Equal(ethPkt[6:12], clientMAC)
}

// tryExtractDHCP checks if a raw IP packet is a DHCP message and handles Offer/ACK.
Expand Down
Loading