1	// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
2	// All rights reserved. This file is part of cc-backend.
3	// Use of this source code is governed by a MIT-style
4	// license that can be found in the LICENSE file.
5
6	// Package main provides the entry point for the ClusterCockpit backend server.
7	// This file contains HTTP server setup, routing configuration, and
8	// authentication middleware integration.
9	package main
10
11	import (
12		"context"
13		"crypto/tls"
14		"encoding/json"
15		"errors"
16		"fmt"
17		"net"
18		"net/http"
19		"os"
20		"strings"
21		"sync"
22		"time"
23
24		"github.com/99designs/gqlgen/graphql"
25		"github.com/99designs/gqlgen/graphql/handler"
26		"github.com/99designs/gqlgen/graphql/handler/transport"
27		"github.com/99designs/gqlgen/graphql/playground"
28		"github.com/ClusterCockpit/cc-backend/internal/api"
29		"github.com/ClusterCockpit/cc-backend/internal/archiver"
30		"github.com/ClusterCockpit/cc-backend/internal/auth"
31		"github.com/ClusterCockpit/cc-backend/internal/config"
32		"github.com/ClusterCockpit/cc-backend/internal/graph"
33		"github.com/ClusterCockpit/cc-backend/internal/graph/generated"
34		"github.com/ClusterCockpit/cc-backend/internal/routerConfig"
35		"github.com/ClusterCockpit/cc-backend/pkg/metricstore"
36		"github.com/ClusterCockpit/cc-backend/web"
37		cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
38		"github.com/ClusterCockpit/cc-lib/v2/nats"
39		"github.com/ClusterCockpit/cc-lib/v2/runtime"
40		"github.com/go-chi/chi/v5"
41		"github.com/go-chi/chi/v5/middleware"
42		"github.com/go-chi/cors"
43		httpSwagger "github.com/swaggo/http-swagger"
44	)
45
46	var buildInfo web.Build
47
48	// Environment variable names
49	const (
50		envDebug = "DEBUG"
51	)
52
53	// Server encapsulates the HTTP server state and dependencies
54	type Server struct {
55		router        chi.Router
56		server        *http.Server
57		restAPIHandle *api.RestAPI
58		natsAPIHandle *api.NatsAPI
59	}
60
61	func onFailureResponse(rw http.ResponseWriter, r *http.Request, err error) {
62		rw.Header().Add("Content-Type", "application/json")
63		rw.WriteHeader(http.StatusUnauthorized)
64		json.NewEncoder(rw).Encode(map[string]string{
65			"status": http.StatusText(http.StatusUnauthorized),
66			"error":  err.Error(),
67		})
68	}
69
70	// NewServer creates and initializes a new Server instance
71	func NewServer(version, commit, buildDate string) (*Server, error) {
72		buildInfo = web.Build{Version: version, Hash: commit, Buildtime: buildDate}
73
74		s := &Server{
75			router: chi.NewRouter(),
76		}
77
78		if err := s.init(); err != nil {
79			return nil, err
80		}
81
82		return s, nil
83	}
84
85	func (s *Server) init() error {
86		// Setup the http.Handler/Router used by the server
87		graph.Init()
88		resolver := graph.GetResolverInstance()
89		graphQLServer := handler.New(
90			generated.NewExecutableSchema(generated.Config{Resolvers: resolver}))
91
92		graphQLServer.AddTransport(transport.POST{})
93
94		// Inject a per-request stats cache so that grouped statistics queries
95		// sharing the same (filter, groupBy) pair are executed only once.
96		graphQLServer.AroundOperations(func(ctx context.Context, next graphql.OperationHandler) graphql.ResponseHandler {
97			return next(graph.WithStatsGroupCache(ctx))
98		})
99
100		if os.Getenv(envDebug) != "1" {
101			// Having this handler means that a error message is returned via GraphQL instead of the connection simply beeing closed.
102			// The problem with this is that then, no more stacktrace is printed to stderr.
103			graphQLServer.SetRecoverFunc(func(ctx context.Context, err any) error {
104				switch e := err.(type) {
105				case string:
106					return fmt.Errorf("MAIN > Panic: %s", e)
107				case error:
108					return fmt.Errorf("MAIN > Panic caused by: %s", e.Error())
109				}
110
111				return errors.New("MAIN > Internal server error (panic)")
112			})
113		}
114
115		authHandle := auth.GetAuthInstance()
116
117		// Middleware must be defined before routes in chi
118		s.router.Use(func(next http.Handler) http.Handler {
119			return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
120				start := time.Now()
121				ww := middleware.NewWrapResponseWriter(rw, r.ProtoMajor)
122				next.ServeHTTP(ww, r)
123				cclog.Debugf("%s %s (%d, %.02fkb, %dms)",
124					r.Method, r.URL.RequestURI(),
125					ww.Status(), float32(ww.BytesWritten())/1024,
126					time.Since(start).Milliseconds())
127			})
128		})
129		s.router.Use(middleware.Compress(5))
130		s.router.Use(middleware.Recoverer)
131		s.router.Use(cors.Handler(cors.Options{
132			AllowCredentials: true,
133			AllowedHeaders:   []string{"X-Requested-With", "Content-Type", "Authorization", "Origin"},
134			AllowedMethods:   []string{"GET", "POST", "HEAD", "OPTIONS"},
135			AllowedOrigins:   []string{"*"},
136		}))
137
138		s.restAPIHandle = api.New()
139
140		info := map[string]any{}
141		info["hasOpenIDConnect"] = false
142
143		if auth.Keys.OpenIDConfig != nil {
144			openIDConnect := auth.NewOIDC(authHandle)
145			openIDConnect.RegisterEndpoints(s.router)
146			info["hasOpenIDConnect"] = true
147		}
148
149		s.router.Get("/login", func(rw http.ResponseWriter, r *http.Request) {
150			rw.Header().Add("Content-Type", "text/html; charset=utf-8")
151			cclog.Debugf("##%v##", info)
152			web.RenderTemplate(rw, "login.tmpl", &web.Page{Title: "Login", Build: buildInfo, Infos: info})
153		})
154		s.router.HandleFunc("/imprint", func(rw http.ResponseWriter, r *http.Request) {
155			rw.Header().Add("Content-Type", "text/html; charset=utf-8")
156			web.RenderTemplate(rw, "imprint.tmpl", &web.Page{Title: "Imprint", Build: buildInfo})
157		})
158		s.router.HandleFunc("/privacy", func(rw http.ResponseWriter, r *http.Request) {
159			rw.Header().Add("Content-Type", "text/html; charset=utf-8")
160			web.RenderTemplate(rw, "privacy.tmpl", &web.Page{Title: "Privacy", Build: buildInfo})
161		})
162
163		if !config.Keys.DisableAuthentication {
164			// Create login failure handler (used by both /login and /jwt-login)
165			loginFailureHandler := func(rw http.ResponseWriter, r *http.Request, err error) {
166				rw.Header().Add("Content-Type", "text/html; charset=utf-8")
167				rw.WriteHeader(http.StatusUnauthorized)
168				web.RenderTemplate(rw, "login.tmpl", &web.Page{
169					Title:   "Login failed - ClusterCockpit",
170					MsgType: "alert-warning",
171					Message: err.Error(),
172					Build:   buildInfo,
173					Infos:   info,
174				})
175			}
176
177			s.router.Post("/login", authHandle.Login(loginFailureHandler).ServeHTTP)
178			s.router.HandleFunc("/jwt-login", authHandle.Login(loginFailureHandler).ServeHTTP)
179
180			s.router.Post("/logout", authHandle.Logout(
181				http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
182					rw.Header().Add("Content-Type", "text/html; charset=utf-8")
183					rw.WriteHeader(http.StatusOK)
184					web.RenderTemplate(rw, "login.tmpl", &web.Page{
185						Title:   "Bye - ClusterCockpit",
186						MsgType: "alert-info",
187						Message: "Logout successful",
188						Build:   buildInfo,
189						Infos:   info,
190					})
191				})).ServeHTTP)
192		}
193
194		if flagDev {
195			s.router.Handle("/playground", playground.Handler("GraphQL playground", "/query"))
196			s.router.Get("/swagger/*", httpSwagger.Handler(
197				httpSwagger.URL("http://"+config.Keys.Addr+"/swagger/doc.json")))
198		}
199
200		// Secured routes (require authentication)
201		s.router.Group(func(secured chi.Router) {
202			if !config.Keys.DisableAuthentication {
203				secured.Use(func(next http.Handler) http.Handler {
204					return authHandle.Auth(
205						next,
206						func(rw http.ResponseWriter, r *http.Request, err error) {
207							rw.WriteHeader(http.StatusUnauthorized)
208							web.RenderTemplate(rw, "login.tmpl", &web.Page{
209								Title:    "Authentication failed - ClusterCockpit",
210								MsgType:  "alert-danger",
211								Message:  err.Error(),
212								Build:    buildInfo,
213								Infos:    info,
214								Redirect: r.RequestURI,
215							})
216						})
217				})
218			}
219
220			secured.Handle("/query", graphQLServer)
221
222			secured.HandleFunc("/search", func(rw http.ResponseWriter, r *http.Request) {
223				routerConfig.HandleSearchBar(rw, r, buildInfo)
224			})
225
226			routerConfig.SetupRoutes(secured, buildInfo)
227		})
228
229		// API routes (JWT token auth)
230		s.router.Route("/api", func(apiRouter chi.Router) {
231			// Main API routes with API auth
232			apiRouter.Group(func(securedapi chi.Router) {
233				if !config.Keys.DisableAuthentication {
234					securedapi.Use(func(next http.Handler) http.Handler {
235						return authHandle.AuthAPI(next, onFailureResponse)
236					})
237				}
238				s.restAPIHandle.MountAPIRoutes(securedapi)
239			})
240
241			// Metric store API routes with separate auth
242			apiRouter.Group(func(metricstoreapi chi.Router) {
243				if !config.Keys.DisableAuthentication {
244					metricstoreapi.Use(func(next http.Handler) http.Handler {
245						return authHandle.AuthMetricStoreAPI(next, onFailureResponse)
246					})
247				}
248				s.restAPIHandle.MountMetricStoreAPIRoutes(metricstoreapi)
249			})
250		})
251
252		// User API routes
253		s.router.Route("/userapi", func(userapi chi.Router) {
254			if !config.Keys.DisableAuthentication {
255				userapi.Use(func(next http.Handler) http.Handler {
256					return authHandle.AuthUserAPI(next, onFailureResponse)
257				})
258			}
259			s.restAPIHandle.MountUserAPIRoutes(userapi)
260		})
261
262		// Config API routes (uses Group with full paths to avoid shadowing
263		// the /config page route that is registered in the secured group)
264		s.router.Group(func(configapi chi.Router) {
265			if !config.Keys.DisableAuthentication {
266				configapi.Use(func(next http.Handler) http.Handler {
267					return authHandle.AuthConfigAPI(next, onFailureResponse)
268				})
269			}
270			s.restAPIHandle.MountConfigAPIRoutes(configapi)
271		})
272
273		// Frontend API routes
274		s.router.Route("/frontend", func(frontendapi chi.Router) {
275			if !config.Keys.DisableAuthentication {
276				frontendapi.Use(func(next http.Handler) http.Handler {
277					return authHandle.AuthFrontendAPI(next, onFailureResponse)
278				})
279			}
280			s.restAPIHandle.MountFrontendAPIRoutes(frontendapi)
281		})
282
283		if config.Keys.APISubjects != nil {
284			s.natsAPIHandle = api.NewNatsAPI()
285			if err := s.natsAPIHandle.StartSubscriptions(); err != nil {
286				return fmt.Errorf("starting NATS subscriptions: %w", err)
287			}
288		}
289
290		// 404 handler for pages and API routes
291		notFoundHandler := func(rw http.ResponseWriter, r *http.Request) {
292			if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/userapi/") ||
293				strings.HasPrefix(r.URL.Path, "/frontend/") || strings.HasPrefix(r.URL.Path, "/config/") {
294				rw.Header().Set("Content-Type", "application/json")
295				rw.WriteHeader(http.StatusNotFound)
296				json.NewEncoder(rw).Encode(map[string]string{
297					"status": "Resource not found",
298					"error":  "the requested endpoint does not exist",
299				})
300				return
301			}
302			rw.Header().Set("Content-Type", "text/html; charset=utf-8")
303			rw.WriteHeader(http.StatusNotFound)
304			web.RenderTemplate(rw, "404.tmpl", &web.Page{
305				Title: "Page Not Found",
306				Build: buildInfo,
307			})
308		}
309
310		// Set NotFound on the router so chi uses it for all unmatched routes,
311		// including those under subrouters like /api, /userapi, /frontend, etc.
312		s.router.NotFound(notFoundHandler)
313
314		if config.Keys.EmbedStaticFiles {
315			if i, err := os.Stat("./var/img"); err == nil {
316				if i.IsDir() {
317					cclog.Info("Use local directory for static images")
318					s.router.Handle("/img/*", http.StripPrefix("/img/", http.FileServer(http.Dir("./var/img"))))
319				}
320			}
321			fileServer := http.StripPrefix("/", web.ServeFiles())
322			s.router.Handle("/*", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
323				if web.StaticFileExists(r.URL.Path) {
324					fileServer.ServeHTTP(rw, r)
325					return
326				}
327				notFoundHandler(rw, r)
328			}))
329		} else {
330			staticDir := http.Dir(config.Keys.StaticFiles)
331			fileServer := http.FileServer(staticDir)
332			s.router.Handle("/*", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
333				f, err := staticDir.Open(r.URL.Path)
334				if err == nil {
335					f.Close()
336					fileServer.ServeHTTP(rw, r)
337					return
338				}
339				notFoundHandler(rw, r)
340			}))
341		}
342
343		return nil
344	}
345
346	// Server timeout defaults (in seconds)
347	const (
348		defaultReadHeaderTimeout = 20
349		defaultWriteTimeout      = 20
350	)
351
352	func (s *Server) Start(ctx context.Context) error {
353		// Use configurable timeouts with defaults
354		readHeaderTimeout := time.Duration(defaultReadHeaderTimeout) * time.Second
355		writeTimeout := time.Duration(defaultWriteTimeout) * time.Second
356
357		s.server = &http.Server{
358			ReadHeaderTimeout: readHeaderTimeout,
359			WriteTimeout:      writeTimeout,
360			Handler:           s.router,
361			Addr:              config.Keys.Addr,
362		}
363
364		// Start http or https server
365		listener, err := net.Listen("tcp", config.Keys.Addr)
366		if err != nil {
367			return fmt.Errorf("starting listener on '%s': %w", config.Keys.Addr, err)
368		}
369
370		if !strings.HasSuffix(config.Keys.Addr, ":80") && config.Keys.RedirectHTTPTo != "" {
371			go func() {
372				http.ListenAndServe(":80", http.RedirectHandler(config.Keys.RedirectHTTPTo, http.StatusMovedPermanently))
373			}()
374		}
375
376		if config.Keys.HTTPSCertFile != "" && config.Keys.HTTPSKeyFile != "" {
377			cert, err := tls.LoadX509KeyPair(
378				config.Keys.HTTPSCertFile, config.Keys.HTTPSKeyFile)
379			if err != nil {
380				return fmt.Errorf("loading X509 keypair (check 'https-cert-file' and 'https-key-file' in config.json): %w", err)
381			}
382			listener = tls.NewListener(listener, &tls.Config{
383				Certificates: []tls.Certificate{cert},
384				CipherSuites: []uint16{
385					tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
386					tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
387				},
388				MinVersion:               tls.VersionTLS12,
389				PreferServerCipherSuites: true,
390			})
391			cclog.Infof("HTTPS server listening at %s...", config.Keys.Addr)
392		} else {
393			cclog.Infof("HTTP server listening at %s...", config.Keys.Addr)
394		}
395		//
396		// Because this program will want to bind to a privileged port (like 80), the listener must
397		// be established first, then the user can be changed, and after that,
398		// the actual http server can be started.
399		if err := runtime.DropPrivileges(config.Keys.Group, config.Keys.User); err != nil {
400			return fmt.Errorf("dropping privileges: %w", err)
401		}
402
403		if err = s.server.Serve(listener); err != nil && err != http.ErrServerClosed {
404			return fmt.Errorf("server failed: %w", err)
405		}
406		return nil
407	}
408
409	func (s *Server) Shutdown(ctx context.Context) {
410		shutdownStart := time.Now()
411
412		natsStart := time.Now()
413		nc := nats.GetClient()
414		if nc != nil {
415			nc.Close()
416		}
417		cclog.Infof("Shutdown: NATS closed (%v)", time.Since(natsStart))
418
419		httpStart := time.Now()
420		shutdownCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
421		defer cancel()
422		if err := s.server.Shutdown(shutdownCtx); err != nil {
423			cclog.Errorf("Server shutdown error: %v", err)
424		}
425		cclog.Infof("Shutdown: HTTP server stopped (%v)", time.Since(httpStart))
426
427		// Run metricstore and archiver shutdown concurrently.
428		// They are independent: metricstore writes .bin snapshots,
429		// archiver flushes pending job archives.
430		storeStart := time.Now()
431		done := make(chan struct{})
432		go func() {
433			defer close(done)
434			var wg sync.WaitGroup
435
436			if ms := metricstore.GetMemoryStore(); ms != nil {
437				wg.Go(func() {
438					metricstore.Shutdown()
439				})
440			}
441
442			wg.Go(func() {
443				if err := archiver.Shutdown(60 * time.Second); err != nil {
444					cclog.Warnf("Archiver shutdown: %v", err)
445				}
446			})
447
448			wg.Wait()
449		}()
450
451		select {
452		case <-done:
453			cclog.Infof("Shutdown: metricstore + archiver completed (%v)", time.Since(storeStart))
454		case <-time.After(60 * time.Second):
455			cclog.Warnf("Shutdown deadline exceeded after %v, forcing exit", time.Since(shutdownStart))
456		}
457
458		cclog.Infof("Shutdown: total time %v", time.Since(shutdownStart))
459	}
460

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

