git init
This commit is contained in:
8
control-plane/.dockerignore
Normal file
8
control-plane/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
secrets
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/*_test.go
|
||||
frontend/src/**/*.test.*
|
||||
.git
|
||||
|
||||
26
control-plane/.env.example
Normal file
26
control-plane/.env.example
Normal file
@@ -0,0 +1,26 @@
|
||||
# Bind to loopback and terminate public TLS at a reverse proxy.
|
||||
HTTP_BIND_ADDRESS=127.0.0.1
|
||||
HTTP_PORT=8080
|
||||
COOKIE_SECURE=true
|
||||
|
||||
POSTGRES_DB=vps_control
|
||||
POSTGRES_USER=vps_control
|
||||
SESSION_TTL=12h
|
||||
METRICS_RETENTION=720h
|
||||
LOGS_RETENTION=168h
|
||||
INITIAL_ADMIN_USERNAME=admin
|
||||
|
||||
# Optional Grafana profile (loopback by default).
|
||||
GRAFANA_BIND_ADDRESS=127.0.0.1
|
||||
GRAFANA_PORT=3000
|
||||
GRAFANA_ROOT_URL=http://127.0.0.1:3000
|
||||
GRAFANA_COOKIE_SECURE=false
|
||||
|
||||
# Optional: enroll one server only when the database is initially empty.
|
||||
BOOTSTRAP_SERVER_NAME=
|
||||
BOOTSTRAP_SERVER_ENDPOINT=
|
||||
BOOTSTRAP_AGENT_CREDENTIAL_REF=agent_token
|
||||
|
||||
# Values are read from ./secrets/{db_password,admin_password,agent_token,
|
||||
# integration_token,grafana_admin_password,grafana_secret_key};
|
||||
# do not put passwords or agent tokens in this file.
|
||||
112
control-plane/README.md
Normal file
112
control-plane/README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Control Plane
|
||||
|
||||
The control plane is a small Go API, PostgreSQL 16, and a React/TypeScript UI
|
||||
served by unprivileged nginx. The browser only reaches nginx/backend; agent
|
||||
credentials are mounted into the backend as files and never enter PostgreSQL or
|
||||
the browser.
|
||||
|
||||
## Docker deployment
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
mkdir -p secrets
|
||||
openssl rand -hex 32 > secrets/db_password
|
||||
openssl rand -base64 24 > secrets/admin_password
|
||||
openssl rand -hex 32 > secrets/integration_token
|
||||
openssl rand -base64 24 > secrets/grafana_admin_password
|
||||
openssl rand -hex 32 > secrets/grafana_secret_key
|
||||
# Securely copy the exact token from one agent for bootstrap:
|
||||
install -m 600 /secure/source/control-plane.token secrets/agent_token
|
||||
chmod 600 secrets/*
|
||||
docker compose config
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8080` through a TLS reverse proxy. With plain local HTTP
|
||||
for development only, set `COOKIE_SECURE=false`. The default host binding is
|
||||
loopback. PostgreSQL and backend ports are not published.
|
||||
|
||||
Set `BOOTSTRAP_SERVER_NAME` and `BOOTSTRAP_SERVER_ENDPOINT` before the first
|
||||
start, or enroll a server in the UI as Administrator. Endpoints must use a
|
||||
literal loopback/private IP and explicit port. `credential_ref` is a filename in
|
||||
`/run/secrets`, never token material. Add another Compose secret mount for each
|
||||
additional server token.
|
||||
|
||||
## Authentication and database
|
||||
|
||||
On an empty database, the backend reads `secrets/admin_password`, hashes it with
|
||||
Argon2id, and creates `INITIAL_ADMIN_USERNAME`. Change the bootstrap password
|
||||
after adding a user-management workflow or rotate it through an audited database
|
||||
administrative procedure. Sessions are stored server-side, expire after 12
|
||||
hours, use Secure/HttpOnly/SameSite cookies, and require a CSRF token on writes.
|
||||
|
||||
Roles are Viewer (read), Operator (Fail2Ban and service actions), and
|
||||
Administrator (server/firewall/policy administration). Every current mutation
|
||||
is audited with actor, target, result, time, and source IP. Secrets are excluded.
|
||||
|
||||
The backend creates schema on startup. One-minute metric samples are retained 30
|
||||
days (`METRICS_RETENTION=720h`) and agent logs seven days
|
||||
(`LOGS_RETENTION=168h`). PostgreSQL is the only durable history store.
|
||||
|
||||
## Grafana and Loki-compatible access
|
||||
|
||||
The backend is both a constrained Prometheus-compatible metrics source and a
|
||||
Loki-compatible read-only log source. A separate Loki server is not required:
|
||||
this is what preserves the required `Grafana -> backend -> agent` boundary.
|
||||
Both APIs require `Authorization: Bearer <integration_token>` and expose no
|
||||
agent endpoint or credential.
|
||||
|
||||
For the included provisioned instance:
|
||||
|
||||
```bash
|
||||
docker compose --profile observability up -d --build
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:3000`, use the `admin` account and the value in
|
||||
`secrets/grafana_admin_password`. The two datasources and `VPS Control Overview`
|
||||
dashboard are provisioned automatically.
|
||||
`grafana_secret_key` encrypts the datasource credential stored by Grafana and
|
||||
must be backed up; changing it invalidates encrypted secure data.
|
||||
|
||||
For an external Grafana, configure:
|
||||
|
||||
- Prometheus datasource URL: `https://vps-control.example/integrations/prometheus`
|
||||
- Loki datasource URL: `https://vps-control.example/integrations/loki`
|
||||
- Custom HTTP header on both: `Authorization: Bearer <integration_token>`
|
||||
|
||||
The frontend proxy publishes these protected routes. Restrict them to Grafana's
|
||||
source network at the outer TLS proxy when possible. Supported queries are a
|
||||
deliberately small subset: direct `vps_control_*` metric selectors and Loki
|
||||
stream matchers (`=` or `=~`) on `server_id`, `server_name`, `unit`, and
|
||||
`priority`.
|
||||
|
||||
## TLS, backup, and upgrades
|
||||
|
||||
Terminate TLS at Caddy, nginx, Traefik, or a managed load balancer and forward to
|
||||
the loopback frontend port. Preserve the original client IP only from a trusted
|
||||
proxy. Keep `COOKIE_SECURE=true` in production.
|
||||
|
||||
Back up with:
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres pg_dump -U vps_control -Fc vps_control > vps-control.dump
|
||||
```
|
||||
|
||||
Also back up `.env`, reverse-proxy/TLS configuration, and secret files through a
|
||||
secret manager. Test restore into a separate database. For upgrades, take a
|
||||
backup, pin image/source revisions, build, then `docker compose up -d`; check
|
||||
health and Audit. Migrations are forward-only in v1, so database restore is the
|
||||
rollback path.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd backend && go test ./... && go vet ./...
|
||||
cd ../frontend && npm ci && npm run lint && npm run typecheck
|
||||
npm test -- --run && npm run build
|
||||
```
|
||||
|
||||
The frontend Vite server proxies `/api` to `localhost:8080`. Use a development
|
||||
PostgreSQL and `COOKIE_SECURE=false`. Never point development at a production
|
||||
agent credential.
|
||||
4
control-plane/backend/.dockerignore
Normal file
4
control-plane/backend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
backend
|
||||
*_test.go
|
||||
.git
|
||||
|
||||
17
control-plane/backend/Dockerfile
Normal file
17
control-plane/backend/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM golang:1.24-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/control-plane .
|
||||
|
||||
FROM alpine:3.22
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
addgroup -S -g 65532 control && adduser -S -D -H -u 65532 -G control control
|
||||
COPY --from=build --chown=65532:65532 /out/control-plane /usr/local/bin/control-plane
|
||||
USER 65532:65532
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/control-plane"]
|
||||
|
||||
149
control-plane/backend/agent.go
Normal file
149
control-plane/backend/agent.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxAgentResponse = 2 << 20
|
||||
|
||||
type AgentClient struct {
|
||||
http *http.Client
|
||||
credentialsDir string
|
||||
}
|
||||
|
||||
func NewAgentClient(credentialsDir string) *AgentClient {
|
||||
transport := &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
TLSHandshakeTimeout: 3 * time.Second,
|
||||
ResponseHeaderTimeout: 5 * time.Second,
|
||||
MaxConnsPerHost: 8,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
}
|
||||
return &AgentClient{
|
||||
http: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 7 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return errors.New("agent redirects are disabled")
|
||||
},
|
||||
},
|
||||
credentialsDir: credentialsDir,
|
||||
}
|
||||
}
|
||||
|
||||
func validateAgentEndpoint(endpoint string) error {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return errors.New("invalid URL")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return errors.New("scheme must be http or https")
|
||||
}
|
||||
if parsed.User != nil || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return errors.New("endpoint must contain only scheme, private IP, and port")
|
||||
}
|
||||
if parsed.Port() == "" {
|
||||
return errors.New("endpoint must include a port")
|
||||
}
|
||||
ip := net.ParseIP(parsed.Hostname())
|
||||
if ip == nil || !(ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()) {
|
||||
return errors.New("endpoint must use a private literal IP address")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validCredentialRef(reference string) bool {
|
||||
if len(reference) < 1 || len(reference) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, char := range reference {
|
||||
if !(char >= 'a' && char <= 'z') && !(char >= 'A' && char <= 'Z') &&
|
||||
!(char >= '0' && char <= '9') && char != '_' && char != '-' && char != '.' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return reference != "." && reference != ".."
|
||||
}
|
||||
|
||||
func (client *AgentClient) credential(reference string) (string, error) {
|
||||
if !validCredentialRef(reference) {
|
||||
return "", errors.New("invalid credential reference")
|
||||
}
|
||||
path := filepath.Join(client.credentialsDir, reference)
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return "", errors.New("credential must be a regular file")
|
||||
}
|
||||
// Docker Compose file-backed secrets are mounted read-only as 0444 on some
|
||||
// engines. Accept read-only mounts, but never a group/world-writable secret.
|
||||
if info.Mode().Perm()&0o022 != 0 {
|
||||
return "", errors.New("credential file must not be group/world writable")
|
||||
}
|
||||
if info.Size() > 4096 {
|
||||
return "", errors.New("credential file is too large")
|
||||
}
|
||||
value, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := strings.TrimSpace(string(value))
|
||||
if len(token) < 32 {
|
||||
return "", errors.New("credential is too short")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (client *AgentClient) Do(ctx context.Context, server Server, method, path string, body []byte) (int, []byte, error) {
|
||||
if err := validateAgentEndpoint(server.AgentEndpoint); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if !strings.HasPrefix(path, "/v1/") {
|
||||
return 0, nil, errors.New("invalid agent API path")
|
||||
}
|
||||
token, err := client.credential(server.CredentialRef)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("load agent credential: %w", err)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method,
|
||||
strings.TrimRight(server.AgentEndpoint, "/")+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if len(body) > 0 {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, err := client.http.Do(request)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
limited := io.LimitReader(response.Body, maxAgentResponse+1)
|
||||
payload, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if len(payload) > maxAgentResponse {
|
||||
return 0, nil, errors.New("agent response is too large")
|
||||
}
|
||||
return response.StatusCode, payload, nil
|
||||
}
|
||||
64
control-plane/backend/agent_test.go
Normal file
64
control-plane/backend/agent_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAgentEndpointValidation(t *testing.T) {
|
||||
for _, endpoint := range []string{"http://127.0.0.1:9105", "https://10.10.0.2:9105", "http://[fd00::1]:9105"} {
|
||||
if err := validateAgentEndpoint(endpoint); err != nil {
|
||||
t.Fatalf("%s: %v", endpoint, err)
|
||||
}
|
||||
}
|
||||
for _, endpoint := range []string{"http://0.0.0.0:9105", "http://8.8.8.8:9105", "https://agent.example:9105", "file:///etc/passwd", "http://127.0.0.1:9105/path"} {
|
||||
if validateAgentEndpoint(endpoint) == nil {
|
||||
t.Fatalf("unsafe endpoint accepted: %s", endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialReferences(t *testing.T) {
|
||||
if !validCredentialRef("agent_primary.token") {
|
||||
t.Fatal("valid ref rejected")
|
||||
}
|
||||
for _, value := range []string{"", "../secret", "path/name", ".", "bad ref"} {
|
||||
if validCredentialRef(value) {
|
||||
t.Fatalf("invalid ref accepted: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentClientAuthenticatesAndBoundsPath(t *testing.T) {
|
||||
token := strings.Repeat("a", 32)
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "agent"), []byte(token), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := NewAgentClient(dir)
|
||||
client.http.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if request.Header.Get("Authorization") != "Bearer "+token {
|
||||
t.Error("missing token")
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"ok":true}`))}, nil
|
||||
})
|
||||
server := Server{AgentEndpoint: "http://127.0.0.1:9105", CredentialRef: "agent"}
|
||||
status, _, err := client.Do(context.Background(), server, http.MethodGet, "/v1/version", nil)
|
||||
if err != nil || status != 200 {
|
||||
t.Fatalf("agent request failed: status=%d err=%v", status, err)
|
||||
}
|
||||
if _, _, err = client.Do(context.Background(), server, http.MethodGet, "/private", nil); err == nil {
|
||||
t.Fatal("out-of-contract path accepted")
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (function roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return function(request)
|
||||
}
|
||||
459
control-plane/backend/api.go
Normal file
459
control-plane/backend/api.go
Normal file
@@ -0,0 +1,459 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const maxRequestBody = 64 << 10
|
||||
|
||||
type App struct {
|
||||
store *Store
|
||||
agent *AgentClient
|
||||
config Config
|
||||
logins *LoginLimiter
|
||||
integrationLimiter *LoginLimiter
|
||||
integrationTokenHash []byte
|
||||
dummyHash string
|
||||
}
|
||||
|
||||
func NewApp(store *Store, agent *AgentClient, config Config) (*App, error) {
|
||||
dummy, err := HashPassword("unused-password-for-timing-equalization")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var integrationTokenHash []byte
|
||||
if config.IntegrationTokenFile != "" {
|
||||
token, tokenErr := readIntegrationToken(config.IntegrationTokenFile)
|
||||
if tokenErr != nil {
|
||||
return nil, tokenErr
|
||||
}
|
||||
integrationTokenHash = tokenHash(token)
|
||||
}
|
||||
return &App{store: store, agent: agent, config: config,
|
||||
logins: NewLoginLimiter(5, time.Minute), integrationLimiter: NewLoginLimiter(600, time.Minute),
|
||||
integrationTokenHash: integrationTokenHash, dummyHash: dummy}, nil
|
||||
}
|
||||
|
||||
func (app *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
writer.Header().Set("X-Frame-Options", "DENY")
|
||||
writer.Header().Set("Referrer-Policy", "no-referrer")
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
writer.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
|
||||
if strings.HasPrefix(request.URL.Path, "/integrations/") {
|
||||
app.integration(writer, request)
|
||||
return
|
||||
}
|
||||
|
||||
if request.URL.Path == "/healthz" && request.Method == http.MethodGet {
|
||||
ctx, cancel := context.WithTimeout(request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := app.store.pool.Ping(ctx); err != nil {
|
||||
writeError(writer, http.StatusServiceUnavailable, "database unavailable", "unhealthy")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, map[string]string{"status": "ok"})
|
||||
return
|
||||
}
|
||||
if request.URL.Path == "/api/v1/auth/login" && request.Method == http.MethodPost {
|
||||
app.login(writer, request)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(request.URL.Path, "/api/v1/") {
|
||||
writeError(writer, http.StatusNotFound, "not found", "not_found")
|
||||
return
|
||||
}
|
||||
user, session, csrfHash, ok := app.authenticate(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case request.URL.Path == "/api/v1/auth/me" && request.Method == http.MethodGet:
|
||||
writeJSON(writer, http.StatusOK, user)
|
||||
case request.URL.Path == "/api/v1/auth/logout" && request.Method == http.MethodPost:
|
||||
if !app.requireCSRF(writer, request, csrfHash) {
|
||||
return
|
||||
}
|
||||
_ = app.store.DeleteSession(request.Context(), session)
|
||||
app.clearCookies(writer)
|
||||
app.store.Audit(request.Context(), &user, "", "auth.logout", user.Username, nil, nil, "success", app.clientIP(request))
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
case request.URL.Path == "/api/v1/servers" && request.Method == http.MethodGet:
|
||||
app.listServers(writer, request)
|
||||
case request.URL.Path == "/api/v1/servers" && request.Method == http.MethodPost:
|
||||
if !app.authorize(writer, request, user, csrfHash, RoleAdministrator) {
|
||||
return
|
||||
}
|
||||
app.createServer(writer, request, user)
|
||||
case request.URL.Path == "/api/v1/audit" && request.Method == http.MethodGet:
|
||||
app.listAudit(writer, request)
|
||||
case strings.HasPrefix(request.URL.Path, "/api/v1/servers/"):
|
||||
app.serverRoute(writer, request, user, csrfHash)
|
||||
default:
|
||||
writeError(writer, http.StatusNotFound, "not found", "not_found")
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) login(writer http.ResponseWriter, request *http.Request) {
|
||||
ip := app.clientIP(request)
|
||||
if !app.logins.Allow(ip, time.Now()) {
|
||||
writeError(writer, http.StatusTooManyRequests, "too many login attempts", "rate_limited")
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeJSON(writer, request, &input); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid request", "invalid_request")
|
||||
return
|
||||
}
|
||||
user, hash, disabled, err := app.store.LoginUser(request.Context(), input.Username)
|
||||
if err != nil {
|
||||
log.Printf("login query: %v", err)
|
||||
writeError(writer, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
if hash == "" {
|
||||
hash = app.dummyHash
|
||||
}
|
||||
valid := VerifyPassword(hash, input.Password) && user.ID != "" && !disabled
|
||||
if !valid {
|
||||
app.store.Audit(request.Context(), nil, "", "auth.login", input.Username, nil, nil, "failure", ip)
|
||||
writeError(writer, http.StatusUnauthorized, "invalid credentials", "invalid_credentials")
|
||||
return
|
||||
}
|
||||
session, csrf, err := app.store.CreateSession(request.Context(), user.ID, ip, app.config.SessionTTL)
|
||||
if err != nil {
|
||||
log.Printf("create session: %v", err)
|
||||
writeError(writer, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
app.setCookies(writer, session, csrf)
|
||||
app.store.Audit(request.Context(), &user, "", "auth.login", user.Username, nil, nil, "success", ip)
|
||||
writeJSON(writer, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (app *App) authenticate(writer http.ResponseWriter, request *http.Request) (User, string, []byte, bool) {
|
||||
cookie, err := request.Cookie("vps_session")
|
||||
if err != nil || len(cookie.Value) > 256 {
|
||||
writeError(writer, http.StatusUnauthorized, "authentication required", "unauthorized")
|
||||
return User{}, "", nil, false
|
||||
}
|
||||
user, csrfHash, err := app.store.SessionUser(request.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
log.Printf("session query: %v", err)
|
||||
}
|
||||
writeError(writer, http.StatusUnauthorized, "authentication required", "unauthorized")
|
||||
return User{}, "", nil, false
|
||||
}
|
||||
return user, cookie.Value, csrfHash, true
|
||||
}
|
||||
|
||||
func (app *App) authorize(writer http.ResponseWriter, request *http.Request, user User, csrfHash []byte, role string) bool {
|
||||
if !permits(user.Role, role) {
|
||||
writeError(writer, http.StatusForbidden, "insufficient permissions", "forbidden")
|
||||
return false
|
||||
}
|
||||
return app.requireCSRF(writer, request, csrfHash)
|
||||
}
|
||||
|
||||
func (app *App) requireCSRF(writer http.ResponseWriter, request *http.Request, expected []byte) bool {
|
||||
provided := request.Header.Get("X-CSRF-Token")
|
||||
if len(provided) > 256 || subtle.ConstantTimeCompare(tokenHash(provided), expected) != 1 {
|
||||
writeError(writer, http.StatusForbidden, "CSRF validation failed", "csrf_failed")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (app *App) setCookies(writer http.ResponseWriter, session, csrf string) {
|
||||
maxAge := int(app.config.SessionTTL.Seconds())
|
||||
http.SetCookie(writer, &http.Cookie{Name: "vps_session", Value: session, Path: "/", HttpOnly: true,
|
||||
Secure: app.config.CookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: maxAge})
|
||||
http.SetCookie(writer, &http.Cookie{Name: "vps_csrf", Value: csrf, Path: "/", HttpOnly: false,
|
||||
Secure: app.config.CookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: maxAge})
|
||||
}
|
||||
|
||||
func (app *App) clearCookies(writer http.ResponseWriter) {
|
||||
for _, name := range []string{"vps_session", "vps_csrf"} {
|
||||
http.SetCookie(writer, &http.Cookie{Name: name, Path: "/", HttpOnly: name == "vps_session",
|
||||
Secure: app.config.CookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: -1})
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) listServers(writer http.ResponseWriter, request *http.Request) {
|
||||
servers, err := app.store.Servers(request.Context())
|
||||
if err != nil {
|
||||
writeInternal(writer, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, servers)
|
||||
}
|
||||
|
||||
func (app *App) createServer(writer http.ResponseWriter, request *http.Request, user User) {
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
AgentEndpoint string `json:"agent_endpoint"`
|
||||
CredentialRef string `json:"credential_ref"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if err := decodeJSON(writer, request, &input); err != nil || len(input.Name) < 1 || len(input.Name) > 128 ||
|
||||
len(input.Hostname) > 255 || len(input.Tags) > 20 || validateAgentEndpoint(input.AgentEndpoint) != nil ||
|
||||
!validCredentialRef(input.CredentialRef) {
|
||||
writeError(writer, http.StatusBadRequest, "invalid server", "invalid_request")
|
||||
return
|
||||
}
|
||||
for _, tag := range input.Tags {
|
||||
if len(tag) > 64 {
|
||||
writeError(writer, 400, "invalid tag", "invalid_request")
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err := app.agent.credential(input.CredentialRef); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "credential reference is unavailable", "credential_unavailable")
|
||||
return
|
||||
}
|
||||
tags, _ := json.Marshal(input.Tags)
|
||||
server := Server{ID: newID(), Name: input.Name, Hostname: input.Hostname, AgentEndpoint: input.AgentEndpoint,
|
||||
CredentialRef: input.CredentialRef, Tags: tags, Status: "unknown", CreatedAt: time.Now()}
|
||||
if err := app.store.CreateServer(request.Context(), server); err != nil {
|
||||
writeInternal(writer, err)
|
||||
return
|
||||
}
|
||||
app.store.Audit(request.Context(), &user, server.ID, "server.create", server.Name, nil,
|
||||
map[string]any{"name": server.Name, "hostname": server.Hostname, "agent_endpoint": server.AgentEndpoint},
|
||||
"success", app.clientIP(request))
|
||||
writeJSON(writer, http.StatusCreated, server)
|
||||
}
|
||||
|
||||
func (app *App) listAudit(writer http.ResponseWriter, request *http.Request) {
|
||||
limit := queryLimit(request, 100, 500)
|
||||
events, err := app.store.AuditEvents(request.Context(), limit)
|
||||
if err != nil {
|
||||
writeInternal(writer, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, events)
|
||||
}
|
||||
|
||||
func (app *App) serverRoute(writer http.ResponseWriter, request *http.Request, user User, csrfHash []byte) {
|
||||
remainder := strings.TrimPrefix(request.URL.Path, "/api/v1/servers/")
|
||||
parts := strings.Split(remainder, "/")
|
||||
if len(parts) < 2 || parts[0] == "" {
|
||||
writeError(writer, 404, "not found", "not_found")
|
||||
return
|
||||
}
|
||||
server, err := app.store.Server(request.Context(), parts[0])
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(writer, 404, "server not found", "not_found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeInternal(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
readRoutes := map[string]string{
|
||||
"status": "/v1/system/status", "interfaces": "/v1/network/interfaces", "routes": "/v1/network/routes",
|
||||
"listeners": "/v1/network/listeners", "connections": "/v1/network/connections",
|
||||
"firewall": "/v1/firewall/rules", "fail2ban": "/v1/fail2ban/status",
|
||||
"wireguard": "/v1/wireguard/status", "services": "/v1/services",
|
||||
}
|
||||
if len(parts) == 2 && request.Method == http.MethodGet {
|
||||
if parts[1] == "metrics" {
|
||||
samples, metricErr := app.store.Metrics(request.Context(), server.ID, queryLimit(request, 120, 1440))
|
||||
if metricErr != nil {
|
||||
writeInternal(writer, metricErr)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, 200, samples)
|
||||
return
|
||||
}
|
||||
if parts[1] == "logs" {
|
||||
entries, logErr := app.store.Logs(request.Context(), server.ID, queryLimit(request, 200, 1000))
|
||||
if logErr != nil {
|
||||
writeInternal(writer, logErr)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, 200, entries)
|
||||
return
|
||||
}
|
||||
if agentPath, found := readRoutes[parts[1]]; found {
|
||||
app.proxyRead(writer, request, server, agentPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(parts) == 3 && request.Method == http.MethodGet && parts[1] == "fail2ban" && validSimpleName(parts[2]) {
|
||||
app.proxyRead(writer, request, server, "/v1/fail2ban/jails/"+url.PathEscape(parts[2]))
|
||||
return
|
||||
}
|
||||
if len(parts) == 3 && request.Method == http.MethodPost && parts[1] == "fail2ban" && (parts[2] == "ban" || parts[2] == "unban") {
|
||||
if !app.authorize(writer, request, user, csrfHash, RoleOperator) {
|
||||
return
|
||||
}
|
||||
app.fail2banMutation(writer, request, user, server, parts[2])
|
||||
return
|
||||
}
|
||||
if len(parts) == 4 && request.Method == http.MethodPost && parts[1] == "services" && parts[3] == "restart" && validServiceName(parts[2]) {
|
||||
if !app.authorize(writer, request, user, csrfHash, RoleOperator) {
|
||||
return
|
||||
}
|
||||
app.serviceMutation(writer, request, user, server, parts[2])
|
||||
return
|
||||
}
|
||||
writeError(writer, 404, "not found", "not_found")
|
||||
}
|
||||
|
||||
func (app *App) proxyRead(writer http.ResponseWriter, request *http.Request, server Server, path string) {
|
||||
status, body, err := app.agent.Do(request.Context(), server, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
log.Printf("agent read %s: %v", server.ID, err)
|
||||
writeError(writer, 502, "agent unavailable", "agent_unavailable")
|
||||
return
|
||||
}
|
||||
writeAgentResponse(writer, status, body)
|
||||
}
|
||||
|
||||
func (app *App) fail2banMutation(writer http.ResponseWriter, request *http.Request, user User, server Server, action string) {
|
||||
var input struct {
|
||||
Jail string `json:"jail"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
if decodeJSON(writer, request, &input) != nil || !validSimpleName(input.Jail) || net.ParseIP(input.IP) == nil {
|
||||
writeError(writer, 400, "invalid jail or IP address", "invalid_request")
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(input)
|
||||
status, response, err := app.agent.Do(request.Context(), server, http.MethodPost, "/v1/fail2ban/"+action, body)
|
||||
result := "success"
|
||||
if err != nil || status >= 300 {
|
||||
result = "failure"
|
||||
}
|
||||
app.store.Audit(request.Context(), &user, server.ID, "fail2ban."+action, input.Jail+":"+input.IP,
|
||||
nil, input, result, app.clientIP(request))
|
||||
if err != nil {
|
||||
writeError(writer, 502, "agent unavailable", "agent_unavailable")
|
||||
return
|
||||
}
|
||||
writeAgentResponse(writer, status, response)
|
||||
}
|
||||
|
||||
func (app *App) serviceMutation(writer http.ResponseWriter, request *http.Request, user User, server Server, name string) {
|
||||
status, response, err := app.agent.Do(request.Context(), server, http.MethodPost,
|
||||
"/v1/services/"+url.PathEscape(name)+"/restart", []byte("{}"))
|
||||
result := "success"
|
||||
if err != nil || status >= 300 {
|
||||
result = "failure"
|
||||
}
|
||||
app.store.Audit(request.Context(), &user, server.ID, "service.restart", name, nil,
|
||||
map[string]string{"requested": "restart"}, result, app.clientIP(request))
|
||||
if err != nil {
|
||||
writeError(writer, 502, "agent unavailable", "agent_unavailable")
|
||||
return
|
||||
}
|
||||
writeAgentResponse(writer, status, response)
|
||||
}
|
||||
|
||||
func (app *App) clientIP(request *http.Request) string {
|
||||
if app.config.TrustProxy {
|
||||
if value := request.Header.Get("X-Real-IP"); net.ParseIP(value) != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(request.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return request.RemoteAddr
|
||||
}
|
||||
|
||||
func validSimpleName(value string) bool {
|
||||
if len(value) < 1 || len(value) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if !(char >= 'a' && char <= 'z') && !(char >= 'A' && char <= 'Z') &&
|
||||
!(char >= '0' && char <= '9') && !strings.ContainsRune("._-", char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validServiceName(value string) bool {
|
||||
if len(value) < 1 || len(value) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if !(char >= 'a' && char <= 'z') && !(char >= 'A' && char <= 'Z') &&
|
||||
!(char >= '0' && char <= '9') && !strings.ContainsRune("._-@:", char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodeJSON(writer http.ResponseWriter, request *http.Request, target any) error {
|
||||
request.Body = http.MaxBytesReader(writer, request.Body, maxRequestBody)
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return errors.New("multiple JSON values")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func queryLimit(request *http.Request, fallback, maximum int) int {
|
||||
value, err := strconv.Atoi(request.URL.Query().Get("limit"))
|
||||
if err != nil || value < 1 {
|
||||
return fallback
|
||||
}
|
||||
if value > maximum {
|
||||
return maximum
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func writeAgentResponse(writer http.ResponseWriter, status int, payload []byte) {
|
||||
if status < 100 || status > 599 {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(status)
|
||||
_, _ = writer.Write(payload)
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, status int, value any) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(status)
|
||||
_ = json.NewEncoder(writer).Encode(value)
|
||||
}
|
||||
|
||||
func writeError(writer http.ResponseWriter, status int, message, code string) {
|
||||
writeJSON(writer, status, map[string]string{"error": message, "code": code})
|
||||
}
|
||||
|
||||
func writeInternal(writer http.ResponseWriter, err error) {
|
||||
log.Printf("internal request error: %v", err)
|
||||
writeError(writer, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
}
|
||||
77
control-plane/backend/api_test.go
Normal file
77
control-plane/backend/api_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStrictJSON(t *testing.T) {
|
||||
type payload struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
for _, test := range []struct {
|
||||
body string
|
||||
valid bool
|
||||
}{
|
||||
{`{"name":"vps"}`, true},
|
||||
{`{"name":"vps","secret":"leak"}`, false},
|
||||
{`{"name":"vps"}{"name":"second"}`, false},
|
||||
} {
|
||||
request := httptest.NewRequest("POST", "/", strings.NewReader(test.body))
|
||||
writer := httptest.NewRecorder()
|
||||
var value payload
|
||||
err := decodeJSON(writer, request, &value)
|
||||
if (err == nil) != test.valid {
|
||||
t.Fatalf("body %q valid=%v err=%v", test.body, test.valid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterIsBounded(t *testing.T) {
|
||||
limiter := NewLoginLimiter(2, time.Minute)
|
||||
now := time.Now()
|
||||
if !limiter.Allow("ip", now) || !limiter.Allow("ip", now) {
|
||||
t.Fatal("initial attempts rejected")
|
||||
}
|
||||
if limiter.Allow("ip", now) {
|
||||
t.Fatal("limit not enforced")
|
||||
}
|
||||
if !limiter.Allow("ip", now.Add(time.Minute)) {
|
||||
t.Fatal("window did not reset")
|
||||
}
|
||||
limiter.maxKeys = 1
|
||||
if limiter.Allow("second-ip", now) {
|
||||
t.Fatal("bounded limiter accepted an untracked key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidators(t *testing.T) {
|
||||
if !validSimpleName("nginx-http-auth") || validSimpleName("jail;shutdown") {
|
||||
t.Fatal("jail validation failed")
|
||||
}
|
||||
if !validServiceName("wg-quick@wg0") || validServiceName("nginx/restart") {
|
||||
t.Fatal("service validation failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPercent(t *testing.T) {
|
||||
if percent(25, 100) != 25 || percent(1, 0) != 0 {
|
||||
t.Fatal("invalid percentage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactLogMessage(t *testing.T) {
|
||||
input := `Authorization: Bearer abc123 password="hunter2" token=xyz&safe=1`
|
||||
want := `Authorization: Bearer [REDACTED] password=[REDACTED] token=[REDACTED]&safe=1`
|
||||
if got := redactLogMessage(input); got != want {
|
||||
t.Fatalf("redaction mismatch: got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateUTF8(t *testing.T) {
|
||||
if got := truncateUTF8("привет", 5); got != "пр" {
|
||||
t.Fatalf("unexpected UTF-8 truncation %q", got)
|
||||
}
|
||||
}
|
||||
113
control-plane/backend/auth.go
Normal file
113
control-plane/backend/auth.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleViewer = "viewer"
|
||||
RoleOperator = "operator"
|
||||
RoleAdministrator = "administrator"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
if len(password) < 12 || len(password) > 1024 {
|
||||
return "", errors.New("password must be between 12 and 1024 bytes")
|
||||
}
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
const memory = 64 * 1024
|
||||
const iterations = 3
|
||||
const parallelism = 2
|
||||
hash := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, 32)
|
||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", memory, iterations, parallelism,
|
||||
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)), nil
|
||||
}
|
||||
|
||||
func VerifyPassword(encoded, password string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" {
|
||||
return false
|
||||
}
|
||||
var memory uint32
|
||||
var iterations uint32
|
||||
var parallelism uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, ¶llelism); err != nil {
|
||||
return false
|
||||
}
|
||||
if memory > 128*1024 || iterations > 10 || parallelism > 8 {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil || len(salt) < 16 {
|
||||
return false
|
||||
}
|
||||
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil || len(expected) != 32 {
|
||||
return false
|
||||
}
|
||||
actual := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, uint32(len(expected)))
|
||||
return subtle.ConstantTimeCompare(actual, expected) == 1
|
||||
}
|
||||
|
||||
func randomToken() (string, []byte, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
digest := sha256.Sum256([]byte(token))
|
||||
return token, digest[:], nil
|
||||
}
|
||||
|
||||
func tokenHash(token string) []byte {
|
||||
digest := sha256.Sum256([]byte(token))
|
||||
return digest[:]
|
||||
}
|
||||
|
||||
func validRole(role string) bool {
|
||||
return role == RoleViewer || role == RoleOperator || role == RoleAdministrator
|
||||
}
|
||||
|
||||
func permits(role, required string) bool {
|
||||
rank := map[string]int{RoleViewer: 1, RoleOperator: 2, RoleAdministrator: 3}
|
||||
return rank[role] >= rank[required] && rank[required] > 0
|
||||
}
|
||||
|
||||
func validUsername(username string) bool {
|
||||
if len(username) < 3 || len(username) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, char := range username {
|
||||
if !(char >= 'a' && char <= 'z') && !(char >= 'A' && char <= 'Z') &&
|
||||
!(char >= '0' && char <= '9') && !strings.ContainsRune("._-", char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseInt(value string, fallback int) int {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
52
control-plane/backend/auth_test.go
Normal file
52
control-plane/backend/auth_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPasswordHashAndVerify(t *testing.T) {
|
||||
hash, err := HashPassword("correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hash == "correct horse battery staple" {
|
||||
t.Fatal("password stored in plaintext")
|
||||
}
|
||||
if !VerifyPassword(hash, "correct horse battery staple") {
|
||||
t.Fatal("correct password rejected")
|
||||
}
|
||||
if VerifyPassword(hash, "wrong password") {
|
||||
t.Fatal("wrong password accepted")
|
||||
}
|
||||
if VerifyPassword("malformed", "correct horse battery staple") {
|
||||
t.Fatal("malformed hash accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBAC(t *testing.T) {
|
||||
if !permits(RoleAdministrator, RoleOperator) {
|
||||
t.Fatal("administrator must inherit operator")
|
||||
}
|
||||
if permits(RoleViewer, RoleOperator) {
|
||||
t.Fatal("viewer must not mutate")
|
||||
}
|
||||
if !permits(RoleOperator, RoleViewer) {
|
||||
t.Fatal("operator must read")
|
||||
}
|
||||
if permits("unknown", RoleViewer) {
|
||||
t.Fatal("unknown role allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsernameValidation(t *testing.T) {
|
||||
for _, value := range []string{"admin", "ops.user", "alice-2"} {
|
||||
if !validUsername(value) {
|
||||
t.Fatalf("valid username rejected: %s", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"x", "admin@example", "../../root"} {
|
||||
if validUsername(value) {
|
||||
t.Fatalf("invalid username accepted: %s", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
126
control-plane/backend/config.go
Normal file
126
control-plane/backend/config.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenAddress string
|
||||
DatabaseURL string
|
||||
CookieSecure bool
|
||||
TrustProxy bool
|
||||
SessionTTL time.Duration
|
||||
CredentialsDir string
|
||||
AdminUsername string
|
||||
AdminPasswordFile string
|
||||
BootstrapServerName string
|
||||
BootstrapEndpoint string
|
||||
BootstrapCredential string
|
||||
MetricsRetention time.Duration
|
||||
LogsRetention time.Duration
|
||||
IntegrationTokenFile string
|
||||
}
|
||||
|
||||
func LoadConfig() (Config, error) {
|
||||
config := Config{
|
||||
ListenAddress: envOr("LISTEN_ADDRESS", ":8080"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
CookieSecure: envBool("COOKIE_SECURE", true),
|
||||
TrustProxy: envBool("TRUST_PROXY", false),
|
||||
SessionTTL: envDuration("SESSION_TTL", 12*time.Hour),
|
||||
CredentialsDir: envOr("AGENT_CREDENTIALS_DIR", "/run/secrets"),
|
||||
AdminUsername: envOr("INITIAL_ADMIN_USERNAME", "admin"),
|
||||
AdminPasswordFile: os.Getenv("INITIAL_ADMIN_PASSWORD_FILE"),
|
||||
BootstrapServerName: os.Getenv("BOOTSTRAP_SERVER_NAME"),
|
||||
BootstrapEndpoint: os.Getenv("BOOTSTRAP_SERVER_ENDPOINT"),
|
||||
BootstrapCredential: os.Getenv("BOOTSTRAP_AGENT_CREDENTIAL_REF"),
|
||||
MetricsRetention: envDuration("METRICS_RETENTION", 30*24*time.Hour),
|
||||
LogsRetention: envDuration("LOGS_RETENTION", 7*24*time.Hour),
|
||||
IntegrationTokenFile: os.Getenv("INTEGRATION_TOKEN_FILE"),
|
||||
}
|
||||
if config.DatabaseURL == "" {
|
||||
config.DatabaseURL = buildDatabaseURL()
|
||||
}
|
||||
if config.DatabaseURL == "" {
|
||||
return Config{}, errors.New("DATABASE_URL or DB_HOST/DB_PASSWORD_FILE is required")
|
||||
}
|
||||
if config.SessionTTL < 5*time.Minute || config.SessionTTL > 7*24*time.Hour {
|
||||
return Config{}, errors.New("SESSION_TTL must be between 5m and 168h")
|
||||
}
|
||||
if config.MetricsRetention < 24*time.Hour {
|
||||
return Config{}, errors.New("METRICS_RETENTION must be at least 24h")
|
||||
}
|
||||
if config.LogsRetention < time.Hour || config.LogsRetention > 90*24*time.Hour {
|
||||
return Config{}, errors.New("LOGS_RETENTION must be between 1h and 2160h")
|
||||
}
|
||||
if config.BootstrapEndpoint != "" {
|
||||
if err := validateAgentEndpoint(config.BootstrapEndpoint); err != nil {
|
||||
return Config{}, fmt.Errorf("bootstrap endpoint: %w", err)
|
||||
}
|
||||
if !validCredentialRef(config.BootstrapCredential) {
|
||||
return Config{}, errors.New("invalid BOOTSTRAP_AGENT_CREDENTIAL_REF")
|
||||
}
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func buildDatabaseURL() string {
|
||||
host := os.Getenv("DB_HOST")
|
||||
passwordFile := os.Getenv("DB_PASSWORD_FILE")
|
||||
if host == "" || passwordFile == "" {
|
||||
return ""
|
||||
}
|
||||
password, err := os.ReadFile(filepath.Clean(passwordFile))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
user := envOr("DB_USER", "vps_control")
|
||||
database := envOr("DB_NAME", "vps_control")
|
||||
values := url.Values{"sslmode": {envOr("DB_SSLMODE", "disable")}}
|
||||
return (&url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(user, strings.TrimSpace(string(password))),
|
||||
Host: net.JoinHostPort(host, envOr("DB_PORT", "5432")),
|
||||
Path: database,
|
||||
RawQuery: values.Encode(),
|
||||
}).String()
|
||||
}
|
||||
|
||||
func envOr(name, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envBool(name string, fallback bool) bool {
|
||||
value := os.Getenv(name)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envDuration(name string, fallback time.Duration) time.Duration {
|
||||
value := os.Getenv(name)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
572
control-plane/backend/db.go
Normal file
572
control-plane/backend/db.go
Normal file
@@ -0,0 +1,572 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id text PRIMARY KEY,
|
||||
username text UNIQUE NOT NULL,
|
||||
password_hash text NOT NULL,
|
||||
role text NOT NULL CHECK (role IN ('viewer','operator','administrator')),
|
||||
disabled boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id_hash bytea PRIMARY KEY,
|
||||
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
csrf_hash bytea NOT NULL,
|
||||
source_ip text NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS sessions_expires_idx ON sessions(expires_at);
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id text PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
hostname text NOT NULL DEFAULT '',
|
||||
agent_endpoint text NOT NULL,
|
||||
credential_ref text NOT NULL,
|
||||
tags jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
status text NOT NULL DEFAULT 'unknown',
|
||||
last_seen timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS metric_samples (
|
||||
server_id text NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at timestamptz NOT NULL DEFAULT now(),
|
||||
cpu double precision NOT NULL,
|
||||
memory double precision NOT NULL,
|
||||
disk double precision NOT NULL,
|
||||
rx bigint NOT NULL,
|
||||
tx bigint NOT NULL,
|
||||
connections integer NOT NULL,
|
||||
bans integer NOT NULL,
|
||||
PRIMARY KEY(server_id, sampled_at)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS metric_samples_time_idx ON metric_samples(sampled_at);
|
||||
CREATE TABLE IF NOT EXISTS agent_log_entries (
|
||||
server_id text NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
journal_cursor text NOT NULL,
|
||||
event_at timestamptz NOT NULL,
|
||||
unit text NOT NULL,
|
||||
priority smallint NOT NULL CHECK (priority BETWEEN 0 AND 7),
|
||||
message text NOT NULL,
|
||||
boot_id text,
|
||||
pid integer,
|
||||
ingested_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(server_id, journal_cursor)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS agent_log_entries_time_idx ON agent_log_entries(event_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS agent_log_entries_server_time_idx ON agent_log_entries(server_id, event_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id bigserial PRIMARY KEY,
|
||||
user_id text REFERENCES users(id) ON DELETE SET NULL,
|
||||
username text NOT NULL,
|
||||
server_id text REFERENCES servers(id) ON DELETE SET NULL,
|
||||
action text NOT NULL,
|
||||
target text NOT NULL,
|
||||
before_state jsonb,
|
||||
after_state jsonb,
|
||||
result text NOT NULL,
|
||||
source_ip text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS audit_events_time_idx ON audit_events(created_at DESC);
|
||||
`
|
||||
|
||||
type Store struct{ pool *pgxpool.Pool }
|
||||
|
||||
type Server struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
AgentEndpoint string `json:"agent_endpoint"`
|
||||
CredentialRef string `json:"-"`
|
||||
Tags json.RawMessage `json:"tags"`
|
||||
Status string `json:"status"`
|
||||
LastSeen *time.Time `json:"last_seen"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type AuditEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
ServerID *string `json:"server_id"`
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target"`
|
||||
Before json.RawMessage `json:"before"`
|
||||
After json.RawMessage `json:"after"`
|
||||
Result string `json:"result"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type MetricSample struct {
|
||||
SampledAt time.Time `json:"sampled_at"`
|
||||
CPU float64 `json:"cpu"`
|
||||
Memory float64 `json:"memory"`
|
||||
Disk float64 `json:"disk"`
|
||||
RX int64 `json:"rx"`
|
||||
TX int64 `json:"tx"`
|
||||
Connections int `json:"connections"`
|
||||
Bans int `json:"bans"`
|
||||
}
|
||||
|
||||
type AgentLogEntry struct {
|
||||
ServerID string `json:"server_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Unit string `json:"unit"`
|
||||
Priority int `json:"priority"`
|
||||
Message string `json:"message"`
|
||||
BootID *string `json:"boot_id,omitempty"`
|
||||
PID *int `json:"pid,omitempty"`
|
||||
}
|
||||
|
||||
type MetricPoint struct {
|
||||
ServerID string
|
||||
ServerName string
|
||||
Timestamp time.Time
|
||||
Value float64
|
||||
}
|
||||
|
||||
type IntegrationLog struct {
|
||||
ServerID string
|
||||
ServerName string
|
||||
Timestamp time.Time
|
||||
Unit string
|
||||
Priority int
|
||||
Message string
|
||||
}
|
||||
|
||||
func OpenStore(ctx context.Context, databaseURL string) (*Store, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 30; attempt++ {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err == nil {
|
||||
err = pool.Ping(ctx)
|
||||
}
|
||||
if err == nil {
|
||||
store := &Store{pool: pool}
|
||||
if _, err = pool.Exec(ctx, schema); err == nil {
|
||||
return store, nil
|
||||
}
|
||||
pool.Close()
|
||||
}
|
||||
lastErr = err
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("connect/migrate database: %w", lastErr)
|
||||
}
|
||||
|
||||
func (store *Store) Close() { store.pool.Close() }
|
||||
|
||||
func (store *Store) Bootstrap(ctx context.Context, config Config) error {
|
||||
var count int
|
||||
if err := store.pool.QueryRow(ctx, "SELECT count(*) FROM users").Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
if !validUsername(config.AdminUsername) || config.AdminPasswordFile == "" {
|
||||
return errors.New("first start requires INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD_FILE")
|
||||
}
|
||||
password, err := os.ReadFile(config.AdminPasswordFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read initial admin password: %w", err)
|
||||
}
|
||||
hash, err := HashPassword(strings.TrimSpace(string(password)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("initial admin password: %w", err)
|
||||
}
|
||||
if _, err = store.pool.Exec(ctx,
|
||||
"INSERT INTO users(id, username, password_hash, role) VALUES($1,$2,$3,$4)",
|
||||
newID(), config.AdminUsername, hash, RoleAdministrator); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("created initial administrator %q", config.AdminUsername)
|
||||
}
|
||||
if config.BootstrapEndpoint != "" {
|
||||
_, err := store.pool.Exec(ctx, `INSERT INTO servers(id,name,agent_endpoint,credential_ref)
|
||||
SELECT $1,$2,$3,$4 WHERE NOT EXISTS (SELECT 1 FROM servers)`,
|
||||
newID(), config.BootstrapServerName, config.BootstrapEndpoint, config.BootstrapCredential)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) LoginUser(ctx context.Context, username string) (User, string, bool, error) {
|
||||
var user User
|
||||
var hash string
|
||||
var disabled bool
|
||||
err := store.pool.QueryRow(ctx,
|
||||
"SELECT id,username,role,password_hash,disabled FROM users WHERE username=$1", username).
|
||||
Scan(&user.ID, &user.Username, &user.Role, &hash, &disabled)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, "", false, nil
|
||||
}
|
||||
return user, hash, disabled, err
|
||||
}
|
||||
|
||||
func (store *Store) CreateSession(ctx context.Context, userID, sourceIP string, ttl time.Duration) (string, string, error) {
|
||||
session, sessionHash, err := randomToken()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
csrf, csrfHash, err := randomToken()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
_, err = store.pool.Exec(ctx,
|
||||
"INSERT INTO sessions(id_hash,user_id,csrf_hash,source_ip,expires_at) VALUES($1,$2,$3,$4,$5)",
|
||||
sessionHash, userID, csrfHash, sourceIP, time.Now().Add(ttl))
|
||||
return session, csrf, err
|
||||
}
|
||||
|
||||
func (store *Store) SessionUser(ctx context.Context, token string) (User, []byte, error) {
|
||||
var user User
|
||||
var csrfHash []byte
|
||||
err := store.pool.QueryRow(ctx, `SELECT u.id,u.username,u.role,s.csrf_hash
|
||||
FROM sessions s JOIN users u ON u.id=s.user_id
|
||||
WHERE s.id_hash=$1 AND s.expires_at>now() AND NOT u.disabled`, tokenHash(token)).
|
||||
Scan(&user.ID, &user.Username, &user.Role, &csrfHash)
|
||||
return user, csrfHash, err
|
||||
}
|
||||
|
||||
func (store *Store) DeleteSession(ctx context.Context, token string) error {
|
||||
_, err := store.pool.Exec(ctx, "DELETE FROM sessions WHERE id_hash=$1", tokenHash(token))
|
||||
return err
|
||||
}
|
||||
|
||||
func (store *Store) Servers(ctx context.Context) ([]Server, error) {
|
||||
rows, err := store.pool.Query(ctx, `SELECT id,name,hostname,agent_endpoint,credential_ref,tags,status,last_seen,created_at
|
||||
FROM servers ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
servers := []Server{}
|
||||
for rows.Next() {
|
||||
var server Server
|
||||
if err = rows.Scan(&server.ID, &server.Name, &server.Hostname, &server.AgentEndpoint,
|
||||
&server.CredentialRef, &server.Tags, &server.Status, &server.LastSeen, &server.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
servers = append(servers, server)
|
||||
}
|
||||
return servers, rows.Err()
|
||||
}
|
||||
|
||||
func (store *Store) Server(ctx context.Context, id string) (Server, error) {
|
||||
var server Server
|
||||
err := store.pool.QueryRow(ctx, `SELECT id,name,hostname,agent_endpoint,credential_ref,tags,status,last_seen,created_at
|
||||
FROM servers WHERE id=$1`, id).Scan(&server.ID, &server.Name, &server.Hostname, &server.AgentEndpoint,
|
||||
&server.CredentialRef, &server.Tags, &server.Status, &server.LastSeen, &server.CreatedAt)
|
||||
return server, err
|
||||
}
|
||||
|
||||
func (store *Store) CreateServer(ctx context.Context, server Server) error {
|
||||
_, err := store.pool.Exec(ctx, `INSERT INTO servers(id,name,hostname,agent_endpoint,credential_ref,tags)
|
||||
VALUES($1,$2,$3,$4,$5,$6)`, server.ID, server.Name, server.Hostname,
|
||||
server.AgentEndpoint, server.CredentialRef, server.Tags)
|
||||
return err
|
||||
}
|
||||
|
||||
func (store *Store) Audit(ctx context.Context, user *User, serverID, action, target string,
|
||||
before, after any, result, sourceIP string) {
|
||||
var userID any
|
||||
username := "anonymous"
|
||||
if user != nil {
|
||||
userID, username = user.ID, user.Username
|
||||
}
|
||||
beforeJSON, _ := json.Marshal(before)
|
||||
afterJSON, _ := json.Marshal(after)
|
||||
if before == nil {
|
||||
beforeJSON = nil
|
||||
}
|
||||
if after == nil {
|
||||
afterJSON = nil
|
||||
}
|
||||
if _, err := store.pool.Exec(ctx, `INSERT INTO audit_events
|
||||
(user_id,username,server_id,action,target,before_state,after_state,result,source_ip)
|
||||
VALUES($1,$2,NULLIF($3,''),$4,$5,$6,$7,$8,$9)`, userID, username, serverID,
|
||||
action, target, beforeJSON, afterJSON, result, sourceIP); err != nil {
|
||||
log.Printf("write audit event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) AuditEvents(ctx context.Context, limit int) ([]AuditEvent, error) {
|
||||
rows, err := store.pool.Query(ctx, `SELECT id,username,server_id,action,target,
|
||||
COALESCE(before_state,'null'::jsonb),COALESCE(after_state,'null'::jsonb),result,source_ip,created_at
|
||||
FROM audit_events ORDER BY created_at DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
events := []AuditEvent{}
|
||||
for rows.Next() {
|
||||
var event AuditEvent
|
||||
if err = rows.Scan(&event.ID, &event.Username, &event.ServerID, &event.Action, &event.Target,
|
||||
&event.Before, &event.After, &event.Result, &event.SourceIP, &event.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (store *Store) Metrics(ctx context.Context, serverID string, limit int) ([]MetricSample, error) {
|
||||
rows, err := store.pool.Query(ctx, `SELECT sampled_at,cpu,memory,disk,rx,tx,connections,bans
|
||||
FROM metric_samples WHERE server_id=$1 ORDER BY sampled_at DESC LIMIT $2`, serverID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
samples := []MetricSample{}
|
||||
for rows.Next() {
|
||||
var sample MetricSample
|
||||
if err = rows.Scan(&sample.SampledAt, &sample.CPU, &sample.Memory, &sample.Disk,
|
||||
&sample.RX, &sample.TX, &sample.Connections, &sample.Bans); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
samples = append(samples, sample)
|
||||
}
|
||||
return samples, rows.Err()
|
||||
}
|
||||
|
||||
func (store *Store) Logs(ctx context.Context, serverID string, limit int) ([]AgentLogEntry, error) {
|
||||
rows, err := store.pool.Query(ctx, `SELECT server_id,event_at,unit,priority,message,boot_id,pid
|
||||
FROM agent_log_entries WHERE server_id=$1 ORDER BY event_at DESC LIMIT $2`, serverID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
entries := []AgentLogEntry{}
|
||||
for rows.Next() {
|
||||
var entry AgentLogEntry
|
||||
if err = rows.Scan(&entry.ServerID, &entry.Timestamp, &entry.Unit, &entry.Priority,
|
||||
&entry.Message, &entry.BootID, &entry.PID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func (store *Store) MetricSeries(ctx context.Context) ([]map[string]string, error) {
|
||||
rows, err := store.pool.Query(ctx, "SELECT id,name FROM servers ORDER BY id LIMIT 1000")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
servers := [][2]string{}
|
||||
for rows.Next() {
|
||||
var server [2]string
|
||||
if err = rows.Scan(&server[0], &server[1]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
servers = append(servers, server)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(metricColumns))
|
||||
for name := range metricColumns {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
series := make([]map[string]string, 0, len(servers)*len(names))
|
||||
for _, server := range servers {
|
||||
for _, name := range names {
|
||||
series = append(series, map[string]string{
|
||||
"__name__": name, "server_id": server[0], "server_name": server[1],
|
||||
})
|
||||
}
|
||||
}
|
||||
return series, nil
|
||||
}
|
||||
|
||||
func (store *Store) MetricLabelValues(ctx context.Context, label string) ([]string, error) {
|
||||
column := ""
|
||||
switch label {
|
||||
case "server_id":
|
||||
column = "id"
|
||||
case "server_name":
|
||||
column = "name"
|
||||
default:
|
||||
return nil, errors.New("unsupported metric label")
|
||||
}
|
||||
rows, err := store.pool.Query(ctx, "SELECT "+column+" FROM servers ORDER BY "+column+" LIMIT 10000")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
values := []string{}
|
||||
for rows.Next() {
|
||||
var value string
|
||||
if err = rows.Scan(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, rows.Err()
|
||||
}
|
||||
|
||||
func (store *Store) InstantMetric(ctx context.Context, query metricQuery, at time.Time) ([]MetricPoint, error) {
|
||||
column, found := metricColumns[query.Name]
|
||||
if !found {
|
||||
return nil, errors.New("unsupported metric")
|
||||
}
|
||||
statement := fmt.Sprintf(`SELECT DISTINCT ON (m.server_id)
|
||||
m.server_id,s.name,m.sampled_at,m.%s::double precision
|
||||
FROM metric_samples m JOIN servers s ON s.id=m.server_id
|
||||
WHERE m.sampled_at <= $1 AND ($2='' OR s.id=$2) AND ($3='' OR s.name=$3)
|
||||
ORDER BY m.server_id,m.sampled_at DESC LIMIT 10000`, column)
|
||||
rows, err := store.pool.Query(ctx, statement, at, query.ServerID, query.ServerName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMetricPoints(rows)
|
||||
}
|
||||
|
||||
func (store *Store) RangeMetric(ctx context.Context, query metricQuery, start, end time.Time, step int) ([]MetricPoint, error) {
|
||||
column, found := metricColumns[query.Name]
|
||||
if !found {
|
||||
return nil, errors.New("unsupported metric")
|
||||
}
|
||||
statement := fmt.Sprintf(`SELECT m.server_id,s.name,
|
||||
to_timestamp(floor(extract(epoch FROM m.sampled_at)/$3)*$3) AS bucket,
|
||||
avg(m.%s::double precision)
|
||||
FROM metric_samples m JOIN servers s ON s.id=m.server_id
|
||||
WHERE m.sampled_at BETWEEN $1 AND $2 AND ($4='' OR s.id=$4) AND ($5='' OR s.name=$5)
|
||||
GROUP BY m.server_id,s.name,bucket ORDER BY m.server_id,bucket LIMIT 100000`, column)
|
||||
rows, err := store.pool.Query(ctx, statement, start, end, step, query.ServerID, query.ServerName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMetricPoints(rows)
|
||||
}
|
||||
|
||||
func scanMetricPoints(rows pgx.Rows) ([]MetricPoint, error) {
|
||||
points := []MetricPoint{}
|
||||
for rows.Next() {
|
||||
var point MetricPoint
|
||||
if err := rows.Scan(&point.ServerID, &point.ServerName, &point.Timestamp, &point.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
points = append(points, point)
|
||||
}
|
||||
return points, rows.Err()
|
||||
}
|
||||
|
||||
func (store *Store) LogLabelValues(ctx context.Context, label string) ([]string, error) {
|
||||
column := ""
|
||||
switch label {
|
||||
case "server_id":
|
||||
column = "s.id"
|
||||
case "server_name":
|
||||
column = "s.name"
|
||||
case "unit":
|
||||
column = "l.unit"
|
||||
case "priority":
|
||||
column = "l.priority::text"
|
||||
default:
|
||||
return nil, errors.New("unsupported log label")
|
||||
}
|
||||
statement := `SELECT DISTINCT ` + column + ` AS value FROM agent_log_entries l
|
||||
JOIN servers s ON s.id=l.server_id ORDER BY value LIMIT 10000`
|
||||
rows, err := store.pool.Query(ctx, statement)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
values := []string{}
|
||||
for rows.Next() {
|
||||
var value string
|
||||
if err = rows.Scan(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, rows.Err()
|
||||
}
|
||||
|
||||
func (store *Store) LogSeries(ctx context.Context) ([]map[string]string, error) {
|
||||
rows, err := store.pool.Query(ctx, `SELECT DISTINCT s.id,s.name,l.unit,l.priority
|
||||
FROM agent_log_entries l JOIN servers s ON s.id=l.server_id
|
||||
ORDER BY s.id,l.unit,l.priority LIMIT 10000`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
series := []map[string]string{}
|
||||
for rows.Next() {
|
||||
var serverID, serverName, unit string
|
||||
var priority int
|
||||
if err = rows.Scan(&serverID, &serverName, &unit, &priority); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
series = append(series, map[string]string{
|
||||
"server_id": serverID, "server_name": serverName, "unit": unit,
|
||||
"priority": strconv.Itoa(priority),
|
||||
})
|
||||
}
|
||||
return series, rows.Err()
|
||||
}
|
||||
|
||||
func (store *Store) IntegrationLogs(ctx context.Context, start, end time.Time, limit int, direction string) ([]IntegrationLog, error) {
|
||||
order := "DESC"
|
||||
if direction == "forward" {
|
||||
order = "ASC"
|
||||
}
|
||||
statement := `SELECT l.server_id,s.name,l.event_at,l.unit,l.priority,l.message
|
||||
FROM agent_log_entries l JOIN servers s ON s.id=l.server_id
|
||||
WHERE l.event_at BETWEEN $1 AND $2 ORDER BY l.event_at ` + order + ` LIMIT $3`
|
||||
rows, err := store.pool.Query(ctx, statement, start, end, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
entries := []IntegrationLog{}
|
||||
for rows.Next() {
|
||||
var entry IntegrationLog
|
||||
if err = rows.Scan(&entry.ServerID, &entry.ServerName, &entry.Timestamp, &entry.Unit,
|
||||
&entry.Priority, &entry.Message); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func newID() string {
|
||||
var value [16]byte
|
||||
if _, err := rand.Read(value[:]); err != nil {
|
||||
panic("system random source unavailable")
|
||||
}
|
||||
value[6] = (value[6] & 0x0f) | 0x40
|
||||
value[8] = (value[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||
value[0:4], value[4:6], value[6:8], value[8:10], value[10:16])
|
||||
}
|
||||
17
control-plane/backend/go.mod
Normal file
17
control-plane/backend/go.mod
Normal file
@@ -0,0 +1,17 @@
|
||||
module github.com/vps-control/vps-control/control-plane/backend
|
||||
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.5.5
|
||||
golang.org/x/crypto v0.23.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
)
|
||||
27
control-plane/backend/go.sum
Normal file
27
control-plane/backend/go.sum
Normal file
@@ -0,0 +1,27 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
53
control-plane/backend/limiter.go
Normal file
53
control-plane/backend/limiter.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type loginEntry struct {
|
||||
window time.Time
|
||||
count int
|
||||
seen time.Time
|
||||
}
|
||||
|
||||
type LoginLimiter struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]loginEntry
|
||||
limit int
|
||||
window time.Duration
|
||||
maxKeys int
|
||||
}
|
||||
|
||||
func NewLoginLimiter(limit int, window time.Duration) *LoginLimiter {
|
||||
return &LoginLimiter{entries: make(map[string]loginEntry), limit: limit, window: window, maxKeys: 10000}
|
||||
}
|
||||
|
||||
func (limiter *LoginLimiter) Allow(key string, now time.Time) bool {
|
||||
limiter.mu.Lock()
|
||||
defer limiter.mu.Unlock()
|
||||
_, known := limiter.entries[key]
|
||||
if !known && len(limiter.entries) >= limiter.maxKeys {
|
||||
cutoff := now.Add(-2 * limiter.window)
|
||||
for item, entry := range limiter.entries {
|
||||
if entry.seen.Before(cutoff) {
|
||||
delete(limiter.entries, item)
|
||||
}
|
||||
}
|
||||
if len(limiter.entries) >= limiter.maxKeys {
|
||||
return false
|
||||
}
|
||||
}
|
||||
entry := limiter.entries[key]
|
||||
if entry.window.IsZero() || now.Sub(entry.window) >= limiter.window {
|
||||
entry.window, entry.count = now, 0
|
||||
}
|
||||
entry.seen = now
|
||||
if entry.count >= limiter.limit {
|
||||
limiter.entries[key] = entry
|
||||
return false
|
||||
}
|
||||
entry.count++
|
||||
limiter.entries[key] = entry
|
||||
return true
|
||||
}
|
||||
52
control-plane/backend/main.go
Normal file
52
control-plane/backend/main.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config, err := LoadConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("configuration: %v", err)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
store, err := OpenStore(ctx, config.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("database: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err = store.Bootstrap(ctx, config); err != nil {
|
||||
log.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
agent := NewAgentClient(config.CredentialsDir)
|
||||
app, err := NewApp(store, agent, config)
|
||||
if err != nil {
|
||||
log.Fatalf("application: %v", err)
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: config.ListenAddress, Handler: app,
|
||||
ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second,
|
||||
MaxHeaderBytes: 16 << 10,
|
||||
}
|
||||
poller := &Poller{store: store, agent: agent, metricRetention: config.MetricsRetention, logRetention: config.LogsRetention}
|
||||
go poller.Run(ctx)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
log.Printf("control-plane API listening on %s", config.ListenAddress)
|
||||
if err = server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("HTTP server: %v", err)
|
||||
}
|
||||
}
|
||||
527
control-plane/backend/observability.go
Normal file
527
control-plane/backend/observability.go
Normal file
@@ -0,0 +1,527 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var metricSelector = regexp.MustCompile(`^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?$`)
|
||||
var logSelector = regexp.MustCompile(`^([a-z_][a-z0-9_]*)\s*(=|=~)\s*"([^"]*)"$`)
|
||||
|
||||
var metricColumns = map[string]string{
|
||||
"vps_control_cpu_usage_percent": "cpu",
|
||||
"vps_control_memory_usage_percent": "memory",
|
||||
"vps_control_disk_usage_percent": "disk",
|
||||
"vps_control_network_rx_bytes": "rx",
|
||||
"vps_control_network_tx_bytes": "tx",
|
||||
"vps_control_connections": "connections",
|
||||
"vps_control_bans": "bans",
|
||||
}
|
||||
|
||||
type metricQuery struct {
|
||||
Name string
|
||||
ServerID string
|
||||
ServerName string
|
||||
}
|
||||
|
||||
type logMatcher struct {
|
||||
Label string
|
||||
Exact string
|
||||
Regex *regexp.Regexp
|
||||
}
|
||||
|
||||
func readIntegrationToken(path string) (string, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read integration token metadata: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o022 != 0 {
|
||||
return "", errors.New("integration token must be a non-writable regular file")
|
||||
}
|
||||
if info.Size() > 4096 {
|
||||
return "", errors.New("integration token file is too large")
|
||||
}
|
||||
value, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read integration token: %w", err)
|
||||
}
|
||||
token := strings.TrimSpace(string(value))
|
||||
if len(token) < 32 || len(token) > 256 || strings.IndexFunc(token, func(character rune) bool {
|
||||
return character < 0x21 || character == 0x7f
|
||||
}) >= 0 {
|
||||
return "", errors.New("integration token must contain 32-256 visible bytes")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (app *App) integration(writer http.ResponseWriter, request *http.Request) {
|
||||
if len(app.integrationTokenHash) == 0 {
|
||||
writeError(writer, http.StatusNotFound, "integration API is disabled", "not_found")
|
||||
return
|
||||
}
|
||||
if !app.integrationLimiter.Allow(app.clientIP(request), time.Now()) {
|
||||
writeError(writer, http.StatusTooManyRequests, "integration request rate exceeded", "rate_limited")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
request = request.WithContext(ctx)
|
||||
provided := strings.TrimPrefix(request.Header.Get("Authorization"), "Bearer ")
|
||||
if len(provided) > 256 || subtle.ConstantTimeCompare(tokenHash(provided), app.integrationTokenHash) != 1 {
|
||||
writeError(writer, http.StatusUnauthorized, "integration authentication required", "unauthorized")
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(request.URL.Path, "/integrations/prometheus/") {
|
||||
app.prometheusAPI(writer, request)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(request.URL.Path, "/integrations/loki/") {
|
||||
app.lokiAPI(writer, request)
|
||||
return
|
||||
}
|
||||
writeError(writer, http.StatusNotFound, "not found", "not_found")
|
||||
}
|
||||
|
||||
func (app *App) prometheusAPI(writer http.ResponseWriter, request *http.Request) {
|
||||
path := strings.TrimPrefix(request.URL.Path, "/integrations/prometheus")
|
||||
if request.Method != http.MethodGet && request.Method != http.MethodPost {
|
||||
writePrometheusError(writer, "bad_data", "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if request.Method == http.MethodPost {
|
||||
request.Body = http.MaxBytesReader(writer, request.Body, maxRequestBody)
|
||||
}
|
||||
switch path {
|
||||
case "/-/healthy", "/-/ready":
|
||||
writeJSON(writer, 200, map[string]string{"status": "success"})
|
||||
case "/api/v1/query":
|
||||
app.prometheusQuery(writer, request, false)
|
||||
case "/api/v1/query_range":
|
||||
app.prometheusQuery(writer, request, true)
|
||||
case "/api/v1/labels":
|
||||
writeJSON(writer, 200, map[string]any{"status": "success", "data": []string{"__name__", "server_id", "server_name"}})
|
||||
case "/api/v1/label/__name__/values":
|
||||
names := make([]string, 0, len(metricColumns))
|
||||
for name := range metricColumns {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
writeJSON(writer, 200, map[string]any{"status": "success", "data": names})
|
||||
case "/api/v1/label/server_id/values", "/api/v1/label/server_name/values":
|
||||
label := strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/label/"), "/values")
|
||||
values, err := app.store.MetricLabelValues(request.Context(), label)
|
||||
if err != nil {
|
||||
writePrometheusError(writer, "internal", "query failed", 500)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, 200, map[string]any{"status": "success", "data": values})
|
||||
case "/api/v1/metadata":
|
||||
metadata := map[string][]map[string]string{}
|
||||
for name := range metricColumns {
|
||||
metadata[name] = []map[string]string{{"type": "gauge", "help": "VPS Control sampled metric", "unit": ""}}
|
||||
}
|
||||
writeJSON(writer, 200, map[string]any{"status": "success", "data": metadata})
|
||||
case "/api/v1/series":
|
||||
series, err := app.store.MetricSeries(request.Context())
|
||||
if err != nil {
|
||||
writePrometheusError(writer, "internal", "query failed", 500)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, 200, map[string]any{"status": "success", "data": series})
|
||||
case "/api/v1/status/buildinfo":
|
||||
writeJSON(writer, 200, map[string]any{"status": "success", "data": map[string]string{"version": "vps-control-compat-v1"}})
|
||||
default:
|
||||
writePrometheusError(writer, "bad_data", "unsupported Prometheus API operation", 404)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) prometheusQuery(writer http.ResponseWriter, request *http.Request, ranged bool) {
|
||||
if err := request.ParseForm(); err != nil {
|
||||
writePrometheusError(writer, "bad_data", "invalid form", 400)
|
||||
return
|
||||
}
|
||||
rawQuery := request.Form.Get("query")
|
||||
if !ranged && rawQuery == "1+1" {
|
||||
at, err := parseTimestamp(request.Form.Get("time"), time.Now())
|
||||
if err != nil {
|
||||
writePrometheusError(writer, "bad_data", "invalid time", 400)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, 200, map[string]any{"status": "success", "data": map[string]any{
|
||||
"resultType": "scalar", "result": []any{float64(at.UnixNano()) / 1e9, "2"},
|
||||
}})
|
||||
return
|
||||
}
|
||||
query, err := parseMetricQuery(rawQuery)
|
||||
if err != nil {
|
||||
writePrometheusError(writer, "bad_data", err.Error(), 400)
|
||||
return
|
||||
}
|
||||
timeParameter := "time"
|
||||
if ranged {
|
||||
timeParameter = "end"
|
||||
}
|
||||
end, err := parseTimestamp(request.Form.Get(timeParameter), time.Now())
|
||||
if err != nil {
|
||||
writePrometheusError(writer, "bad_data", "invalid "+timeParameter, 400)
|
||||
return
|
||||
}
|
||||
if !ranged {
|
||||
points, queryErr := app.store.InstantMetric(request.Context(), query, end)
|
||||
if queryErr != nil {
|
||||
writePrometheusError(writer, "internal", "query failed", 500)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, 200, prometheusVector(query.Name, points))
|
||||
return
|
||||
}
|
||||
start, err := parseTimestamp(request.Form.Get("start"), end.Add(-time.Hour))
|
||||
if err != nil || start.After(end) || end.Sub(start) > 31*24*time.Hour {
|
||||
writePrometheusError(writer, "bad_data", "range must be positive and at most 31 days", 400)
|
||||
return
|
||||
}
|
||||
step, err := parseStep(request.Form.Get("step"))
|
||||
if err != nil {
|
||||
writePrometheusError(writer, "bad_data", "invalid step", 400)
|
||||
return
|
||||
}
|
||||
if int64(end.Sub(start)/time.Second)/int64(step) > 10_000 {
|
||||
writePrometheusError(writer, "bad_data", "range contains more than 10000 points per series", 400)
|
||||
return
|
||||
}
|
||||
points, queryErr := app.store.RangeMetric(request.Context(), query, start, end, step)
|
||||
if queryErr != nil {
|
||||
writePrometheusError(writer, "internal", "query failed", 500)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, 200, prometheusMatrix(query.Name, points))
|
||||
}
|
||||
|
||||
func (app *App) lokiAPI(writer http.ResponseWriter, request *http.Request) {
|
||||
path := strings.TrimPrefix(request.URL.Path, "/integrations/loki")
|
||||
if request.Method != http.MethodGet {
|
||||
writeError(writer, 405, "method not allowed", "method_not_allowed")
|
||||
return
|
||||
}
|
||||
switch path {
|
||||
case "/ready":
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
case "/loki/api/v1/query_range":
|
||||
app.lokiQueryRange(writer, request)
|
||||
case "/loki/api/v1/query":
|
||||
if request.URL.Query().Get("query") != "vector(1)+vector(1)" {
|
||||
writeError(writer, 400, "only the datasource health expression is supported for instant queries", "invalid_query")
|
||||
return
|
||||
}
|
||||
at, err := parseLokiTimestamp(request.URL.Query().Get("time"), time.Now())
|
||||
if err != nil {
|
||||
writeError(writer, 400, "invalid time", "invalid_query")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, 200, map[string]any{"status": "success", "data": map[string]any{
|
||||
"resultType": "vector", "result": []any{map[string]any{
|
||||
"metric": map[string]string{}, "value": []any{float64(at.UnixNano()) / 1e9, "2"},
|
||||
}},
|
||||
}})
|
||||
case "/loki/api/v1/labels":
|
||||
writeJSON(writer, 200, map[string]any{"status": "success", "data": []string{"server_id", "server_name", "unit", "priority"}})
|
||||
case "/loki/api/v1/label/server_id/values", "/loki/api/v1/label/server_name/values", "/loki/api/v1/label/unit/values", "/loki/api/v1/label/priority/values":
|
||||
label := strings.TrimSuffix(strings.TrimPrefix(path, "/loki/api/v1/label/"), "/values")
|
||||
values, err := app.store.LogLabelValues(request.Context(), label)
|
||||
if err != nil {
|
||||
writeError(writer, 500, "query failed", "internal_error")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, 200, map[string]any{"status": "success", "data": values})
|
||||
case "/loki/api/v1/series":
|
||||
series, err := app.store.LogSeries(request.Context())
|
||||
if err != nil {
|
||||
writeError(writer, 500, "query failed", "internal_error")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, 200, map[string]any{"status": "success", "data": series})
|
||||
default:
|
||||
writeError(writer, 404, "unsupported Loki API operation", "not_found")
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) lokiQueryRange(writer http.ResponseWriter, request *http.Request) {
|
||||
matchers, err := parseLogQuery(request.URL.Query().Get("query"))
|
||||
if err != nil {
|
||||
writeError(writer, 400, err.Error(), "invalid_query")
|
||||
return
|
||||
}
|
||||
end, err := parseLokiTimestamp(request.URL.Query().Get("end"), time.Now())
|
||||
if err != nil {
|
||||
writeError(writer, 400, "invalid end", "invalid_query")
|
||||
return
|
||||
}
|
||||
start, err := parseLokiTimestamp(request.URL.Query().Get("start"), end.Add(-time.Hour))
|
||||
if err != nil || start.After(end) || end.Sub(start) > 31*24*time.Hour {
|
||||
writeError(writer, 400, "range must be positive and at most 31 days", "invalid_query")
|
||||
return
|
||||
}
|
||||
limit := parseInt(request.URL.Query().Get("limit"), 1000)
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
if limit > 5000 {
|
||||
limit = 5000
|
||||
}
|
||||
direction := request.URL.Query().Get("direction")
|
||||
if direction == "" {
|
||||
direction = "backward"
|
||||
}
|
||||
if direction != "backward" && direction != "forward" {
|
||||
writeError(writer, 400, "invalid direction", "invalid_query")
|
||||
return
|
||||
}
|
||||
entries, queryErr := app.store.IntegrationLogs(request.Context(), start, end, 20_000, direction)
|
||||
if queryErr != nil {
|
||||
writeError(writer, 500, "query failed", "internal_error")
|
||||
return
|
||||
}
|
||||
streams := buildLokiStreams(entries, matchers, limit, direction)
|
||||
writeJSON(writer, 200, map[string]any{"status": "success", "data": map[string]any{
|
||||
"resultType": "streams", "result": streams, "stats": map[string]any{},
|
||||
}})
|
||||
}
|
||||
|
||||
func parseMetricQuery(raw string) (metricQuery, error) {
|
||||
if len(raw) > 512 {
|
||||
return metricQuery{}, errors.New("query is too long")
|
||||
}
|
||||
match := metricSelector.FindStringSubmatch(strings.TrimSpace(raw))
|
||||
if match == nil {
|
||||
return metricQuery{}, errors.New("only direct metric selectors are supported")
|
||||
}
|
||||
if _, found := metricColumns[match[1]]; !found {
|
||||
return metricQuery{}, errors.New("metric is not supported")
|
||||
}
|
||||
result := metricQuery{Name: match[1]}
|
||||
if match[2] == "" {
|
||||
return result, nil
|
||||
}
|
||||
for _, selector := range strings.Split(match[2], ",") {
|
||||
parts := strings.SplitN(strings.TrimSpace(selector), "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return metricQuery{}, errors.New("invalid label selector")
|
||||
}
|
||||
value, err := strconv.Unquote(strings.TrimSpace(parts[1]))
|
||||
if err != nil || len(value) > 128 {
|
||||
return metricQuery{}, errors.New("invalid label value")
|
||||
}
|
||||
switch strings.TrimSpace(parts[0]) {
|
||||
case "server_id":
|
||||
result.ServerID = value
|
||||
case "server_name":
|
||||
result.ServerName = value
|
||||
default:
|
||||
return metricQuery{}, errors.New("unsupported label")
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseLogQuery(raw string) ([]logMatcher, error) {
|
||||
if len(raw) < 2 || len(raw) > 512 || raw[0] != '{' || raw[len(raw)-1] != '}' {
|
||||
return nil, errors.New("a bounded stream selector is required")
|
||||
}
|
||||
content := strings.TrimSpace(raw[1 : len(raw)-1])
|
||||
if content == "" {
|
||||
return nil, errors.New("at least one stream matcher is required")
|
||||
}
|
||||
matchers := []logMatcher{}
|
||||
for _, part := range strings.Split(content, ",") {
|
||||
match := logSelector.FindStringSubmatch(strings.TrimSpace(part))
|
||||
if match == nil || !allowedLogLabel(match[1]) || len(match[3]) > 128 {
|
||||
return nil, errors.New("unsupported stream matcher")
|
||||
}
|
||||
item := logMatcher{Label: match[1]}
|
||||
if match[2] == "=" {
|
||||
item.Exact = match[3]
|
||||
} else {
|
||||
compiled, err := regexp.Compile("^(?:" + match[3] + ")$")
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid matcher regex")
|
||||
}
|
||||
item.Regex = compiled
|
||||
}
|
||||
matchers = append(matchers, item)
|
||||
}
|
||||
return matchers, nil
|
||||
}
|
||||
|
||||
func allowedLogLabel(label string) bool {
|
||||
return label == "server_id" || label == "server_name" || label == "unit" || label == "priority"
|
||||
}
|
||||
|
||||
func parseTimestamp(raw string, fallback time.Time) (time.Time, error) {
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
value, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return time.Time{}, errors.New("invalid timestamp")
|
||||
}
|
||||
seconds, fraction := math.Modf(value)
|
||||
parsed := time.Unix(int64(seconds), int64(fraction*1e9))
|
||||
if !validIntegrationTime(parsed) {
|
||||
return time.Time{}, errors.New("timestamp out of range")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseLokiTimestamp(raw string, fallback time.Time) (time.Time, error) {
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
var parsed time.Time
|
||||
if value > 1_000_000_000_000 {
|
||||
parsed = time.Unix(0, value)
|
||||
} else {
|
||||
parsed = time.Unix(value, 0)
|
||||
}
|
||||
if !validIntegrationTime(parsed) {
|
||||
return time.Time{}, errors.New("timestamp out of range")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, raw)
|
||||
if err != nil || !validIntegrationTime(parsed) {
|
||||
return time.Time{}, errors.New("invalid timestamp")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func validIntegrationTime(value time.Time) bool {
|
||||
return !value.Before(time.Unix(0, 0)) && value.Before(time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
}
|
||||
|
||||
func parseStep(raw string) (int, error) {
|
||||
if raw == "" {
|
||||
return 60, nil
|
||||
}
|
||||
if duration, err := time.ParseDuration(raw); err == nil {
|
||||
seconds := int(duration.Seconds())
|
||||
if seconds >= 5 && seconds <= 86400 {
|
||||
return seconds, nil
|
||||
}
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 5 || value > 86400 {
|
||||
return 0, errors.New("step out of range")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func writePrometheusError(writer http.ResponseWriter, kind, message string, status int) {
|
||||
writeJSON(writer, status, map[string]any{"status": "error", "errorType": kind, "error": message})
|
||||
}
|
||||
|
||||
func prometheusVector(name string, points []MetricPoint) map[string]any {
|
||||
result := make([]map[string]any, 0, len(points))
|
||||
for _, point := range points {
|
||||
result = append(result, map[string]any{
|
||||
"metric": metricLabels(name, point),
|
||||
"value": []any{float64(point.Timestamp.UnixNano()) / 1e9, strconv.FormatFloat(point.Value, 'f', -1, 64)},
|
||||
})
|
||||
}
|
||||
return map[string]any{"status": "success", "data": map[string]any{"resultType": "vector", "result": result}}
|
||||
}
|
||||
|
||||
func prometheusMatrix(name string, points []MetricPoint) map[string]any {
|
||||
type series struct {
|
||||
Metric map[string]string `json:"metric"`
|
||||
Values [][]any `json:"values"`
|
||||
}
|
||||
byServer := map[string]*series{}
|
||||
order := []string{}
|
||||
for _, point := range points {
|
||||
item := byServer[point.ServerID]
|
||||
if item == nil {
|
||||
item = &series{Metric: metricLabels(name, point)}
|
||||
byServer[point.ServerID] = item
|
||||
order = append(order, point.ServerID)
|
||||
}
|
||||
item.Values = append(item.Values, []any{
|
||||
float64(point.Timestamp.UnixNano()) / 1e9,
|
||||
strconv.FormatFloat(point.Value, 'f', -1, 64),
|
||||
})
|
||||
}
|
||||
result := make([]*series, 0, len(order))
|
||||
for _, id := range order {
|
||||
result = append(result, byServer[id])
|
||||
}
|
||||
return map[string]any{"status": "success", "data": map[string]any{"resultType": "matrix", "result": result}}
|
||||
}
|
||||
|
||||
func metricLabels(name string, point MetricPoint) map[string]string {
|
||||
return map[string]string{"__name__": name, "server_id": point.ServerID, "server_name": point.ServerName}
|
||||
}
|
||||
|
||||
type lokiStream struct {
|
||||
Stream map[string]string `json:"stream"`
|
||||
Values [][2]string `json:"values"`
|
||||
}
|
||||
|
||||
func buildLokiStreams(entries []IntegrationLog, matchers []logMatcher, limit int, direction string) []lokiStream {
|
||||
groups := map[string]*lokiStream{}
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
labels := map[string]string{
|
||||
"server_id": entry.ServerID, "server_name": entry.ServerName,
|
||||
"unit": entry.Unit, "priority": strconv.Itoa(entry.Priority),
|
||||
}
|
||||
if !logMatches(labels, matchers) {
|
||||
continue
|
||||
}
|
||||
key := entry.ServerID + "\x00" + entry.Unit + "\x00" + strconv.Itoa(entry.Priority)
|
||||
stream := groups[key]
|
||||
if stream == nil {
|
||||
stream = &lokiStream{Stream: labels}
|
||||
groups[key] = stream
|
||||
}
|
||||
stream.Values = append(stream.Values, [2]string{strconv.FormatInt(entry.Timestamp.UnixNano(), 10), entry.Message})
|
||||
count++
|
||||
if count >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
result := make([]lokiStream, 0, len(groups))
|
||||
for _, stream := range groups {
|
||||
if direction != "backward" {
|
||||
sort.Slice(stream.Values, func(i, j int) bool { return stream.Values[i][0] < stream.Values[j][0] })
|
||||
}
|
||||
result = append(result, *stream)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Stream["server_id"]+result[i].Stream["unit"] < result[j].Stream["server_id"]+result[j].Stream["unit"]
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func logMatches(labels map[string]string, matchers []logMatcher) bool {
|
||||
for _, matcher := range matchers {
|
||||
value := labels[matcher.Label]
|
||||
if matcher.Regex != nil {
|
||||
if !matcher.Regex.MatchString(value) {
|
||||
return false
|
||||
}
|
||||
} else if value != matcher.Exact {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
101
control-plane/backend/observability_test.go
Normal file
101
control-plane/backend/observability_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseMetricQuery(t *testing.T) {
|
||||
query, err := parseMetricQuery(`vps_control_cpu_usage_percent{server_id="srv-1",server_name="edge"}`)
|
||||
if err != nil || query.Name != "vps_control_cpu_usage_percent" || query.ServerID != "srv-1" || query.ServerName != "edge" {
|
||||
t.Fatalf("unexpected metric query: %#v, %v", query, err)
|
||||
}
|
||||
for _, invalid := range []string{
|
||||
`process_cpu_seconds_total`,
|
||||
`vps_control_cpu_usage_percent{job=~".*"}`,
|
||||
`vps_control_cpu_usage_percent or vector(1)`,
|
||||
} {
|
||||
if _, err = parseMetricQuery(invalid); err == nil {
|
||||
t.Fatalf("accepted unsupported PromQL %q", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogQueryIsAllowListedAndAnchored(t *testing.T) {
|
||||
matchers, err := parseLogQuery(`{server_name="edge",unit=~"(nginx|ssh)\.service"}`)
|
||||
if err != nil || len(matchers) != 2 {
|
||||
t.Fatalf("unexpected log selector: %#v, %v", matchers, err)
|
||||
}
|
||||
if logMatches(map[string]string{"server_name": "edge", "unit": "prefix-nginx.service"}, matchers) {
|
||||
t.Fatal("regex matcher was not anchored")
|
||||
}
|
||||
if !logMatches(map[string]string{"server_name": "edge", "unit": "nginx.service"}, matchers) {
|
||||
t.Fatal("valid labels did not match")
|
||||
}
|
||||
for _, invalid := range []string{`{}`, `{message="secret"}`, `{unit!="ssh.service"}`, `{unit="x"} |= "error"`} {
|
||||
if _, err = parseLogQuery(invalid); err == nil {
|
||||
t.Fatalf("accepted unsupported LogQL %q", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLokiStreamsIsBounded(t *testing.T) {
|
||||
now := time.Now()
|
||||
entries := []IntegrationLog{
|
||||
{ServerID: "1", ServerName: "edge", Unit: "nginx.service", Priority: 6, Timestamp: now, Message: "new"},
|
||||
{ServerID: "1", ServerName: "edge", Unit: "nginx.service", Priority: 6, Timestamp: now.Add(-time.Second), Message: "old"},
|
||||
}
|
||||
streams := buildLokiStreams(entries, []logMatcher{{Label: "server_id", Exact: "1"}}, 1, "backward")
|
||||
if len(streams) != 1 || len(streams[0].Values) != 1 || streams[0].Values[0][1] != "new" {
|
||||
t.Fatalf("unexpected bounded streams: %#v", streams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadIntegrationTokenPermissionsAndLength(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "token")
|
||||
if err := os.WriteFile(path, []byte("0123456789abcdef0123456789abcdef\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readIntegrationToken(path); err != nil {
|
||||
t.Fatalf("valid token rejected: %v", err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o622); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readIntegrationToken(path); err == nil {
|
||||
t.Fatal("group-writable token accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestampBounds(t *testing.T) {
|
||||
if _, err := parseTimestamp("NaN", time.Now()); err == nil {
|
||||
t.Fatal("NaN timestamp accepted")
|
||||
}
|
||||
if _, err := parseLokiTimestamp("4102444800000000000", time.Now()); err == nil {
|
||||
t.Fatal("2100 timestamp accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrafanaDatasourceHealthExpressions(t *testing.T) {
|
||||
app := &App{}
|
||||
prometheusRequest := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("query=1%2B1&time=4"))
|
||||
prometheusRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
prometheusResponse := httptest.NewRecorder()
|
||||
app.prometheusQuery(prometheusResponse, prometheusRequest, false)
|
||||
if prometheusResponse.Code != http.StatusOK || !strings.Contains(prometheusResponse.Body.String(), `"resultType":"scalar"`) {
|
||||
t.Fatalf("unexpected Prometheus health response: %d %s", prometheusResponse.Code, prometheusResponse.Body.String())
|
||||
}
|
||||
|
||||
lokiRequest := httptest.NewRequest(http.MethodGet,
|
||||
"/integrations/loki/loki/api/v1/query?query=vector%281%29%2Bvector%281%29&time=4000000000", nil)
|
||||
lokiResponse := httptest.NewRecorder()
|
||||
app.lokiAPI(lokiResponse, lokiRequest)
|
||||
if lokiResponse.Code != http.StatusOK || !strings.Contains(lokiResponse.Body.String(), `"resultType":"vector"`) {
|
||||
t.Fatalf("unexpected Loki health response: %d %s", lokiResponse.Code, lokiResponse.Body.String())
|
||||
}
|
||||
}
|
||||
254
control-plane/backend/poller.go
Normal file
254
control-plane/backend/poller.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var bearerSecret = regexp.MustCompile(`(?i)(authorization\s*[:=]\s*bearer\s+)[^\s,;&]+`)
|
||||
var namedSecret = regexp.MustCompile(`(?i)((?:password|passwd|token|secret|api[_-]?key)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;&]+)`)
|
||||
|
||||
type Poller struct {
|
||||
store *Store
|
||||
agent *AgentClient
|
||||
metricRetention time.Duration
|
||||
logRetention time.Duration
|
||||
}
|
||||
|
||||
func (poller *Poller) Run(ctx context.Context) {
|
||||
pollTicker := time.NewTicker(time.Minute)
|
||||
cleanupTicker := time.NewTicker(time.Hour)
|
||||
defer pollTicker.Stop()
|
||||
defer cleanupTicker.Stop()
|
||||
poller.poll(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-pollTicker.C:
|
||||
poller.poll(ctx)
|
||||
case <-cleanupTicker.C:
|
||||
if _, err := poller.store.pool.Exec(ctx, "DELETE FROM metric_samples WHERE sampled_at < $1", time.Now().Add(-poller.metricRetention)); err != nil {
|
||||
log.Printf("metric retention cleanup: %v", err)
|
||||
}
|
||||
if _, err := poller.store.pool.Exec(ctx, "DELETE FROM agent_log_entries WHERE event_at < $1", time.Now().Add(-poller.logRetention)); err != nil {
|
||||
log.Printf("log retention cleanup: %v", err)
|
||||
}
|
||||
_, _ = poller.store.pool.Exec(ctx, "DELETE FROM sessions WHERE expires_at < now()")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (poller *Poller) poll(ctx context.Context) {
|
||||
servers, err := poller.store.Servers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("poll server list: %v", err)
|
||||
return
|
||||
}
|
||||
jobs := make(chan Server)
|
||||
var wait sync.WaitGroup
|
||||
workerCount := 4
|
||||
if len(servers) < workerCount {
|
||||
workerCount = len(servers)
|
||||
}
|
||||
for worker := 0; worker < workerCount; worker++ {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
for server := range jobs {
|
||||
poller.pollServer(ctx, server)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, server := range servers {
|
||||
select {
|
||||
case jobs <- server:
|
||||
case <-ctx.Done():
|
||||
close(jobs)
|
||||
wait.Wait()
|
||||
return
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wait.Wait()
|
||||
}
|
||||
|
||||
func (poller *Poller) pollServer(ctx context.Context, server Server) {
|
||||
statusCode, body, err := poller.agent.Do(ctx, server, http.MethodGet, "/v1/system/status", nil)
|
||||
if err != nil || statusCode != http.StatusOK {
|
||||
_, _ = poller.store.pool.Exec(ctx, "UPDATE servers SET status='offline',updated_at=now() WHERE id=$1", server.ID)
|
||||
return
|
||||
}
|
||||
var status struct {
|
||||
Hostname string `json:"hostname"`
|
||||
CPU float64 `json:"cpu_usage_percent"`
|
||||
MemoryTotal uint64 `json:"memory_total_bytes"`
|
||||
MemoryUsed uint64 `json:"memory_used_bytes"`
|
||||
Filesystems []struct {
|
||||
Total uint64 `json:"total_bytes"`
|
||||
Used uint64 `json:"used_bytes"`
|
||||
} `json:"filesystems"`
|
||||
}
|
||||
if json.Unmarshal(body, &status) != nil {
|
||||
return
|
||||
}
|
||||
memory := percent(status.MemoryUsed, status.MemoryTotal)
|
||||
var diskTotal, diskUsed uint64
|
||||
for _, filesystem := range status.Filesystems {
|
||||
diskTotal += filesystem.Total
|
||||
diskUsed += filesystem.Used
|
||||
}
|
||||
disk := percent(diskUsed, diskTotal)
|
||||
var rx, tx int64
|
||||
if code, payload, callErr := poller.agent.Do(ctx, server, http.MethodGet, "/v1/network/interfaces", nil); callErr == nil && code == 200 {
|
||||
var interfaces []struct {
|
||||
RX int64 `json:"rx_bytes"`
|
||||
TX int64 `json:"tx_bytes"`
|
||||
}
|
||||
if json.Unmarshal(payload, &interfaces) == nil {
|
||||
for _, item := range interfaces {
|
||||
rx += item.RX
|
||||
tx += item.TX
|
||||
}
|
||||
}
|
||||
}
|
||||
connections := 0
|
||||
if code, payload, callErr := poller.agent.Do(ctx, server, http.MethodGet, "/v1/network/connections", nil); callErr == nil && code == 200 {
|
||||
var items []json.RawMessage
|
||||
if json.Unmarshal(payload, &items) == nil {
|
||||
connections = len(items)
|
||||
}
|
||||
}
|
||||
txDB, err := poller.store.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer txDB.Rollback(ctx)
|
||||
_, err = txDB.Exec(ctx, "UPDATE servers SET status='online',hostname=$2,last_seen=now(),updated_at=now() WHERE id=$1", server.ID, status.Hostname)
|
||||
if err == nil {
|
||||
_, err = txDB.Exec(ctx, `INSERT INTO metric_samples(server_id,cpu,memory,disk,rx,tx,connections,bans)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,0)`, server.ID, status.CPU, memory, disk, rx, tx, connections)
|
||||
}
|
||||
if err == nil {
|
||||
err = txDB.Commit(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("store metric for %s: %v", server.ID, err)
|
||||
}
|
||||
poller.pollLogs(ctx, server)
|
||||
}
|
||||
|
||||
func (poller *Poller) pollLogs(ctx context.Context, server Server) {
|
||||
var cursor string
|
||||
var since time.Time
|
||||
err := poller.store.pool.QueryRow(ctx, `SELECT
|
||||
COALESCE((SELECT journal_cursor FROM agent_log_entries WHERE server_id=$1 ORDER BY event_at DESC,ingested_at DESC LIMIT 1),''),
|
||||
COALESCE((SELECT max(event_at) FROM agent_log_entries WHERE server_id=$1),now() - interval '2 minutes')`, server.ID).
|
||||
Scan(&cursor, &since)
|
||||
if err != nil {
|
||||
log.Printf("log cursor for %s: %v", server.ID, err)
|
||||
return
|
||||
}
|
||||
fallbackUsed := false
|
||||
for page := 0; page < 5; page++ {
|
||||
path := fmt.Sprintf("/v1/logs?since=%d&limit=200", since.Add(-time.Second).Unix())
|
||||
if cursor != "" {
|
||||
path = "/v1/logs?cursor=" + url.QueryEscape(cursor) + "&limit=200"
|
||||
}
|
||||
status, payload, callErr := poller.agent.Do(ctx, server, http.MethodGet, path, nil)
|
||||
if callErr != nil || status == http.StatusForbidden || status == http.StatusNotFound {
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
if cursor != "" && !fallbackUsed {
|
||||
cursor = ""
|
||||
fallbackUsed = true
|
||||
page--
|
||||
continue
|
||||
}
|
||||
log.Printf("log poll for %s returned %d", server.ID, status)
|
||||
return
|
||||
}
|
||||
var response struct {
|
||||
Entries []struct {
|
||||
Cursor string `json:"cursor"`
|
||||
Timestamp uint64 `json:"timestamp_unix_micros"`
|
||||
Unit string `json:"unit"`
|
||||
Priority int `json:"priority"`
|
||||
Message string `json:"message"`
|
||||
BootID *string `json:"boot_id"`
|
||||
PID *int `json:"pid"`
|
||||
} `json:"entries"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
if err = json.Unmarshal(payload, &response); err != nil {
|
||||
log.Printf("decode logs for %s: %v", server.ID, err)
|
||||
return
|
||||
}
|
||||
if len(response.Entries) > 200 {
|
||||
log.Printf("agent %s exceeded log entry bound", server.ID)
|
||||
return
|
||||
}
|
||||
tx, beginErr := poller.store.pool.Begin(ctx)
|
||||
if beginErr != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for _, entry := range response.Entries {
|
||||
if len(entry.Cursor) == 0 || len(entry.Cursor) > 1024 || len(entry.Unit) > 128 ||
|
||||
len(entry.Message) > 65_536 || entry.Priority < 0 || entry.Priority > 7 {
|
||||
continue
|
||||
}
|
||||
eventAt := time.UnixMicro(int64(entry.Timestamp))
|
||||
if eventAt.After(now.Add(5*time.Minute)) || eventAt.Before(now.Add(-31*24*time.Hour)) {
|
||||
continue
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO agent_log_entries
|
||||
(server_id,journal_cursor,event_at,unit,priority,message,boot_id,pid)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT DO NOTHING`, server.ID, entry.Cursor,
|
||||
eventAt, entry.Unit, entry.Priority, truncateUTF8(redactLogMessage(entry.Message), 65_536), entry.BootID, entry.PID)
|
||||
if err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
log.Printf("store logs for %s: %v", server.ID, err)
|
||||
return
|
||||
}
|
||||
if len(response.Entries) == 0 || !response.Truncated {
|
||||
return
|
||||
}
|
||||
cursor = response.Entries[len(response.Entries)-1].Cursor
|
||||
}
|
||||
log.Printf("agent log backlog for %s exceeded 1000 entries in one poll", server.ID)
|
||||
}
|
||||
|
||||
func redactLogMessage(message string) string {
|
||||
message = bearerSecret.ReplaceAllString(message, "${1}[REDACTED]")
|
||||
return namedSecret.ReplaceAllString(message, "${1}[REDACTED]")
|
||||
}
|
||||
|
||||
func truncateUTF8(value string, maximum int) string {
|
||||
if len(value) <= maximum {
|
||||
return value
|
||||
}
|
||||
for maximum > 0 && value[maximum]&0xc0 == 0x80 {
|
||||
maximum--
|
||||
}
|
||||
return value[:maximum]
|
||||
}
|
||||
|
||||
func percent(used, total uint64) float64 {
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(used) * 100 / float64(total)
|
||||
}
|
||||
161
control-plane/docker-compose.yml
Normal file
161
control-plane/docker-compose.yml
Normal file
@@ -0,0 +1,161 @@
|
||||
name: vps-control
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-vps_control}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-vps_control}
|
||||
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
|
||||
secrets:
|
||||
- db_password
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
networks: [internal]
|
||||
mem_limit: 512m
|
||||
cpus: 1.0
|
||||
pids_limit: 200
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LISTEN_ADDRESS: :8080
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: ${POSTGRES_DB:-vps_control}
|
||||
DB_USER: ${POSTGRES_USER:-vps_control}
|
||||
DB_PASSWORD_FILE: /run/secrets/db_password
|
||||
DB_SSLMODE: disable
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-true}
|
||||
TRUST_PROXY: "true"
|
||||
SESSION_TTL: ${SESSION_TTL:-12h}
|
||||
METRICS_RETENTION: ${METRICS_RETENTION:-720h}
|
||||
LOGS_RETENTION: ${LOGS_RETENTION:-168h}
|
||||
AGENT_CREDENTIALS_DIR: /run/secrets
|
||||
INTEGRATION_TOKEN_FILE: /run/secrets/integration_token
|
||||
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
|
||||
INITIAL_ADMIN_PASSWORD_FILE: /run/secrets/admin_password
|
||||
BOOTSTRAP_SERVER_NAME: ${BOOTSTRAP_SERVER_NAME:-}
|
||||
BOOTSTRAP_SERVER_ENDPOINT: ${BOOTSTRAP_SERVER_ENDPOINT:-}
|
||||
BOOTSTRAP_AGENT_CREDENTIAL_REF: ${BOOTSTRAP_AGENT_CREDENTIAL_REF:-agent_token}
|
||||
secrets:
|
||||
- db_password
|
||||
- admin_password
|
||||
- agent_token
|
||||
- integration_token
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/healthz"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
networks: [internal, agent]
|
||||
read_only: true
|
||||
tmpfs: [/tmp]
|
||||
mem_limit: 128m
|
||||
cpus: 0.5
|
||||
pids_limit: 100
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HTTP_BIND_ADDRESS:-127.0.0.1}:${HTTP_PORT:-8080}:8080"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks: [edge, internal]
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
- /var/cache/nginx
|
||||
- /var/run
|
||||
mem_limit: 64m
|
||||
cpus: 0.25
|
||||
pids_limit: 50
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
|
||||
grafana:
|
||||
profiles: ["observability"]
|
||||
build:
|
||||
context: ./grafana
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${GRAFANA_BIND_ADDRESS:-127.0.0.1}:${GRAFANA_PORT:-3000}:3000"
|
||||
environment:
|
||||
GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-http://127.0.0.1:3000}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
GF_AUTH_ANONYMOUS_ENABLED: "false"
|
||||
GF_SECURITY_COOKIE_SECURE: ${GRAFANA_COOKIE_SECURE:-false}
|
||||
GF_ANALYTICS_REPORTING_ENABLED: "false"
|
||||
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
|
||||
GF_LOG_LEVEL: warn
|
||||
secrets:
|
||||
- integration_token
|
||||
- grafana_admin_password
|
||||
- grafana_secret_key
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3000/api/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
networks: [edge, internal]
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
- /var/log/grafana
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
- grafana-plugins:/usr/share/grafana/data/plugins-bundled
|
||||
mem_limit: 384m
|
||||
cpus: 0.75
|
||||
pids_limit: 150
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
grafana-data:
|
||||
grafana-plugins:
|
||||
|
||||
networks:
|
||||
edge:
|
||||
internal:
|
||||
internal: true
|
||||
agent:
|
||||
|
||||
secrets:
|
||||
db_password:
|
||||
file: ${DB_PASSWORD_SECRET_FILE:-./secrets/db_password}
|
||||
admin_password:
|
||||
file: ${ADMIN_PASSWORD_SECRET_FILE:-./secrets/admin_password}
|
||||
agent_token:
|
||||
file: ${AGENT_TOKEN_SECRET_FILE:-./secrets/agent_token}
|
||||
integration_token:
|
||||
file: ${INTEGRATION_TOKEN_SECRET_FILE:-./secrets/integration_token}
|
||||
grafana_admin_password:
|
||||
file: ${GRAFANA_ADMIN_PASSWORD_SECRET_FILE:-./secrets/grafana_admin_password}
|
||||
grafana_secret_key:
|
||||
file: ${GRAFANA_SECRET_KEY_SECRET_FILE:-./secrets/grafana_secret_key}
|
||||
9
control-plane/frontend/.dockerignore
Normal file
9
control-plane/frontend/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
*.tsbuildinfo
|
||||
vite.config.js
|
||||
vite.config.d.ts
|
||||
vitest.config.js
|
||||
vitest.config.d.ts
|
||||
.git
|
||||
|
||||
13
control-plane/frontend/Dockerfile
Normal file
13
control-plane/frontend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM node:22.14-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build --chown=101:101 /app/dist /usr/share/nginx/html
|
||||
EXPOSE 8080
|
||||
|
||||
21
control-plane/frontend/eslint.config.js
Normal file
21
control-plane/frontend/eslint.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: { ecmaVersion: 2022, globals: globals.browser },
|
||||
plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh },
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
'@typescript-eslint/no-explicit-any': 'off'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
15
control-plane/frontend/index.html
Normal file
15
control-plane/frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="theme-color" content="#0b0e14" />
|
||||
<title>VPS Control</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
55
control-plane/frontend/nginx.conf
Normal file
55
control-plane/frontend/nginx.conf
Normal file
@@ -0,0 +1,55 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
server_tokens off;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 3s;
|
||||
proxy_read_timeout 15s;
|
||||
proxy_send_timeout 15s;
|
||||
client_max_body_size 64k;
|
||||
}
|
||||
|
||||
location /integrations/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 3s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 15s;
|
||||
client_max_body_size 64k;
|
||||
}
|
||||
|
||||
location = /healthz {
|
||||
proxy_pass http://backend:8080/healthz;
|
||||
proxy_set_header Host $host;
|
||||
proxy_connect_timeout 2s;
|
||||
proxy_read_timeout 3s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(?:js|css|svg|png|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
4755
control-plane/frontend/package-lock.json
generated
Normal file
4755
control-plane/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
control-plane/frontend/package.json
Normal file
36
control-plane/frontend/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "vps-control-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
29
control-plane/frontend/src/App.test.tsx
Normal file
29
control-plane/frontend/src/App.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { App } from './App'
|
||||
|
||||
const renderApp = () => render(<QueryClientProvider client={new QueryClient({defaultOptions:{queries:{retry:false}}})}><App/></QueryClientProvider>)
|
||||
|
||||
describe('main screens', () => {
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks() })
|
||||
|
||||
it('shows login when there is no session', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({error:'authentication required'}), {status:401,headers:{'Content-Type':'application/json'}})))
|
||||
renderApp()
|
||||
expect(await screen.findByRole('heading',{name:'Infrastructure, in focus.'})).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Username')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the empty fleet state for an administrator', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn((input:RequestInfo|URL) => {
|
||||
const url=String(input)
|
||||
const value=url.endsWith('/auth/me')?{id:'1',username:'admin',role:'administrator'}:[]
|
||||
return Promise.resolve(new Response(JSON.stringify(value),{status:200,headers:{'Content-Type':'application/json'}}))
|
||||
}))
|
||||
renderApp()
|
||||
await waitFor(()=>expect(screen.getByText('No servers enrolled')).toBeInTheDocument())
|
||||
expect(screen.getByText('admin')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
248
control-plane/frontend/src/App.tsx
Normal file
248
control-plane/frontend/src/App.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { FormEvent, ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Activity, Ban, Boxes, Cable, ChevronDown, CircleGauge, FileClock, Shield, Gauge,
|
||||
KeyRound, ListRestart, LogOut, Menu, Moon, Network, Plus, RadioTower, Server as ServerIcon,
|
||||
Settings, ShieldAlert, Sun, Unplug, X,
|
||||
} from 'lucide-react'
|
||||
import { api, APIError, formatBytes, formatDuration } from './api'
|
||||
import type {
|
||||
AgentLogEntry, AuditEvent, FirewallStatus, Interface, JailStatus, MetricSample, Role, Server, Service,
|
||||
SocketInfo, SystemStatus, User, WireGuardInterface,
|
||||
} from './types'
|
||||
|
||||
type Page = 'Overview' | 'Servers' | 'Network' | 'Firewall' | 'Connections' | 'Security' |
|
||||
'Fail2Ban' | 'WireGuard' | 'Services' | 'Logs' | 'Rate Limits' | 'Audit' | 'Settings'
|
||||
|
||||
const navigation: Array<{name: Page; icon: typeof Activity}> = [
|
||||
{ name: 'Overview', icon: CircleGauge }, { name: 'Servers', icon: ServerIcon },
|
||||
{ name: 'Network', icon: Network }, { name: 'Firewall', icon: Shield },
|
||||
{ name: 'Connections', icon: Cable }, { name: 'Security', icon: ShieldAlert },
|
||||
{ name: 'Fail2Ban', icon: Ban }, { name: 'WireGuard', icon: RadioTower },
|
||||
{ name: 'Services', icon: Boxes }, { name: 'Logs', icon: FileClock },
|
||||
{ name: 'Rate Limits', icon: Gauge }, { name: 'Audit', icon: FileClock },
|
||||
{ name: 'Settings', icon: Settings },
|
||||
]
|
||||
|
||||
export function App() {
|
||||
const queryClient = useQueryClient()
|
||||
const me = useQuery<User>({ queryKey: ['me'], queryFn: () => api('/auth/me'), retry: false })
|
||||
const servers = useQuery<Server[]>({ queryKey: ['servers'], queryFn: () => api('/servers'), enabled: me.isSuccess })
|
||||
const [page, setPage] = useState<Page>('Overview')
|
||||
const [selectedID, setSelectedID] = useState('')
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [dark, setDark] = useState(() => localStorage.getItem('theme') !== 'light')
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||
localStorage.setItem('theme', dark ? 'dark' : 'light')
|
||||
}, [dark])
|
||||
useEffect(() => {
|
||||
if (!selectedID && servers.data?.[0]) setSelectedID(servers.data[0].id)
|
||||
}, [selectedID, servers.data])
|
||||
|
||||
if (me.isPending) return <LoadingScreen />
|
||||
if (me.isError) return <Login onSuccess={() => void queryClient.invalidateQueries({ queryKey: ['me'] })} />
|
||||
|
||||
const selected = servers.data?.find((server) => server.id === selectedID)
|
||||
const choosePage = (next: Page) => { setPage(next); setMobileOpen(false) }
|
||||
return <div className="shell">
|
||||
<aside className={`sidebar ${mobileOpen ? 'open' : ''}`}>
|
||||
<div className="brand"><div className="brand-mark"><Activity size={20}/></div><span>VPS Control</span><button className="mobile-close" onClick={() => setMobileOpen(false)}><X/></button></div>
|
||||
<div className="server-select-wrap">
|
||||
<label>ACTIVE SERVER</label>
|
||||
<div className="select-shell"><StatusDot status={selected?.status ?? 'unknown'}/><select aria-label="Active server" value={selectedID} onChange={(event) => setSelectedID(event.target.value)}>
|
||||
{!servers.data?.length && <option value="">No servers</option>}
|
||||
{servers.data?.map((server) => <option value={server.id} key={server.id}>{server.name}</option>)}
|
||||
</select><ChevronDown size={14}/></div>
|
||||
</div>
|
||||
<nav>{navigation.map(({name, icon: Icon}) => <button key={name} className={page === name ? 'active' : ''} onClick={() => choosePage(name)}><Icon size={17}/><span>{name}</span></button>)}</nav>
|
||||
<div className="sidebar-foot"><span className="eyebrow">SIGNED IN AS</span><strong>{me.data.username}</strong><span>{me.data.role}</span></div>
|
||||
</aside>
|
||||
{mobileOpen && <button className="scrim" onClick={() => setMobileOpen(false)} aria-label="Close navigation"/>}
|
||||
<main>
|
||||
<header className="topbar">
|
||||
<button className="menu-button" onClick={() => setMobileOpen(true)}><Menu/></button>
|
||||
<div><span className="breadcrumb">VPS CONTROL / </span><strong>{page.toUpperCase()}</strong></div>
|
||||
<div className="top-actions"><div className={`connection ${selected?.status ?? 'unknown'}`}><StatusDot status={selected?.status ?? 'unknown'}/>{selected?.status ?? 'No target'}</div>
|
||||
<button className="icon-button" onClick={() => setDark(!dark)} aria-label="Toggle color theme">{dark ? <Sun/> : <Moon/>}</button>
|
||||
<button className="icon-button" aria-label="Sign out" onClick={() => void api('/auth/logout', {method:'POST'}).finally(() => location.reload())}><LogOut/></button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="content">
|
||||
<PageHeader title={page} server={selected}/>
|
||||
<PageContent page={page} server={selected} servers={servers.data ?? []} user={me.data}/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
|
||||
function Login({onSuccess}:{onSuccess:()=>void}) {
|
||||
const [error, setError] = useState('')
|
||||
const mutation = useMutation({mutationFn: ({username,password}:{username:string;password:string}) => api<User>('/auth/login', {method:'POST',body:JSON.stringify({username,password})}), onSuccess, onError:(reason) => setError(reason instanceof Error ? reason.message : 'Sign in failed')})
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault(); const values = new FormData(event.currentTarget)
|
||||
mutation.mutate({username:String(values.get('username')),password:String(values.get('password'))})
|
||||
}
|
||||
return <div className="login-page"><div className="login-glow"/><form className="login-card" onSubmit={submit}>
|
||||
<div className="brand login-brand"><div className="brand-mark"><Activity/></div><span>VPS Control</span></div>
|
||||
<p className="eyebrow">CONTROL PLANE ACCESS</p><h1>Infrastructure, in focus.</h1><p>Authenticate to inspect your private server fleet.</p>
|
||||
<label>Username<input name="username" autoComplete="username" required autoFocus/></label>
|
||||
<label>Password<input name="password" type="password" autoComplete="current-password" required/></label>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
<button className="primary" disabled={mutation.isPending}>{mutation.isPending ? 'Authenticating…' : 'Sign in securely'}</button>
|
||||
<span className="login-note"><KeyRound size={14}/> Session protected by HttpOnly cookie + CSRF</span>
|
||||
</form></div>
|
||||
}
|
||||
|
||||
function PageHeader({title,server}:{title:string;server?:Server}) {
|
||||
return <div className="page-header"><div><div className="eyebrow">{server ? `${server.name} · ${server.hostname || 'hostname pending'}` : 'FLEET CONTROL'}</div><h1>{title}</h1></div>
|
||||
{server?.last_seen && <span className="last-seen">Last contact {new Date(server.last_seen).toLocaleString()}</span>}</div>
|
||||
}
|
||||
|
||||
function PageContent({page,server,servers,user}:{page:Page;server?:Server;servers:Server[];user:User}) {
|
||||
if (page === 'Servers') return <ServersPage servers={servers} user={user}/>
|
||||
if (page === 'Audit') return <AuditPage/>
|
||||
if (page === 'Settings') return <SettingsPage user={user} server={server}/>
|
||||
if (!server) return <Empty title="No servers enrolled" detail="Add the first private agent endpoint from Servers."/>
|
||||
switch (page) {
|
||||
case 'Overview': return <Overview server={server}/>
|
||||
case 'Network': return <NetworkPage server={server}/>
|
||||
case 'Firewall': return <FirewallPage server={server}/>
|
||||
case 'Connections': return <ConnectionsPage server={server}/>
|
||||
case 'Security': return <SecurityPage server={server}/>
|
||||
case 'Fail2Ban': return <Fail2BanPage server={server} role={user.role}/>
|
||||
case 'WireGuard': return <WireGuardPage server={server}/>
|
||||
case 'Services': return <ServicesPage server={server} role={user.role}/>
|
||||
case 'Logs': return <LogsPage server={server}/>
|
||||
case 'Rate Limits': return <RateLimitsPage/>
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function useServerData<T>(server:Server, route:string, interval?:number) {
|
||||
return useQuery<T>({queryKey:['server',server.id,route],queryFn:()=>api(`/servers/${server.id}/${route}`),refetchInterval:interval})
|
||||
}
|
||||
|
||||
function Overview({server}:{server:Server}) {
|
||||
const status = useServerData<SystemStatus>(server,'status',10_000)
|
||||
const interfaces = useServerData<Interface[]>(server,'interfaces',10_000)
|
||||
const listeners = useServerData<SocketInfo[]>(server,'listeners',15_000)
|
||||
const connections = useServerData<SocketInfo[]>(server,'connections',15_000)
|
||||
const services = useServerData<Service[]>(server,'services',15_000)
|
||||
const metrics = useServerData<MetricSample[]>(server,'metrics?limit=60',30_000)
|
||||
if (status.isPending) return <PanelLoading/>
|
||||
if (status.isError) return <QueryError error={status.error}/>
|
||||
const root = status.data.filesystems.find((item)=>item.mountpoint==='/') ?? status.data.filesystems[0]
|
||||
const memory = percent(status.data.memory_used_bytes,status.data.memory_total_bytes)
|
||||
const disk = root ? percent(root.used_bytes,root.total_bytes) : 0
|
||||
const totals = interfaces.data?.reduce((sum,item)=>({rx:sum.rx+item.rx_bytes,tx:sum.tx+item.tx_bytes}),{rx:0,tx:0})
|
||||
return <div className="stack">
|
||||
{status.data.reboot_required && <div className="warning-banner"><ShieldAlert/>Host reports that a reboot is required.</div>}
|
||||
<div className="metric-grid">
|
||||
<Metric label="CPU USAGE" value={`${status.data.cpu_usage_percent.toFixed(1)}%`} sub={`${status.data.cpu_core_count} cores · load ${status.data.load_average[0].toFixed(2)}`} level={status.data.cpu_usage_percent}/>
|
||||
<Metric label="MEMORY" value={`${memory.toFixed(1)}%`} sub={`${formatBytes(status.data.memory_used_bytes)} / ${formatBytes(status.data.memory_total_bytes)}`} level={memory}/>
|
||||
<Metric label="ROOT DISK" value={root ? `${disk.toFixed(1)}%` : '—'} sub={root ? `${formatBytes(root.used_bytes)} / ${formatBytes(root.total_bytes)}` : 'Unavailable'} level={disk}/>
|
||||
<Metric label="UPTIME" value={formatDuration(status.data.uptime_seconds)} sub={`${status.data.os} · ${status.data.kernel}`}/>
|
||||
</div>
|
||||
<div className="overview-grid"><Card title="CPU / memory history" badge="1 min samples"><MiniChart samples={metrics.data ?? []}/></Card>
|
||||
<Card title="Live surface" badge="Now"><div className="surface-stats"><Stat label="Connections" value={connections.data?.length ?? '—'}/><Stat label="Listeners" value={listeners.data?.length ?? '—'}/><Stat label="Network RX" value={formatBytes(totals?.rx)}/><Stat label="Network TX" value={formatBytes(totals?.tx)}/></div></Card></div>
|
||||
<Card title="Allow-listed services" badge={`${services.data?.filter((item)=>item.active_state==='active').length ?? 0}/${services.data?.length ?? 0} active`}>
|
||||
<div className="service-strip">{services.data?.map((service)=><div key={service.name}><StatusDot status={service.active_state==='active'?'online':'offline'}/><span>{service.name}</span><small>{service.sub_state}</small></div>)}{services.data?.length===0&&<EmptyLine text="No services are allow-listed on the agent."/>}</div>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
|
||||
function NetworkPage({server}:{server:Server}) {
|
||||
const interfaces = useServerData<Interface[]>(server,'interfaces',10_000)
|
||||
const routes = useServerData<unknown>(server,'routes',30_000)
|
||||
return <div className="stack"><DataPanel query={interfaces}><Card title="Interfaces" badge={`${interfaces.data?.length ?? 0} detected`}><Table headers={['State','Interface','Addresses','Received','Transmitted']} rows={(interfaces.data??[]).map((item)=>[<StatusDot status={item.state==='up'?'online':'offline'}/>,<code>{item.name}</code>,item.addresses.join(', ')||'—',formatBytes(item.rx_bytes),formatBytes(item.tx_bytes)])}/></Card></DataPanel>
|
||||
<DataPanel query={routes}><Card title="Kernel routes" badge="Read only"><pre className="code-block">{JSON.stringify(routes.data,null,2)}</pre></Card></DataPanel></div>
|
||||
}
|
||||
|
||||
function FirewallPage({server}:{server:Server}) {
|
||||
const firewall = useServerData<FirewallStatus>(server,'firewall',30_000)
|
||||
return <DataPanel query={firewall}><div className="stack"><div className="notice"><Shield/><div><strong>{firewall.data?.backend ?? 'Unknown'} backend</strong><span>Rules are read-only in v1. Unknown and external rules are never mutated.</span></div></div>
|
||||
<Card title="Managed by VPS Control" badge={firewall.data?.management_enabled?'Enabled':'Disabled'}><EmptyLine text={`Reserved table: ${firewall.data?.managed_table ?? 'inet vps_control'}. Crash-safe privileged helper is required before writes can be enabled.`}/></Card>
|
||||
<Card title="External / unmanaged rules" badge="Read only">{firewall.data?.warning&&<div className="inline-warning">{firewall.data.warning}</div>}<pre className="code-block tall">{JSON.stringify(firewall.data?.ruleset,null,2)}</pre></Card></div></DataPanel>
|
||||
}
|
||||
|
||||
function ConnectionsPage({server}:{server:Server}) {
|
||||
const connections = useServerData<SocketInfo[]>(server,'connections',10_000)
|
||||
const [filter,setFilter] = useState('')
|
||||
const rows=(connections.data??[]).filter((item)=>`${item.protocol} ${item.remote_address} ${item.local_port} ${item.state}`.toLowerCase().includes(filter.toLowerCase()))
|
||||
const states=useMemo(()=>Object.entries(rows.reduce<Record<string,number>>((all,item)=>({...all,[item.state]:(all[item.state]??0)+1}),{})).sort((a,b)=>b[1]-a[1]),[rows])
|
||||
return <DataPanel query={connections}><div className="stack"><div className="summary-row">{states.slice(0,4).map(([name,count])=><Stat key={name} label={name} value={count}/>)}</div><Card title="Current connections" badge={`${rows.length} sockets`} action={<input className="filter" placeholder="Filter protocol, IP, port…" value={filter} onChange={(e)=>setFilter(e.target.value)}/>}><Table headers={['Protocol','Local','Remote','State','Process']} rows={rows.map(item=>[item.protocol.toUpperCase(),<code>{item.local_address}:{item.local_port}</code>,<code>{item.remote_address}:{item.remote_port}</code>,<Pill text={item.state}/>,item.process?`${item.process} · ${item.pid}`:'—'])}/></Card></div></DataPanel>
|
||||
}
|
||||
|
||||
function SecurityPage({server}:{server:Server}) {
|
||||
const status=useServerData<SystemStatus>(server,'status')
|
||||
const listeners=useServerData<SocketInfo[]>(server,'listeners')
|
||||
const publicListeners=(listeners.data??[]).filter((item)=>['0.0.0.0','::'].includes(item.local_address))
|
||||
return <div className="stack"><div className="metric-grid compact"><Metric label="REBOOT REQUIRED" value={status.data?.reboot_required?'YES':'NO'} sub="Reported by the operating system"/><Metric label="PUBLIC BINDS" value={String(publicListeners.length)} sub="Firewall reachability requires review"/><Metric label="AGENT TRANSPORT" value={server.agent_endpoint.startsWith('https')?'TLS':'PRIVATE HTTP'} sub={server.agent_endpoint}/></div><Card title="Factual warnings" badge="No synthetic score"><div className="event-list">{status.data?.reboot_required&&<Event severity="warning" text="A reboot-required marker exists on the host."/>}{publicListeners.map((item)=><Event key={`${item.protocol}-${item.local_port}`} severity="warning" text={`${item.process??'Unknown process'} listens on every address at ${item.protocol.toUpperCase()}/${item.local_port}.`}/>)}{!status.data?.reboot_required&&!publicListeners.length&&<EmptyLine text="No warnings in the current snapshot."/>}</div></Card></div>
|
||||
}
|
||||
|
||||
function Fail2BanPage({server,role}:{server:Server;role:Role}) {
|
||||
const queryClient=useQueryClient(); const status=useServerData<JailStatus>(server,'fail2ban',15_000)
|
||||
const mutation=useMutation({mutationFn:(input:{action:'ban'|'unban';jail:string;ip:string})=>api(`/servers/${server.id}/fail2ban/${input.action}`,{method:'POST',body:JSON.stringify(input)}),onSuccess:()=>void queryClient.invalidateQueries({queryKey:['server',server.id,'fail2ban']})})
|
||||
const submit=(event:FormEvent<HTMLFormElement>)=>{event.preventDefault();const data=new FormData(event.currentTarget);mutation.mutate({action:data.get('action') as 'ban'|'unban',jail:String(data.get('jail')),ip:String(data.get('ip'))})}
|
||||
return <DataPanel query={status}><div className="split"><Card title="Daemon status" badge={status.data?.running?'Running':'Unavailable'}><div className="details"><Stat label="Installed" value={status.data?.installed?'Yes':'No'}/><Stat label="Version" value={status.data?.version??'—'}/><Stat label="Jails" value={status.data?.jails.length??0}/></div><div className="tag-list">{status.data?.jails.map(jail=><span key={jail}>{jail}</span>)}</div></Card><Card title="Manual action" badge="Audited"><form className="action-form" onSubmit={submit}><select name="action"><option value="ban">Ban</option><option value="unban">Unban</option></select><select name="jail" required>{status.data?.jails.map(jail=><option key={jail}>{jail}</option>)}</select><input name="ip" required placeholder="192.0.2.20"/><button className="primary" disabled={role==='viewer'||mutation.isPending}>Apply</button>{role==='viewer'&&<small>Operator role required.</small>}{mutation.error&&<small className="danger">{mutation.error.message}</small>}</form></Card></div></DataPanel>
|
||||
}
|
||||
|
||||
function WireGuardPage({server}:{server:Server}) {
|
||||
const query=useServerData<WireGuardInterface[]>(server,'wireguard',10_000)
|
||||
return <DataPanel query={query}><div className="stack">{query.data?.map(item=><Card key={item.name} title={item.name} badge={`UDP ${item.listen_port}`}><Table headers={['Peer','Endpoint','Allowed IPs','Last handshake','RX / TX']} rows={item.peers.map(peer=>[<code>{peer.public_key.slice(0,12)}…</code>,peer.endpoint??'—',peer.allowed_ips.join(', '),peer.latest_handshake?new Date(peer.latest_handshake*1000).toLocaleString():'Never',`${formatBytes(peer.rx_bytes)} / ${formatBytes(peer.tx_bytes)}`])}/></Card>)}{query.data?.length===0&&<Empty title="WireGuard unavailable" detail="No readable WireGuard interfaces were reported."/>}</div></DataPanel>
|
||||
}
|
||||
|
||||
function ServicesPage({server,role}:{server:Server;role:Role}) {
|
||||
const queryClient=useQueryClient(); const query=useServerData<Service[]>(server,'services',15_000)
|
||||
const mutation=useMutation({mutationFn:(name:string)=>api(`/servers/${server.id}/services/${encodeURIComponent(name)}/restart`,{method:'POST',body:'{}'}),onSuccess:()=>void queryClient.invalidateQueries({queryKey:['server',server.id,'services']})})
|
||||
return <DataPanel query={query}><Card title="Allow-listed systemd services" badge={`${query.data?.length??0} configured`}><Table headers={['State','Unit','Sub-state','PID','Memory','Action']} rows={(query.data??[]).map(item=>[<StatusDot status={item.active_state==='active'?'online':'offline'}/>,<code>{item.name}</code>,item.sub_state,item.pid||'—',formatBytes(item.memory_bytes),<button className="table-action" disabled={role==='viewer'||mutation.isPending} onClick={()=>mutation.mutate(item.name)}><ListRestart/>Restart</button>])}/></Card></DataPanel>
|
||||
}
|
||||
|
||||
function ServersPage({servers,user}:{servers:Server[];user:User}) {
|
||||
const queryClient=useQueryClient(); const [open,setOpen]=useState(false)
|
||||
const mutation=useMutation({mutationFn:(body:unknown)=>api('/servers',{method:'POST',body:JSON.stringify(body)}),onSuccess:()=>{setOpen(false);void queryClient.invalidateQueries({queryKey:['servers']})}})
|
||||
const submit=(event:FormEvent<HTMLFormElement>)=>{event.preventDefault();const data=new FormData(event.currentTarget);mutation.mutate({name:data.get('name'),hostname:data.get('hostname'),agent_endpoint:data.get('endpoint'),credential_ref:data.get('credential'),tags:String(data.get('tags')??'').split(',').map(v=>v.trim()).filter(Boolean)})}
|
||||
return <div className="stack"><div className="toolbar"><span>{servers.length} enrolled nodes</span>{user.role==='administrator'&&<button className="primary small" onClick={()=>setOpen(!open)}><Plus/>Add server</button>}</div>{open&&<Card title="Enroll server" badge="Private endpoint only"><form className="server-form" onSubmit={submit}><label>Name<input name="name" required/></label><label>Hostname<input name="hostname"/></label><label>Agent endpoint<input name="endpoint" required placeholder="http://10.10.0.2:9105"/></label><label>Credential file reference<input name="credential" required placeholder="agent_token"/></label><label>Tags<input name="tags" placeholder="production, eu-central"/></label><button className="primary" disabled={mutation.isPending}>Enroll</button>{mutation.error&&<small className="danger">{mutation.error.message}</small>}</form></Card>}
|
||||
<div className="server-grid">{servers.map(server=><Card key={server.id} title={server.name} badge={server.status}><div className="server-card"><div className="server-icon"><ServerIcon/></div><div><strong>{server.hostname||'Awaiting first contact'}</strong><code>{server.agent_endpoint}</code><span>{server.last_seen?`Seen ${new Date(server.last_seen).toLocaleString()}`:'Never contacted'}</span></div></div><div className="tag-list">{server.tags.map(tag=><span key={tag}>{tag}</span>)}</div></Card>)}{!servers.length&&<Empty title="No servers enrolled" detail="Mount an agent token secret, then add its private endpoint."/>}</div></div>
|
||||
}
|
||||
|
||||
function AuditPage() {
|
||||
const query=useQuery<AuditEvent[]>({queryKey:['audit'],queryFn:()=>api('/audit?limit=200'),refetchInterval:15_000})
|
||||
return <DataPanel query={query}><Card title="Control-plane audit trail" badge="Latest 200"><Table headers={['Time','Actor','Action','Target','Result','Source IP']} rows={(query.data??[]).map(item=>[new Date(item.created_at).toLocaleString(),item.username,<code>{item.action}</code>,item.target,<Pill text={item.result}/>,<code>{item.source_ip}</code>])}/></Card></DataPanel>
|
||||
}
|
||||
|
||||
function LogsPage({server}:{server:Server}) {
|
||||
const query=useServerData<AgentLogEntry[]>(server,'logs?limit=500',15_000)
|
||||
const priority=['emerg','alert','crit','err','warning','notice','info','debug']
|
||||
return <DataPanel query={query}><div className="stack">
|
||||
<div className="notice"><FileClock/><div><strong>Backend-retained journald entries</strong><span>The control plane polls only agent-configured systemd units and applies bounded retention. This browser never contacts the VPS agent directly.</span></div></div>
|
||||
<Card title="Agent logs" badge={`${query.data?.length??0} / 500`}><Table headers={['Time','Unit','Priority','PID','Message']} rows={(query.data??[]).map(item=>[
|
||||
new Date(item.timestamp).toLocaleString(),<code>{item.unit}</code>,<Pill text={priority[item.priority]??String(item.priority)}/>,item.pid??'—',<span className="log-message">{item.message}</span>,
|
||||
])}/></Card>
|
||||
</div></DataPanel>
|
||||
}
|
||||
function RateLimitsPage(){return <div className="notice"><Gauge/><div><strong>Policy engine is not enabled in v1</strong><span>L4 nftables policies require the crash-safe privileged helper. L7 provider configuration is planned separately; arbitrary nginx edits will not be supported.</span></div></div>}
|
||||
function SettingsPage({user,server}:{user:User;server?:Server}){return <div className="split"><Card title="Current account" badge={user.role}><div className="details"><Stat label="Username" value={user.username}/><Stat label="Role" value={user.role}/><Stat label="Session" value="HttpOnly + CSRF"/></div></Card><Card title="Agent target" badge={server?.status??'none'}><div className="details"><Stat label="Name" value={server?.name??'—'}/><Stat label="Endpoint" value={server?.agent_endpoint??'—'}/><Stat label="Last contact" value={server?.last_seen?new Date(server.last_seen).toLocaleString():'—'}/></div></Card></div>}
|
||||
|
||||
function Metric({label,value,sub,level}:{label:string;value:string;sub:string;level?:number}){return <div className="metric"><span className="eyebrow">{label}</span><strong>{value}</strong><small>{sub}</small>{level!==undefined&&<div className="meter"><i style={{width:`${Math.min(100,Math.max(0,level))}%`}}/></div>}</div>}
|
||||
function Card({title,badge,action,children}:{title:string;badge?:string;action?:ReactNode;children:ReactNode}){return <section className="card"><header><div><h2>{title}</h2>{badge&&<span className="badge">{badge}</span>}</div>{action}</header><div className="card-body">{children}</div></section>}
|
||||
function Stat({label,value}:{label:string;value:ReactNode}){return <div className="stat"><span>{label}</span><strong>{value}</strong></div>}
|
||||
function StatusDot({status}:{status:string}){return <i className={`status-dot ${status}`}/>}
|
||||
function Pill({text}:{text:string}){return <span className={`pill ${text.toLowerCase()}`}>{text}</span>}
|
||||
function Empty({title,detail}:{title:string;detail:string}){return <div className="empty"><Unplug/><h2>{title}</h2><p>{detail}</p></div>}
|
||||
function EmptyLine({text}:{text:string}){return <div className="empty-line">{text}</div>}
|
||||
function Event({severity,text}:{severity:string;text:string}){return <div className={`event ${severity}`}><StatusDot status={severity}/><span>{text}</span></div>}
|
||||
function LoadingScreen(){return <div className="loading-screen"><div className="brand-mark pulse"><Activity/></div><span>Establishing control plane…</span></div>}
|
||||
function PanelLoading(){return <div className="panel-loading"><i/><i/><i/></div>}
|
||||
function QueryError({error}:{error:Error}){return <div className="error-state"><ShieldAlert/><h2>Data unavailable</h2><p>{error instanceof APIError&&error.status===502?'The agent could not be reached over its private endpoint.':error.message}</p></div>}
|
||||
function DataPanel({query,children}:{query:{isPending:boolean;isError:boolean;error:Error|null};children:ReactNode}){if(query.isPending)return <PanelLoading/>;if(query.isError)return <QueryError error={query.error??new Error('Request failed')}/>;return <>{children}</>}
|
||||
function Table({headers,rows}:{headers:string[];rows:ReactNode[][]}){return <div className="table-wrap"><table><thead><tr>{headers.map(header=><th key={header}>{header}</th>)}</tr></thead><tbody>{rows.map((row,index)=><tr key={index}>{row.map((cell,cellIndex)=><td key={cellIndex}>{cell}</td>)}</tr>)}{!rows.length&&<tr><td colSpan={headers.length}><EmptyLine text="No data in the current snapshot."/></td></tr>}</tbody></table></div>}
|
||||
|
||||
function MiniChart({samples}:{samples:MetricSample[]}) {
|
||||
const ordered=[...samples].reverse(); const points=(key:'cpu'|'memory')=>ordered.map((sample,index)=>`${ordered.length<2?0:index/(ordered.length-1)*100},${40-Math.min(100,sample[key])/2.5}`).join(' ')
|
||||
return <div className="chart"><div className="chart-legend"><span><i className="cpu"/>CPU</span><span><i className="memory"/>Memory</span></div><svg viewBox="0 0 100 42" preserveAspectRatio="none" aria-label="CPU and memory history"><line x1="0" y1="20" x2="100" y2="20"/><line x1="0" y1="40" x2="100" y2="40"/>{ordered.length>1&&<><polyline className="cpu-line" points={points('cpu')}/><polyline className="memory-line" points={points('memory')}/></>}</svg></div>
|
||||
}
|
||||
|
||||
const percent=(used:number,total:number)=>total?used*100/total:0
|
||||
37
control-plane/frontend/src/api.ts
Normal file
37
control-plane/frontend/src/api.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
const csrfCookie = () => document.cookie.split('; ').find((item) => item.startsWith('vps_csrf='))?.split('=')[1] ?? ''
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(public status: number, message: string) { super(message) }
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const method = (init.method ?? 'GET').toUpperCase()
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Accept', 'application/json')
|
||||
if (init.body) headers.set('Content-Type', 'application/json')
|
||||
if (!['GET', 'HEAD'].includes(method)) headers.set('X-CSRF-Token', decodeURIComponent(csrfCookie()))
|
||||
const response = await fetch(`/api/v1${path}`, { ...init, headers, credentials: 'same-origin' })
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({ error: `Request failed (${response.status})` })) as {error?:string}
|
||||
throw new APIError(response.status, data.error ?? 'Request failed')
|
||||
}
|
||||
if (response.status === 204) return undefined as T
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const formatBytes = (value?: number) => {
|
||||
if (value === undefined || !Number.isFinite(value)) return '—'
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||
let amount = value
|
||||
let index = 0
|
||||
while (Math.abs(amount) >= 1024 && index < units.length - 1) { amount /= 1024; index += 1 }
|
||||
return `${amount.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
||||
}
|
||||
|
||||
export const formatDuration = (seconds?: number) => {
|
||||
if (seconds === undefined) return '—'
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
return days > 0 ? `${days}d ${hours}h` : `${hours}h ${Math.floor((seconds % 3600) / 60)}m`
|
||||
}
|
||||
|
||||
12
control-plane/frontend/src/main.tsx
Normal file
12
control-plane/frontend/src/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { App } from './App'
|
||||
import './styles.css'
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10_000, retry: 1, refetchOnWindowFocus: false } } })
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode><QueryClientProvider client={queryClient}><App /></QueryClientProvider></React.StrictMode>
|
||||
)
|
||||
|
||||
7
control-plane/frontend/src/styles.css
Normal file
7
control-plane/frontend/src/styles.css
Normal file
File diff suppressed because one or more lines are too long
2
control-plane/frontend/src/test-setup.ts
Normal file
2
control-plane/frontend/src/test-setup.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
22
control-plane/frontend/src/types.ts
Normal file
22
control-plane/frontend/src/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type Role = 'viewer' | 'operator' | 'administrator'
|
||||
|
||||
export interface User { id: string; username: string; role: Role }
|
||||
export interface Server {
|
||||
id: string; name: string; hostname: string; agent_endpoint: string; tags: string[]
|
||||
status: 'online' | 'offline' | 'unknown'; last_seen: string | null; created_at: string
|
||||
}
|
||||
export interface Filesystem { mountpoint: string; filesystem: string; total_bytes: number; used_bytes: number; inodes_total: number; inodes_used: number }
|
||||
export interface SystemStatus {
|
||||
hostname: string; os: string; kernel: string; uptime_seconds: number; load_average: [number, number, number]
|
||||
cpu_usage_percent: number; cpu_core_count: number; memory_total_bytes: number; memory_used_bytes: number
|
||||
swap_total_bytes: number; swap_used_bytes: number; filesystems: Filesystem[]; reboot_required: boolean
|
||||
}
|
||||
export interface Interface { name: string; state: string; addresses: string[]; rx_bytes: number; tx_bytes: number }
|
||||
export interface SocketInfo { protocol: string; local_address: string; local_port: number; remote_address: string; remote_port: number; state: string; pid?: number; process?: string }
|
||||
export interface Service { name: string; active_state: string; sub_state: string; pid: number; memory_bytes?: number }
|
||||
export interface JailStatus { installed: boolean; running: boolean; version?: string; jails: string[] }
|
||||
export interface WireGuardInterface { name: string; public_key: string; listen_port: number; peers: Array<{public_key:string;endpoint?:string;allowed_ips:string[];latest_handshake:number;rx_bytes:number;tx_bytes:number}> }
|
||||
export interface FirewallStatus { backend: string; management_enabled: boolean; managed_table: string; ruleset: unknown; warning?: string }
|
||||
export interface AuditEvent { id: number; username: string; server_id?: string; action: string; target: string; result: string; source_ip: string; created_at: string }
|
||||
export interface MetricSample { sampled_at:string; cpu:number; memory:number; disk:number; rx:number; tx:number; connections:number; bans:number }
|
||||
export interface AgentLogEntry { server_id:string; timestamp:string; unit:string; priority:number; message:string; boot_id?:string; pid?:number }
|
||||
22
control-plane/frontend/tsconfig.app.json
Normal file
22
control-plane/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
8
control-plane/frontend/tsconfig.json
Normal file
8
control-plane/frontend/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
||||
10
control-plane/frontend/tsconfig.node.json
Normal file
10
control-plane/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts", "eslint.config.js"]
|
||||
}
|
||||
7
control-plane/frontend/vite.config.ts
Normal file
7
control-plane/frontend/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { proxy: { '/api': 'http://localhost:8080', '/healthz': 'http://localhost:8080' } }
|
||||
})
|
||||
5
control-plane/frontend/vitest.config.ts
Normal file
5
control-plane/frontend/vitest.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: { environment: 'jsdom', setupFiles: './src/test-setup.ts' }
|
||||
})
|
||||
11
control-plane/grafana/Dockerfile
Normal file
11
control-plane/grafana/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM grafana/grafana:13.2.0
|
||||
|
||||
USER root
|
||||
COPY --chown=472:0 provisioning /etc/grafana/provisioning
|
||||
COPY --chown=472:0 dashboards /etc/grafana/dashboards
|
||||
COPY --chown=472:0 entrypoint.sh /usr/local/bin/vps-control-grafana-entrypoint
|
||||
RUN chmod 0555 /usr/local/bin/vps-control-grafana-entrypoint
|
||||
USER 472
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/vps-control-grafana-entrypoint"]
|
||||
67
control-plane/grafana/dashboards/vps-control-overview.json
Normal file
67
control-plane/grafana/dashboards/vps-control-overview.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"annotations": {"list": []},
|
||||
"editable": false,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "vps-control-prometheus"},
|
||||
"fieldConfig": {"defaults": {"max": 100, "min": 0, "unit": "percent"}, "overrides": []},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||
"id": 1,
|
||||
"options": {"legend": {"displayMode": "list", "placement": "bottom"}},
|
||||
"targets": [{"editorMode": "code", "expr": "vps_control_cpu_usage_percent", "legendFormat": "{{server_name}}", "range": true, "refId": "A"}],
|
||||
"title": "CPU usage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "vps-control-prometheus"},
|
||||
"fieldConfig": {"defaults": {"max": 100, "min": 0, "unit": "percent"}, "overrides": []},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
|
||||
"id": 2,
|
||||
"options": {"legend": {"displayMode": "list", "placement": "bottom"}},
|
||||
"targets": [{"editorMode": "code", "expr": "vps_control_memory_usage_percent", "legendFormat": "{{server_name}}", "range": true, "refId": "A"}],
|
||||
"title": "Memory usage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "vps-control-prometheus"},
|
||||
"fieldConfig": {"defaults": {"max": 100, "min": 0, "unit": "percent"}, "overrides": []},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
||||
"id": 3,
|
||||
"options": {"legend": {"displayMode": "list", "placement": "bottom"}},
|
||||
"targets": [{"editorMode": "code", "expr": "vps_control_disk_usage_percent", "legendFormat": "{{server_name}}", "range": true, "refId": "A"}],
|
||||
"title": "Disk usage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "vps-control-prometheus"},
|
||||
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
|
||||
"id": 4,
|
||||
"options": {"legend": {"displayMode": "list", "placement": "bottom"}},
|
||||
"targets": [{"editorMode": "code", "expr": "vps_control_connections", "legendFormat": "{{server_name}}", "range": true, "refId": "A"}],
|
||||
"title": "Connections",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "loki", "uid": "vps-control-loki"},
|
||||
"gridPos": {"h": 12, "w": 24, "x": 0, "y": 16},
|
||||
"id": 5,
|
||||
"options": {"dedupStrategy": "none", "enableLogDetails": true, "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": true, "showTime": true, "sortOrder": "Descending", "wrapLogMessage": true},
|
||||
"targets": [{"editorMode": "code", "expr": "{unit=~\".+\"}", "queryType": "range", "refId": "A"}],
|
||||
"title": "Allow-listed journald logs",
|
||||
"type": "logs"
|
||||
}
|
||||
],
|
||||
"refresh": "1m",
|
||||
"schemaVersion": 41,
|
||||
"tags": ["vps-control"],
|
||||
"templating": {"list": []},
|
||||
"time": {"from": "now-6h", "to": "now"},
|
||||
"timezone": "browser",
|
||||
"title": "VPS Control Overview",
|
||||
"uid": "vps-control-overview",
|
||||
"version": 1
|
||||
}
|
||||
22
control-plane/grafana/entrypoint.sh
Executable file
22
control-plane/grafana/entrypoint.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
integration_token_file=/run/secrets/integration_token
|
||||
admin_password_file=/run/secrets/grafana_admin_password
|
||||
secret_key_file=/run/secrets/grafana_secret_key
|
||||
|
||||
if [ ! -r "$integration_token_file" ] || [ ! -r "$admin_password_file" ] || [ ! -r "$secret_key_file" ]; then
|
||||
echo "required Grafana secret file is unavailable" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INTEGRATION_TOKEN=$(tr -d '\r\n' < "$integration_token_file")
|
||||
GF_SECURITY_ADMIN_PASSWORD=$(tr -d '\r\n' < "$admin_password_file")
|
||||
GF_SECURITY_SECRET_KEY=$(tr -d '\r\n' < "$secret_key_file")
|
||||
if [ "${#INTEGRATION_TOKEN}" -lt 32 ] || [ "${#GF_SECURITY_ADMIN_PASSWORD}" -lt 16 ] || [ "${#GF_SECURITY_SECRET_KEY}" -lt 32 ]; then
|
||||
echo "Grafana secrets do not meet minimum length requirements" >&2
|
||||
exit 1
|
||||
fi
|
||||
export INTEGRATION_TOKEN GF_SECURITY_ADMIN_PASSWORD GF_SECURITY_SECRET_KEY
|
||||
|
||||
exec /run.sh
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: VPS Control
|
||||
orgId: 1
|
||||
folder: VPS Control
|
||||
type: file
|
||||
disableDeletion: true
|
||||
allowUiUpdates: false
|
||||
updateIntervalSeconds: 30
|
||||
options:
|
||||
path: /etc/grafana/dashboards
|
||||
@@ -0,0 +1,35 @@
|
||||
apiVersion: 1
|
||||
|
||||
deleteDatasources:
|
||||
- name: VPS Control Metrics
|
||||
orgId: 1
|
||||
- name: VPS Control Logs
|
||||
orgId: 1
|
||||
|
||||
datasources:
|
||||
- name: VPS Control Metrics
|
||||
uid: vps-control-prometheus
|
||||
orgId: 1
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://backend:8080/integrations/prometheus
|
||||
isDefault: true
|
||||
editable: false
|
||||
jsonData:
|
||||
httpMethod: POST
|
||||
httpHeaderName1: Authorization
|
||||
secureJsonData:
|
||||
httpHeaderValue1: Bearer $INTEGRATION_TOKEN
|
||||
|
||||
- name: VPS Control Logs
|
||||
uid: vps-control-loki
|
||||
orgId: 1
|
||||
type: loki
|
||||
access: proxy
|
||||
url: http://backend:8080/integrations/loki
|
||||
editable: false
|
||||
jsonData:
|
||||
httpHeaderName1: Authorization
|
||||
maxLines: 1000
|
||||
secureJsonData:
|
||||
httpHeaderValue1: Bearer $INTEGRATION_TOKEN
|
||||
1
control-plane/secrets/.gitkeep
Normal file
1
control-plane/secrets/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user