Last active
May 11, 2023 06:31
-
-
Save lithdew/b64f978fb5ec38fbe36d73c2a5434b36 to your computer and use it in GitHub Desktop.
deno (udp holepunching): basic stun client for fetching public-facing ip:port
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// deno run -A --unstable main.ts | |
export const classToValue = { | |
request: 0x0000, | |
indication: 0x0010, | |
success: 0x0100, | |
failure: 0x0110, | |
}; | |
export const methodToValue = { | |
binding: 0x0001, | |
}; | |
export const typeToValue = { | |
mappedAddress: 0x0001, | |
username: 0x0006, | |
messageIntegrity: 0x0008, | |
errorCode: 0x0009, | |
unknownAttributes: 0x000a, | |
realm: 0x0014, | |
nonce: 0x0015, | |
xorMappedAddress: 0x0020, | |
software: 0x8022, | |
alternateServer: 0x8023, | |
fingerprint: 0x8028, | |
}; | |
export const addressFamilyToValue = { | |
ipv4: 0x01, | |
ipv6: 0x02, | |
}; | |
export const valueToClass: Record<number, string> = Object.fromEntries( | |
Object.entries(classToValue).map(([k, v]) => [v, k]) | |
); | |
export const valueToMethod: Record<number, string> = Object.fromEntries( | |
Object.entries(methodToValue).map(([k, v]) => [v, k]) | |
); | |
export const valueToType: Record<number, string> = Object.fromEntries( | |
Object.entries(typeToValue).map(([k, v]) => [v, k]) | |
); | |
export const valueToAddressFamily: Record<number, string> = Object.fromEntries( | |
Object.entries(addressFamilyToValue).map(([k, v]) => [v, k]) | |
); | |
const attributeDecoders: { [key in Type]?: (b: Uint8Array) => unknown } = { | |
xorMappedAddress: decodeXorMappedAddress, | |
}; | |
export type Class = keyof typeof classToValue; | |
export type Method = keyof typeof methodToValue; | |
export type Type = keyof typeof typeToValue; | |
export type AddressFamily = keyof typeof addressFamilyToValue; | |
const MAGIC_COOKIE = 0x2112a442; | |
const MAX_RESPONSE_LENGTH = 548; | |
const HEADER_LENGTH = 20; | |
const ATTRIBUTE_HEADER_LENGTH = 4; | |
type Header = { | |
class: Class | number; | |
method: Method | number; | |
length: number; | |
magicCookie: number; | |
transactionId: [number, number, number]; | |
}; | |
type Attribute = { | |
type: Type | number; | |
length: number; | |
value: unknown; | |
}; | |
export type Message = { | |
header: Header; | |
attributes: Attribute[]; | |
}; | |
export function decodeAttribute(b: Uint8Array) { | |
if (b.byteLength < ATTRIBUTE_HEADER_LENGTH) { | |
throw new Error("invalid attribute length"); | |
} | |
const v = new DataView(b.buffer); | |
const typeValue = v.getUint16(0); | |
const type = (valueToType[typeValue] as Type) || typeValue; | |
const length = v.getUint16(2); | |
const bytes = b.slice(4, 4 + length); | |
const value = attributeDecoders[type]?.(bytes) ?? bytes; | |
const attribute: Attribute = { | |
type, | |
length, | |
value, | |
}; | |
return attribute; | |
} | |
export function encodeHeader(header: Header) { | |
const classValue = | |
typeof header.class === "string" | |
? classToValue[header.class] | |
: header.class; | |
const methodValue = | |
typeof header.method === "string" | |
? methodToValue[header.method] | |
: header.method; | |
const b = new Uint8Array(HEADER_LENGTH); | |
const v = new DataView(b.buffer); | |
v.setUint16(0, classValue | methodValue); | |
v.setUint16(2, header.length); | |
v.setUint32(4, header.magicCookie); | |
v.setUint32(8, header.transactionId[0]); | |
v.setUint32(12, header.transactionId[1]); | |
v.setUint32(16, header.transactionId[2]); | |
return b; | |
} | |
export function decodeHeader(b: Uint8Array) { | |
if (b.byteLength < HEADER_LENGTH) { | |
throw new Error("invalid header length"); | |
} | |
const v = new DataView(b.buffer); | |
const classAndMethod = v.getUint16(0); | |
if ((classAndMethod & 0xc000) !== 0x0000) { | |
throw new Error("invalid protocol"); | |
} | |
const length = v.getUint16(2); | |
const magicCookie = v.getUint32(4); | |
const transactionId = [v.getUint32(8), v.getUint32(12), v.getUint32(16)] as [ | |
number, | |
number, | |
number | |
]; | |
if (magicCookie !== MAGIC_COOKIE) { | |
throw new Error("invalid magic cookie"); | |
} | |
const header: Header = { | |
class: | |
(valueToClass[classAndMethod & 0x0110] as Class) || | |
classAndMethod & 0x0110, | |
method: | |
(valueToMethod[classAndMethod & 0x0001] as Method) || | |
classAndMethod & 0x0001, | |
length, | |
magicCookie, | |
transactionId, | |
}; | |
return header; | |
} | |
export function decodeMessage(b: Uint8Array) { | |
const header = decodeHeader(b); | |
const rest = b.slice(HEADER_LENGTH, HEADER_LENGTH + header.length); | |
const attributes: Attribute[] = []; | |
for ( | |
let i = 0; | |
i < rest.byteLength; | |
i += ATTRIBUTE_HEADER_LENGTH + attributes[i].length | |
) { | |
attributes.push(decodeAttribute(rest.slice(i))); | |
} | |
const message: Message = { | |
header, | |
attributes, | |
}; | |
return message; | |
} | |
export function decodeXorMappedAddress(b: Uint8Array) { | |
if (b.byteLength < 8) { | |
throw new Error("invalid xor mapped address length"); | |
} | |
const v = new DataView(b.buffer); | |
const familyValue = v.getUint8(1); | |
if (!valueToAddressFamily[familyValue]) { | |
throw new Error("invalid address family"); | |
} | |
const family = valueToAddressFamily[familyValue] as AddressFamily; | |
const xorPort = v.getUint16(2); | |
const port = xorPort ^ (MAGIC_COOKIE >> 16); | |
const magicCookieBytes = new Uint8Array(4); | |
const magicCookieView = new DataView(magicCookieBytes.buffer); | |
magicCookieView.setUint32(0, MAGIC_COOKIE); | |
switch (family) { | |
case "ipv4": { | |
const xorAddress = new Uint8Array(4); | |
for (let i = 0; i < 4; i++) { | |
xorAddress[i] = b[4 + i] ^ magicCookieBytes[i]; | |
} | |
const address = Array.from(xorAddress).join("."); | |
return { family, address, port }; | |
} | |
case "ipv6": { | |
const xorAddress = new Uint8Array(16); | |
for (let i = 0; i < 16; i++) { | |
xorAddress[i] = b[4 + i] ^ magicCookieBytes[i % 4]; | |
} | |
const address = Array.from(xorAddress) | |
.map((v) => v.toString(16)) | |
.join(":"); | |
return { family, address, port }; | |
} | |
} | |
} | |
if (import.meta.main) { | |
const socket = Deno.listenDatagram({ | |
hostname: "0.0.0.0", | |
port: 0, | |
transport: "udp", | |
}); | |
socket.send( | |
encodeHeader({ | |
class: "request", | |
method: "binding", | |
length: 0, | |
magicCookie: MAGIC_COOKIE, | |
transactionId: [Math.random(), Math.random(), Math.random()], | |
}), | |
{ transport: "udp", port: 19302, hostname: "stun.l.google.com" } | |
); | |
const buffer = new Uint8Array(MAX_RESPONSE_LENGTH); | |
const [bytes] = await socket.receive(buffer); | |
console.log(decodeMessage(bytes)); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment