github.com/pelicanplatform/pelican@v1.0.5/web_ui/ui.go (about) 1 /*************************************************************** 2 * 3 * Copyright (C) 2023, Pelican Project, Morgridge Institute for Research 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); you 6 * may not use this file except in compliance with the License. You may 7 * obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 * 17 ***************************************************************/ 18 19 package web_ui 20 21 import ( 22 "context" 23 "embed" 24 "fmt" 25 "math/rand" 26 "mime" 27 "net/http" 28 "os" 29 "os/signal" 30 "strings" 31 "syscall" 32 "time" 33 34 "github.com/gin-gonic/gin" 35 "github.com/pelicanplatform/pelican/metrics" 36 "github.com/pelicanplatform/pelican/param" 37 "github.com/pkg/errors" 38 log "github.com/sirupsen/logrus" 39 ginprometheus "github.com/zsais/go-gin-prometheus" 40 "golang.org/x/term" 41 ) 42 43 var ( 44 45 //go:embed frontend/out/* 46 webAssets embed.FS 47 ) 48 49 func getConfigValues(ctx *gin.Context) { 50 user := ctx.GetString("User") 51 if user == "" { 52 ctx.JSON(401, gin.H{"error": "Authentication required to visit this API"}) 53 return 54 } 55 config, err := param.GetUnmarshaledConfig() 56 if err != nil { 57 ctx.JSON(500, gin.H{"error": "Failed to get the unmarshaled config"}) 58 return 59 } 60 61 ctx.JSON(200, config) 62 } 63 64 func configureWebResource(engine *gin.Engine) error { 65 engine.GET("/view/*path", func(ctx *gin.Context) { 66 path := ctx.Param("path") 67 68 if strings.HasSuffix(path, "/") { 69 path += "index.html" 70 } 71 72 db := authDB.Load() 73 user, err := getUser(ctx) 74 75 // Redirect initialized users from initialization pages 76 if strings.HasPrefix(path, "/initialization") && strings.HasSuffix(path, "index.html") { 77 78 // If the user has been initialized previously 79 if db != nil { 80 ctx.Redirect(http.StatusFound, "/view/") 81 return 82 } 83 } 84 85 // Redirect authenticated users from login pages 86 if strings.HasPrefix(path, "/login") && strings.HasSuffix(path, "index.html") { 87 88 // If the user has been authenticated previously 89 if err == nil && user != "" { 90 ctx.Redirect(http.StatusFound, "/view/") 91 return 92 } 93 } 94 95 // Direct uninitialized users to initialization pages 96 if !strings.HasPrefix(path, "/initialization") && strings.HasSuffix(path, "index.html") { 97 98 // If the user has not been initialized previously 99 if db == nil { 100 ctx.Redirect(http.StatusFound, "/view/initialization/code/") 101 return 102 } 103 } 104 105 // Direct unauthenticated initialized users to login pages 106 if !strings.HasPrefix(path, "/login") && strings.HasSuffix(path, "index.html") { 107 108 // If the user is not authenticated but initialized 109 if (err != nil || user == "") && db != nil { 110 ctx.Redirect(http.StatusFound, "/view/login/") 111 return 112 } 113 } 114 115 filePath := "frontend/out" + path 116 file, _ := webAssets.ReadFile(filePath) 117 ctx.Data( 118 http.StatusOK, 119 mime.TypeByExtension(filePath), 120 file, 121 ) 122 }) 123 124 engine.GET("/api/v1.0/docs", func(ctx *gin.Context) { 125 126 filePath := "frontend/out/api/docs/index.html" 127 file, _ := webAssets.ReadFile(filePath) 128 ctx.Data( 129 http.StatusOK, 130 mime.TypeByExtension(filePath), 131 file, 132 ) 133 }) 134 135 return nil 136 } 137 138 // Configure common endpoint available to all server web UI which are located at /api/v1.0/* 139 func configureCommonEndpoints(engine *gin.Engine) error { 140 engine.GET("/api/v1.0/config", authHandler, getConfigValues) 141 142 return nil 143 } 144 145 // Configure metrics related endpoints, including Prometheus and /health API 146 func configureMetrics(engine *gin.Engine, isDirector bool) error { 147 // Add authorization to /metric endpoint 148 engine.Use(promMetricAuthHandler) 149 150 err := ConfigureEmbeddedPrometheus(engine, isDirector) 151 if err != nil { 152 return err 153 } 154 155 prometheusMonitor := ginprometheus.NewPrometheus("gin") 156 prometheusMonitor.Use(engine) 157 158 engine.GET("/api/v1.0/health", authHandler, func(ctx *gin.Context) { 159 healthStatus := metrics.GetHealthStatus() 160 ctx.JSON(http.StatusOK, healthStatus) 161 }) 162 return nil 163 } 164 165 // Send the one-time code for initial web UI login to stdout and periodically 166 // re-generate one-time code if user hasn't finished setup 167 func waitUntilLogin(ctx context.Context) error { 168 if authDB.Load() != nil { 169 return nil 170 } 171 sigs := make(chan os.Signal, 1) 172 signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 173 174 hostname := param.Server_Hostname.GetString() 175 port := param.Server_WebPort.GetInt() 176 isTTY := false 177 if term.IsTerminal(int(os.Stdout.Fd())) { 178 isTTY = true 179 fmt.Printf("\n\n\n\n") 180 } 181 activationFile := param.Server_UIActivationCodeFile.GetString() 182 183 defer func() { 184 if err := os.Remove(activationFile); err != nil { 185 log.Warningf("Failed to remove activation code file (%v): %v\n", activationFile, err) 186 } 187 }() 188 for { 189 previousCode.Store(currentCode.Load()) 190 newCode := fmt.Sprintf("%06v", rand.Intn(1000000)) 191 currentCode.Store(&newCode) 192 newCodeWithNewline := fmt.Sprintf("%v\n", newCode) 193 if err := os.WriteFile(activationFile, []byte(newCodeWithNewline), 0600); err != nil { 194 log.Errorf("Failed to write activation code to file (%v): %v\n", activationFile, err) 195 } 196 197 if isTTY { 198 fmt.Printf("\033[A\033[A\033[A\033[A") 199 fmt.Printf("\033[2K\n") 200 fmt.Printf("\033[2K\rPelican admin interface is not initialized\n\033[2KTo initialize, "+ 201 "login at \033[1;34mhttps://%v:%v/view/initialization/code/\033[0m with the following code:\n", 202 hostname, port) 203 fmt.Printf("\033[2K\r\033[1;34m%v\033[0m\n", *currentCode.Load()) 204 } else { 205 fmt.Printf("Pelican admin interface is not initialized\n To initialize, login at https://%v:%v/view/initialization/code/ with the following code:\n", hostname, port) 206 fmt.Println(*currentCode.Load()) 207 } 208 start := time.Now() 209 for time.Since(start) < 30*time.Second { 210 select { 211 case <-sigs: 212 return errors.New("Process terminated...") 213 case <-ctx.Done(): 214 return nil 215 default: 216 time.Sleep(100 * time.Millisecond) 217 } 218 if authDB.Load() != nil { 219 return nil 220 } 221 } 222 } 223 } 224 225 // Configure endpoints for server web APIs. This function does not configure any UI 226 // specific paths but just redirect root path to /view. 227 // 228 // You need to mount the static resources for UI in a separate function 229 func ConfigureServerWebAPI(engine *gin.Engine, isDirector bool) error { 230 if err := configureAuthEndpoints(engine); err != nil { 231 return err 232 } 233 if err := configureCommonEndpoints(engine); err != nil { 234 return err 235 } 236 if err := configureWebResource(engine); err != nil { 237 return err 238 } 239 if err := configureMetrics(engine, isDirector); err != nil { 240 return err 241 } 242 // Redirect root to /view for web UI 243 engine.GET("/", func(c *gin.Context) { 244 c.Redirect(http.StatusFound, "/view/") 245 }) 246 return nil 247 } 248 249 // Setup the initial server web login by sending the one-time code to stdout 250 // and record health status of the WebUI based on the success of the initialization 251 func InitServerWebLogin() { 252 metrics.SetComponentHealthStatus(metrics.Server_WebUI, metrics.StatusWarning, "Authentication not initialized") 253 254 if err := waitUntilLogin(context.Background()); err != nil { 255 log.Errorln("Failure when waiting for web UI to be initialized:", err) 256 return 257 } 258 metrics.SetComponentHealthStatus(metrics.Server_WebUI, metrics.StatusOK, "") 259 } 260 261 func GetEngine() (*gin.Engine, error) { 262 gin.SetMode(gin.ReleaseMode) 263 engine := gin.New() 264 engine.Use(gin.Recovery()) 265 webLogger := log.WithFields(log.Fields{"daemon": "gin"}) 266 engine.Use(func(ctx *gin.Context) { 267 startTime := time.Now() 268 269 ctx.Next() 270 271 latency := time.Since(startTime) 272 webLogger.WithFields(log.Fields{"method": ctx.Request.Method, 273 "status": ctx.Writer.Status(), 274 "time": latency.String(), 275 "client": ctx.RemoteIP(), 276 "resource": ctx.Request.URL.Path}, 277 ).Info("Served Request") 278 }) 279 return engine, nil 280 } 281 282 func RunEngine(engine *gin.Engine) { 283 certFile := param.Server_TLSCertificate.GetString() 284 keyFile := param.Server_TLSKey.GetString() 285 286 addr := fmt.Sprintf("%v:%v", param.Server_WebHost.GetString(), param.Server_WebPort.GetInt()) 287 288 log.Debugln("Starting web engine at address", addr) 289 err := engine.RunTLS(addr, certFile, keyFile) 290 if err != nil { 291 panic(err) 292 } 293 }