git init
This commit is contained in:
64
.github/workflows/ci.yml
vendored
Normal file
64
.github/workflows/ci.yml
vendored
Normal 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
10
.gitignore
vendored
Normal 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
94
AGENTS.md
Normal 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
18
LICENSE
Normal 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
79
README.md
Normal 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
100
README_RU.md
Normal 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
1229
agent/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
agent/Cargo.toml
Normal file
31
agent/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "vps-agent"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.80"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
description = "Small, constrained Linux VPS monitoring agent"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
axum = { version = "0.7", features = ["json", "macros"] }
|
||||||
|
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||||
|
nix = { version = "0.29", features = ["fs"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
subtle = "2.6"
|
||||||
|
thiserror = "2.0"
|
||||||
|
tokio = { version = "1.40", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
|
||||||
|
toml = "0.8"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.12"
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
72
agent/README.md
Normal file
72
agent/README.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# VPS Agent
|
||||||
|
|
||||||
|
The agent is a Rust system daemon with two Tokio workers, no database, no metric
|
||||||
|
history, and no periodic polling. It exposes authenticated semantic JSON APIs and
|
||||||
|
never accepts commands or shell fragments.
|
||||||
|
|
||||||
|
## Build and test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --manifest-path Cargo.toml --check
|
||||||
|
cargo clippy --manifest-path Cargo.toml --all-targets -- -D warnings
|
||||||
|
cargo test --manifest-path Cargo.toml
|
||||||
|
cargo build --manifest-path Cargo.toml --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The release profile enables thin LTO, strips symbols, uses one codegen unit, and
|
||||||
|
aborts on panic. Output is one binary at `target/release/vps-agent`.
|
||||||
|
|
||||||
|
## Configuration and permissions
|
||||||
|
|
||||||
|
Use `config/config.toml` as the reference. Configuration belongs at
|
||||||
|
`/etc/vps-agent/config.toml`, owned `root:vps-agent` mode 0640. The application
|
||||||
|
token belongs at `/etc/vps-agent/credentials/control-plane.token`, owned by the
|
||||||
|
`vps-agent` service user and mode 0600.
|
||||||
|
Generate at least 32 random bytes. Do not put credentials in TOML.
|
||||||
|
|
||||||
|
The default bind is loopback. For remote use, select only the VPS WireGuard IP;
|
||||||
|
an unspecified address is rejected. TLS can be enabled with PEM certificate/key
|
||||||
|
paths. WireGuard plus the application token is the recommended v1 mode.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Send `Authorization: Bearer TOKEN` on every `/v1` request. `/healthz` is public
|
||||||
|
and returns liveness only. The complete route table is in
|
||||||
|
`../docs/architecture.md`. Limits default to 64 KiB bodies, 32 concurrent calls,
|
||||||
|
five-second requests, 10 read rps, 2 write rps, and burst 20.
|
||||||
|
|
||||||
|
System data comes from `/proc` and `/sys`; fixed `ip`, `nft`/`iptables`,
|
||||||
|
`fail2ban-client`, `wg`, and `systemctl` invocations augment it. User input is
|
||||||
|
never part of a command string. Jail names and IPs are validated, and systemd
|
||||||
|
names must exactly match the configuration allow-list.
|
||||||
|
|
||||||
|
`/v1/logs` is disabled by default. When enabled, it streams bounded,
|
||||||
|
cursor-paginated JSON from `journalctl` for `[logs].allowed_units` only. The
|
||||||
|
backend polls and retains these rows; Grafana never calls the endpoint. The
|
||||||
|
`vps-agent` user normally needs membership in `systemd-journal`. That membership
|
||||||
|
widens what the local process can read, so the installer does not add it and an
|
||||||
|
administrator must opt in after review.
|
||||||
|
|
||||||
|
## systemd and privilege
|
||||||
|
|
||||||
|
Install `packaging/vps-agent.service`. It caps memory at 64 MiB, CPU at 10%,
|
||||||
|
tasks at 64, removes all capabilities, sets `NoNewPrivileges`, and enables
|
||||||
|
filesystem/kernel/namespace hardening. The agent logs to journald and does not
|
||||||
|
write successful polls or metrics to disk.
|
||||||
|
|
||||||
|
Read APIs work unprivileged on ordinary supported distributions. Process owners
|
||||||
|
may be absent due to `/proc` permissions. Fail2Ban changes and service restarts
|
||||||
|
will return an error unless the administrator adds narrowly scoped polkit/sudo
|
||||||
|
policy; the installer deliberately grants none. Firewall is read-only in v1.
|
||||||
|
|
||||||
|
## Lifecycle and troubleshooting
|
||||||
|
|
||||||
|
Use `../scripts/vps-agentctl.sh` for install, update, rollback, and removal. Check
|
||||||
|
`systemctl status vps-agent` and `journalctl -u vps-agent --since today`. Validate
|
||||||
|
the bind address, token permissions, WireGuard route, and executable availability.
|
||||||
|
Rotate a token atomically and restart both ends as described in the security doc.
|
||||||
|
|
||||||
|
Resource targets are <=30 MiB idle RSS, near-zero idle CPU and no steady disk
|
||||||
|
writes. Measure on the target distribution with `systemctl status`,
|
||||||
|
`systemd-cgtop`, and `/proc/PID/status`; macOS development builds are not a valid
|
||||||
|
resource benchmark.
|
||||||
41
agent/config/config.toml
Normal file
41
agent/config/config.toml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
[server]
|
||||||
|
listen = "127.0.0.1:9105"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
mode = "token"
|
||||||
|
token_file = "/etc/vps-agent/credentials/control-plane.token"
|
||||||
|
|
||||||
|
[limits]
|
||||||
|
max_concurrent_requests = 32
|
||||||
|
request_timeout_seconds = 5
|
||||||
|
max_body_bytes = 65536
|
||||||
|
read_requests_per_second = 10.0
|
||||||
|
write_requests_per_second = 2.0
|
||||||
|
burst = 20.0
|
||||||
|
command_timeout_seconds = 4
|
||||||
|
|
||||||
|
[services]
|
||||||
|
allowed = ["nginx", "fail2ban", "wg-quick@wg0"]
|
||||||
|
|
||||||
|
[firewall]
|
||||||
|
management_enabled = false
|
||||||
|
rollback_timeout_seconds = 60
|
||||||
|
max_snapshots = 5
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
level = "info"
|
||||||
|
|
||||||
|
[telemetry]
|
||||||
|
connections_enabled = true
|
||||||
|
process_info_enabled = true
|
||||||
|
|
||||||
|
[logs]
|
||||||
|
enabled = false
|
||||||
|
allowed_units = ["vps-agent.service"]
|
||||||
|
max_entries_per_request = 200
|
||||||
|
max_message_bytes = 16384
|
||||||
|
|
||||||
|
[tls]
|
||||||
|
enabled = false
|
||||||
|
certificate_file = ""
|
||||||
|
private_key_file = ""
|
||||||
50
agent/packaging/vps-agent.service
Normal file
50
agent/packaging/vps-agent.service
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=VPS Control monitoring agent
|
||||||
|
Documentation=https://github.com/example/vps-control
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=vps-agent
|
||||||
|
Group=vps-agent
|
||||||
|
ExecStart=/usr/local/bin/vps-agent --config /etc/vps-agent/config.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
TimeoutStopSec=15s
|
||||||
|
|
||||||
|
# Resource controls
|
||||||
|
MemoryHigh=48M
|
||||||
|
MemoryMax=64M
|
||||||
|
CPUQuota=10%
|
||||||
|
TasksMax=64
|
||||||
|
|
||||||
|
# Filesystem and privilege hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelLogs=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectClock=true
|
||||||
|
ProtectHostname=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
LockPersonality=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
|
# Read-only host information and bounded address families.
|
||||||
|
ProcSubset=all
|
||||||
|
ProtectProc=invisible
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
501
agent/src/api.rs
Normal file
501
agent/src/api.rs
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
use crate::{
|
||||||
|
config::Config,
|
||||||
|
fail2ban::{self, BanRequest},
|
||||||
|
firewall, logs, network, services, system, wireguard,
|
||||||
|
};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::{DefaultBodyLimit, Path, Query, State},
|
||||||
|
http::{header::AUTHORIZATION, Method, Request, StatusCode},
|
||||||
|
middleware::{self, Next},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
path::Path as FilePath,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub config: Config,
|
||||||
|
token: Vec<u8>,
|
||||||
|
read_limiter: Mutex<TokenBucket>,
|
||||||
|
write_limiter: Mutex<TokenBucket>,
|
||||||
|
last_auth_warning: Mutex<Instant>,
|
||||||
|
concurrency: Arc<Semaphore>,
|
||||||
|
cpu: Mutex<Option<system::CpuTimes>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn load(config: Config) -> Result<Arc<Self>> {
|
||||||
|
let token_path = FilePath::new(&config.auth.token_file);
|
||||||
|
let token = fs::read(token_path)
|
||||||
|
.with_context(|| format!("read token file {}", token_path.display()))?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = fs::metadata(token_path)?.permissions().mode();
|
||||||
|
if mode & 0o077 != 0 {
|
||||||
|
anyhow::bail!("token file must not be accessible by group or others")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let token = trim_ascii(&token).to_vec();
|
||||||
|
if token.len() < 32 {
|
||||||
|
anyhow::bail!("agent token must contain at least 32 bytes")
|
||||||
|
}
|
||||||
|
Ok(Self::new(config, token))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(config: Config, token: Vec<u8>) -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
read_limiter: Mutex::new(TokenBucket::new(
|
||||||
|
config.limits.read_requests_per_second,
|
||||||
|
config.limits.burst,
|
||||||
|
)),
|
||||||
|
write_limiter: Mutex::new(TokenBucket::new(
|
||||||
|
config.limits.write_requests_per_second,
|
||||||
|
config.limits.burst,
|
||||||
|
)),
|
||||||
|
last_auth_warning: Mutex::new(Instant::now() - Duration::from_secs(60)),
|
||||||
|
concurrency: Arc::new(Semaphore::new(config.limits.max_concurrent_requests)),
|
||||||
|
config,
|
||||||
|
token,
|
||||||
|
cpu: Mutex::new(None),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TokenBucket {
|
||||||
|
rate: f64,
|
||||||
|
capacity: f64,
|
||||||
|
tokens: f64,
|
||||||
|
updated_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenBucket {
|
||||||
|
fn new(rate: f64, capacity: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
rate,
|
||||||
|
capacity,
|
||||||
|
tokens: capacity,
|
||||||
|
updated_at: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allow(&mut self, now: Instant) -> bool {
|
||||||
|
let elapsed = now.duration_since(self.updated_at).as_secs_f64();
|
||||||
|
self.tokens = (self.tokens + elapsed * self.rate).min(self.capacity);
|
||||||
|
self.updated_at = now;
|
||||||
|
if self.tokens >= 1.0 {
|
||||||
|
self.tokens -= 1.0;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(state: Arc<AppState>) -> Router {
|
||||||
|
let max_body = state.config.limits.max_body_bytes;
|
||||||
|
Router::new()
|
||||||
|
.route("/healthz", get(health))
|
||||||
|
.route("/v1/version", get(version))
|
||||||
|
.route("/v1/system/status", get(system_status))
|
||||||
|
.route("/v1/network/interfaces", get(network_interfaces))
|
||||||
|
.route("/v1/network/routes", get(network_routes))
|
||||||
|
.route("/v1/network/listeners", get(network_listeners))
|
||||||
|
.route("/v1/network/connections", get(network_connections))
|
||||||
|
.route("/v1/firewall/rules", get(firewall_rules))
|
||||||
|
.route("/v1/fail2ban/status", get(fail2ban_status))
|
||||||
|
.route("/v1/fail2ban/jails/:jail", get(fail2ban_jail))
|
||||||
|
.route("/v1/fail2ban/ban", post(fail2ban_ban))
|
||||||
|
.route("/v1/fail2ban/unban", post(fail2ban_unban))
|
||||||
|
.route("/v1/wireguard/status", get(wireguard_status))
|
||||||
|
.route("/v1/services", get(service_list))
|
||||||
|
.route("/v1/services/:name/restart", post(service_restart))
|
||||||
|
.route("/v1/logs", get(log_entries))
|
||||||
|
.layer(DefaultBodyLimit::max(max_body))
|
||||||
|
.layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
security_middleware,
|
||||||
|
))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn security_middleware(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
request: Request<Body>,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let is_health = request.uri().path() == "/healthz";
|
||||||
|
|
||||||
|
let header_bytes: usize = request
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.map(|(name, value)| name.as_str().len().saturating_add(value.as_bytes().len()))
|
||||||
|
.sum();
|
||||||
|
if header_bytes > 16 * 1024 {
|
||||||
|
return error_response(
|
||||||
|
StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE,
|
||||||
|
"request headers are too large",
|
||||||
|
"headers_too_large",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_read = matches!(*request.method(), Method::GET | Method::HEAD);
|
||||||
|
let allowed = if is_read {
|
||||||
|
state
|
||||||
|
.read_limiter
|
||||||
|
.lock()
|
||||||
|
.expect("rate mutex poisoned")
|
||||||
|
.allow(Instant::now())
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
.write_limiter
|
||||||
|
.lock()
|
||||||
|
.expect("rate mutex poisoned")
|
||||||
|
.allow(Instant::now())
|
||||||
|
};
|
||||||
|
if !allowed {
|
||||||
|
return error_response(
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"request rate exceeded",
|
||||||
|
"rate_limited",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if is_health {
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let authenticated = request
|
||||||
|
.headers()
|
||||||
|
.get(AUTHORIZATION)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.strip_prefix("Bearer "))
|
||||||
|
.is_some_and(|provided| constant_time_eq(provided.as_bytes(), &state.token));
|
||||||
|
if !authenticated {
|
||||||
|
let mut last_warning = state
|
||||||
|
.last_auth_warning
|
||||||
|
.lock()
|
||||||
|
.expect("authentication warning mutex poisoned");
|
||||||
|
if last_warning.elapsed() >= Duration::from_secs(10) {
|
||||||
|
tracing::warn!("agent authentication failed (repeated failures are coalesced)");
|
||||||
|
*last_warning = Instant::now();
|
||||||
|
}
|
||||||
|
return error_response(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"authentication required",
|
||||||
|
"unauthorized",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(_permit) = state.concurrency.clone().try_acquire_owned() else {
|
||||||
|
return error_response(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"request concurrency exceeded",
|
||||||
|
"busy",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
match tokio::time::timeout(
|
||||||
|
Duration::from_secs(state.config.limits.request_timeout_seconds),
|
||||||
|
next.run(request),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(_) => error_response(StatusCode::GATEWAY_TIMEOUT, "request timed out", "timeout"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constant_time_eq(provided: &[u8], expected: &[u8]) -> bool {
|
||||||
|
provided.len() == expected.len() && bool::from(provided.ct_eq(expected))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_ascii(mut value: &[u8]) -> &[u8] {
|
||||||
|
while value.first().is_some_and(u8::is_ascii_whitespace) {
|
||||||
|
value = &value[1..];
|
||||||
|
}
|
||||||
|
while value.last().is_some_and(u8::is_ascii_whitespace) {
|
||||||
|
value = &value[..value.len() - 1];
|
||||||
|
}
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health() -> Json<Value> {
|
||||||
|
Json(json!({"status": "ok"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn version() -> Json<Value> {
|
||||||
|
Json(json!({"version": env!("CARGO_PKG_VERSION"), "api_version": "v1"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn system_status(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<Json<system::Status>, ApiError> {
|
||||||
|
let state_for_work = state.clone();
|
||||||
|
let status = tokio::task::spawn_blocking(move || system::collect(&state_for_work.cpu))
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::internal())??;
|
||||||
|
Ok(Json(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn network_interfaces(State(state): State<Arc<AppState>>) -> Json<Vec<network::Interface>> {
|
||||||
|
Json(network::interfaces(command_timeout(&state)).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn network_routes(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
|
||||||
|
Ok(Json(network::routes(command_timeout(&state)).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn network_listeners(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<Json<Vec<network::SocketInfo>>, ApiError> {
|
||||||
|
let include_process = state.config.telemetry.process_info_enabled;
|
||||||
|
let rows = tokio::task::spawn_blocking(move || network::sockets(include_process))
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::internal())??;
|
||||||
|
Ok(Json(network::listeners(rows)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn network_connections(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<Json<Vec<network::SocketInfo>>, ApiError> {
|
||||||
|
if !state.config.telemetry.connections_enabled {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"connection telemetry disabled",
|
||||||
|
"disabled",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let include_process = state.config.telemetry.process_info_enabled;
|
||||||
|
let rows = tokio::task::spawn_blocking(move || network::sockets(include_process))
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::internal())??;
|
||||||
|
Ok(Json(
|
||||||
|
rows.into_iter()
|
||||||
|
.filter(|row| row.state != "LISTEN")
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn firewall_rules(State(state): State<Arc<AppState>>) -> Json<firewall::FirewallStatus> {
|
||||||
|
Json(firewall::status(command_timeout(&state)).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fail2ban_status(State(state): State<Arc<AppState>>) -> Json<fail2ban::Status> {
|
||||||
|
Json(fail2ban::status(command_timeout(&state)).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fail2ban_jail(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(jail): Path<String>,
|
||||||
|
) -> Result<Json<fail2ban::JailStatus>, ApiError> {
|
||||||
|
fail2ban::validate_jail(&jail).map_err(|_| ApiError::bad_request("invalid jail name"))?;
|
||||||
|
Ok(Json(fail2ban::jail(&jail, command_timeout(&state)).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fail2ban_ban(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(request): Json<BanRequest>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
mutate_ban(&state, request, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fail2ban_unban(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(request): Json<BanRequest>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
mutate_ban(&state, request, true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mutate_ban(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
request: BanRequest,
|
||||||
|
unban: bool,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
fail2ban::validate_jail(&request.jail)
|
||||||
|
.map_err(|_| ApiError::bad_request("invalid jail name"))?;
|
||||||
|
request
|
||||||
|
.ip
|
||||||
|
.parse::<std::net::IpAddr>()
|
||||||
|
.map_err(|_| ApiError::bad_request("invalid IP address"))?;
|
||||||
|
fail2ban::ban(&request, unban, command_timeout(state)).await?;
|
||||||
|
let action = if unban { "unban" } else { "ban" };
|
||||||
|
tracing::info!(
|
||||||
|
action,
|
||||||
|
jail = request.jail,
|
||||||
|
ip = request.ip,
|
||||||
|
"privileged Fail2Ban action completed"
|
||||||
|
);
|
||||||
|
Ok(Json(json!({"status": "ok"})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wireguard_status(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<Json<Vec<wireguard::Interface>>, ApiError> {
|
||||||
|
Ok(Json(wireguard::status(command_timeout(&state)).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn service_list(State(state): State<Arc<AppState>>) -> Json<Vec<services::ServiceStatus>> {
|
||||||
|
Json(services::list(&state.config.services.allowed, command_timeout(&state)).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn service_restart(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
if !state
|
||||||
|
.config
|
||||||
|
.services
|
||||||
|
.allowed
|
||||||
|
.iter()
|
||||||
|
.any(|item| item == &name)
|
||||||
|
{
|
||||||
|
return Err(ApiError::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"service is not allow-listed",
|
||||||
|
"not_allowed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
services::restart(
|
||||||
|
&name,
|
||||||
|
&state.config.services.allowed,
|
||||||
|
command_timeout(&state),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(service = name, "allow-listed service restarted");
|
||||||
|
Ok(Json(json!({"status": "ok"})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn log_entries(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Query(query): Query<logs::LogsQuery>,
|
||||||
|
) -> Result<Json<logs::LogsResponse>, ApiError> {
|
||||||
|
if !state.config.logs.enabled {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"log collection is disabled",
|
||||||
|
"disabled",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Json(
|
||||||
|
logs::collect(&state.config.logs, query, command_timeout(&state)).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_timeout(state: &AppState) -> Duration {
|
||||||
|
system::command_timeout(state.config.limits.command_timeout_seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ApiError {
|
||||||
|
status: StatusCode,
|
||||||
|
message: &'static str,
|
||||||
|
code: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiError {
|
||||||
|
fn new(status: StatusCode, message: &'static str, code: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn bad_request(message: &'static str) -> Self {
|
||||||
|
Self::new(StatusCode::BAD_REQUEST, message, "invalid_request")
|
||||||
|
}
|
||||||
|
fn internal() -> Self {
|
||||||
|
Self::new(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error",
|
||||||
|
"internal_error",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<anyhow::Error> for ApiError {
|
||||||
|
fn from(error: anyhow::Error) -> Self {
|
||||||
|
tracing::error!(error = %error, "agent operation failed");
|
||||||
|
Self::new(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
"host operation failed",
|
||||||
|
"host_error",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
error_response(self.status, self.message, self.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_response(status: StatusCode, message: &str, code: &str) -> Response {
|
||||||
|
(status, Json(json!({"error": message, "code": code}))).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::http::Request;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_comparison_is_exact() {
|
||||||
|
assert!(constant_time_eq(
|
||||||
|
b"abcdefghijklmnopqrstuvwxyz123456",
|
||||||
|
b"abcdefghijklmnopqrstuvwxyz123456"
|
||||||
|
));
|
||||||
|
assert!(!constant_time_eq(
|
||||||
|
b"wrong",
|
||||||
|
b"abcdefghijklmnopqrstuvwxyz123456"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limiter_refills_and_bounds() {
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut limiter = TokenBucket::new(1.0, 2.0);
|
||||||
|
assert!(limiter.allow(start));
|
||||||
|
assert!(limiter.allow(start));
|
||||||
|
assert!(!limiter.allow(start));
|
||||||
|
assert!(limiter.allow(start + Duration::from_secs(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn health_is_public_but_api_is_not() {
|
||||||
|
let state = AppState::new(
|
||||||
|
Config::default(),
|
||||||
|
b"abcdefghijklmnopqrstuvwxyz123456".to_vec(),
|
||||||
|
);
|
||||||
|
let app = router(state);
|
||||||
|
let health = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/healthz")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(health.status(), StatusCode::OK);
|
||||||
|
let denied = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/v1/version")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(denied.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
agent/src/command.rs
Normal file
109
agent/src/command.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use std::{process::Stdio, time::Duration};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, BufReader},
|
||||||
|
process::Command,
|
||||||
|
time::timeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_STDOUT_BYTES: u64 = 2 * 1024 * 1024;
|
||||||
|
const MAX_STDERR_BYTES: u64 = 64 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Output {
|
||||||
|
pub stdout: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(program: &str, args: &[&str], timeout_duration: Duration) -> Result<Output> {
|
||||||
|
let mut command = Command::new(program);
|
||||||
|
command
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
let mut child = command
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("execute {program}"))?;
|
||||||
|
let stdout = child.stdout.take().context("capture command stdout")?;
|
||||||
|
let stderr = child.stderr.take().context("capture command stderr")?;
|
||||||
|
let (status, stdout, stderr) = timeout(timeout_duration, async move {
|
||||||
|
let stdout_reader = read_bounded(stdout, MAX_STDOUT_BYTES);
|
||||||
|
let stderr_reader = read_bounded(stderr, MAX_STDERR_BYTES);
|
||||||
|
let (status, stdout, stderr) = tokio::join!(child.wait(), stdout_reader, stderr_reader);
|
||||||
|
Ok::<_, anyhow::Error>((status?, stdout?, stderr?))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("command timed out")??;
|
||||||
|
let stdout = String::from_utf8_lossy(&stdout).into_owned();
|
||||||
|
let stderr = String::from_utf8_lossy(&stderr).into_owned();
|
||||||
|
if !status.success() {
|
||||||
|
bail!("{program} failed: {}", stderr.trim())
|
||||||
|
}
|
||||||
|
Ok(Output { stdout })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_lines(
|
||||||
|
program: &str,
|
||||||
|
args: &[&str],
|
||||||
|
timeout_duration: Duration,
|
||||||
|
max_lines: usize,
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
let mut command = Command::new(program);
|
||||||
|
command
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
let mut child = command
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("execute {program}"))?;
|
||||||
|
let stdout = child.stdout.take().context("capture command stdout")?;
|
||||||
|
let stderr = child.stderr.take().context("capture command stderr")?;
|
||||||
|
timeout(timeout_duration, async move {
|
||||||
|
let stderr_reader = tokio::spawn(read_bounded(stderr, MAX_STDERR_BYTES));
|
||||||
|
let mut stdout_reader = BufReader::new(stdout.take(MAX_STDOUT_BYTES + 1));
|
||||||
|
let mut lines = Vec::with_capacity(max_lines);
|
||||||
|
let mut total_bytes = 0_u64;
|
||||||
|
while lines.len() < max_lines {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
let count = stdout_reader.read_until(b'\n', &mut bytes).await?;
|
||||||
|
if count == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
total_bytes = total_bytes.saturating_add(count as u64);
|
||||||
|
if total_bytes > MAX_STDOUT_BYTES {
|
||||||
|
bail!("command output exceeded the configured bound")
|
||||||
|
}
|
||||||
|
while matches!(bytes.last(), Some(b'\n' | b'\r')) {
|
||||||
|
bytes.pop();
|
||||||
|
}
|
||||||
|
lines.push(String::from_utf8(bytes).context("command output is not UTF-8")?);
|
||||||
|
}
|
||||||
|
let deliberately_stopped = lines.len() == max_lines;
|
||||||
|
if deliberately_stopped {
|
||||||
|
child.kill().await.context("stop bounded command")?;
|
||||||
|
}
|
||||||
|
let status = child.wait().await?;
|
||||||
|
let stderr = stderr_reader.await.context("join stderr reader")??;
|
||||||
|
if !deliberately_stopped && !status.success() {
|
||||||
|
bail!(
|
||||||
|
"{program} failed: {}",
|
||||||
|
String::from_utf8_lossy(&stderr).trim()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Ok::<_, anyhow::Error>(lines)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("command timed out")?
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_bounded(reader: impl AsyncRead + Unpin, maximum: u64) -> Result<Vec<u8>> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
reader.take(maximum + 1).read_to_end(&mut bytes).await?;
|
||||||
|
if bytes.len() as u64 > maximum {
|
||||||
|
bail!("command output exceeded the configured bound")
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
263
agent/src/config.rs
Normal file
263
agent/src/config.rs
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::{net::SocketAddr, path::Path};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct Config {
|
||||||
|
pub server: Server,
|
||||||
|
pub auth: Auth,
|
||||||
|
pub limits: Limits,
|
||||||
|
pub services: Services,
|
||||||
|
pub firewall: Firewall,
|
||||||
|
pub logging: Logging,
|
||||||
|
pub telemetry: Telemetry,
|
||||||
|
pub logs: Logs,
|
||||||
|
pub tls: Tls,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct Server {
|
||||||
|
pub listen: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct Auth {
|
||||||
|
pub mode: String,
|
||||||
|
pub token_file: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct Limits {
|
||||||
|
pub max_concurrent_requests: usize,
|
||||||
|
pub request_timeout_seconds: u64,
|
||||||
|
pub max_body_bytes: usize,
|
||||||
|
pub read_requests_per_second: f64,
|
||||||
|
pub write_requests_per_second: f64,
|
||||||
|
pub burst: f64,
|
||||||
|
pub command_timeout_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct Services {
|
||||||
|
pub allowed: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct Firewall {
|
||||||
|
pub management_enabled: bool,
|
||||||
|
pub rollback_timeout_seconds: u64,
|
||||||
|
pub max_snapshots: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct Logging {
|
||||||
|
pub level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct Telemetry {
|
||||||
|
pub connections_enabled: bool,
|
||||||
|
pub process_info_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct Logs {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub allowed_units: Vec<String>,
|
||||||
|
pub max_entries_per_request: usize,
|
||||||
|
pub max_message_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Default)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct Tls {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub certificate_file: String,
|
||||||
|
pub private_key_file: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Server {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
listen: "127.0.0.1:9105"
|
||||||
|
.parse()
|
||||||
|
.expect("valid default listen address"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Auth {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: "token".into(),
|
||||||
|
token_file: "/etc/vps-agent/credentials/control-plane.token".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Limits {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_concurrent_requests: 32,
|
||||||
|
request_timeout_seconds: 5,
|
||||||
|
max_body_bytes: 65_536,
|
||||||
|
read_requests_per_second: 10.0,
|
||||||
|
write_requests_per_second: 2.0,
|
||||||
|
burst: 20.0,
|
||||||
|
command_timeout_seconds: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Firewall {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
management_enabled: false,
|
||||||
|
rollback_timeout_seconds: 60,
|
||||||
|
max_snapshots: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Logging {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
level: "info".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Telemetry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
connections_enabled: true,
|
||||||
|
process_info_enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Logs {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
allowed_units: vec!["vps-agent.service".into()],
|
||||||
|
max_entries_per_request: 200,
|
||||||
|
max_message_bytes: 16_384,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load(path: &Path) -> Result<Self> {
|
||||||
|
let text = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("read configuration {}", path.display()))?;
|
||||||
|
let config: Self = toml::from_str(&text).context("parse TOML configuration")?;
|
||||||
|
config.validate()?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
if self.auth.mode != "token" {
|
||||||
|
bail!("auth.mode must be 'token' in v1")
|
||||||
|
}
|
||||||
|
if self.server.listen.ip().is_unspecified() {
|
||||||
|
bail!("refusing unspecified/public listen address; bind a WireGuard IP explicitly")
|
||||||
|
}
|
||||||
|
if self.limits.max_concurrent_requests == 0 || self.limits.max_concurrent_requests > 256 {
|
||||||
|
bail!("max_concurrent_requests must be between 1 and 256")
|
||||||
|
}
|
||||||
|
if !(1..=64).contains(&self.limits.request_timeout_seconds) {
|
||||||
|
bail!("request_timeout_seconds must be between 1 and 64")
|
||||||
|
}
|
||||||
|
if !(1024..=1_048_576).contains(&self.limits.max_body_bytes) {
|
||||||
|
bail!("max_body_bytes must be between 1024 and 1048576")
|
||||||
|
}
|
||||||
|
if self.limits.read_requests_per_second <= 0.0
|
||||||
|
|| self.limits.write_requests_per_second <= 0.0
|
||||||
|
|| self.limits.burst < 1.0
|
||||||
|
{
|
||||||
|
bail!("rate limits must be positive")
|
||||||
|
}
|
||||||
|
if self.services.allowed.len() > 128 {
|
||||||
|
bail!("at most 128 services may be allow-listed")
|
||||||
|
}
|
||||||
|
if self.logs.allowed_units.len() > 128
|
||||||
|
|| !(1..=1000).contains(&self.logs.max_entries_per_request)
|
||||||
|
|| !(1024..=65_536).contains(&self.logs.max_message_bytes)
|
||||||
|
{
|
||||||
|
bail!("logs limits or allowed unit count are invalid")
|
||||||
|
}
|
||||||
|
if self.logs.enabled && self.logs.allowed_units.is_empty() {
|
||||||
|
bail!("logs.allowed_units must not be empty when log collection is enabled")
|
||||||
|
}
|
||||||
|
for name in &self.logs.allowed_units {
|
||||||
|
if !valid_service_name(name) {
|
||||||
|
bail!("invalid allow-listed log unit name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name in &self.services.allowed {
|
||||||
|
if !valid_service_name(name) {
|
||||||
|
bail!("invalid allow-listed service name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.firewall.management_enabled {
|
||||||
|
bail!("firewall management requires the v2 privileged helper and is unavailable")
|
||||||
|
}
|
||||||
|
if !matches!(
|
||||||
|
self.logging.level.as_str(),
|
||||||
|
"error" | "warn" | "info" | "debug" | "trace"
|
||||||
|
) {
|
||||||
|
bail!("logging.level is invalid")
|
||||||
|
}
|
||||||
|
if self.tls.enabled
|
||||||
|
&& (self.tls.certificate_file.is_empty() || self.tls.private_key_file.is_empty())
|
||||||
|
{
|
||||||
|
bail!("TLS certificate and private key paths are required")
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn valid_service_name(name: &str) -> bool {
|
||||||
|
!name.is_empty()
|
||||||
|
&& name.len() <= 128
|
||||||
|
&& name
|
||||||
|
.bytes()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'@' | b':'))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn safe_defaults_bind_loopback() {
|
||||||
|
let config = Config::default();
|
||||||
|
assert!(config.server.listen.ip().is_loopback());
|
||||||
|
assert!(!config.firewall.management_enabled);
|
||||||
|
config.validate().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unspecified_bind() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.server.listen = "0.0.0.0:9105".parse().unwrap();
|
||||||
|
assert!(config.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_service_names() {
|
||||||
|
assert!(valid_service_name("wg-quick@wg0.service"));
|
||||||
|
assert!(!valid_service_name("nginx;reboot"));
|
||||||
|
assert!(!valid_service_name("../../etc/passwd"));
|
||||||
|
}
|
||||||
|
}
|
||||||
157
agent/src/fail2ban.rs
Normal file
157
agent/src/fail2ban.rs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
use crate::command;
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{net::IpAddr, time::Duration};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Status {
|
||||||
|
pub installed: bool,
|
||||||
|
pub running: bool,
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub jails: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, PartialEq)]
|
||||||
|
pub struct JailStatus {
|
||||||
|
pub jail: String,
|
||||||
|
pub currently_failed: u64,
|
||||||
|
pub total_failed: u64,
|
||||||
|
pub currently_banned: u64,
|
||||||
|
pub total_banned: u64,
|
||||||
|
pub banned_ips: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct BanRequest {
|
||||||
|
pub jail: String,
|
||||||
|
pub ip: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(timeout: Duration) -> Status {
|
||||||
|
let version = match command::run("fail2ban-client", &["--version"], timeout).await {
|
||||||
|
Ok(output) => Some(output.stdout.trim().to_owned()),
|
||||||
|
Err(_) => {
|
||||||
|
return Status {
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
version: None,
|
||||||
|
jails: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match command::run("fail2ban-client", &["status"], timeout).await {
|
||||||
|
Ok(output) => Status {
|
||||||
|
installed: true,
|
||||||
|
running: true,
|
||||||
|
version,
|
||||||
|
jails: parse_jail_list(&output.stdout),
|
||||||
|
},
|
||||||
|
Err(_) => Status {
|
||||||
|
installed: true,
|
||||||
|
running: false,
|
||||||
|
version,
|
||||||
|
jails: vec![],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn jail(name: &str, timeout: Duration) -> Result<JailStatus> {
|
||||||
|
validate_jail(name)?;
|
||||||
|
let output = command::run("fail2ban-client", &["status", name], timeout).await?;
|
||||||
|
Ok(parse_jail_status(name, &output.stdout))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ban(request: &BanRequest, unban: bool, timeout: Duration) -> Result<()> {
|
||||||
|
validate_jail(&request.jail)?;
|
||||||
|
let ip: IpAddr = request
|
||||||
|
.ip
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid IP address"))?;
|
||||||
|
let operation = if unban { "unbanip" } else { "banip" };
|
||||||
|
command::run(
|
||||||
|
"fail2ban-client",
|
||||||
|
&[
|
||||||
|
"set",
|
||||||
|
request.jail.as_str(),
|
||||||
|
operation,
|
||||||
|
ip.to_string().as_str(),
|
||||||
|
],
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_jail(name: &str) -> Result<()> {
|
||||||
|
if name.is_empty()
|
||||||
|
|| name.len() > 64
|
||||||
|
|| !name
|
||||||
|
.bytes()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.'))
|
||||||
|
{
|
||||||
|
bail!("invalid jail name")
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_jail_list(text: &str) -> Vec<String> {
|
||||||
|
text.lines()
|
||||||
|
.find_map(|line| line.split_once("Jail list:"))
|
||||||
|
.map(|(_, list)| {
|
||||||
|
list.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_jail_status(name: &str, text: &str) -> JailStatus {
|
||||||
|
let mut result = JailStatus {
|
||||||
|
jail: name.to_owned(),
|
||||||
|
..JailStatus::default()
|
||||||
|
};
|
||||||
|
for line in text.lines() {
|
||||||
|
let normalized = line.trim().trim_start_matches(['|', '`', '-', '_']).trim();
|
||||||
|
let Some((key, value)) = normalized.split_once(':') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = value.trim();
|
||||||
|
match key.trim() {
|
||||||
|
"Currently failed" => result.currently_failed = value.parse().unwrap_or_default(),
|
||||||
|
"Total failed" => result.total_failed = value.parse().unwrap_or_default(),
|
||||||
|
"Currently banned" => result.currently_banned = value.parse().unwrap_or_default(),
|
||||||
|
"Total banned" => result.total_banned = value.parse().unwrap_or_default(),
|
||||||
|
"Banned IP list" => {
|
||||||
|
result.banned_ips = value.split_whitespace().map(str::to_owned).collect()
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_statuses() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_jail_list("Number of jail: 2\nJail list: sshd, nginx-http-auth\n"),
|
||||||
|
vec!["sshd", "nginx-http-auth"]
|
||||||
|
);
|
||||||
|
let jail = parse_jail_status("sshd", "Currently failed: 2\nTotal failed: 14\nCurrently banned: 1\nTotal banned: 3\nBanned IP list: 192.0.2.1\n");
|
||||||
|
assert_eq!(jail.currently_banned, 1);
|
||||||
|
assert_eq!(jail.banned_ips, vec!["192.0.2.1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_command_like_jail() {
|
||||||
|
assert!(validate_jail("sshd").is_ok());
|
||||||
|
assert!(validate_jail("sshd;shutdown").is_err());
|
||||||
|
assert!(validate_jail("../../root").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
68
agent/src/firewall.rs
Normal file
68
agent/src/firewall.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
use crate::command;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct FirewallStatus {
|
||||||
|
pub backend: String,
|
||||||
|
pub management_enabled: bool,
|
||||||
|
pub managed_table: String,
|
||||||
|
pub ruleset: Value,
|
||||||
|
pub warning: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(timeout: Duration) -> FirewallStatus {
|
||||||
|
if command::run("nft", &["--version"], timeout).await.is_ok() {
|
||||||
|
return match command::run("nft", &["--json", "list", "ruleset"], timeout).await {
|
||||||
|
Ok(output) => FirewallStatus {
|
||||||
|
backend: "nftables".into(),
|
||||||
|
management_enabled: false,
|
||||||
|
managed_table: "inet vps_control".into(),
|
||||||
|
ruleset: serde_json::from_str(&output.stdout).unwrap_or(Value::Null),
|
||||||
|
warning: None,
|
||||||
|
},
|
||||||
|
Err(error) => FirewallStatus {
|
||||||
|
backend: "nftables".into(),
|
||||||
|
management_enabled: false,
|
||||||
|
managed_table: "inet vps_control".into(),
|
||||||
|
ruleset: Value::Null,
|
||||||
|
warning: Some(format!("rules unavailable: {error}")),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (program, backend) in [
|
||||||
|
("iptables-nft", "iptables-nft"),
|
||||||
|
("iptables", "iptables-legacy"),
|
||||||
|
] {
|
||||||
|
if let Ok(version) = command::run(program, &["--version"], timeout).await {
|
||||||
|
let detected = if version.stdout.contains("nf_tables") {
|
||||||
|
"iptables-nft"
|
||||||
|
} else {
|
||||||
|
backend
|
||||||
|
};
|
||||||
|
let rules = command::run(program, &["-S"], timeout)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(|v| v.stdout);
|
||||||
|
return FirewallStatus {
|
||||||
|
backend: detected.into(),
|
||||||
|
management_enabled: false,
|
||||||
|
managed_table: "inet vps_control".into(),
|
||||||
|
ruleset: rules.map(Value::String).unwrap_or(Value::Null),
|
||||||
|
warning: Some(
|
||||||
|
"legacy-compatible firewall view is read-only and has limited structure".into(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FirewallStatus {
|
||||||
|
backend: "unavailable".into(),
|
||||||
|
management_enabled: false,
|
||||||
|
managed_table: "inet vps_control".into(),
|
||||||
|
ruleset: Value::Null,
|
||||||
|
warning: Some("no supported firewall userspace utility found".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
234
agent/src/logs.rs
Normal file
234
agent/src/logs.rs
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
use crate::{command, config::Logs};
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const MAX_LOOKBACK_SECONDS: u64 = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct LogsQuery {
|
||||||
|
pub since: Option<u64>,
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LogsResponse {
|
||||||
|
pub entries: Vec<LogEntry>,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, PartialEq)]
|
||||||
|
pub struct LogEntry {
|
||||||
|
pub cursor: String,
|
||||||
|
pub timestamp_unix_micros: u64,
|
||||||
|
pub unit: String,
|
||||||
|
pub priority: u8,
|
||||||
|
pub message: String,
|
||||||
|
pub boot_id: Option<String>,
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn collect(config: &Logs, query: LogsQuery, timeout: Duration) -> Result<LogsResponse> {
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||||
|
if query.cursor.as_ref().is_some_and(|cursor| {
|
||||||
|
cursor.is_empty() || cursor.len() > 1024 || cursor.chars().any(char::is_control)
|
||||||
|
}) {
|
||||||
|
bail!("journal cursor is invalid")
|
||||||
|
}
|
||||||
|
let limit = query
|
||||||
|
.limit
|
||||||
|
.unwrap_or(config.max_entries_per_request)
|
||||||
|
.min(config.max_entries_per_request);
|
||||||
|
if limit == 0 {
|
||||||
|
bail!("log limit must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut arguments = vec!["--no-pager".to_owned(), "--output=json".to_owned()];
|
||||||
|
if let Some(cursor) = query.cursor {
|
||||||
|
arguments.push(format!("--after-cursor={cursor}"));
|
||||||
|
} else {
|
||||||
|
let since = query.since.unwrap_or_else(|| now.saturating_sub(120));
|
||||||
|
if since > now.saturating_add(300) || now.saturating_sub(since) > MAX_LOOKBACK_SECONDS {
|
||||||
|
bail!("log lookback is outside the allowed range")
|
||||||
|
}
|
||||||
|
arguments.push(format!("--since=@{since}"));
|
||||||
|
}
|
||||||
|
for unit in &config.allowed_units {
|
||||||
|
arguments.push(format!("--unit={unit}"));
|
||||||
|
}
|
||||||
|
let argument_refs: Vec<_> = arguments.iter().map(String::as_str).collect();
|
||||||
|
let lines = command::run_lines("journalctl", &argument_refs, timeout, limit + 1).await?;
|
||||||
|
let truncated = lines.len() > limit;
|
||||||
|
let mut entries = lines
|
||||||
|
.iter()
|
||||||
|
.take(limit)
|
||||||
|
.filter(|line| !line.trim().is_empty())
|
||||||
|
.map(|line| parse_entry(line, config.max_message_bytes))
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
entries.shrink_to_fit();
|
||||||
|
Ok(LogsResponse { entries, truncated })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_entry(line: &str, max_message_bytes: usize) -> Result<LogEntry> {
|
||||||
|
let value: Value = serde_json::from_str(line).context("parse journald JSON row")?;
|
||||||
|
let cursor = string_field(&value, "__CURSOR").context("journal row missing cursor")?;
|
||||||
|
if cursor.len() > 1024 {
|
||||||
|
bail!("journal cursor is too long")
|
||||||
|
}
|
||||||
|
let timestamp_unix_micros = string_field(&value, "__REALTIME_TIMESTAMP")
|
||||||
|
.and_then(|raw| raw.parse().ok())
|
||||||
|
.context("journal row has invalid timestamp")?;
|
||||||
|
let mut message = redact_sensitive(
|
||||||
|
&string_field(&value, "MESSAGE").unwrap_or_else(|| "[binary message]".into()),
|
||||||
|
);
|
||||||
|
if message.len() > max_message_bytes {
|
||||||
|
let mut boundary = max_message_bytes;
|
||||||
|
while !message.is_char_boundary(boundary) {
|
||||||
|
boundary -= 1;
|
||||||
|
}
|
||||||
|
message.truncate(boundary);
|
||||||
|
}
|
||||||
|
Ok(LogEntry {
|
||||||
|
cursor,
|
||||||
|
timestamp_unix_micros,
|
||||||
|
unit: string_field(&value, "_SYSTEMD_UNIT").unwrap_or_else(|| "unknown".into()),
|
||||||
|
priority: string_field(&value, "PRIORITY")
|
||||||
|
.and_then(|raw| raw.parse().ok())
|
||||||
|
.filter(|priority| *priority <= 7)
|
||||||
|
.unwrap_or(6),
|
||||||
|
message,
|
||||||
|
boot_id: string_field(&value, "_BOOT_ID"),
|
||||||
|
pid: string_field(&value, "_PID").and_then(|raw| raw.parse().ok()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_field(value: &Value, name: &str) -> Option<String> {
|
||||||
|
value.get(name)?.as_str().map(str::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redact_sensitive(message: &str) -> String {
|
||||||
|
const KEYS: &[&str] = &[
|
||||||
|
"authorization",
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"api_key",
|
||||||
|
"api-key",
|
||||||
|
"apikey",
|
||||||
|
"token",
|
||||||
|
"secret",
|
||||||
|
];
|
||||||
|
let lower = message.to_ascii_lowercase();
|
||||||
|
let bytes = lower.as_bytes();
|
||||||
|
let mut output = String::with_capacity(message.len());
|
||||||
|
let mut copied = 0;
|
||||||
|
let mut search_from = 0;
|
||||||
|
while search_from < bytes.len() {
|
||||||
|
let candidate = KEYS
|
||||||
|
.iter()
|
||||||
|
.filter_map(|key| {
|
||||||
|
lower[search_from..]
|
||||||
|
.find(key)
|
||||||
|
.map(|offset| (search_from + offset, *key))
|
||||||
|
})
|
||||||
|
.min_by_key(|(position, _)| *position);
|
||||||
|
let Some((position, key)) = candidate else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let before_is_name = position > 0
|
||||||
|
&& (bytes[position - 1].is_ascii_alphanumeric() || bytes[position - 1] == b'_');
|
||||||
|
let mut separator = position + key.len();
|
||||||
|
if before_is_name {
|
||||||
|
search_from = separator;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if bytes
|
||||||
|
.get(separator)
|
||||||
|
.is_some_and(|byte| matches!(byte, b'\'' | b'"'))
|
||||||
|
{
|
||||||
|
separator += 1;
|
||||||
|
}
|
||||||
|
while bytes.get(separator).is_some_and(u8::is_ascii_whitespace) {
|
||||||
|
separator += 1;
|
||||||
|
}
|
||||||
|
if !bytes
|
||||||
|
.get(separator)
|
||||||
|
.is_some_and(|byte| matches!(byte, b':' | b'='))
|
||||||
|
{
|
||||||
|
search_from = position + key.len();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
separator += 1;
|
||||||
|
while bytes.get(separator).is_some_and(u8::is_ascii_whitespace) {
|
||||||
|
separator += 1;
|
||||||
|
}
|
||||||
|
if separator >= bytes.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut value_start = separator;
|
||||||
|
let quote = bytes[value_start];
|
||||||
|
let value_end = if matches!(quote, b'\'' | b'"') {
|
||||||
|
value_start += 1;
|
||||||
|
message[value_start..]
|
||||||
|
.find(char::from(quote))
|
||||||
|
.map_or(bytes.len(), |offset| value_start + offset)
|
||||||
|
} else {
|
||||||
|
if key == "authorization" && lower[value_start..].starts_with("bearer ") {
|
||||||
|
value_start += "bearer ".len();
|
||||||
|
}
|
||||||
|
message[value_start..]
|
||||||
|
.char_indices()
|
||||||
|
.find(|(_, character)| {
|
||||||
|
character.is_ascii_whitespace() || matches!(character, ',' | ';' | '&')
|
||||||
|
})
|
||||||
|
.map_or(bytes.len(), |(offset, _)| value_start + offset)
|
||||||
|
};
|
||||||
|
if value_end == value_start {
|
||||||
|
search_from = separator;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
output.push_str(&message[copied..value_start]);
|
||||||
|
output.push_str("[REDACTED]");
|
||||||
|
copied = value_end;
|
||||||
|
search_from = value_end;
|
||||||
|
}
|
||||||
|
output.push_str(&message[copied..]);
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_and_truncates_journal_row() {
|
||||||
|
let row = r#"{"__CURSOR":"s=cursor","__REALTIME_TIMESTAMP":"1700000000123456","_SYSTEMD_UNIT":"nginx.service","PRIORITY":"4","MESSAGE":"привет world","_PID":"42"}"#;
|
||||||
|
let parsed = parse_entry(row, 12).unwrap();
|
||||||
|
assert_eq!(parsed.cursor, "s=cursor");
|
||||||
|
assert_eq!(parsed.unit, "nginx.service");
|
||||||
|
assert_eq!(parsed.priority, 4);
|
||||||
|
assert_eq!(parsed.pid, Some(42));
|
||||||
|
assert!(parsed.message.len() <= 12);
|
||||||
|
assert!(parsed.message.is_char_boundary(parsed.message.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_missing_cursor() {
|
||||||
|
assert!(parse_entry(r#"{"MESSAGE":"hello"}"#, 1024).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redacts_common_secret_shapes() {
|
||||||
|
assert_eq!(
|
||||||
|
redact_sensitive("Authorization: Bearer abc123 password='hunter2' token=xyz&ok=1"),
|
||||||
|
"Authorization: Bearer [REDACTED] password='[REDACTED]' token=[REDACTED]&ok=1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
redact_sensitive(r#"{"api_key":"value","message":"safe"}"#),
|
||||||
|
r#"{"api_key":"[REDACTED]","message":"safe"}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
agent/src/main.rs
Normal file
91
agent/src/main.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
mod api;
|
||||||
|
mod command;
|
||||||
|
mod config;
|
||||||
|
mod fail2ban;
|
||||||
|
mod firewall;
|
||||||
|
mod logs;
|
||||||
|
mod network;
|
||||||
|
mod services;
|
||||||
|
mod system;
|
||||||
|
mod wireguard;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use axum_server::{tls_rustls::RustlsConfig, Handle};
|
||||||
|
use config::Config;
|
||||||
|
use std::{env, path::PathBuf, time::Duration};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let config_path = parse_args()?;
|
||||||
|
let config = Config::load(&config_path)?;
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::try_new(&config.logging.level).context("invalid logging filter")?,
|
||||||
|
)
|
||||||
|
.compact()
|
||||||
|
.init();
|
||||||
|
let state = api::AppState::load(config.clone())?;
|
||||||
|
let app = api::router(state);
|
||||||
|
let handle = Handle::new();
|
||||||
|
let shutdown_handle = handle.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
wait_for_shutdown().await;
|
||||||
|
tracing::info!("shutdown signal received");
|
||||||
|
shutdown_handle.graceful_shutdown(Some(Duration::from_secs(10)));
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::info!(listen = %config.server.listen, tls = config.tls.enabled, version = env!("CARGO_PKG_VERSION"), "vps-agent started");
|
||||||
|
if config.tls.enabled {
|
||||||
|
let tls =
|
||||||
|
RustlsConfig::from_pem_file(&config.tls.certificate_file, &config.tls.private_key_file)
|
||||||
|
.await
|
||||||
|
.context("load TLS certificate/key")?;
|
||||||
|
axum_server::bind_rustls(config.server.listen, tls)
|
||||||
|
.handle(handle)
|
||||||
|
.serve(app.into_make_service())
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
axum_server::bind(config.server.listen)
|
||||||
|
.handle(handle)
|
||||||
|
.serve(app.into_make_service())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Result<PathBuf> {
|
||||||
|
let mut args = env::args().skip(1);
|
||||||
|
let mut config = PathBuf::from("/etc/vps-agent/config.toml");
|
||||||
|
while let Some(argument) = args.next() {
|
||||||
|
match argument.as_str() {
|
||||||
|
"--config" => {
|
||||||
|
config = args
|
||||||
|
.next()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.context("--config needs a path")?
|
||||||
|
}
|
||||||
|
"--version" | "-V" => {
|
||||||
|
println!("vps-agent {}", env!("CARGO_PKG_VERSION"));
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
"--help" | "-h" => {
|
||||||
|
println!("Usage: vps-agent [--config PATH] [--version]");
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
_ => anyhow::bail!("unknown argument: {argument}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_shutdown() {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use tokio::signal::unix::{signal, SignalKind};
|
||||||
|
let mut terminate = signal(SignalKind::terminate()).expect("install SIGTERM handler");
|
||||||
|
tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = terminate.recv() => {} }
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
}
|
||||||
261
agent/src/network.rs
Normal file
261
agent/src/network.rs
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
use crate::command;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
fs,
|
||||||
|
net::{IpAddr, Ipv4Addr, Ipv6Addr},
|
||||||
|
path::Path,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Interface {
|
||||||
|
pub name: String,
|
||||||
|
pub state: String,
|
||||||
|
pub addresses: Vec<String>,
|
||||||
|
pub rx_bytes: u64,
|
||||||
|
pub tx_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SocketInfo {
|
||||||
|
pub protocol: String,
|
||||||
|
pub local_address: String,
|
||||||
|
pub local_port: u16,
|
||||||
|
pub remote_address: String,
|
||||||
|
pub remote_port: u16,
|
||||||
|
pub state: String,
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
pub process: Option<String>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
inode: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn interfaces(timeout: Duration) -> Vec<Interface> {
|
||||||
|
let addresses = match command::run("ip", &["-j", "addr", "show"], timeout).await {
|
||||||
|
Ok(output) => parse_ip_addresses(&output.stdout),
|
||||||
|
Err(_) => HashMap::new(),
|
||||||
|
};
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let Ok(entries) = fs::read_dir("/sys/class/net") else {
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten().take(256) {
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
let path = entry.path();
|
||||||
|
result.push(Interface {
|
||||||
|
state: read_value(path.join("operstate")).unwrap_or_else(|| "unknown".into()),
|
||||||
|
rx_bytes: read_u64(path.join("statistics/rx_bytes")),
|
||||||
|
tx_bytes: read_u64(path.join("statistics/tx_bytes")),
|
||||||
|
addresses: addresses.get(&name).cloned().unwrap_or_default(),
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn routes(timeout: Duration) -> Result<Value> {
|
||||||
|
let output = command::run("ip", &["-j", "route", "show", "table", "all"], timeout).await?;
|
||||||
|
serde_json::from_str(&output.stdout).context("parse ip route JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sockets(include_process: bool) -> Result<Vec<SocketInfo>> {
|
||||||
|
let mut sockets = Vec::new();
|
||||||
|
for (path, protocol, ipv6) in [
|
||||||
|
("/proc/net/tcp", "tcp", false),
|
||||||
|
("/proc/net/tcp6", "tcp", true),
|
||||||
|
("/proc/net/udp", "udp", false),
|
||||||
|
("/proc/net/udp6", "udp", true),
|
||||||
|
] {
|
||||||
|
let Ok(text) = fs::read_to_string(path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
sockets.extend(parse_socket_table(&text, protocol, ipv6)?);
|
||||||
|
}
|
||||||
|
if include_process {
|
||||||
|
attach_processes(&mut sockets);
|
||||||
|
}
|
||||||
|
Ok(sockets)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn listeners(all: Vec<SocketInfo>) -> Vec<SocketInfo> {
|
||||||
|
all.into_iter()
|
||||||
|
.filter(|socket| {
|
||||||
|
socket.state == "LISTEN" || (socket.protocol == "udp" && socket.remote_port == 0)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ip_addresses(text: &str) -> HashMap<String, Vec<String>> {
|
||||||
|
let Ok(value) = serde_json::from_str::<Value>(text) else {
|
||||||
|
return HashMap::new();
|
||||||
|
};
|
||||||
|
let mut result = HashMap::new();
|
||||||
|
for item in value.as_array().into_iter().flatten() {
|
||||||
|
let Some(name) = item.get("ifname").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let addresses = item
|
||||||
|
.get("addr_info")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|address| {
|
||||||
|
let local = address.get("local")?.as_str()?;
|
||||||
|
let prefix = address.get("prefixlen")?.as_u64()?;
|
||||||
|
Some(format!("{local}/{prefix}"))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
result.insert(name.to_owned(), addresses);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_value(path: impl AsRef<Path>) -> Option<String> {
|
||||||
|
fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u64(path: impl AsRef<Path>) -> u64 {
|
||||||
|
read_value(path)
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_socket_table(text: &str, protocol: &str, ipv6: bool) -> Result<Vec<SocketInfo>> {
|
||||||
|
text.lines()
|
||||||
|
.skip(1)
|
||||||
|
.filter(|line| !line.trim().is_empty())
|
||||||
|
.map(|line| {
|
||||||
|
let fields: Vec<_> = line.split_whitespace().collect();
|
||||||
|
if fields.len() < 10 {
|
||||||
|
anyhow::bail!("malformed /proc socket row")
|
||||||
|
}
|
||||||
|
let (local_address, local_port) = parse_endpoint(fields[1], ipv6)?;
|
||||||
|
let (remote_address, remote_port) = parse_endpoint(fields[2], ipv6)?;
|
||||||
|
let state = tcp_state(fields[3], protocol).to_owned();
|
||||||
|
let inode = fields[9].parse().context("invalid socket inode")?;
|
||||||
|
Ok(SocketInfo {
|
||||||
|
protocol: protocol.to_owned(),
|
||||||
|
local_address,
|
||||||
|
local_port,
|
||||||
|
remote_address,
|
||||||
|
remote_port,
|
||||||
|
state,
|
||||||
|
pid: None,
|
||||||
|
process: None,
|
||||||
|
inode,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_endpoint(value: &str, ipv6: bool) -> Result<(String, u16)> {
|
||||||
|
let (address, port) = value.split_once(':').context("invalid socket endpoint")?;
|
||||||
|
let port = u16::from_str_radix(port, 16).context("invalid socket port")?;
|
||||||
|
let ip = if ipv6 {
|
||||||
|
if address.len() != 32 {
|
||||||
|
anyhow::bail!("invalid IPv6 address")
|
||||||
|
}
|
||||||
|
let mut octets = [0_u8; 16];
|
||||||
|
for word in 0..4 {
|
||||||
|
for byte in 0..4 {
|
||||||
|
let index = (word * 4 + byte) * 2;
|
||||||
|
octets[word * 4 + (3 - byte)] = u8::from_str_radix(&address[index..index + 2], 16)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IpAddr::V6(Ipv6Addr::from(octets))
|
||||||
|
} else {
|
||||||
|
let raw = u32::from_str_radix(address, 16).context("invalid IPv4 address")?;
|
||||||
|
IpAddr::V4(Ipv4Addr::from(raw.to_le_bytes()))
|
||||||
|
};
|
||||||
|
Ok((ip.to_string(), port))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tcp_state(code: &str, protocol: &str) -> &'static str {
|
||||||
|
if protocol == "udp" {
|
||||||
|
return if code == "07" { "UNCONN" } else { "UNKNOWN" };
|
||||||
|
}
|
||||||
|
match code {
|
||||||
|
"01" => "ESTABLISHED",
|
||||||
|
"02" => "SYN_SENT",
|
||||||
|
"03" => "SYN_RECV",
|
||||||
|
"04" => "FIN_WAIT1",
|
||||||
|
"05" => "FIN_WAIT2",
|
||||||
|
"06" => "TIME_WAIT",
|
||||||
|
"07" => "CLOSE",
|
||||||
|
"08" => "CLOSE_WAIT",
|
||||||
|
"09" => "LAST_ACK",
|
||||||
|
"0A" => "LISTEN",
|
||||||
|
"0B" => "CLOSING",
|
||||||
|
_ => "UNKNOWN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attach_processes(sockets: &mut [SocketInfo]) {
|
||||||
|
let by_inode: HashMap<u64, usize> = sockets
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, s)| (s.inode, i))
|
||||||
|
.collect();
|
||||||
|
let Ok(proc_entries) = fs::read_dir("/proc") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in proc_entries.flatten().take(4096) {
|
||||||
|
let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(fds) = fs::read_dir(entry.path().join("fd")) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for fd in fds.flatten().take(4096) {
|
||||||
|
let Ok(target) = fs::read_link(fd.path()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let text = target.to_string_lossy();
|
||||||
|
let Some(raw) = text
|
||||||
|
.strip_prefix("socket:[")
|
||||||
|
.and_then(|v| v.strip_suffix(']'))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(inode) = raw.parse::<u64>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(index) = by_inode.get(&inode).copied() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if sockets[index].pid.is_none() {
|
||||||
|
sockets[index].pid = Some(pid);
|
||||||
|
sockets[index].process = read_value(entry.path().join("comm"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_ipv4_and_listener() {
|
||||||
|
let input = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n 0: 0100007F:238F 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 42\n";
|
||||||
|
let rows = parse_socket_table(input, "tcp", false).unwrap();
|
||||||
|
assert_eq!(rows[0].local_address, "127.0.0.1");
|
||||||
|
assert_eq!(rows[0].local_port, 9103);
|
||||||
|
assert_eq!(rows[0].state, "LISTEN");
|
||||||
|
assert_eq!(listeners(rows).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_ip_json() {
|
||||||
|
let parsed = parse_ip_addresses(
|
||||||
|
r#"[{"ifname":"wg0","addr_info":[{"local":"10.10.0.1","prefixlen":24}]}]"#,
|
||||||
|
);
|
||||||
|
assert_eq!(parsed["wg0"], vec!["10.10.0.1/24"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
agent/src/services.rs
Normal file
105
agent/src/services.rs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
use crate::{command, config::valid_service_name};
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::{collections::HashMap, time::Duration};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ServiceStatus {
|
||||||
|
pub name: String,
|
||||||
|
pub active_state: String,
|
||||||
|
pub sub_state: String,
|
||||||
|
pub pid: u32,
|
||||||
|
pub memory_bytes: Option<u64>,
|
||||||
|
pub active_since_monotonic_us: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(allowed: &[String], timeout: Duration) -> Vec<ServiceStatus> {
|
||||||
|
let mut result = Vec::with_capacity(allowed.len());
|
||||||
|
for name in allowed {
|
||||||
|
match inspect(name, timeout).await {
|
||||||
|
Ok(status) => result.push(status),
|
||||||
|
Err(_) => result.push(ServiceStatus {
|
||||||
|
name: name.clone(),
|
||||||
|
active_state: "unavailable".into(),
|
||||||
|
sub_state: "unknown".into(),
|
||||||
|
pid: 0,
|
||||||
|
memory_bytes: None,
|
||||||
|
active_since_monotonic_us: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restart(name: &str, allowed: &[String], timeout: Duration) -> Result<()> {
|
||||||
|
if !valid_service_name(name) || !allowed.iter().any(|item| item == name) {
|
||||||
|
bail!("service is not allow-listed")
|
||||||
|
}
|
||||||
|
command::run("systemctl", &["restart", "--", name], timeout).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn inspect(name: &str, timeout: Duration) -> Result<ServiceStatus> {
|
||||||
|
if !valid_service_name(name) {
|
||||||
|
bail!("invalid service name")
|
||||||
|
}
|
||||||
|
let output = command::run(
|
||||||
|
"systemctl",
|
||||||
|
&[
|
||||||
|
"show",
|
||||||
|
"--no-pager",
|
||||||
|
"--property=ActiveState,SubState,MainPID,MemoryCurrent,ActiveEnterTimestampMonotonic",
|
||||||
|
"--",
|
||||||
|
name,
|
||||||
|
],
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let values: HashMap<_, _> = output
|
||||||
|
.stdout
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.split_once('='))
|
||||||
|
.collect();
|
||||||
|
Ok(ServiceStatus {
|
||||||
|
name: name.to_owned(),
|
||||||
|
active_state: values
|
||||||
|
.get("ActiveState")
|
||||||
|
.copied()
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_owned(),
|
||||||
|
sub_state: values
|
||||||
|
.get("SubState")
|
||||||
|
.copied()
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_owned(),
|
||||||
|
pid: values
|
||||||
|
.get("MainPID")
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
memory_bytes: values.get("MemoryCurrent").and_then(|v| v.parse().ok()),
|
||||||
|
active_since_monotonic_us: values
|
||||||
|
.get("ActiveEnterTimestampMonotonic")
|
||||||
|
.and_then(|v| v.parse().ok()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_service_outside_allowlist_without_execution() {
|
||||||
|
assert!(
|
||||||
|
restart("postgresql", &["nginx".into()], Duration::from_secs(1))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(restart(
|
||||||
|
"nginx;reboot",
|
||||||
|
&["nginx;reboot".into()],
|
||||||
|
Duration::from_secs(1)
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
223
agent/src/system.rs
Normal file
223
agent/src/system.rs
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use nix::sys::statvfs::statvfs;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::{collections::HashMap, fs, path::Path, sync::Mutex, time::Duration};
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct CpuTimes {
|
||||||
|
total: u64,
|
||||||
|
idle: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Status {
|
||||||
|
pub hostname: String,
|
||||||
|
pub os: String,
|
||||||
|
pub kernel: String,
|
||||||
|
pub uptime_seconds: u64,
|
||||||
|
pub load_average: [f64; 3],
|
||||||
|
pub cpu_usage_percent: f64,
|
||||||
|
pub cpu_core_count: usize,
|
||||||
|
pub memory_total_bytes: u64,
|
||||||
|
pub memory_used_bytes: u64,
|
||||||
|
pub swap_total_bytes: u64,
|
||||||
|
pub swap_used_bytes: u64,
|
||||||
|
pub filesystems: Vec<Filesystem>,
|
||||||
|
pub reboot_required: bool,
|
||||||
|
pub security_updates: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Filesystem {
|
||||||
|
pub mountpoint: String,
|
||||||
|
pub filesystem: String,
|
||||||
|
pub total_bytes: u64,
|
||||||
|
pub used_bytes: u64,
|
||||||
|
pub inodes_total: u64,
|
||||||
|
pub inodes_used: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn collect(previous_cpu: &Mutex<Option<CpuTimes>>) -> Result<Status> {
|
||||||
|
let hostname = read_trimmed("/proc/sys/kernel/hostname").unwrap_or_else(|_| "unknown".into());
|
||||||
|
let kernel = read_trimmed("/proc/sys/kernel/osrelease").unwrap_or_else(|_| "unknown".into());
|
||||||
|
let os = parse_os_release().unwrap_or_else(|_| "Linux".into());
|
||||||
|
let uptime_seconds = fs::read_to_string("/proc/uptime")?
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.and_then(|v| v.parse::<f64>().ok())
|
||||||
|
.unwrap_or_default() as u64;
|
||||||
|
let load_average = parse_load_average(&fs::read_to_string("/proc/loadavg")?);
|
||||||
|
let (cpu, cores) = parse_cpu(&fs::read_to_string("/proc/stat")?)?;
|
||||||
|
let cpu_usage_percent = {
|
||||||
|
let mut previous = previous_cpu.lock().expect("CPU mutex poisoned");
|
||||||
|
let baseline = previous.unwrap_or_default();
|
||||||
|
*previous = Some(cpu);
|
||||||
|
let total = cpu.total.saturating_sub(baseline.total);
|
||||||
|
let idle = cpu.idle.saturating_sub(baseline.idle);
|
||||||
|
if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
100.0 * (total - idle) as f64 / total as f64
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let meminfo = fs::read_to_string("/proc/meminfo")?;
|
||||||
|
let mem = parse_meminfo(&meminfo);
|
||||||
|
|
||||||
|
Ok(Status {
|
||||||
|
hostname,
|
||||||
|
os,
|
||||||
|
kernel,
|
||||||
|
uptime_seconds,
|
||||||
|
load_average,
|
||||||
|
cpu_usage_percent,
|
||||||
|
cpu_core_count: cores,
|
||||||
|
memory_total_bytes: mem.get("MemTotal").copied().unwrap_or_default(),
|
||||||
|
memory_used_bytes: mem
|
||||||
|
.get("MemTotal")
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.saturating_sub(mem.get("MemAvailable").copied().unwrap_or_default()),
|
||||||
|
swap_total_bytes: mem.get("SwapTotal").copied().unwrap_or_default(),
|
||||||
|
swap_used_bytes: mem
|
||||||
|
.get("SwapTotal")
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.saturating_sub(mem.get("SwapFree").copied().unwrap_or_default()),
|
||||||
|
filesystems: collect_filesystems(),
|
||||||
|
reboot_required: Path::new("/var/run/reboot-required").exists(),
|
||||||
|
security_updates: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_trimmed(path: &str) -> Result<String> {
|
||||||
|
Ok(fs::read_to_string(path)?.trim().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_os_release() -> Result<String> {
|
||||||
|
let os_release = fs::read_to_string("/etc/os-release")?;
|
||||||
|
let values: HashMap<_, _> = os_release
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.split_once('='))
|
||||||
|
.map(|(k, v)| (k, v.trim_matches('"')))
|
||||||
|
.collect();
|
||||||
|
Ok(values
|
||||||
|
.get("PRETTY_NAME")
|
||||||
|
.copied()
|
||||||
|
.unwrap_or("Linux")
|
||||||
|
.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_load_average(text: &str) -> [f64; 3] {
|
||||||
|
let mut fields = text
|
||||||
|
.split_whitespace()
|
||||||
|
.take(3)
|
||||||
|
.map(|v| v.parse().unwrap_or_default());
|
||||||
|
[
|
||||||
|
fields.next().unwrap_or_default(),
|
||||||
|
fields.next().unwrap_or_default(),
|
||||||
|
fields.next().unwrap_or_default(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_cpu(text: &str) -> Result<(CpuTimes, usize)> {
|
||||||
|
let mut lines = text.lines();
|
||||||
|
let aggregate = lines.next().context("missing aggregate CPU line")?;
|
||||||
|
let values: Vec<u64> = aggregate
|
||||||
|
.split_whitespace()
|
||||||
|
.skip(1)
|
||||||
|
.map(|v| v.parse().unwrap_or_default())
|
||||||
|
.collect();
|
||||||
|
let idle =
|
||||||
|
values.get(3).copied().unwrap_or_default() + values.get(4).copied().unwrap_or_default();
|
||||||
|
let total = values.iter().sum();
|
||||||
|
let cores = text
|
||||||
|
.lines()
|
||||||
|
.filter(|line| {
|
||||||
|
line.strip_prefix("cpu")
|
||||||
|
.and_then(|suffix| suffix.chars().next())
|
||||||
|
.is_some_and(|c| c.is_ascii_digit())
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
Ok((CpuTimes { total, idle }, cores))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_meminfo(text: &str) -> HashMap<&str, u64> {
|
||||||
|
text.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let (key, value) = line.split_once(':')?;
|
||||||
|
let kib = value.split_whitespace().next()?.parse::<u64>().ok()?;
|
||||||
|
Some((key, kib.saturating_mul(1024)))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_filesystems() -> Vec<Filesystem> {
|
||||||
|
let mounts = match fs::read_to_string("/proc/self/mounts") {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for line in mounts.lines() {
|
||||||
|
let fields: Vec<_> = line.split_whitespace().collect();
|
||||||
|
if fields.len() < 3 || is_pseudo_filesystem(fields[2]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mountpoint = fields[1].replace("\\040", " ");
|
||||||
|
let Ok(stats) = statvfs(Path::new(&mountpoint)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let total = u64::from(stats.blocks()).saturating_mul(stats.fragment_size());
|
||||||
|
let available = u64::from(stats.blocks_available()).saturating_mul(stats.fragment_size());
|
||||||
|
let inodes_total = u64::from(stats.files());
|
||||||
|
let inodes_available = u64::from(stats.files_available());
|
||||||
|
result.push(Filesystem {
|
||||||
|
mountpoint,
|
||||||
|
filesystem: fields[2].to_owned(),
|
||||||
|
total_bytes: total,
|
||||||
|
used_bytes: total.saturating_sub(available),
|
||||||
|
inodes_total,
|
||||||
|
inodes_used: inodes_total.saturating_sub(inodes_available),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_pseudo_filesystem(name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
name,
|
||||||
|
"proc"
|
||||||
|
| "sysfs"
|
||||||
|
| "devtmpfs"
|
||||||
|
| "devpts"
|
||||||
|
| "cgroup"
|
||||||
|
| "cgroup2"
|
||||||
|
| "securityfs"
|
||||||
|
| "pstore"
|
||||||
|
| "debugfs"
|
||||||
|
| "tracefs"
|
||||||
|
| "configfs"
|
||||||
|
| "fusectl"
|
||||||
|
| "mqueue"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn command_timeout(seconds: u64) -> Duration {
|
||||||
|
Duration::from_secs(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_proc_values() {
|
||||||
|
let mem = parse_meminfo("MemTotal: 1000 kB\nMemAvailable: 400 kB\n");
|
||||||
|
assert_eq!(mem["MemTotal"], 1_024_000);
|
||||||
|
assert_eq!(parse_load_average("1.0 2.5 3.0 1/1 1"), [1.0, 2.5, 3.0]);
|
||||||
|
let (cpu, cores) =
|
||||||
|
parse_cpu("cpu 10 0 5 85 0 0 0 0\ncpu0 1 0 1 8\ncpu1 1 0 1 8\n").unwrap();
|
||||||
|
assert_eq!(cpu.total, 100);
|
||||||
|
assert_eq!(cpu.idle, 85);
|
||||||
|
assert_eq!(cores, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
agent/src/wireguard.rs
Normal file
80
agent/src/wireguard.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
use crate::command;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, PartialEq)]
|
||||||
|
pub struct Interface {
|
||||||
|
pub name: String,
|
||||||
|
pub public_key: String,
|
||||||
|
pub listen_port: u16,
|
||||||
|
pub fwmark: String,
|
||||||
|
pub peers: Vec<Peer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, PartialEq)]
|
||||||
|
pub struct Peer {
|
||||||
|
pub public_key: String,
|
||||||
|
pub endpoint: Option<String>,
|
||||||
|
pub allowed_ips: Vec<String>,
|
||||||
|
pub latest_handshake: u64,
|
||||||
|
pub rx_bytes: u64,
|
||||||
|
pub tx_bytes: u64,
|
||||||
|
pub persistent_keepalive: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(timeout: Duration) -> Result<Vec<Interface>> {
|
||||||
|
let output = command::run("wg", &["show", "all", "dump"], timeout).await?;
|
||||||
|
parse_dump(&output.stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_dump(text: &str) -> Result<Vec<Interface>> {
|
||||||
|
let mut interfaces: Vec<Interface> = Vec::new();
|
||||||
|
for line in text.lines().filter(|line| !line.is_empty()) {
|
||||||
|
let fields: Vec<_> = line.split('\t').collect();
|
||||||
|
match fields.len() {
|
||||||
|
5 => interfaces.push(Interface {
|
||||||
|
name: fields[0].to_owned(),
|
||||||
|
public_key: fields[2].to_owned(),
|
||||||
|
listen_port: fields[3].parse().unwrap_or_default(),
|
||||||
|
fwmark: fields[4].to_owned(),
|
||||||
|
peers: Vec::new(),
|
||||||
|
}),
|
||||||
|
9 => {
|
||||||
|
let interface = interfaces
|
||||||
|
.iter_mut()
|
||||||
|
.find(|item| item.name == fields[0])
|
||||||
|
.context("peer references unknown WireGuard interface")?;
|
||||||
|
interface.peers.push(Peer {
|
||||||
|
public_key: fields[1].to_owned(),
|
||||||
|
endpoint: (fields[3] != "(none)").then(|| fields[3].to_owned()),
|
||||||
|
allowed_ips: fields[4]
|
||||||
|
.split(',')
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect(),
|
||||||
|
latest_handshake: fields[5].parse().unwrap_or_default(),
|
||||||
|
rx_bytes: fields[6].parse().unwrap_or_default(),
|
||||||
|
tx_bytes: fields[7].parse().unwrap_or_default(),
|
||||||
|
persistent_keepalive: fields[8].parse().unwrap_or_default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => anyhow::bail!("unexpected WireGuard dump row"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(interfaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_dump() {
|
||||||
|
let dump = "wg0\t(private)\tpub\t51820\toff\nwg0\tpeer\t(none)\t198.51.100.1:51820\t10.0.0.2/32\t100\t200\t300\t25\n";
|
||||||
|
let parsed = parse_dump(dump).unwrap();
|
||||||
|
assert_eq!(parsed[0].name, "wg0");
|
||||||
|
assert_eq!(parsed[0].peers[0].rx_bytes, 200);
|
||||||
|
assert_eq!(parsed[0].peers[0].allowed_ips, vec!["10.0.0.2/32"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
control-plane/.dockerignore
Normal file
8
control-plane/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
secrets
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/*_test.go
|
||||||
|
frontend/src/**/*.test.*
|
||||||
|
.git
|
||||||
|
|
||||||
26
control-plane/.env.example
Normal file
26
control-plane/.env.example
Normal 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
112
control-plane/README.md
Normal 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.
|
||||||
4
control-plane/backend/.dockerignore
Normal file
4
control-plane/backend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
backend
|
||||||
|
*_test.go
|
||||||
|
.git
|
||||||
|
|
||||||
17
control-plane/backend/Dockerfile
Normal file
17
control-plane/backend/Dockerfile
Normal 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"]
|
||||||
|
|
||||||
149
control-plane/backend/agent.go
Normal file
149
control-plane/backend/agent.go
Normal 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
|
||||||
|
}
|
||||||
64
control-plane/backend/agent_test.go
Normal file
64
control-plane/backend/agent_test.go
Normal 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)
|
||||||
|
}
|
||||||
459
control-plane/backend/api.go
Normal file
459
control-plane/backend/api.go
Normal 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")
|
||||||
|
}
|
||||||
77
control-plane/backend/api_test.go
Normal file
77
control-plane/backend/api_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
113
control-plane/backend/auth.go
Normal file
113
control-plane/backend/auth.go
Normal 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, ¶llelism); 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
|
||||||
|
}
|
||||||
52
control-plane/backend/auth_test.go
Normal file
52
control-plane/backend/auth_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
control-plane/backend/config.go
Normal file
126
control-plane/backend/config.go
Normal 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
572
control-plane/backend/db.go
Normal 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])
|
||||||
|
}
|
||||||
17
control-plane/backend/go.mod
Normal file
17
control-plane/backend/go.mod
Normal 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
|
||||||
|
)
|
||||||
27
control-plane/backend/go.sum
Normal file
27
control-plane/backend/go.sum
Normal 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=
|
||||||
53
control-plane/backend/limiter.go
Normal file
53
control-plane/backend/limiter.go
Normal 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
|
||||||
|
}
|
||||||
52
control-plane/backend/main.go
Normal file
52
control-plane/backend/main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
527
control-plane/backend/observability.go
Normal file
527
control-plane/backend/observability.go
Normal 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
|
||||||
|
}
|
||||||
101
control-plane/backend/observability_test.go
Normal file
101
control-plane/backend/observability_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
254
control-plane/backend/poller.go
Normal file
254
control-plane/backend/poller.go
Normal 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)
|
||||||
|
}
|
||||||
161
control-plane/docker-compose.yml
Normal file
161
control-plane/docker-compose.yml
Normal 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}
|
||||||
9
control-plane/frontend/.dockerignore
Normal file
9
control-plane/frontend/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.tsbuildinfo
|
||||||
|
vite.config.js
|
||||||
|
vite.config.d.ts
|
||||||
|
vitest.config.js
|
||||||
|
vitest.config.d.ts
|
||||||
|
.git
|
||||||
|
|
||||||
13
control-plane/frontend/Dockerfile
Normal file
13
control-plane/frontend/Dockerfile
Normal 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
|
||||||
|
|
||||||
21
control-plane/frontend/eslint.config.js
Normal file
21
control-plane/frontend/eslint.config.js
Normal 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
15
control-plane/frontend/index.html
Normal file
15
control-plane/frontend/index.html
Normal 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>
|
||||||
|
|
||||||
55
control-plane/frontend/nginx.conf
Normal file
55
control-plane/frontend/nginx.conf
Normal 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
4755
control-plane/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
control-plane/frontend/package.json
Normal file
36
control-plane/frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
control-plane/frontend/src/App.test.tsx
Normal file
29
control-plane/frontend/src/App.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
248
control-plane/frontend/src/App.tsx
Normal file
248
control-plane/frontend/src/App.tsx
Normal 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
|
||||||
37
control-plane/frontend/src/api.ts
Normal file
37
control-plane/frontend/src/api.ts
Normal 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`
|
||||||
|
}
|
||||||
|
|
||||||
12
control-plane/frontend/src/main.tsx
Normal file
12
control-plane/frontend/src/main.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
|
||||||
7
control-plane/frontend/src/styles.css
Normal file
7
control-plane/frontend/src/styles.css
Normal file
File diff suppressed because one or more lines are too long
2
control-plane/frontend/src/test-setup.ts
Normal file
2
control-plane/frontend/src/test-setup.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
|
|
||||||
22
control-plane/frontend/src/types.ts
Normal file
22
control-plane/frontend/src/types.ts
Normal 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 }
|
||||||
22
control-plane/frontend/tsconfig.app.json
Normal file
22
control-plane/frontend/tsconfig.app.json
Normal 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"]
|
||||||
|
}
|
||||||
|
|
||||||
8
control-plane/frontend/tsconfig.json
Normal file
8
control-plane/frontend/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
10
control-plane/frontend/tsconfig.node.json
Normal file
10
control-plane/frontend/tsconfig.node.json
Normal 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"]
|
||||||
|
}
|
||||||
7
control-plane/frontend/vite.config.ts
Normal file
7
control-plane/frontend/vite.config.ts
Normal 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' } }
|
||||||
|
})
|
||||||
5
control-plane/frontend/vitest.config.ts
Normal file
5
control-plane/frontend/vitest.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: { environment: 'jsdom', setupFiles: './src/test-setup.ts' }
|
||||||
|
})
|
||||||
11
control-plane/grafana/Dockerfile
Normal file
11
control-plane/grafana/Dockerfile
Normal 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"]
|
||||||
67
control-plane/grafana/dashboards/vps-control-overview.json
Normal file
67
control-plane/grafana/dashboards/vps-control-overview.json
Normal 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
|
||||||
|
}
|
||||||
22
control-plane/grafana/entrypoint.sh
Executable file
22
control-plane/grafana/entrypoint.sh
Executable 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
1
control-plane/secrets/.gitkeep
Normal file
1
control-plane/secrets/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
87
docs/architecture.md
Normal file
87
docs/architecture.md
Normal 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
71
docs/deployment.md
Normal 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
19
docs/development.md
Normal 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
67
docs/security.md
Normal 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.
|
||||||
43
examples/agent-config.toml
Normal file
43
examples/agent-config.toml
Normal 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 = ""
|
||||||
6
examples/docker-compose.yml
Normal file
6
examples/docker-compose.yml
Normal 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
38
scripts/README.md
Normal 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
58
scripts/README_RU.md
Normal 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
|
||||||
|
```
|
||||||
37
scripts/package-agent-release.sh
Executable file
37
scripts/package-agent-release.sh
Executable 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
307
scripts/vps-agentctl.sh
Executable 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
|
||||||
Reference in New Issue
Block a user