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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@