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		})

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

// 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)
		})
	})


Let me look at the login template and how routes are set up.


1	{{define "navigation"}}
2
3
4

5
6	                    {{block "brand" .}}
7
8	                    {{end}}
9
10

11
12
13	{{end}}
14
15	{{define "content"}}
16

17

18

19

20	                    {{if .MsgType}}
21

22	                            {{.Message}}
23

24	                    {{end}}
25
26

27

28
Login

29

30

31	                            <form action="/login" method="post">
32

33	                                    Username
34	                                    <input class="form-control" type="text" id="username" name="username" required autofocus/>
35

36

37	                                    Password
38	                                    <input class="form-control" type="password" id="password" name="password" required/>
39

40	                                <button type="submit" class="btn btn-success">Submit</button>
41	                                {{if .Infos.hasOpenIDConnect}}
42	                                    OpenID Connect Login
43	                                {{end}}
44	                                <input type="hidden" id="redirect" name="redirect" value="{{ .Redirect }}" />
45	                            </form>
46

47

48

49

50

51

52	{{end}}
53

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

{{define "navigation"}}

                    {{block "brand" .}}

                    {{end}}

{{end}}

{{define "content"}}

                    {{if .MsgType}}

                            {{.Message}}

                    {{end}}

Login

                            <form action="/login" method="post">

                                    Username
                                    <input class="form-control" type="text" id="username" name="username" required autofocus/>

                                    Password
                                    <input class="form-control" type="password" id="password" name="password" required/>

                                <button type="submit" class="btn btn-success">Submit</button>
                                {{if .Infos.hasOpenIDConnect}}
                                    OpenID Connect Login
                                {{end}}
                                <input type="hidden" id="redirect" name="redirect" value="{{ .Redirect }}" />
                            </form>

