diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ada8a24 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://yggverse.github.io/#donate \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..85edd8b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,27 @@ +name: Build + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Run rustfmt + run: cargo fmt --all -- --check + - name: Run clippy + run: cargo clippy --all-targets + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..869df07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1c57078 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "htcount" +version = "0.1.0" +edition = "2024" +license = "MIT" +readme = "README.md" +description = "Simple CLI/daemon tool for counting visitors using access.log and exporting totals in multiple formats, such as JSON or SVG badge" +keywords = ["access", "log", "counter", "badge", "stats"] +categories = ["network-programming"] +repository = "https://github.com/YGGverse/htcount" +# homepage = "https://yggverse.github.io" + +[dependencies] +anyhow = "1.0" +clap = { version = "4.5", features = ["derive"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..d70c8d4 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# htcount + +![Build](https://github.com/YGGverse/htcount/actions/workflows/build.yml/badge.svg) +[![Dependencies](https://deps.rs/repo/github/YGGverse/htcount/status.svg)](https://deps.rs/repo/github/YGGverse/htcount) +[![crates.io](https://img.shields.io/crates/v/htcount.svg)](https://crates.io/crates/htcount) + +Simple CLI/daemon tool for counting visitors using `access.log` and exporting totals in multiple formats, such as JSON or SVG badge + +## Features + +### Log format support + +* [x] Nginx +* [ ] Apache + +### Export formats + +* [x] JSON - for API usage +* [x] SVG - configurable badge button + +## Install + +1. `git clone https://github.com/YGGverse/htcount.git && cd htcount` +2. `cargo build --release` +3. `sudo install target/release/htcount /usr/local/bin/htcount` + +## Usage + +``` bash +htcount --source /var/log/nginx/access.log\ + --export-json /path/to/totals.json\ + --export-svg /path/to/totals.svg +``` + +### Options + +``` bash +-d, --debug + Debug level + + * `i` - info * `d` - detailed + + [default: i] + +-f, --format + Log format for given `source` + + * `nginx` + + [default: nginx] + + --export-json + Export results to JSON file (e.g. `/path/to/stats.json`) + + --export-svg + Export results to SVG file (e.g. `/path/to/badge.svg`) + + * use `{hits}` / `{hosts}` pattern to replace parsed values + + --template-svg + Use custom SVG file template with `{hits}` / `{hosts}` placeholders + + [default: default/counter.svg] + +-c, --capacity + Expected memory index capacity + + [default: 100] + +-i, --ignore-host + Exclude host(s) from index + +-s, --source + Access log source (e.g. `/var/nginx/access.log`) + +-u, --update + Update delay in seconds + + [default: 300] + +-h, --help + Print help (see a summary with '-h') + +-V, --version + Print version +``` \ No newline at end of file diff --git a/default/counter.svg b/default/counter.svg new file mode 100644 index 0000000..7ef5993 --- /dev/null +++ b/default/counter.svg @@ -0,0 +1,8 @@ + + + hosts: {hosts} + + + hits: {hits} + + \ No newline at end of file diff --git a/src/argument.rs b/src/argument.rs new file mode 100644 index 0000000..bea400c --- /dev/null +++ b/src/argument.rs @@ -0,0 +1,48 @@ +use clap::Parser; + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +pub struct Argument { + /// Debug level + /// + /// * `i` - info + /// * `d` - detailed + #[arg(short, long, default_value_t = String::from("i"))] + pub debug: String, + + /// Log format for given `source` + /// + /// * `nginx` + #[arg(short, long, default_value_t = String::from("nginx"))] + pub format: String, + + /// Export results to JSON file (e.g. `/path/to/stats.json`) + #[arg(long)] + pub export_json: Option, + + /// Export results to SVG file (e.g. `/path/to/badge.svg`) + /// + /// * use `{hits}` / `{hosts}` pattern to replace parsed values + #[arg(long)] + pub export_svg: Option, + + /// Use custom SVG file template with `{hits}` / `{hosts}` placeholders + #[arg(long, default_value_t = String::from("default/counter.svg"))] + pub template_svg: String, + + /// Expected memory index capacity + #[arg(short, long, default_value_t = 100)] + pub capacity: usize, + + /// Exclude host(s) from index + #[arg(short, long)] + pub ignore_host: Vec, + + /// Access log source (e.g. `/var/nginx/access.log`) + #[arg(short, long)] + pub source: String, + + /// Update delay in seconds + #[arg(short, long, default_value_t = 300)] + pub update: u64, +} diff --git a/src/debug.rs b/src/debug.rs new file mode 100644 index 0000000..69cf562 --- /dev/null +++ b/src/debug.rs @@ -0,0 +1,10 @@ +pub fn info(message: String) { + println!("[{}] [info] {message}", now()) +} + +fn now() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e27ec62 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,94 @@ +mod argument; +mod debug; + +fn main() -> anyhow::Result<()> { + use std::{ + collections::HashMap, + fs::File, + io::{BufRead, BufReader, Write}, + thread, + time::Duration, + }; + + let argument = { + use clap::Parser; + argument::Argument::parse() + }; + + // calculate debug level once + let is_debug_i = argument.debug.contains("i"); + let is_debug_d = argument.debug.contains("d"); + + if !matches!(argument.format.to_lowercase().as_str(), "nginx") { + todo!("Format `{}` yet not supported!", argument.format) + } + + if is_debug_i { + debug::info("Crawler started".into()); + } + + loop { + if is_debug_i { + debug::info("Index queue begin...".into()); + } + + let file = File::open(&argument.source)?; + let reader = BufReader::new(file); + + let mut index: HashMap = HashMap::with_capacity(argument.capacity); + + for line in reader.lines() { + let host = line? + .split_whitespace() + .next() + .map(|s| s.into()) + .unwrap_or_default(); + + if argument.ignore_host.contains(&host) { + if is_debug_d { + debug::info(format!("Host `{host}` ignored by settings")) + } + continue; + } + index.entry(host).and_modify(|c| *c += 1).or_insert(1); + } + + let hosts = index.len(); + let hits: usize = index.values().sum(); + + if is_debug_i { + debug::info(format!( + "Index queue completed:\n{}\n\thosts: {} / hits: {}, await {} seconds to continue...", + if is_debug_d { + let mut b = Vec::with_capacity(hosts); + for (host, count) in &index { + b.push(format!("\t{} ({})", host, count)) + } + b.join("\n") + } else { + "".into() + }, + hosts, + hits, + argument.update, + )); + } + + if let Some(ref p) = argument.export_json { + let mut f = File::create(p)?; + f.write_all(format!("{{\"hosts\":{hosts},\"hits\":{hits}}}").as_bytes())?; + } + + if let Some(ref p) = argument.export_svg { + let t = std::fs::read_to_string(&argument.template_svg)?; + let mut f = File::create(p)?; + f.write_all( + t.replace("{hosts}", &hosts.to_string()) + .replace("{hits}", &hits.to_string()) + .as_bytes(), + )?; + } + + thread::sleep(Duration::from_secs(argument.update)); + } +}