package web import ( "context" "fmt" "io/fs" "log" "os" "strings" "text/template" "time" "code.crute.us/mcrute/ssh-proxy/app" "code.crute.us/mcrute/ssh-proxy/app/controllers" "code.crute.us/mcrute/ssh-proxy/app/middleware" "code.crute.us/mcrute/ssh-proxy/app/models" "code.crute.us/mcrute/golib/cli" "code.crute.us/mcrute/golib/clients/autocert/v2" "code.crute.us/mcrute/golib/clients/netbox/v3" "code.crute.us/mcrute/golib/db/mongodb/v2" glecho "code.crute.us/mcrute/golib/echo" glcontroller "code.crute.us/mcrute/golib/echo/controller" glmiddleware "code.crute.us/mcrute/golib/echo/middleware" "code.crute.us/mcrute/golib/echo/session" "code.crute.us/mcrute/golib/secrets" "github.com/go-webauthn/webauthn/webauthn" "github.com/gorilla/websocket" "github.com/labstack/echo/v4" "github.com/spf13/cobra" ) func Register(root *cobra.Command, embeddedTemplates, embeddedClients fs.FS, appVersion string) { webCmd := &cobra.Command{ Use: "web [options]", Short: "Run web server", Run: func(c *cobra.Command, args []string) { cfg := app.Config{} cli.MustGetConfig(c, &cfg) webMain(cfg, embeddedTemplates, embeddedClients, appVersion) }, } cli.AddFlags(webCmd, &app.Config{}, app.DefaultConfig, "web") root.AddCommand(webCmd) } func PopulateTemplateContext(c echo.Context) (interface{}, error) { // May not be set if we're being called from something other than // the generic template controller, which can happen in the order // redirect controller. cp, _ := c.Get("CanonicalPath").(string) return &app.PageContext{ PageName: strings.SplitN(cp, ".", 2)[0], Year: time.Now().Year(), RenderTime: time.Now().Format(time.RFC1123), Flags: glcontroller.NewFeatureFlags(), CSRFToken: glmiddleware.GetCSRFToken(c), Context: glcontroller.NewPageContext(), }, nil } func webMain(cfg app.Config, embeddedTemplates, embeddedClients fs.FS, appVersion string) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() gt := &glcontroller.GenericTemplateHandler{Render: PopulateTemplateContext} s, err := glecho.NewEchoWrapper(ctx, cfg.Debug) if err != nil { log.Fatalf("Error building echo: %s", err) } vc, err := glecho.MakeVaultSecretsClient(ctx) if err != nil { log.Fatalf("Error making vault client %s", err) } if err = s.Configure(glecho.EchoConfig{ ApplicationName: "app-server", ApplicationVersion: appVersion, BindAddresses: cfg.Bind, DiskTemplates: os.DirFS("templates/"), EmbeddedTemplates: embeddedTemplates, RedirectToWWW: false, TrustedProxyIPRanges: cfg.TrustedIPRanges, ContentSecurityPolicy: &glmiddleware.ContentSecurityPolicyConfig{ DefaultSrc: []glmiddleware.CSPDirective{ glmiddleware.CSPSelf, glmiddleware.CSPUnsafeInline, }, }, TemplateFunctions: template.FuncMap{ "cacheBustUrl": gt.TmplMakeCacheBustUrl, }, Autocert: autocert.MustNewAutocertWrapper(ctx, autocert.AutocertConfig{ ApiKey: secrets.MustGetApiKey(vc, ctx, cfg.DNSApiKeyVaultPath).Key, Hosts: cfg.Hostnames, Email: cfg.AutocertEmail, CertHost: cfg.AutocertHost, }), NetboxClient: &netbox.BasicNetboxClient{ Endpoint: cfg.NetboxHost, ApiKey: secrets.MustGetApiKey(vc, ctx, cfg.NetboxApiKeyVaultPath).Key, }, }); err != nil { log.Fatalf("Error configuring echo: %s", err) } glecho.AttachSecretsClient(vc, cancel, s.Runner(), s.Logger) mongo, err := mongodb.Connect(ctx, cfg.MongoDbUri, vc) if err != nil { log.Fatalf("Error connecting to mongodb: %s", err) } cookieKey := secrets.MustGetRSAKey(vc, ctx, cfg.CookieKeyPath) pk, err := cookieKey.RSAPrivateKey() if err != nil { log.Fatalf("Error fetching cookie key from vault: %s", err) } ss, err := session.NewCookieStore[*app.Session](pk, app.NewSession) if err != nil { log.Fatalf("Error creating session store: %s", err) } userStore := &mongodb.MongoDbBasicStore[*models.User]{ Db: mongo, CollectionName: "users", } oauthClientStore := &mongodb.MongoDbBasicStore[*models.OauthClient]{ Db: mongo, CollectionName: "oauth_clients", } authSessionStore := &models.AuthSessionStoreMongodb{ MongoDbBasicStore: &mongodb.MongoDbBasicStore[*models.AuthSession]{ Db: mongo, CollectionName: "oauth_sessions", }, } wauthn, err := webauthn.New(&webauthn.Config{ RPDisplayName: cfg.OauthRPName, RPID: cfg.Hostnames[0], RPOrigins: []string{ fmt.Sprintf("https://%s", cfg.Hostnames[0]), // TODO: Expose port in echo server for use here }, }) if err != nil { log.Fatalf("Error constructing webauthn: %s", err) } lc := &controllers.LoginController[*app.Session]{ Logger: s.Logger, Sessions: ss, Users: userStore, AuthSessions: authSessionStore, Webauthn: wauthn, SessionExpiration: cfg.OauthSessionTimeout, } rc := &controllers.RegisterController[*app.Session]{ Logger: s.Logger, Sessions: ss, Users: userStore, AuthSessions: authSessionStore, Webauthn: wauthn, } // TODO: Clean up this hack and expose these to echo hostname := fmt.Sprintf("https://%s", cfg.Hostnames[0]) if strings.HasPrefix(cfg.Hostnames[0], "dev.") { hostname += ":8070" } o2dc := &controllers.OAuth2DeviceController[*app.Session]{ Logger: s.Logger, AuthSessions: authSessionStore, OauthClients: oauthClientStore, Hostname: hostname, PollSeconds: cfg.OauthDevicePollSecs, SessionExpiration: cfg.OauthSessionTimeout, } od := controllers.Oauth2DiscoveryController{ Hostname: hostname, } ph := &controllers.ProxyHandler{ Logger: s.Logger, Users: userStore, Upgrader: websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, }, } caAuthMw := &middleware.TokenAuthMiddleware{ Logger: s.Logger, RequiredScope: "ca:issue", AuthSessions: authSessionStore, } proxyAuthMw := &middleware.TokenAuthMiddleware{ Logger: s.Logger, RequiredScope: "ssh:proxy", AuthSessions: authSessionStore, } var caSecret controllers.CASecret if _, err := vc.Secret(ctx, cfg.SSHCAKeyPath, &caSecret); err != nil { log.Fatalf("Error fetching SSH CA secret from Vault: %s", err) } ca, err := controllers.NewCAHandler(controllers.CAHandlerConfig{ Logger: s.Logger, Users: userStore, Expiration: cfg.SSHCertificateExpiration, Secret: caSecret, }) if err != nil { log.Fatalf("Error building CA controller: %s", err) } s.Use(session.Middleware(ss)) csm := glmiddleware.CSRFProtect(ss) lcc := controllers.ListClients(embeddedClients) s.GET("/clients", lcc) s.GET("/clients/", lcc) glecho.StaticFSSha256Etags(s.GET, embeddedClients, "/clients/*", "./clients/") s.NeverCacheStaticRoute("/js", "js") s.GET("/login", gt.Handle, csm) s.GET("/register", gt.Handle, csm) ag := s.Group("/auth") { ag.POST("/device", o2dc.HandleStart) ag.POST("/token", o2dc.HandleToken) lg := ag.Group("/login") lg.Use(csm) { lg.GET("/:username", lc.HandleStart) lg.POST("/:username", lc.HandleFinish) } rg := ag.Group("/register") rg.Use(csm) { rg.GET("/:username", rc.HandleStart) rg.POST("/:username", rc.HandleFinish) } } s.POST("/ca/issue", ca.HandleIssue, caAuthMw.Middleware) pg := s.Group("/proxy-to") pg.Use(proxyAuthMw.Middleware) { pg.GET("/:host", ph.Handle) pg.GET("/:host/:port", ph.Handle) } s.GET(models.Oauth2MetadataPath, od.Handle) s.GET(models.Oauth2MetadataCompatPath, od.Handle) s.RunForever(!cfg.DisableBackgroundJobs) }