{{end}}


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	package routerConfig
6
7	import (
8		"fmt"
9		"net/http"
10		"net/url"
11		"os"
12		"strconv"
13		"strings"
14		"time"
15
16		"github.com/ClusterCockpit/cc-backend/internal/config"
17		"github.com/ClusterCockpit/cc-backend/internal/graph/model"
18		"github.com/ClusterCockpit/cc-backend/internal/repository"
19		"github.com/ClusterCockpit/cc-backend/web"
20		cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
21		"github.com/ClusterCockpit/cc-lib/v2/schema"
22		"github.com/ClusterCockpit/cc-lib/v2/util"
23		"github.com/go-chi/chi/v5"
24	)
25
26	type InfoType map[string]any
27
28	type Route struct {
29		Route    string
30		Template string
31		Title    string
32		Filter   bool
33		Setup    func(i InfoType, r *http.Request) InfoType
34	}
35
36	var routes []Route = []Route{
37		{"/", "home.tmpl", "ClusterCockpit", false, setupHomeRoute},
38		{"/config", "config.tmpl", "Settings", false, setupConfigRoute},
39		{"/monitoring/jobs/", "monitoring/jobs.tmpl", "Jobs - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { return i }},
40		{"/monitoring/job/{id:[0-9]+}", "monitoring/job.tmpl", "Job <ID> - ClusterCockpit", false, setupJobRoute},
41		{"/monitoring/users/", "monitoring/list.tmpl", "Users - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { i["listType"] = "USER"; return i }},
42		{"/monitoring/projects/", "monitoring/list.tmpl", "Projects - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { i["listType"] = "PROJECT"; return i }},
43		{"/monitoring/tags/", "monitoring/taglist.tmpl", "Tags - ClusterCockpit", false, setupTaglistRoute},
44		{"/monitoring/user/{id}", "monitoring/user.tmpl", "User <ID> - ClusterCockpit", true, setupUserRoute},
45		{"/monitoring/systems/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> Node Overview - ClusterCockpit", false, setupClusterOverviewRoute},
46		{"/monitoring/systems/list/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> Node List - ClusterCockpit", false, setupClusterListRoute},
47		{"/monitoring/systems/list/{cluster}/{subcluster}", "monitoring/systems.tmpl", "Cluster <ID> <SID> Node List - ClusterCockpit", false, setupClusterListRoute},
48		{"/monitoring/node/{cluster}/{hostname}", "monitoring/node.tmpl", "Node <ID> - ClusterCockpit", false, setupNodeRoute},
49		{"/monitoring/analysis/{cluster}", "monitoring/analysis.tmpl", "Analysis - ClusterCockpit", true, setupAnalysisRoute},
50		{"/monitoring/status/{cluster}", "monitoring/status.tmpl", "<ID> Dashboard - ClusterCockpit", false, setupClusterStatusRoute},
51		{"/monitoring/status/detail/{cluster}", "monitoring/status.tmpl", "Status of <ID> - ClusterCockpit", false, setupClusterDetailRoute},
52		{"/monitoring/dashboard/{cluster}", "monitoring/dashboard.tmpl", "<ID> Dashboard - ClusterCockpit", false, setupDashboardRoute},
53		{"/monitoring/logs", "monitoring/logs.tmpl", "Logs - ClusterCockpit", false, func(i InfoType, r *http.Request) InfoType { return i }},
54	}
55
56	func setupHomeRoute(i InfoType, r *http.Request) InfoType {
57		jobRepo := repository.GetJobRepository()
58		groupBy := model.AggregateCluster
59
60		// startJobCount := time.Now()
61		stats, err := jobRepo.JobCountGrouped(r.Context(), nil, &groupBy)
62		if err != nil {
63			cclog.Warnf("failed to count jobs: %s", err.Error())
64		}
65		// cclog.Infof("Timer HOME ROUTE startJobCount: %s", time.Since(startJobCount))
66
67		// startRunningJobCount := time.Now()
68		stats, err = jobRepo.AddJobCountGrouped(r.Context(), nil, &groupBy, stats, "running")
69		if err != nil {
70			cclog.Warnf("failed to count running jobs: %s", err.Error())
71		}
72		// cclog.Infof("Timer HOME ROUTE startRunningJobCount: %s", time.Since(startRunningJobCount))
73
74		i["clusters"] = stats
75
76		if util.CheckFileExists("./var/notice.txt") {
77			msg, err := os.ReadFile("./var/notice.txt")
78			if err != nil {
79				cclog.Warnf("failed to read notice.txt file: %s", err.Error())
80			} else {
81				i["message"] = string(msg)
82			}
83		}
84
85		return i
86	}
87
88	func setupConfigRoute(i InfoType, r *http.Request) InfoType {
89		if util.CheckFileExists("./var/notice.txt") {
90			msg, err := os.ReadFile("./var/notice.txt")
91			if err == nil {
92				i["ncontent"] = string(msg)
93			}
94		}
95
96		return i
97	}
98
99	func setupJobRoute(i InfoType, r *http.Request) InfoType {
100		i["id"] = chi.URLParam(r, "id")

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

// 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 routerConfig

import (
	"fmt"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/web"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/ClusterCockpit/cc-lib/v2/util"
	"github.com/go-chi/chi/v5"
)

type InfoType map[string]any

type Route struct {
	Route    string
	Template string
	Title    string
	Filter   bool
	Setup    func(i InfoType, r *http.Request) InfoType
}

var routes []Route = []Route{
	{"/", "home.tmpl", "ClusterCockpit", false, setupHomeRoute},
	{"/config", "config.tmpl", "Settings", false, setupConfigRoute},
	{"/monitoring/jobs/", "monitoring/jobs.tmpl", "Jobs - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { return i }},
	{"/monitoring/job/{id:[0-9]+}", "monitoring/job.tmpl", "Job <ID> - ClusterCockpit", false, setupJobRoute},
	{"/monitoring/users/", "monitoring/list.tmpl", "Users - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { i["listType"] = "USER"; return i }},
	{"/monitoring/projects/", "monitoring/list.tmpl", "Projects - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { i["listType"] = "PROJECT"; return i }},
	{"/monitoring/tags/", "monitoring/taglist.tmpl", "Tags - ClusterCockpit", false, setupTaglistRoute},
	{"/monitoring/user/{id}", "monitoring/user.tmpl", "User <ID> - ClusterCockpit", true, setupUserRoute},
	{"/monitoring/systems/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> Node Overview - ClusterCockpit", false, setupClusterOverviewRoute},
	{"/monitoring/systems/list/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> Node List - ClusterCockpit", false, setupClusterListRoute},
	{"/monitoring/systems/list/{cluster}/{subcluster}", "monitoring/systems.tmpl", "Cluster <ID> <SID> Node List - ClusterCockpit", false, setupClusterListRoute},
	{"/monitoring/node/{cluster}/{hostname}", "monitoring/node.tmpl", "Node <ID> - ClusterCockpit", false, setupNodeRoute},
	{"/monitoring/analysis/{cluster}", "monitoring/analysis.tmpl", "Analysis - ClusterCockpit", true, setupAnalysisRoute},
	{"/monitoring/status/{cluster}", "monitoring/status.tmpl", "<ID> Dashboard - ClusterCockpit", false, setupClusterStatusRoute},
	{"/monitoring/status/detail/{cluster}", "monitoring/status.tmpl", "Status of <ID> - ClusterCockpit", false, setupClusterDetailRoute},
	{"/monitoring/dashboard/{cluster}", "monitoring/dashboard.tmpl", "<ID> Dashboard - ClusterCockpit", false, setupDashboardRoute},
	{"/monitoring/logs", "monitoring/logs.tmpl", "Logs - ClusterCockpit", false, func(i InfoType, r *http.Request) InfoType { return i }},
}

func setupHomeRoute(i InfoType, r *http.Request) InfoType {
	jobRepo := repository.GetJobRepository()
	groupBy := model.AggregateCluster

	// startJobCount := time.Now()
	stats, err := jobRepo.JobCountGrouped(r.Context(), nil, &groupBy)
	if err != nil {
		cclog.Warnf("failed to count jobs: %s", err.Error())
	}
	// cclog.Infof("Timer HOME ROUTE startJobCount: %s", time.Since(startJobCount))

	// startRunningJobCount := time.Now()
	stats, err = jobRepo.AddJobCountGrouped(r.Context(), nil, &groupBy, stats, "running")
	if err != nil {
		cclog.Warnf("failed to count running jobs: %s", err.Error())
	}
	// cclog.Infof("Timer HOME ROUTE startRunningJobCount: %s", time.Since(startRunningJobCount))

	i["clusters"] = stats

	if util.CheckFileExists("./var/notice.txt") {
		msg, err := os.ReadFile("./var/notice.txt")
		if err != nil {
			cclog.Warnf("failed to read notice.txt file: %s", err.Error())
		} else {
			i["message"] = string(msg)
		}
	}

	return i
}

func setupConfigRoute(i InfoType, r *http.Request) InfoType {
	if util.CheckFileExists("./var/notice.txt") {
		msg, err := os.ReadFile("./var/notice.txt")
		if err == nil {
			i["ncontent"] = string(msg)
		}
	}

	return i
}

func setupJobRoute(i InfoType, r *http.Request) InfoType {
	i["id"] = chi.URLParam(r, "id")


Let me check how templates are parsed (html/template vs text/template) and also look at the home.tmpl.


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 web implements the HTML templating and web frontend configuration
7	package web
8
9	import (
10		"embed"
11		"encoding/json"
12		"html/template"
13		"io/fs"
14		"net/http"
15		"strings"
16
17		"github.com/ClusterCockpit/cc-backend/internal/config"
18		"github.com/ClusterCockpit/cc-backend/pkg/archive"
19		cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
20		"github.com/ClusterCockpit/cc-lib/v2/schema"
21		"github.com/ClusterCockpit/cc-lib/v2/util"
22	)
23
24	type WebConfig struct {
25		JobList           JobListConfig     `json:"job-list"`
26		NodeList          NodeListConfig    `json:"node-list"`
27		JobView           JobViewConfig     `json:"job-view"`
28		MetricConfig      MetricConfig      `json:"metric-config"`
29		PlotConfiguration PlotConfiguration `json:"plot-configuration"`
30	}
31
32	type JobListConfig struct {
33		UsePaging     bool `json:"use-paging"`
34		ShowFootprint bool `json:"show-footprint"`
35	}
36
37	type NodeListConfig struct {
38		UsePaging bool `json:"use-paging"`
39	}
40
41	type JobViewConfig struct {
42		ShowPolarPlot bool `json:"show-polar-plot"`
43		ShowFootprint bool `json:"show-footprint"`
44		ShowRoofline  bool `json:"show-roofline"`
45		ShowStatTable bool `json:"show-stat-table"`
46	}
47
48	type MetricConfig struct {
49		JobListMetrics      []string        `json:"job-list-metrics"`
50		JobViewPlotMetrics  []string        `json:"job-view-plot-metrics"`
51		JobViewTableMetrics []string        `json:"job-view-table-metrics"`
52		Clusters            []ClusterConfig `json:"clusters"`
53	}
54
55	type ClusterConfig struct {
56		Name                string             `json:"name"`
57		JobListMetrics      []string           `json:"job-list-metrics"`
58		JobViewPlotMetrics  []string           `json:"job-view-plot-metrics"`
59		JobViewTableMetrics []string           `json:"job-view-table-metrics"`
60		SubClusters         []SubClusterConfig `json:"sub-clusters"`
61	}
62
63	type SubClusterConfig struct {
64		Name                string   `json:"name"`
65		JobListMetrics      []string `json:"job-list-metrics"`
66		JobViewPlotMetrics  []string `json:"job-view-plot-metrics"`
67		JobViewTableMetrics []string `json:"job-view-table-metrics"`
68	}
69
70	type PlotConfiguration struct {
71		ColorBackground bool     `json:"color-background"`
72		PlotsPerRow     int      `json:"plots-per-row"`
73		LineWidth       int      `json:"line-width"`
74		ColorScheme     []string `json:"color-scheme"`
75	}
76
77	var UIDefaults = WebConfig{
78		JobList: JobListConfig{
79			UsePaging:     false,
80			ShowFootprint: false,
81		},
82		NodeList: NodeListConfig{
83			UsePaging: false,
84		},
85		JobView: JobViewConfig{
86			ShowPolarPlot: true,
87			ShowFootprint: false,
88			ShowRoofline:  true,
89			ShowStatTable: true,
90		},
91		MetricConfig: MetricConfig{
92			JobListMetrics:      []string{"cpu_load", "flops_any", "mem_bw", "mem_used"},
93			JobViewPlotMetrics:  []string{"cpu_load", "flops_any", "mem_bw", "mem_used"},
94			JobViewTableMetrics: []string{"flops_any", "mem_bw", "mem_used"},
95		},
96		PlotConfiguration: PlotConfiguration{
97			ColorBackground: true,
98			PlotsPerRow:     3,
99			LineWidth:       3,
100			ColorScheme:     []string{"#00bfff", "#0000ff", "#ff00ff", "#ff0000", "#ff8000", "#ffff00", "#80ff00"},

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

// 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 web implements the HTML templating and web frontend configuration
package web

import (
	"embed"
	"encoding/json"
	"html/template"
	"io/fs"
	"net/http"
	"strings"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/pkg/archive"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/ClusterCockpit/cc-lib/v2/util"
)

type WebConfig struct {
	JobList           JobListConfig     `json:"job-list"`
	NodeList          NodeListConfig    `json:"node-list"`
	JobView           JobViewConfig     `json:"job-view"`
	MetricConfig      MetricConfig      `json:"metric-config"`
	PlotConfiguration PlotConfiguration `json:"plot-configuration"`
}

type JobListConfig struct {
	UsePaging     bool `json:"use-paging"`
	ShowFootprint bool `json:"show-footprint"`
}

type NodeListConfig struct {
	UsePaging bool `json:"use-paging"`
}

type JobViewConfig struct {
	ShowPolarPlot bool `json:"show-polar-plot"`
	ShowFootprint bool `json:"show-footprint"`
	ShowRoofline  bool `json:"show-roofline"`
	ShowStatTable bool `json:"show-stat-table"`
}

type MetricConfig struct {
	JobListMetrics      []string        `json:"job-list-metrics"`
	JobViewPlotMetrics  []string        `json:"job-view-plot-metrics"`
	JobViewTableMetrics []string        `json:"job-view-table-metrics"`
	Clusters            []ClusterConfig `json:"clusters"`
}

type ClusterConfig struct {
	Name                string             `json:"name"`
	JobListMetrics      []string           `json:"job-list-metrics"`
	JobViewPlotMetrics  []string           `json:"job-view-plot-metrics"`
	JobViewTableMetrics []string           `json:"job-view-table-metrics"`
	SubClusters         []SubClusterConfig `json:"sub-clusters"`
}

type SubClusterConfig struct {
	Name                string   `json:"name"`
	JobListMetrics      []string `json:"job-list-metrics"`
	JobViewPlotMetrics  []string `json:"job-view-plot-metrics"`
	JobViewTableMetrics []string `json:"job-view-table-metrics"`
}

type PlotConfiguration struct {
	ColorBackground bool     `json:"color-background"`
	PlotsPerRow     int      `json:"plots-per-row"`
	LineWidth       int      `json:"line-width"`
	ColorScheme     []string `json:"color-scheme"`
}

var UIDefaults = WebConfig{
	JobList: JobListConfig{
		UsePaging:     false,
		ShowFootprint: false,
	},
	NodeList: NodeListConfig{
		UsePaging: false,
	},
	JobView: JobViewConfig{
		ShowPolarPlot: true,
		ShowFootprint: false,
		ShowRoofline:  true,
		ShowStatTable: true,
	},
	MetricConfig: MetricConfig{
		JobListMetrics:      []string{"cpu_load", "flops_any", "mem_bw", "mem_used"},
		JobViewPlotMetrics:  []string{"cpu_load", "flops_any", "mem_bw", "mem_used"},
		JobViewTableMetrics: []string{"flops_any", "mem_bw", "mem_used"},
	},
	PlotConfiguration: PlotConfiguration{
		ColorBackground: true,
		PlotsPerRow:     3,
		LineWidth:       3,
		ColorScheme:     []string{"#00bfff", "#0000ff", "#ff00ff", "#ff0000", "#ff8000", "#ffff00", "#80ff00"},


100			ColorScheme:     []string{"#00bfff", "#0000ff", "#ff00ff", "#ff0000", "#ff8000", "#ffff00", "#80ff00"},
101		},
102	}
103
104	var UIDefaultsMap map[string]any
105
106	//
107	// 	map[string]any{
108	// 	"analysis_view_histogramMetrics":         []string{"flops_any", "mem_bw", "mem_used"},
109	// 	"analysis_view_scatterPlotMetrics":       [][]string{{"flops_any", "mem_bw"}, {"flops_any", "cpu_load"}, {"cpu_load", "mem_bw"}},
110	// 	"job_view_nodestats_selectedMetrics":     []string{"flops_any", "mem_bw", "mem_used"},
111	// 	"plot_list_jobsPerPage":                  50,
112	// 	"analysis_view_selectedTopEntity":        "user",
113	// 	"analysis_view_selectedTopCategory":      "totalWalltime",
114	// 	"status_view_selectedTopUserCategory":    "totalJobs",
115	// 	"status_view_selectedTopProjectCategory": "totalJobs",
116	// }
117
118	func Init(rawConfig json.RawMessage) error {
119		var err error
120
121		if rawConfig != nil {
122			config.Validate(configSchema, rawConfig)
123			if err = json.Unmarshal(rawConfig, &UIDefaults); err != nil {
124				cclog.Warn("Error while unmarshaling raw config json")
125				return err
126			}
127		}
128
129		UIDefaultsMap = make(map[string]any)
130
131		UIDefaultsMap["jobList_usePaging"] = UIDefaults.JobList.UsePaging
132		UIDefaultsMap["jobList_showFootprint"] = UIDefaults.JobList.ShowFootprint
133		UIDefaultsMap["nodeList_usePaging"] = UIDefaults.NodeList.UsePaging
134		UIDefaultsMap["jobView_showPolarPlot"] = UIDefaults.JobView.ShowPolarPlot
135		UIDefaultsMap["jobView_showFootprint"] = UIDefaults.JobView.ShowFootprint
136		UIDefaultsMap["jobView_showRoofline"] = UIDefaults.JobView.ShowRoofline
137		UIDefaultsMap["jobView_showStatTable"] = UIDefaults.JobView.ShowStatTable
138
139		UIDefaultsMap["metricConfig_jobListMetrics"] = UIDefaults.MetricConfig.JobListMetrics
140		UIDefaultsMap["metricConfig_jobViewPlotMetrics"] = UIDefaults.MetricConfig.JobViewPlotMetrics
141		UIDefaultsMap["metricConfig_jobViewTableMetrics"] = UIDefaults.MetricConfig.JobViewTableMetrics
142
143		UIDefaultsMap["plotConfiguration_colorBackground"] = UIDefaults.PlotConfiguration.ColorBackground
144		UIDefaultsMap["plotConfiguration_plotsPerRow"] = UIDefaults.PlotConfiguration.PlotsPerRow
145		UIDefaultsMap["plotConfiguration_lineWidth"] = UIDefaults.PlotConfiguration.LineWidth
146		UIDefaultsMap["plotConfiguration_colorScheme"] = UIDefaults.PlotConfiguration.ColorScheme
147
148		for _, c := range UIDefaults.MetricConfig.Clusters {
149			if c.JobListMetrics != nil {
150				UIDefaultsMap["metricConfig_jobListMetrics:"+c.Name] = c.JobListMetrics
151			}
152			if c.JobViewPlotMetrics != nil {
153				UIDefaultsMap["metricConfig_jobViewPlotMetrics:"+c.Name] = c.JobViewPlotMetrics
154			}
155			if c.JobViewTableMetrics != nil {
156				UIDefaultsMap["metricConfig_jobViewTableMetrics:"+c.Name] = c.JobViewTableMetrics
157			}
158
159			for _, sc := range c.SubClusters {
160				suffix := strings.Join([]string{c.Name, sc.Name}, ":")
161				if sc.JobListMetrics != nil {
162					UIDefaultsMap["metricConfig_jobListMetrics:"+suffix] = sc.JobListMetrics
163				}
164				if sc.JobViewPlotMetrics != nil {
165					UIDefaultsMap["metricConfig_jobViewPlotMetrics:"+suffix] = sc.JobViewPlotMetrics
166				}
167				if sc.JobViewTableMetrics != nil {
168					UIDefaultsMap["metricConfig_jobViewTableMetrics:"+suffix] = sc.JobViewTableMetrics
169				}
170			}
171		}
172
173		return err
174	}
175
176	// / Go's embed is only allowed to embed files in a subdirectory of the embedding package ([see here](https://github.com/golang/go/issues/46056)).
177	//
178	//go:embed frontend/public/*
179	var frontendFiles embed.FS
180
181	func ServeFiles() http.Handler {
182		publicFiles, err := fs.Sub(frontendFiles, "frontend/public")
183		if err != nil {
184			cclog.Abortf("Serve Files: Could not find 'frontend/public' file directory.\nError: %s\n", err.Error())
185		}
186		return http.FileServer(http.FS(publicFiles))
187	}
188
189	// StaticFileExists checks whether a static file exists in the embedded frontend FS.
190	func StaticFileExists(path string) bool {
191		path = strings.TrimPrefix(path, "/")
192		if path == "" {
193			return false
194		}
195		_, err := fs.Stat(frontendFiles, "frontend/public/"+path)
196		return err == nil
197	}
198
199	//go:embed templates/*
200	var templateFiles embed.FS
201
202	var templates map[string]*template.Template = map[string]*template.Template{}
203
204	func init() {
205		base := template.Must(template.ParseFS(templateFiles, "templates/base.tmpl"))
206		if err := fs.WalkDir(templateFiles, "templates", func(path string, d fs.DirEntry, err error) error {
207			if err != nil {
208				return err
209			}
210			if d.IsDir() || path == "templates/base.tmpl" {
211				return nil
212			}
213
214			if path == "templates/404.tmpl" {
215				templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.ParseFS(templateFiles, path))
216				return nil
217			}
218			if path == "templates/login.tmpl" {
219				if util.CheckFileExists("./var/login.tmpl") {
220					cclog.Info("overwrite login.tmpl with local file")
221					templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/login.tmpl"))
222					return nil
223				}
224			}
225			if path == "templates/imprint.tmpl" {
226				if util.CheckFileExists("./var/imprint.tmpl") {
227					cclog.Info("overwrite imprint.tmpl with local file")
228					templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/imprint.tmpl"))
229					return nil
230				}
231			}
232			if path == "templates/privacy.tmpl" {
233				if util.CheckFileExists("./var/privacy.tmpl") {
234					cclog.Info("overwrite privacy.tmpl with local file")
235					templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/privacy.tmpl"))
236					return nil
237				}
238			}
239
240			templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFS(templateFiles, path))
241			return nil
242		}); err != nil {
243			cclog.Abortf("Web init(): Could not find frontend template files.\nError: %s\n", err.Error())
244		}
245
246		_ = base
247	}
248
249	type Build struct {

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

		ColorScheme:     []string{"#00bfff", "#0000ff", "#ff00ff", "#ff0000", "#ff8000", "#ffff00", "#80ff00"},
	},
}

var UIDefaultsMap map[string]any

//
// 	map[string]any{
// 	"analysis_view_histogramMetrics":         []string{"flops_any", "mem_bw", "mem_used"},
// 	"analysis_view_scatterPlotMetrics":       [][]string{{"flops_any", "mem_bw"}, {"flops_any", "cpu_load"}, {"cpu_load", "mem_bw"}},
// 	"job_view_nodestats_selectedMetrics":     []string{"flops_any", "mem_bw", "mem_used"},
// 	"plot_list_jobsPerPage":                  50,
// 	"analysis_view_selectedTopEntity":        "user",
// 	"analysis_view_selectedTopCategory":      "totalWalltime",
// 	"status_view_selectedTopUserCategory":    "totalJobs",
// 	"status_view_selectedTopProjectCategory": "totalJobs",
// }

func Init(rawConfig json.RawMessage) error {
	var err error

	if rawConfig != nil {
		config.Validate(configSchema, rawConfig)
		if err = json.Unmarshal(rawConfig, &UIDefaults); err != nil {
			cclog.Warn("Error while unmarshaling raw config json")
			return err
		}
	}

	UIDefaultsMap = make(map[string]any)

	UIDefaultsMap["jobList_usePaging"] = UIDefaults.JobList.UsePaging
	UIDefaultsMap["jobList_showFootprint"] = UIDefaults.JobList.ShowFootprint
	UIDefaultsMap["nodeList_usePaging"] = UIDefaults.NodeList.UsePaging
	UIDefaultsMap["jobView_showPolarPlot"] = UIDefaults.JobView.ShowPolarPlot
	UIDefaultsMap["jobView_showFootprint"] = UIDefaults.JobView.ShowFootprint
	UIDefaultsMap["jobView_showRoofline"] = UIDefaults.JobView.ShowRoofline
	UIDefaultsMap["jobView_showStatTable"] = UIDefaults.JobView.ShowStatTable

	UIDefaultsMap["metricConfig_jobListMetrics"] = UIDefaults.MetricConfig.JobListMetrics
	UIDefaultsMap["metricConfig_jobViewPlotMetrics"] = UIDefaults.MetricConfig.JobViewPlotMetrics
	UIDefaultsMap["metricConfig_jobViewTableMetrics"] = UIDefaults.MetricConfig.JobViewTableMetrics

	UIDefaultsMap["plotConfiguration_colorBackground"] = UIDefaults.PlotConfiguration.ColorBackground
	UIDefaultsMap["plotConfiguration_plotsPerRow"] = UIDefaults.PlotConfiguration.PlotsPerRow
	UIDefaultsMap["plotConfiguration_lineWidth"] = UIDefaults.PlotConfiguration.LineWidth
	UIDefaultsMap["plotConfiguration_colorScheme"] = UIDefaults.PlotConfiguration.ColorScheme

	for _, c := range UIDefaults.MetricConfig.Clusters {
		if c.JobListMetrics != nil {
			UIDefaultsMap["metricConfig_jobListMetrics:"+c.Name] = c.JobListMetrics
		}
		if c.JobViewPlotMetrics != nil {
			UIDefaultsMap["metricConfig_jobViewPlotMetrics:"+c.Name] = c.JobViewPlotMetrics
		}
		if c.JobViewTableMetrics != nil {
			UIDefaultsMap["metricConfig_jobViewTableMetrics:"+c.Name] = c.JobViewTableMetrics
		}

		for _, sc := range c.SubClusters {
			suffix := strings.Join([]string{c.Name, sc.Name}, ":")
			if sc.JobListMetrics != nil {
				UIDefaultsMap["metricConfig_jobListMetrics:"+suffix] = sc.JobListMetrics
			}
			if sc.JobViewPlotMetrics != nil {
				UIDefaultsMap["metricConfig_jobViewPlotMetrics:"+suffix] = sc.JobViewPlotMetrics
			}
			if sc.JobViewTableMetrics != nil {
				UIDefaultsMap["metricConfig_jobViewTableMetrics:"+suffix] = sc.JobViewTableMetrics
			}
		}
	}

	return err
}

// / Go's embed is only allowed to embed files in a subdirectory of the embedding package ([see here](https://github.com/golang/go/issues/46056)).
//
//go:embed frontend/public/*
var frontendFiles embed.FS

func ServeFiles() http.Handler {
	publicFiles, err := fs.Sub(frontendFiles, "frontend/public")
	if err != nil {
		cclog.Abortf("Serve Files: Could not find 'frontend/public' file directory.\nError: %s\n", err.Error())
	}
	return http.FileServer(http.FS(publicFiles))
}

// StaticFileExists checks whether a static file exists in the embedded frontend FS.
func StaticFileExists(path string) bool {
	path = strings.TrimPrefix(path, "/")
	if path == "" {
		return false
	}
	_, err := fs.Stat(frontendFiles, "frontend/public/"+path)
	return err == nil
}

//go:embed templates/*
var templateFiles embed.FS

var templates map[string]*template.Template = map[string]*template.Template{}

func init() {
	base := template.Must(template.ParseFS(templateFiles, "templates/base.tmpl"))
	if err := fs.WalkDir(templateFiles, "templates", func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() || path == "templates/base.tmpl" {
			return nil
		}

		if path == "templates/404.tmpl" {
			templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.ParseFS(templateFiles, path))
			return nil
		}
		if path == "templates/login.tmpl" {
			if util.CheckFileExists("./var/login.tmpl") {
				cclog.Info("overwrite login.tmpl with local file")
				templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/login.tmpl"))
				return nil
			}
		}
		if path == "templates/imprint.tmpl" {
			if util.CheckFileExists("./var/imprint.tmpl") {
				cclog.Info("overwrite imprint.tmpl with local file")
				templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/imprint.tmpl"))
				return nil
			}
		}
		if path == "templates/privacy.tmpl" {
			if util.CheckFileExists("./var/privacy.tmpl") {
				cclog.Info("overwrite privacy.tmpl with local file")
				templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/privacy.tmpl"))
				return nil
			}
		}

		templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFS(templateFiles, path))
		return nil
	}); err != nil {
		cclog.Abortf("Web init(): Could not find frontend template files.\nError: %s\n", err.Error())
	}

	_ = base
}

type Build struct {


Let me look at the main Header Svelte component.


No files found


web/frontend/src/header/NavbarTools.svelte
web/frontend/src/header/NavbarLinks.svelte


1	import { mount } from 'svelte';
2	import Header from './Header.svelte';
3
4	const headerDomTarget = document.getElementById('svelte-header');
5
6	if (headerDomTarget != null) {
7	    mount(Header, {
8	        target: headerDomTarget,
9	        props: {
10	            username: hUsername,
11	            authlevel: hAuthlevel,
12	            clusterNames: hClusterNames,
13	            subclusterMap: hSubclusterMap,
14	            roles: hRoles,
15	        },
16	    });
17	}
18

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import { mount } from 'svelte';
import Header from './Header.svelte';

const headerDomTarget = document.getElementById('svelte-header');

if (headerDomTarget != null) {
    mount(Header, {
        target: headerDomTarget,
        props: {
            username: hUsername,
            authlevel: hAuthlevel,
            clusterNames: hClusterNames,
            subclusterMap: hSubclusterMap,
            roles: hRoles,
        },
    });
}


1
11
12
160
161	<svelte:window bind:innerWidth={screenSize} />
162
163	<Navbar color="light" light expand="md" fixed="top">
164	  <NavbarBrand href="/">
165
166	  </NavbarBrand>
167	  <NavbarToggler onclick={() => (isOpen = !isOpen)} />
168	  <Collapse
169	    style="justify-content: space-between"
170	    expand="md"
171	    {isOpen}
172	    navbar
173	  >
174
175	      {#if showMax || showBrg}
176	        <NavbarLinks
177	          {clusterNames}
178	          {subclusterMap}
179	          links={views.filter((item) => item.requiredRole <= authlevel)}
180	        />
181
182	      {:else if showMid}
183	        <NavbarLinks
184	          {clusterNames}
185	          {subclusterMap}
186	          links={views.filter(
187	            (item) => item.requiredRole <= authlevel && item.menu != "Info",
188	          )}
189	        />
190	        {#if authlevel >= 4}
191	          <Dropdown nav>
192	            <DropdownToggle nav caret>
193	              <Icon name="graph-up" />
194	              Info
195	            </DropdownToggle>
196	            <DropdownMenu class="dropdown-menu-lg-end">
197	              <NavbarLinks
198	                {clusterNames}
199	                {subclusterMap}
200	                direction="right"
201	                links={views.filter(
202	                  (item) =>
203	                    item.requiredRole <= authlevel && item.menu == "Info",
204	                )}
205	              />
206	            </DropdownMenu>
207	          </Dropdown>
208	        {/if}
209
210	      {:else if showSml}
211	        <NavbarLinks
212	          {clusterNames}
213	          {subclusterMap}
214	          links={views.filter(
215	            (item) => item.requiredRole <= authlevel && item.menu == "none",
216	          )}
217	        />
218	        {#if authlevel >= 2}
219	          <Dropdown nav>
220	            <DropdownToggle nav caret>
221	              Jobs
222	            </DropdownToggle>
223	            <DropdownMenu class="dropdown-menu-lg-end">
224	              <NavbarLinks
225	                {clusterNames}
226	                {subclusterMap}
227	                direction="right"
228	                links={views.filter(
229	                  (item) => item.requiredRole <= authlevel && item.menu == 'Jobs',
230	                )}
231	              />
232	            </DropdownMenu>
233	          </Dropdown>
234	        {/if}
235	        {#if authlevel >= 3}
236	          <Dropdown nav>
237	            <DropdownToggle nav caret>
238	              Groups
239	            </DropdownToggle>
240	            <DropdownMenu class="dropdown-menu-lg-end">
241	              <NavbarLinks
242	                {clusterNames}
243	                {subclusterMap}
244	                direction="right"
245	                links={views.filter(
246	                  (item) => item.requiredRole <= authlevel && item.menu == 'Groups',
247	                )}
248	              />
249	            </DropdownMenu>
250	          </Dropdown>
251	        {/if}
252	        {#if authlevel >= 4}
253	          <Dropdown nav>
254	            <DropdownToggle nav caret>
255	              Info
256	            </DropdownToggle>
257	            <DropdownMenu class="dropdown-menu-lg-end">
258	              <NavbarLinks
259	                {clusterNames}
260	                {subclusterMap}
261	                direction="right"
262	                links={views.filter(
263	                  (item) => item.requiredRole <= authlevel && item.menu == 'Info',
264	                )}
265	              />
266	            </DropdownMenu>
267	          </Dropdown>
268	        {/if}
269
270	      {:else}
271	          Error: Unknown Window Size!
272	      {/if}
273
274	    <NavbarTools {username} {authlevel} {roles} {screenSize} />
275	  </Collapse>
276	</Navbar>

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

<svelte:window bind:innerWidth={screenSize} />

<Navbar color="light" light expand="md" fixed="top">
  <NavbarBrand href="/">

  </NavbarBrand>
  <NavbarToggler onclick={() => (isOpen = !isOpen)} />
  <Collapse
    style="justify-content: space-between"
    expand="md"
    {isOpen}
    navbar
  >

      {#if showMax || showBrg}
        <NavbarLinks
          {clusterNames}
          {subclusterMap}
          links={views.filter((item) => item.requiredRole <= authlevel)}
        />

      {:else if showMid}
        <NavbarLinks
          {clusterNames}
          {subclusterMap}
          links={views.filter(
            (item) => item.requiredRole <= authlevel && item.menu != "Info",
          )}
        />
        {#if authlevel >= 4}
          <Dropdown nav>
            <DropdownToggle nav caret>
              <Icon name="graph-up" />
              Info
            </DropdownToggle>
            <DropdownMenu class="dropdown-menu-lg-end">
              <NavbarLinks
                {clusterNames}
                {subclusterMap}
                direction="right"
                links={views.filter(
                  (item) =>
                    item.requiredRole <= authlevel && item.menu == "Info",
                )}
              />
            </DropdownMenu>
          </Dropdown>
        {/if}

      {:else if showSml}
        <NavbarLinks
          {clusterNames}
          {subclusterMap}
          links={views.filter(
            (item) => item.requiredRole <= authlevel && item.menu == "none",
          )}
        />
        {#if authlevel >= 2}
          <Dropdown nav>
            <DropdownToggle nav caret>
              Jobs
            </DropdownToggle>
            <DropdownMenu class="dropdown-menu-lg-end">
              <NavbarLinks
                {clusterNames}
                {subclusterMap}
                direction="right"
                links={views.filter(
                  (item) => item.requiredRole <= authlevel && item.menu == 'Jobs',
                )}
              />
            </DropdownMenu>
          </Dropdown>
        {/if}
        {#if authlevel >= 3}
          <Dropdown nav>
            <DropdownToggle nav caret>
              Groups
            </DropdownToggle>
            <DropdownMenu class="dropdown-menu-lg-end">
              <NavbarLinks
                {clusterNames}
                {subclusterMap}
                direction="right"
                links={views.filter(
                  (item) => item.requiredRole <= authlevel && item.menu == 'Groups',
                )}
              />
            </DropdownMenu>
          </Dropdown>
        {/if}
        {#if authlevel >= 4}
          <Dropdown nav>
            <DropdownToggle nav caret>
              Info
            </DropdownToggle>
            <DropdownMenu class="dropdown-menu-lg-end">
              <NavbarLinks
                {clusterNames}
                {subclusterMap}
                direction="right"
                links={views.filter(
                  (item) => item.requiredRole <= authlevel && item.menu == 'Info',
                )}
              />
            </DropdownMenu>
          </Dropdown>
        {/if}

      {:else}
          Error: Unknown Window Size!
      {/if}

    <NavbarTools {username} {authlevel} {roles} {screenSize} />
  </Collapse>
</Navbar>


Let me look at `GetUserFromContext` and the `GetValidRolesMap` function.


485	type ContextKey string
486
487	const ContextUserKey ContextKey = "user"
488
489	func GetUserFromContext(ctx context.Context) *schema.User {
490		x := ctx.Value(ContextUserKey)
491		if x == nil {
492			cclog.Warnf("no user retrieved from context")
493			return nil
494		}
495		// cclog.Infof("user retrieved from context: %v", x.(*schema.User))
496		return x.(*schema.User)
497	}
498
499	func (r *UserRepository) FetchUserInCtx(ctx context.Context, username string) (*model.User, error) {
500		me := GetUserFromContext(ctx)
501		if me != nil && me.Username != username &&
502			me.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
503			return nil, errors.New("forbidden")
504		}
505
506		user := &model.User{Username: username}
507		var name, email sql.NullString
508		if err := sq.Select("name", "email").From("hpc_user").Where("hpc_user.username = ?", username).
509			RunWith(r.DB).QueryRow().Scan(&name, &email); err != nil {
510			if err == sql.ErrNoRows {
511				/* This warning will be logged *often* for non-local users, i.e. users mentioned only in job-table or archive, */
512				/* since FetchUser will be called to retrieve full name and mail for every job in query/list									 */
513				// cclog.Warnf("User '%s' Not found in DB", username)
514				return nil, nil

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

type ContextKey string

const ContextUserKey ContextKey = "user"

func GetUserFromContext(ctx context.Context) *schema.User {
	x := ctx.Value(ContextUserKey)
	if x == nil {
		cclog.Warnf("no user retrieved from context")
		return nil
	}
	// cclog.Infof("user retrieved from context: %v", x.(*schema.User))
	return x.(*schema.User)
}

func (r *UserRepository) FetchUserInCtx(ctx context.Context, username string) (*model.User, error) {
	me := GetUserFromContext(ctx)
	if me != nil && me.Username != username &&
		me.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
		return nil, errors.New("forbidden")
	}

	user := &model.User{Username: username}
	var name, email sql.NullString
	if err := sq.Select("name", "email").From("hpc_user").Where("hpc_user.username = ?", username).
		RunWith(r.DB).QueryRow().Scan(&name, &email); err != nil {
		if err == sql.ErrNoRows {
			/* This warning will be logged *often* for non-local users, i.e. users mentioned only in job-table or archive, */
			/* since FetchUser will be called to retrieve full name and mail for every job in query/list									 */
			// cclog.Warnf("User '%s' Not found in DB", username)
			return nil, nil


internal/routerConfig/routes.go:203:	userAuthlevel := repository.GetUserFromContext(r.Context()).GetAuthLevel()
internal/routerConfig/routes.go:494:			availableRoles, _ := schema.GetValidRolesMap(user)
internal/routerConfig/routes.go:517:	availableRoles, _ := schema.GetValidRolesMap(user)


Bash

find /Users/jan/prg/ClusterCockpit/cc-backend -name "*.go" -path "*/cc-lib*" | head -20

Find cc-lib Go files


(Bash completed with no output)


cat /Users/jan/prg/ClusterCockpit/cc-backend/go.mod | grep cc-lib

Find cc-lib module path


github.com/ClusterCockpit/cc-lib/v2 v2.9.1


find ~/go/pkg/mod/github.com/\!cluster\!cockpit/cc-lib* -name "user.go" 2>/dev/null | head -5

Find cc-lib user.go in module cache


1	// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
2	// All rights reserved. This file is part of cc-lib.
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 schema
7
8	import (
9		"fmt"
10		"slices"
11		"strings"
12	)
13
14	// Role defines the authorization level for a user in ClusterCockpit.
15	// Roles form a hierarchy with increasing privileges: Anonymous < Api < User < Manager < Support < Admin.
16	type Role int
17
18	const (
19		RoleAnonymous Role = iota // Unauthenticated or guest access
20		RoleAPI                   // API access (programmatic/service accounts)
21		RoleUser                  // Regular user (can view own jobs)
22		RoleManager               // Project manager (can view project jobs)
23		RoleSupport               // Support staff (can view all jobs, limited admin)
24		RoleAdmin                 // Full administrator access
25		RoleError                 // Invalid/error role
26	)
27
28	// AuthSource identifies the authentication backend that validated a user.
29	type AuthSource int
30
31	const (
32		AuthViaLocalPassword AuthSource = iota // Local database password authentication
33		AuthViaLDAP                            // LDAP directory authentication
34		AuthViaToken                           // JWT or API token authentication
35		AuthViaOIDC                            // OpenID Connect authentication
36		AuthViaAll                             // Accepts any auth source (special case)
37	)
38
39	// AuthType distinguishes between different authentication contexts.
40	type AuthType int
41
42	const (
43		AuthToken   AuthType = iota // API token-based authentication
44		AuthSession                 // Session cookie-based authentication
45	)
46
47	// User represents a ClusterCockpit user account with authentication and authorization information.
48	//
49	// Users are authenticated via various sources (local, LDAP, OIDC) and assigned roles that
50	// determine access levels. Projects lists the HPC projects/accounts the user has access to.
51	type User struct {
52		Username   string     `json:"username"`   // Unique username
53		Password   string     `json:"-"`          // Password hash (never serialized to JSON)
54		Name       string     `json:"name"`       // Full display name
55		Email      string     `json:"email"`      // Email address
56		Roles      []string   `json:"roles"`      // Assigned role names
57		Projects   []string   `json:"projects"`   // Authorized project/account names
58		AuthType   AuthType   `json:"authType"`   // How the user authenticated
59		AuthSource AuthSource `json:"authSource"` // Which system authenticated the user
60	}
61
62	// HasProject reports whether the user is authorized for the given project name.
63	func (u *User) HasProject(project string) bool {
64		return slices.Contains(u.Projects, project)
65	}
66
67	// GetRoleString returns the lowercase string representation of a Role enum value.
68	func GetRoleString(roleInt Role) string {
69		return [6]string{"anonymous", "api", "user", "manager", "support", "admin"}[roleInt]
70	}
71
72	func getRoleEnum(roleStr string) Role {
73		switch strings.ToLower(roleStr) {
74		case "admin":
75			return RoleAdmin
76		case "support":
77			return RoleSupport
78		case "manager":
79			return RoleManager
80		case "user":
81			return RoleUser
82		case "api":
83			return RoleAPI
84		case "anonymous":
85			return RoleAnonymous
86		default:
87			return RoleError
88		}
89	}
90
91	// IsValidRole reports whether the given string corresponds to a known role name.
92	func IsValidRole(role string) bool {
93		return getRoleEnum(role) != RoleError
94	}
95
96	// HasValidRole checks whether the user has the specified role and whether the role string is valid.
97	// Returns hasRole=true if the user has the role, and isValid=true if the role name is recognized.
98	func (u *User) HasValidRole(role string) (hasRole bool, isValid bool) {
99		if IsValidRole(role) {
100			if slices.Contains(u.Roles, role) {
101				return true, true
102			}
103			return false, true
104		}
105		return false, false
106	}
107
108	// HasRole reports whether the user has the specified role.
109	func (u *User) HasRole(role Role) bool {
110		return slices.Contains(u.Roles, GetRoleString(role))
111	}
112
113	// HasAnyRole reports whether the user has at least one of the given roles.
114	func (u *User) HasAnyRole(queryroles []Role) bool {
115		for _, ur := range u.Roles {
116			for _, qr := range queryroles {
117				if ur == GetRoleString(qr) {
118					return true
119				}
120			}
121		}
122		return false
123	}
124
125	// HasAllRoles reports whether the user has every one of the given roles.
126	func (u *User) HasAllRoles(queryroles []Role) bool {
127		target := len(queryroles)
128		matches := 0
129		for _, ur := range u.Roles {
130			for _, qr := range queryroles {
131				if ur == GetRoleString(qr) {
132					matches += 1
133					break
134				}
135			}
136		}
137
138		if matches == target {
139			return true
140		} else {
141			return false
142		}
143	}
144
145	// HasNotRoles reports whether the user has none of the given roles.
146	func (u *User) HasNotRoles(queryroles []Role) bool {
147		matches := 0
148		for _, ur := range u.Roles {
149			for _, qr := range queryroles {
150				if ur == GetRoleString(qr) {
151					matches += 1
152					break
153				}
154			}
155		}
156
157		if matches == 0 {
158			return true
159		} else {
160			return false
161		}
162	}
163
164	// GetValidRoles returns the list of assignable role names. Only admins may call this;
165	// returns an error if the user does not have the Admin role.
166	func GetValidRoles(user *User) ([]string, error) {
167		var vals []string
168		if user.HasRole(RoleAdmin) {
169			for i := RoleAPI; i < RoleError; i++ {
170				vals = append(vals, GetRoleString(i))
171			}
172			return vals, nil
173		}
174
175		return vals, fmt.Errorf("%s: only admins are allowed to fetch a list of roles", user.Username)
176	}
177
178	// GetValidRolesMap returns a map of role names to Role enum values. Requires any
179	// authenticated (non-anonymous) user; returns an error for anonymous users.
180	func GetValidRolesMap(user *User) (map[string]Role, error) {
181		named := make(map[string]Role)
182		if user.HasNotRoles([]Role{RoleAnonymous}) {
183			for i := RoleAPI; i < RoleError; i++ {
184				named[GetRoleString(i)] = i
185			}
186			return named, nil
187		}
188		return named, fmt.Errorf("only known users are allowed to fetch a list of roles")
189	}
190
191	// GetAuthLevel returns the user's highest-privilege role.
192	// Returns RoleError if the user has no recognized roles.
193	func (u *User) GetAuthLevel() Role {
194		if u.HasRole(RoleAdmin) {
195			return RoleAdmin
196		} else if u.HasRole(RoleSupport) {
197			return RoleSupport
198		} else if u.HasRole(RoleManager) {
199			return RoleManager
200		} else if u.HasRole(RoleUser) {
201			return RoleUser
202		} else if u.HasRole(RoleAPI) {
203			return RoleAPI
204		} else if u.HasRole(RoleAnonymous) {
205			return RoleAnonymous
206		} else {
207			return RoleError
208		}
209	}
210

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

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

package schema

import (
	"fmt"
	"slices"
	"strings"
)

// Role defines the authorization level for a user in ClusterCockpit.
// Roles form a hierarchy with increasing privileges: Anonymous < Api < User < Manager < Support < Admin.
type Role int

const (
	RoleAnonymous Role = iota // Unauthenticated or guest access
	RoleAPI                   // API access (programmatic/service accounts)
	RoleUser                  // Regular user (can view own jobs)
	RoleManager               // Project manager (can view project jobs)
	RoleSupport               // Support staff (can view all jobs, limited admin)
	RoleAdmin                 // Full administrator access
	RoleError                 // Invalid/error role
)

// AuthSource identifies the authentication backend that validated a user.
type AuthSource int

const (
	AuthViaLocalPassword AuthSource = iota // Local database password authentication
	AuthViaLDAP                            // LDAP directory authentication
	AuthViaToken                           // JWT or API token authentication
	AuthViaOIDC                            // OpenID Connect authentication
	AuthViaAll                             // Accepts any auth source (special case)
)

// AuthType distinguishes between different authentication contexts.
type AuthType int

const (
	AuthToken   AuthType = iota // API token-based authentication
	AuthSession                 // Session cookie-based authentication
)

// User represents a ClusterCockpit user account with authentication and authorization information.
//
// Users are authenticated via various sources (local, LDAP, OIDC) and assigned roles that
// determine access levels. Projects lists the HPC projects/accounts the user has access to.
type User struct {
	Username   string     `json:"username"`   // Unique username
	Password   string     `json:"-"`          // Password hash (never serialized to JSON)
	Name       string     `json:"name"`       // Full display name
	Email      string     `json:"email"`      // Email address
	Roles      []string   `json:"roles"`      // Assigned role names
	Projects   []string   `json:"projects"`   // Authorized project/account names
	AuthType   AuthType   `json:"authType"`   // How the user authenticated
	AuthSource AuthSource `json:"authSource"` // Which system authenticated the user
}

// HasProject reports whether the user is authorized for the given project name.
func (u *User) HasProject(project string) bool {
	return slices.Contains(u.Projects, project)
}

// GetRoleString returns the lowercase string representation of a Role enum value.
func GetRoleString(roleInt Role) string {
	return [6]string{"anonymous", "api", "user", "manager", "support", "admin"}[roleInt]
}

func getRoleEnum(roleStr string) Role {
	switch strings.ToLower(roleStr) {
	case "admin":
		return RoleAdmin
	case "support":
		return RoleSupport
	case "manager":
		return RoleManager
	case "user":
		return RoleUser
	case "api":
		return RoleAPI
	case "anonymous":
		return RoleAnonymous
	default:
		return RoleError
	}
}

// IsValidRole reports whether the given string corresponds to a known role name.
func IsValidRole(role string) bool {
	return getRoleEnum(role) != RoleError
}

// HasValidRole checks whether the user has the specified role and whether the role string is valid.
// Returns hasRole=true if the user has the role, and isValid=true if the role name is recognized.
func (u *User) HasValidRole(role string) (hasRole bool, isValid bool) {
	if IsValidRole(role) {
		if slices.Contains(u.Roles, role) {
			return true, true
		}
		return false, true
	}
	return false, false
}

// HasRole reports whether the user has the specified role.
func (u *User) HasRole(role Role) bool {
	return slices.Contains(u.Roles, GetRoleString(role))
}

// HasAnyRole reports whether the user has at least one of the given roles.
func (u *User) HasAnyRole(queryroles []Role) bool {
	for _, ur := range u.Roles {
		for _, qr := range queryroles {
			if ur == GetRoleString(qr) {
				return true
			}
		}
	}
	return false
}

// HasAllRoles reports whether the user has every one of the given roles.
func (u *User) HasAllRoles(queryroles []Role) bool {
	target := len(queryroles)
	matches := 0
	for _, ur := range u.Roles {
		for _, qr := range queryroles {
			if ur == GetRoleString(qr) {
				matches += 1
				break
			}
		}
	}

	if matches == target {
		return true
	} else {
		return false
	}
}

// HasNotRoles reports whether the user has none of the given roles.
func (u *User) HasNotRoles(queryroles []Role) bool {
	matches := 0
	for _, ur := range u.Roles {
		for _, qr := range queryroles {
			if ur == GetRoleString(qr) {
				matches += 1
				break
			}
		}
	}

	if matches == 0 {
		return true
	} else {
		return false
	}
}

// GetValidRoles returns the list of assignable role names. Only admins may call this;
// returns an error if the user does not have the Admin role.
func GetValidRoles(user *User) ([]string, error) {
	var vals []string
	if user.HasRole(RoleAdmin) {
		for i := RoleAPI; i < RoleError; i++ {
			vals = append(vals, GetRoleString(i))
		}
		return vals, nil
	}

	return vals, fmt.Errorf("%s: only admins are allowed to fetch a list of roles", user.Username)
}

// GetValidRolesMap returns a map of role names to Role enum values. Requires any
// authenticated (non-anonymous) user; returns an error for anonymous users.
func GetValidRolesMap(user *User) (map[string]Role, error) {
	named := make(map[string]Role)
	if user.HasNotRoles([]Role{RoleAnonymous}) {
		for i := RoleAPI; i < RoleError; i++ {
			named[GetRoleString(i)] = i
		}
		return named, nil
	}
	return named, fmt.Errorf("only known users are allowed to fetch a list of roles")
}

// GetAuthLevel returns the user's highest-privilege role.
// Returns RoleError if the user has no recognized roles.
func (u *User) GetAuthLevel() Role {
	if u.HasRole(RoleAdmin) {
		return RoleAdmin
	} else if u.HasRole(RoleSupport) {
		return RoleSupport
	} else if u.HasRole(RoleManager) {
		return RoleManager
	} else if u.HasRole(RoleUser) {
		return RoleUser
	} else if u.HasRole(RoleAPI) {
		return RoleAPI
	} else if u.HasRole(RoleAnonymous) {
		return RoleAnonymous
	} else {
		return RoleError
	}
}


Let me search for any AuthSource or OIDC-specific logic in the templates or frontend.


web/templates/login.tmpl:42:                                    OpenID Connect Login
web/templates/base.tmpl:26:        {{if eq .Infos.displayType "PUBLIC"}}


internal/routerConfig/routes.go:123:	i["displayType"] = "DASHBOARD"
internal/routerConfig/routes.go:131:	i["displayType"] = "DETAILS"
internal/routerConfig/routes.go:139:	i["displayType"] = "PUBLIC" // Used in Main Template
internal/routerConfig/routes.go:147:	i["displayType"] = "OVERVIEW"
internal/routerConfig/routes.go:164:	i["displayType"] = "LIST"


There it is. Let me look at the route that sets `displayType` to "PUBLIC".


110		i["username"] = username
111		// TODO: If forbidden (== err exists), redirect to error page
112		if user, _ := repository.GetUserRepository().FetchUserInCtx(r.Context(), username); user != nil {
113			i["name"] = user.Name
114			i["email"] = user.Email
115		}
116		return i
117	}
118
119	func setupClusterStatusRoute(i InfoType, r *http.Request) InfoType {
120		cluster := chi.URLParam(r, "cluster")
121		i["id"] = cluster
122		i["cluster"] = cluster
123		i["displayType"] = "DASHBOARD"
124		return i
125	}
126
127	func setupClusterDetailRoute(i InfoType, r *http.Request) InfoType {
128		cluster := chi.URLParam(r, "cluster")
129		i["id"] = cluster
130		i["cluster"] = cluster
131		i["displayType"] = "DETAILS"
132		return i
133	}
134
135	func setupDashboardRoute(i InfoType, r *http.Request) InfoType {
136		cluster := chi.URLParam(r, "cluster")
137		i["id"] = cluster
138		i["cluster"] = cluster
139		i["displayType"] = "PUBLIC" // Used in Main Template
140		return i
141	}
142
143	func setupClusterOverviewRoute(i InfoType, r *http.Request) InfoType {
144		cluster := chi.URLParam(r, "cluster")
145		i["id"] = cluster
146		i["cluster"] = cluster
147		i["displayType"] = "OVERVIEW"
148
149		from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
150		if from != "" || to != "" {
151			i["from"] = from
152			i["to"] = to
153		}
154		return i
155	}
156
157	func setupClusterListRoute(i InfoType, r *http.Request) InfoType {
158		cluster := chi.URLParam(r, "cluster")
159		subcluster := chi.URLParam(r, "subcluster")
160		i["id"] = cluster
161		i["cluster"] = cluster
162		i["sid"] = subcluster
163		i["subCluster"] = subcluster
164		i["displayType"] = "LIST"
165
166		from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
167		if from != "" || to != "" {
168			i["from"] = from
169			i["to"] = to
170		}
171		return i
172	}
173
174	func setupNodeRoute(i InfoType, r *http.Request) InfoType {
175		cluster := chi.URLParam(r, "cluster")
176		hostname := chi.URLParam(r, "hostname")
177		i["cluster"] = cluster
178		i["hostname"] = hostname
179		i["id"] = fmt.Sprintf("%s (%s)", cluster, hostname)

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

	i["username"] = username
	// TODO: If forbidden (== err exists), redirect to error page
	if user, _ := repository.GetUserRepository().FetchUserInCtx(r.Context(), username); user != nil {
		i["name"] = user.Name
		i["email"] = user.Email
	}
	return i
}

func setupClusterStatusRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	i["id"] = cluster
	i["cluster"] = cluster
	i["displayType"] = "DASHBOARD"
	return i
}

func setupClusterDetailRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	i["id"] = cluster
	i["cluster"] = cluster
	i["displayType"] = "DETAILS"
	return i
}

func setupDashboardRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	i["id"] = cluster
	i["cluster"] = cluster
	i["displayType"] = "PUBLIC" // Used in Main Template
	return i
}

func setupClusterOverviewRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	i["id"] = cluster
	i["cluster"] = cluster
	i["displayType"] = "OVERVIEW"

	from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
	if from != "" || to != "" {
		i["from"] = from
		i["to"] = to
	}
	return i
}

func setupClusterListRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	subcluster := chi.URLParam(r, "subcluster")
	i["id"] = cluster
	i["cluster"] = cluster
	i["sid"] = subcluster
	i["subCluster"] = subcluster
	i["displayType"] = "LIST"

	from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
	if from != "" || to != "" {
		i["from"] = from
		i["to"] = to
	}
	return i
}

func setupNodeRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	hostname := chi.URLParam(r, "hostname")
	i["cluster"] = cluster
	i["hostname"] = hostname
	i["id"] = fmt.Sprintf("%s (%s)", cluster, hostname)


Let me search for gob registration in the codebase.


No matches found


Let me look at `GetUIConfig` and what happens for users not in the DB.


internal/repository/userConfig_test.go:76:func TestGetUIConfig(t *testing.T) {
internal/repository/userConfig.go:56:func (uCfg *UserCfgRepo) GetUIConfig(user *schema.User) (map[string]any, error) {


460							}
461							statList = append(statList, statEntry)
462						}
463					}
464				}
465			}
466			filterPresets["stats"] = statList
467		}
468		return filterPresets
469	}
470
471	func SetupRoutes(router chi.Router, buildInfo web.Build) {
472		userCfgRepo := repository.GetUserCfgRepo()
473		for _, route := range routes {
474			router.HandleFunc(route.Route, func(rw http.ResponseWriter, r *http.Request) {
475				conf, err := userCfgRepo.GetUIConfig(repository.GetUserFromContext(r.Context()))
476				if err != nil {
477					http.Error(rw, err.Error(), http.StatusInternalServerError)
478					return
479				}
480
481				title := route.Title
482				infos := route.Setup(map[string]any{}, r)
483				if id, ok := infos["id"]; ok {
484					title = strings.Replace(route.Title, "<ID>", id.(string), 1)
485					if sid, ok := infos["sid"]; ok { // 2nd ID element
486						title = strings.Replace(title, "<SID>", sid.(string), 1)
487					}
488				}
489
490				// Get User -> What if NIL?
491				user := repository.GetUserFromContext(r.Context())
492
493				// Get Roles
494				availableRoles, _ := schema.GetValidRolesMap(user)
495
496				page := web.Page{
497					Title:      title,
498					User:       *user,
499					Roles:      availableRoles,
500					Build:      buildInfo,
501					Config:     conf,
502					Resampling: config.Keys.EnableResampling,
503					Infos:      infos,
504				}
505
506				if route.Filter {
507					page.FilterPresets = buildFilterPresets(r.URL.Query())
508				}
509
510				web.RenderTemplate(rw, route.Template, &page)
511			})
512		}
513	}
514

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

						}
						statList = append(statList, statEntry)
					}
				}
			}
		}
		filterPresets["stats"] = statList
	}
	return filterPresets
}

func SetupRoutes(router chi.Router, buildInfo web.Build) {
	userCfgRepo := repository.GetUserCfgRepo()
	for _, route := range routes {
		router.HandleFunc(route.Route, func(rw http.ResponseWriter, r *http.Request) {
			conf, err := userCfgRepo.GetUIConfig(repository.GetUserFromContext(r.Context()))
			if err != nil {
				http.Error(rw, err.Error(), http.StatusInternalServerError)
				return
			}

			title := route.Title
			infos := route.Setup(map[string]any{}, r)
			if id, ok := infos["id"]; ok {
				title = strings.Replace(route.Title, "<ID>", id.(string), 1)
				if sid, ok := infos["sid"]; ok { // 2nd ID element
					title = strings.Replace(title, "<SID>", sid.(string), 1)
				}
			}

			// Get User -> What if NIL?
			user := repository.GetUserFromContext(r.Context())

			// Get Roles
			availableRoles, _ := schema.GetValidRolesMap(user)

			page := web.Page{
				Title:      title,
				User:       *user,
				Roles:      availableRoles,
				Build:      buildInfo,
				Config:     conf,
				Resampling: config.Keys.EnableResampling,
				Infos:      infos,
			}

			if route.Filter {
				page.FilterPresets = buildFilterPresets(r.URL.Query())
			}

			web.RenderTemplate(rw, route.Template, &page)
		})
	}
}


50
51		return userCfgRepoInstance
52	}
53
54	// Return the personalised UI config for the currently authenticated
55	// user or return the plain default config.
56	func (uCfg *UserCfgRepo) GetUIConfig(user *schema.User) (map[string]any, error) {
57		if user == nil {
58			copy := make(map[string]any, len(uCfg.uiDefaults))
59			maps.Copy(copy, uCfg.uiDefaults)
60			return copy, nil
61		}
62
63		// Is the cache invalidated in case the options are changed?
64		data := uCfg.cache.Get(user.Username, func() (any, time.Duration, int) {
65			uiconfig := make(map[string]any, len(uCfg.uiDefaults))
66			maps.Copy(uiconfig, uCfg.uiDefaults)
67
68			rows, err := uCfg.Lookup.Query(user.Username)
69			if err != nil {
70				cclog.Warnf("Error while looking up user uiconfig for user '%v'", user.Username)
71				return err, 0, 0
72			}
73
74			size := 0
75			defer rows.Close()
76			for rows.Next() {
77				var key, rawval string
78				if err := rows.Scan(&key, &rawval); err != nil {
79					cclog.Warn("Error while scanning user uiconfig values")
80					return err, 0, 0
81				}
82
83				var val any
84				if err := json.Unmarshal([]byte(rawval), &val); err != nil {
85					cclog.Warn("Error while unmarshaling raw user uiconfig json")
86					return err, 0, 0
87				}
88
89				size += len(key)
90				size += len(rawval)
91				uiconfig[key] = val
92			}
93
94			// Add global ShortRunningJobsDuration setting as jobList_hideShortRunningJobs
95			uiconfig["jobList_hideShortRunningJobs"] = config.Keys.ShortRunningJobsDuration
96
97			return uiconfig, 24 * time.Hour, size
98		})
99		if err, ok := data.(error); ok {
100			cclog.Error("Error in returned dataset")
101			return nil, err
102		}
103
104		return data.(map[string]any), nil
105	}
106
107	// If the context does not have a user, update the global ui configuration
108	// without persisting it!  If there is a (authenticated) user, update only his
109	// configuration.

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

	return userCfgRepoInstance
}

