Identifies IX members that are connected to the exchange fabric but missing from PeeringDB, and emails them a professional request to fix it — either by enabling Allow IXP Update or by creating/adding their PeeringDB listing. The first contact is an "initial" email; follow-ups are "reminder" emails. Every send is tracked in SQLite, so reminders never double-up and members who resolve their listing are dropped automatically.
An Internet Exchange operator can see exactly who is plugged into the fabric (via its IXP Manager), but PeeringDB — the database the rest of the internet uses to find peers — only shows networks that have listed themselves. The gap between those two lists is a set of members who are reachable on the exchange but invisible to potential peers.
This tool closes that gap:
- Pulls the authoritative member list (with contact emails and on-fabric details) from IXP Manager's keyed IX‑F export.
- Asks PeeringDB who is already listed at the exchange, plus per-network flags.
- Computes the missing members and picks the right call-to-action for each:
- Enable update — the network already has a PeeringDB record, so it just needs to flip on Allow IXP Update (or add the exchange manually).
- Create profile — the network has no PeeringDB record yet and must create one first.
- Sends one tailored email per missing ASN through Mailgun, and records every attempt in SQLite for deduplication and reminder scheduling.
Re-runs are safe and idempotent: a normal run only contacts members that have never been emailed; already-contacted members are re-contacted only through an explicit reminder run after the configured interval.
IXP Manager (IX-F export) PeeringDB
contact emails, who is already listed
IPv4/IPv6, port speed + per-net flags
\ /
\ /
v v
reconcile() = IX-F members
− PeeringDB-listed ASNs
− EXCLUDED_ASNS
|
v
select send batch
(pending / due-for-reminder)
|
v
render Handlebars templates
(subject / text / html)
|
v
Mailgun messages API
|
v
SQLite (member_state + send_log)
Reconciliation rules:
missing = IX-F members − PeeringDB-listed ASNs − EXCLUDED_ASNS
recipient = overrides[asn] ?? IX-F contact_email ?? (none → status 'no_contact')
variant = has PeeringDB net record ? 'enable_update' : 'create_profile'
Reconciliation is pure (no writes); persistence upserts each missing member and marks any
previously-tracked ASN that now appears in PeeringDB as resolved.
- Bun (developed against Bun 1.3.x). Bun runs the TypeScript sources directly — there is no build step.
- A Mailgun account (API key + sending domain).
- An IXP Manager instance with an API key that unlocks the keyed IX‑F export (contact emails).
- A PeeringDB API key.
bun installCopy the example env file and fill in your values. Secrets live in a gitignored .env, which
Bun auto-loads.
cp .env.example .env| Variable | Default | Required | Notes |
|---|---|---|---|
OIX_PEERING_DB_REMINDER |
test |
no | Send blocker. prod emails real members; any other value (or unset) is test mode. |
TEST_RECIPIENT |
you@example.org |
no | Where all mail is redirected in test mode. |
IXP_MANAGER_BASE_URL |
http://ixp-manager.example |
no | IXP Manager host. |
IXP_MANAGER_API_KEY |
— | yes | Sent as X-IXP-Manager-API-Key; unlocks contact emails in the IX‑F export. |
PEERINGDB_API_KEY |
— | yes | Sent as Authorization: Api-Key <key>. |
PEERINGDB_IX_ID |
4727 |
no | PeeringDB IX id to reconcile against. |
MAILGUN_API_KEY |
— | yes | HTTP Basic api:<key>. |
MAILGUN_DOMAIN |
example.org |
no | Mailgun sending domain. |
MAILGUN_BASE_URL |
https://api.mailgun.net |
no | US region. EU region: https://api.eu.mailgun.net. |
MAIL_FROM |
OpenIX <openix-noreply@example.org> |
no | From header. |
MAIL_REPLY_TO |
OpenIX NOC <noc@example.org> |
no | Set as h:Reply-To. |
MAIL_CC |
noc@example.org |
no | CC on prod sends only; suppressed in test mode. Empty disables. |
EXCLUDED_ASNS |
4969,8097,212232,401714,399728,400786 |
no | ASNs to never contact (route servers, collectors, IX/infra ASNs). |
OVERRIDES_PATH |
./overrides.json |
no | Optional ASN→email override file (see below). |
DB_PATH |
./data/oixPdbReminder.db |
no | SQLite database path (WAL mode). |
REMINDER_INTERVAL_DAYS |
7 |
no | Minimum age before a contacted member is due for a reminder. |
SEND_THROTTLE_SECONDS |
15 |
no | Pause between each email (Mailgun rate-limit pacing; also paces after failures). |
If a network's IX‑F contact email is wrong or missing, you can override it. Copy
overrides.example.json to overrides.json (or wherever OVERRIDES_PATH points) and map ASNs
to one or more addresses:
{
"64500": ["peering@example.net"],
"64501": ["noc@example.com", "ipeng@example.com"]
}Overrides take precedence over the IX‑F contact email.
OIX_PEERING_DB_REMINDER gates real delivery — this is the primary safety mechanism:
prod→ emails go to each member's real contact address (and CCMAIL_CC, if set).- anything else / unset → test mode → every message is redirected to
TEST_RECIPIENT. The subject is prefixed[TEST AS<n> -> <intended>], and the originally intended address is still written to the audit log.MAIL_CCis suppressed.
Always start in test mode. Switch to prod only after reviewing a dry run.
bun run start # initial run: reconcile + email 'pending' members
bun run start --dry-run # reconcile + print the report ONLY (no DB writes, no emails)
bun run start --reminders # email members contacted >= REMINDER_INTERVAL_DAYS ago
bun run start --asn 64500 # target ONE network (great for testing a single email)
bun run start --dry-run --asn 64500 # preview just that one network in the report
bun run start --help
bun run typecheck # tsc --noEmit (type-check only)
bun run dev # run with --watch--dry-run— Compute and print the missing-member report (counts + per-ASN table). No database writes, no emails. Use this to review who would be contacted.--reminders— Send follow-up reminders to members already contacted at leastREMINDER_INTERVAL_DAYSago. Without this flag, a run sends the initial email to newly foundpendingmembers only.--asn <list>— Restrict the run to specific ASNs (comma-separated;ASprefix optional, e.g.--asn AS64500,64501). Selects by ASN regardless of prior contact status, so it can be re-run repeatedly. Ideal for testing a single network. Still subject to the send blocker.--help,-h— Show usage.
--asnand campaign state: A--asnsend while not in prod is a preview send — it is written tosend_log(taggedmode=test) but does not advance the member tocontacted, so that network still receives its normal email in the real campaign run. In prod mode,--asnbehaves as a real targeted send and tracks state normally.
bun run start --dry-run— review the recipient list.- Optionally test a single network:
bun run start --asn <one-asn>(still test mode → goes toTEST_RECIPIENT). - Set
OIX_PEERING_DB_REMINDER=prodand runbun run startfor real.
The script is a one-shot — it has no internal scheduler. Drive the reminder cadence externally,
e.g. a weekly cron that runs bun run start --reminders.
src/
index.ts # CLI entry: arg parsing, run flow, report + summary output
config.ts # env loading + validation (zod), test/prod mode resolution
reconcile.ts # pure reconcile() + persistReconciliation() + selectSendBatch()
db/
schema.ts # bun:sqlite init (WAL), table DDL
reminders.ts # ReminderRepository (member_state + send_log access)
services/
ixpManager.ts # keyed IX-F export client (member contact emails + fabric details)
peeringdb.ts # PeeringDB client (who is listed + per-net flags)
mailgun.ts # Mailgun messages API client
overrides.ts # optional ASN→email[] override loader
notifications/
email.ts # builds the per-member view-model
templateRenderer.ts # Handlebars renderer (escaping chosen by filename)
sender.ts # sendBatch(): render, redirect in test, send, log, record
templates/
email.subject.hbs # subject line
email.text.hbs # plain-text body
email.html.hbs # HTML body
types/
domain.ts # core domain types (statuses, member/send shapes)
ixf.ts # IX-F export response types
peeringdb.ts # PeeringDB response types
- Member contact emails — IXP Manager keyed IX‑F export (authoritative):
GET {IXP_MANAGER_BASE_URL}/api/v4/member-export/ixf/1.0with the API-key header. This keyed export includes per-member contact email plus each member's IPv4/IPv6 and port speed. (Public IX‑F copies typically strip contact data, so they are not used.) - Who is already in PeeringDB —
GET /api/netixlan?ix_id=<PEERINGDB_IX_ID>for the set of listed ASNs, andGET /api/net?asn__in=<csv>for per-member flags (has_pdb_net,allow_ixp_update) that select the email variant. - Mailgun messages API for delivery.
SQLite (via bun:sqlite, WAL mode) at DB_PATH. It is the deduplication and reminder ledger —
don't wipe or alter the schema/history without care.
member_state— one row per ASN: contact, IPs, speed,has_pdb_net,allow_ixp_update,status,send_count,first_contacted_at,last_contacted_at. Upserts preserve send tracking and never downgradecontacted/resolvedback topending.send_log— append-only audit: one row per send attempt withintended_email,actual_email,type,mode,mailgun_id,status,error.
Member lifecycle status:
| Status | Meaning |
|---|---|
pending |
Missing from PeeringDB, has a contact email, not yet emailed. |
contacted |
Has been emailed; eligible for reminders after the interval. |
no_contact |
Missing from PeeringDB but no contact email could be resolved (manual follow-up). |
resolved |
Now listed in PeeringDB; dropped from future sends. |
Email content lives in Handlebars templates under src/templates/ — edit these to change
wording, with no code changes required:
email.subject.hbs— subject lineemail.text.hbs— plain-text bodyemail.html.hbs— HTML body
The renderer picks escaping by filename: *.html.hbs is HTML-escaped, while *.text.hbs and
*.subject.hbs render with noEscape so quotes/ampersands stay literal in plain text. One email
is sent per ASN with both text and HTML parts.
Available template variables include: isCreate, isReminder, networkName, asn, ixpName,
ixpPeeringdbId, pdbBaseUrl, pdbIxUrl, ipv4, ipv6, ipv4Display, ipv6Display,
speedLabel, ipv4Prefix, nocEmail, and year. Standalone {{#if ...}} lines are
whitespace-stripped so conditional rows don't leave blank lines in the text body.
Branding for the HTML email (logo, footer text, links, addresses) is hardcoded in
email.html.hbs/email.text.hbs— edit there. Templates are read from disk at runtime viaimport.meta.url(no build step). If the project is ever bundled, copysrc/templates/alongside the output.
- Load and validate config (logs PROD vs TEST); load overrides.
reconcile()→ print the report (counts + per-ASN table).--dry-runstops here.- Initialize the database → persist the reconciliation (upserts + marks resolved members).
- Select the send batch. Eligibility is the live
missingset (the same source--dry-runprints), so excluded ASNs, members now listed in PeeringDB, and members that left the IX are never selected. The DB supplies only the dedup status (pendingfor an initial run, or contacted-before-cutoff for--reminders). - Send: render, redirect in test mode, CC on prod sends, deliver via Mailgun, then log + record
contact. Paces
SEND_THROTTLE_SECONDSbefore every attempt after the first (so a failed or rate-limited send is also followed by a wait). Failures staypending/contactedfor the next run. - Print a summary (
attempted/sent/failed/skipped, plus theno_contactcount).
- No build step. Bun runs the
.tssources directly. Imports use.jsESM specifiers, and Bun resolves./x.jsto a realx.jsif one exists — so a straytscemit next to the sources would silently shadow your.ts.tsconfig.jsonsets"noEmit": trueand.gitignoreignoressrc/**/*.jsto prevent this. Never commit compiled.jsintosrc/. - Type-check with
bun run typecheck(never a baretsc).
ISC.