Bucketeer UI (dark mode, pagination, virtual scroll), readable+indexed resources, LLM-friendly logs

Logging:
- structured slog with LOG_FORMAT=json|text and a stable key vocabulary
- periodic stats record with queue depths, db pool, goroutines, memory, dns/probe
- no per-certificate logging (hot path is counters only)

UI (Bucketeer branding, DESIGN.md):
- light+dark themes (OS default, persisted header toggle, no first-paint flash)
- official mascot: lossless WebP light/dark logos + favicon, embedded and served
  with cache headers (1.2MB source PNGs -> ~27/22KB webp)
- Assets/Contents views, pagination (offset) and native virtual scrolling
  (content-visibility) so large result sets do not blow up the DOM
- search with debounce/abort + URL state, exposure chips, sort, summary counts

Readability + indexing:
- internal/index listers for S3-compatible, GCS, Azure, Firebase, OpenSearch
  (metadata only: keys/sizes/dates; never object contents)
- asset_entries table + store write/search; readable resources are indexed and
  searchable in the UI
- deep, unauthenticated, read-only DB probes (PROBE_DEEP): Redis PING,
  PostgreSQL StartupMessage, MongoDB OP_MSG listDatabases; OpenSearch/Neptune
  HTTP reads
- Azure account-level listing fixed: 403 is reported reachable, not
  auth_required, since anonymous access is per-container

DNS:
- curated reliable resolvers by default; DNS_SERVERS=fresh adds the compiled-in
  Fresh-Resolvers snapshot with a bounded startup sample (warming all 6.7k at
  once broke the container resolver)
- random healthy selection + failover + per-server cooldown

Other: prober Close fixes a shutdown hang; config/README updated.
This commit is contained in:
StasonJatham committed 2026-09-22 14:33:03 +02:00
1 parent 8fce55f8c9
commit a366905a43
28 files changed
+9744 -241

No files matched your search