// Return the personalised UI config for the currently authenticated
// user or return the plain default config.
func (uCfg *UserCfgRepo) GetUIConfig(user *schema.User) (map[string]any, error) {
	if user == nil {
		copy := make(map[string]any, len(uCfg.uiDefaults))
		maps.Copy(copy, uCfg.uiDefaults)
		return copy, nil
	}

	// Is the cache invalidated in case the options are changed?
	data := uCfg.cache.Get(user.Username, func() (any, time.Duration, int) {
		uiconfig := make(map[string]any, len(uCfg.uiDefaults))
		maps.Copy(uiconfig, uCfg.uiDefaults)

		rows, err := uCfg.Lookup.Query(user.Username)
		if err != nil {
			cclog.Warnf("Error while looking up user uiconfig for user '%v'", user.Username)
			return err, 0, 0
		}

		size := 0
		defer rows.Close()
		for rows.Next() {
			var key, rawval string
			if err := rows.Scan(&key, &rawval); err != nil {
				cclog.Warn("Error while scanning user uiconfig values")
				return err, 0, 0
			}

			var val any
			if err := json.Unmarshal([]byte(rawval), &val); err != nil {
				cclog.Warn("Error while unmarshaling raw user uiconfig json")
				return err, 0, 0
			}

			size += len(key)
			size += len(rawval)
			uiconfig[key] = val
		}

		// Add global ShortRunningJobsDuration setting as jobList_hideShortRunningJobs
		uiconfig["jobList_hideShortRunningJobs"] = config.Keys.ShortRunningJobsDuration

		return uiconfig, 24 * time.Hour, size
	})
	if err, ok := data.(error); ok {
		cclog.Error("Error in returned dataset")
		return nil, err
	}

	return data.(map[string]any), nil
}

