Files
inbuxa-webmail/server/src/nodes.ts
T
jcoffey-dev 41fb7875d0
ci / version (pull_request) Skipped
ci / node (pull_request) Canceled after 36s
ci / docker-build (pull_request) Canceled after 0s
ci / publish (pull_request) Canceled after 0s
About: find the mail node's name with resolvePtr
dns.reverse came back empty inside the image while the resolver answered
the PTR, so About showed only the address. Ask for the PTR record of the
in-addr.arpa / ip6.arpa name directly.
2026-09-25 21:14:13 -07:00

86 lines
3.1 KiB
TypeScript

/**
* ihasmail-inbuxa: which webmail node answered, and which inbuxa node it talks
* to -- shown in Settings > About, for troubleshooting a cluster.
*
* Not for upstream ihasmail: it only makes sense where one webmail runs per
* host, each pinned to its own host's mail node.
*
* The webmail node is NODE_NAME, set per host by the deploy; without it, the
* container's hostname, which is at least distinct. The mail node is worked
* out from the address the server's name resolves to *here*, where the host
* entry pins it to the local node, named by that address's PTR record (every
* node has one: mail/mx2/mx3). The server offers its node name only to
* administrators, so asking it would leave everyone else with nothing.
*/
import { lookup as dnsLookup, resolvePtr } from "node:dns/promises";
import { isIPv4 } from "node:net";
import { hostname } from "node:os";
import { config } from "./config.js";
export interface MailNode {
/** The server name the webmail connects to, from its configured URL. */
host: string;
/** What that name resolves to from this container, or null if it doesn't. */
address: string | null;
/** The address's PTR name: the node's own name. Null without a PTR. */
name: string | null;
}
export interface Nodes {
webmail: string;
mailServer: MailNode;
}
type Lookup = (host: string) => Promise<{ address: string }>;
type Reverse = (address: string) => Promise<string[]>;
/**
* The reverse-lookup name for an address: 1.2.0.192.in-addr.arpa, or the
* nibble form under ip6.arpa. Asked for directly, because `dns.reverse` came
* back empty in the image while the resolver answered the PTR (2026-09-26).
*/
export function ptrName(address: string): string {
if (isIPv4(address)) return `${address.split(".").reverse().join(".")}.in-addr.arpa`;
const [head = "", tail = ""] = address.split("::");
const groups = (part: string) => (part ? part.split(":") : []);
const h = groups(head), t = groups(tail);
const full = [...h, ...Array(8 - h.length - t.length).fill("0"), ...t];
return `${full.map((g) => g.padStart(4, "0")).join("").split("").reverse().join(".")}.ip6.arpa`;
}
const dnsReverse: Reverse = (address) => resolvePtr(ptrName(address));
const CACHE_MS = 60_000;
const cache = new Map<string, { node: MailNode; at: number }>();
export function webmailNode(): string {
return config.nodeName || hostname();
}
export async function mailNode(base: string, lookup: Lookup = dnsLookup, reverse: Reverse = dnsReverse, now = Date.now()): Promise<MailNode> {
const host = new URL(base).hostname;
const hit = cache.get(host);
if (hit && now - hit.at < CACHE_MS) return hit.node;
let address: string | null = null;
let name: string | null = null;
try {
address = (await lookup(host)).address;
} catch {
/* unresolvable: say so rather than fail the page */
}
if (address) {
try {
name = (await reverse(address))[0]?.replace(/\.$/, "") ?? null;
} catch {
/* no PTR */
}
}
const node = { host, address, name };
cache.set(host, { node, at: now });
return node;
}
export function clearNodeCache(): void {
cache.clear();
}