// Package main provides the entry point for the ClusterCockpit backend server.
// This file contains HTTP server setup, routing configuration, and
// authentication middleware integration.
package main

import (
	"context"
	"crypto/tls"
	"encoding/json"
	"errors"
	"fmt"
	"net"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"

	"github.com/99designs/gqlgen/graphql"
	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/handler/transport"
	"github.com/99designs/gqlgen/graphql/playground"
	"github.com/ClusterCockpit/cc-backend/internal/api"
	"github.com/ClusterCockpit/cc-backend/internal/archiver"
	"github.com/ClusterCockpit/cc-backend/internal/auth"
	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/graph"
	"github.com/ClusterCockpit/cc-backend/internal/graph/generated"
	"github.com/ClusterCockpit/cc-backend/internal/routerConfig"
	"github.com/ClusterCockpit/cc-backend/pkg/metricstore"
	"github.com/ClusterCockpit/cc-backend/web"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/nats"
	"github.com/ClusterCockpit/cc-lib/v2/runtime"
	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/go-chi/cors"
	httpSwagger "github.com/swaggo/http-swagger"
)

var buildInfo web.Build

// Environment variable names
const (
	envDebug = "DEBUG"
)

// Server encapsulates the HTTP server state and dependencies
type Server struct {
	router        chi.Router
	server        *http.Server
	restAPIHandle *api.RestAPI
	natsAPIHandle *api.NatsAPI
}

func onFailureResponse(rw http.ResponseWriter, r *http.Request, err error) {
	rw.Header().Add("Content-Type", "application/json")
	rw.WriteHeader(http.StatusUnauthorized)
	json.NewEncoder(rw).Encode(map[string]string{
		"status": http.StatusText(http.StatusUnauthorized),
		"error":  err.Error(),
	})
}

// NewServer creates and initializes a new Server instance
func NewServer(version, commit, buildDate string) (*Server, error) {
	buildInfo = web.Build{Version: version, Hash: commit, Buildtime: buildDate}

	s := &Server{
		router: chi.NewRouter(),
	}

	if err := s.init(); err != nil {
		return nil, err
	}

	return s, nil
}

