528 lines
18 KiB
Go
528 lines
18 KiB
Go
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
|
|
}
|