package admin import ( "net" "net/http" "strings" ) // IPAllow is middleware that rejects any request whose source IP is not within // the configured allowlist. It is the first line of defence in front of the // admin backend (doc/06 §2: 管理后台不得暴露公网). // // The source address is taken from the TCP peer (RemoteAddr) ONLY. Proxy // headers like X-Forwarded-For are intentionally ignored: the admin port is // reached directly over an SSH tunnel / intranet, so trusting client-supplied // headers would let an attacker spoof the allowlist. type IPAllow struct { allow []*net.IPNet sec *SecurityLog next http.Handler } // NewIPAllow wraps next with the allowlist check. func NewIPAllow(allow []*net.IPNet, sec *SecurityLog, next http.Handler) *IPAllow { return &IPAllow{allow: allow, sec: sec, next: next} } func (m *IPAllow) ServeHTTP(w http.ResponseWriter, r *http.Request) { ip := clientIP(r.RemoteAddr) if ip == nil || !m.allowed(ip) { if m.sec != nil { m.sec.IPBlocked(r.Context(), hostOnly(r.RemoteAddr), r.URL.Path) } http.Error(w, "forbidden", http.StatusForbidden) return } m.next.ServeHTTP(w, r) } func (m *IPAllow) allowed(ip net.IP) bool { for _, n := range m.allow { if n.Contains(ip) { return true } } return false } // clientIP extracts the net.IP from a "host:port" RemoteAddr. func clientIP(remoteAddr string) net.IP { return net.ParseIP(hostOnly(remoteAddr)) } func hostOnly(remoteAddr string) string { host, _, err := net.SplitHostPort(remoteAddr) if err != nil { // RemoteAddr may already be a bare host in some test setups. return remoteAddr } return host } // realIP returns the client IP for **audit logging** (admin_login_ok 等)。 // // 与上面 IPAllow 的取址原则刻意不同:白名单闸继续只看 TCP 对端(不可伪造); // 而审计日志要的是"人从哪来"——经本机反代(caddy mTLS 网关在同机回环上反代 // 127.0.0.1:9444)时 RemoteAddr 恒为 loopback,没有溯源价值。仅当 TCP 对端是 // loopback(即请求确实来自本机可信反代)才信 X-Forwarded-For,且取**最后一跳** // (Caddy 把真实 TCP 对端追加在末位;更早的段可被客户端伪造预置,不可信)。 func realIP(r *http.Request) string { peer := hostOnly(r.RemoteAddr) ip := net.ParseIP(peer) if ip == nil || !ip.IsLoopback() { return peer } xff := r.Header.Get("X-Forwarded-For") if xff == "" { return peer } parts := strings.Split(xff, ",") last := strings.TrimSpace(parts[len(parts)-1]) if net.ParseIP(last) == nil { return peer } return last }