func (s *Server) init() error {
	// Setup the http.Handler/Router used by the server
	graph.Init()
	resolver := graph.GetResolverInstance()
	graphQLServer := handler.New(
		generated.NewExecutableSchema(generated.Config{Resolvers: resolver}))

	graphQLServer.AddTransport(transport.POST{})

	// Inject a per-request stats cache so that grouped statistics queries
	// sharing the same (filter, groupBy) pair are executed only once.
	graphQLServer.AroundOperations(func(ctx context.Context, next graphql.OperationHandler) graphql.ResponseHandler {
		return next(graph.WithStatsGroupCache(ctx))
	})

	if os.Getenv(envDebug) != "1" {
		// Having this handler means that a error message is returned via GraphQL instead of the connection simply beeing closed.
		// The problem with this is that then, no more stacktrace is printed to stderr.
		graphQLServer.SetRecoverFunc(func(ctx context.Context, err any) error {
			switch e := err.(type) {
			case string:
				return fmt.Errorf("MAIN > Panic: %s", e)
			case error:
				return fmt.Errorf("MAIN > Panic caused by: %s", e.Error())
			}

			return errors.New("MAIN > Internal server error (panic)")
		})
	}

	authHandle := auth.GetAuthInstance()

	// Middleware must be defined before routes in chi
	s.router.Use(func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
			start := time.Now()
			ww := middleware.NewWrapResponseWriter(rw, r.ProtoMajor)
			next.ServeHTTP(ww, r)
			cclog.Debugf("%s %s (%d, %.02fkb, %dms)",
				r.Method, r.URL.RequestURI(),
				ww.Status(), float32(ww.BytesWritten())/1024,
				time.Since(start).Milliseconds())
		})
	})
	s.router.Use(middleware.Compress(5))
	s.router.Use(middleware.Recoverer)
	s.router.Use(cors.Handler(cors.Options{
		AllowCredentials: true,
		AllowedHeaders:   []string{"X-Requested-With", "Content-Type", "Authorization", "Origin"},
		AllowedMethods:   []string{"GET", "POST", "HEAD", "OPTIONS"},
		AllowedOrigins:   []string{"*"},
	}))

	s.restAPIHandle = api.New()

	info := map[string]any{}
	info["hasOpenIDConnect"] = false

	if auth.Keys.OpenIDConfig != nil {
		openIDConnect := auth.NewOIDC(authHandle)
		openIDConnect.RegisterEndpoints(s.router)
		info["hasOpenIDConnect"] = true
	}

	s.router.Get("/login", func(rw http.ResponseWriter, r *http.Request) {
		rw.Header().Add("Content-Type", "text/html; charset=utf-8")
		cclog.Debugf("##%v##", info)
		web.RenderTemplate(rw, "login.tmpl", &web.Page{Title: "Login", Build: buildInfo, Infos: info})
	})
	s.router.HandleFunc("/imprint", func(rw http.ResponseWriter, r *http.Request) {
		rw.Header().Add("Content-Type", "text/html; charset=utf-8")
		web.RenderTemplate(rw, "imprint.tmpl", &web.Page{Title: "Imprint", Build: buildInfo})
	})
	s.router.HandleFunc("/privacy", func(rw http.ResponseWriter, r *http.Request) {
		rw.Header().Add("Content-Type", "text/html; charset=utf-8")
		web.RenderTemplate(rw, "privacy.tmpl", &web.Page{Title: "Privacy", Build: buildInfo})
	})

	if !config.Keys.DisableAuthentication {
		// Create login failure handler (used by both /login and /jwt-login)
		loginFailureHandler := func(rw http.ResponseWriter, r *http.Request, err error) {
			rw.Header().Add("Content-Type", "text/html; charset=utf-8")
			rw.WriteHeader(http.StatusUnauthorized)
			web.RenderTemplate(rw, "login.tmpl", &web.Page{
				Title:   "Login failed - ClusterCockpit",
				MsgType: "alert-warning",
				Message: err.Error(),
				Build:   buildInfo,
				Infos:   info,
			})
		}

		s.router.Post("/login", authHandle.Login(loginFailureHandler).ServeHTTP)
		s.router.HandleFunc("/jwt-login", authHandle.Login(loginFailureHandler).ServeHTTP)

		s.router.Post("/logout", authHandle.Logout(
			http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
				rw.Header().Add("Content-Type", "text/html; charset=utf-8")
				rw.WriteHeader(http.StatusOK)
				web.RenderTemplate(rw, "login.tmpl", &web.Page{
					Title:   "Bye - ClusterCockpit",
					MsgType: "alert-info",
					Message: "Logout successful",
					Build:   buildInfo,
					Infos:   info,
				})
			})).ServeHTTP)
	}

	if flagDev {
		s.router.Handle("/playground", playground.Handler("GraphQL playground", "/query"))
		s.router.Get("/swagger/*", httpSwagger.Handler(
			httpSwagger.URL("http://"+config.Keys.Addr+"/swagger/doc.json")))
	}

	// Secured routes (require authentication)
	s.router.Group(func(secured chi.Router) {
		if !config.Keys.DisableAuthentication {
			secured.Use(func(next http.Handler) http.Handler {
				return authHandle.Auth(
					next,
					func(rw http.ResponseWriter, r *http.Request, err error) {
						rw.WriteHeader(http.StatusUnauthorized)
						web.RenderTemplate(rw, "login.tmpl", &web.Page{
							Title:    "Authentication failed - ClusterCockpit",
							MsgType:  "alert-danger",
							Message:  err.Error(),
							Build:    buildInfo,
							Infos:    info,
							Redirect: r.RequestURI,
						})
					})
			})
		}

		secured.Handle("/query", graphQLServer)

		secured.HandleFunc("/search", func(rw http.ResponseWriter, r *http.Request) {
			routerConfig.HandleSearchBar(rw, r, buildInfo)
		})

		routerConfig.SetupRoutes(secured, buildInfo)
	})

	// API routes (JWT token auth)
	s.router.Route("/api", func(apiRouter chi.Router) {
		// Main API routes with API auth
		apiRouter.Group(func(securedapi chi.Router) {
			if !config.Keys.DisableAuthentication {
				securedapi.Use(func(next http.Handler) http.Handler {
					return authHandle.AuthAPI(next, onFailureResponse)
				})
			}
			s.restAPIHandle.MountAPIRoutes(securedapi)
		})

		// Metric store API routes with separate auth
		apiRouter.Group(func(metricstoreapi chi.Router) {
			if !config.Keys.DisableAuthentication {
				metricstoreapi.Use(func(next http.Handler) http.Handler {
					return authHandle.AuthMetricStoreAPI(next, onFailureResponse)
				})
			}
			s.restAPIHandle.MountMetricStoreAPIRoutes(metricstoreapi)
		})
	})

	// User API routes
	s.router.Route("/userapi", func(userapi chi.Router) {
		if !config.Keys.DisableAuthentication {
			userapi.Use(func(next http.Handler) http.Handler {
				return authHandle.AuthUserAPI(next, onFailureResponse)
			})
		}
		s.restAPIHandle.MountUserAPIRoutes(userapi)
	})

	// Config API routes (uses Group with full paths to avoid shadowing
	// the /config page route that is registered in the secured group)
	s.router.Group(func(configapi chi.Router) {
		if !config.Keys.DisableAuthentication {
			configapi.Use(func(next http.Handler) http.Handler {
				return authHandle.AuthConfigAPI(next, onFailureResponse)
			})
		}
		s.restAPIHandle.MountConfigAPIRoutes(configapi)
	})

	// Frontend API routes
	s.router.Route("/frontend", func(frontendapi chi.Router) {
		if !config.Keys.DisableAuthentication {
			frontendapi.Use(func(next http.Handler) http.Handler {
				return authHandle.AuthFrontendAPI(next, onFailureResponse)
			})
		}
		s.restAPIHandle.MountFrontendAPIRoutes(frontendapi)
	})

	if config.Keys.APISubjects != nil {
		s.natsAPIHandle = api.NewNatsAPI()
		if err := s.natsAPIHandle.StartSubscriptions(); err != nil {
			return fmt.Errorf("starting NATS subscriptions: %w", err)
		}
	}

	// 404 handler for pages and API routes
	notFoundHandler := func(rw http.ResponseWriter, r *http.Request) {
		if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/userapi/") ||
			strings.HasPrefix(r.URL.Path, "/frontend/") || strings.HasPrefix(r.URL.Path, "/config/") {
			rw.Header().Set("Content-Type", "application/json")
			rw.WriteHeader(http.StatusNotFound)
			json.NewEncoder(rw).Encode(map[string]string{
				"status": "Resource not found",
				"error":  "the requested endpoint does not exist",
			})
			return
		}
		rw.Header().Set("Content-Type", "text/html; charset=utf-8")
		rw.WriteHeader(http.StatusNotFound)
		web.RenderTemplate(rw, "404.tmpl", &web.Page{
			Title: "Page Not Found",
			Build: buildInfo,
		})
	}

	// Set NotFound on the router so chi uses it for all unmatched routes,
	// including those under subrouters like /api, /userapi, /frontend, etc.
	s.router.NotFound(notFoundHandler)

	if config.Keys.EmbedStaticFiles {
		if i, err := os.Stat("./var/img"); err == nil {
			if i.IsDir() {
				cclog.Info("Use local directory for static images")
				s.router.Handle("/img/*", http.StripPrefix("/img/", http.FileServer(http.Dir("./var/img"))))
			}
		}
		fileServer := http.StripPrefix("/", web.ServeFiles())
		s.router.Handle("/*", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
			if web.StaticFileExists(r.URL.Path) {
				fileServer.ServeHTTP(rw, r)
				return
			}
			notFoundHandler(rw, r)
		}))
	} else {
		staticDir := http.Dir(config.Keys.StaticFiles)
		fileServer := http.FileServer(staticDir)
		s.router.Handle("/*", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
			f, err := staticDir.Open(r.URL.Path)
			if err == nil {
				f.Close()
				fileServer.ServeHTTP(rw, r)
				return
			}
			notFoundHandler(rw, r)
		}))
	}

	return nil
}

// Server timeout defaults (in seconds)
const (
	defaultReadHeaderTimeout = 20
	defaultWriteTimeout      = 20
)

func (s *Server) Start(ctx context.Context) error {
	// Use configurable timeouts with defaults
	readHeaderTimeout := time.Duration(defaultReadHeaderTimeout) * time.Second
	writeTimeout := time.Duration(defaultWriteTimeout) * time.Second

	s.server = &http.Server{
		ReadHeaderTimeout: readHeaderTimeout,
		WriteTimeout:      writeTimeout,
		Handler:           s.router,
		Addr:              config.Keys.Addr,
	}

	// Start http or https server
	listener, err := net.Listen("tcp", config.Keys.Addr)
	if err != nil {
		return fmt.Errorf("starting listener on '%s': %w", config.Keys.Addr, err)
	}

	if !strings.HasSuffix(config.Keys.Addr, ":80") && config.Keys.RedirectHTTPTo != "" {
		go func() {
			http.ListenAndServe(":80", http.RedirectHandler(config.Keys.RedirectHTTPTo, http.StatusMovedPermanently))
		}()
	}

	if config.Keys.HTTPSCertFile != "" && config.Keys.HTTPSKeyFile != "" {
		cert, err := tls.LoadX509KeyPair(
			config.Keys.HTTPSCertFile, config.Keys.HTTPSKeyFile)
		if err != nil {
			return fmt.Errorf("loading X509 keypair (check 'https-cert-file' and 'https-key-file' in config.json): %w", err)
		}
		listener = tls.NewListener(listener, &tls.Config{
			Certificates: []tls.Certificate{cert},
			CipherSuites: []uint16{
				tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
				tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
			},
			MinVersion:               tls.VersionTLS12,
			PreferServerCipherSuites: true,
		})
		cclog.Infof("HTTPS server listening at %s...", config.Keys.Addr)
	} else {
		cclog.Infof("HTTP server listening at %s...", config.Keys.Addr)
	}
	//
	// Because this program will want to bind to a privileged port (like 80), the listener must
	// be established first, then the user can be changed, and after that,
	// the actual http server can be started.
	if err := runtime.DropPrivileges(config.Keys.Group, config.Keys.User); err != nil {
		return fmt.Errorf("dropping privileges: %w", err)
	}

	if err = s.server.Serve(listener); err != nil && err != http.ErrServerClosed {
		return fmt.Errorf("server failed: %w", err)
	}
	return nil
}

func (s *Server) Shutdown(ctx context.Context) {
	shutdownStart := time.Now()

	natsStart := time.Now()
	nc := nats.GetClient()
	if nc != nil {
		nc.Close()
	}
	cclog.Infof("Shutdown: NATS closed (%v)", time.Since(natsStart))

	httpStart := time.Now()
	shutdownCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
	defer cancel()
	if err := s.server.Shutdown(shutdownCtx); err != nil {
		cclog.Errorf("Server shutdown error: %v", err)
	}
	cclog.Infof("Shutdown: HTTP server stopped (%v)", time.Since(httpStart))

	// Run metricstore and archiver shutdown concurrently.
	// They are independent: metricstore writes .bin snapshots,
	// archiver flushes pending job archives.
	storeStart := time.Now()
	done := make(chan struct{})
	go func() {
		defer close(done)
		var wg sync.WaitGroup

		if ms := metricstore.GetMemoryStore(); ms != nil {
			wg.Go(func() {
				metricstore.Shutdown()
			})
		}

		wg.Go(func() {
			if err := archiver.Shutdown(60 * time.Second); err != nil {
				cclog.Warnf("Archiver shutdown: %v", err)
			}
		})

		wg.Wait()
	}()

	select {
	case <-done:
		cclog.Infof("Shutdown: metricstore + archiver completed (%v)", time.Since(storeStart))
	case <-time.After(60 * time.Second):
		cclog.Warnf("Shutdown deadline exceeded after %v, forcing exit", time.Since(shutdownStart))
	}

	cclog.Infof("Shutdown: total time %v", time.Since(shutdownStart))
}


