Skip to content

Commit 2de3134

Browse files
Milestone v0.4.0: Validated native utun interface creation on macOS (M-series)
1 parent a62d79b commit 2de3134

6 files changed

Lines changed: 199 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ Format:
1616

1717
## [Unreleased]
1818

19-
*(Add entries here as you work. Move to a version block on each git push.)*
19+
### 2026-03-28 14:00 — Implement utun creation on macOS
20+
- What: Created `internal/tun/utun_darwin.go` to support spawning virtual network interfaces via `AF_SYSTEM` / `SYSPROTO_CONTROL`; integrated into daemon callback.
21+
- Why: Milestone v0.4.0; enable the OS to talk to our daemon via a standard network handle.
22+
- Files: `internal/tun/utun.go`, `internal/tun/utun_darwin.go`, `internal/daemon/daemon.go`
23+
- Breaking: no
2024

2125
---
2226

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@
44

55
Works on macOS Ventura, Sonoma, and Sequoia on M1/M2/M3/M4 Macs.
66

7+
### Verifying the Connection
8+
Once the daemon is running and shows `Virtual network interface created interface=utunX`:
9+
10+
1. **Check Interface Status**:
11+
```bash
12+
ifconfig utunX # e.g., ifconfig utun3
13+
```
14+
2. **Monitor Logs**:
15+
The daemon will stay alive until you unplug the phone. You can see it "heartbeat" every 30 seconds.
16+
717
---
818

919
## Install

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.4.0 — 2026-03-28
14+
- Milestone: utun interface creation
15+
- What works: Native macOS virtual interface creation (`utun`). The daemon now spawns a real network interface on the Mac when the phone connects.
16+
- Next: Packet relay engine (Bulk transfer).
17+
18+
---
19+
1320
## v0.3.0 — 2026-03-28
1421
- Milestone: RNDIS handshake working (INIT/QUERY/SET)
1522
- What works: Raw USB Control transfers for RNDIS encapsulated commands; `INIT` handshake; `QUERY MAC` address retrieval; `SET` packet filter to enable promiscuous data mode. **Confirmed on real device.**

internal/daemon/daemon.go

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/princePal/droidtether/config"
1313
"github.com/princePal/droidtether/internal/rndis"
14+
"github.com/princePal/droidtether/internal/tun"
1415
"github.com/princePal/droidtether/internal/usb"
1516
)
1617

@@ -47,19 +48,48 @@ func (d *Daemon) Run() error {
4748
session := rndis.NewSession(dev)
4849
if err := session.Handshake(); err != nil {
4950
log.Error().Str("component", "daemon").Err(err).Msg("RNDIS Handshake failed")
50-
// Cleanup happens naturally via watcher detach
51+
return
5152
}
52-
53-
// Wait a bit to verify logs in dev mode
54-
time.Sleep(3 * time.Second)
55-
})
5653

57-
watcher.OnDetach(func() {
54+
// Milestone v0.4.0: Create virtual network interface
55+
iface, err := tun.OpenUTUN(0)
56+
if err != nil {
57+
log.Error().Str("component", "daemon").Err(err).Msg("Failed to create utun interface")
58+
return
59+
}
60+
defer iface.Close()
61+
5862
log.Info().
5963
Str("component", "daemon").
60-
Msg("Android RNDIS device detached.")
61-
// In the future:
62-
// session.Stop()
64+
Str("interface", iface.Name()).
65+
Msg("Virtual network interface created and ACTIVE. You can now run 'ifconfig' on it.")
66+
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+
71+
stopChan := make(chan bool)
72+
watcher.OnDetach(func() {
73+
log.Info().Str("component", "daemon").Msg("Android RNDIS device detached.")
74+
close(stopChan)
75+
})
76+
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
92+
<-stopChan
6393
})
6494

6595
// Start the USB hotplug watcher

internal/tun/utun.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package tun
2+
3+
import (
4+
"io"
5+
)
6+
7+
// Interface represents a virtual network interface (TUN).
8+
type Interface interface {
9+
io.ReadWriteCloser
10+
Name() string
11+
}

