Skip to main content

gossan_intel/
db.rs

1//! SQLite-backed passive intelligence database.
2
3use std::path::Path;
4
5use anyhow::Context;
6use rusqlite::{params, Connection};
7use serde::{Deserialize, Serialize};
8use std::sync::Mutex;
9
10/// A single passive intelligence record from bulk datasets.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct IntelRecord {
13    pub ip: String,
14    pub host: Option<String>,
15    pub port: u16,
16    pub protocol: String,
17    pub banner: Option<String>,
18    #[serde(default)]
19    pub tech_stack: Vec<String>,
20    pub last_seen: Option<String>,
21}
22
23/// SQLite-backed passive intelligence database for bulk dataset queries.
24pub struct IntelDb {
25    // Wrap connection in a Mutex to allow sharing across threads safely
26    conn: Mutex<Connection>,
27}
28
29impl IntelDb {
30    /// Test-only access to the raw SQLite connection.
31    ///
32    /// Used by integration tests in `tests/intel_tests.rs` to insert
33    /// deliberately-corrupt rows that exercise the `query_*`
34    /// error-handling paths. NOT intended for production use — open
35    /// a fresh `Connection` if you need direct SQL access elsewhere.
36    #[doc(hidden)]
37    pub fn _test_conn(&self) -> &Mutex<Connection> {
38        &self.conn
39    }
40}
41
42impl IntelDb {
43    /// Open an intel database at the given path.
44    pub fn open(path: impl AsRef<Path>) -> anyhow::Result<Self> {
45        let conn = Connection::open(path).context("opening intel database")?;
46
47        // Optimize for high-speed ingestion
48        conn.execute_batch(
49            "PRAGMA journal_mode = WAL;
50             PRAGMA synchronous = NORMAL;
51             PRAGMA cache_size = -64000; -- 64MB cache
52             PRAGMA temp_store = MEMORY;",
53        )?;
54
55        // SQLite treats NULLs as distinct in inline UNIQUE constraints,
56        // so two records with the same (ip, port, protocol) but null
57        // host would silently both insert. The expression-based unique
58        // index uses COALESCE so null host collapses to '', matching
59        // the semantic intent of "same target = same row".
60        conn.execute_batch(
61            "CREATE TABLE IF NOT EXISTS intel (
62                id          INTEGER PRIMARY KEY AUTOINCREMENT,
63                ip          TEXT NOT NULL,
64                host        TEXT,
65                port        INTEGER NOT NULL,
66                protocol    TEXT NOT NULL,
67                banner      TEXT,
68                tech_stack  TEXT, -- JSON array
69                last_seen   TEXT
70            );
71            CREATE UNIQUE INDEX IF NOT EXISTS idx_intel_unique
72                ON intel(ip, COALESCE(host, ''), port, protocol);
73            CREATE INDEX IF NOT EXISTS idx_intel_ip ON intel(ip);
74            CREATE INDEX IF NOT EXISTS idx_intel_host ON intel(host);",
75        )
76        .context("initialising intel schema")?;
77
78        Ok(Self {
79            conn: Mutex::new(conn),
80        })
81    }
82
83    /// Insert a batch of records transactionally.
84    pub fn insert_batch(&self, records: &[IntelRecord]) -> anyhow::Result<()> {
85        let mut conn = self
86            .conn
87            .lock()
88            .map_err(|e| anyhow::anyhow!("mutex poisoned: {e}"))?;
89        let tx = conn.transaction()?;
90        {
91            let mut stmt = tx.prepare(
92                "INSERT OR REPLACE INTO intel (ip, host, port, protocol, banner, tech_stack, last_seen)
93                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
94            )?;
95
96            for r in records {
97                let tech_json = serde_json::to_string(&r.tech_stack)?;
98                stmt.execute(params![
99                    r.ip,
100                    r.host,
101                    r.port,
102                    r.protocol,
103                    r.banner,
104                    tech_json,
105                    r.last_seen
106                ])?;
107            }
108        }
109        tx.commit()?;
110        Ok(())
111    }
112
113    /// Query records by IP.
114    pub fn query_by_ip(&self, ip: &str) -> anyhow::Result<Vec<IntelRecord>> {
115        let conn = self
116            .conn
117            .lock()
118            .map_err(|e| anyhow::anyhow!("mutex poisoned: {e}"))?;
119        let mut stmt = conn.prepare(
120            "SELECT ip, host, port, protocol, banner, tech_stack, last_seen
121             FROM intel WHERE ip = ?1",
122        )?;
123
124        let rows = stmt.query_map(params![ip], |row| {
125            let tech_json: String = row.get(5)?;
126            let tech_stack: Vec<String> = serde_json::from_str(&tech_json).unwrap_or_default();
127            let port_i32: i32 = row.get(2)?;
128            let port = u16::try_from(port_i32)
129                .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(2, port_i32.into()))?;
130            Ok(IntelRecord {
131                ip: row.get(0)?,
132                host: row.get(1)?,
133                port,
134                protocol: row.get(3)?,
135                banner: row.get(4)?,
136                tech_stack,
137                last_seen: row.get(6)?,
138            })
139        })?;
140
141        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
142    }
143
144    /// Query records by hostname.
145    pub fn query_by_host(&self, host: &str) -> anyhow::Result<Vec<IntelRecord>> {
146        let conn = self
147            .conn
148            .lock()
149            .map_err(|e| anyhow::anyhow!("mutex poisoned: {e}"))?;
150        let mut stmt = conn.prepare(
151            "SELECT ip, host, port, protocol, banner, tech_stack, last_seen
152             FROM intel WHERE host = ?1",
153        )?;
154
155        let rows = stmt.query_map(params![host], |row| {
156            let tech_json: String = row.get(5)?;
157            let tech_stack: Vec<String> = serde_json::from_str(&tech_json).unwrap_or_default();
158            let port_i32: i32 = row.get(2)?;
159            let port = u16::try_from(port_i32)
160                .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(2, port_i32.into()))?;
161            Ok(IntelRecord {
162                ip: row.get(0)?,
163                host: row.get(1)?,
164                port,
165                protocol: row.get(3)?,
166                banner: row.get(4)?,
167                tech_stack,
168                last_seen: row.get(6)?,
169            })
170        })?;
171
172        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
173    }
174}