git init
Some checks failed
CI / containers (push) Has been cancelled
CI / agent (push) Has been cancelled
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled

This commit is contained in:
2026-09-19 19:31:36 +03:00
commit c4dc19cd41
78 changed files with 13045 additions and 0 deletions

501
agent/src/api.rs Normal file
View File

@@ -0,0 +1,501 @@
use crate::{
config::Config,
fail2ban::{self, BanRequest},
firewall, logs, network, services, system, wireguard,
};
use anyhow::{Context, Result};
use axum::{
body::Body,
extract::{DefaultBodyLimit, Path, Query, State},
http::{header::AUTHORIZATION, Method, Request, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
use std::{
fs,
path::Path as FilePath,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use subtle::ConstantTimeEq;
use tokio::sync::Semaphore;
pub struct AppState {
pub config: Config,
token: Vec<u8>,
read_limiter: Mutex<TokenBucket>,
write_limiter: Mutex<TokenBucket>,
last_auth_warning: Mutex<Instant>,
concurrency: Arc<Semaphore>,
cpu: Mutex<Option<system::CpuTimes>>,
}
impl AppState {
pub fn load(config: Config) -> Result<Arc<Self>> {
let token_path = FilePath::new(&config.auth.token_file);
let token = fs::read(token_path)
.with_context(|| format!("read token file {}", token_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(token_path)?.permissions().mode();
if mode & 0o077 != 0 {
anyhow::bail!("token file must not be accessible by group or others")
}
}
let token = trim_ascii(&token).to_vec();
if token.len() < 32 {
anyhow::bail!("agent token must contain at least 32 bytes")
}
Ok(Self::new(config, token))
}
fn new(config: Config, token: Vec<u8>) -> Arc<Self> {
Arc::new(Self {
read_limiter: Mutex::new(TokenBucket::new(
config.limits.read_requests_per_second,
config.limits.burst,
)),
write_limiter: Mutex::new(TokenBucket::new(
config.limits.write_requests_per_second,
config.limits.burst,
)),
last_auth_warning: Mutex::new(Instant::now() - Duration::from_secs(60)),
concurrency: Arc::new(Semaphore::new(config.limits.max_concurrent_requests)),
config,
token,
cpu: Mutex::new(None),
})
}
}
struct TokenBucket {
rate: f64,
capacity: f64,
tokens: f64,
updated_at: Instant,
}
impl TokenBucket {
fn new(rate: f64, capacity: f64) -> Self {
Self {
rate,
capacity,
tokens: capacity,
updated_at: Instant::now(),
}
}
fn allow(&mut self, now: Instant) -> bool {
let elapsed = now.duration_since(self.updated_at).as_secs_f64();
self.tokens = (self.tokens + elapsed * self.rate).min(self.capacity);
self.updated_at = now;
if self.tokens >= 1.0 {
self.tokens -= 1.0;
true
} else {
false
}
}
}
pub fn router(state: Arc<AppState>) -> Router {
let max_body = state.config.limits.max_body_bytes;
Router::new()
.route("/healthz", get(health))
.route("/v1/version", get(version))
.route("/v1/system/status", get(system_status))
.route("/v1/network/interfaces", get(network_interfaces))
.route("/v1/network/routes", get(network_routes))
.route("/v1/network/listeners", get(network_listeners))
.route("/v1/network/connections", get(network_connections))
.route("/v1/firewall/rules", get(firewall_rules))
.route("/v1/fail2ban/status", get(fail2ban_status))
.route("/v1/fail2ban/jails/:jail", get(fail2ban_jail))
.route("/v1/fail2ban/ban", post(fail2ban_ban))
.route("/v1/fail2ban/unban", post(fail2ban_unban))
.route("/v1/wireguard/status", get(wireguard_status))
.route("/v1/services", get(service_list))
.route("/v1/services/:name/restart", post(service_restart))
.route("/v1/logs", get(log_entries))
.layer(DefaultBodyLimit::max(max_body))
.layer(middleware::from_fn_with_state(
state.clone(),
security_middleware,
))
.with_state(state)
}
async fn security_middleware(
State(state): State<Arc<AppState>>,
request: Request<Body>,
next: Next,
) -> Response {
let is_health = request.uri().path() == "/healthz";
let header_bytes: usize = request
.headers()
.iter()
.map(|(name, value)| name.as_str().len().saturating_add(value.as_bytes().len()))
.sum();
if header_bytes > 16 * 1024 {
return error_response(
StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE,
"request headers are too large",
"headers_too_large",
);
}
let is_read = matches!(*request.method(), Method::GET | Method::HEAD);
let allowed = if is_read {
state
.read_limiter
.lock()
.expect("rate mutex poisoned")
.allow(Instant::now())
} else {
state
.write_limiter
.lock()
.expect("rate mutex poisoned")
.allow(Instant::now())
};
if !allowed {
return error_response(
StatusCode::TOO_MANY_REQUESTS,
"request rate exceeded",
"rate_limited",
);
}
if is_health {
return next.run(request).await;
}
let authenticated = request
.headers()
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.is_some_and(|provided| constant_time_eq(provided.as_bytes(), &state.token));
if !authenticated {
let mut last_warning = state
.last_auth_warning
.lock()
.expect("authentication warning mutex poisoned");
if last_warning.elapsed() >= Duration::from_secs(10) {
tracing::warn!("agent authentication failed (repeated failures are coalesced)");
*last_warning = Instant::now();
}
return error_response(
StatusCode::UNAUTHORIZED,
"authentication required",
"unauthorized",
);
}
let Ok(_permit) = state.concurrency.clone().try_acquire_owned() else {
return error_response(
StatusCode::SERVICE_UNAVAILABLE,
"request concurrency exceeded",
"busy",
);
};
match tokio::time::timeout(
Duration::from_secs(state.config.limits.request_timeout_seconds),
next.run(request),
)
.await
{
Ok(response) => response,
Err(_) => error_response(StatusCode::GATEWAY_TIMEOUT, "request timed out", "timeout"),
}
}
fn constant_time_eq(provided: &[u8], expected: &[u8]) -> bool {
provided.len() == expected.len() && bool::from(provided.ct_eq(expected))
}
fn trim_ascii(mut value: &[u8]) -> &[u8] {
while value.first().is_some_and(u8::is_ascii_whitespace) {
value = &value[1..];
}
while value.last().is_some_and(u8::is_ascii_whitespace) {
value = &value[..value.len() - 1];
}
value
}
async fn health() -> Json<Value> {
Json(json!({"status": "ok"}))
}
async fn version() -> Json<Value> {
Json(json!({"version": env!("CARGO_PKG_VERSION"), "api_version": "v1"}))
}
async fn system_status(
State(state): State<Arc<AppState>>,
) -> Result<Json<system::Status>, ApiError> {
let state_for_work = state.clone();
let status = tokio::task::spawn_blocking(move || system::collect(&state_for_work.cpu))
.await
.map_err(|_| ApiError::internal())??;
Ok(Json(status))
}
async fn network_interfaces(State(state): State<Arc<AppState>>) -> Json<Vec<network::Interface>> {
Json(network::interfaces(command_timeout(&state)).await)
}
async fn network_routes(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
Ok(Json(network::routes(command_timeout(&state)).await?))
}
async fn network_listeners(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<network::SocketInfo>>, ApiError> {
let include_process = state.config.telemetry.process_info_enabled;
let rows = tokio::task::spawn_blocking(move || network::sockets(include_process))
.await
.map_err(|_| ApiError::internal())??;
Ok(Json(network::listeners(rows)))
}
async fn network_connections(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<network::SocketInfo>>, ApiError> {
if !state.config.telemetry.connections_enabled {
return Err(ApiError::new(
StatusCode::FORBIDDEN,
"connection telemetry disabled",
"disabled",
));
}
let include_process = state.config.telemetry.process_info_enabled;
let rows = tokio::task::spawn_blocking(move || network::sockets(include_process))
.await
.map_err(|_| ApiError::internal())??;
Ok(Json(
rows.into_iter()
.filter(|row| row.state != "LISTEN")
.collect(),
))
}
async fn firewall_rules(State(state): State<Arc<AppState>>) -> Json<firewall::FirewallStatus> {
Json(firewall::status(command_timeout(&state)).await)
}
async fn fail2ban_status(State(state): State<Arc<AppState>>) -> Json<fail2ban::Status> {
Json(fail2ban::status(command_timeout(&state)).await)
}
async fn fail2ban_jail(
State(state): State<Arc<AppState>>,
Path(jail): Path<String>,
) -> Result<Json<fail2ban::JailStatus>, ApiError> {
fail2ban::validate_jail(&jail).map_err(|_| ApiError::bad_request("invalid jail name"))?;
Ok(Json(fail2ban::jail(&jail, command_timeout(&state)).await?))
}
async fn fail2ban_ban(
State(state): State<Arc<AppState>>,
Json(request): Json<BanRequest>,
) -> Result<Json<Value>, ApiError> {
mutate_ban(&state, request, false).await
}
async fn fail2ban_unban(
State(state): State<Arc<AppState>>,
Json(request): Json<BanRequest>,
) -> Result<Json<Value>, ApiError> {
mutate_ban(&state, request, true).await
}
async fn mutate_ban(
state: &Arc<AppState>,
request: BanRequest,
unban: bool,
) -> Result<Json<Value>, ApiError> {
fail2ban::validate_jail(&request.jail)
.map_err(|_| ApiError::bad_request("invalid jail name"))?;
request
.ip
.parse::<std::net::IpAddr>()
.map_err(|_| ApiError::bad_request("invalid IP address"))?;
fail2ban::ban(&request, unban, command_timeout(state)).await?;
let action = if unban { "unban" } else { "ban" };
tracing::info!(
action,
jail = request.jail,
ip = request.ip,
"privileged Fail2Ban action completed"
);
Ok(Json(json!({"status": "ok"})))
}
async fn wireguard_status(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<wireguard::Interface>>, ApiError> {
Ok(Json(wireguard::status(command_timeout(&state)).await?))
}
async fn service_list(State(state): State<Arc<AppState>>) -> Json<Vec<services::ServiceStatus>> {
Json(services::list(&state.config.services.allowed, command_timeout(&state)).await)
}
async fn service_restart(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> Result<Json<Value>, ApiError> {
if !state
.config
.services
.allowed
.iter()
.any(|item| item == &name)
{
return Err(ApiError::new(
StatusCode::FORBIDDEN,
"service is not allow-listed",
"not_allowed",
));
}
services::restart(
&name,
&state.config.services.allowed,
command_timeout(&state),
)
.await?;
tracing::info!(service = name, "allow-listed service restarted");
Ok(Json(json!({"status": "ok"})))
}
async fn log_entries(
State(state): State<Arc<AppState>>,
Query(query): Query<logs::LogsQuery>,
) -> Result<Json<logs::LogsResponse>, ApiError> {
if !state.config.logs.enabled {
return Err(ApiError::new(
StatusCode::FORBIDDEN,
"log collection is disabled",
"disabled",
));
}
Ok(Json(
logs::collect(&state.config.logs, query, command_timeout(&state)).await?,
))
}
fn command_timeout(state: &AppState) -> Duration {
system::command_timeout(state.config.limits.command_timeout_seconds)
}
struct ApiError {
status: StatusCode,
message: &'static str,
code: &'static str,
}
impl ApiError {
fn new(status: StatusCode, message: &'static str, code: &'static str) -> Self {
Self {
status,
message,
code,
}
}
fn bad_request(message: &'static str) -> Self {
Self::new(StatusCode::BAD_REQUEST, message, "invalid_request")
}
fn internal() -> Self {
Self::new(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"internal_error",
)
}
}
impl From<anyhow::Error> for ApiError {
fn from(error: anyhow::Error) -> Self {
tracing::error!(error = %error, "agent operation failed");
Self::new(
StatusCode::BAD_GATEWAY,
"host operation failed",
"host_error",
)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
error_response(self.status, self.message, self.code)
}
}
fn error_response(status: StatusCode, message: &str, code: &str) -> Response {
(status, Json(json!({"error": message, "code": code}))).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::Request;
use tower::ServiceExt;
#[test]
fn token_comparison_is_exact() {
assert!(constant_time_eq(
b"abcdefghijklmnopqrstuvwxyz123456",
b"abcdefghijklmnopqrstuvwxyz123456"
));
assert!(!constant_time_eq(
b"wrong",
b"abcdefghijklmnopqrstuvwxyz123456"
));
}
#[test]
fn rate_limiter_refills_and_bounds() {
let start = Instant::now();
let mut limiter = TokenBucket::new(1.0, 2.0);
assert!(limiter.allow(start));
assert!(limiter.allow(start));
assert!(!limiter.allow(start));
assert!(limiter.allow(start + Duration::from_secs(1)));
}
#[tokio::test]
async fn health_is_public_but_api_is_not() {
let state = AppState::new(
Config::default(),
b"abcdefghijklmnopqrstuvwxyz123456".to_vec(),
);
let app = router(state);
let health = app
.clone()
.oneshot(
Request::builder()
.uri("/healthz")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(health.status(), StatusCode::OK);
let denied = app
.oneshot(
Request::builder()
.uri("/v1/version")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(denied.status(), StatusCode::UNAUTHORIZED);
}
}

109
agent/src/command.rs Normal file
View File

@@ -0,0 +1,109 @@
use anyhow::{bail, Context, Result};
use std::{process::Stdio, time::Duration};
use tokio::{
io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, BufReader},
process::Command,
time::timeout,
};
const MAX_STDOUT_BYTES: u64 = 2 * 1024 * 1024;
const MAX_STDERR_BYTES: u64 = 64 * 1024;
#[derive(Debug)]
pub struct Output {
pub stdout: String,
}
pub async fn run(program: &str, args: &[&str], timeout_duration: Duration) -> Result<Output> {
let mut command = Command::new(program);
command
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = command
.spawn()
.with_context(|| format!("execute {program}"))?;
let stdout = child.stdout.take().context("capture command stdout")?;
let stderr = child.stderr.take().context("capture command stderr")?;
let (status, stdout, stderr) = timeout(timeout_duration, async move {
let stdout_reader = read_bounded(stdout, MAX_STDOUT_BYTES);
let stderr_reader = read_bounded(stderr, MAX_STDERR_BYTES);
let (status, stdout, stderr) = tokio::join!(child.wait(), stdout_reader, stderr_reader);
Ok::<_, anyhow::Error>((status?, stdout?, stderr?))
})
.await
.context("command timed out")??;
let stdout = String::from_utf8_lossy(&stdout).into_owned();
let stderr = String::from_utf8_lossy(&stderr).into_owned();
if !status.success() {
bail!("{program} failed: {}", stderr.trim())
}
Ok(Output { stdout })
}
pub async fn run_lines(
program: &str,
args: &[&str],
timeout_duration: Duration,
max_lines: usize,
) -> Result<Vec<String>> {
let mut command = Command::new(program);
command
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = command
.spawn()
.with_context(|| format!("execute {program}"))?;
let stdout = child.stdout.take().context("capture command stdout")?;
let stderr = child.stderr.take().context("capture command stderr")?;
timeout(timeout_duration, async move {
let stderr_reader = tokio::spawn(read_bounded(stderr, MAX_STDERR_BYTES));
let mut stdout_reader = BufReader::new(stdout.take(MAX_STDOUT_BYTES + 1));
let mut lines = Vec::with_capacity(max_lines);
let mut total_bytes = 0_u64;
while lines.len() < max_lines {
let mut bytes = Vec::new();
let count = stdout_reader.read_until(b'\n', &mut bytes).await?;
if count == 0 {
break;
}
total_bytes = total_bytes.saturating_add(count as u64);
if total_bytes > MAX_STDOUT_BYTES {
bail!("command output exceeded the configured bound")
}
while matches!(bytes.last(), Some(b'\n' | b'\r')) {
bytes.pop();
}
lines.push(String::from_utf8(bytes).context("command output is not UTF-8")?);
}
let deliberately_stopped = lines.len() == max_lines;
if deliberately_stopped {
child.kill().await.context("stop bounded command")?;
}
let status = child.wait().await?;
let stderr = stderr_reader.await.context("join stderr reader")??;
if !deliberately_stopped && !status.success() {
bail!(
"{program} failed: {}",
String::from_utf8_lossy(&stderr).trim()
)
}
Ok::<_, anyhow::Error>(lines)
})
.await
.context("command timed out")?
}
async fn read_bounded(reader: impl AsyncRead + Unpin, maximum: u64) -> Result<Vec<u8>> {
let mut bytes = Vec::new();
reader.take(maximum + 1).read_to_end(&mut bytes).await?;
if bytes.len() as u64 > maximum {
bail!("command output exceeded the configured bound")
}
Ok(bytes)
}

263
agent/src/config.rs Normal file
View File

@@ -0,0 +1,263 @@
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::{net::SocketAddr, path::Path};
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub server: Server,
pub auth: Auth,
pub limits: Limits,
pub services: Services,
pub firewall: Firewall,
pub logging: Logging,
pub telemetry: Telemetry,
pub logs: Logs,
pub tls: Tls,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Server {
pub listen: SocketAddr,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Auth {
pub mode: String,
pub token_file: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Limits {
pub max_concurrent_requests: usize,
pub request_timeout_seconds: u64,
pub max_body_bytes: usize,
pub read_requests_per_second: f64,
pub write_requests_per_second: f64,
pub burst: f64,
pub command_timeout_seconds: u64,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Services {
pub allowed: Vec<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Firewall {
pub management_enabled: bool,
pub rollback_timeout_seconds: u64,
pub max_snapshots: usize,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Logging {
pub level: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Telemetry {
pub connections_enabled: bool,
pub process_info_enabled: bool,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Logs {
pub enabled: bool,
pub allowed_units: Vec<String>,
pub max_entries_per_request: usize,
pub max_message_bytes: usize,
}
#[derive(Clone, Debug, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct Tls {
pub enabled: bool,
pub certificate_file: String,
pub private_key_file: String,
}
impl Default for Server {
fn default() -> Self {
Self {
listen: "127.0.0.1:9105"
.parse()
.expect("valid default listen address"),
}
}
}
impl Default for Auth {
fn default() -> Self {
Self {
mode: "token".into(),
token_file: "/etc/vps-agent/credentials/control-plane.token".into(),
}
}
}
impl Default for Limits {
fn default() -> Self {
Self {
max_concurrent_requests: 32,
request_timeout_seconds: 5,
max_body_bytes: 65_536,
read_requests_per_second: 10.0,
write_requests_per_second: 2.0,
burst: 20.0,
command_timeout_seconds: 4,
}
}
}
impl Default for Firewall {
fn default() -> Self {
Self {
management_enabled: false,
rollback_timeout_seconds: 60,
max_snapshots: 5,
}
}
}
impl Default for Logging {
fn default() -> Self {
Self {
level: "info".into(),
}
}
}
impl Default for Telemetry {
fn default() -> Self {
Self {
connections_enabled: true,
process_info_enabled: true,
}
}
}
impl Default for Logs {
fn default() -> Self {
Self {
enabled: false,
allowed_units: vec!["vps-agent.service".into()],
max_entries_per_request: 200,
max_message_bytes: 16_384,
}
}
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("read configuration {}", path.display()))?;
let config: Self = toml::from_str(&text).context("parse TOML configuration")?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<()> {
if self.auth.mode != "token" {
bail!("auth.mode must be 'token' in v1")
}
if self.server.listen.ip().is_unspecified() {
bail!("refusing unspecified/public listen address; bind a WireGuard IP explicitly")
}
if self.limits.max_concurrent_requests == 0 || self.limits.max_concurrent_requests > 256 {
bail!("max_concurrent_requests must be between 1 and 256")
}
if !(1..=64).contains(&self.limits.request_timeout_seconds) {
bail!("request_timeout_seconds must be between 1 and 64")
}
if !(1024..=1_048_576).contains(&self.limits.max_body_bytes) {
bail!("max_body_bytes must be between 1024 and 1048576")
}
if self.limits.read_requests_per_second <= 0.0
|| self.limits.write_requests_per_second <= 0.0
|| self.limits.burst < 1.0
{
bail!("rate limits must be positive")
}
if self.services.allowed.len() > 128 {
bail!("at most 128 services may be allow-listed")
}
if self.logs.allowed_units.len() > 128
|| !(1..=1000).contains(&self.logs.max_entries_per_request)
|| !(1024..=65_536).contains(&self.logs.max_message_bytes)
{
bail!("logs limits or allowed unit count are invalid")
}
if self.logs.enabled && self.logs.allowed_units.is_empty() {
bail!("logs.allowed_units must not be empty when log collection is enabled")
}
for name in &self.logs.allowed_units {
if !valid_service_name(name) {
bail!("invalid allow-listed log unit name")
}
}
for name in &self.services.allowed {
if !valid_service_name(name) {
bail!("invalid allow-listed service name")
}
}
if self.firewall.management_enabled {
bail!("firewall management requires the v2 privileged helper and is unavailable")
}
if !matches!(
self.logging.level.as_str(),
"error" | "warn" | "info" | "debug" | "trace"
) {
bail!("logging.level is invalid")
}
if self.tls.enabled
&& (self.tls.certificate_file.is_empty() || self.tls.private_key_file.is_empty())
{
bail!("TLS certificate and private key paths are required")
}
Ok(())
}
}
pub fn valid_service_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 128
&& name
.bytes()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'@' | b':'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_defaults_bind_loopback() {
let config = Config::default();
assert!(config.server.listen.ip().is_loopback());
assert!(!config.firewall.management_enabled);
config.validate().unwrap();
}
#[test]
fn rejects_unspecified_bind() {
let mut config = Config::default();
config.server.listen = "0.0.0.0:9105".parse().unwrap();
assert!(config.validate().is_err());
}
#[test]
fn validates_service_names() {
assert!(valid_service_name("wg-quick@wg0.service"));
assert!(!valid_service_name("nginx;reboot"));
assert!(!valid_service_name("../../etc/passwd"));
}
}

157
agent/src/fail2ban.rs Normal file
View File

@@ -0,0 +1,157 @@
use crate::command;
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use std::{net::IpAddr, time::Duration};
#[derive(Debug, Serialize)]
pub struct Status {
pub installed: bool,
pub running: bool,
pub version: Option<String>,
pub jails: Vec<String>,
}
#[derive(Debug, Default, Serialize, PartialEq)]
pub struct JailStatus {
pub jail: String,
pub currently_failed: u64,
pub total_failed: u64,
pub currently_banned: u64,
pub total_banned: u64,
pub banned_ips: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BanRequest {
pub jail: String,
pub ip: String,
}
pub async fn status(timeout: Duration) -> Status {
let version = match command::run("fail2ban-client", &["--version"], timeout).await {
Ok(output) => Some(output.stdout.trim().to_owned()),
Err(_) => {
return Status {
installed: false,
running: false,
version: None,
jails: vec![],
}
}
};
match command::run("fail2ban-client", &["status"], timeout).await {
Ok(output) => Status {
installed: true,
running: true,
version,
jails: parse_jail_list(&output.stdout),
},
Err(_) => Status {
installed: true,
running: false,
version,
jails: vec![],
},
}
}
pub async fn jail(name: &str, timeout: Duration) -> Result<JailStatus> {
validate_jail(name)?;
let output = command::run("fail2ban-client", &["status", name], timeout).await?;
Ok(parse_jail_status(name, &output.stdout))
}
pub async fn ban(request: &BanRequest, unban: bool, timeout: Duration) -> Result<()> {
validate_jail(&request.jail)?;
let ip: IpAddr = request
.ip
.parse()
.map_err(|_| anyhow::anyhow!("invalid IP address"))?;
let operation = if unban { "unbanip" } else { "banip" };
command::run(
"fail2ban-client",
&[
"set",
request.jail.as_str(),
operation,
ip.to_string().as_str(),
],
timeout,
)
.await?;
Ok(())
}
pub fn validate_jail(name: &str) -> Result<()> {
if name.is_empty()
|| name.len() > 64
|| !name
.bytes()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.'))
{
bail!("invalid jail name")
}
Ok(())
}
fn parse_jail_list(text: &str) -> Vec<String> {
text.lines()
.find_map(|line| line.split_once("Jail list:"))
.map(|(_, list)| {
list.split(',')
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
}
fn parse_jail_status(name: &str, text: &str) -> JailStatus {
let mut result = JailStatus {
jail: name.to_owned(),
..JailStatus::default()
};
for line in text.lines() {
let normalized = line.trim().trim_start_matches(['|', '`', '-', '_']).trim();
let Some((key, value)) = normalized.split_once(':') else {
continue;
};
let value = value.trim();
match key.trim() {
"Currently failed" => result.currently_failed = value.parse().unwrap_or_default(),
"Total failed" => result.total_failed = value.parse().unwrap_or_default(),
"Currently banned" => result.currently_banned = value.parse().unwrap_or_default(),
"Total banned" => result.total_banned = value.parse().unwrap_or_default(),
"Banned IP list" => {
result.banned_ips = value.split_whitespace().map(str::to_owned).collect()
}
_ => {}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_statuses() {
assert_eq!(
parse_jail_list("Number of jail: 2\nJail list: sshd, nginx-http-auth\n"),
vec!["sshd", "nginx-http-auth"]
);
let jail = parse_jail_status("sshd", "Currently failed: 2\nTotal failed: 14\nCurrently banned: 1\nTotal banned: 3\nBanned IP list: 192.0.2.1\n");
assert_eq!(jail.currently_banned, 1);
assert_eq!(jail.banned_ips, vec!["192.0.2.1"]);
}
#[test]
fn rejects_command_like_jail() {
assert!(validate_jail("sshd").is_ok());
assert!(validate_jail("sshd;shutdown").is_err());
assert!(validate_jail("../../root").is_err());
}
}

68
agent/src/firewall.rs Normal file
View File

@@ -0,0 +1,68 @@
use crate::command;
use serde::Serialize;
use serde_json::Value;
use std::time::Duration;
#[derive(Debug, Serialize)]
pub struct FirewallStatus {
pub backend: String,
pub management_enabled: bool,
pub managed_table: String,
pub ruleset: Value,
pub warning: Option<String>,
}
pub async fn status(timeout: Duration) -> FirewallStatus {
if command::run("nft", &["--version"], timeout).await.is_ok() {
return match command::run("nft", &["--json", "list", "ruleset"], timeout).await {
Ok(output) => FirewallStatus {
backend: "nftables".into(),
management_enabled: false,
managed_table: "inet vps_control".into(),
ruleset: serde_json::from_str(&output.stdout).unwrap_or(Value::Null),
warning: None,
},
Err(error) => FirewallStatus {
backend: "nftables".into(),
management_enabled: false,
managed_table: "inet vps_control".into(),
ruleset: Value::Null,
warning: Some(format!("rules unavailable: {error}")),
},
};
}
for (program, backend) in [
("iptables-nft", "iptables-nft"),
("iptables", "iptables-legacy"),
] {
if let Ok(version) = command::run(program, &["--version"], timeout).await {
let detected = if version.stdout.contains("nf_tables") {
"iptables-nft"
} else {
backend
};
let rules = command::run(program, &["-S"], timeout)
.await
.ok()
.map(|v| v.stdout);
return FirewallStatus {
backend: detected.into(),
management_enabled: false,
managed_table: "inet vps_control".into(),
ruleset: rules.map(Value::String).unwrap_or(Value::Null),
warning: Some(
"legacy-compatible firewall view is read-only and has limited structure".into(),
),
};
}
}
FirewallStatus {
backend: "unavailable".into(),
management_enabled: false,
managed_table: "inet vps_control".into(),
ruleset: Value::Null,
warning: Some("no supported firewall userspace utility found".into()),
}
}

234
agent/src/logs.rs Normal file
View File

@@ -0,0 +1,234 @@
use crate::{command, config::Logs};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const MAX_LOOKBACK_SECONDS: u64 = 30 * 24 * 60 * 60;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LogsQuery {
pub since: Option<u64>,
pub cursor: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct LogsResponse {
pub entries: Vec<LogEntry>,
pub truncated: bool,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct LogEntry {
pub cursor: String,
pub timestamp_unix_micros: u64,
pub unit: String,
pub priority: u8,
pub message: String,
pub boot_id: Option<String>,
pub pid: Option<u32>,
}
pub async fn collect(config: &Logs, query: LogsQuery, timeout: Duration) -> Result<LogsResponse> {
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
if query.cursor.as_ref().is_some_and(|cursor| {
cursor.is_empty() || cursor.len() > 1024 || cursor.chars().any(char::is_control)
}) {
bail!("journal cursor is invalid")
}
let limit = query
.limit
.unwrap_or(config.max_entries_per_request)
.min(config.max_entries_per_request);
if limit == 0 {
bail!("log limit must be positive")
}
let mut arguments = vec!["--no-pager".to_owned(), "--output=json".to_owned()];
if let Some(cursor) = query.cursor {
arguments.push(format!("--after-cursor={cursor}"));
} else {
let since = query.since.unwrap_or_else(|| now.saturating_sub(120));
if since > now.saturating_add(300) || now.saturating_sub(since) > MAX_LOOKBACK_SECONDS {
bail!("log lookback is outside the allowed range")
}
arguments.push(format!("--since=@{since}"));
}
for unit in &config.allowed_units {
arguments.push(format!("--unit={unit}"));
}
let argument_refs: Vec<_> = arguments.iter().map(String::as_str).collect();
let lines = command::run_lines("journalctl", &argument_refs, timeout, limit + 1).await?;
let truncated = lines.len() > limit;
let mut entries = lines
.iter()
.take(limit)
.filter(|line| !line.trim().is_empty())
.map(|line| parse_entry(line, config.max_message_bytes))
.collect::<Result<Vec<_>>>()?;
entries.shrink_to_fit();
Ok(LogsResponse { entries, truncated })
}
fn parse_entry(line: &str, max_message_bytes: usize) -> Result<LogEntry> {
let value: Value = serde_json::from_str(line).context("parse journald JSON row")?;
let cursor = string_field(&value, "__CURSOR").context("journal row missing cursor")?;
if cursor.len() > 1024 {
bail!("journal cursor is too long")
}
let timestamp_unix_micros = string_field(&value, "__REALTIME_TIMESTAMP")
.and_then(|raw| raw.parse().ok())
.context("journal row has invalid timestamp")?;
let mut message = redact_sensitive(
&string_field(&value, "MESSAGE").unwrap_or_else(|| "[binary message]".into()),
);
if message.len() > max_message_bytes {
let mut boundary = max_message_bytes;
while !message.is_char_boundary(boundary) {
boundary -= 1;
}
message.truncate(boundary);
}
Ok(LogEntry {
cursor,
timestamp_unix_micros,
unit: string_field(&value, "_SYSTEMD_UNIT").unwrap_or_else(|| "unknown".into()),
priority: string_field(&value, "PRIORITY")
.and_then(|raw| raw.parse().ok())
.filter(|priority| *priority <= 7)
.unwrap_or(6),
message,
boot_id: string_field(&value, "_BOOT_ID"),
pid: string_field(&value, "_PID").and_then(|raw| raw.parse().ok()),
})
}
fn string_field(value: &Value, name: &str) -> Option<String> {
value.get(name)?.as_str().map(str::to_owned)
}
fn redact_sensitive(message: &str) -> String {
const KEYS: &[&str] = &[
"authorization",
"password",
"passwd",
"api_key",
"api-key",
"apikey",
"token",
"secret",
];
let lower = message.to_ascii_lowercase();
let bytes = lower.as_bytes();
let mut output = String::with_capacity(message.len());
let mut copied = 0;
let mut search_from = 0;
while search_from < bytes.len() {
let candidate = KEYS
.iter()
.filter_map(|key| {
lower[search_from..]
.find(key)
.map(|offset| (search_from + offset, *key))
})
.min_by_key(|(position, _)| *position);
let Some((position, key)) = candidate else {
break;
};
let before_is_name = position > 0
&& (bytes[position - 1].is_ascii_alphanumeric() || bytes[position - 1] == b'_');
let mut separator = position + key.len();
if before_is_name {
search_from = separator;
continue;
}
if bytes
.get(separator)
.is_some_and(|byte| matches!(byte, b'\'' | b'"'))
{
separator += 1;
}
while bytes.get(separator).is_some_and(u8::is_ascii_whitespace) {
separator += 1;
}
if !bytes
.get(separator)
.is_some_and(|byte| matches!(byte, b':' | b'='))
{
search_from = position + key.len();
continue;
}
separator += 1;
while bytes.get(separator).is_some_and(u8::is_ascii_whitespace) {
separator += 1;
}
if separator >= bytes.len() {
break;
}
let mut value_start = separator;
let quote = bytes[value_start];
let value_end = if matches!(quote, b'\'' | b'"') {
value_start += 1;
message[value_start..]
.find(char::from(quote))
.map_or(bytes.len(), |offset| value_start + offset)
} else {
if key == "authorization" && lower[value_start..].starts_with("bearer ") {
value_start += "bearer ".len();
}
message[value_start..]
.char_indices()
.find(|(_, character)| {
character.is_ascii_whitespace() || matches!(character, ',' | ';' | '&')
})
.map_or(bytes.len(), |(offset, _)| value_start + offset)
};
if value_end == value_start {
search_from = separator;
continue;
}
output.push_str(&message[copied..value_start]);
output.push_str("[REDACTED]");
copied = value_end;
search_from = value_end;
}
output.push_str(&message[copied..]);
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_truncates_journal_row() {
let row = r#"{"__CURSOR":"s=cursor","__REALTIME_TIMESTAMP":"1700000000123456","_SYSTEMD_UNIT":"nginx.service","PRIORITY":"4","MESSAGE":"привет world","_PID":"42"}"#;
let parsed = parse_entry(row, 12).unwrap();
assert_eq!(parsed.cursor, "s=cursor");
assert_eq!(parsed.unit, "nginx.service");
assert_eq!(parsed.priority, 4);
assert_eq!(parsed.pid, Some(42));
assert!(parsed.message.len() <= 12);
assert!(parsed.message.is_char_boundary(parsed.message.len()));
}
#[test]
fn rejects_missing_cursor() {
assert!(parse_entry(r#"{"MESSAGE":"hello"}"#, 1024).is_err());
}
#[test]
fn redacts_common_secret_shapes() {
assert_eq!(
redact_sensitive("Authorization: Bearer abc123 password='hunter2' token=xyz&ok=1"),
"Authorization: Bearer [REDACTED] password='[REDACTED]' token=[REDACTED]&ok=1"
);
assert_eq!(
redact_sensitive(r#"{"api_key":"value","message":"safe"}"#),
r#"{"api_key":"[REDACTED]","message":"safe"}"#
);
}
}

91
agent/src/main.rs Normal file
View File

@@ -0,0 +1,91 @@
mod api;
mod command;
mod config;
mod fail2ban;
mod firewall;
mod logs;
mod network;
mod services;
mod system;
mod wireguard;
use anyhow::{Context, Result};
use axum_server::{tls_rustls::RustlsConfig, Handle};
use config::Config;
use std::{env, path::PathBuf, time::Duration};
use tracing_subscriber::EnvFilter;
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() -> Result<()> {
let config_path = parse_args()?;
let config = Config::load(&config_path)?;
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_new(&config.logging.level).context("invalid logging filter")?,
)
.compact()
.init();
let state = api::AppState::load(config.clone())?;
let app = api::router(state);
let handle = Handle::new();
let shutdown_handle = handle.clone();
tokio::spawn(async move {
wait_for_shutdown().await;
tracing::info!("shutdown signal received");
shutdown_handle.graceful_shutdown(Some(Duration::from_secs(10)));
});
tracing::info!(listen = %config.server.listen, tls = config.tls.enabled, version = env!("CARGO_PKG_VERSION"), "vps-agent started");
if config.tls.enabled {
let tls =
RustlsConfig::from_pem_file(&config.tls.certificate_file, &config.tls.private_key_file)
.await
.context("load TLS certificate/key")?;
axum_server::bind_rustls(config.server.listen, tls)
.handle(handle)
.serve(app.into_make_service())
.await?;
} else {
axum_server::bind(config.server.listen)
.handle(handle)
.serve(app.into_make_service())
.await?;
}
Ok(())
}
fn parse_args() -> Result<PathBuf> {
let mut args = env::args().skip(1);
let mut config = PathBuf::from("/etc/vps-agent/config.toml");
while let Some(argument) = args.next() {
match argument.as_str() {
"--config" => {
config = args
.next()
.map(PathBuf::from)
.context("--config needs a path")?
}
"--version" | "-V" => {
println!("vps-agent {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
"--help" | "-h" => {
println!("Usage: vps-agent [--config PATH] [--version]");
std::process::exit(0);
}
_ => anyhow::bail!("unknown argument: {argument}"),
}
}
Ok(config)
}
async fn wait_for_shutdown() {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let mut terminate = signal(SignalKind::terminate()).expect("install SIGTERM handler");
tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = terminate.recv() => {} }
}
#[cfg(not(unix))]
let _ = tokio::signal::ctrl_c().await;
}

261
agent/src/network.rs Normal file
View File

@@ -0,0 +1,261 @@
use crate::command;
use anyhow::{Context, Result};
use serde::Serialize;
use serde_json::Value;
use std::{
collections::HashMap,
fs,
net::{IpAddr, Ipv4Addr, Ipv6Addr},
path::Path,
time::Duration,
};
#[derive(Debug, Serialize)]
pub struct Interface {
pub name: String,
pub state: String,
pub addresses: Vec<String>,
pub rx_bytes: u64,
pub tx_bytes: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct SocketInfo {
pub protocol: String,
pub local_address: String,
pub local_port: u16,
pub remote_address: String,
pub remote_port: u16,
pub state: String,
pub pid: Option<u32>,
pub process: Option<String>,
#[serde(skip_serializing)]
inode: u64,
}
pub async fn interfaces(timeout: Duration) -> Vec<Interface> {
let addresses = match command::run("ip", &["-j", "addr", "show"], timeout).await {
Ok(output) => parse_ip_addresses(&output.stdout),
Err(_) => HashMap::new(),
};
let mut result = Vec::new();
let Ok(entries) = fs::read_dir("/sys/class/net") else {
return result;
};
for entry in entries.flatten().take(256) {
let name = entry.file_name().to_string_lossy().into_owned();
let path = entry.path();
result.push(Interface {
state: read_value(path.join("operstate")).unwrap_or_else(|| "unknown".into()),
rx_bytes: read_u64(path.join("statistics/rx_bytes")),
tx_bytes: read_u64(path.join("statistics/tx_bytes")),
addresses: addresses.get(&name).cloned().unwrap_or_default(),
name,
});
}
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
pub async fn routes(timeout: Duration) -> Result<Value> {
let output = command::run("ip", &["-j", "route", "show", "table", "all"], timeout).await?;
serde_json::from_str(&output.stdout).context("parse ip route JSON")
}
pub fn sockets(include_process: bool) -> Result<Vec<SocketInfo>> {
let mut sockets = Vec::new();
for (path, protocol, ipv6) in [
("/proc/net/tcp", "tcp", false),
("/proc/net/tcp6", "tcp", true),
("/proc/net/udp", "udp", false),
("/proc/net/udp6", "udp", true),
] {
let Ok(text) = fs::read_to_string(path) else {
continue;
};
sockets.extend(parse_socket_table(&text, protocol, ipv6)?);
}
if include_process {
attach_processes(&mut sockets);
}
Ok(sockets)
}
pub fn listeners(all: Vec<SocketInfo>) -> Vec<SocketInfo> {
all.into_iter()
.filter(|socket| {
socket.state == "LISTEN" || (socket.protocol == "udp" && socket.remote_port == 0)
})
.collect()
}
fn parse_ip_addresses(text: &str) -> HashMap<String, Vec<String>> {
let Ok(value) = serde_json::from_str::<Value>(text) else {
return HashMap::new();
};
let mut result = HashMap::new();
for item in value.as_array().into_iter().flatten() {
let Some(name) = item.get("ifname").and_then(Value::as_str) else {
continue;
};
let addresses = item
.get("addr_info")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|address| {
let local = address.get("local")?.as_str()?;
let prefix = address.get("prefixlen")?.as_u64()?;
Some(format!("{local}/{prefix}"))
})
.collect();
result.insert(name.to_owned(), addresses);
}
result
}
fn read_value(path: impl AsRef<Path>) -> Option<String> {
fs::read_to_string(path)
.ok()
.map(|value| value.trim().to_owned())
}
fn read_u64(path: impl AsRef<Path>) -> u64 {
read_value(path)
.and_then(|v| v.parse().ok())
.unwrap_or_default()
}
fn parse_socket_table(text: &str, protocol: &str, ipv6: bool) -> Result<Vec<SocketInfo>> {
text.lines()
.skip(1)
.filter(|line| !line.trim().is_empty())
.map(|line| {
let fields: Vec<_> = line.split_whitespace().collect();
if fields.len() < 10 {
anyhow::bail!("malformed /proc socket row")
}
let (local_address, local_port) = parse_endpoint(fields[1], ipv6)?;
let (remote_address, remote_port) = parse_endpoint(fields[2], ipv6)?;
let state = tcp_state(fields[3], protocol).to_owned();
let inode = fields[9].parse().context("invalid socket inode")?;
Ok(SocketInfo {
protocol: protocol.to_owned(),
local_address,
local_port,
remote_address,
remote_port,
state,
pid: None,
process: None,
inode,
})
})
.collect()
}
fn parse_endpoint(value: &str, ipv6: bool) -> Result<(String, u16)> {
let (address, port) = value.split_once(':').context("invalid socket endpoint")?;
let port = u16::from_str_radix(port, 16).context("invalid socket port")?;
let ip = if ipv6 {
if address.len() != 32 {
anyhow::bail!("invalid IPv6 address")
}
let mut octets = [0_u8; 16];
for word in 0..4 {
for byte in 0..4 {
let index = (word * 4 + byte) * 2;
octets[word * 4 + (3 - byte)] = u8::from_str_radix(&address[index..index + 2], 16)?;
}
}
IpAddr::V6(Ipv6Addr::from(octets))
} else {
let raw = u32::from_str_radix(address, 16).context("invalid IPv4 address")?;
IpAddr::V4(Ipv4Addr::from(raw.to_le_bytes()))
};
Ok((ip.to_string(), port))
}
fn tcp_state(code: &str, protocol: &str) -> &'static str {
if protocol == "udp" {
return if code == "07" { "UNCONN" } else { "UNKNOWN" };
}
match code {
"01" => "ESTABLISHED",
"02" => "SYN_SENT",
"03" => "SYN_RECV",
"04" => "FIN_WAIT1",
"05" => "FIN_WAIT2",
"06" => "TIME_WAIT",
"07" => "CLOSE",
"08" => "CLOSE_WAIT",
"09" => "LAST_ACK",
"0A" => "LISTEN",
"0B" => "CLOSING",
_ => "UNKNOWN",
}
}
fn attach_processes(sockets: &mut [SocketInfo]) {
let by_inode: HashMap<u64, usize> = sockets
.iter()
.enumerate()
.map(|(i, s)| (s.inode, i))
.collect();
let Ok(proc_entries) = fs::read_dir("/proc") else {
return;
};
for entry in proc_entries.flatten().take(4096) {
let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else {
continue;
};
let Ok(fds) = fs::read_dir(entry.path().join("fd")) else {
continue;
};
for fd in fds.flatten().take(4096) {
let Ok(target) = fs::read_link(fd.path()) else {
continue;
};
let text = target.to_string_lossy();
let Some(raw) = text
.strip_prefix("socket:[")
.and_then(|v| v.strip_suffix(']'))
else {
continue;
};
let Ok(inode) = raw.parse::<u64>() else {
continue;
};
let Some(index) = by_inode.get(&inode).copied() else {
continue;
};
if sockets[index].pid.is_none() {
sockets[index].pid = Some(pid);
sockets[index].process = read_value(entry.path().join("comm"));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_ipv4_and_listener() {
let input = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n 0: 0100007F:238F 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 42\n";
let rows = parse_socket_table(input, "tcp", false).unwrap();
assert_eq!(rows[0].local_address, "127.0.0.1");
assert_eq!(rows[0].local_port, 9103);
assert_eq!(rows[0].state, "LISTEN");
assert_eq!(listeners(rows).len(), 1);
}
#[test]
fn parses_ip_json() {
let parsed = parse_ip_addresses(
r#"[{"ifname":"wg0","addr_info":[{"local":"10.10.0.1","prefixlen":24}]}]"#,
);
assert_eq!(parsed["wg0"], vec!["10.10.0.1/24"]);
}
}

105
agent/src/services.rs Normal file
View File

@@ -0,0 +1,105 @@
use crate::{command, config::valid_service_name};
use anyhow::{bail, Result};
use serde::Serialize;
use std::{collections::HashMap, time::Duration};
#[derive(Debug, Serialize)]
pub struct ServiceStatus {
pub name: String,
pub active_state: String,
pub sub_state: String,
pub pid: u32,
pub memory_bytes: Option<u64>,
pub active_since_monotonic_us: Option<u64>,
}
pub async fn list(allowed: &[String], timeout: Duration) -> Vec<ServiceStatus> {
let mut result = Vec::with_capacity(allowed.len());
for name in allowed {
match inspect(name, timeout).await {
Ok(status) => result.push(status),
Err(_) => result.push(ServiceStatus {
name: name.clone(),
active_state: "unavailable".into(),
sub_state: "unknown".into(),
pid: 0,
memory_bytes: None,
active_since_monotonic_us: None,
}),
}
}
result
}
pub async fn restart(name: &str, allowed: &[String], timeout: Duration) -> Result<()> {
if !valid_service_name(name) || !allowed.iter().any(|item| item == name) {
bail!("service is not allow-listed")
}
command::run("systemctl", &["restart", "--", name], timeout).await?;
Ok(())
}
async fn inspect(name: &str, timeout: Duration) -> Result<ServiceStatus> {
if !valid_service_name(name) {
bail!("invalid service name")
}
let output = command::run(
"systemctl",
&[
"show",
"--no-pager",
"--property=ActiveState,SubState,MainPID,MemoryCurrent,ActiveEnterTimestampMonotonic",
"--",
name,
],
timeout,
)
.await?;
let values: HashMap<_, _> = output
.stdout
.lines()
.filter_map(|line| line.split_once('='))
.collect();
Ok(ServiceStatus {
name: name.to_owned(),
active_state: values
.get("ActiveState")
.copied()
.unwrap_or("unknown")
.to_owned(),
sub_state: values
.get("SubState")
.copied()
.unwrap_or("unknown")
.to_owned(),
pid: values
.get("MainPID")
.and_then(|v| v.parse().ok())
.unwrap_or_default(),
memory_bytes: values.get("MemoryCurrent").and_then(|v| v.parse().ok()),
active_since_monotonic_us: values
.get("ActiveEnterTimestampMonotonic")
.and_then(|v| v.parse().ok()),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn rejects_service_outside_allowlist_without_execution() {
assert!(
restart("postgresql", &["nginx".into()], Duration::from_secs(1))
.await
.is_err()
);
assert!(restart(
"nginx;reboot",
&["nginx;reboot".into()],
Duration::from_secs(1)
)
.await
.is_err());
}
}

223
agent/src/system.rs Normal file
View File

@@ -0,0 +1,223 @@
use anyhow::{Context, Result};
use nix::sys::statvfs::statvfs;
use serde::Serialize;
use std::{collections::HashMap, fs, path::Path, sync::Mutex, time::Duration};
#[derive(Debug, Default, Clone, Copy)]
pub struct CpuTimes {
total: u64,
idle: u64,
}
#[derive(Debug, Serialize)]
pub struct Status {
pub hostname: String,
pub os: String,
pub kernel: String,
pub uptime_seconds: u64,
pub load_average: [f64; 3],
pub cpu_usage_percent: f64,
pub cpu_core_count: usize,
pub memory_total_bytes: u64,
pub memory_used_bytes: u64,
pub swap_total_bytes: u64,
pub swap_used_bytes: u64,
pub filesystems: Vec<Filesystem>,
pub reboot_required: bool,
pub security_updates: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct Filesystem {
pub mountpoint: String,
pub filesystem: String,
pub total_bytes: u64,
pub used_bytes: u64,
pub inodes_total: u64,
pub inodes_used: u64,
}
pub fn collect(previous_cpu: &Mutex<Option<CpuTimes>>) -> Result<Status> {
let hostname = read_trimmed("/proc/sys/kernel/hostname").unwrap_or_else(|_| "unknown".into());
let kernel = read_trimmed("/proc/sys/kernel/osrelease").unwrap_or_else(|_| "unknown".into());
let os = parse_os_release().unwrap_or_else(|_| "Linux".into());
let uptime_seconds = fs::read_to_string("/proc/uptime")?
.split_whitespace()
.next()
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or_default() as u64;
let load_average = parse_load_average(&fs::read_to_string("/proc/loadavg")?);
let (cpu, cores) = parse_cpu(&fs::read_to_string("/proc/stat")?)?;
let cpu_usage_percent = {
let mut previous = previous_cpu.lock().expect("CPU mutex poisoned");
let baseline = previous.unwrap_or_default();
*previous = Some(cpu);
let total = cpu.total.saturating_sub(baseline.total);
let idle = cpu.idle.saturating_sub(baseline.idle);
if total == 0 {
0.0
} else {
100.0 * (total - idle) as f64 / total as f64
}
};
let meminfo = fs::read_to_string("/proc/meminfo")?;
let mem = parse_meminfo(&meminfo);
Ok(Status {
hostname,
os,
kernel,
uptime_seconds,
load_average,
cpu_usage_percent,
cpu_core_count: cores,
memory_total_bytes: mem.get("MemTotal").copied().unwrap_or_default(),
memory_used_bytes: mem
.get("MemTotal")
.copied()
.unwrap_or_default()
.saturating_sub(mem.get("MemAvailable").copied().unwrap_or_default()),
swap_total_bytes: mem.get("SwapTotal").copied().unwrap_or_default(),
swap_used_bytes: mem
.get("SwapTotal")
.copied()
.unwrap_or_default()
.saturating_sub(mem.get("SwapFree").copied().unwrap_or_default()),
filesystems: collect_filesystems(),
reboot_required: Path::new("/var/run/reboot-required").exists(),
security_updates: None,
})
}
fn read_trimmed(path: &str) -> Result<String> {
Ok(fs::read_to_string(path)?.trim().to_owned())
}
fn parse_os_release() -> Result<String> {
let os_release = fs::read_to_string("/etc/os-release")?;
let values: HashMap<_, _> = os_release
.lines()
.filter_map(|line| line.split_once('='))
.map(|(k, v)| (k, v.trim_matches('"')))
.collect();
Ok(values
.get("PRETTY_NAME")
.copied()
.unwrap_or("Linux")
.to_owned())
}
fn parse_load_average(text: &str) -> [f64; 3] {
let mut fields = text
.split_whitespace()
.take(3)
.map(|v| v.parse().unwrap_or_default());
[
fields.next().unwrap_or_default(),
fields.next().unwrap_or_default(),
fields.next().unwrap_or_default(),
]
}
fn parse_cpu(text: &str) -> Result<(CpuTimes, usize)> {
let mut lines = text.lines();
let aggregate = lines.next().context("missing aggregate CPU line")?;
let values: Vec<u64> = aggregate
.split_whitespace()
.skip(1)
.map(|v| v.parse().unwrap_or_default())
.collect();
let idle =
values.get(3).copied().unwrap_or_default() + values.get(4).copied().unwrap_or_default();
let total = values.iter().sum();
let cores = text
.lines()
.filter(|line| {
line.strip_prefix("cpu")
.and_then(|suffix| suffix.chars().next())
.is_some_and(|c| c.is_ascii_digit())
})
.count();
Ok((CpuTimes { total, idle }, cores))
}
fn parse_meminfo(text: &str) -> HashMap<&str, u64> {
text.lines()
.filter_map(|line| {
let (key, value) = line.split_once(':')?;
let kib = value.split_whitespace().next()?.parse::<u64>().ok()?;
Some((key, kib.saturating_mul(1024)))
})
.collect()
}
fn collect_filesystems() -> Vec<Filesystem> {
let mounts = match fs::read_to_string("/proc/self/mounts") {
Ok(value) => value,
Err(_) => return Vec::new(),
};
let mut result = Vec::new();
for line in mounts.lines() {
let fields: Vec<_> = line.split_whitespace().collect();
if fields.len() < 3 || is_pseudo_filesystem(fields[2]) {
continue;
}
let mountpoint = fields[1].replace("\\040", " ");
let Ok(stats) = statvfs(Path::new(&mountpoint)) else {
continue;
};
let total = u64::from(stats.blocks()).saturating_mul(stats.fragment_size());
let available = u64::from(stats.blocks_available()).saturating_mul(stats.fragment_size());
let inodes_total = u64::from(stats.files());
let inodes_available = u64::from(stats.files_available());
result.push(Filesystem {
mountpoint,
filesystem: fields[2].to_owned(),
total_bytes: total,
used_bytes: total.saturating_sub(available),
inodes_total,
inodes_used: inodes_total.saturating_sub(inodes_available),
});
}
result
}
fn is_pseudo_filesystem(name: &str) -> bool {
matches!(
name,
"proc"
| "sysfs"
| "devtmpfs"
| "devpts"
| "cgroup"
| "cgroup2"
| "securityfs"
| "pstore"
| "debugfs"
| "tracefs"
| "configfs"
| "fusectl"
| "mqueue"
)
}
pub fn command_timeout(seconds: u64) -> Duration {
Duration::from_secs(seconds)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_proc_values() {
let mem = parse_meminfo("MemTotal: 1000 kB\nMemAvailable: 400 kB\n");
assert_eq!(mem["MemTotal"], 1_024_000);
assert_eq!(parse_load_average("1.0 2.5 3.0 1/1 1"), [1.0, 2.5, 3.0]);
let (cpu, cores) =
parse_cpu("cpu 10 0 5 85 0 0 0 0\ncpu0 1 0 1 8\ncpu1 1 0 1 8\n").unwrap();
assert_eq!(cpu.total, 100);
assert_eq!(cpu.idle, 85);
assert_eq!(cores, 2);
}
}

80
agent/src/wireguard.rs Normal file
View File

@@ -0,0 +1,80 @@
use crate::command;
use anyhow::{Context, Result};
use serde::Serialize;
use std::time::Duration;
#[derive(Debug, Default, Serialize, PartialEq)]
pub struct Interface {
pub name: String,
pub public_key: String,
pub listen_port: u16,
pub fwmark: String,
pub peers: Vec<Peer>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct Peer {
pub public_key: String,
pub endpoint: Option<String>,
pub allowed_ips: Vec<String>,
pub latest_handshake: u64,
pub rx_bytes: u64,
pub tx_bytes: u64,
pub persistent_keepalive: u16,
}
pub async fn status(timeout: Duration) -> Result<Vec<Interface>> {
let output = command::run("wg", &["show", "all", "dump"], timeout).await?;
parse_dump(&output.stdout)
}
fn parse_dump(text: &str) -> Result<Vec<Interface>> {
let mut interfaces: Vec<Interface> = Vec::new();
for line in text.lines().filter(|line| !line.is_empty()) {
let fields: Vec<_> = line.split('\t').collect();
match fields.len() {
5 => interfaces.push(Interface {
name: fields[0].to_owned(),
public_key: fields[2].to_owned(),
listen_port: fields[3].parse().unwrap_or_default(),
fwmark: fields[4].to_owned(),
peers: Vec::new(),
}),
9 => {
let interface = interfaces
.iter_mut()
.find(|item| item.name == fields[0])
.context("peer references unknown WireGuard interface")?;
interface.peers.push(Peer {
public_key: fields[1].to_owned(),
endpoint: (fields[3] != "(none)").then(|| fields[3].to_owned()),
allowed_ips: fields[4]
.split(',')
.filter(|v| !v.is_empty())
.map(str::to_owned)
.collect(),
latest_handshake: fields[5].parse().unwrap_or_default(),
rx_bytes: fields[6].parse().unwrap_or_default(),
tx_bytes: fields[7].parse().unwrap_or_default(),
persistent_keepalive: fields[8].parse().unwrap_or_default(),
});
}
_ => anyhow::bail!("unexpected WireGuard dump row"),
}
}
Ok(interfaces)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_dump() {
let dump = "wg0\t(private)\tpub\t51820\toff\nwg0\tpeer\t(none)\t198.51.100.1:51820\t10.0.0.2/32\t100\t200\t300\t25\n";
let parsed = parse_dump(dump).unwrap();
assert_eq!(parsed[0].name, "wg0");
assert_eq!(parsed[0].peers[0].rx_bytes, 200);
assert_eq!(parsed[0].peers[0].allowed_ips, vec!["10.0.0.2/32"]);
}
}