Skip to content
Merged
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
8 changes: 4 additions & 4 deletions high-storm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ high-storm/docker.sh connections 1 # List node 1's active connections.
```

The Storm listeners are exposed on host ports `9000`, `9001`, and `9002`; their
REST APIs are exposed on `9100`, `9101`, and `9102`.
external APIs are exposed on `9100`, `9101`, and `9102`.
Each node has its own operator platform at `http://127.0.0.1:9200`,
`http://127.0.0.1:9201`, and `http://127.0.0.1:9202`, respectively. For local
development, log in with the matching deterministic secret from
Expand All @@ -39,8 +39,8 @@ The checked-in identities and database password are for local development only.
Copy `config.example.toml` to `config.toml`, then set the listener port, signer key,
and PostgreSQL connection fields. The `service.db.url` value is the database host
and optional port, for example `localhost:5432`. Set `service.ipc_path` to a unique
Unix socket path for each high-storm process running on the same host. The REST API
binds to `service.rest_address`, which defaults to `127.0.0.1:9001`.
Unix socket path for each high-storm process running on the same host. The external API
binds to `service.external_api_address`, which defaults to `127.0.0.1:9001`.

## Initialize a network

Expand Down Expand Up @@ -87,7 +87,7 @@ user that started the node and the root user are authorized to use the socket.
When high-storm uses a non-default `service.ipc_path`, pass the same path to the
client with `--socket <path>`.

## REST API
## External API

Operator identities are compressed secp256k1 public keys. High-storm derives each
key's mainnet P2WPKH address and verifies BIP322-simple signatures against it.
Expand Down
2 changes: 1 addition & 1 deletion high-storm/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
[service]
port = 9000
ipc_path = "/tmp/high-storm.sock"
rest_address = "127.0.0.1:9001"
external_api_address = "127.0.0.1:9001"

[service.signer]
# secpr256k1 32-byte hex-encoded private key, used for signing,
Expand Down
2 changes: 1 addition & 1 deletion high-storm/docker/node-1.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[service]
port = 9000
rest_address = "0.0.0.0:9100"
external_api_address = "0.0.0.0:9100"

[service.signer]
# Development-only deterministic key. Do not use in production.
Expand Down
2 changes: 1 addition & 1 deletion high-storm/docker/node-2.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[service]
port = 9000
rest_address = "0.0.0.0:9100"
external_api_address = "0.0.0.0:9100"

[service.signer]
# Development-only deterministic key. Do not use in production.
Expand Down
2 changes: 1 addition & 1 deletion high-storm/docker/node-3.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[service]
port = 9000
rest_address = "0.0.0.0:9100"
external_api_address = "0.0.0.0:9100"

[service.signer]
# Development-only deterministic key. Do not use in production.
Expand Down
8 changes: 4 additions & 4 deletions high-storm/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ pub struct ServiceConfig {
pub port: u16,
#[serde(default = "default_ipc_path")]
pub ipc_path: PathBuf,
#[serde(default = "default_rest_address")]
pub rest_address: SocketAddr,
#[serde(default = "default_external_api_address")]
pub external_api_address: SocketAddr,
pub signer: SignerConfig,
pub db: DbConfig,
}
Expand All @@ -32,10 +32,10 @@ fn default_ipc_path() -> PathBuf {
"/tmp/high-storm.sock".into()
}

fn default_rest_address() -> SocketAddr {
fn default_external_api_address() -> SocketAddr {
"127.0.0.1:9001"
.parse()
.expect("the default REST address is valid")
.expect("the default external API address is valid")
}

#[derive(Clone, Debug, Deserialize)]
Expand Down
165 changes: 165 additions & 0 deletions high-storm/src/external_api/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
mod operators;
#[cfg(test)]
mod tests;

use std::net::SocketAddr;

use axum::{
Json, Router,
http::StatusCode,
response::{IntoResponse, Response},
routing::any,
};
use serde::Serialize;

use crate::{HighStormHandle, VotingError, db::node_operator::NodeOperatorStore};
use operators::{AuthError, AuthService};

#[derive(Clone)]
pub(super) struct ExternalApiState {
pub(super) node: HighStormHandle,
pub(super) auth: AuthService,
}

pub struct ExternalApiServer {
listener: tokio::net::TcpListener,
router: Router,
}

impl ExternalApiServer {
pub async fn bind(
address: SocketAddr,
node: HighStormHandle,
operators: NodeOperatorStore,
) -> std::io::Result<Self> {
let listener = tokio::net::TcpListener::bind(address).await?;
Ok(Self {
listener,
router: router(node, operators),
})
}

pub fn local_addr(&self) -> std::io::Result<SocketAddr> {
self.listener.local_addr()
}

pub async fn run(self) -> std::io::Result<()> {
axum::serve(self.listener, self.router).await
}
}

pub fn router(node: HighStormHandle, operators: NodeOperatorStore) -> Router {
let state = ExternalApiState {
node,
auth: AuthService::new(operators),
};
Router::new()
.nest(
"/users",
Router::new()
.route("/", any(not_implemented))
.route("/{*path}", any(not_implemented)),
)
.nest("/operators", operators::router())
.with_state(state)
}

#[derive(Serialize)]
struct ErrorBody {
error: String,
}

pub(super) struct ApiError {
status: StatusCode,
message: String,
}

async fn not_implemented() -> ApiError {
ApiError {
status: StatusCode::NOT_IMPLEMENTED,
message: "user API is not implemented".to_string(),
}
}

impl ApiError {
pub(super) fn bad_request(message: impl ToString) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: message.to_string(),
}
}

