Merge pull request #967 from gotify/env

Rework config parsing
This commit is contained in:
Jannis Mattheis
2026-06-14 12:57:54 +02:00
committed by GitHub
25 changed files with 1290 additions and 316 deletions

3
.gitignore vendored
View File

@@ -9,4 +9,5 @@ coverage.txt
**/*-packr.go
config.yml
data/
images/
images/
/gotify-server.env

View File

@@ -23,11 +23,6 @@ import (
)
func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNotifier *UserChangeNotifier) *OIDCAPI {
scopes := conf.OIDC.Scopes
if len(scopes) == 0 {
scopes = []string{"openid", "profile", "email"}
}
cookieKey := make([]byte, 32)
if _, err := rand.Read(cookieKey); err != nil {
log.Fatal().Err(err).Msg("failed to generate OIDC cookie key")
@@ -46,7 +41,7 @@ func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNo
conf.OIDC.ClientID,
conf.OIDC.ClientSecret,
conf.OIDC.RedirectURL,
scopes,
conf.OIDC.Scopes,
opts...,
)
if err != nil {

View File

@@ -11,7 +11,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/gotify/server/v2/auth"
"github.com/gotify/server/v2/mode"
"github.com/gotify/server/v2/model"
)
@@ -214,9 +213,6 @@ func newUpgrader(allowedWebSocketOrigins []string) *websocket.Upgrader {
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
if mode.IsDev() {
return true
}
return isAllowedOrigin(r, compiledAllowedOrigins)
},
}

91
app.go
View File

@@ -1,10 +1,16 @@
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"runtime/debug"
"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"
@@ -27,26 +33,83 @@ var (
)
func main() {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339, NoColor: noColor()})
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
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)
}
printUsage(stderr)
return 2
}
}
func serve(vInfo *model.VersionInfo) int {
mode.Set(Mode)
conf, futureLogs := config.Get()
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339, NoColor: noColor(conf.NoColor)}).Level(zerolog.Level(conf.LogLevel))
log.Info().Str("version", vInfo.Version).Str("build_date", BuildDate).Msg("Gotify")
conf := config.Get()
exit := false
for _, futureLog := range futureLogs {
log.WithLevel(futureLog.Level).Msg(futureLog.Msg)
exit = exit || futureLog.Level == zerolog.FatalLevel || futureLog.Level == zerolog.PanicLevel
}
if exit {
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()
@@ -55,13 +118,25 @@ 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 noColor() bool {
func printUsage(w io.Writer) {
fmt.Fprint(w, `Usage: gotify [flags] <command> [arguments]
Commands:
serve Start the Gotify server.
migrate-config <file.yml> 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 {
// https://no-color.org/
if os.Getenv("NO_COLOR") == "1" {
if noColorEnv == "1" {
return true
}

31
app_test.go Normal file
View File

@@ -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)
})
}
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/gin-contrib/cors"
"github.com/gotify/server/v2/config"
"github.com/gotify/server/v2/mode"
)
// CorsConfig generates a config to use in gin cors middleware based on server configuration.
@@ -16,28 +15,19 @@ func CorsConfig(conf *config.Configuration) cors.Config {
MaxAge: 12 * time.Hour,
AllowBrowserExtensions: true,
}
if mode.IsDev() {
corsConf.AllowAllOrigins = true
corsConf.AllowMethods = []string{"GET", "POST", "DELETE", "OPTIONS", "PUT"}
corsConf.AllowHeaders = []string{
"X-Gotify-Key", "Authorization", "Content-Type", "Upgrade", "Origin",
"Connection", "Accept-Encoding", "Accept-Language", "Host",
}
} else {
compiledOrigins := compileAllowedCORSOrigins(conf.Server.Cors.AllowOrigins)
corsConf.AllowMethods = conf.Server.Cors.AllowMethods
corsConf.AllowHeaders = conf.Server.Cors.AllowHeaders
corsConf.AllowOriginFunc = func(origin string) bool {
for _, compiledOrigin := range compiledOrigins {
if compiledOrigin.MatchString(strings.ToLower(origin)) {
return true
}
compiledOrigins := compileAllowedCORSOrigins(conf.Server.Cors.AllowOrigins)
corsConf.AllowMethods = conf.Server.Cors.AllowMethods
corsConf.AllowHeaders = conf.Server.Cors.AllowHeaders
corsConf.AllowOriginFunc = func(origin string) bool {
for _, compiledOrigin := range compiledOrigins {
if compiledOrigin.MatchString(strings.ToLower(origin)) {
return true
}
return false
}
if allowedOrigin := headerIgnoreCase(conf, "access-control-allow-origin"); allowedOrigin != "" && len(compiledOrigins) == 0 {
corsConf.AllowOrigins = append(corsConf.AllowOrigins, allowedOrigin)
}
return false
}
if allowedOrigin := headerIgnoreCase(conf, "access-control-allow-origin"); allowedOrigin != "" && len(compiledOrigins) == 0 {
corsConf.AllowOrigins = append(corsConf.AllowOrigins, allowedOrigin)
}
return corsConf

View File

@@ -50,24 +50,3 @@ func TestEmptyCorsConfigWithResponseHeaders(t *testing.T) {
AllowBrowserExtensions: true,
}, actual)
}
func TestDevCorsConfig(t *testing.T) {
mode.Set(mode.Dev)
serverConf := config.Configuration{}
serverConf.Server.Cors.AllowOrigins = []string{"http://test.com"}
serverConf.Server.Cors.AllowHeaders = []string{"content-type"}
serverConf.Server.Cors.AllowMethods = []string{"GET"}
actual := CorsConfig(&serverConf)
assert.Equal(t, cors.Config{
AllowHeaders: []string{
"X-Gotify-Key", "Authorization", "Content-Type", "Upgrade", "Origin",
"Connection", "Accept-Encoding", "Accept-Language", "Host",
},
AllowMethods: []string{"GET", "POST", "DELETE", "OPTIONS", "PUT"},
MaxAge: 12 * time.Hour,
AllowAllOrigins: true,
AllowBrowserExtensions: true,
}, actual)
}

View File

@@ -1,68 +0,0 @@
# Example configuration file for the server.
# Save it to `config.yml` when edited
server:
keepaliveperiodseconds: 0 # 0 = use Go default (15s); -1 = disable keepalive; set the interval in which keepalive packets will be sent. Only change this value if you know what you are doing.
listenaddr: '' # the address to bind on, leave empty to bind on all addresses. Prefix with "unix:" to create a unix socket. Example: "unix:/tmp/gotify.sock".
port: 80 # the port the HTTP server will listen on
ssl:
enabled: false # if https should be enabled
redirecttohttps: true # redirect to https if site is accessed by http
listenaddr: '' # the address to bind on, leave empty to bind on all addresses. Prefix with "unix:" to create a unix socket. Example: "unix:/tmp/gotify.sock".
port: 443 # the https port
certfile: # the cert file (leave empty when using letsencrypt)
certkey: # the cert key (leave empty when using letsencrypt)
letsencrypt:
enabled: false # if the certificate should be requested from letsencrypt
accepttos: false # if you accept the tos from letsencrypt
cache: data/certs # the directory of the cache from letsencrypt
directoryurl: # override the directory url of the ACME server
# Let's Encrypt highly recommend testing against their staging environment before using their production environment.
# Staging server has high rate limits for testing and debugging, issued certificates are not valid
# example: https://acme-staging-v02.api.letsencrypt.org/directory
hosts: # the hosts for which letsencrypt should request certificates
# - mydomain.tld
# - myotherdomain.tld
responseheaders: # response headers are added to every response (default: none)
# X-Custom-Header: "custom value"
trustedproxies: # IPs or IP ranges of trusted proxies. Used to obtain the remote ip via the X-Forwarded-For header. (configure 127.0.0.1 to trust sockets)
# - 127.0.0.1/32
# - ::1
securecookie: false # If the secure flag should be set on cookies. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#secure
cors: # Sets cors headers only when needed and provides support for multiple allowed origins. Overrides Access-Control-* Headers in response headers.
alloworigins:
# - '.+.example.com'
# - 'otherdomain.com'
allowmethods:
# - "GET"
# - "POST"
allowheaders:
# - "Authorization"
# - "content-type"
stream:
pingperiodseconds: 45 # the interval in which websocket pings will be sent. Only change this value if you know what you are doing.
allowedorigins: # allowed origins for websocket connections (same origin is always allowed)
# - ".+.example.com"
# - "otherdomain.com"
oidc:
enabled: false # Enable OpenID Connect login, allowing users to authenticate via an external identity provider (e.g. Keycloak, Authelia, Google).
issuer: # The OIDC issuer URL. This is the base URL of your identity provider, used to discover endpoints. Example: "https://auth.example.com/realms/myrealm"
clientid: # The client ID registered with your identity provider for this application.
clientsecret: # The client secret for the registered client.
redirecturl: http://gotify.example.org/auth/oidc/callback # The callback URL that the identity provider redirects to after authentication. Must match exactly what is configured in your identity provider.
autoregister: true # If true, automatically create a new user on first OIDC login. If false, only existing users can log in via OIDC.
usernameclaim: preferred_username # The OIDC claim used to determine the username. Common values: "preferred_username" or "email".
database: # for database see (configure database section)
dialect: sqlite3
connection: data/gotify.db
defaultuser: # on database creation, gotify creates an admin user (these values will only be used for the first start, if you want to edit the user after the first start use the WebUI)
name: admin # the username of the default user
pass: admin # the password of the default user
passstrength: 10 # the bcrypt password strength (higher = better but also slower)
uploadedimagesdir: data/images # the directory for storing uploaded images
pluginsdir: data/plugins # the directory where plugin resides (leave empty to disable plugins)
registration: false # enable registrations

View File

@@ -4,86 +4,183 @@ import (
"path/filepath"
"strings"
"github.com/gotify/server/v2/mode"
"github.com/jinzhu/configor"
"github.com/rs/zerolog"
)
// Configuration is stuff that can be configured externally per env variables or config file (config.yml).
type LetsEncrypt struct {
Enabled bool
AcceptTOS bool
Cache string
DirectoryURL string
Hosts []string
}
type SSL struct {
Enabled bool
RedirectToHTTPS bool
ListenAddr string
Port int
CertFile string
CertKey string
LetsEncrypt LetsEncrypt
}
type Stream struct {
PingPeriodSeconds int
AllowedOrigins []string
}
type Cors struct {
AllowOrigins []string
AllowMethods []string
AllowHeaders []string
}
type Server struct {
KeepAlivePeriodSeconds int
ListenAddr string
Port int
SSL SSL
ResponseHeaders map[string]string
Stream Stream
Cors Cors
TrustedProxies []string
SecureCookie bool
}
type Database struct {
Dialect string
Connection string
}
type DefaultUser struct {
Name string
Pass string
}
type OIDC struct {
Enabled bool
Issuer string
ClientID string
ClientSecret string
UsernameClaim string
RedirectURL string
AutoRegister bool
Scopes []string
}
type Configuration struct {
Server struct {
KeepAlivePeriodSeconds int
ListenAddr string `default:""`
Port int `default:"80"`
SSL struct {
Enabled bool `default:"false"`
RedirectToHTTPS bool `default:"true"`
ListenAddr string `default:""`
Port int `default:"443"`
CertFile string `default:""`
CertKey string `default:""`
LetsEncrypt struct {
Enabled bool `default:"false"`
AcceptTOS bool `default:"false"`
Cache string `default:"data/certs"`
DirectoryURL string `default:""`
Hosts []string
}
}
ResponseHeaders map[string]string
Stream struct {
PingPeriodSeconds int `default:"45"`
AllowedOrigins []string
}
Cors struct {
AllowOrigins []string
AllowMethods []string
AllowHeaders []string
}
TrustedProxies []string
SecureCookie bool `default:"false"`
}
Database struct {
Dialect string `default:"sqlite3"`
Connection string `default:"data/gotify.db"`
}
DefaultUser struct {
Name string `default:"admin"`
Pass string `default:"admin"`
}
PassStrength int `default:"10"`
UploadedImagesDir string `default:"data/images"`
PluginsDir string `default:"data/plugins"`
Registration bool `default:"false"`
OIDC struct {
Enabled bool `default:"false"`
Issuer string `default:""`
ClientID string `default:""`
ClientSecret string `default:""`
UsernameClaim string `default:"preferred_username"`
RedirectURL string `default:""`
AutoRegister bool `default:"true"`
Scopes []string
}
LogLevel LogLevel
Server Server
Database Database
DefaultUser DefaultUser
PassStrength int
UploadedImagesDir string
PluginsDir string
Registration bool
OIDC OIDC
NoColor string
}
func configFiles() []string {
if mode.Get() == mode.TestDev {
return []string{"config.yml"}
// Get returns the configuration extracted from env variables.
func Get() (*Configuration, []FutureLog) {
c := &Configuration{
LogLevel: LogLevel(zerolog.InfoLevel),
Server: Server{
Port: 80,
SSL: SSL{
RedirectToHTTPS: true,
Port: 443,
LetsEncrypt: LetsEncrypt{
Cache: "data/certs",
},
},
Stream: Stream{
PingPeriodSeconds: 45,
},
},
Database: Database{
Dialect: "sqlite3",
Connection: "data/gotify.db",
},
DefaultUser: DefaultUser{
Name: "admin",
Pass: "admin",
},
PassStrength: 10,
UploadedImagesDir: "data/images",
PluginsDir: "data/plugins",
OIDC: OIDC{
UsernameClaim: "preferred_username",
AutoRegister: true,
Scopes: []string{"openid", "profile", "email"},
},
}
return []string{"config.yml", "/etc/gotify/config.yml"}
}
// Get returns the configuration extracted from env variables or config file.
func Get() *Configuration {
conf := new(Configuration)
err := configor.New(&configor.Config{ENVPrefix: "GOTIFY", Silent: true}).Load(conf, configFiles()...)
if err != nil {
panic(err)
logs := loadFiles()
add := func(err error) {
if err != nil {
logs = append(logs, futureFatal(err.Error()))
}
}
addTrailingSlashToPaths(conf)
return conf
add(parseLogLevel(&c.LogLevel, EnvLogLevel))
add(parseInt(&c.Server.KeepAlivePeriodSeconds, EnvServerKeepAlivePeriodSeconds))
add(parseString(&c.Server.ListenAddr, EnvServerListenAddr))
add(parseInt(&c.Server.Port, EnvServerPort))
add(parseBool(&c.Server.SSL.Enabled, EnvServerSSLEnabled))
add(parseBool(&c.Server.SSL.RedirectToHTTPS, EnvServerSSLRedirectToHTTPS))
add(parseString(&c.Server.SSL.ListenAddr, EnvServerSSLListenAddr))
add(parseInt(&c.Server.SSL.Port, EnvServerSSLPort))
add(parseString(&c.Server.SSL.CertFile, EnvServerSSLCertFile))
add(parseString(&c.Server.SSL.CertKey, EnvServerSSLCertKey))
add(parseBool(&c.Server.SSL.LetsEncrypt.Enabled, EnvServerSSLLetsEncryptEnabled))
add(parseBool(&c.Server.SSL.LetsEncrypt.AcceptTOS, EnvServerSSLLetsEncryptAcceptTOS))
add(parseString(&c.Server.SSL.LetsEncrypt.Cache, EnvServerSSLLetsEncryptCache))
add(parseString(&c.Server.SSL.LetsEncrypt.DirectoryURL, EnvServerSSLLetsEncryptDirectoryURL))
add(parseList(&c.Server.SSL.LetsEncrypt.Hosts, EnvServerSSLLetsEncryptHosts))
add(parseMap(&c.Server.ResponseHeaders, EnvServerResponseHeaders))
add(parseInt(&c.Server.Stream.PingPeriodSeconds, EnvServerStreamPingPeriodSeconds))
add(parseList(&c.Server.Stream.AllowedOrigins, EnvServerStreamAllowedOrigins))
add(parseList(&c.Server.Cors.AllowOrigins, EnvServerCorsAllowOrigins))
add(parseList(&c.Server.Cors.AllowMethods, EnvServerCorsAllowMethods))
add(parseList(&c.Server.Cors.AllowHeaders, EnvServerCorsAllowHeaders))
add(parseList(&c.Server.TrustedProxies, EnvServerTrustedProxies))
add(parseBool(&c.Server.SecureCookie, EnvServerSecureCookie))
add(parseString(&c.Database.Dialect, EnvDatabaseDialect))
add(parseString(&c.Database.Connection, EnvDatabaseConnection))
add(parseString(&c.DefaultUser.Name, EnvDefaultUserName))
add(parseString(&c.DefaultUser.Pass, EnvDefaultUserPass))
add(parseInt(&c.PassStrength, EnvPassStrength))
add(parseString(&c.UploadedImagesDir, EnvUploadedImagesDir))
add(parseString(&c.PluginsDir, EnvPluginsDir))
add(parseBool(&c.Registration, EnvRegistration))
add(parseBool(&c.OIDC.Enabled, EnvOIDCEnabled))
add(parseString(&c.OIDC.Issuer, EnvOIDCIssuer))
add(parseString(&c.OIDC.ClientID, EnvOIDCClientID))
add(parseString(&c.OIDC.ClientSecret, EnvOIDCClientSecret))
add(parseString(&c.OIDC.UsernameClaim, EnvOIDCUsernameClaim))
add(parseString(&c.OIDC.RedirectURL, EnvOIDCRedirectURL))
add(parseBool(&c.OIDC.AutoRegister, EnvOIDCAutoRegister))
add(parseList(&c.OIDC.Scopes, EnvOIDCScopes))
add(parseString(&c.NoColor, EnvNoColor))
addTrailingSlashToPaths(c)
return c, logs
}
func addTrailingSlashToPaths(conf *Configuration) {

View File

@@ -12,16 +12,26 @@ import (
func TestConfigEnv(t *testing.T) {
mode.Set(mode.TestDev)
os.Setenv("GOTIFY_DEFAULTUSER_NAME", "jmattheis")
os.Setenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS", "- push.example.tld\n- push.other.tld")
os.Setenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS", "push.example.tld,push.other.tld")
os.Setenv("GOTIFY_SERVER_RESPONSEHEADERS",
"Access-Control-Allow-Origin: \"*\"\nAccess-Control-Allow-Methods: \"GET,POST\"",
`{"Access-Control-Allow-Origin":"*","Access-Control-Allow-Methods":"GET,POST"}`,
)
os.Setenv("GOTIFY_SERVER_CORS_ALLOWORIGINS", "- \".+.example.com\"\n- \"otherdomain.com\"")
os.Setenv("GOTIFY_SERVER_CORS_ALLOWMETHODS", "- \"GET\"\n- \"POST\"")
os.Setenv("GOTIFY_SERVER_CORS_ALLOWHEADERS", "- \"Authorization\"\n- \"content-type\"")
os.Setenv("GOTIFY_SERVER_STREAM_ALLOWEDORIGINS", "- \".+.example.com\"\n- \"otherdomain.com\"")
os.Setenv("GOTIFY_SERVER_CORS_ALLOWORIGINS", ".+.example.com,otherdomain.com")
os.Setenv("GOTIFY_SERVER_CORS_ALLOWMETHODS", "GET,POST")
os.Setenv("GOTIFY_SERVER_CORS_ALLOWHEADERS", "Authorization,content-type")
os.Setenv("GOTIFY_SERVER_STREAM_ALLOWEDORIGINS", ".+.example.com,otherdomain.com")
conf := Get()
defer func() {
os.Unsetenv("GOTIFY_DEFAULTUSER_NAME")
os.Unsetenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS")
os.Unsetenv("GOTIFY_SERVER_RESPONSEHEADERS")
os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWORIGINS")
os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWMETHODS")
os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWHEADERS")
os.Unsetenv("GOTIFY_SERVER_STREAM_ALLOWEDORIGINS")
}()
conf, _ := Get()
assert.Equal(t, 80, conf.Server.Port, "should use defaults")
assert.Equal(t, "jmattheis", conf.DefaultUser.Name, "should not use default but env var")
assert.Equal(t, []string{"push.example.tld", "push.other.tld"}, conf.Server.SSL.LetsEncrypt.Hosts)
@@ -31,20 +41,43 @@ func TestConfigEnv(t *testing.T) {
assert.Equal(t, []string{"GET", "POST"}, conf.Server.Cors.AllowMethods)
assert.Equal(t, []string{"Authorization", "content-type"}, conf.Server.Cors.AllowHeaders)
assert.Equal(t, []string{".+.example.com", "otherdomain.com"}, conf.Server.Stream.AllowedOrigins)
}
os.Unsetenv("GOTIFY_DEFAULTUSER_NAME")
os.Unsetenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS")
os.Unsetenv("GOTIFY_SERVER_RESPONSEHEADERS")
os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWORIGINS")
os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWMETHODS")
os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWHEADERS")
os.Unsetenv("GOTIFY_SERVER_STREAM_ALLOWEDORIGINS")
func TestFile(t *testing.T) {
mode.Set(mode.TestDev)
dir := t.TempDir()
passPath := filepath.Join(dir, "pass")
hostsPath := filepath.Join(dir, "hosts")
assert.Nil(t, os.WriteFile(passPath, []byte("filesecret\n"), 0o600))
assert.Nil(t, os.WriteFile(hostsPath, []byte("a.example.com,b.example.com"), 0o600))
os.Setenv("GOTIFY_DEFAULTUSER_PASS_FILE", passPath)
os.Setenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS_FILE", hostsPath)
defer os.Unsetenv("GOTIFY_DEFAULTUSER_PASS_FILE")
defer os.Unsetenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS_FILE")
conf, _ := Get()
assert.Equal(t, "filesecret", conf.DefaultUser.Pass)
assert.Equal(t, []string{"a.example.com", "b.example.com"}, conf.Server.SSL.LetsEncrypt.Hosts)
}
func TestGotifyConfigFile(t *testing.T) {
mode.Set(mode.TestDev)
dir := t.TempDir()
configPath := filepath.Join(dir, "custom.env")
assert.Nil(t, os.WriteFile(configPath, []byte("GOTIFY_DEFAULTUSER_NAME=fromfile\n"), 0o600))
os.Setenv("GOTIFY_CONFIG_FILE", configPath)
defer os.Unsetenv("GOTIFY_CONFIG_FILE")
conf, _ := Get()
assert.Equal(t, "fromfile", conf.DefaultUser.Name)
}
func TestAddSlash(t *testing.T) {
mode.Set(mode.TestDev)
os.Setenv("GOTIFY_UPLOADEDIMAGESDIR", "../data/images")
conf := Get()
conf, _ := Get()
assert.Equal(t, "../data/images"+string(filepath.Separator), conf.UploadedImagesDir)
os.Unsetenv("GOTIFY_UPLOADEDIMAGESDIR")
}
@@ -52,87 +85,32 @@ func TestAddSlash(t *testing.T) {
func TestNotAddSlash(t *testing.T) {
mode.Set(mode.TestDev)
os.Setenv("GOTIFY_UPLOADEDIMAGESDIR", "../data/")
conf := Get()
conf, _ := Get()
assert.Equal(t, "../data/", conf.UploadedImagesDir)
os.Unsetenv("GOTIFY_UPLOADEDIMAGESDIR")
}
func TestFileWithSyntaxErrors(t *testing.T) {
mode.Set(mode.TestDev)
file, err := os.Create("config.yml")
defer func() {
file.Close()
}()
assert.Nil(t, err)
_, err = file.WriteString(`
sdgsgsdfgsdfg
`)
file.Close()
assert.Nil(t, err)
assert.Panics(t, func() {
Get()
})
func TestParseList(t *testing.T) {
const env = "GOTIFY_TEST_PARSELIST"
assert.Nil(t, os.Remove("config.yml"))
}
func TestConfigFile(t *testing.T) {
mode.Set(mode.TestDev)
file, err := os.Create("config.yml")
defer func() {
file.Close()
}()
assert.Nil(t, err)
_, err = file.WriteString(`
server:
port: 1234
ssl:
port: 3333
letsencrypt:
hosts:
- push.example.tld
responseheaders:
Access-Control-Allow-Origin: "*"
Access-Control-Allow-Methods: "GET,POST"
cors:
alloworigins:
- ".*"
- ".+"
allowmethods:
- "GET"
- "POST"
allowheaders:
- "Authorization"
- "content-type"
stream:
allowedorigins:
- ".+.example.com"
- "otherdomain.com"
database:
dialect: mysql
connection: user name
defaultuser:
name: nicories
pass: 12345
pluginsdir: data/plugins
`)
file.Close()
assert.Nil(t, err)
conf := Get()
assert.Equal(t, 1234, conf.Server.Port)
assert.Equal(t, 3333, conf.Server.SSL.Port)
assert.Equal(t, []string{"push.example.tld"}, conf.Server.SSL.LetsEncrypt.Hosts)
assert.Equal(t, "nicories", conf.DefaultUser.Name)
assert.Equal(t, "12345", conf.DefaultUser.Pass)
assert.Equal(t, "mysql", conf.Database.Dialect)
assert.Equal(t, "user name", conf.Database.Connection)
assert.Equal(t, "*", conf.Server.ResponseHeaders["Access-Control-Allow-Origin"])
assert.Equal(t, "GET,POST", conf.Server.ResponseHeaders["Access-Control-Allow-Methods"])
assert.Equal(t, []string{".*", ".+"}, conf.Server.Cors.AllowOrigins)
assert.Equal(t, []string{"GET", "POST"}, conf.Server.Cors.AllowMethods)
assert.Equal(t, []string{"Authorization", "content-type"}, conf.Server.Cors.AllowHeaders)
assert.Equal(t, []string{".+.example.com", "otherdomain.com"}, conf.Server.Stream.AllowedOrigins)
assert.Equal(t, "data/plugins", conf.PluginsDir)
assert.Nil(t, os.Remove("config.yml"))
tests := []struct {
name string
raw string
want []string
}{
{name: "escaped quotes", raw: `"a,b","c""d",e`, want: []string{`a,b`, `c"d`, `e`}},
{name: "lazy bare quote", raw: `a"b,c`, want: []string{`a"b`, `c`}},
{name: "lazy quote in quoted field", raw: `"ab"cd",test`, want: []string{`ab"cd`, `test`}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
os.Setenv(env, tc.raw)
defer os.Unsetenv(env)
var got []string
assert.Nil(t, parseList(&got, env))
assert.Equal(t, tc.want, got)
})
}
}

17
config/error.go Normal file
View File

@@ -0,0 +1,17 @@
package config
import "github.com/rs/zerolog"
// FutureLog is an intermediate type for log messages. It is used before the config was loaded because without loaded
// config we do not know the log level, so we log these messages once the config was initialized.
type FutureLog struct {
Level zerolog.Level
Msg string
}
func futureFatal(msg string) FutureLog {
return FutureLog{
Level: zerolog.FatalLevel,
Msg: msg,
}
}

60
config/file.go Normal file
View File

@@ -0,0 +1,60 @@
package config
import (
"fmt"
"os"
"path/filepath"
"github.com/joho/godotenv"
"github.com/rs/zerolog"
)
var osStat = os.Stat
func loadFiles() []FutureLog {
if configFile := os.Getenv("GOTIFY_CONFIG_FILE"); configFile != "" {
log, _ := loadFile(configFile)
return []FutureLog{log}
}
var logs []FutureLog
for _, file := range getFiles() {
log, found := loadFile(file)
logs = append(logs, log)
if found {
break
}
}
return logs
}
func loadFile(file string) (log FutureLog, found bool) {
if _, err := osStat(file); err != nil {
if os.IsNotExist(err) {
return FutureLog{Level: zerolog.DebugLevel, Msg: fmt.Sprintf("config file %s does not exist, skipping", file)}, false
}
return futureFatal(fmt.Sprintf("cannot read file %s: %s", file, err)), true
}
if err := godotenv.Load(file); err != nil {
return futureFatal(fmt.Sprintf("cannot load file %s: %s", file, err)), true
}
return FutureLog{Level: zerolog.InfoLevel, Msg: fmt.Sprintf("Loading file %s", file)}, true
}
func getFiles() []string {
result := []string{"gotify-server.env"}
if configHome := getConfigHome(); configHome != "" {
result = append(result, filepath.Join(configHome, "gotify/gotify-server.env"))
}
return append(result, "/etc/gotify/server.env")
}
func getConfigHome() string {
if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" {
return configHome
}
if homeDir, err := os.UserHomeDir(); err == nil {
return filepath.Join(homeDir, ".config")
}
return ""
}

44
config/keys.go Normal file
View File

@@ -0,0 +1,44 @@
package config
const (
EnvLogLevel = "GOTIFY_LOGLEVEL"
EnvServerKeepAlivePeriodSeconds = "GOTIFY_SERVER_KEEPALIVEPERIODSECONDS"
EnvServerListenAddr = "GOTIFY_SERVER_LISTENADDR"
EnvServerPort = "GOTIFY_SERVER_PORT"
EnvServerSSLEnabled = "GOTIFY_SERVER_SSL_ENABLED"
EnvServerSSLRedirectToHTTPS = "GOTIFY_SERVER_SSL_REDIRECTTOHTTPS"
EnvServerSSLListenAddr = "GOTIFY_SERVER_SSL_LISTENADDR"
EnvServerSSLPort = "GOTIFY_SERVER_SSL_PORT"
EnvServerSSLCertFile = "GOTIFY_SERVER_SSL_CERTFILE"
EnvServerSSLCertKey = "GOTIFY_SERVER_SSL_CERTKEY"
EnvServerSSLLetsEncryptEnabled = "GOTIFY_SERVER_SSL_LETSENCRYPT_ENABLED"
EnvServerSSLLetsEncryptAcceptTOS = "GOTIFY_SERVER_SSL_LETSENCRYPT_ACCEPTTOS"
EnvServerSSLLetsEncryptCache = "GOTIFY_SERVER_SSL_LETSENCRYPT_CACHE"
EnvServerSSLLetsEncryptDirectoryURL = "GOTIFY_SERVER_SSL_LETSENCRYPT_DIRECTORYURL"
EnvServerSSLLetsEncryptHosts = "GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS"
EnvServerResponseHeaders = "GOTIFY_SERVER_RESPONSEHEADERS"
EnvServerStreamPingPeriodSeconds = "GOTIFY_SERVER_STREAM_PINGPERIODSECONDS"
EnvServerStreamAllowedOrigins = "GOTIFY_SERVER_STREAM_ALLOWEDORIGINS"
EnvServerCorsAllowOrigins = "GOTIFY_SERVER_CORS_ALLOWORIGINS"
EnvServerCorsAllowMethods = "GOTIFY_SERVER_CORS_ALLOWMETHODS"
EnvServerCorsAllowHeaders = "GOTIFY_SERVER_CORS_ALLOWHEADERS"
EnvServerTrustedProxies = "GOTIFY_SERVER_TRUSTEDPROXIES"
EnvServerSecureCookie = "GOTIFY_SERVER_SECURECOOKIE"
EnvDatabaseDialect = "GOTIFY_DATABASE_DIALECT"
EnvDatabaseConnection = "GOTIFY_DATABASE_CONNECTION"
EnvDefaultUserName = "GOTIFY_DEFAULTUSER_NAME"
EnvDefaultUserPass = "GOTIFY_DEFAULTUSER_PASS"
EnvPassStrength = "GOTIFY_PASSSTRENGTH"
EnvUploadedImagesDir = "GOTIFY_UPLOADEDIMAGESDIR"
EnvPluginsDir = "GOTIFY_PLUGINSDIR"
EnvRegistration = "GOTIFY_REGISTRATION"
EnvOIDCEnabled = "GOTIFY_OIDC_ENABLED"
EnvOIDCIssuer = "GOTIFY_OIDC_ISSUER"
EnvOIDCClientID = "GOTIFY_OIDC_CLIENTID"
EnvOIDCClientSecret = "GOTIFY_OIDC_CLIENTSECRET"
EnvOIDCUsernameClaim = "GOTIFY_OIDC_USERNAMECLAIM"
EnvOIDCRedirectURL = "GOTIFY_OIDC_REDIRECTURL"
EnvOIDCAutoRegister = "GOTIFY_OIDC_AUTOREGISTER"
EnvOIDCScopes = "GOTIFY_OIDC_SCOPES"
EnvNoColor = "NOCOLOR"
)

25
config/loglevel.go Normal file
View File

@@ -0,0 +1,25 @@
package config
import (
"errors"
"github.com/rs/zerolog"
)
// LogLevel type that provides helper methods for decoding.
type LogLevel zerolog.Level
// Decode decodes a string to a log level.
func (ll *LogLevel) Decode(value string) error {
if level, err := zerolog.ParseLevel(value); err == nil {
*ll = LogLevel(level)
return nil
}
*ll = LogLevel(zerolog.InfoLevel)
return errors.New("unknown log level")
}
// AsZeroLogLevel converts the LogLevel to a zerolog.Level.
func (ll LogLevel) AsZeroLogLevel() zerolog.Level {
return zerolog.Level(ll)
}

22
config/loglevel_test.go Normal file
View File

@@ -0,0 +1,22 @@
package config
import (
"testing"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
)
func TestLogLevel_Decode_success(t *testing.T) {
ll := new(LogLevel)
err := ll.Decode("fatal")
assert.Nil(t, err)
assert.Equal(t, ll.AsZeroLogLevel(), zerolog.FatalLevel)
}
func TestLogLevel_Decode_fail(t *testing.T) {
ll := new(LogLevel)
err := ll.Decode("asdasdasdasdasdasd")
assert.EqualError(t, err, "unknown log level")
assert.Equal(t, ll.AsZeroLogLevel(), zerolog.InfoLevel)
}

185
config/migrate/migrate.go Normal file
View File

@@ -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")
}

View File

@@ -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")
}

114
config/parse.go Normal file
View File

@@ -0,0 +1,114 @@
package config
import (
"encoding/csv"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
)
func lookupEnv(env string) (string, bool, error) {
if raw, ok := os.LookupEnv(env); ok {
return raw, true, nil
}
path, ok := os.LookupEnv(env + "_FILE")
if !ok {
return "", false, nil
}
data, err := os.ReadFile(path)
if err != nil {
return "", false, fmt.Errorf("read file for %s_FILE (%s): %w", env, path, err)
}
return strings.TrimRight(string(data), "\r\n"), true, nil
}
func parseString(target *string, env string) error {
raw, ok, err := lookupEnv(env)
if err != nil {
return err
}
if ok {
*target = raw
}
return nil
}
func parseInt(target *int, env string) error {
raw, ok, err := lookupEnv(env)
if err != nil {
return err
}
if !ok {
return nil
}
n, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("invalid int for %s (%q): %w", env, raw, err)
}
*target = n
return nil
}
func parseBool(target *bool, env string) error {
raw, ok, err := lookupEnv(env)
if err != nil {
return err
}
if !ok {
return nil
}
b, err := strconv.ParseBool(raw)
if err != nil {
return fmt.Errorf("invalid bool for %s (%q): %w", env, raw, err)
}
*target = b
return nil
}
func parseList(target *[]string, env string) error {
raw, ok, err := lookupEnv(env)
if err != nil {
return err
}
if !ok || raw == "" {
return nil
}
reader := csv.NewReader(strings.NewReader(raw))
reader.TrimLeadingSpace = true
reader.LazyQuotes = true
record, err := reader.Read()
if err != nil {
return fmt.Errorf("invalid CSV for %s (%q): %w", env, raw, err)
}
*target = record
return nil
}
func parseMap(target *map[string]string, env string) error {
raw, ok, err := lookupEnv(env)
if err != nil {
return err
}
if !ok || raw == "" {
return nil
}
out := map[string]string{}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return fmt.Errorf("invalid JSON for %s: %w", env, err)
}
*target = out
return nil
}
func parseLogLevel(target *LogLevel, env string) error {
raw, ok, err := lookupEnv(env)
if err != nil {
return err
}
if !ok {
return nil
}
return target.Decode(raw)
}

View File

@@ -75,3 +75,4 @@ EXPOSE $GOTIFY_SERVER_EXPOSE
COPY --from=builder /target /
ENTRYPOINT ["./gotify-app"]
CMD ["serve"]

3
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/gotify/location v0.0.0-20170722210143-03bc4ad20437
github.com/gotify/plugin-api v1.0.0
github.com/h2non/filetype v1.1.3
github.com/jinzhu/configor v1.2.2
github.com/joho/godotenv v1.5.1
github.com/mattn/go-isatty v0.0.20
github.com/robfig/cron v1.2.0
github.com/rs/zerolog v1.35.1
@@ -26,7 +26,6 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect

7
go.sum
View File

@@ -1,8 +1,5 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
@@ -83,12 +80,12 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA=
github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI=
github.com/jinzhu/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA=
github.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=

277
gotify-server.env.example Normal file
View File

@@ -0,0 +1,277 @@
# Example environment variables for the server.
# Save as `gotify-server.env` (or export the variables) when edited.
#
# If $GOTIFY_CONFIG_FILE is set, that file is loaded exclusively and none of
# the files below are tried. Otherwise the first existing file from the search
# order is loaded. Absent or commented out settings fall back to the default
# (shown after the =). Variables already exported in the process environment
# always take precedence over the loaded file.
#
# Config file search order (used only when $GOTIFY_CONFIG_FILE is unset):
# 1. gotify-server.env (in the working directory)
# 2. $XDG_CONFIG_HOME/gotify/gotify-server.env
# ($XDG_CONFIG_HOME falls back to $HOME/.config when unset)
# 3. /etc/gotify/server.env
#
# Value types used below:
# text a plain string value.
# number an integer value.
# boolean `true` or `false`.
# text-list comma-separated list of strings, parsed as a single CSV line.
# A comma can be escaped by wrapping the value in quotes.
# Example: a,b,c
# Example: "a,b",c -> entries: `a,b` and `c`
# json-map a JSON object mapping string keys to string values.
# Example: {"X-Foo":"bar","X-Baz":"qux"}
#
# Every variable also supports a "_FILE" suffix that reads the value from a
# file at the given path (useful for Docker / Kubernetes secrets), e.g.:
# GOTIFY_DEFAULTUSER_PASS_FILE=/run/secrets/admin_pass
# Minimum severity of log messages to emit.
# Values: trace, debug, info, warn, error, fatal, panic
# GOTIFY_LOGLEVEL=info
# Interval in seconds between TCP keepalive probes on accepted connections. !! Only change this if you know what you are doing.
#
# Example: 0 uses the Go default (15s)
# Example: -1 disables keepalives entirely.
# Type: number
# GOTIFY_SERVER_KEEPALIVEPERIODSECONDS=0
# The network address the HTTP server binds to. Leave empty to listen on all
# interfaces (both IPv4 and IPv6). Prefix with "unix:" to listen on a Unix
# domain socket instead of a TCP port.
#
# Type: text
# Example: 192.168.178.2
# Example: unix:/tmp/gotify.sock
# GOTIFY_SERVER_LISTENADDR=
# Port the HTTP server listens on.
# Type: number
# GOTIFY_SERVER_PORT=80
# Enable the HTTPS listener. Requires either CERTFILE+CERTKEY or LETSENCRYPT_ENABLED=true.
# Type: boolean
# GOTIFY_SERVER_SSL_ENABLED=false
# Redirect plain HTTP requests to HTTPS. Only effective when SSL_ENABLED=true.
# Type: boolean
# GOTIFY_SERVER_SSL_REDIRECTTOHTTPS=true
# The network address the HTTPS server binds to. Leave empty to listen on all
# interfaces (both IPv4 and IPv6). Prefix with "unix:" to listen on a Unix
# domain socket instead of a TCP port.
#
# Type: text
# Example: 192.168.178.2
# Example: unix:/tmp/gotify-ssl.sock
# GOTIFY_SERVER_SSL_LISTENADDR=
# Port the HTTPS server listens on.
# Type: number
# GOTIFY_SERVER_SSL_PORT=443
# Path to the TLS certificate.
# Type: text
# Example: /etc/ssl/certs/gotify.crt
# GOTIFY_SERVER_SSL_CERTFILE=
# Path to the TLS private key.
# Type: text
# Example: /etc/ssl/private/gotify.key
# GOTIFY_SERVER_SSL_CERTKEY=
# Obtain the TLS certificate automatically from Let's Encrypt.
# Requires SSL_ENABLED=true and LETSENCRYPT_ACCEPTTOS=true.
# Type: boolean
# GOTIFY_SERVER_SSL_LETSENCRYPT_ENABLED=false
# Accept the Let's Encrypt Terms of Service.
# Type: boolean
# GOTIFY_SERVER_SSL_LETSENCRYPT_ACCEPTTOS=false
# Directory where issued certificates and ACME account data are persisted. Must
# be writable by the server.
#
# Type: text
# Example: /var/lib/gotify/certs
# GOTIFY_SERVER_SSL_LETSENCRYPT_CACHE=data/certs
# Override the ACME directory URL. Leave empty to use the Let's Encrypt
# production server. The staging server has higher rate limits useful for
# testing but issues certificates that are not publicly trusted.
#
# Type: text
# Example: https://acme-staging-v02.api.letsencrypt.org/directory
# GOTIFY_SERVER_SSL_LETSENCRYPT_DIRECTORYURL=
# Hosts Let's Encrypt should issue certificates for. Each host must resolve
# publicly to this server.
#
# Type: text-list
# Example: mydomain.tld,myotherdomain.tld
# GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS=
# Extra HTTP headers attached to every response.
# Type: json-map
# Example: {"X-Custom-Header":"custom value"}
# GOTIFY_SERVER_RESPONSEHEADERS=
# IPs or CIDR ranges of proxies whose X-Forwarded-For header is trusted to
# determine the real client IP. Include 127.0.0.1 when terminating TLS in a
# sidecar on the same host.
#
# Type: text-list
# Example: 127.0.0.1/32,::1
# GOTIFY_SERVER_TRUSTEDPROXIES=
# Set the Secure flag on session cookies, restricting them to HTTPS
# connections. Enable when the server is reachable over HTTPS.
#
# Type: boolean
# GOTIFY_SERVER_SECURECOOKIE=false
# Allowed origins (regex) for cross-origin requests. Setting any CORS_* value
# enables CORS handling.
#
# Type: text-list
# Example: .+\.example\.com,otherdomain\.com
# GOTIFY_SERVER_CORS_ALLOWORIGINS=
# HTTP methods permitted in cross-origin requests.
# Type: text-list
# Example: GET,POST
# GOTIFY_SERVER_CORS_ALLOWMETHODS=
# Request headers permitted in cross-origin requests.
# Type: text-list
# Example: Authorization,content-type
# GOTIFY_SERVER_CORS_ALLOWHEADERS=
# Interval in seconds between WebSocket ping frames sent to streaming clients.
# Only change this if you know what you are doing.
#
# Type: number
# GOTIFY_SERVER_STREAM_PINGPERIODSECONDS=45
# Allowed origins (regex) for WebSocket upgrade requests. Same-origin
# connections are always permitted regardless of this setting.
#
# Type: text-list
# Example: .+\.example\.com,otherdomain\.com
# GOTIFY_SERVER_STREAM_ALLOWEDORIGINS=
# Enable OpenID Connect Single Sign-On, allowing users to authenticate via an
# external identity provider (e.g. Authelia, Dex, Keycloak). The provider must
# support PKCE (https://oauth.net/2/pkce/); IdPs without PKCE support are
# currently unsupported.
#
# Type: boolean
# GOTIFY_OIDC_ENABLED=false
# Base URL of the identity provider. It will be used to discover OIDC endpoints
# via /.well-known/openid-configuration.
#
# Type: text
# Example: https://auth.example.com/realms/myrealm
# GOTIFY_OIDC_ISSUER=
# Client ID registered with the identity provider for this application.
# Type: text
# Example: gotify
# GOTIFY_OIDC_CLIENTID=
# Client secret paired with the client ID.
# Type: text
# Example: super-secret
# GOTIFY_OIDC_CLIENTSECRET=
# Callback URL the identity provider redirects to after authentication. Must
# end with `/auth/oidc/callback` and match exactly what is registered at the
# provider. When Gotify is served on a sub-path behind a reverse proxy, include
# it (e.g. https://example.org/gotify/auth/oidc/callback). To support OIDC
# login in the Android app, also register `gotify://oidc/callback` as an
# additional redirect URL at the provider.
#
# Type: text
# Example: https://gotify.example.org/auth/oidc/callback
# GOTIFY_OIDC_REDIRECTURL=
# Automatically create a local user on first OIDC login. When disabled, only
# users that already exist in Gotify can sign in via OIDC.
#
# Type: boolean
# GOTIFY_OIDC_AUTOREGISTER=true
# OIDC ID-token claim used as the local username. Common values are
# preferred_username or email.
#
# Type: text
# Example: email
# GOTIFY_OIDC_USERNAMECLAIM=preferred_username
# OIDC scopes to request from the identity provider.
# Type: text-list
# GOTIFY_OIDC_SCOPES=openid,profile,email
# Database driver to use. For mysql and postgres the target database must
# already exist and the configured user must have sufficient permissions.
#
# Type: one of sqlite3, mysql, postgres
# GOTIFY_DATABASE_DIALECT=sqlite3
# Database connection string. Format depends on the dialect.
# Type: text
# Example:
# sqlite3: path/to/database.db
# mysql: gotify:secret@tcp(localhost:3306)/gotifydb?charset=utf8&parseTime=True&loc=Local
# postgres: host=localhost port=5432 user=gotify dbname=gotifydb password=secret
# When using postgres without SSL, append `sslmode=disable` (see https://github.com/gotify/server/issues/90).
# GOTIFY_DATABASE_CONNECTION=data/gotify.db
# Username for the initial admin account. Only applied when the database is
# first created; later changes must be made through the WebUI.
#
# Type: text
# Example: myadmin
# GOTIFY_DEFAULTUSER_NAME=admin
# Password for the initial admin account. Only applied when the database is
# first created.
#
# Type: text
# Example: super-secret-password
# GOTIFY_DEFAULTUSER_PASS=admin
# Bcrypt cost factor for password hashes. Higher values are more secure but slower.
# Type: number
# GOTIFY_PASSSTRENGTH=10
# Directory where application icons and other uploaded images are stored. Must
# be writable by the server.
#
# Type: text
# Example: /var/lib/gotify/images
# GOTIFY_UPLOADEDIMAGESDIR=data/images
# Directory scanned for plugin shared libraries on startup. Leave empty to
# disable plugin loading.
#
# Type: text
# Example: /var/lib/gotify/plugins
# GOTIFY_PLUGINSDIR=data/plugins
# Allow unauthenticated users to register new user accounts via the public
# registration endpoint.
#
# Type: boolean
# GOTIFY_REGISTRATION=false
# Disable colored log output. Set to "1" to force-disable colors regardless of
# whether stdout is a terminal. When unset, colors are emitted only if stdout
# is a TTY. See https://no-color.org/.
#
# Type: text
# NOCOLOR=

View File

@@ -59,17 +59,6 @@ func (s *IntegrationSuite) TestVersionInfo() {
doRequestAndExpect(s.T(), req, 200, `{"version":"1.0.0", "commit":"asdasds", "buildDate":"2018-02-20-17:30:47"}`)
}
func (s *IntegrationSuite) TestHeaderInDev() {
mode.Set(mode.TestDev)
req := s.newRequest("GET", "version", "")
// Needs an origin to indicate that it is a CORS request
req.Header.Add("Origin", "some-origin")
res, err := client.Do(req)
assert.Nil(s.T(), err)
assert.NotEmpty(s.T(), res.Header.Get("Access-Control-Allow-Origin"))
}
func (s *IntegrationSuite) TestHeaderInProd() {
mode.Set(mode.Prod)
req := s.newRequest("GET", "version", "")

View File

@@ -123,7 +123,7 @@ const buildGoExecutable = (filename: string): Promise<void> => {
};
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',

View File

@@ -2,6 +2,12 @@ import {defineConfig} from 'vite';
import react from '@vitejs/plugin-react';
import babel from '@rolldown/plugin-babel';
try {
process.loadEnvFile('../gotify-server.env');
} catch {
// file is optional
}
const GOTIFY_SERVER_PORT = process.env.GOTIFY_SERVER_PORT ?? '80';
function decoratorPreset(options: Record<string, unknown>) {
@@ -40,13 +46,11 @@ export default defineConfig({
proxy: {
'^/(application|message|client|current|user|plugin|version|image|auth)': {
target: `http://localhost:${GOTIFY_SERVER_PORT}/`,
changeOrigin: true,
secure: false,
},
'/stream': {
target: `ws://localhost:${GOTIFY_SERVER_PORT}/`,
target: `http://localhost:${GOTIFY_SERVER_PORT}/`,
ws: true,
rewriteWsOrigin: true,
},
},
cors: false,