This commit is contained in:
SakoroYou 2026-04-10 07:29:47 +08:00 committed by GitHub
commit 3168b1ac79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1337 additions and 52 deletions

View file

@ -465,9 +465,11 @@
},
"gateway": {
"_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
"_comment_allowed_cidrs": "Optional CIDR allowlist for gateway HTTP endpoints. Empty means no CIDR restriction unless automatic fallback enables a generated local allowlist.",
"host": "127.0.0.1",
"port": 18790,
"hot_reload": false,
"log_level": "fatal"
"log_level": "fatal",
"allowed_cidrs": []
}
}

View file

@ -49,6 +49,40 @@ When omitted, the default is `warn`. Supported values: `debug`, `info`, `warn`,
You can also override this with the environment variable `PICOCLAW_LOG_LEVEL`.
### Gateway Host Fallback And CIDR Allowlist
`gateway.host` defaults to `127.0.0.1`.
When `gateway.host` is a loopback address (`127.0.0.1`, `::1`, or `localhost`) and bind fails (for example, on boards where loopback is unavailable), PicoClaw automatically:
1. Falls back to bind on a wildcard address (`0.0.0.0` or `::`, depending on configuration).
2. Enforces a CIDR allowlist for gateway HTTP endpoints.
3. Discovers private local interface CIDRs only when `gateway.allowed_cidrs` is empty.
CIDR sources in fallback mode:
- If `gateway.allowed_cidrs` is configured, that list is used.
- If `gateway.allowed_cidrs` is empty, PicoClaw discovers CIDR networks from local interfaces and uses only those that fall within private address ranges (for example, within `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `100.64.0.0/10`, `fc00::/7`).
- If no private non-loopback CIDR can be discovered, gateway startup fails. On public-only hosts, configure `gateway.allowed_cidrs` explicitly.
Loopback clients are always allowed for local administration.
> **Reverse proxy note:** CIDR checks use connection `RemoteAddr`. If you place the gateway behind a local reverse proxy/tunnel (so requests arrive as loopback), those requests are treated as loopback and pass CIDR filtering at this layer.
Example:
```json
{
"gateway": {
"host": "127.0.0.1",
"port": 18790,
"allowed_cidrs": [
"192.168.1.0/24",
"10.0.0.0/8"
]
}
}
```
### Workspace Layout
PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`):

View file

