Skip to content

Commit fe4b724

Browse files
Milestone v0.5.0: Bidirectional Packet Relay Engine
1 parent 2de3134 commit fe4b724

7 files changed

Lines changed: 338 additions & 58 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@ Format:
1616

1717
## [Unreleased]
1818

19+
*(Add entries here as you work. Move to a version block on each git push.)*
20+
21+
---
22+
23+
## v0.5.0 — 2026-03-28
24+
25+
### 2026-03-28 14:35 — Implement Bidirectional Packet Relay Engine
26+
- What: Added `Relay` struct to bridge macOS `utun` packets and USB Bulk endpoints, complete with Ethernet and RNDIS encapsulation synthesis.
27+
- Why: Milestone v0.5.0; this is the core engine that actually moves data between the phone and the Mac.
28+
- Files: `internal/daemon/relay.go`, `internal/usb/device.go`, `internal/rndis/messages.go`
29+
- Breaking: yes (switched to asynchronous relay loops)
30+
31+
---
32+
33+
## v0.4.0 — 2026-03-28
34+
1935
### 2026-03-28 14:00 — Implement utun creation on macOS
2036
- What: Created `internal/tun/utun_darwin.go` to support spawning virtual network interfaces via `AF_SYSTEM` / `SYSPROTO_CONTROL`; integrated into daemon callback.
2137
- Why: Milestone v0.4.0; enable the OS to talk to our daemon via a standard network handle.

VERSIONS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ v1.0.0 = MVP complete and working on M1/M2/M3.
1010

1111
---
1212

13+
## v0.5.0 — 2026-03-28
14+
- Milestone: Packet Relay Engine
15+
- What works: Bidirectional shuttle service over USB Bulk Endpoints. Reads IP packets from macOS `utun`, synthesizes Ethernet headers, wraps in RNDIS encapsulation, and pushes to Android. Strips RNDIS headers from Android replies and writes to `utun`.
16+
- Next: IP assignment and DHCP handling.
17+
18+
---
19+
1320
## v0.4.0 — 2026-03-28
1421
- Milestone: utun interface creation
1522
- What works: Native macOS virtual interface creation (`utun`). The daemon now spawns a real network interface on the Mac when the phone connects.

