Skip to content

Commit 2f6db16

Browse files
vsilentCopilot
andcommitted
feat(cli): add clap subcommands (serve/sniff) + sniff config
- Add clap 4 for CLI argument parsing - Refactor main.rs: dispatch to serve (default) or sniff subcommand - Create src/cli.rs with Cli/Command enums - Create src/sniff/config.rs with SniffConfig (env + CLI args) - Add new deps: clap, async-trait, reqwest, zstd - Update .env.sample with sniff + AI provider config vars - 12 unit tests (7 CLI parsing + 5 config loading) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8759564 commit 2f6db16

7 files changed

Lines changed: 426 additions & 2 deletions

File tree

.env.sample

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,15 @@ APP_HOST=0.0.0.0
55
APP_PORT=5000
66
DATABASE_URL=stackdog.db
77
RUST_BACKTRACE=full
8+
9+
# Log Sniff Configuration
10+
#STACKDOG_LOG_SOURCES=/var/log/syslog,/var/log/auth.log
11+
#STACKDOG_SNIFF_INTERVAL=30
12+
#STACKDOG_SNIFF_OUTPUT_DIR=./stackdog-logs/
13+
14+
# AI Provider Configuration
15+
# Supports OpenAI, Ollama (http://localhost:11434/v1), or any OpenAI-compatible API
16+
#STACKDOG_AI_PROVIDER=openai
17+
#STACKDOG_AI_API_URL=http://localhost:11434/v1
18+
#STACKDOG_AI_API_KEY=
19+
#STACKDOG_AI_MODEL=llama3

Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ tracing-subscriber = "0.3"
2929
dotenv = "0.15"
3030
anyhow = "1"
3131
thiserror = "1"
32+
clap = { version = "4", features = ["derive"] }
3233

3334
# Async runtime
3435
tokio = { version = "1", features = ["full"] }
@@ -37,6 +38,7 @@ actix-web = "4"
3738
actix-cors = "0.6"
3839
actix-web-actors = "4"
3940
actix = "0.13"
41+
async-trait = "0.1"
4042

4143
# Database
4244
rusqlite = { version = "0.32", features = ["bundled"] }
@@ -45,6 +47,12 @@ r2d2 = "0.8"
4547
# Docker
4648
bollard = "0.16"
4749

50+
# HTTP client (for LLM API)
51+
reqwest = { version = "0.12", features = ["json"] }
52+
53+
# Compression
54+
zstd = "0.13"
55+
4856
# eBPF (Linux only)
4957
[target.'cfg(target_os = "linux")'.dependencies]
5058
aya = "0.12"

src/cli.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
//! CLI argument parsing for Stackdog
2+
//!
3+
//! Defines the command-line interface using clap derive macros.
4+
//! Supports `serve` (HTTP server) and `sniff` (log analysis) subcommands.
5+
6+
use clap::{Parser, Subcommand};
7+
8+
/// Stackdog Security — Docker & Linux server security platform
9+
#[derive(Parser, Debug)]
10+
#[command(name = "stackdog", version, about, long_about = None)]
11+
pub struct Cli {
12+
#[command(subcommand)]
13+
pub command: Option<Command>,
14+
}
15+
16+
/// Available subcommands
17+
#[derive(Subcommand, Debug, Clone)]
18+
pub enum Command {
19+
/// Start the HTTP API server (default behavior)
20+
Serve,
21+
22+
/// Sniff and analyze logs from Docker containers and system sources
23+
Sniff {
24+
/// Run a single scan/analysis pass, then exit
25+
#[arg(long)]
26+
once: bool,
27+
28+
/// Consume logs: archive to zstd, then purge originals to free disk
29+
#[arg(long)]
30+
consume: bool,
31+
32+
/// Output directory for consumed logs
33+
#[arg(long, default_value = "./stackdog-logs/")]
34+
output: String,
35+
36+
/// Additional log file paths to watch (comma-separated)
37+
#[arg(long)]
38+
sources: Option<String>,
39+
40+
/// Poll interval in seconds
41+
#[arg(long, default_value = "30")]
42+
interval: u64,
43+
44+
/// AI provider: "openai" or "candle"
45+
#[arg(long)]
46+
ai_provider: Option<String>,
47+
},
48+
}
49+
50+
#[cfg(test)]
51+
mod tests {
52+
use super::*;
53+
use clap::Parser;
54+
55+
#[test]
56+
fn test_no_subcommand_defaults_to_none() {
57+
let cli = Cli::parse_from(["stackdog"]);
58+
assert!(cli.command.is_none(), "No subcommand should yield None (default to serve)");
59+
}
60+
61+
#[test]
62+
fn test_serve_subcommand() {
63+
let cli = Cli::parse_from(["stackdog", "serve"]);
64+
assert!(matches!(cli.command, Some(Command::Serve)));
65+
}
66+
67+
#[test]
68+
fn test_sniff_subcommand_defaults() {
69+
let cli = Cli::parse_from(["stackdog", "sniff"]);
70+
match cli.command {
71+
Some(Command::Sniff { once, consume, output, sources, interval, ai_provider }) => {
72+
assert!(!once);
73+
assert!(!consume);
74+
assert_eq!(output, "./stackdog-logs/");
75+
assert!(sources.is_none());
76+
assert_eq!(interval, 30);
77+
assert!(ai_provider.is_none());
78+
}
79+
_ => panic!("Expected Sniff command"),
80+
}
81+
}
82+
83+
#[test]
84+
fn test_sniff_with_once_flag() {
85+
let cli = Cli::parse_from(["stackdog", "sniff", "--once"]);
86+
match cli.command {
87+
Some(Command::Sniff { once, .. }) => assert!(once),
88+
_ => panic!("Expected Sniff command"),
89+
}
90+
}
91+
92+
#[test]
93+
fn test_sniff_with_consume_flag() {
94+
let cli = Cli::parse_from(["stackdog", "sniff", "--consume"]);
95+
match cli.command {
96+
Some(Command::Sniff { consume, .. }) => assert!(consume),
97+
_ => panic!("Expected Sniff command"),
98+
}
99+
}
100+
101+
#[test]
102+
fn test_sniff_with_all_options() {
103+
let cli = Cli::parse_from([
104+
"stackdog", "sniff",
105+
"--once",
106+
"--consume",
107+
"--output", "/tmp/logs/",
108+
"--sources", "/var/log/syslog,/var/log/auth.log",
109+
"--interval", "60",
110+
"--ai-provider", "openai",
111+
]);
112+
match cli.command {
113+
Some(Command::Sniff { once, consume, output, sources, interval, ai_provider }) => {
114+
assert!(once);
115+
assert!(consume);
116+
assert_eq!(output, "/tmp/logs/");
117+
assert_eq!(sources.unwrap(), "/var/log/syslog,/var/log/auth.log");
118+
assert_eq!(interval, 60);
119+
assert_eq!(ai_provider.unwrap(), "openai");
120+
}
121+
_ => panic!("Expected Sniff command"),
122+
}
123+
}
124+
125+
#[test]
126+
fn test_sniff_with_candle_provider() {
127+
let cli = Cli::parse_from(["stackdog", "sniff", "--ai-provider", "candle"]);
128+
match cli.command {
129+
Some(Command::Sniff { ai_provider, .. }) => {
130+
assert_eq!(ai_provider.unwrap(), "candle");
131+
}
132+
_ => panic!("Expected Sniff command"),
133+
}
134+
}
135+
}

