From 4c7343665ac9ed68503c7cae16982756dea658c6 Mon Sep 17 00:00:00 2001 From: Meghdad Date: Wed, 13 May 2026 03:05:24 +0330 Subject: [PATCH] first commit --- .gitignore | 4 + .idea/.gitignore | 10 + .idea/go.imports.xml | 11 + .idea/modules.xml | 8 + .idea/start-project.iml | 9 + README.md | 75 ++++ assets/favicon.ico | Bin 0 -> 16195 bytes assets/favicon.svg | 13 + assets/icon.svg | 13 + assets/ipecho.ico | Bin 0 -> 16195 bytes cmd/ipecho/main.go | 87 ++++ cmd/ipecho/rsrc_windows_amd64.syso | Bin 0 -> 16798 bytes docs/nginx.md | 126 ++++++ docs/production.md | 75 ++++ generate.go | 3 + go.mod | 3 + internal/buildinfo/buildinfo.go | 7 + internal/clientip/resolver.go | 147 +++++++ internal/clientip/resolver_test.go | 137 +++++++ internal/config/config.go | 215 ++++++++++ internal/config/config_test.go | 130 ++++++ internal/httpapi/page.go | 600 ++++++++++++++++++++++++++++ internal/httpapi/server.go | 225 +++++++++++ internal/httpapi/server_test.go | 175 ++++++++ internal/httpapi/static.go | 16 + internal/httpapi/static/favicon.ico | Bin 0 -> 16195 bytes tools/icongen/main.go | 492 +++++++++++++++++++++++ 27 files changed, 2581 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/go.imports.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/start-project.iml create mode 100644 README.md create mode 100644 assets/favicon.ico create mode 100644 assets/favicon.svg create mode 100644 assets/icon.svg create mode 100644 assets/ipecho.ico create mode 100644 cmd/ipecho/main.go create mode 100644 cmd/ipecho/rsrc_windows_amd64.syso create mode 100644 docs/nginx.md create mode 100644 docs/production.md create mode 100644 generate.go create mode 100644 go.mod create mode 100644 internal/buildinfo/buildinfo.go create mode 100644 internal/clientip/resolver.go create mode 100644 internal/clientip/resolver_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/httpapi/page.go create mode 100644 internal/httpapi/server.go create mode 100644 internal/httpapi/server_test.go create mode 100644 internal/httpapi/static.go create mode 100644 internal/httpapi/static/favicon.ico create mode 100644 tools/icongen/main.go 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 0000000000000000000000000000000000000000..298e4e0c0e5f5447740aad491e6e0d6a42590de0 GIT binary patch literal 16195 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 J@!uc0{U7m5TI&D+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..298e4e0c0e5f5447740aad491e6e0d6a42590de0 GIT binary patch literal 16195 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 J@!uc0{U7m5TI&D+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5d79ee0df70fea4d34d25734b7b20be1f4485333 GIT binary patch literal 16798 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 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 0000000000000000000000000000000000000000..298e4e0c0e5f5447740aad491e6e0d6a42590de0 GIT binary patch literal 16195 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 J@!uc0{U7m5TI&D+ literal 0 HcmV?d00001 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 = ` + + + + + + + + + + + + +`