44 "fmt"
55 "log/slog"
66 "os"
7+ "os/exec"
8+ "strings"
79
810 "github.com/matvejefimovyh/ghost-ship/internal/audit"
911 "github.com/matvejefimovyh/ghost-ship/internal/config"
@@ -69,6 +71,10 @@ func main() {
6971 }
7072 cmdStatus (os .Args [2 ])
7173 }
74+ case "doctor" :
75+ {
76+ cmdDoctor (os .Args [2 :])
77+ }
7278 default :
7379 fmt .Printf ("Unknown command: %s\n " , command )
7480 printUsage ()
@@ -84,13 +90,15 @@ Available commands:
8490 migrate <from> <to> - Migrate infrastructure from one server to another
8591 audit <IP> - Run comprehensive security audit
8692 status <IP> - Check node status
93+ doctor [config] - Run local preflight checks before deployment
8794
8895Examples:
8996 gs land 192.168.1.100 configs/relay-ru.yaml
9097 gs extract configs/relay-ru.yaml
9198 gs migrate --from 192.168.1.100 --to 192.168.1.101
9299 gs audit 192.168.1.100
93100 gs status 192.168.1.100
101+ gs doctor configs/example-node.yaml
94102` )
95103}
96104
@@ -267,6 +275,117 @@ func cmdStatus(ip string) {
267275 fmt .Println ("Status command - not yet implemented" )
268276}
269277
278+ type doctorCheck struct {
279+ name string
280+ ok bool
281+ detail string
282+ }
283+
284+ func cmdDoctor (args []string ) {
285+ configFile := "configs/example-node.yaml"
286+ targetIP := ""
287+
288+ for i := 0 ; i < len (args ); i ++ {
289+ switch args [i ] {
290+ case "--config" :
291+ if i + 1 < len (args ) {
292+ configFile = args [i + 1 ]
293+ i ++
294+ }
295+ case "--ip" :
296+ if i + 1 < len (args ) {
297+ targetIP = args [i + 1 ]
298+ i ++
299+ }
300+ default :
301+ if ! strings .HasPrefix (args [i ], "-" ) && configFile == "configs/example-node.yaml" {
302+ configFile = args [i ]
303+ }
304+ }
305+ }
306+
307+ checks := make ([]doctorCheck , 0 , 8 )
308+ addCheck := func (name string , ok bool , detail string ) {
309+ checks = append (checks , doctorCheck {name : name , ok : ok , detail : detail })
310+ }
311+
312+ addCheck ("go toolchain" , commandAvailable ("go" ), commandDetail ("go" ))
313+ addCheck ("cmake" , commandAvailable ("cmake" ), commandDetail ("cmake" ))
314+ addCheck ("git" , commandAvailable ("git" ), commandDetail ("git" ))
315+ addCheck ("C++ compiler" , commandAvailable ("g++" ) || commandAvailable ("clang++" ), "g++ or clang++ must be available for the C++ streamer" )
316+
317+ sshKey := os .Getenv ("SSH_KEY" )
318+ if sshKey == "" {
319+ addCheck ("SSH key" , false , "SSH_KEY is not set" )
320+ } else if _ , err := os .Stat (sshKey ); err != nil {
321+ addCheck ("SSH key" , false , fmt .Sprintf ("SSH_KEY points to an unreadable file: %v" , err ))
322+ } else {
323+ addCheck ("SSH key" , true , fmt .Sprintf ("found at %s" , sshKey ))
324+ }
325+
326+ cfg , err := config .Load (configFile )
327+ if err != nil {
328+ addCheck ("config" , false , err .Error ())
329+ } else {
330+ addCheck ("config" , true , fmt .Sprintf ("loaded %s for %s (%s)" , configFile , cfg .Node .IP , cfg .Node .Role ))
331+ if targetIP == "" {
332+ targetIP = cfg .Node .IP
333+ }
334+ }
335+
336+ if targetIP != "" {
337+ if sshKey == "" {
338+ addCheck ("SSH connectivity" , true , "skipped; set SSH_KEY to verify remote connectivity" )
339+ } else {
340+ user := "root"
341+ if cfg != nil && cfg .Node .USERNAME != "" {
342+ user = cfg .Node .USERNAME
343+ }
344+ client , err := sshutil .NewSSHClient (user , targetIP , 22 , sshKey )
345+ if err != nil {
346+ addCheck ("SSH connectivity" , false , err .Error ())
347+ } else {
348+ _ = client .Close ()
349+ addCheck ("SSH connectivity" , true , fmt .Sprintf ("connected as %s@%s" , user , targetIP ))
350+ }
351+ }
352+ } else {
353+ addCheck ("SSH connectivity" , true , "skipped; pass --ip or include node.ip in the config to verify a target" )
354+ }
355+
356+ failed := false
357+ fmt .Println ("Preflight checks:" )
358+ for _ , check := range checks {
359+ status := "OK"
360+ if ! check .ok {
361+ status = "WARN"
362+ if check .name == "config" || check .name == "SSH connectivity" {
363+ failed = true
364+ }
365+ }
366+ fmt .Printf (" [%s] %s - %s\n " , status , check .name , check .detail )
367+ }
368+
369+ if failed {
370+ os .Exit (1 )
371+ }
372+
373+ fmt .Println ("\n ✓ Preflight completed" )
374+ }
375+
376+ func commandAvailable (name string ) bool {
377+ _ , err := exec .LookPath (name )
378+ return err == nil
379+ }
380+
381+ func commandDetail (name string ) string {
382+ path , err := exec .LookPath (name )
383+ if err != nil {
384+ return fmt .Sprintf ("%s not found in PATH" , name )
385+ }
386+ return fmt .Sprintf ("found at %s" , path )
387+ }
388+
270389func cmdAudit (ip string ) {
271390 slog .Info ("Starting security audit" , "target_ip" , ip )
272391
0 commit comments