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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

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

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
182 changes: 182 additions & 0 deletions src-tauri/src/aycd.rs
Original file line number Diff line number Diff line change
@@ -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<usize, String> {
if buckets.is_empty() {
return Err("Import proxies before exporting.".into());
}
let proxies: Vec<Proxy> = 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::Value> = 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);
}
}
34 changes: 26 additions & 8 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -112,17 +113,34 @@ pub fn export_dir(
session: State<'_, SessionStore>,
tags: State<'_, TagStore>,
) -> Result<usize, String> {
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<Vec<String>>,
session: State<'_, SessionStore>,
tags: State<'_, TagStore>,
) -> Result<usize, String> {
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<Vec<String>>,
) -> Result<Vec<StoredBucket>, 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>,
Expand Down
41 changes: 17 additions & 24 deletions src-tauri/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@ 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())
.unwrap_or_else(|| "XX".into());
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}"),
}
}

Expand Down Expand Up @@ -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::*;
Expand Down Expand Up @@ -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));
Expand Down
23 changes: 1 addition & 22 deletions src-tauri/src/inventory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
Loading