Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ Docker images are published as `timothyjmiller/cloudflare-ddns:<version>`

## Unreleased

### New features

- **Interface-bound providers (#248).** `cloudflare.trace`, `cloudflare.doh`,
`ipify` and `url:` accept an `@<interface>` suffix
(`IP4_PROVIDER=cloudflare.trace@ens5`) to send detection through a specific
network interface.
- **Detection groups (#248).** `DOMAINS_N`, `IP4_DOMAINS_N`, `IP6_DOMAINS_N`,
`IP4_PROVIDER_N` and `IP6_PROVIDER_N` (N ≥ 2) give groups of domains their
own providers. See [docs/multi-interface.md](docs/multi-interface.md).

### Behavior changes

- **Graceful shutdown on SIGTERM as well as SIGINT.** `docker stop`,
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A dynamic DNS client for Cloudflare, written in Rust: a ~1.1 MB static Docker im
## ✨ Features

- **IPv4 and IPv6** with independent detection providers: Cloudflare trace (default), Cloudflare DNS-over-HTTPS, ipify, local routing table or interface, custom URL, or static IPs
- **Multi-homed hosts**: bind detection to a network interface (`ipify@eth1`) and give groups of domains their own providers
- **Many domains across many zones**, including wildcards (`*.example.com`); zones are found automatically
- **Outage-safe**: a failed IP lookup never deletes or overwrites your records, and Cloudflare's own anycast IPs are rejected
- **WAF IP lists** kept in sync with your addresses
Expand Down Expand Up @@ -154,8 +155,23 @@ Use fully-qualified names (`example.com`, `home.example.com`, `*.example.com`).
| `literal:<ips>` | Fixed addresses, separated by commas or spaces, e.g. `literal:203.0.113.10` |
| `none` | Don't manage this address family |

Add `@<interface>` to `cloudflare.trace`, `cloudflare.doh`, `ipify` or `url:` to send the detection request through a specific network interface, e.g. `IP4_PROVIDER=cloudflare.trace@ens5`.

Detected addresses must be global unicast: private, CGNAT (`100.64.0.0/10`), loopback and link-local addresses are ignored. An empty `IP4_PROVIDER` / `IP6_PROVIDER` counts as unset (the default applies), so use `none` to disable a family.

### Multiple interfaces and detection groups

On multi-homed hosts (cloud instances with several network interfaces, dual-WAN routers), numbered variables define extra detection groups, each with its own domains and providers:

```ini
DOMAINS=a.example.com # default group
DOMAINS_2=b.example.com # group 2
IP4_PROVIDER_2=cloudflare.trace@ens5 # IPv4 detected through ens5
IP6_PROVIDER_2=local.iface:ens5 # IPv6 read from ens5
```

Groups start at `_2`, and a family without its own provider uses the default group's. See [docs/multi-interface.md](docs/multi-interface.md) for the full rules and platform notes.

### Scheduling

| Variable | Default | Description |
Expand Down Expand Up @@ -280,6 +296,7 @@ A heartbeat is sent at startup, after every update cycle (success or fail), and
| `IP6_DOMAINS` | — | Domains for AAAA records only |
| `IP4_PROVIDER` | `cloudflare.trace` | IPv4 detection provider |
| `IP6_PROVIDER` | `cloudflare.trace` | IPv6 detection provider |
| `DOMAINS_N`, `IP4_DOMAINS_N`, `IP6_DOMAINS_N`, `IP4_PROVIDER_N`, `IP6_PROVIDER_N` | — | Detection group N (N ≥ 2); see [multiple interfaces](#multiple-interfaces-and-detection-groups) |
| `REJECT_CLOUDFLARE_IPS` | `true` | Discard detected Cloudflare IPs |
| `UPDATE_CRON` | `@every 5m` | `@every <duration>` (min 30s) or `@once` |
| `UPDATE_ON_START` | `true` | Update immediately at startup |
Expand Down
105 changes: 105 additions & 0 deletions docs/multi-interface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Multiple interfaces and detection groups

By default every domain shares one detected IPv4 and one detected IPv6
address, and IP detection goes out through the default route. On multi-homed
hosts, such as Oracle Cloud instances with several VNICs or dual-WAN routers,
two features let each domain follow a different interface:

- **Interface-bound providers** send the detection request out through a
specific interface.
- **Detection groups** give different domains their own providers.

They work independently and can be combined.

## Interface-bound providers

Add `@<interface>` to any HTTP-based provider: `cloudflare.trace`,
`cloudflare.doh`, `ipify` or `url:`.

```ini
IP4_PROVIDER=cloudflare.trace@ens5
IP6_PROVIDER=ipify@eth1
IP4_PROVIDER=url:https://ip.example.com@wg0
```

The request leaves through that interface, so the endpoint sees, and reports
back, the public address of that interface. This works even when the
interface itself only holds a private, NATed address.

| Platform | Mechanism |
|---|---|
| Linux, Android | `SO_BINDTODEVICE` |
| macOS, iOS, Solaris, illumos | `IP_BOUND_IF` / `IPV6_BOUND_IF` |
| Others (e.g. Windows) | Binds the interface's address as the source address. This only works with source-based routing. |

Notes:

- The binding picks the interface used for the detection request; it does
not configure routing. A secondary interface still needs policy routing so
that replies come back through it.
- On Linux kernels older than 5.7, `SO_BINDTODEVICE` needs `CAP_NET_RAW`.
Run the container with `--cap-add NET_RAW` there; root containers are
unaffected.
- The interface name must contain only letters, digits, `.`, `_` and `-`.
The suffix is split on the last `@`, so a `url:` provider with credentials
in it (`user:pass@host`) is not supported.
- Local providers already read a specific interface; use
`local.iface:<name>` or `local.iface.stable:<name>` for those, not `@`.
- If the interface doesn't exist, detection fails for that cycle and existing
records are left unchanged.

## Detection groups

The unsuffixed variables (`DOMAINS`, `IP4_PROVIDER`, …) make up the default
group. Adding a numeric suffix, starting at `_2`, defines another group:

| Variable | Meaning |
|---|---|
| `DOMAINS_N` | Domains updated with both the IPv4 and IPv6 of group N |
| `IP4_DOMAINS_N` / `IP6_DOMAINS_N` | Domains updated with only one family |
| `IP4_PROVIDER_N` / `IP6_PROVIDER_N` | Providers for group N |

Rules:

- A group with no provider for a family uses the default group's provider
for that family. Set it to `none` to disable the family in that group.
- A group with providers but no domains is ignored, and a warning is logged.
- A domain and record type can belong to only one group. Defining it in two
groups is a startup error.
- `_1` is rejected: the unsuffixed variables are the first group.
- When several groups use the same provider for the same family, detection
runs once per update cycle and the result is shared.
- WAF lists (`WAF_LISTS`) receive the addresses detected by all groups. They
are left unchanged in any cycle where a group's detection fails.
- Detection groups exist only in environment-variable mode, not in legacy
`config.json` mode.

### Example: Oracle Cloud with a secondary VNIC

The secondary VNIC's IPv4 is NATed, so it has to be detected through the
interface. Its IPv6 is set directly on the interface and can be read locally.

```ini
# Default group: primary interface, unchanged
DOMAINS=a.example.com
IP4_PROVIDER=cloudflare.trace

# Group 2: secondary VNIC
DOMAINS_2=b.example.com
IP4_PROVIDER_2=cloudflare.trace@ens5
IP6_PROVIDER_2=local.iface:ens5
```

### Example: dual WAN

```ini
IP4_DOMAINS=wan1.example.com
IP4_PROVIDER=cloudflare.trace@eth0

IP4_DOMAINS_2=wan2.example.com
IP4_PROVIDER_2=cloudflare.trace@eth1
```

Each WAN keeps its own A record up to date. Putting both addresses on one
name (round-robin) is not supported yet, because a record can belong to only
one group. Automatic failover is not part of this feature either.
Loading
Loading