// If the context does not have a user, update the global ui configuration
// without persisting it!  If there is a (authenticated) user, update only his
// configuration.


Let me look for any custom template functions.


Let me look at AuthViaJWT to understand if it could inadvertently affect OIDC session users.


internal/auth/jwt.go:71:func (ja *JWTAuthenticator) AuthViaJWT(


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

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

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)
}


Let me check the Go version requirement in go.mod.


Check Go version in go.mod


module github.com/ClusterCockpit/cc-backend

go 1.25.0

tool (


Let me look at the git log for OIDC-related changes to understand the history.


git log --oneline --all -- internal/auth/oidc.go | head -10

Git log for oidc.go


96a79098 carry forward: uncommitted session files
d3b94c33 Completed 'Explore' agent: Find CUDA/HIP macro switching (toolu_01EbN3)
818a05f2 carry forward: uncommitted session files
ecd29b28 carry forward: uncommitted session files
21b6c1b5 carry forward: uncommitted session files
fd9b76c6 Security hardening of ldap and oicd auth implementations
f6aa40d9 Migrate from gorilla to chi web framework. add 404 handler
1d4c79c8 Unify JSON attribute naming ot use kebab style case. Cleanup configuration.
2ebab1e2 Reformat with gofumpt
8576ae45 Switch to cc-lib v2


git log --oneline --all -- web/templates/base.tmpl web/frontend/src/Header.svelte web/frontend/src/header/NavbarTools.svelte | head -10

Git log for template/frontend files


96a79098 carry forward: uncommitted session files
d3b94c33 Completed 'Explore' agent: Find CUDA/HIP macro switching (toolu_01EbN3)
818a05f2 carry forward: uncommitted session files
ecd29b28 carry forward: uncommitted session files
21b6c1b5 carry forward: uncommitted session files
363e839c Add simple log viewer in web frontend
f18ae350 review plot rendering and doublemetric opts
faacf3f3 svelte state_referenced_locally warning fixes
4cb8d648 adapt frontend to backend config changes, clarify variable names
d56b0e93 cleanup routes, cleanup root components


Show files changed in security hardening commit


commit fd9b76c6a74af3b21c783fc52fadf31795e2651d
Author: Jan Eitzinger <jan@moebiusband.org>
Date:   Mon Feb 9 09:12:06 2026 +0100

    Security hardening of ldap and oicd auth implementations

 internal/auth/auth.go   |   5 ++
 internal/auth/ldap.go   | 122 ++++++++++++++++++++++++------------------------
 internal/auth/oidc.go   | 113 +++++++++++++++++++++++++++++++++-----------
 internal/auth/schema.go |   8 ++++
 4 files changed, 161 insertions(+), 87 deletions(-)


git show fd9b76c6 -- internal/auth/oidc.go | head -120

Show OIDC changes in security hardening commit


commit fd9b76c6a74af3b21c783fc52fadf31795e2651d
Author: Jan Eitzinger <jan@moebiusband.org>
Date:   Mon Feb 9 09:12:06 2026 +0100

    Security hardening of ldap and oicd auth implementations

diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go
index f81b651f..ec6c77a7 100644
--- a/internal/auth/oidc.go
+++ b/internal/auth/oidc.go
@@ -9,6 +9,7 @@ import (
 	"context"
 	"crypto/rand"
 	"encoding/base64"
+	"fmt"
 	"io"
 	"net/http"
 	"os"
@@ -50,6 +51,7 @@ func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value strin
 		MaxAge:   int(time.Hour.Seconds()),
 		Secure:   r.TLS != nil,
 		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
 	}
 	http.SetCookie(w, c)
 }
