PeeringDB Plus exposes the mirrored PeeringDB dataset through five coexisting API
surfaces served from the same process on the same port, plus a small set of
infrastructure endpoints for health, on-demand sync, and service discovery. This
document is the comprehensive reference; see README.md for a one-page overview
and docs/ARCHITECTURE.md for the rationale behind each surface.
All routes are registered in cmd/peeringdb-plus/main.go and pass through the
production middleware chain:
Recovery -> MaxBytesBody -> CORS -> OTel HTTP -> Logging -> PrivacyTier ->
Readiness -> SecurityHeaders -> CSP -> Caching -> Gzip -> RouteTag -> mux
The server speaks HTTP/1.1 and h2c (HTTP/2 cleartext) on the same listener so that Connect, gRPC, and gRPC-Web clients can use the same base URL as browser and CLI clients.
Most endpoints are unauthenticated and read-only — they expose the same public data that PeeringDB itself publishes.
| Endpoint | Authentication |
|---|---|
All GET endpoints (Web UI, GraphQL, REST, /api/, ConnectRPC) |
None |
POST /sync |
X-Sync-Token header must match PDBPLUS_SYNC_TOKEN (constant-time compare) |
Upstream fetch from api.peeringdb.com |
Optional — set PDBPLUS_PEERINGDB_API_KEY to use an authenticated client with higher rate limits |
If PDBPLUS_SYNC_TOKEN is empty at startup the sync endpoint logs a warning and
rejects every request as 401 unauthorized — there is no "accept anything"
mode. Replica instances reject the request with a fly-replay header that
routes the request to the primary region on Fly.io, or return 503 not primary
when running outside Fly.io.
| Method | Path | Surface | Description |
|---|---|---|---|
GET |
/ |
Root | Content-negotiated service discovery (terminal / browser / JSON) |
GET |
/healthz |
Health | Liveness probe (always 200) |
GET |
/readyz |
Health | Readiness probe (checks DB and sync freshness) |
POST |
/sync |
Admin | On-demand sync trigger (primary only, token-gated) |
GET |
/favicon.ico |
Static | Favicon served from embedded internal/web/static/ |
GET |
/static/* |
Static | Embedded UI assets (CSS, JS, images) |
GET |
/ui/ |
Web UI | Home / search page |
GET |
/ui/asn/{asn} |
Web UI | Network detail by ASN |
GET |
/ui/ix/{id} |
Web UI | Internet exchange detail |
GET |
/ui/fac/{id} |
Web UI | Facility detail |
GET |
/ui/org/{id} |
Web UI | Organization detail |
GET |
/ui/campus/{id} |
Web UI | Campus detail |
GET |
/ui/carrier/{id} |
Web UI | Carrier detail |
GET |
/ui/search |
Web UI | Search results (supports ?q=) |
GET |
/ui/about |
Web UI | Build info and sync freshness |
GET |
/ui/compare |
Web UI | ASN comparison form |
GET |
/ui/compare/{asn1}/{asn2} |
Web UI | ASN comparison results |
GET |
/ui/completions/bash |
Web UI | Bash completion script |
GET |
/ui/completions/zsh |
Web UI | Zsh completion script |
GET |
/ui/completions/search |
Web UI | Name suggestions for shell completion |
GET |
/graphql |
GraphQL | GraphiQL playground (HTML) |
POST |
/graphql |
GraphQL | Query execution |
GET |
/rest/v1/openapi.json |
REST | OpenAPI 3 specification |
GET |
/rest/v1/{collection} |
REST | List resources (entrest-generated) |
GET |
/rest/v1/{collection}/{id} |
REST | Get single resource (entrest-generated) |
GET |
/api/ |
PDB Compat | Index of available object types |
GET |
/api/{type} |
PDB Compat | List endpoint with PeeringDB-compatible filters |
GET |
/api/{type}/{id} |
PDB Compat | Single object (wrapped in data: []) |
POST |
/peeringdb.v1.{Service}/{Method} |
ConnectRPC | One of 13 services × {Get, List, Stream} methods |
POST |
/grpc.reflection.v1.ServerReflection/* |
ConnectRPC | gRPC reflection (v1) |
POST |
/grpc.reflection.v1alpha.ServerReflection/* |
ConnectRPC | gRPC reflection (v1alpha) |
POST |
/grpc.health.v1.Health/* |
ConnectRPC | gRPC health check |
The 13 entity types mirrored from PeeringDB are: campus, carrier,
carrierfac, fac, ix, ixfac, ixlan, ixpfx, net, netfac,
netixlan, org, poc.
There is no /metrics endpoint. Prometheus / Grafana metrics are exported
via OTLP to the configured collector (see docs/CONFIGURATION.md's
OTEL_* variables); no Prometheus scrape endpoint is exposed by the
process.
The Web UI is implemented in internal/web/ using templ
for type-safe HTML templates and htmx for interactive
behavior without a JavaScript build pipeline. The same URL space can render
HTML, ANSI-colored terminal text, plain text, or JSON depending on client
characteristics.
The server inspects each request through internal/web/termrender.Detect
(internal/web/termrender/detect.go) and picks a render mode based on:
?Tor?format=plain|json|whois|shortquery parameter (highest priority)Acceptheader (text/plain→ rich terminal,application/json→ JSON)User-Agentprefix —curl/,Wget/,HTTPie/,xh/,PowerShell/,fetchare treated as terminal clients and receive ANSI-colored outputHX-Requestheader — htmx fragments are returned without the page shell- Default: HTML
?nocolor suppresses all ANSI escape codes regardless of mode.
Because curl and wget are detected by User-Agent, running
curl https://peeringdb-plus.fly.dev/ui/asn/15169 returns ANSI-colored text
intended for a terminal. If you pipe this output to a file, a logger, or a
tool that does not render ANSI codes, you will see escape sequences like
\x1b[38;5;....
Three ways to get clean output:
# Option 1: request plain ASCII
curl "https://peeringdb-plus.fly.dev/ui/asn/15169?format=plain"
# Option 2: disable color codes only
curl "https://peeringdb-plus.fly.dev/ui/asn/15169?nocolor"
# Option 3: pretend to be a browser and get HTML
curl -H "User-Agent: Mozilla/5.0" https://peeringdb-plus.fly.dev/ui/asn/15169
# Option 4: strip ANSI codes after the fact
curl https://peeringdb-plus.fly.dev/ui/asn/15169 | sed 's/\x1b\[[0-9;]*m//g'For machine-readable output, use one of the structured API surfaces
(/api/, /rest/v1/, /graphql, or ConnectRPC) instead of scraping /ui/.
| Route | Description |
|---|---|
GET /ui/ |
Home page. Accepts ?q= for pre-rendered search results (shareable URLs) |
GET /ui/search?q= |
Search results. Returns a full page, an htmx fragment, or a terminal render depending on headers. Sets HX-Push-Url for browser history |
GET /ui/asn/{asn} |
Network detail by ASN (1 .. 2³²−1; values outside the range return 400 Problem+JSON) |
GET /ui/ix/{id} |
Internet exchange detail by numeric ID |
GET /ui/fac/{id} |
Facility detail |
GET /ui/org/{id} |
Organization detail |
GET /ui/campus/{id} |
Campus detail |
GET /ui/carrier/{id} |
Carrier detail |
GET /ui/about |
Build info, sync freshness, environment summary. Opted out of response caching in middleware.NewCachingState because it renders relative time (e.g. "5 minutes ago") |
GET /ui/compare |
ASN comparison form. ?asn1= and ?asn2= pre-fill the form |
GET /ui/compare/{asn1} |
Pre-fills the form with asn1, awaits asn2 |
GET /ui/compare/{asn1}/{asn2} |
Comparison results. ?view=shared (default) / all / differences toggles the panes |
GET /ui/completions/bash |
Installable bash completion script |
GET /ui/completions/zsh |
Installable zsh completion script |
GET /ui/completions/search?q=&type= |
Newline-separated name suggestions used by the shell completion scripts (`type=net |
Unknown /ui/* paths render the themed 404 page via handleNotFound.
GraphQL is served by gqlgen wired through
entgql. The handler lives in
internal/graphql/handler.go.
| Method | Behavior |
|---|---|
GET /graphql |
Serves the GraphiQL playground (HTML page, CDN-hosted JS, pre-populated with example queries). Introspection is always enabled |
POST /graphql |
Executes a GraphQL query. Request body capped at 1 MB |
| Limit | Value | Enforcement |
|---|---|---|
| Request body | 1 MB | http.MaxBytesReader wrap at the route + global MaxBytesBody middleware |
| Query complexity | 500 | gqlgen.extension.FixedComplexityLimit(500) |
| Query depth | 15 | gqlgen-depth-limit-extension |
Errors are returned in standard GraphQL format with an extensions.code field
populated by classifyError in internal/graphql/handler.go:
| Code | Trigger |
|---|---|
NOT_FOUND |
ent.IsNotFound(err) |
VALIDATION_ERROR |
ent.IsValidationError(err) |
CONSTRAINT_ERROR |
ent.IsConstraintError(err) |
INTERNAL_ERROR |
Anything else |
Every error also includes a populated path pointing at the offending field.
{
networkByAsn(asn: 13335) {
name
asn
infoType
website
organization { name }
}
}Schema browsing is easiest through the GraphiQL playground. The schema is
generated from ent/schema/ and committed to graph/schema.graphqls.
The REST surface is generated by
entrest directly from the ent schemas
with read-only operations only (OperationRead + OperationList).
| Path | Description |
|---|---|
GET /rest/v1/openapi.json |
OpenAPI 3 specification for the full REST surface. Regenerated as part of go generate ./... |
GET /rest/v1/{collection} |
List resources with filtering and pagination per the OpenAPI spec |
GET /rest/v1/{collection}/{id} |
Get a single resource |
The collection paths and filter parameters are defined by entrest annotations
in ent/schema/; consult the OpenAPI spec for the canonical list.
Non-2xx responses are rewritten to RFC 9457 Problem Details
by restErrorMiddleware in cmd/peeringdb-plus/main.go. The response
Content-Type is application/problem+json and the body includes at minimum
status, title, detail, and instance fields.
Implemented in internal/pdbcompat/, this surface is a drop-in replacement
for the PeeringDB REST API — URL structure, response envelope, filter
operators, and the single-object-wrapped-in-array quirk all match the upstream
API so existing clients can switch to a PeeringDB Plus instance with only a
base-URL change.
| Route | Description |
|---|---|
GET /api/ |
JSON index mapping each of the 13 type names to its list endpoint |
GET /api/{type} |
List endpoint |
GET /api/{type}/{id} |
Single object by numeric ID, wrapped in data: [ ... ] (intentional parity with upstream) |
Valid {type} values are the same 13 constants defined in
internal/peeringdb/types.go: org, net, fac, ix, poc, ixlan,
ixpfx, netixlan, netfac, ixfac, carrier, carrierfac, campus.
| Parameter | Applies to | Description |
|---|---|---|
q |
List | Case-insensitive substring search across the type's search fields. For /api/net, an ASN literal (e.g. 8075 or AS8075) also matches net.asn exactly in addition to the text fields |
limit |
List | Maximum rows in response. Default unlimited when absent — matches upstream rest.py:495 (limit defaults to 0) + rest.py:737 (no slice when limit=0). Bare /api/<type> URLs return ALL rows from the filtered queryset; the response is gated only by the response memory budget (see below). Explicit limit=N: positive N is clamped to MaxLimit=1000; limit=0 is the explicit "unlimited" sentinel; negative values are ignored. Constants: DefaultLimit=0, MaxLimit=1000 (internal/pdbcompat/response.go). The ?page=N shape is not supported — clients that want pagination set ?limit=N&skip=M instead |
skip |
List | Offset for pagination. Negative values are ignored |
depth |
Detail | Edge expansion depth. Accepted values: 0 (flat object) and 2 (default, embed related _set collections). The detail default is 2, matching upstream serializers.py:817-823 (default_depth(is_list=False)); pass ?depth=0 for the bare-row escape hatch. Any other value is silently ignored. List endpoints silently drop ?depth= — see § Known Divergences |
fields |
Both | Comma-separated projection — only the listed JSON keys are returned after retrieval |
since |
List | Only return rows with updated greater than the given timestamp (Unix seconds). Invalid input returns 400. Activates the upstream "since matrix" — see "Soft-delete tombstones" below |
{field}, {field}__{op} |
List | Arbitrary field filter. Operator suffixes: __contains, __icontains, __startswith, __istartswith, __iexact, __in, __lt, __lte, __gt, __gte. contains and startswith are coerced to their case-insensitive variants per upstream rest.py:638-641. Typed against the field; invalid types (e.g. asn__contains) return 400 |
Unknown query parameters that are not in the reserved set (limit, skip,
depth, since, q, fields) are treated as field filters and validated
against the type's schema. Unknown filter keys (including over-cap traversal
keys) are silently ignored — they do not cause 400 — and a debug-level slog
record plus an pdbplus.filter.unknown_fields OTel span attribute are
emitted so operators can observe them. See § Cross-entity traversal for the
2-hop cap and § Validation Notes for the rationale.
__in accepts a CSV value and binds as a single JSON array via SQLite's
json_each(), sidestepping the variable-binding limit. An empty __in
(?asn__in=) short-circuits the request to an empty data: [] envelope
without running SQL (Phase 69 IN-02). Malformed __in values for typed
fields (e.g. non-integer in asn__in=) return 400.
?<field>__contains= and ?<field>__startswith= (and their __icontains /
__istartswith aliases) on the following 16 fields are diacritic-insensitive
— they match Köln and koln interchangeably:
| Entity | Folded fields |
|---|---|
org |
name, aka, city |
net |
name, aka, name_long |
fac |
name, aka, city |
ix |
name, aka, name_long, city |
carrier |
name, aka |
campus |
name |
Implementation: each row carries a sibling <field>_fold shadow column
populated at sync time via internal/unifold.Fold (NFKD decomposition + a
ligature map). Filter routing happens in internal/pdbcompat/filter.go
buildContains / buildStartsWith — when tc.FoldedFields[<field>] is
true the predicate runs against <field>_fold with unifold.Fold(value)
on the RHS. The shadow columns carry entgql.Skip(SkipAll) and
entrest.WithSkip(true) so they are invisible to GraphQL, REST, and proto
wire surfaces — they exist only to power pdbcompat folding. See § Known
Divergences for the upstream-parity comparison and § Validation Notes for
why MySQL collation is not the upstream mechanism.
Sync soft-deletes rows by setting status='deleted' rather than physically
removing them. The list path applies the upstream rest.py:694-727 status
matrix as the final predicate via applyStatusMatrix:
| Request shape | Admitted statuses |
|---|---|
List, no ?since |
status='ok' only |
List with ?since=N |
status IN ('ok', 'deleted'); pending additionally admitted on /api/campus |
Single-object GET /api/<type>/<id> |
status IN ('ok', 'pending') — tombstones return 404 |
?status=<value> is dropped at the filter layer (the key is absent from
every type's Fields map in internal/pdbcompat/registry.go) — the
observable outcome is identical to upstream's effective behaviour. See
§ Known Divergences for the explicit comparison.
Every list response is gated by a pre-flight 413 budget check before any
SQL is executed. serveList in internal/pdbcompat/handler.go runs a
SELECT COUNT(*) against the filtered query, multiplies by the per-entity
typical row size, and refuses up-front if the projected response exceeds
PDBPLUS_RESPONSE_MEMORY_LIMIT (default 128MiB). 0 disables the check
(local-dev escape hatch only).
A budget-exceeded request returns:
413 Request Entity Too LargeContent-Type: application/problem+jsontype: https://peeringdb-plus.fly.dev/errors/response-too-large- Body extension fields
max_rows(the largest result set that would fit) andbudget_bytes(the configured ceiling)
Operators receiving a 413 should narrow their filters or page smaller — the
budget is request-shape, not transient resource pressure, so retrying the
identical request returns the same 413. The budget is enforced only on the
pdbcompat list path; entrest, GraphQL, ConnectRPC, and Web UI have their own
memory stories (see docs/ARCHITECTURE.md § Response Memory Envelope).
# All networks with ASN 15169 (Google)
curl "https://peeringdb-plus.fly.dev/api/net?asn=15169"
# Single network by internal ID, with edges expanded
curl "https://peeringdb-plus.fly.dev/api/net/20/?depth=2"
# Full-text search against networks, matching name, aka, name_long, irr_as_set
# and the asn column for numeric queries
curl "https://peeringdb-plus.fly.dev/api/net?q=AS8075"
# First 50 IXPs in country DE
curl "https://peeringdb-plus.fly.dev/api/ix?country=DE&limit=50"
# Only return id and name
curl "https://peeringdb-plus.fly.dev/api/ix?country=DE&fields=id,name"
# Networks updated since 2024-01-01 (Unix seconds) — admits tombstones
curl "https://peeringdb-plus.fly.dev/api/net?since=1704067200"
# Diacritic-insensitive substring match against organization names
curl "https://peeringdb-plus.fly.dev/api/org?name__contains=koln"
# 2-hop traversal: facilities whose parent organization is named "DE-CIX"
curl "https://peeringdb-plus.fly.dev/api/fac?org__name=DE-CIX"All responses follow the PeeringDB shape:
{
"meta": {},
"data": [ { "...": "..." } ]
}Detail endpoints return a single-element data array, not a bare object, to
preserve parity with upstream PeeringDB clients.
Errors use RFC 9457 Problem Details
with Content-Type: application/problem+json. Typical status codes:
| Status | Cause |
|---|---|
400 |
Invalid filter operator, malformed since, non-integer ID, filter type mismatch, malformed __in value |
404 |
Unknown {type}, missing {id}, or detail GET on a tombstoned row |
413 |
Pre-flight response memory budget exceeded — see "Response memory budget" above |
500 |
Database error (details redacted from response body, full error logged) |
Responses include an X-Powered-By header identifying the server as
PeeringDB Plus.
Implemented in internal/grpcserver/ using
ConnectRPC — a gRPC-compatible framework that
speaks three protocols on the same endpoint:
| Protocol | Typical client | Content types |
|---|---|---|
| Connect (HTTP/1.1 or HTTP/2) | connect-go, browser fetch |
application/proto, application/json |
| gRPC (HTTP/2) | grpc-go, grpcurl, any gRPC stub |
application/grpc, application/grpc+proto, application/grpc+json |
| gRPC-Web | Browser gRPC-Web clients | application/grpc-web, application/grpc-web-text |
The server listens on a single port with h2c enabled
(buildServer in cmd/peeringdb-plus/main.go), so there is no separate
port for gRPC.
All 13 entity types expose the same three RPCs
(proto/peeringdb/v1/services.proto):
| Service | Get | List | Stream |
|---|---|---|---|
peeringdb.v1.CampusService |
GetCampus |
ListCampuses |
StreamCampuses |
peeringdb.v1.CarrierService |
GetCarrier |
ListCarriers |
StreamCarriers |
peeringdb.v1.CarrierFacilityService |
GetCarrierFacility |
ListCarrierFacilities |
StreamCarrierFacilities |
peeringdb.v1.FacilityService |
GetFacility |
ListFacilities |
StreamFacilities |
peeringdb.v1.InternetExchangeService |
GetInternetExchange |
ListInternetExchanges |
StreamInternetExchanges |
peeringdb.v1.IxFacilityService |
GetIxFacility |
ListIxFacilities |
StreamIxFacilities |
peeringdb.v1.IxLanService |
GetIxLan |
ListIxLans |
StreamIxLans |
peeringdb.v1.IxPrefixService |
GetIxPrefix |
ListIxPrefixes |
StreamIxPrefixes |
peeringdb.v1.NetworkService |
GetNetwork |
ListNetworks |
StreamNetworks |
peeringdb.v1.NetworkFacilityService |
GetNetworkFacility |
ListNetworkFacilities |
StreamNetworkFacilities |
peeringdb.v1.NetworkIxLanService |
GetNetworkIxLan |
ListNetworkIxLans |
StreamNetworkIxLans |
peeringdb.v1.OrganizationService |
GetOrganization |
ListOrganizations |
StreamOrganizations |
peeringdb.v1.PocService |
GetPoc |
ListPocs |
StreamPocs |
The URL path for every RPC is /{fully.qualified.ServiceName}/{MethodName} —
e.g. /peeringdb.v1.NetworkService/GetNetwork.
List and Stream requests accept type-specific optional filter fields (see
proto/peeringdb/v1/services.proto). All filters AND together. String filters
for free-text columns (name, aka, name_long) use case-insensitive substring
match (ContainsFold); other strings are matched exactly. Integer filters
such as asn and org_id are validated to be positive; invalid values return
INVALID_ARGUMENT.
| Field | Semantics |
|---|---|
page_size |
Requested page size. Defaults to 100, clamped to 1000. See normalizePageSize in internal/grpcserver/pagination.go |
page_token |
Opaque cursor. Clients pass back the next_page_token from the previous response to fetch the next page. Invalid tokens return INVALID_ARGUMENT |
Stream{Type} RPCs use batched compound keyset pagination under the
hood (StreamEntities in internal/grpcserver/generic.go), fetching
streamBatchSize (500) rows per database round-trip and emitting one proto
message per row. The cursor is the compound (updated, created, id) triple;
under the default (-updated, -created, -id) order each batch resumes via:
WHERE (updated < cursor.updated)
OR (updated = cursor.updated AND created < cursor.created)
OR (updated = cursor.updated AND created = cursor.created AND id < cursor.id)The keyset carries every sort key, so it matches the three-key ordering
exactly: progress stays monotonic and no row is skipped or repeated even when
many rows share an updated timestamp (or an updated+created pair).
| Field | Semantics |
|---|---|
since_id |
Filter — emits only rows with id > since_id. Applied as a WHERE predicate; does not seed the keyset cursor |
updated_since |
Filter — emits only rows with updated > updated_since. Applied as a WHERE predicate; does not seed the keyset cursor |
Every stream is capped by PDBPLUS_STREAM_TIMEOUT (default 60s) enforced via
context.WithTimeout at the handler. Exceeding the timeout closes the stream
with a cancellation error.
On full streams (both since_id and updated_since unset), the handler
runs a SELECT COUNT(*) preflight and sets the grpc-total-count response
header to the total matching row count. On delta streams (either since_id
or updated_since set), the COUNT preflight is skipped entirely and the
grpc-total-count header is absent — not "present with 0" and not
"present with -1". Clients of delta streams have no use for a full-table total
and the skip avoids a needless full-table scan.
| Handler | Path |
|---|---|
| gRPC reflection v1 | /grpc.reflection.v1.ServerReflection/* |
| gRPC reflection v1alpha | /grpc.reflection.v1alpha.ServerReflection/* |
| gRPC health check | /grpc.health.v1.Health/* |
Reflection serves all 13 service descriptors, enabling grpcurl and grpcui
to discover the API with no additional wiring. The health checker reports
NOT_SERVING until the first sync completes (HasCompletedSync in the sync
worker), then flips to SERVING for the empty service name and for every
peeringdb.v1.* service. The health handler bypasses the readiness middleware
so that health checks can poll the service during sync-in-progress state
without being intercepted by the 503 syncing page.
# Get one network by ID using grpcurl (reflection-driven)
grpcurl -d '{"id": 20}' peeringdb-plus.fly.dev:443 \
peeringdb.v1.NetworkService/GetNetwork
# List first 10 ASNs registered under organization 10
grpcurl -d '{"page_size": 10, "org_id": 10}' peeringdb-plus.fly.dev:443 \
peeringdb.v1.NetworkService/ListNetworks
# Stream all organizations updated since an RFC3339 timestamp
grpcurl -d '{"updated_since": "2025-01-01T00:00:00Z"}' peeringdb-plus.fly.dev:443 \
peeringdb.v1.OrganizationService/StreamOrganizations
# Connect-over-HTTP JSON — works with plain curl
curl -X POST https://peeringdb-plus.fly.dev/peeringdb.v1.NetworkService/GetNetwork \
-H "Content-Type: application/json" \
-d '{"id": 20}'PeeringDB Plus mirrors upstream PeeringDB's per-field visibility marker for
the IX-F member list URL: ixlan.ixf_ixp_member_list_url is gated by the
sibling ixlan.ixf_ixp_member_list_url_visible enum (Public / Users /
Private). Anonymous callers (the default PDBPLUS_PUBLIC_TIER=public
deployment) receive the value only when _visible = Public; for Users or
Private the value is omitted across all five surfaces while the _visible
companion field is still emitted (upstream parity, Phase 64 D-05).
The single source of truth is internal/privfield.Redact(ctx, visible, value). Every serializer calls it, and internal/middleware.PrivacyTier
stamps the resolved tier on the request context — unstamped contexts
fail-closed to TierPublic.
| Surface | Mechanism |
|---|---|
/api/ (pdbcompat) |
internal/pdbcompat/serializer.go ixLanFromEnt(ctx, l); the JSON struct tag ,omitempty produces wire absence |
/rest/v1/ix-lans* (entrest) |
restFieldRedactMiddleware in cmd/peeringdb-plus/main.go buffers the JSON response and deletes the key in-place when Redact returns omit=true. Wraps INSIDE restErrorMiddleware so error bodies pass through |
/peeringdb.v1.IxLanService/* (ConnectRPC) |
internal/grpcserver/ixlan.go ixLanToProto(ctx, il) returns nil *wrapperspb.StringValue — wire absence under proto3 optional |
/graphql |
graph/schema.resolvers.go ixLanResolver.IxfIxpMemberListURL returns Go nil → GraphQL null |
/ui/ |
No render path renders the URL today; future templates must call privfield.Redact in the data-prep step |
Operators who run a private deployment can flip PDBPLUS_PUBLIC_TIER=users
to make anonymous callers behave as authenticated users — the startup logger
emits a WARN with public_tier=users so the override is visible in deploy
logs.
Root endpoint with content negotiation (main.go GET /{$}):
| Client | Response |
|---|---|
| Terminal (curl, wget, HTTPie, …) | text/plain help screen rendered by termrender.NewRenderer with optional ANSI colors |
Browser (Accept: text/html) |
302 Found redirect to /ui/ |
JSON client (Accept: application/json) |
Static service discovery document (see below) |
| Default | Same JSON service discovery document |
Service discovery JSON body:
{
"name": "peeringdb-plus",
"version": "0.1.0",
"graphql": "/graphql",
"rest": "/rest/v1/",
"api": "/api/",
"connectrpc": "/peeringdb.v1.",
"ui": "/ui/",
"healthz": "/healthz",
"readyz": "/readyz"
}The root bypasses the readiness middleware so service discovery still works while the first sync is in progress.
Liveness probe. Always returns 200 OK with a fixed JSON body as long as the
process can serve HTTP. It does not check database connectivity or sync
state — a failing /healthz means the process itself is wedged and should be
restarted.
Bypasses the readiness middleware.
Readiness probe. Returns 200 OK only when:
- The database is reachable (
db.PingContext). - The most recent sync is more recent than
PDBPLUS_SYNC_STALE_THRESHOLD(default24h).
Returns 503 Service Unavailable otherwise. The response body is the opaque
shape {"status":"ok"} or {"status":"unhealthy"} — detailed error strings
are written to structured logs only (security hardening: the wire body does
not leak internal failure detail).
Bypasses the readiness-gate middleware itself (so a probe can observe the unready state rather than being redirected to the syncing page).
On-demand sync trigger. Only served by the LiteFS primary.
| Header / Param | Purpose |
|---|---|
X-Sync-Token request header |
Must match PDBPLUS_SYNC_TOKEN using subtle.ConstantTimeCompare. Empty token on either side = always reject |
?mode=full or ?mode=incremental |
Overrides PDBPLUS_SYNC_MODE for this run. Any other value returns 400 |
| Status | Meaning |
|---|---|
202 Accepted |
Request authenticated; sync started in the background (fire-and-forget, using the application root context so request cancellation does not abort it) |
307 Temporary Redirect + fly-replay: region=<primary> |
On Fly.io replicas, the request is replayed to the primary region. Clients following the header will land on the primary and complete the call |
401 Unauthorized |
Missing, wrong, or mismatched-length X-Sync-Token |
400 Bad Request |
Invalid mode value |
503 Service Unavailable |
Replica outside Fly.io (no FLY_REGION) cannot forward the request |
Request body is capped at 1 MB.
Bypasses the readiness middleware so an operator can kick off the first sync before any sync has completed.
PeeringDB Plus does not itself enforce request rate limits at any edge. The only operational limits are:
| Limit | Scope | Default | Configured by |
|---|---|---|---|
| Request body size | Every non-gRPC request | 1 MB |
maxRequestBodySize in main.go (hardcoded) |
| Read header timeout | Every connection | 10s |
buildServer (hardcoded) |
| Read timeout | Every connection | 30s |
buildServer (hardcoded) |
| Idle timeout | Every keep-alive connection | 120s |
buildServer (hardcoded) |
| Stream timeout | ConnectRPC Stream{Type} |
60s |
PDBPLUS_STREAM_TIMEOUT |
| Graceful drain timeout | Shutdown | 10s |
PDBPLUS_DRAIN_TIMEOUT |
| Sync memory ceiling | Sync worker heap | 400MB |
PDBPLUS_SYNC_MEMORY_LIMIT |
| Response memory budget | pdbcompat /api/ list |
128MiB |
PDBPLUS_RESPONSE_MEMORY_LIMIT |
| GraphQL query complexity | POST /graphql |
500 |
FixedComplexityLimit (hardcoded) |
| GraphQL query depth | POST /graphql |
15 |
FixedDepthLimit (hardcoded) |
Upstream PeeringDB rate limits apply to the sync worker's outbound requests —
setting PDBPLUS_PEERINGDB_API_KEY raises that ceiling.
Deployment-level rate limiting (e.g., Fly.io edge, Cloudflare, or a load balancer) is not configured in this repository.
All surfaces pass through the shared middleware.CORS configured by
PDBPLUS_CORS_ORIGINS (default *). The middleware allows the full set of
headers required by Connect / gRPC / gRPC-Web in addition to standard
application headers; see internal/middleware/cors.go. The REST handler
additionally wraps its subtree in its own CORS middleware so that preflight
requests for /rest/v1/* are handled even if another middleware short-circuits.
PeeringDB Plus strives for behavioural parity with the upstream PeeringDB API
(peeringdb/peeringdb) at the /api/ surface. Divergences are documented
here with upstream source-line citations so operators can audit the
boundaries intentionally. Each row cross-references a parity test under
internal/pdbcompat/parity/*_test.go — typically a DIVERGENCE_<…> sub-test.
| Request | Upstream behaviour | peeringdb-plus behaviour | Rationale | Since |
|---|---|---|---|---|
GET /api/<type>?status=<value> (list, no ?since) |
Upstream rest.py:700-712 builds allowed_status from the caller's ?status= parameter (or a default set), then applies a final unconditional filter(status='ok') on rest.py:725. The caller-supplied value is effectively overridden by the final filter — only status=ok rows ever reach the response. |
pdbcompat drops ?status= at the filter layer (the status entry is removed from all 13 Fields maps in internal/pdbcompat/registry.go) so the predicate never reaches ent. The observable outcome is identical to upstream; the implementation is explicit rather than implicit. |
Upstream's double-filter is a no-op by design. We model the intent rather than the mechanism, which makes the D-07 semantic greppable in one place. | v1.16 (Phase 68) |
GET /api/<type>?status=deleted&since=N for a row hard-deleted by sync cycles before v1.16 |
Upstream returns the tombstone row with its deletion timestamp (upstream has always soft-deleted). | peeringdb-plus returns empty for such rows. Tombstone population begins at the first post-upgrade sync cycle; anything hard-deleted before v1.16 shipped is gone forever. Rows deleted from v1.16 onwards are visible via both ?since=N windows and pk lookup (pk admits status IN (ok, pending); tombstones are reachable only via the since window). |
No retroactive reconstruction is possible — PeeringDB's public API does not expose historical state, and we did not persist deleted rows prior to the Phase 68 soft-delete flip (D-03). Documented intentional one-time gap. See peeringdb/peeringdb@99e92c726172ead7d224ce34c344eff0bccb3e63:src/peeringdb_server/rest.py:694-727 for upstream status matrix. Parity-locked by TestParity_Status/STATUS-04_list_since_admits_deleted_excludes_pending_noncampus. |
v1.16 (Phase 68; locked in Phase 72) |
?limit=0 interpreted as "return a count envelope only" (pdbfe claim) |
Upstream rest.py:494-497 treats limit=0 as unlimited (if limit == 0: limit = None — Python None means no SQL LIMIT). There is no count-only semantic upstream — pdbfe's gotchas doc is simply wrong on this point. |
peeringdb-plus matches upstream: limit=0 returns all matching rows unbounded, gated only by the Phase 71 PDBPLUS_RESPONSE_MEMORY_LIMIT budget (default 128 MiB). Callers wanting a count read the length of the returned data array — the meta envelope is empty ({}), matching upstream's non-paginated default (upstream only populates meta.pagination.count under its ?page shape, which this mirror does not implement). |
We match upstream semantics verbatim rather than codifying an invalid-pdbfe-claim as a behavioural divergence. See § Validation Notes entry 2. Parity-locked by TestParity_Limit/LIMIT-01_zero_returns_all_rows_unbounded and TestParity_Limit/LIMIT-01b_zero_over_budget_returns_413_problem_json. |
v1.16 (Phase 68; locked in Phase 72) |
?depth=N on list endpoints (any /api/<type> without a pk) |
Upstream rest.py:744-748 accepts ?depth= on list requests and caps row count at API_DEPTH_ROW_LIMIT=250. |
peeringdb-plus silently drops ?depth= on list endpoints with a slog.DebugContext paper trail (Phase 68 LIMIT-02 guardrail — opts.Depth is never threaded into list closures). ?depth= on single-object GET (/api/<type>/<id>) works as upstream specifies. Functional list+depth is deferred indefinitely — the Phase 71 budget.go memory envelope would refuse the resulting response sizes on 256 MB replicas anyway. |
Memory envelope on 256 MB replicas (Phase 71 D-06 — 13-entity × 2-depth worst case exceeds the 128 MiB budget for any realistic row count). docs/ARCHITECTURE.md § Response Memory Envelope documents the per-entity ceiling. Parity-locked by TestParity_Limit/LIMIT-02_depth_on_list_silently_dropped_DIVERGENCE. |
v1.16 (Phase 68 guardrail; locked in Phase 72) |
?<field>__contains=<non-ASCII> / ?<field>__startswith=<non-ASCII> against searchable text fields on network, facility, ix, organization, campus, carrier (16 fields total — see "Diacritic-insensitive substring / prefix search" above) |
Upstream applies unidecode.unidecode(v) to BOTH the query value and the column at query time (rest.py:576), producing diacritic-insensitive matches in a single SQL pass. |
peeringdb-plus precomputes the folded value into a sibling <field>_fold shadow column at sync time (via internal/unifold.Fold — NFKD normalisation + a small ligature map for ß/æ/ø/ł/þ/đ), then routes __contains / __startswith to <field>_fold LIKE ? with unifold.Fold(query) on the RHS. The end-state semantic match is identical, but it is staged differently: a brief one-time ASCII-only window exists between v1.16 deploy and the first post-deploy sync cycle (≤1h with default PDBPLUS_SYNC_INTERVAL=1h) during which rows have <field>_fold = '' and return no match for non-ASCII queries. ASCII queries continue to work via the non-folded columns throughout the window. No manual backfill is required — the next sync cycle's OnConflict().UpdateNewValues() path rewrites every row's _fold columns. |
Shadow columns let SQLite use a single indexable comparison path (no per-query unidecode call), and Phase 69 benchstat (n=6, 10k rows) shows the shadow path within ±1% of the direct path so the trade-off is invisible at production scale. The folded columns carry entgql.Skip(SkipAll) + entrest.WithSkip(true) annotations and are never exposed on the GraphQL / REST / proto wire surfaces — they are server-side plumbing only. |
v1.16 (Phase 69) |
GET /api/<type>?a__b__c__d=X (3+ __-separated relation segments) |
Upstream walks arbitrary Django ORM chains — no hard depth cap (bounded only by the Django ORM query planner). | peeringdb-plus silently ignores the filter (HTTP 200, unfiltered) per Phase 70 D-04. One aggregated slog.DebugContext("pdbcompat: unknown filter fields silently ignored (Phase 70 TRAVERSAL-04)", ...) plus OTel span attribute pdbplus.filter.unknown_fields record the dropped key. Keys with exactly 1 or 2 relation segments resolve normally via Path A (explicit allowlist) or Path B (ent edge introspection). |
DoS protection: 3+-hop joins in SQLite can trigger super-linear query plan scans at scale, and the replica memory envelope (256 MB post-Phase-65) cannot absorb unbounded Cartesian-product row counts. The 2-hop cap trades limitless traversal for a predictable cost ceiling gated in CI by internal/pdbcompat/bench_traversal_test.go (<50ms/op @ 10k rows). |
v1.16 (Phase 70) |
GET /api/fac?ixlan__ix__fac_count__gt=0 (DEFER-70-verifier-01; upstream citation pdb_api_test.py:2340, 2348) |
Upstream resolves via a per-serializer prepare_query that joins fac → ixfac → ix → fac_count (3-hop bespoke SQL). |
peeringdb-plus silently ignores the filter (HTTP 200, unfiltered result) because fac has no direct ixlan edge in the ent schema — ixlan belongs to ix, not to fac — and the 3-hop walk via ixfac exceeds the hard 2-hop cap (Phase 70 D-04). The generic 2-hop mechanism continues to work for entity pairs with direct edges (e.g. /api/ixpfx?ixlan__ix__id=20 resolves correctly). |
Relaxing the 2-hop cap re-opens cost-ceiling concerns that D-04 was designed to contain (unbounded Cartesian-product joins in SQLite under 256 MB replica memory envelope). Adding a bespoke per-serializer hook for this single upstream citation case doesn't fit the generic D-01/D-04 model cleanly. Tracked as DEFER-70-verifier-01. | v1.16 (Phase 70; locked by TestParity_Traversal/DIVERGENCE_fac_ixlan_ix_fac_count_silent_ignore in Phase 72) |
Future conformance auditors reading third-party gotchas documentation
(notably pdbfe's upstream-behaviour claims) against the PeeringDB Plus
codebase may encounter assertions about upstream behaviour that turn
out to be wrong. This section documents 6 such invalid claims — 5 from
the v1.16 audit (Phases 67-72) and one (org_flags) from the
2026-05-30 audit — each with a pinned peeringdb/peeringdb@<sha>
reference so the authoritative upstream source can be re-read without
re-research. All 6 were confirmed against commit
peeringdb/peeringdb@99e92c726172ead7d224ce34c344eff0bccb3e63 (the
same SHA pinned in internal/testutil/parity/fixtures.go).
| Claim | Verdict | Upstream truth | Our implementation |
|---|---|---|---|
net?country=NL is a valid filter key |
WRONG | country lives on org, not net. See peeringdb/peeringdb@99e92c726172ead7d224ce34c344eff0bccb3e63:src/peeringdb_server/serializers.py:2938-2992 — NetworkSerializer.prepare_query has no country key, and django-peeringdb/src/django_peeringdb/models/abstract.py's Network model has no country field. Callers who want net filtered by country must traverse through org (e.g. net?org__country=NL). |
Filter key silently ignored via the TRAVERSAL-04 silent-ignore mechanism (Phase 70 D-04) — no row-level match, response unfiltered. OTel span attribute pdbplus.filter.unknown_fields records the dropped key. Parity-locked by TestParity_Traversal/TRAVERSAL-04_unknown_field_silently_ignored_with_otel_attr. |
?limit=0 returns a count-only envelope |
WRONG | limit=0 means unlimited. See peeringdb/peeringdb@99e92c726172ead7d224ce34c344eff0bccb3e63:src/peeringdb_server/rest.py:494-497 — if limit == 0: limit = None (Python None = no SQL LIMIT clause). There is no count-only semantic upstream. Callers wanting a count read the length of the returned data array (meta is the empty {} envelope — there is no top-level count field). |
Unbounded response via Phase 68 LIMIT-01 (ent v0.14.6 .Limit(0) = unlimited, gated by sqlgraph graph.go:1086 if q.Limit != 0) plus Phase 71 memory budget (PDBPLUS_RESPONSE_MEMORY_LIMIT, default 128 MiB) with RFC 9457 413 on over-budget. Parity-locked by TestParity_Limit/LIMIT-01_zero_returns_all_rows_unbounded and TestParity_Limit/LIMIT-01b_zero_over_budget_returns_413_problem_json. |
Default list ordering is id ASC |
WRONG | Default ordering is (-updated, -created) via the django-handleref base Meta. See peeringdb/peeringdb@99e92c726172ead7d224ce34c344eff0bccb3e63 + upstream dep django-handleref:src/django_handleref/models.py:95-101 (class Meta: ordering = ('-updated', '-created')). Every PeeringDB model inherits from this base, so the default applies across all 13 entity types. |
Phase 67 flipped pdbcompat /api/*, entrest /rest/v1/*, ConnectRPC list RPCs, and GraphQL list queries to the compound (-updated, -created, -id) order (the trailing -id is a tie-breaker to ensure stable cursor pagination). Single-object lookups and nested _set fields are unchanged per D-04. Parity-locked by TestParity_Ordering/default_list_order_updated_desc, tiebreak_by_created_desc, and tiebreak_by_id_desc. |
Unicode folding uses MySQL collation (utf8_general_ci or similar) |
WRONG | Folding is Python-side via unidecode.unidecode(v) at query time. See peeringdb/peeringdb@99e92c726172ead7d224ce34c344eff0bccb3e63:src/peeringdb_server/rest.py:576 — the call happens in the Python filter construction layer before any SQL is emitted, so the database collation is irrelevant. |
Phase 69 uses shadow <field>_fold columns populated at sync time via internal/unifold.Fold (golang.org/x/text/unicode/norm NFKD decomposition + a hand-rolled ligature map for ß/æ/ø/ł/þ/đ). __contains / __startswith route to <field>_fold LIKE ? with unifold.Fold(query) on the RHS. Not byte-compatible with Python unidecode for every input (e.g. the two libraries handle rare CJK edge cases differently); any specific gap that surfaces will be logged as a new § Known Divergences row. Parity-locked by TestParity_Unicode/UNICODE-01_net_name_contains_diacritic_matches_ascii, UNICODE-01_fac_city_cjk_roundtrip, and UNICODE-01_combining_mark_NFKD_equivalent. |
Filter surface is a DRF filterset_class per ViewSet |
WRONG | Filter surface is a per-serializer prepare_query(...) method plus an auto-queryable_relations() mechanism with a FILTER_EXCLUDE denylist. See peeringdb/peeringdb@99e92c726172ead7d224ce34c344eff0bccb3e63:src/peeringdb_server/serializers.py:754-780 (queryable_relations()) and :128-157 (FILTER_EXCLUDE). No django_filters.FilterSet subclass exists anywhere in the upstream codebase. |
Phase 70 Path A = pdbcompat.WithPrepareQueryAllow ent-schema annotations → allowlist_gen.go Allowlists map (13 entries verbatim from upstream); Path B = ent edge introspection via the generated Edges map. The Phase 70 D-03 WithFilterExcludeFromTraversal edge annotation mirrors upstream's FILTER_EXCLUDE — currently empty across all 13 schemas (every FK edge exposed in v1.16). Parity-locked by TestParity_Traversal/TRAVERSAL-01_path_a_1hop_org_name and TRAVERSAL-03_path_b_1hop_org_city. |
org_flags is a valid filter param on /api/org |
WRONG | No org_flags (or flags) filter key or column exists upstream. Grepping peeringdb/peeringdb@99e92c726172ead7d224ce34c344eff0bccb3e63:src/peeringdb_server/{rest.py,serializers.py,models.py} returns zero matches, and the django-peeringdb Organization model has no flags field — OrganizationSerializer.prepare_query exposes no such key. This is not a real upstream parameter. |
Filter key silently ignored via the TRAVERSAL-04 silent-ignore mechanism (Phase 70 D-04) — response unfiltered, pdbplus.filter.unknown_fields records the dropped key. NOT a § Known Divergences entry: there is no upstream behaviour to diverge from. Covered by the generic TestParity_Traversal/TRAVERSAL-04_unknown_field_silently_ignored_with_otel_attr. |
Quarterly re-validation against upstream (manually or via
cmd/pdb-fixture-port/ --check) is advisory only per Phase 72
CONTEXT.md D-03 — a drift alert does not block merges. Drift that
invalidates a Validation Note row should be surfaced as a GitHub
issue and reviewed against the parity test suite; if upstream has
changed semantics, update the row here and flip or retain the
matching parity assertion as a new § Known Divergences row.
pdbcompat resolves <fk>__<field> and <fk>__<fk>__<field> filter
paths through two mechanisms, both driven by codegen from ent schema
annotations at go generate time:
- Path A — per-serializer allowlists. Mirrors upstream
peeringdb_server/serializers.pyprepare_query(...)/get_relation_filters(...)lists verbatim. Generated from ent schemapdbcompat.WithPrepareQueryAllow(...)annotations viacmd/pdb-compat-allowlist; emitted intointernal/pdbcompat/allowlist_gen.go. This is the "explicitly blessed" set of filter keys — every entry carries a// serializers.py:<line>comment anchoring it to upstream. - Path B — ent edge introspection. When a filter key does not
match Path A, the parser consults the generated
Edgesmap (also emitted intoallowlist_gen.go). Every non-excluded FK edge auto-exposes<fk>__<field>for any filterable field on the target entity. Mirrors upstreamqueryable_relations(). Resolution uses a codegen-time static map — no runtime ent-client introspection, nosync.Once, no init-order coupling (Phase 70 D-02 as amended 2026-04-19).
The resolution order is implemented in
internal/pdbcompat/filter.go buildTraversalPredicate: Path A
first; on a soft miss (allowlist hit but downstream introspection
unavailable) the parser falls through to Path B rather than
suppressing the key (Phase 70 REVIEW WR-03). parseFieldOp returns
the 3-tuple (relationSegments, finalField, op) so the same machinery
serves 1-hop and 2-hop paths with a single split (Phase 70 D-06).
All 13 entity types support Path A 1-hop shapes via
?<fk>__<field>=X. The 2-hop subset tracks upstream
pdb_api_test.py:
| Query | Hops | Path | Upstream citation |
|---|---|---|---|
?org__name=X (net, fac, ix, carrier, campus) |
1 | A | serializers.py:1823, 2244, 2361, 2423, 2573, 2732, 2947, 2995, 3315, 3451, 3622, 3925, 4041 |
?net__asn=X (netfac, netixlan, poc) |
1 | A | (same allowlist block) |
?ix__name=X (ixfac, ixlan, ixpfx) |
1 | A | (same allowlist block) |
?fac__name=X (netfac, ixfac, carrierfac) |
1 | A | (same allowlist block) |
?ixlan__ix__fac_count__gt=0 (fac) |
2 | A | pdb_api_test.py:2340, 2348 |
?ixlan__ix__id=N (fac) |
2 | A | pdb_api_test.py:5081 |
?<fk>__<field>=X for any non-excluded edge |
1 | B | serializers.py:754-780 (queryable_relations()) |
1-hop Path B fallthrough means the explicit Path A allowlists are additive, not restrictive: a key that is not in Path A but is a valid ent FK edge still resolves via Path B. The exclusion list (below) is the only way to block a Path B key.
The pdbcompat.WithFilterExcludeFromTraversal() ent edge annotation
(Phase 70 D-03) hides specific edges from Path B traversal. Mirrors
upstream serializers.py:128-157.
| Entity | Edge | Reason |
|---|---|---|
| (none — all FK edges exposed in v1.16) | — | Initial release. Phase 64's field-level privacy (ixlan.ixf_ixp_member_list_url_visible) operates at the serializer layer, not the edge layer, so no edge exclusion is required for the v1.16 surface. Future OAuth-gated relations (post-VIS-08 OAuth work) will populate this table. |
Filter keys with more than 2 __-separated relation segments are
silently ignored. Examples:
?org__name=X— 1 hop, resolves via Path A (every primary entity).?ixlan__ix__fac_count__gt=0— 2 hops, resolves via Path A (upstreampdb_api_test.py:2340assertion).?ixlan__ix__org__name=X— 3 hops, SILENTLY IGNORED (HTTP 200, result set is unfiltered).
Upstream PeeringDB has no hard cap but is bound by Django ORM's query
planner. We trade limitless traversal for a predictable cost ceiling
gated in CI at <50ms/op @ 10k rows via
internal/pdbcompat/bench_traversal_test.go (Phase 70 D-07). If a
legitimate 3-hop use case emerges, raise the cap together with a
fresh benchstat run and a docs update here.
When a filter key fails Path A, Path B, and the 2-hop cap check, the following observability signals fire:
slog.DebugContext(ctx, "pdbcompat: unknown filter fields silently ignored (Phase 70 TRAVERSAL-04)", slog.String("endpoint", ...), slog.String("type", ...), slog.Any("unknown_fields", ...))- OTel span attribute
pdbplus.filter.unknown_fields(CSV of all unknown keys in the request)
Both are DEBUG-level; INFO and higher are untouched so that naive
clients probing field names don't flood structured logs. To surface
these in production, set OTEL_LOG_LEVEL=debug or query the span
attribute in Grafana/Tempo.