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

64
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
agent:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- run: cargo fmt --manifest-path agent/Cargo.toml --check
- run: cargo clippy --manifest-path agent/Cargo.toml --all-targets -- -D warnings
- run: cargo test --manifest-path agent/Cargo.toml --locked
- run: cargo build --manifest-path agent/Cargo.toml --release --locked
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: control-plane/backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24.x'
cache-dependency-path: control-plane/backend/go.sum
- run: test -z "$(gofmt -l .)"
- run: go vet ./...
- run: go test -race ./...
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: control-plane/frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.14'
cache: npm
cache-dependency-path: control-plane/frontend/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test -- --run
- run: npm run build
- run: npm audit --omit=dev
containers:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: mkdir -p control-plane/secrets
- run: printf 'ci-only-not-a-secret-32-characters\n' | tee control-plane/secrets/db_password control-plane/secrets/admin_password control-plane/secrets/agent_token control-plane/secrets/integration_token control-plane/secrets/grafana_admin_password control-plane/secrets/grafana_secret_key >/dev/null
- run: docker compose -f control-plane/docker-compose.yml config --quiet
- run: docker compose -f control-plane/docker-compose.yml --profile observability build backend frontend grafana

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.DS_Store
*.log
.env
agent/target/
control-plane/frontend/node_modules/
control-plane/frontend/dist/
control-plane/frontend/*.tsbuildinfo
control-plane/backend/backend
control-plane/secrets/*
!control-plane/secrets/.gitkeep

94
AGENTS.md Normal file
View File

@@ -0,0 +1,94 @@
# VPS Control contributor guide
## Purpose and architecture
VPS Control monitors and performs a deliberately small set of administrative
actions on systemd-based Linux VPS hosts. `agent/` is a Rust daemon installed on
each VPS. `control-plane/backend/` is the Go API and polling service,
`control-plane/frontend/` is the React UI, and PostgreSQL is the only durable
metrics/audit store. Browsers never contact agents directly.
## Security invariants
- DO NOT add arbitrary command execution.
- DO NOT expose the agent publicly by default.
- DO NOT disable authentication for convenience.
- DO NOT store plaintext credentials in source control or the database.
- DO NOT allow arbitrary systemd service names.
- DO NOT mutate unmanaged firewall rules.
- DO NOT write unbounded logs, local state, or queues.
- DO NOT add heavyweight Agent dependencies without a measured justification.
- Never pass user input to `sh -c`, `bash -c`, `eval`, or equivalent APIs.
- Agent subprocess arguments are separate argv values and must be validated.
- Sensitive values must be redacted from logs and audit records.
Any privileged operation requires:
1. input validation;
2. allow-listing;
3. an audit event;
4. a bounded timeout;
5. explicit error handling.
The v1 agent is unprivileged. Read-only commands are permitted by the systemd
sandbox. Fail2Ban and systemd writes require narrowly scoped sudoers/polkit rules
installed by an administrator; firewall mutation is intentionally disabled until
the separately reviewed helper/rollback subsystem is enabled.
## Repository layout
- `agent/`: Rust daemon, unit tests, systemd packaging.
- `control-plane/backend/`: Go API, auth/RBAC, polling, audit, migrations.
- `control-plane/frontend/`: React/Vite UI.
- `docs/`: architecture, security, deployment, development.
- `scripts/`: lifecycle script for the agent.
- `examples/`: safe configuration examples.
Keep module ownership narrow. JSON API fields use `snake_case`; URLs are under
`/v1` on the agent and `/api/v1` on the control plane. Errors are JSON objects
with an `error` string and an optional stable `code`.
## Commands
```bash
# Agent
cargo fmt --manifest-path agent/Cargo.toml --check
cargo clippy --manifest-path agent/Cargo.toml --all-targets -- -D warnings
cargo test --manifest-path agent/Cargo.toml
cargo build --manifest-path agent/Cargo.toml --release
# Control-plane backend
cd control-plane/backend && gofmt -w . && go vet ./... && go test ./...
# Frontend
cd control-plane/frontend && npm ci && npm run lint && npm run typecheck
npm test -- --run && npm run build
# Deployment
docker compose -f control-plane/docker-compose.yml config
docker compose -f control-plane/docker-compose.yml build
```
Ordinary tests must not require root, nftables, Fail2Ban, WireGuard, or a live
PostgreSQL instance. Put host-dependent tests behind explicit integration tags.
## Resource and coding rules
The agent targets <=30 MiB idle RSS, a 64 MiB systemd limit, near-zero idle CPU,
and negligible disk writes. Prefer `/proc` and `/sys`, collect expensive data
on demand, bound request bodies/concurrency/timeouts, and do not cache history on
the VPS. Production logging is `info` to journald and successful polls are not
logged.
Rust must be formatted, clippy-clean, avoid `unsafe` unless documented, and keep
blocking OS work out of async executor threads. Go handlers must enforce auth and
RBAC before reading request bodies for mutations. React code must be typed and
must not bypass the backend.
## Release process
CI lints, tests, builds both Linux agent targets, emits
`vps-agent-linux-{amd64,arm64}` plus `SHA256SUMS`, validates Compose, and builds
containers. Release publication is a manual approval step. Update the changelog,
version, checksums/signature, compatibility notes, and rollback instructions.

18
LICENSE Normal file
View File

@@ -0,0 +1,18 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright 2026 VPS Control contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

79
README.md Normal file
View File

@@ -0,0 +1,79 @@
# VPS Control
Production-oriented monitoring and constrained management for Debian and Ubuntu
VPS hosts. A small Rust daemon exposes semantic system APIs over a private
WireGuard or TLS network. A separately hosted Go + React control plane provides
inventory, history, RBAC, and an immutable audit trail.
```text
Browser ──HTTPS──> React/nginx ──> Go API ──private network──> Rust agent
PostgreSQL
Grafana (Prometheus/Loki APIs)
```
The project intentionally has no remote shell. The agent defaults to
`127.0.0.1:9105`, reads credentials from root-managed files, runs as an
unprivileged system user, and is bounded by systemd resource controls.
## Quick start
Control plane:
```bash
cp control-plane/.env.example control-plane/.env
# Fill all CHANGE_ME values and create the secret files documented below.
docker compose -f control-plane/docker-compose.yml up -d --build
```
Agent development build:
```bash
cargo build --manifest-path agent/Cargo.toml --release
sudo install -m 0755 agent/target/release/vps-agent /usr/local/bin/vps-agent
sudo install -m 0640 examples/agent-config.toml /etc/vps-agent/config.toml
```
For a release installation, download the lifecycle script and checksum as
separate files, verify with `sha256sum -c`, then run:
```bash
sudo ./scripts/vps-agentctl.sh install --version v0.1.0
```
See [deployment](docs/deployment.md), [security](docs/security.md), and the
[agent](agent/README.md) and [control-plane](control-plane/README.md) manuals.
## Repository
```text
agent/ Rust VPS daemon and systemd packaging
control-plane/backend/ Go API, PostgreSQL persistence, auth/RBAC/audit
control-plane/frontend React/Vite dashboard
docs/ architecture, threat model, operations, development
scripts/ safe agent lifecycle utility
examples/ deployable configuration examples
```
## Security and compatibility
Supported targets are Debian 12+, Ubuntu 22.04, and Ubuntu 24.04 with systemd.
Keep the agent behind WireGuard or localhost/reverse tunnel. Never bind it to a
public address without TLS, host firewall policy, and an explicit risk review.
Read [docs/security.md](docs/security.md) before enabling write operations.
The current v1 scope supports read-only system, network, socket, firewall,
Fail2Ban, WireGuard, and allow-listed service inspection; manual Fail2Ban
ban/unban and service restart are audited. Managed firewall transactions and L7
rate-limit configuration are deliberately deferred until a separately
privileged helper can guarantee crash-safe rollback.
Optional observability endpoints make the backend a constrained
Prometheus-compatible metrics source and Loki-compatible log source for
Grafana. Grafana never receives an agent address or agent credential.
## Development
See [docs/development.md](docs/development.md). CI-compatible commands are also
listed in [AGENTS.md](AGENTS.md). This repository is licensed under Apache-2.0.

100
README_RU.md Normal file
View File

@@ -0,0 +1,100 @@
# VPS Control
Ориентированная на production-среду система мониторинга и ограниченного управления VPS-хостами на Debian и Ubuntu.
Небольшой демон на Rust предоставляет семантические системные API через приватную сеть WireGuard или TLS. Отдельно размещаемая управляющая часть на Go + React предоставляет инвентаризацию серверов, историю, RBAC и неизменяемый журнал аудита.
```text
Браузер ──HTTPS──> React/nginx ──> Go API ──приватная сеть──> Rust agent
PostgreSQL
Grafana (Prometheus/Loki APIs)
```
В проекте намеренно отсутствует удалённый shell-доступ.
По умолчанию агент слушает `127.0.0.1:9105`, считывает учётные данные из файлов, управляемых `root`, запускается от имени непривилегированного системного пользователя и ограничивается средствами управления ресурсами `systemd`.
## Быстрый старт
Управляющая часть:
```bash
cp control-plane/.env.example control-plane/.env
# Заполните все значения CHANGE_ME и создайте файлы с секретами, описанные ниже.
docker compose -f control-plane/docker-compose.yml up -d --build
```
Сборка агента для разработки:
```bash
cargo build --manifest-path agent/Cargo.toml --release
sudo install -m 0755 agent/target/release/vps-agent /usr/local/bin/vps-agent
sudo install -m 0640 examples/agent-config.toml /etc/vps-agent/config.toml
```
Для установки релизной версии скачайте скрипт управления жизненным циклом и файл контрольной суммы как отдельные файлы, проверьте их с помощью `sha256sum -c`, после чего выполните:
```bash
sudo ./scripts/vps-agentctl.sh install --version v0.1.0
```
См. документацию по [развёртыванию](docs/deployment.md), [безопасности](docs/security.md), а также руководства по [агенту](agent/README.md) и [управляющей части](control-plane/README.md).
## Структура репозитория
```text
agent/ VPS-демон на Rust и файлы для упаковки systemd-службы
control-plane/backend/ Go API, хранение данных в PostgreSQL, аутентификация/RBAC/аудит
control-plane/frontend Панель управления на React/Vite
docs/ архитектура, модель угроз, эксплуатация, разработка
scripts/ безопасная утилита управления жизненным циклом агента
examples/ примеры конфигураций, готовых к развёртыванию
```
## Безопасность и совместимость
Поддерживаемые системы:
- Debian 12+
- Ubuntu 22.04
- Ubuntu 24.04
Требуется `systemd`.
Держите агент за WireGuard либо привязывайте его только к localhost/reverse tunnel.
Никогда не привязывайте агент к публичному сетевому интерфейсу без:
- TLS;
- настроенной политики firewall на хосте;
- явной оценки связанных с этим рисков.
Перед включением операций, изменяющих состояние системы, прочитайте [docs/security.md](docs/security.md).
Текущий объём функциональности v1 поддерживает операции только для чтения для:
- системной информации;
- сетевой конфигурации;
- сетевых сокетов;
- firewall;
- Fail2Ban;
- WireGuard;
- сервисов из явно разрешённого списка.
Также поддерживаются ручные операции Fail2Ban `ban`/`unban` и перезапуск разрешённых сервисов. Все такие действия записываются в журнал аудита.
Управляемое изменение правил firewall и настройка L7 rate limit намеренно отложены до появления отдельного привилегированного helper-процесса, который сможет гарантировать безопасный откат изменений даже при сбое или аварийном завершении.
Дополнительные observability-endpoint'ы позволяют backend-компоненту выступать в роли ограниченного источника метрик, совместимого с Prometheus, и источника логов, совместимого с Loki, для Grafana.
Grafana никогда не получает адрес агента или его учётные данные.
## Разработка
См. [docs/development.md](docs/development.md).
Команды, совместимые с CI, также перечислены в [AGENTS.md](AGENTS.md).
Репозиторий распространяется по лицензии Apache-2.0.

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

View File

@@ -0,0 +1,8 @@
.env
secrets
**/node_modules
**/dist
**/*_test.go
frontend/src/**/*.test.*
.git

View File

@@ -0,0 +1,26 @@
# Bind to loopback and terminate public TLS at a reverse proxy.
HTTP_BIND_ADDRESS=127.0.0.1
HTTP_PORT=8080
COOKIE_SECURE=true
POSTGRES_DB=vps_control
POSTGRES_USER=vps_control
SESSION_TTL=12h
METRICS_RETENTION=720h
LOGS_RETENTION=168h
INITIAL_ADMIN_USERNAME=admin
# Optional Grafana profile (loopback by default).
GRAFANA_BIND_ADDRESS=127.0.0.1
GRAFANA_PORT=3000
GRAFANA_ROOT_URL=http://127.0.0.1:3000
GRAFANA_COOKIE_SECURE=false
# Optional: enroll one server only when the database is initially empty.
BOOTSTRAP_SERVER_NAME=
BOOTSTRAP_SERVER_ENDPOINT=
BOOTSTRAP_AGENT_CREDENTIAL_REF=agent_token
# Values are read from ./secrets/{db_password,admin_password,agent_token,
# integration_token,grafana_admin_password,grafana_secret_key};
# do not put passwords or agent tokens in this file.

112
control-plane/README.md Normal file
View File

@@ -0,0 +1,112 @@
# Control Plane
The control plane is a small Go API, PostgreSQL 16, and a React/TypeScript UI
served by unprivileged nginx. The browser only reaches nginx/backend; agent
credentials are mounted into the backend as files and never enter PostgreSQL or
the browser.
## Docker deployment
```bash
cp .env.example .env
mkdir -p secrets
openssl rand -hex 32 > secrets/db_password
openssl rand -base64 24 > secrets/admin_password
openssl rand -hex 32 > secrets/integration_token
openssl rand -base64 24 > secrets/grafana_admin_password
openssl rand -hex 32 > secrets/grafana_secret_key
# Securely copy the exact token from one agent for bootstrap:
install -m 600 /secure/source/control-plane.token secrets/agent_token
chmod 600 secrets/*
docker compose config
docker compose up -d --build
docker compose ps
```
Open `http://127.0.0.1:8080` through a TLS reverse proxy. With plain local HTTP
for development only, set `COOKIE_SECURE=false`. The default host binding is
loopback. PostgreSQL and backend ports are not published.
Set `BOOTSTRAP_SERVER_NAME` and `BOOTSTRAP_SERVER_ENDPOINT` before the first
start, or enroll a server in the UI as Administrator. Endpoints must use a
literal loopback/private IP and explicit port. `credential_ref` is a filename in
`/run/secrets`, never token material. Add another Compose secret mount for each
additional server token.
## Authentication and database
On an empty database, the backend reads `secrets/admin_password`, hashes it with
Argon2id, and creates `INITIAL_ADMIN_USERNAME`. Change the bootstrap password
after adding a user-management workflow or rotate it through an audited database
administrative procedure. Sessions are stored server-side, expire after 12
hours, use Secure/HttpOnly/SameSite cookies, and require a CSRF token on writes.
Roles are Viewer (read), Operator (Fail2Ban and service actions), and
Administrator (server/firewall/policy administration). Every current mutation
is audited with actor, target, result, time, and source IP. Secrets are excluded.
The backend creates schema on startup. One-minute metric samples are retained 30
days (`METRICS_RETENTION=720h`) and agent logs seven days
(`LOGS_RETENTION=168h`). PostgreSQL is the only durable history store.
## Grafana and Loki-compatible access
The backend is both a constrained Prometheus-compatible metrics source and a
Loki-compatible read-only log source. A separate Loki server is not required:
this is what preserves the required `Grafana -> backend -> agent` boundary.
Both APIs require `Authorization: Bearer <integration_token>` and expose no
agent endpoint or credential.
For the included provisioned instance:
```bash
docker compose --profile observability up -d --build
```
Open `http://127.0.0.1:3000`, use the `admin` account and the value in
`secrets/grafana_admin_password`. The two datasources and `VPS Control Overview`
dashboard are provisioned automatically.
`grafana_secret_key` encrypts the datasource credential stored by Grafana and
must be backed up; changing it invalidates encrypted secure data.
For an external Grafana, configure:
- Prometheus datasource URL: `https://vps-control.example/integrations/prometheus`
- Loki datasource URL: `https://vps-control.example/integrations/loki`
- Custom HTTP header on both: `Authorization: Bearer <integration_token>`
The frontend proxy publishes these protected routes. Restrict them to Grafana's
source network at the outer TLS proxy when possible. Supported queries are a
deliberately small subset: direct `vps_control_*` metric selectors and Loki
stream matchers (`=` or `=~`) on `server_id`, `server_name`, `unit`, and
`priority`.
## TLS, backup, and upgrades
Terminate TLS at Caddy, nginx, Traefik, or a managed load balancer and forward to
the loopback frontend port. Preserve the original client IP only from a trusted
proxy. Keep `COOKIE_SECURE=true` in production.
Back up with:
```bash
docker compose exec -T postgres pg_dump -U vps_control -Fc vps_control > vps-control.dump
```
Also back up `.env`, reverse-proxy/TLS configuration, and secret files through a
secret manager. Test restore into a separate database. For upgrades, take a
backup, pin image/source revisions, build, then `docker compose up -d`; check
health and Audit. Migrations are forward-only in v1, so database restore is the
rollback path.
## Development
```bash
cd backend && go test ./... && go vet ./...
cd ../frontend && npm ci && npm run lint && npm run typecheck
npm test -- --run && npm run build
```
The frontend Vite server proxies `/api` to `localhost:8080`. Use a development
PostgreSQL and `COOKIE_SECURE=false`. Never point development at a production
agent credential.

View File

@@ -0,0 +1,4 @@
backend
*_test.go
.git

View File

@@ -0,0 +1,17 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/control-plane .
FROM alpine:3.22
RUN apk add --no-cache ca-certificates tzdata && \
addgroup -S -g 65532 control && adduser -S -D -H -u 65532 -G control control
COPY --from=build --chown=65532:65532 /out/control-plane /usr/local/bin/control-plane
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/control-plane"]

View File

@@ -0,0 +1,149 @@
package main
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const maxAgentResponse = 2 << 20
type AgentClient struct {
http *http.Client
credentialsDir string
}
func NewAgentClient(credentialsDir string) *AgentClient {
transport := &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
TLSHandshakeTimeout: 3 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
MaxConnsPerHost: 8,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 30 * time.Second,
}
return &AgentClient{
http: &http.Client{
Transport: transport,
Timeout: 7 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return errors.New("agent redirects are disabled")
},
},
credentialsDir: credentialsDir,
}
}
func validateAgentEndpoint(endpoint string) error {
parsed, err := url.Parse(endpoint)
if err != nil {
return errors.New("invalid URL")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return errors.New("scheme must be http or https")
}
if parsed.User != nil || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" {
return errors.New("endpoint must contain only scheme, private IP, and port")
}
if parsed.Port() == "" {
return errors.New("endpoint must include a port")
}
ip := net.ParseIP(parsed.Hostname())
if ip == nil || !(ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()) {
return errors.New("endpoint must use a private literal IP address")
}
return nil
}
func validCredentialRef(reference string) bool {
if len(reference) < 1 || len(reference) > 128 {
return false
}
for _, char := range reference {
if !(char >= 'a' && char <= 'z') && !(char >= 'A' && char <= 'Z') &&
!(char >= '0' && char <= '9') && char != '_' && char != '-' && char != '.' {
return false
}
}
return reference != "." && reference != ".."
}
func (client *AgentClient) credential(reference string) (string, error) {
if !validCredentialRef(reference) {
return "", errors.New("invalid credential reference")
}
path := filepath.Join(client.credentialsDir, reference)
info, err := os.Lstat(path)
if err != nil {
return "", err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return "", errors.New("credential must be a regular file")
}
// Docker Compose file-backed secrets are mounted read-only as 0444 on some
// engines. Accept read-only mounts, but never a group/world-writable secret.
if info.Mode().Perm()&0o022 != 0 {
return "", errors.New("credential file must not be group/world writable")
}
if info.Size() > 4096 {
return "", errors.New("credential file is too large")
}
value, err := os.ReadFile(path)
if err != nil {
return "", err
}
token := strings.TrimSpace(string(value))
if len(token) < 32 {
return "", errors.New("credential is too short")
}
return token, nil
}
func (client *AgentClient) Do(ctx context.Context, server Server, method, path string, body []byte) (int, []byte, error) {
if err := validateAgentEndpoint(server.AgentEndpoint); err != nil {
return 0, nil, err
}
if !strings.HasPrefix(path, "/v1/") {
return 0, nil, errors.New("invalid agent API path")
}
token, err := client.credential(server.CredentialRef)
if err != nil {
return 0, nil, fmt.Errorf("load agent credential: %w", err)
}
request, err := http.NewRequestWithContext(ctx, method,
strings.TrimRight(server.AgentEndpoint, "/")+path, bytes.NewReader(body))
if err != nil {
return 0, nil, err
}
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Accept", "application/json")
if len(body) > 0 {
request.Header.Set("Content-Type", "application/json")
}
response, err := client.http.Do(request)
if err != nil {
return 0, nil, err
}
defer response.Body.Close()
limited := io.LimitReader(response.Body, maxAgentResponse+1)
payload, err := io.ReadAll(limited)
if err != nil {
return 0, nil, err
}
if len(payload) > maxAgentResponse {
return 0, nil, errors.New("agent response is too large")
}
return response.StatusCode, payload, nil
}

