Skip to content

Commit 624f498

Browse files
Infinit3iclaude
andcommitted
Phase 2: Unknown app detection with aggregation
- 5-second aggregation window collects all ports an unknown app tries before emitting a consolidated summary - Daemon logs blocked apps at INFO level with port list and suggested `afw add` command to allow them - `afw pending` shows all blocked unknown apps sorted by attempt count, with ports, destination IPs, and copy-paste add commands - UnknownConnectionSummary generates suggested_command() for easy approval - Periodic 2-second tick in daemon checks aggregation windows Example daemon output: BLOCKED: 'myapp' tried 2 port(s): 443/tcp, 8080/tcp (5 attempts, 2 destinations) To allow: afw add myapp myapp 443/tcp 8080/tcp Example `afw pending` output: myapp (5 attempts, 12s ago) [aggregated] -> 443/tcp -> 8080/tcp IPs: 1.2.3.4, 5.6.7.8 Suggest: afw add myapp myapp 443/tcp 8080/tcp Phase 2 of .idea/README.md roadmap complete. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a42ece2 commit 624f498

3 files changed

Lines changed: 129 additions & 13 deletions

File tree

.idea/README.md

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

afw/src/daemon.rs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ pub async fn run() -> Result<()> {
5757

5858
info!("AFW daemon ready. Monitoring process and connection events...");
5959

60+
// Periodic timer to check aggregation windows for unknown apps
61+
let mut aggregation_tick = tokio::time::interval(std::time::Duration::from_secs(2));
62+
6063
loop {
6164
tokio::select! {
6265
Some(event) = proc_rx.recv() => {
@@ -86,14 +89,31 @@ pub async fn run() -> Result<()> {
8689
};
8790

8891
let mut state = state.lock().await;
89-
let _is_new = state.handle_connection(
92+
state.handle_connection(
9093
&comm,
9194
conn.dest_port,
9295
protocol,
9396
&dest_addr,
9497
);
95-
96-
// Phase 3 will add desktop notifications here when _is_new is true
98+
}
99+
_ = aggregation_tick.tick() => {
100+
let mut state = state.lock().await;
101+
let summaries = state.check_aggregation_windows();
102+
for summary in &summaries {
103+
info!(
104+
"BLOCKED: '{}' tried {} port(s): {} ({} attempts, {} destination(s))",
105+
summary.binary,
106+
summary.ports.len(),
107+
summary.ports.iter()
108+
.map(|(p, proto)| format!("{}/{}", p, proto))
109+
.collect::<Vec<_>>()
110+
.join(", "),
111+
summary.attempt_count,
112+
summary.dest_addrs.len(),
113+
);
114+
info!(" To allow: {}", summary.suggested_command());
115+
// Phase 3 will send desktop notification here
116+
}
97117
}
98118
_ = sigterm.recv() => {
99119
info!("Received SIGTERM, shutting down...");

afw/src/state.rs

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,38 @@ pub struct UnknownConnection {
8181
pub last_seen: Instant,
8282
/// Number of connection attempts
8383
pub attempt_count: u32,
84+
/// Whether a summary has been emitted after the aggregation window
85+
pub summary_emitted: bool,
86+
}
87+
88+
/// Duration to wait after first seeing an unknown app before emitting a summary.
89+
/// This aggregates all ports the app tries during this window into one report.
90+
const AGGREGATION_WINDOW_SECS: u64 = 5;
91+
92+
/// Summary of an unknown app's connection attempts after the aggregation window
93+
#[derive(Debug, Clone)]
94+
pub struct UnknownConnectionSummary {
95+
pub binary: String,
96+
pub ports: Vec<(u16, String)>,
97+
pub dest_addrs: Vec<String>,
98+
pub attempt_count: u32,
99+
}
100+
101+
impl UnknownConnectionSummary {
102+
/// Generate a suggested `afw add` command for this app
103+
pub fn suggested_command(&self) -> String {
104+
let port_args: Vec<String> = self
105+
.ports
106+
.iter()
107+
.map(|(port, proto)| format!("{}/{}", port, proto))
108+
.collect();
109+
format!(
110+
"afw add {} {} {}",
111+
self.binary.to_lowercase().replace(' ', "-"),
112+
self.binary,
113+
port_args.join(" ")
114+
)
115+
}
84116
}
85117

86118
/// Runtime state tracking active processes and their firewall rules
@@ -221,6 +253,7 @@ impl AppState {
221253
first_seen: now,
222254
last_seen: now,
223255
attempt_count: 0,
256+
summary_emitted: false,
224257
});
225258

226259
entry.ports.insert((dest_port, protocol.to_string()));
@@ -257,24 +290,87 @@ impl AppState {
257290
}
258291

259292
out.push_str(&format!(
260-
"Unknown apps with blocked connections: {}\n\n",
293+
"Blocked unknown apps: {}\n\n",
261294
self.unknown_connections.len()
262295
));
263296

264-
for (binary, conn) in &self.unknown_connections {
297+
let mut entries: Vec<_> = self.unknown_connections.values().collect();
298+
entries.sort_by(|a, b| b.attempt_count.cmp(&a.attempt_count));
299+
300+
for conn in entries {
265301
let elapsed = conn.first_seen.elapsed().as_secs();
302+
let status = if conn.summary_emitted {
303+
"aggregated"
304+
} else {
305+
"collecting..."
306+
};
266307
out.push_str(&format!(
267-
" {} ({} attempts, first seen {}s ago)\n",
268-
binary, conn.attempt_count, elapsed
308+
" {} ({} attempts, {}s ago) [{}]\n",
309+
conn.binary, conn.attempt_count, elapsed, status
269310
));
270-
for (port, proto) in &conn.ports {
311+
312+
let mut ports: Vec<_> = conn.ports.iter().collect();
313+
ports.sort_by_key(|(p, _)| *p);
314+
for (port, proto) in &ports {
271315
out.push_str(&format!(" -> {}/{}\n", port, proto));
272316
}
317+
318+
if !conn.dest_addrs.is_empty() {
319+
let addrs: Vec<_> = conn.dest_addrs.iter().take(5).cloned().collect();
320+
let suffix = if conn.dest_addrs.len() > 5 {
321+
format!(" (+{} more)", conn.dest_addrs.len() - 5)
322+
} else {
323+
String::new()
324+
};
325+
out.push_str(&format!(" IPs: {}{}\n", addrs.join(", "), suffix));
326+
}
327+
328+
// Suggest the add command
329+
let port_args: Vec<String> = ports
330+
.iter()
331+
.map(|(port, proto)| format!("{}/{}", port, proto))
332+
.collect();
333+
if !port_args.is_empty() {
334+
out.push_str(&format!(
335+
" Suggest: afw add {} {} {}\n",
336+
conn.binary.to_lowercase().replace(' ', "-"),
337+
conn.binary,
338+
port_args.join(" ")
339+
));
340+
}
341+
out.push('\n');
273342
}
274343

275344
out
276345
}
277346

347+
/// Check aggregation windows and return summaries for apps ready to report.
348+
/// An app is "ready" when it was first seen >= AGGREGATION_WINDOW_SECS ago
349+
/// and hasn't had a summary emitted yet.
350+
pub fn check_aggregation_windows(&mut self) -> Vec<UnknownConnectionSummary> {
351+
let mut summaries = Vec::new();
352+
353+
for conn in self.unknown_connections.values_mut() {
354+
if !conn.summary_emitted
355+
&& conn.first_seen.elapsed().as_secs() >= AGGREGATION_WINDOW_SECS
356+
{
357+
conn.summary_emitted = true;
358+
359+
let mut ports: Vec<(u16, String)> = conn.ports.iter().cloned().collect();
360+
ports.sort_by_key(|(p, _)| *p);
361+
362+
summaries.push(UnknownConnectionSummary {
363+
binary: conn.binary.clone(),
364+
ports,
365+
dest_addrs: conn.dest_addrs.iter().cloned().collect(),
366+
attempt_count: conn.attempt_count,
367+
});
368+
}
369+
}
370+
371+
summaries
372+
}
373+
278374
/// Clear unknown connection tracking for a specific app (e.g. after approval)
279375
pub fn clear_unknown(&mut self, binary: &str) {
280376
self.unknown_connections.remove(binary);

0 commit comments

Comments
 (0)