internal/tun/utun_darwin.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package tun
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"unsafe"
7+
8+
"golang.org/x/sys/unix"
9+
)
10+
11+
// utun configuration constants for macOS
12+
const (
13+
AF_SYSTEM = 32
14+
SYSPROTO_CONTROL = 2
15+
AF_SYS_CONTROL = 2
16+
UTUN_CONTROL_NAME = "com.apple.net.utun_control"
17+
)
18+
19+
// utunInterface implements Interface for Darwin using AF_SYSTEM.
20+
type utunInterface struct {
21+
f *os.File
22+
name string
23+
}
24+
25+
func (i *utunInterface) Read(p []byte) (n int, err error) {
26+
return i.f.Read(p)
27+
}
28+
29+
func (i *utunInterface) Write(p []byte) (n int, err error) {
30+
return i.f.Write(p)
31+
}
32+
33+
func (i *utunInterface) Close() error {
34+
return i.f.Close()
35+
}
36+
37+
func (i *utunInterface) Name() string {
38+
return i.name
39+
}
40+
41+
// OpenUTUN creates a new utun interface on macOS.
42+
// If index is 0, the system chooses the first available (utun0, utun1, etc.).
43+
func OpenUTUN(index int) (Interface, error) {
44+
fd, err := unix.Socket(AF_SYSTEM, unix.SOCK_DGRAM, SYSPROTO_CONTROL)
45+
if err != nil {
46+
return nil, fmt.Errorf("utun: failed to open system socket: %w", err)
47+
}
48+
49+
// 1. Find the control ID for "com.apple.net.utun_control"
50+
info := struct {
51+
ctl_id uint32
52+
ctl_name [96]byte
53+
}{}
54+
copy(info.ctl_name[:], UTUN_CONTROL_NAME)
55+
56+
// CTLIOCGINFO
57+
err = ioctl(fd, 0xc0644e03, unsafe.Pointer(&info))
58+
if err != nil {
59+
unix.Close(fd)
60+
return nil, fmt.Errorf("utun: failed to get utun control info: %w", err)
61+
}
62+
63+
// 2. Connect to the utun control
64+
sc := struct {
65+
sc_len uint8
66+
sc_family uint8
67+
ss_sysaddr uint16
68+
sc_id uint32
69+
sc_unit uint32
70+
sc_reserved [5]uint32
71+
}{
72+
sc_len: 32,
73+
sc_family: AF_SYSTEM,
74+
ss_sysaddr: AF_SYS_CONTROL,
75+
sc_id: info.ctl_id,
76+
sc_unit: uint32(index), // 0 = automatic
77+
}
78+
79+
err = connect(fd, unsafe.Pointer(&sc), 32)
80+
if err != nil {
81+
unix.Close(fd)
82+
return nil, fmt.Errorf("utun: failed to connect to utun control: %w", err)
83+
}
84+
85+
// 3. Get the interface name (e.g., utun3)
86+
nameBuf := make([]byte, 64)
87+
nameLen := uint32(len(nameBuf))
88+
// UTUN_OPT_IFNAME (Option 2)
89+
err = getsockopt(fd, SYSPROTO_CONTROL, 2, unsafe.Pointer(&nameBuf[0]), &nameLen)
90+
if err != nil {
91+
unix.Close(fd)
92+
return nil, fmt.Errorf("utun: failed to get interface name: %w", err)
93+
}
94+
95+
ifname := string(nameBuf[:nameLen-1]) // trim null byte
96+
return &utunInterface{
97+
f: os.NewFile(uintptr(fd), ifname),
98+
name: ifname,
99+
}, nil
100+
}
101+
102+
// Wrapper for unix.Ioctl
103+
func ioctl(fd int, request uintptr, argp unsafe.Pointer) error {
104+
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), request, uintptr(argp))
105+
if errno != 0 {
106+
return errno
107+
}
108+
return nil
109+
}
110+
111+
// Wrapper for unix.Connect
112+
func connect(fd int, addr unsafe.Pointer, len uint32) error {
113+
_, _, errno := unix.Syscall(unix.SYS_CONNECT, uintptr(fd), uintptr(addr), uintptr(len))
114+
if errno != 0 {
115+
return errno
116+
}
117+
return nil
118+
}
119+
120+
// Wrapper for unix.Getsockopt
121+
func getsockopt(fd int, level, name int, val unsafe.Pointer, len *uint32) error {
122+
_, _, errno := unix.Syscall6(unix.SYS_GETSOCKOPT, uintptr(fd), uintptr(level), uintptr(name), uintptr(val), uintptr(unsafe.Pointer(len)), 0)
123+
if errno != 0 {
124+
return errno
125+
}
126+
return nil
127+
}

0 commit comments

Comments
 (0)