Skip to content
Closed
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.
///
/// NOTE: intentionally a separate, narrower list from the service-name table in
/// `known_ports.rs` (which labels ports for display). Adding a proxy port to
/// `known_ports` does not flag it here, and vice versa — keep the two in sync
/// by hand when it matters.
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
6 changes: 6 additions & 0 deletions crates/prt-core/src/known_ports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@ fn builtin_lookup(port: u16) -> Option<&'static str> {
995 => Some("pop3s"),

// ── Common registered ports ──────────────────────────────
// NOTE: proxy/SOCKS labels here are for display only. The suspicious-
// listener heuristic keeps its own narrower `PROXY_PORTS` list in
// `core/suspicious.rs`; the two are intentionally decoupled.
1080 => Some("socks"),
1081 => Some("socks"),
1194 => Some("openvpn"),
1433 => Some("mssql"),
1434 => Some("mssql-m"),
Expand Down Expand Up @@ -160,11 +164,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
1 change: 1 addition & 0 deletions crates/prt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ arboard.workspace = true
nix.workspace = true
ratatui = "0.29"
crossterm = "0.28"
shlex = "1"
31 changes: 28 additions & 3 deletions crates/prt/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ use prt_core::core::ssh_config::{self, SshHost};
use prt_core::core::ssh_tunnel::SshTunnelSpec;
use prt_core::core::{killer, process_detail, session::Session};
use prt_core::i18n;
use prt_core::model::{ProcessesTab, SshTab, TrackedEntry, ViewMode, TICK_RATE};
use prt_core::model::{ConnectionState, ProcessesTab, SshTab, TrackedEntry, ViewMode, TICK_RATE};

use crate::forward::ForwardManager;
use crate::tracer::StraceSession;
use crate::views::action_menu::ActionMenu;
use crate::views::command_palette::CommandPalette;
use crate::views::tunnel_form::TunnelFormState;
use ratatui::prelude::*;
use std::collections::HashSet;
use std::io::stdout;
use std::time::Instant;

Expand Down Expand Up @@ -258,6 +259,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 +573,19 @@ pub fn run() -> Result<()> {
}
}

// Cleanup dead tunnels
app.forwards.cleanup();
// Refresh tunnel statuses from the latest scan (which local ports are
// listening), then auto-reconnect any whose process died (with backoff,
// so an unreachable host isn't hammered). When auto-refresh is paused
// the scan is stale, so the listener health can't be trusted.
let listening: HashSet<u16> = app
.session
.entries
.iter()
.filter(|e| e.entry.state == ConnectionState::Listen)
.map(|e| e.entry.local_addr.port())
.collect();
app.forwards.cleanup(&listening, !app.auto_refresh_paused);
app.forwards.reconnect_failed();

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