package api import ( "context" "errors" "net/http" "github.com/danielgtaylor/huma/v2" "github.com/v10networks/nmon/internal/auth" "github.com/v10networks/nmon/internal/store" ) // Setup errors the app returns; the handlers map them to 409. var ( // ErrSetupClosed means install mode is off: setup was completed, or a sign-in was configured from the start. ErrSetupClosed = errors.New("setup is closed") // ErrSetupNoSignIn means setup cannot finish because no sign-in method is configured yet. ErrSetupNoSignIn = errors.New("no sign-in method is configured yet") ) // OIDCSecretName is where the wizard stores the OIDC client secret. const OIDCSecretName = "oidc-client" // SetupAdmin is the part of the running app the wizard drives. Install mode is a property of the process, so the // API asks the app rather than keeping its own copy. type SetupAdmin interface { // InstallMode reports whether install mode is on right now. InstallMode() bool // SetupCompleted reports whether setup was ever completed (the stored one-way flag). SetupCompleted(ctx context.Context) (bool, error) // SignIn writes the break-glass file, stores the OIDC secret and sets auth.* in config.yaml. A bad value wraps // ErrInvalidSettings; install mode being off is ErrSetupClosed. SignIn(ctx context.Context, actor string, in SetupSignInBody) (SetupSignIn, error) // Complete re-checks the sign-in, marks setup completed for good and swaps the live authentication in. It // returns ErrSetupClosed or ErrSetupNoSignIn. Complete(ctx context.Context, actor string) (SetupComplete, error) } func (s *server) registerSetup(api huma.API) { tags := []string{"setup"} huma.Register(api, huma.Operation{ OperationID: "get-setup", Method: http.MethodGet, Path: "/api/v1/setup", Tags: tags, Summary: "First-install state", Description: "Public. Says whether install mode is on (the UI then sends every page to /setup) and whether setup was " + "completed. Progress counts are filled in only while install mode is on.", }, s.getSetup) huma.Register(api, huma.Operation{ OperationID: "setup-sign-in", Method: http.MethodPost, Path: "/api/v1/setup/signin", Tags: tags, Summary: "Set up sign-in (install mode only)", Description: "Creates the break-glass admin (bcrypt, written to /breakglass with mode 0600) and optionally " + "OIDC. 409 once setup is completed.", Errors: []int{http.StatusConflict, http.StatusUnprocessableEntity, http.StatusServiceUnavailable}, }, s.setupSignIn) huma.Register(api, huma.Operation{ OperationID: "setup-recommended-policies", Method: http.MethodPost, Path: "/api/v1/setup/recommended-policies", Tags: tags, Summary: "Create the recommended alert policies (install mode only)", Description: "device_down, device_warning, interface_errors, cpu_high, memory_high and disk_full, each for everything. " + "Idempotent: a policy that exists by name, or any policy already bound to the rule, is left alone.", Errors: []int{http.StatusConflict, http.StatusServiceUnavailable}, }, s.setupPolicies) huma.Register(api, huma.Operation{ OperationID: "setup-complete", Method: http.MethodPost, Path: "/api/v1/setup/complete", Tags: tags, Summary: "Finish setup (install mode only)", Description: "409 unless a sign-in method is configured. Ends install mode permanently and switches authentication on " + "without a restart; the browser then signs in at the returned URL.", Errors: []int{http.StatusConflict, http.StatusServiceUnavailable}, }, s.setupComplete) } func (s *server) setupAdmin() (SetupAdmin, error) { if s.Setup == nil { return nil, errNotConfigured("setup") } return s.Setup, nil } func (s *server) getSetup(ctx context.Context, _ *struct{}) (*struct{ Body SetupState }, error) { a, err := s.setupAdmin() if err != nil { return nil, err } done, err := a.SetupCompleted(ctx) if err != nil { return nil, s.storeErr(err) } out := SetupState{InstallMode: a.InstallMode(), Completed: done, Defaults: setupDefaults()} if out.InstallMode { out.Steps = s.setupSteps(ctx) } return &struct{ Body SetupState }{out}, nil } func setupDefaults() SetupDefaults { d := formDefaults() return SetupDefaults{BreakGlassUser: "admin", MinSignInLength: auth.MinBreakGlassPassword, OIDCSecretName: OIDCSecretName, Timezone: d.Timezone, Retention: d.Retention, PollInterval: d.PollerInterval} } // setupSteps counts what exists. A failed read counts as nothing: it only decides which step the UI opens on. func (s *server) setupSteps(ctx context.Context) SetupSteps { var st SetupSteps if _, total, err := s.Store.ListDevices(ctx, store.DeviceFilter{}, store.Page{Limit: 1}); err == nil { st.Devices = int(total) } if ps, err := s.Store.ListAlertPolicies(ctx); err == nil { st.Policies = len(ps) } if f, err := s.formAdmin(); err == nil { cfg, _ := f.CurrentConfig() st.Receivers = len(cfg.Alerting.Receivers) st.SignIn = cfg.Auth.OIDC != nil || cfg.Auth.BreakGlassFile != "" } return st } // setupErr maps the app's setup errors to problems. func (s *server) setupErr(err error) error { switch { case errors.Is(err, ErrSetupClosed): return huma.Error409Conflict("setup is closed: it was completed, or a sign-in method was configured before first start") case errors.Is(err, ErrSetupNoSignIn): return huma.Error409Conflict("set up a sign-in method first: setup cannot finish without one") case errors.Is(err, ErrInvalidSettings): return settingsProblem(trimSentinel(err, ErrInvalidSettings), err) } s.Logger.Error("setup", "err", err) return huma.Error500InternalServerError("setup failed; see the log") } func (s *server) setupSignIn(ctx context.Context, in *struct{ Body SetupSignInBody }) (*struct{ Body SetupSignIn }, error) { a, err := s.setupAdmin() if err != nil { return nil, err } out, err := a.SignIn(ctx, actorOf(ctx), in.Body) if err != nil { return nil, s.setupErr(err) } return &struct{ Body SetupSignIn }{out}, nil } func (s *server) setupComplete(ctx context.Context, _ *struct{}) (*struct{ Body SetupComplete }, error) { a, err := s.setupAdmin() if err != nil { return nil, err } out, err := a.Complete(ctx, actorOf(ctx)) if err != nil { return nil, s.setupErr(err) } return &struct{ Body SetupComplete }{out}, nil }