package main import ( "context" "errors" "fmt" "git.misaka.ren/m1saka/dsh-web/internal/api" "git.misaka.ren/m1saka/dsh-web/internal/store" "golang.org/x/crypto/bcrypt" "log" "net/http" "os" "os/signal" "path/filepath" "strings" "syscall" "time" ) func main() { if err := run(); err != nil { log.Fatal(err) } } func run() error { if err := api.ValidateAuthConfig(); err != nil { return err } root, err := filepath.Abs(getenv("CONTAINER_DATA_ROOT", getenv("DATA_ROOT", "./data"))) if err != nil { return err } if err = os.MkdirAll(root, 0700); err != nil { return err } if os.Getenv("HOST_DATA_ROOT") == "" { os.Setenv("HOST_DATA_ROOT", root) } s, err := store.Open(filepath.Join(root, "db.sqlite")) if err != nil { return err } defer s.Close() var count int if err = s.DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil { return err } if count == 0 { password := os.Getenv("ADMIN_PASSWORD") if len(password) < 12 || len(password) > 72 { return fmt.Errorf("first startup requires ADMIN_PASSWORD of 12–72 bytes") } hash, e := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if e != nil { return e } if err = s.CreateUser(store.User{ID: store.ID(), Username: getenv("ADMIN_USERNAME", "admin"), Admin: true, Quota: api.DefaultQuota()}, string(hash)); err != nil { return err } } panel := api.New(s, root) if err := panel.ConfigError(); err != nil { return err } checkCtx, checkCancel := context.WithTimeout(context.Background(), 20*time.Second) if err := panel.Reconcile(checkCtx); err != nil { log.Printf("startup reconciliation unavailable; Docker connectivity must be checked: %v", err) } checkCancel() if err := panel.StartImageBuilds(); err != nil { return fmt.Errorf("image build recovery: %w", err) } backend := panel.Handler() static := http.FileServer(http.Dir(getenv("WEB_DIR", "web/dist"))) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "no-referrer") if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/dsh/") { backend.ServeHTTP(w, r) return } if r.URL.Path == "/healthz" { w.Write([]byte("ok")) return } if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "method not allowed", 405) return } // The embedded runtime visits the same-origin SPA renewal bridge. // Keep cross-origin framing blocked without blocking that bridge. w.Header().Set("X-Frame-Options", "SAMEORIGIN") w.Header().Set("Content-Security-Policy", "frame-ancestors 'self'") static.ServeHTTP(w, r) }) srv := &http.Server{Addr: getenv("LISTEN_ADDR", "127.0.0.1:8088"), Handler: handler, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20} ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if err := panel.StartIntents(ctx); err != nil { return fmt.Errorf("creation intent recovery: %w", err) } go panel.RunQuotaMonitor(ctx) return serve(ctx, srv) } func serve(ctx context.Context, srv *http.Server) error { drained := make(chan struct{}) finished := make(chan struct{}) defer close(finished) go func() { defer close(drained) select { case <-ctx.Done(): case <-finished: return } c, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if err := srv.Shutdown(c); err != nil { log.Printf("graceful shutdown deadline reached: %v", err) _ = srv.Close() } }() log.Printf("dsh panel listening on %s", srv.Addr) err := srv.ListenAndServe() if errors.Is(err, http.ErrServerClosed) { <-drained return nil } return err } func getenv(k, d string) string { if v := os.Getenv(k); v != "" { return v } return d }