internal/daemon/daemon.go

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ func (d *Daemon) Run() error {
4646
Msg("Android RNDIS device connected!")
4747

4848
session := rndis.NewSession(dev)
49-
if err := session.Handshake(); err != nil {
49+
phoneMAC, err := session.Handshake()
50+
if err != nil {
5051
log.Error().Str("component", "daemon").Err(err).Msg("RNDIS Handshake failed")
5152
return
5253
}
@@ -62,33 +63,30 @@ func (d *Daemon) Run() error {
6263
log.Info().
6364
Str("component", "daemon").
6465
Str("interface", iface.Name()).
65-
Msg("Virtual network interface created and ACTIVE. You can now run 'ifconfig' on it.")
66+
Msg("Virtual network interface created and ACTIVE.")
67+
68+
// Milestone v0.5.0: The Relay Engine
69+
relay, err := NewRelay(dev, iface, phoneMAC)
70+
if err != nil {
71+
log.Error().Str("component", "daemon").Err(err).Msg("Failed to initialize Relay")
72+
time.Sleep(2 * time.Second) // prevent busy loops on retry
73+
return
74+
}
6675

67-
// Keep the session alive until the watcher signals detachment.
68-
// For now, we'll just block on a channel that is closed when the phone is unplugged.
69-
// In Milestone v0.5.0, this handles the Packet Relay loop.
70-
7176
stopChan := make(chan bool)
7277
watcher.OnDetach(func() {
7378
log.Info().Str("component", "daemon").Msg("Android RNDIS device detached.")
79+
relay.Stop()
7480
close(stopChan)
7581
})
7682

77-
// Simple stay-alive logger to show the pipe is still open
78-
ticker := time.NewTicker(30 * time.Second)
79-
defer ticker.Stop()
80-
go func() {
81-
for {
82-
select {
83-
case <-ticker.C:
84-
log.Debug().Str("component", "daemon").Str("interface", iface.Name()).Msg("RNDIS session active")
85-
case <-stopChan:
86-
return
87-
}
88-
}
89-
}()
90-
91-
// Block here until phone is detached
83+
// Start the relay loop (blocks until error or stop)
84+
if err := relay.Start(); err != nil {
85+
log.Error().Str("component", "daemon").Err(err).Msg("Relay ended with error")
86+
time.Sleep(1 * time.Second)
87+
}
88+
89+
// Wait here until phone is detached
9290
<-stopChan
9391
})
9492

internal/daemon/relay.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package daemon
2+
3+
import (
4+
"context"
5+
"encoding/binary"
6+
"fmt"
7+
"io"
8+
9+
"github.com/google/gousb"
10+
"github.com/princePal/droidtether/internal/rndis"
11+
"github.com/princePal/droidtether/internal/tun"
12+
"github.com/princePal/droidtether/internal/usb"
13+
"github.com/rs/zerolog/log"
14+
)
15+
16+
// Relay handles the packet shuttle between USB Bulk and Tunnel interface.
17+
type Relay struct {
18+
dev *usb.Device
19+
tun tun.Interface
20+
ctx context.Context
21+
cancel context.CancelFunc
22+
23+
usbIn *gousb.InEndpoint
24+
usbOut *gousb.OutEndpoint
25+
26+
phoneMAC []byte
27+
}
28+
29+
// NewRelay creates a new bidirectional relay.
30+
func NewRelay(dev *usb.Device, tun tun.Interface, phoneMAC []byte) (*Relay, error) {
31+
in, out, err := dev.OpenBulkEndpoints()
32+
if err != nil {
33+
return nil, err
34+
}
35+
36+
ctx, cancel := context.WithCancel(context.Background())
37+
return &Relay{
38+
dev: dev,
39+
tun: tun,
40+
ctx: ctx,
41+
cancel: cancel,
42+
usbIn: in,
43+
usbOut: out,
44+
phoneMAC: phoneMAC,
45+
}, nil
46+
}
47+
48+
// Start spawns the relay goroutines and blocks until context is cancelled or an error occurs.
49+
func (r *Relay) Start() error {
50+
errChan := make(chan error, 2)
51+
52+
// Mac -> Phone (Tunnel -> USB)
53+
go func() {
54+
buf := make([]byte, 2048)
55+
for {
56+
select {
57+
case <-r.ctx.Done():
58+
return
59+
default:
60+
n, err := r.tun.Read(buf)
61+
if err != nil {
62+
if err != io.EOF {
63+
errChan <- fmt.Errorf("relay: tun read error: %w", err)
64+
}
65+
return
66+
}
67+
68+
// macOS utun header is 4 bytes [0 0 0 2] for IPv4. Strip it.
69+
if n < 4 {
70+
continue
71+
}
72+
rawIP := buf[4:n]
73+
74+
// Construct Ethernet Header (L2)
75+
// [DstMAC(6)] [SrcMAC(6)] [Type(2)]
76+
eth := make([]byte, 14+len(rawIP))
77+
copy(eth[0:6], r.phoneMAC)
78+
copy(eth[6:12], []byte{0x02, 0x00, 0x00, 0x00, 0x00, 0x01})
79+
binary.BigEndian.PutUint16(eth[12:14], 0x0800) // IPv4
80+
copy(eth[14:], rawIP)
81+
82+
// Wrap in RNDIS (L1-ish)
83+
pkt := rndis.EncapsulatePacket(eth)
84+
_, err = r.usbOut.Write(pkt)
85+
if err != nil {
86+
errChan <- fmt.Errorf("relay: usb write error: %w", err)
87+
return
88+
}
89+
log.Info().Str("component", "relay").Int("bytes", len(pkt)).Msg("Sent packet to phone")
90+
}
91+
}
92+
}()
93+
94+
95+
// Phone -> Mac (USB -> Tunnel)
96+
go func() {
97+
buf := make([]byte, 16384)
98+
for {
99+
select {
100+
case <-r.ctx.Done():
101+
return
102+
default:
103+
n, err := r.usbIn.Read(buf)
104+
if err != nil {
105+
errChan <- fmt.Errorf("relay: usb read error: %w", err)
106+
return
107+
}
108+
109+
offset := 0
110+
for offset < n {
111+
msg := buf[offset:]
112+
if len(msg) < 8 {
113+
break
114+
}
115+
msgType := binary.LittleEndian.Uint32(msg[0:4])
116+
msgLen := int(binary.LittleEndian.Uint32(msg[4:8]))
117+
118+
if msgType == rndis.MsgPacket && msgLen > 44 {
119+
ethPkt, err := rndis.DecapsulatePacket(msg[:msgLen])
120+
if err == nil && len(ethPkt) > 14 {
121+
// Strip Ethernet header (14 bytes)
122+
rawIP := ethPkt[14:]
123+
124+
// Prepend macOS utun header [0 0 0 2]
125+
outBuf := make([]byte, 4+len(rawIP))
126+
binary.BigEndian.PutUint32(outBuf[0:4], 2)
127+
copy(outBuf[4:], rawIP)
128+
129+
_, _ = r.tun.Write(outBuf)
130+
log.Info().Str("component", "relay").Int("bytes", len(outBuf)).Msg("Received packet from phone")
131+
}
132+
}
133+
134+
if msgLen == 0 {
135+
break
136+
}
137+
offset += msgLen
138+
}
139+
}
140+
}
141+
}()
142+
143+
144+
log.Info().Str("component", "relay").Msg("Bidirectional packet relay started")
145+
146+
// Wait for error or shutdown
147+
select {
148+
case err := <-errChan:
149+
r.cancel()
150+
return err
151+
case <-r.ctx.Done():
152+
return nil
153+
}
154+
}
155+
156+
// Stop shuts down the relay.
157+
func (r *Relay) Stop() {
158+
r.cancel()
159+
}

internal/rndis/messages.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,45 @@ func UnmarshalSetCmplt(data []byte) (uint32, uint32, error) { // returns Request
146146
}
147147
return binary.LittleEndian.Uint32(data[8:12]), binary.LittleEndian.Uint32(data[12:16]), nil
148148
}
149+
150+
// EncapsulatePacket wraps a raw Ethernet packet with an RNDIS header.
151+
func EncapsulatePacket(packet []byte) []byte {
152+
headerLen := 44
153+
totalLen := headerLen + len(packet)
154+
// RNDIS packets should be padded to 8-byte boundaries for some devices
155+
padding := (8 - (totalLen % 8)) % 8
156+
157+
b := make([]byte, totalLen+padding)
158+
binary.LittleEndian.PutUint32(b[0:4], MsgPacket)
159+
binary.LittleEndian.PutUint32(b[4:8], uint32(totalLen+padding))
160+
binary.LittleEndian.PutUint32(b[8:12], 36) // DataOffset (relative to byte 8)
161+
binary.LittleEndian.PutUint32(b[12:16], uint32(len(packet)))
162+
// Bytes 16-43 are reserved/optional fields (offset/length for OOB data, info, etc.)
163+
// We leave them zeroed.
164+
165+
copy(b[44:], packet)
166+
return b
167+
}
168+
169+
// DecapsulatePacket strips the RNDIS header and returns the raw Ethernet packet.
170+
func DecapsulatePacket(data []byte) ([]byte, error) {
171+
if len(data) < 44 {
172+
return nil, fmt.Errorf("packet: too short")
173+
}
174+
msgType := binary.LittleEndian.Uint32(data[0:4])
175+
if msgType != MsgPacket {
176+
return nil, fmt.Errorf("packet: not a data packet (0x%08X)", msgType)
177+
}
178+
179+
dataOff := binary.LittleEndian.Uint32(data[8:12])
180+
dataLen := binary.LittleEndian.Uint32(data[12:16])
181+
182+
start := int(8 + dataOff)
183+
end := start + int(dataLen)
184+
185+
if end > len(data) {
186+
return nil, fmt.Errorf("packet: payload out of bounds")
187+
}
188+
189+
return data[start:end], nil
190+
}

