@@ -4,14 +4,20 @@ import (
44 "context"
55 "encoding/json"
66 "fmt"
7+ "io"
78 "log/slog"
89 "net"
910 "net/http"
1011 "os"
12+ "os/exec"
13+ "path/filepath"
14+ "strings"
1115
1216 "github.com/alysnnix/devproxy/internal/daemon"
1317 "github.com/alysnnix/devproxy/internal/ipman"
1418 "github.com/alysnnix/devproxy/internal/state"
19+ "github.com/alysnnix/devproxy/internal/windows"
20+ "github.com/miekg/dns"
1521)
1622
1723func main () {
@@ -29,11 +35,11 @@ func main() {
2935 case "cleanup" :
3036 runCleanup ()
3137 case "doctor" :
32- fmt . Println ( "devproxy doctor — not yet implemented" )
38+ runDoctor ( )
3339 case "windows-setup" :
34- fmt . Println ( "devproxy windows-setup — not yet implemented" )
40+ runWindowsSetup ( )
3541 case "windows-cleanup" :
36- fmt . Println ( "devproxy windows-cleanup — not yet implemented" )
42+ runWindowsCleanup ( )
3743 default :
3844 fmt .Fprintf (os .Stderr , "unknown command: %s\n " , os .Args [1 ])
3945 os .Exit (1 )
@@ -134,3 +140,219 @@ func unixClient(sockPath string) *http.Client {
134140 },
135141 }
136142}
143+
144+ // dockerSocketPaths lists the Docker socket paths to try, in order.
145+ var dockerSocketPaths = []string {
146+ "/var/run/docker.sock" ,
147+ "/mnt/wsl/docker-desktop/shared-sockets/guest-services/docker.sock" ,
148+ }
149+
150+ // checkDockerSocket verifies that the Docker socket is accessible.
151+ func checkDockerSocket () error {
152+ for _ , p := range dockerSocketPaths {
153+ if _ , err := os .Stat (p ); err == nil {
154+ return nil
155+ }
156+ }
157+ return fmt .Errorf ("Docker socket not found (tried %s)" , strings .Join (dockerSocketPaths , ", " ))
158+ }
159+
160+ // checkDNS queries devproxy-health.localhost on 127.0.53.53:53.
161+ func checkDNS () error {
162+ m := new (dns.Msg )
163+ m .SetQuestion ("devproxy-health.localhost." , dns .TypeA )
164+
165+ c := new (dns.Client )
166+ resp , _ , err := c .Exchange (m , "127.0.53.53:53" )
167+ if err != nil {
168+ return fmt .Errorf ("DNS query failed: %w" , err )
169+ }
170+ if resp .Rcode != dns .RcodeSuccess {
171+ return fmt .Errorf ("DNS query returned rcode %d (%s)" , resp .Rcode , dns .RcodeToString [resp .Rcode ])
172+ }
173+ return nil
174+ }
175+
176+ // daemonSocketPath is the default path to the devproxy daemon socket.
177+ var daemonSocketPath = "/run/devproxy/devproxy.sock"
178+
179+ // checkDaemonAPI connects to the daemon Unix socket and GETs /health.
180+ func checkDaemonAPI () error {
181+ client := unixClient (daemonSocketPath )
182+ resp , err := client .Get ("http://devproxy/health" )
183+ if err != nil {
184+ return fmt .Errorf ("cannot reach daemon: %w" , err )
185+ }
186+ defer resp .Body .Close ()
187+
188+ body , _ := io .ReadAll (resp .Body )
189+ if resp .StatusCode != http .StatusOK {
190+ return fmt .Errorf ("daemon unhealthy (status %d): %s" , resp .StatusCode , string (body ))
191+ }
192+ return nil
193+ }
194+
195+ // checkResolved runs resolvectl to verify systemd-resolved can resolve
196+ // devproxy-health.localhost.
197+ func checkResolved () error {
198+ cmd := exec .Command ("resolvectl" , "query" , "devproxy-health.localhost" )
199+ output , err := cmd .CombinedOutput ()
200+ if err != nil {
201+ return fmt .Errorf ("resolvectl failed: %s" , strings .TrimSpace (string (output )))
202+ }
203+ return nil
204+ }
205+
206+ func runDoctor () {
207+ type check struct {
208+ name string
209+ fn func () error
210+ }
211+
212+ checks := []check {
213+ {"Docker socket" , checkDockerSocket },
214+ {"DNS server (127.0.53.53)" , checkDNS },
215+ {"Daemon API" , checkDaemonAPI },
216+ {"systemd-resolved" , checkResolved },
217+ }
218+
219+ allPassed := true
220+ dnsDirectOK := false
221+
222+ for _ , c := range checks {
223+ err := c .fn ()
224+ if err != nil {
225+ fmt .Printf (" [FAIL] %s: %v\n " , c .name , err )
226+ allPassed = false
227+ } else {
228+ fmt .Printf (" [ OK ] %s\n " , c .name )
229+ if c .name == "DNS server (127.0.53.53)" {
230+ dnsDirectOK = true
231+ }
232+ }
233+ }
234+
235+ // If DNS direct query works but systemd-resolved fails, give a hint.
236+ if dnsDirectOK {
237+ // Check if resolved was the one that failed
238+ if err := checkResolved (); err != nil {
239+ fmt .Println ()
240+ fmt .Println (" Hint: DNS server is running but systemd-resolved cannot resolve" )
241+ fmt .Println (" devproxy domains. Check that /etc/systemd/resolved.conf.d/" )
242+ fmt .Println (" has the correct devproxy DNS delegation configuration." )
243+ }
244+ }
245+
246+ fmt .Println ()
247+ if allPassed {
248+ fmt .Println ("All checks passed." )
249+ } else {
250+ fmt .Println ("Some checks failed. See above for details." )
251+ os .Exit (1 )
252+ }
253+ }
254+
255+ // getWSLIP returns the WSL2 eth0 IPv4 address.
256+ func getWSLIP () (string , error ) {
257+ iface , err := net .InterfaceByName ("eth0" )
258+ if err != nil {
259+ // Fallback: parse hostname -I output
260+ out , cmdErr := exec .Command ("hostname" , "-I" ).Output ()
261+ if cmdErr != nil {
262+ return "" , fmt .Errorf ("cannot detect WSL2 IP: eth0 not found and hostname -I failed" )
263+ }
264+ fields := strings .Fields (string (out ))
265+ if len (fields ) == 0 {
266+ return "" , fmt .Errorf ("hostname -I returned no addresses" )
267+ }
268+ return fields [0 ], nil
269+ }
270+
271+ addrs , err := iface .Addrs ()
272+ if err != nil {
273+ return "" , fmt .Errorf ("cannot get eth0 addresses: %w" , err )
274+ }
275+ for _ , addr := range addrs {
276+ if ipNet , ok := addr .(* net.IPNet ); ok && ipNet .IP .To4 () != nil {
277+ return ipNet .IP .String (), nil
278+ }
279+ }
280+ return "" , fmt .Errorf ("no IPv4 address found on eth0" )
281+ }
282+
283+ func runWindowsSetup () {
284+ // Connect to daemon to get project list.
285+ client := unixClient (daemonSocketPath )
286+ resp , err := client .Get ("http://devproxy/status" )
287+ if err != nil {
288+ fmt .Fprintf (os .Stderr , "error: daemon not running? %v\n " , err )
289+ os .Exit (1 )
290+ }
291+ defer resp .Body .Close ()
292+
293+ var status struct {
294+ Projects []struct {
295+ Name string `json:"name"`
296+ IP string `json:"ip"`
297+ Ports []state.PortMapping `json:"ports"`
298+ } `json:"projects"`
299+ }
300+ if err := json .NewDecoder (resp .Body ).Decode (& status ); err != nil {
301+ fmt .Fprintf (os .Stderr , "error decoding status: %v\n " , err )
302+ os .Exit (1 )
303+ }
304+
305+ if len (status .Projects ) == 0 {
306+ fmt .Fprintln (os .Stderr , "No active projects. Start some containers first." )
307+ os .Exit (1 )
308+ }
309+
310+ // Detect WSL2 IP.
311+ wslIP , err := getWSLIP ()
312+ if err != nil {
313+ fmt .Fprintf (os .Stderr , "error: %v\n " , err )
314+ os .Exit (1 )
315+ }
316+
317+ // Convert to state.Project for GenerateSetupScript.
318+ projects := make ([]* state.Project , len (status .Projects ))
319+ for i , p := range status .Projects {
320+ projects [i ] = & state.Project {
321+ Name : p .Name ,
322+ IP : p .IP ,
323+ Ports : p .Ports ,
324+ }
325+ }
326+
327+ script := windows .GenerateSetupScript (projects , wslIP )
328+
329+ // Check for --execute flag.
330+ executeFlag := false
331+ for _ , arg := range os .Args [2 :] {
332+ if arg == "--execute" {
333+ executeFlag = true
334+ }
335+ }
336+
337+ if executeFlag {
338+ // Write to temp file and print instructions.
339+ tmpDir := os .TempDir ()
340+ tmpFile := filepath .Join (tmpDir , "devproxy-setup.ps1" )
341+ if err := os .WriteFile (tmpFile , []byte (script ), 0644 ); err != nil {
342+ fmt .Fprintf (os .Stderr , "error writing temp file: %v\n " , err )
343+ os .Exit (1 )
344+ }
345+
346+ fmt .Printf ("Script written to: %s\n " , tmpFile )
347+ fmt .Println ()
348+ fmt .Println ("Run in an elevated PowerShell on Windows:" )
349+ fmt .Printf (" powershell.exe -ExecutionPolicy Bypass -File %s\n " , tmpFile )
350+ } else {
351+ fmt .Print (script )
352+ }
353+ }
354+
355+ func runWindowsCleanup () {
356+ script := windows .GenerateCleanupScript ()
357+ fmt .Print (script )
358+ }
0 commit comments