@@ -77,8 +79,7 @@ func NewOIDC(a *Authentication) *OIDC {
 		ClientID:     clientID,
 		ClientSecret: clientSecret,
 		Endpoint:     provider.Endpoint(),
-		RedirectURL:  "oidc-callback",
-		Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
+		Scopes:       []string{oidc.ScopeOpenID, "profile"},
 	}

 	oa := &OIDC{provider: provider, client: client, clientID: clientID, authentication: a}
@@ -122,56 +123,95 @@ func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) {

 	token, err := oa.client.Exchange(ctx, code, oauth2.VerifierOption(codeVerifier))
 	if err != nil {
-		http.Error(rw, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
+		cclog.Errorf("token exchange failed: %s", err.Error())
+		http.Error(rw, "Authentication failed during token exchange", http.StatusInternalServerError)
 		return
 	}

 	// Get user info from OIDC provider with same timeout
 	userInfo, err := oa.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
 	if err != nil {
-		http.Error(rw, "Failed to get userinfo: "+err.Error(), http.StatusInternalServerError)
+		cclog.Errorf("failed to get userinfo: %s", err.Error())
+		http.Error(rw, "Failed to retrieve user information", http.StatusInternalServerError)
 		return
 	}

-	// // Extract the ID Token from OAuth2 token.
-	// rawIDToken, ok := token.Extra("id_token").(string)
-	// if !ok {
-	// 	http.Error(rw, "Cannot access idToken", http.StatusInternalServerError)
-	// }
-	//
-	// verifier := oa.provider.Verifier(&oidc.Config{ClientID: oa.clientID})
-	// // Parse and verify ID Token payload.
-	// idToken, err := verifier.Verify(context.Background(), rawIDToken)
-	// if err != nil {
-	// 	http.Error(rw, "Failed to extract idToken: "+err.Error(), http.StatusInternalServerError)
-	// }
+	// Verify ID token and nonce to prevent replay attacks
+	rawIDToken, ok := token.Extra("id_token").(string)
+	if !ok {
+		http.Error(rw, "ID token not found in response", http.StatusInternalServerError)
+		return
+	}
+
+	nonceCookie, err := r.Cookie("nonce")
+	if err != nil {
+		http.Error(rw, "nonce cookie not found", http.StatusBadRequest)
+		return
+	}
+
+	verifier := oa.provider.Verifier(&oidc.Config{ClientID: oa.clientID})
+	idToken, err := verifier.Verify(ctx, rawIDToken)
+	if err != nil {
+		cclog.Errorf("ID token verification failed: %s", err.Error())
+		http.Error(rw, "ID token verification failed", http.StatusInternalServerError)
+		return
+	}
+
+	if idToken.Nonce != nonceCookie.Value {
+		http.Error(rw, "Nonce mismatch", http.StatusBadRequest)
+		return
+	}

 	projects := make([]string, 0)

-	// Extract custom claims
+	// Extract custom claims from userinfo
 	var claims struct {
 		Username string `json:"preferred_username"`
 		Name     string `json:"name"`
-		Profile  struct {
+		// Keycloak realm-level roles
+		RealmAccess struct {
+			Roles []string `json:"roles"`
+		} `json:"realm_access"`
+		// Keycloak client-level roles
+		ResourceAccess struct {
 			Client struct {
 				Roles []string `json:"roles"`
 			} `json:"clustercockpit"`
 		} `json:"resource_access"`
 	}
 	if err := userInfo.Claims(&claims); err != nil {
-		http.Error(rw, "Failed to extract Claims: "+err.Error(), http.StatusInternalServerError)
+		cclog.Errorf("failed to extract claims: %s", err.Error())
+		http.Error(rw, "Failed to extract user claims", http.StatusInternalServerError)
+		return
 	}

-	var roles []string


git show fd9b76c6 -- internal/auth/auth.go

Show auth.go changes in security hardening commit