// Изменено: 2026-04-25 (добавлена HTML web-консоль, proxy endpoints для source/triggers) // main.go — глобальный HTTP сервис листинга функций пользователя. // Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей. // Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml. // // Маршруты: // GET /funcs — usage hint (без токена) // GET /funcs?token= — листинг через JWT токен // GET /funcs/ — листинг (Accept: text/html → HTML, иначе plain text) // GET /funcs//source/ — прокси к оператору: файлы исходного кода (JSON) // PATCH /funcs//triggers/ — прокси к оператору: enable/disable триггера // GET /health — liveness/readiness probe // // Env vars: // SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090) // SLESS_EXTERNAL_URL — публичный базовый URL для корректных ссылок на функции // SLESS_EXCLUDE — comma-separated список имён функций, скрытых из листинга // SLESS_SERVICE_TOKEN — токен сервиса для запросов к оператору (для /funcs/ без токена юзера) // PORT — порт сервера (default: 8090) package main import ( "bytes" "crypto/sha256" _ "embed" "encoding/base64" "encoding/json" "fmt" "io" "log" "net/http" "os" "regexp" "sort" "strings" ) //go:embed index.html var htmlPageTemplate string // fnResponse — ответ /v1/namespaces/{ns}/functions (подмножество полей оператора) type fnResponse struct { Name string `json:"name"` Runtime string `json:"runtime"` Phase string `json:"phase"` Message string `json:"message"` CreatedAt string `json:"created_at"` LastBuiltAt string `json:"last_built_at"` } // trResponse — ответ /v1/namespaces/{ns}/triggers type trResponse struct { Name string `json:"name"` Type string `json:"type"` FunctionRef string `json:"function"` Schedule string `json:"schedule"` Enabled bool `json:"enabled"` Active bool `json:"active"` URL string `json:"url"` } // pageData — структура данных для HTML-шаблона web-консоли. type pageData struct { Namespace string `json:"namespace"` ExternalURL string `json:"externalURL"` Functions []fnResponse `json:"functions"` TriggersByFn map[string][]trResponse `json:"triggersByFn"` } // nsRegex — базовая валидация kubernetes namespace (ловеркейс + hyphens). // Защита от path traversal при подстановке в URL запросов к оператору. var nsRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$`) func main() { operatorURL := strings.TrimRight(env("SLESS_OPERATOR_URL", "http://sless-operator.sless.svc.cluster.local:9090"), "/") externalURL := strings.TrimRight(env("SLESS_EXTERNAL_URL", ""), "/") port := env("PORT", "8090") serviceToken := os.Getenv("SLESS_SERVICE_TOKEN") exclude := map[string]bool{} for _, n := range strings.Split(os.Getenv("SLESS_EXCLUDE"), ",") { if n = strings.TrimSpace(n); n != "" { exclude[n] = true } } // /funcs (точное совпадение) — namespace из JWT токена (?token= или Authorization header) http.HandleFunc("/funcs", handleFuncsToken(operatorURL, externalURL, exclude)) // /funcs/ — namespace в URL пути + sub-paths (source/triggers proxy) http.HandleFunc("/funcs/", handleFuncsNS(operatorURL, externalURL, serviceToken, exclude)) // /health — для liveness/readiness probe (без auth) http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintln(w, "ok") }) log.Printf("sless-funcs-service listening on :%s (operator: %s)", port, operatorURL) log.Fatal(http.ListenAndServe(":"+port, nil)) } // handleFuncsToken обрабатывает GET /funcs с JWT токеном (?token= или Authorization header). func handleFuncsToken(operatorURL, externalURL string, exclude map[string]bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if token == "" { token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") } if token == "" { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusUnauthorized) fmt.Fprintln(w, "Укажите namespace в URL или передайте токен:") fmt.Fprintln(w, "") fmt.Fprintf(w, " %s/funcs/\n", externalURL) fmt.Fprintf(w, " %s/funcs?token=\n", externalURL) return } sub, err := subFromJWT(token) if err != nil { http.Error(w, fmt.Sprintf("invalid token: %s\n", err), http.StatusUnauthorized) return } fetchAndRender(w, r, operatorURL, externalURL, "Bearer "+token, namespaceFromSub(sub), exclude) } } // handleFuncsNS обрабатывает все запросы /funcs/{ns}[/sub/path]. // // Маршруты: // // GET /funcs/{ns} — листинг (HTML если Accept: text/html, иначе plain text) // GET /funcs/{ns}/source/{fn} — прокси: файлы исходного кода функции // PATCH /funcs/{ns}/triggers/{name} — прокси: enable/disable триггер func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[string]bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rest := strings.TrimPrefix(r.URL.Path, "/funcs/") rest = strings.Trim(rest, "/") if rest == "" { http.Error(w, "usage: /funcs/\n", http.StatusBadRequest) return } // SplitN до 3 частей: [ns], [ns, subtype] или [ns, subtype, resourceName] parts := strings.SplitN(rest, "/", 3) ns := parts[0] if !nsRegex.MatchString(ns) { http.Error(w, "invalid namespace\n", http.StatusBadRequest) return } if len(parts) == 3 { switch parts[1] { case "source": if r.Method != http.MethodGet { http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed) return } proxySourceGet(w, r, operatorURL, serviceToken, ns, parts[2]) return case "triggers": if r.Method != http.MethodPatch { http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed) return } proxyTriggerPatch(w, r, operatorURL, serviceToken, ns, parts[2]) return } } if len(parts) > 1 { http.NotFound(w, r) return } // GET /funcs/{ns} — листинг с авторизацией через serviceToken authHeader := "" if serviceToken != "" { authHeader = "Bearer " + serviceToken } fetchAndRender(w, r, operatorURL, externalURL, authHeader, ns, exclude) } } // fetchAndRender загружает данные из оператора и отдаёт ответ: HTML (браузер) или plain text (curl). func fetchAndRender(w http.ResponseWriter, r *http.Request, operatorURL, externalURL, authHeader, ns string, exclude map[string]bool) { fns, err := apiGet[[]fnResponse](operatorURL+"/v1/namespaces/"+ns+"/functions", authHeader) if err != nil { http.Error(w, fmt.Sprintf("operator error (functions): %s\n", err), http.StatusBadGateway) return } trs, err := apiGet[[]trResponse](operatorURL+"/v1/namespaces/"+ns+"/triggers", authHeader) if err != nil { http.Error(w, fmt.Sprintf("operator error (triggers): %s\n", err), http.StatusBadGateway) return } if acceptsHTML(r) { serveHTML(w, ns, externalURL, fns, trs, exclude) } else { servePlainText(w, ns, externalURL, fns, trs, exclude) } } // acceptsHTML возвращает true если клиент предпочитает HTML (браузер). // curl без -H "Accept: text/html" получит plain text — поведение совместимо с v0.1.x. func acceptsHTML(r *http.Request) bool { return strings.Contains(r.Header.Get("Accept"), "text/html") } // serveHTML рендерит web-консоль. // Данные встраиваются как JSON в в данных экранируется как <\/script> (валидный JSON escape). func serveHTML(w http.ResponseWriter, ns, externalURL string, fns []fnResponse, trs []trResponse, exclude map[string]bool) { trigIdx := buildTriggerIndex(trs) filtered := filterAndSort(fns, trigIdx, exclude) pd := pageData{ Namespace: ns, ExternalURL: externalURL, Functions: filtered, TriggersByFn: trigIdx, } raw, _ := json.Marshal(pd) // Предотвращаем преждевременное закрытие тега