Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/moshpit-dns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
81 changes: 81 additions & 0 deletions lib/dns/instances.ts
Original file line number Diff line number Diff line change
@@ -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<Message | null> };

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<string,{until:number;value:any}>(), inflight = new Map<string,Promise<any>>();
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<Message|null>{
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};
}
9 changes: 9 additions & 0 deletions lib/dns/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -49,6 +50,7 @@ export type QueryContext = {

export type DnsServerOptions = {
registry: RegistryClient;
instances?: InstanceResolver;
forwarder: Forwarder;
gateway: GatewayResolver;
mode?: ResolveMode;
Expand Down Expand Up @@ -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));
Expand Down
6 changes: 6 additions & 0 deletions scripts/moshpit-dns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions tests/dns-instances.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading