diff --git a/Cloudron/CloudronPackages-Artifacts/apisix/app/Dockerfile b/Cloudron/CloudronPackages-Artifacts/apisix/app/Dockerfile new file mode 100644 index 0000000..6049d0b --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/apisix/app/Dockerfile @@ -0,0 +1,17 @@ +FROM alpine:latest + +RUN apk --no-cache add ca-certificates + +WORKDIR /app + +COPY . . + +# Create non-root user +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +USER appuser + +EXPOSE 8080 + +CMD ["./start.sh"] diff --git a/Cloudron/CloudronPackages-Artifacts/apisix/app/manifest.json b/Cloudron/CloudronPackages-Artifacts/apisix/app/manifest.json new file mode 100644 index 0000000..e0ce5cd --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/apisix/app/manifest.json @@ -0,0 +1,24 @@ +{ + "id": "com.apisix.cloudron", + "title": "apisix", + "version": "1.0.0", + "description": "Cloud-native API gateway", + "developer": { + "name": "TSYSDevStack Team", + "email": "support@tsysdevstack.com" + }, + "tags": ["productivity", "web-app", "UNKNOWN"], + "httpPort": 9080, + "manifestVersion": 2, + "healthCheck": { + "path": "/apisix/admin/services/", + "port": 9080 + }, + "memoryLimit": 1073741824, + "addons": { + "localstorage": true, + "postgresql": true, + "redis": true, + "sendmail": true + } +} diff --git a/Cloudron/CloudronPackages-Artifacts/apisix/app/start.sh b/Cloudron/CloudronPackages-Artifacts/apisix/app/start.sh new file mode 100755 index 0000000..16238bd --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/apisix/app/start.sh @@ -0,0 +1,4 @@ +#!/bin/sh +echo "Starting application..." +# Add your startup command here +exec "$@" diff --git a/Cloudron/CloudronPackages-Artifacts/boinc/app/Dockerfile b/Cloudron/CloudronPackages-Artifacts/boinc/app/Dockerfile new file mode 100644 index 0000000..6049d0b --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/boinc/app/Dockerfile @@ -0,0 +1,17 @@ +FROM alpine:latest + +RUN apk --no-cache add ca-certificates + +WORKDIR /app + +COPY . . + +# Create non-root user +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +USER appuser + +EXPOSE 8080 + +CMD ["./start.sh"] diff --git a/Cloudron/CloudronPackages-Artifacts/boinc/app/manifest.json b/Cloudron/CloudronPackages-Artifacts/boinc/app/manifest.json new file mode 100644 index 0000000..0b1dca8 --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/boinc/app/manifest.json @@ -0,0 +1,24 @@ +{ + "id": "com.boinc.cloudron", + "title": "boinc", + "version": "1.0.0", + "description": "Open-source volunteer computing platform", + "developer": { + "name": "TSYSDevStack Team", + "email": "support@tsysdevstack.com" + }, + "tags": ["productivity", "web-app", "UNKNOWN"], + "httpPort": 80, + "manifestVersion": 2, + "healthCheck": { + "path": "/", + "port": 80 + }, + "memoryLimit": 1073741824, + "addons": { + "localstorage": true, + "postgresql": true, + "redis": true, + "sendmail": true + } +} diff --git a/Cloudron/CloudronPackages-Artifacts/boinc/app/start.sh b/Cloudron/CloudronPackages-Artifacts/boinc/app/start.sh new file mode 100755 index 0000000..16238bd --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/boinc/app/start.sh @@ -0,0 +1,4 @@ +#!/bin/sh +echo "Starting application..." +# Add your startup command here +exec "$@" diff --git a/Cloudron/CloudronPackages-Artifacts/dashboard.html b/Cloudron/CloudronPackages-Artifacts/dashboard.html new file mode 100644 index 0000000..1b4a147 --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/dashboard.html @@ -0,0 +1,31 @@ + + + + + + Cloudron Packaging Dashboard + + + +
+

🚀 Cloudron Packaging Dashboard

+

Real-time status of application packaging for Cloudron deployment

