Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/dappmanager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
124 changes: 122 additions & 2 deletions packages/dappmanager/src/api/middlewares/ethForward/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
}

Expand All @@ -26,6 +73,79 @@ export function getEthForwardMiddleware(): express.RequestHandler {
};
}

async function ensureApisAvailability(): Promise<ApisAvailability> {
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<boolean> {
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<boolean> {
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<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timeoutHandle: ReturnType<typeof setTimeout>;

const timeoutPromise = new Promise<never>((_, 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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")
);
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
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;

/**
* ENS parameters
* 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";
Expand All @@ -24,43 +23,24 @@ interface InterfacesAvailable {
[interfaceHash: string]: boolean;
}

async function getEthersProviderByNetwork(network: Network): Promise<string> {
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<Content> {
const _getEthersProviderByNetwork = memoize(getEthersProviderByNetwork, {
promise: true,
maxAge: providerUrlCacheMs
});
const _resolveDomain = memoize(resolveDomain, {
promise: true,
maxAge: domainsCacheMs
});
return async function (domain: string): Promise<Content> {
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);
};
}

/**
* 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
Expand Down Expand Up @@ -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

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 => `<a href="${url}">${text || url}</a>`;
Expand All @@ -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.
<br />
${e.message}`,
e
);
}

export function noRopsten(): string {
return base(
"Ropsten not installed",
`Please install the Ropsten DNP (DAppNode package) to resolve .test domains
<br />
${a(`${adminUiInstallUrl}/${ropstenName}`, "Install Ropsten")}`
);
}

export function noSwarm(e: Error): string {
return base(
"Swarm not installed",
Expand All @@ -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.
<br />
${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.
<br />
${a(adminUiPackagesUrl, "Check core packages status")}`,
e
);
}

export function ethSyncing(): string {
return base(
"Page Not Available",
Expand Down
Loading