Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true,
"playwright@claude-plugins-official": true
}
}
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/caarlos0/env/v11 v11.3.1
github.com/gorilla/handlers v1.5.2
github.com/gorilla/mux v1.8.1
github.com/stretchr/testify v1.10.0
go.opentelemetry.io/contrib/instrumentation/host v0.59.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0
go.opentelemetry.io/contrib/instrumentation/runtime v0.59.0
Expand All @@ -18,6 +19,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
Expand All @@ -28,6 +30,7 @@ require (
github.com/klauspost/compress v1.17.11 // indirect
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
Expand Down Expand Up @@ -56,4 +59,5 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 // indirect
google.golang.org/grpc v1.70.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
9 changes: 9 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.0 h1:VD1gqscl4nYs1YxVuSdemTrSgTK
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.0/go.mod h1:4EgsQoS4TOhJizV+JTFg40qx1Ofh3XmXEQNBpgvNT40=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
Expand All @@ -55,6 +59,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/shirou/gopsutil/v4 v4.24.12 h1:qvePBOk20e0IKA1QXrIIU+jmk+zEiYVVx06WjBRlZo4=
github.com/shirou/gopsutil/v4 v4.24.12/go.mod h1:DCtMPAad2XceTeIAbGyVfycbYQNBGk2P8cvDi7/VN9o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
Expand Down Expand Up @@ -114,5 +120,8 @@ google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
20 changes: 18 additions & 2 deletions internal/handlers/ip.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,25 @@ func init() {
})
}

// clientIP extracts the real client IP from the request, checking
// proxy headers before falling back to the direct connection address.
func clientIP(r *http.Request) string {
if ip := r.Header.Get("X-Real-Ip"); ip != "" {
return strings.TrimSpace(ip)
}
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
// The first entry is the original client IP.
if ip, _, _ := strings.Cut(fwd, ","); ip != "" {
return strings.TrimSpace(ip)
}
}
host, _, _ := strings.Cut(r.RemoteAddr, ":")
return host
}

func RawIPHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := strings.Split(r.RemoteAddr, ":")[0]
ip := clientIP(r)

w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Incoming-IP", ip)
Expand All @@ -37,7 +53,7 @@ func RawIPHandler() http.HandlerFunc {

func IPHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := strings.Split(r.RemoteAddr, ":")[0]
ip := clientIP(r)

w.Header().Set("X-Incoming-IP", ip)

Expand Down
159 changes: 159 additions & 0 deletions internal/handlers/ip_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package handlers

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestClientIP(t *testing.T) {
tests := []struct {
name string
remoteAddr string
headers map[string]string
wantIP string
}{
{
name: "remote addr only",
remoteAddr: "203.0.113.1:12345",
wantIP: "203.0.113.1",
},
{
name: "x-real-ip takes priority",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{
"X-Real-Ip": "203.0.113.50",
"X-Forwarded-For": "198.51.100.1",
},
wantIP: "203.0.113.50",
},
{
name: "x-forwarded-for single entry",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{
"X-Forwarded-For": "203.0.113.10",
},
wantIP: "203.0.113.10",
},
{
name: "x-forwarded-for multiple entries uses first",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{
"X-Forwarded-For": "203.0.113.10, 10.0.0.2, 10.0.0.3",
},
wantIP: "203.0.113.10",
},
{
name: "x-forwarded-for with whitespace",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{
"X-Forwarded-For": " 203.0.113.10 , 10.0.0.2",
},
wantIP: "203.0.113.10",
},
{
name: "x-real-ip with whitespace",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{
"X-Real-Ip": " 203.0.113.50 ",
},
wantIP: "203.0.113.50",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = tt.remoteAddr
for k, v := range tt.headers {
r.Header.Set(k, v)
}

got := clientIP(r)
assert.Equal(t, tt.wantIP, got)
})
}
}

func TestRawIPHandler(t *testing.T) {
tests := []struct {
name string
remoteAddr string
headers map[string]string
wantIP string
}{
{
name: "returns ip as plain text",
remoteAddr: "203.0.113.1:12345",
wantIP: "203.0.113.1",
},
{
name: "respects x-forwarded-for",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{
"X-Forwarded-For": "203.0.113.10",
},
wantIP: "203.0.113.10",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = tt.remoteAddr
for k, v := range tt.headers {
r.Header.Set(k, v)
}
w := httptest.NewRecorder()

RawIPHandler().ServeHTTP(w, r)

assert.Equal(t, tt.wantIP, w.Body.String())
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
assert.Equal(t, tt.wantIP, w.Header().Get("X-Incoming-IP"))
})
}
}

func TestIPHandler(t *testing.T) {
tests := []struct {
name string
remoteAddr string
headers map[string]string
wantIP string
}{
{
name: "renders template with remote addr",
remoteAddr: "203.0.113.1:12345",
wantIP: "203.0.113.1",
},
{
name: "renders template with x-real-ip",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{
"X-Real-Ip": "203.0.113.50",
},
wantIP: "203.0.113.50",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = tt.remoteAddr
for k, v := range tt.headers {
r.Header.Set(k, v)
}
w := httptest.NewRecorder()

IPHandler().ServeHTTP(w, r)

require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, tt.wantIP, w.Header().Get("X-Incoming-IP"))
assert.Contains(t, w.Body.String(), tt.wantIP)
})
}
}
Loading
Loading