first commit

This commit is contained in:
2026-05-13 03:05:24 +03:30
commit 4c7343665a
27 changed files with 2581 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
package buildinfo
var (
Version = "dev"
Commit = "none"
Date = "unknown"
)

View File

@@ -0,0 +1,147 @@
package clientip
import (
"net"
"net/http"
"net/netip"
"strings"
)
type Resolver struct {
TrustProxyHeaders bool
TrustedProxies []netip.Prefix
}
func (r Resolver) FromRequest(req *http.Request) string {
if req == nil {
return ""
}
if r.TrustsProxyHeaders(req) {
if addr, ok := firstForwarded(headerValues(req.Header, "Forwarded")); ok {
return addr.String()
}
if addr, ok := firstForwardedFor(headerValues(req.Header, "X-Forwarded-For")); ok {
return addr.String()
}
if addr, ok := firstAddress(headerValues(req.Header, "X-Real-IP")); ok {
return addr.String()
}
}
if addr, ok := parseAddress(req.RemoteAddr); ok {
return addr.String()
}
return ""
}
func (r Resolver) TrustsProxyHeaders(req *http.Request) bool {
if !r.TrustProxyHeaders || req == nil {
return false
}
if len(r.TrustedProxies) == 0 {
return true
}
remoteAddr, ok := parseAddress(req.RemoteAddr)
if !ok {
return false
}
for _, trustedProxy := range r.TrustedProxies {
if trustedProxy.Contains(remoteAddr) {
return true
}
}
return false
}
func headerValues(header http.Header, name string) []string {
values := header.Values(name)
if len(values) > 0 {
return values
}
for key, values := range header {
if strings.EqualFold(key, name) {
return values
}
}
return nil
}
func firstForwarded(values []string) (netip.Addr, bool) {
for _, value := range values {
for _, element := range strings.Split(value, ",") {
for _, pair := range strings.Split(element, ";") {
key, rawValue, ok := strings.Cut(pair, "=")
if !ok || !strings.EqualFold(strings.TrimSpace(key), "for") {
continue
}
if addr, ok := parseAddress(rawValue); ok {
return addr, true
}
}
}
}
return netip.Addr{}, false
}
func firstForwardedFor(values []string) (netip.Addr, bool) {
for _, value := range values {
for _, part := range strings.Split(value, ",") {
if addr, ok := parseAddress(part); ok {
return addr, true
}
}
}
return netip.Addr{}, false
}
func firstAddress(values []string) (netip.Addr, bool) {
for _, value := range values {
if addr, ok := parseAddress(value); ok {
return addr, true
}
}
return netip.Addr{}, false
}
func parseAddress(value string) (netip.Addr, bool) {
value = strings.TrimSpace(value)
value = strings.Trim(value, `"`)
if addr, ok := parseBareAddress(value); ok {
return addr, true
}
host, _, err := net.SplitHostPort(value)
if err != nil {
return netip.Addr{}, false
}
return parseBareAddress(host)
}
func parseBareAddress(value string) (netip.Addr, bool) {
if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
value = strings.TrimPrefix(strings.TrimSuffix(value, "]"), "[")
}
addr, err := netip.ParseAddr(value)
if err != nil {
return netip.Addr{}, false
}
return addr.Unmap(), true
}

View File

