include db for gitea build
This commit is contained in:
1 parent
8de4a2e010
commit
645f923175
33 files changed
+1490
-150
No files matched your search
@@ -15,6 +15,7 @@ import {
|
||||
RotateCcw,
|
||||
ScrollText,
|
||||
ShieldBan,
|
||||
Bot,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
X,
|
||||
@@ -38,7 +39,7 @@ import { IpButton } from "./common/IpButton"
|
||||
import { IssueOccurrences } from "./IssueOccurrences"
|
||||
import { NeedleTestModal } from "./NeedleTestModal"
|
||||
import { FilterBar, type FilterChip } from "./FilterBar"
|
||||
import { UA_CLASSES, uaClassLabel } from "../lib/ua-class"
|
||||
import { CRAWLER_CLASSES, UA_CLASSES, uaClassLabel } from "../lib/ua-class"
|
||||
import type { Issue, IssueSample } from "../lib/types"
|
||||
|
||||
export const kindMeta: Record<string, { label: string; Icon: typeof Activity }> = {
|
||||
@@ -64,6 +65,20 @@ interface HoneypotIP {
|
||||
promoted_until: string
|
||||
}
|
||||
|
||||
interface BlockerStatus {
|
||||
enabled: boolean
|
||||
dryRun: boolean
|
||||
candidates?: string[]
|
||||
managed?: Record<string, number>
|
||||
}
|
||||
|
||||
/** An IP flagged by the honeypot tracker or the ingress blocker. */
|
||||
interface DetectedIP {
|
||||
ip: string
|
||||
sources: ("honeypot" | "blocker")[]
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface DetectorTestResult {
|
||||
passed?: boolean
|
||||
scenario?: string
|
||||
@@ -684,6 +699,7 @@ export const IssuesVisualizer = ({
|
||||
const [clientExclude, setClientExclude] = useState<Set<string>>(new Set())
|
||||
const [showSpamIPs, setShowSpamIPs] = useState(false)
|
||||
const [spamIPs, setSpamIPs] = useState<HoneypotIP[]>([])
|
||||
const [blockerStatus, setBlockerStatus] = useState<BlockerStatus | null>(null)
|
||||
const [spamIPsLoading, setSpamIPsLoading] = useState(false)
|
||||
const [spamIPsError, setSpamIPsError] = useState<string | null>(null)
|
||||
|
||||
@@ -694,6 +710,9 @@ export const IssuesVisualizer = ({
|
||||
const response = await authFetch("/api/honeypot/ips")
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
setSpamIPs((await response.json()) as HoneypotIP[])
|
||||
// The blocker route only exists when the blocker is enabled; its absence is not an error.
|
||||
const blocker = await authFetch("/api/blocker/status").catch(() => null)
|
||||
if (blocker?.ok) setBlockerStatus((await blocker.json()) as BlockerStatus)
|
||||
} catch (err) {
|
||||
setSpamIPsError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
@@ -701,7 +720,32 @@ export const IssuesVisualizer = ({
|
||||
}
|
||||
}
|
||||
|
||||
const spamIPSet = useMemo(() => new Set(spamIPs.map((entry) => entry.ip)), [spamIPs])
|
||||
const detectedIPs = useMemo(() => {
|
||||
const byIP = new Map<string, DetectedIP>()
|
||||
for (const entry of spamIPs) byIP.set(entry.ip, { ip: entry.ip, sources: ["honeypot"], count: entry.count })
|
||||
for (const ip of blockerStatus?.candidates ?? []) {
|
||||
const known = byIP.get(ip)
|
||||
if (known) known.sources.push("blocker")
|
||||
else byIP.set(ip, { ip, sources: ["blocker"] })
|
||||
}
|
||||
return Array.from(byIP.values())
|
||||
}, [spamIPs, blockerStatus])
|
||||
const spamIPSet = useMemo(() => new Set(detectedIPs.map((entry) => entry.ip)), [detectedIPs])
|
||||
|
||||
const crawlersHidden = CRAWLER_CLASSES.every((c) => clientExclude.has(c))
|
||||
const toggleCrawlers = () => {
|
||||
setClientExclude((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (CRAWLER_CLASSES.every((c) => prev.has(c))) CRAWLER_CLASSES.forEach((c) => next.delete(c))
|
||||
else CRAWLER_CLASSES.forEach((c) => next.add(c))
|
||||
return next
|
||||
})
|
||||
setClientInclude((prev) => {
|
||||
const next = new Set(prev)
|
||||
CRAWLER_CLASSES.forEach((c) => next.delete(c))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<StatusFilter, number> = { all: issues.length, open: 0, resolved: 0, ignored: 0 }
|
||||
@@ -954,7 +998,7 @@ export const IssuesVisualizer = ({
|
||||
setExcludeSpamIPs((v) => !v)
|
||||
}}
|
||||
aria-pressed={excludeSpamIPs}
|
||||
title="Hide HTTP errors whose recorded source IP is on the honeypot list"
|
||||
title="Hide HTTP errors whose recorded source IP is on the honeypot list or the blocker's candidate list"
|
||||
className={`inline-flex h-9 items-center gap-1.5 rounded-[9px] border px-2.5 text-[12px] font-medium transition-colors ${
|
||||
excludeSpamIPs
|
||||
? "border-ds-warn/40 bg-ds-warn-bg text-ds-warn"
|
||||
@@ -964,6 +1008,20 @@ export const IssuesVisualizer = ({
|
||||
<ShieldBan className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{excludeSpamIPs ? "Spam IPs hidden" : "Hide spam IPs"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCrawlers}
|
||||
aria-pressed={crawlersHidden}
|
||||
title="Hide issues whose requests came from AI crawlers, AI agents, search/SEO crawlers, scan tools or other self-declared bots (by User-Agent)"
|
||||
className={`inline-flex h-9 items-center gap-1.5 rounded-[9px] border px-2.5 text-[12px] font-medium transition-colors ${
|
||||
crawlersHidden
|
||||
? "border-ds-warn/40 bg-ds-warn-bg text-ds-warn"
|
||||
: "border-ds-line-strong text-ds-text-3 hover:text-ds-text-2"
|
||||
}`}
|
||||
>
|
||||
<Bot className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{crawlersHidden ? "Crawlers hidden" : "Hide crawlers"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNeedleModalOpen(true)}
|
||||
@@ -1030,8 +1088,11 @@ export const IssuesVisualizer = ({
|
||||
<div className="rounded-[12px] border border-ds-warn/30 bg-ds-warn-bg p-3 text-[12.5px] text-ds-text-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-bold text-ds-warn">Promoted honeypot IPs</div>
|
||||
<div className="text-[11px] text-ds-text-3">Used by the ingress tarpit synchronizer.</div>
|
||||
<div className="font-bold text-ds-warn">Detected scanner IPs</div>
|
||||
<div className="text-[11px] text-ds-text-3">
|
||||
Honeypot-promoted IPs (ingress tarpit) and blocker candidates
|
||||
{blockerStatus ? (blockerStatus.dryRun ? " (blocker in dry-run: nothing is blocked)" : " (blocker enforcing)") : ""}.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1044,15 +1105,18 @@ export const IssuesVisualizer = ({
|
||||
</div>
|
||||
{spamIPsLoading && <div className="text-ds-text-3">Loading…</div>}
|
||||
{spamIPsError && <div className="text-ds-critical">Failed to load: {spamIPsError}</div>}
|
||||
{!spamIPsLoading && !spamIPsError && spamIPs.length === 0 && (
|
||||
<div className="text-ds-text-3">No promoted IPs.</div>
|
||||
{!spamIPsLoading && !spamIPsError && detectedIPs.length === 0 && (
|
||||
<div className="text-ds-text-3">No detected IPs.</div>
|
||||
)}
|
||||
{spamIPs.length > 0 && (
|
||||
{detectedIPs.length > 0 && (
|
||||
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{spamIPs.map((entry) => (
|
||||
<div key={entry.ip} className="flex items-center justify-between rounded-[8px] bg-ds-bg px-2.5 py-1.5 font-[family-name:var(--font-data)]">
|
||||
<span className="text-ds-text">{entry.ip}</span>
|
||||
<span className="text-[10.5px] text-ds-warn">×{entry.count}</span>
|
||||
{detectedIPs.map((entry) => (
|
||||
<div key={entry.ip} className="flex items-center justify-between gap-2 rounded-[8px] bg-ds-bg px-2.5 py-1.5 font-[family-name:var(--font-data)]">
|
||||
<IpButton ip={entry.ip} />
|
||||
<span className="shrink-0 whitespace-nowrap text-[10.5px] text-ds-warn">
|
||||
{entry.sources.join(" + ")}
|
||||
{entry.count !== undefined ? ` ×${entry.count}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1069,7 +1133,7 @@ export const IssuesVisualizer = ({
|
||||
}}
|
||||
className="self-start text-[11.5px] text-ds-text-3 hover:text-ds-text-2"
|
||||
>
|
||||
View promoted spam IPs ({spamIPs.length})
|
||||
View detected scanner IPs ({detectedIPs.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ export const IconRail: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("tab0")}
|
||||
aria-label="Stackship — go to triage overview"
|
||||
aria-label="Ohhtel — go to triage overview"
|
||||
className="mb-2 flex h-[34px] w-[34px] items-center justify-center rounded-[10px] bg-ds-accent-bg text-ds-accent"
|
||||
>
|
||||
<Waypoints className="h-[19px] w-[19px]" aria-hidden="true" />
|
||||
|
||||
@@ -45,7 +45,7 @@ export const TopBar: React.FC = () => {
|
||||
<header className="fixed left-[72px] right-0 top-0 z-40 flex h-[58px] items-center gap-4 border-b border-ds-line bg-ds-chrome px-6 font-[family-name:var(--font-ui)]">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="font-[family-name:var(--font-display)] text-base font-bold tracking-[-0.01em] text-ds-text">
|
||||
Stackship
|
||||
OhhTel
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Activity } from "lucide-react"
|
||||
import { SingleTraceView, type ProcessedTrace } from "../TraceVisualization"
|
||||
import { useObservabilityData } from "../../hooks/useObservabilityData"
|
||||
import { SectionLabel } from "../common/DetailField"
|
||||
import { IpButton } from "../common/IpButton"
|
||||
import { resolveTraceClient } from "../../lib/trace-client"
|
||||
|
||||
/** Waterfall + span inspector for one trace, with an "Open in Traces" jump. Handles the not-yet-loaded state. */
|
||||
export const TraceDetail = ({
|
||||
@@ -28,6 +30,7 @@ export const TraceDetail = ({
|
||||
if (spanId) setActiveSpanId(spanId)
|
||||
setActiveTab("tab1")
|
||||
}
|
||||
const client = trace ? resolveTraceClient(trace) : null
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -41,6 +44,16 @@ export const TraceDetail = ({
|
||||
Open in Traces
|
||||
</button>
|
||||
</div>
|
||||
{client && (
|
||||
<p className="flex flex-wrap items-center gap-x-2 text-[12px] text-ds-text-3">
|
||||
<span className="text-[10.5px] font-bold uppercase tracking-widest">Client</span>
|
||||
<IpButton ip={client.ip} />
|
||||
<span>
|
||||
seen by {client.service}
|
||||
{client.hadInternalHops ? ", resolved past internal proxy hops" : ""}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{trace ? (
|
||||
<SingleTraceView
|
||||
key={trace.id}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Resolves the real client of a trace. One request is recorded by several
|
||||
// spans (ingress outer span, ingress inner span on a loopback hop, the app
|
||||
// span) and only the outer one sees the public peer, so a single span's
|
||||
// net.peer.ip is often 127.0.0.1 or a pod address. The Go side does the same
|
||||
// walk in internal/tracectx; keep the two definitions of "public" in step.
|
||||
import type { ProcessedSpan, ProcessedTrace } from "../components/TraceVisualization"
|
||||
|
||||
const PEER_KEYS = ["net.peer.ip", "client.address", "network.peer.address", "net.sock.peer.addr"]
|
||||
|
||||
const inRange = (a: number, b: number, lo: number, hi: number) => a === lo && b >= 0 && b <= hi
|
||||
|
||||
/** True for addresses that could be a real remote client: not loopback, private, link-local, CGNAT or reserved. */
|
||||
export const isPublicIp = (ip: string): boolean => {
|
||||
const v = ip.trim().toLowerCase()
|
||||
if (!v) return false
|
||||
if (v.includes(":")) {
|
||||
return !(v === "::1" || v === "::" || v.startsWith("fe80:") || v.startsWith("fc") || v.startsWith("fd") || v.startsWith("ff") || v.startsWith("::ffff:"))
|
||||
}
|
||||
const parts = v.split(".").map(Number)
|
||||
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false
|
||||
const [a, b] = parts
|
||||
if (a === 0 || a === 10 || a === 127 || a >= 224) return false
|
||||
if (a === 169 && b === 254) return false
|
||||
if (inRange(a, b, 172, 31) && b >= 16) return false
|
||||
if (a === 192 && b === 168) return false
|
||||
if (a === 100 && b >= 64 && b <= 127) return false // CGNAT / Tailscale
|
||||
if (a === 198 && (b === 18 || b === 19)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export interface TraceClient {
|
||||
ip: string
|
||||
service: string
|
||||
spanId: string
|
||||
/** Ancestors between the outermost span and the span that carried the address (0 = outermost). */
|
||||
depth: number
|
||||
/** True when at least one span in the trace saw only an internal hop (loopback / pod / NAT). */
|
||||
hadInternalHops: boolean
|
||||
}
|
||||
|
||||
/** The public peer address seen closest to the outside of the trace, or null when every hop is internal. */
|
||||
export const resolveTraceClient = (trace: ProcessedTrace): TraceClient | null => {
|
||||
const byId = new Map<string, ProcessedSpan>(trace.spans.map((s) => [s.spanId, s]))
|
||||
const depthOf = (span: ProcessedSpan): number => {
|
||||
let depth = 0
|
||||
let cur: ProcessedSpan | undefined = span
|
||||
while (cur?.parentSpanId && depth < 32) {
|
||||
cur = byId.get(cur.parentSpanId)
|
||||
if (!cur) break
|
||||
depth++
|
||||
}
|
||||
return depth
|
||||
}
|
||||
let best: TraceClient | null = null
|
||||
let internal = false
|
||||
for (const span of trace.spans) {
|
||||
const ip = PEER_KEYS.map((k) => span.formattedAttributes[k]).find(Boolean)
|
||||
if (!ip) continue
|
||||
if (!isPublicIp(ip)) {
|
||||
internal = true
|
||||
continue
|
||||
}
|
||||
const depth = depthOf(span)
|
||||
if (!best || depth < best.depth) best = { ip, service: span.service, spanId: span.spanId, depth, hadInternalHops: false }
|
||||
}
|
||||
return best ? { ...best, hadInternalHops: internal } : null
|
||||
}
|
||||
@@ -17,3 +17,6 @@ export const UA_CLASSES: { value: string; label: string }[] = [
|
||||
|
||||
export const uaClassLabel = (value?: string): string =>
|
||||
UA_CLASSES.find((c) => c.value === value)?.label ?? value ?? ""
|
||||
|
||||
/** Classes the "Hide crawlers" toggle hides: self-declared crawlers, agents and scanners, not monitors or HTTP libraries. */
|
||||
export const CRAWLER_CLASSES = ["ai_crawler", "ai_agent", "search_crawler", "seo_crawler", "scan_tool", "other_bot"]
|
||||
Reference in new issue
Block a user