blob: 4535be2f9615512f522c59337495c472abf39a26 [file] [log] [blame]
package welcome
import (
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"golang.org/x/crypto/bcrypt"
"html/template"
"io"
"io/fs"
"net/http"
"slices"
"strings"
"sync"
"github.com/giolekva/pcloud/core/installer"
"github.com/giolekva/pcloud/core/installer/soft"
"github.com/gorilla/mux"
"github.com/gorilla/securecookie"
)
//go:embed dodo-app-tmpl/*
var dodoAppTmplFS embed.FS
//go:embed all:app-tmpl
var appTmplsFS embed.FS
//go:embed static
var staticResources embed.FS
const (
ConfigRepoName = "config"
namespacesFile = "/namespaces.json"
loginPath = "/login"
logoutPath = "/logout"
staticPath = "/static"
sessionCookie = "dodo-app-session"
userCtx = "user"
)
var types = []string{"golang:1.22.0", "golang:1.20.0", "hugo:latest"}
type dodoAppTmplts struct {
index *template.Template
appStatus *template.Template
}
func parseTemplatesDodoApp(fs embed.FS) (dodoAppTmplts, error) {
base, err := template.ParseFS(fs, "dodo-app-tmpl/base.html")
if err != nil {
return dodoAppTmplts{}, err
}
parse := func(path string) (*template.Template, error) {
if b, err := base.Clone(); err != nil {
return nil, err
} else {
return b.ParseFS(fs, path)
}
}
index, err := parse("dodo-app-tmpl/index.html")
if err != nil {
return dodoAppTmplts{}, err
}
appStatus, err := parse("dodo-app-tmpl/app_status.html")
if err != nil {
return dodoAppTmplts{}, err
}
return dodoAppTmplts{index, appStatus}, nil
}
type DodoAppServer struct {
l sync.Locker
st Store
nf NetworkFilter
ug UserGetter
port int
apiPort int
self string
repoPublicAddr string
sshKey string
gitRepoPublicKey string
client soft.Client
namespace string
envAppManagerAddr string
env installer.EnvConfig
nsc installer.NamespaceCreator
jc installer.JobCreator
workers map[string]map[string]struct{}
appNs map[string]string
sc *securecookie.SecureCookie
tmplts dodoAppTmplts
appTmpls AppTmplStore
}
// TODO(gio): Initialize appNs on startup
func NewDodoAppServer(
st Store,
nf NetworkFilter,
ug UserGetter,
port int,
apiPort int,
self string,
repoPublicAddr string,
sshKey string,
gitRepoPublicKey string,
client soft.Client,
namespace string,
envAppManagerAddr string,
nsc installer.NamespaceCreator,
jc installer.JobCreator,
env installer.EnvConfig,
) (*DodoAppServer, error) {
tmplts, err := parseTemplatesDodoApp(dodoAppTmplFS)
if err != nil {
return nil, err
}
sc := securecookie.New(
securecookie.GenerateRandomKey(64),
securecookie.GenerateRandomKey(32),
)
apps, err := fs.Sub(appTmplsFS, "app-tmpl")
if err != nil {
return nil, err
}
appTmpls, err := NewAppTmplStoreFS(apps)
if err != nil {
return nil, err
}
s := &DodoAppServer{
&sync.Mutex{},
st,
nf,
ug,
port,
apiPort,
self,
repoPublicAddr,
sshKey,
gitRepoPublicKey,
client,
namespace,
envAppManagerAddr,
env,
nsc,
jc,
map[string]map[string]struct{}{},
map[string]string{},
sc,
tmplts,
appTmpls,
}
config, err := client.GetRepo(ConfigRepoName)
if err != nil {
return nil, err
}
r, err := config.Reader(namespacesFile)
if err == nil {
defer r.Close()
if err := json.NewDecoder(r).Decode(&s.appNs); err != nil {
return nil, err
}
} else if !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
return s, nil
}
func (s *DodoAppServer) Start() error {
e := make(chan error)
go func() {
r := mux.NewRouter()
r.Use(s.mwAuth)
r.PathPrefix(staticPath).Handler(http.FileServer(http.FS(staticResources)))
r.HandleFunc(logoutPath, s.handleLogout).Methods(http.MethodGet)
r.HandleFunc("/{app-name}"+loginPath, s.handleLoginForm).Methods(http.MethodGet)
r.HandleFunc("/{app-name}"+loginPath, s.handleLogin).Methods(http.MethodPost)
r.HandleFunc("/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
r.HandleFunc("/", s.handleStatus).Methods(http.MethodGet)
r.HandleFunc("/", s.handleCreateApp).Methods(http.MethodPost)
e <- http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
}()
go func() {
r := mux.NewRouter()
r.HandleFunc("/update", s.handleApiUpdate)
r.HandleFunc("/api/apps/{app-name}/workers", s.handleApiRegisterWorker).Methods(http.MethodPost)
r.HandleFunc("/api/apps", s.handleApiCreateApp).Methods(http.MethodPost)
r.HandleFunc("/api/add-admin-key", s.handleApiAddAdminKey).Methods(http.MethodPost)
e <- http.ListenAndServe(fmt.Sprintf(":%d", s.apiPort), r)
}()
return <-e
}
type UserGetter interface {
Get(r *http.Request) string
}
type externalUserGetter struct {
sc *securecookie.SecureCookie
}
func NewExternalUserGetter() UserGetter {
return &externalUserGetter{}
}
func (ug *externalUserGetter) Get(r *http.Request) string {
cookie, err := r.Cookie(sessionCookie)
if err != nil {
return ""
}
var user string
if err := ug.sc.Decode(sessionCookie, cookie.Value, &user); err != nil {
return ""
}
return user
}
type internalUserGetter struct{}
func NewInternalUserGetter() UserGetter {
return internalUserGetter{}
}
func (ug internalUserGetter) Get(r *http.Request) string {
return r.Header.Get("X-User")
}
func (s *DodoAppServer) mwAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, loginPath) || strings.HasPrefix(r.URL.Path, logoutPath) || strings.HasPrefix(r.URL.Path, staticPath) {
next.ServeHTTP(w, r)
return
}
user := s.ug.Get(r)
if user == "" {
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
http.Redirect(w, r, fmt.Sprintf("/%s%s", appName, loginPath), http.StatusSeeOther)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userCtx, user)))
})
}
func (s *DodoAppServer) handleLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *DodoAppServer) handleLoginForm(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
fmt.Fprint(w, `
<!DOCTYPE html>
<html lang='en'>
<head>
<title>dodo: app - login</title>
<meta charset='utf-8'>
</head>
<body>
<form action="" method="POST">
<input type="password" placeholder="Password" name="password" required />
<button type="submit">Login</button>
</form>
</body>
</html>
`)
}
func (s *DodoAppServer) handleLogin(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
password := r.FormValue("password")
if password == "" {
http.Error(w, "missing password", http.StatusBadRequest)
return
}
user, err := s.st.GetAppOwner(appName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
hashed, err := s.st.GetUserPassword(user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := bcrypt.CompareHashAndPassword(hashed, []byte(password)); err != nil {
http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
return
}
if encoded, err := s.sc.Encode(sessionCookie, user); err == nil {
cookie := &http.Cookie{
Name: sessionCookie,
Value: encoded,
Path: "/",
Secure: true,
HttpOnly: true,
}
http.SetCookie(w, cookie)
}
http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
}
type statusData struct {
Apps []string
Networks []installer.Network
Types []string
}
func (s *DodoAppServer) handleStatus(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(userCtx)
if user == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
apps, err := s.st.GetUserApps(user.(string))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
networks, err := s.getNetworks(user.(string))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := statusData{apps, networks, types}
if err := s.tmplts.index.Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
type appStatusData struct {
Name string
GitCloneCommand string
Commits []Commit
}
func (s *DodoAppServer) handleAppStatus(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
commits, err := s.st.GetCommitHistory(appName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := appStatusData{
Name: appName,
GitCloneCommand: fmt.Sprintf("git clone %s/%s\n\n\n", s.repoPublicAddr, appName),
Commits: commits,
}
if err := s.tmplts.appStatus.Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
type apiUpdateReq struct {
Ref string `json:"ref"`
Repository struct {
Name string `json:"name"`
} `json:"repository"`
After string `json:"after"`
}
func (s *DodoAppServer) handleApiUpdate(w http.ResponseWriter, r *http.Request) {
fmt.Println("update")
var req apiUpdateReq
var contents strings.Builder
io.Copy(&contents, r.Body)
c := contents.String()
fmt.Println(c)
if err := json.NewDecoder(strings.NewReader(c)).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.Ref != "refs/heads/master" || req.Repository.Name == ConfigRepoName {
return
}
// TODO(gio): Create commit record on app init as well
go func() {
owner, err := s.st.GetAppOwner(req.Repository.Name)
if err != nil {
return
}
networks, err := s.getNetworks(owner)
if err != nil {
return
}
if err := s.updateDodoApp(req.Repository.Name, s.appNs[req.Repository.Name], networks); err != nil {
if err := s.st.CreateCommit(req.Repository.Name, req.After, err.Error()); err != nil {
fmt.Printf("Error: %s\n", err.Error())
return
}
}
if err := s.st.CreateCommit(req.Repository.Name, req.After, "OK"); err != nil {
fmt.Printf("Error: %s\n", err.Error())
}
for addr, _ := range s.workers[req.Repository.Name] {
go func() {
// TODO(gio): make port configurable
http.Get(fmt.Sprintf("http://%s/update", addr))
}()
}
}()
}
type apiRegisterWorkerReq struct {
Address string `json:"address"`
}
func (s *DodoAppServer) handleApiRegisterWorker(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
var req apiRegisterWorkerReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, ok := s.workers[appName]; !ok {
s.workers[appName] = map[string]struct{}{}
}
s.workers[appName][req.Address] = struct{}{}
}
func (s *DodoAppServer) handleCreateApp(w http.ResponseWriter, r *http.Request) {
u := r.Context().Value(userCtx)
if u == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
user, ok := u.(string)
if !ok {
http.Error(w, "could not get user", http.StatusInternalServerError)
return
}
network := r.FormValue("network")
if network == "" {
http.Error(w, "missing network", http.StatusBadRequest)
return
}
subdomain := r.FormValue("subdomain")
if subdomain == "" {
http.Error(w, "missing subdomain", http.StatusBadRequest)
return
}
appType := r.FormValue("type")
if appType == "" {
http.Error(w, "missing type", http.StatusBadRequest)
return
}
adminPublicKey := r.FormValue("admin-public-key")
if adminPublicKey == "" {
http.Error(w, "missing admin public key", http.StatusBadRequest)
return
}
g := installer.NewFixedLengthRandomNameGenerator(3)
appName, err := g.Generate()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if ok, err := s.client.UserExists(user); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} else if !ok {
if err := s.client.AddUser(user, adminPublicKey); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
if err := s.st.CreateUser(user, nil, adminPublicKey, network); err != nil && !errors.Is(err, ErrorAlreadyExists) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.st.CreateApp(appName, user); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.CreateApp(user, appName, appType, adminPublicKey, network, subdomain); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
}
type apiCreateAppReq struct {
AppType string `json:"type"`
AdminPublicKey string `json:"adminPublicKey"`
Network string `json:"network"`
Subdomain string `json:"subdomain"`
}
type apiCreateAppResp struct {
AppName string `json:"appName"`
Password string `json:"password"`
}
func (s *DodoAppServer) handleApiCreateApp(w http.ResponseWriter, r *http.Request) {
var req apiCreateAppReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
g := installer.NewFixedLengthRandomNameGenerator(3)
appName, err := g.Generate()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
user, err := s.client.FindUser(req.AdminPublicKey)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if user != "" {
http.Error(w, "public key already registered", http.StatusBadRequest)
return
}
user = appName
if err := s.client.AddUser(user, req.AdminPublicKey); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
password := generatePassword()
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.st.CreateUser(user, hashed, req.AdminPublicKey, req.Network); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.st.CreateApp(appName, user); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.CreateApp(user, appName, req.AppType, req.AdminPublicKey, req.Network, req.Subdomain); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp := apiCreateAppResp{
AppName: appName,
Password: password,
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func (s *DodoAppServer) CreateApp(user, appName, appType, adminPublicKey, network, subdomain string) error {
s.l.Lock()
defer s.l.Unlock()
fmt.Printf("Creating app: %s\n", appName)
if ok, err := s.client.RepoExists(appName); err != nil {
return err
} else if ok {
return nil
}
networks, err := s.getNetworks(user)
if err != nil {
return err
}
n, ok := installer.NetworkMap(networks)[strings.ToLower(network)]
if !ok {
return fmt.Errorf("network not found: %s\n", network)
}
if err := s.client.AddRepository(appName); err != nil {
return err
}
appRepo, err := s.client.GetRepo(appName)
if err != nil {
return err
}
if err := s.initRepo(appRepo, appType, n, subdomain); err != nil {
return err
}
apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
app, err := installer.FindEnvApp(apps, "dodo-app-instance")
if err != nil {
return err
}
suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
suffix, err := suffixGen.Generate()
if err != nil {
return err
}
namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, app.Namespace(), suffix)
s.appNs[appName] = namespace
if err := s.updateDodoApp(appName, namespace, networks); err != nil {
return err
}
repo, err := s.client.GetRepo(ConfigRepoName)
if err != nil {
return err
}
hf := installer.NewGitHelmFetcher()
m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, "/")
if err != nil {
return err
}
if err := repo.Do(func(fs soft.RepoFS) (string, error) {
w, err := fs.Writer(namespacesFile)
if err != nil {
return "", err
}
defer w.Close()
if err := json.NewEncoder(w).Encode(s.appNs); err != nil {
return "", err
}
if _, err := m.Install(
app,
appName,
"/"+appName,
namespace,
map[string]any{
"repoAddr": s.client.GetRepoAddress(appName),
"repoHost": strings.Split(s.client.Address(), ":")[0],
"gitRepoPublicKey": s.gitRepoPublicKey,
},
installer.WithConfig(&s.env),
installer.WithNoNetworks(),
installer.WithNoPublish(),
installer.WithNoLock(),
); err != nil {
return "", err
}
return fmt.Sprintf("Installed app: %s", appName), nil
}); err != nil {
return err
}
cfg, err := m.FindInstance(appName)
if err != nil {
return err
}
fluxKeys, ok := cfg.Input["fluxKeys"]
if !ok {
return fmt.Errorf("Fluxcd keys not found")
}
fluxPublicKey, ok := fluxKeys.(map[string]any)["public"]
if !ok {
return fmt.Errorf("Fluxcd keys not found")
}
if ok, err := s.client.UserExists("fluxcd"); err != nil {
return err
} else if ok {
if err := s.client.AddPublicKey("fluxcd", fluxPublicKey.(string)); err != nil {
return err
}
} else {
if err := s.client.AddUser("fluxcd", fluxPublicKey.(string)); err != nil {
return err
}
}
if err := s.client.AddReadOnlyCollaborator(appName, "fluxcd"); err != nil {
return err
}
if err := s.client.AddWebhook(appName, fmt.Sprintf("http://%s/update", s.self), "--active=true", "--events=push", "--content-type=json"); err != nil {
return err
}
if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
return err
}
return nil
}
type apiAddAdminKeyReq struct {
Public string `json:"public"`
}
func (s *DodoAppServer) handleApiAddAdminKey(w http.ResponseWriter, r *http.Request) {
var req apiAddAdminKeyReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := s.client.AddPublicKey("admin", req.Public); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func (s *DodoAppServer) updateDodoApp(name, namespace string, networks []installer.Network) error {
repo, err := s.client.GetRepo(name)
if err != nil {
return err
}
hf := installer.NewGitHelmFetcher()
m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, "/.dodo")
if err != nil {
return err
}
appCfg, err := soft.ReadFile(repo, "app.cue")
if err != nil {
return err
}
app, err := installer.NewDodoApp(appCfg)
if err != nil {
return err
}
lg := installer.GitRepositoryLocalChartGenerator{"app", namespace}
if _, err := m.Install(
app,
"app",
"/.dodo/app",
namespace,
map[string]any{
"repoAddr": repo.FullAddress(),
"managerAddr": fmt.Sprintf("http://%s", s.self),
"appId": name,
"sshPrivateKey": s.sshKey,
},
installer.WithConfig(&s.env),
installer.WithNetworks(networks),
installer.WithLocalChartGenerator(lg),
installer.WithBranch("dodo"),
installer.WithForce(),
); err != nil {
return err
}
return nil
}
func (s *DodoAppServer) initRepo(repo soft.RepoIO, appType string, network installer.Network, subdomain string) error {
appType = strings.ReplaceAll(appType, ":", "-")
appTmpl, err := s.appTmpls.Find(appType)
if err != nil {
return err
}
return repo.Do(func(fs soft.RepoFS) (string, error) {
if err := appTmpl.Render(network, subdomain, repo); err != nil {
return "", err
}
return "init", nil
})
}
func generatePassword() string {
return "foo"
}
func (s *DodoAppServer) getNetworks(user string) ([]installer.Network, error) {
addr := fmt.Sprintf("%s/api/networks", s.envAppManagerAddr)
resp, err := http.Get(addr)
if err != nil {
return nil, err
}
networks := []installer.Network{}
if json.NewDecoder(resp.Body).Decode(&networks); err != nil {
return nil, err
}
return s.nf.Filter(user, networks)
}
func pickNetwork(networks []installer.Network, network string) []installer.Network {
for _, n := range networks {
if n.Name == network {
return []installer.Network{n}
}
}
return []installer.Network{}
}
type NetworkFilter interface {
Filter(user string, networks []installer.Network) ([]installer.Network, error)
}
type noNetworkFilter struct{}
func NewNoNetworkFilter() NetworkFilter {
return noNetworkFilter{}
}
func (f noNetworkFilter) Filter(app string, networks []installer.Network) ([]installer.Network, error) {
return networks, nil
}
type filterByOwner struct {
st Store
}
func NewNetworkFilterByOwner(st Store) NetworkFilter {
return &filterByOwner{st}
}
func (f *filterByOwner) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
network, err := f.st.GetUserNetwork(user)
if err != nil {
return nil, err
}
ret := []installer.Network{}
for _, n := range networks {
if n.Name == network {
ret = append(ret, n)
}
}
return ret, nil
}
type allowListFilter struct {
allowed []string
}
func NewAllowListFilter(allowed []string) NetworkFilter {
return &allowListFilter{allowed}
}
func (f *allowListFilter) Filter(app string, networks []installer.Network) ([]installer.Network, error) {
ret := []installer.Network{}
for _, n := range networks {
if slices.Contains(f.allowed, n.Name) {
ret = append(ret, n)
}
}
return ret, nil
}
type combinedNetworkFilter struct {
filters []NetworkFilter
}
func NewCombinedFilter(filters ...NetworkFilter) NetworkFilter {
return &combinedNetworkFilter{filters}
}
func (f *combinedNetworkFilter) Filter(app string, networks []installer.Network) ([]installer.Network, error) {
ret := networks
var err error
for _, f := range f.filters {
ret, err = f.Filter(app, ret)
if err != nil {
return nil, err
}
}
return ret, nil
}