View File

@@ -0,0 +1,64 @@
package main
import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)
func TestAgentEndpointValidation(t *testing.T) {
for _, endpoint := range []string{"http://127.0.0.1:9105", "https://10.10.0.2:9105", "http://[fd00::1]:9105"} {
if err := validateAgentEndpoint(endpoint); err != nil {
t.Fatalf("%s: %v", endpoint, err)
}
}
for _, endpoint := range []string{"http://0.0.0.0:9105", "http://8.8.8.8:9105", "https://agent.example:9105", "file:///etc/passwd", "http://127.0.0.1:9105/path"} {
if validateAgentEndpoint(endpoint) == nil {
t.Fatalf("unsafe endpoint accepted: %s", endpoint)
}
}
}
func TestCredentialReferences(t *testing.T) {
if !validCredentialRef("agent_primary.token") {
t.Fatal("valid ref rejected")
}
for _, value := range []string{"", "../secret", "path/name", ".", "bad ref"} {
if validCredentialRef(value) {
t.Fatalf("invalid ref accepted: %q", value)
}
}
}
func TestAgentClientAuthenticatesAndBoundsPath(t *testing.T) {
token := strings.Repeat("a", 32)
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "agent"), []byte(token), 0o600); err != nil {
t.Fatal(err)
}
client := NewAgentClient(dir)
client.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.Header.Get("Authorization") != "Bearer "+token {
t.Error("missing token")
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"ok":true}`))}, nil
})
server := Server{AgentEndpoint: "http://127.0.0.1:9105", CredentialRef: "agent"}
status, _, err := client.Do(context.Background(), server, http.MethodGet, "/v1/version", nil)
if err != nil || status != 200 {
t.Fatalf("agent request failed: status=%d err=%v", status, err)
}
if _, _, err = client.Do(context.Background(), server, http.MethodGet, "/private", nil); err == nil {
t.Fatal("out-of-contract path accepted")
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (function roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return function(request)
}

View File

@@ -0,0 +1,459 @@
package main
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"io"
"log"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
const maxRequestBody = 64 << 10
type App struct {
store *Store
agent *AgentClient
config Config
logins *LoginLimiter
integrationLimiter *LoginLimiter
integrationTokenHash []byte
dummyHash string
}
func NewApp(store *Store, agent *AgentClient, config Config) (*App, error) {
dummy, err := HashPassword("unused-password-for-timing-equalization")
if err != nil {
return nil, err
}
var integrationTokenHash []byte
if config.IntegrationTokenFile != "" {
token, tokenErr := readIntegrationToken(config.IntegrationTokenFile)
if tokenErr != nil {
return nil, tokenErr
}
integrationTokenHash = tokenHash(token)
}
return &App{store: store, agent: agent, config: config,
logins: NewLoginLimiter(5, time.Minute), integrationLimiter: NewLoginLimiter(600, time.Minute),
integrationTokenHash: integrationTokenHash, dummyHash: dummy}, nil
}
func (app *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("X-Frame-Options", "DENY")
writer.Header().Set("Referrer-Policy", "no-referrer")
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
if strings.HasPrefix(request.URL.Path, "/integrations/") {
app.integration(writer, request)
return
}
if request.URL.Path == "/healthz" && request.Method == http.MethodGet {
ctx, cancel := context.WithTimeout(request.Context(), 2*time.Second)
defer cancel()
if err := app.store.pool.Ping(ctx); err != nil {
writeError(writer, http.StatusServiceUnavailable, "database unavailable", "unhealthy")
return
}
writeJSON(writer, http.StatusOK, map[string]string{"status": "ok"})
return
}
if request.URL.Path == "/api/v1/auth/login" && request.Method == http.MethodPost {
app.login(writer, request)
return
}
if !strings.HasPrefix(request.URL.Path, "/api/v1/") {
writeError(writer, http.StatusNotFound, "not found", "not_found")
return
}
user, session, csrfHash, ok := app.authenticate(writer, request)
if !ok {
return
}
switch {
case request.URL.Path == "/api/v1/auth/me" && request.Method == http.MethodGet:
writeJSON(writer, http.StatusOK, user)
case request.URL.Path == "/api/v1/auth/logout" && request.Method == http.MethodPost:
if !app.requireCSRF(writer, request, csrfHash) {
return
}
_ = app.store.DeleteSession(request.Context(), session)
app.clearCookies(writer)
app.store.Audit(request.Context(), &user, "", "auth.logout", user.Username, nil, nil, "success", app.clientIP(request))
writer.WriteHeader(http.StatusNoContent)
case request.URL.Path == "/api/v1/servers" && request.Method == http.MethodGet:
app.listServers(writer, request)
case request.URL.Path == "/api/v1/servers" && request.Method == http.MethodPost:
if !app.authorize(writer, request, user, csrfHash, RoleAdministrator) {
return
}
app.createServer(writer, request, user)
case request.URL.Path == "/api/v1/audit" && request.Method == http.MethodGet:
app.listAudit(writer, request)
case strings.HasPrefix(request.URL.Path, "/api/v1/servers/"):
app.serverRoute(writer, request, user, csrfHash)
default:
writeError(writer, http.StatusNotFound, "not found", "not_found")
}
}
func (app *App) login(writer http.ResponseWriter, request *http.Request) {
ip := app.clientIP(request)
if !app.logins.Allow(ip, time.Now()) {
writeError(writer, http.StatusTooManyRequests, "too many login attempts", "rate_limited")
return
}
var input struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := decodeJSON(writer, request, &input); err != nil {
writeError(writer, http.StatusBadRequest, "invalid request", "invalid_request")
return
}
user, hash, disabled, err := app.store.LoginUser(request.Context(), input.Username)
if err != nil {
log.Printf("login query: %v", err)
writeError(writer, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if hash == "" {
hash = app.dummyHash
}
valid := VerifyPassword(hash, input.Password) && user.ID != "" && !disabled
if !valid {
app.store.Audit(request.Context(), nil, "", "auth.login", input.Username, nil, nil, "failure", ip)
writeError(writer, http.StatusUnauthorized, "invalid credentials", "invalid_credentials")
return
}
session, csrf, err := app.store.CreateSession(request.Context(), user.ID, ip, app.config.SessionTTL)
if err != nil {
log.Printf("create session: %v", err)
writeError(writer, http.StatusInternalServerError, "internal error", "internal_error")
return
}
app.setCookies(writer, session, csrf)
app.store.Audit(request.Context(), &user, "", "auth.login", user.Username, nil, nil, "success", ip)
writeJSON(writer, http.StatusOK, user)
}
func (app *App) authenticate(writer http.ResponseWriter, request *http.Request) (User, string, []byte, bool) {
cookie, err := request.Cookie("vps_session")
if err != nil || len(cookie.Value) > 256 {
writeError(writer, http.StatusUnauthorized, "authentication required", "unauthorized")
return User{}, "", nil, false
}
user, csrfHash, err := app.store.SessionUser(request.Context(), cookie.Value)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
log.Printf("session query: %v", err)
}
writeError(writer, http.StatusUnauthorized, "authentication required", "unauthorized")
return User{}, "", nil, false
}
return user, cookie.Value, csrfHash, true
}
func (app *App) authorize(writer http.ResponseWriter, request *http.Request, user User, csrfHash []byte, role string) bool {
if !permits(user.Role, role) {
writeError(writer, http.StatusForbidden, "insufficient permissions", "forbidden")
return false
}
return app.requireCSRF(writer, request, csrfHash)
}
func (app *App) requireCSRF(writer http.ResponseWriter, request *http.Request, expected []byte) bool {
provided := request.Header.Get("X-CSRF-Token")
if len(provided) > 256 || subtle.ConstantTimeCompare(tokenHash(provided), expected) != 1 {
writeError(writer, http.StatusForbidden, "CSRF validation failed", "csrf_failed")
return false
}
return true
}
func (app *App) setCookies(writer http.ResponseWriter, session, csrf string) {
maxAge := int(app.config.SessionTTL.Seconds())
http.SetCookie(writer, &http.Cookie{Name: "vps_session", Value: session, Path: "/", HttpOnly: true,
Secure: app.config.CookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: maxAge})
http.SetCookie(writer, &http.Cookie{Name: "vps_csrf", Value: csrf, Path: "/", HttpOnly: false,
Secure: app.config.CookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: maxAge})
}
func (app *App) clearCookies(writer http.ResponseWriter) {
for _, name := range []string{"vps_session", "vps_csrf"} {
http.SetCookie(writer, &http.Cookie{Name: name, Path: "/", HttpOnly: name == "vps_session",
Secure: app.config.CookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: -1})
}
}
func (app *App) listServers(writer http.ResponseWriter, request *http.Request) {
servers, err := app.store.Servers(request.Context())
if err != nil {
writeInternal(writer, err)
return
}
writeJSON(writer, http.StatusOK, servers)
}
func (app *App) createServer(writer http.ResponseWriter, request *http.Request, user User) {
var input struct {
Name string `json:"name"`
Hostname string `json:"hostname"`
AgentEndpoint string `json:"agent_endpoint"`
CredentialRef string `json:"credential_ref"`
Tags []string `json:"tags"`
}
if err := decodeJSON(writer, request, &input); err != nil || len(input.Name) < 1 || len(input.Name) > 128 ||
len(input.Hostname) > 255 || len(input.Tags) > 20 || validateAgentEndpoint(input.AgentEndpoint) != nil ||
!validCredentialRef(input.CredentialRef) {
writeError(writer, http.StatusBadRequest, "invalid server", "invalid_request")
return
}
for _, tag := range input.Tags {
if len(tag) > 64 {
writeError(writer, 400, "invalid tag", "invalid_request")
return
}
}
if _, err := app.agent.credential(input.CredentialRef); err != nil {
writeError(writer, http.StatusBadRequest, "credential reference is unavailable", "credential_unavailable")
return
}
tags, _ := json.Marshal(input.Tags)
server := Server{ID: newID(), Name: input.Name, Hostname: input.Hostname, AgentEndpoint: input.AgentEndpoint,
CredentialRef: input.CredentialRef, Tags: tags, Status: "unknown", CreatedAt: time.Now()}
if err := app.store.CreateServer(request.Context(), server); err != nil {
writeInternal(writer, err)
return
}
app.store.Audit(request.Context(), &user, server.ID, "server.create", server.Name, nil,
map[string]any{"name": server.Name, "hostname": server.Hostname, "agent_endpoint": server.AgentEndpoint},
"success", app.clientIP(request))
writeJSON(writer, http.StatusCreated, server)
}
func (app *App) listAudit(writer http.ResponseWriter, request *http.Request) {
limit := queryLimit(request, 100, 500)
events, err := app.store.AuditEvents(request.Context(), limit)
if err != nil {
writeInternal(writer, err)
return
}
writeJSON(writer, http.StatusOK, events)
}
func (app *App) serverRoute(writer http.ResponseWriter, request *http.Request, user User, csrfHash []byte) {
remainder := strings.TrimPrefix(request.URL.Path, "/api/v1/servers/")
parts := strings.Split(remainder, "/")
if len(parts) < 2 || parts[0] == "" {
writeError(writer, 404, "not found", "not_found")
return
}
server, err := app.store.Server(request.Context(), parts[0])
if errors.Is(err, pgx.ErrNoRows) {
writeError(writer, 404, "server not found", "not_found")
return
}
if err != nil {
writeInternal(writer, err)
return
}
readRoutes := map[string]string{
"status": "/v1/system/status", "interfaces": "/v1/network/interfaces", "routes": "/v1/network/routes",
"listeners": "/v1/network/listeners", "connections": "/v1/network/connections",
"firewall": "/v1/firewall/rules", "fail2ban": "/v1/fail2ban/status",
"wireguard": "/v1/wireguard/status", "services": "/v1/services",
}
if len(parts) == 2 && request.Method == http.MethodGet {
if parts[1] == "metrics" {
samples, metricErr := app.store.Metrics(request.Context(), server.ID, queryLimit(request, 120, 1440))
if metricErr != nil {
writeInternal(writer, metricErr)
return
}
writeJSON(writer, 200, samples)
return
}
if parts[1] == "logs" {
entries, logErr := app.store.Logs(request.Context(), server.ID, queryLimit(request, 200, 1000))
if logErr != nil {
writeInternal(writer, logErr)
return
}
writeJSON(writer, 200, entries)
return
}
if agentPath, found := readRoutes[parts[1]]; found {
app.proxyRead(writer, request, server, agentPath)
return
}
}
if len(parts) == 3 && request.Method == http.MethodGet && parts[1] == "fail2ban" && validSimpleName(parts[2]) {
app.proxyRead(writer, request, server, "/v1/fail2ban/jails/"+url.PathEscape(parts[2]))
return
}
if len(parts) == 3 && request.Method == http.MethodPost && parts[1] == "fail2ban" && (parts[2] == "ban" || parts[2] == "unban") {
if !app.authorize(writer, request, user, csrfHash, RoleOperator) {
return
}
app.fail2banMutation(writer, request, user, server, parts[2])
return
}
if len(parts) == 4 && request.Method == http.MethodPost && parts[1] == "services" && parts[3] == "restart" && validServiceName(parts[2]) {
if !app.authorize(writer, request, user, csrfHash, RoleOperator) {
return
}
app.serviceMutation(writer, request, user, server, parts[2])
return
}
writeError(writer, 404, "not found", "not_found")
}
func (app *App) proxyRead(writer http.ResponseWriter, request *http.Request, server Server, path string) {
status, body, err := app.agent.Do(request.Context(), server, http.MethodGet, path, nil)
if err != nil {
log.Printf("agent read %s: %v", server.ID, err)
writeError(writer, 502, "agent unavailable", "agent_unavailable")
return
}
writeAgentResponse(writer, status, body)
}
func (app *App) fail2banMutation(writer http.ResponseWriter, request *http.Request, user User, server Server, action string) {
var input struct {
Jail string `json:"jail"`
IP string `json:"ip"`
}
if decodeJSON(writer, request, &input) != nil || !validSimpleName(input.Jail) || net.ParseIP(input.IP) == nil {
writeError(writer, 400, "invalid jail or IP address", "invalid_request")
return
}
body, _ := json.Marshal(input)
status, response, err := app.agent.Do(request.Context(), server, http.MethodPost, "/v1/fail2ban/"+action, body)
result := "success"
if err != nil || status >= 300 {
result = "failure"
}
app.store.Audit(request.Context(), &user, server.ID, "fail2ban."+action, input.Jail+":"+input.IP,
nil, input, result, app.clientIP(request))
if err != nil {
writeError(writer, 502, "agent unavailable", "agent_unavailable")
return
}
writeAgentResponse(writer, status, response)
}
func (app *App) serviceMutation(writer http.ResponseWriter, request *http.Request, user User, server Server, name string) {
status, response, err := app.agent.Do(request.Context(), server, http.MethodPost,
"/v1/services/"+url.PathEscape(name)+"/restart", []byte("{}"))
result := "success"
if err != nil || status >= 300 {
result = "failure"
}
app.store.Audit(request.Context(), &user, server.ID, "service.restart", name, nil,
map[string]string{"requested": "restart"}, result, app.clientIP(request))
if err != nil {
writeError(writer, 502, "agent unavailable", "agent_unavailable")
return
}
writeAgentResponse(writer, status, response)
}
func (app *App) clientIP(request *http.Request) string {
if app.config.TrustProxy {
if value := request.Header.Get("X-Real-IP"); net.ParseIP(value) != nil {
return value
}
}
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err == nil {
return host
}
return request.RemoteAddr
}
func validSimpleName(value string) bool {
if len(value) < 1 || len(value) > 64 {
return false
}
for _, char := range value {
if !(char >= 'a' && char <= 'z') && !(char >= 'A' && char <= 'Z') &&
!(char >= '0' && char <= '9') && !strings.ContainsRune("._-", char) {
return false
}
}
return true
}
func validServiceName(value string) bool {
if len(value) < 1 || len(value) > 128 {
return false
}
for _, char := range value {
if !(char >= 'a' && char <= 'z') && !(char >= 'A' && char <= 'Z') &&
!(char >= '0' && char <= '9') && !strings.ContainsRune("._-@:", char) {
return false
}
}
return true
}
func decodeJSON(writer http.ResponseWriter, request *http.Request, target any) error {
request.Body = http.MaxBytesReader(writer, request.Body, maxRequestBody)
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("multiple JSON values")
}
return nil
}
func queryLimit(request *http.Request, fallback, maximum int) int {
value, err := strconv.Atoi(request.URL.Query().Get("limit"))
if err != nil || value < 1 {
return fallback
}
if value > maximum {
return maximum
}
return value
}
func writeAgentResponse(writer http.ResponseWriter, status int, payload []byte) {
if status < 100 || status > 599 {
status = http.StatusBadGateway
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(status)
_, _ = writer.Write(payload)
}
func writeJSON(writer http.ResponseWriter, status int, value any) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(value)
}
func writeError(writer http.ResponseWriter, status int, message, code string) {
writeJSON(writer, status, map[string]string{"error": message, "code": code})
}
func writeInternal(writer http.ResponseWriter, err error) {
log.Printf("internal request error: %v", err)
writeError(writer, http.StatusInternalServerError, "internal error", "internal_error")
}

View File

@@ -0,0 +1,77 @@
package main
import (
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestStrictJSON(t *testing.T) {
type payload struct {
Name string `json:"name"`
}
for _, test := range []struct {
body string
valid bool
}{
{`{"name":"vps"}`, true},
{`{"name":"vps","secret":"leak"}`, false},
{`{"name":"vps"}{"name":"second"}`, false},
} {
request := httptest.NewRequest("POST", "/", strings.NewReader(test.body))
writer := httptest.NewRecorder()
var value payload
err := decodeJSON(writer, request, &value)
if (err == nil) != test.valid {
t.Fatalf("body %q valid=%v err=%v", test.body, test.valid, err)
}
}
}
func TestLoginLimiterIsBounded(t *testing.T) {
limiter := NewLoginLimiter(2, time.Minute)
now := time.Now()
if !limiter.Allow("ip", now) || !limiter.Allow("ip", now) {
t.Fatal("initial attempts rejected")
}
if limiter.Allow("ip", now) {
t.Fatal("limit not enforced")
}
if !limiter.Allow("ip", now.Add(time.Minute)) {
t.Fatal("window did not reset")
}
limiter.maxKeys = 1
if limiter.Allow("second-ip", now) {
t.Fatal("bounded limiter accepted an untracked key")
}
}
func TestValidators(t *testing.T) {
if !validSimpleName("nginx-http-auth") || validSimpleName("jail;shutdown") {
t.Fatal("jail validation failed")
}
if !validServiceName("wg-quick@wg0") || validServiceName("nginx/restart") {
t.Fatal("service validation failed")
}
}
func TestPercent(t *testing.T) {
if percent(25, 100) != 25 || percent(1, 0) != 0 {
t.Fatal("invalid percentage")
}
}
func TestRedactLogMessage(t *testing.T) {
input := `Authorization: Bearer abc123 password="hunter2" token=xyz&safe=1`
want := `Authorization: Bearer [REDACTED] password=[REDACTED] token=[REDACTED]&safe=1`
if got := redactLogMessage(input); got != want {
t.Fatalf("redaction mismatch: got %q want %q", got, want)
}
}
func TestTruncateUTF8(t *testing.T) {
if got := truncateUTF8("привет", 5); got != "пр" {
t.Fatalf("unexpected UTF-8 truncation %q", got)
}
}

View File

@@ -0,0 +1,113 @@
package main
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"golang.org/x/crypto/argon2"
)
const (
RoleViewer = "viewer"
RoleOperator = "operator"
RoleAdministrator = "administrator"
)
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
}
func HashPassword(password string) (string, error) {
if len(password) < 12 || len(password) > 1024 {
return "", errors.New("password must be between 12 and 1024 bytes")
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
const memory = 64 * 1024
const iterations = 3
const parallelism = 2
hash := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, 32)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", memory, iterations, parallelism,
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)), nil
}
func VerifyPassword(encoded, password string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" {
return false
}
var memory uint32
var iterations uint32
var parallelism uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, &parallelism); err != nil {
return false
}
if memory > 128*1024 || iterations > 10 || parallelism > 8 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil || len(salt) < 16 {
return false
}
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil || len(expected) != 32 {
return false
}
actual := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, uint32(len(expected)))
return subtle.ConstantTimeCompare(actual, expected) == 1
}
func randomToken() (string, []byte, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", nil, err
}
token := base64.RawURLEncoding.EncodeToString(raw)
digest := sha256.Sum256([]byte(token))
return token, digest[:], nil
}
func tokenHash(token string) []byte {
digest := sha256.Sum256([]byte(token))
return digest[:]
}
func validRole(role string) bool {
return role == RoleViewer || role == RoleOperator || role == RoleAdministrator
}
func permits(role, required string) bool {
rank := map[string]int{RoleViewer: 1, RoleOperator: 2, RoleAdministrator: 3}
return rank[role] >= rank[required] && rank[required] > 0
}
func validUsername(username string) bool {
if len(username) < 3 || len(username) > 64 {
return false
}
for _, char := range username {
if !(char >= 'a' && char <= 'z') && !(char >= 'A' && char <= 'Z') &&
!(char >= '0' && char <= '9') && !strings.ContainsRune("._-", char) {
return false
}
}
return true
}
func parseInt(value string, fallback int) int {
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}

View File

@@ -0,0 +1,52 @@
package main
import (
"testing"
)
func TestPasswordHashAndVerify(t *testing.T) {
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatal(err)
}
if hash == "correct horse battery staple" {
t.Fatal("password stored in plaintext")
}
if !VerifyPassword(hash, "correct horse battery staple") {
t.Fatal("correct password rejected")
}
if VerifyPassword(hash, "wrong password") {
t.Fatal("wrong password accepted")
}
if VerifyPassword("malformed", "correct horse battery staple") {
t.Fatal("malformed hash accepted")
}
}
func TestRBAC(t *testing.T) {
if !permits(RoleAdministrator, RoleOperator) {
t.Fatal("administrator must inherit operator")
}
if permits(RoleViewer, RoleOperator) {
t.Fatal("viewer must not mutate")
}
if !permits(RoleOperator, RoleViewer) {
t.Fatal("operator must read")
}
if permits("unknown", RoleViewer) {
t.Fatal("unknown role allowed")
}
}
func TestUsernameValidation(t *testing.T) {
for _, value := range []string{"admin", "ops.user", "alice-2"} {
if !validUsername(value) {
t.Fatalf("valid username rejected: %s", value)
}
}
for _, value := range []string{"x", "admin@example", "../../root"} {
if validUsername(value) {
t.Fatalf("invalid username accepted: %s", value)
}
}
}

View File

@@ -0,0 +1,126 @@
package main
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type Config struct {
ListenAddress string
DatabaseURL string
CookieSecure bool
TrustProxy bool
SessionTTL time.Duration
CredentialsDir string
AdminUsername string
AdminPasswordFile string
BootstrapServerName string
BootstrapEndpoint string
BootstrapCredential string
MetricsRetention time.Duration
LogsRetention time.Duration
IntegrationTokenFile string
}
func LoadConfig() (Config, error) {
config := Config{
ListenAddress: envOr("LISTEN_ADDRESS", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
CookieSecure: envBool("COOKIE_SECURE", true),
TrustProxy: envBool("TRUST_PROXY", false),
SessionTTL: envDuration("SESSION_TTL", 12*time.Hour),
CredentialsDir: envOr("AGENT_CREDENTIALS_DIR", "/run/secrets"),
AdminUsername: envOr("INITIAL_ADMIN_USERNAME", "admin"),
AdminPasswordFile: os.Getenv("INITIAL_ADMIN_PASSWORD_FILE"),
BootstrapServerName: os.Getenv("BOOTSTRAP_SERVER_NAME"),
BootstrapEndpoint: os.Getenv("BOOTSTRAP_SERVER_ENDPOINT"),
BootstrapCredential: os.Getenv("BOOTSTRAP_AGENT_CREDENTIAL_REF"),
MetricsRetention: envDuration("METRICS_RETENTION", 30*24*time.Hour),
LogsRetention: envDuration("LOGS_RETENTION", 7*24*time.Hour),
IntegrationTokenFile: os.Getenv("INTEGRATION_TOKEN_FILE"),
}
if config.DatabaseURL == "" {
config.DatabaseURL = buildDatabaseURL()
}
if config.DatabaseURL == "" {
return Config{}, errors.New("DATABASE_URL or DB_HOST/DB_PASSWORD_FILE is required")
}
if config.SessionTTL < 5*time.Minute || config.SessionTTL > 7*24*time.Hour {
return Config{}, errors.New("SESSION_TTL must be between 5m and 168h")
}
if config.MetricsRetention < 24*time.Hour {
return Config{}, errors.New("METRICS_RETENTION must be at least 24h")
}
if config.LogsRetention < time.Hour || config.LogsRetention > 90*24*time.Hour {
return Config{}, errors.New("LOGS_RETENTION must be between 1h and 2160h")
}
if config.BootstrapEndpoint != "" {
if err := validateAgentEndpoint(config.BootstrapEndpoint); err != nil {
return Config{}, fmt.Errorf("bootstrap endpoint: %w", err)
}
if !validCredentialRef(config.BootstrapCredential) {
return Config{}, errors.New("invalid BOOTSTRAP_AGENT_CREDENTIAL_REF")
}
}
return config, nil
}
func buildDatabaseURL() string {
host := os.Getenv("DB_HOST")
passwordFile := os.Getenv("DB_PASSWORD_FILE")
if host == "" || passwordFile == "" {
return ""
}
password, err := os.ReadFile(filepath.Clean(passwordFile))
if err != nil {
return ""
}
user := envOr("DB_USER", "vps_control")
database := envOr("DB_NAME", "vps_control")
values := url.Values{"sslmode": {envOr("DB_SSLMODE", "disable")}}
return (&url.URL{
Scheme: "postgres",
User: url.UserPassword(user, strings.TrimSpace(string(password))),
Host: net.JoinHostPort(host, envOr("DB_PORT", "5432")),
Path: database,
RawQuery: values.Encode(),
}).String()
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func envBool(name string, fallback bool) bool {
value := os.Getenv(name)
if value == "" {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return fallback
}
return parsed
}
func envDuration(name string, fallback time.Duration) time.Duration {
value := os.Getenv(name)
if value == "" {
return fallback
}
parsed, err := time.ParseDuration(value)
if err != nil {
return fallback
}
return parsed
}

572
control-plane/backend/db.go Normal file
View File

@@ -0,0 +1,572 @@
package main
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const schema = `
CREATE TABLE IF NOT EXISTS users (
id text PRIMARY KEY,
username text UNIQUE NOT NULL,
password_hash text NOT NULL,
role text NOT NULL CHECK (role IN ('viewer','operator','administrator')),
disabled boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS sessions (
id_hash bytea PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_hash bytea NOT NULL,
source_ip text NOT NULL,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS sessions_expires_idx ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS servers (
id text PRIMARY KEY,
name text NOT NULL,
hostname text NOT NULL DEFAULT '',
agent_endpoint text NOT NULL,
credential_ref text NOT NULL,
tags jsonb NOT NULL DEFAULT '[]'::jsonb,
status text NOT NULL DEFAULT 'unknown',
last_seen timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS metric_samples (
server_id text NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sampled_at timestamptz NOT NULL DEFAULT now(),
cpu double precision NOT NULL,
memory double precision NOT NULL,
disk double precision NOT NULL,
rx bigint NOT NULL,
tx bigint NOT NULL,
connections integer NOT NULL,
bans integer NOT NULL,
PRIMARY KEY(server_id, sampled_at)
);
CREATE INDEX IF NOT EXISTS metric_samples_time_idx ON metric_samples(sampled_at);
CREATE TABLE IF NOT EXISTS agent_log_entries (
server_id text NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
journal_cursor text NOT NULL,
event_at timestamptz NOT NULL,
unit text NOT NULL,
priority smallint NOT NULL CHECK (priority BETWEEN 0 AND 7),
message text NOT NULL,
boot_id text,
pid integer,
ingested_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY(server_id, journal_cursor)
);
CREATE INDEX IF NOT EXISTS agent_log_entries_time_idx ON agent_log_entries(event_at DESC);
CREATE INDEX IF NOT EXISTS agent_log_entries_server_time_idx ON agent_log_entries(server_id, event_at DESC);
CREATE TABLE IF NOT EXISTS audit_events (
id bigserial PRIMARY KEY,
user_id text REFERENCES users(id) ON DELETE SET NULL,
username text NOT NULL,
server_id text REFERENCES servers(id) ON DELETE SET NULL,
action text NOT NULL,
target text NOT NULL,
before_state jsonb,
after_state jsonb,
result text NOT NULL,
source_ip text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS audit_events_time_idx ON audit_events(created_at DESC);
`
type Store struct{ pool *pgxpool.Pool }
type Server struct {
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
AgentEndpoint string `json:"agent_endpoint"`
CredentialRef string `json:"-"`
Tags json.RawMessage `json:"tags"`
Status string `json:"status"`
LastSeen *time.Time `json:"last_seen"`
CreatedAt time.Time `json:"created_at"`
}
type AuditEvent struct {
ID int64 `json:"id"`
Username string `json:"username"`
ServerID *string `json:"server_id"`
Action string `json:"action"`
Target string `json:"target"`
Before json.RawMessage `json:"before"`
After json.RawMessage `json:"after"`
Result string `json:"result"`
SourceIP string `json:"source_ip"`
CreatedAt time.Time `json:"created_at"`
}
type MetricSample struct {
SampledAt time.Time `json:"sampled_at"`
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
Disk float64 `json:"disk"`
RX int64 `json:"rx"`
TX int64 `json:"tx"`
Connections int `json:"connections"`
Bans int `json:"bans"`
}
type AgentLogEntry struct {
ServerID string `json:"server_id"`
Timestamp time.Time `json:"timestamp"`
Unit string `json:"unit"`
Priority int `json:"priority"`
Message string `json:"message"`
BootID *string `json:"boot_id,omitempty"`
PID *int `json:"pid,omitempty"`
}
type MetricPoint struct {
ServerID string
ServerName string
Timestamp time.Time
Value float64
}
type IntegrationLog struct {
ServerID string
ServerName string
Timestamp time.Time
Unit string
Priority int
Message string
}
func OpenStore(ctx context.Context, databaseURL string) (*Store, error) {
var lastErr error
for attempt := 0; attempt < 30; attempt++ {
pool, err := pgxpool.New(ctx, databaseURL)
if err == nil {
err = pool.Ping(ctx)
}
if err == nil {
store := &Store{pool: pool}
if _, err = pool.Exec(ctx, schema); err == nil {
return store, nil
}
pool.Close()
}
lastErr = err
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Second):
}
}
return nil, fmt.Errorf("connect/migrate database: %w", lastErr)
}
func (store *Store) Close() { store.pool.Close() }
func (store *Store) Bootstrap(ctx context.Context, config Config) error {
var count int
if err := store.pool.QueryRow(ctx, "SELECT count(*) FROM users").Scan(&count); err != nil {
return err
}
if count == 0 {
if !validUsername(config.AdminUsername) || config.AdminPasswordFile == "" {
return errors.New("first start requires INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD_FILE")
}
password, err := os.ReadFile(config.AdminPasswordFile)
if err != nil {
return fmt.Errorf("read initial admin password: %w", err)
}
hash, err := HashPassword(strings.TrimSpace(string(password)))
if err != nil {
return fmt.Errorf("initial admin password: %w", err)
}
if _, err = store.pool.Exec(ctx,
"INSERT INTO users(id, username, password_hash, role) VALUES($1,$2,$3,$4)",
newID(), config.AdminUsername, hash, RoleAdministrator); err != nil {
return err
}
log.Printf("created initial administrator %q", config.AdminUsername)
}
if config.BootstrapEndpoint != "" {
_, err := store.pool.Exec(ctx, `INSERT INTO servers(id,name,agent_endpoint,credential_ref)
SELECT $1,$2,$3,$4 WHERE NOT EXISTS (SELECT 1 FROM servers)`,
newID(), config.BootstrapServerName, config.BootstrapEndpoint, config.BootstrapCredential)
return err
}
return nil
}
func (store *Store) LoginUser(ctx context.Context, username string) (User, string, bool, error) {
var user User
var hash string
var disabled bool
err := store.pool.QueryRow(ctx,
"SELECT id,username,role,password_hash,disabled FROM users WHERE username=$1", username).
Scan(&user.ID, &user.Username, &user.Role, &hash, &disabled)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, "", false, nil
}
return user, hash, disabled, err
}
func (store *Store) CreateSession(ctx context.Context, userID, sourceIP string, ttl time.Duration) (string, string, error) {
session, sessionHash, err := randomToken()
if err != nil {
return "", "", err
}
csrf, csrfHash, err := randomToken()
if err != nil {
return "", "", err
}
_, err = store.pool.Exec(ctx,
"INSERT INTO sessions(id_hash,user_id,csrf_hash,source_ip,expires_at) VALUES($1,$2,$3,$4,$5)",
sessionHash, userID, csrfHash, sourceIP, time.Now().Add(ttl))
return session, csrf, err
}
func (store *Store) SessionUser(ctx context.Context, token string) (User, []byte, error) {
var user User
var csrfHash []byte
err := store.pool.QueryRow(ctx, `SELECT u.id,u.username,u.role,s.csrf_hash
FROM sessions s JOIN users u ON u.id=s.user_id
WHERE s.id_hash=$1 AND s.expires_at>now() AND NOT u.disabled`, tokenHash(token)).
Scan(&user.ID, &user.Username, &user.Role, &csrfHash)
return user, csrfHash, err
}
func (store *Store) DeleteSession(ctx context.Context, token string) error {
_, err := store.pool.Exec(ctx, "DELETE FROM sessions WHERE id_hash=$1", tokenHash(token))
return err
}
func (store *Store) Servers(ctx context.Context) ([]Server, error) {
rows, err := store.pool.Query(ctx, `SELECT id,name,hostname,agent_endpoint,credential_ref,tags,status,last_seen,created_at
FROM servers ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
servers := []Server{}
for rows.Next() {
var server Server
if err = rows.Scan(&server.ID, &server.Name, &server.Hostname, &server.AgentEndpoint,
&server.CredentialRef, &server.Tags, &server.Status, &server.LastSeen, &server.CreatedAt); err != nil {
return nil, err
}
servers = append(servers, server)
}
return servers, rows.Err()
}
func (store *Store) Server(ctx context.Context, id string) (Server, error) {
var server Server
err := store.pool.QueryRow(ctx, `SELECT id,name,hostname,agent_endpoint,credential_ref,tags,status,last_seen,created_at
FROM servers WHERE id=$1`, id).Scan(&server.ID, &server.Name, &server.Hostname, &server.AgentEndpoint,
&server.CredentialRef, &server.Tags, &server.Status, &server.LastSeen, &server.CreatedAt)
return server, err
}
func (store *Store) CreateServer(ctx context.Context, server Server) error {
_, err := store.pool.Exec(ctx, `INSERT INTO servers(id,name,hostname,agent_endpoint,credential_ref,tags)
VALUES($1,$2,$3,$4,$5,$6)`, server.ID, server.Name, server.Hostname,
server.AgentEndpoint, server.CredentialRef, server.Tags)
return err
}
func (store *Store) Audit(ctx context.Context, user *User, serverID, action, target string,
before, after any, result, sourceIP string) {
var userID any
username := "anonymous"
if user != nil {
userID, username = user.ID, user.Username
}
beforeJSON, _ := json.Marshal(before)
afterJSON, _ := json.Marshal(after)
if before == nil {
beforeJSON = nil
}
if after == nil {
afterJSON = nil
}
if _, err := store.pool.Exec(ctx, `INSERT INTO audit_events
(user_id,username,server_id,action,target,before_state,after_state,result,source_ip)
VALUES($1,$2,NULLIF($3,''),$4,$5,$6,$7,$8,$9)`, userID, username, serverID,
action, target, beforeJSON, afterJSON, result, sourceIP); err != nil {
log.Printf("write audit event: %v", err)
}
}
func (store *Store) AuditEvents(ctx context.Context, limit int) ([]AuditEvent, error) {
rows, err := store.pool.Query(ctx, `SELECT id,username,server_id,action,target,
COALESCE(before_state,'null'::jsonb),COALESCE(after_state,'null'::jsonb),result,source_ip,created_at
FROM audit_events ORDER BY created_at DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
events := []AuditEvent{}
for rows.Next() {
var event AuditEvent
if err = rows.Scan(&event.ID, &event.Username, &event.ServerID, &event.Action, &event.Target,
&event.Before, &event.After, &event.Result, &event.SourceIP, &event.CreatedAt); err != nil {
return nil, err
}
events = append(events, event)
}
return events, rows.Err()
}
func (store *Store) Metrics(ctx context.Context, serverID string, limit int) ([]MetricSample, error) {
rows, err := store.pool.Query(ctx, `SELECT sampled_at,cpu,memory,disk,rx,tx,connections,bans
FROM metric_samples WHERE server_id=$1 ORDER BY sampled_at DESC LIMIT $2`, serverID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
samples := []MetricSample{}
for rows.Next() {
var sample MetricSample
if err = rows.Scan(&sample.SampledAt, &sample.CPU, &sample.Memory, &sample.Disk,
&sample.RX, &sample.TX, &sample.Connections, &sample.Bans); err != nil {
return nil, err
}
samples = append(samples, sample)
}
return samples, rows.Err()
}
func (store *Store) Logs(ctx context.Context, serverID string, limit int) ([]AgentLogEntry, error) {
rows, err := store.pool.Query(ctx, `SELECT server_id,event_at,unit,priority,message,boot_id,pid
FROM agent_log_entries WHERE server_id=$1 ORDER BY event_at DESC LIMIT $2`, serverID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
entries := []AgentLogEntry{}
for rows.Next() {
var entry AgentLogEntry
if err = rows.Scan(&entry.ServerID, &entry.Timestamp, &entry.Unit, &entry.Priority,
&entry.Message, &entry.BootID, &entry.PID); err != nil {
return nil, err
}
entries = append(entries, entry)
}
return entries, rows.Err()
}
func (store *Store) MetricSeries(ctx context.Context) ([]map[string]string, error) {
rows, err := store.pool.Query(ctx, "SELECT id,name FROM servers ORDER BY id LIMIT 1000")
if err != nil {
return nil, err
}
defer rows.Close()
servers := [][2]string{}
for rows.Next() {
var server [2]string
if err = rows.Scan(&server[0], &server[1]); err != nil {
return nil, err
}
servers = append(servers, server)
}
if err = rows.Err(); err != nil {
return nil, err
}
names := make([]string, 0, len(metricColumns))
for name := range metricColumns {
names = append(names, name)
}
sort.Strings(names)
series := make([]map[string]string, 0, len(servers)*len(names))
for _, server := range servers {
for _, name := range names {
series = append(series, map[string]string{
"__name__": name, "server_id": server[0], "server_name": server[1],
})
}
}
return series, nil
}
func (store *Store) MetricLabelValues(ctx context.Context, label string) ([]string, error) {
column := ""
switch label {
case "server_id":
column = "id"
case "server_name":
column = "name"
default:
return nil, errors.New("unsupported metric label")
}
rows, err := store.pool.Query(ctx, "SELECT "+column+" FROM servers ORDER BY "+column+" LIMIT 10000")
if err != nil {
return nil, err
}
defer rows.Close()
values := []string{}
for rows.Next() {
var value string
if err = rows.Scan(&value); err != nil {
return nil, err
}
values = append(values, value)
}
return values, rows.Err()
}
func (store *Store) InstantMetric(ctx context.Context, query metricQuery, at time.Time) ([]MetricPoint, error) {
column, found := metricColumns[query.Name]
if !found {
return nil, errors.New("unsupported metric")
}
statement := fmt.Sprintf(`SELECT DISTINCT ON (m.server_id)
m.server_id,s.name,m.sampled_at,m.%s::double precision
FROM metric_samples m JOIN servers s ON s.id=m.server_id
WHERE m.sampled_at <= $1 AND ($2='' OR s.id=$2) AND ($3='' OR s.name=$3)
ORDER BY m.server_id,m.sampled_at DESC LIMIT 10000`, column)
rows, err := store.pool.Query(ctx, statement, at, query.ServerID, query.ServerName)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMetricPoints(rows)
}
func (store *Store) RangeMetric(ctx context.Context, query metricQuery, start, end time.Time, step int) ([]MetricPoint, error) {
column, found := metricColumns[query.Name]
if !found {
return nil, errors.New("unsupported metric")
}
statement := fmt.Sprintf(`SELECT m.server_id,s.name,
to_timestamp(floor(extract(epoch FROM m.sampled_at)/$3)*$3) AS bucket,
avg(m.%s::double precision)
FROM metric_samples m JOIN servers s ON s.id=m.server_id
WHERE m.sampled_at BETWEEN $1 AND $2 AND ($4='' OR s.id=$4) AND ($5='' OR s.name=$5)
GROUP BY m.server_id,s.name,bucket ORDER BY m.server_id,bucket LIMIT 100000`, column)
rows, err := store.pool.Query(ctx, statement, start, end, step, query.ServerID, query.ServerName)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMetricPoints(rows)
}
func scanMetricPoints(rows pgx.Rows) ([]MetricPoint, error) {
points := []MetricPoint{}
for rows.Next() {
var point MetricPoint
if err := rows.Scan(&point.ServerID, &point.ServerName, &point.Timestamp, &point.Value); err != nil {
return nil, err
}
points = append(points, point)
}
return points, rows.Err()
}
func (store *Store) LogLabelValues(ctx context.Context, label string) ([]string, error) {
column := ""
switch label {
case "server_id":
column = "s.id"
case "server_name":
column = "s.name"
case "unit":
column = "l.unit"
case "priority":
column = "l.priority::text"
default:
return nil, errors.New("unsupported log label")
}
statement := `SELECT DISTINCT ` + column + ` AS value FROM agent_log_entries l
JOIN servers s ON s.id=l.server_id ORDER BY value LIMIT 10000`
rows, err := store.pool.Query(ctx, statement)
if err != nil {
return nil, err
}
defer rows.Close()
values := []string{}
for rows.Next() {
var value string
if err = rows.Scan(&value); err != nil {
return nil, err
}
values = append(values, value)
}
return values, rows.Err()
}
func (store *Store) LogSeries(ctx context.Context) ([]map[string]string, error) {
rows, err := store.pool.Query(ctx, `SELECT DISTINCT s.id,s.name,l.unit,l.priority
FROM agent_log_entries l JOIN servers s ON s.id=l.server_id
ORDER BY s.id,l.unit,l.priority LIMIT 10000`)
if err != nil {
return nil, err
}
defer rows.Close()
series := []map[string]string{}
for rows.Next() {
var serverID, serverName, unit string
var priority int
if err = rows.Scan(&serverID, &serverName, &unit, &priority); err != nil {
return nil, err
}
series = append(series, map[string]string{
"server_id": serverID, "server_name": serverName, "unit": unit,
"priority": strconv.Itoa(priority),
})
}
return series, rows.Err()
}
func (store *Store) IntegrationLogs(ctx context.Context, start, end time.Time, limit int, direction string) ([]IntegrationLog, error) {
order := "DESC"
if direction == "forward" {
order = "ASC"
}
statement := `SELECT l.server_id,s.name,l.event_at,l.unit,l.priority,l.message
FROM agent_log_entries l JOIN servers s ON s.id=l.server_id
WHERE l.event_at BETWEEN $1 AND $2 ORDER BY l.event_at ` + order + ` LIMIT $3`
rows, err := store.pool.Query(ctx, statement, start, end, limit)
if err != nil {
return nil, err
}
defer rows.Close()
entries := []IntegrationLog{}
for rows.Next() {
var entry IntegrationLog
if err = rows.Scan(&entry.ServerID, &entry.ServerName, &entry.Timestamp, &entry.Unit,
&entry.Priority, &entry.Message); err != nil {
return nil, err
}
entries = append(entries, entry)
}
return entries, rows.Err()
}
func newID() string {
var value [16]byte
if _, err := rand.Read(value[:]); err != nil {
panic("system random source unavailable")
}
value[6] = (value[6] & 0x0f) | 0x40
value[8] = (value[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
value[0:4], value[4:6], value[6:8], value[8:10], value[10:16])
}

View File

@@ -0,0 +1,17 @@
module github.com/vps-control/vps-control/control-plane/backend
go 1.20
require (
github.com/jackc/pgx/v5 v5.5.5
golang.org/x/crypto v0.23.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
)

View File

@@ -0,0 +1,27 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -0,0 +1,53 @@
package main
import (
"sync"
"time"
)
type loginEntry struct {
window time.Time
count int
seen time.Time
}
type LoginLimiter struct {
mu sync.Mutex
entries map[string]loginEntry
limit int
window time.Duration
maxKeys int
}
func NewLoginLimiter(limit int, window time.Duration) *LoginLimiter {
return &LoginLimiter{entries: make(map[string]loginEntry), limit: limit, window: window, maxKeys: 10000}
}
func (limiter *LoginLimiter) Allow(key string, now time.Time) bool {
limiter.mu.Lock()
defer limiter.mu.Unlock()
_, known := limiter.entries[key]
if !known && len(limiter.entries) >= limiter.maxKeys {
cutoff := now.Add(-2 * limiter.window)
for item, entry := range limiter.entries {
if entry.seen.Before(cutoff) {
delete(limiter.entries, item)
}
}
if len(limiter.entries) >= limiter.maxKeys {
return false
}
}
entry := limiter.entries[key]
if entry.window.IsZero() || now.Sub(entry.window) >= limiter.window {
entry.window, entry.count = now, 0
}
entry.seen = now
if entry.count >= limiter.limit {
limiter.entries[key] = entry
return false
}
entry.count++
limiter.entries[key] = entry
return true
}

View File

@@ -0,0 +1,52 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
config, err := LoadConfig()
if err != nil {
log.Fatalf("configuration: %v", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
store, err := OpenStore(ctx, config.DatabaseURL)
if err != nil {
log.Fatalf("database: %v", err)
}
defer store.Close()
if err = store.Bootstrap(ctx, config); err != nil {
log.Fatalf("bootstrap: %v", err)
}
agent := NewAgentClient(config.CredentialsDir)
app, err := NewApp(store, agent, config)
if err != nil {
log.Fatalf("application: %v", err)
}
server := &http.Server{
Addr: config.ListenAddress, Handler: app,
ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 16 << 10,
}
poller := &Poller{store: store, agent: agent, metricRetention: config.MetricsRetention, logRetention: config.LogsRetention}
go poller.Run(ctx)
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
log.Printf("control-plane API listening on %s", config.ListenAddress)
if err = server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("HTTP server: %v", err)
}
}

View File

@@ -0,0 +1,527 @@
package main
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"math"
"net/http"
"os"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
var metricSelector = regexp.MustCompile(`^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?$`)
var logSelector = regexp.MustCompile(`^([a-z_][a-z0-9_]*)\s*(=|=~)\s*"([^"]*)"$`)
var metricColumns = map[string]string{
"vps_control_cpu_usage_percent": "cpu",
"vps_control_memory_usage_percent": "memory",
"vps_control_disk_usage_percent": "disk",
"vps_control_network_rx_bytes": "rx",
"vps_control_network_tx_bytes": "tx",
"vps_control_connections": "connections",
"vps_control_bans": "bans",
}
type metricQuery struct {
Name string
ServerID string
ServerName string
}
type logMatcher struct {
Label string
Exact string
Regex *regexp.Regexp
}
func readIntegrationToken(path string) (string, error) {
info, err := os.Lstat(path)
if err != nil {
return "", fmt.Errorf("read integration token metadata: %w", err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o022 != 0 {
return "", errors.New("integration token must be a non-writable regular file")
}
if info.Size() > 4096 {
return "", errors.New("integration token file is too large")
}
value, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read integration token: %w", err)
}
token := strings.TrimSpace(string(value))
if len(token) < 32 || len(token) > 256 || strings.IndexFunc(token, func(character rune) bool {
return character < 0x21 || character == 0x7f
}) >= 0 {
return "", errors.New("integration token must contain 32-256 visible bytes")
}
return token, nil
}
func (app *App) integration(writer http.ResponseWriter, request *http.Request) {
if len(app.integrationTokenHash) == 0 {
writeError(writer, http.StatusNotFound, "integration API is disabled", "not_found")
return
}
if !app.integrationLimiter.Allow(app.clientIP(request), time.Now()) {
writeError(writer, http.StatusTooManyRequests, "integration request rate exceeded", "rate_limited")
return
}
ctx, cancel := context.WithTimeout(request.Context(), 10*time.Second)
defer cancel()
request = request.WithContext(ctx)
provided := strings.TrimPrefix(request.Header.Get("Authorization"), "Bearer ")
if len(provided) > 256 || subtle.ConstantTimeCompare(tokenHash(provided), app.integrationTokenHash) != 1 {
writeError(writer, http.StatusUnauthorized, "integration authentication required", "unauthorized")
return
}
if strings.HasPrefix(request.URL.Path, "/integrations/prometheus/") {
app.prometheusAPI(writer, request)
return
}
if strings.HasPrefix(request.URL.Path, "/integrations/loki/") {
app.lokiAPI(writer, request)
return
}
writeError(writer, http.StatusNotFound, "not found", "not_found")
}
func (app *App) prometheusAPI(writer http.ResponseWriter, request *http.Request) {
path := strings.TrimPrefix(request.URL.Path, "/integrations/prometheus")
if request.Method != http.MethodGet && request.Method != http.MethodPost {
writePrometheusError(writer, "bad_data", "method not allowed", http.StatusMethodNotAllowed)
return
}
if request.Method == http.MethodPost {
request.Body = http.MaxBytesReader(writer, request.Body, maxRequestBody)
}
switch path {
case "/-/healthy", "/-/ready":
writeJSON(writer, 200, map[string]string{"status": "success"})
case "/api/v1/query":
app.prometheusQuery(writer, request, false)
case "/api/v1/query_range":
app.prometheusQuery(writer, request, true)
case "/api/v1/labels":
writeJSON(writer, 200, map[string]any{"status": "success", "data": []string{"__name__", "server_id", "server_name"}})
case "/api/v1/label/__name__/values":
names := make([]string, 0, len(metricColumns))
for name := range metricColumns {
names = append(names, name)
}
sort.Strings(names)
writeJSON(writer, 200, map[string]any{"status": "success", "data": names})
case "/api/v1/label/server_id/values", "/api/v1/label/server_name/values":
label := strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/label/"), "/values")
values, err := app.store.MetricLabelValues(request.Context(), label)
if err != nil {
writePrometheusError(writer, "internal", "query failed", 500)
return
}
writeJSON(writer, 200, map[string]any{"status": "success", "data": values})
case "/api/v1/metadata":
metadata := map[string][]map[string]string{}
for name := range metricColumns {
metadata[name] = []map[string]string{{"type": "gauge", "help": "VPS Control sampled metric", "unit": ""}}
}
writeJSON(writer, 200, map[string]any{"status": "success", "data": metadata})
case "/api/v1/series":
series, err := app.store.MetricSeries(request.Context())
if err != nil {
writePrometheusError(writer, "internal", "query failed", 500)
return
}
writeJSON(writer, 200, map[string]any{"status": "success", "data": series})
case "/api/v1/status/buildinfo":
writeJSON(writer, 200, map[string]any{"status": "success", "data": map[string]string{"version": "vps-control-compat-v1"}})
default:
writePrometheusError(writer, "bad_data", "unsupported Prometheus API operation", 404)
}
}
func (app *App) prometheusQuery(writer http.ResponseWriter, request *http.Request, ranged bool) {
if err := request.ParseForm(); err != nil {
writePrometheusError(writer, "bad_data", "invalid form", 400)
return
}
rawQuery := request.Form.Get("query")
if !ranged && rawQuery == "1+1" {
at, err := parseTimestamp(request.Form.Get("time"), time.Now())
if err != nil {
writePrometheusError(writer, "bad_data", "invalid time", 400)
return
}
writeJSON(writer, 200, map[string]any{"status": "success", "data": map[string]any{
"resultType": "scalar", "result": []any{float64(at.UnixNano()) / 1e9, "2"},
}})
return
}
query, err := parseMetricQuery(rawQuery)
if err != nil {
writePrometheusError(writer, "bad_data", err.Error(), 400)
return
}
timeParameter := "time"
if ranged {
timeParameter = "end"
}
end, err := parseTimestamp(request.Form.Get(timeParameter), time.Now())
if err != nil {
writePrometheusError(writer, "bad_data", "invalid "+timeParameter, 400)
return
}
if !ranged {
points, queryErr := app.store.InstantMetric(request.Context(), query, end)
if queryErr != nil {
writePrometheusError(writer, "internal", "query failed", 500)
return
}
writeJSON(writer, 200, prometheusVector(query.Name, points))
return
}
start, err := parseTimestamp(request.Form.Get("start"), end.Add(-time.Hour))
if err != nil || start.After(end) || end.Sub(start) > 31*24*time.Hour {
writePrometheusError(writer, "bad_data", "range must be positive and at most 31 days", 400)
return
}
step, err := parseStep(request.Form.Get("step"))
if err != nil {
writePrometheusError(writer, "bad_data", "invalid step", 400)
return
}
if int64(end.Sub(start)/time.Second)/int64(step) > 10_000 {
writePrometheusError(writer, "bad_data", "range contains more than 10000 points per series", 400)
return
}
points, queryErr := app.store.RangeMetric(request.Context(), query, start, end, step)
if queryErr != nil {
writePrometheusError(writer, "internal", "query failed", 500)
return
}
writeJSON(writer, 200, prometheusMatrix(query.Name, points))
}
func (app *App) lokiAPI(writer http.ResponseWriter, request *http.Request) {
path := strings.TrimPrefix(request.URL.Path, "/integrations/loki")
if request.Method != http.MethodGet {
writeError(writer, 405, "method not allowed", "method_not_allowed")
return
}
switch path {
case "/ready":
writer.WriteHeader(http.StatusOK)
case "/loki/api/v1/query_range":
app.lokiQueryRange(writer, request)
case "/loki/api/v1/query":
if request.URL.Query().Get("query") != "vector(1)+vector(1)" {
writeError(writer, 400, "only the datasource health expression is supported for instant queries", "invalid_query")
return
}
at, err := parseLokiTimestamp(request.URL.Query().Get("time"), time.Now())
if err != nil {
writeError(writer, 400, "invalid time", "invalid_query")
return
}
writeJSON(writer, 200, map[string]any{"status": "success", "data": map[string]any{
"resultType": "vector", "result": []any{map[string]any{
"metric": map[string]string{}, "value": []any{float64(at.UnixNano()) / 1e9, "2"},
}},
}})
case "/loki/api/v1/labels":
writeJSON(writer, 200, map[string]any{"status": "success", "data": []string{"server_id", "server_name", "unit", "priority"}})
case "/loki/api/v1/label/server_id/values", "/loki/api/v1/label/server_name/values", "/loki/api/v1/label/unit/values", "/loki/api/v1/label/priority/values":
label := strings.TrimSuffix(strings.TrimPrefix(path, "/loki/api/v1/label/"), "/values")
values, err := app.store.LogLabelValues(request.Context(), label)
if err != nil {
writeError(writer, 500, "query failed", "internal_error")
return
}
writeJSON(writer, 200, map[string]any{"status": "success", "data": values})
case "/loki/api/v1/series":
series, err := app.store.LogSeries(request.Context())
if err != nil {
writeError(writer, 500, "query failed", "internal_error")
return
}
writeJSON(writer, 200, map[string]any{"status": "success", "data": series})
default:
writeError(writer, 404, "unsupported Loki API operation", "not_found")
}
}
func (app *App) lokiQueryRange(writer http.ResponseWriter, request *http.Request) {
matchers, err := parseLogQuery(request.URL.Query().Get("query"))
if err != nil {
writeError(writer, 400, err.Error(), "invalid_query")
return
}
end, err := parseLokiTimestamp(request.URL.Query().Get("end"), time.Now())
if err != nil {
writeError(writer, 400, "invalid end", "invalid_query")
return
}
start, err := parseLokiTimestamp(request.URL.Query().Get("start"), end.Add(-time.Hour))
if err != nil || start.After(end) || end.Sub(start) > 31*24*time.Hour {
writeError(writer, 400, "range must be positive and at most 31 days", "invalid_query")
return
}
limit := parseInt(request.URL.Query().Get("limit"), 1000)
if limit < 1 {
limit = 1
}
if limit > 5000 {
limit = 5000
}
direction := request.URL.Query().Get("direction")
if direction == "" {
direction = "backward"
}
if direction != "backward" && direction != "forward" {
writeError(writer, 400, "invalid direction", "invalid_query")
return
}
entries, queryErr := app.store.IntegrationLogs(request.Context(), start, end, 20_000, direction)
if queryErr != nil {
writeError(writer, 500, "query failed", "internal_error")
return
}
streams := buildLokiStreams(entries, matchers, limit, direction)
writeJSON(writer, 200, map[string]any{"status": "success", "data": map[string]any{
"resultType": "streams", "result": streams, "stats": map[string]any{},
}})
}
func parseMetricQuery(raw string) (metricQuery, error) {
if len(raw) > 512 {
return metricQuery{}, errors.New("query is too long")
}
match := metricSelector.FindStringSubmatch(strings.TrimSpace(raw))
if match == nil {
return metricQuery{}, errors.New("only direct metric selectors are supported")
}
if _, found := metricColumns[match[1]]; !found {
return metricQuery{}, errors.New("metric is not supported")
}
result := metricQuery{Name: match[1]}
if match[2] == "" {
return result, nil
}
for _, selector := range strings.Split(match[2], ",") {
parts := strings.SplitN(strings.TrimSpace(selector), "=", 2)
if len(parts) != 2 {
return metricQuery{}, errors.New("invalid label selector")
}
value, err := strconv.Unquote(strings.TrimSpace(parts[1]))
if err != nil || len(value) > 128 {
return metricQuery{}, errors.New("invalid label value")
}
switch strings.TrimSpace(parts[0]) {
case "server_id":
result.ServerID = value
case "server_name":
result.ServerName = value
default:
return metricQuery{}, errors.New("unsupported label")
}
}
return result, nil
}
func parseLogQuery(raw string) ([]logMatcher, error) {
if len(raw) < 2 || len(raw) > 512 || raw[0] != '{' || raw[len(raw)-1] != '}' {
return nil, errors.New("a bounded stream selector is required")
}
content := strings.TrimSpace(raw[1 : len(raw)-1])
if content == "" {
return nil, errors.New("at least one stream matcher is required")
}
matchers := []logMatcher{}
for _, part := range strings.Split(content, ",") {
match := logSelector.FindStringSubmatch(strings.TrimSpace(part))
if match == nil || !allowedLogLabel(match[1]) || len(match[3]) > 128 {
return nil, errors.New("unsupported stream matcher")
}
item := logMatcher{Label: match[1]}
if match[2] == "=" {
item.Exact = match[3]
} else {
compiled, err := regexp.Compile("^(?:" + match[3] + ")$")
if err != nil {
return nil, errors.New("invalid matcher regex")
}
item.Regex = compiled
}
matchers = append(matchers, item)
}
return matchers, nil
}
func allowedLogLabel(label string) bool {
return label == "server_id" || label == "server_name" || label == "unit" || label == "priority"
}
func parseTimestamp(raw string, fallback time.Time) (time.Time, error) {
if raw == "" {
return fallback, nil
}
value, err := strconv.ParseFloat(raw, 64)
if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {
return time.Time{}, errors.New("invalid timestamp")
}
seconds, fraction := math.Modf(value)
parsed := time.Unix(int64(seconds), int64(fraction*1e9))
if !validIntegrationTime(parsed) {
return time.Time{}, errors.New("timestamp out of range")
}
return parsed, nil
}
func parseLokiTimestamp(raw string, fallback time.Time) (time.Time, error) {
if raw == "" {
return fallback, nil
}
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
var parsed time.Time
if value > 1_000_000_000_000 {
parsed = time.Unix(0, value)
} else {
parsed = time.Unix(value, 0)
}
if !validIntegrationTime(parsed) {
return time.Time{}, errors.New("timestamp out of range")
}
return parsed, nil
}
parsed, err := time.Parse(time.RFC3339Nano, raw)
if err != nil || !validIntegrationTime(parsed) {
return time.Time{}, errors.New("invalid timestamp")
}
return parsed, nil
}
func validIntegrationTime(value time.Time) bool {
return !value.Before(time.Unix(0, 0)) && value.Before(time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC))
}
func parseStep(raw string) (int, error) {
if raw == "" {
return 60, nil
}
if duration, err := time.ParseDuration(raw); err == nil {
seconds := int(duration.Seconds())
if seconds >= 5 && seconds <= 86400 {
return seconds, nil
}
}
value, err := strconv.Atoi(raw)
if err != nil || value < 5 || value > 86400 {
return 0, errors.New("step out of range")
}
return value, nil
}
func writePrometheusError(writer http.ResponseWriter, kind, message string, status int) {
writeJSON(writer, status, map[string]any{"status": "error", "errorType": kind, "error": message})
}
func prometheusVector(name string, points []MetricPoint) map[string]any {
result := make([]map[string]any, 0, len(points))
for _, point := range points {
result = append(result, map[string]any{
"metric": metricLabels(name, point),
"value": []any{float64(point.Timestamp.UnixNano()) / 1e9, strconv.FormatFloat(point.Value, 'f', -1, 64)},
})
}
return map[string]any{"status": "success", "data": map[string]any{"resultType": "vector", "result": result}}
}
func prometheusMatrix(name string, points []MetricPoint) map[string]any {
type series struct {
Metric map[string]string `json:"metric"`
Values [][]any `json:"values"`
}
byServer := map[string]*series{}
order := []string{}
for _, point := range points {
item := byServer[point.ServerID]
if item == nil {
item = &series{Metric: metricLabels(name, point)}
byServer[point.ServerID] = item
order = append(order, point.ServerID)
}
item.Values = append(item.Values, []any{
float64(point.Timestamp.UnixNano()) / 1e9,
strconv.FormatFloat(point.Value, 'f', -1, 64),
})
}
result := make([]*series, 0, len(order))
for _, id := range order {
result = append(result, byServer[id])
}
return map[string]any{"status": "success", "data": map[string]any{"resultType": "matrix", "result": result}}
}
func metricLabels(name string, point MetricPoint) map[string]string {
return map[string]string{"__name__": name, "server_id": point.ServerID, "server_name": point.ServerName}
}
type lokiStream struct {
Stream map[string]string `json:"stream"`
Values [][2]string `json:"values"`
}
func buildLokiStreams(entries []IntegrationLog, matchers []logMatcher, limit int, direction string) []lokiStream {
groups := map[string]*lokiStream{}
count := 0
for _, entry := range entries {
labels := map[string]string{
"server_id": entry.ServerID, "server_name": entry.ServerName,
"unit": entry.Unit, "priority": strconv.Itoa(entry.Priority),
}
if !logMatches(labels, matchers) {
continue
}
key := entry.ServerID + "\x00" + entry.Unit + "\x00" + strconv.Itoa(entry.Priority)
stream := groups[key]
if stream == nil {
stream = &lokiStream{Stream: labels}
groups[key] = stream
}
stream.Values = append(stream.Values, [2]string{strconv.FormatInt(entry.Timestamp.UnixNano(), 10), entry.Message})
count++
if count >= limit {
break
}
}
result := make([]lokiStream, 0, len(groups))
for _, stream := range groups {
if direction != "backward" {
sort.Slice(stream.Values, func(i, j int) bool { return stream.Values[i][0] < stream.Values[j][0] })
}
result = append(result, *stream)
}
sort.Slice(result, func(i, j int) bool {
return result[i].Stream["server_id"]+result[i].Stream["unit"] < result[j].Stream["server_id"]+result[j].Stream["unit"]
})
return result
}
func logMatches(labels map[string]string, matchers []logMatcher) bool {
for _, matcher := range matchers {
value := labels[matcher.Label]
if matcher.Regex != nil {
if !matcher.Regex.MatchString(value) {
return false
}
} else if value != matcher.Exact {
return false
}
}
return true
}

View File

@@ -0,0 +1,101 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestParseMetricQuery(t *testing.T) {
query, err := parseMetricQuery(`vps_control_cpu_usage_percent{server_id="srv-1",server_name="edge"}`)
if err != nil || query.Name != "vps_control_cpu_usage_percent" || query.ServerID != "srv-1" || query.ServerName != "edge" {
t.Fatalf("unexpected metric query: %#v, %v", query, err)
}
for _, invalid := range []string{
`process_cpu_seconds_total`,
`vps_control_cpu_usage_percent{job=~".*"}`,
`vps_control_cpu_usage_percent or vector(1)`,
} {
if _, err = parseMetricQuery(invalid); err == nil {
t.Fatalf("accepted unsupported PromQL %q", invalid)
}
}
}
func TestParseLogQueryIsAllowListedAndAnchored(t *testing.T) {
matchers, err := parseLogQuery(`{server_name="edge",unit=~"(nginx|ssh)\.service"}`)
if err != nil || len(matchers) != 2 {
t.Fatalf("unexpected log selector: %#v, %v", matchers, err)
}
if logMatches(map[string]string{"server_name": "edge", "unit": "prefix-nginx.service"}, matchers) {
t.Fatal("regex matcher was not anchored")
}
if !logMatches(map[string]string{"server_name": "edge", "unit": "nginx.service"}, matchers) {
t.Fatal("valid labels did not match")
}
for _, invalid := range []string{`{}`, `{message="secret"}`, `{unit!="ssh.service"}`, `{unit="x"} |= "error"`} {
if _, err = parseLogQuery(invalid); err == nil {
t.Fatalf("accepted unsupported LogQL %q", invalid)
}
}
}
func TestBuildLokiStreamsIsBounded(t *testing.T) {
now := time.Now()
entries := []IntegrationLog{
{ServerID: "1", ServerName: "edge", Unit: "nginx.service", Priority: 6, Timestamp: now, Message: "new"},
{ServerID: "1", ServerName: "edge", Unit: "nginx.service", Priority: 6, Timestamp: now.Add(-time.Second), Message: "old"},
}
streams := buildLokiStreams(entries, []logMatcher{{Label: "server_id", Exact: "1"}}, 1, "backward")
if len(streams) != 1 || len(streams[0].Values) != 1 || streams[0].Values[0][1] != "new" {
t.Fatalf("unexpected bounded streams: %#v", streams)
}
}
func TestReadIntegrationTokenPermissionsAndLength(t *testing.T) {
path := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(path, []byte("0123456789abcdef0123456789abcdef\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := readIntegrationToken(path); err != nil {
t.Fatalf("valid token rejected: %v", err)
}
if err := os.Chmod(path, 0o622); err != nil {
t.Fatal(err)
}
if _, err := readIntegrationToken(path); err == nil {
t.Fatal("group-writable token accepted")
}
}
func TestTimestampBounds(t *testing.T) {
if _, err := parseTimestamp("NaN", time.Now()); err == nil {
t.Fatal("NaN timestamp accepted")
}
if _, err := parseLokiTimestamp("4102444800000000000", time.Now()); err == nil {
t.Fatal("2100 timestamp accepted")
}
}
func TestGrafanaDatasourceHealthExpressions(t *testing.T) {
app := &App{}
prometheusRequest := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("query=1%2B1&time=4"))
prometheusRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
prometheusResponse := httptest.NewRecorder()
app.prometheusQuery(prometheusResponse, prometheusRequest, false)
if prometheusResponse.Code != http.StatusOK || !strings.Contains(prometheusResponse.Body.String(), `"resultType":"scalar"`) {
t.Fatalf("unexpected Prometheus health response: %d %s", prometheusResponse.Code, prometheusResponse.Body.String())
}
lokiRequest := httptest.NewRequest(http.MethodGet,
"/integrations/loki/loki/api/v1/query?query=vector%281%29%2Bvector%281%29&time=4000000000", nil)
lokiResponse := httptest.NewRecorder()
app.lokiAPI(lokiResponse, lokiRequest)
if lokiResponse.Code != http.StatusOK || !strings.Contains(lokiResponse.Body.String(), `"resultType":"vector"`) {
t.Fatalf("unexpected Loki health response: %d %s", lokiResponse.Code, lokiResponse.Body.String())
}
}

View File

@@ -0,0 +1,254 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"regexp"
"sync"
"time"
)
var bearerSecret = regexp.MustCompile(`(?i)(authorization\s*[:=]\s*bearer\s+)[^\s,;&]+`)
var namedSecret = regexp.MustCompile(`(?i)((?:password|passwd|token|secret|api[_-]?key)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;&]+)`)
type Poller struct {
store *Store
agent *AgentClient
metricRetention time.Duration
logRetention time.Duration
}
func (poller *Poller) Run(ctx context.Context) {
pollTicker := time.NewTicker(time.Minute)
cleanupTicker := time.NewTicker(time.Hour)
defer pollTicker.Stop()
defer cleanupTicker.Stop()
poller.poll(ctx)
for {
select {
case <-ctx.Done():
return
case <-pollTicker.C:
poller.poll(ctx)
case <-cleanupTicker.C:
if _, err := poller.store.pool.Exec(ctx, "DELETE FROM metric_samples WHERE sampled_at < $1", time.Now().Add(-poller.metricRetention)); err != nil {
log.Printf("metric retention cleanup: %v", err)
}
if _, err := poller.store.pool.Exec(ctx, "DELETE FROM agent_log_entries WHERE event_at < $1", time.Now().Add(-poller.logRetention)); err != nil {
log.Printf("log retention cleanup: %v", err)
}
_, _ = poller.store.pool.Exec(ctx, "DELETE FROM sessions WHERE expires_at < now()")
}
}
}
func (poller *Poller) poll(ctx context.Context) {
servers, err := poller.store.Servers(ctx)
if err != nil {
log.Printf("poll server list: %v", err)
return
}
jobs := make(chan Server)
var wait sync.WaitGroup
workerCount := 4
if len(servers) < workerCount {
workerCount = len(servers)
}
for worker := 0; worker < workerCount; worker++ {
wait.Add(1)
go func() {
defer wait.Done()
for server := range jobs {
poller.pollServer(ctx, server)
}
}()
}
for _, server := range servers {
select {
case jobs <- server:
case <-ctx.Done():
close(jobs)
wait.Wait()
return
}
}
close(jobs)
wait.Wait()
}
func (poller *Poller) pollServer(ctx context.Context, server Server) {
statusCode, body, err := poller.agent.Do(ctx, server, http.MethodGet, "/v1/system/status", nil)
if err != nil || statusCode != http.StatusOK {
_, _ = poller.store.pool.Exec(ctx, "UPDATE servers SET status='offline',updated_at=now() WHERE id=$1", server.ID)
return
}
var status struct {
Hostname string `json:"hostname"`
CPU float64 `json:"cpu_usage_percent"`
MemoryTotal uint64 `json:"memory_total_bytes"`
MemoryUsed uint64 `json:"memory_used_bytes"`
Filesystems []struct {
Total uint64 `json:"total_bytes"`
Used uint64 `json:"used_bytes"`
} `json:"filesystems"`
}
if json.Unmarshal(body, &status) != nil {
return
}
memory := percent(status.MemoryUsed, status.MemoryTotal)
var diskTotal, diskUsed uint64
for _, filesystem := range status.Filesystems {
diskTotal += filesystem.Total
diskUsed += filesystem.Used
}
disk := percent(diskUsed, diskTotal)
var rx, tx int64
if code, payload, callErr := poller.agent.Do(ctx, server, http.MethodGet, "/v1/network/interfaces", nil); callErr == nil && code == 200 {
var interfaces []struct {
RX int64 `json:"rx_bytes"`
TX int64 `json:"tx_bytes"`
}
if json.Unmarshal(payload, &interfaces) == nil {
for _, item := range interfaces {
rx += item.RX
tx += item.TX
}
}
}
connections := 0
if code, payload, callErr := poller.agent.Do(ctx, server, http.MethodGet, "/v1/network/connections", nil); callErr == nil && code == 200 {
var items []json.RawMessage
if json.Unmarshal(payload, &items) == nil {
connections = len(items)
}
}
txDB, err := poller.store.pool.Begin(ctx)
if err != nil {
return
}
defer txDB.Rollback(ctx)
_, err = txDB.Exec(ctx, "UPDATE servers SET status='online',hostname=$2,last_seen=now(),updated_at=now() WHERE id=$1", server.ID, status.Hostname)
if err == nil {
_, err = txDB.Exec(ctx, `INSERT INTO metric_samples(server_id,cpu,memory,disk,rx,tx,connections,bans)
VALUES($1,$2,$3,$4,$5,$6,$7,0)`, server.ID, status.CPU, memory, disk, rx, tx, connections)
}
if err == nil {
err = txDB.Commit(ctx)
}
if err != nil {
log.Printf("store metric for %s: %v", server.ID, err)
}
poller.pollLogs(ctx, server)
}
func (poller *Poller) pollLogs(ctx context.Context, server Server) {
var cursor string
var since time.Time
err := poller.store.pool.QueryRow(ctx, `SELECT
COALESCE((SELECT journal_cursor FROM agent_log_entries WHERE server_id=$1 ORDER BY event_at DESC,ingested_at DESC LIMIT 1),''),
COALESCE((SELECT max(event_at) FROM agent_log_entries WHERE server_id=$1),now() - interval '2 minutes')`, server.ID).
Scan(&cursor, &since)
if err != nil {
log.Printf("log cursor for %s: %v", server.ID, err)
return
}
fallbackUsed := false
for page := 0; page < 5; page++ {
path := fmt.Sprintf("/v1/logs?since=%d&limit=200", since.Add(-time.Second).Unix())
if cursor != "" {
path = "/v1/logs?cursor=" + url.QueryEscape(cursor) + "&limit=200"
}
status, payload, callErr := poller.agent.Do(ctx, server, http.MethodGet, path, nil)
if callErr != nil || status == http.StatusForbidden || status == http.StatusNotFound {
return
}
if status != http.StatusOK {
if cursor != "" && !fallbackUsed {
cursor = ""
fallbackUsed = true
page--
continue
}
log.Printf("log poll for %s returned %d", server.ID, status)
return
}
var response struct {
Entries []struct {
Cursor string `json:"cursor"`
Timestamp uint64 `json:"timestamp_unix_micros"`
Unit string `json:"unit"`
Priority int `json:"priority"`
Message string `json:"message"`
BootID *string `json:"boot_id"`
PID *int `json:"pid"`
} `json:"entries"`
Truncated bool `json:"truncated"`
}
if err = json.Unmarshal(payload, &response); err != nil {
log.Printf("decode logs for %s: %v", server.ID, err)
return
}
if len(response.Entries) > 200 {
log.Printf("agent %s exceeded log entry bound", server.ID)
return
}
tx, beginErr := poller.store.pool.Begin(ctx)
if beginErr != nil {
return
}
now := time.Now()
for _, entry := range response.Entries {
if len(entry.Cursor) == 0 || len(entry.Cursor) > 1024 || len(entry.Unit) > 128 ||
len(entry.Message) > 65_536 || entry.Priority < 0 || entry.Priority > 7 {
continue
}
eventAt := time.UnixMicro(int64(entry.Timestamp))
if eventAt.After(now.Add(5*time.Minute)) || eventAt.Before(now.Add(-31*24*time.Hour)) {
continue
}
_, err = tx.Exec(ctx, `INSERT INTO agent_log_entries
(server_id,journal_cursor,event_at,unit,priority,message,boot_id,pid)
VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT DO NOTHING`, server.ID, entry.Cursor,
eventAt, entry.Unit, entry.Priority, truncateUTF8(redactLogMessage(entry.Message), 65_536), entry.BootID, entry.PID)
if err != nil {
_ = tx.Rollback(ctx)
return
}
}
if err = tx.Commit(ctx); err != nil {
log.Printf("store logs for %s: %v", server.ID, err)
return
}
if len(response.Entries) == 0 || !response.Truncated {
return
}
cursor = response.Entries[len(response.Entries)-1].Cursor
}
log.Printf("agent log backlog for %s exceeded 1000 entries in one poll", server.ID)
}
func redactLogMessage(message string) string {
message = bearerSecret.ReplaceAllString(message, "${1}[REDACTED]")
return namedSecret.ReplaceAllString(message, "${1}[REDACTED]")
}
func truncateUTF8(value string, maximum int) string {
if len(value) <= maximum {
return value
}
for maximum > 0 && value[maximum]&0xc0 == 0x80 {
maximum--
}
return value[:maximum]
}
func percent(used, total uint64) float64 {
if total == 0 {
return 0
}
return float64(used) * 100 / float64(total)
}

View File

@@ -0,0 +1,161 @@
name: vps-control
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-vps_control}
POSTGRES_USER: ${POSTGRES_USER:-vps_control}
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 8
networks: [internal]
mem_limit: 512m
cpus: 1.0
pids_limit: 200
security_opt: ["no-new-privileges:true"]
backend:
build:
context: ./backend
restart: unless-stopped
environment:
LISTEN_ADDRESS: :8080
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: ${POSTGRES_DB:-vps_control}
DB_USER: ${POSTGRES_USER:-vps_control}
DB_PASSWORD_FILE: /run/secrets/db_password
DB_SSLMODE: disable
COOKIE_SECURE: ${COOKIE_SECURE:-true}
TRUST_PROXY: "true"
SESSION_TTL: ${SESSION_TTL:-12h}
METRICS_RETENTION: ${METRICS_RETENTION:-720h}
LOGS_RETENTION: ${LOGS_RETENTION:-168h}
AGENT_CREDENTIALS_DIR: /run/secrets
INTEGRATION_TOKEN_FILE: /run/secrets/integration_token
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
INITIAL_ADMIN_PASSWORD_FILE: /run/secrets/admin_password
BOOTSTRAP_SERVER_NAME: ${BOOTSTRAP_SERVER_NAME:-}
BOOTSTRAP_SERVER_ENDPOINT: ${BOOTSTRAP_SERVER_ENDPOINT:-}
BOOTSTRAP_AGENT_CREDENTIAL_REF: ${BOOTSTRAP_AGENT_CREDENTIAL_REF:-agent_token}
secrets:
- db_password
- admin_password
- agent_token
- integration_token
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
networks: [internal, agent]
read_only: true
tmpfs: [/tmp]
mem_limit: 128m
cpus: 0.5
pids_limit: 100
security_opt: ["no-new-privileges:true"]
frontend:
build:
context: ./frontend
restart: unless-stopped
ports:
- "${HTTP_BIND_ADDRESS:-127.0.0.1}:${HTTP_PORT:-8080}:8080"
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/"]
interval: 10s
timeout: 3s
retries: 5
networks: [edge, internal]
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
- /var/run
mem_limit: 64m
cpus: 0.25
pids_limit: 50
security_opt: ["no-new-privileges:true"]
grafana:
profiles: ["observability"]
build:
context: ./grafana
restart: unless-stopped
ports:
- "${GRAFANA_BIND_ADDRESS:-127.0.0.1}:${GRAFANA_PORT:-3000}:3000"
environment:
GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-http://127.0.0.1:3000}
GF_USERS_ALLOW_SIGN_UP: "false"
GF_AUTH_ANONYMOUS_ENABLED: "false"
GF_SECURITY_COOKIE_SECURE: ${GRAFANA_COOKIE_SECURE:-false}
GF_ANALYTICS_REPORTING_ENABLED: "false"
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
GF_LOG_LEVEL: warn
secrets:
- integration_token
- grafana_admin_password
- grafana_secret_key
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3000/api/health"]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
networks: [edge, internal]
read_only: true
tmpfs:
- /tmp
- /var/log/grafana
volumes:
- grafana-data:/var/lib/grafana
- grafana-plugins:/usr/share/grafana/data/plugins-bundled
mem_limit: 384m
cpus: 0.75
pids_limit: 150
security_opt: ["no-new-privileges:true"]
volumes:
postgres-data:
grafana-data:
grafana-plugins:
networks:
edge:
internal:
internal: true
agent:
secrets:
db_password:
file: ${DB_PASSWORD_SECRET_FILE:-./secrets/db_password}
admin_password:
file: ${ADMIN_PASSWORD_SECRET_FILE:-./secrets/admin_password}
agent_token:
file: ${AGENT_TOKEN_SECRET_FILE:-./secrets/agent_token}
integration_token:
file: ${INTEGRATION_TOKEN_SECRET_FILE:-./secrets/integration_token}
grafana_admin_password:
file: ${GRAFANA_ADMIN_PASSWORD_SECRET_FILE:-./secrets/grafana_admin_password}
grafana_secret_key:
file: ${GRAFANA_SECRET_KEY_SECRET_FILE:-./secrets/grafana_secret_key}