1	// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
2	// All rights reserved. This file is part of cc-backend.
3	// Use of this source code is governed by a MIT-style
4	// license that can be found in the LICENSE file.
5
6	package api
7
8	import (
9		"database/sql"
10		"encoding/json"
11		"maps"
12		"strings"
13		"sync"
14		"time"
15
16		"github.com/ClusterCockpit/cc-backend/internal/archiver"
17		"github.com/ClusterCockpit/cc-backend/internal/config"
18		"github.com/ClusterCockpit/cc-backend/internal/importer"
19		"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
20		"github.com/ClusterCockpit/cc-backend/internal/repository"
21		"github.com/ClusterCockpit/cc-backend/pkg/archive"
22		"github.com/ClusterCockpit/cc-backend/pkg/metricstore"
23		cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
24		lp "github.com/ClusterCockpit/cc-lib/v2/ccMessage"
25		"github.com/ClusterCockpit/cc-lib/v2/nats"
26		"github.com/ClusterCockpit/cc-lib/v2/receivers"
27		"github.com/ClusterCockpit/cc-lib/v2/schema"
28		influx "github.com/ClusterCockpit/cc-line-protocol/v2/lineprotocol"
29	)
30
31	// natsMessage wraps a raw NATS message with its subject for channel-based processing.
32	type natsMessage struct {
33		subject string
34		data    []byte
35	}
36
37	// NatsAPI provides NATS subscription-based handlers for Job and Node operations.
38	// It mirrors the functionality of the REST API but uses NATS messaging with
39	// InfluxDB line protocol as the message format.
40	//
41	// # Message Format
42	//
43	// All NATS messages use InfluxDB line protocol format (https://docs.influxdata.com/influxdb/v2.0/reference/syntax/line-protocol/)
44	// with the following structure:
45	//
46	//	measurement,tag1=value1,tag2=value2 field1=value1,field2=value2 timestamp
47	//
48	// # Job Events
49	//
50	// Job start/stop events use the "job" measurement with a "function" tag to distinguish operations:
51	//
52	//	job,function=start_job event="{...JSON payload...}" <timestamp>
53	//	job,function=stop_job event="{...JSON payload...}" <timestamp>
54	//
55	// The JSON payload in the "event" field follows the schema.Job or StopJobAPIRequest structure.
56	//
57	// Example job start message:
58	//
59	//	job,function=start_job event="{\"jobId\":1001,\"user\":\"testuser\",\"cluster\":\"testcluster\",...}" 1234567890000000000
60	//
61	// # Node State Events
62	//
63	// Node state updates use the "nodestate" measurement with cluster information:
64	//
65	//	nodestate event="{...JSON payload...}" <timestamp>
66	//
67	// The JSON payload follows the UpdateNodeStatesRequest structure.
68	//
69	// Example node state message:
70	//
71	//	nodestate event="{\"cluster\":\"testcluster\",\"nodes\":[{\"hostname\":\"node01\",\"states\":[\"idle\"]}]}" 1234567890000000000
72	type NatsAPI struct {
73		JobRepository *repository.JobRepository
74		// RepositoryMutex protects job creation operations from race conditions
75		// when checking for duplicate jobs during startJob calls.
76		RepositoryMutex sync.Mutex
77		// jobCh receives job event messages for processing by worker goroutines.
78		jobCh chan natsMessage
79		// nodeCh receives node state messages for processing by worker goroutines.
80		nodeCh chan natsMessage
81	}
82
83	// NewNatsAPI creates a new NatsAPI instance with channel-based worker pools.
84	// Concurrency is configured via NATSConfig (defaults: JobConcurrency=8, NodeConcurrency=2).
85	func NewNatsAPI() *NatsAPI {
86		jobConc := 8
87		nodeConc := 2
88
89		if s := config.Keys.APISubjects; s != nil {
90			if s.JobConcurrency > 0 {
91				jobConc = s.JobConcurrency
92			}
93			if s.NodeConcurrency > 0 {
94				nodeConc = s.NodeConcurrency
95			}
96		}
97
98		api := &NatsAPI{
99			JobRepository: repository.GetJobRepository(),
100			jobCh:         make(chan natsMessage, jobConc),
101			nodeCh:        make(chan natsMessage, nodeConc),
102		}
103
104		// Start worker goroutines
105		for range jobConc {
106			go api.jobWorker()
107		}
108		for range nodeConc {
109			go api.nodeWorker()
110		}
111
112		return api
113	}
114
115	// jobWorker processes job event messages from the job channel.
116	func (api *NatsAPI) jobWorker() {
117		for msg := range api.jobCh {
118			api.handleJobEvent(msg.subject, msg.data)
119		}
120	}
121
122	// nodeWorker processes node state messages from the node channel.
123	func (api *NatsAPI) nodeWorker() {
124		for msg := range api.nodeCh {
125			api.handleNodeState(msg.subject, msg.data)
126		}
127	}
128
129	// StartSubscriptions registers all NATS subscriptions for Job and Node APIs.
130	// Messages are delivered to buffered channels and processed by worker goroutines.
131	// Returns an error if the NATS client is not available or subscription fails.
132	func (api *NatsAPI) StartSubscriptions() error {
133		client := nats.GetClient()
134		if client == nil {
135			cclog.Warn("NATS client not available, skipping API subscriptions")
136			return nil
137		}
138
139		if config.Keys.APISubjects != nil {
140			s := config.Keys.APISubjects
141
142			if err := client.Subscribe(s.SubjectJobEvent, func(subject string, data []byte) {
143				api.jobCh <- natsMessage{subject: subject, data: data}
144			}); err != nil {
145				return err
146			}
147
148			if err := client.Subscribe(s.SubjectNodeState, func(subject string, data []byte) {
149				api.nodeCh <- natsMessage{subject: subject, data: data}
150			}); err != nil {
151				return err
152			}
153
154			cclog.Info("NATS API subscriptions started")
155		}
156		return nil
157	}
158
159	// processJobEvent routes job event messages to the appropriate handler based on the "function" tag.
160	// Validates that required tags and fields are present before processing.
161	func (api *NatsAPI) processJobEvent(msg lp.CCMessage) {
162		function, ok := msg.GetTag("function")
163		if !ok {
164			cclog.Errorf("Job event is missing required tag 'function': measurement=%s", msg.Name())
165			return
166		}
167
168		switch function {
169		case "start_job":
170			v, ok := msg.GetEventValue()
171			if !ok {
172				cclog.Errorf("Job start event is missing event field with JSON payload")
173				return
174			}
175			api.handleStartJob(v)
176
177		case "stop_job":
178			v, ok := msg.GetEventValue()
179			if !ok {
180				cclog.Errorf("Job stop event is missing event field with JSON payload")
181				return
182			}
183			api.handleStopJob(v)
184
185		default:
186			cclog.Warnf("Unknown job event function '%s', expected 'start_job' or 'stop_job'", function)
187		}
188	}
189
190	// handleJobEvent processes job-related messages received via NATS using InfluxDB line protocol.
191	// The message must be in line protocol format with measurement="job" and include:
192	//   - tag "function" with value "start_job" or "stop_job"
193	//   - field "event" containing JSON payload (schema.Job or StopJobAPIRequest)
194	//
195	// Example: job,function=start_job event="{\"jobId\":1001,...}" 1234567890000000000
196	func (api *NatsAPI) handleJobEvent(subject string, data []byte) {
197		if len(data) == 0 {
198			cclog.Warnf("NATS %s: received empty message", subject)
199			return
200		}
201
202		d := influx.NewDecoderWithBytes(data)
203
204		for d.Next() {
205			m, err := receivers.DecodeInfluxMessage(d)
206			if err != nil {
207				cclog.Errorf("NATS %s: failed to decode InfluxDB line protocol message: %v", subject, err)
208				return
209			}
210
211			if !m.IsEvent() {
212				cclog.Debugf("NATS %s: received non-event message, skipping", subject)
213				continue
214			}
215
216			if m.Name() == "job" {
217				api.processJobEvent(m)
218			} else {
219				cclog.Debugf("NATS %s: unexpected measurement name '%s', expected 'job'", subject, m.Name())
220			}
221		}
222	}
223
224	// handleStartJob processes job start messages received via NATS.
225	// The payload parameter contains JSON following the schema.Job structure.
226	// Jobs are validated, checked for duplicates, and inserted into the database.
227	func (api *NatsAPI) handleStartJob(payload string) {
228		if payload == "" {
229			cclog.Error("NATS start job: payload is empty")
230			return
231		}
232		req := schema.Job{
233			Shared:           "none",
234			MonitoringStatus: schema.MonitoringStatusRunningOrArchiving,
235		}
236
237		dec := json.NewDecoder(strings.NewReader(payload))
238		dec.DisallowUnknownFields()
239		if err := dec.Decode(&req); err != nil {
240			cclog.Errorf("NATS start job: parsing request failed: %v", err)
241			return
242		}
243
244		cclog.Debugf("NATS start job: %s", req.GoString())
245		req.State = schema.JobStateRunning
246
247		if err := importer.SanityChecks(&req); err != nil {
248			cclog.Errorf("NATS start job: sanity check failed: %v", err)
249			return
250		}
251
252		var unlockOnce sync.Once
253		api.RepositoryMutex.Lock()
254		defer unlockOnce.Do(api.RepositoryMutex.Unlock)
255
256		jobs, err := api.JobRepository.FindAll(&req.JobID, &req.Cluster, nil)
257		if err != nil && err != sql.ErrNoRows {
258			cclog.Errorf("NATS start job: checking for duplicate failed: %v", err)
259			return
260		}
261		if err == nil {
262			for _, job := range jobs {
263				if (req.StartTime - job.StartTime) < secondsPerDay {
264					cclog.Errorf("NATS start job: job with jobId %d, cluster %s already exists (dbid: %d)",
265						req.JobID, req.Cluster, job.ID)
266					return
267				}
268			}
269		}
270
271		// When tags are present, insert directly into the job table so that the
272		// returned ID can be used with AddTagOrCreate (which queries the job table).
273		var id int64
274		if len(req.Tags) > 0 {
275			id, err = api.JobRepository.StartDirect(&req)
276		} else {
277			id, err = api.JobRepository.Start(&req)
278		}
279		if err != nil {
280			cclog.Errorf("NATS start job: insert into database failed: %v", err)
281			return
282		}
283		unlockOnce.Do(api.RepositoryMutex.Unlock)
284
285		for _, tag := range req.Tags {
286			if _, err := api.JobRepository.AddTagOrCreate(nil, id, tag.Type, tag.Name, tag.Scope); err != nil {
287				cclog.Errorf("NATS start job: adding tag to new job %d failed: %v", id, err)
288				return
289			}
290		}
291
292		cclog.Infof("NATS: new job (id: %d): cluster=%s, jobId=%d, user=%s, startTime=%d",
293			id, req.Cluster, req.JobID, req.User, req.StartTime)
294	}
295
296	// handleStopJob processes job stop messages received via NATS.
297	// The payload parameter contains JSON following the StopJobAPIRequest structure.
298	// The job is marked as stopped in the database and archiving is triggered if monitoring is enabled.
299	func (api *NatsAPI) handleStopJob(payload string) {
300		if payload == "" {
301			cclog.Error("NATS stop job: payload is empty")
302			return
303		}
304		var req StopJobAPIRequest
305
306		dec := json.NewDecoder(strings.NewReader(payload))
307		dec.DisallowUnknownFields()
308		if err := dec.Decode(&req); err != nil {
309			cclog.Errorf("NATS job stop: parsing request failed: %v", err)
310			return
311		}
312
313		if req.JobID == nil {
314			cclog.Errorf("NATS job stop: the field 'jobId' is required")
315			return
316		}
317
318		isCached := false
319		job, err := api.JobRepository.FindCached(req.JobID, req.Cluster, req.StartTime)
320		if err != nil {
321			// Not in cache, try main job table
322			job, err = api.JobRepository.Find(req.JobID, req.Cluster, req.StartTime)
323			if err != nil {
324				cclog.Errorf("NATS job stop: finding job failed: %v", err)
325				return
326			}
327		} else {
328			isCached = true
329		}
330
331		if job.State != schema.JobStateRunning {
332			cclog.Errorf("NATS job stop: jobId %d (id %d) on %s: job has already been stopped (state is: %s)",
333				job.JobID, job.ID, job.Cluster, job.State)
334			return
335		}
336
337		if job.StartTime > req.StopTime {
338			cclog.Errorf("NATS job stop: jobId %d (id %d) on %s: stopTime %d must be >= startTime %d",
339				job.JobID, job.ID, job.Cluster, req.StopTime, job.StartTime)
340			return
341		}
342
343		if req.State != "" && !req.State.Valid() {
344			cclog.Errorf("NATS job stop: jobId %d (id %d) on %s: invalid job state: %#v",
345				job.JobID, job.ID, job.Cluster, req.State)
346			return
347		} else if req.State == "" {
348			req.State = schema.JobStateCompleted
349		}
350
351		job.Duration = int32(req.StopTime - job.StartTime)
352		job.State = req.State
353		api.JobRepository.Mutex.Lock()
354		defer api.JobRepository.Mutex.Unlock()
355
356		// If the job is still in job_cache, transfer it to the job table first
357		if isCached {
358			newID, err := api.JobRepository.TransferCachedJobToMain(*job.ID)
359			if err != nil {
360				cclog.Errorf("NATS job stop: jobId %d (id %d) on %s: transferring cached job failed: %v",
361					job.JobID, *job.ID, job.Cluster, err)
362				return
363			}
364			cclog.Infof("NATS: transferred cached job to main table: old id %d -> new id %d (jobId=%d)", *job.ID, newID, job.JobID)
365			job.ID = &newID
366		}
367
368		if err := api.JobRepository.Stop(*job.ID, job.Duration, job.State, job.MonitoringStatus); err != nil {
369			cclog.Errorf("NATS job stop: jobId %d (id %d) on %s: marking job as '%s' failed: %v",
370				job.JobID, *job.ID, job.Cluster, job.State, err)
371			return
372		}
373
374		cclog.Infof("NATS: archiving job (dbid: %d): cluster=%s, jobId=%d, user=%s, startTime=%d, duration=%d, state=%s",
375			*job.ID, job.Cluster, job.JobID, job.User, job.StartTime, job.Duration, job.State)
376
377		if job.MonitoringStatus == schema.MonitoringStatusDisabled {
378			return
379		}
380
381		archiver.TriggerArchiving(job)
382	}
383
384	// processNodestateEvent extracts and processes node state data from the InfluxDB message.
385	// Updates node states in the repository for all nodes in the payload.
386	func (api *NatsAPI) processNodestateEvent(msg lp.CCMessage) {
387		v, ok := msg.GetEventValue()
388		if !ok {
389			cclog.Errorf("Nodestate event is missing event field with JSON payload")
390			return
391		}
392
393		var req UpdateNodeStatesRequest
394
395		dec := json.NewDecoder(strings.NewReader(v))
396		dec.DisallowUnknownFields()
397		if err := dec.Decode(&req); err != nil {
398			cclog.Errorf("NATS nodestate: parsing request failed: %v", err)
399			return
400		}
401
402		repo := repository.GetNodeRepository()
403		requestReceived := time.Now().Unix()
404
405		// Pre-compute node states; only include non-down nodes in health check
406		nodeStates := make(map[string]schema.SchedulerState, len(req.Nodes))
407		for _, node := range req.Nodes {
408			nodeStates[node.Hostname] = determineState(node.States)
409		}
410
411		// Build nodeList per subcluster for health check, skipping down nodes
412		m := make(map[string][]string)
413		metricNames := make(map[string][]string)
414		healthResults := make(map[string]metricstore.HealthCheckResult)
415
416		for _, node := range req.Nodes {
417			if nodeStates[node.Hostname] == schema.NodeStateDown {
418				continue
419			}
420			if sc, err := archive.GetSubClusterByNode(req.Cluster, node.Hostname); err == nil {
421				m[sc] = append(m[sc], node.Hostname)
422			}
423		}
424
425		for sc := range m {
426			if sc != "" {
427				metricList := archive.GetMetricConfigSubCluster(req.Cluster, sc)
428				metricNames[sc] = metricListToNames(metricList)
429			}
430		}
431
432		// Perform health check against metric store
433		healthRepo, err := metricdispatch.GetHealthCheckRepo(req.Cluster)
434		if err != nil {
435			cclog.Warnf("NATS nodestate: no metric store for cluster %s, skipping health check: %v", req.Cluster, err)
436		} else {
437			for sc, nl := range m {
438				if sc != "" {
439					if results, err := healthRepo.HealthCheck(req.Cluster, nl, metricNames[sc]); err == nil {
440						maps.Copy(healthResults, results)
441					}
442				}
443			}
444		}
445
446		updates := make([]repository.NodeStateUpdate, 0, len(req.Nodes))
447		for _, node := range req.Nodes {
448			state := nodeStates[node.Hostname]
449			var healthState schema.MonitoringState
450			var healthMetrics string
451			if state == schema.NodeStateDown {
452				healthState = schema.MonitoringStateFull
453			} else {
454				healthState = schema.MonitoringStateFailed
455				if result, ok := healthResults[node.Hostname]; ok {
456					healthState = result.State
457					healthMetrics = result.HealthMetrics
458				}
459			}
460			nodeState := schema.NodeStateDB{
461				TimeStamp:       requestReceived,
462				NodeState:       state,
463				CpusAllocated:   node.CpusAllocated,
464				MemoryAllocated: node.MemoryAllocated,
465				GpusAllocated:   node.GpusAllocated,
466				HealthState:     healthState,
467				HealthMetrics:   healthMetrics,
468				JobsRunning:     node.JobsRunning,
469			}
470			updates = append(updates, repository.NodeStateUpdate{
471				Hostname:  node.Hostname,
472				Cluster:   req.Cluster,
473				NodeState: &nodeState,
474			})
475		}
476
477		if err := repo.BatchUpdateNodeStates(updates); err != nil {
478			cclog.Errorf("NATS nodestate: batch update for cluster %s failed: %v", req.Cluster, err)
479		}
480
481		cclog.Debugf("NATS nodestate: updated %d node states for cluster %s", len(req.Nodes), req.Cluster)
482	}
483
484	// handleNodeState processes node state update messages received via NATS using InfluxDB line protocol.
485	// The message must be in line protocol format with measurement="nodestate" and include:
486	//   - field "event" containing JSON payload (UpdateNodeStatesRequest)
487	//
488	// Example: nodestate event="{\"cluster\":\"testcluster\",\"nodes\":[...]}" 1234567890000000000
489	func (api *NatsAPI) handleNodeState(subject string, data []byte) {
490		if len(data) == 0 {
491			cclog.Warnf("NATS %s: received empty message", subject)
492			return
493		}
494
495		d := influx.NewDecoderWithBytes(data)
496
497		for d.Next() {
498			m, err := receivers.DecodeInfluxMessage(d)
499			if err != nil {
500				cclog.Errorf("NATS %s: failed to decode InfluxDB line protocol message: %v", subject, err)
501				return
502			}
503
504			if !m.IsEvent() {
505				cclog.Warnf("NATS %s: received non-event message, skipping", subject)
506				continue
507			}
508
509			if m.Name() == "nodestate" {
510				api.processNodestateEvent(m)
511			} else {
512				cclog.Warnf("NATS %s: unexpected measurement name '%s', expected 'nodestate'", subject, m.Name())
513			}
514		}
515	}
516

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package api

