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/w3bs-net.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,11 @@ This event feed is a W3BS Net protocol surface inspired by relay and repository
## Domain routing

An active name like `riotcoder.agent` resolves through `riotcoder.agent.w3bs.org`. Configure `W3BS_NET_HOSTED_TLDS` and a DNS wildcard, edge proxy host and TLS certificate for every suffix served here (the launch includes `*.whatever.w3bs.org`, `*.agent.w3bs.org` and `*.hacker.w3bs.org` for Moshpit backfill). If a name has a public HTTPS target, W3BS redirects to it. Otherwise the host serves the W3BS name record. Imported Moshpit names redirect to their source gateway when no local W3BS registration overrides them.

## Instance DNS records

W3BS is an independent Moshpit instance: W3BS names, owner ids and DNS writes stay in its database. Primary Moshpit records are read-only discovery backfill, not a grant to edit W3BS names. `/net/records` follows the primary Moshpit records workflow: filter names owned by the signed-in local account, publish AAAA/CNAME/MX/TXT with TTL and MX priority, inspect public values and remove individual records. Pending or suspended names/namespaces cannot publish. CNAME is exclusive with other types. The public read API is `/api/net/v1/dns/records?name=label.namespace`; authenticated mutations are `POST /api/net/v1/names/:name/records` and `DELETE /api/net/v1/names/:name/records/:id`. The existing W3BS session or OAuth bearer authenticates writes.

A configured Moshpit resolver maps the W3BS realm `label.namespace.w3bs.org` to this instance, querying its resolve and records APIs. It does not treat bare `label.namespace` on the primary network as the same ownership. Active instance records are authoritative within that explicitly configured realm, including over public parking answers; inactive local names cannot be revived through primary-network fallback. A name with no custom address records goes to the W3BS gateway so its existing HTTPS destination can redirect there. Custom AAAA or CNAME records override the gateway address; TXT/MX coexist with its default address. DNS changes publish immediately in the instance; cached resolver/browser answers still follow TTL.

DNS answers and HTTPS certificates are separate rollout gates. Standard browser access to every new `*.namespace.w3bs.org` requires public DNS and a publicly trusted certificate for that suffix. A generic `*.w3bs.org` certificate does not cover two labels. The primary Moshpit CA intentionally refuses public TLDs and must not be changed to mint arbitrary `.org` certificates to work around missing registrar credentials. Resolver routing is not evidence that public DNS or HTTPS is ready. Preserve that distinction when reporting a new name live.
57 changes: 57 additions & 0 deletions public/net-records.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const root = document.querySelector('#dns-names');
const status = document.querySelector('#dns-status');
let names = [];
const el = (tag,text,props={}) => Object.assign(document.createElement(tag),{...(text===null?{}:{textContent:text}),...props});
const api = async(path,init={}) => {
const response=await fetch(path,{...init,headers:{accept:'application/json','content-type':'application/json'},cache:'no-store'});
const body=await response.json();
if(!response.ok)throw Error(body.error?.message||'The request failed. Please retry.');
return body;
};
const help={AAAA:'IPv6 address, for example 2606:4700:4700::1111',CNAME:'Hostname only, for example box.example.com',MX:'Mail hostname, for example mx.example.com; lower priority wins',TXT:'Verification text or a key, up to 1024 bytes'};
function render(){
root.replaceChildren();
const query=document.querySelector('#dns-filter').value.trim().toLowerCase();
const match=n=>query.endsWith('.*')?n.name.startsWith(query.slice(0,-1)):n.name.includes(query);
const visible=names.filter(match);
status.textContent=`${visible.length} name${visible.length===1?'':'s'} in your W3BS account.`;
for(const item of visible){
const card=el('article',null,{className:'net-dns-card'});
card.append(el('h2',item.name),el('p',`${item.name}.w3bs.org`,{className:'net-help'}));
if(item.status!=='active'||item.namespace_status!=='active'){
card.append(el('p',`DNS editing waits until both the name and namespace are active. Name: ${item.status}; namespace: ${item.namespace_status}.`,{className:'net-status'}));root.append(card);continue;
}
const table=el('table',null,{className:'net-dns-table'}),head=el('tr',null);
for(const title of ['Type','Value','TTL','Priority',''])head.append(el('th',title));
const thead=el('thead',null);thead.append(head);table.append(thead);
const tbody=el('tbody',null);table.append(tbody);
const feedback=el('p','',{className:'net-status'});feedback.setAttribute('role','status');feedback.setAttribute('aria-live','polite');
const perform=async(button,fn)=>{button.disabled=true;feedback.textContent='Saving…';feedback.classList.remove('net-error');try{const data=await fn();item.records=data.records;drawRows();feedback.textContent='Published to this W3BS instance. Resolver caches follow the record TTL.';}catch(error){feedback.textContent=error.message;feedback.classList.add('net-error');}finally{button.disabled=false;}};
const path=`/api/net/v1/names/${encodeURIComponent(item.name)}/records`;
const drawRows=()=>{
tbody.replaceChildren();
for(const record of item.records){
const row=el('tr',null);for(const value of [record.type,record.value,`${record.ttl}s`,record.type==='MX'?record.priority:'—'])row.append(el('td',String(value)));
const td=el('td',null),button=el('button','Remove',{type:'button',className:'button'});
button.setAttribute('aria-label',`Remove ${record.type} ${record.value} from ${item.name}`);
button.addEventListener('click',()=>perform(button,()=>api(`${path}/${encodeURIComponent(record.id)}`,{method:'DELETE'})));
td.append(button);row.append(td);tbody.append(row);
}
if(!item.records.length){const row=el('tr',null),td=el('td','No custom records. The W3BS gateway serves this name by default.');td.colSpan=5;row.append(td);tbody.append(row);}
};
drawRows();
const scroll=el('div',null,{className:'net-dns-scroll'});scroll.append(table);card.append(scroll);
const form=el('form',null,{className:'net-dns-form'});
const field=(label,input)=>{input.setAttribute('aria-label',label);const wrapper=el('label',label);wrapper.append(input);form.append(wrapper);return wrapper;};
const type=el('select',null,{name:'type'});for(const t of Object.keys(help))type.append(el('option',t,{value:t}));field('Type',type);
const value=el('input',null,{name:'value',required:true,maxLength:1024,placeholder:help.AAAA});field('Value',value);
const ttl=el('input',null,{name:'ttl',type:'number',value:'300',min:'60',max:'86400',required:true});field('TTL (seconds)',ttl);
const priority=el('input',null,{name:'priority',type:'number',value:'10',min:'0',max:'65535'});const priorityLabel=field('MX priority',priority);priorityLabel.hidden=true;
const button=el('button','Publish record',{type:'submit',className:'button primary'});form.append(button);
type.addEventListener('change',()=>{value.placeholder=help[type.value];priorityLabel.hidden=type.value!=='MX';});
form.addEventListener('submit',async event=>{event.preventDefault();await perform(button,()=>api(path,{method:'POST',body:JSON.stringify({type:type.value,value:value.value,ttl:Number(ttl.value),priority:Number(priority.value)})}));});
card.append(form,feedback);root.append(card);
}
}
document.querySelector('#net-dns-filter').addEventListener('submit',event=>{event.preventDefault();render();});
try{names=(await api('/api/net/v1/dns/mine')).names;render();}catch(error){status.textContent=error.message;status.classList.add('net-error');}
2 changes: 2 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -2966,3 +2966,5 @@ footer.pfs-footer p {
padding: 18px;
}
}

