Files
ip-echo/internal/httpapi/server.go
2026-05-13 03:05:24 +03:30

226 lines
5.5 KiB
Go

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))
}