352 lines
8.6 KiB
Go
352 lines
8.6 KiB
Go
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")
|
|
}
|
|
|
|
if valid, err := manifest.Validate(manifestMetadataValidators...); !valid {
|
|
return errors.Wrap(err, "invalid app manifest")
|
|
}
|
|
|
|
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
|
|
}
|