@@ -0,0 +1,137 @@
package clientip
import (
"net/http"
"net/netip"
"testing"
)
func TestResolverUsesRemoteAddressByDefault(t *testing.T) {
req := &http.Request{
Header: http.Header{"X-Forwarded-For": {"203.0.113.10"}},
RemoteAddr: "198.51.100.20:54123",
}
got := (Resolver{}).FromRequest(req)
want := "198.51.100.20"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolverCanTrustForwardedFor(t *testing.T) {
req := &http.Request{
Header: http.Header{
"X-Forwarded-For": {"203.0.113.10, 198.51.100.20"},
},
RemoteAddr: "192.0.2.30:54123",
}
got := (Resolver{TrustProxyHeaders: true}).FromRequest(req)
want := "203.0.113.10"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolverCanTrustRFCForwardedHeader(t *testing.T) {
req := &http.Request{
Header: http.Header{
"Forwarded": {`for="203.0.113.10";proto=https;host=ip.example.com`},
},
RemoteAddr: "192.0.2.30:54123",
}
got := (Resolver{TrustProxyHeaders: true}).FromRequest(req)
want := "203.0.113.10"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolverOnlyTrustsConfiguredProxyCIDRs(t *testing.T) {
trustedProxy := netip.MustParsePrefix("127.0.0.1/32")
resolver := Resolver{
TrustProxyHeaders: true,
TrustedProxies: []netip.Prefix{trustedProxy},
}
req := &http.Request{
Header: http.Header{"X-Forwarded-For": {"203.0.113.10"}},
RemoteAddr: "198.51.100.20:54123",
}
got := resolver.FromRequest(req)
want := "198.51.100.20"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolverTrustsForwardedHeaderFromConfiguredProxyCIDR(t *testing.T) {
trustedProxy := netip.MustParsePrefix("127.0.0.1/32")
resolver := Resolver{
TrustProxyHeaders: true,
TrustedProxies: []netip.Prefix{trustedProxy},
}
req := &http.Request{
Header: http.Header{"X-Forwarded-For": {"203.0.113.10"}},
RemoteAddr: "127.0.0.1:54123",
}
got := resolver.FromRequest(req)
want := "203.0.113.10"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolverFallsBackToRealIPHeader(t *testing.T) {
req := &http.Request{
Header: http.Header{"X-Real-IP": {"203.0.113.10"}},
RemoteAddr: "192.0.2.30:54123",
}
got := (Resolver{TrustProxyHeaders: true}).FromRequest(req)
want := "203.0.113.10"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolverHandlesIPv6RemoteAddress(t *testing.T) {
req := &http.Request{
Header: http.Header{},
RemoteAddr: "[2001:db8::1]:54123",
}
got := (Resolver{}).FromRequest(req)
want := "2001:db8::1"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolverSkipsInvalidForwardedForEntries(t *testing.T) {
req := &http.Request{
Header: http.Header{
"X-Forwarded-For": {"unknown, 203.0.113.10"},
},
RemoteAddr: "192.0.2.30:54123",
}
got := (Resolver{TrustProxyHeaders: true}).FromRequest(req)
want := "203.0.113.10"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}

215
internal/config/config.go Normal file
View File

@@ -0,0 +1,215 @@
package config
import (
"fmt"
"log/slog"
"net/netip"
"os"
"strconv"
"strings"
"time"
)
const defaultTrustedProxyCIDRs = "127.0.0.1/32,::1/128"
type Config struct {
Addr string
TrustProxyHeaders bool
TrustedProxyCIDRs []netip.Prefix
ReadHeaderTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
ShutdownTimeout time.Duration
MaxHeaderBytes int
LogLevel slog.Level
LogFormat string
}
func Load() (Config, error) {
trustProxyHeaders, err := envBool("TRUST_PROXY_HEADERS", false)
if err != nil {
return Config{}, err
}
trustedProxyCIDRs, err := envPrefixes("TRUSTED_PROXY_CIDRS", defaultTrustedProxyCIDRs)
if err != nil {
return Config{}, err
}
readHeaderTimeout, err := envDuration("READ_HEADER_TIMEOUT", 5*time.Second)
if err != nil {
return Config{}, err
}
readTimeout, err := envDuration("READ_TIMEOUT", 10*time.Second)
if err != nil {
return Config{}, err
}
writeTimeout, err := envDuration("WRITE_TIMEOUT", 10*time.Second)
if err != nil {
return Config{}, err
}
idleTimeout, err := envDuration("IDLE_TIMEOUT", 60*time.Second)
if err != nil {
return Config{}, err
}
shutdownTimeout, err := envDuration("SHUTDOWN_TIMEOUT", 5*time.Second)
if err != nil {
return Config{}, err
}
maxHeaderBytes, err := envInt("MAX_HEADER_BYTES", 1<<20)
if err != nil {
return Config{}, err
}
logLevel, err := envLogLevel("LOG_LEVEL", slog.LevelInfo)
if err != nil {
return Config{}, err
}
logFormat, err := envLogFormat("LOG_FORMAT", "text")
if err != nil {
return Config{}, err
}
return Config{
Addr: envString("ADDR", ":8080"),
TrustProxyHeaders: trustProxyHeaders,
TrustedProxyCIDRs: trustedProxyCIDRs,
ReadHeaderTimeout: readHeaderTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
ShutdownTimeout: shutdownTimeout,
MaxHeaderBytes: maxHeaderBytes,
LogLevel: logLevel,
LogFormat: logFormat,
}, nil
}
func envString(name, fallback string) string {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback
}
return value
}
func envBool(name string, fallback bool) (bool, error) {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback, nil
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return false, fmt.Errorf("%s must be a boolean value: %w", name, err)
}
return parsed, nil
}
func envDuration(name string, fallback time.Duration) (time.Duration, error) {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback, nil
}
parsed, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("%s must be a duration like 5s or 1m: %w", name, err)
}
if parsed <= 0 {
return 0, fmt.Errorf("%s must be greater than zero", name)
}
return parsed, nil
}
func envInt(name string, fallback int) (int, error) {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback, nil
}
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("%s must be an integer: %w", name, err)
}
if parsed <= 0 {
return 0, fmt.Errorf("%s must be greater than zero", name)
}
return parsed, nil
}
func envPrefixes(name, fallback string) ([]netip.Prefix, error) {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
value = fallback
}
parts := strings.Split(value, ",")
prefixes := make([]netip.Prefix, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if prefix, err := netip.ParsePrefix(part); err == nil {
prefixes = append(prefixes, prefix.Masked())
continue
}
addr, err := netip.ParseAddr(part)
if err != nil {
return nil, fmt.Errorf("%s contains invalid CIDR or IP %q: %w", name, part, err)
}
prefixes = append(prefixes, netip.PrefixFrom(addr, addr.BitLen()))
}
if len(prefixes) == 0 {
return nil, fmt.Errorf("%s must contain at least one CIDR or IP", name)
}
return prefixes, nil
}
func envLogLevel(name string, fallback slog.Level) (slog.Level, error) {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback, nil
}
var level slog.Level
if err := level.UnmarshalText([]byte(strings.ToUpper(value))); err != nil {
return 0, fmt.Errorf("%s must be debug, info, warn, or error: %w", name, err)
}
return level, nil
}
func envLogFormat(name, fallback string) (string, error) {
value := strings.ToLower(strings.TrimSpace(os.Getenv(name)))
if value == "" {
return fallback, nil
}
switch value {
case "text", "json":
return value, nil
default:
return "", fmt.Errorf("%s must be text or json", name)
}
}

