console: add user token fn gateway
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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/<route> + Authorization: Bearer <user-token>.
|
||||
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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
+27
-24
@@ -590,10 +590,13 @@
|
||||
<h4>Поля при создании функции</h4>
|
||||
<div class="help-list">
|
||||
<div><strong>Name</strong> — имя Function в Fission. Используется в CRD и в связанных объектах.</div>
|
||||
<div><strong>Language</strong> — язык runtime. От него зависят environment, шаблон кода и entrypoint по умолчанию.</div>
|
||||
<div><strong>Entrypoint</strong> — точка входа внутри пакета. Например, <span class="mono">main.main</span> для Python или <span class="mono">main.php::handler</span> для PHP.</div>
|
||||
<div><strong>Route</strong> — HTTP путь функции. По нему функцию можно вызвать вне морды через ingress.</div>
|
||||
<div><strong>Методы</strong> — HTTP методы route, например <span class="mono">GET</span> или <span class="mono">GET,POST</span>.</div>
|
||||
<div><strong>Language</strong> — язык runtime. От него зависят environment, шаблон кода и entrypoint по
|
||||
умолчанию.</div>
|
||||
<div><strong>Entrypoint</strong> — точка входа внутри пакета. Например, <span class="mono">main.main</span>
|
||||
для Python или <span class="mono">main.php::handler</span> для PHP.</div>
|
||||
<div><strong>Route</strong> — HTTP путь функции. Внешний пользовательский вызов идёт через <span class="mono">/fn + ваш Route</span>.</div>
|
||||
<div><strong>Методы</strong> — HTTP методы route, например <span class="mono">GET</span> или <span
|
||||
class="mono">GET,POST</span>.</div>
|
||||
<div><strong>Timeout (сек)</strong> — максимум времени выполнения именно этой функции.</div>
|
||||
<div><strong>Код</strong> — исходный код функции.</div>
|
||||
</div>
|
||||
@@ -605,7 +608,8 @@
|
||||
<div><strong>Name</strong> — только для просмотра, не редактируется.</div>
|
||||
<div><strong>Environment</strong> — только для просмотра, не редактируется из этой формы.</div>
|
||||
<div><strong>Entrypoint</strong> — только для просмотра, не редактируется из этой формы.</div>
|
||||
<div><strong>Timeout (сек)</strong> — редактируется и сохраняется в <span class="mono">spec.functionTimeout</span>.</div>
|
||||
<div><strong>Timeout (сек)</strong> — редактируется и сохраняется в <span
|
||||
class="mono">spec.functionTimeout</span>.</div>
|
||||
<div><strong>Код</strong> — редактируется и обновляет Package функции.</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -613,32 +617,31 @@
|
||||
<div class="help-section">
|
||||
<h4>Как вызвать функцию вне морды</h4>
|
||||
<div class="help-list">
|
||||
<div>Функция вызывается по её <strong>Route</strong>, то есть по HTTP пути, который задан при создании.</div>
|
||||
<div>Для прямого вызова через публичный route нужен JWT router-а в заголовке <span class="mono">Authorization: Bearer ...</span>.</div>
|
||||
<div>Альтернатива — вызывать через <span class="mono">/console/api/functions/ИМЯ/invoke</span>, но тогда нужен токен console в заголовках <span class="mono">X-Auth-Token</span> и <span class="mono">X-Auth-Env</span>.</div>
|
||||
<div>Обычный пользователь вызывает функцию через HTTP gateway console: <span class="mono">/fn/...</span>.</div>
|
||||
<div>Используется только ваш токен из личного кабинета в заголовке <span class="mono">Authorization: Bearer <token></span>.</div>
|
||||
<div>Работают обычные HTTP методы: <span class="mono">GET</span>, <span class="mono">POST</span>, <span class="mono">PUT</span>, <span class="mono">DELETE</span> и другие, если они разрешены в поле Методы.</div>
|
||||
</div>
|
||||
<div class="help-code">export TOKEN="<router-jwt>"
|
||||
curl -sk https://fission.kube5s.ru/ВАШ_ROUTE \
|
||||
-H "Authorization: Bearer $TOKEN"</div>
|
||||
<div class="help-code">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}'</div>
|
||||
<div class="help-code">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}'</div>
|
||||
<div class="help-code">export TOKEN="<ваш пользовательский токен>"
|
||||
curl -sk https://fission.kube5s.ru/fn/ВАШ_ROUTE \
|
||||
-H "Authorization: Bearer $TOKEN"</div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h4>Где взять токен</h4>
|
||||
<div class="help-list">
|
||||
<div><strong>Для входа в console</strong> — токен пользователя берётся в личном кабинете: Профиль пользователя → Токены.</div>
|
||||
<div><strong>Для прямого вызова route</strong> — нужен router JWT. Его можно получить через <span class="mono">/auth/login</span> с логином <span class="mono">admin</span> и паролем из секрета <span class="mono">router</span>.</div>
|
||||
<div>Токен удобно класть в переменную окружения и потом подставлять в curl как <span class="mono">$TOKEN</span>.</div>
|
||||
<div><strong>Для обычного пользователя</strong> — используйте ваш пользовательский токен из личного кабинета:
|
||||
Профиль пользователя → Токены.</div>
|
||||
<div>Тот же пользовательский токен подходит и для входа в console, и для внешнего вызова через <span
|
||||
class="mono">/fn/...</span>.</div>
|
||||
<div>Токен удобно класть в переменную окружения и потом подставлять в curl как <span
|
||||
class="mono">$TOKEN</span>.</div>
|
||||
</div>
|
||||
<div class="help-code">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"])')</div>
|
||||
<div class="help-code">export TOKEN="<вставьте сюда токен из личного кабинета>"</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
|
||||
Reference in New Issue
Block a user