+
diff --git a/Cloudron/CloudronPackages-Artifacts/goalert/app/Dockerfile b/Cloudron/CloudronPackages-Artifacts/goalert/app/Dockerfile new file mode 100644 index 0000000..18ad7b1 --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/goalert/app/Dockerfile @@ -0,0 +1,37 @@ +FROM golang:1.21-alpine AS builder + +# Install build dependencies +RUN apk add --no-cache git ca-certificates tzdata + +WORKDIR /build + +# Copy go mod files +COPY go.mod ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/goalert + +# Runtime stage +FROM alpine:latest + +# Install runtime dependencies +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /build/cmd/main/main /app/main + +# Create non-root user +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +USER appuser + +EXPOSE 8080 + +CMD ["./main"] diff --git a/Cloudron/CloudronPackages-Artifacts/goalert/app/app.go b/Cloudron/CloudronPackages-Artifacts/goalert/app/app.go new file mode 100644 index 0000000..e230910 --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/goalert/app/app.go @@ -0,0 +1,252 @@ +package app + +import ( + "context" + "crypto/tls" + "database/sql" + "fmt" + "log/slog" + "net" + "net/http" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pkg/errors" + "github.com/riverqueue/river" + "github.com/target/goalert/alert" + "github.com/target/goalert/alert/alertlog" + "github.com/target/goalert/alert/alertmetrics" + "github.com/target/goalert/apikey" + "github.com/target/goalert/app/lifecycle" + "github.com/target/goalert/auth" + "github.com/target/goalert/auth/authlink" + "github.com/target/goalert/auth/basic" + "github.com/target/goalert/auth/nonce" + "github.com/target/goalert/calsub" + "github.com/target/goalert/config" + "github.com/target/goalert/engine" + "github.com/target/goalert/escalation" + "github.com/target/goalert/graphql2/graphqlapp" + "github.com/target/goalert/heartbeat" + "github.com/target/goalert/integrationkey" + "github.com/target/goalert/integrationkey/uik" + "github.com/target/goalert/keyring" + "github.com/target/goalert/label" + "github.com/target/goalert/limit" + "github.com/target/goalert/notice" + "github.com/target/goalert/notification" + "github.com/target/goalert/notification/nfydest" + "github.com/target/goalert/notification/slack" + "github.com/target/goalert/notification/twilio" + "github.com/target/goalert/notificationchannel" + "github.com/target/goalert/oncall" + "github.com/target/goalert/override" + "github.com/target/goalert/permission" + "github.com/target/goalert/schedule" + "github.com/target/goalert/schedule/rotation" + "github.com/target/goalert/schedule/rule" + "github.com/target/goalert/service" + "github.com/target/goalert/smtpsrv" + "github.com/target/goalert/timezone" + "github.com/target/goalert/user" + "github.com/target/goalert/user/contactmethod" + "github.com/target/goalert/user/favorite" + "github.com/target/goalert/user/notificationrule" + "github.com/target/goalert/util/calllimiter" + "github.com/target/goalert/util/log" + "github.com/target/goalert/util/sqlutil" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + "riverqueue.com/riverui" +) + +// App represents an instance of the GoAlert application. +type App struct { + cfg Config + + Logger *slog.Logger + + mgr *lifecycle.Manager + + db *sql.DB + pgx *pgxpool.Pool + l net.Listener + events *sqlutil.Listener + + httpClient *http.Client + + doneCh chan struct{} + + sysAPIL net.Listener + sysAPISrv *grpc.Server + hSrv *health.Server + + srv *http.Server + smtpsrv *smtpsrv.Server + smtpsrvL net.Listener + startupErr error + + notificationManager *notification.Manager + Engine *engine.Engine + graphql2 *graphqlapp.App + AuthHandler *auth.Handler + + twilioSMS *twilio.SMS + twilioVoice *twilio.Voice + twilioConfig *twilio.Config + + slackChan *slack.ChannelSender + + ConfigStore *config.Store + + AlertStore *alert.Store + AlertLogStore *alertlog.Store + AlertMetricsStore *alertmetrics.Store + + AuthBasicStore *basic.Store + UserStore *user.Store + ContactMethodStore *contactmethod.Store + NotificationRuleStore *notificationrule.Store + FavoriteStore *favorite.Store + + ServiceStore *service.Store + EscalationStore *escalation.Store + IntegrationKeyStore *integrationkey.Store + UIKHandler *uik.Handler + ScheduleRuleStore *rule.Store + NotificationStore *notification.Store + ScheduleStore *schedule.Store + RotationStore *rotation.Store + DestRegistry *nfydest.Registry + + CalSubStore *calsub.Store + OverrideStore *override.Store + LimitStore *limit.Store + HeartbeatStore *heartbeat.Store + + OAuthKeyring keyring.Keyring + SessionKeyring keyring.Keyring + APIKeyring keyring.Keyring + AuthLinkKeyring keyring.Keyring + + NonceStore *nonce.Store + LabelStore *label.Store + OnCallStore *oncall.Store + NCStore *notificationchannel.Store + TimeZoneStore *timezone.Store + NoticeStore *notice.Store + AuthLinkStore *authlink.Store + APIKeyStore *apikey.Store + River *river.Client[pgx.Tx] + + // RiverDBSQL is a river client that uses the old sql.DB driver for use while transitioning to pgx. + // + // This allows us to add jobs from transactions that are not using the pgx driver. This client is not used for any job or queue processing. + RiverDBSQL *river.Client[*sql.Tx] + RiverUI *riverui.Handler + RiverWorkers *river.Workers +} + +// NewApp constructs a new App and binds the listening socket. +func NewApp(c Config, pool *pgxpool.Pool) (*App, error) { + if c.Logger == nil { + return nil, errors.New("Logger is required") + } + + var err error + db := stdlib.OpenDBFromPool(pool) + permission.SudoContext(context.Background(), func(ctx context.Context) { + c.Logger.DebugContext(ctx, "checking switchover_state table") + // Should not be possible for the app to ever see `use_next_db` unless misconfigured. + // + // In switchover mode, the connector wrapper will check this and provide the app with + // a connection to the next DB instead, if this was set. + // + // This is a sanity check to ensure that the app is not accidentally using the previous DB + // after a switchover. + err = db.QueryRowContext(ctx, `select true from switchover_state where current_state = 'use_next_db'`).Scan(new(bool)) + if errors.Is(err, sql.ErrNoRows) { + err = nil + return + } + if err != nil { + return + } + + err = fmt.Errorf("refusing to connect to stale database (switchover_state table has use_next_db set)") + }) + if err != nil { + return nil, err + } + + l, err := net.Listen("tcp", c.ListenAddr) + if err != nil { + return nil, errors.Wrapf(err, "bind address %s", c.ListenAddr) + } + + if c.TLSListenAddr != "" { + l2, err := tls.Listen("tcp", c.TLSListenAddr, c.TLSConfig) + if err != nil { + return nil, errors.Wrapf(err, "listen %s", c.TLSListenAddr) + } + l = newMultiListener(l, l2) + } + + c.LegacyLogger.AddErrorMapper(func(ctx context.Context, err error) context.Context { + if e := sqlutil.MapError(err); e != nil && e.Detail != "" { + ctx = log.WithField(ctx, "SQLErrDetails", e.Detail) + } + + return ctx + }) + + app := &App{ + l: l, + db: db, + pgx: pool, + cfg: c, + doneCh: make(chan struct{}), + Logger: c.Logger, + httpClient: &http.Client{ + Transport: calllimiter.RoundTripper(http.DefaultTransport), + }, + } + + if c.StatusAddr != "" { + err = listenStatus(c.StatusAddr, app.doneCh) + if err != nil { + return nil, errors.Wrap(err, "start status listener") + } + } + + c.Logger.Debug("starting app") + app.mgr = lifecycle.NewManager(app._Run, app._Shutdown) + err = app.mgr.SetStartupFunc(app.startup) + if err != nil { + return nil, err + } + + return app, nil +} + +// WaitForStartup will wait until the startup sequence is completed or the context is expired. +func (a *App) WaitForStartup(ctx context.Context) error { + return a.mgr.WaitForStartup(a.Context(ctx)) +} + +// DB returns the sql.DB instance used by the application. +func (a *App) DB() *sql.DB { return a.db } + +// URL returns the non-TLS listener URL of the application. +func (a *App) URL() string { + return "http://" + a.l.Addr().String() +} + +func (a *App) SMTPAddr() string { + if a.smtpsrvL == nil { + return "" + } + + return a.smtpsrvL.Addr().String() +} diff --git a/Cloudron/CloudronPackages-Artifacts/goalert/app/cmd.go b/Cloudron/CloudronPackages-Artifacts/goalert/app/cmd.go new file mode 100644 index 0000000..59a762b --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/goalert/app/cmd.go @@ -0,0 +1,1008 @@ +package app + +import ( + "context" + "database/sql" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "os/signal" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/pelletier/go-toml/v2" + "github.com/pkg/errors" + sloglogrus "github.com/samber/slog-logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/target/goalert/auth/basic" + "github.com/target/goalert/config" + "github.com/target/goalert/expflag" + "github.com/target/goalert/keyring" + "github.com/target/goalert/migrate" + "github.com/target/goalert/permission" + "github.com/target/goalert/remotemonitor" + "github.com/target/goalert/swo" + "github.com/target/goalert/user" + "github.com/target/goalert/util" + "github.com/target/goalert/util/calllimiter" + "github.com/target/goalert/util/log" + "github.com/target/goalert/util/sqldrv" + "github.com/target/goalert/util/sqlutil" + "github.com/target/goalert/validation" + "github.com/target/goalert/version" + "github.com/target/goalert/web" + "golang.org/x/term" +) + +var shutdownSignalCh = make(chan os.Signal, 2) + +// ErrDBRequired is returned when the DB URL is unset. +var ErrDBRequired = validation.NewFieldError("db-url", "is required") + +func init() { + if testing.Testing() { + // Skip signal handling in tests. + return + } + + signal.Notify(shutdownSignalCh, shutdownSignals...) +} + +func isCfgNotFound(err error) bool { + var cfgErr viper.ConfigFileNotFoundError + return errors.As(err, &cfgErr) +} + +// RootCmd is the configuration for running the app binary. +var RootCmd = &cobra.Command{ + Use: "goalert", + Short: "Alerting platform.", + RunE: func(cmd *cobra.Command, args []string) error { + l := log.FromContext(cmd.Context()) + + // update JSON output first + if viper.GetBool("json") { + l.EnableJSON() + } + if viper.GetBool("verbose") { + l.EnableDebug() + } + if viper.GetBool("log-errors-only") { + l.ErrorsOnly() + } + + if viper.GetBool("list-experimental") { + fmt.Print(`Usage: goalert --experimental=, ... + +These flags are not guaranteed to be stable and may change or be removed at any +time. They are used to enable in-development features and are not intended for +production use. + + +Available Flags: + +`) + + for _, f := range expflag.AllFlags() { + fmt.Printf("\t%s\t\t%s", f, expflag.Description(f)) + } + + return nil + } + + err := viper.ReadInConfig() + // ignore file not found error + if err != nil && !isCfgNotFound(err) { + return errors.Wrap(err, "read config") + } + + err = initPromServer() + if err != nil { + return err + } + err = initPprofServer() + if err != nil { + return err + } + + ctx := cmd.Context() + cfg, err := getConfig(ctx) + if err != nil { + return err + } + + // Config is loaded, so don't print usage anymore on future errors. + cmd.SilenceUsage = true + + doMigrations := func(ctx context.Context, url string) error { + if cfg.APIOnly { + err = migrate.VerifyAll(ctx, url) + if err != nil { + return errors.Wrap(err, "verify migrations") + } + return nil + } + + s := time.Now() + n, err := migrate.ApplyAll(ctx, url) + if err != nil { + return errors.Wrap(err, "apply migrations") + } + if n > 0 { + log.Logf(ctx, "Applied %d migrations in %s.", n, time.Since(s)) + } + + return nil + } + + var earlyShutdown sync.WaitGroup + earlyShutdownCtx, sdCancel := context.WithCancel(ctx) + earlyShutdown.Go(func() { + select { + case <-shutdownSignalCh: + cfg.Logger.Warn("Received shutdown signal during startup.") + sdCancel() + case <-earlyShutdownCtx.Done(): + } + }) + + cfg.Logger.DebugContext(earlyShutdownCtx, "validating database migrations") + err = doMigrations(log.WithLogger(earlyShutdownCtx, cfg.LegacyLogger), cfg.DBURL) + if err != nil { + return err + } + + cfg.Logger.DebugContext(earlyShutdownCtx, "connecting to database") + var pool *pgxpool.Pool + if cfg.DBURLNext != "" { + err = migrate.VerifyIsLatest(earlyShutdownCtx, cfg.DBURL) + if err != nil { + return errors.Wrap(err, "verify db") + } + + err = doMigrations(earlyShutdownCtx, cfg.DBURLNext) + if err != nil { + return errors.Wrap(err, "nextdb") + } + + mgr, err := swo.NewManager(swo.Config{ + OldDBURL: cfg.DBURL, + NewDBURL: cfg.DBURLNext, + CanExec: !cfg.APIOnly, + Logger: cfg.LegacyLogger, + MaxOpen: cfg.DBMaxOpen, + MaxIdle: cfg.DBMaxIdle, + }) + if err != nil { + return errors.Wrap(err, "init switchover handler") + } + pool = mgr.Pool() + cfg.SWO = mgr + } else { + appURL, err := sqldrv.AppURL(cfg.DBURL, fmt.Sprintf("GoAlert %s", version.GitVersion())) + if err != nil { + return errors.Wrap(err, "connect to postgres") + } + + poolCfg, err := pgxpool.ParseConfig(appURL) + if err != nil { + return errors.Wrap(err, "parse db URL") + } + poolCfg.MaxConns = int32(cfg.DBMaxOpen) + poolCfg.MinConns = int32(cfg.DBMaxIdle) + sqldrv.SetConfigRetries(poolCfg) + calllimiter.SetConfigQueryLimiterSupport(poolCfg) + + pool, err = pgxpool.NewWithConfig(context.Background(), poolCfg) + if err != nil { + return errors.Wrap(err, "connect to postgres") + } + } + + if err = pool.Ping(earlyShutdownCtx); err != nil { + return errors.Wrap(err, "ping db") + } + + // unregister shutdown signal before creating app + sdCancel() + earlyShutdown.Wait() + + app, err := NewApp(cfg, pool) + if err != nil { + return errors.Wrap(err, "init app") + } + + go handleShutdown(ctx, app.Shutdown) + + // trigger engine cycles by process signal + trigCh := make(chan os.Signal, 1) + signal.Notify(trigCh, triggerSignals...) + go func() { + for range trigCh { + app.Trigger() + } + }() + + return errors.Wrap(app.Run(ctx), "run app") + }, +} + +func handleShutdown(ctx context.Context, fn func(ctx context.Context) error) { + <-shutdownSignalCh + log.Logf(ctx, "Application attempting graceful shutdown.") + sCtx, cancel := context.WithTimeout(ctx, shutdownTimeout) + defer cancel() + + go func() { + <-shutdownSignalCh + log.Logf(ctx, "Second signal received, terminating immediately") + cancel() + }() + + err := fn(sCtx) + if err != nil { + log.Log(ctx, err) + } +} + +var ( + versionCmd = &cobra.Command{ + Use: "version", + Short: "Output the current version.", + RunE: func(cmd *cobra.Command, args []string) error { + migrations := migrate.Names() + + fmt.Printf(`Version: %s +GitCommit: %s (%s) +BuildDate: %s +GoVersion: %s (%s) +Platform: %s/%s +Migration: %s (#%d) +`, version.GitVersion(), + version.GitCommit(), version.GitTreeState(), + version.BuildDate().Local().Format(time.RFC3339), + runtime.Version(), runtime.Compiler, + runtime.GOOS, runtime.GOARCH, + migrations[len(migrations)-1], len(migrations), + ) + + return nil + }, + } + + testCmd = &cobra.Command{ + Use: "self-test", + Short: "test suite to validate functionality of GoAlert environment", + RunE: func(cmd *cobra.Command, args []string) error { + offlineOnly, _ := cmd.Flags().GetBool("offline") + + var failed bool + result := func(name string, err error) { + if err != nil { + failed = true + fmt.Printf("%s: FAIL (%v)\n", name, err) + return + } + fmt.Printf("%s: OK\n", name) + } + + // only do version check if UI is bundled + if web.AppVersion() != "" { + var err error + if version.GitVersion() != web.AppVersion() { + err = errors.Errorf( + "mismatch: backend version = '%s'; bundled UI version = '%s'", + version.GitVersion(), + web.AppVersion(), + ) + } + result("Version", err) + } + + cf, err := getConfig(cmd.Context()) + if errors.Is(err, ErrDBRequired) { + err = nil + } + if err != nil { + return err + } + var cfg config.Config + loadConfigDB := func() error { + conn, err := sql.Open("pgx", cf.DBURL) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + + ctx := cmd.Context() + + storeCfg := config.StoreConfig{ + DB: conn, + Keys: cf.EncryptionKeys, + } + store, err := config.NewStore(ctx, storeCfg) + if err != nil { + return fmt.Errorf("read config: %w", err) + } + cfg = store.Config() + return store.Shutdown(ctx) + } + if cf.DBURL != "" && !offlineOnly { + result("DB", loadConfigDB()) + } + + type service struct { + name, baseUrl string + } + + serviceList := []service{ + {name: "Twilio", baseUrl: "https://api.twilio.com/2010-04-01"}, + {name: "Mailgun", baseUrl: "https://api.mailgun.net/v3"}, + {name: "Slack", baseUrl: "https://slack.com/api/api.test"}, + } + + if cfg.OIDC.Enable { + serviceList = append(serviceList, service{name: "OIDC", baseUrl: cfg.OIDC.IssuerURL + "/.well-known.openid-configuration"}) + } + + if cfg.GitHub.Enable { + url := "https://github.com" + if cfg.GitHub.EnterpriseURL != "" { + url = cfg.GitHub.EnterpriseURL + } + serviceList = append(serviceList, service{name: "GitHub", baseUrl: url}) + } + + if offlineOnly { + serviceList = nil + } + + for _, s := range serviceList { + resp, err := http.Get(s.baseUrl) + result(s.name, err) + if err == nil { + resp.Body.Close() + } + } + + dstCheck := func() error { + const ( + standardOffset = -21600 + daylightOffset = -18000 + ) + loc, err := util.LoadLocation("America/Chicago") + if err != nil { + return fmt.Errorf("load location: %w", err) + } + t := time.Date(2020, time.March, 8, 0, 0, 0, 0, loc) + _, offset := t.Zone() + if offset != standardOffset { + return errors.Errorf("invalid offset: got %d; want %d", offset, standardOffset) + } + t = t.Add(3 * time.Hour) + _, offset = t.Zone() + if offset != daylightOffset { + return errors.Errorf("invalid offset: got %d; want %d", offset, daylightOffset) + } + t = time.Date(2020, time.November, 1, 0, 0, 0, 0, loc) + _, offset = t.Zone() + if offset != daylightOffset { + return errors.Errorf("invalid offset: got %d; want %d", offset, daylightOffset) + } + t = t.Add(3 * time.Hour) + _, offset = t.Zone() + if offset != standardOffset { + return errors.Errorf("invalid offset: got %d; want %d", offset, standardOffset) + } + return nil + } + + result("DST Rules", dstCheck()) + + if failed { + cmd.SilenceUsage = true + return errors.New("one or more checks failed.") + } + return nil + }, + } + + monitorCmd = &cobra.Command{ + Use: "monitor", + Short: "Start a remote-monitoring process that functionally tests alerts.", + RunE: func(cmd *cobra.Command, args []string) error { + file := viper.GetString("config-file") + if file == "" { + return errors.New("config file is required") + } + + data, err := os.ReadFile(file) + if err != nil { + return err + } + + var cfg remotemonitor.Config + err = toml.Unmarshal(data, &cfg) + if err != nil { + return err + } + + err = initPromServer() + if err != nil { + return err + } + + err = initPprofServer() + if err != nil { + return err + } + + mon, err := remotemonitor.NewMonitor(cfg) + if err != nil { + return err + } + + handleShutdown(context.Background(), mon.Shutdown) + return nil + }, + } + + reEncryptCmd = &cobra.Command{ + Use: "re-encrypt", + Short: "Re-encrypt all keyring secrets and config with the current data-encryption-key (experimental).", + RunE: func(cmd *cobra.Command, args []string) error { + l := log.FromContext(cmd.Context()) + // update JSON output first + if viper.GetBool("json") { + l.EnableJSON() + } + if viper.GetBool("verbose") { + l.EnableDebug() + } + + err := viper.ReadInConfig() + // ignore file not found error + if err != nil && !isCfgNotFound(err) { + return errors.Wrap(err, "read config") + } + + ctx := cmd.Context() + c, err := getConfig(ctx) + if err != nil { + return err + } + + if viper.GetString("data-encryption-key") == "" && !viper.GetBool("allow-empty-data-encryption-key") { + fmt.Println("what", c.EncryptionKeys) + return validation.NewFieldError("data-encryption-key", "Must not be empty, or set --allow-empty-data-encryption-key") + } + + db, err := sql.Open("pgx", c.DBURL) + if err != nil { + return errors.Wrap(err, "connect to postgres") + } + defer db.Close() + + ctx = permission.SystemContext(ctx, "ReEncryptAll") + + return keyring.ReEncryptAll(ctx, db, c.EncryptionKeys) + }, + } + + exportCmd = &cobra.Command{ + Use: "export-migrations", + Short: "Export all migrations as .sql files. Use --export-dir to control the destination.", + + RunE: func(cmd *cobra.Command, args []string) error { + l := log.FromContext(cmd.Context()) + // update JSON output first + if viper.GetBool("json") { + l.EnableJSON() + } + if viper.GetBool("verbose") { + l.EnableDebug() + } + + err := viper.ReadInConfig() + // ignore file not found error + if err != nil && !isCfgNotFound(err) { + return errors.Wrap(err, "read config") + } + + return migrate.DumpMigrations(viper.GetString("export-dir")) + }, + } + + migrateCmd = &cobra.Command{ + Use: "migrate", + Short: "Perform migration(s), then exit.", + RunE: func(cmd *cobra.Command, args []string) error { + l := log.FromContext(cmd.Context()) + if viper.GetBool("verbose") { + l.EnableDebug() + } + + err := viper.ReadInConfig() + // ignore file not found error + if err != nil && !isCfgNotFound(err) { + return errors.Wrap(err, "read config") + } + + ctx := cmd.Context() + c, err := getConfig(ctx) + if err != nil { + return err + } + + down := viper.GetString("down") + up := viper.GetString("up") + if down != "" { + n, err := migrate.Down(ctx, c.DBURL, down) + if err != nil { + return errors.Wrap(err, "apply DOWN migrations") + } + if n > 0 { + log.Debugf(ctx, "Applied %d DOWN migrations.", n) + } + } + + if up != "" || down == "" { + n, err := migrate.Up(ctx, c.DBURL, up) + if err != nil { + return errors.Wrap(err, "apply UP migrations") + } + if n > 0 { + log.Debugf(ctx, "Applied %d UP migrations.", n) + } + } + + return nil + }, + } + + setConfigCmd = &cobra.Command{ + Use: "set-config", + Short: "Sets current config values in the DB from stdin.", + RunE: func(cmd *cobra.Command, args []string) error { + if viper.GetString("data-encryption-key") == "" && !viper.GetBool("allow-empty-data-encryption-key") { + return validation.NewFieldError("data-encryption-key", "Must not be empty, or set --allow-empty-data-encryption-key") + } + var data []byte + if viper.GetString("data") != "" { + data = []byte(viper.GetString("data")) + } else { + if term.IsTerminal(int(os.Stdin.Fd())) { + // Only print message if we're not piping + fmt.Println("Enter or paste config data (JSON), then press CTRL+D when done or CTRL+C to quit.") + } + intCh := make(chan os.Signal, 1) + doneCh := make(chan struct{}) + signal.Notify(intCh, os.Interrupt) + go func() { + select { + case <-intCh: + os.Exit(1) + case <-doneCh: + } + }() + + var err error + data, err = io.ReadAll(os.Stdin) + close(doneCh) + if err != nil { + return errors.Wrap(err, "read stdin") + } + } + + return getSetConfig(cmd.Context(), true, data) + }, + } + + getConfigCmd = &cobra.Command{ + Use: "get-config", + Short: "Gets current config values.", + RunE: func(cmd *cobra.Command, args []string) error { + return getSetConfig(cmd.Context(), false, nil) + }, + } + + addUserCmd = &cobra.Command{ + Use: "add-user", + Short: "Adds a user for basic authentication.", + RunE: func(cmd *cobra.Command, args []string) error { + l := log.FromContext(cmd.Context()) + if viper.GetBool("verbose") { + l.EnableDebug() + } + + err := viper.ReadInConfig() + // ignore file not found error + if err != nil && !isCfgNotFound(err) { + return errors.Wrap(err, "read config") + } + + c, err := getConfig(cmd.Context()) + if err != nil { + return err + } + db, err := sql.Open("pgx", c.DBURL) + if err != nil { + return errors.Wrap(err, "connect to postgres") + } + defer db.Close() + + ctx := permission.SystemContext(cmd.Context(), "AddUser") + + basicStore, err := basic.NewStore(ctx, db) + if err != nil { + return errors.Wrap(err, "init basic auth store") + } + + pass := cmd.Flag("pass").Value.String() + id := cmd.Flag("user-id").Value.String() + username := cmd.Flag("user").Value.String() + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return errors.Wrap(err, "begin tx") + } + defer sqlutil.Rollback(ctx, "add-user", tx) + + if id == "" { + u := &user.User{ + Name: username, + Email: cmd.Flag("email").Value.String(), + Role: permission.RoleUser, + } + if cmd.Flag("admin").Value.String() == "true" { + u.Role = permission.RoleAdmin + } + userStore, err := user.NewStore(ctx, db) + if err != nil { + return errors.Wrap(err, "init user store") + } + u, err = userStore.InsertTx(ctx, tx, u) + if err != nil { + return errors.Wrap(err, "create user") + } + id = u.ID + } + + if pass == "" { + fmt.Fprint(os.Stderr, "New Password: ") + p, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return errors.Wrap(err, "get password") + } + pass = string(p) + fmt.Fprintln(os.Stderr) + } + + pw, err := basicStore.NewHashedPassword(ctx, pass) + if err != nil { + return errors.Wrap(err, "hash password") + } + + err = basicStore.CreateTx(ctx, tx, id, username, pw) + if err != nil { + return errors.Wrap(err, "add basic auth entry") + } + + err = tx.Commit() + if err != nil { + return errors.Wrap(err, "commit tx") + } + + log.Logf(ctx, "Username '%s' added.", username) + + return nil + }, + } +) + +// getConfig will load the current configuration from viper +func getConfig(ctx context.Context) (Config, error) { + cfg := Config{ + LegacyLogger: log.FromContext(ctx), + + JSON: viper.GetBool("json"), + LogRequests: viper.GetBool("log-requests"), + LogEngine: viper.GetBool("log-engine-cycles"), + Verbose: viper.GetBool("verbose"), + APIOnly: viper.GetBool("api-only"), + + DBMaxOpen: viper.GetInt("db-max-open"), + DBMaxIdle: viper.GetInt("db-max-idle"), + + PublicURL: viper.GetString("public-url"), + + MaxReqBodyBytes: viper.GetInt64("max-request-body-bytes"), + MaxReqHeaderBytes: viper.GetInt("max-request-header-bytes"), + + DisableHTTPSRedirect: viper.GetBool("disable-https-redirect"), + EnableSecureHeaders: viper.GetBool("enable-secure-headers"), + + ListenAddr: viper.GetString("listen"), + + TLSListenAddr: viper.GetString("listen-tls"), + + SysAPIListenAddr: viper.GetString("listen-sysapi"), + SysAPICertFile: viper.GetString("sysapi-cert-file"), + SysAPIKeyFile: viper.GetString("sysapi-key-file"), + SysAPICAFile: viper.GetString("sysapi-ca-file"), + + SMTPListenAddr: viper.GetString("smtp-listen"), + SMTPListenAddrTLS: viper.GetString("smtp-listen-tls"), + SMTPAdditionalDomains: viper.GetString("smtp-additional-domains"), + SMTPMaxRecipients: viper.GetInt("smtp-max-recipients"), + + EmailIntegrationDomain: viper.GetString("email-integration-domain"), + + EngineCycleTime: viper.GetDuration("engine-cycle-time"), + + HTTPPrefix: viper.GetString("http-prefix"), + + SlackBaseURL: viper.GetString("slack-base-url"), + TwilioBaseURL: viper.GetString("twilio-base-url"), + + DBURL: viper.GetString("db-url"), + DBURLNext: viper.GetString("db-url-next"), + + StatusAddr: viper.GetString("status-addr"), + + EncryptionKeys: keyring.Keys{[]byte(viper.GetString("data-encryption-key")), []byte(viper.GetString("data-encryption-key-old"))}, + + RegionName: viper.GetString("region-name"), + + StubNotifiers: viper.GetBool("stub-notifiers"), + + UIDir: viper.GetString("ui-dir"), + } + + lg := cfg.LegacyLogger.Logrus() + opts := sloglogrus.Option{ + Level: slog.LevelInfo, + Logger: lg, + } + if viper.GetBool("log-errors-only") { + opts.Level = slog.LevelError + lg.SetLevel(2) + cfg.LegacyLogger.ErrorsOnly() + } else if cfg.Verbose { + opts.Level = slog.LevelDebug + lg.SetLevel(5) + } + cfg.Logger = slog.New(opts.NewLogrusHandler()) + + var fs expflag.FlagSet + strict := viper.GetBool("strict-experimental") + s := viper.GetStringSlice("experimental") + if len(s) == 1 { + s = strings.Split(s[0], ",") + } + for _, f := range s { + if strict && expflag.Description(expflag.Flag(f)) == "" { + return cfg, errors.Errorf("unknown experimental flag: %s", f) + } + + fs = append(fs, expflag.Flag(f)) + } + cfg.ExpFlags = fs + + if cfg.PublicURL != "" { + u, err := url.Parse(cfg.PublicURL) + if err != nil { + return cfg, errors.Wrap(err, "parse public url") + } + if u.Scheme == "" { + return cfg, errors.New("public-url must be an absolute URL (missing scheme)") + } + u.Path = strings.TrimSuffix(u.Path, "/") + cfg.PublicURL = u.String() + if cfg.HTTPPrefix != "" { + return cfg, errors.New("public-url and http-prefix cannot be used together") + } + cfg.HTTPPrefix = u.Path + } + + if cfg.DBURL == "" { + return cfg, ErrDBRequired + } + + var err error + cfg.TLSConfig, err = getTLSConfig("") + if err != nil { + return cfg, err + } + if cfg.TLSConfig != nil { + cfg.TLSConfig.NextProtos = []string{"h2", "http/1.1"} + } + + if cfg.SMTPListenAddr != "" || cfg.SMTPListenAddrTLS != "" { + if cfg.EmailIntegrationDomain == "" { + return cfg, errors.New("email-integration-domain is required when smtp-listen or smtp-listen-tls is set") + } + } + + cfg.TLSConfigSMTP, err = getTLSConfig("smtp-") + if err != nil { + return cfg, err + } + + if viper.GetBool("stack-traces") { + log.FromContext(ctx).EnableStacks() + } + return cfg, nil +} + +func init() { + def := Defaults() + RootCmd.Flags().StringP("listen", "l", def.ListenAddr, "Listen address:port for the application.") + + RootCmd.Flags().StringP("listen-tls", "t", def.TLSListenAddr, "HTTPS listen address:port for the application. Requires setting --tls-cert-data and --tls-key-data OR --tls-cert-file and --tls-key-file.") + + RootCmd.Flags().StringSlice("experimental", nil, "Enable experimental features.") + RootCmd.Flags().Bool("list-experimental", false, "List experimental features.") + RootCmd.Flags().Bool("strict-experimental", false, "Fail to start if unknown experimental features are specified.") + + RootCmd.Flags().String("listen-sysapi", "", "(Experimental) Listen address:port for the system API (gRPC).") + + RootCmd.Flags().String("sysapi-cert-file", "", "(Experimental) Specifies a path to a PEM-encoded certificate to use when connecting to plugin services.") + RootCmd.Flags().String("sysapi-key-file", "", "(Experimental) Specifies a path to a PEM-encoded private key file use when connecting to plugin services.") + RootCmd.Flags().String("sysapi-ca-file", "", "(Experimental) Specifies a path to a PEM-encoded certificate(s) to authorize connections from plugin services.") + + RootCmd.Flags().String("public-url", "", "Externally routable URL to the application. Used for validating callback requests, links, auth, and prefix calculation.") + + RootCmd.PersistentFlags().StringP("listen-prometheus", "p", "", "Bind address for Prometheus metrics.") + RootCmd.PersistentFlags().String("listen-pprof", "", "Bind address for pprof.") + RootCmd.PersistentFlags().Int("pprof-block-profile-rate", 0, "Set the block profile rate in hz.") + RootCmd.PersistentFlags().Int("pprof-mutex-profile-fraction", 0, "Set the mutex profile fraction (rate is 1/this-value).") + + RootCmd.Flags().String("tls-cert-file", "", "Specifies a path to a PEM-encoded certificate. Has no effect if --listen-tls is unset.") + RootCmd.Flags().String("tls-key-file", "", "Specifies a path to a PEM-encoded private key file. Has no effect if --listen-tls is unset.") + RootCmd.Flags().String("tls-cert-data", "", "Specifies a PEM-encoded certificate. Has no effect if --listen-tls is unset.") + RootCmd.Flags().String("tls-key-data", "", "Specifies a PEM-encoded private key. Has no effect if --listen-tls is unset.") + + RootCmd.Flags().String("smtp-listen", "", "Listen address:port for an internal SMTP server.") + RootCmd.Flags().String("smtp-listen-tls", "", "SMTPS listen address:port for an internal SMTP server. Requires setting --smtp-tls-cert-data and --smtp-tls-key-data OR --smtp-tls-cert-file and --smtp-tls-key-file.") + RootCmd.Flags().String("email-integration-domain", "", "This flag is required to set the domain used for email integration keys when --smtp-listen or --smtp-listen-tls are set.") + + RootCmd.Flags().String("smtp-tls-cert-file", "", "Specifies a path to a PEM-encoded certificate. Has no effect if --smtp-listen-tls is unset.") + RootCmd.Flags().String("smtp-tls-key-file", "", "Specifies a path to a PEM-encoded private key file. Has no effect if --smtp-listen-tls is unset.") + RootCmd.Flags().String("smtp-tls-cert-data", "", "Specifies a PEM-encoded certificate. Has no effect if --smtp-listen-tls is unset.") + RootCmd.Flags().String("smtp-tls-key-data", "", "Specifies a PEM-encoded private key. Has no effect if --smtp-listen-tls is unset.") + + RootCmd.Flags().Int("smtp-max-recipients", def.SMTPMaxRecipients, "Specifies the maximum number of recipients allowed per message.") + RootCmd.Flags().String("smtp-additional-domains", "", "Specifies additional destination domains that are allowed for the SMTP server. For multiple domains, separate them with a comma, e.g., \"domain1.com,domain2.org,domain3.net\".") + + RootCmd.Flags().Duration("engine-cycle-time", def.EngineCycleTime, "Time between engine cycles.") + + RootCmd.Flags().String("http-prefix", def.HTTPPrefix, "Specify the HTTP prefix of the application.") + _ = RootCmd.Flags().MarkDeprecated("http-prefix", "use --public-url instead") + + RootCmd.Flags().Bool("api-only", def.APIOnly, "Starts in API-only mode (schedules & notifications will not be processed). Useful in clusters.") + + RootCmd.Flags().Int("db-max-open", def.DBMaxOpen, "Max open DB connections.") + RootCmd.Flags().Int("db-max-idle", def.DBMaxIdle, "Max idle DB connections.") + + RootCmd.Flags().Int64("max-request-body-bytes", def.MaxReqBodyBytes, "Max body size for all incoming requests (in bytes). Set to 0 to disable limit.") + RootCmd.Flags().Int("max-request-header-bytes", def.MaxReqHeaderBytes, "Max header size for all incoming requests (in bytes). Set to 0 to disable limit.") + + // No longer used + RootCmd.Flags().String("github-base-url", "", "Base URL for GitHub auth and API calls.") + + RootCmd.Flags().String("twilio-base-url", def.TwilioBaseURL, "Override the Twilio API URL.") + RootCmd.Flags().String("slack-base-url", def.SlackBaseURL, "Override the Slack base URL.") + + RootCmd.Flags().String("region-name", def.RegionName, "Name of region for message processing (case sensitive). Only one instance per-region-name will process outgoing messages.") + + RootCmd.PersistentFlags().String("db-url", def.DBURL, "Connection string for Postgres.") + RootCmd.PersistentFlags().String("db-url-next", def.DBURLNext, "Connection string for the *next* Postgres server (enables DB switchover mode).") + + RootCmd.Flags().String("jaeger-endpoint", "", "Jaeger HTTP Thrift endpoint") + RootCmd.Flags().String("jaeger-agent-endpoint", "", "Instructs Jaeger exporter to send spans to jaeger-agent at this address.") + _ = RootCmd.Flags().MarkDeprecated("jaeger-endpoint", "Jaeger support has been removed.") + _ = RootCmd.Flags().MarkDeprecated("jaeger-agent-endpoint", "Jaeger support has been removed.") + RootCmd.Flags().String("stackdriver-project-id", "", "Project ID for Stackdriver. Enables tracing output to Stackdriver.") + _ = RootCmd.Flags().MarkDeprecated("stackdriver-project-id", "Stackdriver support has been removed.") + RootCmd.Flags().String("tracing-cluster-name", "", "Cluster name to use for tracing (i.e. kubernetes, Stackdriver/GKE environment).") + _ = RootCmd.Flags().MarkDeprecated("tracing-cluster-name", "Tracing support has been removed.") + RootCmd.Flags().String("tracing-pod-namespace", "", "Pod namespace to use for tracing.") + _ = RootCmd.Flags().MarkDeprecated("tracing-pod-namespace", "Tracing support has been removed.") + RootCmd.Flags().String("tracing-pod-name", "", "Pod name to use for tracing.") + _ = RootCmd.Flags().MarkDeprecated("tracing-pod-name", "Tracing support has been removed.") + RootCmd.Flags().String("tracing-container-name", "", "Container name to use for tracing.") + _ = RootCmd.Flags().MarkDeprecated("tracing-container-name", "Tracing support has been removed.") + RootCmd.Flags().String("tracing-node-name", "", "Node name to use for tracing.") + _ = RootCmd.Flags().MarkDeprecated("tracing-node-name", "Tracing support has been removed.") + RootCmd.Flags().Float64("tracing-probability", 0, "Probability of a new trace to be recorded.") + _ = RootCmd.Flags().MarkDeprecated("tracing-probability", "Tracing support has been removed.") + + RootCmd.Flags().Duration("kubernetes-cooldown", 0, "Cooldown period, from the last TCP connection, before terminating the listener when receiving a shutdown signal.") + _ = RootCmd.Flags().MarkDeprecated("kubernetes-cooldown", "Use lifecycle hooks (preStop) instead.") + RootCmd.Flags().String("status-addr", def.StatusAddr, "Open a port to emit status updates. Connections are closed when the server shuts down. Can be used to keep containers running until GoAlert has exited.") + + RootCmd.PersistentFlags().String("data-encryption-key", "", "Used to generate an encryption key for sensitive data like signing keys. Can be any length. Only use this when performing a switchover.") + RootCmd.PersistentFlags().String("data-encryption-key-old", "", "Fallback key. Used for decrypting existing data only. Only necessary when changing --data-encryption-key.") + RootCmd.PersistentFlags().Bool("stack-traces", false, "Enables stack traces with all error logs.") + + RootCmd.Flags().Bool("stub-notifiers", def.StubNotifiers, "If true, notification senders will be replaced with a stub notifier that always succeeds (useful for staging/sandbox environments).") + + RootCmd.PersistentFlags().BoolP("verbose", "v", def.Verbose, "Enable verbose logging.") + RootCmd.Flags().Bool("log-requests", def.LogRequests, "Log all HTTP requests. If false, requests will be logged for debug/trace contexts only.") + RootCmd.Flags().Bool("log-engine-cycles", def.LogEngine, "Log start and end of each engine cycle.") + RootCmd.PersistentFlags().Bool("json", def.JSON, "Log in JSON format.") + RootCmd.PersistentFlags().Bool("log-errors-only", false, "Only log errors (superseeds other flags).") + + RootCmd.Flags().String("ui-dir", "", "Serve UI assets from a local directory instead of from memory.") + + RootCmd.Flags().Bool("disable-https-redirect", def.DisableHTTPSRedirect, "Disable automatic HTTPS redirects.") + RootCmd.Flags().Bool("enable-secure-headers", false, "Enable secure headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Content-Security-Policy).") + + migrateCmd.Flags().String("up", "", "Target UP migration to apply.") + migrateCmd.Flags().String("down", "", "Target DOWN migration to roll back to.") + exportCmd.Flags().String("export-dir", "migrations", "Destination dir for export. If it does not exist, it will be created.") + + addUserCmd.Flags().String("user-id", "", "If specified, the auth entry will be created for an existing user ID. Default is to create a new user.") + addUserCmd.Flags().String("pass", "", "Specify new users password (if blank, prompt will be given).") + addUserCmd.Flags().String("user", "", "Specifies the login username.") + addUserCmd.Flags().String("email", "", "Specifies the email address of the new user (ignored if user-id is provided).") + addUserCmd.Flags().Bool("admin", false, "If specified, the user will be created with the admin role (ignored if user-id is provided).") + + setConfigCmd.Flags().String("data", "", "Use data instead of reading config from stdin.") + setConfigCmd.Flags().Bool("allow-empty-data-encryption-key", false, "Explicitly allow an empty data-encryption-key when setting config or re-encrypting data.") + reEncryptCmd.Flags().AddFlag(setConfigCmd.Flag("allow-empty-data-encryption-key")) + + testCmd.Flags().Bool("offline", false, "Only perform offline checks.") + + monitorCmd.Flags().StringP("config-file", "f", "", "Configuration file for monitoring (required).") + initCertCommands() + RootCmd.AddCommand(versionCmd, testCmd, migrateCmd, exportCmd, monitorCmd, addUserCmd, getConfigCmd, setConfigCmd, genCerts, reEncryptCmd) + + err := viper.BindPFlags(RootCmd.Flags()) + if err != nil { + panic(err) + } + err = viper.BindPFlags(reEncryptCmd.Flags()) + if err != nil { + panic(err) + } + err = viper.BindPFlags(monitorCmd.Flags()) + if err != nil { + panic(err) + } + err = viper.BindPFlags(migrateCmd.Flags()) + if err != nil { + panic(err) + } + err = viper.BindPFlags(exportCmd.Flags()) + if err != nil { + panic(err) + } + err = viper.BindPFlags(setConfigCmd.Flags()) + if err != nil { + panic(err) + } + err = viper.BindPFlags(getConfigCmd.Flags()) + if err != nil { + panic(err) + } + err = viper.BindPFlags(RootCmd.PersistentFlags()) + if err != nil { + panic(err) + } + + viper.SetEnvPrefix("GOALERT") + + // use underscores in env names + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + + viper.AutomaticEnv() +} diff --git a/Cloudron/CloudronPackages-Artifacts/goalert/app/cmd/goalert-slack-email-sync/main.go b/Cloudron/CloudronPackages-Artifacts/goalert/app/cmd/goalert-slack-email-sync/main.go new file mode 100644 index 0000000..dd754f9 --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/goalert/app/cmd/goalert-slack-email-sync/main.go @@ -0,0 +1,111 @@ +/* +goalert-slack-email-sync will create/update AuthSubject entries for users by matching the user's GoAlert email to the corresponding Slack user. +*/ +package main + +import ( + "context" + "errors" + "flag" + "io" + "log" + "strings" + "time" + + "github.com/slack-go/slack" + "github.com/target/goalert/pkg/sysapi" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" +) + +func main() { + api := flag.String("api", "localhost:1234", "Target address of GoAlert SysAPI server.") + cert := flag.String("cert-file", "", "Path to PEM-encoded certificate for gRPC auth.") + key := flag.String("key-file", "", "Path to PEM-encoded key for gRPC auth.") + ca := flag.String("ca-file", "", "Path to PEM-encoded CA certificate for gRPC auth.") + token := flag.String("token", "", "Slack API token for looking up users.") + domain := flag.String("domain", "", "Limit requests to users with an email at the provided domain.") + flag.Parse() + + log.SetFlags(log.Lshortfile) + + creds := insecure.NewCredentials() + if *cert+*key+*ca != "" { + cfg, err := sysapi.NewTLS(*ca, *cert, *key) + if err != nil { + log.Fatal("tls credentials:", err) + } + creds = credentials.NewTLS(cfg) + } + + conn, err := grpc.NewClient(*api, grpc.WithTransportCredentials(creds)) + if err != nil { + log.Fatal("connect to GoAlert:", err) + } + defer conn.Close() + + goalertClient := sysapi.NewSysAPIClient(conn) + slackClient := slack.New(*token) + + getRetry := func(email string) (*slack.User, error) { + for { + slackUser, err := slackClient.GetUserByEmail(email) + var rateLimitErr *slack.RateLimitedError + if errors.As(err, &rateLimitErr) { + log.Printf("ERROR: rate-limited, waiting %s", rateLimitErr.RetryAfter.String()) + time.Sleep(rateLimitErr.RetryAfter) + continue + } + + return slackUser, err + } + } + + ctx := context.Background() + + info, err := slackClient.GetTeamInfoContext(ctx) + if err != nil { + log.Fatalln("get team info:", err) + } + + providerID := "slack:" + info.ID + users, err := goalertClient.UsersWithoutAuthProvider(ctx, &sysapi.UsersWithoutAuthProviderRequest{ProviderId: providerID}) + if err != nil { + log.Fatalln("fetch users missing provider:", err) + } + + var count int + for { + u, err := users.Recv() + if err == io.EOF { + break + } + if err != nil { + log.Fatalln("fetch missing user:", err) + } + if !strings.HasSuffix(u.Email, *domain) { + continue + } + slackUser, err := getRetry(u.Email) + if err != nil { + if !strings.Contains(err.Error(), "users_not_found") { + log.Fatalf("lookup Slack user '%s': %v", u.Email, err) + } + log.Printf("lookup Slack user '%s': %v", u.Email, err) + continue + } + + _, err = goalertClient.SetAuthSubject(ctx, &sysapi.SetAuthSubjectRequest{Subject: &sysapi.AuthSubject{ + ProviderId: providerID, + UserId: u.Id, + SubjectId: slackUser.ID, + }}) + if err != nil { + log.Fatalf("set provider '%s' auth subject for user '%s' to '%s': %v", providerID, u.Id, slackUser.ID, err) + } + count++ + } + + log.Printf("Updated %d users.", count) +} diff --git a/Cloudron/CloudronPackages-Artifacts/goalert/app/cmd/goalert/main.go b/Cloudron/CloudronPackages-Artifacts/goalert/app/cmd/goalert/main.go new file mode 100644 index 0000000..22c5dcc --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/goalert/app/cmd/goalert/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "fmt" + "log" + "net/http" +) + +func main() { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello from %s", "app") + }) + + fmt.Println("Server starting on :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/Cloudron/CloudronPackages-Artifacts/goalert/app/cmdcerts.go b/Cloudron/CloudronPackages-Artifacts/goalert/app/cmdcerts.go new file mode 100644 index 0000000..27a2199 --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/goalert/app/cmdcerts.go @@ -0,0 +1,331 @@ +package app + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "time" + + "github.com/spf13/cobra" + "golang.org/x/crypto/ed25519" +) + +type certType int + +const ( + certTypeUnknown certType = iota + certTypeCASystem + certTypeCAPlugin + certTypeServer + certTypeClient +) + +func copyFile(dst, src string) error { + data, err := os.ReadFile(src) + if err != nil { + return fmt.Errorf("read '%s': %w", src, err) + } + err = os.WriteFile(dst, data, 0o644) + if err != nil { + return fmt.Errorf("write '%s': %w", dst, err) + } + return nil +} + +func loadPair(certFile, keyFile string) (cert *x509.Certificate, pk interface{}, err error) { + data, err := os.ReadFile(certFile) + if err != nil { + return nil, nil, fmt.Errorf("read cert file '%s': %w", certFile, err) + } + p, _ := pem.Decode(data) + cert, err = x509.ParseCertificate(p.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("parse cert file '%s': %w", certFile, err) + } + + data, err = os.ReadFile(keyFile) + if err != nil { + return nil, nil, fmt.Errorf("read key file '%s': %w", keyFile, err) + } + p, _ = pem.Decode(data) + pk, err = x509.ParsePKCS8PrivateKey(p.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("parse key file '%s': %w", keyFile, err) + } + return cert, pk, nil +} + +func certTemplate(t certType) *x509.Certificate { + switch t { + case certTypeCASystem, certTypeCAPlugin: + return &x509.Certificate{ + IsCA: true, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(100, 0, 0), + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + case certTypeServer, certTypeClient: + return &x509.Certificate{ + Subject: pkix.Name{ + CommonName: _certCommonName, // Will be checked by the server + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(100, 0, 0), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: []string{_certCommonName}, + } + } + + panic("unknown certType") +} + +type keypair interface { + Public() crypto.PublicKey +} + +func privateKey() (keypair, error) { + if _certED25519Key { + _, pk, err := ed25519.GenerateKey(rand.Reader) + return pk, err + } + + switch _certECDSACurve { + case "": + // fall to RSA + case "P224": + return ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + case "P256": + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + case "P384": + return ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + case "P521": + return ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + default: + return nil, fmt.Errorf("invalid ECDSA curve '%s'", _certECDSACurve) + } + + return rsa.GenerateKey(rand.Reader, _certRSABits) +} + +func genCertFiles(t certType, extra ...certType) error { + template := certTemplate(t) + sn, err := certSerialNumber() + if err != nil { + return err + } + + pk, err := privateKey() + if err != nil { + return fmt.Errorf("generate private key: %w", err) + } + template.SerialNumber = sn + parentCert, parentKey := template, (interface{})(pk) + + var certFile, keyFile string + switch t { + case certTypeCASystem: + certFile = _certSystemCACertFile + keyFile = _certSystemCAKeyFile + case certTypeCAPlugin: + certFile = _certPluginCACertFile + keyFile = _certPluginCAKeyFile + case certTypeServer: + certFile = _certServerCertFile + keyFile = _certServerKeyFile + parentCert, parentKey, err = loadPair(_certSystemCACertFile, _certSystemCAKeyFile) + if err != nil { + return fmt.Errorf("load keypair: %w", err) + } + err = copyFile(_certServerCAFile, _certPluginCACertFile) + if err != nil { + return fmt.Errorf("copy CA bundle: %w", err) + } + case certTypeClient: + certFile = _certClientCertFile + keyFile = _certClientKeyFile + parentCert, parentKey, err = loadPair(_certPluginCACertFile, _certPluginCAKeyFile) + if err != nil { + return fmt.Errorf("load keypair: %w", err) + } + err = copyFile(_certClientCAFile, _certSystemCACertFile) + if err != nil { + return fmt.Errorf("copy CA bundle: %w", err) + } + default: + panic("unknown certType") + } + + certBytes, err := x509.CreateCertificate(rand.Reader, template, parentCert, pk.Public(), parentKey) + if err != nil { + return fmt.Errorf("create certificate: %w", err) + } + + certOut, err := os.Create(certFile) + if err != nil { + return fmt.Errorf("open cert file '%s': %w", certFile, err) + } + defer certOut.Close() + + err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}) + if err != nil { + return fmt.Errorf("encode certificate: %w", err) + } + + privBytes, err := x509.MarshalPKCS8PrivateKey(pk) + if err != nil { + return fmt.Errorf("encode private key: %w", err) + } + + keyOut, err := os.Create(keyFile) + if err != nil { + return fmt.Errorf("open key file '%s': %w", keyFile, err) + } + defer keyOut.Close() + + err = pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}) + if err != nil { + return fmt.Errorf("encode private key: %w", err) + } + + if len(extra) > 0 { + return genCertFiles(extra[0], extra[1:]...) + } + return nil +} + +var ( + genCerts = &cobra.Command{ + Use: "gen-cert", + Short: "Generate a certificate for SysAPI (gRPC) usage.", + } + + genAllCert = &cobra.Command{ + Use: "all", + Short: "Generate all certificates for GoAlert to authenticate to/from gRPC clients.", + RunE: func(cmd *cobra.Command, args []string) error { + err := genCertFiles(certTypeCASystem, certTypeCAPlugin, certTypeServer, certTypeClient) + if err != nil { + return fmt.Errorf("generate cert files: %w", err) + } + return nil + }, + } + + genCACert = &cobra.Command{ + Use: "ca", + Short: "Generate a CA certificates for GoAlert to authenticate to/from gRPC clients.", + RunE: func(cmd *cobra.Command, args []string) error { + err := genCertFiles(certTypeCASystem, certTypeCAPlugin) + if err != nil { + return fmt.Errorf("generate cert files: %w", err) + } + return nil + }, + } + + genServerCert = &cobra.Command{ + Use: "server", + Short: "Generate a server certificate for GoAlert to authenticate to/from gRPC clients.", + RunE: func(cmd *cobra.Command, args []string) error { + err := genCertFiles(certTypeServer) + if err != nil { + return fmt.Errorf("generate cert files: %w", err) + } + return nil + }, + } + genClientCert = &cobra.Command{ + Use: "client", + Short: "Generate a client certificate for services that talk to GoAlert.", + RunE: func(cmd *cobra.Command, args []string) error { + err := genCertFiles(certTypeClient) + if err != nil { + return fmt.Errorf("generate cert files: %w", err) + } + return nil + }, + } +) + +func certSerialNumber() (*big.Int, error) { + if _certSerialNumber == "" { + return randSerialNumber(), nil + } + + sn := new(big.Int) + sn, ok := sn.SetString(_certSerialNumber, 10) + if !ok { + return nil, fmt.Errorf("invalid value for serial number '%s'", _certSerialNumber) + } + return sn, nil +} + +func randSerialNumber() *big.Int { + maxSN := new(big.Int) + // x509 serial number can be up to 20 bytes, so 160 bits -1 (sign) + maxSN.Exp(big.NewInt(2), big.NewInt(159), nil).Sub(maxSN, big.NewInt(1)) + sn, err := rand.Int(rand.Reader, maxSN) + if err != nil { + panic(err) + } + return sn +} + +var ( + _certCommonName string = "GoAlert" + _certSerialNumber string = "" + _certSystemCACertFile string = "system.ca.pem" + _certSystemCAKeyFile string = "system.ca.key" + _certPluginCACertFile string = "plugin.ca.pem" + _certPluginCAKeyFile string = "plugin.ca.key" + _certClientCertFile string = "goalert-client.pem" + _certClientKeyFile string = "goalert-client.key" + _certClientCAFile string = "goalert-client.ca.pem" + _certServerCertFile string = "goalert-server.pem" + _certServerKeyFile string = "goalert-server.key" + _certServerCAFile string = "goalert-server.ca.pem" + + _certValidFrom string = "" + _certValidFor time.Duration = 10 * 365 * 24 * time.Hour + _certRSABits int = 2048 + _certECDSACurve string = "" + _certED25519Key bool = false +) + +func initCertCommands() { + genCerts.PersistentFlags().StringVar(&_certSerialNumber, "serial-number", _certSerialNumber, "Serial number to use for generated certificate (default is random).") + + genCerts.PersistentFlags().StringVar(&_certValidFrom, "start-date", _certValidFrom, "Creation date formatted as Jan 2 15:04:05 2006") + genCerts.PersistentFlags().DurationVar(&_certValidFor, "duration", _certValidFor, "Creation date formatted as Jan 2 15:04:05 2006") + genCerts.PersistentFlags().IntVar(&_certRSABits, "rsa-bits", _certRSABits, "Size of RSA key(s) to create. Ignored if either --ecdsa-curve or --ed25519 are set.") + genCerts.PersistentFlags().StringVar(&_certECDSACurve, "ecdsa-curve", _certECDSACurve, "ECDSA curve to use to generate a key. Valid values are P224, P256 (recommended), P384, P521. Ignored if --ed25519 is set.") + genCerts.PersistentFlags().BoolVar(&_certED25519Key, "ed25519", _certED25519Key, "Generate ED25519 key(s).") + + genCerts.PersistentFlags().StringVar(&_certCommonName, "cn", _certCommonName, "Common name of the certificate.") + + genCerts.PersistentFlags().StringVar(&_certSystemCACertFile, "system-ca-cert-file", _certSystemCACertFile, "CA cert file for signing server certs.") + genCerts.PersistentFlags().StringVar(&_certSystemCAKeyFile, "system-ca-key-file", _certSystemCAKeyFile, "CA key file for signing server certs.") + genCerts.PersistentFlags().StringVar(&_certPluginCACertFile, "plugin-ca-cert-file", _certPluginCACertFile, "CA cert file for signing client certs.") + genCerts.PersistentFlags().StringVar(&_certPluginCAKeyFile, "plugin-ca-key-file", _certPluginCAKeyFile, "CA key file for signing client certs.") + + genServerCert.Flags().StringVar(&_certServerCertFile, "server-cert-file", _certServerCertFile, "Output file for the new server certificate.") + genServerCert.Flags().StringVar(&_certServerKeyFile, "server-key-file", _certServerKeyFile, "Output file for the new server key.") + genServerCert.Flags().StringVar(&_certServerCAFile, "server-ca-file", _certServerCAFile, "Output file for the server CA bundle.") + + genClientCert.Flags().StringVar(&_certClientCertFile, "client-cert-file", _certClientCertFile, "Output file for the new client certificate.") + genClientCert.Flags().StringVar(&_certClientKeyFile, "client-key-file", _certClientKeyFile, "Output file for the new client key.") + genClientCert.Flags().StringVar(&_certClientCAFile, "client-ca-file", _certClientCAFile, "Output file for the client CA bundle.") + + genCerts.AddCommand(genAllCert, genCACert, genServerCert, genClientCert) +} diff --git a/Cloudron/CloudronPackages-Artifacts/goalert/app/config.go b/Cloudron/CloudronPackages-Artifacts/goalert/app/config.go new file mode 100644 index 0000000..0f6108d --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/goalert/app/config.go @@ -0,0 +1,85 @@ +package app + +import ( + "crypto/tls" + "log/slog" + "time" + + "github.com/target/goalert/config" + "github.com/target/goalert/expflag" + "github.com/target/goalert/keyring" + "github.com/target/goalert/swo" + "github.com/target/goalert/util/log" +) + +type Config struct { + LegacyLogger *log.Logger + + Logger *slog.Logger + + ExpFlags expflag.FlagSet + + ListenAddr string + Verbose bool + JSON bool + LogRequests bool + APIOnly bool + LogEngine bool + + ForceRiverDBTime bool + + PublicURL string + + TLSListenAddr string + TLSConfig *tls.Config + + SysAPIListenAddr string + SysAPICertFile string + SysAPIKeyFile string + SysAPICAFile string + + SMTPListenAddr string + SMTPListenAddrTLS string + SMTPMaxRecipients int + TLSConfigSMTP *tls.Config + SMTPAdditionalDomains string + + EmailIntegrationDomain string + + HTTPPrefix string + + DBMaxOpen int + DBMaxIdle int + + MaxReqBodyBytes int64 + MaxReqHeaderBytes int + + DisableHTTPSRedirect bool + + EnableSecureHeaders bool + + TwilioBaseURL string + SlackBaseURL string + + DBURL string + DBURLNext string + + StatusAddr string + + EngineCycleTime time.Duration + + EncryptionKeys keyring.Keys + + RegionName string + + StubNotifiers bool + + UIDir string + + // InitialConfig will be pushed into the config store + // if specified before the engine is started. + InitialConfig *config.Config + + // SWO should be set to operate in switchover mode. + SWO *swo.Manager +} diff --git a/Cloudron/CloudronPackages-Artifacts/goalert/app/context.go b/Cloudron/CloudronPackages-Artifacts/goalert/app/context.go new file mode 100644 index 0000000..7d94be3 --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/goalert/app/context.go @@ -0,0 +1,24 @@ +package app + +import ( + "context" + + "github.com/target/goalert/expflag" + "github.com/target/goalert/util/log" +) + +// Context returns a new context with the App's configuration for +// experimental flags and logger. +// +// It should be used for calls from other packages to ensure that +// the correct configuration is used. +func (app *App) Context(ctx context.Context) context.Context { + ctx = expflag.Context(ctx, app.cfg.ExpFlags) + ctx = log.WithLogger(ctx, app.cfg.LegacyLogger) + + if app.ConfigStore != nil { + ctx = app.ConfigStore.Config().Context(ctx) + } + + return ctx +} diff --git a/Cloudron/CloudronPackages-Artifacts/goalert/app/csp/context.go b/Cloudron/CloudronPackages-Artifacts/goalert/app/csp/context.go new file mode 100644 index 0000000..ec2cfb3 --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/goalert/app/csp/context.go @@ -0,0 +1,21 @@ +package csp + +import ( + "context" +) + +type nonceval struct{} + +// WithNonce will add a nonce value to the context. +func WithNonce(ctx context.Context, value string) context.Context { + return context.WithValue(ctx, nonceval{}, value) +} + +// NonceValue will return the nonce value from the context. +func NonceValue(ctx context.Context) string { + v := ctx.Value(nonceval{}) + if v == nil { + return "" + } + return v.(string) +} diff --git a/Cloudron/CloudronPackages-Artifacts/goalert/app/csp/rewriter.go b/Cloudron/CloudronPackages-Artifacts/goalert/app/csp/rewriter.go new file mode 100644 index 0000000..bf61a7e --- /dev/null +++ b/Cloudron/CloudronPackages-Artifacts/goalert/app/csp/rewriter.go @@ -0,0 +1,38 @@ +package csp + +import ( + "bytes" + "mime" + "net/http" +) + +type nonceRW struct { + http.ResponseWriter + nonce string +} + +func (w nonceRW) Write(b []byte) (int, error) { + // check content type + // if not html, return as-is + ct := w.Header().Get("Content-Type") + mediaType, _, _ := mime.ParseMediaType(ct) // ignore error, we just want the cleaned-up type + if mediaType != "text/html" { + return w.ResponseWriter.Write(b) + } + buf := make([]byte, len(b)) + copy(buf, b) + buf = bytes.ReplaceAll(buf, []byte(""), []byte(``), 1) + _, err := w.ResponseWriter.Write(buf) + return len(b), err +} + +// NonceResponseWriter will add a nonce value to + + +EOF + + echo "Dashboard generated: $STATUS_FILE" +} + +# Terminal summary +show_terminal_summary() { + local total=0 + local completed=0 + local pending=0 + + echo "" + echo -e "${BLUE}=== Cloudron Packaging Status ===${NC}" + echo "" + + for dir in "$WORKSPACE_DIR"/*; do + if [ -d "$dir" ]; then + ((total++)) + local status=$(get_app_status "$dir") + if [ "$status" = "completed" ]; then + ((completed++)) + else + ((pending++)) + fi + fi + done + + echo -e "${CYAN}Total Applications:${NC} $total" + echo -e "${GREEN}Completed:${NC} $completed" + echo -e "${YELLOW}Pending:${NC} $pending" + + local progress=$((completed * 100 / total)) + echo -e "${BLUE}Progress:${NC} ${progress}%" + echo "" + + # Show recent completed apps + echo -e "${GREEN}Recently Completed:${NC}" + for dir in "$WORKSPACE_DIR"/*; do + if [ -d "$dir" ] && [ -d "$dir/app" ]; then + local app_name=$(basename "$dir") + local app_type=$(detect_app_type "$dir") + echo -e " ✓ ${GREEN}$app_name${NC} ($app_type)" + fi + done | head -10 + + if [ $(find "$WORKSPACE_DIR" -name "app" -type d | wc -l) -gt 10 ]; then + echo " ... and $(($(find "$WORKSPACE_DIR" -name "app" -type d | wc -l) - 10)) more" + fi +} + +# Main function +main() { + mkdir -p "$ARTIFACTS_DIR" + + generate_dashboard + show_terminal_summary + + echo "" + echo -e "${BLUE}Dashboard:${NC} file://$STATUS_FILE" + echo -e "${BLUE}Auto-refresh:${NC} Every 30 seconds" +} + +# Run main +main "$@" \ No newline at end of file diff --git a/Cloudron/test-builds.sh b/Cloudron/test-builds.sh new file mode 100755 index 0000000..be1d460 --- /dev/null +++ b/Cloudron/test-builds.sh @@ -0,0 +1,301 @@ +#!/bin/bash + +# Build Verification Script for Cloudron Packages +# Tests Docker builds for a sample of applications to verify Dockerfiles work correctly + +set -e + +WORKSPACE="/home/localuser/TSYSDevStack/Cloudron/CloudronPackages-Workspace" + +# Sample applications to test (one from each language) +TEST_APPS=( + "goalert:go" # Go - we have source code + "webhook:go" # Go - simple webhook server + "runme:node" # Node.js - markdown runner + "netbox:python" # Python - IPAM system + "rundeck:java" # Java - job scheduler + "hyperswitch:rust" # Rust - payment processor + "corteza:php" # PHP - low-code platform + "huginn:ruby" # Ruby - web agents +) + +# Function to test build +test_build() { + local app_name="$1" + local app_type="$2" + local app_dir="$WORKSPACE/$app_name" + + echo "🐳 Testing build for $app_name ($app_type)..." + + if [ ! -d "$app_dir/app" ]; then + echo "❌ No app directory for $app_name" + return 1 + fi + + # Create a minimal source structure for testing (since we don't have actual source) + create_test_source "$app_name" "$app_type" "$app_dir" + + cd "$app_dir/app" + + # Try to build the Docker image + local image_name="test-$app_name:latest" + + echo " 🔨 Building Docker image..." + if timeout 300 docker build -t "$image_name" . 2>/dev/null; then + echo " ✅ Build successful for $app_name" + + # Test if image runs (briefly) + echo " 🏃 Testing container startup..." + if timeout 10 docker run --rm "$image_name" 2>/dev/null || true; then + echo " ✅ Container starts successfully" + else + echo " ⚠️ Container has startup issues (expected without real source)" + fi + + # Clean up + docker rmi "$image_name" 2>/dev/null || true + + return 0 + else + echo " ❌ Build failed for $app_name" + return 1 + fi +} + +# Function to create minimal test source files +create_test_source() { + local app_name="$1" + local app_type="$2" + local app_dir="$3" + + echo " 📁 Creating test source structure for $app_type..." + + case "$app_type" in + "go") + # Create minimal Go application + mkdir -p "$app_dir/cmd/$app_name" + cat > "$app_dir/go.mod" << EOF +module github.com/test/$app_name + +go 1.21 +EOF + cat > "$app_dir/cmd/$app_name/main.go" << 'EOF' +package main + +import ( + "fmt" + "log" + "net/http" +) + +func main() { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello from %s", "app") + }) + + fmt.Println("Server starting on :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} +EOF + ;; + + "node") + # Create minimal Node.js application + cat > "$app_dir/package.json" << EOF +{ + "name": "$app_name", + "version": "1.0.0", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.18.0" + } +} +EOF + cat > "$app_dir/server.js" << 'EOF' +const express = require('express'); +const app = express(); +const port = 3000; + +app.get('/', (req, res) => { + res.send('Hello from Node.js app!'); +}); + +app.listen(port, () => { + console.log(`Server running at http://localhost:${port}`); +}); +EOF + ;; + + "python") + # Create minimal Python application + cat > "$app_dir/requirements.txt" << EOF +flask==2.3.0 +EOF + cat > "$app_dir/app.py" << 'EOF' +from flask import Flask +app = Flask(__name__) + +@app.route('/') +def hello(): + return 'Hello from Python Flask app!' + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8000) +EOF + ;; + + "java") + # Create minimal Java application + mkdir -p "$app_dir/src/main/java/com/test" + cat > "$app_dir/pom.xml" << EOF + + + 4.0.0 + com.test + $app_name + 1.0.0 + + 17 + 17 + + + + org.springframework.boot + spring-boot-starter-web + 3.1.0 + + + +EOF + cat > "$app_dir/src/main/java/com/test/App.java" << EOF +package com.test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@SpringBootApplication +@RestController +public class App { + public static void main(String[] args) { + SpringApplication.run(App.class, args); + } + + @GetMapping("/") + public String home() { + return "Hello from Java Spring Boot app!"; + } +} +EOF + ;; + + "rust") + # Create minimal Rust application + cat > "$app_dir/Cargo.toml" << EOF +[package] +name = "$app_name" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { version = "1.0", features = ["full"] } +EOF + mkdir -p "$app_dir/src" + cat > "$app_dir/src/main.rs" << 'EOF' +use tokio::net::TcpListener; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[tokio::main] +async fn main() { + let listener = TcpListener::bind("0.0.0.0:8080").await.unwrap(); + println!("Server listening on port 8080"); + + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + tokio::spawn(async move { + let mut buf = [0; 1024]; + loop { + let n = socket.read(&mut buf).await.unwrap(); + if n == 0 { break; } + + let response = "HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, Rust!"; + socket.write_all(response.as_bytes()).await.unwrap(); + break; + } + }); + } +} +EOF + ;; + + "php") + # Create minimal PHP application + cat > "$app_dir/composer.json" << EOF +{ + "name": "$app_name/app", + "require": { + "php": "^8.0" + } +} +EOF + cat > "$app_dir/index.php" << 'EOF' + +EOF + ;; + + "ruby") + # Create minimal Ruby application + cat > "$app_dir/Gemfile" << EOF +source 'https://rubygems.org' +gem 'sinatra' +EOF + cat > "$app_dir/app.rb" << 'EOF' +require 'sinatra' + +get '/' do + 'Hello from Ruby Sinatra app!' +end +EOF + ;; + esac +} + +# Main execution +echo "🚀 Starting Docker build verification..." +echo "📁 Workspace: $WORKSPACE" +echo "" + +# Check if Docker is available +if ! command -v docker &> /dev/null; then + echo "❌ Docker is not available. Skipping build tests." + exit 1 +fi + +success_count=0 +total_count=${#TEST_APPS[@]} + +for app_info in "${TEST_APPS[@]}"; do + IFS=':' read -r app_name app_type <<< "$app_info" + + if test_build "$app_name" "$app_type"; then + ((success_count++)) + fi + + echo "" +done + +echo "🎉 Build verification complete!" +echo "📊 Results: $success_count/$total_count builds successful" +echo "" + +if [ "$success_count" -eq "$total_count" ]; then + echo "✅ All Dockerfile templates are working correctly!" +else + echo "⚠️ Some builds failed - Dockerfiles may need adjustments" +fi \ No newline at end of file diff --git a/Cloudron/unique_urls.txt b/Cloudron/unique_urls.txt new file mode 100644 index 0000000..2904021 --- /dev/null +++ b/Cloudron/unique_urls.txt @@ -0,0 +1,59 @@ +https://github.com/adnanh/webhook +https://github.com/apache/apisix +https://github.com/apache/seatunnel +https://github.com/BOINC/boinc +https://github.com/chirpstack/chirpstack +https://github.com/consuldemocracy/consuldemocracy +https://github.com/cortezaproject/corteza +https://github.com/datahub-project/datahub +https://github.com/elabftw/elabftw +https://github.com/f4exb/sdrangel +https://github.com/fleetdm/fleet +https://github.com/fonoster/fonoster +https://github.com/GemGeorge/SniperPhish +https://github.com/getsentry/sentry +https://github.com/gophish/gophish +https://github.com/gristlabs/grist-core +https://github.com/healthchecks/healthchecks +https://github.com/HeyPuter/puter +https://github.com/huginn/huginn +https://github.com/inventree/InvenTree +https://github.com/jamovi/jamovi +https://github.com/jgraph/docker-drawio +https://github.com/jhpyle/docassemble +https://github.com/juspay/hyperswitch +https://github.com/kazhuravlev/database-gateway +https://github.com/killbill/killbill +https://github.com/langfuse/langfuse +https://github.com/mendersoftware +https://github.com/mendersoftware/mender +https://github.com/metrue/fx +https://github.com/midday-ai/midday +https://github.com/nautechsystems/nautilus_trader +https://github.com/netbox-community/netbox +https://github.com/oat-sa +https://github.com/openblocks-dev/openblocks +https://github.com/openboxes/openboxes +https://github.com/opulo-inc/autobom +https://github.com/Payroll-Engine/PayrollEngine +https://github.com/pimcore/pimcore +https://github.com/PLMore/PLMore +https://github.com/rapiz1/rathole +https://github.com/Resgrid/Core +https://github.com/reviewboard/reviewboard +https://github.com/rundeck/rundeck +https://github.com/runmedev/runme +https://github.com/SchedMD/slurm +https://github.com/sebo-b/warp +https://github.com/security-companion/security-awareness-training +https://github.com/SigNoz/signoz +https://github.com/stephengpope/no-code-architects-toolkit +https://github.com/strongdm/comply +https://github.com/target/goalert +https://github.com/tirrenotechnologies/tirreno +https://github.com/todogroup/policies +https://github.com/windmill-labs/windmill +https://github.com/wiredlush/easy-gate +https://github.com/wireviz/WireViz +https://github.com/wireviz/wireviz-web +https://gitlab.com/librespacefoundation/satnogs diff --git a/Cloudron/update-manifests.sh b/Cloudron/update-manifests.sh new file mode 100755 index 0000000..a34e8d7 --- /dev/null +++ b/Cloudron/update-manifests.sh @@ -0,0 +1,235 @@ +#!/bin/bash + +# Manifest Update Script for Cloudron Packages +# Updates manifests with correct ports, health checks, and metadata for each application type + +set -e + +WORKSPACE="/home/localuser/TSYSDevStack/Cloudron/CloudronPackages-Workspace" + +# Application port and health check mapping +declare -A APP_PORTS=( + # Go Applications (typically 8080) + ["goalert"]="8080" + ["webhook"]="9000" + ["tirreno"]="8080" + ["fx"]="8080" + ["database-gateway"]="8080" + ["chirpstack"]="8080" + ["fleet"]="8080" + ["comply"]="8080" + ["gophish"]="8080" + ["SniperPhish"]="8080" + ["mender"]="8080" + ["autobom"]="8080" + ["easy-gate"]="8080" + + # Node.js Applications (typically 3000) + ["runme"]="3000" + ["datahub"]="9002" + ["openblocks"]="3000" + ["windmill"]="3000" + ["midday"]="3000" + ["no-code-architects-toolkit"]="3000" + ["grist-core"]="8484" + ["signoz"]="3301" + ["sentry"]="9000" + ["docker-drawio"]="8080" + ["puter"]="8080" + ["puter"]="8080" + ["policies"]="3000" + ["fonoster"]="3000" + + # Python Applications (typically 8000) + ["docassemble"]="80" + ["netbox"]="8000" + ["healthchecks"]="8000" + ["langfuse"]="3000" + ["security-awareness-training"]="8000" + ["InvenTree"]="8000" + ["nautilus_trader"]="8000" + ["satnogs"]="80" + ["reviewboard"]="8080" + + # Java Applications (typically 8080) + ["rundeck"]="4440" + ["seatunnel"]="8080" + ["killbill"]="8080" + ["openboxes"]="8080" + + # PHP Applications (typically 8080) + ["corteza"]="8080" + ["elabftw"]="8080" + ["pimcore"]="8000" + + # Rust Applications (typically 8080) + ["hyperswitch"]="8080" + ["rathole"]="8080" + ["warp"]="8080" + + # Ruby Applications (typically 3000) + ["huginn"]="3000" + ["consuldemocracy"]="3000" + + # C# Applications (typically 5000) + ["PayrollEngine"]="5000" + ["Core"]="5000" + + # C/C++ Applications + ["boinc"]="80" + ["slurm"]="6817" + ["sdrangel"]="80" + + # Special Cases + ["apisix"]="9080" + ["jamovi"]="80" + ["wireviz-web"]="80" + ["WireViz"]="80" + ["PLMore"]="8080" +) + +# Application health check paths +declare -A HEALTH_PATHS=( + ["goalert"]="/v1/config" + ["webhook"]="/" + ["runme"]="/health" + ["datahub"]="/health" + ["netbox"]="/api/status/" + ["healthchecks"]="/health/" + ["fleet"]="/api/v1/fleet" + ["signoz"]="/health" + ["sentry"]="/_health/" + ["rundeck"]="/api/1/system/info" + ["killbill"]="/1.0/healthcheck" + ["corteza"]="/" + ["huginn"]="/login" + ["apisix"]="/apisix/admin/services/" + ["grist-core"]="/status" + ["windmill"]="/api/status" + ["openblocks"]="/api/v1/users/me" + ["langfuse"]="/api/health" + ["InvenTree"]="/api/status/" + ["elabftw"]="/" + ["pimcore"]="/" +) + +# Application descriptions +declare -A APP_DESCRIPTIONS=( + ["goalert"]="Alerting and on-call management platform for DevOps teams" + ["webhook"]="Lightweight incoming webhook server" + ["tirreno"]="IP geolocation and intelligence data platform" + ["runme"]="Execute markdown files as interactive notebooks" + ["datahub"]="Modern data catalog and metadata platform" + ["docassemble"]="Open-source expert system for guided interviews" + ["pimcore"]="Digital experience platform (PIM/CMS/DAM)" + ["database-gateway"]="Database connection gateway and proxy" + ["fx"]="Function as a Service platform" + ["fonoster"]="Open-source CPaaS (Communication Platform as a Service)" + ["rundeck"]="Job scheduling and automation platform" + ["hyperswitch"]="Open-source payment processing platform" + ["PayrollEngine"]="Payroll calculation and management system" + ["openboxes"]="Supply chain and inventory management" + ["nautilus_trader"]="Algorithmic trading platform" + ["apisix"]="Cloud-native API gateway" + ["grist-core"]="Smart documents and spreadsheets platform" + ["healthchecks"]="Cron job monitoring service" + ["fleet"]="Device management and monitoring platform" + ["netbox"]="IP address management (IPAM) and data center infrastructure management" + ["seatunnel"]="Data integration and streaming platform" + ["rathole"]="Lightweight and high-performance reverse proxy" + ["easy-gate"]="Simple gateway and proxy service" + ["huginn"]="Web agent system for creating agents that do things online" + ["consuldemocracy"]="Democratic decision-making platform" + ["boinc"]="Open-source volunteer computing platform" + ["slurm"]="Workload manager and job scheduling system" + ["chirpstack"]="LoRaWAN network server" + ["sdrangel"]="Software defined radio application" + ["security-awareness-training"]="Security training and awareness platform" + ["signoz"]="Observability platform with logs, traces, and metrics" + ["sentry"]="Error tracking and performance monitoring" + ["elabftw"]="Electronic lab notebook and research data management" + ["jamovi"]="Statistical analysis software" + ["reviewboard"]="Code review tool" + ["InvenTree"]="Inventory management system" + ["mender"]="Over-the-air (OTA) software update manager" + ["langfuse"]="LLM engineering platform with observability" + ["wireviz-web"]="Cable and wiring harness visualization" + ["WireViz"]="Cable and wiring harness documentation tool" + ["killbill"]="Subscription and billing management platform" + ["autobom"]="Bill of materials (BOM) management and analysis" + ["midday"]="Modern operating system for startups" + ["openblocks"]="Low-code data application development platform" + ["docker-drawio"]="Diagrams.net (draw.io) in Docker" + ["satnogs"]="Ground station network for satellite operations" + ["Core"]="Emergency services management platform" + ["warp"]="Terminal and shell productivity tool" + ["windmill"]="Self-hosted workflow automation platform" + ["corteza"]="Low-code platform for workflow automation" + ["gophish"]="Phishing simulation and awareness training" + ["SniperPhish"]="Advanced phishing simulation platform" + ["PLMore"]="Unknown application" + ["policies"]="Policy management and compliance platform" + ["puter"]="Open-source internet OS" + ["comply"]="Compliance management platform" +) + +# Function to update manifest +update_manifest() { + local app_name="$1" + local app_dir="$WORKSPACE/$app_name" + local manifest_file="$app_dir/app/manifest.json" + + if [ ! -f "$manifest_file" ]; then + echo "❌ No manifest found for $app_name" + return + fi + + local port="${APP_PORTS[$app_name]:-8080}" + local health_path="${HEALTH_PATHS[$app_name]:-/}" + local description="${APP_DESCRIPTIONS[$app_name]:-Auto-generated Cloudron package for $app_name}" + + echo "🔧 Updating manifest for $app_name (port: $port, health: $health_path)" + + cat > "$manifest_file" << EOF +{ + "id": "com.$app_name.cloudron", + "title": "$app_name", + "version": "1.0.0", + "description": "$description", + "developer": { + "name": "TSYSDevStack Team", + "email": "support@tsysdevstack.com" + }, + "tags": ["productivity", "web-app", "$(echo "${APP_TYPES[$app_name]:-unknown}" | tr '[:lower:]' '[:upper:]')"], + "httpPort": $port, + "manifestVersion": 2, + "healthCheck": { + "path": "$health_path", + "port": $port + }, + "memoryLimit": 1073741824, + "addons": { + "localstorage": true, + "postgresql": true, + "redis": true, + "sendmail": true + } +} +EOF + + echo "✅ Manifest updated for $app_name" +} + +# Main execution +echo "🚀 Updating manifests with correct ports and health checks..." +echo "" + +# Process all applications (hardcoded list) +for app_name in goalert webhook tirreno fx rathole nautilus_trader database-gateway runme datahub openblocks windmill midday no-code-architects-toolkit docassemble netbox healthchecks gophish SniperPhish langfuse security-awareness-training rundeck seatunnel killbill elabftw pimcore corteza autobom openboxes hyperswitch boinc slurm chirpstack sdrangel grist-core fleet signoz sentry apisix jamovi reviewboard InvenTree mender wireviz-web WireViz PayrollEngine docker-drawio satnogs Core warp puter comply policies easy-gate huginn consuldemocracy fonoster PLMore; do + update_manifest "$app_name" +done + +echo "" +echo "🎉 Manifest updates complete!" +echo "📊 Updated $(echo "${!APP_TYPES[@]}" | wc -w) manifests" +echo "" \ No newline at end of file