import (
	"database/sql"
	"encoding/json"
	"maps"
	"strings"
	"sync"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/archiver"
	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/importer"
	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/pkg/archive"
	"github.com/ClusterCockpit/cc-backend/pkg/metricstore"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	lp "github.com/ClusterCockpit/cc-lib/v2/ccMessage"
	"github.com/ClusterCockpit/cc-lib/v2/nats"
	"github.com/ClusterCockpit/cc-lib/v2/receivers"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	influx "github.com/ClusterCockpit/cc-line-protocol/v2/lineprotocol"
)

// natsMessage wraps a raw NATS message with its subject for channel-based processing.
type natsMessage struct {
	subject string
	data    []byte
}

// NatsAPI provides NATS subscription-based handlers for Job and Node operations.
// It mirrors the functionality of the REST API but uses NATS messaging with
// InfluxDB line protocol as the message format.
//
// # Message Format
//
// All NATS messages use InfluxDB line protocol format (https://docs.influxdata.com/influxdb/v2.0/reference/syntax/line-protocol/)
// with the following structure:
//
//	measurement,tag1=value1,tag2=value2 field1=value1,field2=value2 timestamp
//
// # Job Events
//
// Job start/stop events use the "job" measurement with a "function" tag to distinguish operations:
//
//	job,function=start_job event="{...JSON payload...}" <timestamp>
//	job,function=stop_job event="{...JSON payload...}" <timestamp>
//
// The JSON payload in the "event" field follows the schema.Job or StopJobAPIRequest structure.
//
// Example job start message:
//
//	job,function=start_job event="{\"jobId\":1001,\"user\":\"testuser\",\"cluster\":\"testcluster\",...}" 1234567890000000000
//
// # Node State Events
//
// Node state updates use the "nodestate" measurement with cluster information:
//
//	nodestate event="{...JSON payload...}" <timestamp>
//
// The JSON payload follows the UpdateNodeStatesRequest structure.
//
// Example node state message:
//
//	nodestate event="{\"cluster\":\"testcluster\",\"nodes\":[{\"hostname\":\"node01\",\"states\":[\"idle\"]}]}" 1234567890000000000
type NatsAPI struct {
	JobRepository *repository.JobRepository
	// RepositoryMutex protects job creation operations from race conditions
	// when checking for duplicate jobs during startJob calls.
	RepositoryMutex sync.Mutex
	// jobCh receives job event messages for processing by worker goroutines.
	jobCh chan natsMessage
	// nodeCh receives node state messages for processing by worker goroutines.
	nodeCh chan natsMessage
}

// NewNatsAPI creates a new NatsAPI instance with channel-based worker pools.
// Concurrency is configured via NATSConfig (defaults: JobConcurrency=8, NodeConcurrency=2).
func NewNatsAPI() *NatsAPI {
	jobConc := 8
	nodeConc := 2

	if s := config.Keys.APISubjects; s != nil {
		if s.JobConcurrency > 0 {
			jobConc = s.JobConcurrency
		}
		if s.NodeConcurrency > 0 {
			nodeConc = s.NodeConcurrency
		}
	}

	api := &NatsAPI{
		JobRepository: repository.GetJobRepository(),
		jobCh:         make(chan natsMessage, jobConc),
		nodeCh:        make(chan natsMessage, nodeConc),
	}

	// Start worker goroutines
	for range jobConc {
		go api.jobWorker()
	}
	for range nodeConc {
		go api.nodeWorker()
	}

	return api
}

// jobWorker processes job event messages from the job channel.
func (api *NatsAPI) jobWorker() {
	for msg := range api.jobCh {
		api.handleJobEvent(msg.subject, msg.data)
	}
}

// nodeWorker processes node state messages from the node channel.
func (api *NatsAPI) nodeWorker() {
	for msg := range api.nodeCh {
		api.handleNodeState(msg.subject, msg.data)
	}
}

// StartSubscriptions registers all NATS subscriptions for Job and Node APIs.
// Messages are delivered to buffered channels and processed by worker goroutines.
// Returns an error if the NATS client is not available or subscription fails.
func (api *NatsAPI) StartSubscriptions() error {
	client := nats.GetClient()
	if client == nil {
		cclog.Warn("NATS client not available, skipping API subscriptions")
		return nil
	}

	if config.Keys.APISubjects != nil {
		s := config.Keys.APISubjects

		if err := client.Subscribe(s.SubjectJobEvent, func(subject string, data []byte) {
			api.jobCh <- natsMessage{subject: subject, data: data}
		}); err != nil {
			return err
		}

		if err := client.Subscribe(s.SubjectNodeState, func(subject string, data []byte) {
			api.nodeCh <- natsMessage{subject: subject, data: data}
		}); err != nil {
			return err
		}

		cclog.Info("NATS API subscriptions started")
	}
	return nil
}

