Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: per-domain proxy settings #202

Merged
merged 6 commits into from
Aug 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
23 changes: 19 additions & 4 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ A small and fast DDNS updater for Cloudflare.
* Full configurability via environment variables.
* Ability to pass API tokens via a file instead of an environment variable.
* Local caching to reduce Cloudflare API usage.
* Per-domain proxy settings _(experimental)._
* Integration with [Healthchecks.io](https://healthchecks.io).

## 🕵️ Privacy
Expand Down Expand Up @@ -322,15 +323,13 @@ In most cases, `CF_ACCOUNT_ID` is not needed.
</details>

<details>
<summary>⏳ Schedules, timeouts, and parameters of new DNS records</summary>
<summary>⏳ Schedules and timeouts</summary>

| Name | Valid Values | Meaning | Required? | Default Value |
| ---- | ------------ | ------- | --------- | ------------- |
| `CACHE_EXPIRATION` | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) | The expiration of cached Cloudflare API responses | No | `6h0m0s` (6 hours)
| `DELETE_ON_STOP` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool) | Whether managed DNS records should be deleted on exit | No | `false`
| `DETECTION_TIMEOUT` | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) | The timeout of each attempt to detect IP addresses | No | `5s` (5 seconds)
| `PROXIED` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool) | Whether new DNS records should be proxied by Cloudflare | No | `false`
| `TTL` | Time-to-live (TTL) values in seconds | The TTL values used to create new DNS records | No | `1` (This means “automatic” to Cloudflare)
| `TZ` | Recognized timezones, such as `UTC` | The timezone used for logging and parsing `UPDATE_CRON` | No | `UTC`
| `UPDATE_CRON` | Cron expressions. See the [documentation of cron](https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format) | The schedule to re-check IP addresses and update DNS records (if necessary) | No | `@every 5m` (every 5 minutes)
| `UPDATE_ON_START` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool) | Whether to check IP addresses on start regardless of `UPDATE_CRON` | No | `true`
Expand All @@ -339,6 +338,22 @@ In most cases, `CF_ACCOUNT_ID` is not needed.
Note that the update schedule _does not_ take the time to update records into consideration. For example, if the schedule is “for every 5 minutes”, and if the updating itself takes 2 minutes, then the actual interval between adjacent updates is 3 minutes, not 5 minutes.
</details>

<details>
<summary>🆕 Parameters of new DNS records</summary>

| Name | Valid Values | Meaning | Required? | Default Value |
| ---- | ------------ | ------- | --------- | ------------- |
| `PROXIED` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool) | Whether new DNS records should be proxied by Cloudflare | No | `false`
| `TTL` | Time-to-live (TTL) values in seconds | The TTL values used to create new DNS records | No | `1` (This means “automatic” to Cloudflare)