View File

@@ -0,0 +1,130 @@
package config
import (
"log/slog"
"net/netip"
"testing"
"time"
)
func TestLoadUsesProductionDefaults(t *testing.T) {
clearConfigEnv(t)
cfg, err := Load()
if err != nil {
t.Fatalf("load config: %v", err)
}
if cfg.Addr != ":8080" {
t.Fatalf("got addr %q, want %q", cfg.Addr, ":8080")
}
if cfg.TrustProxyHeaders {
t.Fatal("proxy headers should not be trusted by default")
}
wantProxy := netip.MustParsePrefix("127.0.0.1/32")
if len(cfg.TrustedProxyCIDRs) == 0 || cfg.TrustedProxyCIDRs[0] != wantProxy {
t.Fatalf("got trusted proxies %v, want first %v", cfg.TrustedProxyCIDRs, wantProxy)
}
if cfg.ReadHeaderTimeout != 5*time.Second {
t.Fatalf("got read header timeout %s", cfg.ReadHeaderTimeout)
}
if cfg.LogLevel != slog.LevelInfo {
t.Fatalf("got log level %s, want info", cfg.LogLevel)
}
}
func TestLoadParsesEnvironment(t *testing.T) {
clearConfigEnv(t)
t.Setenv("ADDR", "127.0.0.1:9090")
t.Setenv("TRUST_PROXY_HEADERS", "true")
t.Setenv("TRUSTED_PROXY_CIDRS", "10.0.0.0/8,192.0.2.10")
t.Setenv("READ_HEADER_TIMEOUT", "3s")
t.Setenv("READ_TIMEOUT", "4s")
t.Setenv("WRITE_TIMEOUT", "5s")
t.Setenv("IDLE_TIMEOUT", "30s")
t.Setenv("SHUTDOWN_TIMEOUT", "7s")
t.Setenv("MAX_HEADER_BYTES", "8192")
t.Setenv("LOG_LEVEL", "debug")
t.Setenv("LOG_FORMAT", "json")
cfg, err := Load()
if err != nil {
t.Fatalf("load config: %v", err)
}
if cfg.Addr != "127.0.0.1:9090" {
t.Fatalf("got addr %q", cfg.Addr)
}
if !cfg.TrustProxyHeaders {
t.Fatal("expected proxy headers to be trusted")
}
if got, want := cfg.TrustedProxyCIDRs[1], netip.MustParsePrefix("192.0.2.10/32"); got != want {
t.Fatalf("got proxy %v, want %v", got, want)
}
if cfg.ReadTimeout != 4*time.Second || cfg.WriteTimeout != 5*time.Second {
t.Fatalf("got read/write timeouts %s/%s", cfg.ReadTimeout, cfg.WriteTimeout)
}
if cfg.MaxHeaderBytes != 8192 {
t.Fatalf("got max header bytes %d", cfg.MaxHeaderBytes)
}
if cfg.LogLevel != slog.LevelDebug || cfg.LogFormat != "json" {
t.Fatalf("got logging %s/%s", cfg.LogLevel, cfg.LogFormat)
}
}
func TestLoadRejectsInvalidEnvironment(t *testing.T) {
tests := map[string]string{
"TRUST_PROXY_HEADERS": "yes-please",
"TRUSTED_PROXY_CIDRS": "not-a-cidr",
"TRUSTED_PROXY_CIDRS_EMPTY": ",",
"READ_TIMEOUT": "forever",
"MAX_HEADER_BYTES": "0",
"LOG_LEVEL": "verbose",
"LOG_FORMAT": "xml",
}
for name, value := range tests {
t.Run(name, func(t *testing.T) {
clearConfigEnv(t)
envName := name
if name == "TRUSTED_PROXY_CIDRS_EMPTY" {
envName = "TRUSTED_PROXY_CIDRS"
}
t.Setenv(envName, value)
if _, err := Load(); err == nil {
t.Fatal("expected error")
}
})
}
}
func clearConfigEnv(t *testing.T) {
t.Helper()
for _, name := range []string{
"ADDR",
"TRUST_PROXY_HEADERS",
"TRUSTED_PROXY_CIDRS",
"READ_HEADER_TIMEOUT",
"READ_TIMEOUT",
"WRITE_TIMEOUT",
"IDLE_TIMEOUT",
"SHUTDOWN_TIMEOUT",
"MAX_HEADER_BYTES",
"LOG_LEVEL",
"LOG_FORMAT",
} {
t.Setenv(name, "")
}
}

