diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 081a3f939..46799747c 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -96,3 +96,13 @@ jobs: concurrency: group: ${{ github.workflow }}-clock-rocks-${{ github.ref || github.run_id }} cancel-in-progress: true + + e2e-admin-password: + name: E2E Admin Password + uses: ./.github/workflows/_setup-e2e.yml + with: + justfile_recipe: "e2e-admin-password" + + concurrency: + group: ${{ github.workflow }}-admin-password-${{ github.ref || github.run_id }} + cancel-in-progress: true diff --git a/.gitignore b/.gitignore index 0454f0fd1..dd7200e47 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ stratus.log # OS specific .DS_Store +.aider* diff --git a/e2e/cloudwalk-contracts/integration/test/leader-follower-change.test.ts b/e2e/cloudwalk-contracts/integration/test/leader-follower-change.test.ts index 132118801..e556e5897 100644 --- a/e2e/cloudwalk-contracts/integration/test/leader-follower-change.test.ts +++ b/e2e/cloudwalk-contracts/integration/test/leader-follower-change.test.ts @@ -16,7 +16,7 @@ describe("Leader & Follower change integration test", function () { const version = await sendWithRetry("stratus_version", []); expect(version).to.have.nested.property("git.commit"); expect(version.git.commit).to.be.a("string"); - expect(version.git.commit).to.have.lengthOf(7); + expect(version.git.commit.length).to.be.oneOf([7, 8]); }); it("Validate initial Follower state, health and version", async function () { @@ -32,7 +32,7 @@ describe("Leader & Follower change integration test", function () { const version = await sendWithRetry("stratus_version", []); expect(version).to.have.nested.property("git.commit"); expect(version.git.commit).to.be.a("string"); - expect(version.git.commit).to.have.lengthOf(7); + expect(version.git.commit.length).to.be.oneOf([7, 8]); }); it("Change Leader to Leader should return false", async function () { diff --git a/e2e/test/admin/e2e-admin-password-disabled.test.ts b/e2e/test/admin/e2e-admin-password-disabled.test.ts new file mode 100644 index 000000000..5bc24036f --- /dev/null +++ b/e2e/test/admin/e2e-admin-password-disabled.test.ts @@ -0,0 +1,26 @@ +import { expect } from "chai"; + +import { send, sendReset } from "../helpers/rpc"; + +describe("Admin Password (without password set)", () => { + before(async () => { + await sendReset(); + }); + + it("should accept requests without password", async () => { + const result = await send("stratus_enableTransactions", []); + expect(result).to.be.true; + + // Cleanup - disable transactions + await send("stratus_disableTransactions", []); + }); + + it("should accept requests with any password", async () => { + const headers = { Authorization: "Password random123" }; + const result = await send("stratus_enableTransactions", [], headers); + expect(result).to.be.true; + + // Cleanup - disable transactions + await send("stratus_disableTransactions", [], headers); + }); +}); diff --git a/e2e/test/admin/e2e-admin-password-enabled.test.ts b/e2e/test/admin/e2e-admin-password-enabled.test.ts new file mode 100644 index 000000000..0f20ae660 --- /dev/null +++ b/e2e/test/admin/e2e-admin-password-enabled.test.ts @@ -0,0 +1,32 @@ +import { expect } from "chai"; + +import { send, sendAndGetError, sendReset } from "../helpers/rpc"; + +describe("Admin Password (with password set)", () => { + before(async () => { + await sendReset(); + }); + + it("should reject requests without password", async () => { + const error = await sendAndGetError("stratus_enableTransactions", []); + console.log(error); + expect(error.code).eq(-32009); // Internal error + expect(error.message).to.contain("Incorrect password"); + }); + + it("should reject requests with wrong password", async () => { + const headers = { Authorization: "Password wrong123" }; + const error = await sendAndGetError("stratus_enableTransactions", [], headers); + expect(error.code).eq(-32009); // Internal error + expect(error.message).to.contain("Incorrect password"); + }); + + it("should accept requests with correct password", async () => { + const headers = { Authorization: "Password test123" }; + const result = await send("stratus_enableTransactions", [], headers); + expect(result).to.be.true; + + // Cleanup - disable transactions + await send("stratus_disableTransactions", [], headers); + }); +}); diff --git a/e2e/test/helpers/rpc.ts b/e2e/test/helpers/rpc.ts index a3cacafb7..57c46de59 100644 --- a/e2e/test/helpers/rpc.ts +++ b/e2e/test/helpers/rpc.ts @@ -111,7 +111,11 @@ if (process.env.RPC_LOG) { // Sends an RPC request to the blockchain, returning full response. let requestId = 0; -export async function sendAndGetFullResponse(method: string, params: any[] = []): Promise { +export async function sendAndGetFullResponse( + method: string, + params: any[] = [], + headers: Record = {}, +): Promise { for (let i = 0; i < params.length; ++i) { const param = params[i]; if (param instanceof Account) { @@ -130,8 +134,14 @@ export async function sendAndGetFullResponse(method: string, params: any[] = []) console.log("REQ ->", JSON.stringify(payload)); } + // prepare headers + const requestHeaders = { + "Content-Type": "application/json", + ...headers, + }; + // execute request and log response - const response = await axios.post(providerUrl, payload, { headers: { "Content-Type": "application/json" } }); + const response = await axios.post(providerUrl, payload, { headers: requestHeaders }); if (process.env.RPC_LOG) { console.log("RESP <-", JSON.stringify(response.data)); } @@ -140,15 +150,19 @@ export async function sendAndGetFullResponse(method: string, params: any[] = []) } // Sends an RPC request to the blockchain, returning its result field. -export async function send(method: string, params: any[] = []): Promise { - const response = await sendAndGetFullResponse(method, params); +export async function send(method: string, params: any[] = [], headers: Record = {}): Promise { + const response = await sendAndGetFullResponse(method, params, headers); return response.data.result; } // Sends an RPC request to the blockchain, returning its error field. // Use it when you expect the RPC call to fail. -export async function sendAndGetError(method: string, params: any[] = []): Promise { - const response = await sendAndGetFullResponse(method, params); +export async function sendAndGetError( + method: string, + params: any[] = [], + headers: Record = {}, +): Promise { + const response = await sendAndGetFullResponse(method, params, headers); return response.data.error; } diff --git a/justfile b/justfile index b97c82bc7..40f0f1851 100644 --- a/justfile +++ b/justfile @@ -159,6 +159,25 @@ e2e network="stratus" block_modes="automine" test="": fi done +# E2E: Execute admin password tests +e2e-admin-password: + #!/bin/bash + cd e2e + + # Start Stratus with password set + just _log "Running admin password tests with password set" + ADMIN_PASSWORD=test123 just run -a 0.0.0.0:3000 > /dev/null & + just _wait_for_stratus + npx hardhat test test/admin/e2e-admin-password-enabled.test.ts --network stratus + killport 3000 + + # Start Stratus without password set + just _log "Running admin password tests without password set" + just run -a 0.0.0.0:3000 > /dev/null & + just _wait_for_stratus + npx hardhat test test/admin/e2e-admin-password-disabled.test.ts --network stratus + killport 3000 + # E2E: Starts and execute Hardhat tests in Hardhat e2e-hardhat block-mode="automine" test="": #!/bin/bash @@ -599,4 +618,4 @@ stratus-test-coverage *args="": -rm utils/deploy/deploy_02.log */ - cargo llvm-cov report {{args}} \ No newline at end of file + cargo llvm-cov report {{args}} diff --git a/src/eth/primitives/stratus_error.rs b/src/eth/primitives/stratus_error.rs index 039db494f..1176586b4 100644 --- a/src/eth/primitives/stratus_error.rs +++ b/src/eth/primitives/stratus_error.rs @@ -192,6 +192,10 @@ pub enum StratusError { #[strum(props(kind = "server_state"))] StratusNotFollower, + #[error("Incorrect password, cancelling operation.")] + #[strum(props(kind = "server_state"))] + InvalidPassword, + #[error("Stratus node is already in the process of changing mode.")] #[strum(props(kind = "server_state"))] ModeChangeInProgress, diff --git a/src/eth/rpc/rpc_http_middleware.rs b/src/eth/rpc/rpc_http_middleware.rs index 608f8166f..3511f5546 100644 --- a/src/eth/rpc/rpc_http_middleware.rs +++ b/src/eth/rpc/rpc_http_middleware.rs @@ -12,6 +12,7 @@ use reqwest::header::HeaderMap; use reqwest::header::HeaderValue; use tower::Service; +use crate::eth::primitives::StratusError; use crate::eth::rpc::RpcClientApp; use crate::ext::not; @@ -36,12 +37,46 @@ where fn call(&mut self, mut request: HttpRequest) -> Self::Future { let client_app = parse_client_app(request.headers(), request.uri()); + let authentication = parse_admin_password(request.headers()); request.extensions_mut().insert(client_app); + request.extensions_mut().insert(authentication); Box::pin(self.service.call(request).map_err(Into::into)) } } +#[derive(Debug, Clone)] +pub enum Authentication { + Admin, + None, +} + +impl Authentication { + pub fn auth_admin(&self) -> Result<(), StratusError> { + if matches!(self, Authentication::Admin) { + return Ok(()); + } + Err(StratusError::InvalidPassword) + } +} + +/// Checks if the provided admin password is correct +fn parse_admin_password(headers: &HeaderMap) -> Authentication { + let real_pass = match std::env::var("ADMIN_PASSWORD") { + Ok(pass) if !pass.is_empty() => pass, + _ => return Authentication::Admin, + }; + + match headers + .get("Authorization") + .and_then(|val| val.to_str().ok()) + .and_then(|val| val.strip_prefix("Password ")) + { + Some(password) if password == real_pass => Authentication::Admin, + _ => Authentication::None, + } +} + /// Extracts the client application name from the `app` query parameter. fn parse_client_app(headers: &HeaderMap, uri: &Uri) -> RpcClientApp { fn try_query_params(uri: &Uri) -> Option { diff --git a/src/eth/rpc/rpc_middleware.rs b/src/eth/rpc/rpc_middleware.rs index d777ea060..9f648c243 100644 --- a/src/eth/rpc/rpc_middleware.rs +++ b/src/eth/rpc/rpc_middleware.rs @@ -87,6 +87,8 @@ impl<'a> RpcServiceT<'a> for RpcMiddleware { _ => None, }; + let is_admin = request.extensions.is_admin(); + let client = if let Some(tx_client) = tx.as_ref().and_then(|tx| tx.client.as_ref()) { let val = tx_client.clone(); request.extensions_mut().insert(val); @@ -115,6 +117,7 @@ impl<'a> RpcServiceT<'a> for RpcMiddleware { rpc_tx_function = %tx.as_ref().map(|tx|tx.function).or_empty(), rpc_tx_from = %tx.as_ref().and_then(|tx|tx.from).or_empty(), rpc_tx_to = %tx.as_ref().and_then(|tx|tx.to).or_empty(), + is_admin = %is_admin, "rpc request" ); diff --git a/src/eth/rpc/rpc_parser.rs b/src/eth/rpc/rpc_parser.rs index 793603c5f..e4f654839 100644 --- a/src/eth/rpc/rpc_parser.rs +++ b/src/eth/rpc/rpc_parser.rs @@ -5,6 +5,7 @@ use jsonrpsee::Extensions; use rlp::Decodable; use tracing::Span; +use super::rpc_http_middleware::Authentication; use crate::eth::primitives::StratusError; use crate::eth::rpc::rpc_client_app::RpcClientApp; use crate::ext::type_basename; @@ -15,6 +16,12 @@ pub trait RpcExtensionsExt { /// Returns the client performing the JSON-RPC request. fn rpc_client(&self) -> &RpcClientApp; + /// Returns current Authentication. + fn authentication(&self) -> &Authentication; + + /// Returns wheather admin authentication suceeded. + fn is_admin(&self) -> bool; + /// Enters RpcMiddleware request span if present. fn enter_middleware_span(&self) -> Option>; } @@ -24,6 +31,14 @@ impl RpcExtensionsExt for Extensions { self.get::().unwrap_or(&RpcClientApp::Unknown) } + fn authentication(&self) -> &Authentication { + self.get::().unwrap_or(&Authentication::None) + } + + fn is_admin(&self) -> bool { + matches!(self.authentication(), Authentication::Admin) + } + fn enter_middleware_span(&self) -> Option> { self.get::().map(|s| s.enter()).map(EnteredWrap::new) } diff --git a/src/eth/rpc/rpc_server.rs b/src/eth/rpc/rpc_server.rs index 65ad2f8a8..412ee3dde 100644 --- a/src/eth/rpc/rpc_server.rs +++ b/src/eth/rpc/rpc_server.rs @@ -321,6 +321,7 @@ fn stratus_reset(_: Params<'_>, ctx: Arc, _: Extensions) -> Result = Lazy::new(|| Semaphore::new(1)); async fn stratus_change_to_leader(_: Params<'_>, ctx: Arc, ext: Extensions) -> Result { + ext.authentication().auth_admin()?; let permit = MODE_CHANGE_SEMAPHORE.try_acquire(); let _permit: SemaphorePermit = match permit { Ok(permit) => permit, @@ -370,6 +371,7 @@ async fn stratus_change_to_leader(_: Params<'_>, ctx: Arc, ext: Exte } async fn stratus_change_to_follower(params: Params<'_>, ctx: Arc, ext: Extensions) -> Result { + ext.authentication().auth_admin()?; let permit = MODE_CHANGE_SEMAPHORE.try_acquire(); let _permit: SemaphorePermit = match permit { Ok(permit) => permit, @@ -423,7 +425,8 @@ async fn stratus_change_to_follower(params: Params<'_>, ctx: Arc, ex Ok(json!(true)) } -async fn stratus_init_importer(params: Params<'_>, ctx: Arc, _: Extensions) -> Result { +async fn stratus_init_importer(params: Params<'_>, ctx: Arc, ext: Extensions) -> Result { + ext.authentication().auth_admin()?; let (params, external_rpc) = next_rpc_param::(params.sequence())?; let (params, external_rpc_ws) = next_rpc_param::(params)?; let (params, raw_external_rpc_timeout) = next_rpc_param::(params)?; @@ -449,7 +452,8 @@ async fn stratus_init_importer(params: Params<'_>, ctx: Arc, _: Exte importer_config.init_follower_importer(ctx).await } -fn stratus_shutdown_importer(_: Params<'_>, ctx: &RpcContext, _: &Extensions) -> Result { +fn stratus_shutdown_importer(_: Params<'_>, ctx: &RpcContext, ext: &Extensions) -> Result { + ext.authentication().auth_admin()?; if GlobalState::get_node_mode() != NodeMode::Follower { tracing::error!("node is currently not a follower"); return Err(StratusError::StratusNotFollower); @@ -468,8 +472,10 @@ fn stratus_shutdown_importer(_: Params<'_>, ctx: &RpcContext, _: &Extensions) -> Ok(json!(true)) } -async fn stratus_change_miner_mode(params: Params<'_>, ctx: Arc, _: Extensions) -> Result { +async fn stratus_change_miner_mode(params: Params<'_>, ctx: Arc, ext: Extensions) -> Result { + ext.authentication().auth_admin()?; let (_, mode_str) = next_rpc_param::(params.sequence())?; + let mode = MinerMode::from_str(&mode_str).map_err(|e| { tracing::error!(reason = ?e, "failed to parse miner mode"); StratusError::MinerModeParamInvalid @@ -530,34 +536,40 @@ async fn change_miner_mode(new_mode: MinerMode, ctx: &RpcContext) -> Result, _: &RpcContext, _: &Extensions) -> bool { +fn stratus_enable_unknown_clients(_: Params<'_>, _: &RpcContext, ext: &Extensions) -> Result { + ext.authentication().auth_admin()?; GlobalState::set_unknown_client_enabled(true); - GlobalState::is_unknown_client_enabled() + Ok(GlobalState::is_unknown_client_enabled()) } -fn stratus_disable_unknown_clients(_: Params<'_>, _: &RpcContext, _: &Extensions) -> bool { +fn stratus_disable_unknown_clients(_: Params<'_>, _: &RpcContext, ext: &Extensions) -> Result { + ext.authentication().auth_admin()?; GlobalState::set_unknown_client_enabled(false); - GlobalState::is_unknown_client_enabled() + Ok(GlobalState::is_unknown_client_enabled()) } -fn stratus_enable_transactions(_: Params<'_>, _: &RpcContext, _: &Extensions) -> bool { +fn stratus_enable_transactions(_: Params<'_>, _: &RpcContext, ext: &Extensions) -> Result { + ext.authentication().auth_admin()?; GlobalState::set_transactions_enabled(true); - GlobalState::is_transactions_enabled() + Ok(GlobalState::is_transactions_enabled()) } -fn stratus_disable_transactions(_: Params<'_>, _: &RpcContext, _: &Extensions) -> bool { +fn stratus_disable_transactions(_: Params<'_>, _: &RpcContext, ext: &Extensions) -> Result { + ext.authentication().auth_admin()?; GlobalState::set_transactions_enabled(false); - GlobalState::is_transactions_enabled() + Ok(GlobalState::is_transactions_enabled()) } -fn stratus_enable_miner(_: Params<'_>, ctx: &RpcContext, _: &Extensions) -> bool { +fn stratus_enable_miner(_: Params<'_>, ctx: &RpcContext, ext: &Extensions) -> Result { + ext.authentication().auth_admin()?; ctx.miner.unpause(); - true + Ok(true) } -fn stratus_disable_miner(_: Params<'_>, ctx: &RpcContext, _: &Extensions) -> bool { +fn stratus_disable_miner(_: Params<'_>, ctx: &RpcContext, ext: &Extensions) -> Result { + ext.authentication().auth_admin()?; ctx.miner.pause(); - false + Ok(false) } /// Returns the count of executed transactions waiting to enter the next block.