...

Source file src/edge-infra.dev/pkg/f8n/devinfra/ghappman/ghappman.go

Documentation: edge-infra.dev/pkg/f8n/devinfra/ghappman

     1  // Package ghappman contains functions to create a ghappman server, as well as types
     2  // that can be shared between client/server code for request/responses
     3  package ghappman
     4  
     5  import (
     6  	"embed"
     7  	"fmt"
     8  	"html/template"
     9  	"log"
    10  	"math/rand"
    11  	"net/http"
    12  	"os"
    13  	"time"
    14  
    15  	"github.com/gin-gonic/gin"
    16  	"github.com/google/go-github/v33/github"
    17  	"golang.org/x/crypto/bcrypt"
    18  )
    19  
    20  //go:embed templates
    21  var templates embed.FS
    22  
    23  const (
    24  	// createAppTimeout controls how long the server will hold onto a new app creation result before
    25  	// expiring it. Clients should not expect a result if this duration has passed
    26  	createAppTimeout = time.Hour
    27  	ghamURL          = "https://ghappman.edge-infra.dev"
    28  )
    29  
    30  // AppConfig represents the output of github app creation via the manifest flow
    31  type AppConfig struct {
    32  	Name         string `json:"name"`
    33  	ClientID     string `json:"client_id"`
    34  	ClientSecret string `json:"client_secret"`
    35  	PEM          string `json:"pem"`
    36  	AppID        string `json:"id"`
    37  }
    38  
    39  // CreateAppReq requests that the server start a new app creation session
    40  type CreateAppReq struct {
    41  	UserKey       string `json:"user_key" binding:"required"`
    42  	InstallSuffix string `json:"install_suffix" binding:"required"`
    43  }
    44  
    45  // CreateAppRes contains information for the client to continue with app creation
    46  type CreateAppRes struct {
    47  	State     string        `json:"state"`
    48  	CreateURL string        `json:"create_url"`
    49  	ExpiresIn time.Duration `json:"expires_in"`
    50  }
    51  
    52  // Server is the ghappman web server
    53  type Server struct {
    54  	*gin.Engine
    55  	Sessions *sessionStore
    56  }
    57  
    58  // RandAN create a length n random alphanumeric string
    59  func RandAN(n int) string {
    60  	var runes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
    61  	genName := make([]rune, n)
    62  	for i := range genName {
    63  		genName[i] = runes[rand.Intn(len(runes))] // #nosec G404 - string is cosmetic only
    64  	}
    65  	return string(genName)
    66  }
    67  
    68  // NewServer create a ghappman gin server
    69  func NewServer() *Server {
    70  	server := &Server{
    71  		Engine:   gin.Default(),
    72  		Sessions: newSessionStore(createAppTimeout),
    73  	}
    74  	logger := log.New(os.Stdout, "ghappman", log.Ldate|log.Ltime|log.Lshortfile)
    75  
    76  	t, err := template.ParseFS(templates, "templates/*.tmpl")
    77  	if err != nil {
    78  		logger.Fatalf("failed to load templates dir. err: %v", err.Error())
    79  	}
    80  	server.SetHTMLTemplate(t)
    81  
    82  	server.GET("/", func(c *gin.Context) {
    83  		// Healthcheck
    84  		c.Status(http.StatusOK)
    85  	})
    86  
    87  	server.POST("/new-app", func(c *gin.Context) {
    88  		var body CreateAppReq
    89  		if err := c.ShouldBindJSON(&body); err != nil {
    90  			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    91  			return
    92  		}
    93  		state := RandAN(64)
    94  		createURL := fmt.Sprintf("%s/new-app?state=%s&install_suffix=%s", ghamURL, state, body.InstallSuffix)
    95  
    96  		hash, _ := bcrypt.GenerateFromPassword([]byte(body.UserKey), bcrypt.MinCost)
    97  		server.Sessions.startSession(state, hash)
    98  
    99  		res := &CreateAppRes{
   100  			State:     state,
   101  			CreateURL: createURL,
   102  			ExpiresIn: createAppTimeout,
   103  		}
   104  		c.JSON(http.StatusAccepted, res)
   105  	})
   106  
   107  	server.GET("/new-app", func(c *gin.Context) {
   108  		installSuffix := c.Query("install_suffix")
   109  		state := c.Query("state")
   110  		if installSuffix == "" || state == "" {
   111  			c.AbortWithStatus(http.StatusBadRequest)
   112  			return
   113  		}
   114  		if !server.Sessions.hasSession(state) {
   115  			c.AbortWithStatus(http.StatusBadRequest)
   116  			return
   117  		}
   118  		c.HTML(http.StatusOK, "form.tmpl", gin.H{
   119  			"state":       state,
   120  			"installName": installSuffix,
   121  		})
   122  
   123  		// TODO: figure out what to do if user goes to new app page in browser manually
   124  		// instead of via bootstrapping client (without posting to start session)
   125  	})
   126  
   127  	server.GET("/new-app/:state/callback", func(c *gin.Context) {
   128  		code := c.Query("code")
   129  		state := c.Param("state")
   130  		// Handle callback
   131  		if code == "" || state == "" {
   132  			c.AbortWithStatus(http.StatusBadRequest)
   133  			return
   134  		}
   135  		if !server.Sessions.hasSession(state) {
   136  			c.AbortWithStatus(http.StatusNotFound)
   137  			return
   138  		}
   139  
   140  		// Exchange code to retrieve app configuration
   141  		// POST /app-manifests/{code}/conversions
   142  		client := github.NewClient(nil)
   143  		app, _, err := client.Apps.CompleteAppManifest(c.Request.Context(), code)
   144  		if err != nil {
   145  			logger.Printf("failed to complete app manifest. err: %s\n", err.Error())
   146  			c.Status(http.StatusBadGateway)
   147  			return
   148  		}
   149  
   150  		session := server.Sessions.getSession(state)
   151  		if session == nil {
   152  			c.AbortWithStatus(http.StatusInternalServerError)
   153  			return
   154  		}
   155  		sessionApp := &AppConfig{
   156  			Name:     *app.Name,
   157  			ClientID: *app.ClientID,
   158  			PEM:      *app.PEM,
   159  			AppID:    fmt.Sprint(*app.ID),
   160  		}
   161  		server.Sessions.updateSession(state, sessionApp)
   162  
   163  		logger.Println("app created, will attempt to start installing it...")
   164  		logger.Printf("id: %d\n", app.ID)
   165  		logger.Printf("name: %s\n", *app.Name)
   166  
   167  		// success page (or different page) sends user BACK to github, again, for app installation.
   168  		// at https://github.com/apps/<app name>/installations/new
   169  
   170  		// app is created at this point
   171  		if app.Name == nil || *app.Name == "" {
   172  			logger.Println("failed to create app. app name from github was missing")
   173  			c.Status(http.StatusBadGateway)
   174  			return
   175  		}
   176  		installationURL := fmt.Sprintf("https://github.com/apps/%s/installations/new", *app.Name)
   177  		c.Redirect(http.StatusFound, installationURL)
   178  	})
   179  
   180  	server.GET("/new-app/:state/app", func(c *gin.Context) {
   181  		// basic request parameter validation
   182  		state := c.Param("state")
   183  		if state == "" {
   184  			c.AbortWithStatus(http.StatusBadRequest)
   185  			return
   186  		}
   187  
   188  		// application level auth behind IAP
   189  		key := c.Request.Header.Get("X-Appkey")
   190  		if key == "" {
   191  			c.AbortWithStatus(http.StatusUnauthorized)
   192  			return
   193  		}
   194  		session := server.Sessions.getSession(state)
   195  		if session == nil {
   196  			c.AbortWithStatus(http.StatusNotFound)
   197  			return
   198  		}
   199  		hash := session.KeyHash
   200  		if err := bcrypt.CompareHashAndPassword(hash, []byte(key)); err != nil {
   201  			c.AbortWithStatus(http.StatusNotFound)
   202  			return
   203  		}
   204  
   205  		// if request is authorized either return session data or
   206  		// status indicating the new app isnt ready yet
   207  		appConfig := session.App
   208  		if appConfig == nil {
   209  			c.AbortWithStatus(http.StatusBadRequest)
   210  			return
   211  		}
   212  		fmt.Printf("app creation complete. expiring the session: %v\n", state)
   213  		server.Sessions.endSession(state)
   214  		c.JSON(http.StatusOK, appConfig)
   215  	})
   216  
   217  	return server
   218  }
   219  

View as plain text