internal/rndis/rndis.go

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func NewSession(dev *usb.Device) *Session {
2222
}
2323

2424
// Handshake performs the RNDIS INIT -> QUERY(MAC) -> SET(FILTER) sequence.
25-
func (s *Session) Handshake() error {
25+
func (s *Session) Handshake() ([]byte, error) {
2626
log.Info().Str("component", "rndis").Msg("Starting RNDIS handshake...")
2727

2828
// 1. Send INIT
@@ -33,20 +33,20 @@ func (s *Session) Handshake() error {
3333
}
3434

3535
if err := s.sendControl(initMsg.Marshal()); err != nil {
36-
return fmt.Errorf("rndis: failed to send INIT: %w", err)
36+
return nil, fmt.Errorf("rndis: failed to send INIT: %w", err)
3737
}
3838

3939
initResp, err := s.receiveControl()
4040
if err != nil {
41-
return fmt.Errorf("rndis: failed to receive INIT_CMPLT: %w", err)
41+
return nil, fmt.Errorf("rndis: failed to receive INIT_CMPLT: %w", err)
4242
}
4343

4444
initCmplt, err := UnmarshalInitializeCmplt(initResp)
4545
if err != nil {
46-
return err
46+
return nil, err
4747
}
4848
if initCmplt.Status != StatusSuccess {
49-
return fmt.Errorf("rndis: INIT failed with status 0x%08X", initCmplt.Status)
49+
return nil, fmt.Errorf("rndis: INIT failed with status 0x%08X", initCmplt.Status)
5050
}
5151

5252
log.Debug().
@@ -62,17 +62,17 @@ func (s *Session) Handshake() error {
6262
}
6363

6464
if err := s.sendControl(queryMac.Marshal()); err != nil {
65-
return fmt.Errorf("rndis: failed to query MAC: %w", err)
65+
return nil, fmt.Errorf("rndis: failed to query MAC: %w", err)
6666
}
6767

6868
queryResp, err := s.receiveControl()
6969
if err != nil {
70-
return fmt.Errorf("rndis: failed to receive MAC_QUERY_CMPLT: %w", err)
70+
return nil, fmt.Errorf("rndis: failed to receive MAC_QUERY_CMPLT: %w", err)
7171
}
7272

7373
macCmplt, err := UnmarshalQueryCmplt(queryResp)
7474
if err != nil {
75-
return err
75+
return nil, err
7676
}
7777
log.Info().
7878
Str("component", "rndis").
@@ -88,24 +88,24 @@ func (s *Session) Handshake() error {
8888
}
8989

9090
if err := s.sendControl(setFilter.Marshal()); err != nil {
91-
return fmt.Errorf("rndis: failed to set packet filter: %w", err)
91+
return nil, fmt.Errorf("rndis: failed to set packet filter: %w", err)
9292
}
9393

9494
setResp, err := s.receiveControl()
9595
if err != nil {
96-
return fmt.Errorf("rndis: failed to receive SET_CMPLT: %w", err)
96+
return nil, fmt.Errorf("rndis: failed to receive SET_CMPLT: %w", err)
9797
}
9898

9999
_, setStatus, err := UnmarshalSetCmplt(setResp)
100100
if err != nil {
101-
return err
101+
return nil, err
102102
}
103103
if setStatus != StatusSuccess {
104-
return fmt.Errorf("rndis: SET filter failed with status 0x%08X", setStatus)
104+
return nil, fmt.Errorf("rndis: SET filter failed with status 0x%08X", setStatus)
105105
}
106106

107107
log.Info().Str("component", "rndis").Msg("RNDIS handshake complete. Device in DATA mode.")
108-
return nil
108+
return macCmplt.Payload, nil
109109
}
110110

111111
// sendControl sends an encapsulated RNDIS command via USB control endpoint.

0 commit comments

Comments
 (0)