View File

@@ -0,0 +1,9 @@
node_modules
dist
*.tsbuildinfo
vite.config.js
vite.config.d.ts
vitest.config.js
vitest.config.d.ts
.git

View File

@@ -0,0 +1,13 @@
# syntax=docker/dockerfile:1.7
FROM node:22.14-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
FROM nginxinc/nginx-unprivileged:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build --chown=101:101 /app/dist /usr/share/nginx/html
EXPOSE 8080

View File

@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: { ecmaVersion: 2022, globals: globals.browser },
plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh },
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-explicit-any': 'off'
}
}
)

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#0b0e14" />
<title>VPS Control</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,55 @@
server {
listen 8080;
server_name _;
server_tokens off;
root /usr/share/nginx/html;
index index.html;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
location /api/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 3s;
proxy_read_timeout 15s;
proxy_send_timeout 15s;
client_max_body_size 64k;
}
location /integrations/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 3s;
proxy_read_timeout 30s;
proxy_send_timeout 15s;
client_max_body_size 64k;
}
location = /healthz {
proxy_pass http://backend:8080/healthz;
proxy_set_header Host $host;
proxy_connect_timeout 2s;
proxy_read_timeout 3s;
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:js|css|svg|png|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}

4755
control-plane/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
{
"name": "vps-control-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc -b --pretty false",
"lint": "eslint src --max-warnings 0",
"test": "vitest"
},
"dependencies": {
"@tanstack/react-query": "^5.59.0",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"jsdom": "^25.0.1",
"typescript": "~5.7.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.3",
"vitest": "^4.1.11"
}
}

