diff --git a/.golangci.yaml b/.golangci.yaml index c1237245..9ead0c28 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -39,6 +39,7 @@ linters: - varcheck # deprecated - cyclop # can detect complicated code, but never leads to actual code changes + - funlen # can detect complicated code, but never leads to actual code changes - gocognit # can detect complicated code, but never leads to actual code changes - maintidx # can detect complicated code, but never leads to actual code changes diff --git a/README.markdown b/README.markdown index 44efcd04..b737d5d1 100644 --- a/README.markdown +++ b/README.markdown @@ -13,7 +13,7 @@ A feature-rich and robust Cloudflare DDNS updater with a small footprint. The pr ### โšก Efficiency -- ๐Ÿค The Docker image takes less than 3 MB (after compression). +- ๐Ÿค The Docker image takes less than 5 MB after compression. - ๐Ÿ” The Go runtime re-uses existing HTTP connections. - ๐Ÿ—ƒ๏ธ Cloudflare API responses are cached to reduce the API usage. diff --git a/go.mod b/go.mod index fa4db938..eb8a7310 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.21 require ( github.com/cloudflare/cloudflare-go v0.80.0 + github.com/containrrr/shoutrrr v0.8.0 github.com/google/go-querystring v1.1.0 github.com/hashicorp/go-retryablehttp v0.7.4 github.com/jellydator/ttlcache/v3 v3.1.0 @@ -16,10 +17,14 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.15.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/sync v0.5.0 // indirect + golang.org/x/sys v0.14.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 68a6eb2d..f029efb2 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,26 @@ github.com/cloudflare/cloudflare-go v0.80.0 h1:BfCFK9gy+2H/R3yjZWzFvWsVXwr2zICCfmAW3brbHpE= github.com/cloudflare/cloudflare-go v0.80.0/go.mod h1:gkHQf9xEubaQPEuerBuoinR9P8bf8a05Lq0X6WKy1Oc= +github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec= +github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= -github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= @@ -18,16 +28,23 @@ github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXc github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-retryablehttp v0.7.4 h1:ZQgVdpTdAL7WpMIwLzCfbalOcSUdkDZnpUv3/+BxzFA= github.com/hashicorp/go-retryablehttp v0.7.4/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= +github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU= +github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= +github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= +github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -45,13 +62,19 @@ golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY= golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4= +golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/config/config.go b/internal/config/config.go index 681e2c61..6f78e3b6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,7 @@ import ( "github.com/favonia/cloudflare-ddns/internal/domain" "github.com/favonia/cloudflare-ddns/internal/ipnet" "github.com/favonia/cloudflare-ddns/internal/monitor" + "github.com/favonia/cloudflare-ddns/internal/notifier" "github.com/favonia/cloudflare-ddns/internal/provider" ) @@ -29,6 +30,7 @@ type Config struct { DetectionTimeout time.Duration UpdateTimeout time.Duration Monitors []monitor.Monitor + Notifiers []notifier.Notifier } // Default gives the default configuration. @@ -54,5 +56,6 @@ func Default() *Config { UpdateTimeout: time.Second * 30, //nolint:gomnd DetectionTimeout: time.Second * 5, //nolint:gomnd Monitors: nil, + Notifiers: nil, } } diff --git a/internal/config/config_print.go b/internal/config/config_print.go index e45935ab..4094790c 100644 --- a/internal/config/config_print.go +++ b/internal/config/config_print.go @@ -10,6 +10,7 @@ import ( "github.com/favonia/cloudflare-ddns/internal/domain" "github.com/favonia/cloudflare-ddns/internal/ipnet" "github.com/favonia/cloudflare-ddns/internal/monitor" + "github.com/favonia/cloudflare-ddns/internal/notifier" "github.com/favonia/cloudflare-ddns/internal/pp" "github.com/favonia/cloudflare-ddns/internal/provider" ) @@ -94,4 +95,11 @@ func (c *Config) Print(ppfmt pp.PP) { item(service+":", "%s", params) }, c.Monitors) } + + if len(c.Notifiers) > 0 { + section("Notifiers (via shoutrrr):") + notifier.DescribeAll(func(service, params string) { + item(service+":", "%s", params) + }, c.Notifiers) + } } diff --git a/internal/config/config_print_test.go b/internal/config/config_print_test.go index a8739966..63d08fa3 100644 --- a/internal/config/config_print_test.go +++ b/internal/config/config_print_test.go @@ -4,7 +4,6 @@ import ( "strings" "testing" - "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "github.com/favonia/cloudflare-ddns/internal/config" @@ -12,6 +11,7 @@ import ( "github.com/favonia/cloudflare-ddns/internal/ipnet" "github.com/favonia/cloudflare-ddns/internal/mocks" "github.com/favonia/cloudflare-ddns/internal/monitor" + "github.com/favonia/cloudflare-ddns/internal/notifier" "github.com/favonia/cloudflare-ddns/internal/pp" ) @@ -117,7 +117,9 @@ func TestPrintMaps(t *testing.T) { printItem(innerMockPP, "IP detection:", "5s"), printItem(innerMockPP, "Record updating:", "30s"), mockPP.EXPECT().Infof(pp.EmojiConfig, "Monitors:"), - printItem(innerMockPP, "Healthchecks:", "(URL redacted)"), + printItem(innerMockPP, "Meow:", "purrrr"), + mockPP.EXPECT().Infof(pp.EmojiConfig, "Notifiers (via shoutrrr):"), + printItem(innerMockPP, "Snake:", "hissss"), ) c := config.Default() @@ -133,10 +135,20 @@ func TestPrintMaps(t *testing.T) { c.Proxied[domain.FQDN("c")] = false c.Proxied[domain.FQDN("d")] = false - m, ok := monitor.NewHealthchecks(mockPP, "https://user:pass@host/path") - require.True(t, ok) + m := mocks.NewMockMonitor(mockCtrl) + m.EXPECT().Describe(gomock.Any()). + DoAndReturn(func(f func(string, string)) { + f("Meow", "purrrr") + }).AnyTimes() c.Monitors = []monitor.Monitor{m} + n := mocks.NewMockNotifier(mockCtrl) + n.EXPECT().Describe(gomock.Any()). + DoAndReturn(func(f func(string, string)) { + f("Snake", "hissss") + }).AnyTimes() + c.Notifiers = []notifier.Notifier{n} + c.Print(mockPP) } diff --git a/internal/config/config_read.go b/internal/config/config_read.go index 61439330..bfd19e5e 100644 --- a/internal/config/config_read.go +++ b/internal/config/config_read.go @@ -29,7 +29,8 @@ func (c *Config) ReadEnv(ppfmt pp.PP) bool { !ReadNonnegDuration(ppfmt, "DETECTION_TIMEOUT", &c.DetectionTimeout) || !ReadNonnegDuration(ppfmt, "UPDATE_TIMEOUT", &c.UpdateTimeout) || !ReadAndAppendHealthchecksURL(ppfmt, "HEALTHCHECKS", &c.Monitors) || - !ReadAndAppendUptimeKumaURL(ppfmt, "UPTIMEKUMA", &c.Monitors) { + !ReadAndAppendUptimeKumaURL(ppfmt, "UPTIMEKUMA", &c.Monitors) || + !ReadAndAppendShoutrrrURL(ppfmt, "SHOUTRRR", &c.Notifiers) { return false } diff --git a/internal/config/config_read_test.go b/internal/config/config_read_test.go index bcfe6b78..dc0ba395 100644 --- a/internal/config/config_read_test.go +++ b/internal/config/config_read_test.go @@ -17,16 +17,31 @@ import ( "github.com/favonia/cloudflare-ddns/internal/provider" ) -//nolint:paralleltest // environment variables are global -func TestReadEnvWithOnlyToken(t *testing.T) { - mockCtrl := gomock.NewController(t) - +func unsetAll(t *testing.T) { + t.Helper() unset(t, "CF_API_TOKEN", "CF_API_TOKEN_FILE", "CF_ACCOUNT_ID", "IP4_PROVIDER", "IP6_PROVIDER", "DOMAINS", "IP4_DOMAINS", "IP6_DOMAINS", - "UPDATE_CRON", "UPDATE_ON_START", "DELETE_ON_STOP", "CACHE_EXPIRATION", "TTL", "PROXIED", "DETECTION_TIMEOUT") + "UPDATE_CRON", + "UPDATE_ON_START", + "DELETE_ON_STOP", + "CACHE_EXPIRATION", + "TTL", + "PROXIED", + "DETECTION_TIMEOUT", + "UPDATE_TIMEOUT", + "HEALTHCHECKS", + "UPTIMEKUMA", + "SHOUTRRR", + ) +} + +//nolint:paralleltest // environment variables are global +func TestReadEnvWithOnlyToken(t *testing.T) { + mockCtrl := gomock.NewController(t) + unsetAll(t) store(t, "CF_API_TOKEN", "deadbeaf") var cfg config.Config @@ -55,12 +70,7 @@ func TestReadEnvWithOnlyToken(t *testing.T) { func TestReadEnvEmpty(t *testing.T) { mockCtrl := gomock.NewController(t) - unset(t, - "CF_API_TOKEN", "CF_API_TOKEN_FILE", "CF_ACCOUNT_ID", - "IP4_PROVIDER", "IP6_PROVIDER", - "IP4_POLICY", "IP6_POLICY", - "DOMAINS", "IP4_DOMAINS", "IP6_DOMAINS", - "UPDATE_CRON", "UPDATE_ON_START", "DELETE_ON_STOP", "CACHE_EXPIRATION", "TTL", "PROXIED", "DETECTION_TIMEOUT") + unsetAll(t) var cfg config.Config mockPP := mocks.NewMockPP(mockCtrl) diff --git a/internal/config/env_base.go b/internal/config/env_base.go index b3c3a563..1a576140 100644 --- a/internal/config/env_base.go +++ b/internal/config/env_base.go @@ -16,6 +16,19 @@ func Getenv(key string) string { return strings.TrimSpace(os.Getenv(key)) } +// Getenvs reads an environment variable, split it by '\n', and trim the space. +func Getenvs(key string) []string { + rawVals := strings.Split(os.Getenv(key), "\n") + vals := make([]string, 0, len(rawVals)) + for _, v := range rawVals { + v = strings.TrimSpace(v) + if len(v) > 0 { + vals = append(vals, v) + } + } + return vals +} + // ReadString reads an environment variable as a plain string. func ReadString(ppfmt pp.PP, key string, field *string) bool { val := Getenv(key) diff --git a/internal/config/env_base_test.go b/internal/config/env_base_test.go index bf72d7ea..7bd7eca2 100644 --- a/internal/config/env_base_test.go +++ b/internal/config/env_base_test.go @@ -59,6 +59,29 @@ func TestGetenv(t *testing.T) { } } +//nolint:paralleltest // environment vars are global +func TestGetenvs(t *testing.T) { + key := keyPrefix + "VAR" + for name, tc := range map[string]struct { + set bool + val string + expected []string + }{ + "nil": {false, "", []string{}}, + "empty": {true, "", []string{}}, + "only-spaces": {true, "\n \n \n \t", []string{}}, + "simple": {true, "VAL", []string{"VAL"}}, + "space1": {true, " VAL1 \nVAL2 ", []string{"VAL1", "VAL2"}}, + "space2": {true, " VAL1 \n VAL2 ", []string{"VAL1", "VAL2"}}, + } { + tc := tc + t.Run(name, func(t *testing.T) { + set(t, key, tc.set, tc.val) + require.Equal(t, tc.expected, config.Getenvs(key)) + }) + } +} + //nolint:paralleltest // environment vars are global func TestReadString(t *testing.T) { key := keyPrefix + "STRING" diff --git a/internal/config/env_notifier.go b/internal/config/env_notifier.go new file mode 100644 index 00000000..68b6663d --- /dev/null +++ b/internal/config/env_notifier.go @@ -0,0 +1,24 @@ +package config + +import ( + "github.com/favonia/cloudflare-ddns/internal/notifier" + "github.com/favonia/cloudflare-ddns/internal/pp" +) + +// ReadAndAppendShoutrrrURL reads the URLs separated by the newline. +func ReadAndAppendShoutrrrURL(ppfmt pp.PP, key string, field *[]notifier.Notifier) bool { + vals := Getenvs(key) + + if len(vals) == 0 { + return true + } + + s, ok := notifier.NewShoutrrr(ppfmt, vals) + if !ok { + return false + } + + // Append the new monitor to the existing list + *field = append(*field, s) + return true +} diff --git a/internal/config/env_notifier_test.go b/internal/config/env_notifier_test.go new file mode 100644 index 00000000..f2c6a49d --- /dev/null +++ b/internal/config/env_notifier_test.go @@ -0,0 +1,100 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/favonia/cloudflare-ddns/internal/config" + "github.com/favonia/cloudflare-ddns/internal/mocks" + "github.com/favonia/cloudflare-ddns/internal/notifier" + "github.com/favonia/cloudflare-ddns/internal/pp" +) + +//nolint:paralleltest,funlen // paralleltest should not be used because environment vars are global +func TestReadAndAppendShoutrrrURL(t *testing.T) { + key := keyPrefix + "SHOUTRRR" + + type not = notifier.Notifier + + for name, tc := range map[string]struct { + set bool + val string + oldField []not + newField func(*testing.T, []not) + ok bool + prepareMockPP func(*mocks.MockPP) + }{ + "unset": { + false, "", nil, + func(t *testing.T, ns []not) { + t.Helper() + require.Nil(t, ns) + }, + true, nil, + }, + "empty": { + true, "", nil, + func(t *testing.T, ns []not) { + t.Helper() + require.Nil(t, ns) + }, + true, nil, + }, + "generic": { + true, "generic+https://example.com/api/v1/postStuff", + nil, + func(t *testing.T, ns []not) { + t.Helper() + require.Len(t, ns, 1) + m := ns[0] + s, ok := m.(*notifier.Shoutrrr) + require.True(t, ok) + require.Equal(t, []string{"generic"}, s.ServiceNames) + }, + true, + nil, + }, + "ill-formed": { + true, "meow-meow-meow://cute", + nil, + func(t *testing.T, ns []not) { + t.Helper() + require.Nil(t, ns) + }, + false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, `Could not create shoutrrr client: %v`, gomock.Any()) + }, + }, + "multiple": { + true, "generic+https://example.com/api/v1/postStuff\npushover://shoutrrr:token@userKey", + nil, + func(t *testing.T, ns []not) { + t.Helper() + require.Len(t, ns, 1) + m := ns[0] + s, ok := m.(*notifier.Shoutrrr) + require.True(t, ok) + require.Equal(t, []string{"generic", "pushover"}, s.ServiceNames) + }, + true, + nil, + }, + } { + tc := tc + t.Run(name, func(t *testing.T) { + set(t, key, tc.set, tc.val) + field := tc.oldField + mockCtrl := gomock.NewController(t) + mockPP := mocks.NewMockPP(mockCtrl) + if tc.prepareMockPP != nil { + tc.prepareMockPP(mockPP) + } + ok := config.ReadAndAppendShoutrrrURL(mockPP, key, &field) + require.Equal(t, tc.ok, ok) + tc.newField(t, field) + }) + } +} diff --git a/internal/mocks/mock_notifier.go b/internal/mocks/mock_notifier.go new file mode 100644 index 00000000..74585b2a --- /dev/null +++ b/internal/mocks/mock_notifier.go @@ -0,0 +1,114 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/favonia/cloudflare-ddns/internal/notifier (interfaces: Notifier) +// +// Generated by this command: +// +// mockgen -typed -destination=../mocks/mock_notifier.go -package=mocks . Notifier +// +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + pp "github.com/favonia/cloudflare-ddns/internal/pp" + gomock "go.uber.org/mock/gomock" +) + +// MockNotifier is a mock of Notifier interface. +type MockNotifier struct { + ctrl *gomock.Controller + recorder *MockNotifierMockRecorder +} + +// MockNotifierMockRecorder is the mock recorder for MockNotifier. +type MockNotifierMockRecorder struct { + mock *MockNotifier +} + +// NewMockNotifier creates a new mock instance. +func NewMockNotifier(ctrl *gomock.Controller) *MockNotifier { + mock := &MockNotifier{ctrl: ctrl} + mock.recorder = &MockNotifierMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockNotifier) EXPECT() *MockNotifierMockRecorder { + return m.recorder +} + +// Describe mocks base method. +func (m *MockNotifier) Describe(arg0 func(string, string)) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Describe", arg0) +} + +// Describe indicates an expected call of Describe. +func (mr *MockNotifierMockRecorder) Describe(arg0 any) *NotifierDescribeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Describe", reflect.TypeOf((*MockNotifier)(nil).Describe), arg0) + return &NotifierDescribeCall{Call: call} +} + +// NotifierDescribeCall wrap *gomock.Call +type NotifierDescribeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *NotifierDescribeCall) Return() *NotifierDescribeCall { + c.Call = c.Call.Return() + return c +} + +// Do rewrite *gomock.Call.Do +func (c *NotifierDescribeCall) Do(f func(func(string, string))) *NotifierDescribeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *NotifierDescribeCall) DoAndReturn(f func(func(string, string))) *NotifierDescribeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Send mocks base method. +func (m *MockNotifier) Send(arg0 context.Context, arg1 pp.PP, arg2 string) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Send", arg0, arg1, arg2) + ret0, _ := ret[0].(bool) + return ret0 +} + +// Send indicates an expected call of Send. +func (mr *MockNotifierMockRecorder) Send(arg0, arg1, arg2 any) *NotifierSendCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Send", reflect.TypeOf((*MockNotifier)(nil).Send), arg0, arg1, arg2) + return &NotifierSendCall{Call: call} +} + +// NotifierSendCall wrap *gomock.Call +type NotifierSendCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *NotifierSendCall) Return(arg0 bool) *NotifierSendCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *NotifierSendCall) Do(f func(context.Context, pp.PP, string) bool) *NotifierSendCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *NotifierSendCall) DoAndReturn(f func(context.Context, pp.PP, string) bool) *NotifierSendCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/monitor/healthchecks.go b/internal/monitor/healthchecks.go index 97968b86..02c2d345 100644 --- a/internal/monitor/healthchecks.go +++ b/internal/monitor/healthchecks.go @@ -25,6 +25,8 @@ type Healthchecks struct { Timeout time.Duration } +var _ Monitor = (*Healthchecks)(nil) + const ( // HealthchecksDefaultTimeout is the default timeout for a Healthchecks ping. HealthchecksDefaultTimeout = 10 * time.Second @@ -32,7 +34,7 @@ const ( // NewHealthchecks creates a new Healthchecks monitor. // See https://healthchecks.io/docs/http_api/ for more information. -func NewHealthchecks(ppfmt pp.PP, rawURL string) (Monitor, bool) { +func NewHealthchecks(ppfmt pp.PP, rawURL string) (*Healthchecks, bool) { u, err := url.Parse(rawURL) if err != nil { ppfmt.Errorf(pp.EmojiUserError, "Failed to parse the Healthchecks URL (redacted)") @@ -147,7 +149,7 @@ func (h *Healthchecks) ping(ctx context.Context, ppfmt pp.PP, endpoint string, m return false } - ppfmt.Infof(pp.EmojiNotification, "Successfully pinged the %s endpoint of Healthchecks", endpointDescription) + ppfmt.Infof(pp.EmojiPing, "Pinged the %s endpoint of Healthchecks", endpointDescription) return true } diff --git a/internal/monitor/healthchecks_test.go b/internal/monitor/healthchecks_test.go index 405fdfc8..839d86f7 100644 --- a/internal/monitor/healthchecks_test.go +++ b/internal/monitor/healthchecks_test.go @@ -114,7 +114,7 @@ func TestHealthchecksEndPoints(t *testing.T) { func(m *mocks.MockPP) { gomock.InOrder( m.EXPECT().Warningf(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS"), - m.EXPECT().Infof(pp.EmojiNotification, "Successfully pinged the %s endpoint of Healthchecks", `default (root)`), //nolint:lll + m.EXPECT().Infof(pp.EmojiPing, "Pinged the %s endpoint of Healthchecks", `default (root)`), ) }, }, @@ -159,7 +159,7 @@ func TestHealthchecksEndPoints(t *testing.T) { func(m *mocks.MockPP) { gomock.InOrder( m.EXPECT().Warningf(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS"), - m.EXPECT().Infof(pp.EmojiNotification, "Successfully pinged the %s endpoint of Healthchecks", `"/start"`), //nolint:lll + m.EXPECT().Infof(pp.EmojiPing, "Pinged the %s endpoint of Healthchecks", `"/start"`), ) }, }, @@ -174,7 +174,7 @@ func TestHealthchecksEndPoints(t *testing.T) { func(m *mocks.MockPP) { gomock.InOrder( m.EXPECT().Warningf(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS"), - m.EXPECT().Infof(pp.EmojiNotification, "Successfully pinged the %s endpoint of Healthchecks", `"/fail"`), //nolint:lll + m.EXPECT().Infof(pp.EmojiPing, "Pinged the %s endpoint of Healthchecks", `"/fail"`), ) }, }, @@ -189,7 +189,7 @@ func TestHealthchecksEndPoints(t *testing.T) { func(m *mocks.MockPP) { gomock.InOrder( m.EXPECT().Warningf(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS"), - m.EXPECT().Infof(pp.EmojiNotification, "Successfully pinged the %s endpoint of Healthchecks", `"/log"`), //nolint:lll + m.EXPECT().Infof(pp.EmojiPing, "Pinged the %s endpoint of Healthchecks", `"/log"`), ) }, }, @@ -204,7 +204,7 @@ func TestHealthchecksEndPoints(t *testing.T) { func(m *mocks.MockPP) { gomock.InOrder( m.EXPECT().Warningf(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS"), - m.EXPECT().Infof(pp.EmojiNotification, "Successfully pinged the %s endpoint of Healthchecks", `"/0"`), + m.EXPECT().Infof(pp.EmojiPing, "Pinged the %s endpoint of Healthchecks", `"/0"`), ) }, }, @@ -219,7 +219,7 @@ func TestHealthchecksEndPoints(t *testing.T) { func(m *mocks.MockPP) { gomock.InOrder( m.EXPECT().Warningf(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS"), - m.EXPECT().Infof(pp.EmojiNotification, "Successfully pinged the %s endpoint of Healthchecks", `"/1"`), + m.EXPECT().Infof(pp.EmojiPing, "Pinged the %s endpoint of Healthchecks", `"/1"`), ) }, }, diff --git a/internal/monitor/uptimekuma.go b/internal/monitor/uptimekuma.go index b5e2c5c0..51e5beff 100644 --- a/internal/monitor/uptimekuma.go +++ b/internal/monitor/uptimekuma.go @@ -29,13 +29,15 @@ type UptimeKuma struct { Timeout time.Duration } +var _ Monitor = (*UptimeKuma)(nil) + const ( // UptimeKumaDefaultTimeout is the default timeout for a UptimeKuma ping. UptimeKumaDefaultTimeout = 10 * time.Second ) // NewUptimeKuma creates a new UptimeKuma monitor. -func NewUptimeKuma(ppfmt pp.PP, rawURL string) (Monitor, bool) { +func NewUptimeKuma(ppfmt pp.PP, rawURL string) (*UptimeKuma, bool) { u, err := url.Parse(rawURL) if err != nil { ppfmt.Errorf(pp.EmojiUserError, "Failed to parse the Uptime Kuma URL (redacted)") @@ -145,7 +147,7 @@ func (h *UptimeKuma) ping(ctx context.Context, ppfmt pp.PP, param UptimeKumaRequ return false } - ppfmt.Infof(pp.EmojiNotification, "Successfully pinged Uptime Kuma") + ppfmt.Infof(pp.EmojiPing, "Pinged Uptime Kuma") return true } diff --git a/internal/monitor/uptimekuma_test.go b/internal/monitor/uptimekuma_test.go index e88b186a..f378f609 100644 --- a/internal/monitor/uptimekuma_test.go +++ b/internal/monitor/uptimekuma_test.go @@ -102,7 +102,7 @@ func TestUptimeKumaEndPoints(t *testing.T) { successPP := func(m *mocks.MockPP) { gomock.InOrder( m.EXPECT().Warningf(pp.EmojiUserWarning, httpUnsafeMsg), - m.EXPECT().Infof(pp.EmojiNotification, "Successfully pinged Uptime Kuma"), + m.EXPECT().Infof(pp.EmojiPing, "Pinged Uptime Kuma"), ) } diff --git a/internal/notifier/base.go b/internal/notifier/base.go new file mode 100644 index 00000000..abc4d3b3 --- /dev/null +++ b/internal/notifier/base.go @@ -0,0 +1,19 @@ +// Package notifier implements push notifications. +package notifier + +import ( + "context" + + "github.com/favonia/cloudflare-ddns/internal/pp" +) + +//go:generate mockgen -typed -destination=../mocks/mock_notifier.go -package=mocks . Notifier + +// Notifier is an abstract service for push notifications. +type Notifier interface { + // Describe a notifier in a human-readable format by calling callback with service names and params. + Describe(callback func(service, params string)) + + // Send out a message. + Send(ctx context.Context, ppfmt pp.PP, msg string) bool +} diff --git a/internal/notifier/composite.go b/internal/notifier/composite.go new file mode 100644 index 00000000..0eb7f6f0 --- /dev/null +++ b/internal/notifier/composite.go @@ -0,0 +1,25 @@ +package notifier + +import ( + "context" + + "github.com/favonia/cloudflare-ddns/internal/pp" +) + +// DescribeAll calls [Notifier.Describe] for each monitor in the group with the callback. +func DescribeAll(callback func(service, params string), ns []Notifier) { + for _, n := range ns { + n.Describe(callback) + } +} + +// SendAll calls [Notifier.Success] for each monitor in the group. +func SendAll(ctx context.Context, ppfmt pp.PP, message string, ns []Notifier) bool { + ok := true + for _, n := range ns { + if !n.Send(ctx, ppfmt, message) { + ok = false + } + } + return ok +} diff --git a/internal/notifier/composite_test.go b/internal/notifier/composite_test.go new file mode 100644 index 00000000..43cb25e7 --- /dev/null +++ b/internal/notifier/composite_test.go @@ -0,0 +1,47 @@ +package notifier_test + +import ( + "context" + "testing" + + "go.uber.org/mock/gomock" + + "github.com/favonia/cloudflare-ddns/internal/mocks" + "github.com/favonia/cloudflare-ddns/internal/notifier" +) + +func TestDescribeAll(t *testing.T) { + t.Parallel() + + var ms []notifier.Notifier + + mockCtrl := gomock.NewController(t) + + for i := 0; i < 5; i++ { + m := mocks.NewMockNotifier(mockCtrl) + m.EXPECT().Describe(gomock.Any()) + ms = append(ms, m) + } + + callback := func(service, params string) { /* the callback content is not relevant here. */ } + notifier.DescribeAll(callback, ms) +} + +func TestSendAll(t *testing.T) { + t.Parallel() + + var ms []notifier.Notifier + + mockCtrl := gomock.NewController(t) + mockPP := mocks.NewMockPP(mockCtrl) + + message := "aloha" + + for i := 0; i < 5; i++ { + m := mocks.NewMockNotifier(mockCtrl) + m.EXPECT().Send(context.Background(), mockPP, message) + ms = append(ms, m) + } + + notifier.SendAll(context.Background(), mockPP, message, ms) +} diff --git a/internal/notifier/shoutrrr.go b/internal/notifier/shoutrrr.go new file mode 100644 index 00000000..3ffa9422 --- /dev/null +++ b/internal/notifier/shoutrrr.go @@ -0,0 +1,67 @@ +package notifier + +import ( + "context" + "time" + + "github.com/containrrr/shoutrrr" + "github.com/containrrr/shoutrrr/pkg/router" + "github.com/containrrr/shoutrrr/pkg/types" + + "github.com/favonia/cloudflare-ddns/internal/pp" +) + +type Shoutrrr struct { + // The router + Router *router.ServiceRouter + + // The services + ServiceNames []string +} + +var _ Notifier = (*Shoutrrr)(nil) + +const ( + // ShoutrrrDefaultTimeout is the default timeout for a UptimeKuma ping. + ShoutrrrDefaultTimeout = 10 * time.Second +) + +// NewShoutrrr creates a new shoutrrr notifier. +func NewShoutrrr(ppfmt pp.PP, rawURLs []string) (*Shoutrrr, bool) { + r, err := shoutrrr.CreateSender(rawURLs...) + if err != nil { + ppfmt.Errorf(pp.EmojiUserError, "Could not create shoutrrr client: %v", err) + return nil, false + } + + r.Timeout = ShoutrrrDefaultTimeout + + serviceNames := make([]string, 0, len(rawURLs)) + for _, u := range rawURLs { + s, _, _ := r.ExtractServiceName(u) + serviceNames = append(serviceNames, s) + } + + return &Shoutrrr{Router: r, ServiceNames: serviceNames}, true +} + +func (s *Shoutrrr) Describe(callback func(service, params string)) { + for _, n := range s.ServiceNames { + callback(n, "(URL redacted)") + } +} + +func (s *Shoutrrr) Send(_ context.Context, ppfmt pp.PP, msg string) bool { + errs := s.Router.Send(msg, &types.Params{}) + allOk := true + for _, err := range errs { + if err != nil { + ppfmt.Errorf(pp.EmojiError, "Failed to send some shoutrrr message: %v", err) + allOk = false + } + } + if allOk { + ppfmt.Infof(pp.EmojiNotification, "Sent shoutrrr message") + } + return allOk +} diff --git a/internal/notifier/shoutrrr_test.go b/internal/notifier/shoutrrr_test.go new file mode 100644 index 00000000..ec3d4dbf --- /dev/null +++ b/internal/notifier/shoutrrr_test.go @@ -0,0 +1,88 @@ +package notifier_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/favonia/cloudflare-ddns/internal/mocks" + "github.com/favonia/cloudflare-ddns/internal/notifier" + "github.com/favonia/cloudflare-ddns/internal/pp" +) + +func TestShoutrrrDescripbe(t *testing.T) { + t.Parallel() + + mockCtrl := gomock.NewController(t) + mockPP := mocks.NewMockPP(mockCtrl) + m, ok := notifier.NewShoutrrr(mockPP, []string{"generic://localhost/"}) + require.True(t, ok) + m.Describe(func(service, params string) { + require.Equal(t, "generic", service) + }) +} + +func TestShoutrrrSend(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + path string + service func(serverURL string) string + message string + pinged bool + ok bool + prepareMockPP func(*mocks.MockPP) + }{ + "success": { + "/greeting", + func(serverURL string) string { return "generic+" + serverURL + "/greeting" }, + "hello", + true, true, + func(m *mocks.MockPP) { + m.EXPECT().Infof(pp.EmojiNotification, "Sent shoutrrr message") + }, + }, + "ill-formed url": { + "", + func(_serverURL string) string { return "generic+https://0.0.0.0" }, + "hello", + false, false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiError, "Failed to send some shoutrrr message: %v", gomock.Any()) + }, + }, + } { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + mockCtrl := gomock.NewController(t) + mockPP := mocks.NewMockPP(mockCtrl) + if tc.prepareMockPP != nil { + tc.prepareMockPP(mockPP) + } + + pinged := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, tc.path, r.URL.EscapedPath()) + + reqBody, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Equal(t, tc.message, string(reqBody)) + + pinged = true + })) + + s, ok := notifier.NewShoutrrr(mockPP, []string{tc.service(server.URL)}) + require.True(t, ok) + ok = s.Send(context.Background(), mockPP, tc.message) + require.Equal(t, tc.ok, ok) + require.Equal(t, tc.pinged, pinged) + }) + } +} diff --git a/internal/pp/emoji.go b/internal/pp/emoji.go index 792710ea..184fe7c5 100644 --- a/internal/pp/emoji.go +++ b/internal/pp/emoji.go @@ -19,8 +19,8 @@ const ( EmojiUpdateRecord Emoji = "๐Ÿ“ก" // updating DNS records EmojiClearRecord Emoji = "๐Ÿงน" // clearing DNS records - EmojiNotification Emoji = "๐Ÿ””" // sending out notifications, pinging, health checks - EmojiRepeat Emoji = "๐Ÿ”" // repeating things + EmojiPing Emoji = "๐Ÿ””" // pinging and health checks + EmojiNotification Emoji = "๐Ÿ“จ" // notifications EmojiSignal Emoji = "๐Ÿšจ" // catching signals EmojiAlreadyDone Emoji = "๐Ÿคท" // DNS records were already up to date