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