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
154 lines
6.1 KiB
Go
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
|
|
}
|