diff --git a/docs/w3bs-net.md b/docs/w3bs-net.md index f6e514f..e51c6b3 100644 --- a/docs/w3bs-net.md +++ b/docs/w3bs-net.md @@ -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. diff --git a/public/net-records.js b/public/net-records.js new file mode 100644 index 0000000..141792b --- /dev/null +++ b/public/net-records.js @@ -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');} diff --git a/public/style.css b/public/style.css index c7c0a9d..fc70413 100644 --- a/public/style.css +++ b/public/style.css @@ -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}} diff --git a/src/net-records.mjs b/src/net-records.mjs new file mode 100644 index 0000000..0a49a0e --- /dev/null +++ b/src/net-records.mjs @@ -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); + }); + }, + }; +} diff --git a/src/net-routes.mjs b/src/net-routes.mjs index 18ac382..13ab861 100644 --- a/src/net-routes.mjs +++ b/src/net-routes.mjs @@ -74,7 +74,7 @@ function registryMarkup(names, { query = '', moshpitError = '' } = {}) { }) .join('')}` : '
No names match this search.
'; - return `W3BS Net is an independent registry and event relay. Names registered here are primary on W3BS; opted-in peers receive announcements. Public Moshpit records are imported as backfill and stay attributed to their source.
Requests are held for moderation. Active namespaces and names are free, never sold, and never reserved for pre-buy.
Moshpit records fill gaps in discovery. W3BS registrations always take precedence when a name exists in both registries.
W3BS Net is a separate Moshpit instance with its own registry and event relay. Names registered here are primary on W3BS; opted-in peers receive announcements. Public Moshpit records are imported as backfill and stay attributed to their source.
Requests are held for moderation. Active namespaces and names are free, never sold, and never reserved for pre-buy.
Moshpit records fill gaps in discovery. W3BS registrations always take precedence when a name exists in both registries.
Real records on the names you hold: AAAA for IPv6, CNAME to another hostname, MX for mail and TXT for verification. Changes publish to this W3BS instance immediately; existing resolver caches follow the record’s TTL.
+W3BS is a separate Moshpit instance. Your names and records belong here; imported primary-network records remain read-only backfill. The public gateway name is name.namespace.w3bs.org.
Loading your names…
Sign in to publish records on names you own in W3BS.
Sign in →Use the existing W3BS OAuth bearer from w3bs login. Primary-network API keys do not grant W3BS ownership.
POST /api/net/v1/names/blue.example/records
+{ "type": "AAAA", "value": "2606:4700:4700::1111", "ttl": 300 }
+
+GET /api/net/v1/dns/records?name=blue.example