Initial: Rust CLI client für opusR Monitor

This commit is contained in:
Josef 2026-04-02 09:06:26 +02:00
commit 6933b92e79
24 changed files with 1244 additions and 0 deletions

View File

@ -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<Raw>` 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

View File

@ -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.

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
/target
Cargo.lock
*.swp
*.swo
.env
.DS_Store

125
CLAUDE.md Normal file
View File

@ -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<T, E> 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
```

53
Cargo.toml Normal file
View File

@ -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 <info@opus-it.at>"]
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

62
README.md Normal file
View File

@ -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)

49
docs/security.md Normal file
View File

@ -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

145
src/api/client.rs Normal file
View File

@ -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<Vec<Value>>,
#[serde(rename = "StatusCode")]
pub status_code: Option<i32>,
#[serde(rename = "StatusDescription")]
pub status_description: Option<String>,
}
/// 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<Self> {
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<Vec<Value>> {
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()
}
}

5
src/api/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod client;
pub mod services;
pub use client::Db2RestClient;
pub use services::OpusrServices;

104
src/api/services.rs Normal file
View File

@ -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<Vec<TableInfo>> {
let params = json!({ "P1": &self.schema });
let rows = self
.client
.call_service(&format!("{SVC_PREFIX}/opusrTables"), &params)
.await?;
let tables: Vec<TableInfo> = rows
.into_iter()
.filter_map(|v| serde_json::from_value::<RawTableInfo>(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<Vec<ColumnInfo>> {
let params = json!({ "P1": &self.schema, "P2": table_name });
let rows = self
.client
.call_service(&format!("{SVC_PREFIX}/opusrColumns"), &params)
.await?;
let columns: Vec<ColumnInfo> = rows
.into_iter()
.filter_map(|v| serde_json::from_value::<RawColumnInfo>(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<i32>,
) -> OpusrResult<QueryResult> {
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"), &params)
.await?;
let query_rows: Vec<QueryRow> = 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,
})
}
}

48
src/auth/login.rs Normal file
View File

@ -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<SecretString> {
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))
}

5
src/auth/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod login;
pub mod passticket;
pub use login::login;
pub use passticket::PassTicketManager;

66
src/auth/passticket.rs Normal file
View File

@ -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<SecretString>,
obtained_at: Option<Instant>,
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<Duration> {
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()
}
}

3
src/config/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod settings;
pub use settings::AppConfig;

71
src/config/settings.rs Normal file
View File

@ -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<String>,
/// 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<Command>,
}
#[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<String>,
/// Sort by column
#[arg(long)]
order_by: Option<String>,
/// Max rows to return
#[arg(long, default_value = "100")]
limit: i32,
},
/// Test connectivity to z/OS
Test,
}

229
src/main.rs Normal file
View File

@ -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::<Vec<_>>())?);
}
_ => {
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::<Vec<_>>())?);
}
_ => {
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<models::QueryFilter> = 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(())
}

44
src/models/error.rs Normal file
View File

@ -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<T> = Result<T, OpusrError>;

7
src/models/mod.rs Normal file
View File

@ -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};

85
src/models/query.rs Normal file
View File

@ -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<i32>,
#[serde(rename = "NAME")]
pub name: Option<String>,
#[serde(rename = "COLTYPE")]
pub col_type: Option<String>,
#[serde(rename = "LENGTH")]
pub length: Option<i32>,
#[serde(rename = "SCALE")]
pub scale: Option<i32>,
#[serde(rename = "NULLS")]
pub nulls: Option<String>,
#[serde(rename = "REMARKS")]
pub remarks: Option<String>,
}
/// 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<RawColumnInfo> 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<String, serde_json::Value>;
/// The result of a dynamic query.
#[derive(Debug, Clone)]
pub struct QueryResult {
pub rows: Vec<QueryRow>,
pub row_count: usize,
}

34
src/models/table.rs Normal file
View File

@ -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<String>,
#[serde(rename = "TYPE")]
pub table_type: Option<String>,
#[serde(rename = "COLCOUNT")]
pub col_count: Option<i32>,
#[serde(rename = "REMARKS")]
pub remarks: Option<String>,
}
/// 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<RawTableInfo> 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(),
}
}
}

22
src/tui/app.rs Normal file
View File

@ -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()
}
}

4
src/tui/mod.rs Normal file
View File

@ -0,0 +1,4 @@
pub mod app;
// TUI wird in einer späteren Phase implementiert.
// Phase 1: nur CLI-Ausgabe (JSON / Tabelle).

13
tasks/lessons.md Normal file
View File

@ -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]".

34
tasks/todo.md Normal file
View File

@ -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