@ -98,6 +98,10 @@ type asyncTask struct {
cancel context.CancelFunc
}
// HTTPMiddleware wraps an HTTP handler and may return an error if middleware
// configuration is invalid.
type HTTPMiddleware func(http.Handler) (http.Handler, error)
// RecordPlaceholder registers a placeholder message for later editing.
// Implements PlaceholderRecorder.
func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
@ -453,7 +457,7 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
// SetupHTTPServer creates a shared HTTP server with the given listen address.
// It registers health endpoints from the health server and discovers channels
// that implement WebhookHandler and/or HealthChecker to register their handlers.
func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) {
func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server, middlewares ...HTTPMiddleware) error {
m.mux = newDynamicServeMux()
// Register health endpoints
@ -464,12 +468,26 @@ func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) {
// Discover and register webhook handlers and health checkers
m.registerHTTPHandlersLocked()
handler := http.Handler(m.mux)
for idx, mw := range middlewares {
if mw == nil {
continue
}
wrapped, err := mw(handler)
if err != nil {
return fmt.Errorf("apply HTTP middleware #%d: %w", idx, err)
}
handler = wrapped
}
m.httpServer = &http.Server{
Addr: addr,
Handler: m.mux,
Handler: handler,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
return nil
}
// registerHTTPHandlersLocked registers webhook and health-check handlers for

View file

@ -363,10 +363,11 @@ func DefaultConfig() *Config {
},
},
Gateway: GatewayConfig{
Host: "127.0.0.1",
Port: 18790,
HotReload: false,
LogLevel: DefaultGatewayLogLevel,
Host: "127.0.0.1",
Port: 18790,
HotReload: false,
LogLevel: DefaultGatewayLogLevel,
AllowedCIDRs: nil,
},
Tools: ToolsConfig{
FilterSensitiveData: true,

View file

@ -10,10 +10,11 @@ import (
const DefaultGatewayLogLevel = "warn"
type GatewayConfig struct {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
}
func canonicalGatewayLogLevel(level logger.LogLevel) string {

View file

@ -3,10 +3,12 @@ package gateway
import (
"context"
"fmt"
"net"
"os"
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
@ -66,6 +68,9 @@ type services struct {
DeviceService *devices.Service
HealthServer *health.Server
VoiceAgentCancel context.CancelFunc
ListenHost string
ListenAddr string
EffectiveCIDRs []string
manualReloadChan chan struct{}
reloading atomic.Bool
authToken string
@ -187,6 +192,19 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
return err
}
if pidData.Host != runningServices.ListenHost || pidData.Port != cfg.Gateway.Port {
updatedPidData, updateErr := pid.UpdatePidFileEndpoint(homePath, runningServices.ListenHost, cfg.Gateway.Port)
if updateErr != nil {
logger.WarnCF("gateway", "Failed to sync pid listen endpoint", map[string]any{
"error": updateErr.Error(),
"listen_host": runningServices.ListenHost,
"listen_port": cfg.Gateway.Port,
})
} else {
pidData = updatedPidData
}
}
// Setup manual reload channel for /reload endpoint
manualReloadChan := make(chan struct{}, 1)
runningServices.manualReloadChan = manualReloadChan
@ -206,7 +224,11 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
runningServices.HealthServer.SetReloadFunc(reloadTrigger)
agentLoop.SetReloadFunc(reloadTrigger)
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
if isWildcardBindHost(runningServices.ListenHost) {
fmt.Printf("✓ Gateway started (all interfaces, port %d)\n", pidData.Port)
} else {
fmt.Printf("✓ Gateway started on %s\n", runningServices.ListenAddr)
}
fmt.Println("Press Ctrl+C to stop")
ctx, cancel := context.WithCancel(context.Background())
@ -267,6 +289,9 @@ func preCheckConfig(cfg *config.Config) error {
if cfg.Gateway.Port <= 0 || cfg.Gateway.Port > 65535 {
return fmt.Errorf("invalid gateway port: %d, port must be between 1 and 65535", cfg.Gateway.Port)
}
if _, err := normalizeAndValidateCIDRs(cfg.Gateway.AllowedCIDRs); err != nil {
return fmt.Errorf("invalid gateway allowed_cidrs: %w", err)
}
return nil
}
@ -379,10 +404,39 @@ func setupAndStartServices(
fmt.Println("⚠ Warning: No channels enabled")
}
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
listenDecision, err := resolveGatewayListenDecision(cfg.Gateway.Host, cfg.Gateway.Port, cfg.Gateway.AllowedCIDRs)
if err != nil {
if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
fms.Stop()
}
return nil, fmt.Errorf("error resolving gateway listen host: %w", err)
}
if listenDecision.AutoFallback {
logger.WarnCF("gateway", "Loopback bind failed, fallback to all interfaces with CIDR allowlist", map[string]any{
"configured_host": cfg.Gateway.Host,
"bind_host": listenDecision.BindHost,
"port": cfg.Gateway.Port,
"allowed_cidrs": listenDecision.AllowedCIDRs,
"reason": listenDecision.FallbackReason,
})
}
addr := net.JoinHostPort(listenDecision.BindHost, strconv.Itoa(cfg.Gateway.Port))
runningServices.ListenHost = listenDecision.BindHost
runningServices.ListenAddr = addr
runningServices.EffectiveCIDRs = listenDecision.AllowedCIDRs
runningServices.authToken = authToken
runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port, authToken)
runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
runningServices.HealthServer = health.NewServer(listenDecision.BindHost, cfg.Gateway.Port, authToken)
if err = runningServices.ChannelManager.SetupHTTPServer(
addr,
runningServices.HealthServer,
newCIDRAllowlistMiddleware(listenDecision.AllowedCIDRs),
); err != nil {
if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
fms.Stop()
}
return nil, fmt.Errorf("error setting up shared HTTP server: %w", err)
}
if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil {
return nil, fmt.Errorf("error starting channels: %w", err)
@ -398,11 +452,20 @@ func setupAndStartServices(
voiceAgent.Start(vaCtx)
}
fmt.Printf(
"✓ Health endpoints available at http://%s:%d/health, /ready and /reload (POST)\n",
cfg.Gateway.Host,
cfg.Gateway.Port,
)
if isWildcardBindHost(runningServices.ListenHost) {
fmt.Printf(
"✓ Health endpoints available on all interfaces at port %d (/health, /ready and /reload POST)\n",
cfg.Gateway.Port,
)
} else {
fmt.Printf(
"✓ Health endpoints available at http://%s/health, /ready and /reload (POST)\n",
runningServices.ListenAddr,
)
}
if len(runningServices.EffectiveCIDRs) > 0 {
fmt.Printf("✓ Gateway CIDR allowlist enabled: %s\n", strings.Join(runningServices.EffectiveCIDRs, ", "))
}
stateManager := state.NewManager(cfg.WorkspacePath())
runningServices.DeviceService = devices.NewService(devices.Config{

View file

@ -0,0 +1,394 @@
package gateway
import (
"errors"
"fmt"
"net"
"net/http"
"sort"
"strconv"
"strings"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/netpolicy"
)
const (
gatewayDefaultLoopbackHost = "127.0.0.1"
gatewayFallbackBindHostV4 = "0.0.0.0"
gatewayFallbackBindHostV6 = "::"
)
type gatewayListenDecision struct {
BindHost string
AllowedCIDRs []string
AutoFallback bool
FallbackReason string
}
var (
probeGatewayBind = probeTCPBind
discoverGatewayCIDRs = discoverLocalInterfaceCIDRs
fallbackPrivateCIDRs = mustParseCIDRs(
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"100.64.0.0/10",
"fc00::/7",
)
)
func resolveGatewayListenDecision(
configuredHost string,
port int,
configuredCIDRs []string,
) (*gatewayListenDecision, error) {
host := strings.TrimSpace(configuredHost)
if host == "" {
host = gatewayDefaultLoopbackHost
}
normalizedCIDRs, err := normalizeAndValidateCIDRs(configuredCIDRs)
if err != nil {
return nil, fmt.Errorf("invalid gateway allowed_cidrs: %w", err)
}
bindErr := probeGatewayBind(host, port)
if bindErr == nil {
return &gatewayListenDecision{
BindHost: host,
AllowedCIDRs: normalizedCIDRs,
}, nil
}
if !isLoopbackHost(host) {
return nil, fmt.Errorf("bind %s:%d failed: %w", host, port, bindErr)
}
// Before widening exposure via wildcard bind, try other common loopback hosts.
for _, altHost := range alternativeLoopbackHosts(host) {
if err := probeGatewayBind(altHost, port); err == nil {
return &gatewayListenDecision{
BindHost: altHost,
AllowedCIDRs: normalizedCIDRs,
AutoFallback: true,
FallbackReason: fmt.Sprintf(
"loopback bind %s:%d failed, fallback to loopback host %s:%d",
host,
port,
altHost,
port,
),
}, nil
}
}
var discoveredCIDRs []string
if len(normalizedCIDRs) == 0 {
var discoverErr error
discoveredCIDRs, discoverErr = discoverGatewayCIDRs()
if discoverErr != nil {
return nil, fmt.Errorf(
"loopback bind %s:%d failed: %w; interface discovery failed: %v",
host,
port,
bindErr,
discoverErr,
)
}
if len(discoveredCIDRs) == 0 {
return nil, fmt.Errorf(
"loopback bind %s:%d failed: %w; no private non-loopback interface CIDRs discovered",
host,
port,
bindErr,
)
}
}
fallbackCIDRs := normalizedCIDRs
if len(fallbackCIDRs) == 0 {
fallbackCIDRs = discoveredCIDRs
}
if len(fallbackCIDRs) == 0 {
return nil, fmt.Errorf("loopback bind %s:%d failed: %w; fallback allowlist is empty", host, port, bindErr)
}
fallbackCandidates := fallbackBindCandidates(host)
fallbackHost, fallbackBindErr := probeFallbackBindCandidates(fallbackCandidates, port)
if fallbackBindErr != nil {
return nil, fmt.Errorf(
"loopback bind %s:%d failed: %w; fallback bind candidates %v failed: %v",
host,
port,
bindErr,
fallbackCandidates,
fallbackBindErr,
)
}
return &gatewayListenDecision{
BindHost: fallbackHost,
AllowedCIDRs: fallbackCIDRs,
AutoFallback: true,
FallbackReason: fmt.Sprintf(
"loopback bind %s:%d failed, fallback to %s:%d with CIDR allowlist",
host,
port,
fallbackHost,
port,
),
}, nil
}
func alternativeLoopbackHosts(configuredHost string) []string {
configured := strings.TrimSpace(strings.ToLower(configuredHost))
candidates := []string{"127.0.0.1", "::1", "localhost"}
out := make([]string, 0, len(candidates))
seen := make(map[string]struct{}, len(candidates))
for _, candidate := range candidates {
normalized := strings.TrimSpace(strings.ToLower(candidate))
if normalized == configured {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
out = append(out, candidate)
}
return out
}
func fallbackBindCandidates(configuredLoopbackHost string) []string {
lower := strings.TrimSpace(strings.ToLower(configuredLoopbackHost))
if lower == "localhost" {
return []string{gatewayFallbackBindHostV6, gatewayFallbackBindHostV4}
}
ip := net.ParseIP(lower)
if ip != nil && ip.To4() == nil {
return []string{gatewayFallbackBindHostV6, gatewayFallbackBindHostV4}
}
return []string{gatewayFallbackBindHostV4, gatewayFallbackBindHostV6}
}
func probeFallbackBindCandidates(candidates []string, port int) (string, error) {
errList := make([]error, 0, len(candidates))
for _, host := range candidates {
err := probeGatewayBind(host, port)
if err == nil {
return host, nil
}
errList = append(errList, fmt.Errorf("%s:%d: %w", host, port, err))
}
if len(errList) == 0 {
return "", fmt.Errorf("no fallback bind candidates")
}
return "", errors.Join(errList...)
}
func isWildcardBindHost(host string) bool {
switch strings.TrimSpace(host) {
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
return true
default:
return false
}
}
func normalizeAndValidateCIDRs(cidrs []string) ([]string, error) {
if len(cidrs) == 0 {
return nil, nil
}
seen := make(map[string]struct{}, len(cidrs))
out := make([]string, 0, len(cidrs))
for _, raw := range cidrs {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
continue
}
_, ipNet, err := net.ParseCIDR(trimmed)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q: %w", trimmed, err)
}
canonical := ipNet.String()
if _, ok := seen[canonical]; ok {
continue
}
seen[canonical] = struct{}{}
out = append(out, canonical)
}
if len(out) == 0 {
return nil, nil
}
sort.Strings(out)
return out, nil
}
func discoverLocalInterfaceCIDRs() ([]string, error) {
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
out := make([]string, 0, len(ifaces))
seen := make(map[string]struct{})
ifaceErrs := make([]error, 0)
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
ifaceErrs = append(ifaceErrs, fmt.Errorf("%s: %w", iface.Name, err))
continue
}
for _, addr := range addrs {
ipNet := toIPNet(addr)
if ipNet == nil || ipNet.IP == nil {
continue
}
ip := ipNet.IP
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() ||
ip.IsInterfaceLocalMulticast() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() {
continue
}
mask := ipNet.Mask
if len(mask) == 0 {
continue
}
ip = normalizeIPForMask(ip, mask)
if !isSafeFallbackIP(ip) {
continue
}
masked := ip.Mask(mask)
if masked == nil {
continue
}
canonical := (&net.IPNet{IP: masked, Mask: mask}).String()
if _, ok := seen[canonical]; ok {
continue
}
seen[canonical] = struct{}{}
out = append(out, canonical)
}
}
sort.Strings(out)
if len(out) == 0 && len(ifaceErrs) > 0 {
return nil, errors.Join(ifaceErrs...)
}
return out, nil
}
func mustParseCIDRs(cidrs ...string) []*net.IPNet {
parsed := make([]*net.IPNet, 0, len(cidrs))
for _, cidr := range cidrs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
panic(fmt.Sprintf("invalid built-in fallback CIDR %q: %v", cidr, err))
}
parsed = append(parsed, ipNet)
}
return parsed
}
func isSafeFallbackIP(ip net.IP) bool {
if ip == nil {
return false
}
for _, ipNet := range fallbackPrivateCIDRs {
if ipNet.Contains(ip) {
return true
}
}
return false
}
func normalizeIPForMask(ip net.IP, mask net.IPMask) net.IP {
switch len(mask) {
case net.IPv4len:
if ip4 := ip.To4(); ip4 != nil {
return ip4
}
case net.IPv6len:
if ip16 := ip.To16(); ip16 != nil {
return ip16
}
}
return ip
}
func toIPNet(addr net.Addr) *net.IPNet {
switch v := addr.(type) {
case *net.IPNet:
return v
case *net.IPAddr:
if v.IP == nil {
return nil
}
bits := 128
if v.IP.To4() != nil {
bits = 32
}
return &net.IPNet{IP: v.IP, Mask: net.CIDRMask(bits, bits)}
default:
return nil
}
}
func probeTCPBind(host string, port int) error {
addr := net.JoinHostPort(host, strconv.Itoa(port))
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
_ = ln.Close()
return nil
}
func isLoopbackHost(host string) bool {
normalized := strings.TrimSpace(strings.ToLower(host))
if normalized == "localhost" {
return true
}
ip := net.ParseIP(normalized)
return ip != nil && ip.IsLoopback()
}
func newCIDRAllowlistMiddleware(allowedCIDRs []string) channels.HTTPMiddleware {
effectiveCIDRs := append([]string(nil), allowedCIDRs...)
return func(next http.Handler) (http.Handler, error) {
allowlist, err := netpolicy.NewIPAllowlist(effectiveCIDRs)
if err != nil {
return nil, err
}
if allowlist.IsOpen() {
return next, nil
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Loopback is always allowed for local administration.
// When deployed behind a local reverse proxy/tunnel, forwarded
// external traffic may still appear as loopback at this layer.
if allowlist.AllowsRemoteAddr(r.RemoteAddr) {
next.ServeHTTP(w, r)
return
}
http.Error(w, "Forbidden", http.StatusForbidden)
}), nil
}
}

