____ _ _______ __
/ __ \ | /| / / __/ |/ /
/ /_/ / |/ |/ / _// /
\____/|__/|__/___/_||_/
snolc / src/bin/geoconv.rs
//! `snolc-geoconv` -- converts plain-text geoip/geosite source lists into
//! the flat YAML routing-rule fragments snolc understands (see
//! `snolc::routing`). This is a separate tool, not a `snolc` subcommand:
//! `snolc` itself only ever accepts `run`/`version`/`keygen`.
//!
//! Two source formats are supported, both of which are genuine, commonly
//! published plain-text formats (not sing-box's or Xray's own compiled
//! binary `.srs`/`.dat` databases, which this tool does not parse):
//!
//! - `geosite`: the domain-list-community line format
//! (), which sing-box and
//! Xray themselves compile their binary geosite databases from. Lines
//! look like `domain:example.com`, `full:www.example.com`,
//! `# comment`, or `include:other-list`. `keyword:`/`regexp:`/`include:`
//! entries have no equivalent in snolc's routing schema (exact
//! domain/suffix matching only) and are skipped, not silently
//! mistranslated; the run summarizes how many were skipped.
//! - `geoip`: one CIDR per line (optionally with a trailing `# comment`),
//! the format used by, for example, ipdeny.com's per-country zone files.
//!
//! Output is a sequence of routing-rule YAML items on stdout, meant to be
//! assembled by hand into a complete `routing.yml` alongside a final
//! `- match: any` rule (required by `snolc::routing` and intentionally not
//! added here, since only the user knows whether the default should be
//! `direct` or `proxy`).
//!
//! Usage:
//! ```text
//! snolc-geoconv geosite --action
//! snolc-geoconv geoip --action
//! ```
use std::env;
use std::ffi::OsString;
use std::fs;
use std::process::ExitCode;
fn main() -> ExitCode {
match run(env::args_os().skip(1)) {
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
eprintln!("error: {message}");
eprintln!();
print_usage();
ExitCode::FAILURE
}
}
}
fn print_usage() {
eprintln!("usage:");
eprintln!(" snolc-geoconv geosite --action ");
eprintln!(" snolc-geoconv geoip --action ");
}
fn run(args: impl Iterator- ) -> Result<(), String> {
let args: Vec = args
.map(|arg| {
arg.into_string()
.map_err(|_| "argument is not valid UTF-8".to_owned())
})
.collect::>()?;
let [kind, flag, action, files @ ..] = args.as_slice() else {
return Err("missing arguments".to_owned());
};
if flag != "--action" {
return Err(format!("expected --action, found {flag}"));
}
let action = match action.as_str() {
"direct" => "direct",
"proxy" => "proxy",
other => return Err(format!("unknown action {other}, expected direct or proxy")),
};
if files.is_empty() {
return Err("at least one source file is required".to_owned());
}
match kind.as_str() {
"geosite" => convert_geosite(files, action),
"geoip" => convert_geoip(files, action),
other => Err(format!("unknown kind {other}, expected geosite or geoip")),
}
}
fn convert_geosite(files: &[String], action: &str) -> Result<(), String> {
let mut converted = 0_usize;
let mut skipped = 0_usize;
let mut out = String::new();
for path in files {
let contents = fs::read_to_string(path).map_err(|error| format!("{path}: {error}"))?;
for raw_line in contents.lines() {
let line = strip_comment(raw_line).trim();
if line.is_empty() {
continue;
}
// Attribute tags, e.g. "domain:example.com @cn", are informational
// (usually a category grouping) and do not change the match.
let line = line.split_whitespace().next().unwrap_or(line);
let Some((kind, value)) = line.split_once(':') else {
skipped += 1;
continue;
};
match kind {
"domain" | "full" => {
if let Some(domain) = normalize_domain(value) {
out.push_str(&format!(
"- match: domain\n value: {domain}\n action: {action}\n"
));
converted += 1;
} else {
skipped += 1;
}
}
// No equivalent in snolc's exact/suffix-only domain matcher.
"keyword" | "regexp" | "include" => skipped += 1,
_ => skipped += 1,
}
}
}
print!("{out}");
eprintln!(
"geosite: {converted} rule(s) converted, {skipped} entr(y/ies) skipped (unsupported kind)"
);
Ok(())
}
fn convert_geoip(files: &[String], action: &str) -> Result<(), String> {
let mut converted = 0_usize;
let mut skipped = 0_usize;
let mut out = String::new();
for path in files {
let contents = fs::read_to_string(path).map_err(|error| format!("{path}: {error}"))?;
for raw_line in contents.lines() {
let line = strip_comment(raw_line).trim();
if line.is_empty() {
continue;
}
match line.parse::() {
Ok(network) => {
out.push_str(&format!(
"- match: ip\n value: {network}\n action: {action}\n"
));
converted += 1;
}
Err(_) => skipped += 1,
}
}
}
print!("{out}");
eprintln!("geoip: {converted} rule(s) converted, {skipped} line(s) skipped (not a valid CIDR)");
Ok(())
}
fn strip_comment(line: &str) -> &str {
line.split('#').next().unwrap_or("")
}
/// Lower-cases and strips a trailing dot, matching the normalization
/// `snolc::routing` itself applies; returns `None` for anything that would
/// fail that module's own validation, so invalid entries are skipped rather
/// than passed through to fail later, silently or not, inside snolc.
fn normalize_domain(value: &str) -> Option {
let value = value
.strip_suffix('.')
.unwrap_or(value)
.to_ascii_lowercase();
if value.is_empty() || value.len() > 253 {
return None;
}
for label in value.split('.') {
if label.is_empty()
|| label.len() > 63
|| label.starts_with('-')
|| label.ends_with('-')
|| !label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return None;
}
}
Some(value)
}
#[cfg(test)]
mod tests {
use std::io::Write;
use super::*;
fn temp_file(contents: &str) -> (tempfile::TempDir, String) {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("source.txt");
let mut file = fs::File::create(&path).unwrap();
file.write_all(contents.as_bytes()).unwrap();
(directory, path.to_string_lossy().into_owned())
}
#[test]
fn geosite_converts_domain_and_full_and_skips_unsupported_kinds() {
let (_directory, path) = temp_file(
"# a comment\n\
domain:Example.RU.\n\
full:exact.example.com @cn\n\
keyword:blocked\n\
regexp:^ads\\.\n\
include:other-list\n\
malformed-line-without-colon\n\
\n",
);
let args = vec![
"geosite".to_owned(),
"--action".to_owned(),
"direct".to_owned(),
path,
];
run(args.into_iter().map(OsString::from)).unwrap();
}
#[test]
fn geoip_converts_valid_cidrs_and_skips_invalid_lines() {
let (_directory, path) = temp_file(
"10.0.0.0/8\n\
not-a-cidr\n\
2001:db8::/32 # example documentation range\n\
\n",
);
let args = vec![
"geoip".to_owned(),
"--action".to_owned(),
"proxy".to_owned(),
path,
];
run(args.into_iter().map(OsString::from)).unwrap();
}
#[test]
fn normalize_domain_matches_the_routing_module_rules() {
assert_eq!(
normalize_domain("Example.RU."),
Some("example.ru".to_owned())
);
assert_eq!(normalize_domain(""), None);
assert_eq!(normalize_domain("-bad.example.com"), None);
}
#[test]
fn rejects_unknown_action_and_kind() {
let args = vec![
"geosite".to_owned(),
"--action".to_owned(),
"maybe".to_owned(),
"file.txt".to_owned(),
];
assert!(run(args.into_iter().map(OsString::from)).is_err());
let args = vec![
"bogus".to_owned(),
"--action".to_owned(),
"direct".to_owned(),
"file.txt".to_owned(),
];
assert!(run(args.into_iter().map(OsString::from)).is_err());
}
}