Compare commits
5 Commits
m4
...
m6-integra
| Author | SHA1 | Date | |
|---|---|---|---|
| 0754268e9e | |||
| a768a15fb6 | |||
| d7df853a35 | |||
| 81162b57b5 | |||
| fe4db76a6e |
@@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
var errUsage = errors.New("usage: nmon-devtools backfill|seed-demo|topology-agents|topology-add|webhook-sink|gate-dump|" +
|
||||
"proxmox-mock|push-replay|backfill-long [flags]" +
|
||||
"proxmox-mock|push-replay|backfill-long|oidc-mock|bcrypt [flags]" +
|
||||
" (see docs/dev.md)")
|
||||
|
||||
func main() {
|
||||
@@ -54,6 +54,10 @@ func run(ctx context.Context, args []string) error {
|
||||
return pushReplayCmd(ctx, args[1:])
|
||||
case "backfill-long":
|
||||
return backfillLongCmd(ctx, args[1:])
|
||||
case "bcrypt":
|
||||
return bcryptCmd(ctx, args[1:])
|
||||
case "oidc-mock":
|
||||
return oidcMockCmd(ctx, args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q: %w", args[0], errUsage)
|
||||
}
|
||||
@@ -88,7 +92,7 @@ func devFlagSet(name string) *flag.FlagSet {
|
||||
return fs
|
||||
}
|
||||
|
||||
// httpJSON sends an optional JSON body and decodes a 2xx JSON reply into out (nil to discard it).
|
||||
// httpJSON sends an optional JSON body (with the API token in NMON_TOKEN, if set) and decodes a 2xx reply into out (nil: discard).
|
||||
func httpJSON(ctx context.Context, hc *http.Client, method, url string, in, out any) error {
|
||||
var body io.Reader
|
||||
if in != nil {
|
||||
@@ -105,6 +109,9 @@ func httpJSON(ctx context.Context, hc *http.Client, method, url string, in, out
|
||||
if in != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if tok := os.Getenv("NMON_TOKEN"); tok != "" { // an nmon with authentication on needs an admin API token
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
102
cmd/nmon-devtools/oidcmock.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/v10networks/nmon/internal/auth/oidctest"
|
||||
)
|
||||
|
||||
// devTB lets the test-only mock issuer run outside a test binary: it supplies the three methods oidctest uses
|
||||
// (Helper, Fatal, Cleanup) and leaves the rest of testing.TB nil.
|
||||
type devTB struct {
|
||||
testing.TB
|
||||
cleanups []func()
|
||||
}
|
||||
|
||||
func (*devTB) Helper() {}
|
||||
|
||||
func (*devTB) Fatal(args ...any) { panic(fmt.Sprint(args...)) }
|
||||
|
||||
func (d *devTB) Cleanup(f func()) { d.cleanups = append(d.cleanups, f) }
|
||||
|
||||
func (d *devTB) close() {
|
||||
for i := len(d.cleanups) - 1; i >= 0; i-- {
|
||||
d.cleanups[i]()
|
||||
}
|
||||
}
|
||||
|
||||
// oidcMockCmd runs internal/auth/oidctest as a standalone OpenID Connect issuer for the M6 gate and for
|
||||
// screenshots. The issuer approves every login at once as the "current user"; a control listener changes who
|
||||
// that is: GET /user?sub=ID&name=NAME&groups=a,b on --control. The issuer URL is written to --issuer-file.
|
||||
func oidcMockCmd(ctx context.Context, args []string) error {
|
||||
fs := devFlagSet("oidc-mock")
|
||||
control := fs.String("control", "[::1]:0", "listen address of the control endpoint")
|
||||
clientID := fs.String("client-id", "nmon", "OIDC client id the issuer accepts")
|
||||
clientSecret := fs.String("client-secret", "s3cret", "OIDC client secret the issuer accepts")
|
||||
groupsClaim := fs.String("groups-claim", "groups", "name of the groups claim")
|
||||
issuerFile := fs.String("issuer-file", "", "file that receives the issuer URL")
|
||||
portFile := fs.String("port-file", "", "file that receives the control port")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return flagErr(err)
|
||||
}
|
||||
tb := &devTB{}
|
||||
defer tb.close()
|
||||
srv := oidctest.New(tb)
|
||||
srv.ClientID, srv.ClientSecret, srv.GroupsClaim = *clientID, *clientSecret, *groupsClaim
|
||||
ln, err := net.Listen("tcp", *control)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePort(*portFile, ln); err != nil {
|
||||
return err
|
||||
}
|
||||
if *issuerFile != "" {
|
||||
if err := os.WriteFile(*issuerFile, []byte(srv.Issuer()), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /user", func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
u := oidctest.User{Sub: q.Get("sub"), Name: q.Get("name"), Email: q.Get("email")}
|
||||
if g := q.Get("groups"); g != "" {
|
||||
u.Groups = strings.Split(g, ",")
|
||||
}
|
||||
if u.Sub == "" {
|
||||
http.Error(w, "sub is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
srv.SetUser(u)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
hs := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() { <-ctx.Done(); _ = hs.Close() }()
|
||||
fmt.Printf("oidc-mock issuer %s control %s\n", srv.Issuer(), ln.Addr())
|
||||
if err := hs.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bcryptCmd prints the bcrypt hash of its argument: the content of a break-glass password file.
|
||||
func bcryptCmd(_ context.Context, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: nmon-devtools bcrypt PASSWORD")
|
||||
}
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(args[0]), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(h))
|
||||
return nil
|
||||
}
|
||||
90
cmd/nmon/apiclient.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// apiFlags are the flags of every command that talks to a running nmon.
|
||||
type apiFlags struct {
|
||||
URL, Token string
|
||||
SkipVerify bool
|
||||
}
|
||||
|
||||
func (f *apiFlags) register(fs *flag.FlagSet) {
|
||||
fs.StringVar(&f.URL, "api", os.Getenv("NMON_API"), "base URL of the running nmon, such as https://nmon.example.net:8443 (or set NMON_API)")
|
||||
fs.StringVar(&f.Token, "token", os.Getenv("NMON_TOKEN"), "admin API token, nma_... (or set NMON_TOKEN)")
|
||||
fs.BoolVar(&f.SkipVerify, "tls-skip-verify", false,
|
||||
"do not verify the server certificate (a self-signed certificate on a trusted network)")
|
||||
}
|
||||
|
||||
func (f *apiFlags) validate() error {
|
||||
if f.URL == "" {
|
||||
return errors.New("--api is required (the URL of the running nmon)")
|
||||
}
|
||||
if f.Token == "" {
|
||||
return errors.New("--token is required (an admin API token; create one in Settings, or POST /api/v1/tokens)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// client returns an HTTP client without an overall timeout: a backup download can take minutes. ctx bounds each call.
|
||||
func (f *apiFlags) client() *http.Client {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if f.SkipVerify {
|
||||
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12} // opt-in flag for self-signed certificates
|
||||
}
|
||||
return &http.Client{Transport: tr, Timeout: 0}
|
||||
}
|
||||
|
||||
// call sends one request and returns the open response when the status is 2xx; any other status is an error
|
||||
// carrying the problem detail. The caller closes the body.
|
||||
func (f *apiFlags) call(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(f.URL, "/")+path, body)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+f.Token)
|
||||
resp, err := f.client().Do(req)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
defer cancel()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
return nil, problemError(resp)
|
||||
}
|
||||
resp.Body = &cancelOnClose{ReadCloser: resp.Body, cancel: cancel}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type cancelOnClose struct {
|
||||
io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (c *cancelOnClose) Close() error { defer c.cancel(); return c.ReadCloser.Close() }
|
||||
|
||||
// problemError turns an RFC 9457 response into an error with the server's explanation.
|
||||
func problemError(resp *http.Response) error {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
var p struct {
|
||||
Title, Detail string
|
||||
}
|
||||
if json.Unmarshal(b, &p) == nil && (p.Title != "" || p.Detail != "") {
|
||||
return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(p.Title+": "+p.Detail))
|
||||
}
|
||||
return fmt.Errorf("%s", resp.Status)
|
||||
}
|
||||
94
cmd/nmon/backup.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/v10networks/nmon/internal/backup"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register("backup", "write a backup of inventory, metrics, config and profiles from a running nmon", backupCmd)
|
||||
}
|
||||
|
||||
// backupCmd asks the running nmon to take its online backup and saves the stream. A running nmon owns the
|
||||
// stores, so the work happens inside it (POST /api/v1/admin/backup); this command only downloads the result.
|
||||
func backupCmd(ctx context.Context, args []string) error {
|
||||
var af apiFlags
|
||||
var includeKey bool
|
||||
fs := newFlagSet("backup", "backup FILE --api URL --token TOKEN [--include-key]")
|
||||
af.register(fs)
|
||||
fs.BoolVar(&includeKey, "include-key", false, "put the encryption key file in the archive (protect the archive like the key)")
|
||||
file, rest, err := leadingArg(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fs.Parse(rest); err != nil {
|
||||
return flagErr(err)
|
||||
}
|
||||
if file == "" && fs.NArg() == 1 {
|
||||
file = fs.Arg(0)
|
||||
}
|
||||
if file == "" || fs.NArg() > 1 {
|
||||
return errors.New("usage: nmon backup FILE --api URL --token TOKEN [--include-key]")
|
||||
}
|
||||
if err := af.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := af.call(ctx, "POST", "/api/v1/admin/backup?"+url.Values{"include_key": {strconv.FormatBool(includeKey)}}.Encode(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
included := resp.Header.Get("X-Nmon-Key-Included") == "true"
|
||||
n, err := saveAtomically(file, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: %w", err)
|
||||
}
|
||||
fmt.Println(backup.KeyNotice(included))
|
||||
fmt.Printf("backup: wrote %s (%d bytes)\n", file, n)
|
||||
return nil
|
||||
}
|
||||
|
||||
// leadingArg splits a positional first argument from the flags after it ("nmon backup FILE --api ...").
|
||||
func leadingArg(args []string) (string, []string, error) {
|
||||
if len(args) > 0 && len(args[0]) > 0 && args[0][0] != '-' {
|
||||
return args[0], args[1:], nil
|
||||
}
|
||||
return "", args, nil
|
||||
}
|
||||
|
||||
// saveAtomically writes r to a temp file beside path, syncs it and renames it, so an interrupted download never
|
||||
// leaves a truncated file under the final name.
|
||||
func saveAtomically(path string, r io.Reader) (n int64, err error) {
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".nmon-backup-*")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = os.Remove(f.Name()) // best effort: the download error is the one to report
|
||||
}
|
||||
}()
|
||||
if n, err = io.Copy(f, r); err != nil {
|
||||
_ = f.Close() // best effort: the copy error is the one to report
|
||||
return 0, err
|
||||
}
|
||||
if err = f.Sync(); err != nil {
|
||||
_ = f.Close() // best effort: the sync error is the one to report
|
||||
return 0, err
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err = os.Chmod(f.Name(), 0o600); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, os.Rename(f.Name(), path)
|
||||
}
|
||||
@@ -31,8 +31,6 @@ func init() {
|
||||
for name, summary := range map[string]string{
|
||||
"discover": "run discovery for a device now",
|
||||
"profile": "test, record and draft device profiles",
|
||||
"backup": "write a backup of inventory, metrics, config and profiles",
|
||||
"restore": "restore a backup into a clean data dir",
|
||||
"loadtest": "run the scale test against simulated devices",
|
||||
} {
|
||||
if _, ok := commands[name]; !ok {
|
||||
|
||||
229
cmd/nmon/ops.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/v10networks/nmon/internal/app"
|
||||
"github.com/v10networks/nmon/internal/auth"
|
||||
"github.com/v10networks/nmon/internal/clock"
|
||||
"github.com/v10networks/nmon/internal/config"
|
||||
"github.com/v10networks/nmon/internal/keyrotate"
|
||||
"github.com/v10networks/nmon/internal/secrets"
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register("key", "rotate the encryption key: nmon key rotate (nmon stopped)", keyCmd)
|
||||
register("unlock", "clear break-glass lockouts: nmon unlock [IP|all]", unlockCmd)
|
||||
register("secret", "store a named secret before the first start: nmon secret set NAME", secretCmd)
|
||||
register("store", "operate the metric stores of a running nmon: nmon store restart [hot|long]", storeCmd)
|
||||
}
|
||||
|
||||
// openInventory opens the inventory named by the shared store flags. keys may be nil for commands that never
|
||||
// decrypt (unlock).
|
||||
func openInventory(ctx context.Context, f *config.Flags, kr *secrets.Keyring) (store.Store, error) {
|
||||
if f.DataDir == "" {
|
||||
return nil, errors.New("--data-dir is required")
|
||||
}
|
||||
dsn := f.DBDSN
|
||||
if dsn == "" {
|
||||
if f.DBDriver != store.DriverSQLite {
|
||||
return nil, fmt.Errorf("--db-dsn is required for --db-driver %s", f.DBDriver)
|
||||
}
|
||||
dsn = config.InventoryDB(f.DataDir)
|
||||
}
|
||||
return store.Open(ctx, f.DBDriver, dsn, kr, clock.Real())
|
||||
}
|
||||
|
||||
// keyCmd is "nmon key rotate": a new key version for new data, every credential re-sealed under it, the old
|
||||
// key kept in the file so older backups still decrypt. nmon must be stopped: a running process holds the old keys.
|
||||
func keyCmd(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 || args[0] != "rotate" {
|
||||
return errors.New("usage: nmon key rotate --data-dir DIR --secret-key-file FILE (stop nmon first)")
|
||||
}
|
||||
var f config.Flags
|
||||
fs := newFlagSet("key rotate", "key rotate --data-dir DIR --secret-key-file FILE [flags]")
|
||||
storeFlags(fs, &f)
|
||||
fs.StringVar(&f.SecretKeyFile, "secret-key-file", app.DefaultSecretKeyFile, "the key file to rotate")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return flagErr(err)
|
||||
}
|
||||
res, err := keyrotate.Rotate(ctx, f.SecretKeyFile, func(ctx context.Context, kr *secrets.Keyring) (store.Store, error) {
|
||||
st, err := openInventory(ctx, &f, kr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st, st.Migrate(ctx)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("key rotate: key version %d -> %d; re-encrypted %d SNMP credentials and %d secrets; "+
|
||||
"the previous key stays in %s so older backups still restore\n",
|
||||
res.OldVersion, res.NewVersion, res.Credentials, res.Secrets, f.SecretKeyFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// unlockCmd clears the break-glass lockout of one address, or of all. It works on the database, so it takes
|
||||
// effect in a running nmon too.
|
||||
func unlockCmd(ctx context.Context, args []string) error {
|
||||
target, rest, err := leadingArg(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var f config.Flags
|
||||
fs := newFlagSet("unlock", "unlock [IP|all] --data-dir DIR [flags]")
|
||||
storeFlags(fs, &f)
|
||||
if err := fs.Parse(rest); err != nil {
|
||||
return flagErr(err)
|
||||
}
|
||||
if target == "" && fs.NArg() == 1 {
|
||||
target = fs.Arg(0)
|
||||
}
|
||||
if target == "" {
|
||||
target = "all"
|
||||
}
|
||||
st, err := openInventory(ctx, &f, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = st.Close() }() // the unlock is committed
|
||||
if err := st.Migrate(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := auth.Unlock(ctx, st, "console", target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("unlock: cleared %d lockout(s) for %s\n", n, target)
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeCmd is "nmon store restart [hot|long]": resume a store the supervisor gave up on.
|
||||
func storeCmd(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 || args[0] != "restart" {
|
||||
return errors.New("usage: nmon store restart [hot|long] --api URL --token TOKEN")
|
||||
}
|
||||
name, rest, err := leadingArg(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var af apiFlags
|
||||
fs := flag.NewFlagSet("store restart", flag.ContinueOnError)
|
||||
af.register(fs)
|
||||
if err := fs.Parse(rest); err != nil {
|
||||
return flagErr(err)
|
||||
}
|
||||
if err := af.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
names := []string{"hot", "long"}
|
||||
if name != "" {
|
||||
names = []string{name}
|
||||
}
|
||||
var errs []error
|
||||
for _, n := range names {
|
||||
resp, err := af.call(ctx, http.MethodPost, "/api/v1/admin/stores/"+n+"/restart", nil)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", n, err))
|
||||
continue
|
||||
}
|
||||
_ = resp.Body.Close() // 204: nothing to read
|
||||
fmt.Printf("store restart: %s store resumed\n", n)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// secretCmd is "nmon secret set NAME": it stores a named secret (the OIDC client secret, a Proxmox token) in the
|
||||
// inventory without a running nmon, reading the value from --value-file or standard input. It exists because
|
||||
// OIDC needs its client secret at start while the API that stores secrets needs a way to sign in.
|
||||
func secretCmd(ctx context.Context, args []string) error {
|
||||
if len(args) < 2 || args[0] != "set" {
|
||||
return errors.New("usage: nmon secret set NAME --data-dir DIR --secret-key-file FILE [--value-file FILE] (value on stdin otherwise)")
|
||||
}
|
||||
name, rest, err := leadingArg(args[1:])
|
||||
if err != nil || name == "" {
|
||||
return errors.New("usage: nmon secret set NAME --data-dir DIR --secret-key-file FILE")
|
||||
}
|
||||
var f config.Flags
|
||||
var valueFile string
|
||||
fs := newFlagSet("secret set", "secret set NAME --data-dir DIR --secret-key-file FILE [flags]")
|
||||
storeFlags(fs, &f)
|
||||
fs.StringVar(&f.SecretKeyFile, "secret-key-file", app.DefaultSecretKeyFile, "the encryption key file")
|
||||
fs.StringVar(&valueFile, "value-file", "", "read the secret from this file instead of standard input")
|
||||
if err := fs.Parse(rest); err != nil {
|
||||
return flagErr(err)
|
||||
}
|
||||
value, err := readSecretValue(valueFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kr, err := keyringForSecrets(&f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := openInventory(ctx, &f, kr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = st.Close() }() // the secret is committed
|
||||
if err := st.Migrate(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.PutSecret(ctx, name, value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.AppendAudit(ctx, "console", "secret.put", "secret:"+name, nil, nil); err != nil { // the value is never audited
|
||||
return err
|
||||
}
|
||||
fmt.Printf("secret set: stored %q (encrypted with key version %d)\n", name, kr.CurrentVersion())
|
||||
return nil
|
||||
}
|
||||
|
||||
// readSecretValue reads the secret from a file or standard input, without its trailing newline.
|
||||
func readSecretValue(valueFile string) (string, error) {
|
||||
var src io.Reader = os.Stdin
|
||||
if valueFile != "" {
|
||||
vf, err := os.Open(valueFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = vf.Close() }() // read-only
|
||||
src = vf
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(src, 4097))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
value := strings.TrimRight(string(raw), "\r\n")
|
||||
if value == "" || len(value) > 4096 {
|
||||
return "", errors.New("the secret must be 1 to 4096 bytes")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// keyringForSecrets loads the key file. On a brand-new install (no key file and no inventory yet) it creates the
|
||||
// key first, as `nmon serve` would, so the OIDC client secret can be stored before the first start.
|
||||
func keyringForSecrets(f *config.Flags) (*secrets.Keyring, error) {
|
||||
if _, err := os.Stat(f.SecretKeyFile); errors.Is(err, os.ErrNotExist) {
|
||||
if _, err := os.Stat(config.InventoryDB(f.DataDir)); err == nil {
|
||||
return nil, fmt.Errorf("secret key file %s is missing but the inventory exists; restore the key file", f.SecretKeyFile)
|
||||
}
|
||||
if err := secrets.GenerateKeyFile(f.SecretKeyFile); err != nil {
|
||||
return nil, fmt.Errorf("generate secret key: %w", err)
|
||||
}
|
||||
fmt.Printf("secret set: created a new encryption key at %s; back it up now\n", f.SecretKeyFile)
|
||||
}
|
||||
kr, err := secrets.LoadKeyring(f.SecretKeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secret key: %w", err)
|
||||
}
|
||||
return kr, nil
|
||||
}
|
||||
55
cmd/nmon/restore.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/v10networks/nmon/internal/app"
|
||||
"github.com/v10networks/nmon/internal/backup"
|
||||
"github.com/v10networks/nmon/internal/config"
|
||||
"github.com/v10networks/nmon/internal/vmsup"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register("restore", "restore a backup into a clean data dir (nmon stopped)", restoreCmd)
|
||||
}
|
||||
|
||||
// restoreCmd unpacks a backup into an empty data directory and verifies it before it reports success. It
|
||||
// runs with nmon stopped, because it starts the restored metric stores itself to compare their series.
|
||||
func restoreCmd(ctx context.Context, args []string) error {
|
||||
var f config.Flags
|
||||
fs := newFlagSet("restore", "restore FILE --data-dir CLEAN_DIR [--secret-key-file PATH]")
|
||||
fs.StringVar(&f.DataDir, "data-dir", "", "the clean (absent or empty) data folder to restore into (required)")
|
||||
fs.StringVar(&f.SecretKeyFile, "secret-key-file", "",
|
||||
"where to write the key file if the backup contains one (default <data-dir>/secret.key; nmon serve wants it outside the data dir)")
|
||||
file, rest, err := leadingArg(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fs.Parse(rest); err != nil {
|
||||
return flagErr(err)
|
||||
}
|
||||
if file == "" && fs.NArg() == 1 {
|
||||
file = fs.Arg(0)
|
||||
}
|
||||
if file == "" || f.DataDir == "" || fs.NArg() > 1 {
|
||||
return errors.New("usage: nmon restore FILE --data-dir CLEAN_DIR")
|
||||
}
|
||||
res, err := backup.Restore(ctx, backup.RestoreOptions{
|
||||
File: file, DataDir: f.DataDir, KeyFile: f.SecretKeyFile, Log: os.Stdout,
|
||||
Verifier: backup.NewVMVerifier(vmsup.Options{DataDir: f.DataDir}),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("restore: done and verified: %d inventory rows and %d series matched the manifest\n", res.RowsVerified, res.SeriesVerified)
|
||||
if res.KeyRestoredTo != "" {
|
||||
fmt.Printf("restore: the encryption key was restored to %s\n", res.KeyRestoredTo)
|
||||
} else {
|
||||
fmt.Printf("restore: this backup has NO key file; start nmon with --secret-key-file pointing at the key that sealed it "+
|
||||
"(default %s)\n", app.DefaultSecretKeyFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -51,12 +51,22 @@ func storeFlags(fs *flag.FlagSet, f *config.Flags) {
|
||||
|
||||
func serve(ctx context.Context, args []string) error {
|
||||
var f config.Flags
|
||||
fs := newFlagSet("serve", "serve --data-dir DIR --insecure-http [flags]")
|
||||
fs := newFlagSet("serve", "serve --data-dir DIR (--tls-cert F --tls-key F | --acme-host H | --insecure-http) [flags]")
|
||||
storeFlags(fs, &f)
|
||||
fs.StringVar(&f.SecretKeyFile, "secret-key-file", app.DefaultSecretKeyFile,
|
||||
"SNMP credential key file; created on first run; must be outside --data-dir")
|
||||
fs.StringVar(&f.Listen, "listen", app.DefaultListen, "address for the API")
|
||||
fs.BoolVar(&f.InsecureHTTP, "insecure-http", false, "serve plain HTTP with no authentication (required until TLS and auth arrive in M6)")
|
||||
fs.BoolVar(&f.InsecureHTTP, "insecure-http", false,
|
||||
"development only: serve plain HTTP (insecure cookies); with no auth.oidc or auth.break_glass_file configured, "+
|
||||
"authentication is off and every request is an admin")
|
||||
fs.StringVar(&f.TLSCert, "tls-cert", "", "PEM certificate chain for HTTPS (reloaded on SIGHUP); needs --tls-key")
|
||||
fs.StringVar(&f.TLSKey, "tls-key", "", "PEM private key for --tls-cert")
|
||||
fs.Func("acme-host", "obtain a Let's Encrypt certificate for this host name (repeatable); needs port 80 reachable", func(v string) error {
|
||||
f.ACMEHosts = append(f.ACMEHosts, v)
|
||||
return nil
|
||||
})
|
||||
fs.StringVar(&f.ACMEEmail, "acme-email", "", "contact address for the ACME account")
|
||||
fs.StringVar(&f.ACMEListen, "acme-listen", app.DefaultACMEListen, "address of the ACME http-01 challenge listener")
|
||||
fs.StringVar(&f.VMHotURL, "vm-hot-url", "", "use this VictoriaMetrics instance as the hot store instead of the embedded one")
|
||||
fs.StringVar(&f.VMLongURL, "vm-long-url", "", "use this VictoriaMetrics instance as the long store instead of the embedded one")
|
||||
fs.StringVar(&f.TrapListen, "trap-listen", app.DefaultTrapListen,
|
||||
|
||||
@@ -35,3 +35,10 @@ conscious, reviewed exception.
|
||||
| 28 | Pushed series carry only `device` | Pushed series also carry `push="1"` (senders cannot set it). | The device page lists what a push sender wrote without guessing from metric names. | M5 |
|
||||
| 29 | Ingest caps, late p95 months and a stalled rollup "raise an alert" | They are rows of the alerts table with rules `ingest_<kind>`, `p95_late`, `rollup_stalled` and no device, kept open until cleared (ingest alarms close 20 min after the last trip). | They are not metric rules and have no device; the metric-rule evaluator leaves these rule names alone. | M5 |
|
||||
| 30 | Proxmox cluster config could carry the token secret | Only `secret_name` exists; the secret is stored encrypted via `PUT /api/v1/secrets/{name}` and cannot be read back. | `config.yaml` is plain text. | M5 |
|
||||
| 31 | HTTPS by default; plain HTTP only with `--insecure-http` (Auth: OIDC only) | With `--insecure-http` **and** neither `auth.oidc` nor `auth.break_glass_file` configured, nmon runs in **dev-open mode**: every request is the admin principal `dev`, a loud warning is logged at start and the UI shows "Authentication disabled — development mode". Any start over TLS with no sign-in method configured refuses to start. If either sign-in method is configured, `--insecure-http` only means plain HTTP and cookies without `Secure`; authentication stays on. | The M1-M5 gates (and local development) run `nmon serve --insecure-http` with no IdP; M6 must not break them. The refusal for TLS-without-auth keeps the unsafe mode from ever being a production start. | M6 |
|
||||
| 32 | Settings (retention, alert thresholds, IdP, receivers, outbound allow-list) are edited in the UI and reloaded | `PUT /api/v1/settings` and SIGHUP reload in place: alert receivers, the outbound allow-list, trusted proxies, the nightly backup schedule, disk thresholds and the retired-device purge period. Alert rules and their interval, retention, timezone and sites, Proxmox, checks, `auth.oidc`, break-glass, session lifetimes, listen address, TLS and the data dir are saved but reported as `restart_required`. An invalid document is a 422 and changes nothing; the file is rewritten from the validated structure, so comments in a hand-edited `config.yaml` are lost by a UI save. | Those parts are built once at start (stores, evaluators, the OIDC client); rebuilding them live is a larger change than the risk of a restart warrants for v1. | M6 |
|
||||
| 33 | `nmon backup <file>` takes an online snapshot | `nmon backup` is a client of the running nmon: it calls `POST /api/v1/admin/backup` (admin token), nmon runs the backup in a scratch file under `<data-dir>/backups/.downloads/`, streams it and deletes it, and the client saves it atomically (temp file + rename). The key-included line is printed from the archive's own manifest. `nmon restore` runs offline and starts the restored stores for a moment to verify series. | Only the running process may touch the live stores and the database. | M6 |
|
||||
| 34 | (not specified) | Beyond the spec, additive to the API/CLI: `GET /api/v1/me`, `GET /api/v1/auth/options` (public), `GET /api/v1/audit` (admin), `POST /api/v1/admin/backup`, `POST /api/v1/admin/stores/{name}/restart`, `POST /api/v1/devices/{slug}/purge`, `POST /api/v1/devices/import`, `GET/PUT /api/v1/settings`; `nmon secret set NAME` (stores the OIDC client secret before the first start; it also creates the key on a brand-new install); API tokens (viewer, admin) share `/api/v1/tokens` with push tokens and have ids like `a7` because the two kinds live in different tables. | OIDC needs its client secret at start, while the API that stores secrets needs a way to sign in: the CLI breaks the cycle. The audit list makes "every use is logged" checkable. | M6 |
|
||||
| 35 | Port descriptions imported from CSV | Interfaces have a new operator-owned `description` column (migration 8). The CSV import and the API write it; discovery rewrites `ifalias` from the device on every run and never touches `description`. Interface search `q` matches ifname, ifalias and description. | A description typed into `ifalias` would be overwritten at the next discovery. | M6 |
|
||||
| 36 | OIDC login with a browser flow; break-glass as a local login | **No login UI** (owner decision). Every route except `/auth/*`, `/healthz` and push ingest needs a credential, the SPA's files included: an unauthenticated page load is a 302 to `/auth/login?return=<url>` (the OIDC flow; the return target must be a same-site path), an API call without credentials is a 401 problem+json. Break-glass is `GET /auth/breakglass` with HTTP Basic, so the browser's own prompt appears; success sets a session and redirects to `return` or `/` (`POST /auth/breakglass` with JSON stays for scripts). When no IdP is configured the redirect goes to the break-glass prompt. Logout is `POST /auth/logout`, then `/auth/signedout` (static HTML). `/docs` and `/openapi*` need the viewer role. A missing OIDC client secret at start leaves OIDC off with a warning. | The IdP is the login page; a second one would be a second thing to secure. | M6 |
|
||||
| 37 | Disk guard: "ingest throttled below the reserve" | Throttle answers push ingest with 429 + Retry-After below the 5% reserve; the poller keeps writing (its volume is bounded by the inventory). Above 90% used, new series are dropped on the nmon side (poller, push and checks) while known series continue; disk alarms are re-raised each minute while the condition holds and resolve themselves 3 minutes after it clears. | VictoriaMetrics series limits are start-up flags; the guard is nmon-side admission. | M6 |
|
||||
|
||||
@@ -44,7 +44,16 @@ by the store.
|
||||
|
||||
It starts the simulated SNMP agents in-process on `[::1]`, each on its own port (passed to the API as `snmp.port`), adds the devices through the API (a 48-port switch, access switches, routers, a firewall, a UPS, the counters fixture, a v1 device with 32-bit counters, and one whose agent is stopped after seeding so it reads as down), waits for live polls, back-fills 31 days, writes `NMON_DEVICE`, `NMON_DEVICE_48`, `NMON_DEVICE_32BIT` and `NMON_DEVICE_DOWN` to `--env-out`, and keeps the agents answering until interrupted. The simulated 64-bit counters start at 1e14 so history fits below them.
|
||||
|
||||
**oidc-mock** runs `internal/auth/oidctest` as a standalone OpenID Connect issuer for the M6 gate and for screenshots:
|
||||
|
||||
nmon-devtools oidc-mock --issuer-file F --port-file P [--client-id nmon --client-secret s3cret]
|
||||
curl 'http://[::1]:PORT/user?sub=viewer-1&name=Vera&groups=staff' # who the next login is
|
||||
|
||||
The issuer approves every login at once as the current user (the control endpoint changes it); `--issuer-file` receives the issuer URL for `auth.oidc.issuer`. **bcrypt PASSWORD** prints a bcrypt hash, the content of a break-glass file. Every devtools command that calls the API sends `Authorization: Bearer $NMON_TOKEN` when that variable is set (an nmon with authentication on needs an admin token).
|
||||
|
||||
## Gates
|
||||
|
||||
`make gate-m3` runs everything above end to end and writes live screenshots to `docs/screenshots/M3/`.
|
||||
It needs Playwright's chromium: `cd web && npx playwright install chromium`.
|
||||
|
||||
`make gate-m6` starts nmon with a mock IdP and a break-glass file (authentication ON) and checks roles, tokens, break-glass lock-out, backup/restore, key rotation, settings reload, CSV import and the UI; its live screenshots go to `docs/screenshots/M6/`.
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
- `nmon loadtest`: in-process simagents (2,000 × 100 ports on `::1` with distinct ports), nmon serve in-process, traps plus push plus rollup running, metrics collected from `nmon_` series and `/proc/self/status`.
|
||||
- Platforms: `make build-arm64` cross-compiles. Non-Linux builds compile without `vmembed` and require `--vm-*-url`.
|
||||
|
||||
### Decisions recorded during M6 (see `docs/DEVIATIONS.md` rows 31-37)
|
||||
- **No login UI** (owner decision): page loads without a session redirect (302) to `/auth/login?return=...` (OIDC); API calls get 401; break-glass is `GET /auth/breakglass` with HTTP Basic; the SPA has a user menu (name, role, sign out) and role-aware controls but no login page. Dev-open mode is unchanged.
|
||||
- Hot reload covers receivers, the outbound allow-list, trusted proxies, backup schedule, disk thresholds and the purge period; the rest is `restart_required`.
|
||||
- Interfaces gained an operator-owned `description` (migration 8) that the CSV port import writes and discovery never overwrites.
|
||||
|
||||
## Work packages
|
||||
|
||||
| WP | Files | Depends on |
|
||||
|
||||
68
docs/operations.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Operating nmon
|
||||
|
||||
How to run, secure, back up and maintain a production nmon. Design background: `docs/spec/spec.md` ("Auth", "Operations").
|
||||
|
||||
## Starting
|
||||
|
||||
nmon serve --data-dir /var/lib/nmon --secret-key-file /etc/nmon/secret.key \
|
||||
--tls-cert /etc/nmon/tls.crt --tls-key /etc/nmon/tls.key
|
||||
|
||||
- **HTTPS is the default.** Pass a certificate pair (`--tls-cert`, `--tls-key`; `SIGHUP` reloads them without dropping connections) or Let's Encrypt (`--acme-host nmon.example.net` repeatable, `--acme-email you@example.net`; nmon answers the http-01 challenge on `--acme-listen`, default `:80`, certificates are cached in `<data-dir>/acme`). Without either, `serve` refuses to start and says so. `--insecure-http` is for development: plain HTTP, cookies without `Secure`.
|
||||
- **A sign-in method is required** over TLS (`auth.oidc` and/or `auth.break_glass_file` in `config.yaml`). With `--insecure-http` and neither configured nmon runs in *dev-open mode* (everyone is admin `dev`; a banner says so); never expose that port.
|
||||
- Behind a reverse proxy, list it in `auth.trusted_proxies`; `X-Forwarded-*` is ignored from anyone else.
|
||||
|
||||
## Sign-in
|
||||
|
||||
`config.yaml`:
|
||||
|
||||
auth:
|
||||
oidc:
|
||||
issuer: https://idp.example.net/realms/net
|
||||
client_id: nmon
|
||||
client_secret_name: oidc-client # the secret itself is never in this file
|
||||
redirect_url: https://nmon.example.net/auth/callback
|
||||
groups_claim: groups
|
||||
admin_group: nmon-admins # members are admins; every other user is a viewer
|
||||
break_glass_file: /etc/nmon/breakglass # bcrypt hash, "hash" or "user:hash", created by you
|
||||
|
||||
Store the client secret before the first start: `printf '%s\n' "$SECRET" | nmon secret set oidc-client --data-dir D --secret-key-file K` (it can also be set later with `PUT /api/v1/secrets/oidc-client`; OIDC stays off until the secret exists). The break-glass file holds one bcrypt hash (`$2a$`/`$2b$`), made with any bcrypt tool (the source tree's `nmon-devtools bcrypt PASSWORD` works); keep it readable by the nmon user only.
|
||||
|
||||
- There is no login page. Opening nmon without a session redirects to the IdP and back; API clients use a token. If the IdP is down, open `/auth/breakglass`: the browser asks for the local admin user and password (HTTP Basic), and a successful sign-in lands on the page you asked for.
|
||||
- Break-glass: five wrong passwords lock the source address for 15 minutes; every use and every lock is audited and raises an alert. `nmon unlock [IP|all] --data-dir D` clears locks, also while nmon runs.
|
||||
- API tokens: an admin mints viewer, admin or push tokens (`POST /api/v1/tokens`, or Settings, API tokens). Use `Authorization: Bearer nma_...`. A push token works for ingest only. Revoking takes effect on the next request.
|
||||
- `GET /api/v1/audit` (admin) lists who did what.
|
||||
|
||||
## Backup and restore
|
||||
|
||||
A running nmon owns the stores, so copying the data folder is **not** a backup.
|
||||
|
||||
nmon backup /backups/nmon.tar --api https://nmon.example.net --token nma_... # key NOT included
|
||||
nmon backup /backups/nmon.tar --include-key --api ... --token ... # key included
|
||||
|
||||
Every run prints whether the key file is in the archive. **Lose the key file and a restore has an inventory with no SNMP credentials.** The nightly schedule is `backup: {at: "02:30", dir: /var/backups/nmon, keep: 7}` (time in the configured timezone, default dir `<data-dir>/backups`); a failed run raises the `backup_failed` alert. A backup is refused when the disk is below its 5% reserve.
|
||||
|
||||
Restore with nmon stopped, into a clean directory:
|
||||
|
||||
nmon restore /backups/nmon.tar --data-dir /var/lib/nmon-restored --secret-key-file /etc/nmon/secret.key
|
||||
|
||||
It verifies inventory row counts and a sample of series before it says done. Then start nmon on that directory. Restore needs the embedded stores (the normal release build).
|
||||
|
||||
## Keys
|
||||
|
||||
`nmon key rotate --data-dir D --secret-key-file K` (nmon stopped) adds a new key version, re-encrypts every SNMP credential and secret under it, and keeps the old key in the file so older backups still restore. Remove old keys only after a release.
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit in Settings (admin) or `config.yaml` + `SIGHUP`. An invalid document is rejected and the running configuration stays. Alert receivers, the outbound allow-list, trusted proxies, the backup schedule, disk thresholds and the purge period apply live; everything else is listed as `restart_required` until the next restart. `disk: {alert_used: 0.8, admit_used: 0.9, reserve_free: 0.05}`: alert at 80% used, no new series at 90%, ingest throttled / rollup paused / backups refused below 5% free.
|
||||
|
||||
## Metric stores
|
||||
|
||||
The supervisor restarts a crashed store; after three restarts in ten minutes it raises `store_failed` and stops. `nmon store restart [hot|long] --api URL --token T` (or `SIGHUP`) resumes it.
|
||||
|
||||
## Retired devices
|
||||
|
||||
`POST /api/v1/devices/{slug}/purge` (admin) deletes a retired device's series from both stores once it has been retired for `retired_purge_after` (default 1y).
|
||||
|
||||
## CSV import
|
||||
|
||||
Devices (`name,address,site,role,credential,parents,...`) and port descriptions (`device,ifname,alias`) from Settings or `POST /api/v1/devices/import`. Port descriptions are stored in the operator-owned `description` field, which discovery never overwrites. A dependency cycle is rejected with its path; nothing is written unless the whole import is valid.
|
||||
BIN
docs/screenshots/M6-mock/import-dialog.png
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
docs/screenshots/M6-mock/settings.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/screenshots/M6-mock/tokens.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
docs/screenshots/M6-mock/viewer-device.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
docs/screenshots/M6/import-dialog.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
docs/screenshots/M6/settings.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
docs/screenshots/M6/tokens.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/screenshots/M6/viewer-device.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
@@ -14,9 +14,18 @@ const (
|
||||
RuleP95Late = "p95_late"
|
||||
// RuleRollupStalled is the rollup job failing for longer than its tolerance.
|
||||
RuleRollupStalled = "rollup_stalled"
|
||||
// RuleBreakGlassPrefix starts the break-glass alarms: breakglass_login, breakglass_failure, breakglass_locked.
|
||||
RuleBreakGlassPrefix = "breakglass_"
|
||||
// RuleBackupFailed is the nightly backup failing; it closes when the next backup succeeds.
|
||||
RuleBackupFailed = "backup_failed"
|
||||
)
|
||||
|
||||
// SystemRule reports whether name is a subsystem alarm rule.
|
||||
func SystemRule(name string) bool {
|
||||
return name == RuleP95Late || name == RuleRollupStalled || strings.HasPrefix(name, RuleIngestPrefix)
|
||||
return name == RuleP95Late || name == RuleBackupFailed || name == RuleRollupStalled || strings.HasPrefix(name, RuleIngestPrefix) ||
|
||||
diskGuardRules[name] || strings.HasPrefix(name, RuleBreakGlassPrefix)
|
||||
}
|
||||
|
||||
// diskGuardRules are the disk guard's alarm kinds (internal/diskguard). They are listed rather than matched by a
|
||||
// "disk_" prefix because the shipped metric rule "disk_full" shares it.
|
||||
var diskGuardRules = map[string]bool{"disk_high": true, "disk_series_halt": true, "disk_reserve": true, "disk_statfs": true}
|
||||
|
||||
19
internal/api/actor.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/v10networks/nmon/internal/auth"
|
||||
)
|
||||
|
||||
// anonymousActor names the caller when no auth middleware ran (unit tests that call the API directly).
|
||||
const anonymousActor = "anonymous"
|
||||
|
||||
// actorOf names the authenticated caller for the audit log, acknowledgements and maintenance windows:
|
||||
// the OIDC subject, "token:<name>" for an API token, "breakglass", or "dev" in development-open mode.
|
||||
func actorOf(ctx context.Context) string {
|
||||
if p, ok := auth.FromContext(ctx); ok {
|
||||
return p.Actor()
|
||||
}
|
||||
return anonymousActor
|
||||
}
|
||||
135
internal/api/admin.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
||||
|
||||
"github.com/v10networks/nmon/internal/vmsup"
|
||||
)
|
||||
|
||||
// ErrBackupRefused means a backup was refused because the disk is below the reserve.
|
||||
var ErrBackupRefused = errors.New("backup refused")
|
||||
|
||||
// BackupFile is a finished backup waiting to be streamed to the caller.
|
||||
type BackupFile struct {
|
||||
Path string
|
||||
KeyIncluded bool
|
||||
// Done deletes the file once it has been sent.
|
||||
Done func()
|
||||
}
|
||||
|
||||
// BackupRunner runs one online backup into a temporary file.
|
||||
type BackupRunner interface {
|
||||
Create(ctx context.Context, includeKey bool) (BackupFile, error)
|
||||
}
|
||||
|
||||
// StoreRestarter resumes a metric store that gave up after repeated crashes. *vmsup.Supervisor implements it.
|
||||
type StoreRestarter interface{ Restart(name string) error }
|
||||
|
||||
// backupOut streams the archive. KeyIncluded repeats what the archive's manifest says, so a client can print it.
|
||||
type backupOut struct {
|
||||
ContentType string `header:"Content-Type"`
|
||||
Disposition string `header:"Content-Disposition"`
|
||||
KeyIncluded string `header:"X-Nmon-Key-Included"`
|
||||
Body func(ctx huma.Context)
|
||||
}
|
||||
|
||||
func (s *server) registerAdmin(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "create-backup", Method: http.MethodPost, Path: "/api/v1/admin/backup", Tags: []string{"admin"},
|
||||
Summary: "Take a backup and download it",
|
||||
Description: "Admin only. Runs nmon's online backup (inventory snapshot, both metric stores' snapshots, config and profiles) " +
|
||||
"and streams the tar. The X-Nmon-Key-Included header says whether the encryption key is inside. `nmon backup` is the client.",
|
||||
Responses: map[string]*huma.Response{"200": {
|
||||
Description: "The backup archive.",
|
||||
Content: map[string]*huma.MediaType{"application/x-tar": {}},
|
||||
}},
|
||||
Middlewares: huma.Middlewares{noWriteDeadline},
|
||||
Errors: []int{http.StatusInsufficientStorage, http.StatusServiceUnavailable},
|
||||
}, s.createBackup)
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "restart-store", Method: http.MethodPost, Path: "/api/v1/admin/stores/{name}/restart", Tags: []string{"admin"},
|
||||
Summary: "Resume a failed metric store", DefaultStatus: http.StatusNoContent,
|
||||
Description: "Admin only. After store_failed the supervisor stopped restarting the store; this starts it again. " +
|
||||
"A store that has not failed is a 409.",
|
||||
Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable},
|
||||
}, s.restartStore)
|
||||
}
|
||||
|
||||
// noWriteDeadline lifts the server's write timeout for one request: a backup can take longer than it.
|
||||
func noWriteDeadline(ctx huma.Context, next func(huma.Context)) {
|
||||
if _, w := humago.Unwrap(ctx); w != nil {
|
||||
_ = http.NewResponseController(w).SetWriteDeadline(time.Time{}) // unsupported writers keep the default
|
||||
}
|
||||
next(ctx)
|
||||
}
|
||||
|
||||
func (s *server) createBackup(ctx context.Context, in *struct {
|
||||
IncludeKey bool `query:"include_key" doc:"Put the encryption key in the archive. Default false."`
|
||||
}) (*backupOut, error) {
|
||||
if s.Backups == nil {
|
||||
return nil, errNotConfigured("backup")
|
||||
}
|
||||
bf, err := s.Backups.Create(ctx, in.IncludeKey)
|
||||
if errors.Is(err, ErrBackupRefused) {
|
||||
return nil, huma.NewError(http.StatusInsufficientStorage, trimSentinel(err, ErrBackupRefused))
|
||||
}
|
||||
if err != nil {
|
||||
s.Logger.Error("backup", "err", err)
|
||||
return nil, huma.Error500InternalServerError("backup failed; see the log")
|
||||
}
|
||||
detail := map[string]bool{"key_included": bf.KeyIncluded}
|
||||
if err := s.Store.AppendAudit(ctx, actorOf(ctx), "backup.create", "backup", nil, detail); err != nil {
|
||||
s.Logger.Warn("audit backup.create", "err", err)
|
||||
}
|
||||
key := "false"
|
||||
if bf.KeyIncluded {
|
||||
key = "true"
|
||||
}
|
||||
return &backupOut{
|
||||
ContentType: "application/x-tar", Disposition: `attachment; filename="nmon-backup.tar"`, KeyIncluded: key,
|
||||
Body: func(hc huma.Context) { s.streamBackup(hc, bf) },
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) streamBackup(hc huma.Context, bf BackupFile) {
|
||||
defer bf.Done()
|
||||
f, err := os.Open(bf.Path)
|
||||
if err != nil {
|
||||
s.Logger.Error("open backup for download", "err", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
if _, err := io.Copy(hc.BodyWriter(), f); err != nil {
|
||||
s.Logger.Warn("stream backup", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) restartStore(ctx context.Context, in *struct {
|
||||
Name string `path:"name" enum:"hot,long" doc:"Which metric store."`
|
||||
}) (*struct{}, error) {
|
||||
if s.Stores == nil {
|
||||
return nil, errNotConfigured("metric store supervisor")
|
||||
}
|
||||
err := s.Stores.Restart(in.Name)
|
||||
switch {
|
||||
case errors.Is(err, vmsup.ErrUnknownStore):
|
||||
return nil, huma.Error404NotFound("no such store")
|
||||
case errors.Is(err, vmsup.ErrNotFailed), errors.Is(err, vmsup.ErrExternalStore):
|
||||
return nil, huma.Error409Conflict(err.Error())
|
||||
case err != nil:
|
||||
s.Logger.Error("restart store", "err", err)
|
||||
return nil, huma.Error500InternalServerError("restart failed; see the log")
|
||||
}
|
||||
if err := s.Store.AppendAudit(ctx, actorOf(ctx), "store.restart", "store:"+in.Name, nil, nil); err != nil {
|
||||
s.Logger.Warn("audit store.restart", "err", err)
|
||||
}
|
||||
return &struct{}{}, nil
|
||||
}
|
||||
@@ -176,10 +176,10 @@ func (s *server) ackAlert(ctx context.Context, in *struct {
|
||||
return nil, huma.Error422UnprocessableEntity("until must be in the future")
|
||||
}
|
||||
err = s.Store.WithTx(ctx, func(tx store.Store) error {
|
||||
if err := tx.AckAlert(ctx, id, "anonymous", in.Body.Reason, in.Body.Until.UTC()); err != nil {
|
||||
if err := tx.AckAlert(ctx, id, actorOf(ctx), in.Body.Reason, in.Body.Until.UTC()); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.AppendAudit(ctx, "anonymous", "alert.ack", "alert:"+in.ID, nil,
|
||||
return tx.AppendAudit(ctx, actorOf(ctx), "alert.ack", "alert:"+in.ID, nil,
|
||||
map[string]any{"reason": in.Body.Reason, "until": in.Body.Until.UTC()})
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -103,6 +103,22 @@ type Deps struct {
|
||||
Zones p95.Zones
|
||||
Checks CheckResults
|
||||
Proxmox ProxmoxStatus
|
||||
|
||||
// M6 collaborators; a route whose collaborator is nil answers 503 "not configured".
|
||||
// APITokens mints and revokes viewer and admin API tokens (scope push stays with Tokens).
|
||||
APITokens APITokenAdmin
|
||||
// Settings reads and replaces config.yaml and reloads it.
|
||||
Settings SettingsAdmin
|
||||
// Backups runs an online backup for POST /admin/backup.
|
||||
Backups BackupRunner
|
||||
// Stores restarts a failed metric store.
|
||||
Stores StoreRestarter
|
||||
// Series deletes whole series from the metric stores (device purge).
|
||||
Series SeriesDeleter
|
||||
// PurgeAfter is how long a device must have been retired before it can be purged. Default one year.
|
||||
PurgeAfter func() time.Duration
|
||||
// OnImported is told the ids of devices a CSV import created, so they can be discovered at once.
|
||||
OnImported func(ids []int64)
|
||||
}
|
||||
|
||||
type server struct {
|
||||
@@ -187,6 +203,12 @@ func (s *server) register(api huma.API) {
|
||||
s.registerP95(api)
|
||||
s.registerRollup(api)
|
||||
s.registerProxmox(api)
|
||||
s.registerMe(api)
|
||||
s.registerSettings(api)
|
||||
s.registerImport(api)
|
||||
s.registerPurge(api)
|
||||
s.registerAdmin(api)
|
||||
s.registerAudit(api)
|
||||
}
|
||||
|
||||
// newHuma builds the huma API on mux. /docs is served by registerDocs from embedded assets, not huma's CDN page.
|
||||
|
||||
48
internal/api/audit.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
)
|
||||
|
||||
// AuditView is one audit-log entry. What changed (before and after) stays in the database: it can hold request
|
||||
// bodies, and this list is for answering who did what and when.
|
||||
type AuditView struct {
|
||||
At time.Time `json:"at"`
|
||||
Actor string `json:"actor" doc:"Who: an IDP subject, token:<name>, breakglass, console, dev or system."`
|
||||
Action string `json:"action" example:"device.edit"`
|
||||
Object string `json:"object"`
|
||||
}
|
||||
|
||||
// AuditList is a page of AuditView, newest first.
|
||||
type AuditList struct {
|
||||
Items []AuditView `json:"items"`
|
||||
Page int64 `json:"page"`
|
||||
PerPage int64 `json:"per_page"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
func (s *server) registerAudit(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "list-audit", Method: http.MethodGet, Path: "/api/v1/audit", Tags: []string{"admin"},
|
||||
Summary: "Audit log",
|
||||
Description: "Admin only. Who changed what, newest first: logins, token and secret changes, imports, settings, backups.",
|
||||
}, s.listAudit)
|
||||
}
|
||||
|
||||
func (s *server) listAudit(ctx context.Context, in *struct{ PageParams }) (*struct{ Body AuditList }, error) {
|
||||
rows, total, err := s.Store.ListAudit(ctx, store.Page{Offset: in.offset(), Limit: in.limit()})
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
out := AuditList{Items: make([]AuditView, 0, len(rows)), Page: in.pageNo(), PerPage: in.limit(), Total: total}
|
||||
for _, r := range rows {
|
||||
out.Items = append(out.Items, AuditView{At: r.At.UTC(), Actor: r.Actor, Action: r.Action, Object: r.Object})
|
||||
}
|
||||
return &struct{ Body AuditList }{out}, nil
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func (s *server) createCheck(ctx context.Context, in *struct{ Body CheckBody })
|
||||
return err
|
||||
}
|
||||
row = c
|
||||
return tx.AppendAudit(ctx, "anonymous", "check.create", "check:"+strconv.FormatInt(c.ID, 10), nil, in.Body)
|
||||
return tx.AppendAudit(ctx, actorOf(ctx), "check.create", "check:"+strconv.FormatInt(c.ID, 10), nil, in.Body)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, s.txErr(err)
|
||||
@@ -230,7 +230,7 @@ func (s *server) updateCheck(ctx context.Context, in *struct {
|
||||
return err
|
||||
}
|
||||
row = c
|
||||
return tx.AppendAudit(ctx, "anonymous", "check.update", "check:"+in.ID, nil, in.Body)
|
||||
return tx.AppendAudit(ctx, actorOf(ctx), "check.update", "check:"+in.ID, nil, in.Body)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, s.txErr(err)
|
||||
@@ -250,7 +250,7 @@ func (s *server) deleteCheck(ctx context.Context, in *struct {
|
||||
if err := tx.DeleteCheck(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.AppendAudit(ctx, "anonymous", "check.delete", "check:"+in.ID, nil, nil)
|
||||
return tx.AppendAudit(ctx, actorOf(ctx), "check.delete", "check:"+in.ID, nil, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, s.txErr(err)
|
||||
|
||||
@@ -125,7 +125,7 @@ func (s *server) applyEdit(ctx context.Context, dev *store.Device, p *editPlan)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.AppendAudit(ctx, "anonymous", "device.edit", dev.Slug, nil, p.change)
|
||||
return tx.AppendAudit(ctx, actorOf(ctx), "device.edit", dev.Slug, nil, p.change)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ type InterfaceView struct {
|
||||
IfName string `json:"ifname"`
|
||||
IfDescr string `json:"ifdescr"`
|
||||
IfAlias string `json:"ifalias"`
|
||||
Description string `json:"description" doc:"Operator-owned port description; discovery never overwrites it."`
|
||||
IfPhysAddr string `json:"ifphysaddr"`
|
||||
IfSpeed int64 `json:"ifspeed" doc:"bits per second"`
|
||||
IfHighSpeed int64 `json:"ifhighspeed" doc:"megabits per second"`
|
||||
@@ -198,7 +199,7 @@ func interfaceView(i *store.Interface, r *ifRates) InterfaceView {
|
||||
in, out := r.bps(i)
|
||||
inE, outE := r.errs(i)
|
||||
return InterfaceView{
|
||||
ID: i.ID, IfIndex: i.IfIndex, IfName: i.IfName, IfDescr: i.IfDescr, IfAlias: i.IfAlias,
|
||||
ID: i.ID, IfIndex: i.IfIndex, IfName: i.IfName, IfDescr: i.IfDescr, IfAlias: i.IfAlias, Description: i.Description,
|
||||
IfPhysAddr: i.IfPhysAddr, IfSpeed: i.IfSpeed, IfHighSpeed: i.IfHighSpeed, IfType: i.IfType,
|
||||
AdminStatus: i.AdminStatus, OperStatus: i.OperStatus, HasHCCounters: i.HasHCCounters, Excluded: i.Excluded,
|
||||
FirstSeen: i.FirstSeen.UTC(), LastDiscovered: i.LastDiscovered.UTC(), Unreliable: i.Unreliable,
|
||||
|
||||
@@ -212,7 +212,7 @@ func (s *server) persistDevice(
|
||||
if restore != nil {
|
||||
action = "device.reattach"
|
||||
}
|
||||
return tx.AppendAudit(ctx, "anonymous", action, d.Slug, nil,
|
||||
return tx.AppendAudit(ctx, actorOf(ctx), action, d.Slug, nil,
|
||||
map[string]any{"name": d.Name, "kind": d.Kind, "addresses": req.strs})
|
||||
})
|
||||
return slug, err
|
||||
|
||||
@@ -150,7 +150,7 @@ func (s *server) createGroup(ctx context.Context, in *struct{ Body GroupBody })
|
||||
if err := tx.CreateGroup(ctx, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.AppendAudit(ctx, "anonymous", "group.create", "group:"+strconv.FormatInt(g.ID, 10), nil, in.Body)
|
||||
return tx.AppendAudit(ctx, actorOf(ctx), "group.create", "group:"+strconv.FormatInt(g.ID, 10), nil, in.Body)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
@@ -230,7 +230,7 @@ func (s *server) updateGroup(ctx context.Context, in *struct {
|
||||
if err := tx.UpdateGroup(ctx, g); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.AppendAudit(ctx, "anonymous", "group.update", "group:"+in.ID, nil, in.Body)
|
||||
return tx.AppendAudit(ctx, actorOf(ctx), "group.update", "group:"+in.ID, nil, in.Body)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
@@ -253,7 +253,7 @@ func (s *server) deleteGroup(ctx context.Context, in *struct {
|
||||
if err := tx.DeleteGroup(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.AppendAudit(ctx, "anonymous", "group.delete", "group:"+in.ID, nil, nil)
|
||||
return tx.AppendAudit(ctx, actorOf(ctx), "group.delete", "group:"+in.ID, nil, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
@@ -282,7 +282,7 @@ func (s *server) addGroupMember(ctx context.Context, in *struct {
|
||||
if err := tx.AddGroupMember(ctx, m); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.AppendAudit(ctx, "anonymous", "group.member.add", "group:"+in.ID, nil, in.Body)
|
||||
return tx.AppendAudit(ctx, actorOf(ctx), "group.member.add", "group:"+in.ID, nil, in.Body)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
@@ -307,7 +307,7 @@ func (s *server) endGroupMember(ctx context.Context, in *struct {
|
||||
if err := tx.EndGroupMember(ctx, id, in.InterfaceID, at); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.AppendAudit(ctx, "anonymous", "group.member.end", "group:"+in.ID, nil, map[string]string{"interface_id": in.InterfaceID})
|
||||
return tx.AppendAudit(ctx, actorOf(ctx), "group.member.end", "group:"+in.ID, nil, map[string]string{"interface_id": in.InterfaceID})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
|
||||
103
internal/api/import.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/v10networks/nmon/internal/deps"
|
||||
"github.com/v10networks/nmon/internal/importcsv"
|
||||
)
|
||||
|
||||
// importMaxBytes bounds the multipart body of one import.
|
||||
const importMaxBytes = 8 << 20
|
||||
|
||||
// ImportForm is the multipart form of POST /devices/import; send either file or both.
|
||||
type ImportForm struct {
|
||||
Devices huma.FormFile `form:"devices" contentType:"text/csv,text/plain,application/csv,application/vnd.ms-excel,application/octet-stream" required:"false" doc:"devices.csv: name, address, site, role, credential, parents."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
|
||||
Ports huma.FormFile `form:"ports" contentType:"text/csv,text/plain,application/csv,application/vnd.ms-excel,application/octet-stream" required:"false" doc:"ports.csv: device, ifname, alias (stored as the port description)."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
|
||||
}
|
||||
|
||||
// ImportResult counts what an import wrote.
|
||||
type ImportResult struct {
|
||||
DevicesCreated int `json:"devices_created"`
|
||||
SNMPProfilesCreated int `json:"snmp_profiles_created" doc:"SNMP credential records created for devices that gave a community inline."`
|
||||
PortsUpdated int `json:"ports_updated" doc:"Interfaces whose operator-owned description was set."`
|
||||
}
|
||||
|
||||
func (s *server) registerImport(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "import-devices", Method: http.MethodPost, Path: "/api/v1/devices/import", Tags: []string{"devices"},
|
||||
Summary: "Import devices and port descriptions from CSV",
|
||||
Description: "Admin only. Multipart form with devices.csv and/or ports.csv. Every problem is reported with its row; " +
|
||||
"a dependency cycle is rejected with its path. Nothing is written unless the whole import is valid. " +
|
||||
"Port descriptions are operator-owned: discovery never overwrites them.",
|
||||
MaxBodyBytes: importMaxBytes,
|
||||
Errors: []int{http.StatusUnprocessableEntity},
|
||||
}, s.importDevices)
|
||||
}
|
||||
|
||||
func (s *server) importDevices(ctx context.Context, in *struct {
|
||||
RawBody huma.MultipartFormFiles[ImportForm]
|
||||
}) (*struct{ Body ImportResult }, error) {
|
||||
form := in.RawBody.Data()
|
||||
var inputs importcsv.Inputs
|
||||
if form.Devices.IsSet {
|
||||
inputs.Devices = form.Devices
|
||||
}
|
||||
if form.Ports.IsSet {
|
||||
inputs.Ports = form.Ports
|
||||
}
|
||||
if inputs.Devices == nil && inputs.Ports == nil {
|
||||
return nil, huma.Error422UnprocessableEntity("send devices.csv, ports.csv or both")
|
||||
}
|
||||
plan, err := importcsv.Build(ctx, s.Store, inputs)
|
||||
if err != nil {
|
||||
return nil, s.importErr(err)
|
||||
}
|
||||
res, err := importcsv.Apply(ctx, s.Store, plan)
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
if err := s.Store.AppendAudit(ctx, actorOf(ctx), "device.import", "import", nil, res); err != nil {
|
||||
s.Logger.Warn("audit device.import", "err", err)
|
||||
}
|
||||
s.afterImport(ctx, res)
|
||||
return &struct{ Body ImportResult }{ImportResult{
|
||||
DevicesCreated: res.DevicesCreated, SNMPProfilesCreated: res.CredentialsCreated, PortsUpdated: res.PortsUpdated,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// afterImport makes the poller and the dependency graph see the new devices and starts their discovery.
|
||||
func (s *server) afterImport(ctx context.Context, res importcsv.Result) {
|
||||
if s.Poller != nil {
|
||||
s.Poller.Refresh()
|
||||
}
|
||||
if err := s.Dependencies.Refresh(ctx); err != nil {
|
||||
s.Logger.Warn("refresh dependency graph after import", "err", err)
|
||||
}
|
||||
if s.OnImported != nil && len(res.DeviceIDs) > 0 {
|
||||
s.OnImported(res.DeviceIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// importErr turns a plan failure into a 422 that names every bad row, or the cycle's path.
|
||||
func (s *server) importErr(err error) error {
|
||||
var pe *importcsv.PlanError
|
||||
if errors.As(err, &pe) {
|
||||
details := make([]error, 0, len(pe.Errors))
|
||||
for _, e := range pe.Errors {
|
||||
details = append(details, &huma.ErrorDetail{Message: e.Msg, Location: fmt.Sprintf("%s.csv row %d", e.File, e.Row)})
|
||||
}
|
||||
msg := fmt.Sprintf("%d row(s) are invalid; nothing was imported", len(pe.Errors))
|
||||
return huma.NewError(http.StatusUnprocessableEntity, msg, details...)
|
||||
}
|
||||
var ce *deps.CycleError
|
||||
if errors.As(err, &ce) {
|
||||
return huma.Error422UnprocessableEntity("nothing was imported: " + err.Error())
|
||||
}
|
||||
return s.storeErr(err)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ type InterfaceHit struct {
|
||||
Device DeviceRef `json:"device"`
|
||||
IfName string `json:"ifname"`
|
||||
IfAlias string `json:"ifalias"`
|
||||
Description string `json:"description" doc:"Operator-owned port description (CSV import); discovery never overwrites it."`
|
||||
IfDescr string `json:"ifdescr"`
|
||||
OperStatus int64 `json:"oper_status"`
|
||||
AdminStatus int64 `json:"admin_status"`
|
||||
@@ -34,13 +35,14 @@ type InterfaceList struct {
|
||||
func (s *server) registerInterfaces(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "list-interfaces", Method: http.MethodGet, Path: "/api/v1/interfaces", Tags: []string{"interfaces"},
|
||||
Summary: "Search interfaces by name or alias",
|
||||
Description: "q matches ifname and ifalias, case-insensitively, across all devices. Rates are null when unknown or unreliable.",
|
||||
Summary: "Search interfaces by name or alias",
|
||||
Description: "q matches ifname, ifalias and description, case-insensitively, across all devices. " +
|
||||
"Rates are null when unknown or unreliable.",
|
||||
}, s.listInterfaces)
|
||||
}
|
||||
|
||||
func (s *server) listInterfaces(ctx context.Context, in *struct {
|
||||
Q string `query:"q" doc:"Substring of ifname or ifalias."`
|
||||
Q string `query:"q" doc:"Substring of ifname, ifalias or description."`
|
||||
PageParams
|
||||
}) (*struct{ Body InterfaceList }, error) {
|
||||
rows, total, err := s.Store.SearchInterfaceHits(ctx, in.Q, store.Page{Offset: in.offset(), Limit: in.limit()})
|
||||
@@ -60,7 +62,7 @@ func (s *server) listInterfaces(ctx context.Context, in *struct {
|
||||
r := &rows[i]
|
||||
inB, outB := rates[r.ID].bps(&r.Interface)
|
||||
out.Items = append(out.Items, InterfaceHit{
|
||||
ID: r.ID, Device: DeviceRef{Slug: r.DeviceSlug, Name: r.DeviceName}, IfName: r.IfName, IfAlias: r.IfAlias,
|
||||
ID: r.ID, Device: DeviceRef{Slug: r.DeviceSlug, Name: r.DeviceName}, IfName: r.IfName, IfAlias: r.IfAlias, Description: r.Description,
|
||||
IfDescr: r.IfDescr, OperStatus: r.OperStatus, AdminStatus: r.AdminStatus, SpeedBPS: speedBPS(&r.Interface),
|
||||
InBPS: inB, OutBPS: outB,
|
||||
})
|
||||
|
||||
354
internal/api/m6_test.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/api"
|
||||
"github.com/v10networks/nmon/internal/auth"
|
||||
"github.com/v10networks/nmon/internal/auth/authtest"
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
)
|
||||
|
||||
// call sends a request through the auth middleware with the given credentials.
|
||||
func (e *authedEnv) call(method, path, body string, c authtest.Creds) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
if c.Cookie != nil {
|
||||
req.AddCookie(c.Cookie)
|
||||
req.Header.Set(auth.CSRFHeader, c.CSRF)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.top.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestAPITokensOfEachKindMintListAndRevokeWithTheRightScope(t *testing.T) {
|
||||
e := newAuthedEnv(t, nil)
|
||||
adminSess, _ := authtest.Mint(t, e.auth, auth.RoleAdmin)
|
||||
viewerSess, _ := authtest.Mint(t, e.auth, auth.RoleViewer)
|
||||
dev := e.device(t, "core-1", "dc1", "core")
|
||||
|
||||
mint := func(body string) (id, secret string) {
|
||||
rec := e.call(http.MethodPost, "/api/v1/tokens", body, adminSess)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("mint %s: %d %s", body, rec.Code, rec.Body)
|
||||
}
|
||||
var v struct{ ID, Secret string }
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v.ID, v.Secret
|
||||
}
|
||||
vid, vsecret := mint(`{"name":"grafana","scope":"viewer"}`)
|
||||
_, asecret := mint(`{"name":"ci","scope":"admin"}`)
|
||||
_, psecret := mint(fmt.Sprintf(`{"name":"pve","scope":"push","devices":[%q]}`, dev.Slug))
|
||||
if !strings.HasPrefix(vsecret, "nma_") || !strings.HasPrefix(asecret, "nma_") || !strings.HasPrefix(psecret, "nmp_") {
|
||||
t.Fatalf("secret prefixes: %q %q %q", vsecret, asecret, psecret)
|
||||
}
|
||||
|
||||
viewer, admin, push := authtest.Creds{Token: vsecret}, authtest.Creds{Token: asecret}, authtest.Creds{Token: psecret}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
c authtest.Creds
|
||||
method string
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{"viewer token reads", viewer, http.MethodGet, "/api/v1/devices", http.StatusOK},
|
||||
{"viewer token cannot PUT", viewer, http.MethodPut, "/api/v1/devices/" + dev.Slug, http.StatusForbidden},
|
||||
{"viewer token cannot list tokens", viewer, http.MethodGet, "/api/v1/tokens", http.StatusForbidden},
|
||||
{"admin token PUTs", admin, http.MethodPut, "/api/v1/devices/" + dev.Slug, http.StatusOK},
|
||||
{"admin token lists tokens", admin, http.MethodGet, "/api/v1/tokens", http.StatusOK},
|
||||
{"push token cannot read", push, http.MethodGet, "/api/v1/devices", http.StatusForbidden},
|
||||
} {
|
||||
body := ""
|
||||
if tc.method == http.MethodPut {
|
||||
body = `{"name":"core-1"}`
|
||||
}
|
||||
if got := e.call(tc.method, tc.path, body, tc.c).Code; got != tc.want {
|
||||
t.Errorf("%s: %s %s = %d, want %d", tc.name, tc.method, tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
if code := e.call(http.MethodGet, "/api/v1/tokens", "", viewerSess).Code; code != http.StatusForbidden {
|
||||
t.Errorf("a viewer session listing tokens: %d, want 403", code)
|
||||
}
|
||||
|
||||
list := e.call(http.MethodGet, "/api/v1/tokens", "", admin)
|
||||
for _, secret := range []string{vsecret, asecret, psecret} {
|
||||
if strings.Contains(list.Body.String(), secret) || strings.Contains(list.Body.String(), "$") {
|
||||
t.Fatalf("the token list leaks a secret or hash: %s", list.Body)
|
||||
}
|
||||
}
|
||||
var items struct {
|
||||
Items []struct{ ID, Name, Scope string }
|
||||
}
|
||||
if err := json.Unmarshal(list.Body.Bytes(), &items); err != nil || len(items.Items) != 5 { // three minted here and the two Mint made for the sessions
|
||||
t.Fatalf("list: %v %s", err, list.Body)
|
||||
}
|
||||
|
||||
if code := e.call(http.MethodDelete, "/api/v1/tokens/"+vid, "", admin).Code; code != http.StatusNoContent {
|
||||
t.Fatalf("revoke the viewer token: %d", code)
|
||||
}
|
||||
if code := e.call(http.MethodGet, "/api/v1/devices", "", viewer).Code; code != http.StatusUnauthorized {
|
||||
t.Fatalf("a revoked token must be refused at once: %d", code)
|
||||
}
|
||||
if code := e.call(http.MethodGet, "/api/v1/devices", "", admin).Code; code != http.StatusOK {
|
||||
t.Fatalf("revoking one token must not affect another: %d", code)
|
||||
}
|
||||
if code := e.call(http.MethodPost, "/api/v1/tokens", `{"name":"x","scope":"admin","devices":["`+dev.Slug+`"]}`, adminSess).Code; code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("an admin token bound to devices: %d, want 422", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeReportsTheCaller(t *testing.T) {
|
||||
e := newAuthedEnv(t, nil)
|
||||
viewerSess, _ := authtest.Mint(t, e.auth, auth.RoleViewer)
|
||||
var me struct{ User, Role, Via, CSRF string }
|
||||
rec := e.call(http.MethodGet, "/api/v1/me", "", viewerSess)
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil || me.Role != "viewer" || me.Via != "session" || me.CSRF != viewerSess.CSRF {
|
||||
t.Fatalf("me = %+v (%v) %s", me, err, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryAuditedActionNamesThePrincipalNotAnonymous(t *testing.T) {
|
||||
e := newAuthedEnv(t, nil)
|
||||
adminSess, _ := authtest.Mint(t, e.auth, auth.RoleAdmin)
|
||||
dev := e.device(t, "core-1", "dc1", "core")
|
||||
if rec := e.call(http.MethodPut, "/api/v1/devices/"+dev.Slug, `{"site":"dc2"}`, adminSess); rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
rows, _, err := e.st.ListAudit(context.Background(), store.Page{Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, r := range rows {
|
||||
if r.Action == "device.edit" {
|
||||
found = true
|
||||
if r.Actor != "oidc:authtest-admin" {
|
||||
t.Errorf("device.edit actor %q, want the session's subject", r.Actor)
|
||||
}
|
||||
}
|
||||
if r.Actor == "anonymous" {
|
||||
t.Errorf("audit row %s has the actor anonymous", r.Action)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no device.edit audit row in %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeSettings records what the API asked and refuses invalid documents.
|
||||
type fakeSettings struct {
|
||||
mu sync.Mutex
|
||||
yaml string
|
||||
last string
|
||||
}
|
||||
|
||||
func (f *fakeSettings) Current(context.Context) (string, []string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.yaml, []string{"retention"}, nil
|
||||
}
|
||||
|
||||
func (f *fakeSettings) Apply(_ context.Context, actor, y string) (api.SettingsResult, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if strings.Contains(y, "bad") {
|
||||
return api.SettingsResult{}, fmt.Errorf("%w: retention.hot must be at least 1d", api.ErrInvalidSettings)
|
||||
}
|
||||
f.yaml, f.last = y, actor
|
||||
return api.SettingsResult{Applied: []string{"backup"}}, nil
|
||||
}
|
||||
|
||||
func TestSettingsInvalidIs422AndUnchangedValidIsApplied(t *testing.T) {
|
||||
fs := &fakeSettings{yaml: "timezone: UTC\n"}
|
||||
e := newAuthedEnv(t, func(d *api.Deps) { d.Settings = fs })
|
||||
adminSess, _ := authtest.Mint(t, e.auth, auth.RoleAdmin)
|
||||
viewerSess, _ := authtest.Mint(t, e.auth, auth.RoleViewer)
|
||||
|
||||
if code := e.call(http.MethodGet, "/api/v1/settings", "", viewerSess).Code; code != http.StatusForbidden {
|
||||
t.Fatalf("a viewer reading settings: %d, want 403", code)
|
||||
}
|
||||
rec := e.call(http.MethodPut, "/api/v1/settings", `{"yaml":"bad: 1"}`, adminSess)
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "retention.hot") {
|
||||
t.Fatalf("invalid settings: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if fs.yaml != "timezone: UTC\n" {
|
||||
t.Fatalf("an invalid document changed the configuration: %q", fs.yaml)
|
||||
}
|
||||
rec = e.call(http.MethodPut, "/api/v1/settings", `{"yaml":"backup: {at: \"03:00\"}"}`, adminSess)
|
||||
var out struct {
|
||||
Applied []string `json:"applied"`
|
||||
RestartRequired []string `json:"restart_required"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || rec.Code != http.StatusOK || len(out.Applied) != 1 || out.Applied[0] != "backup" {
|
||||
t.Fatalf("valid settings: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if fs.last != "oidc:authtest-admin" {
|
||||
t.Fatalf("the change was attributed to %q", fs.last)
|
||||
}
|
||||
}
|
||||
|
||||
func multipartBody(t *testing.T, files map[string]string) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
for field, content := range files {
|
||||
fw, err := w.CreateFormFile(field, field+".csv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = fw.Write([]byte(content))
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &buf, w.FormDataContentType()
|
||||
}
|
||||
|
||||
func (e *authedEnv) importCSV(t *testing.T, c authtest.Creds, files map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, ct := multipartBody(t, files)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/devices/import", body)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
req.AddCookie(c.Cookie)
|
||||
req.Header.Set(auth.CSRFHeader, c.CSRF)
|
||||
rec := httptest.NewRecorder()
|
||||
e.top.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestImportCycleIs422WithPathAndWritesNothingThenAValidImportLands(t *testing.T) {
|
||||
var imported []int64
|
||||
e := newAuthedEnv(t, func(d *api.Deps) { d.OnImported = func(ids []int64) { imported = append(imported, ids...) } })
|
||||
adminSess, _ := authtest.Mint(t, e.auth, auth.RoleAdmin)
|
||||
|
||||
cyc := "name,address,parents\na,192.0.2.1,c\nb,192.0.2.2,a\nc,192.0.2.3,b\n"
|
||||
rec := e.importCSV(t, adminSess, map[string]string{"devices": cyc})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "->") || !strings.Contains(rec.Body.String(), "cycle") {
|
||||
t.Fatalf("cycle import: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if _, n, err := e.st.ListDevices(context.Background(), store.DeviceFilter{}, store.Page{Limit: 10}); err != nil || n != 0 {
|
||||
t.Fatalf("a rejected import wrote %d devices (%v)", n, err)
|
||||
}
|
||||
|
||||
bad := "name,address\n,192.0.2.1\n"
|
||||
rec = e.importCSV(t, adminSess, map[string]string{"devices": bad})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "devices.csv row 2") {
|
||||
t.Fatalf("bad row: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
ok := "name,address,site,role,parents\ncore,192.0.2.1,dc1,core,\nedge,192.0.2.2,dc1,edge,core\n"
|
||||
rec = e.importCSV(t, adminSess, map[string]string{"devices": ok})
|
||||
var res struct {
|
||||
DevicesCreated int `json:"devices_created"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil || rec.Code != http.StatusOK || res.DevicesCreated != 2 {
|
||||
t.Fatalf("valid import: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if len(imported) != 2 {
|
||||
t.Fatalf("new devices were not handed to discovery: %v", imported)
|
||||
}
|
||||
viewerSess, _ := authtest.Mint(t, e.auth, auth.RoleViewer)
|
||||
if rec := e.importCSV(t, viewerSess, map[string]string{"devices": ok}); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("a viewer importing: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortDescriptionImportSetsTheOperatorOwnedFieldAndSearchFindsIt(t *testing.T) {
|
||||
e := newAuthedEnv(t, nil)
|
||||
adminSess, _ := authtest.Mint(t, e.auth, auth.RoleAdmin)
|
||||
d := e.device(t, "sw1", "dc1", "edge")
|
||||
ifc := e.iface(t, d, 1, "Gi1/0/1", "uplink from device")
|
||||
rec := e.importCSV(t, adminSess, map[string]string{"ports": "device,ifname,alias\nsw1,Gi1/0/1,Customer ACME Corp\n"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("ports import: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
got, err := e.st.GetInterface(context.Background(), ifc.ID)
|
||||
if err != nil || got.Description != "Customer ACME Corp" || got.IfAlias != "uplink from device" {
|
||||
t.Fatalf("interface = %+v (%v): the description must be set and the discovered alias left alone", got, err)
|
||||
}
|
||||
var list struct {
|
||||
Items []struct{ Description, IfAlias string }
|
||||
}
|
||||
rec = e.call(http.MethodGet, "/api/v1/interfaces?q=acme", "", adminSess)
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil || len(list.Items) != 1 || list.Items[0].Description != "Customer ACME Corp" {
|
||||
t.Fatalf("search by description: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var dv struct {
|
||||
Interfaces []struct{ Description string }
|
||||
}
|
||||
rec = e.call(http.MethodGet, "/api/v1/devices/"+d.Slug, "", adminSess)
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &dv); err != nil || len(dv.Interfaces) != 1 || dv.Interfaces[0].Description != "Customer ACME Corp" {
|
||||
t.Fatalf("device page: %s", rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSeries struct {
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSeries) DeleteSeries(_ context.Context, st string, m []string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, st+":"+strings.Join(m, ","))
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestPurgeOnlyAfterThePeriodDeletesSeriesFromBothStoresAndAudits(t *testing.T) {
|
||||
fs := &fakeSeries{}
|
||||
e := newAuthedEnv(t, func(d *api.Deps) { d.Series = fs; d.PurgeAfter = func() time.Duration { return 30 * 24 * time.Hour } })
|
||||
adminSess, _ := authtest.Mint(t, e.auth, auth.RoleAdmin)
|
||||
active := e.device(t, "active", "dc1", "core")
|
||||
old := e.device(t, "old", "dc1", "core")
|
||||
e.iface(t, old, 1, "Gi1", "")
|
||||
if err := e.st.RetireDevice(context.Background(), old.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if code := e.call(http.MethodPost, "/api/v1/devices/"+active.Slug+"/purge", "", adminSess).Code; code != http.StatusConflict {
|
||||
t.Fatalf("purging an active device: %d, want 409", code)
|
||||
}
|
||||
if code := e.call(http.MethodPost, "/api/v1/devices/"+old.Slug+"/purge", "", adminSess).Code; code != http.StatusConflict || len(fs.calls) != 0 {
|
||||
t.Fatalf("purging before the period: %d, deletions %v", code, fs.calls)
|
||||
}
|
||||
e.clk.Advance(31 * 24 * time.Hour)
|
||||
adminSess, _ = authtest.Mint(t, e.auth, auth.RoleAdmin) // the first session expired meanwhile
|
||||
fs.err = errors.New("store down")
|
||||
if code := e.call(http.MethodPost, "/api/v1/devices/"+old.Slug+"/purge", "", adminSess).Code; code != http.StatusBadGateway {
|
||||
t.Fatalf("purge with the store down: %d, want 502", code)
|
||||
}
|
||||
if d, _ := e.st.GetDeviceByID(context.Background(), old.ID); d.PurgedAt != nil {
|
||||
t.Fatal("the device was marked purged although its series could not be deleted")
|
||||
}
|
||||
fs.err, fs.calls = nil, nil
|
||||
if code := e.call(http.MethodPost, "/api/v1/devices/"+old.Slug+"/purge", "", adminSess).Code; code != http.StatusNoContent {
|
||||
t.Fatalf("purge: %d", code)
|
||||
}
|
||||
if len(fs.calls) != 2 || !strings.HasPrefix(fs.calls[0], "hot:") || !strings.HasPrefix(fs.calls[1], "long:") ||
|
||||
!strings.Contains(fs.calls[0], `device="`+old.Slug+`"`) || !strings.Contains(fs.calls[0], "iface=~") {
|
||||
t.Fatalf("deletions: %v", fs.calls)
|
||||
}
|
||||
if d, _ := e.st.GetDeviceByID(context.Background(), old.ID); d.PurgedAt == nil {
|
||||
t.Fatal("the device is not marked purged")
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func (s *server) createMaintenance(ctx context.Context, in *struct{ Body Mainten
|
||||
if err := tx.CreateMaintenanceWindow(ctx, w); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.AppendAudit(ctx, "anonymous", "maintenance.create", "maintenance:"+strconv.FormatInt(w.ID, 10), nil,
|
||||
return tx.AppendAudit(ctx, actorOf(ctx), "maintenance.create", "maintenance:"+strconv.FormatInt(w.ID, 10), nil,
|
||||
map[string]any{"device": in.Body.Device, "group_id": in.Body.GroupID, "ends_at": w.EndsAt, "reason": w.Reason})
|
||||
})
|
||||
if err != nil {
|
||||
@@ -178,7 +178,7 @@ func (s *server) deleteMaintenance(ctx context.Context, in *struct {
|
||||
if err := tx.DeleteMaintenanceWindow(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.AppendAudit(ctx, "anonymous", "maintenance.delete", "maintenance:"+in.ID, nil, nil)
|
||||
return tx.AppendAudit(ctx, actorOf(ctx), "maintenance.delete", "maintenance:"+in.ID, nil, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
|
||||
38
internal/api/me.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/v10networks/nmon/internal/auth"
|
||||
)
|
||||
|
||||
// MeView is the caller as nmon sees them.
|
||||
type MeView struct {
|
||||
User string `json:"user" doc:"Stable subject: the IDP subject, the token name, \"breakglass\" or \"dev\"."`
|
||||
Name string `json:"name" doc:"Display name; may be empty."`
|
||||
Role string `json:"role" enum:"viewer,admin"`
|
||||
Via string `json:"via" enum:"session,token,breakglass,dev" doc:"How the caller proved who they are."`
|
||||
CSRF string `json:"csrf" doc:"Send as X-CSRF-Token on every state-changing request made with the session cookie; empty for tokens."`
|
||||
DevOpen bool `json:"dev_open" doc:"Authentication is off: every caller is the admin \"dev\"."`
|
||||
}
|
||||
|
||||
func (s *server) registerMe(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "get-me", Method: http.MethodGet, Path: "/api/v1/me", Tags: []string{"auth"},
|
||||
Summary: "Who am I", Description: "The authenticated caller, their role and the CSRF token to send with writes.",
|
||||
Errors: []int{http.StatusUnauthorized},
|
||||
}, s.getMe)
|
||||
}
|
||||
|
||||
func (s *server) getMe(ctx context.Context, _ *struct{}) (*struct{ Body MeView }, error) {
|
||||
p, ok := auth.FromContext(ctx)
|
||||
if !ok {
|
||||
return nil, huma.Error401Unauthorized("authentication required")
|
||||
}
|
||||
return &struct{ Body MeView }{MeView{
|
||||
User: p.User, Name: p.Name, Role: string(p.Role), Via: string(p.Via), CSRF: p.CSRF, DevOpen: p.Via == auth.ViaDev,
|
||||
}}, nil
|
||||
}
|
||||
60
internal/api/policy.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/v10networks/nmon/internal/auth"
|
||||
)
|
||||
|
||||
// Route is one operation of the API as the Go 1.22 ServeMux and auth.Policy write it.
|
||||
type Route struct {
|
||||
Method string
|
||||
Pattern string // "PUT /api/v1/devices/{slug}"
|
||||
Path string // "/api/v1/devices/{slug}"
|
||||
}
|
||||
|
||||
// Routes lists every operation huma registers, taken from the same registration that serves them. The
|
||||
// viewer-cannot-mutate test and the auth policy both start from it, so a new route cannot be missed.
|
||||
func Routes() []Route {
|
||||
s := &server{Deps: Deps{}.withDefaults()}
|
||||
api := s.newHuma(http.NewServeMux())
|
||||
s.register(api)
|
||||
var out []Route
|
||||
for path, item := range api.OpenAPI().Paths {
|
||||
for method, op := range map[string]*huma.Operation{
|
||||
http.MethodGet: item.Get, http.MethodPut: item.Put, http.MethodPost: item.Post,
|
||||
http.MethodDelete: item.Delete, http.MethodPatch: item.Patch,
|
||||
} {
|
||||
if op == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Route{Method: method, Pattern: method + " " + path, Path: path})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Pattern < out[j].Pattern })
|
||||
return out
|
||||
}
|
||||
|
||||
// adminReads are GET routes only an admin may call: they reveal who can reach nmon or how it is configured.
|
||||
var adminReads = []string{"GET /api/v1/tokens", "GET /api/v1/settings", "GET /api/v1/audit"}
|
||||
|
||||
// viewerPosts are non-GET routes that only read, so a viewer may call them. None today; a query endpoint taking
|
||||
// a body would be listed here.
|
||||
var viewerPosts []string
|
||||
|
||||
// DeclarePolicy states the minimum role of each route that departs from the default (GET and HEAD need viewer,
|
||||
// everything else admin).
|
||||
func DeclarePolicy(p *auth.Policy) {
|
||||
for _, pat := range adminReads {
|
||||
p.Require(pat, auth.RoleAdmin)
|
||||
}
|
||||
for _, pat := range viewerPosts {
|
||||
p.Require(pat, auth.RoleViewer)
|
||||
}
|
||||
}
|
||||
|
||||
// ReadOnlyPosts are the non-GET patterns declared viewer-level, for the viewer-cannot-mutate test to skip.
|
||||
func ReadOnlyPosts() []string { return append([]string(nil), viewerPosts...) }
|
||||
96
internal/api/purge.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
"github.com/v10networks/nmon/internal/vmsup"
|
||||
)
|
||||
|
||||
// DefaultPurgeAfter is how long a device stays retired before an admin may purge it.
|
||||
const DefaultPurgeAfter = 365 * day
|
||||
|
||||
// SeriesDeleter deletes whole series, by selector, from one metric store ("hot" or "long").
|
||||
type SeriesDeleter interface {
|
||||
DeleteSeries(ctx context.Context, store string, matchers []string) error
|
||||
}
|
||||
|
||||
func (s *server) registerPurge(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "purge-device", Method: http.MethodPost, Path: "/api/v1/devices/{slug}/purge", Tags: []string{"devices"},
|
||||
Summary: "Purge a retired device",
|
||||
Description: "Admin only. Allowed once the device has been retired for the configured period (default one year). Deletes " +
|
||||
"the device's series from both metric stores and marks the inventory row purged; the slug stays reserved.",
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable, http.StatusBadGateway},
|
||||
}, s.purgeDevice)
|
||||
}
|
||||
|
||||
func (s *server) purgeAfter() time.Duration {
|
||||
if s.PurgeAfter != nil {
|
||||
if d := s.PurgeAfter(); d > 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return DefaultPurgeAfter
|
||||
}
|
||||
|
||||
func (s *server) purgeDevice(ctx context.Context, in *struct {
|
||||
Slug string `path:"slug" doc:"Device slug."`
|
||||
}) (*struct{}, error) {
|
||||
if s.Series == nil {
|
||||
return nil, errNotConfigured("metric store")
|
||||
}
|
||||
d, err := s.Store.GetDeviceBySlug(ctx, in.Slug)
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
if d.PurgedAt != nil {
|
||||
return nil, huma.Error409Conflict("device is already purged")
|
||||
}
|
||||
if d.RetiredAt == nil {
|
||||
return nil, huma.Error409Conflict("only a retired device can be purged")
|
||||
}
|
||||
if wait := s.purgeAfter(); s.Clock.Now().Before(d.RetiredAt.Add(wait)) {
|
||||
return nil, huma.Error409Conflict("device was retired " + d.RetiredAt.UTC().Format(time.RFC3339) +
|
||||
"; it can be purged after " + d.RetiredAt.Add(wait).UTC().Format(time.RFC3339))
|
||||
}
|
||||
matchers, err := s.purgeMatchers(ctx, d)
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
for _, st := range []string{vmsup.StoreHot, vmsup.StoreLong} {
|
||||
if err := s.Series.DeleteSeries(ctx, st, matchers); err != nil {
|
||||
s.Logger.Error("purge: delete series", "device", d.Slug, "store", st, "err", err)
|
||||
return nil, huma.Error502BadGateway("could not delete the device's series from the " + st + " store; nothing was purged")
|
||||
}
|
||||
}
|
||||
if err := s.Store.PurgeDevice(ctx, d.ID); err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
if err := s.Store.AppendAudit(ctx, actorOf(ctx), "device.purge", d.Slug, nil, map[string]any{"retired_at": d.RetiredAt}); err != nil {
|
||||
s.Logger.Warn("audit device.purge", "err", err)
|
||||
}
|
||||
return &struct{}{}, nil
|
||||
}
|
||||
|
||||
// purgeMatchers selects every series of the device: its own (device label) and its interfaces' (iface label).
|
||||
func (s *server) purgeMatchers(ctx context.Context, d *store.Device) ([]string, error) {
|
||||
out := []string{`{device=` + quote(d.Slug) + `}`}
|
||||
ifs, err := s.Store.ListInterfaces(ctx, d.ID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ifs) > 0 {
|
||||
ids := make([]string, len(ifs))
|
||||
for i := range ifs {
|
||||
ids[i] = ifs[i].ID
|
||||
}
|
||||
out = append(out, `{iface=~`+quote(regexAlt(ids))+`}`)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -223,10 +223,10 @@ func (s *server) applyResolution(ctx context.Context, r *store.IdentityReview, b
|
||||
}
|
||||
retired = append(retired, id)
|
||||
}
|
||||
if err := tx.ResolveIdentityReview(ctx, r.ID, "anonymous"); err != nil {
|
||||
if err := tx.ResolveIdentityReview(ctx, r.ID, actorOf(ctx)); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.AppendAudit(ctx, "anonymous", "identity_review."+b.Action, "identity_review:"+strconv.FormatInt(r.ID, 10),
|
||||
return tx.AppendAudit(ctx, actorOf(ctx), "identity_review."+b.Action, "identity_review:"+strconv.FormatInt(r.ID, 10),
|
||||
map[string]any{"kind": r.Kind, "interfaces": reviewIfaceIDs(r)}, map[string]any{"kept": b.Keep, "retired": retired})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func (s *server) repairRollup(ctx context.Context, in *struct{ Body rollup.Repai
|
||||
s.Logger.Error("rollup repair", "err", err)
|
||||
return nil, huma.Error500InternalServerError("rollup repair failed; see the log")
|
||||
}
|
||||
if err := s.Store.AppendAudit(ctx, "anonymous", "rollup.repair", "rollup", nil, in.Body); err != nil {
|
||||
if err := s.Store.AppendAudit(ctx, actorOf(ctx), "rollup.repair", "rollup", nil, in.Body); err != nil {
|
||||
s.Logger.Warn("audit rollup.repair", "err", err)
|
||||
}
|
||||
return &struct{ Body rollup.RepairReport }{rep}, nil
|
||||
|
||||
@@ -47,7 +47,7 @@ func (s *server) putSecret(ctx context.Context, in *struct {
|
||||
if err := w.PutSecret(ctx, in.Name, in.Body.Value); err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
if err := s.Store.AppendAudit(ctx, "anonymous", "secret.put", "secret:"+in.Name, nil, nil); err != nil { // the value is never audited
|
||||
if err := s.Store.AppendAudit(ctx, actorOf(ctx), "secret.put", "secret:"+in.Name, nil, nil); err != nil { // the value is never audited
|
||||
s.Logger.Warn("audit secret.put", "err", err)
|
||||
}
|
||||
return &struct{}{}, nil
|
||||
|
||||
94
internal/api/settings.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// ErrInvalidSettings wraps a settings document that failed validation; the running configuration is unchanged.
|
||||
var ErrInvalidSettings = errors.New("invalid settings")
|
||||
|
||||
// SettingsResult says what a settings change did.
|
||||
type SettingsResult struct {
|
||||
// Applied names the sections that took effect in the running process.
|
||||
Applied []string
|
||||
// RestartRequired names the changed sections that only take effect after a restart.
|
||||
RestartRequired []string
|
||||
}
|
||||
|
||||
// SettingsAdmin reads config.yaml and replaces it. Apply validates the whole document first and changes
|
||||
// nothing when it is invalid (the error wraps ErrInvalidSettings); otherwise it writes the file atomically and
|
||||
// reloads the parts that can change while nmon runs.
|
||||
type SettingsAdmin interface {
|
||||
// Current is the configuration as YAML and the sections edited since start that still need a restart.
|
||||
Current(ctx context.Context) (yaml string, restartRequired []string, err error)
|
||||
Apply(ctx context.Context, actor, yaml string) (SettingsResult, error)
|
||||
}
|
||||
|
||||
// SettingsView is the configuration document.
|
||||
type SettingsView struct {
|
||||
YAML string `json:"yaml" doc:"config.yaml. Secrets are never in it; the OIDC client secret is a named secret."`
|
||||
RestartRequired []string `json:"restart_required" doc:"Sections whose saved value differs from what is running and needs a restart."`
|
||||
}
|
||||
|
||||
// SettingsBody replaces the configuration document.
|
||||
type SettingsBody struct {
|
||||
YAML string `json:"yaml" minLength:"1" maxLength:"1048576" doc:"The complete new config.yaml."`
|
||||
}
|
||||
|
||||
// SettingsApplied reports the outcome of PUT /settings.
|
||||
type SettingsApplied struct {
|
||||
YAML string `json:"yaml" doc:"The configuration as saved."`
|
||||
Applied []string `json:"applied" doc:"Sections reloaded in place."`
|
||||
RestartRequired []string `json:"restart_required" doc:"Sections saved but running with the old value until a restart: listen address, TLS, data dir, retention, rules, checks, Proxmox, timezone."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
|
||||
}
|
||||
|
||||
func (s *server) registerSettings(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "get-settings", Method: http.MethodGet, Path: "/api/v1/settings", Tags: []string{"settings"},
|
||||
Summary: "Read the configuration", Description: "Admin only. The effective config.yaml.",
|
||||
Errors: []int{http.StatusServiceUnavailable},
|
||||
}, s.getSettings)
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "put-settings", Method: http.MethodPut, Path: "/api/v1/settings", Tags: []string{"settings"},
|
||||
Summary: "Replace the configuration",
|
||||
Description: "Admin only. Validates the whole document, writes config.yaml atomically and reloads what can change " +
|
||||
"without a restart. An invalid document is a 422 and nothing changes. SIGHUP reloads the file the same way.",
|
||||
Errors: []int{http.StatusUnprocessableEntity, http.StatusServiceUnavailable},
|
||||
}, s.putSettings)
|
||||
}
|
||||
|
||||
func (s *server) getSettings(ctx context.Context, _ *struct{}) (*struct{ Body SettingsView }, error) {
|
||||
if s.Settings == nil {
|
||||
return nil, errNotConfigured("settings")
|
||||
}
|
||||
y, pending, err := s.Settings.Current(ctx)
|
||||
if err != nil {
|
||||
s.Logger.Error("read settings", "err", err)
|
||||
return nil, huma.Error500InternalServerError("could not read the configuration; see the log")
|
||||
}
|
||||
return &struct{ Body SettingsView }{SettingsView{YAML: y, RestartRequired: nonNil(pending)}}, nil
|
||||
}
|
||||
|
||||
func (s *server) putSettings(ctx context.Context, in *struct{ Body SettingsBody }) (*struct{ Body SettingsApplied }, error) {
|
||||
if s.Settings == nil {
|
||||
return nil, errNotConfigured("settings")
|
||||
}
|
||||
res, err := s.Settings.Apply(ctx, actorOf(ctx), in.Body.YAML)
|
||||
if errors.Is(err, ErrInvalidSettings) {
|
||||
return nil, huma.Error422UnprocessableEntity(trimSentinel(err, ErrInvalidSettings))
|
||||
}
|
||||
if err != nil {
|
||||
s.Logger.Error("apply settings", "err", err)
|
||||
return nil, huma.Error500InternalServerError("could not save the configuration; see the log")
|
||||
}
|
||||
y, _, err := s.Settings.Current(ctx)
|
||||
if err != nil {
|
||||
s.Logger.Warn("read back settings", "err", err)
|
||||
}
|
||||
out := SettingsApplied{YAML: y, Applied: nonNil(res.Applied), RestartRequired: nonNil(res.RestartRequired)}
|
||||
return &struct{ Body SettingsApplied }{out}, nil
|
||||
}
|
||||
@@ -9,15 +9,16 @@ import (
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/v10networks/nmon/internal/auth"
|
||||
"github.com/v10networks/nmon/internal/ingest"
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
)
|
||||
|
||||
// TokenView is a token as listed. The secret and its hash are never part of it.
|
||||
type TokenView struct {
|
||||
ID string `json:"id"`
|
||||
ID string `json:"id" doc:"Token id. Viewer and admin tokens have an id such as a7; push tokens have a plain number."`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope" enum:"push" doc:"What the token may do. Only push exists until API tokens arrive."`
|
||||
Scope string `json:"scope" enum:"push,viewer,admin" doc:"push (ingest only), viewer (every read) or admin (everything)."`
|
||||
Devices []string `json:"devices" doc:"Slugs of the devices a push token may write as."`
|
||||
SeriesCap int64 `json:"series_cap" doc:"Most distinct series the sender may keep alive."`
|
||||
ExpiresAt *time.Time `json:"expires_at" nullable:"true"`
|
||||
@@ -38,8 +39,8 @@ type TokenList struct {
|
||||
// TokenBody is the body of POST /tokens.
|
||||
type TokenBody struct {
|
||||
Name string `json:"name" minLength:"1" maxLength:"100"`
|
||||
Scope string `json:"scope,omitempty" enum:"push" default:"push" doc:"Only push is accepted for now."`
|
||||
Devices []string `json:"devices" minItems:"1" doc:"Device slugs the token may push as. With several, the payload's host label picks one."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
|
||||
Scope string `json:"scope,omitempty" enum:"push,viewer,admin" default:"push" doc:"push writes metrics through ingest only; viewer and admin tokens (nma_...) call the API with that role."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
|
||||
Devices []string `json:"devices,omitempty" doc:"Push tokens only, at least one: device slugs the token may push as. With several, the payload's host label picks one."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
|
||||
SeriesCap int64 `json:"series_cap,omitempty" minimum:"0" doc:"Series cap; 0 or absent means the default of 50,000."`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty" doc:"After this time the token is rejected."`
|
||||
}
|
||||
@@ -47,20 +48,23 @@ type TokenBody struct {
|
||||
func (s *server) registerTokens(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "create-token", Method: http.MethodPost, Path: "/api/v1/tokens", Tags: []string{"tokens"},
|
||||
Summary: "Mint a push token", DefaultStatus: http.StatusCreated,
|
||||
Description: "The secret (nmp_<id>_<random>) is in this response only. A push token authenticates POST /write, " +
|
||||
"POST /api/v1/write and the Graphite listener, and nothing else.",
|
||||
Summary: "Mint a token", DefaultStatus: http.StatusCreated,
|
||||
Description: "Admin only. The secret is in this response only. A push token (nmp_<id>_<random>) authenticates POST /write, " +
|
||||
"POST /api/v1/write and the Graphite listener, and nothing else. A viewer or admin token (nma_<id>_<random>) " +
|
||||
"authenticates the API with that role in an Authorization: Bearer header.",
|
||||
Errors: []int{http.StatusUnprocessableEntity, http.StatusServiceUnavailable},
|
||||
}, s.createToken)
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "list-tokens", Method: http.MethodGet, Path: "/api/v1/tokens", Tags: []string{"tokens"},
|
||||
Summary: "List tokens", Description: "Revoked tokens stay listed. Secrets and hashes are never returned.",
|
||||
Errors: []int{http.StatusServiceUnavailable},
|
||||
Summary: "List tokens",
|
||||
Description: "Admin only. Push, viewer and admin tokens; revoked ones stay listed. Secrets and hashes are never returned.",
|
||||
Errors: []int{http.StatusServiceUnavailable},
|
||||
}, s.listTokens)
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "revoke-token", Method: http.MethodDelete, Path: "/api/v1/tokens/{id}", Tags: []string{"tokens"},
|
||||
Summary: "Revoke a token", Description: "The token is rejected on its next request.", DefaultStatus: http.StatusNoContent,
|
||||
Errors: []int{http.StatusNotFound, http.StatusServiceUnavailable},
|
||||
Summary: "Revoke a token", Description: "The token is rejected on its next request. Use the id as listed (a7 for an API token).",
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
Errors: []int{http.StatusNotFound, http.StatusServiceUnavailable},
|
||||
}, s.revokeToken)
|
||||
}
|
||||
|
||||
@@ -77,20 +81,13 @@ func (s *server) tokenView(ctx context.Context, t *store.PushToken) TokenView {
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *server) createToken(ctx context.Context, in *struct{ Body TokenBody }) (*struct{ Body TokenCreated }, error) {
|
||||
if s.Tokens == nil {
|
||||
return nil, errNotConfigured("push ingest")
|
||||
}
|
||||
b := in.Body
|
||||
if b.Scope != "" && b.Scope != store.PushScope {
|
||||
return nil, huma.Error422UnprocessableEntity("scope: only \"push\" tokens can be minted here")
|
||||
}
|
||||
name := strings.TrimSpace(b.Name)
|
||||
if name == "" {
|
||||
return nil, huma.Error422UnprocessableEntity("name must not be empty")
|
||||
// pushDeviceIDs resolves the slugs a push token is bound to; each must be an active device.
|
||||
func (s *server) pushDeviceIDs(ctx context.Context, slugs []string) ([]int64, error) {
|
||||
if len(slugs) == 0 {
|
||||
return nil, huma.Error422UnprocessableEntity("devices: a push token needs at least one device slug")
|
||||
}
|
||||
var ids []int64
|
||||
for _, slug := range b.Devices {
|
||||
for _, slug := range slugs {
|
||||
d, err := s.Store.GetDeviceBySlug(ctx, slug)
|
||||
if err != nil || d.RetiredAt != nil || d.PurgedAt != nil {
|
||||
if err != nil && !isNotFound(err) {
|
||||
@@ -100,16 +97,35 @@ func (s *server) createToken(ctx context.Context, in *struct{ Body TokenBody })
|
||||
}
|
||||
ids = append(ids, d.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *server) createToken(ctx context.Context, in *struct{ Body TokenBody }) (*struct{ Body TokenCreated }, error) {
|
||||
b := in.Body
|
||||
name := strings.TrimSpace(b.Name)
|
||||
if name == "" {
|
||||
return nil, huma.Error422UnprocessableEntity("name must not be empty")
|
||||
}
|
||||
if b.ExpiresAt != nil && !b.ExpiresAt.After(s.Clock.Now()) {
|
||||
return nil, huma.Error422UnprocessableEntity("expires_at must be in the future")
|
||||
}
|
||||
if b.Scope == string(auth.RoleViewer) || b.Scope == string(auth.RoleAdmin) {
|
||||
return s.createAPIToken(ctx, name, b)
|
||||
}
|
||||
if s.Tokens == nil {
|
||||
return nil, errNotConfigured("push ingest")
|
||||
}
|
||||
ids, err := s.pushDeviceIDs(ctx, b.Devices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, secret, err := s.Tokens.Mint(ctx, ingest.MintOptions{
|
||||
Name: name, DeviceIDs: ids, SeriesCap: int(b.SeriesCap), ExpiresAt: b.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
if err := s.Store.AppendAudit(ctx, "anonymous", "token.create", "token:"+strconv.FormatInt(row.ID, 10), nil,
|
||||
if err := s.Store.AppendAudit(ctx, actorOf(ctx), "token.create", "token:"+strconv.FormatInt(row.ID, 10), nil,
|
||||
map[string]any{"name": name, "scope": row.Scope, "devices": b.Devices}); err != nil {
|
||||
s.Logger.Warn("audit token.create", "err", err)
|
||||
}
|
||||
@@ -117,23 +133,33 @@ func (s *server) createToken(ctx context.Context, in *struct{ Body TokenBody })
|
||||
}
|
||||
|
||||
func (s *server) listTokens(ctx context.Context, _ *struct{}) (*struct{ Body TokenList }, error) {
|
||||
if s.Tokens == nil {
|
||||
return nil, errNotConfigured("push ingest")
|
||||
if s.Tokens == nil && s.APITokens == nil {
|
||||
return nil, errNotConfigured("tokens")
|
||||
}
|
||||
rows, err := s.Tokens.List(ctx)
|
||||
out := TokenList{Items: []TokenView{}}
|
||||
if s.Tokens != nil {
|
||||
rows, err := s.Tokens.List(ctx)
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
for i := range rows {
|
||||
out.Items = append(out.Items, s.tokenView(ctx, &rows[i]))
|
||||
}
|
||||
}
|
||||
api, err := s.listAPITokens(ctx)
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
out := TokenList{Items: make([]TokenView, 0, len(rows))}
|
||||
for i := range rows {
|
||||
out.Items = append(out.Items, s.tokenView(ctx, &rows[i]))
|
||||
}
|
||||
out.Items = append(out.Items, api...)
|
||||
return &struct{ Body TokenList }{out}, nil
|
||||
}
|
||||
|
||||
func (s *server) revokeToken(ctx context.Context, in *struct {
|
||||
ID string `path:"id" doc:"Token id."`
|
||||
}) (*struct{}, error) {
|
||||
if rest, ok := strings.CutPrefix(in.ID, apiTokenIDPrefix); ok {
|
||||
return s.revokeAPIToken(ctx, rest)
|
||||
}
|
||||
if s.Tokens == nil {
|
||||
return nil, errNotConfigured("push ingest")
|
||||
}
|
||||
@@ -144,7 +170,7 @@ func (s *server) revokeToken(ctx context.Context, in *struct {
|
||||
if err := s.Tokens.Revoke(ctx, id); err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
if err := s.Store.AppendAudit(ctx, "anonymous", "token.revoke", "token:"+in.ID, nil, nil); err != nil {
|
||||
if err := s.Store.AppendAudit(ctx, actorOf(ctx), "token.revoke", "token:"+in.ID, nil, nil); err != nil {
|
||||
s.Logger.Warn("audit token.revoke", "err", err)
|
||||
}
|
||||
return &struct{}{}, nil
|
||||
|
||||
72
internal/api/tokens_api.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/v10networks/nmon/internal/auth"
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
)
|
||||
|
||||
// apiTokenIDPrefix distinguishes the id of a viewer or admin token from a push token's: the two kinds live in
|
||||
// different tables, so their numbers overlap.
|
||||
const apiTokenIDPrefix = "a"
|
||||
|
||||
// APITokenAdmin mints, lists and revokes viewer and admin API tokens. *auth.Tokens implements it.
|
||||
type APITokenAdmin interface {
|
||||
Mint(ctx context.Context, actor string, o auth.MintOptions) (*store.APIToken, string, error)
|
||||
Revoke(ctx context.Context, actor string, id int64) error
|
||||
List(ctx context.Context) ([]store.APIToken, error)
|
||||
}
|
||||
|
||||
func apiTokenView(t *store.APIToken) TokenView {
|
||||
return TokenView{
|
||||
ID: apiTokenIDPrefix + strconv.FormatInt(t.ID, 10), Name: t.Name, Scope: t.Scope, Devices: []string{},
|
||||
ExpiresAt: utcp(t.ExpiresAt), RevokedAt: utcp(t.RevokedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) createAPIToken(ctx context.Context, name string, b TokenBody) (*struct{ Body TokenCreated }, error) {
|
||||
if s.APITokens == nil {
|
||||
return nil, errNotConfigured("API tokens")
|
||||
}
|
||||
if len(b.Devices) > 0 {
|
||||
return nil, huma.Error422UnprocessableEntity("devices: only push tokens are bound to devices")
|
||||
}
|
||||
row, secret, err := s.APITokens.Mint(ctx, actorOf(ctx), auth.MintOptions{Name: name, Scope: auth.Role(b.Scope), ExpiresAt: b.ExpiresAt})
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err) // Mint audits the creation itself
|
||||
}
|
||||
return &struct{ Body TokenCreated }{TokenCreated{TokenView: apiTokenView(row), Secret: secret}}, nil
|
||||
}
|
||||
|
||||
func (s *server) listAPITokens(ctx context.Context) ([]TokenView, error) {
|
||||
if s.APITokens == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.APITokens.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]TokenView, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, apiTokenView(&rows[i])) // the hash stays in the row
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *server) revokeAPIToken(ctx context.Context, rest string) (*struct{}, error) {
|
||||
if s.APITokens == nil {
|
||||
return nil, errNotConfigured("API tokens")
|
||||
}
|
||||
id, err := idParam(rest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.APITokens.Revoke(ctx, actorOf(ctx), id); err != nil {
|
||||
return nil, s.storeErr(err) // Revoke audits itself
|
||||
}
|
||||
return &struct{}{}, nil
|
||||
}
|
||||
184
internal/api/viewer_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/api"
|
||||
"github.com/v10networks/nmon/internal/auth"
|
||||
"github.com/v10networks/nmon/internal/auth/authtest"
|
||||
"github.com/v10networks/nmon/internal/clock"
|
||||
"github.com/v10networks/nmon/internal/ingest"
|
||||
"github.com/v10networks/nmon/internal/metrics"
|
||||
"github.com/v10networks/nmon/internal/poller"
|
||||
"github.com/v10networks/nmon/internal/selfmon"
|
||||
"github.com/v10networks/nmon/internal/testutil"
|
||||
)
|
||||
|
||||
// authedEnv is the API as production composes it: the auth middleware over a mux of the API, push ingest and
|
||||
// the /auth/ handlers (internal/app builds the same shape in topHandler).
|
||||
type authedEnv struct {
|
||||
*env
|
||||
auth *auth.Auth
|
||||
top http.Handler
|
||||
}
|
||||
|
||||
func newAuthedEnv(t *testing.T, mutate func(*api.Deps)) *authedEnv {
|
||||
t.Helper()
|
||||
clk := clock.NewFake(time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC))
|
||||
st := testutil.OpenStore(t, clk)
|
||||
a, err := auth.New(context.Background(), auth.Options{
|
||||
Store: st, Clock: clk, IngestPrefixes: []string{"/write", "/api/v1/write"}, Policy: auth.NewPolicy(),
|
||||
BreakGlass: auth.BreakGlassConfig{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
api.DeclarePolicy(a.Policy())
|
||||
a.Policy().Public("GET /healthz")
|
||||
e := &env{clk: clk, st: st, q: &querier{body: []byte(`{"status":"success","data":{"resultType":"vector","result":[]}}`)},
|
||||
ref: &refresher{}, reg: selfmon.New()}
|
||||
pushTokens := ingest.NewTokens(st, clk)
|
||||
d := api.Deps{Store: st, Querier: e.q, Poller: e.ref, Clock: clk, Registry: e.reg, DiscoveryTimeout: time.Second,
|
||||
PollerStatus: func() []poller.DeviceStatus { return nil }, Tokens: pushTokens, APITokens: a.Tokens,
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(&d)
|
||||
}
|
||||
e.h = api.New(d)
|
||||
g, err := ingest.New(ingest.Config{
|
||||
Tokens: pushTokens, Clock: clk, Registry: selfmon.New(),
|
||||
Writer: metrics.WriterFunc(func(context.Context, []metrics.Sample) error { return nil }),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(http.ResponseWriter, *http.Request) {})
|
||||
mux.Handle("/auth/", a.Handler())
|
||||
g.Mount(mux)
|
||||
mux.Handle("/", e.h)
|
||||
return &authedEnv{env: e, auth: a, top: a.Middleware(mux)}
|
||||
}
|
||||
|
||||
var pathParam = regexp.MustCompile(`\{[^}]+\}`)
|
||||
|
||||
// concrete turns a route pattern into a path a request can use.
|
||||
func concrete(path string) string { return pathParam.ReplaceAllString(path, "fuzz") }
|
||||
|
||||
// TestViewerCannotMutate is the M6 gate's core: every state-changing route of the real router refuses a viewer
|
||||
// session (with a valid CSRF token) and a viewer API token with 403, refuses a cookie request without the CSRF
|
||||
// token, and still works for an admin so the check cannot pass by refusing everything.
|
||||
func TestViewerCannotMutate(t *testing.T) {
|
||||
e := newAuthedEnv(t, nil)
|
||||
viewerSess, viewerTok := authtest.Mint(t, e.auth, auth.RoleViewer)
|
||||
adminSess, _ := authtest.Mint(t, e.auth, auth.RoleAdmin)
|
||||
|
||||
readOnly := map[string]bool{}
|
||||
for _, p := range api.ReadOnlyPosts() {
|
||||
readOnly[p] = true
|
||||
}
|
||||
var routes []authtest.Route
|
||||
for _, r := range api.Routes() {
|
||||
routes = append(routes, authtest.Route{Method: r.Method, Path: concrete(r.Path), ReadOnly: readOnly[r.Pattern]})
|
||||
}
|
||||
mutating := 0
|
||||
for _, r := range routes {
|
||||
if !auth.IsSafeMethod(r.Method) && !r.ReadOnly {
|
||||
mutating++
|
||||
}
|
||||
}
|
||||
if mutating < 25 {
|
||||
t.Fatalf("only %d state-changing routes were enumerated from the huma API; the enumeration is broken", mutating)
|
||||
}
|
||||
authtest.CheckViewerCannotMutate(t, e.top, routes, viewerSess, viewerTok, &adminSess)
|
||||
}
|
||||
|
||||
// TestViewerAndPushCredentialsOnIngestAndAuthRoutes covers the routes outside huma: push ingest accepts only a
|
||||
// push token (a viewer session or an API token is refused, 401 or 403), and the /auth/ endpoints that change
|
||||
// state either need no credentials by design (login) or only act on the caller's own session (logout).
|
||||
func TestViewerAndPushCredentialsOnIngestAndAuthRoutes(t *testing.T) {
|
||||
e := newAuthedEnv(t, nil)
|
||||
viewerSess, viewerTok := authtest.Mint(t, e.auth, auth.RoleViewer)
|
||||
for _, path := range []string{"/write", "/api/v1/write?format=influx"} {
|
||||
for name, c := range map[string]authtest.Creds{"viewer session": viewerSess, "viewer token": viewerTok} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("m v=1\n"))
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
if c.Cookie != nil {
|
||||
req.AddCookie(c.Cookie)
|
||||
req.Header.Set(auth.CSRFHeader, c.CSRF)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.top.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden {
|
||||
t.Errorf("POST %s with a %s: status %d, want 401 or 403", path, name, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Login endpoints are public by design; with nothing configured they say so instead of logging anyone in.
|
||||
for _, path := range []string{"/auth/breakglass", "/auth/login"} {
|
||||
method := http.MethodPost
|
||||
if path == "/auth/login" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(`{"username":"x","password":"y"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
e.top.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s with no provider configured: status %d, want 404", method, path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryRouteIsEitherPublicOrNeedsACredential: no declared API route answers an anonymous caller with data.
|
||||
func TestEveryRouteIsEitherPublicOrNeedsACredential(t *testing.T) {
|
||||
e := newAuthedEnv(t, nil)
|
||||
for _, r := range api.Routes() {
|
||||
req := httptest.NewRequest(r.Method, concrete(r.Path), strings.NewReader("{}"))
|
||||
rec := httptest.NewRecorder()
|
||||
e.top.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s without credentials: status %d, want 401", r.Pattern, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageLoadsRedirectToSignInAndAPICallsGet401(t *testing.T) {
|
||||
e := newAuthedEnv(t, nil)
|
||||
get := func(path string, hdr ...string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
for i := 0; i+1 < len(hdr); i += 2 {
|
||||
req.Header.Set(hdr[i], hdr[i+1])
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.top.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
rec := get("/devices/sw1?tab=ifaces")
|
||||
// No IdP is configured in this environment, so the redirect goes to the break-glass prompt.
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/auth/breakglass?return=%2Fdevices%2Fsw1%3Ftab%3Difaces" {
|
||||
t.Fatalf("page load: %d Location %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
for _, p := range []string{"/api/v1/devices", "/metrics", "/openapi.json"} {
|
||||
if rec := get(p); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s without credentials: %d, want 401 (no redirect)", p, rec.Code)
|
||||
}
|
||||
}
|
||||
if rec := get("/", "Authorization", "Bearer nma_1_nope"); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("a request with a bad bearer token is an API client: %d, want 401", rec.Code)
|
||||
}
|
||||
if rec := get("/auth/signedout"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Signed out") {
|
||||
t.Errorf("/auth/signedout: %d", rec.Code)
|
||||
}
|
||||
rec = get("/auth/breakglass")
|
||||
if rec.Code != http.StatusNotFound && rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("/auth/breakglass with no break-glass configured: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
135
internal/app/apiwire.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/api"
|
||||
"github.com/v10networks/nmon/internal/vmsup"
|
||||
"github.com/v10networks/nmon/internal/webui"
|
||||
)
|
||||
|
||||
// deleteTimeout bounds one delete_series call to a metric store.
|
||||
const deleteTimeout = time.Minute
|
||||
|
||||
// apiDeps hands the API everything it needs.
|
||||
func (a *App) apiDeps(s *settings) api.Deps {
|
||||
return api.Deps{
|
||||
Store: a.st, Querier: vmsup.NewQuerier(a.sup), Poller: a.poller, SNMPPort: s.SNMPPort, Logger: a.log,
|
||||
Profiles: a.profiles, PollerStatus: a.poller.Status, AfterDiscover: a.afterDiscover, PollInterval: s.PollInterval,
|
||||
HotRetention: s.cfg.Retention.Hot.D(), FiveMinuteRetention: s.cfg.Retention.FiveMinute.D(), LongRetention: s.cfg.Retention.Long.D(),
|
||||
Reachability: a.alerting.reach, DeviceWarnings: a.alerting.reach.Warnings,
|
||||
Dependencies: depGraph{a.alerting.reach}, OnMaintenanceChange: a.alerting.maint.Refresh,
|
||||
Tokens: a.svc.tokens, Secrets: a.st, Rollup: a.svc.rollup, P95: a.svc.p95, Groups: a.svc.groups, Zones: a.svc.zones,
|
||||
Checks: a.svc.checks, Proxmox: a.svc.proxmox,
|
||||
APITokens: a.auth.Tokens, Settings: a, Backups: a.backups, Stores: a.sup,
|
||||
Series: seriesDeleter{a.sup}, PurgeAfter: func() time.Duration { return a.live.get().PurgeAfter() },
|
||||
OnImported: a.discoverNew,
|
||||
}
|
||||
}
|
||||
|
||||
// discoverNew starts discovery for freshly imported devices.
|
||||
func (a *App) discoverNew(ids []int64) {
|
||||
for _, id := range ids {
|
||||
a.sched.Trigger(id)
|
||||
}
|
||||
}
|
||||
|
||||
// seriesDeleter deletes series from the metric stores through their localhost delete_series API.
|
||||
type seriesDeleter struct{ sup *vmsup.Supervisor }
|
||||
|
||||
// DeleteSeries implements api.SeriesDeleter.
|
||||
func (d seriesDeleter) DeleteSeries(ctx context.Context, store string, matchers []string) error {
|
||||
return vmsup.DeleteSeries(ctx, &http.Client{Timeout: deleteTimeout}, d.sup, store, matchers)
|
||||
}
|
||||
|
||||
// startAPI binds the listen address and serves the API, the UI and push ingest, over TLS unless --insecure-http.
|
||||
func (a *App) startAPI(s *settings) error {
|
||||
ui, err := webui.Handler(webui.Dist(), a.mux(api.New(a.apiDeps(s))))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ui = a.auth.Middleware(ui)
|
||||
ln, err := net.Listen("tcp", s.Listen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
a.ln = ln
|
||||
serve := ln
|
||||
if a.tr.cfg != nil {
|
||||
serve = tls.NewListener(ln, a.tr.cfg)
|
||||
}
|
||||
a.srv = &http.Server{Handler: ui, ReadHeaderTimeout: 10 * time.Second, WriteTimeout: 2 * time.Minute, IdleTimeout: 2 * time.Minute}
|
||||
go func() {
|
||||
if err := a.srv.Serve(serve); !errors.Is(err, http.ErrServerClosed) {
|
||||
a.serveErr <- err
|
||||
}
|
||||
}()
|
||||
a.onStop("http", a.srv.Shutdown)
|
||||
a.log.Info("serving", "addr", ln.Addr().String(), "transport", a.tr.describe())
|
||||
a.startChallenge(s)
|
||||
return nil
|
||||
}
|
||||
|
||||
// startChallenge serves the ACME http-01 challenge. Failing to bind port 80 never stops nmon: certificates
|
||||
// already issued keep working and renewal is retried by autocert.
|
||||
func (a *App) startChallenge(s *settings) {
|
||||
if a.tr.acme == nil {
|
||||
return
|
||||
}
|
||||
addr := s.ACMEListen
|
||||
if addr == "" {
|
||||
addr = DefaultACMEListen
|
||||
}
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
a.log.Warn("ACME challenge listener not started; certificates cannot be issued or renewed until "+addr+" can be bound", "err", err)
|
||||
return
|
||||
}
|
||||
srv := &http.Server{Handler: a.tr.acme, ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
a.onStop("acme challenge", srv.Shutdown)
|
||||
a.log.Info("ACME http-01 challenge listener up", "addr", addr)
|
||||
}
|
||||
|
||||
// stopSchedule ends the nightly backup schedule.
|
||||
func (a *App) stopSchedule() {
|
||||
a.schedMu.Lock()
|
||||
defer a.schedMu.Unlock()
|
||||
if a.stopBackup != nil {
|
||||
a.stopBackup()
|
||||
a.stopBackup = nil
|
||||
}
|
||||
}
|
||||
|
||||
// OnSIGHUP reloads what a running nmon can reload: config.yaml, the TLS certificate pair, and metric stores
|
||||
// that gave up restarting.
|
||||
func (a *App) OnSIGHUP() {
|
||||
if res, err := a.ReloadFile(); err != nil {
|
||||
a.log.Error("SIGHUP: config.yaml is invalid; the running configuration is unchanged", "err", err)
|
||||
} else {
|
||||
a.log.Info("SIGHUP: config reloaded", "applied", res.Applied, "restart_required", res.RestartRequired)
|
||||
}
|
||||
if a.tr.files != nil {
|
||||
if err := a.tr.files.Reload(); err != nil {
|
||||
a.log.Error("SIGHUP: certificate reload failed; the previous certificate keeps serving", "err", err)
|
||||
} else {
|
||||
a.log.Info("SIGHUP: TLS certificate reloaded")
|
||||
}
|
||||
}
|
||||
for _, c := range a.sup.Status() {
|
||||
if !c.Failed {
|
||||
continue
|
||||
}
|
||||
if err := a.sup.Restart(c.Store); err != nil {
|
||||
a.log.Error("SIGHUP: could not resume metric store", "store", c.Store, "err", err)
|
||||
} else {
|
||||
a.log.Info("SIGHUP: resumed metric store", "store", c.Store)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,18 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/auth"
|
||||
"github.com/v10networks/nmon/internal/config"
|
||||
"github.com/v10networks/nmon/internal/discovery"
|
||||
"github.com/v10networks/nmon/internal/diskguard"
|
||||
"github.com/v10networks/nmon/internal/ingest"
|
||||
"github.com/v10networks/nmon/internal/metrics"
|
||||
"github.com/v10networks/nmon/internal/netpolicy"
|
||||
"github.com/v10networks/nmon/internal/poller"
|
||||
"github.com/v10networks/nmon/internal/profile"
|
||||
@@ -56,6 +63,16 @@ type App struct {
|
||||
|
||||
serveErr chan error // the HTTP server stopped on its own
|
||||
|
||||
dataDir, keyFile string
|
||||
live *liveConfig // the configuration as running; see reload.go
|
||||
tr *transport
|
||||
auth *auth.Auth
|
||||
guard *diskguard.Guard
|
||||
hotW metrics.Writer // a.hot behind the disk guard's new-series admission
|
||||
backups *backupService
|
||||
schedMu sync.Mutex
|
||||
stopBackup func() // ends the nightly backup schedule; nil when none
|
||||
|
||||
// stops holds one step per subsystem that came up, in start order. Stop runs
|
||||
// them in reverse, so a subsystem goes down before the ones it depends on.
|
||||
stops []stopStep
|
||||
@@ -80,9 +97,19 @@ func Run(ctx context.Context, f config.Flags) error {
|
||||
return err
|
||||
}
|
||||
var runErr error
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case runErr = <-a.serveErr:
|
||||
hup := make(chan os.Signal, 1)
|
||||
signal.Notify(hup, syscall.SIGHUP)
|
||||
defer signal.Stop(hup)
|
||||
wait:
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break wait
|
||||
case runErr = <-a.serveErr:
|
||||
break wait
|
||||
case <-hup:
|
||||
a.OnSIGHUP()
|
||||
}
|
||||
}
|
||||
sctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
@@ -93,9 +120,10 @@ func Run(ctx context.Context, f config.Flags) error {
|
||||
// bounds startup only; call Stop to shut down. A failed Start stops whatever
|
||||
// had already come up.
|
||||
//
|
||||
// Startup order: inventory, metric stores, write queues, profiles, alerting
|
||||
// (built), poller, alerting (started), discovery scheduler, trap receiver,
|
||||
// rollup, p95 and group jobs, Proxmox sync and checks, push ingest (HTTP and Graphite), HTTP API.
|
||||
// Startup order: inventory, metric stores, alerting (built), write queues and the
|
||||
// disk guard, profiles, poller, alerting (started), discovery scheduler, trap receiver,
|
||||
// rollup, p95 and group jobs, Proxmox sync and checks, push ingest (HTTP and Graphite),
|
||||
// authentication, HTTP API, the nightly backup schedule.
|
||||
// Stop reverses it.
|
||||
func Start(ctx context.Context, f config.Flags) (_ *App, err error) {
|
||||
log := slog.Default()
|
||||
@@ -107,7 +135,8 @@ func Start(ctx context.Context, f config.Flags) (_ *App, err error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &App{log: log, serveErr: make(chan error, 1)}
|
||||
a := &App{log: log, serveErr: make(chan error, 1), dataDir: s.DataDir, keyFile: s.keyFile, live: newLiveConfig(s.cfg), tr: s.tr}
|
||||
a.backups = &backupService{a: a}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
sctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
@@ -121,11 +150,14 @@ func Start(ctx context.Context, f config.Flags) (_ *App, err error) {
|
||||
if err = a.startMetricStores(ctx, s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.startQueues()
|
||||
if err = a.loadProfiles(s); err != nil {
|
||||
if err = a.newAlerting(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = a.newAlerting(s); err != nil {
|
||||
a.startQueues()
|
||||
if err = a.startDisk(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = a.loadProfiles(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.startPoller(s)
|
||||
@@ -145,9 +177,14 @@ func Start(ctx context.Context, f config.Flags) (_ *App, err error) {
|
||||
if err = a.startIngest(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = a.startAuth(ctx, s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = a.startAPI(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.setBackupSchedule(s.cfg)
|
||||
a.onStop("backup schedule", func(context.Context) error { a.stopSchedule(); return nil })
|
||||
return a, nil
|
||||
}
|
||||
|
||||
|
||||
109
internal/app/authwire.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/api"
|
||||
"github.com/v10networks/nmon/internal/auth"
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
)
|
||||
|
||||
// ingestPrefixes are the paths push ingest authenticates itself (push tokens); the auth middleware leaves them alone.
|
||||
var ingestPrefixes = []string{"/write", "/api/v1/write"}
|
||||
|
||||
// breakGlassAlarmTTL is how long a break-glass alarm stays open after its last raise. A login lingers an hour so
|
||||
// an operator who was not watching still sees it; failures and lockouts clear when the attacker stops.
|
||||
func breakGlassAlarmTTL(kind string) time.Duration {
|
||||
if kind == auth.AlarmBreakGlassLogin {
|
||||
return time.Hour
|
||||
}
|
||||
return 20 * time.Minute
|
||||
}
|
||||
|
||||
// startAuth builds the authentication service and declares the route policy. OIDC needs its client secret, a
|
||||
// named secret set through PUT /api/v1/secrets/{name}; while it is missing OIDC stays off (with a warning) and
|
||||
// break-glass still works, so an admin can sign in and set it.
|
||||
func (a *App) startAuth(ctx context.Context, s *settings) error {
|
||||
ac := s.cfg.Auth
|
||||
opts := auth.Options{
|
||||
Store: a.st, Alarms: kindAlarms{a.alerting.sys, breakGlassAlarmTTL}, Logger: a.log,
|
||||
Session: auth.SessionConfig{
|
||||
InsecureCookies: s.InsecureHTTP, Idle: ac.Session.Idle.D(), Absolute: ac.Session.Absolute.D(),
|
||||
},
|
||||
BreakGlass: auth.BreakGlassConfig{File: ac.BreakGlassFile, Username: ac.BreakGlassUser},
|
||||
TrustedProxies: ac.TrustedProxies, IngestPrefixes: ingestPrefixes, Policy: auth.NewPolicy(),
|
||||
DevOpen: s.mode.devOpen,
|
||||
}
|
||||
oidc, err := a.oidcConfig(ctx, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.OIDC = oidc
|
||||
svc, err := auth.New(ctx, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: %w", err)
|
||||
}
|
||||
api.DeclarePolicy(svc.Policy())
|
||||
svc.Policy().Public("GET /healthz")
|
||||
a.auth = svc
|
||||
actx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { svc.Run(actx); close(done) }()
|
||||
a.onStop("auth", func(context.Context) error {
|
||||
cancel()
|
||||
<-done
|
||||
return nil
|
||||
})
|
||||
a.logAuth(s, oidc != nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// oidcConfig reads the client secret from the secret store. nil means OIDC is not configured, or its secret
|
||||
// is not stored yet.
|
||||
func (a *App) oidcConfig(ctx context.Context, s *settings) (*auth.OIDCConfig, error) {
|
||||
o := s.cfg.Auth.OIDC
|
||||
if o == nil {
|
||||
return nil, nil
|
||||
}
|
||||
secret, err := a.st.GetSecret(ctx, o.ClientSecretName)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
a.log.Warn("OIDC is configured but its client secret is not stored, so OIDC sign-in is OFF; "+
|
||||
"sign in with break-glass, PUT /api/v1/secrets/"+o.ClientSecretName+", and restart", "secret", o.ClientSecretName)
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("read OIDC client secret %q: %w", o.ClientSecretName, err)
|
||||
}
|
||||
return &auth.OIDCConfig{
|
||||
Issuer: o.Issuer, ClientID: o.ClientID, ClientSecret: secret, RedirectURL: o.RedirectURL,
|
||||
GroupsClaim: o.GroupsClaim, AdminGroup: o.AdminGroup,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) logAuth(s *settings, oidc bool) {
|
||||
if s.mode.devOpen {
|
||||
warnDevOpen(a.log, s.Listen)
|
||||
return
|
||||
}
|
||||
a.log.Info("authentication on", "oidc", oidc, "break_glass", s.cfg.Auth.BreakGlassFile != "",
|
||||
"trusted_proxies", len(s.cfg.Auth.TrustedProxies))
|
||||
}
|
||||
|
||||
// mux is the whole site behind the auth middleware: health, /auth/, push ingest, the API and the SPA. Only
|
||||
// /auth/*, /healthz and the ingest paths are public. An unauthenticated page load (the SPA, its files, /docs) is
|
||||
// redirected to sign in; an API call gets a 401 problem. The SPA itself has no login page.
|
||||
func (a *App) mux(apiH http.Handler) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
mux.Handle("/auth/", a.auth.Handler())
|
||||
a.ingester.Mount(mux)
|
||||
mux.Handle("/", apiH)
|
||||
return mux
|
||||
}
|
||||
154
internal/app/backupsvc.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/alert"
|
||||
"github.com/v10networks/nmon/internal/api"
|
||||
"github.com/v10networks/nmon/internal/backup"
|
||||
"github.com/v10networks/nmon/internal/clock"
|
||||
"github.com/v10networks/nmon/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
backupPrefix = "nmon-"
|
||||
backupSuffix = ".tar"
|
||||
backupAlarmRule = alert.RuleBackupFailed
|
||||
downloadDir = ".downloads"
|
||||
)
|
||||
|
||||
// backupService runs backups inside the serving process, because only it may touch the live stores.
|
||||
type backupService struct {
|
||||
a *App
|
||||
mu sync.Mutex // one backup at a time: two would snapshot the stores twice for nothing
|
||||
log bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *backupService) options(out string, includeKey bool) backup.Options {
|
||||
a := b.a
|
||||
return backup.Options{
|
||||
DataDir: a.dataDir, KeyFile: a.keyFile, IncludeKey: includeKey, OutPath: out, Store: a.st,
|
||||
Stores: backup.StoresFromStatus(a.dataDir, a.sup.Status()), ReserveCheck: a.guard.ReserveCheck,
|
||||
Clock: clock.Real(), Log: &b.log,
|
||||
}
|
||||
}
|
||||
|
||||
// run takes one backup into out.
|
||||
func (b *backupService) run(ctx context.Context, out string, includeKey bool) (*backup.Result, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.log.Reset()
|
||||
res, err := backup.Backup(ctx, b.options(out, includeKey))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.a.log.Info(strings.TrimSpace(b.log.String()), "file", res.Path, "bytes", res.Bytes)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Create implements api.BackupRunner: a backup in a scratch file that the caller streams and deletes.
|
||||
func (b *backupService) Create(ctx context.Context, includeKey bool) (api.BackupFile, error) {
|
||||
dir := filepath.Join(b.a.dataDir, "backups", downloadDir)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return api.BackupFile{}, err
|
||||
}
|
||||
out := filepath.Join(dir, fmt.Sprintf("download-%d%s", clock.Real().Now().UnixNano(), backupSuffix))
|
||||
res, err := b.run(ctx, out, includeKey)
|
||||
if errors.Is(err, backup.ErrReserve) {
|
||||
return api.BackupFile{}, fmt.Errorf("%w: %w", api.ErrBackupRefused, err)
|
||||
}
|
||||
if err != nil {
|
||||
return api.BackupFile{}, err
|
||||
}
|
||||
return api.BackupFile{Path: res.Path, KeyIncluded: res.KeyIncluded, Done: func() { _ = os.Remove(res.Path) }}, nil
|
||||
}
|
||||
|
||||
// scheduled takes the nightly backup into the configured directory and prunes old ones.
|
||||
func (b *backupService) scheduled(ctx context.Context, cfg config.Backup) error {
|
||||
dir := cfg.Dir
|
||||
if dir == "" {
|
||||
dir = filepath.Join(b.a.dataDir, "backups")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
name := backupPrefix + clock.Real().Now().UTC().Format("20060102-150405") + backupSuffix
|
||||
if _, err := b.run(ctx, filepath.Join(dir, name), false); err != nil {
|
||||
return err
|
||||
}
|
||||
return pruneBackups(dir, cfg.KeepOrDefault())
|
||||
}
|
||||
|
||||
// pruneBackups deletes the oldest nmon-*.tar files in dir beyond keep. Names sort by time.
|
||||
func pruneBackups(dir string, keep int) error {
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var names []string
|
||||
for _, e := range ents {
|
||||
if !e.IsDir() && strings.HasPrefix(e.Name(), backupPrefix) && strings.HasSuffix(e.Name(), backupSuffix) {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
var errs []error
|
||||
for len(names) > keep {
|
||||
errs = append(errs, os.Remove(filepath.Join(dir, names[0])))
|
||||
names = names[1:]
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// setBackupSchedule (re)starts the nightly backup for cfg; an empty "at" stops it. Called at start and on reload.
|
||||
func (a *App) setBackupSchedule(cfg config.Config) {
|
||||
a.schedMu.Lock()
|
||||
defer a.schedMu.Unlock()
|
||||
if a.stopBackup != nil {
|
||||
a.stopBackup()
|
||||
a.stopBackup = nil
|
||||
}
|
||||
if cfg.Backup.At == "" {
|
||||
return
|
||||
}
|
||||
loc, err := time.LoadLocation(cfg.Timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
sch := &backup.Scheduler{
|
||||
Clock: clock.Real(), At: cfg.Backup.At, Loc: loc,
|
||||
Job: func(ctx context.Context) error { return a.backups.scheduled(ctx, cfg.Backup) },
|
||||
OnResult: func(at time.Time, err error) {
|
||||
a.afterScheduledBackup(at, err)
|
||||
},
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { _ = sch.Run(ctx); close(done) }()
|
||||
a.stopBackup = func() { cancel(); <-done }
|
||||
a.log.Info("nightly backup scheduled", "at", cfg.Backup.At, "zone", loc.String(), "keep", cfg.Backup.KeepOrDefault())
|
||||
}
|
||||
|
||||
// afterScheduledBackup logs the result and keeps the backup_failed alarm in step with it.
|
||||
func (a *App) afterScheduledBackup(at time.Time, err error) {
|
||||
ctx := context.Background()
|
||||
if err != nil {
|
||||
a.log.Error("scheduled backup failed", "at", at, "err", err)
|
||||
if rerr := a.alerting.sys.raise(ctx, backupAlarmRule, "nightly", "the nightly backup failed: "+err.Error(), at, 0); rerr != nil {
|
||||
a.log.Warn("raise backup alarm", "err", rerr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if cerr := a.alerting.sys.clear(ctx, backupAlarmRule, "nightly"); cerr != nil {
|
||||
a.log.Warn("clear backup alarm", "err", cerr)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func (a *App) startCollectors(s *settings) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("proxmox: %w", err)
|
||||
}
|
||||
run, err := checks.New(s.cfg.Checks, a.policy, clk, a.st, icmp.New(clk), a.hot, obs, a.log)
|
||||
run, err := checks.New(s.cfg.Checks, a.policy, clk, a.st, icmp.New(clk), a.hotW, obs, a.log)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checks: %w", err)
|
||||
}
|
||||
|
||||
139
internal/app/diskwire.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/clock"
|
||||
"github.com/v10networks/nmon/internal/diskguard"
|
||||
)
|
||||
|
||||
// diskAlarmTTL is how long a disk alarm stays open after the condition was last seen: the guard raises on the
|
||||
// edge only, so diskLoop re-raises every minute while the condition holds and the alarm resolves itself after.
|
||||
const (
|
||||
diskAlarmTTL = 3 * time.Minute
|
||||
diskLoopEvery = time.Minute
|
||||
warmWindow = 15 * time.Minute
|
||||
warmTimeout = time.Minute
|
||||
maxWarmSeries = 2_000_000
|
||||
)
|
||||
|
||||
func diskTTL(string) time.Duration { return diskAlarmTTL }
|
||||
|
||||
// startDisk starts the disk guard and puts its admission policy in front of the hot write queue. The poller,
|
||||
// push ingest and the checks write through a.hotW; the rollup writes to the long store and pauses with the guard.
|
||||
func (a *App) startDisk(s *settings) error {
|
||||
g, err := diskguard.New(diskguard.Options{
|
||||
Path: s.DataDir, Alarms: kindAlarms{a.alerting.sys, diskTTL},
|
||||
AlertUsed: s.cfg.Disk.AlertUsed, AdmitUsed: s.cfg.Disk.AdmitUsed, ReserveFree: s.cfg.Disk.ReserveFree,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("disk guard: %w", err)
|
||||
}
|
||||
adm := diskguard.NewAdmission(a.hot, g, 0, nil)
|
||||
a.guard, a.hotW = g, adm
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{}, 2)
|
||||
go func() { g.Run(ctx); done <- struct{}{} }()
|
||||
go func() { a.diskLoop(ctx); done <- struct{}{} }()
|
||||
go a.warmAdmission(ctx, adm)
|
||||
a.onStop("disk guard", func(context.Context) error {
|
||||
cancel()
|
||||
<-done
|
||||
<-done
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// diskLoop keeps the three disk alarms open while their conditions hold.
|
||||
func (a *App) diskLoop(ctx context.Context) {
|
||||
t := clock.Real().NewTicker(diskLoopEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C():
|
||||
}
|
||||
g := a.guard
|
||||
detail := fmt.Sprintf("%.1f%% of %s used", g.Used()*100, a.dataDir)
|
||||
for kind, on := range map[string]bool{
|
||||
diskguard.AlarmNewSeriesHalt: g.RefuseNewSeries(),
|
||||
diskguard.AlarmReserve: g.Throttle(),
|
||||
} {
|
||||
if on {
|
||||
kindAlarms{a.alerting.sys, diskTTL}.Raise(kind, "", detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// warmAdmission tells the admission writer which series the hot store already has, so a restart while the disk is
|
||||
// above 90% does not treat every existing series as new.
|
||||
func (a *App) warmAdmission(ctx context.Context, adm *diskguard.Admission) {
|
||||
wctx, cancel := context.WithTimeout(ctx, warmTimeout)
|
||||
defer cancel()
|
||||
keys, err := hotSeriesKeys(wctx, a.sup.HotURL(), clock.Real().Now())
|
||||
if err != nil {
|
||||
a.log.Warn("could not list existing series for the disk guard; new series are treated as new until written once", "err", err)
|
||||
return
|
||||
}
|
||||
adm.Warm(keys)
|
||||
a.log.Info("disk guard knows the hot store's series", "series", len(keys))
|
||||
}
|
||||
|
||||
// hotSeriesKeys lists the series written recently as metrics.Sample.SeriesKey strings.
|
||||
func hotSeriesKeys(ctx context.Context, base string, now time.Time) ([]string, error) {
|
||||
q := url.Values{
|
||||
"match[]": {`{__name__=~".+"}`}, "start": {fmt.Sprint(now.Add(-warmWindow).Unix())}, "end": {fmt.Sprint(now.Unix())},
|
||||
"limit": {fmt.Sprint(maxWarmSeries)},
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/v1/series?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("series list: %s", resp.Status)
|
||||
}
|
||||
var body struct {
|
||||
Data []map[string]string `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, 0, len(body.Data))
|
||||
for _, m := range body.Data {
|
||||
keys = append(keys, seriesKeyOf(m))
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// seriesKeyOf builds the key metrics.Sample.SeriesKey would for these labels (sorted by name).
|
||||
func seriesKeyOf(m map[string]string) string {
|
||||
names := make([]string, 0, len(m))
|
||||
for n := range m {
|
||||
if n != "__name__" {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
var b strings.Builder
|
||||
b.WriteString(m["__name__"])
|
||||
for _, n := range names {
|
||||
b.WriteByte(0)
|
||||
b.WriteString(n + "=" + m[n])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/v10networks/nmon/internal/clock"
|
||||
@@ -17,7 +16,7 @@ func (a *App) startIngest(s *settings) error {
|
||||
clk := clock.Real()
|
||||
tokens := ingest.NewTokens(a.st, clk)
|
||||
g, err := ingest.New(ingest.Config{
|
||||
Tokens: tokens, Writer: a.hot, Clock: clk, Observer: a.alerting.reach, Alarms: ingestAlarms{a.alerting.sys},
|
||||
Tokens: tokens, Writer: a.hotW, Throttle: a.guard.Throttle, Clock: clk, Observer: a.alerting.reach, Alarms: ingestAlarms{a.alerting.sys},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("ingest: %w", err)
|
||||
@@ -44,11 +43,3 @@ func (a *App) logGraphiteDown(addr string, err error) {
|
||||
}
|
||||
a.log.Warn("graphite listener not started; HTTP push is unaffected", "addr", addr, "err", err)
|
||||
}
|
||||
|
||||
// mountIngest puts the push routes in front of the API handler on one mux.
|
||||
func (a *App) mountIngest(api http.Handler) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
a.ingester.Mount(mux)
|
||||
mux.Handle("/", api)
|
||||
return mux
|
||||
}
|
||||
|
||||
339
internal/app/m6_test.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/v10networks/nmon/internal/api"
|
||||
"github.com/v10networks/nmon/internal/config"
|
||||
)
|
||||
|
||||
// startWith starts a full server over external fake metric stores with config.yaml preset.
|
||||
func startWith(t *testing.T, mut func(*config.Flags), configYAML string) (*App, config.Flags) {
|
||||
t.Helper()
|
||||
f := flagsFor(t, fakeMetricStore(t).URL)
|
||||
if mut != nil {
|
||||
mut(&f)
|
||||
}
|
||||
if configYAML != "" {
|
||||
if err := os.MkdirAll(f.DataDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(config.File(f.DataDir), []byte(configYAML), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
a, err := Start(context.Background(), f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { stopApp(t, a) })
|
||||
return a, f
|
||||
}
|
||||
|
||||
func getBody(t *testing.T, c *http.Client, url string) (int, string) {
|
||||
t.Helper()
|
||||
resp, err := c.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
func breakGlassFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
h, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := filepath.Join(t.TempDir(), "bg")
|
||||
if err := os.WriteFile(p, h, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestDevOpenModeIsAdminDevWithInsecureHTTPAndNoSignInConfigured(t *testing.T) {
|
||||
a, _ := startWith(t, nil, "")
|
||||
code, body := getBody(t, http.DefaultClient, "http://"+a.Addr()+"/api/v1/me")
|
||||
var me struct {
|
||||
User, Role string
|
||||
DevOpen bool `json:"dev_open"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &me); err != nil || code != http.StatusOK || me.User != "dev" || me.Role != "admin" || !me.DevOpen {
|
||||
t.Fatalf("GET /api/v1/me in dev-open mode: %d %s", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredSignInMeansPagesRedirectAndAPIRoutesNeedACredentialWhileHealthAndSignedOutDoNot(t *testing.T) {
|
||||
a, _ := startWith(t, nil, "auth:\n break_glass_file: "+breakGlassFile(t)+"\n")
|
||||
base := "http://" + a.Addr()
|
||||
c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
for path, want := range map[string]int{
|
||||
"/healthz": http.StatusOK, "/auth/signedout": http.StatusOK,
|
||||
"/": http.StatusFound, "/devices/x": http.StatusFound, "/assets/app.js": http.StatusFound, "/docs": http.StatusFound,
|
||||
"/api/v1/devices": http.StatusUnauthorized, "/api/v1/me": http.StatusUnauthorized, "/metrics": http.StatusUnauthorized,
|
||||
"/auth/session": http.StatusUnauthorized, "/api/v1/settings": http.StatusUnauthorized, "/openapi.json": http.StatusUnauthorized,
|
||||
} {
|
||||
if code, body := getBody(t, c, base+path); code != want {
|
||||
t.Errorf("GET %s = %d, want %d: %.100s", path, code, want, body)
|
||||
}
|
||||
}
|
||||
resp, err := c.Get(base + "/devices/x?tab=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if loc := resp.Header.Get("Location"); loc != "/auth/breakglass?return=%2Fdevices%2Fx%3Ftab%3D1" {
|
||||
t.Fatalf("redirect to %q", loc)
|
||||
}
|
||||
// The break-glass prompt: a Basic challenge, then a session and a redirect to the page asked for.
|
||||
type answer struct {
|
||||
code int
|
||||
header http.Header
|
||||
cookies int
|
||||
}
|
||||
do := func(user string) answer {
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/auth/breakglass?return=%2Fdevices%2Fx", nil)
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, "pw")
|
||||
}
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
return answer{resp.StatusCode, resp.Header, len(resp.Cookies())}
|
||||
}
|
||||
if a := do(""); a.code != http.StatusUnauthorized || !strings.HasPrefix(a.header.Get("WWW-Authenticate"), "Basic") {
|
||||
t.Fatalf("break-glass without credentials: %d %v", a.code, a.header)
|
||||
}
|
||||
if a := do("breakglass"); a.code != http.StatusFound || a.header.Get("Location") != "/devices/x" || a.cookies == 0 {
|
||||
t.Fatalf("break-glass with credentials: %d %v", a.code, a.header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanTransportAndAuthRefusals(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
f config.Flags
|
||||
auth config.Auth
|
||||
want string
|
||||
}{
|
||||
"no tls and no insecure": {config.Flags{}, config.Auth{}, "--tls-cert"},
|
||||
"half a certificate pair": {config.Flags{TLSCert: "c.pem"}, config.Auth{}, "go together"},
|
||||
"cert and acme": {config.Flags{TLSCert: "c", TLSKey: "k", ACMEHosts: []string{"a.example"}}, config.Auth{}, "not both"},
|
||||
"insecure with tls": {config.Flags{InsecureHTTP: true, ACMEHosts: []string{"a.example"}}, config.Auth{}, "cannot be combined"},
|
||||
"unreadable certificate": {config.Flags{TLSCert: "/nonexistent/c", TLSKey: "/nonexistent/k"}, config.Auth{}, "tls: load"},
|
||||
"acme needs a host": {config.Flags{ACMEHosts: []string{}}, config.Auth{}, "--tls-cert"},
|
||||
"tls and no sign-in method": {config.Flags{ACMEHosts: []string{"a.example"}}, config.Auth{}, "no way to sign in"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
tr, err := planTransport(&tc.f, t.TempDir())
|
||||
if err == nil {
|
||||
_, err = planAuth(tr, tc.auth)
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error %v, want one mentioning %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
tr, err := planTransport(&config.Flags{InsecureHTTP: true}, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m, err := planAuth(tr, config.Auth{}); err != nil || !m.devOpen {
|
||||
t.Fatalf("insecure-http with no sign-in must be dev-open: %+v %v", m, err)
|
||||
}
|
||||
if m, err := planAuth(tr, config.Auth{BreakGlassFile: "x"}); err != nil || m.devOpen {
|
||||
t.Fatalf("a configured sign-in turns dev-open off: %+v %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
// selfSigned writes a certificate for 127.0.0.1 and returns its files and pool.
|
||||
func selfSigned(t *testing.T, serial int64) (certFile, keyFile string, pool *x509.CertPool) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(serial), Subject: pkix.Name{CommonName: "nmon test"},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, IsCA: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kb, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
certFile, keyFile = filepath.Join(dir, "c.pem"), filepath.Join(dir, "k.pem")
|
||||
if err := os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cert, _ := x509.ParseCertificate(der)
|
||||
pool = x509.NewCertPool()
|
||||
pool.AddCert(cert)
|
||||
return certFile, keyFile, pool
|
||||
}
|
||||
|
||||
func TestServesHTTPSWithTheProvidedCertificateAndReloadsItOnSIGHUP(t *testing.T) {
|
||||
certFile, keyFile, pool := selfSigned(t, 1)
|
||||
a, _ := startWith(t, func(f *config.Flags) { f.InsecureHTTP, f.TLSCert, f.TLSKey = false, certFile, keyFile },
|
||||
"auth:\n break_glass_file: "+breakGlassFile(t)+"\n")
|
||||
c := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}}}
|
||||
if code, _ := getBody(t, c, "https://"+a.Addr()+"/healthz"); code != http.StatusOK {
|
||||
t.Fatalf("https /healthz = %d", code)
|
||||
}
|
||||
if resp, err := http.Get("http://" + a.Addr() + "/healthz"); err == nil { // Go answers 400 to plain HTTP on a TLS port
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatal("plain HTTP was served on the TLS port")
|
||||
}
|
||||
}
|
||||
// A new pair on disk is served after SIGHUP, with no dropped listener.
|
||||
newCert, newKey, newPool := selfSigned(t, 2)
|
||||
copyFile(t, newCert, certFile)
|
||||
copyFile(t, newKey, keyFile)
|
||||
a.OnSIGHUP()
|
||||
c2 := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: newPool, MinVersion: tls.VersionTLS12}}}
|
||||
if code, _ := getBody(t, c2, "https://"+a.Addr()+"/healthz"); code != http.StatusOK {
|
||||
t.Fatalf("after SIGHUP the new certificate is not served: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func copyFile(t *testing.T, from, to string) {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(from)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(to, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsApplyRejectsInvalidWithoutChangeAndReloadsWhatCanChange(t *testing.T) {
|
||||
a, f := startWith(t, nil, "")
|
||||
ctx := context.Background()
|
||||
before, err := os.ReadFile(config.File(f.DataDir))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, bad := range []string{"retention: {hot: 1h}\n", "bogus: 1\n", "backup: {at: \"25:00\"}\n", "auth: {oidc: {issuer: x}}\n",
|
||||
"alerting:\n receivers:\n - {name: a, type: carrier-pigeon}\n", "disk: {alert_used: 0.95, admit_used: 0.9}\n"} {
|
||||
_, err := a.Apply(ctx, "tester", bad)
|
||||
if !errors.Is(err, api.ErrInvalidSettings) {
|
||||
t.Errorf("%q: error %v, want ErrInvalidSettings", bad, err)
|
||||
}
|
||||
}
|
||||
after, _ := os.ReadFile(config.File(f.DataDir))
|
||||
if string(before) != string(after) {
|
||||
t.Fatalf("an invalid document changed config.yaml:\n%s", after)
|
||||
}
|
||||
if err := a.policy.Check("127.0.0.1"); err == nil {
|
||||
t.Fatal("loopback must be denied by default")
|
||||
}
|
||||
|
||||
res, err := a.Apply(ctx, "tester", "alerting:\n outbound_allow: [\"127.0.0.1\"]\nbackup: {at: \"03:30\", keep: 2}\ndisk: {alert_used: 0.7}\n"+
|
||||
"retention: {hot: 40d, fiveminute: 120d, long: 2y}\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "alerting.outbound_allow backup disk"; strings.Join(res.Applied, " ") != want {
|
||||
t.Fatalf("applied %v, want %q", res.Applied, want)
|
||||
}
|
||||
if strings.Join(res.RestartRequired, " ") != "retention" {
|
||||
t.Fatalf("restart_required %v: retention is read once at start", res.RestartRequired)
|
||||
}
|
||||
if err := a.policy.Check("127.0.0.1"); err != nil {
|
||||
t.Fatalf("the reloaded allow-list is not in effect: %v", err)
|
||||
}
|
||||
if got, err := config.Load(f.DataDir); err != nil || got.Backup.At != "03:30" {
|
||||
t.Fatalf("config.yaml after apply: %+v %v", got.Backup, err)
|
||||
}
|
||||
if a.stopBackup == nil {
|
||||
t.Fatal("the nightly backup schedule was not started by the reload")
|
||||
}
|
||||
if _, restart, _ := a.Current(ctx); strings.Join(restart, " ") != "retention" {
|
||||
t.Fatalf("Current reports restart_required %v", restart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSIGHUPWithABrokenFileKeepsTheRunningConfiguration(t *testing.T) {
|
||||
a, f := startWith(t, nil, "alerting:\n outbound_allow: [\"127.0.0.1\"]\n")
|
||||
if err := a.policy.Check("127.0.0.1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(config.File(f.DataDir), []byte("retention: {hot: 1h}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.OnSIGHUP()
|
||||
if err := a.policy.Check("127.0.0.1"); err != nil {
|
||||
t.Fatalf("a failed reload changed the running allow-list: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(config.File(f.DataDir), []byte("timezone: UTC\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.OnSIGHUP()
|
||||
if err := a.policy.Check("127.0.0.1"); err == nil {
|
||||
t.Fatal("a valid reload that removes the allow entry must take effect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneBackupsKeepsTheNewestAndOnlyTouchesNmonArchives(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, n := range []string{"nmon-20261020-020000.tar", "nmon-20261021-020000.tar", "nmon-20261022-020000.tar", "nmon-20261023-020000.tar",
|
||||
"notes.txt", "other-20260101.tar"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, n), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := pruneBackups(dir, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ents, _ := os.ReadDir(dir)
|
||||
var got []string
|
||||
for _, e := range ents {
|
||||
got = append(got, e.Name())
|
||||
}
|
||||
want := "nmon-20261022-020000.tar nmon-20261023-020000.tar notes.txt other-20260101.tar"
|
||||
if strings.Join(got, " ") != want {
|
||||
t.Fatalf("after pruning: %v, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesKeyMatchesMetricsSampleSeriesKey(t *testing.T) {
|
||||
got := seriesKeyOf(map[string]string{"__name__": "ifHCInOctets", "iface": "01J", "device": "sw1"})
|
||||
if want := "ifHCInOctets\x00device=sw1\x00iface=01J"; got != want {
|
||||
t.Fatalf("%q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/alert"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
// and resolved notifications of one incident, and every retry, carry the same one.
|
||||
type notifier struct {
|
||||
q *notify.Queue
|
||||
mu sync.RWMutex // guards receivers, which a config reload replaces
|
||||
receivers []string
|
||||
st store.Store
|
||||
devices *ttlCache[string, store.Device]
|
||||
@@ -30,19 +32,35 @@ func newNotifier(
|
||||
q *notify.Queue, rs []notify.Receiver, st store.Store, devices *ttlCache[string, store.Device], log *slog.Logger,
|
||||
) *notifier {
|
||||
n := ¬ifier{q: q, st: st, devices: devices, log: log}
|
||||
for _, r := range rs {
|
||||
n.receivers = append(n.receivers, r.Name)
|
||||
}
|
||||
n.setReceivers(rs)
|
||||
return n
|
||||
}
|
||||
|
||||
// setReceivers replaces the receivers every notification goes to.
|
||||
func (n *notifier) setReceivers(rs []notify.Receiver) {
|
||||
names := make([]string, 0, len(rs))
|
||||
for _, r := range rs {
|
||||
names = append(names, r.Name)
|
||||
}
|
||||
n.mu.Lock()
|
||||
n.receivers = names
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
// names returns the current receivers.
|
||||
func (n *notifier) names() []string {
|
||||
n.mu.RLock()
|
||||
defer n.mu.RUnlock()
|
||||
return n.receivers
|
||||
}
|
||||
|
||||
// enqueue sends n to every receiver. A failure on one receiver is returned
|
||||
// after the others were tried; the evaluator then retries the whole transition,
|
||||
// so a receiver that already got it may see it twice, with the same incident ID
|
||||
// and therefore deduplicable.
|
||||
func (n *notifier) enqueue(ctx context.Context, note notify.Notification) error {
|
||||
var errs []error
|
||||
for _, r := range n.receivers {
|
||||
for _, r := range n.names() {
|
||||
if _, err := n.q.Enqueue(ctx, r, note); err != nil {
|
||||
errs = append(errs, fmt.Errorf("receiver %s: %w", r, err))
|
||||
}
|
||||
@@ -70,7 +88,7 @@ type reachNotifier struct{ *notifier }
|
||||
|
||||
// Notify implements reach.Notifier.
|
||||
func (r reachNotifier) Notify(ctx context.Context, t reach.Transition) error {
|
||||
if len(r.receivers) == 0 {
|
||||
if len(r.names()) == 0 {
|
||||
return nil
|
||||
}
|
||||
note := notify.Notification{
|
||||
@@ -91,7 +109,7 @@ type alertNotifier struct{ *notifier }
|
||||
|
||||
// Notify implements alert.Notifier.
|
||||
func (a alertNotifier) Notify(ctx context.Context, t alert.Transition) error {
|
||||
if len(a.receivers) == 0 {
|
||||
if len(a.names()) == 0 {
|
||||
return nil
|
||||
}
|
||||
note := notify.Notification{
|
||||
|
||||
184
internal/app/reload.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/v10networks/nmon/internal/alertcfg"
|
||||
"github.com/v10networks/nmon/internal/api"
|
||||
"github.com/v10networks/nmon/internal/config"
|
||||
"github.com/v10networks/nmon/internal/netpolicy"
|
||||
)
|
||||
|
||||
// Section names reported by a settings change.
|
||||
const (
|
||||
secReceivers = "alerting.receivers"
|
||||
secOutbound = "alerting.outbound_allow"
|
||||
secProxies = "auth.trusted_proxies"
|
||||
secBackup = "backup"
|
||||
secDisk = "disk"
|
||||
secPurge = "retired_purge_after"
|
||||
|
||||
secRules = "alerting.rules"
|
||||
secAuth = "auth"
|
||||
secRetention = "retention"
|
||||
secTimezone = "timezone"
|
||||
secSites = "sites"
|
||||
secProxmox = "proxmox"
|
||||
secChecks = "checks"
|
||||
)
|
||||
|
||||
// liveConfig is the configuration as the running process sees it. running is what the restart-only parts were
|
||||
// built from at start; current is the last one applied (or saved).
|
||||
type liveConfig struct {
|
||||
mu sync.Mutex
|
||||
running config.Config
|
||||
current config.Config
|
||||
}
|
||||
|
||||
func newLiveConfig(c config.Config) *liveConfig { return &liveConfig{running: c, current: c} }
|
||||
|
||||
func (l *liveConfig) get() config.Config {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.current
|
||||
}
|
||||
|
||||
// restartSections are the parts of the configuration that are read once at start. They are compared on the
|
||||
// resolved values so that formatting differences in the YAML do not count as a change.
|
||||
func restartSections(old, cur config.Config) []string {
|
||||
var out []string
|
||||
add := func(changed bool, name string) {
|
||||
if changed {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
oldRules, errOld := alertcfg.Resolve(old.Alerting)
|
||||
curRules, errCur := alertcfg.Resolve(cur.Alerting)
|
||||
add(errOld != nil || errCur != nil || !reflect.DeepEqual(oldRules.Rules, curRules.Rules) || oldRules.Interval != curRules.Interval,
|
||||
secRules)
|
||||
add(!reflect.DeepEqual(authExceptProxies(old.Auth), authExceptProxies(cur.Auth)), secAuth)
|
||||
add(old.Retention != cur.Retention, secRetention)
|
||||
add(old.Timezone != cur.Timezone, secTimezone)
|
||||
add(!reflect.DeepEqual(old.Sites, cur.Sites), secSites)
|
||||
add(!reflect.DeepEqual(old.Proxmox, cur.Proxmox), secProxmox)
|
||||
add(!reflect.DeepEqual(old.Checks, cur.Checks), secChecks)
|
||||
return out
|
||||
}
|
||||
|
||||
func authExceptProxies(a config.Auth) config.Auth {
|
||||
a.TrustedProxies = nil
|
||||
return a
|
||||
}
|
||||
|
||||
// Current implements api.SettingsAdmin.
|
||||
func (a *App) Current(context.Context) (string, []string, error) {
|
||||
cur := a.live.get()
|
||||
b, err := yaml.Marshal(cur)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
a.live.mu.Lock()
|
||||
running := a.live.running
|
||||
a.live.mu.Unlock()
|
||||
return string(b), restartSections(running, cur), nil
|
||||
}
|
||||
|
||||
// Apply implements api.SettingsAdmin: validate everything first, then save, then reload in place.
|
||||
func (a *App) Apply(ctx context.Context, actor, text string) (api.SettingsResult, error) {
|
||||
cfg, err := decodeStrict(text)
|
||||
if err != nil {
|
||||
return api.SettingsResult{}, fmt.Errorf("%w: %w", api.ErrInvalidSettings, err)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return api.SettingsResult{}, fmt.Errorf("%w: %w", api.ErrInvalidSettings, err)
|
||||
}
|
||||
if _, err := alertcfg.Resolve(cfg.Alerting); err != nil {
|
||||
return api.SettingsResult{}, fmt.Errorf("%w: %w", api.ErrInvalidSettings, err)
|
||||
}
|
||||
if err := config.Save(a.dataDir, cfg); err != nil {
|
||||
return api.SettingsResult{}, err
|
||||
}
|
||||
res := a.reloadWith(cfg)
|
||||
if err := a.st.AppendAudit(ctx, actor, "settings.update", "config.yaml", nil,
|
||||
map[string][]string{"applied": res.Applied, "restart_required": res.RestartRequired}); err != nil {
|
||||
a.log.Warn("audit settings.update", "err", err)
|
||||
}
|
||||
a.log.Info("settings saved", "by", actor, "applied", res.Applied, "restart_required", res.RestartRequired)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// decodeStrict parses a config document over the defaults, rejecting keys nmon does not know.
|
||||
func decodeStrict(text string) (config.Config, error) {
|
||||
cfg := config.Default()
|
||||
dec := yaml.NewDecoder(bytes.NewReader([]byte(text)))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(&cfg); err != nil && !errors.Is(err, io.EOF) {
|
||||
return cfg, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ReloadFile rereads config.yaml (SIGHUP). An invalid file changes nothing and returns the error.
|
||||
func (a *App) ReloadFile() (api.SettingsResult, error) {
|
||||
cfg, err := config.Load(a.dataDir)
|
||||
if err != nil {
|
||||
return api.SettingsResult{}, err
|
||||
}
|
||||
if _, err := alertcfg.Resolve(cfg.Alerting); err != nil {
|
||||
return api.SettingsResult{}, fmt.Errorf("config.yaml: %w", err)
|
||||
}
|
||||
return a.reloadWith(cfg), nil
|
||||
}
|
||||
|
||||
// reloadWith applies the parts of cfg that can change while running. Everything it calls has been validated.
|
||||
func (a *App) reloadWith(cfg config.Config) api.SettingsResult {
|
||||
old := a.live.get()
|
||||
al, _ := alertcfg.Resolve(cfg.Alerting) // validated by the caller
|
||||
oldAl, _ := alertcfg.Resolve(old.Alerting)
|
||||
var applied []string
|
||||
apply := func(changed bool, name string, fn func() error) {
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
if err := fn(); err != nil {
|
||||
a.log.Error("reload: could not apply "+name, "err", err)
|
||||
return
|
||||
}
|
||||
applied = append(applied, name)
|
||||
}
|
||||
apply(!reflect.DeepEqual(oldAl.Receivers, al.Receivers), secReceivers, func() error { return a.setReceivers(al) })
|
||||
apply(!reflect.DeepEqual(oldAl.OutboundAllow, al.OutboundAllow), secOutbound, func() error {
|
||||
return a.policy.Reload(netpolicy.Config{Allow: al.OutboundAllow})
|
||||
})
|
||||
apply(!reflect.DeepEqual(old.Auth.TrustedProxies, cfg.Auth.TrustedProxies), secProxies, func() error {
|
||||
return a.auth.SetTrustedProxies(cfg.Auth.TrustedProxies)
|
||||
})
|
||||
apply(old.Backup != cfg.Backup, secBackup, func() error { a.setBackupSchedule(cfg); return nil })
|
||||
apply(old.Disk != cfg.Disk, secDisk, func() error {
|
||||
a.guard.SetThresholds(cfg.Disk.AlertUsed, cfg.Disk.AdmitUsed, cfg.Disk.ReserveFree)
|
||||
return nil
|
||||
})
|
||||
apply(old.RetiredPurgeAfter != cfg.RetiredPurgeAfter, secPurge, func() error { return nil })
|
||||
a.live.mu.Lock()
|
||||
a.live.current = cfg
|
||||
running := a.live.running
|
||||
a.live.mu.Unlock()
|
||||
return api.SettingsResult{Applied: applied, RestartRequired: restartSections(running, cfg)}
|
||||
}
|
||||
|
||||
// setReceivers swaps the notification receivers: the delivery queue first (it validates), then the fan-out list.
|
||||
func (a *App) setReceivers(al alertcfg.Settings) error {
|
||||
if err := a.alerting.queue.SetReceivers(al.Receivers); err != nil {
|
||||
return err
|
||||
}
|
||||
a.alerting.nt.setReceivers(al.Receivers)
|
||||
return nil
|
||||
}
|
||||
@@ -37,7 +37,8 @@ func (a *App) startRollup(s *settings) error {
|
||||
ls := rollup.NewLongStore(a.sup, q)
|
||||
origin := a.installTime(context.Background(), clk)
|
||||
job, err := rollup.New(rollup.Options{
|
||||
Query: q, Inventory: a.st, State: a.st, Writer: ls, Deleter: ls, Queue: a.hot, Clock: clk, Logger: a.log, Origin: origin,
|
||||
Query: q, Inventory: a.st, State: a.st, Writer: ls, Deleter: ls, Queue: a.hot, Paused: a.guard.PauseRollup, Clock: clk,
|
||||
Logger: a.log, Origin: origin,
|
||||
Gauges: profileGauges(a.profiles), Alarms: ruleAlarms{a.alerting.sys, alert.RuleRollupStalled},
|
||||
HotRetention: s.cfg.Retention.Hot.D(), FiveMinuteRetention: s.cfg.Retention.FiveMinute.D(), LongRetention: s.cfg.Retention.Long.D(),
|
||||
})
|
||||
|
||||
92
internal/app/security.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/v10networks/nmon/internal/auth"
|
||||
"github.com/v10networks/nmon/internal/config"
|
||||
)
|
||||
|
||||
// DefaultACMEListen is where the http-01 challenge is served; the CA connects to port 80.
|
||||
const DefaultACMEListen = ":80"
|
||||
|
||||
// transport is how the API is served: plain HTTP for development, or HTTPS.
|
||||
type transport struct {
|
||||
insecure bool
|
||||
files *auth.CertFiles // provided certificate, reloaded on SIGHUP; nil with ACME or plain HTTP
|
||||
cfg *tls.Config // nil for plain HTTP
|
||||
acme http.Handler // the http-01 challenge handler when ACME is on
|
||||
}
|
||||
|
||||
// planTransport decides how to serve and refuses the combinations that would expose nmon unprotected:
|
||||
// no TLS without --insecure-http, both at once, or half a certificate pair.
|
||||
func planTransport(f *config.Flags, dataDir string) (*transport, error) {
|
||||
provided := f.TLSCert != "" || f.TLSKey != ""
|
||||
acme := len(f.ACMEHosts) > 0
|
||||
switch {
|
||||
case provided && (f.TLSCert == "" || f.TLSKey == ""):
|
||||
return nil, errors.New("--tls-cert and --tls-key go together")
|
||||
case provided && acme:
|
||||
return nil, errors.New("use either --tls-cert/--tls-key or --acme-host, not both")
|
||||
case f.InsecureHTTP && (provided || acme):
|
||||
return nil, errors.New("--insecure-http serves plain HTTP; it cannot be combined with TLS flags")
|
||||
case f.InsecureHTTP:
|
||||
return &transport{insecure: true}, nil
|
||||
case provided:
|
||||
files, err := auth.LoadCertFiles(f.TLSCert, f.TLSKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &transport{files: files, cfg: files.Config()}, nil
|
||||
case acme:
|
||||
cfg, challenge, err := auth.ACME(auth.ACMEOptions{DataDir: dataDir, Hosts: f.ACMEHosts, Email: f.ACMEEmail})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &transport{cfg: cfg, acme: challenge}, nil
|
||||
}
|
||||
return nil, errors.New("nmon serves HTTPS by default and has no certificate: pass --tls-cert and --tls-key, " +
|
||||
"or --acme-host (repeatable) and --acme-email for Let's Encrypt; " +
|
||||
"for development only, --insecure-http serves plain HTTP")
|
||||
}
|
||||
|
||||
// authMode says how callers are authenticated.
|
||||
type authMode struct {
|
||||
// devOpen: no authentication; only allowed together with --insecure-http.
|
||||
devOpen bool
|
||||
}
|
||||
|
||||
// planAuth refuses to serve over TLS with no way to sign in, and turns authentication off only for
|
||||
// --insecure-http with no OIDC and no break-glass file (development mode, DEVIATIONS 31).
|
||||
func planAuth(tr *transport, a config.Auth) (authMode, error) {
|
||||
configured := a.OIDC != nil || a.BreakGlassFile != ""
|
||||
switch {
|
||||
case configured:
|
||||
return authMode{}, nil
|
||||
case tr.insecure:
|
||||
return authMode{devOpen: true}, nil
|
||||
}
|
||||
return authMode{}, errors.New("no way to sign in is configured: set auth.oidc and/or auth.break_glass_file in config.yaml " +
|
||||
"(nmon will not serve over TLS with authentication off)")
|
||||
}
|
||||
|
||||
// warnDevOpen logs, loudly, that every request is an admin.
|
||||
func warnDevOpen(log *slog.Logger, addr string) {
|
||||
log.Warn("AUTHENTICATION IS DISABLED (development mode): every request is the admin \"dev\"; "+
|
||||
"plain HTTP is served on "+addr+"; configure auth.oidc or auth.break_glass_file and use TLS before exposing this port",
|
||||
"mode", "dev-open")
|
||||
}
|
||||
|
||||
// describe is the transport in one startup log line.
|
||||
func (t *transport) describe() string {
|
||||
switch {
|
||||
case t.insecure:
|
||||
return "plain HTTP (--insecure-http)"
|
||||
case t.acme != nil:
|
||||
return "HTTPS with ACME certificates"
|
||||
}
|
||||
return "HTTPS with the provided certificate"
|
||||
}
|
||||
@@ -26,18 +26,16 @@ type settings struct {
|
||||
cfg config.Config // <data-dir>/config.yaml over the defaults
|
||||
// alerting is cfg.Alerting decoded and validated.
|
||||
alerting alertcfg.Settings
|
||||
tr *transport
|
||||
mode authMode
|
||||
}
|
||||
|
||||
func (s *settings) profilesDir() string { return config.ProfilesDir(s.DataDir) }
|
||||
|
||||
// resolveSettings refuses start-up configurations that would put data at risk
|
||||
// (no --insecure-http, no data dir, the key inside the data dir) and fills in
|
||||
// resolveSettings refuses start-up configurations that would put data or access at risk
|
||||
// (no TLS and no --insecure-http, TLS with no way to sign in, the key inside the data dir) and fills in
|
||||
// the defaults. It creates the data dir but never the key.
|
||||
func resolveSettings(f config.Flags) (*settings, error) {
|
||||
if !f.InsecureHTTP {
|
||||
return nil, errors.New("TLS and authentication arrive in M6; " +
|
||||
"until then pass --insecure-http to run without them (development only)")
|
||||
}
|
||||
if f.DataDir == "" {
|
||||
return nil, errors.New("--data-dir is required")
|
||||
}
|
||||
@@ -69,7 +67,15 @@ func resolveSettings(f config.Flags) (*settings, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config.yaml: %w", err)
|
||||
}
|
||||
s := &settings{Flags: f, keyFile: keyFile, driver: f.DBDriver, dsn: f.DBDSN, cfg: cfg, alerting: al}
|
||||
tr, err := planTransport(&f, dataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mode, err := planAuth(tr, cfg.Auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &settings{Flags: f, keyFile: keyFile, driver: f.DBDriver, dsn: f.DBDSN, cfg: cfg, alerting: al, tr: tr, mode: mode}
|
||||
if s.driver == "" {
|
||||
s.driver = store.DriverSQLite
|
||||
}
|
||||
|
||||
@@ -39,7 +39,13 @@ type sysAlarms struct {
|
||||
expires map[string]time.Time // series key -> when an unrefreshed alarm resolves itself
|
||||
}
|
||||
|
||||
type ingestRaise struct{ kind, token, detail string }
|
||||
// ingestRaise is one queued raise. rule is empty for ingest alarms (rule = "ingest_" + kind); other
|
||||
// subsystems name their rule and how long the alarm stays open without being raised again.
|
||||
type ingestRaise struct {
|
||||
kind, token, detail string
|
||||
rule string
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func newSysAlarms(st store.Alerts, clk clock.Clock, nt alert.Notifier, log *slog.Logger) *sysAlarms {
|
||||
return &sysAlarms{st: st, clk: clk, nt: nt, log: log, in: make(chan ingestRaise, sysQueueSize), expires: map[string]time.Time{}}
|
||||
@@ -144,9 +150,13 @@ func (s *sysAlarms) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (s *sysAlarms) handleIngest(ctx context.Context, r ingestRaise) {
|
||||
rule := alert.RuleIngestPrefix + r.kind
|
||||
summary := fmt.Sprintf("push ingest %s tripped for token %q: %s", r.kind, r.token, r.detail)
|
||||
if err := s.raise(ctx, rule, r.token, summary, time.Time{}, ingestAlarmTTL); err != nil {
|
||||
rule, ttl := r.rule, r.ttl
|
||||
summary := fmt.Sprintf("%s: %s: %s", r.kind, r.token, r.detail)
|
||||
if rule == "" {
|
||||
rule, ttl = alert.RuleIngestPrefix+r.kind, ingestAlarmTTL
|
||||
summary = fmt.Sprintf("push ingest %s tripped for token %q: %s", r.kind, r.token, r.detail)
|
||||
}
|
||||
if err := s.raise(ctx, rule, r.token, summary, time.Time{}, ttl); err != nil {
|
||||
s.log.Warn("raise ingest alarm", "rule", rule, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -189,7 +199,7 @@ type ingestAlarms struct{ s *sysAlarms }
|
||||
// Raise implements ingest.Alarms.
|
||||
func (a ingestAlarms) Raise(kind, token, detail string) {
|
||||
select {
|
||||
case a.s.in <- ingestRaise{kind, token, detail}:
|
||||
case a.s.in <- ingestRaise{kind: kind, token: token, detail: detail}:
|
||||
default: // the queue is full of alarms already; the drop counters still tell the story
|
||||
}
|
||||
}
|
||||
@@ -207,3 +217,18 @@ func (r ruleAlarms) Raise(ctx context.Context, key, summary string, since time.T
|
||||
|
||||
// Clear implements p95.Alarms and rollup.Alarms.
|
||||
func (r ruleAlarms) Clear(ctx context.Context, key string) error { return r.s.clear(ctx, r.rule, key) }
|
||||
|
||||
// kindAlarms adapts diskguard.Alarms and auth.Alarms: the alarm kind is the rule name, the subject is the
|
||||
// second argument (an address, a path), and the alarm resolves on its own ttl after the last raise.
|
||||
type kindAlarms struct {
|
||||
s *sysAlarms
|
||||
ttl func(kind string) time.Duration
|
||||
}
|
||||
|
||||
// Raise implements diskguard.Alarms and auth.Alarms. It only queues.
|
||||
func (k kindAlarms) Raise(kind, subject, detail string) {
|
||||
select {
|
||||
case k.s.in <- ingestRaise{kind: kind, token: subject, detail: detail, rule: kind, ttl: k.ttl(kind)}:
|
||||
default: // the queue is full of alarms already
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/api"
|
||||
"github.com/v10networks/nmon/internal/clock"
|
||||
"github.com/v10networks/nmon/internal/config"
|
||||
"github.com/v10networks/nmon/internal/discovery"
|
||||
@@ -20,7 +16,6 @@ import (
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
"github.com/v10networks/nmon/internal/traps"
|
||||
"github.com/v10networks/nmon/internal/vmsup"
|
||||
"github.com/v10networks/nmon/internal/webui"
|
||||
)
|
||||
|
||||
// Each start* method brings one subsystem up and registers, with onStop, how
|
||||
@@ -97,7 +92,7 @@ func (a *App) loadProfiles(s *settings) error {
|
||||
func (a *App) startPoller(s *settings) {
|
||||
dir := s.profilesDir()
|
||||
a.poller = poller.New(poller.Options{
|
||||
Store: a.st, Writer: a.hot, Interval: s.PollInterval, MaxInflight: s.MaxInflight, SNMPPort: s.SNMPPort, Logger: a.log,
|
||||
Store: a.st, Writer: a.hotW, Interval: s.PollInterval, MaxInflight: s.MaxInflight, SNMPPort: s.SNMPPort, Logger: a.log,
|
||||
Profiles: a.profiles,
|
||||
LoadProfiles: func() (*profile.Set, error) { return profile.Load(dir) },
|
||||
Observer: a.alerting.reach, ICMPOff: s.ICMP == config.ICMPOff,
|
||||
@@ -178,37 +173,6 @@ func (a *App) logTrapsDown(addr string, err error) {
|
||||
a.log.Warn("trap receiver not started; polling and discovery are unaffected", "addr", addr, "err", err)
|
||||
}
|
||||
|
||||
// startAPI binds the listen address and serves the HTTP API.
|
||||
func (a *App) startAPI(s *settings) error {
|
||||
handler := api.New(api.Deps{
|
||||
Store: a.st, Querier: vmsup.NewQuerier(a.sup), Poller: a.poller, SNMPPort: s.SNMPPort, Logger: a.log,
|
||||
Profiles: a.profiles, PollerStatus: a.poller.Status, AfterDiscover: a.afterDiscover, PollInterval: s.PollInterval,
|
||||
HotRetention: s.cfg.Retention.Hot.D(), FiveMinuteRetention: s.cfg.Retention.FiveMinute.D(), LongRetention: s.cfg.Retention.Long.D(),
|
||||
Reachability: a.alerting.reach, DeviceWarnings: a.alerting.reach.Warnings,
|
||||
Dependencies: depGraph{a.alerting.reach}, OnMaintenanceChange: a.alerting.maint.Refresh,
|
||||
Tokens: a.svc.tokens, Secrets: a.st, Rollup: a.svc.rollup, P95: a.svc.p95, Groups: a.svc.groups, Zones: a.svc.zones,
|
||||
Checks: a.svc.checks, Proxmox: a.svc.proxmox,
|
||||
})
|
||||
ui, err := webui.Handler(webui.Dist(), a.mountIngest(handler))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ln, err := net.Listen("tcp", s.Listen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
a.ln = ln
|
||||
a.srv = &http.Server{Handler: ui, ReadHeaderTimeout: 10 * time.Second, WriteTimeout: 2 * time.Minute, IdleTimeout: 2 * time.Minute}
|
||||
go func() {
|
||||
if err := a.srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
||||
a.serveErr <- err
|
||||
}
|
||||
}()
|
||||
a.onStop("http", a.srv.Shutdown)
|
||||
a.log.Warn("serving plain HTTP without authentication (--insecure-http); do not expose this port", "addr", ln.Addr().String())
|
||||
return nil
|
||||
}
|
||||
|
||||
// discoverable lists the devices the scheduler should discover.
|
||||
func (a *App) discoverable(ctx context.Context) ([]int64, error) {
|
||||
var ids []int64
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/clock"
|
||||
@@ -29,6 +30,9 @@ type Options struct {
|
||||
IngestPrefixes []string
|
||||
// Policy declares per-route minimum roles; nil means defaults only.
|
||||
Policy *Policy
|
||||
// DevOpen turns authentication off: every request outside ingest is an admin Principal named "dev".
|
||||
// It exists for `serve --insecure-http` with no OIDC or break-glass configured (development only).
|
||||
DevOpen bool
|
||||
// KeyRotateEvery is how often the cookie-signing key rotates; default 30 days.
|
||||
KeyRotateEvery time.Duration
|
||||
}
|
||||
@@ -39,7 +43,7 @@ const DefaultKeyRotateEvery = 30 * 24 * time.Hour
|
||||
// Auth is the assembled authentication service.
|
||||
type Auth struct {
|
||||
opts Options
|
||||
proxies *Proxies
|
||||
proxies atomic.Pointer[Proxies]
|
||||
signer *signer
|
||||
Sessions *Sessions
|
||||
Tokens *Tokens
|
||||
@@ -69,7 +73,8 @@ func New(ctx context.Context, opts Options) (*Auth, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &Auth{opts: opts, proxies: proxies, signer: sg}
|
||||
a := &Auth{opts: opts, signer: sg}
|
||||
a.proxies.Store(proxies)
|
||||
if a.opts.Policy == nil {
|
||||
a.opts.Policy = NewPolicy()
|
||||
}
|
||||
@@ -104,11 +109,27 @@ func (a *Auth) build() error {
|
||||
// declareOwnRoutes registers the /auth/ endpoints in the policy: login, callback and break-glass need no
|
||||
// credentials; logout and the session probe need any authenticated user.
|
||||
func (a *Auth) declareOwnRoutes() {
|
||||
a.opts.Policy.Public("GET /auth/login", "GET /auth/callback", "POST /auth/breakglass")
|
||||
a.opts.Policy.Public("GET /auth/login", "GET /auth/callback", "GET /auth/breakglass", "POST /auth/breakglass", "GET /auth/signedout")
|
||||
a.opts.Policy.Require("POST /auth/logout", RoleViewer)
|
||||
a.opts.Policy.Require("GET /auth/session", RoleViewer)
|
||||
}
|
||||
|
||||
// SetTrustedProxies replaces the trusted-proxy list (config reload). An invalid list changes nothing.
|
||||
func (a *Auth) SetTrustedProxies(cidrs []string) error {
|
||||
p, err := ParseProxies(cidrs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.proxies.Store(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DevOpen reports whether authentication is off.
|
||||
func (a *Auth) DevOpen() bool { return a.opts.DevOpen }
|
||||
|
||||
// OIDCEnabled reports whether an identity provider is configured.
|
||||
func (a *Auth) OIDCEnabled() bool { return a.OIDC != nil }
|
||||
|
||||
// Policy returns the route policy, for the API to declare its routes on.
|
||||
func (a *Auth) Policy() *Policy { return a.opts.Policy }
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -13,14 +14,18 @@ import (
|
||||
//
|
||||
// GET /auth/login?next=/path start the OIDC flow
|
||||
// GET /auth/callback finish it and set the session cookie
|
||||
// POST /auth/breakglass {"username","password"}: local admin login
|
||||
// GET /auth/breakglass HTTP Basic prompt: local admin login, then redirect to ?return= or /
|
||||
// POST /auth/breakglass {"username","password"}: the same for scripts
|
||||
// GET /auth/signedout the page the UI shows after logout
|
||||
// POST /auth/logout revoke the session (CSRF token required)
|
||||
// GET /auth/session who am I, and the CSRF token to send as X-CSRF-Token
|
||||
func (a *Auth) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /auth/login", a.handleLogin)
|
||||
mux.HandleFunc("GET /auth/callback", a.handleCallback)
|
||||
mux.HandleFunc("GET /auth/breakglass", a.handleBreakGlassBasic)
|
||||
mux.HandleFunc("POST /auth/breakglass", a.handleBreakGlass)
|
||||
mux.HandleFunc("GET /auth/signedout", handleSignedOut)
|
||||
mux.HandleFunc("POST /auth/logout", a.handleLogout)
|
||||
mux.HandleFunc("GET /auth/session", a.handleSession)
|
||||
return mux
|
||||
@@ -84,17 +89,49 @@ func (a *Auth) handleBreakGlass(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
client, _ := ClientFromContext(r.Context())
|
||||
cookie, err := a.BreakGlass.Login(r.Context(), client, body.Username, body.Password)
|
||||
var locked *LockedError
|
||||
switch {
|
||||
case err == nil:
|
||||
if err == nil {
|
||||
http.SetCookie(w, cookie)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
a.breakGlassFailed(w, err, false)
|
||||
}
|
||||
|
||||
// handleBreakGlassBasic is the way in when the IdP is down: the browser's own Basic prompt, no page of ours.
|
||||
func (a *Auth) handleBreakGlassBasic(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
challengeBasic(w)
|
||||
writeProblem(w, http.StatusUnauthorized, "break-glass sign-in: enter the local admin user and password")
|
||||
return
|
||||
}
|
||||
client, _ := ClientFromContext(r.Context())
|
||||
cookie, err := a.BreakGlass.Login(r.Context(), client, user, pass)
|
||||
if err != nil {
|
||||
a.breakGlassFailed(w, err, true)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
http.Redirect(w, r, safeNext(returnParam(r)), http.StatusFound)
|
||||
}
|
||||
|
||||
func challengeBasic(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="nmon break-glass", charset="UTF-8"`)
|
||||
}
|
||||
|
||||
// breakGlassFailed answers a failed break-glass login; basic asks the browser to prompt again on a wrong password.
|
||||
func (a *Auth) breakGlassFailed(w http.ResponseWriter, err error, basic bool) {
|
||||
var locked *LockedError
|
||||
switch {
|
||||
case errors.As(err, &locked):
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(max(time.Second, locked.Until.Sub(a.opts.Clock.Now()))/time.Second)))
|
||||
writeProblem(w, http.StatusTooManyRequests, "too many failed attempts; this address is locked")
|
||||
case errors.Is(err, ErrBreakGlassDisabled):
|
||||
writeProblem(w, http.StatusNotFound, "break-glass login is not configured")
|
||||
case errors.Is(err, ErrBadCredentials):
|
||||
if basic {
|
||||
challengeBasic(w)
|
||||
}
|
||||
writeProblem(w, http.StatusUnauthorized, "wrong user or password")
|
||||
default:
|
||||
a.opts.Logger.Error("break-glass", slog.Any("err", err))
|
||||
@@ -132,3 +169,16 @@ func (a *Auth) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||
"user": p.User, "name": p.Name, "role": string(p.Role), "via": string(p.Via), "csrf_token": p.CSRF,
|
||||
})
|
||||
}
|
||||
|
||||
const signedOutPage = `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Signed out</title><style>body{font:16px system-ui,sans-serif;margin:4rem auto;max-width:28rem;padding:0 1rem;color:#222}
|
||||
@media (prefers-color-scheme:dark){body{background:#111;color:#ddd}a{color:#8ab4f8}}</style></head>
|
||||
<body><h1>Signed out</h1><p>Your nmon session has ended.</p><p><a href="/">Sign in again</a></p></body></html>`
|
||||
|
||||
// handleSignedOut is the page the UI goes to after logout: static HTML, no SPA, no credentials needed.
|
||||
func handleSignedOut(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = io.WriteString(w, signedOutPage) // a client that hung up has nobody to tell
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -18,7 +19,7 @@ const CSRFHeader = "X-CSRF-Token"
|
||||
// cookie request; and checks the route Policy's minimum role. Push tokens get 403 here.
|
||||
func (a *Auth) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
client := a.proxies.Resolve(r)
|
||||
client := a.proxies.Load().Resolve(r)
|
||||
if !client.ViaProxy {
|
||||
scrub(r)
|
||||
}
|
||||
@@ -32,9 +33,13 @@ func (a *Auth) Middleware(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if a.opts.DevOpen {
|
||||
next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), devPrincipal())))
|
||||
return
|
||||
}
|
||||
p, err := a.authenticate(r)
|
||||
if err != nil {
|
||||
a.deny(w, err)
|
||||
a.deny(w, r, err)
|
||||
return
|
||||
}
|
||||
if !p.Role.AtLeast(ru.min) {
|
||||
@@ -64,13 +69,17 @@ func validCSRF(want, got string) bool {
|
||||
return want != "" && subtle.ConstantTimeCompare([]byte(want), []byte(got)) == 1
|
||||
}
|
||||
|
||||
func (a *Auth) deny(w http.ResponseWriter, err error) {
|
||||
func (a *Auth) deny(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrForbidden):
|
||||
writeProblem(w, http.StatusForbidden, "this token is not valid for this API")
|
||||
case errors.Is(err, ErrCSRF):
|
||||
writeProblem(w, http.StatusForbidden, "missing or wrong "+CSRFHeader)
|
||||
case errors.Is(err, ErrUnauthenticated):
|
||||
if a.isNavigation(r) {
|
||||
http.Redirect(w, r, a.signInURL(r), http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="nmon"`)
|
||||
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
||||
default:
|
||||
@@ -93,3 +102,31 @@ func bearer(r *http.Request) (string, bool) {
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// isNavigation reports whether r is a browser page load (the SPA, its files, /docs) rather than an API call: those
|
||||
// are sent to sign in, while API calls get a 401 problem. A request that carried a bearer token is an API client.
|
||||
func (a *Auth) isNavigation(r *http.Request) bool {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
return false
|
||||
}
|
||||
if _, ok := bearer(r); ok {
|
||||
return false
|
||||
}
|
||||
p := r.URL.Path
|
||||
for _, api := range []string{"/api/", "/auth/", "/metrics", "/openapi", "/schemas"} {
|
||||
if strings.HasPrefix(p, api) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// signInURL is where an unauthenticated page load goes: the IdP flow, or the break-glass prompt when no IdP is
|
||||
// configured. The original URL travels in ?return= and is validated as a same-site path when it is used.
|
||||
func (a *Auth) signInURL(r *http.Request) string {
|
||||
target := "/auth/login"
|
||||
if a.OIDC == nil {
|
||||
target = "/auth/breakglass"
|
||||
}
|
||||
return target + "?return=" + url.QueryEscape(r.URL.RequestURI())
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ func (o *OIDC) Login(w http.ResponseWriter, r *http.Request) error {
|
||||
}
|
||||
f := flow{
|
||||
State: state, Nonce: nonce, Verifier: oauth2.GenerateVerifier(),
|
||||
Next: safeNext(r.URL.Query().Get("next")), Expires: o.clk.Now().Add(oidcFlowTTL),
|
||||
Next: safeNext(returnParam(r)), Expires: o.clk.Now().Add(oidcFlowTTL),
|
||||
}
|
||||
b, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
@@ -353,3 +353,12 @@ func (o *OIDC) EndSession(ctx context.Context, t IDPTokens) error {
|
||||
}
|
||||
return resp.Body.Close()
|
||||
}
|
||||
|
||||
// returnParam is where to go after sign-in: ?return=, or the older ?next=.
|
||||
func returnParam(r *http.Request) string {
|
||||
q := r.URL.Query()
|
||||
if v := q.Get("return"); v != "" {
|
||||
return v
|
||||
}
|
||||
return q.Get("next")
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ const (
|
||||
ViaSession Via = "session"
|
||||
ViaToken Via = "token"
|
||||
ViaBreakGlass Via = "breakglass"
|
||||
// ViaDev is the development-open mode: no authentication at all.
|
||||
ViaDev Via = "dev"
|
||||
)
|
||||
|
||||
// Principal is the authenticated caller of one request.
|
||||
@@ -53,6 +55,11 @@ type Principal struct {
|
||||
CSRF string
|
||||
}
|
||||
|
||||
// devPrincipal is who every caller is when authentication is off.
|
||||
func devPrincipal() *Principal {
|
||||
return &Principal{User: "dev", Name: "dev", Role: RoleAdmin, Via: ViaDev}
|
||||
}
|
||||
|
||||
// Actor names the principal for the audit log.
|
||||
func (p *Principal) Actor() string {
|
||||
switch p.Via {
|
||||
@@ -60,6 +67,8 @@ func (p *Principal) Actor() string {
|
||||
return "token:" + p.User
|
||||
case ViaBreakGlass:
|
||||
return "breakglass"
|
||||
case ViaDev:
|
||||
return "dev"
|
||||
default:
|
||||
return p.User
|
||||
}
|
||||
|
||||
136
internal/config/auth.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Auth is the auth section of config.yaml. Secrets never appear in it: the OIDC client secret is a named
|
||||
// secret in the inventory (PUT /api/v1/secrets/{name}) and the break-glass hash lives in its own file.
|
||||
type Auth struct {
|
||||
// OIDC configures the identity provider; nil means only break-glass and API tokens can sign in.
|
||||
OIDC *OIDC `yaml:"oidc,omitempty"`
|
||||
// BreakGlassFile holds a bcrypt hash ("hash" or "user:hash"), created by the operator. Empty disables it.
|
||||
BreakGlassFile string `yaml:"break_glass_file,omitempty"`
|
||||
// BreakGlassUser applies when the file holds a bare hash. Default "breakglass".
|
||||
BreakGlassUser string `yaml:"break_glass_user,omitempty"`
|
||||
// TrustedProxies are the CIDRs or addresses whose X-Forwarded-* headers are honoured.
|
||||
TrustedProxies []string `yaml:"trusted_proxies,omitempty"`
|
||||
// Session sets the session lifetimes; zero means the defaults (1 h idle, 12 h absolute).
|
||||
Session Session `yaml:"session,omitempty"`
|
||||
}
|
||||
|
||||
// OIDC is one OpenID Connect provider.
|
||||
type OIDC struct {
|
||||
Issuer string `yaml:"issuer"`
|
||||
ClientID string `yaml:"client_id"`
|
||||
// ClientSecretName names the stored secret holding the client secret.
|
||||
ClientSecretName string `yaml:"client_secret_name"`
|
||||
// RedirectURL is nmon's callback, ".../auth/callback".
|
||||
RedirectURL string `yaml:"redirect_url"`
|
||||
GroupsClaim string `yaml:"groups_claim,omitempty"`
|
||||
// AdminGroup is the group whose members are admins; empty means nobody is.
|
||||
AdminGroup string `yaml:"admin_group,omitempty"`
|
||||
}
|
||||
|
||||
// Session sets session lifetimes.
|
||||
type Session struct {
|
||||
Idle Duration `yaml:"idle,omitempty"`
|
||||
Absolute Duration `yaml:"absolute,omitempty"`
|
||||
}
|
||||
|
||||
// Backup is the nightly backup schedule and where its files go.
|
||||
type Backup struct {
|
||||
// At is the local time of day, "HH:MM" in the configured timezone; empty disables the schedule.
|
||||
At string `yaml:"at,omitempty"`
|
||||
// Dir receives the files; default <data-dir>/backups.
|
||||
Dir string `yaml:"dir,omitempty"`
|
||||
// Keep is how many scheduled backups to retain; older ones are deleted. Default 7.
|
||||
Keep int `yaml:"keep,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultBackupKeep is how many scheduled backups are kept when keep is not set.
|
||||
const DefaultBackupKeep = 7
|
||||
|
||||
// Disk overrides the disk guard's thresholds, as fractions of the filesystem.
|
||||
type Disk struct {
|
||||
// AlertUsed raises the disk alert (default 0.80).
|
||||
AlertUsed float64 `yaml:"alert_used,omitempty"`
|
||||
// AdmitUsed stops new series in the hot store (default 0.90).
|
||||
AdmitUsed float64 `yaml:"admit_used,omitempty"`
|
||||
// ReserveFree throttles ingest, pauses rollup and refuses backup below this free fraction (default 0.05).
|
||||
ReserveFree float64 `yaml:"reserve_free,omitempty"`
|
||||
}
|
||||
|
||||
var secretNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$`)
|
||||
|
||||
// Validate reports the first invalid setting.
|
||||
func (a Auth) Validate() error {
|
||||
if o := a.OIDC; o != nil {
|
||||
for name, v := range map[string]string{
|
||||
"issuer": o.Issuer, "client_id": o.ClientID, "client_secret_name": o.ClientSecretName, "redirect_url": o.RedirectURL,
|
||||
} {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return fmt.Errorf("auth.oidc.%s is required", name)
|
||||
}
|
||||
}
|
||||
if !secretNameRE.MatchString(o.ClientSecretName) {
|
||||
return errors.New("auth.oidc.client_secret_name: letters, digits and . _ : - only " +
|
||||
"(the secret itself is set with `nmon secret set` or PUT /api/v1/secrets/{name})")
|
||||
}
|
||||
}
|
||||
for _, p := range a.TrustedProxies {
|
||||
if _, err := netip.ParsePrefix(p); err != nil {
|
||||
if _, err := netip.ParseAddr(p); err != nil {
|
||||
return fmt.Errorf("auth.trusted_proxies: %q is not an address or CIDR", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.Session.Idle.D() < 0 || a.Session.Absolute.D() < 0 {
|
||||
return errors.New("auth.session durations must not be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate reports the first invalid setting.
|
||||
func (b Backup) Validate() error {
|
||||
if b.Keep < 0 {
|
||||
return errors.New("backup.keep must not be negative")
|
||||
}
|
||||
if b.At == "" {
|
||||
return nil
|
||||
}
|
||||
var h, m int
|
||||
if n, err := fmt.Sscanf(b.At, "%d:%d", &h, &m); err != nil || n != 2 || h < 0 || h > 23 || m < 0 || m > 59 || len(b.At) != 5 {
|
||||
return fmt.Errorf("backup.at %q: want HH:MM (24 hour)", b.At)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// KeepOrDefault is Keep, or DefaultBackupKeep when unset.
|
||||
func (b Backup) KeepOrDefault() int {
|
||||
if b.Keep <= 0 {
|
||||
return DefaultBackupKeep
|
||||
}
|
||||
return b.Keep
|
||||
}
|
||||
|
||||
// Validate reports the first invalid setting.
|
||||
func (d Disk) Validate() error {
|
||||
for name, v := range map[string]float64{"alert_used": d.AlertUsed, "admit_used": d.AdmitUsed, "reserve_free": d.ReserveFree} {
|
||||
if v < 0 || v >= 1 {
|
||||
return fmt.Errorf("disk.%s must be a fraction between 0 and 1", name)
|
||||
}
|
||||
}
|
||||
if d.AlertUsed != 0 && d.AdmitUsed != 0 && d.AlertUsed >= d.AdmitUsed {
|
||||
return errors.New("disk.alert_used must be below disk.admit_used")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultRetiredPurgeAfter is how long a device stays retired before an admin may purge it.
|
||||
const DefaultRetiredPurgeAfter = 365 * 24 * time.Hour
|
||||
59
internal/config/auth_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthBackupDiskValidation(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
yaml, want string
|
||||
}{
|
||||
"oidc without secret name": {"auth: {oidc: {issuer: https://i, client_id: c, redirect_url: https://n/auth/callback}}", "client_secret_name is required"},
|
||||
"inline-looking secret": {"auth: {oidc: {issuer: https://i, client_id: c, client_secret_name: 'a b', redirect_url: u}}", "client_secret_name"},
|
||||
"proxy that is not an addr": {"auth: {trusted_proxies: ['nope']}", "trusted_proxies"},
|
||||
"backup time": {"backup: {at: '9pm'}", "backup.at"},
|
||||
"backup keep negative": {"backup: {keep: -1}", "backup.keep"},
|
||||
"disk fraction": {"disk: {admit_used: 1.5}", "disk.admit_used"},
|
||||
"disk order": {"disk: {alert_used: 0.9, admit_used: 0.8}", "below"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeConfig(t, dir, tc.yaml)
|
||||
if _, err := Load(dir); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error %v, want one mentioning %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
dir := t.TempDir()
|
||||
writeConfig(t, dir, `
|
||||
auth:
|
||||
oidc: {issuer: https://idp, client_id: nmon, client_secret_name: oidc-client, redirect_url: https://nmon/auth/callback, admin_group: nmon-admins}
|
||||
break_glass_file: /etc/nmon/bg
|
||||
trusted_proxies: [10.0.0.0/8, "::1"]
|
||||
backup: {at: "02:30", dir: /var/backups/nmon, keep: 5}
|
||||
disk: {alert_used: 0.75}
|
||||
retired_purge_after: 400d
|
||||
`)
|
||||
c, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Auth.OIDC.AdminGroup != "nmon-admins" || c.Backup.KeepOrDefault() != 5 || c.PurgeAfter().Hours() != 400*24 {
|
||||
t.Fatalf("loaded %+v", c)
|
||||
}
|
||||
if got := (Config{}).PurgeAfter(); got != DefaultRetiredPurgeAfter {
|
||||
t.Fatalf("default purge period %v", got)
|
||||
}
|
||||
if got := (Backup{}).KeepOrDefault(); got != DefaultBackupKeep {
|
||||
t.Fatalf("default keep %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, dir, text string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(File(dir), []byte(text), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,14 @@ type Flags struct {
|
||||
SecretKeyFile string
|
||||
Listen string
|
||||
InsecureHTTP bool
|
||||
DBDriver string // sqlite (default), postgres, mysql
|
||||
DBDSN string // empty: <data-dir>/inventory.db for sqlite
|
||||
VMHotURL string // empty: run embedded child
|
||||
TLSCert string // PEM certificate chain served over HTTPS; reloaded on SIGHUP
|
||||
TLSKey string // its private key
|
||||
ACMEHosts []string // host names to obtain Let's Encrypt certificates for (http-01 on ACMEListen)
|
||||
ACMEEmail string // contact address for the ACME account
|
||||
ACMEListen string // address of the http-01 challenge listener; default ":80"
|
||||
DBDriver string // sqlite (default), postgres, mysql
|
||||
DBDSN string // empty: <data-dir>/inventory.db for sqlite
|
||||
VMHotURL string // empty: run embedded child
|
||||
VMLongURL string
|
||||
MaxInflight int
|
||||
TrapListen string // UDP address for SNMP traps; empty disables the receiver
|
||||
@@ -53,6 +58,14 @@ type Config struct {
|
||||
Proxmox proxmox.Config `yaml:"proxmox,omitempty"`
|
||||
// Checks tunes the service-check runner; the checks themselves live in the inventory (/api/v1/checks).
|
||||
Checks checks.Config `yaml:"checks,omitempty"`
|
||||
// Auth configures sign-in (OIDC, break-glass, trusted proxies, session lifetimes).
|
||||
Auth Auth `yaml:"auth,omitempty"`
|
||||
// Backup schedules the nightly backup.
|
||||
Backup Backup `yaml:"backup,omitempty"`
|
||||
// Disk overrides the disk guard's thresholds.
|
||||
Disk Disk `yaml:"disk,omitempty"`
|
||||
// RetiredPurgeAfter is how long a device must have been retired before an admin may purge it; default 1y.
|
||||
RetiredPurgeAfter Duration `yaml:"retired_purge_after,omitempty"`
|
||||
}
|
||||
|
||||
// Retention controls how long each store keeps data.
|
||||
@@ -120,9 +133,26 @@ func (c Config) Validate() error {
|
||||
if err := c.Proxmox.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Auth.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Backup.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Disk.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Alerting.Validate()
|
||||
}
|
||||
|
||||
// PurgeAfter is RetiredPurgeAfter with its default applied.
|
||||
func (c Config) PurgeAfter() time.Duration {
|
||||
if d := c.RetiredPurgeAfter.D(); d > 0 {
|
||||
return d
|
||||
}
|
||||
return DefaultRetiredPurgeAfter
|
||||
}
|
||||
|
||||
// Load reads config.yaml from dataDir over the defaults. A missing file yields
|
||||
// the defaults.
|
||||
func Load(dataDir string) (Config, error) {
|
||||
|
||||
@@ -71,6 +71,7 @@ type Guard struct {
|
||||
o Options
|
||||
clk clock.Clock
|
||||
|
||||
thr atomic.Pointer[thresholds]
|
||||
used atomic.Uint64 // math.Float64bits of the used fraction
|
||||
seen atomic.Bool // a sample has been taken
|
||||
|
||||
@@ -107,13 +108,35 @@ func New(o Options) (*Guard, error) {
|
||||
if o.ReserveFree <= 0 {
|
||||
o.ReserveFree = DefaultReserveFree
|
||||
}
|
||||
return &Guard{
|
||||
g := &Guard{
|
||||
o: o, clk: o.Clock, raised: map[string]bool{},
|
||||
usedG: o.Registry.Gauge("nmon_disk_used_ratio"),
|
||||
throttledG: o.Registry.Gauge("nmon_disk_throttled"),
|
||||
haltedG: o.Registry.Gauge("nmon_disk_new_series_halted"),
|
||||
statErr: o.Registry.Counter("nmon_disk_statfs_errors_total"),
|
||||
}, nil
|
||||
}
|
||||
g.SetThresholds(o.AlertUsed, o.AdmitUsed, o.ReserveFree)
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// thresholds are the three fractions the decisions compare against.
|
||||
type thresholds struct{ alert, admit, reserve float64 }
|
||||
|
||||
func (g *Guard) th() thresholds { return *g.thr.Load() }
|
||||
|
||||
// SetThresholds replaces the thresholds (config reload); zero keeps the default. They apply from the next sample.
|
||||
func (g *Guard) SetThresholds(alertUsed, admitUsed, reserveFree float64) {
|
||||
g.thr.Store(&thresholds{
|
||||
alert: orDefault(alertUsed, DefaultAlertUsed), admit: orDefault(admitUsed, DefaultAdmitUsed),
|
||||
reserve: orDefault(reserveFree, DefaultReserveFree),
|
||||
})
|
||||
}
|
||||
|
||||
func orDefault(v, def float64) float64 {
|
||||
if v <= 0 {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Check takes one sample, updates the decisions and raises alarms for any
|
||||
@@ -137,7 +160,7 @@ func (g *Guard) Check() error {
|
||||
g.throttledG.Set(b2f(g.Throttle()))
|
||||
g.haltedG.Set(b2f(g.RefuseNewSeries()))
|
||||
detail := fmt.Sprintf("%.1f%% of %s used", used*100, g.o.Path)
|
||||
g.edge(AlarmDiskHigh, used >= g.o.AlertUsed, detail)
|
||||
g.edge(AlarmDiskHigh, used >= g.th().alert, detail)
|
||||
g.edge(AlarmNewSeriesHalt, g.RefuseNewSeries(), detail+"; hot store refuses new series")
|
||||
g.edge(AlarmReserve, g.Throttle(), detail+"; ingest throttled, rollup paused, backup refused")
|
||||
return nil
|
||||
@@ -173,13 +196,13 @@ func (g *Guard) Run(ctx context.Context) {
|
||||
func (g *Guard) Used() float64 { return math.Float64frombits(g.used.Load()) }
|
||||
|
||||
// Throttle is true while free space is below the reserve: ingest must shed load.
|
||||
func (g *Guard) Throttle() bool { return g.seen.Load() && 1-g.Used() < g.o.ReserveFree }
|
||||
func (g *Guard) Throttle() bool { return g.seen.Load() && 1-g.Used() < g.th().reserve }
|
||||
|
||||
// PauseRollup is true while free space is below the reserve.
|
||||
func (g *Guard) PauseRollup() bool { return g.Throttle() }
|
||||
|
||||
// RefuseNewSeries is true at or above the admission threshold (90% used).
|
||||
func (g *Guard) RefuseNewSeries() bool { return g.seen.Load() && g.Used() >= g.o.AdmitUsed }
|
||||
func (g *Guard) RefuseNewSeries() bool { return g.seen.Load() && g.Used() >= g.th().admit }
|
||||
|
||||
// ReserveCheck returns ErrBelowReserve (wrapped with the numbers) below the
|
||||
// reserve. Backup calls it before it starts. It samples afresh so a stale
|
||||
@@ -189,7 +212,7 @@ func (g *Guard) ReserveCheck() error {
|
||||
return err
|
||||
}
|
||||
if g.Throttle() {
|
||||
return fmt.Errorf("%w: %.1f%% free, need %.0f%%", ErrBelowReserve, (1-g.Used())*100, g.o.ReserveFree*100)
|
||||
return fmt.Errorf("%w: %.1f%% free, need %.0f%%", ErrBelowReserve, (1-g.Used())*100, g.th().reserve*100)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -217,3 +217,24 @@ func TestAdmissionWarmAndBound(t *testing.T) {
|
||||
t.Fatalf("passed %d, want the two warmed series", len(out.got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetThresholdsChangesTheDecisionsFromTheNextSample(t *testing.T) {
|
||||
used := uint64(85)
|
||||
g, err := New(Options{Path: "/x", Statfs: func(string) (uint64, uint64, error) { return 100, 100 - used, nil }, Registry: selfmon.New()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = g.Check()
|
||||
if g.RefuseNewSeries() {
|
||||
t.Fatal("85% used is below the default 90% admission threshold")
|
||||
}
|
||||
g.SetThresholds(0.7, 0.8, 0)
|
||||
_ = g.Check()
|
||||
if !g.RefuseNewSeries() {
|
||||
t.Fatal("85% used is above the new 80% admission threshold")
|
||||
}
|
||||
g.SetThresholds(0, 0, 0)
|
||||
if g.RefuseNewSeries() {
|
||||
t.Fatal("zero thresholds must mean the defaults")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ type Result struct {
|
||||
DevicesCreated int
|
||||
CredentialsCreated int
|
||||
PortsUpdated int
|
||||
// DeviceIDs are the ids of the devices created, in CSV order.
|
||||
DeviceIDs []int64
|
||||
}
|
||||
|
||||
// Apply writes the plan in one transaction: new credentials, devices with
|
||||
@@ -58,6 +60,7 @@ func createDevices(ctx context.Context, tx store.Store, plans []DevicePlan, res
|
||||
}
|
||||
}
|
||||
ids[dp.Name] = d.ID
|
||||
res.DeviceIDs = append(res.DeviceIDs, d.ID)
|
||||
res.DevicesCreated++
|
||||
}
|
||||
return ids, nil
|
||||
@@ -112,9 +115,9 @@ func applyPorts(ctx context.Context, tx store.Store, ports []PortPlan, res *Resu
|
||||
ifs := byDev[pp.DeviceID]
|
||||
for i := range ifs {
|
||||
if ifs[i].ID == pp.IfaceID {
|
||||
ifs[i].IfAlias = pp.Alias
|
||||
ifs[i].Description = pp.Description
|
||||
if err := tx.UpsertInterfaces(ctx, pp.DeviceID, []store.Interface{ifs[i]}); err != nil {
|
||||
return fmt.Errorf("row %d: set alias on %s: %w", pp.Row, pp.IfName, err)
|
||||
return fmt.Errorf("row %d: set description on %s: %w", pp.Row, pp.IfName, err)
|
||||
}
|
||||
res.PortsUpdated++
|
||||
}
|
||||
|
||||
@@ -93,8 +93,8 @@ func TestImportHappyPathCreatesDevicesParentsCredentialsAndAliases(t *testing.T)
|
||||
t.Fatalf("ap1 parents %+v", aps)
|
||||
}
|
||||
ifs, _ := st.ListInterfaces(ctx, existing.ID, false)
|
||||
if ifs[0].IfAlias != "Customer ACME" {
|
||||
t.Fatalf("alias %q", ifs[0].IfAlias)
|
||||
if ifs[0].Description != "Customer ACME" {
|
||||
t.Fatalf("description %q", ifs[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,14 +61,14 @@ type DevicePlan struct {
|
||||
Parents []string
|
||||
}
|
||||
|
||||
// PortPlan sets the alias of one existing interface.
|
||||
// PortPlan sets the operator-owned description of one existing interface (CSV column "alias" or "description").
|
||||
type PortPlan struct {
|
||||
Row int
|
||||
DeviceID int64
|
||||
Device string // slug
|
||||
IfName string
|
||||
IfaceID string
|
||||
Alias string
|
||||
Row int
|
||||
DeviceID int64
|
||||
Device string // slug
|
||||
IfName string
|
||||
IfaceID string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Plan is a validated import. It holds no store handles.
|
||||
|
||||
@@ -63,5 +63,5 @@ func portRow(ctx context.Context, st store.Store, cols map[string]int, rec []str
|
||||
if !ok {
|
||||
return PortPlan{}, fmt.Sprintf("device %s has no interface %q", d.Slug, ifname), nil
|
||||
}
|
||||
return PortPlan{Row: row, DeviceID: d.ID, Device: d.Slug, IfName: ifname, IfaceID: id, Alias: field(cols, rec, "alias")}, "", nil
|
||||
return PortPlan{Row: row, DeviceID: d.ID, Device: d.Slug, IfName: ifname, IfaceID: id, Description: field(cols, rec, "alias")}, "", nil
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ const (
|
||||
reasonUnbound = "unbound_device"
|
||||
reasonParse = "parse"
|
||||
reasonInvalid = "invalid_name"
|
||||
reasonDisk = "disk_reserve"
|
||||
)
|
||||
|
||||
// Config wires an Ingester. Zero values take the defaults noted.
|
||||
@@ -50,6 +51,9 @@ type Config struct {
|
||||
Observer Observer // none
|
||||
Alarms Alarms // none (events are still recorded)
|
||||
Registry *selfmon.Registry // selfmon.Default
|
||||
// Throttle, when set and true, makes every push answer 429 with Retry-After and write nothing: the disk is
|
||||
// below its free-space reserve.
|
||||
Throttle func() bool // none
|
||||
|
||||
MaxBodyBytes int64 // 10 MiB, applied to the compressed and the decompressed body
|
||||
// RatePerSec and Burst bound accepted samples per token: 100_000/s, burst 2x the rate.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -422,3 +423,22 @@ func TestWriterFailureIs503AndNothingIsObserved(t *testing.T) {
|
||||
t.Fatal("observed a push that was not stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThrottleWhileTheDiskIsBelowItsReserveAnswers429AndWritesNothing(t *testing.T) {
|
||||
var throttled atomic.Bool
|
||||
r := newRig(t, func(c *Config) { c.Throttle = throttled.Load })
|
||||
secret, _ := r.mintFor("pve1")
|
||||
line := "m v=1 " + ns(t0) + "\n"
|
||||
wantStatus(t, r.post("/write", secret, line, nil), http.StatusNoContent)
|
||||
throttled.Store(true)
|
||||
resp := r.post("/write", secret, line, nil)
|
||||
wantStatus(t, resp, http.StatusTooManyRequests)
|
||||
if got := len(r.w.samples()); got != 1 {
|
||||
t.Fatalf("a throttled push wrote samples: %d in the store", got)
|
||||
}
|
||||
if r.dropped("disk_reserve") == 0 {
|
||||
t.Fatal("throttled samples were not counted")
|
||||
}
|
||||
throttled.Store(false)
|
||||
wantStatus(t, r.post("/write", secret, line, nil), http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -89,6 +89,9 @@ func (g *Ingester) reject(b *Binding, reason, kind, detail string) {
|
||||
g.alarms.raise(g.clk.Now(), kind, b.TokenID, b.Name, detail)
|
||||
}
|
||||
|
||||
// diskRetryAfter is what a throttled sender is told to wait while the disk is below its reserve.
|
||||
const diskRetryAfter = 30 * time.Second
|
||||
|
||||
// Ingest applies the rules to samples pushed over proto ("influx", "remote_write", "graphite") by the
|
||||
// holder of b, and writes what passes. It returns a *RateLimitedError, with nothing written, when the
|
||||
// token is over its rate; a writer error is returned wrapped after the counts are final.
|
||||
@@ -98,6 +101,11 @@ func (g *Ingester) Ingest(ctx context.Context, b *Binding, proto string, in []ra
|
||||
return res, nil
|
||||
}
|
||||
now := g.clk.Now()
|
||||
if g.cfg.Throttle != nil && g.cfg.Throttle() {
|
||||
g.drop(reasonDisk, len(in))
|
||||
res.Dropped[reasonDisk] = len(in)
|
||||
return res, &RateLimitedError{RetryAfter: diskRetryAfter}
|
||||
}
|
||||
if ok, wait := g.rate.take(b.TokenID, len(in), now); !ok {
|
||||
g.drop(reasonRateLimit, len(in))
|
||||
res.Dropped[reasonRateLimit] = len(in)
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/v10networks/nmon/internal/selfmon"
|
||||
)
|
||||
@@ -101,13 +102,18 @@ var ErrTooManyRedirects = errors.New("netpolicy: too many redirects")
|
||||
// MaxRedirects is how many redirects HTTPClient follows.
|
||||
const MaxRedirects = 2
|
||||
|
||||
// Policy is a compiled Config. It is immutable and safe for concurrent use.
|
||||
type Policy struct {
|
||||
// ruleset is the compiled allow-list; Reload swaps a whole one, so a check never sees half of a new list.
|
||||
type ruleset struct {
|
||||
prefixes []netip.Prefix
|
||||
hosts map[string]bool // exact, lower-case
|
||||
wild []string // ".example.com" suffixes
|
||||
deny bool
|
||||
strict bool
|
||||
}
|
||||
|
||||
// Policy is a compiled Config, safe for concurrent use. Reload replaces the allow-list in place.
|
||||
type Policy struct {
|
||||
rules atomic.Pointer[ruleset]
|
||||
|
||||
resolver Resolver
|
||||
dial DialFunc
|
||||
@@ -116,8 +122,35 @@ type Policy struct {
|
||||
|
||||
// New compiles cfg.
|
||||
func New(cfg Config, opts ...Option) (*Policy, error) {
|
||||
p := &Policy{hosts: map[string]bool{}, deny: cfg.DenyDefaults == nil || *cfg.DenyDefaults, strict: cfg.Strict,
|
||||
resolver: net.DefaultResolver, reg: selfmon.Default}
|
||||
rs, err := compileRules(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := &Policy{resolver: net.DefaultResolver, reg: selfmon.Default}
|
||||
p.rules.Store(rs)
|
||||
for _, o := range opts {
|
||||
o(p)
|
||||
}
|
||||
if p.dial == nil {
|
||||
p.dial = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, addr)
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Reload compiles cfg and, only if it is valid, makes it the allow-list for every later check.
|
||||
func (p *Policy) Reload(cfg Config) error {
|
||||
rs, err := compileRules(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.rules.Store(rs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func compileRules(cfg Config) (*ruleset, error) {
|
||||
p := &ruleset{hosts: map[string]bool{}, deny: cfg.DenyDefaults == nil || *cfg.DenyDefaults, strict: cfg.Strict}
|
||||
for _, e := range cfg.Allow {
|
||||
e = strings.TrimSpace(e)
|
||||
if e == "" {
|
||||
@@ -137,14 +170,6 @@ func New(cfg Config, opts ...Option) (*Policy, error) {
|
||||
}
|
||||
p.hosts[h] = true
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(p)
|
||||
}
|
||||
if p.dial == nil {
|
||||
p.dial = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, addr)
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@@ -213,10 +238,11 @@ func defaultDenial(ip netip.Addr) string {
|
||||
|
||||
func (p *Policy) hostListed(host string) bool {
|
||||
h := strings.ToLower(strings.TrimSuffix(host, "."))
|
||||
if p.hosts[h] {
|
||||
rs := p.rules.Load()
|
||||
if rs.hosts[h] {
|
||||
return true
|
||||
}
|
||||
for _, w := range p.wild {
|
||||
for _, w := range rs.wild {
|
||||
if strings.HasSuffix(h, w) && len(h) > len(w) {
|
||||
return true
|
||||
}
|
||||
@@ -226,7 +252,7 @@ func (p *Policy) hostListed(host string) bool {
|
||||
|
||||
func (p *Policy) ipListed(ip netip.Addr) bool {
|
||||
ip = ip.WithZone("").Unmap()
|
||||
for _, pf := range p.prefixes {
|
||||
for _, pf := range p.rules.Load().prefixes {
|
||||
if pf.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
@@ -240,10 +266,11 @@ func (p *Policy) checkIP(ip netip.Addr, hostListed bool) string {
|
||||
if hostListed || p.ipListed(ip) {
|
||||
return ""
|
||||
}
|
||||
if p.strict {
|
||||
rs := p.rules.Load()
|
||||
if rs.strict {
|
||||
return ReasonNotAllowed
|
||||
}
|
||||
if p.deny {
|
||||
if rs.deny {
|
||||
return defaultDenial(ip)
|
||||
}
|
||||
return ""
|
||||
@@ -278,7 +305,7 @@ func (p *Policy) Resolve(ctx context.Context, host string) ([]netip.Addr, error)
|
||||
}
|
||||
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||||
listed := p.hostListed(host)
|
||||
if p.strict && !listed && len(p.prefixes) == 0 {
|
||||
if rs := p.rules.Load(); rs.strict && !listed && len(rs.prefixes) == 0 {
|
||||
return nil, p.denied(host, ReasonNotAllowed)
|
||||
}
|
||||
ips, err := p.resolver.LookupNetIP(ctx, "ip", host)
|
||||
|
||||
@@ -266,3 +266,28 @@ func get(c *http.Client, url string) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func TestReloadSwapsTheAllowListAndAnInvalidOneChangesNothing(t *testing.T) {
|
||||
p, err := New(Config{Allow: []string{"10.1.0.0/16"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.Check("10.1.2.3"); err != nil {
|
||||
t.Fatalf("listed range denied: %v", err)
|
||||
}
|
||||
if err := p.Reload(Config{Allow: []string{"bad host name"}}); err == nil {
|
||||
t.Fatal("an invalid entry was accepted")
|
||||
}
|
||||
if err := p.Check("10.1.2.3"); err != nil {
|
||||
t.Fatalf("a failed reload changed the list: %v", err)
|
||||
}
|
||||
if err := p.Reload(Config{Allow: []string{"10.2.0.0/16"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.Check("10.1.2.3"); err == nil {
|
||||
t.Fatal("the old range is still allowed after the reload")
|
||||
}
|
||||
if err := p.Check("10.2.0.9"); err != nil {
|
||||
t.Fatalf("the new range is denied: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
81
internal/rollup/alarm_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package rollup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// recAlarms records what the job told alerting.
|
||||
type recAlarms struct {
|
||||
mu sync.Mutex
|
||||
raised []raisedAlarm
|
||||
clears []string
|
||||
}
|
||||
|
||||
type raisedAlarm struct {
|
||||
key, summary string
|
||||
since time.Time
|
||||
}
|
||||
|
||||
func (r *recAlarms) Raise(_ context.Context, key, summary string, since time.Time) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.raised = append(r.raised, raisedAlarm{key, summary, since})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recAlarms) Clear(_ context.Context, key string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.clears = append(r.clears, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRollupStalledRaisesOnceFailuresLastStallAfterAndClearsOnRecovery(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rec := &recAlarms{}
|
||||
r := newRig(t, t0.Add(24*time.Hour), func(o *Options) { o.Alarms = rec; o.StallAfter = 30 * time.Minute })
|
||||
boom := errors.New("long store down")
|
||||
|
||||
r.job.trackHealth(ctx, boom) // the first failure starts the clock
|
||||
r.clk.Advance(20 * time.Minute)
|
||||
r.job.trackHealth(ctx, boom)
|
||||
if len(rec.raised) != 0 {
|
||||
t.Fatalf("raised after only 20 minutes of failures (StallAfter is 30): %+v", rec.raised)
|
||||
}
|
||||
failedAt := r.clk.Now().Add(-20 * time.Minute)
|
||||
r.clk.Advance(15 * time.Minute)
|
||||
r.job.trackHealth(ctx, boom)
|
||||
if len(rec.raised) != 1 || rec.raised[0].key != AlarmKey || !rec.raised[0].since.Equal(failedAt) ||
|
||||
!strings.Contains(rec.raised[0].summary, "long store down") {
|
||||
t.Fatalf("after 35 minutes of failures: %+v", rec.raised)
|
||||
}
|
||||
if len(rec.clears) != 0 {
|
||||
t.Fatalf("cleared while still failing: %v", rec.clears)
|
||||
}
|
||||
|
||||
r.job.trackHealth(ctx, nil) // recovery
|
||||
r.job.trackHealth(ctx, nil) // a second success has nothing to clear
|
||||
if len(rec.clears) != 1 || rec.clears[0] != AlarmKey {
|
||||
t.Fatalf("recovery must clear the alarm exactly once: %v", rec.clears)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollupStalledIgnoresShutdownCancellationAndAShortHiccup(t *testing.T) {
|
||||
rec := &recAlarms{}
|
||||
r := newRig(t, t0.Add(24*time.Hour), func(o *Options) { o.Alarms = rec; o.StallAfter = 30 * time.Minute })
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
r.clk.Advance(time.Hour)
|
||||
r.job.trackHealth(cancelled, errors.New("context canceled")) // shutdown is not a stall
|
||||
r.job.trackHealth(context.Background(), errors.New("blip"))
|
||||
r.clk.Advance(time.Minute)
|
||||
r.job.trackHealth(context.Background(), nil) // a blip that heals before StallAfter never alerts (the Clear is a no-op)
|
||||
if len(rec.raised) != 0 {
|
||||
t.Fatalf("raised %+v", rec.raised)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,9 @@ type Options struct {
|
||||
// of type gauge that are not interface metrics. Names failing ValidGauge are
|
||||
// ignored. Nil rolls up no gauges.
|
||||
Gauges func() []string
|
||||
// Paused, when set and true, makes the job skip a pass (the disk is below its reserve, so no new
|
||||
// rollup data may be written). The skipped windows stay unrecorded and are caught up later.
|
||||
Paused func() bool
|
||||
// Alarms, when set, hears that the rollup has been failing for StallAfter and that it recovered.
|
||||
Alarms Alarms
|
||||
|
||||
@@ -147,13 +150,10 @@ func (j *Job) Run(ctx context.Context) {
|
||||
t := j.clk.NewTicker(FiveMinutes)
|
||||
defer t.Stop()
|
||||
for {
|
||||
err := j.RunOnce(ctx)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
j.log.Warn("rollup pass failed", "err", err)
|
||||
}
|
||||
j.trackHealth(ctx, err)
|
||||
if err := j.PruneIfDue(ctx); err != nil && ctx.Err() == nil {
|
||||
j.log.Warn("five-minute retention failed", "err", err)
|
||||
if j.o.Paused != nil && j.o.Paused() {
|
||||
j.log.Warn("rollup paused: disk is below its free-space reserve")
|
||||
} else {
|
||||
j.pass(ctx)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -163,6 +163,18 @@ func (j *Job) Run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// pass is one scheduled run: the rollup itself, its health tracking and the five-minute retention job.
|
||||
func (j *Job) pass(ctx context.Context) {
|
||||
err := j.RunOnce(ctx)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
j.log.Warn("rollup pass failed", "err", err)
|
||||
}
|
||||
j.trackHealth(ctx, err)
|
||||
if err := j.PruneIfDue(ctx); err != nil && ctx.Err() == nil {
|
||||
j.log.Warn("five-minute retention failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RunOnce does one pass: re-do the last Redo of five-minute windows, catch up
|
||||
// on windows with no record (oldest first, at most MaxWindowsPerRun), then the
|
||||
// hourly windows whose inputs are final or changed.
|
||||
|
||||
20
internal/store/20261023000008_interface_description.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// Migration 8 adds interfaces.description: the operator-owned port description (the CSV import writes it).
|
||||
// Discovery rewrites ifalias from the device on every run, so a description an operator typed there would be
|
||||
// lost; this column is never written by discovery.
|
||||
func init() {
|
||||
Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
|
||||
_, err := db.NewAddColumn().Table("interfaces").ColumnExpr("description TEXT NOT NULL DEFAULT ''").Exec(ctx)
|
||||
return err
|
||||
}, func(ctx context.Context, db *bun.DB) error {
|
||||
_, err := db.NewDropColumn().Table("interfaces").Column("description").Exec(ctx)
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -116,6 +116,7 @@ func (s *bunStore) SearchInterfaces(ctx context.Context, qs string, includeRetir
|
||||
return q.Where("LOWER(i.ifname) LIKE ? ESCAPE '!'", pat).
|
||||
WhereOr("LOWER(i.ifdescr) LIKE ? ESCAPE '!'", pat).
|
||||
WhereOr("LOWER(i.ifalias) LIKE ? ESCAPE '!'", pat).
|
||||
WhereOr("LOWER(i.description) LIKE ? ESCAPE '!'", pat).
|
||||
WhereOr("i.id LIKE ? ESCAPE '!'", pat).
|
||||
WhereOr("LOWER(d.name) LIKE ? ESCAPE '!'", pat).
|
||||
WhereOr("LOWER(d.slug) LIKE ? ESCAPE '!'", pat)
|
||||
|
||||
@@ -98,12 +98,14 @@ type DeviceParent struct {
|
||||
type Interface struct {
|
||||
bun.BaseModel `bun:"table:interfaces,alias:i"`
|
||||
|
||||
ID string `bun:"id,pk"`
|
||||
DeviceID int64 `bun:"device_id,notnull"`
|
||||
IfIndex int64 `bun:"ifindex,notnull"`
|
||||
IfName string `bun:"ifname,notnull"`
|
||||
IfDescr string `bun:"ifdescr,notnull"`
|
||||
IfAlias string `bun:"ifalias,notnull"`
|
||||
ID string `bun:"id,pk"`
|
||||
DeviceID int64 `bun:"device_id,notnull"`
|
||||
IfIndex int64 `bun:"ifindex,notnull"`
|
||||
IfName string `bun:"ifname,notnull"`
|
||||
IfDescr string `bun:"ifdescr,notnull"`
|
||||
IfAlias string `bun:"ifalias,notnull"`
|
||||
// Description is operator-owned (CSV import, API); discovery never writes it.
|
||||
Description string `bun:"description,notnull"`
|
||||
IfPhysAddr string `bun:"ifphysaddr,notnull"`
|
||||
IfSpeed int64 `bun:"ifspeed,notnull"`
|
||||
IfHighSpeed int64 `bun:"ifhighspeed,notnull"`
|
||||
|
||||
@@ -28,7 +28,7 @@ type GroupRef struct {
|
||||
|
||||
// Views are the read-only queries the API's list and detail pages need.
|
||||
type Views interface {
|
||||
// SearchInterfaceHits matches q against ifname and ifalias, case-insensitively,
|
||||
// SearchInterfaceHits matches q against ifname, ifalias and description, case-insensitively,
|
||||
// over active interfaces of unretired devices. An empty q matches everything.
|
||||
SearchInterfaceHits(ctx context.Context, q string, p Page) ([]InterfaceHit, int64, error)
|
||||
// InterfaceCounts tallies active interfaces per device ID in one query.
|
||||
@@ -49,7 +49,8 @@ func (s *bunStore) SearchInterfaceHits(ctx context.Context, qs string, p Page) (
|
||||
if qs != "" {
|
||||
pat := likePattern(qs)
|
||||
q = q.WhereGroup(" AND ", func(q *selectQ) *selectQ {
|
||||
return q.Where("LOWER(i.ifname) LIKE ? ESCAPE '!'", pat).WhereOr("LOWER(i.ifalias) LIKE ? ESCAPE '!'", pat)
|
||||
return q.Where("LOWER(i.ifname) LIKE ? ESCAPE '!'", pat).WhereOr("LOWER(i.ifalias) LIKE ? ESCAPE '!'", pat).
|
||||
WhereOr("LOWER(i.description) LIKE ? ESCAPE '!'", pat)
|
||||
})
|
||||
}
|
||||
total, err := q.Count(ctx)
|
||||
|
||||
35
internal/vmsup/delete.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package vmsup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DeleteSeries removes every series matching any of matchers from the named store with VictoriaMetrics'
|
||||
// delete_series. Whole series only: the API takes no time range. It talks to the store's localhost URL.
|
||||
func DeleteSeries(ctx context.Context, hc *http.Client, t Target, store string, matchers []string) error {
|
||||
if !t.Healthy(store) {
|
||||
return fmt.Errorf("%w: %s", ErrStoreDown, store)
|
||||
}
|
||||
form := url.Values{"match[]": matchers}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.URL(store)+"/api/v1/admin/tsdb/delete_series",
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete series in %s: %w", store, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("delete series in %s: %s: %s", store, resp.Status, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -289,20 +289,30 @@ func (s *Supervisor) Status() []ChildStatus {
|
||||
return out
|
||||
}
|
||||
|
||||
// Restart errors.
|
||||
var (
|
||||
// ErrUnknownStore means the name is neither hot nor long.
|
||||
ErrUnknownStore = errors.New("vmsup: unknown store")
|
||||
// ErrExternalStore means the store is an external instance nmon does not manage.
|
||||
ErrExternalStore = errors.New("vmsup: store is external and not managed by nmon")
|
||||
// ErrNotFailed means the store has not given up, so there is nothing to resume.
|
||||
ErrNotFailed = errors.New("vmsup: store has not failed")
|
||||
)
|
||||
|
||||
// Restart resumes a store after store_failed: the restart history is cleared
|
||||
// and the child is started again. It is an error for a store that has not failed.
|
||||
func (s *Supervisor) Restart(name string) error {
|
||||
st := s.store(name)
|
||||
if st == nil {
|
||||
return fmt.Errorf("vmsup: unknown store %q", name)
|
||||
return fmt.Errorf("%w: %q", ErrUnknownStore, name)
|
||||
}
|
||||
if st.external {
|
||||
return fmt.Errorf("vmsup: store %q is external and not managed by nmon", name)
|
||||
return fmt.Errorf("%w: store %q", ErrExternalStore, name)
|
||||
}
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
if !st.failed {
|
||||
return fmt.Errorf("vmsup: store %q has not failed", name)
|
||||
return fmt.Errorf("%w: store %q", ErrNotFailed, name)
|
||||
}
|
||||
st.failed = false
|
||||
st.resume <- struct{}{} // buffered 1; only one Restart can pass the check above
|
||||
|
||||
@@ -44,7 +44,7 @@ func mustSub(f fs.FS, dir string) fs.FS {
|
||||
}
|
||||
|
||||
// reserved are the path prefixes that belong to the API handler, never the SPA.
|
||||
var reserved = []string{"/api/", "/docs", "/openapi", "/metrics", "/schemas"}
|
||||
var reserved = []string{"/api/", "/docs", "/openapi", "/metrics", "/schemas", "/auth/", "/healthz"}
|
||||
|
||||
const (
|
||||
indexName = "index.html"
|
||||
|
||||
851
openapi.json
470
scripts/gate-m6.sh
Executable file
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env bash
|
||||
# Gate M6: a viewer gets 403 on PUT (the 2,000-device loadtest is skipped: docs/DEVIATIONS.md row 17).
|
||||
# Procedure: docs/milestones/M6-auth-and-ops.md.
|
||||
# 0. make lint; unit tests (race) and the integration tests of the packages this milestone adds.
|
||||
# 1. make build + make devtools. nmon serve refuses to start with no TLS and no --insecure-http.
|
||||
# 2. A mock OIDC issuer (nmon-devtools oidc-mock), a break-glass password file, two simulated switches and
|
||||
# nmon serve --insecure-http with auth.oidc + auth.break_glass_file configured (so authentication is ON).
|
||||
# 3. (1) No login page: an unauthenticated page load is a 302 into the OIDC flow, API calls stay 401, break-glass is
|
||||
# HTTP Basic on GET /auth/breakglass. (1) viewer 403: an OIDC viewer gets 403 on PUT /api/v1/devices/{slug} (with a valid CSRF token); an
|
||||
# admin-group user gets 200; a viewer API token gets 403; a push token gets 403 on GET /api/v1/devices; a revoked
|
||||
# token gets 401; a cookie mutation without the CSRF token gets 403; X-Forwarded-For from a non-proxy is ignored.
|
||||
# (2) the viewer-fuzz Go test over every state-changing route. (3) break-glass: 5 wrong passwords lock the
|
||||
# address, `nmon unlock all` lets it in again, audit rows and alerts exist. (4) `nmon backup` during polling
|
||||
# (prints whether the key is included), stop, `nmon restore` into a clean dir (verified), start on it: devices,
|
||||
# tokens and a sample series are there. (5) `nmon key rotate` on a copy: nmon starts over HTTPS with its own
|
||||
# certificate and still polls and signs in (credentials and secrets decrypt). (6) settings PUT invalid -> 422 and
|
||||
# unchanged, valid -> applied, SIGHUP reloads the file and survives a broken one. (7) CSV import: a cycle -> 422 with
|
||||
# the path and nothing written; a valid import creates devices and port descriptions that survive rediscovery.
|
||||
# (8) openapi-check against m3, the web client built from the live spec, mock UI specs, live screenshots.
|
||||
# Needs: go, make, node 22+, npm, Playwright's chromium (cd web && npx playwright install chromium), curl, ss, awk,
|
||||
# python3, openssl.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# Maintainability is a hard rule (docs/MAINTAINABILITY.md): no gate passes with lint findings.
|
||||
make lint || { echo "FAIL: make lint"; exit 1; }
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
FAILS=0
|
||||
NMON_PID="" AGENTS_PID="" OIDC_PID=""
|
||||
SHOTS="$ROOT/${NMON_SHOT_DIR:-docs/screenshots/M6}"
|
||||
TOPO="$ROOT/testdata/topologies/m5.yaml"
|
||||
DEVTOOLS="$ROOT/nmon-devtools"
|
||||
MAP="$TMP/slugs.map"
|
||||
CTL="$TMP/ctl"
|
||||
BG_PASSWORD="gate-break-glass-pw"
|
||||
OIDC_SECRET="gate-oidc-client-secret"
|
||||
|
||||
pass() { echo "PASS: $*"; }
|
||||
fail() { echo "FAIL: $*"; FAILS=$((FAILS + 1)); }
|
||||
die() { fail "$*"; exit 1; }
|
||||
|
||||
cleanup() {
|
||||
for p in "$NMON_PID" "$AGENTS_PID" "$OIDC_PID"; do
|
||||
[ -n "$p" ] && kill -TERM "$p" 2>/dev/null && wait "$p" 2>/dev/null
|
||||
done
|
||||
rm -rf "$TMP"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT TERM
|
||||
|
||||
pick_port() { # a port free for both TCP and UDP
|
||||
local p
|
||||
while :; do
|
||||
p=$((20000 + RANDOM % 30000))
|
||||
ss -H -lntu 2>/dev/null | awk '{print $5}' | grep -q "[:.]$p\$" || { echo "$p"; return; }
|
||||
done
|
||||
}
|
||||
|
||||
pick_udp_range() { # $1 consecutive UDP ports that are all free
|
||||
local base n="$1" i busy
|
||||
while :; do
|
||||
base=$((20000 + RANDOM % 25000)); busy=0
|
||||
for i in $(seq 0 $((n - 1))); do
|
||||
ss -H -lnu 2>/dev/null | awk '{print $5}' | grep -q "[:.]$((base + i))\$" && { busy=1; break; }
|
||||
done
|
||||
[ "$busy" -eq 0 ] && { echo "$base"; return; }
|
||||
done
|
||||
}
|
||||
|
||||
wait_for() { # wait_for <seconds> <description> <command...>
|
||||
local n="$1" what="$2"; shift 2
|
||||
for _ in $(seq 1 "$((n * 2))"); do "$@" && return 0; sleep 0.5; done
|
||||
echo "timed out after ${n}s waiting for $what"; return 1
|
||||
}
|
||||
|
||||
# jq-less JSON access: py <python expression over d> reads JSON on stdin.
|
||||
py() { python3 -I -c 'import json,sys; d=json.load(sys.stdin); print(eval(sys.argv[1]))' "$1"; }
|
||||
code() { curl -s -o "$TMP/last.body" -w '%{http_code}' "$@"; } # prints the status; the body is in $TMP/last.body
|
||||
vm_value() { # vm_value <base url> <promql>: first sample value or empty
|
||||
curl -fsS -G "$1/api/v1/query" --data-urlencode "query=$2" | py "d['data']['result'][0]['value'][1] if d['data']['result'] else ''"
|
||||
}
|
||||
|
||||
# Sign-in helpers. A "jar" is a curl cookie file.
|
||||
ctl_user() { curl -fsS -G "$CTL_URL/user" --data-urlencode "sub=$1" --data-urlencode "name=$2" --data-urlencode "groups=${3:-}" -o /dev/null; }
|
||||
sso_login() { # sso_login <jar> <sub> <name> [groups]: the whole OIDC code flow, redirects followed
|
||||
rm -f "$1"; ctl_user "$2" "$3" "${4:-}"
|
||||
curl -fsS -L -c "$1" -b "$1" -o /dev/null "$URL/auth/login?return=/"
|
||||
}
|
||||
bg_login() { # bg_login <jar> [password] [extra curl args...]: HTTP Basic against /auth/breakglass; success is a 302 to /
|
||||
local jar="$1" pw="${2:-$BG_PASSWORD}"
|
||||
shift; [ $# -gt 0 ] && shift
|
||||
code -c "$jar" -b "$jar" -u "breakglass:$pw" "$URL/auth/breakglass" "$@"
|
||||
}
|
||||
csrf_of() { curl -fsS -b "$1" "$URL/api/v1/me" | py "d['csrf']"; }
|
||||
as() { # as <jar> <method> <path> [json body]: a cookie request WITH its CSRF token; prints the status
|
||||
local jar="$1" m="$2" p="$3" body="${4:-}"
|
||||
code -b "$jar" -H "X-CSRF-Token: $(csrf_of "$jar")" -X "$m" "$URL$p" ${body:+-H 'Content-Type: application/json' -d "$body"}
|
||||
}
|
||||
tok() { # tok <token> <method> <path> [json body]
|
||||
local t="$1" m="$2" p="$3" body="${4:-}"
|
||||
code -H "Authorization: Bearer $t" -X "$m" "$URL$p" ${body:+-H 'Content-Type: application/json' -d "$body"}
|
||||
}
|
||||
api() { curl -fsS -H "Authorization: Bearer $ADMIN_TOKEN" "$URL$1"; }
|
||||
slug_by_name() { api "/api/v1/devices?per_page=1000" | py "next((i['slug'] for i in d['items'] if i['name']=='$1'), '')" 2>/dev/null; }
|
||||
|
||||
start_nmon() { # start_nmon <data dir> <key file> <port> <log> [extra serve flags...]
|
||||
local data="$1" key="$2" port="$3" log="$4"; shift 4
|
||||
./nmon serve --data-dir "$data" --secret-key-file "$key" --listen "127.0.0.1:$port" \
|
||||
--poll-interval 5s --icmp off --trap-listen '' --graphite-listen '' "$@" >"$log" 2>&1 &
|
||||
NMON_PID=$!
|
||||
}
|
||||
stop_nmon() {
|
||||
[ -n "$NMON_PID" ] && kill -TERM "$NMON_PID" 2>/dev/null && wait "$NMON_PID" 2>/dev/null
|
||||
NMON_PID=""
|
||||
}
|
||||
healthy() { curl -fsS "$URL/healthz" >/dev/null 2>&1; }
|
||||
|
||||
# 0. Unit and integration tests of what this milestone adds.
|
||||
echo "== unit tests"
|
||||
if go test -race -count=1 ./internal/auth/... ./internal/api/... ./internal/app/... ./internal/backup/... ./internal/keyrotate/... \
|
||||
./internal/diskguard/... ./internal/importcsv/... ./internal/config/... ./internal/store/... ./internal/rollup/... \
|
||||
./internal/ingest/... ./internal/netpolicy/... ./internal/vmsup/... ./internal/alert/... ./cmd/... >"$TMP/unit.log" 2>&1; then
|
||||
pass "go test -race: auth, api, app, backup, keyrotate, diskguard, importcsv, config, store, rollup, ingest, netpolicy, vmsup, alert, cmd"
|
||||
else
|
||||
tail -n 30 "$TMP/unit.log"; fail "go test -race"
|
||||
fi
|
||||
if go test -count=1 -tags 'integration vmembed' ./internal/backup/... >"$TMP/integ.log" 2>&1; then
|
||||
pass "backup/restore integration tests against a real VictoriaMetrics"
|
||||
else
|
||||
tail -n 30 "$TMP/integ.log"; fail "backup integration tests"
|
||||
fi
|
||||
|
||||
# 1. Binaries and the start-up refusals.
|
||||
echo "== make build"
|
||||
make build >"$TMP/build.log" 2>&1 || { tail -n 30 "$TMP/build.log"; die "make build"; }
|
||||
make devtools >"$TMP/devtools.log" 2>&1 || { tail -n 30 "$TMP/devtools.log"; die "make devtools"; }
|
||||
pass "nmon and nmon-devtools built"
|
||||
|
||||
echo "== start-up refusals"
|
||||
if ./nmon serve --data-dir "$TMP/refuse1" --secret-key-file "$TMP/refuse1.key" >"$TMP/refuse1.log" 2>&1; then
|
||||
fail "serve with no TLS and no --insecure-http started"
|
||||
else
|
||||
grep -q -- '--tls-cert' "$TMP/refuse1.log" && grep -q -- '--insecure-http' "$TMP/refuse1.log" &&
|
||||
pass "serve without TLS or --insecure-http refuses, and says how to fix it" || { cat "$TMP/refuse1.log"; fail "refusal message"; }
|
||||
fi
|
||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 2 -subj "/CN=nmon-gate" \
|
||||
-addext "subjectAltName=IP:127.0.0.1" -keyout "$TMP/tls.key" -out "$TMP/tls.crt" >/dev/null 2>&1 || die "openssl"
|
||||
if ./nmon serve --data-dir "$TMP/refuse2" --secret-key-file "$TMP/refuse2.key" --tls-cert "$TMP/tls.crt" --tls-key "$TMP/tls.key" \
|
||||
>"$TMP/refuse2.log" 2>&1; then
|
||||
fail "serve over TLS with no sign-in method started"
|
||||
else
|
||||
grep -q 'no way to sign in' "$TMP/refuse2.log" && pass "serve over TLS with no auth configured refuses to start" || { cat "$TMP/refuse2.log"; fail "auth refusal message"; }
|
||||
fi
|
||||
[ ! -e "$TMP/refuse1.key" ] && [ ! -e "$TMP/refuse2.key" ] && pass "a refused start creates no key file" || fail "a refused start created a key file"
|
||||
|
||||
# 2. Mock IDP, break-glass file, simulated switches, nmon with authentication on.
|
||||
echo "== mock oidc + agents + nmon serve"
|
||||
mkdir -p "$CTL"
|
||||
"$DEVTOOLS" oidc-mock --client-id nmon --client-secret "$OIDC_SECRET" --issuer-file "$TMP/issuer" --port-file "$TMP/oidc.port" >"$TMP/oidc.log" 2>&1 &
|
||||
OIDC_PID=$!
|
||||
wait_for 20 "the mock issuer" test -s "$TMP/issuer" -a -s "$TMP/oidc.port" || { cat "$TMP/oidc.log"; die "oidc-mock did not start"; }
|
||||
ISSUER="$(cat "$TMP/issuer")"; CTL_URL="http://[::1]:$(cat "$TMP/oidc.port")"
|
||||
"$DEVTOOLS" bcrypt "$BG_PASSWORD" >"$TMP/breakglass.hash" || die "bcrypt"
|
||||
NDEV="$(grep -c '^ - {slug:' "$TOPO")"
|
||||
BASE="$(pick_udp_range "$NDEV")"
|
||||
"$DEVTOOLS" topology-agents --topology "$TOPO" --port-base "$BASE" --control-dir "$CTL" >"$TMP/agents.log" 2>&1 &
|
||||
AGENTS_PID=$!
|
||||
wait_for 30 "the simulated agents" grep -q 'agents ready' "$TMP/agents.log" || { cat "$TMP/agents.log"; die "agents did not start"; }
|
||||
|
||||
PORT="$(pick_port)"
|
||||
URL="http://127.0.0.1:$PORT"
|
||||
DATA="$TMP/data"; KEY="$TMP/secret.key"
|
||||
mkdir -p "$DATA"
|
||||
cat >"$DATA/config.yaml" <<YAML
|
||||
alerting:
|
||||
outbound_allow: ["::1"]
|
||||
auth:
|
||||
oidc:
|
||||
issuer: $ISSUER
|
||||
client_id: nmon
|
||||
client_secret_name: oidc-client
|
||||
redirect_url: $URL/auth/callback
|
||||
groups_claim: groups
|
||||
admin_group: nmon-admins
|
||||
break_glass_file: $TMP/breakglass.hash
|
||||
YAML
|
||||
printf '%s\n' "$OIDC_SECRET" | ./nmon secret set oidc-client --data-dir "$DATA" --secret-key-file "$KEY" >"$TMP/secretset.log" 2>&1 &&
|
||||
pass "nmon secret set stores the OIDC client secret before the first start ($(tail -n 1 "$TMP/secretset.log"))" || { cat "$TMP/secretset.log"; die "nmon secret set"; }
|
||||
start_nmon "$DATA" "$KEY" "$PORT" "$TMP/nmon.log" --insecure-http
|
||||
wait_for 90 "nmon to serve" healthy || { tail -n 20 "$TMP/nmon.log"; die "nmon serve did not start"; }
|
||||
HOT="$(grep -o 'hot=http://[^ ]*' "$TMP/nmon.log" | head -n 1 | cut -d= -f2)"
|
||||
[ -n "$HOT" ] || die "cannot read the hot store URL from the log"
|
||||
grep -q 'authentication on.*oidc=true.*break_glass=true' "$TMP/nmon.log" && pass "nmon is up with OIDC and break-glass configured (authentication is on)" ||
|
||||
{ grep -i 'auth' "$TMP/nmon.log"; fail "auth did not start with OIDC"; }
|
||||
! grep -q 'AUTHENTICATION IS DISABLED' "$TMP/nmon.log" && pass "no dev-open warning when sign-in is configured" || fail "dev-open warning printed"
|
||||
|
||||
# Bootstrap: break-glass admin session, an admin API token for the tooling.
|
||||
ADMIN_JAR="$TMP/admin.jar"
|
||||
[ "$(bg_login "$ADMIN_JAR" "$BG_PASSWORD" -D "$TMP/bg.headers")" = 302 ] && pass "break-glass login (HTTP Basic on GET /auth/breakglass) creates an admin session and redirects" || { cat "$TMP/last.body"; die "break-glass login"; }
|
||||
grep -qi '^set-cookie: nmon_session=.*HttpOnly' "$TMP/bg.headers" && grep -qi 'SameSite=Lax' "$TMP/bg.headers" &&
|
||||
pass "the session cookie is HttpOnly and SameSite=Lax (not Secure only because of --insecure-http)" || { cat "$TMP/bg.headers"; fail "session cookie attributes"; }
|
||||
as "$ADMIN_JAR" POST /api/v1/tokens '{"name":"gate-admin","scope":"admin"}' >/dev/null
|
||||
ADMIN_TOKEN="$(py "d['secret']" <"$TMP/last.body")"; ADMIN_TOKEN_ID="$(py "d['id']" <"$TMP/last.body")"
|
||||
case "$ADMIN_TOKEN" in nma_*) pass "minted an admin API token ($ADMIN_TOKEN_ID)";; *) fail "admin token: $ADMIN_TOKEN"; die "cannot continue without a token";; esac
|
||||
export NMON_TOKEN="$ADMIN_TOKEN"
|
||||
"$DEVTOOLS" topology-add --api "$URL" --topology "$TOPO" --port-base "$BASE" --map-out "$MAP" >"$TMP/add.log" 2>&1 || { cat "$TMP/add.log"; die "topology-add"; }
|
||||
pass "devices added through the API with the admin token: $(cat "$TMP/add.log")"
|
||||
SW1="$(awk '$2=="sw-1" {print $1}' "$MAP")"; SW2="$(awk '$2=="sw-2" {print $1}' "$MAP")"
|
||||
have_ifaces() { [ "$(api "/api/v1/devices/$SW1" | py "len(d['interfaces'])")" -ge 1 ] && [ "$(api "/api/v1/devices/$SW2" | py "len(d['interfaces'])")" -ge 1 ]; }
|
||||
wait_for 90 "interfaces on both switches" have_ifaces || die "switches have no interfaces"
|
||||
IFNAME="$(api "/api/v1/devices/$SW1" | py "d['interfaces'][0]['ifname']")"
|
||||
IFALIAS0="$(api "/api/v1/devices/$SW1" | py "d['interfaces'][0]['ifalias']")"
|
||||
|
||||
# 3(1). Roles and tokens.
|
||||
echo "== (1) a viewer gets 403 on PUT"
|
||||
[ "$(code "$URL/api/v1/devices")" = 401 ] && pass "no credentials: GET /api/v1/devices is 401" || fail "anonymous GET: $(cat "$TMP/last.body")"
|
||||
[ "$(code "$URL/healthz")" = 200 ] && [ "$(code "$URL/auth/signedout")" = 200 ] && pass "/healthz and /auth/signedout need no credentials" || fail "public routes"
|
||||
for pth in / /devices/x /assets/index.js /docs; do
|
||||
c="$(code -D "$TMP/redir.headers" "$URL$pth")"
|
||||
loc="$(tr -d '\r' <"$TMP/redir.headers" | awk 'tolower($1)=="location:" {print $2}')"
|
||||
[ "$c" = 302 ] && case "$loc" in /auth/login?return=*) true;; *) false;; esac &&
|
||||
pass "an unauthenticated page load of $pth is a 302 to $loc (no login page exists)" || fail "GET $pth without credentials: $c $loc"
|
||||
done
|
||||
[ "$(code -D "$TMP/redir.headers" "$URL/auth/breakglass")" = 401 ] && grep -qi '^www-authenticate: Basic realm="nmon break-glass"' "$TMP/redir.headers" &&
|
||||
pass "GET /auth/breakglass without credentials is a Basic challenge (the browser's own prompt)" || fail "break-glass challenge"
|
||||
for pth in /api/v1/me /metrics /openapi.json; do [ "$(code "$URL$pth")" = 401 ] || fail "GET $pth without credentials was not 401"; done
|
||||
pass "API paths without credentials stay 401 problem+json (no redirect)"
|
||||
EVIL="$(curl -s -L -c "$TMP/evil.jar" -b "$TMP/evil.jar" -o /dev/null -w '%{url_effective}' "$URL/auth/login?return=//evil.example/x")"
|
||||
[ "$EVIL" = "$URL/" ] && pass "an off-site return target is rejected: the flow comes back to $EVIL" || fail "open redirect: ended at $EVIL"
|
||||
VIEWER_JAR="$TMP/viewer.jar"; ADMINU_JAR="$TMP/adminuser.jar"
|
||||
sso_login "$VIEWER_JAR" viewer-1 "Vera Viewer" "staff" && sso_login "$ADMINU_JAR" admin-1 "Adam Admin" "staff,nmon-admins" || die "OIDC sign-in"
|
||||
[ "$(curl -fsS -b "$VIEWER_JAR" "$URL/api/v1/me" | py "d['role']")" = viewer ] && pass "an OIDC user outside the admin group is a viewer" || fail "viewer role"
|
||||
[ "$(curl -fsS -b "$ADMINU_JAR" "$URL/api/v1/me" | py "d['role']")" = admin ] && pass "an OIDC user in the admin group is an admin" || fail "admin role"
|
||||
[ "$(curl -s -o /dev/null -w '%{http_code}' -b "$VIEWER_JAR" "$URL/api/v1/devices")" = 200 ] && pass "the viewer can read" || fail "viewer read"
|
||||
PUTBODY='{"site":"gate-site"}'
|
||||
[ "$(as "$VIEWER_JAR" PUT "/api/v1/devices/$SW1" "$PUTBODY")" = 403 ] && pass "viewer session: PUT /api/v1/devices/$SW1 with a valid CSRF token is 403" || { cat "$TMP/last.body"; fail "viewer PUT was not 403"; }
|
||||
[ "$(as "$ADMINU_JAR" PUT "/api/v1/devices/$SW1" "$PUTBODY")" = 200 ] && pass "admin-group user: the same PUT is 200" || { cat "$TMP/last.body"; fail "admin PUT"; }
|
||||
[ "$(api "/api/v1/devices/$SW1" | py "d['site']")" = gate-site ] && pass "the admin's change is stored" || fail "site not changed"
|
||||
[ "$(code -b "$ADMINU_JAR" -X PUT "$URL/api/v1/devices/$SW1" -H 'Content-Type: application/json' -d "$PUTBODY")" = 403 ] &&
|
||||
pass "a cookie mutation without the CSRF token is 403 (even for an admin)" || { cat "$TMP/last.body"; fail "missing CSRF accepted"; }
|
||||
[ "$(code -b "$ADMINU_JAR" -H "X-CSRF-Token: $(csrf_of "$VIEWER_JAR")" -X PUT "$URL/api/v1/devices/$SW1" -H 'Content-Type: application/json' -d "$PUTBODY")" = 403 ] &&
|
||||
pass "another session's CSRF token is refused" || fail "foreign CSRF token accepted"
|
||||
as "$ADMIN_JAR" POST /api/v1/tokens '{"name":"gate-viewer","scope":"viewer"}' >/dev/null; VTOK="$(py "d['secret']" <"$TMP/last.body")"; VTOK_ID="$(py "d['id']" <"$TMP/last.body")"
|
||||
as "$ADMIN_JAR" POST /api/v1/tokens "{\"name\":\"gate-push\",\"scope\":\"push\",\"devices\":[\"$SW2\"]}" >/dev/null; PTOK="$(py "d['secret']" <"$TMP/last.body")"
|
||||
[ "$(tok "$VTOK" GET /api/v1/devices)" = 200 ] && pass "viewer API token: GET /api/v1/devices is 200" || fail "viewer token read"
|
||||
[ "$(tok "$VTOK" PUT "/api/v1/devices/$SW1" "$PUTBODY")" = 403 ] && pass "viewer API token: PUT is 403" || { cat "$TMP/last.body"; fail "viewer token PUT"; }
|
||||
[ "$(tok "$VTOK" GET /api/v1/tokens)" = 403 ] && [ "$(tok "$VTOK" GET /api/v1/settings)" = 403 ] && pass "a viewer cannot list tokens or read settings" || fail "viewer read of admin-only GETs"
|
||||
[ "$(tok "$PTOK" GET /api/v1/devices)" = 403 ] && pass "push token: GET /api/v1/devices is 403" || { cat "$TMP/last.body"; fail "push token on the API"; }
|
||||
[ "$(code -X POST "$URL/write" -H "Authorization: Bearer $PTOK" --data-binary "gate,host=$SW2 v=1")" = 204 ] && pass "the push token still writes through ingest" || { cat "$TMP/last.body"; fail "push write"; }
|
||||
[ "$(code -X POST "$URL/write" -H "Authorization: Bearer $VTOK" --data-binary "gate,host=$SW2 v=1")" != 204 ] && pass "a viewer API token cannot push" || fail "viewer token pushed"
|
||||
[ "$(tok "$ADMIN_TOKEN" PUT "/api/v1/devices/$SW1" "$PUTBODY")" = 200 ] && pass "admin API token: PUT is 200" || fail "admin token PUT"
|
||||
[ "$(tok "$ADMIN_TOKEN" DELETE "/api/v1/tokens/$VTOK_ID")" = 204 ] && pass "revoked the viewer token" || { cat "$TMP/last.body"; fail "revoke"; }
|
||||
[ "$(tok "$VTOK" GET /api/v1/devices)" = 401 ] && pass "a revoked token is 401 on its very next request" || fail "revoked token still works"
|
||||
api /api/v1/tokens >"$TMP/toks.json"
|
||||
! grep -q "$ADMIN_TOKEN\|$PTOK" "$TMP/toks.json" && ! grep -q '\$' "$TMP/toks.json" && grep -q '"gate-push"' "$TMP/toks.json" && grep -q '"gate-admin"' "$TMP/toks.json" &&
|
||||
pass "the token list shows push and API tokens without secrets or hashes" || { cat "$TMP/toks.json"; fail "token listing"; }
|
||||
[ "$(code -H "Authorization: Bearer nma_9999_notatoken" "$URL/api/v1/devices")" = 401 ] && pass "a made-up API token is 401" || fail "bad token"
|
||||
|
||||
# 3(2). The fuzz test.
|
||||
echo "== (2) viewer fuzz"
|
||||
if go test -count=1 -run 'TestViewerCannotMutate|TestEveryRouteIsEitherPublic|TestViewerAndPush' ./internal/api/... >"$TMP/fuzz.log" 2>&1; then
|
||||
pass "TestViewerCannotMutate: every state-changing route refuses a viewer session and a viewer token with 403"
|
||||
else
|
||||
tail -n 30 "$TMP/fuzz.log"; fail "viewer fuzz"
|
||||
fi
|
||||
|
||||
# 3(5 part 1). Break-glass lock-out, unlock, audit and alerts.
|
||||
echo "== (3) break-glass"
|
||||
before="$(api "/api/v1/audit?per_page=1000" | py "d['total']")"
|
||||
for i in 1 2 3 4; do # a different X-Forwarded-For each time: from a non-proxy it must change nothing
|
||||
c="$(bg_login "$TMP/bad.jar" "wrong-$i" -H "X-Forwarded-For: 203.0.113.$i")"
|
||||
[ "$c" = 401 ] || { fail "wrong password $i answered $c"; break; }
|
||||
done
|
||||
[ "$(bg_login "$TMP/bad.jar" "wrong-5" -H "X-Forwarded-For: 203.0.113.5")" = 429 ] && pass "the 5th wrong password locks the source address (429)" || fail "the 5th failure did not lock"
|
||||
c="$(bg_login "$TMP/bad.jar" "$BG_PASSWORD" -H "X-Forwarded-For: 203.0.113.99")"
|
||||
[ "$c" = 429 ] && pass "while locked even the RIGHT password is 429 (5 failures with 5 different X-Forwarded-For values counted against the real peer: the header from a non-proxy was ignored)" ||
|
||||
{ cat "$TMP/last.body"; fail "no lock-out after 5 failures (HTTP $c)"; }
|
||||
grep -q 'locked' "$TMP/last.body" && pass "the lock-out is a problem response naming the cause" || fail "lock-out body: $(cat "$TMP/last.body")"
|
||||
[ "$(code -b "$ADMIN_JAR" "$URL/api/v1/me")" = 200 ] && pass "an existing session keeps working during the lock-out" || fail "existing session broken"
|
||||
./nmon unlock all --data-dir "$DATA" >"$TMP/unlock.log" 2>&1 && pass "nmon unlock all (nmon running): $(cat "$TMP/unlock.log")" || { cat "$TMP/unlock.log"; fail "nmon unlock"; }
|
||||
[ "$(bg_login "$TMP/again.jar" "$BG_PASSWORD")" = 302 ] && pass "after nmon unlock the break-glass login works again" || fail "still locked after unlock"
|
||||
api "/api/v1/audit?per_page=1000" >"$TMP/audit.json"
|
||||
for a in breakglass.login breakglass.unlock; do
|
||||
py "[x['action'] for x in d['items']].count('$a')" <"$TMP/audit.json" | grep -qv '^0$' && pass "audit has $a rows" || fail "no $a audit row"
|
||||
done
|
||||
py "sum(1 for x in d['items'] if x['action'].startswith('breakglass.') and x['action'] not in ('breakglass.login','breakglass.unlock'))" <"$TMP/audit.json" |
|
||||
grep -qv '^0$' && pass "failed attempts and the lock are audited" || fail "no failure audit rows"
|
||||
py "d['total'] > $before" <"$TMP/audit.json" | grep -q True || fail "audit total did not grow"
|
||||
have_alert() { api "/api/v1/alerts?per_page=1000" | py "'$1' in [x['rule'] for x in d['items']]" 2>/dev/null | grep -q True; }
|
||||
wait_for 30 "the break-glass alert" have_alert breakglass_login && pass "every break-glass use is alerted (rule breakglass_login)" || { api "/api/v1/alerts?per_page=50"; fail "no breakglass_login alert"; }
|
||||
wait_for 30 "the lock-out alert" have_alert breakglass_locked && pass "the lock-out is alerted (rule breakglass_locked)" || fail "no breakglass_locked alert"
|
||||
if grep -q "$BG_PASSWORD" "$TMP/nmon.log"; then fail "the break-glass password is in the log"; else pass "the break-glass password is not in the log"; fi
|
||||
|
||||
# 3(7). CSV import.
|
||||
echo "== (7) CSV import"
|
||||
ndev() { api "/api/v1/devices?per_page=1000" | py "d['total']"; }
|
||||
N0="$(ndev)"
|
||||
cat >"$TMP/cycle.csv" <<CSV
|
||||
name,address,site,role,parents
|
||||
gate-imp-a,192.0.2.11,hq,core,gate-imp-c
|
||||
gate-imp-b,192.0.2.12,hq,edge,gate-imp-a
|
||||
gate-imp-c,192.0.2.13,hq,edge,gate-imp-b
|
||||
CSV
|
||||
C="$(code -b "$ADMIN_JAR" -H "X-CSRF-Token: $(csrf_of "$ADMIN_JAR")" -F "devices=@$TMP/cycle.csv;type=text/csv" "$URL/api/v1/devices/import")"
|
||||
[ "$C" = 422 ] && grep -q 'cycle' "$TMP/last.body" && grep -q -- '->' "$TMP/last.body" && pass "an import with a cycle is 422 and names the path: $(py "d['detail']" <"$TMP/last.body")" ||
|
||||
{ cat "$TMP/last.body"; fail "cycle import (HTTP $C)"; }
|
||||
[ "$(ndev)" = "$N0" ] && pass "nothing was written by the rejected import" || fail "devices appeared after a rejected import"
|
||||
printf 'name,address\n,192.0.2.50\n' >"$TMP/badrow.csv"
|
||||
C="$(code -b "$ADMIN_JAR" -H "X-CSRF-Token: $(csrf_of "$ADMIN_JAR")" -F "devices=@$TMP/badrow.csv;type=text/csv" "$URL/api/v1/devices/import")"
|
||||
[ "$C" = 422 ] && grep -q 'devices.csv row 2' "$TMP/last.body" && pass "a bad row is reported with its row number" || { cat "$TMP/last.body"; fail "bad row import"; }
|
||||
cat >"$TMP/devices.csv" <<CSV
|
||||
name,address,site,role,parents
|
||||
gate-imp-a,192.0.2.11,hq,core,
|
||||
gate-imp-b,192.0.2.12,hq,edge,gate-imp-a
|
||||
CSV
|
||||
printf 'device,ifname,alias\n%s,%s,Customer ACME Corp\n' "$SW1" "$IFNAME" >"$TMP/ports.csv"
|
||||
C="$(code -b "$ADMIN_JAR" -H "X-CSRF-Token: $(csrf_of "$ADMIN_JAR")" -F "devices=@$TMP/devices.csv;type=text/csv" -F "ports=@$TMP/ports.csv;type=text/csv" "$URL/api/v1/devices/import")"
|
||||
[ "$C" = 200 ] && [ "$(py "d['devices_created']" <"$TMP/last.body")" = 2 ] && [ "$(py "d['ports_updated']" <"$TMP/last.body")" = 1 ] &&
|
||||
pass "a valid import creates 2 devices and sets 1 port description" || { cat "$TMP/last.body"; fail "valid import (HTTP $C)"; }
|
||||
IMPA="$(slug_by_name gate-imp-a)"; IMPB="$(slug_by_name gate-imp-b)"
|
||||
[ -n "$IMPA" ] && [ -n "$IMPB" ] && [ "$(api "/api/v1/devices/$IMPB" | py "[p['slug'] for p in d['parents']][0]")" = "$IMPA" ] &&
|
||||
pass "imported devices exist and gate-imp-b's parent is gate-imp-a" || fail "imported devices"
|
||||
desc() { api "/api/v1/devices/$SW1" | py "next(i['description'] for i in d['interfaces'] if i['ifname']=='$IFNAME')"; }
|
||||
[ "$(desc)" = "Customer ACME Corp" ] && pass "the port description is on the device page" || fail "description: $(desc)"
|
||||
[ "$(api "/api/v1/interfaces?q=acme" | py "len([i for i in d['items'] if i['description']=='Customer ACME Corp'])")" = 1 ] && pass "interface search (q=acme) finds it by description" || fail "search by description"
|
||||
[ "$(api "/api/v1/devices/$SW1" | py "next(i['ifalias'] for i in d['interfaces'] if i['ifname']=='$IFNAME')")" = "$IFALIAS0" ] && pass "the discovered ifalias is untouched" || fail "ifalias changed"
|
||||
[ "$(as "$ADMIN_JAR" POST "/api/v1/devices/$SW1/discover")" = 200 ] && pass "rediscovery of $SW1 ran" || { cat "$TMP/last.body"; fail "discover"; }
|
||||
[ "$(desc)" = "Customer ACME Corp" ] && pass "the port description SURVIVES rediscovery (discovery rewrites ifalias, never description)" || fail "description lost after rediscovery: $(desc)"
|
||||
[ "$(as "$VIEWER_JAR" POST /api/v1/devices/import)" = 403 ] && pass "a viewer cannot import" || fail "viewer import"
|
||||
[ "$(as "$ADMIN_JAR" POST "/api/v1/devices/$SW1/purge")" = 409 ] && pass "purge of an active device is refused (409): $(py "d['detail']" <"$TMP/last.body")" || fail "purge of an active device"
|
||||
|
||||
# 3(6). Settings.
|
||||
echo "== (6) settings"
|
||||
api /api/v1/settings >"$TMP/settings0.json"; Y0="$(py "d['yaml']" <"$TMP/settings0.json")"
|
||||
put_settings() { # put_settings <yaml text>: prints the status; the body is in $TMP/last.body
|
||||
python3 -I -c 'import json,sys; print(json.dumps({"yaml": sys.argv[1]}))' "$1" >"$TMP/put.json"
|
||||
code -b "$ADMIN_JAR" -H "X-CSRF-Token: $(csrf_of "$ADMIN_JAR")" -X PUT "$URL/api/v1/settings" -H 'Content-Type: application/json' -d @"$TMP/put.json"
|
||||
}
|
||||
C="$(put_settings "retention: {hot: 1h}")"
|
||||
[ "$C" = 422 ] && grep -q 'retention.hot' "$TMP/last.body" && pass "an invalid settings document is 422: $(py "d['detail']" <"$TMP/last.body")" || { cat "$TMP/last.body"; fail "invalid settings (HTTP $C)"; }
|
||||
[ "$(put_settings "nonsense_key: 1")" = 422 ] && pass "an unknown key is 422 too" || fail "unknown key accepted"
|
||||
[ "$(api /api/v1/settings | py "d['yaml']")" = "$Y0" ] && pass "the running configuration is unchanged after invalid PUTs" || fail "configuration changed by an invalid PUT"
|
||||
grep -q 'nonsense_key\|hot: 1h' "$DATA/config.yaml" && fail "an invalid document reached config.yaml" || pass "config.yaml on disk is unchanged"
|
||||
Y1="$(printf '%s\n' "$Y0" | sed 's/hot: 30d/hot: 31d/')"
|
||||
C="$(put_settings "$Y1
|
||||
backup: {at: \"03:30\", keep: 3}
|
||||
disk: {alert_used: 0.75}
|
||||
")"
|
||||
[ "$C" = 200 ] && py "'backup' in d['applied'] and 'disk' in d['applied']" <"$TMP/last.body" | grep -q True &&
|
||||
pass "a valid PUT is applied in place: applied=$(py "d['applied']" <"$TMP/last.body")" || { cat "$TMP/last.body"; fail "valid settings (HTTP $C)"; }
|
||||
py "'retention' in d['restart_required']" <"$TMP/last.body" | grep -q True && pass "a change that needs a restart (retention) is reported as restart_required" || fail "restart_required"
|
||||
grep -q '03:30' "$DATA/config.yaml" && pass "config.yaml was rewritten" || fail "config.yaml not updated"
|
||||
grep -q 'nightly backup scheduled.*03:30' "$TMP/nmon.log" && pass "the nightly backup schedule was reloaded without a restart" || fail "backup schedule not reloaded"
|
||||
[ "$(as "$VIEWER_JAR" PUT /api/v1/settings '{"yaml":"timezone: UTC"}')" = 403 ] && pass "a viewer cannot PUT settings" || fail "viewer PUT settings"
|
||||
cp "$DATA/config.yaml" "$TMP/config.good"
|
||||
sed -i 's/keep: 3/keep: 4/' "$DATA/config.yaml"
|
||||
kill -HUP "$NMON_PID"; wait_for 10 "SIGHUP reload" grep -q 'SIGHUP: config reloaded' "$TMP/nmon.log" && pass "SIGHUP reloads config.yaml from the file" || fail "no SIGHUP reload"
|
||||
printf 'retention: {hot: 1h}\n' >"$DATA/config.yaml"
|
||||
kill -HUP "$NMON_PID"; wait_for 10 "SIGHUP refusal" grep -q 'SIGHUP: config.yaml is invalid' "$TMP/nmon.log" && pass "SIGHUP with a broken file keeps the running configuration and logs why" || fail "SIGHUP with a broken file"
|
||||
cp "$TMP/config.good" "$DATA/config.yaml"
|
||||
[ "$(code "$URL/healthz")" = 200 ] && pass "nmon is still serving after the reload attempts" || fail "nmon down after reload"
|
||||
[ "$(code -H "Authorization: Bearer $ADMIN_TOKEN" -X POST "$URL/api/v1/admin/stores/hot/restart")" = 409 ] &&
|
||||
pass "POST /api/v1/admin/stores/hot/restart on a healthy store is 409: $(py "d['detail']" <"$TMP/last.body")" || { cat "$TMP/last.body"; fail "store restart route"; }
|
||||
./nmon store restart hot --api "$URL" --token "$ADMIN_TOKEN" >"$TMP/storerestart.log" 2>&1
|
||||
grep -q 'has not failed' "$TMP/storerestart.log" && pass "nmon store restart reaches the supervisor and says the store has not failed" || { cat "$TMP/storerestart.log"; fail "nmon store restart"; }
|
||||
|
||||
# 3(4). Backup during polling, restore, start on the restored directory.
|
||||
echo "== (4) backup and restore"
|
||||
have_samples() { [ "$(vm_value "$HOT" 'count(ifHCInOctets)')" != "" ]; }
|
||||
wait_for 60 "polled samples in the hot store" have_samples || fail "no ifHCInOctets samples before the backup"
|
||||
sleep 95 # the backup's consistent boundary sits 90 s in the past: let polling write samples older than that
|
||||
./nmon backup "$TMP/nokey.tar" --api "$URL" --token "$ADMIN_TOKEN" >"$TMP/backup1.log" 2>&1 && grep -q 'NOT included' "$TMP/backup1.log" &&
|
||||
pass "nmon backup (default) prints that the key was NOT included" || { cat "$TMP/backup1.log"; fail "backup without key"; }
|
||||
./nmon backup "$TMP/withkey.tar" --include-key --api "$URL" --token "$ADMIN_TOKEN" >"$TMP/backup2.log" 2>&1 && grep -q 'INCLUDED' "$TMP/backup2.log" &&
|
||||
pass "nmon backup --include-key prints that the key was INCLUDED" || { cat "$TMP/backup2.log"; fail "backup with key"; }
|
||||
tar tf "$TMP/nokey.tar" | grep -q '^secret.key$' && fail "the no-key archive contains the key" || pass "the default archive holds no key file"
|
||||
tar tf "$TMP/withkey.tar" | grep -q '^secret.key$' && pass "the --include-key archive holds the key file" || fail "no key in the --include-key archive"
|
||||
[ "$(as "$VIEWER_JAR" POST /api/v1/admin/backup)" = 403 ] && pass "a viewer cannot take a backup" || fail "backup route authorisation"
|
||||
NDEV_BEFORE="$(ndev)"; DEVICES_BEFORE="$(api "/api/v1/devices?per_page=1000" | py "sorted(i['slug'] for i in d['items'])")"
|
||||
stop_nmon
|
||||
./nmon restore "$TMP/withkey.tar" --data-dir "$TMP/restored" --secret-key-file "$TMP/restored.key" >"$TMP/restore.log" 2>&1 &&
|
||||
grep -q 'done and verified' "$TMP/restore.log" && pass "nmon restore into a clean dir: $(grep 'done and verified' "$TMP/restore.log")" || { cat "$TMP/restore.log"; die "restore"; }
|
||||
[ -s "$TMP/restored.key" ] && pass "the restored key was written outside the data dir" || fail "restored key missing"
|
||||
if ./nmon restore "$TMP/withkey.tar" --data-dir "$TMP/restored" --secret-key-file "$TMP/restored2.key" >"$TMP/restore2.log" 2>&1; then fail "restore into a non-empty dir succeeded"; else
|
||||
pass "restore refuses a data dir that is not clean"; fi
|
||||
PORT2="$(pick_port)"; URL="http://127.0.0.1:$PORT2"
|
||||
sed -i "s#redirect_url: .*#redirect_url: $URL/auth/callback#" "$TMP/restored/config.yaml"
|
||||
start_nmon "$TMP/restored" "$TMP/restored.key" "$PORT2" "$TMP/nmon-restored.log" --insecure-http
|
||||
wait_for 90 "nmon on the restored dir" healthy || { tail -n 20 "$TMP/nmon-restored.log"; die "nmon did not start on the restored dir"; }
|
||||
HOT="$(grep -o 'hot=http://[^ ]*' "$TMP/nmon-restored.log" | head -n 1 | cut -d= -f2)"
|
||||
[ "$(ndev)" = "$NDEV_BEFORE" ] && [ "$(api "/api/v1/devices?per_page=1000" | py "sorted(i['slug'] for i in d['items'])")" = "$DEVICES_BEFORE" ] &&
|
||||
pass "the restored nmon has the same $NDEV_BEFORE devices (and the admin API token still works)" || fail "devices differ after restore"
|
||||
[ "$(desc)" = "Customer ACME Corp" ] && pass "the port description came back with the inventory" || fail "description lost in restore"
|
||||
SAMPLES="$(vm_value "$HOT" 'count(ifHCInOctets{device="'"$SW1"'"})')"
|
||||
[ -n "$SAMPLES" ] && [ "${SAMPLES%.*}" -ge 1 ] && pass "a sample series is present in the restored hot store (ifHCInOctets for $SW1: $SAMPLES series)" || fail "no ifHCInOctets series after restore"
|
||||
OLDEST="$(curl -fsS -G "$HOT/api/v1/query" --data-urlencode "query=count_over_time(ifHCInOctets{device=\"$SW1\"}[2h])" | py "max([float(r['value'][1]) for r in d['data']['result']] or [0])")"
|
||||
python3 -I -c "import sys; sys.exit(0 if float('$OLDEST') >= 5 else 1)" && pass "history was restored, not just new samples ($OLDEST samples in the last 2 h)" || fail "restored store has too few samples ($OLDEST)"
|
||||
sso_login "$TMP/restored.jar" admin-1 "Adam Admin" "nmon-admins" && [ "$(curl -fsS -b "$TMP/restored.jar" "$URL/api/v1/me" | py "d['role']")" = admin ] &&
|
||||
pass "OIDC sign-in works on the restored instance (the client secret was restored and decrypts)" || fail "OIDC on the restored instance"
|
||||
stop_nmon
|
||||
|
||||
# 3(5). Key rotation on a copy, started over HTTPS.
|
||||
echo "== (5) key rotate"
|
||||
cp -r "$TMP/restored" "$TMP/rotated"; cp "$TMP/restored.key" "$TMP/rotated.key"
|
||||
./nmon key rotate --data-dir "$TMP/rotated" --secret-key-file "$TMP/rotated.key" >"$TMP/rotate.log" 2>&1 && grep -q 're-encrypted' "$TMP/rotate.log" &&
|
||||
pass "nmon key rotate: $(cat "$TMP/rotate.log")" || { cat "$TMP/rotate.log"; die "key rotate"; }
|
||||
[ "$(wc -l <"$TMP/rotated.key")" -gt "$(wc -l <"$TMP/restored.key")" ] && ! cmp -s "$TMP/rotated.key" "$TMP/restored.key" &&
|
||||
pass "the key file now holds the new key as well as the old one" || fail "key file unchanged"
|
||||
PORT3="$(pick_port)"; URL="https://127.0.0.1:$PORT3"
|
||||
sed -i "s#redirect_url: .*#redirect_url: $URL/auth/callback#" "$TMP/rotated/config.yaml"
|
||||
start_nmon "$TMP/rotated" "$TMP/rotated.key" "$PORT3" "$TMP/nmon-rotated.log" --tls-cert "$TMP/tls.crt" --tls-key "$TMP/tls.key"
|
||||
healthy_tls() { curl -fsS --cacert "$TMP/tls.crt" "$URL/healthz" >/dev/null 2>&1; }
|
||||
wait_for 90 "nmon over HTTPS" healthy_tls || { tail -n 20 "$TMP/nmon-rotated.log"; die "nmon did not start over HTTPS on the rotated dir"; }
|
||||
pass "nmon serves HTTPS with the provided certificate on the rotated data dir"
|
||||
curl() { command curl --cacert "$TMP/tls.crt" "$@"; } # from here on talk TLS and verify the certificate
|
||||
[ "$(command curl -s -o /dev/null -w '%{http_code}' "$URL/healthz")" = 000 ] && pass "an HTTPS endpoint without the CA is refused by a verifying client" || fail "certificate not verified"
|
||||
bg_login "$TMP/rot.jar" "$BG_PASSWORD" -D "$TMP/rot.headers" | grep -q 302 && grep -qi '^set-cookie: nmon_session=.*; Secure' "$TMP/rot.headers" &&
|
||||
pass "over HTTPS the session cookie is Secure" || { cat "$TMP/rot.headers"; fail "cookie not Secure over HTTPS"; }
|
||||
ADMIN_TOKEN_OK="$(api "/api/v1/devices?per_page=1000" | py "d['total']")"
|
||||
[ "$ADMIN_TOKEN_OK" = "$NDEV_BEFORE" ] && pass "the rotated instance serves the same devices" || fail "devices after rotation: $ADMIN_TOKEN_OK"
|
||||
polled() { api /api/v1/poller/health | py "sum(1 for x in d['devices'] if x['slug'] in ('$SW1','$SW2') and x['state']=='ok')" 2>/dev/null | grep -qx 2; }
|
||||
wait_for 90 "both switches to poll ok with the rotated key" polled && pass "nmon polls both switches with the rotated key (SNMP credentials still decrypt)" ||
|
||||
{ api /api/v1/poller/health | head -c 600; fail "polling after rotation"; }
|
||||
sso_login "$TMP/rot-sso.jar" admin-1 "Adam Admin" "nmon-admins" 2>/dev/null
|
||||
[ "$(curl -fsS -b "$TMP/rot-sso.jar" "$URL/api/v1/me" 2>/dev/null | py "d['role']" 2>/dev/null)" = admin ] && pass "OIDC still works after the rotation (the client secret was re-sealed)" || fail "OIDC after rotation"
|
||||
unset -f curl
|
||||
stop_nmon
|
||||
|
||||
# Back to the first data dir (plain HTTP) for the remaining checks and the UI.
|
||||
URL="http://127.0.0.1:$PORT"
|
||||
start_nmon "$DATA" "$KEY" "$PORT" "$TMP/nmon2.log" --insecure-http
|
||||
wait_for 90 "nmon to serve again" healthy || { tail -n 20 "$TMP/nmon2.log"; die "nmon did not restart"; }
|
||||
HOT="$(grep -o 'hot=http://[^ ]*' "$TMP/nmon2.log" | head -n 1 | cut -d= -f2)"
|
||||
|
||||
# 3(8). OpenAPI, the web client, UI specs, screenshots.
|
||||
echo "== openapi"
|
||||
curl -fsS -H "Authorization: Bearer $ADMIN_TOKEN" "$URL/openapi.json" >"$TMP/openapi.live.json" || die "GET /openapi.json"
|
||||
OPENAPI_BASE_TAG=m3 OPENAPI_NEW="$TMP/openapi.live.json" ./scripts/openapi-check.sh && pass "openapi-check against m3: additive only" || fail "openapi-check"
|
||||
if ./nmon openapi >"$TMP/openapi.cli.json" 2>/dev/null; then
|
||||
cmp -s "$TMP/openapi.cli.json" openapi.json && pass "openapi.json matches nmon openapi" || fail "openapi.json is stale: run ./nmon openapi > openapi.json"
|
||||
fi
|
||||
echo "== contract drift"
|
||||
cp web/src/api/schema.d.ts "$TMP/schema.before.d.ts"
|
||||
if (cd web && npm run gen:api -- "$TMP/openapi.live.json" >"$TMP/genapi.log" 2>&1 && npm run build >"$TMP/webbuild.log" 2>&1); then
|
||||
pass "web client generated from the live spec type-checks and builds"
|
||||
else
|
||||
tail -n 30 "$TMP/genapi.log" "$TMP/webbuild.log" 2>/dev/null; fail "web build against the live spec"
|
||||
fi
|
||||
cmp -s web/src/api/schema.d.ts "$TMP/schema.before.d.ts" && pass "web/src/api/schema.d.ts is current" || fail "web/src/api/schema.d.ts changed after regeneration from the live spec; commit it"
|
||||
(cd web && npm run lint >"$TMP/weblint.log" 2>&1) && pass "web lint after regeneration" || { tail -n 20 "$TMP/weblint.log"; fail "web lint"; }
|
||||
echo "== playwright (mock)"
|
||||
if (cd web && npm run e2e) >"$TMP/e2e-mock.log" 2>&1; then pass "mock UI specs (incl. docs/screenshots/M6-mock)"; else tail -n 40 "$TMP/e2e-mock.log"; fail "mock UI specs"; fi
|
||||
|
||||
echo "== screenshots (live)"
|
||||
rm -rf "$SHOTS"; mkdir -p "$SHOTS"
|
||||
printf 'name,address,parents\nshot-a,192.0.2.71,shot-b\nshot-b,192.0.2.72,shot-a\nshot-c,,\n' >"$TMP/shot-devices.csv"
|
||||
export NMON_URL="$URL" NMON_DEVICE="$SW1" NMON_BG_USER=breakglass NMON_BG_PASS="$BG_PASSWORD" NMON_OIDC_CONTROL="$CTL_URL" NMON_IMPORT_BAD_CSV="$TMP/shot-devices.csv"
|
||||
if (cd web && NMON_SHOT_DIR="$SHOTS" npm run e2e:live -- screenshots-m6.spec.ts) >"$TMP/shots.log" 2>&1; then
|
||||
N="$(ls "$SHOTS"/*.png 2>/dev/null | wc -l)"
|
||||
[ "$N" -ge 4 ] && pass "$N screenshots in $SHOTS" || fail "only $N screenshots in $SHOTS"
|
||||
ls "$SHOTS"
|
||||
else
|
||||
tail -n 40 "$TMP/shots.log"; fail "screenshots"
|
||||
fi
|
||||
[ "$(ndev)" = "$((NDEV_BEFORE))" ] && pass "the screenshot run wrote no data (the bad CSV was rejected)" || fail "device count changed during the screenshots"
|
||||
|
||||
if [ "$FAILS" -eq 0 ]; then echo "GATE M6: PASS"; else echo "GATE M6: FAIL ($FAILS)"; exit 1; fi
|
||||
@@ -152,6 +152,7 @@ function makeIface(sp: Spec, di: number, name: string, idx: number, speed: numbe
|
||||
ifname: name,
|
||||
ifdescr: sp.profile === 'cisco-ios' ? `GigabitEthernet${name.replace(/^Gi/, '')}` : name,
|
||||
ifalias: aliasFor(sp, id, idx, speed, uplink, st),
|
||||
description: '',
|
||||
ifphysaddr: [0x00, 0x1c, 0x73, di, idx >> 8, idx & 255].map((b) => b.toString(16).padStart(2, '0')).join(':'),
|
||||
ifspeed: Math.min(speed, 4294967295),
|
||||
ifhighspeed: Math.round(speed / 1e6),
|
||||
|
||||
@@ -61,7 +61,11 @@ test('Proxmox guest device page', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('2 y stitched graph of the gate interface, with p95 provenance', async ({ page }) => {
|
||||
await page.goto(`/graph/${need('GATE_IFACE_ID')}?range=2y&p95=true`)
|
||||
// p95 is per calendar month: a 2 y range offers it only with a month picked (the gate writes last month's).
|
||||
const now = new Date()
|
||||
const last = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1))
|
||||
const month = `${last.getUTCFullYear()}-${String(last.getUTCMonth() + 1).padStart(2, '0')}`
|
||||
await page.goto(`/graph/${need('GATE_IFACE_ID')}?range=2y&p95=true&month=${month}`)
|
||||
const g = page.getByTestId('traffic-graph')
|
||||
await expect(g).toHaveAttribute('data-state', 'ready')
|
||||
await expect(g).toHaveAttribute('data-ready', 'true')
|
||||
|
||||
75
web/e2e/live/screenshots-m6.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Live M6 screenshots against a real nmon with the OIDC mock and a break-glass admin configured. There is no
|
||||
// login page in the SPA: an unauthenticated browser is redirected by the server. This spec NEVER creates
|
||||
// data: it only signs in, navigates and shoots; the import is rejected (bad CSV), so nothing is written, and
|
||||
// no token is minted.
|
||||
// NMON_URL=https://nmon.test NMON_DEVICE=<slug> NMON_BG_USER=... NMON_BG_PASS=... \
|
||||
// NMON_OIDC_CONTROL=http://127.0.0.1:9100 NMON_IMPORT_BAD_CSV=/path/devices.csv \
|
||||
// NMON_SHOT_DIR=../docs/screenshots/M6 npm run e2e:live -- screenshots-m6.spec.ts
|
||||
import path from 'node:path'
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
const DIR = path.resolve(import.meta.dirname, '../..', process.env.NMON_SHOT_DIR ?? '../docs/screenshots/M6')
|
||||
const need = (name: string): string => {
|
||||
const v = process.env[name]
|
||||
if (!v) throw new Error(`${name} is required`)
|
||||
return v
|
||||
}
|
||||
|
||||
async function shot(page: Page, name: string, fullPage = false) {
|
||||
await page.mouse.move(2, 2)
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.screenshot({ path: path.join(DIR, `${name}.png`), fullPage, animations: 'disabled' })
|
||||
}
|
||||
|
||||
test('admin through the break-glass prompt: settings, tokens and a rejected import', async ({ browser }) => {
|
||||
const url = need('NMON_URL')
|
||||
const context = await browser.newContext({
|
||||
baseURL: url,
|
||||
viewport: { width: 1440, height: 900 },
|
||||
colorScheme: 'dark',
|
||||
ignoreHTTPSErrors: true,
|
||||
httpCredentials: { username: need('NMON_BG_USER'), password: need('NMON_BG_PASS'), origin: new URL(url).origin },
|
||||
})
|
||||
const page = await context.newPage()
|
||||
// /auth/breakglass answers the HTTP Basic challenge, creates the session and redirects to /
|
||||
await page.goto('/auth/breakglass')
|
||||
await expect(page.getByTestId('user-menu')).toBeVisible()
|
||||
await expect(page.getByTestId('role-badge')).toHaveText('admin')
|
||||
|
||||
await page.goto('/settings')
|
||||
await expect(page.getByTestId('settings-yaml')).toHaveValue(/retention:/)
|
||||
await shot(page, 'settings')
|
||||
|
||||
await page.goto('/settings/tokens')
|
||||
await expect(page.getByTestId('tokens-table').or(page.getByTestId('empty-state'))).toBeVisible()
|
||||
await shot(page, 'tokens')
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('device-row').first()).toBeVisible()
|
||||
await page.getByTestId('import-open').click()
|
||||
const dialog = page.getByTestId('import-dialog')
|
||||
await dialog.getByTestId('import-devices').setInputFiles(need('NMON_IMPORT_BAD_CSV'))
|
||||
await dialog.getByTestId('import-submit').click()
|
||||
await expect(dialog.getByTestId('import-failure')).toBeVisible()
|
||||
await shot(page, 'import-dialog')
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('a plain viewer through the OIDC mock sees a read-only device page', async ({ browser }) => {
|
||||
const url = need('NMON_URL')
|
||||
const control = need('NMON_OIDC_CONTROL')
|
||||
const context = await browser.newContext({ baseURL: url, viewport: { width: 1440, height: 900 }, colorScheme: 'dark', ignoreHTTPSErrors: true })
|
||||
const set = await context.request.get(`${control}/user?sub=viewer1&name=${encodeURIComponent('Vera Viewer')}&groups=`)
|
||||
expect(set.ok(), 'OIDC mock control endpoint').toBe(true)
|
||||
const page = await context.newPage()
|
||||
// the server redirects through the IdP mock and back to this URL
|
||||
await page.goto(`/devices/${need('NMON_DEVICE')}`)
|
||||
await expect(page.getByTestId('device-header')).toBeVisible()
|
||||
await expect(page.getByTestId('role-badge')).toHaveText('viewer')
|
||||
await expect(page.getByTestId('if-row').first()).toBeVisible()
|
||||
for (const id of ['import-open', 'purge-button', 'nav-settings', 'nav-tokens', 'check-new', 'group-new', 'ack-button']) {
|
||||
await expect(page.getByTestId(id)).toHaveCount(0)
|
||||
}
|
||||
await shot(page, 'viewer-device')
|
||||
await context.close()
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import type { Page } from '@playwright/test'
|
||||
import type { components } from '../../src/api/schema.d.ts'
|
||||
import { AlertStore } from './alerts.ts'
|
||||
import { GroupStore } from './groups.ts'
|
||||
import { M6Store, type M6Options } from './m6.ts'
|
||||
import { OpsStore } from './ops.ts'
|
||||
import { NOW, profileFor, rateAt, rnd, type Profile } from './traffic.ts'
|
||||
|
||||
@@ -21,6 +22,15 @@ const views = new Map<string, S['DeviceView']>(
|
||||
.map((f) => readJson(path.join('devices', f)) as S['DeviceView'])
|
||||
.map((d) => [d.slug, d]),
|
||||
)
|
||||
/** Operator-owned port descriptions (device/ifname), so the Description column and its search have data. */
|
||||
const DESCRIPTIONS: Record<string, string> = {
|
||||
'yyz1-core1/xe-0/0/1': 'Uplink to yul1-core1, circuit YYZ-YUL-0042',
|
||||
'yyz1-acc01/Gi1/0/2': 'Lakeshore Clinic, circuit LKSH-4412, patch panel B7',
|
||||
'yyz1-acc01/Gi1/0/3': 'Contoso transit, circuit CTSO-0098',
|
||||
}
|
||||
for (const dev of views.values()) {
|
||||
for (const iface of dev.interfaces) iface.description = DESCRIPTIONS[`${dev.name}/${iface.ifname}`] ?? ''
|
||||
}
|
||||
const health = readJson('poller-health.json') as S['PollerHealth']
|
||||
const facets = readJson('facets.json') as S['DeviceFacets']
|
||||
const meta = readJson('meta.json') as S['Meta']
|
||||
@@ -145,7 +155,7 @@ export function buildGraph(id: string, from: number, to: number, step: number, o
|
||||
|
||||
const problem = (status: number, title: string, detail: string) => ({ type: 'about:blank', title, status, detail })
|
||||
|
||||
export interface MockOptions {
|
||||
export interface MockOptions extends M6Options {
|
||||
latencyMs?: number
|
||||
/** Overrides a fixture device's reachability by name (used by the M4 alert scenarios). */
|
||||
reach?: Record<string, S['DeviceSummary']['reachability']>
|
||||
@@ -182,8 +192,9 @@ function setup(opts: MockOptions) {
|
||||
},
|
||||
series: (id, from, to, step) => buildGraph(id, from, to, step)?.series ?? null,
|
||||
})
|
||||
const ops = new OpsStore({ slug: slugOfName, name })
|
||||
return { latency: opts.latencyMs ?? 8, devs: devices.map((d) => withReach(d)), withReach, store, groups, ops }
|
||||
const ops = new OpsStore({ slug: slugOfName, name }, opts.apiTokens)
|
||||
const m6 = new M6Store(opts, name)
|
||||
return { latency: opts.latencyMs ?? 8, devs: devices.map((d) => withReach(d)), withReach, store, groups, ops, m6 }
|
||||
}
|
||||
|
||||
type Ctx = ReturnType<typeof setup>
|
||||
@@ -195,13 +206,14 @@ function interfaceHits(q: URLSearchParams): S['InterfaceHit'][] {
|
||||
const term = (q.get('q') ?? '').toLowerCase()
|
||||
return allInterfaces()
|
||||
.filter(({ iface }) => !iface.retired_at)
|
||||
.filter(({ iface }) => term && (iface.ifname.toLowerCase().includes(term) || iface.ifalias.toLowerCase().includes(term)))
|
||||
.filter(({ iface }) => term && [iface.ifname, iface.ifalias, iface.description].some((f) => f.toLowerCase().includes(term)))
|
||||
.sort((a, b) => a.dev.name.localeCompare(b.dev.name) || a.iface.ifindex - b.iface.ifindex)
|
||||
.map(({ iface, dev }) => ({
|
||||
id: iface.id,
|
||||
device: { slug: dev.slug, name: dev.name },
|
||||
ifname: iface.ifname,
|
||||
ifalias: iface.ifalias,
|
||||
description: iface.description,
|
||||
ifdescr: iface.ifdescr,
|
||||
oper_status: iface.oper_status,
|
||||
admin_status: iface.admin_status,
|
||||
@@ -222,7 +234,10 @@ function graphReply(q: URLSearchParams): Reply {
|
||||
|
||||
function deviceReply(c: Ctx, slug: string): Reply {
|
||||
const v = views.get(slug)
|
||||
return v ? ok({ ...c.withReach(v), checks: c.ops.checksFor(v.slug) }) : notFound(`device "${slug}" does not exist`)
|
||||
if (!v || c.m6.isPurged(slug)) return notFound(`device "${slug}" does not exist`)
|
||||
const days = c.m6.retiredDays(v.name)
|
||||
const retired = days === undefined ? {} : { retired_at: isoZ(NOW - days * 86400) }
|
||||
return ok({ ...c.withReach(v), ...retired, checks: c.ops.checksFor(v.slug) })
|
||||
}
|
||||
|
||||
/** The read-only fixture endpoints; undefined when `p` is not one of them. */
|
||||
@@ -254,10 +269,14 @@ function reply(c: Ctx, method: string, p: string, q: URLSearchParams, body: () =
|
||||
/** Routes every /api/v1 call of `page` to the fixtures. */
|
||||
export async function installMockApi(page: Page, opts: MockOptions = {}) {
|
||||
const c = setup(opts)
|
||||
await c.m6.install(page)
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const req = route.request()
|
||||
const url = new URL(req.url())
|
||||
const r = reply(c, req.method(), url.pathname.replace(/^\/api\/v1/, ''), url.searchParams, () => req.postDataJSON() as unknown)
|
||||
const p = url.pathname.replace(/^\/api\/v1/, '')
|
||||
const header = (n: string) => req.headers()[n]
|
||||
const body = { json: () => req.postDataJSON() as unknown, raw: () => req.postData() ?? '' }
|
||||
const r = c.m6.guard(req.method(), p, header) ?? c.m6.route(req.method(), p, body) ?? reply(c, req.method(), p, url.searchParams, body.json)
|
||||
if (c.latency) await new Promise((res) => setTimeout(res, c.latency))
|
||||
await route.fulfill({ status: r.status, contentType: r.status >= 400 ? 'application/problem+json' : 'application/json', body: JSON.stringify(r.body) })
|
||||
})
|
||||
|
||||
79
web/e2e/mock/importCsv.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// Mock of POST /api/v1/devices/import: reads the multipart body, validates the rows like the server does
|
||||
// (every problem with its row, a parent cycle with its path, nothing written on any 422).
|
||||
import type { components } from '../../src/api/schema.d.ts'
|
||||
import type { Reply } from './groups.ts'
|
||||
|
||||
type Row = Record<string, string>
|
||||
type Err = components['schemas']['ProblemDetail']
|
||||
|
||||
/** The text of each file field of a multipart/form-data body. */
|
||||
export function multipartFiles(raw: string): Record<string, string> {
|
||||
const boundary = raw.split('\r\n', 1)[0] ?? ''
|
||||
const out: Record<string, string> = {}
|
||||
for (const part of raw.split(boundary)) {
|
||||
const m = /name="([^"]+)"[\s\S]*?\r\n\r\n([\s\S]*?)\r\n$/.exec(part)
|
||||
if (m) out[m[1] as string] = m[2] as string
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function parseCsv(text: string): Row[] {
|
||||
const [head, ...lines] = text.split(/\r?\n/).filter((l) => l.trim() !== '')
|
||||
const cols = (head ?? '').split(',').map((c) => c.trim())
|
||||
return lines.map((l) => Object.fromEntries(l.split(',').map((v, i) => [cols[i] ?? `col${i}`, v.trim()])))
|
||||
}
|
||||
|
||||
const ADDRESS = /^(\d{1,3}(\.\d{1,3}){3}|[0-9a-f:]+:[0-9a-f:]*|[a-z0-9-]+(\.[a-z0-9-]+)+)$/i
|
||||
|
||||
function deviceErrors(rows: Row[]): Err[] {
|
||||
const errs: Err[] = []
|
||||
rows.forEach((r, i) => {
|
||||
const location = `devices.csv row ${i + 2}`
|
||||
if (!r.name) errs.push({ location, message: 'name is required' })
|
||||
if (r.address && !ADDRESS.test(r.address)) errs.push({ location, message: `address "${r.address}" is not an IP address or hostname` })
|
||||
if (!r.address) errs.push({ location, message: 'address is required' })
|
||||
})
|
||||
return errs
|
||||
}
|
||||
|
||||
function portErrors(rows: Row[]): Err[] {
|
||||
const errs: Err[] = []
|
||||
rows.forEach((r, i) => {
|
||||
if (!r.device || !r.ifname) errs.push({ location: `ports.csv row ${i + 2}`, message: 'device and ifname are required' })
|
||||
})
|
||||
return errs
|
||||
}
|
||||
|
||||
/** "a -> b -> a" for the first parent cycle among the rows, or null. */
|
||||
function findCycle(rows: Row[]): string | null {
|
||||
const parents = new Map(rows.map((r) => [r.name, (r.parents ?? '').split(/[;\s]+/).filter(Boolean)]))
|
||||
const visit = (n: string, path: string[]): string[] | null => {
|
||||
const at = path.indexOf(n)
|
||||
if (at >= 0) return [...path.slice(at), n]
|
||||
for (const p of parents.get(n) ?? []) {
|
||||
const c = parents.has(p) ? visit(p, [...path, n]) : null
|
||||
if (c) return c
|
||||
}
|
||||
return null
|
||||
}
|
||||
for (const n of parents.keys()) {
|
||||
const c = n ? visit(n, []) : null
|
||||
if (c) return c.join(' -> ')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const unprocessable = (detail: string, errors?: Err[]): Reply => ({ status: 422, body: { type: 'about:blank', title: 'Unprocessable Entity', status: 422, detail, ...(errors && { errors }) } })
|
||||
|
||||
export function importCsv(raw: string): Reply {
|
||||
const files = multipartFiles(raw)
|
||||
if (!files.devices && !files.ports) return unprocessable('attach devices.csv and/or ports.csv')
|
||||
const devices = files.devices ? parseCsv(files.devices) : []
|
||||
const ports = files.ports ? parseCsv(files.ports) : []
|
||||
const errors = [...deviceErrors(devices), ...portErrors(ports)]
|
||||
if (errors.length) return unprocessable(`${errors.length} problem${errors.length === 1 ? '' : 's'} found; nothing was imported`, errors)
|
||||
const cycle = findCycle(devices)
|
||||
if (cycle) return unprocessable(`parents form a dependency cycle: ${cycle}; nothing was imported`)
|
||||
const result: components['schemas']['ImportResult'] = { devices_created: devices.length, snmp_profiles_created: devices.filter((d) => d.credential).length, ports_updated: ports.length }
|
||||
return { status: 200, body: result }
|
||||
}
|
||||
114
web/e2e/mock/m6.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// Stateful mock of the M6 surface: identity (/me, /auth/*), roles and CSRF enforcement, settings, CSV import
|
||||
// and purge. One store per page. Shapes come from the generated schema.
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type { components } from '../../src/api/schema.d.ts'
|
||||
import type { Reply } from './groups.ts'
|
||||
import { CONFIG_YAML, diffSections } from './settingsYaml.ts'
|
||||
import { importCsv } from './importCsv.ts'
|
||||
|
||||
type S = components['schemas']
|
||||
|
||||
export type Identity = 'admin' | 'viewer' | 'dev' | 'anon'
|
||||
export const CSRF = 'csrf-test'
|
||||
|
||||
export interface M6Options {
|
||||
/** Who the browser is signed in as (default admin via session); anon answers every API call with 401. */
|
||||
who?: Identity
|
||||
/** Device name -> days since it was retired. */
|
||||
retired?: Record<string, number>
|
||||
/** Seed one viewer and one admin API token next to the push tokens. */
|
||||
apiTokens?: boolean
|
||||
/** restart_required reported by GET /settings (default ["timezone"]). */
|
||||
pendingRestart?: string[]
|
||||
}
|
||||
|
||||
const problem = (status: number, title: string, detail: string): Reply => ({ status, body: { type: 'about:blank', title, status, detail } })
|
||||
const RESTART_SECTIONS = new Set(['retention', 'timezone', 'checks', 'proxmox'])
|
||||
const PURGE_AFTER_DAYS = 365
|
||||
|
||||
export class M6Store {
|
||||
who: Identity
|
||||
via: S['MeView']['via'] = 'session'
|
||||
private yaml = CONFIG_YAML
|
||||
private restart: string[]
|
||||
private readonly purged = new Set<string>()
|
||||
constructor(
|
||||
private readonly opts: M6Options,
|
||||
private readonly nameOf: (slug: string) => string | undefined,
|
||||
) {
|
||||
this.who = opts.who ?? 'admin'
|
||||
this.restart = opts.pendingRestart ?? ['timezone']
|
||||
if (this.who === 'dev') this.via = 'dev'
|
||||
}
|
||||
|
||||
retiredDays(name: string): number | undefined {
|
||||
return this.opts.retired?.[name]
|
||||
}
|
||||
|
||||
isPurged(slug: string): boolean {
|
||||
return this.purged.has(slug)
|
||||
}
|
||||
|
||||
me(): S['MeView'] {
|
||||
if (this.who === 'dev') return { user: 'dev', name: '', role: 'admin', via: 'dev', csrf: '', dev_open: true }
|
||||
const viewer = this.who === 'viewer'
|
||||
return { user: viewer ? 'viewer1' : 'alice', name: viewer ? 'Vera Viewer' : 'Alice Admin', role: viewer ? 'viewer' : 'admin', via: 'session', csrf: CSRF, dev_open: false }
|
||||
}
|
||||
|
||||
/** Authentication, role and CSRF enforcement, the way the server does it. */
|
||||
guard(method: string, p: string, header: (name: string) => string | undefined): Reply | undefined {
|
||||
if (this.who === 'anon') return problem(401, 'Unauthorized', 'sign in first')
|
||||
if (this.who === 'dev') return undefined
|
||||
const write = method !== 'GET'
|
||||
if (write && header('x-csrf-token') !== CSRF) return problem(403, 'Forbidden', 'missing or wrong CSRF token')
|
||||
if (this.who === 'viewer' && (write || p === '/tokens' || p === '/settings')) return problem(403, 'Forbidden', 'this needs the admin role')
|
||||
return undefined
|
||||
}
|
||||
|
||||
route(method: string, p: string, body: { json: () => unknown; raw: () => string }): Reply | undefined {
|
||||
if (p === '/me' && method === 'GET') return { status: 200, body: this.me() }
|
||||
if (p === '/settings') return this.settings(method, body.json)
|
||||
if (p === '/devices/import' && method === 'POST') return importCsv(body.raw())
|
||||
const purge = /^\/devices\/([^/]+)\/purge$/.exec(p)
|
||||
return purge && method === 'POST' ? this.purge(decodeURIComponent(purge[1] as string)) : undefined
|
||||
}
|
||||
|
||||
private settings(method: string, json: () => unknown): Reply | undefined {
|
||||
if (method === 'GET') return { status: 200, body: { yaml: this.yaml, restart_required: this.restart } satisfies S['SettingsView'] }
|
||||
if (method !== 'PUT') return undefined
|
||||
const yaml = (json() as S['SettingsBody']).yaml
|
||||
const bad = /\t/.exec(yaml) ? 'yaml: line 1: found character that cannot start any token' : /^bogus_key:/m.test(yaml) ? 'config: unknown field "bogus_key"' : ''
|
||||
if (bad) return { status: 422, body: { type: 'about:blank', title: 'Unprocessable Entity', status: 422, detail: bad } }
|
||||
const changed = diffSections(this.yaml, yaml)
|
||||
const restart = changed.filter((s) => RESTART_SECTIONS.has(s))
|
||||
this.yaml = yaml
|
||||
this.restart = [...new Set([...this.restart, ...restart])]
|
||||
return { status: 200, body: { yaml, applied: changed.filter((s) => !RESTART_SECTIONS.has(s)), restart_required: this.restart } satisfies S['SettingsApplied'] }
|
||||
}
|
||||
|
||||
private purge(slug: string): Reply {
|
||||
const name = this.nameOf(slug)
|
||||
if (!name) return problem(404, 'Not Found', `device "${slug}" does not exist`)
|
||||
const days = this.retiredDays(name)
|
||||
if (days === undefined) return problem(409, 'Conflict', 'device is not retired')
|
||||
if (days < PURGE_AFTER_DAYS) return problem(409, 'Conflict', `device was retired ${days} days ago; purge is allowed after ${PURGE_AFTER_DAYS} days`)
|
||||
this.purged.add(slug)
|
||||
return { status: 204, body: undefined }
|
||||
}
|
||||
|
||||
/** /auth/* endpoints that live outside the OpenAPI spec. */
|
||||
private async authRoute(route: Route): Promise<void> {
|
||||
const req = route.request()
|
||||
const path = new URL(req.url()).pathname
|
||||
if (path === '/auth/login') return route.fulfill({ status: 200, contentType: 'text/html', body: '<h1>IdP sign-in</h1>' })
|
||||
if (path === '/auth/signedout') return route.fulfill({ status: 200, contentType: 'text/html', body: '<h1>Signed out</h1>' })
|
||||
if (path !== '/auth/logout') return route.fulfill({ status: 404 })
|
||||
if (req.headers()['x-csrf-token'] !== CSRF && this.who !== 'dev') return route.fulfill({ status: 403, contentType: 'application/problem+json', body: '{"title":"Forbidden","status":403}' })
|
||||
this.who = 'anon'
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
|
||||
async install(page: Page): Promise<void> {
|
||||
await page.route((u) => u.pathname.startsWith('/auth/'), (r) => this.authRoute(r))
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,14 @@ function seedTokens(r: OpsResolver): S['TokenView'][] {
|
||||
]
|
||||
}
|
||||
|
||||
function seedApiTokens(): S['TokenView'][] {
|
||||
const base = { devices: [] as string[], series_cap: 0, revoked_at: null as string | null }
|
||||
return [
|
||||
{ ...base, id: 'a1', name: 'grafana read-only', scope: 'viewer', expires_at: iso(NOW + 90 * 86400) },
|
||||
{ ...base, id: 'a2', name: 'backup cron', scope: 'admin', expires_at: null },
|
||||
]
|
||||
}
|
||||
|
||||
function seedChecks(r: OpsResolver): S['CheckView'][] {
|
||||
const dev = (n: string) => ({ slug: r.slug(n), name: n })
|
||||
const mk = (id: string, device: string, kind: S['CheckView']['kind'], target: string, last: S['CheckLast'] | undefined, params: S['Params'] = {}): S['CheckView'] => ({
|
||||
@@ -59,8 +67,12 @@ export class OpsStore {
|
||||
private checks: S['CheckView'][]
|
||||
private clusters = seedClusters()
|
||||
private seq = 100
|
||||
constructor(private readonly r: OpsResolver) {
|
||||
constructor(
|
||||
private readonly r: OpsResolver,
|
||||
apiTokens = false,
|
||||
) {
|
||||
this.tokens = seedTokens(r)
|
||||
if (apiTokens) this.tokens.push(...seedApiTokens())
|
||||
this.checks = seedChecks(r)
|
||||
}
|
||||
|
||||
@@ -70,21 +82,24 @@ export class OpsStore {
|
||||
|
||||
private mint(b: Partial<S['TokenBody']>): Reply {
|
||||
if (!b.name?.trim()) return invalid('body.name', 'must not be empty')
|
||||
const scope = b.scope ?? 'push'
|
||||
const devices = b.devices ?? []
|
||||
if (devices.length === 0) return invalid('body.devices', 'at least one device is required')
|
||||
if (scope === 'push' && devices.length === 0) return invalid('body.devices', 'at least one device is required')
|
||||
if (scope !== 'push' && devices.length > 0) return invalid('body.devices', 'only push tokens take devices')
|
||||
const unknown = devices.find((d) => !this.r.name(d))
|
||||
if (unknown) return invalid('body.devices', `device "${unknown}" does not exist`)
|
||||
const api = scope !== 'push'
|
||||
const view: S['TokenView'] = {
|
||||
id: `tok${++this.seq}`,
|
||||
id: api ? `a${++this.seq}` : `tok${++this.seq}`,
|
||||
name: b.name.trim(),
|
||||
devices,
|
||||
scope: 'push',
|
||||
series_cap: b.series_cap || 50000,
|
||||
scope,
|
||||
series_cap: api ? 0 : b.series_cap || 50000,
|
||||
expires_at: b.expires_at ?? null,
|
||||
revoked_at: null,
|
||||
}
|
||||
this.tokens.push(view)
|
||||
const secret = `nmon_push_${hash32(view.id).toString(36)}${hash32(view.name).toString(36)}kP3xQ9vLw2`
|
||||
const secret = `${api ? 'nma' : 'nmon_push'}_${hash32(view.id).toString(36)}${hash32(view.name).toString(36)}kP3xQ9vLw2`
|
||||
return { status: 201, body: { ...view, secret } satisfies S['TokenCreated'] }
|
||||
}
|
||||
|
||||
|
||||
66
web/e2e/mock/settingsYaml.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export const CONFIG_YAML = `# nmon configuration (secrets are never stored here)
|
||||
retention:
|
||||
hot: 744h
|
||||
fiveminute: 2160h
|
||||
hourly: 17520h
|
||||
timezone: America/Toronto
|
||||
retired_purge_after: 8760h
|
||||
alerting:
|
||||
receivers:
|
||||
- name: noc-email
|
||||
type: email
|
||||
to: noc@example.net
|
||||
rules:
|
||||
- name: interface-down
|
||||
for: 5m
|
||||
severity: critical
|
||||
outbound_allow:
|
||||
- 192.0.2.0/24
|
||||
sites:
|
||||
- name: yyz1
|
||||
timezone: America/Toronto
|
||||
proxmox:
|
||||
clusters:
|
||||
- name: pve-yyz1
|
||||
url: https://pve-yyz1.example.net:8006
|
||||
secret_name: pve-yyz1-token
|
||||
checks:
|
||||
default_interval: 60s
|
||||
auth:
|
||||
oidc:
|
||||
issuer: https://sso.example.net
|
||||
client_id: nmon
|
||||
client_secret_name: oidc-client-secret
|
||||
break_glass_file: /etc/nmon/breakglass
|
||||
trusted_proxies:
|
||||
- 10.0.0.0/8
|
||||
session:
|
||||
ttl: 12h
|
||||
backup:
|
||||
at: "02:30"
|
||||
dir: /var/backups/nmon
|
||||
keep: 7
|
||||
disk:
|
||||
alert_used: 0.85
|
||||
admit_used: 0.92
|
||||
reserve_free: 0.05
|
||||
`
|
||||
|
||||
/** Top-level section name -> its text block. */
|
||||
function sections(yaml: string): Map<string, string> {
|
||||
const out = new Map<string, string>()
|
||||
let key = ''
|
||||
for (const line of yaml.split('\n')) {
|
||||
const m = /^([a-z_]+):/.exec(line)
|
||||
if (m) key = m[1] as string
|
||||
if (key) out.set(key, (out.get(key) ?? '') + line + '\n')
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Names of the sections whose text differs between two documents. */
|
||||
export function diffSections(oldYaml: string, newYaml: string): string[] {
|
||||
const a = sections(oldYaml)
|
||||
const b = sections(newYaml)
|
||||
return [...new Set([...a.keys(), ...b.keys()])].filter((k) => a.get(k) !== b.get(k))
|
||||
}
|
||||
@@ -205,7 +205,7 @@ test.describe('interface graph p95 legend', () => {
|
||||
})
|
||||
|
||||
test('a long range has partial coverage and says so', async ({ page }) => {
|
||||
await page.goto(`/graph/${id()}?range=2y&p95=true`)
|
||||
await page.goto(`/graph/${id()}?range=2y&p95=true&month=2026-10`)
|
||||
await expect(page.getByTestId('p95-partial')).toBeVisible()
|
||||
await expect(page.getByTestId('p95-note')).toHaveAttribute('data-partial', 'true')
|
||||
})
|
||||
|
||||
158
web/e2e/specs/m6-ops.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
// M6 UI: settings editor, API token kinds and the CSV import dialog.
|
||||
import { expect, slugOfName, test } from './support.ts'
|
||||
|
||||
test.describe('settings', () => {
|
||||
test('the YAML loads with the pending restart list, and old URLs still work', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.getByTestId('nav-settings').last().click()
|
||||
await expect(page).toHaveURL(/\/settings$/)
|
||||
await expect(page.getByTestId('settings-yaml')).toHaveValue(/retention:/)
|
||||
await expect(page.getByTestId('settings-restart')).toContainText('timezone')
|
||||
await expect(page.getByTestId('settings-help')).toContainText('retired_purge_after')
|
||||
await page.getByTestId('settings-tab-tokens').click()
|
||||
await expect(page).toHaveURL(/\/settings\/tokens$/)
|
||||
await expect(page.getByTestId('token-row').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('a 422 shows the server detail and keeps the text; Revert restores the saved config', async ({ page }) => {
|
||||
await page.goto('/settings')
|
||||
const yaml = page.getByTestId('settings-yaml')
|
||||
const original = await yaml.inputValue()
|
||||
await yaml.fill(`${original}\nbogus_key: 1\n`)
|
||||
await page.getByTestId('settings-save').click()
|
||||
await expect(page.getByTestId('settings-error')).toContainText('unknown field "bogus_key"')
|
||||
await expect(yaml).toHaveValue(/bogus_key: 1/)
|
||||
await page.getByTestId('settings-revert').click()
|
||||
await expect(page.getByTestId('settings-error')).toHaveCount(0)
|
||||
await expect(yaml).toHaveValue(original)
|
||||
})
|
||||
|
||||
test('a 200 lists what was applied and what needs a restart', async ({ page }) => {
|
||||
await page.goto('/settings')
|
||||
const yaml = page.getByTestId('settings-yaml')
|
||||
const text = (await yaml.inputValue()).replace('keep: 7', 'keep: 14').replace('retired_purge_after: 8760h', 'retired_purge_after: 4380h').replace('timezone: America/Toronto', 'timezone: UTC')
|
||||
await yaml.fill(text)
|
||||
const req = page.waitForRequest((r) => r.method() === 'PUT' && r.url().endsWith('/api/v1/settings'))
|
||||
await page.getByTestId('settings-save').click()
|
||||
expect(await (await req).headerValue('x-csrf-token')).toBe('csrf-test')
|
||||
await expect(page.getByTestId('settings-applied')).toContainText('backup')
|
||||
await expect(page.getByTestId('settings-applied')).toContainText('retired_purge_after')
|
||||
await expect(page.getByTestId('settings-restart')).toContainText('timezone')
|
||||
await expect(page.getByTestId('settings-error')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('API tokens', () => {
|
||||
test.use({ mock: { apiTokens: true } })
|
||||
|
||||
test('the list shows the kind of every token', async ({ page }) => {
|
||||
await page.goto('/settings/tokens')
|
||||
await expect(page.getByTestId('token-row')).toHaveCount(5)
|
||||
await expect(page.locator('[data-testid=token-kind-badge][data-kind=viewer]')).toHaveText('Viewer API token')
|
||||
await expect(page.locator('[data-testid=token-kind-badge][data-kind=admin]')).toHaveText('Admin API token')
|
||||
await expect(page.locator('[data-testid=token-kind-badge][data-kind=push]')).toHaveCount(3)
|
||||
})
|
||||
|
||||
for (const [kind, label, prefix] of [
|
||||
['viewer', 'Viewer API token', 'nma_'],
|
||||
['admin', 'Admin API token', 'nma_'],
|
||||
] as const) {
|
||||
test(`minting a ${kind} token needs no devices and shows the secret once`, async ({ page }) => {
|
||||
await page.goto('/settings/tokens')
|
||||
await page.getByTestId('token-new').click()
|
||||
const dialog = page.getByTestId('token-dialog')
|
||||
await dialog.getByTestId('token-kind').selectOption(kind)
|
||||
await expect(dialog.getByTestId('token-device-input')).toHaveCount(0)
|
||||
await expect(dialog.getByTestId('token-cap')).toHaveCount(0)
|
||||
await dialog.getByTestId('token-name').fill(`${kind} script`)
|
||||
const req = page.waitForRequest((r) => r.method() === 'POST' && r.url().endsWith('/api/v1/tokens'))
|
||||
await dialog.getByTestId('token-mint').click()
|
||||
expect((await req).postDataJSON()).toEqual({ name: `${kind} script`, scope: kind })
|
||||
const secret = await dialog.getByTestId('token-secret').inputValue()
|
||||
expect(secret.startsWith(prefix)).toBe(true)
|
||||
await dialog.getByTestId('token-done').click()
|
||||
await expect(page.getByTestId('token-row')).toHaveCount(6)
|
||||
await expect(page.locator(`[data-testid=token-row]:has-text("${kind} script")`).getByTestId('token-kind-badge')).toHaveText(label)
|
||||
await expect(page.locator('body')).not.toContainText(secret)
|
||||
})
|
||||
}
|
||||
|
||||
test('a push token still requires devices', async ({ page }) => {
|
||||
await page.goto('/settings/tokens')
|
||||
await page.getByTestId('token-new').click()
|
||||
const dialog = page.getByTestId('token-dialog')
|
||||
await dialog.getByTestId('token-name').fill('agent')
|
||||
await dialog.getByTestId('token-mint').click()
|
||||
await expect(dialog.getByTestId('token-validation')).toContainText('at least one device')
|
||||
await dialog.getByTestId('token-device-input').fill(slugOfName('yyz1-pve1'))
|
||||
await dialog.getByTestId('token-device-add').click()
|
||||
await dialog.getByTestId('token-mint').click()
|
||||
await expect(dialog.getByTestId('token-secret')).toHaveValue(/^nmon_push_/)
|
||||
})
|
||||
|
||||
test('revoking an API token uses its id as listed', async ({ page }) => {
|
||||
await page.goto('/settings/tokens')
|
||||
await page.locator('[data-testid=token-row]:has-text("grafana read-only")').getByTestId('token-revoke').click()
|
||||
const req = page.waitForRequest((r) => r.method() === 'DELETE')
|
||||
await page.getByTestId('token-revoke-dialog-confirm').click()
|
||||
expect(new URL((await req).url()).pathname).toBe('/api/v1/tokens/a1')
|
||||
await expect(page.locator('[data-testid=token-row][data-state=revoked]')).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
|
||||
const csv = (name: string, text: string) => ({ name, mimeType: 'text/csv', buffer: Buffer.from(text) })
|
||||
const GOOD = 'name,address,site,role,credential,parents\nedge-1,192.0.2.10,yyz1,edge,public,\nedge-2,192.0.2.11,yyz1,edge,,edge-1\n'
|
||||
const BAD_ROWS = 'name,address,site,role,credential,parents\nedge-1,192.0.2.10,yyz1,edge,,\nedge-2,not an address,yyz1,edge,,\n,192.0.2.12,yyz1,edge,,\n'
|
||||
const CYCLE = 'name,address,site,role,credential,parents\na,192.0.2.1,s,r,,b\nb,192.0.2.2,s,r,,a\n'
|
||||
|
||||
test.describe('CSV import', () => {
|
||||
test('success shows the counts', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.getByTestId('import-open').click()
|
||||
const dialog = page.getByTestId('import-dialog')
|
||||
await expect(dialog.getByTestId('import-submit')).toBeDisabled()
|
||||
await dialog.getByTestId('import-devices').setInputFiles(csv('devices.csv', GOOD))
|
||||
await dialog.getByTestId('import-ports').setInputFiles(csv('ports.csv', 'device,ifname,alias\nedge-1,Gi0/1,Customer A\n'))
|
||||
const req = page.waitForRequest((r) => r.method() === 'POST' && r.url().endsWith('/devices/import'))
|
||||
await dialog.getByTestId('import-submit').click()
|
||||
const r = await req
|
||||
expect(await r.headerValue('x-csrf-token')).toBe('csrf-test')
|
||||
expect(await r.headerValue('content-type')).toContain('multipart/form-data')
|
||||
await expect(dialog.getByTestId('import-result')).toContainText('Created 2 devices and 1 SNMP profile; set 1 port description')
|
||||
})
|
||||
|
||||
test('row errors are listed, nothing is cleared, and a fixed file then succeeds', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.getByTestId('import-open').click()
|
||||
const dialog = page.getByTestId('import-dialog')
|
||||
await dialog.getByTestId('import-devices').setInputFiles(csv('devices.csv', BAD_ROWS))
|
||||
await dialog.getByTestId('import-submit').click()
|
||||
const rows = dialog.getByTestId('import-error-row')
|
||||
await expect(rows).toHaveCount(2)
|
||||
await expect(rows.first()).toContainText('devices.csv row 3')
|
||||
await expect(rows.first()).toContainText('not an IP address')
|
||||
await expect(dialog.getByTestId('import-devices')).toHaveValue(/devices\.csv/)
|
||||
await dialog.getByTestId('import-devices').setInputFiles(csv('devices.csv', GOOD))
|
||||
await dialog.getByTestId('import-submit').click()
|
||||
await expect(dialog.getByTestId('import-result')).toBeVisible()
|
||||
})
|
||||
|
||||
test('a dependency cycle shows its path', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.getByTestId('import-open').click()
|
||||
const dialog = page.getByTestId('import-dialog')
|
||||
await dialog.getByTestId('import-devices').setInputFiles(csv('devices.csv', CYCLE))
|
||||
await dialog.getByTestId('import-submit').click()
|
||||
await expect(dialog.getByTestId('import-cycle')).toContainText('a -> b -> a')
|
||||
await expect(dialog.getByTestId('import-error-row')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('the CSV format help is collapsible', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.getByTestId('import-open').click()
|
||||
const help = page.getByTestId('import-help')
|
||||
await expect(help).not.toHaveAttribute('open', '')
|
||||
await help.getByText('CSV format').click()
|
||||
await expect(help).toContainText('name,address,site,role,credential,parents')
|
||||
})
|
||||
})
|
||||