Initial: Rust CLI client für opusR Monitor
This commit is contained in:
commit
6933b92e79
21
.claude/commands/add-endpoint.md
Normal file
21
.claude/commands/add-endpoint.md
Normal 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
|
||||||
9
.claude/commands/qa-cycle.md
Normal file
9
.claude/commands/qa-cycle.md
Normal 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
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
125
CLAUDE.md
Normal file
125
CLAUDE.md
Normal 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
53
Cargo.toml
Normal 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
62
README.md
Normal 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
49
docs/security.md
Normal 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
145
src/api/client.rs
Normal 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
5
src/api/mod.rs
Normal 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
104
src/api/services.rs
Normal 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"), ¶ms)
|
||||||
|
.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"), ¶ms)
|
||||||
|
.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"), ¶ms)
|
||||||
|
.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
48
src/auth/login.rs
Normal 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
5
src/auth/mod.rs
Normal 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
66
src/auth/passticket.rs
Normal 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
3
src/config/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
pub mod settings;
|
||||||
|
|
||||||
|
pub use settings::AppConfig;
|
||||||
71
src/config/settings.rs
Normal file
71
src/config/settings.rs
Normal 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
229
src/main.rs
Normal 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
44
src/models/error.rs
Normal 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
7
src/models/mod.rs
Normal 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
85
src/models/query.rs
Normal 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
34
src/models/table.rs
Normal 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
22
src/tui/app.rs
Normal 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
4
src/tui/mod.rs
Normal 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
13
tasks/lessons.md
Normal 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
34
tasks/todo.md
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user