bootstrap rust services, auth, and compose stack

This commit is contained in:
2026-03-05 14:51:02 +01:00
parent 1238079454
commit 88db9805b5
25 changed files with 3576 additions and 22 deletions

13
apps/admin-ui/Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "admin-ui"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
axum.workspace = true
stripstream-core = { path = "../../crates/core" }
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

22
apps/admin-ui/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM rust:1-bookworm AS builder
WORKDIR /app
COPY Cargo.toml ./
COPY apps/api/Cargo.toml apps/api/Cargo.toml
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
COPY apps/admin-ui/Cargo.toml apps/admin-ui/Cargo.toml
COPY crates/core/Cargo.toml crates/core/Cargo.toml
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
COPY apps/api/src apps/api/src
COPY apps/indexer/src apps/indexer/src
COPY apps/admin-ui/src apps/admin-ui/src
COPY crates/core/src crates/core/src
COPY crates/parsers/src crates/parsers/src
RUN cargo build --release -p admin-ui
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/admin-ui /usr/local/bin/admin-ui
EXPOSE 8082
CMD ["/usr/local/bin/admin-ui"]

32
apps/admin-ui/src/main.rs Normal file
View File

@@ -0,0 +1,32 @@
use axum::{response::Html, routing::get, Router};
use stripstream_core::config::AdminUiConfig;
use tracing::info;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
std::env::var("RUST_LOG").unwrap_or_else(|_| "admin_ui=info,axum=info".to_string()),
)
.init();
let config = AdminUiConfig::from_env();
let app = Router::new()
.route("/health", get(health))
.route("/", get(index));
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
info!(addr = %config.listen_addr, "admin ui listening");
axum::serve(listener, app).await?;
Ok(())
}
async fn health() -> &'static str {
"ok"
}
async fn index() -> Html<&'static str> {
Html(
"<html><body><h1>Stripstream Admin</h1><p>UI skeleton ready. Next: libraries, jobs, tokens screens.</p></body></html>",
)
}

22
apps/api/Cargo.toml Normal file
View File

@@ -0,0 +1,22 @@
[package]
name = "api"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
argon2.workspace = true
axum.workspace = true
base64.workspace = true
chrono.workspace = true
stripstream-core = { path = "../../crates/core" }
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
tokio.workspace = true
tower.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
uuid.workspace = true

22
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM rust:1-bookworm AS builder
WORKDIR /app
COPY Cargo.toml ./
COPY apps/api/Cargo.toml apps/api/Cargo.toml
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
COPY apps/admin-ui/Cargo.toml apps/admin-ui/Cargo.toml
COPY crates/core/Cargo.toml crates/core/Cargo.toml
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
COPY apps/api/src apps/api/src
COPY apps/indexer/src apps/indexer/src
COPY apps/admin-ui/src apps/admin-ui/src
COPY crates/core/src crates/core/src
COPY crates/parsers/src crates/parsers/src
RUN cargo build --release -p api
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/api /usr/local/bin/api
EXPOSE 8080
CMD ["/usr/local/bin/api"]

93
apps/api/src/auth.rs Normal file
View File

@@ -0,0 +1,93 @@
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use axum::{
extract::{Request, State},
http::header::AUTHORIZATION,
middleware::Next,
response::Response,
};
use chrono::Utc;
use sqlx::Row;
use crate::{error::ApiError, AppState};
#[derive(Clone, Debug)]
pub enum Scope {
Admin,
Read,
}
pub async fn require_admin(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, ApiError> {
let token = bearer_token(&req).ok_or_else(|| ApiError::unauthorized("missing bearer token"))?;
let scope = authenticate(&state, token).await?;
if !matches!(scope, Scope::Admin) {
return Err(ApiError::forbidden("admin scope required"));
}
req.extensions_mut().insert(scope);
Ok(next.run(req).await)
}
fn bearer_token(req: &Request) -> Option<&str> {
req.headers()
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
}
async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError> {
if token == state.bootstrap_token.as_ref() {
return Ok(Scope::Admin);
}
let prefix = parse_prefix(token).ok_or_else(|| ApiError::unauthorized("invalid token format"))?;
let maybe_row = sqlx::query(
r#"
SELECT id, token_hash, scope
FROM api_tokens
WHERE prefix = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())
"#,
)
.bind(prefix)
.fetch_optional(&state.pool)
.await?;
let row = maybe_row.ok_or_else(|| ApiError::unauthorized("invalid token"))?;
let token_hash: String = row.try_get("token_hash").map_err(|_| ApiError::unauthorized("invalid token"))?;
let parsed_hash = PasswordHash::new(&token_hash).map_err(|_| ApiError::unauthorized("invalid token"))?;
Argon2::default()
.verify_password(token.as_bytes(), &parsed_hash)
.map_err(|_| ApiError::unauthorized("invalid token"))?;
let token_id: uuid::Uuid = row.try_get("id").map_err(|_| ApiError::unauthorized("invalid token"))?;
sqlx::query("UPDATE api_tokens SET last_used_at = $1 WHERE id = $2")
.bind(Utc::now())
.bind(token_id)
.execute(&state.pool)
.await?;
let scope: String = row.try_get("scope").map_err(|_| ApiError::unauthorized("invalid token"))?;
match scope.as_str() {
"admin" => Ok(Scope::Admin),
"read" => Ok(Scope::Read),
_ => Err(ApiError::unauthorized("invalid token scope")),
}
}
fn parse_prefix(token: &str) -> Option<&str> {
let mut parts = token.split('_');
let namespace = parts.next()?;
let prefix = parts.next()?;
let secret = parts.next()?;
if namespace != "stl" || secret.is_empty() || prefix.len() < 6 {
return None;
}
Some(prefix)
}

