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") }