// processJobEvent routes job event messages to the appropriate handler based on the "function" tag.
// Validates that required tags and fields are present before processing.
func (api *NatsAPI) processJobEvent(msg lp.CCMessage) {
	function, ok := msg.GetTag("function")
	if !ok {
		cclog.Errorf("Job event is missing required tag 'function': measurement=%s", msg.Name())
		return
	}

	switch function {
	case "start_job":
		v, ok := msg.GetEventValue()
		if !ok {
			cclog.Errorf("Job start event is missing event field with JSON payload")
			return
		}
		api.handleStartJob(v)

	case "stop_job":
		v, ok := msg.GetEventValue()
		if !ok {
			cclog.Errorf("Job stop event is missing event field with JSON payload")
			return
		}
		api.handleStopJob(v)

	default:
		cclog.Warnf("Unknown job event function '%s', expected 'start_job' or 'stop_job'", function)
	}
}

// handleJobEvent processes job-related messages received via NATS using InfluxDB line protocol.
// The message must be in line protocol format with measurement="job" and include:
//   - tag "function" with value "start_job" or "stop_job"
//   - field "event" containing JSON payload (schema.Job or StopJobAPIRequest)
//
// Example: job,function=start_job event="{\"jobId\":1001,...}" 1234567890000000000
func (api *NatsAPI) handleJobEvent(subject string, data []byte) {
	if len(data) == 0 {
		cclog.Warnf("NATS %s: received empty message", subject)
		return
	}

	d := influx.NewDecoderWithBytes(data)

	for d.Next() {
		m, err := receivers.DecodeInfluxMessage(d)
		if err != nil {
			cclog.Errorf("NATS %s: failed to decode InfluxDB line protocol message: %v", subject, err)
			return
		}

		if !m.IsEvent() {
			cclog.Debugf("NATS %s: received non-event message, skipping", subject)
			continue
		}

		if m.Name() == "job" {
			api.processJobEvent(m)
		} else {
			cclog.Debugf("NATS %s: unexpected measurement name '%s', expected 'job'", subject, m.Name())
		}
	}
}

// handleStartJob processes job start messages received via NATS.
// The payload parameter contains JSON following the schema.Job structure.
// Jobs are validated, checked for duplicates, and inserted into the database.
func (api *NatsAPI) handleStartJob(payload string) {
	if payload == "" {
		cclog.Error("NATS start job: payload is empty")
		return
	}
	req := schema.Job{
		Shared:           "none",
		MonitoringStatus: schema.MonitoringStatusRunningOrArchiving,
	}

	dec := json.NewDecoder(strings.NewReader(payload))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&req); err != nil {
		cclog.Errorf("NATS start job: parsing request failed: %v", err)
		return
	}

	cclog.Debugf("NATS start job: %s", req.GoString())
	req.State = schema.JobStateRunning

	if err := importer.SanityChecks(&req); err != nil {
		cclog.Errorf("NATS start job: sanity check failed: %v", err)
		return
	}

	var unlockOnce sync.Once
	api.RepositoryMutex.Lock()
	defer unlockOnce.Do(api.RepositoryMutex.Unlock)

	jobs, err := api.JobRepository.FindAll(&req.JobID, &req.Cluster, nil)
	if err != nil && err != sql.ErrNoRows {
		cclog.Errorf("NATS start job: checking for duplicate failed: %v", err)
		return
	}
	if err == nil {
		for _, job := range jobs {
			if (req.StartTime - job.StartTime) < secondsPerDay {
				cclog.Errorf("NATS start job: job with jobId %d, cluster %s already exists (dbid: %d)",
					req.JobID, req.Cluster, job.ID)
				return
			}
		}
	}

	// When tags are present, insert directly into the job table so that the
	// returned ID can be used with AddTagOrCreate (which queries the job table).
	var id int64
	if len(req.Tags) > 0 {
		id, err = api.JobRepository.StartDirect(&req)
	} else {
		id, err = api.JobRepository.Start(&req)
	}
	if err != nil {
		cclog.Errorf("NATS start job: insert into database failed: %v", err)
		return
	}
	unlockOnce.Do(api.RepositoryMutex.Unlock)

	for _, tag := range req.Tags {
		if _, err := api.JobRepository.AddTagOrCreate(nil, id, tag.Type, tag.Name, tag.Scope); err != nil {
			cclog.Errorf("NATS start job: adding tag to new job %d failed: %v", id, err)
			return
		}
	}

	cclog.Infof("NATS: new job (id: %d): cluster=%s, jobId=%d, user=%s, startTime=%d",
		id, req.Cluster, req.JobID, req.User, req.StartTime)
}

// handleStopJob processes job stop messages received via NATS.
// The payload parameter contains JSON following the StopJobAPIRequest structure.
// The job is marked as stopped in the database and archiving is triggered if monitoring is enabled.
func (api *NatsAPI) handleStopJob(payload string) {
	if payload == "" {
		cclog.Error("NATS stop job: payload is empty")
		return
	}
	var req StopJobAPIRequest

	dec := json.NewDecoder(strings.NewReader(payload))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&req); err != nil {
		cclog.Errorf("NATS job stop: parsing request failed: %v", err)
		return
	}

	if req.JobID == nil {
		cclog.Errorf("NATS job stop: the field 'jobId' is required")
		return
	}

	isCached := false
	job, err := api.JobRepository.FindCached(req.JobID, req.Cluster, req.StartTime)
	if err != nil {
		// Not in cache, try main job table
		job, err = api.JobRepository.Find(req.JobID, req.Cluster, req.StartTime)
		if err != nil {
			cclog.Errorf("NATS job stop: finding job failed: %v", err)
			return
		}
	} else {
		isCached = true
	}

	if job.State != schema.JobStateRunning {
		cclog.Errorf("NATS job stop: jobId %d (id %d) on %s: job has already been stopped (state is: %s)",
			job.JobID, job.ID, job.Cluster, job.State)
		return
	}

	if job.StartTime > req.StopTime {
		cclog.Errorf("NATS job stop: jobId %d (id %d) on %s: stopTime %d must be >= startTime %d",
			job.JobID, job.ID, job.Cluster, req.StopTime, job.StartTime)
		return
	}

	if req.State != "" && !req.State.Valid() {
		cclog.Errorf("NATS job stop: jobId %d (id %d) on %s: invalid job state: %#v",
			job.JobID, job.ID, job.Cluster, req.State)
		return
	} else if req.State == "" {
		req.State = schema.JobStateCompleted
	}

	job.Duration = int32(req.StopTime - job.StartTime)
	job.State = req.State
	api.JobRepository.Mutex.Lock()
	defer api.JobRepository.Mutex.Unlock()

	// If the job is still in job_cache, transfer it to the job table first
	if isCached {
		newID, err := api.JobRepository.TransferCachedJobToMain(*job.ID)
		if err != nil {
			cclog.Errorf("NATS job stop: jobId %d (id %d) on %s: transferring cached job failed: %v",
				job.JobID, *job.ID, job.Cluster, err)
			return
		}
		cclog.Infof("NATS: transferred cached job to main table: old id %d -> new id %d (jobId=%d)", *job.ID, newID, job.JobID)
		job.ID = &newID
	}

	if err := api.JobRepository.Stop(*job.ID, job.Duration, job.State, job.MonitoringStatus); err != nil {
		cclog.Errorf("NATS job stop: jobId %d (id %d) on %s: marking job as '%s' failed: %v",
			job.JobID, *job.ID, job.Cluster, job.State, err)
		return
	}

	cclog.Infof("NATS: archiving job (dbid: %d): cluster=%s, jobId=%d, user=%s, startTime=%d, duration=%d, state=%s",
		*job.ID, job.Cluster, job.JobID, job.User, job.StartTime, job.Duration, job.State)

	if job.MonitoringStatus == schema.MonitoringStatusDisabled {
		return
	}

	archiver.TriggerArchiving(job)
}

// processNodestateEvent extracts and processes node state data from the InfluxDB message.
// Updates node states in the repository for all nodes in the payload.
func (api *NatsAPI) processNodestateEvent(msg lp.CCMessage) {
	v, ok := msg.GetEventValue()
	if !ok {
		cclog.Errorf("Nodestate event is missing event field with JSON payload")
		return
	}

	var req UpdateNodeStatesRequest

	dec := json.NewDecoder(strings.NewReader(v))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&req); err != nil {
		cclog.Errorf("NATS nodestate: parsing request failed: %v", err)
		return
	}

	repo := repository.GetNodeRepository()
	requestReceived := time.Now().Unix()

	// Pre-compute node states; only include non-down nodes in health check
	nodeStates := make(map[string]schema.SchedulerState, len(req.Nodes))
	for _, node := range req.Nodes {
		nodeStates[node.Hostname] = determineState(node.States)
	}

	// Build nodeList per subcluster for health check, skipping down nodes
	m := make(map[string][]string)
	metricNames := make(map[string][]string)
	healthResults := make(map[string]metricstore.HealthCheckResult)

	for _, node := range req.Nodes {
		if nodeStates[node.Hostname] == schema.NodeStateDown {
			continue
		}
		if sc, err := archive.GetSubClusterByNode(req.Cluster, node.Hostname); err == nil {
			m[sc] = append(m[sc], node.Hostname)
		}
	}

	for sc := range m {
		if sc != "" {
			metricList := archive.GetMetricConfigSubCluster(req.Cluster, sc)
			metricNames[sc] = metricListToNames(metricList)
		}
	}

	// Perform health check against metric store
	healthRepo, err := metricdispatch.GetHealthCheckRepo(req.Cluster)
	if err != nil {
		cclog.Warnf("NATS nodestate: no metric store for cluster %s, skipping health check: %v", req.Cluster, err)
	} else {
		for sc, nl := range m {
			if sc != "" {
				if results, err := healthRepo.HealthCheck(req.Cluster, nl, metricNames[sc]); err == nil {
					maps.Copy(healthResults, results)
				}
			}
		}
	}

	updates := make([]repository.NodeStateUpdate, 0, len(req.Nodes))
	for _, node := range req.Nodes {
		state := nodeStates[node.Hostname]
		var healthState schema.MonitoringState
		var healthMetrics string
		if state == schema.NodeStateDown {
			healthState = schema.MonitoringStateFull
		} else {
			healthState = schema.MonitoringStateFailed
			if result, ok := healthResults[node.Hostname]; ok {
				healthState = result.State
				healthMetrics = result.HealthMetrics
			}
		}
		nodeState := schema.NodeStateDB{
			TimeStamp:       requestReceived,
			NodeState:       state,
			CpusAllocated:   node.CpusAllocated,
			MemoryAllocated: node.MemoryAllocated,
			GpusAllocated:   node.GpusAllocated,
			HealthState:     healthState,
			HealthMetrics:   healthMetrics,
			JobsRunning:     node.JobsRunning,
		}
		updates = append(updates, repository.NodeStateUpdate{
			Hostname:  node.Hostname,
			Cluster:   req.Cluster,
			NodeState: &nodeState,
		})
	}

	if err := repo.BatchUpdateNodeStates(updates); err != nil {
		cclog.Errorf("NATS nodestate: batch update for cluster %s failed: %v", req.Cluster, err)
	}

	cclog.Debugf("NATS nodestate: updated %d node states for cluster %s", len(req.Nodes), req.Cluster)
}

