diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml index ab99a6e..51d1e28 100644 --- a/console/deploy/console.yaml +++ b/console/deploy/console.yaml @@ -126,6 +126,13 @@ spec: name: fission-console port: number: 8090 + - path: /fn + pathType: Prefix + backend: + service: + name: fission-console + port: + number: 8090 - path: /console pathType: Prefix backend: diff --git a/console/internal/api/auth.go b/console/internal/api/auth.go index 773a3a1..6cb07c6 100644 --- a/console/internal/api/auth.go +++ b/console/internal/api/auth.go @@ -15,6 +15,22 @@ import ( // Использует приватный тип чтобы избежать коллизий с ключами из других пакетов. type ctxKeyNS struct{} +func authTokenFromRequest(r *http.Request) string { + token := strings.TrimSpace(r.Header.Get("X-Auth-Token")) + if token != "" { + return token + } + authz := strings.TrimSpace(r.Header.Get("Authorization")) + if authz == "" { + return "" + } + const bearerPrefix = "Bearer " + if len(authz) >= len(bearerPrefix) && strings.EqualFold(authz[:len(bearerPrefix)], bearerPrefix) { + return strings.TrimSpace(authz[len(bearerPrefix):]) + } + return "" +} + // userNS возвращает namespace пользователя из контекста запроса. // Устанавливается в authMiddleware после успешной аутентификации. func (s *Server) userNS(r *http.Request) string { @@ -47,7 +63,7 @@ func (s *Server) authMiddleware(h http.HandlerFunc) http.HandlerFunc { if sub != "" { ns = namespaceFromSub(sub) } else { - token := strings.TrimSpace(r.Header.Get("X-Auth-Token")) + token := authTokenFromRequest(r) resolvedNS, err := s.resolveNamespaceForToken(token, env, true) if err != nil { writeJSONError(w, http.StatusUnauthorized, "unauthorized") @@ -56,7 +72,7 @@ func (s *Server) authMiddleware(h http.HandlerFunc) http.HandlerFunc { ns = resolvedNS } } else { - token := strings.TrimSpace(r.Header.Get("X-Auth-Token")) + token := authTokenFromRequest(r) resolvedNS, err := s.resolveNamespaceForToken(token, env, false) if err != nil { writeJSONError(w, http.StatusUnauthorized, "unauthorized") diff --git a/console/internal/api/handlers.go b/console/internal/api/handlers.go index fcb93d7..0bb1b00 100644 --- a/console/internal/api/handlers.go +++ b/console/internal/api/handlers.go @@ -70,6 +70,78 @@ func normalizeFunctionTimeout(seconds int64) int64 { return seconds } +func normalizeRoute(route string) string { + route = strings.TrimSpace(route) + if route == "" || route == "/" { + return "/" + } + if !strings.HasPrefix(route, "/") { + return "/" + route + } + return route +} + +func routeAllowsMethod(methods []string, method string) bool { + if len(methods) == 0 { + return true + } + for _, candidate := range methods { + if strings.EqualFold(strings.TrimSpace(candidate), method) { + return true + } + } + return false +} + +func appendUniqueMethods(dst []string, src []string) []string { + for _, method := range src { + method = strings.ToUpper(strings.TrimSpace(method)) + if method == "" { + continue + } + seen := false + for _, existing := range dst { + if existing == method { + seen = true + break + } + } + if !seen { + dst = append(dst, method) + } + } + return dst +} + +func shouldForwardRequestBody(method string) bool { + switch method { + case http.MethodGet, http.MethodHead: + return false + default: + return true + } +} + +func copyProxyRequestHeaders(dst, src http.Header) { + for key, values := range src { + switch http.CanonicalHeaderKey(key) { + case "Authorization", "X-Auth-Token", "X-Auth-Env", "Host", "Content-Length": + continue + } + for _, value := range values { + dst.Add(key, value) + } + } +} + +func copyProxyResponseHeaders(dst, src http.Header) { + for key, values := range src { + for _, value := range values { + dst.Add(key, value) + } + } +} + // handleFunctionsRoot обрабатывает запросы к /console/api/functions без имени функции. // GET → список всех функций, POST → создать новую. func (s *Server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) { @@ -615,6 +687,110 @@ func (s *Server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na }) } +// handleInvokeRoute даёт пользователю прямой HTTP gateway к своей функции по route. +// Внешний контракт: /fn/ + Authorization: Bearer . +func (s *Server) handleInvokeRoute(w http.ResponseWriter, r *http.Request) { + route := normalizeRoute(strings.TrimPrefix(r.URL.Path, "/fn")) + if route == "/" { + writeJSONError(w, http.StatusBadRequest, "route is required") + return + } + + ns := s.userNS(r) + lookupCtx, lookupCancel := context.WithTimeout(r.Context(), 10*time.Second) + defer lookupCancel() + + triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(lookupCtx, metav1.ListOptions{}) + if err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list httptriggers: %v", err)) + return + } + + matchedFunction := "" + allowedMethods := make([]string, 0, 4) + for _, trig := range triggers.Items { + trigRoute, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl") + if normalizeRoute(trigRoute) != route { + continue + } + methods, _, _ := unstructured.NestedStringSlice(trig.Object, "spec", "methods") + allowedMethods = appendUniqueMethods(allowedMethods, methods) + if !routeAllowsMethod(methods, r.Method) { + continue + } + matchedFunction, _, _ = unstructured.NestedString(trig.Object, "spec", "functionref", "name") + if matchedFunction != "" { + break + } + } + + if matchedFunction == "" { + if len(allowedMethods) > 0 { + w.Header().Set("Allow", strings.Join(allowedMethods, ", ")) + writeJSONError(w, http.StatusMethodNotAllowed, fmt.Sprintf("route %q does not allow method %s", route, r.Method)) + return + } + writeJSONError(w, http.StatusNotFound, fmt.Sprintf("route %q not found", route)) + return + } + + fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(lookupCtx, matchedFunction, metav1.GetOptions{}) + if err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", matchedFunction, err)) + return + } + + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read request body: %v", err)) + return + } + + invokeTimeout := s.resolveInvokeTimeout(fn) + ctx, cancel := context.WithTimeout(r.Context(), invokeTimeout) + defer cancel() + + invokeURL := s.routerURL + route + if r.URL.RawQuery != "" { + invokeURL += "?" + r.URL.RawQuery + } + + var invokeBody io.Reader + if shouldForwardRequestBody(r.Method) { + invokeBody = bytes.NewReader(bodyBytes) + } + + req, err := http.NewRequestWithContext(ctx, r.Method, invokeURL, invokeBody) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build invoke request: %v", err)) + return + } + copyProxyRequestHeaders(req.Header, r.Header) + if token := s.getRouterToken(); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := s.http.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke route %q timeout after %s", route, invokeTimeout)) + return + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke route %q timeout after %s", route, invokeTimeout)) + return + } + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke route %q: %v", route, err)) + return + } + defer resp.Body.Close() + + copyProxyResponseHeaders(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + // handleDeleteFunction удаляет функцию и связанные объекты: HTTPTrigger, Package. // После удаления вызывает CleanupEnvironmentIfUnused — убирает environment если язык больше не используется. func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) { diff --git a/console/internal/api/server.go b/console/internal/api/server.go index 76501f8..ea125bf 100644 --- a/console/internal/api/server.go +++ b/console/internal/api/server.go @@ -144,6 +144,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/console/api/packages", auth(s.handleList(fission.PackageGVR))) mux.HandleFunc("/console/api/functions", auth(s.handleFunctionsRoot)) mux.HandleFunc("/console/api/functions/", auth(s.handleFunctionsAction)) + mux.HandleFunc("/fn/", auth(s.handleInvokeRoute)) mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR))) mux.HandleFunc("/console/api/timetriggers", auth(s.handleList(fission.TimeTrigGVR))) mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus)) diff --git a/console/ui/index.html b/console/ui/index.html index 04c3313..3e282b1 100644 --- a/console/ui/index.html +++ b/console/ui/index.html @@ -590,10 +590,13 @@

Поля при создании функции

Name — имя Function в Fission. Используется в CRD и в связанных объектах.
-
Language — язык runtime. От него зависят environment, шаблон кода и entrypoint по умолчанию.
-
Entrypoint — точка входа внутри пакета. Например, main.main для Python или main.php::handler для PHP.
-
Route — HTTP путь функции. По нему функцию можно вызвать вне морды через ingress.
-
Методы — HTTP методы route, например GET или GET,POST.
+
Language — язык runtime. От него зависят environment, шаблон кода и entrypoint по + умолчанию.
+
Entrypoint — точка входа внутри пакета. Например, main.main + для Python или main.php::handler для PHP.
+
Route — HTTP путь функции. Внешний пользовательский вызов идёт через /fn + ваш Route.
+
Методы — HTTP методы route, например GET или GET,POST.
Timeout (сек) — максимум времени выполнения именно этой функции.
Код — исходный код функции.
@@ -605,7 +608,8 @@
Name — только для просмотра, не редактируется.
Environment — только для просмотра, не редактируется из этой формы.
Entrypoint — только для просмотра, не редактируется из этой формы.
-
Timeout (сек) — редактируется и сохраняется в spec.functionTimeout.
+
Timeout (сек) — редактируется и сохраняется в spec.functionTimeout.
Код — редактируется и обновляет Package функции.
@@ -613,32 +617,31 @@

Как вызвать функцию вне морды

-
Функция вызывается по её Route, то есть по HTTP пути, который задан при создании.
-
Для прямого вызова через публичный route нужен JWT router-а в заголовке Authorization: Bearer ....
-
Альтернатива — вызывать через /console/api/functions/ИМЯ/invoke, но тогда нужен токен console в заголовках X-Auth-Token и X-Auth-Env.
+
Обычный пользователь вызывает функцию через HTTP gateway console: /fn/....
+
Используется только ваш токен из личного кабинета в заголовке Authorization: Bearer <token>.
+
Работают обычные HTTP методы: GET, POST, PUT, DELETE и другие, если они разрешены в поле Методы.
-
export TOKEN="<router-jwt>" -curl -sk https://fission.kube5s.ru/ВАШ_ROUTE \ - -H "Authorization: Bearer $TOKEN"
-
export TOKEN="<router-jwt>" -curl -sk -X POST https://fission.kube5s.ru/ВАШ_ROUTE \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"x":1,"y":2}'
+
export TOKEN="<ваш пользовательский токен>" + curl -sk -X POST https://fission.kube5s.ru/fn/ВАШ_ROUTE \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"x":1,"y":2}'
+
export TOKEN="<ваш пользовательский токен>" + curl -sk https://fission.kube5s.ru/fn/ВАШ_ROUTE \ + -H "Authorization: Bearer $TOKEN"

Где взять токен

-
Для входа в console — токен пользователя берётся в личном кабинете: Профиль пользователя → Токены.
-
Для прямого вызова route — нужен router JWT. Его можно получить через /auth/login с логином admin и паролем из секрета router.
-
Токен удобно класть в переменную окружения и потом подставлять в curl как $TOKEN.
+
Для обычного пользователя — используйте ваш пользовательский токен из личного кабинета: + Профиль пользователя → Токены.
+
Тот же пользовательский токен подходит и для входа в console, и для внешнего вызова через /fn/....
+
Токен удобно класть в переменную окружения и потом подставлять в curl как $TOKEN.
-
PASSWORD=$(kubectl -n fission get secret router -o jsonpath={.data.password} | base64 -d) -TOKEN=$(curl -sk -X POST https://fission.kube5s.ru/auth/login \ - -H "Content-Type: application/json" \ - -d "{\"username\":\"admin\",\"password\":\"$PASSWORD\"}" \ - | python3 -c 'import sys,json; print(json.load(sys.stdin)["accesstoken"])')
+
export TOKEN="<вставьте сюда токен из личного кабинета>"