460 lines
16 KiB
Go
460 lines
16 KiB
Go
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")
|
|
}
|