226 lines
5.5 KiB
Go
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))
|
|
}
|