// handleNodeState processes node state update messages received via NATS using InfluxDB line protocol.
// The message must be in line protocol format with measurement="nodestate" and include:
//   - field "event" containing JSON payload (UpdateNodeStatesRequest)
//
// Example: nodestate event="{\"cluster\":\"testcluster\",\"nodes\":[...]}" 1234567890000000000
func (api *NatsAPI) handleNodeState(subject string, data []byte) {
	if len(data) == 0 {
		cclog.Warnf("NATS %s: received empty message", subject)
		return
	}

	d := influx.NewDecoderWithBytes(data)

	for d.Next() {
		m, err := receivers.DecodeInfluxMessage(d)
		if err != nil {
			cclog.Errorf("NATS %s: failed to decode InfluxDB line protocol message: %v", subject, err)
			return
		}

		if !m.IsEvent() {
			cclog.Warnf("NATS %s: received non-event message, skipping", subject)
			continue
		}

		if m.Name() == "nodestate" {
			api.processNodestateEvent(m)
		} else {
			cclog.Warnf("NATS %s: unexpected measurement name '%s', expected 'nodestate'", subject, m.Name())
		}
	}
}


Do a security audit of the authentication in this application.


Good — I've now confirmed the most critical findings directly. Let me verify a few more details on JWT signing, the token-generation endpoint, and the issuer check order.


1	// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
2	// All rights reserved. This file is part of cc-backend.
3	// Use of this source code is governed by a MIT-style
4	// license that can be found in the LICENSE file.
5
6	package auth
7
8	import (
9		"crypto/ed25519"
10		"encoding/base64"
11		"errors"
12		"net/http"
13		"os"
14		"strings"
15		"time"
16
17		cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
18		"github.com/ClusterCockpit/cc-lib/v2/schema"
19		"github.com/golang-jwt/jwt/v5"
20	)
21
22	type JWTAuthConfig struct {
23		// Specifies for how long a JWT token shall be valid
24		// as a string parsable by time.ParseDuration().
25		MaxAge string `json:"max-age"`
26
27		// Specifies which cookie should be checked for a JWT token (if no authorization header is present)
28		CookieName string `json:"cookie-name"`
29
30		// Deny login for users not in database (but defined in JWT).
31		// Ignore user roles defined in JWTs ('roles' claim), get them from db.
32		ValidateUser bool `json:"validate-user"`
33
34		// Specifies which issuer should be accepted when validating external JWTs ('iss' claim)
35		TrustedIssuer string `json:"trusted-issuer"`
36
37		// Should an non-existent user be added to the DB based on the information in the token
38		SyncUserOnLogin bool `json:"sync-user-on-login"`
39
40		// Should an existent user be updated in the DB based on the information in the token
41		UpdateUserOnLogin bool `json:"update-user-on-login"`
42	}
43
44	type JWTAuthenticator struct {
45		publicKey  ed25519.PublicKey
46		privateKey ed25519.PrivateKey
47	}
48
49	func (ja *JWTAuthenticator) Init() error {
50		pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY")
51		if pubKey == "" || privKey == "" {
52			cclog.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)")
53		} else {
54			bytes, err := base64.StdEncoding.DecodeString(pubKey)
55			if err != nil {
56				cclog.Warn("Could not decode JWT public key")
57				return err
58			}
59			ja.publicKey = ed25519.PublicKey(bytes)
60			bytes, err = base64.StdEncoding.DecodeString(privKey)
61			if err != nil {
62				cclog.Warn("Could not decode JWT private key")
63				return err
64			}
65			ja.privateKey = ed25519.PrivateKey(bytes)
66		}
67
68		return nil
69	}
70
71	func (ja *JWTAuthenticator) AuthViaJWT(
72		rw http.ResponseWriter,
73		r *http.Request,
74	) (*schema.User, error) {
75		rawtoken := r.Header.Get("X-Auth-Token")
76		if rawtoken == "" {
77			rawtoken = r.Header.Get("Authorization")
78			rawtoken = strings.TrimPrefix(rawtoken, "Bearer ")
79		}
80
81		// there is no token
82		if rawtoken == "" {
83			return nil, nil
84		}
85
86		token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (any, error) {
87			if t.Method != jwt.SigningMethodEdDSA {
88				return nil, errors.New("only Ed25519/EdDSA supported")
89			}
90
91			return ja.publicKey, nil
92		})
93		if err != nil {
94			cclog.Warn("Error while parsing JWT token")
95			return nil, err
96		}
97		if !token.Valid {
98			cclog.Warn("jwt token claims are not valid")
99			return nil, errors.New("jwt token claims are not valid")
100		}
101
102		// Token is valid, extract payload
103		claims := token.Claims.(jwt.MapClaims)
104
105		// Use shared helper to get user from JWT claims
106		var user *schema.User
107		user, err = getUserFromJWT(claims, Keys.JwtConfig.ValidateUser, schema.AuthToken, -1)
108		if err != nil {
109			return nil, err
110		}
111
112		// If not validating user, we only get roles from JWT (no projects for this auth method)
113		if !Keys.JwtConfig.ValidateUser {
114			user.Roles = extractRolesFromClaims(claims, false)
115			user.Projects = nil // Standard JWT auth doesn't include projects
116		}
117
118		return user, nil
119	}
120
121	// ProvideJWT generates a new JWT that can be used for authentication
122	func (ja *JWTAuthenticator) ProvideJWT(user *schema.User) (string, error) {
123		if ja.privateKey == nil {
124			return "", errors.New("environment variable 'JWT_PRIVATE_KEY' not set")
125		}
126
127		now := time.Now()
128		claims := jwt.MapClaims{
129			"sub":   user.Username,
130			"roles": user.Roles,
131			"iat":   now.Unix(),
132		}
133		if Keys.JwtConfig.MaxAge != "" {
134			d, err := time.ParseDuration(Keys.JwtConfig.MaxAge)
135			if err != nil {
136				return "", errors.New("cannot parse max-age config key")
137			}
138			claims["exp"] = now.Add(d).Unix()
139		}
140
141		return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(ja.privateKey)
142	}
143

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package auth

import (
	"crypto/ed25519"
	"encoding/base64"
	"errors"
	"net/http"
	"os"
	"strings"
	"time"

	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/golang-jwt/jwt/v5"
)

type JWTAuthConfig struct {
	// Specifies for how long a JWT token shall be valid
	// as a string parsable by time.ParseDuration().
	MaxAge string `json:"max-age"`

	// Specifies which cookie should be checked for a JWT token (if no authorization header is present)
	CookieName string `json:"cookie-name"`

	// Deny login for users not in database (but defined in JWT).
	// Ignore user roles defined in JWTs ('roles' claim), get them from db.
	ValidateUser bool `json:"validate-user"`

	// Specifies which issuer should be accepted when validating external JWTs ('iss' claim)
	TrustedIssuer string `json:"trusted-issuer"`

	// Should an non-existent user be added to the DB based on the information in the token
	SyncUserOnLogin bool `json:"sync-user-on-login"`

	// Should an existent user be updated in the DB based on the information in the token
	UpdateUserOnLogin bool `json:"update-user-on-login"`
}

type JWTAuthenticator struct {
	publicKey  ed25519.PublicKey
	privateKey ed25519.PrivateKey
}

func (ja *JWTAuthenticator) Init() error {
	pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY")
	if pubKey == "" || privKey == "" {
		cclog.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)")
	} else {
		bytes, err := base64.StdEncoding.DecodeString(pubKey)
		if err != nil {
			cclog.Warn("Could not decode JWT public key")
			return err
		}
		ja.publicKey = ed25519.PublicKey(bytes)
		bytes, err = base64.StdEncoding.DecodeString(privKey)
		if err != nil {
			cclog.Warn("Could not decode JWT private key")
			return err
		}
		ja.privateKey = ed25519.PrivateKey(bytes)
	}

	return nil
}

func (ja *JWTAuthenticator) AuthViaJWT(
	rw http.ResponseWriter,
	r *http.Request,
) (*schema.User, error) {
	rawtoken := r.Header.Get("X-Auth-Token")
	if rawtoken == "" {
		rawtoken = r.Header.Get("Authorization")
		rawtoken = strings.TrimPrefix(rawtoken, "Bearer ")
	}

	// there is no token
	if rawtoken == "" {
		return nil, nil
	}

	token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (any, error) {
		if t.Method != jwt.SigningMethodEdDSA {
			return nil, errors.New("only Ed25519/EdDSA supported")
		}

		return ja.publicKey, nil
	})
	if err != nil {
		cclog.Warn("Error while parsing JWT token")
		return nil, err
	}
	if !token.Valid {
		cclog.Warn("jwt token claims are not valid")
		return nil, errors.New("jwt token claims are not valid")
	}

	// Token is valid, extract payload
	claims := token.Claims.(jwt.MapClaims)

	// Use shared helper to get user from JWT claims
	var user *schema.User
	user, err = getUserFromJWT(claims, Keys.JwtConfig.ValidateUser, schema.AuthToken, -1)
	if err != nil {
		return nil, err
	}

	// If not validating user, we only get roles from JWT (no projects for this auth method)
	if !Keys.JwtConfig.ValidateUser {
		user.Roles = extractRolesFromClaims(claims, false)
		user.Projects = nil // Standard JWT auth doesn't include projects
	}

	return user, nil
}

// ProvideJWT generates a new JWT that can be used for authentication
func (ja *JWTAuthenticator) ProvideJWT(user *schema.User) (string, error) {
	if ja.privateKey == nil {
		return "", errors.New("environment variable 'JWT_PRIVATE_KEY' not set")
	}

	now := time.Now()
	claims := jwt.MapClaims{
		"sub":   user.Username,
		"roles": user.Roles,
		"iat":   now.Unix(),
	}
	if Keys.JwtConfig.MaxAge != "" {
		d, err := time.ParseDuration(Keys.JwtConfig.MaxAge)
		if err != nil {
			return "", errors.New("cannot parse max-age config key")
		}
		claims["exp"] = now.Add(d).Unix()
	}

	return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(ja.privateKey)
}