View file

@ -0,0 +1,410 @@
package gateway
import (
"errors"
"net"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
func TestNormalizeAndValidateCIDRs(t *testing.T) {
got, err := normalizeAndValidateCIDRs([]string{" 192.168.1.20/24 ", "10.0.0.0/8", "192.168.1.0/24"})
if err != nil {
t.Fatalf("normalizeAndValidateCIDRs() error = %v", err)
}
want := []string{"10.0.0.0/8", "192.168.1.0/24"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("normalizeAndValidateCIDRs() = %v, want %v", got, want)
}
}
func TestNormalizeAndValidateCIDRsInvalid(t *testing.T) {
_, err := normalizeAndValidateCIDRs([]string{"bad-cidr"})
if err == nil {
t.Fatal("normalizeAndValidateCIDRs() expected error for invalid CIDR")
}
}
func TestResolveGatewayListenDecisionLoopbackFallbackUsesConfiguredCIDRs(t *testing.T) {
origProbe := probeGatewayBind
origDiscover := discoverGatewayCIDRs
t.Cleanup(func() {
probeGatewayBind = origProbe
discoverGatewayCIDRs = origDiscover
})
discoverCalled := false
probeGatewayBind = func(host string, _ int) error {
switch host {
case "127.0.0.1":
return errors.New("loopback unavailable")
case "::1", "localhost":
return errors.New("alternative loopback unavailable")
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
return nil
default:
return nil
}
}
discoverGatewayCIDRs = func() ([]string, error) {
discoverCalled = true
return nil, errors.New("must not be called when configured CIDRs are provided")
}
decision, err := resolveGatewayListenDecision("127.0.0.1", 18790, []string{"10.0.0.0/8"})
if err != nil {
t.Fatalf("resolveGatewayListenDecision() error = %v", err)
}
if !decision.AutoFallback {
t.Fatal("decision.AutoFallback = false, want true")
}
if decision.BindHost != gatewayFallbackBindHostV4 {
t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, gatewayFallbackBindHostV4)
}
wantCIDRs := []string{"10.0.0.0/8"}
if !reflect.DeepEqual(decision.AllowedCIDRs, wantCIDRs) {
t.Fatalf("decision.AllowedCIDRs = %v, want %v", decision.AllowedCIDRs, wantCIDRs)
}
if discoverCalled {
t.Fatal("discoverGatewayCIDRs() called unexpectedly")
}
}
func TestResolveGatewayListenDecisionLoopbackFallbackUsesDiscoveredCIDRs(t *testing.T) {
origProbe := probeGatewayBind
origDiscover := discoverGatewayCIDRs
t.Cleanup(func() {
probeGatewayBind = origProbe
discoverGatewayCIDRs = origDiscover
})
probeGatewayBind = func(host string, _ int) error {
switch host {
case "localhost":
return errors.New("loopback unavailable")
case "127.0.0.1", "::1":
return errors.New("alternative loopback unavailable")
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
return nil
default:
return nil
}
}
discoverGatewayCIDRs = func() ([]string, error) {
return []string{"192.168.1.0/24", "10.0.0.0/8"}, nil
}
decision, err := resolveGatewayListenDecision("localhost", 18790, nil)
if err != nil {
t.Fatalf("resolveGatewayListenDecision() error = %v", err)
}
if !decision.AutoFallback {
t.Fatal("decision.AutoFallback = false, want true")
}
wantCIDRs := []string{"192.168.1.0/24", "10.0.0.0/8"}
if !reflect.DeepEqual(decision.AllowedCIDRs, wantCIDRs) {
t.Fatalf("decision.AllowedCIDRs = %v, want %v", decision.AllowedCIDRs, wantCIDRs)
}
}
func TestResolveGatewayListenDecisionNonLoopbackFailure(t *testing.T) {
origProbe := probeGatewayBind
origDiscover := discoverGatewayCIDRs
t.Cleanup(func() {
probeGatewayBind = origProbe
discoverGatewayCIDRs = origDiscover
})
probeGatewayBind = func(host string, _ int) error {
if host == "192.0.2.1" {
return errors.New("cannot assign requested address")
}
return nil
}
discoverGatewayCIDRs = func() ([]string, error) {
return []string{"10.0.0.0/8"}, nil
}
_, err := resolveGatewayListenDecision("192.0.2.1", 18790, nil)
if err == nil {
t.Fatal("resolveGatewayListenDecision() expected error")
}
if !strings.Contains(err.Error(), "bind 192.0.2.1:18790 failed") {
t.Fatalf("error = %q, want bind failure", err.Error())
}
}
func TestResolveGatewayListenDecisionLoopbackFailureNoDiscoveredCIDRs(t *testing.T) {
origProbe := probeGatewayBind
origDiscover := discoverGatewayCIDRs
t.Cleanup(func() {
probeGatewayBind = origProbe
discoverGatewayCIDRs = origDiscover
})
probeGatewayBind = func(host string, _ int) error {
switch host {
case "127.0.0.1":
return errors.New("loopback unavailable")
case "::1", "localhost":
return errors.New("alternative loopback unavailable")
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
return nil
default:
return nil
}
}
discoverGatewayCIDRs = func() ([]string, error) {
return nil, nil
}
_, err := resolveGatewayListenDecision("127.0.0.1", 18790, nil)
if err == nil {
t.Fatal("resolveGatewayListenDecision() expected error")
}
if !strings.Contains(err.Error(), "no private non-loopback interface CIDRs discovered") {
t.Fatalf("error = %q, want no-interface-cidr failure", err.Error())
}
}
func TestResolveGatewayListenDecisionLoopbackFallbackIPv6Preferred(t *testing.T) {
origProbe := probeGatewayBind
origDiscover := discoverGatewayCIDRs
t.Cleanup(func() {
probeGatewayBind = origProbe
discoverGatewayCIDRs = origDiscover
})
probeGatewayBind = func(host string, _ int) error {
switch host {
case "::1":
return errors.New("loopback unavailable")
case "127.0.0.1", "localhost":
return errors.New("alternative loopback unavailable")
case gatewayFallbackBindHostV6:
return nil
case gatewayFallbackBindHostV4:
return errors.New("should not probe IPv4 when IPv6 fallback succeeds")
default:
return nil
}
}
discoverGatewayCIDRs = func() ([]string, error) {
return []string{"192.168.1.0/24"}, nil
}
decision, err := resolveGatewayListenDecision("::1", 18790, nil)
if err != nil {
t.Fatalf("resolveGatewayListenDecision() error = %v", err)
}
if decision.BindHost != gatewayFallbackBindHostV6 {
t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, gatewayFallbackBindHostV6)
}
}
func TestResolveGatewayListenDecisionLoopbackFallbackTriesSecondaryWildcard(t *testing.T) {
origProbe := probeGatewayBind
origDiscover := discoverGatewayCIDRs
t.Cleanup(func() {
probeGatewayBind = origProbe
discoverGatewayCIDRs = origDiscover
})
probeGatewayBind = func(host string, _ int) error {
switch host {
case "::1":
return errors.New("loopback unavailable")
case "127.0.0.1", "localhost":
return errors.New("alternative loopback unavailable")
case gatewayFallbackBindHostV6:
return errors.New("ipv6 wildcard unavailable")
case gatewayFallbackBindHostV4:
return nil
default:
return nil
}
}
discoverGatewayCIDRs = func() ([]string, error) {
return []string{"192.168.1.0/24"}, nil
}
decision, err := resolveGatewayListenDecision("::1", 18790, nil)
if err != nil {
t.Fatalf("resolveGatewayListenDecision() error = %v", err)
}
if decision.BindHost != gatewayFallbackBindHostV4 {
t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, gatewayFallbackBindHostV4)
}
}
func TestResolveGatewayListenDecisionLoopbackUsesAlternativeBeforeWildcard(t *testing.T) {
origProbe := probeGatewayBind
origDiscover := discoverGatewayCIDRs
t.Cleanup(func() {
probeGatewayBind = origProbe
discoverGatewayCIDRs = origDiscover
})
discoverCalled := false
probeGatewayBind = func(host string, _ int) error {
switch host {
case "127.0.0.1":
return errors.New("configured loopback unavailable")
case "::1":
return nil
case "localhost", gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
return errors.New("must not be probed after alternative loopback succeeds")
default:
return nil
}
}
discoverGatewayCIDRs = func() ([]string, error) {
discoverCalled = true
return nil, errors.New("must not be called when alternative loopback succeeds")
}
decision, err := resolveGatewayListenDecision("127.0.0.1", 18790, nil)
if err != nil {
t.Fatalf("resolveGatewayListenDecision() error = %v", err)
}
if decision.BindHost != "::1" {
t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, "::1")
}
if len(decision.AllowedCIDRs) != 0 {
t.Fatalf("decision.AllowedCIDRs = %v, want empty", decision.AllowedCIDRs)
}
if discoverCalled {
t.Fatal("discoverGatewayCIDRs() called unexpectedly")
}
}
func TestCIDRAllowlistMiddleware(t *testing.T) {
mw := newCIDRAllowlistMiddleware([]string{"192.168.1.0/24"})
h, err := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
if err != nil {
t.Fatalf("middleware error = %v", err)
}
tests := []struct {
name string
remoteAddr string
wantStatus int
}{
{name: "inside cidr", remoteAddr: "192.168.1.99:1234", wantStatus: http.StatusOK},
{name: "loopback", remoteAddr: "127.0.0.1:1234", wantStatus: http.StatusOK},
{name: "outside cidr", remoteAddr: "10.0.0.7:1234", wantStatus: http.StatusForbidden},
{name: "malformed", remoteAddr: "not-an-ip", wantStatus: http.StatusForbidden},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
req.RemoteAddr = tt.remoteAddr
h.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d", rec.Code, tt.wantStatus)
}
})
}
}
func TestCIDRAllowlistMiddlewareInvalidCIDR(t *testing.T) {
mw := newCIDRAllowlistMiddleware([]string{"bad-cidr"})
_, err := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
if err == nil {
t.Fatal("middleware expected error for invalid CIDR")
}
}
func TestNormalizeIPForMaskIPv4MappedIPv6(t *testing.T) {
ip := net.ParseIP("::ffff:192.168.10.20")
mask := net.CIDRMask(24, 32)
normalized := normalizeIPForMask(ip, mask)
if got := normalized.String(); got != "192.168.10.20" {
t.Fatalf("normalizeIPForMask() = %q, want %q", got, "192.168.10.20")
}
masked := normalized.Mask(mask)
if got := (&net.IPNet{IP: masked, Mask: mask}).String(); got != "192.168.10.0/24" {
t.Fatalf("masked network = %q, want %q", got, "192.168.10.0/24")
}
}
func TestFallbackBindCandidates(t *testing.T) {
tests := []struct {
name string
host string
want []string
}{
{
name: "ipv4 loopback",
host: "127.0.0.1",
want: []string{gatewayFallbackBindHostV4, gatewayFallbackBindHostV6},
},
{name: "ipv6 loopback", host: "::1", want: []string{gatewayFallbackBindHostV6, gatewayFallbackBindHostV4}},
{name: "localhost", host: "localhost", want: []string{gatewayFallbackBindHostV6, gatewayFallbackBindHostV4}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := fallbackBindCandidates(tt.host)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("fallbackBindCandidates() = %v, want %v", got, tt.want)
}
})
}
}
func TestAlternativeLoopbackHosts(t *testing.T) {
tests := []struct {
name string
host string
want []string
}{
{name: "configured ipv4", host: "127.0.0.1", want: []string{"::1", "localhost"}},
{name: "configured ipv6", host: "::1", want: []string{"127.0.0.1", "localhost"}},
{name: "configured localhost", host: "localhost", want: []string{"127.0.0.1", "::1"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := alternativeLoopbackHosts(tt.host)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("alternativeLoopbackHosts() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsSafeFallbackIP(t *testing.T) {
tests := []struct {
name string
ip string
want bool
}{
{name: "rfc1918 a", ip: "10.1.2.3", want: true},
{name: "rfc1918 b", ip: "172.20.1.1", want: true},
{name: "rfc1918 c", ip: "192.168.1.1", want: true},
{name: "cgnat", ip: "100.64.2.3", want: true},
{name: "ipv6 ula", ip: "fd12::1", want: true},
{name: "public ipv4", ip: "8.8.8.8", want: false},
{name: "public ipv6", ip: "2001:4860:4860::8888", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ip := net.ParseIP(tt.ip)
if got := isSafeFallbackIP(ip); got != tt.want {
t.Fatalf("isSafeFallbackIP(%q) = %v, want %v", tt.ip, got, tt.want)
}
})
}
}

View file

@ -0,0 +1,97 @@
package netpolicy
import (
"fmt"
"net"
"strings"
)
// IPAllowlist evaluates whether a remote address is allowed by CIDR policy.
// Loopback addresses are always allowed for local administration.
type IPAllowlist struct {
nets []*net.IPNet
}
// NewIPAllowlist parses CIDR rules and constructs an allowlist checker.
// Empty CIDR list means unrestricted policy.
func NewIPAllowlist(allowedCIDRs []string) (*IPAllowlist, error) {
if len(allowedCIDRs) == 0 {
return &IPAllowlist{}, nil
}
seen := make(map[string]struct{}, len(allowedCIDRs))
nets := make([]*net.IPNet, 0, len(allowedCIDRs))
for _, rawCIDR := range allowedCIDRs {
cidr := strings.TrimSpace(rawCIDR)
if cidr == "" {
continue
}
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err)
}
canonical := ipNet.String()
if _, ok := seen[canonical]; ok {
continue
}
seen[canonical] = struct{}{}
nets = append(nets, ipNet)
}
if len(nets) == 0 {
return &IPAllowlist{}, nil
}
return &IPAllowlist{nets: nets}, nil
}
// IsOpen reports whether the allowlist has no restrictions.
func (a *IPAllowlist) IsOpen() bool {
return a == nil || len(a.nets) == 0
}
// AllowsRemoteAddr checks whether RemoteAddr is permitted.
func (a *IPAllowlist) AllowsRemoteAddr(remoteAddr string) bool {
if a.IsOpen() {
return true
}
ip := ClientIPFromRemoteAddr(remoteAddr)
return a.AllowsIP(ip)
}
// AllowsIP checks whether an IP is permitted.
func (a *IPAllowlist) AllowsIP(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsLoopback() {
return true
}
if a.IsOpen() {
return true
}
for _, ipNet := range a.nets {
if ipNet.Contains(ip) {
return true
}
}
return false
}
// ClientIPFromRemoteAddr parses the IP component from net/http RemoteAddr.
func ClientIPFromRemoteAddr(remoteAddr string) net.IP {
host := strings.TrimSpace(remoteAddr)
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
// Strip IPv6 zone identifier (for example: fe80::1%eth0).
if i := strings.LastIndex(host, "%"); i != -1 {
host = host[:i]
}
return net.ParseIP(strings.TrimSpace(host))
}

View file

@ -0,0 +1,110 @@
package netpolicy
import (
"testing"
)
func TestIPAllowlistOpenPolicy(t *testing.T) {
allowlist, err := NewIPAllowlist(nil)
if err != nil {
t.Fatalf("NewIPAllowlist() error = %v", err)
}
if !allowlist.IsOpen() {
t.Fatal("allowlist should be open for empty CIDRs")
}
if !allowlist.AllowsRemoteAddr("203.0.113.7:1234") {
t.Fatal("open policy should allow any remote address")
}
}
func TestIPAllowlistAllowsInsideCIDR(t *testing.T) {
allowlist, err := NewIPAllowlist([]string{"192.168.1.0/24"})
if err != nil {
t.Fatalf("NewIPAllowlist() error = %v", err)
}
if !allowlist.AllowsRemoteAddr("192.168.1.8:1234") {
t.Fatal("allowlist should allow address inside CIDR")
}
if allowlist.AllowsRemoteAddr("10.0.0.8:1234") {
t.Fatal("allowlist should reject address outside CIDR")
}
}
func TestIPAllowlistAlwaysAllowsLoopback(t *testing.T) {
allowlist, err := NewIPAllowlist([]string{"192.168.1.0/24"})
if err != nil {
t.Fatalf("NewIPAllowlist() error = %v", err)
}
if !allowlist.AllowsRemoteAddr("127.0.0.1:1234") {
t.Fatal("loopback should always be allowed")
}
}
func TestClientIPFromRemoteAddrIPv6Zone(t *testing.T) {
ip := ClientIPFromRemoteAddr("[fe80::1%eth0]:1234")
if ip == nil {
t.Fatal("ClientIPFromRemoteAddr() returned nil")
}
if got := ip.String(); got != "fe80::1" {
t.Fatalf("ClientIPFromRemoteAddr() = %q, want %q", got, "fe80::1")
}
}
func TestNewIPAllowlistInvalidCIDR(t *testing.T) {
_, err := NewIPAllowlist([]string{"bad-cidr"})
if err == nil {
t.Fatal("NewIPAllowlist() expected error for invalid CIDR")
}
}
func TestIPAllowlistWithZoneAddressInCIDR(t *testing.T) {
allowlist, err := NewIPAllowlist([]string{"fe80::/10"})
if err != nil {
t.Fatalf("NewIPAllowlist() error = %v", err)
}
if !allowlist.AllowsRemoteAddr("[fe80::2%eth0]:1234") {
t.Fatal("allowlist should accept IPv6 link-local with zone")
}
}
func TestNewIPAllowlistTrimsSkipsAndDedups(t *testing.T) {
allowlist, err := NewIPAllowlist([]string{
" 192.168.1.8/24 ",
"",
"192.168.1.0/24",
" ",
"10.0.0.0/8",
})
if err != nil {
t.Fatalf("NewIPAllowlist() error = %v", err)
}
if allowlist.IsOpen() {
t.Fatal("allowlist should not be open")
}
if len(allowlist.nets) != 2 {
t.Fatalf("len(allowlist.nets) = %d, want 2", len(allowlist.nets))
}
if !allowlist.AllowsRemoteAddr("192.168.1.22:1234") {
t.Fatal("allowlist should allow deduplicated 192.168.1.0/24 CIDR")
}
if !allowlist.AllowsRemoteAddr("10.9.8.7:1234") {
t.Fatal("allowlist should allow 10.0.0.0/8 CIDR")
}
if allowlist.AllowsRemoteAddr("203.0.113.7:1234") {
t.Fatal("allowlist should reject outside CIDR")
}
}
func TestNewIPAllowlistAllEmptyEntries(t *testing.T) {
allowlist, err := NewIPAllowlist([]string{"", " ", "\t"})
if err != nil {
t.Fatalf("NewIPAllowlist() error = %v", err)
}
if !allowlist.IsOpen() {
t.Fatal("allowlist should be open when all entries are empty")
}
}

View file

@ -102,6 +102,41 @@ func WritePidFile(homePath, host string, port int) (*PidFileData, error) {
return data, nil
}
// UpdatePidFileEndpoint updates host/port in the current process pid file
// without rotating the auth token.
func UpdatePidFileEndpoint(homePath, host string, port int) (*PidFileData, error) {
pidMu.Lock()
defer pidMu.Unlock()
pidPath := pidFilePath(homePath)
data, err := readPidFileUnlocked(pidPath)
if err != nil {
return nil, fmt.Errorf("failed to read pid file: %w", err)
}
if data.PID != os.Getpid() {
return nil, fmt.Errorf("pid file belongs to another process: %d", data.PID)
}
data.Host = host
data.Port = port
raw, err := json.MarshalIndent(data, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal pid file: %w", err)
}
tmp := pidPath + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return nil, fmt.Errorf("failed to write pid file: %w", err)
}
if err := os.Rename(tmp, pidPath); err != nil {
os.Remove(tmp)
return nil, fmt.Errorf("failed to rename pid file: %w", err)
}
return data, nil
}
// ReadPidFileWithCheck reads the PID file and additionally checks if
// the recorded process is still alive. Returns nil if the file is
// missing, unreadable, or the process has exited.

