From 0f85e40e4e24fe79fb174d1eb690482e1615ae1c Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sat, 13 Jun 2026 19:30:48 +0200 Subject: [PATCH 1/2] feat!: add cli serving the server now requires the serve action --- app.go | 68 +++++++++++++++++++++++++++++++++++++++---- app_test.go | 31 ++++++++++++++++++++ docker/Dockerfile | 1 + ui/src/tests/setup.ts | 2 +- 4 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 app_test.go diff --git a/app.go b/app.go index cad549627..9c4bbb3d3 100644 --- a/app.go +++ b/app.go @@ -1,7 +1,12 @@ package main import ( + "errors" + "flag" + "fmt" + "io" "os" + "runtime/debug" "time" "github.com/gotify/server/v2/config" @@ -27,7 +32,45 @@ var ( ) func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { vInfo := &model.VersionInfo{Version: Version, Commit: Commit, BuildDate: BuildDate} + fs := flag.NewFlagSet("gotify", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { printUsage(stderr) } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + + command := fs.Arg(0) + switch command { + case "serve", "": + return serve(vInfo) + case "version": + fmt.Fprintln(stdout, "Version:", vInfo.Version) + fmt.Fprintln(stdout, "Commit:", vInfo.Commit) + fmt.Fprintln(stdout, "Build Date:", vInfo.BuildDate) + fmt.Fprintln(stdout, "Go Build Info:") + b, ok := debug.ReadBuildInfo() + if ok { + fmt.Fprintln(stdout, b) + } + return 0 + default: + if command != "" { + fmt.Fprintf(stderr, "gotify: unknown command %q\n\n", command) + } + printUsage(stderr) + return 2 + } +} + +func serve(vInfo *model.VersionInfo) int { mode.Set(Mode) conf, futureLogs := config.Get() @@ -40,21 +83,24 @@ func main() { exit = exit || futureLog.Level == zerolog.FatalLevel || futureLog.Level == zerolog.PanicLevel } if exit { - os.Exit(1) + return 1 } if conf.PluginsDir != "" { if err := os.MkdirAll(conf.PluginsDir, 0o755); err != nil { - panic(err) + log.Error().Err(err).Str("dir", conf.PluginsDir).Msg("Cannot create plugins directory") + return 1 } } if err := os.MkdirAll(conf.UploadedImagesDir, 0o755); err != nil { - panic(err) + log.Error().Err(err).Str("dir", conf.UploadedImagesDir).Msg("Cannot create uploaded images directory") + return 1 } db, err := database.New(conf.Database.Dialect, conf.Database.Connection, conf.DefaultUser.Name, conf.DefaultUser.Pass, conf.PassStrength, true, time.Now) if err != nil { - panic(err) + log.Error().Err(err).Msg("Cannot initialize database") + return 1 } defer db.Close() @@ -63,8 +109,20 @@ func main() { if err := runner.Run(engine, conf); err != nil { log.Error().Err(err).Msg("Server error") - os.Exit(1) + return 1 } + return 0 +} + +func printUsage(w io.Writer) { + fmt.Fprint(w, `Usage: gotify [flags] [arguments] + +Commands: + serve Start the Gotify server. + migrate-config Convert an old YAML config file to the new env + format and print it to stdout. + version Show version information +`) } func noColor(noColorEnv string) bool { diff --git a/app_test.go b/app_test.go new file mode 100644 index 000000000..ef1f03009 --- /dev/null +++ b/app_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRun(t *testing.T) { + cases := []struct { + name string + args []string + wantCode int + stdout string // substring expected on stdout + stderr string // substring expected on stderr + }{ + {"version", []string{"version"}, 0, "Version: ", ""}, + {"unknown command", []string{"bogus"}, 2, "", "unknown command"}, + {"unknown flag", []string{"--nope"}, 2, "", "not defined"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run(c.args, &stdout, &stderr) + assert.Equal(t, c.wantCode, code) + assert.Contains(t, stdout.String(), c.stdout) + assert.Contains(t, stderr.String(), c.stderr) + }) + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile index 4c1dee11e..bbed0fb58 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -75,3 +75,4 @@ EXPOSE $GOTIFY_SERVER_EXPOSE COPY --from=builder /target / ENTRYPOINT ["./gotify-app"] +CMD ["serve"] diff --git a/ui/src/tests/setup.ts b/ui/src/tests/setup.ts index 84b757afd..171ee03ed 100644 --- a/ui/src/tests/setup.ts +++ b/ui/src/tests/setup.ts @@ -123,7 +123,7 @@ const buildGoExecutable = (filename: string): Promise => { }; const startGotify = (filename: string, port: number, pluginDir: string): ChildProcess => { - const gotify = spawn(filename, [], { + const gotify = spawn(filename, ['serve'], { env: { GOTIFY_SERVER_PORT: '' + port, GOTIFY_DATABASE_CONNECTION: 'file::memory:?mode=memory&cache=shared', From 37f4160ad875d92fdc7090e2aac6e895a2fa370b Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sat, 13 Jun 2026 19:33:11 +0200 Subject: [PATCH 2/2] feat: add config migration --- app.go | 9 ++ config/migrate/migrate.go | 185 +++++++++++++++++++++++++++++++++ config/migrate/migrate_test.go | 166 +++++++++++++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 config/migrate/migrate.go create mode 100644 config/migrate/migrate_test.go diff --git a/app.go b/app.go index 9c4bbb3d3..3935ffc6d 100644 --- a/app.go +++ b/app.go @@ -10,6 +10,7 @@ import ( "time" "github.com/gotify/server/v2/config" + "github.com/gotify/server/v2/config/migrate" "github.com/gotify/server/v2/database" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" @@ -61,6 +62,14 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stdout, b) } return 0 + case "migrate-config": + content, err := migrate.Config(fs.Arg(1)) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + fmt.Fprintln(stdout, content) + return 0 default: if command != "" { fmt.Fprintf(stderr, "gotify: unknown command %q\n\n", command) diff --git a/config/migrate/migrate.go b/config/migrate/migrate.go new file mode 100644 index 000000000..759d05ea2 --- /dev/null +++ b/config/migrate/migrate.go @@ -0,0 +1,185 @@ +package migrate + +import ( + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "strings" + + "github.com/gotify/server/v2/config" + "github.com/joho/godotenv" + "gopkg.in/yaml.v3" +) + +type oldConfig struct { + Server struct { + KeepAlivePeriodSeconds *int + ListenAddr *string + Port *int + SSL struct { + Enabled *bool + RedirectToHTTPS *bool + ListenAddr *string + Port *int + CertFile *string + CertKey *string + LetsEncrypt struct { + Enabled *bool + AcceptTOS *bool + Cache *string + DirectoryURL *string + Hosts []string + } + } + ResponseHeaders map[string]string + Stream struct { + PingPeriodSeconds *int + AllowedOrigins []string + } + Cors struct { + AllowOrigins []string + AllowMethods []string + AllowHeaders []string + } + TrustedProxies []string + SecureCookie *bool + } + Database struct { + Dialect *string + Connection *string + } + DefaultUser struct { + Name *string + Pass *string + } + PassStrength *int + UploadedImagesDir *string + PluginsDir *string + Registration *bool + OIDC struct { + Enabled *bool + Issuer *string + ClientID *string + ClientSecret *string + UsernameClaim *string + RedirectURL *string + AutoRegister *bool + Scopes []string + } +} + +func Config(file string) (string, error) { + if file == "" { + return "", errors.New("migrate-config requires one argument: the path to the old config.yml") + } + data, err := os.ReadFile(file) + if err != nil { + return "", fmt.Errorf("cannot read config file %s: %w", file, err) + } + + var migrated oldConfig + if err := yaml.Unmarshal(data, &migrated); err != nil { + return "", fmt.Errorf("cannot parse config file %s: %w", file, err) + } + + content, err := godotenv.Marshal(buildEnv(migrated)) + if err != nil { + return "", fmt.Errorf("cannot render config: %w", err) + } + + return content, nil +} + +func buildEnv(c oldConfig) map[string]string { + out := map[string]string{} + str := func(key string, value *string) { + if value != nil { + out[key] = *value + } + } + num := func(key string, value *int) { + if value != nil { + out[key] = strconv.Itoa(*value) + } + } + boolean := func(key string, value *bool) { + if value != nil { + out[key] = strconv.FormatBool(*value) + } + } + list := func(key string, value []string) { + if value != nil { + out[key] = marshalList(value) + } + } + headers := func(key string, value map[string]string) { + if value != nil { + out[key] = marshalMap(value) + } + } + + num(config.EnvServerKeepAlivePeriodSeconds, c.Server.KeepAlivePeriodSeconds) + str(config.EnvServerListenAddr, c.Server.ListenAddr) + num(config.EnvServerPort, c.Server.Port) + boolean(config.EnvServerSSLEnabled, c.Server.SSL.Enabled) + boolean(config.EnvServerSSLRedirectToHTTPS, c.Server.SSL.RedirectToHTTPS) + str(config.EnvServerSSLListenAddr, c.Server.SSL.ListenAddr) + num(config.EnvServerSSLPort, c.Server.SSL.Port) + str(config.EnvServerSSLCertFile, c.Server.SSL.CertFile) + str(config.EnvServerSSLCertKey, c.Server.SSL.CertKey) + boolean(config.EnvServerSSLLetsEncryptEnabled, c.Server.SSL.LetsEncrypt.Enabled) + boolean(config.EnvServerSSLLetsEncryptAcceptTOS, c.Server.SSL.LetsEncrypt.AcceptTOS) + str(config.EnvServerSSLLetsEncryptCache, c.Server.SSL.LetsEncrypt.Cache) + str(config.EnvServerSSLLetsEncryptDirectoryURL, c.Server.SSL.LetsEncrypt.DirectoryURL) + list(config.EnvServerSSLLetsEncryptHosts, c.Server.SSL.LetsEncrypt.Hosts) + headers(config.EnvServerResponseHeaders, c.Server.ResponseHeaders) + num(config.EnvServerStreamPingPeriodSeconds, c.Server.Stream.PingPeriodSeconds) + list(config.EnvServerStreamAllowedOrigins, c.Server.Stream.AllowedOrigins) + list(config.EnvServerCorsAllowOrigins, c.Server.Cors.AllowOrigins) + list(config.EnvServerCorsAllowMethods, c.Server.Cors.AllowMethods) + list(config.EnvServerCorsAllowHeaders, c.Server.Cors.AllowHeaders) + list(config.EnvServerTrustedProxies, c.Server.TrustedProxies) + boolean(config.EnvServerSecureCookie, c.Server.SecureCookie) + str(config.EnvDatabaseDialect, c.Database.Dialect) + str(config.EnvDatabaseConnection, c.Database.Connection) + str(config.EnvDefaultUserName, c.DefaultUser.Name) + str(config.EnvDefaultUserPass, c.DefaultUser.Pass) + num(config.EnvPassStrength, c.PassStrength) + str(config.EnvUploadedImagesDir, c.UploadedImagesDir) + str(config.EnvPluginsDir, c.PluginsDir) + boolean(config.EnvRegistration, c.Registration) + boolean(config.EnvOIDCEnabled, c.OIDC.Enabled) + str(config.EnvOIDCIssuer, c.OIDC.Issuer) + str(config.EnvOIDCClientID, c.OIDC.ClientID) + str(config.EnvOIDCClientSecret, c.OIDC.ClientSecret) + str(config.EnvOIDCUsernameClaim, c.OIDC.UsernameClaim) + str(config.EnvOIDCRedirectURL, c.OIDC.RedirectURL) + boolean(config.EnvOIDCAutoRegister, c.OIDC.AutoRegister) + list(config.EnvOIDCScopes, c.OIDC.Scopes) + return out +} + +func marshalMap(m map[string]string) string { + if len(m) == 0 { + return "" + } + data, err := json.Marshal(m) + if err != nil { + return "" + } + return string(data) +} + +func marshalList(values []string) string { + var sb strings.Builder + writer := csv.NewWriter(&sb) + writer.UseCRLF = false + if err := writer.Write(values); err != nil { + return "" + } + writer.Flush() + return strings.TrimRight(sb.String(), "\n") +} diff --git a/config/migrate/migrate_test.go b/config/migrate/migrate_test.go new file mode 100644 index 000000000..d4a5b4cb9 --- /dev/null +++ b/config/migrate/migrate_test.go @@ -0,0 +1,166 @@ +package migrate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func runMigrate(t *testing.T, yaml string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yml") + assert.NoError(t, os.WriteFile(path, []byte(yaml), 0o600)) + got, err := Config(path) + assert.NoError(t, err) + return got +} + +func TestMigrateConfigAllOptions(t *testing.T) { + yaml := ` +server: + keepaliveperiodseconds: 30 + listenaddr: 0.0.0.0 + port: 8080 + ssl: + enabled: true + redirecttohttps: false + listenaddr: 127.0.0.1 + port: 8443 + certfile: /cert.pem + certkey: /key.pem + letsencrypt: + enabled: true + accepttos: true + cache: /le + directoryurl: https://acme.example + hosts: + - a.tld + - b.tld + responseheaders: + X-Custom: hello + stream: + pingperiodseconds: 30 + allowedorigins: + - o1 + - o2 + cors: + alloworigins: + - c1 + allowmethods: + - GET + allowheaders: + - Authorization + trustedproxies: + - 10.0.0.1 + securecookie: true +database: + dialect: postgres + connection: postgres://localhost/gotify +defaultuser: + name: root + pass: secret +passstrength: 12 +uploadedimagesdir: /images +pluginsdir: /plugins +registration: true +oidc: + enabled: true + issuer: https://issuer.example + clientid: client + clientsecret: topsecret + usernameclaim: email + redirecturl: https://gotify.example/callback + autoregister: false + scopes: + - openid + - custom +` + assert.Equal(t, `GOTIFY_DATABASE_CONNECTION="postgres://localhost/gotify" +GOTIFY_DATABASE_DIALECT="postgres" +GOTIFY_DEFAULTUSER_NAME="root" +GOTIFY_DEFAULTUSER_PASS="secret" +GOTIFY_OIDC_AUTOREGISTER="false" +GOTIFY_OIDC_CLIENTID="client" +GOTIFY_OIDC_CLIENTSECRET="topsecret" +GOTIFY_OIDC_ENABLED="true" +GOTIFY_OIDC_ISSUER="https://issuer.example" +GOTIFY_OIDC_REDIRECTURL="https://gotify.example/callback" +GOTIFY_OIDC_SCOPES="openid,custom" +GOTIFY_OIDC_USERNAMECLAIM="email" +GOTIFY_PASSSTRENGTH=12 +GOTIFY_PLUGINSDIR="/plugins" +GOTIFY_REGISTRATION="true" +GOTIFY_SERVER_CORS_ALLOWHEADERS="Authorization" +GOTIFY_SERVER_CORS_ALLOWMETHODS="GET" +GOTIFY_SERVER_CORS_ALLOWORIGINS="c1" +GOTIFY_SERVER_KEEPALIVEPERIODSECONDS=30 +GOTIFY_SERVER_LISTENADDR="0.0.0.0" +GOTIFY_SERVER_PORT=8080 +GOTIFY_SERVER_RESPONSEHEADERS="{\"X-Custom\":\"hello\"}" +GOTIFY_SERVER_SECURECOOKIE="true" +GOTIFY_SERVER_SSL_CERTFILE="/cert.pem" +GOTIFY_SERVER_SSL_CERTKEY="/key.pem" +GOTIFY_SERVER_SSL_ENABLED="true" +GOTIFY_SERVER_SSL_LETSENCRYPT_ACCEPTTOS="true" +GOTIFY_SERVER_SSL_LETSENCRYPT_CACHE="/le" +GOTIFY_SERVER_SSL_LETSENCRYPT_DIRECTORYURL="https://acme.example" +GOTIFY_SERVER_SSL_LETSENCRYPT_ENABLED="true" +GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS="a.tld,b.tld" +GOTIFY_SERVER_SSL_LISTENADDR="127.0.0.1" +GOTIFY_SERVER_SSL_PORT=8443 +GOTIFY_SERVER_SSL_REDIRECTTOHTTPS="false" +GOTIFY_SERVER_STREAM_ALLOWEDORIGINS="o1,o2" +GOTIFY_SERVER_STREAM_PINGPERIODSECONDS=30 +GOTIFY_SERVER_TRUSTEDPROXIES="10.0.0.1" +GOTIFY_UPLOADEDIMAGESDIR="/images"`, runMigrate(t, yaml)) +} + +func TestMigrateConfigNoOptions(t *testing.T) { + assert.Equal(t, "", runMigrate(t, ""), "an empty config produces no settings") +} + +func TestMigrateConfigOnlyDefaultValues(t *testing.T) { + yaml := `server: + port: 80 + ssl: + redirecttohttps: true +database: + dialect: sqlite3 +oidc: + autoregister: true + scopes: + - openid + - profile + - email +` + assert.Equal(t, `GOTIFY_DATABASE_DIALECT="sqlite3" +GOTIFY_OIDC_AUTOREGISTER="true" +GOTIFY_OIDC_SCOPES="openid,profile,email" +GOTIFY_SERVER_PORT=80 +GOTIFY_SERVER_SSL_REDIRECTTOHTTPS="true"`, runMigrate(t, yaml)) +} + +func TestMigrateConfigEscapesListEntries(t *testing.T) { + yaml := `server: + cors: + alloworigins: + - a,b + - 'say "hi"' + - c +` + // The CSV-encoded list contains commas and quotes, which godotenv then + // double-quotes and escapes. + assert.Contains(t, runMigrate(t, yaml), + `GOTIFY_SERVER_CORS_ALLOWORIGINS="\"a,b\",\"say \"\"hi\"\"\",c"`) +} + +func TestMigrateConfigErrors(t *testing.T) { + _, err := Config("") + assert.ErrorContains(t, err, "requires one argument", "no path -> usage error") + + missing := filepath.Join(t.TempDir(), "missing.yml") + _, err = Config(missing) + assert.ErrorContains(t, err, "cannot read config file", "unreadable file -> error") +}