1	// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
2	// All rights reserved. This file is part of cc-backend.
3	// Use of this source code is governed by a MIT-style
4	// license that can be found in the LICENSE file.
5
6	package auth
7
8	import (
9		"crypto/ed25519"
10		"encoding/base64"
11		"errors"
12		"net/http"
13		"os"
14
15		cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
16		"github.com/ClusterCockpit/cc-lib/v2/schema"
17		"github.com/golang-jwt/jwt/v5"
18	)
19
20	type JWTCookieSessionAuthenticator struct {
21		publicKey           ed25519.PublicKey
22		privateKey          ed25519.PrivateKey
23		publicKeyCrossLogin ed25519.PublicKey // For accepting externally generated JWTs
24	}
25
26	var _ Authenticator = (*JWTCookieSessionAuthenticator)(nil)
27
28	func (ja *JWTCookieSessionAuthenticator) Init() error {
29		pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY")
30		if pubKey == "" || privKey == "" {
31			cclog.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)")
32			return errors.New("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)")
33		} else {
34			bytes, err := base64.StdEncoding.DecodeString(pubKey)
35			if err != nil {
36				cclog.Warn("Could not decode JWT public key")
37				return err
38			}
39			ja.publicKey = ed25519.PublicKey(bytes)
40			bytes, err = base64.StdEncoding.DecodeString(privKey)
41			if err != nil {
42				cclog.Warn("Could not decode JWT private key")
43				return err
44			}
45			ja.privateKey = ed25519.PrivateKey(bytes)
46		}
47
48		// Look for external public keys
49		pubKeyCrossLogin, keyFound := os.LookupEnv("CROSS_LOGIN_JWT_PUBLIC_KEY")
50		if keyFound && pubKeyCrossLogin != "" {
51			bytes, err := base64.StdEncoding.DecodeString(pubKeyCrossLogin)
52			if err != nil {
53				cclog.Warn("Could not decode cross login JWT public key")
54				return err
55			}
56			ja.publicKeyCrossLogin = ed25519.PublicKey(bytes)
57		} else {
58			ja.publicKeyCrossLogin = nil
59			cclog.Debug("environment variable 'CROSS_LOGIN_JWT_PUBLIC_KEY' not set (cross login token based authentication will not work)")
60			return errors.New("environment variable 'CROSS_LOGIN_JWT_PUBLIC_KEY' not set (cross login token based authentication will not work)")
61		}
62
63		// Warn if other necessary settings are not configured
64		if Keys.JwtConfig != nil {
65			if Keys.JwtConfig.CookieName == "" {
66				cclog.Info("cookieName for JWTs not configured (cross login via JWT cookie will fail)")
67				return errors.New("cookieName for JWTs not configured (cross login via JWT cookie will fail)")
68			}
69			if !Keys.JwtConfig.ValidateUser {
70				cclog.Info("forceJWTValidationViaDatabase not set to true: CC will accept users and roles defined in JWTs regardless of its own database!")
71			}
72			if Keys.JwtConfig.TrustedIssuer == "" {
73				cclog.Info("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)")
74				return errors.New("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)")
75			}
76		} else {
77			cclog.Warn("config for JWTs not configured (cross login via JWT cookie will fail)")
78			return errors.New("config for JWTs not configured (cross login via JWT cookie will fail)")
79		}
80
81		cclog.Info("JWT Cookie Session authenticator successfully registered")
82		return nil
83	}
84
85	func (ja *JWTCookieSessionAuthenticator) CanLogin(
86		user *schema.User,
87		username string,
88		rw http.ResponseWriter,
89		r *http.Request,
90	) (*schema.User, bool) {
91		jc := Keys.JwtConfig
92		cookieName := ""
93		if jc.CookieName != "" {
94			cookieName = jc.CookieName
95		}
96
97		// Try to read the JWT cookie
98		if cookieName != "" {
99			jwtCookie, err := r.Cookie(cookieName)
100
101			if err == nil && jwtCookie.Value != "" {
102				return user, true
103			}
104		}
105
106		return nil, false
107	}
108
109	func (ja *JWTCookieSessionAuthenticator) Login(
110		user *schema.User,
111		rw http.ResponseWriter,
112		r *http.Request,
113	) (*schema.User, error) {
114		jc := Keys.JwtConfig
115		jwtCookie, err := r.Cookie(jc.CookieName)
116		var rawtoken string
117
118		if err == nil && jwtCookie.Value != "" {
119			rawtoken = jwtCookie.Value
120		}
121
122		token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (any, error) {
123			if t.Method != jwt.SigningMethodEdDSA {
124				return nil, errors.New("only Ed25519/EdDSA supported")
125			}
126
127			unvalidatedIssuer, success := t.Claims.(jwt.MapClaims)["iss"].(string)
128			if success && unvalidatedIssuer == jc.TrustedIssuer {
129				// The (unvalidated) issuer seems to be the expected one,
130				// use public cross login key from config
131				return ja.publicKeyCrossLogin, nil
132			}
133
134			// No cross login key configured or issuer not expected
135			// Try own key
136			return ja.publicKey, nil
137		})
138		if err != nil {
139			cclog.Warn("JWT cookie session: error while parsing token")
140			return nil, err
141		}
142
143		if !token.Valid {
144			cclog.Warn("jwt token claims are not valid")
145			return nil, errors.New("jwt token claims are not valid")
146		}
147
148		claims := token.Claims.(jwt.MapClaims)
149
150		// Use shared helper to get user from JWT claims
151		user, err = getUserFromJWT(claims, jc.ValidateUser, schema.AuthSession, schema.AuthViaToken)
152		if err != nil {
153			return nil, err
154		}
155
156		// Sync or update user if configured
157		if !jc.ValidateUser && (jc.SyncUserOnLogin || jc.UpdateUserOnLogin) {
158			handleTokenUser(user)
159		}
160
161		// (Ask browser to) Delete JWT cookie
162		deletedCookie := &http.Cookie{
163			Name:     jc.CookieName,
164			Value:    "",
165			Path:     "/",
166			MaxAge:   -1,
167			HttpOnly: true,
168		}
169		http.SetCookie(rw, deletedCookie)
170
171		return user, nil
172	}
173

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package auth

import (
	"crypto/ed25519"
	"encoding/base64"
	"errors"
	"net/http"
	"os"

	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/golang-jwt/jwt/v5"
)

type JWTCookieSessionAuthenticator struct {
	publicKey           ed25519.PublicKey
	privateKey          ed25519.PrivateKey
	publicKeyCrossLogin ed25519.PublicKey // For accepting externally generated JWTs
}

var _ Authenticator = (*JWTCookieSessionAuthenticator)(nil)

func (ja *JWTCookieSessionAuthenticator) Init() error {
	pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY")
	if pubKey == "" || privKey == "" {
		cclog.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)")
		return errors.New("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)")
	} else {
		bytes, err := base64.StdEncoding.DecodeString(pubKey)
		if err != nil {
			cclog.Warn("Could not decode JWT public key")
			return err
		}
		ja.publicKey = ed25519.PublicKey(bytes)
		bytes, err = base64.StdEncoding.DecodeString(privKey)
		if err != nil {
			cclog.Warn("Could not decode JWT private key")
			return err
		}
		ja.privateKey = ed25519.PrivateKey(bytes)
	}

	// Look for external public keys
	pubKeyCrossLogin, keyFound := os.LookupEnv("CROSS_LOGIN_JWT_PUBLIC_KEY")
	if keyFound && pubKeyCrossLogin != "" {
		bytes, err := base64.StdEncoding.DecodeString(pubKeyCrossLogin)
		if err != nil {
			cclog.Warn("Could not decode cross login JWT public key")
			return err
		}
		ja.publicKeyCrossLogin = ed25519.PublicKey(bytes)
	} else {
		ja.publicKeyCrossLogin = nil
		cclog.Debug("environment variable 'CROSS_LOGIN_JWT_PUBLIC_KEY' not set (cross login token based authentication will not work)")
		return errors.New("environment variable 'CROSS_LOGIN_JWT_PUBLIC_KEY' not set (cross login token based authentication will not work)")
	}

	// Warn if other necessary settings are not configured
	if Keys.JwtConfig != nil {
		if Keys.JwtConfig.CookieName == "" {
			cclog.Info("cookieName for JWTs not configured (cross login via JWT cookie will fail)")
			return errors.New("cookieName for JWTs not configured (cross login via JWT cookie will fail)")
		}
		if !Keys.JwtConfig.ValidateUser {
			cclog.Info("forceJWTValidationViaDatabase not set to true: CC will accept users and roles defined in JWTs regardless of its own database!")
		}
		if Keys.JwtConfig.TrustedIssuer == "" {
			cclog.Info("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)")
			return errors.New("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)")
		}
	} else {
		cclog.Warn("config for JWTs not configured (cross login via JWT cookie will fail)")
		return errors.New("config for JWTs not configured (cross login via JWT cookie will fail)")
	}

	cclog.Info("JWT Cookie Session authenticator successfully registered")
	return nil
}

func (ja *JWTCookieSessionAuthenticator) CanLogin(
	user *schema.User,
	username string,
	rw http.ResponseWriter,
	r *http.Request,
) (*schema.User, bool) {
	jc := Keys.JwtConfig
	cookieName := ""
	if jc.CookieName != "" {
		cookieName = jc.CookieName
	}

	// Try to read the JWT cookie
	if cookieName != "" {
		jwtCookie, err := r.Cookie(cookieName)

		if err == nil && jwtCookie.Value != "" {
			return user, true
		}
	}

	return nil, false
}

func (ja *JWTCookieSessionAuthenticator) Login(
	user *schema.User,
	rw http.ResponseWriter,
	r *http.Request,
) (*schema.User, error) {
	jc := Keys.JwtConfig
	jwtCookie, err := r.Cookie(jc.CookieName)
	var rawtoken string

	if err == nil && jwtCookie.Value != "" {
		rawtoken = jwtCookie.Value
	}

	token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (any, error) {
		if t.Method != jwt.SigningMethodEdDSA {
			return nil, errors.New("only Ed25519/EdDSA supported")
		}

		unvalidatedIssuer, success := t.Claims.(jwt.MapClaims)["iss"].(string)
		if success && unvalidatedIssuer == jc.TrustedIssuer {
			// The (unvalidated) issuer seems to be the expected one,
			// use public cross login key from config
			return ja.publicKeyCrossLogin, nil
		}

		// No cross login key configured or issuer not expected
		// Try own key
		return ja.publicKey, nil
	})
	if err != nil {
		cclog.Warn("JWT cookie session: error while parsing token")
		return nil, err
	}

	if !token.Valid {
		cclog.Warn("jwt token claims are not valid")
		return nil, errors.New("jwt token claims are not valid")
	}

	claims := token.Claims.(jwt.MapClaims)

	// Use shared helper to get user from JWT claims
	user, err = getUserFromJWT(claims, jc.ValidateUser, schema.AuthSession, schema.AuthViaToken)
	if err != nil {
		return nil, err
	}

	// Sync or update user if configured
	if !jc.ValidateUser && (jc.SyncUserOnLogin || jc.UpdateUserOnLogin) {
		handleTokenUser(user)
	}

	// (Ask browser to) Delete JWT cookie
	deletedCookie := &http.Cookie{
		Name:     jc.CookieName,
		Value:    "",
		Path:     "/",
		MaxAge:   -1,
		HttpOnly: true,
	}
	http.SetCookie(rw, deletedCookie)

	return user, nil
}


1	// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
2	// All rights reserved. This file is part of cc-backend.
3	// Use of this source code is governed by a MIT-style
4	// license that can be found in the LICENSE file.
5
6	package auth
7
8	import (
9		"fmt"
10		"net/http"
11
12		cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
13		"github.com/ClusterCockpit/cc-lib/v2/schema"
14		"golang.org/x/crypto/bcrypt"
15	)
16
17	type LocalAuthenticator struct {
18		auth *Authentication
19	}
20
21	var _ Authenticator = (*LocalAuthenticator)(nil)
22
23	func (la *LocalAuthenticator) Init() error {
24		return nil
25	}
26
27	func (la *LocalAuthenticator) CanLogin(
28		user *schema.User,
29		username string,
30		rw http.ResponseWriter,
31		r *http.Request,
32	) (*schema.User, bool) {
33		return user, user != nil && user.AuthSource == schema.AuthViaLocalPassword
34	}
35
36	func (la *LocalAuthenticator) Login(
37		user *schema.User,
38		rw http.ResponseWriter,
39		r *http.Request,
40	) (*schema.User, error) {
41		if e := bcrypt.CompareHashAndPassword([]byte(user.Password),
42			[]byte(r.FormValue("password"))); e != nil {
43			cclog.Errorf("AUTH/LOCAL > Authentication for user %s failed!", user.Username)
44			return nil, fmt.Errorf("Authentication failed")
45		}
46
47		return user, nil
48	}
49

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package auth

import (
	"fmt"
	"net/http"

	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"golang.org/x/crypto/bcrypt"
)

type LocalAuthenticator struct {
	auth *Authentication
}

var _ Authenticator = (*LocalAuthenticator)(nil)

func (la *LocalAuthenticator) Init() error {
	return nil
}

func (la *LocalAuthenticator) CanLogin(
	user *schema.User,
	username string,
	rw http.ResponseWriter,
	r *http.Request,
) (*schema.User, bool) {
	return user, user != nil && user.AuthSource == schema.AuthViaLocalPassword
}

func (la *LocalAuthenticator) Login(
	user *schema.User,
	rw http.ResponseWriter,
	r *http.Request,
) (*schema.User, error) {
	if e := bcrypt.CompareHashAndPassword([]byte(user.Password),
		[]byte(r.FormValue("password"))); e != nil {
		cclog.Errorf("AUTH/LOCAL > Authentication for user %s failed!", user.Username)
		return nil, fmt.Errorf("Authentication failed")
	}

	return user, nil
}


I have enough verified detail. Let me write the security audit report.