View File

@@ -0,0 +1,29 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { App } from './App'
const renderApp = () => render(<QueryClientProvider client={new QueryClient({defaultOptions:{queries:{retry:false}}})}><App/></QueryClientProvider>)
describe('main screens', () => {
beforeEach(() => { localStorage.clear() })
afterEach(() => { cleanup(); vi.restoreAllMocks() })
it('shows login when there is no session', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({error:'authentication required'}), {status:401,headers:{'Content-Type':'application/json'}})))
renderApp()
expect(await screen.findByRole('heading',{name:'Infrastructure, in focus.'})).toBeInTheDocument()
expect(screen.getByLabelText('Username')).toBeInTheDocument()
})
it('shows the empty fleet state for an administrator', async () => {
vi.stubGlobal('fetch', vi.fn((input:RequestInfo|URL) => {
const url=String(input)
const value=url.endsWith('/auth/me')?{id:'1',username:'admin',role:'administrator'}:[]
return Promise.resolve(new Response(JSON.stringify(value),{status:200,headers:{'Content-Type':'application/json'}}))
}))
renderApp()
await waitFor(()=>expect(screen.getByText('No servers enrolled')).toBeInTheDocument())
expect(screen.getByText('admin')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,248 @@
import { FormEvent, ReactNode, useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Activity, Ban, Boxes, Cable, ChevronDown, CircleGauge, FileClock, Shield, Gauge,
KeyRound, ListRestart, LogOut, Menu, Moon, Network, Plus, RadioTower, Server as ServerIcon,
Settings, ShieldAlert, Sun, Unplug, X,
} from 'lucide-react'
import { api, APIError, formatBytes, formatDuration } from './api'
import type {
AgentLogEntry, AuditEvent, FirewallStatus, Interface, JailStatus, MetricSample, Role, Server, Service,
SocketInfo, SystemStatus, User, WireGuardInterface,
} from './types'
type Page = 'Overview' | 'Servers' | 'Network' | 'Firewall' | 'Connections' | 'Security' |
'Fail2Ban' | 'WireGuard' | 'Services' | 'Logs' | 'Rate Limits' | 'Audit' | 'Settings'
const navigation: Array<{name: Page; icon: typeof Activity}> = [
{ name: 'Overview', icon: CircleGauge }, { name: 'Servers', icon: ServerIcon },
{ name: 'Network', icon: Network }, { name: 'Firewall', icon: Shield },
{ name: 'Connections', icon: Cable }, { name: 'Security', icon: ShieldAlert },
{ name: 'Fail2Ban', icon: Ban }, { name: 'WireGuard', icon: RadioTower },
{ name: 'Services', icon: Boxes }, { name: 'Logs', icon: FileClock },
{ name: 'Rate Limits', icon: Gauge }, { name: 'Audit', icon: FileClock },
{ name: 'Settings', icon: Settings },
]
export function App() {
const queryClient = useQueryClient()
const me = useQuery<User>({ queryKey: ['me'], queryFn: () => api('/auth/me'), retry: false })
const servers = useQuery<Server[]>({ queryKey: ['servers'], queryFn: () => api('/servers'), enabled: me.isSuccess })
const [page, setPage] = useState<Page>('Overview')
const [selectedID, setSelectedID] = useState('')
const [mobileOpen, setMobileOpen] = useState(false)
const [dark, setDark] = useState(() => localStorage.getItem('theme') !== 'light')
useEffect(() => {
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
localStorage.setItem('theme', dark ? 'dark' : 'light')
}, [dark])
useEffect(() => {
if (!selectedID && servers.data?.[0]) setSelectedID(servers.data[0].id)
}, [selectedID, servers.data])
if (me.isPending) return <LoadingScreen />
if (me.isError) return <Login onSuccess={() => void queryClient.invalidateQueries({ queryKey: ['me'] })} />
const selected = servers.data?.find((server) => server.id === selectedID)
const choosePage = (next: Page) => { setPage(next); setMobileOpen(false) }
return <div className="shell">
<aside className={`sidebar ${mobileOpen ? 'open' : ''}`}>
<div className="brand"><div className="brand-mark"><Activity size={20}/></div><span>VPS Control</span><button className="mobile-close" onClick={() => setMobileOpen(false)}><X/></button></div>
<div className="server-select-wrap">
<label>ACTIVE SERVER</label>
<div className="select-shell"><StatusDot status={selected?.status ?? 'unknown'}/><select aria-label="Active server" value={selectedID} onChange={(event) => setSelectedID(event.target.value)}>
{!servers.data?.length && <option value="">No servers</option>}
{servers.data?.map((server) => <option value={server.id} key={server.id}>{server.name}</option>)}
</select><ChevronDown size={14}/></div>
</div>
<nav>{navigation.map(({name, icon: Icon}) => <button key={name} className={page === name ? 'active' : ''} onClick={() => choosePage(name)}><Icon size={17}/><span>{name}</span></button>)}</nav>
<div className="sidebar-foot"><span className="eyebrow">SIGNED IN AS</span><strong>{me.data.username}</strong><span>{me.data.role}</span></div>
</aside>
{mobileOpen && <button className="scrim" onClick={() => setMobileOpen(false)} aria-label="Close navigation"/>}
<main>
<header className="topbar">
<button className="menu-button" onClick={() => setMobileOpen(true)}><Menu/></button>
<div><span className="breadcrumb">VPS CONTROL / </span><strong>{page.toUpperCase()}</strong></div>
<div className="top-actions"><div className={`connection ${selected?.status ?? 'unknown'}`}><StatusDot status={selected?.status ?? 'unknown'}/>{selected?.status ?? 'No target'}</div>
<button className="icon-button" onClick={() => setDark(!dark)} aria-label="Toggle color theme">{dark ? <Sun/> : <Moon/>}</button>
<button className="icon-button" aria-label="Sign out" onClick={() => void api('/auth/logout', {method:'POST'}).finally(() => location.reload())}><LogOut/></button>
</div>
</header>
<div className="content">
<PageHeader title={page} server={selected}/>
<PageContent page={page} server={selected} servers={servers.data ?? []} user={me.data}/>
</div>
</main>
</div>
}
function Login({onSuccess}:{onSuccess:()=>void}) {
const [error, setError] = useState('')
const mutation = useMutation({mutationFn: ({username,password}:{username:string;password:string}) => api<User>('/auth/login', {method:'POST',body:JSON.stringify({username,password})}), onSuccess, onError:(reason) => setError(reason instanceof Error ? reason.message : 'Sign in failed')})
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); const values = new FormData(event.currentTarget)
mutation.mutate({username:String(values.get('username')),password:String(values.get('password'))})
}
return <div className="login-page"><div className="login-glow"/><form className="login-card" onSubmit={submit}>
<div className="brand login-brand"><div className="brand-mark"><Activity/></div><span>VPS Control</span></div>
<p className="eyebrow">CONTROL PLANE ACCESS</p><h1>Infrastructure, in focus.</h1><p>Authenticate to inspect your private server fleet.</p>
<label>Username<input name="username" autoComplete="username" required autoFocus/></label>
<label>Password<input name="password" type="password" autoComplete="current-password" required/></label>
{error && <div className="error-banner">{error}</div>}
<button className="primary" disabled={mutation.isPending}>{mutation.isPending ? 'Authenticating…' : 'Sign in securely'}</button>
<span className="login-note"><KeyRound size={14}/> Session protected by HttpOnly cookie + CSRF</span>
</form></div>
}
function PageHeader({title,server}:{title:string;server?:Server}) {
return <div className="page-header"><div><div className="eyebrow">{server ? `${server.name} · ${server.hostname || 'hostname pending'}` : 'FLEET CONTROL'}</div><h1>{title}</h1></div>
{server?.last_seen && <span className="last-seen">Last contact {new Date(server.last_seen).toLocaleString()}</span>}</div>
}
function PageContent({page,server,servers,user}:{page:Page;server?:Server;servers:Server[];user:User}) {
if (page === 'Servers') return <ServersPage servers={servers} user={user}/>
if (page === 'Audit') return <AuditPage/>
if (page === 'Settings') return <SettingsPage user={user} server={server}/>
if (!server) return <Empty title="No servers enrolled" detail="Add the first private agent endpoint from Servers."/>
switch (page) {
case 'Overview': return <Overview server={server}/>
case 'Network': return <NetworkPage server={server}/>
case 'Firewall': return <FirewallPage server={server}/>
case 'Connections': return <ConnectionsPage server={server}/>
case 'Security': return <SecurityPage server={server}/>
case 'Fail2Ban': return <Fail2BanPage server={server} role={user.role}/>
case 'WireGuard': return <WireGuardPage server={server}/>
case 'Services': return <ServicesPage server={server} role={user.role}/>
case 'Logs': return <LogsPage server={server}/>
case 'Rate Limits': return <RateLimitsPage/>
default: return null
}
}
function useServerData<T>(server:Server, route:string, interval?:number) {
return useQuery<T>({queryKey:['server',server.id,route],queryFn:()=>api(`/servers/${server.id}/${route}`),refetchInterval:interval})
}
function Overview({server}:{server:Server}) {
const status = useServerData<SystemStatus>(server,'status',10_000)
const interfaces = useServerData<Interface[]>(server,'interfaces',10_000)
const listeners = useServerData<SocketInfo[]>(server,'listeners',15_000)
const connections = useServerData<SocketInfo[]>(server,'connections',15_000)
const services = useServerData<Service[]>(server,'services',15_000)
const metrics = useServerData<MetricSample[]>(server,'metrics?limit=60',30_000)
if (status.isPending) return <PanelLoading/>
if (status.isError) return <QueryError error={status.error}/>
const root = status.data.filesystems.find((item)=>item.mountpoint==='/') ?? status.data.filesystems[0]
const memory = percent(status.data.memory_used_bytes,status.data.memory_total_bytes)
const disk = root ? percent(root.used_bytes,root.total_bytes) : 0
const totals = interfaces.data?.reduce((sum,item)=>({rx:sum.rx+item.rx_bytes,tx:sum.tx+item.tx_bytes}),{rx:0,tx:0})
return <div className="stack">
{status.data.reboot_required && <div className="warning-banner"><ShieldAlert/>Host reports that a reboot is required.</div>}
<div className="metric-grid">
<Metric label="CPU USAGE" value={`${status.data.cpu_usage_percent.toFixed(1)}%`} sub={`${status.data.cpu_core_count} cores · load ${status.data.load_average[0].toFixed(2)}`} level={status.data.cpu_usage_percent}/>
<Metric label="MEMORY" value={`${memory.toFixed(1)}%`} sub={`${formatBytes(status.data.memory_used_bytes)} / ${formatBytes(status.data.memory_total_bytes)}`} level={memory}/>
<Metric label="ROOT DISK" value={root ? `${disk.toFixed(1)}%` : '—'} sub={root ? `${formatBytes(root.used_bytes)} / ${formatBytes(root.total_bytes)}` : 'Unavailable'} level={disk}/>
<Metric label="UPTIME" value={formatDuration(status.data.uptime_seconds)} sub={`${status.data.os} · ${status.data.kernel}`}/>
</div>
<div className="overview-grid"><Card title="CPU / memory history" badge="1 min samples"><MiniChart samples={metrics.data ?? []}/></Card>
<Card title="Live surface" badge="Now"><div className="surface-stats"><Stat label="Connections" value={connections.data?.length ?? '—'}/><Stat label="Listeners" value={listeners.data?.length ?? '—'}/><Stat label="Network RX" value={formatBytes(totals?.rx)}/><Stat label="Network TX" value={formatBytes(totals?.tx)}/></div></Card></div>
<Card title="Allow-listed services" badge={`${services.data?.filter((item)=>item.active_state==='active').length ?? 0}/${services.data?.length ?? 0} active`}>
<div className="service-strip">{services.data?.map((service)=><div key={service.name}><StatusDot status={service.active_state==='active'?'online':'offline'}/><span>{service.name}</span><small>{service.sub_state}</small></div>)}{services.data?.length===0&&<EmptyLine text="No services are allow-listed on the agent."/>}</div>
</Card>
</div>
}
function NetworkPage({server}:{server:Server}) {
const interfaces = useServerData<Interface[]>(server,'interfaces',10_000)
const routes = useServerData<unknown>(server,'routes',30_000)
return <div className="stack"><DataPanel query={interfaces}><Card title="Interfaces" badge={`${interfaces.data?.length ?? 0} detected`}><Table headers={['State','Interface','Addresses','Received','Transmitted']} rows={(interfaces.data??[]).map((item)=>[<StatusDot status={item.state==='up'?'online':'offline'}/>,<code>{item.name}</code>,item.addresses.join(', ')||'—',formatBytes(item.rx_bytes),formatBytes(item.tx_bytes)])}/></Card></DataPanel>
<DataPanel query={routes}><Card title="Kernel routes" badge="Read only"><pre className="code-block">{JSON.stringify(routes.data,null,2)}</pre></Card></DataPanel></div>
}
function FirewallPage({server}:{server:Server}) {
const firewall = useServerData<FirewallStatus>(server,'firewall',30_000)
return <DataPanel query={firewall}><div className="stack"><div className="notice"><Shield/><div><strong>{firewall.data?.backend ?? 'Unknown'} backend</strong><span>Rules are read-only in v1. Unknown and external rules are never mutated.</span></div></div>
<Card title="Managed by VPS Control" badge={firewall.data?.management_enabled?'Enabled':'Disabled'}><EmptyLine text={`Reserved table: ${firewall.data?.managed_table ?? 'inet vps_control'}. Crash-safe privileged helper is required before writes can be enabled.`}/></Card>
<Card title="External / unmanaged rules" badge="Read only">{firewall.data?.warning&&<div className="inline-warning">{firewall.data.warning}</div>}<pre className="code-block tall">{JSON.stringify(firewall.data?.ruleset,null,2)}</pre></Card></div></DataPanel>
}
function ConnectionsPage({server}:{server:Server}) {
const connections = useServerData<SocketInfo[]>(server,'connections',10_000)
const [filter,setFilter] = useState('')
const rows=(connections.data??[]).filter((item)=>`${item.protocol} ${item.remote_address} ${item.local_port} ${item.state}`.toLowerCase().includes(filter.toLowerCase()))
const states=useMemo(()=>Object.entries(rows.reduce<Record<string,number>>((all,item)=>({...all,[item.state]:(all[item.state]??0)+1}),{})).sort((a,b)=>b[1]-a[1]),[rows])
return <DataPanel query={connections}><div className="stack"><div className="summary-row">{states.slice(0,4).map(([name,count])=><Stat key={name} label={name} value={count}/>)}</div><Card title="Current connections" badge={`${rows.length} sockets`} action={<input className="filter" placeholder="Filter protocol, IP, port…" value={filter} onChange={(e)=>setFilter(e.target.value)}/>}><Table headers={['Protocol','Local','Remote','State','Process']} rows={rows.map(item=>[item.protocol.toUpperCase(),<code>{item.local_address}:{item.local_port}</code>,<code>{item.remote_address}:{item.remote_port}</code>,<Pill text={item.state}/>,item.process?`${item.process} · ${item.pid}`:'—'])}/></Card></div></DataPanel>
}
function SecurityPage({server}:{server:Server}) {
const status=useServerData<SystemStatus>(server,'status')
const listeners=useServerData<SocketInfo[]>(server,'listeners')
const publicListeners=(listeners.data??[]).filter((item)=>['0.0.0.0','::'].includes(item.local_address))
return <div className="stack"><div className="metric-grid compact"><Metric label="REBOOT REQUIRED" value={status.data?.reboot_required?'YES':'NO'} sub="Reported by the operating system"/><Metric label="PUBLIC BINDS" value={String(publicListeners.length)} sub="Firewall reachability requires review"/><Metric label="AGENT TRANSPORT" value={server.agent_endpoint.startsWith('https')?'TLS':'PRIVATE HTTP'} sub={server.agent_endpoint}/></div><Card title="Factual warnings" badge="No synthetic score"><div className="event-list">{status.data?.reboot_required&&<Event severity="warning" text="A reboot-required marker exists on the host."/>}{publicListeners.map((item)=><Event key={`${item.protocol}-${item.local_port}`} severity="warning" text={`${item.process??'Unknown process'} listens on every address at ${item.protocol.toUpperCase()}/${item.local_port}.`}/>)}{!status.data?.reboot_required&&!publicListeners.length&&<EmptyLine text="No warnings in the current snapshot."/>}</div></Card></div>
}
function Fail2BanPage({server,role}:{server:Server;role:Role}) {
const queryClient=useQueryClient(); const status=useServerData<JailStatus>(server,'fail2ban',15_000)
const mutation=useMutation({mutationFn:(input:{action:'ban'|'unban';jail:string;ip:string})=>api(`/servers/${server.id}/fail2ban/${input.action}`,{method:'POST',body:JSON.stringify(input)}),onSuccess:()=>void queryClient.invalidateQueries({queryKey:['server',server.id,'fail2ban']})})
const submit=(event:FormEvent<HTMLFormElement>)=>{event.preventDefault();const data=new FormData(event.currentTarget);mutation.mutate({action:data.get('action') as 'ban'|'unban',jail:String(data.get('jail')),ip:String(data.get('ip'))})}
return <DataPanel query={status}><div className="split"><Card title="Daemon status" badge={status.data?.running?'Running':'Unavailable'}><div className="details"><Stat label="Installed" value={status.data?.installed?'Yes':'No'}/><Stat label="Version" value={status.data?.version??'—'}/><Stat label="Jails" value={status.data?.jails.length??0}/></div><div className="tag-list">{status.data?.jails.map(jail=><span key={jail}>{jail}</span>)}</div></Card><Card title="Manual action" badge="Audited"><form className="action-form" onSubmit={submit}><select name="action"><option value="ban">Ban</option><option value="unban">Unban</option></select><select name="jail" required>{status.data?.jails.map(jail=><option key={jail}>{jail}</option>)}</select><input name="ip" required placeholder="192.0.2.20"/><button className="primary" disabled={role==='viewer'||mutation.isPending}>Apply</button>{role==='viewer'&&<small>Operator role required.</small>}{mutation.error&&<small className="danger">{mutation.error.message}</small>}</form></Card></div></DataPanel>
}
function WireGuardPage({server}:{server:Server}) {
const query=useServerData<WireGuardInterface[]>(server,'wireguard',10_000)
return <DataPanel query={query}><div className="stack">{query.data?.map(item=><Card key={item.name} title={item.name} badge={`UDP ${item.listen_port}`}><Table headers={['Peer','Endpoint','Allowed IPs','Last handshake','RX / TX']} rows={item.peers.map(peer=>[<code>{peer.public_key.slice(0,12)}</code>,peer.endpoint??'—',peer.allowed_ips.join(', '),peer.latest_handshake?new Date(peer.latest_handshake*1000).toLocaleString():'Never',`${formatBytes(peer.rx_bytes)} / ${formatBytes(peer.tx_bytes)}`])}/></Card>)}{query.data?.length===0&&<Empty title="WireGuard unavailable" detail="No readable WireGuard interfaces were reported."/>}</div></DataPanel>
}
function ServicesPage({server,role}:{server:Server;role:Role}) {
const queryClient=useQueryClient(); const query=useServerData<Service[]>(server,'services',15_000)
const mutation=useMutation({mutationFn:(name:string)=>api(`/servers/${server.id}/services/${encodeURIComponent(name)}/restart`,{method:'POST',body:'{}'}),onSuccess:()=>void queryClient.invalidateQueries({queryKey:['server',server.id,'services']})})
return <DataPanel query={query}><Card title="Allow-listed systemd services" badge={`${query.data?.length??0} configured`}><Table headers={['State','Unit','Sub-state','PID','Memory','Action']} rows={(query.data??[]).map(item=>[<StatusDot status={item.active_state==='active'?'online':'offline'}/>,<code>{item.name}</code>,item.sub_state,item.pid||'—',formatBytes(item.memory_bytes),<button className="table-action" disabled={role==='viewer'||mutation.isPending} onClick={()=>mutation.mutate(item.name)}><ListRestart/>Restart</button>])}/></Card></DataPanel>
}
function ServersPage({servers,user}:{servers:Server[];user:User}) {
const queryClient=useQueryClient(); const [open,setOpen]=useState(false)
const mutation=useMutation({mutationFn:(body:unknown)=>api('/servers',{method:'POST',body:JSON.stringify(body)}),onSuccess:()=>{setOpen(false);void queryClient.invalidateQueries({queryKey:['servers']})}})
const submit=(event:FormEvent<HTMLFormElement>)=>{event.preventDefault();const data=new FormData(event.currentTarget);mutation.mutate({name:data.get('name'),hostname:data.get('hostname'),agent_endpoint:data.get('endpoint'),credential_ref:data.get('credential'),tags:String(data.get('tags')??'').split(',').map(v=>v.trim()).filter(Boolean)})}
return <div className="stack"><div className="toolbar"><span>{servers.length} enrolled nodes</span>{user.role==='administrator'&&<button className="primary small" onClick={()=>setOpen(!open)}><Plus/>Add server</button>}</div>{open&&<Card title="Enroll server" badge="Private endpoint only"><form className="server-form" onSubmit={submit}><label>Name<input name="name" required/></label><label>Hostname<input name="hostname"/></label><label>Agent endpoint<input name="endpoint" required placeholder="http://10.10.0.2:9105"/></label><label>Credential file reference<input name="credential" required placeholder="agent_token"/></label><label>Tags<input name="tags" placeholder="production, eu-central"/></label><button className="primary" disabled={mutation.isPending}>Enroll</button>{mutation.error&&<small className="danger">{mutation.error.message}</small>}</form></Card>}
<div className="server-grid">{servers.map(server=><Card key={server.id} title={server.name} badge={server.status}><div className="server-card"><div className="server-icon"><ServerIcon/></div><div><strong>{server.hostname||'Awaiting first contact'}</strong><code>{server.agent_endpoint}</code><span>{server.last_seen?`Seen ${new Date(server.last_seen).toLocaleString()}`:'Never contacted'}</span></div></div><div className="tag-list">{server.tags.map(tag=><span key={tag}>{tag}</span>)}</div></Card>)}{!servers.length&&<Empty title="No servers enrolled" detail="Mount an agent token secret, then add its private endpoint."/>}</div></div>
}
function AuditPage() {
const query=useQuery<AuditEvent[]>({queryKey:['audit'],queryFn:()=>api('/audit?limit=200'),refetchInterval:15_000})
return <DataPanel query={query}><Card title="Control-plane audit trail" badge="Latest 200"><Table headers={['Time','Actor','Action','Target','Result','Source IP']} rows={(query.data??[]).map(item=>[new Date(item.created_at).toLocaleString(),item.username,<code>{item.action}</code>,item.target,<Pill text={item.result}/>,<code>{item.source_ip}</code>])}/></Card></DataPanel>
}
function LogsPage({server}:{server:Server}) {
const query=useServerData<AgentLogEntry[]>(server,'logs?limit=500',15_000)
const priority=['emerg','alert','crit','err','warning','notice','info','debug']
return <DataPanel query={query}><div className="stack">
<div className="notice"><FileClock/><div><strong>Backend-retained journald entries</strong><span>The control plane polls only agent-configured systemd units and applies bounded retention. This browser never contacts the VPS agent directly.</span></div></div>
<Card title="Agent logs" badge={`${query.data?.length??0} / 500`}><Table headers={['Time','Unit','Priority','PID','Message']} rows={(query.data??[]).map(item=>[
new Date(item.timestamp).toLocaleString(),<code>{item.unit}</code>,<Pill text={priority[item.priority]??String(item.priority)}/>,item.pid??'—',<span className="log-message">{item.message}</span>,
])}/></Card>
</div></DataPanel>
}
function RateLimitsPage(){return <div className="notice"><Gauge/><div><strong>Policy engine is not enabled in v1</strong><span>L4 nftables policies require the crash-safe privileged helper. L7 provider configuration is planned separately; arbitrary nginx edits will not be supported.</span></div></div>}
function SettingsPage({user,server}:{user:User;server?:Server}){return <div className="split"><Card title="Current account" badge={user.role}><div className="details"><Stat label="Username" value={user.username}/><Stat label="Role" value={user.role}/><Stat label="Session" value="HttpOnly + CSRF"/></div></Card><Card title="Agent target" badge={server?.status??'none'}><div className="details"><Stat label="Name" value={server?.name??'—'}/><Stat label="Endpoint" value={server?.agent_endpoint??'—'}/><Stat label="Last contact" value={server?.last_seen?new Date(server.last_seen).toLocaleString():'—'}/></div></Card></div>}
function Metric({label,value,sub,level}:{label:string;value:string;sub:string;level?:number}){return <div className="metric"><span className="eyebrow">{label}</span><strong>{value}</strong><small>{sub}</small>{level!==undefined&&<div className="meter"><i style={{width:`${Math.min(100,Math.max(0,level))}%`}}/></div>}</div>}
function Card({title,badge,action,children}:{title:string;badge?:string;action?:ReactNode;children:ReactNode}){return <section className="card"><header><div><h2>{title}</h2>{badge&&<span className="badge">{badge}</span>}</div>{action}</header><div className="card-body">{children}</div></section>}
function Stat({label,value}:{label:string;value:ReactNode}){return <div className="stat"><span>{label}</span><strong>{value}</strong></div>}
function StatusDot({status}:{status:string}){return <i className={`status-dot ${status}`}/>}
function Pill({text}:{text:string}){return <span className={`pill ${text.toLowerCase()}`}>{text}</span>}
function Empty({title,detail}:{title:string;detail:string}){return <div className="empty"><Unplug/><h2>{title}</h2><p>{detail}</p></div>}
function EmptyLine({text}:{text:string}){return <div className="empty-line">{text}</div>}
function Event({severity,text}:{severity:string;text:string}){return <div className={`event ${severity}`}><StatusDot status={severity}/><span>{text}</span></div>}
function LoadingScreen(){return <div className="loading-screen"><div className="brand-mark pulse"><Activity/></div><span>Establishing control plane</span></div>}
function PanelLoading(){return <div className="panel-loading"><i/><i/><i/></div>}
function QueryError({error}:{error:Error}){return <div className="error-state"><ShieldAlert/><h2>Data unavailable</h2><p>{error instanceof APIError&&error.status===502?'The agent could not be reached over its private endpoint.':error.message}</p></div>}
function DataPanel({query,children}:{query:{isPending:boolean;isError:boolean;error:Error|null};children:ReactNode}){if(query.isPending)return <PanelLoading/>;if(query.isError)return <QueryError error={query.error??new Error('Request failed')}/>;return <>{children}</>}
function Table({headers,rows}:{headers:string[];rows:ReactNode[][]}){return <div className="table-wrap"><table><thead><tr>{headers.map(header=><th key={header}>{header}</th>)}</tr></thead><tbody>{rows.map((row,index)=><tr key={index}>{row.map((cell,cellIndex)=><td key={cellIndex}>{cell}</td>)}</tr>)}{!rows.length&&<tr><td colSpan={headers.length}><EmptyLine text="No data in the current snapshot."/></td></tr>}</tbody></table></div>}
function MiniChart({samples}:{samples:MetricSample[]}) {
const ordered=[...samples].reverse(); const points=(key:'cpu'|'memory')=>ordered.map((sample,index)=>`${ordered.length<2?0:index/(ordered.length-1)*100},${40-Math.min(100,sample[key])/2.5}`).join(' ')
return <div className="chart"><div className="chart-legend"><span><i className="cpu"/>CPU</span><span><i className="memory"/>Memory</span></div><svg viewBox="0 0 100 42" preserveAspectRatio="none" aria-label="CPU and memory history"><line x1="0" y1="20" x2="100" y2="20"/><line x1="0" y1="40" x2="100" y2="40"/>{ordered.length>1&&<><polyline className="cpu-line" points={points('cpu')}/><polyline className="memory-line" points={points('memory')}/></>}</svg></div>
}
const percent=(used:number,total:number)=>total?used*100/total:0

View File

@@ -0,0 +1,37 @@
const csrfCookie = () => document.cookie.split('; ').find((item) => item.startsWith('vps_csrf='))?.split('=')[1] ?? ''
export class APIError extends Error {
constructor(public status: number, message: string) { super(message) }
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const method = (init.method ?? 'GET').toUpperCase()
const headers = new Headers(init.headers)
headers.set('Accept', 'application/json')
if (init.body) headers.set('Content-Type', 'application/json')
if (!['GET', 'HEAD'].includes(method)) headers.set('X-CSRF-Token', decodeURIComponent(csrfCookie()))
const response = await fetch(`/api/v1${path}`, { ...init, headers, credentials: 'same-origin' })
if (!response.ok) {
const data = await response.json().catch(() => ({ error: `Request failed (${response.status})` })) as {error?:string}
throw new APIError(response.status, data.error ?? 'Request failed')
}
if (response.status === 204) return undefined as T
return response.json() as Promise<T>
}
export const formatBytes = (value?: number) => {
if (value === undefined || !Number.isFinite(value)) return '—'
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
let amount = value
let index = 0
while (Math.abs(amount) >= 1024 && index < units.length - 1) { amount /= 1024; index += 1 }
return `${amount.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
}
export const formatDuration = (seconds?: number) => {
if (seconds === undefined) return '—'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
return days > 0 ? `${days}d ${hours}h` : `${hours}h ${Math.floor((seconds % 3600) / 60)}m`
}

View File

@@ -0,0 +1,12 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { App } from './App'
import './styles.css'
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10_000, retry: 1, refetchOnWindowFocus: false } } })
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode><QueryClientProvider client={queryClient}><App /></QueryClientProvider></React.StrictMode>
)

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
import '@testing-library/jest-dom/vitest'

View File

@@ -0,0 +1,22 @@
export type Role = 'viewer' | 'operator' | 'administrator'
export interface User { id: string; username: string; role: Role }
export interface Server {
id: string; name: string; hostname: string; agent_endpoint: string; tags: string[]
status: 'online' | 'offline' | 'unknown'; last_seen: string | null; created_at: string
}
export interface Filesystem { mountpoint: string; filesystem: string; total_bytes: number; used_bytes: number; inodes_total: number; inodes_used: number }
export interface SystemStatus {
hostname: string; os: string; kernel: string; uptime_seconds: number; load_average: [number, number, number]
cpu_usage_percent: number; cpu_core_count: number; memory_total_bytes: number; memory_used_bytes: number
swap_total_bytes: number; swap_used_bytes: number; filesystems: Filesystem[]; reboot_required: boolean
}
export interface Interface { name: string; state: string; addresses: string[]; rx_bytes: number; tx_bytes: number }
export interface SocketInfo { protocol: string; local_address: string; local_port: number; remote_address: string; remote_port: number; state: string; pid?: number; process?: string }
export interface Service { name: string; active_state: string; sub_state: string; pid: number; memory_bytes?: number }
export interface JailStatus { installed: boolean; running: boolean; version?: string; jails: string[] }
export interface WireGuardInterface { name: string; public_key: string; listen_port: number; peers: Array<{public_key:string;endpoint?:string;allowed_ips:string[];latest_handshake:number;rx_bytes:number;tx_bytes:number}> }
export interface FirewallStatus { backend: string; management_enabled: boolean; managed_table: string; ruleset: unknown; warning?: string }
export interface AuditEvent { id: number; username: string; server_id?: string; action: string; target: string; result: string; source_ip: string; created_at: string }
export interface MetricSample { sampled_at:string; cpu:number; memory:number; disk:number; rx:number; tx:number; connections:number; bans:number }
export interface AgentLogEntry { server_id:string; timestamp:string; unit:string; priority:number; message:string; boot_id?:string; pid?:number }

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"noUncheckedIndexedAccess": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,8 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"noEmit": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["vite.config.ts", "vitest.config.ts", "eslint.config.js"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { proxy: { '/api': 'http://localhost:8080', '/healthz': 'http://localhost:8080' } }
})

View File

@@ -0,0 +1,5 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: { environment: 'jsdom', setupFiles: './src/test-setup.ts' }
})

View File

@@ -0,0 +1,11 @@
# syntax=docker/dockerfile:1.7
FROM grafana/grafana:13.2.0
USER root
COPY --chown=472:0 provisioning /etc/grafana/provisioning
COPY --chown=472:0 dashboards /etc/grafana/dashboards
COPY --chown=472:0 entrypoint.sh /usr/local/bin/vps-control-grafana-entrypoint
RUN chmod 0555 /usr/local/bin/vps-control-grafana-entrypoint
USER 472
ENTRYPOINT ["/usr/local/bin/vps-control-grafana-entrypoint"]

View File

@@ -0,0 +1,67 @@
{
"annotations": {"list": []},
"editable": false,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"datasource": {"type": "prometheus", "uid": "vps-control-prometheus"},
"fieldConfig": {"defaults": {"max": 100, "min": 0, "unit": "percent"}, "overrides": []},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
"id": 1,
"options": {"legend": {"displayMode": "list", "placement": "bottom"}},
"targets": [{"editorMode": "code", "expr": "vps_control_cpu_usage_percent", "legendFormat": "{{server_name}}", "range": true, "refId": "A"}],
"title": "CPU usage",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "vps-control-prometheus"},
"fieldConfig": {"defaults": {"max": 100, "min": 0, "unit": "percent"}, "overrides": []},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
"id": 2,
"options": {"legend": {"displayMode": "list", "placement": "bottom"}},
"targets": [{"editorMode": "code", "expr": "vps_control_memory_usage_percent", "legendFormat": "{{server_name}}", "range": true, "refId": "A"}],
"title": "Memory usage",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "vps-control-prometheus"},
"fieldConfig": {"defaults": {"max": 100, "min": 0, "unit": "percent"}, "overrides": []},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
"id": 3,
"options": {"legend": {"displayMode": "list", "placement": "bottom"}},
"targets": [{"editorMode": "code", "expr": "vps_control_disk_usage_percent", "legendFormat": "{{server_name}}", "range": true, "refId": "A"}],
"title": "Disk usage",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "vps-control-prometheus"},
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
"id": 4,
"options": {"legend": {"displayMode": "list", "placement": "bottom"}},
"targets": [{"editorMode": "code", "expr": "vps_control_connections", "legendFormat": "{{server_name}}", "range": true, "refId": "A"}],
"title": "Connections",
"type": "timeseries"
},
{
"datasource": {"type": "loki", "uid": "vps-control-loki"},
"gridPos": {"h": 12, "w": 24, "x": 0, "y": 16},
"id": 5,
"options": {"dedupStrategy": "none", "enableLogDetails": true, "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": true, "showTime": true, "sortOrder": "Descending", "wrapLogMessage": true},
"targets": [{"editorMode": "code", "expr": "{unit=~\".+\"}", "queryType": "range", "refId": "A"}],
"title": "Allow-listed journald logs",
"type": "logs"
}
],
"refresh": "1m",
"schemaVersion": 41,
"tags": ["vps-control"],
"templating": {"list": []},
"time": {"from": "now-6h", "to": "now"},
"timezone": "browser",
"title": "VPS Control Overview",
"uid": "vps-control-overview",
"version": 1
}

View File

@@ -0,0 +1,22 @@
#!/bin/sh
set -eu
integration_token_file=/run/secrets/integration_token
admin_password_file=/run/secrets/grafana_admin_password
secret_key_file=/run/secrets/grafana_secret_key
if [ ! -r "$integration_token_file" ] || [ ! -r "$admin_password_file" ] || [ ! -r "$secret_key_file" ]; then
echo "required Grafana secret file is unavailable" >&2
exit 1
fi
INTEGRATION_TOKEN=$(tr -d '\r\n' < "$integration_token_file")
GF_SECURITY_ADMIN_PASSWORD=$(tr -d '\r\n' < "$admin_password_file")
GF_SECURITY_SECRET_KEY=$(tr -d '\r\n' < "$secret_key_file")
if [ "${#INTEGRATION_TOKEN}" -lt 32 ] || [ "${#GF_SECURITY_ADMIN_PASSWORD}" -lt 16 ] || [ "${#GF_SECURITY_SECRET_KEY}" -lt 32 ]; then
echo "Grafana secrets do not meet minimum length requirements" >&2
exit 1
fi
export INTEGRATION_TOKEN GF_SECURITY_ADMIN_PASSWORD GF_SECURITY_SECRET_KEY
exec /run.sh

View File

@@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: VPS Control
orgId: 1
folder: VPS Control
type: file
disableDeletion: true
allowUiUpdates: false
updateIntervalSeconds: 30
options:
path: /etc/grafana/dashboards

View File

@@ -0,0 +1,35 @@
apiVersion: 1
deleteDatasources:
- name: VPS Control Metrics
orgId: 1
- name: VPS Control Logs
orgId: 1
datasources:
- name: VPS Control Metrics
uid: vps-control-prometheus
orgId: 1
type: prometheus
access: proxy
url: http://backend:8080/integrations/prometheus
isDefault: true
editable: false
jsonData:
httpMethod: POST
httpHeaderName1: Authorization
secureJsonData:
httpHeaderValue1: Bearer $INTEGRATION_TOKEN
- name: VPS Control Logs
uid: vps-control-loki
orgId: 1
type: loki
access: proxy
url: http://backend:8080/integrations/loki
editable: false
jsonData:
httpHeaderName1: Authorization
maxLines: 1000
secureJsonData:
httpHeaderValue1: Bearer $INTEGRATION_TOKEN

View File

@@ -0,0 +1 @@

87
docs/architecture.md Normal file
View File

@@ -0,0 +1,87 @@
# Architecture
## Components and trust boundaries
The Rust agent is a stateless, pull-oriented HTTP API. It stores no metrics or
log history and does no background polling. `/proc`, `/sys`, journald, and fixed
read-only utilities are queried only when the control plane asks. The Go backend
owns users, sessions, servers, metric/log samples, retention, and audit. React
is served behind nginx and only calls the backend.
Grafana also talks only to the backend. The backend implements the constrained
read portions of the Prometheus and Loki HTTP APIs, backed by PostgreSQL. There
is intentionally no Grafana-to-agent or Loki-to-agent path and no agent token is
ever provisioned into Grafana.
Agent transport is expected to be WireGuard plus a 256-bit application token,
or TLS plus that token. The listen address is loopback by default. Tokens live in
files outside configuration and are never returned by an API.
## Agent API v1
All `/v1` routes require `Authorization: Bearer …`; `/healthz` only returns
liveness. JSON bodies are capped at 64 KiB. Read traffic is capped at 10 rps,
writes at 2 rps, burst 20, with 32 concurrent requests and five-second timeouts.
| Method | Route | Purpose |
|---|---|---|
| GET | `/v1/system/status` | CPU, load, memory, swap, filesystems, uptime |
| GET | `/v1/network/interfaces` | interfaces, addresses and counters |
| GET | `/v1/network/routes` | kernel routes |
| GET | `/v1/network/listeners` | listening TCP/UDP sockets |
| GET | `/v1/network/connections` | current TCP/UDP sockets |
| GET | `/v1/firewall/rules` | backend and read-only ruleset |
| GET | `/v1/fail2ban/status` | installation and jail summary |
| GET | `/v1/fail2ban/jails/:jail` | validated jail detail |
| POST | `/v1/fail2ban/ban` | validated IP and jail ban |
| POST | `/v1/fail2ban/unban` | validated IP and jail unban |
| GET | `/v1/wireguard/status` | interface and peer dump |
| GET | `/v1/services` | allow-listed services only |
| POST | `/v1/services/:name/restart` | allow-listed restart only |
| GET | `/v1/logs` | cursor-paginated journald rows from allow-listed units |
No generic command route exists. Child processes receive a fixed executable and
fixed subcommand plus individually validated argv values.
## Data model
- `users(id, username, password_hash, role, disabled, timestamps)`
- `sessions(id_hash, user_id, csrf_hash, expires_at, source_ip)`
- `servers(id, name, hostname, agent_endpoint, credential_ref, tags, status,
last_seen, timestamps)`
- `metric_samples(server_id, sampled_at, cpu, memory, disk, rx, tx,
connections, bans)` with 30-day retention
- `agent_log_entries(server_id, journal_cursor, event_at, unit, priority,
message, boot_id, pid)` with configurable seven-day retention
- `audit_events(actor, server, action, target, before, after, result, source_ip,
created_at)`; secrets are excluded
Credentials are indirect references to Docker secrets or environment variables,
not database values. Database migrations are append-only and run on startup.
## Observability compatibility APIs
`/integrations/prometheus` accepts direct selectors for the fixed
`vps_control_*` metric set. `/integrations/loki` accepts bounded stream selectors
over `server_id`, `server_name`, `unit`, and `priority`. Full PromQL, arbitrary
LogQL pipelines, writes, remote-write, and Loki push APIs are deliberately not
implemented. Queries are bearer-authenticated with an independent secret,
rate-limited, range-limited to 31 days, and result-limited.
The data path is:
```text
Grafana ──Prometheus/Loki read APIs──> backend ──semantic polling──> VPS agent
PostgreSQL
```
## Privilege design
The agent has no ambient capabilities and runs as `vps-agent`. Linux usually
permits the read-only endpoints without elevation. Fail2Ban mutations and
service restarts need administrator-provided sudoers or polkit rules restricted
to each approved verb/name. The shipped service does not install such policy
automatically. Managed firewall mutation is out of v1 because giving the main
daemon `CAP_NET_ADMIN` violates least privilege; its future helper protocol must
provide prepare/apply/health/confirm/commit and persistent timed rollback.

71
docs/deployment.md Normal file
View File

@@ -0,0 +1,71 @@
# Deployment
## Network
Place each agent and the backend in a WireGuard network. Bind the agent to its
WireGuard address only and allow TCP/9105 solely from the control-plane peer.
Alternatively bind to loopback and expose it through an authenticated tunnel.
TLS termination at the agent is supported when certificate paths are configured.
## Control plane
```bash
cp control-plane/.env.example control-plane/.env
mkdir -p control-plane/secrets
openssl rand -hex 32 > control-plane/secrets/db_password
openssl rand -base64 24 > control-plane/secrets/admin_password
openssl rand -hex 32 > control-plane/secrets/integration_token
openssl rand -base64 24 > control-plane/secrets/grafana_admin_password
openssl rand -hex 32 > control-plane/secrets/grafana_secret_key
# Securely copy the exact token installed on the agent:
install -m 600 /secure/source/control-plane.token control-plane/secrets/agent_token
chmod 600 control-plane/secrets/*
docker compose -f control-plane/docker-compose.yml up -d --build
docker compose -f control-plane/docker-compose.yml ps
```
Terminate public TLS at a maintained reverse proxy and forward only to the
frontend container. Set `COOKIE_SECURE=true`. Restrict PostgreSQL to its Compose
network; it has no published host port.
## Backups and upgrades
Back up PostgreSQL with `pg_dump -Fc`, the `.env` file via a secret manager, TLS
key material, and Docker secret files. Test restores periodically. Agent backup
needs only `/etc/vps-agent/`. For upgrades, pin image tags, back up, pull/build,
run `docker compose up -d`, then inspect health and audit events. Database
migrations are forward-only; restore the database backup for rollback.
Metric samples are retained for 30 days and collected logs for seven days by
the backend cleanup loop. `METRICS_RETENTION` and `LOGS_RETENTION` control these
windows; log retention is constrained to one hour through 90 days. Configure
database-level backups independently of retention.
## Grafana and journald collection
Enable log collection explicitly on each agent:
```toml
[logs]
enabled = true
allowed_units = ["vps-agent.service", "nginx.service", "ssh.service"]
max_entries_per_request = 200
max_message_bytes = 16384
```
The service user also needs read access to journald. On Debian/Ubuntu this is
usually granted with `sudo usermod -aG systemd-journal vps-agent`, followed by an
agent restart. Membership permits the process to read the host journal even
though the API returns only configured units, so enable it only after the host
security review. The installer deliberately does not grant this access.
Start the provisioned Grafana profile with:
```bash
docker compose -f control-plane/docker-compose.yml --profile observability up -d --build
```
Grafana is bound to `127.0.0.1:3000`, requires the password from
`secrets/grafana_admin_password`, and comes with Prometheus/Loki datasources plus
a starter dashboard. For public access, place it behind TLS, set
`GRAFANA_ROOT_URL`, and set `GRAFANA_COOKIE_SECURE=true`.

19
docs/development.md Normal file
View File

@@ -0,0 +1,19 @@
# Development
Requirements: Rust 1.80+, Go 1.22+ (the container build uses 1.24), Node 22+,
Docker with Compose v2, and PostgreSQL 16 for backend integration.
```bash
cargo test --manifest-path agent/Cargo.toml
cd control-plane/backend && go test ./...
cd ../frontend && npm ci && npm test -- --run && npm run build
docker compose -f ../docker-compose.yml config
```
Copy example configuration; never commit live tokens. Parser tests use fixtures
and ordinary suites must work on macOS/Linux without root. Host integration is a
separate manual pass on a disposable supported VM.
When adding an endpoint, update the agent route, control-plane proxy allow-list,
RBAC policy, audit behavior (for writes), docs, and tests together. Prefer small
dependencies and measure agent RSS before accepting runtime-heavy crates.

67
docs/security.md Normal file
View File

@@ -0,0 +1,67 @@
# Security model
## Assumptions
The agent host, control-plane host, private transport, and browser are distinct
trust zones. Root compromise is out of scope; reducing the blast radius of an
application compromise is in scope. WireGuard/TLS protects transport while the
application token authenticates the control plane. Control-plane cookies are
HttpOnly, Secure in production, SameSite=Strict, short-lived, and paired with a
CSRF header on mutations. Passwords use Argon2id.
## Threats and mitigations
| Threat | Mitigation |
|---|---|
| Public scanning | loopback default, documented WireGuard-only binding, no unauthenticated metadata beyond liveness |
| Stolen agent token | file permissions, independent per-server credential refs, rotation without config secrets, TLS/WireGuard |
| Malicious API input | typed JSON, size limits, IP/jail/service validation, fixed argv, no shell |
| Request flooding | request timeouts, concurrency semaphore, token buckets, reverse-proxy limits |
| Command injection | no command strings; executable and subcommands are constants; service names are allow-listed |
| Privilege escalation | unprivileged user, systemd sandbox, no capabilities, write privileges opt-in and narrow |
| Accidental firewall lockout | v1 is read-only; future mutations require local, crash-safe confirm-or-rollback |
| Disk/log exhaustion | journald only, no metric spool, PostgreSQL retention, bounded body/audit fields |
| Observability token theft | independent file secret, constant-time verification, per-source limiting, no agent credentials in Grafana |
| Expensive dashboard queries | fixed metric set, restricted selectors, 31-day ranges, point/row/result caps |
| Compromised browser | RBAC, CSRF, Secure cookies, no agent access, audit; administrators still must use a trusted endpoint |
| Compromised control plane | semantic agent API and allow-lists limit actions; unique rotatable tokens reduce lateral movement |
| Authentication brute force | generic errors, Argon2id, per-IP login limiter, login audit |
| Suspicious traffic misclassification | display factual events and counts; no ungrounded “security score” |
## Credential rotation
Generate at least 32 random bytes (`openssl rand -hex 32`). Write the new value
to `/etc/vps-agent/credentials/control-plane.token`, owned by `vps-agent`, mode
0600, atomically replace
the file, restart the agent, then update its control-plane Docker secret and
restart the backend. Keep the overlap window short; the agent intentionally
accepts one token. Never pass the token on a command line.
## Firewall safety design (future helper)
The future root helper owns only `table inet vps_control`. It writes a bounded
snapshot (maximum five), fsyncs a pending transaction with a monotonic deadline,
applies a complete validated table, and starts an independent rollback timer.
Only an authenticated confirmation commits. On restart it rolls back any pending
transaction before accepting a new one. Unknown tables/chains remain read-only.
## Operational logging
The agent logs startup/shutdown, invalid configuration, auth failures (without
tokens), mutations, and unexpected errors. Successful metric polls are silent.
Bound journald globally only after reviewing other services, for example with
`SystemMaxUse=500M`, `RuntimeMaxUse=100M`, and `MaxRetentionSec=1month`.
Agent log export is off by default. When enabled, unit names are configuration
allow-listed, journal cursors and timestamps are validated, message/page sizes
are capped, subprocess time and output are bounded, and backlog ingestion is
limited per poll. Common `Authorization`, password, token, secret, and API-key
shapes are redacted on the agent and again before database insertion. This is a
defense in depth measure, not a substitute for applications avoiding secrets in
logs. PostgreSQL is the durable log store. The Loki-compatible API is read-only
and cannot be used to inject logs.
Treat the integration bearer token like a password. Keep it distinct from all
agent tokens, mount it from a secret file, rotate it by replacing the file and
restarting backend/Grafana, and restrict `/integrations/` at the outer reverse
proxy to Grafana source addresses where possible.

View File

@@ -0,0 +1,43 @@
[server]
# Use the VPS WireGuard address in production. Never use 0.0.0.0.
listen = "10.10.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]
# Requires read access to journald. Keep units narrowly allow-listed.
enabled = false
allowed_units = ["vps-agent.service", "nginx.service"]
max_entries_per_request = 200
max_message_bytes = 16384
[tls]
enabled = false
certificate_file = ""
private_key_file = ""

View File

@@ -0,0 +1,6 @@
# Start from the maintained file in control-plane/:
# cp control-plane/.env.example control-plane/.env
# docker compose -f control-plane/docker-compose.yml up -d --build
include:
- ../control-plane/docker-compose.yml

38
scripts/README.md Normal file
View File

@@ -0,0 +1,38 @@
# Agent lifecycle script
`vps-agentctl.sh` installs signed/checksummed release artifacts without piping a
network response into a shell. Run a locally inspected copy as root.
```bash
./vps-agentctl.sh install --version v0.1.0
./vps-agentctl.sh update --version v0.1.1
./vps-agentctl.sh status
./vps-agentctl.sh restart
./vps-agentctl.sh version
./vps-agentctl.sh rollback
./vps-agentctl.sh remove
./vps-agentctl.sh remove --purge --yes
```
Set `VPS_AGENT_RELEASES_URL` or pass `--releases-url` for a GitHub-compatible
releases root. Set `VPS_AGENT_MINISIGN_PUBLIC_KEY` to require verification of
`SHA256SUMS.minisig`; checksums are always mandatory. Supported artifact names
are `vps-agent-linux-amd64` and `vps-agent-linux-arm64`.
Maintainers can create that release layout from two cross-compiled binaries:
```bash
./package-agent-release.sh path/to/x86_64/vps-agent path/to/aarch64/vps-agent dist
```
This produces both canonical names and `SHA256SUMS`; when
`VPS_AGENT_MINISIGN_SECRET_KEY` is set it also creates `SHA256SUMS.minisig`.
Install checks root, Linux/systemd and architecture, creates the locked service
user/directories, validates the binary before installation, writes safe config
and a random token, installs hardening, and starts the unit. Update stages the
new binary and automatically restores the previous one when the service health
check fails. `rollback` swaps current/previous versions.
Remove preserves `/etc/vps-agent` by default. `--purge` removes credentials and
requires a terminal confirmation or the explicit `--yes` flag.

58
scripts/README_RU.md Normal file
View File

@@ -0,0 +1,58 @@
# Скрипт управления жизненным циклом агента
`vps-agentctl.sh` устанавливает подписанные и проверенные по контрольной сумме артефакты релизов, не передавая сетевой ответ напрямую в shell. Запускайте локально проверенную копию скрипта от имени `root`.
```bash
./vps-agentctl.sh install --version v0.1.0
./vps-agentctl.sh update --version v0.1.1
./vps-agentctl.sh status
./vps-agentctl.sh restart
./vps-agentctl.sh version
./vps-agentctl.sh rollback
./vps-agentctl.sh remove
./vps-agentctl.sh remove --purge --yes
```
Укажите переменную окружения `VPS_AGENT_RELEASES_URL` или передайте параметр `--releases-url`, чтобы задать корневой URL репозитория релизов, совместимого с GitHub Releases.
Чтобы сделать обязательной проверку файла `SHA256SUMS.minisig`, задайте переменную `VPS_AGENT_MINISIGN_PUBLIC_KEY`. Проверка контрольных сумм обязательна всегда.
Поддерживаются следующие имена артефактов:
- `vps-agent-linux-amd64`
- `vps-agent-linux-arm64`
Сопровождающие проект разработчики могут сформировать необходимую структуру релиза из двух заранее скомпилированных бинарных файлов:
```bash
./package-agent-release.sh path/to/x86_64/vps-agent path/to/aarch64/vps-agent dist
```
Этот скрипт создаёт файлы с каноническими именами, а также `SHA256SUMS`. Если задана переменная `VPS_AGENT_MINISIGN_SECRET_KEY`, дополнительно создаётся файл подписи `SHA256SUMS.minisig`.
Во время установки скрипт:
- проверяет наличие прав `root`;
- проверяет, что система работает под Linux и использует `systemd`;
- определяет и проверяет архитектуру процессора;
- создаёт изолированного системного пользователя и необходимые директории;
- проверяет бинарный файл перед установкой;
- создаёт безопасную конфигурацию и случайный токен;
- устанавливает настройки усиления безопасности (`hardening`);
- запускает systemd-службу агента.
При обновлении новая версия бинарного файла сначала устанавливается во временное состояние. После этого выполняется проверка работоспособности службы. Если проверка завершается неудачно, автоматически восстанавливается предыдущая версия.
Команда `rollback` меняет местами текущую и предыдущую версии агента.
По умолчанию команда удаления сохраняет каталог:
```text
/etc/vps-agent
```
Параметр `--purge` дополнительно удаляет конфигурацию и учётные данные агента. Для этого требуется подтверждение через терминал либо явное указание флага:
```bash
--yes
```

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
if [[ $# -ne 3 ]]; then
printf 'Usage: %s X86_64_BINARY AARCH64_BINARY OUTPUT_DIRECTORY\n' "$0" >&2
exit 2
fi
AMD64_BINARY="$1"
ARM64_BINARY="$2"
OUTPUT_DIRECTORY="$3"
[[ -x "$AMD64_BINARY" ]] || { printf 'amd64 binary is not executable\n' >&2; exit 1; }
[[ -x "$ARM64_BINARY" ]] || { printf 'arm64 binary is not executable\n' >&2; exit 1; }
command -v sha256sum >/dev/null || { printf 'sha256sum is required\n' >&2; exit 1; }
command -v file >/dev/null || { printf 'file is required\n' >&2; exit 1; }
AMD64_DESCRIPTION="$(file -b "$AMD64_BINARY")"
ARM64_DESCRIPTION="$(file -b "$ARM64_BINARY")"
[[ "$AMD64_DESCRIPTION" == *ELF* && "$AMD64_DESCRIPTION" == *x86-64* ]] || {
printf 'amd64 artifact is not an x86-64 Linux ELF binary\n' >&2; exit 1;
}
[[ "$ARM64_DESCRIPTION" == *ELF* && ("$ARM64_DESCRIPTION" == *aarch64* || "$ARM64_DESCRIPTION" == *ARM64*) ]] || {
printf 'arm64 artifact is not an aarch64 Linux ELF binary\n' >&2; exit 1;
}
install -d -m 0755 "$OUTPUT_DIRECTORY"
install -m 0755 "$AMD64_BINARY" "$OUTPUT_DIRECTORY/vps-agent-linux-amd64"
install -m 0755 "$ARM64_BINARY" "$OUTPUT_DIRECTORY/vps-agent-linux-arm64"
(
cd "$OUTPUT_DIRECTORY"
sha256sum vps-agent-linux-amd64 vps-agent-linux-arm64 > SHA256SUMS
if [[ -n "${VPS_AGENT_MINISIGN_SECRET_KEY:-}" ]]; then
command -v minisign >/dev/null || { printf 'minisign is required for signing\n' >&2; exit 1; }
minisign -Sm SHA256SUMS -s "$VPS_AGENT_MINISIGN_SECRET_KEY"
fi
)

307
scripts/vps-agentctl.sh Executable file
View File

@@ -0,0 +1,307 @@
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
readonly PROGRAM="vps-agent"
readonly BINARY="/usr/local/bin/vps-agent"
readonly PREVIOUS="/usr/local/lib/vps-agent/vps-agent.previous"
readonly CONFIG_DIR="/etc/vps-agent"
readonly CREDENTIAL_DIR="${CONFIG_DIR}/credentials"
readonly STATE_DIR="/var/lib/vps-agent"
readonly UNIT="/etc/systemd/system/vps-agent.service"
readonly DEFAULT_RELEASES="https://github.com/vps-control/vps-control/releases"
VERSION="latest"
RELEASES_URL="${VPS_AGENT_RELEASES_URL:-$DEFAULT_RELEASES}"
PURGE=false
ASSUME_YES=false
TMP_DIR=""
DOWNLOADED_BINARY=""
usage() {
printf '%s\n' "Usage: $0 <install|update|remove|rollback|status|restart|version> [options]" \
" --version VERSION release tag, or latest" \
" --releases-url URL releases root (default: VPS_AGENT_RELEASES_URL)" \
" --purge remove configuration and credentials" \
" --yes confirm destructive purge"
}
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
info() { printf '==> %s\n' "$*"; }
cleanup() {
if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then
rm -rf -- "$TMP_DIR"
fi
}
trap cleanup EXIT
require_root() { [[ ${EUID:-$(id -u)} -eq 0 ]] || die "run this action as root"; }
check_host() {
[[ "$(uname -s)" == "Linux" ]] || die "only Linux is supported"
command -v systemctl >/dev/null || die "systemd is required"
[[ -d /run/systemd/system ]] || die "systemd is not running"
}
release_arch() {
case "$(uname -m)" in
x86_64|amd64) printf 'amd64\n' ;;
aarch64|arm64) printf 'arm64\n' ;;
*) die "unsupported architecture: $(uname -m)" ;;
esac
}
download_release() {
command -v curl >/dev/null || die "curl is required"
command -v sha256sum >/dev/null || die "sha256sum is required"
TMP_DIR="$(mktemp -d -t vps-agent.XXXXXXXX)"
chmod 700 "$TMP_DIR"
local artifact="vps-agent-linux-$(release_arch)"
local release_url
if [[ "$VERSION" == "latest" ]]; then
release_url="${RELEASES_URL%/}/latest/download"
else
[[ "$VERSION" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$ ]] || die "invalid release version"
release_url="${RELEASES_URL%/}/download/${VERSION}"
fi
info "downloading ${artifact} (${VERSION})"
curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
--output "$TMP_DIR/$artifact" "$release_url/$artifact"
curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
--output "$TMP_DIR/SHA256SUMS" "$release_url/SHA256SUMS"
local expected actual
expected="$(awk -v name="$artifact" '$2 == name || $2 == "*" name { print $1; exit }' "$TMP_DIR/SHA256SUMS")"
[[ "$expected" =~ ^[a-fA-F0-9]{64}$ ]] || die "artifact checksum is absent or malformed"
actual="$(sha256sum "$TMP_DIR/$artifact" | awk '{print $1}')"
[[ "$actual" == "$expected" ]] || die "checksum verification failed"
if [[ -n "${VPS_AGENT_MINISIGN_PUBLIC_KEY:-}" ]]; then
command -v minisign >/dev/null || die "minisign is required when a public key is configured"
curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
--output "$TMP_DIR/SHA256SUMS.minisig" "$release_url/SHA256SUMS.minisig"
minisign -Vm "$TMP_DIR/SHA256SUMS" -P "$VPS_AGENT_MINISIGN_PUBLIC_KEY"
fi
chmod 0755 "$TMP_DIR/$artifact"
"$TMP_DIR/$artifact" --version >/dev/null || die "downloaded binary self-check failed"
DOWNLOADED_BINARY="$TMP_DIR/$artifact"
}
install_unit() {
if [[ -f "$(cd "$(dirname "$0")" && pwd)/../agent/packaging/vps-agent.service" ]]; then
install -o root -g root -m 0644 \
"$(cd "$(dirname "$0")" && pwd)/../agent/packaging/vps-agent.service" "$UNIT"
return
fi
install -o root -g root -m 0644 /dev/null "$UNIT"
tee "$UNIT" >/dev/null <<'EOF'
[Unit]
Description=VPS Control monitoring agent
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
MemoryHigh=48M
MemoryMax=64M
CPUQuota=10%
TasksMax=64
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
ProcSubset=all
ProtectProc=invisible
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
[Install]
WantedBy=multi-user.target
EOF
}
create_config() {
[[ -f "$CONFIG_DIR/config.toml" ]] && return
install -o root -g vps-agent -m 0640 /dev/null "$CONFIG_DIR/config.toml"
tee "$CONFIG_DIR/config.toml" >/dev/null <<'EOF'
[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 = []
[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 = ""
EOF
}
create_token() {
local token_file="$CREDENTIAL_DIR/control-plane.token"
[[ -f "$token_file" ]] && return
if command -v openssl >/dev/null; then
umask 077
openssl rand -hex 32 > "$token_file"
else
umask 077
od -An -N32 -tx1 /dev/urandom | tr -d ' \n' > "$token_file"
printf '\n' >> "$token_file"
fi
chown vps-agent:vps-agent "$token_file"
chmod 0600 "$token_file"
info "created agent token at $token_file; copy it through a secure channel"
}
wait_healthy() {
local attempts=15
while (( attempts > 0 )); do
if systemctl is-active --quiet vps-agent.service; then
return 0
fi
sleep 1
attempts=$((attempts - 1))
done
systemctl status --no-pager vps-agent.service || true
return 1
}
do_install() {
require_root; check_host
[[ ! -e "$BINARY" ]] || die "$BINARY already exists; use update"
local downloaded
download_release
downloaded="$DOWNLOADED_BINARY"
getent group vps-agent >/dev/null || groupadd --system vps-agent
id -u vps-agent >/dev/null 2>&1 || useradd --system --gid vps-agent --home-dir /nonexistent --shell /usr/sbin/nologin vps-agent
install -d -o root -g vps-agent -m 0750 "$CONFIG_DIR" "$CREDENTIAL_DIR"
install -d -o vps-agent -g vps-agent -m 0700 "$STATE_DIR"
install -d -o root -g root -m 0755 "$(dirname "$PREVIOUS")"
install -o root -g root -m 0755 "$downloaded" "$BINARY"
create_config
create_token
install_unit
systemctl daemon-reload
systemctl enable --now vps-agent.service
wait_healthy || die "agent failed to become healthy"
systemctl status --no-pager vps-agent.service
}
do_update() {
require_root; check_host
[[ -x "$BINARY" ]] || die "agent is not installed"
local downloaded staged
download_release
downloaded="$DOWNLOADED_BINARY"
staged="$(dirname "$BINARY")/.vps-agent.new"
install -o root -g root -m 0755 "$downloaded" "$staged"
install -o root -g root -m 0755 "$BINARY" "$PREVIOUS"
mv -f -- "$staged" "$BINARY"
systemctl restart vps-agent.service
if ! wait_healthy; then
info "health check failed; restoring previous binary"
install -o root -g root -m 0755 "$PREVIOUS" "$BINARY"
systemctl restart vps-agent.service
wait_healthy || die "rollback also failed; inspect systemctl status"
die "update rolled back"
fi
info "updated to $($BINARY --version)"
}
do_rollback() {
require_root; check_host
[[ -x "$PREVIOUS" ]] || die "no previous binary is available"
local current="$(dirname "$BINARY")/.vps-agent.current"
install -o root -g root -m 0755 "$BINARY" "$current"
install -o root -g root -m 0755 "$PREVIOUS" "$BINARY"
mv -f -- "$current" "$PREVIOUS"
systemctl restart vps-agent.service
wait_healthy || die "rolled-back version did not become healthy"
}
do_remove() {
require_root; check_host
if [[ "$PURGE" == true && "$ASSUME_YES" != true ]]; then
[[ -t 0 ]] || die "--purge requires an interactive terminal or --yes"
read -r -p "Permanently delete $CONFIG_DIR and credentials? [y/N] " answer
[[ "$answer" == "y" || "$answer" == "Y" ]] || die "purge cancelled"
fi
systemctl disable --now vps-agent.service 2>/dev/null || true
rm -f -- "$UNIT" "$BINARY" "$PREVIOUS"
rm -rf -- "$STATE_DIR"
systemctl daemon-reload
if [[ "$PURGE" == true ]]; then
rm -rf -- "$CONFIG_DIR"
info "removed configuration and credentials; this cannot be recovered"
else
info "preserved $CONFIG_DIR (use remove --purge to delete it)"
fi
userdel vps-agent 2>/dev/null || true
groupdel vps-agent 2>/dev/null || true
}
[[ $# -ge 1 ]] || { usage; exit 2; }
ACTION="$1"; shift
while [[ $# -gt 0 ]]; do
case "$1" in
--version) [[ $# -ge 2 ]] || die "--version needs a value"; VERSION="$2"; shift 2 ;;
--releases-url) [[ $# -ge 2 ]] || die "--releases-url needs a value"; RELEASES_URL="$2"; shift 2 ;;
--purge) PURGE=true; shift ;;
--yes) ASSUME_YES=true; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option: $1" ;;
esac
done
case "$ACTION" in
install) do_install ;;
update) do_update ;;
remove) do_remove ;;
rollback) do_rollback ;;
status) systemctl status --no-pager vps-agent.service ;;
restart) require_root; systemctl restart vps-agent.service; wait_healthy ;;
version) [[ -x "$BINARY" ]] && "$BINARY" --version || die "agent is not installed" ;;
*) usage; exit 2 ;;
esac