From 9c3beddf5454622c0df2507c84b609e38c6b015e Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 14 Jan 2026 15:39:38 +0100 Subject: [PATCH] Improve documentation --- internal/api/rest.go | 147 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 125 insertions(+), 22 deletions(-) diff --git a/internal/api/rest.go b/internal/api/rest.go index 195de826..27374f6e 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -48,6 +48,7 @@ import ( const ( noticeFilePath = "./var/notice.txt" noticeFilePerms = 0o644 + maxNoticeLength = 10000 // Maximum allowed notice content length in characters ) type RestAPI struct { @@ -61,6 +62,7 @@ type RestAPI struct { RepositoryMutex sync.Mutex } +// New creates and initializes a new RestAPI instance with configured dependencies. func New() *RestAPI { return &RestAPI{ JobRepository: repository.GetJobRepository(), @@ -69,6 +71,8 @@ func New() *RestAPI { } } +// MountAPIRoutes registers REST API endpoints for job and cluster management. +// These routes use JWT token authentication via the X-Auth-Token header. func (api *RestAPI) MountAPIRoutes(r *mux.Router) { r.StrictSlash(true) // REST API Uses TokenAuth @@ -103,6 +107,8 @@ func (api *RestAPI) MountAPIRoutes(r *mux.Router) { } } +// MountUserAPIRoutes registers user-accessible REST API endpoints. +// These are limited endpoints for regular users with JWT token authentication. func (api *RestAPI) MountUserAPIRoutes(r *mux.Router) { r.StrictSlash(true) // REST API Uses TokenAuth @@ -112,6 +118,8 @@ func (api *RestAPI) MountUserAPIRoutes(r *mux.Router) { r.HandleFunc("/jobs/metrics/{id}", api.getJobMetrics).Methods(http.MethodGet) } +// MountMetricStoreAPIRoutes registers metric storage API endpoints. +// These endpoints handle metric data ingestion and health checks with JWT token authentication. func (api *RestAPI) MountMetricStoreAPIRoutes(r *mux.Router) { // REST API Uses TokenAuth // Note: StrictSlash handles trailing slash variations automatically @@ -126,6 +134,8 @@ func (api *RestAPI) MountMetricStoreAPIRoutes(r *mux.Router) { r.HandleFunc("/api/healthcheck/", metricsHealth).Methods(http.MethodGet) } +// MountConfigAPIRoutes registers configuration and user management endpoints. +// These routes use session-based authentication and require admin privileges. func (api *RestAPI) MountConfigAPIRoutes(r *mux.Router) { r.StrictSlash(true) // Settings Frontend Uses SessionAuth @@ -139,6 +149,8 @@ func (api *RestAPI) MountConfigAPIRoutes(r *mux.Router) { } } +// MountFrontendAPIRoutes registers frontend-specific API endpoints. +// These routes support JWT generation and user configuration updates with session authentication. func (api *RestAPI) MountFrontendAPIRoutes(r *mux.Router) { r.StrictSlash(true) // Settings Frontend Uses SessionAuth @@ -160,6 +172,8 @@ type DefaultAPIResponse struct { Message string `json:"msg"` } +// handleError writes a standardized JSON error response with the given status code. +// It logs the error at WARN level and ensures proper Content-Type headers are set. func handleError(err error, statusCode int, rw http.ResponseWriter) { cclog.Warnf("REST ERROR : %s", err.Error()) rw.Header().Add("Content-Type", "application/json") @@ -172,15 +186,38 @@ func handleError(err error, statusCode int, rw http.ResponseWriter) { } } +// decode reads JSON from r into val with strict validation that rejects unknown fields. func decode(r io.Reader, val any) error { dec := json.NewDecoder(r) dec.DisallowUnknownFields() return dec.Decode(val) } -func (api *RestAPI) editNotice(rw http.ResponseWriter, r *http.Request) { - // SecuredCheck() only worked with TokenAuth: Removed +// validatePathComponent checks if a path component contains potentially malicious patterns +// that could be used for path traversal attacks. Returns an error if validation fails. +func validatePathComponent(component, componentName string) error { + if strings.Contains(component, "..") || + strings.Contains(component, "/") || + strings.Contains(component, "\\") { + return fmt.Errorf("invalid %s", componentName) + } + return nil +} +// editNotice godoc +// @summary Update system notice +// @tags Config +// @description Updates the notice.txt file content. Only admins are allowed. Content is limited to 10000 characters. +// @accept mpfd +// @produce plain +// @param new-content formData string true "New notice content (max 10000 characters)" +// @success 200 {string} string "Update Notice Content Success" +// @failure 400 {object} ErrorResponse "Bad Request" +// @failure 403 {object} ErrorResponse "Forbidden" +// @failure 500 {object} ErrorResponse "Internal Server Error" +// @security ApiKeyAuth +// @router /notice/ [post] +func (api *RestAPI) editNotice(rw http.ResponseWriter, r *http.Request) { if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) { handleError(fmt.Errorf("only admins are allowed to update the notice.txt file"), http.StatusForbidden, rw) return @@ -189,9 +226,8 @@ func (api *RestAPI) editNotice(rw http.ResponseWriter, r *http.Request) { // Get Value newContent := r.FormValue("new-content") - // Validate content length to prevent DoS - if len(newContent) > 10000 { - handleError(fmt.Errorf("notice content exceeds maximum length of 10000 characters"), http.StatusBadRequest, rw) + if len(newContent) > maxNoticeLength { + handleError(fmt.Errorf("notice content exceeds maximum length of %d characters", maxNoticeLength), http.StatusBadRequest, rw) return } @@ -203,7 +239,9 @@ func (api *RestAPI) editNotice(rw http.ResponseWriter, r *http.Request) { handleError(fmt.Errorf("creating notice file failed: %w", err), http.StatusInternalServerError, rw) return } - ntxt.Close() + if err := ntxt.Close(); err != nil { + cclog.Warnf("Failed to close notice file: %v", err) + } } if err := os.WriteFile(noticeFilePath, []byte(newContent), noticeFilePerms); err != nil { @@ -213,13 +251,30 @@ func (api *RestAPI) editNotice(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "text/plain") rw.WriteHeader(http.StatusOK) + var msg []byte if newContent != "" { - rw.Write([]byte("Update Notice Content Success")) + msg = []byte("Update Notice Content Success") } else { - rw.Write([]byte("Empty Notice Content Success")) + msg = []byte("Empty Notice Content Success") + } + if _, err := rw.Write(msg); err != nil { + cclog.Errorf("Failed to write response: %v", err) } } +// getJWT godoc +// @summary Generate JWT token +// @tags Frontend +// @description Generates a JWT token for a user. Admins can generate tokens for any user, regular users only for themselves. +// @accept mpfd +// @produce plain +// @param username formData string true "Username to generate JWT for" +// @success 200 {string} string "JWT token" +// @failure 403 {object} ErrorResponse "Forbidden" +// @failure 404 {object} ErrorResponse "User Not Found" +// @failure 500 {object} ErrorResponse "Internal Server Error" +// @security ApiKeyAuth +// @router /jwt/ [get] func (api *RestAPI) getJWT(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "text/plain") username := r.FormValue("username") @@ -244,12 +299,22 @@ func (api *RestAPI) getJWT(rw http.ResponseWriter, r *http.Request) { } rw.WriteHeader(http.StatusOK) - rw.Write([]byte(jwt)) + if _, err := rw.Write([]byte(jwt)); err != nil { + cclog.Errorf("Failed to write JWT response: %v", err) + } } +// getRoles godoc +// @summary Get available roles +// @tags Config +// @description Returns a list of valid user roles. Only admins are allowed. +// @produce json +// @success 200 {array} string "List of role names" +// @failure 403 {object} ErrorResponse "Forbidden" +// @failure 500 {object} ErrorResponse "Internal Server Error" +// @security ApiKeyAuth +// @router /roles/ [get] func (api *RestAPI) getRoles(rw http.ResponseWriter, r *http.Request) { - // SecuredCheck() only worked with TokenAuth: Removed - user := repository.GetUserFromContext(r.Context()) if !user.HasRole(schema.RoleAdmin) { handleError(fmt.Errorf("only admins are allowed to fetch a list of roles"), http.StatusForbidden, rw) @@ -268,6 +333,18 @@ func (api *RestAPI) getRoles(rw http.ResponseWriter, r *http.Request) { } } +// updateConfiguration godoc +// @summary Update user configuration +// @tags Frontend +// @description Updates a user's configuration key-value pair. +// @accept mpfd +// @produce plain +// @param key formData string true "Configuration key" +// @param value formData string true "Configuration value" +// @success 200 {string} string "success" +// @failure 500 {object} ErrorResponse "Internal Server Error" +// @security ApiKeyAuth +// @router /configuration/ [post] func (api *RestAPI) updateConfiguration(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "text/plain") key, value := r.FormValue("key"), r.FormValue("value") @@ -278,9 +355,25 @@ func (api *RestAPI) updateConfiguration(rw http.ResponseWriter, r *http.Request) } rw.WriteHeader(http.StatusOK) - rw.Write([]byte("success")) + if _, err := rw.Write([]byte("success")); err != nil { + cclog.Errorf("Failed to write response: %v", err) + } } +// putMachineState godoc +// @summary Store machine state +// @tags Machine State +// @description Stores machine state data for a specific cluster node. Validates cluster and host names to prevent path traversal. +// @accept json +// @produce plain +// @param cluster path string true "Cluster name" +// @param host path string true "Host name" +// @success 201 "Created" +// @failure 400 {object} ErrorResponse "Bad Request" +// @failure 404 {object} ErrorResponse "Machine state not enabled" +// @failure 500 {object} ErrorResponse "Internal Server Error" +// @security ApiKeyAuth +// @router /machine_state/{cluster}/{host} [put] func (api *RestAPI) putMachineState(rw http.ResponseWriter, r *http.Request) { if api.MachineStateDir == "" { handleError(fmt.Errorf("machine state not enabled"), http.StatusNotFound, rw) @@ -291,13 +384,12 @@ func (api *RestAPI) putMachineState(rw http.ResponseWriter, r *http.Request) { cluster := vars["cluster"] host := vars["host"] - // Validate cluster and host to prevent path traversal attacks - if strings.Contains(cluster, "..") || strings.Contains(cluster, "/") || strings.Contains(cluster, "\\") { - handleError(fmt.Errorf("invalid cluster name"), http.StatusBadRequest, rw) + if err := validatePathComponent(cluster, "cluster name"); err != nil { + handleError(err, http.StatusBadRequest, rw) return } - if strings.Contains(host, "..") || strings.Contains(host, "/") || strings.Contains(host, "\\") { - handleError(fmt.Errorf("invalid host name"), http.StatusBadRequest, rw) + if err := validatePathComponent(host, "host name"); err != nil { + handleError(err, http.StatusBadRequest, rw) return } @@ -323,6 +415,18 @@ func (api *RestAPI) putMachineState(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusCreated) } +// getMachineState godoc +// @summary Retrieve machine state +// @tags Machine State +// @description Retrieves stored machine state data for a specific cluster node. Validates cluster and host names to prevent path traversal. +// @produce json +// @param cluster path string true "Cluster name" +// @param host path string true "Host name" +// @success 200 {object} object "Machine state JSON data" +// @failure 400 {object} ErrorResponse "Bad Request" +// @failure 404 {object} ErrorResponse "Machine state not enabled or file not found" +// @security ApiKeyAuth +// @router /machine_state/{cluster}/{host} [get] func (api *RestAPI) getMachineState(rw http.ResponseWriter, r *http.Request) { if api.MachineStateDir == "" { handleError(fmt.Errorf("machine state not enabled"), http.StatusNotFound, rw) @@ -333,13 +437,12 @@ func (api *RestAPI) getMachineState(rw http.ResponseWriter, r *http.Request) { cluster := vars["cluster"] host := vars["host"] - // Validate cluster and host to prevent path traversal attacks - if strings.Contains(cluster, "..") || strings.Contains(cluster, "/") || strings.Contains(cluster, "\\") { - handleError(fmt.Errorf("invalid cluster name"), http.StatusBadRequest, rw) + if err := validatePathComponent(cluster, "cluster name"); err != nil { + handleError(err, http.StatusBadRequest, rw) return } - if strings.Contains(host, "..") || strings.Contains(host, "/") || strings.Contains(host, "\\") { - handleError(fmt.Errorf("invalid host name"), http.StatusBadRequest, rw) + if err := validatePathComponent(host, "host name"); err != nil { + handleError(err, http.StatusBadRequest, rw) return }