commit 4c7343665ac9ed68503c7cae16982756dea658c6 Author: Meghdad Date: Wed May 13 03:05:24 2026 +0330 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c40f446 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/bin/ +/tmp/ +*.exe +*.test diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..877fc9d --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/start-project.iml b/.idea/start-project.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/start-project.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a83e659 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# IP Echo + +A small Go web service like `ifconfig.me`: call it from a browser, curl, or another service and it tells you what IP address the server sees. + +## What It Teaches + +- `net/http` routing with the standard library. +- Reading configuration from environment variables. +- Extracting a client IP safely from `RemoteAddr`. +- Optional proxy header support for deployments behind Nginx, Caddy, or a load balancer. +- Table-focused tests for request parsing and HTTP handlers. + +## Run + +```powershell +go run ./cmd/ipecho +``` + +The service listens on `:8080` by default. + +```powershell +curl.exe http://localhost:8080/ +curl.exe http://localhost:8080/ip +curl.exe http://localhost:8080/json +curl.exe http://localhost:8080/headers +curl.exe http://localhost:8080/user-agent +``` + +Use another port: + +```powershell +$env:ADDR = ":9090" +go run ./cmd/ipecho +``` + +Trust reverse proxy headers: + +```powershell +$env:TRUST_PROXY_HEADERS = "true" +$env:TRUSTED_PROXY_CIDRS = "127.0.0.1/32,::1/128" +go run ./cmd/ipecho +``` + +Only enable `TRUST_PROXY_HEADERS` when your service is behind a proxy you control. Keep `TRUSTED_PROXY_CIDRS` limited to that proxy; otherwise, anyone can spoof forwarded IP headers. + +## Endpoints + +| Endpoint | Response | +|-------------------|------------------------------------------------------------| +| `GET /` | Browser dashboard or plain text client IP for CLI requests | +| `GET /ip` | Plain text client IP | +| `GET /json` | JSON request metadata | +| `GET /headers` | Plain text request headers | +| `GET /user-agent` | Plain text user agent | +| `GET /healthz` | Plain text health check | +| `GET /favicon.ico` | Embedded favicon | + +## Test + +```powershell +go test ./... +``` + +## Production + +See: + +- [Production Guide](docs/production.md) +- [nginx Configuration](docs/nginx.md) + +Regenerate icon assets and the Windows executable resource: + +```powershell +go generate ./... +``` diff --git a/assets/favicon.ico b/assets/favicon.ico new file mode 100644 index 0000000..298e4e0 Binary files /dev/null and b/assets/favicon.ico differ diff --git a/assets/favicon.svg b/assets/favicon.svg new file mode 100644 index 0000000..a040e39 --- /dev/null +++ b/assets/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..a040e39 --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/ipecho.ico b/assets/ipecho.ico new file mode 100644 index 0000000..298e4e0 Binary files /dev/null and b/assets/ipecho.ico differ diff --git a/cmd/ipecho/main.go b/cmd/ipecho/main.go new file mode 100644 index 0000000..f260430 --- /dev/null +++ b/cmd/ipecho/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + + "ipecho/internal/buildinfo" + "ipecho/internal/clientip" + "ipecho/internal/config" + "ipecho/internal/httpapi" +) + +func main() { + cfg, err := config.Load() + if err != nil { + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + logger.Error("invalid configuration", "error", err) + os.Exit(1) + } + + logger := newLogger(cfg) + server := &http.Server{ + Addr: cfg.Addr, + Handler: httpapi.NewWithLogger(clientip.Resolver{ + TrustProxyHeaders: cfg.TrustProxyHeaders, + TrustedProxies: cfg.TrustedProxyCIDRs, + }, logger), + ReadHeaderTimeout: cfg.ReadHeaderTimeout, + ReadTimeout: cfg.ReadTimeout, + WriteTimeout: cfg.WriteTimeout, + IdleTimeout: cfg.IdleTimeout, + MaxHeaderBytes: cfg.MaxHeaderBytes, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + serverErrors := make(chan error, 1) + go func() { + logger.Info("server started", + "addr", cfg.Addr, + "trust_proxy_headers", cfg.TrustProxyHeaders, + "trusted_proxy_cidrs", cfg.TrustedProxyCIDRs, + "version", buildinfo.Version, + "commit", buildinfo.Commit, + "date", buildinfo.Date, + ) + serverErrors <- server.ListenAndServe() + }() + + select { + case <-ctx.Done(): + logger.Info("server stopping") + case err := <-serverErrors: + if !errors.Is(err, http.ErrServerClosed) { + logger.Error("server stopped unexpectedly", "error", err) + os.Exit(1) + } + + return + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) + defer cancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + logger.Error("server shutdown failed", "error", err) + os.Exit(1) + } +} + +func newLogger(cfg config.Config) *slog.Logger { + options := &slog.HandlerOptions{ + Level: cfg.LogLevel, + } + + if cfg.LogFormat == "json" { + return slog.New(slog.NewJSONHandler(os.Stdout, options)) + } + + return slog.New(slog.NewTextHandler(os.Stdout, options)) +} diff --git a/cmd/ipecho/rsrc_windows_amd64.syso b/cmd/ipecho/rsrc_windows_amd64.syso new file mode 100644 index 0000000..5d79ee0 Binary files /dev/null and b/cmd/ipecho/rsrc_windows_amd64.syso differ diff --git a/docs/nginx.md b/docs/nginx.md new file mode 100644 index 0000000..2570643 --- /dev/null +++ b/docs/nginx.md @@ -0,0 +1,126 @@ +# nginx Configuration + +This service should usually sit behind nginx. Let nginx handle TLS, public ports, compression, and connection buffering. Let the Go app listen on loopback. + +## App Environment + +Run the app with: + +```bash +ADDR=127.0.0.1:8080 +TRUST_PROXY_HEADERS=true +TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128 +LOG_FORMAT=json +``` + +If nginx runs in Docker or on another private host, add that proxy network to `TRUSTED_PROXY_CIDRS`, for example: + +```bash +TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128,172.16.0.0/12 +``` + +## Basic Reverse Proxy + +Replace `ip.example.com` with your domain. + +```nginx +upstream ipecho { + server 127.0.0.1:8080; + keepalive 16; +} + +server { + listen 80; + server_name ip.example.com; + + location / { + proxy_pass http://ipecho; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + proxy_connect_timeout 5s; + proxy_send_timeout 15s; + proxy_read_timeout 15s; + } +} +``` + +## HTTPS Server Block + +After installing a certificate, use: + +```nginx +upstream ipecho { + server 127.0.0.1:8080; + keepalive 16; +} + +server { + listen 80; + server_name ip.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name ip.example.com; + + ssl_certificate /etc/letsencrypt/live/ip.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/ip.example.com/privkey.pem; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + location / { + proxy_pass http://ipecho; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + + proxy_connect_timeout 5s; + proxy_send_timeout 15s; + proxy_read_timeout 15s; + } +} +``` + +## With A CDN Or Load Balancer In Front + +If nginx is behind another proxy, configure nginx real IP handling first so `$remote_addr` becomes the actual client address before it is passed to the Go app. + +Example shape: + +```nginx +real_ip_header X-Forwarded-For; +real_ip_recursive on; + +# Add only the CIDR ranges for proxies you control or your CDN publishes. +set_real_ip_from 203.0.113.0/24; +set_real_ip_from 2001:db8::/32; +``` + +Keep those CIDR ranges current. Do not trust `0.0.0.0/0` or `::/0`. + +## Verify + +```bash +curl https://ip.example.com/ip +curl https://ip.example.com/json +curl -I https://ip.example.com/favicon.ico +curl http://127.0.0.1:8080/healthz +``` + +Expected: + +- `/ip` returns the client IP as plain text. +- `/json` shows `ip`, `remote_addr`, headers, method, and path. +- `/favicon.ico` returns `Content-Type: image/x-icon`. +- `/healthz` returns `ok`. diff --git a/docs/production.md b/docs/production.md new file mode 100644 index 0000000..b345c42 --- /dev/null +++ b/docs/production.md @@ -0,0 +1,75 @@ +# Production Guide + +## Build + +Windows: + +```powershell +$version = "1.0.0" +$commit = git rev-parse --short HEAD +$date = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + +go generate ./... +go test ./... +go build -trimpath -ldflags "-s -w -X ipecho/internal/buildinfo.Version=$version -X ipecho/internal/buildinfo.Commit=$commit -X ipecho/internal/buildinfo.Date=$date" -o bin\ipecho.exe .\cmd\ipecho +``` + +Linux: + +```bash +version=1.0.0 +commit="$(git rev-parse --short HEAD)" +date="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +go generate ./... +go test ./... +go build -trimpath -ldflags "-s -w -X ipecho/internal/buildinfo.Version=$version -X ipecho/internal/buildinfo.Commit=$commit -X ipecho/internal/buildinfo.Date=$date" -o bin/ipecho ./cmd/ipecho +``` + +`go generate ./...` refreshes: + +- `assets/icon.svg` +- `assets/favicon.svg` +- `assets/ipecho.ico` +- `assets/favicon.ico` +- `internal/httpapi/static/favicon.ico` +- `cmd/ipecho/rsrc_windows_amd64.syso` + +The `.syso` file is used by Go when building `cmd/ipecho` on Windows amd64, so `bin\ipecho.exe` gets the application icon. The HTML does not include a favicon link; browsers request `/favicon.ico` automatically, and the service serves the embedded favicon. + +## Configuration + +| Variable | Default | Description | +| --- | --- | --- | +| `ADDR` | `:8080` | TCP address for the Go server. In production behind nginx, use `127.0.0.1:8080`. | +| `TRUST_PROXY_HEADERS` | `false` | Enables `Forwarded`, `X-Forwarded-For`, and `X-Real-IP` parsing. | +| `TRUSTED_PROXY_CIDRS` | `127.0.0.1/32,::1/128` | Proxy IP ranges allowed to supply forwarded client IP headers. | +| `READ_HEADER_TIMEOUT` | `5s` | Time allowed to read request headers. | +| `READ_TIMEOUT` | `10s` | Time allowed to read the whole request. | +| `WRITE_TIMEOUT` | `10s` | Time allowed to write the response. | +| `IDLE_TIMEOUT` | `60s` | Keep-alive idle timeout. | +| `SHUTDOWN_TIMEOUT` | `5s` | Graceful shutdown deadline after SIGINT/SIGTERM. | +| `MAX_HEADER_BYTES` | `1048576` | Maximum request header size. | +| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error`. | +| `LOG_FORMAT` | `text` | `text` or `json`. Use `json` for production log collectors. | + +Example behind nginx on the same host: + +```powershell +$env:ADDR = "127.0.0.1:8080" +$env:TRUST_PROXY_HEADERS = "true" +$env:TRUSTED_PROXY_CIDRS = "127.0.0.1/32,::1/128" +$env:LOG_FORMAT = "json" +.\bin\ipecho.exe +``` + +Do not enable `TRUST_PROXY_HEADERS` while exposing the Go server directly to the internet. A direct client can spoof those headers. + +## Production Checklist + +- Run the Go service on loopback or a private network, not directly on a public interface, when proxy headers are trusted. +- Terminate TLS at nginx or another reverse proxy. +- Use `LOG_FORMAT=json` in production. +- Keep `/healthz` available for uptime checks. +- Build with `-trimpath` and release metadata through `-ldflags`. +- Regenerate icons with `go generate ./...` after changing `tools/icongen`. diff --git a/generate.go b/generate.go new file mode 100644 index 0000000..ebc4e31 --- /dev/null +++ b/generate.go @@ -0,0 +1,3 @@ +package ipecho + +//go:generate go run ./tools/icongen diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4f2960f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module ipecho + +go 1.26 diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..d9ccd72 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,7 @@ +package buildinfo + +var ( + Version = "dev" + Commit = "none" + Date = "unknown" +) diff --git a/internal/clientip/resolver.go b/internal/clientip/resolver.go new file mode 100644 index 0000000..c996ff4 --- /dev/null +++ b/internal/clientip/resolver.go @@ -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 +} diff --git a/internal/clientip/resolver_test.go b/internal/clientip/resolver_test.go new file mode 100644 index 0000000..e5944a5 --- /dev/null +++ b/internal/clientip/resolver_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5bb3b4e --- /dev/null +++ b/internal/config/config.go @@ -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) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..df3ae3b --- /dev/null +++ b/internal/config/config_test.go @@ -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, "") + } +} diff --git a/internal/httpapi/page.go b/internal/httpapi/page.go new file mode 100644 index 0000000..cecc19d --- /dev/null +++ b/internal/httpapi/page.go @@ -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 = ` + + + + + IP Echo + + + +
+
+
+ + + + + + + + +
+ IP Echo + Client network lookup +
+
+
Online
+
+ +
+
+

