Merge pull request #3 from jasonwitty/feature/connection-profiles
Feature/connection profiles
This commit is contained in:
commit
384953d5d5
44
Cargo.lock
generated
44
Cargo.lock
generated
@ -540,6 +540,27 @@ dependencies = [
|
|||||||
"crypto-common",
|
"crypto-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-next"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"dirs-sys-next",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-sys-next"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"redox_users",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@ -1204,6 +1225,16 @@ dependencies = [
|
|||||||
"windows-targets 0.53.3",
|
"windows-targets 0.53.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libredox"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.4.15"
|
version = "0.4.15"
|
||||||
@ -1850,6 +1881,17 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_users"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.16",
|
||||||
|
"libredox",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@ -2183,6 +2225,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"crossterm 0.27.0",
|
"crossterm 0.27.0",
|
||||||
|
"dirs-next",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@ -2194,6 +2237,7 @@ dependencies = [
|
|||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
"url",
|
"url",
|
||||||
|
|||||||
@ -39,6 +39,7 @@ axum = { version = "0.7", features = ["ws"] }
|
|||||||
prost = "0.13"
|
prost = "0.13"
|
||||||
prost-types = "0.13"
|
prost-types = "0.13"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
|
dirs-next = "2"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
# Favor smaller, simpler binaries with good runtime perf
|
# Favor smaller, simpler binaries with good runtime perf
|
||||||
|
|||||||
103
README.md
103
README.md
@ -94,31 +94,18 @@ cargo build --release
|
|||||||
./target/release/socktop ws://REMOTE_HOST:3000/ws
|
./target/release/socktop ws://REMOTE_HOST:3000/ws
|
||||||
```
|
```
|
||||||
|
|
||||||
Tip: Add ?token=... if you enable auth (see Security).
|
### Quick demo (no agent setup)
|
||||||
|
|
||||||
TLS quick start (optional, recommended on untrusted networks):
|
Spin up a temporary local agent on port 3231 and connect automatically:
|
||||||
|
|
||||||
- Start the agent with TLS enabled (default TLS port 8443). On first run it will generate a self‑signed certificate and key under your config directory.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./target/release/socktop_agent --enableSSL --port 8443 # or: -p 8443
|
socktop --demo
|
||||||
# First run prints the cert and key paths, e.g.:
|
|
||||||
# socktop_agent: generated self-signed TLS certificate at /home/you/.config/socktop_agent/tls/cert.pem
|
|
||||||
# socktop_agent: private key at /home/you/.config/socktop_agent/tls/key.pem
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Copy the certificate file to the client machine (keep the key private on the server):
|
Or just run `socktop` with no arguments and pick the built‑in `demo` entry from the interactive profile list (if you have saved profiles, `demo` is appended). The demo agent:
|
||||||
|
|
||||||
```bash
|
- Runs locally (`ws://127.0.0.1:3231/ws`)
|
||||||
scp /home/you/.config/socktop_agent/tls/cert.pem you@client:/tmp/socktop-agent-ca.pem
|
- Stops automatically (you'll see "Stopped demo agent on port 3231") when you quit the TUI or press Ctrl-C
|
||||||
```
|
|
||||||
|
|
||||||
- Connect with the TUI, pinning the server cert:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./target/release/socktop --tls-ca /tmp/socktop-agent-ca.pem wss://REMOTE_HOST:8443/ws
|
|
||||||
# Note: if you pass --tls-ca but use ws://, the client auto-upgrades to wss://
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -186,6 +173,84 @@ The agent stays idle unless queried. When queried, it collects just what’s nee
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Connection Profiles (Named)
|
||||||
|
|
||||||
|
You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later.
|
||||||
|
|
||||||
|
Config file location:
|
||||||
|
|
||||||
|
- Linux (XDG): `$XDG_CONFIG_HOME/socktop/profiles.json`
|
||||||
|
- Fallback (when XDG not set): `~/.config/socktop/profiles.json`
|
||||||
|
|
||||||
|
### Creating a profile
|
||||||
|
|
||||||
|
First time you specify a new `--profile/-P` name together with a URL (and optional `--tls-ca`), it is saved automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
socktop --profile prod ws://prod-host:3000/ws
|
||||||
|
# With TLS pinning:
|
||||||
|
socktop --profile prod-tls --tls-ca /path/to/cert.pem wss://prod-host:8443/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
If a profile already exists you will be prompted before overwriting:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ socktop --profile prod ws://new-host:3000/ws
|
||||||
|
Overwrite existing profile 'prod'? [y/N]: y
|
||||||
|
```
|
||||||
|
|
||||||
|
To overwrite without an interactive prompt pass `--save`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
socktop --profile prod --save ws://new-host:3000/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using a saved profile
|
||||||
|
|
||||||
|
Just pass the profile name (no URL needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
socktop --profile prod
|
||||||
|
socktop -P prod-tls # short flag
|
||||||
|
```
|
||||||
|
|
||||||
|
The stored URL (and TLS CA path, if any) will be used. TLS auto-upgrade still applies if a CA path is stored alongside a ws:// URL.
|
||||||
|
|
||||||
|
### Interactive selection (no args)
|
||||||
|
|
||||||
|
If you run `socktop` with no arguments and at least one profile exists, you will be shown a numbered list to pick from:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ socktop
|
||||||
|
Select profile:
|
||||||
|
1. prod
|
||||||
|
2. prod-tls
|
||||||
|
Enter number (or blank to abort): 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Choosing a number starts the TUI with that profile. A built‑in `demo` option is always appended; selecting it launches a local agent on port 3231 (no TLS) and connects to `ws://127.0.0.1:3231/ws`. Pressing Enter on blank aborts without connecting.
|
||||||
|
|
||||||
|
### JSON format
|
||||||
|
|
||||||
|
An example `profiles.json` (pretty‑printed):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"prod": { "url": "ws://prod-host:3000/ws" },
|
||||||
|
"prod-tls": { "url": "wss://prod-host:8443/ws", "tls_ca": "/home/user/certs/prod-cert.pem" }
|
||||||
|
},
|
||||||
|
"version": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The `tls_ca` path is stored as given; if you move or rotate the certificate update the profile by re-running with `--profile NAME --save`.
|
||||||
|
- Deleting a profile: edit the JSON file and remove the entry (TUI does not yet have an in-app delete command).
|
||||||
|
- Profiles are client-side convenience only; they do not affect the agent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
Update the agent (systemd):
|
Update the agent (systemd):
|
||||||
|
|||||||
@ -19,6 +19,7 @@ crossterm = { workspace = true }
|
|||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||||
|
dirs-next = { workspace = true }
|
||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
rustls-pemfile = "2.1"
|
rustls-pemfile = "2.1"
|
||||||
prost = { workspace = true }
|
prost = { workspace = true }
|
||||||
@ -26,6 +27,7 @@ bytes = { workspace = true }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2.0"
|
assert_cmd = "2.0"
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
prost-build = "0.13"
|
prost-build = "0.13"
|
||||||
|
|||||||
@ -2,29 +2,55 @@
|
|||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod history;
|
mod history;
|
||||||
|
mod profiles;
|
||||||
mod types;
|
mod types;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
use app::App;
|
use app::App;
|
||||||
|
use profiles::{load_profiles, save_profiles, ProfileEntry, ProfileRequest, ResolveProfile};
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<(String, Option<String>), String> {
|
pub(crate) struct ParsedArgs {
|
||||||
|
url: Option<String>,
|
||||||
|
tls_ca: Option<String>,
|
||||||
|
profile: Option<String>,
|
||||||
|
save: bool,
|
||||||
|
demo: bool,
|
||||||
|
dry_run: bool, // hidden test helper: skip connecting
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
|
||||||
let mut it = args.into_iter();
|
let mut it = args.into_iter();
|
||||||
let prog = it.next().unwrap_or_else(|| "socktop".into());
|
let prog = it.next().unwrap_or_else(|| "socktop".into());
|
||||||
let mut url: Option<String> = None;
|
let mut url: Option<String> = None;
|
||||||
let mut tls_ca: Option<String> = None;
|
let mut tls_ca: Option<String> = None;
|
||||||
|
let mut profile: Option<String> = None;
|
||||||
|
let mut save = false;
|
||||||
|
let mut demo = false;
|
||||||
|
let mut dry_run = false;
|
||||||
while let Some(arg) = it.next() {
|
while let Some(arg) = it.next() {
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
"-h" | "--help" => {
|
"-h" | "--help" => {
|
||||||
return Err(format!(
|
return Err(format!("Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]\n"));
|
||||||
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] ws://HOST:PORT/ws"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
"--tls-ca" | "-t" => {
|
"--tls-ca" | "-t" => {
|
||||||
tls_ca = it.next();
|
tls_ca = it.next();
|
||||||
}
|
}
|
||||||
|
"--profile" | "-P" => {
|
||||||
|
profile = it.next();
|
||||||
|
}
|
||||||
|
"--save" => {
|
||||||
|
save = true;
|
||||||
|
}
|
||||||
|
"--demo" => {
|
||||||
|
demo = true;
|
||||||
|
}
|
||||||
|
"--dry-run" => {
|
||||||
|
// intentionally undocumented
|
||||||
|
dry_run = true;
|
||||||
|
}
|
||||||
_ if arg.starts_with("--tls-ca=") => {
|
_ if arg.starts_with("--tls-ca=") => {
|
||||||
if let Some((_, v)) = arg.split_once('=') {
|
if let Some((_, v)) = arg.split_once('=') {
|
||||||
if !v.is_empty() {
|
if !v.is_empty() {
|
||||||
@ -32,37 +58,228 @@ fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<(String, Option
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ if arg.starts_with("--profile=") => {
|
||||||
|
if let Some((_, v)) = arg.split_once('=') {
|
||||||
|
if !v.is_empty() {
|
||||||
|
profile = Some(v.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if url.is_none() {
|
if url.is_none() {
|
||||||
url = Some(arg);
|
url = Some(arg);
|
||||||
} else {
|
} else {
|
||||||
return Err(format!(
|
return Err(format!("Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]"));
|
||||||
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] ws://HOST:PORT/ws"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(ParsedArgs {
|
||||||
match url {
|
url,
|
||||||
Some(u) => Ok((u, tls_ca)),
|
tls_ca,
|
||||||
None => Err(format!(
|
profile,
|
||||||
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] ws://HOST:PORT/ws"
|
save,
|
||||||
)),
|
demo,
|
||||||
}
|
dry_run,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Reuse the same parsing logic for testability
|
let parsed = match parse_args(env::args()) {
|
||||||
let (url, tls_ca) = match parse_args(env::args()) {
|
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
eprintln!("{msg}");
|
eprintln!("{msg}");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) {
|
||||||
|
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||||
|
}
|
||||||
|
let profiles_file = load_profiles();
|
||||||
|
let req = ProfileRequest {
|
||||||
|
profile_name: parsed.profile.clone(),
|
||||||
|
url: parsed.url.clone(),
|
||||||
|
tls_ca: parsed.tls_ca.clone(),
|
||||||
|
};
|
||||||
|
let resolved = req.resolve(&profiles_file);
|
||||||
|
let mut profiles_mut = profiles_file.clone();
|
||||||
|
let (url, tls_ca): (String, Option<String>) = match resolved {
|
||||||
|
ResolveProfile::Direct(u, t) => {
|
||||||
|
if let Some(name) = parsed.profile.as_ref() {
|
||||||
|
let existing = profiles_mut.profiles.get(name);
|
||||||
|
match existing {
|
||||||
|
None => {
|
||||||
|
profiles_mut.profiles.insert(
|
||||||
|
name.clone(),
|
||||||
|
ProfileEntry {
|
||||||
|
url: u.clone(),
|
||||||
|
tls_ca: t.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let _ = save_profiles(&profiles_mut);
|
||||||
|
}
|
||||||
|
Some(entry) => {
|
||||||
|
let changed = entry.url != u || entry.tls_ca != t;
|
||||||
|
if changed {
|
||||||
|
let overwrite = if parsed.save {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
prompt_yes_no(&format!(
|
||||||
|
"Overwrite existing profile '{name}'? [y/N]: "
|
||||||
|
))
|
||||||
|
};
|
||||||
|
if overwrite {
|
||||||
|
profiles_mut.profiles.insert(
|
||||||
|
name.clone(),
|
||||||
|
ProfileEntry {
|
||||||
|
url: u.clone(),
|
||||||
|
tls_ca: t.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let _ = save_profiles(&profiles_mut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(u, t)
|
||||||
|
}
|
||||||
|
ResolveProfile::Loaded(u, t) => (u, t),
|
||||||
|
ResolveProfile::PromptSelect(mut names) => {
|
||||||
|
if !names.iter().any(|n| n == "demo") {
|
||||||
|
names.push("demo".into());
|
||||||
|
}
|
||||||
|
eprintln!("Select profile:");
|
||||||
|
for (i, n) in names.iter().enumerate() {
|
||||||
|
eprintln!(" {}. {}", i + 1, n);
|
||||||
|
}
|
||||||
|
eprint!("Enter number (or blank to abort): ");
|
||||||
|
let _ = io::stderr().flush();
|
||||||
|
let mut line = String::new();
|
||||||
|
if io::stdin().read_line(&mut line).is_ok() {
|
||||||
|
if let Ok(idx) = line.trim().parse::<usize>() {
|
||||||
|
if idx >= 1 && idx <= names.len() {
|
||||||
|
let name = &names[idx - 1];
|
||||||
|
if name == "demo" {
|
||||||
|
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||||
|
}
|
||||||
|
if let Some(entry) = profiles_mut.profiles.get(name) {
|
||||||
|
(entry.url.clone(), entry.tls_ca.clone())
|
||||||
|
} else {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResolveProfile::PromptCreate(name) => {
|
||||||
|
eprintln!("Profile '{name}' does not exist yet.");
|
||||||
|
let url = prompt_string("Enter URL (ws://HOST:PORT/ws or wss://...): ")?;
|
||||||
|
if url.trim().is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let ca = prompt_string("Enter TLS CA path (or leave blank): ")?;
|
||||||
|
let ca_opt = if ca.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(ca.trim().to_string())
|
||||||
|
};
|
||||||
|
profiles_mut.profiles.insert(
|
||||||
|
name.clone(),
|
||||||
|
ProfileEntry {
|
||||||
|
url: url.trim().to_string(),
|
||||||
|
tls_ca: ca_opt.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let _ = save_profiles(&profiles_mut);
|
||||||
|
(url.trim().to_string(), ca_opt)
|
||||||
|
}
|
||||||
|
ResolveProfile::None => {
|
||||||
|
eprintln!("No URL provided and no profiles to select.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
if parsed.dry_run {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
app.run(&url, tls_ca.as_deref()).await
|
app.run(&url, tls_ca.as_deref()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prompt_yes_no(prompt: &str) -> bool {
|
||||||
|
eprint!("{prompt}");
|
||||||
|
let _ = io::stderr().flush();
|
||||||
|
let mut line = String::new();
|
||||||
|
if io::stdin().read_line(&mut line).is_ok() {
|
||||||
|
matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn prompt_string(prompt: &str) -> io::Result<String> {
|
||||||
|
eprint!("{prompt}");
|
||||||
|
let _ = io::stderr().flush();
|
||||||
|
let mut line = String::new();
|
||||||
|
io::stdin().read_line(&mut line)?;
|
||||||
|
Ok(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo mode implementation
|
||||||
|
async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let port = 3231;
|
||||||
|
let url = format!("ws://127.0.0.1:{port}/ws");
|
||||||
|
let child = spawn_demo_agent(port)?;
|
||||||
|
let mut app = App::new();
|
||||||
|
tokio::select! { res=app.run(&url,None)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } }
|
||||||
|
}
|
||||||
|
struct DemoGuard {
|
||||||
|
port: u16,
|
||||||
|
child: std::sync::Arc<std::sync::Mutex<Option<std::process::Child>>>,
|
||||||
|
}
|
||||||
|
impl Drop for DemoGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(mut ch) = self.child.lock().unwrap().take() {
|
||||||
|
let _ = ch.kill();
|
||||||
|
}
|
||||||
|
eprintln!("Stopped demo agent on port {}", self.port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, Box<dyn std::error::Error>> {
|
||||||
|
let candidate = find_agent_executable();
|
||||||
|
let mut cmd = std::process::Command::new(candidate);
|
||||||
|
cmd.arg("--port").arg(port.to_string());
|
||||||
|
cmd.env("SOCKTOP_ENABLE_SSL", "0");
|
||||||
|
|
||||||
|
//JW: do not disable GPU and TEMP in demo mode
|
||||||
|
//cmd.env("SOCKTOP_AGENT_GPU", "0");
|
||||||
|
//cmd.env("SOCKTOP_AGENT_TEMP", "0");
|
||||||
|
|
||||||
|
let child = cmd.spawn()?;
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||||
|
Ok(DemoGuard {
|
||||||
|
port,
|
||||||
|
child: std::sync::Arc::new(std::sync::Mutex::new(Some(child))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn find_agent_executable() -> std::path::PathBuf {
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(parent) = exe.parent() {
|
||||||
|
#[cfg(windows)]
|
||||||
|
let name = "socktop_agent.exe";
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let name = "socktop_agent";
|
||||||
|
let candidate = parent.join(name);
|
||||||
|
if candidate.exists() {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::path::PathBuf::from("socktop_agent")
|
||||||
|
}
|
||||||
|
|||||||
98
socktop/src/profiles.rs
Normal file
98
socktop/src/profiles.rs
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
//! Connection profiles: load/save simple JSON mapping of profile name -> { url, tls_ca }
|
||||||
|
//! Stored under XDG config dir: $XDG_CONFIG_HOME/socktop/profiles.json (fallback ~/.config/socktop/profiles.json)
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{collections::BTreeMap, fs, path::PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ProfileEntry {
|
||||||
|
pub url: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tls_ca: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ProfilesFile {
|
||||||
|
#[serde(default)]
|
||||||
|
pub profiles: BTreeMap<String, ProfileEntry>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_dir() -> PathBuf {
|
||||||
|
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||||
|
PathBuf::from(xdg).join("socktop")
|
||||||
|
} else {
|
||||||
|
dirs_next::config_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("socktop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn profiles_path() -> PathBuf {
|
||||||
|
config_dir().join("profiles.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_profiles() -> ProfilesFile {
|
||||||
|
let path = profiles_path();
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||||
|
Err(_) => ProfilesFile::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_profiles(p: &ProfilesFile) -> std::io::Result<()> {
|
||||||
|
let path = profiles_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let data = serde_json::to_vec_pretty(p).expect("serialize profiles");
|
||||||
|
fs::write(path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum ResolveProfile {
|
||||||
|
/// Use the provided runtime inputs (not persisted). (url, tls_ca)
|
||||||
|
Direct(String, Option<String>),
|
||||||
|
/// Loaded from existing profile entry (url, tls_ca)
|
||||||
|
Loaded(String, Option<String>),
|
||||||
|
/// Should prompt user to select among profile names
|
||||||
|
PromptSelect(Vec<String>),
|
||||||
|
/// Should prompt user to create a new profile (name)
|
||||||
|
PromptCreate(String),
|
||||||
|
/// No profile could be resolved (e.g., missing arguments)
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProfileRequest {
|
||||||
|
pub profile_name: Option<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub tls_ca: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileRequest {
|
||||||
|
pub fn resolve(self, pf: &ProfilesFile) -> ResolveProfile {
|
||||||
|
// Case: only profile name given -> try load
|
||||||
|
if self.url.is_none() && self.profile_name.is_some() {
|
||||||
|
let name = self.profile_name.unwrap();
|
||||||
|
if let Some(entry) = pf.profiles.get(&name) {
|
||||||
|
return ResolveProfile::Loaded(entry.url.clone(), entry.tls_ca.clone());
|
||||||
|
} else {
|
||||||
|
return ResolveProfile::PromptCreate(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Both provided -> direct (maybe later saved by caller)
|
||||||
|
if let Some(u) = self.url {
|
||||||
|
return ResolveProfile::Direct(u, self.tls_ca);
|
||||||
|
}
|
||||||
|
// Nothing provided -> maybe prompt select if profiles exist
|
||||||
|
if self.url.is_none() && self.profile_name.is_none() {
|
||||||
|
if pf.profiles.is_empty() {
|
||||||
|
ResolveProfile::None
|
||||||
|
} else {
|
||||||
|
ResolveProfile::PromptSelect(pf.profiles.keys().cloned().collect())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ResolveProfile::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,8 +17,11 @@ fn test_help_mentions_short_and_long_flags() {
|
|||||||
String::from_utf8_lossy(&output.stderr)
|
String::from_utf8_lossy(&output.stderr)
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("--tls-ca") && text.contains("-t"),
|
text.contains("--tls-ca")
|
||||||
"help text missing --tls-ca/-t\n{text}"
|
&& text.contains("-t")
|
||||||
|
&& text.contains("--profile")
|
||||||
|
&& text.contains("-P"),
|
||||||
|
"help text missing expected flags (--tls-ca/-t, --profile/-P)\n{text}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,4 +56,20 @@ fn test_tlc_ca_arg_long_and_short_parsed() {
|
|||||||
String::from_utf8_lossy(&out2.stderr)
|
String::from_utf8_lossy(&out2.stderr)
|
||||||
);
|
);
|
||||||
assert!(text2.contains("Usage:"));
|
assert!(text2.contains("Usage:"));
|
||||||
|
|
||||||
|
// Profile flags with help (should not error)
|
||||||
|
let out3 = Command::new(exe)
|
||||||
|
.args(["--profile", "dev", "--help"])
|
||||||
|
.output()
|
||||||
|
.expect("run socktop");
|
||||||
|
assert!(
|
||||||
|
out3.status.success(),
|
||||||
|
"socktop --profile dev --help did not succeed"
|
||||||
|
);
|
||||||
|
let text3 = format!(
|
||||||
|
"{}{}",
|
||||||
|
String::from_utf8_lossy(&out3.stdout),
|
||||||
|
String::from_utf8_lossy(&out3.stderr)
|
||||||
|
);
|
||||||
|
assert!(text3.contains("Usage:"));
|
||||||
}
|
}
|
||||||
|
|||||||
118
socktop/tests/profiles.rs
Normal file
118
socktop/tests/profiles.rs
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
//! Tests for profile load/save and resolution logic (non-interactive paths only)
|
||||||
|
use std::fs;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
// Global lock to serialize tests that mutate process-wide environment variables.
|
||||||
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
#[allow(dead_code)] // touch crate
|
||||||
|
fn touch() {
|
||||||
|
let _ = socktop::types::Metrics {
|
||||||
|
cpu_total: 0.0,
|
||||||
|
cpu_per_core: vec![],
|
||||||
|
mem_total: 0,
|
||||||
|
mem_used: 0,
|
||||||
|
swap_total: 0,
|
||||||
|
swap_used: 0,
|
||||||
|
process_count: None,
|
||||||
|
hostname: String::new(),
|
||||||
|
cpu_temp_c: None,
|
||||||
|
disks: vec![],
|
||||||
|
networks: vec![],
|
||||||
|
top_processes: vec![],
|
||||||
|
gpus: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// We re-import internal modules by copying minimal logic here because profiles.rs isn't public.
|
||||||
|
// Instead of exposing internals, we simulate profile saving through CLI invocations.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn run_socktop(args: &[&str]) -> (bool, String) {
|
||||||
|
let exe = env!("CARGO_BIN_EXE_socktop");
|
||||||
|
let output = Command::new(exe).args(args).output().expect("run socktop");
|
||||||
|
let ok = output.status.success();
|
||||||
|
let text = format!(
|
||||||
|
"{}{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
(ok, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_dir() -> std::path::PathBuf {
|
||||||
|
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||||
|
std::path::PathBuf::from(xdg).join("socktop")
|
||||||
|
} else {
|
||||||
|
dirs_next::config_dir()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||||
|
.join("socktop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profiles_path() -> std::path::PathBuf {
|
||||||
|
config_dir().join("profiles.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_profile_created_on_first_use() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
// Isolate config in a temp dir
|
||||||
|
let td = tempfile::tempdir().unwrap();
|
||||||
|
std::env::set_var("XDG_CONFIG_HOME", td.path());
|
||||||
|
// Ensure directory exists fresh
|
||||||
|
std::fs::create_dir_all(td.path().join("socktop")).unwrap();
|
||||||
|
let _ = fs::remove_file(profiles_path());
|
||||||
|
// Provide profile + url => should create profiles.json
|
||||||
|
let (_ok, _out) = run_socktop(&["--profile", "unittest", "ws://example:1/ws", "--dry-run"]);
|
||||||
|
// We pass --help to exit early after parsing (no network attempt)
|
||||||
|
let data = fs::read_to_string(profiles_path()).expect("profiles.json created");
|
||||||
|
assert!(
|
||||||
|
data.contains("unittest"),
|
||||||
|
"profiles.json missing profile entry: {data}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_profile_overwrite_only_when_changed() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
let td = tempfile::tempdir().unwrap();
|
||||||
|
std::env::set_var("XDG_CONFIG_HOME", td.path());
|
||||||
|
std::fs::create_dir_all(td.path().join("socktop")).unwrap();
|
||||||
|
let _ = fs::remove_file(profiles_path());
|
||||||
|
// Initial create
|
||||||
|
let (_ok, _out) = run_socktop(&["--profile", "prod", "ws://one/ws", "--dry-run"]); // create
|
||||||
|
let first = fs::read_to_string(profiles_path()).unwrap();
|
||||||
|
// Re-run identical (should not duplicate or corrupt)
|
||||||
|
let (_ok2, _out2) = run_socktop(&["--profile", "prod", "ws://one/ws", "--dry-run"]); // identical
|
||||||
|
let second = fs::read_to_string(profiles_path()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
first, second,
|
||||||
|
"Profile file changed despite identical input"
|
||||||
|
);
|
||||||
|
// Overwrite with different URL using --save (no prompt path)
|
||||||
|
let (_ok3, _out3) = run_socktop(&["--profile", "prod", "--save", "ws://two/ws", "--dry-run"]);
|
||||||
|
let third = fs::read_to_string(profiles_path()).unwrap();
|
||||||
|
assert!(third.contains("two"), "Updated URL not written: {third}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_profile_tls_ca_persisted() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
let td = tempfile::tempdir().unwrap();
|
||||||
|
std::env::set_var("XDG_CONFIG_HOME", td.path());
|
||||||
|
std::fs::create_dir_all(td.path().join("socktop")).unwrap();
|
||||||
|
let _ = fs::remove_file(profiles_path());
|
||||||
|
let (_ok, _out) = run_socktop(&[
|
||||||
|
"--profile",
|
||||||
|
"secureX",
|
||||||
|
"--tls-ca",
|
||||||
|
"/tmp/cert.pem",
|
||||||
|
"wss://host/ws",
|
||||||
|
"--dry-run",
|
||||||
|
]);
|
||||||
|
let data = fs::read_to_string(profiles_path()).unwrap();
|
||||||
|
assert!(data.contains("secureX"));
|
||||||
|
assert!(data.contains("cert.pem"));
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user