+9 -1
View File
@@ -10,12 +10,20 @@ DATABASE_URL=postgres://ctcloud:change-me-strong@postgres:5432/ctcloud?sslmode=d
# Production: use a TLS-enabled database instead, e.g.
# DATABASE_URL=postgres://user:[email protected]:5432/ctcloud?sslmode=verify-full
DNS_SERVERS=1.1.1.1:53,8.8.8.8:53,1.0.0.1:53
# DNS_SERVERS unset = ~20 curated public resolvers. Options:
# DNS_SERVERS=fresh curated + compiled-in Fresh-Resolvers (sampled)
# DNS_SERVERS=system use the OS resolver
# DNS_SERVERS=1.1.1.1,8.8.8.8 your own comma-separated list
# DNS_SERVERS=system
DNS_WORKERS=16
DNS_CACHE_SIZE=262144
DEDUP_SIZE=262144
LOG_LEVEL=info
LOG_FORMAT=text # json for log pipelines / LLM analysis
ACTIVE_PROBING=false
PROBE_DEEP=true # unauthenticated read-only DB protocol checks
INDEX_ENABLED=true # index metadata of readable resources
INDEX_MAX_OBJECTS=1000
# Optional HTTP basic auth for the web UI (/ and /api/*):
# UI_USER=admin
+736
View File
@@ -0,0 +1,736 @@
---
version: alpha
name: Ollama-inspired Web App
description: A light, content-first web application system informed by Ollama home and model search. Tokens are implementation targets; provenance
and unverified values are explicitly documented in Overview. Not an official or pixel-exact extraction.
colors:
primary: '#262626'
on-primary: '#ffffff'
primary-hover: '#404040'
primary-active: '#171717'
text-primary: '#000000'
text-secondary: '#525252'
text-muted: '#737373'
canvas: '#ffffff'
surface-subtle: '#fafafa'
surface-control: '#f2f2f2'
surface-hover: '#ededed'
border: '#e5e5e5'
border-strong: '#d4d4d4'
focus: '#2563eb'
capability-background: '#eef2ff'
capability-text: '#4338ca'
size-background: '#e0f2fe'
size-text: '#075985'
error-background: '#fef2f2'
error-text: '#b91c1c'
success-background: '#f0fdf4'
success-text: '#166534'
typography:
display-desktop:
fontFamily: '"SF Pro Rounded", ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
fontSize: 48px
fontWeight: 500
lineHeight: 52px
letterSpacing: -0.02em
display-mobile:
fontFamily: '"SF Pro Rounded", ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
fontSize: 36px
fontWeight: 500
lineHeight: 40px
letterSpacing: -0.015em
section-heading:
fontFamily: '"SF Pro Rounded", ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
fontSize: 32px
fontWeight: 500
lineHeight: 40px
letterSpacing: -0.015em
section-heading-mobile:
fontFamily: '"SF Pro Rounded", ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
fontSize: 28px
fontWeight: 500
lineHeight: 36px
letterSpacing: -0.01em
page-heading:
fontFamily: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"
fontSize: 24px
fontWeight: 500
lineHeight: 32px
letterSpacing: -0.01em
result-title:
fontFamily: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"
fontSize: 20px
fontWeight: 500
lineHeight: 28px
letterSpacing: 0em
body-lg:
fontFamily: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"
fontSize: 18px
fontWeight: 400
lineHeight: 28px
letterSpacing: 0em
body-md:
fontFamily: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"
fontSize: 16px
fontWeight: 400
lineHeight: 24px
letterSpacing: 0em
body-sm:
fontFamily: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"
fontSize: 14px
fontWeight: 400
lineHeight: 20px
letterSpacing: 0em
nav-link:
fontFamily: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"
fontSize: 18px
fontWeight: 400
lineHeight: 28px
letterSpacing: 0em
button-lg:
fontFamily: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"
fontSize: 18px
fontWeight: 500
lineHeight: 28px
letterSpacing: 0em
button-md:
fontFamily: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"
fontSize: 14px
fontWeight: 500
lineHeight: 20px
letterSpacing: 0em
filter-label:
fontFamily: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"
fontSize: 14px
fontWeight: 400
lineHeight: 20px
letterSpacing: 0em
badge:
fontFamily: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"
fontSize: 12px
fontWeight: 500
lineHeight: 16px
letterSpacing: 0em
code:
fontFamily: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace
fontSize: 14px
fontWeight: 400
lineHeight: 20px
letterSpacing: 0em
rounded:
none: 0px
tag: 4px
sm: 6px
md: 8px
panel: 12px
full: 9999px
spacing:
hairline: 1px
micro: 2px
xs: 4px
tag-gap: 6px
sm: 8px
input-y: 10px
md: 12px
lg: 16px
xl: 24px
2xl: 32px
3xl: 40px
4xl: 48px
5xl: 64px
6xl: 80px
7xl: 96px
layout-site-max: 1280px
layout-section-max: 1024px
layout-search-max: 768px
layout-hero-max: 640px
layout-prose-max: 576px
components:
page:
backgroundColor: '{colors.canvas}'
textColor: '{colors.text-primary}'
typography: '{typography.body-md}'
header:
backgroundColor: '{colors.canvas}'
textColor: '{colors.text-primary}'
height: 72px
navigation-link:
backgroundColor: '{colors.canvas}'
textColor: '{colors.text-primary}'
typography: '{typography.nav-link}'
button-primary:
backgroundColor: '{colors.primary}'
textColor: '{colors.on-primary}'
typography: '{typography.button-md}'
rounded: '{rounded.full}'
height: 40px
button-primary-hover:
backgroundColor: '{colors.primary-hover}'
textColor: '{colors.on-primary}'
typography: '{typography.button-md}'
rounded: '{rounded.full}'
button-primary-active:
backgroundColor: '{colors.primary-active}'
textColor: '{colors.on-primary}'
typography: '{typography.button-md}'
rounded: '{rounded.full}'
button-header:
backgroundColor: '{colors.primary}'
textColor: '{colors.on-primary}'
typography: '{typography.nav-link}'
rounded: '{rounded.full}'
height: 40px
button-hero:
backgroundColor: '{colors.primary}'
textColor: '{colors.on-primary}'
typography: '{typography.button-lg}'
rounded: '{rounded.full}'
height: 52px
button-secondary:
backgroundColor: '{colors.surface-control}'
textColor: '{colors.text-primary}'
typography: '{typography.button-md}'
rounded: '{rounded.full}'
height: 40px
button-secondary-hover:
backgroundColor: '{colors.surface-hover}'
textColor: '{colors.text-primary}'
typography: '{typography.button-md}'
rounded: '{rounded.full}'
button-outline:
backgroundColor: '{colors.canvas}'
textColor: '{colors.text-primary}'
typography: '{typography.button-md}'
rounded: '{rounded.full}'
height: 40px
button-disabled:
backgroundColor: '{colors.surface-subtle}'
textColor: '{colors.text-muted}'
typography: '{typography.button-md}'
rounded: '{rounded.full}'
icon-button:
backgroundColor: '{colors.canvas}'
textColor: '{colors.text-secondary}'
rounded: '{rounded.full}'
size: 40px
header-search:
backgroundColor: '{colors.surface-control}'
textColor: '{colors.text-primary}'
typography: '{typography.body-sm}'
rounded: '{rounded.full}'
height: 40px
query-search:
backgroundColor: '{colors.surface-control}'
textColor: '{colors.text-primary}'
typography: '{typography.body-md}'
rounded: '{rounded.full}'
height: 48px
query-search-focused:
backgroundColor: '{colors.canvas}'
textColor: '{colors.text-primary}'
typography: '{typography.body-md}'
rounded: '{rounded.full}'
search-placeholder:
backgroundColor: '{colors.surface-control}'
textColor: '{colors.text-secondary}'
typography: '{typography.body-sm}'
text-input:
backgroundColor: '{colors.canvas}'
textColor: '{colors.text-primary}'
typography: '{typography.body-md}'
rounded: '{rounded.md}'
height: 44px
sort-select:
backgroundColor: '{colors.canvas}'
textColor: '{colors.text-primary}'
typography: '{typography.body-sm}'
rounded: '{rounded.full}'
height: 40px
filter-chip:
backgroundColor: '{colors.surface-control}'
textColor: '{colors.text-secondary}'
typography: '{typography.filter-label}'
rounded: '{rounded.full}'
height: 32px
filter-chip-hover:
backgroundColor: '{colors.surface-hover}'
textColor: '{colors.text-primary}'
typography: '{typography.filter-label}'
rounded: '{rounded.full}'
filter-chip-selected:
backgroundColor: '{colors.primary}'
textColor: '{colors.on-primary}'
typography: '{typography.filter-label}'
rounded: '{rounded.full}'
height: 32px
result-row:
backgroundColor: '{colors.canvas}'
textColor: '{colors.text-primary}'
rounded: '{rounded.none}'
result-row-hover:
backgroundColor: '{colors.surface-subtle}'
textColor: '{colors.text-primary}'
rounded: '{rounded.none}'
result-title:
backgroundColor: '{colors.canvas}'
textColor: '{colors.text-primary}'
typography: '{typography.result-title}'
result-description:
backgroundColor: '{colors.canvas}'
textColor: '{colors.text-secondary}'
typography: '{typography.body-md}'
result-metadata:
backgroundColor: '{colors.canvas}'
textColor: '{colors.text-muted}'
typography: '{typography.body-sm}'
badge-capability:
backgroundColor: '{colors.capability-background}'
textColor: '{colors.capability-text}'
typography: '{typography.badge}'
rounded: '{rounded.tag}'
badge-size:
backgroundColor: '{colors.size-background}'
textColor: '{colors.size-text}'
typography: '{typography.badge}'
rounded: '{rounded.tag}'
badge-neutral:
backgroundColor: '{colors.surface-control}'
textColor: '{colors.text-secondary}'
typography: '{typography.badge}'
rounded: '{rounded.tag}'
feature-panel:
backgroundColor: '{colors.surface-subtle}'
textColor: '{colors.text-primary}'
rounded: '{rounded.panel}'
padding: 24px
popover:
backgroundColor: '{colors.canvas}'
textColor: '{colors.text-primary}'
typography: '{typography.body-sm}'
rounded: '{rounded.md}'
padding: 8px
empty-state:
backgroundColor: '{colors.canvas}'
textColor: '{colors.text-secondary}'
typography: '{typography.body-md}'
padding: 32px
skeleton:
backgroundColor: '{colors.surface-control}'
rounded: '{rounded.sm}'
notice-error:
backgroundColor: '{colors.error-background}'
textColor: '{colors.error-text}'
typography: '{typography.body-sm}'
rounded: '{rounded.md}'
padding: 12px
notice-success:
backgroundColor: '{colors.success-background}'
textColor: '{colors.success-text}'
typography: '{typography.body-sm}'
rounded: '{rounded.md}'
padding: 12px
code-snippet:
backgroundColor: '{colors.surface-subtle}'
textColor: '{colors.text-primary}'
typography: '{typography.code}'
rounded: '{rounded.md}'
padding: 12px
footer:
backgroundColor: '{colors.canvas}'
textColor: '{colors.text-muted}'
typography: '{typography.body-sm}'
divider:
backgroundColor: '{colors.border}'
height: 1px
---
# Ollama-inspired web application design system
## Overview
### Purpose
Build a web application with a quiet, developer-oriented interface: a white canvas, dark text, restrained controls, generous outer whitespace, and readable resource lists. Borrow the reference's visual language, not its logo, product claims, or model catalog.
Use two related compositions: a spacious **home/overview page** and a compact **search/discovery page**. Keep the same typography, navigation, buttons, and neutral palette across both. Do not turn the discovery page into a marketing page or a dashboard full of elevated cards.
This document follows Google's DESIGN.md alpha format: YAML design tokens followed by the eight canonical prose sections. Token names and values are normative **for the application being built**, not a claim that Ollama publishes these tokens. [S3]
### Evidence boundary — read before implementation
**Prepared:** 2026-09-21. **Requested references:** Ollama home and `/search?q=qwen`.
The current pages were readable as extracted web content. Their live stylesheets, browser-computed styles, desktop/mobile screenshots, and interaction states could not be captured in this environment. A separate author's July 2026 measurement report was available for the homepage and pricing surfaces. That report does not cover the requested search page. Consequently, this is a **reference-informed implementation specification, not a pixel-exact live CSS extraction**.
Use these provenance labels throughout:
| Label | Meaning | What it establishes |
| --- | --- | --- |
| **C — Current content** | Page content inspected on 2026-09-21 | Information hierarchy and available content/control types; not CSS or geometry. |
| **H — Historical report** | Measurements reported by another author on 2026-07-13 | A dated reference value; not independently reproduced here and not proof of today's rendering. |
| **R — Recommendation** | A deliberate application design decision | A concrete value or behavior to implement, pending visual comparison. |
**Default rule:** every token and behavior is **R** unless the table below explicitly identifies its historical basis. Where an H value is reused, it becomes an implementation target; its provenance does not become current.
| Item | Reported evidence / treatment | Status |
| --- | --- | --- |
| Header download control | `#262626` background, white text, `9999px` radius, `6px 16px` padding, `18px` text at weight `400` | H [S4] |
| Hero action | Same dark surface and pill radius; `12px 32px` padding; `18px`, weight `500` | H [S4] |
| Header sign-in control | `rgba(0,0,0,0.05)` background, black text, `6px 16px` padding, `18px` text | H [S4] |
| Product search input | Transparent input inside its container; pill radius; `10px 12px` padding; `14px/20px` typography | H [S4] |
| Outlined action | White surface, `#d4d4d4` 1px outline, pill radius, `8px 24px` padding; sampled on the historical home/pricing composition | H [S4] |
| Font families | System sans and system monospace stacks reported; a heading declaration containing `SF Pro Rounded` was reported, but no corresponding loaded font/source was established | H; exact rendered heading face unresolved [S4] |
| `surface-control: #f2f2f2` | Normalized approximation of 5% black composited over white; not the original RGBA declaration | R derived from H |
| All heading sizes, row spacing, widths, breakpoints, badge colors, and unobserved states | Chosen for a coherent, accessible implementation | R |
The measured historical input was transparent. The YAML `header-search` component describes the **outer search wrapper**, with a transparent input inside it; it does not claim that the sampled input itself had a gray fill.
### What the current references establish
**Home — C:** the current content proceeds from a short product proposition and download action to developer/social proof, performance evidence, model comparisons, integrations, privacy information, usage/pricing information, and a closing action. These are content motifs, not verified measurements or layouts. Do not reproduce an older terminal-install hero as though it were the current homepage. [S1]
**Search — C:** the current page provides a model query input, a sort control, five capability filters, and a linked sequence of results. Each result carries a name, short description, capability/size labels, and popularity/version/recency metadata. The selected query is `qwen`. The filters cover cloud availability, embeddings, vision, tools, and thinking. Current result names and counts are data, not design tokens. [S2]
### Design priorities
Content and whitespace carry the hierarchy. The largest contrast belongs to the page title and primary action; the smallest contrast belongs to metadata and dividers. Search should feel like browsing a well-maintained software registry, not browsing a card-based storefront. Keep personality in the heading treatment and the application's own small brand mark.
### Sources
- **[S1]** Ollama homepage; current extracted content inspected 2026-09-21. `https://ollama.com/`
- **[S2]** Ollama model search; current extracted content inspected 2026-09-21. `https://ollama.com/search?q=qwen`
- **[S3]** Google Labs DESIGN.md specification and README; inspected 2026-09-21. `https://github.com/google-labs-code/design.md/blob/main/docs/spec.md` and `https://github.com/google-labs-code/design.md/blob/main/README.md`. Retrieved spec file blob SHA: `5995e5482fc338a9f9923d5912551241a82e6e8a`.
- **[S4]** Original measurement report by the oh-my-design project, dated 2026-07-13; not an official Ollama design system. `https://github.com/kwakseongjae/oh-my-design/blob/main/web/references/ollama/.verification.md`. Retrieved file blob SHA: `f06f5515f3d3af93d894c91acf5ea8cf8a9aa919`. The referenced raw collector bundle was unavailable; only the report was inspected.
- **[S5]** W3C, WCAG 2.2 Understanding SC 1.4.3, text contrast. `https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html`
- **[S6]** W3C, WCAG 2.2 Understanding SC 2.5.8, target size. `https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html`
- **[S7]** MDN, CSS font-family and computed-style behavior. `https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-family` and `https://developer.mozilla.org/en-US/docs/Web/API/Window/getComputedStyle`
## Colors
### Neutral foundation
Use `{colors.canvas}` for the page and default content surfaces. Use `{colors.text-primary}` for headings and names, `{colors.text-secondary}` for descriptions, and `{colors.text-muted}` for timestamps and supporting statistics. Distinguish hierarchy primarily through size, weight, spacing, and text tone.
The primary action uses `{colors.primary}` with `{colors.on-primary}`. That slightly softened black is based on the historical control measurements, rather than the assumption that every dark control must be pure black. [S4]
Use `{colors.surface-control}` for low-emphasis controls and search wrappers. `{colors.surface-subtle}` is reserved for a quiet hover, code surface, or supporting panel. Hairlines use `{colors.border}`; outlined controls may use `{colors.border-strong}`. Neither pale border is sufficient on its own to communicate an essential control boundary: retain a recognizable shape and label, or strengthen the border for that use case.
### Small semantic accents — R
The app is neutral-first, **not literally color-free**. Use muted indigo for capability badges and pale blue for size/version badges. These exact badge colors are proposed, not captured from the current search page. Small colored labels must not become large colored sections or colored primary buttons.
Use error and success pairs only for real status messages. Include a textual label or icon so color is never the sole status signal. Do not color an entire result row by capability.
### Contrast rules — R
Normal text must meet at least **4.5:1** against its actual background. Large text has a **3:1** minimum under WCAG's large-text definition. Placeholder text is not exempt. The token pairs are checked numerically in the accompanying validation report; a passing palette does not establish page-level accessibility. [S5]
Do not lower an entire result row's opacity to create hierarchy. That also lowers contrast on its text and controls. Use the dedicated text tones instead. Do not use `#a3a3a3` for important small text on white.
Dark mode is intentionally **not specified**. Do not invent one and label it as extracted from these references. A future dark palette should be designed and tested separately.
## Typography
### Font stacks
Use the system sans stack for body text, navigation, inputs, metadata, and result titles. Use the display stack only for the home hero and marketing section headings. Reserve monospace for actual commands, identifiers requiring character alignment, and code; model/resource names and all metadata do not automatically become monospace.
The display stack begins with `"SF Pro Rounded"`, followed by `ui-rounded` and system fallbacks. This is an application choice informed by the historical CSS declaration, **not proof that a particular font file loaded on Ollama**. A computed `font-family` value is a fallback list, not a rendered-font report; platform availability can change the resulting face. [S4][S7]
Use locally available fonts and fallbacks. Do not download or bundle Apple's font files, and do not add an external font service as a hidden dependency. Expect platform-specific glyph widths and test line wrapping on the supported operating systems.
### Type hierarchy — R unless noted
| Role | Desktop | Small screens | Weight | Use |
| --- | --- | --- | --- | --- |
| Home display | 48px / 52px | 36px / 40px | 500 | The single main product proposition. |
| Section heading | 32px / 40px | 28px / 36px | 500 | Home content sections. |
| App page heading | 24px / 32px | Same | 500 | Non-marketing application views. |
| Result title | 20px / 28px | Same | 500 | The primary link in each result. |
| Lead / navigation | 18px / 28px | Lead can become 16px / 24px | 400 | Introductory copy and desktop navigation. |
| Body | 16px / 24px | Same | 400 | Descriptions and form values. |
| Supporting text | 14px / 20px | Same | 400 | Metadata, sort labels, footer. |
| Badge | 12px / 16px | Same | 500 | Short, non-paragraph labels. |
| Code | 14px / 20px | Same | 400 | Commands and technical snippets. |
The header and hero action's 18px sizes and weights have historical support; their 28px line-height and resulting target heights are implementation choices. All display sizes above are recommendations. Do not substitute the historically reported **pricing-page** H1 measurement for a current homepage H1 measurement.
Keep letter spacing normal for controls and body text. Slightly tighten large headings using the provided display tokens. Avoid uppercase navigation, heavy 700–900 headings, widely tracked labels, and giant 80–120px marketing headlines. Allow titles to wrap; do not truncate a meaningful resource name just to preserve a fixed row height.
## Layout
### Sizing model — R
Use `box-sizing: border-box`. All stated widths are **maximum widths**, never fixed widths that force overflow. Use a 4px-oriented rhythm with explicit 2px, 6px, and 10px micro adjustments for labels and controls.
| Region | Maximum width | Alignment / purpose |
| --- | --- | --- |
| Site/navigation shell | 1280px | Centered in the viewport. |
| Broad home section | 1024px | Proof, comparison, or integration content. |
| Search/results column | 768px | A readable single-column directory. |
| Home hero | 640px | Centered headline and primary action. |
| Introductory prose | 576px | Prevent overly long lines. |
Outer page gutters: 16px below 640px, 24px from 640px, and 32px from 1024px. The widths and breakpoints are proposed implementation values, not measurements from Ollama.
### Shared shell
Use a minimum 72px header on desktop with the application's compact brand mark at the left. Place a small set of navigation links beside it, an optional flexible global search region, and account/primary-action controls at the right. Leave 24px between major header groups and 16px between related controls.
The header sits on the same white canvas as the page. Do not add a dark bar, persistent left sidebar, or large drop shadow. Default to a normal-flow header. Sticky navigation is a product decision and requires separate overlap/focus testing.
On the discovery route, the main query field is authoritative. Avoid displaying two equally prominent search inputs. Hide the global header search or make its relationship to the query field unambiguous.
### Home / overview composition
The content sequence is informed by the current homepage; the geometry below is R. [S1]
1. **Hero:** 80–96px top space after the header on desktop, 48px on small screens. Center the headline, a short supporting paragraph 16–24px below it, and a primary action 24px below the paragraph. Keep the hero compact enough that useful content can appear within the first screen.
2. **Proof and explanation:** add a restrained proof row or compact metrics, followed by content sections spaced 64–96px apart. Use a two-column text/visual layout only when the content benefits from it; stack below 1024px.
3. **Closing action and footer:** repeat the primary action after the explanatory content, then provide a compact footer. Do not fill the home page with identical CTA buttons.
Use the application's actual features and evidence. Performance comparisons, logo rows, integrations, and usage displays are optional content patterns, not permission to copy Ollama's claims or invent customer logos. A command snippet belongs here only when a real installation workflow needs it.
### Search / discovery composition
Start the search controls about 48px below the header on desktop and 24–32px below it on small screens. Do not insert a large hero, oversized page title, or illustration above the query field.
```text
Shared header
Search/discovery column — max 768px
┌──────────────────────────────────┐
│ Search query × │
└──────────────────────────────────┘
[Filter] [Filter] [Filter] [Sort ▾]
Resource name
Short explanatory description, wrapping naturally.
[capability] [capability] [size] [size]
↓ popularity tags/versions updated recently
────────────────────────────────────────────────
Resource name
...
Load more / pagination, only when appropriate
```
This wireframe is a proposed arrangement of the observed search ingredients, **not a captured source screenshot**. Start with 16px between query and filter controls, 24px before the first result, and 24px vertical padding per result. Within a result, use 4–8px between title and description, 8px before badges, and 12px before metadata.
Keep results in one column at every viewport size. Use whitespace and optional 1px separators, not individual floating cards. Result height is content-driven; long names, multiple badges, and translated descriptions must expand the row.
### Responsive behavior — R
| Viewport / condition | Required behavior |
| --- | --- |
| Below 640px | 16px gutters; compact header; main search on its own line; filters wrap; sort can move to a separate line; metadata wraps; 36px home display. |
| 640–1023px | 24px gutters; preserve the single results column; show navigation only when it fits; home sections remain stacked by default. |
| 1024px and above | 32px gutters; full navigation; optional centered global search; two-column home sections where useful. |
| Coarse pointer | Increase interactive chip and icon-button targets to at least 44px, without overlapping adjacent targets. |
| 200% zoom / narrow effective viewport | Reflow rather than clip. No fixed row heights or hard-coded single-line headers. |
Do not hide filtering or sorting functionality on mobile. Prefer wrapping filters over an invisible horizontal scroll area. Where a real table cannot reflow, give only that table a labeled scroll region; the whole page must not scroll horizontally.
### CSS layout recipe
The companion `design-tokens.css` defines the custom properties used below. These classes are implementation examples, not source CSS.
```css
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; color: var(--color-text-primary); background: var(--color-canvas); }
:root { --page-gutter: 16px; }
.site-shell, .home-section, .search-layout, .hero {
width: calc(100% - 2 * var(--page-gutter));
margin-inline: auto;
}
.site-shell { max-width: var(--spacing-layout-site-max); }
.home-section { max-width: var(--spacing-layout-section-max); }
.search-layout { max-width: var(--spacing-layout-search-max); }
.hero { max-width: var(--spacing-layout-hero-max); text-align: center; }
.result-list { list-style: none; padding: 0; margin: 0; }
.result-row { padding-block: 24px; border-bottom: 1px solid var(--color-border); }
.badge-group, .result-meta, .filter-group { display: flex; flex-wrap: wrap; }
.badge-group { gap: 6px; }
.result-meta { gap: 8px 16px; }
.filter-group { gap: 8px; }
@media (min-width: 640px) { :root { --page-gutter: 24px; } }
@media (min-width: 1024px) { :root { --page-gutter: 32px; } }
```
Apply these width utilities to top-level regions. Do not nest two gutter-bearing shells and unintentionally double the side padding. Use flex children with `min-width: 0` where long content must wrap.
## Elevation & Depth
For this app, default to **no shadow** on page regions, buttons, search fields, and result rows. Build hierarchy through whitespace, text contrast, subtle fills, and hairlines. This is an intentional recommendation, not a verified statement that the reference never uses shadows.
A floating menu may use `0 4px 16px rgba(0,0,0,0.08)` with a 1px border. Do not use the menu shadow on cards or every control. Keep a simple layering order: normal content `0`, optional sticky chrome `10`, popovers `20`, dialogs `40`. These are proposed values.
Use 120ms color/border transitions with an `ease-out` timing curve. Never scale result rows or move buttons on hover. Avoid animated gradients, background blobs, glass effects, large blurred shadows, and scroll-triggered entrance animation. Respect reduced-motion preferences; loading skeletons can be static.
## Shapes
Use pill geometry for primary/secondary buttons, global search wrappers, query fields, sort controls, and interactive filter chips. The historical source supports pill-shaped header actions and the product input. [S4]
Use **4px corners for compact metadata badges**, 8px for generic form fields/popovers, and 12px for occasional explanatory panels. These are app recommendations; do not infer that every element needs a full pill radius. Result rows themselves remain flat and unrounded.
Icons should be simple, monochrome line icons using `currentColor`, normally 16px next to metadata and 18–20px inside controls. Use consistent stroke weight around 1.5–2px. The app's brand mark may have its own geometry; do not reuse or redraw the Ollama mascot as the application's identity.
Use 1px separators. Avoid thick card outlines, corner radii above 12px on ordinary panels, decorative cutouts, tilted cards, and oversized circular icon backgrounds.
## Components
All behavior in this section is R unless explicitly identified as H. The YAML intentionally uses the DESIGN.md specification's standard component properties. Directional padding, borders, max-widths, focus, and interaction semantics are specified here rather than hidden in unsupported token fields. [S3]
### Header and navigation
Use a semantic `header` and `nav`. Suggested application slots are brand, browse, documentation, optional pricing, account, and one primary action. These are slots to adapt, not a verified inventory of today's Ollama header. Mark the active destination with a restrained underline or weight change and `aria-current="page"`.
On small screens, use an accessible menu button when links no longer fit. It must expose its expanded state, provide predictable keyboard behavior, and return focus when closed. Keep the main content reachable through a skip link.
### Buttons
| Variant | Geometry and padding | Appearance / role |
| --- | --- | --- |
| Header primary | Target 40px; `6px 16px`; 18px text | Historical padding, size, and color basis; primary navigation action. |
| Hero primary | Target 52px; `12px 32px`; 18px medium text | Historical padding, size, and color basis; central home action. |
| App primary | Min 40px; `10px 20px`; 14px medium text | Dark pill; main action in a form or workflow. |
| Secondary | Min 40px; `10px 16px` | Light gray pill; lower-emphasis action. |
| Outline | Min 40px; `9px 20px` plus 1px border | White with a quiet outline; secondary standalone action. |
| Icon only | Min 40px square; 44px for coarse pointers | Transparent or subtle background; accessible name required. |
These are minimum heights, not clipping boxes. Let translated labels or user text scaling increase the height. Use links for navigation and buttons for actions. Disabled controls must really be disabled, not just gray. Loading retains the action label and dimensions; reserve space for its indicator.
For primary hover and pressed states, use the YAML variants. Secondary hover darkens its neutral surface slightly. Do not change corner radius or geometry between states.
### Search fields
The **header-search** wrapper is 40px tall with a transparent inner input. The historical input had `14px/20px` text and `10px 12px` padding; increase left padding to about 36–40px only when a leading search icon occupies that space. [S4]
The **query-search** is the more prominent 48px field in the discovery column. Use 16px text, a 20px leading icon, roughly 16px external horizontal padding, and reserved trailing space for a clear button. The placeholder is a hint, not the accessible label. A real visible or visually hidden label is required.
In the focused state, use a white surface, a clear border, and the focus outline described below. Do not show a redundant outline around both wrapper and input. For a clear control, preserve input focus after clearing. Escape can dismiss suggestions; do not assign it to unexpected navigation.
Persist the query in `?q=`. Enter submits. Optional live search uses a proposed 200–300ms debounce, aborts stale requests, and never overwrites newer results with an older response. IME composition must finish before submitting. These are recommended application behaviors, not observations of Ollama's implementation.
### Sort control and popover
Use a labeled native select by default, approximately 40px high. Preserve its native keyboard semantics. A custom menu is justified only when the product needs richer options and implements equivalent accessibility.
Keep the selected sort visible. Proposed options are relevance, popularity, and recently updated; adapt them to actual data. Do not pretend these are the source page's exact options. Update URL state alongside query/filter state and preserve it when navigating back.
### Filter chips
Use 32px visual height with `6px 12px` padding, 14px text, and 8px gaps. Selected chips become dark with white text; a checkmark or equivalent state cue should reinforce selection. On coarse pointers, increase the actual target to 44px rather than extending invisible hit areas into neighboring controls.
Use actual checkboxes with styled labels for independently selectable filters, or buttons with `aria-pressed` for toggle buttons. Define whether multiple selections are combined with AND or OR and communicate that rule. Do not use `role="tab"` for filters that do not switch between tab panels. Offer a quiet clear-filters action when selections are active.
### Result rows
Each result contains, in order, a linked resource title, short description, a wrapping badge group, and a wrapping metadata line. This information pattern is supported by the current search content; its exact spacing and typography here are R. [S2]
Keep titles black rather than turning the page into a column of saturated blue links. On pointer hover, underline the title and optionally apply the subtle row fill. Keyboard focus must remain visible independently of hover.
Use a semantic list. For a completely navigational row, a single block link can contain its non-interactive contents. When a row needs menus, favorite buttons, or other controls, keep the title as the link and place those controls as siblings. Never nest buttons or another anchor inside a whole-row anchor.
Do not impose a fixed height. Avoid truncating descriptions by default; a genuinely long description can have a deliberate, accessible expansion pattern. Long unbroken identifiers should wrap using `overflow-wrap: anywhere` where necessary, not force horizontal scrolling.
### Badges and metadata
Badges use 12px/16px medium text, `2px 6px` padding, and a 4px radius. Use indigo for capabilities, blue for size/version labels, and neutral gray for general categories. The **existence** of capability/size labels is C; these exact colors, radii, and sizes are R. [S2]
Keep non-interactive badges non-interactive: no pointer cursor, false focusability, or button-like hover. When a badge really is a filter action, expose that as a genuine interactive control with an adequate target instead of silently converting all labels into links.
Metadata uses 14px/20px muted text and 16px icons, with approximately 16px between groups. Provide understandable labels for counts; do not rely on an icon alone. Display relative time with access to the full date, and use `<time datetime="...">` where appropriate. Missing metadata is omitted or labeled unavailable, never replaced with invented counts.
### Home evidence panels
Use panels sparingly for real comparisons, code snippets, integration examples, or usage previews. Prefer 24px internal padding, 12px radius, a light neutral surface, and no shadow. A panel is an explanation container, not the default wrapper for every paragraph.
For charts, provide visible units and an accessible summary. Real comparisons need a source and date. Do not copy reference benchmark data, customer marks, or pricing values into the application as decorative fixtures. Avoid an inverted premium-pricing card unless the actual product has a reason for one.
### Loading, empty, error, and success states
A loading view uses a few restrained skeleton rows with the same column width and basic rhythm as loaded results. Keep the search field and filters usable where possible; preserve existing results while a new query is pending when this avoids a blank flash.
An empty view states that no items matched, retains the query, and offers clear-filters or clear-query controls. A network failure is not an empty result: show an inline error with retry and keep user input intact. Announce relevant result updates through a polite live region without reading the entire list repeatedly.
Success/error notices use the provided semantic pairs and a textual message. Do not use global toast messages for every filter change. Generic dialogs and full data-table systems are outside the extracted reference scope; derive them separately if the app needs them.
### Focus and accessibility
Use a visible 2px focus outline in `{colors.focus}`, offset by 3px, for keyboard-operable controls and links. This is an app accessibility rule, not a captured Ollama focus state. Never remove the browser outline without supplying an effective replacement.
Use at least 44px pointer targets for key mobile controls as an application goal. Do not call 44px the WCAG 2.2 AA minimum: SC 2.5.8 generally uses 24 by 24 CSS pixels with stated exceptions. Text contrast and target size still need testing in the assembled page. [S5][S6]
```css
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 3px;
}
@media (pointer: coarse) {
.filter-chip, .icon-button, .button { min-height: 44px; }
.icon-button { min-width: 44px; }
}
@media (prefers-reduced-motion: reduce) {
.button, .result-row, .filter-chip, .skeleton {
transition: none;
animation: none;
}
}
```
For composite inputs, put the visual ring on the outer wrapper and suppress the inner ring **only while that equivalent wrapper focus indicator is present**. Verify that outlines are not clipped by overflow containers. Test meaningful keyboard order, accessible names, zoom, and forced-colors mode.
### Footer
Use a white surface, 14px muted text, and compact utility/legal links. Keep roughly 64–80px between the last major content region and the footer. Wrap links on small screens. Do not give the footer a contrasting dark background or large promotional content by default.
## Do's and Don'ts
### Do
- Keep the neutral canvas, small control vocabulary, readable single results column, and clear text hierarchy.
- Reuse the exact application tokens before adding local exceptions. Treat layout caps as max-widths and component heights as minimums.
- Keep metadata badges compact while ensuring real controls remain operable by keyboard and touch.
- Preserve query/filter/sort state, distinguish empty from failed requests, and use real data and the application's own identity.
- Treat C, H, and R as different evidence classes; refresh unverified values before claiming source fidelity.
### Don't
- Turn the search page into a multi-column card grid, sidebar-heavy dashboard, or second marketing hero.
- Add gradients, glass panels, oversized shadows, giant headlines, or a different accent color for every feature.
- Set every component to a pill: buttons and filters are pills; badges are small rounded rectangles; rows are flat.
- Import a generic component theme unchanged and assume its spacing, fonts, shadows, and borders will match this specification.
- Claim that the current source was screenshot-verified, that SF Pro Rounded definitely rendered, or that recommended colors/states are measured Ollama values.
### Implementation acceptance checklist
Check the built app at **390×844**, **768×1024**, and **1440×900**, plus a 320px-wide effective viewport and 200% zoom. These are test targets, not captured reference sizes.
The search column must remain centered and single-column; filters and metadata must wrap; long names must not overflow; inputs must retain clear labels and focus; loading must not erase user state; controls must work without a mouse. The interface should remain recognizably sparse even with real, variable-length data.
Visual sign-off against Ollama is **pending**. To close that gap, capture both reference URLs and the application at the same viewport, browser, OS, and zoom. Compare shell geometry first, then fonts/wrapping, controls, rows, and small details. Do not compare changing model counts or marketing copy as though they were stable visual tokens.
### Updating this document from live measurements
The optional `capture-design.js` can be run in a browser's DevTools console on each requested public page. It saves a local JSON snapshot of computed styles, visible DOM elements, CSS variables, available stylesheet rules, and font declarations. It does not upload data, fetch font files, or capture screenshots. Review the script before executing it.
A computed family list is not evidence of the actual rendered font. A declaration in a stylesheet is not evidence of a used state. The collector also does not prove hover/pressed behavior; inspect those states separately. Browser-native screenshots and rendered-font inspection are still required for visual verification. [S7]
When measurements arrive, update values and their provenance together. Do not silently promote R tokens to C. Retain both viewport and capture date with measurements, and avoid copying documentation-site styling into the marketing/search system.
### Validation and handoff
Place `DESIGN.md` at the application repository root. Import `design-tokens.css` once from the global stylesheet, or read `design-tokens.json` to generate equivalent project-native tokens. The JSON file is a direct serialization of this YAML, **not a W3C DTCG export**. The CSS contains variables only; it does not silently restyle the application.
Local checks validate the YAML structure, allowed token fields, reference resolution, section order, CSS-variable agreement, and declared component text/background contrast. Results are in `validation-report.json`. These checks do **not** constitute the official Google CLI linter or a visual/accessibility audit.
The official commands below are documented by the DESIGN.md project; they were not run in this environment. [S3]
```bash
npx @google/design.md lint DESIGN.md
npx @google/design.md export --format css-tailwind DESIGN.md > theme.css
npx @google/design.md export --format dtcg DESIGN.md > tokens.dtcg.json
```
**Agent instruction:** read this entire file before implementing the UI. Apply its tokens and layout rules, replace reference branding/content with the app's own, preserve the evidence boundary, and avoid adding unsupported components or decoration. Search is a usable product surface first, not a landing-page imitation.
+88 -16
View File
@@ -108,7 +108,7 @@ Environment variables only; no config files, no runtime pattern files.
| --- | --- | --- |
| `DATABASE_URL` | *(required)* | PostgreSQL connection string |
| `DB_MAX_CONNS` | `8` | pgx pool size |
| `DNS_SERVERS` | `1.1.1.1:53,8.8.8.8:53,1.0.0.1:53` | comma-separated resolvers (round-robin + failover); `system` for the host resolver |
| `DNS_SERVERS` | *(unset → curated + community list)* | comma-separated resolvers, or `system` for the OS resolver |
| `DNS_WORKERS` | `16` | DNS enrichment workers |
| `DNS_TIMEOUT` | `5s` | per-lookup timeout |
| `DNS_CACHE_SIZE` | `262144` | max DNS cache entries |
@@ -123,6 +123,9 @@ Environment variables only; no config files, no runtime pattern files.
| `ACTIVE_PROBING` | `false` | enable optional reachability checks |
| `PROBE_WORKERS` / `PROBE_TIMEOUT` | `8` / `5s` | probe worker pool |
| `PROBE_QUEUE_SIZE` | `65536` | probe sample size; when full, old entries are replaced at random (bounded memory) |
| `PROBE_DEEP` | `true` | unauthenticated, read-only protocol reads for databases (Redis/Postgres/Mongo) |
| `INDEX_ENABLED` / `INDEX_MAX_OBJECTS` | `true` / `1000` | index metadata of readable resources (keys/sizes only) |
| `LOG_FORMAT` | `text` | `text` or `json` (JSON is best for log pipelines/LLM analysis) |
| `DEDUP_TTL` | `1h` | hostname/finding dedup window |
| `METRICS_ADDR` | `:9090` | metrics/health/UI listener |
| `UI_USER` / `UI_PASS` | *(unset)* | optional HTTP basic auth for the web UI |
@@ -156,10 +159,44 @@ basic auth on the UI and its API.
## Web UI
A small, dependency-free UI lists discovered assets with provider/service,
resource, region, exposure state and the probe detail, plus summary counts and
provider/service/exposure filters. It is read-only, refreshes every 15s, and is
served from the same loopback port (`http://127.0.0.1:9090/`).
A small, dependency-free UI (vanilla JS, no build step, embedded in the binary)
styled to the tokens and layout in `DESIGN.md` (neutral canvas, single 768px
results column, flat rows, pill controls, no shadows).
* search box with 250ms debounce, stale-request cancellation and `?q=` URL state
* exposure filter chips — use **Public read** to see open buckets, **Reachable**
to see publicly reachable database endpoints — plus a sort control
* result rows show the provider hostname, probe detail, provider/service/region
badges, resource identifier and relative last-seen/probed times
* summary line with total assets, publicly readable and reachable counts
* refreshes every 15s (paused when the tab is hidden), read-only
* light and dark mode: follows the OS by default, with a header toggle that is
persisted in `localStorage`; the theme is applied before first paint to avoid
a flash
Served from the same loopback port: `http://127.0.0.1:9090/`.
### Brand assets
The UI ships the official Bucketeer mascot for both themes, optimized to
lossless WebP and embedded in the binary (`internal/webui/assets/`):
| Asset | Size | Use |
| --- | --- | --- |
| `logo-light.webp` | ~27 KB | black line art, light theme |
| `logo-dark.webp` | ~22 KB | white line art, dark theme |
| `favicon.png` | ~7 KB | browser tab |
They are downscaled to 256px and encoded with `cwebp -lossless -z 9`, so they
stay crisp on HiDPI displays while shrinking the ~1.2 MB source PNGs by ~96%.
Regenerate after replacing the source PNGs at the repo root:
```sh
magick bucketeer-light.png -resize 256x -strip /tmp/l.png
magick bucketeer-dark.png -resize 256x -strip /tmp/d.png
cwebp -quiet -lossless -z 9 -alpha_q 100 /tmp/l.png -o internal/webui/assets/logo-light.webp
cwebp -quiet -lossless -z 9 -alpha_q 100 /tmp/d.png -o internal/webui/assets/logo-dark.webp
```
## Reachability and readability checks
@@ -169,13 +206,25 @@ objects, mutates anything, runs database queries or attempts authentication.
| Vendor | Check | `public_read` means |
| --- | --- | --- |
| AWS S3, Cloudflare R2, DigitalOcean Spaces | anonymous `ListObjectsV2` (`?list-type=2&max-keys=1`) | anonymous bucket listing succeeded |
| Google Cloud Storage | JSON API object list (`/storage/v1/b/<bucket>/o?maxResults=1`) | `allUsers` can list objects |
| Azure Blob/File/Queue/Table | anonymous `?comp=list` | anonymous listing succeeded |
| AWS S3, Cloudflare R2, DigitalOcean Spaces, OCI | anonymous `ListObjectsV2` (`?list-type=2&max-keys=1`) | bucket policy grants `s3:ListBucket` to `*` |
| Google Cloud Storage | JSON API object list (`/storage/v1/b/<bucket>/o?maxResults=1`) | the bucket grants `allUsers` `storage.objects.list` |
| Azure Blob/File/Queue/Table | account-level `?comp=list` | anonymous access is **per container**, so account-level 403 is reported `reachable` (not "not readable"). Container names cannot be derived from the account hostname. |
| Firebase Realtime DB | `GET /.json?shallow=true` | security rules allow anonymous read |
| Supabase | `GET /rest/v1/` | — (an API key is required, so this reports `auth_required`) |
| Cosmos, OpenSearch, Search, Appspot, OCI | root `GET /` | a 2xx was returned |
| RDS, Redshift, DocumentDB, Neptune, ElastiCache, MemoryDB, Azure SQL/Postgres/MySQL/Redis, Supabase Postgres | unauthenticated TCP connect only | *never* — reported as `reachable` (port open) |
| Supabase | `GET /rest/v1/` | not testable without the public **anon API key**; reports `auth_required` |
| OpenSearch / Elasticsearch | `GET /_cat/indices` | security/access control disabled |
| Neptune | `GET /status` | a 2xx was returned |
| Cosmos / Appspot / Azure Search | root `GET /` | a 2xx was returned |
| Redis (ElastiCache, MemoryDB, Azure Redis) | unauthenticated `PING` | `+PONG` without `AUTH` |
| PostgreSQL (RDS/Aurora, Redshift, Azure, Supabase) | unauthenticated StartupMessage | `AuthenticationOk` (server trusts the connection) |
| MongoDB (DocumentDB, Cosmos Mongo) | OP_MSG `listDatabases` | command succeeds without auth |
| MySQL, Azure SQL, Cassandra (Cosmos) | TCP connect only | *never* — reported `reachable` |
Every check sends **no credentials**: no `Authorization` header, no password, no
token. "Anonymous" here means an unauthenticated request, exactly as an anonymous
cloud principal would make.
Deep protocol probes (`PROBE_DEEP`, default on) only ever issue read-only
requests or commands; nothing is written, and no authentication is attempted.
Probe outcomes are stored in `exposure_state` and a short factual
`exposure_detail` string (e.g. `s3 redirect region=eu-central-1`,
@@ -257,11 +306,34 @@ exactly 0 allocs/op, so the target is enforced by the test suite.
### DNS load balancing
DNS lookups are round-robined across `DNS_SERVERS`. Each attempt is bounded
(2s) and fails over to the next server on timeout/refused errors, so one dead
resolver does not stall lookups. Definitive answers (NXDOMAIN) are returned
immediately rather than retried on every server, so negative lookups do not
multiply DNS traffic.
When `DNS_SERVERS` is unset, the pool is ~20 curated, reliable public resolvers
(Cloudflare, Google, Quad9, OpenDNS, AdGuard, …). Each query picks a random
healthy resolver and fails over to at most two others; each attempt is bounded
(2s) and resolvers that fail repeatedly enter a 5-minute cooldown. Definitive
answers (NXDOMAIN) return immediately rather than being retried on every server,
so negative lookups do not multiply DNS traffic.
Set `DNS_SERVERS=fresh` to add a compiled-in snapshot of
[proabiral/Fresh-Resolvers](https://github.com/proabiral/Fresh-Resolvers)
(6,760 entries) on top of the curated set. It validates a random sample of 256 at
startup and cools down the rest on failure. Refresh the snapshot with:
```sh
curl -sL https://raw.githubusercontent.com/proabiral/Fresh-Resolvers/master/resolvers.txt \
-o internal/dns/resolvers.txt
```
> Probing or using thousands of community resolvers at once can saturate the
> container's own DNS and break unrelated lookups, which is why `fresh` is
> opt-in and sampled. These resolvers are also untrusted: they can observe and,
> in principle, forge DNS answers. That only affects enrichment (which endpoints
> are looked up), never the classifier. For sensitive deployments set
> `DNS_SERVERS` to your own resolvers, or `DNS_SERVERS=system`.
> These community resolvers are untrusted: they can observe and, in principle,
> forge DNS answers. That only affects enrichment (which endpoints are looked
> up), never the classifier. For sensitive deployments set `DNS_SERVERS` to your
> own resolvers, or `DNS_SERVERS=system`.
## Testing
Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

+107 -11
View File
@@ -8,8 +8,10 @@ import (
"errors"
"log/slog"
"net/http"
"net/url"
"os"
"os/signal"
"runtime"
"strings"
"sync/atomic"
"syscall"
@@ -19,6 +21,7 @@ import (
"tea.karlcom.de/karl/bucketeer/internal/config"
"tea.karlcom.de/karl/bucketeer/internal/dns"
"tea.karlcom.de/karl/bucketeer/internal/index"
"tea.karlcom.de/karl/bucketeer/internal/metrics"
"tea.karlcom.de/karl/bucketeer/internal/model"
"tea.karlcom.de/karl/bucketeer/internal/pipeline"
@@ -52,10 +55,31 @@ func run() error {
if err != nil {
return err
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: levelFromString(cfg.LogLevel)})))
if strings.Contains(cfg.DatabaseURL, "sslmode=disable") {
slog.Warn("DATABASE_URL has sslmode=disable; only use this on a private network")
opts := &slog.HandlerOptions{Level: levelFromString(cfg.LogLevel)}
var handler slog.Handler = slog.NewTextHandler(os.Stderr, opts)
if cfg.LogFormat == "json" {
handler = slog.NewJSONHandler(os.Stderr, opts) // machine/LLM friendly
}
slog.SetDefault(slog.New(handler))
if strings.Contains(cfg.DatabaseURL, "sslmode=disable") {
slog.Warn("insecure database transport", "event", "config_warning",
"detail", "DATABASE_URL has sslmode=disable; only use this on a private network")
}
slog.Info("configuration",
"event", "startup",
"database_host", redactURLHost(cfg.DatabaseURL),
"metrics_addr", cfg.MetricsAddr,
"ui_auth", cfg.UIUser != "",
"active_probing", cfg.ActiveProbing,
"log_list_source", cfg.LogListSource,
"start_at_end", cfg.StartAtEnd,
"dns_workers", cfg.DNSWorkers,
"direct_queue", cfg.DirectQueueSize,
"dns_queue", cfg.DNSQueueSize,
"db_queue", cfg.DBQueueSize,
"db_batch_size", cfg.DBBatchSize,
"db_flush_interval", cfg.DBFlushInterval.String(),
)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
@@ -73,8 +97,29 @@ func run() error {
}
slog.Info("database ready")
dnsServers := cfg.DNSServers
var resolver *dns.ServerPool
switch {
case len(dnsServers) == 0:
// Default: a curated set of reliable public resolvers. Deliberately not
// the community list by default: warming thousands of unknown resolvers
// at once has been observed to saturate and break the container's own
// resolver, taking down the CT log-list fetch.
resolver = dns.NewServerPool(dns.CuratedServers())
slog.Info("dns resolvers configured", "count", resolver.Len(), "set", "curated")
case len(dnsServers) == 1 && dnsServers[0] == "fresh":
resolver = dns.NewServerPool(append(dns.CuratedServers(), dns.EmbeddedServers()...))
warmCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
healthy := resolver.Warm(warmCtx, "example.com", 32, 256)
cancel()
slog.Info("dns resolvers warmed", "total", resolver.Len(), "healthy", healthy, "set", "fresh")
default:
resolver = dns.NewServerPool(dnsServers)
slog.Info("dns resolvers configured", "count", resolver.Len(), "set", "custom")
}
enricher := &dns.Enricher{
Resolver: dns.NewServerPool(cfg.DNSServers),
Resolver: resolver,
Cache: dns.NewTTLCache[dns.Result](cfg.DNSCacheSize, 64),
PositiveTTL: cfg.DNSPositiveTTL,
NegativeTTL: cfg.DNSNegativeTTL,
@@ -85,17 +130,30 @@ func run() error {
var prober *probe.Prober
if cfg.ActiveProbing {
prober = probe.New(probe.Config{
pcfg := probe.Config{
Workers: cfg.ProbeWorkers,
Timeout: cfg.ProbeTimeout,
Queue: cfg.ProbeQueueSize,
Deep: cfg.ProbeDeep,
Metrics: m,
Update: func(uctx context.Context, c model.Candidate, state model.ExposureState, detail string) {
_ = st.UpdateExposure(uctx, c.Provider.String(), c.Service.String(), c.Hostname, state, detail)
},
})
}
if cfg.IndexEnabled {
pcfg.Indexer = index.New(cfg.IndexMaxObjects, 2*cfg.ProbeTimeout)
pcfg.OnIndex = func(uctx context.Context, c model.Candidate, entries []model.Entry) {
if n, err := st.ReplaceEntries(uctx, c.Provider.String(), c.Service.String(), c.Hostname, entries); err != nil {
slog.Warn("index write failed", "event", "index_error", "host", c.Hostname, "error", err)
} else if n > 0 {
slog.Info("indexed readable resource", "event", "indexed",
"host", c.Hostname, "provider", c.Provider.String(), "service", c.Service.String(), "entries", n)
}
}
}
prober = probe.New(pcfg)
prober.Start(ctx)
slog.Info("active probing enabled")
slog.Info("active probing enabled", "event", "startup", "indexing", cfg.IndexEnabled, "index_max_objects", cfg.IndexMaxObjects)
}
write := func(wctx context.Context, batch []pipeline.Finding) error {
@@ -141,7 +199,7 @@ func run() error {
ui := webui.New(st, webui.Config{User: cfg.UIUser, Pass: cfg.UIPass})
httpSrv := startHTTP(cfg.MetricsAddr, m, st, ui, &monitorReady)
go logStats(ctx, m, cfg.StatsInterval)
go logStats(ctx, m, p, st, resolver, prober, cfg.StatsInterval)
slog.Info("ctcloud started")
err = <-monitorDone
@@ -158,6 +216,7 @@ func run() error {
go func() {
p.Close()
if prober != nil {
prober.Close()
prober.Wait()
}
close(drained)
@@ -213,7 +272,10 @@ func startHTTP(addr string, m *metrics.Metrics, st *store.Store, ui http.Handler
return srv
}
func logStats(ctx context.Context, m *metrics.Metrics, interval time.Duration) {
// logStats emits one structured record per interval with a stable key
// vocabulary, so it can be parsed by an LLM or a log pipeline. It never logs per
// certificate; per-certificate hot-path events are only counted.
func logStats(ctx context.Context, m *metrics.Metrics, p *pipeline.Pipeline, st *store.Store, resolver *dns.ServerPool, prober *probe.Prober, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
@@ -221,7 +283,12 @@ func logStats(ctx context.Context, m *metrics.Metrics, interval time.Duration) {
case <-ctx.Done():
return
case <-ticker.C:
slog.Info("stats",
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
direct, dnsQ, dbQ := p.Depths()
dbTotal, dbIdle, dbAcquired := st.PoolStats()
attrs := []any{
"event", "stats",
"certificates", m.CTCertificates.Load(),
"identifiers", m.CTIdentifiers.Load(),
"direct_matches", m.DirectMatches.Load(),
@@ -230,10 +297,39 @@ func logStats(ctx context.Context, m *metrics.Metrics, interval time.Duration) {
"dns_queries", m.DNSQueries.Load(),
"dns_cache_hits", m.DNSCacheHits.Load(),
"dns_errors", m.DNSErrors.Load(),
"dns_resolvers", resolver.Len(),
"ct_errors", m.CTErrors.Load(),
"ct_malformed", m.MalformedCT.Load(),
"db_rows", m.DBRows.Load(),
"db_errors", m.DBErrors.Load(),
"db_pool_total", dbTotal,
"db_pool_idle", dbIdle,
"db_pool_acquired", dbAcquired,
"queue_direct", direct,
"queue_dns", dnsQ,
"queue_db", dbQ,
"queue_drops", m.QueueDrops.Load(),
)
"goroutines", runtime.NumGoroutine(),
"mem_sys_bytes", ms.Sys,
"mem_heap_bytes", ms.HeapInuse,
"gc_cycles", ms.NumGC,
}
if prober != nil {
attrs = append(attrs,
"probe_queue", prober.QueueLen(),
"probe_attempts", m.ProbeAttempts.Load(),
"probe_public", m.ProbePublic.Load(),
)
}
slog.Info("periodic stats", attrs...)
}
}
}
func redactURLHost(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return "unparseable"
}
return u.Host
}
+7 -9
View File
@@ -34,18 +34,16 @@ services:
depends_on:
postgres:
condition: service_healthy
# All optional tuning (DNS_SERVERS, PROBE_*, UI_USER, ...) comes from .env.
# Leave DNS_SERVERS unset to use the curated + community resolver list.
env_file:
- .env
environment:
DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL in .env}
METRICS_ADDR: ":9090"
LOG_LEVEL: ${LOG_LEVEL:-info}
DNS_SERVERS: ${DNS_SERVERS:-1.1.1.1:53,8.8.8.8:53,1.0.0.1:53}
DNS_WORKERS: ${DNS_WORKERS:-16}
ACTIVE_PROBING: ${ACTIVE_PROBING:-false}
# START_AT_END: "true" # skip historical certificates on first run
# Metrics/health are bound to loopback only: scrape from the host or a
# sidecar, never expose them publicly (there is no auth on this listener).
# Loopback by default. Set UI_BIND=0.0.0.0 in .env to reach the UI/metrics
# from another host (there is no auth unless UI_USER/UI_PASS is set).
ports:
- "127.0.0.1:9090:9090"
- "${UI_BIND:-127.0.0.1}:9090:9090"
# No application volume: the container is stateless apart from PostgreSQL.
read_only: true
tmpfs:
+18 -9
View File
@@ -31,15 +31,19 @@ type Config struct {
DBBatchSize int
DBFlushInterval time.Duration
ActiveProbing bool
ProbeWorkers int
ProbeTimeout time.Duration
ProbeQueueSize int
ActiveProbing bool
ProbeWorkers int
ProbeTimeout time.Duration
ProbeQueueSize int
ProbeDeep bool
IndexEnabled bool
IndexMaxObjects int
MetricsAddr string
UIUser string
UIPass string
LogLevel string
LogFormat string
StatsInterval time.Duration
LogListSource string
@@ -51,15 +55,11 @@ type Config struct {
// DefaultLogListSource is the public CT log list used by certspotter.
const DefaultLogListSource = "https://www.gstatic.com/ct/log_list/v3/log_list.json"
// DefaultDNSServers are load-balanced across. Set DNS_SERVERS=system to use the
// host resolver instead, or provide your own comma-separated list.
var DefaultDNSServers = []string{"1.1.1.1:53", "8.8.8.8:53", "1.0.0.1:53"}
func Load() (*Config, error) {
c := &Config{
DatabaseURL: os.Getenv("DATABASE_URL"),
DBMaxConns: envInt("DB_MAX_CONNS", 8),
DNSServers: envList("DNS_SERVERS", DefaultDNSServers),
DNSServers: envList("DNS_SERVERS", nil),
DNSWorkers: envInt("DNS_WORKERS", 16),
DNSTimeout: envDuration("DNS_TIMEOUT", 5*time.Second),
DNSCacheSize: envInt("DNS_CACHE_SIZE", 1<<18),
@@ -76,10 +76,14 @@ func Load() (*Config, error) {
ProbeWorkers: envInt("PROBE_WORKERS", 8),
ProbeTimeout: envDuration("PROBE_TIMEOUT", 5*time.Second),
ProbeQueueSize: envInt("PROBE_QUEUE_SIZE", 1<<16),
ProbeDeep: envBool("PROBE_DEEP", true),
IndexEnabled: envBool("INDEX_ENABLED", true),
IndexMaxObjects: envInt("INDEX_MAX_OBJECTS", 1000),
MetricsAddr: envString("METRICS_ADDR", ":9090"),
UIUser: envString("UI_USER", ""),
UIPass: envString("UI_PASS", ""),
LogLevel: envString("LOG_LEVEL", "info"),
LogFormat: envString("LOG_FORMAT", "text"),
StatsInterval: envDuration("STATS_INTERVAL", 60*time.Second),
LogListSource: envString("LOG_LIST_SOURCE", DefaultLogListSource),
StartAtEnd: envBool("START_AT_END", false),
@@ -113,6 +117,11 @@ func (c *Config) validate() error {
default:
return fmt.Errorf("LOG_LEVEL must be one of debug, info, warn, error")
}
switch c.LogFormat {
case "text", "json":
default:
return fmt.Errorf("LOG_FORMAT must be text or json")
}
return nil
}
+231 -52
View File
@@ -2,58 +2,181 @@ package dns
import (
"context"
_ "embed"
"errors"
mathrand "math/rand/v2"
"net"
"strings"
"sync"
"sync/atomic"
"time"
)
// ServerPool load-balances DNS lookups across multiple resolvers using
// round-robin, and fails over to the next server on a timeout or server error.
// Definitive answers (NXDOMAIN) are returned immediately and are not retried on
// every server.
// defaultAttemptTimeout bounds each individual server attempt so a dead
// resolver costs a couple of seconds before failover, not the entire lookup
// budget.
const defaultAttemptTimeout = 2 * time.Second
//go:embed resolvers.txt
var embeddedResolvers string
// EmbeddedServers returns the compiled-in public resolver list (a snapshot of
// proabiral/Fresh-Resolvers). Refresh it with:
//
// curl -sL https://raw.githubusercontent.com/proabiral/Fresh-Resolvers/master/resolvers.txt -o internal/dns/resolvers.txt
//
// These are untrusted third-party resolvers: they can observe and, in theory,
// forge DNS answers. Use DNS_SERVERS with your own resolvers for sensitive
// deployments. A circuit breaker skips ones that stop responding.
func EmbeddedServers() []string {
lines := strings.Split(embeddedResolvers, "\n")
out := make([]string, 0, len(lines))
for _, l := range lines {
if l = strings.TrimSpace(l); l != "" {
out = append(out, l)
}
}
return out
}
// CuratedServers are well-known, reliable public resolvers. They are always
// included (ahead of the community list) so a mostly-dead list still works.
func CuratedServers() []string {
return []string{
"1.1.1.1", "1.0.0.1", // Cloudflare
"8.8.8.8", "8.8.4.4", // Google
"9.9.9.9", "149.112.112.112", // Quad9
"208.67.222.222", "208.67.220.220", // OpenDNS
"94.140.14.14", "94.140.15.15", // AdGuard
"76.76.2.0", "76.76.10.0", // ControlD
"185.228.168.9", "185.228.169.9", // CleanBrowsing
"156.154.70.1", "156.154.71.1", // Neustar
"64.6.64.6", "64.6.65.6", // Verisign
"74.82.42.42", // Hurricane Electric
"216.146.35.35", "216.146.36.36", // Oracle/Dyn
}
}
const (
defaultAttemptTimeout = 2 * time.Second
maxQueryAttempts = 3
serverFailureThreshold = 3
serverCooldown = 5 * time.Minute
)
type server struct {
r Resolver
failures atomic.Int32
downUntil atomic.Int64 // unix nanoseconds; 0 = healthy
}
func (s *server) available(now int64) bool { return s.downUntil.Load() <= now }
func (s *server) success() {
s.failures.Store(0)
s.downUntil.Store(0)
}
func (s *server) failure(now int64) {
if s.failures.Add(1) >= serverFailureThreshold {
s.downUntil.Store(now + int64(serverCooldown))
s.failures.Store(0)
}
}
// ServerPool load-balances DNS lookups across many resolvers. Each query picks
// a random healthy resolver and, on a transient error, fails over to a bounded
// number of others. Resolvers that repeatedly fail are put in cooldown, so a
// large list of mostly-dead public resolvers converges on the working subset.
//
// ponytail: random selection + cooldown, not latency-weighted or health-scored.
// Upgrade to EWMA latency weighting if query latency matters.
type ServerPool struct {
resolvers []Resolver
next atomic.Uint64
servers []*server
attemptTimeout time.Duration
}
func (p *ServerPool) withAttemptTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
t := p.attemptTimeout
if t <= 0 {
t = defaultAttemptTimeout
}
return context.WithTimeout(ctx, t)
}
// NewServerPool builds a pool from "host", "host:port" or "system" entries.
// An empty list uses the system resolver.
// NewServerPool builds a pool. Entries may be "host", "host:port" or "system".
// An empty list falls back to the OS resolver.
func NewServerPool(servers []string) *ServerPool {
var resolvers []Resolver
p := &ServerPool{}
for _, s := range servers {
s = strings.TrimSpace(s)
switch {
case s == "":
continue
case s == "system" || s == "local":
return &ServerPool{resolvers: []Resolver{&GoResolver{R: net.DefaultResolver}}}
p.servers = append(p.servers, &server{r: &GoResolver{R: net.DefaultResolver}})
default:
if !strings.Contains(s, ":") {
s += ":53"
}
resolvers = append(resolvers, &GoResolver{R: newServerResolver(s)})
p.servers = append(p.servers, &server{r: &GoResolver{R: newServerResolver(s)}})
}
}
if len(resolvers) == 0 {
resolvers = append(resolvers, &GoResolver{R: net.DefaultResolver})
if len(p.servers) == 0 {
p.servers = append(p.servers, &server{r: &GoResolver{R: net.DefaultResolver}})
}
return &ServerPool{resolvers: resolvers}
return p
}
// Len reports how many resolvers are in the pool.
func (p *ServerPool) Len() int { return len(p.servers) }
// Warm probes up to maxProbe randomly chosen resolvers and puts non-responders
// into cooldown, so a large, partly-stale public list converges on a working
// subset. It deliberately samples instead of probing the whole list: firing
// thousands of concurrent DNS queries can overwhelm the host/container
// resolver and break unrelated lookups. maxProbe <= 0 means all. Returns the
// healthy count.
func (p *ServerPool) Warm(ctx context.Context, probeHost string, workers, maxProbe int) int {
if workers <= 0 {
workers = 32
}
servers := p.servers
if maxProbe > 0 && maxProbe < len(servers) {
order := make([]int, len(servers))
for i := range order {
order[i] = i
}
mathrand.Shuffle(len(order), func(i, j int) { order[i], order[j] = order[j], order[i] })
servers = make([]*server, maxProbe)
for i := range servers {
servers[i] = p.servers[order[i]]
}
}
probeTimeout := time.Second
if p.attemptTimeout > 0 && p.attemptTimeout < probeTimeout {
probeTimeout = p.attemptTimeout
}
jobs := make(chan *server)
var (
wg sync.WaitGroup
healthy atomic.Int32
)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for s := range jobs {
rctx, cancel := context.WithTimeout(ctx, probeTimeout)
_, err := s.r.LookupIP(rctx, probeHost)
cancel()
if err == nil || definitive(err) {
healthy.Add(1) // answered, even if NXDOMAIN
continue
}
s.downUntil.Store(time.Now().Add(serverCooldown).UnixNano())
}
}()
}
for _, s := range servers {
select {
case jobs <- s:
case <-ctx.Done():
close(jobs)
wg.Wait()
return int(healthy.Load())
}
}
close(jobs)
wg.Wait()
return int(healthy.Load())
}
func newServerResolver(server string) *net.Resolver {
@@ -69,8 +192,48 @@ func newServerResolver(server string) *net.Resolver {
}
}
func (p *ServerPool) pick() int {
return int((p.next.Add(1) - 1) % uint64(len(p.resolvers)))
func (p *ServerPool) withAttemptTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
t := p.attemptTimeout
if t <= 0 {
t = defaultAttemptTimeout
}
return context.WithTimeout(ctx, t)
}
func triedContain(tried []int, n, idx int) bool {
for i := 0; i < n; i++ {
if tried[i] == idx {
return true
}
}
return false
}
// pickIndex returns an untried resolver, preferring healthy ones, by scanning
// cyclically from a random start. Scanning (rather than random sampling) means
// a healthy resolver is always found when one exists, which matters for small
// pools. With a warm pool of thousands the first candidate is almost always
// healthy, so it stays O(1) in practice. If all are in cooldown it still
// returns one, so a fully-cooled pool can recover.
func (p *ServerPool) pickIndex(now int64, tried []int, n int) int {
total := len(p.servers)
if total == 0 {
return -1
}
start := mathrand.IntN(total)
for i := 0; i < total; i++ {
idx := (start + i) % total
if !triedContain(tried, n, idx) && p.servers[idx].available(now) {
return idx
}
}
for i := 0; i < total; i++ {
idx := (start + i) % total
if !triedContain(tried, n, idx) {
return idx
}
}
return -1
}
// definitive reports whether err is a real DNS answer (NXDOMAIN) that should not
@@ -80,40 +243,56 @@ func definitive(err error) bool {
return errors.As(err, &dnsErr) && dnsErr.IsNotFound
}
func (p *ServerPool) LookupCNAME(ctx context.Context, host string) (string, error) {
n := len(p.resolvers)
start := p.pick()
var last error
for i := 0; i < n; i++ {
func (p *ServerPool) lookup(ctx context.Context, fn func(context.Context, Resolver) error) error {
var (
tried [maxQueryAttempts]int
last error
)
for n := 0; n < maxQueryAttempts; n++ {
idx := p.pickIndex(time.Now().UnixNano(), tried[:], n)
if idx < 0 {
break
}
tried[n] = idx
actx, cancel := p.withAttemptTimeout(ctx)
cname, err := p.resolvers[(start+i)%n].LookupCNAME(actx, host)
err := fn(actx, p.servers[idx].r)
cancel()
if err == nil {
return cname, nil
p.servers[idx].success()
return nil
}
if definitive(err) {
return "", err
return err
}
p.servers[idx].failure(time.Now().UnixNano())
last = err
if ctx.Err() != nil {
return err
}
}
return "", last
return last
}
func (p *ServerPool) LookupCNAME(ctx context.Context, host string) (string, error) {
var result string
err := p.lookup(ctx, func(c context.Context, r Resolver) error {
v, e := r.LookupCNAME(c, host)
if e == nil {
result = v
}
return e
})
return result, err
}
func (p *ServerPool) LookupIP(ctx context.Context, host string) ([]net.IP, error) {
n := len(p.resolvers)
start := p.pick()
var last error
for i := 0; i < n; i++ {
actx, cancel := p.withAttemptTimeout(ctx)
ips, err := p.resolvers[(start+i)%n].LookupIP(actx, host)
cancel()
if err == nil {
return ips, nil
var result []net.IP
err := p.lookup(ctx, func(c context.Context, r Resolver) error {
v, e := r.LookupIP(c, host)
if e == nil {
result = v
}
if definitive(err) {
return nil, err
}
last = err
}
return nil, last
return e
})
return result, err
}
+55 -27
View File
@@ -5,6 +5,7 @@ import (
"net"
"sync/atomic"
"testing"
"time"
)
type fakeLookup struct {
@@ -29,40 +30,67 @@ func (f *fakeLookup) LookupIP(ctx context.Context, host string) ([]net.IP, error
return []net.IP{net.ParseIP("1.2.3.4")}, nil
}
func TestServerPoolRoundRobin(t *testing.T) {
a, b := &fakeLookup{}, &fakeLookup{}
p := &ServerPool{resolvers: []Resolver{a, b}}
for i := 0; i < 4; i++ {
if _, err := p.LookupIP(context.Background(), "example.com"); err != nil {
t.Fatal(err)
func TestEmbeddedServers(t *testing.T) {
if n := len(EmbeddedServers()); n < 1000 {
t.Fatalf("embedded resolver list has %d entries, want thousands", n)
}
if n := len(CuratedServers()); n < 10 {
t.Fatalf("curated resolver list has %d entries, want at least 10", n)
}
}
func TestServerCooldown(t *testing.T) {
s := &server{}
now := time.Now().UnixNano()
for i := 0; i < serverFailureThreshold; i++ {
s.failure(now)
}
if s.available(now) {
t.Fatal("server should be in cooldown after repeated failures")
}
if !s.available(now + int64(serverCooldown) + 1) {
t.Fatal("server should be available after cooldown")
}
s.success()
if !s.available(now) || s.failures.Load() != 0 {
t.Fatal("success should clear failures and cooldown")
}
}
func TestPoolFailover(t *testing.T) {
bad := &fakeLookup{ipErr: &net.DNSError{Err: "server misbehaving", IsTemporary: true}}
good := &fakeLookup{}
p := &ServerPool{servers: []*server{{r: bad}, {r: good}}}
for i := 0; i < 50; i++ {
ips, err := p.LookupIP(context.Background(), "example.com")
if err != nil || len(ips) != 1 {
t.Fatalf("lookup %d failed: %v", i, err)
}
}
if a.calls.Load() != 2 || b.calls.Load() != 2 {
t.Fatalf("round robin calls: a=%d b=%d, want 2/2", a.calls.Load(), b.calls.Load())
}
}
func TestServerPoolFailover(t *testing.T) {
a := &fakeLookup{ipErr: &net.DNSError{Err: "timeout", IsTimeout: true}}
b := &fakeLookup{}
p := &ServerPool{resolvers: []Resolver{a, b}}
ips, err := p.LookupIP(context.Background(), "example.com")
if err != nil {
t.Fatal(err)
}
if len(ips) != 1 || a.calls.Load() != 1 {
t.Fatalf("expected failover to second server, a.calls=%d", a.calls.Load())
}
}
func TestServerPoolNoFailoverOnNXDOMAIN(t *testing.T) {
func TestPoolNoFailoverOnNXDOMAIN(t *testing.T) {
a := &fakeLookup{ipErr: &net.DNSError{Err: "no such host", IsNotFound: true}}
b := &fakeLookup{}
p := &ServerPool{resolvers: []Resolver{a, b}}
b := &fakeLookup{ipErr: &net.DNSError{Err: "no such host", IsNotFound: true}}
p := &ServerPool{servers: []*server{{r: a}, {r: b}}}
if _, err := p.LookupIP(context.Background(), "missing.example.com"); err == nil {
t.Fatal("expected error")
}
if b.calls.Load() != 0 {
t.Fatalf("NXDOMAIN must not fail over; b.calls=%d", b.calls.Load())
if got := a.calls.Load() + b.calls.Load(); got != 1 {
t.Fatalf("NXDOMAIN made %d lookups, want exactly 1 (no failover)", got)
}
}
func TestWarmDisablesDeadResolvers(t *testing.T) {
good := &fakeLookup{}
dead := &fakeLookup{ipErr: &net.DNSError{Err: "timeout", IsTimeout: true}}
deadServer := &server{r: dead}
p := &ServerPool{servers: []*server{deadServer, {r: good}}}
healthy := p.Warm(context.Background(), "example.com", 4, 0)
if healthy != 1 {
t.Fatalf("healthy = %d, want 1", healthy)
}
if deadServer.available(time.Now().UnixNano()) {
t.Fatal("dead resolver should be in cooldown after warmup")
}
}
File diff suppressed because it is too large. Load diff
+364
View File
@@ -0,0 +1,364 @@
// Package index fetches a bounded metadata index (object keys, sizes, dates) of
// resources that were confirmed anonymously readable. It never downloads object
// contents: list APIs only. It uses plain HTTP and the standard library XML/JSON
// parsers rather than cloud SDKs, which keeps the binary small and the hot path
// allocation-free; the list APIs are exactly what the SDKs call underneath.
package index
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"tea.karlcom.de/karl/bucketeer/internal/model"
)
// Indexer lists readable resources. Safe for concurrent use.
type Indexer struct {
client *http.Client
maxObjects int
scheme string // overridable in tests
gcsBase string // overridable in tests
}
func New(maxObjects int, timeout time.Duration) *Indexer {
if maxObjects <= 0 {
maxObjects = 1000
}
if timeout <= 0 {
timeout = 10 * time.Second
}
return &Indexer{
maxObjects: maxObjects,
scheme: "https",
gcsBase: "https://storage.googleapis.com",
client: &http.Client{
Timeout: timeout,
// Use a distinct read limit per response; timeout bounds the whole list.
Transport: &http.Transport{MaxIdleConnsPerHost: 2, IdleConnTimeout: 30 * time.Second},
},
}
}
// List returns up to the configured maximum number of entries for a readable
// candidate. Provider families without a listing API return no entries.
func (x *Indexer) List(ctx context.Context, c model.Candidate) ([]model.Entry, error) {
switch c.Provider {
case model.ProviderAWS:
switch c.Service {
case model.ServiceS3:
return x.listS3(ctx, c.Hostname)
case model.ServiceOpenSearch:
return x.listOpenSearch(ctx, c.Hostname)
}
case model.ProviderGCP:
switch c.Service {
case model.ServiceObjectStorage:
return x.listGCS(ctx, c.Resource)
case model.ServiceFirebaseDB:
return x.listFirebase(ctx, c.Hostname)
}
case model.ProviderAzure:
switch c.Service {
case model.ServiceBlobStorage, model.ServiceFileStorage, model.ServiceQueue, model.ServiceTableStorage:
return x.listAzure(ctx, c.Hostname)
}
case model.ProviderCloudflare:
return x.listS3(ctx, c.Hostname)
case model.ProviderDigitalOcean:
if c.Service == model.ServiceObjectStorage {
return x.listS3(ctx, c.Hostname)
}
case model.ProviderOracle:
return x.listS3(ctx, c.Hostname)
}
return nil, nil
}
func (x *Indexer) get(ctx context.Context, endpoint string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "bucketeer-indexer")
resp, err := x.client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode >= 300 {
_, _ = io.CopyN(io.Discard, resp.Body, 512)
resp.Body.Close()
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return resp, nil
}
// --- S3-compatible (AWS S3, DigitalOcean Spaces, Cloudflare R2, OCI) ---
type s3ListResult struct {
IsTruncated bool `xml:"IsTruncated"`
NextContinuationToken string `xml:"NextContinuationToken"`
Contents []struct {
Key string `xml:"Key"`
LastModified string `xml:"LastModified"`
ETag string `xml:"ETag"`
Size int64 `xml:"Size"`
StorageClass string `xml:"StorageClass"`
} `xml:"Contents"`
CommonPrefixes []struct {
Prefix string `xml:"Prefix"`
} `xml:"CommonPrefixes"`
}
func (x *Indexer) listS3(ctx context.Context, host string) ([]model.Entry, error) {
var (
entries []model.Entry
token string
pageGuard int
)
for len(entries) < x.maxObjects && pageGuard < 50 {
pageGuard++
endpoint := x.scheme + "://" + host + "/?list-type=2&max-keys=1000"
if token != "" {
endpoint += "&continuation-token=" + url.QueryEscape(token)
}
resp, err := x.get(ctx, endpoint)
if err != nil {
return entries, err
}
var page s3ListResult
err = xml.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&page)
resp.Body.Close()
if err != nil {
return entries, err
}
for _, p := range page.CommonPrefixes {
entries = append(entries, model.Entry{Kind: "prefix", Name: p.Prefix})
}
for _, o := range page.Contents {
entries = append(entries, model.Entry{
Kind: "object", Name: o.Key, Size: o.Size,
Updated: parseTime(o.LastModified), ETag: strings.Trim(o.ETag, `"`),
})
if len(entries) >= x.maxObjects {
break
}
}
if !page.IsTruncated || page.NextContinuationToken == "" {
break
}
token = page.NextContinuationToken
}
return entries, nil
}
// --- Google Cloud Storage ---
type gcsList struct {
Items []struct {
Name string `json:"name"`
Size string `json:"size"`
Updated string `json:"updated"`
ContentType string `json:"contentType"`
ETag string `json:"etag"`
} `json:"items"`
Prefixes []string `json:"prefixes"`
NextPageToken string `json:"nextPageToken"`
}
func (x *Indexer) listGCS(ctx context.Context, bucket string) ([]model.Entry, error) {
if bucket == "" {
return nil, nil
}
var (
entries []model.Entry
token string
pageGuard int
)
for len(entries) < x.maxObjects && pageGuard < 50 {
pageGuard++
endpoint := x.gcsBase + "/storage/v1/b/" + url.PathEscape(bucket) + "/o?maxResults=1000"
if token != "" {
endpoint += "&pageToken=" + url.QueryEscape(token)
}
resp, err := x.get(ctx, endpoint)
if err != nil {
return entries, err
}
var page gcsList
err = json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(&page)
resp.Body.Close()
if err != nil {
return entries, err
}
for _, p := range page.Prefixes {
entries = append(entries, model.Entry{Kind: "prefix", Name: p})
}
for _, o := range page.Items {
size, _ := strconv.ParseInt(o.Size, 10, 64)
entries = append(entries, model.Entry{
Kind: "object", Name: o.Name, Size: size,
Updated: parseTime(o.Updated), ContentType: o.ContentType, ETag: o.ETag,
})
if len(entries) >= x.maxObjects {
break
}
}
if page.NextPageToken == "" {
break
}
token = page.NextPageToken
}
return entries, nil
}
// --- Azure Blob / File / Queue / Table (account-level listing) ---
type azContainers struct {
Containers struct {
Container []struct {
Name string `xml:"Name"`
} `xml:"Container"`
} `xml:"Containers"`
}
type azBlobs struct {
Blobs struct {
Blob []struct {
Name string `xml:"Name"`
Properties struct {
ContentLength int64 `xml:"Content-Length"`
ContentType string `xml:"Content-Type"`
LastModified string `xml:"Last-Modified"`
Etag string `xml:"Etag"`
} `xml:"Properties"`
} `xml:"Blob"`
} `xml:"Blobs"`
}
func (x *Indexer) listAzure(ctx context.Context, host string) ([]model.Entry, error) {
resp, err := x.get(ctx, x.scheme+"://"+host+"/?comp=list")
if err != nil {
return nil, err
}
var page azContainers
err = xml.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&page)
resp.Body.Close()
if err != nil {
return nil, err
}
var entries []model.Entry
for _, c := range page.Containers.Container {
entries = append(entries, model.Entry{Kind: "container", Name: c.Name})
if len(entries) >= x.maxObjects {
return entries, nil
}
// List a bounded number of blobs from each container.
blobs, err := x.listAzureContainer(ctx, host, c.Name, x.maxObjects-len(entries))
if err != nil {
continue
}
entries = append(entries, blobs...)
if len(entries) >= x.maxObjects {
return entries, nil
}
}
return entries, nil
}
func (x *Indexer) listAzureContainer(ctx context.Context, host, container string, remaining int) ([]model.Entry, error) {
resp, err := x.get(ctx, x.scheme+"://"+host+"/"+url.PathEscape(container)+"?restype=container&comp=list")
if err != nil {
return nil, err
}
var page azBlobs
err = xml.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&page)
resp.Body.Close()
if err != nil {
return nil, err
}
var entries []model.Entry
for _, b := range page.Blobs.Blob {
entries = append(entries, model.Entry{
Kind: "object", Name: container + "/" + b.Name, Size: b.Properties.ContentLength,
Updated: parseTime(b.Properties.LastModified), ContentType: b.Properties.ContentType,
ETag: b.Properties.Etag,
})
if len(entries) >= remaining {
break
}
}
return entries, nil
}
// --- Firebase Realtime Database (shallow root keys) ---
func (x *Indexer) listFirebase(ctx context.Context, host string) ([]model.Entry, error) {
resp, err := x.get(ctx, x.scheme+"://"+host+"/.json?shallow=true")
if err != nil {
return nil, err
}
var keys map[string]json.RawMessage
err = json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&keys)
resp.Body.Close()
if err != nil {
return nil, err
}
entries := make([]model.Entry, 0, len(keys))
for k := range keys {
entries = append(entries, model.Entry{Kind: "key", Name: k})
if len(entries) >= x.maxObjects {
break
}
}
return entries, nil
}
// --- OpenSearch / Elasticsearch indices ---
func (x *Indexer) listOpenSearch(ctx context.Context, host string) ([]model.Entry, error) {
resp, err := x.get(ctx, x.scheme+"://"+host+"/_cat/indices?format=json&h=index,health,status,docs.count,store.size")
if err != nil {
return nil, err
}
var rows []struct {
Index string `json:"index"`
Health string `json:"health"`
Status string `json:"status"`
Docs string `json:"docs.count"`
StoreSiz string `json:"store.size"`
}
err = json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&rows)
resp.Body.Close()
if err != nil {
return nil, err
}
var entries []model.Entry
for _, r := range rows {
size, _ := strconv.ParseInt(strings.TrimSuffix(r.Docs, " "), 10, 64)
entries = append(entries, model.Entry{Kind: "index", Name: r.Index, Size: size, ETag: r.StoreSiz})
if len(entries) >= x.maxObjects {
break
}
}
return entries, nil
}
func parseTime(s string) time.Time {
if s == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, http.TimeFormat} {
if t, err := time.Parse(layout, s); err == nil {
return t
}
}
return time.Time{}
}
+114
View File
@@ -0,0 +1,114 @@
package index
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"tea.karlcom.de/karl/bucketeer/internal/model"
)
func newTestIndexer(handler http.Handler) (*Indexer, string) {
srv := httptest.NewServer(handler)
x := New(50, 2*time.Second)
x.scheme = "http"
x.gcsBase = srv.URL
return x, strings.TrimPrefix(srv.URL, "http://")
}
func TestListS3(t *testing.T) {
body := `<?xml version="1.0"?><ListBucketResult>
<IsTruncated>false</IsTruncated>
<CommonPrefixes><Prefix>photos/</Prefix></CommonPrefixes>
<Contents><Key>photos/a.jpg</Key><LastModified>2024-01-02T03:04:05.000Z</LastModified><ETag>"abc"</ETag><Size>1234</Size></Contents>
<Contents><Key>readme.txt</Key><Size>12</Size></Contents>
</ListBucketResult>`
x, host := newTestIndexer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("list-type") != "2" {
t.Errorf("expected list-type=2, got %q", r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(body))
}))
entries, err := x.List(context.Background(), model.Candidate{Hostname: host, Provider: model.ProviderAWS, Service: model.ServiceS3})
if err != nil {
t.Fatal(err)
}
if len(entries) != 3 {
t.Fatalf("got %d entries: %+v", len(entries), entries)
}
if entries[0].Kind != "prefix" || entries[0].Name != "photos/" {
t.Fatalf("prefix entry wrong: %+v", entries[0])
}
if entries[1].Name != "photos/a.jpg" || entries[1].Size != 1234 || entries[1].ETag != "abc" {
t.Fatalf("object entry wrong: %+v", entries[1])
}
if entries[1].Updated.IsZero() {
t.Fatal("expected updated timestamp parsed")
}
}
func TestListGCS(t *testing.T) {
body := `{"items":[{"name":"a/b.txt","size":"42","updated":"2024-05-06T07:08:09Z","contentType":"text/plain","etag":"e1"}],"prefixes":["a/"]}`
x, _ := newTestIndexer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/storage/v1/b/") {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(body))
}))
entries, err := x.List(context.Background(), model.Candidate{Resource: "mybucket", Provider: model.ProviderGCP, Service: model.ServiceObjectStorage})
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 || entries[1].Name != "a/b.txt" || entries[1].Size != 42 || entries[1].ContentType != "text/plain" {
t.Fatalf("unexpected entries: %+v", entries)
}
}
func TestListFirebase(t *testing.T) {
x, host := newTestIndexer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"users":true,"orders":true}`))
}))
entries, err := x.List(context.Background(), model.Candidate{Hostname: host, Provider: model.ProviderGCP, Service: model.ServiceFirebaseDB})
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 || entries[0].Kind != "key" {
t.Fatalf("unexpected entries: %+v", entries)
}
}
func TestListOpenSearch(t *testing.T) {
x, host := newTestIndexer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[{"index":"logs-2024","docs.count":"100","store.size":"1.2mb"}]`))
}))
entries, err := x.List(context.Background(), model.Candidate{Hostname: host, Provider: model.ProviderAWS, Service: model.ServiceOpenSearch})
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Name != "logs-2024" || entries[0].Size != 100 {
t.Fatalf("unexpected entries: %+v", entries)
}
}
func TestListBounded(t *testing.T) {
var keys strings.Builder
for i := 0; i < 100; i++ {
keys.WriteString("<Contents><Key>k")
keys.WriteString(string(rune('a' + i%26)))
keys.WriteString("</Key><Size>1</Size></Contents>")
}
body := `<ListBucketResult><IsTruncated>false</IsTruncated>` + keys.String() + `</ListBucketResult>`
x, host := newTestIndexer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(body)) }))
x.maxObjects = 10
entries, err := x.List(context.Background(), model.Candidate{Hostname: host, Provider: model.ProviderAWS, Service: model.ServiceS3})
if err != nil {
t.Fatal(err)
}
if len(entries) > 10 {
t.Fatalf("got %d entries, want <= 10", len(entries))
}
}
+8 -6
View File
@@ -33,12 +33,13 @@ type Metrics struct {
DBRows atomic.Uint64
DBErrors atomic.Uint64
QueueDrops atomic.Uint64
QueueFull atomic.Uint64
MalformedCT atomic.Uint64
CTErrors atomic.Uint64
ProbeAttempts atomic.Uint64
ProbePublic atomic.Uint64
QueueDrops atomic.Uint64
QueueFull atomic.Uint64
MalformedCT atomic.Uint64
CTErrors atomic.Uint64
ProbeAttempts atomic.Uint64
ProbePublic atomic.Uint64
EntriesIndexed atomic.Uint64
gauges []gauge
}
@@ -84,6 +85,7 @@ func (m *Metrics) Handler() http.Handler {
counter("ct_errors_total", "Non-fatal CT errors", m.CTErrors.Load())
counter("probe_attempts_total", "Optional reachability probes performed", m.ProbeAttempts.Load())
counter("probe_public_total", "Probes classified as public read", m.ProbePublic.Load())
counter("entries_indexed_total", "Metadata entries indexed from readable resources", m.EntriesIndexed.Load())
for _, g := range m.gauges {
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s gauge\n%s%s %g\n", g.name, g.help, g.name, g.name, g.extra, g.fn())
}
+13
View File
@@ -2,6 +2,8 @@
// classifier, pipeline and store.
package model
import "time"
// Provider identifies a cloud/database/storage provider.
type Provider uint8
@@ -205,6 +207,17 @@ type Match struct {
Resource string
}
// Entry is one indexed item of a readable resource (object key, container,
// prefix, database index, root key). It is metadata only — never the content.
type Entry struct {
Kind string // object | prefix | container | key | index
Name string
Size int64
Updated time.Time
ContentType string
ETag string
}
// Candidate is a provider resource discovered directly or through DNS.
type Candidate struct {
Hostname string // hostname that matched (provider hostname)
+5
View File
@@ -117,6 +117,11 @@ func (p *Pipeline) Start(ctx context.Context) {
go p.dbWorker(ctx)
}
// Depths reports the current length of the direct, DNS and DB queues.
func (p *Pipeline) Depths() (direct, dns, db int) {
return len(p.directQ), len(p.dnsQ), len(p.dbQ)
}
// Close stops intake and drains all queues. It must only be called after the
// CertSpotter monitor has stopped calling HandleIdentifiers.
func (p *Pipeline) Close() {
+61 -2
View File
@@ -23,6 +23,7 @@ import (
"sync"
"time"
"tea.karlcom.de/karl/bucketeer/internal/index"
"tea.karlcom.de/karl/bucketeer/internal/metrics"
"tea.karlcom.de/karl/bucketeer/internal/model"
)
@@ -34,14 +35,21 @@ type Config struct {
Workers int
Timeout time.Duration
Queue int
Deep bool // unauthenticated protocol reads for database services
Metrics *metrics.Metrics
Update UpdateFunc
// Indexer, when set, lists metadata for resources confirmed public_read and
// hands the entries to OnIndex. Contents are never downloaded.
Indexer *index.Indexer
OnIndex func(ctx context.Context, c model.Candidate, entries []model.Entry)
}
type Prober struct {
cfg Config
sample *sampleQueue
client *http.Client
stop chan struct{}
wg sync.WaitGroup
}
@@ -75,6 +83,12 @@ func (q *sampleQueue) push(c model.Candidate) {
}
}
func (q *sampleQueue) len() int {
q.mu.Lock()
defer q.mu.Unlock()
return len(q.items)
}
func (q *sampleQueue) pop() (model.Candidate, bool) {
q.mu.Lock()
n := len(q.items)
@@ -103,6 +117,7 @@ func New(cfg Config) *Prober {
return &Prober{
cfg: cfg,
sample: newSampleQueue(cfg.Queue),
stop: make(chan struct{}),
client: &http.Client{
Timeout: cfg.Timeout,
// Observe the first response; do not follow redirects so region
@@ -141,14 +156,23 @@ func (p *Prober) Submit(c model.Candidate) {
p.sample.push(c)
}
// Close stops the workers after any in-flight probe. It must be called before
// Wait, otherwise Wait blocks until ctx is cancelled.
func (p *Prober) Close() { close(p.stop) }
func (p *Prober) Wait() { p.wg.Wait() }
// QueueLen reports the current probe sample depth.
func (p *Prober) QueueLen() int { return p.sample.len() }
func (p *Prober) worker(ctx context.Context) {
defer p.wg.Done()
for {
select {
case <-ctx.Done():
return
case <-p.stop:
return
case <-p.sample.notify:
}
for {
@@ -163,12 +187,27 @@ func (p *Prober) worker(ctx context.Context) {
if p.cfg.Update != nil {
p.cfg.Update(ctx, c, state, detail)
}
if state == model.ExposurePublicRead && p.cfg.Indexer != nil && p.cfg.OnIndex != nil {
if entries, err := p.cfg.Indexer.List(ctx, c); err == nil && len(entries) > 0 {
p.cfg.OnIndex(ctx, c, entries)
if p.cfg.Metrics != nil {
p.cfg.Metrics.EntriesIndexed.Add(uint64(len(entries)))
}
}
}
}
}
}
// Probe runs the vendor-appropriate check for a single candidate.
func (p *Prober) Probe(ctx context.Context, c model.Candidate) (model.ExposureState, string) {
// Deep, read-only protocol checks take precedence for database services and
// can distinguish "reachable" from "actually readable without credentials".
if p.cfg.Deep {
if state, detail, ok := p.deepDB(ctx, c); ok {
return state, detail
}
}
switch c.Provider {
case model.ProviderAWS:
switch c.Service {
@@ -188,8 +227,7 @@ func (p *Prober) Probe(ctx context.Context, c model.Candidate) (model.ExposureSt
case model.ProviderAzure:
switch c.Service {
case model.ServiceBlobStorage, model.ServiceFileStorage, model.ServiceQueue, model.ServiceTableStorage:
// Anonymous container/file/share/queue/table listing.
return p.checkHTTP(ctx, "https://"+c.Hostname+"/?comp=list")
return p.checkAzureStorage(ctx, c)
case model.ServiceWebApp, model.ServiceSearch:
return p.checkHTTP(ctx, "https://"+c.Hostname+"/")
case model.ServiceCosmosDB:
@@ -230,6 +268,27 @@ func (p *Prober) Probe(ctx context.Context, c model.Candidate) (model.ExposureSt
return p.checkHTTP(ctx, "https://"+c.Hostname+"/")
}
// checkAzureStorage probes the account-level container list. Anonymous read is
// configured per container in Azure ("Public access level"), never at the
// account level, so a 401/403 here means only that account listing needs auth —
// it does NOT mean the containers are unreadable. We therefore report reachable
// rather than auth_required, and only index if account listing is unexpectedly
// public (which would let us discover container names).
func (p *Prober) checkAzureStorage(ctx context.Context, c model.Candidate) (model.ExposureState, string) {
status, _, err := p.do(ctx, "https://"+c.Hostname+"/?comp=list")
if err != nil {
return classifyError(err)
}
switch {
case status >= 200 && status < 300:
return model.ExposurePublicRead, "azure account-level anonymous listing succeeded"
case status == http.StatusUnauthorized || status == http.StatusForbidden:
return model.ExposureReachable, "azure account listing requires auth; per-container readability needs a container name"
default:
return classifyStatus(status, "azure")
}
}
// checkS3 queries the S3 ListObjectsV2 anonymous endpoint. A 200 means the
// bucket allows anonymous listing (public read); 403 means it exists but is not
// public; 404 means no such bucket. 301/307 carries the real region.
+378
View File
@@ -0,0 +1,378 @@
package probe
import (
"context"
"crypto/tls"
"encoding/binary"
"fmt"
"io"
"math"
"net"
"strings"
"time"
"tea.karlcom.de/karl/bucketeer/internal/model"
)
// Deep, unauthenticated, read-only protocol probes. They determine whether a
// database endpoint actually answers a read without credentials. No password,
// token or credential of any kind is ever sent, and no state is mutated.
func (p *Prober) deepDB(ctx context.Context, c model.Candidate) (model.ExposureState, string, bool) {
switch c.Service {
case model.ServiceRedis, model.ServiceElastiCache, model.ServiceMemoryDB:
return p.probeRedis(ctx, c.Hostname, dbPort(c.Service))
case model.ServiceRDS, model.ServicePostgres, model.ServiceRedshift:
return p.probePostgres(ctx, c.Hostname, 5432)
case model.ServiceDocumentDB, model.ServiceCosmosDB:
return p.probeMongo(ctx, c.Hostname, 27017)
case model.ServiceOpenSearch:
return p.probeHTTPReadable(ctx, "https://"+c.Hostname+"/_cat/indices?format=json&h=index")
case model.ServiceNeptune:
return p.probeHTTPReadable(ctx, "https://"+c.Hostname+"/status")
}
return model.ExposureUnknown, "", false
}
func dbPort(s model.Service) int {
switch s {
case model.ServiceMySQL:
return 3306
case model.ServicePostgres, model.ServiceRDS:
return 5432
case model.ServiceRedis, model.ServiceElastiCache, model.ServiceMemoryDB:
return 6379
case model.ServiceDocumentDB, model.ServiceCosmosDB:
return 27017
case model.ServiceRedshift:
return 5439
case model.ServiceNeptune:
return 8182
case model.ServiceDatabase:
return 1433
default:
return 5432
}
}
func (p *Prober) dial(ctx context.Context, host string, port int) (net.Conn, error) {
d := net.Dialer{Timeout: p.cfg.Timeout}
return d.DialContext(ctx, "tcp", net.JoinHostPort(host, fmt.Sprint(port)))
}
func (p *Prober) dialTLS(ctx context.Context, host string, port int) (net.Conn, error) {
conn, err := p.dial(ctx, host, port)
if err != nil {
return nil, err
}
tconn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true, ServerName: host}) // observation only
_ = tconn.SetDeadline(time.Now().Add(p.cfg.Timeout))
if err := tconn.HandshakeContext(ctx); err != nil {
conn.Close()
return nil, err
}
return tconn, nil
}
// probeRedis sends PING. Redis answers +PONG when no authentication is required.
func (p *Prober) probeRedis(ctx context.Context, host string, port int) (model.ExposureState, string, bool) {
var lastErr error
for _, tlsOn := range []bool{false, true} {
var (
conn net.Conn
err error
)
if tlsOn {
conn, err = p.dialTLS(ctx, host, port)
} else {
conn, err = p.dial(ctx, host, port)
}
if err != nil {
lastErr = err
continue
}
_ = conn.SetDeadline(time.Now().Add(p.cfg.Timeout))
_, _ = conn.Write([]byte("PING\r\n"))
buf := make([]byte, 256)
n, _ := conn.Read(buf)
conn.Close()
resp := string(buf[:n])
switch {
case strings.HasPrefix(resp, "+PONG"):
return model.ExposurePublicRead, "redis PING answered without auth", true
case strings.Contains(resp, "NOAUTH"), strings.HasPrefix(resp, "-ERR"), strings.Contains(resp, "DENIED"), strings.Contains(resp, "protected mode"):
return model.ExposureAuthRequired, "redis requires authentication", true
}
// no/invalid response: try the other transport
}
if lastErr == nil {
lastErr = fmt.Errorf("redis: no response")
}
st, detail := classifyError(lastErr)
return st, detail, true
}
// probePostgres negotiates TLS if offered, then sends a StartupMessage with no
// password. The server's AuthenticationRequest tells us whether it trusts the
// connection (AuthenticationOk) or requires a password.
func (p *Prober) probePostgres(ctx context.Context, host string, port int) (model.ExposureState, string, bool) {
conn, err := p.dial(ctx, host, port)
if err != nil {
st, d := classifyError(err)
return st, d, true
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(p.cfg.Timeout))
// SSLRequest (length 8, code 80877103).
req := []byte{0, 0, 0, 8, 4, 210, 22, 47}
if _, err := conn.Write(req); err == nil {
one := make([]byte, 1)
if _, err := io.ReadFull(conn, one); err == nil {
switch one[0] {
case 'S': // proceed with TLS
tconn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true, ServerName: host})
if err := tconn.HandshakeContext(ctx); err == nil {
conn = tconn
}
case 'N': // plaintext only
case 'E': // server refused; fall through and try startup anyway
}
}
}
// StartupMessage: user=postgres database=postgres
payload := []byte{0, 3, 0, 0}
payload = append(payload, "user\x00postgres\x00database\x00postgres\x00\x00"...)
msg := make([]byte, 4+len(payload))
binary.BigEndian.PutUint32(msg, uint32(4+len(payload)))
copy(msg[4:], payload)
if _, err := conn.Write(msg); err != nil {
st, d := classifyError(err)
return st, d, true
}
head := make([]byte, 5)
if _, err := io.ReadFull(conn, head); err != nil {
st, d := classifyError(err)
return st, d, true
}
kind := head[0]
length := int(binary.BigEndian.Uint32(head[1:5]))
if length > 4 {
body := make([]byte, length-4)
_, _ = io.ReadFull(conn, body)
if kind == 'R' && len(body) >= 4 {
authType := binary.BigEndian.Uint32(body[:4])
if authType == 0 {
return model.ExposurePublicRead, "postgres AuthenticationOk (trust, no password required)", true
}
return model.ExposureAuthRequired, fmt.Sprintf("postgres requires password (auth type %d)", authType), true
}
if kind == 'E' {
return model.ExposureAuthRequired, "postgres rejected startup (pg_hba/host not permitted)", true
}
}
return model.ExposureReachable, "postgres port open; auth state unclear", true
}
// probeMongo performs the OP_MSG handshake then a read-only listDatabases. A
// successful, non-empty reply means the deployment has no authentication.
func (p *Prober) probeMongo(ctx context.Context, host string, port int) (model.ExposureState, string, bool) {
var (
conn net.Conn
err error
)
// Managed MongoDB (DocumentDB, Cosmos) requires TLS; try it, then plain.
conn, err = p.dialTLS(ctx, host, port)
if err != nil {
conn, err = p.dial(ctx, host, port)
if err != nil {
st, d := classifyError(err)
return st, d, true
}
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(p.cfg.Timeout))
doc := bsonDoc{"listDatabases": int32(1), "$db": "admin"}
if err := writeOPMsg(conn, doc); err != nil {
st, d := classifyError(err)
return st, d, true
}
raw, err := readOPMsg(conn)
if err != nil {
st, d := classifyError(err)
return st, d, true
}
ok, errmsg := parseBSONFields(raw)
switch {
case ok == 1 && errmsg == "":
return model.ExposurePublicRead, "mongodb listDatabases succeeded without auth", true
case strings.Contains(strings.ToLower(errmsg), "auth") || strings.Contains(strings.ToLower(errmsg), "unauthorized"):
return model.ExposureAuthRequired, "mongodb requires authentication", true
default:
return model.ExposureReachable, "mongodb replied but auth state unclear", true
}
}
func (p *Prober) probeHTTPReadable(ctx context.Context, endpoint string) (model.ExposureState, string, bool) {
status, _, err := p.do(ctx, endpoint)
if err != nil {
st, d := classifyError(err)
return st, d, true
}
st, d := classifyStatus(status, "")
return st, d, true
}
// --- minimal BSON/OP_MSG helpers (read-only) ---
type bsonDoc map[string]any
func appendCString(b []byte, s string) []byte {
b = append(b, s...)
return append(b, 0)
}
func encodeBSON(doc bsonDoc) []byte {
var body []byte
for k, v := range doc {
switch val := v.(type) {
case int32:
body = append(body, 0x10)
body = appendCString(body, k)
var tmp [4]byte
binary.LittleEndian.PutUint32(tmp[:], uint32(val))
body = append(body, tmp[:]...)
case string:
body = append(body, 0x02)
body = appendCString(body, k)
var tmp [4]byte
binary.LittleEndian.PutUint32(tmp[:], uint32(len(val)+1))
body = append(body, tmp[:]...)
body = appendCString(body, val)
}
}
out := make([]byte, 4, 4+len(body)+1)
binary.LittleEndian.PutUint32(out, uint32(4+len(body)+1))
out = append(out, body...)
out = append(out, 0)
return out
}
func writeOPMsg(w io.Writer, doc bsonDoc) error {
bson := encodeBSON(doc)
msg := make([]byte, 0, 16+4+1+len(bson))
var hdr [16]byte
binary.LittleEndian.PutUint32(hdr[0:], uint32(16+4+1+len(bson))) // messageLength
binary.LittleEndian.PutUint32(hdr[4:], 1) // requestID
binary.LittleEndian.PutUint32(hdr[8:], 0) // responseTo
binary.LittleEndian.PutUint32(hdr[12:], 2013) // opCode OP_MSG
msg = append(msg, hdr[:]...)
msg = append(msg, 0, 0, 0, 0) // flagBits
msg = append(msg, 0) // section kind 0
msg = append(msg, bson...)
_, err := w.Write(msg)
return err
}
func readOPMsg(r io.Reader) ([]byte, error) {
hdr := make([]byte, 16)
if _, err := io.ReadFull(r, hdr); err != nil {
return nil, err
}
length := int(binary.LittleEndian.Uint32(hdr[0:]))
if length < 21 || length > 8<<20 {
return nil, fmt.Errorf("mongo: bad message length %d", length)
}
body := make([]byte, length-16)
if _, err := io.ReadFull(r, body); err != nil {
return nil, err
}
return body, nil
}
// parseBSONFields extracts the top-level "ok" (double/int) and "errmsg" fields.
func parseBSONFields(b []byte) (float64, string) {
if len(b) < 5 {
return 0, ""
}
// body: flagBits(4) sectionKind(1) [doc] or [kind 1 + size + id + doc]
p := 4
if p < len(b) && b[p] == 1 && p+5 <= len(b) {
p++ // section kind 1
sz := int(binary.LittleEndian.Uint32(b[p:]))
p += 4 + sz // skip identifier
} else if p < len(b) && b[p] == 0 {
p++
}
if p >= len(b) {
return 0, ""
}
docLen := int(int32(binary.LittleEndian.Uint32(b[p:])))
if p+docLen > len(b) {
docLen = len(b) - p
}
doc := b[p : p+docLen]
var ok float64
var errmsg string
i := 4
for i < len(doc)-1 {
typ := doc[i]
i++
start := i
for i < len(doc) && doc[i] != 0 {
i++
}
name := string(doc[start:i])
i++ // skip null
switch typ {
case 0x01: // double
if i+8 <= len(doc) {
bits := binary.LittleEndian.Uint64(doc[i:])
if name == "ok" {
ok = math.Float64frombits(bits)
}
}
i += 8
case 0x10: // int32
if i+4 <= len(doc) {
v := int32(binary.LittleEndian.Uint32(doc[i:]))
if name == "ok" {
ok = float64(v)
}
}
i += 4
case 0x02: // string
if i+4 <= len(doc) {
n := int(int32(binary.LittleEndian.Uint32(doc[i:])))
i += 4
if n > 0 && i+n <= len(doc) {
if name == "errmsg" {
errmsg = string(doc[i : i+n-1])
}
i += n
}
}
case 0x07: // objectId
i += 12
case 0x08: // bool
i++
case 0x03, 0x04: // document/array
if i+4 <= len(doc) {
n := int(int32(binary.LittleEndian.Uint32(doc[i:])))
i += n
}
case 0x09: // datetime
i += 8
case 0x0A: // null
case 0x11: // timestamp
i += 8
default:
// Unknown type: abandon parsing to avoid mis-stepping.
return ok, errmsg
}
}
return ok, errmsg
}
+145
View File
@@ -0,0 +1,145 @@
package probe
import (
"context"
"encoding/binary"
"io"
"net"
"testing"
"time"
"tea.karlcom.de/karl/bucketeer/internal/model"
)
func listenFake(t *testing.T, handle func(net.Conn)) (string, int) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
handle(conn)
conn.Close()
}()
host, port := splitAddr(t, ln.Addr().String())
return host, port
}
func splitAddr(t *testing.T, addr string) (string, int) {
t.Helper()
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
t.Fatal(err)
}
return host, mustAtoi(portStr)
}
func TestProbeRedisReadable(t *testing.T) {
host, port := listenFake(t, func(c net.Conn) {
buf := make([]byte, 6)
_, _ = io.ReadFull(c, buf)
_, _ = c.Write([]byte("+PONG\r\n"))
})
p := New(Config{Workers: 1, Timeout: time.Second})
state, detail, ok := p.probeRedis(context.Background(), host, port)
if !ok || state != model.ExposurePublicRead {
t.Fatalf("got %s (%s) ok=%v, want public_read", state, detail, ok)
}
}
func TestProbeRedisAuthRequired(t *testing.T) {
host, port := listenFake(t, func(c net.Conn) {
buf := make([]byte, 6)
_, _ = io.ReadFull(c, buf)
_, _ = c.Write([]byte("-NOAUTH Authentication required.\r\n"))
})
p := New(Config{Workers: 1, Timeout: time.Second})
state, _, _ := p.probeRedis(context.Background(), host, port)
if state != model.ExposureAuthRequired {
t.Fatalf("got %s, want auth_required", state)
}
}
func TestProbePostgresTrust(t *testing.T) {
host, port := listenFake(t, func(c net.Conn) {
ssl := make([]byte, 8)
if _, err := io.ReadFull(c, ssl); err != nil {
return
}
_, _ = c.Write([]byte{'N'})
var l [4]byte
if _, err := io.ReadFull(c, l[:]); err != nil {
return
}
n := int(binary.BigEndian.Uint32(l[:])) - 4
_, _ = io.ReadFull(c, make([]byte, n))
// AuthenticationOk: 'R', length 8, auth type 0
_, _ = c.Write([]byte{'R', 0, 0, 0, 8, 0, 0, 0, 0})
})
p := New(Config{Workers: 1, Timeout: time.Second})
state, detail, _ := p.probePostgres(context.Background(), host, port)
if state != model.ExposurePublicRead {
t.Fatalf("got %s (%s), want public_read", state, detail)
}
}
func TestProbePostgresPassword(t *testing.T) {
host, port := listenFake(t, func(c net.Conn) {
ssl := make([]byte, 8)
if _, err := io.ReadFull(c, ssl); err != nil {
return
}
_, _ = c.Write([]byte{'N'})
var l [4]byte
if _, err := io.ReadFull(c, l[:]); err != nil {
return
}
n := int(binary.BigEndian.Uint32(l[:])) - 4
_, _ = io.ReadFull(c, make([]byte, n))
// AuthenticationMD5Password: 'R', length 8, auth type 5
_, _ = c.Write([]byte{'R', 0, 0, 0, 8, 0, 0, 0, 5})
})
p := New(Config{Workers: 1, Timeout: time.Second})
state, _, _ := p.probePostgres(context.Background(), host, port)
if state != model.ExposureAuthRequired {
t.Fatalf("got %s, want auth_required", state)
}
}
func TestParseBSONFields(t *testing.T) {
var doc []byte
addDouble := func(name string, v uint64) {
doc = append(doc, 0x01)
doc = append(doc, appendCString(nil, name)...)
var tmp [8]byte
binary.LittleEndian.PutUint64(tmp[:], v)
doc = append(doc, tmp[:]...)
}
addString := func(name, val string) {
doc = append(doc, 0x02)
doc = append(doc, appendCString(nil, name)...)
var tmp [4]byte
binary.LittleEndian.PutUint32(tmp[:], uint32(len(val)+1))
doc = append(doc, tmp[:]...)
doc = append(doc, appendCString(nil, val)...)
}
addDouble("ok", 0x3FF0000000000000) // 1.0
addString("errmsg", "requires authentication")
body := make([]byte, 0, 5+len(doc)+5)
body = append(body, 0, 0, 0, 0, 0) // flags + section kind 0
full := make([]byte, 4, 4+len(doc)+1)
binary.LittleEndian.PutUint32(full, uint32(4+len(doc)+1))
full = append(full, doc...)
full = append(full, 0)
body = append(body, full...)
ok, errmsg := parseBSONFields(body)
if ok != 1 || errmsg != "requires authentication" {
t.Fatalf("got ok=%v errmsg=%q", ok, errmsg)
}
}
+157 -2
View File
@@ -49,6 +49,12 @@ func (s *Store) Close() { s.pool.Close() }
func (s *Store) Pool() *pgxpool.Pool { return s.pool }
// PoolStats reports pgx pool utilization for logging/metrics.
func (s *Store) PoolStats() (total, idle, acquired int32) {
st := s.pool.Stat()
return st.TotalConns(), st.IdleConns(), st.AcquiredConns()
}
// Migrate applies the embedded schema idempotently.
func (s *Store) Migrate(ctx context.Context) error {
for _, name := range migrations.SQL() {
@@ -137,6 +143,129 @@ func nonNil(s []string) []string {
return s
}
// IndexEntry is one indexed resource item for the UI.
type IndexEntry struct {
ID int64 `json:"id"`
AssetID int64 `json:"asset_id"`
Provider string `json:"provider"`
Service string `json:"service"`
ProviderHost string `json:"provider_hostname"`
Kind string `json:"kind"`
Name string `json:"name"`
Size int64 `json:"size"`
Updated *time.Time `json:"updated"`
ContentType string `json:"content_type"`
IndexedAt time.Time `json:"indexed_at"`
}
// ReplaceEntries replaces the stored index for a readable asset. Returns the
// number of entries stored; a no-op if the asset row is unknown or entries is
// empty.
func (s *Store) ReplaceEntries(ctx context.Context, provider, service, host string, entries []model.Entry) (int, error) {
if len(entries) == 0 {
return 0, nil
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return 0, err
}
defer tx.Rollback(ctx)
var assetID int64
err = tx.QueryRow(ctx,
`SELECT id FROM cloud_assets WHERE provider = $1 AND service = $2 AND provider_hostname = $3`,
provider, service, host).Scan(&assetID)
if err == pgx.ErrNoRows {
return 0, nil
}
if err != nil {
return 0, err
}
if _, err := tx.Exec(ctx, `DELETE FROM asset_entries WHERE asset_id = $1`, assetID); err != nil {
return 0, err
}
b := &pgx.Batch{}
for i := range entries {
e := &entries[i]
var updated any
if !e.Updated.IsZero() {
updated = e.Updated
}
b.Queue(`
INSERT INTO asset_entries (asset_id, kind, name, size, updated, content_type, etag)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (asset_id, kind, name) DO UPDATE
SET size = EXCLUDED.size, updated = EXCLUDED.updated,
content_type = EXCLUDED.content_type, etag = EXCLUDED.etag, indexed_at = now()`,
assetID, e.Kind, e.Name, e.Size, updated, e.ContentType, e.ETag)
}
br := tx.SendBatch(ctx, b)
for range entries {
if _, err := br.Exec(); err != nil {
br.Close()
return 0, err
}
}
if err := br.Close(); err != nil {
return 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, err
}
return len(entries), nil
}
// SearchEntries searches the index across all readable assets, newest first.
func (s *Store) SearchEntries(ctx context.Context, query string, limit, offset int) ([]IndexEntry, int, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.pool.Query(ctx, `
SELECT e.id, e.asset_id, a.provider, a.service, a.provider_hostname,
e.kind, e.name, e.size, e.updated, e.content_type, e.indexed_at
FROM asset_entries e JOIN cloud_assets a ON a.id = e.asset_id
WHERE ($1 = '' OR e.name ILIKE '%' || $1 || '%' OR a.provider_hostname ILIKE '%' || $1 || '%')
ORDER BY e.indexed_at DESC, e.id DESC
LIMIT $2 OFFSET $3`, query, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []IndexEntry{}
for rows.Next() {
var e IndexEntry
if err := rows.Scan(&e.ID, &e.AssetID, &e.Provider, &e.Service, &e.ProviderHost,
&e.Kind, &e.Name, &e.Size, &e.Updated, &e.ContentType, &e.IndexedAt); err != nil {
return nil, 0, err
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
var total int
if err := s.pool.QueryRow(ctx, `
SELECT count(*) FROM asset_entries e JOIN cloud_assets a ON a.id = e.asset_id
WHERE ($1 = '' OR e.name ILIKE '%' || $1 || '%' OR a.provider_hostname ILIKE '%' || $1 || '%')`,
query).Scan(&total); err != nil {
return nil, 0, err
}
return out, total, nil
}
// CountAssets returns the number of assets matching the filter (for pagination).
func (s *Store) CountAssets(ctx context.Context, f AssetFilter) (int, error) {
var n int
err := s.pool.QueryRow(ctx, `
SELECT count(*) FROM cloud_assets
WHERE ($1 = '' OR provider = $1)
AND ($2 = '' OR service = $2)
AND ($3 = '' OR exposure_state = $3)
AND ($4 = '' OR provider_hostname ILIKE '%' || $4 || '%' OR hostname ILIKE '%' || $4 || '%' OR resource_identifier ILIKE '%' || $4 || '%')`,
f.Provider, f.Service, f.Exposure, f.Query).Scan(&n)
return n, err
}
// UpdateExposure records the outcome of an optional reachability probe.
func (s *Store) UpdateExposure(ctx context.Context, provider, service, providerHostname string, state model.ExposureState, detail string) error {
_, err := s.pool.Exec(ctx, `
@@ -170,10 +299,27 @@ type AssetFilter struct {
Service string
Exposure string
Query string
Sort string
Limit int
Offset int
}
// orderClause maps a UI sort key to a fixed ORDER BY, preventing injection.
func orderClause(sort string) string {
switch sort {
case "first_seen":
return "first_seen DESC"
case "provider":
return "provider ASC, service ASC, provider_hostname ASC"
case "hostname":
return "provider_hostname ASC"
case "exposure":
return "CASE exposure_state WHEN 'public_read' THEN 0 WHEN 'reachable' THEN 1 WHEN 'auth_required' THEN 2 WHEN 'not_found' THEN 3 WHEN 'private' THEN 4 ELSE 5 END, last_seen DESC"
default:
return "last_seen DESC"
}
}
// ListAssets returns assets matching the filter, newest first.
func (s *Store) ListAssets(ctx context.Context, f AssetFilter) ([]Asset, error) {
if f.Limit <= 0 || f.Limit > 500 {
@@ -187,7 +333,7 @@ func (s *Store) ListAssets(ctx context.Context, f AssetFilter) ([]Asset, error)
AND ($2 = '' OR service = $2)
AND ($3 = '' OR exposure_state = $3)
AND ($4 = '' OR provider_hostname ILIKE '%' || $4 || '%' OR hostname ILIKE '%' || $4 || '%' OR resource_identifier ILIKE '%' || $4 || '%')
ORDER BY last_seen DESC
ORDER BY `+orderClause(f.Sort)+`
LIMIT $5 OFFSET $6`,
f.Provider, f.Service, f.Exposure, f.Query, f.Limit, f.Offset)
if err != nil {
@@ -217,6 +363,8 @@ type Count struct {
type Stats struct {
Total int64 `json:"total"`
Readable int64 `json:"readable"`
Reachable int64 `json:"reachable"`
Entries int64 `json:"entries"`
ByProvider []Count `json:"by_provider"`
ByService []Count `json:"by_service"`
ByExposure []Count `json:"by_exposure"`
@@ -224,7 +372,14 @@ type Stats struct {
func (s *Store) Stats(ctx context.Context) (Stats, error) {
var st Stats
if err := s.pool.QueryRow(ctx, `SELECT count(*), count(*) FILTER (WHERE exposure_state = 'public_read') FROM cloud_assets`).Scan(&st.Total, &st.Readable); err != nil {
if err := s.pool.QueryRow(ctx, `
SELECT count(*),
count(*) FILTER (WHERE exposure_state = 'public_read'),
count(*) FILTER (WHERE exposure_state = 'reachable')
FROM cloud_assets`).Scan(&st.Total, &st.Readable, &st.Reachable); err != nil {
return st, err
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM asset_entries`).Scan(&st.Entries); err != nil {
return st, err
}
group := func(column string) ([]Count, error) {
+14
View File
@@ -66,7 +66,21 @@ func TestIntegration(t *testing.T) {
if _, err := s.ListAssets(ctx, AssetFilter{Provider: "aws", Limit: 10}); err != nil {
t.Fatal(err)
}
if _, err := s.CountAssets(ctx, AssetFilter{Provider: "aws"}); err != nil {
t.Fatal(err)
}
if _, err := s.Stats(ctx); err != nil {
t.Fatal(err)
}
n, err := s.ReplaceEntries(ctx, model.ProviderAWS.String(), model.ServiceS3.String(), f.Hostname, []model.Entry{
{Kind: "object", Name: "it/index/a.txt", Size: 10},
{Kind: "prefix", Name: "it/index/"},
})
if err != nil || n != 2 {
t.Fatalf("ReplaceEntries = %d, %v", n, err)
}
entries, total, err := s.SearchEntries(ctx, "index/a", 10, 0)
if err != nil || total != 1 || len(entries) != 1 || entries[0].ProviderHost != f.Hostname {
t.Fatalf("SearchEntries = %+v total=%d err=%v", entries, total, err)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+381 -94
View File
@@ -3,121 +3,408 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ctcloud · cloud assets</title>
<title>Bucketeer · CT resource monitor</title>
<link rel="icon" href="/assets/favicon.png">
<script>/* set theme before first paint to avoid a flash */
try{var t=localStorage.getItem('bucketeer-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);}catch(e){}</script>
<style>
:root { --bg:#0f1115; --panel:#171a21; --line:#262b36; --fg:#e7e9ee; --mut:#9aa4b2; --accent:#5b9dff; }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; background:var(--bg); color:var(--fg); }
header { padding:16px 20px; border-bottom:1px solid var(--line); display:flex; align-items:baseline; gap:12px; flex-wrap:wrap; }
h1 { font-size:16px; margin:0; letter-spacing:.5px; }
header .mut { color:var(--mut); }
main { padding:20px; max-width:1400px; margin:0 auto; }
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:12px; margin-bottom:20px; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:12px 14px; }
.card .n { font-size:22px; font-weight:600; }
.card .l { color:var(--mut); font-size:12px; text-transform:uppercase; letter-spacing:.5px; }
.filters { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:14px; }
input,select,button { background:var(--panel); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:7px 10px; font:inherit; }
input { min-width:220px; }
button { cursor:pointer; }
button:hover { border-color:var(--accent); }
table { width:100%; border-collapse:collapse; font-size:13px; }
th,td { text-align:left; padding:7px 10px; border-bottom:1px solid var(--line); vertical-align:top; }
th { color:var(--mut); font-weight:500; position:sticky; top:0; background:var(--bg); }
tr:hover td { background:#141821; }
.pill { display:inline-block; padding:1px 7px; border-radius:999px; font-size:11px; border:1px solid var(--line); }
.public_read { color:#0b1; border-color:#0b1; }
.auth_required { color:#fd7; border-color:#fd7; }
.not_found, .private { color:#f77; border-color:#f77; }
.reachable { color:var(--accent); border-color:var(--accent); }
.unknown { color:var(--mut); }
.detail { color:var(--mut); font-size:11px; }
a { color:var(--accent); text-decoration:none; }
footer { color:var(--mut); padding:16px 20px; }
/* Tokens from DESIGN.md. Neutral-first, no shadows, system fonts, light only. */
:root {
--primary:#262626; --on-primary:#fff; --primary-hover:#404040; --primary-active:#171717;
--text-primary:#000; --text-secondary:#525252; --text-muted:#737373;
--canvas:#fff; --surface-subtle:#fafafa; --surface-control:#f2f2f2; --surface-hover:#ededed;
--border:#e5e5e5; --border-strong:#d4d4d4; --focus:#2563eb;
--cap-bg:#eef2ff; --cap-text:#4338ca; --size-bg:#e0f2fe; --size-text:#075985;
--err-bg:#fef2f2; --err-text:#b91c1c; --ok-bg:#f0fdf4; --ok-text:#166534;
--font: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--page-gutter:16px;
color-scheme: light dark;
}
/* Dark palette (DESIGN.md specifies light only; this is an explicit product
decision). Neutral-first, same hierarchy, contrast-preserving. */
[data-theme=dark]{
--primary:#e7e9ee; --on-primary:#14161a; --primary-hover:#d3d7de; --primary-active:#ffffff;
--text-primary:#f4f5f7; --text-secondary:#b3b9c4; --text-muted:#858d9a;
--canvas:#0f1115; --surface-subtle:#171a20; --surface-control:#1c2027; --surface-hover:#252b35;
--border:#2a2f39; --border-strong:#3a414e; --focus:#69a8ff;
--cap-bg:#1e2440; --cap-text:#aab5ff; --size-bg:#0e2a3a; --size-text:#7cc4ff;
--err-bg:#3a1616; --err-text:#ffb4b4; --ok-bg:#0f2a1a; --ok-text:#7ee2a8;
}
*,*::before,*::after{box-sizing:border-box}
body{margin:0;background:var(--canvas);color:var(--text-primary);font:400 16px/24px var(--font);-webkit-font-smoothing:antialiased}
h1{margin:0;font:500 24px/32px var(--font);letter-spacing:-.01em}
a{color:inherit}
.site-shell,.search-layout{width:calc(100% - 2*var(--page-gutter));margin-inline:auto}
.site-shell{max-width:1280px}
.search-layout{max-width:768px}
@media (min-width:640px){:root{--page-gutter:24px}}
@media (min-width:1024px){:root{--page-gutter:32px}}
header{border-bottom:1px solid var(--border)}
.header{height:72px;display:flex;align-items:center;gap:24px}
.brand{display:flex;align-items:center;gap:8px;font:500 18px/28px var(--font);text-decoration:none}
.brand-logo{width:33px;height:30px;display:block;
background:url('/assets/logo-light.webp') center/contain no-repeat}
[data-theme=dark] .brand-logo{background-image:url('/assets/logo-dark.webp')}
.theme-btn{width:40px;height:40px;display:grid;place-items:center;background:transparent;border:1px solid var(--border);
border-radius:9999px;color:var(--text-secondary);cursor:pointer;padding:0}
.theme-btn:hover{background:var(--surface-hover);color:var(--text-primary)}
.theme-btn svg{width:18px;height:18px;display:block}
nav{display:flex;gap:16px;font:400 18px/28px var(--font)}
nav a{text-decoration:none;color:var(--text-secondary)}
nav a[aria-current=page]{color:var(--text-primary);text-decoration:underline;text-underline-offset:6px}
.header-right{margin-left:auto;display:flex;align-items:center;gap:12px}
.live{display:inline-flex;align-items:center;gap:6px;font:400 14px/20px var(--font);color:var(--text-muted)}
.dot{width:8px;height:8px;border-radius:9999px;background:var(--ok-text)}
.dot.paused{background:var(--text-muted)}
main{padding:48px 0 80px}
.query{position:relative;display:flex;align-items:center}
.query input{width:100%;height:48px;border:1px solid transparent;border-radius:9999px;background:var(--surface-control);
color:var(--text-primary);font:400 16px/24px var(--font);padding:0 48px 0 20px}
.query input::placeholder{color:var(--text-secondary)}
.query input:focus-visible{outline:2px solid var(--focus);outline-offset:3px;background:var(--canvas)}
.query .clear{position:absolute;right:4px;border:0;background:transparent;color:var(--text-secondary);
width:40px;height:40px;border-radius:9999px;cursor:pointer;font-size:18px;display:none}
.query .clear.show{display:block}
.query .clear:hover{background:var(--surface-hover);color:var(--text-primary)}
.controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-top:16px}
.filters{display:flex;gap:8px;flex-wrap:wrap}
.chip{height:32px;padding:0 12px;border:1px solid var(--border-strong);border-radius:9999px;
background:var(--surface-control);color:var(--text-secondary);font:400 14px/20px var(--font);
cursor:pointer;display:inline-flex;align-items:center;gap:6px}
.chip:hover{background:var(--surface-hover);color:var(--text-primary)}
.chip[aria-pressed=true]{background:var(--primary);border-color:var(--primary);color:var(--on-primary)}
.sort{margin-left:auto;display:flex;align-items:center;gap:8px;color:var(--text-secondary);font:400 14px/20px var(--font)}
select{height:40px;border:1px solid var(--border-strong);border-radius:9999px;background:var(--canvas);
color:var(--text-primary);font:400 14px/20px var(--font);padding:0 12px}
.summary{display:flex;gap:16px;flex-wrap:wrap;margin-top:24px;color:var(--text-muted);font:400 14px/20px var(--font)}
.summary strong{color:var(--text-primary);font-weight:500}
ul.result-list{list-style:none;margin:0;padding:0}
/* Native virtualization: off-screen rows are not laid out or painted. */
.result-row{padding-block:24px;border-bottom:1px solid var(--border);
content-visibility:auto;contain-intrinsic-size:auto 128px}
.result-head{display:flex;align-items:flex-start;gap:12px;min-width:0}
.result-title{font:500 20px/28px var(--font);color:var(--text-primary);text-decoration:none;overflow-wrap:anywhere}
.result-title:hover{text-decoration:underline}
.result-description{margin:4px 0 0;color:var(--text-secondary);font:400 16px/24px var(--font);overflow-wrap:anywhere}
.badge-group{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
.result-meta{display:flex;gap:8px 16px;flex-wrap:wrap;margin-top:12px;color:var(--text-muted);font:400 14px/20px var(--font)}
.badge{font:500 12px/16px var(--font);padding:2px 6px;border-radius:4px;background:var(--surface-control);color:var(--text-secondary);white-space:nowrap}
.badge-cap{background:var(--cap-bg);color:var(--cap-text)}
.badge-size{background:var(--size-bg);color:var(--size-text)}
.badge-ok{background:var(--ok-bg);color:var(--ok-text)}
.badge-err{background:var(--err-bg);color:var(--err-text)}
.mono{font-family:var(--mono);font-size:14px}
.empty{padding:32px 0;color:var(--text-secondary)}
.error{padding:12px;border-radius:8px;background:var(--err-bg);color:var(--err-text);font:400 14px/20px var(--font)}
.skeleton{height:16px;border-radius:6px;background:var(--surface-control);margin:12px 0}
.skeleton.w60{width:60%}.skeleton.w40{width:40%}
.more{display:flex;justify-content:center;margin-top:24px}
.more button{min-height:40px;padding:10px 20px;border-radius:9999px;border:1px solid var(--border-strong);
background:var(--canvas);color:var(--text-primary);font:500 14px/20px var(--font);cursor:pointer}
.more button:hover{background:var(--surface-hover)}
.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
footer{color:var(--text-muted);font:400 14px/20px var(--font);padding:0 0 64px}
footer p{max-width:768px;margin-inline:auto}
:where(a,button,input,select,[tabindex]):focus-visible{outline:2px solid var(--focus);outline-offset:3px}
@media (pointer:coarse){.chip,.clear,.more button{min-height:44px}.chip{min-width:44px}}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
</style>
</head>
<body>
<header>
<h1>ctcloud</h1>
<span class="mut">cloud/database resources discovered in Certificate Transparency</span>
<span style="margin-left:auto" class="mut" id="updated">—</span>
<div class="site-shell header">
<a class="brand" href="/"><span class="brand-logo" role="img" aria-label="Bucketeer"></span>Bucketeer</a>
<nav aria-label="Primary">
<a href="?view=assets" id="nav-assets" aria-current="page">Assets</a>
<a href="?view=contents" id="nav-contents">Contents</a>
<a href="/metrics">Metrics</a>
<a href="/healthz">Health</a>
</nav>
<div class="header-right">
<span class="live" id="live"><span class="dot" id="livedot"></span><span id="livetext">live · 15s</span></span>
<button class="theme-btn" id="themebtn" type="button" aria-label="Switch to dark mode"></button>
</div>
</div>
</header>
<main>
<div class="cards" id="cards"></div>
<div class="filters">
<input id="q" placeholder="search hostname / resource">
<select id="provider" data-all="all providers"><option value="">all providers</option></select>
<select id="service" data-all="all services"><option value="">all services</option></select>
<select id="exposure" data-all="all exposure"><option value="">all exposure</option></select>
<button id="refresh">refresh</button>
<div class="search-layout">
<h1 class="visually-hidden">Cloud resources discovered in Certificate Transparency</h1>
<div class="query">
<label class="visually-hidden" for="q">Search</label>
<input id="q" type="search" autocomplete="off" spellcheck="false" placeholder="Search hostname, bucket, database…">
<button class="clear" id="clear" type="button" aria-label="Clear search">×</button>
</div>
<div class="controls" id="asset-controls">
<div class="filters" id="filters" role="group" aria-label="Filter by exposure">
<button class="chip" data-exposure="" aria-pressed="true">All</button>
<button class="chip" data-exposure="public_read" aria-pressed="false">Public read</button>
<button class="chip" data-exposure="reachable" aria-pressed="false">Reachable</button>
<button class="chip" data-exposure="auth_required" aria-pressed="false">Auth required</button>
<button class="chip" data-exposure="not_found" aria-pressed="false">Not found</button>
<button class="chip" data-exposure="private" aria-pressed="false">Private</button>
</div>
<div class="sort">
<label for="sort">Sort</label>
<select id="sort">
<option value="exposure">Exposure</option>
<option value="last_seen">Recently seen</option>
<option value="first_seen">Newest</option>
<option value="provider">Provider</option>
<option value="hostname">Hostname</option>
</select>
</div>
</div>
<div class="summary" id="summary" aria-live="polite"></div>
<div id="state"></div>
<ul class="result-list" id="results"></ul>
<div class="more" id="more" hidden><button id="morebtn" type="button">Load more</button></div>
</div>
<table>
<thead><tr>
<th>provider</th><th>service</th><th>resource</th><th>region</th>
<th>hostname</th><th>hostname (CT)</th><th>exposure</th><th>detail</th><th>last seen</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
</main>
<footer>
Detected endpoints are not automatically publicly accessible.
<span class="public_read">public_read</span> means an anonymous read succeeded at probe time.
<span class="reachable">reachable</span> on a database means the port was open; no authentication was attempted.
<p>
A detected endpoint is not automatically accessible. <span class="badge badge-ok">public_read</span>
means an anonymous read succeeded at probe time and readable resources are indexed below (keys and
sizes only, never contents). Databases are only reported <span class="badge badge-size">reachable</span>
unless an unauthenticated protocol read was explicitly enabled; no credentials are ever sent.
</p>
</footer>
<script>
const PAGE = 100, REFRESH_MS = 15000;
const $ = (id) => document.getElementById(id);
const fill = (sel, values) => {
const cur = sel.value;
sel.innerHTML = '<option value="">' + sel.dataset.all + '</option>' +
values.map(v => `<option value="${v}">${v}</option>`).join('');
sel.value = cur;
const params = new URLSearchParams(location.search);
const state = {
view: params.get('view') === 'contents' ? 'contents' : 'assets',
q: params.get('q') || '',
exposure: params.get('exposure') || '',
sort: params.get('sort') || 'exposure',
assets: [], assetsTotal: 0, assetsOffset: 0,
entries: [], entriesTotal: 0, entriesOffset: 0,
loading: false, abort: null, timer: null, countdown: REFRESH_MS / 1000, ok: true,
};
function pill(v) { return `<span class="pill ${v}">${v || 'unknown'}</span>`; }
$('q').value = state.q;
$('sort').value = state.sort;
$('clear').classList.toggle('show', state.q.length > 0);
document.querySelectorAll('#filters .chip').forEach((c) =>
c.setAttribute('aria-pressed', String(c.dataset.exposure === state.exposure)));
const SUN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/></svg>';
const MOON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
const currentTheme = () => document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
function setTheme(t, persist) {
document.documentElement.setAttribute('data-theme', t);
if (persist) { try { localStorage.setItem('bucketeer-theme', t); } catch (e) {} }
$('themebtn').innerHTML = t === 'dark' ? SUN : MOON;
$('themebtn').setAttribute('aria-label', t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
}
setTheme(currentTheme(), false);
$('themebtn').onclick = () => setTheme(currentTheme() === 'dark' ? 'light' : 'dark', true);
if (window.matchMedia) matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
try { if (!localStorage.getItem('bucketeer-theme')) setTheme(e.matches ? 'dark' : 'light', false); } catch (_) {}
});
const esc = (s) => (s ?? '').toString().replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const key = (a) => a.provider + '|' + a.service + '|' + a.provider_hostname;
const badge = (v, cls) => `<span class="badge ${cls || ''}">${esc(v)}</span>`;
function exposureBadge(v) {
const cls = v === 'public_read' ? 'badge-ok' : v === 'reachable' ? 'badge-size'
: (v === 'not_found' || v === 'private') ? 'badge-err' : '';
return badge(v || 'unknown', cls);
}
function ago(ts) {
if (!ts) return '—';
if (!ts) return null;
const s = Math.max(0, (Date.now() - new Date(ts)) / 1000);
if (s < 60) return Math.floor(s) + 's ago';
if (s < 3600) return Math.floor(s/60) + 'm ago';
if (s < 86400) return Math.floor(s/3600) + 'h ago';
return Math.floor(s/86400) + 'd ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
async function loadStats() {
const r = await fetch('api/stats'); if (!r.ok) return;
const s = await r.json();
$('cards').innerHTML = [
['total assets', s.total], ['public read', s.readable],
['providers', (s.by_provider||[]).length], ['services', (s.by_service||[]).length]
].map(([l,n]) => `<div class="card"><div class="n">${n??0}</div><div class="l">${l}</div></div>`).join('');
if (!$('provider').dataset.filled) {
fill($('provider'), (s.by_provider||[]).map(x=>x.label));
fill($('service'), (s.by_service||[]).map(x=>x.label));
fill($('exposure'), (s.by_exposure||[]).map(x=>x.label));
['provider','service','exposure'].forEach(id => $(id).dataset.filled = '1');
function bytes(n) {
if (!n) return '';
const u = ['B','KB','MB','GB','TB']; let i = 0, v = n;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
return (i === 0 ? v : v.toFixed(1)) + ' ' + u[i];
}
function timeEl(ts, label) {
const a = ago(ts);
return a ? `<span>${label} <time datetime="${esc(ts)}">${a}</time></span>` : '';
}
function assetRow(a) {
const meta = [];
if (a.region) meta.push(`<span>${esc(a.region)}</span>`);
if (a.hostname && a.hostname !== a.provider_hostname) meta.push(`<span>CT host <span class="mono">${esc(a.hostname)}</span></span>`);
meta.push(timeEl(a.last_seen, 'seen'));
meta.push(timeEl(a.probed_at, 'probed'));
return `<li class="result-row">
<div class="result-head">
<a class="result-title" href="https://${esc(a.provider_hostname)}/" target="_blank" rel="noopener noreferrer nofollow">${esc(a.provider_hostname)}</a>
${exposureBadge(a.exposure_state)}
</div>
${a.exposure_detail ? `<p class="result-description">${esc(a.exposure_detail)}</p>` : ''}
<div class="badge-group">
${badge(a.provider, 'badge-cap')}${badge(a.service, 'badge-size')}
${a.resource_identifier ? badge(a.resource_identifier) : ''}
${a.public_semantic && a.public_semantic !== 'none' ? badge(a.public_semantic) : ''}
</div>
<div class="result-meta">${meta.filter(Boolean).join('')}</div>
</li>`;
}
function entryRow(e) {
const meta = [timeEl(e.indexed_at, 'indexed')];
if (e.size) meta.push(`<span>${bytes(e.size)}</span>`);
if (e.content_type) meta.push(`<span>${esc(e.content_type)}</span>`);
return `<li class="result-row">
<div class="result-head">
<span class="result-title mono">${esc(e.name)}</span>
${badge(e.kind)}
</div>
<div class="badge-group">
${badge(e.provider, 'badge-cap')}${badge(e.service, 'badge-size')}
<span class="badge mono">${esc(e.provider_hostname)}</span>
</div>
<div class="result-meta">${meta.join('')}</div>
</li>`;
}
function render() {
const ul = $('results');
if (state.view === 'assets') {
ul.innerHTML = state.assets.map(assetRow).join('');
if (!state.assets.length) showEmpty('No assets match.');
} else {
ul.innerHTML = state.entries.map(entryRow).join('');
if (!state.entries.length) showEmpty('No indexed contents match. Only resources confirmed publicly readable are indexed.');
}
const loaded = state.view === 'assets' ? state.assets.length : state.entries.length;
const total = state.view === 'assets' ? state.assetsTotal : state.entriesTotal;
$('more').hidden = loaded >= total;
$('morebtn').textContent = `Load more (${loaded} of ${total})`;
}
function showEmpty(msg) {
$('state').innerHTML = `<div class="empty">${esc(msg)}${state.q ? '' : ''}</div>`;
}
async function fetchJSON(url, signal) {
const r = await fetch(url, { signal });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
async function loadAssets(reset) {
if (reset) { state.assets = []; state.assetsOffset = 0; }
const p = new URLSearchParams({ q: state.q, exposure: state.exposure, sort: state.sort,
limit: String(PAGE), offset: String(state.assetsOffset) });
const data = await fetchJSON('api/assets?' + p);
mergeAssets(data.assets || []);
state.assetsTotal = data.total || 0;
state.assetsOffset = state.assets.length;
}
function mergeAssets(rows) {
const seen = new Set(state.assets.map(key));
for (const a of rows) { const k = key(a); if (!seen.has(k)) { seen.add(k); state.assets.push(a); } }
if (state.sort === 'hostname') state.assets.sort((a, b) => a.provider_hostname.localeCompare(b.provider_hostname));
}
async function loadEntries(reset) {
if (reset) { state.entries = []; state.entriesOffset = 0; }
const p = new URLSearchParams({ q: state.q, limit: String(PAGE), offset: String(state.entriesOffset) });
const data = await fetchJSON('api/entries?' + p);
const seen = new Set(state.entries.map((e) => e.id));
for (const e of (data.entries || [])) if (!seen.has(e.id)) state.entries.push(e);
state.entriesTotal = data.total || 0;
state.entriesOffset = state.entries.length;
}
async function reload(showSkeleton) {
if (state.abort) state.abort.abort();
const ac = new AbortController();
state.abort = ac;
if (showSkeleton) $('state').innerHTML = '<div class="skeleton w60"></div><div class="skeleton w40"></div><div class="skeleton w60"></div>';
try {
if (state.view === 'assets') await loadAssets(true); else await loadEntries(true);
$('state').innerHTML = '';
render();
await loadStats();
setLive(true);
} catch (e) {
if (e.name === 'AbortError') return;
$('state').innerHTML = `<div class="error">Could not load data (${esc(e.message)}). Retrying in 15s.</div>`;
setLive(false);
}
}
async function loadAssets() {
const p = new URLSearchParams({
q: $('q').value, provider: $('provider').value,
service: $('service').value, exposure: $('exposure').value, limit: 200
});
const r = await fetch('api/assets?' + p); if (!r.ok) return;
const rows = await r.json() || [];
$('rows').innerHTML = rows.length ? rows.map(a => `<tr>
<td>${a.provider}</td><td>${a.service}</td>
<td>${a.resource_identifier||''}</td><td>${a.region||''}</td>
<td>${a.provider_hostname}</td><td>${a.hostname}</td>
<td>${pill(a.exposure_state)}</td>
<td class="detail">${a.exposure_detail||''}</td>
<td class="detail">${ago(a.last_seen)}</td>
</tr>`).join('') : '<tr><td colspan="9" class="detail">no assets yet</td></tr>';
$('updated').textContent = 'updated ' + new Date().toLocaleTimeString();
async function loadStats() {
const s = await fetchJSON('api/stats');
const readable = (s.by_exposure || []).find((x) => x.label === 'public_read');
$('summary').innerHTML =
`<span><strong>${s.total ?? 0}</strong> assets</span>` +
`<span><strong>${readable ? readable.count : 0}</strong> publicly readable</span>` +
`<span><strong>${s.reachable ?? 0}</strong> reachable endpoints</span>` +
`<span><strong>${s.entries ?? 0}</strong> indexed items</span>`;
}
async function refresh() { await Promise.all([loadStats(), loadAssets()]); }
$('refresh').onclick = refresh;
$('q').addEventListener('keydown', e => { if (e.key === 'Enter') loadAssets(); setTimeout(loadAssets, 0); });
['provider','service','exposure'].forEach(id => $(id).addEventListener('change', loadAssets));
refresh();
setInterval(refresh, 15000);
function setLive(ok) {
state.ok = ok;
$('livedot').classList.toggle('paused', !ok);
$('livetext').textContent = (ok ? 'live' : 'offline') + ' · ' + state.countdown + 's';
}
function syncURL() {
const p = new URLSearchParams();
p.set('view', state.view);
if (state.q) p.set('q', state.q);
if (state.view === 'assets' && state.exposure) p.set('exposure', state.exposure);
if (state.view === 'assets' && state.sort !== 'exposure') p.set('sort', state.sort);
history.replaceState(null, '', '?' + p);
$('nav-assets').setAttribute('aria-current', state.view === 'assets' ? 'page' : 'false');
$('nav-contents').setAttribute('aria-current', state.view === 'contents' ? 'page' : 'false');
$('asset-controls').hidden = state.view !== 'assets';
$('q').placeholder = state.view === 'assets' ? 'Search hostname, bucket, database…' : 'Search indexed contents…';
}
function setView(v) { state.view = v; syncURL(); reload(true); }
$('nav-assets').onclick = (e) => { e.preventDefault(); setView('assets'); };
$('nav-contents').onclick = (e) => { e.preventDefault(); setView('contents'); };
$('morebtn').onclick = async () => {
if (state.loading) return;
state.loading = true; $('morebtn').disabled = true;
try { if (state.view === 'assets') await loadAssets(false); else await loadEntries(false); render(); }
finally { state.loading = false; $('morebtn').disabled = false; }
};
let debounce;
$('q').addEventListener('input', () => {
state.q = $('q').value.trim();
$('clear').classList.toggle('show', state.q.length > 0);
syncURL();
clearTimeout(debounce);
debounce = setTimeout(() => reload(false), 250);
});
$('clear').onclick = () => { $('q').value = ''; state.q = ''; $('clear').classList.remove('show'); syncURL(); reload(true); $('q').focus(); };
document.querySelectorAll('#filters .chip').forEach((c) => c.onclick = () => {
state.exposure = c.dataset.exposure;
document.querySelectorAll('#filters .chip').forEach((x) => x.setAttribute('aria-pressed', String(x.dataset.exposure === state.exposure)));
syncURL(); reload(true);
});
$('sort').onchange = () => { state.sort = $('sort').value; syncURL(); reload(true); };
document.addEventListener('visibilitychange', () => { if (!document.hidden) reload(false); });
function schedule() {
clearInterval(state.timer);
state.timer = setInterval(() => {
if (document.hidden) return;
state.countdown--;
if (state.countdown <= 0) { state.countdown = REFRESH_MS / 1000; reload(false); }
$('livetext').textContent = (state.ok ? 'live' : 'offline') + ' · ' + state.countdown + 's';
}, 1000);
}
syncURL();
reload(true);
schedule();
</script>
</body>
</html>
+43 -7
View File
@@ -1,12 +1,13 @@
// Package webui serves a small read-only web UI over the discovered cloud
// assets. It is intentionally dependency-free (stdlib + embedded HTML) and
// Package webui serves a small read-only web UI over discovered cloud assets
// and their content index. It is dependency-free (stdlib + embedded HTML) and
// exposes no mutation endpoints.
package webui
import (
"context"
_ "embed"
"embed"
"encoding/json"
"io/fs"
"net/http"
"strconv"
"strings"
@@ -17,9 +18,14 @@ import (
//go:embed index.html
var indexHTML []byte
//go:embed assets
var assetsFS embed.FS
// Store is the read-only subset of the database the UI needs.
type Store interface {
ListAssets(ctx context.Context, f store.AssetFilter) ([]store.Asset, error)
CountAssets(ctx context.Context, f store.AssetFilter) (int, error)
SearchEntries(ctx context.Context, query string, limit, offset int) ([]store.IndexEntry, int, error)
Stats(ctx context.Context) (store.Stats, error)
}
@@ -31,6 +37,9 @@ type Config struct {
// New returns the UI handler. If Config.User is set, all routes require basic auth.
func New(s Store, cfg Config) http.Handler {
mux := http.NewServeMux()
assets, _ := fs.Sub(assetsFS, "assets")
fileServer := http.FileServer(http.FS(assets))
mux.Handle("/assets/", http.StripPrefix("/assets/", cacheForever(fileServer)))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
@@ -43,19 +52,37 @@ func New(s Store, cfg Config) http.Handler {
q := r.URL.Query()
limit, _ := strconv.Atoi(q.Get("limit"))
offset, _ := strconv.Atoi(q.Get("offset"))
assets, err := s.ListAssets(r.Context(), store.AssetFilter{
f := store.AssetFilter{
Provider: strings.TrimSpace(q.Get("provider")),
Service: strings.TrimSpace(q.Get("service")),
Exposure: strings.TrimSpace(q.Get("exposure")),
Query: strings.TrimSpace(q.Get("q")),
Sort: strings.TrimSpace(q.Get("sort")),
Limit: limit,
Offset: offset,
})
}
assets, err := s.ListAssets(r.Context(), f)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, assets)
total, err := s.CountAssets(r.Context(), f)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"assets": assets, "total": total, "limit": limit, "offset": offset})
})
mux.HandleFunc("/api/entries", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit, _ := strconv.Atoi(q.Get("limit"))
offset, _ := strconv.Atoi(q.Get("offset"))
entries, total, err := s.SearchEntries(r.Context(), strings.TrimSpace(q.Get("q")), limit, offset)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"entries": entries, "total": total, "limit": limit, "offset": offset})
})
mux.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) {
st, err := s.Stats(r.Context())
@@ -72,6 +99,15 @@ func New(s Store, cfg Config) http.Handler {
return mux
}
// cacheForever marks embedded, content-addressed-by-name assets as long-lived,
// since they only change with a new binary.
func cacheForever(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=86400")
next.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(v)
@@ -81,7 +117,7 @@ func basicAuth(user, pass string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || u != user || p != pass {
w.Header().Set("WWW-Authenticate", `Basic realm="ctcloud"`)
w.Header().Set("WWW-Authenticate", `Basic realm="bucketeer"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
+32 -5
View File
@@ -19,8 +19,16 @@ func (f *fakeStore) ListAssets(ctx context.Context, fl store.AssetFilter) ([]sto
return []store.Asset{{ID: 1, Provider: "aws", Service: "s3", ProviderHost: "b.s3.amazonaws.com", Exposure: "public_read"}}, nil
}
func (f *fakeStore) CountAssets(ctx context.Context, fl store.AssetFilter) (int, error) {
return 1, nil
}
func (f *fakeStore) SearchEntries(ctx context.Context, query string, limit, offset int) ([]store.IndexEntry, int, error) {
return []store.IndexEntry{{ID: 7, Kind: "object", Name: "photos/a.jpg", ProviderHost: "b.s3.amazonaws.com"}}, 1, nil
}
func (f *fakeStore) Stats(ctx context.Context) (store.Stats, error) {
return store.Stats{Total: 1, Readable: 1}, nil
return store.Stats{Total: 1, Readable: 1, Entries: 1}, nil
}
func TestAPI(t *testing.T) {
@@ -36,12 +44,31 @@ func TestAPI(t *testing.T) {
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var assets []store.Asset
if err := json.NewDecoder(resp.Body).Decode(&assets); err != nil {
var payload struct {
Assets []store.Asset `json:"assets"`
Total int `json:"total"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if len(assets) != 1 || fs.lastFilter.Provider != "aws" || fs.lastFilter.Query != "bucket" || fs.lastFilter.Limit != 5 {
t.Fatalf("unexpected assets/filter: %+v %+v", assets, fs.lastFilter)
if len(payload.Assets) != 1 || payload.Total != 1 || fs.lastFilter.Provider != "aws" || fs.lastFilter.Query != "bucket" || fs.lastFilter.Limit != 5 {
t.Fatalf("unexpected assets/filter: %+v %+v", payload, fs.lastFilter)
}
entriesResp, err := http.Get(srv.URL + "/api/entries?q=photos")
if err != nil {
t.Fatal(err)
}
defer entriesResp.Body.Close()
var entriesPayload struct {
Entries []store.IndexEntry `json:"entries"`
Total int `json:"total"`
}
if err := json.NewDecoder(entriesResp.Body).Decode(&entriesPayload); err != nil {
t.Fatal(err)
}
if len(entriesPayload.Entries) != 1 || entriesPayload.Total != 1 {
t.Fatalf("unexpected entries: %+v", entriesPayload)
}
statsResp, err := http.Get(srv.URL + "/api/stats")
+18
View File
@@ -39,6 +39,24 @@ CREATE TABLE IF NOT EXISTS observations (
CREATE INDEX IF NOT EXISTS observations_asset_idx ON observations (asset_id, observed_at DESC);
-- Metadata index of resources confirmed anonymously readable. Keys/sizes/dates
-- only; never object contents.
CREATE TABLE IF NOT EXISTS asset_entries (
id BIGSERIAL PRIMARY KEY,
asset_id BIGINT NOT NULL REFERENCES cloud_assets (id) ON DELETE CASCADE,
kind TEXT NOT NULL,
name TEXT NOT NULL,
size BIGINT NOT NULL DEFAULT 0,
updated TIMESTAMPTZ,
content_type TEXT NOT NULL DEFAULT '',
etag TEXT NOT NULL DEFAULT '',
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (asset_id, kind, name)
);
CREATE INDEX IF NOT EXISTS asset_entries_asset_idx ON asset_entries (asset_id);
CREATE INDEX IF NOT EXISTS asset_entries_name_idx ON asset_entries (name);
-- CT resume state. `state` is the JSON-encoded certspotter monitor.LogState
-- (download/verified Merkle positions and last verified STH). No certificate
-- contents are ever stored here.