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

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

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

152 lines
4.3 KiB
Go

package auth
import (
"context"
"fmt"
"net"
"net/http"
"net/netip"
"strings"
)
// Proxies is the set of reverse proxies whose X-Forwarded-* headers nmon believes. A request from any
// other peer has those headers ignored (and removed), because anyone can send them.
type Proxies struct{ nets []netip.Prefix }
// ParseProxies parses CIDRs or single addresses ("10.0.0.0/8", "192.0.2.7", "::1").
func ParseProxies(cidrs []string) (*Proxies, error) {
p := &Proxies{}
for _, c := range cidrs {
c = strings.TrimSpace(c)
if pf, err := netip.ParsePrefix(c); err == nil {
p.nets = append(p.nets, pf.Masked())
continue
}
a, err := netip.ParseAddr(c)
if err != nil {
return nil, fmt.Errorf("trusted_proxy %q: not a CIDR or address", c)
}
p.nets = append(p.nets, netip.PrefixFrom(a.Unmap(), a.Unmap().BitLen()))
}
return p, nil
}
func (p *Proxies) trusts(a netip.Addr) bool {
if p == nil {
return false
}
a = a.Unmap()
for _, n := range p.nets {
if n.Contains(a) {
return true
}
}
return false
}
// Client is what a request says about its origin after proxy headers have been judged.
type Client struct {
// IP is the original client: the direct peer, or the nearest untrusted hop in X-Forwarded-For when
// the peer is a trusted proxy.
IP netip.Addr
Scheme string
Host string
// ViaProxy is true when the headers were honoured.
ViaProxy bool
}
// Resolve returns the Client for r.
func (p *Proxies) Resolve(r *http.Request) Client {
c := Client{Scheme: "http", Host: r.Host}
if r.TLS != nil {
c.Scheme = "https"
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
peer, err := netip.ParseAddr(host)
if err != nil {
return c
}
c.IP = peer.Unmap()
if !p.trusts(peer) {
return c
}
c.ViaProxy = true
c.IP = p.originalClient(peer, r.Header.Values("X-Forwarded-For"))
if v := firstToken(r.Header.Get("X-Forwarded-Proto")); v == "https" || v == "http" {
c.Scheme = v
}
if v := firstToken(r.Header.Get("X-Forwarded-Host")); v != "" {
c.Host = v
}
return c
}
// originalClient walks X-Forwarded-For from the right, skipping hops that are themselves trusted
// proxies; the first other address is the client. Entries to its left were supplied by it and are not believed.
func (p *Proxies) originalClient(peer netip.Addr, xff []string) netip.Addr {
var hops []string
for _, v := range xff {
for _, h := range strings.Split(v, ",") {
hops = append(hops, strings.TrimSpace(h))
}
}
client := peer.Unmap()
for i := len(hops) - 1; i >= 0; i-- {
a, err := netip.ParseAddr(hops[i])
if err != nil {
return client
}
client = a.Unmap()
if !p.trusts(a) {
return client
}
}
return client
}
func firstToken(v string) string {
first, _, _ := strings.Cut(v, ",")
return strings.ToLower(strings.TrimSpace(first))
}
// scrub removes the forwarding headers from a request whose peer is not a trusted proxy, so no later
// handler can be fooled by them.
func scrub(r *http.Request) {
for _, h := range []string{"X-Forwarded-For", "X-Forwarded-Proto", "X-Forwarded-Host", "X-Forwarded-Port", "Forwarded"} {
r.Header.Del(h)
}
}
// RequestIsHTTPS reports whether the browser reached nmon over HTTPS: the request arrived on a TLS connection, or a
// trusted proxy said so in X-Forwarded-Proto. The header from any other peer was already ignored by Resolve.
func RequestIsHTTPS(r *http.Request) bool {
if r.TLS != nil {
return true
}
c, ok := ClientFromContext(r.Context())
return ok && c.Scheme == "https"
}
// setCookie sends c, dropping its Secure attribute when the browser is talking plain HTTP to nmon: a browser
// discards a Secure cookie received over http, which would make sign-in on the plain-HTTP listener silently fail.
// The attribute is never added here; the cookie's own Secure (off only for --insecure-http) is the ceiling.
func setCookie(w http.ResponseWriter, r *http.Request, c *http.Cookie) {
c.Secure = c.Secure && RequestIsHTTPS(r)
http.SetCookie(w, c)
}
type clientKey struct{}
// ClientFromContext returns the Client Middleware resolved for the request.
func ClientFromContext(ctx context.Context) (Client, bool) {
c, ok := ctx.Value(clientKey{}).(Client)
return c, ok
}
func withClient(ctx context.Context, c Client) context.Context {
return context.WithValue(ctx, clientKey{}, c)
}