first commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/bin/
|
||||
/tmp/
|
||||
*.exe
|
||||
*.test
|
||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@@ -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/
|
||||
11
.idea/go.imports.xml
generated
Normal file
11
.idea/go.imports.xml
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GoImports">
|
||||
<option name="excludedPackages">
|
||||
<array>
|
||||
<option value="github.com/pkg/errors" />
|
||||
<option value="golang.org/x/net/context" />
|
||||
</array>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/start-project.iml" filepath="$PROJECT_DIR$/.idea/start-project.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
9
.idea/start-project.iml
generated
Normal file
9
.idea/start-project.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
75
README.md
Normal file
75
README.md
Normal file
@@ -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 ./...
|
||||
```
|
||||
BIN
assets/favicon.ico
Normal file
BIN
assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
13
assets/favicon.svg
Normal file
13
assets/favicon.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="IP Echo">
|
||||
<rect x="8" y="8" width="240" height="240" rx="42" fill="#0a5294"/>
|
||||
<rect width="240" height="240" rx="42" fill="#1167b1"/>
|
||||
<path d="M76 128h104M128 76v104" stroke="#fff" stroke-width="20" stroke-linecap="round"/>
|
||||
<circle cx="76" cy="128" r="31" fill="#fff"/>
|
||||
<circle cx="180" cy="128" r="31" fill="#fff"/>
|
||||
<circle cx="128" cy="76" r="31" fill="#fff"/>
|
||||
<circle cx="128" cy="180" r="31" fill="#fff"/>
|
||||
<circle cx="76" cy="128" r="22" fill="#f59f45"/>
|
||||
<circle cx="180" cy="128" r="22" fill="#41c7d7"/>
|
||||
<circle cx="128" cy="76" r="22" fill="#8bd6aa"/>
|
||||
<circle cx="128" cy="180" r="22" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 719 B |
13
assets/icon.svg
Normal file
13
assets/icon.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="IP Echo">
|
||||
<rect x="8" y="8" width="240" height="240" rx="42" fill="#0a5294"/>
|
||||
<rect width="240" height="240" rx="42" fill="#1167b1"/>
|
||||
<path d="M76 128h104M128 76v104" stroke="#fff" stroke-width="20" stroke-linecap="round"/>
|
||||
<circle cx="76" cy="128" r="31" fill="#fff"/>
|
||||
<circle cx="180" cy="128" r="31" fill="#fff"/>
|
||||
<circle cx="128" cy="76" r="31" fill="#fff"/>
|
||||
<circle cx="128" cy="180" r="31" fill="#fff"/>
|
||||
<circle cx="76" cy="128" r="22" fill="#f59f45"/>
|
||||
<circle cx="180" cy="128" r="22" fill="#41c7d7"/>
|
||||
<circle cx="128" cy="76" r="22" fill="#8bd6aa"/>
|
||||
<circle cx="128" cy="180" r="22" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 719 B |
BIN
assets/ipecho.ico
Normal file
BIN
assets/ipecho.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
87
cmd/ipecho/main.go
Normal file
87
cmd/ipecho/main.go
Normal file
@@ -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))
|
||||
}
|
||||
BIN
cmd/ipecho/rsrc_windows_amd64.syso
Normal file
BIN
cmd/ipecho/rsrc_windows_amd64.syso
Normal file
Binary file not shown.
126
docs/nginx.md
Normal file
126
docs/nginx.md
Normal file
@@ -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`.
|
||||
75
docs/production.md
Normal file
75
docs/production.md
Normal file
@@ -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`.
|
||||
3
generate.go
Normal file
3
generate.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package ipecho
|
||||
|
||||
//go:generate go run ./tools/icongen
|
||||
7
internal/buildinfo/buildinfo.go
Normal file
7
internal/buildinfo/buildinfo.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package buildinfo
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "none"
|
||||
Date = "unknown"
|
||||
)
|
||||
147
internal/clientip/resolver.go
Normal file
147
internal/clientip/resolver.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package clientip
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Resolver struct {
|
||||
TrustProxyHeaders bool
|
||||
TrustedProxies []netip.Prefix
|
||||
}
|
||||
|
||||
func (r Resolver) FromRequest(req *http.Request) string {
|
||||
if req == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if r.TrustsProxyHeaders(req) {
|
||||
if addr, ok := firstForwarded(headerValues(req.Header, "Forwarded")); ok {
|
||||
return addr.String()
|
||||
}
|
||||
|
||||
if addr, ok := firstForwardedFor(headerValues(req.Header, "X-Forwarded-For")); ok {
|
||||
return addr.String()
|
||||
}
|
||||
|
||||
if addr, ok := firstAddress(headerValues(req.Header, "X-Real-IP")); ok {
|
||||
return addr.String()
|
||||
}
|
||||
}
|
||||
|
||||
if addr, ok := parseAddress(req.RemoteAddr); ok {
|
||||
return addr.String()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r Resolver) TrustsProxyHeaders(req *http.Request) bool {
|
||||
if !r.TrustProxyHeaders || req == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(r.TrustedProxies) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
remoteAddr, ok := parseAddress(req.RemoteAddr)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, trustedProxy := range r.TrustedProxies {
|
||||
if trustedProxy.Contains(remoteAddr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func headerValues(header http.Header, name string) []string {
|
||||
values := header.Values(name)
|
||||
if len(values) > 0 {
|
||||
return values
|
||||
}
|
||||
|
||||
for key, values := range header {
|
||||
if strings.EqualFold(key, name) {
|
||||
return values
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstForwarded(values []string) (netip.Addr, bool) {
|
||||
for _, value := range values {
|
||||
for _, element := range strings.Split(value, ",") {
|
||||
for _, pair := range strings.Split(element, ";") {
|
||||
key, rawValue, ok := strings.Cut(pair, "=")
|
||||
if !ok || !strings.EqualFold(strings.TrimSpace(key), "for") {
|
||||
continue
|
||||
}
|
||||
|
||||
if addr, ok := parseAddress(rawValue); ok {
|
||||
return addr, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
func firstForwardedFor(values []string) (netip.Addr, bool) {
|
||||
for _, value := range values {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
if addr, ok := parseAddress(part); ok {
|
||||
return addr, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
func firstAddress(values []string) (netip.Addr, bool) {
|
||||
for _, value := range values {
|
||||
if addr, ok := parseAddress(value); ok {
|
||||
return addr, true
|
||||
}
|
||||
}
|
||||
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
func parseAddress(value string) (netip.Addr, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.Trim(value, `"`)
|
||||
|
||||
if addr, ok := parseBareAddress(value); ok {
|
||||
return addr, true
|
||||
}
|
||||
|
||||
host, _, err := net.SplitHostPort(value)
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
return parseBareAddress(host)
|
||||
}
|
||||
|
||||
func parseBareAddress(value string) (netip.Addr, bool) {
|
||||
if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
|
||||
value = strings.TrimPrefix(strings.TrimSuffix(value, "]"), "[")
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
return addr.Unmap(), true
|
||||
}
|
||||
137
internal/clientip/resolver_test.go
Normal file
137
internal/clientip/resolver_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package clientip
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolverUsesRemoteAddressByDefault(t *testing.T) {
|
||||
req := &http.Request{
|
||||
Header: http.Header{"X-Forwarded-For": {"203.0.113.10"}},
|
||||
RemoteAddr: "198.51.100.20:54123",
|
||||
}
|
||||
|
||||
got := (Resolver{}).FromRequest(req)
|
||||
want := "198.51.100.20"
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverCanTrustForwardedFor(t *testing.T) {
|
||||
req := &http.Request{
|
||||
Header: http.Header{
|
||||
"X-Forwarded-For": {"203.0.113.10, 198.51.100.20"},
|
||||
},
|
||||
RemoteAddr: "192.0.2.30:54123",
|
||||
}
|
||||
|
||||
got := (Resolver{TrustProxyHeaders: true}).FromRequest(req)
|
||||
want := "203.0.113.10"
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverCanTrustRFCForwardedHeader(t *testing.T) {
|
||||
req := &http.Request{
|
||||
Header: http.Header{
|
||||
"Forwarded": {`for="203.0.113.10";proto=https;host=ip.example.com`},
|
||||
},
|
||||
RemoteAddr: "192.0.2.30:54123",
|
||||
}
|
||||
|
||||
got := (Resolver{TrustProxyHeaders: true}).FromRequest(req)
|
||||
want := "203.0.113.10"
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverOnlyTrustsConfiguredProxyCIDRs(t *testing.T) {
|
||||
trustedProxy := netip.MustParsePrefix("127.0.0.1/32")
|
||||
resolver := Resolver{
|
||||
TrustProxyHeaders: true,
|
||||
TrustedProxies: []netip.Prefix{trustedProxy},
|
||||
}
|
||||
|
||||
req := &http.Request{
|
||||
Header: http.Header{"X-Forwarded-For": {"203.0.113.10"}},
|
||||
RemoteAddr: "198.51.100.20:54123",
|
||||
}
|
||||
|
||||
got := resolver.FromRequest(req)
|
||||
want := "198.51.100.20"
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverTrustsForwardedHeaderFromConfiguredProxyCIDR(t *testing.T) {
|
||||
trustedProxy := netip.MustParsePrefix("127.0.0.1/32")
|
||||
resolver := Resolver{
|
||||
TrustProxyHeaders: true,
|
||||
TrustedProxies: []netip.Prefix{trustedProxy},
|
||||
}
|
||||
|
||||
req := &http.Request{
|
||||
Header: http.Header{"X-Forwarded-For": {"203.0.113.10"}},
|
||||
RemoteAddr: "127.0.0.1:54123",
|
||||
}
|
||||
|
||||
got := resolver.FromRequest(req)
|
||||
want := "203.0.113.10"
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverFallsBackToRealIPHeader(t *testing.T) {
|
||||
req := &http.Request{
|
||||
Header: http.Header{"X-Real-IP": {"203.0.113.10"}},
|
||||
RemoteAddr: "192.0.2.30:54123",
|
||||
}
|
||||
|
||||
got := (Resolver{TrustProxyHeaders: true}).FromRequest(req)
|
||||
want := "203.0.113.10"
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverHandlesIPv6RemoteAddress(t *testing.T) {
|
||||
req := &http.Request{
|
||||
Header: http.Header{},
|
||||
RemoteAddr: "[2001:db8::1]:54123",
|
||||
}
|
||||
|
||||
got := (Resolver{}).FromRequest(req)
|
||||
want := "2001:db8::1"
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverSkipsInvalidForwardedForEntries(t *testing.T) {
|
||||
req := &http.Request{
|
||||
Header: http.Header{
|
||||
"X-Forwarded-For": {"unknown, 203.0.113.10"},
|
||||
},
|
||||
RemoteAddr: "192.0.2.30:54123",
|
||||
}
|
||||
|
||||
got := (Resolver{TrustProxyHeaders: true}).FromRequest(req)
|
||||
want := "203.0.113.10"
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
215
internal/config/config.go
Normal file
215
internal/config/config.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultTrustedProxyCIDRs = "127.0.0.1/32,::1/128"
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
TrustProxyHeaders bool
|
||||
TrustedProxyCIDRs []netip.Prefix
|
||||
ReadHeaderTimeout time.Duration
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
MaxHeaderBytes int
|
||||
LogLevel slog.Level
|
||||
LogFormat string
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
trustProxyHeaders, err := envBool("TRUST_PROXY_HEADERS", false)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
trustedProxyCIDRs, err := envPrefixes("TRUSTED_PROXY_CIDRS", defaultTrustedProxyCIDRs)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
readHeaderTimeout, err := envDuration("READ_HEADER_TIMEOUT", 5*time.Second)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
readTimeout, err := envDuration("READ_TIMEOUT", 10*time.Second)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
writeTimeout, err := envDuration("WRITE_TIMEOUT", 10*time.Second)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
idleTimeout, err := envDuration("IDLE_TIMEOUT", 60*time.Second)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
shutdownTimeout, err := envDuration("SHUTDOWN_TIMEOUT", 5*time.Second)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
maxHeaderBytes, err := envInt("MAX_HEADER_BYTES", 1<<20)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
logLevel, err := envLogLevel("LOG_LEVEL", slog.LevelInfo)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
logFormat, err := envLogFormat("LOG_FORMAT", "text")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return Config{
|
||||
Addr: envString("ADDR", ":8080"),
|
||||
TrustProxyHeaders: trustProxyHeaders,
|
||||
TrustedProxyCIDRs: trustedProxyCIDRs,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
IdleTimeout: idleTimeout,
|
||||
ShutdownTimeout: shutdownTimeout,
|
||||
MaxHeaderBytes: maxHeaderBytes,
|
||||
LogLevel: logLevel,
|
||||
LogFormat: logFormat,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func envString(name, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func envBool(name string, fallback bool) (bool, error) {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%s must be a boolean value: %w", name, err)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func envDuration(name string, fallback time.Duration) (time.Duration, error) {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s must be a duration like 5s or 1m: %w", name, err)
|
||||
}
|
||||
|
||||
if parsed <= 0 {
|
||||
return 0, fmt.Errorf("%s must be greater than zero", name)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func envInt(name string, fallback int) (int, error) {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s must be an integer: %w", name, err)
|
||||
}
|
||||
|
||||
if parsed <= 0 {
|
||||
return 0, fmt.Errorf("%s must be greater than zero", name)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func envPrefixes(name, fallback string) ([]netip.Prefix, error) {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
value = fallback
|
||||
}
|
||||
|
||||
parts := strings.Split(value, ",")
|
||||
prefixes := make([]netip.Prefix, 0, len(parts))
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if prefix, err := netip.ParsePrefix(part); err == nil {
|
||||
prefixes = append(prefixes, prefix.Masked())
|
||||
continue
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contains invalid CIDR or IP %q: %w", name, part, err)
|
||||
}
|
||||
|
||||
prefixes = append(prefixes, netip.PrefixFrom(addr, addr.BitLen()))
|
||||
}
|
||||
|
||||
if len(prefixes) == 0 {
|
||||
return nil, fmt.Errorf("%s must contain at least one CIDR or IP", name)
|
||||
}
|
||||
|
||||
return prefixes, nil
|
||||
}
|
||||
|
||||
func envLogLevel(name string, fallback slog.Level) (slog.Level, error) {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
var level slog.Level
|
||||
if err := level.UnmarshalText([]byte(strings.ToUpper(value))); err != nil {
|
||||
return 0, fmt.Errorf("%s must be debug, info, warn, or error: %w", name, err)
|
||||
}
|
||||
|
||||
return level, nil
|
||||
}
|
||||
|
||||
func envLogFormat(name, fallback string) (string, error) {
|
||||
value := strings.ToLower(strings.TrimSpace(os.Getenv(name)))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
switch value {
|
||||
case "text", "json":
|
||||
return value, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%s must be text or json", name)
|
||||
}
|
||||
}
|
||||
130
internal/config/config_test.go
Normal file
130
internal/config/config_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadUsesProductionDefaults(t *testing.T) {
|
||||
clearConfigEnv(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Addr != ":8080" {
|
||||
t.Fatalf("got addr %q, want %q", cfg.Addr, ":8080")
|
||||
}
|
||||
|
||||
if cfg.TrustProxyHeaders {
|
||||
t.Fatal("proxy headers should not be trusted by default")
|
||||
}
|
||||
|
||||
wantProxy := netip.MustParsePrefix("127.0.0.1/32")
|
||||
if len(cfg.TrustedProxyCIDRs) == 0 || cfg.TrustedProxyCIDRs[0] != wantProxy {
|
||||
t.Fatalf("got trusted proxies %v, want first %v", cfg.TrustedProxyCIDRs, wantProxy)
|
||||
}
|
||||
|
||||
if cfg.ReadHeaderTimeout != 5*time.Second {
|
||||
t.Fatalf("got read header timeout %s", cfg.ReadHeaderTimeout)
|
||||
}
|
||||
|
||||
if cfg.LogLevel != slog.LevelInfo {
|
||||
t.Fatalf("got log level %s, want info", cfg.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadParsesEnvironment(t *testing.T) {
|
||||
clearConfigEnv(t)
|
||||
t.Setenv("ADDR", "127.0.0.1:9090")
|
||||
t.Setenv("TRUST_PROXY_HEADERS", "true")
|
||||
t.Setenv("TRUSTED_PROXY_CIDRS", "10.0.0.0/8,192.0.2.10")
|
||||
t.Setenv("READ_HEADER_TIMEOUT", "3s")
|
||||
t.Setenv("READ_TIMEOUT", "4s")
|
||||
t.Setenv("WRITE_TIMEOUT", "5s")
|
||||
t.Setenv("IDLE_TIMEOUT", "30s")
|
||||
t.Setenv("SHUTDOWN_TIMEOUT", "7s")
|
||||
t.Setenv("MAX_HEADER_BYTES", "8192")
|
||||
t.Setenv("LOG_LEVEL", "debug")
|
||||
t.Setenv("LOG_FORMAT", "json")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Addr != "127.0.0.1:9090" {
|
||||
t.Fatalf("got addr %q", cfg.Addr)
|
||||
}
|
||||
|
||||
if !cfg.TrustProxyHeaders {
|
||||
t.Fatal("expected proxy headers to be trusted")
|
||||
}
|
||||
|
||||
if got, want := cfg.TrustedProxyCIDRs[1], netip.MustParsePrefix("192.0.2.10/32"); got != want {
|
||||
t.Fatalf("got proxy %v, want %v", got, want)
|
||||
}
|
||||
|
||||
if cfg.ReadTimeout != 4*time.Second || cfg.WriteTimeout != 5*time.Second {
|
||||
t.Fatalf("got read/write timeouts %s/%s", cfg.ReadTimeout, cfg.WriteTimeout)
|
||||
}
|
||||
|
||||
if cfg.MaxHeaderBytes != 8192 {
|
||||
t.Fatalf("got max header bytes %d", cfg.MaxHeaderBytes)
|
||||
}
|
||||
|
||||
if cfg.LogLevel != slog.LevelDebug || cfg.LogFormat != "json" {
|
||||
t.Fatalf("got logging %s/%s", cfg.LogLevel, cfg.LogFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidEnvironment(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"TRUST_PROXY_HEADERS": "yes-please",
|
||||
"TRUSTED_PROXY_CIDRS": "not-a-cidr",
|
||||
"TRUSTED_PROXY_CIDRS_EMPTY": ",",
|
||||
"READ_TIMEOUT": "forever",
|
||||
"MAX_HEADER_BYTES": "0",
|
||||
"LOG_LEVEL": "verbose",
|
||||
"LOG_FORMAT": "xml",
|
||||
}
|
||||
|
||||
for name, value := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
clearConfigEnv(t)
|
||||
envName := name
|
||||
if name == "TRUSTED_PROXY_CIDRS_EMPTY" {
|
||||
envName = "TRUSTED_PROXY_CIDRS"
|
||||
}
|
||||
|
||||
t.Setenv(envName, value)
|
||||
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func clearConfigEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
for _, name := range []string{
|
||||
"ADDR",
|
||||
"TRUST_PROXY_HEADERS",
|
||||
"TRUSTED_PROXY_CIDRS",
|
||||
"READ_HEADER_TIMEOUT",
|
||||
"READ_TIMEOUT",
|
||||
"WRITE_TIMEOUT",
|
||||
"IDLE_TIMEOUT",
|
||||
"SHUTDOWN_TIMEOUT",
|
||||
"MAX_HEADER_BYTES",
|
||||
"LOG_LEVEL",
|
||||
"LOG_FORMAT",
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
600
internal/httpapi/page.go
Normal file
600
internal/httpapi/page.go
Normal file
@@ -0,0 +1,600 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var homeTemplate = template.Must(template.New("home").Parse(homePageHTML))
|
||||
|
||||
type homePageData struct {
|
||||
Info ClientInfo
|
||||
BaseURL string
|
||||
Commands []commandLink
|
||||
Endpoints []endpointLink
|
||||
}
|
||||
|
||||
type commandLink struct {
|
||||
Label string
|
||||
Value string
|
||||
}
|
||||
|
||||
type endpointLink struct {
|
||||
Path string
|
||||
Description string
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, req *http.Request) {
|
||||
baseURL := s.requestBaseURL(req)
|
||||
data := homePageData{
|
||||
Info: s.clientInfo(req),
|
||||
BaseURL: baseURL,
|
||||
Commands: []commandLink{
|
||||
{Label: "IP", Value: "curl " + baseURL + "/ip"},
|
||||
{Label: "JSON", Value: "curl " + baseURL + "/json"},
|
||||
{Label: "Headers", Value: "curl " + baseURL + "/headers"},
|
||||
},
|
||||
Endpoints: []endpointLink{
|
||||
{Path: "/", Description: "Plain IP for CLI, dashboard for browsers"},
|
||||
{Path: "/ip", Description: "Plain text client IP"},
|
||||
{Path: "/json", Description: "Request metadata as JSON"},
|
||||
{Path: "/headers", Description: "Request headers as text"},
|
||||
{Path: "/user-agent", Description: "User agent as text"},
|
||||
{Path: "/healthz", Description: "Health check"},
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = homeTemplate.Execute(w, data)
|
||||
}
|
||||
|
||||
func (s *Server) requestBaseURL(req *http.Request) string {
|
||||
scheme := "http"
|
||||
if req.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
host := req.Host
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
|
||||
if s.resolver.TrustsProxyHeaders(req) {
|
||||
if proto := firstCommaSeparatedHeader(req.Header.Get("X-Forwarded-Proto")); proto == "http" || proto == "https" {
|
||||
scheme = proto
|
||||
}
|
||||
|
||||
if forwardedHost := firstCommaSeparatedHeader(req.Header.Get("X-Forwarded-Host")); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
}
|
||||
}
|
||||
|
||||
return scheme + "://" + host
|
||||
}
|
||||
|
||||
func firstCommaSeparatedHeader(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
value, _, _ = strings.Cut(value, ",")
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
const homePageHTML = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>IP Echo</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7fb;
|
||||
--panel: #ffffff;
|
||||
--panel-soft: #eef4f8;
|
||||
--text: #162033;
|
||||
--muted: #667085;
|
||||
--line: #d9e2ec;
|
||||
--blue: #1167b1;
|
||||
--cyan: #0b9fb3;
|
||||
--green: #1f8a5b;
|
||||
--orange: #c85f16;
|
||||
--shadow: 0 18px 45px rgba(22, 32, 51, 0.11);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
linear-gradient(180deg, #ffffff 0, var(--bg) 340px),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1120px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 40px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 42px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 5px 12px;
|
||||
color: var(--green);
|
||||
background: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status::before {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr);
|
||||
gap: 20px;
|
||||
align-items: stretch;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ip-panel,
|
||||
.trace-panel,
|
||||
.section,
|
||||
.endpoint {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.ip-panel {
|
||||
padding: 30px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: var(--orange);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ip-value {
|
||||
margin: 12px 0 20px;
|
||||
font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 42px;
|
||||
font-weight: 760;
|
||||
line-height: 1.15;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex: 0 0 17px;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: var(--blue);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
border-color: var(--line);
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.detail span {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail strong {
|
||||
display: block;
|
||||
font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.trace-panel {
|
||||
min-height: 100%;
|
||||
padding: 22px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(17, 103, 177, 0.08), transparent 55%),
|
||||
#ffffff;
|
||||
}
|
||||
|
||||
.trace-visual {
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.trace-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 18px;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.trace-copy {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 17px;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.commands {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.command {
|
||||
display: grid;
|
||||
grid-template-columns: 76px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.command span {
|
||||
color: var(--cyan);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.endpoints {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.endpoint {
|
||||
min-height: 92px;
|
||||
padding: 14px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.endpoint code {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--blue);
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.endpoint span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
transform: translateY(12px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
background: #ffffff;
|
||||
color: var(--green);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.toast.is-visible {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.hero,
|
||||
.grid,
|
||||
.details,
|
||||
.endpoints {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ip-value {
|
||||
font-size: 31px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.shell {
|
||||
width: min(100% - 20px, 1120px);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ip-panel,
|
||||
.trace-panel,
|
||||
.section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ip-value {
|
||||
font-size: 27px;
|
||||
}
|
||||
|
||||
.command {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<svg class="mark" viewBox="0 0 48 48" role="img" aria-label="IP Echo">
|
||||
<rect width="48" height="48" rx="8" fill="#1167b1"></rect>
|
||||
<path d="M14 25h20M24 14v20" stroke="#ffffff" stroke-width="3" stroke-linecap="round"></path>
|
||||
<circle cx="14" cy="25" r="4" fill="#f59f45"></circle>
|
||||
<circle cx="34" cy="25" r="4" fill="#41c7d7"></circle>
|
||||
<circle cx="24" cy="14" r="4" fill="#8bd6aa"></circle>
|
||||
<circle cx="24" cy="34" r="4" fill="#ffffff"></circle>
|
||||
</svg>
|
||||
<div>
|
||||
<strong>IP Echo</strong>
|
||||
<span>Client network lookup</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status">Online</div>
|
||||
</header>
|
||||
|
||||
<section class="hero" aria-label="Client IP summary">
|
||||
<div class="ip-panel">
|
||||
<p class="eyebrow">Public address</p>
|
||||
<h1>Your visible IP address</h1>
|
||||
<div class="ip-value" id="client-ip">{{.Info.IP}}</div>
|
||||
<div class="actions">
|
||||
<button class="button button-primary" type="button" data-copy="{{.Info.IP}}">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 8h10v12H8zM6 16H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
Copy IP
|
||||
</button>
|
||||
<a class="button button-secondary" href="/json">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 17 3 12l5-5M16 7l5 5-5 5M14 4l-4 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
JSON
|
||||
</a>
|
||||
<a class="button button-secondary" href="/headers">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h16M4 12h16M4 17h10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
Headers
|
||||
</a>
|
||||
</div>
|
||||
<div class="details">
|
||||
<div class="detail">
|
||||
<span>Remote address</span>
|
||||
<strong>{{.Info.RemoteAddr}}</strong>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span>Method</span>
|
||||
<strong>{{.Info.Method}}</strong>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span>User agent</span>
|
||||
<strong>{{.Info.UserAgent}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="trace-panel" aria-label="Request path">
|
||||
<svg class="trace-visual" viewBox="0 0 360 220" role="img" aria-label="Browser request reaching IP Echo">
|
||||
<defs>
|
||||
<linearGradient id="line" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop stop-color="#1167b1"></stop>
|
||||
<stop offset="0.55" stop-color="#0b9fb3"></stop>
|
||||
<stop offset="1" stop-color="#1f8a5b"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="1" y="1" width="358" height="218" rx="8" fill="#f8fbfd" stroke="#d9e2ec"></rect>
|
||||
<path d="M72 112 C126 50, 214 50, 286 108" fill="none" stroke="url(#line)" stroke-width="5" stroke-linecap="round"></path>
|
||||
<path d="M72 112 C126 170, 214 170, 286 108" fill="none" stroke="#d9e2ec" stroke-width="2" stroke-dasharray="7 8" stroke-linecap="round"></path>
|
||||
<circle cx="72" cy="112" r="28" fill="#ffffff" stroke="#1167b1" stroke-width="4"></circle>
|
||||
<circle cx="286" cy="108" r="34" fill="#ffffff" stroke="#1f8a5b" stroke-width="4"></circle>
|
||||
<circle cx="179" cy="58" r="12" fill="#f59f45"></circle>
|
||||
<circle cx="179" cy="164" r="12" fill="#41c7d7"></circle>
|
||||
<path d="M58 104h28v18H58zM62 99h20" fill="none" stroke="#1167b1" stroke-width="3" stroke-linejoin="round"></path>
|
||||
<path d="M271 96h30v24h-30zM277 102h18M277 109h18M277 116h10" fill="none" stroke="#1f8a5b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<text x="72" y="158" text-anchor="middle" font-family="Consolas, monospace" font-size="13" fill="#667085">client</text>
|
||||
<text x="286" y="158" text-anchor="middle" font-family="Consolas, monospace" font-size="13" fill="#667085">server</text>
|
||||
</svg>
|
||||
<p class="trace-title">Request received</p>
|
||||
<p class="trace-copy">The service reports the address seen by this Go server. Behind a trusted proxy, it can use forwarded headers.</p>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="section">
|
||||
<h2>CLI</h2>
|
||||
<div class="commands">
|
||||
{{range .Commands}}
|
||||
<div class="command">
|
||||
<span>{{.Label}}</span>
|
||||
<code>{{.Value}}</code>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Endpoints</h2>
|
||||
<div class="endpoints">
|
||||
{{range .Endpoints}}
|
||||
<a class="endpoint" href="{{.Path}}">
|
||||
<code>{{.Path}}</code>
|
||||
<span>{{.Description}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast" role="status" aria-live="polite">Copied</div>
|
||||
|
||||
<script>
|
||||
const toast = document.querySelector("#toast");
|
||||
document.querySelectorAll("[data-copy]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(button.dataset.copy);
|
||||
toast.classList.add("is-visible");
|
||||
window.setTimeout(() => toast.classList.remove("is-visible"), 1400);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
225
internal/httpapi/server.go
Normal file
225
internal/httpapi/server.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ipecho/internal/clientip"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
resolver clientip.Resolver
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type ClientInfo struct {
|
||||
IP string `json:"ip"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Headers map[string][]string `json:"headers"`
|
||||
}
|
||||
|
||||
func New(resolver clientip.Resolver) http.Handler {
|
||||
return NewWithLogger(resolver, discardLogger())
|
||||
}
|
||||
|
||||
func NewWithLogger(resolver clientip.Resolver, logger *slog.Logger) http.Handler {
|
||||
if logger == nil {
|
||||
logger = discardLogger()
|
||||
}
|
||||
|
||||
server := &Server{
|
||||
resolver: resolver,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /{$}", server.handleRoot)
|
||||
mux.HandleFunc("GET /ip", server.handleIP)
|
||||
mux.HandleFunc("GET /json", server.handleJSON)
|
||||
mux.HandleFunc("GET /headers", server.handleHeaders)
|
||||
mux.HandleFunc("GET /user-agent", server.handleUserAgent)
|
||||
mux.HandleFunc("GET /healthz", server.handleHealth)
|
||||
mux.HandleFunc("GET /favicon.ico", server.handleFavicon)
|
||||
|
||||
return accessLog(secureHeaders(mux), logger, resolver)
|
||||
}
|
||||
|
||||
func (s *Server) handleRoot(w http.ResponseWriter, req *http.Request) {
|
||||
if wantsHTML(req) {
|
||||
s.handleHome(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
s.handleIP(w, req)
|
||||
}
|
||||
|
||||
func (s *Server) handleIP(w http.ResponseWriter, req *http.Request) {
|
||||
writePlain(w, http.StatusOK, s.resolver.FromRequest(req))
|
||||
}
|
||||
|
||||
func (s *Server) handleJSON(w http.ResponseWriter, req *http.Request) {
|
||||
info := s.clientInfo(req)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", " ")
|
||||
_ = encoder.Encode(info)
|
||||
}
|
||||
|
||||
func (s *Server) handleHeaders(w http.ResponseWriter, req *http.Request) {
|
||||
writePlain(w, http.StatusOK, formatHeaders(req))
|
||||
}
|
||||
|
||||
func (s *Server) handleUserAgent(w http.ResponseWriter, req *http.Request) {
|
||||
writePlain(w, http.StatusOK, req.UserAgent())
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writePlain(w, http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
func (s *Server) clientInfo(req *http.Request) ClientInfo {
|
||||
return ClientInfo{
|
||||
IP: s.resolver.FromRequest(req),
|
||||
RemoteAddr: req.RemoteAddr,
|
||||
Method: req.Method,
|
||||
Path: req.URL.Path,
|
||||
UserAgent: req.UserAgent(),
|
||||
Headers: cloneHeaders(req),
|
||||
}
|
||||
}
|
||||
|
||||
func secureHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
func accessLog(next http.Handler, logger *slog.Logger, resolver clientip.Resolver) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
startedAt := time.Now()
|
||||
recorder := &responseRecorder{ResponseWriter: w}
|
||||
|
||||
next.ServeHTTP(recorder, req)
|
||||
|
||||
status := recorder.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
|
||||
logger.Info("http_request",
|
||||
"method", req.Method,
|
||||
"path", req.URL.Path,
|
||||
"status", status,
|
||||
"bytes", recorder.bytes,
|
||||
"duration", time.Since(startedAt).String(),
|
||||
"client_ip", resolver.FromRequest(req),
|
||||
"remote_addr", req.RemoteAddr,
|
||||
"user_agent", req.UserAgent(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
type responseRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (r *responseRecorder) WriteHeader(status int) {
|
||||
if r.status != 0 {
|
||||
return
|
||||
}
|
||||
|
||||
r.status = status
|
||||
r.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (r *responseRecorder) Write(body []byte) (int, error) {
|
||||
if r.status == 0 {
|
||||
r.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
written, err := r.ResponseWriter.Write(body)
|
||||
r.bytes += written
|
||||
|
||||
return written, err
|
||||
}
|
||||
|
||||
func (r *responseRecorder) Unwrap() http.ResponseWriter {
|
||||
return r.ResponseWriter
|
||||
}
|
||||
|
||||
func wantsHTML(req *http.Request) bool {
|
||||
for _, value := range req.Header.Values("Accept") {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
mediaType := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
|
||||
if mediaType == "text/html" || mediaType == "application/xhtml+xml" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func writePlain(w http.ResponseWriter, status int, body string) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_, _ = fmt.Fprintln(w, body)
|
||||
}
|
||||
|
||||
func cloneHeaders(req *http.Request) map[string][]string {
|
||||
headers := make(map[string][]string, len(req.Header)+1)
|
||||
headers["Host"] = []string{req.Host}
|
||||
|
||||
for name, values := range req.Header {
|
||||
headers[name] = append([]string(nil), values...)
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
func formatHeaders(req *http.Request) string {
|
||||
headers := cloneHeaders(req)
|
||||
names := make([]string, 0, len(headers))
|
||||
|
||||
for name := range headers {
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
sort.Strings(names)
|
||||
|
||||
var builder strings.Builder
|
||||
for _, name := range names {
|
||||
for _, value := range headers[name] {
|
||||
builder.WriteString(name)
|
||||
builder.WriteString(": ")
|
||||
builder.WriteString(value)
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimRight(builder.String(), "\n")
|
||||
}
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
175
internal/httpapi/server_test.go
Normal file
175
internal/httpapi/server_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ipecho/internal/clientip"
|
||||
)
|
||||
|
||||
func TestIPEndpointReturnsPlainTextClientIP(t *testing.T) {
|
||||
handler := New(clientip.Resolver{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/ip", nil)
|
||||
req.RemoteAddr = "198.51.100.20:54123"
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got status %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if got, want := rec.Body.String(), "198.51.100.20\n"; got != want {
|
||||
t.Fatalf("got body %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/plain") {
|
||||
t.Fatalf("got content-type %q, want text/plain", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootReturnsPlainTextClientIPForCLIRequests(t *testing.T) {
|
||||
handler := New(clientip.Resolver{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "198.51.100.20:54123"
|
||||
req.Header.Set("Accept", "*/*")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got status %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if got, want := rec.Body.String(), "198.51.100.20\n"; got != want {
|
||||
t.Fatalf("got body %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/plain") {
|
||||
t.Fatalf("got content-type %q, want text/plain", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootReturnsHTMLForBrowserRequests(t *testing.T) {
|
||||
handler := New(clientip.Resolver{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "198.51.100.20:54123"
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got status %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
|
||||
t.Fatalf("got content-type %q, want text/html", got)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"IP Echo", "198.51.100.20", "Copy IP", "/json"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("body does not contain %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONEndpointReturnsRequestMetadata(t *testing.T) {
|
||||
handler := New(clientip.Resolver{TrustProxyHeaders: true})
|
||||
req := httptest.NewRequest(http.MethodGet, "/json", nil)
|
||||
req.RemoteAddr = "198.51.100.20:54123"
|
||||
req.Header.Set("User-Agent", "ipecho-test")
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.10")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got status %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var got ClientInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if got.IP != "203.0.113.10" {
|
||||
t.Fatalf("got ip %q, want %q", got.IP, "203.0.113.10")
|
||||
}
|
||||
|
||||
if got.UserAgent != "ipecho-test" {
|
||||
t.Fatalf("got user agent %q, want %q", got.UserAgent, "ipecho-test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadersEndpointIncludesHostAndHeaders(t *testing.T) {
|
||||
handler := New(clientip.Resolver{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/headers", nil)
|
||||
req.Host = "example.test"
|
||||
req.Header.Set("User-Agent", "ipecho-test")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"Host: example.test", "User-Agent: ipecho-test"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("body %q does not contain %q", body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaviconEndpointReturnsIconWithoutHTMLLink(t *testing.T) {
|
||||
handler := New(clientip.Resolver{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got status %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if got := rec.Header().Get("Content-Type"); got != "image/x-icon" {
|
||||
t.Fatalf("got content-type %q, want image/x-icon", got)
|
||||
}
|
||||
|
||||
if rec.Body.Len() == 0 {
|
||||
t.Fatal("favicon response is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersAreApplied(t *testing.T) {
|
||||
handler := New(clientip.Resolver{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
for _, header := range []string{
|
||||
"X-Content-Type-Options",
|
||||
"X-Frame-Options",
|
||||
"Referrer-Policy",
|
||||
"Content-Security-Policy",
|
||||
} {
|
||||
if rec.Header().Get(header) == "" {
|
||||
t.Fatalf("missing %s header", header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownEndpointReturnsNotFound(t *testing.T) {
|
||||
handler := New(clientip.Resolver{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/missing", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("got status %d, want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
16
internal/httpapi/static.go
Normal file
16
internal/httpapi/static.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed static/favicon.ico
|
||||
var faviconICO []byte
|
||||
|
||||
func (s *Server) handleFavicon(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/x-icon")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(faviconICO)
|
||||
}
|
||||
BIN
internal/httpapi/static/favicon.ico
Normal file
BIN
internal/httpapi/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
492
tools/icongen/main.go
Normal file
492
tools/icongen/main.go
Normal file
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="IP Echo">
|
||||
<rect x="8" y="8" width="240" height="240" rx="42" fill="#0a5294"/>
|
||||
<rect width="240" height="240" rx="42" fill="#1167b1"/>
|
||||
<path d="M76 128h104M128 76v104" stroke="#fff" stroke-width="20" stroke-linecap="round"/>
|
||||
<circle cx="76" cy="128" r="31" fill="#fff"/>
|
||||
<circle cx="180" cy="128" r="31" fill="#fff"/>
|
||||
<circle cx="128" cy="76" r="31" fill="#fff"/>
|
||||
<circle cx="128" cy="180" r="31" fill="#fff"/>
|
||||
<circle cx="76" cy="128" r="22" fill="#f59f45"/>
|
||||
<circle cx="180" cy="128" r="22" fill="#41c7d7"/>
|
||||
<circle cx="128" cy="76" r="22" fill="#8bd6aa"/>
|
||||
<circle cx="128" cy="180" r="22" fill="#fff"/>
|
||||
</svg>
|
||||
`
|
||||
Reference in New Issue
Block a user