// Command server runs the DriverVault API Server. It is the single gateway // between clients (web app, phone app, Home Assistant plugin, ESP32 device) and // the PocketBase database kept behind it — clients never talk to PocketBase // directly. It also serves the superadmin web panel at the server root. package main import ( "context" "errors" "log" "net/http" "os" "os/signal" "syscall" "time" "drivervault/apiserver/internal/api" "drivervault/apiserver/internal/bootstrap" "drivervault/apiserver/internal/config" "drivervault/apiserver/internal/pb" ) func main() { log.SetFlags(log.LstdFlags | log.Lmsgprefix) log.SetPrefix("[api] ") cfg := config.Load() client := pb.New(cfg.PocketBaseURL, cfg.PocketBaseAdminEmail, cfg.PocketBaseAdminPassword) // Authenticate the service account up front so the first request doesn't pay // for it. A failure is NOT fatal: the PocketBase connection is editable at // runtime from the panel, so a superadmin must be able to log in and fix a // bad address or bad credentials. if cfg.AdminConfigured() { authCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) if err := client.Authenticate(authCtx); err != nil { log.Printf("WARNING: PocketBase service account auth failed (%s): %v", cfg.PocketBaseURL, err) log.Printf("fix it under Settings → PocketBase in the panel at %s", cfg.Addr) } else { log.Printf("authenticated to PocketBase at %s", cfg.PocketBaseURL) } cancel() } else { log.Println("WARNING: POCKETBASE_ADMIN_EMAIL/PASSWORD unset — management endpoints return 503 until configured") } // Bring PocketBase up to the expected schema (create missing collections, // reconcile existing ones) and ensure the super-admin. Idempotent, so it runs // on every boot. Non-fatal: a fresh PocketBase that isn't reachable yet, or a // bad service account, must not stop the panel from coming up so a superadmin // can log in and fix the connection. if cfg.Bootstrap && cfg.AdminConfigured() { bootCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) if err := bootstrap.Run(bootCtx, client, bootstrap.Options{ UsersCollection: cfg.UsersCollection, SuperAdminEmail: cfg.SuperAdminEmail, SuperAdminPassword: cfg.SuperAdminPassword, SuperAdminName: cfg.SuperAdminName, }); err != nil { log.Printf("WARNING: bootstrap failed: %v", err) } else { log.Println("bootstrap: PocketBase schema ready") } cancel() } srv := api.New(cfg, client) // Not fatal: the plugin settings live in PocketBase, which may not be // reachable yet on a cold stack or before a service account is configured. // StartPlugins logs the reason and retries in the background; the plugin // endpoints answer 503 until the read succeeds. _ = srv.StartPlugins() httpServer := &http.Server{ Addr: cfg.Addr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, } go func() { log.Printf("listening on %s (PocketBase: %s, panel at /)", cfg.Addr, cfg.PocketBaseURL) if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("server error: %v", err) } }() // Graceful shutdown on SIGINT/SIGTERM. stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) <-stop log.Println("shutting down...") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() srv.Stop(ctx) if err := httpServer.Shutdown(ctx); err != nil { log.Printf("shutdown error: %v", err) } log.Println("stopped") }