pub(super) fn unauthorized(message: impl ToString) -> Self {
Self {
status: StatusCode::UNAUTHORIZED,
message: message.to_string(),
}
}

pub(super) fn not_found(message: impl ToString) -> Self {
Self {
status: StatusCode::NOT_FOUND,
message: message.to_string(),
}
}

pub(super) fn internal(message: impl ToString) -> Self {
tracing::error!(error = %message.to_string(), "external API request failed");
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: "internal server error".to_string(),
}
}
}

impl From<AuthError> for ApiError {
fn from(error: AuthError) -> Self {
let status = match error {
AuthError::InvalidPublicKey
| AuthError::InvalidChallenge
| AuthError::InvalidTimestamp
| AuthError::InvalidNonce => StatusCode::BAD_REQUEST,
AuthError::Unauthorized => StatusCode::FORBIDDEN,
AuthError::ReplayedNonce => StatusCode::CONFLICT,
AuthError::InvalidToken | AuthError::InvalidSignature => StatusCode::UNAUTHORIZED,
AuthError::Clock | AuthError::Random | AuthError::Store(_) => {
return Self::internal(error);
}
};
Self {
status,
message: error.to_string(),
}
}
}

impl From<VotingError> for ApiError {
fn from(error: VotingError) -> Self {
let status = match error {
VotingError::InvalidRequest(_) | VotingError::InvalidApproval(_) => {
StatusCode::BAD_REQUEST
}
VotingError::UnknownRequest(_) => StatusCode::NOT_FOUND,
VotingError::DuplicateRequest(_) | VotingError::DuplicateApproval(_) => {
StatusCode::CONFLICT
}
_ => return Self::internal(error),
};
Self {
status,
message: error.to_string(),
}
}
}

impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(
self.status,
Json(ErrorBody {
error: self.message,
}),
)
.into_response()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH},
};

use axum::{Json, extract::State, http::HeaderMap};
use bitcoin::{Address, CompressedPublicKey, address::KnownHrp};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;

use crate::db::node_operator::NodeOperatorStore;
use crate::{
db::node_operator::NodeOperatorStore,
external_api::{ApiError, ExternalApiState},
};

const CHALLENGE_TTL: Duration = Duration::from_secs(5 * 60);
const TOKEN_TTL: Duration = Duration::from_secs(60 * 60);
Expand Down Expand Up @@ -53,6 +57,18 @@ pub struct AccessToken {
pub expires_at: u64,
}

#[derive(Deserialize)]
pub(super) struct ChallengeRequest {
public_key: String,
}

#[derive(Deserialize)]
pub(super) struct TokenRequest {
public_key: String,
message: String,
signature: String,
}

#[derive(Deserialize)]
pub(super) struct SignedRequest<T> {
pub(super) public_key: String,
Expand Down Expand Up @@ -126,7 +142,8 @@ impl AuthService {
.await
}

pub fn write_message<T: Serialize>(
#[cfg(test)]
pub(crate) fn write_message<T: Serialize>(
method: &str,
path: &str,
timestamp: u64,
Expand Down Expand Up @@ -264,6 +281,43 @@ impl AuthService {
}
}

pub(super) async fn issue_challenge(
State(state): State<ExternalApiState>,
Json(request): Json<ChallengeRequest>,
) -> Result<Json<Challenge>, ApiError> {
state
.auth
.issue_challenge(&request.public_key)
.await
.map(Json)
.map_err(Into::into)
}

pub(super) async fn exchange_token(
State(state): State<ExternalApiState>,
Json(request): Json<TokenRequest>,
) -> Result<Json<AccessToken>, ApiError> {
state
.auth
.exchange_token(&request.public_key, &request.message, &request.signature)
.await
.map(Json)
.map_err(Into::into)
}

pub(super) async fn authenticate_bearer(
auth: &AuthService,
headers: &HeaderMap,
) -> Result<(), ApiError> {
let token = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or_else(|| ApiError::unauthorized("missing bearer token"))?;
auth.authenticate_token(token).await?;
Ok(())
}

impl AuthState {
fn cleanup(&mut self, now: u64) {
self.challenges.retain(|_, value| value.expires_at > now);
Expand Down
25 changes: 25 additions & 0 deletions high-storm/src/external_api/operators/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
pub(super) mod auth;
mod state;
mod voting;

use axum::{
Router,
routing::{get, post},
};

use super::ExternalApiState;
pub(super) use auth::{AuthError, AuthService};

pub(super) fn router() -> Router<ExternalApiState> {
Router::new()
.route("/auth/challenge", post(auth::issue_challenge))
.route("/auth/token", post(auth::exchange_token))
.route("/state", get(state::get_network_state))
.route("/state/peers", get(state::get_network_peers))
.route(
"/voting",
get(voting::list_votings).post(voting::create_voting),
)
.route("/voting/{hash}", get(voting::get_voting))
.route("/voting/{hash}/approve", post(voting::approve_voting))
}
Loading
Loading