Files
nmon/internal/api/setup.go
Vibe Coder f31fb3712a
Some checks failed
ci / lint (push) Successful in 4m19s
ci / openapi (push) Successful in 29s
ci / postgres (push) Successful in 4m55s
ci / test (push) Failing after 19m48s
Add settings forms, first-install wizard, webhook presets, help and poll tools
Make nmon easier to install and run without editing YAML by hand:

- Settings: point-and-click forms over config.yaml (GET/PUT
  /api/v1/settings/form); the YAML editor moves to /settings/yaml.
- First install: with no sign-in configured nmon starts in install
  mode, serves only the setup wizard (sign-in, basics, devices,
  alerting) and closes it for good once sign-in is saved. A
  self-signed certificate is generated when no TLS source is given.
  poller.interval becomes a config setting.
- Reverse proxies: opt-in --http-listen plain-HTTP listener beside
  HTTPS; sign-in stays required.
- Webhooks: Generic, Teams Adaptive Card, Slack and Discord presets,
  template preview and test send.
- Help: the operations, coverage and profile docs served at /help.
- Polling: per-device "Poll now", a dry-run test poll returning an
  SNMP trace, and time-limited in-memory SNMP debug recording, in the
  UI and as `nmon device poll`.
- Ansible: deploys into install mode when no sign-in is set, and no
  longer overwrites the config.yaml the wizard wrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
2026-10-25 06:31:12 +00:00

154 lines
6.1 KiB
Go

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 <data-dir>/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
}