View file

@ -120,6 +120,42 @@ func TestWritePidFileOverwrite(t *testing.T) {
}
}
func TestUpdatePidFileEndpoint(t *testing.T) {
dir := tmpDir(t)
data, err := WritePidFile(dir, "127.0.0.1", 18790)
if err != nil {
t.Fatalf("WritePidFile failed: %v", err)
}
updated, err := UpdatePidFileEndpoint(dir, "0.0.0.0", 18888)
if err != nil {
t.Fatalf("UpdatePidFileEndpoint failed: %v", err)
}
if updated.Host != "0.0.0.0" {
t.Fatalf("updated.Host = %q, want %q", updated.Host, "0.0.0.0")
}
if updated.Port != 18888 {
t.Fatalf("updated.Port = %d, want 18888", updated.Port)
}
if updated.Token != data.Token {
t.Fatal("UpdatePidFileEndpoint must not rotate token")
}
}
func TestUpdatePidFileEndpointDifferentPID(t *testing.T) {
dir := tmpDir(t)
other := PidFileData{PID: 99999999, Token: "deadbeef12345678deadbeef12345678", Host: "127.0.0.1", Port: 18790}
raw, _ := json.MarshalIndent(other, "", " ")
os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600)
_, err := UpdatePidFileEndpoint(dir, "0.0.0.0", 18888)
if err == nil {
t.Fatal("expected error when pid file belongs to another process")
}
}
// TestWritePidFileStalePID writes a PID file with a non-running PID, then
// verifies WritePidFile cleans it up and writes a new one.
func TestWritePidFileStalePID(t *testing.T) {

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"regexp"
"strings"
@ -280,6 +281,32 @@ func validateConfig(cfg *config.Config) []string {
errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port))
}
// Normalize and validate gateway allowed CIDRs:
// - trim whitespace
// - drop empty entries
// - canonicalize via ipNet.String()
// - deduplicate canonical CIDRs
normalizedCIDRs := make([]string, 0, len(cfg.Gateway.AllowedCIDRs))
seenCIDRs := make(map[string]struct{}, len(cfg.Gateway.AllowedCIDRs))
for index, cidr := range cfg.Gateway.AllowedCIDRs {
trimmed := strings.TrimSpace(cidr)
if trimmed == "" {
continue
}
_, ipNet, err := net.ParseCIDR(trimmed)
if err != nil {
errs = append(errs, fmt.Sprintf("gateway.allowed_cidrs[%d] is not a valid CIDR: %v", index, err))
continue
}
canonical := ipNet.String()
if _, exists := seenCIDRs[canonical]; exists {
continue
}
seenCIDRs[canonical] = struct{}{}
normalizedCIDRs = append(normalizedCIDRs, canonical)
}
cfg.Gateway.AllowedCIDRs = normalizedCIDRs
// Pico channel: token required when enabled
if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token.String() == "" {
errs = append(errs, "channels.pico.token is required when pico channel is enabled")

View file

@ -173,6 +173,68 @@ func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *tes
}
}
func TestHandlePatchConfig_RejectsInvalidGatewayAllowedCIDRs(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
"gateway": {
"allowed_cidrs": ["bad-cidr"]
}
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("gateway.allowed_cidrs")) {
t.Fatalf("expected validation error mentioning gateway.allowed_cidrs, body=%s", rec.Body.String())
}
}
func TestHandlePatchConfig_NormalizesGatewayAllowedCIDRs(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
"gateway": {
"allowed_cidrs": [" 192.168.1.20/24 ", "", "192.168.1.0/24", " 10.0.0.0/8 ", " "]
}
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
want := []string{"192.168.1.0/24", "10.0.0.0/8"}
if len(cfg.Gateway.AllowedCIDRs) != len(want) {
t.Fatalf("len(gateway.allowed_cidrs) = %d, want %d", len(cfg.Gateway.AllowedCIDRs), len(want))
}
for i, cidr := range want {
if cfg.Gateway.AllowedCIDRs[i] != cidr {
t.Fatalf("gateway.allowed_cidrs[%d] = %q, want %q", i, cfg.Gateway.AllowedCIDRs[i], cidr)
}
}
}
// setupPicoEnabledEnv creates a test environment with Pico channel enabled and
// its token stored only in .security.yml (not in the JSON payload).
func setupPicoEnabledEnv(t *testing.T) (string, func()) {

View file

@ -1,58 +1,35 @@
package middleware
import (
"fmt"
"net"
"net/http"
"strings"
"github.com/sipeed/picoclaw/pkg/netpolicy"
)
// IPAllowlist restricts access to requests from configured CIDR ranges.
// Loopback addresses are always allowed for local administration.
// Empty CIDR list means no restriction.
func IPAllowlist(allowedCIDRs []string, next http.Handler) (http.Handler, error) {
if len(allowedCIDRs) == 0 {
allowlist, err := netpolicy.NewIPAllowlist(allowedCIDRs)
if err != nil {
return nil, err
}
if allowlist.IsOpen() {
return next, nil
}
nets := make([]*net.IPNet, 0, len(allowedCIDRs))
for _, cidr := range allowedCIDRs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err)
}
nets = append(nets, ipNet)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := clientIPFromRemoteAddr(r.RemoteAddr)
if ip == nil {
rejectByPolicy(w, r)
return
}
if ip.IsLoopback() {
if allowlist.AllowsRemoteAddr(r.RemoteAddr) {
next.ServeHTTP(w, r)
return
}
for _, ipNet := range nets {
if ipNet.Contains(ip) {
next.ServeHTTP(w, r)
return
}
}
rejectByPolicy(w, r)
}), nil
}
func clientIPFromRemoteAddr(remoteAddr string) net.IP {
host := remoteAddr
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = h
}
return net.ParseIP(host)
}
func rejectByPolicy(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Type", "application/json")

View file

@ -84,3 +84,21 @@ func TestIPAllowlist_InvalidCIDR(t *testing.T) {
t.Fatal("IPAllowlist() expected error for invalid CIDR")
}
}
func TestIPAllowlist_AllowsIPv6ZoneAddress(t *testing.T) {
h, err := IPAllowlist([]string{"fe80::/10"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
if err != nil {
t.Fatalf("IPAllowlist() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "[fe80::1%eth0]:1234"
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
}