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

1229
agent/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

31
agent/Cargo.toml Normal file
View File

@@ -0,0 +1,31 @@
[package]
name = "vps-agent"
version = "0.1.0"
edition = "2021"
rust-version = "1.80"
license = "Apache-2.0"
description = "Small, constrained Linux VPS monitoring agent"
[dependencies]
anyhow = "1.0"
axum = { version = "0.7", features = ["json", "macros"] }
axum-server = { version = "0.7", features = ["tls-rustls"] }
nix = { version = "0.29", features = ["fs"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
subtle = "2.6"
thiserror = "2.0"
tokio = { version = "1.40", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
toml = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[dev-dependencies]
tempfile = "3.12"
tower = { version = "0.5", features = ["util"] }
[profile.release]
strip = true
lto = "thin"
codegen-units = 1
panic = "abort"

72
agent/README.md Normal file
View File

@@ -0,0 +1,72 @@
# VPS Agent
The agent is a Rust system daemon with two Tokio workers, no database, no metric
history, and no periodic polling. It exposes authenticated semantic JSON APIs and
never accepts commands or shell fragments.
## Build and test
```bash
cargo fmt --manifest-path Cargo.toml --check
cargo clippy --manifest-path Cargo.toml --all-targets -- -D warnings
cargo test --manifest-path Cargo.toml
cargo build --manifest-path Cargo.toml --release
```
The release profile enables thin LTO, strips symbols, uses one codegen unit, and
aborts on panic. Output is one binary at `target/release/vps-agent`.
## Configuration and permissions
Use `config/config.toml` as the reference. Configuration belongs at
`/etc/vps-agent/config.toml`, owned `root:vps-agent` mode 0640. The application
token belongs at `/etc/vps-agent/credentials/control-plane.token`, owned by the
`vps-agent` service user and mode 0600.
Generate at least 32 random bytes. Do not put credentials in TOML.
The default bind is loopback. For remote use, select only the VPS WireGuard IP;
an unspecified address is rejected. TLS can be enabled with PEM certificate/key
paths. WireGuard plus the application token is the recommended v1 mode.
## API
Send `Authorization: Bearer TOKEN` on every `/v1` request. `/healthz` is public
and returns liveness only. The complete route table is in
`../docs/architecture.md`. Limits default to 64 KiB bodies, 32 concurrent calls,
five-second requests, 10 read rps, 2 write rps, and burst 20.
System data comes from `/proc` and `/sys`; fixed `ip`, `nft`/`iptables`,
`fail2ban-client`, `wg`, and `systemctl` invocations augment it. User input is
never part of a command string. Jail names and IPs are validated, and systemd
names must exactly match the configuration allow-list.
`/v1/logs` is disabled by default. When enabled, it streams bounded,
cursor-paginated JSON from `journalctl` for `[logs].allowed_units` only. The
backend polls and retains these rows; Grafana never calls the endpoint. The
`vps-agent` user normally needs membership in `systemd-journal`. That membership
widens what the local process can read, so the installer does not add it and an
administrator must opt in after review.
## systemd and privilege
Install `packaging/vps-agent.service`. It caps memory at 64 MiB, CPU at 10%,
tasks at 64, removes all capabilities, sets `NoNewPrivileges`, and enables
filesystem/kernel/namespace hardening. The agent logs to journald and does not
write successful polls or metrics to disk.
Read APIs work unprivileged on ordinary supported distributions. Process owners
may be absent due to `/proc` permissions. Fail2Ban changes and service restarts
will return an error unless the administrator adds narrowly scoped polkit/sudo
policy; the installer deliberately grants none. Firewall is read-only in v1.
## Lifecycle and troubleshooting
Use `../scripts/vps-agentctl.sh` for install, update, rollback, and removal. Check
`systemctl status vps-agent` and `journalctl -u vps-agent --since today`. Validate
the bind address, token permissions, WireGuard route, and executable availability.
Rotate a token atomically and restart both ends as described in the security doc.
Resource targets are <=30 MiB idle RSS, near-zero idle CPU and no steady disk
writes. Measure on the target distribution with `systemctl status`,
`systemd-cgtop`, and `/proc/PID/status`; macOS development builds are not a valid
resource benchmark.

41
agent/config/config.toml Normal file
View File

@@ -0,0 +1,41 @@
[server]
listen = "127.0.0.1:9105"
[auth]
mode = "token"
token_file = "/etc/vps-agent/credentials/control-plane.token"
[limits]
max_concurrent_requests = 32
request_timeout_seconds = 5
max_body_bytes = 65536
read_requests_per_second = 10.0
write_requests_per_second = 2.0
burst = 20.0
command_timeout_seconds = 4
[services]
allowed = ["nginx", "fail2ban", "wg-quick@wg0"]
[firewall]
management_enabled = false
rollback_timeout_seconds = 60
max_snapshots = 5
[logging]
level = "info"
[telemetry]
connections_enabled = true
process_info_enabled = true
[logs]
enabled = false
allowed_units = ["vps-agent.service"]
max_entries_per_request = 200
max_message_bytes = 16384
[tls]
enabled = false
certificate_file = ""
private_key_file = ""

View File

@@ -0,0 +1,50 @@
[Unit]
Description=VPS Control monitoring agent
Documentation=https://github.com/example/vps-control
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=vps-agent
Group=vps-agent
ExecStart=/usr/local/bin/vps-agent --config /etc/vps-agent/config.toml
Restart=on-failure
RestartSec=5s
TimeoutStopSec=15s
# Resource controls
MemoryHigh=48M
MemoryMax=64M
CPUQuota=10%
TasksMax=64
# Filesystem and privilege hardening
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectHome=true
ProtectSystem=strict
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictRealtime=true
RestrictNamespaces=true
CapabilityBoundingSet=
AmbientCapabilities=
SystemCallArchitectures=native
UMask=0077
# Read-only host information and bounded address families.
ProcSubset=all
ProtectProc=invisible
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
[Install]
WantedBy=multi-user.target

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"]);
}
}