cactus-agent
Version:
cactus rpc/websocket client library
317 lines (279 loc) • 10.9 kB
text/typescript
import {Agent as HttpsAgent, request as httpsRequest, RequestOptions} from "https";
import {Agent as HttpAgent, OutgoingHttpHeaders, request as httpRequest} from "http";
import type {checkServerIdentity} from "tls";
import {existsSync, readFileSync} from "fs";
import * as JSONbigBuilder from "@chiamine/json-bigint";
import {getLogger} from "../logger";
import {configPath as defaultConfigPath, getConfig, resolveFromCactusRoot, TConfig} from "../config/index";
const JSONbig = JSONbigBuilder({
useNativeBigInt: true,
alwaysParseAsBig: false,
});
type TDestination = "farmer"|"harvester"|"full_node"|"wallet"|"data_layer"|"daemon"|"pool";
export function getConnectionInfoFromConfig(destination: TDestination, config: TConfig){
let hostname = "localhost";
let port = -1;
if(destination === "daemon"){
port = +(config["/daemon_port"] as string);
}
else if(destination === "farmer"){
port = +(config["/farmer/rpc_port"] as string);
}
else if(destination === "harvester"){
port = +(config["/harvester/rpc_port"] as string);
}
else if(destination === "full_node"){
port = +(config["/full_node/rpc_port"] as string);
}
else if(destination === "wallet"){
port = +(config["/wallet/rpc_port"] as string);
}
else if(destination === "data_layer"){
port = +(config["/data_layer/rpc_port"] as string);
}
else if(destination === "pool"){
const pool_url = config["/pool/pool_list/0/pool_url"] as string;
const regex = /^(https?:\/\/)?([^/:]+):?(\d*)/;
const match = regex.exec(pool_url);
if(match){
hostname = match[2];
port = match[3] ? +match[3] : 80;
}
else{
getLogger().error("Pool list was not found in config.yaml.");
throw new Error("Pool list was not found in config.yaml");
}
}
else{
throw new Error(`Unknown destination: ${destination}`);
}
return {hostname, port};
}
export type TRPCAgentProps = {
protocol: "https";
host: string;
port: number;
ca_cert?: string|Buffer;
client_cert?: string|Buffer;
client_key?: string|Buffer;
skip_hostname_verification?: boolean;
} | {
protocol: "https";
host: string;
port: number;
configPath: string;
skip_hostname_verification?: boolean;
} | {
protocol: "http";
host: string;
port: number;
} | {
service: TDestination;
configPath?: string;
skip_hostname_verification?: boolean;
};
export type ErrorResponse = {
error: string;
success: false;
};
const userAgent = "cactus-agent/1.0.0";
export class RPCAgent {
protected _protocol: "http"|"https";
protected _hostname: string;
protected _port: number;
protected _caCert?: string|Buffer = "";
protected _clientCert?: string|Buffer = "";
protected _clientKey?: string|Buffer = "";
protected _agent: HttpsAgent|HttpAgent;
protected _skip_hostname_verification: boolean = false;
public constructor(props: TRPCAgentProps) {
if("protocol" in props){
this._protocol = props.protocol;
this._hostname = props.host;
this._port = props.port;
if(props.protocol === "https"){
if("configPath" in props){
const config = this._getConfig(props.configPath);
const certs = this._loadCertFilesFromConfig(config);
this._clientCert = certs.clientCert;
this._clientKey = certs.clientKey;
this._caCert = certs.caCert;
this._skip_hostname_verification = Boolean(props.skip_hostname_verification);
}
else{
this._caCert = props.ca_cert;
this._clientCert = props.client_cert;
this._clientKey = props.client_key;
this._skip_hostname_verification = Boolean(props.skip_hostname_verification);
}
this._agent = new HttpsAgent({
host: this._hostname,
port: this._port,
ca: this._caCert,
cert: this._clientCert,
key: this._clientKey,
rejectUnauthorized: Boolean(this._caCert) && this._hostname !== "localhost",
});
}
else{
this._agent = new HttpAgent();
}
}
else{
this._protocol = "https";
const config = this._getConfig("configPath" in props ? props.configPath : undefined);
const {hostname, port} = getConnectionInfoFromConfig(props.service, config);
getLogger().debug(`Picked ${hostname}:${port} for ${props.service}`);
this._hostname = hostname;
this._port = port;
const certs = this._loadCertFilesFromConfig(config);
this._clientCert = certs.clientCert;
this._clientKey = certs.clientKey;
this._caCert = certs.caCert;
this._skip_hostname_verification = Boolean(props.skip_hostname_verification);
this._agent = new HttpsAgent({
host: this._hostname,
port: this._port,
ca: this._caCert,
cert: this._clientCert,
key: this._clientKey,
rejectUnauthorized: Boolean(this._caCert) && this._hostname !== "localhost",
});
}
}
protected _getConfig(configPath?: string){
configPath = configPath || defaultConfigPath;
if (!existsSync(configPath)) {
getLogger().error(`cactus config file does not exist at: ${configPath}`)
throw new Error("cactus config file Not Found.");
}
return getConfig(configPath);
}
protected _loadCertFilesFromConfig(config: TConfig){
const clientCertPath = resolveFromCactusRoot([config["/daemon_ssl/private_crt"]] as string[]);
const clientKeyPath = resolveFromCactusRoot([config["/daemon_ssl/private_key"]] as string[]);
const caCertPath = resolveFromCactusRoot([config["/private_ssl_ca/crt"]] as string[]);
getLogger().debug(`Loading client cert file from ${clientCertPath}`);
getLogger().debug(`Loading client key file from ${clientKeyPath}`);
getLogger().debug(`Loading ca cert file from ${caCertPath}`);
const getCertOrKey = (path: string) => {
if (!existsSync(path)) {
getLogger().error(`ssl crt/key does not exist at: ${path}`)
throw new Error(`crt/key Not Found at ${path}`);
}
return readFileSync(path);
};
const clientCert = getCertOrKey(clientCertPath);
const clientKey = getCertOrKey(clientKeyPath);
const caCert = getCertOrKey(caCertPath);
return {clientCert, clientKey, caCert};
}
public async sendMessage<M extends unknown>(
destination: string,
command: string,
data?: Record<string, unknown>,
): Promise<M | ErrorResponse> {
// parameter `destination` is not used because target rpc server is determined by url.
getLogger().debug(`Sending message. dest=${destination} command=${command}`);
return this.request<M>("POST", command, data);
}
public async request<R>(method: string, path: string, data?: any){
return new Promise((resolve: (v: R) => void, reject) => {
const body = data ? JSONbig.stringify(data) : "{}";
const pathname = `/${path.replace(/^\/+/, "")}`;
const METHOD = method.toUpperCase();
const options: RequestOptions & {checkServerIdentity?: typeof checkServerIdentity;} = {
protocol: this._protocol + ":", // nodejs's https module requires protocol to include ':'.
hostname: this._hostname,
port: `${this._port}`,
path: pathname,
method: METHOD,
agent: this._agent,
headers: {
Accept: "application/json, text/plain, */*",
"User-Agent": userAgent,
} as OutgoingHttpHeaders,
};
if(this._skip_hostname_verification){
options.checkServerIdentity = () => {
return undefined;
};
}
if(METHOD === "POST" || METHOD === "PUT" || METHOD === "DELETE"){
options.headers = {
...(options.headers || {}),
"Content-Type": "application/json;charset=utf-8",
"Content-Length": body.length,
};
}
else if(METHOD === "GET"){
// Add query string if `data` is object.
if(data && typeof data === "object"){
// Remove string after '?' on path to prevent duplication.
let p = options.path as string;
if(/\?/.test(p)){
getLogger().warning("querystring in `path` is replaced by `data`");
p.replace(/\?.*/, "");
}
p += "?";
for(const key in data){
if(data.hasOwnProperty(key)){
p += `${key}=${data[key]}`;
}
}
options.path = p;
}
}
const transporter = this._protocol === "https" ? httpsRequest : httpRequest;
getLogger().debug(`Requesting to ${options.protocol}//${options.hostname}:${options.port}${options.path}`);
const req = transporter(options, (res) => {
if(!res.statusCode || res.statusCode < 200 || res.statusCode >= 300){
getLogger().error(`Status not ok: ${res.statusCode}`);
if(res.statusCode === 404){
getLogger().error(`Maybe the RPCAgent is connecting to different service against target rpc command.`);
getLogger().error(`For example, this happens when invoking 'new_farming_info' rpc command to 'full_node' service, which 'farm' service is correct`);
getLogger().error(`Check invoking command is correct and connecting service/host is right for the command`);
}
return reject(new Error(`Status not ok: ${res.statusCode}`));
}
const chunks: any[] = [];
res.on("data", chunk => {
chunks.push(chunk);
if(chunks.length === 0){
getLogger().debug(`The first response chunk data arrived`);
}
});
res.on("end", () => {
try{
if(chunks.length > 0){
const data = JSONbig.parse(Buffer.concat(chunks).toString());
return resolve(data);
}
// RPC Server should return response like
// {origin: string; destination: string; request_id: string; data: any; ...}
// If no such response is returned, reject it.
getLogger().error(`RPC Server returned no data. This is not expected.`);
reject(new Error("Server responded without expected data"));
}
catch (e) {
getLogger().error(`Failed to parse response data`);
try{
getLogger().error(Buffer.concat(chunks).toString());
}
catch(_){}
reject(new Error("Server responded without expected data"));
}
});
});
req.on("error", error => {
getLogger().error(JSON.stringify(error));
reject(error);
});
if((METHOD === "POST" || METHOD === "PUT" || METHOD === "DELETE") && body){
req.write(body);
}
req.end();
});
}
}
export type TRPCAgent = InstanceType<typeof RPCAgent>;