src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ pub mod database;
5959
// Configuration
6060
pub mod config;
6161

62+
// Log sniffing
63+
pub mod sniff;
64+
6265
// Re-export commonly used types
6366
pub use events::syscall::{SyscallEvent, SyscallType};
6467
pub use events::security::{SecurityEvent, NetworkEvent, ContainerEvent, AlertEvent};

src/main.rs

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,26 @@ mod config;
2222
mod api;
2323
mod database;
2424
mod docker;
25+
mod cli;
26+
mod sniff;
2527

2628
use std::{io, env};
2729
use actix_web::{HttpServer, App, web};
2830
use actix_cors::Cors;
31+
use clap::Parser;
2932
use tracing::{Level, info};
3033
use tracing_subscriber::FmtSubscriber;
3134
use database::{create_pool, init_database};
35+
use cli::{Cli, Command};
3236

3337
#[actix_rt::main]
3438
async fn main() -> io::Result<()> {
3539
// Load environment
3640
dotenv::dotenv().expect("Could not read .env file");
3741

42+
// Parse CLI arguments
43+
let cli = Cli::parse();
44+
3845
// Setup logging
3946
env::set_var("RUST_LOG", "stackdog=info,actix_web=info");
4047
env_logger::init();
@@ -49,8 +56,17 @@ async fn main() -> io::Result<()> {
4956
info!("🐕 Stackdog Security starting...");
5057
info!("Platform: {}", std::env::consts::OS);
5158
info!("Architecture: {}", std::env::consts::ARCH);
52-
53-
// Display configuration
59+
60+
match cli.command {
61+
Some(Command::Sniff { once, consume, output, sources, interval, ai_provider }) => {
62+
run_sniff(once, consume, output, sources, interval, ai_provider).await
63+
}
64+
// Default: serve (backward compatible)
65+
Some(Command::Serve) | None => run_serve().await,
66+
}
67+
}
68+
69+
async fn run_serve() -> io::Result<()> {
5470
let app_host = env::var("APP_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
5571
let app_port = env::var("APP_PORT").unwrap_or_else(|_| "5000".to_string());
5672
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "./stackdog.db".to_string());
@@ -99,3 +115,33 @@ async fn main() -> io::Result<()> {
99115
.run()
100116
.await
101117
}
118+
119+
async fn run_sniff(
120+
once: bool,
121+
consume: bool,
122+
output: String,
123+
sources: Option<String>,
124+
interval: u64,
125+
ai_provider: Option<String>,
126+
) -> io::Result<()> {
127+
let config = sniff::config::SniffConfig::from_env_and_args(
128+
once,
129+
consume,
130+
&output,
131+
sources.as_deref(),
132+
interval,
133+
ai_provider.as_deref(),
134+
);
135+
136+
info!("🔍 Stackdog Sniff starting...");
137+
info!("Mode: {}", if config.once { "one-shot" } else { "continuous" });
138+
info!("Consume: {}", config.consume);
139+
info!("Output: {}", config.output_dir.display());
140+
info!("Interval: {}s", config.interval_secs);
141+
info!("AI Provider: {:?}", config.ai_provider);
142+
143+
// TODO: Implement sniff orchestrator (Checkpoint 6)
144+
info!("⚠️ Sniff orchestrator not yet implemented");
145+
Ok(())
146+
}
147+

0 commit comments

Comments
 (0)