62
apps/api/src/error.rs Normal file
View File

@@ -0,0 +1,62 @@
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde::Serialize;
#[derive(Debug)]
pub struct ApiError {
pub status: StatusCode,
pub message: String,
}
#[derive(Serialize)]
struct ErrorBody<'a> {
error: &'a str,
}
impl ApiError {
pub fn bad_request(message: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: message.into(),
}
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self {
status: StatusCode::UNAUTHORIZED,
message: message.into(),
}
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self {
status: StatusCode::FORBIDDEN,
message: message.into(),
}
}
pub fn not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
message: message.into(),
}
}
pub fn internal(message: impl Into<String>) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: message.into(),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.status, Json(ErrorBody { error: &self.message })).into_response()
}
}
impl From<sqlx::Error> for ApiError {
fn from(err: sqlx::Error) -> Self {
Self::internal(format!("database error: {err}"))
}
}

101
apps/api/src/libraries.rs Normal file
View File

@@ -0,0 +1,101 @@
use std::path::{Path, PathBuf};
use axum::{extract::{Path as AxumPath, State}, Json};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use uuid::Uuid;
use crate::{error::ApiError, AppState};
#[derive(Serialize)]
pub struct LibraryDto {
pub id: Uuid,
pub name: String,
pub root_path: String,
pub enabled: bool,
}
#[derive(Deserialize)]
pub struct CreateLibraryInput {
pub name: String,
pub root_path: String,
}
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryDto>>, ApiError> {
let rows = sqlx::query("SELECT id, name, root_path, enabled FROM libraries ORDER BY created_at DESC")
.fetch_all(&state.pool)
.await?;
let items = rows
.into_iter()
.map(|row| LibraryDto {
id: row.get("id"),
name: row.get("name"),
root_path: row.get("root_path"),
enabled: row.get("enabled"),
})
.collect();
Ok(Json(items))
}
pub async fn create_library(
State(state): State<AppState>,
Json(input): Json<CreateLibraryInput>,
) -> Result<Json<LibraryDto>, ApiError> {
if input.name.trim().is_empty() {
return Err(ApiError::bad_request("name is required"));
}
let canonical = canonicalize_library_root(&input.root_path)?;
let id = Uuid::new_v4();
let root_path = canonical.to_string_lossy().to_string();
sqlx::query(
"INSERT INTO libraries (id, name, root_path, enabled) VALUES ($1, $2, $3, TRUE)",
)
.bind(id)
.bind(input.name.trim())
.bind(&root_path)
.execute(&state.pool)
.await?;
Ok(Json(LibraryDto {
id,
name: input.name.trim().to_string(),
root_path,
enabled: true,
}))
}
pub async fn delete_library(
State(state): State<AppState>,
AxumPath(id): AxumPath<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
let result = sqlx::query("DELETE FROM libraries WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("library not found"));
}
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
}
fn canonicalize_library_root(root_path: &str) -> Result<PathBuf, ApiError> {
let path = Path::new(root_path);
if !path.is_absolute() {
return Err(ApiError::bad_request("root_path must be absolute"));
}
let canonical = std::fs::canonicalize(path)
.map_err(|_| ApiError::bad_request("root_path does not exist or is inaccessible"))?;
if !canonical.is_dir() {
return Err(ApiError::bad_request("root_path must point to a directory"));
}
Ok(canonical)
}

58
apps/api/src/main.rs Normal file
View File

@@ -0,0 +1,58 @@
mod auth;
mod error;
mod libraries;
mod tokens;
use std::sync::Arc;
use axum::{middleware, routing::{delete, get}, Router};
use stripstream_core::config::ApiConfig;
use sqlx::postgres::PgPoolOptions;
use tracing::info;
#[derive(Clone)]
struct AppState {
pool: sqlx::PgPool,
bootstrap_token: Arc<str>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
std::env::var("RUST_LOG").unwrap_or_else(|_| "api=info,axum=info".to_string()),
)
.init();
let config = ApiConfig::from_env()?;
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(&config.database_url)
.await?;
let state = AppState {
pool,
bootstrap_token: Arc::from(config.api_bootstrap_token),
};
let protected = Router::new()
.route("/libraries", get(libraries::list_libraries).post(libraries::create_library))
.route("/libraries/:id", delete(libraries::delete_library))
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
.route("/admin/tokens/:id", delete(tokens::revoke_token))
.layer(middleware::from_fn_with_state(state.clone(), auth::require_admin));
let app = Router::new()
.route("/health", get(health))
.merge(protected)
.with_state(state);
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
info!(addr = %config.listen_addr, "api listening");
axum::serve(listener, app).await?;
Ok(())
}
async fn health() -> &'static str {
"ok"
}

