package app import ( "context" "encoding/json" "fmt" "io/ioutil" "net" "net/http" "os" "path/filepath" "strings" "forge.cadoles.com/arcad/edge/pkg/app" "forge.cadoles.com/arcad/edge/pkg/bus" "forge.cadoles.com/arcad/edge/pkg/bus/memory" appHTTP "forge.cadoles.com/arcad/edge/pkg/http" "forge.cadoles.com/arcad/edge/pkg/module" appModule "forge.cadoles.com/arcad/edge/pkg/module/app" appModuleMemory "forge.cadoles.com/arcad/edge/pkg/module/app/memory" "forge.cadoles.com/arcad/edge/pkg/module/auth" authHTTP "forge.cadoles.com/arcad/edge/pkg/module/auth/http" "forge.cadoles.com/arcad/edge/pkg/module/blob" "forge.cadoles.com/arcad/edge/pkg/module/cast" "forge.cadoles.com/arcad/edge/pkg/module/fetch" netModule "forge.cadoles.com/arcad/edge/pkg/module/net" "forge.cadoles.com/arcad/edge/pkg/storage" "forge.cadoles.com/arcad/edge/pkg/storage/sqlite" "gitlab.com/wpetit/goweb/logger" "forge.cadoles.com/arcad/edge/pkg/bundle" "github.com/dop251/goja" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/pkg/errors" "github.com/urfave/cli/v2" _ "embed" _ "forge.cadoles.com/arcad/edge/pkg/module/auth/http/passwd/argon2id" _ "forge.cadoles.com/arcad/edge/pkg/module/auth/http/passwd/plain" ) func RunCommand() *cli.Command { return &cli.Command{ Name: "run", Usage: "Run the specified app bundle", Flags: []cli.Flag{ &cli.StringFlag{ Name: "path", Usage: "use `PATH` as app bundle (zipped bundle or directory)", Aliases: []string{"p"}, Value: ".", }, &cli.StringFlag{ Name: "address", Usage: "use `ADDRESS` as http server listening address", Aliases: []string{"a"}, Value: ":8080", }, &cli.StringFlag{ Name: "log-format", Usage: "use `LOG-FORMAT` ('json' or 'human')", Value: "human", }, &cli.IntFlag{ Name: "log-level", Usage: "use `LOG-LEVEL` (0: debug -> 5: fatal)", Value: 0, }, &cli.StringFlag{ Name: "storage-file", Usage: "use `FILE` for SQLite storage database", Value: ".edge/%APPID%/data.sqlite?_pragma=foreign_keys(1)&_pragma=busy_timeout=60000", }, &cli.StringFlag{ Name: "accounts-file", Usage: "use `FILE` as local accounts", Value: ".edge/%APPID%/accounts.json", }, }, Action: func(ctx *cli.Context) error { address := ctx.String("address") path := ctx.String("path") logFormat := ctx.String("log-format") logLevel := ctx.Int("log-level") logger.SetFormat(logger.Format(logFormat)) logger.SetLevel(logger.Level(logLevel)) cmdCtx := ctx.Context absPath, err := filepath.Abs(path) if err != nil { return errors.Wrapf(err, "could not resolve path '%s'", path) } logger.Info(cmdCtx, "opening app bundle", logger.F("path", absPath)) bundle, err := bundle.FromPath(path) if err != nil { return errors.Wrapf(err, "could not open path '%s' as an app bundle", path) } manifest, err := app.LoadManifest(bundle) if err != nil { return errors.Wrap(err, "could not load manifest from app bundle") } storageFile := injectAppID(ctx.String("storage-file"), manifest.ID) if err := ensureDir(storageFile); err != nil { return errors.WithStack(err) } db, err := sqlite.Open(storageFile) if err != nil { return errors.WithStack(err) } ds := sqlite.NewDocumentStoreWithDB(db) bs := sqlite.NewBlobStoreWithDB(db) bus := memory.NewBus() handler := appHTTP.NewHandler( appHTTP.WithBus(bus), appHTTP.WithServerModules(getServerModules(bus, ds, bs, manifest, address)...), ) if err := handler.Load(bundle); err != nil { return errors.Wrap(err, "could not load app bundle") } router := chi.NewRouter() router.Use(middleware.Logger) accountsFile := injectAppID(ctx.String("accounts-file"), manifest.ID) accounts, err := loadLocalAccounts(accountsFile) if err != nil { return errors.Wrap(err, "could not load local accounts") } // Add auth handler key, err := dummyKey() if err != nil { return errors.WithStack(err) } router.Handle("/auth/*", authHTTP.NewLocalHandler( jwa.HS256, key, authHTTP.WithRoutePrefix("/auth"), authHTTP.WithAccounts(accounts...), )) // Add app handler router.Handle("/*", handler) logger.Info(cmdCtx, "listening", logger.F("address", address)) if err := http.ListenAndServe(address, router); err != nil { return errors.WithStack(err) } return nil }, } } func getServerModules(bus bus.Bus, ds storage.DocumentStore, bs storage.BlobStore, manifest *app.Manifest, address string) []app.ServerModuleFactory { return []app.ServerModuleFactory{ module.ContextModuleFactory(), module.ConsoleModuleFactory(), cast.CastModuleFactory(), module.LifecycleModuleFactory(), netModule.ModuleFactory(bus), module.RPCModuleFactory(bus), module.StoreModuleFactory(ds), blob.ModuleFactory(bus, bs), module.Extends( auth.ModuleFactory( auth.WithJWT(dummyKeySet), ), func(o *goja.Object) { if err := o.Set("CLAIM_TENANT", "arcad_tenant"); err != nil { panic(errors.New("could not set 'CLAIM_TENANT' property")) } if err := o.Set("CLAIM_ENTRYPOINT", "arcad_entrypoint"); err != nil { panic(errors.New("could not set 'CLAIM_ENTRYPOINT' property")) } if err := o.Set("CLAIM_ROLE", "arcad_role"); err != nil { panic(errors.New("could not set 'CLAIM_ROLE' property")) } if err := o.Set("CLAIM_PREFERRED_USERNAME", "preferred_username"); err != nil { panic(errors.New("could not set 'CLAIM_PREFERRED_USERNAME' property")) } }, ), appModule.ModuleFactory(appModuleMemory.NewRepository( func(ctx context.Context, id app.ID, from string) (string, error) { addr := address if strings.HasPrefix(addr, ":") { addr = "0.0.0.0" + addr } host, port, err := net.SplitHostPort(addr) if err != nil { return "", errors.WithStack(err) } addr, err = findMatchingDeviceAddress(ctx, from, host) if err != nil { return "", errors.WithStack(err) } return fmt.Sprintf("http://%s:%s", addr, port), nil }, manifest, )), fetch.ModuleFactory(bus), } } var dummySecret = []byte("not_so_secret") func dummyKey() (jwk.Key, error) { key, err := jwk.FromRaw(dummySecret) if err != nil { return nil, errors.WithStack(err) } return key, nil } func dummyKeySet() (jwk.Set, error) { key, err := dummyKey() if err != nil { return nil, errors.WithStack(err) } if err := key.Set(jwk.AlgorithmKey, jwa.HS256); err != nil { return nil, errors.WithStack(err) } set := jwk.NewSet() if err := set.AddKey(key); err != nil { return nil, errors.WithStack(err) } return set, nil } func ensureDir(path string) error { if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { return errors.WithStack(err) } return nil } func injectAppID(str string, appID app.ID) string { return strings.ReplaceAll(str, "%APPID%", string(appID)) } //go:embed default-accounts.json var defaultAccounts []byte func loadLocalAccounts(path string) ([]authHTTP.LocalAccount, error) { if err := ensureDir(path); err != nil { return nil, errors.WithStack(err) } data, err := ioutil.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { if err := ioutil.WriteFile(path, defaultAccounts, 0o640); err != nil { return nil, errors.WithStack(err) } data = defaultAccounts } else { return nil, errors.WithStack(err) } } var accounts []authHTTP.LocalAccount if err := json.Unmarshal(data, &accounts); err != nil { return nil, errors.WithStack(err) } return accounts, nil } func findMatchingDeviceAddress(ctx context.Context, from string, defaultAddr string) (string, error) { if from == "" { return defaultAddr, nil } fromIP := net.ParseIP(from) if fromIP == nil { return defaultAddr, nil } ifaces, err := net.Interfaces() if err != nil { return "", errors.WithStack(err) } for _, ifa := range ifaces { addrs, err := ifa.Addrs() if err != nil { logger.Error( ctx, "could not retrieve iface adresses", logger.E(errors.WithStack(err)), logger.F("iface", ifa.Name), ) continue } for _, addr := range addrs { ip, network, err := net.ParseCIDR(addr.String()) if err != nil { logger.Error( ctx, "could not parse address", logger.E(errors.WithStack(err)), logger.F("address", addr.String()), ) continue } if !network.Contains(fromIP) { continue } return ip.String(), nil } } return defaultAddr, nil }