601 lines
15 KiB
Go
601 lines
15 KiB
Go
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>
|
|
`
|