Public address

+

Your visible IP address

+
{{.Info.IP}}
+
+ + + + JSON + + + + Headers + +
+
+
+ Remote address + {{.Info.RemoteAddr}} +
+
+ Method + {{.Info.Method}} +
+
+ User agent + {{.Info.UserAgent}} +
+
+
+ + +
+ +
+
+

CLI

+
+ {{range .Commands}} +
+ {{.Label}} + {{.Value}} +
+ {{end}} +
+
+ +
+

Endpoints

+
+ {{range .Endpoints}} + + {{.Path}} + {{.Description}} + + {{end}} +
+
+
+
+ +
Copied
+ + + + +` diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go new file mode 100644 index 0000000..52f0e9b --- /dev/null +++ b/internal/httpapi/server.go @@ -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)) +} diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go new file mode 100644 index 0000000..80b2750 --- /dev/null +++ b/internal/httpapi/server_test.go @@ -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) + } +} diff --git a/internal/httpapi/static.go b/internal/httpapi/static.go new file mode 100644 index 0000000..92dace8 --- /dev/null +++ b/internal/httpapi/static.go @@ -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) +} diff --git a/internal/httpapi/static/favicon.ico b/internal/httpapi/static/favicon.ico new file mode 100644 index 0000000..298e4e0 Binary files /dev/null and b/internal/httpapi/static/favicon.ico differ diff --git a/tools/icongen/main.go b/tools/icongen/main.go new file mode 100644 index 0000000..769a409 --- /dev/null +++ b/tools/icongen/main.go @@ -0,0 +1,492 @@ +package main + +import ( + "bytes" + "encoding/binary" + "fmt" + "image" + "image/color" + "image/png" + "math" + "os" + "path/filepath" + "time" +) + +const ( + resourceLanguageEnglishUS = 1033 + resourceTypeIcon = 3 + resourceTypeGroupIcon = 14 +) + +type iconImage struct { + ID uint16 + Size int + PNG []byte + Planes uint16 + Bits uint16 +} + +type relocation struct { + offset uint32 +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + if err := os.MkdirAll("assets", 0o755); err != nil { + return err + } + + if err := os.MkdirAll(filepath.Join("cmd", "ipecho"), 0o755); err != nil { + return err + } + + if err := os.MkdirAll(filepath.Join("internal", "httpapi", "static"), 0o755); err != nil { + return err + } + + if err := os.WriteFile(filepath.Join("assets", "icon.svg"), []byte(iconSVG), 0o644); err != nil { + return err + } + + if err := os.WriteFile(filepath.Join("assets", "favicon.svg"), []byte(iconSVG), 0o644); err != nil { + return err + } + + images, err := renderIconImages([]int{16, 24, 32, 48, 64, 128, 256}) + if err != nil { + return err + } + + ico, err := buildICO(images) + if err != nil { + return err + } + + if err := os.WriteFile(filepath.Join("assets", "ipecho.ico"), ico, 0o644); err != nil { + return err + } + + if err := os.WriteFile(filepath.Join("assets", "favicon.ico"), ico, 0o644); err != nil { + return err + } + + if err := os.WriteFile(filepath.Join("internal", "httpapi", "static", "favicon.ico"), ico, 0o644); err != nil { + return err + } + + syso, err := buildWindowsResourceObject(images) + if err != nil { + return err + } + + return os.WriteFile(filepath.Join("cmd", "ipecho", "rsrc_windows_amd64.syso"), syso, 0o644) +} + +func renderIconImages(sizes []int) ([]iconImage, error) { + images := make([]iconImage, 0, len(sizes)) + + for index, size := range sizes { + var pngData bytes.Buffer + if err := png.Encode(&pngData, renderIcon(size)); err != nil { + return nil, err + } + + images = append(images, iconImage{ + ID: uint16(index + 1), + Size: size, + PNG: pngData.Bytes(), + Planes: 1, + Bits: 32, + }) + } + + return images, nil +} + +func renderIcon(size int) image.Image { + const scale = 4 + large := image.NewNRGBA(image.Rect(0, 0, size*scale, size*scale)) + renderIconAt(large) + + return downsample(large, scale) +} + +func renderIconAt(img *image.NRGBA) { + size := float64(img.Bounds().Dx()) + unit := size / 256 + + blue := color.NRGBA{R: 17, G: 103, B: 177, A: 255} + darkBlue := color.NRGBA{R: 10, G: 82, B: 148, A: 255} + white := color.NRGBA{R: 255, G: 255, B: 255, A: 255} + orange := color.NRGBA{R: 245, G: 159, B: 69, A: 255} + cyan := color.NRGBA{R: 65, G: 199, B: 215, A: 255} + green := color.NRGBA{R: 139, G: 214, B: 170, A: 255} + + fillRoundedRect(img, 8*unit, 8*unit, 248*unit, 248*unit, 42*unit, darkBlue) + fillRoundedRect(img, 0, 0, 240*unit, 240*unit, 42*unit, blue) + + drawLine(img, 76*unit, 128*unit, 180*unit, 128*unit, 10*unit, white) + drawLine(img, 128*unit, 76*unit, 128*unit, 180*unit, 10*unit, white) + + fillCircle(img, 76*unit, 128*unit, 31*unit, white) + fillCircle(img, 180*unit, 128*unit, 31*unit, white) + fillCircle(img, 128*unit, 76*unit, 31*unit, white) + fillCircle(img, 128*unit, 180*unit, 31*unit, white) + + fillCircle(img, 76*unit, 128*unit, 22*unit, orange) + fillCircle(img, 180*unit, 128*unit, 22*unit, cyan) + fillCircle(img, 128*unit, 76*unit, 22*unit, green) + fillCircle(img, 128*unit, 180*unit, 22*unit, white) +} + +func fillRoundedRect(img *image.NRGBA, x0, y0, x1, y1, radius float64, c color.NRGBA) { + for y := int(math.Floor(y0)); y < int(math.Ceil(y1)); y++ { + for x := int(math.Floor(x0)); x < int(math.Ceil(x1)); x++ { + if insideRoundedRect(float64(x)+0.5, float64(y)+0.5, x0, y0, x1, y1, radius) { + setNRGBA(img, x, y, c) + } + } + } +} + +func insideRoundedRect(x, y, x0, y0, x1, y1, radius float64) bool { + if x < x0 || x >= x1 || y < y0 || y >= y1 { + return false + } + + cx := math.Min(math.Max(x, x0+radius), x1-radius) + cy := math.Min(math.Max(y, y0+radius), y1-radius) + dx := x - cx + dy := y - cy + + return dx*dx+dy*dy <= radius*radius +} + +func drawLine(img *image.NRGBA, x0, y0, x1, y1, radius float64, c color.NRGBA) { + dx := x1 - x0 + dy := y1 - y0 + steps := int(math.Ceil(math.Hypot(dx, dy))) + if steps < 1 { + steps = 1 + } + + for i := 0; i <= steps; i++ { + t := float64(i) / float64(steps) + fillCircle(img, x0+dx*t, y0+dy*t, radius, c) + } +} + +func fillCircle(img *image.NRGBA, cx, cy, radius float64, c color.NRGBA) { + minX := int(math.Floor(cx - radius)) + maxX := int(math.Ceil(cx + radius)) + minY := int(math.Floor(cy - radius)) + maxY := int(math.Ceil(cy + radius)) + radius2 := radius * radius + + for y := minY; y <= maxY; y++ { + for x := minX; x <= maxX; x++ { + dx := float64(x) + 0.5 - cx + dy := float64(y) + 0.5 - cy + if dx*dx+dy*dy <= radius2 { + setNRGBA(img, x, y, c) + } + } + } +} + +func setNRGBA(img *image.NRGBA, x, y int, c color.NRGBA) { + if !image.Pt(x, y).In(img.Bounds()) { + return + } + + offset := img.PixOffset(x, y) + img.Pix[offset+0] = c.R + img.Pix[offset+1] = c.G + img.Pix[offset+2] = c.B + img.Pix[offset+3] = c.A +} + +func downsample(src *image.NRGBA, scale int) *image.NRGBA { + dst := image.NewNRGBA(image.Rect(0, 0, src.Bounds().Dx()/scale, src.Bounds().Dy()/scale)) + + for y := 0; y < dst.Bounds().Dy(); y++ { + for x := 0; x < dst.Bounds().Dx(); x++ { + var r, g, b, a uint32 + for sy := 0; sy < scale; sy++ { + for sx := 0; sx < scale; sx++ { + offset := src.PixOffset(x*scale+sx, y*scale+sy) + r += uint32(src.Pix[offset+0]) + g += uint32(src.Pix[offset+1]) + b += uint32(src.Pix[offset+2]) + a += uint32(src.Pix[offset+3]) + } + } + + count := uint32(scale * scale) + offset := dst.PixOffset(x, y) + dst.Pix[offset+0] = uint8(r / count) + dst.Pix[offset+1] = uint8(g / count) + dst.Pix[offset+2] = uint8(b / count) + dst.Pix[offset+3] = uint8(a / count) + } + } + + return dst +} + +func buildICO(images []iconImage) ([]byte, error) { + var buf bytes.Buffer + + writeLE(&buf, uint16(0)) + writeLE(&buf, uint16(1)) + writeLE(&buf, uint16(len(images))) + + imageOffset := 6 + len(images)*16 + for _, img := range images { + buf.WriteByte(iconDimensionByte(img.Size)) + buf.WriteByte(iconDimensionByte(img.Size)) + buf.WriteByte(0) + buf.WriteByte(0) + writeLE(&buf, img.Planes) + writeLE(&buf, img.Bits) + writeLE(&buf, uint32(len(img.PNG))) + writeLE(&buf, uint32(imageOffset)) + imageOffset += len(img.PNG) + } + + for _, img := range images { + if _, err := buf.Write(img.PNG); err != nil { + return nil, err + } + } + + return buf.Bytes(), nil +} + +func buildWindowsResourceObject(images []iconImage) ([]byte, error) { + section, relocations, err := buildResourceSection(images) + if err != nil { + return nil, err + } + + const ( + machineAMD64 = 0x8664 + sectionHeaderSize = 40 + fileHeaderSize = 20 + symbolSize = 18 + relocationSize = 10 + imageRelAMD64Addr32NB = 0x0003 + imageScnCntInitializedData = 0x00000040 + imageScnAlign4Bytes = 0x00300000 + imageScnMemRead = 0x40000000 + imageSymClassStatic = 3 + ) + + rawDataPointer := uint32(fileHeaderSize + sectionHeaderSize) + relocationPointer := rawDataPointer + uint32(len(section)) + symbolPointer := relocationPointer + uint32(len(relocations)*relocationSize) + + var buf bytes.Buffer + + writeLE(&buf, uint16(machineAMD64)) + writeLE(&buf, uint16(1)) + writeLE(&buf, uint32(time.Now().Unix())) + writeLE(&buf, symbolPointer) + writeLE(&buf, uint32(1)) + writeLE(&buf, uint16(0)) + writeLE(&buf, uint16(0)) + + writeSectionName(&buf, ".rsrc") + writeLE(&buf, uint32(0)) + writeLE(&buf, uint32(0)) + writeLE(&buf, uint32(len(section))) + writeLE(&buf, rawDataPointer) + writeLE(&buf, relocationPointer) + writeLE(&buf, uint32(0)) + writeLE(&buf, uint16(len(relocations))) + writeLE(&buf, uint16(0)) + writeLE(&buf, uint32(imageScnCntInitializedData|imageScnAlign4Bytes|imageScnMemRead)) + + buf.Write(section) + + for _, rel := range relocations { + writeLE(&buf, rel.offset) + writeLE(&buf, uint32(0)) + writeLE(&buf, uint16(imageRelAMD64Addr32NB)) + } + + writeSectionName(&buf, ".rsrc") + writeLE(&buf, uint32(0)) + writeLE(&buf, int16(1)) + writeLE(&buf, uint16(0)) + buf.WriteByte(imageSymClassStatic) + buf.WriteByte(0) + writeLE(&buf, uint32(4)) + + return buf.Bytes(), nil +} + +func buildResourceSection(images []iconImage) ([]byte, []relocation, error) { + var b resourceBuilder + + b.directory(0, 2) + rootEntryIcon := b.entry(resourceTypeIcon, 0) + rootEntryGroupIcon := b.entry(resourceTypeGroupIcon, 0) + + iconTypeDir := b.directory(0, uint16(len(images))) + b.patchDirectoryEntry(rootEntryIcon, resourceTypeIcon, iconTypeDir) + + iconEntryOffsets := make([]int, len(images)) + for i, img := range images { + iconEntryOffsets[i] = b.entry(uint32(img.ID), 0) + } + + groupTypeDir := b.directory(0, 1) + b.patchDirectoryEntry(rootEntryGroupIcon, resourceTypeGroupIcon, groupTypeDir) + groupEntry := b.entry(1, 0) + + for i, img := range images { + nameDir := b.directory(0, 1) + b.patchDirectoryEntry(iconEntryOffsets[i], uint32(img.ID), nameDir) + langEntry := b.entry(resourceLanguageEnglishUS, 0) + dataEntry := b.dataEntry(img.PNG) + b.patchDataEntry(langEntry, resourceLanguageEnglishUS, dataEntry) + } + + groupNameDir := b.directory(0, 1) + b.patchDirectoryEntry(groupEntry, 1, groupNameDir) + groupLangEntry := b.entry(resourceLanguageEnglishUS, 0) + groupData := buildGroupIconResource(images) + groupDataEntry := b.dataEntry(groupData) + b.patchDataEntry(groupLangEntry, resourceLanguageEnglishUS, groupDataEntry) + + return b.bytes(), b.relocations, nil +} + +type resourceBuilder struct { + buf bytes.Buffer + relocations []relocation +} + +func (b *resourceBuilder) directory(namedEntries, idEntries uint16) int { + offset := b.buf.Len() + writeLE(&b.buf, uint32(0)) + writeLE(&b.buf, uint32(0)) + writeLE(&b.buf, uint16(0)) + writeLE(&b.buf, uint16(0)) + writeLE(&b.buf, namedEntries) + writeLE(&b.buf, idEntries) + + return offset +} + +func (b *resourceBuilder) entry(id uint32, offset uint32) int { + entryOffset := b.buf.Len() + writeLE(&b.buf, id) + writeLE(&b.buf, offset) + + return entryOffset +} + +func (b *resourceBuilder) patchDirectoryEntry(entryOffset int, id uint32, directoryOffset int) { + b.writeAt(entryOffset, id) + b.writeAt(entryOffset+4, uint32(directoryOffset)|0x80000000) +} + +func (b *resourceBuilder) patchDataEntry(entryOffset int, id uint32, dataEntryOffset int) { + b.writeAt(entryOffset, id) + b.writeAt(entryOffset+4, uint32(dataEntryOffset)) +} + +func (b *resourceBuilder) dataEntry(data []byte) int { + b.align4() + dataEntryOffset := b.buf.Len() + writeLE(&b.buf, uint32(0)) + writeLE(&b.buf, uint32(len(data))) + writeLE(&b.buf, uint32(0)) + writeLE(&b.buf, uint32(0)) + + b.align4() + dataOffset := b.buf.Len() + b.writeAt(dataEntryOffset, uint32(dataOffset)) + b.relocations = append(b.relocations, relocation{offset: uint32(dataEntryOffset)}) + b.buf.Write(data) + b.align4() + + return dataEntryOffset +} + +func (b *resourceBuilder) align4() { + for b.buf.Len()%4 != 0 { + b.buf.WriteByte(0) + } +} + +func (b *resourceBuilder) writeAt(offset int, value uint32) { + binary.LittleEndian.PutUint32(b.buf.Bytes()[offset:offset+4], value) +} + +func (b *resourceBuilder) bytes() []byte { + return b.buf.Bytes() +} + +func buildGroupIconResource(images []iconImage) []byte { + var buf bytes.Buffer + + writeLE(&buf, uint16(0)) + writeLE(&buf, uint16(1)) + writeLE(&buf, uint16(len(images))) + + for _, img := range images { + buf.WriteByte(iconDimensionByte(img.Size)) + buf.WriteByte(iconDimensionByte(img.Size)) + buf.WriteByte(0) + buf.WriteByte(0) + writeLE(&buf, img.Planes) + writeLE(&buf, img.Bits) + writeLE(&buf, uint32(len(img.PNG))) + writeLE(&buf, img.ID) + } + + return buf.Bytes() +} + +func iconDimensionByte(size int) byte { + if size >= 256 { + return 0 + } + + return byte(size) +} + +func writeLE[T ~uint16 | ~uint32 | ~int16](buf *bytes.Buffer, value T) { + _ = binary.Write(buf, binary.LittleEndian, value) +} + +func writeSectionName(buf *bytes.Buffer, name string) { + var raw [8]byte + copy(raw[:], name) + buf.Write(raw[:]) +} + +const iconSVG = ` + + + + + + + + + + + + +`