git init
This commit is contained in:
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")
|
||||
}
|
||||
Reference in New Issue
Block a user