Skip to content

Commit b4c897b

Browse files
committed
feat(cli): implement doctor, windows-setup, windows-cleanup commands
1 parent 21f6083 commit b4c897b

2 files changed

Lines changed: 482 additions & 3 deletions

File tree

cmd/devproxy/main.go

Lines changed: 225 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

1723
func 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

Comments
 (0)