diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml index 92fa11e..9e6f856 100644 --- a/console/deploy/console.yaml +++ b/console/deploy/console.yaml @@ -46,7 +46,7 @@ spec: serviceAccountName: fission-console containers: - name: console - image: naeel/fission-console:v0.1.5 + image: naeel/fission-console:v0.2.2 ports: - containerPort: 8090 env: @@ -56,6 +56,16 @@ spec: value: "http://router.fission.svc.cluster.local" - name: PORT value: "8090" + - name: FISSION_AUTH_USERNAME + valueFrom: + secretKeyRef: + name: router + key: username + - name: FISSION_AUTH_PASSWORD + valueFrom: + secretKeyRef: + name: router + key: password livenessProbe: httpGet: path: /health diff --git a/console/main.go b/console/main.go index ee4bbea..c23b998 100644 --- a/console/main.go +++ b/console/main.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "strings" + "sync" "time" "fission-console/ui" @@ -32,11 +33,21 @@ var ( timeTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"} ) +const defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + type server struct { - dyn dynamic.Interface - ns string - routerURL string - http *http.Client + dyn dynamic.Interface + ns string + routerURL string + http *http.Client + saTokenPath string + + authUser string + authPass string + + tokenMu sync.Mutex + cachedJWT string + tokenExpAt time.Time } type createFunctionRequest struct { @@ -68,11 +79,18 @@ func main() { log.Fatalf("create dynamic client: %v", err) } + authUser := envDefault("FISSION_AUTH_USERNAME", "") + authPass := envDefault("FISSION_AUTH_PASSWORD", "") + saTokenPath := envDefault("SA_TOKEN_PATH", defaultSATokenPath) + s := &server{ - dyn: dyn, - ns: namespace, - routerURL: routerURL, - http: &http.Client{Timeout: 30 * time.Second}, + dyn: dyn, + ns: namespace, + routerURL: routerURL, + http: &http.Client{Timeout: 30 * time.Second}, + saTokenPath: saTokenPath, + authUser: authUser, + authPass: authPass, } mux := http.NewServeMux() @@ -474,6 +492,9 @@ func (s *server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na if invokeMethod == http.MethodPost { req.Header.Set("Content-Type", "application/json") } + if token := s.getRouterToken(); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } resp, err := s.http.Do(req) if err != nil { @@ -491,6 +512,58 @@ func (s *server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na }) } +func (s *server) readSAToken() string { + if s.saTokenPath == "" { + return "" + } + data, err := os.ReadFile(s.saTokenPath) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func (s *server) getRouterToken() string { + if s.authUser == "" || s.authPass == "" { + return s.readSAToken() + } + + s.tokenMu.Lock() + defer s.tokenMu.Unlock() + + if s.cachedJWT != "" && time.Now().Before(s.tokenExpAt) { + return s.cachedJWT + } + + loginURL := s.routerURL + "/auth/login" + body, _ := json.Marshal(map[string]string{"username": s.authUser, "password": s.authPass}) + resp, err := s.http.Post(loginURL, "application/json", bytes.NewReader(body)) + if err != nil { + log.Printf("router login failed: %v", err) + return s.readSAToken() + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(resp.Body) + log.Printf("router login %d: %s", resp.StatusCode, string(respBody)) + return s.readSAToken() + } + + var result struct { + AccessToken string `json:"accesstoken"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || result.AccessToken == "" { + log.Printf("router login decode error: %v", err) + return s.readSAToken() + } + + s.cachedJWT = result.AccessToken + s.tokenExpAt = time.Now().Add(100 * time.Second) + log.Printf("router JWT obtained, expires in 100s") + return s.cachedJWT +} + func (s *server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) { ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() diff --git a/console/main_test.go b/console/main_test.go index 36adfe2..f0a3e91 100644 --- a/console/main_test.go +++ b/console/main_test.go @@ -26,7 +26,7 @@ func newTestServer(objs ...runtime.Object) *server { timeTrigGVR: "TimeTriggerList", } dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), listKinds, objs...) - return &server{dyn: dyn, ns: "default", routerURL: "http://example.invalid", http: &http.Client{}} + return &server{dyn: dyn, ns: "default", routerURL: "http://example.invalid", http: &http.Client{}, saTokenPath: ""} } func TestNormalizeMethods(t *testing.T) { @@ -109,7 +109,7 @@ func TestUpdateFunctionCode(t *testing.T) { env := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "fission.io/v1", "kind": "Environment", - "metadata": map[string]any{"name": "python", "namespace": "default"}, + "metadata": map[string]any{"name": "python", "namespace": "default"}, }} s := newTestServer(env) @@ -141,3 +141,73 @@ func TestUpdateFunctionCode(t *testing.T) { t.Fatalf("expected new-code, got %q", string(decoded)) } } + +func TestInvokeFunctionWithJWTAuth(t *testing.T) { + // Mock router: /auth/login returns JWT, /inv-fn returns hello + var gotAuth string + mockRouter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/auth/login" && r.Method == http.MethodPost { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"accesstoken":"fake-jwt-token-xyz","tokentype":"Bearer"}`)) + return + } + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"hello":"world"}`)) + })) + defer mockRouter.Close() + + env := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Environment", + "metadata": map[string]any{"name": "python", "namespace": "default"}, + }} + + listKinds := map[schema.GroupVersionResource]string{ + environmentGVR: "EnvironmentList", + packageGVR: "PackageList", + functionGVR: "FunctionList", + httpTrigGVR: "HTTPTriggerList", + timeTrigGVR: "TimeTriggerList", + } + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), listKinds, env) + s := &server{ + dyn: dyn, + ns: "default", + routerURL: mockRouter.URL, + http: mockRouter.Client(), + authUser: "admin", + authPass: "pass", + } + + // Create function first + createBody := `{"name":"inv-fn","environment":"python","code":"print(1)","route":"/inv-fn","methods":["GET"]}` + createRec := httptest.NewRecorder() + s.handleCreateFunction(createRec, httptest.NewRequest(http.MethodPost, "/api/functions", strings.NewReader(createBody))) + if createRec.Code != http.StatusCreated { + t.Fatalf("create: %d %s", createRec.Code, createRec.Body.String()) + } + + // Invoke + invokeRec := httptest.NewRecorder() + s.handleInvokeFunction(invokeRec, httptest.NewRequest(http.MethodPost, "/api/functions/inv-fn/invoke", strings.NewReader(`{}`)), "inv-fn") + if invokeRec.Code != http.StatusOK { + t.Fatalf("invoke: %d %s", invokeRec.Code, invokeRec.Body.String()) + } + + // Verify JWT was obtained via login and sent + if gotAuth != "Bearer fake-jwt-token-xyz" { + t.Fatalf("expected 'Bearer fake-jwt-token-xyz', got %q", gotAuth) + } + + var out map[string]any + if err := json.Unmarshal(invokeRec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v", err) + } + if out["status"] != float64(200) { + t.Fatalf("expected status 200, got %v", out["status"]) + } + if !strings.Contains(out["response_raw"].(string), "hello") { + t.Fatalf("unexpected response: %v", out["response_raw"]) + } +}