122
apps/api/src/tokens.rs Normal file
View File

@@ -0,0 +1,122 @@
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use axum::{extract::{Path, State}, Json};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use chrono::{DateTime, Utc};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use uuid::Uuid;
use crate::{error::ApiError, AppState};
#[derive(Deserialize)]
pub struct CreateTokenInput {
pub name: String,
pub scope: Option<String>,
}
#[derive(Serialize)]
pub struct CreatedToken {
pub id: Uuid,
pub name: String,
pub scope: String,
pub token: String,
pub prefix: String,
}
#[derive(Serialize)]
pub struct TokenItem {
pub id: Uuid,
pub name: String,
pub scope: String,
pub prefix: String,
pub last_used_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
pub async fn create_token(
State(state): State<AppState>,
Json(input): Json<CreateTokenInput>,
) -> Result<Json<CreatedToken>, ApiError> {
if input.name.trim().is_empty() {
return Err(ApiError::bad_request("name is required"));
}
let scope = match input.scope.as_deref().unwrap_or("read") {
"admin" => "admin",
"read" => "read",
_ => return Err(ApiError::bad_request("scope must be 'admin' or 'read'")),
};
let mut random = [0u8; 24];
OsRng.fill_bytes(&mut random);
let secret = URL_SAFE_NO_PAD.encode(random);
let prefix: String = secret.chars().take(8).collect();
let token = format!("stl_{prefix}_{secret}");
let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
let token_hash = Argon2::default()
.hash_password(token.as_bytes(), &salt)
.map_err(|_| ApiError::internal("failed to hash token"))?
.to_string();
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO api_tokens (id, name, prefix, token_hash, scope) VALUES ($1, $2, $3, $4, $5)",
)
.bind(id)
.bind(input.name.trim())
.bind(&prefix)
.bind(token_hash)
.bind(scope)
.execute(&state.pool)
.await?;
Ok(Json(CreatedToken {
id,
name: input.name.trim().to_string(),
scope: scope.to_string(),
token,
prefix,
}))
}
pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<TokenItem>>, ApiError> {
let rows = sqlx::query(
"SELECT id, name, scope, prefix, last_used_at, revoked_at, created_at FROM api_tokens ORDER BY created_at DESC",
)
.fetch_all(&state.pool)
.await?;
let items = rows
.into_iter()
.map(|row| TokenItem {
id: row.get("id"),
name: row.get("name"),
scope: row.get("scope"),
prefix: row.get("prefix"),
last_used_at: row.get("last_used_at"),
revoked_at: row.get("revoked_at"),
created_at: row.get("created_at"),
})
.collect();
Ok(Json(items))
}
pub async fn revoke_token(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
let result = sqlx::query("UPDATE api_tokens SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL")
.bind(id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("token not found"));
}
Ok(Json(serde_json::json!({"revoked": true, "id": id})))
}

13
apps/indexer/Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "indexer"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
axum.workspace = true
stripstream-core = { path = "../../crates/core" }
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

22
apps/indexer/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM rust:1-bookworm AS builder
WORKDIR /app
COPY Cargo.toml ./
COPY apps/api/Cargo.toml apps/api/Cargo.toml
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
COPY apps/admin-ui/Cargo.toml apps/admin-ui/Cargo.toml
COPY crates/core/Cargo.toml crates/core/Cargo.toml
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
COPY apps/api/src apps/api/src
COPY apps/indexer/src apps/indexer/src
COPY apps/admin-ui/src apps/admin-ui/src
COPY crates/core/src crates/core/src
COPY crates/parsers/src crates/parsers/src
RUN cargo build --release -p indexer
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget unrar-free && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/indexer /usr/local/bin/indexer
EXPOSE 8081
CMD ["/usr/local/bin/indexer"]

24
apps/indexer/src/main.rs Normal file
View File

@@ -0,0 +1,24 @@
use axum::{routing::get, Router};
use stripstream_core::config::IndexerConfig;
use tracing::info;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
std::env::var("RUST_LOG").unwrap_or_else(|_| "indexer=info,axum=info".to_string()),
)
.init();
let config = IndexerConfig::from_env();
let app = Router::new().route("/health", get(health));
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
info!(addr = %config.listen_addr, "indexer listening");
axum::serve(listener, app).await?;
Ok(())
}
async fn health() -> &'static str {
"ok"
}