Make nmon easier to install and run without editing YAML by hand: - Settings: point-and-click forms over config.yaml (GET/PUT /api/v1/settings/form); the YAML editor moves to /settings/yaml. - First install: with no sign-in configured nmon starts in install mode, serves only the setup wizard (sign-in, basics, devices, alerting) and closes it for good once sign-in is saved. A self-signed certificate is generated when no TLS source is given. poller.interval becomes a config setting. - Reverse proxies: opt-in --http-listen plain-HTTP listener beside HTTPS; sign-in stays required. - Webhooks: Generic, Teams Adaptive Card, Slack and Discord presets, template preview and test send. - Help: the operations, coverage and profile docs served at /help. - Polling: per-device "Poll now", a dry-run test poll returning an SNMP trace, and time-limited in-memory SNMP debug recording, in the UI and as `nmon device poll`. - Ansible: deploys into install mode when no sign-in is set, and no longer overwrites the config.yaml the wizard wrote. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
134 lines
4.3 KiB
Go
134 lines
4.3 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"math/big"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/v10networks/nmon/internal/clock"
|
|
)
|
|
|
|
// Self-signed certificate lifetime, and how long before expiry nmon replaces it.
|
|
const (
|
|
selfSignedValidity = 2 * 365 * 24 * time.Hour
|
|
selfSignedRenewBy = 30 * 24 * time.Hour
|
|
)
|
|
|
|
// SelfSignedDir is where the generated certificate and key live.
|
|
func SelfSignedDir(dataDir string) string { return filepath.Join(dataDir, "tls") }
|
|
|
|
// SelfSignedNames are the names and addresses a generated certificate is valid for.
|
|
type SelfSignedNames struct {
|
|
DNS []string
|
|
IPs []net.IP
|
|
}
|
|
|
|
// LocalNames gathers the host name, every non-loopback interface address (IPv6 included), localhost, ::1 and
|
|
// 127.0.0.1: the ways an operator is likely to reach a fresh install. Addresses are read from addrs.
|
|
func LocalNames(hostname string, addrs []net.Addr) SelfSignedNames {
|
|
n := SelfSignedNames{DNS: []string{"localhost"}, IPs: []net.IP{net.IPv6loopback, net.IPv4(127, 0, 0, 1)}}
|
|
if hostname != "" && hostname != "localhost" {
|
|
n.DNS = append([]string{hostname}, n.DNS...)
|
|
}
|
|
for _, a := range addrs {
|
|
ipn, ok := a.(*net.IPNet)
|
|
if !ok || ipn.IP.IsLoopback() || ipn.IP.IsUnspecified() || ipn.IP.IsLinkLocalMulticast() {
|
|
continue
|
|
}
|
|
if !slices.ContainsFunc(n.IPs, ipn.IP.Equal) {
|
|
n.IPs = append(n.IPs, ipn.IP)
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// EnsureSelfSigned returns the paths of <dataDir>/tls/cert.pem and key.pem, generating an ECDSA P-256 pair
|
|
// (valid about two years, key mode 0600) when none exists or the existing one is within 30 days of expiry or
|
|
// cannot be read. generated says whether it made a new pair. An existing valid pair is reused untouched, so
|
|
// the fingerprint a browser trusted stays the same across restarts.
|
|
func EnsureSelfSigned(dataDir string, names SelfSignedNames, clk clock.Clock) (certFile, keyFile string, generated bool, err error) {
|
|
dir := SelfSignedDir(dataDir)
|
|
certFile, keyFile = filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
|
|
if usable(certFile, keyFile, clk.Now()) {
|
|
return certFile, keyFile, false, nil
|
|
}
|
|
if err = os.MkdirAll(dir, 0o700); err != nil {
|
|
return "", "", false, err
|
|
}
|
|
certPEM, keyPEM, err := newSelfSigned(names, clk.Now())
|
|
if err != nil {
|
|
return "", "", false, err
|
|
}
|
|
if err = os.WriteFile(keyFile, keyPEM, 0o600); err != nil {
|
|
return "", "", false, fmt.Errorf("tls: write %s: %w", keyFile, err)
|
|
}
|
|
if err = os.WriteFile(certFile, certPEM, 0o644); err != nil { //nolint:gosec // a certificate is public
|
|
return "", "", false, fmt.Errorf("tls: write %s: %w", certFile, err)
|
|
}
|
|
return certFile, keyFile, true, nil
|
|
}
|
|
|
|
// usable reports whether the stored pair loads and has more than selfSignedRenewBy left.
|
|
func usable(certFile, keyFile string, now time.Time) bool {
|
|
b, err := os.ReadFile(certFile)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
blk, _ := pem.Decode(b)
|
|
if blk == nil {
|
|
return false
|
|
}
|
|
c, err := x509.ParseCertificate(blk.Bytes)
|
|
if err != nil || now.Add(selfSignedRenewBy).After(c.NotAfter) {
|
|
return false
|
|
}
|
|
_, err = LoadCertFiles(certFile, keyFile)
|
|
return err == nil
|
|
}
|
|
|
|
func newSelfSigned(names SelfSignedNames, now time.Time) (certPEM, keyPEM []byte, err error) {
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 120))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
cn := "nmon"
|
|
if len(names.DNS) > 0 {
|
|
cn = names.DNS[0]
|
|
}
|
|
tpl := &x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: pkix.Name{CommonName: cn, Organization: []string{"nmon (self-signed)"}},
|
|
NotBefore: now.Add(-time.Hour),
|
|
NotAfter: now.Add(selfSignedValidity),
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
BasicConstraintsValid: true,
|
|
DNSNames: names.DNS,
|
|
IPAddresses: names.IPs,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
kd, err := x509.MarshalECPrivateKey(key)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
|
|
pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd}), nil
|
|
}
|