diff --git a/docs/moshpit-dns.md b/docs/moshpit-dns.md index c561acf..dc07ec1 100644 --- a/docs/moshpit-dns.md +++ b/docs/moshpit-dns.md @@ -261,3 +261,11 @@ internet is unaffected: an outage here costs the namespace, not the web. Code: `lib/dns/` (`wire.ts` codec, `policy.ts` the namespace decision, `registry.ts`, `upstream.ts`, `answers.ts`, `server.ts`, `doh.ts`), entry point `scripts/moshpit-dns.ts`, tests in `tests/dns-*.test.mjs`. + +### Separate Moshpit instances + +`MOSHPIT_INSTANCES` is a JSON list of explicit realm authorities. The default contains W3BS: `[{"suffix":"w3bs.org","registry":"https://w3bs.org","gateway":"w3bs.org"}]`. Set `[]` to disable instance routing. Each configured registry must be HTTPS; query text never chooses the registry URL. Optional `ipv4`/`ipv6` arrays pin that instance's gateway addresses. + +Only two-label names under the configured suffix (for example `dumb.fuck.w3bs.org`) enter that instance. Bare `dumb.fuck` remains in the primary network. Active instance records override public parking inside this operator-configured realm. AAAA, CNAME, MX and TXT come from the instance's records API; otherwise address queries use its gateway. Suspended names return NXDOMAIN, an unavailable authoritative instance returns SERVFAIL, and neither condition transfers ownership to the primary registry. CNAME recursion is bounded. Instance reads are coalesced and cached for two seconds; existing downstream caches honor published TTL. + +This provides network DNS, not public registrar records or publicly trusted certificates. Do not report a public HTTPS gateway ready based on a successful DNS query alone. diff --git a/lib/dns/instances.ts b/lib/dns/instances.ts new file mode 100644 index 0000000..34c99dd --- /dev/null +++ b/lib/dns/instances.ts @@ -0,0 +1,81 @@ +/** Explicit Moshpit instance realms. Never infer authority from a query or import ownership. */ +import { CLASS, TYPE, RCODE, encodeName, type Message, type ResourceRecord } from './wire'; +import { soaFor } from './answers'; +import type { GatewayResolver } from './gateway'; +export type InstanceRealm = { suffix: string; registry: string; gateway: GatewayResolver }; +export type InstanceResolver = { answer(query: Message): Promise }; + +export function createInstanceResolver(realms: InstanceRealm[], options: { fetchImpl?: typeof fetch; now?: ()=>number; cacheMs?: number } = {}): InstanceResolver { + const fetchImpl = options.fetchImpl ?? fetch, now = options.now ?? Date.now; + const cache = new Map(), inflight = new Map>(); + const configured = realms.map(r=>{ + const suffix=r.suffix.toLowerCase().replace(/\.$/,''); + const url=new URL(r.registry); + if(url.protocol!=='https:' || url.username || url.password || !/^[a-z0-9.-]+$/.test(suffix)) throw Error('Invalid instance realm'); + return {...r,suffix,registry:url.origin}; + }).sort((a,b)=>b.suffix.length-a.suffix.length); + async function load(realm:InstanceRealm,name:string) { + const key=`${realm.registry}:${name}`, hit=cache.get(key); + if(hit && hit.until>now())return hit.value; + if(inflight.has(key))return inflight.get(key); + const job=(async()=>{ + const read=async(path:string)=>{ + const response=await fetchImpl(`${realm.registry}${path}`,{headers:{accept:'application/json'},redirect:'error',signal:AbortSignal.timeout(2000)}); + if(response.status===404)return null; + if(!response.ok)throw Error('Instance unavailable'); + return response.json(); + }; + const result=await read(`/api/net/v1/resolve/${encodeURIComponent(name)}`) as any; + if(!result)return null; + const records=await read(`/api/net/v1/dns/records?name=${encodeURIComponent(name)}`) as any; + const value={...result,records:Array.isArray(records?.records)?records.records:[]}; + if(cache.size>=10000)cache.delete(cache.keys().next().value!); + cache.set(key,{until:now()+(options.cacheMs??2000),value}); + return value; + })().finally(()=>inflight.delete(key)); + inflight.set(key,job);return job; + } + async function answer(query:Message,depth=0):Promise{ + if(query.questions.length!==1 || query.flags.opcode!==0 || query.questions[0].class!==CLASS.IN)return null; + const question=query.questions[0], host=question.name.toLowerCase().replace(/\.$/,''); + const realm=configured.find(r=>host.endsWith(`.${r.suffix}`)); + if(!realm)return null; + const name=host.slice(0,-realm.suffix.length-1); + if(!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name))return null; + const message:Message={id:query.id,flags:{...query.flags,qr:true,aa:true,ra:true,ad:false,rcode:0},questions:query.questions,answers:[],authorities:[],additionals:[]}; + let record:any; + try{record=await load(realm,name);}catch{message.flags.rcode=RCODE.SERVFAIL;return message;} + if(!record)return null; + const gateway=await realm.gateway.addresses().catch(()=>realm.gateway.current()); + if(!['active','backfill','federated'].includes(record.status)){ + message.flags.rcode=RCODE.NXDOMAIN;message.authorities=[soaFor(realm.suffix,gateway,5)];return message; + } + const records=record.records.slice(0,100), cname=records.find((r:any)=>r.type==='CNAME'); + const ttl=(r:any)=>Math.min(86400,Math.max(1,Number(r.ttl)||60)); + const base=(r:any):ResourceRecord=>({name:host,type:question.type,class:CLASS.IN,ttl:ttl(r)}); + if(cname){ + if(cname.value===host){message.flags.rcode=RCODE.SERVFAIL;return message;} + message.answers.push({...base(cname),type:TYPE.CNAME,target:cname.value}); + if((question.type===TYPE.A || question.type===TYPE.AAAA) && depth>=8){message.flags.rcode=RCODE.SERVFAIL;return message;} + if((question.type===TYPE.A || question.type===TYPE.AAAA) && depth<8 && cname.value!==host){ + const target=await answer({...query,questions:[{...question,name:cname.value}]},depth+1); + if(target){message.flags.rcode=target.flags.rcode;message.answers.push(...target.answers);} + } + }else if(question.type===TYPE.A || question.type===TYPE.AAAA){ + const addresses=records.filter((r:any)=>r.type==='AAAA'); + if(addresses.length){if(question.type===TYPE.AAAA)for(const r of addresses)message.answers.push({...base(r),address:r.value});} + else for(const address of question.type===TYPE.A?gateway.ipv4:gateway.ipv6)message.answers.push({name:host,type:question.type,class:CLASS.IN,ttl:30,address}); + }else if(question.type===TYPE.TXT){ + for(const r of records.filter((r:any)=>r.type==='TXT')){ + const chunks:string[]=[];let chunk=''; + for(const ch of String(r.value)){if(Buffer.byteLength(chunk+ch)>255){chunks.push(chunk);chunk='';}chunk+=ch;} + chunks.push(chunk);message.answers.push({...base(r),txt:chunks}); + } + }else if(question.type===TYPE.MX){ + for(const r of records.filter((r:any)=>r.type==='MX')){const priority=Buffer.alloc(2);priority.writeUInt16BE(Number(r.priority)||0);message.answers.push({...base(r),rdata:Buffer.concat([priority,encodeName(r.value)])});} + }else if(question.type===TYPE.SOA)message.answers.push(soaFor(realm.suffix,gateway,30)); + if(!message.answers.length)message.authorities=[soaFor(realm.suffix,gateway,5)]; + return message; + } + return {answer}; +} diff --git a/lib/dns/server.ts b/lib/dns/server.ts index dceb866..cdcb466 100644 --- a/lib/dns/server.ts +++ b/lib/dns/server.ts @@ -5,6 +5,7 @@ // policy serves plain DNS, DNS over TCP, and DNS over HTTPS without three // copies of the decision-making that could drift apart. +import type { InstanceResolver } from './instances'; import dgram from "node:dgram"; import net from "node:net"; @@ -49,6 +50,7 @@ export type QueryContext = { export type DnsServerOptions = { registry: RegistryClient; + instances?: InstanceResolver; forwarder: Forwarder; gateway: GatewayResolver; mode?: ResolveMode; @@ -344,6 +346,13 @@ export function createDnsServer(options: DnsServerOptions): DnsServer { return finish(`refused(${plan.reason})`, errorResponse(query, plan.rcode)); } + const instance = await options.instances?.answer(query); + if (instance) { + if (instance.flags.rcode === 0) await completeCnameChain(instance, question); + stats.moshpit++; + return finish("instance", encodeMessage(instance)); + } + if (plan.action === "forward") { stats.forwarded++; return finish("forwarded", await forwardQuery(rawQuery, ctx)); diff --git a/scripts/moshpit-dns.ts b/scripts/moshpit-dns.ts index c52283b..d4e3dba 100644 --- a/scripts/moshpit-dns.ts +++ b/scripts/moshpit-dns.ts @@ -11,6 +11,7 @@ // with no configuration at all this resolves Moshpit names from the public // registry and forwards everything else to 8.8.8.8 and 1.1.1.1. +import { createInstanceResolver } from "../lib/dns/instances"; import { createDohServer } from "../lib/dns/doh"; import { createGatewayResolver } from "../lib/dns/gateway"; import { DEFAULT_RESOLVE_MODE, type ResolveMode } from "../lib/dns/policy"; @@ -95,7 +96,12 @@ const DEFAULT_BURST = 600; // decision rather than a default a resolver should assume. const catchAll = /^(1|true|yes|on)$/i.test(env.MOSHPIT_DNS_CATCHALL ?? ""); +const instanceConfig = JSON.parse(env.MOSHPIT_INSTANCES ?? '[{"suffix":"w3bs.org","registry":"https://w3bs.org","gateway":"w3bs.org"}]'); +const instances = createInstanceResolver(instanceConfig.map((r: {suffix:string;registry:string;gateway:string;ipv4?:string[];ipv6?:string[]}) => ({ + suffix:r.suffix, registry:r.registry, gateway:createGatewayResolver({host:r.gateway,forwarder,ipv4:r.ipv4??[],ipv6:r.ipv6??[]}), +}))); const dns = createDnsServer({ + instances, registry, forwarder, gateway, diff --git a/tests/dns-instances.test.mjs b/tests/dns-instances.test.mjs new file mode 100644 index 0000000..cbc21e8 --- /dev/null +++ b/tests/dns-instances.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createInstanceResolver} from '../lib/dns/instances.ts'; +import {createDnsServer} from '../lib/dns/server.ts'; +import {CLASS,TYPE,RCODE,encodeMessage,decodeMessage} from '../lib/dns/wire.ts'; +const query=(name,type=TYPE.A)=>({id:123,flags:{qr:false,rd:true,opcode:0},questions:[{name,type,class:CLASS.IN}],answers:[],authorities:[],additionals:[]}); +const gateway={addresses:async()=>({host:'w3bs.org',ipv4:['23.95.228.174'],ipv6:[]}),current:()=>({host:'w3bs.org',ipv4:['23.95.228.174'],ipv6:[]})}; +function setup(){ + const state={status:'active',records:[],fail:false};const urls=[]; + const instances=createInstanceResolver([{suffix:'w3bs.org',registry:'https://w3bs.org',gateway}],{cacheMs:0,fetchImpl:async url=>{ + urls.push(String(url));if(state.fail)throw Error('down'); + return Response.json(String(url).includes('/dns/records?')?{records:state.records}:{name:'dumb.fuck',source:'w3bs',status:state.status,target:'https://fumb.duck.w3bs.org/'}); + }}); + return {state,urls,instances}; +} +test('a W3BS hostname is authoritative in its configured realm, not primary-network ownership',async()=>{ + const h=setup();let forwarded=0,primary=0; + const server=createDnsServer({instances:h.instances,registry:{lookup:async()=>{primary++;return null;}},gateway,forwarder:{close(){},query:async bytes=>{forwarded++;const q=decodeMessage(bytes);return encodeMessage({...q,flags:{...q.flags,qr:true,ra:true,rcode:0},answers:[{name:q.questions[0].name,type:TYPE.A,class:CLASS.IN,ttl:300,address:'207.207.210.107'}]});}}}); + const response=decodeMessage(await server.handle(encodeMessage(query('dumb.fuck.w3bs.org')))); + assert.equal(response.answers[0].address,'23.95.228.174');assert.equal(forwarded,0);assert.equal(primary,0); + assert(h.urls.every(u=>u.startsWith('https://w3bs.org/'))); + assert.equal(await h.instances.answer(query('dumb.fuck')),null); + assert.equal(await h.instances.answer(query('dumb.fuck.other-instance.org')),null); + await server.close(); +}); +test('AAAA, MX, TXT and CNAME records answer without a zone-copy or ownership merge',async()=>{ + const h=setup();h.state.records=[{type:'AAAA',value:'2606:4700:4700::1111',ttl:300},{type:'MX',value:'mx.example.com',priority:10,ttl:120},{type:'TXT',value:'é'.repeat(300),ttl:60}]; + assert.equal((await h.instances.answer(query('dumb.fuck.w3bs.org',TYPE.AAAA))).answers[0].address,'2606:4700:4700::1111'); + assert.equal((await h.instances.answer(query('dumb.fuck.w3bs.org',TYPE.A))).answers.length,0); + const mx=decodeMessage(encodeMessage(await h.instances.answer(query('dumb.fuck.w3bs.org',TYPE.MX)))); + assert.equal(mx.answers[0].rdata.readUInt16BE(0),10); + const txt=decodeMessage(encodeMessage(await h.instances.answer(query('dumb.fuck.w3bs.org',TYPE.TXT)))); + assert.equal(txt.answers[0].txt.join(''),'é'.repeat(300)); + h.state.records=[{type:'CNAME',value:'target.example.com',ttl:60}]; + assert.equal((await h.instances.answer(query('dumb.fuck.w3bs.org',TYPE.CNAME))).answers[0].target,'target.example.com'); +}); +test('suspension and provider failure cannot fall through to a parked or primary name',async()=>{ + const h=setup();h.state.status='suspended';assert.equal((await h.instances.answer(query('dumb.fuck.w3bs.org'))).flags.rcode,RCODE.NXDOMAIN); + h.state.fail=true;assert.equal((await h.instances.answer(query('dumb.fuck.w3bs.org'))).flags.rcode,RCODE.SERVFAIL); + h.state.fail=false;h.state.status='active';h.state.records=[{type:'CNAME',value:'another.fuck.w3bs.org',ttl:60}]; + assert.equal((await h.instances.answer(query('dumb.fuck.w3bs.org'))).flags.rcode,RCODE.SERVFAIL); +});