diff --git a/packages/dappmanager/package.json b/packages/dappmanager/package.json index 6c34640b29..286e0951ad 100644 --- a/packages/dappmanager/package.json +++ b/packages/dappmanager/package.json @@ -70,6 +70,7 @@ "http-proxy": "^1.18.0", "is-ip": "^3.0.0", "lodash-es": "^4.17.21", + "kubo-rpc-client": "^3.0.2", "memoizee": "^0.4.14", "multicodec": "^3.2.1", "multiformats": "^11.0.1", @@ -81,7 +82,6 @@ "devDependencies": { "@types/mocha": "^10", "dotenv": "^8.2.0", - "kubo-rpc-client": "^3.0.2", "mocha": "^10.7.0", "prettier": "^2.3.2", "rewiremock": "^3.13.7", diff --git a/packages/dappmanager/src/api/middlewares/ethForward/index.ts b/packages/dappmanager/src/api/middlewares/ethForward/index.ts index 6c6463dfab..1b47c3cc2c 100644 --- a/packages/dappmanager/src/api/middlewares/ethForward/index.ts +++ b/packages/dappmanager/src/api/middlewares/ethForward/index.ts @@ -1,6 +1,28 @@ import express from "express"; +import { params } from "@dappnode/params"; +import { ethers } from "ethers"; +import { create as createIpfsClient } from "kubo-rpc-client"; import { getIpfsProxyHandler, ProxyType } from "./ipfsProxy.js"; -import { ResolveDomainWithCache } from "./resolveDomain.js"; +import { mainnetJsonRpc, ResolveDomainWithCache } from "./resolveDomain.js"; +import { logs } from "@dappnode/logger"; +import * as views from "./views/index.js"; + +const ETH_API_URL = mainnetJsonRpc; +const IPFS_API_URL = getIpfsApiUrl(); +const APIS_CHECK_TIMEOUT_MS = 3_000; +const APIS_CHECK_CACHE_MS = 10_000; + +type ApisAvailability = { + isEthAvailable: boolean; + isIpfsAvailable: boolean; +}; + +let apisAvailabilityCache: + | { + value: ApisAvailability; + timestamp: number; + } + | undefined; export function getEthForwardMiddleware(): express.RequestHandler { // Create a domain resolver with cache @@ -15,7 +37,32 @@ export function getEthForwardMiddleware(): express.RequestHandler { try { const domain = parseEthDomainHost(req); if (domain !== null) { - ethForwardHandler(req, res, domain); + ensureApisAvailability() + .then((apisAvailability) => { + if (!apisAvailability.isEthAvailable || !apisAvailability.isIpfsAvailable) { + logs.warn( + `ETHFORWARD blocked ${domain}: ETH API up=${apisAvailability.isEthAvailable}, IPFS API up=${apisAvailability.isIpfsAvailable}` + ); + + res.writeHead(200, { "Content-Type": "text/html" }); + if (!apisAvailability.isEthAvailable && !apisAvailability.isIpfsAvailable) { + res.write( + views.noEthAndIpfs( + new Error(`Ethereum API ${ETH_API_URL} and IPFS API ${IPFS_API_URL} are unavailable`) + ) + ); + } else if (!apisAvailability.isEthAvailable) { + res.write(views.noEth(new Error(`Ethereum API ${ETH_API_URL} is unavailable`))); + } else { + res.write(views.noIpfs(new Error(`IPFS API ${IPFS_API_URL} is unavailable`))); + } + res.end(); + return; + } + + ethForwardHandler(req, res, domain); + }) + .catch(next); return; } @@ -26,6 +73,79 @@ export function getEthForwardMiddleware(): express.RequestHandler { }; } +async function ensureApisAvailability(): Promise { + const now = Date.now(); + + if (apisAvailabilityCache && now - apisAvailabilityCache.timestamp < APIS_CHECK_CACHE_MS) { + return apisAvailabilityCache.value; + } + + const [isEthAvailable, isIpfsAvailable] = await Promise.all([isEthApiAvailable(), isIpfsApiAvailable()]); + const value = { isEthAvailable, isIpfsAvailable }; + apisAvailabilityCache = { value, timestamp: now }; + + return value; +} + +async function isEthApiAvailable(): Promise { + try { + const provider = new ethers.JsonRpcProvider(ETH_API_URL); + await withTimeout(provider.send("web3_clientVersion", []), APIS_CHECK_TIMEOUT_MS); + return true; + } catch (e) { + logs.debug("ETHFORWARD ETH API check failed", e); + return false; + } +} + +async function isIpfsApiAvailable(): Promise { + if (!IPFS_API_URL) return false; + + try { + const ipfsClient = createIpfsClient({ + url: IPFS_API_URL, + timeout: APIS_CHECK_TIMEOUT_MS + }); + + await withTimeout(ipfsClient.id(), APIS_CHECK_TIMEOUT_MS); + return true; + } catch (e) { + logs.debug("ETHFORWARD IPFS API check failed", e); + return false; + } +} + +function getIpfsApiUrl(): string { + try { + const ipfsUrl = params.IPFS_HOST || params.IPFS_LOCAL; + const url = new URL(ipfsUrl); + url.port = "5001"; + url.pathname = "/"; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch (e) { + logs.warn("ETHFORWARD Invalid IPFS URL for API check", e); + return ""; + } +} + +async function withTimeout(promise: Promise, timeoutMs: number): Promise { + let timeoutHandle: ReturnType; + + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`Timeout after ${timeoutMs}ms`)); + }, timeoutMs); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timeoutHandle!); + } +} + function parseEthDomainHost(req: express.Request): string | null { // Check if a request is for a decentralized website, based on their host // - decentral.eth => true diff --git a/packages/dappmanager/src/api/middlewares/ethForward/ipfsProxy.ts b/packages/dappmanager/src/api/middlewares/ethForward/ipfsProxy.ts index 14f2ef4789..9894c4d988 100644 --- a/packages/dappmanager/src/api/middlewares/ethForward/ipfsProxy.ts +++ b/packages/dappmanager/src/api/middlewares/ethForward/ipfsProxy.ts @@ -6,6 +6,7 @@ import { urlJoin } from "@dappnode/utils"; import { logs } from "@dappnode/logger"; import * as views from "./views/index.js"; import { NodeNotAvailable, ProxyError, EnsResolverError, NotFoundError, Content } from "./types.js"; +import { mainnetJsonRpc } from "./resolveDomain.js"; export enum ProxyType { ETHFORWARD = "ETHFORWARD", @@ -110,9 +111,31 @@ function errorToResponseHtml(e: Error, domain?: string): string { else if (e.location === "ipfs") return views.noIpfs(e); // Proxy errors - if (e instanceof ProxyError) return views.unknownError(e); + if (e instanceof ProxyError) { + if (e.target.includes("ipfs.dappnode")) return views.noIpfs(e); + if (e.target.includes("swarm.dappnode")) return views.noSwarm(e); + return views.unknownError(e); + } + + // ETH resolution errors may still happen after preflight checks + if (isEthNodeUnavailableError(e)) return views.noEth(e); // Unknown errors, log to error logs.error(`ETHFORWARD Unknown error resolving ${domain}`, e); return views.unknownError(e); } + +function isEthNodeUnavailableError(e: Error): boolean { + const err = e as Error & { code?: string; shortMessage?: string }; + const fullMessage = `${err.message || ""} ${err.shortMessage || ""}`.toLowerCase(); + + return ( + err.code === "NETWORK_ERROR" || + err.code === "SERVER_ERROR" || + fullMessage.includes(mainnetJsonRpc.toLowerCase()) || + fullMessage.includes("could not detect network") || + fullMessage.includes("failed to fetch") || + fullMessage.includes("econnrefused") || + fullMessage.includes("ehostunreach") + ); +} diff --git a/packages/dappmanager/src/api/middlewares/ethForward/resolveDomain.ts b/packages/dappmanager/src/api/middlewares/ethForward/resolveDomain.ts index af5a4ae619..7c7113626f 100644 --- a/packages/dappmanager/src/api/middlewares/ethForward/resolveDomain.ts +++ b/packages/dappmanager/src/api/middlewares/ethForward/resolveDomain.ts @@ -1,11 +1,10 @@ import { ethers } from "ethers"; import resolverAbi from "./abi/resolverAbi.json" with { type: "json" }; import ensAbi from "./abi/ens.json" with { type: "json" }; -import { Network, Content, NotFoundError, EnsResolverError } from "./types.js"; +import { Content, NotFoundError, EnsResolverError } from "./types.js"; import { decodeContentHash, isEmpty, decodeDnsLink, decodeContent } from "./utils/index.js"; import memoize from "memoizee"; -const providerUrlCacheMs = 60 * 1000; const domainsCacheMs = 5 * 60 * 1000; /** @@ -13,7 +12,7 @@ const domainsCacheMs = 5 * 60 * 1000; * Last updated March 2020 */ const ensAddress = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e"; -const ropstenJsonRpc = "http://ropsten.dappnode:8545"; +export const mainnetJsonRpc = "http://execution.mainnet.dncore.dappnode:8545"; const CONTENTHASH_INTERFACE_ID = "0xbc1c58d1"; const TEXT_INTERFACE_ID = "0x59d1d43c"; @@ -24,34 +23,17 @@ interface InterfacesAvailable { [interfaceHash: string]: boolean; } -async function getEthersProviderByNetwork(network: Network): Promise { - switch (network) { - case "mainnet": - return "http://execution.mainnet.dncore.dappnode:8545"; - case "ropsten": - return ropstenJsonRpc; - default: - throw Error(`Unsupported network: ${network}`); - } -} - /** * Caches obtaining and validating an eth client * Caches the domains by domain and provider instance */ export function ResolveDomainWithCache(): (domain: string) => Promise { - const _getEthersProviderByNetwork = memoize(getEthersProviderByNetwork, { - promise: true, - maxAge: providerUrlCacheMs - }); const _resolveDomain = memoize(resolveDomain, { promise: true, maxAge: domainsCacheMs }); return async function (domain: string): Promise { - const network = parseNetworkFromDomain(domain); - const providerUrl = await _getEthersProviderByNetwork(network); - const provider = new ethers.JsonRpcProvider(providerUrl); // TODO: review + const provider = new ethers.JsonRpcProvider(mainnetJsonRpc); // TODO: review return _resolveDomain(domain, provider); }; } @@ -59,8 +41,6 @@ export function ResolveDomainWithCache(): (domain: string) => Promise { /** * Resolves a request for an ENS domain iterating over various methods * - `.eth` domains: Resolve with mainnet - * - `.test` domains: Resolve with ropsten - * - If NETOFF error, return no-ropsten.html * - else: throw Error * @param domain * @returns content object @@ -95,24 +75,6 @@ export async function resolveDomain(domain: string, provider: ethers.Provider): throw new NotFoundError("content not configured", { domain }); } -/** - * Returns the network to fetch from given an ENS domain - * @param domain "name.eth" | "name.test" - */ -function parseNetworkFromDomain(domain: string): Network { - if (!domain.includes(".")) throw Error(`domain does not have an TDL`); - const parts = domain.split("."); - const extension = parts[parts.length - 1]; - switch (extension) { - case "eth": - return "mainnet"; - case "test": - return "ropsten"; - default: - throw Error(`TDL not supported ${extension}`); - } -} - // Utils /** diff --git a/packages/dappmanager/src/api/middlewares/ethForward/types.ts b/packages/dappmanager/src/api/middlewares/ethForward/types.ts index b342c0ccde..28b844779f 100644 --- a/packages/dappmanager/src/api/middlewares/ethForward/types.ts +++ b/packages/dappmanager/src/api/middlewares/ethForward/types.ts @@ -6,7 +6,7 @@ export type Location = "ipfs" | "swarm"; /** * Network names supported by the ETH FORWARD */ -export type Network = "mainnet" | "ropsten"; +export type Network = "mainnet"; /** * Content descriptor diff --git a/packages/dappmanager/src/api/middlewares/ethForward/views/index.ts b/packages/dappmanager/src/api/middlewares/ethForward/views/index.ts index 262e4dbc02..b1668e1bd4 100644 --- a/packages/dappmanager/src/api/middlewares/ethForward/views/index.ts +++ b/packages/dappmanager/src/api/middlewares/ethForward/views/index.ts @@ -5,7 +5,6 @@ import { params } from "@dappnode/params"; const adminUiUrl = `http://my.dappnode/`; const adminUiInstallUrl = `${adminUiUrl}/installer`; const adminUiPackagesUrl = `${adminUiUrl}/packages`; -const ropstenName = "ropsten.dnp.dappnode.eth"; const swarmName = "swarm.dnp.dappnode.eth"; const a = (url: string, text?: string): string => `${text || url}`; @@ -22,22 +21,13 @@ export function notFound(e: NotFoundError): string { export function noEth(e: Error): string { return base( "Ethereum node not available", - `Your mainnet ethereum node is not available + `This feature is only available when running both IPFS and Ethereum nodes.
${e.message}`, e ); } -export function noRopsten(): string { - return base( - "Ropsten not installed", - `Please install the Ropsten DNP (DAppNode package) to resolve .test domains -
- ${a(`${adminUiInstallUrl}/${ropstenName}`, "Install Ropsten")}` - ); -} - export function noSwarm(e: Error): string { return base( "Swarm not installed", @@ -51,12 +41,23 @@ export function noSwarm(e: Error): string { export function noIpfs(e: Error): string { return base( "IPFS not available", - `Make sure your IPFS node is available + `This feature is only available when running both IPFS and Ethereum nodes. +
${a(`${adminUiPackagesUrl}/${params.ipfsDnpName}`, "IPFS status")}`, e ); } +export function noEthAndIpfs(e: Error): string { + return base( + "Ethereum and IPFS not available", + `This feature is only available when running both IPFS and Ethereum nodes. +
+ ${a(adminUiPackagesUrl, "Check core packages status")}`, + e + ); +} + export function ethSyncing(): string { return base( "Page Not Available",