From 6933b92e79ab27dd3dad03be6fbbf1be428a06a3 Mon Sep 17 00:00:00 2001 From: Josef Date: Thu, 2 Apr 2026 09:06:26 +0200 Subject: [PATCH] =?UTF-8?q?Initial:=20Rust=20CLI=20client=20f=C3=BCr=20opu?= =?UTF-8?q?sR=20Monitor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/commands/add-endpoint.md | 21 +++ .claude/commands/qa-cycle.md | 9 ++ .gitignore | 6 + CLAUDE.md | 125 +++++++++++++++++ Cargo.toml | 53 +++++++ README.md | 62 +++++++++ docs/security.md | 49 +++++++ src/api/client.rs | 145 +++++++++++++++++++ src/api/mod.rs | 5 + src/api/services.rs | 104 ++++++++++++++ src/auth/login.rs | 48 +++++++ src/auth/mod.rs | 5 + src/auth/passticket.rs | 66 +++++++++ src/config/mod.rs | 3 + src/config/settings.rs | 71 ++++++++++ src/main.rs | 229 +++++++++++++++++++++++++++++++ src/models/error.rs | 44 ++++++ src/models/mod.rs | 7 + src/models/query.rs | 85 ++++++++++++ src/models/table.rs | 34 +++++ src/tui/app.rs | 22 +++ src/tui/mod.rs | 4 + tasks/lessons.md | 13 ++ tasks/todo.md | 34 +++++ 24 files changed, 1244 insertions(+) create mode 100644 .claude/commands/add-endpoint.md create mode 100644 .claude/commands/qa-cycle.md create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 docs/security.md create mode 100644 src/api/client.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/services.rs create mode 100644 src/auth/login.rs create mode 100644 src/auth/mod.rs create mode 100644 src/auth/passticket.rs create mode 100644 src/config/mod.rs create mode 100644 src/config/settings.rs create mode 100644 src/main.rs create mode 100644 src/models/error.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/query.rs create mode 100644 src/models/table.rs create mode 100644 src/tui/app.rs create mode 100644 src/tui/mod.rs create mode 100644 tasks/lessons.md create mode 100644 tasks/todo.md diff --git a/.claude/commands/add-endpoint.md b/.claude/commands/add-endpoint.md new file mode 100644 index 0000000..8998758 --- /dev/null +++ b/.claude/commands/add-endpoint.md @@ -0,0 +1,21 @@ +Add a new DB2 REST endpoint to the project. For the given endpoint: + +1. **Models**: Create/update structs in `src/models/` + - Raw struct with `#[serde(rename = "UPPERCASE")]` fields + - Normalized struct with idiomatic Rust names + - `From` impl that trims strings and handles defaults + +2. **Service method**: Add to `src/api/services.rs` + - Typed method on `OpusrServices` + - Correct positional parameters (P1, P2, ...) + - Map raw response to normalized types + +3. **CLI command**: Add subcommand variant to `src/config/settings.rs` + - Implement handler in `main.rs` + - Support both JSON and table output + +4. **Tests**: Write tests in `tests/` + - Mock server with `wiremock` + - Test success, empty result, error cases + +5. Run `/qa-cycle` to validate everything diff --git a/.claude/commands/qa-cycle.md b/.claude/commands/qa-cycle.md new file mode 100644 index 0000000..c5cc49f --- /dev/null +++ b/.claude/commands/qa-cycle.md @@ -0,0 +1,9 @@ +Run the full QA cycle. Fix any errors before proceeding. + +1. `cargo fmt --check` — if it fails, run `cargo fmt` and restart +2. `cargo clippy -- -D warnings` — fix all warnings +3. `cargo test` — all tests must pass +4. `cargo build --release` — verify release build compiles + +If ANY step fails: fix the issue, then restart from step 1. +Only report success when all 4 steps pass consecutively. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..474f1a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +Cargo.lock +*.swp +*.swo +.env +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b1c9840 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,125 @@ +# OpusR-Client — Project Instructions + +## Project Overview +Rust CLI/TUI client for opusR Monitor (z/OS RACF security analysis). +Connects to DB2 Native REST Services on z/OS via HTTPS. +Designed for distribution to enterprise customers with z/OS RACF installations. + +## Architecture +``` +OpusR-Client (Rust binary) + │ + ├── Auth: RACF login → PassTicket (one-time, 10min TTL) + ├── API: HTTPS POST → DB2 DDF Native REST → JSON + └── UI: Terminal TUI (ratatui) or JSON stdout +``` + +- **No middleware**: direct HTTPS to DB2 DDF on z/OS +- **Auth**: Initial RACF login, then PassTicket-based (no password stored) +- **Transport**: TLS mandatory (DDF SECPORT), certificate validation +- **Data**: Read-only SELECT queries, JSON responses + +## z/OS Backend (3 REST Services) +1. `opusrTables` — list tables in OPUSR schema (→ main menu) +2. `opusrColumns` — list columns for a table (→ selection panel) +3. `opusrQuery` — dynamic query via Stored Procedure (→ data view) + +All services use HTTP POST to DB2 DDF. +Parameters are positional: P1, P2, P3... (DB2ServiceManager convention). + +## Tech Stack +- Rust 2021 edition, MSRV 1.75 +- `reqwest` — HTTPS client with native-tls +- `serde` / `serde_json` — JSON serialization +- `ratatui` + `crossterm` — terminal UI +- `clap` — CLI argument parsing +- `secrecy` — password/token handling (zeroize on drop) +- `keyring` — OS keychain for session tokens (optional) +- `tokio` — async runtime + +## Security Requirements (CRITICAL) +- Passwords NEVER stored on disk, NEVER logged, NEVER in error messages +- Use `secrecy::SecretString` for all credential handling +- PassTickets stored only in memory, refreshed before expiry +- TLS certificate validation ON by default (--danger-accept-invalid-certs for dev only) +- No panics in production paths — use Result everywhere +- Audit log: every REST call logged (without credentials) + +## Code Style +- `cargo clippy -- -D warnings` must pass +- `cargo fmt` applied before every commit +- No `unwrap()` or `expect()` except in tests +- Error types: use `thiserror` for library errors, `anyhow` in main +- Doc comments on all public items +- German comments OK for business logic, code identifiers in English + +## Development Workflow (Self-Steering Loop) + +### After every code change: +1. `cargo fmt --check` — formatting +2. `cargo clippy -- -D warnings` — lints +3. `cargo test` — all tests +4. `cargo build --release` — verify release build +5. If ANY step fails → fix immediately, restart from step 1 +6. Only proceed when all 4 steps pass + +### When adding a new feature: +1. Plan in `tasks/todo.md` +2. Define types in `src/models/` +3. Write tests first (TDD) +4. Implement +5. Run full cycle +6. Mark complete in `tasks/todo.md` + +### After ANY correction from the user: +- Update `tasks/lessons.md` with what went wrong +- Write a rule that prevents the same mistake + +## DB2 REST Response Format +```json +{ + "ResultSet Output": [ + {"COL1": "value", "COL2": 123}, + {"COL1": "value2", "COL2": 456} + ] +} +``` +- Column names are UPPERCASE +- CHAR fields are right-padded with spaces → always trim +- NULL values: field is absent from JSON object +- Errors: HTTP 400/401/403/500 with JSON error body + +## Project Structure +``` +src/ + main.rs — CLI entry point, argument parsing + api/ + mod.rs — REST client, request/response handling + client.rs — DB2RestClient struct + services.rs — opusrTables, opusrColumns, opusrQuery + auth/ + mod.rs — authentication module + login.rs — RACF login flow + passticket.rs — PassTicket management (refresh, expire) + config/ + mod.rs — configuration (host, port, schema, TLS) + settings.rs — CLI args + config file + env vars + models/ + mod.rs — data types + table.rs — TableInfo, ColumnInfo + query.rs — QueryFilter, QueryResult + error.rs — OpusrError enum + tui/ + mod.rs — terminal UI (ratatui) + app.rs — app state machine + views/ — table list, column select, data view +tests/ + integration.rs — integration tests with mock server + api_tests.rs — API client unit tests +docs/ + architecture.md — design decisions + security.md — security model documentation +tasks/ + todo.md — task tracker + lessons.md — learned mistakes +``` diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ac0da13 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "opusr-client" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "opusR Monitor client — z/OS RACF security analysis" +authors = ["opus Software GmbH "] +license = "Proprietary" +repository = "https://iws.opus-it.at/opus/opusr-client" + +[dependencies] +# Async runtime +tokio = { version = "1", features = ["full"] } + +# HTTP client +reqwest = { version = "0.12", features = ["json", "native-tls"], default-features = false } + +# JSON +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# CLI +clap = { version = "4", features = ["derive", "env"] } + +# TUI +ratatui = "0.29" +crossterm = "0.28" + +# Security +secrecy = { version = "0.10", features = ["serde"] } + +# Error handling +thiserror = "2" +anyhow = "1" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Config +directories = "6" +toml = "0.8" + +[dev-dependencies] +wiremock = "0.6" +tokio-test = "0.4" +assert_cmd = "2" +predicates = "3" + +[profile.release] +lto = true +strip = true +codegen-units = 1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..007798d --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# OpusR-Client + +Rust CLI-Client für opusR Monitor — z/OS RACF Security Analysis. + +Verbindet sich direkt per HTTPS mit DB2 Native REST Services auf z/OS. +Keine Middleware, keine Java, keine zusätzliche Software beim Kunden. + +## Quick Start + +```bash +# Tabellenliste +opusr --host 10.1.1.2 tables + +# Spalten einer Tabelle +opusr --host 10.1.1.2 columns DSBD + +# Daten abfragen mit Filter +opusr --host 10.1.1.2 query DSBD -f "OWNER=IBMUSER" --limit 50 + +# JSON-Ausgabe +opusr --host 10.1.1.2 --output json tables +``` + +## Build + +```bash +cargo build --release +``` + +Binary: `target/release/opusr-client` (bzw. `.exe` auf Windows) + +## Gitea Repository einrichten + +```bash +# 1. Neues Repo auf Gitea erstellen (im Browser): +# https://iws.opus-it.at/opus/opusr-client + +# 2. Lokales Repo initialisieren: +cd opusr-client +git init +git add . +git commit -m "Initial: Rust CLI client for opusR Monitor" + +# 3. Remote setzen und pushen: +git remote add origin https://iws.opus-it.at/opus/opusr-client.git +git push -u origin main +``` + +## Architektur + +``` +Browser/TUI → HTTPS → z/OS DB2 DDF (Native REST) → opusR DB2 Tables + ↑ + PassTicket Auth (Phase 2) +``` + +## Sicherheit + +- TLS mandatory, Zertifikatvalidierung aktiv +- Passwords nie auf Disk, nie im Log (SecretString + Zeroize) +- PassTicket-basierte Auth (Phase 2): 10-min Token, Einmalverwendung +- Siehe [docs/security.md](docs/security.md) diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..ba32dcb --- /dev/null +++ b/docs/security.md @@ -0,0 +1,49 @@ +# OpusR-Client — Security Model + +## Übersicht + +Der OpusR-Client ist für den Einsatz in hochsicheren z/OS-Umgebungen +(Banken, Versicherungen, Behörden) konzipiert. Sicherheit hat absolute +Priorität über Komfort. + +## Authentifizierung + +### Phase 1: Basic Auth über HTTPS +- User gibt RACF User/Password ein +- Credentials werden per TLS verschlüsselt an DDF geschickt +- Password wird nur im Speicher gehalten (SecretString mit Zeroize) +- Bei Programmende wird der Speicher überschrieben + +### Phase 2: RACF PassTickets +1. Einmal-Login: User/Password → Login-Service auf z/OS +2. Login-Service prüft gegen RACF (ICHRIX02 / RACROUTE VERIFY) +3. Bei Erfolg: PassTicket generieren (IRRSGS00) +4. PassTicket zurück an Client (8 Byte, 10 min TTL) +5. Alle weiteren REST-Calls nutzen PassTicket statt Password +6. Refresh vor Ablauf (9 min Intervall) + +### PassTicket Eigenschaften +- 8 Byte, kryptographisch generiert +- Gebunden an Applikationsname (PTKTDATA-Profil in RACF) +- Einmalig verwendbar (Replay-Schutz) +- 10 Minuten gültig +- Kein echtes Passwort verlässt nach dem Login den Client + +## Transport +- TLS 1.2+ mandatory (DDF SECPORT) +- Zertifikatvalidierung standardmäßig aktiv +- `--danger-accept-invalid-certs` nur für Entwicklung +- Kundeninstallation: echtes Zertifikat im RACF Keyring + +## Credential-Handling im Code +- `secrecy::SecretString` für Passwords und PassTickets +- Automatisches Zeroize bei Drop +- Debug-Output zeigt "[REDACTED]" +- Kein Logging von Credentials (auch nicht bei Fehlern) +- Kein Speichern auf Disk (kein Config-File mit Passwords) + +## RACF-Voraussetzungen beim Kunden +1. DDF aktiv mit SECPORT (SSL) +2. RACF PTKTDATA-Profil für opusR-Applikation +3. User braucht EXECUTE auf das REST-Service-Package +4. Optional: Client-Zertifikat im RACF Keyring diff --git a/src/api/client.rs b/src/api/client.rs new file mode 100644 index 0000000..5a37616 --- /dev/null +++ b/src/api/client.rs @@ -0,0 +1,145 @@ +use reqwest::Client; +use secrecy::{ExposeSecret, SecretString}; +use serde::Deserialize; +use serde_json::Value; +use tracing::{debug, warn}; + +use crate::models::{OpusrError, OpusrResult}; + +/// DB2 Native REST response wrapper. +/// DB2 returns results in `{"ResultSet Output": [...]}`. +#[derive(Debug, Deserialize)] +pub struct Db2Response { + #[serde(rename = "ResultSet Output")] + pub result_set: Option>, + + #[serde(rename = "StatusCode")] + pub status_code: Option, + + #[serde(rename = "StatusDescription")] + pub status_description: Option, +} + +/// Client for DB2 Native REST Services on z/OS. +pub struct Db2RestClient { + http: Client, + base_url: String, + user: String, + password: SecretString, +} + +impl Db2RestClient { + /// Create a new client. + /// + /// - `host`: z/OS hostname or IP + /// - `port`: DDF SECPORT (SSL) or TCPPORT + /// - `use_tls`: whether to use HTTPS + /// - `verify_certs`: whether to validate server certificates + pub fn new( + host: &str, + port: u16, + user: &str, + password: SecretString, + use_tls: bool, + verify_certs: bool, + ) -> OpusrResult { + let proto = if use_tls { "https" } else { "http" }; + let base_url = format!("{proto}://{host}:{port}"); + + let http = Client::builder() + .danger_accept_invalid_certs(!verify_certs) + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| OpusrError::TlsError(e.to_string()))?; + + Ok(Self { + http, + base_url, + user: user.to_string(), + password, + }) + } + + /// Update the password/PassTicket (for token refresh). + pub fn set_password(&mut self, password: SecretString) { + self.password = password; + } + + /// Call a DB2 REST service by name. + /// + /// Returns the parsed `ResultSet Output` array, or an error. + pub async fn call_service( + &self, + service_path: &str, + params: &serde_json::Value, + ) -> OpusrResult> { + let url = format!("{}/services/{service_path}", self.base_url); + + debug!(url = %url, "Calling DB2 REST service"); + + let resp = self + .http + .post(&url) + .basic_auth(&self.user, Some(self.password.expose_secret())) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(params) + .send() + .await + .map_err(|e| { + if e.is_connect() { + OpusrError::Connection(e.to_string()) + } else { + OpusrError::Http(e) + } + })?; + + let status = resp.status(); + + match status.as_u16() { + 200 | 201 => { + let body: Db2Response = resp.json().await?; + + // Check for DB2 error in the response body + if let Some(code) = body.status_code { + if code >= 400 { + return Err(OpusrError::Db2Error { + sqlcode: code, + message: body + .status_description + .unwrap_or_else(|| "Unknown DB2 error".into()), + }); + } + } + + Ok(body.result_set.unwrap_or_default()) + } + 400 => { + let body = resp.text().await.unwrap_or_default(); + warn!(status = 400, body = %body, "Bad request"); + Err(OpusrError::InvalidResponse(body)) + } + 401 => Err(OpusrError::AuthFailed), + 403 => Err(OpusrError::Forbidden), + 404 => Err(OpusrError::ServiceNotFound { + service: service_path.to_string(), + }), + _ => { + let body = resp.text().await.unwrap_or_default(); + Err(OpusrError::InvalidResponse(format!( + "HTTP {status}: {body}" + ))) + } + } + } +} + +impl std::fmt::Debug for Db2RestClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Db2RestClient") + .field("base_url", &self.base_url) + .field("user", &self.user) + .field("password", &"[REDACTED]") + .finish() + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..d66c052 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,5 @@ +pub mod client; +pub mod services; + +pub use client::Db2RestClient; +pub use services::OpusrServices; diff --git a/src/api/services.rs b/src/api/services.rs new file mode 100644 index 0000000..63c8e65 --- /dev/null +++ b/src/api/services.rs @@ -0,0 +1,104 @@ +use serde_json::json; +use tracing::info; + +use super::client::Db2RestClient; +use crate::models::{ + ColumnInfo, OpusrResult, QueryFilter, QueryResult, QueryRow, RawColumnInfo, RawTableInfo, + TableInfo, +}; + +/// Service URL patterns for DB2ServiceManager-created services. +const SVC_PREFIX: &str = "DB2ServiceManager"; + +/// High-level API for opusR REST services. +pub struct OpusrServices<'a> { + client: &'a Db2RestClient, + schema: String, +} + +impl<'a> OpusrServices<'a> { + pub fn new(client: &'a Db2RestClient, schema: &str) -> Self { + Self { + client, + schema: schema.to_string(), + } + } + + /// List all tables in the opusR schema. + pub async fn list_tables(&self) -> OpusrResult> { + let params = json!({ "P1": &self.schema }); + let rows = self + .client + .call_service(&format!("{SVC_PREFIX}/opusrTables"), ¶ms) + .await?; + + let tables: Vec = rows + .into_iter() + .filter_map(|v| serde_json::from_value::(v).ok()) + .map(TableInfo::from) + .collect(); + + info!(count = tables.len(), "Tables loaded"); + Ok(tables) + } + + /// List columns for a specific table. + pub async fn list_columns(&self, table_name: &str) -> OpusrResult> { + let params = json!({ "P1": &self.schema, "P2": table_name }); + let rows = self + .client + .call_service(&format!("{SVC_PREFIX}/opusrColumns"), ¶ms) + .await?; + + let columns: Vec = rows + .into_iter() + .filter_map(|v| serde_json::from_value::(v).ok()) + .map(ColumnInfo::from) + .collect(); + + info!(table = table_name, count = columns.len(), "Columns loaded"); + Ok(columns) + } + + /// Execute a dynamic query with optional filters. + pub async fn query( + &self, + table_name: &str, + filters: &[QueryFilter], + order_by: Option<&str>, + order: Option<&str>, + limit: Option, + ) -> OpusrResult { + let filter_json = if filters.is_empty() { + String::new() + } else { + serde_json::to_string(filters).unwrap_or_default() + }; + + let params = json!({ + "P1": table_name, + "P2": if filter_json.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(filter_json) }, + "P3": order_by.unwrap_or(""), + "P4": order.unwrap_or("ASC"), + "P5": limit.unwrap_or(1000) + }); + + let rows = self + .client + .call_service(&format!("{SVC_PREFIX}/opusrQuery"), ¶ms) + .await?; + + let query_rows: Vec = rows + .into_iter() + .filter_map(|v| serde_json::from_value(v).ok()) + .collect(); + + let row_count = query_rows.len(); + info!(table = table_name, rows = row_count, "Query executed"); + + Ok(QueryResult { + rows: query_rows, + row_count, + }) + } +} diff --git a/src/auth/login.rs b/src/auth/login.rs new file mode 100644 index 0000000..a9c1a60 --- /dev/null +++ b/src/auth/login.rs @@ -0,0 +1,48 @@ +use secrecy::SecretString; +use std::io::{self, Write}; + +use crate::models::OpusrResult; + +/// Prompt user for RACF credentials. +/// Password is read without echo. +pub fn login() -> OpusrResult<(String, SecretString)> { + print!("RACF User ID: "); + io::stdout().flush()?; + + let mut user = String::new(); + io::stdin().read_line(&mut user)?; + let user = user.trim().to_uppercase(); + + // Read password without echo + let password = rpassword_read("Password: ")?; + + Ok((user, password)) +} + +/// Read a password from terminal without echoing. +fn rpassword_read(prompt: &str) -> OpusrResult { + print!("{prompt}"); + io::stdout().flush()?; + + // Disable echo + crossterm::terminal::enable_raw_mode()?; + let mut password = String::new(); + loop { + if let crossterm::event::Event::Key(key) = + crossterm::event::read().map_err(|e| io::Error::new(io::ErrorKind::Other, e))? + { + match key.code { + crossterm::event::KeyCode::Enter => break, + crossterm::event::KeyCode::Char(c) => password.push(c), + crossterm::event::KeyCode::Backspace => { + password.pop(); + } + _ => {} + } + } + } + crossterm::terminal::disable_raw_mode()?; + println!(); // newline after password + + Ok(SecretString::from(password)) +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..1af858e --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,5 @@ +pub mod login; +pub mod passticket; + +pub use login::login; +pub use passticket::PassTicketManager; diff --git a/src/auth/passticket.rs b/src/auth/passticket.rs new file mode 100644 index 0000000..047e2f1 --- /dev/null +++ b/src/auth/passticket.rs @@ -0,0 +1,66 @@ +use secrecy::SecretString; +use std::time::{Duration, Instant}; + +/// Manages PassTicket lifecycle: obtain, cache, refresh before expiry. +/// +/// In Phase 1, this is a placeholder that uses the RACF password directly. +/// In the full implementation, this will call a z/OS login service that +/// returns a RACF PassTicket (8-byte, 10-minute TTL, single-use). +pub struct PassTicketManager { + current_ticket: Option, + obtained_at: Option, + ttl: Duration, +} + +impl PassTicketManager { + /// Create a new manager with default 10-minute TTL. + pub fn new() -> Self { + Self { + current_ticket: None, + obtained_at: None, + ttl: Duration::from_secs(10 * 60), // 10 minutes + } + } + + /// Check if the current ticket is still valid. + /// Uses a 60-second safety margin. + pub fn is_valid(&self) -> bool { + match self.obtained_at { + Some(t) => t.elapsed() < self.ttl - Duration::from_secs(60), + None => false, + } + } + + /// Store a new PassTicket. + pub fn set_ticket(&mut self, ticket: SecretString) { + self.current_ticket = Some(ticket); + self.obtained_at = Some(Instant::now()); + } + + /// Get the current ticket, or None if expired. + pub fn get_ticket(&self) -> Option<&SecretString> { + if self.is_valid() { + self.current_ticket.as_ref() + } else { + None + } + } + + /// Time remaining until expiry. + pub fn time_remaining(&self) -> Option { + self.obtained_at.map(|t| { + let elapsed = t.elapsed(); + if elapsed < self.ttl { + self.ttl - elapsed + } else { + Duration::ZERO + } + }) + } +} + +impl Default for PassTicketManager { + fn default() -> Self { + Self::new() + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..e9f2372 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,3 @@ +pub mod settings; + +pub use settings::AppConfig; diff --git a/src/config/settings.rs b/src/config/settings.rs new file mode 100644 index 0000000..83ac428 --- /dev/null +++ b/src/config/settings.rs @@ -0,0 +1,71 @@ +use clap::Parser; + +/// opusR Monitor Client — z/OS RACF security analysis +#[derive(Parser, Debug, Clone)] +#[command(name = "opusr", version, about)] +pub struct AppConfig { + /// z/OS hostname or IP address + #[arg(long, env = "OPUSR_HOST")] + pub host: String, + + /// DDF port (default: 5012 = SECPORT/SSL) + #[arg(long, default_value = "5012", env = "OPUSR_PORT")] + pub port: u16, + + /// DB2 schema containing opusR tables + #[arg(long, default_value = "OPUSR", env = "OPUSR_SCHEMA")] + pub schema: String, + + /// RACF user ID (prompted if not provided) + #[arg(long, env = "OPUSR_USER")] + pub user: Option, + + /// Disable TLS (DANGER — only for development!) + #[arg(long, default_value = "false")] + pub no_tls: bool, + + /// Accept invalid TLS certificates (DANGER — only for development!) + #[arg(long, default_value = "false")] + pub danger_accept_invalid_certs: bool, + + /// Output format + #[arg(long, default_value = "tui", value_parser = ["tui", "json", "table"])] + pub output: String, + + /// Specific command to run (non-interactive) + #[command(subcommand)] + pub command: Option, +} + +#[derive(clap::Subcommand, Debug, Clone)] +pub enum Command { + /// List all opusR tables + Tables, + + /// Show columns for a table + Columns { + /// Table name + table: String, + }, + + /// Query a table + Query { + /// Table name + table: String, + + /// Filter in format COL=VALUE (can repeat) + #[arg(short, long)] + filter: Vec, + + /// Sort by column + #[arg(long)] + order_by: Option, + + /// Max rows to return + #[arg(long, default_value = "100")] + limit: i32, + }, + + /// Test connectivity to z/OS + Test, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..cb10ee3 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,229 @@ +mod api; +mod auth; +mod config; +mod models; +mod tui; + +use anyhow::Result; +use clap::Parser; +use secrecy::SecretString; +use tracing::info; + +use api::{Db2RestClient, OpusrServices}; +use config::{AppConfig, Command}; + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "opusr_client=info".into()), + ) + .init(); + + let config = AppConfig::parse(); + + // Get credentials + let (user, password) = match &config.user { + Some(u) => { + // User provided on CLI, prompt for password only + print!("Password for {u}: "); + let pw = auth::login::login()?.1; + (u.clone(), pw) + } + None => auth::login::login()?, + }; + + // Create REST client + let use_tls = !config.no_tls; + let verify_certs = !config.danger_accept_invalid_certs; + + let client = Db2RestClient::new( + &config.host, + config.port, + &user, + password, + use_tls, + verify_certs, + )?; + + let services = OpusrServices::new(&client, &config.schema); + + // Execute command + match &config.command { + Some(Command::Test) => cmd_test(&client).await?, + Some(Command::Tables) => cmd_tables(&services, &config).await?, + Some(Command::Columns { table }) => cmd_columns(&services, table, &config).await?, + Some(Command::Query { + table, + filter, + order_by, + limit, + }) => { + cmd_query(&services, table, filter, order_by.as_deref(), *limit, &config).await? + } + None => { + // Default: list tables + cmd_tables(&services, &config).await?; + } + } + + Ok(()) +} + +async fn cmd_test(client: &Db2RestClient) -> Result<()> { + info!("Testing connectivity..."); + + // Try calling service discovery + let result = client + .call_service("", &serde_json::json!({})) + .await; + + match result { + Ok(_) => { + println!("✓ Connection successful!"); + Ok(()) + } + Err(e) => { + eprintln!("✗ Connection failed: {e}"); + std::process::exit(1); + } + } +} + +async fn cmd_tables(services: &OpusrServices<'_>, config: &AppConfig) -> Result<()> { + let tables = services.list_tables().await?; + + match config.output.as_str() { + "json" => { + println!("{}", serde_json::to_string_pretty(&tables.iter().map(|t| { + serde_json::json!({ + "name": t.name, + "columns": t.col_count, + "remarks": t.remarks + }) + }).collect::>())?); + } + _ => { + println!("{:<20} {:>4} {}", "TABLE", "COLS", "DESCRIPTION"); + println!("{}", "-".repeat(60)); + for t in &tables { + println!("{:<20} {:>4} {}", t.name, t.col_count, t.remarks); + } + println!("\n{} tables found.", tables.len()); + } + } + Ok(()) +} + +async fn cmd_columns( + services: &OpusrServices<'_>, + table: &str, + config: &AppConfig, +) -> Result<()> { + let columns = services.list_columns(table).await?; + + match config.output.as_str() { + "json" => { + println!("{}", serde_json::to_string_pretty(&columns.iter().map(|c| { + serde_json::json!({ + "name": c.name, + "type": c.col_type, + "length": c.length, + "nullable": c.nullable + }) + }).collect::>())?); + } + _ => { + println!("Columns for {table}:\n"); + println!("{:<4} {:<20} {:<12} {:>6} {}", "#", "NAME", "TYPE", "LEN", "NULL"); + println!("{}", "-".repeat(60)); + for c in &columns { + println!( + "{:<4} {:<20} {:<12} {:>6} {}", + c.col_no, + c.name, + c.col_type, + c.length, + if c.nullable { "Y" } else { "N" } + ); + } + println!("\n{} columns.", columns.len()); + } + } + Ok(()) +} + +async fn cmd_query( + services: &OpusrServices<'_>, + table: &str, + filters: &[String], + order_by: Option<&str>, + limit: i32, + config: &AppConfig, +) -> Result<()> { + // Parse filters from "COL=VALUE" format + let parsed_filters: Vec = filters + .iter() + .filter_map(|f| { + let parts: Vec<&str> = f.splitn(2, '=').collect(); + if parts.len() == 2 { + Some(models::QueryFilter { + col: parts[0].to_uppercase(), + op: if parts[1].contains('%') || parts[1].contains('*') { + models::FilterOp::Like + } else { + models::FilterOp::Eq + }, + val: parts[1].replace('*', "%"), + }) + } else { + None + } + }) + .collect(); + + let result = services + .query(table, &parsed_filters, order_by, Some("ASC"), Some(limit)) + .await?; + + match config.output.as_str() { + "json" => { + println!("{}", serde_json::to_string_pretty(&result.rows)?); + } + _ => { + if result.rows.is_empty() { + println!("No rows found."); + return Ok(()); + } + + // Get column names from first row + let cols: Vec<&String> = result.rows[0].keys().collect(); + + // Print header + for col in &cols { + print!("{:<20} ", col); + } + println!(); + println!("{}", "-".repeat(cols.len() * 21)); + + // Print rows + for row in &result.rows { + for col in &cols { + let val = row + .get(*col) + .map(|v| match v { + serde_json::Value::String(s) => s.trim().to_string(), + other => other.to_string(), + }) + .unwrap_or_default(); + print!("{:<20} ", val); + } + println!(); + } + println!("\n{} rows.", result.row_count); + } + } + Ok(()) +} diff --git a/src/models/error.rs b/src/models/error.rs new file mode 100644 index 0000000..e6406d7 --- /dev/null +++ b/src/models/error.rs @@ -0,0 +1,44 @@ +use thiserror::Error; + +/// All errors that can occur in the opusR client. +#[derive(Error, Debug)] +pub enum OpusrError { + #[error("Connection failed: {0}")] + Connection(String), + + #[error("Authentication failed: invalid credentials")] + AuthFailed, + + #[error("PassTicket expired — re-authentication required")] + PassTicketExpired, + + #[error("Access denied: insufficient RACF permissions")] + Forbidden, + + #[error("Service not found: {service}")] + ServiceNotFound { service: String }, + + #[error("DB2 error (SQLCODE {sqlcode}): {message}")] + Db2Error { sqlcode: i32, message: String }, + + #[error("Invalid response from server: {0}")] + InvalidResponse(String), + + #[error("TLS/SSL error: {0}")] + TlsError(String), + + #[error("Configuration error: {0}")] + ConfigError(String), + + #[error(transparent)] + Http(#[from] reqwest::Error), + + #[error(transparent)] + Json(#[from] serde_json::Error), + + #[error(transparent)] + Io(#[from] std::io::Error), +} + +/// Result type alias for opusR operations. +pub type OpusrResult = Result; diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..d1622a6 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,7 @@ +pub mod error; +pub mod query; +pub mod table; + +pub use error::{OpusrError, OpusrResult}; +pub use query::{ColumnInfo, FilterOp, QueryFilter, QueryResult, QueryRow, RawColumnInfo}; +pub use table::{RawTableInfo, TableInfo}; diff --git a/src/models/query.rs b/src/models/query.rs new file mode 100644 index 0000000..41ef8f5 --- /dev/null +++ b/src/models/query.rs @@ -0,0 +1,85 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// A column definition, as returned by opusrColumns service. +#[derive(Debug, Clone, Deserialize)] +pub struct RawColumnInfo { + #[serde(rename = "COLNO")] + pub col_no: Option, + #[serde(rename = "NAME")] + pub name: Option, + #[serde(rename = "COLTYPE")] + pub col_type: Option, + #[serde(rename = "LENGTH")] + pub length: Option, + #[serde(rename = "SCALE")] + pub scale: Option, + #[serde(rename = "NULLS")] + pub nulls: Option, + #[serde(rename = "REMARKS")] + pub remarks: Option, +} + +/// Normalized column info. +#[derive(Debug, Clone)] +pub struct ColumnInfo { + pub col_no: i32, + pub name: String, + pub col_type: String, + pub length: i32, + pub scale: i32, + pub nullable: bool, + pub remarks: String, +} + +impl From for ColumnInfo { + fn from(raw: RawColumnInfo) -> Self { + Self { + col_no: raw.col_no.unwrap_or(0), + name: raw.name.unwrap_or_default().trim().to_string(), + col_type: raw.col_type.unwrap_or_default().trim().to_string(), + length: raw.length.unwrap_or(0), + scale: raw.scale.unwrap_or(0), + nullable: raw.nulls.as_deref() == Some("Y"), + remarks: raw.remarks.unwrap_or_default().trim().to_string(), + } + } +} + +/// A filter criterion for the opusrQuery service. +#[derive(Debug, Clone, Serialize)] +pub struct QueryFilter { + pub col: String, + pub op: FilterOp, + pub val: String, +} + +/// Supported filter operators. +#[derive(Debug, Clone, Serialize)] +pub enum FilterOp { + #[serde(rename = "EQ")] + Eq, + #[serde(rename = "NE")] + Ne, + #[serde(rename = "LT")] + Lt, + #[serde(rename = "GT")] + Gt, + #[serde(rename = "LE")] + Le, + #[serde(rename = "GE")] + Ge, + #[serde(rename = "LIKE")] + Like, +} + +/// A single row from a dynamic query result. +/// Keys are UPPERCASE column names, values are JSON values. +pub type QueryRow = HashMap; + +/// The result of a dynamic query. +#[derive(Debug, Clone)] +pub struct QueryResult { + pub rows: Vec, + pub row_count: usize, +} diff --git a/src/models/table.rs b/src/models/table.rs new file mode 100644 index 0000000..bd9d567 --- /dev/null +++ b/src/models/table.rs @@ -0,0 +1,34 @@ +use serde::Deserialize; + +/// A table in the opusR schema, as returned by opusrTables service. +#[derive(Debug, Clone, Deserialize)] +pub struct RawTableInfo { + #[serde(rename = "NAME")] + pub name: Option, + #[serde(rename = "TYPE")] + pub table_type: Option, + #[serde(rename = "COLCOUNT")] + pub col_count: Option, + #[serde(rename = "REMARKS")] + pub remarks: Option, +} + +/// Normalized table info (trimmed, with defaults). +#[derive(Debug, Clone)] +pub struct TableInfo { + pub name: String, + pub table_type: String, + pub col_count: i32, + pub remarks: String, +} + +impl From for TableInfo { + fn from(raw: RawTableInfo) -> Self { + Self { + name: raw.name.unwrap_or_default().trim().to_string(), + table_type: raw.table_type.unwrap_or_default().trim().to_string(), + col_count: raw.col_count.unwrap_or(0), + remarks: raw.remarks.unwrap_or_default().trim().to_string(), + } + } +} diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..a67c9f5 --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,22 @@ +/// TUI application state — placeholder for Phase 2. +/// Will use ratatui for a full terminal UI with: +/// - Table list (main menu) +/// - Column selection panel +/// - Data view with scrolling +/// - Filter input +/// - Navigation between views (like ISPF Line Commands) +pub struct App { + pub running: bool, +} + +impl App { + pub fn new() -> Self { + Self { running: true } + } +} + +impl Default for App { + fn default() -> Self { + Self::new() + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..f6b2521 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,4 @@ +pub mod app; + +// TUI wird in einer späteren Phase implementiert. +// Phase 1: nur CLI-Ausgabe (JSON / Tabelle). diff --git a/tasks/lessons.md b/tasks/lessons.md new file mode 100644 index 0000000..f02049d --- /dev/null +++ b/tasks/lessons.md @@ -0,0 +1,13 @@ +# OpusR-Client — Lessons Learned + +## DB2 Native REST +- **Parameter sind positional**: DB2ServiceManager-Services erwarten `P1`, `P2`, etc. + Nicht die SQL-Spaltennamen verwenden! (Fehler: "unmatching P1 key") +- **Immer POST**: DB2 Native REST akzeptiert nur HTTP POST, nie GET. +- **CHAR-Felder trimmen**: DB2 CHAR ist right-padded mit Spaces. +- **NULL = fehlendes Feld**: In der JSON-Response fehlt das Feld komplett. + +## Rust / Sicherheit +- **Kein unwrap()**: Immer `?` oder explizites Error-Handling. +- **SecretString für Credentials**: Passwörter nie als normaler String. +- **Password nicht loggen**: Debug-Impl für Client zeigt "[REDACTED]". diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 0000000..49b5406 --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,34 @@ +# OpusR-Client — Aufgaben + +## Phase 1: Basis (CLI + REST) +- [x] Projektstruktur, Cargo.toml, CLAUDE.md +- [x] Datenmodelle (TableInfo, ColumnInfo, QueryFilter) +- [x] DB2 REST Client (reqwest, Basic Auth, TLS) +- [x] Service-Layer (opusrTables, opusrColumns, opusrQuery) +- [x] CLI mit clap (tables, columns, query, test) +- [x] Auth-Modul (Login-Prompt, Password ohne Echo) +- [ ] Kompilierung verifizieren (cargo build --release) +- [ ] Unit-Tests mit wiremock +- [ ] Integration-Test gegen echten Host + +## Phase 2: PassTicket Auth +- [ ] Login-Service auf z/OS (RACF verify + IRRSGS00) +- [ ] PTKTDATA-Profil in RACF definieren +- [ ] PassTicketManager: obtain → cache → refresh cycle +- [ ] Token-Refresh vor Ablauf (9 min Intervall) + +## Phase 3: TUI (ratatui) +- [ ] Tabellenliste als navigierbare Liste +- [ ] Spalten-Selektion (Checkboxen) +- [ ] Daten-View mit horizontalem Scrollen +- [ ] Filter-Eingabezeile +- [ ] Line Commands (wie ISPF — Aktion pro Zeile) +- [ ] Navigationslogik aus @D Macros (JSON-Config) + +## Phase 4: Produktreife +- [ ] Client-Zertifikate als Alternative zu PassTickets +- [ ] Config-Datei (~/.opusr/config.toml) +- [ ] Audit-Logging (jeder REST-Call) +- [ ] Cross-Compilation (Windows, Linux, macOS) +- [ ] MSI/deb/rpm Installer +- [ ] Kundenhandbuch