600
internal/httpapi/page.go Normal file
View File

@@ -0,0 +1,600 @@
package httpapi
import (
"html/template"
"net/http"
"strings"
)
var homeTemplate = template.Must(template.New("home").Parse(homePageHTML))
type homePageData struct {
Info ClientInfo
BaseURL string
Commands []commandLink
Endpoints []endpointLink
}
type commandLink struct {
Label string
Value string
}
type endpointLink struct {
Path string
Description string
}
func (s *Server) handleHome(w http.ResponseWriter, req *http.Request) {
baseURL := s.requestBaseURL(req)
data := homePageData{
Info: s.clientInfo(req),
BaseURL: baseURL,
Commands: []commandLink{
{Label: "IP", Value: "curl " + baseURL + "/ip"},
{Label: "JSON", Value: "curl " + baseURL + "/json"},
{Label: "Headers", Value: "curl " + baseURL + "/headers"},
},
Endpoints: []endpointLink{
{Path: "/", Description: "Plain IP for CLI, dashboard for browsers"},
{Path: "/ip", Description: "Plain text client IP"},
{Path: "/json", Description: "Request metadata as JSON"},
{Path: "/headers", Description: "Request headers as text"},
{Path: "/user-agent", Description: "User agent as text"},
{Path: "/healthz", Description: "Health check"},
},
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = homeTemplate.Execute(w, data)
}
func (s *Server) requestBaseURL(req *http.Request) string {
scheme := "http"
if req.TLS != nil {
scheme = "https"
}
host := req.Host
if host == "" {
host = "localhost"
}
if s.resolver.TrustsProxyHeaders(req) {
if proto := firstCommaSeparatedHeader(req.Header.Get("X-Forwarded-Proto")); proto == "http" || proto == "https" {
scheme = proto
}
if forwardedHost := firstCommaSeparatedHeader(req.Header.Get("X-Forwarded-Host")); forwardedHost != "" {
host = forwardedHost
}
}
return scheme + "://" + host
}
func firstCommaSeparatedHeader(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
value, _, _ = strings.Cut(value, ",")
return strings.TrimSpace(value)
}
const homePageHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IP Echo</title>
<style>
:root {
color-scheme: light;
--bg: #f5f7fb;
--panel: #ffffff;
--panel-soft: #eef4f8;
--text: #162033;
--muted: #667085;
--line: #d9e2ec;
--blue: #1167b1;
--cyan: #0b9fb3;
--green: #1f8a5b;
--orange: #c85f16;
--shadow: 0 18px 45px rgba(22, 32, 51, 0.11);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
linear-gradient(180deg, #ffffff 0, var(--bg) 340px),
var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.5;
}
a {
color: inherit;
}
.shell {
width: min(1120px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 40px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.mark {
width: 42px;
height: 42px;
flex: 0 0 42px;
}
.brand strong {
display: block;
font-size: 18px;
font-weight: 750;
}
.brand span {
display: block;
color: var(--muted);
font-size: 13px;
}
.status {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 12px;
color: var(--green);
background: #ffffff;
font-size: 13px;
font-weight: 650;
white-space: nowrap;
}
.status::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr);
gap: 20px;
align-items: stretch;
margin-bottom: 20px;
}
.ip-panel,
.trace-panel,
.section,
.endpoint {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
}
.ip-panel {
padding: 30px;
min-width: 0;
}
.eyebrow {
margin: 0 0 8px;
color: var(--orange);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--muted);
}
.ip-value {
margin: 12px 0 20px;
font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
font-size: 42px;
font-weight: 760;
line-height: 1.15;
overflow-wrap: anywhere;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 24px;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
border: 1px solid transparent;
border-radius: 8px;
padding: 8px 14px;
font: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
text-decoration: none;
}
.button svg {
width: 17px;
height: 17px;
flex: 0 0 17px;
}
.button-primary {
background: var(--blue);
color: #ffffff;
}
.button-secondary {
border-color: var(--line);
background: #ffffff;
color: var(--text);
}
.details {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.detail {
min-width: 0;
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: var(--panel-soft);
}
.detail span {
display: block;
margin-bottom: 4px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.detail strong {
display: block;
font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
font-size: 13px;
font-weight: 650;
overflow-wrap: anywhere;
}
.trace-panel {
min-height: 100%;
padding: 22px;
overflow: hidden;
background:
linear-gradient(135deg, rgba(17, 103, 177, 0.08), transparent 55%),
#ffffff;
}
.trace-visual {
width: 100%;
height: 210px;
display: block;
margin-bottom: 12px;
}
.trace-title {
margin: 0 0 6px;
font-size: 18px;
font-weight: 780;
}
.trace-copy {
margin: 0;
color: var(--muted);
font-size: 14px;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.section {
padding: 20px;
}
.section h2 {
margin: 0 0 14px;
font-size: 17px;
font-weight: 780;
}
.commands {
display: grid;
gap: 10px;
}
.command {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 12px;
align-items: center;
min-height: 48px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px 12px;
background: #fbfcfe;
}
.command span {
color: var(--cyan);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
code {
font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
font-size: 13px;
overflow-wrap: anywhere;
}
.endpoints {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.endpoint {
min-height: 92px;
padding: 14px;
box-shadow: none;
}
.endpoint code {
display: block;
margin-bottom: 8px;
color: var(--blue);
font-size: 15px;
font-weight: 800;
}
.endpoint span {
display: block;
color: var(--muted);
font-size: 13px;
}
.toast {
position: fixed;
right: 18px;
bottom: 18px;
transform: translateY(12px);
opacity: 0;
pointer-events: none;
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px 14px;
background: #ffffff;
color: var(--green);
box-shadow: var(--shadow);
font-size: 14px;
font-weight: 700;
transition: opacity 160ms ease, transform 160ms ease;
}
.toast.is-visible {
transform: translateY(0);
opacity: 1;
}
@media (max-width: 860px) {
.hero,
.grid,
.details,
.endpoints {
grid-template-columns: 1fr;
}
.ip-value {
font-size: 31px;
}
}
@media (max-width: 560px) {
.shell {
width: min(100% - 20px, 1120px);
padding-top: 16px;
}
.topbar {
align-items: flex-start;
flex-direction: column;
}
.ip-panel,
.trace-panel,
.section {
padding: 16px;
}
.ip-value {
font-size: 27px;
}
.command {
grid-template-columns: 1fr;
gap: 4px;
}
}
</style>
</head>
<body>
<main class="shell">
<header class="topbar">
<div class="brand">
<svg class="mark" viewBox="0 0 48 48" role="img" aria-label="IP Echo">
<rect width="48" height="48" rx="8" fill="#1167b1"></rect>
<path d="M14 25h20M24 14v20" stroke="#ffffff" stroke-width="3" stroke-linecap="round"></path>
<circle cx="14" cy="25" r="4" fill="#f59f45"></circle>
<circle cx="34" cy="25" r="4" fill="#41c7d7"></circle>
<circle cx="24" cy="14" r="4" fill="#8bd6aa"></circle>
<circle cx="24" cy="34" r="4" fill="#ffffff"></circle>
</svg>
<div>
<strong>IP Echo</strong>
<span>Client network lookup</span>
</div>
</div>
<div class="status">Online</div>
</header>
<section class="hero" aria-label="Client IP summary">
<div class="ip-panel">
<p class="eyebrow">Public address</p>
<h1>Your visible IP address</h1>
<div class="ip-value" id="client-ip">{{.Info.IP}}</div>
<div class="actions">
<button class="button button-primary" type="button" data-copy="{{.Info.IP}}">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 8h10v12H8zM6 16H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"></path>
</svg>
Copy IP
</button>
<a class="button button-secondary" href="/json">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 17 3 12l5-5M16 7l5 5-5 5M14 4l-4 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
JSON
</a>
<a class="button button-secondary" href="/headers">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16M4 12h16M4 17h10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
</svg>
Headers
</a>
</div>
<div class="details">
<div class="detail">
<span>Remote address</span>
<strong>{{.Info.RemoteAddr}}</strong>
</div>
<div class="detail">
<span>Method</span>
<strong>{{.Info.Method}}</strong>
</div>
<div class="detail">
<span>User agent</span>
<strong>{{.Info.UserAgent}}</strong>
</div>
</div>
</div>
<aside class="trace-panel" aria-label="Request path">
<svg class="trace-visual" viewBox="0 0 360 220" role="img" aria-label="Browser request reaching IP Echo">
<defs>
<linearGradient id="line" x1="0" y1="0" x2="1" y2="0">
<stop stop-color="#1167b1"></stop>
<stop offset="0.55" stop-color="#0b9fb3"></stop>
<stop offset="1" stop-color="#1f8a5b"></stop>
</linearGradient>
</defs>
<rect x="1" y="1" width="358" height="218" rx="8" fill="#f8fbfd" stroke="#d9e2ec"></rect>
<path d="M72 112 C126 50, 214 50, 286 108" fill="none" stroke="url(#line)" stroke-width="5" stroke-linecap="round"></path>
<path d="M72 112 C126 170, 214 170, 286 108" fill="none" stroke="#d9e2ec" stroke-width="2" stroke-dasharray="7 8" stroke-linecap="round"></path>
<circle cx="72" cy="112" r="28" fill="#ffffff" stroke="#1167b1" stroke-width="4"></circle>
<circle cx="286" cy="108" r="34" fill="#ffffff" stroke="#1f8a5b" stroke-width="4"></circle>
<circle cx="179" cy="58" r="12" fill="#f59f45"></circle>
<circle cx="179" cy="164" r="12" fill="#41c7d7"></circle>
<path d="M58 104h28v18H58zM62 99h20" fill="none" stroke="#1167b1" stroke-width="3" stroke-linejoin="round"></path>
<path d="M271 96h30v24h-30zM277 102h18M277 109h18M277 116h10" fill="none" stroke="#1f8a5b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"></path>
<text x="72" y="158" text-anchor="middle" font-family="Consolas, monospace" font-size="13" fill="#667085">client</text>
<text x="286" y="158" text-anchor="middle" font-family="Consolas, monospace" font-size="13" fill="#667085">server</text>
</svg>
<p class="trace-title">Request received</p>
<p class="trace-copy">The service reports the address seen by this Go server. Behind a trusted proxy, it can use forwarded headers.</p>
</aside>
</section>
<section class="grid">
<div class="section">
<h2>CLI</h2>
<div class="commands">
{{range .Commands}}
<div class="command">
<span>{{.Label}}</span>
<code>{{.Value}}</code>
</div>
{{end}}
</div>
</div>
<div class="section">
<h2>Endpoints</h2>
<div class="endpoints">
{{range .Endpoints}}
<a class="endpoint" href="{{.Path}}">
<code>{{.Path}}</code>
<span>{{.Description}}</span>
</a>
{{end}}
</div>
</div>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite">Copied</div>
<script>
const toast = document.querySelector("#toast");
document.querySelectorAll("[data-copy]").forEach((button) => {
button.addEventListener("click", async () => {
await navigator.clipboard.writeText(button.dataset.copy);
toast.classList.add("is-visible");
window.setTimeout(() => toast.classList.remove("is-visible"), 1400);
});
});
</script>
</body>
</html>
`

225
internal/httpapi/server.go Normal file
View File

@@ -0,0 +1,225 @@
package httpapi
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"sort"
"strings"
"time"
"ipecho/internal/clientip"
)
type Server struct {
resolver clientip.Resolver
logger *slog.Logger
}
type ClientInfo struct {
IP string `json:"ip"`
RemoteAddr string `json:"remote_addr"`
Method string `json:"method"`
Path string `json:"path"`
UserAgent string `json:"user_agent"`
Headers map[string][]string `json:"headers"`
}
func New(resolver clientip.Resolver) http.Handler {
return NewWithLogger(resolver, discardLogger())
}
func NewWithLogger(resolver clientip.Resolver, logger *slog.Logger) http.Handler {
if logger == nil {
logger = discardLogger()
}
server := &Server{
resolver: resolver,
logger: logger,
}
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", server.handleRoot)
mux.HandleFunc("GET /ip", server.handleIP)
mux.HandleFunc("GET /json", server.handleJSON)
mux.HandleFunc("GET /headers", server.handleHeaders)
mux.HandleFunc("GET /user-agent", server.handleUserAgent)
mux.HandleFunc("GET /healthz", server.handleHealth)
mux.HandleFunc("GET /favicon.ico", server.handleFavicon)
return accessLog(secureHeaders(mux), logger, resolver)
}
func (s *Server) handleRoot(w http.ResponseWriter, req *http.Request) {
if wantsHTML(req) {
s.handleHome(w, req)
return
}
s.handleIP(w, req)
}
func (s *Server) handleIP(w http.ResponseWriter, req *http.Request) {
writePlain(w, http.StatusOK, s.resolver.FromRequest(req))
}
func (s *Server) handleJSON(w http.ResponseWriter, req *http.Request) {
info := s.clientInfo(req)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
_ = encoder.Encode(info)
}
func (s *Server) handleHeaders(w http.ResponseWriter, req *http.Request) {
writePlain(w, http.StatusOK, formatHeaders(req))
}
func (s *Server) handleUserAgent(w http.ResponseWriter, req *http.Request) {
writePlain(w, http.StatusOK, req.UserAgent())
}
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
writePlain(w, http.StatusOK, "ok")
}
func (s *Server) clientInfo(req *http.Request) ClientInfo {
return ClientInfo{
IP: s.resolver.FromRequest(req),
RemoteAddr: req.RemoteAddr,
Method: req.Method,
Path: req.URL.Path,
UserAgent: req.UserAgent(),
Headers: cloneHeaders(req),
}
}
func secureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
next.ServeHTTP(w, req)
})
}
func accessLog(next http.Handler, logger *slog.Logger, resolver clientip.Resolver) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
startedAt := time.Now()
recorder := &responseRecorder{ResponseWriter: w}
next.ServeHTTP(recorder, req)
status := recorder.status
if status == 0 {
status = http.StatusOK
}
logger.Info("http_request",
"method", req.Method,
"path", req.URL.Path,
"status", status,
"bytes", recorder.bytes,
"duration", time.Since(startedAt).String(),
"client_ip", resolver.FromRequest(req),
"remote_addr", req.RemoteAddr,
"user_agent", req.UserAgent(),
)
})
}
type responseRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *responseRecorder) WriteHeader(status int) {
if r.status != 0 {
return
}
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func (r *responseRecorder) Write(body []byte) (int, error) {
if r.status == 0 {
r.WriteHeader(http.StatusOK)
}
written, err := r.ResponseWriter.Write(body)
r.bytes += written
return written, err
}
func (r *responseRecorder) Unwrap() http.ResponseWriter {
return r.ResponseWriter
}
func wantsHTML(req *http.Request) bool {
for _, value := range req.Header.Values("Accept") {
for _, part := range strings.Split(value, ",") {
mediaType := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
if mediaType == "text/html" || mediaType == "application/xhtml+xml" {
return true
}
}
}
return false
}
func writePlain(w http.ResponseWriter, status int, body string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(status)
_, _ = fmt.Fprintln(w, body)
}
func cloneHeaders(req *http.Request) map[string][]string {
headers := make(map[string][]string, len(req.Header)+1)
headers["Host"] = []string{req.Host}
for name, values := range req.Header {
headers[name] = append([]string(nil), values...)
}
return headers
}
func formatHeaders(req *http.Request) string {
headers := cloneHeaders(req)
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
}
sort.Strings(names)
var builder strings.Builder
for _, name := range names {
for _, value := range headers[name] {
builder.WriteString(name)
builder.WriteString(": ")
builder.WriteString(value)
builder.WriteByte('\n')
}
}
return strings.TrimRight(builder.String(), "\n")
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}

View File

@@ -0,0 +1,175 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"ipecho/internal/clientip"
)
func TestIPEndpointReturnsPlainTextClientIP(t *testing.T) {
handler := New(clientip.Resolver{})
req := httptest.NewRequest(http.MethodGet, "/ip", nil)
req.RemoteAddr = "198.51.100.20:54123"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got status %d, want %d", rec.Code, http.StatusOK)
}
if got, want := rec.Body.String(), "198.51.100.20\n"; got != want {
t.Fatalf("got body %q, want %q", got, want)
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/plain") {
t.Fatalf("got content-type %q, want text/plain", got)
}
}
func TestRootReturnsPlainTextClientIPForCLIRequests(t *testing.T) {
handler := New(clientip.Resolver{})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "198.51.100.20:54123"
req.Header.Set("Accept", "*/*")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got status %d, want %d", rec.Code, http.StatusOK)
}
if got, want := rec.Body.String(), "198.51.100.20\n"; got != want {
t.Fatalf("got body %q, want %q", got, want)
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/plain") {
t.Fatalf("got content-type %q, want text/plain", got)
}
}
func TestRootReturnsHTMLForBrowserRequests(t *testing.T) {
handler := New(clientip.Resolver{})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "198.51.100.20:54123"
req.Header.Set("Accept", "text/html,application/xhtml+xml")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got status %d, want %d", rec.Code, http.StatusOK)
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
t.Fatalf("got content-type %q, want text/html", got)
}
body := rec.Body.String()
for _, want := range []string{"IP Echo", "198.51.100.20", "Copy IP", "/json"} {
if !strings.Contains(body, want) {
t.Fatalf("body does not contain %q", want)
}
}
}
func TestJSONEndpointReturnsRequestMetadata(t *testing.T) {
handler := New(clientip.Resolver{TrustProxyHeaders: true})
req := httptest.NewRequest(http.MethodGet, "/json", nil)
req.RemoteAddr = "198.51.100.20:54123"
req.Header.Set("User-Agent", "ipecho-test")
req.Header.Set("X-Forwarded-For", "203.0.113.10")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got status %d, want %d", rec.Code, http.StatusOK)
}
var got ClientInfo
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode response: %v", err)
}
if got.IP != "203.0.113.10" {
t.Fatalf("got ip %q, want %q", got.IP, "203.0.113.10")
}
if got.UserAgent != "ipecho-test" {
t.Fatalf("got user agent %q, want %q", got.UserAgent, "ipecho-test")
}
}
func TestHeadersEndpointIncludesHostAndHeaders(t *testing.T) {
handler := New(clientip.Resolver{})
req := httptest.NewRequest(http.MethodGet, "/headers", nil)
req.Host = "example.test"
req.Header.Set("User-Agent", "ipecho-test")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
body := rec.Body.String()
for _, want := range []string{"Host: example.test", "User-Agent: ipecho-test"} {
if !strings.Contains(body, want) {
t.Fatalf("body %q does not contain %q", body, want)
}
}
}
func TestFaviconEndpointReturnsIconWithoutHTMLLink(t *testing.T) {
handler := New(clientip.Resolver{})
req := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got status %d, want %d", rec.Code, http.StatusOK)
}
if got := rec.Header().Get("Content-Type"); got != "image/x-icon" {
t.Fatalf("got content-type %q, want image/x-icon", got)
}
if rec.Body.Len() == 0 {
t.Fatal("favicon response is empty")
}
}
func TestSecurityHeadersAreApplied(t *testing.T) {
handler := New(clientip.Resolver{})
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
for _, header := range []string{
"X-Content-Type-Options",
"X-Frame-Options",
"Referrer-Policy",
"Content-Security-Policy",
} {
if rec.Header().Get(header) == "" {
t.Fatalf("missing %s header", header)
}
}
}
func TestUnknownEndpointReturnsNotFound(t *testing.T) {
handler := New(clientip.Resolver{})
req := httptest.NewRequest(http.MethodGet, "/missing", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("got status %d, want %d", rec.Code, http.StatusNotFound)
}
}

View File

@@ -0,0 +1,16 @@
package httpapi
import (
_ "embed"
"net/http"
)
//go:embed static/favicon.ico
var faviconICO []byte
func (s *Server) handleFavicon(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/x-icon")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(faviconICO)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB