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  }