Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions crates/prt-core/src/core/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,22 @@ pub fn format_duration(d: Duration) -> String {
}
}

/// Like [`format_duration`] but keeps the finer unit for longer spans:
/// `45s`, `12m`, `3h04m`, `2d05h`. Used for tunnel uptime, where minute/hour
/// precision is more useful than [`format_duration`]'s coarser buckets.
pub fn format_uptime(d: Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
} else {
format!("{}d{:02}h", secs / 86_400, (secs % 86_400) / 3600)
}
}

/// Sort entries in-place by the given column and direction.
pub fn sort_entries(entries: &mut [TrackedEntry], state: &SortState) {
entries.sort_by(|a, b| {
Expand Down Expand Up @@ -1231,6 +1247,22 @@ mod tests {
}
}

#[test]
fn format_uptime_buckets() {
assert_eq!(format_uptime(Duration::from_secs(0)), "0s");
assert_eq!(format_uptime(Duration::from_secs(45)), "45s");
assert_eq!(format_uptime(Duration::from_secs(59)), "59s");
assert_eq!(format_uptime(Duration::from_secs(60)), "1m");
assert_eq!(format_uptime(Duration::from_secs(3599)), "59m");
assert_eq!(format_uptime(Duration::from_secs(3600)), "1h00m");
assert_eq!(format_uptime(Duration::from_secs(3600 + 4 * 60)), "1h04m");
assert_eq!(format_uptime(Duration::from_secs(86_400)), "1d00h");
assert_eq!(
format_uptime(Duration::from_secs(86_400 + 5 * 3600)),
"1d05h"
);
}

// ── build_process_tree ────────────────────────────────────────

#[test]
Expand Down
59 changes: 59 additions & 0 deletions crates/prt-core/src/core/suspicious.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
//! 1. **NonRootPrivileged** — non-root process listening on port < 1024
//! 2. **ScriptOnSensitive** — scripting language on a sensitive port (22, 80, 443)
//! 3. **RootHighPortOutgoing** — root process with outgoing connection to high port
//! 4. **ProxyListening** — process listening on a well-known SOCKS/proxy port

use crate::model::{ConnectionState, PortEntry, SuspiciousReason};

Expand All @@ -18,6 +19,22 @@ const SCRIPT_NAMES: &[&str] = &["python", "python3", "perl", "ruby", "node"];
/// Sensitive ports that should not normally be served by scripting languages.
const SENSITIVE_PORTS: &[u16] = &[22, 80, 443];

/// Well-known SOCKS/proxy listener ports. Deliberately narrow — `8080`/`8443`
/// are excluded because they are far more often legitimate HTTP(S)-alt servers
/// than proxies, which would make this heuristic too noisy.
///
/// This is intentionally a separate, tighter list from
/// `crate::known_ports::known_service` (which names many proxy ports for
/// display); keep the two in sync by hand when adding a proxy port that should
/// also drive this heuristic.
const PROXY_PORTS: &[u16] = &[
1080, // SOCKS
1081, // SOCKS (alt)
3128, // Squid
9050, // Tor SOCKS
9150, // Tor Browser SOCKS
];

/// Run all heuristics on a single port entry and return matching reasons.
pub fn check(entry: &PortEntry) -> Vec<SuspiciousReason> {
let mut reasons = Vec::new();
Expand All @@ -31,6 +48,9 @@ pub fn check(entry: &PortEntry) -> Vec<SuspiciousReason> {
if is_root_high_port_outgoing(entry) {
reasons.push(SuspiciousReason::RootHighPortOutgoing);
}
if is_proxy_listening(entry) {
reasons.push(SuspiciousReason::ProxyListening);
}

reasons
}
Expand Down Expand Up @@ -83,6 +103,15 @@ fn is_root_high_port_outgoing(entry: &PortEntry) -> bool {
}
}

/// Process listening on a well-known SOCKS/proxy port (see [`PROXY_PORTS`]).
///
/// A SOCKS/proxy listener is worth surfacing: it may be a legitimate `ssh -D`
/// tunnel, but it can equally be an attacker pivoting traffic through the host.
/// Flagging it lets the operator confirm the listener is one they started.
fn is_proxy_listening(entry: &PortEntry) -> bool {
entry.state == ConnectionState::Listen && PROXY_PORTS.contains(&entry.local_addr.port())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -293,4 +322,34 @@ mod tests {
let entry = listen_entry(443, "nginx", Some("root"));
assert!(check(&entry).is_empty());
}

// ── ProxyListening ───────────────────────────────────────────

#[test]
fn socks_listener_flagged() {
let entry = listen_entry(1080, "ssh", Some("user"));
assert!(is_proxy_listening(&entry));
assert!(check(&entry).contains(&SuspiciousReason::ProxyListening));
}

#[test]
fn tor_socks_listener_flagged() {
let entry = listen_entry(9050, "tor", Some("debian-tor"));
assert!(check(&entry).contains(&SuspiciousReason::ProxyListening));
}

#[test]
fn http_alt_8080_not_flagged_as_proxy() {
// 8080 is excluded to avoid false positives on legitimate http-alt.
let entry = listen_entry(8080, "nginx", Some("www-data"));
assert!(!is_proxy_listening(&entry));
}

#[test]
fn proxy_port_established_not_flagged() {
// An outgoing connection to a proxy port is not a local proxy listener.
let mut entry = listen_entry(1080, "curl", Some("user"));
entry.state = ConnectionState::Established;
assert!(!is_proxy_listening(&entry));
}
}
3 changes: 3 additions & 0 deletions crates/prt-core/src/i18n/en.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,12 @@ pub static STRINGS: Strings = Strings {
tunnel_col_remote: "Remote",
tunnel_col_host: "Host",
tunnel_col_status: "Status",
tunnel_col_uptime: "Uptime",
tunnel_status_alive: "alive",
tunnel_status_dead: "dead",
tunnel_status_starting: "starting",
tunnel_status_failed: "failed",
tunnel_health_no_listener: "no listener",
tunnel_form_edit_title: " Edit SSH Tunnel ",
tunnel_form_field_required: "required",
tunnels_empty: " No active tunnels. Press [n] to create one.",
Expand All @@ -169,6 +171,7 @@ pub static STRINGS: Strings = Strings {
hint_new_tunnel: "new",
hint_kill_tunnel: "kill",
hint_restart_tunnel: "restart",
hint_copy_tunnel: "copy cmd",
hint_save_tunnels: "save",
hint_reload: "reload",
hint_open_tunnel: "tunnel",
Expand Down
3 changes: 3 additions & 0 deletions crates/prt-core/src/i18n/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,10 +249,12 @@ pub struct Strings {
pub tunnel_col_remote: &'static str,
pub tunnel_col_host: &'static str,
pub tunnel_col_status: &'static str,
pub tunnel_col_uptime: &'static str,
pub tunnel_status_alive: &'static str,
pub tunnel_status_dead: &'static str,
pub tunnel_status_starting: &'static str,
pub tunnel_status_failed: &'static str,
pub tunnel_health_no_listener: &'static str,
pub tunnel_form_edit_title: &'static str,
pub tunnel_form_field_required: &'static str,
pub tunnels_empty: &'static str,
Expand All @@ -277,6 +279,7 @@ pub struct Strings {
pub hint_new_tunnel: &'static str,
pub hint_kill_tunnel: &'static str,
pub hint_restart_tunnel: &'static str,
pub hint_copy_tunnel: &'static str,
pub hint_save_tunnels: &'static str,
pub hint_reload: &'static str,
pub hint_open_tunnel: &'static str,
Expand Down
3 changes: 3 additions & 0 deletions crates/prt-core/src/i18n/ru.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,12 @@ pub static STRINGS: Strings = Strings {
tunnel_col_remote: "Удалённ.",
tunnel_col_host: "Хост",
tunnel_col_status: "Статус",
tunnel_col_uptime: "Аптайм",
tunnel_status_alive: "активен",
tunnel_status_dead: "мёртв",
tunnel_status_starting: "запускается",
tunnel_status_failed: "сбой",
tunnel_health_no_listener: "нет листенера",
tunnel_form_edit_title: " Правка SSH-туннеля ",
tunnel_form_field_required: "обязательно",
tunnels_empty: " Активных туннелей нет. Нажмите [n] чтобы создать.",
Expand All @@ -169,6 +171,7 @@ pub static STRINGS: Strings = Strings {
hint_new_tunnel: "новый",
hint_kill_tunnel: "убить",
hint_restart_tunnel: "рестарт",
hint_copy_tunnel: "копир. cmd",
hint_save_tunnels: "сохр.",
hint_reload: "обновить",
hint_open_tunnel: "туннель",
Expand Down
3 changes: 3 additions & 0 deletions crates/prt-core/src/i18n/zh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,12 @@ pub static STRINGS: Strings = Strings {
tunnel_col_remote: "远端",
tunnel_col_host: "主机",
tunnel_col_status: "状态",
tunnel_col_uptime: "运行时长",
tunnel_status_alive: "活跃",
tunnel_status_dead: "已断",
tunnel_status_starting: "启动中",
tunnel_status_failed: "失败",
tunnel_health_no_listener: "无监听",
tunnel_form_edit_title: " 编辑 SSH 隧道 ",
tunnel_form_field_required: "必填",
tunnels_empty: " 无活跃隧道。按 [n] 创建。",
Expand All @@ -168,6 +170,7 @@ pub static STRINGS: Strings = Strings {
hint_new_tunnel: "新建",
hint_kill_tunnel: "终止",
hint_restart_tunnel: "重启",
hint_copy_tunnel: "复制命令",
hint_save_tunnels: "保存",
hint_reload: "重载",
hint_open_tunnel: "隧道",
Expand Down
8 changes: 8 additions & 0 deletions crates/prt-core/src/known_ports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
//!
//! Provides a compile-time lookup table for ~170 common ports.
//! User overrides from `~/.config/prt/config.toml` take precedence.
//!
//! NOTE: the proxy/SOCKS ports here are for *display* naming only. The
//! suspicious-listener heuristic uses its own intentionally narrower list,
//! `crate::core::suspicious::PROXY_PORTS` — adding a proxy port here does not
//! automatically flag it as suspicious.

use std::collections::HashMap;

Expand Down Expand Up @@ -77,6 +82,7 @@ fn builtin_lookup(port: u16) -> Option<&'static str> {

// ── Common registered ports ──────────────────────────────
1080 => Some("socks"),
1081 => Some("socks"),
1194 => Some("openvpn"),
1433 => Some("mssql"),
1434 => Some("mssql-m"),
Expand Down Expand Up @@ -160,11 +166,13 @@ fn builtin_lookup(port: u16) -> Option<&'static str> {
9000 => Some("php-fpm"),
9042 => Some("cassandra"),
9043 => Some("websphere"),
9050 => Some("tor-socks"),
9090 => Some("prometheus"),
9091 => Some("transmsn"),
9092 => Some("kafka"),
9093 => Some("alertmgr"),
9100 => Some("node-exp"),
9150 => Some("tor-socks"),
9160 => Some("cassandra"),
9200 => Some("elastic"),
9300 => Some("elastic"),
Expand Down
2 changes: 2 additions & 0 deletions crates/prt-core/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ pub enum SuspiciousReason {
ScriptOnSensitive,
/// Root process making outgoing connection to a high port.
RootHighPortOutgoing,
/// Process listening on a well-known SOCKS/proxy port (1080, 9050, …).
ProxyListening,
}

/// Column by which the port table can be sorted.
Expand Down
17 changes: 16 additions & 1 deletion crates/prt/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,19 @@ impl App {
}
}

/// Copy the `ssh` command line of the selected tunnel to the clipboard.
pub fn copy_selected_tunnel_command(&mut self) {
let idx = match self.clamp_tunnels_selected() {
Some(i) => i,
None => return,
};
let cmd = match self.forwards.tunnels.get(idx) {
Some(t) => t.command_string(),
None => return,
};
self.copy_to_clipboard(&cmd);
}

/// Persist the current set of active tunnels to the user's config file.
/// Failed tunnels are pruned first so the on-disk config stays clean.
pub fn save_tunnels(&mut self) {
Expand Down Expand Up @@ -559,8 +572,10 @@ pub fn run() -> Result<()> {
}
}

// Cleanup dead tunnels
// Refresh tunnel statuses, then auto-reconnect any that died on their
// own (with backoff, so an unreachable host isn't hammered).
app.forwards.cleanup();
app.forwards.reconnect_failed();

if last_tick.elapsed() >= TICK_RATE {
if !app.auto_refresh_paused {
Expand Down
Loading
Loading