.net-tabs{display:flex;gap:20px;flex-wrap:wrap;border-bottom:1px solid var(--line,#333);padding:18px 0}.net-tabs [aria-current=page]{font-weight:700;text-decoration:underline;text-underline-offset:8px}.net-dns-card{border-top:1px solid #424a3b;margin-top:28px;padding:24px 0}.net-dns-scroll{overflow-x:auto}.net-dns-table{border-collapse:collapse;width:100%;text-align:left;font-size:13px}.net-dns-table th,.net-dns-table td{padding:12px 8px;border-bottom:1px solid #424a3b;overflow-wrap:anywhere}.net-dns-table td:nth-child(2){min-width:140px;max-width:420px}.net-dns-form{display:grid;grid-template-columns:110px minmax(150px,1fr) 120px 120px;align-items:end;gap:12px;margin-top:24px}.net-dns-form label{display:grid;gap:8px;font-size:12px}.net-dns-form input,.net-dns-form select{min-width:0;width:100%;padding:12px;font:inherit;background:transparent;color:inherit;border:1px solid #606952}.net-dns-form button{grid-column:1/-1;justify-self:start}.net-dns-form [hidden]{display:none}.net-block pre{max-width:100%;overflow-x:auto}@media(max-width:680px){.net-dns-form{grid-template-columns:1fr}.net-dns-table{font-size:12px}.net-dns-table th,.net-dns-table td{padding:10px 6px}}
84 changes: 84 additions & 0 deletions src/net-records.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { isIP } from 'node:net';
import { randomUUID } from 'node:crypto';
import { W3bsError } from './core.mjs';

export const RECORD_TYPES = ['AAAA', 'CNAME', 'MX', 'TXT'];
const host = (value) => {
const text = String(value ?? '').trim().toLowerCase().replace(/\.$/, '');
return text.length <= 253 && text.split('.').length >= 2 && text.split('.').every(l => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(l)) && !isIP(text) ? text : null;
};
export function normalizeNetRecord(input, name) {
const type = String(input?.type ?? '').toUpperCase();
if (!RECORD_TYPES.includes(type)) throw new W3bsError('INVALID_RECORD', 'Choose AAAA, CNAME, MX or TXT.', 400);
let value = String(input?.value ?? '').trim();
if (!value || Buffer.byteLength(value) > 1024) throw new W3bsError('INVALID_RECORD', 'Enter a record value of at most 1024 bytes.', 400);
const ttl = Number(input.ttl ?? 300);
if (!Number.isInteger(ttl) || ttl < 60 || ttl > 86400) throw new W3bsError('INVALID_TTL', 'TTL must be 60–86400 seconds.', 400);
let priority = 0;
if (type === 'AAAA') {
value = value.replace(/^\[|\]$/g, '').toLowerCase();
if (isIP(value) !== 6 || value.includes('%')) throw new W3bsError('INVALID_RECORD', 'AAAA needs an IPv6 address. Use CNAME for a hostname.', 400);
value = new URL(`http://[${value}]/`).hostname.slice(1,-1);
if (/^(?:::(?:1)?$|::ffff:|f[cd]|fe[89ab]|ff)/i.test(value)) throw new W3bsError('INVALID_RECORD', 'Use a public IPv6 address, not a local, mapped or multicast address.', 400);
} else if (type === 'CNAME' || type === 'MX') {
value = host(value);
if (!value) throw new W3bsError('INVALID_RECORD', `${type} needs a hostname, without https://, a path or a port.`, 400);
if (type === 'CNAME' && [name, `${name}.w3bs.org`].includes(value)) throw new W3bsError('INVALID_RECORD', 'A CNAME cannot point at itself.', 400);
if (type === 'MX') {
priority = Number(input.priority ?? 10);
if (!Number.isInteger(priority) || priority < 0 || priority > 65535) throw new W3bsError('INVALID_PRIORITY', 'MX priority must be 0–65535.', 400);
}
}
return { type, value, ttl, priority };
}

export function createNetRecords(db) {
let writes = Promise.resolve();
const transaction = (fn) => {
const next = writes.then(() => db.transaction(fn));
writes = next.catch(() => {});
return next;
};
const parts = (name) => {
const p = String(name).toLowerCase().split('.');
if (p.length !== 2 || !p.every(l=>/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(l))) throw new W3bsError('INVALID_NAME','Use label.namespace.',400);
return [p[1],p[0]];
};
const rows = async (tx, tld, label) => (await tx.all('SELECT id,type,value,ttl,priority FROM net_dns_records WHERE tld=? AND label=? ORDER BY type,value,priority',[tld,label]));
async function authorize(tx, name, ownerId) {
const [tld,label] = parts(name);
const row = await tx.get('SELECT n.owner_id,n.status,t.status AS namespace_status FROM net_names n JOIN net_tlds t ON t.tld=n.tld WHERE n.tld=? AND n.label=?' + (db.dialect === 'postgres' ? ' FOR UPDATE OF n' : ''),[tld,label]);
if (!row || row.owner_id !== ownerId) throw new W3bsError('NAME_OWNER_REQUIRED','Only the W3BS owner of this name can change its DNS records.',403);
if (row.status !== 'active' || row.namespace_status !== 'active') throw new W3bsError('NAME_NOT_ACTIVE','The name and namespace must be active before publishing DNS records.',409);
return [tld,label];
}
return {
async owned(ownerId) {
return db.all('SELECT n.label,n.tld,n.status,n.target,t.status AS namespace_status FROM net_names n JOIN net_tlds t ON t.tld=n.tld WHERE n.owner_id=? ORDER BY n.tld,n.label',[ownerId]);
},
async list(name) {
const [tld,label] = parts(name);
const active = await db.get("SELECT n.label FROM net_names n JOIN net_tlds t ON t.tld=n.tld WHERE n.tld=? AND n.label=? AND n.status='active' AND t.status='active'",[tld,label]);
return active ? rows(db,tld,label) : [];
},
async put(name, ownerId, input) {
const record = normalizeNetRecord(input,name);
return transaction(async tx => {
const [tld,label] = await authorize(tx,name,ownerId);
const existing = await rows(tx,tld,label);
if (existing.some(r=>r.type==='CNAME' && (record.type!=='CNAME' || r.value!==record.value)) || (record.type==='CNAME' && existing.some(r=>r.type!=='CNAME')))
throw new W3bsError('CNAME_CONFLICT','CNAME must be the only record on a name. Remove the existing records first.',409);
if (existing.length >= 50 && !existing.some(r=>r.type===record.type && r.value===record.value && r.priority===record.priority)) throw new W3bsError('RECORD_LIMIT','A name can have at most 50 records.',409);
await tx.run('INSERT INTO net_dns_records(id,tld,label,type,value,ttl,priority,created_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(tld,label,type,value,priority) DO UPDATE SET ttl=excluded.ttl',[randomUUID(),tld,label,record.type,record.value,record.ttl,record.priority,new Date().toISOString()]);
return rows(tx,tld,label);
});
},
async remove(name, ownerId, id) {
return transaction(async tx => {
const [tld,label] = await authorize(tx,name,ownerId);
await tx.run('DELETE FROM net_dns_records WHERE id=? AND tld=? AND label=?',[String(id),tld,label]);
return rows(tx,tld,label);
});
},
};
}
Loading
Loading