first commit
This commit is contained in:
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 |
Reference in New Issue
Block a user