// Hamlet — Proxmox-style web console for a bare-metal Debian host. package main import ( "context" "errors" "flag" "fmt" "log" "net/http" "os" "os/signal" "os/user" "path/filepath" "syscall" "time" "golang.org/x/term" "hamlet/internal/api" "hamlet/internal/auth" "hamlet/internal/config" "hamlet/internal/store" hterm "hamlet/internal/term" "hamlet/internal/web" ) var version = "dev" func main() { defaultCfg := os.Getenv("HAMLET_CONFIG") if defaultCfg == "" { home, _ := os.UserHomeDir() defaultCfg = filepath.Join(home, "apps", "hamlet", "config.json") } cfgPath := flag.String("config", defaultCfg, "path to config.json") flag.Usage = func() { fmt.Fprintf(os.Stderr, "usage: hamlet [-config path] [serve|passwd|version]\n") flag.PrintDefaults() } flag.Parse() cmd := "serve" if flag.NArg() > 0 { cmd = flag.Arg(0) } cfg, err := config.Load(*cfgPath) if err != nil { log.Fatal(err) } switch cmd { case "version": fmt.Println("hamlet", version) case "passwd": if err := passwd(cfg); err != nil { log.Fatal(err) } case "serve": if err := serve(cfg); err != nil { log.Fatal(err) } default: flag.Usage() os.Exit(2) } } func passwd(cfg *config.Config) error { fmt.Fprint(os.Stderr, "New password: ") p1, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Fprintln(os.Stderr) if err != nil { return err } fmt.Fprint(os.Stderr, "Again: ") p2, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Fprintln(os.Stderr) if err != nil { return err } if string(p1) != string(p2) { return errors.New("passwords do not match") } if len(p1) < 12 { return errors.New("password must be at least 12 characters") } h, err := auth.HashPassword(string(p1)) if err != nil { return err } cfg.PasswordHash = h if err := cfg.Save(); err != nil { return err } fmt.Fprintln(os.Stderr, "password set in", cfg.Path()) return nil } func serve(cfg *config.Config) error { if cfg.PasswordHash == "" { return fmt.Errorf("no password set — run: hamlet -config %s passwd", cfg.Path()) } if err := os.MkdirAll(cfg.DataDir(), 0o700); err != nil { return err } st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json")) if err != nil { return err } sessions := auth.NewSessions(st) u, err := user.Current() if err != nil { return err } shell := os.Getenv("SHELL") if shell == "" { shell = "/bin/bash" } mgr := hterm.NewManager(shell, u.HomeDir) mgr.OnChange = func(list []hterm.Info) { _ = st.Update(func(s *store.State) { s.Terminals = s.Terminals[:0] for _, t := range list { s.Terminals = append(s.Terminals, store.Terminal{ID: t.ID, Name: t.Name}) } }) } // Re-open the shells that existed before the last restart (fresh processes, // same names/order) so the sidebar comes back as it was. var saved []store.Terminal st.View(func(s *store.State) { saved = append(saved, s.Terminals...) }) for _, t := range saved { if _, err := mgr.Create(t.ID, t.Name); err != nil { log.Printf("restore terminal %q: %v", t.Name, err) } } srv := api.New(cfg, sessions, mgr, web.FS(), version) hs := &http.Server{ Addr: cfg.Listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, } go func() { sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) <-sig log.Print("shutting down") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = hs.Shutdown(ctx) mgr.Shutdown() }() log.Printf("hamlet %s listening on %s as %s (shell %s)", version, cfg.Listen, u.Username, shell) if err := hs.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { return err } return nil }