diff --git a/AGENTS.md b/AGENTS.md index 6f6fd83..3743d5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,12 +22,13 @@ One window, not resizable. One workflow: Probe all, selected, or one `/24` → OK + Connect p50/p95 + TTFB p50/p95 + last probe. 5 s timeout, 5 starts/s, 32 in flight. 4. Export `[{tags}_]{CC}_{IP}_24_{qty}.txt`. Untagged files start with the - country code. No country → `XX`. + country code. No country → `XX`. The Export menu also writes one AYCD JSON + file for the same scope, categorized by the TXT filename stem. 5. Filter sits with Open files, Export, and Probe all. Toolbar Probe and Export act on selected rows when present. Probe progress and the version sit in the bottom bar. 15 subnets per page. Right-click a selected row to Probe or Export the selection; otherwise those actions apply to that row. - Remove always applies to that row. + The Export menu includes AYCD JSON. Remove always applies to that row. Probe is the only action verb for a run. Anything else is out of scope until asked. diff --git a/README.md b/README.md index aa93dd4..0171198 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ usually already have it. Windows installers are unsigned. `[{tags}_]{CC}_{IP}_24_{qty}.txt`. Example: `isp-mobile_FR_192.0.2.0_24_42.txt`. No tags means the filename starts with the country code. No country means `XX`. + The adjacent export menu also offers **Export for AYCD (.json)**, which writes + one categorized JSON file for the same scope. Run a probe once, then pause. Back-to-back runs saturate your own uplink and inflate every subnet. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8cec02c..7a75d15 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2811,6 +2811,7 @@ dependencies = [ "tokio-rustls", "ureq", "url", + "uuid", "webpki-roots 1.0.9", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6da9b8d..7b52c31 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,6 +16,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" +uuid = { version = "1", features = ["v4"] } ureq = { version = "2", features = ["json"] } tauri-plugin-dialog = "2" url = "2" diff --git a/src-tauri/src/aycd.rs b/src-tauri/src/aycd.rs new file mode 100644 index 0000000..de7b1f9 --- /dev/null +++ b/src-tauri/src/aycd.rs @@ -0,0 +1,182 @@ +use std::path::Path; + +use serde::Serialize; +use uuid::Uuid; + +use crate::export; +use crate::session::StoredBucket; +use crate::tags::Store; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Proxy { + id: String, + all_index: i8, + category_index: i8, + category: String, + url: String, + port: String, + user: String, + pass: String, + #[serde(rename = "type")] + kind: &'static str, +} + +pub fn write(path: &Path, buckets: &[StoredBucket], tags: &Store) -> Result { + if buckets.is_empty() { + return Err("Import proxies before exporting.".into()); + } + let proxies: Vec = buckets + .iter() + .flat_map(|bucket| { + let category = export::category( + &tags.get(&bucket.subnet.cidr()), + bucket.country.as_deref(), + bucket, + ); + bucket.proxies.iter().map(move |proxy| Proxy { + id: Uuid::new_v4().to_string(), + all_index: -1, + category_index: -1, + category: category.clone(), + url: proxy.host.to_string(), + port: proxy.port.to_string(), + user: proxy.username.clone(), + pass: proxy.password.clone(), + kind: "HTTP", + }) + }) + .collect(); + let bytes = serde_json::to_vec_pretty(&proxies).map_err(|err| err.to_string())?; + let path = json_path(path); + crate::secure_file::write(&path, &bytes).map_err(|err| io_error(&path, err))?; + Ok(proxies.len()) +} + +fn json_path(path: &Path) -> std::path::PathBuf { + if path.extension().and_then(|value| value.to_str()) == Some("json") { + path.to_owned() + } else { + let mut value = path.as_os_str().to_owned(); + value.push(".json"); + value.into() + } +} + +fn io_error(path: &Path, err: std::io::Error) -> String { + format!("Could not write {} ({})", path.display(), err.kind()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse::ProxyLine; + use crate::split::Subnet; + use crate::tags::Tag; + use std::fs; + + fn bucket(host: &str, country: Option<&str>, password: &str) -> StoredBucket { + StoredBucket { + subnet: Subnet::from_host(host.parse().unwrap()), + proxies: vec![ProxyLine { + host: host.parse().unwrap(), + port: 8080, + username: "user".into(), + password: password.into(), + source: String::new(), + }], + country: country.map(str::to_owned), + last_probe: None, + } + } + + fn temp_path() -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "proxybench-aycd-{}-{}.json", + std::process::id(), + Uuid::new_v4() + )) + } + + fn credential() -> String { + Uuid::new_v4().to_string() + } + + #[test] + fn write_serializes_aycd_schema_with_categories_and_unique_v4_ids() { + let path = temp_path(); + let tags_path = temp_path(); + let mut tags = Store::load(tags_path.clone()).unwrap(); + tags.set( + "192.0.2.0/24".into(), + vec![Tag::parse("Residential").unwrap()], + ) + .unwrap(); + let password = format!("{}:{}", credential(), credential()); + let other_password = credential(); + let buckets = vec![ + bucket("192.0.2.10", Some("fr"), &password), + bucket("198.51.100.10", None, &other_password), + ]; + assert_eq!(write(&path, &buckets, &tags).unwrap(), 2); + let bytes = fs::read(&path).unwrap(); + assert!(bytes.windows(4).any(|window| window == b"\n {")); + let rows: Vec = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0]["allIndex"], -1); + assert_eq!(rows[0]["categoryIndex"], -1); + assert_eq!(rows[0]["category"], "residential_FR_192.0.2.0_24_1"); + assert_eq!(rows[0]["url"], "192.0.2.10"); + assert_eq!(rows[0]["port"], "8080"); + assert_eq!(rows[0]["user"], "user"); + assert_eq!(rows[0]["pass"], password); + assert_eq!(rows[0]["type"], "HTTP"); + assert_eq!(rows[1]["category"], "XX_198.51.100.0_24_1"); + let first = rows[0]["id"].as_str().unwrap(); + let second = rows[1]["id"].as_str().unwrap(); + assert_ne!(first, second); + assert_eq!(Uuid::parse_str(first).unwrap().get_version_num(), 4); + assert_eq!(Uuid::parse_str(second).unwrap().get_version_num(), 4); + let _ = fs::remove_file(path); + let _ = fs::remove_file(tags_path); + } + + #[test] + fn write_appends_json_extension() { + let path = temp_path().with_extension("txt"); + let expected = json_path(&path); + assert!(expected.to_string_lossy().ends_with(".txt.json")); + let tags_path = temp_path(); + let tags = Store::load(tags_path.clone()).unwrap(); + write( + &path, + &[bucket("192.0.2.10", Some("FR"), &credential())], + &tags, + ) + .unwrap(); + assert!(expected.exists()); + let _ = fs::remove_file(expected); + let _ = fs::remove_file(tags_path); + } + + #[cfg(unix)] + #[test] + fn write_sets_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt; + let path = temp_path(); + let tags_path = temp_path(); + let tags = Store::load(tags_path.clone()).unwrap(); + write( + &path, + &[bucket("192.0.2.10", Some("FR"), &credential())], + &tags, + ) + .unwrap(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + let _ = fs::remove_file(path); + let _ = fs::remove_file(tags_path); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5789e12..4875311 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4,11 +4,12 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde::Serialize; use tauri::{AppHandle, Emitter, Manager, State}; +use crate::aycd; use crate::export; use crate::import::{self, InventoryStore, TagStore}; use crate::last_target; use crate::run::{self, Metrics, Progress}; -use crate::session::{SessionStore, StoredMetrics}; +use crate::session::{SessionStore, StoredBucket, StoredMetrics}; use crate::target::Target; const LAST_TARGET_FILE: &str = "last-target.txt"; @@ -112,17 +113,34 @@ pub fn export_dir( session: State<'_, SessionStore>, tags: State<'_, TagStore>, ) -> Result { - let buckets = { - let session = session.0.lock().map_err(|err| err.to_string())?; - if session.is_empty() { - return Err("Import proxies before exporting.".into()); - } - session.resolve_scope(cidrs)? - }; + let buckets = export_buckets(&session, cidrs)?; let store = tags.0.lock().map_err(|err| err.to_string())?; export::write_dir(std::path::Path::new(&path), &buckets, &store) } +#[tauri::command] +pub fn export_aycd( + path: String, + cidrs: Option>, + session: State<'_, SessionStore>, + tags: State<'_, TagStore>, +) -> Result { + let buckets = export_buckets(&session, cidrs)?; + let store = tags.0.lock().map_err(|err| err.to_string())?; + aycd::write(std::path::Path::new(&path), &buckets, &store) +} + +fn export_buckets( + session: &SessionStore, + cidrs: Option>, +) -> Result, String> { + let session = session.0.lock().map_err(|err| err.to_string())?; + if session.is_empty() { + return Err("Import proxies before exporting.".into()); + } + session.resolve_scope(cidrs) +} + #[tauri::command] pub fn session_rows( session: State<'_, SessionStore>, diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs index 45e443c..24b06f9 100644 --- a/src-tauri/src/export.rs +++ b/src-tauri/src/export.rs @@ -5,6 +5,10 @@ use crate::session::StoredBucket; use crate::tags::{Store, Tag}; pub fn filename(tags: &[Tag], country: Option<&str>, bucket: &StoredBucket) -> String { + format!("{}.txt", category(tags, country, bucket)) +} + +pub fn category(tags: &[Tag], country: Option<&str>, bucket: &StoredBucket) -> String { let country = country .filter(|code| code.len() == 2 && code.bytes().all(|b| b.is_ascii_alphabetic())) .map(|code| code.to_ascii_uppercase()) @@ -12,8 +16,8 @@ pub fn filename(tags: &[Tag], country: Option<&str>, bucket: &StoredBucket) -> S let ip = bucket.subnet.network(); let qty = bucket.proxies.len(); match tag_stem(tags) { - Some(tags) => format!("{tags}_{country}_{ip}_24_{qty}.txt"), - None => format!("{country}_{ip}_24_{qty}.txt"), + Some(tags) => format!("{tags}_{country}_{ip}_24_{qty}"), + None => format!("{country}_{ip}_24_{qty}"), } } @@ -67,34 +71,13 @@ fn write_file(path: &Path, bucket: &StoredBucket) -> Result<(), String> { body.push_str(&proxy.source); body.push('\n'); } - write_mode(path, body.as_bytes()).map_err(|err| io_error(path, err)) + crate::secure_file::write(path, body.as_bytes()).map_err(|err| io_error(path, err)) } fn io_error(path: &Path, err: std::io::Error) -> String { format!("Could not write {} ({})", path.display(), err.kind()) } -fn write_mode(path: &Path, bytes: &[u8]) -> std::io::Result<()> { - #[cfg(unix)] - { - use std::io::Write; - use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; - let mut file = fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(path)?; - file.set_permissions(fs::Permissions::from_mode(0o600))?; - file.write_all(bytes)?; - Ok(()) - } - #[cfg(not(unix))] - { - fs::write(path, bytes) - } -} - #[cfg(test)] mod tests { use super::*; @@ -137,6 +120,16 @@ mod tests { assert_eq!(name, "isp-mobile_FR_51.194.38.0_24_42.txt"); } + #[test] + fn category_is_the_filename_without_the_extension() { + let bucket = bucket("51.194.38.2", 42); + let category = category(&tags(&["isp", "mobile"]), Some("fr"), &bucket); + assert_eq!( + filename(&tags(&["isp", "mobile"]), Some("fr"), &bucket), + format!("{category}.txt") + ); + } + #[test] fn filename_starts_with_country_when_tags_are_missing() { let name = filename(&[], None, &bucket("192.0.2.10", 1)); diff --git a/src-tauri/src/inventory.rs b/src-tauri/src/inventory.rs index 72ce772..074312e 100644 --- a/src-tauri/src/inventory.rs +++ b/src-tauri/src/inventory.rs @@ -192,34 +192,13 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { fs::create_dir_all(parent).map_err(|err| err.to_string())?; } let tmp = path.with_extension("json.tmp"); - write_mode(&tmp, bytes).map_err(|err| err.to_string())?; + crate::secure_file::write(&tmp, bytes).map_err(|err| err.to_string())?; fs::rename(&tmp, path).map_err(|err| { let _ = fs::remove_file(&tmp); err.to_string() }) } -fn write_mode(path: &Path, bytes: &[u8]) -> std::io::Result<()> { - #[cfg(unix)] - { - use std::io::Write; - use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; - let mut file = fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(path)?; - file.set_permissions(fs::Permissions::from_mode(0o600))?; - file.write_all(bytes)?; - Ok(()) - } - #[cfg(not(unix))] - { - fs::write(path, bytes) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 23e17e3..8317e04 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod aycd; mod commands; mod country; mod export; @@ -7,6 +8,7 @@ mod last_target; mod parse; mod probe; mod run; +mod secure_file; mod session; mod split; mod stats; @@ -42,6 +44,7 @@ pub fn run() { commands::last_target, commands::start_run, commands::export_dir, + commands::export_aycd, commands::session_rows, commands::remove_subnet ]) diff --git a/src-tauri/src/secure_file.rs b/src-tauri/src/secure_file.rs new file mode 100644 index 0000000..eb8bdd8 --- /dev/null +++ b/src-tauri/src/secure_file.rs @@ -0,0 +1,22 @@ +use std::fs; +use std::path::Path; + +pub fn write(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.set_permissions(fs::Permissions::from_mode(0o600))?; + file.write_all(bytes) + } + #[cfg(not(unix))] + { + fs::write(path, bytes) + } +} diff --git a/src/lib/ExportMenu.svelte b/src/lib/ExportMenu.svelte new file mode 100644 index 0000000..0965c62 --- /dev/null +++ b/src/lib/ExportMenu.svelte @@ -0,0 +1,35 @@ + + + diff --git a/src/lib/RowMenu.svelte b/src/lib/RowMenu.svelte index 162d15e..6199f50 100644 --- a/src/lib/RowMenu.svelte +++ b/src/lib/RowMenu.svelte @@ -5,6 +5,7 @@ locked, onProbe, onExport, + onExportAycd, onRemove, onDismiss, }: { @@ -13,6 +14,7 @@ locked: boolean; onProbe: () => void; onExport: () => void; + onExportAycd: () => void; onRemove: () => void; onDismiss: () => void; } = $props(); @@ -63,6 +65,15 @@ > Export + - +
+ + + {#if exportMenuOpen} + void exportAycd()} + onDismiss={closeExportMenu} + /> + {/if} +