git init
This commit is contained in:
1229
agent/Cargo.lock
generated
Normal file
1229
agent/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
agent/Cargo.toml
Normal file
31
agent/Cargo.toml
Normal 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
72
agent/README.md
Normal 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
41
agent/config/config.toml
Normal 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 = ""
|
||||
50
agent/packaging/vps-agent.service
Normal file
50
agent/packaging/vps-agent.service
Normal 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
501
agent/src/api.rs
Normal 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
109
agent/src/command.rs
Normal 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
263
agent/src/config.rs
Normal 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
157
agent/src/fail2ban.rs
Normal 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
68
agent/src/firewall.rs
Normal 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
234
agent/src/logs.rs
Normal 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
91
agent/src/main.rs
Normal 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
261
agent/src/network.rs
Normal 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
105
agent/src/services.rs
Normal 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
223
agent/src/system.rs
Normal 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
80
agent/src/wireguard.rs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user