Experimental features: (Please [share your usage at this GitHub issue](https://github.com/favonia/cloudflare-ddns/issues/199) so that we can further revise the interface. Thanks!)

| Name | Valid Values | Meaning | Required? | Default Value |
| ---- | ------------ | ------- | --------- | ------------- |
| `PROXIED_DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names | The domains for which the new DNS records should be proxied by Cloudflare, overriding the global setting (`PROXIED`) for these domains | No | `""` (empty)
| `NON_PROXIED_DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names | The domains for which the new DNS records should **not** be proxied by Cloudflare, overriding the global setting (`PROXIED`) for these domains | No | `""` (empty)
</details>

<details>
<summary>🛡️ Dropping superuser privileges</summary>

Expand All @@ -356,7 +371,7 @@ The updater will also try to drop supplementary group IDs.
| Name | Valid Values | Meaning | Required? | Default Value |
| ---- | ------------ | ------- | --------- | ------------- |
| `QUIET` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool) | Whether the updater should reduce the logging to the standard output | No | `false`
| `HEALTHCHECKS` | [Healthchecks.io ping URLs,](https://healthchecks.io/docs/) such as `https://hc-ping.com/<uuid>` or `https://hc-ping.com/<project-ping-key>/<name-slug>` (see below) | If set, the updater will ping the URL when it successfully updates IP addresses | No | N/A
| `HEALTHCHECKS` | [Healthchecks.io ping URLs,](https://healthchecks.io/docs/) such as `https://hc-ping.com/<uuid>` or `https://hc-ping.com/<project-ping-key>/<name-slug>` (see below) | If set, the updater will ping the URL when it successfully updates IP addresses | No | `""` (unset)
</details>

For `HEALTHCHECKS`, the updater accepts any URL that follows the [same notification protocol.](https://healthchecks.io/docs/http_api/)
Expand Down
2 changes: 1 addition & 1 deletion cmd/ddns.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func initConfig(ctx context.Context, ppfmt pp.PP) (*config.Config, api.Handle, s
}

// Get the setter
s, ok := setter.New(ppfmt, h, c.TTL, c.Proxied)
s, ok := setter.New(ppfmt, h, c.TTL)
if !ok {
bye()
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/priviledges.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ func printCapabilities(ppfmt pp.PP) {

// printPriviledges prints out all remaining privileges.
func printPriviledges(ppfmt pp.PP) {
ppfmt.Noticef(pp.EmojiPriviledges, "Priviledges after dropping:")
ppfmt.Noticef(pp.EmojiPriviledges, "Remaining priviledges:")
inner := ppfmt.IncIndent()

inner.Noticef(pp.EmojiBullet, "Effective UID: %d", syscall.Geteuid())
Expand Down
183 changes: 136 additions & 47 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ type Config struct {
DeleteOnStop bool
CacheExpiration time.Duration
TTL api.TTL
Proxied bool
DefaultProxied bool
ProxiedByDomain map[api.Domain]bool
DetectionTimeout time.Duration
UpdateTimeout time.Duration
Monitors []monitor.Monitor
Expand All @@ -45,7 +46,8 @@ func Default() *Config {
DeleteOnStop: false,
CacheExpiration: time.Hour * 6, //nolint:gomnd
TTL: api.TTL(1),
Proxied: false,
DefaultProxied: false,
ProxiedByDomain: map[api.Domain]bool{},
UpdateTimeout: time.Second * 30, //nolint:gomnd
DetectionTimeout: time.Second * 5, //nolint:gomnd
Monitors: nil,
Expand Down Expand Up @@ -102,27 +104,27 @@ func ReadAuth(ppfmt pp.PP, field *api.Auth) bool {

// deduplicate always sorts and deduplicates the input list,
// returning true if elements are already distinct.
func deduplicate(list *[]api.Domain) {
api.SortDomains(*list)
func deduplicate(list []api.Domain) []api.Domain {
api.SortDomains(list)

if len(*list) == 0 {
return
if len(list) == 0 {
return list
}

j := 0
for i := range *list {
if i == 0 || (*list)[j] == (*list)[i] {
for i := range list {
if i == 0 || list[j] == list[i] {
continue
}
j++
(*list)[j] = (*list)[i]
list[j] = list[i]
}

if len(*list) == j+1 {
return
if len(list) == j+1 {
return list
}

*list = (*list)[:j+1]
return list[:j+1]
}

func ReadDomainMap(ppfmt pp.PP, field *map[ipnet.Type][]api.Domain) bool {
Expand All @@ -134,11 +136,8 @@ func ReadDomainMap(ppfmt pp.PP, field *map[ipnet.Type][]api.Domain) bool {
return false
}

ip4Domains = append(ip4Domains, domains...)
ip6Domains = append(ip6Domains, domains...)

deduplicate(&ip4Domains)
deduplicate(&ip6Domains)
ip4Domains = deduplicate(append(ip4Domains, domains...))
ip6Domains = deduplicate(append(ip6Domains, domains...))

*field = map[ipnet.Type][]api.Domain{
ipnet.IP4: ip4Domains,
Expand All @@ -164,7 +163,53 @@ func ReadProviderMap(ppfmt pp.PP, field *map[ipnet.Type]provider.Provider) bool
return true
}

func ReadProxiedByDomain(ppfmt pp.PP, field *map[api.Domain]bool) bool {
var proxiedDomains, nonProxiedDomains []api.Domain

if !ReadDomains(ppfmt, "PROXIED_DOMAINS", &proxiedDomains) ||
!ReadDomains(ppfmt, "NON_PROXIED_DOMAINS", &nonProxiedDomains) {
return false
}

proxiedDomains = deduplicate(proxiedDomains)
nonProxiedDomains = deduplicate(nonProxiedDomains)

if len(proxiedDomains) > 0 || len(nonProxiedDomains) > 0 {
ppfmt.Warningf(pp.EmojiExperimental, "PROXIED_DOMAINS and NON_PROXIED_DOMAINS are experimental and subject to changes") //nolint:lll
ppfmt.Warningf(pp.EmojiExperimental, "Please share your usage at https://github.com/favonia/cloudflare-ddns/issues/199") //nolint:lll
ppfmt.Warningf(pp.EmojiExperimental, "We might redesign or remove this feature based on your (lack of) feedback") //nolint:lll
}

// the new map to be created
m := map[api.Domain]bool{}

// all proxied domains
for _, proxiedDomain := range proxiedDomains {
m[proxiedDomain] = true
}

// non-proxied domains
for _, nonProxiedDomain := range nonProxiedDomains {
if proxied, ok := m[nonProxiedDomain]; ok && proxied {
ppfmt.Errorf(pp.EmojiUserError,
"Domain %q appeared in both PROXIED_DOMAINS and NON_PROXIED_DOMAINS",
nonProxiedDomain.Describe())
return false
}

m[nonProxiedDomain] = false
}

*field = m

return true
}

func describeDomains(domains []api.Domain) string {
if len(domains) == 0 {
return "(none)"
}

descriptions := make([]string, 0, len(domains))
for _, domain := range domains {
descriptions = append(descriptions, domain.Describe())
Expand Down Expand Up @@ -201,7 +246,19 @@ func (c *Config) Print(ppfmt pp.PP) {

ppfmt.Infof(pp.EmojiConfig, "New DNS records:")
inner.Infof(pp.EmojiBullet, "TTL: %s", c.TTL.Describe())
inner.Infof(pp.EmojiBullet, "Proxied: %t", c.Proxied)
{
proxiedMapping := map[bool][]api.Domain{}
proxiedMapping[true] = make([]api.Domain, 0, len(c.ProxiedByDomain))
proxiedMapping[false] = make([]api.Domain, 0, len(c.ProxiedByDomain))
for domain, proxied := range c.ProxiedByDomain {
proxiedMapping[proxied] = append(proxiedMapping[proxied], domain)
}
for b := range proxiedMapping {
proxiedMapping[b] = deduplicate(proxiedMapping[b])
}
inner.Infof(pp.EmojiBullet, "Proxied: %s", describeDomains(proxiedMapping[true]))
inner.Infof(pp.EmojiBullet, "Non-proxied: %s", describeDomains(proxiedMapping[false]))
}

ppfmt.Infof(pp.EmojiConfig, "Timeouts:")
inner.Infof(pp.EmojiBullet, "IP detection: %v", c.DetectionTimeout)
Expand All @@ -217,7 +274,7 @@ func (c *Config) Print(ppfmt pp.PP) {

func (c *Config) ReadEnv(ppfmt pp.PP) bool { //nolint:cyclop
if ppfmt.IsEnabledFor(pp.Info) {
ppfmt.Noticef(pp.EmojiEnvVars, "Reading settings . . .")
ppfmt.Infof(pp.EmojiEnvVars, "Reading settings . . .")
ppfmt = ppfmt.IncIndent()
}

Expand All @@ -229,43 +286,24 @@ func (c *Config) ReadEnv(ppfmt pp.PP) bool { //nolint:cyclop
!ReadBool(ppfmt, "DELETE_ON_STOP", &c.DeleteOnStop) ||
!ReadNonnegDuration(ppfmt, "CACHE_EXPIRATION", &c.CacheExpiration) ||
!ReadNonnegInt(ppfmt, "TTL", (*int)(&c.TTL)) ||
!ReadBool(ppfmt, "PROXIED", &c.Proxied) ||
!ReadBool(ppfmt, "PROXIED", &c.DefaultProxied) ||
!ReadNonnegDuration(ppfmt, "DETECTION_TIMEOUT", &c.DetectionTimeout) ||
!ReadNonnegDuration(ppfmt, "UPDATE_TIMEOUT", &c.UpdateTimeout) ||
!ReadHealthChecksURL(ppfmt, "HEALTHCHECKS", &c.Monitors) {
!ReadHealthChecksURL(ppfmt, "HEALTHCHECKS", &c.Monitors) ||
!ReadProxiedByDomain(ppfmt, &c.ProxiedByDomain) {
return false
}

return true
}

func (c *Config) checkUselessDomains(ppfmt pp.PP) {
count := map[api.Domain]int{}
for _, domains := range c.Domains {
for _, domain := range domains {
count[domain]++
}
}

for ipNet, domains := range c.Domains {
if c.Provider[ipNet] == nil {
for _, domain := range domains {
// there are only two possible values here:
// count[domain] = 0, len(c.Domains) = 0: impossible---NormalizeDomains would have erred
// count[domain] = 1, len(c.Domains) = 1: impossible---NormalizeDomains would have erred
// count[domain] = 1, len(c.Domains) = 2: the warning is displayed
// count[domain] = 2, len(c.Domains) = 2: the other IPv4/6 still works for this domain
if count[domain] == 1 {
ppfmt.Warningf(pp.EmojiUserWarning,
"Domain %q is ignored because it is only for %s but %s is disabled",
domain.Describe(), ipNet.Describe(), ipNet.Describe())
}
}
}
//nolint:funlen,gocognit,cyclop
func (c *Config) NormalizeDomains(ppfmt pp.PP) bool {
if ppfmt.IsEnabledFor(pp.Info) {
ppfmt.Infof(pp.EmojiEnvVars, "Checking settings . . .")
ppfmt = ppfmt.IncIndent()
}
}

func (c *Config) NormalizeDomains(ppfmt pp.PP) bool {
if len(c.Domains[ipnet.IP4]) == 0 && len(c.Domains[ipnet.IP6]) == 0 {
ppfmt.Errorf(pp.EmojiUserError, "No domains were specified")
return false
Expand All @@ -280,12 +318,63 @@ func (c *Config) NormalizeDomains(ppfmt pp.PP) bool {
}
}

// check if all policies are none
if c.Provider[ipnet.IP4] == nil && c.Provider[ipnet.IP6] == nil {
ppfmt.Errorf(pp.EmojiUserError, "Both IPv4 and IPv6 are disabled")
return false
}

c.checkUselessDomains(ppfmt)
// domainSet is the set of managed domains.
domainSet := map[api.Domain]bool{}
for ipNet, domains := range c.Domains {
if c.Provider[ipNet] != nil {
for _, domain := range domains {
domainSet[domain] = true
}
}
}

// check if some domains are unused
for ipNet, domains := range c.Domains {
if c.Provider[ipNet] == nil {
for _, domain := range domains {
if !domainSet[domain] {
ppfmt.Warningf(pp.EmojiUserWarning,
"Domain %q is ignored because it is only for %s but %s is disabled",
domain.Describe(), ipNet.Describe(), ipNet.Describe())
}
}
}
}

// fill in the default "proxied"
if c.ProxiedByDomain == nil {
c.ProxiedByDomain = map[api.Domain]bool{}
ppfmt.Warningf(pp.EmojiImpossible,
"Internal failure: ProxiedByDomain is re-initialized because it was nil",
)
ppfmt.Warningf(pp.EmojiImpossible,
"Please report the bug at https://github.com/favonia/cloudflare-ddns/issues/new",
)
}
for domain := range domainSet {
if _, ok := c.ProxiedByDomain[domain]; !ok {
c.ProxiedByDomain[domain] = c.DefaultProxied
}
}

// check if some domain-specific "proxied" setting is not used
envMap := map[bool]string{true: "PROXIED_DOMAINS", false: "NON_PROXIED_DOMAINS"}
if len(c.ProxiedByDomain) > len(domainSet) {
for domain, proxied := range c.ProxiedByDomain {
if !domainSet[domain] {
delete(c.ProxiedByDomain, domain)
ppfmt.Warningf(pp.EmojiUserWarning,
"Domain %q was listed in %s, but it is ignored because it is not managed by the updater",
domain.Describe(), envMap[proxied])
}
}
}

return true
}