fix: harden tailscale serve auth
This commit is contained in:
@@ -35,6 +35,7 @@ Status: unreleased.
|
|||||||
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
|
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
||||||
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
|
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
|
||||||
|
|
||||||
## 2026.1.24-3
|
## 2026.1.24-3
|
||||||
|
|||||||
@@ -2878,10 +2878,11 @@ Auth and Tailscale:
|
|||||||
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
|
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
|
||||||
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
|
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
|
||||||
(`tailscale-user-login`) to satisfy auth when the request arrives on loopback
|
(`tailscale-user-login`) to satisfy auth when the request arrives on loopback
|
||||||
with `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`. When
|
with `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`. Clawdbot
|
||||||
`true`, Serve requests do not need a token/password; set `false` to require
|
verifies the identity by resolving the `x-forwarded-for` address via
|
||||||
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
|
`tailscale whois` before accepting it. When `true`, Serve requests do not need
|
||||||
auth mode is not `password`.
|
a token/password; set `false` to require explicit credentials. Defaults to
|
||||||
|
`true` when `tailscale.mode = "serve"` and auth mode is not `password`.
|
||||||
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
|
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
|
||||||
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
|
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
|
||||||
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
|
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
|
||||||
|
|||||||
@@ -333,9 +333,11 @@ Rotation checklist (token/password):
|
|||||||
|
|
||||||
When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot
|
When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot
|
||||||
accepts Tailscale Serve identity headers (`tailscale-user-login`) as
|
accepts Tailscale Serve identity headers (`tailscale-user-login`) as
|
||||||
authentication. This only triggers for requests that hit loopback and include
|
authentication. Clawdbot verifies the identity by resolving the
|
||||||
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as injected by
|
`x-forwarded-for` address through the local Tailscale daemon (`tailscale whois`)
|
||||||
Tailscale.
|
and matching it to the header. This only triggers for requests that hit loopback
|
||||||
|
and include `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as
|
||||||
|
injected by Tailscale.
|
||||||
|
|
||||||
**Security rule:** do not forward these headers from your own reverse proxy. If
|
**Security rule:** do not forward these headers from your own reverse proxy. If
|
||||||
you terminate TLS or proxy in front of the gateway, disable
|
you terminate TLS or proxy in front of the gateway, disable
|
||||||
|
|||||||
@@ -25,9 +25,12 @@ Set `gateway.auth.mode` to control the handshake:
|
|||||||
|
|
||||||
When `tailscale.mode = "serve"` and `gateway.auth.allowTailscale` is `true`,
|
When `tailscale.mode = "serve"` and `gateway.auth.allowTailscale` is `true`,
|
||||||
valid Serve proxy requests can authenticate via Tailscale identity headers
|
valid Serve proxy requests can authenticate via Tailscale identity headers
|
||||||
(`tailscale-user-login`) without supplying a token/password. Clawdbot only
|
(`tailscale-user-login`) without supplying a token/password. Clawdbot verifies
|
||||||
treats a request as Serve when it arrives from loopback with Tailscale’s
|
the identity by resolving the `x-forwarded-for` address via the local Tailscale
|
||||||
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` headers.
|
daemon (`tailscale whois`) and matching it to the header before accepting it.
|
||||||
|
Clawdbot only treats a request as Serve when it arrives from loopback with
|
||||||
|
Tailscale’s `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`
|
||||||
|
headers.
|
||||||
To require explicit credentials, set `gateway.auth.allowTailscale: false` or
|
To require explicit credentials, set `gateway.auth.allowTailscale: false` or
|
||||||
force `gateway.auth.mode: "password"`.
|
force `gateway.auth.mode: "password"`.
|
||||||
|
|
||||||
|
|||||||
@@ -70,10 +70,11 @@ Open:
|
|||||||
|
|
||||||
By default, Serve requests can authenticate via Tailscale identity headers
|
By default, Serve requests can authenticate via Tailscale identity headers
|
||||||
(`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. Clawdbot
|
(`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. Clawdbot
|
||||||
only accepts these when the request hits loopback with Tailscale’s
|
verifies the identity by resolving the `x-forwarded-for` address with
|
||||||
`x-forwarded-*` headers. Set `gateway.auth.allowTailscale: false` (or force
|
`tailscale whois` and matching it to the header, and only accepts these when the
|
||||||
`gateway.auth.mode: "password"`) if you want to require a token/password even
|
request hits loopback with Tailscale’s `x-forwarded-*` headers. Set
|
||||||
for Serve traffic.
|
`gateway.auth.allowTailscale: false` (or force `gateway.auth.mode: "password"`)
|
||||||
|
if you want to require a token/password even for Serve traffic.
|
||||||
|
|
||||||
### Bind to tailnet + token
|
### Bind to tailnet + token
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ describe("gateway auth", () => {
|
|||||||
const res = await authorizeGatewayConnect({
|
const res = await authorizeGatewayConnect({
|
||||||
auth: { mode: "token", token: "secret", allowTailscale: true },
|
auth: { mode: "token", token: "secret", allowTailscale: true },
|
||||||
connectAuth: null,
|
connectAuth: null,
|
||||||
|
tailscaleWhois: async () => ({ login: "peter", name: "Peter" }),
|
||||||
req: {
|
req: {
|
||||||
socket: { remoteAddress: "127.0.0.1" },
|
socket: { remoteAddress: "127.0.0.1" },
|
||||||
headers: {
|
headers: {
|
||||||
@@ -143,6 +144,28 @@ describe("gateway auth", () => {
|
|||||||
expect(res.user).toBe("peter");
|
expect(res.user).toBe("peter");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects mismatched tailscale identity when required", async () => {
|
||||||
|
const res = await authorizeGatewayConnect({
|
||||||
|
auth: { mode: "none", allowTailscale: true },
|
||||||
|
connectAuth: null,
|
||||||
|
tailscaleWhois: async () => ({ login: "alice@example.com", name: "Alice" }),
|
||||||
|
req: {
|
||||||
|
socket: { remoteAddress: "127.0.0.1" },
|
||||||
|
headers: {
|
||||||
|
host: "gateway.local",
|
||||||
|
"x-forwarded-for": "100.64.0.1",
|
||||||
|
"x-forwarded-proto": "https",
|
||||||
|
"x-forwarded-host": "ai-hub.bone-egret.ts.net",
|
||||||
|
"tailscale-user-login": "peter@example.com",
|
||||||
|
"tailscale-user-name": "Peter",
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.reason).toBe("tailscale_user_mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
it("treats trusted proxy loopback clients as direct", async () => {
|
it("treats trusted proxy loopback clients as direct", async () => {
|
||||||
const res = await authorizeGatewayConnect({
|
const res = await authorizeGatewayConnect({
|
||||||
auth: { mode: "none", allowTailscale: true },
|
auth: { mode: "none", allowTailscale: true },
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { timingSafeEqual } from "node:crypto";
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import type { IncomingMessage } from "node:http";
|
import type { IncomingMessage } from "node:http";
|
||||||
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
||||||
import { isTrustedProxyAddress, resolveGatewayClientIp } from "./net.js";
|
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
|
||||||
|
import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js";
|
||||||
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
|
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
|
||||||
|
|
||||||
export type ResolvedGatewayAuth = {
|
export type ResolvedGatewayAuth = {
|
||||||
@@ -29,11 +30,17 @@ type TailscaleUser = {
|
|||||||
profilePic?: string;
|
profilePic?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TailscaleWhoisLookup = (ip: string) => Promise<TailscaleWhoisIdentity | null>;
|
||||||
|
|
||||||
function safeEqual(a: string, b: string): boolean {
|
function safeEqual(a: string, b: string): boolean {
|
||||||
if (a.length !== b.length) return false;
|
if (a.length !== b.length) return false;
|
||||||
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLogin(login: string): string {
|
||||||
|
return login.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function isLoopbackAddress(ip: string | undefined): boolean {
|
function isLoopbackAddress(ip: string | undefined): boolean {
|
||||||
if (!ip) return false;
|
if (!ip) return false;
|
||||||
if (ip === "127.0.0.1") return true;
|
if (ip === "127.0.0.1") return true;
|
||||||
@@ -58,6 +65,12 @@ function headerValue(value: string | string[] | undefined): string | undefined {
|
|||||||
return Array.isArray(value) ? value[0] : value;
|
return Array.isArray(value) ? value[0] : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined {
|
||||||
|
if (!req) return undefined;
|
||||||
|
const forwardedFor = headerValue(req.headers?.["x-forwarded-for"]);
|
||||||
|
return forwardedFor ? parseForwardedForClientIp(forwardedFor) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveRequestClientIp(
|
function resolveRequestClientIp(
|
||||||
req?: IncomingMessage,
|
req?: IncomingMessage,
|
||||||
trustedProxies?: string[],
|
trustedProxies?: string[],
|
||||||
@@ -118,6 +131,39 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
|
|||||||
return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req);
|
return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveVerifiedTailscaleUser(params: {
|
||||||
|
req?: IncomingMessage;
|
||||||
|
tailscaleWhois: TailscaleWhoisLookup;
|
||||||
|
}): Promise<{ ok: true; user: TailscaleUser } | { ok: false; reason: string }> {
|
||||||
|
const { req, tailscaleWhois } = params;
|
||||||
|
const tailscaleUser = getTailscaleUser(req);
|
||||||
|
if (!tailscaleUser) {
|
||||||
|
return { ok: false, reason: "tailscale_user_missing" };
|
||||||
|
}
|
||||||
|
if (!isTailscaleProxyRequest(req)) {
|
||||||
|
return { ok: false, reason: "tailscale_proxy_missing" };
|
||||||
|
}
|
||||||
|
const clientIp = resolveTailscaleClientIp(req);
|
||||||
|
if (!clientIp) {
|
||||||
|
return { ok: false, reason: "tailscale_whois_failed" };
|
||||||
|
}
|
||||||
|
const whois = await tailscaleWhois(clientIp);
|
||||||
|
if (!whois?.login) {
|
||||||
|
return { ok: false, reason: "tailscale_whois_failed" };
|
||||||
|
}
|
||||||
|
if (normalizeLogin(whois.login) !== normalizeLogin(tailscaleUser.login)) {
|
||||||
|
return { ok: false, reason: "tailscale_user_mismatch" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
user: {
|
||||||
|
login: whois.login,
|
||||||
|
name: whois.name ?? tailscaleUser.name,
|
||||||
|
profilePic: tailscaleUser.profilePic,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveGatewayAuth(params: {
|
export function resolveGatewayAuth(params: {
|
||||||
authConfig?: GatewayAuthConfig | null;
|
authConfig?: GatewayAuthConfig | null;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
@@ -155,29 +201,26 @@ export async function authorizeGatewayConnect(params: {
|
|||||||
connectAuth?: ConnectAuth | null;
|
connectAuth?: ConnectAuth | null;
|
||||||
req?: IncomingMessage;
|
req?: IncomingMessage;
|
||||||
trustedProxies?: string[];
|
trustedProxies?: string[];
|
||||||
|
tailscaleWhois?: TailscaleWhoisLookup;
|
||||||
}): Promise<GatewayAuthResult> {
|
}): Promise<GatewayAuthResult> {
|
||||||
const { auth, connectAuth, req, trustedProxies } = params;
|
const { auth, connectAuth, req, trustedProxies } = params;
|
||||||
|
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
|
||||||
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
||||||
|
|
||||||
if (auth.allowTailscale && !localDirect) {
|
if (auth.allowTailscale && !localDirect) {
|
||||||
const tailscaleUser = getTailscaleUser(req);
|
const tailscaleCheck = await resolveVerifiedTailscaleUser({
|
||||||
const tailscaleProxy = isTailscaleProxyRequest(req);
|
req,
|
||||||
|
tailscaleWhois,
|
||||||
if (tailscaleUser && tailscaleProxy) {
|
});
|
||||||
|
if (tailscaleCheck.ok) {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
method: "tailscale",
|
method: "tailscale",
|
||||||
user: tailscaleUser.login,
|
user: tailscaleCheck.user.login,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth.mode === "none") {
|
if (auth.mode === "none") {
|
||||||
if (!tailscaleUser) {
|
return { ok: false, reason: tailscaleCheck.reason };
|
||||||
return { ok: false, reason: "tailscale_user_missing" };
|
|
||||||
}
|
|
||||||
if (!tailscaleProxy) {
|
|
||||||
return { ok: false, reason: "tailscale_proxy_missing" };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +235,7 @@ export async function authorizeGatewayConnect(params: {
|
|||||||
if (!connectAuth?.token) {
|
if (!connectAuth?.token) {
|
||||||
return { ok: false, reason: "token_missing" };
|
return { ok: false, reason: "token_missing" };
|
||||||
}
|
}
|
||||||
if (connectAuth.token !== auth.token) {
|
if (!safeEqual(connectAuth.token, auth.token)) {
|
||||||
return { ok: false, reason: "token_mismatch" };
|
return { ok: false, reason: "token_mismatch" };
|
||||||
}
|
}
|
||||||
return { ok: true, method: "token" };
|
return { ok: true, method: "token" };
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function stripOptionalPort(ip: string): string {
|
|||||||
return ip;
|
return ip;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseForwardedForClientIp(forwardedFor?: string): string | undefined {
|
export function parseForwardedForClientIp(forwardedFor?: string): string | undefined {
|
||||||
const raw = forwardedFor?.split(",")[0]?.trim();
|
const raw = forwardedFor?.split(",")[0]?.trim();
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
return normalizeIp(stripOptionalPort(raw));
|
return normalizeIp(stripOptionalPort(raw));
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ function formatGatewayAuthFailureMessage(params: {
|
|||||||
return "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)";
|
return "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)";
|
||||||
case "tailscale_proxy_missing":
|
case "tailscale_proxy_missing":
|
||||||
return "unauthorized: tailscale proxy headers missing (use Tailscale Serve or gateway token/password)";
|
return "unauthorized: tailscale proxy headers missing (use Tailscale Serve or gateway token/password)";
|
||||||
|
case "tailscale_whois_failed":
|
||||||
|
return "unauthorized: tailscale identity check failed (use Tailscale Serve auth or gateway token/password)";
|
||||||
|
case "tailscale_user_mismatch":
|
||||||
|
return "unauthorized: tailscale identity mismatch (use Tailscale Serve auth or gateway token/password)";
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,18 @@ type ExecErrorDetails = {
|
|||||||
code?: unknown;
|
code?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TailscaleWhoisIdentity = {
|
||||||
|
login: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TailscaleWhoisCacheEntry = {
|
||||||
|
value: TailscaleWhoisIdentity | null;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const whoisCache = new Map<string, TailscaleWhoisCacheEntry>();
|
||||||
|
|
||||||
function extractExecErrorText(err: unknown) {
|
function extractExecErrorText(err: unknown) {
|
||||||
const errOutput = err as ExecErrorDetails;
|
const errOutput = err as ExecErrorDetails;
|
||||||
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
|
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
|
||||||
@@ -381,3 +393,73 @@ export async function disableTailscaleFunnel(exec: typeof runExec = runExec) {
|
|||||||
timeoutMs: 15_000,
|
timeoutMs: 15_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWhoisIdentity(payload: Record<string, unknown>): TailscaleWhoisIdentity | null {
|
||||||
|
const userProfile =
|
||||||
|
readRecord(payload.UserProfile) ?? readRecord(payload.userProfile) ?? readRecord(payload.User);
|
||||||
|
const login =
|
||||||
|
getString(userProfile?.LoginName) ??
|
||||||
|
getString(userProfile?.Login) ??
|
||||||
|
getString(userProfile?.login) ??
|
||||||
|
getString(payload.LoginName) ??
|
||||||
|
getString(payload.login);
|
||||||
|
if (!login) return null;
|
||||||
|
const name =
|
||||||
|
getString(userProfile?.DisplayName) ??
|
||||||
|
getString(userProfile?.Name) ??
|
||||||
|
getString(userProfile?.displayName) ??
|
||||||
|
getString(payload.DisplayName) ??
|
||||||
|
getString(payload.name);
|
||||||
|
return { login, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCachedWhois(ip: string, now: number): TailscaleWhoisIdentity | null | undefined {
|
||||||
|
const cached = whoisCache.get(ip);
|
||||||
|
if (!cached) return undefined;
|
||||||
|
if (cached.expiresAt <= now) {
|
||||||
|
whoisCache.delete(ip);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCachedWhois(ip: string, value: TailscaleWhoisIdentity | null, ttlMs: number) {
|
||||||
|
whoisCache.set(ip, { value, expiresAt: Date.now() + ttlMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readTailscaleWhoisIdentity(
|
||||||
|
ip: string,
|
||||||
|
exec: typeof runExec = runExec,
|
||||||
|
opts?: { timeoutMs?: number; cacheTtlMs?: number; errorTtlMs?: number },
|
||||||
|
): Promise<TailscaleWhoisIdentity | null> {
|
||||||
|
const normalized = ip.trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = readCachedWhois(normalized, now);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
const cacheTtlMs = opts?.cacheTtlMs ?? 60_000;
|
||||||
|
const errorTtlMs = opts?.errorTtlMs ?? 5_000;
|
||||||
|
try {
|
||||||
|
const tailscaleBin = await getTailscaleBinary();
|
||||||
|
const { stdout } = await exec(tailscaleBin, ["whois", "--json", normalized], {
|
||||||
|
timeoutMs: opts?.timeoutMs ?? 5_000,
|
||||||
|
maxBuffer: 200_000,
|
||||||
|
});
|
||||||
|
const parsed = stdout ? parsePossiblyNoisyJsonObject(stdout) : {};
|
||||||
|
const identity = parseWhoisIdentity(parsed);
|
||||||
|
writeCachedWhois(normalized, identity, cacheTtlMs);
|
||||||
|
return identity;
|
||||||
|
} catch {
|
||||||
|
writeCachedWhois(normalized, null, errorTtlMs);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user