From 6bc767de6a1adf8de790497a36f1aab63002b1ea Mon Sep 17 00:00:00 2001 From: h7x4 Date: Mon, 25 May 2026 23:32:45 +0900 Subject: [PATCH] WIP: temmie/userweb: add log processor for apache --- .../userweb/apache-log-processor/.gitignore | 1 + .../userweb/apache-log-processor/Cargo.lock | 171 ++++++++++ .../userweb/apache-log-processor/Cargo.toml | 19 ++ .../userweb/apache-log-processor/default.nix | 33 ++ .../userweb/apache-log-processor/src/main.rs | 322 ++++++++++++++++++ hosts/temmie/services/userweb/default.nix | 10 +- 6 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 hosts/temmie/services/userweb/apache-log-processor/.gitignore create mode 100644 hosts/temmie/services/userweb/apache-log-processor/Cargo.lock create mode 100644 hosts/temmie/services/userweb/apache-log-processor/Cargo.toml create mode 100644 hosts/temmie/services/userweb/apache-log-processor/default.nix create mode 100644 hosts/temmie/services/userweb/apache-log-processor/src/main.rs diff --git a/hosts/temmie/services/userweb/apache-log-processor/.gitignore b/hosts/temmie/services/userweb/apache-log-processor/.gitignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/hosts/temmie/services/userweb/apache-log-processor/.gitignore @@ -0,0 +1 @@ +target diff --git a/hosts/temmie/services/userweb/apache-log-processor/Cargo.lock b/hosts/temmie/services/userweb/apache-log-processor/Cargo.lock new file mode 100644 index 0000000..a642066 --- /dev/null +++ b/hosts/temmie/services/userweb/apache-log-processor/Cargo.lock @@ -0,0 +1,171 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "apache-log-processor" +version = "0.1.0" +dependencies = [ + "nix", + "time", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/hosts/temmie/services/userweb/apache-log-processor/Cargo.toml b/hosts/temmie/services/userweb/apache-log-processor/Cargo.toml new file mode 100644 index 0000000..b305310 --- /dev/null +++ b/hosts/temmie/services/userweb/apache-log-processor/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "apache-log-processor" +version = "0.1.0" +edition = "2024" +autobins = false +license = "MIT" +authors = [ + "projects@pvv.ntnu.no", +] + +[dependencies] +nix = { version = "0.31.3", features = ["event", "fs", "user"] } +time = { version = "0.3.47", features = ["formatting", "local-offset"] } + + +[[bin]] +name = "apache-log-processor" +bench = false +path = "src/main.rs" diff --git a/hosts/temmie/services/userweb/apache-log-processor/default.nix b/hosts/temmie/services/userweb/apache-log-processor/default.nix new file mode 100644 index 0000000..48dbb1e --- /dev/null +++ b/hosts/temmie/services/userweb/apache-log-processor/default.nix @@ -0,0 +1,33 @@ +{ + lib +, rustPlatform +, stdenv +}: +let + cargoToml = fromTOML (builtins.readFile ./Cargo.toml); + cargoLock = ./Cargo.lock; + mainProgram = (lib.head cargoToml.bin).name; + pname = cargoToml.package.name; +in +rustPlatform.buildRustPackage { + inherit pname; + inherit (cargoToml.package) version; + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./Cargo.toml + ./Cargo.lock + ./src + ]; + }; + + cargoLock.lockFile = cargoLock; + + doCheck = true; + + meta = with lib; { + license = licenses.mit; + platforms = platforms.linux; + inherit mainProgram; + }; +} diff --git a/hosts/temmie/services/userweb/apache-log-processor/src/main.rs b/hosts/temmie/services/userweb/apache-log-processor/src/main.rs new file mode 100644 index 0000000..dba8719 --- /dev/null +++ b/hosts/temmie/services/userweb/apache-log-processor/src/main.rs @@ -0,0 +1,322 @@ +use nix::{ + errno::Errno, + fcntl::{FcntlArg, OFlag, fcntl, open}, + sys::{ + epoll::{Epoll, EpollCreateFlags, EpollEvent, EpollFlags, EpollTimeout}, + stat::Mode, + }, + // unistd::{User, getegid, geteuid, read, setegid, seteuid, write}, + unistd::{User, read, write}, +}; +use std::{ + collections::VecDeque, + os::fd::{AsFd, BorrowedFd, OwnedFd}, + path::PathBuf, + process::exit, +}; +use time::{OffsetDateTime, format_description}; + +const READ_BUFFER_SIZE: usize = 8 * 1024; + +#[derive(Debug, Clone, Copy)] +enum LogMode { + Access, + Error, +} + +fn main() -> Result<(), String> { + let log_mode = match std::env::args().nth(1).as_deref() { + Some("access") => LogMode::Access, + Some("error") => LogMode::Error, + Some(other) => { + return Err(format!( + "invalid log mode `{other}`; expected `access` or `error`" + )); + } + None => return Err("missing log mode argument; expected `access` or `error`".to_string()), + }; + + let tee_file = match log_mode { + LogMode::Access => None, + LogMode::Error => Some( + open( + &PathBuf::from("/var/log/httpd/error.log"), + OFlag::O_WRONLY | OFlag::O_APPEND | OFlag::O_CREAT | OFlag::O_CLOEXEC, + Mode::S_IRUSR | Mode::S_IWUSR, + ) + .map_err(|error| format!("failed to open error log for teeing: {error}"))?, + ), + }; + + let stdin = std::io::stdin(); + + fcntl(stdin.as_fd(), FcntlArg::F_GETFL) + .map(OFlag::from_bits_retain) + .map(|flags| FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK)) + .and_then(|flags| fcntl(stdin.as_fd(), flags)) + .map_err(|error| format!("failed to make stdin nonblocking: {error}"))?; + + let epoll = Epoll::new(EpollCreateFlags::EPOLL_CLOEXEC) + .map_err(|error| format!("failed to create epoll instance: {error}"))?; + + epoll + .add( + stdin.as_fd(), + EpollEvent::new( + EpollFlags::EPOLLIN | EpollFlags::EPOLLERR | EpollFlags::EPOLLHUP, + 0, + ), + ) + .map_err(|error| format!("failed to register stdin with epoll: {error}"))?; + + if let Err(error) = event_loop(log_mode, epoll, stdin.as_fd(), tee_file) { + eprintln!("Error: {error}"); + exit(1); + } + + Ok(()) +} + +fn event_loop( + log_mode: LogMode, + epoll: Epoll, + stdin_fd: BorrowedFd<'_>, + mut tee_file: Option, +) -> Result<(), String> { + let mut events = [EpollEvent::empty(); 1]; + let mut pending = VecDeque::new(); + + loop { + let ready = loop { + match epoll.wait(&mut events, EpollTimeout::NONE) { + Ok(ready) => break ready, + Err(Errno::EINTR) => continue, + Err(error) => { + return Err(format!("epoll wait failed: {error}")); + } + } + }; + + if ready == 0 { + continue; + } + + let mut scratch = [0u8; READ_BUFFER_SIZE]; + + let eof = loop { + match read(stdin_fd, &mut scratch) { + Ok(0) => break true, + Ok(read_bytes) => pending.extend(scratch[..read_bytes].iter().copied()), + Err(Errno::EINTR) => continue, + Err(Errno::EAGAIN) => break false, + Err(error) => { + return Err(format!("failed to read from stdin: {error}")); + } + } + }; + + while let Some(newline_index) = pending.iter().position(|byte| *byte == b'\n') { + let line = pending.make_contiguous(); + process_line(log_mode, &line[..=newline_index], &mut tee_file)?; + pending.drain(..=newline_index); + } + + if eof { + if !pending.is_empty() { + process_line(log_mode, pending.make_contiguous(), &mut tee_file)?; + pending.clear(); + } + return Ok(()); + } + } +} + +fn process_line( + log_mode: LogMode, + line: &[u8], + tee_file: &mut Option, +) -> Result<(), String> { + if let Some(tee_file) = tee_file.as_ref() { + write_all_fd(tee_file, line).map_err(|error| { + format!("failed to append to APACHE_LOG_PROCESSOR_TEE_FILE: {error}") + })?; + } + + if let Some(user) = + parse_username_from_line(line).and_then(|name| User::from_name(name).ok().flatten()) + { + // let identity = EffectiveIdentity::switch_to(&user).map_err(|error| { + // format!( + // "failed to switch effective identity to {} (uid {}, gid {}): {error}", + // user.name, user.uid, user.gid + // ) + // })?; + + let result: Result<(), String> = (|| { + let dir = user.dir.join("nobackup/weblogs"); + + if !dir.is_dir() { + return Err(format!( + "logs directory {} does not exist for user {}", + dir.display(), + user.name + )); + } + + let now = OffsetDateTime::now_local() + .unwrap_or_else(|_| OffsetDateTime::now_utc()) + .format(&format_description::parse("[year]-[month]-[day]").unwrap()) + .map_err(|error| { + format!("failed to format current date for log file name: {error}") + })?; + + let logfile = dir.join(match log_mode { + LogMode::Access => format!("access-{now}.log"), + LogMode::Error => format!("error-{now}.log"), + }); + + let fd = open( + &logfile, + OFlag::O_WRONLY | OFlag::O_APPEND | OFlag::O_CREAT | OFlag::O_CLOEXEC, + Mode::S_IRUSR + | Mode::S_IWUSR + | Mode::S_IRGRP + | Mode::S_IROTH + | Mode::S_IWGRP + | Mode::S_IWOTH, + ) + .map_err(|error| format!("failed to open log file for user {}: {error}", user.name))?; + + write_all_fd(fd.as_fd(), line).map_err(|error| { + format!( + "failed to append to log file for user {}: {error}", + user.name + ) + })?; + + Ok(()) + })(); + + if let Err(error) = result { + eprintln!("Error processing log line for user {}: {error}", user.name); + } + + // identity.restore().map_err(|error| { + // format!( + // "failed to restore original effective identity after handling {}: {error}", + // user.name + // ) + // })?; + } + + Ok(()) +} + +fn parse_username_from_line(line: &[u8]) -> Option<&str> { + line.splitn(8, |&b| b == b' ') + .nth(6) + .and_then(|path| { + path.strip_prefix(b"/~") + .and_then(|rest| rest.split(|&b| b == b'/').next()) + }) + .or_else(|| { + line.windows(b"/home/pvv/".len()) + .enumerate() + .find_map(|(start, window)| { + (window == b"/home/pvv/") + .then_some(start + b"/home/pvv/".len()) + .and_then(|start| line.get(start..)) + .filter(|rest| rest.get(1) == Some(&b'/')) + .and_then(|rest| rest.get(2..)) + .and_then(|rest| rest.split(|&b| b == b'/').next()) + }) + }) + .filter(|segment| !segment.is_empty()) + .and_then(|segment| std::str::from_utf8(segment).ok()) +} + +fn write_all_fd(fd: Fd, mut buffer: &[u8]) -> nix::Result<()> { + while !buffer.is_empty() { + match write(fd.as_fd(), buffer) { + Ok(0) => return Err(Errno::EIO), + Ok(written) => buffer = &buffer[written..], + Err(Errno::EINTR) => continue, + Err(error) => return Err(error), + } + } + + Ok(()) +} + +// struct EffectiveIdentity { +// saved_euid: nix::unistd::Uid, +// saved_egid: nix::unistd::Gid, +// restored: bool, +// } + +// impl EffectiveIdentity { +// fn switch_to(user: &User) -> nix::Result { +// let guard = Self { +// saved_euid: geteuid(), +// saved_egid: getegid(), +// restored: false, +// }; + +// setegid(user.gid)?; +// if let Err(error) = seteuid(user.uid) { +// let _ = setegid(guard.saved_egid); +// return Err(error); +// } + +// Ok(guard) +// } + +// fn restore(mut self) -> nix::Result<()> { +// let restore_uid = seteuid(self.saved_euid); +// let restore_gid = setegid(self.saved_egid); +// self.restored = true; + +// restore_uid?; +// restore_gid?; +// Ok(()) +// } +// } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_user_from_access_log() { + let inputs = [( + "1.2.3.4 - - [25/May/2026:10:07:24 +0200] \"GET /~oysteikt/ HTTP/2.0\" 200 3708", + "oysteikt", + )]; + + for (line, expected_user) in inputs { + let parsed_user = parse_username_from_line(line.as_bytes()); + assert_eq!( + parsed_user, + Some(expected_user), + "Failed to parse user from line: {line}" + ); + } + } + + #[test] + fn test_parse_user_from_error_log() { + let inputs = [( + "[Sat May 09 20:45:21.480016 2026] [authz_core:error] [pid 3555:tid 3617] [remote 1::2:42000] AH01630: client denied by server configuration: /home/pvv/d/oysteikt/web-docs/.git", + "oysteikt", + )]; + + for (line, expected_user) in inputs { + let parsed_user = parse_username_from_line(line.as_bytes()); + assert_eq!( + parsed_user, + Some(expected_user), + "Failed to parse user from line: {line}" + ); + } + } +} diff --git a/hosts/temmie/services/userweb/default.nix b/hosts/temmie/services/userweb/default.nix index 7885f36..39243b8 100644 --- a/hosts/temmie/services/userweb/default.nix +++ b/hosts/temmie/services/userweb/default.nix @@ -11,6 +11,8 @@ let upload_max_filesize = "40M"; }); + apache-log-processor = pkgs.callPackage ./apache-log-processor { }; + # https://nixos.org/manual/nixpkgs/stable/#ssec-php-user-guide-installing-with-extensions phpEnv = pkgs.php.buildEnv { extensions = { all, ... }: with all; [ @@ -193,10 +195,11 @@ in } ]; + logPerVirtualHost = false; + extraConfig = '' TraceEnable on LogLevel warn rewrite:trace3 - ScriptLog ${cfg.logDir}/cgi.log ''; virtualHosts."temmie.pvv.ntnu.no" = { @@ -208,6 +211,11 @@ in ]; extraConfig = '' + CustomLog "${cfg.logDir}/access.log" combined + CustomLog "|${lib.getExe apache-log-processor} access" combined + ErrorLog "|${lib.getExe apache-log-processor} error" + ScriptLog "${cfg.logDir}/cgi.log" + UserDir ${lib.concatMapStringsSep " " (l: "/home/pvv/${l}/*/web-docs") homeLetters} UserDir disabled root AddHandler cgi-script .cgi